@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
@@ -115,11 +115,28 @@ test('node-relative memory slash commands omit preview metadata and register inv
115
115
  assert.equal(loadedCommandPreview('/acceptance-independent first evidence', live.commands), undefined);
116
116
  assert.deepEqual(live.notifications, []);
117
117
  const sentContent = Object.fromEntries(live.sent.map((send) => [send.name, send.message.content]));
118
- assert.deepEqual(sentContent, {
118
+ const invokedBody = {
119
119
  [COMMAND_NAMES[0]]: `Store evidence in ${join(home, 'nodes', nodeId, 'context', 'result.md')}.\n`,
120
120
  [COMMAND_NAMES[1]]: `Node ${nodeId} received evidence.\n`,
121
121
  [COMMAND_NAMES[2]]: 'Independent payload: evidence.\n',
122
- });
122
+ };
123
+ // An invoked document is content delivery, so the FIRST invocation carries the
124
+ // same companions `crtr memory read` attaches: the listings that place the doc,
125
+ // plus every corpus doc its memory-read routes match. The doc being delivered is
126
+ // absent from its own listing because its exposure registers before they render.
127
+ const firstSent = sentContent[COMMAND_NAMES[0]];
128
+ assert.ok(firstSent.endsWith(invokedBody[COMMAND_NAMES[0]]), 'the expanded body ends the delivery');
129
+ const companions = firstSent.slice(0, firstSent.length - invokedBody[COMMAND_NAMES[0]].length);
130
+ assert.match(companions, /^<auto-loaded-context>\n[\s\S]*<\/auto-loaded-context>\n\n$/);
131
+ assert.ok(companions.includes('<memory-listing dir="workspace/workspace">\n'
132
+ + `[[workspace/workspace/${COMMAND_LOCALS[2]}]]: Acceptance fixture.\n`
133
+ + `[[workspace/workspace/${COMMAND_LOCALS[1]}]]: Acceptance fixture.\n`
134
+ + '</memory-listing>'), 'the invoked doc\'s own directory listing is attached as a block');
135
+ assert.ok(companions.includes('<memory-listing dir="workspace">\n[[workspace/workspace]]\n</memory-listing>'));
136
+ assert.match(companions, /<memory kind="knowledge" name="memory-read-orientation">/);
137
+ // The later invocations attach nothing: the first delivery already exposed them.
138
+ assert.equal(sentContent[COMMAND_NAMES[1]], invokedBody[COMMAND_NAMES[1]]);
139
+ assert.equal(sentContent[COMMAND_NAMES[2]], invokedBody[COMMAND_NAMES[2]]);
123
140
  for (const send of live.sent) {
124
141
  assert.deepEqual(send.message, {
125
142
  customType: 'memory-slash-command',
@@ -61,7 +61,9 @@ test('RelayTransport dials crtrd attach with Authorization: Bearer <token>', asy
61
61
  const relay = await startMockRelay();
62
62
  const transport = new RelayTransport('node-auth', relay.target);
63
63
  await connectTransport(transport);
64
- assert.equal(relay.requestUrls[0], '/v1/nodes/node-auth/attach');
64
+ // `wakeParked=1` is part of the dial contract: a person attached deliberately,
65
+ // so the bridge wakes an exact parked resident for this caller.
66
+ assert.equal(relay.requestUrls[0], '/v1/nodes/node-auth/attach?wakeParked=1');
65
67
  assert.equal(relay.authHeaders[0], 'Bearer test-token');
66
68
  transport.destroy();
67
69
  await relay.close();
@@ -14,7 +14,11 @@ function relayUrl(target, nodeId) {
14
14
  const url = new URL(target.previewEndpoint);
15
15
  url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:';
16
16
  url.pathname = `/v1/nodes/${encodeURIComponent(nodeId)}/attach`;
17
- url.search = '';
17
+ // A person typed `crtr surface attach` at this node, so an exact parked
18
+ // resident is woken for it — the same thing the LOCAL attach ensure does for
19
+ // a direct attach. The bridge leaves a parked node parked without this, which
20
+ // is what a product page reconnecting its viewport wants.
21
+ url.search = '?wakeParked=1';
18
22
  url.hash = '';
19
23
  return url.toString();
20
24
  }
@@ -0,0 +1,102 @@
1
+ // history-transcript.test.ts — transcript artifacts carry the origin their
2
+ // stamp recorded.
3
+ //
4
+ // The join is the fragile part: a stamp is physically written BEFORE the
5
+ // message it describes, so correlation runs on the message's own epoch-ms
6
+ // `timestamp` and never on tree position. Getting that wrong yields a corpus
7
+ // where every origin silently reads `unknown` — which looks like a stamping
8
+ // regression rather than a reader bug, so it is worth pinning.
9
+ import { test, before, beforeEach, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { createNode } from '../canvas.js';
15
+ import { closeDb } from '../db.js';
16
+ import { nodeDir, sessionPtrPath } from '../paths.js';
17
+ import { nodeArtifacts, groupCorpus } from '../history.js';
18
+ const NODE = 'test-transcript-node';
19
+ function meta(id, cwd) {
20
+ return {
21
+ node_id: id,
22
+ name: id,
23
+ created: new Date().toISOString(),
24
+ cwd,
25
+ kind: 'general',
26
+ mode: 'base',
27
+ lifecycle: 'terminal',
28
+ status: 'active',
29
+ };
30
+ }
31
+ function messageEntry(role, text, msgTs) {
32
+ return JSON.stringify({
33
+ type: 'message',
34
+ timestamp: new Date(msgTs).toISOString(),
35
+ message: { role, content: [{ type: 'text', text }], timestamp: msgTs },
36
+ });
37
+ }
38
+ function stampEntry(msgTs, origin) {
39
+ return JSON.stringify({
40
+ type: 'custom',
41
+ customType: 'crtr-stamp',
42
+ data: { v: 1, kind: 'message', payload: { msgTs, origin } },
43
+ });
44
+ }
45
+ /** A session file in the real on-disk shape: stamps precede their messages,
46
+ * one inbound turn is unstamped, and an assistant turn sits between them. */
47
+ function seedSession(home) {
48
+ const dir = nodeDir(NODE);
49
+ mkdirSync(dir, { recursive: true });
50
+ const file = join(home, 'session.jsonl');
51
+ writeFileSync(file, [
52
+ JSON.stringify({ type: 'custom', customType: 'crtr-stamp', data: { v: 1, kind: 'session', payload: { node: NODE, crtr: '0.0.0' } } }),
53
+ stampEntry(1000, { type: 'runtime', kind: 'wake', wake: { kind: 'schedule_revive', ownerId: null, armedAt: 'x' } }),
54
+ messageEntry('user', 'woken by cron', 1000),
55
+ JSON.stringify({ type: 'message', timestamp: new Date(1500).toISOString(), message: { role: 'assistant', content: [{ type: 'thinking', text: 'private' }, { type: 'toolCall', name: 'Bash' }], timestamp: 1500 } }),
56
+ stampEntry(2000, { type: 'runtime', kind: 'inbox', entries: [{ from: 'n1', fromName: 'child-node', kind: 'report', tier: 'normal' }] }),
57
+ messageEntry('user', 'a delivery', 2000),
58
+ messageEntry('user', 'no stamp for this one', 3000),
59
+ ].join('\n'), 'utf8');
60
+ writeFileSync(sessionPtrPath(NODE), file, 'utf8');
61
+ }
62
+ let home;
63
+ let cwd;
64
+ before(() => {
65
+ home = mkdtempSync(join(tmpdir(), 'crtr-history-transcript-'));
66
+ cwd = mkdtempSync(join(tmpdir(), 'crtr-history-transcript-cwd-'));
67
+ process.env['CRTR_HOME'] = home;
68
+ });
69
+ beforeEach(() => {
70
+ closeDb();
71
+ });
72
+ after(() => {
73
+ closeDb();
74
+ for (const d of [home, cwd])
75
+ rmSync(d, { recursive: true, force: true });
76
+ delete process.env['CRTR_HOME'];
77
+ });
78
+ test('a stamped message carries its origin; an unstamped one reads unknown', () => {
79
+ createNode(meta(NODE, cwd));
80
+ seedSession(home);
81
+ const arts = nodeArtifacts(NODE, ['transcript']);
82
+ assert.equal(arts.length, 4, 'every message entry becomes one artifact');
83
+ const origins = arts.map((a) => a.origin);
84
+ assert.deepEqual(origins, ['wake', undefined, 'inbox', 'unknown']);
85
+ // Origin is a property of ARRIVAL — a generated turn must not acquire one,
86
+ // which is what lets `--origin` exclude assistant turns without a role filter.
87
+ const assistant = arts.find((a) => a.role === 'assistant');
88
+ assert.equal(assistant?.origin, undefined);
89
+ // Sender attribution rides the inbox stamp's delivered entries.
90
+ assert.equal(arts[2]?.from, 'child-node');
91
+ // Private model reasoning stays out of the searchable body, matching every
92
+ // other human-facing transcript surface.
93
+ assert.ok(!assistant?.loadBody().includes('private'));
94
+ assert.ok(assistant?.loadBody().includes('Tool: Bash'));
95
+ });
96
+ test('grouping by origin counts arrivals only, and unknown is its own bucket', () => {
97
+ createNode(meta(NODE, cwd));
98
+ seedSession(home);
99
+ const groups = groupCorpus(nodeArtifacts(NODE, ['transcript']), 'origin');
100
+ assert.deepEqual(groups.map((g) => g.key).sort(), ['inbox', 'unknown', 'wake'], 'the assistant turn contributes no bucket');
101
+ assert.equal(groups.reduce((n, g) => n + g.count, 0), 3);
102
+ });
@@ -1,6 +1,6 @@
1
1
  import type { CanvasSource } from '../source.js';
2
2
  import { type BindingId, type BindingResolution, type RawTerminalInput } from '../../keybindings/index.js';
3
- declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
3
+ declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.pin", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
4
4
  declare const BROWSE_SEARCH_ACTIONS: readonly ["crtr.browse.search.cancel", "crtr.browse.search.commit", "crtr.browse.search.delete", "crtr.browse.search.quit"];
5
5
  export declare function resolveBrowseNavAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_NAV_ACTIONS)[number] | null;
6
6
  export declare function resolveBrowseSearchAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_SEARCH_ACTIONS)[number] | null;
@@ -21,7 +21,8 @@ const BROWSE_NAV_ACTIONS = [
21
21
  'crtr.browse.expand-or-child', 'crtr.browse.collapse-or-parent', 'crtr.browse.toggle-fold',
22
22
  'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
23
23
  'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
24
- 'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview', 'crtr.browse.search',
24
+ 'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview',
25
+ 'crtr.browse.pin', 'crtr.browse.search',
25
26
  'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background', 'crtr.browse.yank-id',
26
27
  ];
27
28
  const BROWSE_SEARCH_ACTIONS = [
@@ -40,14 +41,16 @@ export function resolveBrowseSearchAction(bindings, raw) {
40
41
  // Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
41
42
  // press restores the structural `tree` view, then relevance/recency, then back.
42
43
  const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
43
- import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './render.js';
44
+ import { renderFrame, detectColorCaps, headerHeight, previewLayout, PREVIEW_HEIGHT } from './render.js';
44
45
  import { writeClipboardTextEverywhere } from '../../clipboard-text.js';
45
46
  /** Viewport (body) height = total rows minus the header renderFrame draws (see
46
- * render.ts headerHeight), the footer, and the preview panel when shown. Kept
47
- * in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
48
- function viewportHeight(rowsTotal, inputLine, previewOn) {
49
- const rows = Math.max(8, rowsTotal);
50
- const previewH = previewOn ? PREVIEW_HEIGHT : 0;
47
+ * render.ts headerHeight), the footer, and the BOTTOM preview panel when that is
48
+ * the layout in play. The side preview column costs columns, not rows, so it does
49
+ * not shrink the viewport. Kept in lockstep with render.ts via the shared
50
+ * headerHeight/previewLayout/PREVIEW_HEIGHT. */
51
+ function viewportHeight(size, inputLine, previewOn) {
52
+ const rows = Math.max(8, size.rows);
53
+ const previewH = previewLayout(size.cols, previewOn).mode === 'bottom' ? PREVIEW_HEIGHT : 0;
51
54
  return Math.max(1, rows - headerHeight(inputLine) - 1 /* footer */ - previewH);
52
55
  }
53
56
  export async function runBrowse(opts) {
@@ -117,7 +120,7 @@ export async function runBrowse(opts) {
117
120
  cwdScope: null, // default: All dirs
118
121
  profileScope: launchProfileId,
119
122
  sort: 'attention', // default: attention ordering (attached/streaming/live first)
120
- preview: true, // default ON (decision)
123
+ preview: false, // default OFF (decision) — `v` opens it for the hovered row
121
124
  residentsOnly: true, // default ON: hide one-shot workers (decision)
122
125
  pendingClose: null,
123
126
  flash: null,
@@ -218,7 +221,7 @@ export async function runBrowse(opts) {
218
221
  const size = getTerminalSize();
219
222
  const previewOn = state.preview && visible.length > 0;
220
223
  const inputMode = state.search || state.rename || state.message;
221
- const viewport = viewportHeight(size.rows, inputMode, previewOn);
224
+ const viewport = viewportHeight(size, inputMode, previewOn);
222
225
  // Keep the cursor inside the viewport window.
223
226
  if (state.cursor < state.scrollOffset)
224
227
  state.scrollOffset = state.cursor;
@@ -708,7 +711,7 @@ export async function runBrowse(opts) {
708
711
  return;
709
712
  }
710
713
  // Pin or unpin in the local, view-only pin set.
711
- if (input === 'f') {
714
+ if (action === 'crtr.browse.pin') {
712
715
  if (row !== undefined)
713
716
  togglePin(row.id);
714
717
  scheduleFlush();
@@ -201,13 +201,28 @@ export function previewSnippet(query, text, width, maxLines) {
201
201
  // 2. Recompute highlight indices on the FINAL snippet string (no drift), then
202
202
  // word-wrap with offset tracking and split the highlight into per-line cols.
203
203
  const hi = highlightIndices(query, snippet);
204
- return wrapTracked(snippet, width, maxLines).map(({ text: lt, start }) => {
204
+ const wrapped = wrapTracked(snippet, width, maxLines);
205
+ const lines = wrapped.map(({ text: lt, start }) => {
205
206
  const lhi = new Set();
206
207
  for (const idx of hi)
207
208
  if (idx >= start && idx < start + lt.length)
208
209
  lhi.add(idx - start);
209
210
  return { text: lt, hi: lhi };
210
211
  });
212
+ // 3. Mark a truncation. Without this the last line reads as the end of the text,
213
+ // which is exactly the wrong impression for "where did this node leave off?".
214
+ const last = wrapped[wrapped.length - 1];
215
+ const lastLine = lines[lines.length - 1];
216
+ if (last !== undefined && lastLine !== undefined && last.start + last.text.length < snippet.length) {
217
+ const keep = Math.max(0, Math.min(lastLine.text.length, width - 1));
218
+ const text = lastLine.text.slice(0, keep);
219
+ const lhi = new Set();
220
+ for (const idx of lastLine.hi)
221
+ if (idx < keep)
222
+ lhi.add(idx);
223
+ lines[lines.length - 1] = { text: `${text}…`, hi: lhi };
224
+ }
225
+ return lines;
211
226
  }
212
227
  function shortId(id) {
213
228
  return id.slice(0, 8);
@@ -2,10 +2,21 @@ import type { Tab, Tree, VisibleRow, SortMode } from './model.js';
2
2
  import { detectColorCaps, type ColorCaps } from '../../tui/draw.js';
3
3
  export { detectColorCaps };
4
4
  export type { ColorCaps };
5
- export declare const PROMPT_LINES = 3;
6
- export declare const REPLY_LINES = 3;
7
- export declare const PREVIEW_BODY: number;
5
+ export declare const RECAP_LINES = 2;
6
+ export declare const REPORT_LINES = 2;
7
+ export declare const PREVIEW_BODY = 8;
8
8
  export declare const PREVIEW_HEIGHT: number;
9
+ export type PreviewMode = 'none' | 'bottom' | 'side';
10
+ /** Where the preview goes at this terminal width, and how the body splits.
11
+ * `rowsW` is the width the row table draws into (full width unless `side`).
12
+ * Exported so app.ts's viewport math can subtract preview ROWS only in `bottom`
13
+ * mode — the side column costs columns, not rows. */
14
+ export interface PreviewLayout {
15
+ mode: PreviewMode;
16
+ panelW: number;
17
+ rowsW: number;
18
+ }
19
+ export declare function previewLayout(cols: number, previewOn: boolean): PreviewLayout;
9
20
  export declare function headerHeight(inputLine: boolean): number;
10
21
  export interface RenderState {
11
22
  tree: Tree;
@@ -52,6 +63,15 @@ export interface RenderState {
52
63
  * dead action. undefined/false = local, byte-identical footer. */
53
64
  readOnly?: boolean;
54
65
  }
66
+ /** How the fixed PREVIEW_BODY budget is divided for one row: the optional blocks
67
+ * take their two lines only when the node actually has them, and the remainder is
68
+ * split between the ask and the reply (ask keeps the odd line). */
69
+ export declare function previewAllocation(hasRecap: boolean, hasReport: boolean): {
70
+ recap: number;
71
+ ask: number;
72
+ reply: number;
73
+ report: number;
74
+ };
55
75
  /**
56
76
  * Render the whole frame. Returns a single string that, written as-is, repaints
57
77
  * the screen in place. `caps` gates all hue (defaults to a no-color frame so