@agi-fans/dsh-tui 0.21.0 → 0.22.0

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 (243) hide show
  1. package/lib/CHANGELOG.md +158 -1
  2. package/lib/chrome/box.d.ts +47 -16
  3. package/lib/chrome/box.js +172 -130
  4. package/lib/chrome/box.js.map +1 -1
  5. package/lib/chrome/code-highlight.d.ts +15 -0
  6. package/lib/chrome/code-highlight.js +60 -4
  7. package/lib/chrome/code-highlight.js.map +1 -1
  8. package/lib/chrome/command-output.d.ts +29 -2
  9. package/lib/chrome/command-output.js +100 -4
  10. package/lib/chrome/command-output.js.map +1 -1
  11. package/lib/chrome/main-screen-renderer.d.ts +16 -0
  12. package/lib/chrome/main-screen-renderer.js +53 -5
  13. package/lib/chrome/main-screen-renderer.js.map +1 -1
  14. package/lib/chrome/markdown.js +30 -10
  15. package/lib/chrome/markdown.js.map +1 -1
  16. package/lib/chrome/relative-time.d.ts +17 -0
  17. package/lib/chrome/relative-time.js +36 -0
  18. package/lib/chrome/relative-time.js.map +1 -1
  19. package/lib/chrome/secret-mask.d.ts +35 -0
  20. package/lib/chrome/secret-mask.js +180 -0
  21. package/lib/chrome/secret-mask.js.map +1 -0
  22. package/lib/chrome/status-config.d.ts +161 -51
  23. package/lib/chrome/status-config.js +237 -126
  24. package/lib/chrome/status-config.js.map +1 -1
  25. package/lib/chrome/status-line.d.ts +3 -2
  26. package/lib/chrome/status-line.js +105 -63
  27. package/lib/chrome/status-line.js.map +1 -1
  28. package/lib/chrome/terminal-colors.d.ts +85 -0
  29. package/lib/chrome/terminal-colors.js +230 -0
  30. package/lib/chrome/terminal-colors.js.map +1 -0
  31. package/lib/chrome/terminal-images.d.ts +9 -1
  32. package/lib/chrome/terminal-images.js +27 -4
  33. package/lib/chrome/terminal-images.js.map +1 -1
  34. package/lib/chrome/theme.d.ts +32 -9
  35. package/lib/chrome/theme.js +595 -498
  36. package/lib/chrome/theme.js.map +1 -1
  37. package/lib/chrome/tool-renderers.js +69 -13
  38. package/lib/chrome/tool-renderers.js.map +1 -1
  39. package/lib/chrome/tools-list.d.ts +1 -4
  40. package/lib/chrome/tools-list.js +1 -41
  41. package/lib/chrome/tools-list.js.map +1 -1
  42. package/lib/chrome/welcome-tips.d.ts +20 -8
  43. package/lib/chrome/welcome-tips.js +22 -42
  44. package/lib/chrome/welcome-tips.js.map +1 -1
  45. package/lib/commands/auth-ids.d.ts +25 -0
  46. package/lib/commands/auth-ids.js +27 -0
  47. package/lib/commands/auth-ids.js.map +1 -0
  48. package/lib/commands/auth-input.d.ts +34 -0
  49. package/lib/commands/auth-input.js +96 -0
  50. package/lib/commands/auth-input.js.map +1 -0
  51. package/lib/commands/auth-providers.d.ts +39 -0
  52. package/lib/commands/auth-providers.js +125 -0
  53. package/lib/commands/auth-providers.js.map +1 -0
  54. package/lib/commands/auth.d.ts +12 -20
  55. package/lib/commands/auth.js +42 -229
  56. package/lib/commands/auth.js.map +1 -1
  57. package/lib/commands/btw.d.ts +13 -1
  58. package/lib/commands/btw.js +37 -104
  59. package/lib/commands/btw.js.map +1 -1
  60. package/lib/commands/diff.js +1 -96
  61. package/lib/commands/diff.js.map +1 -1
  62. package/lib/commands/export.d.ts +1 -1
  63. package/lib/commands/export.js +3 -27
  64. package/lib/commands/export.js.map +1 -1
  65. package/lib/commands/files.d.ts +17 -1
  66. package/lib/commands/files.js +67 -107
  67. package/lib/commands/files.js.map +1 -1
  68. package/lib/commands/jobs.d.ts +19 -4
  69. package/lib/commands/jobs.js +50 -7
  70. package/lib/commands/jobs.js.map +1 -1
  71. package/lib/commands/model.js +108 -76
  72. package/lib/commands/model.js.map +1 -1
  73. package/lib/commands/permission.js +15 -16
  74. package/lib/commands/permission.js.map +1 -1
  75. package/lib/commands/plugins.js +133 -16
  76. package/lib/commands/plugins.js.map +1 -1
  77. package/lib/commands/schedule.d.ts +10 -0
  78. package/lib/commands/schedule.js +45 -14
  79. package/lib/commands/schedule.js.map +1 -1
  80. package/lib/commands/session-configuration.js +2 -10
  81. package/lib/commands/session-configuration.js.map +1 -1
  82. package/lib/commands/session.js +14 -38
  83. package/lib/commands/session.js.map +1 -1
  84. package/lib/commands/terminal.d.ts +14 -8
  85. package/lib/commands/terminal.js +19 -178
  86. package/lib/commands/terminal.js.map +1 -1
  87. package/lib/commands/trajectory.js +4 -1
  88. package/lib/commands/trajectory.js.map +1 -1
  89. package/lib/definition.d.ts +122 -22
  90. package/lib/definition.js.map +1 -1
  91. package/lib/index.d.ts +1 -0
  92. package/lib/index.js +1 -0
  93. package/lib/index.js.map +1 -1
  94. package/lib/input/actions.d.ts +51 -0
  95. package/lib/input/actions.js +120 -0
  96. package/lib/input/actions.js.map +1 -0
  97. package/lib/input/editor.d.ts +0 -10
  98. package/lib/input/editor.js +11 -9
  99. package/lib/input/editor.js.map +1 -1
  100. package/lib/input/image-paste.d.ts +19 -1
  101. package/lib/input/image-paste.js +82 -1
  102. package/lib/input/image-paste.js.map +1 -1
  103. package/lib/input/keybindings-config.d.ts +12 -5
  104. package/lib/input/keybindings-config.js +30 -57
  105. package/lib/input/keybindings-config.js.map +1 -1
  106. package/lib/input/keys.d.ts +16 -0
  107. package/lib/input/keys.js +38 -3
  108. package/lib/input/keys.js.map +1 -1
  109. package/lib/input/wheel-scroll.d.ts +46 -0
  110. package/lib/input/wheel-scroll.js +69 -0
  111. package/lib/input/wheel-scroll.js.map +1 -0
  112. package/lib/prompt-intents.d.ts +182 -0
  113. package/lib/prompt-intents.js +127 -0
  114. package/lib/prompt-intents.js.map +1 -0
  115. package/lib/runtime/cancellable.d.ts +75 -0
  116. package/lib/runtime/cancellable.js +76 -0
  117. package/lib/runtime/cancellable.js.map +1 -0
  118. package/lib/runtime/composer-images.js +5 -4
  119. package/lib/runtime/composer-images.js.map +1 -1
  120. package/lib/runtime/composer-shell.d.ts +17 -2
  121. package/lib/runtime/composer-shell.js +109 -43
  122. package/lib/runtime/composer-shell.js.map +1 -1
  123. package/lib/runtime/file-recovery-review.d.ts +0 -1
  124. package/lib/runtime/file-recovery-review.js +29 -30
  125. package/lib/runtime/file-recovery-review.js.map +1 -1
  126. package/lib/runtime/file-recovery-store.js +5 -1
  127. package/lib/runtime/file-recovery-store.js.map +1 -1
  128. package/lib/runtime/file-review.js +1 -1
  129. package/lib/runtime/file-review.js.map +1 -1
  130. package/lib/runtime/hook-settings.d.ts +2 -2
  131. package/lib/runtime/plain-tui.d.ts +2 -0
  132. package/lib/runtime/plain-tui.js +7 -1
  133. package/lib/runtime/plain-tui.js.map +1 -1
  134. package/lib/runtime/plugin-settings.d.ts +1 -1
  135. package/lib/runtime/plugin-settings.js +10 -8
  136. package/lib/runtime/plugin-settings.js.map +1 -1
  137. package/lib/runtime/provider-local.d.ts +57 -15
  138. package/lib/runtime/provider-local.js +1112 -471
  139. package/lib/runtime/provider-local.js.map +1 -1
  140. package/lib/runtime/runner.js +5 -11
  141. package/lib/runtime/runner.js.map +1 -1
  142. package/lib/runtime/settings-preferences.js +1 -1
  143. package/lib/runtime/settings-preferences.js.map +1 -1
  144. package/lib/runtime/startup-notices.js +2 -0
  145. package/lib/runtime/startup-notices.js.map +1 -1
  146. package/lib/runtime/terminal-modes.d.ts +47 -0
  147. package/lib/runtime/terminal-modes.js +86 -0
  148. package/lib/runtime/terminal-modes.js.map +1 -0
  149. package/lib/runtime/terminal-notifications.d.ts +3 -5
  150. package/lib/runtime/terminal-notifications.js +4 -8
  151. package/lib/runtime/terminal-notifications.js.map +1 -1
  152. package/lib/runtime/terminal-probe.d.ts +36 -0
  153. package/lib/runtime/terminal-probe.js +37 -0
  154. package/lib/runtime/terminal-probe.js.map +1 -0
  155. package/lib/runtime/terminal-session.d.ts +57 -0
  156. package/lib/runtime/terminal-session.js +164 -0
  157. package/lib/runtime/terminal-session.js.map +1 -0
  158. package/lib/session/interaction-adapter.js +7 -8
  159. package/lib/session/interaction-adapter.js.map +1 -1
  160. package/lib/session/message-queue.js +1 -1
  161. package/lib/session/message-queue.js.map +1 -1
  162. package/lib/session/package-metadata.d.ts +8 -0
  163. package/lib/session/package-metadata.js +8 -0
  164. package/lib/session/package-metadata.js.map +1 -1
  165. package/lib/session/session-controller.d.ts +10 -11
  166. package/lib/session/session-controller.js +95 -13
  167. package/lib/session/session-controller.js.map +1 -1
  168. package/lib/session/session-tree.d.ts +2 -0
  169. package/lib/session/session-tree.js +2 -0
  170. package/lib/session/session-tree.js.map +1 -1
  171. package/lib/session/tui-settings.d.ts +40 -11
  172. package/lib/session/tui-settings.js +73 -34
  173. package/lib/session/tui-settings.js.map +1 -1
  174. package/lib/views/autocomplete.d.ts +18 -2
  175. package/lib/views/autocomplete.js +51 -38
  176. package/lib/views/autocomplete.js.map +1 -1
  177. package/lib/views/copy-targets.d.ts +12 -13
  178. package/lib/views/copy-targets.js +16 -46
  179. package/lib/views/copy-targets.js.map +1 -1
  180. package/lib/views/document-reader.js +18 -5
  181. package/lib/views/document-reader.js.map +1 -1
  182. package/lib/views/event-views.js +42 -6
  183. package/lib/views/event-views.js.map +1 -1
  184. package/lib/views/history-search.js +15 -21
  185. package/lib/views/history-search.js.map +1 -1
  186. package/lib/views/hotkeys.d.ts +7 -1
  187. package/lib/views/hotkeys.js +49 -25
  188. package/lib/views/hotkeys.js.map +1 -1
  189. package/lib/views/list-navigation.d.ts +62 -0
  190. package/lib/views/list-navigation.js +73 -0
  191. package/lib/views/list-navigation.js.map +1 -0
  192. package/lib/views/list-view.d.ts +99 -0
  193. package/lib/views/list-view.js +165 -0
  194. package/lib/views/list-view.js.map +1 -0
  195. package/lib/views/prompt-selector.d.ts +31 -3
  196. package/lib/views/prompt-selector.js +249 -125
  197. package/lib/views/prompt-selector.js.map +1 -1
  198. package/lib/views/session-files.d.ts +1 -2
  199. package/lib/views/session-files.js +1 -11
  200. package/lib/views/session-files.js.map +1 -1
  201. package/lib/views/settings-list.d.ts +10 -8
  202. package/lib/views/settings-list.js +116 -283
  203. package/lib/views/settings-list.js.map +1 -1
  204. package/lib/views/trajectory-timeline.d.ts +72 -0
  205. package/lib/views/trajectory-timeline.js +133 -0
  206. package/lib/views/trajectory-timeline.js.map +1 -0
  207. package/lib/views/trajectory.d.ts +15 -4
  208. package/lib/views/trajectory.js +557 -123
  209. package/lib/views/trajectory.js.map +1 -1
  210. package/lib/views/transcript-render.d.ts +95 -25
  211. package/lib/views/transcript-render.js +501 -167
  212. package/lib/views/transcript-render.js.map +1 -1
  213. package/lib/views/transcript-search.d.ts +39 -6
  214. package/lib/views/transcript-search.js +46 -11
  215. package/lib/views/transcript-search.js.map +1 -1
  216. package/lib/views/transcript-types.d.ts +63 -5
  217. package/lib/views/transcript-types.js +23 -11
  218. package/lib/views/transcript-types.js.map +1 -1
  219. package/package.json +67 -86
  220. package/lib/commands/advisor.d.ts +0 -15
  221. package/lib/commands/advisor.js +0 -200
  222. package/lib/commands/advisor.js.map +0 -1
  223. package/lib/commands/loop.d.ts +0 -52
  224. package/lib/commands/loop.js +0 -307
  225. package/lib/commands/loop.js.map +0 -1
  226. package/lib/commands/rate.d.ts +0 -33
  227. package/lib/commands/rate.js +0 -212
  228. package/lib/commands/rate.js.map +0 -1
  229. package/lib/commands/steer.d.ts +0 -5
  230. package/lib/commands/steer.js +0 -24
  231. package/lib/commands/steer.js.map +0 -1
  232. package/lib/commands/worktree.d.ts +0 -5
  233. package/lib/commands/worktree.js +0 -103
  234. package/lib/commands/worktree.js.map +0 -1
  235. package/lib/runtime/terminal-reader.d.ts +0 -11
  236. package/lib/runtime/terminal-reader.js +0 -87
  237. package/lib/runtime/terminal-reader.js.map +0 -1
  238. package/lib/views/agent-hub.d.ts +0 -32
  239. package/lib/views/agent-hub.js +0 -209
  240. package/lib/views/agent-hub.js.map +0 -1
  241. package/lib/views/copy-selector.d.ts +0 -54
  242. package/lib/views/copy-selector.js +0 -170
  243. package/lib/views/copy-selector.js.map +0 -1
