diff --git a/COMMANDS.md b/COMMANDS.md index 4a8a127..eec2f3e 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -75,8 +75,8 @@ These apply to any command (parsed anywhere on the line). ### `aether` — interactive REPL Opens a session. Type a prompt to chat; type `/` commands to control it (see -[Slash commands](#slash-commands)). Up-arrow recalls prompts across sessions -(history lives at `~/.aether-agent/history`). +[Slash commands](#slash-commands)). Up-arrow recalls submitted prompts from +this workspace (stored under `~/.aether-agent/history.d/`). `Ctrl-C` mid-answer cancels the turn and keeps the session; `Ctrl-C` at an empty prompt (or `/exit`) leaves. @@ -87,6 +87,19 @@ one step; a bracketed paste is one step). Ctrl+Y restores the last text killed with Ctrl+K, Ctrl+U, or Ctrl+W. Submitting or clearing a draft ends its undo and yank history. Non-TTY input stays line-oriented. +Ctrl+R searches this workspace's loaded prompt history. An empty query selects +the newest entry; type to narrow the matches and press Ctrl+R again to move +older without wrapping. The panel shows the query, selected prompt, and a +no-match state. Enter inserts the complete stored prompt for editing without +sending it; Escape restores the exact prior draft and cursor. Search examines +at most 1,000 loaded entries, 16 KiB per entry and 1 MiB total per query update. +Oversized or over-budget entries are skipped and counted; displayed matches +are clipped to 512 characters and terminal width without clipping the prompt +inserted on Enter. `AETHER_NO_HISTORY=1` disables loading and saving history. +Stored prompts are not automatically filtered for secrets. Shell commands, +tool output, and approval feedback are not added to prompt history. Search +is available only when the idle raw composer owns input. + Some terminals send LF for Enter as well as Ctrl+J, so those two keys cannot be distinguished there. Use `aether config set lfSubmits true` to make LF 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` | `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. | | `AETHER_STREAM_TIMEOUT_MS` | `120000` | Stream open/idle timeout (ms). `0` disables it. | | `AETHER_NO_ANIM` | *(unset)* | `1` disables all animated status lines and the thinking pulse. | +| `AETHER_NO_HISTORY` | *(unset)* | `1` disables workspace prompt-history loading and saving, including Ctrl+R search. | | `NO_COLOR` | *(unset)* | Any value disables ANSI colors (https://no-color.org). | See [`.env.example`](.env.example). diff --git a/README.md b/README.md index bf01ea7..a88622e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,10 @@ undoes recent edits, and Ctrl+Y restores killed text. The single-row input shows newlines as `⏎` with a line count. If your terminal sends LF for Enter, set `aether config set lfSubmits true` to keep LF as submit. [Composer details](COMMANDS.md#aether--interactive-repl). +Ctrl+R searches this workspace's submitted prompts newest first. Type to narrow +matches, repeat Ctrl+R for older ones, Enter to insert a match for editing, or +Escape to restore your draft. `AETHER_NO_HISTORY=1` disables prompt history. + Type `/` at an idle raw terminal prompt to browse described commands. Use arrows or Tab to choose, Enter to insert editable command text, then Enter again to run it; Escape restores your earlier draft. diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 550a7a8..8bf4085 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -38,6 +38,10 @@ import { promptPrefix } from "../ui/prompt.js"; import { InputBuffer } from "../ui/input_line.js"; import { renderInputView } from "../ui/input_render.js"; import { decodeKey, splitKeys } from "../ui/keys.js"; +import { + backspaceHistoryQuery, olderHistoryMatch, openHistorySearch, renderHistorySearch, + selectedHistoryMatch, typeHistoryQuery, type HistorySearchState, +} from "../ui/history_search.js"; import { ThinkingPulse } from "../ui/thinking.js"; import { ModelTextProgress } from "../core/model_text_progress.js"; import { ModelOutputBudget } from "../core/model_output_budget.js"; @@ -1314,6 +1318,8 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P let terminalOwnsInput = false; let approvalOwnsInput = false; let picker: SlashPickerState | null = null; + let historySearch: HistorySearchState | null = null; + let searchPriorPicker: SlashPickerState | null = null; let pickerPanelLines = 0; let pickerSuppressed = false; const composerCtx: AppContext = { @@ -1348,8 +1354,10 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P if (pickerPanelLines) process.stdout.write(viewerClearSequence(pickerPanelLines)); pickerPanelLines = 0; if (!buf.value) pickerSuppressed = false; - if (picker) { - const lines = renderSlashPicker(picker, buf.value, process.stdout.columns ?? 80, process.stdout.rows ?? 24); + if (picker || historySearch) { + const lines = historySearch + ? renderHistorySearch(historySearch, process.stdout.columns ?? 80, process.stdout.rows ?? 24) + : renderSlashPicker(picker!, buf.value, process.stdout.columns ?? 80, process.stdout.rows ?? 24); if (lines.length) { process.stdout.write("\r\x1b[2K" + lines.join("\n") + "\n"); pickerPanelLines = lines.length; @@ -1421,7 +1429,15 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P let viewerLastLines = 0; return await new Promise((resolve) => { const onResize = (): void => { - if ((process.stdout.rows ?? 24) < 3) picker = null; + if ((process.stdout.rows ?? 24) < 3) { + picker = null; + searchPriorPicker = null; + } + if ((process.stdout.rows ?? 24) < 2 && historySearch) { + buf.restoreDraft(historySearch.priorValue, historySearch.priorCursor); + historySearch = null; + searchPriorPicker = null; + } repaint(); }; const cleanup = (): void => { @@ -1725,6 +1741,8 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P const onCtrlC = (): void => { picker = null; + historySearch = null; + searchPriorPicker = null; const now = Date.now(); const armed = now - ctrlCArmedAt <= CTRL_C_WINDOW_MS && ctrlCArmedAt > 0; const active = turnAbort ?? slashAbort; @@ -2036,10 +2054,9 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P }; // Input-owner precedence: PTY/approval/setup are gated in onData; paste - // owns literal bytes; while idle, the slash picker owns Enter/Escape and - // selection keys before the workflow viewer or history. Dismissal returns - // those keys to their prior owners. Acceptance edits only the composer; - // a second, explicit Enter invokes the command. + // owns literal bytes. Idle search owns query/Enter/Escape before the slash + // picker, which in turn owns selection keys before viewer/history. Both + // overlays only edit the composer; a later Enter submits a draft. const dismissPicker = (restore: boolean): void => { if (!picker) return; if (restore) buf.restoreDraft(picker.priorValue, picker.priorCursor); @@ -2075,6 +2092,50 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P return; } const k = decodeKey(seq, ctx.cfg.lfSubmits); + if (k.kind === "history-search") { + if (busy || viewerOpen || (process.stdout.rows ?? 24) < 2) return; + if (historySearch) historySearch = olderHistoryMatch(historySearch); + else { + searchPriorPicker = picker; + picker = null; + buf.endRecoveryScope(); + const enabled = historyEnabled(); + historySearch = openHistorySearch(enabled ? buf.historyEntries() : [], buf.value, buf.pos, !enabled); + } + repaint(); + return; + } + if (historySearch) { + switch (k.kind) { + case "char": historySearch = typeHistoryQuery(historySearch, k.value); repaint(); return; + case "backspace": historySearch = backspaceHistoryQuery(historySearch); repaint(); return; + case "submit": { + const match = selectedHistoryMatch(historySearch); + if (match === null) { repaint(); return; } + historySearch = null; + searchPriorPicker = null; + picker = null; + buf.replace(match); + repaint(); + return; + } + case "escape": + buf.restoreDraft(historySearch.priorValue, historySearch.priorCursor); + historySearch = null; + picker = searchPriorPicker; + searchPriorPicker = null; + repaint(); + return; + case "paste-start": + buf.restoreDraft(historySearch.priorValue, historySearch.priorCursor); + historySearch = null; + searchPriorPicker = null; + break; // paste is owned by the composer below + case "clear-screen": + process.stdout.write("\x1b[2J\x1b[H"); repaint(); return; + default: return; + } + } switch (k.kind) { case "paste-start": picker = null; diff --git a/src/ui/history_search.ts b/src/ui/history_search.ts new file mode 100644 index 0000000..98e02fd --- /dev/null +++ b/src/ui/history_search.ts @@ -0,0 +1,117 @@ +// Pure reverse search over prompt history already loaded for one workspace. +// It never reads storage or runs a model/process. Entries remain complete for +// acceptance; only the examination and terminal display are bounded. +import { HISTORY_CAP } from "../core/history_store.js"; +import { sliceVisible, visibleWidth } from "./text.js"; + +export const HISTORY_SEARCH_BOUNDS = { + entryBytes: 16 * 1024, + totalBytes: 1024 * 1024, + queryBytes: 256, + displayChars: 512, +} as const; + +export interface HistorySearchState { + readonly entries: readonly string[]; + readonly priorValue: string; + readonly priorCursor: number; + readonly query: string; + readonly matches: readonly number[]; + readonly selected: number; + readonly skippedOversize: number; + readonly skippedBudget: number; + readonly disabled: boolean; +} + +function search(state: HistorySearchState, query: string): HistorySearchState { + const matches: number[] = []; + let examined = 0; + let skippedOversize = 0; + let skippedBudget = 0; + const countRemainder = (through: number): void => { + for (let i = through; i >= 0; i--) { + if (state.entries[i]!.length > HISTORY_SEARCH_BOUNDS.entryBytes) skippedOversize++; + else skippedBudget++; + } + }; + for (let index = state.entries.length - 1; index >= 0; index--) { + const entry = state.entries[index]!; + // Check the JS length first so a hostile giant entry is never traversed + // just to calculate its byte size. Shorter Unicode entries get a byte cap. + if (entry.length > HISTORY_SEARCH_BOUNDS.entryBytes) { skippedOversize++; continue; } + if (examined + entry.length > HISTORY_SEARCH_BOUNDS.totalBytes) { + countRemainder(index); + break; + } + const bytes = Buffer.byteLength(entry, "utf8"); + if (bytes > HISTORY_SEARCH_BOUNDS.entryBytes) { skippedOversize++; continue; } + if (examined + bytes > HISTORY_SEARCH_BOUNDS.totalBytes) { + countRemainder(index); + break; + } + examined += bytes; + if (entry.includes(query)) matches.push(index); + } + return { ...state, query, matches, selected: 0, skippedOversize, skippedBudget }; +} + +/** Empty query selects the newest examinable entry, then Ctrl+R walks older. */ +export function openHistorySearch(entries: readonly string[], priorValue: string, priorCursor: number, disabled = false): HistorySearchState { + return search({ entries: disabled ? [] : entries.slice(-HISTORY_CAP), priorValue, priorCursor, + query: "", matches: [], selected: 0, skippedOversize: 0, skippedBudget: 0, disabled }, ""); +} + +export function typeHistoryQuery(state: HistorySearchState, input: string): HistorySearchState { + let query = state.query; + for (const ch of input) { + if (Buffer.byteLength(query + ch, "utf8") > HISTORY_SEARCH_BOUNDS.queryBytes) break; + query += ch; + } + return search(state, query); +} + +export function backspaceHistoryQuery(state: HistorySearchState): HistorySearchState { + return search(state, [...state.query].slice(0, -1).join("")); +} + +/** Stops at the oldest match; it does not wrap invisibly. */ +export function olderHistoryMatch(state: HistorySearchState): HistorySearchState { + return { ...state, selected: Math.min(state.selected + 1, Math.max(0, state.matches.length - 1)) }; +} + +export function selectedHistoryMatch(state: HistorySearchState): string | null { + const index = state.matches[state.selected]; + return index === undefined ? null : state.entries[index] ?? null; +} + +function safeDisplay(value: string): string { + return value.replace(/\r\n|\r|\n/g, "⏎").replace(/[\x00-\x1f\x7f]/g, "�"); +} + +function matchPreview(value: string, width: number): string { + const chars = [...safeDisplay(value)]; + const capped = chars.slice(0, HISTORY_SEARCH_BOUNDS.displayChars).join(""); + if (chars.length <= HISTORY_SEARCH_BOUNDS.displayChars && visibleWidth(capped) <= width) return capped; + const marker = "… [clipped]"; + return sliceVisible(capped, Math.max(0, width - visibleWidth(marker))) + marker; +} + +/** At most two terminal rows, with an explicit clipped/skipped indication. */ +export function renderHistorySearch(state: HistorySearchState, cols: number, rows: number): string[] { + if (rows < 2) return []; + const width = Math.max(1, cols - 1); + const query = safeDisplay(state.query) || "(newest)"; + const selected = selectedHistoryMatch(state); + const status = state.disabled ? "history disabled" : selected === null ? "no match" : `${state.selected + 1}/${state.matches.length}`; + const skipped = state.skippedOversize + state.skippedBudget; + const note = skipped ? `skipped ${skipped} (${state.skippedOversize} oversized, ${state.skippedBudget} budget) · ` : ""; + const header = ` Ctrl+R ${note}${query} · ${status}`; + if (rows === 2) { + const compact = `R ${sliceVisible(query, Math.max(1, Math.floor(width / 4)))} ${status}${skipped ? ` skip ${skipped}` : ""} · `; + if (selected === null) return [sliceVisible(compact + "no match", width)]; + return [sliceVisible(compact + matchPreview(selected, Math.max(0, width - visibleWidth(compact))), width)]; + } + if (selected === null) return [sliceVisible(header, width), sliceVisible(" No matching prompts · Esc restores draft", width)]; + const shown = `> ${matchPreview(selected, Math.max(0, width - 2))}`; + return [sliceVisible(header, width), sliceVisible(shown, width)]; +} diff --git a/src/ui/input_line.ts b/src/ui/input_line.ts index 321aa4d..42d4f2f 100644 --- a/src/ui/input_line.ts +++ b/src/ui/input_line.ts @@ -4,6 +4,7 @@ // History semantics: consecutive duplicates collapse, recalling history // stashes the in-progress draft (restored when you arrow back down), and the // REPL persists entries across sessions via core/history_store. +import { HISTORY_CAP } from "../core/history_store.js"; interface EditSnapshot { value: string; @@ -199,15 +200,18 @@ export class InputBuffer { commit(line: string): void { if (line.trim() && this.history[this.history.length - 1] !== line) { this.history.push(line); + if (this.history.length > HISTORY_CAP) this.history.shift(); } this.clear(); } /** Seed history from a persisted store (oldest first). */ loadHistory(lines: readonly string[]): void { - this.history = [...lines]; + this.history = lines.slice(-HISTORY_CAP); this.histIdx = -1; this.endRecoveryScope(); } + /** Search reads only the bounded, already loaded workspace prompt entries. */ + historyEntries(): readonly string[] { return this.history.slice(); } historyUp(): void { if (this.history.length === 0) return; if (this.histIdx === 0) return; diff --git a/src/ui/keys.ts b/src/ui/keys.ts index 83aa558..d9cd35e 100644 --- a/src/ui/keys.ts +++ b/src/ui/keys.ts @@ -8,6 +8,7 @@ export type Key = | { kind: "newline" } | { kind: "undo" } | { kind: "yank" } + | { kind: "history-search" } | { kind: "backspace" } | { kind: "interrupt" } | { kind: "eof" } @@ -85,6 +86,8 @@ export function decodeKey(seq: string, lfSubmits = false): Key { return { kind: "undo" }; // ctrl-_ case "\x19": return { kind: "yank" }; // ctrl-y + case "\x12": + return { kind: "history-search" }; // ctrl-r case "\x7f": case "\b": return { kind: "backspace" }; diff --git a/test/composer_tty.test.ts b/test/composer_tty.test.ts index f4f7429..0e73991 100644 --- a/test/composer_tty.test.ts +++ b/test/composer_tty.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { repl } from "../src/commands/chat.js"; import { ApiClient } from "../src/core/transport.js"; import { DEFAULT_CONFIG } from "../src/core/config.js"; -import { historyPath } from "../src/core/history_store.js"; +import { appendHistory, historyPath } from "../src/core/history_store.js"; import type { AppContext } from "../src/core/context.js"; import type { TokenStore } from "../src/core/auth.js"; @@ -16,7 +16,7 @@ async function withComposer(lfSubmits: boolean, run: (h: { until: (text: string) => Promise; modelCalls: () => number; resize: (cols: number, rows: number) => void; -}) => Promise): Promise { +}) => Promise, options: { history?: string[]; noHistory?: boolean } = {}): Promise { const root = mkdtempSync(join(tmpdir(), "aether-composer-")); const oldFetch = globalThis.fetch; const oldWrite = process.stdout.write; @@ -25,6 +25,7 @@ async function withComposer(lfSubmits: boolean, run: (h: { const raw = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); const columns = Object.getOwnPropertyDescriptor(process.stdout, "columns"); const rows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); + const oldNoHistory = process.env["AETHER_NO_HISTORY"]; let output = ""; let modelCalls = 0; let pending: Promise | null = null; @@ -43,6 +44,9 @@ async function withComposer(lfSubmits: boolean, run: (h: { } }; try { + for (const entry of options.history ?? []) appendHistory(entry, historyPath(root)); + if (options.noHistory) process.env["AETHER_NO_HISTORY"] = "1"; + else delete process.env["AETHER_NO_HISTORY"]; Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stdin, "setRawMode", { value: () => process.stdin, configurable: true }); Object.defineProperty(process.stdout, "columns", { value: 80, configurable: true }); @@ -67,6 +71,8 @@ async function withComposer(lfSubmits: boolean, run: (h: { pending = null; assert.equal(modelCalls, 0); } finally { + if (oldNoHistory === undefined) delete process.env["AETHER_NO_HISTORY"]; + else process.env["AETHER_NO_HISTORY"] = oldNoHistory; if (pending) { key("\x03\x03\x04"); await Promise.race([pending.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1_000))]); @@ -164,3 +170,49 @@ test("picker selection, Escape, history, paste and resize retain their input own await until("unknown command: /mo"); }); }); + +test("Ctrl+R searches workspace prompts, restores drafts, and switches cleanly with the slash picker", async () => { + await withComposer(false, async ({ key, output, modelCalls, resize }) => { + resize(160, 24); + key("draft"); + key("\x1b[D\x1b[D"); // caret after dra + key("\x12"); + assert.match(output().slice(-500), /Ctrl\+R.*newest/); + key("match"); + assert.match(output().slice(-500), /1\/2\n> match newer/); + key("\x12"); + assert.match(output().slice(-500), /2\/2\n> match older/); + key("\x1b"); + key("X"); + assert.match(output().slice(output().lastIndexOf("\r\x1b[2K")), /draXft/); + + key("\x12match\r"); // acceptance edits the composer, not the model + assert.match(output().slice(output().lastIndexOf("\r\x1b[2K")), /match newer/); + assert.equal(modelCalls(), 0); + key("\x1f"); // only the accepted edit is undone, not older draft edits + assert.match(output().slice(output().lastIndexOf("\r\x1b[2K")), /draXft/); + + key("\x05\x15/mo"); + assert.match(output().slice(-500), /> \/model/); + key("\x12"); // search takes ownership from picker + assert.match(output().slice(-500), /Ctrl\+R/); + key("\x1b"); // restore /mo and picker selection + assert.match(output().slice(-500), /> \/model/); + key("\x12/btw\r"); // accept a slash prompt without executing it + assert.match(output().slice(output().lastIndexOf("\r\x1b[2K")), /\/btw archived/); + key("\t"); // picker can be opened again on accepted slash text + assert.match(output().slice(-500), /> \/btw/); + key("\x1b"); + assert.equal(modelCalls(), 0); + }, { history: ["match older", "/btw archived", "match newer"] }); +}); + +test("Ctrl+R honors AETHER_NO_HISTORY and keeps disabled entries unseen", async () => { + await withComposer(false, async ({ key, output, modelCalls }) => { + key("\x12"); + assert.match(output().slice(-500), /history disabled/); + assert.doesNotMatch(output().slice(-500), /stored secret/); + key("\x1b"); + assert.equal(modelCalls(), 0); + }, { history: ["stored secret"], noHistory: true }); +}); diff --git a/test/console_queue_tty.test.ts b/test/console_queue_tty.test.ts index e41a538..5cb63e7 100644 --- a/test/console_queue_tty.test.ts +++ b/test/console_queue_tty.test.ts @@ -104,6 +104,27 @@ async function withConsole(run: (h: Harness) => Promise): Promise { const shellResults = (output: string): number => (output.match(/"type":"shell_result"/g) ?? []).length; +test("Ctrl+R does not take input ownership during a streaming turn; typeahead remains a draft", async () => { + await withConsole(async ({ bodies, output, submit, type, until, answer, waitForCall }) => { + submit("stored prompt"); + await answer(1); + await until(() => (output().match(/"type":"turn_outcome"/g) ?? []).length >= 1, "first turn completed"); + submit("active prompt"); + await waitForCall(2); + const before = output().length; + type("\x12queued draft"); + assert.doesNotMatch(output().slice(before), /Ctrl\+R/); + assert.equal(bodies.length, 2); + await answer(2); + await until(() => (output().match(/"type":"turn_outcome"/g) ?? []).length >= 2, "second turn completed"); + type("\r"); + await waitForCall(3); + assert.match(bodies[2]!, /queued draft/); + await answer(3); + await until(() => (output().match(/"type":"turn_outcome"/g) ?? []).length >= 3, "typeahead turn completed"); + }); +}); + test("TTY: list, edit and remove mixed queued entries while a turn streams; removed entries make zero calls", async () => { await withConsole(async ({ root, bodies, output, submit, type, until, answer, waitForCall }) => { submit("first question"); diff --git a/test/history_search.test.ts b/test/history_search.test.ts new file mode 100644 index 0000000..57dd242 --- /dev/null +++ b/test/history_search.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + HISTORY_SEARCH_BOUNDS, backspaceHistoryQuery, olderHistoryMatch, openHistorySearch, + renderHistorySearch, selectedHistoryMatch, typeHistoryQuery, +} from "../src/ui/history_search.js"; + +test("reverse search is newest-first, keeps duplicates, and stops at the oldest match", () => { + let state = openHistorySearch(["écho\nline", "other", "écho\nline"], "draft", 2); + assert.equal(selectedHistoryMatch(state), "écho\nline"); // empty query picks newest + state = typeHistoryQuery(state, "écho"); + assert.deepEqual(state.matches, [2, 0]); + state = olderHistoryMatch(state); + assert.equal(state.selected, 1); + state = olderHistoryMatch(state); + assert.equal(state.selected, 1); + state = backspaceHistoryQuery(state); + assert.equal(state.query, "éch"); + assert.equal(state.selected, 0); + assert.match(renderHistorySearch(state, 80, 24).join("\n"), /écho⏎line/); +}); + +test("no match and disabled history are visible without reading or changing drafts", () => { + const state = typeHistoryQuery(openHistorySearch(["hello"], "kept", 1), "absent"); + assert.equal(selectedHistoryMatch(state), null); + assert.match(renderHistorySearch(state, 80, 24).join("\n"), /no match|No matching prompts/); + const disabled = openHistorySearch(["hidden"], "draft", 3, true); + assert.equal(selectedHistoryMatch(disabled), null); + assert.match(renderHistorySearch(disabled, 80, 24).join("\n"), /history disabled/); + assert.equal(disabled.priorValue, "draft"); + assert.equal(disabled.priorCursor, 3); +}); + +test("oversized entries are skipped, work is bounded, and clipped display retains the full match", () => { + const large = "L".repeat(HISTORY_SEARCH_BOUNDS.entryBytes + 1) + "needle"; + const full = "needle " + "🛰".repeat(300); + const many = Array.from({ length: 80 }, (_, i) => `entry ${i} ` + "x".repeat(15_000)); + const state = typeHistoryQuery(openHistorySearch([large, ...many, full], "", 0), "needle"); + assert.equal(state.skippedOversize, 1); + assert.ok(state.skippedBudget > 0); + assert.equal(selectedHistoryMatch(state), full); + const rendered = renderHistorySearch(state, 160, 24).join("\n"); + assert.match(rendered, /skipped/); + assert.match(rendered, /\[clipped\]/); + assert.ok(rendered.split("\n").every(line => [...line].length <= 160)); + assert.match(renderHistorySearch(state, 80, 2)[0]!, /\[clipped\]/); + const capped = typeHistoryQuery(state, "q".repeat(1000)); + assert.ok(Buffer.byteLength(capped.query, "utf8") <= HISTORY_SEARCH_BOUNDS.queryBytes); +}); diff --git a/test/history_store.test.ts b/test/history_store.test.ts index c62852b..cc6cd66 100644 --- a/test/history_store.test.ts +++ b/test/history_store.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadHistory, appendHistory } from "../src/core/history_store.js"; +import { loadHistory, appendHistory, historyPath } from "../src/core/history_store.js"; const fresh = (): string => join(mkdtempSync(join(tmpdir(), "aether-hist-")), "history"); @@ -16,6 +16,24 @@ test("round-trips entries oldest-first and survives a missing file", () => { rmSync(join(p, ".."), { recursive: true, force: true }); }); +test("workspace paths load separate prompt histories", () => { + const first = mkdtempSync(join(tmpdir(), "aether-hist-workspace-")); + const second = mkdtempSync(join(tmpdir(), "aether-hist-workspace-")); + try { + const firstPath = historyPath(first); + const secondPath = historyPath(second); + assert.notEqual(firstPath, secondPath); + appendHistory("first workspace only", firstPath); + assert.deepEqual(loadHistory(firstPath), ["first workspace only"]); + assert.deepEqual(loadHistory(secondPath), []); + } finally { + rmSync(historyPath(first), { force: true }); + rmSync(historyPath(second), { force: true }); + rmSync(first, { recursive: true, force: true }); + rmSync(second, { recursive: true, force: true }); + } +}); + test("consecutive duplicates collapse; blanks are never stored", () => { const p = fresh(); appendHistory("same", p); diff --git a/test/input_line.test.ts b/test/input_line.test.ts index 7ca8c2d..aeabc52 100644 --- a/test/input_line.test.ts +++ b/test/input_line.test.ts @@ -1,12 +1,19 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { InputBuffer } from "../src/ui/input_line.js"; +import { HISTORY_CAP } from "../src/core/history_store.js"; test("typed characters accumulate", () => { const b = new InputBuffer(); for (const c of "hello") b.insert(c); assert.equal(b.value, "hello"); }); +test("in-memory prompt history stays under the existing workspace entry cap", () => { + const b = new InputBuffer(); + for (let i = 0; i < HISTORY_CAP + 2; i++) b.commit(`prompt ${i}`); + assert.equal(b.historyEntries().length, HISTORY_CAP); + assert.equal(b.historyEntries()[0], "prompt 2"); +}); test("bracketed paste inserts the whole block at the cursor", () => { const b = new InputBuffer(); b.insert("a");