diff --git a/README.md b/README.md
index 6bcfbaab..63f1a542 100644
--- a/README.md
+++ b/README.md
@@ -482,6 +482,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd+W` | Kill active session |
+| `Ctrl/Cmd+K` | Find open session or start a new one |
| `Ctrl/Cmd+Tab` | Next session |
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
diff --git a/src/web/public/app.js b/src/web/public/app.js
index c63f12be..1a08d601 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -804,11 +804,19 @@ class CodemanApp {
// 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?.();
}
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
diff --git a/src/web/public/index.html b/src/web/public/index.html
index b00481eb..de3daa80 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -569,18 +569,47 @@
-
-
Ctrl+W
Close Session
-
Ctrl+Tab
Next Session
-
Alt/Option+[ / Alt/Option+]
Previous / Next Session
-
Alt/Option+1-9
Switch to Tab N
-
Ctrl+L
Clear Terminal
-
Ctrl++
Increase Font
-
Ctrl+-
Decrease Font
-
Ctrl+?
Show Help
-
Ctrl+Shift+V
Voice Input
-
Escape
Close Panels
-
+
+ Session
+
+
Ctrl+W
Close Session
+
Ctrl/Cmd+K
Find Open Session
+
Ctrl+Tab
Next Session
+
Alt/Option+[ / Alt/Option+]
Previous / Next Session
+
Alt/Option+1-9
Switch to Tab N
+
+
+
+ Tabs
+
+
Ctrl+{
Move Active Tab Left
+
Ctrl+}
Move Active Tab Right
+
ArrowLeft
Focus Previous Tab
+
ArrowRight
Focus Next Tab
+
Home
Focus First Tab
+
End
Focus Last Tab
+
Enter / Space
Activate Focused Tab
+
+
+
+ Terminal
+
+
Ctrl+L
Clear Terminal
+
Ctrl++
Increase Font
+
Ctrl+-
Decrease Font
+
Shift+Enter
Insert Newline
+
Ctrl+Enter
Insert Newline
+
Ctrl+Shift+V
Voice Input
+
+
+
+ Panels
+
+
Ctrl+?
Show Help
+
Ctrl+/
Show Help
+
Escape
Close Panels
+
+
@@ -1963,6 +1992,36 @@
+
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index aa7332bc..2d5ed698 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -198,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;
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 26918a95..2ce47739 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -250,6 +250,305 @@ 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;
+ if ((e.key || '').toLowerCase() !== 'k') return false;
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return false;
+
+ const target = e.target;
+ if (!target) return true;
+ const tagName = (target.tagName || '').toUpperCase();
+ 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.closeMobileHeaderUtilities?.();
+ 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 = 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());
+ return sessionItems;
+ },
+
+ _buildCommandPaletteNewSessionItem() {
+ const mode = this.runMode || this._runMode || 'claude';
+ const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
+ const caseName = document.getElementById('quickStartCase')?.value || 'testcase';
+ return {
+ id: 'new-session',
+ type: 'new-session',
+ title: 'New session',
+ subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
+ };
+ },
+
+ 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' ? '+' : '›';
+ return `
+
+ `;
+ })
+ .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 === 'new-session') {
+ await this.run();
+ }
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 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.
+ // ═══════════════════════════════════════════════════════════════
+
+ 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');
+ },
+
+ /**
+ * 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);
+ }
+ },
+
+ 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();
+ 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);
+ 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());
+ 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);
+ }
+ },
// ═══════════════════════════════════════════════════════════════
// Away Digest Modal
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index a74a21ea..d7217049 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -5525,6 +5525,217 @@ 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;
+}
+
+/* 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;
+}
+
+/* COD-130: per-row ⋯ kebab context menu (appended to , 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);
+}
+
+.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);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 0.8rem;
+ text-align: left;
+ 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);
+ font-size: 0.72rem;
+}
+
.away-digest-ranges {
display: flex;
flex-wrap: wrap;
diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts
new file mode 100644
index 00000000..1a3dc6ec
--- /dev/null
+++ b/test/command-palette-ui.test.ts
@@ -0,0 +1,181 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+function loadPaletteHarness(overrides: Record
= {}) {
+ const elements: Record = {};
+ const listeners: Record void> = {};
+ const CodemanApp = function CodemanApp(this: any) {};
+
+ const makeClassList = () => {
+ const classes = new Set();
+ return {
+ add: (...names: string[]) => names.forEach((name) => classes.add(name)),
+ remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
+ contains: (name: string) => classes.has(name),
+ toggle: (name: string, force?: boolean) => {
+ const shouldAdd = force ?? !classes.has(name);
+ if (shouldAdd) classes.add(name);
+ else classes.delete(name);
+ return shouldAdd;
+ },
+ };
+ };
+
+ elements.commandPaletteModal = {
+ classList: makeClassList(),
+ addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
+ listeners[`modal:${event}`] = handler;
+ }),
+ };
+ elements.commandPaletteSearch = {
+ value: '',
+ focus: vi.fn(),
+ select: vi.fn(),
+ addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
+ listeners[`search:${event}`] = handler;
+ }),
+ };
+ elements.commandPaletteList = {
+ innerHTML: '',
+ addEventListener: vi.fn((event: string, handler: (event: any) => void) => {
+ listeners[`list:${event}`] = handler;
+ }),
+ };
+
+ const context = vm.createContext({
+ CodemanApp,
+ document: {
+ getElementById: (id: string) => elements[id] ?? null,
+ },
+ console,
+ escapeHtml: (value: string) =>
+ String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, '''),
+ ...overrides,
+ });
+
+ const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
+ vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
+
+ const app = new (CodemanApp as any)();
+ app.sessions = new Map([
+ [
+ 'sess-alpha',
+ {
+ id: 'sess-alpha',
+ name: 'Alpha API cleanup',
+ workingDir: '/repo/api',
+ mode: 'codex',
+ status: 'busy',
+ },
+ ],
+ [
+ 'sess-beta',
+ {
+ id: 'sess-beta',
+ name: 'Billing prompt polish',
+ workingDir: '/repo/billing',
+ mode: 'claude',
+ status: 'idle',
+ },
+ ],
+ ]);
+ app.sessionOrder = ['sess-beta', 'sess-alpha'];
+ app.selectSession = vi.fn();
+ app.run = vi.fn();
+ app.closeMobileHeaderUtilities = vi.fn();
+
+ return { app, elements, listeners };
+}
+
+describe('Command-K session palette', () => {
+ it('recognizes Cmd/Ctrl-K outside text-entry contexts only', () => {
+ const { app } = loadPaletteHarness();
+
+ expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'k', metaKey: true, ctrlKey: false, target: null })).toBe(
+ true
+ );
+ expect(app.shouldOpenCommandPaletteFromShortcut({ key: 'K', metaKey: false, ctrlKey: true, target: null })).toBe(
+ true
+ );
+ expect(
+ app.shouldOpenCommandPaletteFromShortcut({
+ key: 'k',
+ metaKey: true,
+ ctrlKey: false,
+ target: { tagName: 'INPUT', isContentEditable: false },
+ })
+ ).toBe(false);
+ expect(
+ app.shouldOpenCommandPaletteFromShortcut({
+ key: 'k',
+ metaKey: false,
+ ctrlKey: true,
+ target: { tagName: 'DIV', isContentEditable: true },
+ })
+ ).toBe(false);
+ });
+
+ it('opens and focuses the palette search box', () => {
+ const { app, elements } = loadPaletteHarness();
+
+ app.openCommandPalette();
+
+ expect(elements.commandPaletteModal.classList.contains('active')).toBe(true);
+ expect(elements.commandPaletteSearch.focus).toHaveBeenCalledTimes(1);
+ expect(elements.commandPaletteList.innerHTML).toContain('Alpha API cleanup');
+ });
+
+ it('filters currently open sessions and always includes a new-session action', () => {
+ const { app } = loadPaletteHarness();
+
+ const results = app.buildCommandPaletteItems('bill');
+
+ expect(results.map((item: any) => item.id)).toEqual(['session:sess-beta', 'new-session']);
+ expect(results[0]).toMatchObject({ type: 'session', sessionId: 'sess-beta', title: 'Billing prompt polish' });
+ expect(results[1]).toMatchObject({ type: 'new-session', title: 'New session' });
+ });
+
+ it('activates the highlighted session result', async () => {
+ const { app } = loadPaletteHarness();
+ app.openCommandPalette();
+ app.commandPaletteItems = app.buildCommandPaletteItems('api');
+ app.commandPaletteActiveIndex = 0;
+
+ await app.activateCommandPaletteItem();
+
+ expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
+ expect(app.run).not.toHaveBeenCalled();
+ });
+
+ it('activates the new-session result through the current run path', async () => {
+ const { app } = loadPaletteHarness();
+ app.openCommandPalette();
+ app.commandPaletteItems = app.buildCommandPaletteItems('does-not-match');
+ app.commandPaletteActiveIndex = 0;
+
+ await app.activateCommandPaletteItem();
+
+ expect(app.run).toHaveBeenCalledTimes(1);
+ expect(app.selectSession).not.toHaveBeenCalled();
+ });
+
+ it('routes Enter from the palette search to the current result', async () => {
+ const { app, listeners } = loadPaletteHarness();
+ app.openCommandPalette();
+ app.commandPaletteItems = app.buildCommandPaletteItems('api');
+ app.commandPaletteActiveIndex = 0;
+
+ const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() };
+ await listeners['search:keydown'](event);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
+ });
+});
diff --git a/test/keyboard-shortcuts.test.ts b/test/keyboard-shortcuts.test.ts
index 7b93e541..1e3d8b8c 100644
--- a/test/keyboard-shortcuts.test.ts
+++ b/test/keyboard-shortcuts.test.ts
@@ -34,4 +34,10 @@ describe('keyboard shortcuts', () => {
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
});
+
+ it('documents the Command-K open-session palette in help and README', () => {
+ expect(appSource).toContain('this.openCommandPalette()');
+ expect(helpHtml).toContain('Ctrl/Cmd+K');
+ expect(readme).toMatch(/\| `Ctrl\/Cmd\+K`\s+\| Find open session or start a new one\s+\|/);
+ });
});