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
12 changes: 12 additions & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ Opens a session. Type a prompt to chat; type `/` commands to control it (see
`Ctrl-C` mid-answer cancels the turn and keeps the session; `Ctrl-C` at an
empty prompt (or `/exit`) leaves.

In a raw TTY, Enter (CR) submits and Ctrl+J (LF) inserts a newline into the
same draft. The composer stays on one row: `⏎` marks each newline and `[nL]`
shows the line count. Ctrl+_ undoes recent edits (adjacent typing groups into
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.

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
newline entry. This setting affects only the raw TTY composer.

### `aether "<prompt>"` — one-shot
Runs a single turn against your default (or `--model`) and streams the answer.
`aether chat "<prompt>"` is the explicit form — it bypasses command matching,
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ On Windows, run `/shell-profile list` to see the installed executable and versio

Shell and terminal output stays out of saved chat history and automatic hosted prompts. Account-agent DMs use a separate console. [Full shell and Linux guide](docs/LOCAL_SHELL_SESSION.md).

In the raw terminal composer, Ctrl+J adds a newline, Enter submits, Ctrl+_
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).

## Everyday commands

| Goal | Command |
Expand Down
42 changes: 34 additions & 8 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,16 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
let busy = false;
let setupOwnsInput = false;
let terminalOwnsInput = false;
let approvalOwnsInput = false;
const composerCtx: AppContext = {
...ctx,
confirm: async (question) => {
buf.endRecoveryScope();
approvalOwnsInput = true;
try { return await ctx.confirm(question); }
finally { approvalOwnsInput = false; }
},
};
const renderHudLine = (): void => {
if (!process.stdout.isTTY) return;
const reg = getRegistry();
Expand Down Expand Up @@ -1470,7 +1480,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
const nextPrompt = continuation.promptForNextTurn(built.prompt, continuation.hasAcceptedBrief ? await consoleContinuationState(ctx) : undefined);
submittedPrompt = nextPrompt;
if (await resolveBackend(ctx) === "cloud") authRepair.markHostedTurnStarted();
const outcome = await runTurn(ctx, nextPrompt, turnAbort.signal, (f) => {
const outcome = await runTurn(composerCtx, nextPrompt, turnAbort.signal, (f) => {
if (receipts.length < 64) {
if (f.type === "tool_call") receipts.push(sanitizeServerText(`tool call ${f.toolCallId} (${f.name})`));
if (f.type === "tool_result_ack") receipts.push(sanitizeServerText(`tool result ${f.toolCallId}`));
Expand Down Expand Up @@ -1918,10 +1928,11 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
renderHudLine(); repaint();
return;
}
setupOwnsInput = /^\/agent-create\s+ATS(?:\s|$)/i.test(t);
setupOwnsInput = /^\/agent-create\s+ATS(?:\s|$)|^\/(?:mcp|model|models|agent)\s*$/i.test(t);
if (setupOwnsInput) buf.endRecoveryScope();
if (setupOwnsInput) process.stdout.write("\x1b[?2004l");
try {
const res = await handleSlash(ctx, t, process.stdout, slashAbort.signal);
const res = await handleSlash(composerCtx, t, process.stdout, slashAbort.signal);
if (res.exit) {
discardQueue("session ended"); // entries held after a failure are listed, not lost silently
cleanup();
Expand Down Expand Up @@ -2005,7 +2016,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
}
return;
}
const k = decodeKey(seq);
const k = decodeKey(seq, ctx.cfg.lfSubmits);
switch (k.kind) {
case "paste-start":
pasting = true;
Expand All @@ -2015,6 +2026,21 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
buf.insert(k.value);
repaint();
return;
case "newline":
if (viewerOpen) return;
buf.insertNewline();
repaint();
return;
case "undo":
if (viewerOpen) return;
buf.undo();
repaint();
return;
case "yank":
if (viewerOpen) return;
buf.yank();
repaint();
return;
case "backspace":
buf.backspace();
repaint();
Expand Down Expand Up @@ -2079,8 +2105,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
if (v.startsWith("/") && !/\s/.test(v) && buf.pos === [...v].length) {
const r = completeManifestSlash(v);
if (r.completed) {
buf.clear();
buf.insert(r.completed);
buf.replace(r.completed);
} else if (r.matches.length > 1) {
const shown = r.matches.slice(0, 12).map((m) => "/" + m).join(" ");
const more = r.matches.length > 12 ? ` … +${r.matches.length - 12} more` : "";
Expand Down Expand Up @@ -2118,6 +2143,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
case "down":
if (viewerState.visible && !viewerOpen) {
viewerOpen = true;
buf.endRecoveryScope();
redrawViewerTree();
} else if (viewerOpen) {
if (viewerState.selectedAgentId == null) {
Expand Down Expand Up @@ -2183,7 +2209,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
// decoded as two replacement chars.
const decoder = new StringDecoder("utf8");
const onData = (chunk: Buffer): void => {
if (terminalOwnsInput) return;
if (terminalOwnsInput || approvalOwnsInput) return;
if (setupOwnsInput) { if (chunk.includes(3)) slashAbort?.abort(); return; }
let data = carry + decoder.write(chunk);
carry = "";
Expand All @@ -2193,7 +2219,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
data = data.slice(0, partial.index);
}
for (const seq of splitKeys(data)) {
if (setupOwnsInput || terminalOwnsInput) break;
if (setupOwnsInput || terminalOwnsInput || approvalOwnsInput) break;
processSeq(seq);
}
};
Expand Down
6 changes: 5 additions & 1 deletion src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { AetherConfig, BackendPref, PermissionMode } from "../types.js";
import { saveConfig } from "../core/config.js";
import { EFFORT_TIERS, normalizeEffort } from "../ui/effort.js";

const BOOL_KEYS = new Set<keyof AetherConfig>(["autoApply", "telemetry"]);
const BOOL_KEYS = new Set<keyof AetherConfig>(["autoApply", "telemetry", "lfSubmits"]);
const PERMISSION_MODES: PermissionMode[] = ["ask", "auto", "skip"];
const BACKEND_PREFS: BackendPref[] = ["auto", "local", "cloud"];

Expand Down Expand Up @@ -56,6 +56,10 @@ export async function cmdConfig(ctx: AppContext, argv: string[]): Promise<number
}
ctx.cfg.backend = value as BackendPref;
} else if (BOOL_KEYS.has(key)) {
if (key === "lfSubmits" && !["true", "false", "1", "0"].includes(value)) {
process.stderr.write("lfSubmits must be true or false\n");
return 2;
}
(ctx.cfg[key] as boolean) = value === "true" || value === "1";
} else {
(ctx.cfg[key] as string) = value;
Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const DEFAULT_CONFIG: AetherConfig = {
localModel: "",
permissionMode: "ask",
autoApply: false,
lfSubmits: false,
telemetry: true,
defaultEffort: "",
// Local-first: 'auto' runs the cloud brain when signed in, else local Ollama.
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface AetherConfig {
permissionMode: PermissionMode;
/** Auto-apply streamed edits without per-edit prompt. Mirrors Aether Agent. */
autoApply: boolean;
/** Raw TTY compatibility: treat LF/Ctrl+J as submit instead of newline. */
lfSubmits?: boolean;
/** Anonymous usage telemetry opt-in. */
telemetry: boolean;
/** Default effort tier (LOW|MED|MAX|ULTRA|CODEPRO, "" = server default).
Expand Down
106 changes: 103 additions & 3 deletions src/ui/input_line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,28 @@
// stashes the in-progress draft (restored when you arrow back down), and the
// REPL persists entries across sessions via core/history_store.

interface EditSnapshot {
value: string;
cursor: number;
histIdx: number;
draft: string | null;
bytes: number;
}

const MAX_UNDO_STEPS = 64;
const MAX_UNDO_BYTES = 1024 * 1024;
const MAX_YANK_BYTES = 1024 * 1024;

export class InputBuffer {
private chars: string[] = [];
private cursor = 0;
private history: string[] = [];
private histIdx = -1;
private draft: string | null = null; // the line being typed when history recall began
private undoStack: EditSnapshot[] = [];
private undoBytes = 0;
private killedText: string | null = null;
private typingEnd: number | null = null;

get value(): string {
return this.chars.join("");
Expand All @@ -19,54 +35,128 @@ export class InputBuffer {
return this.cursor;
}

insert(s: string): void {
private breakTyping(): void { this.typingEnd = null; }

private saveEdit(coalesce = false): void {
if (coalesce && this.typingEnd === this.cursor) return;
const value = this.value;
const bytes = Buffer.byteLength(value, "utf8") + Buffer.byteLength(this.draft ?? "", "utf8");
if (bytes > MAX_UNDO_BYTES) { this.undoStack = []; this.undoBytes = 0; return; }
this.undoStack.push({ value, cursor: this.cursor, histIdx: this.histIdx, draft: this.draft, bytes });
this.undoBytes += bytes;
while (this.undoStack.length > MAX_UNDO_STEPS || this.undoBytes > MAX_UNDO_BYTES) {
this.undoBytes -= this.undoStack.shift()!.bytes;
}
}

private insertText(s: string, typing: boolean): void {
if (!s) return;
this.saveEdit(typing);
// One O(n) rebuild — splice-per-char is O(n²) and freezes on large pastes.
const add = [...s];
this.chars = this.chars.slice(0, this.cursor).concat(add, this.chars.slice(this.cursor));
this.cursor += add.length;
this.typingEnd = typing ? this.cursor : null;
}
/** Adjacent printable typing is one undo transaction until another action. */
insert(s: string): void {
this.insertText(s, !s.includes("\n") && !s.includes("\r"));
}
insertNewline(): void {
this.insertText("\n", false);
}
/** Completion replaces the current draft as one undoable edit. */
replace(s: string): void {
if (s === this.value) return;
this.saveEdit();
this.chars = [...s];
this.cursor = this.chars.length;
this.histIdx = -1;
this.draft = null;
this.breakTyping();
}
/** A bracketed-paste block: inserted verbatim (newlines kept) at the cursor. */
paste(block: string): void {
this.insert(block);
this.breakTyping();
this.insertText(block, false);
}
backspace(): void {
if (this.cursor > 0) {
this.saveEdit();
this.chars.splice(this.cursor - 1, 1);
this.cursor--;
}
this.breakTyping();
}
/** Delete the char at the cursor (the `Del` key). */
deleteForward(): void {
if (this.cursor < this.chars.length) this.chars.splice(this.cursor, 1);
if (this.cursor < this.chars.length) { this.saveEdit(); this.chars.splice(this.cursor, 1); }
this.breakTyping();
}
/** Kill from the cursor to end of line (ctrl-k). */
killToEnd(): void {
const killed = this.chars.slice(this.cursor).join("");
if (killed) { this.saveEdit(); this.rememberKill(killed); }
this.chars.splice(this.cursor);
this.breakTyping();
}
/** Kill from start of line to the cursor (ctrl-u). */
killToStart(): void {
const killed = this.chars.slice(0, this.cursor).join("");
if (killed) { this.saveEdit(); this.rememberKill(killed); }
this.chars.splice(0, this.cursor);
this.cursor = 0;
this.breakTyping();
}
deleteWord(): void {
let i = this.cursor;
while (i > 0 && this.chars[i - 1] === " ") i--;
while (i > 0 && this.chars[i - 1] !== " ") i--;
const killed = this.chars.slice(i, this.cursor).join("");
if (killed) { this.saveEdit(); this.rememberKill(killed); }
this.chars.splice(i, this.cursor - i);
this.cursor = i;
this.breakTyping();
}
private rememberKill(text: string): void {
this.killedText = Buffer.byteLength(text, "utf8") <= MAX_YANK_BYTES ? text : null;
}
yank(): void {
if (this.killedText) this.insertText(this.killedText, false);
this.breakTyping();
}
undo(): void {
const previous = this.undoStack.pop();
if (!previous) return;
this.undoBytes -= previous.bytes;
this.chars = [...previous.value];
this.cursor = previous.cursor;
this.histIdx = previous.histIdx;
this.draft = previous.draft;
this.breakTyping();
}
/** A different input owner cannot recover this draft's earlier edits. */
endRecoveryScope(): void {
this.undoStack = [];
this.undoBytes = 0;
this.killedText = null;
this.breakTyping();
}
left(): void {
if (this.cursor > 0) this.cursor--;
this.breakTyping();
}
right(): void {
if (this.cursor < this.chars.length) this.cursor++;
this.breakTyping();
}
/** Jump to the start of the previous word (ctrl/alt-left). */
wordLeft(): void {
let i = this.cursor;
while (i > 0 && this.chars[i - 1] === " ") i--;
while (i > 0 && this.chars[i - 1] !== " ") i--;
this.cursor = i;
this.breakTyping();
}
/** Jump past the end of the next word (ctrl/alt-right). */
wordRight(): void {
Expand All @@ -75,18 +165,22 @@ export class InputBuffer {
while (i < n && this.chars[i] === " ") i++;
while (i < n && this.chars[i] !== " ") i++;
this.cursor = i;
this.breakTyping();
}
home(): void {
this.cursor = 0;
this.breakTyping();
}
end(): void {
this.cursor = this.chars.length;
this.breakTyping();
}
clear(): void {
this.chars = [];
this.cursor = 0;
this.histIdx = -1;
this.draft = null;
this.endRecoveryScope();
}
/** Record a submitted line into history and clear the buffer.
* Consecutive duplicates collapse to one entry. */
Expand All @@ -100,19 +194,24 @@ export class InputBuffer {
loadHistory(lines: readonly string[]): void {
this.history = [...lines];
this.histIdx = -1;
this.endRecoveryScope();
}
historyUp(): void {
if (this.history.length === 0) return;
if (this.histIdx === 0) return;
this.saveEdit();
if (this.histIdx < 0) {
this.draft = this.value; // stash what was being typed
this.histIdx = this.history.length - 1;
} else {
this.histIdx = Math.max(0, this.histIdx - 1);
}
this.setTo(this.history[this.histIdx]!);
this.breakTyping();
}
historyDown(): void {
if (this.histIdx < 0) return;
this.saveEdit();
this.histIdx++;
if (this.histIdx >= this.history.length) {
this.histIdx = -1;
Expand All @@ -121,6 +220,7 @@ export class InputBuffer {
} else {
this.setTo(this.history[this.histIdx]!);
}
this.breakTyping();
}
private setTo(s: string): void {
this.chars = [...s];
Expand Down
Loading
Loading