@pellux/goodvibes-tui 2.0.20 → 2.1.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/CHANGELOG.md +70 -0
- package/README.md +16 -16
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +59 -4
- package/package.json +16 -6
- package/src/cli/management.ts +5 -2
- package/src/cli/tui-startup.ts +34 -39
- package/src/core/agent-view-render.ts +157 -0
- package/src/core/approval-alert.ts +49 -11
- package/src/core/bookmark-navigation.ts +7 -1
- package/src/core/budget-breach-notifier.ts +27 -8
- package/src/core/compaction-receipt.ts +1 -1
- package/src/core/context-usage.ts +1 -1
- package/src/core/conversation-compaction-render.ts +3 -3
- package/src/core/conversation-display-only.ts +86 -0
- package/src/core/conversation-event-navigation.ts +27 -0
- package/src/core/conversation-line-cache.ts +216 -447
- package/src/core/conversation-render-context.ts +20 -27
- package/src/core/conversation-rendering.ts +157 -575
- package/src/core/conversation-splash-state.ts +6 -1
- package/src/core/conversation-types.ts +35 -0
- package/src/core/conversation-usage.ts +12 -0
- package/src/core/conversation.ts +130 -131
- package/src/core/long-task-notifier.ts +100 -31
- package/src/core/notices.ts +75 -0
- package/src/core/pairing-tailscale-gateway.ts +1 -1
- package/src/core/process-output-log.ts +143 -0
- package/src/core/rewind-receipt.ts +1 -1
- package/src/core/session-focus.ts +190 -0
- package/src/core/session-resume-core.ts +23 -81
- package/src/core/stream-event-wiring.ts +31 -5
- package/src/core/system-message-noise.ts +2 -2
- package/src/core/system-message-router.ts +16 -18
- package/src/core/turn-event-wiring.ts +272 -33
- package/src/core/work-tree-focus.ts +290 -0
- package/src/core/work-tree-fold-store.ts +143 -0
- package/src/core/work-tree-model.ts +799 -0
- package/src/core/work-tree-render.ts +384 -0
- package/src/core/work-tree-sources.ts +184 -0
- package/src/core/work-tree-wiring.ts +176 -0
- package/src/core/workstream-notification.ts +36 -6
- package/src/core/wrfc-notice-titles.ts +37 -0
- package/src/input/agents-modal-text.ts +119 -0
- package/src/input/agents-modal.ts +699 -0
- package/src/input/bookmark-modal.ts +33 -9
- package/src/input/changes-git.ts +251 -0
- package/src/input/changes-modal.ts +629 -0
- package/src/input/changes-model.ts +125 -0
- package/src/input/command-palette-types.ts +41 -0
- package/src/input/command-palette.ts +272 -0
- package/src/input/command-registry.ts +77 -35
- package/src/input/commands/acp-runtime.ts +19 -9
- package/src/input/commands/channel-runtime.ts +2 -2
- package/src/input/commands/ci-runtime.ts +1 -1
- package/src/input/commands/codebase-runtime.ts +1 -1
- package/src/input/commands/config.ts +7 -1
- package/src/input/commands/context-window.ts +25 -19
- package/src/input/commands/control-room-runtime.ts +18 -18
- package/src/input/commands/conversation-runtime.ts +0 -1
- package/src/input/commands/cost-runtime.ts +29 -20
- package/src/input/commands/diff-runtime.ts +35 -181
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/experience-runtime.ts +3 -7
- package/src/input/commands/fresh-conversation.ts +86 -0
- package/src/input/commands/git-runtime.ts +5 -22
- package/src/input/commands/graph-runtime.ts +1 -1
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +8 -9
- package/src/input/commands/hooks-runtime.ts +2 -2
- package/src/input/commands/hosted-runtime.ts +3 -3
- package/src/input/commands/incident-runtime.ts +3 -3
- package/src/input/commands/intelligence-runtime.ts +6 -9
- package/src/input/commands/knowledge.ts +2 -2
- package/src/input/commands/legacy-panel-command.ts +57 -0
- package/src/input/commands/local-auth-runtime.ts +8 -9
- package/src/input/commands/local-provider-runtime.ts +7 -4
- package/src/input/commands/local-runtime.ts +12 -15
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/local-setup.ts +13 -19
- package/src/input/commands/memory.ts +1 -1
- package/src/input/commands/notify-runtime.ts +9 -0
- package/src/input/commands/operator-runtime.ts +10 -12
- package/src/input/commands/permissions-provenance.ts +3 -3
- package/src/input/commands/planning-runtime.ts +7 -7
- package/src/input/commands/platform-sandbox-runtime.ts +2 -2
- package/src/input/commands/platform-services-runtime.ts +1 -1
- package/src/input/commands/policy-dispatch.ts +2 -2
- package/src/input/commands/policy.ts +3 -3
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/remote-runtime.ts +1 -1
- package/src/input/commands/review-runtime.ts +39 -94
- package/src/input/commands/rewind-runtime.ts +81 -108
- package/src/input/commands/runtime-services.ts +15 -17
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-content.ts +21 -5
- package/src/input/commands/session-workflow.ts +13 -38
- package/src/input/commands/settings-sync-runtime.ts +2 -2
- package/src/input/commands/shell-core.ts +19 -4
- package/src/input/commands/skills-runtime.ts +2 -2
- package/src/input/commands/status-runtime.ts +13 -0
- package/src/input/commands/subscription-runtime.ts +1 -1
- package/src/input/commands/tasks-runtime.ts +2 -8
- package/src/input/commands/work-plan-runtime.ts +4 -4
- package/src/input/commands/workstream-attempts.ts +24 -47
- package/src/input/commands/workstream-runtime.ts +2 -2
- package/src/input/commands/worktree-runtime.ts +3 -3
- package/src/input/commands.ts +3 -1
- package/src/input/composer-editor.ts +44 -0
- package/src/input/concealed-input.ts +1 -1
- package/src/input/config-modal-types.ts +20 -8
- package/src/input/config-modal.ts +106 -105
- package/src/input/confirm-dialog-types.ts +28 -0
- package/src/input/confirm-dialog.ts +70 -0
- package/src/input/delete-key-policy.ts +3 -3
- package/src/input/feed-context-factory.ts +15 -16
- package/src/input/handler-command-route.ts +7 -42
- package/src/input/handler-content-actions.ts +16 -15
- package/src/input/handler-feed-routes.ts +26 -211
- package/src/input/handler-feed.ts +68 -95
- package/src/input/handler-interactions.ts +5 -3
- package/src/input/handler-modal-routes.ts +215 -216
- package/src/input/handler-modal-stack.ts +81 -11
- package/src/input/handler-modal-token-routes.ts +4 -0
- package/src/input/handler-onboarding.ts +0 -1
- package/src/input/handler-picker-routes.ts +65 -107
- package/src/input/handler-session-view-route.ts +149 -0
- package/src/input/handler-shortcuts.ts +16 -154
- package/src/input/handler-types.ts +4 -2
- package/src/input/handler-ui-state.ts +45 -15
- package/src/input/handler-work-tree-route.ts +123 -0
- package/src/input/handler.ts +25 -70
- package/src/input/keybindings.ts +16 -71
- package/src/input/masked-entry-modal.ts +117 -0
- package/src/input/mcp-workspace.ts +48 -17
- package/src/input/model-picker-filter.ts +8 -0
- package/src/input/model-picker-open.ts +99 -0
- package/src/input/model-picker-types.ts +1 -1
- package/src/input/model-picker.ts +92 -9
- package/src/input/notifications-modal.ts +123 -0
- package/src/input/overlay-filter.ts +26 -0
- package/src/input/profile-picker-modal.ts +37 -8
- package/src/input/search.ts +3 -3
- package/src/input/selection-modal-queue.ts +2 -0
- package/src/input/selection-modal.ts +28 -16
- package/src/input/session-picker-modal.ts +33 -8
- package/src/input/settings-modal-activation.ts +7 -2
- package/src/input/settings-modal-data.ts +11 -6
- package/src/input/settings-modal-types.ts +1 -1
- package/src/input/settings-modal.ts +20 -20
- package/src/input/submission-intent.ts +1 -1
- package/src/input/submission-router.ts +4 -9
- package/src/input/surface-modal-host.ts +110 -0
- package/src/input/theme-settings-actions.ts +82 -0
- package/src/input/usage-modal.ts +183 -0
- package/src/input/views.ts +97 -0
- package/src/main.ts +175 -165
- package/src/permissions/hunk-selection.ts +1 -1
- package/src/permissions/prompt-card.ts +511 -0
- package/src/permissions/prompt.ts +59 -622
- package/src/renderer/agents-modal.ts +310 -0
- package/src/renderer/autocomplete-overlay.ts +42 -220
- package/src/renderer/back-to-bottom.ts +80 -0
- package/src/renderer/block-actions-overlay.ts +30 -48
- package/src/renderer/bookmark-modal.ts +48 -79
- package/src/renderer/changes-modal.ts +325 -0
- package/src/renderer/code-block.ts +184 -106
- package/src/renderer/command-palette.ts +93 -0
- package/src/renderer/composer-fragments.ts +4 -4
- package/src/renderer/composer.ts +146 -0
- package/src/renderer/compositor.ts +26 -160
- package/src/renderer/config-modal.ts +185 -81
- package/src/renderer/confirm-dialog.ts +50 -0
- package/src/renderer/context-inspector.ts +116 -154
- package/src/renderer/context-status-hint.ts +4 -4
- package/src/renderer/conversation-layout.ts +20 -0
- package/src/renderer/conversation-overlays.ts +94 -84
- package/src/renderer/conversation-surface.ts +19 -38
- package/src/renderer/diff-tint.ts +98 -0
- package/src/renderer/file-picker-overlay.ts +63 -90
- package/src/renderer/footer-targets.ts +38 -0
- package/src/renderer/git-status.ts +3 -4
- package/src/renderer/header-line.ts +140 -117
- package/src/renderer/help-overlay.ts +133 -219
- package/src/renderer/history-search-overlay.ts +15 -12
- package/src/renderer/lane-graph/bead.ts +800 -0
- package/src/renderer/lane-graph/glyphs.ts +134 -0
- package/src/renderer/lane-graph/layout.ts +248 -0
- package/src/renderer/lane-graph/paint.ts +537 -0
- package/src/renderer/lane-graph/semantic-memo.ts +77 -0
- package/src/renderer/layout-engine.ts +2 -57
- package/src/renderer/layout.ts +7 -13
- package/src/renderer/markdown-inline.ts +2 -1
- package/src/renderer/markdown-table.ts +17 -12
- package/src/renderer/markdown.ts +92 -34
- package/src/renderer/masked-entry-modal.ts +78 -0
- package/src/renderer/mcp-workspace.ts +166 -203
- package/src/renderer/model-workspace.ts +381 -436
- package/src/renderer/notifications-modal.ts +116 -0
- package/src/renderer/onboarding/onboarding-wizard.ts +178 -588
- package/src/renderer/process-view.ts +192 -0
- package/src/renderer/profile-picker-modal.ts +58 -110
- package/src/renderer/search-overlay.ts +6 -3
- package/src/renderer/selection-modal-overlay.ts +87 -327
- package/src/renderer/semantic-diff.ts +2 -2
- package/src/renderer/session-chips.ts +102 -0
- package/src/renderer/session-picker-modal.ts +177 -297
- package/src/renderer/settings-modal-connections.ts +14 -23
- package/src/renderer/settings-modal-context.ts +367 -0
- package/src/renderer/settings-modal-helpers.ts +6 -11
- package/src/renderer/settings-modal.ts +322 -726
- package/src/renderer/shell-surface.ts +277 -175
- package/src/renderer/shortcuts-overlay.ts +217 -0
- package/src/renderer/status-line.ts +475 -0
- package/src/renderer/surface-compose.ts +174 -0
- package/src/renderer/surface-kit-extra.ts +303 -0
- package/src/renderer/surface-kit-list.ts +366 -0
- package/src/renderer/surface-kit-parts.ts +360 -0
- package/src/renderer/surface-kit.ts +568 -0
- package/src/renderer/syntax-highlighter.ts +280 -197
- package/src/renderer/syntax-theme.ts +51 -0
- package/src/renderer/system-message.ts +0 -1
- package/src/renderer/term-caps.ts +42 -1
- package/src/renderer/terminal-bg-probe.ts +116 -19
- package/src/renderer/terminal-palette-probe.ts +322 -0
- package/src/renderer/terminal-palette-reader.ts +183 -0
- package/src/renderer/terminal-palette.ts +58 -0
- package/src/renderer/theme-mode-config.ts +16 -1
- package/src/renderer/theme.ts +234 -207
- package/src/renderer/thinking.ts +0 -1
- package/src/renderer/throbber.ts +188 -0
- package/src/renderer/toast-center.ts +119 -0
- package/src/renderer/ui-factory.ts +39 -602
- package/src/renderer/ui-primitives.ts +8 -25
- package/src/renderer/usage-modal.ts +353 -0
- package/src/renderer/user-message.ts +58 -0
- package/src/renderer/voice-capture-chip.ts +53 -0
- package/src/runtime/bootstrap-command-context.ts +0 -5
- package/src/runtime/bootstrap-command-parts.ts +38 -89
- package/src/runtime/bootstrap-core.ts +13 -15
- package/src/runtime/bootstrap-hook-bridge.ts +12 -21
- package/src/runtime/bootstrap-notifier-sync.ts +17 -1
- package/src/runtime/bootstrap-shell.ts +16 -50
- package/src/runtime/bootstrap.ts +14 -14
- package/src/runtime/client/fleet-union.ts +5 -5
- package/src/runtime/client/hosted-exit.ts +2 -2
- package/src/runtime/client/hosted-sessions.ts +1 -1
- package/src/runtime/client/spine-adoption.ts +1 -1
- package/src/runtime/context-accounting-source.ts +5 -4
- package/src/runtime/index.ts +1 -0
- package/src/runtime/notification-dispatch.ts +102 -29
- package/src/runtime/recovery-offer-wiring.ts +4 -1
- package/src/runtime/recovery-prompt.ts +31 -15
- package/src/runtime/runtime-services-types.ts +10 -6
- package/src/runtime/services.ts +17 -12
- package/src/runtime/session-continuity-hints.ts +2 -5
- package/src/runtime/session-inbound-inputs.ts +1 -1
- package/src/runtime/session-resume-liveness-confirm.ts +14 -2
- package/src/runtime/startup-typeahead-gate.ts +103 -0
- package/src/runtime/store/domains/domain-read-matrix.ts +0 -1
- package/src/runtime/store/domains/index.ts +0 -8
- package/src/runtime/store/selectors/index.ts +0 -28
- package/src/runtime/store/state.ts +0 -8
- package/src/runtime/ui-services.ts +4 -6
- package/src/runtime/usage-tracker.ts +348 -0
- package/src/runtime/wrfc-persistence.ts +4 -4
- package/src/shell/blocking-input.ts +92 -2
- package/src/shell/recovery-input-helpers.ts +1 -1
- package/src/shell/session-views.ts +539 -0
- package/src/shell/status-report.ts +86 -0
- package/src/shell/transcript-scroll.ts +68 -0
- package/src/shell/ui-openers.ts +84 -172
- package/src/shell/view-openers.ts +205 -0
- package/src/tools/wrfc-agent-guard.ts +22 -2
- package/src/utils/format-duration.ts +4 -8
- package/src/utils/splash-lines.ts +9 -1
- package/src/utils/terminal-width.ts +56 -0
- package/src/verification/verification-ledger.ts +18 -16
- package/src/{panels → views}/agent-inspector-shared.ts +2 -2
- package/src/{panels → views}/builtin-modals.ts +22 -21
- package/src/views/builtin-views.ts +135 -0
- package/src/{panels → views}/confirm-state.ts +9 -9
- package/src/{panels → views}/diff-review-model.ts +10 -3
- package/src/{panels → views}/eval-registry.ts +1 -1
- package/src/{panels → views}/fleet-acts.ts +49 -85
- package/src/{panels/fleet-panel-format.ts → views/fleet-format.ts} +40 -40
- package/src/{panels → views}/fleet-gateway.ts +5 -5
- package/src/{panels → views}/fleet-observed-render.ts +16 -16
- package/src/{panels → views}/fleet-read-model.ts +9 -9
- package/src/{panels/fleet-tabs.ts → views/fleet-session-tabs.ts} +13 -13
- package/src/{panels → views}/fleet-spawn.ts +33 -49
- package/src/{panels → views}/fleet-steer.ts +12 -12
- package/src/{panels → views}/fleet-stop.ts +4 -4
- package/src/{panels → views}/fleet-transcript.ts +26 -27
- package/src/{panels/fleet-panel-worktree-detail.ts → views/fleet-worktree-detail.ts} +7 -7
- package/src/{panels → views}/hosted-session-feed.ts +6 -6
- package/src/views/modal-surface-registry.ts +41 -0
- package/src/{panels → views}/modals/devices-modal.ts +2 -2
- package/src/{panels → views}/modals/hooks-modal.ts +13 -13
- package/src/{panels → views}/modals/keybindings-modal.ts +19 -27
- package/src/{panels → views}/modals/knowledge-modal.ts +6 -5
- package/src/{panels → views}/modals/local-auth-modal.ts +22 -21
- package/src/{panels → views}/modals/marketplace-modal.ts +11 -11
- package/src/{panels → views}/modals/memory-modal.ts +8 -8
- package/src/{panels → views}/modals/modal-surface-helpers.ts +15 -5
- package/src/{panels → views}/modals/pairing-modal.ts +9 -9
- package/src/{panels → views}/modals/planning-modal.ts +15 -20
- package/src/{panels → views}/modals/plugins-modal.ts +6 -6
- package/src/{panels → views}/modals/policy-modal.ts +13 -5
- package/src/{panels → views}/modals/provider-health-modal.ts +4 -4
- package/src/{panels → views}/modals/remote-modal.ts +3 -3
- package/src/{panels → views}/modals/sandbox-modal.ts +4 -4
- package/src/{panels → views}/modals/security-modal.ts +22 -16
- package/src/{panels → views}/modals/services-modal.ts +3 -3
- package/src/{panels → views}/modals/settings-sync-modal.ts +4 -4
- package/src/{panels → views}/modals/skills-modal.ts +9 -9
- package/src/{panels → views}/modals/subscription-modal.ts +3 -3
- package/src/{panels → views}/modals/work-plan-modal.ts +10 -13
- package/src/views/notifications-feed.ts +314 -0
- package/src/{panels → views}/polish-core.ts +38 -36
- package/src/{panels → views}/polish-tables.ts +35 -26
- package/src/{panels → views}/polish.ts +101 -95
- package/src/{panels → views}/project-planning-answer-actions.ts +3 -3
- package/src/{panels → views}/provider-health-routes.ts +14 -14
- package/src/{panels → views}/search-focus.ts +8 -8
- package/src/{panels → views}/session-maintenance.ts +11 -11
- package/src/views/skills-discovery.ts +159 -0
- package/src/views/view-deps.ts +80 -0
- package/src/views/view-panel-adapter.ts +53 -0
- package/src/{panels → views}/workstream-graph-render.ts +1 -1
- package/src/core/conversation-fold.ts +0 -101
- package/src/core/conversation-turn-structure.ts +0 -464
- package/src/core/conversation-user-receipts.ts +0 -42
- package/src/input/commands/operator-panel-runtime.ts +0 -186
- package/src/input/panel-integration-actions.ts +0 -33
- package/src/input/panel-mouse-geometry.ts +0 -97
- package/src/input/panel-paste-flood-guard.ts +0 -86
- package/src/panels/base-panel.ts +0 -301
- package/src/panels/builtin/agent.ts +0 -27
- package/src/panels/builtin/development.ts +0 -92
- package/src/panels/builtin/knowledge.ts +0 -12
- package/src/panels/builtin/operations.ts +0 -177
- package/src/panels/builtin/session.ts +0 -81
- package/src/panels/builtin/shared.ts +0 -357
- package/src/panels/builtin-panels.ts +0 -28
- package/src/panels/cost-tracker-panel.ts +0 -746
- package/src/panels/diff-panel.ts +0 -797
- package/src/panels/diff-review-panel.ts +0 -395
- package/src/panels/expandable-list-panel.ts +0 -189
- package/src/panels/fleet-deep-link.ts +0 -31
- package/src/panels/fleet-panel.ts +0 -799
- package/src/panels/git-panel.ts +0 -800
- package/src/panels/hosted-session-panel.ts +0 -221
- package/src/panels/index.ts +0 -21
- package/src/panels/local-auth-panel.ts +0 -478
- package/src/panels/notifications-feed.ts +0 -127
- package/src/panels/notifications-panel.ts +0 -160
- package/src/panels/panel-confirm-overlay.ts +0 -72
- package/src/panels/panel-manager.ts +0 -791
- package/src/panels/plugins-panel.ts +0 -371
- package/src/panels/scrollable-list-panel.ts +0 -498
- package/src/panels/skills-panel.ts +0 -454
- package/src/panels/token-budget-panel.ts +0 -688
- package/src/panels/types.ts +0 -191
- package/src/panels/wrfc-panel-format.ts +0 -133
- package/src/renderer/diff-view.ts +0 -134
- package/src/renderer/fleet-tab-strip.ts +0 -56
- package/src/renderer/footer-tips.ts +0 -53
- package/src/renderer/fullscreen-primitives.ts +0 -146
- package/src/renderer/fullscreen-workspace.ts +0 -194
- package/src/renderer/hint-grammar.ts +0 -52
- package/src/renderer/modal-factory.ts +0 -480
- package/src/renderer/overlay-box.ts +0 -154
- package/src/renderer/overlay-viewport.ts +0 -104
- package/src/renderer/panel-composite.ts +0 -145
- package/src/renderer/panel-workspace-bar.ts +0 -54
- package/src/renderer/process-indicator.ts +0 -163
- package/src/renderer/tab-strip.ts +0 -222
- package/src/renderer/text-layout.ts +0 -31
- package/src/renderer/tool-call.ts +0 -276
- package/src/renderer/tool-result-expanded-lines.ts +0 -48
- package/src/renderer/tool-result-summary.ts +0 -219
- package/src/runtime/diagnostics/panels/index.ts +0 -21
- package/src/runtime/diagnostics/panels/ops.ts +0 -156
- package/src/runtime/perf/panel-contracts.ts +0 -32
- package/src/runtime/store/domains/panels.ts +0 -117
- /package/src/{panels → views}/memory-consolidation-gateway.ts +0 -0
- /package/src/{panels → views}/modals/modal-theme.ts +0 -0
- /package/src/{panels → views}/provider-health-domains.ts +0 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* work-tree-model.ts, one assistant turn as the rows of its lane graph.
|
|
3
|
+
*
|
|
4
|
+
* A turn is every assistant and tool message between two user messages. Its
|
|
5
|
+
* rows, in display order:
|
|
6
|
+
*
|
|
7
|
+
* head the turn header (the spine opens)
|
|
8
|
+
* prose narration between tool calls, on the spine
|
|
9
|
+
* bead one tool call, on the lane of whoever made it
|
|
10
|
+
* spawn an agent branching into its own lane
|
|
11
|
+
* merge that agent finishing, back into the lane it came from
|
|
12
|
+
* folded a folded agent lane: one ◉ bead on its parent's lane
|
|
13
|
+
* answer the final prose (the spine closes on its first row)
|
|
14
|
+
*
|
|
15
|
+
* Ordering: calls are placed by when they started when the timing store
|
|
16
|
+
* knows (so lanes that ran at the same time interleave), and otherwise in
|
|
17
|
+
* transcript order with a child lane's rows directly under the call that
|
|
18
|
+
* started it.
|
|
19
|
+
*
|
|
20
|
+
* Pure: every live fact comes in through WorkTreeSources.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
24
|
+
import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
|
|
25
|
+
import {
|
|
26
|
+
beadArgument,
|
|
27
|
+
beadBody,
|
|
28
|
+
beadName,
|
|
29
|
+
beadStatus,
|
|
30
|
+
beadSummary,
|
|
31
|
+
cellText,
|
|
32
|
+
formatBeadTime,
|
|
33
|
+
formatCost,
|
|
34
|
+
inferResultToolName,
|
|
35
|
+
isBackgroundCall,
|
|
36
|
+
needsAttention,
|
|
37
|
+
toolFamily,
|
|
38
|
+
type BeadBody,
|
|
39
|
+
type BeadStatus,
|
|
40
|
+
type BeadSummary,
|
|
41
|
+
type CallOutcome,
|
|
42
|
+
} from '../renderer/lane-graph/bead.ts';
|
|
43
|
+
import { SPINE, type LaneId } from '../renderer/lane-graph/layout.ts';
|
|
44
|
+
import { userMessageFingerprint, type AgentLaneInfo, type TurnOutcome, type TurnTiming, type WorkTreeSources, type WrfcPhaseInfo } from './work-tree-sources.ts';
|
|
45
|
+
|
|
46
|
+
type Message = ConversationMessageSnapshot;
|
|
47
|
+
type AssistantMessage = Extract<Message, { role: 'assistant' }>;
|
|
48
|
+
type ToolMessage = Extract<Message, { role: 'tool' }>;
|
|
49
|
+
|
|
50
|
+
/** Deepest agent nesting drawn as lanes; deeper work folds to one bead that says so. */
|
|
51
|
+
export const MAX_NEST_DEPTH = 8;
|
|
52
|
+
|
|
53
|
+
/** Collapse key of a turn (true = folded to its header). */
|
|
54
|
+
export function turnKeyOf(headIdx: number): string {
|
|
55
|
+
return `turn_${headIdx}`;
|
|
56
|
+
}
|
|
57
|
+
/** Collapse key of a bead's body (true or unset = closed). */
|
|
58
|
+
export function beadKeyOf(beadId: string): string {
|
|
59
|
+
return `bead_${beadId}`;
|
|
60
|
+
}
|
|
61
|
+
/** Collapse key of a bead body's "… N more" (false or unset = capped). */
|
|
62
|
+
export function beadMoreKeyOf(beadId: string): string {
|
|
63
|
+
return `beadmore_${beadId}`;
|
|
64
|
+
}
|
|
65
|
+
/** Collapse key of an agent lane (true = folded to one ◉ bead). */
|
|
66
|
+
export function laneKeyOf(scope: string, agentId: string): string {
|
|
67
|
+
return `lane_${scope}${agentId}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface BeadModel {
|
|
71
|
+
/** Stable id: `<scope>c:<messageIndex>:<callIndex>`. */
|
|
72
|
+
readonly id: string;
|
|
73
|
+
readonly lane: LaneId;
|
|
74
|
+
readonly call: ToolCall;
|
|
75
|
+
/** Absolute index (in its own transcript) of the message that made the call. */
|
|
76
|
+
readonly messageIndex: number;
|
|
77
|
+
/** Absolute index of the result message, when settled (root transcript only). */
|
|
78
|
+
readonly resultIndex: number | undefined;
|
|
79
|
+
readonly scope: string;
|
|
80
|
+
readonly result: string | undefined;
|
|
81
|
+
readonly status: BeadStatus;
|
|
82
|
+
readonly name: string;
|
|
83
|
+
readonly arg: string;
|
|
84
|
+
readonly summary: BeadSummary | null;
|
|
85
|
+
readonly time: string | undefined;
|
|
86
|
+
readonly body: BeadBody | null;
|
|
87
|
+
readonly open: boolean;
|
|
88
|
+
readonly expanded: boolean;
|
|
89
|
+
/** WRFC fix rows: the finding this call answers. */
|
|
90
|
+
readonly answers?: string | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface LaneModel {
|
|
94
|
+
readonly id: LaneId;
|
|
95
|
+
readonly key: string;
|
|
96
|
+
readonly parent: LaneId;
|
|
97
|
+
readonly name: string;
|
|
98
|
+
readonly arg: string;
|
|
99
|
+
/** ok ✓, warn ! (finished carrying a failure), err ✕ (the agent failed), run (still working). */
|
|
100
|
+
readonly outcome: 'ok' | 'warn' | 'err' | 'run';
|
|
101
|
+
readonly mergeText: string;
|
|
102
|
+
/** Folded row summary: "5 tools · $0.12". */
|
|
103
|
+
readonly foldSummary: string;
|
|
104
|
+
readonly time: string | undefined;
|
|
105
|
+
readonly folded: boolean;
|
|
106
|
+
/** Recursion stopped here (depth ceiling or a cycle); the lane is drawn folded and says why. */
|
|
107
|
+
readonly truncated?: 'depth' | 'cycle' | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type TurnRow =
|
|
111
|
+
| { readonly kind: 'head' }
|
|
112
|
+
| { readonly kind: 'prose'; readonly messageIndex: number; readonly content: string; readonly role: 'assistant' | 'system' | 'thinking' | 'summary' }
|
|
113
|
+
| { readonly kind: 'bead'; readonly bead: BeadModel }
|
|
114
|
+
| { readonly kind: 'spawn'; readonly lane: LaneModel; readonly bead: BeadModel }
|
|
115
|
+
| { readonly kind: 'merge'; readonly lane: LaneModel }
|
|
116
|
+
| { readonly kind: 'folded'; readonly lane: LaneModel; readonly bead: BeadModel }
|
|
117
|
+
| { readonly kind: 'answer'; readonly messageIndex: number; readonly content: string };
|
|
118
|
+
|
|
119
|
+
export interface TurnModel {
|
|
120
|
+
readonly turnKey: string;
|
|
121
|
+
readonly headIndex: number;
|
|
122
|
+
/** Every message index (root transcript) the turn covers. */
|
|
123
|
+
readonly memberIndexes: readonly number[];
|
|
124
|
+
readonly folded: boolean;
|
|
125
|
+
/** "claude-opus-5-5 · 4 tools · 1 agent · 1.9s" */
|
|
126
|
+
readonly headerText: string;
|
|
127
|
+
readonly rows: readonly TurnRow[];
|
|
128
|
+
/** Something on the turn is live (a running bead or lane): it repaints as time passes. */
|
|
129
|
+
readonly live: boolean;
|
|
130
|
+
readonly toolCount: number;
|
|
131
|
+
readonly agentCount: number;
|
|
132
|
+
/** Collapse keys of every bead in the turn (search and /expand reach them). */
|
|
133
|
+
readonly beadKeys: readonly string[];
|
|
134
|
+
/**
|
|
135
|
+
* System notices that arrived while the turn ran ([WRFC] …, [Agents] …,
|
|
136
|
+
* compaction receipts), in order. A notice is not a step of the turn: it is
|
|
137
|
+
* drawn after the turn, outside its lanes (conversation-rendering.ts), the
|
|
138
|
+
* way a notice between turns is.
|
|
139
|
+
*/
|
|
140
|
+
readonly notices: ReadonlyArray<{ readonly messageIndex: number; readonly content: string }>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A contiguous stretch of the transcript: one user message, one turn, or one standalone system message. */
|
|
144
|
+
export type TranscriptUnit =
|
|
145
|
+
| { readonly kind: 'message'; readonly index: number }
|
|
146
|
+
| { readonly kind: 'turn'; readonly start: number; readonly end: number; readonly headIndex: number };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Split a transcript slice into units. A turn runs from the first assistant
|
|
150
|
+
* message after a user message to the last assistant or tool message before
|
|
151
|
+
* the next one; system messages inside that stretch belong to the turn, and
|
|
152
|
+
* system messages around it stand alone.
|
|
153
|
+
*/
|
|
154
|
+
export function transcriptUnits(messages: readonly Message[], offset = 0): TranscriptUnit[] {
|
|
155
|
+
const units: TranscriptUnit[] = [];
|
|
156
|
+
let i = 0;
|
|
157
|
+
while (i < messages.length) {
|
|
158
|
+
const role = messages[i]!.role;
|
|
159
|
+
if (role !== 'assistant' && role !== 'tool') {
|
|
160
|
+
units.push({ kind: 'message', index: offset + i });
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
let end = i;
|
|
165
|
+
let j = i;
|
|
166
|
+
while (j < messages.length && messages[j]!.role !== 'user') {
|
|
167
|
+
if (messages[j]!.role !== 'system') end = j;
|
|
168
|
+
j++;
|
|
169
|
+
}
|
|
170
|
+
if (messages[i]!.role === 'tool' && !messages.slice(i, end + 1).some((m) => m.role === 'assistant')) {
|
|
171
|
+
// Orphan results with no assistant message in the slice (a display cleared mid-turn).
|
|
172
|
+
for (let k = i; k <= end; k++) units.push({ kind: 'message', index: offset + k });
|
|
173
|
+
} else {
|
|
174
|
+
const head = messages.slice(i, end + 1).findIndex((m) => m.role === 'assistant');
|
|
175
|
+
units.push({ kind: 'turn', start: offset + i, end: offset + end, headIndex: offset + i + Math.max(0, head) });
|
|
176
|
+
}
|
|
177
|
+
for (let k = end + 1; k < j; k++) units.push({ kind: 'message', index: offset + k });
|
|
178
|
+
i = j;
|
|
179
|
+
}
|
|
180
|
+
return units;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function outcomeOf(content: string): CallOutcome {
|
|
184
|
+
if (/^Error: cancelled by user\b/.test(content)) return 'cancelled';
|
|
185
|
+
if (/^Error: /.test(content)) return 'error';
|
|
186
|
+
return 'ok';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasProse(message: AssistantMessage): boolean {
|
|
190
|
+
return typeof message.content === 'string' && message.content.trim().length > 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function firstLine(text: string, max = 120): string {
|
|
194
|
+
const line = cellText(text.split('\n').find((l) => l.trim().length > 0) ?? '').trim();
|
|
195
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function plural(n: number, word: string): string {
|
|
199
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The agent ids a call started: one for `agent` spawn, several for batch-spawn. */
|
|
203
|
+
function spawnedAgentIds(call: ToolCall, result: string | undefined): string[] {
|
|
204
|
+
if (toolFamily(call.name) !== 'agent') return [];
|
|
205
|
+
const mode = call.arguments.mode;
|
|
206
|
+
if (mode !== 'spawn' && mode !== 'batch-spawn' && !(mode === undefined && typeof call.arguments.task === 'string')) return [];
|
|
207
|
+
if (!result || !result.includes('agentId')) return [];
|
|
208
|
+
try {
|
|
209
|
+
const parsed: unknown = JSON.parse(result);
|
|
210
|
+
const ids: string[] = [];
|
|
211
|
+
const visit = (value: unknown, depth: number): void => {
|
|
212
|
+
if (depth > 3 || value === null || typeof value !== 'object') return;
|
|
213
|
+
if (Array.isArray(value)) { for (const v of value) visit(v, depth + 1); return; }
|
|
214
|
+
const rec = value as Record<string, unknown>;
|
|
215
|
+
if (typeof rec.agentId === 'string' && rec.agentId.length > 0) ids.push(rec.agentId);
|
|
216
|
+
for (const [key, child] of Object.entries(rec)) if (key !== 'agentId') visit(child, depth + 1);
|
|
217
|
+
};
|
|
218
|
+
visit(parsed, 0);
|
|
219
|
+
return [...new Set(ids)];
|
|
220
|
+
} catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Rows of one lane plus their start times, before interleaving. */
|
|
226
|
+
interface TimedRow {
|
|
227
|
+
readonly row: TurnRow;
|
|
228
|
+
/** Epoch ms this row happened, when known. */
|
|
229
|
+
readonly t: number | undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface BuildContext {
|
|
233
|
+
readonly sources: WorkTreeSources;
|
|
234
|
+
readonly collapse: ReadonlyMap<string, boolean>;
|
|
235
|
+
readonly waiting: ReadonlySet<string>;
|
|
236
|
+
readonly now: number;
|
|
237
|
+
/** Counters for the header. */
|
|
238
|
+
lanes: number;
|
|
239
|
+
live: boolean;
|
|
240
|
+
beadKeys: string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function callTime(ctx: BuildContext, call: ToolCall, status: BeadStatus): { t: number | undefined; text: string | undefined } {
|
|
244
|
+
const timing = call.id !== undefined ? ctx.sources.callTiming?.(call.id) : undefined;
|
|
245
|
+
if (!timing) return { t: undefined, text: undefined };
|
|
246
|
+
if (status === 'run' && timing.startedAt !== undefined) return { t: timing.startedAt, text: formatBeadTime(ctx.now - timing.startedAt) };
|
|
247
|
+
if (status === 'wait' && timing.startedAt !== undefined) return { t: timing.startedAt, text: formatBeadTime(ctx.now - timing.startedAt) };
|
|
248
|
+
return { t: timing.startedAt, text: timing.durationMs !== undefined ? formatBeadTime(timing.durationMs) : undefined };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function makeBead(
|
|
252
|
+
ctx: BuildContext,
|
|
253
|
+
lane: LaneId,
|
|
254
|
+
scope: string,
|
|
255
|
+
call: ToolCall,
|
|
256
|
+
messageIndex: number,
|
|
257
|
+
callIndex: number,
|
|
258
|
+
result: { content: string; index: number } | undefined,
|
|
259
|
+
ownerActive: boolean,
|
|
260
|
+
extra: { answers?: string } = {},
|
|
261
|
+
): { bead: BeadModel; t: number | undefined } {
|
|
262
|
+
const id = `${scope}c:${messageIndex}:${callIndex}`;
|
|
263
|
+
const content = result?.content;
|
|
264
|
+
const outcome = content !== undefined ? outcomeOf(content) : undefined;
|
|
265
|
+
const status = beadStatus({
|
|
266
|
+
outcome,
|
|
267
|
+
content,
|
|
268
|
+
waiting: call.id !== undefined && ctx.waiting.has(call.id),
|
|
269
|
+
ownerActive,
|
|
270
|
+
background: isBackgroundCall(call),
|
|
271
|
+
attention: content !== undefined && outcome === 'ok' && needsAttention(call, content),
|
|
272
|
+
});
|
|
273
|
+
if (status === 'run' || status === 'wait') ctx.live = true;
|
|
274
|
+
const body = beadBody(call, status, content);
|
|
275
|
+
const key = beadKeyOf(id);
|
|
276
|
+
ctx.beadKeys.push(key);
|
|
277
|
+
const time = callTime(ctx, call, status);
|
|
278
|
+
return {
|
|
279
|
+
t: time.t,
|
|
280
|
+
bead: {
|
|
281
|
+
id,
|
|
282
|
+
lane,
|
|
283
|
+
call,
|
|
284
|
+
messageIndex,
|
|
285
|
+
resultIndex: scope === '' ? result?.index : undefined,
|
|
286
|
+
scope,
|
|
287
|
+
result: content,
|
|
288
|
+
status,
|
|
289
|
+
name: beadName(call),
|
|
290
|
+
arg: beadArgument(call),
|
|
291
|
+
summary: beadSummary(call, status, content),
|
|
292
|
+
time: time.text,
|
|
293
|
+
body,
|
|
294
|
+
open: body !== null && ctx.collapse.get(key) === false,
|
|
295
|
+
expanded: ctx.collapse.get(beadMoreKeyOf(id)) === true,
|
|
296
|
+
answers: extra.answers,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Interleave a child lane's rows with the parent rows that follow its spawn. */
|
|
302
|
+
function interleave(parent: readonly TimedRow[], child: readonly TimedRow[]): TimedRow[] {
|
|
303
|
+
const out: TimedRow[] = [];
|
|
304
|
+
let p = 0;
|
|
305
|
+
let c = 0;
|
|
306
|
+
while (p < parent.length && c < child.length) {
|
|
307
|
+
const pt = parent[p]!.t;
|
|
308
|
+
const ct = child[c]!.t;
|
|
309
|
+
// Without both times, a child's rows come first: sequential nesting.
|
|
310
|
+
if (pt !== undefined && ct !== undefined && pt < ct) out.push(parent[p++]!);
|
|
311
|
+
else out.push(child[c++]!);
|
|
312
|
+
}
|
|
313
|
+
while (c < child.length) out.push(child[c++]!);
|
|
314
|
+
while (p < parent.length) out.push(parent[p++]!);
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A pending row list item: either a finished row, or a spawn whose child rows interleave with what follows. */
|
|
319
|
+
type Pending = TimedRow | { readonly spawn: TimedRow; readonly child: readonly TimedRow[] };
|
|
320
|
+
|
|
321
|
+
function resolvePending(items: readonly Pending[]): TimedRow[] {
|
|
322
|
+
for (let i = 0; i < items.length; i++) {
|
|
323
|
+
const item = items[i]!;
|
|
324
|
+
if ('spawn' in item) {
|
|
325
|
+
const rest = resolvePending(items.slice(i + 1));
|
|
326
|
+
const before = items.slice(0, i) as TimedRow[];
|
|
327
|
+
return [...before, item.spawn, ...interleave(rest, item.child)];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return items as TimedRow[];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Fill in missing times so each row keeps its place relative to the timed rows before it. */
|
|
334
|
+
function inheritTimes(rows: readonly TimedRow[], start: number | undefined): TimedRow[] {
|
|
335
|
+
let last = start;
|
|
336
|
+
return rows.map((r) => {
|
|
337
|
+
if (r.t !== undefined) { last = r.t; return r; }
|
|
338
|
+
return { row: r.row, t: last };
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function laneOutcome(info: AgentLaneInfo, rows: readonly TimedRow[]): LaneModel['outcome'] {
|
|
343
|
+
if (info.status === 'running' || info.status === 'pending') return 'run';
|
|
344
|
+
if (info.status === 'failed') return 'err';
|
|
345
|
+
if (info.status === 'cancelled') return 'warn';
|
|
346
|
+
if (info.wrfcPassed === false) return 'warn';
|
|
347
|
+
// A lane carries a failure when one of ITS OWN calls failed; a failure deeper
|
|
348
|
+
// down shows on the merge row of the lane it happened in.
|
|
349
|
+
const carriesFailure = rows.some((r) => r.row.kind === 'bead' && r.row.bead.lane === info.id && r.row.bead.status === 'err');
|
|
350
|
+
return carriesFailure ? 'warn' : 'ok';
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function laneTexts(info: AgentLaneInfo, name: string, rows: readonly TimedRow[], outcome: LaneModel['outcome'], ctx: BuildContext): { merge: string; fold: string; time: string | undefined } {
|
|
354
|
+
const tools = info.wrfcPhases ? plural(info.wrfcPhases.length, 'phase') : plural(info.toolCallCount, 'tool');
|
|
355
|
+
const end = info.completedAt ?? ctx.now;
|
|
356
|
+
const elapsed = info.startedAt !== undefined ? formatBeadTime(end - info.startedAt) : undefined;
|
|
357
|
+
const cost = info.costUsd !== undefined ? formatCost(info.costUsd) : undefined;
|
|
358
|
+
const failedTools = rows.filter((r) => r.row.kind === 'bead' && r.row.bead.lane === info.id && r.row.bead.status === 'err').length;
|
|
359
|
+
const nested = rows.filter((r) => r.row.kind === 'spawn' || r.row.kind === 'folded').length;
|
|
360
|
+
let lead: string;
|
|
361
|
+
if (info.wrfcPhases) {
|
|
362
|
+
lead = outcome === 'run' ? `${name} running` : info.wrfcPassed === false || outcome === 'err' ? `${name} failed` : `${name} passed`;
|
|
363
|
+
} else if (info.status === 'failed') {
|
|
364
|
+
lead = `${name} failed${info.error ? `: ${firstLine(info.error, 60)}` : ''}`;
|
|
365
|
+
} else if (info.status === 'cancelled') {
|
|
366
|
+
lead = `${name} cancelled`;
|
|
367
|
+
} else if (failedTools > 0) {
|
|
368
|
+
lead = `${name} finished with ${plural(failedTools, 'failed tool')}`;
|
|
369
|
+
} else {
|
|
370
|
+
lead = `${name} finished`;
|
|
371
|
+
}
|
|
372
|
+
const merge = [lead, tools, nested > 0 ? `${plural(nested, 'agent')} inside` : undefined, elapsed, cost].filter(Boolean).join(' · ');
|
|
373
|
+
const fold = [tools, cost].filter(Boolean).join(' · ');
|
|
374
|
+
return { merge, fold, time: elapsed };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The rows of an agent's own lane: its tool calls as beads, its own spawns as
|
|
379
|
+
* nested lanes. `ancestors` guards cycles; `depth` guards runaway nesting.
|
|
380
|
+
*/
|
|
381
|
+
function agentLaneRows(ctx: BuildContext, info: AgentLaneInfo, scope: string, depth: number, ancestors: readonly string[]): TimedRow[] {
|
|
382
|
+
const lane = info.id;
|
|
383
|
+
const active = info.status === 'running' || info.status === 'pending';
|
|
384
|
+
if (info.wrfcPhases) return wrfcLaneRows(ctx, info, scope, active);
|
|
385
|
+
const results = new Map<string, { content: string; index: number }>();
|
|
386
|
+
info.messages.forEach((m, i) => { if (m.role === 'tool' && m.callId) results.set(m.callId, { content: m.content, index: i }); });
|
|
387
|
+
const items: Pending[] = [];
|
|
388
|
+
info.messages.forEach((m, messageIndex) => {
|
|
389
|
+
if (m.role !== 'assistant') return;
|
|
390
|
+
(m.toolCalls ?? []).forEach((call, k) => {
|
|
391
|
+
const result = call.id !== undefined ? results.get(call.id) : undefined;
|
|
392
|
+
items.push(...callRows(ctx, lane, scope, call, messageIndex, k, result, active, depth, ancestors));
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
return inheritTimes(resolvePending(items), info.startedAt);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** A WRFC chain's lane: write, review, fix and confirm as beads, a fix pointing at the finding it answers. */
|
|
399
|
+
function wrfcLaneRows(ctx: BuildContext, info: AgentLaneInfo, scope: string, active: boolean): TimedRow[] {
|
|
400
|
+
const rows: TimedRow[] = [];
|
|
401
|
+
const phases = info.wrfcPhases ?? [];
|
|
402
|
+
let lastFindings: readonly string[] | undefined;
|
|
403
|
+
let fixed = false;
|
|
404
|
+
phases.forEach((phase, k) => {
|
|
405
|
+
const label = phaseLabel(phase, fixed);
|
|
406
|
+
if (phase.role === 'fixer') fixed = true;
|
|
407
|
+
const running = phase.status === 'running' || phase.status === 'pending';
|
|
408
|
+
const status: BeadStatus = running ? (active ? 'run' : 'cancel')
|
|
409
|
+
: phase.status === 'failed' ? 'err'
|
|
410
|
+
: phase.status === 'cancelled' ? 'cancel'
|
|
411
|
+
: phase.role === 'reviewer' && (phase.passed === false || (phase.findings?.length ?? 0) > 0) ? 'warn' : 'ok';
|
|
412
|
+
if (status === 'run') ctx.live = true;
|
|
413
|
+
const id = `${scope}w:${info.id}:${k}`;
|
|
414
|
+
const key = beadKeyOf(id);
|
|
415
|
+
ctx.beadKeys.push(key);
|
|
416
|
+
const findings = phase.findings ?? [];
|
|
417
|
+
const summary: BeadSummary | null = status === 'run' ? null
|
|
418
|
+
: phase.role === 'reviewer'
|
|
419
|
+
? (findings.length > 0 ? { text: plural(findings.length, 'finding'), tone: 'warn' } : phase.passed === false ? { text: 'changes requested', tone: 'warn' } : { text: 'pass', tone: 'good' })
|
|
420
|
+
: status === 'err' ? { text: 'failed', tone: 'bad' } : null;
|
|
421
|
+
const answers = phase.role === 'fixer' ? (lastFindings?.[0] ?? undefined) : undefined;
|
|
422
|
+
if (phase.role === 'reviewer') lastFindings = findings.length > 0 ? findings : lastFindings;
|
|
423
|
+
const bodyLines = [
|
|
424
|
+
...findings.map((f) => `• ${f}`),
|
|
425
|
+
...(phase.output ? phase.output.split('\n') : []),
|
|
426
|
+
].filter((l) => l.trim().length > 0);
|
|
427
|
+
const end = phase.completedAt ?? ctx.now;
|
|
428
|
+
// A phase is an agent, not a tool call: the bead's call carries its phase id and label only.
|
|
429
|
+
const call: ToolCall = { id, name: label, arguments: {} };
|
|
430
|
+
rows.push({
|
|
431
|
+
t: phase.startedAt,
|
|
432
|
+
row: {
|
|
433
|
+
kind: 'bead',
|
|
434
|
+
bead: {
|
|
435
|
+
id,
|
|
436
|
+
lane: info.id,
|
|
437
|
+
call,
|
|
438
|
+
messageIndex: -1,
|
|
439
|
+
resultIndex: undefined,
|
|
440
|
+
scope,
|
|
441
|
+
result: phase.output,
|
|
442
|
+
status,
|
|
443
|
+
name: label,
|
|
444
|
+
arg: firstLine(phase.role === 'reviewer' && findings.length > 0 ? findings[0]! : phase.task, 90),
|
|
445
|
+
summary,
|
|
446
|
+
time: phase.startedAt !== undefined ? formatBeadTime(end - phase.startedAt) : undefined,
|
|
447
|
+
body: bodyLines.length > 0 ? { kind: 'text', lines: bodyLines } : null,
|
|
448
|
+
open: bodyLines.length > 0 && ctx.collapse.get(key) === false,
|
|
449
|
+
expanded: ctx.collapse.get(beadMoreKeyOf(id)) === true,
|
|
450
|
+
answers,
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
return inheritTimes(rows, info.startedAt);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function phaseLabel(phase: WrfcPhaseInfo, afterFix: boolean): string {
|
|
459
|
+
switch (phase.role) {
|
|
460
|
+
case 'engineer': return 'write';
|
|
461
|
+
case 'reviewer': return afterFix ? 'confirm' : 'review';
|
|
462
|
+
case 'fixer': return 'fix';
|
|
463
|
+
case 'integrator': return 'integrate';
|
|
464
|
+
case 'verifier': return 'verify';
|
|
465
|
+
default: return phase.role;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The row(s) one call contributes: a bead, or a spawn with its child lane(s). */
|
|
470
|
+
function callRows(
|
|
471
|
+
ctx: BuildContext,
|
|
472
|
+
lane: LaneId,
|
|
473
|
+
scope: string,
|
|
474
|
+
call: ToolCall,
|
|
475
|
+
messageIndex: number,
|
|
476
|
+
callIndex: number,
|
|
477
|
+
result: { content: string; index: number } | undefined,
|
|
478
|
+
ownerActive: boolean,
|
|
479
|
+
depth: number,
|
|
480
|
+
ancestors: readonly string[],
|
|
481
|
+
): Pending[] {
|
|
482
|
+
const { bead, t } = makeBead(ctx, lane, scope, call, messageIndex, callIndex, result, ownerActive);
|
|
483
|
+
const agentIds = ctx.sources.agent ? spawnedAgentIds(call, result?.content) : [];
|
|
484
|
+
const out: Pending[] = [];
|
|
485
|
+
const infos = agentIds.map((id) => ({ id, info: ctx.sources.agent!(id) })).filter((a): a is { id: string; info: AgentLaneInfo } => a.info !== null);
|
|
486
|
+
if (infos.length === 0) return [{ row: { kind: 'bead', bead }, t }];
|
|
487
|
+
for (const { id, info } of infos) {
|
|
488
|
+
ctx.lanes++;
|
|
489
|
+
const key = laneKeyOf(scope, id);
|
|
490
|
+
const truncated: LaneModel['truncated'] = ancestors.includes(id) ? 'cycle' : depth + 1 > MAX_NEST_DEPTH ? 'depth' : undefined;
|
|
491
|
+
const childScope = `${scope}a:${id}/`;
|
|
492
|
+
const childRows = truncated ? [] : agentLaneRows(ctx, info, childScope, depth + 1, [...ancestors, id]);
|
|
493
|
+
const outcome = laneOutcome(info, childRows);
|
|
494
|
+
if (outcome === 'run') ctx.live = true;
|
|
495
|
+
// A hosted agent's lane is named for its harness ("Claude Code"), its task marked hosted.
|
|
496
|
+
const name = cellText(info.wrfcPhases ? 'WRFC chain' : info.hostedLabel ?? info.name);
|
|
497
|
+
const texts = laneTexts(info, name, childRows, outcome, ctx);
|
|
498
|
+
const arg = `${info.hostedLabel ? 'hosted · ' : ''}${firstLine(info.task, 100)}`;
|
|
499
|
+
const laneModel: LaneModel = {
|
|
500
|
+
id,
|
|
501
|
+
key,
|
|
502
|
+
parent: lane,
|
|
503
|
+
name,
|
|
504
|
+
arg: truncated === 'depth' ? `${arg} · nested deeper than ${MAX_NEST_DEPTH} lanes` : truncated === 'cycle' ? `${arg} · repeats an agent shown above` : arg,
|
|
505
|
+
outcome,
|
|
506
|
+
mergeText: texts.merge,
|
|
507
|
+
foldSummary: texts.fold,
|
|
508
|
+
time: texts.time,
|
|
509
|
+
folded: truncated !== undefined || ctx.collapse.get(key) === true,
|
|
510
|
+
truncated,
|
|
511
|
+
};
|
|
512
|
+
const spawnT = t ?? info.startedAt;
|
|
513
|
+
if (laneModel.folded) {
|
|
514
|
+
out.push({ row: { kind: 'folded', lane: laneModel, bead }, t: spawnT });
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const child: TimedRow[] = childRows.length > 0 ? inheritTimes(childRows, spawnT) : [];
|
|
518
|
+
if (outcome !== 'run') child.push({ row: { kind: 'merge', lane: laneModel }, t: info.completedAt ?? child[child.length - 1]?.t ?? spawnT });
|
|
519
|
+
out.push({ spawn: { row: { kind: 'spawn', lane: laneModel, bead }, t: spawnT }, child });
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface BuildTurnInput {
|
|
525
|
+
readonly messages: readonly Message[];
|
|
526
|
+
/** Absolute index of messages[0]. */
|
|
527
|
+
readonly offset: number;
|
|
528
|
+
readonly unit: Extract<TranscriptUnit, { kind: 'turn' }>;
|
|
529
|
+
readonly sources: WorkTreeSources;
|
|
530
|
+
readonly collapse: ReadonlyMap<string, boolean>;
|
|
531
|
+
/** The streaming placeholder's absolute index (its prose is drawn by the streaming path), or -1. */
|
|
532
|
+
readonly streamingIndex: number;
|
|
533
|
+
/** display.showThinking: reasoning text rows (collapsed to one row by default). */
|
|
534
|
+
readonly showThinking?: boolean;
|
|
535
|
+
/** display.showReasoningSummary: reasoning summary rows. */
|
|
536
|
+
readonly showReasoningSummary?: boolean;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function buildTurnModel(input: BuildTurnInput): TurnModel {
|
|
540
|
+
const { messages, offset, unit, sources } = input;
|
|
541
|
+
const at = (abs: number): Message => messages[abs - offset]!;
|
|
542
|
+
const now = sources.now?.() ?? Date.now();
|
|
543
|
+
const ctx: BuildContext = {
|
|
544
|
+
sources,
|
|
545
|
+
collapse: input.collapse,
|
|
546
|
+
waiting: sources.waitingCallIds?.() ?? new Set<string>(),
|
|
547
|
+
now,
|
|
548
|
+
lanes: 0,
|
|
549
|
+
live: false,
|
|
550
|
+
beadKeys: [],
|
|
551
|
+
};
|
|
552
|
+
// Whether the main conversation says it is working. With no live source (a
|
|
553
|
+
// plain transcript render) that is unknown: the header claims nothing, and
|
|
554
|
+
// an unsettled call in the latest turn still reads as running (an earlier
|
|
555
|
+
// turn has ended: a user message followed it).
|
|
556
|
+
const turnActiveNow = sources.turnActive?.() ?? false;
|
|
557
|
+
const callsMayRun = sources.turnActive?.() ?? true;
|
|
558
|
+
|
|
559
|
+
// Is this the transcript's latest turn? Only that one can still be running.
|
|
560
|
+
let isLatest = true;
|
|
561
|
+
for (let abs = unit.end + 1; abs < offset + messages.length; abs++) {
|
|
562
|
+
if (at(abs).role === 'user') { isLatest = false; break; }
|
|
563
|
+
}
|
|
564
|
+
const active = turnActiveNow && isLatest;
|
|
565
|
+
const callsLive = callsMayRun && isLatest;
|
|
566
|
+
|
|
567
|
+
const results = new Map<string, { content: string; index: number }>();
|
|
568
|
+
const members: number[] = [];
|
|
569
|
+
for (let abs = unit.start; abs <= unit.end; abs++) {
|
|
570
|
+
members.push(abs);
|
|
571
|
+
const m = at(abs);
|
|
572
|
+
if (m.role === 'tool' && m.callId) results.set(m.callId, { content: m.content, index: abs });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The answer: the last assistant message, when it has prose and no calls.
|
|
576
|
+
let answerIndex = -1;
|
|
577
|
+
for (let abs = unit.end; abs >= unit.start; abs--) {
|
|
578
|
+
const m = at(abs);
|
|
579
|
+
if (m.role !== 'assistant') continue;
|
|
580
|
+
if (hasProse(m) && (m.toolCalls?.length ?? 0) === 0 && abs !== input.streamingIndex) answerIndex = abs;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const head = at(unit.headIndex) as AssistantMessage;
|
|
585
|
+
let model = head.model;
|
|
586
|
+
let toolCount = 0;
|
|
587
|
+
const items: Pending[] = [];
|
|
588
|
+
const notices: Array<{ messageIndex: number; content: string }> = [];
|
|
589
|
+
const calledIds = new Set<string>();
|
|
590
|
+
for (let abs = unit.start; abs <= unit.end; abs++) {
|
|
591
|
+
const m = at(abs);
|
|
592
|
+
if (m.role === 'assistant') for (const c of m.toolCalls ?? []) if (c.id !== undefined) calledIds.add(c.id);
|
|
593
|
+
}
|
|
594
|
+
for (let abs = unit.start; abs <= unit.end; abs++) {
|
|
595
|
+
const m = at(abs);
|
|
596
|
+
if (m.role === 'tool' && !calledIds.has(m.callId)) {
|
|
597
|
+
// A result whose call is not in this turn (a cleared display, a replayed
|
|
598
|
+
// journal): its own bead, where it sits.
|
|
599
|
+
toolCount++;
|
|
600
|
+
const orphan = orphanResultBead(m, abs, ctx.collapse);
|
|
601
|
+
ctx.beadKeys.push(beadKeyOf(orphan.id));
|
|
602
|
+
items.push({ row: { kind: 'bead', bead: orphan }, t: undefined });
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (m.role === 'system') {
|
|
606
|
+
notices.push({ messageIndex: abs, content: m.content });
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (m.role !== 'assistant') continue;
|
|
610
|
+
if (m.model && model && m.model !== model) {
|
|
611
|
+
items.push({ row: { kind: 'prose', messageIndex: abs, content: `switched to ${m.model}`, role: 'system' }, t: undefined });
|
|
612
|
+
model = m.model;
|
|
613
|
+
}
|
|
614
|
+
if (input.showThinking && m.reasoningContent) {
|
|
615
|
+
items.push({ row: { kind: 'prose', messageIndex: abs, content: m.reasoningContent, role: 'thinking' }, t: undefined });
|
|
616
|
+
}
|
|
617
|
+
if (input.showReasoningSummary && m.reasoningSummary) {
|
|
618
|
+
items.push({ row: { kind: 'prose', messageIndex: abs, content: m.reasoningSummary, role: 'summary' }, t: undefined });
|
|
619
|
+
}
|
|
620
|
+
if (abs !== answerIndex && hasProse(m) && abs !== input.streamingIndex) {
|
|
621
|
+
items.push({ row: { kind: 'prose', messageIndex: abs, content: m.content, role: 'assistant' }, t: undefined });
|
|
622
|
+
}
|
|
623
|
+
(m.toolCalls ?? []).forEach((call, k) => {
|
|
624
|
+
toolCount++;
|
|
625
|
+
const result = call.id !== undefined ? results.get(call.id) : undefined;
|
|
626
|
+
items.push(...callRows(ctx, SPINE, '', call, abs, k, result, callsLive, 0, []));
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let rows = inheritTimes(resolvePending(items), undefined);
|
|
631
|
+
if (answerIndex >= 0) {
|
|
632
|
+
// With every lane finished the answer closes the turn. While a lane still
|
|
633
|
+
// runs, the answer sits after the spine's own last row and the running
|
|
634
|
+
// lane keeps drawing under it.
|
|
635
|
+
const running = rows.some((r) => (r.row.kind === 'spawn' && r.row.lane.outcome === 'run'));
|
|
636
|
+
let answerAt = rows.length;
|
|
637
|
+
if (running) {
|
|
638
|
+
let lastSpine = -1;
|
|
639
|
+
rows.forEach((r, k) => {
|
|
640
|
+
const lane = r.row.kind === 'bead' ? r.row.bead.lane : r.row.kind === 'spawn' || r.row.kind === 'merge' || r.row.kind === 'folded' ? r.row.lane.parent : SPINE;
|
|
641
|
+
if (lane === SPINE) lastSpine = k;
|
|
642
|
+
});
|
|
643
|
+
answerAt = lastSpine + 1;
|
|
644
|
+
// Merges into the spine must stay above the answer.
|
|
645
|
+
while (answerAt < rows.length && rows[answerAt]!.row.kind === 'merge' && (rows[answerAt]!.row as { lane: LaneModel }).lane.parent === SPINE) answerAt++;
|
|
646
|
+
}
|
|
647
|
+
rows = [...rows.slice(0, answerAt), { row: { kind: 'answer', messageIndex: answerIndex, content: (at(answerIndex) as AssistantMessage).content }, t: undefined }, ...rows.slice(answerAt)];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Header text.
|
|
651
|
+
const turnKey = turnKeyOf(unit.headIndex);
|
|
652
|
+
let userIndex = -1;
|
|
653
|
+
for (let abs = unit.start - 1; abs >= offset; abs--) if (at(abs).role === 'user') { userIndex = abs; break; }
|
|
654
|
+
const userMessage = userIndex >= 0 ? at(userIndex) : undefined;
|
|
655
|
+
const timing = turnTimingFor(sources, userIndex, userMessage);
|
|
656
|
+
const outcome = active ? undefined : turnOutcomeFor(sources, userIndex, userMessage, timing);
|
|
657
|
+
const parts: string[] = [];
|
|
658
|
+
if (model) parts.push(model);
|
|
659
|
+
if (active) parts.push('working');
|
|
660
|
+
else if (outcome === 'failed' || outcome === 'cancelled') parts.push(outcome);
|
|
661
|
+
if (toolCount > 0) parts.push(plural(toolCount, 'tool'));
|
|
662
|
+
if (ctx.lanes > 0) parts.push(plural(ctx.lanes, 'agent'));
|
|
663
|
+
if (head.reasoningContent || head.reasoningSummary) parts.push('reasoning');
|
|
664
|
+
// A finished turn whose end was never recorded states no time rather than a wrong one.
|
|
665
|
+
if (timing && (timing.endedAt !== undefined || active)) parts.push(formatBeadTime((timing.endedAt ?? now) - timing.startedAt));
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
turnKey,
|
|
669
|
+
headIndex: unit.headIndex,
|
|
670
|
+
memberIndexes: members,
|
|
671
|
+
folded: input.collapse.get(turnKey) === true && !active,
|
|
672
|
+
headerText: parts.join(' · '),
|
|
673
|
+
rows: [{ kind: 'head' }, ...rows.map((r) => r.row)],
|
|
674
|
+
live: ctx.live || active,
|
|
675
|
+
toolCount,
|
|
676
|
+
agentCount: ctx.lanes,
|
|
677
|
+
beadKeys: ctx.beadKeys,
|
|
678
|
+
notices,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* A WRFC chain owner opened full screen. The owner never takes a turn of its
|
|
684
|
+
* own (it supervises), so its view is built from its chain: each phase agent
|
|
685
|
+
* (engineer, reviewer, fixer, …) branches off the owner's spine as a lane
|
|
686
|
+
* drawn from that agent's own transcript, the way main draws any spawned
|
|
687
|
+
* agent. The header names the chain and counts phases, not tools.
|
|
688
|
+
*/
|
|
689
|
+
export function buildWrfcOwnerModel(input: {
|
|
690
|
+
readonly info: AgentLaneInfo;
|
|
691
|
+
readonly sources: WorkTreeSources;
|
|
692
|
+
readonly collapse: ReadonlyMap<string, boolean>;
|
|
693
|
+
}): TurnModel {
|
|
694
|
+
const { info, sources } = input;
|
|
695
|
+
const now = sources.now?.() ?? Date.now();
|
|
696
|
+
const ctx: BuildContext = { sources, collapse: input.collapse, waiting: sources.waitingCallIds?.() ?? new Set<string>(), now, lanes: 0, live: false, beadKeys: [] };
|
|
697
|
+
const active = info.status === 'running' || info.status === 'pending';
|
|
698
|
+
const scope = `wrfc:${info.id}/`;
|
|
699
|
+
const items: Pending[] = [];
|
|
700
|
+
(info.wrfcPhases ?? []).forEach((phase, k) => {
|
|
701
|
+
// The phase is an agent the chain spawned: the same shape main's spawn
|
|
702
|
+
// call has, so its lane, beads and merge row come from the one path.
|
|
703
|
+
const call: ToolCall = { id: `${scope}${phase.agentId}`, name: 'agent', arguments: { mode: 'spawn', task: phase.task } };
|
|
704
|
+
items.push(...callRows(ctx, SPINE, scope, call, -1, k, { content: JSON.stringify({ agentId: phase.agentId }), index: -1 }, active, 0, [info.id]));
|
|
705
|
+
});
|
|
706
|
+
const rows = inheritTimes(resolvePending(items), info.startedAt);
|
|
707
|
+
const phases = info.wrfcPhases?.length ?? 0;
|
|
708
|
+
const parts = ['WRFC chain', plural(phases, 'phase')];
|
|
709
|
+
if (active) parts.push('working');
|
|
710
|
+
else if (info.wrfcPassed === true) parts.push('passed');
|
|
711
|
+
else if (info.wrfcPassed === false || info.status === 'failed') parts.push('failed');
|
|
712
|
+
if (info.startedAt !== undefined) parts.push(formatBeadTime((info.completedAt ?? now) - info.startedAt));
|
|
713
|
+
return {
|
|
714
|
+
turnKey: `${scope}turn`,
|
|
715
|
+
headIndex: -1,
|
|
716
|
+
memberIndexes: [],
|
|
717
|
+
folded: false,
|
|
718
|
+
headerText: parts.join(' · '),
|
|
719
|
+
rows: [{ kind: 'head' }, ...rows.map((r) => r.row)],
|
|
720
|
+
live: ctx.live || active,
|
|
721
|
+
toolCount: 0,
|
|
722
|
+
agentCount: ctx.lanes,
|
|
723
|
+
beadKeys: ctx.beadKeys,
|
|
724
|
+
notices: [],
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The timing record of the turn answering the user message at `userIndex`,
|
|
730
|
+
* when it is that turn's: a record made for a different message (the
|
|
731
|
+
* transcript was compacted or replaced since) is ignored.
|
|
732
|
+
*/
|
|
733
|
+
function turnTimingFor(sources: WorkTreeSources, userIndex: number, userMessage: Message | undefined): TurnTiming | undefined {
|
|
734
|
+
if (userIndex < 0) return undefined;
|
|
735
|
+
const timing = sources.turnTiming?.(userIndex);
|
|
736
|
+
if (!timing) return undefined;
|
|
737
|
+
return timing.fingerprint === undefined || timing.fingerprint === userMessageFingerprint(userMessage) ? timing : undefined;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* How a finished turn ended: cancelled when its user message says so, else
|
|
742
|
+
* the live turn-end record, else the session's saved record for this message.
|
|
743
|
+
*/
|
|
744
|
+
function turnOutcomeFor(sources: WorkTreeSources, userIndex: number, userMessage: Message | undefined, timing: TurnTiming | undefined): TurnOutcome | undefined {
|
|
745
|
+
if (userMessage?.role === 'user' && userMessage.cancelled) return 'cancelled';
|
|
746
|
+
if (timing?.outcome) return timing.outcome;
|
|
747
|
+
if (userIndex < 0) return undefined;
|
|
748
|
+
const saved = sources.turnOutcome?.(userIndex);
|
|
749
|
+
return saved && saved.fingerprint === userMessageFingerprint(userMessage) ? saved.outcome : undefined;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Everything a drawn turn depends on, flattened for an element-by-element
|
|
754
|
+
* comparison (the line cache reuses a turn's lines while this is unchanged).
|
|
755
|
+
* Strings compare by value; a bead's result only counts while its body is open.
|
|
756
|
+
*/
|
|
757
|
+
export function turnSignature(model: TurnModel): Array<string | number | boolean | undefined> {
|
|
758
|
+
const parts: Array<string | number | boolean | undefined> = [model.turnKey, model.folded, model.headerText, model.live, model.rows.length];
|
|
759
|
+
for (const notice of model.notices) parts.push('notice', notice.messageIndex, notice.content);
|
|
760
|
+
const bead = (b: BeadModel): void => {
|
|
761
|
+
parts.push(b.id, b.lane, b.status, b.name, b.arg, b.summary?.text, b.summary?.tone, b.time, b.open, b.expanded, b.answers, b.body?.kind);
|
|
762
|
+
if (b.open) parts.push(b.result, b.body?.kind === 'diff' ? b.body.diff : undefined);
|
|
763
|
+
};
|
|
764
|
+
const lane = (l: LaneModel): void => {
|
|
765
|
+
parts.push(l.id, l.key, l.parent, l.name, l.arg, l.outcome, l.mergeText, l.foldSummary, l.time, l.folded, l.truncated);
|
|
766
|
+
};
|
|
767
|
+
for (const row of model.rows) {
|
|
768
|
+
parts.push(row.kind);
|
|
769
|
+
switch (row.kind) {
|
|
770
|
+
case 'prose': parts.push(row.messageIndex, row.role, row.content); break;
|
|
771
|
+
case 'answer': parts.push(row.messageIndex, row.content); break;
|
|
772
|
+
case 'bead': bead(row.bead); break;
|
|
773
|
+
case 'spawn':
|
|
774
|
+
case 'folded': lane(row.lane); bead(row.bead); break;
|
|
775
|
+
case 'merge': lane(row.lane); break;
|
|
776
|
+
default: break;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return parts;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Whether a turn draws an opened diff (its ◈ summary may still be computing). */
|
|
783
|
+
export function turnHasOpenDiff(model: TurnModel): boolean {
|
|
784
|
+
return model.rows.some((r) => r.kind === 'bead' && r.bead.open && r.bead.body?.kind === 'diff');
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* A result whose call is outside the rendered slice (the display was cleared
|
|
789
|
+
* mid-turn): one bead of its own, named from the result, with the same body,
|
|
790
|
+
* block and open state as any other bead.
|
|
791
|
+
*/
|
|
792
|
+
export function orphanResultBead(message: ToolMessage, index: number, collapse: ReadonlyMap<string, boolean>): BeadModel {
|
|
793
|
+
const ctx: BuildContext = { sources: {}, collapse, waiting: new Set(), now: 0, lanes: 0, live: false, beadKeys: [] };
|
|
794
|
+
// A result stored without its call and without a tool name is named from
|
|
795
|
+
// its own shape when that is unambiguous, otherwise "result", never "tool".
|
|
796
|
+
const call: ToolCall = { id: message.callId, name: message.toolName ?? inferResultToolName(message.content), arguments: {} };
|
|
797
|
+
const { bead } = makeBead(ctx, SPINE, 'o', call, index, 0, { content: message.content, index }, false);
|
|
798
|
+
return bead;
|
|
799
|
+
}
|