@vincemakes/kiso-tui 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -73,8 +73,39 @@ export declare function askDescriptionStop(q: AskQuestion, W: number): number;
73
73
  * type-your-own line last. R2: the shape is the RULE's — rule,
74
74
  * title, header, body, affordance, rule. */
75
75
  export declare function askBlockRows(view: PanelView, state: AskRuntime, W: number, maxRows: number): string[];
76
- /** The status row's right-hand hint — the phase's keys. */
77
- export declare function askAffordance(state: AskRuntime): string;
76
+ /**
77
+ * R6/D2 the affordance names the FINISHER, and it names the real keys.
78
+ *
79
+ * Three defects lived in the one line this replaces:
80
+ * - it never mentioned ENTER, on the one panel shape where enter is the
81
+ * only way to finish. The owner could not find the finisher because
82
+ * the screen did not name it;
83
+ * - it said the same words in both modes, while a digit means two
84
+ * different things across them (take vs mark);
85
+ * - it hardcoded "1-4" whatever the option count was — the same defect
86
+ * REL-0152-D3 fixed in the input LEAD one function up, and missed
87
+ * here.
88
+ *
89
+ * The clauses are returned whole; `askAffordanceFit` below is what a
90
+ * width-bound caller uses.
91
+ */
92
+ export declare function askAffordance(state: AskRuntime, q?: AskQuestion): string;
93
+ /**
94
+ * The affordance at a width, by the DC-2 clause ladder.
95
+ *
96
+ * The row this serves used to be pushed UNCUT into the panel block
97
+ * (`askBlockRows`), unlike the approval's cutLine'd row — and panel rows
98
+ * go through `#checked`, which THROWS on any row wider than W
99
+ * (invariant ①). A multi-question ask on question 2 at W ≤ 41, or any
100
+ * ask at W ≤ 32, killed the renderer. So the ladder is not polish; it is
101
+ * the fix for a crash.
102
+ *
103
+ * The order gives way from the least load-bearing inward, and the LAST
104
+ * standing pair in both modes is the way through and the way out. In
105
+ * multi, `⏎ sends` is the fact whose absence caused this whole finding:
106
+ * it gives way never.
107
+ */
108
+ export declare function askAffordanceFit(state: AskRuntime, q: AskQuestion | undefined, W: number): string;
78
109
  /** The status row's left text — the ask's own line, with the walk. */
79
110
  export declare function askStatus(view: PanelView, state: AskRuntime): string;
