@north-light/crouter 0.3.263 → 0.3.269

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 (116) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/nodes.d.ts +6 -2
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  12. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  13. package/dist/clients/attach/session/whip-achievements.js +145 -0
  14. package/dist/clients/attach/slash/dispatch.js +5 -3
  15. package/dist/clients/attach/viewer.js +607 -606
  16. package/dist/commands/canvas-history/grep.js +3 -22
  17. package/dist/commands/canvas-history/read.js +5 -3
  18. package/dist/commands/canvas-history/search.js +3 -22
  19. package/dist/commands/canvas-history/shared.d.ts +8 -0
  20. package/dist/commands/canvas-history/shared.js +64 -1
  21. package/dist/commands/canvas-history/stats.d.ts +1 -0
  22. package/dist/commands/canvas-history/stats.js +68 -0
  23. package/dist/commands/canvas-history.js +4 -3
  24. package/dist/commands/node/create.js +2 -2
  25. package/dist/commands/sys/config.js +19 -2
  26. package/dist/commands/sys/panels/models-panel.js +3 -1
  27. package/dist/core/__tests__/integration/revive.test.js +4 -2
  28. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  29. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  30. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  31. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  32. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  33. package/dist/core/broker-client/transport-relay.js +5 -1
  34. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  36. package/dist/core/canvas/browse/app.d.ts +1 -1
  37. package/dist/core/canvas/browse/app.js +13 -10
  38. package/dist/core/canvas/browse/model.js +16 -1
  39. package/dist/core/canvas/browse/render.d.ts +23 -3
  40. package/dist/core/canvas/browse/render.js +429 -64
  41. package/dist/core/canvas/canvas.js +12 -8
  42. package/dist/core/canvas/extensions.d.ts +1 -1
  43. package/dist/core/canvas/extensions.js +7 -1
  44. package/dist/core/canvas/history.d.ts +36 -1
  45. package/dist/core/canvas/history.js +328 -7
  46. package/dist/core/canvas/labels.d.ts +2 -2
  47. package/dist/core/canvas/migrations.js +32 -0
  48. package/dist/core/canvas/node-recap.d.ts +23 -0
  49. package/dist/core/canvas/node-recap.js +67 -0
  50. package/dist/core/canvas/render-source.d.ts +33 -1
  51. package/dist/core/canvas/render-source.js +80 -22
  52. package/dist/core/canvas/types.d.ts +9 -0
  53. package/dist/core/config.js +13 -5
  54. package/dist/core/keybindings/catalog.d.ts +2 -2
  55. package/dist/core/keybindings/catalog.js +2 -1
  56. package/dist/core/model-routes.js +1 -1
  57. package/dist/core/runtime/broker/auth-reload.js +4 -1
  58. package/dist/core/runtime/broker/fault-retry.js +7 -2
  59. package/dist/core/runtime/broker/frame-client.js +4 -0
  60. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  61. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  62. package/dist/core/runtime/broker/inbox.js +68 -38
  63. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  64. package/dist/core/runtime/broker.js +14 -0
  65. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  66. package/dist/core/runtime/canvas-extensions.js +2 -0
  67. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  68. package/dist/core/runtime/interactive-deliver.js +12 -3
  69. package/dist/core/runtime/launch-target.js +1 -1
  70. package/dist/core/runtime/launch.d.ts +12 -6
  71. package/dist/core/runtime/launch.js +19 -17
  72. package/dist/core/runtime/model-swap.d.ts +5 -4
  73. package/dist/core/runtime/model-swap.js +7 -5
  74. package/dist/core/runtime/promote.js +2 -1
  75. package/dist/core/runtime/reset.js +4 -2
  76. package/dist/core/runtime/revive.js +22 -0
  77. package/dist/core/runtime/spawn.d.ts +3 -3
  78. package/dist/core/runtime/spawn.js +20 -3
  79. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  80. package/dist/core/runtime/stamp/channel.js +74 -0
  81. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  82. package/dist/core/runtime/stamp/protocol.js +113 -0
  83. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  84. package/dist/core/runtime/tmux-driver.js +27 -11
  85. package/dist/core/self-update.js +2 -5
  86. package/dist/core/tui/draw.d.ts +1 -0
  87. package/dist/core/tui/draw.js +5 -1
  88. package/dist/core/tui/markdown.d.ts +17 -0
  89. package/dist/core/tui/markdown.js +407 -0
  90. package/dist/core/user-settings.d.ts +4 -0
  91. package/dist/core/user-settings.js +1 -0
  92. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  93. package/dist/daemon/api/bridge.d.ts +3 -2
  94. package/dist/daemon/api/bridge.js +34 -9
  95. package/dist/daemon/api/handlers/canvas.js +62 -16
  96. package/dist/daemon/api/handlers/messages.js +12 -1
  97. package/dist/daemon/api/handlers/nodes.js +10 -3
  98. package/dist/daemon/api/map.js +2 -0
  99. package/dist/daemon/messaging/node-message.js +15 -1
  100. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  104. package/dist/pi-extensions/canvas-recap.js +4 -0
  105. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  106. package/dist/pi-extensions/canvas-stamp.js +77 -0
  107. package/dist/pi-extensions/canvas-stophook.js +3 -0
  108. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  109. package/dist/shared/crtr-version.d.ts +2 -0
  110. package/dist/shared/crtr-version.js +18 -0
  111. package/dist/shared/env.d.ts +10 -0
  112. package/dist/shared/env.js +19 -4
  113. package/dist/types.d.ts +10 -0
  114. package/dist/types.js +4 -0
  115. package/package.json +1 -1
  116. package/runtime.lock.json +2 -2
