Skip to content

Commit b164345

Browse files
committed
feat(input): Ctrl-R reverse history search in both TUIs
Persisted prompt history was only reachable by arrowing through it one entry at a time. Ctrl-R now opens a reverse-incremental search — type to filter (newest first, deduplicated), ↑↓ or repeated Ctrl-R to move, Enter drops the pick into the input buffer. Shared candidate/filter semantics live in one tested module; ink renders inline above the input, pi-tui as a focused overlay.
1 parent 77ef56f commit b164345

7 files changed

Lines changed: 330 additions & 10 deletions

File tree

src/ui-pi/app.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { pickNextVerb, THINKING_VERBS } from "../ui/thinking-verbs.js";
2828
import { BackgroundShellPanel } from "./background-shell-panel.js";
2929
import { ContextWarning, ErrorCard } from "./banners.js";
3030
import { CompactionBanner } from "./compaction-banner.js";
31+
import { HistorySearchOverlay } from "./history-search-overlay.js";
3132
import { buildMessageBlocks, MessageView } from "./message-view.js";
3233
import { type ModelOption, ModelPickerOverlay } from "./model-picker-overlay.js";
3334
import { PermissionOverlay } from "./permission-overlay.js";
@@ -67,6 +68,7 @@ export class App extends Container {
6768
private permissionOverlay: { handle: OverlayHandle; component: PermissionOverlay } | undefined;
6869
private userQueryOverlay: { handle: OverlayHandle; component: UserQueryOverlay } | undefined;
6970
private modelPickerOverlay: { handle: OverlayHandle; component: ModelPickerOverlay } | undefined;
71+
private historySearchOverlay: { handle: OverlayHandle; component: HistorySearchOverlay } | undefined;
7072
private removePermSubscription: (() => void) | undefined;
7173
private removeUserQuerySubscription: (() => void) | undefined;
7274
private tui: TUI | undefined;
@@ -343,9 +345,52 @@ export class App extends Container {
343345
this.exitResolve?.();
344346
return { consume: true };
345347
}
348+
// Ctrl-R opens reverse history search when no other modal is up.
349+
// (Once open, the overlay's own input handles the repeat-Ctrl-R cycle.)
350+
if (
351+
data === "\x12" &&
352+
this.inputBar &&
353+
!this.historySearchOverlay &&
354+
!this.permissionOverlay &&
355+
!this.userQueryOverlay &&
356+
!this.modelPickerOverlay
357+
) {
358+
this.showHistorySearchOverlay();
359+
return { consume: true };
360+
}
346361
return undefined;
347362
}
348363

