diff --git a/.changeset/filter-coalesced-tab-input.md b/.changeset/filter-coalesced-tab-input.md new file mode 100644 index 000000000..5445f248f --- /dev/null +++ b/.changeset/filter-coalesced-tab-input.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Text typed in the same input chunk as Tab now fills the file filter instead of triggering global shortcuts. diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index a1cb6b9b7..dec52a7b6 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1252,6 +1252,22 @@ export function App({ [focusFiles, jumpToFile, review.revealLine, review.selectVisibleHunk], ); + /** Report whether the host filter prompt owns typing, read live from the store. */ + const isFilterPromptOpen = useCallback(() => { + const promptId = filterPromptIdRef.current; + return promptId !== null && statusLineStore.getSnapshot().prompt?.id === promptId; + }, [statusLineStore]); + + /** Append printable input to the filter prompt's value before its input has mounted. */ + const queueFilterInput = useCallback( + (text: string) => { + const prompt = statusLineStore.getSnapshot().prompt; + if (!prompt || prompt.id !== filterPromptIdRef.current) return; + statusLineStore.updatePromptValue(prompt.id, prompt.value + text); + }, + [statusLineStore], + ); + /** Toggle keyboard focus between the file list and the file filter. */ const toggleFocusArea = useCallback(() => { if (filterPromptIdRef.current !== null) { @@ -1526,6 +1542,8 @@ export function App({ exitKeyboardMode, sendKeyboardModeKey, focusArea, + isFilterPromptOpen, + queueFilterInput, promptActive: statusLineState.prompt !== null, moveMenuItem, moveThemeSelector, diff --git a/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx index cb4ed7bbb..503e3d905 100644 --- a/packages/hunk/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -2762,6 +2762,40 @@ describe("App interactions", () => { } }); + test("typing in the same input chunk as Tab fills the filter instead of running shortcuts", async () => { + const setup = await testRender(, { + width: 240, + height: 24, + }); + + try { + await flush(setup); + + // Tab and the text arrive together, before any render can mount the filter input; + // `t` would otherwise open the theme selector. + await act(async () => { + await setup.mockInput.typeText("\ttzz"); + }); + await flush(setup); + + const frame = setup.captureCharFrame(); + expect(frame).toContain("filter: tzz"); + expect(frame).toContain("No files match the current filter."); + expect(frame).not.toContain("Theme"); + + // Once mounted, the input continues after the held text. + await act(async () => { + await setup.mockInput.typeText("x"); + }); + await flush(setup); + expect(setup.captureCharFrame()).toContain("filter: tzzx"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("filtering away the selected file reselects the first visible match", async () => { const setup = await testRender(, { width: 240, diff --git a/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts b/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts index 3f3c2c772..8ad680907 100644 --- a/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -69,6 +69,10 @@ export interface UseAppKeyboardShortcutsOptions { /** Offer one key to the active session keyboard mode. */ sendKeyboardModeKey: (key: ExtensionKeyEvent) => ExtensionKeyboardModeKeyResult; focusArea: FocusArea; + /** Whether the host filter prompt owns typing, including before its input has rendered. */ + isFilterPromptOpen: () => boolean; + /** Append printable input to the filter prompt while its input is still mounting. */ + queueFilterInput: (text: string) => void; /** Whether a status-line prompt (the host filter or an extension's) currently owns typing. */ promptActive: boolean; moveMenuItem: (delta: number) => void; @@ -134,6 +138,8 @@ export function useAppKeyboardShortcuts({ exitKeyboardMode, sendKeyboardModeKey, focusArea, + isFilterPromptOpen, + queueFilterInput, promptActive, moveMenuItem, moveThemeSelector, @@ -172,6 +178,8 @@ export function useAppKeyboardShortcuts({ const flushDraftInputRef = useRef(flushDraftInput); const exitKeyboardModeRef = useRef(exitKeyboardMode); const sendKeyboardModeKeyRef = useRef(sendKeyboardModeKey); + const isFilterPromptOpenRef = useRef(isFilterPromptOpen); + const queueFilterInputRef = useRef(queueFilterInput); // These three close over live dialog state (the highlighted option, the typed // text), so they are read through refs rather than captured once. const acceptExtensionDialogRef = useRef(acceptExtensionDialog); @@ -198,6 +206,8 @@ export function useAppKeyboardShortcuts({ flushDraftInputRef.current = flushDraftInput; exitKeyboardModeRef.current = exitKeyboardMode; sendKeyboardModeKeyRef.current = sendKeyboardModeKey; + isFilterPromptOpenRef.current = isFilterPromptOpen; + queueFilterInputRef.current = queueFilterInput; acceptExtensionDialogRef.current = acceptExtensionDialog; cancelExtensionDialogRef.current = cancelExtensionDialog; moveExtensionDialogSelectionRef.current = moveExtensionDialogSelection; @@ -486,7 +496,9 @@ export function useAppKeyboardShortcuts({ * and closes second. */ const handleFocusedInputShortcut = (key: KeyEvent): KeyOwner => { - if (focusAreaRef.current === "filter") { + // Asked live rather than from the last render: Tab opens the filter synchronously, + // and the rest of the same input chunk must already be its text. + if (isFilterPromptOpenRef.current()) { // Deliberately no modifier check: Shift+Tab toggles focus exactly like // Tab, in both its CSI-u and legacy backtab encodings. if (key.name === "tab") { @@ -498,6 +510,13 @@ export function useAppKeyboardShortcuts({ return "mine"; } + // Until a render mounts the input, hold typed text in the prompt value it mounts with. + if (focusAreaRef.current !== "filter") { + const text = printableKeyText(key); + if (text !== undefined) queueFilterInputRef.current(text); + return "mine"; + } + // Everything else is the filter's text. return "focused"; } diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index 5c8394027..f8bc93add 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -217,6 +217,34 @@ describe("PTY chrome", () => { } }); + test("text coalesced with Tab fills the filter instead of running shortcuts", async () => { + const fixture = harness.createTwoFileRepoFixture(); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "split"], + cwd: fixture.dir, + cols: 220, + rows: 24, + }); + + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + await harness.ensureKeyboardIsLive(session); + + // One chunk, as a paste or a slow remote terminal delivers it; `t` is the theme shortcut. + session.writeRaw("\tbeta"); + const filtered = await harness.waitForSnapshot( + session, + (text) => text.includes("filter: beta") && !text.includes("alpha.ts"), + 5_000, + ); + + expect(filtered).toContain("betaValue"); + expect(filtered).not.toContain("Theme selector"); + } finally { + session.close(); + } + }); + test("a remapped slash focuses the filter and narrows the visible review stream", async () => { // `/` ships on content search; the documented one-liner hands it back to the filter. const configHome = harness.createIsolatedConfigHome();