@@ -18,15 +18,48 @@ import { matchIndices } from '../../tui/fuzzy.js';
18
18
  // `crtr view` host). Re-export the color caps so browse's importers + tests keep
19
19
  // resolving them from this module.
20
20
  import { clip, assemble, detectColorCaps } from '../../tui/draw.js';
21
+ import { renderMarkdownSpans } from '../../tui/markdown.js';
21
22
  export { detectColorCaps };
22
23
  // Fixed chrome heights, shared with app.ts so its viewport math never drifts
23
24
  // from what renderFrame actually draws.
24
25
  // header = title + tab bar + status line + separator (+ search input when searching)
25
- // preview = separator + meta line + PROMPT_LINES (spawn/match) + REPLY_LINES (last reply)
26
- export const PROMPT_LINES = 3;
27
- export const REPLY_LINES = 3;
28
- export const PREVIEW_BODY = PROMPT_LINES + REPLY_LINES;
26
+ // preview = separator + meta line + PREVIEW_BODY lines of node briefing
27
+ // The body budget is FIXED and divided between four blocks at paint time (see
28
+ // previewPanel): the two optional blocks (recap, report) take two lines each when
29
+ // the node has them, and whatever is left over is split between the two blocks that
30
+ // always render (the ask and the reply). A node with neither therefore shows twice
31
+ // as much literal conversation instead of half a panel of blanks.
32
+ export const RECAP_LINES = 2;
33
+ export const REPORT_LINES = 2;
34
+ export const PREVIEW_BODY = 8;
29
35
  export const PREVIEW_HEIGHT = PREVIEW_BODY + 2;
36
+ // ── Preview placement ─────────────────────────────────────────────────────────
37
+ // A wide terminal gets the preview as a full-height RIGHT-HAND COLUMN: the row
38
+ // table drops its flush-right metadata cluster (those per-row facts are noise when
39
+ // you only care about the hovered node — they come back as a facts block inside the
40
+ // panel) and the reclaimed columns become the panel. A narrow terminal cannot spare
41
+ // the columns, so it keeps the bottom panel. The choice is width alone, so there is
42
+ // one code path per layout and never two hand-maintained copies of the content.
43
+ const SIDE_MIN_COLS = 100; // below this the side column would starve the name column
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
48
+ const SIDE_GUTTER = ' │ '; // rows │ panel — the dim rule that separates them
49
+ export function previewLayout(cols, previewOn) {
50
+ const width = Math.max(20, cols) - 1; // last column is reserved for \x1b[K
51
+ if (!previewOn)
52
+ return { mode: 'none', panelW: 0, rowsW: width };
53
+ if (cols < SIDE_MIN_COLS)
54
+ return { mode: 'bottom', panelW: 0, rowsW: width };
55
+ const gutter = [...SIDE_GUTTER].length;
56
+ let panelW = Math.min(SIDE_PANEL_MAX, Math.max(SIDE_PANEL_MIN, Math.round(width * SIDE_PANEL_RATIO)));
57
+ if (width - panelW - gutter < SIDE_ROWS_MIN)
58
+ panelW = width - gutter - SIDE_ROWS_MIN;
59
+ if (panelW < SIDE_PANEL_MIN)
60
+ return { mode: 'bottom', panelW: 0, rowsW: width };
61
+ return { mode: 'side', panelW, rowsW: width - panelW - gutter };
62
+ }
30
63
  // header = title + tab bar + status line + column header + separator (+ input line)
31
64
  // `inputLine` is true whenever a text-input mode is open (search / rename / message)
32
65
  // — each adds one prompt row above the column header.
@@ -240,7 +273,13 @@ function assembleRow(left, right, width, color, lineBase, fill) {
240
273
  }
241
274
  /** One table row: `<status rail> <indent><collapse> <name> <flags>` ........ `<meta>`.
242
275
  * `showCwd` adds the project column (All-dirs view); `now` drives the age. */
