@vincemakes/kiso-tui 0.1.34 → 0.1.36

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.
@@ -177,15 +177,25 @@ class ThinkingFold {
177
177
  render(W, _ctx) {
178
178
  const block = this.cell.text;
179
179
  const trimmed = escapeTerminal(block.trim());
180
+ // the SHORT branch is width-aware TOO: the ≤100 short-circuit was
181
+ // W-blind — a short block at a narrow width returned the line
182
+ // UNFOLDED and tripped invariant ① (the crash class still live on
183
+ // npm for short /think blocks after a resize).
180
184
  if (trimmed.length <= 100)
181
- return [`${palette().dim}…${trimmed}${palette().reset}`];
185
+ return [`${palette().dim}…${widthCut(trimmed, Math.max(1, W - 1))}${palette().reset}`];
182
186
  const suffix = ` (${block.length} chars · /think)`;
183
187
  const slice = Math.max(1, W - 1 - suffix.length);
184
188
  return [`${palette().dim}…${widthCut(trimmed, slice)}${suffix}${palette().reset}`];
185
189
  }
186
190
  }
187
- /** The tool execution line + the approval mini-diff — every state is
188
- * its own render; the lines fold (the summary gives way first). */
191
+ /** The tool execution line + the bounded block — every state is its
192
+ * own render; the lines fold (the summary gives way first). W7 (the
193
+ * flow contract): the block's BODY (the rows below the header) is
194
+ * capped in SCREEN rows AFTER the fold, at the current width — the
195
+ * renderer-cut row (`└ +N … · ctrl+r`) sits INSIDE the cap (a
196
+ * truncated block is cap−1 output rows + the cut row); the TOOL-cut
197
+ * row (`└ capped by …` — the tool's OWN truncation note, W10) is a
198
+ * DIFFERENT fact, never counted in the output cap. */
189
199
  class ToolExecution {
190
200
  cell;
191
201
  constructor(cell) {
@@ -201,29 +211,162 @@ class ToolExecution {
201
211
  const line = c.isError
202
212
  ? `${p.red}✗ ${name} (${escapeTerminal(c.resultText.split("\n")[0].slice(0, 60))}, ${elapsed}s)${p.reset}`
203
213
  : `${p.bold}✓ ${name}${p.reset} (${summary}${c.added + c.removed > 0 ? `, +${c.added} -${c.removed}` : ""}, ${elapsed}s)`;
204
- return foldLine(line, W);
214
+ const out = foldLine(line, W);
215
+ out.push(...toolBlockBody(c, W));
216
+ return out;
205
217
  }
206
218
  if (c.state === "approval") {
207
- const lines = foldLine(`→ ${name} ${summary} ${p.bold}⏸${p.reset}`, W);
208
- if (c.diff !== null) {
209
- for (const d of c.diff) {
210
- const body = d.kind === "-"
211
- ? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
212
- : d.kind === "+"
213
- ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
214
- : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
215
- lines.push(...foldLine(`${p.bold}▎${p.reset}${body}`, W));
216
- }
217
- }
218
- return lines;
219
+ const out = foldLine(`→ ${name} ${summary} ${p.bold}⏸${p.reset}`, W);
220
+ out.push(...toolBlockBody(c, W));
221
+ return out;
219
222
  }
220
223
  if (c.state === "running") {
221
224
  const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
222
- return foldLine(`→ ${name} ${summary} ${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`, W);
225
+ const out = foldLine(`→ ${name} ${summary} ${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`, W);
226
+ out.push(...toolBlockBody(c, W));
227
+ return out;
223
228
  }
224
229
  return foldLine(`→ ${name} ${summary}`, W);
225
230
  }
226
231
  }
