@cruxy/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ import pc from "picocolors";
2
+ import type { ActionPreview } from "../tools/types.js";
3
+ /**
4
+ * The one diff/action-preview renderer (U.2). The approval prompt, PR preview,
5
+ * and the streaming render path all draw diffs through here — there is no
6
+ * second implementation to drift. Pure data → string; color is gated on a
7
+ * picocolors instance, so NO_COLOR/non-TTY callers get symbol-only `+`/`-`
8
+ * lines from the exact same code path.
9
+ */
10
+ /** The picocolors instance type (colorless or not), from createColors. */
11
+ export type Colors = ReturnType<typeof pc.createColors>;
12
+ /** Cap on rendered preview lines before collapsing the rest. */
13
+ export declare const PREVIEW_MAX_LINES = 40;
14
+ /**
15
+ * Render any {@link ActionPreview} as an indented block: a diff for edits and
16
+ * patches, a create/overwrite listing for writes, the publish plan for PRs.
17
+ * Long previews collapse past {@link PREVIEW_MAX_LINES}.
18
+ */
19
+ export declare function renderActionPreview(preview: ActionPreview | undefined, c: Colors): string;
@@ -0,0 +1,80 @@
1
+ /** Cap on rendered preview lines before collapsing the rest. */
2
+ export const PREVIEW_MAX_LINES = 40;
3
+ function diffLines(oldStr, newStr, c) {
4
+ const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
5
+ const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
6
+ return [...removed, ...added];
7
+ }
8
+ function renderPatchFiles(files, c) {
9
+ const out = [];
10
+ for (const file of files) {
11
+ if (file.op === "delete") {
12
+ out.push(c.red(`delete ${file.path}`));
13
+ }
14
+ else if (file.op === "create") {
15
+ out.push(c.green(`create ${file.path}`));
16
+ out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
17
+ if (file.omittedLines > 0)
18
+ out.push(c.dim(` ...${file.omittedLines} more lines`));
19
+ }
20
+ else {
21
+ out.push(c.yellow(`update ${file.path}`));
22
+ for (const hunk of file.hunks)
23
+ out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ /** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
29
+ function renderPrPreview(preview, c) {
30
+ const out = [];
31
+ out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
32
+ out.push("");
33
+ out.push(c.bold("commit"));
34
+ out.push(` ${preview.commitSubject}`);
35
+ for (const line of bodyLines(preview.commitBody))
36
+ out.push(c.dim(` ${line}`));
37
+ out.push("");
38
+ out.push(`${c.bold("pull request")} ${preview.prTitle}`);
39
+ for (const line of bodyLines(preview.prBody))
40
+ out.push(c.dim(` ${line}`));
41
+ return out;
42
+ }
43
+ /** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
44
+ function bodyLines(body) {
45
+ const lines = body.replace(/\s+$/, "").split("\n");
46
+ return lines.length === 1 && lines[0] === "" ? [] : lines;
47
+ }
48
+ /**
49
+ * Render any {@link ActionPreview} as an indented block: a diff for edits and
50
+ * patches, a create/overwrite listing for writes, the publish plan for PRs.
51
+ * Long previews collapse past {@link PREVIEW_MAX_LINES}.
52
+ */
53
+ export function renderActionPreview(preview, c) {
54
+ if (!preview)
55
+ return "";
56
+ let lines;
57
+ if (preview.type === "edit") {
58
+ lines = diffLines(preview.oldStr, preview.newStr, c);
59
+ }
60
+ else if (preview.type === "patch") {
61
+ lines = renderPatchFiles(preview.files, c);
62
+ }
63
+ else if (preview.type === "pr") {
64
+ lines = renderPrPreview(preview, c);
65
+ }
66
+ else {
67
+ const header = preview.exists
68
+ ? c.yellow("OVERWRITE existing")
69
+ : c.green("create");
70
+ const body = preview.lines.map((l) => ` ${l}`);
71
+ if (preview.omittedLines > 0)
72
+ body.push(c.dim(` ...${preview.omittedLines} more lines`));
73
+ lines = [header, ...body];
74
+ }
75
+ if (lines.length > PREVIEW_MAX_LINES) {
76
+ const hidden = lines.length - PREVIEW_MAX_LINES;
77
+ lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
78
+ }
79
+ return lines.map((l) => ` ${l}`).join("\n");
80
+ }
@@ -0,0 +1,47 @@
1
+ import type { Colors } from "./diff.js";
2
+ /**
3
+ * Best-effort, bounded syntax highlighting for fenced code blocks in streamed
4
+ * markdown (U.2). Priorities, in order: never crash the stream, never hold the
5
+ * stream back, then look nice.
6
+ *
7
+ * - Prose passes through immediately, byte for byte. The only hold-back is a
8
+ * line that is still a plausible fence opener (a leading backtick run), held
9
+ * until disambiguated — bounded by one short line, never a block.
10
+ * - Inside a fence, lines are highlighted incrementally: each line is emitted
11
+ * the moment its newline arrives, so latency is one line, and committed
12
+ * output is never repainted.
13
+ * - Unknown language → plain text. A tokenizer throw → that line plain. The
14
+ * tokenizer is hand-rolled (no highlighter dependency, in the same spirit as
15
+ * the hand-rolled HTTP client) and colors only what it is sure about:
16
+ * comments, strings, keywords, numbers.
17
+ */
18
+ /** Cross-line tokenizer state (block comments / multi-line strings). */
19
+ export interface HighlightCarry {
20
+ /** Inside a block comment (`/* … *``/`). */
21
+ blockComment: boolean;
22
+ /** Inside a multi-line string; the delimiter that will close it. */
23
+ stringDelim: string | null;
24
+ }
25
+ /** One line of code → styled line + the carry for the next line. */
26
+ export type LineHighlighter = (line: string, lang: string | null, carry: HighlightCarry) => {
27
+ text: string;
28
+ carry: HighlightCarry;
29
+ };
30
+ /** Incremental highlighter for one streamed text segment. */
31
+ export interface StreamHighlighter {
32
+ /** Feed a delta; returns the styled text that is safe to emit now. */
33
+ push(delta: string): string;
34
+ /** Return whatever is still held (segment end); resets to prose state. */
35
+ flush(): string;
36
+ }
37
+ /**
38
+ * Create the per-segment streaming highlighter. `highlightLine` is injectable
39
+ * for tests (e.g. to prove a throwing tokenizer degrades to plain text).
40
+ */
41
+ export declare function createStreamHighlighter(c: Colors, highlightLine?: LineHighlighter): StreamHighlighter;
42
+ /**
43
+ * Build the default per-line tokenizer over `c`. A plain left-to-right scan:
44
+ * comments dim, strings green, keywords magenta, numbers yellow, everything
45
+ * else untouched. Unknown language → identity.
46
+ */
47
+ export declare function defaultLineHighlighter(c: Colors): LineHighlighter;
@@ -0,0 +1,265 @@
1
+ const FRESH_CARRY = { blockComment: false, stringDelim: null };
2
+ /** A fence opener: ``` or longer, optional language word. */
3
+ const FENCE_OPEN = /^(`{3,})([\w+#.-]*)\s*$/;
4
+ /** A line that could still grow into a fence opener. */
5
+ const FENCE_PLAUSIBLE = /^(?:`{1,2}|`{3,}[\w+#.-]*\s*)$/;
6
+ /**
7
+ * Create the per-segment streaming highlighter. `highlightLine` is injectable
8
+ * for tests (e.g. to prove a throwing tokenizer degrades to plain text).
9
+ */
10
+ export function createStreamHighlighter(c, highlightLine = defaultLineHighlighter(c)) {
11
+ let mode = "prose";
12
+ let atLineStart = true;
13
+ let lineBuf = "";
14
+ let lang = null;
15
+ let carry = FRESH_CARRY;
16
+ /** Highlight one completed code line; any tokenizer throw → plain text. */
17
+ const styleCodeLine = (line) => {
18
+ try {
19
+ const res = highlightLine(line, lang, carry);
20
+ carry = res.carry;
21
+ return res.text;
22
+ }
23
+ catch {
24
+ carry = FRESH_CARRY; // state is suspect after a throw; start clean
25
+ return line;
26
+ }
27
+ };
28
+ const push = (delta) => {
29
+ let out = "";
30
+ for (const ch of delta) {
31
+ if (mode === "prose") {
32
+ if (atLineStart && ch === "`") {
33
+ mode = "maybe-fence";
34
+ lineBuf = ch;
35
+ }
36
+ else {
37
+ out += ch;
38
+ atLineStart = ch === "\n";
39
+ }
40
+ }
41
+ else if (mode === "maybe-fence") {
42
+ if (ch === "\n") {
43
+ const m = FENCE_OPEN.exec(lineBuf);
44
+ if (m) {
45
+ lang = m[2] ? m[2].toLowerCase() : null;
46
+ carry = FRESH_CARRY;
47
+ mode = "code";
48
+ out += c.dim(lineBuf) + "\n";
49
+ }
50
+ else {
51
+ mode = "prose";
52
+ out += lineBuf + "\n";
53
+ }
54
+ lineBuf = "";
55
+ atLineStart = true;
56
+ }
57
+ else {
58
+ lineBuf += ch;
59
+ if (!FENCE_PLAUSIBLE.test(lineBuf)) {
60
+ // Can no longer become a fence (e.g. inline `code`) — release it.
61
+ mode = "prose";
62
+ out += lineBuf;
63
+ lineBuf = "";
64
+ atLineStart = false;
65
+ }
66
+ }
67
+ }
68
+ else {
69
+ // code
70
+ if (ch === "\n") {
71
+ if (/^`{3,}\s*$/.test(lineBuf)) {
72
+ mode = "prose";
73
+ out += c.dim(lineBuf) + "\n";
74
+ }
75
+ else {
76
+ out += styleCodeLine(lineBuf) + "\n";
77
+ }
78
+ lineBuf = "";
79
+ atLineStart = true;
80
+ }
81
+ else {
82
+ lineBuf += ch;
83
+ }
84
+ }
85
+ }
86
+ return out;
87
+ };
88
+ const flush = () => {
89
+ // Segment ended mid-line: release the held text as-is (highlighted when we
90
+ // know it's code — styled *before* the language resets) and start the next
91
+ // segment back in prose state.
92
+ const out = lineBuf === "" ? "" : mode === "code" ? styleCodeLine(lineBuf) : lineBuf;
93
+ mode = "prose";
94
+ lineBuf = "";
95
+ atLineStart = true;
96
+ lang = null;
97
+ carry = FRESH_CARRY;
98
+ return out;
99
+ };
100
+ return { push, flush };
101
+ }
102
+ const JS_KEYWORDS = "abstract as async await break case catch class const continue debugger default delete do else enum export extends false finally for from function get if implements import in instanceof interface let new null of private protected public readonly return satisfies set static super switch this throw true try type typeof undefined var void while with yield";
103
+ const PY_KEYWORDS = "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield match case self";
104
+ const SH_KEYWORDS = "if then else elif fi for while until do done case esac function in select time coproc break continue return exit export local readonly declare set unset shift trap source alias cd echo printf read test";
105
+ const GO_KEYWORDS = "break case chan const continue default defer else fallthrough for func go goto if import interface map nil package range return select struct switch true false type var";
106
+ const RUST_KEYWORDS = "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while";
107
+ const words = (list) => new Set(list.split(" "));
108
+ const JS_DEF = {
109
+ keywords: words(JS_KEYWORDS),
110
+ lineComment: "//",
111
+ blockComment: ["/*", "*/"],
112
+ quotes: ['"', "'", "`"],
113
+ multiline: ["`"],
114
+ };
115
+ const LANGS = {
116
+ js: JS_DEF,
117
+ jsx: JS_DEF,
118
+ ts: JS_DEF,
119
+ tsx: JS_DEF,
120
+ javascript: JS_DEF,
121
+ typescript: JS_DEF,
122
+ json: {
123
+ keywords: words("true false null"),
124
+ quotes: ['"'],
125
+ multiline: [],
126
+ },
127
+ py: {
128
+ keywords: words(PY_KEYWORDS),
129
+ lineComment: "#",
130
+ quotes: ['"', "'", '"""', "'''"],
131
+ multiline: ['"""', "'''"],
132
+ },
133
+ sh: {
134
+ keywords: words(SH_KEYWORDS),
135
+ lineComment: "#",
136
+ quotes: ['"', "'"],
137
+ multiline: [],
138
+ },
139
+ go: {
140
+ keywords: words(GO_KEYWORDS),
141
+ lineComment: "//",
142
+ blockComment: ["/*", "*/"],
143
+ quotes: ['"', "'", "`"],
144
+ multiline: ["`"],
145
+ },
146
+ rust: {
147
+ keywords: words(RUST_KEYWORDS),
148
+ lineComment: "//",
149
+ blockComment: ["/*", "*/"],
150
+ quotes: ['"'],
151
+ multiline: [],
152
+ },
153
+ };
154
+ // Aliases.
155
+ LANGS.python = LANGS.py;
156
+ LANGS.bash = LANGS.sh;
157
+ LANGS.shell = LANGS.sh;
158
+ LANGS.zsh = LANGS.sh;
159
+ LANGS.golang = LANGS.go;
160
+ LANGS.rs = LANGS.rust;
161
+ /** Word characters for keyword/identifier scanning. */
162
+ const WORD = /[A-Za-z0-9_$]/;
163
+ /**
164
+ * Build the default per-line tokenizer over `c`. A plain left-to-right scan:
165
+ * comments dim, strings green, keywords magenta, numbers yellow, everything
166
+ * else untouched. Unknown language → identity.
167
+ */
168
+ export function defaultLineHighlighter(c) {
169
+ return (line, lang, carry) => {
170
+ const def = lang ? LANGS[lang] : undefined;
171
+ if (!def)
172
+ return { text: line, carry };
173
+ // Longest quote first so `"""` wins over `"` in python.
174
+ const quotes = [...def.quotes].sort((a, b) => b.length - a.length);
175
+ let out = "";
176
+ let i = 0;
177
+ let next = { ...carry };
178
+ // Resume a multi-line construct from the previous line.
179
+ if (next.blockComment && def.blockComment) {
180
+ const close = line.indexOf(def.blockComment[1]);
181
+ if (close === -1)
182
+ return { text: c.dim(line), carry: next };
183
+ const end = close + def.blockComment[1].length;
184
+ out += c.dim(line.slice(0, end));
185
+ i = end;
186
+ next.blockComment = false;
187
+ }
188
+ else if (next.stringDelim) {
189
+ const close = findStringEnd(line, 0, next.stringDelim);
190
+ if (close === -1)
191
+ return { text: c.green(line), carry: next };
192
+ out += c.green(line.slice(0, close));
193
+ i = close;
194
+ next.stringDelim = null;
195
+ }
196
+ while (i < line.length) {
197
+ const rest = line.slice(i);
198
+ if (def.lineComment && rest.startsWith(def.lineComment)) {
199
+ out += c.dim(rest);
200
+ i = line.length;
201
+ break;
202
+ }
203
+ if (def.blockComment && rest.startsWith(def.blockComment[0])) {
204
+ const close = line.indexOf(def.blockComment[1], i + def.blockComment[0].length);
205
+ if (close === -1) {
206
+ out += c.dim(rest);
207
+ next = { ...next, blockComment: true };
208
+ i = line.length;
209
+ break;
210
+ }
211
+ const end = close + def.blockComment[1].length;
212
+ out += c.dim(line.slice(i, end));
213
+ i = end;
214
+ continue;
215
+ }
216
+ const quote = quotes.find((q) => rest.startsWith(q));
217
+ if (quote) {
218
+ const close = findStringEnd(line, i + quote.length, quote);
219
+ if (close === -1) {
220
+ out += c.green(rest);
221
+ if (def.multiline.includes(quote))
222
+ next = { ...next, stringDelim: quote };
223
+ i = line.length;
224
+ break;
225
+ }
226
+ out += c.green(line.slice(i, close));
227
+ i = close;
228
+ continue;
229
+ }
230
+ const ch = line[i];
231
+ if (WORD.test(ch)) {
232
+ let j = i + 1;
233
+ while (j < line.length && WORD.test(line[j]))
234
+ j++;
235
+ const word = line.slice(i, j);
236
+ if (def.keywords.has(word))
237
+ out += c.magenta(word);
238
+ else if (/^\d/.test(word))
239
+ out += c.yellow(word);
240
+ else
241
+ out += word;
242
+ i = j;
243
+ continue;
244
+ }
245
+ out += ch;
246
+ i++;
247
+ }
248
+ return { text: out, carry: next };
249
+ };
250
+ }
251
+ /**
252
+ * Index just past the closing `delim` starting the scan at `from`, honoring
253
+ * backslash escapes; -1 when the string does not close on this line.
254
+ */
255
+ function findStringEnd(line, from, delim) {
256
+ for (let i = from; i < line.length; i++) {
257
+ if (line[i] === "\\") {
258
+ i++;
259
+ continue;
260
+ }
261
+ if (line.startsWith(delim, i))
262
+ return i + delim.length;
263
+ }
264
+ return -1;
265
+ }
@@ -0,0 +1,14 @@
1
+ import type { RenderStream, StreamRenderer } from "./types.js";
2
+ export type { RenderCapabilities, RenderStream, StreamRenderer, } from "./types.js";
3
+ export { detectCapabilities } from "./capabilities.js";
4
+ export { renderActionPreview, PREVIEW_MAX_LINES, type Colors } from "./diff.js";
5
+ export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
6
+ export { PlainRenderer } from "./plain-renderer.js";
7
+ export { TtyRenderer } from "./tty-renderer.js";
8
+ /**
9
+ * Build the renderer for the detected environment: the managed-live-region
10
+ * {@link TtyRenderer} when cursor control is safe, otherwise the append-only
11
+ * {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
12
+ * to the {@link StreamRenderer} interface and never re-probes the terminal.
13
+ */
14
+ export declare function createRenderer(out?: RenderStream, err?: RenderStream, env?: NodeJS.ProcessEnv): StreamRenderer;
@@ -0,0 +1,20 @@
1
+ import { detectCapabilities } from "./capabilities.js";
2
+ import { PlainRenderer } from "./plain-renderer.js";
3
+ import { TtyRenderer } from "./tty-renderer.js";
4
+ export { detectCapabilities } from "./capabilities.js";
5
+ export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
6
+ export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
7
+ export { PlainRenderer } from "./plain-renderer.js";
8
+ export { TtyRenderer } from "./tty-renderer.js";
9
+ /**
10
+ * Build the renderer for the detected environment: the managed-live-region
11
+ * {@link TtyRenderer} when cursor control is safe, otherwise the append-only
12
+ * {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
13
+ * to the {@link StreamRenderer} interface and never re-probes the terminal.
14
+ */
15
+ export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
16
+ const caps = detectCapabilities(out, env);
17
+ return caps.cursor
18
+ ? new TtyRenderer(caps, out)
19
+ : new PlainRenderer(caps, out, err);
20
+ }
@@ -0,0 +1,32 @@
1
+ import type { ActionPreview } from "../tools/types.js";
2
+ import type { RenderCapabilities, RenderStream, StreamRenderer } from "./types.js";
3
+ /**
4
+ * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
5
+ * cursor-control sequences ever, and no color unless the capabilities say so
6
+ * (FORCE_COLOR); with color off, not a single ANSI byte leaves this class.
7
+ *
8
+ * Assistant text goes to `out` verbatim (beyond the per-turn leading-newline
9
+ * trim) so piped stdout stays pure model output; chrome (`note`) goes to `err`,
10
+ * matching the logger's stdout/stderr split. Transient `status` has no meaning
11
+ * in an append-only medium and is dropped — the loop reports anything durable
12
+ * via `note`.
13
+ */
14
+ export declare class PlainRenderer implements StreamRenderer {
15
+ readonly caps: RenderCapabilities;
16
+ private readonly out;
17
+ private readonly err;
18
+ private readonly colors;
19
+ /** Per-turn leading-newline trim; also tells endSegment whether to newline. */
20
+ private print;
21
+ private wroteInSegment;
22
+ constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
23
+ private newPrinter;
24
+ beginTurn(): void;
25
+ write(delta: string): void;
26
+ endSegment(): void;
27
+ note(text: string): void;
28
+ preview(preview: ActionPreview): void;
29
+ status(): void;
30
+ endTurn(): void;
31
+ close(): void;
32
+ }
@@ -0,0 +1,61 @@
1
+ import pc from "picocolors";
2
+ import { createStreamPrinter } from "../cli/stream-print.js";
3
+ import { renderActionPreview } from "./diff.js";
4
+ /**
5
+ * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
6
+ * cursor-control sequences ever, and no color unless the capabilities say so
7
+ * (FORCE_COLOR); with color off, not a single ANSI byte leaves this class.
8
+ *
9
+ * Assistant text goes to `out` verbatim (beyond the per-turn leading-newline
10
+ * trim) so piped stdout stays pure model output; chrome (`note`) goes to `err`,
11
+ * matching the logger's stdout/stderr split. Transient `status` has no meaning
12
+ * in an append-only medium and is dropped — the loop reports anything durable
13
+ * via `note`.
14
+ */
15
+ export class PlainRenderer {
16
+ caps;
17
+ out;
18
+ err;
19
+ colors;
20
+ /** Per-turn leading-newline trim; also tells endSegment whether to newline. */
21
+ print;
22
+ wroteInSegment = false;
23
+ constructor(caps, out, err) {
24
+ this.caps = caps;
25
+ this.out = out;
26
+ this.err = err;
27
+ this.colors = pc.createColors(caps.color);
28
+ this.print = this.newPrinter();
29
+ }
30
+ newPrinter() {
31
+ return createStreamPrinter((text) => {
32
+ this.wroteInSegment = true;
33
+ this.out.write(text);
34
+ });
35
+ }
36
+ beginTurn() {
37
+ this.print = this.newPrinter();
38
+ this.wroteInSegment = false;
39
+ }
40
+ write(delta) {
41
+ this.print(delta);
42
+ }
43
+ endSegment() {
44
+ if (this.wroteInSegment)
45
+ this.out.write("\n");
46
+ this.wroteInSegment = false;
47
+ }
48
+ note(text) {
49
+ this.err.write(this.colors.dim(text) + "\n");
50
+ }
51
+ preview(preview) {
52
+ const block = renderActionPreview(preview, this.colors);
53
+ if (block)
54
+ this.out.write(block + "\n");
55
+ }
56
+ status() {
57
+ // Append-only medium: transient state is dropped by design.
58
+ }
59
+ endTurn() { }
60
+ close() { }
61
+ }
@@ -0,0 +1,47 @@
1
+ import type { ActionPreview } from "../tools/types.js";
2
+ import type { RenderCapabilities, RenderStream, StreamRenderer } from "./types.js";
3
+ /**
4
+ * The interactive renderer: committed content is append-only; the one transient
5
+ * thing on screen is a single managed status line, redrawn in place.
6
+ *
7
+ * The no-flicker discipline, concretely:
8
+ * - Only the status line is ever rewritten, via `\r` + erase-line — never a
9
+ * screen clear, never a repaint of committed rows.
10
+ * - Every committed write first erases the status line, so the live region is
11
+ * always the last row and committed text can never interleave with it. A
12
+ * committed write *dismisses* the status (it does not redraw underneath), so
13
+ * nothing re-renders per delta while text streams.
14
+ * - The status text is hard-truncated to the terminal width: a soft-wrapped
15
+ * status would span two rows and erase-line could no longer clean it up.
16
+ *
17
+ * Fenced code blocks are highlighted incrementally (see highlight.ts): prose
18
+ * deltas pass straight through, code is styled line-by-line on arrival.
19
+ */
20
+ export declare class TtyRenderer implements StreamRenderer {
21
+ readonly caps: RenderCapabilities;
22
+ private readonly out;
23
+ private readonly colors;
24
+ private print;
25
+ private highlighter;
26
+ private wroteInSegment;
27
+ private statusText;
28
+ private timer;
29
+ private frame;
30
+ private closed;
31
+ constructor(caps: RenderCapabilities, out: RenderStream);
32
+ private newPrinter;
33
+ /** Append committed content, erasing the status line first if one is live. */
34
+ private commit;
35
+ /** Erase the live status line (if any) and stop the spinner. */
36
+ private dismissStatus;
37
+ private stopTimer;
38
+ private drawStatus;
39
+ beginTurn(): void;
40
+ write(delta: string): void;
41
+ endSegment(): void;
42
+ note(text: string): void;
43
+ preview(preview: ActionPreview): void;
44
+ status(text: string | null): void;
45
+ endTurn(): void;
46
+ close(): void;
47
+ }