364+
private showHistorySearchOverlay(): void {
365+
if (!this.tui || !this.inputBar) return;
366+
// Persisted prior runs ++ this session's prompts, chronological.
367+
const history = [...this.historyStore.load()];
368+
for (const m of this.messages) {
369+
if (m.role !== "user" || typeof m.content !== "string") continue;
370+
if (m.content.trim() && history[history.length - 1] !== m.content) history.push(m.content);
371+
}
372+
const component = new HistorySearchOverlay(
373+
history,
374+
(text) => {
375+
this.hideHistorySearchOverlay();
376+
this.inputBar?.setText(text);
377+
this.tui?.requestRender();
378+
},
379+
() => this.hideHistorySearchOverlay(),
380+
);
381+
const handle = this.tui.showOverlay(component, { anchor: "center", width: "70%", minWidth: 50 });
382+
this.tui.setFocus(component.getFocusTarget());
383+
this.historySearchOverlay = { handle, component };
384+
}
385+
386+
private hideHistorySearchOverlay(): void {
387+
if (!this.historySearchOverlay) return;
388+
this.historySearchOverlay.handle.hide();
389+
this.historySearchOverlay = undefined;
390+
if (this.inputBar) this.tui?.setFocus(this.inputBar);
391+
this.tui?.requestRender();
392+
}
393+
349394
private async handleSubmit(text: string): Promise<void> {
350395
// Both call sites fire this as a floating promise (`void`). Any
351396
// throw in the early branches — slash dispatch, @path attachment
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { type Component, Container, Input, Text } from "@earendil-works/pi-tui";
2+
import { displayLine, filterHistory, searchCandidates } from "../ui/history-search-core.js";
3+
import { ansi } from "./theme.js";
4+
5+
const MAX_SHOWN = 8;
6+
7+
/**
8+
* Ctrl-R reverse history search overlay. Typing filters past prompts
9+
* (newest first, deduplicated); ↑↓ or repeated Ctrl-R move the
10+
* selection; Enter hands the pick back; Esc cancels.
11+
*/
12+
export class HistorySearchOverlay extends Container {
13+
private readonly candidates: readonly string[];
14+
private readonly input: SearchInput;
15+
private cursor = 0;
16+
private readonly onPick: (text: string) => void;
17+
private readonly onCancel: () => void;
18+
19+
constructor(history: readonly string[], onPick: (text: string) => void, onCancel: () => void) {
20+
super();
21+
this.onPick = onPick;
22+
this.onCancel = onCancel;
23+
this.candidates = searchCandidates(history);
24+
25+
this.input = new SearchInput();
26+
this.input.onChanged = () => {
27+
this.cursor = 0;
28+
this.rebuild();
29+
};
30+
this.input.onNavigate = (delta) => {
31+
const total = this.matches().length;
32+
if (total > 0) this.cursor = (this.cursor + delta + total) % total;
33+
this.rebuild();
34+
};
35+
this.input.onSubmit = () => {
36+
const picked = this.matches()[this.cursor];
37+
if (picked) this.onPick(picked);
38+
else this.onCancel();
39+
};
40+
this.input.onEscape = () => this.onCancel();
41+
this.rebuild();
42+
}
43+
44+
getFocusTarget(): Component {
45+
return this.input;
46+
}
47+
48+
private matches(): string[] {
49+
return filterHistory(this.candidates, this.input.getValue());
50+
}
51+
52+
private rebuild(): void {
53+
this.clear();
54+
this.addChild(new Text(ansi.bold(ansi.cyan("(reverse-i-search)")), 1, 0));
55+
this.addChild(this.input);
56+
const matches = this.matches();
57+
this.cursor = Math.min(this.cursor, Math.max(0, matches.length - 1));
58+
if (matches.length === 0) {
59+
this.addChild(new Text(ansi.dim(" no matching prompts"), 1, 0));
60+
} else {
61+
const start = Math.max(0, Math.min(this.cursor - 2, matches.length - MAX_SHOWN));
62+
for (let i = start; i < Math.min(start + MAX_SHOWN, matches.length); i++) {
63+
const selected = i === this.cursor;
64+
const line = displayLine(matches[i]);
65+
this.addChild(new Text(selected ? `${ansi.cyan("▸ ")}${ansi.bold(line)}` : ansi.dim(` ${line}`), 1, 0));
66+
}
67+
}
68+
this.addChild(new Text(ansi.dim("Enter to use · ↑↓/Ctrl-R to move · Esc to cancel"), 1, 1));
69+
this.invalidate();
70+
}
71+
}
72+
73+
/**
74+
* Input that reports edits (for live filtering) and intercepts the
75+
* navigation keys before the base class can treat them as cursor moves.
76+
*/
77+
class SearchInput extends Input {
78+
onChanged?: () => void;
79+
onNavigate?: (delta: 1 | -1) => void;
80+
81+
override handleInput(data: string): void {
82+
if (data === "\x1b[A") {
83+
this.onNavigate?.(-1);
84+
return;
85+
}
86+
if (data === "\x1b[B" || data === "\x12") {
87+
this.onNavigate?.(1);
88+
return;
89+
}
90+
const before = this.getValue();
91+
super.handleInput(data);
92+
if (this.getValue() !== before) this.onChanged?.();
93+
}
94+
}

src/ui/App.tsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { buildAttachmentPrompt, collectAttachments } from "./attachments.js";
1818
import { BackgroundShellPanel } from "./BackgroundShellPanel.js";
1919
import { CompactionBanner } from "./CompactionBanner.js";
2020
import { FirstRunSetup } from "./FirstRunSetup.js";
21+
import { HistorySearch } from "./HistorySearch.js";
2122
import { HistoryStore } from "./history-store.js";
2223
import { Input, type InputHandle } from "./Input.js";
2324
import { MessageList } from "./MessageList.js";
@@ -110,6 +111,7 @@ function ChatApp({ initialBundle, onExit }: ChatAppProps) {
110111
const [tasks, setTasks] = useState<readonly Task[]>(() => bundle.toolContext.tasks.list());
111112
const inputRef = useRef<InputHandle | null>(null);
112113
const [modelPickerOpen, setModelPickerOpen] = useState(false);
114+
const [historySearchOpen, setHistorySearchOpen] = useState(false);
113115
// Prompts typed while the agent is busy. The bottom of the queue is
114116
// dispatched automatically when the agent goes idle. Lets the user
115117
// stack the next thing while the current turn is still running, the
@@ -562,6 +564,10 @@ function ChatApp({ initialBundle, onExit }: ChatAppProps) {
562564
// (Esc to deny, etc.) but Ctrl-C now routes through here.
563565
useInput((input, key) => {
564566
if (key.ctrl && input === "c") handleAbort();
567+
// Ctrl-R opens reverse history search when no other modal is up.
568+
if (key.ctrl && input === "r" && !historySearchOpen && !modelPickerOpen && !permRequest && !userQuery) {
569+
setHistorySearchOpen(true);
570+
}
565571
});
566572

567573
return (
@@ -619,16 +625,29 @@ function ChatApp({ initialBundle, onExit }: ChatAppProps) {
619625
onCancel={() => setModelPickerOpen(false)}
620626
/>
621627
) : (
622-
<Input
623-
ref={inputRef}
624-
onSubmit={handleSubmit}
625-
onAbort={handleAbort}
626-
commands={commandSuggestions}
627-
history={inputHistory}
628-
cwd={bundle.toolContext.cwd}
629-
suggestion={suggestion}
630-
onSuggestionDismiss={dismissSuggestion}
631-
/>
628+
<>
629+
{historySearchOpen ? (
630+
<HistorySearch
631+
history={inputHistory}
632+
onPick={(text) => {
633+
setHistorySearchOpen(false);
634+
inputRef.current?.setText(text);
635+
}}
636+
onCancel={() => setHistorySearchOpen(false)}
637+
/>
638+
) : null}
639+
<Input
640+
ref={inputRef}
641+
disabled={historySearchOpen}
642+
onSubmit={handleSubmit}
643+
onAbort={handleAbort}
644+
commands={commandSuggestions}
645+
history={inputHistory}
646+
cwd={bundle.toolContext.cwd}
647+
suggestion={suggestion}
648+
onSuggestionDismiss={dismissSuggestion}
649+
/>
650+
</>
632651
)}
633652
</Box>
634653
);

src/ui/HistorySearch.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Box, Text, useInput } from "ink";
2+
import { useMemo, useState } from "react";
3+
import { displayLine, filterHistory, searchCandidates } from "./history-search-core.js";
4+
5+
export interface HistorySearchProps {
6+
/** Chronological prompt history (oldest first), as kept by App. */
7+
history: readonly string[];
8+
onPick: (text: string) => void;
9+
onCancel: () => void;
10+
}
11+
12+
const MAX_SHOWN = 8;
13+
14+
/**
15+
* Ctrl-R reverse history search. Type to filter past prompts (newest
16+
* first, deduplicated); ↑↓ or repeated Ctrl-R move the selection; Enter
17+
* drops the pick into the input buffer; Esc cancels.
18+
*/
19+
export function HistorySearch({ history, onPick, onCancel }: HistorySearchProps) {
20+
const [query, setQuery] = useState("");
21+
const [cursor, setCursor] = useState(0);
22+
23+
const candidates = useMemo(() => searchCandidates(history), [history]);
24+
const matches = useMemo(() => filterHistory(candidates, query), [candidates, query]);
25+
26+
const clamped = Math.min(cursor, Math.max(0, matches.length - 1));
27+
28+
useInput((input, key) => {
29+
if (key.escape) {
30+
onCancel();
31+
return;
32+
}
33+
if (key.return) {
34+
if (matches[clamped]) onPick(matches[clamped]);
35+
else onCancel();
36+
return;
37+
}
38+
// Repeated Ctrl-R steps to the next-older match, readline-style.
39+
if ((key.ctrl && input === "r") || key.downArrow) {
40+
setCursor((c) => (matches.length === 0 ? 0 : (Math.min(c, matches.length - 1) + 1) % matches.length));
41+
return;
42+
}
43+
if (key.upArrow) {
44+
setCursor((c) =>
45+
matches.length === 0 ? 0 : (Math.min(c, matches.length - 1) - 1 + matches.length) % matches.length,
46+
);
47+
return;
48+
}
49+
if (key.backspace || key.delete) {
50+
setQuery((q) => q.slice(0, -1));
51+
setCursor(0);
52+
return;
53+
}
54+
if (input && !key.ctrl && !key.meta) {
55+
setQuery((q) => q + input);
56+
setCursor(0);
57+
}
58+
});
59+
60+
const shownStart = Math.max(0, Math.min(clamped - 2, matches.length - MAX_SHOWN));
61+
const shown = matches.slice(shownStart, shownStart + MAX_SHOWN);
62+
63+
return (
64+
<Box flexDirection="column" paddingX={1}>
65+
<Text>
66+
<Text color="cyan">(reverse-i-search)</Text> <Text>{query}</Text>
67+
<Text color="magenta"></Text>
68+
</Text>
69+
{matches.length === 0 ? (
70+
<Text dimColor> no matching prompts</Text>
71+
) : (
72+
shown.map((m, i) => {
73+
const idx = shownStart + i;
74+
const selected = idx === clamped;
75+
const line = displayLine(m);
76+
return (
77+
<Text key={`${idx}-${m.slice(0, 20)}`}>
78+
<Text color={selected ? "cyan" : "gray"}>{selected ? "▸ " : " "}</Text>
79+
<Text bold={selected}>{line}</Text>
80+
</Text>
81+
);
82+
})
83+
)}
84+
<Text dimColor>Enter to use · ↑↓/Ctrl-R to move · Esc to cancel</Text>
85+
</Box>
86+
);
87+
}

src/ui/Input.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface InputHandle {
3333
/** If the buffer has non-whitespace text, clear it and return true.
3434
* Otherwise no-op and return false. */
3535
clearIfHasText: () => boolean;
36+
/** Replace the buffer with `text`, cursor at the end. Used by Ctrl-R history search. */
37+
setText: (text: string) => void;
3638
}
3739

3840
interface InputProps {
@@ -123,6 +125,9 @@ export const Input = forwardRef<InputHandle, InputProps>(function Input(
123125
setState(initialInputState());
124126
return true;
125127
},
128+
setText: (text: string) => {
129+
setState({ ...initialInputState(), buffer: text, cursor: text.length });
130+
},
126131
}),
127132
[],
128133
);

