mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
fix(review): wire Session Manager to /api/sessions/unified contract, stop Ctrl+K PTY leak, finish shortcut registry (PR #146)
- Session Manager (COD-121/192): align _loadSessionManagerList() with the merged #139 endpoint — map UnifiedSessionItem fields (lastActivityAt epoch-ms → lastModified, optional sizeBytes/firstPrompt/name) to the history-record shape _buildHistoryItem renders; surface non-2xx / error-envelope responses as a visible message instead of a silent "No sessions found"; route clicks by liveness (live row → selectSession, history row → resumeHistorySession by conversation UUID) via a new onActivate option so a live session is never duplicate-resumed - Ctrl+K double-dispatch: gate the palette chord in attachCustomKeyEventHandler (return false on keydown) so xterm never writes 0x0b kill-line into the PTY while the palette opens; gate is registry-aware so a rebound/disabled palette shortcut restores normal terminal Ctrl+K - Shortcut registry (COD-157) finished per maintainer decision: document keydown now dispatches through getShortcutRegistry() + matchesShortcutEvent() (legacy SHORTCUTS table removed), honoring per-shortcut disable and rebinds incl. the palette chord; overrides persist via saveAppSettingsToStorage() (correct device key + cache coherence, was orphaned 'codeman:settings'); Shortcuts tab renders on open via switchSettingsTab hook; capture uses a persistent listener that ignores bare modifier keydowns (combos now capturable) and requires a Ctrl/Cmd/Alt chord; settings rows use delegated listeners instead of inline onclick (JS-string injection sink) and overrides can no longer clobber id/label/action; added the missing row + overlay CSS - matchesShortcutEvent: reject undeclared extra modifiers (Ctrl+Shift+K no longer hijacked from Firefox devtools) while keeping Ctrl/Cmd interchangeable; match physical code OR produced key for layout parity - Registry/dispatch gaps: added restore-terminal-size entry, documented Ctrl+Shift+R again in the help modal (test flipped to assert presence), Ctrl+?/Alt+? now really open the registry-driven shortcut overlay, and Escape closes it - Palette new-session pick routes through selectQuickStartCase() so the searchable combobox, dir display, and lastUsedCase stay in sync - Removed fork cherry-pick debris: dead _onSessionListMaybeChanged(), orphaned .session-row-menu CSS, nonexistent closeMobileHeaderUtilities calls - Tests: functional vm-harness coverage for the unified-list field mapping + error state + liveness routing, palette chord shift/disable/ rebind handling, override persistence round-trip, capture flow, tab render hook, and source guards for the PTY gate + registry dispatch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+60
-31
@@ -288,6 +288,7 @@ const DEFAULT_SHORTCUTS = [
|
||||
label: 'Show Shortcuts',
|
||||
bindings: [
|
||||
{ modifiers: ['ctrl'], key: '?', code: 'Slash' },
|
||||
{ modifiers: ['ctrl', 'shift'], key: '?' },
|
||||
{ modifiers: ['alt'], key: '?', code: 'Slash' },
|
||||
],
|
||||
action: 'showShortcutOverlay',
|
||||
@@ -337,6 +338,13 @@ const DEFAULT_SHORTCUTS = [
|
||||
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'V' }],
|
||||
action: 'toggleVoiceInput',
|
||||
},
|
||||
{
|
||||
id: 'restore-terminal-size',
|
||||
group: 'Terminal',
|
||||
label: 'Restore Terminal Size',
|
||||
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'R' }],
|
||||
action: 'restoreTerminalSize',
|
||||
},
|
||||
{
|
||||
id: 'move-tab-left',
|
||||
group: 'Tabs',
|
||||
@@ -903,21 +911,23 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
setupEventListeners() {
|
||||
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
|
||||
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
|
||||
// shift? (require Shift), action }.
|
||||
const SHORTCUTS = [
|
||||
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
|
||||
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
|
||||
];
|
||||
// Action name → handler map for the shortcut registry (DEFAULT_SHORTCUTS +
|
||||
// user overrides from settings.shortcutOverrides, merged by
|
||||
// getShortcutRegistry()). The command palette chord is deliberately NOT in
|
||||
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
|
||||
// focus-target awareness (it must fire from the terminal but not from inputs).
|
||||
const SHORTCUT_ACTIONS = {
|
||||
showShortcutOverlay: () => this.showShortcutOverlay(),
|
||||
killActiveSession: () => this.killActiveSession(),
|
||||
nextSession: () => this.nextSession(),
|
||||
clearTerminal: () => this.clearTerminal(),
|
||||
restoreTerminalSize: () => this.restoreTerminalSize(),
|
||||
increaseFontSize: () => this.increaseFontSize(),
|
||||
decreaseFontSize: () => this.decreaseFontSize(),
|
||||
toggleVoiceInput: () => VoiceInput.toggle(),
|
||||
moveActiveTabLeft: () => this.moveActiveTabLeft(),
|
||||
moveActiveTabRight: () => this.moveActiveTabRight(),
|
||||
};
|
||||
|
||||
// Use capture to handle before terminal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
@@ -937,6 +947,7 @@ class CodemanApp {
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
this.closeShortcutOverlay?.();
|
||||
}
|
||||
|
||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||
@@ -966,14 +977,18 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Match against shortcut table
|
||||
for (const s of SHORTCUTS) {
|
||||
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
|
||||
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
|
||||
if (keyMatch && ctrlMatch && shiftMatch) {
|
||||
// Match against the shortcut registry so user rebinds and per-shortcut
|
||||
// disables (App Settings → Shortcuts) take effect. Every dispatchable
|
||||
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
|
||||
// typing exits early without touching the registry.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return;
|
||||
for (const shortcut of this.getShortcutRegistry()) {
|
||||
if (shortcut.disabled || !shortcut.action) continue;
|
||||
const action = SHORTCUT_ACTIONS[shortcut.action];
|
||||
if (!action) continue;
|
||||
if (this.matchesShortcutEvent(e, shortcut)) {
|
||||
e.preventDefault();
|
||||
s.action();
|
||||
action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -4360,21 +4375,35 @@ class CodemanApp {
|
||||
return DEFAULT_SHORTCUTS.map((shortcut) => {
|
||||
const override = shortcutOverrides[shortcut.id];
|
||||
if (!override) return shortcut;
|
||||
return { ...shortcut, ...override };
|
||||
// Only binding-shaped fields may come from storage — id/label/group/action
|
||||
// stay trusted so persisted data can never redirect a shortcut's action or
|
||||
// spoof another row in the settings/overlay renderers.
|
||||
const merged = { ...shortcut };
|
||||
if (Array.isArray(override.bindings)) {
|
||||
merged.bindings = override.bindings;
|
||||
delete merged.displayBindings; // show the override, not the stale default label
|
||||
}
|
||||
if (typeof override.disabled === 'boolean') merged.disabled = override.disabled;
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
matchesShortcutEvent(e, shortcut) {
|
||||
if (!shortcut.bindings) return false;
|
||||
if (!shortcut || !Array.isArray(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();
|
||||
// Ctrl and Cmd are interchangeable as the primary modifier (parity with
|
||||
// the legacy shortcut table), but every OTHER pressed modifier must be
|
||||
// declared by the binding — a plain Ctrl+K binding must not also swallow
|
||||
// Ctrl+Shift+K (the Firefox devtools chord).
|
||||
const wantsPrimary = mods.includes('ctrl') || mods.includes('meta');
|
||||
if (wantsPrimary !== !!(e.ctrlKey || e.metaKey)) return false;
|
||||
if (mods.includes('shift') !== !!e.shiftKey) return false;
|
||||
if (mods.includes('alt') !== !!e.altKey) return false;
|
||||
// Match the physical key when the binding pins one (layout-independent),
|
||||
// or the produced character otherwise (layout-dependent keys like '+').
|
||||
if (binding.code && e.code === binding.code) return true;
|
||||
if (binding.key && typeof e.key === 'string' && e.key.toLowerCase() === binding.key.toLowerCase()) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -597,6 +597,7 @@
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></div><div>Restore Terminal Size</div>
|
||||
<div><kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
|
||||
|
||||
+74
-44
@@ -257,9 +257,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
shouldOpenCommandPaletteFromShortcut(e) {
|
||||
if (!e) return false;
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if (key !== 'k' && e.code !== 'KeyK') return false;
|
||||
if (!(e.metaKey || e.ctrlKey || e.altKey)) return false;
|
||||
// Every palette chord requires Ctrl/Cmd/Alt (capture enforces the same for
|
||||
// rebinds), so plain typing exits before any registry work — this runs on
|
||||
// the document AND xterm keydown hot paths.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
|
||||
|
||||
// Registry-aware chord check (COD-157): honors a rebound or disabled
|
||||
// palette shortcut. Falls back to the default Ctrl/Cmd/Alt+K chord when the
|
||||
// registry isn't available (isolated test harnesses).
|
||||
const registryAvailable =
|
||||
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
|
||||
const palette = registryAvailable
|
||||
? this.getShortcutRegistry().find((s) => s.id === 'command-palette')
|
||||
: null;
|
||||
if (palette) {
|
||||
if (palette.disabled || !this.matchesShortcutEvent(e, palette)) return false;
|
||||
} else {
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if (key !== 'k' && e.code !== 'KeyK') return false;
|
||||
// Don't hijack chords with extra modifiers (Ctrl+Shift+K is the Firefox
|
||||
// devtools console; matchesShortcutEvent applies the same rule above).
|
||||
if (e.shiftKey) return false;
|
||||
}
|
||||
|
||||
const target = e.target;
|
||||
if (!target) return true;
|
||||
@@ -279,7 +298,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
if (!modal || !search) return;
|
||||
|
||||
this.closeMobileHeaderUtilities?.();
|
||||
this.commandPaletteActiveIndex = 0;
|
||||
search.value = '';
|
||||
modal.classList.add('active');
|
||||
@@ -491,7 +509,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
option.textContent = item.caseName;
|
||||
caseSelect.appendChild(option);
|
||||
}
|
||||
caseSelect.value = item.caseName;
|
||||
// selectQuickStartCase keeps the searchable combobox, dir display, and
|
||||
// persisted last-used case in sync with the palette's pick (COD-151);
|
||||
// fall back to a bare value set when the picker mixin isn't loaded.
|
||||
if (typeof this.selectQuickStartCase === 'function') {
|
||||
this.selectQuickStartCase(item.caseName);
|
||||
} else {
|
||||
caseSelect.value = item.caseName;
|
||||
}
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
@@ -499,9 +524,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Manager Modal (COD-121)
|
||||
// Persistent, header-reachable session list (GET /api/sessions/unified)
|
||||
// reachable mid-session, with a server-side search box. Reuses the
|
||||
// unit-2 history item renderer so clicking resumes/switches sessions.
|
||||
// Unified session list (GET /api/sessions/unified) reachable mid-session,
|
||||
// with a server-side search box. Reuses the history item renderer; clicking
|
||||
// a live row switches to it, a history row resumes the conversation.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async openSessionManager() {
|
||||
@@ -557,26 +582,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
/**
|
||||
* COD-121: live-refresh the unified session list when sessions change
|
||||
* (created/updated/deleted via SSE). Only touches surfaces that are currently
|
||||
* showing — the open Session Manager modal and/or the visible welcome list —
|
||||
* and is debounced so an event burst collapses into one re-fetch. The current
|
||||
* search query is preserved.
|
||||
*/
|
||||
_onSessionListMaybeChanged() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal && modal.classList.contains('active')) {
|
||||
this._debouncedCall(
|
||||
'sessionManagerRefresh',
|
||||
() => this._loadSessionManagerList(this._sessionManagerQuery || ''),
|
||||
400
|
||||
);
|
||||
}
|
||||
const welcome = document.getElementById('welcomeOverlay');
|
||||
if (welcome && welcome.classList.contains('visible')) {
|
||||
this._debouncedCall('welcomeHistoryRefresh', () => this.loadHistorySessions(), 600);
|
||||
}
|
||||
/** Replace the Session Manager list body with a single status line. */
|
||||
_setSessionManagerMessage(list, message) {
|
||||
list.replaceChildren();
|
||||
const line = document.createElement('p');
|
||||
line.className = 'empty-message';
|
||||
line.textContent = message;
|
||||
list.appendChild(line);
|
||||
},
|
||||
|
||||
async _loadSessionManagerList(q = '') {
|
||||
@@ -586,31 +598,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const sessions = data.data?.sessions || [];
|
||||
const data = await res.json().catch(() => null);
|
||||
// ApiResponse envelope: { success: true, data: { sessions, total } }.
|
||||
// Surface failures instead of rendering them as an empty result set.
|
||||
if (!res.ok || !data || data.success === false || !data.data) {
|
||||
this._setSessionManagerMessage(list, data?.error || `Failed to load sessions (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
const sessions = data.data.sessions || [];
|
||||
list.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'empty-message';
|
||||
empty.textContent = q ? 'No sessions match your search' : 'No sessions found';
|
||||
list.appendChild(empty);
|
||||
this._setSessionManagerMessage(list, q ? 'No sessions match your search' : 'No sessions found');
|
||||
return;
|
||||
}
|
||||
for (const s of sessions) {
|
||||
const item = this._buildHistoryItem(s, this.cases, { showViewAll: false });
|
||||
// COD-130: scope the modal-close to the main (resume) row only, in the
|
||||
// bubble phase. The ⋯ kebab button calls stopPropagation(), so clicking
|
||||
// it (or its menu) no longer closes the Session Manager modal.
|
||||
item.querySelector('.history-item-main')?.addEventListener('click', () => this.closeSessionManager());
|
||||
// Adapt UnifiedSessionItem (lastActivityAt epoch-ms, optional fields) to
|
||||
// the history-record shape _buildHistoryItem renders (lastModified date
|
||||
// string, sizeBytes, firstPrompt).
|
||||
const record = {
|
||||
sessionId: s.sessionId,
|
||||
workingDir: s.workingDir || '',
|
||||
sizeBytes: s.sizeBytes ?? 0,
|
||||
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
|
||||
firstPrompt: s.firstPrompt || s.name || '',
|
||||
};
|
||||
const isLive = !!this.sessions?.has?.(s.sessionId);
|
||||
const item = this._buildHistoryItem(record, this.cases, {
|
||||
showViewAll: false,
|
||||
onActivate: () => {
|
||||
this.closeSessionManager();
|
||||
if (isLive) {
|
||||
void this.selectSession(s.sessionId);
|
||||
} else if (record.workingDir) {
|
||||
// History rows are keyed by the Claude conversation UUID; resumed
|
||||
// sessions carry theirs separately as claudeSessionId.
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
|
||||
}
|
||||
},
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[_loadSessionManagerList]', err);
|
||||
list.replaceChildren();
|
||||
const errLine = document.createElement('p');
|
||||
errLine.className = 'empty-message';
|
||||
errLine.textContent = 'Failed to load sessions';
|
||||
list.appendChild(errLine);
|
||||
this._setSessionManagerMessage(list, 'Failed to load sessions');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -466,6 +466,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
content.classList.toggle('hidden', content.id !== tabName);
|
||||
});
|
||||
// The Shortcuts tab renders lazily so the list reflects the CURRENT
|
||||
// registry (defaults + overrides) every time it is opened.
|
||||
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
|
||||
},
|
||||
|
||||
closeAppSettings() {
|
||||
@@ -2365,24 +2368,61 @@ 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.
|
||||
// and persists overrides under settings.shortcutOverrides (saved through
|
||||
// saveAppSettingsToStorage so the device key + settings cache stay coherent).
|
||||
|
||||
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)}">
|
||||
const registry = this.getShortcutRegistry
|
||||
? this.getShortcutRegistry()
|
||||
: typeof DEFAULT_SHORTCUTS !== 'undefined'
|
||||
? DEFAULT_SHORTCUTS
|
||||
: [];
|
||||
const overrides = this.readShortcutOverridesFromSettings();
|
||||
list.innerHTML = registry
|
||||
.map((shortcut) => {
|
||||
const bindingLabel = shortcut.displayBindings
|
||||
? shortcut.displayBindings.join(' / ')
|
||||
: (shortcut.bindings || []).map((b) => [...(b.modifiers || []), b.key || b.code || ''].join('+')).join(' / ');
|
||||
// Only registry entries dispatched through matchesShortcutEvent() are
|
||||
// configurable; fixed keys (Escape, tab arrows, …) render read-only.
|
||||
const configurable = !!shortcut.action && Array.isArray(shortcut.bindings);
|
||||
const overridden = !!overrides[shortcut.id];
|
||||
const controls = configurable
|
||||
? `<button type="button" class="shortcut-capture-btn" data-shortcut-action="capture" title="Capture new binding">Edit</button>
|
||||
<button type="button" class="shortcut-reset-btn" data-shortcut-action="reset" title="Reset to default"${overridden ? '' : ' disabled'}>Reset</button>
|
||||
<input class="shortcut-enabled-checkbox" type="checkbox" ${shortcut.disabled ? '' : 'checked'} data-shortcut-action="toggle" title="Enable/disable">`
|
||||
: '';
|
||||
return `<div class="shortcut-setting-row${configurable ? '' : ' shortcut-setting-row--fixed'}" 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">
|
||||
${controls}
|
||||
</div>`;
|
||||
}).join('');
|
||||
})
|
||||
.join('');
|
||||
this._wireShortcutSettingsList(list);
|
||||
},
|
||||
|
||||
// Delegated handlers (no inline onclick — registry ids never land inside a
|
||||
// JS string context, and the listeners survive re-renders).
|
||||
_wireShortcutSettingsList(list) {
|
||||
if (list.dataset.shortcutListenersAdded) return;
|
||||
list.dataset.shortcutListenersAdded = 'true';
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target?.closest?.('[data-shortcut-action]');
|
||||
if (!btn) return;
|
||||
const id = btn.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
|
||||
if (!id) return;
|
||||
if (btn.dataset.shortcutAction === 'capture') this.startShortcutCapture(id);
|
||||
else if (btn.dataset.shortcutAction === 'reset') this.resetShortcutOverride(id);
|
||||
});
|
||||
list.addEventListener('change', (e) => {
|
||||
const box = e.target;
|
||||
if (!box?.matches?.('[data-shortcut-action="toggle"]')) return;
|
||||
const id = box.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
|
||||
if (id) this.toggleShortcutEnabled(id, box.checked);
|
||||
});
|
||||
},
|
||||
|
||||
readShortcutOverridesFromSettings() {
|
||||
@@ -2396,45 +2436,66 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = 'Press keys…';
|
||||
input.focus();
|
||||
this._capturingShortcutId = shortcutId;
|
||||
input.addEventListener('keydown', (e) => this.onShortcutCaptureKeydown(e, shortcutId), { once: true });
|
||||
// Persistent listener (NOT {once}) — the first keydown of a combo like
|
||||
// Ctrl+Shift+P is the modifier itself ('Control'), which must not end the
|
||||
// capture. The first non-modifier key completes it.
|
||||
const onCaptureKeydown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') return;
|
||||
input.removeEventListener('keydown', onCaptureKeydown);
|
||||
this.onShortcutCaptureKeydown(e, shortcutId);
|
||||
};
|
||||
input.addEventListener('keydown', onCaptureKeydown);
|
||||
},
|
||||
|
||||
onShortcutCaptureKeydown(e, shortcutId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._capturingShortcutId = null;
|
||||
if (e.key === 'Escape') {
|
||||
this.renderShortcutSettingsList();
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||
// Require a real chord: the dispatcher has no focus-target guard, so a
|
||||
// bare-key binding would fire while typing in any input.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
this.renderShortcutSettingsList();
|
||||
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
||||
return;
|
||||
}
|
||||
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 }] };
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
shortcutOverrides[shortcutId] = {
|
||||
...(shortcutOverrides[shortcutId] || {}),
|
||||
bindings: [{ modifiers, key: e.key, code: e.code }],
|
||||
};
|
||||
settings.shortcutOverrides = shortcutOverrides;
|
||||
localStorage.setItem('codeman:settings', JSON.stringify(settings));
|
||||
this._capturingShortcutId = null;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this.renderShortcutSettingsList();
|
||||
},
|
||||
|
||||
resetShortcutOverride(shortcutId) {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
delete shortcutOverrides[shortcutId];
|
||||
settings.shortcutOverrides = shortcutOverrides;
|
||||
localStorage.setItem('codeman:settings', JSON.stringify(settings));
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this.renderShortcutSettingsList();
|
||||
},
|
||||
|
||||
toggleShortcutEnabled(shortcutId, enabled) {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = settings.shortcutOverrides || {};
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
|
||||
settings.shortcutOverrides = shortcutOverrides;
|
||||
localStorage.setItem('codeman:settings', JSON.stringify(settings));
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this.renderShortcutSettingsList();
|
||||
},
|
||||
|
||||
closeAllPanels() {
|
||||
@@ -2442,10 +2503,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeAppSettings();
|
||||
this.cancelCloseSession();
|
||||
this.closeTokenStats();
|
||||
// Mobile header utility tray closes alongside the other overlays so Escape
|
||||
// (and any future closeAllPanels caller) dismisses it like every other panel
|
||||
// instead of leaving it pinned open with only its toggle to close it.
|
||||
this.closeMobileHeaderUtilities?.();
|
||||
document.getElementById('monitorPanel').classList.remove('open');
|
||||
// Collapse subagents panel (don't hide it permanently)
|
||||
const subagentsPanel = document.getElementById('subagentsPanel');
|
||||
|
||||
+98
-35
@@ -5704,49 +5704,112 @@ kbd {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* COD-130: per-row ⋯ kebab context menu (appended to <body>, fixed-positioned).
|
||||
z-index must clear the Session Manager modal (.modal is z-index: 1000). */
|
||||
.session-row-menu {
|
||||
position: fixed;
|
||||
z-index: 10010;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: rgba(22, 22, 28, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
/* Shortcut settings rows (App Settings → Shortcuts, COD-157) */
|
||||
.shortcut-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 190px) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.session-row-menu-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--btn-radius);
|
||||
.shortcut-setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shortcut-setting-label {
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition-smooth);
|
||||
}
|
||||
|
||||
.session-row-menu-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-row-menu-sublabel {
|
||||
color: var(--text-muted);
|
||||
.shortcut-binding-input {
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text-dim);
|
||||
font-family: monospace;
|
||||
font-size: 0.72rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shortcut-binding-input:focus {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.shortcut-capture-btn,
|
||||
.shortcut-reset-btn {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.shortcut-capture-btn:hover,
|
||||
.shortcut-reset-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shortcut-reset-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shortcut-enabled-checkbox {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shortcut overlay modal (registry-driven; opened with Ctrl+? / Alt+?) */
|
||||
.shortcut-overlay-modal .modal-content {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.shortcut-overlay-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.shortcut-overlay-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.shortcut-overlay-group-label {
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.shortcut-overlay-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.shortcut-overlay-label {
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.shortcut-overlay-keys {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
|
||||
@@ -134,6 +134,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Command palette chord (COD-153): keep it out of the PTY. The document
|
||||
// CAPTURE handler has already opened the palette by the time xterm sees
|
||||
// this keydown, but its preventDefault() does NOT stop xterm — without
|
||||
// this gate Ctrl+K would ALSO write 0x0b (readline kill-line) into the
|
||||
// live session behind the palette, truncating whatever the user had
|
||||
// typed. Route through the registry-aware checker so a rebound or
|
||||
// disabled palette shortcut restores normal terminal Ctrl+K.
|
||||
if (ev.type === 'keydown' && this.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
|
||||
// Route through our paste trap which handles both images and text.
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||
@@ -1074,6 +1085,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @param {Array} cases linked cases (for #caseName label)
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
|
||||
* @param {Function} [options.onActivate] main-row click handler override (default: resume the conversation)
|
||||
*/
|
||||
_buildHistoryItem(s, cases, options) {
|
||||
const showViewAll = options?.showViewAll !== false;
|
||||
@@ -1095,10 +1107,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir;
|
||||
|
||||
// Main row: clickable surface that triggers resume
|
||||
// Main row: clickable surface that triggers resume (or a caller-supplied
|
||||
// activation — the Session Manager switches to live sessions instead)
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'history-item-main';
|
||||
mainRow.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
mainRow.addEventListener(
|
||||
'click',
|
||||
options?.onActivate || (() => this.resumeHistorySession(s.sessionId, s.workingDir))
|
||||
);
|
||||
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
|
||||
@@ -107,7 +107,6 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
app.cases = [{ name: 'plex-previews' }, { name: 'flux-player' }, { name: 'api-tools' }];
|
||||
app.selectSession = vi.fn();
|
||||
app.run = vi.fn();
|
||||
app.closeMobileHeaderUtilities = vi.fn();
|
||||
app.getShortId = (id: string) => id.slice(0, 8);
|
||||
app.getSessionName = (session: any) =>
|
||||
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
||||
@@ -177,6 +176,37 @@ describe('Command-K session palette', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the palette chord when extra modifiers are held (Ctrl+Shift+K is the Firefox devtools console)', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
expect(
|
||||
app.shouldOpenCommandPaletteFromShortcut({ key: 'K', code: 'KeyK', ctrlKey: true, shiftKey: true, target: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a disabled or rebound palette shortcut from the registry', () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
|
||||
// Disabled entry → never opens, even for the default chord.
|
||||
app.getShortcutRegistry = () => [
|
||||
{ id: 'command-palette', disabled: true, bindings: [{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' }] },
|
||||
];
|
||||
app.matchesShortcutEvent = () => true;
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
// Rebound entry → the new chord opens, the old default no longer does.
|
||||
app.getShortcutRegistry = () => [{ id: 'command-palette', bindings: [{ modifiers: ['ctrl'], code: 'KeyP' }] }];
|
||||
app.matchesShortcutEvent = (e: any, s: any) => e.code === s.bindings[0].code;
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', code: 'KeyK', ctrlKey: true, target: null })).toBe(
|
||||
false
|
||||
);
|
||||
expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'p', code: 'KeyP', ctrlKey: true, target: null })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('opens and focuses the palette search box', () => {
|
||||
const { app, elements } = loadPaletteHarness();
|
||||
|
||||
@@ -258,6 +288,21 @@ describe('Command-K session palette', () => {
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes the new-session case pick through selectQuickStartCase when the picker mixin is loaded', async () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
app.selectQuickStartCase = vi.fn();
|
||||
|
||||
const newSession = app.buildCommandPaletteItems('flux').find((item: any) => item.type === 'new-session');
|
||||
app.commandPaletteItems = [newSession];
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
// Keeps the searchable combobox, dir display, and lastUsedCase in sync
|
||||
// instead of silently mutating the hidden native <select>.
|
||||
expect(app.selectQuickStartCase).toHaveBeenCalledWith('flux-player');
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('activates the highlighted session result', async () => {
|
||||
const { app } = loadPaletteHarness();
|
||||
app.openCommandPalette();
|
||||
@@ -296,6 +341,88 @@ describe('Command-K session palette', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Manager unified list', () => {
|
||||
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
|
||||
const { app, elements } = loadPaletteHarness({
|
||||
fetch: async (url: string) => {
|
||||
expect(url).toBe('/api/sessions/unified?limit=200&q=api');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'sess-alpha',
|
||||
name: 'Alpha API cleanup',
|
||||
workingDir: '/repo/api',
|
||||
lastActivityAt: 1751000000000,
|
||||
sources: ['live'],
|
||||
},
|
||||
{
|
||||
sessionId: 'conv-uuid-1',
|
||||
workingDir: '/repo/old',
|
||||
sizeBytes: 2048,
|
||||
firstPrompt: 'old prompt',
|
||||
lastActivityAt: 1750000000000,
|
||||
sources: ['history'],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
||||
app._buildHistoryItem = vi.fn(() => ({}));
|
||||
app.resumeHistorySession = vi.fn();
|
||||
|
||||
await app._loadSessionManagerList('api');
|
||||
|
||||
expect(app._buildHistoryItem).toHaveBeenCalledTimes(2);
|
||||
const [liveRecord, , liveOptions] = app._buildHistoryItem.mock.calls[0];
|
||||
expect(liveRecord).toMatchObject({
|
||||
sessionId: 'sess-alpha',
|
||||
workingDir: '/repo/api',
|
||||
sizeBytes: 0,
|
||||
firstPrompt: 'Alpha API cleanup',
|
||||
});
|
||||
expect(new Date(liveRecord.lastModified).getTime()).toBe(1751000000000);
|
||||
expect(liveOptions.showViewAll).toBe(false);
|
||||
|
||||
// Live row → switch to the session (resuming it would spawn a duplicate).
|
||||
liveOptions.onActivate();
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
expect(app.resumeHistorySession).not.toHaveBeenCalled();
|
||||
|
||||
// History row → resume by conversation UUID.
|
||||
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
|
||||
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
|
||||
historyOptions.onActivate();
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old');
|
||||
});
|
||||
|
||||
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
||||
const appended: any[] = [];
|
||||
const { app, elements } = loadPaletteHarness({
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ success: false, error: 'unified list unavailable', errorCode: 'OPERATION_FAILED' }),
|
||||
}),
|
||||
});
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: (el: any) => appended.push(el) };
|
||||
|
||||
await app._loadSessionManagerList('');
|
||||
|
||||
expect(appended).toHaveLength(1);
|
||||
expect(appended[0].textContent).toBe('unified list unavailable');
|
||||
expect(appended[0].textContent).not.toBe('No sessions found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel close helpers', () => {
|
||||
it('closes panels when the mobile header helper is unavailable', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
@@ -54,9 +54,10 @@ describe('help modal shortcuts', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', '-'], 'Decrease Font');
|
||||
expectShortcut(helpModal, ['Shift', 'Enter'], 'Insert Newline');
|
||||
expectShortcut(helpModal, ['Ctrl', 'Enter'], 'Insert Newline');
|
||||
// Ctrl+Shift+R (restore terminal size) is still dispatched — keep it documented.
|
||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'R'], 'Restore Terminal Size');
|
||||
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>K<\/kbd>/i);
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>Shift<\/kbd>\s*\+\s*<kbd>R<\/kbd>/i);
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>Enter<\/kbd>.*?(Run|Start)/i);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,4 +40,22 @@ describe('keyboard shortcuts', () => {
|
||||
expect(helpHtml).toContain('<kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd>');
|
||||
expect(readme).toMatch(/\| `Ctrl\/Cmd\/Option\+K`\s+\| Find open session or start a new one\s+\|/);
|
||||
});
|
||||
|
||||
it('gates the palette chord in the xterm custom key handler (no 0x0b kill-line into the PTY)', () => {
|
||||
// The document-level capture handler opens the palette, but preventDefault()
|
||||
// does NOT stop xterm from evaluating Ctrl+K into 0x0b and writing it to the
|
||||
// live PTY — terminal-ui.js must return false for the palette chord.
|
||||
expect(terminalUiSource).toMatch(/ev\.type === 'keydown' && this\.shouldOpenCommandPaletteFromShortcut\?\.\(ev\)/);
|
||||
});
|
||||
|
||||
it('dispatches document shortcuts through the shortcut registry (rebind/disable aware)', () => {
|
||||
// The legacy hardcoded SHORTCUTS table must stay gone — dispatch goes through
|
||||
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
|
||||
// disables (App Settings → Shortcuts) actually take effect.
|
||||
expect(appSource).not.toContain('const SHORTCUTS = [');
|
||||
expect(appSource).toContain('const SHORTCUT_ACTIONS = {');
|
||||
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
|
||||
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
|
||||
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appSource = readFileSync('src/web/public/app.js', 'utf8');
|
||||
const settingsSource = readFileSync('src/web/public/settings-ui.js', 'utf8');
|
||||
@@ -50,4 +52,165 @@ describe('shortcut registry and overlay', () => {
|
||||
expect(settingsSource).toContain('shortcut-reset-btn');
|
||||
expect(settingsSource).toContain('shortcut-enabled-checkbox');
|
||||
});
|
||||
|
||||
it('styles the shortcut settings rows and overlay (no unstyled tab)', () => {
|
||||
const css = readFileSync('src/web/public/styles.css', 'utf8');
|
||||
expect(css).toContain('.shortcut-setting-row {');
|
||||
expect(css).toContain('.shortcut-capture-btn,');
|
||||
expect(css).toContain('.shortcut-overlay-row {');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Functional coverage (vm-sandbox harness, mirrors run-mode-ui.test.ts) ────
|
||||
// The grep assertions above pin the wiring; these exercise the actual
|
||||
// persistence round-trip and capture flow that were broken in review.
|
||||
|
||||
function makeLocalStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadSettingsHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const localStorage = makeLocalStorage();
|
||||
const elements: Record<string, any> = {};
|
||||
const holder: { queryResult: any } = { queryResult: null };
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
|
||||
localStorage,
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
querySelector: () => holder.queryResult,
|
||||
},
|
||||
console,
|
||||
escapeHtml: (s: string) => String(s),
|
||||
});
|
||||
|
||||
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
||||
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
return { app, localStorage, elements, holder };
|
||||
}
|
||||
|
||||
describe('shortcut settings persistence and capture', () => {
|
||||
it('persists overrides under the app-settings storage key and round-trips through the cache', () => {
|
||||
const { app, localStorage } = loadSettingsHarness();
|
||||
|
||||
app.toggleShortcutEnabled('close-session', false);
|
||||
|
||||
// Written to the SAME key loadAppSettingsFromStorage() reads (NOT the
|
||||
// legacy 'codeman:settings' key), and the in-memory cache stays coherent.
|
||||
const raw = localStorage.getItem('codeman-app-settings');
|
||||
expect(raw).toBeTruthy();
|
||||
expect(JSON.parse(raw!).shortcutOverrides['close-session']).toMatchObject({ disabled: true });
|
||||
expect(localStorage.getItem('codeman:settings')).toBeNull();
|
||||
expect(app.readShortcutOverridesFromSettings()['close-session']).toMatchObject({ disabled: true });
|
||||
|
||||
app.resetShortcutOverride('close-session');
|
||||
const after = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
||||
expect(after.shortcutOverrides['close-session']).toBeUndefined();
|
||||
expect(app.readShortcutOverridesFromSettings()['close-session']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('captures multi-modifier combos: bare modifier keydowns do not end the capture', () => {
|
||||
const { app, localStorage, holder } = loadSettingsHarness();
|
||||
const listeners: Array<(e: any) => void> = [];
|
||||
const input = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
holder.queryResult = input;
|
||||
|
||||
app.startShortcutCapture('clear-terminal');
|
||||
expect(input.value).toBe('Press keys…');
|
||||
const handler = listeners[0];
|
||||
|
||||
// First keydown of Ctrl+Shift+P is 'Control' — must not finalize.
|
||||
handler({ key: 'Control', ctrlKey: true, preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
expect(input.removeEventListener).not.toHaveBeenCalled();
|
||||
|
||||
handler({
|
||||
key: 'P',
|
||||
code: 'KeyP',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
expect(input.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
const stored = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
||||
expect(stored.shortcutOverrides['clear-terminal'].bindings[0]).toMatchObject({
|
||||
modifiers: ['ctrl', 'shift'],
|
||||
key: 'P',
|
||||
code: 'KeyP',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects captures without a Ctrl/Cmd/Alt modifier (a bare key would fire while typing)', () => {
|
||||
const { app, localStorage, holder } = loadSettingsHarness();
|
||||
const listeners: Array<(e: any) => void> = [];
|
||||
holder.queryResult = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
app.showToast = vi.fn();
|
||||
|
||||
app.startShortcutCapture('clear-terminal');
|
||||
listeners[0]({ key: 'x', code: 'KeyX', preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
|
||||
expect(localStorage.getItem('codeman-app-settings')).toBeNull();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
||||
});
|
||||
|
||||
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
|
||||
const { app, elements } = loadSettingsHarness();
|
||||
elements.appSettingsModal = { querySelectorAll: () => [] };
|
||||
app.renderShortcutSettingsList = vi.fn();
|
||||
|
||||
app.switchSettingsTab('settings-shortcuts');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
|
||||
app.switchSettingsTab('settings-display');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders configurable rows with delegated controls (no inline onclick) and fixed rows read-only', () => {
|
||||
const { app, elements } = loadSettingsHarness();
|
||||
const list: any = { innerHTML: '', dataset: {}, addEventListener: vi.fn() };
|
||||
elements.appSettingsShortcutsList = list;
|
||||
app.getShortcutRegistry = () => [
|
||||
{
|
||||
id: 'clear-terminal',
|
||||
group: 'Terminal',
|
||||
label: 'Clear Terminal',
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
|
||||
action: 'clearTerminal',
|
||||
},
|
||||
{ id: 'close-panels', group: 'Panels', label: 'Close Panels', displayBindings: ['Escape'] },
|
||||
];
|
||||
|
||||
app.renderShortcutSettingsList();
|
||||
|
||||
expect(list.innerHTML).not.toContain('onclick=');
|
||||
expect((list.innerHTML.match(/shortcut-capture-btn/g) || []).length).toBe(1);
|
||||
expect(list.innerHTML).toContain('shortcut-setting-row--fixed');
|
||||
// Delegated listeners wired exactly once.
|
||||
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
||||
app.renderShortcutSettingsList();
|
||||
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user