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:
Codeman maintainer
2026-07-12 19:06:12 +02:00
parent 8a133d083b
commit 88f47754ad
10 changed files with 645 additions and 140 deletions
+60 -31
View File
@@ -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;
});
}
+1
View File
@@ -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
View File
@@ -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');
}
},
+82 -25
View File
@@ -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
View File
@@ -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 {
+18 -2
View File
@@ -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';
+128 -1
View File
@@ -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) {};
+2 -1
View File
@@ -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);
});
+18
View File
@@ -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;');
});
});
+164 -1
View File
@@ -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);
});
});