src/ui/history-search-core.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vitest";
2+
import { displayLine, filterHistory, searchCandidates } from "./history-search-core.js";
3+
4+
describe("searchCandidates", () => {
5+
it("returns newest first, deduplicated, blanks dropped", () => {
6+
const history = ["fix tests", " ", "run build", "fix tests", "deploy"];
7+
expect(searchCandidates(history)).toEqual(["deploy", "fix tests", "run build"]);
8+
});
9+
10+
it("handles empty history", () => {
11+
expect(searchCandidates([])).toEqual([]);
12+
});
13+
});
14+
15+
describe("filterHistory", () => {
16+
const candidates = ["deploy to staging", "Fix the LOGIN test", "run build"];
17+
18+
it("matches case-insensitively", () => {
19+
expect(filterHistory(candidates, "login")).toEqual(["Fix the LOGIN test"]);
20+
});
21+
22+
it("empty query returns everything", () => {
23+
expect(filterHistory(candidates, "")).toEqual(candidates);
24+
});
25+
26+
it("no match returns empty", () => {
27+
expect(filterHistory(candidates, "zzz")).toEqual([]);
28+
});
29+
});
30+
31+
describe("displayLine", () => {
32+
it("flattens newlines", () => {
33+
expect(displayLine("a\nb")).toBe("a ⏎ b");
34+
});
35+
36+
it("clips long entries", () => {
37+
const long = "x".repeat(150);
38+
expect(displayLine(long)).toHaveLength(100);
39+
expect(displayLine(long).endsWith("…")).toBe(true);
40+
});
41+
});

0 commit comments

Comments
 (0)