From e29721046c89a1196363e1def479aecefbd226db Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Wed, 1 Jul 2026 09:35:51 +0000 Subject: [PATCH 01/10] feat: COD-151 add searchable case picker --- src/web/public/index.html | 17 ++- src/web/public/mobile.css | 6 +- src/web/public/session-ui.js | 230 +++++++++++++++++++++++++++++++---- src/web/public/styles.css | 110 ++++++++++++++++- test/run-mode-ui.test.ts | 167 +++++++++++++++++++++++++ 5 files changed, 502 insertions(+), 28 deletions(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index 3f6edd2d..b00481eb 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -469,7 +469,22 @@

Resume Conversation

- + +
+ diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index ed681b37..aa7332bc 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -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 { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 733276b3..cd9606c2 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -44,6 +44,202 @@ 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 => ``) + .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 = '
No cases match
'; + 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 ` + + `; + }) + .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); + } + } 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,25 +260,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 displayName = c.name.length > maxNameLength - ? c.name.substring(0, maxNameLength) + '…' - : c.name; - options += ``; - }); - - // Add testcase option if it doesn't exist (will be created on first run) - if (!hasTestcase) { - options = `` + 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 @@ -107,6 +286,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) { @@ -114,9 +296,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); } @@ -1521,11 +1705,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; @@ -1537,7 +1717,7 @@ Object.assign(CodemanApp.prototype, { - ${escapeHtml(c.name)} + ${escapeHtml(c.label)} diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bf0d3cdc..a74a21ea 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index c3c9a3d7..84948803 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -144,6 +144,173 @@ describe('Codex quick start settings', () => { }); }); +describe('case selector refresh', () => { + it('sorts case picker options alphabetically and filters by case or host label', () => { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + console, + }); + + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + const cases = [ + { name: 'zeta' }, + { name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } }, + { name: 'Alpha' }, + { name: 'plex-previews' }, + ]; + + const options = app.buildCasePickerOptions(cases); + + expect(options.map((option: any) => option.name)).toEqual([ + 'Alpha', + 'moneytrove', + 'plex-previews', + 'testcase', + 'zeta', + ]); + expect(options.find((option: any) => option.name === 'moneytrove')?.label).toBe('moneytrove @ mac-mini'); + expect(app.filterCasePickerOptions(options, 'MAC').map((option: any) => option.name)).toEqual(['moneytrove']); + expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']); + }); + + it('creates remote shell sessions by caseName instead of remote display path', async () => { + const elements: Record = { + quickStartCase: { value: 'gpu-work' }, + shellCount: { value: '1' }, + }; + const requests: Array<{ url: string; body?: any }> = []; + const CodemanApp = function CodemanApp(this: any) {}; + + const context = vm.createContext({ + CodemanApp, + localStorage: { + getItem: () => null, + setItem: () => {}, + }, + document: { + getElementById: (id: string) => elements[id] ?? null, + }, + fetch: async (url: string, init?: { body?: string }) => { + requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }); + if (url === '/api/cases/gpu-work') { + return { + json: async () => ({ + success: true, + data: { + name: 'gpu-work', + path: 'ubuntu@10.0.0.42:/home/ubuntu/work', + location: 'remote', + remote: { hostId: 'gpu-box', path: '/home/ubuntu/work' }, + }, + }), + }; + } + if (url === '/api/sessions') { + return { json: async () => ({ success: true, data: { session: { id: 'sess-1' } } }) }; + } + if (url === '/api/sessions/sess-1/shell') return { json: async () => ({ success: true }) }; + throw new Error(`unexpected fetch: ${url}`); + }, + console, + }); + + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} }; + app.sessions = new Map(); + app.cases = [{ name: 'gpu-work', path: 'ubuntu@10.0.0.42:/home/ubuntu/work', location: 'remote' }]; + app.getTerminalDimensions = () => null; + app.selectSession = async () => {}; + + await app.runShell(); + + expect(requests.find((req) => req.url === '/api/sessions')?.body).toMatchObject({ + caseName: 'gpu-work', + mode: 'shell', + }); + expect(requests.find((req) => req.url === '/api/sessions')?.body).not.toHaveProperty('workingDir'); + }); + + it('removes a deleted selected case from the dropdown and blurs the native picker', async () => { + const elements: Record = {}; + const requests: Array<{ url: string; method: string; body?: any }> = []; + const CodemanApp = function CodemanApp(this: any) {}; + const quickStartCase = { + value: 'deleted-case', + innerHTML: '', + dataset: {}, + blur: vi.fn(), + addEventListener: vi.fn(), + }; + + elements.quickStartCase = quickStartCase; + elements.caseManageList = { innerHTML: '' }; + elements.mobileCaseName = { textContent: '' }; + elements.dirDisplay = { textContent: '' }; + elements.dirInput = { value: '' }; + + const context = vm.createContext({ + CodemanApp, + MobileDetection: { getDeviceType: () => 'desktop' }, + localStorage: { + getItem: () => null, + setItem: () => {}, + }, + document: { + getElementById: (id: string) => elements[id] ?? null, + }, + confirm: () => true, + fetch: async (url: string, init?: { method?: string; body?: string }) => { + requests.push({ url, method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined }); + if (url === '/api/cases/deleted-case') + return { json: async () => ({ success: true, data: { name: 'deleted-case' } }) }; + // The server's preSerialization hook wraps bare payloads as { success, data }, + // so the frontend reads `.data` off every JSON response — mirror that here. + if (url === '/api/settings') + return { ok: true, json: async () => ({ success: true, data: { lastUsedCase: 'deleted-case' } }) }; + if (url === '/api/cases') + return { json: async () => ({ success: true, data: [{ name: 'kept-case', path: '/tmp/kept-case' }] }) }; + if (url === '/api/cases/kept-case') + return { json: async () => ({ success: true, data: { path: '/tmp/kept-case' } }) }; + if (url === '/api/settings' && init?.method === 'PUT') return { json: async () => ({ success: true }) }; + throw new Error(`unexpected fetch: ${url}`); + }, + console, + escapeHtml: (s: string) => s, + }); + + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.cases = [ + { name: 'deleted-case', path: '/tmp/deleted-case' }, + { name: 'kept-case', path: '/tmp/kept-case' }, + ]; + app.showToast = vi.fn(); + + await app.deleteCase('deleted-case'); + + expect(quickStartCase.blur).toHaveBeenCalled(); + expect(quickStartCase.innerHTML).not.toContain('deleted-case'); + expect(quickStartCase.innerHTML).toContain('kept-case'); + expect(elements.mobileCaseName.textContent).toBe('kept-case'); + expect(requests).toContainEqual({ + url: '/api/settings', + method: 'PUT', + body: { lastUsedCase: 'kept-case' }, + }); + }); +}); + describe('Gemini quick start', () => { // Regression guard for the ApiResponse-envelope unwrap in runGemini(): the // status check must read `.data.available` and the quick-start response must From 48fd2da6ceeae24ce3599f9053e8d13357046a5e Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Wed, 1 Jul 2026 09:42:29 +0000 Subject: [PATCH 02/10] fix: COD-151 launch case picker selection on enter --- src/web/public/session-ui.js | 1 + test/run-mode-ui.test.ts | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index cd9606c2..1bad3e17 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -211,6 +211,7 @@ Object.assign(CodemanApp.prototype, { if (option) { event.preventDefault(); this.selectQuickStartCase(option.name); + this.run?.(); } } else if (event.key === 'Escape') { event.preventDefault(); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 84948803..380df455 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -179,6 +179,66 @@ describe('case selector refresh', () => { expect(app.filterCasePickerOptions(options, 'plex').map((option: any) => option.name)).toEqual(['plex-previews']); }); + it('launches the highlighted case with the current run mode when pressing Enter in the picker', () => { + const elements: Record = {}; + const listeners: Record void> = {}; + const CodemanApp = function CodemanApp(this: any) {}; + + elements.quickStartCase = { + value: 'Alpha', + dataset: {}, + }; + elements.quickStartCaseSearch = { + value: 'mon', + dataset: {}, + setAttribute: vi.fn(), + removeAttribute: vi.fn(), + addEventListener: vi.fn((event: string, handler: (event: any) => void) => { + listeners[event] = handler; + }), + select: vi.fn(), + }; + elements.quickStartCaseList = { + innerHTML: '', + classList: { add: vi.fn(), remove: vi.fn() }, + addEventListener: vi.fn(), + }; + elements.quickStartCasePicker = { + contains: () => true, + }; + + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { + getElementById: (id: string) => elements[id] ?? null, + addEventListener: vi.fn(), + }, + console, + escapeHtml: (s: string) => s, + }); + + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.cases = [ + { name: 'Alpha' }, + { name: 'moneytrove', location: 'remote', remote: { hostId: 'mac-mini', path: '/Users/saqeb/moneytrove' } }, + { name: 'zeta' }, + ]; + app.updateDirDisplayForCase = vi.fn(); + app.updateMobileCaseLabel = vi.fn(); + app.saveLastUsedCase = vi.fn(); + app.run = vi.fn(async () => {}); + + app.setupQuickStartCasePicker(); + listeners.keydown({ key: 'Enter', preventDefault: vi.fn() }); + + expect(elements.quickStartCase.value).toBe('moneytrove'); + expect(app.run).toHaveBeenCalledTimes(1); + }); + it('creates remote shell sessions by caseName instead of remote display path', async () => { const elements: Record = { quickStartCase: { value: 'gpu-work' }, From ad25e234f40c6c3c2e7c05244061f81c26bafd30 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Wed, 1 Jul 2026 10:05:58 +0000 Subject: [PATCH 03/10] feat: COD-153 add command-k session palette --- README.md | 1 + src/web/public/app.js | 8 + src/web/public/index.html | 83 +++++++-- src/web/public/mobile.css | 21 +++ src/web/public/panels-ui.js | 299 ++++++++++++++++++++++++++++++++ src/web/public/styles.css | 211 ++++++++++++++++++++++ test/command-palette-ui.test.ts | 181 +++++++++++++++++++ test/keyboard-shortcuts.test.ts | 6 + 8 files changed, 798 insertions(+), 12 deletions(-) create mode 100644 test/command-palette-ui.test.ts 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 @@

Keyboard Shortcuts

@@ -1963,6 +1992,36 @@

Away Digest

+ + + + + + +
@@ -1654,6 +1668,20 @@

App Settings

+ + +