@agi-fans/dsh-tui 0.22.0 → 0.23.1

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 (282) hide show
  1. package/LICENSE +26 -0
  2. package/lib/CHANGELOG.md +219 -1
  3. package/lib/chrome/box.d.ts +1 -18
  4. package/lib/chrome/box.js +2 -45
  5. package/lib/chrome/box.js.map +1 -1
  6. package/lib/chrome/code-highlight.js +4 -4
  7. package/lib/chrome/code-highlight.js.map +1 -1
  8. package/lib/chrome/command-output.d.ts +0 -1
  9. package/lib/chrome/command-output.js +11 -5
  10. package/lib/chrome/command-output.js.map +1 -1
  11. package/lib/chrome/goal-bar.js +1 -1
  12. package/lib/chrome/goal-bar.js.map +1 -1
  13. package/lib/chrome/main-screen-renderer.js +4 -3
  14. package/lib/chrome/main-screen-renderer.js.map +1 -1
  15. package/lib/chrome/markdown.d.ts +3 -3
  16. package/lib/chrome/markdown.js +85 -36
  17. package/lib/chrome/markdown.js.map +1 -1
  18. package/lib/chrome/md-style.d.ts +5 -2
  19. package/lib/chrome/md-style.js +13 -4
  20. package/lib/chrome/md-style.js.map +1 -1
  21. package/lib/chrome/renderer.d.ts +37 -1
  22. package/lib/chrome/renderer.js.map +1 -1
  23. package/lib/chrome/status-line.d.ts +3 -3
  24. package/lib/chrome/status-line.js +5 -5
  25. package/lib/chrome/status-line.js.map +1 -1
  26. package/lib/chrome/theme.d.ts +8 -3
  27. package/lib/chrome/theme.js +51 -153
  28. package/lib/chrome/theme.js.map +1 -1
  29. package/lib/commands/auth-account.d.ts +9 -0
  30. package/lib/commands/auth-account.js +202 -0
  31. package/lib/commands/auth-account.js.map +1 -0
  32. package/lib/commands/auth.js +238 -414
  33. package/lib/commands/auth.js.map +1 -1
  34. package/lib/commands/diff.js +22 -16
  35. package/lib/commands/diff.js.map +1 -1
  36. package/lib/commands/export.d.ts +0 -1
  37. package/lib/commands/export.js +99 -32
  38. package/lib/commands/export.js.map +1 -1
  39. package/lib/commands/files.js +11 -9
  40. package/lib/commands/files.js.map +1 -1
  41. package/lib/commands/integrations.d.ts +12 -1
  42. package/lib/commands/integrations.js +120 -23
  43. package/lib/commands/integrations.js.map +1 -1
  44. package/lib/commands/jobs-browser.d.ts +3 -0
  45. package/lib/commands/jobs-browser.js +152 -0
  46. package/lib/commands/jobs-browser.js.map +1 -0
  47. package/lib/commands/jobs.d.ts +3 -14
  48. package/lib/commands/jobs.js +26 -18
  49. package/lib/commands/jobs.js.map +1 -1
  50. package/lib/commands/model.d.ts +3 -0
  51. package/lib/commands/model.js +212 -128
  52. package/lib/commands/model.js.map +1 -1
  53. package/lib/commands/open-external.d.ts +2 -0
  54. package/lib/commands/open-external.js +19 -0
  55. package/lib/commands/open-external.js.map +1 -0
  56. package/lib/commands/permission.js +65 -54
  57. package/lib/commands/permission.js.map +1 -1
  58. package/lib/commands/plugins.js +381 -139
  59. package/lib/commands/plugins.js.map +1 -1
  60. package/lib/commands/schedule-catalog.d.ts +12 -0
  61. package/lib/commands/schedule-catalog.js +41 -0
  62. package/lib/commands/schedule-catalog.js.map +1 -0
  63. package/lib/commands/schedule-history.d.ts +4 -0
  64. package/lib/commands/schedule-history.js +62 -0
  65. package/lib/commands/schedule-history.js.map +1 -0
  66. package/lib/commands/schedule.d.ts +12 -15
  67. package/lib/commands/schedule.js +220 -187
  68. package/lib/commands/schedule.js.map +1 -1
  69. package/lib/commands/session-configuration.d.ts +3 -0
  70. package/lib/commands/session-configuration.js +62 -28
  71. package/lib/commands/session-configuration.js.map +1 -1
  72. package/lib/commands/session.js +116 -66
  73. package/lib/commands/session.js.map +1 -1
  74. package/lib/commands/terminal.js +14 -8
  75. package/lib/commands/terminal.js.map +1 -1
  76. package/lib/definition.d.ts +115 -15
  77. package/lib/index.d.ts +1 -0
  78. package/lib/index.js +1 -0
  79. package/lib/index.js.map +1 -1
  80. package/lib/input/actions.d.ts +1 -1
  81. package/lib/input/actions.js +5 -2
  82. package/lib/input/actions.js.map +1 -1
  83. package/lib/input/composer-files.d.ts +2 -0
  84. package/lib/input/composer-files.js +13 -3
  85. package/lib/input/composer-files.js.map +1 -1
  86. package/lib/input/image-paste.d.ts +5 -12
  87. package/lib/input/image-paste.js +32 -28
  88. package/lib/input/image-paste.js.map +1 -1
  89. package/lib/input/key-test.d.ts +5 -0
  90. package/lib/input/key-test.js +19 -0
  91. package/lib/input/key-test.js.map +1 -0
  92. package/lib/input/keys.d.ts +2 -0
  93. package/lib/input/keys.js +4 -1
  94. package/lib/input/keys.js.map +1 -1
  95. package/lib/locale/index.d.ts +6 -0
  96. package/lib/locale/index.js +32 -0
  97. package/lib/locale/index.js.map +1 -0
  98. package/lib/locale/prompt.d.ts +4 -0
  99. package/lib/locale/prompt.js +24 -0
  100. package/lib/locale/prompt.js.map +1 -0
  101. package/lib/locale/zh.d.ts +1114 -0
  102. package/lib/locale/zh.js +1115 -0
  103. package/lib/locale/zh.js.map +1 -0
  104. package/lib/prompt-intents.d.ts +4 -0
  105. package/lib/prompt-intents.js +4 -0
  106. package/lib/prompt-intents.js.map +1 -1
  107. package/lib/runtime/account-settings.d.ts +18 -0
  108. package/lib/runtime/account-settings.js +241 -0
  109. package/lib/runtime/account-settings.js.map +1 -0
  110. package/lib/runtime/agent-profile.d.ts +3 -3
  111. package/lib/runtime/agent-profile.js +4 -4
  112. package/lib/runtime/agent-profile.js.map +1 -1
  113. package/lib/runtime/btw-policy.js +2 -1
  114. package/lib/runtime/btw-policy.js.map +1 -1
  115. package/lib/runtime/btw.js +1 -2
  116. package/lib/runtime/btw.js.map +1 -1
  117. package/lib/runtime/composer-images.d.ts +2 -2
  118. package/lib/runtime/composer-images.js +4 -4
  119. package/lib/runtime/composer-images.js.map +1 -1
  120. package/lib/runtime/composer-shell.js +27 -1
  121. package/lib/runtime/composer-shell.js.map +1 -1
  122. package/lib/runtime/file-browser.d.ts +19 -0
  123. package/lib/runtime/file-browser.js +291 -0
  124. package/lib/runtime/file-browser.js.map +1 -0
  125. package/lib/runtime/file-review.d.ts +6 -9
  126. package/lib/runtime/file-review.js +91 -7
  127. package/lib/runtime/file-review.js.map +1 -1
  128. package/lib/runtime/help-reader.d.ts +12 -0
  129. package/lib/runtime/help-reader.js +50 -0
  130. package/lib/runtime/help-reader.js.map +1 -0
  131. package/lib/runtime/human-interaction.js +1 -1
  132. package/lib/runtime/human-interaction.js.map +1 -1
  133. package/lib/runtime/model-catalog-editor.d.ts +12 -0
  134. package/lib/runtime/model-catalog-editor.js +215 -0
  135. package/lib/runtime/model-catalog-editor.js.map +1 -0
  136. package/lib/runtime/model-settings.d.ts +11 -0
  137. package/lib/runtime/model-settings.js +404 -0
  138. package/lib/runtime/model-settings.js.map +1 -0
  139. package/lib/runtime/plugin-settings.d.ts +4 -1
  140. package/lib/runtime/plugin-settings.js +164 -88
  141. package/lib/runtime/plugin-settings.js.map +1 -1
  142. package/lib/runtime/prompt-browser.d.ts +9 -0
  143. package/lib/runtime/prompt-browser.js +19 -0
  144. package/lib/runtime/prompt-browser.js.map +1 -0
  145. package/lib/runtime/provider-local.d.ts +27 -27
  146. package/lib/runtime/provider-local.js +1288 -308
  147. package/lib/runtime/provider-local.js.map +1 -1
  148. package/lib/runtime/reference-reader.d.ts +15 -0
  149. package/lib/runtime/reference-reader.js +78 -0
  150. package/lib/runtime/reference-reader.js.map +1 -0
  151. package/lib/runtime/runner.js +38 -29
  152. package/lib/runtime/runner.js.map +1 -1
  153. package/lib/runtime/session-runtime.js +1 -1
  154. package/lib/runtime/session-runtime.js.map +1 -1
  155. package/lib/runtime/settings-draft.d.ts +21 -0
  156. package/lib/runtime/settings-draft.js +166 -0
  157. package/lib/runtime/settings-draft.js.map +1 -0
  158. package/lib/runtime/settings-preferences.js +1 -1
  159. package/lib/runtime/settings-preferences.js.map +1 -1
  160. package/lib/runtime/startup-notices.d.ts +0 -1
  161. package/lib/runtime/startup-notices.js +35 -6
  162. package/lib/runtime/startup-notices.js.map +1 -1
  163. package/lib/runtime/subagent-model-selection.js +2 -1
  164. package/lib/runtime/subagent-model-selection.js.map +1 -1
  165. package/lib/runtime/subagent-settings.d.ts +14 -0
  166. package/lib/runtime/subagent-settings.js +288 -0
  167. package/lib/runtime/subagent-settings.js.map +1 -0
  168. package/lib/runtime/terminal-notifications.d.ts +3 -0
  169. package/lib/runtime/terminal-notifications.js +13 -8
  170. package/lib/runtime/terminal-notifications.js.map +1 -1
  171. package/lib/runtime/terminal-probe.d.ts +2 -0
  172. package/lib/runtime/terminal-probe.js +9 -0
  173. package/lib/runtime/terminal-probe.js.map +1 -1
  174. package/lib/runtime/tool-presentation.js.map +1 -1
  175. package/lib/runtime/tool-reader.d.ts +5 -0
  176. package/lib/runtime/tool-reader.js +27 -0
  177. package/lib/runtime/tool-reader.js.map +1 -0
  178. package/lib/runtime/web-settings.d.ts +14 -0
  179. package/lib/runtime/web-settings.js +339 -0
  180. package/lib/runtime/web-settings.js.map +1 -0
  181. package/lib/session/command-attachments.d.ts +17 -0
  182. package/lib/session/command-attachments.js +50 -0
  183. package/lib/session/command-attachments.js.map +1 -0
  184. package/lib/session/context-diagnostics.d.ts +16 -0
  185. package/lib/session/context-diagnostics.js +15 -0
  186. package/lib/session/context-diagnostics.js.map +1 -1
  187. package/lib/session/external-agents.d.ts +3 -1
  188. package/lib/session/external-agents.js +35 -10
  189. package/lib/session/external-agents.js.map +1 -1
  190. package/lib/session/generation-continuation.d.ts +11 -0
  191. package/lib/session/generation-continuation.js +34 -0
  192. package/lib/session/generation-continuation.js.map +1 -0
  193. package/lib/session/goal-controls.d.ts +5 -0
  194. package/lib/session/goal-controls.js +81 -0
  195. package/lib/session/goal-controls.js.map +1 -0
  196. package/lib/session/interaction-adapter.d.ts +7 -3
  197. package/lib/session/interaction-adapter.js +121 -49
  198. package/lib/session/interaction-adapter.js.map +1 -1
  199. package/lib/session/message-queue.d.ts +2 -0
  200. package/lib/session/message-queue.js +102 -17
  201. package/lib/session/message-queue.js.map +1 -1
  202. package/lib/session/pending-inputs.d.ts +30 -0
  203. package/lib/session/pending-inputs.js +183 -0
  204. package/lib/session/pending-inputs.js.map +1 -0
  205. package/lib/session/session-configuration.js +1 -1
  206. package/lib/session/session-configuration.js.map +1 -1
  207. package/lib/session/session-controller.d.ts +33 -20
  208. package/lib/session/session-controller.js +500 -129
  209. package/lib/session/session-controller.js.map +1 -1
  210. package/lib/session/session-library.d.ts +3 -1
  211. package/lib/session/session-library.js +15 -0
  212. package/lib/session/session-library.js.map +1 -1
  213. package/lib/session/subagent-roster.d.ts +1 -0
  214. package/lib/session/subagent-roster.js +16 -5
  215. package/lib/session/subagent-roster.js.map +1 -1
  216. package/lib/session/subagent-tracker.d.ts +2 -0
  217. package/lib/session/subagent-tracker.js +18 -0
  218. package/lib/session/subagent-tracker.js.map +1 -1
  219. package/lib/session/tui-settings.d.ts +9 -5
  220. package/lib/session/tui-settings.js +3 -1
  221. package/lib/session/tui-settings.js.map +1 -1
  222. package/lib/session/workflow-projection.d.ts +2 -1
  223. package/lib/session/workflow-projection.js +34 -4
  224. package/lib/session/workflow-projection.js.map +1 -1
  225. package/lib/views/autocomplete.d.ts +12 -12
  226. package/lib/views/autocomplete.js +54 -45
  227. package/lib/views/autocomplete.js.map +1 -1
  228. package/lib/views/context-overview.d.ts +4 -0
  229. package/lib/views/context-overview.js +43 -0
  230. package/lib/views/context-overview.js.map +1 -0
  231. package/lib/views/copy-targets.d.ts +6 -2
  232. package/lib/views/copy-targets.js +19 -14
  233. package/lib/views/copy-targets.js.map +1 -1
  234. package/lib/views/document-reader.d.ts +10 -1
  235. package/lib/views/document-reader.js +63 -6
  236. package/lib/views/document-reader.js.map +1 -1
  237. package/lib/views/event-views.js +29 -11
  238. package/lib/views/event-views.js.map +1 -1
  239. package/lib/views/file-actions.js +21 -0
  240. package/lib/views/file-actions.js.map +1 -1
  241. package/lib/views/file-browser.d.ts +98 -0
  242. package/lib/views/file-browser.js +120 -0
  243. package/lib/views/file-browser.js.map +1 -0
  244. package/lib/views/history-search.js +4 -4
  245. package/lib/views/history-search.js.map +1 -1
  246. package/lib/views/hotkey-format.d.ts +3 -2
  247. package/lib/views/hotkey-format.js +4 -3
  248. package/lib/views/hotkey-format.js.map +1 -1
  249. package/lib/views/hotkeys.js +10 -12
  250. package/lib/views/hotkeys.js.map +1 -1
  251. package/lib/views/list-view.js +16 -11
  252. package/lib/views/list-view.js.map +1 -1
  253. package/lib/views/prompt-selector.d.ts +23 -1
  254. package/lib/views/prompt-selector.js +123 -60
  255. package/lib/views/prompt-selector.js.map +1 -1
  256. package/lib/views/settings-list.d.ts +6 -1
  257. package/lib/views/settings-list.js +69 -46
  258. package/lib/views/settings-list.js.map +1 -1
  259. package/lib/views/tool-actions.d.ts +16 -0
  260. package/lib/views/tool-actions.js +29 -0
  261. package/lib/views/tool-actions.js.map +1 -0
  262. package/lib/views/trajectory-timeline.d.ts +5 -6
  263. package/lib/views/trajectory-timeline.js +25 -15
  264. package/lib/views/trajectory-timeline.js.map +1 -1
  265. package/lib/views/trajectory.d.ts +23 -2
  266. package/lib/views/trajectory.js +404 -186
  267. package/lib/views/trajectory.js.map +1 -1
  268. package/lib/views/transcript-render.d.ts +39 -15
  269. package/lib/views/transcript-render.js +406 -208
  270. package/lib/views/transcript-render.js.map +1 -1
  271. package/lib/views/transcript-types.d.ts +3 -2
  272. package/lib/views/transcript-types.js.map +1 -1
  273. package/lib/views/workflow.d.ts +16 -0
  274. package/lib/views/workflow.js +42 -0
  275. package/lib/views/workflow.js.map +1 -0
  276. package/package.json +61 -58
  277. package/lib/chrome/welcome-tips.d.ts +0 -23
  278. package/lib/chrome/welcome-tips.js +0 -24
  279. package/lib/chrome/welcome-tips.js.map +0 -1
  280. package/lib/session/steering.d.ts +0 -3
  281. package/lib/session/steering.js +0 -9
  282. package/lib/session/steering.js.map +0 -1
