@vincemakes/kiso-tui 0.1.36 → 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 +113 -7
- package/dist/components.js +471 -66
- package/dist/compositor.d.ts +58 -5
- package/dist/compositor.js +475 -58
- package/dist/editor.d.ts +3 -6
- package/dist/editor.js +27 -54
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/render.d.ts +46 -5
- package/dist/render.js +150 -30
- package/dist/width.d.ts +15 -0
- package/dist/width.js +59 -0
- package/package.json +1 -1
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 { 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
|
/**
|
|
@@ -106,14 +106,39 @@ export function visibleWidth(line) {
|
|
|
106
106
|
}
|
|
107
107
|
return w;
|
|
108
108
|
}
|
|
109
|
-
/** The
|
|
109
|
+
/** The W11 spacing formula — "a row gets one blank line above it when
|
|
110
|
+
* the row is itself a block, or when the previous sibling was taller
|
|
111
|
+
* than one row". One-row siblings pack tight; anything multi-row
|
|
112
|
+
* breathes on both sides. The FIRST cell never gets the blank (it sits
|
|
113
|
+
* at the body's top — the banner would otherwise start one row down).
|
|
114
|
+
* `prev` is the previous sibling's OWN rows (raw — a cell's own blank
|
|
115
|
+
* must never count toward its height). The blank is a JOIN artifact:
|
|
116
|
+
* the cell's own render stays blank-free, so per-cell accounting
|
|
117
|
+
* (heights, the fold cache) never sees a fake row. */
|
|
118
|
+
export function bodySpacing(prev, rows) {
|
|
119
|
+
if (rows.length === 0 || prev === null || prev.length === 0)
|
|
120
|
+
return rows;
|
|
121
|
+
if (rows.length > 1 || prev.length > 1)
|
|
122
|
+
return ["", ...rows];
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
/** The container — vertical concatenation with the W11 formula. No
|
|
126
|
+
* component decides its own spacing: every blank in the body is the
|
|
127
|
+
* container's. */
|
|
110
128
|
export class Container {
|
|
111
129
|
children;
|
|
112
130
|
constructor(children) {
|
|
113
131
|
this.children = children;
|
|
114
132
|
}
|
|
115
133
|
render(width, ctx) {
|
|
116
|
-
|
|
134
|
+
const out = [];
|
|
135
|
+
let prev = null;
|
|
136
|
+
for (const c of this.children) {
|
|
137
|
+
const rows = c.render(width, ctx);
|
|
138
|
+
out.push(...bodySpacing(prev, rows));
|
|
139
|
+
prev = rows;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
117
142
|
}
|
|
118
143
|
}
|
|
119
144
|
const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
|
|
@@ -131,6 +156,8 @@ export function cellComponent(cell) {
|
|
|
131
156
|
return new AssistantMessage(cell);
|
|
132
157
|
case "notice":
|
|
133
158
|
return new ErrorLine(cell);
|
|
159
|
+
case "banner":
|
|
160
|
+
return new Banner(cell);
|
|
134
161
|
case "raw":
|
|
135
162
|
return new RawBlock(cell);
|
|
136
163
|
case "terminal":
|
|
@@ -141,10 +168,18 @@ export function cellComponent(cell) {
|
|
|
141
168
|
}
|
|
142
169
|
/**
|
|
143
170
|
* The user message — the left rail (bright-white BOLD ▍ per row, the
|
|
144
|
-
* v4.1 design)
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
171
|
+
* v4.1 design) + the W16 inset chip. The chip folds the text at W−6
|
|
172
|
+
* (the rail + the indent + the side pads), then pads EVERY row to the
|
|
173
|
+
* longest row's DISPLAY width + one space each side, indented two: the
|
|
174
|
+
* block is only as wide as what was said (never the full-width band —
|
|
175
|
+
* a short message like /think would paint a bar across the terminal).
|
|
176
|
+
* The padding is by cells (charWidth is the width authority), so a CJK
|
|
177
|
+
* row pads by width, never by chars, and the chip never overruns its
|
|
178
|
+
* fold. SGR 7 closed with SGR 27 — never SGR 0, the chip composes
|
|
179
|
+
* with a surrounding span — and NEVER dim: reverse video inverts the
|
|
180
|
+
* CURRENT colours, so dimmed text would invert into a dimmed block
|
|
181
|
+
* with no contrast. The ▍ rail stays: SGR is an emphasis on top, the
|
|
182
|
+
* rail is the structural fallback that survives a pipe.
|
|
148
183
|
*/
|
|
149
184
|
class UserMessage {
|
|
150
185
|
cell;
|
|
@@ -154,12 +189,15 @@ class UserMessage {
|
|
|
154
189
|
render(W, _ctx) {
|
|
155
190
|
const p = palette();
|
|
156
191
|
const rail = `${p.bold}▍${p.reset} `;
|
|
157
|
-
const
|
|
192
|
+
const chipW = Math.max(1, W - 6);
|
|
158
193
|
const rows = [];
|
|
159
194
|
for (const para of this.cell.text.split("\n")) {
|
|
160
|
-
const folded = foldLine(escapeTerminal(para),
|
|
161
|
-
|
|
162
|
-
|
|
195
|
+
const folded = foldLine(escapeTerminal(para), chipW);
|
|
196
|
+
const inner = Math.max(...folded.map((r) => displayWidth(r)));
|
|
197
|
+
for (const row of folded) {
|
|
198
|
+
const pad = inner - displayWidth(row);
|
|
199
|
+
rows.push(`${rail} ${p.rv} ${row}${" ".repeat(pad)} ${p.rvEnd}`);
|
|
200
|
+
}
|
|
163
201
|
}
|
|
164
202
|
return rows.length > 0 ? rows : [rail.trimEnd()];
|
|
165
203
|
}
|
|
@@ -168,7 +206,8 @@ class UserMessage {
|
|
|
168
206
|
* rides the fold's own row (the #17 fix's slice, componentized). The
|
|
169
207
|
* slice is DISPLAY-WIDTH-based (the char-based slice overflowed with
|
|
170
208
|
* CJK — 2 cells per char — and tripped invariant ① on a real
|
|
171
|
-
* Chinese session).
|
|
209
|
+
* Chinese session). W2: the leading ⋯ is the thinking gutter — the
|
|
210
|
+
* midline mark (the state), never the text ellipsis (the truncation). */
|
|
172
211
|
class ThinkingFold {
|
|
173
212
|
cell;
|
|
174
213
|
constructor(cell) {
|
|
@@ -182,12 +221,87 @@ class ThinkingFold {
|
|
|
182
221
|
// UNFOLDED and tripped invariant ① (the crash class still live on
|
|
183
222
|
// npm for short /think blocks after a resize).
|
|
184
223
|
if (trimmed.length <= 100)
|
|
185
|
-
return [`${palette().dim}
|
|
224
|
+
return [`${palette().dim}⋯${widthCut(trimmed, Math.max(1, W - 1))}${palette().reset}`];
|
|
186
225
|
const suffix = ` (${block.length} chars · /think)`;
|
|
187
226
|
const slice = Math.max(1, W - 1 - suffix.length);
|
|
188
|
-
return [`${palette().dim}
|
|
227
|
+
return [`${palette().dim}⋯${widthCut(trimmed, slice)}${suffix}${palette().reset}`];
|
|
189
228
|
}
|
|
190
229
|
}
|
|
230
|
+
/** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
|
|
231
|
+
* (W2: a wrapped tool row keeps its state mark — the left edge alone
|
|
232
|
+
* distinguishes the states at --plain; the UserMessage rail precedent,
|
|
233
|
+
* v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). */
|
|
234
|
+
function gutterFold(gutter, line, W) {
|
|
235
|
+
const textW = Math.max(1, W - 2);
|
|
236
|
+
return foldLine(line, textW).map((r) => `${gutter}${r}`);
|
|
237
|
+
}
|
|
238
|
+
/** Lines without the phantom empty line after a trailing newline. */
|
|
239
|
+
function countLines(text) {
|
|
240
|
+
if (text === "")
|
|
241
|
+
return 0;
|
|
242
|
+
const parts = text.split("\n");
|
|
243
|
+
return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
|
|
244
|
+
}
|
|
245
|
+
/** W4: the settled-row metadata — the human summary in parentheses. The
|
|
246
|
+
* separation NEVER relies on dim: a pipe drops the SGR, and the shapes
|
|
247
|
+
* below read at full strength with the palette off. read → the line
|
|
248
|
+
* count ("912 lines"; "200 of 3412 lines" when the tool cut it — the
|
|
249
|
+
* note names the remainder; ≥1000 k-formats, "2.4k lines"); write/edit
|
|
250
|
+
* → the ± diff stats (the approval diff's counts — an auto-allowed
|
|
251
|
+
* write never computed one, so the input's own counts fall back, then
|
|
252
|
+
* the result's line count); shell → the exit code (parsed from the
|
|
253
|
+
* failure text — the tool names it — 0 on success); a non-shell error
|
|
254
|
+
* → the error text's first line; anything else → the result's line
|
|
255
|
+
* count. */
|
|
256
|
+
function settledMeta(c) {
|
|
257
|
+
if (c.isError) {
|
|
258
|
+
// a shell EXECUTION failure names its code first ("exit 1: …") —
|
|
259
|
+
// that IS the metadata, and the body shows the full text. A
|
|
260
|
+
// shell without the code (a denial, a precondition) is not an
|
|
261
|
+
// exit failure: the first line stays the metadata, exactly like
|
|
262
|
+
// any other error.
|
|
263
|
+
if (c.name === "shell" && /^exit \d+/.test(c.resultText))
|
|
264
|
+
return `exit ${/^exit (\d+)/.exec(c.resultText)[1]}`;
|
|
265
|
+
return c.resultText.split("\n")[0].slice(0, 60);
|
|
266
|
+
}
|
|
267
|
+
if (c.name === "read_file") {
|
|
268
|
+
const noteAt = c.resultText.lastIndexOf("\n… ");
|
|
269
|
+
const shown = countLines(noteAt >= 0 ? c.resultText.slice(0, noteAt) : c.resultText);
|
|
270
|
+
const more = noteAt >= 0 ? /(\d+) more lines?/.exec(c.resultText.slice(noteAt)) : null;
|
|
271
|
+
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n));
|
|
272
|
+
if (more !== null) {
|
|
273
|
+
const total = shown + Number(more[1]);
|
|
274
|
+
return `${shown} of ${total} line${total === 1 ? "" : "s"}`;
|
|
275
|
+
}
|
|
276
|
+
return `${k(shown)} line${shown === 1 ? "" : "s"}`;
|
|
277
|
+
}
|
|
278
|
+
if (c.name === "write_file" || c.name === "edit_file") {
|
|
279
|
+
if (c.added + c.removed > 0)
|
|
280
|
+
return `+${c.added} -${c.removed}`;
|
|
281
|
+
// no approval diff (an auto-allowed write): the input summary may
|
|
282
|
+
// be sliced at TOOL_SUMMARY_MAX — best-effort, then the last resort
|
|
283
|
+
let parsed = null;
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(c.input);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
parsed = null;
|
|
289
|
+
}
|
|
290
|
+
if (c.name === "write_file" && parsed !== null && typeof parsed.content === "string") {
|
|
291
|
+
return `+${countLines(parsed.content)}`;
|
|
292
|
+
}
|
|
293
|
+
if (c.name === "edit_file" && parsed !== null && typeof parsed.search === "string" && typeof parsed.replace === "string") {
|
|
294
|
+
const added = countLines(parsed.replace);
|
|
295
|
+
const removed = countLines(parsed.search);
|
|
296
|
+
if (added + removed > 0)
|
|
297
|
+
return `+${added} -${removed}`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (c.name === "shell")
|
|
301
|
+
return "exit 0";
|
|
302
|
+
const n = countLines(c.resultText);
|
|
303
|
+
return `${n} line${n === 1 ? "" : "s"}`;
|
|
304
|
+
}
|
|
191
305
|
/** The tool execution line + the bounded block — every state is its
|
|
192
306
|
* own render; the lines fold (the summary gives way first). W7 (the
|
|
193
307
|
* flow contract): the block's BODY (the rows below the header) is
|
|
@@ -195,7 +309,14 @@ class ThinkingFold {
|
|
|
195
309
|
* renderer-cut row (`└ +N … · ctrl+r`) sits INSIDE the cap (a
|
|
196
310
|
* truncated block is cap−1 output rows + the cut row); the TOOL-cut
|
|
197
311
|
* row (`└ capped by …` — the tool's OWN truncation note, W10) is a
|
|
198
|
-
* DIFFERENT fact, never counted in the output cap.
|
|
312
|
+
* DIFFERENT fact, never counted in the output cap. W3: the verb is
|
|
313
|
+
* stripped of its "_file" suffix and padded to 5 columns — the target
|
|
314
|
+
* paths line up (the pipe path strips the same suffix, render.ts —
|
|
315
|
+
* both paths print the same verb; a verb ≥ 5 columns is not padded).
|
|
316
|
+
* The block's cut note keeps the RAW name (it names the tool the
|
|
317
|
+
* model should call again). W4: the settled row's parentheses hold
|
|
318
|
+
* the human metadata (settledMeta) — the input summary lived in the
|
|
319
|
+
* running row; the OUTCOME is what the settled row says. */
|
|
199
320
|
class ToolExecution {
|
|
200
321
|
cell;
|
|
201
322
|
constructor(cell) {
|
|
@@ -204,31 +325,115 @@ class ToolExecution {
|
|
|
204
325
|
render(W, ctx) {
|
|
205
326
|
const p = palette();
|
|
206
327
|
const c = this.cell;
|
|
207
|
-
const
|
|
328
|
+
const verb = escapeTerminal(c.name.replace("_file", ""));
|
|
329
|
+
const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
|
|
208
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
|
+
}
|
|
209
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
|
+
}
|
|
210
368
|
const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
369
|
+
const meta = escapeTerminal(settledMeta(c));
|
|
370
|
+
const out = c.isError
|
|
371
|
+
? gutterFold(`${p.red}✗${p.reset} `, `${p.red}${verbCol} (${meta}, ${elapsed}s)${p.reset}`, W)
|
|
372
|
+
: gutterFold(`${p.bold}✓${p.reset} `, `${verbCol} (${meta}, ${elapsed}s)`, W);
|
|
215
373
|
out.push(...toolBlockBody(c, W));
|
|
216
374
|
return out;
|
|
217
375
|
}
|
|
218
376
|
if (c.state === "approval") {
|
|
219
|
-
|
|
377
|
+
// W2: the ⏸ is the GUTTER (the left edge), never the line's tail
|
|
378
|
+
const out = gutterFold(`${p.bold}⏸${p.reset} `, `${verbCol} ${summary}`, W);
|
|
220
379
|
out.push(...toolBlockBody(c, W));
|
|
221
380
|
return out;
|
|
222
381
|
}
|
|
223
382
|
if (c.state === "running") {
|
|
383
|
+
// W2: the spinner IS the gutter (the left edge); the elapsed
|
|
384
|
+
// rides the summary's tail
|
|
224
385
|
const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
|
|
225
|
-
const out =
|
|
386
|
+
const out = gutterFold(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${summary} ${elapsed}s`, W);
|
|
226
387
|
out.push(...toolBlockBody(c, W));
|
|
227
388
|
return out;
|
|
228
389
|
}
|
|
229
|
-
|
|
390
|
+
// W2: ◦ replaces → for QUEUED — · is the separator inside every
|
|
391
|
+
// metadata group; a queued marker that is also the separator
|
|
392
|
+
// glyph reads as noise
|
|
393
|
+
return gutterFold(`${p.dim}◦${p.reset} `, `${verbCol} ${summary}`, W);
|
|
230
394
|
}
|
|
231
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
|
+
}
|
|
232
437
|
// ---- the bounded-block flow contract (W7, W8, W10) ----
|
|
233
438
|
/** The caps — screen rows counted AFTER the fold, at the current width
|
|
234
439
|
* (the W7 table). The renderer-cut row is inside the cap. */
|
|
@@ -238,30 +443,47 @@ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
|
|
|
238
443
|
const CAP_ERROR = 3; // the error text head
|
|
239
444
|
/** The block body rows' prefixes (W2's gutter table): │ a bounded
|
|
240
445
|
* block's body, └ the block's last row — what was cut, where the rest
|
|
241
|
-
* is
|
|
242
|
-
|
|
243
|
-
const
|
|
446
|
+
* is — at the LEFT EDGE (the gutter column: the left edge alone
|
|
447
|
+
* distinguishes the states at --plain). Structural (constraint 1). */
|
|
448
|
+
const BODY_ROW = "│ ";
|
|
449
|
+
const CUT_ROW = "└ ";
|
|
244
450
|
const blockMemo = new WeakMap();
|
|
245
451
|
/** The block's body rows below the header (memoized, W9). */
|
|
246
452
|
function toolBlockBody(c, W) {
|
|
247
453
|
const memo = blockMemo.get(c);
|
|
248
|
-
const state = `${c.state}:${c.isError}:${c.name}`;
|
|
454
|
+
const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}`;
|
|
249
455
|
const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
|
|
250
456
|
if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
|
|
251
457
|
return memo.rows;
|
|
252
458
|
const p = palette();
|
|
253
|
-
const rows = c.
|
|
254
|
-
?
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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);
|
|
265
487
|
if (note !== null)
|
|
266
488
|
rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
|
|
267
489
|
blockMemo.set(c, { width: W, state, content, rows });
|
|
@@ -297,9 +519,18 @@ function shellTail(text, W) {
|
|
|
297
519
|
/** The error text head: the FIRST rows, capped at 3 — the answer is at
|
|
298
520
|
* the start (opencode's collapseToolOutput direction). The header row
|
|
299
521
|
* already summarizes the first line, so the body starts at line 2. */
|
|
300
|
-
function errorBody(
|
|
522
|
+
function errorBody(c, W) {
|
|
301
523
|
const p = palette();
|
|
302
|
-
|
|
524
|
+
// W4: a shell EXECUTION failure's line 0 ("exit 1: …") no longer
|
|
525
|
+
// rides the header — the parsed code does — so the body keeps the
|
|
526
|
+
// FULL text. Any other error keeps the pre-W4 split: line 0 is the
|
|
527
|
+
// header's metadata, the body shows the rest.
|
|
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;
|
|
533
|
+
const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
|
|
303
534
|
if (rows.length <= CAP_ERROR)
|
|
304
535
|
return rows;
|
|
305
536
|
const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+r${p.reset}`, W);
|
|
@@ -324,32 +555,88 @@ function liveWindow(text, W) {
|
|
|
324
555
|
const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
|
|
325
556
|
return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
|
|
326
557
|
}
|
|
558
|
+
/** W12: the delegate's child sessions collapse to the tool row plus ONE
|
|
559
|
+
* line — the height NEVER changes (running → settled replaces the row
|
|
560
|
+
* in place). The running row derives from the INPUT: the parent has no
|
|
561
|
+
* live channel to a running child (ToolContext carries only
|
|
562
|
+
* signal/sessionId; execute returns ONE result), so the roles are the
|
|
563
|
+
* honest current data — the spec's "<child's current tool>" has no
|
|
564
|
+
* event source. The settled row parses the extension's summary marker
|
|
565
|
+
* (the blob's first line) — its absence falls back to no body (an old
|
|
566
|
+
* extension's output still renders). The one-line shape is shared with
|
|
567
|
+
* W18's status row (the work order: "implement them with one helper"). */
|
|
568
|
+
function delegateRunning(c, W) {
|
|
569
|
+
const p = palette();
|
|
570
|
+
const n = c.childRoles.length;
|
|
571
|
+
const text = n === 0 ? "children running…" : `${n === 1 ? "1 child" : `${n} children`} · ${c.childRoles.join(" · ")}`;
|
|
572
|
+
return [oneLineRow(p, text, W)];
|
|
573
|
+
}
|
|
574
|
+
function delegateSettled(c, W) {
|
|
575
|
+
const p = palette();
|
|
576
|
+
const m = /^summary: (.+)$/m.exec(c.resultText);
|
|
577
|
+
if (m === null)
|
|
578
|
+
return [];
|
|
579
|
+
return [oneLineRow(p, `${m[1]} · /last for the report`, W)];
|
|
580
|
+
}
|
|
581
|
+
/** ONE row at the left gutter, truncated to fit the width — never a
|
|
582
|
+
* fold (a fold would wrap into TWO rows and break the one-line height
|
|
583
|
+
* contract). */
|
|
584
|
+
function oneLineRow(p, text, W) {
|
|
585
|
+
const esc = escapeTerminal(text);
|
|
586
|
+
if (visibleWidth(`${p.dim}${CUT_ROW}${esc}${p.reset}`) <= W)
|
|
587
|
+
return `${p.dim}${CUT_ROW}${esc}${p.reset}`;
|
|
588
|
+
const w = Math.max(1, W - visibleWidth(`${p.dim}${CUT_ROW}${p.reset}`));
|
|
589
|
+
return `${p.dim}${CUT_ROW}${esc.slice(0, w - 1)}…${p.reset}`;
|
|
590
|
+
}
|
|
327
591
|
/** The approval mini-diff (W7): capped at 12 folded rows — the head +
|
|
328
592
|
* the named middle (the renderer cut — what was cut, how to expand) +
|
|
329
593
|
* the tail. The rows are folded at the current width BEFORE the cap —
|
|
330
594
|
* the R1 measured bug: truncateDiff capped at 40 ENTRIES while the
|
|
331
595
|
* fold turned them into 73 SCREEN rows at W≤80 (a 44-row terminal's
|
|
332
596
|
* content cap is H−4 = 40 — the approval force-committed a third of
|
|
333
|
-
* the screen into scrollback inside one frame).
|
|
334
|
-
|
|
597
|
+
* the screen into scrollback inside one frame).
|
|
598
|
+
* W17: the cap is a ROW budget at every width — the └ cut is ONE line
|
|
599
|
+
* (a folded cut pushed the total past 12 at narrow widths), and below
|
|
600
|
+
* a floor of 3 SOURCE lines visible the head/tail pair is noise (each
|
|
601
|
+
* fragment a sliver of a long line): drop to the head only — the head
|
|
602
|
+
* takes the whole budget — and the └ row carries the rest. */
|
|
603
|
+
function diffBody(diff, W, expanded = false) {
|
|
335
604
|
const p = palette();
|
|
336
605
|
if (diff === null)
|
|
337
606
|
return [];
|
|
338
607
|
const rows = [];
|
|
608
|
+
// W17: each line's fold START row (the running total) — the pair
|
|
609
|
+
// floor reads it for the head/tail SOURCE-line counts below.
|
|
610
|
+
const starts = [0];
|
|
339
611
|
for (const d of diff) {
|
|
340
612
|
const body = d.kind === "-"
|
|
341
613
|
? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
|
|
342
614
|
: d.kind === "+"
|
|
343
615
|
? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
|
|
344
616
|
: `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
|
|
345
|
-
|
|
617
|
+
// W2: the diff body is a bounded block's body — the │ gutter
|
|
618
|
+
// (dim), never the old bold ▎ rail (the table lists no ▎); the
|
|
619
|
+
// +/- marks and their colors ride the content
|
|
620
|
+
rows.push(...gutterFold(`${p.dim}│${p.reset} `, body, W));
|
|
621
|
+
starts.push(rows.length);
|
|
346
622
|
}
|
|
347
|
-
if (rows.length <= CAP_DIFF)
|
|
623
|
+
if (expanded || rows.length <= CAP_DIFF)
|
|
348
624
|
return rows;
|
|
349
625
|
const head = Math.floor((CAP_DIFF - 1) / 2);
|
|
350
626
|
const tail = CAP_DIFF - 1 - head;
|
|
351
|
-
|
|
352
|
-
|
|
627
|
+
// W17: the └ cut is ONE row at every width — the count leads, the
|
|
628
|
+
// expand affordances are cuttable (the same one-line shape as W12's
|
|
629
|
+
// delegate row and W18's status row).
|
|
630
|
+
const cut = (n) => oneLineRow(p, `+${n} rows · ctrl+r to expand · /last for the full diff`, W);
|
|
631
|
+
// W17: the floor — the head window shows the lines whose fold starts
|
|
632
|
+
// before `head` rows; the tail window the lines whose fold ENDS after
|
|
633
|
+
// `rows.length - tail` (starts[i+1] is line i's end). When the pair
|
|
634
|
+
// shows fewer than 3 SOURCE lines together, it is noise at this width
|
|
635
|
+
// (each fragment a sliver of a long line): drop to the head only —
|
|
636
|
+
// the head takes the whole budget, the └ row carries the rest.
|
|
637
|
+
if (starts.filter((s) => s < head).length + starts.slice(1).filter((s) => s > rows.length - tail).length < 3)
|
|
638
|
+
return [...rows.slice(0, CAP_DIFF - 1), cut(rows.length - (CAP_DIFF - 1))];
|
|
639
|
+
return [...rows.slice(0, head), cut(rows.length - head - tail), ...rows.slice(rows.length - tail)];
|
|
353
640
|
}
|
|
354
641
|
/** The TOOL's OWN truncation note (W10) — a different fact from the
|
|
355
642
|
* renderer's cut: the tools truncate and append a continuation note
|
|
@@ -403,20 +690,92 @@ class RawBlock {
|
|
|
403
690
|
return this.cell.lines.flatMap((l) => foldLine(l, W));
|
|
404
691
|
}
|
|
405
692
|
}
|
|
406
|
-
/** The terminal label + the status line
|
|
693
|
+
/** The terminal label + the status line. W11: the rhythm gap blank is
|
|
694
|
+
* gone — the container's formula breathes below a multi-row cell (the
|
|
695
|
+
* terminal is always multi-row when labelled), never the component. */
|
|
407
696
|
class TerminalBlock {
|
|
408
697
|
cell;
|
|
409
698
|
constructor(cell) {
|
|
410
699
|
this.cell = cell;
|
|
411
700
|
}
|
|
412
701
|
render(W, _ctx) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
702
|
+
return [...foldLine(this.cell.label, W), ...foldLine(this.cell.line, W)];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** The startup banner — a LIVE cell: every render re-derives the tier
|
|
706
|
+
* from the CURRENT width AND height (bannerLines), so a resize re-tiers
|
|
707
|
+
* the art instead of re-folding frozen rows (the W1 tier table: below
|
|
708
|
+
* 40 cols the logo never paints). W11: no trailing blank — the
|
|
709
|
+
* container's formula breathes below the (always multi-row) banner. */
|
|
710
|
+
class Banner {
|
|
711
|
+
cell;
|
|
712
|
+
constructor(cell) {
|
|
713
|
+
this.cell = cell;
|
|
714
|
+
}
|
|
715
|
+
render(W, ctx) {
|
|
716
|
+
const p = palette();
|
|
717
|
+
const rows = bannerLines(W, ctx.height, this.cell.version, this.cell.extensionsText, this.cell.resume, ctx.now);
|
|
718
|
+
return rows.map((r) => `${p.dim}${r}${p.reset}`);
|
|
417
719
|
}
|
|
418
720
|
}
|
|
419
|
-
/**
|
|
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
|
+
*/
|
|
420
779
|
class Checklist {
|
|
421
780
|
cell;
|
|
422
781
|
constructor(cell) {
|
|
@@ -424,12 +783,47 @@ class Checklist {
|
|
|
424
783
|
}
|
|
425
784
|
render(W, _ctx) {
|
|
426
785
|
const p = palette();
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
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;
|
|
431
811
|
}
|
|
432
|
-
|
|
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))];
|
|
433
827
|
}
|
|
434
828
|
}
|
|
435
829
|
// ---- the chrome components (the status container, the slot, the footer) ----
|
|
@@ -438,20 +832,24 @@ class Checklist {
|
|
|
438
832
|
* the hint CUT FIRST when the width is short (the #16g rule); when
|
|
439
833
|
* the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
|
|
440
834
|
* enforced by invariant ① (the old code let the status soft-wrap). */
|
|
441
|
-
export function statusLine(status, tail, question, W) {
|
|
835
|
+
export function statusLine(status, tail, question, W, hint) {
|
|
442
836
|
const p = palette();
|
|
443
837
|
const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
|
|
444
838
|
if (question)
|
|
445
839
|
return `${p.dim}${widthCut(text, W)}${p.reset}`;
|
|
446
|
-
|
|
840
|
+
// W18: the hint is a parameter — the compacting row right-aligns its
|
|
841
|
+
// "esc to cancel" (the same one-line-bounded shape as W12's delegate
|
|
842
|
+
// row; the #16g rule still cuts the HINT first, then the status with
|
|
843
|
+
// a "…" — never a fold).
|
|
844
|
+
const hintText = hint ?? " / commands · ↑ history";
|
|
447
845
|
const statusW = visibleWidth(text);
|
|
448
846
|
if (statusW > W) {
|
|
449
847
|
return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
|
|
450
848
|
}
|
|
451
|
-
const hintW = visibleWidth(
|
|
849
|
+
const hintW = visibleWidth(hintText);
|
|
452
850
|
if (statusW + hintW > W)
|
|
453
851
|
return `${p.dim}${text}${p.reset}`;
|
|
454
|
-
return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${
|
|
852
|
+
return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
|
|
455
853
|
}
|
|
456
854
|
/** The display-width prefix of a plain (SGR-free) text. */
|
|
457
855
|
function widthCut(text, max) {
|
|
@@ -465,10 +863,17 @@ function widthCut(text, max) {
|
|
|
465
863
|
}
|
|
466
864
|
return text.slice(0, i);
|
|
467
865
|
}
|
|
468
|
-
/**
|
|
469
|
-
* the
|
|
470
|
-
|
|
471
|
-
|
|
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`;
|
|
472
877
|
}
|
|
473
878
|
/** The terminal label + rhythm gap (the pipe path's v2c bytes — the
|
|
474
879
|
* exact render the passthrough needs). */
|