232
+ // ---- the bounded-block flow contract (W7, W8, W10) ----
233
+ /** The caps — screen rows counted AFTER the fold, at the current width
234
+ * (the W7 table). The renderer-cut row is inside the cap. */
235
+ const CAP_SHELL_SETTLED = 5; // the shell output tail, settled
236
+ const CAP_LIVE_WINDOW = 3; // the running tool's FIXED window (W8)
237
+ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
238
+ const CAP_ERROR = 3; // the error text head
239
+ /** The block body rows' prefixes (W2's gutter table): │ a bounded
240
+ * block's body, └ the block's last row — what was cut, where the rest
241
+ * is. Structural (constraint 1) — they survive a pipe. */
242
+ const BODY_ROW = " │ ";
243
+ const CUT_ROW = " └ ";
244
+ const blockMemo = new WeakMap();
245
+ /** The block's body rows below the header (memoized, W9). */
246
+ function toolBlockBody(c, W) {
247
+ const memo = blockMemo.get(c);
248
+ const state = `${c.state}:${c.isError}:${c.name}`;
249
+ const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
250
+ if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
251
+ return memo.rows;
252
+ const p = palette();
253
+ const rows = c.state === "done"
254
+ ? c.isError
255
+ ? errorBody(c.resultText, W)
256
+ : c.name.startsWith("shell")
257
+ ? shellTail(c.resultText, W)
258
+ : []
259
+ : c.state === "running"
260
+ ? liveWindow(c.resultText, W)
261
+ : c.state === "approval"
262
+ ? diffBody(c.diff, W)
263
+ : [];
264
+ const note = toolCutNote(c.name, c.resultText);
265
+ if (note !== null)
266
+ rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
267
+ blockMemo.set(c, { width: W, state, content, rows });
268
+ return rows;
269
+ }
270
+ /** Fold result text into dim body rows (the BODY_ROW prefix): escape,
271
+ * split, fold each line at W−prefix; trailing empty rows (the result's
272
+ * final newline) drop. */
273
+ function blockRows(text, W) {
274
+ const p = palette();
275
+ const textW = Math.max(1, W - visibleWidth(BODY_ROW));
276
+ const rows = [];
277
+ for (const raw of escapeTerminal(text).split("\n")) {
278
+ for (const row of foldLine(raw, textW))
279
+ rows.push(`${p.dim}${BODY_ROW}${row}${p.reset}`);
280
+ }
281
+ while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(BODY_ROW))
282
+ rows.pop();
283
+ return rows;
284
+ }
285
+ /** The shell output tail, settled: the LAST rows, capped at 5 — the
286
+ * renderer cut at the block's bottom ("earlier rows" — the conclusion
287
+ * is at the end, pi's truncateToVisualLines direction). */
288
+ function shellTail(text, W) {
289
+ const p = palette();
290
+ const rows = blockRows(text, W);
291
+ if (rows.length <= CAP_SHELL_SETTLED)
292
+ return rows;
293
+ const kept = CAP_SHELL_SETTLED - 1;
294
+ const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - kept} earlier rows · ctrl+r${p.reset}`, W);
295
+ return [...rows.slice(rows.length - kept), ...cut];
296
+ }
297
+ /** The error text head: the FIRST rows, capped at 3 — the answer is at
298
+ * the start (opencode's collapseToolOutput direction). The header row
299
+ * already summarizes the first line, so the body starts at line 2. */
300
+ function errorBody(text, W) {
301
+ const p = palette();
302
+ const rows = blockRows(text.split("\n").slice(1).join("\n"), W);
303
+ if (rows.length <= CAP_ERROR)
304
+ return rows;
305
+ const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+r${p.reset}`, W);
306
+ return [...rows.slice(0, CAP_ERROR - 1), ...cut];
307
+ }
308
+ /** The running tool's FIXED-height window (W8): exactly 3 rows from
309
+ * the FIRST frame — blank-padded before output arrives, the renderer
310
+ * cut inside the window. The height changes exactly once, at settle —
311
+ * a cell that grows mid-list would shift every row after it on every
312
+ * delta (the parallel-tools jitter). */
313
+ function liveWindow(text, W) {
314
+ const p = palette();
315
+ if (text === "") {
316
+ return [`${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${CUT_ROW}waiting for output${p.reset}`];
317
+ }
318
+ const rows = blockRows(text, W);
319
+ if (rows.length <= CAP_LIVE_WINDOW) {
320
+ while (rows.length < CAP_LIVE_WINDOW)
321
+ rows.push(`${p.dim}${BODY_ROW}${p.reset}`);
322
+ return rows;
323
+ }
324
+ const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
325
+ return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
326
+ }
327
+ /** The approval mini-diff (W7): capped at 12 folded rows — the head +
328
+ * the named middle (the renderer cut — what was cut, how to expand) +
329
+ * the tail. The rows are folded at the current width BEFORE the cap —
330
+ * the R1 measured bug: truncateDiff capped at 40 ENTRIES while the
331
+ * fold turned them into 73 SCREEN rows at W≤80 (a 44-row terminal's
332
+ * content cap is H−4 = 40 — the approval force-committed a third of
333
+ * the screen into scrollback inside one frame). */
334
+ function diffBody(diff, W) {
335
+ const p = palette();
336
+ if (diff === null)
337
+ return [];
338
+ const rows = [];
339
+ for (const d of diff) {
340
+ const body = d.kind === "-"
341
+ ? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
342
+ : d.kind === "+"
343
+ ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
344
+ : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
345
+ rows.push(...foldLine(`${p.bold}▎${p.reset}${body}`, W));
346
+ }
347
+ if (rows.length <= CAP_DIFF)
348
+ return rows;
349
+ const head = Math.floor((CAP_DIFF - 1) / 2);
350
+ const tail = CAP_DIFF - 1 - head;
351
+ const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - head - tail} rows · ctrl+r to expand · /last for the full diff${p.reset}`, W);
352
+ return [...rows.slice(0, head), ...cut, ...rows.slice(rows.length - tail)];
353
+ }
354
+ /** The TOOL's OWN truncation note (W10) — a different fact from the
355
+ * renderer's cut: the tools truncate and append a continuation note
356
+ * (packages/tools-node/src/index.ts — read_file's "call again with
357
+ * offset=N", the output cap, list_dir's entry cap). The note reaches
358
+ * the MODEL and never the human — this row surfaces it. Detected in
359
+ * the result's TAIL (the note is appended at the end); returns null
360
+ * when the tool did not truncate. */
361
+ function toolCutNote(name, resultText) {
362
+ const tail = resultText.slice(-300);
363
+ const m = /offset=(\d+)/.exec(tail);
364
+ if (m !== null)
365
+ return `capped by ${escapeTerminal(name)} · offset=${m[1]} for the rest`;
366
+ if (/…\[truncated\]/.test(tail) || /… \+?\d+ more (?:lines|entries)/.test(tail))
367
+ return `capped by ${escapeTerminal(name)} · /last for the rest`;
368
+ return null;
369
+ }
227
370
  /** The assistant body text — wrapped at W, the inline-code tint per
228
371
  * row (the #16e rule: a span never matches across rows). */
229
372
  class AssistantMessage {
@@ -20,10 +20,13 @@
20
20
  * sharp edge (asserted by the VT-emulator gate);
21
21
  * - two crash invariants: ① every emitted line's visible width ≤ W
22
22
  * (components fold; a violation THROWS with diagnostics — pi
23
- * tui-main-screen.ts:447-473, no silent truncate); ② within the
24
- * live region only RELATIVE cursor moves (vertical A/B, horizontal
25
- * G/D) CUP exists only in the full-redraw path (the first frame,
26
- * the resize repaint);
23
+ * tui-main-screen.ts:447-473, no silent truncate); ② every steady-
24
+ * frame CUP lands in the CONTENT area (rows H−4−menu — the
25
+ * committed band, the stale/gap ELs, and the LIVE lines at their
26
+ * model rows; fix C's sanctioned reinterpretation, ADR-0046) — the
27
+ * CHROME rows (H−3..H) are RELATIVE-only (vertical A/B, horizontal
28
+ * G/D); CUP over the whole screen exists only in the full-redraw
29
+ * path (the first frame, the resize repaint);
27
30
  * - the cursor DERIVES from the frame: the focus component embeds the
28
31
  * APC marker in its rendered line; the compositor locates, strips,
29
32
  * and relatively positions from the frame — no side-channel cursor
@@ -20,10 +20,13 @@
20
20
  * sharp edge (asserted by the VT-emulator gate);
21
21
  * - two crash invariants: ① every emitted line's visible width ≤ W
22
22
  * (components fold; a violation THROWS with diagnostics — pi
23
- * tui-main-screen.ts:447-473, no silent truncate); ② within the
24
- * live region only RELATIVE cursor moves (vertical A/B, horizontal
25
- * G/D) CUP exists only in the full-redraw path (the first frame,
26
- * the resize repaint);
23
+ * tui-main-screen.ts:447-473, no silent truncate); ② every steady-
24
+ * frame CUP lands in the CONTENT area (rows H−4−menu — the
25
+ * committed band, the stale/gap ELs, and the LIVE lines at their
26
+ * model rows; fix C's sanctioned reinterpretation, ADR-0046) — the
27
+ * CHROME rows (H−3..H) are RELATIVE-only (vertical A/B, horizontal
28
+ * G/D); CUP over the whole screen exists only in the full-redraw
29
+ * path (the first frame, the resize repaint);
27
30
  * - the cursor DERIVES from the frame: the focus component embeds the
28
31
  * APC marker in its rendered line; the compositor locates, strips,
29
32
  * and relatively positions from the frame — no side-channel cursor
@@ -674,17 +677,16 @@ export class Body {
674
677
  #drawSteady(out, W, H, liveTop, liveLines, menuRows, ctx) {
675
678
  const editor = this.#inputRow(W, ctx); // derived from the frame — the marker
676
679
  const committed = this.#committedLinesThisFrame;
677
- // the commits' scrolls — from the anchor (H−1) down to H, then LF
678
- if (committed.length > 0) {
679
- out.push("\x1b[1B");
680
- for (let i = 0; i < committed.length; i += 1)
681
- out.push("\n");
682
- }
683
- else {
684
- // no commits — jump from the anchor (H−2) straight to the
685
- // bottom row H (the commit path's LFs left the cursor there)
686
- out.push("\x1b[2B");
687
- }
680
+ // the jump from the anchor (H−2) straight to the bottom row H,
681
+ // then N real LFs scroll the screen exactly N rows — ONE per
682
+ // committed line (the bookkeeping; the stale 1B anchor jumped to
683
+ // H−1 and the N LFs scrolled only N−1 — the committed section sat
684
+ // one row short in the scrollback). The bottom-up repaint below
685
+ // overwrites the scrolled-in rows (the scroll count is screen-
686
+ // neutral — proven by the emulator probe).
687
+ out.push("\x1b[2B");
688
+ for (let i = 0; i < committed.length; i += 1)
689
+ out.push("\n");
688
690
  // the bottom-up repaint, from the last row up — V6-3: the design
689
691
  // §03 chrome: status (H), lower ╌ (H−1), input (H−2), upper ╌ (H−3)
690
692
  out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`); // H — the status
