@north-light/crouter 0.3.277 → 0.3.279

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.
Files changed (37) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +11 -3
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +11 -3
  3. package/dist/clients/attach/chrome/status-line.js +1 -22
  4. package/dist/clients/attach/input/capabilities.js +2 -1
  5. package/dist/clients/attach/render/chat-view.d.ts +11 -0
  6. package/dist/clients/attach/render/chat-view.js +23 -4
  7. package/dist/clients/attach/render/tool-calls.d.ts +6 -1
  8. package/dist/clients/attach/render/tool-calls.js +47 -3
  9. package/dist/clients/attach/session/bindings.d.ts +1 -1
  10. package/dist/clients/attach/session/bindings.js +1 -1
  11. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  12. package/dist/clients/attach/session/chrome-refresh.js +3 -3
  13. package/dist/clients/attach/slash/dispatch.js +42 -19
  14. package/dist/clients/attach/viewer.js +564 -564
  15. package/dist/commands/canvas-browse.js +2 -2
  16. package/dist/commands/node/bash.js +5 -8
  17. package/dist/core/__tests__/helpers/fleet.d.ts +1 -1
  18. package/dist/core/__tests__/helpers/fleet.js +5 -0
  19. package/dist/core/__tests__/helpers/harness.js +5 -1
  20. package/dist/core/canvas/browse/app.d.ts +1 -1
  21. package/dist/core/canvas/browse/app.js +32 -7
  22. package/dist/core/canvas/browse/render.d.ts +17 -2
  23. package/dist/core/canvas/browse/render.js +342 -242
  24. package/dist/core/canvas/render-source.d.ts +26 -0
  25. package/dist/core/canvas/render-source.js +73 -0
  26. package/dist/core/keybindings/catalog.d.ts +2 -2
  27. package/dist/core/keybindings/catalog.js +1 -0
  28. package/dist/core/runtime/busy.d.ts +13 -0
  29. package/dist/core/runtime/busy.js +43 -1
  30. package/dist/core/runtime/revive.js +2 -0
  31. package/dist/core/runtime/tmux-bindings.js +1 -1
  32. package/dist/daemon/fleet.d.ts +3 -0
  33. package/dist/daemon/fleet.js +35 -5
  34. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +14 -0
  35. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +18 -0
  36. package/package.json +1 -1
  37. 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, META_FG, RECESSIVE_FG } from '../../tui/draw.js';
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 = 80;
46
- const SIDE_PANEL_RATIO = 0.46;
47
- const SIDE_ROWS_MIN = 44; // the row table never shrinks below this
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
- // Cursor-row band. Absolute (256-color) rather than theme-derived: the browser
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
- // META_FG / RECESSIVE_FG (imported) carry every grey in this view. Bright-black
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 ? `${META} ${t} ${RESET}` : ` ${t} `;
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 (the
191
- * recessive grey for terminal status, bold on the cursor row). */
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: { fg: style.fg, bold: style.bold } }];
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: { fg: style.fg, bold: style.bold } });
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: META_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 ?? META_FG } },
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: { fg: META_FG } },
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: META_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
- // A finished node's name recedes to the grey ramp rather than to SGR DIM: dim
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: { fg: RECESSIVE_FG } },
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 — grey labels aligned to the same zones the rows use, so
350
- * the metadata columns read as a table. Bold + the metadata grey: a header is a
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: hdr },
357
- { text: 'NAME', style: hdr },
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: hdr },
343
+ { text: 'KIND/MODE'.padEnd(KM_W), style: { dim: true, bold: true } },
363
344
  { text: COL_GAP },
364
- { text: 'CTX'.padStart(CTX_W), style: hdr },
345
+ { text: 'CTX'.padStart(CTX_W), style: { dim: true, bold: true } },
365
346
  { text: COL_GAP },
