@vincemakes/kiso-tui-cells 0.1.41
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/approval-panel.d.ts +109 -0
- package/dist/approval-panel.js +144 -0
- package/dist/components.d.ts +298 -0
- package/dist/components.js +979 -0
- package/dist/diff.d.ts +32 -0
- package/dist/diff.js +122 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +21 -0
- package/dist/render.d.ts +129 -0
- package/dist/render.js +295 -0
- package/dist/width.d.ts +21 -0
- package/dist/width.js +67 -0
- package/package.json +57 -0
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI v6 (ADR-0046) — the components: EVERY screen line's renderer.
|
|
3
|
+
* Extracted to tui-cells (ADR-0043 Amendment 4): the cell renderer
|
|
4
|
+
* leaves the tui for the 9th package; the tui's shims re-export it.
|
|
5
|
+
*
|
|
6
|
+
* Each component turns one piece of state into display lines (SGR
|
|
7
|
+
* included, raw — the compositor writes them verbatim). The folding
|
|
8
|
+
* lives HERE: every line a component returns must fit the terminal
|
|
9
|
+
* width — the compositor's crash-on-violation invariant backs it up
|
|
10
|
+
* (a component that forgets to fold CRASHES with a diagnostic, never
|
|
11
|
+
* silently truncates — the crash is the contract, not a symptom).
|
|
12
|
+
*
|
|
13
|
+
* The fold is SGR-AWARE: a line whose bold/dim span would straddle a
|
|
14
|
+
* fold boundary closes the span at the break and reopens it on the
|
|
15
|
+
* next row — the #16b contract (no literal "[2m" fragments) survives
|
|
16
|
+
* folding. displayWidth/charWidth (width.ts) are the width primitives
|
|
17
|
+
* (untouched); render.ts supplies the original text (palette, escape,
|
|
18
|
+
* tint, fold wording).
|
|
19
|
+
*/
|
|
20
|
+
import { displayWidth } from "./width.js";
|
|
21
|
+
import { bannerLines, escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
|
|
22
|
+
/** The spinner glyphs, cycled by the compositor's on-demand tick. */
|
|
23
|
+
export const SPINNER = ["▖", "▘", "▝", "▗"];
|
|
24
|
+
/**
|
|
25
|
+
* The fold — split a display-width line into ≤W rows, preserving SGR
|
|
26
|
+
* spans across the break: a span open at the break closes (reset) at
|
|
27
|
+
* the row's end and reopens on the next row. The rows are what the
|
|
28
|
+
* terminal's own soft-wrap would have produced — except the compositor
|
|
29
|
+
* folds FIRST, so the terminal never reflows a component's line (the
|
|
30
|
+
* #17 merge class cannot reach committed content).
|
|
31
|
+
*/
|
|
32
|
+
export function foldLine(line, W) {
|
|
33
|
+
if (W < 1)
|
|
34
|
+
return [line];
|
|
35
|
+
// collect the plain text + the SGR segments so the walk can track
|
|
36
|
+
// the open span state
|
|
37
|
+
const out = [];
|
|
38
|
+
let current = "";
|
|
39
|
+
let width = 0;
|
|
40
|
+
let open = []; // the SGR sequences seen since the last reset
|
|
41
|
+
for (let i = 0; i < line.length;) {
|
|
42
|
+
if (line[i] === "\n") {
|
|
43
|
+
// a real line break — the row ends here (the same close/reopen
|
|
44
|
+
// as a fold boundary, so a span never leaks across the break)
|
|
45
|
+
const close = open.length > 0 ? "\x1b[0m" : "";
|
|
46
|
+
out.push(current + close);
|
|
47
|
+
current = open.join("");
|
|
48
|
+
width = 0;
|
|
49
|
+
i += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (line[i] === "\x1b") {
|
|
53
|
+
const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
|
|
54
|
+
if (m !== null) {
|
|
55
|
+
if (m[0] === "\x1b[0m")
|
|
56
|
+
open = [];
|
|
57
|
+
else
|
|
58
|
+
open.push(m[0]);
|
|
59
|
+
current += m[0];
|
|
60
|
+
i += m[0].length;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// a non-SGR CSI (the raw cell's own content, escaped at
|
|
64
|
+
// composition) — copy verbatim, zero width
|
|
65
|
+
const csi = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(line.slice(i));
|
|
66
|
+
if (csi !== null) {
|
|
67
|
+
current += csi[0];
|
|
68
|
+
i += csi[0].length;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
current += line[i];
|
|
72
|
+
i += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const cw = displayWidth(line[i]);
|
|
76
|
+
if (width + cw > W && width > 0) {
|
|
77
|
+
// the fold — close the open spans, push, reopen on the next row
|
|
78
|
+
const close = open.length > 0 ? "\x1b[0m" : "";
|
|
79
|
+
out.push(current + close);
|
|
80
|
+
current = open.join("");
|
|
81
|
+
width = 0;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
current += line[i];
|
|
85
|
+
width += cw;
|
|
86
|
+
i += 1;
|
|
87
|
+
}
|
|
88
|
+
if (current !== "" || out.length === 0)
|
|
89
|
+
out.push(current);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** The visible width of a rendered line (SGR stripped — the invariant
|
|
93
|
+
* the compositor enforces on every emitted line). */
|
|
94
|
+
export function visibleWidth(line) {
|
|
95
|
+
let w = 0;
|
|
96
|
+
for (let i = 0; i < line.length;) {
|
|
97
|
+
if (line[i] === "\x1b") {
|
|
98
|
+
const m = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(line.slice(i));
|
|
99
|
+
if (m !== null) {
|
|
100
|
+
i += m[0].length;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
w += displayWidth(line[i]);
|
|
107
|
+
i += 1;
|
|
108
|
+
}
|
|
109
|
+
return w;
|
|
110
|
+
}
|
|
111
|
+
/** The W11 spacing formula — "a row gets one blank line above it when
|
|
112
|
+
* the row is itself a block, or when the previous sibling was taller
|
|
113
|
+
* than one row". One-row siblings pack tight; anything multi-row
|
|
114
|
+
* breathes on both sides. The FIRST cell never gets the blank (it sits
|
|
115
|
+
* at the body's top — the banner would otherwise start one row down).
|
|
116
|
+
* `prev` is the previous sibling's OWN rows (raw — a cell's own blank
|
|
117
|
+
* must never count toward its height). The blank is a JOIN artifact:
|
|
118
|
+
* the cell's own render stays blank-free, so per-cell accounting
|
|
119
|
+
* (heights, the fold cache) never sees a fake row. */
|
|
120
|
+
export function bodySpacing(prev, rows) {
|
|
121
|
+
if (rows.length === 0 || prev === null || prev.length === 0)
|
|
122
|
+
return rows;
|
|
123
|
+
if (rows.length > 1 || prev.length > 1)
|
|
124
|
+
return ["", ...rows];
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
127
|
+
/** The container — vertical concatenation with the W11 formula. No
|
|
128
|
+
* component decides its own spacing: every blank in the body is the
|
|
129
|
+
* container's. */
|
|
130
|
+
export class Container {
|
|
131
|
+
children;
|
|
132
|
+
constructor(children) {
|
|
133
|
+
this.children = children;
|
|
134
|
+
}
|
|
135
|
+
render(width, ctx) {
|
|
136
|
+
const out = [];
|
|
137
|
+
let prev = null;
|
|
138
|
+
for (const c of this.children) {
|
|
139
|
+
const rows = c.render(width, ctx);
|
|
140
|
+
out.push(...bodySpacing(prev, rows));
|
|
141
|
+
prev = rows;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
|
|
147
|
+
/** The component for one cell — the mapping table lives here so the
|
|
148
|
+
* compositor stays a pure writer. */
|
|
149
|
+
export function cellComponent(cell) {
|
|
150
|
+
switch (cell.kind) {
|
|
151
|
+
case "user":
|
|
152
|
+
return new UserMessage(cell);
|
|
153
|
+
case "thinking":
|
|
154
|
+
return new ThinkingFold(cell);
|
|
155
|
+
case "tool":
|
|
156
|
+
return new ToolExecution(cell);
|
|
157
|
+
case "text":
|
|
158
|
+
return new AssistantMessage(cell);
|
|
159
|
+
case "notice":
|
|
160
|
+
return new ErrorLine(cell);
|
|
161
|
+
case "banner":
|
|
162
|
+
return new Banner(cell);
|
|
163
|
+
case "raw":
|
|
164
|
+
return new RawBlock(cell);
|
|
165
|
+
case "terminal":
|
|
166
|
+
return new TerminalBlock(cell);
|
|
167
|
+
case "checklist":
|
|
168
|
+
return new Checklist(cell);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The user message — the W16 inset chip ALONE (the 2026-08-09 ruling:
|
|
173
|
+
* the ▍ rail and the indent are retired — the rail's stated pipe
|
|
174
|
+
* fallback was theoretical redundancy: the CLI's pipe path is the
|
|
175
|
+
* line-mode "you>" form and never renders UserMessage). The chip folds
|
|
176
|
+
* the text at W−2 (the side pads), then pads EVERY row to the longest
|
|
177
|
+
* row's DISPLAY width + one space each side, flush left: the block is
|
|
178
|
+
* only as wide as what was said (never the full-width band — a short
|
|
179
|
+
* message like /think would paint a bar across the terminal). The
|
|
180
|
+
* padding is by cells (charWidth is the width authority), so a CJK row
|
|
181
|
+
* pads by width, never by chars, and the chip never overruns its fold.
|
|
182
|
+
* SGR 7 closed with SGR 27 — never SGR 0, the chip composes with a
|
|
183
|
+
* surrounding span — and NEVER dim: reverse video inverts the CURRENT
|
|
184
|
+
* colours, so dimmed text would invert into a dimmed block with no
|
|
185
|
+
* contrast.
|
|
186
|
+
*/
|
|
187
|
+
class UserMessage {
|
|
188
|
+
cell;
|
|
189
|
+
constructor(cell) {
|
|
190
|
+
this.cell = cell;
|
|
191
|
+
}
|
|
192
|
+
render(W, _ctx) {
|
|
193
|
+
const p = palette();
|
|
194
|
+
const chipW = Math.max(1, W - 2);
|
|
195
|
+
const rows = [];
|
|
196
|
+
for (const para of this.cell.text.split("\n")) {
|
|
197
|
+
const folded = foldLine(escapeTerminal(para), chipW);
|
|
198
|
+
const inner = Math.max(...folded.map((r) => displayWidth(r)));
|
|
199
|
+
for (const row of folded) {
|
|
200
|
+
const pad = inner - displayWidth(row);
|
|
201
|
+
rows.push(`${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return rows;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** W22: the pending-queue chips — queued user lines pre-render above
|
|
208
|
+
* the input row as the SAME UserMessage chip (undimmed: reverse video
|
|
209
|
+
* inverts the CURRENT colours), the dim `□` gutter marking the queued
|
|
210
|
+
* state (the gutter rides EVERY row — the gutterFold precedent: the
|
|
211
|
+
* left edge alone distinguishes the states). Each chip folds at W−3
|
|
212
|
+
* (the gutter's 2 cells), so a long line hard-folds INSIDE the chip
|
|
213
|
+
* and invariant ① holds on the band. */
|
|
214
|
+
export function pendingQueueRows(lines, W) {
|
|
215
|
+
const p = palette();
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
for (const row of new UserMessage({ text: line }).render(Math.max(1, W - 3), { spinnerI: 0, now: 0, height: 0 })) {
|
|
219
|
+
out.push(`${p.dim}□${p.reset} ${row}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/** The thinking fold — one dim line, width-capped so the /think suffix
|
|
225
|
+
* rides the fold's own row (the #17 fix's slice, componentized). The
|
|
226
|
+
* slice is DISPLAY-WIDTH-based (the char-based slice overflowed with
|
|
227
|
+
* CJK — 2 cells per char — and tripped invariant ① on a real
|
|
228
|
+
* Chinese session). W2: the leading ⋯ is the thinking gutter — the
|
|
229
|
+
* midline mark (the state), never the text ellipsis (the truncation). */
|
|
230
|
+
class ThinkingFold {
|
|
231
|
+
cell;
|
|
232
|
+
constructor(cell) {
|
|
233
|
+
this.cell = cell;
|
|
234
|
+
}
|
|
235
|
+
render(W, _ctx) {
|
|
236
|
+
const block = this.cell.text;
|
|
237
|
+
const trimmed = escapeTerminal(block.trim());
|
|
238
|
+
// the SHORT branch is width-aware TOO: the ≤100 short-circuit was
|
|
239
|
+
// W-blind — a short block at a narrow width returned the line
|
|
240
|
+
// UNFOLDED and tripped invariant ① (the crash class still live on
|
|
241
|
+
// npm for short /think blocks after a resize).
|
|
242
|
+
if (trimmed.length <= 100)
|
|
243
|
+
return [`${palette().dim}⋯${widthCut(trimmed, Math.max(1, W - 1))}${palette().reset}`];
|
|
244
|
+
const suffix = ` (${block.length} chars · /think)`;
|
|
245
|
+
const slice = Math.max(1, W - 1 - suffix.length);
|
|
246
|
+
return [`${palette().dim}⋯${widthCut(trimmed, slice)}${suffix}${palette().reset}`];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
|
|
250
|
+
* (W2: a wrapped tool row keeps its state mark — the left edge alone
|
|
251
|
+
* distinguishes the states at --plain; the UserMessage rail precedent,
|
|
252
|
+
* v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). W21:
|
|
253
|
+
* exported for the approval panel's text args (the same │ gutter). */
|
|
254
|
+
export function gutterFold(gutter, line, W) {
|
|
255
|
+
const textW = Math.max(1, W - 2);
|
|
256
|
+
return foldLine(line, textW).map((r) => `${gutter}${r}`);
|
|
257
|
+
}
|
|
258
|
+
/** A6: the tool-header variant — ONE cut row, never a fold. A wide
|
|
259
|
+
* header (a long target path, a wordy denial reason) used to wrap
|
|
260
|
+
* through foldLine — every wrapped row repeated the gutter, the
|
|
261
|
+
* settled row grew past its previewed height. The header names the
|
|
262
|
+
* call — the ellipsis marks the cut, the body below still carries the
|
|
263
|
+
* full content. The budget: the gutter's own visible width + the
|
|
264
|
+
* ellipsis ride the row (the invariant ① cap holds). */
|
|
265
|
+
export function gutterCut(gutter, line, W) {
|
|
266
|
+
const gutterW = visibleWidth(gutter);
|
|
267
|
+
const textW = Math.max(1, W - gutterW - 1);
|
|
268
|
+
const cut = widthCut(line, textW);
|
|
269
|
+
return [`${gutter}${cut}${visibleWidth(line) > textW ? "…" : ""}`];
|
|
270
|
+
}
|
|
271
|
+
/** Lines without the phantom empty line after a trailing newline. */
|
|
272
|
+
function countLines(text) {
|
|
273
|
+
if (text === "")
|
|
274
|
+
return 0;
|
|
275
|
+
const parts = text.split("\n");
|
|
276
|
+
return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
|
|
277
|
+
}
|
|
278
|
+
/** W4: the settled-row metadata — the human summary in parentheses. The
|
|
279
|
+
* separation NEVER relies on dim: a pipe drops the SGR, and the shapes
|
|
280
|
+
* below read at full strength with the palette off. read → the line
|
|
281
|
+
* count ("912 lines"; "200 of 3412 lines" when the tool cut it — the
|
|
282
|
+
* note names the remainder; ≥1000 k-formats, "2.4k lines"); write/edit
|
|
283
|
+
* → the ± diff stats (the approval diff's counts — an auto-allowed
|
|
284
|
+
* write never computed one, so the input's own counts fall back, then
|
|
285
|
+
* the result's line count); shell → the exit code (parsed from the
|
|
286
|
+
* failure text — the tool names it — 0 on success); a non-shell error
|
|
287
|
+
* → the error text's first line; anything else → the result's line
|
|
288
|
+
* count. */
|
|
289
|
+
function settledMeta(c) {
|
|
290
|
+
if (c.isError) {
|
|
291
|
+
// a shell EXECUTION failure names its code first ("exit 1: …") —
|
|
292
|
+
// that IS the metadata, and the body shows the full text. A
|
|
293
|
+
// shell without the code (a denial, a precondition) is not an
|
|
294
|
+
// exit failure: the first line stays the metadata, exactly like
|
|
295
|
+
// any other error.
|
|
296
|
+
if (c.name === "shell" && /^exit \d+/.test(c.resultText))
|
|
297
|
+
return `exit ${/^exit (\d+)/.exec(c.resultText)[1]}`;
|
|
298
|
+
return c.resultText.split("\n")[0].slice(0, 60);
|
|
299
|
+
}
|
|
300
|
+
if (c.name === "read_file") {
|
|
301
|
+
const noteAt = c.resultText.lastIndexOf("\n… ");
|
|
302
|
+
const shown = countLines(noteAt >= 0 ? c.resultText.slice(0, noteAt) : c.resultText);
|
|
303
|
+
const more = noteAt >= 0 ? /(\d+) more lines?/.exec(c.resultText.slice(noteAt)) : null;
|
|
304
|
+
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n));
|
|
305
|
+
if (more !== null) {
|
|
306
|
+
const total = shown + Number(more[1]);
|
|
307
|
+
return `${shown} of ${total} line${total === 1 ? "" : "s"}`;
|
|
308
|
+
}
|
|
309
|
+
return `${k(shown)} line${shown === 1 ? "" : "s"}`;
|
|
310
|
+
}
|
|
311
|
+
if (c.name === "write_file" || c.name === "edit_file") {
|
|
312
|
+
if (c.added + c.removed > 0)
|
|
313
|
+
return `+${c.added} -${c.removed}`;
|
|
314
|
+
// no approval diff (an auto-allowed write): the input summary may
|
|
315
|
+
// be sliced at TOOL_SUMMARY_MAX — best-effort, then the last resort
|
|
316
|
+
let parsed = null;
|
|
317
|
+
try {
|
|
318
|
+
parsed = JSON.parse(c.input);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
parsed = null;
|
|
322
|
+
}
|
|
323
|
+
if (c.name === "write_file" && parsed !== null && typeof parsed.content === "string") {
|
|
324
|
+
return `+${countLines(parsed.content)}`;
|
|
325
|
+
}
|
|
326
|
+
if (c.name === "edit_file" && parsed !== null && typeof parsed.search === "string" && typeof parsed.replace === "string") {
|
|
327
|
+
const added = countLines(parsed.replace);
|
|
328
|
+
const removed = countLines(parsed.search);
|
|
329
|
+
if (added + removed > 0)
|
|
330
|
+
return `+${added} -${removed}`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (c.name === "shell")
|
|
334
|
+
return "exit 0";
|
|
335
|
+
const n = countLines(c.resultText);
|
|
336
|
+
return `${n} line${n === 1 ? "" : "s"}`;
|
|
337
|
+
}
|
|
338
|
+
/** The parsed tool target — the head-row form shared by the W19 pinned
|
|
339
|
+
* row (full call name + target) and the A4 settled row (verb + target):
|
|
340
|
+
* read/write/edit → the path, shell → the command, list_dir → path ??
|
|
341
|
+
* "(root)". Parsed from the FULL input — the folded summary is a
|
|
342
|
+
* truncated slice. */
|
|
343
|
+
function toolTargetOf(c) {
|
|
344
|
+
let input = {};
|
|
345
|
+
try {
|
|
346
|
+
input = JSON.parse(c.inputFull);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// the full JSON is always parseable (stringified at toolStart)
|
|
350
|
+
// — the empty fallback never fires
|
|
351
|
+
}
|
|
352
|
+
return toolTarget(c.name, input);
|
|
353
|
+
}
|
|
354
|
+
/** The tool execution line + the bounded block — every state is its
|
|
355
|
+
* own render; the lines fold (the summary gives way first). W7 (the
|
|
356
|
+
* flow contract): the block's BODY (the rows below the header) is
|
|
357
|
+
* capped in SCREEN rows AFTER the fold, at the current width — the
|
|
358
|
+
* renderer-cut row (`└ +N … · ctrl+r`) sits INSIDE the cap (a
|
|
359
|
+
* truncated block is cap−1 output rows + the cut row); the TOOL-cut
|
|
360
|
+
* row (`└ capped by …` — the tool's OWN truncation note, W10) is a
|
|
361
|
+
* DIFFERENT fact, never counted in the output cap. W3: the verb is
|
|
362
|
+
* stripped of its "_file" suffix and padded to 5 columns — the target
|
|
363
|
+
* paths line up (the pipe path strips the same suffix, render.ts —
|
|
364
|
+
* both paths print the same verb; a verb ≥ 5 columns is not padded).
|
|
365
|
+
* The block's cut note keeps the RAW name (it names the tool the
|
|
366
|
+
* model should call again). W4: the settled row's parentheses hold
|
|
367
|
+
* the human metadata (settledMeta) — the input summary lived in the
|
|
368
|
+
* running row; the OUTCOME is what the settled row says. A4: the
|
|
369
|
+
* settled row keeps the TARGET — verb + target + outcome, the running
|
|
370
|
+
* row's summary column (the W19 pinned row keeps the full call name
|
|
371
|
+
* instead). A5: the verdict rides the head row — a decidedBy present
|
|
372
|
+
* on the cell appends `· approved by X` (extension auto-approvals) or
|
|
373
|
+
* `· by X` on the pinned deny; the human decision needs no marker. */
|
|
374
|
+
class ToolExecution {
|
|
375
|
+
cell;
|
|
376
|
+
constructor(cell) {
|
|
377
|
+
this.cell = cell;
|
|
378
|
+
}
|
|
379
|
+
render(W, ctx) {
|
|
380
|
+
const p = palette();
|
|
381
|
+
const c = this.cell;
|
|
382
|
+
const verb = escapeTerminal(c.name.replace("_file", ""));
|
|
383
|
+
const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
|
|
384
|
+
const summary = escapeTerminal(c.input);
|
|
385
|
+
if (c.rolled !== null) {
|
|
386
|
+
// W13 — the rolled-up group's ONE row + the target children:
|
|
387
|
+
// the work order's claimed shape, verbatim — the verbCol's
|
|
388
|
+
// 5-char pad reproduces the "read 5 files" double space, the
|
|
389
|
+
// children are the first 3 basename targets, the overflow row
|
|
390
|
+
// carries the ctrl+r affordance (its "└ … ctrl+r" joins the
|
|
391
|
+
// W15 expand history — the head's commit captures it).
|
|
392
|
+
const r = c.rolled;
|
|
393
|
+
const noun = ROLLUP_NOUN[c.name] ?? "calls";
|
|
394
|
+
const out = gutterCut(`${p.bold}✓${p.reset} `, `${verbCol} ${r.count} ${noun} (${kUnit(r.lines)} lines, ${r.elapsed}s)`, W);
|
|
395
|
+
const shown = r.targets.slice(0, 3);
|
|
396
|
+
if (shown.length > 0)
|
|
397
|
+
out.push(` ${p.dim}${CUT_ROW}${escapeTerminal(shown.join(" · "))}${p.reset}`);
|
|
398
|
+
if (r.targets.length > 3)
|
|
399
|
+
out.push(` ${p.dim}${CUT_ROW}+${r.targets.length - 3} more — ctrl+r expands${p.reset}`);
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
if (c.state === "done") {
|
|
403
|
+
// W19: the pinned deny — the claimed shape verbatim: the FULL
|
|
404
|
+
// call name (the denial names the call), the target, the reason
|
|
405
|
+
// in the W4 parentheses idiom, no timing (the call never ran).
|
|
406
|
+
// The same ✗ family as any failure; the [result ✗] body still
|
|
407
|
+
// rides below (never hide information). A5: an extension's
|
|
408
|
+
// denial appends `· by <decidedBy>` — the aggregated head row
|
|
409
|
+
// names the decider; a human denial (no decidedBy) has no tail.
|
|
410
|
+
if (c.reason !== null) {
|
|
411
|
+
const by = c.verdict !== null && c.verdict.decidedBy !== undefined ? ` · by ${escapeTerminal(c.verdict.decidedBy)}` : "";
|
|
412
|
+
const out = gutterCut(`${p.red}✗${p.reset} `, `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
|
|
413
|
+
out.push(...toolBlockBody(c, W));
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
|
|
417
|
+
const meta = escapeTerminal(settledMeta(c));
|
|
418
|
+
// A4: the target rides the settled head row — the verb's
|
|
419
|
+
// summary column (W3's 5-char pad keeps the paths lined up).
|
|
420
|
+
// A5: an extension's auto-approval appends `· approved by
|
|
421
|
+
// <decidedBy>` — the "why wasn't I asked" answer; the human
|
|
422
|
+
// approval (no decidedBy) leaves the row unchanged.
|
|
423
|
+
const approvedBy = c.verdict !== null && c.verdict.decidedBy !== undefined ? ` · approved by ${escapeTerminal(c.verdict.decidedBy)}` : "";
|
|
424
|
+
const out = c.isError
|
|
425
|
+
? gutterCut(`${p.red}✗${p.reset} `, `${p.red}${verbCol} ${escapeTerminal(toolTargetOf(c))} (${meta}${approvedBy}, ${elapsed}s)${p.reset}`, W)
|
|
426
|
+
: gutterCut(`${p.bold}✓${p.reset} `, `${verbCol} ${escapeTerminal(toolTargetOf(c))} (${meta}${approvedBy}, ${elapsed}s)`, W);
|
|
427
|
+
out.push(...toolBlockBody(c, W));
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
if (c.state === "approval") {
|
|
431
|
+
// W2: the ⏸ is the GUTTER (the left edge), never the line's tail
|
|
432
|
+
const out = gutterCut(`${p.bold}⏸${p.reset} `, `${verbCol} ${summary}`, W);
|
|
433
|
+
out.push(...toolBlockBody(c, W));
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
if (c.state === "running") {
|
|
437
|
+
// W2: the spinner IS the gutter (the left edge); the elapsed
|
|
438
|
+
// rides the summary's tail
|
|
439
|
+
const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
|
|
440
|
+
const out = gutterCut(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${summary} ${elapsed}s`, W);
|
|
441
|
+
out.push(...toolBlockBody(c, W));
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
// W2: ◦ replaces → for QUEUED — · is the separator inside every
|
|
445
|
+
// metadata group; a queued marker that is also the separator
|
|
446
|
+
// glyph reads as noise
|
|
447
|
+
return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${summary}`, W);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** W13 — the rollup opt-in table: which tools collapse, and the count
|
|
451
|
+
* NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
|
|
452
|
+
* → "5 matches"). Only these tools opt in — a shell burst is never
|
|
453
|
+
* rolled up (its rows carry meaning). The folded-turn line (W14) reuses
|
|
454
|
+
* the plurals for its other-tool terms ("2 dirs", "1 match"). */
|
|
455
|
+
export const ROLLUP_NOUN = {
|
|
456
|
+
read_file: "files",
|
|
457
|
+
list_dir: "dirs",
|
|
458
|
+
search_text: "matches",
|
|
459
|
+
};
|
|
460
|
+
/** The count term with the singular/plural forms — "no reads", "1 read",
|
|
461
|
+
* "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
|
|
462
|
+
* "matches" → "match"). */
|
|
463
|
+
function countTerm(n, singular, plural) {
|
|
464
|
+
if (n === 0)
|
|
465
|
+
return `no ${plural}`;
|
|
466
|
+
if (n === 1)
|
|
467
|
+
return `1 ${singular}`;
|
|
468
|
+
return `${n} ${plural}`;
|
|
469
|
+
}
|
|
470
|
+
/** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
|
|
471
|
+
* scrollback, becomes ONE line — the work order's claimed shape
|
|
472
|
+
* (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
|
|
473
|
+
* toolStart: read_file → "reads", edit_file → "edits", the other tools
|
|
474
|
+
* as first-call-order terms (the ROLLUP_NOUN plurals when the tool opts
|
|
475
|
+
* in, the verb + "s" otherwise).
|
|
476
|
+
* A9 (ruling R2, mock A): the user chip rides the fold — the human's
|
|
477
|
+
* words LEAD the one line, `▞ <chip> · thought 19s · 5 reads · no
|
|
478
|
+
* edits` — the chip the SAME SGR-7 bracket as the live user row (#16f,
|
|
479
|
+
* side pads included). The words take the fold's width budget: the
|
|
480
|
+
* metadata terms survive (the W14 metadata rule — they give way LAST),
|
|
481
|
+
* the words width-cut at the end with the honest "…" (never a silent
|
|
482
|
+
* truncate — invariant ① holds on the ONE row by construction). */
|
|
483
|
+
export function turnFold(t, W) {
|
|
484
|
+
const p = palette();
|
|
485
|
+
const parts = [`thought ${t.thoughtSeconds}s`, countTerm(t.reads, "read", "reads"), countTerm(t.edits, "edit", "edits")];
|
|
486
|
+
for (const [name, n] of t.others) {
|
|
487
|
+
const noun = ROLLUP_NOUN[name];
|
|
488
|
+
if (noun !== undefined) {
|
|
489
|
+
parts.push(countTerm(n, noun.endsWith("es") ? noun.slice(0, -2) : noun.slice(0, -1), noun));
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
const verb = name.replace("_file", "");
|
|
493
|
+
parts.push(countTerm(n, verb, `${verb}s`));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const meta = parts.join(" · ");
|
|
497
|
+
const words = escapeTerminal(t.words);
|
|
498
|
+
if (words === "") {
|
|
499
|
+
const row = `${p.bold}▞${p.reset} ${meta}`;
|
|
500
|
+
return visibleWidth(row) <= W ? [row] : [`${p.bold}▞${p.reset} ${widthCut(meta, Math.max(1, W - 3))}…`]; // a wordless turn folds to the W14 shape
|
|
501
|
+
}
|
|
502
|
+
// A9 (ruling R2, mock A): the user chip rides the fold — the human's
|
|
503
|
+
// words LEAD the one line, the same SGR-7 bracket as the live user
|
|
504
|
+
// row (#16f, side pads included). The words take the fold's width
|
|
505
|
+
// budget: W − the gutter ("▞ " = 2) − the join (" · " = 3) − the
|
|
506
|
+
// chip's side pads (2) − the cut-tail reserve (1, the "…") − the
|
|
507
|
+
// metadata's own width — the metadata survives, the words width-cut
|
|
508
|
+
// at the end with the honest "…" (the "…" alone is the honest floor:
|
|
509
|
+
// the words were there, cut).
|
|
510
|
+
const budget = Math.max(0, W - visibleWidth(`▞ ${meta}`) - 6);
|
|
511
|
+
const cut = visibleWidth(words) > budget ? `${widthCut(words, budget)}…` : words;
|
|
512
|
+
const row = `${p.bold}▞${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${meta}`;
|
|
513
|
+
if (visibleWidth(row) <= W)
|
|
514
|
+
return [row];
|
|
515
|
+
// the last resort: the METADATA gives way — the words hold their
|
|
516
|
+
// budget, the meta cuts with the honest "…"; invariant ① never trips
|
|
517
|
+
// at ANY width (a degenerate W's fold is a cut, never a crash).
|
|
518
|
+
return [`${p.bold}▞${p.reset} ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(meta, Math.max(1, W - 8 - visibleWidth(cut)))}…`];
|
|
519
|
+
}
|
|
520
|
+
// ---- the bounded-block flow contract (W7, W8, W10) ----
|
|
521
|
+
/** The caps — screen rows counted AFTER the fold, at the current width
|
|
522
|
+
* (the W7 table). The renderer-cut row is inside the cap. */
|
|
523
|
+
const CAP_SHELL_SETTLED = 5; // the shell output tail, settled
|
|
524
|
+
const CAP_LIVE_WINDOW = 3; // the running tool's FIXED window (W8)
|
|
525
|
+
const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
|
|
526
|
+
const CAP_ERROR = 3; // the error text head
|
|
527
|
+
/** The block body rows' prefixes (W2's gutter table): │ a bounded
|
|
528
|
+
* block's body, └ the block's last row — what was cut, where the rest
|
|
529
|
+
* is — at the LEFT EDGE (the gutter column: the left edge alone
|
|
530
|
+
* distinguishes the states at --plain). Structural (constraint 1). */
|
|
531
|
+
const BODY_ROW = "│ ";
|
|
532
|
+
const CUT_ROW = "└ ";
|
|
533
|
+
const blockMemo = new WeakMap();
|
|
534
|
+
/** The block's body rows below the header (memoized, W9). */
|
|
535
|
+
function toolBlockBody(c, W) {
|
|
536
|
+
const memo = blockMemo.get(c);
|
|
537
|
+
const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}`;
|
|
538
|
+
const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
|
|
539
|
+
if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
|
|
540
|
+
return memo.rows;
|
|
541
|
+
const p = palette();
|
|
542
|
+
const rows = c.expanded
|
|
543
|
+
? // W15: the toggle's full form — the WHOLE body, no cap, no
|
|
544
|
+
// cut note (nothing is cut; the width fold still holds — the
|
|
545
|
+
// height may change while live, the user asked for it). The
|
|
546
|
+
// delegate has no body — its rows are unchanged.
|
|
547
|
+
c.state === "approval"
|
|
548
|
+
? diffBody(c.diff, W, true)
|
|
549
|
+
: c.name === "delegate"
|
|
550
|
+
? c.state === "running"
|
|
551
|
+
? delegateRunning(c, W)
|
|
552
|
+
: delegateSettled(c, W)
|
|
553
|
+
: blockRows(c.resultText, W)
|
|
554
|
+
: c.state === "done"
|
|
555
|
+
? c.isError
|
|
556
|
+
? errorBody(c, W)
|
|
557
|
+
: c.name === "delegate"
|
|
558
|
+
? delegateSettled(c, W)
|
|
559
|
+
: c.name.startsWith("shell")
|
|
560
|
+
? shellTail(c.resultText, W)
|
|
561
|
+
: []
|
|
562
|
+
: c.state === "running"
|
|
563
|
+
? c.name === "delegate"
|
|
564
|
+
? delegateRunning(c, W)
|
|
565
|
+
: liveWindow(c.resultText, W)
|
|
566
|
+
: c.state === "approval"
|
|
567
|
+
? diffBody(c.diff, W)
|
|
568
|
+
: [];
|
|
569
|
+
const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
|
|
570
|
+
if (note !== null)
|
|
571
|
+
rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
|
|
572
|
+
blockMemo.set(c, { width: W, state, content, rows });
|
|
573
|
+
return rows;
|
|
574
|
+
}
|
|
575
|
+
/** Fold result text into dim body rows (the BODY_ROW prefix): escape,
|
|
576
|
+
* split, fold each line at W−prefix; trailing empty rows (the result's
|
|
577
|
+
* final newline) drop. */
|
|
578
|
+
function blockRows(text, W) {
|
|
579
|
+
const p = palette();
|
|
580
|
+
const textW = Math.max(1, W - visibleWidth(BODY_ROW));
|
|
581
|
+
const rows = [];
|
|
582
|
+
for (const raw of escapeTerminal(text).split("\n")) {
|
|
583
|
+
for (const row of foldLine(raw, textW))
|
|
584
|
+
rows.push(`${p.dim}${BODY_ROW}${row}${p.reset}`);
|
|
585
|
+
}
|
|
586
|
+
while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(BODY_ROW))
|
|
587
|
+
rows.pop();
|
|
588
|
+
return rows;
|
|
589
|
+
}
|
|
590
|
+
/** The shell output tail, settled: the LAST rows, capped at 5 — the
|
|
591
|
+
* renderer cut at the block's bottom ("earlier rows" — the conclusion
|
|
592
|
+
* is at the end, the reference implementation's truncateToVisualLines
|
|
593
|
+
* direction). */
|
|
594
|
+
function shellTail(text, W) {
|
|
595
|
+
const p = palette();
|
|
596
|
+
const rows = blockRows(text, W);
|
|
597
|
+
if (rows.length <= CAP_SHELL_SETTLED)
|
|
598
|
+
return rows;
|
|
599
|
+
const kept = CAP_SHELL_SETTLED - 1;
|
|
600
|
+
const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - kept} earlier rows · ctrl+r${p.reset}`, W);
|
|
601
|
+
return [...rows.slice(rows.length - kept), ...cut];
|
|
602
|
+
}
|
|
603
|
+
/** The error text head: the FIRST rows, capped at 3 — the answer is at
|
|
604
|
+
* the start (opencode's collapseToolOutput direction). The header row
|
|
605
|
+
* already summarizes the first line, so the body starts at line 2. */
|
|
606
|
+
function errorBody(c, W) {
|
|
607
|
+
const p = palette();
|
|
608
|
+
// W4: a shell EXECUTION failure's line 0 ("exit 1: …") no longer
|
|
609
|
+
// rides the header — the parsed code does — so the body keeps the
|
|
610
|
+
// FULL text. Any other error keeps the pre-W4 split: line 0 is the
|
|
611
|
+
// header's metadata, the body shows the rest.
|
|
612
|
+
// W19: a DENIED call's header meta is the PARSED reason (from the
|
|
613
|
+
// denied tag), decoupled from the result text — the body keeps the
|
|
614
|
+
// FULL content including the "[Permission denied] " prefix (never
|
|
615
|
+
// hide information — the folded body rides the pinned row).
|
|
616
|
+
const skipFirst = c.name === "shell" && /^exit \d+/.test(c.resultText) ? 0 : c.reason !== null && c.reason !== undefined ? 0 : 1;
|
|
617
|
+
const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
|
|
618
|
+
if (rows.length <= CAP_ERROR)
|
|
619
|
+
return rows;
|
|
620
|
+
const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+r${p.reset}`, W);
|
|
621
|
+
return [...rows.slice(0, CAP_ERROR - 1), ...cut];
|
|
622
|
+
}
|
|
623
|
+
/** The running tool's FIXED-height window (W8): exactly 3 rows from
|
|
624
|
+
* the FIRST frame — blank-padded before output arrives, the renderer
|
|
625
|
+
* cut inside the window. The height changes exactly once, at settle —
|
|
626
|
+
* a cell that grows mid-list would shift every row after it on every
|
|
627
|
+
* delta (the parallel-tools jitter). */
|
|
628
|
+
function liveWindow(text, W) {
|
|
629
|
+
const p = palette();
|
|
630
|
+
if (text === "") {
|
|
631
|
+
return [`${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${BODY_ROW}${p.reset}`, `${p.dim}${CUT_ROW}waiting for output${p.reset}`];
|
|
632
|
+
}
|
|
633
|
+
const rows = blockRows(text, W);
|
|
634
|
+
if (rows.length <= CAP_LIVE_WINDOW) {
|
|
635
|
+
while (rows.length < CAP_LIVE_WINDOW)
|
|
636
|
+
rows.push(`${p.dim}${BODY_ROW}${p.reset}`);
|
|
637
|
+
return rows;
|
|
638
|
+
}
|
|
639
|
+
const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
|
|
640
|
+
return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
|
|
641
|
+
}
|
|
642
|
+
/** W12: the delegate's child sessions collapse to the tool row plus ONE
|
|
643
|
+
* line — the height NEVER changes (running → settled replaces the row
|
|
644
|
+
* in place). The running row derives from the INPUT: the parent has no
|
|
645
|
+
* live channel to a running child (ToolContext carries only
|
|
646
|
+
* signal/sessionId; execute returns ONE result), so the roles are the
|
|
647
|
+
* honest current data — the spec's "<child's current tool>" has no
|
|
648
|
+
* event source. The settled row parses the extension's summary marker
|
|
649
|
+
* (the blob's first line) — its absence falls back to no body (an old
|
|
650
|
+
* extension's output still renders). The one-line shape is shared with
|
|
651
|
+
* W18's status row (the work order: "implement them with one helper"). */
|
|
652
|
+
function delegateRunning(c, W) {
|
|
653
|
+
const p = palette();
|
|
654
|
+
const n = c.childRoles.length;
|
|
655
|
+
const text = n === 0 ? "children running…" : `${n === 1 ? "1 child" : `${n} children`} · ${c.childRoles.join(" · ")}`;
|
|
656
|
+
return [oneLineRow(p, text, W)];
|
|
657
|
+
}
|
|
658
|
+
function delegateSettled(c, W) {
|
|
659
|
+
const p = palette();
|
|
660
|
+
const m = /^summary: (.+)$/m.exec(c.resultText);
|
|
661
|
+
if (m === null)
|
|
662
|
+
return [];
|
|
663
|
+
return [oneLineRow(p, `${m[1]} · /last for the report`, W)];
|
|
664
|
+
}
|
|
665
|
+
/** ONE row at the left gutter, truncated to fit the width — never a
|
|
666
|
+
* fold (a fold would wrap into TWO rows and break the one-line height
|
|
667
|
+
* contract). */
|
|
668
|
+
function oneLineRow(p, text, W) {
|
|
669
|
+
const esc = escapeTerminal(text);
|
|
670
|
+
if (visibleWidth(`${p.dim}${CUT_ROW}${esc}${p.reset}`) <= W)
|
|
671
|
+
return `${p.dim}${CUT_ROW}${esc}${p.reset}`;
|
|
672
|
+
const w = Math.max(1, W - visibleWidth(`${p.dim}${CUT_ROW}${p.reset}`));
|
|
673
|
+
return `${p.dim}${CUT_ROW}${esc.slice(0, w - 1)}…${p.reset}`;
|
|
674
|
+
}
|
|
675
|
+
/** The approval mini-diff (W7): capped at 12 folded rows — the head +
|
|
676
|
+
* the named middle (the renderer cut — what was cut, how to expand) +
|
|
677
|
+
* the tail. The rows are folded at the current width BEFORE the cap —
|
|
678
|
+
* the R1 measured bug: truncateDiff capped at 40 ENTRIES while the
|
|
679
|
+
* fold turned them into 73 SCREEN rows at W≤80 (a 44-row terminal's
|
|
680
|
+
* content cap is H−4 = 40 — the approval force-committed a third of
|
|
681
|
+
* the screen into scrollback inside one frame).
|
|
682
|
+
* W17: the cap is a ROW budget at every width — the └ cut is ONE line
|
|
683
|
+
* (a folded cut pushed the total past 12 at narrow widths), and below
|
|
684
|
+
* a floor of 3 SOURCE lines visible the head/tail pair is noise (each
|
|
685
|
+
* fragment a sliver of a long line): drop to the head only — the head
|
|
686
|
+
* takes the whole budget — and the └ row carries the rest.
|
|
687
|
+
* W21: exported for the approval panel — the expanded path renders
|
|
688
|
+
* the approval's ALWAYS-verbose args (never the capped copy). */
|
|
689
|
+
export function diffBody(diff, W, expanded = false) {
|
|
690
|
+
const p = palette();
|
|
691
|
+
if (diff === null)
|
|
692
|
+
return [];
|
|
693
|
+
const rows = [];
|
|
694
|
+
// W17: each line's fold START row (the running total) — the pair
|
|
695
|
+
// floor reads it for the head/tail SOURCE-line counts below.
|
|
696
|
+
const starts = [0];
|
|
697
|
+
for (const d of diff) {
|
|
698
|
+
const body = d.kind === "-"
|
|
699
|
+
? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
|
|
700
|
+
: d.kind === "+"
|
|
701
|
+
? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
|
|
702
|
+
: `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
|
|
703
|
+
// W2: the diff body is a bounded block's body — the │ gutter
|
|
704
|
+
// (dim), never the old bold ▎ rail (the table lists no ▎); the
|
|
705
|
+
// +/- marks and their colors ride the content
|
|
706
|
+
rows.push(...gutterFold(`${p.dim}│${p.reset} `, body, W));
|
|
707
|
+
starts.push(rows.length);
|
|
708
|
+
}
|
|
709
|
+
if (expanded || rows.length <= CAP_DIFF)
|
|
710
|
+
return rows;
|
|
711
|
+
const head = Math.floor((CAP_DIFF - 1) / 2);
|
|
712
|
+
const tail = CAP_DIFF - 1 - head;
|
|
713
|
+
// W17: the └ cut is ONE row at every width — the count leads, the
|
|
714
|
+
// expand affordances are cuttable (the same one-line shape as W12's
|
|
715
|
+
// delegate row and W18's status row).
|
|
716
|
+
const cut = (n) => oneLineRow(p, `+${n} rows · ctrl+r to expand · /last for the full diff`, W);
|
|
717
|
+
// W17: the floor — the head window shows the lines whose fold starts
|
|
718
|
+
// before `head` rows; the tail window the lines whose fold ENDS after
|
|
719
|
+
// `rows.length - tail` (starts[i+1] is line i's end). When the pair
|
|
720
|
+
// shows fewer than 3 SOURCE lines together, it is noise at this width
|
|
721
|
+
// (each fragment a sliver of a long line): drop to the head only —
|
|
722
|
+
// the head takes the whole budget, the └ row carries the rest.
|
|
723
|
+
if (starts.filter((s) => s < head).length + starts.slice(1).filter((s) => s > rows.length - tail).length < 3)
|
|
724
|
+
return [...rows.slice(0, CAP_DIFF - 1), cut(rows.length - (CAP_DIFF - 1))];
|
|
725
|
+
return [...rows.slice(0, head), cut(rows.length - head - tail), ...rows.slice(rows.length - tail)];
|
|
726
|
+
}
|
|
727
|
+
/** The TOOL's OWN truncation note (W10) — a different fact from the
|
|
728
|
+
* renderer's cut: the tools truncate and append a continuation note
|
|
729
|
+
* (packages/tools-node/src/index.ts — read_file's "call again with
|
|
730
|
+
* offset=N", the output cap, list_dir's entry cap). The note reaches
|
|
731
|
+
* the MODEL and never the human — this row surfaces it. Detected in
|
|
732
|
+
* the result's TAIL (the note is appended at the end); returns null
|
|
733
|
+
* when the tool did not truncate. */
|
|
734
|
+
function toolCutNote(name, resultText) {
|
|
735
|
+
const tail = resultText.slice(-300);
|
|
736
|
+
const m = /offset=(\d+)/.exec(tail);
|
|
737
|
+
if (m !== null)
|
|
738
|
+
return `capped by ${escapeTerminal(name)} · offset=${m[1]} for the rest`;
|
|
739
|
+
if (/…\[truncated\]/.test(tail) || /… \+?\d+ more (?:lines|entries)/.test(tail))
|
|
740
|
+
return `capped by ${escapeTerminal(name)} · /last for the rest`;
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
/** The assistant body text — wrapped at W, the inline-code tint per
|
|
744
|
+
* row (the #16e rule: a span never matches across rows). */
|
|
745
|
+
class AssistantMessage {
|
|
746
|
+
cell;
|
|
747
|
+
constructor(cell) {
|
|
748
|
+
this.cell = cell;
|
|
749
|
+
}
|
|
750
|
+
render(W, _ctx) {
|
|
751
|
+
const text = escapeTerminal(this.cell.text);
|
|
752
|
+
const wrapped = foldLine(text, W);
|
|
753
|
+
return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** The ⚠ / notice lines — the error surface. */
|
|
757
|
+
class ErrorLine {
|
|
758
|
+
cell;
|
|
759
|
+
constructor(cell) {
|
|
760
|
+
this.cell = cell;
|
|
761
|
+
}
|
|
762
|
+
render(W, _ctx) {
|
|
763
|
+
return foldLine(escapeTerminal(this.cell.text), W);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** The CLI's pre-rendered blocks (the banner, the recap, slash-command
|
|
767
|
+
* output) — the SGR applied at composition (render.ts), folded here
|
|
768
|
+
* verbatim: the #16b contract (no re-escaping) holds, and the fold is
|
|
769
|
+
* SGR-aware so the accent spans survive a break. */
|
|
770
|
+
class RawBlock {
|
|
771
|
+
cell;
|
|
772
|
+
constructor(cell) {
|
|
773
|
+
this.cell = cell;
|
|
774
|
+
}
|
|
775
|
+
render(W, _ctx) {
|
|
776
|
+
return this.cell.lines.flatMap((l) => foldLine(l, W));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** The terminal label + the status line. W11: the rhythm gap blank is
|
|
780
|
+
* gone — the container's formula breathes below a multi-row cell (the
|
|
781
|
+
* terminal is always multi-row when labelled), never the component. */
|
|
782
|
+
class TerminalBlock {
|
|
783
|
+
cell;
|
|
784
|
+
constructor(cell) {
|
|
785
|
+
this.cell = cell;
|
|
786
|
+
}
|
|
787
|
+
render(W, _ctx) {
|
|
788
|
+
return [...foldLine(this.cell.label, W), ...foldLine(this.cell.line, W)];
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/** The startup banner — a LIVE cell: every render re-derives the tier
|
|
792
|
+
* from the CURRENT width AND height (bannerLines), so a resize re-tiers
|
|
793
|
+
* the art instead of re-folding frozen rows (the W1 tier table: below
|
|
794
|
+
* 40 cols the logo never paints). W11: no trailing blank — the
|
|
795
|
+
* container's formula breathes below the (always multi-row) banner. */
|
|
796
|
+
class Banner {
|
|
797
|
+
cell;
|
|
798
|
+
constructor(cell) {
|
|
799
|
+
this.cell = cell;
|
|
800
|
+
}
|
|
801
|
+
render(W, ctx) {
|
|
802
|
+
const p = palette();
|
|
803
|
+
const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now);
|
|
804
|
+
return rows.map((r) => `${p.dim}${r}${p.reset}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** W20 — the task block's fixed-window height: the whole live block
|
|
808
|
+
* (header + rows) in POST-FOLD screen rows at EVERY width: the header,
|
|
809
|
+
* the active row, up to 2 pending, the overflow-pending fold, the
|
|
810
|
+
* done-collapse. Every live row CUTS at W (never folds) — the block's
|
|
811
|
+
* height is its row count. */
|
|
812
|
+
export const CAP_TASK_LIVE = 6;
|
|
813
|
+
/** W20 — the live block's fixed-window row cut: an SGR-aware ONE-ROW
|
|
814
|
+
* truncation (foldLine wraps; a wrapped row would break the height
|
|
815
|
+
* cap — every live row is exactly one screen row at every width).
|
|
816
|
+
* A line that fits (≤ W) passes through whole; an overflow cuts the
|
|
817
|
+
* content at W−1 — the ellipsis's slot — and the ellipsis rides AFTER
|
|
818
|
+
* the reset (post-reset — the PTY needles' convention). The cut row
|
|
819
|
+
* never exceeds W (invariant ①). W21: exported for the approval
|
|
820
|
+
* panel's single-row lines (the rule line, the title, the divider,
|
|
821
|
+
* the options/affordance rows). */
|
|
822
|
+
export function cutLine(line, W) {
|
|
823
|
+
if (visibleWidth(line) <= W)
|
|
824
|
+
return line;
|
|
825
|
+
let out = "";
|
|
826
|
+
let width = 0;
|
|
827
|
+
for (let i = 0; i < line.length;) {
|
|
828
|
+
if (line[i] === "\x1b") {
|
|
829
|
+
// exec returns an ARRAY — copying m coerces it (the match), but
|
|
830
|
+
// m.length is the CAPTURE count (1), not the sequence length:
|
|
831
|
+
// the old `i += m.length` re-processed the sequence's bracket
|
|
832
|
+
// text as literal rows, doubling every code in a cut line
|
|
833
|
+
// (the W21 panel-slot red test). Index 0 is the sequence.
|
|
834
|
+
const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i))?.[0] ?? line[i];
|
|
835
|
+
out += m;
|
|
836
|
+
i += m.length;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const cw = displayWidth(line[i]);
|
|
840
|
+
if (width + cw > W - 1)
|
|
841
|
+
break; // reserve the ellipsis's column
|
|
842
|
+
out += line[i];
|
|
843
|
+
width += cw;
|
|
844
|
+
i += 1;
|
|
845
|
+
}
|
|
846
|
+
return `${out}\x1b[0m…`;
|
|
847
|
+
}
|
|
848
|
+
/** W20 — the settled block's duration, the `2h 14m` form (the task
|
|
849
|
+
* narrative's long-horizon idiom): minutes+seconds under an hour,
|
|
850
|
+
* hours+minutes past it. */
|
|
851
|
+
export function formatDuration(totalSeconds) {
|
|
852
|
+
const s = Math.max(0, Math.round(totalSeconds));
|
|
853
|
+
if (s < 60)
|
|
854
|
+
return `${s}s`;
|
|
855
|
+
const m = Math.floor(s / 60);
|
|
856
|
+
return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* W20 — the task checklist as STATE: ONE live block that redraws in
|
|
860
|
+
* place (the current turn's in-place updates), settling at the turn's
|
|
861
|
+
* end as ONE recap block. LIVE (done:false): the fixed "task" prefix +
|
|
862
|
+
* the compositor-derived counts (the model tail rides AFTER — never
|
|
863
|
+
* model-controlled), the active item first with ▸ (the menu's "the
|
|
864
|
+
* current one"), pending next (≤2), the done items COLLAPSED behind the
|
|
865
|
+
* W10 cut family `└ +N done · ctrl+r`, overflow pending behind
|
|
866
|
+
* `└ +N more · ctrl+r` — every row cut at W so the cap holds at every
|
|
867
|
+
* width. ctrl+r (W15) toggles the full list in place (expanded). SETTLED
|
|
868
|
+
* (done:true): the recap idiom `task done · N items · <duration>` + the
|
|
869
|
+
* FULL final item list in the checklist's existing shape (▖/□/▣ —
|
|
870
|
+
* indented two, the glyph leads, no │ gutter).
|
|
871
|
+
*/
|
|
872
|
+
class Checklist {
|
|
873
|
+
cell;
|
|
874
|
+
constructor(cell) {
|
|
875
|
+
this.cell = cell;
|
|
876
|
+
}
|
|
877
|
+
render(W, _ctx) {
|
|
878
|
+
const p = palette();
|
|
879
|
+
const { items, done, expanded, durationSeconds } = this.cell;
|
|
880
|
+
const active = items.filter((i) => i.status === "active");
|
|
881
|
+
const pending = items.filter((i) => i.status === "pending");
|
|
882
|
+
const doneCount = items.length - active.length - pending.length;
|
|
883
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
884
|
+
const tail = this.cell.header === "" ? "" : ` · ${this.cell.header}`;
|
|
885
|
+
const fixed = done
|
|
886
|
+
? `task done · ${plural(items.length, "item")} · ${formatDuration(durationSeconds)}`
|
|
887
|
+
: `task · ${plural(items.length, "item")} · ${active.length} active · ${doneCount} done`;
|
|
888
|
+
const header = `${p.bold}▞${p.reset} ${escapeTerminal(fixed + tail)}`;
|
|
889
|
+
// the FULL-list forms: SETTLED — the durable record (the fold is
|
|
890
|
+
// fine — committed content wraps naturally) — and the LIVE ctrl+r
|
|
891
|
+
// toggle (the header CUTS — the block stays one window high; the
|
|
892
|
+
// expanded rows show the ▣ the collapse hid). The live flag picks
|
|
893
|
+
// the glyphs: the settled list keeps the durable ▖, the expanded
|
|
894
|
+
// live list the ▸.
|
|
895
|
+
const glyph = (status, live) => {
|
|
896
|
+
const g = status === "pending" ? "□" : status === "active" ? (live ? "▸" : "▖") : "▣";
|
|
897
|
+
return g === "▸" ? `${p.bold}▸${p.reset}` : g;
|
|
898
|
+
};
|
|
899
|
+
if (done || expanded) {
|
|
900
|
+
const rows = done ? foldLine(header, W) : [cutLine(header, W)];
|
|
901
|
+
for (const item of items)
|
|
902
|
+
rows.push(...foldLine(` ${glyph(item.status, !done)} ${escapeTerminal(item.text)}`, W));
|
|
903
|
+
return rows;
|
|
904
|
+
}
|
|
905
|
+
// LIVE — the fixed window: the header + the item rows CUT at W
|
|
906
|
+
// (one screen row each — the block's height is its row count,
|
|
907
|
+
// CAP_TASK_LIVE, at every width). The cut is the momentary view;
|
|
908
|
+
// the settle (and the ctrl+r toggle) show everything.
|
|
909
|
+
const itemRows = [];
|
|
910
|
+
if (active.length > 0)
|
|
911
|
+
itemRows.push(` ${p.bold}▸${p.reset} ${escapeTerminal(active[0].text)}`);
|
|
912
|
+
for (const item of pending.slice(0, 2))
|
|
913
|
+
itemRows.push(` □ ${escapeTerminal(item.text)}`);
|
|
914
|
+
const more = pending.length - 2;
|
|
915
|
+
if (more > 0)
|
|
916
|
+
itemRows.push(` ${p.dim}└ +${more} more · ctrl+r${p.reset}`);
|
|
917
|
+
if (doneCount > 0)
|
|
918
|
+
itemRows.push(` ${p.dim}└ +${doneCount} done · ctrl+r${p.reset}`);
|
|
919
|
+
return [cutLine(header, W), ...itemRows.map((r) => cutLine(r, W))];
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// ---- the chrome components (the status container, the slot, the footer) ----
|
|
923
|
+
/** The status container's row: the status text (+ the tail) with the
|
|
924
|
+
* right-aligned "/ commands · ↑ history" hint in the idle state —
|
|
925
|
+
* the hint CUT FIRST when the width is short (the #16g rule); when
|
|
926
|
+
* the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
|
|
927
|
+
* enforced by invariant ① (the old code let the status soft-wrap).
|
|
928
|
+
* W21: the question param is gone — the old question slot retires; a
|
|
929
|
+
* pending approval's status IS the panel's (the compositor derives
|
|
930
|
+
* it from the bound panel state). */
|
|
931
|
+
export function statusLine(status, tail, W, hint) {
|
|
932
|
+
const p = palette();
|
|
933
|
+
const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
|
|
934
|
+
// W18: the hint is a parameter — the compacting row right-aligns its
|
|
935
|
+
// "esc to cancel" (the same one-line-bounded shape as W12's delegate
|
|
936
|
+
// row; the #16g rule still cuts the HINT first, then the status with
|
|
937
|
+
// a "…" — never a fold).
|
|
938
|
+
const hintText = hint ?? " / commands · ↑ history";
|
|
939
|
+
const statusW = visibleWidth(text);
|
|
940
|
+
if (statusW > W) {
|
|
941
|
+
return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
|
|
942
|
+
}
|
|
943
|
+
const hintW = visibleWidth(hintText);
|
|
944
|
+
if (statusW + hintW > W)
|
|
945
|
+
return `${p.dim}${text}${p.reset}`;
|
|
946
|
+
return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
|
|
947
|
+
}
|
|
948
|
+
/** The display-width prefix of a plain (SGR-free) text. W21: exported
|
|
949
|
+
* for the approval panel's option-2 rule-name cut. */
|
|
950
|
+
export function widthCut(text, max) {
|
|
951
|
+
let w = 0;
|
|
952
|
+
let i = 0;
|
|
953
|
+
for (; i < text.length; i += 1) {
|
|
954
|
+
const cw = displayWidth(text[i]);
|
|
955
|
+
if (w + cw > max)
|
|
956
|
+
break;
|
|
957
|
+
w += cw;
|
|
958
|
+
}
|
|
959
|
+
return text.slice(0, i);
|
|
960
|
+
}
|
|
961
|
+
/** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
|
|
962
|
+
* a rounded box (the box already says "input lives here"); the rails
|
|
963
|
+
* stay dim, the width is still the full W (the box is a rail with
|
|
964
|
+
* corners — the menu/gap rows above and the status below are
|
|
965
|
+
* untouched). */
|
|
966
|
+
export function boxTop(W) {
|
|
967
|
+
return `\x1b[2m╭${"─".repeat(Math.max(0, W - 2))}╮\x1b[0m`;
|
|
968
|
+
}
|
|
969
|
+
/** W6 — the box: the chrome's bottom rail. */
|
|
970
|
+
export function boxBottom(W) {
|
|
971
|
+
return `\x1b[2m╰${"─".repeat(Math.max(0, W - 2))}╯\x1b[0m`;
|
|
972
|
+
}
|
|
973
|
+
/** The terminal label + rhythm gap (the pipe path's v2c bytes — the
|
|
974
|
+
* exact render the passthrough needs). */
|
|
975
|
+
export function terminalPipe(label, statusLineText) {
|
|
976
|
+
return label + renderTerminalGap(statusLineText);
|
|
977
|
+
}
|
|
978
|
+
/** The pipe-path pieces the passthrough reuses (byte-identical). */
|
|
979
|
+
export { foldThinking, foldResult, renderToolSummary, TOOL_SUMMARY_MAX };
|