@north-light/crouter 0.3.277 → 0.3.278
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/clients/attach/input/capabilities.js +2 -1
- package/dist/clients/attach/slash/dispatch.js +42 -19
- package/dist/clients/attach/viewer.js +523 -523
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +32 -7
- package/dist/core/canvas/browse/render.d.ts +17 -2
- package/dist/core/canvas/browse/render.js +342 -242
- package/dist/core/canvas/render-source.d.ts +26 -0
- package/dist/core/canvas/render-source.js +73 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -17,7 +17,7 @@ import { matchIndices } from '../../tui/fuzzy.js';
|
|
|
17
17
|
// Span/color primitives live in core/tui/draw.ts (one copy, shared with the
|
|
18
18
|
// `crtr view` host). Re-export the color caps so browse's importers + tests keep
|
|
19
19
|
// resolving them from this module.
|
|
20
|
-
import { clip, assemble, detectColorCaps
|
|
20
|
+
import { clip, assemble, detectColorCaps } from '../../tui/draw.js';
|
|
21
21
|
import { renderMarkdownSpans } from '../../tui/markdown.js';
|
|
22
22
|
export { detectColorCaps };
|
|
23
23
|
// Fixed chrome heights, shared with app.ts so its viewport math never drifts
|
|
@@ -42,9 +42,9 @@ export const PREVIEW_HEIGHT = PREVIEW_BODY + 2;
|
|
|
42
42
|
// one code path per layout and never two hand-maintained copies of the content.
|
|
43
43
|
const SIDE_MIN_COLS = 100; // below this the side column would starve the name column
|
|
44
44
|
const SIDE_PANEL_MIN = 46;
|
|
45
|
-
const SIDE_PANEL_MAX =
|
|
46
|
-
const SIDE_PANEL_RATIO = 0.
|
|
47
|
-
const SIDE_ROWS_MIN =
|
|
45
|
+
const SIDE_PANEL_MAX = 110;
|
|
46
|
+
const SIDE_PANEL_RATIO = 0.55;
|
|
47
|
+
const SIDE_ROWS_MIN = 40; // the row table never shrinks below this
|
|
48
48
|
const SIDE_GUTTER = ' │ '; // rows │ panel — the dim rule that separates them
|
|
49
49
|
export function previewLayout(cols, previewOn) {
|
|
50
50
|
const width = Math.max(20, cols) - 1; // last column is reserved for \x1b[K
|
|
@@ -85,26 +85,12 @@ const RESET = `${ESC}0m`;
|
|
|
85
85
|
const REVERSE = `${ESC}7m`;
|
|
86
86
|
const DIM = `${ESC}2m`;
|
|
87
87
|
const BOLD = `${ESC}1m`;
|
|
88
|
-
//
|
|
89
|
-
// runs in its own process that deliberately never loads pi, so it cannot read the
|
|
90
|
-
// theme. It must therefore lift off BOTH planes it can land on — a bare terminal
|
|
91
|
-
// background when launched from a shell or a tmux keybinding, and the theme's
|
|
92
|
-
// card plane when the attach viewer opens it as a painted popup (see
|
|
93
|
-
// runtime/surface-bg.ts). 236 was chosen against a bare dark terminal alone and
|
|
94
|
-
// vanishes on a card (1.01:1 on gruvbox-material); 239 reads on both (1.6–1.8:1)
|
|
95
|
-
// while leaving body text above 4.5:1 on the band itself.
|
|
96
|
-
const CURSOR_BG = `${ESC}48;5;239m`;
|
|
88
|
+
const CURSOR_BG = `${ESC}48;5;236m`; // subtle dark-gray cursor-row bg (256-color)
|
|
97
89
|
// Basic-16 ANSI fg codes used by the palette.
|
|
98
90
|
const FG_YELLOW = '33';
|
|
99
91
|
const FG_RED = '31';
|
|
100
92
|
const FG_CYAN = '36';
|
|
101
|
-
|
|
102
|
-
// and SGR DIM are deliberately NOT used for text — see their definition in draw.ts.
|
|
103
|
-
// DIM survives for exactly one job: the whole-row base on a non-matching ancestor
|
|
104
|
-
// during a search, where it is a RELATIVE step down from whatever the row already
|
|
105
|
-
// is, not an absolute colour choice.
|
|
106
|
-
const META = `${ESC}${META_FG}m`;
|
|
107
|
-
const RECESSIVE = `${ESC}${RECESSIVE_FG}m`;
|
|
93
|
+
const FG_GRAY = '90'; // bright-black
|
|
108
94
|
const FG_BRIGHT_YELLOW = '93';
|
|
109
95
|
const FG_BRIGHT_CYAN = '96'; // query-match highlight (ties to the cyan search accent)
|
|
110
96
|
const FG_MAGENTA = '35'; // pin star (distinct from the yellow ⚑ asks flag)
|
|
@@ -182,17 +168,17 @@ function tabBar(active, color) {
|
|
|
182
168
|
// Keep the [ ] brackets in BOTH paths so the active tab reads without color.
|
|
183
169
|
return color ? `${BOLD}${ESC}${FG_CYAN}m[ ${t} ]${RESET}` : `${REVERSE}[ ${t} ]${RESET}`;
|
|
184
170
|
}
|
|
185
|
-
return color ? `${
|
|
171
|
+
return color ? `${DIM} ${t} ${RESET}` : ` ${t} `;
|
|
186
172
|
}).join('');
|
|
187
173
|
}
|
|
188
174
|
const EMPTY_HI = new Set();
|
|
189
175
|
/** Name → spans, splitting out the query-matched chars (bold + bright-cyan) so
|
|
190
|
-
* matches are scannable. Non-matched chars carry the row's name style (
|
|
191
|
-
*
|
|
176
|
+
* matches are scannable. Non-matched chars carry the row's name style (dim for
|
|
177
|
+
* terminal status, bold on the cursor row). */
|
|
192
178
|
function nameSpans(name, query, style) {
|
|
193
179
|
const hi = query === '' ? EMPTY_HI : matchIndices(query, name);
|
|
194
180
|
if (hi.size === 0)
|
|
195
|
-
return [{ text: name, style: {
|
|
181
|
+
return [{ text: name, style: { dim: style.dim, bold: style.bold } }];
|
|
196
182
|
const out = [];
|
|
197
183
|
let buf = '';
|
|
198
184
|
let bufHi = false;
|
|
@@ -202,7 +188,7 @@ function nameSpans(name, query, style) {
|
|
|
202
188
|
if (bufHi)
|
|
203
189
|
out.push({ text: buf, style: { fg: FG_BRIGHT_CYAN, bold: true } });
|
|
204
190
|
else
|
|
205
|
-
out.push({ text: buf, style: {
|
|
191
|
+
out.push({ text: buf, style: { dim: style.dim, bold: style.bold } });
|
|
206
192
|
buf = '';
|
|
207
193
|
};
|
|
208
194
|
for (let i = 0; i < name.length; i++) {
|
|
@@ -252,14 +238,14 @@ function statusRail(r) {
|
|
|
252
238
|
function metaCluster(r, now, showCwd) {
|
|
253
239
|
const ctxFg = ctxColorCode(r.ctx_tokens);
|
|
254
240
|
const out = [
|
|
255
|
-
{ text: clipPad(`${r.kind}/${r.mode}`, KM_W), style: { fg:
|
|
241
|
+
{ text: clipPad(`${r.kind}/${r.mode}`, KM_W), style: { fg: FG_GRAY } },
|
|
256
242
|
{ text: COL_GAP },
|
|
257
|
-
{ text: fmtCtx(r.ctx_tokens).padStart(CTX_W), style: { fg: ctxFg
|
|
243
|
+
{ text: fmtCtx(r.ctx_tokens).padStart(CTX_W), style: { fg: ctxFg, dim: ctxFg === undefined } },
|
|
258
244
|
{ text: COL_GAP },
|
|
259
|
-
{ text: relAge(r.created, now).padStart(AGE_W), style: {
|
|
245
|
+
{ text: relAge(r.created, now).padStart(AGE_W), style: { dim: true } },
|
|
260
246
|
];
|
|
261
247
|
if (showCwd) {
|
|
262
|
-
out.push({ text: COL_GAP }, { text: clipPad(`~${baseDir(r.cwd)}`, PROJ_W), style: { fg:
|
|
248
|
+
out.push({ text: COL_GAP }, { text: clipPad(`~${baseDir(r.cwd)}`, PROJ_W), style: { fg: FG_GRAY } });
|
|
263
249
|
}
|
|
264
250
|
return out;
|
|
265
251
|
}
|
|
@@ -309,7 +295,7 @@ padToWidth = false) {
|
|
|
309
295
|
// Hanging: the fault summary travels with the name (the ⚠ + kind
|
|
310
296
|
// label live in the rail). Dim yellow so it reads as secondary detail.
|
|
311
297
|
if (r.hanging != null)
|
|
312
|
-
flags.push({ text: ` · ${faultSummary(r.hanging, now)}`, style: { fg: FG_YELLOW } });
|
|
298
|
+
flags.push({ text: ` · ${faultSummary(r.hanging, now)}`, style: { fg: FG_YELLOW, dim: true } });
|
|
313
299
|
// Pin marker travels just before the name so a starred node is scannable in any
|
|
314
300
|
// tab/sort. Magenta keeps it distinct from the yellow ⚑ asks flag.
|
|
315
301
|
const pinMark = isPinned ? [{ text: '★ ', style: { fg: FG_MAGENTA, bold: true } }] : [];
|
|
@@ -321,14 +307,11 @@ padToWidth = false) {
|
|
|
321
307
|
const fixed = spansWidth([gutter]) + spansWidth(status) + [...treeLead].length + spansWidth(pinMark) + spansWidth(flags);
|
|
322
308
|
const rightW = spansWidth(right);
|
|
323
309
|
const nameMax = Math.max(4, width - fixed - rightW - 1);
|
|
324
|
-
|
|
325
|
-
// is a multiplier on the theme's own foreground, and on a dark theme that lands
|
|
326
|
-
// where the canceled rows became unreadable.
|
|
327
|
-
const nameStyle = { fg: !isCursor && terminal ? RECESSIVE_FG : undefined, bold: isCursor };
|
|
310
|
+
const nameStyle = { dim: !isCursor && terminal, bold: isCursor }; // dim terminal names; bold the cursor row
|
|
328
311
|
const left = [
|
|
329
312
|
gutter,
|
|
330
313
|
...status,
|
|
331
|
-
{ text: treeLead, style: {
|
|
314
|
+
{ text: treeLead, style: { dim: true } },
|
|
332
315
|
...pinMark,
|
|
333
316
|
...nameSpans(clip(r.name, nameMax), query, nameStyle),
|
|
334
317
|
...flags,
|
|
@@ -346,34 +329,32 @@ padToWidth = false) {
|
|
|
346
329
|
}
|
|
347
330
|
return assembleRow(left, right, width, caps.color, lineBase, fill);
|
|
348
331
|
}
|
|
349
|
-
/** The column-header row —
|
|
350
|
-
* the metadata columns read as a table.
|
|
351
|
-
* landmark you scan back to, so it sits at the same weight as the column under it. */
|
|
332
|
+
/** The column-header row — dim labels aligned to the same zones the rows use, so
|
|
333
|
+
* the metadata columns read as a table. */
|
|
352
334
|
function columnHeaderLine(width, showCwd, caps, showMeta = true, padToWidth = false) {
|
|
353
|
-
const hdr = { fg: META_FG, bold: true };
|
|
354
335
|
const left = [
|
|
355
336
|
{ text: ' ' }, // viewer gutter (no label)
|
|
356
|
-
{ text: 'STATUS'.padEnd(STATUS_W), style:
|
|
357
|
-
{ text: 'NAME', style:
|
|
337
|
+
{ text: 'STATUS'.padEnd(STATUS_W), style: { dim: true, bold: true } },
|
|
338
|
+
{ text: 'NAME', style: { dim: true, bold: true } },
|
|
358
339
|
];
|
|
359
340
|
if (!showMeta)
|
|
360
341
|
return assemble(left, width, caps.color, '', padToWidth);
|
|
361
342
|
const right = [
|
|
362
|
-
{ text: 'KIND/MODE'.padEnd(KM_W), style:
|
|
343
|
+
{ text: 'KIND/MODE'.padEnd(KM_W), style: { dim: true, bold: true } },
|
|
363
344
|
{ text: COL_GAP },
|
|
364
|
-
{ text: 'CTX'.padStart(CTX_W), style:
|
|
345
|
+
{ text: 'CTX'.padStart(CTX_W), style: { dim: true, bold: true } },
|
|
365
346
|
{ text: COL_GAP },
|
|
366
|
-
{ text: 'AGE'.padStart(AGE_W), style:
|
|
347
|
+
{ text: 'AGE'.padStart(AGE_W), style: { dim: true, bold: true } },
|
|
367
348
|
];
|
|
368
349
|
if (showCwd)
|
|
369
|
-
right.push({ text: COL_GAP }, { text: 'PROJECT'.padEnd(PROJ_W), style:
|
|
350
|
+
right.push({ text: COL_GAP }, { text: 'PROJECT'.padEnd(PROJ_W), style: { dim: true, bold: true } });
|
|
370
351
|
return assembleRow(left, right, width, caps.color, '', false);
|
|
371
352
|
}
|
|
372
353
|
/** The status line (always present): active profile/cwd scopes · sort mode ·
|
|
373
354
|
* committed filter. The committed-filter indicator lives here now (not its own
|
|
374
355
|
* line) so a filtered view always advertises its query without consuming a header row. */
|
|
375
356
|
function statusLine(state) {
|
|
376
|
-
const dim = (s) => `${
|
|
357
|
+
const dim = (s) => `${DIM}${s}${RESET}`;
|
|
377
358
|
const profile = state.profileScope === undefined ? 'all profiles' : 'current';
|
|
378
359
|
const scope = state.cwdScope === null ? 'all dirs' : baseDir(state.cwdScope);
|
|
379
360
|
const segs = [
|
|
@@ -427,14 +408,14 @@ function markedBlock(marker, text, empty, lines, width, caps, query) {
|
|
|
427
408
|
if (lines <= 0)
|
|
428
409
|
return [];
|
|
429
410
|
const out = [];
|
|
430
|
-
const mark = caps.color ? `${
|
|
411
|
+
const mark = caps.color ? `${DIM}${marker}${RESET}` : marker;
|
|
431
412
|
const inner = Math.max(1, width - 2);
|
|
432
413
|
// No query → render the text as markdown (see blockLines for why a live query
|
|
433
414
|
// keeps the raw windowed path instead). No spacing: this panel is cramped.
|
|
434
415
|
if (query === '') {
|
|
435
416
|
const md = renderMarkdownSpans(text, inner, lines, false);
|
|
436
417
|
if (md.length === 0)
|
|
437
|
-
out.push(`${mark}${
|
|
418
|
+
out.push(`${mark}${DIM}${clip(empty, inner)}${RESET}`);
|
|
438
419
|
else
|
|
439
420
|
for (const spans of md)
|
|
440
421
|
out.push(`${out.length === 0 ? mark : ' '}${assemble(spans, inner, caps.color, '', false)}`);
|
|
@@ -444,7 +425,7 @@ function markedBlock(marker, text, empty, lines, width, caps, query) {
|
|
|
444
425
|
}
|
|
445
426
|
const snippet = previewSnippet(query, text, inner, lines);
|
|
446
427
|
if (snippet.length === 0) {
|
|
447
|
-
out.push(`${mark}${
|
|
428
|
+
out.push(`${mark}${DIM}${clip(empty, inner)}${RESET}`);
|
|
448
429
|
}
|
|
449
430
|
else {
|
|
450
431
|
for (const ln of snippet)
|
|
@@ -454,11 +435,11 @@ function markedBlock(marker, text, empty, lines, width, caps, query) {
|
|
|
454
435
|
out.push('');
|
|
455
436
|
return out;
|
|
456
437
|
}
|
|
457
|
-
/** One `Goal`/`State` briefing line: a
|
|
438
|
+
/** One `Goal`/`State` briefing line: a dim fixed-width label then the text. */
|
|
458
439
|
function recapLine(label, text, width, caps) {
|
|
459
440
|
const lbl = `${label.padEnd(6)}`;
|
|
460
441
|
const body = clip(text, Math.max(1, width - lbl.length));
|
|
461
|
-
return caps.color ? `${
|
|
442
|
+
return caps.color ? `${DIM}${lbl}${RESET}${BOLD}${body}${RESET}` : `${lbl}${body}`;
|
|
462
443
|
}
|
|
463
444
|
/** The bottom preview panel — exactly PREVIEW_HEIGHT lines, ordered by how fast
|
|
464
445
|
* each answers "which node was this, and can I pick it back up?":
|
|
@@ -476,7 +457,7 @@ function recapLine(label, text, width, caps) {
|
|
|
476
457
|
* The optional blocks yield their lines to the literal ones when absent, so the
|
|
477
458
|
* panel is never padded with blanks. Always full height so viewport math holds. */
|
|
478
459
|
function previewPanel(r, width, caps, now, query) {
|
|
479
|
-
const out = [`${
|
|
460
|
+
const out = [`${DIM}${'─'.repeat(width)}${RESET}`];
|
|
480
461
|
if (r === undefined) {
|
|
481
462
|
while (out.length < PREVIEW_HEIGHT)
|
|
482
463
|
out.push('');
|
|
@@ -500,7 +481,7 @@ function previewPanel(r, width, caps, now, query) {
|
|
|
500
481
|
if (r.viewed === true)
|
|
501
482
|
metaPieces.push('\uf06e viewing');
|
|
502
483
|
const metaText = clip(metaPieces.filter((p) => p !== '').join(' · '), Math.max(0, width - 2));
|
|
503
|
-
out.push(caps.color ? `${glyph} ${
|
|
484
|
+
out.push(caps.color ? `${glyph} ${DIM}${metaText}${RESET}` : `${glyph} ${metaText}`);
|
|
504
485
|
const alloc = previewAllocation(r.recap !== undefined, r.report !== undefined);
|
|
505
486
|
// Goal / State — the generated briefing, the fastest "what is this and where is
|
|
506
487
|
// it" in the panel. Absent for a node that never idled long enough to make one.
|
|
@@ -521,14 +502,14 @@ function previewPanel(r, width, caps, now, query) {
|
|
|
521
502
|
const age = relAge(r.report.ts, now);
|
|
522
503
|
const stamp = `${age === '' ? '' : `${age} `}${r.report.kind}`;
|
|
523
504
|
const headline = r.report.heading === '' ? r.report.body : r.report.heading;
|
|
524
|
-
const mark = caps.color ? `${
|
|
505
|
+
const mark = caps.color ? `${DIM}▣ ${RESET}` : '▣ ';
|
|
525
506
|
const inner = Math.max(1, width - 2);
|
|
526
|
-
const stampText = caps.color ? `${ESC}${FG_CYAN}m${stamp}${RESET}${
|
|
507
|
+
const stampText = caps.color ? `${ESC}${FG_CYAN}m${stamp}${RESET}${DIM} · ${RESET}` : `${stamp} · `;
|
|
527
508
|
out.push(`${mark}${stampText}${clip(headline, Math.max(1, inner - stamp.length - 3))}`);
|
|
528
509
|
for (let i = 1; i < alloc.report; i++) {
|
|
529
510
|
const detail = r.report.heading === '' ? '' : r.report.body;
|
|
530
511
|
const ln = previewSnippet('', detail, inner, alloc.report - 1)[i - 1];
|
|
531
|
-
out.push(ln === undefined ? '' : ` ${
|
|
512
|
+
out.push(ln === undefined ? '' : ` ${DIM}${clip(ln.text, inner)}${RESET}`);
|
|
532
513
|
}
|
|
533
514
|
}
|
|
534
515
|
return out;
|
|
@@ -551,16 +532,15 @@ function previewPanel(r, width, caps, now, query) {
|
|
|
551
532
|
// natural size, the largest gives a line back until it fits, and only then does the
|
|
552
533
|
// least-valuable section drop out entirely (report → state → goal → reply; the ask
|
|
553
534
|
// is the recognition anchor and never drops).
|
|
554
|
-
/** A hairline with a small-caps section label — `── GOAL ────────`. The optional
|
|
555
|
-
* suffix rides after the label in its own hue (the report's age · kind stamp).
|
|
556
|
-
* The label is a landmark (metadata grey + bold); the rule itself recedes. */
|
|
535
|
+
/** A dim hairline with a small-caps section label — `── GOAL ────────`. The optional
|
|
536
|
+
* suffix rides after the label in its own hue (the report's age · kind stamp). */
|
|
557
537
|
function sectionRule(label, width, caps, suffix) {
|
|
558
538
|
const suf = suffix === undefined ? '' : `${suffix.text} `;
|
|
559
539
|
const tail = Math.max(0, width - 4 - label.length - suf.length);
|
|
560
540
|
const sufStyled = suffix === undefined
|
|
561
541
|
? ''
|
|
562
|
-
: caps.color ? `${ESC}${suffix.fg}m${suffix.text}${RESET}${
|
|
563
|
-
return `${
|
|
542
|
+
: caps.color ? `${ESC}${suffix.fg}m${suffix.text}${RESET}${DIM} ` : `${suffix.text} `;
|
|
543
|
+
return `${DIM}── ${BOLD}${label}${RESET}${DIM} ${sufStyled}${'─'.repeat(tail)}${RESET}`;
|
|
564
544
|
}
|
|
565
545
|
/** A marked body block with a hanging indent: `marker` leads the first line, `cont`
|
|
566
546
|
* every wrapped line after it (`▌ ` keeps the accent bar running down a quote).
|
|
@@ -581,7 +561,7 @@ function blockLines(marker, cont, markerStyle, text, empty, maxLines, width, cap
|
|
|
581
561
|
if (md.length === 0) {
|
|
582
562
|
if (empty === undefined)
|
|
583
563
|
return [];
|
|
584
|
-
return [`${mk(marker, markerStyle)}${
|
|
564
|
+
return [`${mk(marker, markerStyle)}${DIM}${clip(empty, inner)}${RESET}`];
|
|
585
565
|
}
|
|
586
566
|
return md.map((spans, i) => `${mk(i === 0 ? marker : cont, markerStyle)}${assemble(spans, inner, caps.color, '', false)}`);
|
|
587
567
|
}
|
|
@@ -589,204 +569,323 @@ function blockLines(marker, cont, markerStyle, text, empty, maxLines, width, cap
|
|
|
589
569
|
if (snippet.length === 0) {
|
|
590
570
|
if (empty === undefined)
|
|
591
571
|
return [];
|
|
592
|
-
return [`${mk(marker, markerStyle)}${
|
|
572
|
+
return [`${mk(marker, markerStyle)}${DIM}${clip(empty, inner)}${RESET}`];
|
|
593
573
|
}
|
|
594
574
|
return snippet.map((ln, i) => `${mk(i === 0 ? marker : cont, markerStyle)}${snippetLine(ln, inner, caps, bodyStyle)}`);
|
|
595
575
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
576
|
+
// ── The chat transcript ───────────────────────────────────────────────
|
|
577
|
+
//
|
|
578
|
+
// The panel's centrepiece: the node's conversation painted the way the attach viewer
|
|
579
|
+
// paints it — a coloured left bar per turn, a small-caps role label, and the message
|
|
580
|
+
// body with its markdown honoured. It serves two jobs from one renderer:
|
|
581
|
+
//
|
|
582
|
+
// no query — the RECENT tail, newest turn last, so the panel reads as "where did
|
|
583
|
+
// this conversation get to".
|
|
584
|
+
// a query — only the turns that CONTAIN the query, oldest match first, with every
|
|
585
|
+
// occurrence highlighted and the skipped turns counted in a dim elision
|
|
586
|
+
// marker. Every visible line is then evidence for the search.
|
|
587
|
+
/** Rendered lines a single turn may occupy before it is windowed. Bounded by the
|
|
588
|
+
* per-message cap in readTranscript, so this is a ceiling, not a budget. */
|
|
589
|
+
const TURN_RENDER_MAX = 400;
|
|
590
|
+
/** Rendered lines kept above the first match when a matched turn must be windowed. */
|
|
591
|
+
const TURN_MATCH_LEAD = 1;
|
|
592
|
+
/** Lines a single turn may take when several turns share the panel. */
|
|
593
|
+
const TURN_SOFT_CAP = 14;
|
|
594
|
+
/** The matched-run style. `reverse` is structural SGR, so a search match stays
|
|
595
|
+
* visible with hue disabled — which the fg-only row highlight cannot manage. */
|
|
596
|
+
const MATCH_STYLE = { fg: FG_BRIGHT_CYAN, bold: true, reverse: true };
|
|
597
|
+
function spansText(spans) {
|
|
598
|
+
let out = '';
|
|
599
|
+
for (const s of spans)
|
|
600
|
+
out += s.text;
|
|
601
|
+
return out;
|
|
606
602
|
}
|
|
607
|
-
/**
|
|
608
|
-
*
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
603
|
+
/** Re-style every case-insensitive occurrence of `query` inside already-rendered
|
|
604
|
+
* spans, splitting runs at the match edges. Working on the RENDERED text is what
|
|
605
|
+
* lets a transcript keep its markdown and still highlight: the markup is already
|
|
606
|
+
* gone, so no column can drift out from under a highlight. */
|
|
607
|
+
export function highlightSpans(spans, query) {
|
|
608
|
+
if (query === '')
|
|
609
|
+
return { spans, hits: 0 };
|
|
610
|
+
const text = spansText(spans);
|
|
611
|
+
const lower = text.toLowerCase();
|
|
612
|
+
const q = query.toLowerCase();
|
|
613
|
+
if (lower.indexOf(q) < 0)
|
|
614
|
+
return { spans, hits: 0 };
|
|
615
|
+
const hi = new Uint8Array(text.length);
|
|
616
|
+
let hits = 0;
|
|
617
|
+
for (let i = lower.indexOf(q); i >= 0; i = lower.indexOf(q, i + q.length)) {
|
|
618
|
+
hits++;
|
|
619
|
+
for (let j = i; j < i + q.length; j++)
|
|
620
|
+
hi[j] = 1;
|
|
621
|
+
}
|
|
617
622
|
const out = [];
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
for (let
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
623
|
+
let col = 0;
|
|
624
|
+
for (const s of spans) {
|
|
625
|
+
let buf = '';
|
|
626
|
+
let bufHi = hi[col] === 1;
|
|
627
|
+
for (let k = 0; k < s.text.length; k++) {
|
|
628
|
+
const h = hi[col] === 1;
|
|
629
|
+
if (h !== bufHi) {
|
|
630
|
+
if (buf !== '')
|
|
631
|
+
out.push({ text: buf, style: bufHi ? MATCH_STYLE : s.style });
|
|
632
|
+
buf = '';
|
|
633
|
+
bufHi = h;
|
|
634
|
+
}
|
|
635
|
+
buf += s.text[k];
|
|
636
|
+
col++;
|
|
629
637
|
}
|
|
638
|
+
if (buf !== '')
|
|
639
|
+
out.push({ text: buf, style: bufHi ? MATCH_STYLE : s.style });
|
|
630
640
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
641
|
+
return { spans: out, hits };
|
|
642
|
+
}
|
|
643
|
+
const TURN_BAR = '▌ ';
|
|
644
|
+
/** Paint one turn: `▌ YOU` / `▌ AGENT` then the body, every line carrying the bar so
|
|
645
|
+
* a turn stays one contiguous block. The user's bar is the cyan accent and the
|
|
646
|
+
* agent's is dim gray — the same weight distinction the row table already uses
|
|
647
|
+
* between what you said and what the runtime produced. */
|
|
648
|
+
function turnBlock(turn, query, maxLines, width, caps) {
|
|
649
|
+
if (maxLines < 2)
|
|
650
|
+
return { lines: [], hits: 0, truncated: true };
|
|
651
|
+
const user = turn.role === 'user';
|
|
652
|
+
const barStyle = user ? { fg: FG_BRIGHT_CYAN, bold: true } : { fg: FG_GRAY };
|
|
653
|
+
const inner = Math.max(1, width - TURN_BAR.length);
|
|
654
|
+
const bar = () => assemble([{ text: TURN_BAR, style: barStyle }], TURN_BAR.length, caps.color, '', true);
|
|
655
|
+
const out = [
|
|
656
|
+
assemble([{ text: TURN_BAR, style: barStyle }, { text: user ? 'YOU' : 'AGENT', style: { ...barStyle, bold: true } }], width, caps.color, '', false),
|
|
657
|
+
];
|
|
658
|
+
const body = renderMarkdownSpans(turn.text, inner, TURN_RENDER_MAX, false, user ? {} : { dim: true });
|
|
659
|
+
const marked = body.map((spans) => highlightSpans(spans, query));
|
|
660
|
+
const hits = marked.reduce((n, m) => n + m.hits, 0);
|
|
661
|
+
// Window the body when it outgrows its slice of the panel. Searching, the window
|
|
662
|
+
// opens on the first match (that is the only line the search has to prove); idle,
|
|
663
|
+
// it opens at the top of the message.
|
|
664
|
+
const budget = maxLines - 1;
|
|
665
|
+
let start = 0;
|
|
666
|
+
if (marked.length > budget && hits > 0) {
|
|
667
|
+
start = Math.max(0, marked.findIndex((m) => m.hits > 0) - TURN_MATCH_LEAD);
|
|
668
|
+
start = Math.min(start, marked.length - budget);
|
|
669
|
+
}
|
|
670
|
+
const shown = marked.slice(start, start + budget);
|
|
671
|
+
for (const m of shown)
|
|
672
|
+
out.push(`${bar()}${assemble(m.spans, inner, caps.color, '', false)}`);
|
|
673
|
+
// An honest edge: a windowed body must not read as the whole message. The marker
|
|
674
|
+
// costs a body line, so it is only worth drawing when a body line survives it.
|
|
675
|
+
const cutHead = start > 0;
|
|
676
|
+
const cutTail = start + shown.length < marked.length;
|
|
677
|
+
if (cutHead && shown.length > 1)
|
|
678
|
+
out[1] = `${bar()}${DIM}⋯${RESET}`;
|
|
679
|
+
if (cutTail && shown.length > (cutHead ? 2 : 1))
|
|
680
|
+
out[out.length - 1] = `${bar()}${DIM}⋯${RESET}`;
|
|
681
|
+
return { lines: out, hits, truncated: cutHead || cutTail };
|
|
682
|
+
}
|
|
683
|
+
/** The dim `⋯ n turns` marker standing in for turns the search skipped over. */
|
|
684
|
+
function elisionLine(n, width, caps) {
|
|
685
|
+
return assemble([{ text: ` ⋯ ${n} ${n === 1 ? 'turn' : 'turns'}`, style: { dim: true } }], width, caps.color, '', false);
|
|
686
|
+
}
|
|
687
|
+
/** Does this turn contain the query as a literal, case-insensitive substring?
|
|
688
|
+
* Scattered letters are not a transcript hit — the same rule the row preview uses. */
|
|
689
|
+
function turnMatches(turn, query) {
|
|
690
|
+
return query !== '' && turn.text.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
|
691
|
+
}
|
|
692
|
+
/** How many turns contain the query — the count the section rule advertises. */
|
|
693
|
+
export function transcriptHitTurns(turns, query) {
|
|
694
|
+
return turns.reduce((n, t) => n + (turnMatches(t, query) ? 1 : 0), 0);
|
|
695
|
+
}
|
|
696
|
+
/** The transcript body, at most `height` lines. With a query it shows the matching
|
|
697
|
+
* turns from the oldest match forward; without one it shows the newest turns,
|
|
698
|
+
* bottom-anchored so the latest turn is the last line — the chat reading order. */
|
|
699
|
+
function transcriptLines(turns, query, width, height, caps) {
|
|
700
|
+
if (height <= 0 || turns.length === 0)
|
|
701
|
+
return [];
|
|
702
|
+
const hitIdx = query === '' ? [] : turns.map((t, i) => (turnMatches(t, query) ? i : -1)).filter((i) => i >= 0);
|
|
703
|
+
// Searching with hits: forward from the oldest match, eliding the gaps.
|
|
704
|
+
if (hitIdx.length > 0) {
|
|
705
|
+
const out = [];
|
|
706
|
+
let prev = -1;
|
|
707
|
+
for (const i of hitIdx) {
|
|
708
|
+
const lead = out.length === 0 ? 0 : 1; // blank separator between turns
|
|
709
|
+
const left = height - out.length - lead;
|
|
710
|
+
if (left < 3)
|
|
711
|
+
break; // a header over one elision marker proves nothing
|
|
712
|
+
const { lines } = turnBlock(turns[i], query, Math.min(left, TURN_SOFT_CAP), width, caps);
|
|
713
|
+
if (lines.length === 0)
|
|
714
|
+
break;
|
|
715
|
+
const gap = prev < 0 ? i : i - prev - 1;
|
|
716
|
+
const marker = gap > 0 && out.length + lead + lines.length + 1 <= height;
|
|
717
|
+
if (lead > 0)
|
|
718
|
+
out.push('');
|
|
719
|
+
if (marker)
|
|
720
|
+
out.push(elisionLine(gap, width, caps));
|
|
721
|
+
out.push(...lines);
|
|
722
|
+
prev = i;
|
|
723
|
+
}
|
|
724
|
+
// Slack goes to the turns that FOLLOW the last match, painted in full, so the
|
|
725
|
+
// panel reads on as a conversation instead of stopping dead at the evidence.
|
|
726
|
+
let last = prev;
|
|
727
|
+
for (let i = prev + 1; i < turns.length; i++) {
|
|
728
|
+
const left = height - out.length - 1;
|
|
729
|
+
if (left < 3)
|
|
730
|
+
break;
|
|
731
|
+
const { lines, truncated } = turnBlock(turns[i], query, Math.min(left, TURN_SOFT_CAP), width, caps);
|
|
732
|
+
if (lines.length === 0 || truncated)
|
|
733
|
+
break;
|
|
734
|
+
out.push('', ...lines);
|
|
735
|
+
last = i;
|
|
736
|
+
}
|
|
737
|
+
if (last >= 0 && last < turns.length - 1 && out.length < height) {
|
|
738
|
+
out.push(elisionLine(turns.length - 1 - last, width, caps));
|
|
739
|
+
}
|
|
740
|
+
return out.slice(0, height);
|
|
741
|
+
}
|
|
742
|
+
// No query (or a query that only matched the node's name/kind/id): the tail.
|
|
743
|
+
// Built newest-first so the budget is spent on recent turns, then reversed.
|
|
744
|
+
const blocks = [];
|
|
745
|
+
let used = 0;
|
|
746
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
747
|
+
const newest = blocks.length === 0;
|
|
748
|
+
const lead = newest ? 0 : 1;
|
|
749
|
+
const left = height - used - lead;
|
|
750
|
+
if (left < 3)
|
|
751
|
+
break;
|
|
752
|
+
const { lines, truncated } = turnBlock(turns[i], '', Math.min(left, TURN_SOFT_CAP), width, caps);
|
|
753
|
+
if (lines.length === 0)
|
|
754
|
+
break;
|
|
755
|
+
// Only the newest turn may be shown cut — an older one that no longer fits is
|
|
756
|
+
// dropped whole rather than left as a header over an elision marker, which
|
|
757
|
+
// reads as a message the panel refused to show.
|
|
758
|
+
if (truncated && !newest)
|
|
759
|
+
break;
|
|
760
|
+
if (lead > 0)
|
|
761
|
+
blocks.push(['']);
|
|
762
|
+
blocks.push(lines);
|
|
763
|
+
used += lines.length + lead;
|
|
634
764
|
}
|
|
765
|
+
return blocks.reverse().flat().slice(-height);
|
|
766
|
+
}
|
|
767
|
+
/** The turns the panel paints. A node that was never revived has no session, so its
|
|
768
|
+
* spawn prompt and last reply stand in — the panel then still reads as a chat
|
|
769
|
+
* rather than going blank, and a remote canvas (no local session file) keeps a body. */
|
|
770
|
+
function panelTurns(r) {
|
|
771
|
+
const t = r.transcript;
|
|
772
|
+
if (t !== undefined && t.length > 0)
|
|
773
|
+
return t;
|
|
774
|
+
const out = [];
|
|
775
|
+
if (r.goal !== undefined && r.goal !== '')
|
|
776
|
+
out.push({ role: 'user', text: r.goal });
|
|
777
|
+
if (r.lastAssistant !== undefined && r.lastAssistant !== '')
|
|
778
|
+
out.push({ role: 'assistant', text: r.lastAssistant });
|
|
635
779
|
return out;
|
|
636
780
|
}
|
|
637
|
-
/**
|
|
638
|
-
*
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
781
|
+
/** Lines the GOAL and STATE briefings each get. They are one-sentence summaries by
|
|
782
|
+
* construction, so a small fixed budget leaves the chat everything else. */
|
|
783
|
+
const BRIEFING_LINES = 4;
|
|
784
|
+
/** The panel's one metadata line. Deliberately ONE line: the row the cursor sits on
|
|
785
|
+
* already carries this node's status word, ask flag, fault and viewer cues, so a
|
|
786
|
+
* status line and a four-cell facts grid here would spend six of the panel's rows
|
|
787
|
+
* repeating what is on screen a few columns to the left. What is left is what the
|
|
788
|
+
* row table gave up in side mode — kind/mode, project, age, ctx. */
|
|
789
|
+
function panelFactsLine(r, width, now, caps) {
|
|
790
|
+
const ctxFg = ctxColorCode(r.ctx_tokens);
|
|
791
|
+
const dot = () => ({ text: ' · ', style: { dim: true } });
|
|
792
|
+
const spans = [
|
|
793
|
+
{ text: `${r.kind}/${r.mode}`, style: { fg: FG_GRAY } },
|
|
794
|
+
dot(),
|
|
795
|
+
{ text: `~${baseDir(r.cwd)}`, style: { fg: FG_GRAY } },
|
|
796
|
+
dot(),
|
|
797
|
+
{ text: relAge(r.created, now), style: { dim: true } },
|
|
798
|
+
dot(),
|
|
799
|
+
{ text: `ctx ${fmtCtx(r.ctx_tokens)}`, style: { fg: ctxFg, dim: ctxFg === undefined } },
|
|
800
|
+
];
|
|
654
801
|
return assemble(spans, width, caps.color, '', false);
|
|
655
802
|
}
|
|
656
803
|
/** The panel's title, drawn on the column-header row so the header rule crosses
|
|
657
804
|
* beneath it: the pinned star, the node name in bold, its short id flush right. */
|
|
658
805
|
function panelTitleLine(r, width, caps, isPinned) {
|
|
659
806
|
if (r === undefined)
|
|
660
|
-
return assemble([{ text: 'PREVIEW', style: {
|
|
807
|
+
return assemble([{ text: 'PREVIEW', style: { dim: true, bold: true } }], width, caps.color, '', false);
|
|
661
808
|
const left = [];
|
|
662
809
|
if (isPinned)
|
|
663
810
|
left.push({ text: '★ ', style: { fg: FG_MAGENTA, bold: true } });
|
|
664
811
|
const id = r.node_id.slice(0, 8);
|
|
665
812
|
left.push({ text: clip(r.name, Math.max(4, width - id.length - 2 - spansWidth(left))), style: { bold: true } });
|
|
666
|
-
return assembleRow(left, [{ text: id, style: {
|
|
813
|
+
return assembleRow(left, [{ text: id, style: { dim: true } }], width, caps.color, '', false);
|
|
667
814
|
}
|
|
668
|
-
/** The side panel's body, at most `height` lines
|
|
669
|
-
|
|
670
|
-
|
|
815
|
+
/** The side panel's body, at most `height` lines. Three exclusive shapes:
|
|
816
|
+
*
|
|
817
|
+
* searching — the panel IS a chat transcript: the turns that literally contain
|
|
818
|
+
* the query, oldest match first, every occurrence highlighted.
|
|
819
|
+
* report — the node's newest pushed report in full (toggled with `V`).
|
|
820
|
+
* default — goal on top, the recent conversation in the middle, state pinned to
|
|
821
|
+
* the bottom edge. That is the vertical order the live chat surface
|
|
822
|
+
* uses, so the preview reads as a small copy of the conversation it
|
|
823
|
+
* is previewing instead of a different presentation of the same node.
|
|
824
|
+
*/
|
|
825
|
+
function sidePanelBody(r, width, height, caps, now, query, showReport) {
|
|
671
826
|
if (height <= 0)
|
|
672
|
-
return
|
|
673
|
-
if (r === undefined)
|
|
674
|
-
|
|
675
|
-
|
|
827
|
+
return [];
|
|
828
|
+
if (r === undefined)
|
|
829
|
+
return [`${DIM}(no selection)${RESET}`];
|
|
830
|
+
// ── searching ── the transcript takes the whole panel. The goal/state briefing is
|
|
831
|
+
// a summary, and a summary cannot show you WHERE your term appears.
|
|
832
|
+
if (query !== '') {
|
|
833
|
+
const turns = panelTurns(r);
|
|
834
|
+
const hits = transcriptHitTurns(turns, query);
|
|
835
|
+
const out = [sectionRule('TRANSCRIPT', width, caps, hits > 0
|
|
836
|
+
? { text: `${hits} ${hits === 1 ? 'turn' : 'turns'}`, fg: FG_BRIGHT_CYAN }
|
|
837
|
+
: { text: 'matched on name', fg: FG_GRAY })];
|
|
838
|
+
const body = transcriptLines(turns, query, width, height - 1, caps);
|
|
839
|
+
out.push(...(body.length === 0 ? [`${DIM}(no conversation yet)${RESET}`] : body));
|
|
840
|
+
return out.slice(0, height);
|
|
676
841
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
out.push('');
|
|
682
|
-
const v = resolveNodeVisual(r.status, {
|
|
683
|
-
streaming: r.streaming === true,
|
|
684
|
-
frozen: isFrozenRow(r),
|
|
685
|
-
hanging: r.hanging ?? null,
|
|
686
|
-
});
|
|
687
|
-
const accent = { fg: v.color };
|
|
688
|
-
const secs = [];
|
|
689
|
-
if (r.recap !== undefined && r.recap.goal !== '') {
|
|
690
|
-
const goal = r.recap.goal;
|
|
691
|
-
secs.push({ label: 'GOAL', drop: 2, min: 1, want: 4,
|
|
692
|
-
lines: (n) => blockLines('▌ ', '▌ ', accent, goal, undefined, n, width, caps, '', { bold: true }) });
|
|
693
|
-
}
|
|
694
|
-
if (r.recap !== undefined && r.recap.state !== '') {
|
|
695
|
-
const stateText = r.recap.state;
|
|
696
|
-
secs.push({ label: 'STATE', drop: 3, min: 1, want: 5,
|
|
697
|
-
lines: (n) => blockLines('▌ ', '▌ ', accent, stateText, undefined, n, width, caps, '', { bold: true }) });
|
|
698
|
-
}
|
|
699
|
-
// Under a live query the ask becomes the WINDOWED match from anywhere in the
|
|
700
|
-
// conversation, highlighted — the one thing a search result has to show.
|
|
701
|
-
const askText = query !== '' ? promptText(r) : (r.goal ?? '');
|
|
702
|
-
secs.push({ label: 'ASK', drop: 0, min: 2, want: 6,
|
|
703
|
-
lines: (n) => blockLines('❯ ', ' ', { fg: FG_BRIGHT_CYAN, bold: true }, askText, '(no spawn prompt)', n, width, caps, query) });
|
|
704
|
-
const reply = r.lastAssistant ?? '';
|
|
705
|
-
secs.push({ label: 'REPLY', drop: 1, min: 2, want: 9,
|
|
706
|
-
lines: (n) => blockLines('↩ ', ' ', { fg: META_FG }, reply, '(no reply yet)', n, width, caps, '') });
|
|
707
|
-
if (r.report !== undefined) {
|
|
708
|
-
const rep = r.report;
|
|
709
|
-
const age = relAge(rep.ts, now);
|
|
710
|
-
secs.push({
|
|
711
|
-
label: 'REPORT', drop: 4, min: 2, want: 6,
|
|
712
|
-
suffix: { text: `${age === '' ? '' : `${age} · `}${rep.kind}`, fg: FG_CYAN },
|
|
713
|
-
lines: (n) => {
|
|
714
|
-
const headline = rep.heading === '' ? rep.body : rep.heading;
|
|
715
|
-
const head = blockLines('▣ ', ' ', { fg: FG_CYAN, bold: true }, headline, undefined, Math.min(2, n), width, caps, '', { bold: true });
|
|
716
|
-
if (rep.heading !== '' && n > head.length) {
|
|
717
|
-
head.push(...blockLines(' ', ' ', undefined, rep.body, undefined, n - head.length, width, caps, '', { fg: META_FG }));
|
|
718
|
-
}
|
|
719
|
-
return head;
|
|
720
|
-
},
|
|
842
|
+
const rep = r.report;
|
|
843
|
+
const accentOf = () => {
|
|
844
|
+
const v = resolveNodeVisual(r.status, {
|
|
845
|
+
streaming: r.streaming === true, frozen: isFrozenRow(r), hanging: r.hanging ?? null,
|
|
721
846
|
});
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
for (const s of list)
|
|
735
|
-
alloc.set(s.label, s.want);
|
|
736
|
-
for (;;) {
|
|
737
|
-
if (need() <= avail)
|
|
738
|
-
break;
|
|
739
|
-
let biggest;
|
|
740
|
-
for (const s of list) {
|
|
741
|
-
const a = alloc.get(s.label);
|
|
742
|
-
if (a > s.min && (biggest === undefined || a > alloc.get(biggest.label)))
|
|
743
|
-
biggest = s;
|
|
744
|
-
}
|
|
745
|
-
if (biggest === undefined)
|
|
746
|
-
break;
|
|
747
|
-
alloc.set(biggest.label, alloc.get(biggest.label) - 1);
|
|
748
|
-
}
|
|
749
|
-
if (need() <= avail || list.length <= 1)
|
|
750
|
-
break;
|
|
751
|
-
let victim = list[0];
|
|
752
|
-
for (const s of list)
|
|
753
|
-
if (s.drop > victim.drop)
|
|
754
|
-
victim = s;
|
|
755
|
-
list = list.filter((s) => s !== victim);
|
|
756
|
-
}
|
|
757
|
-
// Render, then hand any room the content did not use to the blocks that always
|
|
758
|
-
// have more to show (the live tail first, then the ask). Growing a block by at
|
|
759
|
-
// most the measured surplus keeps every later section on screen.
|
|
760
|
-
const rendered = list.map((s) => s.lines(alloc.get(s.label) ?? s.min));
|
|
761
|
-
let spare = avail - (rendered.reduce((n, b) => n + b.length + 1 + gap, 0) - gap);
|
|
762
|
-
for (const key of ['REPLY', 'ASK']) {
|
|
763
|
-
if (spare <= 0)
|
|
764
|
-
break;
|
|
765
|
-
const i = list.findIndex((s) => s.label === key);
|
|
766
|
-
if (i < 0)
|
|
767
|
-
continue;
|
|
768
|
-
const grown = list[i].lines((alloc.get(key) ?? 0) + spare);
|
|
769
|
-
const delta = grown.length - rendered[i].length;
|
|
770
|
-
if (delta > 0) {
|
|
771
|
-
rendered[i] = grown;
|
|
772
|
-
spare -= delta;
|
|
847
|
+
return { fg: v.color };
|
|
848
|
+
};
|
|
849
|
+
// ── report ── what the node deliberately DELIVERED, at full length. Reports are
|
|
850
|
+
// written to be read, so this one gets the panel rather than a two-line teaser.
|
|
851
|
+
if (showReport && rep !== undefined) {
|
|
852
|
+
const age = relAge(rep.ts, now);
|
|
853
|
+
const out = [sectionRule('REPORT', width, caps, { text: `${age === '' ? '' : `${age} · `}${rep.kind}`, fg: FG_CYAN })];
|
|
854
|
+
const headline = rep.heading === '' ? rep.body : rep.heading;
|
|
855
|
+
out.push(...blockLines('▣ ', ' ', { fg: FG_CYAN, bold: true }, headline, undefined, 3, width, caps, '', { bold: true }));
|
|
856
|
+
if (rep.heading !== '' && rep.body !== '') {
|
|
857
|
+
out.push('');
|
|
858
|
+
out.push(...blockLines(' ', ' ', undefined, rep.body, undefined, height - out.length, width, caps, ''));
|
|
773
859
|
}
|
|
860
|
+
return out.slice(0, height);
|
|
861
|
+
}
|
|
862
|
+
// ── default ── goal, chat, state.
|
|
863
|
+
const out = [panelFactsLine(r, width, now, caps), ''];
|
|
864
|
+
const accent = accentOf();
|
|
865
|
+
const briefing = (label, text) => [
|
|
866
|
+
sectionRule(label, width, caps),
|
|
867
|
+
...blockLines('▌ ', '▌ ', accent, text, undefined, BRIEFING_LINES, width, caps, '', { bold: true }),
|
|
868
|
+
];
|
|
869
|
+
const goal = r.recap?.goal ?? '';
|
|
870
|
+
if (goal !== '')
|
|
871
|
+
out.push(...briefing('GOAL', goal), '');
|
|
872
|
+
// State is measured before the chat is drawn so the chat can claim exactly the
|
|
873
|
+
// slack that is left and state still lands on the panel's bottom edge.
|
|
874
|
+
const stateText = r.recap?.state ?? '';
|
|
875
|
+
const stateBlock = stateText === '' ? [] : [...briefing('STATE', stateText)];
|
|
876
|
+
const reserved = stateBlock.length === 0 ? 0 : stateBlock.length + 1;
|
|
877
|
+
const chatH = height - out.length - reserved - 1 /* the CHAT rule */;
|
|
878
|
+
if (chatH >= 2) {
|
|
879
|
+
out.push(sectionRule('CHAT', width, caps));
|
|
880
|
+
const body = transcriptLines(panelTurns(r), '', width, chatH, caps);
|
|
881
|
+
out.push(...(body.length === 0 ? [`${DIM}(no conversation yet)${RESET}`] : body));
|
|
774
882
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (body.length === 0 || out.length >= height)
|
|
778
|
-
continue;
|
|
779
|
-
out.push(sectionRule(list[i].label, width, caps, list[i].suffix));
|
|
780
|
-
for (const l of body)
|
|
781
|
-
out.push(l);
|
|
782
|
-
if (gap > 0)
|
|
883
|
+
if (stateBlock.length > 0) {
|
|
884
|
+
while (out.length < height - stateBlock.length)
|
|
783
885
|
out.push('');
|
|
886
|
+
out.push(...stateBlock);
|
|
784
887
|
}
|
|
785
|
-
|
|
786
|
-
out.pop();
|
|
787
|
-
while (out.length > 0 && out[out.length - 1] === '')
|
|
788
|
-
out.pop();
|
|
789
|
-
return out;
|
|
888
|
+
return out.slice(0, height);
|
|
790
889
|
}
|
|
791
890
|
/** The keybinding help overlay — a full-screen cheatsheet that replaces the body
|
|
792
891
|
* while `?` is held open. Grouped by intent so the whole key surface is legible in
|
|
@@ -811,7 +910,8 @@ const HELP_ROWS = [
|
|
|
811
910
|
['', 'c', 'toggle cwd scope'],
|
|
812
911
|
['', 'P', 'toggle profile scope'],
|
|
813
912
|
['', 'r', 'toggle residents-only'],
|
|
814
|
-
['', 'v', 'toggle preview (goal
|
|
913
|
+
['', 'v', 'toggle preview (goal, chat, state)'],
|
|
914
|
+
['', 'V', 'in the preview: swap chat for the newest report'],
|
|
815
915
|
['Quit', 'q / Esc', ''],
|
|
816
916
|
];
|
|
817
917
|
function renderHelp(size, caps) {
|
|
@@ -821,7 +921,7 @@ function renderHelp(size, caps) {
|
|
|
821
921
|
const lines = [];
|
|
822
922
|
lines.push(`${BOLD}Canvas navigator — keys${RESET}`);
|
|
823
923
|
lines.push('');
|
|
824
|
-
const dim = (s) => `${
|
|
924
|
+
const dim = (s) => `${DIM}${s}${RESET}`;
|
|
825
925
|
for (const [group, keys, desc] of HELP_ROWS) {
|
|
826
926
|
const g = group.padEnd(6);
|
|
827
927
|
const k = keys.padEnd(12);
|
|
@@ -855,8 +955,8 @@ export function renderFrame(state, size, caps = { color: false, color256: false
|
|
|
855
955
|
const parts = statusCounts(state.tree);
|
|
856
956
|
const summaryPlain = parts.map((p) => `${STATUS_GLYPH[p.status]}${p.count}`).join(' ');
|
|
857
957
|
const summaryColored = caps.color
|
|
858
|
-
? parts.map((p) => `${ESC}${STATUS_COLOR[p.status]}m${STATUS_GLYPH[p.status]}${RESET}${
|
|
859
|
-
: `${
|
|
958
|
+
? parts.map((p) => `${ESC}${STATUS_COLOR[p.status]}m${STATUS_GLYPH[p.status]}${RESET}${DIM}${p.count}${RESET}`).join(' ')
|
|
959
|
+
: `${DIM}${summaryPlain}${RESET}`;
|
|
860
960
|
const gap = width - titlePlainLen - summaryPlain.length;
|
|
861
961
|
lines.push(gap > 1 ? `${title}${' '.repeat(gap)}${summaryColored}` : title);
|
|
862
962
|
// line 2 — tab bar.
|
|
@@ -877,7 +977,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
|
|
|
877
977
|
const layout = previewLayout(cols, previewOn);
|
|
878
978
|
const side = layout.mode === 'side';
|
|
879
979
|
const rowsW = side ? layout.rowsW : width;
|
|
880
|
-
const gutter = `${
|
|
980
|
+
const gutter = `${DIM}${SIDE_GUTTER}${RESET}`;
|
|
881
981
|
const sel = state.visible[state.cursor];
|
|
882
982
|
const selRow = sel !== undefined ? state.tree.nodes.get(sel.id)?.row : undefined;
|
|
883
983
|
// column header (table labels, plus the panel title in side mode) + separator.
|
|
@@ -887,8 +987,8 @@ export function renderFrame(state, size, caps = { color: false, color256: false
|
|
|
887
987
|
? `${colHeader}${gutter}${panelTitleLine(selRow, layout.panelW, caps, sel !== undefined && state.pinned.has(sel.id))}`
|
|
888
988
|
: colHeader);
|
|
889
989
|
lines.push(side
|
|
890
|
-
? `${
|
|
891
|
-
: `${
|
|
990
|
+
? `${DIM}${'─'.repeat(rowsW)}─┼─${'─'.repeat(layout.panelW)}${RESET}`
|
|
991
|
+
: `${DIM}${'─'.repeat(width)}${RESET}`);
|
|
892
992
|
// body — windowed visible rows, leaving room for the (optional) bottom panel.
|
|
893
993
|
const head = lines.length; // === headerHeight(state.search)
|
|
894
994
|
const previewH = layout.mode === 'bottom' ? PREVIEW_HEIGHT : 0;
|
|
@@ -897,11 +997,11 @@ export function renderFrame(state, size, caps = { color: false, color256: false
|
|
|
897
997
|
const end = Math.min(state.visible.length, start + viewport);
|
|
898
998
|
// In side mode each body line is `<row><rule><panel line>`, so the rows are padded
|
|
899
999
|
// to an exact width and the panel body is rendered once for the whole column.
|
|
900
|
-
const panel = side ? sidePanelBody(selRow, layout.panelW, viewport, caps, now, state.query) : [];
|
|
1000
|
+
const panel = side ? sidePanelBody(selRow, layout.panelW, viewport, caps, now, state.query, state.showReport === true) : [];
|
|
901
1001
|
const compose = (row, i) => (side ? `${row}${gutter}${panel[i] ?? ''}` : row);
|
|
902
1002
|
const blankRow = side ? ' '.repeat(rowsW) : '';
|
|
903
1003
|
if (state.visible.length === 0) {
|
|
904
|
-
lines.push(`${
|
|
1004
|
+
lines.push(`${DIM} (no nodes match this view)${RESET}`);
|
|
905
1005
|
for (let i = 1; i < viewport; i++)
|
|
906
1006
|
lines.push('');
|
|
907
1007
|
}
|
|
@@ -937,8 +1037,8 @@ export function renderFrame(state, size, caps = { color: false, color256: false
|
|
|
937
1037
|
? '⏎ commit Esc cancel ⌫ delete'
|
|
938
1038
|
: state.readOnly === true
|
|
939
1039
|
? '↑↓ move →/← tree / search p pin v preview Tab tabs ? more q quit — read-only remote canvas'
|
|
940
|
-
: `${hangingHint}↑↓ move ⏎ open O bg / search p pin v preview m msg x close ? more q quit`;
|
|
941
|
-
lines.push(`${
|
|
1040
|
+
: `${hangingHint}${selRow?.report !== undefined && side && state.query === '' ? (state.showReport === true ? 'V chat ' : 'V report ') : ''}↑↓ move ⏎ open O bg / search p pin v preview m msg x close ? more q quit`;
|
|
1041
|
+
lines.push(`${DIM}${clip(footer, width)}${RESET}`);
|
|
942
1042
|
}
|
|
943
1043
|
// Assemble: home, each line cleared to EOL, then clear below.
|
|
944
1044
|
const body = lines.map((l) => `${l}${ESC}K`).join('\r\n');
|