@@ -1,10 +1,12 @@
1
1
  /** Keyboard-first Trajectory ledger projected directly from durable session events. */
2
- import { padToWidth, truncateToWidth, visibleWidth, wrapText } from "../chrome/width.js";
2
+ import { expandTabs, padToWidth, truncateToWidth, visibleWidth, wrapCode, wrapText } from "../chrome/width.js";
3
3
  import { moveGraphemeLeft, moveGraphemeRight } from "../chrome/grapheme.js";
4
4
  import { firstVisibleStreamTime } from "./stream-time.js";
5
- import { formatOverlayHint } from "./hotkey-format.js";
6
5
  import { listNavigation } from "./list-navigation.js";
7
- import { highlightCodeLines, toolPayloadLanguage } from "../chrome/code-highlight.js";
6
+ import { highlightCodeLines } from "../chrome/code-highlight.js";
7
+ import { toolArgSubject } from "../chrome/tool-renderers.js";
8
+ import { toolArgsObject } from "../chrome/tool-args.js";
9
+ import { paintSurface } from "../chrome/theme.js";
8
10
  import { renderMarkdown } from "../chrome/markdown.js";
9
11
  import { renderTimelineOverview, trajectoryTimeline } from "./trajectory-timeline.js";
10
12
  import { formatDuration, formatTokens } from "../chrome/status-line.js";
@@ -72,6 +74,120 @@ function tokenValue(source, ...keys) {
72
74
  }
73
75
  return null;
74
76
  }
