diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index ddca92b..b90429e 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -496,6 +496,7 @@ function AppInner({ const chatScroll = useChatScrollActions(); const [input, setInput] = useState(""); const [composerCursor, setComposerCursor] = useState(0); + const [promptHistorySearchOpen, setPromptHistorySearchOpen] = useState(false); const [busy, setBusy] = useState(false); const [slashUsage, setSlashUsage] = useState>>(() => loadSlashUsage(), @@ -732,6 +733,7 @@ function AppInner({ !!pendingPlan || !!pendingReviseEditor || !!pendingSessionsPicker || + promptHistorySearchOpen || !!pendingWorkspacePicker || !!pendingCheckpointPicker || !!pendingMcpHub || @@ -1552,6 +1554,19 @@ function AppInner({ setSlashSelected, recallPrev, ]); + + const handleHistorySearchChoose = useCallback( + (value: string) => { + setInput(value); + resetCursor(); + setPromptHistorySearchOpen(false); + }, + [resetCursor], + ); + + const handleHistorySearchCancel = useCallback(() => { + setPromptHistorySearchOpen(false); + }, []); const handleHistoryNext = useCallback(() => { if (atState && atState.entries.length > 0) { setAtSelected((i) => Math.min(atState.entries.length - 1, i + 1)); @@ -1731,6 +1746,11 @@ function AppInner({ }); return; } + if (promptHistorySearchOpen) return; + if (key.ctrl && key.input.toLowerCase() === "r" && !busy && !modalOpen) { + setPromptHistorySearchOpen(true); + return; + } // Esc dismisses any composer-level picker (slash / @ / slash-arg) // by clearing the prefix that triggered it. Picker footers advertise // "esc cancel" —this binds it. @@ -4691,6 +4711,10 @@ function AppInner({ onSubmit={handleSubmit} onHistoryPrev={handleHistoryPrev} onHistoryNext={handleHistoryNext} + promptHistory={promptHistory} + historySearchOpen={promptHistorySearchOpen} + onHistorySearchChoose={handleHistorySearchChoose} + onHistorySearchCancel={handleHistorySearchCancel} onOpenExternalEditor={handleOpenExternalEditor} onCursorChange={setComposerCursor} vimEnabled={vimEnabled} diff --git a/src/cli/ui/ComposerArea.tsx b/src/cli/ui/ComposerArea.tsx index 3204adb..5241e39 100644 --- a/src/cli/ui/ComposerArea.tsx +++ b/src/cli/ui/ComposerArea.tsx @@ -11,6 +11,7 @@ import { t } from "../../i18n/index.js"; import type { JobRegistry } from "../../tools/jobs.js"; import { AtMentionSuggestions } from "./AtMentionSuggestions.js"; +import { PromptHistoryPicker } from "./PromptHistoryPicker.js"; import { PromptInput } from "./PromptInput.js"; import type { SlashArgPickerProps } from "./SlashArgPicker.js"; import { SlashArgPicker } from "./SlashArgPicker.js"; @@ -45,6 +46,10 @@ export interface ComposerAreaProps { onSubmit: (raw: string) => Promise; onHistoryPrev: () => void; onHistoryNext: () => void; + promptHistory: readonly string[]; + historySearchOpen: boolean; + onHistorySearchChoose: (value: string) => void; + onHistorySearchCancel: () => void; onOpenExternalEditor: () => void; onCursorChange: (cursor: number) => void; /** Vim editing layer for the composer (toggled by /vim). */ @@ -102,6 +107,10 @@ export const ComposerArea: React.FC = React.memo( onSubmit, onHistoryPrev, onHistoryNext, + promptHistory, + historySearchOpen, + onHistorySearchChoose, + onHistorySearchCancel, onOpenExternalEditor, onCursorChange, vimEnabled, @@ -140,16 +149,25 @@ export const ComposerArea: React.FC = React.memo( /> ) : null} - + {historySearchOpen ? ( + + ) : ( + + )} {busy || queuedSubmitCount > 0 ? ( diff --git a/src/cli/ui/PromptHistoryPicker.tsx b/src/cli/ui/PromptHistoryPicker.tsx new file mode 100644 index 0000000..4f6686c --- /dev/null +++ b/src/cli/ui/PromptHistoryPicker.tsx @@ -0,0 +1,132 @@ +import { Box, Text, useStdout } from "ink"; +// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation +import React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { t } from "../../i18n/index.js"; +import { useKeystroke } from "./keystroke-context.js"; +import { useReserveRows } from "./layout/viewport-budget.js"; +import { FG, TONE } from "./theme/tokens.js"; + +export interface PromptHistoryPickerProps { + history: readonly string[]; + initialQuery?: string; + onChoose: (value: string) => void; + onCancel: () => void; +} + +export function filterPromptHistory(history: readonly string[], query: string): string[] { + const needle = query.trim().toLowerCase(); + const seen = new Set(); + const newestFirst: string[] = []; + + for (let i = history.length - 1; i >= 0; i--) { + const value = history[i]?.trim(); + if (!value || seen.has(value)) continue; + seen.add(value); + if (!needle || value.toLowerCase().includes(needle)) newestFirst.push(value); + } + + return newestFirst; +} + +export function PromptHistoryPicker({ + history, + initialQuery = "", + onChoose, + onCancel, +}: PromptHistoryPickerProps): React.ReactElement { + const [query, setQuery] = useState(initialQuery); + const [focus, setFocus] = useState(0); + const matches = useMemo(() => filterPromptHistory(history, query), [history, query]); + const { stdout } = useStdout(); + const visibleCount = Math.max(3, Math.min(8, (stdout?.rows ?? 30) - 8)); + const promptWidth = Math.max(20, (stdout?.columns ?? 100) - 8); + useReserveRows("input", { min: 4, max: visibleCount + 4 }); + + useEffect(() => { + setFocus((current) => Math.max(0, Math.min(current, matches.length - 1))); + }, [matches.length]); + + useKeystroke((ev) => { + if (ev.paste) { + setQuery((current) => current + oneLine(ev.input)); + setFocus(0); + return; + } + if (ev.escape) return onCancel(); + if (ev.return) { + const selected = matches[focus]; + if (selected) onChoose(selected); + return; + } + if (ev.upArrow) { + setFocus((current) => Math.max(0, current - 1)); + return; + } + if (ev.downArrow || (ev.ctrl && ev.input.toLowerCase() === "r")) { + setFocus((current) => (matches.length > 0 ? (current + 1) % matches.length : 0)); + return; + } + if (ev.backspace) { + setQuery((current) => current.slice(0, -1)); + setFocus(0); + return; + } + if (ev.input && !ev.ctrl && !ev.meta && !ev.tab) { + setQuery((current) => current + ev.input); + setFocus(0); + } + }); + + const start = Math.max( + 0, + Math.min(focus - Math.floor(visibleCount / 2), matches.length - visibleCount), + ); + const shown = matches.slice(start, start + visibleCount); + + return ( + + + + {t("composer.historySearchTitle")} + + + {t("composer.historySearchCount", { shown: matches.length, total: history.length })} + + + + {t("composer.historySearchPrompt")} + {query} + + {" "} + + + {history.length === 0 ? ( + {t("composer.historySearchEmpty")} + ) : matches.length === 0 ? ( + {t("composer.historySearchNoMatch")} + ) : ( + shown.map((value, index) => { + const selected = start + index === focus; + return ( + + {selected ? "> " : " "} + + {truncate(oneLine(value), promptWidth)} + + + ); + }) + )} + {t("composer.historySearchHint")} + + ); +} + +function oneLine(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max - 3)}...`; +} diff --git a/src/cli/ui/PromptInput.tsx b/src/cli/ui/PromptInput.tsx index 05b76d8..789b62e 100644 --- a/src/cli/ui/PromptInput.tsx +++ b/src/cli/ui/PromptInput.tsx @@ -455,7 +455,7 @@ export function HintRow(): React.ReactElement { { key: "\u23ce", tKey: "composer.hintSend" }, { key: "\u21e7\u23ce", tKey: "composer.hintNewline" }, { key: "^U", tKey: "composer.hintClear" }, - { key: "↑/↓", tKey: "composer.hintHistory" }, + { key: "^R", tKey: "composer.hintHistory" }, { key: "esc", tKey: "composer.hintAbort" }, { key: "^C", tKey: "composer.hintQuit" }, ]; diff --git a/src/i18n/EN.ts b/src/i18n/EN.ts index cf92fa0..370ffca 100644 --- a/src/i18n/EN.ts +++ b/src/i18n/EN.ts @@ -1352,6 +1352,12 @@ export const EN: TranslationSchema = { hintHistory: "history", hintAbort: "abort", hintQuit: "quit", + historySearchTitle: "Prompt history", + historySearchPrompt: "search: ", + historySearchCount: " · {shown}/{total}", + historySearchEmpty: "No prompts submitted in this session yet.", + historySearchNoMatch: "No matching prompts.", + historySearchHint: "type to filter · ↑/↓ select · Ctrl+R next · Enter use · Esc cancel", abortedHint: "turn aborted by user \u00b7 esc again to clear \u00b7 \u23ce to ask a follow-up", editorNoRawMode: "external editor unavailable \u2014 stdin doesn't support raw-mode toggling on this terminal", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 931cbc0..f0d3988 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -473,6 +473,12 @@ export interface TranslationSchema { hintHistory: string; hintAbort: string; hintQuit: string; + historySearchTitle: string; + historySearchPrompt: string; + historySearchCount: string; + historySearchEmpty: string; + historySearchNoMatch: string; + historySearchHint: string; abortedHint: string; editorNoRawMode: string; editorFailed: string; diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index 575eb4c..b04b220 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -1286,6 +1286,12 @@ export const zhCN: TranslationSchema = { hintHistory: "历史", hintAbort: "中止", hintQuit: "退出", + historySearchTitle: "输入历史", + historySearchPrompt: "搜索:", + historySearchCount: " · {shown}/{total}", + historySearchEmpty: "本次会话还没有已提交的输入。", + historySearchNoMatch: "没有匹配的历史输入。", + historySearchHint: "输入筛选 · ↑/↓ 选择 · Ctrl+R 下一个 · Enter 使用 · Esc 取消", abortedHint: "用户已中止本轮 · 再按 Esc 清除 · ⏎ 继续提问", editorNoRawMode: "外部编辑器不可用 — 当前终端不支持 raw-mode 切换", editorFailed: "外部编辑器:", diff --git a/tests/composer-hint.test.tsx b/tests/composer-hint.test.tsx index 94a80e8..d461866 100644 --- a/tests/composer-hint.test.tsx +++ b/tests/composer-hint.test.tsx @@ -48,11 +48,11 @@ describe("composer hint bar — issue #564", () => { const { lastFrame, unmount } = render(); const out = lastFrame() ?? ""; unmount(); - // ⏎ send · ⇧⏎ newline · ^U clear · ↑/↓ history · esc abort · ^C quit + // ⏎ send · ⇧⏎ newline · ^U clear · ^R history · esc abort · ^C quit expect(out).toContain("send"); expect(out).toContain("newline"); expect(out).toContain("clear"); - expect(out).toContain("↑/↓"); + expect(out).toContain("^R"); expect(out).toContain("history"); expect(out).toContain("esc"); expect(out).toContain("abort"); diff --git a/tests/prompt-history-picker.test.tsx b/tests/prompt-history-picker.test.tsx new file mode 100644 index 0000000..43e4883 --- /dev/null +++ b/tests/prompt-history-picker.test.tsx @@ -0,0 +1,119 @@ +import { render } from "ink"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { PromptHistoryPicker, filterPromptHistory } from "../src/cli/ui/PromptHistoryPicker.js"; +import { + type KeystrokeHandler, + KeystrokeProvider, + type KeystrokeReader, + makeKeyEvent, +} from "../src/cli/ui/keystroke-context.js"; +import { ViewportBudgetProvider } from "../src/cli/ui/layout/viewport-budget.js"; +import type { KeyEvent } from "../src/cli/ui/stdin-reader.js"; +import { makeFakeStdin, makeFakeStdout } from "./helpers/ink-stdio.js"; + +class FakeReader implements KeystrokeReader { + private readonly handlers = new Set(); + + start(): void { + // no-op + } + + subscribe(handler: KeystrokeHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + feed(event: Partial): void { + for (const handler of [...this.handlers]) handler(makeKeyEvent(event)); + } +} + +async function feed(reader: FakeReader, event: Partial): Promise { + reader.feed(event); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function mount( + reader: FakeReader, + onChoose: (value: string) => void, + onCancel: () => void, + initialQuery = "", +): Promise & { stdout: ReturnType }> { + const stdout = makeFakeStdout(); + const view = render( + + + + + , + { stdout: stdout as never, stdin: makeFakeStdin() as never }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { ...view, stdout }; +} + +describe("filterPromptHistory", () => { + it("returns unique matches newest-first with case-insensitive search", () => { + expect( + filterPromptHistory(["Fix Tests", "add dashboard", "Fix Tests", "release tests"], "TESTS"), + ).toEqual(["release tests", "Fix Tests"]); + }); +}); + +describe("PromptHistoryPicker", () => { + it("filters typed text and fills the selected prompt on Enter", async () => { + const reader = new FakeReader(); + const chosen: string[] = []; + const view = await mount( + reader, + (value) => chosen.push(value), + () => undefined, + ); + await feed(reader, { input: "d" }); + await feed(reader, { input: "a" }); + await feed(reader, { return: true }); + + expect(chosen).toEqual(["add dashboard"]); + view.unmount(); + }); + + it("cycles to the next match with Ctrl+R", async () => { + const reader = new FakeReader(); + const chosen: string[] = []; + const view = await mount( + reader, + (value) => chosen.push(value), + () => undefined, + ); + await feed(reader, { input: "r", ctrl: true }); + await feed(reader, { return: true }); + + expect(chosen).toEqual(["fix tests"]); + view.unmount(); + }); + + it("keeps cancellation separate from choosing", async () => { + const reader = new FakeReader(); + const chosen: string[] = []; + let cancelled = 0; + const view = await mount( + reader, + (value) => chosen.push(value), + () => { + cancelled++; + }, + "release", + ); + await feed(reader, { escape: true }); + + expect(chosen).toEqual([]); + expect(cancelled).toBe(1); + view.unmount(); + }); +});