@vincemakes/kiso-tui 0.1.37 → 0.1.39
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 +240 -48
- 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 +14 -3
- 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 "task" 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 task 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_TASK_LIVE = 6;
|
|
200
|
+
/** W20 — the settled block's duration, the `2h 14m` form (the task
|
|
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
|
/**
|
|
@@ -167,19 +167,20 @@ export function cellComponent(cell) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
/**
|
|
170
|
-
* The user message — the
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
170
|
+
* The user message — the W16 inset chip ALONE (the 2026-08-09 ruling:
|
|
171
|
+
* the ▍ rail and the indent are retired — the rail's stated pipe
|
|
172
|
+
* fallback was theoretical redundancy: the CLI's pipe path is the
|
|
173
|
+
* line-mode "you>" form and never renders UserMessage). The chip folds
|
|
174
|
+
* the text at W−2 (the side pads), then pads EVERY row to the longest
|
|
175
|
+
* row's DISPLAY width + one space each side, flush left: the block is
|
|
176
|
+
* only as wide as what was said (never the full-width band — a short
|
|
177
|
+
* message like /think would paint a bar across the terminal). The
|
|
178
|
+
* padding is by cells (charWidth is the width authority), so a CJK row
|
|
179
|
+
* pads by width, never by chars, and the chip never overruns its fold.
|
|
180
|
+
* SGR 7 closed with SGR 27 — never SGR 0, the chip composes with a
|
|
181
|
+
* surrounding span — and NEVER dim: reverse video inverts the CURRENT
|
|
182
|
+
* colours, so dimmed text would invert into a dimmed block with no
|
|
183
|
+
* contrast.
|
|
183
184
|
*/
|
|
184
185
|
class UserMessage {
|
|
185
186
|
cell;
|
|
@@ -188,18 +189,17 @@ class UserMessage {
|
|
|
188
189
|
}
|
|
189
190
|
render(W, _ctx) {
|
|
190
191
|
const p = palette();
|
|
191
|
-
const
|
|
192
|
-
const chipW = Math.max(1, W - 6);
|
|
192
|
+
const chipW = Math.max(1, W - 2);
|
|
193
193
|
const rows = [];
|
|
194
194
|
for (const para of this.cell.text.split("\n")) {
|
|
195
195
|
const folded = foldLine(escapeTerminal(para), chipW);
|
|
196
196
|
const inner = Math.max(...folded.map((r) => displayWidth(r)));
|
|
197
197
|
for (const row of folded) {
|
|
198
198
|
const pad = inner - displayWidth(row);
|
|
199
|
-
rows.push(`${
|
|
199
|
+
rows.push(`${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
-
return rows
|
|
202
|
+
return rows;
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
/** The thinking fold — one dim line, width-capped so the /think suffix
|
|
@@ -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 task 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_TASK_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 task
|
|
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 task 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 "task" 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 `task 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
|
+
? `task done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
|
|
794
|
+
: `task · ${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_TASK_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 task 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 task 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;
|