@agi-fans/dsh-tui 0.20.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.
- package/lib/CHANGELOG.md +249 -3
- package/lib/chrome/box.d.ts +47 -16
- package/lib/chrome/box.js +172 -130
- package/lib/chrome/box.js.map +1 -1
- package/lib/chrome/code-highlight.d.ts +15 -0
- package/lib/chrome/code-highlight.js +60 -4
- package/lib/chrome/code-highlight.js.map +1 -1
- package/lib/chrome/command-output.d.ts +29 -2
- package/lib/chrome/command-output.js +100 -4
- package/lib/chrome/command-output.js.map +1 -1
- package/lib/chrome/main-screen-renderer.d.ts +18 -0
- package/lib/chrome/main-screen-renderer.js +58 -5
- package/lib/chrome/main-screen-renderer.js.map +1 -1
- package/lib/chrome/markdown.js +30 -10
- package/lib/chrome/markdown.js.map +1 -1
- package/lib/chrome/relative-time.d.ts +17 -0
- package/lib/chrome/relative-time.js +36 -0
- package/lib/chrome/relative-time.js.map +1 -1
- package/lib/chrome/renderer.d.ts +9 -0
- package/lib/chrome/renderer.js +1 -1
- package/lib/chrome/renderer.js.map +1 -1
- package/lib/chrome/secret-mask.d.ts +35 -0
- package/lib/chrome/secret-mask.js +180 -0
- package/lib/chrome/secret-mask.js.map +1 -0
- package/lib/chrome/status-config.d.ts +161 -51
- package/lib/chrome/status-config.js +237 -126
- package/lib/chrome/status-config.js.map +1 -1
- package/lib/chrome/status-line.d.ts +3 -2
- package/lib/chrome/status-line.js +105 -63
- package/lib/chrome/status-line.js.map +1 -1
- package/lib/chrome/terminal-colors.d.ts +85 -0
- package/lib/chrome/terminal-colors.js +230 -0
- package/lib/chrome/terminal-colors.js.map +1 -0
- package/lib/chrome/terminal-images.d.ts +9 -1
- package/lib/chrome/terminal-images.js +27 -4
- package/lib/chrome/terminal-images.js.map +1 -1
- package/lib/chrome/theme.d.ts +32 -9
- package/lib/chrome/theme.js +616 -520
- package/lib/chrome/theme.js.map +1 -1
- package/lib/chrome/tool-args.d.ts +1 -1
- package/lib/chrome/tool-args.js +3 -1
- package/lib/chrome/tool-args.js.map +1 -1
- package/lib/chrome/tool-renderers.d.ts +2 -0
- package/lib/chrome/tool-renderers.js +69 -3
- package/lib/chrome/tool-renderers.js.map +1 -1
- package/lib/chrome/tools-list.d.ts +1 -4
- package/lib/chrome/tools-list.js +1 -41
- package/lib/chrome/tools-list.js.map +1 -1
- package/lib/chrome/welcome-tips.d.ts +20 -7
- package/lib/chrome/welcome-tips.js +22 -41
- package/lib/chrome/welcome-tips.js.map +1 -1
- package/lib/commands/auth-ids.d.ts +25 -0
- package/lib/commands/auth-ids.js +27 -0
- package/lib/commands/auth-ids.js.map +1 -0
- package/lib/commands/auth-input.d.ts +34 -0
- package/lib/commands/auth-input.js +96 -0
- package/lib/commands/auth-input.js.map +1 -0
- package/lib/commands/auth-providers.d.ts +39 -0
- package/lib/commands/auth-providers.js +125 -0
- package/lib/commands/auth-providers.js.map +1 -0
- package/lib/commands/auth.d.ts +16 -22
- package/lib/commands/auth.js +346 -299
- package/lib/commands/auth.js.map +1 -1
- package/lib/commands/btw.d.ts +27 -0
- package/lib/commands/btw.js +59 -0
- package/lib/commands/btw.js.map +1 -0
- package/lib/commands/diff.js +30 -5
- package/lib/commands/diff.js.map +1 -1
- package/lib/commands/export.d.ts +1 -1
- package/lib/commands/export.js +5 -3
- package/lib/commands/export.js.map +1 -1
- package/lib/commands/files.d.ts +17 -1
- package/lib/commands/files.js +69 -61
- package/lib/commands/files.js.map +1 -1
- package/lib/commands/integrations.js +18 -6
- package/lib/commands/integrations.js.map +1 -1
- package/lib/commands/jobs.d.ts +19 -4
- package/lib/commands/jobs.js +50 -7
- package/lib/commands/jobs.js.map +1 -1
- package/lib/commands/model.js +109 -76
- package/lib/commands/model.js.map +1 -1
- package/lib/commands/permission.js +46 -13
- package/lib/commands/permission.js.map +1 -1
- package/lib/commands/plugins.js +196 -42
- package/lib/commands/plugins.js.map +1 -1
- package/lib/commands/schedule.d.ts +29 -0
- package/lib/commands/schedule.js +228 -0
- package/lib/commands/schedule.js.map +1 -0
- package/lib/commands/session-configuration.d.ts +1 -1
- package/lib/commands/session-configuration.js +29 -28
- package/lib/commands/session-configuration.js.map +1 -1
- package/lib/commands/session.js +75 -80
- package/lib/commands/session.js.map +1 -1
- package/lib/commands/terminal.d.ts +24 -4
- package/lib/commands/terminal.js +38 -94
- package/lib/commands/terminal.js.map +1 -1
- package/lib/commands/trajectory.js +4 -1
- package/lib/commands/trajectory.js.map +1 -1
- package/lib/definition.d.ts +150 -11
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/input/actions.d.ts +51 -0
- package/lib/input/actions.js +120 -0
- package/lib/input/actions.js.map +1 -0
- package/lib/input/editor-discovery.js +2 -2
- package/lib/input/editor-discovery.js.map +1 -1
- package/lib/input/editor.d.ts +0 -10
- package/lib/input/editor.js +11 -9
- package/lib/input/editor.js.map +1 -1
- package/lib/input/image-paste.d.ts +19 -1
- package/lib/input/image-paste.js +82 -1
- package/lib/input/image-paste.js.map +1 -1
- package/lib/input/keybindings-config.d.ts +15 -4
- package/lib/input/keybindings-config.js +57 -35
- package/lib/input/keybindings-config.js.map +1 -1
- package/lib/input/keys.d.ts +18 -0
- package/lib/input/keys.js +48 -3
- package/lib/input/keys.js.map +1 -1
- package/lib/input/wheel-scroll.d.ts +46 -0
- package/lib/input/wheel-scroll.js +69 -0
- package/lib/input/wheel-scroll.js.map +1 -0
- package/lib/prompt-intents.d.ts +182 -0
- package/lib/prompt-intents.js +127 -0
- package/lib/prompt-intents.js.map +1 -0
- package/lib/runtime/agent-profile.d.ts +5 -0
- package/lib/runtime/agent-profile.js +12 -0
- package/lib/runtime/agent-profile.js.map +1 -1
- package/lib/runtime/approval-rules.d.ts +59 -0
- package/lib/runtime/approval-rules.js +179 -0
- package/lib/runtime/approval-rules.js.map +1 -0
- package/lib/runtime/btw-policy.d.ts +7 -0
- package/lib/runtime/btw-policy.js +18 -0
- package/lib/runtime/btw-policy.js.map +1 -0
- package/lib/runtime/btw.d.ts +47 -0
- package/lib/runtime/btw.js +200 -0
- package/lib/runtime/btw.js.map +1 -0
- package/lib/runtime/cancellable.d.ts +75 -0
- package/lib/runtime/cancellable.js +76 -0
- package/lib/runtime/cancellable.js.map +1 -0
- package/lib/runtime/composer-images.js +5 -4
- package/lib/runtime/composer-images.js.map +1 -1
- package/lib/runtime/composer-shell.d.ts +30 -0
- package/lib/runtime/composer-shell.js +150 -0
- package/lib/runtime/composer-shell.js.map +1 -0
- package/lib/runtime/file-recovery-local.d.ts +73 -0
- package/lib/runtime/file-recovery-local.js +351 -0
- package/lib/runtime/file-recovery-local.js.map +1 -0
- package/lib/runtime/file-recovery-recording.d.ts +23 -0
- package/lib/runtime/file-recovery-recording.js +131 -0
- package/lib/runtime/file-recovery-recording.js.map +1 -0
- package/lib/runtime/file-recovery-review.d.ts +3 -0
- package/lib/runtime/file-recovery-review.js +130 -0
- package/lib/runtime/file-recovery-review.js.map +1 -0
- package/lib/runtime/file-recovery-store.d.ts +83 -0
- package/lib/runtime/file-recovery-store.js +329 -0
- package/lib/runtime/file-recovery-store.js.map +1 -0
- package/lib/runtime/file-recovery-worker.d.ts +22 -0
- package/lib/runtime/file-recovery-worker.js +153 -0
- package/lib/runtime/file-recovery-worker.js.map +1 -0
- package/lib/runtime/file-review.d.ts +6 -3
- package/lib/runtime/file-review.js +44 -7
- package/lib/runtime/file-review.js.map +1 -1
- package/lib/runtime/herdr-agent.d.ts +4 -13
- package/lib/runtime/herdr-agent.js +19 -63
- package/lib/runtime/herdr-agent.js.map +1 -1
- package/lib/runtime/plain-tui.d.ts +2 -0
- package/lib/runtime/plain-tui.js +7 -1
- package/lib/runtime/plain-tui.js.map +1 -1
- package/lib/runtime/plugin-settings.d.ts +25 -2
- package/lib/runtime/plugin-settings.js +329 -31
- package/lib/runtime/plugin-settings.js.map +1 -1
- package/lib/runtime/provider-local.d.ts +81 -24
- package/lib/runtime/provider-local.js +1807 -608
- package/lib/runtime/provider-local.js.map +1 -1
- package/lib/runtime/runner.js +52 -12
- package/lib/runtime/runner.js.map +1 -1
- package/lib/runtime/session-archive.d.ts +6 -0
- package/lib/runtime/session-archive.js +129 -3
- package/lib/runtime/session-archive.js.map +1 -1
- package/lib/runtime/session-runtime.js +1 -1
- package/lib/runtime/session-runtime.js.map +1 -1
- package/lib/runtime/settings-preferences.d.ts +5 -0
- package/lib/runtime/settings-preferences.js +34 -0
- package/lib/runtime/settings-preferences.js.map +1 -0
- package/lib/runtime/startup-notices.js +2 -0
- package/lib/runtime/startup-notices.js.map +1 -1
- package/lib/runtime/terminal-modes.d.ts +47 -0
- package/lib/runtime/terminal-modes.js +86 -0
- package/lib/runtime/terminal-modes.js.map +1 -0
- package/lib/runtime/terminal-notifications.d.ts +3 -5
- package/lib/runtime/terminal-notifications.js +4 -8
- package/lib/runtime/terminal-notifications.js.map +1 -1
- package/lib/runtime/terminal-probe.d.ts +36 -0
- package/lib/runtime/terminal-probe.js +37 -0
- package/lib/runtime/terminal-probe.js.map +1 -0
- package/lib/runtime/terminal-session.d.ts +57 -0
- package/lib/runtime/terminal-session.js +164 -0
- package/lib/runtime/terminal-session.js.map +1 -0
- package/lib/runtime/terminal-state.d.ts +29 -0
- package/lib/runtime/terminal-state.js +62 -0
- package/lib/runtime/terminal-state.js.map +1 -0
- package/lib/runtime/tool-presentation.js +50 -14
- package/lib/runtime/tool-presentation.js.map +1 -1
- package/lib/session/external-agents.d.ts +5 -0
- package/lib/session/external-agents.js +27 -0
- package/lib/session/external-agents.js.map +1 -0
- package/lib/session/feature-toggles.d.ts +1 -1
- package/lib/session/feature-toggles.js +1 -1
- package/lib/session/interaction-adapter.d.ts +1 -6
- package/lib/session/interaction-adapter.js +95 -18
- package/lib/session/interaction-adapter.js.map +1 -1
- package/lib/session/message-queue.js +1 -1
- package/lib/session/message-queue.js.map +1 -1
- package/lib/session/model-readiness.d.ts +16 -0
- package/lib/session/model-readiness.js +43 -0
- package/lib/session/model-readiness.js.map +1 -0
- package/lib/session/package-metadata.d.ts +8 -0
- package/lib/session/package-metadata.js +8 -0
- package/lib/session/package-metadata.js.map +1 -1
- package/lib/session/project-context.d.ts +5 -0
- package/lib/session/project-context.js +36 -0
- package/lib/session/project-context.js.map +1 -1
- package/lib/session/session-controller.d.ts +10 -13
- package/lib/session/session-controller.js +183 -98
- package/lib/session/session-controller.js.map +1 -1
- package/lib/session/session-library.js +2 -0
- package/lib/session/session-library.js.map +1 -1
- package/lib/session/session-tree.d.ts +3 -0
- package/lib/session/session-tree.js +3 -1
- package/lib/session/session-tree.js.map +1 -1
- package/lib/session/subagent-roster.d.ts +2 -1
- package/lib/session/subagent-roster.js +24 -3
- package/lib/session/subagent-roster.js.map +1 -1
- package/lib/session/subagent-tracker.d.ts +6 -2
- package/lib/session/subagent-tracker.js +49 -2
- package/lib/session/subagent-tracker.js.map +1 -1
- package/lib/session/tui-settings.d.ts +47 -13
- package/lib/session/tui-settings.js +76 -34
- package/lib/session/tui-settings.js.map +1 -1
- package/lib/session/working-directory.d.ts +4 -0
- package/lib/session/working-directory.js +4 -0
- package/lib/session/working-directory.js.map +1 -0
- package/lib/session/worktrees.d.ts +13 -0
- package/lib/session/worktrees.js +49 -0
- package/lib/session/worktrees.js.map +1 -0
- package/lib/views/approval-rule.d.ts +3 -0
- package/lib/views/approval-rule.js +4 -0
- package/lib/views/approval-rule.js.map +1 -0
- package/lib/views/at-complete.d.ts +8 -0
- package/lib/views/at-complete.js +64 -2
- package/lib/views/at-complete.js.map +1 -1
- package/lib/views/autocomplete.d.ts +22 -2
- package/lib/views/autocomplete.js +70 -40
- package/lib/views/autocomplete.js.map +1 -1
- package/lib/views/copy-targets.d.ts +13 -14
- package/lib/views/copy-targets.js +42 -54
- package/lib/views/copy-targets.js.map +1 -1
- package/lib/views/document-reader.js +18 -5
- package/lib/views/document-reader.js.map +1 -1
- package/lib/views/event-views.d.ts +1 -1
- package/lib/views/event-views.js +60 -6
- package/lib/views/event-views.js.map +1 -1
- package/lib/views/file-actions.d.ts +12 -0
- package/lib/views/file-actions.js +72 -0
- package/lib/views/file-actions.js.map +1 -0
- package/lib/views/history-search.js +15 -21
- package/lib/views/history-search.js.map +1 -1
- package/lib/views/hotkeys.d.ts +8 -1
- package/lib/views/hotkeys.js +53 -26
- package/lib/views/hotkeys.js.map +1 -1
- package/lib/views/list-navigation.d.ts +62 -0
- package/lib/views/list-navigation.js +73 -0
- package/lib/views/list-navigation.js.map +1 -0
- package/lib/views/list-view.d.ts +99 -0
- package/lib/views/list-view.js +165 -0
- package/lib/views/list-view.js.map +1 -0
- package/lib/views/prompt-selector.d.ts +34 -4
- package/lib/views/prompt-selector.js +270 -134
- package/lib/views/prompt-selector.js.map +1 -1
- package/lib/views/session-files.d.ts +1 -2
- package/lib/views/session-files.js +1 -11
- package/lib/views/session-files.js.map +1 -1
- package/lib/views/settings-list.d.ts +51 -10
- package/lib/views/settings-list.js +267 -315
- package/lib/views/settings-list.js.map +1 -1
- package/lib/views/trajectory-timeline.d.ts +72 -0
- package/lib/views/trajectory-timeline.js +133 -0
- package/lib/views/trajectory-timeline.js.map +1 -0
- package/lib/views/trajectory.d.ts +15 -4
- package/lib/views/trajectory.js +557 -123
- package/lib/views/trajectory.js.map +1 -1
- package/lib/views/transcript-export.js +9 -0
- package/lib/views/transcript-export.js.map +1 -1
- package/lib/views/transcript-render.d.ts +97 -25
- package/lib/views/transcript-render.js +542 -170
- package/lib/views/transcript-render.js.map +1 -1
- package/lib/views/transcript-search.d.ts +39 -6
- package/lib/views/transcript-search.js +46 -11
- package/lib/views/transcript-search.js.map +1 -1
- package/lib/views/transcript-types.d.ts +63 -5
- package/lib/views/transcript-types.js +23 -11
- package/lib/views/transcript-types.js.map +1 -1
- package/package.json +95 -65
- package/lib/commands/loop.d.ts +0 -52
- package/lib/commands/loop.js +0 -307
- package/lib/commands/loop.js.map +0 -1
- package/lib/commands/steer.d.ts +0 -5
- package/lib/commands/steer.js +0 -24
- package/lib/commands/steer.js.map +0 -1
- package/lib/runtime/terminal-reader.d.ts +0 -11
- package/lib/runtime/terminal-reader.js +0 -87
- package/lib/runtime/terminal-reader.js.map +0 -1
- package/lib/views/agent-hub.d.ts +0 -32
- package/lib/views/agent-hub.js +0 -206
- package/lib/views/agent-hub.js.map +0 -1
- package/lib/views/copy-selector.d.ts +0 -51
- package/lib/views/copy-selector.js +0 -139
- package/lib/views/copy-selector.js.map +0 -1
package/lib/views/trajectory.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
565
|
-
const
|
|
566
|
-
|
|
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
|
-
|
|
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 (
|
|
636
|
-
return { state: move(state,
|
|
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
|
|
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
|
|
706
|
-
if (
|
|
925
|
+
const rows = trajectoryRows(state);
|
|
926
|
+
if (rows.length === 0)
|
|
707
927
|
return { pageSize: 1 };
|
|
708
|
-
const selected =
|
|
709
|
-
const start = Math.max(0, Math.min(
|
|
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 <
|
|
714
|
-
rows
|
|
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 -
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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 (
|
|
833
|
-
return
|
|
834
|
-
if (
|
|
835
|
-
return
|
|
836
|
-
if (
|
|
837
|
-
return
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
'',
|
|
844
|
-
record.
|
|
845
|
-
|
|
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
|
|
849
|
-
if (
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
887
|
-
const
|
|
888
|
-
const
|
|
889
|
-
const
|
|
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 +
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|