77
+ const TAB_LABELS = {
78
+ summary: 'Overview', input: 'Input', result: 'Result', payload: 'Raw', schema: 'Schema',
79
+ };
80
+ function rawPayload(record) {
81
+ return [record.payload, record.rawResult].filter(value => value !== undefined && value !== '').join('\n\n');
82
+ }
83
+ /** Decode only the outer argument object; strings keep their whitespace and newlines. */
84
+ function argumentRows(record, theme, width) {
85
+ const raw = record.input ?? '';
86
+ const args = toolArgsObject(raw);
87
+ if (args === undefined)
88
+ return codeRows(raw, undefined, theme, width);
89
+ const rows = [];
90
+ for (const [key, value] of Object.entries(args)) {
91
+ if (rows.length > 0)
92
+ rows.push('');
93
+ rows.push(theme.fg('dim', key));
94
+ rows.push(...codeRows(typeof value === 'string' ? value : json(value), typeof value === 'string' ? undefined : 'json', theme, width));
95
+ }
96
+ return rows.length === 0 ? [theme.fg('dim', 'No arguments.')] : rows;
97
+ }
98
+ /** The same split geometry drives drawing, pointer hit tests and wheel routing. */
99
+ export function trajectoryLeftWidth(width) {
100
+ return Math.max(32, Math.min(64, Math.floor(width * 0.38)));
101
+ }
102
+ const groupCache = new WeakMap();
103
+ function recordGroups(ledger) {
104
+ const cached = groupCache.get(ledger);
105
+ if (cached?.version === ledger.version)
106
+ return cached.groups;
107
+ const contexts = new Map();
108
+ const owner = new Map();
109
+ const children = new Map();
110
+ let previous, contextHead;
111
+ for (const record of ledger.records) {
112
+ if (record.kind === 'context') {
113
+ if (previous?.kind !== 'context' || previous.turn !== record.turn || previous.step !== record.step)
114
+ contextHead = record.id;
115
+ const head = contextHead;
116
+ const group = contexts.get(head) ?? [];
117
+ group.push(record);
118
+ contexts.set(head, group);
119
+ owner.set(record.id, head);
120
+ }
121
+ if (record.parentCallId !== null) {
122
+ const parent = `tool:${record.parentCallId}`;
123
+ const group = children.get(parent) ?? [];
124
+ group.push(record);
125
+ children.set(parent, group);
126
+ }
127
+ previous = record;
128
+ }
129
+ const groups = { contexts, owner, children };
130
+ groupCache.set(ledger, { version: ledger.version, groups });
131
+ return groups;
132
+ }
133
+ function toggleGroup(state) {
134
+ const record = selectedRecord(state);
135
+ if (record === undefined)
136
+ return state;
137
+ const groups = recordGroups(state.ledger);
138
+ const id = groups.owner.get(record.id) ?? record.id;
139
+ if (!groups.contexts.has(id) && !groups.children.has(id))
140
+ return state;
141
+ const expanded = new Set(state.expandedGroups);
142
+ if (expanded.has(id))
143
+ expanded.delete(id);
144
+ else
145
+ expanded.add(id);
146
+ return { ...state, expandedGroups: expanded, selectedId: id, following: false };
147
+ }
148
+ /** Clicks are committed on release by the terminal owner, after excluding drags. */
149
+ export function trajectoryClick(state, width, height, row, column) {
150
+ if (row < 3 || row >= height - 1 || column < 0 || column >= width)
151
+ return state;
152
+ const split = state.details && !state.fullDetail && width >= 96;
153
+ const inDetail = state.details && (!split || column > trajectoryLeftWidth(width));
154
+ if (inDetail) {
155
+ const next = { ...state, focus: 'detail', following: false };
156
+ if (row === 4) {
157
+ const tabs = detailTabsFor(selectedRecord(state));
158
+ let at = (split ? trajectoryLeftWidth(width) + 1 : 0) + 1;
159
+ for (const tab of tabs) {
160
+ const cells = visibleWidth(TAB_LABELS[tab]) + 4;
161
+ if (column >= at && column < at + cells)
162
+ return { ...next, detailTab: tab, detailScroll: 0 };
163
+ at += cells;
164
+ }
165
+ }
166
+ return next;
167
+ }
168
+ const overview = trajectoryOverviewHeight(state, width);
169
+ const body = Math.max(0, height - 4 - overview);
170
+ const offset = row - 3 - overview;
171
+ if (offset < 0 || offset >= body)
172
+ return state;
173
+ const rows = trajectoryRows(state);
174
+ const index = Math.max(0, rows.findIndex(item => item.kind === 'record' && item.id === state.selectedId));
175
+ const start = Math.max(0, Math.min(Math.max(0, rows.length - body), index - Math.floor(body / 2)));
176
+ const target = rows[start + offset];
177
+ if (target?.kind === 'turn' && target.stats.turn !== null) {
178
+ const collapsed = new Set(state.collapsedTurns);
179
+ if (collapsed.has(target.stats.turn))
180
+ collapsed.delete(target.stats.turn);
181
+ else
182
+ collapsed.add(target.stats.turn);
183
+ const first = state.ledger.records.find(record => record.turn === target.stats.turn);
184
+ return retainVisibleSelection({ ...state, collapsedTurns: collapsed, selectedId: first?.id ?? state.selectedId, focus: 'list', following: false });
185
+ }
186
+ if (target?.kind !== 'record')
187
+ return state;
188
+ const next = { ...state, selectedId: target.id, details: true, focus: 'list', following: false, detailScroll: 0, notice: '' };
189
+ return column < 4 ? toggleGroup({ ...next, details: state.details }) : next;
190
+ }
75
191
  /** Incremental projection. Opening a long session is O(events); live updates are O(1). */
