@north-light/crouter 0.3.263 → 0.3.270
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/api/client.d.ts +5 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/canvas.d.ts +53 -2
- package/dist/api/dto/nodes.d.ts +10 -2
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
- package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
- package/dist/clients/attach/session/whip-achievements.js +145 -0
- package/dist/clients/attach/slash/dispatch.js +5 -3
- package/dist/clients/attach/viewer.js +607 -606
- package/dist/commands/canvas-history/grep.js +3 -22
- package/dist/commands/canvas-history/read.js +5 -3
- package/dist/commands/canvas-history/search.js +3 -22
- package/dist/commands/canvas-history/shared.d.ts +8 -0
- package/dist/commands/canvas-history/shared.js +64 -1
- package/dist/commands/canvas-history/stats.d.ts +1 -0
- package/dist/commands/canvas-history/stats.js +68 -0
- package/dist/commands/canvas-history.js +4 -3
- package/dist/commands/node/create.js +2 -2
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/config.js +19 -2
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/commands/sys/panels/models-panel.js +3 -1
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +19 -17
- package/dist/core/__tests__/model-pin-durability.test.js +25 -1
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/relaunch-root.test.js +17 -0
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
- package/dist/core/broker-client/transport-relay.js +5 -1
- package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +13 -10
- package/dist/core/canvas/browse/model.js +16 -1
- package/dist/core/canvas/browse/render.d.ts +23 -3
- package/dist/core/canvas/browse/render.js +429 -64
- package/dist/core/canvas/canvas.js +12 -8
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/history.d.ts +36 -1
- package/dist/core/canvas/history.js +328 -7
- package/dist/core/canvas/labels.d.ts +2 -2
- package/dist/core/canvas/migrations.js +32 -0
- package/dist/core/canvas/node-recap.d.ts +23 -0
- package/dist/core/canvas/node-recap.js +67 -0
- package/dist/core/canvas/render-source.d.ts +33 -1
- package/dist/core/canvas/render-source.js +80 -22
- package/dist/core/canvas/types.d.ts +9 -0
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/config.js +13 -5
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/model-routes.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/auth-reload.js +4 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/fault-retry.js +7 -2
- package/dist/core/runtime/broker/frame-client.js +3 -0
- package/dist/core/runtime/broker/frame-dispatch.js +7 -0
- package/dist/core/runtime/broker/inbox.d.ts +12 -2
- package/dist/core/runtime/broker/inbox.js +68 -38
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +16 -1
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +27 -3
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/interactive-deliver.d.ts +8 -2
- package/dist/core/runtime/interactive-deliver.js +12 -3
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/launch.d.ts +12 -6
- package/dist/core/runtime/launch.js +19 -17
- package/dist/core/runtime/model-swap.d.ts +5 -4
- package/dist/core/runtime/model-swap.js +7 -5
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/promote.js +2 -1
- package/dist/core/runtime/reset.js +4 -2
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +71 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/spawn.d.ts +3 -3
- package/dist/core/runtime/spawn.js +20 -3
- package/dist/core/runtime/stamp/channel.d.ts +22 -0
- package/dist/core/runtime/stamp/channel.js +83 -0
- package/dist/core/runtime/stamp/protocol.d.ts +90 -0
- package/dist/core/runtime/stamp/protocol.js +113 -0
- package/dist/core/runtime/tmux-driver.d.ts +11 -1
- package/dist/core/runtime/tmux-driver.js +27 -11
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/core/self-update.js +2 -5
- package/dist/core/tui/draw.d.ts +1 -0
- package/dist/core/tui/draw.js +5 -1
- package/dist/core/tui/markdown.d.ts +17 -0
- package/dist/core/tui/markdown.js +407 -0
- package/dist/core/user-settings.d.ts +4 -0
- package/dist/core/user-settings.js +1 -0
- package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.d.ts +3 -2
- package/dist/daemon/api/bridge.js +34 -9
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/canvas.js +62 -16
- package/dist/daemon/api/handlers/messages.js +14 -3
- package/dist/daemon/api/handlers/nodes.js +17 -7
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +1 -1
- package/dist/daemon/messaging/node-message.js +16 -2
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/broker-supervision.js +26 -9
- package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
- package/dist/daemon/reconcilers/live-obligation.js +17 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
- package/dist/pi-extensions/canvas-recap.js +4 -0
- package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
- package/dist/pi-extensions/canvas-stamp.js +77 -0
- package/dist/pi-extensions/canvas-stophook.js +3 -0
- package/dist/shared/__tests__/env-boundary.test.js +4 -2
- package/dist/shared/crtr-version.d.ts +2 -0
- package/dist/shared/crtr-version.js +18 -0
- package/dist/shared/env.d.ts +10 -0
- package/dist/shared/env.js +19 -4
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/dist/types.d.ts +10 -0
- package/dist/types.js +4 -0
- package/package.json +1 -1
- 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 +
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
out.push(
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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 (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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', '
|
|
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
|
-
['', '
|
|
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
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
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,
|
|
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
|
|
533
|
-
if (
|
|
534
|
-
const
|
|
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
|
|
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
|
|
560
|
-
: `${hangingHint}↑↓ move ⏎ open O bg / search
|
|
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.
|