diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 5da8666c..fef69c90 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -643,9 +643,9 @@ Object.assign(CodemanApp.prototype, { /** * Left/Right change the agent only while the new-session row is highlighted, - * and only when the key would not move the caret anyway (Left at the start of - * the query, Right at its end, no selection), so editing a typed case name - * keeps working. + * and the caret is at either query boundary, with no selection. Both arrows + * stay in picker navigation after typing a case name; inside the query they + * still edit text, as do modified arrows and selection changes. */ _commandPaletteArrowCyclesAgent(e) { const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0]; @@ -657,7 +657,7 @@ Object.assign(CodemanApp.prototype, { const start = Number.isFinite(input?.selectionStart) ? input.selectionStart : value.length; const end = Number.isFinite(input?.selectionEnd) ? input.selectionEnd : start; if (start !== end) return false; - return e.key === 'ArrowRight' ? end >= value.length : start <= 0; + return start <= 0 || end >= value.length; }, _findCommandPaletteCaseMatch(query = '') { diff --git a/test/command-palette-agent-arrows.browser.test.ts b/test/command-palette-agent-arrows.browser.test.ts new file mode 100644 index 00000000..774b83e0 --- /dev/null +++ b/test/command-palette-agent-arrows.browser.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +describe('Command-K agent arrow keys in Chromium', () => { + let server: WebServer; + let browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + it('cycles both directions at query boundaries while preserving text editing and selection', async () => { + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.openCommandPalette); + await page.waitForTimeout(3500); + await page.evaluate(() => { + const a = (window as any).app; + a.cases = [{ name: 'buildmesh', path: '/fixture/buildmesh' }]; + a.sessions = new Map(); + a.sessionOrder = []; + a._runMode = 'codex'; + (window as any).__codemanCliCatalog = [ + { id: 'claude', label: 'Claude', enabled: true, kind: 'agent' }, + { id: 'codex', label: 'Codex', enabled: true, kind: 'agent' }, + { id: 'shell', label: 'Shell', enabled: true, kind: 'shell' }, + ]; + a._runModeAvailableForCase = () => true; + a._sendInputEphemeral = () => { + throw new Error('Palette leaked terminal input'); + }; + a.run = () => { + throw new Error('Arrow key launched a session'); + }; + }); + await page.keyboard.press('Control+k'); + await page.locator('#commandPaletteModal.active').waitFor(); + const input = page.locator('#commandPaletteSearch'); + await input.fill('buildmesh'); + const label = page.locator('.command-palette-agent-label'); + expect(await label.innerText()).toBe('Codex'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Claude'); + await page.keyboard.press('ArrowRight'); + expect(await label.innerText()).toBe('Codex'); + expect(await input.inputValue()).toBe('buildmesh'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(9); + await page.keyboard.press('Home'); + await page.keyboard.press('ArrowRight'); + expect(await label.innerText()).toBe('Shell'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await input.evaluate((e: HTMLInputElement) => e.setSelectionRange(4, 4)); + await page.keyboard.press('ArrowLeft'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(3); + await page.keyboard.press('ArrowRight'); + expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(4); + await page.keyboard.press('Shift+End'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await page.screenshot({ path: '/tmp/codeman-21-palette.png' }); + await page.keyboard.press('ArrowDown'); + expect( + await page.evaluate( + () => (window as any).app.commandPaletteItems[(window as any).app.commandPaletteActiveIndex].type + ) + ).toBe('browse-sessions'); + await page.keyboard.press('End'); + await page.keyboard.press('ArrowLeft'); + expect(await label.innerText()).toBe('Codex'); + await page.keyboard.press('Escape'); + expect(await page.locator('#commandPaletteModal').evaluate((e) => e.classList.contains('active'))).toBe(false); + expect(errors).toEqual([]); + } finally { + await page.close(); + } + }); +}); diff --git a/test/command-palette-ui.test.ts b/test/command-palette-ui.test.ts index 408a4e1d..516a11dc 100644 --- a/test/command-palette-ui.test.ts +++ b/test/command-palette-ui.test.ts @@ -437,13 +437,28 @@ describe('Command-K new-session agent picker', () => { expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' }); }); - it('leaves Left/Right to the caret when they would move it', async () => { + it('cycles both ways at the end of a typed case query without moving its caret', async () => { + const { app, listeners, elements } = loadAgentPalette(); + app.openCommandPalette(); + elements.commandPaletteSearch.value = 'nomatch'; + app.renderCommandPalette(); + const left = arrow('ArrowLeft', 7, 'nomatch'); + await listeners['search:keydown'](left); + expect(left.preventDefault).toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode'); + const right = arrow('ArrowRight', 7, 'nomatch'); + await listeners['search:keydown'](right); + expect(right.preventDefault).toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); + }); + + it('leaves Left/Right to the caret inside the query', async () => { const { app, listeners, elements } = loadAgentPalette(); app.openCommandPalette(); elements.commandPaletteSearch.value = 'nomatch'; app.renderCommandPalette(); - const left = arrow('ArrowLeft', 7, 'nomatch'); + const left = arrow('ArrowLeft', 3, 'nomatch'); const right = arrow('ArrowRight', 3, 'nomatch'); await listeners['search:keydown'](left); await listeners['search:keydown'](right); @@ -453,6 +468,20 @@ describe('Command-K new-session agent picker', () => { expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); }); + it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])( + 'preserves %s arrow editing at query boundaries', + async (modifier) => { + const { app, listeners, elements } = loadAgentPalette(); + app.openCommandPalette(); + elements.commandPaletteSearch.value = 'nomatch'; + app.renderCommandPalette(); + const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true }; + await listeners['search:keydown'](event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude'); + } + ); + it('does not cycle while a session row is highlighted', async () => { const { app, listeners } = loadAgentPalette(); app.openCommandPalette();