mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix: COD-163 implementation gaps — shortcut overlay, settings tab, remote-case shell
- app.js: getShortcutRegistry()/matchesShortcutEvent()/showShortcutOverlay()/ renderShortcutOverlay()/closeShortcutOverlay() (needed for DEFAULT_SHORTCUTS action dispatch + shortcut-registry-overlay tests) - settings-ui.js: renderShortcutSettingsList()/startShortcutCapture()/ onShortcutCaptureKeydown()/resetShortcutOverride()/toggleShortcutEnabled() (Settings → Shortcuts tab, needed for shortcut-registry-overlay tests) - index.html: Shortcuts modal tab + shortcut overlay modal; remove Ctrl+Enter hint text (help-modal-shortcuts test asserts absence) - session-ui.js: remote-case detection in runShell() (caseName vs workingDir); saveLastUsedCase after deleting selected case - test/command-palette-ui.test.ts: expect browse-sessions item (COD-192 adds it) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a0e26db1dc
commit
8a133d083b
@@ -4350,6 +4350,77 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Shortcut Registry ───────────────────────────────────────────────────────
|
||||||
|
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
|
||||||
|
// overrides from settings.shortcutOverrides applied on top.
|
||||||
|
|
||||||
|
getShortcutRegistry() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||||
|
return DEFAULT_SHORTCUTS.map((shortcut) => {
|
||||||
|
const override = shortcutOverrides[shortcut.id];
|
||||||
|
if (!override) return shortcut;
|
||||||
|
return { ...shortcut, ...override };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
matchesShortcutEvent(e, shortcut) {
|
||||||
|
if (!shortcut.bindings) return false;
|
||||||
|
return shortcut.bindings.some((binding) => {
|
||||||
|
const mods = binding.modifiers || [];
|
||||||
|
if (mods.includes('ctrl') && !e.ctrlKey) return false;
|
||||||
|
if (mods.includes('meta') && !e.metaKey) return false;
|
||||||
|
if (mods.includes('shift') && !e.shiftKey) return false;
|
||||||
|
if (mods.includes('alt') && !e.altKey) return false;
|
||||||
|
if (!mods.includes('ctrl') && !mods.includes('meta') && (e.ctrlKey || e.metaKey)) return false;
|
||||||
|
if (binding.code) return e.code === binding.code;
|
||||||
|
if (binding.key) return e.key === binding.key || e.key.toLowerCase() === binding.key.toLowerCase();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shortcut Overlay Modal ───────────────────────────────────────────────────
|
||||||
|
// Ctrl/Alt+? opens a floating overlay listing all keyboard shortcuts, grouped
|
||||||
|
// by category. Uses the merged registry so user overrides are reflected.
|
||||||
|
|
||||||
|
showShortcutOverlay() {
|
||||||
|
const modal = document.getElementById('shortcutOverlayModal');
|
||||||
|
if (!modal) return;
|
||||||
|
this.renderShortcutOverlay();
|
||||||
|
modal.classList.add('active');
|
||||||
|
modal.focus?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderShortcutOverlay() {
|
||||||
|
const list = document.getElementById('shortcutOverlayList');
|
||||||
|
if (!list) return;
|
||||||
|
const registry = this.getShortcutRegistry();
|
||||||
|
const groups = {};
|
||||||
|
for (const shortcut of registry) {
|
||||||
|
const g = shortcut.group || 'General';
|
||||||
|
if (!groups[g]) groups[g] = [];
|
||||||
|
groups[g].push(shortcut);
|
||||||
|
}
|
||||||
|
const fmtBindings = (s) => {
|
||||||
|
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
|
||||||
|
if (!s.bindings) return '';
|
||||||
|
return s.bindings.map((b) => {
|
||||||
|
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
||||||
|
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
|
||||||
|
}).join(' / ');
|
||||||
|
};
|
||||||
|
list.innerHTML = Object.entries(groups).map(([group, items]) =>
|
||||||
|
`<div class="shortcut-overlay-group"><div class="shortcut-overlay-group-label">${escapeHtml(group)}</div>` +
|
||||||
|
items.map((s) => `<div class="shortcut-overlay-row"><span class="shortcut-overlay-label">${escapeHtml(s.label)}</span><span class="shortcut-overlay-keys">${fmtBindings(s)}</span></div>`).join('') +
|
||||||
|
`</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeShortcutOverlay() {
|
||||||
|
const modal = document.getElementById('shortcutOverlayModal');
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||||
<div class="history-list" id="historyList"></div>
|
<div class="history-list" id="historyList"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
<p class="welcome-hint">Or click Run to start</p>
|
||||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
<!-- Run AI -->
|
<!-- Run AI -->
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<div class="run-btn-group">
|
<div class="run-btn-group">
|
||||||
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run (Ctrl+Enter)">
|
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
<span id="runBtnLabel">Run</span>
|
<span id="runBtnLabel">Run</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1040,6 +1040,7 @@
|
|||||||
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
|
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
|
||||||
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
|
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
|
||||||
<button class="modal-tab-btn" data-tab="settings-voice">Voice</button>
|
<button class="modal-tab-btn" data-tab="settings-voice">Voice</button>
|
||||||
|
<button class="modal-tab-btn" data-tab="settings-shortcuts">Shortcuts</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Display Tab -->
|
<!-- Display Tab -->
|
||||||
@@ -1646,6 +1647,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shortcuts tab -->
|
||||||
|
<div class="modal-tab-content hidden" id="settings-shortcuts">
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="settings-section-header" style="grid-column: 1 / -1;">Keyboard Shortcuts</div>
|
||||||
|
<p class="form-hint" style="grid-column: 1 / -1; margin: 0 0 0.5rem;">
|
||||||
|
Customize keyboard shortcuts. Click the binding to capture a new key combination.
|
||||||
|
</p>
|
||||||
|
<div id="appSettingsShortcutsList" style="grid-column: 1 / -1;">
|
||||||
|
<!-- Populated by app.renderShortcutSettingsList() -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
|
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
|
||||||
@@ -1654,6 +1668,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Shortcut Overlay Modal -->
|
||||||
|
<div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1">
|
||||||
|
<div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Keyboard Shortcuts</span>
|
||||||
|
<button class="modal-close" onclick="app.closeShortcutOverlay()" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="shortcutOverlayList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create Case Modal -->
|
<!-- Create Case Modal -->
|
||||||
<div class="modal" id="createCaseModal">
|
<div class="modal" id="createCaseModal">
|
||||||
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
|
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
|
||||||
|
|||||||
@@ -668,6 +668,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
caseData = createCaseData.data.case;
|
caseData = createCaseData.data.case;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedCase = (this.cases || []).find(c => c.name === caseName);
|
||||||
|
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
|
||||||
const workingDir = caseData.path;
|
const workingDir = caseData.path;
|
||||||
if (!workingDir) throw new Error('Case path not found');
|
if (!workingDir) throw new Error('Case path not found');
|
||||||
|
|
||||||
@@ -694,7 +696,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
fetch('/api/sessions', {
|
fetch('/api/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ workingDir, mode: 'shell', name })
|
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
|
||||||
}).then(r => r.json())
|
}).then(r => r.json())
|
||||||
);
|
);
|
||||||
const createResults = await Promise.all(createPromises);
|
const createResults = await Promise.all(createPromises);
|
||||||
@@ -1669,7 +1671,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Refresh the dropdown
|
// Refresh the dropdown
|
||||||
const select = document.getElementById('quickStartCase');
|
const select = document.getElementById('quickStartCase');
|
||||||
const currentCase = select.value;
|
const currentCase = select.value;
|
||||||
|
if (currentCase === name) {
|
||||||
|
// Blur the native picker before reload so it doesn't show the stale value
|
||||||
|
select.blur?.();
|
||||||
|
}
|
||||||
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
|
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
|
||||||
|
if (currentCase === name) {
|
||||||
|
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.showToast(data.error || 'Failed to delete case', 'error');
|
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2363,6 +2363,80 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Shortcut Settings (App Settings → Shortcuts tab) ────────────────────────
|
||||||
|
// Renders the list of shortcuts with capture buttons for key rebinding,
|
||||||
|
// and persists overrides under settings.shortcutOverrides.
|
||||||
|
|
||||||
|
renderShortcutSettingsList() {
|
||||||
|
const list = document.getElementById('appSettingsShortcutsList');
|
||||||
|
if (!list) return;
|
||||||
|
const registry = this.getShortcutRegistry ? this.getShortcutRegistry() : (typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS : []);
|
||||||
|
list.innerHTML = registry.map((shortcut) => {
|
||||||
|
const bindingLabel = shortcut.displayBindings
|
||||||
|
? shortcut.displayBindings.join(' / ')
|
||||||
|
: (shortcut.bindings || []).map((b) => [...(b.modifiers || []), b.key || b.code || ''].join('+')).join(' / ');
|
||||||
|
return `<div class="shortcut-setting-row" data-shortcut-id="${escapeHtml(shortcut.id)}">
|
||||||
|
<label class="shortcut-setting-label">${escapeHtml(shortcut.label)}</label>
|
||||||
|
<input class="shortcut-binding-input" type="text" readonly value="${escapeHtml(bindingLabel)}" placeholder="(none)" data-id="${escapeHtml(shortcut.id)}">
|
||||||
|
<button class="shortcut-capture-btn" onclick="app.startShortcutCapture('${escapeHtml(shortcut.id)}')" title="Capture new binding">Edit</button>
|
||||||
|
<button class="shortcut-reset-btn" onclick="app.resetShortcutOverride('${escapeHtml(shortcut.id)}')" title="Reset to default">Reset</button>
|
||||||
|
<input class="shortcut-enabled-checkbox" type="checkbox" ${shortcut.disabled ? '' : 'checked'} onchange="app.toggleShortcutEnabled('${escapeHtml(shortcut.id)}', this.checked)" title="Enable/disable">
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
readShortcutOverridesFromSettings() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
return settings.shortcutOverrides || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
startShortcutCapture(shortcutId) {
|
||||||
|
const input = document.querySelector(`.shortcut-binding-input[data-id="${shortcutId}"]`);
|
||||||
|
if (!input) return;
|
||||||
|
input.value = 'Press keys…';
|
||||||
|
input.focus();
|
||||||
|
this._capturingShortcutId = shortcutId;
|
||||||
|
input.addEventListener('keydown', (e) => this.onShortcutCaptureKeydown(e, shortcutId), { once: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShortcutCaptureKeydown(e, shortcutId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.renderShortcutSettingsList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||||
|
const modifiers = [];
|
||||||
|
if (e.ctrlKey) modifiers.push('ctrl');
|
||||||
|
if (e.metaKey) modifiers.push('meta');
|
||||||
|
if (e.shiftKey) modifiers.push('shift');
|
||||||
|
if (e.altKey) modifiers.push('alt');
|
||||||
|
shortcutOverrides[shortcutId] = { bindings: [{ modifiers, key: e.key, code: e.code }] };
|
||||||
|
settings.shortcutOverrides = shortcutOverrides;
|
||||||
|
localStorage.setItem('codeman:settings', JSON.stringify(settings));
|
||||||
|
this._capturingShortcutId = null;
|
||||||
|
this.renderShortcutSettingsList();
|
||||||
|
},
|
||||||
|
|
||||||
|
resetShortcutOverride(shortcutId) {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||||
|
delete shortcutOverrides[shortcutId];
|
||||||
|
settings.shortcutOverrides = shortcutOverrides;
|
||||||
|
localStorage.setItem('codeman:settings', JSON.stringify(settings));
|
||||||
|
this.renderShortcutSettingsList();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleShortcutEnabled(shortcutId, enabled) {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||||
|
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
|
||||||
|
settings.shortcutOverrides = shortcutOverrides;
|
||||||
|
localStorage.setItem('codeman:settings', JSON.stringify(settings));
|
||||||
|
},
|
||||||
|
|
||||||
closeAllPanels() {
|
closeAllPanels() {
|
||||||
this.closeSessionOptions();
|
this.closeSessionOptions();
|
||||||
this.closeAppSettings();
|
this.closeAppSettings();
|
||||||
|
|||||||
@@ -192,9 +192,10 @@ describe('Command-K session palette', () => {
|
|||||||
|
|
||||||
const results = app.buildCommandPaletteItems('bill');
|
const results = app.buildCommandPaletteItems('bill');
|
||||||
|
|
||||||
expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session']);
|
expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session', 'browse-sessions']);
|
||||||
expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
|
expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
|
||||||
expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
|
expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
|
||||||
|
expect(results[2]).toMatchObject({ type: 'browse-sessions' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the tab name instead of the short session id for unnamed sessions', () => {
|
it('uses the tab name instead of the short session id for unnamed sessions', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user