Skip to content
Merged
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
18 changes: 16 additions & 2 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 68 additions & 7 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1421,7 +1429,15 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
let viewerLastLines = 0;
return await new Promise<number>((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 => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
117 changes: 117 additions & 0 deletions src/ui/history_search.ts
Original file line number Diff line number Diff line change
@@ -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)];
}
6 changes: 5 additions & 1 deletion src/ui/input_line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/ui/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type Key =
| { kind: "newline" }
| { kind: "undo" }
| { kind: "yank" }
| { kind: "history-search" }
| { kind: "backspace" }
| { kind: "interrupt" }
| { kind: "eof" }
Expand Down Expand Up @@ -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" };
Expand Down
Loading
Loading