@vincemakes/kiso-code 0.1.15 → 0.1.17

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 CHANGED
@@ -39,6 +39,9 @@ export type BodyCell = {
39
39
  state: "pending" | "approval" | "running" | "done";
40
40
  isError: boolean;
41
41
  resultText: string;
42
+ diff: import("./diff.js").DiffLine[] | null;
43
+ added: number;
44
+ removed: number;
42
45
  startedAt: number | null;
43
46
  doneAt: number | null;
44
47
  done: boolean;
@@ -96,7 +99,7 @@ export declare class Body {
96
99
  thinkingAppend(text: string): void;
97
100
  thinkingEnd(): void;
98
101
  toolStart(name: string, callId: string, input: Record<string, unknown>): void;
99
- toolApproval(callId: string): void;
102
+ toolApproval(callId: string, diff: import("./diff.js").DiffResult | null): void;
100
103
  toolRunning(callId: string): void;
101
104
  toolSucceeded(callId: string): void;
102
105
  toolFailed(callId: string, error: string): void;
package/dist/body.js CHANGED
@@ -23,10 +23,11 @@
23
23
  * NoticeCell / raw block) — deliberately NOT pi's Component interface
24
24
  * shape (ADR-0040).
25
25
  */
26
+ import { truncateDiff } from "./diff.js";
26
27
  import { displayWidth } from "./editor.js";
27
28
  import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
28
- /** The spinner glyphs, cycled by the heartbeat. */
29
- const SPINNER = ["", "", "", ""];
29
+ /** The spinner glyphs, cycled by the heartbeat (v3 §05 — the working family). */
30
+ const SPINNER = ["", "", "", ""];
30
31
  const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
31
32
  const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
32
33
  const HEARTBEAT_MS = 200; // spinner / elapsed cadence
@@ -35,7 +36,7 @@ export class Body {
35
36
  #opts;
36
37
  #cells = [];
37
38
  #nextFrozen = 0; // index of the first not-yet-printed cell
38
- #frozenRows = 0; // rows of the region occupied by printed cells
39
+ #frozenRows = 0; // the frozen area's rows filled WITHOUT scrolling (then the real LFs take over)
39
40
  #oldTailTop = 0; // the tail's previous first row — for the clear pass
40
41
  #frameTimer = null;
41
42
  #heartbeat = null;
@@ -53,6 +54,14 @@ export class Body {
53
54
  this.#active = opts.active();
54
55
  if (this.#isActive()) {
55
56
  this.#heartbeat = setInterval(() => {
57
+ // #14 (P1): the idle heartbeat PAINTS NOTHING. An idle body
58
+ // (every cell frozen) has no glyph/elapsed to advance — a
59
+ // beat that renders anyway re-paints the tail + the dock
60
+ // every 200ms with ZERO change (12s idle = 61 copies /
61
+ // ~47KB — the measured defect). Only an ACTIVE tail makes
62
+ // the beat matter: paint, then.
63
+ if (!this.#cells.some((c) => !c.done))
64
+ return;
56
65
  this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
57
66
  this.#dirty = true; // the running cells' glyph/elapsed advance
58
67
  this.#scheduleFrame();
@@ -90,11 +99,13 @@ export class Body {
90
99
  userLine(text) {
91
100
  if (!this.#isActive()) {
92
101
  this.#closeOpenThinking();
102
+ this.#closeOpenText();
93
103
  const p = palette();
94
104
  this.#write(`${p.blue}you> ${escapeTerminal(text)}${p.reset}\n`);
95
105
  return;
96
106
  }
97
107
  this.#closeOpenThinking();
108
+ this.#closeOpenText();
98
109
  this.#cells.push({ kind: "user", text, done: true });
99
110
  this.#mark();
100
111
  }
@@ -129,19 +140,27 @@ export class Body {
129
140
  this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
130
141
  if (!this.#isActive()) {
131
142
  this.#closeOpenThinking();
143
+ this.#closeOpenText();
132
144
  process.stdout.write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
133
145
  return;
134
146
  }
135
147
  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 });
148
+ 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 });
137
149
  this.#mark();
138
150
  }
139
- toolApproval(callId) {
151
+ toolApproval(callId, diff) {
140
152
  if (!this.#isActive())
141
153
  return;
142
154
  const cell = this.#toolCell(callId);
143
- if (cell !== null && cell.kind === "tool" && !cell.done)
155
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
144
156
  cell.state = "approval";
157
+ // v2e: the mini-diff renders BELOW the tool line at the approval
158
+ // moment — the human sees the change before deciding. Auto-allowed
159
+ // tools pass null (nobody is looking — no diff, no cost).
160
+ cell.diff = diff === null ? null : truncateDiff(diff.lines);
161
+ cell.added = diff?.added ?? 0;
162
+ cell.removed = diff?.removed ?? 0;
163
+ }
145
164
  this.#mark();
146
165
  }
147
166
  toolRunning(callId) {
@@ -196,6 +215,7 @@ export class Body {
196
215
  textAppend(text) {
197
216
  if (!this.#isActive()) {
198
217
  this.#closeOpenThinking();
218
+ this.#closeOpenText();
199
219
  process.stdout.write(escapeTerminal(text));
200
220
  return;
201
221
  }
@@ -205,6 +225,7 @@ export class Body {
205
225
  }
206
226
  else {
207
227
  this.#closeOpenThinking();
228
+ this.#closeOpenText();
208
229
  this.#cells.push({ kind: "text", text, done: false });
209
230
  }
210
231
  this.#mark();
@@ -223,21 +244,25 @@ export class Body {
223
244
  terminal(label, statusLine) {
224
245
  if (!this.#isActive()) {
225
246
  this.#closeOpenThinking();
247
+ this.#closeOpenText();
226
248
  // the v2c bytes: the terminal label (\ndone\n) + the status gap.
227
249
  process.stdout.write(label + renderTerminalGap(statusLine));
228
250
  return;
229
251
  }
230
252
  this.#closeOpenThinking();
253
+ this.#closeOpenText();
231
254
  this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLine, done: true });
232
255
  this.#mark();
233
256
  }
234
257
  notice(text) {
235
258
  if (!this.#isActive()) {
236
259
  this.#closeOpenThinking();
260
+ this.#closeOpenText();
237
261
  process.stdout.write(`${text}\n`);
238
262
  return;
239
263
  }
240
264
  this.#closeOpenThinking();
265
+ this.#closeOpenText();
241
266
  this.#cells.push({ kind: "notice", text, done: true });
242
267
  this.#mark();
243
268
  }
@@ -246,11 +271,13 @@ export class Body {
246
271
  raw(lines) {
247
272
  if (!this.#isActive()) {
248
273
  this.#closeOpenThinking();
274
+ this.#closeOpenText();
249
275
  for (const line of lines)
250
276
  process.stdout.write(`${line}\n`);
251
277
  return;
252
278
  }
253
279
  this.#closeOpenThinking();
280
+ this.#closeOpenText();
254
281
  this.#cells.push({ kind: "raw", lines, done: true });
255
282
  this.#mark();
256
283
  }
@@ -280,34 +307,47 @@ export class Body {
280
307
  return;
281
308
  const H = this.#opts.height();
282
309
  const W = this.#opts.width();
283
- const regionBottom = H - 3;
284
- if (regionBottom < 1)
310
+ if (H < 4)
285
311
  return;
286
312
  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
313
+ // #13 (P1), v2d-B: NO DECSTBM — overflow scrolls with a REAL LF at
314
+ // the screen's last row, so the frozen lines enter the terminal's
315
+ // NATIVE scrollback deterministically (region-scrolled lines are
316
+ // terminal-dependent; some terminals drop them — the measured v2d-A
317
+ // defect). The body fills from the top without scrolling; once full,
318
+ // every new frozen line scrolls the whole screen (\x1b[H;1H\n — the
319
+ // top line leaves into the scrollback) and lands at the body's
320
+ // bottom row, just above the active tail. The dock is redrawn after.
321
+ // The tail (the remaining ACTIVE cells) and its geometry — computed
322
+ // FIRST from the final nextFrozen, so the frozen cells are NOT in it
323
+ // (a stale tail would re-draw them — the double-render).
324
+ let nextFrozen = this.#nextFrozen;
325
+ while (nextFrozen < this.#cells.length && this.#cells[nextFrozen].done)
326
+ nextFrozen += 1;
327
+ const tail = this.#cells.slice(nextFrozen);
328
+ const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
329
+ const tailTop = Math.max(1, H - 3 - tailHeight); // v3 §03: 4 dock rows below
330
+ const writeRow = Math.max(1, tailTop - 1); // the frozen area's bottom row
331
+ let scrolled = 0;
332
+ for (let i = this.#nextFrozen; i < nextFrozen; i += 1) {
333
+ for (const line of this.#cellLines(this.#cells[i], W)) {
334
+ if (this.#frozenRows < writeRow) {
335
+ this.#frozenRows += 1;
336
+ out.push(`\x1b[${this.#frozenRows};1H\x1b[0K${line}`);
298
337
  }
299
338
  else {
300
- this.#frozenRows += 1;
339
+ out.push(`\x1b[${H};1H\n`); // the REAL LF — the whole screen scrolls
340
+ out.push(`\x1b[${writeRow};1H\x1b[0K${line}`);
341
+ scrolled += 1;
301
342
  }
302
343
  }
303
344
  this.#nextFrozen += 1;
304
345
  }
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) {
346
+ // 2. the active tail — clear its old area (shifted up by the freeze
347
+ // scrolls) and the current area, draw the cells at the body's bottom.
348
+ out.push("\x1b[?2026h");
349
+ const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop - scrolled, tailTop);
350
+ for (let row = clearFrom; row <= H - 4; row += 1) {
311
351
  out.push(`\x1b[${row};1H\x1b[0K`);
312
352
  }
313
353
  let row = tailTop;
@@ -317,24 +357,29 @@ export class Body {
317
357
  row += 1;
318
358
  }
319
359
  }
320
- this.#oldTailTop = tailTop;
321
360
  // 3. the cursor home — the input line's edit column.
322
361
  out.push(`\x1b[${H};${this.#opts.editCol()}H`);
323
362
  out.push("\x1b[?2026l");
324
363
  this.#write(out.join(""));
364
+ // 4. the dock rows — the freeze scrolls shifted them; redraw (the
365
+ // dock's own redraw re-pins the cursor at the edit position).
366
+ this.#opts.onDock?.();
325
367
  }
326
368
  // ---- cell → lines ----
327
369
  #cellLines(cell, W) {
328
370
  const p = palette();
329
371
  switch (cell.kind) {
330
372
  case "user":
331
- return [`${p.blue}you> ${escapeTerminal(cell.text)}${p.reset}`];
373
+ // v3 §02: the user message is a SGR BACKGROUND block, no
374
+ // prefix — every line carries the block's background
375
+ // (multi-line whole; resize-safe). Pipes stay plain.
376
+ return cell.text.split("\n").map((l) => `${p.bg}${escapeTerminal(l)}${p.reset}`);
332
377
  case "thinking": {
333
378
  const block = cell.text;
334
379
  const trimmed = escapeTerminal(block.trim());
335
380
  if (trimmed.length <= 100)
336
381
  return [`${p.dim}…${trimmed}${p.reset}`];
337
- return [`${p.dim}…${trimmed.slice(0, 100)} (${block.length} chars · /think shows full)${p.reset}`];
382
+ return [`${p.dim}…${trimmed.slice(0, 100)} (${block.length} chars · /think)${p.reset}`];
338
383
  }
339
384
  case "tool": {
340
385
  const name = escapeTerminal(cell.name);
@@ -345,10 +390,25 @@ export class Body {
345
390
  const err = escapeTerminal(cell.resultText.split("\n")[0].slice(0, 60));
346
391
  return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
347
392
  }
348
- return [`${p.blue}✓ ${name}${p.reset} (${summary}, ${elapsed}s)`];
393
+ const delta = cell.added + cell.removed > 0 ? `, +${cell.added} -${cell.removed}` : "";
394
+ return [`${p.blue}✓ ${name}${p.reset} (${summary}${delta}, ${elapsed}s)`];
395
+ }
396
+ if (cell.state === "approval") {
397
+ const lines = [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
398
+ // v2e: the mini-diff — ▎ blue edge (the brick motif), - red /
399
+ // + green / context dim; NO_COLOR keeps the ± prefixes plain.
400
+ if (cell.diff !== null) {
401
+ for (const d of cell.diff) {
402
+ const body = d.kind === "-"
403
+ ? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
404
+ : d.kind === "+"
405
+ ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
406
+ : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
407
+ lines.push(`${p.blue}▎${p.reset}${body}`);
408
+ }
409
+ }
410
+ return lines;
349
411
  }
350
- if (cell.state === "approval")
351
- return [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
352
412
  if (cell.state === "running") {
353
413
  const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
354
414
  return [`→ ${name} ${summary} ${p.blue}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
@@ -401,6 +461,16 @@ export class Body {
401
461
  const i = this.#toolCells.get(callId);
402
462
  return i === undefined ? null : (this.#cells[i] ?? null);
403
463
  }
464
+ /** Close an open TEXT cell when a new cell starts — the runtime emits
465
+ * no text_end (it is an adapter-level event), so the stream's next
466
+ * cell is the close signal; without it the freeze blocks behind the
467
+ * open text and everything after it re-renders in the tail forever
468
+ * (the #13 flood reproduced the overwrite). */
469
+ #closeOpenText() {
470
+ const last = this.#cells[this.#cells.length - 1];
471
+ if (last !== undefined && last.kind === "text" && !last.done)
472
+ last.done = true;
473
+ }
404
474
  /** Close an open thinking cell when a new cell starts (the block's
405
475
  * fold freezes at the transition). */
406
476
  #closeOpenThinking() {
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;
package/dist/diff.js ADDED
@@ -0,0 +1,122 @@
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
+ /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
14
+ function lcsDiff(oldLines, newLines) {
15
+ const n = oldLines.length;
16
+ const m = newLines.length;
17
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
18
+ for (let i = n - 1; i >= 0; i -= 1) {
19
+ for (let j = m - 1; j >= 0; j -= 1) {
20
+ dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
21
+ }
22
+ }
23
+ const out = [];
24
+ let i = 0;
25
+ let j = 0;
26
+ while (i < n && j < m) {
27
+ if (oldLines[i] === newLines[j]) {
28
+ out.push({ kind: " ", text: oldLines[i] });
29
+ i += 1;
30
+ j += 1;
31
+ }
32
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
33
+ out.push({ kind: "-", text: oldLines[i] });
34
+ i += 1;
35
+ }
36
+ else {
37
+ out.push({ kind: "+", text: newLines[j] });
38
+ j += 1;
39
+ }
40
+ }
41
+ while (i < n) {
42
+ out.push({ kind: "-", text: oldLines[i] });
43
+ i += 1;
44
+ }
45
+ while (j < m) {
46
+ out.push({ kind: "+", text: newLines[j] });
47
+ j += 1;
48
+ }
49
+ return out;
50
+ }
51
+ /** Keep 2 context rows around each change — the unified-style window. */
52
+ function withContext(diff) {
53
+ const out = [];
54
+ let lastAdded = -10;
55
+ for (let k = 0; k < diff.length; k += 1) {
56
+ if (diff[k].kind === " ")
57
+ continue;
58
+ const from = Math.max(0, k - 2);
59
+ const to = Math.min(diff.length - 1, k + 2);
60
+ for (let c = from; c <= to; c += 1) {
61
+ if (c > lastAdded) {
62
+ out.push(diff[c]);
63
+ lastAdded = c;
64
+ }
65
+ }
66
+ lastAdded = to;
67
+ }
68
+ return out;
69
+ }
70
+ const MAX_DIFF_LINES = 40; // the RENDERED cap
71
+ const TRUNCATE_KEEP = 18;
72
+ /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
73
+ export function truncateDiff(diff) {
74
+ if (diff.length <= MAX_DIFF_LINES)
75
+ return diff;
76
+ const omitted = diff.length - 2 * TRUNCATE_KEEP;
77
+ return [
78
+ ...diff.slice(0, TRUNCATE_KEEP),
79
+ { kind: " ", text: `… ${omitted} lines (/last for full)` },
80
+ ...diff.slice(diff.length - TRUNCATE_KEEP),
81
+ ];
82
+ }
83
+ function stats(diff) {
84
+ let added = 0;
85
+ let removed = 0;
86
+ for (const d of diff) {
87
+ if (d.kind === "+")
88
+ added += 1;
89
+ else if (d.kind === "-")
90
+ removed += 1;
91
+ }
92
+ return { added, removed };
93
+ }
94
+ /** edit_file: the search→replace windows replace in place — the changed
95
+ * region is KNOWN, so the diff is the old window vs the new window,
96
+ * context from the surrounding file. */
97
+ export function editFileDiff(oldContent, search, replace) {
98
+ const oldLines = oldContent.split("\n");
99
+ const searchLines = search.split("\n");
100
+ const replaceLines = replace.split("\n");
101
+ // Locate the search window (the first occurrence — the edit tool's own
102
+ // semantics); no occurrence → the whole file is the old side.
103
+ let at = -1;
104
+ for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
105
+ if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
106
+ at = i;
107
+ break;
108
+ }
109
+ }
110
+ const lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
111
+ return { lines, ...stats(lines) };
112
+ }
113
+ /** write_file: a new file is all +; an existing file diffs row-level
114
+ * against its old content. */
115
+ export function writeFileDiff(oldContent, newContent) {
116
+ if (oldContent === null) {
117
+ const lines = newContent.split("\n").map((text) => ({ kind: "+", text }));
118
+ return { lines, added: lines.length, removed: 0 };
119
+ }
120
+ const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
121
+ return { lines, ...stats(lines) };
122
+ }
package/dist/dock.d.ts CHANGED
@@ -4,14 +4,16 @@
4
4
  * implementation: zero dependencies, line-level ANSI, no differential
5
5
  * renderer.
6
6
  *
7
- * Layout (H = terminal height): rows 1..H-3 = the scroll region (the body
8
- * streams and scrolls here, never touching the bottom), row H-2 = the dim
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
7
+ * Layout (H = terminal height): rows 1..H-4 = the scroll region (the body
8
+ * streams and scrolls here, never touching the bottom), row H-3 = the
9
+ * upper dim dotted separator (╌), row H-2 = the input line (the blue
10
+ * brick ▌you> + the v2c editor's row readline is gone from the TTY
11
+ * path), row H-1 = the lower dotted separator, row H = the live status
12
+ * bar (v3 §03: idle "▸ <mode> · /mode to switch · …", running
13
+ * "▖ working Ns · …"; a takeover question replaces it). Bottom redraws
14
+ * are wrapped in CSI 2026 (synchronized output) to avoid flicker the
15
+ * pi trick. The visual identity is the kiso brick motif — ▌ half-block,
16
+ * dotted separators — deliberately NOT the CC rounded frame nor the pi
15
17
  * editor (ADR-0039 Amendment 2).
16
18
  *
17
19
  * Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
@@ -27,7 +29,11 @@ export declare class Dock {
27
29
  line: string;
28
30
  cursor: number;
29
31
  }, prompt: string): void;
30
- /** Enter docked mode: DECSTBM the scroll region, draw the chrome. A
32
+ /** Enter docked mode: draw the chrome. #13 (P1): the DECSTBM scroll
33
+ * region is GONE — v2d-B (ADR-0040): the body uses plain LF scrolling
34
+ * so frozen lines enter the native scrollback deterministically
35
+ * (region-scrolled lines are terminal-dependent — some terminals drop
36
+ * them). The dock rows are redrawn by the body after every scroll. A
31
37
  * TTY without a real window size (rows < 4) stays in the v2a line
32
38
  * mode — the bottom three rows need room to exist. */
33
39
  enter(): void;
@@ -36,21 +42,15 @@ export declare class Dock {
36
42
  * from main's finally on EVERY exit path (kill -9 excepted — README:
37
43
  * `reset` saves it). */
38
44
  exit(): void;
39
- /** SIGWINCH: recompute the region, redraw the chrome. */
45
+ /** SIGWINCH: recompute the size, redraw the chrome. */
40
46
  onResize(): void;
41
- /** Body output: position the cursor inside the scroll region at the
42
- * body's tracked position, write, and hand the cursor back to the
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). */
53
- writeBody(text: string): void;
47
+ /** v3 §04: bind the editor's slash-command menu state the menu rows
48
+ * render ABOVE the chrome (over the body's bottom rows; the menu
49
+ * opens while the buffer is a "/" prefix, when no tail is live). */
50
+ bindMenu(state: () => {
51
+ items: readonly import("./editor.js").MenuItem[];
52
+ selected: number;
53
+ } | null): void;
54
54
  /** The input line's edit column — prompt width + cursor + 1. The
55
55
  * dock's redraw and the body's cursor return both end here, so the
56
56
  * ACTUAL cursor always equals what the editor tracks. The width is
@@ -66,11 +66,10 @@ export declare class Dock {
66
66
  * input line by the caller's readline); clearQuestion() restores. */
67
67
  showQuestion(question: string): void;
68
68
  clearQuestion(): void;
69
- /** The bottom three rows, wrapped in CSI 2026 (synchronized output —
69
+ /** The bottom four rows, wrapped in CSI 2026 (synchronized output —
70
70
  * the pi trick against flicker). The cursor ends at the input line's
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. */
71
+ * edit position. v3 §03: the upper row, the input row, the lower
72
+ * row, the status row the status is dim (blue accents inside
73
+ * come from the CLI's composition). */
75
74
  redraw(): void;
76
75
  }