@@ -694,17 +696,26 @@ export class Body {
694
696
  for (let i = menuRows.length - 1; i >= 0; i -= 1) {
695
697
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
696
698
  }
697
- for (let i = liveLines.length - 1; i >= 0; i -= 1) {
698
- out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(liveLines[i], W)}`);
699
+ // the LIVE lines at their MODEL rows CUP, never the relative
700
+ // march: the march drew them adjacent to the chrome (rows
701
+ // H−3−n..H−3) while the model placed them at [liveTop..liveTop+n−1]
702
+ // — in the unclamped geometry the gap ELs below erased the march's
703
+ // copy and the streamed text was INVISIBLE until the commit frame.
704
+ // The CUP rows land in the content area (≤ H−4−menu ≤ 21), inside
705
+ // the frozen CUP budget of invariant ②.
706
+ for (let i = 0; i < liveLines.length; i += 1) {
707
+ out.push(`\x1b[${liveTop + i};1H\x1b[0K${this.#checked(liveLines[i], W)}`);
699
708
  }
700
709
  // the FROZEN area — CUP (absolute rows; this is the FREEZE path —
701
- // the old code's frozen writes were CUP too. The live region above
702
- // keeps the relative-only rule; the frozen rows are computed from
703
- // the current geometry, so external writes (the CLI's console.error
704
- // CRLF) cannot misplace them the way a relative march could).
710
+ // the old code's frozen writes were CUP too. The frozen rows are
711
+ // computed from the current geometry, so external writes (the CLI's
712
+ // console.error CRLF) cannot misplace them the way a relative march
713
+ // could).
705
714
  // 1. the GAP rows (between the live content and the chrome) — EL'd
706
- // so the old content there cannot ghost.
707
- for (let r = liveTop + liveLines.length; r <= H - 4; r += 1) {
715
+ // so the old content there cannot ghost; the range stops ABOVE
716
+ // the menu (the menu's rows at [H−3−menu..H−4] are marched and
717
+ // must survive — the unclamped geometry erased them).
718
+ for (let r = liveTop + liveLines.length; r <= H - 4 - menuRows.length; r += 1) {
708
719
  out.push(`\x1b[${r};1H\x1b[0K`);
709
720
  }
710
721
  // 2. the STALE rows above the committed section — the scrolled old
@@ -721,9 +732,21 @@ export class Body {
721
732
  out.push(`\x1b[${Math.max(1, liveTop - committed.length + i)};1H\x1b[0K${this.#checked(committed[i], W)}`);
722
733
  }
723
734
  // the cursor: down to the anchor (H−2, the input row) + left to the marker —
724
- // the down-distance from the LAST written row (the committed bottom,
725
- // the stale bottom, or the gap bottom when nothing else wrote).
726
- const lastRow = committed.length > 0 ? liveTop - 1 : staleFrom < liveTop ? liveTop - 1 : H - 4;
735
+ // the down-distance from the LAST written row, in byte order: the
736
+ // committed band's bottom, then the stale ELs' bottom, then the gap
737
+ // ELs' bottom, then the live lines' bottom, then the menu's top
738
+ // (its last marched row), else the chrome's upper ╌.
739
+ const lastRow = committed.length > 0
740
+ ? Math.max(1, liveTop - 1)
741
+ : staleFrom < liveTop
742
+ ? liveTop - 1
743
+ : liveTop + liveLines.length <= H - 4 - menuRows.length
744
+ ? H - 4 - menuRows.length
745
+ : liveLines.length > 0
746
+ ? liveTop + liveLines.length - 1
747
+ : menuRows.length > 0
748
+ ? H - 3 - menuRows.length
749
+ : H - 3;
727
750
  const down = H - 2 - lastRow; // the anchor: the input row (H−2)
728
751
  if (down > 0)
729
752
  out.push(`\x1b[${down}B`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",