diff --git a/COMMANDS.md b/COMMANDS.md index baab1574..e9b893aa 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -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 ""` — one-shot Runs a single turn against your default (or `--model`) and streams the answer. `aether chat ""` is the explicit form — it bypasses command matching, diff --git a/README.md b/README.md index 285b7ee2..2cd56d62 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 056323f2..91777a1d 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -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(); @@ -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}`)); @@ -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(); @@ -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; @@ -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(); @@ -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` : ""; @@ -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) { @@ -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 = ""; @@ -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); } }; diff --git a/src/commands/config.ts b/src/commands/config.ts index b284941d..adb1da15 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -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(["autoApply", "telemetry"]); +const BOOL_KEYS = new Set(["autoApply", "telemetry", "lfSubmits"]); const PERMISSION_MODES: PermissionMode[] = ["ask", "auto", "skip"]; const BACKEND_PREFS: BackendPref[] = ["auto", "local", "cloud"]; @@ -56,6 +56,10 @@ export async function cmdConfig(ctx: AppContext, argv: string[]): Promise 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 { @@ -67,6 +156,7 @@ export class InputBuffer { 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 { @@ -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. */ @@ -100,9 +194,12 @@ 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; @@ -110,9 +207,11 @@ export class InputBuffer { 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; @@ -121,6 +220,7 @@ export class InputBuffer { } else { this.setTo(this.history[this.histIdx]!); } + this.breakTyping(); } private setTo(s: string): void { this.chars = [...s]; diff --git a/src/ui/input_render.ts b/src/ui/input_render.ts index a00e8cae..32ab05c0 100644 --- a/src/ui/input_render.ts +++ b/src/ui/input_render.ts @@ -35,7 +35,10 @@ export function renderInputView(prompt: string, value: string, cursor: number, c p = sliceVisible(p, Math.max(0, cols - 8)); pw = visibleWidth(p); } - const avail = Math.max(1, cols - pw - 1); // one spare column for the caret + const lines = 1 + [...value].filter((ch) => ch === "\n").length; + const lineHint = lines > 1 ? ` [${lines}L]` : ""; + const hint = cols - pw >= visibleWidth(lineHint) + 3 ? lineHint : ""; + const avail = Math.max(1, cols - pw - visibleWidth(hint) - 1); // one spare column for the caret // Control chars from multi-line pastes render as glyphs — a raw \n written // during repaint would drop the cursor a row and stack garbage lines. const cps = [...value].map(displayChar); @@ -56,7 +59,7 @@ export function renderInputView(prompt: string, value: string, cursor: number, c end++; } return { - text: p + cps.slice(start, end).join(""), + text: p + cps.slice(start, end).join("") + hint, cursorCol: Math.min(Math.max(1, cols), pw + winW + 1), }; } diff --git a/src/ui/keys.ts b/src/ui/keys.ts index 08266976..83aa558c 100644 --- a/src/ui/keys.ts +++ b/src/ui/keys.ts @@ -5,6 +5,9 @@ export type Key = | { kind: "char"; value: string } // a printable run — may be multi-char (batched typing / paste) | { kind: "submit" } + | { kind: "newline" } + | { kind: "undo" } + | { kind: "yank" } | { kind: "backspace" } | { kind: "interrupt" } | { kind: "eof" } @@ -72,11 +75,16 @@ export function splitKeys(chunk: string): string[] { } /** Decode one key sequence (a splitKeys token) into a Key. Pure. */ -export function decodeKey(seq: string): Key { +export function decodeKey(seq: string, lfSubmits = false): Key { switch (seq) { case "\r": - case "\n": return { kind: "submit" }; + case "\n": + return { kind: lfSubmits ? "submit" : "newline" }; + case "\x1f": + return { kind: "undo" }; // ctrl-_ + case "\x19": + return { kind: "yank" }; // ctrl-y case "\x7f": case "\b": return { kind: "backspace" }; diff --git a/test/composer_config.test.ts b/test/composer_config.test.ts new file mode 100644 index 00000000..83646497 --- /dev/null +++ b/test/composer_config.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +test("LF submit compatibility can be explicitly enabled and disabled", () => { + const dir = mkdtempSync(join(tmpdir(), "aether-lf-setting-")); + const cli = resolve("dist", "src", "main.js"); + try { + for (const value of ["true", "false"]) { + const result = spawnSync(process.execPath, [cli, "config", "set", "lfSubmits", value], { + encoding: "utf8", env: { ...process.env, AETHER_CONFIG_DIR: dir }, timeout: 5_000, + }); + assert.equal(result.status, 0, String(result.stderr)); + const saved = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as { lfSubmits: boolean }; + assert.equal(saved.lfSubmits, value === "true"); + } + const invalid = spawnSync(process.execPath, [cli, "config", "set", "lfSubmits", "maybe"], { + encoding: "utf8", env: { ...process.env, AETHER_CONFIG_DIR: dir }, timeout: 5_000, + }); + assert.equal(invalid.status, 2); + assert.match(String(invalid.stderr), /lfSubmits must be true or false/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/composer_tty.test.ts b/test/composer_tty.test.ts new file mode 100644 index 00000000..c387ad6b --- /dev/null +++ b/test/composer_tty.test.ts @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +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 type { AppContext } from "../src/core/context.js"; +import type { TokenStore } from "../src/core/auth.js"; + +async function withComposer(lfSubmits: boolean, run: (h: { + key: (text: string) => void; + output: () => string; + until: (text: string) => Promise; + modelCalls: () => number; +}) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), "aether-composer-")); + const oldFetch = globalThis.fetch; + const oldWrite = process.stdout.write; + const oldErrWrite = process.stderr.write; + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const raw = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); + const columns = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + let output = ""; + let modelCalls = 0; + let pending: Promise | null = null; + const tokens = { get: async () => "fixture-token" } as unknown as TokenStore; + const ctx = { + cfg: { ...DEFAULT_CONFIG, baseUrl: "https://stub.test", backend: "cloud", lfSubmits }, + flags: { cwd: root, json: true, yes: false, audit: false }, + tokens, api: new ApiClient("https://stub.test", tokens), confirm: async () => false, + } as AppContext; + const key = (text: string): void => { process.stdin.emit("data", Buffer.from(text)); }; + const until = async (text: string): Promise => { + const deadline = Date.now() + 5_000; + while (!output.includes(text)) { + if (Date.now() > deadline) throw new Error(`composer did not show ${JSON.stringify(text)}`); + await new Promise(resolve => setTimeout(resolve, 5)); + } + }; + try { + 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 }); + process.stdout.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stdout.write; + process.stderr.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stderr.write; + globalThis.fetch = (async (url) => { + if (String(url).includes("/agent/chat/stream")) { modelCalls++; throw new Error("editing must not request a model"); } + return Response.json({ models: [] }); + }) as typeof fetch; + pending = repl(ctx, { noSkills: true }); + await until("\x1b[?2004h"); + await run({ key, output: () => output, until, modelCalls: () => modelCalls }); + key("/exit\r"); + assert.equal(await Promise.race([pending, new Promise((_, reject) => setTimeout(() => reject(new Error("TTY exit timed out")), 2_000))]), 0); + pending = null; + assert.equal(modelCalls, 0); + } finally { + if (pending) { + key("\x03\x03\x04"); + await Promise.race([pending.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1_000))]); + } + globalThis.fetch = oldFetch; + process.stdout.write = oldWrite; + process.stderr.write = oldErrWrite; + if (tty) Object.defineProperty(process.stdin, "isTTY", tty); else delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; + if (raw) Object.defineProperty(process.stdin, "setRawMode", raw); else delete (process.stdin as unknown as { setRawMode?: unknown }).setRawMode; + if (columns) Object.defineProperty(process.stdout, "columns", columns); else delete (process.stdout as unknown as { columns?: number }).columns; + rmSync(historyPath(root), { force: true }); + rmSync(root, { recursive: true, force: true }); + } +} + +test("raw composer inserts LF, shows a line hint, and submits only on CR", async () => { + await withComposer(false, async ({ key, output, until, modelCalls }) => { + key("/btw first\nsecond"); + assert.match(output(), /⏎.*\[2L\]/); + assert.doesNotMatch(output(), /Noted:/); + assert.equal(modelCalls(), 0); + key("\r"); + await until("Noted:"); + assert.match(output(), /Noted: "first\nsecond"/); + key("\x1f"); // a sent draft is outside the undo scope + assert.doesNotMatch(output().slice(output().lastIndexOf("\r\x1b[2K")), /first|second/); + key("\x1b[200~pasted\nlines\x1b[201~"); + assert.match(output().slice(output().lastIndexOf("\r\x1b[2K")), /\[2L\]/); + key("\x1f"); // one undo removes the entire bracketed paste + assert.doesNotMatch(output().slice(output().lastIndexOf("\r\x1b[2K")), /pasted|lines/); + key("draft"); + key("\x15"); // kill to start + key("\x19"); // yank + assert.match(output().slice(output().lastIndexOf("\r\x1b[2K")), /draft/); + key("\x1f"); // undo yank + assert.doesNotMatch(output().slice(output().lastIndexOf("\r\x1b[2K")), /draft/); + }); +}); + +test("LF-as-submit setting retains legacy raw TTY behavior", async () => { + await withComposer(true, async ({ key, until, output }) => { + key("/btw compatibility\n"); + await until("Noted:"); + assert.match(output(), /Noted: "compatibility"/); + }); +}); diff --git a/test/input_line.test.ts b/test/input_line.test.ts index f4f65be5..715ce91a 100644 --- a/test/input_line.test.ts +++ b/test/input_line.test.ts @@ -121,3 +121,86 @@ test("insert is bulk — a large paste lands intact with the cursor at the end", assert.equal(b.value, big); assert.equal(b.pos, 50_001); // 50k ascii + one astral code point }); + +test("adjacent Unicode typing coalesces; newline is its own undo step", () => { + const b = new InputBuffer(); + b.insert("é"); + b.insert("🙂"); + b.insertNewline(); + b.insert("漢"); + b.undo(); + assert.equal(b.value, "é🙂\n"); + assert.equal(b.pos, 3); + b.undo(); + assert.equal(b.value, "é🙂"); + assert.equal(b.pos, 2); + b.undo(); + assert.equal(b.value, ""); + assert.equal(b.pos, 0); +}); + +test("bracketed paste is one transaction and restores the prior cursor", () => { + const b = new InputBuffer(); + b.insert("ab"); + b.left(); + b.paste("👩‍💻\nnext"); + assert.equal(b.value, "a👩‍💻\nnextb"); + b.undo(); + assert.equal(b.value, "ab"); + assert.equal(b.pos, 1); +}); + +test("kill, yank, and undo recover text without crossing a clear or submit", () => { + const b = new InputBuffer(); + b.insert("alpha beta"); + b.deleteWord(); + assert.equal(b.value, "alpha "); + b.yank(); + assert.equal(b.value, "alpha beta"); + b.undo(); + assert.equal(b.value, "alpha "); + b.undo(); + assert.equal(b.value, "alpha beta"); + b.commit(b.value); + b.undo(); + b.yank(); + assert.equal(b.value, "", "sent text and its kill register are outside the recovery scope"); + b.insert("new draft"); + b.killToStart(); + b.clear(); + b.yank(); + b.undo(); + assert.equal(b.value, ""); +}); + +test("input-owner changes end recovery but keep the live draft", () => { + const b = new InputBuffer(); + b.insert("draft"); + b.left(); + b.left(); + b.killToEnd(); + b.endRecoveryScope(); + b.undo(); + b.yank(); + assert.equal(b.value, "dra"); +}); + +test("history recall and slash completion are undoable within one draft", () => { + const b = new InputBuffer(); + b.loadHistory(["older", "latest"]); + b.insert("draft"); + b.historyUp(); + assert.equal(b.value, "latest"); + b.undo(); + assert.equal(b.value, "draft"); + b.replace("/models"); + b.undo(); + assert.equal(b.value, "draft"); +}); + +test("undo retains at most 64 edit snapshots", () => { + const b = new InputBuffer(); + for (let i = 0; i < 100; i++) { b.insert("x"); b.left(); b.right(); } + for (let i = 0; i < 100; i++) b.undo(); + assert.equal(b.value.length, 36); +}); diff --git a/test/input_render.test.ts b/test/input_render.test.ts index 5c79d286..5ce70962 100644 --- a/test/input_render.test.ts +++ b/test/input_render.test.ts @@ -37,4 +37,16 @@ test("embedded newlines render as ⏎ on the single row (no raw linefeed)", () = assert.ok(!v.text.includes("\n"), "a raw LF would break the pinned row"); assert.ok(v.text.includes("⏎")); assert.equal(v.cursorCol, 2 + 5 + 1); // ⏎ counts one column + assert.match(v.text, /\[2L\]$/); +}); + +test("multiline Unicode input keeps a one-row cursor and line hint after resize", () => { + const value = "漢🙂\nsecond\nthird"; + for (const cols of [12, 20, 40]) { + const view = renderInputView("> ", value, [...value].length, cols); + assert.ok(!view.text.includes("\n")); + assert.ok(view.cursorCol >= 1 && view.cursorCol <= cols); + assert.ok(stripAnsi(view.text).length <= cols); + if (cols >= 20) assert.match(view.text, /\[3L\]$/); + } }); diff --git a/test/keys.test.ts b/test/keys.test.ts index 3c1777c7..c6cd4425 100644 --- a/test/keys.test.ts +++ b/test/keys.test.ts @@ -17,6 +17,15 @@ test("splitKeys: multibyte graphemes survive in one run", () => { test("splitKeys: control chars are individual tokens", () => { assert.deepEqual(splitKeys("ab\rcd\x7f"), ["ab", "\r", "cd", "\x7f"]); + assert.deepEqual(splitKeys("a\nb\x1f\x19"), ["a", "\n", "b", "\x1f", "\x19"]); +}); + +test("Ctrl+J inserts LF by default; explicit compatibility mode submits it", () => { + assert.deepEqual(decodeKey("\r"), { kind: "submit" }); + assert.deepEqual(decodeKey("\n"), { kind: "newline" }); + assert.deepEqual(decodeKey("\n", true), { kind: "submit" }); + assert.deepEqual(decodeKey("\x1f"), { kind: "undo" }); + assert.deepEqual(decodeKey("\x19"), { kind: "yank" }); }); test("splitKeys: bracketed paste markers tokenize exactly", () => {