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();