@pellux/goodvibes-tui 2.0.21 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -1
- package/README.md +30 -29
- package/docs/README.md +8 -5
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +28 -19
- 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-reference.ts +2 -2
- 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/live-verifier.ts +0 -10
- package/src/version.ts +1 -1
- 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/verification/verification-ledger.ts +0 -707
- /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,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lane-graph/bead.ts, what one tool call's bead says: its status mark, its
|
|
3
|
+
* name and key argument, the result summary on its row, and what opening it
|
|
4
|
+
* shows.
|
|
5
|
+
*
|
|
6
|
+
* The bead IS the status mark. There is no separate result row and no
|
|
7
|
+
* separate live row: a running call spins on its own bead, a failed call's
|
|
8
|
+
* bead is the ✕, and the result's one-line summary sits on the same row,
|
|
9
|
+
* right-aligned before the time.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is pure data (no Lines, no theme): paint.ts draws it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
|
|
15
|
+
import { stripDangerousAnsi } from '../ansi-sanitize.ts';
|
|
16
|
+
|
|
17
|
+
/** Text safe to put in a cell: no escape sequences at all, no control characters. */
|
|
18
|
+
export function cellText(text: string): string {
|
|
19
|
+
return stripDangerousAnsi(text)
|
|
20
|
+
// CSI (colors included), OSC, and two-byte escapes.
|
|
21
|
+
.replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g, '')
|
|
22
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* ok ✓ done
|
|
27
|
+
* warn ! done, with something to look at (review findings, an expected non-zero exit, a partial edit)
|
|
28
|
+
* err ✕ failed
|
|
29
|
+
* run ◐ running (spins in place, the time counts up)
|
|
30
|
+
* wait ● waiting on the user (a permission prompt is open for it)
|
|
31
|
+
* cancel ○ cancelled (the row's text is struck through)
|
|
32
|
+
* bg ▶ a process it started keeps running in the background
|
|
33
|
+
*/
|
|
34
|
+
export type BeadStatus = 'ok' | 'warn' | 'err' | 'run' | 'wait' | 'cancel' | 'bg';
|
|
35
|
+
|
|
36
|
+
/** How a settled call's result reads (conversation-render-context.ts outcomeOfToolContent). */
|
|
37
|
+
export type CallOutcome = 'ok' | 'error' | 'cancelled';
|
|
38
|
+
|
|
39
|
+
export interface BeadStatusInput {
|
|
40
|
+
/** Undefined while no result has arrived. */
|
|
41
|
+
readonly outcome: CallOutcome | undefined;
|
|
42
|
+
/** The result text, when settled. */
|
|
43
|
+
readonly content?: string | undefined;
|
|
44
|
+
/** A permission prompt is open for this call. */
|
|
45
|
+
readonly waiting: boolean;
|
|
46
|
+
/** The call's owner (the turn, or the agent whose lane it is on) is still working. */
|
|
47
|
+
readonly ownerActive: boolean;
|
|
48
|
+
/** The call asked for a background process. */
|
|
49
|
+
readonly background: boolean;
|
|
50
|
+
/** The settled result carries something to look at (see needsAttention). */
|
|
51
|
+
readonly attention: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The status a bead shows. An unsettled call whose owner stopped working never
|
|
56
|
+
* got a result, so it reads as cancelled rather than spinning forever.
|
|
57
|
+
*/
|
|
58
|
+
export function beadStatus(input: BeadStatusInput): BeadStatus {
|
|
59
|
+
if (input.outcome === undefined) {
|
|
60
|
+
if (input.waiting) return 'wait';
|
|
61
|
+
return input.ownerActive ? 'run' : 'cancel';
|
|
62
|
+
}
|
|
63
|
+
if (input.outcome === 'error') return 'err';
|
|
64
|
+
if (input.outcome === 'cancelled') return 'cancel';
|
|
65
|
+
if (input.background) return 'bg';
|
|
66
|
+
return input.attention ? 'warn' : 'ok';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract the most meaningful argument from a tool call for display.
|
|
71
|
+
*/
|
|
72
|
+
function extractKeyArg(toolCall: ToolCall): string {
|
|
73
|
+
const args = toolCall.arguments;
|
|
74
|
+
// Path-based tools
|
|
75
|
+
if (typeof args.path === 'string') return args.path;
|
|
76
|
+
if (typeof args.file === 'string') return args.file;
|
|
77
|
+
if (typeof args.query === 'string') return args.query;
|
|
78
|
+
if (typeof args.title === 'string') return args.title;
|
|
79
|
+
// Edit: the first edit's path
|
|
80
|
+
if (Array.isArray(args.edits) && args.edits.length > 0) {
|
|
81
|
+
const first = args.edits[0];
|
|
82
|
+
if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).path === 'string') {
|
|
83
|
+
const paths = new Set(args.edits.map((e) => (e && typeof e === 'object' ? (e as Record<string, unknown>).path : undefined)).filter((p): p is string => typeof p === 'string'));
|
|
84
|
+
return paths.size > 1 ? `${(first as Record<string, unknown>).path as string} +${paths.size - 1} more` : (first as Record<string, unknown>).path as string;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Array-based (read/write)
|
|
88
|
+
if (Array.isArray(args.files) && args.files.length > 0) {
|
|
89
|
+
const first = args.files[0];
|
|
90
|
+
if (typeof first === 'string') return first;
|
|
91
|
+
if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).path === 'string')
|
|
92
|
+
return (first as Record<string, unknown>).path as string;
|
|
93
|
+
}
|
|
94
|
+
// Exec
|
|
95
|
+
if (typeof args.command === 'string') return args.command;
|
|
96
|
+
if (typeof args.cmd === 'string') return args.cmd;
|
|
97
|
+
if (Array.isArray(args.commands) && args.commands.length > 0) {
|
|
98
|
+
const first = args.commands[0];
|
|
99
|
+
if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).cmd === 'string')
|
|
100
|
+
return (first as Record<string, unknown>).cmd as string;
|
|
101
|
+
}
|
|
102
|
+
// Find/grep
|
|
103
|
+
if (typeof args.pattern === 'string') return args.pattern;
|
|
104
|
+
if (Array.isArray(args.queries) && args.queries.length > 0) {
|
|
105
|
+
const first = args.queries[0];
|
|
106
|
+
if (first && typeof first === 'object') {
|
|
107
|
+
const firstRecord = first as Record<string, unknown>;
|
|
108
|
+
if (typeof firstRecord.query === 'string') return firstRecord.query;
|
|
109
|
+
if (typeof firstRecord.pattern === 'string') return firstRecord.pattern;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Fetch
|
|
113
|
+
if (Array.isArray(args.urls) && args.urls.length > 0) {
|
|
114
|
+
const first = args.urls[0];
|
|
115
|
+
if (first && typeof first === 'object' && typeof (first as Record<string, unknown>).url === 'string')
|
|
116
|
+
return (first as Record<string, unknown>).url as string;
|
|
117
|
+
}
|
|
118
|
+
// Agent
|
|
119
|
+
if (typeof args.task === 'string') return args.task.slice(0, 40);
|
|
120
|
+
if (typeof args.mode === 'string') return args.mode;
|
|
121
|
+
// Fallback: first string value
|
|
122
|
+
for (const val of Object.values(args)) {
|
|
123
|
+
if (typeof val === 'string' && val.length > 0) return val.slice(0, 40);
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Parsing helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
type Json = Record<string, unknown>;
|
|
133
|
+
|
|
134
|
+
function parseObject(content: string | undefined): Json | null {
|
|
135
|
+
if (!content) return null;
|
|
136
|
+
const trimmed = content.trimStart();
|
|
137
|
+
if (!trimmed.startsWith('{')) return null;
|
|
138
|
+
try {
|
|
139
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
140
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Json : null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function num(value: unknown): number | undefined {
|
|
147
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function str(value: unknown): string | undefined {
|
|
151
|
+
return typeof value === 'string' ? value : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The tool family: an MCP tool's last name segment, lowercased. */
|
|
155
|
+
export function toolFamily(name: string): string {
|
|
156
|
+
return (name.includes('__') ? name.split('__').pop()! : name).toLowerCase();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The name drawn on the bead row. */
|
|
160
|
+
export function beadName(call: Pick<ToolCall, 'name'>): string {
|
|
161
|
+
return cellText(call.name.includes('__') ? call.name.split('__').pop()! : call.name);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The key argument drawn after the name (the path, the command, the pattern). */
|
|
165
|
+
export function beadArgument(call: ToolCall): string {
|
|
166
|
+
return cellText(extractKeyArg(call)).replace(/\s+/g, ' ').trim();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** True when the call asked for a process to keep running in the background. */
|
|
170
|
+
export function isBackgroundCall(call: ToolCall): boolean {
|
|
171
|
+
const args = call.arguments;
|
|
172
|
+
if (args.background === true) return true;
|
|
173
|
+
if (Array.isArray(args.commands)) {
|
|
174
|
+
return args.commands.length > 0 && args.commands.every((c) => c !== null && typeof c === 'object' && (c as Json).background === true);
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The error text of a failed result (after the "Error: " prefix). */
|
|
180
|
+
function errorText(content: string): string {
|
|
181
|
+
return content.replace(/^Error:\s*/, '');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Exec results
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
interface ExecCommandResult {
|
|
189
|
+
readonly cmd?: string;
|
|
190
|
+
readonly exitCode: number | null | undefined;
|
|
191
|
+
readonly stdout: string;
|
|
192
|
+
readonly stderr: string;
|
|
193
|
+
readonly timedOut: boolean;
|
|
194
|
+
readonly skipped: boolean;
|
|
195
|
+
readonly droppedLines: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function execCommands(obj: Json): ExecCommandResult[] {
|
|
199
|
+
const one = (o: Json): ExecCommandResult => ({
|
|
200
|
+
cmd: str(o.cmd),
|
|
201
|
+
exitCode: o.exit_code === null ? null : num(o.exit_code),
|
|
202
|
+
stdout: str(o.stdout) ?? '',
|
|
203
|
+
stderr: str(o.stderr) ?? '',
|
|
204
|
+
timedOut: o.timed_out === true,
|
|
205
|
+
skipped: o.skipped === true,
|
|
206
|
+
droppedLines: (num(o.stdout_dropped_lines) ?? 0) + (num(o.stderr_dropped_lines) ?? 0),
|
|
207
|
+
});
|
|
208
|
+
if (Array.isArray(obj.commands)) {
|
|
209
|
+
return obj.commands.filter((c): c is Json => c !== null && typeof c === 'object').map(one);
|
|
210
|
+
}
|
|
211
|
+
if ('exit_code' in obj || 'stdout' in obj || 'stderr' in obj) return [one(obj)];
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** "4 pass", "2 fail": test-runner totals read off the output, when it printed them. */
|
|
216
|
+
function testTotals(text: string): string | undefined {
|
|
217
|
+
const pass = /(\d+)\s+pass(?:ed|ing)?\b/i.exec(text);
|
|
218
|
+
const fail = /(\d+)\s+fail(?:ed|ing|ures?)?\b/i.exec(text);
|
|
219
|
+
if (!pass && !fail) return undefined;
|
|
220
|
+
const failCount = fail ? Number(fail[1]) : 0;
|
|
221
|
+
if (failCount > 0) return `${failCount} failing`;
|
|
222
|
+
return pass ? `${pass[1]} pass` : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// A result whose call is gone
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/** The name a result with no call gets when its shape does not say which tool made it. */
|
|
230
|
+
const UNNAMED_RESULT = 'result';
|
|
231
|
+
|
|
232
|
+
/** ` 12 | text`: the read tool's line-numbered file text (formatContent's standard format). */
|
|
233
|
+
const NUMBERED_LINE = /^ *\d+ \| ?/;
|
|
234
|
+
|
|
235
|
+
/** True when every non-blank line is line-numbered file text. */
|
|
236
|
+
function isNumberedFileText(content: string): boolean {
|
|
237
|
+
const lines = textLines(content).filter((l) => l.trim() !== '');
|
|
238
|
+
return lines.length > 0 && lines.every((l) => NUMBERED_LINE.test(l));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The tool behind a stored result whose call was lost (results kept without
|
|
243
|
+
* their call before compaction kept each call whole), named only when the
|
|
244
|
+
* result's own shape leaves no doubt: a unified diff or the edit tool's
|
|
245
|
+
* applied/failed report is an edit, command output or test totals are exec,
|
|
246
|
+
* line-numbered file text or a read summary is a read. Anything else is
|
|
247
|
+
* UNNAMED_RESULT, never the bare word "tool".
|
|
248
|
+
*/
|
|
249
|
+
export function inferResultToolName(content: string): string {
|
|
250
|
+
const obj = parseObject(content);
|
|
251
|
+
if (obj) {
|
|
252
|
+
if (execCommands(obj).length > 0) return 'exec';
|
|
253
|
+
const summary = obj.summary && typeof obj.summary === 'object' ? obj.summary as Json : null;
|
|
254
|
+
if (summary && num(summary.files_read) !== undefined && num(summary.total_lines) !== undefined) return 'read';
|
|
255
|
+
if (num(obj.applied) !== undefined && num(obj.failed) !== undefined) return 'edit';
|
|
256
|
+
return UNNAMED_RESULT;
|
|
257
|
+
}
|
|
258
|
+
if (/^@@ /m.test(content) && /^(\+\+\+|---) /m.test(content)) return 'edit';
|
|
259
|
+
if (/^Edits applied: \d+, failed: \d+/m.test(content)) return 'edit';
|
|
260
|
+
if (testTotals(content)) return 'exec';
|
|
261
|
+
if (isNumberedFileText(content)) return 'read';
|
|
262
|
+
return UNNAMED_RESULT;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Attention
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A result that settled without failing but still has something the user
|
|
271
|
+
* should look at: a command that exited non-zero, an edit with failed items,
|
|
272
|
+
* or a test run with failures.
|
|
273
|
+
*/
|
|
274
|
+
export function needsAttention(call: ToolCall, content: string): boolean {
|
|
275
|
+
const family = toolFamily(call.name);
|
|
276
|
+
const obj = parseObject(content);
|
|
277
|
+
if (family === 'exec' && obj) {
|
|
278
|
+
return execCommands(obj).some((c) => (c.exitCode !== undefined && c.exitCode !== null && c.exitCode !== 0) || c.timedOut);
|
|
279
|
+
}
|
|
280
|
+
if (family === 'edit') {
|
|
281
|
+
if (obj) return (num(obj.failed) ?? 0) > 0;
|
|
282
|
+
const m = /Edits applied: \d+, failed: (\d+)/.exec(content);
|
|
283
|
+
return m !== null && Number(m[1]) > 0;
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Summaries
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
export type SummaryTone = 'faint' | 'good' | 'warn' | 'bad';
|
|
293
|
+
|
|
294
|
+
export interface BeadSummary {
|
|
295
|
+
readonly text: string;
|
|
296
|
+
readonly tone: SummaryTone;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function plural(n: number, word: string): string {
|
|
300
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function formatBytes(n: number): string {
|
|
304
|
+
if (n < 1024) return `${n} B`;
|
|
305
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
306
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Added and removed line counts of a unified diff. */
|
|
310
|
+
function diffCounts(diff: string): { added: number; removed: number } {
|
|
311
|
+
let added = 0;
|
|
312
|
+
let removed = 0;
|
|
313
|
+
for (const line of diff.split('\n')) {
|
|
314
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
315
|
+
if (line.startsWith('+')) added++;
|
|
316
|
+
else if (line.startsWith('-')) removed++;
|
|
317
|
+
}
|
|
318
|
+
return { added, removed };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function countSummary(counts: { added: number; removed: number }): string {
|
|
322
|
+
const parts: string[] = [];
|
|
323
|
+
if (counts.added > 0 || counts.removed === 0) parts.push(`+${counts.added}`);
|
|
324
|
+
if (counts.removed > 0) parts.push(`−${counts.removed}`);
|
|
325
|
+
return parts.join(' ');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Match entries (file, line, text) found anywhere in a find result. */
|
|
329
|
+
function findEntries(value: unknown, out: Array<{ file: string; line?: number; text?: string }>, depth = 0): void {
|
|
330
|
+
if (depth > 4 || value === null || typeof value !== 'object') return;
|
|
331
|
+
if (Array.isArray(value)) {
|
|
332
|
+
for (const item of value) {
|
|
333
|
+
if (typeof item === 'string') out.push({ file: item });
|
|
334
|
+
else if (item && typeof item === 'object') {
|
|
335
|
+
const rec = item as Json;
|
|
336
|
+
const file = str(rec.file) ?? str(rec.path);
|
|
337
|
+
if (file !== undefined) out.push({ file, line: num(rec.line), text: str(rec.text) ?? str(rec.name) });
|
|
338
|
+
else findEntries(item, out, depth + 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
for (const [key, child] of Object.entries(value as Json)) {
|
|
344
|
+
if (key === 'relationships' || key === 'warnings' || key.startsWith('_')) continue;
|
|
345
|
+
findEntries(child, out, depth + 1);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The one-line result summary on a bead's row, or null when the row should
|
|
351
|
+
* carry none (a running call, or a shape with nothing honest to say).
|
|
352
|
+
*/
|
|
353
|
+
export function beadSummary(call: ToolCall, status: BeadStatus, content: string | undefined): BeadSummary | null {
|
|
354
|
+
const summary = rawSummary(call, status, content);
|
|
355
|
+
return summary ? { ...summary, text: cellText(summary.text) } : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function rawSummary(call: ToolCall, status: BeadStatus, content: string | undefined): BeadSummary | null {
|
|
359
|
+
if (status === 'run') return null;
|
|
360
|
+
if (status === 'wait') return { text: 'waiting for you', tone: 'warn' };
|
|
361
|
+
if (status === 'cancel') return { text: 'cancelled', tone: 'faint' };
|
|
362
|
+
if (content === undefined) return null;
|
|
363
|
+
if (status === 'err') {
|
|
364
|
+
const totals = testTotals(content);
|
|
365
|
+
if (totals) return { text: totals, tone: 'bad' };
|
|
366
|
+
const first = errorText(content).split('\n')[0] ?? '';
|
|
367
|
+
const execExit = /exit(?: code)? (\d+)/i.exec(first);
|
|
368
|
+
return { text: execExit ? `exit ${execExit[1]}` : first, tone: 'bad' };
|
|
369
|
+
}
|
|
370
|
+
const family = toolFamily(call.name);
|
|
371
|
+
const obj = parseObject(content);
|
|
372
|
+
|
|
373
|
+
if (status === 'bg') {
|
|
374
|
+
const pid = obj ? num(obj.pid) ?? num(obj.process_id) : undefined;
|
|
375
|
+
const port = /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]|\[::1\]):(\d{2,5})\b/.exec(content)?.[1];
|
|
376
|
+
return { text: ['in background', port ? `:${port}` : pid !== undefined ? `pid ${pid}` : undefined].filter(Boolean).join(' · '), tone: 'good' };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
switch (family) {
|
|
380
|
+
case 'read': {
|
|
381
|
+
if (!obj) return textReadSummary(call, content);
|
|
382
|
+
const summary = (obj.summary && typeof obj.summary === 'object') ? obj.summary as Json : obj;
|
|
383
|
+
const files = num(summary.files_read);
|
|
384
|
+
const lines = num(summary.total_lines);
|
|
385
|
+
if (files !== undefined && files > 1) return { text: `${plural(files, 'file')}${lines !== undefined ? ` · ${plural(lines, 'line')}` : ''}`, tone: 'faint' };
|
|
386
|
+
if (lines !== undefined) return { text: plural(lines, 'line'), tone: 'faint' };
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case 'find': {
|
|
390
|
+
if (!obj) return textFindSummary(content);
|
|
391
|
+
const entries: Array<{ file: string }> = [];
|
|
392
|
+
findEntries(obj, entries);
|
|
393
|
+
const count = num(obj.count) ?? entries.length;
|
|
394
|
+
const files = new Set(entries.map((e) => e.file)).size;
|
|
395
|
+
if (count === 0) return { text: 'no matches', tone: 'faint' };
|
|
396
|
+
return { text: files > 1 ? `${plural(count, 'match').replace('matchs', 'matches')} in ${files} files` : plural(count, 'match').replace('matchs', 'matches'), tone: 'faint' };
|
|
397
|
+
}
|
|
398
|
+
case 'edit': {
|
|
399
|
+
const diff = editDiff(call, content);
|
|
400
|
+
const failed = obj ? num(obj.failed) ?? 0 : Number(/failed: (\d+)/.exec(content)?.[1] ?? 0);
|
|
401
|
+
if (failed > 0) return { text: `${failed} failed`, tone: 'warn' };
|
|
402
|
+
if (diff) return { text: countSummary(diffCounts(diff.text)), tone: 'good' };
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case 'write': {
|
|
406
|
+
const written = writtenFiles(call);
|
|
407
|
+
if (written.length > 0) {
|
|
408
|
+
const lines = written.reduce((n, f) => n + f.content.split('\n').length, 0);
|
|
409
|
+
return { text: written.length > 1 ? `${plural(written.length, 'file')} · +${lines}` : `+${lines}`, tone: 'good' };
|
|
410
|
+
}
|
|
411
|
+
if (obj) {
|
|
412
|
+
const bytes = num(obj.bytes_written);
|
|
413
|
+
if (bytes !== undefined) return { text: formatBytes(bytes), tone: 'good' };
|
|
414
|
+
}
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
case 'exec': {
|
|
418
|
+
if (!obj) return textExecSummary(content);
|
|
419
|
+
const commands = execCommands(obj);
|
|
420
|
+
if (commands.length === 0) break;
|
|
421
|
+
if (commands.length > 1) {
|
|
422
|
+
const failing = commands.filter((c) => c.exitCode !== 0 && !c.skipped).length;
|
|
423
|
+
return failing > 0
|
|
424
|
+
? { text: `${commands.length} commands · ${failing} failed`, tone: 'warn' }
|
|
425
|
+
: { text: `${commands.length} commands · exit 0`, tone: 'faint' };
|
|
426
|
+
}
|
|
427
|
+
const c = commands[0]!;
|
|
428
|
+
if (c.timedOut) return { text: 'timed out', tone: 'warn' };
|
|
429
|
+
const totals = testTotals(`${c.stdout}\n${c.stderr}`);
|
|
430
|
+
const exit = c.exitCode === null || c.exitCode === undefined ? 'no exit code' : `exit ${c.exitCode}`;
|
|
431
|
+
return { text: totals ? `${totals} · ${exit}` : exit, tone: c.exitCode === 0 || c.exitCode === undefined ? 'faint' : 'warn' };
|
|
432
|
+
}
|
|
433
|
+
case 'fetch': {
|
|
434
|
+
return { text: formatBytes(Buffer.byteLength(content, 'utf8')), tone: 'faint' };
|
|
435
|
+
}
|
|
436
|
+
default:
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
if (obj) return null;
|
|
440
|
+
const shaped = shapedTextSummary(call, content);
|
|
441
|
+
if (shaped) return shaped;
|
|
442
|
+
const first = content.split('\n').find((line) => line.trim().length > 0);
|
|
443
|
+
return first ? { text: first.trim(), tone: 'faint' } : null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A multi-line text result whose shape says what it is, whatever the tool
|
|
448
|
+
* (a result whose call is unknown carries no tool name to go by): a unified
|
|
449
|
+
* diff reads as its line counts, test output as its totals, grep-style lines
|
|
450
|
+
* as their match count. Anything else keeps its first line.
|
|
451
|
+
*/
|
|
452
|
+
function shapedTextSummary(call: ToolCall, content: string): BeadSummary | null {
|
|
453
|
+
const lines = textLines(content);
|
|
454
|
+
if (lines.length < 2) return null;
|
|
455
|
+
const diff = /^@@ /m.test(content) && /^(\+\+\+|---) /m.test(content) ? editDiff(call, content) : null;
|
|
456
|
+
if (diff) return { text: countSummary(diffCounts(diff.text)), tone: 'good' };
|
|
457
|
+
const totals = testTotals(content);
|
|
458
|
+
if (totals) return { text: totals, tone: /failing/.test(totals) ? 'warn' : 'faint' };
|
|
459
|
+
if (lines.every((l) => MATCH_LINE.test(l))) return textFindSummary(content);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Non-empty lines of a text result (trailing blank lines dropped). */
|
|
464
|
+
function textLines(content: string): string[] {
|
|
465
|
+
const trimmed = content.replace(/\s+$/, '');
|
|
466
|
+
return trimmed === '' ? [] : trimmed.split('\n');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The paths a read call asked for, from its arguments. */
|
|
470
|
+
function readPaths(call: ToolCall): string[] {
|
|
471
|
+
const args = call.arguments;
|
|
472
|
+
const out: string[] = [];
|
|
473
|
+
if (Array.isArray(args.files)) {
|
|
474
|
+
for (const f of args.files) {
|
|
475
|
+
if (typeof f === 'string') out.push(f);
|
|
476
|
+
else if (f && typeof f === 'object' && typeof (f as Json).path === 'string') out.push((f as Json).path as string);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (out.length === 0 && typeof args.path === 'string') out.push(args.path);
|
|
480
|
+
if (out.length === 0 && typeof args.file === 'string') out.push(args.file);
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A read whose result is the file text itself (an imported or older session,
|
|
486
|
+
* an MCP read) says how much it read, like a structured read does: never the
|
|
487
|
+
* file's first line.
|
|
488
|
+
*/
|
|
489
|
+
function textReadSummary(call: ToolCall, content: string): BeadSummary {
|
|
490
|
+
const lines = textLines(content).length;
|
|
491
|
+
const files = new Set(readPaths(call)).size;
|
|
492
|
+
if (lines === 0) return { text: 'empty', tone: 'faint' };
|
|
493
|
+
return { text: files > 1 ? `${plural(files, 'file')} · ${plural(lines, 'line')}` : plural(lines, 'line'), tone: 'faint' };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** `path:line: text` (grep style) or `path:line:col: text`. */
|
|
497
|
+
const MATCH_LINE = /^(.+?):(\d+)(?::\d+)?[:\s-]/;
|
|
498
|
+
|
|
499
|
+
/** A find whose result is grep-style text lines: how many matches in how many files, or how many files. */
|
|
500
|
+
function textFindSummary(content: string): BeadSummary {
|
|
501
|
+
const lines = textLines(content).filter((l) => l.trim().length > 0);
|
|
502
|
+
if (lines.length === 0 || /^no (matches|results|files)\b/i.test(lines[0]!.trim())) return { text: 'no matches', tone: 'faint' };
|
|
503
|
+
const matches = lines.map((l) => MATCH_LINE.exec(l)).filter((m): m is RegExpExecArray => m !== null);
|
|
504
|
+
if (matches.length === 0) return { text: plural(lines.length, 'file'), tone: 'faint' };
|
|
505
|
+
const files = new Set(matches.map((m) => m[1])).size;
|
|
506
|
+
const count = plural(matches.length, 'match').replace('matchs', 'matches');
|
|
507
|
+
return { text: files > 1 ? `${count} in ${files} files` : count, tone: 'faint' };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** A command whose result is its bare output: test totals when it printed them, otherwise how much it printed. */
|
|
511
|
+
function textExecSummary(content: string): BeadSummary {
|
|
512
|
+
const totals = testTotals(content);
|
|
513
|
+
if (totals) return { text: totals, tone: /failing/.test(totals) ? 'warn' : 'faint' };
|
|
514
|
+
const lines = textLines(content).length;
|
|
515
|
+
return { text: lines === 0 ? 'no output' : `${plural(lines, 'line')} of output`, tone: 'faint' };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// Bodies
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
export type BodyTone = 'text' | 'muted' | 'faint' | 'good' | 'bad' | 'warn';
|
|
523
|
+
|
|
524
|
+
export type BeadBody =
|
|
525
|
+
/** A unified diff. `numbered` is false when line numbers are unknown (built from the call's own find/replace). */
|
|
526
|
+
| { readonly kind: 'diff'; readonly diff: string; readonly numbered: boolean; readonly path: string | undefined }
|
|
527
|
+
/** Terminal output, one entry per line. */
|
|
528
|
+
| { readonly kind: 'output'; readonly lines: ReadonlyArray<{ readonly text: string; readonly tone: BodyTone }>; readonly footer?: string }
|
|
529
|
+
/** A short list (files read, matches found). */
|
|
530
|
+
| { readonly kind: 'list'; readonly items: ReadonlyArray<{ readonly text: string; readonly detail?: string }> }
|
|
531
|
+
/**
|
|
532
|
+
* The text a read returned, one section per file: its path and line count,
|
|
533
|
+
* then the file's lines exactly as read (indentation kept, the read tool's
|
|
534
|
+
* line-number prefix removed). `path` is undefined when the call named no
|
|
535
|
+
* path (a single text result).
|
|
536
|
+
*/
|
|
537
|
+
| { readonly kind: 'read'; readonly files: ReadonlyArray<ReadBodyFile> }
|
|
538
|
+
/** A failure, wrapped in full. */
|
|
539
|
+
| { readonly kind: 'error'; readonly text: string }
|
|
540
|
+
/** Plain text lines (a result that is text, or a flattened object). */
|
|
541
|
+
| { readonly kind: 'text'; readonly lines: readonly string[] };
|
|
542
|
+
|
|
543
|
+
/** One file of a read body. */
|
|
544
|
+
export interface ReadBodyFile {
|
|
545
|
+
readonly path: string | undefined;
|
|
546
|
+
/** Lines the file has in all (the read's own count), which may exceed `lines` for a ranged read. */
|
|
547
|
+
readonly lineCount: number;
|
|
548
|
+
readonly lines: readonly string[];
|
|
549
|
+
/** Why the file could not be read, instead of lines. */
|
|
550
|
+
readonly error?: string;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** A file's text as the model read it: the `NNNNN | ` prefix removed when every line carries one. */
|
|
554
|
+
function readText(content: string): string[] {
|
|
555
|
+
const lines = content.replace(/\n+$/, '').split('\n');
|
|
556
|
+
const numbered = lines.filter((l) => l.trim() !== '').every((l) => NUMBERED_LINE.test(l));
|
|
557
|
+
return numbered ? lines.map((l) => l.replace(NUMBERED_LINE, '')) : lines;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The files a write call wrote, with their content. */
|
|
561
|
+
function writtenFiles(call: ToolCall): Array<{ path: string; content: string }> {
|
|
562
|
+
const args = call.arguments;
|
|
563
|
+
const out: Array<{ path: string; content: string }> = [];
|
|
564
|
+
const push = (rec: Json): void => {
|
|
565
|
+
const path = str(rec.path) ?? str(rec.file);
|
|
566
|
+
const content = str(rec.content);
|
|
567
|
+
if (path !== undefined && content !== undefined) out.push({ path, content });
|
|
568
|
+
};
|
|
569
|
+
if (Array.isArray(args.files)) for (const f of args.files) if (f && typeof f === 'object') push(f as Json);
|
|
570
|
+
if (out.length === 0) push(args);
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The diff an edit made: the unified diff the edit tool printed when it ran
|
|
576
|
+
* with a diff output format, otherwise one built from the call's own
|
|
577
|
+
* find/replace pairs (then line numbers are unknown and the body shows none).
|
|
578
|
+
*/
|
|
579
|
+
function editDiff(call: ToolCall, content: string | undefined): { text: string; numbered: boolean; path: string | undefined } | null {
|
|
580
|
+
if (content && /^@@ /m.test(content) && /^(\+\+\+|---) /m.test(content)) {
|
|
581
|
+
// The edit tool prints its own "--- path (N replacement(s)) ---" banners around each
|
|
582
|
+
// file's diff: start at the real diff header, and drop any banner lines inside it.
|
|
583
|
+
let start = content.search(/^diff --git /m);
|
|
584
|
+
if (start < 0) start = content.search(/^--- \S+\n\+\+\+ /m);
|
|
585
|
+
const text = (start >= 0 ? content.slice(start) : content)
|
|
586
|
+
.split('\n')
|
|
587
|
+
.filter((line) => !/^--- .* ---$/.test(line) && !/^\s*WARN: /.test(line) && !/^\[diff truncated/.test(line))
|
|
588
|
+
.join('\n');
|
|
589
|
+
const path = /^\+\+\+ (?:b\/)?(.+)$/m.exec(text)?.[1];
|
|
590
|
+
return { text, numbered: true, path };
|
|
591
|
+
}
|
|
592
|
+
const edits = Array.isArray(call.arguments.edits) ? call.arguments.edits.filter((e): e is Json => e !== null && typeof e === 'object') : [];
|
|
593
|
+
const usable = edits.filter((e) => typeof e.find === 'string' && typeof e.replace === 'string');
|
|
594
|
+
if (usable.length === 0) return null;
|
|
595
|
+
const byPath = new Map<string, Json[]>();
|
|
596
|
+
for (const edit of usable) {
|
|
597
|
+
const path = str(edit.path) ?? 'file';
|
|
598
|
+
byPath.set(path, [...(byPath.get(path) ?? []), edit]);
|
|
599
|
+
}
|
|
600
|
+
const chunks: string[] = [];
|
|
601
|
+
for (const [path, list] of byPath) {
|
|
602
|
+
chunks.push(`diff --git a/${path} b/${path}`, `--- a/${path}`, `+++ b/${path}`);
|
|
603
|
+
for (const edit of list) {
|
|
604
|
+
const find = (edit.find as string).split('\n');
|
|
605
|
+
const replace = (edit.replace as string).split('\n');
|
|
606
|
+
// Shared leading and trailing lines are context, not change.
|
|
607
|
+
let head = 0;
|
|
608
|
+
while (head < find.length && head < replace.length && find[head] === replace[head]) head++;
|
|
609
|
+
let tail = 0;
|
|
610
|
+
while (tail < find.length - head && tail < replace.length - head && find[find.length - 1 - tail] === replace[replace.length - 1 - tail]) tail++;
|
|
611
|
+
const near = num((edit.hints as Json | undefined)?.near_line) ?? 1;
|
|
612
|
+
chunks.push(`@@ -${near},${find.length} +${near},${replace.length} @@`);
|
|
613
|
+
for (const line of find.slice(0, head)) chunks.push(` ${line}`);
|
|
614
|
+
for (const line of find.slice(head, find.length - tail)) chunks.push(`-${line}`);
|
|
615
|
+
for (const line of replace.slice(head, replace.length - tail)) chunks.push(`+${line}`);
|
|
616
|
+
for (const line of find.slice(find.length - tail)) chunks.push(` ${line}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { text: `${chunks.join('\n')}\n`, numbered: false, path: byPath.size === 1 ? [...byPath.keys()][0] : undefined };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function flattenObject(obj: Json, prefix = '', out: string[] = [], depth = 0): string[] {
|
|
623
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
624
|
+
const label = prefix ? `${prefix}.${key}` : key;
|
|
625
|
+
if (value === null || value === undefined) continue;
|
|
626
|
+
if (typeof value === 'string') {
|
|
627
|
+
const lines = value.split('\n');
|
|
628
|
+
out.push(`${label} ${lines[0] ?? ''}`);
|
|
629
|
+
for (const line of lines.slice(1)) out.push(` ${line}`);
|
|
630
|
+
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
|
631
|
+
out.push(`${label} ${String(value)}`);
|
|
632
|
+
} else if (Array.isArray(value)) {
|
|
633
|
+
const scalars = value.filter((v) => typeof v === 'string' || typeof v === 'number');
|
|
634
|
+
if (scalars.length === value.length && value.length <= 8) out.push(`${label} ${scalars.join(', ')}`);
|
|
635
|
+
else out.push(`${label} ${plural(value.length, 'item')}`);
|
|
636
|
+
} else if (typeof value === 'object' && depth < 2) {
|
|
637
|
+
flattenObject(value as Json, label, out, depth + 1);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* What opening a bead shows, or null when there is nothing inside (then the
|
|
645
|
+
* row carries no ▸).
|
|
646
|
+
*/
|
|
647
|
+
export function beadBody(call: ToolCall, status: BeadStatus, content: string | undefined): BeadBody | null {
|
|
648
|
+
const body = rawBody(call, status, content);
|
|
649
|
+
if (!body) return null;
|
|
650
|
+
switch (body.kind) {
|
|
651
|
+
case 'list': return { kind: 'list', items: body.items.map((i) => ({ text: cellText(i.text), detail: i.detail !== undefined ? cellText(i.detail) : undefined })) };
|
|
652
|
+
case 'read': return {
|
|
653
|
+
kind: 'read',
|
|
654
|
+
files: body.files.map((f) => ({
|
|
655
|
+
...f,
|
|
656
|
+
path: f.path !== undefined ? cellText(f.path) : undefined,
|
|
657
|
+
lines: f.lines.map(cellText),
|
|
658
|
+
...(f.error !== undefined ? { error: cellText(f.error) } : {}),
|
|
659
|
+
})),
|
|
660
|
+
};
|
|
661
|
+
case 'text': return { kind: 'text', lines: body.lines.map(cellText) };
|
|
662
|
+
case 'error': return { kind: 'error', text: body.text.split('\n').map(cellText).join('\n') };
|
|
663
|
+
default: return body;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function rawBody(call: ToolCall, status: BeadStatus, content: string | undefined): BeadBody | null {
|
|
668
|
+
const family = toolFamily(call.name);
|
|
669
|
+
if (family === 'edit') {
|
|
670
|
+
const diff = editDiff(call, content);
|
|
671
|
+
if (status === 'err' && content !== undefined) return { kind: 'error', text: errorText(content) };
|
|
672
|
+
if (diff) return { kind: 'diff', diff: diff.text, numbered: diff.numbered, path: diff.path };
|
|
673
|
+
}
|
|
674
|
+
if (family === 'write') {
|
|
675
|
+
const files = writtenFiles(call);
|
|
676
|
+
if (status === 'err' && content !== undefined) return { kind: 'error', text: errorText(content) };
|
|
677
|
+
if (files.length > 0) {
|
|
678
|
+
const text = files.map((f) => {
|
|
679
|
+
const lines = f.content.replace(/\n$/, '').split('\n');
|
|
680
|
+
return [`diff --git a/${f.path} b/${f.path}`, '--- /dev/null', `+++ b/${f.path}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((l) => `+${l}`)].join('\n');
|
|
681
|
+
}).join('\n');
|
|
682
|
+
return { kind: 'diff', diff: `${text}\n`, numbered: true, path: files.length === 1 ? files[0]!.path : undefined };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (content === undefined) return null;
|
|
686
|
+
if (status === 'err') return { kind: 'error', text: errorText(content) };
|
|
687
|
+
// Any result that is itself a unified diff opens as one.
|
|
688
|
+
if (/^--- \S/m.test(content) && /^\+\+\+ \S/m.test(content) && /^@@ /m.test(content)) {
|
|
689
|
+
const diff = editDiff(call, content);
|
|
690
|
+
if (diff) return { kind: 'diff', diff: diff.text, numbered: diff.numbered, path: diff.path };
|
|
691
|
+
}
|
|
692
|
+
if (status === 'cancel') {
|
|
693
|
+
// Keep the partial output's own indentation: drop only blank lines around it.
|
|
694
|
+
const partial = content.split('\n').slice(1).join('\n').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '');
|
|
695
|
+
return partial ? { kind: 'text', lines: partial.split('\n') } : null;
|
|
696
|
+
}
|
|
697
|
+
const obj = parseObject(content);
|
|
698
|
+
|
|
699
|
+
if (family === 'exec' && obj) {
|
|
700
|
+
const commands = execCommands(obj);
|
|
701
|
+
if (commands.length > 0) {
|
|
702
|
+
const lines: Array<{ text: string; tone: BodyTone }> = [];
|
|
703
|
+
let dropped = 0;
|
|
704
|
+
for (const c of commands) {
|
|
705
|
+
if (commands.length > 1 && c.cmd) lines.push({ text: `$ ${c.cmd}`, tone: 'muted' });
|
|
706
|
+
for (const text of c.stdout.replace(/\n$/, '').split('\n')) if (c.stdout !== '') lines.push({ text: cellText(text), tone: 'text' });
|
|
707
|
+
for (const text of c.stderr.replace(/\n$/, '').split('\n')) if (c.stderr !== '') lines.push({ text: cellText(text), tone: c.exitCode === 0 ? 'muted' : 'bad' });
|
|
708
|
+
dropped += c.droppedLines;
|
|
709
|
+
}
|
|
710
|
+
const exits = commands.map((c) => (c.skipped ? 'skipped' : c.timedOut ? 'timed out' : c.exitCode === null || c.exitCode === undefined ? 'no exit code' : `exit ${c.exitCode}`));
|
|
711
|
+
const footer = [commands.length === 1 ? exits[0] : `${plural(commands.length, 'command')}`, dropped > 0 ? `${dropped} lines trimmed by the tool` : undefined].filter(Boolean).join(' · ');
|
|
712
|
+
if (lines.length === 0) return { kind: 'output', lines: [{ text: 'no output', tone: 'faint' }], footer };
|
|
713
|
+
return { kind: 'output', lines, footer };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (family === 'read' && obj && Array.isArray(obj.files)) {
|
|
717
|
+
const entries = obj.files.filter((f): f is Json => f !== null && typeof f === 'object');
|
|
718
|
+
// Opened, a read shows the text the model read, like a command shows its
|
|
719
|
+
// output: one section per file, headed by its path and line count.
|
|
720
|
+
const withText = entries.filter((f) => typeof f.content === 'string' || str(f.error) !== undefined);
|
|
721
|
+
if (withText.length > 0 && withText.length === entries.length && entries.some((f) => typeof f.content === 'string')) {
|
|
722
|
+
return {
|
|
723
|
+
kind: 'read',
|
|
724
|
+
files: entries.map((f): ReadBodyFile => {
|
|
725
|
+
const error = str(f.error);
|
|
726
|
+
const lines = typeof f.content === 'string' ? readText(f.content) : [];
|
|
727
|
+
return {
|
|
728
|
+
path: str(f.path) ?? str(f.resolvedPath),
|
|
729
|
+
lineCount: num(f.lineCount) ?? lines.length,
|
|
730
|
+
lines: error !== undefined ? [] : lines,
|
|
731
|
+
...(error !== undefined ? { error } : {}),
|
|
732
|
+
};
|
|
733
|
+
}),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
// A read that returned no text (a metadata-only format) lists its files;
|
|
737
|
+
// one file with nothing but its line count repeats the row, so it has no body.
|
|
738
|
+
const items = entries.map((f) => {
|
|
739
|
+
const path = str(f.path) ?? str(f.resolvedPath) ?? '?';
|
|
740
|
+
const lines = num(f.lineCount);
|
|
741
|
+
const error = str(f.error);
|
|
742
|
+
return { text: path, detail: error ? error : lines !== undefined ? plural(lines, 'line') : undefined, error };
|
|
743
|
+
});
|
|
744
|
+
if (items.length === 1 && items[0]!.error === undefined) return null;
|
|
745
|
+
if (items.length > 0) return { kind: 'list', items: items.map(({ text, detail }) => ({ text, detail })) };
|
|
746
|
+
}
|
|
747
|
+
if (family === 'read' && !obj) {
|
|
748
|
+
// A read whose result is the file text itself.
|
|
749
|
+
const lines = readText(content);
|
|
750
|
+
if (lines.some((l) => l.trim() !== '')) {
|
|
751
|
+
const paths = readPaths(call);
|
|
752
|
+
return { kind: 'read', files: [{ path: paths.length === 1 ? paths[0] : undefined, lineCount: lines.length, lines }] };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (family === 'find' && obj) {
|
|
756
|
+
const entries: Array<{ file: string; line?: number; text?: string }> = [];
|
|
757
|
+
findEntries(obj, entries);
|
|
758
|
+
if (entries.length > 0) {
|
|
759
|
+
return {
|
|
760
|
+
kind: 'list',
|
|
761
|
+
items: entries.map((e) => ({ text: e.line !== undefined ? `${e.file}:${e.line}` : e.file, detail: e.text?.trim() })),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (obj) {
|
|
766
|
+
const lines = flattenObject(obj);
|
|
767
|
+
return lines.length > 0 ? { kind: 'text', lines } : null;
|
|
768
|
+
}
|
|
769
|
+
const trimmed = content.replace(/\n+$/, '');
|
|
770
|
+
if (trimmed.trim() === '') return null;
|
|
771
|
+
// A JSON array or other structured text still never shows raw: one entry per line.
|
|
772
|
+
if (trimmed.trimStart().startsWith('[')) {
|
|
773
|
+
try {
|
|
774
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
775
|
+
if (Array.isArray(parsed)) {
|
|
776
|
+
return { kind: 'text', lines: parsed.map((v) => (typeof v === 'string' ? v : v && typeof v === 'object' ? flattenObject(v as Json).join(' ') : String(v))) };
|
|
777
|
+
}
|
|
778
|
+
} catch {
|
|
779
|
+
// Not JSON after all, falls through to plain text.
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return { kind: 'text', lines: trimmed.split('\n').map((l) => cellText(l)) };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Compact duration for a bead row: 0.2s, 1.4s, 38s, 2m 14s, 1h 02m. */
|
|
786
|
+
export function formatBeadTime(ms: number): string {
|
|
787
|
+
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
|
788
|
+
if (ms < 10_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`;
|
|
789
|
+
const secs = Math.floor(ms / 1000);
|
|
790
|
+
if (secs < 60) return `${secs}s`;
|
|
791
|
+
const mins = Math.floor(secs / 60);
|
|
792
|
+
if (mins < 60) return `${mins}m ${String(secs % 60).padStart(2, '0')}s`;
|
|
793
|
+
return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Compact dollars: $0.12, $1.40, $12. */
|
|
797
|
+
export function formatCost(usd: number): string {
|
|
798
|
+
if (usd >= 10) return `$${Math.round(usd)}`;
|
|
799
|
+
return `$${usd.toFixed(2)}`;
|
|
800
|
+
}
|