76
192
  export class TrajectoryLedger {
77
193
  records = [];
@@ -85,7 +201,7 @@ export class TrajectoryLedger {
85
201
  #assistantByStep = new Map();
86
202
  #toolByCallId = new Map();
87
203
  #compactionById = new Map();
88
- #requestByStep = new Map();
204
+ #requestHeader = '';
89
205
  #schemas = new Map();
90
206
  #version = 0;
91
207
  constructor(events = []) {
@@ -97,7 +213,7 @@ export class TrajectoryLedger {
97
213
  const cached = this.#fieldCache.get(record);
98
214
  if (cached !== undefined)
99
215
  return cached;
100
- const fields = [record.type, record.label, record.summary, record.payload, record.result, record.schema, record.thinking]
216
+ const fields = SEARCH_FIELDS.map(field => record[field] ?? '')
101
217
  .map(field => field.toLocaleLowerCase());
102
218
  this.#fieldCache.set(record, fields);
103
219
  return fields;
@@ -176,8 +292,9 @@ export class TrajectoryLedger {
176
292
  return;
177
293
  }
178
294
  if (eventType === 'request/header') {
179
- this.#requestByStep.set(key, json(event.data));
180
- const prompt = object(data['prompt']);
295
+ this.#requestHeader = json(event.data);
296
+ this.#schemas.clear();
297
+ const prompt = object(data['header']) ?? object(data['prompt']);
181
298
  const tools = prompt?.['tools'];
182
299
  if (Array.isArray(tools)) {
183
300
  for (const tool of tools) {
@@ -189,11 +306,15 @@ export class TrajectoryLedger {
189
306
  }
190
307
  return;
191
308
  }
192
- if (eventType === 'user/message') {
193
- const source = object(data['source']);
194
- const isUser = source?.['kind'] === 'user';
195
- const text = contentText(data['content']);
196
- this.#push(this.#base(event, isUser ? 'user' : 'context', isUser ? 'USER' : 'CONTEXT', compact(text, 'Injected context'), data));
309
+ if (eventType === 'user/message' || eventType === 'developer/message' || eventType === 'system/message') {
310
+ const message = object(data['message']) ?? data;
311
+ const source = object(message['source']);
312
+ const isUser = source?.['kind'] === 'user' || source?.['kind'] === 'user-question-reply';
313
+ const text = contentText(message['content']);
314
+ const system = eventType === 'system/message';
315
+ const record = this.#base(event, isUser ? 'user' : system ? 'system' : 'context', isUser ? 'USER' : system ? 'SYSTEM' : 'CONTEXT', compact(text, system ? 'System prompt' : 'Injected context'), data);
316
+ record.result = text;
317
+ this.#push(record);
197
318
  return;
198
319
  }
199
320
  if (eventType === 'assistant/message') {
@@ -201,7 +322,7 @@ export class TrajectoryLedger {
201
322
  const text = contentText(message?.['content'] ?? data['content'], 'text');
202
323
  const reasoning = contentText(message?.['content'] ?? data['content'], 'reasoning');
203
324
  const usage = object(message?.['usage'] ?? data['usage']);
204
- const startedAt = this.#stepStarted.get(key) ?? event.time;
325
+ const startedAt = this.#stepStarted.get(key) ?? null;
205
326
  const existing = this.#assistantByStep.get(key);
206
327
  // Format-v2 messages embed the exact timed stream; derive the first
207
328
  // visible token time from it instead of live chunk events.
@@ -214,15 +335,16 @@ export class TrajectoryLedger {
214
335
  this.#stepFirstToken.set(key, firstTime);
215
336
  }
216
337
  if (existing === undefined) {
217
- const record = this.#base(event, 'assistant', 'ASSISTANT', compact(text, 'Assistant response'), data);
338
+ const record = this.#base(event, 'assistant', 'ASSISTANT', compact(text || reasoning, 'Model response'), data);
218
339
  record.id = `assistant:${key}`;
219
- record.payload = this.#requestByStep.get(key) ?? record.payload;
340
+ record.payload = this.#requestHeader || record.payload;
220
341
  record.result = text;
221
342
  record.thinking = reasoning;
343
+ record.status = data['interrupted'] === true ? 'error' : 'ok';
222
344
  record.startedAt = startedAt;
223
- record.durationMs = Math.max(0, event.time - startedAt);
345
+ record.durationMs = startedAt === null ? null : Math.max(0, event.time - startedAt);
224
346
  const first = this.#stepFirstToken.get(key);
225
- record.ttftMs = first === undefined ? null : Math.max(0, first - startedAt);
347
+ record.ttftMs = first === undefined || startedAt === null ? null : Math.max(0, first - startedAt);
226
348
  record.inputTokens = tokenValue(usage, 'inputTokens', 'input_tokens', 'promptTokens', 'prompt_tokens');
227
349
  record.outputTokens = tokenValue(usage, 'outputTokens', 'output_tokens', 'completionTokens', 'completion_tokens');
228
350
  this.#assistantByStep.set(key, this.#push(record));
@@ -232,11 +354,11 @@ export class TrajectoryLedger {
232
354
  if (record !== undefined) {
233
355
  this.#invalidateText(record);
234
356
  record.seq = event.seq;
235
- record.summary = compact(text, record.summary);
357
+ record.summary = compact(text || reasoning, record.summary);
236
358
  record.result = text;
237
359
  record.thinking = reasoning;
238
360
  record.status = data['interrupted'] === true ? 'error' : 'ok';
239
- record.durationMs = Math.max(0, event.time - startedAt);
361
+ record.durationMs = startedAt === null ? null : Math.max(0, event.time - startedAt);
240
362
  record.inputTokens = tokenValue(usage, 'inputTokens', 'input_tokens', 'promptTokens', 'prompt_tokens');
241
363
  record.outputTokens = tokenValue(usage, 'outputTokens', 'output_tokens', 'completionTokens', 'completion_tokens');
242
364
  }
@@ -244,11 +366,15 @@ export class TrajectoryLedger {
244
366
  return;
245
367
  }
246
368
  if (eventType === 'tool/call' || eventType === 'tool/ptc-dispatch-start') {
247
- const callId = string(data['callId']) ?? string(data['id']) ?? `${event.seq}`;
369
+ const callId = string(data['subCallId']) ?? string(data['callId']) ?? string(data['id']) ?? `${event.seq}`;
248
370
  const parentCallId = string(data['parentCallId']) ?? string(data['parentId']) ?? null;
249
371
  const name = string(data['name']) ?? string(data['toolName']) ?? 'tool';
250
372
  const args = data['arguments'] ?? data['input'] ?? data;
251
- const record = this.#base(event, parentCallId === null ? 'tool' : 'subtool', name, `${name} ${compact(json(args), '')}`.trim(), data);
373
+ const input = typeof args === 'string' ? args : json(args);
374
+ const decoded = toolArgsObject(input);
375
+ const subject = string(decoded?.['description']) ?? toolArgSubject(input, false);
376
+ const record = this.#base(event, parentCallId === null ? 'tool' : 'subtool', name, compact(subject, name), data);
377
+ record.input = input;
252
378
  record.id = `tool:${callId}`;
253
379
  record.label = parentCallId === null ? 'TOOL' : 'SUBTOOL';
254
380
  record.name = name;
@@ -260,18 +386,39 @@ export class TrajectoryLedger {
260
386
  }
261
387
  if (eventType === 'tool/result' || eventType === 'tool/ptc-dispatch') {
262
388
  const message = object(data['message']);
263
- const callId = string(message?.['callId']) ?? string(data['callId']) ?? string(data['id']);
389
+ const source = object(message?.['source']);
390
+ const callId = string(data['subCallId']) ?? string(message?.['toolCallId']) ?? string(source?.['callId'])
391
+ ?? string(message?.['callId']) ?? string(data['callId']) ?? string(data['id']);
264
392
  if (callId === undefined)
265
393
  return;
266
- const index = this.#toolByCallId.get(callId);
267
- if (index === undefined)
268
- return;
394
+ let index = this.#toolByCallId.get(callId);
395
+ if (index === undefined) {
396
+ // Partial history can contain a result without its call. Keep the result
397
+ // inspectable, without inferring its input, start time or duration.
398
+ const parent = string(data['parentCallId']) ?? null;
399
+ const name = string(data['name']) ?? 'tool';
400
+ const orphan = this.#base(event, parent === null ? 'tool' : 'subtool', parent === null ? 'TOOL' : 'SUBTOOL', 'Call not loaded', data);
401
+ orphan.id = `tool:${callId}`;
402
+ orphan.name = name;
403
+ orphan.parentCallId = parent;
404
+ orphan.startedAt = null;
405
+ orphan.payload = '';
406
+ index = this.#push(orphan);
407
+ this.#toolByCallId.set(callId, index);
408
+ }
269
409
  const record = this.records[index];
270
410
  if (record === undefined)
271
411
  return;
272
412
  this.#invalidateText(record);
273
- record.result = contentText(message?.['content'] ?? data['result'] ?? data['output']) || json(message ?? data);
274
- record.status = data['error'] === true || object(data['error']) !== undefined ? 'error' : 'ok';
413
+ const content = message?.['content'] ?? data['content'] ?? data['result'] ?? data['output'];
414
+ const error = object(data['error']);
415
+ record.result = contentText(content);
416
+ record.rawResult = json(event.data);
417
+ record.resultRecorded = true;
418
+ record.status = message?.['isError'] === true || data['isError'] === true || data['error'] === true || error !== undefined ? 'error' : 'ok';
419
+ if (record.result === '' && record.status === 'error') {
420
+ record.result = [string(error?.['name']), string(error?.['code']), string(error?.['reason'])].filter(Boolean).join(' · ') || 'Tool failed';
421
+ }
275
422
  record.durationMs = record.startedAt === null ? null : Math.max(0, event.time - record.startedAt);
276
423
  return;
277
424
  }
@@ -331,8 +478,10 @@ export class TrajectoryLedger {
331
478
  const record = index === undefined ? undefined : this.records[index];
332
479
  const reason = object(data['reason']);
333
480
  if (record !== undefined) {
334
- if (reason?.['kind'] === 'error' || data['error'] !== undefined)
481
+ if (reason?.['kind'] === 'error' || data['error'] !== undefined) {
482
+ this.#invalidateText(record);
335
483
  record.status = 'error';
484
+ }
336
485
  if (record.durationMs === null && record.startedAt !== null)
337
486
  record.durationMs = Math.max(0, event.time - record.startedAt);
338
487
  }
@@ -343,6 +492,12 @@ export class TrajectoryLedger {
343
492
  const reasonKind = string(reason?.['kind']);
344
493
  if (reasonKind === undefined || reasonKind === 'completed')
345
494
  return;
495
+ for (const call of this.records) {
496
+ if (isCall(call) && call.turn === place.turn && call.status === 'running') {
497
+ this.#invalidateText(call);
498
+ call.status = 'warning';
499
+ }
500
+ }
346
501
  const isError = reasonKind === 'error' || reasonKind === 'aborted';
347
502
  const summary = reasonKind === 'max-tokens'
348
503
  ? 'Output token limit reached'
@@ -359,23 +514,26 @@ export class TrajectoryLedger {
359
514
  }
360
515
  }
361
516
  }
362
- const SEARCH_FIELDS = ['type', 'label', 'summary', 'payload', 'result', 'schema', 'thinking'];
517
+ const SEARCH_FIELDS = ['type', 'label', 'summary', 'payload', 'result', 'schema', 'thinking', 'input', 'rawResult'];
363
518
  export function createTrajectory(events) {
364
519
  const ledger = new TrajectoryLedger(events);
365
- return {
520
+ const state = {
366
521
  ledger,
367
- selectedId: ledger.records.at(-1)?.id ?? null,
522
+ selectedId: null,
368
523
  query: '',
369
524
  searching: false,
370
525
  details: false,
371
526
  detailTab: 'summary',
372
527
  detailScroll: 0,
528
+ focus: 'list',
529
+ expandedGroups: new Set(),
373
530
  collapsedTurns: new Set(),
374
531
  callsCollapsed: false,
375
532
  following: true,
376
533
  searchFocus: null,
377
534
  followNotice: 0,
378
535
  };
536
+ return { ...state, selectedId: trajectoryVisibleRecords(state).at(-1)?.id ?? null };
379
537
  }
380
538
  export function appendTrajectoryEvent(state, event) {
381
539
  const beforeCount = state.ledger.records.length;
@@ -449,7 +607,13 @@ function isCall(record) {
449
607
  export function trajectoryVisibleRecords(state) {
450
608
  const query = state.query.trim().toLocaleLowerCase();
451
609
  const firstInCollapsed = new Set();
610
+ const groups = recordGroups(state.ledger);
611
+ const byId = new Map(state.ledger.records.map(record => [record.id, record]));
452
612
  return state.ledger.records.filter((record) => {
613
+ if (query !== '')
614
+ return state.ledger.recordMatches(record, query);
615
+ if (state.errorsOnly)
616
+ return ['error', 'warning', 'retry'].includes(record.status);
453
617
  if (state.callsCollapsed && isCall(record))
454
618
  return false;
455
619
  if (record.turn !== null && state.collapsedTurns.has(record.turn)) {
@@ -457,9 +621,19 @@ export function trajectoryVisibleRecords(state) {
457
621
  return false;
458
622
  firstInCollapsed.add(record.turn);
459
623
  }
460
- if (query === '')
461
- return true;
462
- return state.ledger.recordMatches(record, query);
624
+ const head = groups.owner.get(record.id);
625
+ if (head !== undefined && head !== record.id && !state.expandedGroups?.has(head))
626
+ return false;
627
+ let parent = record.parentCallId === null ? undefined : `tool:${record.parentCallId}`;
628
+ const seen = new Set();
629
+ while (parent !== undefined && byId.has(parent) && !seen.has(parent)) {
630
+ if (!state.expandedGroups?.has(parent))
631
+ return false;
632
+ seen.add(parent);
633
+ const next = byId.get(parent)?.parentCallId;
634
+ parent = next == null ? undefined : `tool:${next}`;
635
+ }
636
+ return true;
463
637
  });
464
638
  }
465
639
  /**
@@ -502,7 +676,7 @@ function trajectoryIndex(ledger) {
502
676
  continue;
503
677
  const callKey = `${key}:${record.step ?? 'none'}`;
504
678
  const counts = calls.get(callKey) ?? new Map();
505
- const name = record.summary.split(/\s/u)[0] ?? record.label;
679
+ const name = record.name || record.label;
506
680
  counts.set(name, (counts.get(name) ?? 0) + 1);
507
681
  calls.set(callKey, counts);
508
682
  }
@@ -524,16 +698,7 @@ function trajectoryIndex(ledger) {
524
698
  turnStatsCache.set(ledger, entry);
525
699
  return entry;
526
700
  }
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
- */
701
+ /** Turn and step boundaries remain visible; folded-call counts come from the full ledger. */
537
702
  function trajectoryRows(state) {
538
703
  const index = trajectoryIndex(state.ledger);
539
704
  const rows = [];
@@ -611,7 +776,7 @@ export function trajectorySearch(state) {
611
776
  const lower = lowerFields[fieldIndex] ?? '';
612
777
  if (lower === '')
613
778
  continue;
614
- const original = record[field];
779
+ const original = record[field] ?? '';
615
780
  // Case folding can expand code points (e.g. U+0130); only then are the
616
781
  // lowercase offsets valid against the original text.
617
782
  const offsetsExact = Array.from(lower).length === Array.from(original).length;
@@ -639,27 +804,42 @@ export function trajectorySearch(state) {
639
804
  searchCache.set(state.ledger, { version: state.ledger.version, query, result });
640
805
  return result;
641
806
  }
807
+ /** Reveal the selected record without discarding unrelated folds. */
808
+ function revealRecord(state, record) {
809
+ const expanded = new Set(state.expandedGroups);
810
+ const groups = recordGroups(state.ledger);
811
+ const head = groups.owner.get(record.id);
812
+ if (head !== undefined)
813
+ expanded.add(head);
814
+ const byId = new Map(state.ledger.records.map(row => [row.id, row]));
815
+ let parent = record.parentCallId;
816
+ const seen = new Set();
817
+ while (parent !== null && !seen.has(parent)) {
818
+ seen.add(parent);
819
+ expanded.add(`tool:${parent}`);
820
+ parent = byId.get(`tool:${parent}`)?.parentCallId ?? null;
821
+ }
822
+ const turns = new Set(state.collapsedTurns);
823
+ if (record.turn !== null)
824
+ turns.delete(record.turn);
825
+ return { ...state, expandedGroups: expanded, collapsedTurns: turns, errorsOnly: false,
826
+ callsCollapsed: isCall(record) ? false : state.callsCollapsed, selectedId: record.id, following: false, detailScroll: 0 };
827
+ }
828
+ function retainVisibleSelection(state) {
829
+ const visible = trajectoryVisibleRecords(state);
830
+ if (visible.some(record => record.id === state.selectedId))
831
+ return state;
832
+ const selected = selectedRecord(state);
833
+ const nearest = visible.findLast(record => record.index <= (selected?.index ?? 0)) ?? visible[0];
834
+ return { ...state, selectedId: nearest?.id ?? null, detailScroll: 0 };
835
+ }
642
836
  /** Locate one match: expand its turn/subtool and select the carrying record. */
643
837
  function locateMatch(state, index) {
644
838
  const match = trajectorySearch(state).matches[index];
645
839
  if (match === undefined)
646
840
  return state;
647
841
  const record = match.record;
648
- let collapsedTurns = state.collapsedTurns;
649
- if (record.turn !== null && collapsedTurns.has(record.turn)) {
650
- const next = new Set(collapsedTurns);
651
- next.delete(record.turn);
652
- collapsedTurns = next;
653
- }
654
- return {
655
- ...state,
656
- searchFocus: index,
657
- selectedId: record.id,
658
- collapsedTurns,
659
- callsCollapsed: state.callsCollapsed && !isCall(record),
660
- detailScroll: 0,
661
- following: false,
662
- };
842
+ return { ...revealRecord(state, record), searchFocus: index };
663
843
  }
664
844
  function moveMatch(state, delta) {
665
845
  const matches = trajectorySearch(state).matches;
@@ -682,7 +862,8 @@ function move(state, delta) {
682
862
  ...state,
683
863
  selectedId: visible[index]?.id ?? null,
684
864
  detailScroll: 0,
685
- following,
865
+ following: state.details ? false : following,
866
+ notice: '',
686
867
  ...(following ? { followNotice: 0 } : {}),
687
868
  };
688
869
  }
@@ -691,24 +872,17 @@ function graphemes(text) {
691
872
  trajectorySegmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
692
873
  return trajectorySegmenter.segment(text);
693
874
  }
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
- */
875
+ /** Offer only sections with recorded content; completed empty results still have a Result section. */
704
876
  function detailTabsFor(record) {
705
877
  if (record === undefined)
706
878
  return ['summary'];
707
879
  const tabs = ['summary'];
708
- if (record.payload !== '')
709
- tabs.push('payload');
710
- if (record.result !== '')
880
+ if (record.input !== undefined && record.input !== '')
881
+ tabs.push('input');
882
+ if (record.resultRecorded || record.result !== '' || record.thinking !== '')
711
883
  tabs.push('result');
884
+ if (record.payload !== '' || record.rawResult !== undefined)
885
+ tabs.push('payload');
712
886
  if (record.schema !== '')
713
887
  tabs.push('schema');
714
888
  return tabs;
@@ -733,6 +907,10 @@ function moveTab(state, delta) {
733
907
  const DEFAULT_PAGE_SIZE = 10;
734
908
  const DEFAULT_DETAIL_PAGE_LINES = 10;
735
909
  export function applyTrajectoryEvent(state, event, options = {}) {
910
+ const detailFocused = state.details && (state.focus !== 'list' || state.fullDetail === true || (options.width !== undefined && options.width < 96));
911
+ const maxScroll = options.maxDetailScroll ?? Number.MAX_SAFE_INTEGER;
912
+ const scroll = (offset) => ({ ...state, detailScroll: Math.max(0, Math.min(maxScroll, offset)), following: false });
913
+ const currentScroll = Math.min(state.detailScroll, maxScroll);
736
914
  const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
737
915
  const detailPageLines = options.detailPageLines ?? DEFAULT_DETAIL_PAGE_LINES;
738
916
  if (state.searching) {
@@ -759,6 +937,34 @@ export function applyTrajectoryEvent(state, event, options = {}) {
759
937
  return { state: { ...state, searching: false } };
760
938
  return { state };
761
939
  }
940
+ if (event.type === 'text' && state.query === '') {
941
+ if (event.value === ' ')
942
+ return { state: toggleGroup(state) };
943
+ if (event.value === 'f' && state.details)
944
+ return { state: { ...state, fullDetail: !state.fullDetail, focus: 'detail', following: false } };
945
+ if (event.value === 'e') {
946
+ const next = { ...state, errorsOnly: !state.errorsOnly, following: false };
947
+ return { state: { ...next, selectedId: trajectoryVisibleRecords(next)[0]?.id ?? null } };
948
+ }
949
+ if (event.value === 's') {
950
+ const slow = state.ledger.records.filter(record => isCall(record) && record.durationMs !== null)
951
+ .sort((a, b) => b.durationMs - a.durationMs || a.index - b.index);
952
+ const at = slow.findIndex(record => record.id === state.selectedId);
953
+ const record = slow[(at + 1) % slow.length];
954
+ if (record !== undefined) {
955
+ return { state: { ...revealRecord(state, record), notice: 'Calls by duration · s next' } };
956
+ }
957
+ }
958
+ if (event.value === 'y') {
959
+ const record = selectedRecord(state);
960
+ if (record !== undefined) {
961
+ const tab = state.details ? activeTab(state) : 'summary';
962
+ const text = tab === 'input' ? record.input ?? '' : tab === 'payload' ? rawPayload(record) : tab === 'schema' ? record.schema
963
+ : tab === 'result' ? [record.thinking, record.result].filter(Boolean).join('\n\n') : [record.summary, record.input === undefined ? '' : `Input\n${record.input}`, record.thinking === '' ? '' : `Thinking\n${record.thinking}`, record.result === '' ? '' : `Result\n${record.result}`].filter(Boolean).join('\n\n');
964
+ return { copy: text, label: TAB_LABELS[tab] };
965
+ }
966
+ }
967
+ }
762
968
  if (event.type === 'text') {
763
969
  const lower = event.value.toLowerCase();
764
970
  if (event.value === '/' && state.query === '') {
@@ -771,7 +977,7 @@ export function applyTrajectoryEvent(state, event, options = {}) {
771
977
  if (lower === 'n' && state.query !== '')
772
978
  return { state: moveMatch(state, event.value === 'N' ? -1 : 1) };
773
979
  if (lower === 'c')
774
- return { state: { ...state, callsCollapsed: !state.callsCollapsed } };
980
+ return { state: retainVisibleSelection({ ...state, callsCollapsed: !state.callsCollapsed, following: false }) };
775
981
  if (lower === 't') {
776
982
  const selected = state.ledger.records.find(record => record.id === state.selectedId);
777
983
  if (selected?.turn === null || selected?.turn === undefined)
@@ -782,7 +988,7 @@ export function applyTrajectoryEvent(state, event, options = {}) {
782
988
  else
783
989
  collapsed.add(selected.turn);
784
990
  const first = state.ledger.records.find(record => record.turn === selected.turn);
785
- return { state: { ...state, collapsedTurns: collapsed, selectedId: first?.id ?? state.selectedId } };
991
+ return { state: retainVisibleSelection({ ...state, collapsedTurns: collapsed, selectedId: first?.id ?? state.selectedId, following: false }) };
786
992
  }
787
993
  if (state.query !== '') {
788
994
  // Any other printable character in the result state starts a new query.
@@ -790,70 +996,60 @@ export function applyTrajectoryEvent(state, event, options = {}) {
790
996
  }
791
997
  return { state };
792
998
  }
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
- */
999
+ // Wheel routing follows the pointer, independently of keyboard focus.
802
1000
  if (event.type === 'wheel') {
803
1001
  const step = Math.max(1, Math.round(detailPageLines / 2));
804
- if (state.details) {
1002
+ const overDetail = event.column === undefined ? detailFocused
1003
+ : state.fullDetail || (options.width ?? 0) < 96 || event.column > trajectoryLeftWidth(options.width);
1004
+ if (state.details && overDetail) {
805
1005
  const delta = event.direction === 'up' ? -step : step;
806
- return { state: { ...state, detailScroll: Math.max(0, state.detailScroll + delta) } };
1006
+ return { state: scroll(currentScroll + delta) };
807
1007
  }
808
1008
  return { state: move(state, (event.direction === 'up' ? -1 : 1) * 3) };
809
1009
  }
810
1010
  if (event.type !== 'key')
811
1011
  return { state };
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')) {
1012
+ if (event.id === 'tab' || event.id === 'shift+tab') {
1013
+ if (!state.details)
1014
+ return { state };
1015
+ const detailOnly = state.fullDetail || (options.width !== undefined && options.width < 96);
1016
+ return { state: { ...state, focus: detailOnly ? 'detail' : state.focus === 'list' ? 'detail' : 'list' } };
1017
+ }
1018
+ if (event.id === 'right')
1019
+ return { state: { ...moveTab(state, 1), details: true, focus: 'detail', following: false } };
1020
+ if (event.id === 'left')
1021
+ return { state: { ...moveTab(state, -1), details: true, focus: 'detail', following: false } };
1022
+ if (event.id === 'ctrl+n' || event.id === 'ctrl+p')
1023
+ return { state: move(state, event.id === 'ctrl+n' ? 1 : -1) };
1024
+ if (detailFocused && (event.id === 'up' || event.id === 'down')) {
831
1025
  const delta = event.id === 'up' ? -1 : 1;
832
- return { state: { ...state, detailScroll: Math.max(0, state.detailScroll + delta) } };
1026
+ return { state: scroll(currentScroll + delta) };
833
1027
  }
834
1028
  const navigation = listNavigation(event);
835
1029
  if (navigation.kind === 'close') {
836
1030
  // One layer per press: an open detail panel closes before the ledger does.
837
1031
  if (state.details)
838
- return { state: { ...state, details: false, detailScroll: 0 } };
1032
+ return { state: { ...state, details: false, fullDetail: false, focus: 'list', detailScroll: 0 } };
839
1033
  return { close: true };
840
1034
  }
841
1035
  if (navigation.kind === 'move')
842
1036
  return { state: move(state, navigation.delta) };
843
1037
  if (event.id === 'pageUp') {
844
- if (state.details)
845
- return { state: { ...state, detailScroll: Math.max(0, state.detailScroll - detailPageLines) } };
1038
+ if (detailFocused)
1039
+ return { state: scroll(currentScroll - detailPageLines) };
846
1040
  if (pageSize === 0)
847
1041
  return { state };
848
1042
  return { state: move(state, -pageSize) };
849
1043
  }
850
1044
  if (event.id === 'pageDown') {
851
- if (state.details)
852
- return { state: { ...state, detailScroll: state.detailScroll + detailPageLines } };
1045
+ if (detailFocused)
1046
+ return { state: scroll(currentScroll + detailPageLines) };
853
1047
  if (pageSize === 0)
854
1048
  return { state };
855
1049
  return { state: move(state, pageSize) };
856
1050
  }
1051
+ if (detailFocused && (event.id === 'home' || event.id === 'end'))
1052
+ return { state: scroll(event.id === 'home' ? 0 : maxScroll) };
857
1053
  if (event.id === 'home') {
858
1054
  const first = trajectoryVisibleRecords(state)[0];
859
1055
  return { state: { ...state, selectedId: first?.id ?? null, following: false } };
@@ -863,16 +1059,16 @@ export function applyTrajectoryEvent(state, event, options = {}) {
863
1059
  return { state: { ...state, selectedId: last?.id ?? null, following: true, followNotice: 0 } };
864
1060
  }
865
1061
  if (event.id === 'enter')
866
- return { state: { ...state, details: !state.details, detailScroll: 0 } };
1062
+ return { state: { ...state, details: true, focus: 'detail', following: false, detailScroll: 0 } };
867
1063
  return { state };
868
1064
  }
869
1065
  // Every key `applyTrajectoryEvent` accepts. Rows whose first word is the footer
870
1066
  // label are also printed by the overlay's toolbar and footer.
871
1067
  const HOTKEY_NAVIGATE = { keys: '↑↓', action: 'Navigate records, or scroll an open detail' };
872
- const HOTKEY_EDGE = { keys: 'Home / End', action: 'Jump to the first record, or follow the newest' };
1068
+ const HOTKEY_EDGE = { keys: 'Home / End', action: 'First / newest record; top / bottom in details' };
873
1069
  const HOTKEY_PAGE = { keys: '↑↓ / PgUp / PgDn', action: 'Scroll the open detail, or page the record list' };
874
- const HOTKEY_DETAILS = { keys: 'Enter', action: 'Details — open or close the detail panel' };
875
- const HOTKEY_SECTION = { keys: 'Tab / ←→', action: 'Section — switch the detail tab' };
1070
+ const HOTKEY_DETAILS = { keys: 'Enter', action: 'Details — open and focus the detail panel' };
1071
+ const HOTKEY_SECTION = { keys: '←→', action: 'Section — switch the detail tab' };
876
1072
  const HOTKEY_SEARCH = { keys: '/', action: 'Search the ledger' };
877
1073
  const HOTKEY_MATCH = { keys: 'n/N', action: 'Match — step to the next or previous hit' };
878
1074
  const HOTKEY_MATCH_EDIT = { keys: 'Ctrl+N / Ctrl+P', action: 'Match while typing — step to the next or previous hit' };
@@ -888,6 +1084,13 @@ export const TRAJECTORY_HOTKEYS = [
888
1084
  HOTKEY_PAGE,
889
1085
  HOTKEY_DETAILS,
890
1086
  HOTKEY_SECTION,
1087
+ { keys: 'Tab', action: 'Focus — switch between records and details' },
1088
+ { keys: 'Ctrl+N / Ctrl+P', action: 'Record — inspect the next or previous record' },
1089
+ { keys: 'Space', action: 'Group — expand context entries or subcalls' },
1090
+ { keys: 'f', action: 'Full screen — enlarge the detail pane' },
1091
+ { keys: 'e', action: 'Problems — filter failed, interrupted and retry records' },
1092
+ { keys: 's', action: 'Slow calls — visit calls in descending duration order' },
1093
+ { keys: 'y', action: 'Copy — copy the current section without display formatting' },
891
1094
  HOTKEY_SEARCH,
892
1095
  HOTKEY_MATCH,
893
1096
  HOTKEY_MATCH_EDIT,
@@ -905,7 +1108,7 @@ export const TRAJECTORY_HOTKEYS = [
905
1108
  * frame can show.
906
1109
  */
907
1110
  export function trajectoryOverviewHeight(state, width = Number.POSITIVE_INFINITY) {
908
- if (state.details || state.query !== '')
1111
+ if (state.details || state.query !== '' || state.errorsOnly)
909
1112
  return 0;
910
1113
  if (width < 24)
911
1114
  return 0;
@@ -926,7 +1129,7 @@ export function trajectoryListMetrics(state, height, width = Number.POSITIVE_INF
926
1129
  if (rows.length === 0)
927
1130
  return { pageSize: 1 };
928
1131
  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)));
1132
+ const start = Math.max(0, Math.min(Math.max(0, rows.length - body), Math.max(0, selected) - Math.floor(body / 2)));
930
1133
  // Count record rows inside the window; turn and step headers only occupy space.
931
1134
  let count = 0;
932
1135
  for (let index = start; index < rows.length && index - start < body; index += 1) {
@@ -990,7 +1193,7 @@ function highlightSummary(summary, spans, theme) {
990
1193
  }
991
1194
  return out + summary.slice(cursor);
992
1195
  }
993
- function recordLine(record, selected, theme, width, highlightSpans, matchCount, summaryOverride) {
1196
+ function recordLine(record, selected, theme, width, highlightSpans, matchCount, summaryOverride, disclosure) {
994
1197
  const marker = selected ? theme.fg('accent', '›') : ' ';
995
1198
  /*
996
1199
  * No location column: the turn rule and the step header above already say
@@ -998,7 +1201,10 @@ function recordLine(record, selected, theme, width, highlightSpans, matchCount,
998
1201
  * a form nobody reads. The right edge instead carries what the row cost:
999
1202
  * tokens for a model request, duration for everything.
1000
1203
  */
1001
- const prefix = `${marker} ${theme.fg('dim', `#${String(record.index).padStart(3)}`)} ${theme.fg(kindColor(record.kind), record.label.padEnd(9).slice(0, 9))} `;
1204
+ const status = record.status === 'error' ? '✗' : record.status === 'warning' ? '!' : record.status === 'running' ? '◌' : isCall(record) ? '✓' : ' ';
1205
+ const label = isCall(record) && record.name !== '' ? record.name : record.kind === 'assistant' ? 'Model' : record.kind === 'context' ? 'Context' : record.label;
1206
+ const tone = record.status === 'error' ? 'error' : record.status === 'warning' ? 'warning' : kindColor(record.kind);
1207
+ const prefix = `${marker} ${disclosure === undefined ? '' : theme.fg('dim', disclosure) + ' '}${theme.fg(tone, status)} ${record.parentCallId === null ? '' : ' ↳ '}${theme.fg(isCall(record) ? 'text' : tone, truncateToWidth(label, 12))} `;
1002
1208
  const badge = matchCount === undefined ? '' : ` ${theme.fg('dim', `×${matchCount}`)}`;
1003
1209
  // A record with no recorded duration prints nothing rather than an em dash:
1004
1210
  // a column of `—` where most rows have a time reads as missing data, when in
@@ -1014,11 +1220,12 @@ function recordLine(record, selected, theme, width, highlightSpans, matchCount,
1014
1220
  const bodyWidth = Math.max(0, width - fixed - visibleWidth(columns) - 2);
1015
1221
  const text = truncateToWidth(body, bodyWidth);
1016
1222
  const gap = Math.max(1, width - fixed - visibleWidth(text) - visibleWidth(columns));
1017
- return prefix + text + ' '.repeat(Math.max(0, gap)) + columns + badge;
1223
+ const row = truncateToWidth(prefix + text + ' '.repeat(Math.max(0, gap)) + columns + badge, width);
1224
+ return selected ? paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth(row, width)) : row;
1018
1225
  }
1019
1226
  /** Snippet of one record field around its first hit (non-summary fields). */
1020
1227
  function fieldSnippet(record, field, highlightSpans, width, theme, focused) {
1021
- const text = record[field];
1228
+ const text = record[field] ?? '';
1022
1229
  if (text === '')
1023
1230
  return highlightSummary(record.summary, highlightSpans, theme);
1024
1231
  const span = focused ?? highlightSpans.find(match => match.field === field);
@@ -1051,16 +1258,12 @@ function clockTime(ms) {
1051
1258
  const pad = (value) => String(value).padStart(2, '0');
1052
1259
  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1053
1260
  }
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
- */
1261
+ /** Recorded timing and usage, with exceptional status shown explicitly. */
1061
1262
  function summaryFields(record) {
1062
1263
  const rows = [];
1063
1264
  rows.push({ label: 'Event', value: `${record.type} · #${record.seq}` });
1265
+ if (record.durationMs !== null)
1266
+ rows.push({ label: 'Duration', value: formatDuration(record.durationMs) });
1064
1267
  if (record.startedAt !== null)
1065
1268
  rows.push({ label: 'Started', value: clockTime(record.startedAt) });
1066
1269
  if (record.ttftMs !== null)
@@ -1072,14 +1275,14 @@ function summaryFields(record) {
1072
1275
  // `ok` on every record is the kind of field that teaches nothing; the ones
1073
1276
  // worth a line are the ones that went wrong.
1074
1277
  if (record.status !== 'ok')
1075
- rows.push({ label: 'Status', value: record.status });
1278
+ rows.push({ label: 'Status', value: record.status === 'warning' && isCall(record) ? 'Interrupted · no result recorded' : record.status });
1076
1279
  return rows;
1077
1280
  }
1078
1281
  /** The assistant's reasoning and answer are two pieces of writing, not one blob. */
1079
1282
  function assistantWriting(record, theme, width) {
1080
1283
  const out = [];
1081
1284
  if (record.thinking !== '') {
1082
- out.push(theme.fg('dim', 'Thinking'), ...renderMarkdown(record.thinking, theme, width));
1285
+ out.push(theme.fg('dim', 'Thinking'), ...renderMarkdown(record.thinking, theme, width, { color: 'thinkingText', italic: true }));
1083
1286
  if (record.result !== '')
1084
1287
  out.push('', theme.fg('dim', 'Answer'));
1085
1288
  }
@@ -1093,7 +1296,9 @@ function detailSection(tab, record) {
1093
1296
  if (tab === 'summary')
1094
1297
  return { kind: 'fields', rows: summaryFields(record) };
1095
1298
  if (tab === 'payload')
1096
- return { kind: 'code', text: record.payload, language: 'json' };
1299
+ return { kind: 'code', text: rawPayload(record), language: 'json' };
1300
+ if (tab === 'input')
1301
+ return { kind: 'code', text: record.input ?? '', language: 'json' };
1097
1302
  if (tab === 'schema')
1098
1303
  return { kind: 'code', text: record.schema, language: 'json' };
1099
1304
  // A model's answer is prose; a tool's output is whatever the tool spoke.
@@ -1102,14 +1307,14 @@ function detailSection(tab, record) {
1102
1307
  return { kind: 'prose', text: record.result };
1103
1308
  return {
1104
1309
  kind: 'code',
1105
- text: record.result,
1106
- language: toolPayloadLanguage(record.type === 'tool/call' ? record.label.toLowerCase() : ''),
1310
+ text: record.result || (record.resultRecorded ? 'No text output. Non-text fields are available in Raw.' : 'No result recorded.'),
1311
+ language: undefined,
1107
1312
  };
1108
1313
  }
1109
1314
  function ledgerRows(state, theme, width, height) {
1110
1315
  const rows = trajectoryRows(state);
1111
1316
  if (!rows.some(row => row.kind === 'record')) {
1112
- return [theme.fg('dim', state.query === '' ? ' No trajectory records.' : ' No matching trajectory records.')];
1317
+ return [theme.fg('dim', state.query === '' ? state.errorsOnly ? ' No problems recorded.' : ' No trajectory records.' : ' No matching trajectory records.')];
1113
1318
  }
1114
1319
  const { matches, counts } = trajectorySearch(state);
1115
1320
  const focused = state.searchFocus === null ? undefined : matches[state.searchFocus];
@@ -1119,7 +1324,10 @@ function ledgerRows(state, theme, width, height) {
1119
1324
  list.push(match);
1120
1325
  highlighted.set(match.record.id, list);
1121
1326
  }
1122
- const lines = rows.map((row) => {
1327
+ const groups = recordGroups(state.ledger);
1328
+ const selectedIndex = Math.max(0, rows.findIndex(row => row.kind === 'record' && row.id === state.selectedId));
1329
+ const start = Math.max(0, Math.min(Math.max(0, rows.length - height), selectedIndex - Math.floor(height / 2)));
1330
+ const lines = rows.slice(start, start + height).map((row) => {
1123
1331
  if (row.kind === 'turn')
1124
1332
  return turnLine(row.stats, row.collapsed, theme, width);
1125
1333
  if (row.kind === 'step')
@@ -1129,42 +1337,60 @@ function ledgerRows(state, theme, width, height) {
1129
1337
  const focusedForRecord = focused?.record === record ? focused : undefined;
1130
1338
  const headField = focusedForRecord !== undefined ? focusedForRecord.field : recordMatches[0]?.field;
1131
1339
  const headSpans = recordMatches.filter(match => match.field === headField);
1132
- const summary = headField === undefined || headField === 'summary'
1340
+ let summary = headField === undefined || headField === 'summary'
1133
1341
  ? highlightSummary(record.summary, headSpans, theme)
1134
1342
  : fieldSnippet(record, headField, headSpans, Math.max(0, width - 24), theme, focusedForRecord);
1135
- return recordLine(record, record.id === state.selectedId, theme, width, headField === 'summary' ? headSpans : [], state.query === '' ? undefined : counts.get(record.id), summary);
1343
+ let disclosure;
1344
+ if (state.query === '' && !state.errorsOnly) {
1345
+ const contexts = groups.contexts.get(record.id);
1346
+ const children = groups.children.get(record.id);
1347
+ if ((contexts?.length ?? 0) > 1 || children !== undefined)
1348
+ disclosure = state.expandedGroups?.has(record.id) ? '▾' : '▸';
1349
+ if (contexts !== undefined && contexts.length > 1 && !state.expandedGroups?.has(record.id))
1350
+ summary = `${contexts.length} injected entries · Space to expand`;
1351
+ else if (children !== undefined)
1352
+ summary += ` · ${children.length} subcalls`;
1353
+ }
1354
+ return recordLine(record, record.id === state.selectedId, theme, width, headField === 'summary' ? headSpans : [], state.query === '' ? undefined : counts.get(record.id), summary, disclosure);
1136
1355
  });
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));
1356
+ return lines.map(line => truncateToWidth(line, width));
1140
1357
  }
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
- */
1150
- function detailRows(state, theme, width, height) {
1358
+ const detailBodyCache = new WeakMap();
1359
+ /** Wrapped source is reused while scrolling the same section. */
1360
+ function detailBody(state, theme, width) {
1151
1361
  const record = selectedRecord(state);
1152
1362
  const tab = activeTab(state);
1153
- const tabs = detailTabsFor(record);
1363
+ const cached = detailBodyCache.get(state.ledger);
1364
+ if (cached?.version === state.ledger.version && cached.record === record && cached.tab === tab && cached.width === width && cached.theme === theme.key)
1365
+ return cached.rows;
1154
1366
  const section = detailSection(tab, record);
1155
1367
  const inner = Math.max(1, width - 2);
1156
- const body = section.kind === 'fields'
1368
+ const rows = tab === 'input' && record !== undefined ? argumentRows(record, theme, inner) : section.kind === 'fields'
1157
1369
  ? summaryBlock(section.rows, record, theme, inner)
1158
1370
  : section.kind === 'prose'
1159
1371
  ? (record !== undefined && record.kind === 'assistant' && tab === 'result'
1160
1372
  ? assistantWriting(record, theme, inner)
1161
1373
  : renderMarkdown(section.text, theme, inner))
1162
1374
  : codeRows(section.text, section.language, theme, inner);
1375
+ detailBodyCache.set(state.ledger, { version: state.ledger.version, record, tab, width, theme: theme.key, rows });
1376
+ return rows;
1377
+ }
1378
+ export function trajectoryDetailScrollLimit(state, theme, width, height) {
1379
+ if (!state.details)
1380
+ return 0;
1381
+ const paneWidth = !state.fullDetail && width >= 96 ? width - trajectoryLeftWidth(width) - 1 : width;
1382
+ return Math.max(0, detailBody(state, theme, Math.max(1, paneWidth)).length - Math.max(0, height - 7));
1383
+ }
1384
+ function detailRows(state, theme, width, height) {
1385
+ const record = selectedRecord(state);
1386
+ const tab = activeTab(state);
1387
+ const tabs = detailTabsFor(record);
1388
+ const body = detailBody(state, theme, width);
1163
1389
  const viewport = Math.max(0, height - 3);
1164
1390
  const maxScroll = Math.max(0, body.length - viewport);
1165
1391
  const start = Math.min(state.detailScroll, maxScroll);
1166
1392
  const end = Math.min(body.length, start + viewport);
1167
- const first = truncateToWidth(titleLine(record, theme, width), width);
1393
+ const first = truncateToWidth(' ' + titleLine(record, theme, Math.max(1, width - 2)), width);
1168
1394
  /*
1169
1395
  * The active section is marked, not only coloured. Every style helper is a
1170
1396
  * no-op when the terminal has no colour, so colour alone would leave a reader
@@ -1172,11 +1398,11 @@ function detailRows(state, theme, width, height) {
1172
1398
  * the same "this one" the ledger uses for its selected row.
1173
1399
  */
1174
1400
  const tabsText = tabs.map(candidate => candidate === tab
1175
- ? theme.bold(theme.fg('accent', `› ${candidate}`))
1176
- : theme.fg('dim', ` ${candidate}`)).join(' ');
1401
+ ? theme.bold(theme.fg('accent', `› ${TAB_LABELS[candidate]}`))
1402
+ : theme.fg('dim', ` ${TAB_LABELS[candidate]}`)).join(' ');
1177
1403
  const position = body.length > viewport ? theme.fg('dim', `${start + 1}-${end} / ${body.length}`) : '';
1178
1404
  const gap = Math.max(1, width - visibleWidth(tabsText) - visibleWidth(position));
1179
- const second = truncateToWidth(tabsText + ' '.repeat(gap) + position, width);
1405
+ const second = truncateToWidth(' ' + tabsText + ' '.repeat(Math.max(1, gap - 2)) + position, width);
1180
1406
  return [first, second, '', ...body.slice(start, end).map(line => truncateToWidth(' ' + line, width))];
1181
1407
  }
1182
1408
  /** `#3 TOOL` — which record this pane is about, and what it cost. */
@@ -1191,19 +1417,18 @@ function titleLine(record, theme, width) {
1191
1417
  const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
1192
1418
  return left + ' '.repeat(gap) + right;
1193
1419
  }
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
- */
1420
+ /** Overview combines recorded facts with readable input, reasoning and output. */
1202
1421
  function summaryBlock(rows, record, theme, width) {
1203
1422
  const facts = fieldRows(rows, theme, width);
1204
- if (record === undefined || record.result !== '' || record.summary === '')
1423
+ if (record === undefined)
1205
1424
  return facts;
1206
- return [...facts, '', ...renderMarkdown(record.summary, theme, width)];
1425
+ if (isCall(record) && record.input !== undefined) {
1426
+ return [...facts, '', theme.bold('Input'), ...argumentRows(record, theme, width), '', theme.bold('Result'),
1427
+ ...codeRows(record.resultRecorded ? record.result || 'No text output. Non-text fields are available in Raw.' : record.status === 'running' ? 'Running…' : 'No result recorded.', undefined, theme, width)];
1428
+ }
1429
+ if (record.kind === 'assistant')
1430
+ return [...facts, '', ...assistantWriting(record, theme, width)];
1431
+ return [...facts, '', ...renderMarkdown(record.result || record.summary, theme, width)];
1207
1432
  }
1208
1433
  /** Label/value rows with the label column as wide as the widest label needs. */
1209
1434
  function fieldRows(rows, theme, width) {
@@ -1219,29 +1444,22 @@ function fieldRows(rows, theme, width) {
1219
1444
  }
1220
1445
  return out;
1221
1446
  }
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
- */
1447
+ /** Preserve source indentation; only wrapped structured data receives a line-number gutter. */
1231
1448
  function codeRows(text, language, theme, width) {
1232
1449
  if (text === '')
1233
1450
  return [theme.fg('dim', 'Nothing recorded.')];
1234
- const source = text.split('\n');
1451
+ const source = text.split('\n').map(line => expandTabs(line, 8, 0));
1235
1452
  const digits = String(source.length).length;
1236
1453
  const gutter = digits + 1;
1237
- const unwrapped = source.every(line => visibleWidth(line) <= width);
1454
+ // Tool output may already contain source line numbers; only number structured raw data.
1455
+ const unwrapped = language === undefined || source.every(line => visibleWidth(line) <= width);
1238
1456
  const room = Math.max(1, width - (unwrapped ? 0 : gutter));
1239
1457
  const painted = language === undefined
1240
1458
  ? source
1241
1459
  : highlightCodeLines(source, language, theme);
1242
1460
  const out = [];
1243
1461
  for (const [index, line] of painted.entries()) {
1244
- const rows = wrapText(line, room);
1462
+ const rows = wrapCode(line, room);
1245
1463
  out.push(unwrapped ? (rows[0] ?? '') : theme.fg('dim', String(index + 1).padStart(digits) + ' ') + (rows[0] ?? ''));
1246
1464
  for (const rest of rows.slice(1))
1247
1465
  out.push(' '.repeat(unwrapped ? 0 : gutter) + rest);
@@ -1336,8 +1554,8 @@ export function renderTrajectory(state, theme, width, height) {
1336
1554
  const searchInfo = state.query === '' ? '' : ` · ${searchPosition(state)}`;
1337
1555
  const followInfo = state.following ? '' : state.followNotice > 0 ? ` · End: follow · +${state.followNotice} new` : '';
1338
1556
  const toolbar = truncateToWidth(theme.fg('dim', state.details
1339
- ? `${searchInfo.slice(2)}${followInfo} · ${formatOverlayHint([HOTKEY_NAVIGATE, HOTKEY_PAGE, HOTKEY_SECTION, HOTKEY_CLOSE])}`
1340
- : `${searchInfo.slice(2)}${followInfo} · ${formatOverlayHint([HOTKEY_NAVIGATE, HOTKEY_DETAILS, HOTKEY_SEARCH, HOTKEY_TURN, HOTKEY_CALLS])}${state.following ? ' ●' : ''}`), safeWidth);
1557
+ ? ` ${state.focus === 'list' && !state.fullDetail && safeWidth >= 96 ? 'Records' : 'Details'} · Tab focus · ←→ section · Ctrl+N/P record · f ${state.fullDetail ? 'split' : 'full screen'}`
1558
+ : ` / Search · Space group · t Turn · c Calls · e ${state.errorsOnly ? 'All records' : 'Problems'} · s Slow calls${state.following ? ' · Following' : ''}${searchInfo}${followInfo}`), safeWidth);
1341
1559
  const divider = theme.fg('border', '─'.repeat(safeWidth));
1342
1560
  /*
1343
1561
  * The overview answers "where did the time go" before the reader has to open a
@@ -1353,10 +1571,10 @@ export function renderTrajectory(state, theme, width, height) {
1353
1571
  */
1354
1572
  const overview = trajectoryOverviewHeight(state, safeWidth) === 0 ? undefined : timelineRows(state, theme, safeWidth);
1355
1573
  const bodyHeight = Math.max(0, safeHeight - 4 - (overview?.length ?? 0));
1356
- const wideDetails = state.details && safeWidth >= 96;
1574
+ const wideDetails = state.details && !state.fullDetail && safeWidth >= 96;
1357
1575
  let body;
1358
1576
  if (wideDetails) {
1359
- const leftWidth = Math.max(40, Math.floor(safeWidth * 0.58));
1577
+ const leftWidth = trajectoryLeftWidth(safeWidth);
1360
1578
  const rightWidth = Math.max(1, safeWidth - leftWidth - 1);
1361
1579
  const left = ledgerRows(state, theme, leftWidth, bodyHeight);
1362
1580
  const right = detailRows(state, theme, rightWidth, bodyHeight);
@@ -1373,8 +1591,8 @@ export function renderTrajectory(state, theme, width, height) {
1373
1591
  const footerText = state.searching
1374
1592
  ? ` Search: ${state.query}`
1375
1593
  : state.details
1376
- ? ' ' + formatOverlayHint([HOTKEY_PAGE, HOTKEY_SECTION, HOTKEY_CLOSE])
1377
- : ' ' + formatOverlayHint([HOTKEY_CLOSE]);
1594
+ ? ` ↑↓ / PgUp PgDn scroll · y Copy section · Esc Back${state.notice ? ' · ' + state.notice : ''}`
1595
+ : ` ↑↓ Select · Enter Inspect · End Follow · Esc Close${state.notice ? ' · ' + state.notice : ''}`;
1378
1596
  const footer = truncateToWidth(theme.fg(state.searching ? 'accent' : 'dim', footerText), safeWidth);
1379
1597
  const lines = [header, toolbar, divider, ...(overview ?? []), ...body.slice(0, bodyHeight), footer].slice(0, safeHeight);
1380
1598
  while (lines.length < safeHeight)