80
111
  /** The input row's lead: the digit lead while picking, the typing lead
package/dist/ask-panel.js CHANGED
@@ -149,7 +149,27 @@ export function askKey(spec, state, key) {
149
149
  // row's neighbours answer with.
150
150
  if (state.cursor === customRow(q))
151
151
  return { state: { ...state, phase: "custom" } };
152
- return answered(state, state.qIndex) ? advance(spec, state) : { state };
152
+ if (answered(state, state.qIndex))
153
+ return advance(spec, state);
154
+ // R6/D2 — ENTER TAKES THE BAR'S ROW.
155
+ //
156
+ // It used to return `{ state }` here: the cursor is not a pick, so
157
+ // bar-on-option-1 + enter did NOTHING, in either mode, with no
158
+ // message. The approval panel ruled the opposite in TUI2-R3v2 ①
159
+ // ("the panel used to ignore the key every human presses first"),
160
+ // so the product had two selection models under one identical
161
+ // bar — and the owner's dogfood is that trap verbatim: bar to
162
+ // option 1, enter (silence), "do I really have to type 1?", digit
163
+ // (a mark appears), enter (it commits).
164
+ //
165
+ // Single-select takes the row and advances, which is what the bar
166
+ // has always promised. Multi MARKS the row and stays: the press
167
+ // becomes visible (◯ → ◉), which teaches the mode in one frame,
168
+ // and the second enter sends the set. A press that "did nothing"
169
+ // is the defect; a press that does the smallest true thing is the
170
+ // fix.
171
+ const taken = toggle(state, state.cursor, multi);
172
+ return multi ? { state: taken } : advance(spec, taken);
153
173
  }
154
174
  // SPACE selects at the cursor and NEVER commits — in either mode. It
155
175
  // used to answer-and-advance a single-select question, which made a
@@ -270,7 +290,10 @@ export function askBlockRows(view, state, W, maxRows) {
270
290
  // here), but the multi-select gesture it carried is INFORMATION and
271
291
  // rides the header instead — dropping it would have been a regression
272
292
  // wearing a restyle's clothes.
273
- const gesture = multi ? `${p.dim} · pick any space toggles${p.reset}` : "";
293
+ // R6/D2: the header names the MODE; the keys live on the keys row,
294
+ // once. It used to name `space toggles` there and nowhere say what
295
+ // finishes the set.
296
+ const gesture = multi ? `${p.dim} · pick any${p.reset}` : "";
274
297
  rows.push(` ${cutLine(`${p.dim}${header}${p.reset}${gesture}`, Math.max(1, W - 2))}`);
275
298
  rows.push("");
276
299
  const picks = state.picks[state.qIndex] ?? [];
@@ -311,7 +334,9 @@ export function askBlockRows(view, state, W, maxRows) {
311
334
  else {
312
335
  rows.push(...body);
313
336
  }
314
- rows.push(` ${p.dim}${askAffordance(state)}${p.reset}`);
337
+ // R6/D2: FITTED, and the two-space indent is inside the budget —
338
+ // the uncut push here is what made a narrow ask throw.
339
+ rows.push(` ${p.dim}${askAffordanceFit(state, q, Math.max(1, W - 2))}${p.reset}`);
315
340
  // R2, shared with the approval panel: the block closes with the SAME
316
341
  // dashed rule it opened with, and the same one the composer uses.
317
342
  // TUI2-R1.5 ⑪ had already replaced a two-cell `\u2514 ` stub with a
@@ -320,11 +345,68 @@ export function askBlockRows(view, state, W, maxRows) {
320
345
  rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
321
346
  return rows;
322
347
  }
323
- /** The status row's right-hand hint — the phase's keys. */
324
- export function askAffordance(state) {
348
+ /**
349
+ * R6/D2 — the affordance names the FINISHER, and it names the real keys.
350
+ *
351
+ * Three defects lived in the one line this replaces:
352
+ * - it never mentioned ENTER, on the one panel shape where enter is the
353
+ * only way to finish. The owner could not find the finisher because
354
+ * the screen did not name it;
355
+ * - it said the same words in both modes, while a digit means two
356
+ * different things across them (take vs mark);
357
+ * - it hardcoded "1-4" whatever the option count was — the same defect
358
+ * REL-0152-D3 fixed in the input LEAD one function up, and missed
359
+ * here.
360
+ *
361
+ * The clauses are returned whole; `askAffordanceFit` below is what a
362
+ * width-bound caller uses.
363
+ */
364
+ export function askAffordance(state, q) {
325
365
  if (state.phase === "custom")
326
366
  return "enter answers · esc backs out";
327
- return state.qIndex > 0 ? "1-4 pick · t type · ← back · esc decline" : "1-4 pick · t type · esc decline";
367
+ const n = q?.options.length ?? 4;
368
+ const multi = q?.multiSelect === true;
369
+ const back = state.qIndex > 0 ? ["← back"] : [];
370
+ return (multi
371
+ ? ["↑↓ move", `space or 1-${n} marks`, "⏎ sends the set", "t types", ...back, "esc declines"]
372
+ : ["↑↓ move", "⏎ confirms", `1-${n} instant`, "t types", ...back, "esc declines"]).join(" · ");
373
+ }
374
+ /**
375
+ * The affordance at a width, by the DC-2 clause ladder.
376
+ *
377
+ * The row this serves used to be pushed UNCUT into the panel block
378
+ * (`askBlockRows`), unlike the approval's cutLine'd row — and panel rows
379
+ * go through `#checked`, which THROWS on any row wider than W
380
+ * (invariant ①). A multi-question ask on question 2 at W ≤ 41, or any
381
+ * ask at W ≤ 32, killed the renderer. So the ladder is not polish; it is
382
+ * the fix for a crash.
383
+ *
384
+ * The order gives way from the least load-bearing inward, and the LAST
385
+ * standing pair in both modes is the way through and the way out. In
386
+ * multi, `⏎ sends` is the fact whose absence caused this whole finding:
387
+ * it gives way never.
388
+ */
389
+ export function askAffordanceFit(state, q, W) {
390
+ if (state.phase === "custom")
391
+ return cutLine("enter answers · esc backs out", W);
392
+ const n = q?.options.length ?? 4;
393
+ const multi = q?.multiSelect === true;
394
+ const back = state.qIndex > 0 ? ["← back"] : [];
395
+ const act = multi ? `space or 1-${n} marks` : `1-${n} instant`;
396
+ const go = multi ? "⏎ sends the set" : "⏎ confirms";
397
+ const tiers = [
398
+ ["↑↓ move", act, go, "t types", ...back, "esc declines"],
399
+ ["↑↓ move", act, go, ...back, "esc declines"],
400
+ [act, go, ...back, "esc declines"],
401
+ [multi ? "space marks" : `1-${n} picks`, multi ? "⏎ sends" : "⏎ confirms", "esc declines"],
402
+ [multi ? "⏎ sends" : "⏎ confirms", "esc declines"],
403
+ ];
404
+ for (const tier of tiers) {
405
+ const row = tier.join(" · ");
406
+ if (visibleWidth(row) <= W)
407
+ return row;
408
+ }
409
+ return cutLine(tiers[tiers.length - 1].join(" · "), W);
328
410
  }
329
411
  /** The status row's left text — the ask's own line, with the walk. */
330
412
  export function askStatus(view, state) {
@@ -380,7 +462,7 @@ export function panelAffordance(view, phase, cursor, ask, pick, safer) {
380
462
  if (view.pick !== undefined && pick !== undefined)
381
463
  return pickAffordance(pick);
382
464
  if (view.ask !== undefined && ask !== undefined)
383
- return askAffordance(ask);
465
+ return askAffordance(ask, view.ask.questions[ask.qIndex]);
384
466
  return basePanelAffordance(view, phase, cursor, safer);
385
467
  }
386
468
  /** The whole panel state in one call — the compositor's four reads share
@@ -52,7 +52,7 @@ import { MOUSE_OFF } from "./editor.js";
52
52
  import { atPanelRows, bandHeader } from "./at-picker.js";
53
53
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
54
54
  import { sessionPickerRows } from "./session-picker.js";
55
- import { ACT_SLOT_ROWS, Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, cutLine, isExploreTool, moreRunningRow, pendingQueueRows, slotPad, slotTail, statusLine, stretchLine, turnFold, visibleWidth, twinkleFrame, } from "./components.js";
55
+ import { ACT_SLOT_ROWS, Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, cutLine, isExploreTool, moreRunningRow, pendingQueueRows, slotPad, slotTail, statusLine, stretchLine, turnFold, visibleWidth, breathFrame, } from "./components.js";
56
56
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
57
57
  import { displayVerb, keysSheetRows } from "./strings.js";
58
58
  // R5 — the transcript viewer's PURE projection. The compositor supplies
@@ -476,7 +476,7 @@ export class Body {
476
476
  // W14: the turn boundary — the record the fold-hold's release
477
477
  // state machine reads; the cell carries the record's index. A9:
478
478
  // the user's own words ride the record — the fold's leading chip.
479
- this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), seen: new Map(), words: text, folded: false, segments: [] });
479
+ this.#turns.push({ ended: false, hasText: false, begun: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), seen: new Map(), words: text, folded: false, segments: [] });
480
480
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
481
481
  this.#mark();
482
482
  }
@@ -491,21 +491,35 @@ export class Body {
491
491
  }
492
492
  else {
493
493
  this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
494
- // R3b: thinking is WORK, so it opens a segment too a turn that
495
- // thinks, speaks, then thinks again has two segments, and the
496
- // second one's clock starts here rather than at a tool call it
497
- // may never make.
494
+ // DECLARED SUPERSESSION (R7, owner-ruled 2026-08-31)THINKING
495
+ // IS WORDS, NOT WORK.
498
496
  //
499
- // OPEN then STAMP, in that order: the stamp records the segment
500
- // the cell belongs to, and a stamp taken first records the
501
- // PREVIOUS segment (or none at all) which left the thinking
502
- // row standing outside the fold it should have led.
503
- const seg = openSegment(this.#turns[this.#turns.length - 1], Date.now());
504
- // R3i: the stretch's thinking clock starts HERE at the first
505
- // delta of this stretch, the same moment the CLI starts the
506
- // turn's and stops at the next non-thinking event below.
507
- if (seg !== null && seg.thinkingSince === null)
508
- seg.thinkingSince = Date.now();
497
+ // R3b made thinking open a segment, on the reading that it is
498
+ // work like a tool call. Four rounds of consequences followed
499
+ // from that one classification: folded away with the calls, it
500
+ // became unreachable, and R4's printed ordinal, R5's viewer,
501
+ // R6's subject index and a look-back viewport were each built
502
+ // to hand it back. The owner's ruling is to stop hiding it
503
+ // and then none of those mechanisms is answering a question
504
+ // anyone still asks.
505
+ //
506
+ // So thinking CLOSES the open segment, exactly as text does
507
+ // (see textAppend): a segment is what sits between two of
508
+ // these. It must close rather than merely not-open, because
509
+ // `#committed` is a PREFIX count — a thinking cell cannot
510
+ // commit past a held call, so think → call → think would
511
+ // otherwise flush at the segment's close with the second
512
+ // thought printing BELOW the fold that contains the later
513
+ // call.
514
+ //
515
+ // Consequence, and it is wanted: the segment's thinking clock
516
+ // never starts, so `thought Ns` drops off every fold line by
517
+ // R3h's own zero-term rule. The line stops claiming a fact the
518
+ // paragraph above it already states in full.
519
+ const t0 = this.#turns[this.#turns.length - 1];
520
+ closeSegment(t0, Date.now());
521
+ if (t0 !== undefined)
522
+ t0.begun = true; // R6/D1: the block allocates here
509
523
  // R3i: and the beat starts HERE. Law 1.4 says "a running thought
510
524
  // twinkles", and `#armSpinner`'s own predicate has always
511
525
  // included an open thinking cell — but the only caller was
@@ -514,7 +528,6 @@ export class Body {
514
528
  // derivation, so without the beat they also never ticked: the
515
529
  // row read `thinking 0s` for as long as the model thought.
516
530
  this.#armSpinner();
517
- this.#stampSegment();
518
531
  }
519
532
  this.#mark();
520
533
  }
@@ -630,6 +643,9 @@ export class Body {
630
643
  }
631
644
  }
632
645
  this.#stampSegment();
646
+ const t1 = this.#turns[this.#turns.length - 1];
647
+ if (t1 !== undefined)
648
+ t1.begun = true; // R6/D1: the block allocates here
633
649
  this.#mark();
634
650
  }
635
651
  toolApproval(callId, diff) {
@@ -1934,8 +1950,10 @@ export class Body {
1934
1950
  const openSeg = open !== null && open.closedAt === null ? open : null;
1935
1951
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1936
1952
  let stretchDrawn = false;
1953
+ let lastIdx = this.#committed;
1937
1954
  for (let i = this.#committed; i < this.#cells.length; i += 1) {
1938
1955
  const cell = this.#cells[i];
1956
+ lastIdx = i;
1939
1957
  const inOpen = openSeg !== null && openSeg.cells.includes(i);
1940
1958
  if (inOpen) {
1941
1959
  // R4 — the open stretch is ONE contiguous block: its line
@@ -1950,11 +1968,35 @@ export class Body {
1950
1968
  if (stretchDrawn)
1951
1969
  continue;
1952
1970
  stretchDrawn = true;
1971
+ // R7a — a ONE-CELL stretch draws NO line.
1972
+ //
1973
+ // It said `running 1 shell command` directly above a row
1974
+ // reading `● shell npm run check`: the same fact twice, and
1975
+ // fable's R4 review had already named the duplication. R7
1976
+ // then made it a SWALLOW as well — a one-cell segment does
1977
+ // not fold, so that line has no committed counterpart and
1978
+ // vanished at the settle, taking a row off the screen. The
1979
+ // call's own head row is the line; a summary of one thing
1980
+ // is the thing.
1981
+ const single = openSeg.cells.filter((j) => this.#cells[j]?.kind === "tool").length <= 1;
1953
1982
  const rows = [
1954
- ...stretchLine({ ...this.#stretchTerms(openSeg), liveNames: this.#liveNames(openSeg), phase: this.#stretchPhase(openSeg), mark: twinkleFrame(this.#spinnerI) }, W),
1983
+ ...(single
1984
+ ? []
1985
+ : stretchLine({ ...this.#stretchTerms(openSeg), liveNames: this.#liveNames(openSeg), phase: this.#stretchPhase(openSeg), ...(this.#inFlight(openSeg) ? { mark: breathFrame(this.#spinnerI) } : {}) }, W)),
1955
1986
  ...this.#actSlot(openSeg, W, ctx, budget, focus),
1956
1987
  ];
1957
- out.push(...this.#space(i, prev, rows));
1988
+ // R7a — the block TAKES the W11 blank, like everything else.
1989
+ //
1990
+ // CORRECTION of my own R6/D1 change, which removed it. The
1991
+ // reasoning then was that the committed fold is a single row
1992
+ // and single rows take no blank — but `bodySpacing` gives a
1993
+ // blank after any MULTI-row sibling, and the thinking block
1994
+ // above the work is exactly that. So the committed side had
1995
+ // one and the live side did not, and a blank row APPEARED at
1996
+ // every settle, shoving everything below it down. The owner
1997
+ // saw it and said the blank is the correct form; it is, and
1998
+ // the fix is to have it on both sides rather than neither.
1999
+ out.push(...this.#blockSpace(i, prev, rows));
1958
2000
  prev = rows;
1959
2001
  continue;
1960
2002
  }
@@ -1966,8 +2008,52 @@ export class Body {
1966
2008
  out.push(...this.#space(i, prev, rows));
1967
2009
  prev = rows;
1968
2010
  }
2011
+ // R6/D1 — THE BLOCK STANDS FOR THE TURN, not for the stretch.
2012
+ //
2013
+ // R4 made the block's height constant WITHIN a stretch. Between
2014
+ // two stretches it was released and rebuilt, so the live region
2015
+ // breathed by its whole height twice per stretch — and the live
2016
+ // region is anchored to the bottom (liveTop below), so every
2017
+ // committed row on screen moved with it. The owner's report, and
2018
+ // their own formulation of the cure: "once something is at a line
2019
+ // it should not jump up or down — hold the absolute position and
2020
+ // update the content there."
2021
+ //
2022
+ // So when the turn has begun work and no open stretch drew the
2023
+ // block above, it is drawn HERE, after the live cells, with its
2024
+ // top row swapped to the slot's own pad: the closed stretch's
2025
+ // terms are already the committed fold row further up, and
2026
+ // printing them again one screen apart is the duplication A9's
2027
+ // narrowing forbids.
2028
+ //
2029
+ // Projection-only. Commit order, commit timing and every
2030
+ // committed byte are untouched — which is why the transcript this
2031
+ // leaves behind cannot regress: it cannot differ.
2032
+ if (!stretchDrawn && turn !== undefined && !turn.ended && turn.begun) {
2033
+ // R7a: the top row is BLANK, not a `│`. It stands where the
2034
+ // stretch line stands while a stretch is open, and between two
2035
+ // stretches there is no line to draw — a bare gutter there is
2036
+ // a mark on a row with nothing to mark (law 1.3), and it is
2037
+ // the "long vertical line" the owner saw under a finished
2038
+ // turn. The ROW is what holds the height; the glyph never was.
2039
+ const rows = ["", ...this.#actSlot(null, W, ctx, budget, focus)];
2040
+ out.push(...this.#blockSpace(lastIdx, prev, rows)); // R7a: the same blank
2041
+ }
1969
2042
  return out;
1970
2043
  }
2044
+ /** R6/D1 — the turn's most recent CLOSED segment: what the block
2045
+ * shows in the gap between two stretches. */
2046
+ #lastClosedSegment() {
2047
+ const turn = this.#turns[this.#turns.length - 1];
2048
+ if (turn === undefined)
2049
+ return null;
2050
+ for (let i = turn.segments.length - 1; i >= 0; i -= 1) {
2051
+ const seg = turn.segments[i];
2052
+ if (seg.cells.some((j) => j >= this.#committed))
2053
+ return seg;
2054
+ }
2055
+ return null;
2056
+ }
1971
2057
  /**
1972
2058
  * R4 — the standing act slot's rows. EXACTLY `budget` rows in every
1973
2059
  * phase, so the live region's height changes twice per stretch (once
@@ -1995,7 +2081,16 @@ export class Body {
1995
2081
  rows[0] = focusToken(rows[0], W);
1996
2082
  return rows;
1997
2083
  };
1998
- const live = seg.cells.filter((i) => i >= this.#committed);
2084
+ // R6/D1: with no open stretch (the gap BETWEEN two of them) the
2085
+ // slot looks at the turn's last closed segment instead — the call
2086
+ // that just finished keeps its head and its tail, which is R4 B's
2087
+ // rule extended across the boundary. Cells outlive their commit,
2088
+ // so these are live repaints of live rows, never a rewrite of a
2089
+ // committed one.
2090
+ const src = seg ?? this.#lastClosedSegment();
2091
+ if (src === null)
2092
+ return [];
2093
+ const live = src.cells.filter((i) => i >= this.#committed);
1999
2094
  const tools = [];
2000
2095
  for (const i of live)
2001
2096
  if (this.#cells[i]?.kind === "tool")
@@ -2042,31 +2137,142 @@ export class Body {
2042
2137
  out.push(moreRunningRow(hidden, W));
2043
2138
  return out;
2044
2139
  }
2045
- const flight = tools.filter((i) => !toolAt(i).done);
2046
- if (flight.length > 0) {
2047
- // the commonest frame exactly one call, the full budget is
2048
- // the W8 block verbatim, which is what 0.17.0 already drew.
2049
- if (flight.length === 1 && budget >= ACT_SLOT_ROWS)
2050
- return slotPad(tint(flight[0], cellComponent(this.#cells[flight[0]]).render(W, ctx)), budget);
2051
- const heads = flight.slice(0, Math.max(1, Math.min(flight.length, budget - 1, LIVE_ACT_HEADS)));
2052
- const hidden = flight.length - heads.length;
2140
+ // R7a ONE PATH, whether or not anything is in flight.
2141
+ //
2142
+ // There used to be two: the in-flight composition, and a
2143
+ // "last finished call plus its output" composition for the gap
2144
+ // between stretches. The moment the last call of a burst
2145
+ // returned, the block re-composed from four head rows to one
2146
+ // head and a tail a row shorter, so on a full screen every
2147
+ // row above slid DOWN. The block is supposed to change its
2148
+ // CONTENTS, not its shape; the last call returning is not a
2149
+ // reason to redraw the stretch differently.
2150
+ if (tools.length > 0) {
2151
+ // the one-call special case is GONE: the path below draws a
2152
+ // lone running call by its own component (the W8 block
2153
+ // verbatim, which is what 0.17.0 drew) and a lone finished
2154
+ // one as its head plus its output. The special case only
2155
+ // differed once the call SETTLED, where it collapsed to the
2156
+ // bare head row — a three-row shrink the moment a single
2157
+ // call returned.
2158
+ // R7a — EVERY call of the stretch keeps its row, not just the
2159
+ // ones still in flight.
2160
+ //
2161
+ // R4 showed the in-flight calls only, so a finished one left
2162
+ // the block and its target went with it: a four-file burst
2163
+ // ended having shown four names and left none of them, while
2164
+ // the rows below shuffled up one at a time. Two complaints in
2165
+ // one — "I can't see what it read" and "the rows keep moving".
2166
+ //
2167
+ // A call now takes a row when it STARTS and changes in place
2168
+ // when it finishes: `● read x · 1s` becomes the settled head.
2169
+ // Nothing moves, every target stays, and exactly ONE row wears
2170
+ // the breathing mark — the running one — which is the mark's
2171
+ // whole job (§7.4: only the call still running carries one,
2172
+ // because only it is moving).
2173
+ //
2174
+ // The slot's fixed height is what pays for this: the rows are
2175
+ // already allocated, so the names fill blanks rather than
2176
+ // pushing anything.
2177
+ // TRUNCATION NEVER DROPS A CALL THAT IS STILL RUNNING.
2178
+ //
2179
+ // Taking the first N is wrong the moment a burst outlives the
2180
+ // slot: four reads that finished held every row while the
2181
+ // shell still running was cut, so the screen said "4 files"
2182
+ // and showed nothing of the work actually in flight. The
2183
+ // in-flight set is admitted first, then the most RECENT
2184
+ // finished calls fill what is left — newest first, because
2185
+ // the oldest is the one the eye has already read.
2186
+ const live = tools.filter((i) => !toolAt(i).done);
2187
+ const past = tools.filter((i) => toolAt(i).done);
2188
+ // WHAT IS HAPPENING NOW OUTRANKS WHAT HAPPENED. In order:
2189
+ // the in-flight rows, then that call's output when it is the
2190
+ // only one running, then the finished NAMES, newest first.
2191
+ //
2192
+ // This is the line between R3i P1 and the owner's R7a ruling,
2193
+ // which look contradictory and are not. The ruling is about a
2194
+ // parallel burst — four reads whose names vanished one at a
2195
+ // time, so the turn ended having shown four files and left
2196
+ // none of them. P1 is about a burst that is OVER and a new
2197
+ // call running: there the finished names have had their time
2198
+ // on screen and the work in flight has not. Recency decides
2199
+ // both, and neither gate has to give.
2200
+ // the lone in-flight call is drawn by its OWN component, head
2201
+ // and tail together — that is where the waiting row, VD-4's
2202
+ // never-blank-first-row rule and the shell's live window all
2203
+ // already live. Reaching past it to slotTail() lost every one
2204
+ // of them: a running shell with no output yet drew three
2205
+ // blank rows where `└ waiting for output` belongs.
2206
+ // `grouped` says "an activity line above wears the mark for
2207
+ // us". A stretch of ONE call draws no such line (R7a), so
2208
+ // there is nothing above to carry it and the head keeps its
2209
+ // own — otherwise a lone running call breathes nowhere.
2210
+ const grouped = { ...ctx, grouped: tools.length > 1 };
2211
+ const soloRows = live.length === 1 ? tint(live[0], cellComponent(this.#cells[live[0]]).render(W, grouped)) : [];
2212
+ const tailWant = Math.max(0, soloRows.length - 1);
2213
+ // the overflow row is itself a row: an in-flight set larger
2214
+ // than the slot gives one back so `+N more running` fits.
2215
+ const liveRows = live.slice(0, live.length > budget ? Math.max(1, budget - 1) : budget);
2216
+ const spare = Math.max(0, budget - liveRows.length - tailWant);
2217
+ const nameRoom = past.length > spare ? Math.max(0, spare - 1) : spare;
2218
+ const keep = new Set([...liveRows, ...past.slice(past.length - nameRoom)]);
2219
+ const shown = tools.filter((i) => keep.has(i));
2220
+ // `+N more running` COUNTS ONLY CALLS THAT ARE RUNNING.
2221
+ //
2222
+ // Counting every dropped call said "+1 more running" over a
2223
+ // read that had already returned — a false sentence of the
2224
+ // R3h class, and the stretch line above had ALREADY counted
2225
+ // that read ("read 1 file"), so the row was both wrong and
2226
+ // redundant. A finished name giving way to live work is the
2227
+ // recency rule doing its job, not an overflow.
2228
+ const hidden = live.length - shown.filter((i) => !toolAt(i).done).length;
2053
2229
  const rows = [];
2054
- for (const i of heads)
2055
- rows.push(tint(i, cellComponent(this.#cells[i]).render(W, ctx))[0] ?? "");
2056
- const rest = budget - rows.length - (hidden > 0 ? 1 : 0);
2057
- if (rest > 0)
2058
- rows.push(...slotTail(toolAt(heads[heads.length - 1]).resultText, W, rest));
2230
+ // the mark lives on the ACTIVITY line above, so the members
2231
+ // wear a plain gutter — see FrameCtx.grouped.
2232
+ for (const i of shown) {
2233
+ if (i === live[0] && live.length === 1)
2234
+ rows.push(...soloRows.slice(0, Math.max(1, budget - rows.length)));
2235
+ else
2236
+ rows.push(tint(i, cellComponent(this.#cells[i]).render(W, grouped))[0] ?? "");
2237
+ }
2059
2238
  if (hidden > 0)
2060
2239
  rows.push(moreRunningRow(hidden, W));
2240
+ // R3i P3 SURVIVES: the call in flight keeps its row AND its
2241
+ // output. R7a gave every call a row, which spent the budget
2242
+ // the tail used to hold — but a running shell with no output
2243
+ // on screen is the defect R3i named, and the owner's ruling
2244
+ // was about the finished calls' NAMES, not about this. The
2245
+ // tail takes whatever the head rows leave, so it is full
2246
+ // height for a lone call and gives way to the names first.
2247
+ // R4 B SURVIVES THE UNIFICATION: between two calls — nothing
2248
+ // in flight — the slot still shows the call that just
2249
+ // finished AND its output. It is appended UNDER the head
2250
+ // rows now instead of replacing them, so the block's shape
2251
+ // does not change when the last call of a burst returns.
2252
+ const rest = budget - rows.length;
2253
+ if (live.length === 0 && rest > 0 && tools.length > 0)
2254
+ rows.push(...slotTail(toolAt(tools[tools.length - 1]).resultText, W, rest));
2061
2255
  return slotPad(rows, budget);
2062
2256
  }
2063
- const settled = tools.length > 0 ? tools[tools.length - 1] : null;
2064
- if (settled !== null) {
2065
- const head = tint(settled, cellComponent(this.#cells[settled]).render(W, ctx))[0] ?? "";
2066
- return slotPad([head, ...slotTail(toolAt(settled).resultText, W, budget - 1)], budget);
2067
- }
2068
2257
  const think = [...live].reverse().find((i) => this.#cells[i]?.kind === "thinking");
2069
- return slotPad(think === undefined ? [] : slotTail(this.#cells[think].text, W, budget), budget);
2258
+ // R7a A SLOT WITH NOTHING TO SHOW TAKES NO ROWS.
2259
+ //
2260
+ // R7 moved thinking OUT of the slot (it is words, and words do
2261
+ // not fold), which left this branch — the pre-tool phase of a
2262
+ // stretch — with nothing to put in the rows it was still
2263
+ // reserving. It padded them anyway: six blank rows between the
2264
+ // thought and the composer, on 653 of a 733-frame dogfood
2265
+ // replay. Until today those rows were drawn as `│`, so the
2266
+ // blank-run guard never saw them and the owner saw a gutter
2267
+ // running down the screen marking nothing; blanking the gutter
2268
+ // (law 1.3) revealed the hole the gutter had been covering.
2269
+ //
2270
+ // Reserving height buys stability only where the content
2271
+ // CHANGES under it — a stretch whose calls come and go. Before
2272
+ // the first call there is nothing to stabilise, so the rows are
2273
+ // pure cost, and both complaints are the same complaint.
2274
+ const tail = think === undefined ? [] : slotTail(this.#cells[think].text, W, budget);
2275
+ return tail.length === 0 ? [] : slotPad(tail, budget);
2070
2276
  }
2071
2277
  /** R4 — the tool names with a call still IN FLIGHT in this segment.
2072
2278
  * The stretch line's tense is per term, so a finished shell reads
@@ -2454,6 +2660,37 @@ export class Body {
2454
2660
  * the only place that knows it. Every other pair is untouched,
2455
2661
  * including the boundary INTO a markdown message (the blank under the
2456
2662
  * user chip is still W11's). */
2663
+ /** R7a — the live block's spacing is the spacing its COMMITTED form
2664
+ * will get, never its own.
2665
+ *
2666
+ * W11 gives a blank when either side is multi-row. The block is
2667
+ * always multi-row and the fold it commits into is always ONE row,
2668
+ * so the two sides disagreed by construction and a blank appeared
2669
+ * or vanished at every settle, shoving the whole transcript by a
2670
+ * row. Both directions occur: after a two-row thought the settle
2671
+ * ADDED one (my R6/D1 note saw only this case and removed the
2672
+ * block's blank, which fixed that direction and broke the other);
2673
+ * after a one-row thought it REMOVED one.
2674
+ *
2675
+ * Deciding on a one-row stand-in makes the block spaced exactly as
2676
+ * its fold will be, so the settle changes the row's CONTENT and
2677
+ * never its position — which is the whole claim of the standing
2678
+ * block. */
2679
+ /** R7a — is any call of this stretch actually RUNNING?
2680
+ *
2681
+ * The phase is not the same question. A stretch stays "acting" from
2682
+ * its first tool to its close, so between two bursts — every call
2683
+ * returned, the model is composing the next one — the phase still
2684
+ * said acting and the activity line went on breathing over four
2685
+ * finished reads. A mark that is lit when nothing moves is the
2686
+ * spinner-implies-progress error §5.3 forbids, one scale up. */
2687
+ #inFlight(seg) {
2688
+ return seg.cells.some((i) => { const c = this.#cells[i]; return c?.kind === "tool" && !c.done; });
2689
+ }
2690
+ #blockSpace(i, prev, rows) {
2691
+ const lead = bodySpacing(this.#lastDrawn(i, prev), ["x"]).length > 1 ? [""] : [];
2692
+ return [...lead, ...rows];
2693
+ }
2457
2694
  #space(i, prev, rows) {
2458
2695
  if (i > 0 && this.#cells[i]?.kind === "md" && this.#cells[i - 1]?.kind === "md")
2459
2696
  return rows;
@@ -2687,6 +2924,12 @@ export class Body {
2687
2924
  // is not going to speak for it.
2688
2925
  if (cell.kind === "tool" && cell.name === "ask_user")
2689
2926
  return false;
2927
+ // R7: and neither is THINKING. It is words now (law 1.7 — work
2928
+ // folds, words do not), so it commits when it is done, like prose,
2929
+ // and is never held for a fold that no longer speaks for it. The
2930
+ // ask_user exemption one line up is the precedent this follows.
2931
+ if (cell.kind === "thinking")
2932
+ return false;
2690
2933
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
2691
2934
  if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
2692
2935
  return false;
@@ -3225,6 +3468,21 @@ export class Body {
3225
3468
  // march below is clamped to the window instead, which makes the
3226
3469
  // sheet displace content ON SCREEN. Closing takes the full-redraw
3227
3470
  // path with the same #lastSkip and every displaced row comes back.
3471
+ // R7a — a monotone skip was TRIED HERE AND REJECTED, measured.
3472
+ //
3473
+ // The seam it aimed at is real: the turn boundary releases the
3474
+ // block into a one-row fold, so a full screen's computed skip
3475
+ // drops and every row above slides down one. Holding skip at its
3476
+ // high-water mark removes that motion exactly.
3477
+ //
3478
+ // It also holds the window BELOW the content, and the a7 dogfood
3479
+ // replay prices that at 40x24: the frame from which the screen
3480
+ // durably fills (no blank run over 2) goes 65 -> 692 of 733 —
3481
+ // a three-row hole above the composer through most of a real
3482
+ // session. One row of motion once per turn, at the moment the
3483
+ // answer lands and the eye is on it, is the cheaper of the two.
3484
+ // The A8b guard is written against a real session; this is what
3485
+ // it exists to catch.
3228
3486
  const skip = overlay
3229
3487
  ? this.#lastSkip
3230
3488
  : Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.19.0"
38
+ "@vincemakes/kiso-tui-cells": "0.20.0"
39
39
  }
40
40
  }