Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,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<Readonly<Record<string, number>>>(() =>
loadSlashUsage(),
Expand Down Expand Up @@ -743,6 +744,7 @@ function AppInner({
!!pendingPlan ||
!!pendingReviseEditor ||
!!pendingSessionsPicker ||
promptHistorySearchOpen ||
!!pendingWorkspacePicker ||
!!pendingCheckpointPicker ||
!!pendingMcpHub ||
Expand Down Expand Up @@ -1529,6 +1531,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));
Expand Down Expand Up @@ -1708,6 +1723,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.
Expand Down Expand Up @@ -4635,6 +4655,10 @@ function AppInner({
onSubmit={handleSubmit}
onHistoryPrev={handleHistoryPrev}
onHistoryNext={handleHistoryNext}
promptHistory={promptHistory}
historySearchOpen={promptHistorySearchOpen}
onHistorySearchChoose={handleHistorySearchChoose}
onHistorySearchCancel={handleHistorySearchCancel}
onOpenExternalEditor={handleOpenExternalEditor}
onCursorChange={setComposerCursor}
vimEnabled={vimEnabled}
Expand Down
38 changes: 28 additions & 10 deletions src/cli/ui/ComposerArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,10 @@ export interface ComposerAreaProps {
onSubmit: (raw: string) => Promise<void>;
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). */
Expand Down Expand Up @@ -102,6 +107,10 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
onSubmit,
onHistoryPrev,
onHistoryNext,
promptHistory,
historySearchOpen,
onHistorySearchChoose,
onHistorySearchCancel,
onOpenExternalEditor,
onCursorChange,
vimEnabled,
Expand Down Expand Up @@ -140,16 +149,25 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
/>
) : null}
</Box>
<PromptInput
value={input}
onChange={setInput}
onSubmit={onSubmit}
onHistoryPrev={onHistoryPrev}
onHistoryNext={onHistoryNext}
onOpenExternalEditor={onOpenExternalEditor}
onCursorChange={onCursorChange}
vimEnabled={vimEnabled}
/>
{historySearchOpen ? (
<PromptHistoryPicker
history={promptHistory}
initialQuery={input}
onChoose={onHistorySearchChoose}
onCancel={onHistorySearchCancel}
/>
) : (
<PromptInput
value={input}
onChange={setInput}
onSubmit={onSubmit}
onHistoryPrev={onHistoryPrev}
onHistoryNext={onHistoryNext}
onOpenExternalEditor={onOpenExternalEditor}
onCursorChange={onCursorChange}
vimEnabled={vimEnabled}
/>
)}
{busy || queuedSubmitCount > 0 ? (
<Box>
<Text color={FG.faint}>
Expand Down
132 changes: 132 additions & 0 deletions src/cli/ui/PromptHistoryPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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 (
<Box flexDirection="column" borderStyle="round" borderColor={TONE.brand} paddingX={1}>
<Box>
<Text bold color={TONE.brand}>
{t("composer.historySearchTitle")}
</Text>
<Text color={FG.meta}>
{t("composer.historySearchCount", { shown: matches.length, total: history.length })}
</Text>
</Box>
<Box>
<Text color={FG.faint}>{t("composer.historySearchPrompt")}</Text>
<Text color={FG.strong}>{query}</Text>
<Text backgroundColor={TONE.brand} color="black">
{" "}
</Text>
</Box>
{history.length === 0 ? (
<Text color={FG.faint}>{t("composer.historySearchEmpty")}</Text>
) : matches.length === 0 ? (
<Text color={FG.faint}>{t("composer.historySearchNoMatch")}</Text>
) : (
shown.map((value, index) => {
const selected = start + index === focus;
return (
<Box key={`${start + index}:${value}`}>
<Text color={selected ? TONE.brand : FG.faint}>{selected ? "> " : " "}</Text>
<Text bold={selected} color={selected ? FG.strong : FG.sub}>
{truncate(oneLine(value), promptWidth)}
</Text>
</Box>
);
})
)}
<Text color={FG.faint}>{t("composer.historySearchHint")}</Text>
</Box>
);
}

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)}...`;
}
2 changes: 1 addition & 1 deletion src/cli/ui/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/EN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,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",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,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: "外部编辑器:",
Expand Down
4 changes: 2 additions & 2 deletions tests/composer-hint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ describe("composer hint bar — issue #564", () => {
const { lastFrame, unmount } = render(<HintRow />);
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");
Expand Down
Loading