Merge PR #146 from aakhter/cod-163-command-palette: searchable case picker + Command-K session palette

Includes review fixes: Session Manager aligned to the merged /api/sessions/unified contract with error states, Ctrl+K no longer leaks 0x0B into the PTY, shortcut registry finished (dispatch/persistence/rendering), shortcutOverrides preserved across settings saves, help modal kept reachable.

# Conflicts:
#	README.md
#	src/web/public/index.html
#	src/web/public/session-ui.js
This commit is contained in:
Codeman maintainer
2026-07-12 20:03:53 +02:00
14 changed files with 2711 additions and 194 deletions
+250 -22
View File
@@ -305,6 +305,134 @@ function parseSessionPrefix(name) {
return null;
}
const DEFAULT_SHORTCUTS = [
{
id: 'show-shortcuts',
group: 'Panels',
label: 'Show Shortcuts',
bindings: [
{ modifiers: ['ctrl'], key: '?', code: 'Slash' },
{ modifiers: ['ctrl', 'shift'], key: '?' },
{ modifiers: ['alt'], key: '?', code: 'Slash' },
],
action: 'showShortcutOverlay',
},
{
id: 'close-session',
group: 'Session',
label: 'Close Session',
bindings: [{ modifiers: ['ctrl'], key: 'w' }],
action: 'killActiveSession',
},
{
id: 'next-session',
group: 'Session',
label: 'Next Session',
bindings: [{ modifiers: ['ctrl'], key: 'Tab' }],
action: 'nextSession',
},
{
id: 'clear-terminal',
group: 'Terminal',
label: 'Clear Terminal',
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
action: 'clearTerminal',
},
{
id: 'increase-font',
group: 'Terminal',
label: 'Increase Font',
bindings: [
{ modifiers: ['ctrl'], key: '=', code: 'Equal' },
{ modifiers: ['ctrl'], key: '+', code: 'Equal' },
],
action: 'increaseFontSize',
},
{
id: 'decrease-font',
group: 'Terminal',
label: 'Decrease Font',
bindings: [{ modifiers: ['ctrl'], key: '-', code: 'Minus' }],
action: 'decreaseFontSize',
},
{
id: 'voice-input',
group: 'Terminal',
label: 'Voice Input',
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',
label: 'Move Active Tab Left',
bindings: [{ modifiers: ['ctrl', 'shift'], key: '{', code: 'BracketLeft' }],
action: 'moveActiveTabLeft',
},
{
id: 'move-tab-right',
group: 'Tabs',
label: 'Move Active Tab Right',
bindings: [{ modifiers: ['ctrl', 'shift'], key: '}', code: 'BracketRight' }],
action: 'moveActiveTabRight',
},
{
id: 'command-palette',
group: 'Session',
label: 'Find Open Session',
bindings: [
{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' },
{ modifiers: ['meta'], key: 'k', code: 'KeyK' },
{ modifiers: ['alt'], key: 'k', code: 'KeyK' },
],
action: 'openCommandPalette',
},
{
id: 'previous-next-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
},
{
id: 'switch-tab-n',
group: 'Session',
label: 'Switch to Tab N',
displayBindings: ['Alt/Option+1-9'],
},
{
id: 'focus-tabs',
group: 'Tabs',
label: 'Focus Tabs',
displayBindings: ['ArrowLeft', 'ArrowRight', 'Home', 'End'],
},
{
id: 'activate-focused-tab',
group: 'Tabs',
label: 'Activate Focused Tab',
displayBindings: ['Enter', 'Space'],
},
{
id: 'insert-newline',
group: 'Terminal',
label: 'Insert Newline',
displayBindings: ['Shift+Enter', 'Ctrl+Enter'],
},
{
id: 'close-panels',
group: 'Panels',
label: 'Close Panels',
displayBindings: ['Escape'],
},
];
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
@@ -807,32 +935,43 @@ 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) => {
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
if (this.shouldOpenCommandPaletteFromShortcut?.(e)) {
e.preventDefault();
this.openCommandPalette();
return;
}
// Escape - close panels and modals (different logic: no preventDefault, no return)
if (e.key === 'Escape') {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
}
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
@@ -862,14 +1001,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;
}
}
@@ -4252,6 +4395,91 @@ class CodemanApp {
}
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
getShortcutRegistry() {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = settings.shortcutOverrides || {};
return DEFAULT_SHORTCUTS.map((shortcut) => {
const override = shortcutOverrides[shortcut.id];
if (!override) return shortcut;
// 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 || !Array.isArray(shortcut.bindings)) return false;
return shortcut.bindings.some((binding) => {
const mods = binding.modifiers || [];
// 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;
});
}
// ─── Shortcut Overlay Modal ───────────────────────────────────────────────────
// Ctrl/Alt+? opens a floating overlay listing all keyboard shortcuts, grouped
// by category. Uses the merged registry so user overrides are reflected.
showShortcutOverlay() {
const modal = document.getElementById('shortcutOverlayModal');
if (!modal) return;
this.renderShortcutOverlay();
modal.classList.add('active');
modal.focus?.();
}
renderShortcutOverlay() {
const list = document.getElementById('shortcutOverlayList');
if (!list) return;
const registry = this.getShortcutRegistry();
const groups = {};
for (const shortcut of registry) {
const g = shortcut.group || 'General';
if (!groups[g]) groups[g] = [];
groups[g].push(shortcut);
}
const fmtBindings = (s) => {
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
if (!s.bindings) return '';
return s.bindings.map((b) => {
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
}).join(' / ');
};
list.innerHTML = Object.entries(groups).map(([group, items]) =>
`<div class="shortcut-overlay-group"><div class="shortcut-overlay-group-label">${escapeHtml(group)}</div>` +
items.map((s) => `<div class="shortcut-overlay-row"><span class="shortcut-overlay-label">${escapeHtml(s.label)}</span><span class="shortcut-overlay-keys">${fmtBindings(s)}</span></div>`).join('') +
`</div>`
).join('');
}
closeShortcutOverlay() {
const modal = document.getElementById('shortcutOverlayModal');
if (modal) modal.classList.remove('active');
}
}
// ═══════════════════════════════════════════════════════════════
+122 -16
View File
@@ -358,7 +358,7 @@
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<div class="history-list" id="historyList"></div>
</div>
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
<p class="welcome-hint">Or click Run to start</p>
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
</div>
@@ -427,7 +427,7 @@
<!-- Run AI -->
<div class="toolbar-group">
<div class="run-btn-group">
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run (Ctrl+Enter)">
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span id="runBtnLabel">Run</span>
</button>
@@ -469,7 +469,22 @@
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
</div>
<div class="case-select-group">
<select id="quickStartCase" class="toolbar-select" title="Select case">
<div class="case-combobox" id="quickStartCasePicker">
<input
type="text"
id="quickStartCaseSearch"
class="case-combobox-input"
role="combobox"
aria-controls="quickStartCaseList"
aria-expanded="false"
aria-autocomplete="list"
autocomplete="off"
spellcheck="false"
title="Select case"
>
<div id="quickStartCaseList" class="case-combobox-list hidden" role="listbox"></div>
</div>
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
<option value="testcase">testcase</option>
</select>
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
@@ -555,19 +570,49 @@
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">&times;</button>
</div>
<div class="modal-body">
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</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>?</kbd></div><div>Show Help</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
</div>
<section class="shortcut-section">
<h4>Session</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
</div>
</section>
<section class="shortcut-section">
<h4>Tabs</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
</div>
</section>
<section class="shortcut-section">
<h4>Terminal</h4>
<div class="shortcuts-grid">
<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>
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
</div>
</section>
<section class="shortcut-section">
<h4>Panels</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
</div>
</section>
</div>
</div>
</div>
@@ -1091,6 +1136,7 @@
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
<button class="modal-tab-btn" data-tab="settings-voice">Voice</button>
<button class="modal-tab-btn" data-tab="settings-shortcuts">Shortcuts</button>
</div>
<div class="modal-body">
<!-- Display Tab -->
@@ -1704,6 +1750,19 @@
</div>
</div>
</div>
<!-- Shortcuts tab -->
<div class="modal-tab-content hidden" id="settings-shortcuts">
<div class="settings-grid">
<div class="settings-section-header" style="grid-column: 1 / -1;">Keyboard Shortcuts</div>
<p class="form-hint" style="grid-column: 1 / -1; margin: 0 0 0.5rem;">
Customize keyboard shortcuts. Click the binding to capture a new key combination.
</p>
<div id="appSettingsShortcutsList" style="grid-column: 1 / -1;">
<!-- Populated by app.renderShortcutSettingsList() -->
</div>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
@@ -1712,6 +1771,23 @@
</div>
</div>
<!-- Shortcut Overlay Modal -->
<div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1">
<div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div>
<div class="modal-content">
<div class="modal-header">
<span class="modal-title">Keyboard Shortcuts</span>
<button class="modal-close" onclick="app.closeShortcutOverlay()" aria-label="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div id="shortcutOverlayList"></div>
<div class="shortcut-overlay-footer">
<button class="btn btn-sm" onclick="app.closeShortcutOverlay(); app.showHelp()">Full shortcut reference</button>
</div>
</div>
</div>
</div>
<!-- Create Case Modal -->
<div class="modal" id="createCaseModal">
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
@@ -2111,6 +2187,36 @@
</div>
</div>
<!-- Command Palette Modal -->
<div class="modal command-palette-modal" id="commandPaletteModal">
<div class="modal-backdrop" onclick="app.closeCommandPalette()"></div>
<div class="command-palette-shell" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
<div class="command-palette-input-row">
<span class="command-palette-search-icon" aria-hidden="true">⌕</span>
<input type="search" id="commandPaletteSearch" class="command-palette-search" placeholder="Search open sessions or start a new one" autocomplete="off" maxlength="160" aria-labelledby="commandPaletteTitle">
<kbd>Esc</kbd>
</div>
<div class="command-palette-label" id="commandPaletteTitle">Open sessions</div>
<div id="commandPaletteList" class="command-palette-list"></div>
</div>
</div>
<!-- Session Manager Modal -->
<div class="modal" id="sessionManagerModal">
<div class="modal-backdrop" onclick="app.closeSessionManager()"></div>
<div class="modal-content session-manager-modal">
<div class="modal-header">
<h3>Sessions</h3>
<button class="modal-close" onclick="app.closeSessionManager()" aria-label="Close session manager">&times;</button>
</div>
<div class="modal-body">
<input type="search" id="sessionManagerSearch" class="search-input" placeholder="Search sessions by name, prompt, or path&hellip;" autocomplete="off" maxlength="200">
<div id="sessionManagerList" class="session-manager-list"></div>
</div>
</div>
</div>
<!-- Token Stats Modal -->
<div class="modal" id="tokenStatsModal">
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
+26 -1
View File
@@ -162,7 +162,11 @@ html.mobile-init .file-browser-panel {
}
.case-select-group {
max-width: 150px;
max-width: none;
}
.case-combobox {
width: 160px;
}
.toolbar-select {
@@ -194,6 +198,27 @@ html.mobile-init .file-browser-panel {
z-index: 1300;
}
.command-palette-modal {
padding: 10vh 0.75rem 0;
}
.command-palette-shell {
width: 100%;
max-height: 74vh;
}
.command-palette-input-row {
grid-template-columns: 20px minmax(0, 1fr);
}
.command-palette-input-row kbd {
display: none;
}
.command-palette-item {
min-height: 56px;
}
.modal-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
+393
View File
@@ -250,6 +250,399 @@ Object.assign(CodemanApp.prototype, {
this.openImagePopup(data);
},
// ═══════════════════════════════════════════════════════════════
// Command Palette (COD-153)
// Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new.
// ═══════════════════════════════════════════════════════════════
shouldOpenCommandPaletteFromShortcut(e) {
if (!e) 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;
const tagName = (target.tagName || '').toUpperCase();
const className = typeof target.className === 'string' ? target.className : '';
const isXtermHelper =
target.classList?.contains?.('xterm-helper-textarea') || className.includes('xterm-helper-textarea');
if (isXtermHelper) return true;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return false;
if (target.isContentEditable) return false;
if (typeof target.closest === 'function' && target.closest('[contenteditable="true"]')) return false;
return true;
},
openCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
if (!modal || !search) return;
this.commandPaletteActiveIndex = 0;
search.value = '';
modal.classList.add('active');
this._wireCommandPalette();
this.renderCommandPalette();
search.focus();
search.select?.();
},
closeCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
if (modal) modal.classList.remove('active');
},
_wireCommandPalette() {
if (this._commandPaletteWired) return;
this._commandPaletteWired = true;
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
search?.addEventListener('input', () => {
this.commandPaletteActiveIndex = 0;
this.renderCommandPalette();
});
search?.addEventListener('keydown', async (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.moveCommandPaletteSelection(1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.moveCommandPaletteSelection(-1);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
await this.activateCommandPaletteItem();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
modal?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
list?.addEventListener?.('click', (e) => {
const row = e.target?.closest?.('[data-command-index]');
if (!row) return;
this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0;
void this.activateCommandPaletteItem();
});
},
buildCommandPaletteItems(query = '') {
const needle = query.trim().toLowerCase();
const orderedIds = [
...(Array.isArray(this.sessionOrder) ? this.sessionOrder : []),
...Array.from(this.sessions?.keys?.() || []).filter((id) => !this.sessionOrder?.includes?.(id)),
];
const seen = new Set();
const sessionItems = [];
for (const sessionId of orderedIds) {
if (seen.has(sessionId)) continue;
seen.add(sessionId);
const session = this.sessions?.get?.(sessionId);
if (!session) continue;
const title = this.getSessionName?.(session) || session.name || session.title || sessionId.slice(0, 8);
const subtitleParts = [session.workingDir, session.mode, session.status].filter(Boolean);
const haystack = [title, session.workingDir, session.mode, session.status, sessionId].filter(Boolean).join(' ').toLowerCase();
if (needle && !haystack.includes(needle)) continue;
sessionItems.push({
id: `session:${sessionId}`,
type: 'session',
sessionId,
title,
subtitle: subtitleParts.join(' · '),
});
}
sessionItems.push(this._buildCommandPaletteNewSessionItem(query));
sessionItems.push({ id: 'browse-sessions', type: 'browse-sessions', title: 'Browse all sessions…', subtitle: 'Open Session Manager' });
return sessionItems;
},
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
type: 'new-session',
caseName,
title: 'New session',
subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
};
},
_findCommandPaletteCaseMatch(query = '') {
const needle = query.trim().toLowerCase();
if (!needle || !Array.isArray(this.cases)) return null;
const scoreCase = (caseItem) => {
const name = String(caseItem?.name || '').trim();
if (!name) return 0;
const haystack = [
name,
caseItem?.path,
caseItem?.casePath,
caseItem?.workingDir,
caseItem?.remote?.path,
caseItem?.remote?.hostId,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
const lowerName = name.toLowerCase();
if (lowerName === needle) return 100;
if (lowerName.startsWith(needle)) return 90;
if (lowerName.includes(needle)) return 80;
if (haystack.includes(needle)) return 60;
return 0;
};
let best = null;
let bestScore = 0;
for (const caseItem of this.cases) {
const score = scoreCase(caseItem);
if (score > bestScore) {
best = caseItem;
bestScore = score;
}
}
return best?.name || null;
},
renderCommandPalette() {
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
if (!list) return;
const query = search?.value || '';
const items = this.buildCommandPaletteItems(query);
this.commandPaletteItems = items;
this.commandPaletteActiveIndex = Math.max(0, Math.min(this.commandPaletteActiveIndex || 0, items.length - 1));
list.innerHTML = items
.map((item, index) => {
const active = index === this.commandPaletteActiveIndex ? ' active' : '';
const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›';
const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : '';
return `
<button class="command-palette-item${active}${browse}" type="button" data-command-index="${index}">
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
<span class="command-palette-text">
<span class="command-palette-title">${escapeHtml(item.title)}</span>
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
</span>
</button>
`;
})
.join('');
},
moveCommandPaletteSelection(delta) {
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
if (!items.length) return;
this.commandPaletteActiveIndex = (this.commandPaletteActiveIndex + delta + items.length) % items.length;
this.renderCommandPalette();
},
async activateCommandPaletteItem(index = this.commandPaletteActiveIndex || 0) {
const item = (this.commandPaletteItems || [])[index];
if (!item) return;
this.closeCommandPalette();
if (item.type === 'session' && item.sessionId) {
await this.selectSession(item.sessionId);
return;
}
if (item.type === 'browse-sessions') {
this.openSessionManager();
return;
}
if (item.type === 'new-session') {
const caseSelect = document.getElementById('quickStartCase');
if (caseSelect && item.caseName) {
if (
caseSelect.tagName === 'SELECT' &&
typeof caseSelect.appendChild === 'function' &&
!Array.from(caseSelect.options || []).some((option) => option.value === item.caseName)
) {
const option = document.createElement('option');
option.value = item.caseName;
option.textContent = item.caseName;
caseSelect.appendChild(option);
}
// 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();
}
},
// ═══════════════════════════════════════════════════════════════
// Session Manager Modal (COD-121)
// 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() {
const modal = document.getElementById('sessionManagerModal');
if (modal) {
modal.classList.add('active');
// Escape closes the modal even while focus is in the search input. A
// modal-scoped listener is robust regardless of the global Escape chain
// (which runs other close handlers first and can short-circuit). Wire once.
if (!this._sessionManagerEscWired) {
this._sessionManagerEscWired = true;
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
this.closeSessionManager();
}
});
}
}
// Ensure cases are loaded so item subtitles can show "#caseName" labels.
// Mirror loadHistorySessions(): prefer already-loaded this.cases.
if (!Array.isArray(this.cases) || this.cases.length === 0) {
try {
const r = await fetch('/api/cases');
const d = r.ok ? await r.json() : null;
this.cases = d?.data || [];
} catch {
this.cases = this.cases || [];
}
}
const search = document.getElementById('sessionManagerSearch');
if (search) {
// Wire the debounced search input once (lazy — the element exists by
// the time the modal is first opened, and mixin methods are bound).
if (!this._sessionManagerSearchWired) {
this._sessionManagerSearchWired = true;
search.addEventListener('input', () => {
const value = search.value.trim();
this._debouncedCall('sessionManagerSearch', () => this._loadSessionManagerList(value), 200);
});
}
search.value = '';
search.focus();
}
await this._loadSessionManagerList('');
},
closeSessionManager() {
const modal = document.getElementById('sessionManagerModal');
if (modal) modal.classList.remove('active');
},
/** 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 = '') {
this._sessionManagerQuery = q;
const list = document.getElementById('sessionManagerList');
if (!list) return;
try {
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
const res = await fetch(url);
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) {
this._setSessionManagerMessage(list, q ? 'No sessions match your search' : 'No sessions found');
return;
}
for (const s of sessions) {
// 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);
this._setSessionManagerMessage(list, 'Failed to load sessions');
}
},
// ═══════════════════════════════════════════════════════════════
// Away Digest Modal
+216 -29
View File
@@ -44,6 +44,203 @@ Object.assign(CodemanApp.prototype, {
// Quick Start
// ═══════════════════════════════════════════════════════════════
formatCasePickerLabel(c) {
return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || '';
},
buildCasePickerOptions(cases = []) {
const normalized = [];
const seen = new Set();
for (const c of cases) {
if (!c?.name || seen.has(c.name)) continue;
seen.add(c.name);
normalized.push(c);
}
if (!seen.has('testcase')) {
normalized.push({ name: 'testcase' });
}
return normalized
.map(c => {
const label = this.formatCasePickerLabel(c);
const searchText = [
c.name,
label,
c.path,
c.location,
c.remote?.hostId,
c.remote?.label,
c.remote?.path
].filter(Boolean).join(' ').toLowerCase();
return { name: c.name, label, case: c, searchText };
})
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base', numeric: true }));
},
filterCasePickerOptions(options, query) {
const terms = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return options;
return options.filter(option => terms.every(term => option.searchText.includes(term)));
},
getCasePickerOptions() {
return this.buildCasePickerOptions(this.cases || []);
},
updateCasePickerInput(caseName) {
const input = document.getElementById('quickStartCaseSearch');
if (!input) return;
const option = this.getCasePickerOptions().find(item => item.name === caseName);
input.value = option?.label || caseName || 'testcase';
input.title = option?.label || input.value;
},
renderQuickStartCaseSelectOptions(select, options) {
if (!select) return;
select.innerHTML = options
.map(option => `<option value="${escapeHtml(option.name)}">${escapeHtml(option.label)}</option>`)
.join('');
},
openCasePicker(filter = '') {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
if (!input || !list) return;
this._casePickerOpen = true;
this._casePickerFilter = filter;
this._casePickerActiveIndex = 0;
input.setAttribute('aria-expanded', 'true');
this.renderCasePickerList();
},
closeCasePicker() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
this._casePickerOpen = false;
this._casePickerFilter = '';
input?.setAttribute('aria-expanded', 'false');
input?.removeAttribute('aria-activedescendant');
list?.classList.add('hidden');
},
renderCasePickerList() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const select = document.getElementById('quickStartCase');
if (!input || !list || !select) return;
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
const selectedName = select.value || 'testcase';
const maxIndex = Math.max(0, options.length - 1);
this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex);
if (options.length === 0) {
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>';
list.classList.remove('hidden');
input.removeAttribute('aria-activedescendant');
return;
}
list.innerHTML = options
.map((option, index) => {
const active = index === this._casePickerActiveIndex;
const selected = option.name === selectedName;
const id = `quickStartCaseOption-${index}`;
return `
<button
type="button"
id="${id}"
class="case-combobox-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}"
role="option"
aria-selected="${selected ? 'true' : 'false'}"
data-case="${escapeHtml(option.name)}"
title="${escapeHtml(option.label)}">
<span class="case-combobox-check">${selected ? '✓' : ''}</span>
<span class="case-combobox-option-label">${escapeHtml(option.label)}</span>
</button>
`;
})
.join('');
list.classList.remove('hidden');
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
},
selectQuickStartCase(caseName, { save = true } = {}) {
const select = document.getElementById('quickStartCase');
if (!select) return;
select.value = caseName || 'testcase';
this.updateCasePickerInput(select.value);
this.closeCasePicker();
this.updateDirDisplayForCase(select.value);
this.updateMobileCaseLabel(select.value);
if (save) {
this.saveLastUsedCase(select.value);
}
},
setupQuickStartCasePicker() {
const select = document.getElementById('quickStartCase');
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const picker = document.getElementById('quickStartCasePicker');
if (!select || !input || !list || !picker || input.dataset.listenerAdded) return;
input.addEventListener('focus', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('click', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('input', () => {
this.openCasePicker(input.value);
});
input.addEventListener('keydown', event => {
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
if (event.key === 'ArrowDown') {
event.preventDefault();
this._casePickerActiveIndex = Math.min((this._casePickerActiveIndex || 0) + 1, Math.max(0, options.length - 1));
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'Enter') {
const option = options[this._casePickerActiveIndex || 0];
if (option) {
event.preventDefault();
this.selectQuickStartCase(option.name);
this.run?.();
}
} else if (event.key === 'Escape') {
event.preventDefault();
this.updateCasePickerInput(select.value);
this.closeCasePicker();
} else if (event.key === 'Tab') {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
list.addEventListener('mousedown', event => event.preventDefault());
list.addEventListener('click', event => {
const option = event.target.closest?.('.case-combobox-option');
if (option?.dataset?.case) {
this.selectQuickStartCase(option.dataset.case);
}
});
if (document.addEventListener && !this._casePickerDocumentListenerAdded) {
document.addEventListener('pointerdown', event => {
if (!picker.contains(event.target)) {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
this._casePickerDocumentListenerAdded = true;
}
input.dataset.listenerAdded = 'true';
},
async loadQuickStartCases(selectCaseName = null, settingsPromise = null) {
try {
// Load settings to get lastUsedCase (reuse shared promise if provided)
@@ -64,28 +261,8 @@ Object.assign(CodemanApp.prototype, {
const select = document.getElementById('quickStartCase');
// Build options - existing cases first, then testcase as fallback if not present
let options = '';
const hasTestcase = cases.some(c => c.name === 'testcase');
const isMobile = MobileDetection.getDeviceType() === 'mobile';
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
cases.forEach(c => {
const baseLabel = c.location === 'remote' && c.remote
? `${c.name} @ ${c.remote.hostId}`
: c.name;
const displayName = baseLabel.length > maxNameLength
? baseLabel.substring(0, maxNameLength) + '…'
: baseLabel;
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
});
// Add testcase option if it doesn't exist (will be created on first run)
if (!hasTestcase) {
options = `<option value="testcase">testcase</option>` + options;
}
select.innerHTML = options;
const options = this.getCasePickerOptions();
this.renderQuickStartCaseSelectOptions(select, options);
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
// If a specific case was requested, select it
@@ -110,6 +287,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase';
this.updateMobileCaseLabel('testcase');
}
this.updateCasePickerInput(select.value);
this.renderCasePickerList();
this.closeCasePicker();
// Only add event listener once (on first load)
if (!select.dataset.listenerAdded) {
@@ -117,9 +297,11 @@ Object.assign(CodemanApp.prototype, {
this.updateDirDisplayForCase(select.value);
this.saveLastUsedCase(select.value);
this.updateMobileCaseLabel(select.value);
this.updateCasePickerInput(select.value);
});
select.dataset.listenerAdded = 'true';
}
this.setupQuickStartCasePicker();
} catch (err) {
console.error('Failed to load cases:', err);
}
@@ -511,6 +693,8 @@ Object.assign(CodemanApp.prototype, {
caseData = createCaseData.data.case;
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
@@ -558,7 +742,7 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell', name })
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
@@ -1656,7 +1840,14 @@ Object.assign(CodemanApp.prototype, {
// Refresh the dropdown
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
if (currentCase === name) {
// Blur the native picker before reload so it doesn't show the stale value
select.blur?.();
}
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
if (currentCase === name) {
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
}
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
@@ -1693,11 +1884,7 @@ Object.assign(CodemanApp.prototype, {
// Build case list HTML
let html = '';
const cases = this.cases || [];
// Add testcase if not in list
const hasTestcase = cases.some(c => c.name === 'testcase');
const allCases = hasTestcase ? cases : [{ name: 'testcase' }, ...cases];
const allCases = this.getCasePickerOptions();
for (const c of allCases) {
const isSelected = c.name === currentCase;
@@ -1709,7 +1896,7 @@ Object.assign(CodemanApp.prototype, {
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-name">${escapeHtml(c.label)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
+138
View File
@@ -471,6 +471,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() {
@@ -1464,6 +1467,9 @@ Object.assign(CodemanApp.prototype, {
// with no UI left to turn it back off. Preserve the prior stored preference.
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
// Shortcut overrides are edited from the Shortcuts tab (not rebuilt from the
// general-settings DOM), so the fresh rebuild would drop them on every save.
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
// Save to localStorage
this.saveAppSettingsToStorage(settings);
@@ -2387,6 +2393,138 @@ 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 (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
: [];
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)}">
${controls}
</div>`;
})
.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() {
const settings = this.loadAppSettingsFromStorage();
return settings.shortcutOverrides || {};
},
startShortcutCapture(shortcutId) {
const input = document.querySelector(`.shortcut-binding-input[data-id="${shortcutId}"]`);
if (!input) return;
input.value = 'Press keys…';
input.focus();
this._capturingShortcutId = shortcutId;
// 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;
}
// 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');
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = {
...(shortcutOverrides[shortcutId] || {}),
bindings: [{ modifiers, key: e.key, code: e.code }],
};
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
resetShortcutOverride(shortcutId) {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
delete shortcutOverrides[shortcutId];
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
toggleShortcutEnabled(shortcutId, enabled) {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
closeAllPanels() {
this.closeSessionOptions();
this.closeAppSettings();
+403 -1
View File
@@ -3034,7 +3034,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
/* backdrop-filter creates a stacking context, trapping the popover's
z-index inside the toolbar. When the popover is open, raise the toolbar
above the CJK input (z-index 52) so the popover is interactable. */
.toolbar:has(.case-settings-popover:not(.hidden)) {
.toolbar:has(.case-settings-popover:not(.hidden)),
.toolbar:has(.case-combobox-list:not(.hidden)) {
z-index: 100;
}
@@ -3668,6 +3669,103 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
position: relative;
}
.case-combobox {
position: relative;
width: clamp(170px, 18vw, 280px);
}
.case-combobox-input {
width: 100%;
height: 100%;
min-height: 28px;
padding: 0.4rem 1.6rem 0.4rem 0.65rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: var(--btn-radius) 0 0 var(--btn-radius);
color: var(--text-dim);
font-size: 0.75rem;
font-family: inherit;
outline: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b97' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
transition: border-color var(--transition-smooth), background var(--transition-smooth), box-shadow var(--transition-smooth);
}
.case-combobox-input:hover,
.case-combobox-input:focus {
background-color: rgba(255, 255, 255, 0.07);
border-color: var(--accent);
color: var(--text);
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
}
.case-combobox-list {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
width: min(340px, calc(100vw - 24px));
max-height: min(320px, 55vh);
overflow-y: auto;
padding: 0.35rem;
background: rgba(22, 27, 35, 0.98);
border: 1px solid rgba(120, 141, 170, 0.28);
border-radius: 8px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
z-index: 1000;
}
.case-combobox-list.hidden {
display: none;
}
.case-combobox-option {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
width: 100%;
min-height: 34px;
padding: 0.35rem 0.45rem;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-dim);
font: inherit;
font-size: 0.78rem;
text-align: left;
cursor: pointer;
}
.case-combobox-option:hover,
.case-combobox-option.active {
background: rgba(59, 130, 246, 0.16);
border-color: rgba(96, 165, 250, 0.28);
color: var(--text);
}
.case-combobox-option.selected {
color: var(--text);
}
.case-combobox-check {
color: var(--green);
font-weight: 700;
}
.case-combobox-option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-combobox-empty {
padding: 0.85rem 0.75rem;
color: var(--text-muted);
font-size: 0.78rem;
text-align: center;
}
.toolbar-select {
padding: 0.4rem 1.5rem 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.05);
@@ -3686,6 +3784,16 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transition: all var(--transition-smooth);
}
.toolbar-select.case-native-select {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
opacity: 0;
pointer-events: none;
}
.toolbar-select:hover {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.07);
@@ -5417,6 +5525,300 @@ kbd {
padding: 1rem;
}
/* Command palette (COD-153) */
.command-palette-modal {
align-items: flex-start;
padding-top: min(16vh, 120px);
}
.command-palette-shell {
position: relative;
width: min(92vw, 620px);
max-height: min(70vh, 620px);
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(19, 19, 22, 0.97);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55), 0 4px 18px rgba(0, 0, 0, 0.35);
}
.command-palette-input-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 0.55rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.command-palette-search-icon {
color: var(--text-muted);
font-size: 1rem;
text-align: center;
}
.command-palette-search {
min-width: 0;
width: 100%;
background: transparent;
border: 0;
color: var(--text);
font: inherit;
font-size: 0.95rem;
outline: 0;
}
.command-palette-search:focus-visible {
box-shadow: none;
}
.command-palette-search::placeholder {
color: var(--text-muted);
}
.command-palette-input-row kbd {
padding: 0.15rem 0.35rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 0.68rem;
}
.command-palette-label {
padding: 0.55rem 0.8rem 0.25rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0;
text-transform: uppercase;
}
.command-palette-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 0;
overflow-y: auto;
padding: 0.25rem 0.45rem 0.55rem;
}
.command-palette-item {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 0.55rem;
width: 100%;
min-height: 52px;
padding: 0.45rem 0.55rem;
color: var(--text);
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
text-align: left;
cursor: pointer;
}
.command-palette-item:hover,
.command-palette-item.active {
background: rgba(59, 130, 246, 0.14);
border-color: rgba(59, 130, 246, 0.28);
}
.command-palette-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
font-size: 0.9rem;
}
.command-palette-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.command-palette-title,
.command-palette-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-title {
font-size: 0.85rem;
font-weight: 600;
}
.command-palette-subtitle {
color: var(--text-muted);
font-size: 0.72rem;
}
/* COD-192: "Browse all sessions…" escape hatch — visually secondary */
.command-palette-item--browse {
margin-top: 4px;
}
.command-palette-item--browse .command-palette-icon {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
}
.command-palette-item--browse .command-palette-title {
color: var(--text-muted);
font-weight: 500;
}
/* Session Manager modal (COD-121) */
.session-manager-modal {
max-width: 720px;
width: min(92vw, 720px);
max-height: 70vh;
display: flex;
flex-direction: column;
}
.session-manager-modal .modal-body {
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow: hidden;
padding: 1rem;
}
#sessionManagerSearch {
flex: 0 0 auto;
}
.session-manager-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
overflow-y: auto;
}
/* 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);
}
.shortcut-setting-row:last-child {
border-bottom: none;
}
.shortcut-setting-label {
color: var(--text);
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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);
}
.shortcut-overlay-footer {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
text-align: right;
}
.away-digest-ranges {
display: flex;
flex-wrap: wrap;
+26 -5
View File
@@ -120,12 +120,28 @@ Object.assign(CodemanApp.prototype, {
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“", Option+K -> "˚") are suppressed here too and
// don't leak an escape sequence into the focused terminal on every tab switch.
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
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;
}
@@ -1165,6 +1181,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;
@@ -1186,10 +1203,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';