243
- function rowLine(row, tree, width, isCursor, query, caps, showCwd, now, isPinned) {
276
+ function rowLine(row, tree, width, isCursor, query, caps, showCwd, now, isPinned,
277
+ /** false in side-preview mode: the metadata cluster is replaced by the panel's
278
+ * facts block, so the name gets the whole row. */
279
+ showMeta = true,
280
+ /** true in side-preview mode: pad every row to exactly `width` so the vertical
281
+ * rule and the panel land in the same columns on every line. */
282
+ padToWidth = false) {
244
283
  const node = tree.nodes.get(row.id);
245
284
  if (node === undefined)
246
285
  return '';
@@ -266,7 +305,7 @@ function rowLine(row, tree, width, isCursor, query, caps, showCwd, now, isPinned
266
305
  // tab/sort. Magenta keeps it distinct from the yellow ⚑ asks flag.
267
306
  const pinMark = isPinned ? [{ text: '★ ', style: { fg: FG_MAGENTA, bold: true } }] : [];
268
307
  const status = statusRail(r);
269
- const right = metaCluster(r, now, showCwd);
308
+ const right = showMeta ? metaCluster(r, now, showCwd) : [];
270
309
  const treeLead = `${indent}${collapse} `;
271
310
  // Clip the NAME (only) so gutter + status + tree + pin + name + flags + meta all
272
311
  // fit; the cluster is dropped first (assembleRow) when even a 4-col name won't fit.
@@ -285,7 +324,7 @@ function rowLine(row, tree, width, isCursor, query, caps, showCwd, now, isPinned
285
324
  // Row base: cursor → subtle bg (256) or reverse fallback (also !color); non-matched
286
325
  // ancestor → whole-row dim for tree context.
287
326
  let lineBase = '';
288
- let fill = false;
327
+ let fill = padToWidth;
289
328
  if (isCursor) {
290
329
  lineBase = caps.color256 ? CURSOR_BG : REVERSE;
291
330
  fill = true;
@@ -297,12 +336,14 @@ function rowLine(row, tree, width, isCursor, query, caps, showCwd, now, isPinned
297
336
  }
298
337
  /** The column-header row — dim labels aligned to the same zones the rows use, so
299
338
  * the metadata columns read as a table. */
300
- function columnHeaderLine(width, showCwd, caps) {
339
+ function columnHeaderLine(width, showCwd, caps, showMeta = true, padToWidth = false) {
301
340
  const left = [
302
341
  { text: ' ' }, // viewer gutter (no label)
303
342
  { text: 'STATUS'.padEnd(STATUS_W), style: { dim: true, bold: true } },
304
343
  { text: 'NAME', style: { dim: true, bold: true } },
305
344
  ];
345
+ if (!showMeta)
346
+ return assemble(left, width, caps.color, '', padToWidth);
306
347
  const right = [
307
348
  { text: 'KIND/MODE'.padEnd(KM_W), style: { dim: true, bold: true } },
308
349
  { text: COL_GAP },
@@ -333,16 +374,16 @@ function statusLine(state) {
333
374
  }
334
375
  /** One snippet line → a rendered string with the query-matched columns in bold
335
376
  * bright-cyan (mirrors the row name highlight). Unmatched text stays default. */
336
- function snippetLine(ln, width, caps) {
377
+ function snippetLine(ln, width, caps, base) {
337
378
  if (ln.hi.size === 0)
338
- return assemble([{ text: ln.text }], width, caps.color, '', false);
379
+ return assemble([{ text: ln.text, style: base }], width, caps.color, '', false);
339
380
  const spans = [];
340
381
  let buf = '';
341
382
  let bufHi = false;
342
383
  const flush = () => {
343
384
  if (buf === '')
344
385
  return;
345
- spans.push(bufHi ? { text: buf, style: { fg: FG_BRIGHT_CYAN, bold: true } } : { text: buf });
386
+ spans.push(bufHi ? { text: buf, style: { fg: FG_BRIGHT_CYAN, bold: true } } : { text: buf, style: base });
346
387
  buf = '';
347
388
  };
348
389
  for (let i = 0; i < ln.text.length; i++) {
@@ -356,14 +397,70 @@ function snippetLine(ln, width, caps) {
356
397
  flush();
357
398
  return assemble(spans, width, caps.color, '', false);
358
399
  }
359
- /** The bottom preview panel — exactly PREVIEW_HEIGHT lines: a separator, a meta
360
- * line (status · kind/mode · project · age · ctx · asks · fault/viewing), then
361
- * TWO blocks — the spawn prompt / query-match (PROMPT_LINES) and the node's LAST
362
- * assistant reply (REPLY_LINES, prefixed `↩`). Under a live query the prompt block
363
- * is WINDOWED to the matching prompt (anywhere in the conversation) with the match
364
- * highlighted; with no query it shows the spawn prompt from the start. Seeing both
365
- * ends — what it was asked and where it left off — answers "which one was this?".
366
- * Always full height so viewport math holds. */
400
+ /** How the fixed PREVIEW_BODY budget is divided for one row: the optional blocks
401
+ * take their two lines only when the node actually has them, and the remainder is
402
+ * split between the ask and the reply (ask keeps the odd line). */
403
+ export function previewAllocation(hasRecap, hasReport) {
404
+ const recap = hasRecap ? RECAP_LINES : 0;
405
+ const report = hasReport ? REPORT_LINES : 0;
406
+ const free = Math.max(2, PREVIEW_BODY - recap - report);
407
+ const ask = Math.ceil(free / 2);
408
+ return { recap, ask, reply: free - ask, report };
409
+ }
410
+ /** Render one marked block (`marker` on the first line, aligned continuation after)
411
+ * into exactly `lines` rows, or the `empty` placeholder when there is no text. */
412
+ function markedBlock(marker, text, empty, lines, width, caps, query) {
413
+ if (lines <= 0)
414
+ return [];
415
+ const out = [];
416
+ const mark = caps.color ? `${DIM}${marker}${RESET}` : marker;
417
+ const inner = Math.max(1, width - 2);
418
+ // No query → render the text as markdown (see blockLines for why a live query
419
+ // keeps the raw windowed path instead). No spacing: this panel is cramped.
420
+ if (query === '') {
421
+ const md = renderMarkdownSpans(text, inner, lines, false);
422
+ if (md.length === 0)
423
+ out.push(`${mark}${DIM}${clip(empty, inner)}${RESET}`);
424
+ else
425
+ for (const spans of md)
426
+ out.push(`${out.length === 0 ? mark : ' '}${assemble(spans, inner, caps.color, '', false)}`);
427
+ while (out.length < lines)
428
+ out.push('');
429
+ return out;
430
+ }
431
+ const snippet = previewSnippet(query, text, inner, lines);
432
+ if (snippet.length === 0) {
433
+ out.push(`${mark}${DIM}${clip(empty, inner)}${RESET}`);
434
+ }
435
+ else {
436
+ for (const ln of snippet)
437
+ out.push(`${out.length === 0 ? mark : ' '}${snippetLine(ln, inner, caps)}`);
438
+ }
439
+ while (out.length < lines)
440
+ out.push('');
441
+ return out;
442
+ }
443
+ /** One `Goal`/`State` briefing line: a dim fixed-width label then the text. */
444
+ function recapLine(label, text, width, caps) {
445
+ const lbl = `${label.padEnd(6)}`;
446
+ const body = clip(text, Math.max(1, width - lbl.length));
447
+ return caps.color ? `${DIM}${lbl}${RESET}${BOLD}${body}${RESET}` : `${lbl}${body}`;
448
+ }
449
+ /** The bottom preview panel — exactly PREVIEW_HEIGHT lines, ordered by how fast
450
+ * each answers "which node was this, and can I pick it back up?":
451
+ *
452
+ * separator
453
+ * meta status · kind/mode · project · age · ctx · asks · fault/viewing
454
+ * Goal/State the node's last persisted recap briefing — the one-glance answer
455
+ * ❯ ask the spawn prompt in the user's own words (the recognition anchor),
456
+ * or, under a live query, the WINDOWED matching prompt from anywhere
457
+ * in the conversation with the match highlighted
458
+ * ↩ reply the node's last assistant message — where it actually left off
459
+ * ▣ report the newest thing it pushed (age · kind · headline), because a
460
+ * resident's reports are written to be read, unlike its live tail
461
+ *
462
+ * The optional blocks yield their lines to the literal ones when absent, so the
463
+ * panel is never padded with blanks. Always full height so viewport math holds. */
367
464
  function previewPanel(r, width, caps, now, query) {
368
465
  const out = [`${DIM}${'─'.repeat(width)}${RESET}`];
369
466
  if (r === undefined) {
@@ -390,37 +487,290 @@ function previewPanel(r, width, caps, now, query) {
390
487
  metaPieces.push('\uf06e viewing');
391
488
  const metaText = clip(metaPieces.filter((p) => p !== '').join(' · '), Math.max(0, width - 2));
392
489
  out.push(caps.color ? `${glyph} ${DIM}${metaText}${RESET}` : `${glyph} ${metaText}`);
393
- // Block 1 — spawn prompt / query-match. With a query, window+highlight the
394
- // matching prompt from the WHOLE conversation; otherwise the spawn prompt.
395
- const sourceText = query !== '' ? promptText(r) : (r.goal ?? '');
396
- const promptSnippet = previewSnippet(query, sourceText, width, PROMPT_LINES);
397
- if (promptSnippet.length === 0) {
398
- out.push(`${DIM}(no spawn prompt)${RESET}`);
399
- for (let i = 1; i < PROMPT_LINES; i++)
400
- out.push('');
490
+ const alloc = previewAllocation(r.recap !== undefined, r.report !== undefined);
491
+ // Goal / State — the generated briefing, the fastest "what is this and where is
492
+ // it" in the panel. Absent for a node that never idled long enough to make one.
493
+ if (r.recap !== undefined) {
494
+ out.push(recapLine('Goal', r.recap.goal === '' ? '—' : r.recap.goal, width, caps));
495
+ out.push(recapLine('State', r.recap.state === '' ? '—' : r.recap.state, width, caps));
401
496
  }
402
- else {
403
- for (let i = 0; i < PROMPT_LINES; i++) {
404
- const ln = promptSnippet[i];
405
- out.push(ln === undefined ? '' : snippetLine(ln, width, caps));
497
+ // The ask, verbatim. Under a live query this becomes the windowed match from the
498
+ // WHOLE conversation (the one thing a search result must show), so it keeps the
499
+ // query highlight; with no query it is the spawn prompt from the start.
500
+ const askText = query !== '' ? promptText(r) : (r.goal ?? '');
501
+ out.push(...markedBlock('❯ ', askText, '(no spawn prompt)', alloc.ask, width, caps, query));
502
+ // Where it left off.
503
+ out.push(...markedBlock('↩ ', r.lastAssistant ?? '', '(no reply yet)', alloc.reply, width, caps, ''));
504
+ // What it last delivered. The stamp + kind lead the line because a report's value
505
+ // here is "how recently did this node report, and was that its final?".
506
+ if (r.report !== undefined) {
507
+ const age = relAge(r.report.ts, now);
508
+ const stamp = `${age === '' ? '' : `${age} `}${r.report.kind}`;
509
+ const headline = r.report.heading === '' ? r.report.body : r.report.heading;
510
+ const mark = caps.color ? `${DIM}▣ ${RESET}` : '▣ ';
511
+ const inner = Math.max(1, width - 2);
512
+ const stampText = caps.color ? `${ESC}${FG_CYAN}m${stamp}${RESET}${DIM} · ${RESET}` : `${stamp} · `;
513
+ out.push(`${mark}${stampText}${clip(headline, Math.max(1, inner - stamp.length - 3))}`);
514
+ for (let i = 1; i < alloc.report; i++) {
515
+ const detail = r.report.heading === '' ? '' : r.report.body;
516
+ const ln = previewSnippet('', detail, inner, alloc.report - 1)[i - 1];
517
+ out.push(ln === undefined ? '' : ` ${DIM}${clip(ln.text, inner)}${RESET}`);
406
518
  }
407
519
  }
408
- // Block 2 — the node's LAST assistant reply, prefixed `↩` on its first line so
409
- // the two blocks read apart. Wrapped to width-2 to leave room for the marker.
410
- const replyMark = caps.color ? `${DIM}↩ ${RESET}` : '↩ ';
411
- const replySnippet = previewSnippet('', r.lastAssistant ?? '', Math.max(1, width - 2), REPLY_LINES);
412
- if (replySnippet.length === 0) {
413
- out.push(`${replyMark}${DIM}(no reply yet)${RESET}`);
414
- for (let i = 1; i < REPLY_LINES; i++)
415
- out.push('');
520
+ return out;
521
+ }
522
+ // ── The side preview panel ────────────────────────────────────────────────────
523
+ //
524
+ // Same four zones as the bottom panel (recap · ask · reply · report) with room to
525
+ // breathe, drawn as designed chrome instead of a wall of prose:
526
+ //
527
+ // <title row, drawn on the column-header line> ★ node name 8-char id
528
+ // ● active ⚑2 asks viewing ← state, in the status hue
529
+ // kind general/base ctx 42k ← facts block: dim right-aligned
530
+ // proj ~northlight age 3h labels, values in their own hues
531
+ // ── GOAL ───────────────────────────── ← hairline + small-caps label
532
+ // ▌ what this conversation is for ← accent bar: the hero quote
533
+ // ── ASK ──────────────────────────────
534
+ // ❯ the spawn prompt, hanging-indented
535
+ //
536
+ // Section bodies are allocated from the panel's real height: everyone asks for a
537
+ // natural size, the largest gives a line back until it fits, and only then does the
538
+ // least-valuable section drop out entirely (report → state → goal → reply; the ask
539
+ // is the recognition anchor and never drops).
540
+ /** A dim hairline with a small-caps section label — `── GOAL ────────`. The optional
541
+ * suffix rides after the label in its own hue (the report's age · kind stamp). */
542
+ function sectionRule(label, width, caps, suffix) {
543
+ const suf = suffix === undefined ? '' : `${suffix.text} `;
544
+ const tail = Math.max(0, width - 4 - label.length - suf.length);
545
+ const sufStyled = suffix === undefined
546
+ ? ''
547
+ : caps.color ? `${ESC}${suffix.fg}m${suffix.text}${RESET}${DIM} ` : `${suffix.text} `;
548
+ return `${DIM}── ${BOLD}${label}${RESET}${DIM} ${sufStyled}${'─'.repeat(tail)}${RESET}`;
549
+ }
550
+ /** A marked body block with a hanging indent: `marker` leads the first line, `cont`
551
+ * every wrapped line after it (`▌ ` keeps the accent bar running down a quote).
552
+ * Returns the lines it actually filled — never padded, so the panel stacks tight.
553
+ * `empty` renders a dim placeholder for a block that must show even when absent. */
554
+ function blockLines(marker, cont, markerStyle, text, empty, maxLines, width, caps, query, bodyStyle) {
555
+ if (maxLines <= 0)
556
+ return [];
557
+ const indent = [...marker].length;
558
+ const inner = Math.max(1, width - indent);
559
+ const mk = (s, st) => assemble([{ text: s, style: st }], indent, caps.color, '', true);
560
+ // The ONE case that stays raw: under a live query the ask shows the windowed
561
+ // match with the matched columns highlighted, and stripping `**` would shift
562
+ // every later column out from under the highlight. Everything else renders
563
+ // markdown — headings, lists, and emphasis are how these messages were written.
564
+ if (query === '') {
565
+ const md = renderMarkdownSpans(text, inner, maxLines, true, bodyStyle);
566
+ if (md.length === 0) {
567
+ if (empty === undefined)
568
+ return [];
569
+ return [`${mk(marker, markerStyle)}${DIM}${clip(empty, inner)}${RESET}`];
570
+ }
571
+ return md.map((spans, i) => `${mk(i === 0 ? marker : cont, markerStyle)}${assemble(spans, inner, caps.color, '', false)}`);
572
+ }
573
+ const snippet = previewSnippet(query, text, inner, maxLines);
574
+ if (snippet.length === 0) {
575
+ if (empty === undefined)
576
+ return [];
577
+ return [`${mk(marker, markerStyle)}${DIM}${clip(empty, inner)}${RESET}`];
578
+ }
579
+ return snippet.map((ln, i) => `${mk(i === 0 ? marker : cont, markerStyle)}${snippetLine(ln, inner, caps, bodyStyle)}`);
580
+ }
581
+ /** One `label value` fact cell: a 4-col right-aligned dim label, then the value in
582
+ * its own hue, padded so a two-column facts block aligns down both columns. */
583
+ function factCell(label, value, w, style, pad) {
584
+ const vw = Math.max(1, w - 5);
585
+ const v = clip(value, vw);
586
+ return [
587
+ { text: `${label.padStart(4)} `, style: { dim: true } },
588
+ { text: v, style },
589
+ ...(pad && v.length < vw ? [{ text: ' '.repeat(vw - v.length) }] : []),
590
+ ];
591
+ }
592
+ /** The panel's facts block — the per-row metadata cluster the side layout took off
593
+ * the rows, laid out as two columns when the panel is wide enough for them. */
594
+ function factsBlock(r, width, now, caps) {
595
+ const ctxFg = ctxColorCode(r.ctx_tokens);
596
+ const facts = [
597
+ ['kind', `${r.kind}/${r.mode}`, { fg: FG_GRAY }],
598
+ ['ctx', fmtCtx(r.ctx_tokens), { fg: ctxFg, dim: ctxFg === undefined }],
599
+ ['proj', `~${baseDir(r.cwd)}`, { fg: FG_GRAY }],
600
+ ['age', relAge(r.created, now), { dim: true }],
601
+ ];
602
+ const out = [];
603
+ // Two columns at any panel width the side layout allows; the column is capped so
604
+ // a wide panel does not fling `ctx`/`age` out to its far edge.
605
+ if (width >= SIDE_PANEL_MIN) {
606
+ const colW = Math.min(30, Math.floor((width - 2) / 2));
607
+ for (let i = 0; i < facts.length; i += 2) {
608
+ const a = facts[i];
609
+ const b = facts[i + 1];
610
+ const spans = [...factCell(a[0], a[1], colW, a[2], true), { text: ' ' }];
611
+ if (b !== undefined)
612
+ spans.push(...factCell(b[0], b[1], colW, b[2], false));
613
+ out.push(assemble(spans, width, caps.color, '', false));
614
+ }
416
615
  }
417
616
  else {
418
- for (let i = 0; i < REPLY_LINES; i++) {
419
- const ln = replySnippet[i];
420
- const prefix = i === 0 ? replyMark : ' ';
421
- out.push(ln === undefined ? (i === 0 ? `${replyMark}` : '') : `${prefix}${snippetLine(ln, Math.max(1, width - 2), caps)}`);
617
+ for (const [l, v, st] of facts)
618
+ out.push(assemble(factCell(l, v, width, st, false), width, caps.color, '', false));
619
+ }
620
+ return out;
621
+ }
622
+ /** The panel's state line: the status glyph + WORD in the status hue, then the
623
+ * attention facts that only matter when they are true (asks, a live viewer, a fault). */
624
+ function panelStatusLine(r, width, caps, now) {
625
+ const v = resolveNodeVisual(r.status, {
626
+ streaming: r.streaming === true,
627
+ frozen: isFrozenRow(r),
628
+ hanging: r.hanging ?? null,
629
+ });
630
+ const word = v.word ?? ({ active: 'active', idle: 'idle', done: 'done', dead: 'dead', canceled: 'canceled' }[r.status] ?? r.status);
631
+ const spans = [{ text: `${v.glyph} ${word}`, style: { fg: FG(v.color), bold: true } }];
632
+ const sep = () => ({ text: ' ' });
633
+ if (r.asks > 0)
634
+ spans.push(sep(), { text: `⚑${r.asks} ${r.asks === 1 ? 'ask' : 'asks'}`, style: { fg: FG_BRIGHT_YELLOW, bold: true } });
635
+ if (r.viewed === true)
636
+ spans.push(sep(), { text: '\uf06e viewing', style: { fg: FG_BRIGHT_CYAN } });
637
+ if (r.hanging != null)
638
+ spans.push(sep(), { text: faultSummary(r.hanging, now), style: { fg: FG_YELLOW } });
639
+ return assemble(spans, width, caps.color, '', false);
640
+ }
641
+ /** The panel's title, drawn on the column-header row so the header rule crosses
642
+ * beneath it: the pinned star, the node name in bold, its short id flush right. */
643
+ function panelTitleLine(r, width, caps, isPinned) {
644
+ if (r === undefined)
645
+ return assemble([{ text: 'PREVIEW', style: { dim: true, bold: true } }], width, caps.color, '', false);
646
+ const left = [];
647
+ if (isPinned)
648
+ left.push({ text: '★ ', style: { fg: FG_MAGENTA, bold: true } });
649
+ const id = r.node_id.slice(0, 8);
650
+ left.push({ text: clip(r.name, Math.max(4, width - id.length - 2 - spansWidth(left))), style: { bold: true } });
651
+ return assembleRow(left, [{ text: id, style: { dim: true } }], width, caps.color, '', false);
652
+ }
653
+ /** The side panel's body, at most `height` lines (never padded to it). */
654
+ function sidePanelBody(r, width, height, caps, now, query) {
655
+ const out = [];
656
+ if (height <= 0)
657
+ return out;
658
+ if (r === undefined) {
659
+ out.push(`${DIM}(no selection)${RESET}`);
660
+ return out;
661
+ }
662
+ out.push(panelStatusLine(r, width, caps, now));
663
+ out.push('');
664
+ for (const l of factsBlock(r, width, now, caps))
665
+ out.push(l);
666
+ out.push('');
667
+ const v = resolveNodeVisual(r.status, {
668
+ streaming: r.streaming === true,
669
+ frozen: isFrozenRow(r),
670
+ hanging: r.hanging ?? null,
671
+ });
672
+ const accent = { fg: FG(v.color) };
673
+ const secs = [];
674
+ if (r.recap !== undefined && r.recap.goal !== '') {
675
+ const goal = r.recap.goal;
676
+ secs.push({ label: 'GOAL', drop: 2, min: 1, want: 4,
677
+ lines: (n) => blockLines('▌ ', '▌ ', accent, goal, undefined, n, width, caps, '', { bold: true }) });
678
+ }
679
+ if (r.recap !== undefined && r.recap.state !== '') {
680
+ const stateText = r.recap.state;
681
+ secs.push({ label: 'STATE', drop: 3, min: 1, want: 5,
682
+ lines: (n) => blockLines('▌ ', '▌ ', accent, stateText, undefined, n, width, caps, '', { bold: true }) });
683
+ }
684
+ // Under a live query the ask becomes the WINDOWED match from anywhere in the
685
+ // conversation, highlighted — the one thing a search result has to show.
686
+ const askText = query !== '' ? promptText(r) : (r.goal ?? '');
687
+ secs.push({ label: 'ASK', drop: 0, min: 2, want: 6,
688
+ lines: (n) => blockLines('❯ ', ' ', { fg: FG_BRIGHT_CYAN, bold: true }, askText, '(no spawn prompt)', n, width, caps, query) });
689
+ const reply = r.lastAssistant ?? '';
690
+ secs.push({ label: 'REPLY', drop: 1, min: 2, want: 9,
691
+ lines: (n) => blockLines('↩ ', ' ', { dim: true }, reply, '(no reply yet)', n, width, caps, '') });
692
+ if (r.report !== undefined) {
693
+ const rep = r.report;
694
+ const age = relAge(rep.ts, now);
695
+ secs.push({
696
+ label: 'REPORT', drop: 4, min: 2, want: 6,
697
+ suffix: { text: `${age === '' ? '' : `${age} · `}${rep.kind}`, fg: FG_CYAN },
698
+ lines: (n) => {
699
+ const headline = rep.heading === '' ? rep.body : rep.heading;
700
+ const head = blockLines('▣ ', ' ', { fg: FG_CYAN, bold: true }, headline, undefined, Math.min(2, n), width, caps, '', { bold: true });
701
+ if (rep.heading !== '' && n > head.length) {
702
+ head.push(...blockLines(' ', ' ', undefined, rep.body, undefined, n - head.length, width, caps, '', { dim: true }));
703
+ }
704
+ return head;
705
+ },
706
+ });
707
+ }
708
+ // Fit the sections into what is left: shrink the largest first, and only drop a
709
+ // whole section once every survivor is already at its minimum.
710
+ const avail = height - out.length;
711
+ let list = secs.slice();
712
+ // A blank line between sections when the panel can afford one; when it cannot,
713
+ // the hairlines alone separate them, which beats dropping a whole section.
714
+ const minNeed = (g) => secs.reduce((n, s) => n + s.min, 0) + (1 + g) * secs.length - g;
715
+ const gap = minNeed(1) <= avail ? 1 : 0;
716
+ const alloc = new Map();
717
+ const need = () => list.reduce((n, s) => n + (alloc.get(s.label) ?? 0), 0) + (1 + gap) * list.length - gap;
718
+ for (;;) {
719
+ for (const s of list)
720
+ alloc.set(s.label, s.want);
721
+ for (;;) {
722
+ if (need() <= avail)
723
+ break;
724
+ let biggest;
725
+ for (const s of list) {
726
+ const a = alloc.get(s.label);
727
+ if (a > s.min && (biggest === undefined || a > alloc.get(biggest.label)))
728
+ biggest = s;
729
+ }
730
+ if (biggest === undefined)
731
+ break;
732
+ alloc.set(biggest.label, alloc.get(biggest.label) - 1);
733
+ }
734
+ if (need() <= avail || list.length <= 1)
735
+ break;
736
+ let victim = list[0];
737
+ for (const s of list)
738
+ if (s.drop > victim.drop)
739
+ victim = s;
740
+ list = list.filter((s) => s !== victim);
741
+ }
742
+ // Render, then hand any room the content did not use to the blocks that always
743
+ // have more to show (the live tail first, then the ask). Growing a block by at
744
+ // most the measured surplus keeps every later section on screen.
745
+ const rendered = list.map((s) => s.lines(alloc.get(s.label) ?? s.min));
746
+ let spare = avail - (rendered.reduce((n, b) => n + b.length + 1 + gap, 0) - gap);
747
+ for (const key of ['REPLY', 'ASK']) {
748
+ if (spare <= 0)
749
+ break;
750
+ const i = list.findIndex((s) => s.label === key);
751
+ if (i < 0)
752
+ continue;
753
+ const grown = list[i].lines((alloc.get(key) ?? 0) + spare);
754
+ const delta = grown.length - rendered[i].length;
755
+ if (delta > 0) {
756
+ rendered[i] = grown;
757
+ spare -= delta;
422
758
  }
423
759
  }
760
+ for (let i = 0; i < list.length; i++) {
761
+ const body = rendered[i];
762
+ if (body.length === 0 || out.length >= height)
763
+ continue;
764
+ out.push(sectionRule(list[i].label, width, caps, list[i].suffix));
765
+ for (const l of body)
766
+ out.push(l);
767
+ if (gap > 0)
768
+ out.push('');
769
+ }
770
+ while (out.length > height)
771
+ out.pop();
772
+ while (out.length > 0 && out[out.length - 1] === '')
773
+ out.pop();
424
774
  return out;
425
775
  }
426
776
  /** The keybinding help overlay — a full-screen cheatsheet that replaces the body
@@ -434,7 +784,7 @@ const HELP_ROWS = [
434
784
  ['', 'space', 'toggle fold'],
435
785
  ['Tabs', 'Tab / 1-5', 'All · Live · Dormant · Attention · Pinned'],
436
786
  ['Find', '/', 'fuzzy search (name · kind · id · prompts)'],
437
- ['Pin', 'f', 'star / unstar — floats to top, Pinned tab'],
787
+ ['Pin', 'p', 'star / unstar — floats to top, Pinned tab'],
438
788
  ['Open', '⏎ o', 'open conversation (closes navigator)'],
439
789
  ['', 'O', 'open, keep browsing (background)'],
440
790
  ['Act', 'y', 'copy the node id to the clipboard'],
@@ -446,7 +796,7 @@ const HELP_ROWS = [
446
796
  ['', 'c', 'toggle cwd scope'],
447
797
  ['', 'P', 'toggle profile scope'],
448
798
  ['', 'r', 'toggle residents-only'],
449
- ['', 'p', 'toggle preview panel'],
799
+ ['', 'v', 'toggle preview (goal/state, ask, reply, report)'],
450
800
  ['Quit', 'q / Esc', ''],
451
801
  ];
452
802
  function renderHelp(size, caps) {
@@ -505,17 +855,36 @@ export function renderFrame(state, size, caps = { color: false, color256: false
505
855
  const p = caps.color ? `${ESC}${FG_CYAN}m${prefix}${RESET}` : prefix;
506
856
  lines.push(`${p} ${buf}▎`);
507
857
  }
508
- // column header (table labels) + separator.
509
- const showCwd = state.cwdScope === null; // project column only matters across dirs
510
- lines.push(columnHeaderLine(width, showCwd, caps));
511
- lines.push(`${DIM}${'─'.repeat(width)}${RESET}`);
512
- // body — windowed visible rows, leaving room for the (optional) preview panel.
513
- const head = lines.length; // === headerHeight(state.search)
858
+ // Where the preview goes at this width: a full-height right-hand column when
859
+ // there is room for one, else the bottom panel. In side mode the rows give up
860
+ // their metadata cluster and the panel takes those columns.
514
861
  const previewOn = state.preview && state.visible.length > 0;
515
- const previewH = previewOn ? PREVIEW_HEIGHT : 0;
862
+ const layout = previewLayout(cols, previewOn);
863
+ const side = layout.mode === 'side';
864
+ const rowsW = side ? layout.rowsW : width;
865
+ const gutter = `${DIM}${SIDE_GUTTER}${RESET}`;
866
+ const sel = state.visible[state.cursor];
867
+ const selRow = sel !== undefined ? state.tree.nodes.get(sel.id)?.row : undefined;
868
+ // column header (table labels, plus the panel title in side mode) + separator.
869
+ const showCwd = state.cwdScope === null && !side; // project column only matters across dirs
870
+ const colHeader = columnHeaderLine(rowsW, showCwd, caps, !side, side);
871
+ lines.push(side
872
+ ? `${colHeader}${gutter}${panelTitleLine(selRow, layout.panelW, caps, sel !== undefined && state.pinned.has(sel.id))}`
873
+ : colHeader);
874
+ lines.push(side
875
+ ? `${DIM}${'─'.repeat(rowsW)}─┼─${'─'.repeat(layout.panelW)}${RESET}`
876
+ : `${DIM}${'─'.repeat(width)}${RESET}`);
877
+ // body — windowed visible rows, leaving room for the (optional) bottom panel.
878
+ const head = lines.length; // === headerHeight(state.search)
879
+ const previewH = layout.mode === 'bottom' ? PREVIEW_HEIGHT : 0;
516
880
  const viewport = Math.max(1, rows - head - 1 /* footer */ - previewH);
517
881
  const start = state.scrollOffset;
518
882
  const end = Math.min(state.visible.length, start + viewport);
883
+ // In side mode each body line is `<row><rule><panel line>`, so the rows are padded
884
+ // to an exact width and the panel body is rendered once for the whole column.
885
+ const panel = side ? sidePanelBody(selRow, layout.panelW, viewport, caps, now, state.query) : [];
886
+ const compose = (row, i) => (side ? `${row}${gutter}${panel[i] ?? ''}` : row);
887
+ const blankRow = side ? ' '.repeat(rowsW) : '';
519
888
  if (state.visible.length === 0) {
520
889
  lines.push(`${DIM} (no nodes match this view)${RESET}`);
521
890
  for (let i = 1; i < viewport; i++)
@@ -524,16 +893,14 @@ export function renderFrame(state, size, caps = { color: false, color256: false
524
893
  else {
525
894
  for (let i = start; i < end; i++) {
526
895
  const vr = state.visible[i];
527
- lines.push(rowLine(vr, state.tree, width, i === state.cursor, state.query, caps, showCwd, now, state.pinned.has(vr.id)));
896
+ lines.push(compose(rowLine(vr, state.tree, rowsW, i === state.cursor, state.query, caps, showCwd, now, state.pinned.has(vr.id), !side, side), i - start));
528
897
  }
529
898
  for (let i = end - start; i < viewport; i++)
530
- lines.push('');
899
+ lines.push(compose(blankRow, i));
531
900
  }
532
- // preview panel — the selected row's prompt + meta.
533
- if (previewOn) {
534
- const sel = state.visible[state.cursor];
535
- const r = sel !== undefined ? state.tree.nodes.get(sel.id)?.row : undefined;
536
- for (const l of previewPanel(r, width, caps, now, state.query))
901
+ // bottom preview panel — the narrow-terminal layout.
902
+ if (layout.mode === 'bottom') {
903
+ for (const l of previewPanel(selRow, width, caps, now, state.query))
537
904
  lines.push(l);
538
905
  }
539
906
  // footer — the y/n close-out confirm takes over when pending; else search / nav.
@@ -550,14 +917,12 @@ export function renderFrame(state, size, caps = { color: false, color256: false
550
917
  // When the cursor row has an active fault, lead the footer with the on-demand kick
551
918
  // hint (K shells `node lifecycle revive <id> --now` — immediate revive instead of
552
919
  // waiting for the daemon's retry schedule).
553
- const selRow = state.visible[state.cursor];
554
- const cursorRow = selRow !== undefined ? state.tree.nodes.get(selRow.id)?.row : undefined;
555
- const hangingHint = cursorRow?.hanging != null ? 'K revive now ' : '';
920
+ const hangingHint = selRow?.hanging != null ? 'K revive now ' : '';
556
921
  const footer = inputMode
557
922
  ? '⏎ commit Esc cancel ⌫ delete'
558
923
  : state.readOnly === true
559
- ? '↑↓ move →/← tree / search f pin Tab tabs ? more q quit — read-only remote canvas'
560
- : `${hangingHint}↑↓ move ⏎ open O bg / search f pin m msg x close R rename ? more q quit`;
924
+ ? '↑↓ move →/← tree / search p pin v preview Tab tabs ? more q quit — read-only remote canvas'
925
+ : `${hangingHint}↑↓ move ⏎ open O bg / search p pin v preview m msg x close ? more q quit`;
561
926
  lines.push(`${DIM}${clip(footer, width)}${RESET}`);
562
927
  }
563
928
  // Assemble: home, each line cleared to EOL, then clear below.