@vincemakes/kiso-code 0.1.14 → 0.1.16

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.
package/dist/body.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * v2d — the body renderer: ONE writer for the stdout scroll region.
3
+ *
4
+ * The v2b/v2c body writes streamed directly (each event wrote its bytes),
5
+ * so the tool lines, thinking folds, and text deltas could interleave in
6
+ * the same frame — and a tool's life was scattered across several writes
7
+ * (the "leak"). v2d: every event handler ONLY mutates cell state; the
8
+ * Body's render loop is the only thing that writes the region.
9
+ *
10
+ * Frozen semantics: a completed cell prints its final form into the
11
+ * scroll region ONCE and is never touched again. The ACTIVE TAIL — the
12
+ * unfinished cells — renders at the region's bottom (between the frozen
13
+ * area and the dock) and redraws in place, CSI 2026 wrapped. State
14
+ * changes coalesce to ≥16ms frames; a 200ms heartbeat drives the running
15
+ * spinners and elapsed timers. An over-height tail (rare) overflows to
16
+ * freeze by completion order.
17
+ *
18
+ * Pipes / NO_COLOR: the Body runs in PASSTHROUGH — every mutation writes
19
+ * the v2b/v2c line-mode bytes immediately, byte-for-byte (the existing
20
+ * e2e guards it). The cell renderer never activates.
21
+ *
22
+ * The cell model is ours (UserCell / ThinkingCell / ToolCell / TextCell /
23
+ * NoticeCell / raw block) — deliberately NOT pi's Component interface
24
+ * shape (ADR-0040).
25
+ */
26
+ /** One completed-or-active body line. The renderer's only state. */
27
+ export type BodyCell = {
28
+ kind: "user";
29
+ text: string;
30
+ done: true;
31
+ } | {
32
+ kind: "thinking";
33
+ text: string;
34
+ done: boolean;
35
+ } | {
36
+ kind: "tool";
37
+ name: string;
38
+ input: string;
39
+ state: "pending" | "approval" | "running" | "done";
40
+ isError: boolean;
41
+ resultText: string;
42
+ diff: import("./diff.js").DiffLine[] | null;
43
+ added: number;
44
+ removed: number;
45
+ startedAt: number | null;
46
+ doneAt: number | null;
47
+ done: boolean;
48
+ } | {
49
+ kind: "text";
50
+ text: string;
51
+ done: boolean;
52
+ } | {
53
+ kind: "notice";
54
+ text: string;
55
+ done: true;
56
+ } | {
57
+ kind: "raw";
58
+ lines: string[];
59
+ done: true;
60
+ } | {
61
+ kind: "terminal";
62
+ label: string;
63
+ line: string;
64
+ done: true;
65
+ };
66
+ export interface BodyOptions {
67
+ /** Is the cell renderer live? A color TTY with a real size — checked
68
+ * per mutation (the TIOCSWINSZ can land after main constructs us). */
69
+ active: () => boolean;
70
+ /** The terminal height (rows) — live, for the region geometry. */
71
+ height: () => number;
72
+ /** The terminal width (cols) — live, for wrap estimates. */
73
+ width: () => number;
74
+ /** The input line's edit column — the render's cursor home. */
75
+ editCol: () => number;
76
+ /** The dock's redraw (the bottom three rows) — the body never writes
77
+ * below the region, but the frame may call it to re-pin the chrome. */
78
+ onDock?: () => void;
79
+ /** The stdout writer — injectable for unit tests (default: stdout). */
80
+ write?: (s: string) => void;
81
+ }
82
+ export declare class Body {
83
+ #private;
84
+ constructor(opts: BodyOptions);
85
+ /** Teardown — flush a pending frame, stop the timers. */
86
+ close(): void;
87
+ /** The last COMPLETE thinking block, for /think. */
88
+ lastThinking(): string | null;
89
+ /** The last completed tool call, for /last. */
90
+ lastTool(): {
91
+ name: string;
92
+ input: Record<string, unknown>;
93
+ result: {
94
+ content: string;
95
+ isError: boolean;
96
+ };
97
+ } | null;
98
+ userLine(text: string): void;
99
+ thinkingAppend(text: string): void;
100
+ thinkingEnd(): void;
101
+ toolStart(name: string, callId: string, input: Record<string, unknown>): void;
102
+ toolApproval(callId: string, diff: import("./diff.js").DiffResult | null): void;
103
+ toolRunning(callId: string): void;
104
+ toolSucceeded(callId: string): void;
105
+ toolFailed(callId: string, error: string): void;
106
+ toolResult(callId: string, result: {
107
+ content: string;
108
+ isError: boolean;
109
+ }): void;
110
+ textAppend(text: string): void;
111
+ textEnd(): void;
112
+ /** The terminal's status line + the rhythm gap (one blank). */
113
+ terminal(label: string, statusLine: string): void;
114
+ notice(text: string): void;
115
+ /** A pre-rendered block (the banner, the session line, slash-command
116
+ * outputs) — frozen immediately. */
117
+ raw(lines: string[]): void;
118
+ /** The one writer. Frozen cells print once; the tail redraws in place,
119
+ * CSI 2026 wrapped; the cursor lands at the input edit column. */
120
+ render(): void;
121
+ }
package/dist/body.js ADDED
@@ -0,0 +1,478 @@
1
+ /**
2
+ * v2d — the body renderer: ONE writer for the stdout scroll region.
3
+ *
4
+ * The v2b/v2c body writes streamed directly (each event wrote its bytes),
5
+ * so the tool lines, thinking folds, and text deltas could interleave in
6
+ * the same frame — and a tool's life was scattered across several writes
7
+ * (the "leak"). v2d: every event handler ONLY mutates cell state; the
8
+ * Body's render loop is the only thing that writes the region.
9
+ *
10
+ * Frozen semantics: a completed cell prints its final form into the
11
+ * scroll region ONCE and is never touched again. The ACTIVE TAIL — the
12
+ * unfinished cells — renders at the region's bottom (between the frozen
13
+ * area and the dock) and redraws in place, CSI 2026 wrapped. State
14
+ * changes coalesce to ≥16ms frames; a 200ms heartbeat drives the running
15
+ * spinners and elapsed timers. An over-height tail (rare) overflows to
16
+ * freeze by completion order.
17
+ *
18
+ * Pipes / NO_COLOR: the Body runs in PASSTHROUGH — every mutation writes
19
+ * the v2b/v2c line-mode bytes immediately, byte-for-byte (the existing
20
+ * e2e guards it). The cell renderer never activates.
21
+ *
22
+ * The cell model is ours (UserCell / ThinkingCell / ToolCell / TextCell /
23
+ * NoticeCell / raw block) — deliberately NOT pi's Component interface
24
+ * shape (ADR-0040).
25
+ */
26
+ import { truncateDiff } from "./diff.js";
27
+ import { displayWidth } from "./editor.js";
28
+ import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
29
+ /** The spinner glyphs, cycled by the heartbeat. */
30
+ const SPINNER = ["◐", "◓", "◑", "◒"];
31
+ const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
32
+ const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
33
+ const HEARTBEAT_MS = 200; // spinner / elapsed cadence
34
+ export class Body {
35
+ #active;
36
+ #opts;
37
+ #cells = [];
38
+ #nextFrozen = 0; // index of the first not-yet-printed cell
39
+ #frozenRows = 0; // the frozen area's rows filled WITHOUT scrolling (then the real LFs take over)
40
+ #oldTailTop = 0; // the tail's previous first row — for the clear pass
41
+ #frameTimer = null;
42
+ #heartbeat = null;
43
+ #dirty = false;
44
+ #spinnerI = 0;
45
+ #lastThinking = null;
46
+ #lastTool = null;
47
+ #pendingCalls = new Map();
48
+ #pipeBuf = ""; // the passthrough's thinking buffer — the cell model needs no buffer
49
+ #toolCells = new Map(); // callId → cell index (parallel tools)
50
+ #write;
51
+ constructor(opts) {
52
+ this.#opts = opts;
53
+ this.#write = opts.write ?? ((s) => process.stdout.write(s));
54
+ this.#active = opts.active();
55
+ if (this.#isActive()) {
56
+ this.#heartbeat = setInterval(() => {
57
+ this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
58
+ this.#dirty = true; // the running cells' glyph/elapsed advance
59
+ this.#scheduleFrame();
60
+ }, HEARTBEAT_MS);
61
+ this.#heartbeat.unref();
62
+ }
63
+ }
64
+ /** Live re-check — a TTY whose size lands after construction flips in. */
65
+ #isActive() {
66
+ this.#active = this.#opts.active();
67
+ return this.#active;
68
+ }
69
+ /** Teardown — flush a pending frame, stop the timers. */
70
+ close() {
71
+ if (this.#frameTimer !== null) {
72
+ clearTimeout(this.#frameTimer);
73
+ this.#frameTimer = null;
74
+ }
75
+ if (this.#heartbeat !== null) {
76
+ clearInterval(this.#heartbeat);
77
+ this.#heartbeat = null;
78
+ }
79
+ if (this.#dirty)
80
+ this.render();
81
+ }
82
+ /** The last COMPLETE thinking block, for /think. */
83
+ lastThinking() {
84
+ return this.#lastThinking;
85
+ }
86
+ /** The last completed tool call, for /last. */
87
+ lastTool() {
88
+ return this.#lastTool;
89
+ }
90
+ // ---- mutations (the ONLY way the CLI touches the body) ----
91
+ userLine(text) {
92
+ if (!this.#isActive()) {
93
+ this.#closeOpenThinking();
94
+ this.#closeOpenText();
95
+ const p = palette();
96
+ this.#write(`${p.blue}you> ${escapeTerminal(text)}${p.reset}\n`);
97
+ return;
98
+ }
99
+ this.#closeOpenThinking();
100
+ this.#closeOpenText();
101
+ this.#cells.push({ kind: "user", text, done: true });
102
+ this.#mark();
103
+ }
104
+ thinkingAppend(text) {
105
+ if (!this.#isActive()) {
106
+ this.#pipeBuf += text; // buffered; the fold prints at the block's end
107
+ return;
108
+ }
109
+ const last = this.#cells[this.#cells.length - 1];
110
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
111
+ last.text += text;
112
+ }
113
+ else {
114
+ this.#cells.push({ kind: "thinking", text, done: false });
115
+ }
116
+ this.#mark();
117
+ }
118
+ thinkingEnd() {
119
+ const last = this.#cells[this.#cells.length - 1];
120
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
121
+ last.done = true;
122
+ this.#lastThinking = last.text;
123
+ if (!this.#isActive())
124
+ process.stdout.write(foldThinking(last.text));
125
+ this.#mark();
126
+ }
127
+ }
128
+ toolStart(name, callId, input) {
129
+ const summary = JSON.stringify(input).slice(0, TOOL_SUMMARY_MAX);
130
+ // Registered BEFORE the passthrough branch — /last and the pipe
131
+ // summary need the call on BOTH paths.
132
+ this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
133
+ if (!this.#isActive()) {
134
+ this.#closeOpenThinking();
135
+ this.#closeOpenText();
136
+ process.stdout.write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
137
+ return;
138
+ }
139
+ this.#toolCells.set(callId, this.#cells.length);
140
+ this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
141
+ this.#mark();
142
+ }
143
+ toolApproval(callId, diff) {
144
+ if (!this.#isActive())
145
+ return;
146
+ const cell = this.#toolCell(callId);
147
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
148
+ cell.state = "approval";
149
+ // v2e: the mini-diff renders BELOW the tool line at the approval
150
+ // moment — the human sees the change before deciding. Auto-allowed
151
+ // tools pass null (nobody is looking — no diff, no cost).
152
+ cell.diff = diff === null ? null : truncateDiff(diff.lines);
153
+ cell.added = diff?.added ?? 0;
154
+ cell.removed = diff?.removed ?? 0;
155
+ }
156
+ this.#mark();
157
+ }
158
+ toolRunning(callId) {
159
+ if (!this.#isActive()) {
160
+ const p = palette();
161
+ process.stdout.write(`${p.dim} running…${p.reset}\n`);
162
+ return;
163
+ }
164
+ const cell = this.#toolCell(callId);
165
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
166
+ cell.state = "running";
167
+ cell.startedAt = Date.now();
168
+ }
169
+ this.#mark();
170
+ }
171
+ toolSucceeded(callId) {
172
+ if (!this.#isActive()) {
173
+ process.stdout.write(` ok\n`);
174
+ }
175
+ }
176
+ toolFailed(callId, error) {
177
+ if (!this.#isActive()) {
178
+ const p = palette();
179
+ process.stdout.write(`${p.red} failed: ${escapeTerminal(error.slice(0, 160))}${p.reset}\n`);
180
+ }
181
+ }
182
+ toolResult(callId, result) {
183
+ const call = this.#pendingCalls.get(callId);
184
+ if (call !== undefined) {
185
+ call.result = result;
186
+ this.#lastTool = { name: call.name, input: call.input, result };
187
+ this.#pendingCalls.delete(callId);
188
+ }
189
+ if (!this.#isActive()) {
190
+ // The v2b/v2c pipe bytes: the summary line + the [result] line.
191
+ const p = palette();
192
+ process.stdout.write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
193
+ `${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
194
+ return;
195
+ }
196
+ const cell = this.#toolCell(callId);
197
+ this.#toolCells.delete(callId);
198
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
199
+ cell.state = "done";
200
+ cell.isError = result.isError;
201
+ cell.resultText = result.content;
202
+ cell.doneAt = Date.now();
203
+ cell.done = true;
204
+ }
205
+ this.#mark();
206
+ }
207
+ textAppend(text) {
208
+ if (!this.#isActive()) {
209
+ this.#closeOpenThinking();
210
+ this.#closeOpenText();
211
+ process.stdout.write(escapeTerminal(text));
212
+ return;
213
+ }
214
+ const last = this.#cells[this.#cells.length - 1];
215
+ if (last !== undefined && last.kind === "text" && !last.done) {
216
+ last.text += text;
217
+ }
218
+ else {
219
+ this.#closeOpenThinking();
220
+ this.#closeOpenText();
221
+ this.#cells.push({ kind: "text", text, done: false });
222
+ }
223
+ this.#mark();
224
+ }
225
+ textEnd() {
226
+ if (!this.#isActive()) {
227
+ process.stdout.write("\n");
228
+ return;
229
+ }
230
+ const last = this.#cells[this.#cells.length - 1];
231
+ if (last !== undefined && last.kind === "text" && !last.done)
232
+ last.done = true;
233
+ this.#mark();
234
+ }
235
+ /** The terminal's status line + the rhythm gap (one blank). */
236
+ terminal(label, statusLine) {
237
+ if (!this.#isActive()) {
238
+ this.#closeOpenThinking();
239
+ this.#closeOpenText();
240
+ // the v2c bytes: the terminal label (\ndone\n) + the status gap.
241
+ process.stdout.write(label + renderTerminalGap(statusLine));
242
+ return;
243
+ }
244
+ this.#closeOpenThinking();
245
+ this.#closeOpenText();
246
+ this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLine, done: true });
247
+ this.#mark();
248
+ }
249
+ notice(text) {
250
+ if (!this.#isActive()) {
251
+ this.#closeOpenThinking();
252
+ this.#closeOpenText();
253
+ process.stdout.write(`${text}\n`);
254
+ return;
255
+ }
256
+ this.#closeOpenThinking();
257
+ this.#closeOpenText();
258
+ this.#cells.push({ kind: "notice", text, done: true });
259
+ this.#mark();
260
+ }
261
+ /** A pre-rendered block (the banner, the session line, slash-command
262
+ * outputs) — frozen immediately. */
263
+ raw(lines) {
264
+ if (!this.#isActive()) {
265
+ this.#closeOpenThinking();
266
+ this.#closeOpenText();
267
+ for (const line of lines)
268
+ process.stdout.write(`${line}\n`);
269
+ return;
270
+ }
271
+ this.#closeOpenThinking();
272
+ this.#closeOpenText();
273
+ this.#cells.push({ kind: "raw", lines, done: true });
274
+ this.#mark();
275
+ }
276
+ // ---- rendering ----
277
+ #mark() {
278
+ if (!this.#isActive())
279
+ return;
280
+ this.#dirty = true;
281
+ this.#scheduleFrame();
282
+ }
283
+ #scheduleFrame() {
284
+ if (this.#frameTimer !== null)
285
+ return;
286
+ this.#frameTimer = setTimeout(() => {
287
+ this.#frameTimer = null;
288
+ if (this.#dirty) {
289
+ this.#dirty = false;
290
+ this.render();
291
+ }
292
+ }, FRAME_MS);
293
+ this.#frameTimer.unref();
294
+ }
295
+ /** The one writer. Frozen cells print once; the tail redraws in place,
296
+ * CSI 2026 wrapped; the cursor lands at the input edit column. */
297
+ render() {
298
+ if (!this.#isActive())
299
+ return;
300
+ const H = this.#opts.height();
301
+ const W = this.#opts.width();
302
+ if (H < 4)
303
+ return;
304
+ const out = [];
305
+ // #13 (P1), v2d-B: NO DECSTBM — overflow scrolls with a REAL LF at
306
+ // the screen's last row, so the frozen lines enter the terminal's
307
+ // NATIVE scrollback deterministically (region-scrolled lines are
308
+ // terminal-dependent; some terminals drop them — the measured v2d-A
309
+ // defect). The body fills from the top without scrolling; once full,
310
+ // every new frozen line scrolls the whole screen (\x1b[H;1H\n — the
311
+ // top line leaves into the scrollback) and lands at the body's
312
+ // bottom row, just above the active tail. The dock is redrawn after.
313
+ // The tail (the remaining ACTIVE cells) and its geometry — computed
314
+ // FIRST from the final nextFrozen, so the frozen cells are NOT in it
315
+ // (a stale tail would re-draw them — the double-render).
316
+ let nextFrozen = this.#nextFrozen;
317
+ while (nextFrozen < this.#cells.length && this.#cells[nextFrozen].done)
318
+ nextFrozen += 1;
319
+ const tail = this.#cells.slice(nextFrozen);
320
+ const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
321
+ const tailTop = Math.max(1, H - 2 - tailHeight);
322
+ const writeRow = Math.max(1, tailTop - 1); // the frozen area's bottom row
323
+ let scrolled = 0;
324
+ for (let i = this.#nextFrozen; i < nextFrozen; i += 1) {
325
+ for (const line of this.#cellLines(this.#cells[i], W)) {
326
+ if (this.#frozenRows < writeRow) {
327
+ this.#frozenRows += 1;
328
+ out.push(`\x1b[${this.#frozenRows};1H\x1b[0K${line}`);
329
+ }
330
+ else {
331
+ out.push(`\x1b[${H};1H\n`); // the REAL LF — the whole screen scrolls
332
+ out.push(`\x1b[${writeRow};1H\x1b[0K${line}`);
333
+ scrolled += 1;
334
+ }
335
+ }
336
+ this.#nextFrozen += 1;
337
+ }
338
+ // 2. the active tail — clear its old area (shifted up by the freeze
339
+ // scrolls) and the current area, draw the cells at the body's bottom.
340
+ out.push("\x1b[?2026h");
341
+ const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop - scrolled, tailTop);
342
+ for (let row = clearFrom; row <= H - 3; row += 1) {
343
+ out.push(`\x1b[${row};1H\x1b[0K`);
344
+ }
345
+ let row = tailTop;
346
+ for (const cell of tail) {
347
+ for (const line of this.#cellLines(cell, W)) {
348
+ out.push(`\x1b[${row};1H${line}`);
349
+ row += 1;
350
+ }
351
+ }
352
+ // 3. the cursor home — the input line's edit column.
353
+ out.push(`\x1b[${H};${this.#opts.editCol()}H`);
354
+ out.push("\x1b[?2026l");
355
+ this.#write(out.join(""));
356
+ // 4. the dock rows — the freeze scrolls shifted them; redraw (the
357
+ // dock's own redraw re-pins the cursor at the edit position).
358
+ this.#opts.onDock?.();
359
+ }
360
+ // ---- cell → lines ----
361
+ #cellLines(cell, W) {
362
+ const p = palette();
363
+ switch (cell.kind) {
364
+ case "user":
365
+ return [`${p.blue}you> ${escapeTerminal(cell.text)}${p.reset}`];
366
+ case "thinking": {
367
+ const block = cell.text;
368
+ const trimmed = escapeTerminal(block.trim());
369
+ if (trimmed.length <= 100)
370
+ return [`${p.dim}…${trimmed}${p.reset}`];
371
+ return [`${p.dim}…${trimmed.slice(0, 100)} (… ${block.length} chars · /think shows full)${p.reset}`];
372
+ }
373
+ case "tool": {
374
+ const name = escapeTerminal(cell.name);
375
+ const summary = escapeTerminal(cell.input);
376
+ if (cell.state === "done") {
377
+ const elapsed = cell.startedAt !== null && cell.doneAt !== null ? ((cell.doneAt - cell.startedAt) / 1000).toFixed(1) : "?";
378
+ if (cell.isError) {
379
+ const err = escapeTerminal(cell.resultText.split("\n")[0].slice(0, 60));
380
+ return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
381
+ }
382
+ const delta = cell.added + cell.removed > 0 ? `, +${cell.added} -${cell.removed}` : "";
383
+ return [`${p.blue}✓ ${name}${p.reset} (${summary}${delta}, ${elapsed}s)`];
384
+ }
385
+ if (cell.state === "approval") {
386
+ const lines = [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
387
+ // v2e: the mini-diff — ▎ blue edge (the brick motif), - red /
388
+ // + green / context dim; NO_COLOR keeps the ± prefixes plain.
389
+ if (cell.diff !== null) {
390
+ for (const d of cell.diff) {
391
+ const body = d.kind === "-"
392
+ ? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
393
+ : d.kind === "+"
394
+ ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
395
+ : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
396
+ lines.push(`${p.blue}▎${p.reset}${body}`);
397
+ }
398
+ }
399
+ return lines;
400
+ }
401
+ if (cell.state === "running") {
402
+ const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
403
+ return [`→ ${name} ${summary} ${p.blue}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
404
+ }
405
+ return [`→ ${name} ${summary}`];
406
+ }
407
+ case "text": {
408
+ const text = escapeTerminal(cell.text);
409
+ const wrapped = this.#wrap(text, W);
410
+ return wrapped.length > 0 ? wrapped : [""];
411
+ }
412
+ case "notice":
413
+ return [escapeTerminal(cell.text)];
414
+ case "raw":
415
+ return cell.lines.map((l) => escapeTerminal(l));
416
+ case "terminal":
417
+ // the honest label (done / aborted / error) + the status + the
418
+ // rhythm gap blank
419
+ return [cell.label, cell.line, ""];
420
+ }
421
+ }
422
+ #cellHeight(cell, W) {
423
+ const lines = this.#cellLines(cell, W);
424
+ return Math.max(1, lines.length);
425
+ }
426
+ /** Wrap a body text by display width (the terminal's own wrapping,
427
+ * approximated — documented; the region clamp keeps the dock safe). */
428
+ #wrap(text, W) {
429
+ if (W < 4)
430
+ return [text];
431
+ const out = [];
432
+ let current = "";
433
+ let width = 0;
434
+ for (const ch of text) {
435
+ const cw = displayWidth(ch);
436
+ if (ch === "\n" || width + cw > W) {
437
+ out.push(current);
438
+ current = "";
439
+ width = 0;
440
+ if (ch === "\n")
441
+ continue;
442
+ }
443
+ current += ch;
444
+ width += cw;
445
+ }
446
+ out.push(current);
447
+ return out;
448
+ }
449
+ #toolCell(callId) {
450
+ const i = this.#toolCells.get(callId);
451
+ return i === undefined ? null : (this.#cells[i] ?? null);
452
+ }
453
+ /** Close an open TEXT cell when a new cell starts — the runtime emits
454
+ * no text_end (it is an adapter-level event), so the stream's next
455
+ * cell is the close signal; without it the freeze blocks behind the
456
+ * open text and everything after it re-renders in the tail forever
457
+ * (the #13 flood reproduced the overwrite). */
458
+ #closeOpenText() {
459
+ const last = this.#cells[this.#cells.length - 1];
460
+ if (last !== undefined && last.kind === "text" && !last.done)
461
+ last.done = true;
462
+ }
463
+ /** Close an open thinking cell when a new cell starts (the block's
464
+ * fold freezes at the transition). */
465
+ #closeOpenThinking() {
466
+ if (!this.#isActive() && this.#pipeBuf !== "") {
467
+ this.#lastThinking = this.#pipeBuf;
468
+ process.stdout.write(foldThinking(this.#pipeBuf));
469
+ this.#pipeBuf = "";
470
+ return;
471
+ }
472
+ const last = this.#cells[this.#cells.length - 1];
473
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
474
+ last.done = true;
475
+ this.#lastThinking = last.text;
476
+ }
477
+ }
478
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
+ * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
+ * the approval moment ONLY — the frozen summary stays one line (v2d's
5
+ * anti-leak principle), /last has the full data.
6
+ *
7
+ * edit_file diffs IN PLACE (the search→replace windows are known — no
8
+ * general engine needed); write_file does a row-level LCS over the old
9
+ * file (small files are the target). Context: 2 rows each side. The
10
+ * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
+ * from the full diff.
12
+ */
13
+ /** The diff block's per-row kind. */
14
+ export type DiffLine = {
15
+ kind: "-" | "+" | " ";
16
+ text: string;
17
+ };
18
+ export interface DiffResult {
19
+ /** The FULL diff (with context, not truncated) — the display truncates. */
20
+ lines: DiffLine[];
21
+ added: number;
22
+ removed: number;
23
+ }
24
+ /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
25
+ export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
26
+ /** edit_file: the search→replace windows replace in place — the changed
27
+ * region is KNOWN, so the diff is the old window vs the new window,
28
+ * context from the surrounding file. */
29
+ export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
30
+ /** write_file: a new file is all +; an existing file diffs row-level
31
+ * against its old content. */
32
+ export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;