@vincemakes/kiso-code 0.1.13 → 0.1.15

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,118 @@
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
+ startedAt: number | null;
43
+ doneAt: number | null;
44
+ done: boolean;
45
+ } | {
46
+ kind: "text";
47
+ text: string;
48
+ done: boolean;
49
+ } | {
50
+ kind: "notice";
51
+ text: string;
52
+ done: true;
53
+ } | {
54
+ kind: "raw";
55
+ lines: string[];
56
+ done: true;
57
+ } | {
58
+ kind: "terminal";
59
+ label: string;
60
+ line: string;
61
+ done: true;
62
+ };
63
+ export interface BodyOptions {
64
+ /** Is the cell renderer live? A color TTY with a real size — checked
65
+ * per mutation (the TIOCSWINSZ can land after main constructs us). */
66
+ active: () => boolean;
67
+ /** The terminal height (rows) — live, for the region geometry. */
68
+ height: () => number;
69
+ /** The terminal width (cols) — live, for wrap estimates. */
70
+ width: () => number;
71
+ /** The input line's edit column — the render's cursor home. */
72
+ editCol: () => number;
73
+ /** The dock's redraw (the bottom three rows) — the body never writes
74
+ * below the region, but the frame may call it to re-pin the chrome. */
75
+ onDock?: () => void;
76
+ /** The stdout writer — injectable for unit tests (default: stdout). */
77
+ write?: (s: string) => void;
78
+ }
79
+ export declare class Body {
80
+ #private;
81
+ constructor(opts: BodyOptions);
82
+ /** Teardown — flush a pending frame, stop the timers. */
83
+ close(): void;
84
+ /** The last COMPLETE thinking block, for /think. */
85
+ lastThinking(): string | null;
86
+ /** The last completed tool call, for /last. */
87
+ lastTool(): {
88
+ name: string;
89
+ input: Record<string, unknown>;
90
+ result: {
91
+ content: string;
92
+ isError: boolean;
93
+ };
94
+ } | null;
95
+ userLine(text: string): void;
96
+ thinkingAppend(text: string): void;
97
+ thinkingEnd(): void;
98
+ toolStart(name: string, callId: string, input: Record<string, unknown>): void;
99
+ toolApproval(callId: string): void;
100
+ toolRunning(callId: string): void;
101
+ toolSucceeded(callId: string): void;
102
+ toolFailed(callId: string, error: string): void;
103
+ toolResult(callId: string, result: {
104
+ content: string;
105
+ isError: boolean;
106
+ }): void;
107
+ textAppend(text: string): void;
108
+ textEnd(): void;
109
+ /** The terminal's status line + the rhythm gap (one blank). */
110
+ terminal(label: string, statusLine: string): void;
111
+ notice(text: string): void;
112
+ /** A pre-rendered block (the banner, the session line, slash-command
113
+ * outputs) — frozen immediately. */
114
+ raw(lines: string[]): void;
115
+ /** The one writer. Frozen cells print once; the tail redraws in place,
116
+ * CSI 2026 wrapped; the cursor lands at the input edit column. */
117
+ render(): void;
118
+ }
package/dist/body.js ADDED
@@ -0,0 +1,419 @@
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 { displayWidth } from "./editor.js";
27
+ import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
28
+ /** The spinner glyphs, cycled by the heartbeat. */
29
+ const SPINNER = ["◐", "◓", "◑", "◒"];
30
+ const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
31
+ const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
32
+ const HEARTBEAT_MS = 200; // spinner / elapsed cadence
33
+ export class Body {
34
+ #active;
35
+ #opts;
36
+ #cells = [];
37
+ #nextFrozen = 0; // index of the first not-yet-printed cell
38
+ #frozenRows = 0; // rows of the region occupied by printed cells
39
+ #oldTailTop = 0; // the tail's previous first row — for the clear pass
40
+ #frameTimer = null;
41
+ #heartbeat = null;
42
+ #dirty = false;
43
+ #spinnerI = 0;
44
+ #lastThinking = null;
45
+ #lastTool = null;
46
+ #pendingCalls = new Map();
47
+ #pipeBuf = ""; // the passthrough's thinking buffer — the cell model needs no buffer
48
+ #toolCells = new Map(); // callId → cell index (parallel tools)
49
+ #write;
50
+ constructor(opts) {
51
+ this.#opts = opts;
52
+ this.#write = opts.write ?? ((s) => process.stdout.write(s));
53
+ this.#active = opts.active();
54
+ if (this.#isActive()) {
55
+ this.#heartbeat = setInterval(() => {
56
+ this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
57
+ this.#dirty = true; // the running cells' glyph/elapsed advance
58
+ this.#scheduleFrame();
59
+ }, HEARTBEAT_MS);
60
+ this.#heartbeat.unref();
61
+ }
62
+ }
63
+ /** Live re-check — a TTY whose size lands after construction flips in. */
64
+ #isActive() {
65
+ this.#active = this.#opts.active();
66
+ return this.#active;
67
+ }
68
+ /** Teardown — flush a pending frame, stop the timers. */
69
+ close() {
70
+ if (this.#frameTimer !== null) {
71
+ clearTimeout(this.#frameTimer);
72
+ this.#frameTimer = null;
73
+ }
74
+ if (this.#heartbeat !== null) {
75
+ clearInterval(this.#heartbeat);
76
+ this.#heartbeat = null;
77
+ }
78
+ if (this.#dirty)
79
+ this.render();
80
+ }
81
+ /** The last COMPLETE thinking block, for /think. */
82
+ lastThinking() {
83
+ return this.#lastThinking;
84
+ }
85
+ /** The last completed tool call, for /last. */
86
+ lastTool() {
87
+ return this.#lastTool;
88
+ }
89
+ // ---- mutations (the ONLY way the CLI touches the body) ----
90
+ userLine(text) {
91
+ if (!this.#isActive()) {
92
+ this.#closeOpenThinking();
93
+ const p = palette();
94
+ this.#write(`${p.blue}you> ${escapeTerminal(text)}${p.reset}\n`);
95
+ return;
96
+ }
97
+ this.#closeOpenThinking();
98
+ this.#cells.push({ kind: "user", text, done: true });
99
+ this.#mark();
100
+ }
101
+ thinkingAppend(text) {
102
+ if (!this.#isActive()) {
103
+ this.#pipeBuf += text; // buffered; the fold prints at the block's end
104
+ return;
105
+ }
106
+ const last = this.#cells[this.#cells.length - 1];
107
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
108
+ last.text += text;
109
+ }
110
+ else {
111
+ this.#cells.push({ kind: "thinking", text, done: false });
112
+ }
113
+ this.#mark();
114
+ }
115
+ thinkingEnd() {
116
+ const last = this.#cells[this.#cells.length - 1];
117
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
118
+ last.done = true;
119
+ this.#lastThinking = last.text;
120
+ if (!this.#isActive())
121
+ process.stdout.write(foldThinking(last.text));
122
+ this.#mark();
123
+ }
124
+ }
125
+ toolStart(name, callId, input) {
126
+ const summary = JSON.stringify(input).slice(0, TOOL_SUMMARY_MAX);
127
+ // Registered BEFORE the passthrough branch — /last and the pipe
128
+ // summary need the call on BOTH paths.
129
+ this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
130
+ if (!this.#isActive()) {
131
+ this.#closeOpenThinking();
132
+ process.stdout.write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
133
+ return;
134
+ }
135
+ this.#toolCells.set(callId, this.#cells.length);
136
+ this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", startedAt: null, doneAt: null, done: false });
137
+ this.#mark();
138
+ }
139
+ toolApproval(callId) {
140
+ if (!this.#isActive())
141
+ return;
142
+ const cell = this.#toolCell(callId);
143
+ if (cell !== null && cell.kind === "tool" && !cell.done)
144
+ cell.state = "approval";
145
+ this.#mark();
146
+ }
147
+ toolRunning(callId) {
148
+ if (!this.#isActive()) {
149
+ const p = palette();
150
+ process.stdout.write(`${p.dim} running…${p.reset}\n`);
151
+ return;
152
+ }
153
+ const cell = this.#toolCell(callId);
154
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
155
+ cell.state = "running";
156
+ cell.startedAt = Date.now();
157
+ }
158
+ this.#mark();
159
+ }
160
+ toolSucceeded(callId) {
161
+ if (!this.#isActive()) {
162
+ process.stdout.write(` ok\n`);
163
+ }
164
+ }
165
+ toolFailed(callId, error) {
166
+ if (!this.#isActive()) {
167
+ const p = palette();
168
+ process.stdout.write(`${p.red} failed: ${escapeTerminal(error.slice(0, 160))}${p.reset}\n`);
169
+ }
170
+ }
171
+ toolResult(callId, result) {
172
+ const call = this.#pendingCalls.get(callId);
173
+ if (call !== undefined) {
174
+ call.result = result;
175
+ this.#lastTool = { name: call.name, input: call.input, result };
176
+ this.#pendingCalls.delete(callId);
177
+ }
178
+ if (!this.#isActive()) {
179
+ // The v2b/v2c pipe bytes: the summary line + the [result] line.
180
+ const p = palette();
181
+ process.stdout.write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
182
+ `${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
183
+ return;
184
+ }
185
+ const cell = this.#toolCell(callId);
186
+ this.#toolCells.delete(callId);
187
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
188
+ cell.state = "done";
189
+ cell.isError = result.isError;
190
+ cell.resultText = result.content;
191
+ cell.doneAt = Date.now();
192
+ cell.done = true;
193
+ }
194
+ this.#mark();
195
+ }
196
+ textAppend(text) {
197
+ if (!this.#isActive()) {
198
+ this.#closeOpenThinking();
199
+ process.stdout.write(escapeTerminal(text));
200
+ return;
201
+ }
202
+ const last = this.#cells[this.#cells.length - 1];
203
+ if (last !== undefined && last.kind === "text" && !last.done) {
204
+ last.text += text;
205
+ }
206
+ else {
207
+ this.#closeOpenThinking();
208
+ this.#cells.push({ kind: "text", text, done: false });
209
+ }
210
+ this.#mark();
211
+ }
212
+ textEnd() {
213
+ if (!this.#isActive()) {
214
+ process.stdout.write("\n");
215
+ return;
216
+ }
217
+ const last = this.#cells[this.#cells.length - 1];
218
+ if (last !== undefined && last.kind === "text" && !last.done)
219
+ last.done = true;
220
+ this.#mark();
221
+ }
222
+ /** The terminal's status line + the rhythm gap (one blank). */
223
+ terminal(label, statusLine) {
224
+ if (!this.#isActive()) {
225
+ this.#closeOpenThinking();
226
+ // the v2c bytes: the terminal label (\ndone\n) + the status gap.
227
+ process.stdout.write(label + renderTerminalGap(statusLine));
228
+ return;
229
+ }
230
+ this.#closeOpenThinking();
231
+ this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLine, done: true });
232
+ this.#mark();
233
+ }
234
+ notice(text) {
235
+ if (!this.#isActive()) {
236
+ this.#closeOpenThinking();
237
+ process.stdout.write(`${text}\n`);
238
+ return;
239
+ }
240
+ this.#closeOpenThinking();
241
+ this.#cells.push({ kind: "notice", text, done: true });
242
+ this.#mark();
243
+ }
244
+ /** A pre-rendered block (the banner, the session line, slash-command
245
+ * outputs) — frozen immediately. */
246
+ raw(lines) {
247
+ if (!this.#isActive()) {
248
+ this.#closeOpenThinking();
249
+ for (const line of lines)
250
+ process.stdout.write(`${line}\n`);
251
+ return;
252
+ }
253
+ this.#closeOpenThinking();
254
+ this.#cells.push({ kind: "raw", lines, done: true });
255
+ this.#mark();
256
+ }
257
+ // ---- rendering ----
258
+ #mark() {
259
+ if (!this.#isActive())
260
+ return;
261
+ this.#dirty = true;
262
+ this.#scheduleFrame();
263
+ }
264
+ #scheduleFrame() {
265
+ if (this.#frameTimer !== null)
266
+ return;
267
+ this.#frameTimer = setTimeout(() => {
268
+ this.#frameTimer = null;
269
+ if (this.#dirty) {
270
+ this.#dirty = false;
271
+ this.render();
272
+ }
273
+ }, FRAME_MS);
274
+ this.#frameTimer.unref();
275
+ }
276
+ /** The one writer. Frozen cells print once; the tail redraws in place,
277
+ * CSI 2026 wrapped; the cursor lands at the input edit column. */
278
+ render() {
279
+ if (!this.#isActive())
280
+ return;
281
+ const H = this.#opts.height();
282
+ const W = this.#opts.width();
283
+ const regionBottom = H - 3;
284
+ if (regionBottom < 1)
285
+ return;
286
+ const out = [];
287
+ out.push("\x1b[?2026h");
288
+ // 1. freeze completed cells — print their final form at the frozen
289
+ // area's next rows. The terminal scrolls the region when full.
290
+ while (this.#nextFrozen < this.#cells.length && this.#cells[this.#nextFrozen].done) {
291
+ const cell = this.#cells[this.#nextFrozen];
292
+ const lines = this.#cellLines(cell, W);
293
+ for (const line of lines) {
294
+ const row = Math.min(this.#frozenRows + 1, regionBottom);
295
+ out.push(`\x1b[${row};1H\x1b[0K${line}`);
296
+ if (this.#frozenRows + 1 > regionBottom) {
297
+ out.push("\n"); // the region scrolls — the frozen rows shift up
298
+ }
299
+ else {
300
+ this.#frozenRows += 1;
301
+ }
302
+ }
303
+ this.#nextFrozen += 1;
304
+ }
305
+ // 2. the active tail — clear its old area, draw the cells.
306
+ const tail = this.#cells.slice(this.#nextFrozen);
307
+ const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
308
+ const tailTop = Math.max(1, regionBottom - tailHeight + 1);
309
+ const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop, tailTop);
310
+ for (let row = clearFrom; row <= regionBottom; row += 1) {
311
+ out.push(`\x1b[${row};1H\x1b[0K`);
312
+ }
313
+ let row = tailTop;
314
+ for (const cell of tail) {
315
+ for (const line of this.#cellLines(cell, W)) {
316
+ out.push(`\x1b[${row};1H${line}`);
317
+ row += 1;
318
+ }
319
+ }
320
+ this.#oldTailTop = tailTop;
321
+ // 3. the cursor home — the input line's edit column.
322
+ out.push(`\x1b[${H};${this.#opts.editCol()}H`);
323
+ out.push("\x1b[?2026l");
324
+ this.#write(out.join(""));
325
+ }
326
+ // ---- cell → lines ----
327
+ #cellLines(cell, W) {
328
+ const p = palette();
329
+ switch (cell.kind) {
330
+ case "user":
331
+ return [`${p.blue}you> ${escapeTerminal(cell.text)}${p.reset}`];
332
+ case "thinking": {
333
+ const block = cell.text;
334
+ const trimmed = escapeTerminal(block.trim());
335
+ if (trimmed.length <= 100)
336
+ return [`${p.dim}…${trimmed}${p.reset}`];
337
+ return [`${p.dim}…${trimmed.slice(0, 100)} (… ${block.length} chars · /think shows full)${p.reset}`];
338
+ }
339
+ case "tool": {
340
+ const name = escapeTerminal(cell.name);
341
+ const summary = escapeTerminal(cell.input);
342
+ if (cell.state === "done") {
343
+ const elapsed = cell.startedAt !== null && cell.doneAt !== null ? ((cell.doneAt - cell.startedAt) / 1000).toFixed(1) : "?";
344
+ if (cell.isError) {
345
+ const err = escapeTerminal(cell.resultText.split("\n")[0].slice(0, 60));
346
+ return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
347
+ }
348
+ return [`${p.blue}✓ ${name}${p.reset} (${summary}, ${elapsed}s)`];
349
+ }
350
+ if (cell.state === "approval")
351
+ return [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
352
+ if (cell.state === "running") {
353
+ const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
354
+ return [`→ ${name} ${summary} ${p.blue}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
355
+ }
356
+ return [`→ ${name} ${summary}`];
357
+ }
358
+ case "text": {
359
+ const text = escapeTerminal(cell.text);
360
+ const wrapped = this.#wrap(text, W);
361
+ return wrapped.length > 0 ? wrapped : [""];
362
+ }
363
+ case "notice":
364
+ return [escapeTerminal(cell.text)];
365
+ case "raw":
366
+ return cell.lines.map((l) => escapeTerminal(l));
367
+ case "terminal":
368
+ // the honest label (done / aborted / error) + the status + the
369
+ // rhythm gap blank
370
+ return [cell.label, cell.line, ""];
371
+ }
372
+ }
373
+ #cellHeight(cell, W) {
374
+ const lines = this.#cellLines(cell, W);
375
+ return Math.max(1, lines.length);
376
+ }
377
+ /** Wrap a body text by display width (the terminal's own wrapping,
378
+ * approximated — documented; the region clamp keeps the dock safe). */
379
+ #wrap(text, W) {
380
+ if (W < 4)
381
+ return [text];
382
+ const out = [];
383
+ let current = "";
384
+ let width = 0;
385
+ for (const ch of text) {
386
+ const cw = displayWidth(ch);
387
+ if (ch === "\n" || width + cw > W) {
388
+ out.push(current);
389
+ current = "";
390
+ width = 0;
391
+ if (ch === "\n")
392
+ continue;
393
+ }
394
+ current += ch;
395
+ width += cw;
396
+ }
397
+ out.push(current);
398
+ return out;
399
+ }
400
+ #toolCell(callId) {
401
+ const i = this.#toolCells.get(callId);
402
+ return i === undefined ? null : (this.#cells[i] ?? null);
403
+ }
404
+ /** Close an open thinking cell when a new cell starts (the block's
405
+ * fold freezes at the transition). */
406
+ #closeOpenThinking() {
407
+ if (!this.#isActive() && this.#pipeBuf !== "") {
408
+ this.#lastThinking = this.#pipeBuf;
409
+ process.stdout.write(foldThinking(this.#pipeBuf));
410
+ this.#pipeBuf = "";
411
+ return;
412
+ }
413
+ const last = this.#cells[this.#cells.length - 1];
414
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
415
+ last.done = true;
416
+ this.#lastThinking = last.text;
417
+ }
418
+ }
419
+ }
package/dist/dock.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
- * v2b — the bottom-anchored UI (TTY + color only). Borrows pi-tui's IDEA —
3
- * a DECSTBM scroll region with a reserved bottom — without its
2
+ * v2b/v2c — the bottom-anchored UI (TTY + color only). Borrows pi-tui's
3
+ * IDEA — a DECSTBM scroll region with a reserved bottom — without its
4
4
  * implementation: zero dependencies, line-level ANSI, no differential
5
5
  * renderer.
6
6
  *
7
7
  * Layout (H = terminal height): rows 1..H-3 = the scroll region (the body
8
8
  * streams and scrolls here, never touching the bottom), row H-2 = the dim
9
- * separator, row H-1 = the live status bar (or a takeover question), row
10
- * H = the input line (blue you> + readline). Bottom redraws are wrapped in
11
- * CSI 2026 (synchronized output) to avoid flicker the pi trick.
9
+ * dotted separator (╌), row H-1 = the live status bar (or a takeover
10
+ * question), row H = the input line (the blue brick ▌you> + the v2c
11
+ * editor's row readline is gone from the TTY path). Bottom redraws are
12
+ * wrapped in CSI 2026 (synchronized output) to avoid flicker — the pi
13
+ * trick. The visual identity is the kiso brick motif — ▌ half-block,
14
+ * dotted separator — deliberately NOT the CC rounded frame nor the pi
15
+ * editor (ADR-0039 Amendment 2).
12
16
  *
13
17
  * Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
14
18
  * byte-for-byte (the existing e2e assertions guard it).
@@ -36,10 +40,24 @@ export declare class Dock {
36
40
  onResize(): void;
37
41
  /** Body output: position the cursor inside the scroll region at the
38
42
  * body's tracked position, write, and hand the cursor back to the
39
- * input line. The row/col tracking is approximate for width-wrapped
40
- * and wide-char lines (documented) — the region clamp keeps the
41
- * bottom rows safe regardless. */
43
+ * input line's EDIT position. The row/col tracking is approximate for
44
+ * width-wrapped and wide-char lines (documented) — the region clamp
45
+ * keeps the bottom rows safe regardless.
46
+ *
47
+ * The edit-position return is a correctness requirement, not
48
+ * cosmetics: readline tracks its cursor internally and NEVER
49
+ * re-syncs after an external move — a body write that left the
50
+ * cursor at column 1 made the next keystroke overwrite the prompt
51
+ * (probe-confirmed; the dock's redraw self-repaired ~200ms later,
52
+ * which read the user as cursor drift). */
42
53
  writeBody(text: string): void;
54
+ /** The input line's edit column — prompt width + cursor + 1. The
55
+ * dock's redraw and the body's cursor return both end here, so the
56
+ * ACTUAL cursor always equals what the editor tracks. The width is
57
+ * DISPLAY width (the editor's cursor column is already width-based —
58
+ * the CJK drift root cause, editor.ts). v2d: public — the Body's
59
+ * render loop ends at this column. */
60
+ editCol(): number;
43
61
  /** The status bar's base text (usage, ctx, session, …). */
44
62
  setStatus(text: string): void;
45
63
  /** The live tail — the spinner glyph or "running <tool> Ns". */
@@ -50,6 +68,9 @@ export declare class Dock {
50
68
  clearQuestion(): void;
51
69
  /** The bottom three rows, wrapped in CSI 2026 (synchronized output —
52
70
  * the pi trick against flicker). The cursor ends at the input line's
53
- * edit position. */
71
+ * edit position. v2c: the separator is the dim dotted ╌ (a weaker
72
+ * presence than the solid ─), the status line is dim (blue accents
73
+ * inside come from the CLI's composition), the input row is the blue
74
+ * brick ▌you> + the editor's visible slice. */
54
75
  redraw(): void;
55
76
  }