Skip to content

Commit 2afd833

Browse files
authored
feat: search workspace prompt history with Ctrl+R (#333)
1 parent 4b520f8 commit 2afd833

11 files changed

Lines changed: 363 additions & 13 deletions

‎COMMANDS.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ These apply to any command (parsed anywhere on the line).
7575

7676
### `aether` — interactive REPL
7777
Opens a session. Type a prompt to chat; type `/` commands to control it (see
78-
[Slash commands](#slash-commands)). Up-arrow recalls prompts across sessions
79-
(history lives at `~/.aether-agent/history`).
78+
[Slash commands](#slash-commands)). Up-arrow recalls submitted prompts from
79+
this workspace (stored under `~/.aether-agent/history.d/`).
8080
`Ctrl-C` mid-answer cancels the turn and keeps the session; `Ctrl-C` at an
8181
empty prompt (or `/exit`) leaves.
8282

@@ -87,6 +87,19 @@ one step; a bracketed paste is one step). Ctrl+Y restores the last text killed
8787
with Ctrl+K, Ctrl+U, or Ctrl+W. Submitting or clearing a draft ends its undo
8888
and yank history. Non-TTY input stays line-oriented.
8989

90+
Ctrl+R searches this workspace's loaded prompt history. An empty query selects
91+
the newest entry; type to narrow the matches and press Ctrl+R again to move
92+
older without wrapping. The panel shows the query, selected prompt, and a
93+
no-match state. Enter inserts the complete stored prompt for editing without
94+
sending it; Escape restores the exact prior draft and cursor. Search examines
95+
at most 1,000 loaded entries, 16 KiB per entry and 1 MiB total per query update.
96+
Oversized or over-budget entries are skipped and counted; displayed matches
97+
are clipped to 512 characters and terminal width without clipping the prompt
98+
inserted on Enter. `AETHER_NO_HISTORY=1` disables loading and saving history.
99+
Stored prompts are not automatically filtered for secrets. Shell commands,
100+
tool output, and approval feedback are not added to prompt history. Search
101+
is available only when the idle raw composer owns input.
102+
90103
Some terminals send LF for Enter as well as Ctrl+J, so those two keys cannot
91104
be distinguished there. Use `aether config set lfSubmits true` to make LF
92105
submit for compatibility; `aether config set lfSubmits false` restores Ctrl+J
@@ -731,6 +744,7 @@ Requires an active orchestrator — switch with `/agent neo` or `/agent kronus`
731744
| `OLLAMA_HOST` | `http://localhost:11434` | Where the offline brain looks for Ollama. Accepts Ollama's own scheme-less form (`127.0.0.1:11434`) as well as a full URL — see below. |
732745
| `AETHER_STREAM_TIMEOUT_MS` | `120000` | Stream open/idle timeout (ms). `0` disables it. |
733746
| `AETHER_NO_ANIM` | *(unset)* | `1` disables all animated status lines and the thinking pulse. |
747+
| `AETHER_NO_HISTORY` | *(unset)* | `1` disables workspace prompt-history loading and saving, including Ctrl+R search. |
734748
| `NO_COLOR` | *(unset)* | Any value disables ANSI colors (https://no-color.org). |
735749

736750
See [`.env.example`](.env.example).

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ undoes recent edits, and Ctrl+Y restores killed text. The single-row input
151151
shows newlines as `⏎` with a line count. If your terminal sends LF for Enter,
152152
set `aether config set lfSubmits true` to keep LF as submit. [Composer details](COMMANDS.md#aether--interactive-repl).
153153

154+
Ctrl+R searches this workspace's submitted prompts newest first. Type to narrow
155+
matches, repeat Ctrl+R for older ones, Enter to insert a match for editing, or
156+
Escape to restore your draft. `AETHER_NO_HISTORY=1` disables prompt history.
157+
154158
Type `/` at an idle raw terminal prompt to browse described commands. Use
155159
arrows or Tab to choose, Enter to insert editable command text, then Enter
156160
again to run it; Escape restores your earlier draft.

‎src/commands/chat.ts‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import { promptPrefix } from "../ui/prompt.js";
3838
import { InputBuffer } from "../ui/input_line.js";
3939
import { renderInputView } from "../ui/input_render.js";
4040
import { decodeKey, splitKeys } from "../ui/keys.js";
41+
import {
42+
backspaceHistoryQuery, olderHistoryMatch, openHistorySearch, renderHistorySearch,
43+
selectedHistoryMatch, typeHistoryQuery, type HistorySearchState,
44+
} from "../ui/history_search.js";
4145
import { ThinkingPulse } from "../ui/thinking.js";
4246
import { ModelTextProgress } from "../core/model_text_progress.js";
4347
import { ModelOutputBudget } from "../core/model_output_budget.js";
@@ -1314,6 +1318,8 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
13141318
let terminalOwnsInput = false;
13151319
let approvalOwnsInput = false;
13161320
let picker: SlashPickerState | null = null;
1321+
let historySearch: HistorySearchState | null = null;
1322+
let searchPriorPicker: SlashPickerState | null = null;
13171323
let pickerPanelLines = 0;
13181324
let pickerSuppressed = false;
13191325
const composerCtx: AppContext = {
@@ -1348,8 +1354,10 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
13481354
if (pickerPanelLines) process.stdout.write(viewerClearSequence(pickerPanelLines));
13491355
pickerPanelLines = 0;
13501356
if (!buf.value) pickerSuppressed = false;
1351-
if (picker) {
1352-
const lines = renderSlashPicker(picker, buf.value, process.stdout.columns ?? 80, process.stdout.rows ?? 24);
1357+
if (picker || historySearch) {
1358+
const lines = historySearch
1359+
? renderHistorySearch(historySearch, process.stdout.columns ?? 80, process.stdout.rows ?? 24)
1360+
: renderSlashPicker(picker!, buf.value, process.stdout.columns ?? 80, process.stdout.rows ?? 24);
13531361
if (lines.length) {
13541362
process.stdout.write("\r\x1b[2K" + lines.join("\n") + "\n");
13551363
pickerPanelLines = lines.length;
@@ -1421,7 +1429,15 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
14211429
let viewerLastLines = 0;
14221430
return await new Promise<number>((resolve) => {
14231431
const onResize = (): void => {
1424-
if ((process.stdout.rows ?? 24) < 3) picker = null;
1432+
if ((process.stdout.rows ?? 24) < 3) {
1433+
picker = null;
1434+
searchPriorPicker = null;
1435+
}
1436+
if ((process.stdout.rows ?? 24) < 2 && historySearch) {
1437+
buf.restoreDraft(historySearch.priorValue, historySearch.priorCursor);
1438+
historySearch = null;
1439+
searchPriorPicker = null;
1440+
}
14251441
repaint();
14261442
};
14271443
const cleanup = (): void => {
@@ -1725,6 +1741,8 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
17251741

17261742
const onCtrlC = (): void => {
17271743
picker = null;
1744+
historySearch = null;
1745+
searchPriorPicker = null;
17281746
const now = Date.now();
17291747
const armed = now - ctrlCArmedAt <= CTRL_C_WINDOW_MS && ctrlCArmedAt > 0;
17301748
const active = turnAbort ?? slashAbort;
@@ -2036,10 +2054,9 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
20362054
};
20372055

20382056
// Input-owner precedence: PTY/approval/setup are gated in onData; paste
2039-
// owns literal bytes; while idle, the slash picker owns Enter/Escape and
2040-
// selection keys before the workflow viewer or history. Dismissal returns
2041-
// those keys to their prior owners. Acceptance edits only the composer;
2042-
// a second, explicit Enter invokes the command.
2057+
// owns literal bytes. Idle search owns query/Enter/Escape before the slash
2058+
// picker, which in turn owns selection keys before viewer/history. Both
2059+
// overlays only edit the composer; a later Enter submits a draft.
20432060
const dismissPicker = (restore: boolean): void => {
20442061
if (!picker) return;
20452062
if (restore) buf.restoreDraft(picker.priorValue, picker.priorCursor);
@@ -2075,6 +2092,50 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
20752092
return;
20762093
}
20772094
const k = decodeKey(seq, ctx.cfg.lfSubmits);
2095+
if (k.kind === "history-search") {
2096+
if (busy || viewerOpen || (process.stdout.rows ?? 24) < 2) return;
2097+
if (historySearch) historySearch = olderHistoryMatch(historySearch);
2098+
else {
2099+
searchPriorPicker = picker;
2100+
picker = null;
2101+
buf.endRecoveryScope();
2102+
const enabled = historyEnabled();
2103+
historySearch = openHistorySearch(enabled ? buf.historyEntries() : [], buf.value, buf.pos, !enabled);
2104+
}
2105+
repaint();
2106+
return;
2107+
}
2108+
if (historySearch) {
2109+
switch (k.kind) {
2110+
case "char": historySearch = typeHistoryQuery(historySearch, k.value); repaint(); return;
2111+
case "backspace": historySearch = backspaceHistoryQuery(historySearch); repaint(); return;
2112+
case "submit": {
2113+
const match = selectedHistoryMatch(historySearch);
2114+
if (match === null) { repaint(); return; }
2115+
historySearch = null;
2116+
searchPriorPicker = null;
2117+
picker = null;
2118+
buf.replace(match);
2119+
repaint();
2120+
return;
2121+
}
2122+
case "escape":
2123+
buf.restoreDraft(historySearch.priorValue, historySearch.priorCursor);
2124+
historySearch = null;
2125+
picker = searchPriorPicker;
2126+
searchPriorPicker = null;
2127+
repaint();
2128+
return;
2129+
case "paste-start":
2130+
buf.restoreDraft(historySearch.priorValue, historySearch.priorCursor);
2131+
historySearch = null;
2132+
searchPriorPicker = null;
2133+
break; // paste is owned by the composer below
2134+
case "clear-screen":
2135+
process.stdout.write("\x1b[2J\x1b[H"); repaint(); return;
2136+
default: return;
2137+
}
2138+
}
20782139
switch (k.kind) {
20792140
case "paste-start":
20802141
picker = null;

‎src/ui/history_search.ts‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Pure reverse search over prompt history already loaded for one workspace.
2+
// It never reads storage or runs a model/process. Entries remain complete for
3+
// acceptance; only the examination and terminal display are bounded.
4+
import { HISTORY_CAP } from "../core/history_store.js";
5+
import { sliceVisible, visibleWidth } from "./text.js";
6+
7+
export const HISTORY_SEARCH_BOUNDS = {
8+
entryBytes: 16 * 1024,
9+
totalBytes: 1024 * 1024,
10+
queryBytes: 256,
11+
displayChars: 512,
12+
} as const;
13+
14+
export interface HistorySearchState {
15+
readonly entries: readonly string[];
16+
readonly priorValue: string;
17+
readonly priorCursor: number;
18+
readonly query: string;
19+
readonly matches: readonly number[];
20+
readonly selected: number;
21+
readonly skippedOversize: number;
22+
readonly skippedBudget: number;
23+
readonly disabled: boolean;
24+
}
25+
26+
function search(state: HistorySearchState, query: string): HistorySearchState {
27+
const matches: number[] = [];
28+
let examined = 0;
29+
let skippedOversize = 0;
30+
let skippedBudget = 0;
31+
const countRemainder = (through: number): void => {
32+
for (let i = through; i >= 0; i--) {
33+
if (state.entries[i]!.length > HISTORY_SEARCH_BOUNDS.entryBytes) skippedOversize++;
34+
else skippedBudget++;
35+
}
36+
};
37+
for (let index = state.entries.length - 1; index >= 0; index--) {
38+
const entry = state.entries[index]!;
39+
// Check the JS length first so a hostile giant entry is never traversed
40+
// just to calculate its byte size. Shorter Unicode entries get a byte cap.
41+
if (entry.length > HISTORY_SEARCH_BOUNDS.entryBytes) { skippedOversize++; continue; }
42+
if (examined + entry.length > HISTORY_SEARCH_BOUNDS.totalBytes) {
43+
countRemainder(index);
44+
break;
45+
}
46+
const bytes = Buffer.byteLength(entry, "utf8");
47+
if (bytes > HISTORY_SEARCH_BOUNDS.entryBytes) { skippedOversize++; continue; }
48+
if (examined + bytes > HISTORY_SEARCH_BOUNDS.totalBytes) {
49+
countRemainder(index);
50+
break;
51+
}
52+
examined += bytes;
53+
if (entry.includes(query)) matches.push(index);
54+
}
55+
return { ...state, query, matches, selected: 0, skippedOversize, skippedBudget };
56+
}
57+
58+
/** Empty query selects the newest examinable entry, then Ctrl+R walks older. */
59+
export function openHistorySearch(entries: readonly string[], priorValue: string, priorCursor: number, disabled = false): HistorySearchState {
60+
return search({ entries: disabled ? [] : entries.slice(-HISTORY_CAP), priorValue, priorCursor,
61+
query: "", matches: [], selected: 0, skippedOversize: 0, skippedBudget: 0, disabled }, "");
62+
}
63+
64+
export function typeHistoryQuery(state: HistorySearchState, input: string): HistorySearchState {
65+
let query = state.query;
66+
for (const ch of input) {
67+
if (Buffer.byteLength(query + ch, "utf8") > HISTORY_SEARCH_BOUNDS.queryBytes) break;
68+
query += ch;
69+
}
70+
return search(state, query);
71+
}
72+
73+
export function backspaceHistoryQuery(state: HistorySearchState): HistorySearchState {
74+
return search(state, [...state.query].slice(0, -1).join(""));
75+
}
76+
77+
/** Stops at the oldest match; it does not wrap invisibly. */
78+
export function olderHistoryMatch(state: HistorySearchState): HistorySearchState {
79+
return { ...state, selected: Math.min(state.selected + 1, Math.max(0, state.matches.length - 1)) };
80+
}
81+
82+
export function selectedHistoryMatch(state: HistorySearchState): string | null {
83+
const index = state.matches[state.selected];
84+
return index === undefined ? null : state.entries[index] ?? null;
85+
}
86+
87+
function safeDisplay(value: string): string {
88+
return value.replace(/\r\n|\r|\n/g, "⏎").replace(/[\x00-\x1f\x7f]/g, "�");
89+
}
90+
91+
function matchPreview(value: string, width: number): string {
92+
const chars = [...safeDisplay(value)];
93+
const capped = chars.slice(0, HISTORY_SEARCH_BOUNDS.displayChars).join("");
94+
if (chars.length <= HISTORY_SEARCH_BOUNDS.displayChars && visibleWidth(capped) <= width) return capped;
95+
const marker = "… [clipped]";
96+
return sliceVisible(capped, Math.max(0, width - visibleWidth(marker))) + marker;
97+
}
98+
99+
/** At most two terminal rows, with an explicit clipped/skipped indication. */
100+
export function renderHistorySearch(state: HistorySearchState, cols: number, rows: number): string[] {
101+
if (rows < 2) return [];
102+
const width = Math.max(1, cols - 1);
103+
const query = safeDisplay(state.query) || "(newest)";
104+
const selected = selectedHistoryMatch(state);
105+
const status = state.disabled ? "history disabled" : selected === null ? "no match" : `${state.selected + 1}/${state.matches.length}`;
106+
const skipped = state.skippedOversize + state.skippedBudget;
107+
const note = skipped ? `skipped ${skipped} (${state.skippedOversize} oversized, ${state.skippedBudget} budget) · ` : "";
108+
const header = ` Ctrl+R ${note}${query} · ${status}`;
109+
if (rows === 2) {
110+
const compact = `R ${sliceVisible(query, Math.max(1, Math.floor(width / 4)))} ${status}${skipped ? ` skip ${skipped}` : ""} · `;
111+
if (selected === null) return [sliceVisible(compact + "no match", width)];
112+
return [sliceVisible(compact + matchPreview(selected, Math.max(0, width - visibleWidth(compact))), width)];
113+
}
114+
if (selected === null) return [sliceVisible(header, width), sliceVisible(" No matching prompts · Esc restores draft", width)];
115+
const shown = `> ${matchPreview(selected, Math.max(0, width - 2))}`;
116+
return [sliceVisible(header, width), sliceVisible(shown, width)];
117+
}

‎src/ui/input_line.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// History semantics: consecutive duplicates collapse, recalling history
55
// stashes the in-progress draft (restored when you arrow back down), and the
66
// REPL persists entries across sessions via core/history_store.
7+
import { HISTORY_CAP } from "../core/history_store.js";
78

89
interface EditSnapshot {
910
value: string;
@@ -199,15 +200,18 @@ export class InputBuffer {
199200
commit(line: string): void {
200201
if (line.trim() && this.history[this.history.length - 1] !== line) {
201202
this.history.push(line);
203+
if (this.history.length > HISTORY_CAP) this.history.shift();
202204
}
203205
this.clear();
204206
}
205207
/** Seed history from a persisted store (oldest first). */
206208
loadHistory(lines: readonly string[]): void {
207-
this.history = [...lines];
209+
this.history = lines.slice(-HISTORY_CAP);
208210
this.histIdx = -1;
209211
this.endRecoveryScope();
210212
}
213+
/** Search reads only the bounded, already loaded workspace prompt entries. */
214+
historyEntries(): readonly string[] { return this.history.slice(); }
211215
historyUp(): void {
212216
if (this.history.length === 0) return;
213217
if (this.histIdx === 0) return;

‎src/ui/keys.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type Key =
88
| { kind: "newline" }
99
| { kind: "undo" }
1010
| { kind: "yank" }
11+
| { kind: "history-search" }
1112
| { kind: "backspace" }
1213
| { kind: "interrupt" }
1314
| { kind: "eof" }
@@ -85,6 +86,8 @@ export function decodeKey(seq: string, lfSubmits = false): Key {
8586
return { kind: "undo" }; // ctrl-_
8687
case "\x19":
8788
return { kind: "yank" }; // ctrl-y
89+
case "\x12":
90+
return { kind: "history-search" }; // ctrl-r
8891
case "\x7f":
8992
case "\b":
9093
return { kind: "backspace" };

0 commit comments

Comments
 (0)