@@ -3,6 +3,11 @@ import { padToWidth, truncateToWidth, visibleWidth, wrapText } from "../chrome/w
3
3
  import { moveGraphemeLeft, moveGraphemeRight } from "../chrome/grapheme.js";
4
4
  import { firstVisibleStreamTime } from "./stream-time.js";
5
5
  import { formatOverlayHint } from "./hotkey-format.js";
6
+ import { listNavigation } from "./list-navigation.js";
7
+ import { highlightCodeLines, toolPayloadLanguage } from "../chrome/code-highlight.js";
8
+ import { renderMarkdown } from "../chrome/markdown.js";
9
+ import { renderTimelineOverview, trajectoryTimeline } from "./trajectory-timeline.js";
10
+ import { formatDuration, formatTokens } from "../chrome/status-line.js";
6
11
  function object(value) {
7
12
  return typeof value === 'object' && value !== null && !Array.isArray(value)
8
13
  ? value
@@ -92,7 +97,7 @@ export class TrajectoryLedger {
92
97
  const cached = this.#fieldCache.get(record);
93
98
  if (cached !== undefined)
94
99
  return cached;
95
- const fields = [record.type, record.label, record.summary, record.payload, record.result, record.schema]
100
+ const fields = [record.type, record.label, record.summary, record.payload, record.result, record.schema, record.thinking]
96
101
  .map(field => field.toLocaleLowerCase());
97
102
  this.#fieldCache.set(record, fields);
98
103
  return fields;
@@ -139,7 +144,9 @@ export class TrajectoryLedger {
139
144
  turn: place.turn,
140
145
  step: place.step,
141
146
  label,
147
+ name: '',
142
148
  summary,
149
+ thinking: '',
143
150
  payload: json(event.data),
144
151
  result: '',
145
152
  schema: '',
@@ -196,7 +203,6 @@ export class TrajectoryLedger {
196
203
  const usage = object(message?.['usage'] ?? data['usage']);
197
204
  const startedAt = this.#stepStarted.get(key) ?? event.time;
198
205
  const existing = this.#assistantByStep.get(key);
199
- const result = reasoning === '' ? text : `Thinking\n${reasoning}\n\nAnswer\n${text}`;
200
206
  // Format-v2 messages embed the exact timed stream; derive the first
201
207
  // visible token time from it instead of live chunk events.
202
208
  if (!this.#stepFirstToken.has(key)) {
@@ -211,7 +217,8 @@ export class TrajectoryLedger {
211
217
  const record = this.#base(event, 'assistant', 'ASSISTANT', compact(text, 'Assistant response'), data);
212
218
  record.id = `assistant:${key}`;
213
219
  record.payload = this.#requestByStep.get(key) ?? record.payload;
214
- record.result = result;
220
+ record.result = text;
221
+ record.thinking = reasoning;
215
222
  record.startedAt = startedAt;
216
223
  record.durationMs = Math.max(0, event.time - startedAt);
217
224
  const first = this.#stepFirstToken.get(key);
@@ -226,7 +233,8 @@ export class TrajectoryLedger {
226
233
  this.#invalidateText(record);
227
234
  record.seq = event.seq;
228
235
  record.summary = compact(text, record.summary);
229
- record.result = result;
236
+ record.result = text;
237
+ record.thinking = reasoning;
230
238
  record.status = data['interrupted'] === true ? 'error' : 'ok';
231
239
  record.durationMs = Math.max(0, event.time - startedAt);
232
240
  record.inputTokens = tokenValue(usage, 'inputTokens', 'input_tokens', 'promptTokens', 'prompt_tokens');
@@ -243,6 +251,7 @@ export class TrajectoryLedger {
243
251
  const record = this.#base(event, parentCallId === null ? 'tool' : 'subtool', name, `${name} ${compact(json(args), '')}`.trim(), data);
244
252
  record.id = `tool:${callId}`;
245
253
  record.label = parentCallId === null ? 'TOOL' : 'SUBTOOL';
254
+ record.name = name;
246
255
  record.status = 'running';
247
256
  record.parentCallId = parentCallId;
248
257
  record.schema = this.#schemas.get(name) ?? '';
@@ -350,7 +359,7 @@ export class TrajectoryLedger {
350
359
  }
351
360
  }
352
361
  }
353
- const SEARCH_FIELDS = ['type', 'label', 'summary', 'payload', 'result', 'schema'];
362
+ const SEARCH_FIELDS = ['type', 'label', 'summary', 'payload', 'result', 'schema', 'thinking'];
354
363
  export function createTrajectory(events) {
355
364
  const ledger = new TrajectoryLedger(events);
356
365
  return {
@@ -433,11 +442,15 @@ export function restoreSearchTarget(state, target) {
433
442
  return { ...state, searchFocus: null };
434
443
  return { ...state, searchFocus: index, selectedId: match.record.id };
435
444
  }
445
+ /** Whether a record is a tool call, shown or hidden by the calls fold. */
446
+ function isCall(record) {
447
+ return record.kind === 'tool' || record.kind === 'subtool';
448
+ }
436
449
  export function trajectoryVisibleRecords(state) {
437
450
  const query = state.query.trim().toLocaleLowerCase();
438
451
  const firstInCollapsed = new Set();
439
452
  return state.ledger.records.filter((record) => {
440
- if (state.callsCollapsed && record.kind === 'subtool')
453
+ if (state.callsCollapsed && isCall(record))
441
454
  return false;
442
455
  if (record.turn !== null && state.collapsedTurns.has(record.turn)) {
443
456
  if (firstInCollapsed.has(record.turn))
@@ -449,6 +462,125 @@ export function trajectoryVisibleRecords(state) {
449
462
  return state.ledger.recordMatches(record, query);
450
463
  });
451
464
  }
465
+ /**
466
+ * One pass over the ledger for everything a frame needs to describe structure:
467
+ * turn totals and per-step call histograms.
468
+ *
469
+ * A folded turn still has to state how much it folded, and the calls fold has to
470
+ * say what it hid, so both come from every record rather than from the rows that
471
+ * survived folding. Deriving them per render would walk a 10,000-record ledger
472
+ * several times per keystroke; the ledger already counts its own mutations, so
473
+ * one pass lasts until it moves.
474
+ */
475
+ const turnStatsCache = new WeakMap();
476
+ function trajectoryIndex(ledger) {
477
+ const cached = turnStatsCache.get(ledger);
478
+ if (cached !== undefined && cached.version === ledger.version)
479
+ return cached;
480
+ const spans = new Map();
481
+ const calls = new Map();
482
+ for (const record of ledger.records) {
483
+ const key = record.turn === null ? 'between' : String(record.turn);
484
+ let entry = spans.get(key);
485
+ if (entry === undefined) {
486
+ entry = { turn: record.turn, steps: new Set(), errors: 0, warnings: 0, earliest: Number.POSITIVE_INFINITY, latest: Number.NEGATIVE_INFINITY };
487
+ spans.set(key, entry);
488
+ }
489
+ if (record.step !== null)
490
+ entry.steps.add(record.step);
491
+ if (record.status === 'error')
492
+ entry.errors += 1;
493
+ if (record.status === 'warning')
494
+ entry.warnings += 1;
495
+ if (record.startedAt !== null) {
496
+ entry.earliest = Math.min(entry.earliest, record.startedAt);
497
+ // A running record has no end yet. Counting its start as its end keeps the
498
+ // span from inventing elapsed time the ledger never recorded.
499
+ entry.latest = Math.max(entry.latest, record.startedAt + (record.durationMs ?? 0));
500
+ }
501
+ if (!isCall(record))
502
+ continue;
503
+ const callKey = `${key}:${record.step ?? 'none'}`;
504
+ const counts = calls.get(callKey) ?? new Map();
505
+ const name = record.summary.split(/\s/u)[0] ?? record.label;
506
+ counts.set(name, (counts.get(name) ?? 0) + 1);
507
+ calls.set(callKey, counts);
508
+ }
509
+ const stats = new Map();
510
+ for (const [key, entry] of spans) {
511
+ stats.set(key, {
512
+ turn: entry.turn,
513
+ steps: entry.steps.size,
514
+ errors: entry.errors,
515
+ warnings: entry.warnings,
516
+ spanMs: entry.latest > entry.earliest ? entry.latest - entry.earliest : null,
517
+ });
518
+ }
519
+ const entry = {
520
+ version: ledger.version,
521
+ stats,
522
+ calls: new Map([...calls].map(([key, counts]) => [key, [...counts]])),
523
+ };
524
+ turnStatsCache.set(ledger, entry);
525
+ return entry;
526
+ }
527
+ /**
528
+ * The ledger as three levels, the shape the desktop client settled on.
529
+ *
530
+ * A turn is the question, a step is one model request and the calls it made, and
531
+ * a record is one event. The flat list this replaced gave a user message and a
532
+ * tool result the same weight, so answering "which step took sixteen minutes"
533
+ * meant reading a location column on every row and adding durations up by hand.
534
+ * Step headers also give the calls fold somewhere to report what it hid, which is
535
+ * why the histogram comes from the whole ledger and not from the visible rows.
536
+ */
537
+ function trajectoryRows(state) {
538
+ const index = trajectoryIndex(state.ledger);
539
+ const rows = [];
540
+ const buckets = [];
541
+ for (const record of trajectoryVisibleRecords(state)) {
542
+ const last = buckets.at(-1);
543
+ if (last !== undefined && last.turn === record.turn)
544
+ last.records.push(record);
545
+ else
546
+ buckets.push({ turn: record.turn, records: [record] });
547
+ }
548
+ for (const bucket of buckets) {
549
+ const key = bucket.turn === null ? 'between' : String(bucket.turn);
550
+ rows.push({
551
+ kind: 'turn',
552
+ id: `turn:${key}`,
553
+ stats: index.stats.get(key) ?? { turn: bucket.turn, steps: 0, errors: 0, warnings: 0, spanMs: null },
554
+ collapsed: bucket.turn !== null && state.collapsedTurns.has(bucket.turn),
555
+ });
556
+ let step;
557
+ let group = [];
558
+ const flush = () => {
559
+ if (group.length === 0)
560
+ return;
561
+ if (step !== null && step !== undefined) {
562
+ rows.push({
563
+ kind: 'step',
564
+ id: `step:${key}:${step}`,
565
+ step,
566
+ hiddenCalls: state.callsCollapsed ? index.calls.get(`${key}:${step}`) ?? [] : [],
567
+ });
568
+ }
569
+ for (const record of group)
570
+ rows.push({ kind: 'record', id: record.id, record });
571
+ group = [];
572
+ };
573
+ for (const record of bucket.records) {
574
+ if (record.step !== step) {
575
+ flush();
576
+ step = record.step;
577
+ }
578
+ group.push(record);
579
+ }
580
+ flush();
581
+ }
582
+ return rows;
583
+ }
452
584
  /** Shared result for the empty query, which matches nothing by definition. */
453
585
  const EMPTY_SEARCH = { matches: [], counts: new Map() };
454
586
  /**
@@ -524,7 +656,7 @@ function locateMatch(state, index) {
524
656
  searchFocus: index,
525
657
  selectedId: record.id,
526
658
  collapsedTurns,
527
- callsCollapsed: state.callsCollapsed && record.kind !== 'subtool',
659
+ callsCollapsed: state.callsCollapsed && !isCall(record),
528
660
  detailScroll: 0,
529
661
  following: false,
530
662
  };
@@ -559,11 +691,44 @@ function graphemes(text) {
559
691
  trajectorySegmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
560
692
  return trajectorySegmenter.segment(text);
561
693
  }
562
- const DETAIL_TABS = ['summary', 'payload', 'result', 'schema', 'timing'];
694
+ /**
695
+ * Tabs a record can actually fill.
696
+ *
697
+ * The tabs used to be fixed, so most records offered four that said "No payload
698
+ * recorded." and the reader pressed `Tab` through them to find the one with
699
+ * content. The desktop client instead asks each record which sections it has,
700
+ * which is also why its tab row never shows a dead one. Timing was a section of
701
+ * its own until it became clear that the numbers a reader wants first belong in
702
+ * the summary, beside the record's identity.
703
+ */
704
+ function detailTabsFor(record) {
705
+ if (record === undefined)
706
+ return ['summary'];
707
+ const tabs = ['summary'];
708
+ if (record.payload !== '')
709
+ tabs.push('payload');
710
+ if (record.result !== '')
711
+ tabs.push('result');
712
+ if (record.schema !== '')
713
+ tabs.push('schema');
714
+ return tabs;
715
+ }
716
+ /**
717
+ * The tab the pane is actually showing.
718
+ *
719
+ * The state keeps the reader's last choice, so returning to a record brings the
720
+ * same section back; a record without that section falls back to its first one
721
+ * instead of printing an empty placeholder.
722
+ */
723
+ function activeTab(state) {
724
+ const tabs = detailTabsFor(selectedRecord(state));
725
+ return tabs.includes(state.detailTab) ? state.detailTab : tabs[0] ?? 'summary';
726
+ }
563
727
  function moveTab(state, delta) {
564
- const current = DETAIL_TABS.indexOf(state.detailTab);
565
- const next = (current + delta + DETAIL_TABS.length) % DETAIL_TABS.length;
566
- return { ...state, detailTab: DETAIL_TABS[next] ?? 'summary', detailScroll: 0 };
728
+ const tabs = detailTabsFor(selectedRecord(state));
729
+ const current = Math.max(0, tabs.indexOf(activeTab(state)));
730
+ const next = (current + delta + tabs.length) % tabs.length;
731
+ return { ...state, detailTab: tabs[next] ?? 'summary', detailScroll: 0 };
567
732
  }
568
733
  const DEFAULT_PAGE_SIZE = 10;
569
734
  const DEFAULT_DETAIL_PAGE_LINES = 10;
@@ -625,17 +790,56 @@ export function applyTrajectoryEvent(state, event, options = {}) {
625
790
  }
626
791
  return { state };
627
792
  }
793
+ /**
794
+ * A wheel gesture scrolls whatever the reader is reading.
795
+ *
796
+ * The view received wheel events already but ignored everything that was not a
797
+ * key, so a mouse reader found the only way to scroll the detail pane was
798
+ * `PgUp`/`PgDn` — while the wheel silently did nothing at all. It now scrolls
799
+ * the detail when one is open and moves the selection otherwise, which is what
800
+ * the same gesture does in the transcript.
801
+ */
802
+ if (event.type === 'wheel') {
803
+ const step = Math.max(1, Math.round(detailPageLines / 2));
804
+ if (state.details) {
805
+ const delta = event.direction === 'up' ? -step : step;
806
+ return { state: { ...state, detailScroll: Math.max(0, state.detailScroll + delta) } };
807
+ }
808
+ return { state: move(state, (event.direction === 'up' ? -1 : 1) * 3) };
809
+ }
628
810
  if (event.type !== 'key')
629
811
  return { state };
630
- if (event.id === 'escape' || event.id === 'ctrl+c') {
812
+ // This ledger keeps three keys that the shared reading would move through a
813
+ // list: `tab` switches the detail tab, `pageUp`/`pageDown` scroll an open
814
+ // detail pane, and `home`/`end` carry following state. They are read first, so
815
+ // the shared reading below only ever answers for the keys this surface really
816
+ // shares — up, down, and closing one layer.
817
+ if (event.id === 'tab' || event.id === 'right')
818
+ return { state: moveTab(state, 1) };
819
+ if (event.id === 'shift+tab' || event.id === 'left')
820
+ return { state: moveTab(state, -1) };
821
+ /**
822
+ * With the detail pane open, the arrows belong to it.
823
+ *
824
+ * They used to move the record selection even then, so a reader scrolling a long
825
+ * payload changed which record the pane described instead of scrolling: the
826
+ * content jumped out from under them and the pane snapped back to the top of a
827
+ * different record. `Tab` still switches tabs and `Esc` still closes the pane,
828
+ * so nothing is lost — the selection simply stops competing with the text.
829
+ */
830
+ if (state.details && (event.id === 'up' || event.id === 'down')) {
831
+ const delta = event.id === 'up' ? -1 : 1;
832
+ return { state: { ...state, detailScroll: Math.max(0, state.detailScroll + delta) } };
833
+ }
834
+ const navigation = listNavigation(event);
835
+ if (navigation.kind === 'close') {
836
+ // One layer per press: an open detail panel closes before the ledger does.
631
837
  if (state.details)
632
838
  return { state: { ...state, details: false, detailScroll: 0 } };
633
839
  return { close: true };
634
840
  }
635
- if (event.id === 'up')
636
- return { state: move(state, -1) };
637
- if (event.id === 'down')
638
- return { state: move(state, 1) };
841
+ if (navigation.kind === 'move')
842
+ return { state: move(state, navigation.delta) };
639
843
  if (event.id === 'pageUp') {
640
844
  if (state.details)
641
845
  return { state: { ...state, detailScroll: Math.max(0, state.detailScroll - detailPageLines) } };
@@ -660,17 +864,13 @@ export function applyTrajectoryEvent(state, event, options = {}) {
660
864
  }
661
865
  if (event.id === 'enter')
662
866
  return { state: { ...state, details: !state.details, detailScroll: 0 } };
663
- if (event.id === 'tab' || event.id === 'right')
664
- return { state: moveTab(state, 1) };
665
- if (event.id === 'shift+tab' || event.id === 'left')
666
- return { state: moveTab(state, -1) };
667
867
  return { state };
668
868
  }
669
869
  // Every key `applyTrajectoryEvent` accepts. Rows whose first word is the footer
670
870
  // label are also printed by the overlay's toolbar and footer.
671
- const HOTKEY_NAVIGATE = { keys: '↑↓', action: 'Navigate records' };
871
+ const HOTKEY_NAVIGATE = { keys: '↑↓', action: 'Navigate records, or scroll an open detail' };
672
872
  const HOTKEY_EDGE = { keys: 'Home / End', action: 'Jump to the first record, or follow the newest' };
673
- const HOTKEY_PAGE = { keys: 'PgUp / PgDn', action: 'Scroll — page the detail or the record list' };
873
+ const HOTKEY_PAGE = { keys: '↑↓ / PgUp / PgDn', action: 'Scroll the open detail, or page the record list' };
674
874
  const HOTKEY_DETAILS = { keys: 'Enter', action: 'Details — open or close the detail panel' };
675
875
  const HOTKEY_SECTION = { keys: 'Tab / ←→', action: 'Section — switch the detail tab' };
676
876
  const HOTKEY_SEARCH = { keys: '/', action: 'Search the ledger' };
@@ -697,53 +897,67 @@ export const TRAJECTORY_HOTKEYS = [
697
897
  HOTKEY_BACKSPACE,
698
898
  HOTKEY_CLOSE,
699
899
  ];
900
+ /**
901
+ * Rows the timing overview occupies, or 0 when it is not drawn.
902
+ *
903
+ * Rendering and paging both need this: the overview sits between the toolbar and
904
+ * the ledger, so a page that ignored it would scroll three rows further than the
905
+ * frame can show.
906
+ */
907
+ export function trajectoryOverviewHeight(state, width = Number.POSITIVE_INFINITY) {
908
+ if (state.details || state.query !== '')
909
+ return 0;
910
+ if (width < 24)
911
+ return 0;
912
+ let steps = 0;
913
+ for (const stats of trajectoryIndex(state.ledger).stats.values())
914
+ steps += stats.steps;
915
+ if (steps < 2)
916
+ return 0;
917
+ const overview = trajectoryTimeline(state.ledger.records, { width: 1 });
918
+ return overview === undefined ? 0 : overview.lanes.filter(span => span.ms > 0).length;
919
+ }
700
920
  /** List paging capacity in records for one body height (pure, shared with rendering). */
701
- export function trajectoryListMetrics(state, height) {
702
- const body = Math.max(0, height - 4);
921
+ export function trajectoryListMetrics(state, height, width = Number.POSITIVE_INFINITY) {
922
+ const body = Math.max(0, height - 4 - trajectoryOverviewHeight(state, width));
703
923
  if (body <= 0)
704
924
  return { pageSize: 0 };
705
- const layout = rowLayout(state);
706
- if (layout.length === 0)
925
+ const rows = trajectoryRows(state);
926
+ if (rows.length === 0)
707
927
  return { pageSize: 1 };
708
- const selected = Math.max(0, layout.findIndex(row => row.id === state.selectedId));
709
- const start = Math.max(0, Math.min(layout.length - 1, selected - Math.floor(body / 2)));
710
- // Count record rows inside the window; turn headers only occupy space.
711
- let rows = 0;
928
+ const selected = rows.findIndex(row => row.kind === 'record' && row.id === state.selectedId);
929
+ const start = Math.max(0, Math.min(rows.length - 1, Math.max(0, selected) - Math.floor(body / 2)));
930
+ // Count record rows inside the window; turn and step headers only occupy space.
712
931
  let count = 0;
713
- for (let index = start; index < layout.length; index += 1) {
714
- rows += 1;
715
- if (rows > body)
716
- break;
717
- if (layout[index]?.turnHeader !== true)
932
+ for (let index = start; index < rows.length && index - start < body; index += 1) {
933
+ if (rows[index]?.kind === 'record')
718
934
  count += 1;
719
935
  }
720
936
  return { pageSize: Math.max(1, count) };
721
937
  }
722
- /** Per-record row layout (turn headers included), consumed by rendering and metrics. */
723
- function rowLayout(state) {
724
- const layout = [];
725
- let previousTurn;
726
- for (const record of trajectoryVisibleRecords(state)) {
727
- if (record.turn !== previousTurn) {
728
- previousTurn = record.turn;
729
- layout.push({ id: `turn:${record.turn ?? 'between'}`, turnHeader: true });
730
- }
731
- layout.push({ id: record.id, turnHeader: false });
732
- }
733
- return layout;
734
- }
735
938
  /** Detail scroll step in wrapped lines for one body height (pure, shared with rendering). */
736
939
  export function trajectoryDetailMetrics(_state, height) {
940
+ // The frame spends four rows on its own chrome and the pane three on its title,
941
+ // its sections and the gap between them.
737
942
  const body = Math.max(0, height - 4);
738
- const viewport = Math.max(0, body - 2);
943
+ const viewport = Math.max(0, body - 3);
739
944
  return { pageLines: Math.max(1, viewport) };
740
945
  }
741
946
  function duration(record) {
742
947
  if (record.durationMs === null)
743
948
  return record.status === 'running' ? 'live' : '—';
744
- if (record.durationMs < 1_000)
745
- return `${Math.round(record.durationMs)}ms`;
746
- return `${(record.durationMs / 1_000).toFixed(record.durationMs < 10_000 ? 1 : 0)}s`;
949
+ return formatDuration(record.durationMs);
950
+ }
951
+ /** `12.4K→812` for a model request, or '' for a record that reported no usage. */
952
+ function tokenPair(record) {
953
+ if (record.kind !== 'assistant')
954
+ return '';
955
+ if (record.inputTokens === null && record.outputTokens === null)
956
+ return '';
957
+ const side = (value) => value === null ? '—' : formatTokens(value);
958
+ // The incoming side is padded so the arrow, and therefore the duration column
959
+ // beside it, stays on one column across every request in the ledger.
960
+ return `${side(record.inputTokens).padStart(6)}→${side(record.outputTokens)}`;
747
961
  }
748
962
  function kindColor(kind) {
749
963
  if (kind === 'user')
@@ -778,14 +992,29 @@ function highlightSummary(summary, spans, theme) {
778
992
  }
779
993
  function recordLine(record, selected, theme, width, highlightSpans, matchCount, summaryOverride) {
780
994
  const marker = selected ? theme.fg('accent', '›') : ' ';
781
- const locationLabel = record.turn === null ? ' — ' : `T${record.turn}${record.step === null ? '' : `·${record.step}`}`.padEnd(5);
782
- const kind = theme.fg(kindColor(record.kind), record.label.padEnd(9).slice(0, 9));
783
- const elapsed = duration(record).padStart(6);
995
+ /*
996
+ * No location column: the turn rule and the step header above already say
997
+ * where this row is, and `T1·1` on every line spent five cells restating it in
998
+ * a form nobody reads. The right edge instead carries what the row cost:
999
+ * tokens for a model request, duration for everything.
1000
+ */
1001
+ const prefix = `${marker} ${theme.fg('dim', `#${String(record.index).padStart(3)}`)} ${theme.fg(kindColor(record.kind), record.label.padEnd(9).slice(0, 9))} `;
784
1002
  const badge = matchCount === undefined ? '' : ` ${theme.fg('dim', `×${matchCount}`)}`;
785
- const prefix = `${marker} ${String(record.index).padStart(4)} ${locationLabel} ${kind} ${elapsed} `;
786
- const bodyWidth = Math.max(0, width - visibleWidth(prefix) - visibleWidth(badge));
1003
+ // A record with no recorded duration prints nothing rather than an em dash:
1004
+ // a column of `—` where most rows have a time reads as missing data, when in
1005
+ // fact a user message simply never had a duration.
1006
+ const elapsed = record.durationMs === null && record.status !== 'running' ? '' : duration(record);
1007
+ const tokens = tokenPair(record);
1008
+ const right = (withTokens) => withTokens ? `${tokens} ${elapsed}` : elapsed;
1009
+ const fixed = visibleWidth(prefix) + visibleWidth(badge);
1010
+ // Tokens are the first thing to go: the pane still has them, and the duration
1011
+ // is the reason this view exists.
1012
+ const columns = tokens === '' || width - fixed - visibleWidth(right(true)) - 12 < 0 ? right(false) : right(true);
787
1013
  const body = summaryOverride ?? highlightSummary(record.summary, highlightSpans);
788
- return prefix + truncateToWidth(body, bodyWidth) + badge;
1014
+ const bodyWidth = Math.max(0, width - fixed - visibleWidth(columns) - 2);
1015
+ const text = truncateToWidth(body, bodyWidth);
1016
+ const gap = Math.max(1, width - fixed - visibleWidth(text) - visibleWidth(columns));
1017
+ return prefix + text + ' '.repeat(Math.max(0, gap)) + columns + badge;
789
1018
  }
790
1019
  /** Snippet of one record field around its first hit (non-summary fields). */
791
1020
  function fieldSnippet(record, field, highlightSpans, width, theme, focused) {
@@ -816,38 +1045,72 @@ function graphemeStart(text, cursor) {
816
1045
  function selectedRecord(state) {
817
1046
  return state.ledger.records.find(record => record.id === state.selectedId);
818
1047
  }
819
- function timingText(record) {
820
- return [
821
- `Event: ${record.type} (#${record.seq})`,
822
- `Started: ${record.startedAt === null ? '—' : new Date(record.startedAt).toISOString()}`,
823
- `Duration: ${record.durationMs === null ? '—' : `${Math.round(record.durationMs)} ms`}`,
824
- `TTFT: ${record.ttftMs === null ? '—' : `${Math.round(record.ttftMs)} ms`}`,
825
- `Tokens: ${record.inputTokens ?? '—'} in · ${record.outputTokens ?? '—'} out`,
826
- `Status: ${record.status}`,
827
- ].join('\n');
828
- }
829
- function detailText(state, record) {
1048
+ /** Clock time, which is what a reader compares against a log line. */
1049
+ function clockTime(ms) {
1050
+ const date = new Date(ms);
1051
+ const pad = (value) => String(value).padStart(2, '0');
1052
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1053
+ }
1054
+ /**
1055
+ * Label/value pairs for one record, omitting what it does not have.
1056
+ *
1057
+ * The title already states where the record sits and how long it took, so this
1058
+ * block does not repeat either: it answers what the record is, when it happened,
1059
+ * and what it cost the model.
1060
+ */
1061
+ function summaryFields(record) {
1062
+ const rows = [];
1063
+ rows.push({ label: 'Event', value: `${record.type} · #${record.seq}` });
1064
+ if (record.startedAt !== null)
1065
+ rows.push({ label: 'Started', value: clockTime(record.startedAt) });
1066
+ if (record.ttftMs !== null)
1067
+ rows.push({ label: 'First token', value: formatDuration(record.ttftMs) });
1068
+ if (record.inputTokens !== null || record.outputTokens !== null) {
1069
+ const side = (value) => value === null ? '—' : formatTokens(value);
1070
+ rows.push({ label: 'Tokens', value: `${side(record.inputTokens)} in · ${side(record.outputTokens)} out` });
1071
+ }
1072
+ // `ok` on every record is the kind of field that teaches nothing; the ones
1073
+ // worth a line are the ones that went wrong.
1074
+ if (record.status !== 'ok')
1075
+ rows.push({ label: 'Status', value: record.status });
1076
+ return rows;
1077
+ }
1078
+ /** The assistant's reasoning and answer are two pieces of writing, not one blob. */
1079
+ function assistantWriting(record, theme, width) {
1080
+ const out = [];
1081
+ if (record.thinking !== '') {
1082
+ out.push(theme.fg('dim', 'Thinking'), ...renderMarkdown(record.thinking, theme, width));
1083
+ if (record.result !== '')
1084
+ out.push('', theme.fg('dim', 'Answer'));
1085
+ }
1086
+ if (record.result !== '')
1087
+ out.push(...renderMarkdown(record.result, theme, width));
1088
+ return out;
1089
+ }
1090
+ function detailSection(tab, record) {
830
1091
  if (record === undefined)
831
- return 'No trajectory record selected.';
832
- if (state.detailTab === 'payload')
833
- return record.payload || 'No payload recorded.';
834
- if (state.detailTab === 'result')
835
- return record.result || 'No result recorded.';
836
- if (state.detailTab === 'schema')
837
- return record.schema || 'No tool schema recorded.';
838
- if (state.detailTab === 'timing')
839
- return timingText(record);
840
- return [
841
- `${record.label} · ${record.type}`,
842
- `Turn ${record.turn ?? 'between'} · Step ${record.step ?? '—'} · ${duration(record)}`,
843
- '',
844
- record.summary,
845
- ].join('\n');
1092
+ return { kind: 'prose', text: 'No trajectory record selected.' };
1093
+ if (tab === 'summary')
1094
+ return { kind: 'fields', rows: summaryFields(record) };
1095
+ if (tab === 'payload')
1096
+ return { kind: 'code', text: record.payload, language: 'json' };
1097
+ if (tab === 'schema')
1098
+ return { kind: 'code', text: record.schema, language: 'json' };
1099
+ // A model's answer is prose; a tool's output is whatever the tool spoke.
1100
+ const spoken = record.kind === 'assistant' || record.kind === 'compaction';
1101
+ if (spoken)
1102
+ return { kind: 'prose', text: record.result };
1103
+ return {
1104
+ kind: 'code',
1105
+ text: record.result,
1106
+ language: toolPayloadLanguage(record.type === 'tool/call' ? record.label.toLowerCase() : ''),
1107
+ };
846
1108
  }
847
1109
  function ledgerRows(state, theme, width, height) {
848
- const visible = trajectoryVisibleRecords(state);
849
- if (visible.length === 0)
1110
+ const rows = trajectoryRows(state);
1111
+ if (!rows.some(row => row.kind === 'record')) {
850
1112
  return [theme.fg('dim', state.query === '' ? ' No trajectory records.' : ' No matching trajectory records.')];
1113
+ }
851
1114
  const { matches, counts } = trajectorySearch(state);
852
1115
  const focused = state.searchFocus === null ? undefined : matches[state.searchFocus];
853
1116
  const highlighted = new Map();
@@ -856,16 +1119,12 @@ function ledgerRows(state, theme, width, height) {
856
1119
  list.push(match);
857
1120
  highlighted.set(match.record.id, list);
858
1121
  }
859
- const lines = [];
860
- const layout = rowLayout(state);
861
- let previousTurn;
862
- for (const record of visible) {
863
- if (record.turn !== previousTurn) {
864
- previousTurn = record.turn;
865
- const label = record.turn === null ? 'Between turns' : `Turn ${record.turn}`;
866
- const collapsed = record.turn !== null && state.collapsedTurns.has(record.turn) ? ' · collapsed' : '';
867
- lines.push({ id: `turn:${record.turn ?? 'between'}`, text: theme.fg('accent', `── ${label}${collapsed} `) });
868
- }
1122
+ const lines = rows.map((row) => {
1123
+ if (row.kind === 'turn')
1124
+ return turnLine(row.stats, row.collapsed, theme, width);
1125
+ if (row.kind === 'step')
1126
+ return stepLine(row.step, row.hiddenCalls, theme, width);
1127
+ const record = row.record;
869
1128
  const recordMatches = highlighted.get(record.id) ?? [];
870
1129
  const focusedForRecord = focused?.record === record ? focused : undefined;
871
1130
  const headField = focusedForRecord !== undefined ? focusedForRecord.field : recordMatches[0]?.field;
@@ -873,26 +1132,150 @@ function ledgerRows(state, theme, width, height) {
873
1132
  const summary = headField === undefined || headField === 'summary'
874
1133
  ? highlightSummary(record.summary, headSpans, theme)
875
1134
  : fieldSnippet(record, headField, headSpans, Math.max(0, width - 24), theme, focusedForRecord);
876
- lines.push({
877
- id: record.id,
878
- text: recordLine(record, record.id === state.selectedId, theme, width, headField === 'summary' ? headSpans : [], state.query === '' ? undefined : counts.get(record.id), summary),
879
- });
880
- }
881
- const selectedIndex = Math.max(0, layout.findIndex(row => row.id === state.selectedId));
882
- const start = Math.max(0, Math.min(layout.length - 1, selectedIndex - Math.floor(height / 2)));
883
- return lines.slice(start, start + height).map(line => truncateToWidth(line.text, width));
1135
+ return recordLine(record, record.id === state.selectedId, theme, width, headField === 'summary' ? headSpans : [], state.query === '' ? undefined : counts.get(record.id), summary);
1136
+ });
1137
+ const selectedIndex = Math.max(0, rows.findIndex(row => row.kind === 'record' && row.id === state.selectedId));
1138
+ const start = Math.max(0, Math.min(rows.length - 1, selectedIndex - Math.floor(height / 2)));
1139
+ return lines.slice(start, start + height).map(line => truncateToWidth(line, width));
884
1140
  }
1141
+ /**
1142
+ * The detail pane.
1143
+ *
1144
+ * Two rows of header — the record's identity and its section names — then the
1145
+ * section itself. Position and scroll hint ride on the section row rather than
1146
+ * taking a line of their own, and the sections are separated from the content by
1147
+ * whitespace instead of a rule, because in the split view a rule would sit one
1148
+ * column from the pane's own border.
1149
+ */
885
1150
  function detailRows(state, theme, width, height) {
886
- const tabs = DETAIL_TABS.map(tab => tab === state.detailTab ? theme.fg('accent', `[${tab}]`) : theme.fg('dim', tab)).join(' ');
887
- const body = wrapText(detailText(state, selectedRecord(state)), Math.max(1, width - 2));
888
- const viewportHeight = Math.max(0, height - 2);
889
- const maxScroll = Math.max(0, body.length - viewportHeight);
1151
+ const record = selectedRecord(state);
1152
+ const tab = activeTab(state);
1153
+ const tabs = detailTabsFor(record);
1154
+ const section = detailSection(tab, record);
1155
+ const inner = Math.max(1, width - 2);
1156
+ const body = section.kind === 'fields'
1157
+ ? summaryBlock(section.rows, record, theme, inner)
1158
+ : section.kind === 'prose'
1159
+ ? (record !== undefined && record.kind === 'assistant' && tab === 'result'
1160
+ ? assistantWriting(record, theme, inner)
1161
+ : renderMarkdown(section.text, theme, inner))
1162
+ : codeRows(section.text, section.language, theme, inner);
1163
+ const viewport = Math.max(0, height - 3);
1164
+ const maxScroll = Math.max(0, body.length - viewport);
890
1165
  const start = Math.min(state.detailScroll, maxScroll);
891
- const end = Math.min(body.length, start + viewportHeight);
892
- const position = body.length > viewportHeight
893
- ? theme.fg('dim', ` ${start + 1}-${end} / ${body.length} lines · PgUp/PgDn scroll`)
894
- : '';
895
- return [truncateToWidth(tabs, width), truncateToWidth(position, width), ...body.slice(start, end)];
1166
+ const end = Math.min(body.length, start + viewport);
1167
+ const first = truncateToWidth(titleLine(record, theme, width), width);
1168
+ /*
1169
+ * The active section is marked, not only coloured. Every style helper is a
1170
+ * no-op when the terminal has no colour, so colour alone would leave a reader
1171
+ * on such a terminal unable to tell which section they are looking at. `›` is
1172
+ * the same "this one" the ledger uses for its selected row.
1173
+ */
1174
+ const tabsText = tabs.map(candidate => candidate === tab
1175
+ ? theme.bold(theme.fg('accent', `› ${candidate}`))
1176
+ : theme.fg('dim', ` ${candidate}`)).join(' ');
1177
+ const position = body.length > viewport ? theme.fg('dim', `${start + 1}-${end} / ${body.length}`) : '';
1178
+ const gap = Math.max(1, width - visibleWidth(tabsText) - visibleWidth(position));
1179
+ const second = truncateToWidth(tabsText + ' '.repeat(gap) + position, width);
1180
+ return [first, second, '', ...body.slice(start, end).map(line => truncateToWidth(' ' + line, width))];
1181
+ }
1182
+ /** `#3 TOOL` — which record this pane is about, and what it cost. */
1183
+ function titleLine(record, theme, width) {
1184
+ if (record === undefined)
1185
+ return '';
1186
+ const where = record.turn === null ? 'Between turns' : `Turn ${record.turn}`;
1187
+ const place = record.step === null ? where : `${where} · Step ${record.step}`;
1188
+ const called = record.name === '' ? '' : theme.fg('text', ` ${record.name}`);
1189
+ const left = theme.bold(`#${record.index} ${record.label}`) + called + theme.fg('dim', ` ${place}`);
1190
+ const right = record.durationMs === null ? '' : theme.fg('dim', duration(record));
1191
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
1192
+ return left + ' '.repeat(gap) + right;
1193
+ }
1194
+ /**
1195
+ * The facts, then the record's own text when no other section holds it.
1196
+ *
1197
+ * A record whose text has a section of its own — an answer in `result`, a call in
1198
+ * `payload` — would only see that text flattened into one row here, which is what
1199
+ * made the pane read as a wall of code. A record with nowhere else to put it (a
1200
+ * turn that failed, a message the user sent) gets it as writing, below the facts.
1201
+ */
1202
+ function summaryBlock(rows, record, theme, width) {
1203
+ const facts = fieldRows(rows, theme, width);
1204
+ if (record === undefined || record.result !== '' || record.summary === '')
1205
+ return facts;
1206
+ return [...facts, '', ...renderMarkdown(record.summary, theme, width)];
1207
+ }
1208
+ /** Label/value rows with the label column as wide as the widest label needs. */
1209
+ function fieldRows(rows, theme, width) {
1210
+ const labelWidth = Math.max(...rows.map(field => field.label.length));
1211
+ const out = [];
1212
+ for (const field of rows) {
1213
+ const room = Math.max(1, width - labelWidth - 2);
1214
+ const wrapped = field.wrap === true ? wrapText(field.value, room) : [truncateToWidth(field.value, room)];
1215
+ out.push(theme.fg('dim', field.label.padEnd(labelWidth)) + ' ' + (wrapped[0] ?? ''));
1216
+ // A long value continues under itself rather than under its label.
1217
+ for (const line of wrapped.slice(1))
1218
+ out.push(' '.repeat(labelWidth + 2) + line);
1219
+ }
1220
+ return out;
1221
+ }
1222
+ /**
1223
+ * Code, numbered only once it wraps.
1224
+ *
1225
+ * The gutter used to number every wrapped row, so a JSON line spread over four
1226
+ * rows read as four source lines and nothing lined up. Numbers now belong to
1227
+ * source lines, a wrapped continuation leaves the column blank, and the gutter
1228
+ * appears at all only when some line is too long to fit — a short payload reads
1229
+ * better as the block it is.
1230
+ */
1231
+ function codeRows(text, language, theme, width) {
1232
+ if (text === '')
1233
+ return [theme.fg('dim', 'Nothing recorded.')];
1234
+ const source = text.split('\n');
1235
+ const digits = String(source.length).length;
1236
+ const gutter = digits + 1;
1237
+ const unwrapped = source.every(line => visibleWidth(line) <= width);
1238
+ const room = Math.max(1, width - (unwrapped ? 0 : gutter));
1239
+ const painted = language === undefined
1240
+ ? source
1241
+ : highlightCodeLines(source, language, theme);
1242
+ const out = [];
1243
+ for (const [index, line] of painted.entries()) {
1244
+ const rows = wrapText(line, room);
1245
+ out.push(unwrapped ? (rows[0] ?? '') : theme.fg('dim', String(index + 1).padStart(digits) + ' ') + (rows[0] ?? ''));
1246
+ for (const rest of rows.slice(1))
1247
+ out.push(' '.repeat(unwrapped ? 0 : gutter) + rest);
1248
+ }
1249
+ return out;
1250
+ }
1251
+ /**
1252
+ * A turn rule: the label on the left, the time it took against the right edge.
1253
+ *
1254
+ * The time is the whole reason this view exists, and "where did sixteen minutes
1255
+ * go" is answered top-down: the turn states its span, the step headers break it
1256
+ * into requests, and each record states its own duration.
1257
+ */
1258
+ function turnLine(stats, collapsed, theme, width) {
1259
+ const notes = [];
1260
+ // Steps are only worth counting where they are not visible.
1261
+ if (collapsed)
1262
+ notes.push(`${stats.steps} ${stats.steps === 1 ? 'step' : 'steps'}`, 'collapsed');
1263
+ if (stats.errors > 0)
1264
+ notes.push(`${stats.errors} ${stats.errors === 1 ? 'error' : 'errors'}`);
1265
+ if (stats.warnings > 0)
1266
+ notes.push(`${stats.warnings} ${stats.warnings === 1 ? 'warning' : 'warnings'}`);
1267
+ const label = stats.turn === null ? 'Between turns' : `Turn ${stats.turn}`;
1268
+ const left = `── ${label}${notes.length === 0 ? '' : ` · ${notes.join(' · ')}`} `;
1269
+ const right = stats.spanMs === null ? '' : ` ${formatDuration(stats.spanMs)}`;
1270
+ const room = Math.max(0, width - visibleWidth(left) - visibleWidth(right));
1271
+ return theme.fg('accent', left) + theme.fg('border', '─'.repeat(room)) + theme.fg('dim', right);
1272
+ }
1273
+ /** A step group: the request's boundary, and what the calls fold is hiding. */
1274
+ function stepLine(step, hiddenCalls, theme, width) {
1275
+ const tools = hiddenCalls.map(([name, count]) => count > 1 ? `${name}×${count}` : name).join(' ');
1276
+ const left = ` Step ${step}${tools === '' ? '' : ` · ${tools}`} `;
1277
+ const room = Math.max(0, width - visibleWidth(left) - 1);
1278
+ return theme.fg('dim', left + '·'.repeat(room));
896
1279
  }
897
1280
  /** `(3/10)`-style position while the search is active, or ''. */
898
1281
  function searchPosition(state) {
@@ -902,23 +1285,74 @@ function searchPosition(state) {
902
1285
  const focus = Math.max(0, state.searchFocus ?? 0);
903
1286
  return `(${Math.min(focus + 1, matches.length)}/${matches.length})`;
904
1287
  }
905
- /** Render the full-screen ledger without touching the ordinary transcript viewport. */
1288
+ /** Lane ink, matching how the ledger paints the same kinds. */
1289
+ const LANE_TONE = {
1290
+ llm: 'accent',
1291
+ tools: 'success',
1292
+ other: 'dim',
1293
+ };
1294
+ /**
1295
+ * The overview rows, or `undefined` when no record recorded a duration.
1296
+ *
1297
+ * A session that has only just started has nothing to show, and an empty
1298
+ * three-lane bar would read as "all lanes are zero" rather than "not yet".
1299
+ */
1300
+ function timelineRows(state, theme, width) {
1301
+ const overview = trajectoryTimeline(state.ledger.records, { width });
1302
+ if (overview === undefined || width < 24)
1303
+ return undefined;
1304
+ return renderTimelineOverview(overview, {
1305
+ width,
1306
+ // The same precision the ledger rows use: a lane that took 0.4 s must not
1307
+ // read `0s` beside a row that says `0.4s`.
1308
+ formatDuration,
1309
+ paint: (lane, text) => theme.fg(LANE_TONE[lane], text),
1310
+ paintEmpty: text => theme.fg('borderMuted', text),
1311
+ }).map(row => truncateToWidth(' ' + row, width));
1312
+ }
906
1313
  export function renderTrajectory(state, theme, width, height) {
907
1314
  const safeWidth = Math.max(1, width);
908
1315
  const safeHeight = Math.max(1, height);
909
- const records = state.ledger.records;
910
- const errors = records.filter(record => record.status === 'error').length;
911
- const warnings = records.filter(record => record.status === 'warning').length;
912
- const requests = records.filter(record => record.kind === 'assistant').length;
913
- const tools = records.filter(record => record.kind === 'tool' || record.kind === 'subtool').length;
914
- const header = truncateToWidth(theme.bold(' Trajectory ') + theme.fg('dim', `${records.length} records · ${requests} requests · ${tools} calls · ${warnings} warnings · ${errors} errors`), safeWidth);
1316
+ /*
1317
+ * Human nouns, not internal ones. `12 records · 6 requests · 9 calls` counted
1318
+ * events the reader never named; a turn and a step are what the ledger is made
1319
+ * of, and the span is the question. Counts of problems appear only when there
1320
+ * are problems.
1321
+ */
1322
+ const stats = [...trajectoryIndex(state.ledger).stats.values()];
1323
+ const turns = stats.filter(entry => entry.turn !== null).length;
1324
+ const steps = stats.reduce((sum, entry) => sum + entry.steps, 0);
1325
+ const spanMs = stats.reduce((sum, entry) => sum + (entry.spanMs ?? 0), 0);
1326
+ const errors = stats.reduce((sum, entry) => sum + entry.errors, 0);
1327
+ const warnings = stats.reduce((sum, entry) => sum + entry.warnings, 0);
1328
+ const summary = [
1329
+ turns === 0 ? '' : `${turns} ${turns === 1 ? 'turn' : 'turns'}`,
1330
+ steps === 0 ? '' : `${steps} ${steps === 1 ? 'step' : 'steps'}`,
1331
+ spanMs === 0 ? '' : formatDuration(spanMs),
1332
+ errors === 0 ? '' : `${errors} ${errors === 1 ? 'error' : 'errors'}`,
1333
+ warnings === 0 ? '' : `${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`,
1334
+ ].filter(part => part !== '').join(' · ');
1335
+ const header = truncateToWidth(theme.bold(' Trajectory ') + theme.fg('dim', summary), safeWidth);
915
1336
  const searchInfo = state.query === '' ? '' : ` · ${searchPosition(state)}`;
916
1337
  const followInfo = state.following ? '' : state.followNotice > 0 ? ` · End: follow · +${state.followNotice} new` : '';
917
1338
  const toolbar = truncateToWidth(theme.fg('dim', state.details
918
1339
  ? `${searchInfo.slice(2)}${followInfo} · ${formatOverlayHint([HOTKEY_NAVIGATE, HOTKEY_PAGE, HOTKEY_SECTION, HOTKEY_CLOSE])}`
919
- : `${searchInfo.slice(2)}${followInfo} · ${formatOverlayHint([HOTKEY_NAVIGATE, HOTKEY_DETAILS, HOTKEY_SEARCH, HOTKEY_MATCH, HOTKEY_TURN, HOTKEY_CALLS, HOTKEY_EDGE])}${state.following ? ' ●' : ''}`), safeWidth);
1340
+ : `${searchInfo.slice(2)}${followInfo} · ${formatOverlayHint([HOTKEY_NAVIGATE, HOTKEY_DETAILS, HOTKEY_SEARCH, HOTKEY_TURN, HOTKEY_CALLS])}${state.following ? ' ●' : ''}`), safeWidth);
920
1341
  const divider = theme.fg('border', '─'.repeat(safeWidth));
921
- const bodyHeight = Math.max(0, safeHeight - 4);
1342
+ /*
1343
+ * The overview answers "where did the time go" before the reader has to open a
1344
+ * single record. It sits between the toolbar and the ledger, only in the list
1345
+ * view: the detail panel is already about one record's timing, and repeating
1346
+ * the project-wide bars beside it would split attention for no gain.
1347
+ *
1348
+ * It is also gated on there being more than one step. A single request drawn as
1349
+ * `LLM 80% / Tools 20%` restates what the one row below it already says, and two
1350
+ * rows of restatement on a six-row body is the kind of chrome this view was
1351
+ * meant to replace. Searching hides it for the same reason: the reader is
1352
+ * looking for text, not for time.
1353
+ */
1354
+ const overview = trajectoryOverviewHeight(state, safeWidth) === 0 ? undefined : timelineRows(state, theme, safeWidth);
1355
+ const bodyHeight = Math.max(0, safeHeight - 4 - (overview?.length ?? 0));
922
1356
  const wideDetails = state.details && safeWidth >= 96;
923
1357
  let body;
924
1358
  if (wideDetails) {
@@ -942,7 +1376,7 @@ export function renderTrajectory(state, theme, width, height) {
942
1376
  ? ' ' + formatOverlayHint([HOTKEY_PAGE, HOTKEY_SECTION, HOTKEY_CLOSE])
943
1377
  : ' ' + formatOverlayHint([HOTKEY_CLOSE]);
944
1378
  const footer = truncateToWidth(theme.fg(state.searching ? 'accent' : 'dim', footerText), safeWidth);
945
- const lines = [header, toolbar, divider, ...body.slice(0, bodyHeight), footer].slice(0, safeHeight);
1379
+ const lines = [header, toolbar, divider, ...(overview ?? []), ...body.slice(0, bodyHeight), footer].slice(0, safeHeight);
946
1380
  while (lines.length < safeHeight)
947
1381
  lines.push('');
948
1382
  return {