@vincemakes/kiso-tui 0.1.37 → 0.1.38
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/components.d.ts +87 -4
- package/dist/components.js +223 -31
- package/dist/compositor.d.ts +41 -2
- package/dist/compositor.js +374 -26
- package/dist/editor.d.ts +1 -0
- package/dist/editor.js +16 -1
- package/dist/render.d.ts +11 -1
- package/dist/render.js +35 -1
- package/package.json +1 -1
package/dist/components.d.ts
CHANGED
|
@@ -69,14 +69,20 @@ export type BodyCell = {
|
|
|
69
69
|
kind: "user";
|
|
70
70
|
text: string;
|
|
71
71
|
done: true;
|
|
72
|
+
turn: number;
|
|
72
73
|
} | {
|
|
73
74
|
kind: "thinking";
|
|
74
75
|
text: string;
|
|
75
76
|
done: boolean;
|
|
77
|
+
turn: number;
|
|
76
78
|
} | {
|
|
77
79
|
kind: "tool";
|
|
78
80
|
name: string;
|
|
79
81
|
input: string;
|
|
82
|
+
/** W15: the FULL input JSON (pretty-printed) — the display
|
|
83
|
+
* summary above is sliced at 60 chars; the expanded block's
|
|
84
|
+
* "--- input ---" section mirrors /last and needs it all. */
|
|
85
|
+
inputFull: string;
|
|
80
86
|
childRoles: string[];
|
|
81
87
|
state: "pending" | "approval" | "running" | "done";
|
|
82
88
|
isError: boolean;
|
|
@@ -87,6 +93,32 @@ export type BodyCell = {
|
|
|
87
93
|
startedAt: number | null;
|
|
88
94
|
doneAt: number | null;
|
|
89
95
|
done: boolean;
|
|
96
|
+
/** W15: the live-region expand toggle — while the cell is live
|
|
97
|
+
* the FULL body renders in place (the compositor owns those
|
|
98
|
+
* rows and redraws them); a committed cell can never toggle
|
|
99
|
+
* (history is never rewritten — ADR-0046). */
|
|
100
|
+
expanded: boolean;
|
|
101
|
+
/** W14: the turn boundary — the index of the turn record that
|
|
102
|
+
* created this cell (the fold-hold's owner; −1 when no turn
|
|
103
|
+
* exists yet — the pre-turn cells never hold). */
|
|
104
|
+
turn: number;
|
|
105
|
+
/** W13: the rolled-up group summary — set at COMMIT time when
|
|
106
|
+
* the head of an N > 2 same-tool run renders the group (the
|
|
107
|
+
* work order's claimed shape: "✓ read 5 files (2.4k lines,
|
|
108
|
+
* 1.1s)" + the target children). The members carry null — the
|
|
109
|
+
* compositor's rolled-heads bookkeeping renders them []. */
|
|
110
|
+
rolled: null | {
|
|
111
|
+
count: number;
|
|
112
|
+
lines: number;
|
|
113
|
+
elapsed: string;
|
|
114
|
+
targets: string[];
|
|
115
|
+
};
|
|
116
|
+
/** W19: a DENIED call's reason (the CLI extracted it from the
|
|
117
|
+
* result's "[Permission denied] " prefix, keyed on the "denied"
|
|
118
|
+
* tag). Non-null renders the pinned row — the full call name,
|
|
119
|
+
* the target, the reason in the W4 parentheses idiom, NO timing
|
|
120
|
+
* metadata (the call never ran). */
|
|
121
|
+
reason: string | null;
|
|
90
122
|
} | {
|
|
91
123
|
kind: "text";
|
|
92
124
|
text: string;
|
|
@@ -112,26 +144,77 @@ export type BodyCell = {
|
|
|
112
144
|
done: true;
|
|
113
145
|
} | {
|
|
114
146
|
kind: "checklist";
|
|
147
|
+
/** the model-authored header tail (parseChecklist's count line —
|
|
148
|
+
* chat.ts). The compositor's fixed "todo" prefix rides BEFORE it
|
|
149
|
+
* (W20 naming ruling: never model-controlled). */
|
|
115
150
|
header: string;
|
|
116
151
|
items: {
|
|
117
152
|
text: string;
|
|
118
153
|
status: "pending" | "active" | "done";
|
|
119
154
|
}[];
|
|
120
|
-
|
|
155
|
+
/** W20: false while LIVE — the current turn's ONE in-place block
|
|
156
|
+
* (the commit loop only takes done cells, so it stays in the
|
|
157
|
+
* live region); true once SETTLED — endTurn committed it as the
|
|
158
|
+
* turn's one recap block. */
|
|
159
|
+
done: boolean;
|
|
160
|
+
/** W20: the LIVE block's ctrl+r toggle (W15) — the capped form
|
|
161
|
+
* flips to the full list in place. The settled render ignores
|
|
162
|
+
* it (already full). */
|
|
163
|
+
expanded: boolean;
|
|
164
|
+
/** W20: the wall clock of the block's FIRST call — the settled
|
|
165
|
+
* header's duration is clocked from here, compositor-side (the
|
|
166
|
+
* CLI stays unchanged). */
|
|
167
|
+
startedAt: number;
|
|
168
|
+
/** W20: the run's duration at the settle — the `2h 14m` form. */
|
|
169
|
+
durationSeconds: number;
|
|
170
|
+
turn: number;
|
|
121
171
|
};
|
|
122
172
|
declare const TOOL_SUMMARY_MAX = 60;
|
|
123
173
|
/** The component for one cell — the mapping table lives here so the
|
|
124
174
|
* compositor stays a pure writer. */
|
|
125
175
|
export declare function cellComponent(cell: BodyCell): Component;
|
|
176
|
+
/** W13 — the rollup opt-in table: which tools collapse, and the count
|
|
177
|
+
* NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
|
|
178
|
+
* → "5 matches"). Only these tools opt in — a shell burst is never
|
|
179
|
+
* rolled up (its rows carry meaning). The folded-turn line (W14) reuses
|
|
180
|
+
* the plurals for its other-tool terms ("2 dirs", "1 match"). */
|
|
181
|
+
export declare const ROLLUP_NOUN: Readonly<Record<string, string>>;
|
|
182
|
+
/** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
|
|
183
|
+
* scrollback, becomes ONE line — the work order's claimed shape
|
|
184
|
+
* (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
|
|
185
|
+
* toolStart: read_file → "reads", edit_file → "edits", the other tools
|
|
186
|
+
* as first-call-order terms (the ROLLUP_NOUN plurals when the tool opts
|
|
187
|
+
* in, the verb + "s" otherwise). */
|
|
188
|
+
export declare function turnFold(t: {
|
|
189
|
+
thoughtSeconds: number;
|
|
190
|
+
reads: number;
|
|
191
|
+
edits: number;
|
|
192
|
+
others: [string, number][];
|
|
193
|
+
}): string[];
|
|
194
|
+
/** W20 — the todo block's fixed-window height: the whole live block
|
|
195
|
+
* (header + rows) in POST-FOLD screen rows at EVERY width: the header,
|
|
196
|
+
* the active row, up to 2 pending, the overflow-pending fold, the
|
|
197
|
+
* done-collapse. Every live row CUTS at W (never folds) — the block's
|
|
198
|
+
* height is its row count. */
|
|
199
|
+
export declare const CAP_TODO_LIVE = 6;
|
|
200
|
+
/** W20 — the settled block's duration, the `2h 14m` form (the todo
|
|
201
|
+
* narrative's long-horizon idiom): minutes+seconds under an hour,
|
|
202
|
+
* hours+minutes past it. */
|
|
203
|
+
export declare function formatDuration(totalSeconds: number): string;
|
|
126
204
|
/** The status container's row: the status text (+ the tail) with the
|
|
127
205
|
* right-aligned "/ commands · ↑ history" hint in the idle state —
|
|
128
206
|
* the hint CUT FIRST when the width is short (the #16g rule); when
|
|
129
207
|
* the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
|
|
130
208
|
* enforced by invariant ① (the old code let the status soft-wrap). */
|
|
131
209
|
export declare function statusLine(status: string, tail: string, question: boolean, W: number, hint?: string): string;
|
|
132
|
-
/**
|
|
133
|
-
* the
|
|
134
|
-
|
|
210
|
+
/** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
|
|
211
|
+
* a rounded box (the box already says "input lives here"); the rails
|
|
212
|
+
* stay dim, the width is still the full W (the box is a rail with
|
|
213
|
+
* corners — the menu/gap rows above and the status below are
|
|
214
|
+
* untouched). */
|
|
215
|
+
export declare function boxTop(W: number): string;
|
|
216
|
+
/** W6 — the box: the chrome's bottom rail. */
|
|
217
|
+
export declare function boxBottom(W: number): string;
|
|
135
218
|
/** The terminal label + rhythm gap (the pipe path's v2c bytes — the
|
|
136
219
|
* exact render the passthrough needs). */
|
|
137
220
|
export declare function terminalPipe(label: string, statusLineText: string): string;
|
package/dist/components.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* tint, fold wording).
|
|
17
17
|
*/
|
|
18
18
|
import { displayWidth } from "./editor.js";
|
|
19
|
-
import { bannerLines, escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, palette, } from "./render.js";
|
|
19
|
+
import { bannerLines, escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
|
|
20
20
|
/** The spinner glyphs, cycled by the compositor's on-demand tick. */
|
|
21
21
|
export const SPINNER = ["▖", "▘", "▝", "▗"];
|
|
22
22
|
/**
|
|
@@ -328,7 +328,43 @@ class ToolExecution {
|
|
|
328
328
|
const verb = escapeTerminal(c.name.replace("_file", ""));
|
|
329
329
|
const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
|
|
330
330
|
const summary = escapeTerminal(c.input);
|
|
331
|
+
if (c.rolled !== null) {
|
|
332
|
+
// W13 — the rolled-up group's ONE row + the target children:
|
|
333
|
+
// the work order's claimed shape, verbatim — the verbCol's
|
|
334
|
+
// 5-char pad reproduces the "read 5 files" double space, the
|
|
335
|
+
// children are the first 3 basename targets, the overflow row
|
|
336
|
+
// carries the ctrl+r affordance (its "└ … ctrl+r" joins the
|
|
337
|
+
// W15 expand history — the head's commit captures it).
|
|
338
|
+
const r = c.rolled;
|
|
339
|
+
const noun = ROLLUP_NOUN[c.name] ?? "calls";
|
|
340
|
+
const out = gutterFold(`${p.bold}✓${p.reset} `, `${verbCol} ${r.count} ${noun} (${kUnit(r.lines)} lines, ${r.elapsed}s)`, W);
|
|
341
|
+
const shown = r.targets.slice(0, 3);
|
|
342
|
+
if (shown.length > 0)
|
|
343
|
+
out.push(` ${p.dim}${CUT_ROW}${escapeTerminal(shown.join(" · "))}${p.reset}`);
|
|
344
|
+
if (r.targets.length > 3)
|
|
345
|
+
out.push(` ${p.dim}${CUT_ROW}+${r.targets.length - 3} more — ctrl+r expands${p.reset}`);
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
331
348
|
if (c.state === "done") {
|
|
349
|
+
// W19: the pinned deny — the claimed shape verbatim: the FULL
|
|
350
|
+
// call name (the denial names the call), the target, the reason
|
|
351
|
+
// in the W4 parentheses idiom, no timing (the call never ran).
|
|
352
|
+
// The same ✗ family as any failure; the [result ✗] body still
|
|
353
|
+
// rides below (never hide information).
|
|
354
|
+
if (c.reason !== null) {
|
|
355
|
+
let input = {};
|
|
356
|
+
try {
|
|
357
|
+
input = JSON.parse(c.inputFull);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// the full JSON is always parseable (stringified at
|
|
361
|
+
// toolStart) — the empty fallback never fires
|
|
362
|
+
}
|
|
363
|
+
const target = toolTarget(c.name, input);
|
|
364
|
+
const out = gutterFold(`${p.red}✗${p.reset} `, `${p.red}${escapeTerminal(`${c.name} ${target}`)} (${escapeTerminal(c.reason)})${p.reset}`, W);
|
|
365
|
+
out.push(...toolBlockBody(c, W));
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
332
368
|
const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
|
|
333
369
|
const meta = escapeTerminal(settledMeta(c));
|
|
334
370
|
const out = c.isError
|
|
@@ -357,6 +393,47 @@ class ToolExecution {
|
|
|
357
393
|
return gutterFold(`${p.dim}◦${p.reset} `, `${verbCol} ${summary}`, W);
|
|
358
394
|
}
|
|
359
395
|
}
|
|
396
|
+
/** W13 — the rollup opt-in table: which tools collapse, and the count
|
|
397
|
+
* NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
|
|
398
|
+
* → "5 matches"). Only these tools opt in — a shell burst is never
|
|
399
|
+
* rolled up (its rows carry meaning). The folded-turn line (W14) reuses
|
|
400
|
+
* the plurals for its other-tool terms ("2 dirs", "1 match"). */
|
|
401
|
+
export const ROLLUP_NOUN = {
|
|
402
|
+
read_file: "files",
|
|
403
|
+
list_dir: "dirs",
|
|
404
|
+
search_text: "matches",
|
|
405
|
+
};
|
|
406
|
+
/** The count term with the singular/plural forms — "no reads", "1 read",
|
|
407
|
+
* "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
|
|
408
|
+
* "matches" → "match"). */
|
|
409
|
+
function countTerm(n, singular, plural) {
|
|
410
|
+
if (n === 0)
|
|
411
|
+
return `no ${plural}`;
|
|
412
|
+
if (n === 1)
|
|
413
|
+
return `1 ${singular}`;
|
|
414
|
+
return `${n} ${plural}`;
|
|
415
|
+
}
|
|
416
|
+
/** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
|
|
417
|
+
* scrollback, becomes ONE line — the work order's claimed shape
|
|
418
|
+
* (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
|
|
419
|
+
* toolStart: read_file → "reads", edit_file → "edits", the other tools
|
|
420
|
+
* as first-call-order terms (the ROLLUP_NOUN plurals when the tool opts
|
|
421
|
+
* in, the verb + "s" otherwise). */
|
|
422
|
+
export function turnFold(t) {
|
|
423
|
+
const p = palette();
|
|
424
|
+
const parts = [`thought ${t.thoughtSeconds}s`, countTerm(t.reads, "read", "reads"), countTerm(t.edits, "edit", "edits")];
|
|
425
|
+
for (const [name, n] of t.others) {
|
|
426
|
+
const noun = ROLLUP_NOUN[name];
|
|
427
|
+
if (noun !== undefined) {
|
|
428
|
+
parts.push(countTerm(n, noun.endsWith("es") ? noun.slice(0, -2) : noun.slice(0, -1), noun));
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
const verb = name.replace("_file", "");
|
|
432
|
+
parts.push(countTerm(n, verb, `${verb}s`));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return [`${p.bold}▞${p.reset} ${parts.join(" · ")}`];
|
|
436
|
+
}
|
|
360
437
|
// ---- the bounded-block flow contract (W7, W8, W10) ----
|
|
361
438
|
/** The caps — screen rows counted AFTER the fold, at the current width
|
|
362
439
|
* (the W7 table). The renderer-cut row is inside the cap. */
|
|
@@ -374,27 +451,39 @@ const blockMemo = new WeakMap();
|
|
|
374
451
|
/** The block's body rows below the header (memoized, W9). */
|
|
375
452
|
function toolBlockBody(c, W) {
|
|
376
453
|
const memo = blockMemo.get(c);
|
|
377
|
-
const state = `${c.state}:${c.isError}:${c.name}`;
|
|
454
|
+
const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}`;
|
|
378
455
|
const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
|
|
379
456
|
if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
|
|
380
457
|
return memo.rows;
|
|
381
458
|
const p = palette();
|
|
382
|
-
const rows = c.
|
|
383
|
-
?
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
459
|
+
const rows = c.expanded
|
|
460
|
+
? // W15: the toggle's full form — the WHOLE body, no cap, no
|
|
461
|
+
// cut note (nothing is cut; the width fold still holds — the
|
|
462
|
+
// height may change while live, the user asked for it). The
|
|
463
|
+
// delegate has no body — its rows are unchanged.
|
|
464
|
+
c.state === "approval"
|
|
465
|
+
? diffBody(c.diff, W, true)
|
|
466
|
+
: c.name === "delegate"
|
|
467
|
+
? c.state === "running"
|
|
468
|
+
? delegateRunning(c, W)
|
|
469
|
+
: delegateSettled(c, W)
|
|
470
|
+
: blockRows(c.resultText, W)
|
|
471
|
+
: c.state === "done"
|
|
472
|
+
? c.isError
|
|
473
|
+
? errorBody(c, W)
|
|
474
|
+
: c.name === "delegate"
|
|
475
|
+
? delegateSettled(c, W)
|
|
476
|
+
: c.name.startsWith("shell")
|
|
477
|
+
? shellTail(c.resultText, W)
|
|
478
|
+
: []
|
|
479
|
+
: c.state === "running"
|
|
480
|
+
? c.name === "delegate"
|
|
481
|
+
? delegateRunning(c, W)
|
|
482
|
+
: liveWindow(c.resultText, W)
|
|
483
|
+
: c.state === "approval"
|
|
484
|
+
? diffBody(c.diff, W)
|
|
485
|
+
: [];
|
|
486
|
+
const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
|
|
398
487
|
if (note !== null)
|
|
399
488
|
rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
|
|
400
489
|
blockMemo.set(c, { width: W, state, content, rows });
|
|
@@ -436,7 +525,11 @@ function errorBody(c, W) {
|
|
|
436
525
|
// rides the header — the parsed code does — so the body keeps the
|
|
437
526
|
// FULL text. Any other error keeps the pre-W4 split: line 0 is the
|
|
438
527
|
// header's metadata, the body shows the rest.
|
|
439
|
-
|
|
528
|
+
// W19: a DENIED call's header meta is the PARSED reason (from the
|
|
529
|
+
// denied tag), decoupled from the result text — the body keeps the
|
|
530
|
+
// FULL content including the "[Permission denied] " prefix (never
|
|
531
|
+
// hide information — the folded body rides the pinned row).
|
|
532
|
+
const skipFirst = c.name === "shell" && /^exit \d+/.test(c.resultText) ? 0 : c.reason !== null && c.reason !== undefined ? 0 : 1;
|
|
440
533
|
const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
|
|
441
534
|
if (rows.length <= CAP_ERROR)
|
|
442
535
|
return rows;
|
|
@@ -507,7 +600,7 @@ function oneLineRow(p, text, W) {
|
|
|
507
600
|
* a floor of 3 SOURCE lines visible the head/tail pair is noise (each
|
|
508
601
|
* fragment a sliver of a long line): drop to the head only — the head
|
|
509
602
|
* takes the whole budget — and the └ row carries the rest. */
|
|
510
|
-
function diffBody(diff, W) {
|
|
603
|
+
function diffBody(diff, W, expanded = false) {
|
|
511
604
|
const p = palette();
|
|
512
605
|
if (diff === null)
|
|
513
606
|
return [];
|
|
@@ -527,7 +620,7 @@ function diffBody(diff, W) {
|
|
|
527
620
|
rows.push(...gutterFold(`${p.dim}│${p.reset} `, body, W));
|
|
528
621
|
starts.push(rows.length);
|
|
529
622
|
}
|
|
530
|
-
if (rows.length <= CAP_DIFF)
|
|
623
|
+
if (expanded || rows.length <= CAP_DIFF)
|
|
531
624
|
return rows;
|
|
532
625
|
const head = Math.floor((CAP_DIFF - 1) / 2);
|
|
533
626
|
const tail = CAP_DIFF - 1 - head;
|
|
@@ -625,7 +718,64 @@ class Banner {
|
|
|
625
718
|
return rows.map((r) => `${p.dim}${r}${p.reset}`);
|
|
626
719
|
}
|
|
627
720
|
}
|
|
628
|
-
/**
|
|
721
|
+
/** W20 — the todo block's fixed-window height: the whole live block
|
|
722
|
+
* (header + rows) in POST-FOLD screen rows at EVERY width: the header,
|
|
723
|
+
* the active row, up to 2 pending, the overflow-pending fold, the
|
|
724
|
+
* done-collapse. Every live row CUTS at W (never folds) — the block's
|
|
725
|
+
* height is its row count. */
|
|
726
|
+
export const CAP_TODO_LIVE = 6;
|
|
727
|
+
/** W20 — the live block's fixed-window row cut: an SGR-aware ONE-ROW
|
|
728
|
+
* truncation (foldLine wraps; a wrapped row would break the height
|
|
729
|
+
* cap — every live row is exactly one screen row at every width).
|
|
730
|
+
* A line that fits (≤ W) passes through whole; an overflow cuts the
|
|
731
|
+
* content at W−1 — the ellipsis's slot — and the ellipsis rides AFTER
|
|
732
|
+
* the reset (post-reset — the PTY needles' convention). The cut row
|
|
733
|
+
* never exceeds W (invariant ①). */
|
|
734
|
+
function cutLine(line, W) {
|
|
735
|
+
if (visibleWidth(line) <= W)
|
|
736
|
+
return line;
|
|
737
|
+
let out = "";
|
|
738
|
+
let width = 0;
|
|
739
|
+
for (let i = 0; i < line.length;) {
|
|
740
|
+
if (line[i] === "\x1b") {
|
|
741
|
+
const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i)) ?? line[i];
|
|
742
|
+
out += m;
|
|
743
|
+
i += m.length;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const cw = displayWidth(line[i]);
|
|
747
|
+
if (width + cw > W - 1)
|
|
748
|
+
break; // reserve the ellipsis's column
|
|
749
|
+
out += line[i];
|
|
750
|
+
width += cw;
|
|
751
|
+
i += 1;
|
|
752
|
+
}
|
|
753
|
+
return `${out}\x1b[0m…`;
|
|
754
|
+
}
|
|
755
|
+
/** W20 — the settled block's duration, the `2h 14m` form (the todo
|
|
756
|
+
* narrative's long-horizon idiom): minutes+seconds under an hour,
|
|
757
|
+
* hours+minutes past it. */
|
|
758
|
+
export function formatDuration(totalSeconds) {
|
|
759
|
+
const s = Math.max(0, Math.round(totalSeconds));
|
|
760
|
+
if (s < 60)
|
|
761
|
+
return `${s}s`;
|
|
762
|
+
const m = Math.floor(s / 60);
|
|
763
|
+
return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* W20 — the todo checklist as STATE: ONE live block that redraws in
|
|
767
|
+
* place (the current turn's in-place updates), settling at the turn's
|
|
768
|
+
* end as ONE recap block. LIVE (done:false): the fixed "todo" prefix +
|
|
769
|
+
* the compositor-derived counts (the model tail rides AFTER — never
|
|
770
|
+
* model-controlled), the active item first with ▸ (the menu's "the
|
|
771
|
+
* current one"), pending next (≤2), the done items COLLAPSED behind the
|
|
772
|
+
* W10 cut family `└ +N done · ctrl+r`, overflow pending behind
|
|
773
|
+
* `└ +N more · ctrl+r` — every row cut at W so the cap holds at every
|
|
774
|
+
* width. ctrl+r (W15) toggles the full list in place (expanded). SETTLED
|
|
775
|
+
* (done:true): the recap idiom `todo done · N items · <duration>` + the
|
|
776
|
+
* FULL final item list in the checklist's existing shape (▖/□/▣ —
|
|
777
|
+
* indented two, the glyph leads, no │ gutter).
|
|
778
|
+
*/
|
|
629
779
|
class Checklist {
|
|
630
780
|
cell;
|
|
631
781
|
constructor(cell) {
|
|
@@ -633,12 +783,47 @@ class Checklist {
|
|
|
633
783
|
}
|
|
634
784
|
render(W, _ctx) {
|
|
635
785
|
const p = palette();
|
|
636
|
-
const
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
786
|
+
const { items, done, expanded, durationSeconds } = this.cell;
|
|
787
|
+
const active = items.filter((i) => i.status === "active");
|
|
788
|
+
const pending = items.filter((i) => i.status === "pending");
|
|
789
|
+
const doneCount = items.length - active.length - pending.length;
|
|
790
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
791
|
+
const tail = this.cell.header === "" ? "" : ` · ${this.cell.header}`;
|
|
792
|
+
const fixed = done
|
|
793
|
+
? `todo done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
|
|
794
|
+
: `todo · ${plural(items.length, "item")} · ${active.length} active · ${doneCount} done`;
|
|
795
|
+
const header = `${p.bold}▞${p.reset} ${escapeTerminal(fixed + tail)}`;
|
|
796
|
+
// the FULL-list forms: SETTLED — the durable record (the fold is
|
|
797
|
+
// fine — committed content wraps naturally) — and the LIVE ctrl+r
|
|
798
|
+
// toggle (the header CUTS — the block stays one window high; the
|
|
799
|
+
// expanded rows show the ▣ the collapse hid). The live flag picks
|
|
800
|
+
// the glyphs: the settled list keeps the durable ▖, the expanded
|
|
801
|
+
// live list the ▸.
|
|
802
|
+
const glyph = (status, live) => {
|
|
803
|
+
const g = status === "pending" ? "□" : status === "active" ? (live ? "▸" : "▖") : "▣";
|
|
804
|
+
return g === "▸" ? `${p.bold}▸${p.reset}` : g;
|
|
805
|
+
};
|
|
806
|
+
if (done || expanded) {
|
|
807
|
+
const rows = done ? foldLine(header, W) : [cutLine(header, W)];
|
|
808
|
+
for (const item of items)
|
|
809
|
+
rows.push(...foldLine(` ${glyph(item.status, !done)} ${escapeTerminal(item.text)}`, W));
|
|
810
|
+
return rows;
|
|
640
811
|
}
|
|
641
|
-
|
|
812
|
+
// LIVE — the fixed window: the header + the item rows CUT at W
|
|
813
|
+
// (one screen row each — the block's height is its row count,
|
|
814
|
+
// CAP_TODO_LIVE, at every width). The cut is the momentary view;
|
|
815
|
+
// the settle (and the ctrl+r toggle) show everything.
|
|
816
|
+
const itemRows = [];
|
|
817
|
+
if (active.length > 0)
|
|
818
|
+
itemRows.push(` ${p.bold}▸${p.reset} ${escapeTerminal(active[0].text)}`);
|
|
819
|
+
for (const item of pending.slice(0, 2))
|
|
820
|
+
itemRows.push(` □ ${escapeTerminal(item.text)}`);
|
|
821
|
+
const more = pending.length - 2;
|
|
822
|
+
if (more > 0)
|
|
823
|
+
itemRows.push(` ${p.dim}└ +${more} more · ctrl+r${p.reset}`);
|
|
824
|
+
if (doneCount > 0)
|
|
825
|
+
itemRows.push(` ${p.dim}└ +${doneCount} done · ctrl+r${p.reset}`);
|
|
826
|
+
return [cutLine(header, W), ...itemRows.map((r) => cutLine(r, W))];
|
|
642
827
|
}
|
|
643
828
|
}
|
|
644
829
|
// ---- the chrome components (the status container, the slot, the footer) ----
|
|
@@ -678,10 +863,17 @@ function widthCut(text, max) {
|
|
|
678
863
|
}
|
|
679
864
|
return text.slice(0, i);
|
|
680
865
|
}
|
|
681
|
-
/**
|
|
682
|
-
* the
|
|
683
|
-
|
|
684
|
-
|
|
866
|
+
/** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
|
|
867
|
+
* a rounded box (the box already says "input lives here"); the rails
|
|
868
|
+
* stay dim, the width is still the full W (the box is a rail with
|
|
869
|
+
* corners — the menu/gap rows above and the status below are
|
|
870
|
+
* untouched). */
|
|
871
|
+
export function boxTop(W) {
|
|
872
|
+
return `\x1b[2m╭${"─".repeat(Math.max(0, W - 2))}╮\x1b[0m`;
|
|
873
|
+
}
|
|
874
|
+
/** W6 — the box: the chrome's bottom rail. */
|
|
875
|
+
export function boxBottom(W) {
|
|
876
|
+
return `\x1b[2m╰${"─".repeat(Math.max(0, W - 2))}╯\x1b[0m`;
|
|
685
877
|
}
|
|
686
878
|
/** The terminal label + rhythm gap (the pipe path's v2c bytes — the
|
|
687
879
|
* exact render the passthrough needs). */
|
package/dist/compositor.d.ts
CHANGED
|
@@ -35,8 +35,9 @@
|
|
|
35
35
|
* scheduler — a one-shot setTimeout re-armed only while a running
|
|
36
36
|
* tool exists (the #14/#15 zero-output contract is structural).
|
|
37
37
|
*
|
|
38
|
-
* Layout at H rows (V6-3 — the design §03 chrome):
|
|
39
|
-
* 1..H−4,
|
|
38
|
+
* Layout at H rows (V6-3 — the design §03 chrome; W6 — the box):
|
|
39
|
+
* content rows 1..H−4, box top H−3, editor (the slot) H−2, box
|
|
40
|
+
* bottom H−1, status H.
|
|
40
41
|
* Pipes / NO_COLOR: the passthrough branches below keep the v2a/v2b
|
|
41
42
|
* line-mode bytes byte-for-byte (the e2e guards them).
|
|
42
43
|
*/
|
|
@@ -78,11 +79,32 @@ export declare class Body {
|
|
|
78
79
|
toolResult(callId: string, result: {
|
|
79
80
|
content: string;
|
|
80
81
|
isError: boolean;
|
|
82
|
+
reason?: string | null;
|
|
81
83
|
}): void;
|
|
82
84
|
textAppend(text: string): void;
|
|
83
85
|
textEnd(): void;
|
|
86
|
+
/** W14 — the turn boundary's END: the CLI calls this at the run's
|
|
87
|
+
* terminal event, once per run, BEFORE the recap (so the fold line
|
|
88
|
+
* commits before the recap in the cell order). `thoughtSeconds` is
|
|
89
|
+
* the CLI's wall-clocked thinking window. The QUIET turn (ended, no
|
|
90
|
+
* text) releases its held cells as the ONE fold line; a turn with
|
|
91
|
+
* text releases them as individual commits (the W13 rollups). The
|
|
92
|
+
* release is LAZY — the held cells commit at the next frame, when
|
|
93
|
+
* the fold/rollup decision runs. */
|
|
94
|
+
endTurn(thoughtSeconds: number): void;
|
|
84
95
|
terminal(label: string, statusLineText: string): void;
|
|
85
96
|
notice(text: string): void;
|
|
97
|
+
/** W20 — the todo checklist as STATE, not events: the FIRST call of a
|
|
98
|
+
* turn creates the ONE live block (done:false — the commit loop only
|
|
99
|
+
* takes done cells, so it stays in the live region); later calls of
|
|
100
|
+
* the SAME turn MUTATE that block in place — same position, same
|
|
101
|
+
* height, zero committed rows (the W8 fixed-window rule generalised
|
|
102
|
+
* to state). An unchanged whole-table replace (the todo extension's
|
|
103
|
+
* idempotent shape) is a no-op — no mark, no frame. The block commits
|
|
104
|
+
* ONCE at the turn's end (endTurn); the next turn's first call starts
|
|
105
|
+
* a fresh block — one settled block per turn that touched the list,
|
|
106
|
+
* never one per update. The pipe path stays per-call (byte-linear —
|
|
107
|
+
* every write is final; there is no in-place redraw in a pipe). */
|
|
86
108
|
checklist(header: string, items: {
|
|
87
109
|
text: string;
|
|
88
110
|
status: "pending" | "active" | "done";
|
|
@@ -107,6 +129,23 @@ export declare class Body {
|
|
|
107
129
|
isError: boolean;
|
|
108
130
|
};
|
|
109
131
|
} | null;
|
|
132
|
+
/** W15 — the expand key's target (ctrl+r). A cell still in the LIVE
|
|
133
|
+
* region (the newest live tool) TOGGLES in place — the compositor
|
|
134
|
+
* owns those rows and redraws them (the body flips to the full
|
|
135
|
+
* form, no cap). A committed cell can never toggle — history is
|
|
136
|
+
* never rewritten (ADR-0046) — so the key APPENDS a fresh expanded
|
|
137
|
+
* block at the bottom instead, the /last idiom aimed at a chosen
|
|
138
|
+
* cell: the pointer cycles the collapsed history, newest first, and
|
|
139
|
+
* the header names the target ("N turns back" — the user cells
|
|
140
|
+
* after it), so every press tells the user what they got. */
|
|
141
|
+
expandNext(): {
|
|
142
|
+
kind: "toggled";
|
|
143
|
+
} | {
|
|
144
|
+
kind: "appended";
|
|
145
|
+
lines: string[];
|
|
146
|
+
} | {
|
|
147
|
+
kind: "none";
|
|
148
|
+
};
|
|
110
149
|
/** Docked = the chrome is live (a color TTY with a real size). */
|
|
111
150
|
get active(): boolean;
|
|
112
151
|
enter(): void;
|
package/dist/compositor.js
CHANGED
|
@@ -35,22 +35,29 @@
|
|
|
35
35
|
* scheduler — a one-shot setTimeout re-armed only while a running
|
|
36
36
|
* tool exists (the #14/#15 zero-output contract is structural).
|
|
37
37
|
*
|
|
38
|
-
* Layout at H rows (V6-3 — the design §03 chrome):
|
|
39
|
-
* 1..H−4,
|
|
38
|
+
* Layout at H rows (V6-3 — the design §03 chrome; W6 — the box):
|
|
39
|
+
* content rows 1..H−4, box top H−3, editor (the slot) H−2, box
|
|
40
|
+
* bottom H−1, status H.
|
|
40
41
|
* Pipes / NO_COLOR: the passthrough branches below keep the v2a/v2b
|
|
41
42
|
* line-mode bytes byte-for-byte (the e2e guards them).
|
|
42
43
|
*/
|
|
43
44
|
import { truncateDiff } from "./diff.js";
|
|
44
45
|
import { displayWidth } from "./editor.js";
|
|
45
|
-
import { Container, SPINNER, bodySpacing, cellComponent, foldLine,
|
|
46
|
-
import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./render.js";
|
|
46
|
+
import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, statusLine, turnFold, visibleWidth, } from "./components.js";
|
|
47
|
+
import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
|
|
47
48
|
/** The cursor marker — an APC private sequence the focus component
|
|
48
49
|
* embeds at the edit position; the compositor strips it and moves
|
|
49
50
|
* relatively (it never reaches the terminal). */
|
|
50
51
|
export const CURSOR_MARKER = "\x1b_[kiso-cur]\x1b\\";
|
|
51
52
|
const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
|
|
52
53
|
const SPINNER_MS = 200; // the spinner cadence — a ONE-SHOT re-armed on demand
|
|
53
|
-
const CHROME_ROWS = 4; //
|
|
54
|
+
const CHROME_ROWS = 4; // box top + input + box bottom + status — the design §03 chrome (V6-3; the box is W6)
|
|
55
|
+
/** W20 — the whole-table-replace comparison: the live todo block only
|
|
56
|
+
* redraws when the items actually changed (the todo extension's
|
|
57
|
+
* idempotent shape — an unchanged replace is a no-op, no frame). */
|
|
58
|
+
function sameTodo(a, b) {
|
|
59
|
+
return a.length === b.length && a.every((x, i) => x.text === b[i].text && x.status === b[i].status);
|
|
60
|
+
}
|
|
54
61
|
/** The one compositor — implements the Body façade AND the Dock chrome
|
|
55
62
|
* API (see the class comments on each method group). */
|
|
56
63
|
export class Body {
|
|
@@ -74,6 +81,19 @@ export class Body {
|
|
|
74
81
|
#pendingCalls = new Map();
|
|
75
82
|
#pipeBuf = ""; // the passthrough's thinking buffer
|
|
76
83
|
#toolCells = new Map(); // callId → cell index (parallel tools)
|
|
84
|
+
// W15: the collapsed (cut) tool cells — committed cells whose last
|
|
85
|
+
// rendered row carried the "ctrl+r" affordance; the expand key's
|
|
86
|
+
// cycling pointer walks this list from the newest back.
|
|
87
|
+
#collapsed = [];
|
|
88
|
+
#expandPtr = 0;
|
|
89
|
+
// W14: the turn records — one per userLine, the fold-hold's state
|
|
90
|
+
// machine (ended / hasText / folded) plus the folded-turn line's
|
|
91
|
+
// counts (accumulated at toolStart). The cells carry the record's
|
|
92
|
+
// index as their turn boundary.
|
|
93
|
+
#turns = [];
|
|
94
|
+
// W13: the rolled-up run heads — the commit-time scan's verdict:
|
|
95
|
+
// the head's group summary renders, the members render [].
|
|
96
|
+
#rolledHeads = new Set();
|
|
77
97
|
#write;
|
|
78
98
|
#resizeHandler = null;
|
|
79
99
|
// the chrome state (the Dock façade)
|
|
@@ -119,7 +139,10 @@ export class Body {
|
|
|
119
139
|
}
|
|
120
140
|
this.#closeOpenThinking();
|
|
121
141
|
this.#closeOpenText();
|
|
122
|
-
|
|
142
|
+
// W14: the turn boundary — the record the fold-hold's release
|
|
143
|
+
// state machine reads; the cell carries the record's index.
|
|
144
|
+
this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), folded: false });
|
|
145
|
+
this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
|
|
123
146
|
this.#mark();
|
|
124
147
|
}
|
|
125
148
|
thinkingAppend(text) {
|
|
@@ -132,7 +155,7 @@ export class Body {
|
|
|
132
155
|
last.text += text;
|
|
133
156
|
}
|
|
134
157
|
else {
|
|
135
|
-
this.#cells.push({ kind: "thinking", text, done: false });
|
|
158
|
+
this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
|
|
136
159
|
}
|
|
137
160
|
this.#mark();
|
|
138
161
|
}
|
|
@@ -166,7 +189,19 @@ export class Body {
|
|
|
166
189
|
}
|
|
167
190
|
}
|
|
168
191
|
this.#toolCells.set(callId, this.#cells.length);
|
|
169
|
-
this.#cells.push({ kind: "tool", name, input: summary, childRoles, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
|
|
192
|
+
this.#cells.push({ kind: "tool", name, input: summary, inputFull: JSON.stringify(input, null, 2), childRoles, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false, expanded: false, turn: this.#turns.length - 1, rolled: null, reason: null });
|
|
193
|
+
// W14: the turn record's counts — the folded-turn line's terms
|
|
194
|
+
// (reads = read_file, edits = edit_file, the rest in first-call
|
|
195
|
+
// order). The CLI's recap counts the same way (edit_file).
|
|
196
|
+
const turn = this.#turns[this.#turns.length - 1];
|
|
197
|
+
if (turn !== undefined) {
|
|
198
|
+
if (name === "read_file")
|
|
199
|
+
turn.reads += 1;
|
|
200
|
+
else if (name === "edit_file")
|
|
201
|
+
turn.edits += 1;
|
|
202
|
+
else
|
|
203
|
+
turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
|
|
204
|
+
}
|
|
170
205
|
this.#mark();
|
|
171
206
|
}
|
|
172
207
|
toolApproval(callId, diff) {
|
|
@@ -213,8 +248,11 @@ export class Body {
|
|
|
213
248
|
this.#pendingCalls.delete(callId);
|
|
214
249
|
}
|
|
215
250
|
if (!this.#isActive()) {
|
|
251
|
+
// W19: the pipe path renders the SAME pinned deny row (the
|
|
252
|
+
// reason in the W4 parentheses idiom), byte-clean — plus the
|
|
253
|
+
// folded [result ✗] body below (never hide information).
|
|
216
254
|
const p = palette();
|
|
217
|
-
this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
|
|
255
|
+
this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result, result.reason ?? null)}\n` +
|
|
218
256
|
`${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
|
|
219
257
|
return;
|
|
220
258
|
}
|
|
@@ -224,6 +262,7 @@ export class Body {
|
|
|
224
262
|
cell.state = "done";
|
|
225
263
|
cell.isError = result.isError;
|
|
226
264
|
cell.resultText = result.content;
|
|
265
|
+
cell.reason = result.reason ?? null;
|
|
227
266
|
cell.doneAt = Date.now();
|
|
228
267
|
cell.done = true;
|
|
229
268
|
}
|
|
@@ -236,6 +275,12 @@ export class Body {
|
|
|
236
275
|
this.#write(escapeTerminal(text));
|
|
237
276
|
return;
|
|
238
277
|
}
|
|
278
|
+
// W14: the text's arrival RELEASES the fold-hold — the turn now
|
|
279
|
+
// has text, its held cells commit individually (with the W13
|
|
280
|
+
// rollups; the fold is only for the QUIET turn).
|
|
281
|
+
const turn = this.#turns[this.#turns.length - 1];
|
|
282
|
+
if (turn !== undefined)
|
|
283
|
+
turn.hasText = true;
|
|
239
284
|
const last = this.#cells[this.#cells.length - 1];
|
|
240
285
|
if (last !== undefined && last.kind === "text" && !last.done) {
|
|
241
286
|
last.text += text;
|
|
@@ -257,6 +302,51 @@ export class Body {
|
|
|
257
302
|
last.done = true;
|
|
258
303
|
this.#mark();
|
|
259
304
|
}
|
|
305
|
+
/** W14 — the turn boundary's END: the CLI calls this at the run's
|
|
306
|
+
* terminal event, once per run, BEFORE the recap (so the fold line
|
|
307
|
+
* commits before the recap in the cell order). `thoughtSeconds` is
|
|
308
|
+
* the CLI's wall-clocked thinking window. The QUIET turn (ended, no
|
|
309
|
+
* text) releases its held cells as the ONE fold line; a turn with
|
|
310
|
+
* text releases them as individual commits (the W13 rollups). The
|
|
311
|
+
* release is LAZY — the held cells commit at the next frame, when
|
|
312
|
+
* the fold/rollup decision runs. */
|
|
313
|
+
endTurn(thoughtSeconds) {
|
|
314
|
+
if (!this.#isActive())
|
|
315
|
+
return;
|
|
316
|
+
const turn = this.#turns[this.#turns.length - 1];
|
|
317
|
+
if (turn === undefined || turn.ended)
|
|
318
|
+
return;
|
|
319
|
+
turn.ended = true;
|
|
320
|
+
turn.thoughtSeconds = thoughtSeconds;
|
|
321
|
+
// W20: the turn's live todo block settles HERE — the ONE recap
|
|
322
|
+
// block for the turn (`todo done · N items · <duration>`, the
|
|
323
|
+
// duration clocked compositor-side from the block's first call —
|
|
324
|
+
// the CLI stays unchanged). A turn that never touched the list has
|
|
325
|
+
// no live block — nothing settles. Newest-first: the live block is
|
|
326
|
+
// the newest cell of its turn.
|
|
327
|
+
for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
|
|
328
|
+
const c = this.#cells[i];
|
|
329
|
+
if (c.kind === "checklist" && !c.done) {
|
|
330
|
+
c.done = true;
|
|
331
|
+
c.durationSeconds = Math.max(0, Math.round((Date.now() - c.startedAt) / 1000));
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// the QUIET turn: an open thinking cell closes at the boundary —
|
|
336
|
+
// its natural closer is the text's arrival (never comes here — the
|
|
337
|
+
// text-less turn), so without this the fold could never commit AT
|
|
338
|
+
// it (the commit loop only takes done cells — the fold would stall
|
|
339
|
+
// forever behind the live thinking).
|
|
340
|
+
for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
|
|
341
|
+
const c = this.#cells[i];
|
|
342
|
+
if (c.kind === "thinking" && !c.done) {
|
|
343
|
+
c.done = true;
|
|
344
|
+
this.#lastThinking = c.text;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
this.#mark();
|
|
349
|
+
}
|
|
260
350
|
terminal(label, statusLineText) {
|
|
261
351
|
if (!this.#isActive()) {
|
|
262
352
|
this.#closeOpenThinking();
|
|
@@ -281,6 +371,17 @@ export class Body {
|
|
|
281
371
|
this.#cells.push({ kind: "notice", text, done: true });
|
|
282
372
|
this.#mark();
|
|
283
373
|
}
|
|
374
|
+
/** W20 — the todo checklist as STATE, not events: the FIRST call of a
|
|
375
|
+
* turn creates the ONE live block (done:false — the commit loop only
|
|
376
|
+
* takes done cells, so it stays in the live region); later calls of
|
|
377
|
+
* the SAME turn MUTATE that block in place — same position, same
|
|
378
|
+
* height, zero committed rows (the W8 fixed-window rule generalised
|
|
379
|
+
* to state). An unchanged whole-table replace (the todo extension's
|
|
380
|
+
* idempotent shape) is a no-op — no mark, no frame. The block commits
|
|
381
|
+
* ONCE at the turn's end (endTurn); the next turn's first call starts
|
|
382
|
+
* a fresh block — one settled block per turn that touched the list,
|
|
383
|
+
* never one per update. The pipe path stays per-call (byte-linear —
|
|
384
|
+
* every write is final; there is no in-place redraw in a pipe). */
|
|
284
385
|
checklist(header, items) {
|
|
285
386
|
if (!this.#isActive()) {
|
|
286
387
|
this.#closeOpenThinking();
|
|
@@ -294,7 +395,25 @@ export class Body {
|
|
|
294
395
|
}
|
|
295
396
|
this.#closeOpenThinking();
|
|
296
397
|
this.#closeOpenText();
|
|
297
|
-
this.#
|
|
398
|
+
const turn = this.#turns.length - 1;
|
|
399
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
400
|
+
if (last !== undefined && last.kind === "checklist" && !last.done && last.turn === turn) {
|
|
401
|
+
if (!sameTodo(last.items, items)) {
|
|
402
|
+
Object.assign(last, { header, items });
|
|
403
|
+
this.#mark();
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
this.#cells.push({
|
|
408
|
+
kind: "checklist",
|
|
409
|
+
header,
|
|
410
|
+
items,
|
|
411
|
+
done: false,
|
|
412
|
+
expanded: false,
|
|
413
|
+
startedAt: Date.now(),
|
|
414
|
+
durationSeconds: 0,
|
|
415
|
+
turn,
|
|
416
|
+
});
|
|
298
417
|
this.#mark();
|
|
299
418
|
}
|
|
300
419
|
/** The startup banner (W1): a LIVE cell — the tier re-derives per
|
|
@@ -342,6 +461,77 @@ export class Body {
|
|
|
342
461
|
lastTool() {
|
|
343
462
|
return this.#lastTool;
|
|
344
463
|
}
|
|
464
|
+
/** W15 — the expand key's target (ctrl+r). A cell still in the LIVE
|
|
465
|
+
* region (the newest live tool) TOGGLES in place — the compositor
|
|
466
|
+
* owns those rows and redraws them (the body flips to the full
|
|
467
|
+
* form, no cap). A committed cell can never toggle — history is
|
|
468
|
+
* never rewritten (ADR-0046) — so the key APPENDS a fresh expanded
|
|
469
|
+
* block at the bottom instead, the /last idiom aimed at a chosen
|
|
470
|
+
* cell: the pointer cycles the collapsed history, newest first, and
|
|
471
|
+
* the header names the target ("N turns back" — the user cells
|
|
472
|
+
* after it), so every press tells the user what they got. */
|
|
473
|
+
expandNext() {
|
|
474
|
+
for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
|
|
475
|
+
const cell = this.#cells[i];
|
|
476
|
+
if (cell.kind === "tool" && cell.state !== "pending") {
|
|
477
|
+
cell.expanded = !cell.expanded;
|
|
478
|
+
this.#mark();
|
|
479
|
+
return { kind: "toggled" };
|
|
480
|
+
}
|
|
481
|
+
// W20: the LIVE todo block toggles in place too — the capped
|
|
482
|
+
// form flips to the full list (the "done-collapse expands
|
|
483
|
+
// under ctrl+r" claim). The SETTLED block is already full —
|
|
484
|
+
// no toggle, and its rows carry no affordance, so it never
|
|
485
|
+
// joins #collapsed (the committed /last append is moot).
|
|
486
|
+
if (cell.kind === "checklist" && !cell.done) {
|
|
487
|
+
cell.expanded = !cell.expanded;
|
|
488
|
+
this.#mark();
|
|
489
|
+
return { kind: "toggled" };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (this.#collapsed.length === 0)
|
|
493
|
+
return { kind: "none" };
|
|
494
|
+
const idx = this.#collapsed[this.#expandPtr % this.#collapsed.length];
|
|
495
|
+
this.#expandPtr += 1;
|
|
496
|
+
const cell = this.#cells[idx];
|
|
497
|
+
if (cell.kind !== "tool")
|
|
498
|
+
return { kind: "none" };
|
|
499
|
+
if (cell.rolled !== null) {
|
|
500
|
+
// W13: a rolled-up head expands to the FULL per-call children —
|
|
501
|
+
// the rollup showed the first 3 + the overflow; the expand shows
|
|
502
|
+
// every target, one └ row each (the /last idiom — the children
|
|
503
|
+
// land as NEW content, history is never rewritten, ADR-0046).
|
|
504
|
+
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
505
|
+
const p = palette();
|
|
506
|
+
const noun = ROLLUP_NOUN[cell.name] ?? "calls";
|
|
507
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
508
|
+
return {
|
|
509
|
+
kind: "appended",
|
|
510
|
+
lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
let input = {};
|
|
514
|
+
try {
|
|
515
|
+
input = JSON.parse(cell.inputFull);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// the full JSON is always parseable (it was stringified at
|
|
519
|
+
// toolStart) — the empty fallback never fires
|
|
520
|
+
}
|
|
521
|
+
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
522
|
+
const p = palette();
|
|
523
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
524
|
+
return {
|
|
525
|
+
kind: "appended",
|
|
526
|
+
lines: [
|
|
527
|
+
header,
|
|
528
|
+
`--- ${cell.name} input ---`,
|
|
529
|
+
cell.inputFull,
|
|
530
|
+
`--- ${cell.name} output${cell.isError ? " (error)" : ""} ---`,
|
|
531
|
+
cell.resultText,
|
|
532
|
+
],
|
|
533
|
+
};
|
|
534
|
+
}
|
|
345
535
|
// ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
|
|
346
536
|
/** Docked = the chrome is live (a color TTY with a real size). */
|
|
347
537
|
get active() {
|
|
@@ -431,7 +621,8 @@ export class Body {
|
|
|
431
621
|
* reads it — the marker math never desyncs by construction). */
|
|
432
622
|
editCol() {
|
|
433
623
|
const inp = this.#inputState();
|
|
434
|
-
|
|
624
|
+
// W6: the box's left wall (2 cols) prefixes the prompt
|
|
625
|
+
return 2 + displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "")) + inp.cursor + 1;
|
|
435
626
|
}
|
|
436
627
|
/** The old dock's redraw — the editor's onRender target: mark + the
|
|
437
628
|
* scheduler (16ms coalescing — the old sync draw coalesces the same). */
|
|
@@ -546,7 +737,13 @@ export class Body {
|
|
|
546
737
|
// emit exactly once).
|
|
547
738
|
this.#committedAtFrameStart = this.#committed;
|
|
548
739
|
this.#committedLinesThisFrame = [];
|
|
549
|
-
|
|
740
|
+
// W14: the natural loop HONORS the fold-hold — a thinking/tool
|
|
741
|
+
// cell of the OPEN quiet turn (no text yet) does not commit: its
|
|
742
|
+
// committed form is decided at the release (the turn's text →
|
|
743
|
+
// individual commits with the W13 rollups; the turn's end → the
|
|
744
|
+
// fold). The FORCE-commit path below bypasses the hold — the
|
|
745
|
+
// screen never sticks, the rollup degrades to individuals.
|
|
746
|
+
while (this.#committed < this.#cells.length && this.#cells[this.#committed].done && !this.#held(this.#committed)) {
|
|
550
747
|
this.#commitCell(this.#committed, W, ctx);
|
|
551
748
|
}
|
|
552
749
|
// 2. the live lines — the unfinished cells (the tail) + the chrome.
|
|
@@ -606,13 +803,138 @@ export class Body {
|
|
|
606
803
|
* this cell's commit — the cache stays raw, the placed rows count. */
|
|
607
804
|
#commitCell(i, W, ctx) {
|
|
608
805
|
const cell = this.#cells[i];
|
|
609
|
-
const lines =
|
|
806
|
+
const lines = this.#foldOrRollup(cell, i, W, ctx);
|
|
807
|
+
// W15: a tool cell whose last committed row carried the "ctrl+r"
|
|
808
|
+
// affordance (the renderer cut "└ … ctrl+r") joins the expand
|
|
809
|
+
// history — the detection is the renderer's OWN output, so the
|
|
810
|
+
// read's "/last"-only cut note never lands here.
|
|
811
|
+
// unshift: the cells commit oldest-first, so the NEWEST cut lands
|
|
812
|
+
// at the front — the expand pointer's "newest back" walk starts
|
|
813
|
+
// where the user's last key press would aim.
|
|
814
|
+
if (cell.kind === "tool" && /└ .*ctrl\+r/.test(lines[lines.length - 1] ?? ""))
|
|
815
|
+
this.#collapsed.unshift(i);
|
|
610
816
|
this.#lineCache[i] = lines;
|
|
611
817
|
const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
|
|
612
818
|
this.#committed += 1;
|
|
613
819
|
this.#committedLines += placed.length;
|
|
614
820
|
this.#committedLinesThisFrame.push(...placed);
|
|
615
821
|
}
|
|
822
|
+
/** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
|
|
823
|
+
* (no text yet) does not commit — its committed form is decided at
|
|
824
|
+
* the release. The cell's OWN turn must be the CURRENT one (a cell
|
|
825
|
+
* of a released turn commits normally). The force-commit path never
|
|
826
|
+
* consults this — the screen's hard cap wins over the hold. */
|
|
827
|
+
#held(i) {
|
|
828
|
+
const cell = this.#cells[i];
|
|
829
|
+
if (cell.kind !== "thinking" && cell.kind !== "tool")
|
|
830
|
+
return false;
|
|
831
|
+
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
832
|
+
if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
|
|
833
|
+
return false;
|
|
834
|
+
return !turn.ended && !turn.hasText;
|
|
835
|
+
}
|
|
836
|
+
/** W14/W13 — the release-time decision at a commit, BEFORE the cell's
|
|
837
|
+
* own render: the folded-turn fold first (a QUIET turn — ended, no
|
|
838
|
+
* text — becomes the ONE fold line; the rest of its thinking/tool
|
|
839
|
+
* cells render [] after the fold), then the W13 rollup (a text
|
|
840
|
+
* turn's N > 2 same-tool run: the HEAD renders the group summary,
|
|
841
|
+
* the members render [] — the scan is the work order's "group key",
|
|
842
|
+
* derived at commit time, never pre-stored). */
|
|
843
|
+
#foldOrRollup(cell, i, W, ctx) {
|
|
844
|
+
if (cell.kind === "thinking" || cell.kind === "tool") {
|
|
845
|
+
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
846
|
+
if (turn !== undefined && turn.ended && !turn.hasText) {
|
|
847
|
+
if (!turn.folded) {
|
|
848
|
+
turn.folded = true;
|
|
849
|
+
return turnFold({
|
|
850
|
+
thoughtSeconds: turn.thoughtSeconds,
|
|
851
|
+
reads: turn.reads,
|
|
852
|
+
edits: turn.edits,
|
|
853
|
+
others: [...turn.others],
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
return [];
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (cell.kind !== "tool" || ROLLUP_NOUN[cell.name] === undefined)
|
|
860
|
+
return cellComponent(cell).render(W, ctx);
|
|
861
|
+
// the maximal same-name run around i — forward/backward scans over
|
|
862
|
+
// the cells. The turn-less noise cells (the permission raws, the ⚠
|
|
863
|
+
// notices) are TRANSPARENT: the streaming execution (loop.ts launch)
|
|
864
|
+
// interleaves them BETWEEN the calls of one burst, so the run must
|
|
865
|
+
// see through them. It never crosses a user/text/thinking cell —
|
|
866
|
+
// those separate turns and contexts.
|
|
867
|
+
let s = i;
|
|
868
|
+
let head = i;
|
|
869
|
+
while (s > 0) {
|
|
870
|
+
const prev = this.#cells[s - 1];
|
|
871
|
+
if (prev.kind === "raw" || prev.kind === "notice") {
|
|
872
|
+
s -= 1;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (prev.kind !== "tool" || prev.name !== cell.name)
|
|
876
|
+
break;
|
|
877
|
+
s -= 1;
|
|
878
|
+
head = s; // a same-name tool precedes — it is the group's head
|
|
879
|
+
}
|
|
880
|
+
let e = i;
|
|
881
|
+
while (e + 1 < this.#cells.length) {
|
|
882
|
+
const next = this.#cells[e + 1];
|
|
883
|
+
if (next.kind === "raw" || next.kind === "notice") {
|
|
884
|
+
e += 1;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (next.kind !== "tool" || next.name !== cell.name)
|
|
888
|
+
break;
|
|
889
|
+
e += 1;
|
|
890
|
+
}
|
|
891
|
+
// the run counts the TOOL cells only — the span's raws are noise.
|
|
892
|
+
const members = this.#cells.slice(s, e + 1).filter((c) => c.kind === "tool");
|
|
893
|
+
if (members.length <= 2)
|
|
894
|
+
return cellComponent(cell).render(W, ctx);
|
|
895
|
+
if (head === i) {
|
|
896
|
+
// the HEAD — the rollup only when EVERY member is done (at the
|
|
897
|
+
// text's release they are — the natural loop commits the run in
|
|
898
|
+
// one frame; the force-commit's early commits degrade to the
|
|
899
|
+
// individual rows, the members render normally after).
|
|
900
|
+
if (!members.every((c) => c.done))
|
|
901
|
+
return cellComponent(cell).render(W, ctx);
|
|
902
|
+
this.#rolledHeads.add(head);
|
|
903
|
+
let total = 0;
|
|
904
|
+
const targets = [];
|
|
905
|
+
for (const m of members) {
|
|
906
|
+
// the lines count, excluding the tool's OWN truncation note
|
|
907
|
+
// (read_file's "… N more lines") — the per-cell meta's rule
|
|
908
|
+
const noteAt = m.resultText.lastIndexOf("\n… ");
|
|
909
|
+
const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
|
|
910
|
+
const parts = shown.split("\n");
|
|
911
|
+
total += parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
|
|
912
|
+
let input = {};
|
|
913
|
+
try {
|
|
914
|
+
input = JSON.parse(m.inputFull);
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
// the full JSON is always parseable (stringified at
|
|
918
|
+
// toolStart) — the empty fallback never fires
|
|
919
|
+
}
|
|
920
|
+
const target = toolTarget(m.name, input);
|
|
921
|
+
targets.push(target.split("/").pop() ?? target);
|
|
922
|
+
}
|
|
923
|
+
const first = members[0];
|
|
924
|
+
const last = members[members.length - 1];
|
|
925
|
+
const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
|
|
926
|
+
cell.rolled = { count: members.length, lines: total, elapsed, targets };
|
|
927
|
+
return cellComponent(cell).render(W, ctx);
|
|
928
|
+
}
|
|
929
|
+
// a MEMBER of an already-rolled run → [] (its rows live in the
|
|
930
|
+
// head's summary). A member of a run whose head committed
|
|
931
|
+
// INDIVIDUALLY (the force-commit's degradation) renders normally —
|
|
932
|
+
// the head is not in #rolledHeads, the run never rolls after the
|
|
933
|
+
// head's individual commit.
|
|
934
|
+
if (this.#rolledHeads.has(head))
|
|
935
|
+
return [];
|
|
936
|
+
return cellComponent(cell).render(W, ctx);
|
|
937
|
+
}
|
|
616
938
|
/** The slot occupant's extra rows — the slash-command menu (above the
|
|
617
939
|
* status, in the rhythm gap + the content's spare rows — the old
|
|
618
940
|
* menu's position, slot-shaped). */
|
|
@@ -635,7 +957,14 @@ export class Body {
|
|
|
635
957
|
* cursor's display column WITHIN THE ROW (the brick/question lead
|
|
636
958
|
* included), the question/editor/menu variants. The compositor
|
|
637
959
|
* strips the marker and moves LEFT by the trailing width — the
|
|
638
|
-
* cursor derives from the frame, never from side-channel math.
|
|
960
|
+
* cursor derives from the frame, never from side-channel math.
|
|
961
|
+
*
|
|
962
|
+
* W6: the row lives INSIDE the box — the walls are a prefix/suffix
|
|
963
|
+
* width only, composed AFTER the marker embed (the marker math is
|
|
964
|
+
* untouched; the marker's row column = the wall + the lead + the
|
|
965
|
+
* cursor). The content caps at W−4 (the walls' columns) and the
|
|
966
|
+
* pad completes the row to EXACTLY W — invariant ① throws on
|
|
967
|
+
* overflow, so the box row is built full-width, never truncated. */
|
|
639
968
|
#inputRow(W, _ctx) {
|
|
640
969
|
const st = this.#inputState();
|
|
641
970
|
let row;
|
|
@@ -653,8 +982,8 @@ export class Body {
|
|
|
653
982
|
const leadW = visibleWidth(row.slice(0, row.length - st.line.length));
|
|
654
983
|
// embed the marker at the cursor's display column
|
|
655
984
|
let markerLine = "";
|
|
985
|
+
let w = 0;
|
|
656
986
|
{
|
|
657
|
-
let w = 0;
|
|
658
987
|
let inserted = false;
|
|
659
988
|
let i = 0;
|
|
660
989
|
while (i < row.length) {
|
|
@@ -671,6 +1000,8 @@ export class Body {
|
|
|
671
1000
|
inserted = true;
|
|
672
1001
|
}
|
|
673
1002
|
const cw = displayWidth(row[i]);
|
|
1003
|
+
if (w + cw > W - 4)
|
|
1004
|
+
break; // the cap — the two walls' columns
|
|
674
1005
|
markerLine += row[i];
|
|
675
1006
|
w += cw;
|
|
676
1007
|
i += 1;
|
|
@@ -679,8 +1010,24 @@ export class Body {
|
|
|
679
1010
|
markerLine += CURSOR_MARKER;
|
|
680
1011
|
}
|
|
681
1012
|
}
|
|
682
|
-
const
|
|
683
|
-
const
|
|
1013
|
+
const stripped0 = markerLine.replace(CURSOR_MARKER, "");
|
|
1014
|
+
const tailW = visibleWidth(markerLine.slice(markerLine.indexOf(CURSOR_MARKER) + CURSOR_MARKER.length));
|
|
1015
|
+
if (W < 4) {
|
|
1016
|
+
// the degenerate screen: the box cannot hold its walls — the
|
|
1017
|
+
// bare row (the pre-W6 bytes; the fold probe's pass-through
|
|
1018
|
+
// line still crashes invariant ① downstream, as before)
|
|
1019
|
+
return { stripped: stripped0, afterW: tailW };
|
|
1020
|
+
}
|
|
1021
|
+
// the pad completes the row to W — the content stopped at W−4,
|
|
1022
|
+
// so the pad is ≥ 1
|
|
1023
|
+
const padW = W - 3 - w;
|
|
1024
|
+
const stripped = `\x1b[2m│ \x1b[0m${stripped0}\x1b[2m${" ".repeat(padW)}│\x1b[0m`;
|
|
1025
|
+
// the LEFT move = the width AFTER the cursor-rest cell — the
|
|
1026
|
+
// full-width row parks the terminal cursor at the LAST cell (not
|
|
1027
|
+
// one past the end), so the rest cell IS the first tail cell and
|
|
1028
|
+
// the move = the tail + the pad (the right wall rides inside the
|
|
1029
|
+
// pad's tail)
|
|
1030
|
+
const afterW = tailW + padW;
|
|
684
1031
|
return { stripped, afterW };
|
|
685
1032
|
}
|
|
686
1033
|
/** The full-redraw path (the first frame, the resize repaint) — CUP
|
|
@@ -727,12 +1074,12 @@ export class Body {
|
|
|
727
1074
|
for (let i = 0; i < menuRows.length; i += 1) {
|
|
728
1075
|
out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
|
|
729
1076
|
}
|
|
730
|
-
// V6-3: the design §03 chrome —
|
|
731
|
-
//
|
|
732
|
-
out.push(`\x1b[${H - 3};1H\x1b[0K${
|
|
1077
|
+
// V6-3 + W6: the design §03 chrome — box top (H−3), input
|
|
1078
|
+
// (H−2), box bottom (H−1), status (H) — the box's four rows.
|
|
1079
|
+
out.push(`\x1b[${H - 3};1H\x1b[0K${boxTop(W)}`);
|
|
733
1080
|
const editor = this.#inputRow(W, ctx);
|
|
734
1081
|
out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
|
|
735
|
-
out.push(`\x1b[${H - 1};1H\x1b[0K${
|
|
1082
|
+
out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
|
|
736
1083
|
out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`);
|
|
737
1084
|
// the cursor: up two (the input row at H−2) + left to the marker
|
|
738
1085
|
out.push("\x1b[2A");
|
|
@@ -756,12 +1103,13 @@ export class Body {
|
|
|
756
1103
|
out.push("\x1b[2B");
|
|
757
1104
|
for (let i = 0; i < committed.length; i += 1)
|
|
758
1105
|
out.push("\n");
|
|
759
|
-
// the bottom-up repaint, from the last row up — V6-3: the
|
|
760
|
-
// §03 chrome: status (H),
|
|
1106
|
+
// the bottom-up repaint, from the last row up — V6-3 + W6: the
|
|
1107
|
+
// design §03 chrome: status (H), box bottom (H−1), input (H−2),
|
|
1108
|
+
// box top (H−3)
|
|
761
1109
|
out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`); // H — the status
|
|
762
|
-
out.push(`\x1b[1A\x1b[1G\x1b[0K${
|
|
1110
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
|
|
763
1111
|
out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 — the input
|
|
764
|
-
out.push(`\x1b[1A\x1b[1G\x1b[0K${
|
|
1112
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−3 — the box top
|
|
765
1113
|
for (let i = menuRows.length - 1; i >= 0; i -= 1) {
|
|
766
1114
|
out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
|
|
767
1115
|
}
|
|
@@ -804,7 +1152,7 @@ export class Body {
|
|
|
804
1152
|
// the down-distance from the LAST written row, in byte order: the
|
|
805
1153
|
// committed band's bottom, then the stale ELs' bottom, then the gap
|
|
806
1154
|
// ELs' bottom, then the live lines' bottom, then the menu's top
|
|
807
|
-
// (its last marched row), else the chrome's
|
|
1155
|
+
// (its last marched row), else the chrome's box top.
|
|
808
1156
|
const lastRow = committed.length > 0
|
|
809
1157
|
? Math.max(1, liveTop - 1)
|
|
810
1158
|
: staleFrom < liveTop
|
package/dist/editor.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export declare class Editor {
|
|
|
41
41
|
onSigint(cb: () => void): void;
|
|
42
42
|
onEot(cb: () => void): void;
|
|
43
43
|
onEscape(cb: () => void): void;
|
|
44
|
+
onExpand(cb: () => void): void;
|
|
44
45
|
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
45
46
|
line(): string;
|
|
46
47
|
clearLine(): void;
|
package/dist/editor.js
CHANGED
|
@@ -56,6 +56,11 @@ export class Editor {
|
|
|
56
56
|
// (dispatch) coexist; a listener removes itself via an unarmed guard
|
|
57
57
|
// (the compact's handler no-ops after its abort has fired).
|
|
58
58
|
#escapeCbs = [];
|
|
59
|
+
// W15: the expand-key list (ctrl+r) — the CLI's dispatch decides the
|
|
60
|
+
// target (a live cell toggles in place; a committed cell appends the
|
|
61
|
+
// expanded block). Mirrors the escape list: multiple listeners can
|
|
62
|
+
// coexist; the editor never interprets the key itself.
|
|
63
|
+
#expandCbs = [];
|
|
59
64
|
#onRender;
|
|
60
65
|
#menuOpen = false; // v3 §04: the slash-command menu
|
|
61
66
|
#menuSel = 0;
|
|
@@ -95,6 +100,9 @@ export class Editor {
|
|
|
95
100
|
onEscape(cb) {
|
|
96
101
|
this.#escapeCbs.push(cb);
|
|
97
102
|
}
|
|
103
|
+
onExpand(cb) {
|
|
104
|
+
this.#expandCbs.push(cb);
|
|
105
|
+
}
|
|
98
106
|
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
99
107
|
line() {
|
|
100
108
|
return String.fromCodePoint(...this.#chars);
|
|
@@ -272,6 +280,13 @@ export class Editor {
|
|
|
272
280
|
}
|
|
273
281
|
i += 1;
|
|
274
282
|
}
|
|
283
|
+
else if (c === "\x12") {
|
|
284
|
+
// W15: the expand key (ctrl+r) — rides the chain like a
|
|
285
|
+
// command, the editor just forwards it.
|
|
286
|
+
for (const cb of [...this.#expandCbs])
|
|
287
|
+
cb();
|
|
288
|
+
i += 1;
|
|
289
|
+
}
|
|
275
290
|
else if (c !== undefined && c < " ") {
|
|
276
291
|
i += 1; // other control — ignored
|
|
277
292
|
}
|
|
@@ -455,7 +470,7 @@ export class Editor {
|
|
|
455
470
|
// ---- width-based horizontal scroll ----
|
|
456
471
|
#reflow() {
|
|
457
472
|
const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
|
|
458
|
-
const maxW = Math.max(1, W - PROMPT_WIDTH -
|
|
473
|
+
const maxW = Math.max(1, W - PROMPT_WIDTH - 4); // W6: the box's walls (2+2) — the visible line fits the box's inner width; the "…" rides inside
|
|
459
474
|
const curCol = widthOf(this.#chars.slice(0, this.#cursor));
|
|
460
475
|
const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
|
|
461
476
|
if (curCol < scrolledW) {
|
package/dist/render.d.ts
CHANGED
|
@@ -177,7 +177,12 @@ export declare function renderEvent(ev: RenderInput, prevThinking?: boolean, res
|
|
|
177
177
|
export declare function renderToolSummary(name: string, input: Record<string, unknown>, result: {
|
|
178
178
|
content: string;
|
|
179
179
|
isError: boolean;
|
|
180
|
-
}): string;
|
|
180
|
+
}, reason?: string | null): string;
|
|
181
|
+
/** W15 — the expand header's target: the tool call's subject (the path
|
|
182
|
+
* for the *_file tools, the command for shell) — the same extraction
|
|
183
|
+
* the summary detail uses, WITHOUT the counts (the header names what
|
|
184
|
+
* was expanded, not its size). */
|
|
185
|
+
export declare function toolTarget(name: string, input: Record<string, unknown>): string;
|
|
181
186
|
/** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
|
|
182
187
|
export declare function kUnit(value: number | null): string;
|
|
183
188
|
/** B area: usage data gathered from the run's usage events. */
|
|
@@ -242,6 +247,11 @@ export interface RecapStats {
|
|
|
242
247
|
readonly edits: number;
|
|
243
248
|
readonly usage: RunUsage;
|
|
244
249
|
readonly ctxLeftPct: number | null;
|
|
250
|
+
/** W19 — the mode the turn ran under. Under "plan" the recap becomes
|
|
251
|
+
* the way-forward row (the claimed shape): a plan turn's currency is
|
|
252
|
+
* the plan, not the tool count — the timing and tool-count parts
|
|
253
|
+
* drop, and the two /mode hints replace them. */
|
|
254
|
+
readonly mode?: string;
|
|
245
255
|
}
|
|
246
256
|
export declare function renderRecap(s: RecapStats): string;
|
|
247
257
|
/** One-line summary of a session, for `kiso sessions`. */
|
package/dist/render.js
CHANGED
|
@@ -189,9 +189,16 @@ function approvalDetail(name, input, resolvePath) {
|
|
|
189
189
|
* edit/write show +/- line counts, read shows lines, shell shows the exit
|
|
190
190
|
* code; failures (isError) are ✗. Pure and deterministic.
|
|
191
191
|
*/
|
|
192
|
-
export function renderToolSummary(name, input, result) {
|
|
192
|
+
export function renderToolSummary(name, input, result, reason = null) {
|
|
193
193
|
// v2a/v5: ✓ is a bold identity accent; ✗ stays red.
|
|
194
194
|
const p = palette();
|
|
195
|
+
// W19: a DENIED call (the "denied" tag) renders the pinned row — the
|
|
196
|
+
// FULL call name, the target, the reason in the W4 parentheses idiom,
|
|
197
|
+
// and NO timing metadata (the call never ran — (0.0s) would be noise).
|
|
198
|
+
// The same row in the interactive and pipe paths, byte-clean on a pipe.
|
|
199
|
+
if (reason !== null) {
|
|
200
|
+
return `${p.red}✗${p.reset} ${escapeTerminal(`${name} ${toolTarget(name, input)} (${reason})`)}`;
|
|
201
|
+
}
|
|
195
202
|
const mark = result.isError ? `${p.red}✗${p.reset}` : `${p.bold}✓${p.reset}`;
|
|
196
203
|
const shortName = name.replace("_file", "");
|
|
197
204
|
const detail = toolSummaryDetail(name, input, result);
|
|
@@ -233,6 +240,24 @@ function toolSummaryDetail(name, input, result) {
|
|
|
233
240
|
return String(input.path ?? input.command ?? "");
|
|
234
241
|
}
|
|
235
242
|
}
|
|
243
|
+
/** W15 — the expand header's target: the tool call's subject (the path
|
|
244
|
+
* for the *_file tools, the command for shell) — the same extraction
|
|
245
|
+
* the summary detail uses, WITHOUT the counts (the header names what
|
|
246
|
+
* was expanded, not its size). */
|
|
247
|
+
export function toolTarget(name, input) {
|
|
248
|
+
switch (name) {
|
|
249
|
+
case "read_file":
|
|
250
|
+
case "write_file":
|
|
251
|
+
case "edit_file":
|
|
252
|
+
return String(input.path ?? "?");
|
|
253
|
+
case "shell":
|
|
254
|
+
return String(input.command ?? "?");
|
|
255
|
+
case "list_dir":
|
|
256
|
+
return String(input.path ?? "(root)");
|
|
257
|
+
default:
|
|
258
|
+
return String(input.path ?? input.command ?? "");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
236
261
|
/** The exit code of a shell result: parsed from the failure text, 0 on success. */
|
|
237
262
|
function exitCodeOf(result) {
|
|
238
263
|
if (!result.isError)
|
|
@@ -373,6 +398,15 @@ export function bannerLines(W, H, version, extensionsText, resume = [], now = Da
|
|
|
373
398
|
}
|
|
374
399
|
export function renderRecap(s) {
|
|
375
400
|
const p = palette();
|
|
401
|
+
// W19: under plan the recap is the way out of the mode — the header
|
|
402
|
+
// names the mode's posture, the hints name the exits (the ONLY
|
|
403
|
+
// controls — /mode is the only way to leave plan mode).
|
|
404
|
+
if (s.mode === "plan") {
|
|
405
|
+
const parts = ["plan ready", "/mode default executes", "/mode accept-edits auto-approves edits"];
|
|
406
|
+
if (s.ctxLeftPct !== null)
|
|
407
|
+
parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
|
|
408
|
+
return `${p.bold}▞${p.reset} ${parts.join(" · ")}\n`;
|
|
409
|
+
}
|
|
376
410
|
const parts = [`${s.seconds}s`, `${s.tools} tool${s.tools === 1 ? "" : "s"}${s.edits > 0 ? ` (${s.edits} edit${s.edits === 1 ? "" : "s"})` : ""}`];
|
|
377
411
|
if (s.usage.known) {
|
|
378
412
|
const seg = `${s.usage.in !== null ? `in ${kUnit(s.usage.in)}` : ""}${s.usage.in !== null && s.usage.out !== null ? " " : ""}${s.usage.out !== null ? `out ${kUnit(s.usage.out)}` : ""}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
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",
|