@vincemakes/kiso-tui 0.16.4 → 0.16.6
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/ask-panel.js +2 -2
- package/dist/at-picker.js +2 -2
- package/dist/components.d.ts +2 -0
- package/dist/components.js +4 -0
- package/dist/compositor.js +389 -56
- package/dist/editor.js +10 -4
- package/dist/status.d.ts +15 -3
- package/dist/status.js +16 -3
- package/package.json +2 -2
package/dist/ask-panel.js
CHANGED
|
@@ -263,7 +263,7 @@ export function askBlockRows(view, state, W, maxRows) {
|
|
|
263
263
|
const counter = spec.questions.length > 1 ? `${p.dim} ‹ ${state.qIndex + 1}/${spec.questions.length} ›${p.reset}` : "";
|
|
264
264
|
const rows = [];
|
|
265
265
|
// R2: the same dashed rule the composer and the approval panel use.
|
|
266
|
-
rows.push(`${p.dim}${"\
|
|
266
|
+
rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
|
|
267
267
|
rows.push(` ${cutLine(`${p.bold}${escapeTerminal(q.question)}${p.reset}${counter}`, Math.max(1, W - 2))}`);
|
|
268
268
|
const header = q.header === undefined ? "the question" : escapeTerminal(q.header.slice(0, ASK_HEADER_CAP));
|
|
269
269
|
// R2: the divider row is gone (the opening rule says a block starts
|
|
@@ -317,7 +317,7 @@ export function askBlockRows(view, state, W, maxRows) {
|
|
|
317
317
|
// TUI2-R1.5 ⑪ had already replaced a two-cell `\u2514 ` stub with a
|
|
318
318
|
// real rule for the reason that stub read as the cut-notice prefix it
|
|
319
319
|
// collides with; this keeps that finding and only changes which rule.
|
|
320
|
-
rows.push(`${p.dim}${"\
|
|
320
|
+
rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
|
|
321
321
|
return rows;
|
|
322
322
|
}
|
|
323
323
|
/** The status row's right-hand hint — the phase's keys. */
|
package/dist/at-picker.js
CHANGED
|
@@ -265,6 +265,6 @@ export function atPanelRows(state, W) {
|
|
|
265
265
|
*/
|
|
266
266
|
export function bandHeader(label, W) {
|
|
267
267
|
const p = palette();
|
|
268
|
-
const head = `\
|
|
269
|
-
return `${p.dim}${widthCut(`${head}${"\
|
|
268
|
+
const head = `\u2500\u2500\u2500 ${label} `;
|
|
269
|
+
return `${p.dim}${widthCut(`${head}${"\u2500".repeat(Math.max(1, W - head.length))}`, Math.max(1, W))}${p.reset}`;
|
|
270
270
|
}
|
package/dist/components.d.ts
CHANGED
|
@@ -2,3 +2,5 @@
|
|
|
2
2
|
* Amendment 4) — this module is the re-export shim: the compositor's
|
|
3
3
|
* and index.ts's imports (./components.js) stay verbatim. */
|
|
4
4
|
export * from "@vincemakes/kiso-tui-cells/components";
|
|
5
|
+
export { foldTerms } from "@vincemakes/kiso-tui-cells/components";
|
|
6
|
+
export { MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame } from "@vincemakes/kiso-tui-cells/render";
|
package/dist/components.js
CHANGED
|
@@ -2,3 +2,7 @@
|
|
|
2
2
|
* Amendment 4) — this module is the re-export shim: the compositor's
|
|
3
3
|
* and index.ts's imports (./components.js) stay verbatim. */
|
|
4
4
|
export * from "@vincemakes/kiso-tui-cells/components";
|
|
5
|
+
// R3 (design §5.2): the two motion cycles reach the compositor through
|
|
6
|
+
// the same shim every other cell primitive does.
|
|
7
|
+
export { foldTerms } from "@vincemakes/kiso-tui-cells/components";
|
|
8
|
+
export { MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame } from "@vincemakes/kiso-tui-cells/render";
|
package/dist/compositor.js
CHANGED
|
@@ -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 { Container, ROLLUP_NOUN,
|
|
55
|
+
import { Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldTerms, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } 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
|
/** The cursor marker — an APC private sequence the focus component
|
|
@@ -69,6 +69,123 @@ const NOT_PAINTED = "\u0000never";
|
|
|
69
69
|
* that a single resize still feels immediate. */
|
|
70
70
|
const RESIZE_SETTLE_MS = 80;
|
|
71
71
|
const CHROME_ROWS = 4; // box top + input + box bottom + status — the design §03 chrome (V6-3; the box is W6)
|
|
72
|
+
/** W13 / TUI2-R1 (B) — a rolled run's TITLE: the exploration sentence on
|
|
73
|
+
* a mixed run, W13's verb+count on a single-name one. */
|
|
74
|
+
function rolledTitle(cell) {
|
|
75
|
+
const r = cell.rolled;
|
|
76
|
+
if (r.parts !== undefined)
|
|
77
|
+
return `explored ${exploreCounts(r.parts)}`;
|
|
78
|
+
return `${displayVerb(cell.name)} ${r.count} ${ROLLUP_NOUN[cell.name] ?? "calls"}`;
|
|
79
|
+
}
|
|
80
|
+
/** W13 / TUI2-R1 (B) — a rolled run's DETAIL rows: one row per tool with
|
|
81
|
+
* its subjects on a mixed run, one `└ target` per call on a single-name
|
|
82
|
+
* one.
|
|
83
|
+
*
|
|
84
|
+
* Extracted at R3b so the segment fold's expansion opens a run to the
|
|
85
|
+
* SAME rows `ctrl+r` on the run itself would have opened. Two copies of
|
|
86
|
+
* this would be two answers to "show me that run". */
|
|
87
|
+
function rolledDetail(cell, W) {
|
|
88
|
+
const p = palette();
|
|
89
|
+
const r = cell.rolled;
|
|
90
|
+
if (r.parts !== undefined)
|
|
91
|
+
return exploreRows(r.parts, W);
|
|
92
|
+
return r.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* W13 / TUI2-R1 (B) — the rollup's own projection of a run: the count,
|
|
96
|
+
* the lines, the elapsed, the targets, and — on a MIXED run only — the
|
|
97
|
+
* per-tool parts. A single-name run keeps W13's row byte for byte,
|
|
98
|
+
* which is the "the generalization adds, it never rewrites" rule.
|
|
99
|
+
*
|
|
100
|
+
* Extracted at R3b because the segment fold's EXPANSION renders the run
|
|
101
|
+
* through this same projection rather than reimplementing it — so the
|
|
102
|
+
* expanded rows cannot drift from the ones the commit path would have
|
|
103
|
+
* drawn.
|
|
104
|
+
*/
|
|
105
|
+
function rolledOf(members) {
|
|
106
|
+
let total = 0;
|
|
107
|
+
const targets = [];
|
|
108
|
+
for (const m of members) {
|
|
109
|
+
// the lines count, excluding the tool's OWN truncation note
|
|
110
|
+
// (read_file's "… N more lines") — the per-cell meta's rule
|
|
111
|
+
const noteAt = m.resultText.lastIndexOf("\n… ");
|
|
112
|
+
const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
|
|
113
|
+
const rows = shown.split("\n");
|
|
114
|
+
total += rows[rows.length - 1] === "" ? rows.length - 1 : rows.length;
|
|
115
|
+
let input = {};
|
|
116
|
+
try {
|
|
117
|
+
input = JSON.parse(m.inputFull);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// the full JSON is always parseable (stringified at toolStart)
|
|
121
|
+
}
|
|
122
|
+
const target = toolTarget(m.name, input);
|
|
123
|
+
targets.push(target.split("/").pop() ?? target);
|
|
124
|
+
}
|
|
125
|
+
const parts = exploreParts(members);
|
|
126
|
+
const first = members[0];
|
|
127
|
+
const last = members[members.length - 1];
|
|
128
|
+
const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
|
|
129
|
+
return { count: members.length, lines: total, elapsed, targets, ...(parts.length > 1 ? { parts } : {}) };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* TUI2-R1 (B) / R3b — the per-tool parts of an explore run, in
|
|
133
|
+
* first-call order. A search's subject is the PATTERN it looked for
|
|
134
|
+
* (quoted); a read's or a list's is the path it named.
|
|
135
|
+
*
|
|
136
|
+
* Extracted at R3b because TWO paths need it now: the commit-time
|
|
137
|
+
* rollup, which has always built it, and the segment fold's EXPANSION,
|
|
138
|
+
* which shows the rollup's rows rather than one row per call. Two
|
|
139
|
+
* copies of this would be two answers to "what did that run do".
|
|
140
|
+
*/
|
|
141
|
+
function exploreParts(members) {
|
|
142
|
+
const parts = [];
|
|
143
|
+
for (const m of members) {
|
|
144
|
+
let input = {};
|
|
145
|
+
try {
|
|
146
|
+
input = JSON.parse(m.inputFull);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// the full JSON is always parseable (stringified at toolStart)
|
|
150
|
+
}
|
|
151
|
+
const target = toolTarget(m.name, input);
|
|
152
|
+
const subject = m.name === "search_text" ? `"${String(input.pattern ?? "")}"` : target;
|
|
153
|
+
const part = parts.find((x) => x.name === m.name);
|
|
154
|
+
if (part === undefined)
|
|
155
|
+
parts.push({ name: m.name, subjects: [subject] });
|
|
156
|
+
else
|
|
157
|
+
part.subjects.push(subject);
|
|
158
|
+
}
|
|
159
|
+
return parts;
|
|
160
|
+
}
|
|
161
|
+
/** R3b — a segment's terms, for the expand header. The fold line's own
|
|
162
|
+
* wording comes from `turnFold`; this is the same facts in the header
|
|
163
|
+
* idiom the other expands use. */
|
|
164
|
+
function foldMeta(seg) {
|
|
165
|
+
const parts = foldTerms(seg.reads, seg.edits, [...seg.others]);
|
|
166
|
+
return parts.length === 0 ? "thinking" : parts.join(" · ");
|
|
167
|
+
}
|
|
168
|
+
/** R3b — the turn's open segment, opened on demand at the first cell of
|
|
169
|
+
* work that follows a text block (or the turn's start). Returns null
|
|
170
|
+
* only when there is no turn at all, which is the pipe path's shape. */
|
|
171
|
+
function openSegment(turn, now) {
|
|
172
|
+
if (turn === undefined)
|
|
173
|
+
return null;
|
|
174
|
+
const last = turn.segments[turn.segments.length - 1];
|
|
175
|
+
if (last !== undefined && last.closedAt === null)
|
|
176
|
+
return last;
|
|
177
|
+
const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), folded: false, spilled: false, headCell: null, cells: [] };
|
|
178
|
+
turn.segments.push(fresh);
|
|
179
|
+
return fresh;
|
|
180
|
+
}
|
|
181
|
+
/** R3b — close the turn's open segment, if it has one. Idempotent: text
|
|
182
|
+
* arriving twice in a row closes nothing the second time, which is what
|
|
183
|
+
* keeps a zero-cell segment from ever existing. */
|
|
184
|
+
function closeSegment(turn, now) {
|
|
185
|
+
const last = turn?.segments[turn.segments.length - 1];
|
|
186
|
+
if (last !== undefined && last.closedAt === null)
|
|
187
|
+
last.closedAt = now;
|
|
188
|
+
}
|
|
72
189
|
/** W20 — the whole-table-replace comparison: the live task block only
|
|
73
190
|
* redraws when the items actually changed (the task extension's
|
|
74
191
|
* idempotent shape — an unchanged replace is a no-op, no frame). */
|
|
@@ -163,6 +280,11 @@ export class Body {
|
|
|
163
280
|
#lastThinking = null;
|
|
164
281
|
#lastTool = null;
|
|
165
282
|
#pendingCalls = new Map();
|
|
283
|
+
/** R3b — cell index → the index of the segment it belongs to, for
|
|
284
|
+
* thinking/tool cells; -1 for every other kind. Parallel to #cells,
|
|
285
|
+
* because a segment is the COMPOSITOR's bookkeeping and does not
|
|
286
|
+
* belong on the cell type the renderer sees. */
|
|
287
|
+
#cellSegment = [];
|
|
166
288
|
#pipeBuf = ""; // the passthrough's thinking buffer
|
|
167
289
|
/** TUI2-MD ⑤ — the markdown scanner of the message currently
|
|
168
290
|
* streaming, and the cell index its first block landed at. Null
|
|
@@ -320,7 +442,7 @@ export class Body {
|
|
|
320
442
|
// W14: the turn boundary — the record the fold-hold's release
|
|
321
443
|
// state machine reads; the cell carries the record's index. A9:
|
|
322
444
|
// the user's own words ride the record — the fold's leading chip.
|
|
323
|
-
this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), words: text, folded: false });
|
|
445
|
+
this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), words: text, folded: false, segments: [] });
|
|
324
446
|
this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
|
|
325
447
|
this.#mark();
|
|
326
448
|
}
|
|
@@ -335,6 +457,17 @@ export class Body {
|
|
|
335
457
|
}
|
|
336
458
|
else {
|
|
337
459
|
this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
|
|
460
|
+
// R3b: thinking is WORK, so it opens a segment too — a turn that
|
|
461
|
+
// thinks, speaks, then thinks again has two segments, and the
|
|
462
|
+
// second one's clock starts here rather than at a tool call it
|
|
463
|
+
// may never make.
|
|
464
|
+
//
|
|
465
|
+
// OPEN then STAMP, in that order: the stamp records the segment
|
|
466
|
+
// the cell belongs to, and a stamp taken first records the
|
|
467
|
+
// PREVIOUS segment (or none at all) — which left the thinking
|
|
468
|
+
// row standing outside the fold it should have led.
|
|
469
|
+
openSegment(this.#turns[this.#turns.length - 1], Date.now());
|
|
470
|
+
this.#stampSegment();
|
|
338
471
|
}
|
|
339
472
|
this.#mark();
|
|
340
473
|
}
|
|
@@ -393,7 +526,19 @@ export class Body {
|
|
|
393
526
|
turn.edits += 1;
|
|
394
527
|
else
|
|
395
528
|
turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
|
|
529
|
+
// R3b: and into the SEGMENT, which opens here when this is the
|
|
530
|
+
// first work since the last text block.
|
|
531
|
+
const seg = openSegment(turn, Date.now());
|
|
532
|
+
if (seg !== null) {
|
|
533
|
+
if (name === "read_file")
|
|
534
|
+
seg.reads += 1;
|
|
535
|
+
else if (name === "edit_file")
|
|
536
|
+
seg.edits += 1;
|
|
537
|
+
else
|
|
538
|
+
seg.others.set(name, (seg.others.get(name) ?? 0) + 1);
|
|
539
|
+
}
|
|
396
540
|
}
|
|
541
|
+
this.#stampSegment();
|
|
397
542
|
this.#mark();
|
|
398
543
|
}
|
|
399
544
|
toolApproval(callId, diff) {
|
|
@@ -515,6 +660,11 @@ export class Body {
|
|
|
515
660
|
const turn = this.#turns[this.#turns.length - 1];
|
|
516
661
|
if (turn !== undefined)
|
|
517
662
|
turn.hasText = true;
|
|
663
|
+
// R3b: text CLOSES the open segment. This is the boundary design.md
|
|
664
|
+
// §8 names — "folding at every text boundary changes what commits
|
|
665
|
+
// and when" — and it is the whole mechanism: a segment is what sits
|
|
666
|
+
// between two of these.
|
|
667
|
+
closeSegment(turn, Date.now());
|
|
518
668
|
// TUI2-MD ⑤: assistant body text is MARKDOWN, scanned as it
|
|
519
669
|
// streams. The scanner yields CLOSED blocks (final source, final
|
|
520
670
|
// render) and one OPEN tail block; each becomes a cell, and the
|
|
@@ -598,6 +748,9 @@ export class Body {
|
|
|
598
748
|
return;
|
|
599
749
|
turn.ended = true;
|
|
600
750
|
turn.thoughtSeconds = thoughtSeconds;
|
|
751
|
+
// R3b: the settle closes the last open segment — the turn's end is
|
|
752
|
+
// a boundary exactly as a text block is.
|
|
753
|
+
closeSegment(turn, Date.now());
|
|
601
754
|
// W20: the turn's live task block settles HERE — the ONE recap
|
|
602
755
|
// block for the turn ("`task done · N items · <duration>", the
|
|
603
756
|
// duration clocked compositor-side from the block's first call —
|
|
@@ -801,6 +954,89 @@ export class Body {
|
|
|
801
954
|
const idx = this.#collapsed[this.#expandPtr % this.#collapsed.length];
|
|
802
955
|
this.#expandPtr += 1;
|
|
803
956
|
const cell = this.#cells[idx];
|
|
957
|
+
// R3b — a folded SEGMENT expands to the work it stands for.
|
|
958
|
+
//
|
|
959
|
+
// The fold line collapses a run of thinking and tool cells into
|
|
960
|
+
// one row; without this the run would be unreachable, which is
|
|
961
|
+
// hiding a durable record behind a summary. The rows are APPENDED
|
|
962
|
+
// (ADR-0046 — history is never rewritten), exactly as every other
|
|
963
|
+
// expand in this method does, and they are the cells' OWN renders,
|
|
964
|
+
// so the expansion cannot drift from what was folded.
|
|
965
|
+
const seg = this.#segmentOf(idx);
|
|
966
|
+
if (seg !== null && seg.headCell === idx) {
|
|
967
|
+
const p = palette();
|
|
968
|
+
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
969
|
+
const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
970
|
+
const W = this.#opts.width();
|
|
971
|
+
const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
|
|
972
|
+
// R3b (owner ruling): the ROLLUP is the expansion. TUI2-R1 built
|
|
973
|
+
// a richer projection of an explore run than a fold line can
|
|
974
|
+
// carry — the per-tool counts, and one row per tool with its
|
|
975
|
+
// subjects — and the segment fold would have retired it by
|
|
976
|
+
// simply arriving first. So the run's own rows are what the key
|
|
977
|
+
// opens: explore tools group the way the rollup groups them,
|
|
978
|
+
// everything else renders as itself.
|
|
979
|
+
// The segment's cells IN ORDER, with consecutive explore tools
|
|
980
|
+
// grouped exactly as the rollup groups them — a write, a shell
|
|
981
|
+
// or anything else BREAKS the run, which is TUI2-R1's own rule
|
|
982
|
+
// and the reason two explore runs on either side of a write
|
|
983
|
+
// stay two runs. Merging every explore tool of the segment
|
|
984
|
+
// would have been simpler and would have quietly deleted that
|
|
985
|
+
// rule.
|
|
986
|
+
const rows = [];
|
|
987
|
+
let run = [];
|
|
988
|
+
const flush = () => {
|
|
989
|
+
if (run.length === 0)
|
|
990
|
+
return;
|
|
991
|
+
// the same threshold the commit-time rollup uses: below it a
|
|
992
|
+
// "run" is just some rows
|
|
993
|
+
if (run.length > 2) {
|
|
994
|
+
// the run renders through the ROLLUP's own projection —
|
|
995
|
+
// literally the same function the commit path uses — so
|
|
996
|
+
// a single-name run keeps W13's row and a mixed one gets
|
|
997
|
+
// the exploration line, exactly as they would have if the
|
|
998
|
+
// segment had never folded.
|
|
999
|
+
const head = run[0];
|
|
1000
|
+
const saved = head.rolled;
|
|
1001
|
+
head.rolled = rolledOf(run);
|
|
1002
|
+
// the run OPENS. The fold's key already asked to see the
|
|
1003
|
+
// work, so what lands is the same rows `ctrl+r` on the
|
|
1004
|
+
// run itself would have opened — its title, then its
|
|
1005
|
+
// detail — never its collapsed row, which would make the
|
|
1006
|
+
// reader press a second time for what the first press
|
|
1007
|
+
// was for.
|
|
1008
|
+
rows.push(` ${p.dim}${escapeTerminal(rolledTitle(head))}${p.reset}`);
|
|
1009
|
+
rows.push(...rolledDetail(head, W));
|
|
1010
|
+
head.rolled = saved;
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
for (const c of run)
|
|
1014
|
+
rows.push(...cellComponent(c).render(W, ctx));
|
|
1015
|
+
}
|
|
1016
|
+
run = [];
|
|
1017
|
+
};
|
|
1018
|
+
for (const j of seg.cells) {
|
|
1019
|
+
if (j < idx)
|
|
1020
|
+
continue;
|
|
1021
|
+
const c = this.#cells[j];
|
|
1022
|
+
if (c.kind === "tool" && isExploreTool(c.name)) {
|
|
1023
|
+
run.push(c);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
flush();
|
|
1027
|
+
rows.push(...cellComponent(c).render(W, ctx));
|
|
1028
|
+
}
|
|
1029
|
+
flush();
|
|
1030
|
+
// the header NAMES the segment. When the segment is exactly one
|
|
1031
|
+
// explore run, "explored 8 files · 14 searches" is what that run
|
|
1032
|
+
// is called everywhere else in the product, and the header says
|
|
1033
|
+
// the same thing rather than a second wording of it.
|
|
1034
|
+
// the header states what the SEGMENT did, in the fold line's own
|
|
1035
|
+
// terms; each run below states what IT did, in the rollup's. Two
|
|
1036
|
+
// scales, one wording each — the header used to borrow the run's
|
|
1037
|
+
// sentence, which read as the same run twice.
|
|
1038
|
+
return { kind: "appended", lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(foldMeta(seg))} · ${back}`, ...rows] };
|
|
1039
|
+
}
|
|
804
1040
|
if (cell.kind !== "tool")
|
|
805
1041
|
return { kind: "none" };
|
|
806
1042
|
if (cell.rolled !== null) {
|
|
@@ -814,15 +1050,10 @@ export class Body {
|
|
|
814
1050
|
// TUI2-R1 (B): an EXPLORATION head lists per TOOL — the counts
|
|
815
1051
|
// the row showed, then one row per tool with its subjects. The
|
|
816
1052
|
// header keeps W15's shape; only the subject changes.
|
|
817
|
-
|
|
818
|
-
const header = `${p.bold}✦${p.reset} expanded · ${escapeTerminal(`explored ${exploreCounts(cell.rolled.parts)}`)} · ${back}`;
|
|
819
|
-
return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
|
|
820
|
-
}
|
|
821
|
-
const noun = ROLLUP_NOUN[cell.name] ?? "calls";
|
|
822
|
-
const header = `${p.bold}✦${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${cell.rolled.count} ${noun}`)} · ${back}`;
|
|
1053
|
+
const rolledHead = rolledTitle(cell);
|
|
823
1054
|
return {
|
|
824
1055
|
kind: "appended",
|
|
825
|
-
lines: [
|
|
1056
|
+
lines: [`${p.bold}✦${p.reset} expanded · ${escapeTerminal(rolledHead)} · ${back}`, ...rolledDetail(cell, this.#opts.width())],
|
|
826
1057
|
};
|
|
827
1058
|
}
|
|
828
1059
|
let input = {};
|
|
@@ -1232,15 +1463,32 @@ export class Body {
|
|
|
1232
1463
|
}, this.#frameMs);
|
|
1233
1464
|
this.#frameTimer.unref();
|
|
1234
1465
|
}
|
|
1235
|
-
/**
|
|
1236
|
-
*
|
|
1466
|
+
/**
|
|
1467
|
+
* The spinner: a ONE-SHOT re-armed ONLY while something is MOVING —
|
|
1468
|
+
* nothing moving → no timer → zero bytes (the #14/#15 contract,
|
|
1469
|
+
* structural).
|
|
1470
|
+
*
|
|
1471
|
+
* R3: "moving" used to mean a running TOOL and nothing else. But the
|
|
1472
|
+
* same counter drives the status row's `working` twinkle, and the
|
|
1473
|
+
* model spends whole stretches thinking with no tool open at all —
|
|
1474
|
+
* so the one indicator whose entire job is "I am still alive" froze
|
|
1475
|
+
* solid exactly when the user most needed it to move. It was
|
|
1476
|
+
* reported as "I thought it had failed", which is precisely the
|
|
1477
|
+
* message a frozen liveness mark sends.
|
|
1478
|
+
*
|
|
1479
|
+
* An OPEN THINKING cell counts as movement now, and so does a tool
|
|
1480
|
+
* parked at an approval — that one is waiting on a human rather than
|
|
1481
|
+
* working, but the mark is still the proof the process is alive.
|
|
1482
|
+
*/
|
|
1237
1483
|
#armSpinner() {
|
|
1238
1484
|
if (this.#spinnerTimer !== null)
|
|
1239
1485
|
return;
|
|
1240
1486
|
this.#spinnerTimer = setTimeout(() => {
|
|
1241
1487
|
this.#spinnerTimer = null;
|
|
1242
|
-
|
|
1243
|
-
|
|
1488
|
+
const moving = this.#cells.some((c) => (c.kind === "tool" && (c.state === "running" || c.state === "approval") && !c.done) ||
|
|
1489
|
+
(c.kind === "thinking" && !c.done));
|
|
1490
|
+
if (moving) {
|
|
1491
|
+
this.#spinnerI = (this.#spinnerI + 1) % MOTION_FRAMES;
|
|
1244
1492
|
this.#dirty = true;
|
|
1245
1493
|
this.#scheduleFrame();
|
|
1246
1494
|
this.#armSpinner();
|
|
@@ -1603,7 +1851,11 @@ export class Body {
|
|
|
1603
1851
|
// unshift: the cells commit oldest-first, so the NEWEST cut lands
|
|
1604
1852
|
// at the front — the expand pointer's "newest back" walk starts
|
|
1605
1853
|
// where the user's last key press would aim.
|
|
1606
|
-
|
|
1854
|
+
// R3b: a fold HEAD joins the ring too. The test used to demand a
|
|
1855
|
+
// tool cell, and a segment's fold can be emitted at a thinking
|
|
1856
|
+
// cell — which would have left the whole segment unreachable by
|
|
1857
|
+
// the very key its own row advertises.
|
|
1858
|
+
if ((cell.kind === "tool" || this.#segmentOf(i)?.headCell === i) && lines.some((l) => l.includes("ctrl+r")))
|
|
1607
1859
|
this.#collapsed.unshift(i);
|
|
1608
1860
|
this.#lineCache[i] = lines;
|
|
1609
1861
|
const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
|
|
@@ -1611,6 +1863,60 @@ export class Body {
|
|
|
1611
1863
|
this.#committedLines += placed.length;
|
|
1612
1864
|
this.#committedLinesThisFrame.push(...placed);
|
|
1613
1865
|
}
|
|
1866
|
+
/** R3b — record which segment the cell just pushed belongs to. Called
|
|
1867
|
+
* right after the push, so #cells.length-1 is that cell. */
|
|
1868
|
+
#stampSegment() {
|
|
1869
|
+
const turn = this.#turns[this.#turns.length - 1];
|
|
1870
|
+
const idx = turn === undefined ? -1 : turn.segments.length - 1;
|
|
1871
|
+
const at = this.#cells.length - 1;
|
|
1872
|
+
this.#cellSegment[at] = idx;
|
|
1873
|
+
if (turn !== undefined && idx >= 0)
|
|
1874
|
+
turn.segments[idx].cells.push(at);
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* R3b — does the segment hold a call that FAILED or was DENIED?
|
|
1878
|
+
*
|
|
1879
|
+
* Such a segment does not fold. Routine work is what the fold is for;
|
|
1880
|
+
* a refusal and an error are the opposite of routine, and putting
|
|
1881
|
+
* either behind a key hides the one thing on the screen that most
|
|
1882
|
+
* needs a human's eye. Law 1.3 makes the same call about marks — a
|
|
1883
|
+
* failure keeps its colour AND its words — and this is that rule at
|
|
1884
|
+
* the scale of a run.
|
|
1885
|
+
*
|
|
1886
|
+
* The cost, accepted: a turn that reads twenty files and hits one
|
|
1887
|
+
* denial keeps all twenty rows. The alternative is a screen that says
|
|
1888
|
+
* `✦ thought 3s · 20 reads` while a write was refused inside it.
|
|
1889
|
+
*/
|
|
1890
|
+
#segmentHasTrouble(seg) {
|
|
1891
|
+
return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null);
|
|
1892
|
+
}
|
|
1893
|
+
/** R3b — the segment's TOOL cells, in order. */
|
|
1894
|
+
#segmentTools(seg) {
|
|
1895
|
+
const out = [];
|
|
1896
|
+
for (const j of seg.cells) {
|
|
1897
|
+
const c = this.#cells[j];
|
|
1898
|
+
if (c.kind === "tool")
|
|
1899
|
+
out.push(c);
|
|
1900
|
+
}
|
|
1901
|
+
return out;
|
|
1902
|
+
}
|
|
1903
|
+
/** R3b — how many cells the segment holds. The fold's threshold reads
|
|
1904
|
+
* it; nothing else needs it, so it is counted rather than tracked. */
|
|
1905
|
+
#segmentCells(seg) {
|
|
1906
|
+
return seg.cells.length;
|
|
1907
|
+
}
|
|
1908
|
+
/** R3b — the segment a committed cell belongs to, or null when it has
|
|
1909
|
+
* none (a cell of the pipe path, or a kind that is not work). */
|
|
1910
|
+
#segmentOf(i) {
|
|
1911
|
+
const cell = this.#cells[i];
|
|
1912
|
+
if (cell.kind !== "thinking" && cell.kind !== "tool")
|
|
1913
|
+
return null;
|
|
1914
|
+
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
1915
|
+
const si = this.#cellSegment[i];
|
|
1916
|
+
if (turn === undefined || si === undefined || si < 0)
|
|
1917
|
+
return null;
|
|
1918
|
+
return turn.segments[si] ?? null;
|
|
1919
|
+
}
|
|
1614
1920
|
/** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
|
|
1615
1921
|
* (no text yet) does not commit — its committed form is decided at
|
|
1616
1922
|
* the release. The cell's OWN turn must be the CURRENT one (a cell
|
|
@@ -1623,10 +1929,32 @@ export class Body {
|
|
|
1623
1929
|
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
1624
1930
|
if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
|
|
1625
1931
|
return false;
|
|
1932
|
+
// R3b (owner, 2026-08-27) — the hold is the SEGMENT's, not the
|
|
1933
|
+
// quiet turn's.
|
|
1934
|
+
//
|
|
1935
|
+
// W14 held a turn's work only while the turn had produced no text
|
|
1936
|
+
// at all, because the fold existed only for a turn that never
|
|
1937
|
+
// spoke. The owner ruled that a segment folds the moment text
|
|
1938
|
+
// arrives, so the unit whose committed form is undecided is the
|
|
1939
|
+
// SEGMENT: while it is open, its cells must not reach the
|
|
1940
|
+
// scrollback, because a committed row cannot be replaced by the
|
|
1941
|
+
// fold line that is going to stand for it.
|
|
1942
|
+
//
|
|
1943
|
+
// This is exactly the change design.md §8 warned about — "folding
|
|
1944
|
+
// at every text boundary changes what commits and when" — and the
|
|
1945
|
+
// warning is why the hold is stated here, once, rather than
|
|
1946
|
+
// spread across the callers.
|
|
1947
|
+
//
|
|
1948
|
+
// The quiet turn is the same rule seen from one side: its single
|
|
1949
|
+
// segment never closes until the settle, so it holds exactly as
|
|
1950
|
+
// it always did.
|
|
1951
|
+
const seg = this.#segmentOf(i);
|
|
1952
|
+
if (seg !== null)
|
|
1953
|
+
return seg.closedAt === null;
|
|
1954
|
+
// no segment (the pipe path's shape) — W14's original test, kept
|
|
1955
|
+
// so a cell that never got a segment behaves as it used to.
|
|
1626
1956
|
if (!turn.ended && !turn.hasText)
|
|
1627
1957
|
return true;
|
|
1628
|
-
// the turn's END releases every hold — the settle is where the run
|
|
1629
|
-
// is decided, and a held cell at settle would never commit at all.
|
|
1630
1958
|
if (turn.ended)
|
|
1631
1959
|
return false;
|
|
1632
1960
|
return this.#growingRun(i);
|
|
@@ -1679,18 +2007,53 @@ export class Body {
|
|
|
1679
2007
|
#foldOrRollup(cell, i, W, ctx) {
|
|
1680
2008
|
if (cell.kind === "thinking" || cell.kind === "tool") {
|
|
1681
2009
|
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
1682
|
-
|
|
1683
|
-
|
|
2010
|
+
const seg = this.#segmentOf(i);
|
|
2011
|
+
// R3b — the SEGMENT folds, and it folds once.
|
|
2012
|
+
//
|
|
2013
|
+
// A cell only reaches here once its segment is closed (the hold
|
|
2014
|
+
// above keeps an open segment out of the commit loop entirely),
|
|
2015
|
+
// so the first cell of a closed segment emits the fold and every
|
|
2016
|
+
// cell after it renders nothing. `folded` is the latch: without
|
|
2017
|
+
// it the second cell would emit a second fold line for the same
|
|
2018
|
+
// work.
|
|
2019
|
+
//
|
|
2020
|
+
// A SPILLED segment is the honest degradation. The force-commit
|
|
2021
|
+
// path does not consult the hold — the screen's hard cap wins —
|
|
2022
|
+
// so a segment too big for the screen already has rows in the
|
|
2023
|
+
// scrollback that cannot be taken back. It renders normally and
|
|
2024
|
+
// says nothing false; it simply does not collapse.
|
|
2025
|
+
// R3b — a ONE-CELL segment does not fold. Collapsing one row into
|
|
2026
|
+
// one row gains no space and costs the row's subject: `✦ thought
|
|
2027
|
+
// 0s · 1 shell` says strictly less than `shell make build ·
|
|
2028
|
+
// exit 0`. The fold exists to stop a screen filling with work
|
|
2029
|
+
// rows, and one row is not that.
|
|
2030
|
+
if (turn !== undefined && seg !== null && seg.closedAt !== null && !seg.spilled && this.#segmentCells(seg) >= 2 && !this.#segmentHasTrouble(seg)) {
|
|
2031
|
+
if (!seg.folded) {
|
|
2032
|
+
seg.folded = true;
|
|
2033
|
+
seg.headCell = i;
|
|
1684
2034
|
turn.folded = true;
|
|
1685
2035
|
// A9 (ruling R2, mock A): the user chip rides the fold —
|
|
1686
|
-
//
|
|
1687
|
-
//
|
|
2036
|
+
// but ONLY on a quiet turn, where the fold stands for the
|
|
2037
|
+
// whole turn and the chip has nowhere else to be. In a
|
|
2038
|
+
// turn WITH text the chip cell commits on its own, so a
|
|
2039
|
+
// fold that repeated the words would put the user's line
|
|
2040
|
+
// on screen twice. The words take the fold's width budget
|
|
2041
|
+
// (turnFold is W-aware — the ONE row never trips
|
|
2042
|
+
// invariant ①).
|
|
2043
|
+
const quiet = turn.ended && !turn.hasText;
|
|
2044
|
+
// A QUIET turn (no text at all) keeps `thoughtSeconds` —
|
|
2045
|
+
// the CLI's own measure, taken at the settle, and the
|
|
2046
|
+
// number every W14 gate pins. A mid-turn segment cannot
|
|
2047
|
+
// have it (endTurn has not run), so it reports its own
|
|
2048
|
+
// wall clock, which is the only honest number available
|
|
2049
|
+
// at the moment it folds.
|
|
2050
|
+
const seconds = quiet ? turn.thoughtSeconds : Math.max(0, Math.round(((seg.closedAt ?? 0) - seg.openedAt) / 1000));
|
|
1688
2051
|
return turnFold({
|
|
1689
|
-
words: turn.words,
|
|
1690
|
-
thoughtSeconds:
|
|
1691
|
-
reads:
|
|
1692
|
-
edits:
|
|
1693
|
-
others: [...
|
|
2052
|
+
words: quiet ? turn.words : "",
|
|
2053
|
+
thoughtSeconds: seconds,
|
|
2054
|
+
reads: seg.reads,
|
|
2055
|
+
edits: seg.edits,
|
|
2056
|
+
others: [...seg.others],
|
|
1694
2057
|
}, W);
|
|
1695
2058
|
}
|
|
1696
2059
|
return [];
|
|
@@ -1758,37 +2121,7 @@ export class Body {
|
|
|
1758
2121
|
// exploration row's counts and its expanded list both read them.
|
|
1759
2122
|
// A search's subject is the PATTERN it looked for (quoted); a
|
|
1760
2123
|
// read's or a list's is the path it named.
|
|
1761
|
-
|
|
1762
|
-
for (const m of members) {
|
|
1763
|
-
// the lines count, excluding the tool's OWN truncation note
|
|
1764
|
-
// (read_file's "… N more lines") — the per-cell meta's rule
|
|
1765
|
-
const noteAt = m.resultText.lastIndexOf("\n… ");
|
|
1766
|
-
const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
|
|
1767
|
-
const rows = shown.split("\n");
|
|
1768
|
-
total += rows[rows.length - 1] === "" ? rows.length - 1 : rows.length;
|
|
1769
|
-
let input = {};
|
|
1770
|
-
try {
|
|
1771
|
-
input = JSON.parse(m.inputFull);
|
|
1772
|
-
}
|
|
1773
|
-
catch {
|
|
1774
|
-
// the full JSON is always parseable (stringified at
|
|
1775
|
-
// toolStart) — the empty fallback never fires
|
|
1776
|
-
}
|
|
1777
|
-
const target = toolTarget(m.name, input);
|
|
1778
|
-
targets.push(target.split("/").pop() ?? target);
|
|
1779
|
-
const subject = m.name === "search_text" ? `"${String(input.pattern ?? "")}"` : target;
|
|
1780
|
-
const part = parts.find((x) => x.name === m.name);
|
|
1781
|
-
if (part === undefined)
|
|
1782
|
-
parts.push({ name: m.name, subjects: [subject] });
|
|
1783
|
-
else
|
|
1784
|
-
part.subjects.push(subject);
|
|
1785
|
-
}
|
|
1786
|
-
const first = members[0];
|
|
1787
|
-
const last = members[members.length - 1];
|
|
1788
|
-
const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
|
|
1789
|
-
// TUI2-R1 (B): `parts` rides ONLY a mixed run — a single-name
|
|
1790
|
-
// run keeps W13's row, byte for byte (the generalization adds).
|
|
1791
|
-
cell.rolled = { count: members.length, lines: total, elapsed, targets, ...(parts.length > 1 ? { parts } : {}) };
|
|
2124
|
+
cell.rolled = rolledOf(members);
|
|
1792
2125
|
return cellComponent(cell).render(W, ctx);
|
|
1793
2126
|
}
|
|
1794
2127
|
// a MEMBER of an already-rolled run → [] (its rows live in the
|
package/dist/editor.js
CHANGED
|
@@ -88,7 +88,13 @@ const N_MAX = 6;
|
|
|
88
88
|
const OSC_MAX = 1024;
|
|
89
89
|
/** The dim "…" — the ONE truncation mark: the horizontal scroll's
|
|
90
90
|
* prefix (unchanged) and the viewport's hidden-rows markers. */
|
|
91
|
-
|
|
91
|
+
/** R3: built per call from the palette — `dim` is an absolute grey once
|
|
92
|
+
* the ground is known, so a frozen SGR 2 here would be the one span
|
|
93
|
+
* that ignores it. */
|
|
94
|
+
const ellipsis = () => {
|
|
95
|
+
const p = palette();
|
|
96
|
+
return `${p.dim}\u2026${p.reset}`;
|
|
97
|
+
};
|
|
92
98
|
/**
|
|
93
99
|
* The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
|
|
94
100
|
* exit. The input row is rendered by `onRender` (the CLI wires it to the
|
|
@@ -459,9 +465,9 @@ export class Editor {
|
|
|
459
465
|
// the cursor's own row carries the horizontal scroll (and its
|
|
460
466
|
// "…"); the other rows render whole and cap at the frame's wall
|
|
461
467
|
const from = i === cursorLine ? b.start + this.#scroll : b.start;
|
|
462
|
-
const scrolled = i === cursorLine && this.#scroll > 0 ?
|
|
463
|
-
const above = i === first && first > 0 ?
|
|
464
|
-
const below = i === first + n - 1 && first + n < bounds.length ?
|
|
468
|
+
const scrolled = i === cursorLine && this.#scroll > 0 ? ellipsis() : "";
|
|
469
|
+
const above = i === first && first > 0 ? ellipsis() : "";
|
|
470
|
+
const below = i === first + n - 1 && first + n < bounds.length ? ellipsis() : "";
|
|
465
471
|
lines.push(`${above}${scrolled}${String.fromCodePoint(...this.#chars.slice(from, b.end))}${below}`);
|
|
466
472
|
}
|
|
467
473
|
const cursorRow = cursorLine - first;
|
package/dist/status.d.ts
CHANGED
|
@@ -21,9 +21,21 @@
|
|
|
21
21
|
* deliberate exception: the running row's interrupt hint, which KC2 §2
|
|
22
22
|
* widens to name the new gesture.
|
|
23
23
|
*/
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
24
|
+
/**
|
|
25
|
+
* R3 (design §5.2) — the working glyph family is the TWINKLE, and the
|
|
26
|
+
* CLI's 200ms spinner walks it exactly as it walked the four quadrant
|
|
27
|
+
* blocks it replaces.
|
|
28
|
+
*
|
|
29
|
+
* Two reasons the quadrants had to go. §5.3: they ROTATED, and a mark
|
|
30
|
+
* that turns implies progress a call of unpredictable duration does not
|
|
31
|
+
* have. §4.1: the twinkle settles onto `✦`, which is the mark the
|
|
32
|
+
* folded segment keeps — so the glyph a human watches while the model
|
|
33
|
+
* thinks is the glyph left behind when the thought collapses into a
|
|
34
|
+
* record, and nothing new appears at the transition.
|
|
35
|
+
*
|
|
36
|
+
* Glyphs only, no colour: intact under NO_COLOR and on any ground.
|
|
37
|
+
*/
|
|
38
|
+
export declare const STATUS_GLYPHS: readonly ["✧", "✦", "✶", "✸", "✺", "✸", "✦"];
|
|
27
39
|
/**
|
|
28
40
|
* The RUNNING row: the rotating glyph, the wall seconds since `since`
|
|
29
41
|
* (never below 1 — a run that just started still reads "1s", so the row
|
package/dist/status.js
CHANGED
|
@@ -22,9 +22,22 @@
|
|
|
22
22
|
* widens to name the new gesture.
|
|
23
23
|
*/
|
|
24
24
|
import { kUnit } from "./render.js";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
import { TWINKLE } from "@vincemakes/kiso-tui-cells/render";
|
|
26
|
+
/**
|
|
27
|
+
* R3 (design §5.2) — the working glyph family is the TWINKLE, and the
|
|
28
|
+
* CLI's 200ms spinner walks it exactly as it walked the four quadrant
|
|
29
|
+
* blocks it replaces.
|
|
30
|
+
*
|
|
31
|
+
* Two reasons the quadrants had to go. §5.3: they ROTATED, and a mark
|
|
32
|
+
* that turns implies progress a call of unpredictable duration does not
|
|
33
|
+
* have. §4.1: the twinkle settles onto `✦`, which is the mark the
|
|
34
|
+
* folded segment keeps — so the glyph a human watches while the model
|
|
35
|
+
* thinks is the glyph left behind when the thought collapses into a
|
|
36
|
+
* record, and nothing new appears at the transition.
|
|
37
|
+
*
|
|
38
|
+
* Glyphs only, no colour: intact under NO_COLOR and on any ground.
|
|
39
|
+
*/
|
|
40
|
+
export const STATUS_GLYPHS = TWINKLE;
|
|
28
41
|
/** The ~ctx estimate as the whole-percent LEFT. A non-finite ratio (no
|
|
29
42
|
* window, no estimate) yields null and the row prints "~null%" — the
|
|
30
43
|
* long-standing shape, kept on purpose: an honest null beats an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.6",
|
|
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.16.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.16.6"
|
|
39
39
|
}
|
|
40
40
|
}
|