366
- { text: 'AGE'.padStart(AGE_W), style: hdr },
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: hdr });
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) => `${ESC}${META_FG}m${s}${RESET}`;
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 ? `${RECESSIVE}${marker}${RESET}` : marker;
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}${RECESSIVE}${clip(empty, inner)}${RESET}`);
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}${RECESSIVE}${clip(empty, inner)}${RESET}`);
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 grey fixed-width label then the text. */
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 ? `${META}${lbl}${RESET}${BOLD}${body}${RESET}` : `${lbl}${body}`;
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 = [`${RECESSIVE}${'─'.repeat(width)}${RESET}`];
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} ${META}${metaText}${RESET}` : `${glyph} ${metaText}`);
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 ? `${RECESSIVE}▣ ${RESET}` : '▣ ';
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}${RECESSIVE} · ${RESET}` : `${stamp} · `;
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 ? '' : ` ${META}${clip(ln.text, inner)}${RESET}`);
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}${RECESSIVE} ` : `${suffix.text} `;
563
- return `${RECESSIVE}── ${META}${BOLD}${label}${RESET}${RECESSIVE} ${sufStyled}${'─'.repeat(tail)}${RESET}`;
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)}${RECESSIVE}${clip(empty, inner)}${RESET}`];
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)}${RECESSIVE}${clip(empty, inner)}${RESET}`];
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
- /** One `label value` fact cell: a 4-col right-aligned grey label, then the value in
597
- * its own hue, padded so a two-column facts block aligns down both columns. */
598
- function factCell(label, value, w, style, pad) {
599
- const vw = Math.max(1, w - 5);
600
- const v = clip(value, vw);
601
- return [
602
- { text: `${label.padStart(4)} `, style: { fg: RECESSIVE_FG } },
603
- { text: v, style },
604
- ...(pad && v.length < vw ? [{ text: ' '.repeat(vw - v.length) }] : []),
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
- /** The panel's facts block — the per-row metadata cluster the side layout took off
608
- * the rows, laid out as two columns when the panel is wide enough for them. */
609
- function factsBlock(r, width, now, caps) {
610
- const ctxFg = ctxColorCode(r.ctx_tokens);
611
- const facts = [
612
- ['kind', `${r.kind}/${r.mode}`, { fg: META_FG }],
613
- ['ctx', fmtCtx(r.ctx_tokens), { fg: ctxFg ?? META_FG }],
614
- ['proj', `~${baseDir(r.cwd)}`, { fg: META_FG }],
615
- ['age', relAge(r.created, now), { fg: META_FG }],
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
- // Two columns at any panel width the side layout allows; the column is capped so
619
- // a wide panel does not fling `ctx`/`age` out to its far edge.
620
- if (width >= SIDE_PANEL_MIN) {
621
- const colW = Math.min(30, Math.floor((width - 2) / 2));
622
- for (let i = 0; i < facts.length; i += 2) {
623
- const a = facts[i];
624
- const b = facts[i + 1];
625
- const spans = [...factCell(a[0], a[1], colW, a[2], true), { text: ' ' }];
626
- if (b !== undefined)
627
- spans.push(...factCell(b[0], b[1], colW, b[2], false));
628
- out.push(assemble(spans, width, caps.color, '', false));
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
- else {
632
- for (const [l, v, st] of facts)
633
- out.push(assemble(factCell(l, v, width, st, false), width, caps.color, '', false));
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
- /** The panel's state line: the status glyph + WORD in the status hue, then the
638
- * attention facts that only matter when they are true (asks, a live viewer, a fault). */
639
- function panelStatusLine(r, width, caps, now) {
640
- const v = resolveNodeVisual(r.status, {
641
- streaming: r.streaming === true,
642
- frozen: isFrozenRow(r),
643
- hanging: r.hanging ?? null,
644
- });
645
- const word = v.word ?? ({ active: 'active', idle: 'idle', done: 'done', dead: 'dead', canceled: 'canceled' }[r.status] ?? r.status);
646
- const spans = [{ text: `${v.glyph} ${word}`, style: { fg: v.color, bold: true } }];
647
- const sep = () => ({ text: ' ' });
648
- if (r.asks > 0)
649
- spans.push(sep(), { text: `⚑${r.asks} ${r.asks === 1 ? 'ask' : 'asks'}`, style: { fg: FG_BRIGHT_YELLOW, bold: true } });
650
- if (r.viewed === true)
651
- spans.push(sep(), { text: '\uf06e viewing', style: { fg: FG_BRIGHT_CYAN } });
652
- if (r.hanging != null)
653
- spans.push(sep(), { text: faultSummary(r.hanging, now), style: { fg: FG_YELLOW } });
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: { fg: META_FG, bold: true } }], width, caps.color, '', false);
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: { fg: META_FG } }], width, caps.color, '', false);
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 (never padded to it). */
669
- function sidePanelBody(r, width, height, caps, now, query) {
670
- const out = [];
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 out;
673
- if (r === undefined) {
674
- out.push(`${RECESSIVE}(no selection)${RESET}`);
675
- return out;
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
- out.push(panelStatusLine(r, width, caps, now));
678
- out.push('');
679
- for (const l of factsBlock(r, width, now, caps))
680
- out.push(l);
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
- // Fit the sections into what is left: shrink the largest first, and only drop a
724
- // whole section once every survivor is already at its minimum.
725
- const avail = height - out.length;
726
- let list = secs.slice();
727
- // A blank line between sections when the panel can afford one; when it cannot,
728
- // the hairlines alone separate them, which beats dropping a whole section.
729
- const minNeed = (g) => secs.reduce((n, s) => n + s.min, 0) + (1 + g) * secs.length - g;
730
- const gap = minNeed(1) <= avail ? 1 : 0;
731
- const alloc = new Map();
732
- const need = () => list.reduce((n, s) => n + (alloc.get(s.label) ?? 0), 0) + (1 + gap) * list.length - gap;
733
- for (;;) {
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
- for (let i = 0; i < list.length; i++) {
776
- const body = rendered[i];
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
- while (out.length > height)
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/state, ask, reply, report)'],
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) => `${META}${s}${RESET}`;
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}${META}${p.count}${RESET}`).join(' ')
859
- : `${META}${summaryPlain}${RESET}`;
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 = `${RECESSIVE}${SIDE_GUTTER}${RESET}`;
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
- ? `${RECESSIVE}${'─'.repeat(rowsW)}─┼─${'─'.repeat(layout.panelW)}${RESET}`
891
- : `${RECESSIVE}${'─'.repeat(width)}${RESET}`);
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(`${META} (no nodes match this view)${RESET}`);
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(`${META}${clip(footer, width)}${RESET}`);
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');