@pellux/goodvibes-tui 1.1.0 → 1.7.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 +85 -21
- package/README.md +20 -11
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/core/alert-gating.ts +67 -0
- package/src/core/approval-alert.ts +72 -0
- package/src/core/budget-breach-notifier.ts +106 -0
- package/src/core/conversation-rendering.ts +19 -0
- package/src/core/conversation.ts +18 -0
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/long-task-notifier.ts +33 -6
- package/src/core/system-message-router.ts +37 -51
- package/src/core/turn-cancellation.ts +20 -0
- package/src/core/turn-event-wiring.ts +64 -3
- package/src/export/cost-utils.ts +36 -0
- package/src/input/command-registry.ts +38 -1
- package/src/input/commands/checkpoint-runtime.ts +280 -0
- package/src/input/commands/codebase-runtime.ts +192 -0
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +1 -1
- package/src/input/commands/image-runtime.ts +112 -0
- package/src/input/commands/intelligence-runtime.ts +11 -1
- package/src/input/commands/local-auth-runtime.ts +4 -1
- package/src/input/commands/local-runtime.ts +9 -3
- package/src/input/commands/marketplace-runtime.ts +2 -2
- package/src/input/commands/memory.ts +6 -1
- package/src/input/commands/operator-panel-runtime.ts +40 -24
- package/src/input/commands/planning-runtime.ts +26 -3
- package/src/input/commands/platform-sandbox-runtime.ts +1 -6
- package/src/input/commands/plugin-runtime.ts +2 -2
- package/src/input/commands/policy-dispatch.ts +21 -0
- package/src/input/commands/provider-accounts-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +3 -3
- package/src/input/commands/recall-review.ts +35 -0
- package/src/input/commands/remote-runtime.ts +3 -3
- package/src/input/commands/runtime-services.ts +54 -13
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +16 -1
- package/src/input/commands/settings-sync-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +15 -7
- package/src/input/commands/skills-runtime.ts +2 -8
- package/src/input/commands/subscription-runtime.ts +2 -2
- package/src/input/commands/test-runtime.ts +277 -0
- package/src/input/commands/websearch-runtime.ts +101 -0
- package/src/input/commands/work-plan-runtime.ts +36 -10
- package/src/input/commands/workstream-runtime.ts +338 -0
- package/src/input/commands.ts +12 -0
- package/src/input/config-modal-types.ts +161 -0
- package/src/input/config-modal.ts +377 -0
- package/src/input/feed-context-factory.ts +7 -8
- package/src/input/handler-command-route.ts +27 -17
- package/src/input/handler-feed-routes.ts +61 -23
- package/src/input/handler-feed.ts +47 -23
- package/src/input/handler-interactions.ts +1 -3
- package/src/input/handler-modal-routes.ts +65 -0
- package/src/input/handler-modal-stack.ts +3 -5
- package/src/input/handler-modal-token-routes.ts +16 -49
- package/src/input/handler-picker-routes.ts +11 -94
- package/src/input/handler-shortcuts.ts +24 -14
- package/src/input/handler-types.ts +2 -6
- package/src/input/handler-ui-state.ts +10 -22
- package/src/input/handler.ts +6 -28
- package/src/input/keybindings.ts +22 -8
- package/src/input/model-picker-types.ts +24 -6
- package/src/input/model-picker.ts +58 -0
- package/src/input/panel-integration-actions.ts +9 -170
- package/src/input/settings-modal-data.ts +95 -0
- package/src/input/settings-modal-mutations.ts +6 -4
- package/src/main.ts +24 -27
- package/src/panels/agent-inspector-shared.ts +2 -1
- package/src/panels/base-panel.ts +22 -1
- package/src/panels/builtin/agent.ts +21 -107
- package/src/panels/builtin/development.ts +15 -61
- package/src/panels/builtin/knowledge.ts +8 -32
- package/src/panels/builtin/operations.ts +84 -428
- package/src/panels/builtin/session.ts +21 -112
- package/src/panels/builtin/shared.ts +9 -43
- package/src/panels/builtin-modals.ts +218 -0
- package/src/panels/builtin-panels.ts +5 -0
- package/src/panels/cost-tracker-panel.ts +36 -10
- package/src/panels/diff-panel.ts +12 -43
- package/src/panels/eval-registry.ts +60 -0
- package/src/panels/fleet-panel.ts +800 -0
- package/src/panels/fleet-read-model.ts +477 -0
- package/src/panels/fleet-steer.ts +92 -0
- package/src/panels/fleet-stop.ts +125 -0
- package/src/panels/fleet-tabs.ts +230 -0
- package/src/panels/fleet-transcript.ts +356 -0
- package/src/panels/index.ts +7 -31
- package/src/panels/modals/hooks-modal.ts +187 -0
- package/src/panels/modals/keybindings-modal.ts +151 -0
- package/src/panels/modals/knowledge-modal.ts +158 -0
- package/src/panels/modals/local-auth-modal.ts +132 -0
- package/src/panels/modals/marketplace-modal.ts +218 -0
- package/src/panels/modals/memory-modal.ts +180 -0
- package/src/panels/modals/modal-surface-helpers.ts +54 -0
- package/src/panels/modals/modal-theme.ts +36 -0
- package/src/panels/modals/pairing-modal.ts +144 -0
- package/src/panels/modals/planning-modal.ts +282 -0
- package/src/panels/modals/plugins-modal.ts +174 -0
- package/src/panels/modals/policy-modal.ts +256 -0
- package/src/panels/modals/provider-health-modal.ts +136 -0
- package/src/panels/modals/remote-modal.ts +115 -0
- package/src/panels/modals/sandbox-modal.ts +150 -0
- package/src/panels/modals/security-modal.ts +217 -0
- package/src/panels/modals/services-modal.ts +179 -0
- package/src/panels/modals/settings-sync-modal.ts +142 -0
- package/src/panels/modals/skills-modal.ts +189 -0
- package/src/panels/modals/subscription-modal.ts +172 -0
- package/src/panels/modals/work-plan-modal.ts +149 -0
- package/src/panels/panel-confirm-overlay.ts +72 -0
- package/src/panels/panel-manager.ts +108 -5
- package/src/panels/project-planning-answer-actions.ts +4 -2
- package/src/panels/skills-panel.ts +7 -51
- package/src/panels/types.ts +13 -0
- package/src/permissions/hunk-selection.ts +179 -0
- package/src/permissions/prompt.ts +60 -4
- package/src/renderer/compaction-history-modal.ts +19 -3
- package/src/renderer/compaction-preview.ts +13 -2
- package/src/renderer/compaction-quality.ts +100 -0
- package/src/renderer/config-modal.ts +81 -0
- package/src/renderer/conversation-overlays.ts +10 -17
- package/src/renderer/fleet-tab-strip.ts +56 -0
- package/src/renderer/footer-tips.ts +10 -2
- package/src/renderer/git-status.ts +40 -0
- package/src/renderer/help-overlay.ts +2 -2
- package/src/renderer/model-workspace.ts +44 -1
- package/src/renderer/panel-workspace-bar.ts +8 -2
- package/src/renderer/turn-injection.ts +114 -0
- package/src/runtime/bootstrap-command-context.ts +21 -1
- package/src/runtime/bootstrap-command-parts.ts +34 -7
- package/src/runtime/bootstrap-core.ts +14 -4
- package/src/runtime/bootstrap-shell.ts +29 -16
- package/src/runtime/bootstrap.ts +11 -17
- package/src/runtime/code-index-services.ts +112 -0
- package/src/runtime/orchestrator-core-services.ts +49 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +91 -43
- package/src/runtime/ui-services.ts +8 -0
- package/src/runtime/workstream-services.ts +195 -0
- package/src/shell/blocking-input.ts +22 -1
- package/src/shell/ui-openers.ts +129 -17
- package/src/utils/format-duration.ts +8 -18
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
- package/src/panels/agent-inspector-panel.ts +0 -786
- package/src/panels/approval-panel.ts +0 -252
- package/src/panels/automation-control-panel.ts +0 -479
- package/src/panels/cockpit-panel.ts +0 -481
- package/src/panels/cockpit-read-model.ts +0 -238
- package/src/panels/communication-panel.ts +0 -256
- package/src/panels/control-plane-panel.ts +0 -470
- package/src/panels/debug-panel.ts +0 -615
- package/src/panels/docs-panel.ts +0 -384
- package/src/panels/eval-panel.ts +0 -627
- package/src/panels/file-explorer-panel.ts +0 -673
- package/src/panels/file-preview-panel.ts +0 -517
- package/src/panels/hooks-panel.ts +0 -401
- package/src/panels/incident-review-panel.ts +0 -406
- package/src/panels/intelligence-panel.ts +0 -383
- package/src/panels/knowledge-graph-panel.ts +0 -515
- package/src/panels/marketplace-panel.ts +0 -399
- package/src/panels/memory-panel.ts +0 -558
- package/src/panels/ops-control-panel.ts +0 -329
- package/src/panels/ops-strategy-panel.ts +0 -321
- package/src/panels/orchestration-panel.ts +0 -465
- package/src/panels/panel-list-panel.ts +0 -566
- package/src/panels/plan-dashboard-panel.ts +0 -707
- package/src/panels/policy-panel.ts +0 -517
- package/src/panels/project-planning-panel.ts +0 -731
- package/src/panels/provider-health-panel.ts +0 -678
- package/src/panels/provider-health-tracker.ts +0 -310
- package/src/panels/provider-health-views.ts +0 -567
- package/src/panels/qr-panel.ts +0 -280
- package/src/panels/remote-panel.ts +0 -534
- package/src/panels/routes-panel.ts +0 -241
- package/src/panels/sandbox-panel.ts +0 -456
- package/src/panels/security-panel.ts +0 -447
- package/src/panels/services-panel.ts +0 -329
- package/src/panels/session-browser-panel.ts +0 -496
- package/src/panels/settings-sync-panel.ts +0 -398
- package/src/panels/subscription-panel.ts +0 -342
- package/src/panels/symbol-outline-panel.ts +0 -619
- package/src/panels/system-messages-panel.ts +0 -364
- package/src/panels/tasks-panel.ts +0 -608
- package/src/panels/thinking-panel.ts +0 -333
- package/src/panels/tool-inspector-panel.ts +0 -537
- package/src/panels/work-plan-panel.ts +0 -540
- package/src/panels/worktree-panel.ts +0 -360
- package/src/panels/wrfc-panel.ts +0 -790
- package/src/renderer/agent-detail-modal.ts +0 -466
- package/src/renderer/live-tail-modal.ts +0 -156
- package/src/renderer/process-modal.ts +0 -671
- package/src/renderer/qr-renderer.ts +0 -120
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// fleet-panel.ts
|
|
3
|
+
//
|
|
4
|
+
// W2.2 — FleetPanel: the live unified observability tree. Renders the
|
|
5
|
+
// registered process fleet (agents incl. WRFC roles, WRFC chains/subtasks,
|
|
6
|
+
// workflow-tool FSMs, watchers, background processes) as a depth-first tree
|
|
7
|
+
// with a selected-row detail region.
|
|
8
|
+
//
|
|
9
|
+
// Wave-3 (W3.1 Part C) adds session tabs: Enter on an attachable node (agent
|
|
10
|
+
// or wrfc-chain) opens a tab showing that process's transcript (agent) or
|
|
11
|
+
// live member summary (chain); the tree itself is the always-present root
|
|
12
|
+
// tab. See fleet-tabs.ts for the tab-state model and fleet-transcript.ts for
|
|
13
|
+
// tab content rendering — this file owns input dispatch and layout only.
|
|
14
|
+
//
|
|
15
|
+
// Coexists with (does not replace) the process modal, ops, wrfc, and
|
|
16
|
+
// inspector entry points this wave — only the footer process indicator's
|
|
17
|
+
// [Enter] is repointed to open this panel (see handler-feed-routes.ts).
|
|
18
|
+
// Removal of the older entry points is Wave 6.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
import type { Line } from '../types/grid.ts';
|
|
23
|
+
import type { ProcessCostState, ProcessUsage, SteerResult } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
|
|
24
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
25
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
26
|
+
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
27
|
+
import { PanelConfirmOverlay } from './panel-confirm-overlay.ts';
|
|
28
|
+
import { formatAgentCost } from './agent-inspector-shared.ts';
|
|
29
|
+
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
30
|
+
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
31
|
+
import { isPanelSearchBackspace, isPanelSearchCancel, isPanelSearchCommit, isPanelSearchPrintable } from './search-focus.ts';
|
|
32
|
+
import { buildAlignedRow, buildKeyboardHints, buildPanelLine, buildPanelWorkspace, buildSearchInputLine, DEFAULT_PANEL_PALETTE, getPanelWorkspaceContentBudget, type ColumnSpec, type PanelPalette } from './polish.ts';
|
|
33
|
+
import {
|
|
34
|
+
fleetKindTag,
|
|
35
|
+
fleetUsageTokens,
|
|
36
|
+
hasFleetCost,
|
|
37
|
+
isRunningProcessState,
|
|
38
|
+
isTerminalProcessState,
|
|
39
|
+
type FleetReadModel,
|
|
40
|
+
type FleetStateTone,
|
|
41
|
+
type FleetTreeRow,
|
|
42
|
+
} from './fleet-read-model.ts';
|
|
43
|
+
import {
|
|
44
|
+
activeFleetTab,
|
|
45
|
+
appendSteerText,
|
|
46
|
+
attachFleetTab,
|
|
47
|
+
detachActiveFleetTab,
|
|
48
|
+
EMPTY_FLEET_TABS_STATE,
|
|
49
|
+
isAttachableFleetKind,
|
|
50
|
+
stepFleetTab,
|
|
51
|
+
type FleetTab,
|
|
52
|
+
type FleetTabsState,
|
|
53
|
+
type SteerBadge,
|
|
54
|
+
} from './fleet-tabs.ts';
|
|
55
|
+
import { reconcileSteerBadges as reconcileSteerBadgesPure, steerBadgeGlyph, steerBadgeTone } from './fleet-steer.ts';
|
|
56
|
+
import { FleetStopTracker, fleetStateDisplay, toggleFleetPause, buildFleetTreeHints } from './fleet-stop.ts';
|
|
57
|
+
import { parseAgentLedger, renderFleetAgentTranscript, renderFleetChainSummary, renderFleetLedgerFallback, renderFleetTranscriptLoading } from './fleet-transcript.ts';
|
|
58
|
+
import { renderFleetTabStrip } from '../renderer/fleet-tab-strip.ts';
|
|
59
|
+
|
|
60
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
61
|
+
|
|
62
|
+
// Column widths for the tree row layout. `label` absorbs whatever width is
|
|
63
|
+
// left over after the fixed columns + gaps; on hostile (narrow) widths the
|
|
64
|
+
// trailing columns simply clip (buildAlignedRow/buildSelectablePanelLine stop
|
|
65
|
+
// writing once a cell would overflow the row) rather than throwing or
|
|
66
|
+
// wrapping — the tree stays readable, just denser.
|
|
67
|
+
const KIND_W = 8;
|
|
68
|
+
const ELAPSED_W = 7;
|
|
69
|
+
const TOKENS_W = 7;
|
|
70
|
+
const COST_W = 8;
|
|
71
|
+
const ACTIVITY_W = 20;
|
|
72
|
+
const GAP = 1;
|
|
73
|
+
const FIXED_W = 1 /* glyph */ + KIND_W + ELAPSED_W + TOKENS_W + COST_W + ACTIVITY_W + GAP * 6;
|
|
74
|
+
|
|
75
|
+
function planColumns(width: number): ColumnSpec[] {
|
|
76
|
+
const labelWidth = Math.max(10, width - FIXED_W);
|
|
77
|
+
return [
|
|
78
|
+
{ width: 1 },
|
|
79
|
+
{ width: KIND_W },
|
|
80
|
+
{ width: labelWidth },
|
|
81
|
+
{ width: ELAPSED_W, align: 'right' },
|
|
82
|
+
{ width: TOKENS_W, align: 'right' },
|
|
83
|
+
{ width: COST_W, align: 'right' },
|
|
84
|
+
{ width: ACTIVITY_W },
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toneColor(tone: FleetStateTone, palette: PanelPalette): string {
|
|
89
|
+
switch (tone) {
|
|
90
|
+
case 'active': return palette.info ?? DEFAULT_PANEL_PALETTE.info;
|
|
91
|
+
case 'success': return palette.good ?? DEFAULT_PANEL_PALETTE.good;
|
|
92
|
+
case 'failure': return palette.bad ?? DEFAULT_PANEL_PALETTE.bad;
|
|
93
|
+
case 'warn': return palette.warn ?? DEFAULT_PANEL_PALETTE.warn;
|
|
94
|
+
case 'muted': return palette.dim;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatFleetTokens(usage: ProcessUsage | undefined): string {
|
|
99
|
+
const total = fleetUsageTokens(usage);
|
|
100
|
+
if (total === null) return 'n/a';
|
|
101
|
+
return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : String(total);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Honest cost display: never a fabricated $0.00 — 'unpriced' when costState says so. */
|
|
105
|
+
function formatFleetCost(costUsd: number | null | undefined, costState: ProcessCostState): string {
|
|
106
|
+
if (!hasFleetCost(costUsd, costState)) return 'unpriced';
|
|
107
|
+
const formatted = formatAgentCost(costUsd as number);
|
|
108
|
+
return costState === 'estimated' ? `~${formatted}` : formatted;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface FleetActionCallbacks {
|
|
112
|
+
/** Graceful interruption (AgentManager.cancel / trigger-schedule disable, via the registry). */
|
|
113
|
+
readonly interrupt: (id: string) => boolean;
|
|
114
|
+
/** Wave-6 (wo-F item d2): re-arm a `paused` node (disabled trigger/schedule or automation job) — interrupt()'s inverse. Honest false when not paused / non-resumable. */
|
|
115
|
+
readonly resume: (id: string) => boolean;
|
|
116
|
+
/** Hard stop, optionally cascading to killable descendants. Returns the node ids acted on. */
|
|
117
|
+
readonly kill: (id: string, opts: { readonly cascade: boolean }) => readonly string[];
|
|
118
|
+
/**
|
|
119
|
+
* Wave-3 (C6): fetch an agent's conversation history — live (running) or
|
|
120
|
+
* frozen (just-completed, still in the SDK's retention ring). Empty array
|
|
121
|
+
* means "unavailable" (evicted, or never registered) — FleetPanel degrades
|
|
122
|
+
* to the on-disk ledger fallback in that case, never fabricating content.
|
|
123
|
+
*/
|
|
124
|
+
readonly getConversationSnapshot: (agentId: string) => readonly ConversationMessageSnapshot[];
|
|
125
|
+
/** Wave-3 (C6): resolve the on-disk `<agentId>.jsonl` event-ledger path for the degraded fallback view. */
|
|
126
|
+
readonly resolveSessionLogPath: (agentId: string) => string;
|
|
127
|
+
/**
|
|
128
|
+
* Wave-3 (W3.2): queue a human message for a live in-process agent (or a
|
|
129
|
+
* wrfc-subtask's current live member agent), delivered at the target's
|
|
130
|
+
* next turn boundary — never mid-token. Honest refusal
|
|
131
|
+
* (`{queued:false,reason}`) for anything that cannot take mid-run input
|
|
132
|
+
* (terminal nodes, non-agent kinds, the wrfc-chain coordinator itself).
|
|
133
|
+
*/
|
|
134
|
+
readonly steer: (id: string, text: string) => SteerResult;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const NOOP_ACTIONS: FleetActionCallbacks = {
|
|
138
|
+
interrupt: (_id: string) => false,
|
|
139
|
+
resume: (_id: string) => false,
|
|
140
|
+
kill: (_id: string, _opts: { readonly cascade: boolean }) => [],
|
|
141
|
+
getConversationSnapshot: (_agentId: string) => [],
|
|
142
|
+
resolveSessionLogPath: (agentId: string) => agentId,
|
|
143
|
+
steer: (_id: string, _text: string) => ({ queued: false, reason: 'no live registry' }),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
|
|
147
|
+
private readonly unsub: () => void;
|
|
148
|
+
private readonly unsubConsumed: () => void;
|
|
149
|
+
private readonly actions: FleetActionCallbacks;
|
|
150
|
+
private readonly confirmOverlay: PanelConfirmOverlay;
|
|
151
|
+
private follow = false;
|
|
152
|
+
private tabsState: FleetTabsState = EMPTY_FLEET_TABS_STATE;
|
|
153
|
+
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
154
|
+
/**
|
|
155
|
+
* The node id the cursor is anchored to, independent of its row index.
|
|
156
|
+
* `selectedIndex` is a plain offset into `getItems()`'s current order —
|
|
157
|
+
* every snapshot rebuilds that array from scratch (new nodes, completed
|
|
158
|
+
* nodes pruned, tree re-sorted), so a stale index silently points at
|
|
159
|
+
* whatever row now happens to occupy that slot. `reanchorSelection()`
|
|
160
|
+
* re-locates this id in every fresh snapshot and repoints selectedIndex
|
|
161
|
+
* there; null means "no anchor yet" (follows the base class's index-0
|
|
162
|
+
* default until the first navigation/tick establishes one).
|
|
163
|
+
*/
|
|
164
|
+
private selectedNodeId: string | null = null;
|
|
165
|
+
|
|
166
|
+
/** d1 (W6.2): the "stopping…" write-window overlay tracker (see fleet-stop.ts). */
|
|
167
|
+
private readonly stopTracker = new FleetStopTracker();
|
|
168
|
+
|
|
169
|
+
public constructor(
|
|
170
|
+
private readonly readModel: FleetReadModel,
|
|
171
|
+
actions: Partial<FleetActionCallbacks> = {},
|
|
172
|
+
private readonly configManager: ConfigManager | null = null,
|
|
173
|
+
) {
|
|
174
|
+
super('fleet', 'Fleet', '⊟', 'runtime-ops');
|
|
175
|
+
this.showSelectionGutter = true; // W0.8: visible ▸ focus indicator
|
|
176
|
+
this.actions = { ...NOOP_ACTIONS, ...actions };
|
|
177
|
+
this.confirmOverlay = new PanelConfirmOverlay(() => this.markDirty());
|
|
178
|
+
this.unsub = readModel.subscribe(() => {
|
|
179
|
+
this.reanchorSelection();
|
|
180
|
+
this.applyFollow();
|
|
181
|
+
this.reconcileSteerBadges();
|
|
182
|
+
this.markDirty();
|
|
183
|
+
});
|
|
184
|
+
// Wave-3 (W3.2): the honest "the agent actually consumed this steer at
|
|
185
|
+
// its turn boundary" signal — see fleet-tabs.ts's SteerBadgeStatus doc.
|
|
186
|
+
// A read-model without a runtimeBus dep never invokes this (graceful
|
|
187
|
+
// no-op), same degrade as steer()/steerable without a messageBus dep.
|
|
188
|
+
this.unsubConsumed = readModel.subscribeConsumed((event) => {
|
|
189
|
+
let changed = false;
|
|
190
|
+
for (const tab of this.tabsState.tabs) {
|
|
191
|
+
const badge = tab.steerBadge;
|
|
192
|
+
if (!badge || badge.messageId !== event.messageId) continue;
|
|
193
|
+
// Consumed-wins: this real signal can still upgrade an already
|
|
194
|
+
// 'dropped' (inferred) badge, as long as it hasn't linger-cleared yet.
|
|
195
|
+
if (badge.status === 'queued' || badge.status === 'dropped') {
|
|
196
|
+
tab.steerBadge = { messageId: badge.messageId, status: 'consumed', queuedAt: badge.queuedAt, resolvedAt: Date.now() };
|
|
197
|
+
changed = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (changed) this.markDirty();
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
public override onActivate(): void {
|
|
205
|
+
super.onActivate();
|
|
206
|
+
// Time-derived fields (elapsed, follow target) stay live even when no
|
|
207
|
+
// registry tick fires while this panel is on screen (cockpit-panel.ts
|
|
208
|
+
// STALL_TICK_MS precedent). Also re-anchors defensively in case the
|
|
209
|
+
// underlying list changed without a subscribe notification.
|
|
210
|
+
if (this.tickTimer === null) {
|
|
211
|
+
this.tickTimer = this.registerTimer(setInterval(() => {
|
|
212
|
+
this.reanchorSelection();
|
|
213
|
+
this.applyFollow();
|
|
214
|
+
this.reconcileSteerBadges();
|
|
215
|
+
this.markDirty();
|
|
216
|
+
}, 1000));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
public override onDeactivate(): void {
|
|
221
|
+
super.onDeactivate();
|
|
222
|
+
if (this.tickTimer !== null) {
|
|
223
|
+
this.clearTimer(this.tickTimer);
|
|
224
|
+
this.tickTimer = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public override onDestroy(): void {
|
|
229
|
+
super.onDestroy();
|
|
230
|
+
this.unsub();
|
|
231
|
+
this.unsubConsumed();
|
|
232
|
+
for (const tab of this.tabsState.tabs) tab.lineCache.clear();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** True while a session tab (not the root tree) is focused. Replaces the old Wave-2 isDetailFocused() seam. */
|
|
236
|
+
public isTabActive(): boolean {
|
|
237
|
+
return this.tabsState.activeTabIndex > 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Wave-3 (W3.2): true while the steer composer on the active tab is open —
|
|
242
|
+
* every character of a burst (paste, or several fast-typed chars in one
|
|
243
|
+
* stdin chunk) must land in the draft one at a time rather than being
|
|
244
|
+
* routed away as panel hotkeys or exploded to the main composer. See
|
|
245
|
+
* Panel.isCapturingTextBurst's doc comment and git-panel.ts's
|
|
246
|
+
* `commitMessage` precedent.
|
|
247
|
+
*/
|
|
248
|
+
public override isCapturingTextBurst(): boolean {
|
|
249
|
+
const tab = activeFleetTab(this.tabsState);
|
|
250
|
+
return tab !== null && tab.steerDraft !== null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Introspection for tests: the current tab-bar state (read-only). */
|
|
254
|
+
public getTabsState(): FleetTabsState {
|
|
255
|
+
return this.tabsState;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
public isFollowing(): boolean {
|
|
259
|
+
return this.follow;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Ctrl+X (panel-close) collision resolution (Wave-3 Part C4): the global
|
|
264
|
+
* shortcut route (handler-shortcuts.ts) calls this BEFORE closing the
|
|
265
|
+
* panel. Detaching the active tab consumes the key; on the root tree tab
|
|
266
|
+
* there is nothing to detach, so this returns false and Ctrl+X falls
|
|
267
|
+
* through to its ordinary panel-close behavior.
|
|
268
|
+
*/
|
|
269
|
+
public interceptPanelClose(): boolean {
|
|
270
|
+
if (this.tabsState.activeTabIndex === 0) return false;
|
|
271
|
+
this.tabsState = detachActiveFleetTab(this.tabsState);
|
|
272
|
+
this.markDirty();
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
protected getItems(): readonly FleetTreeRow[] {
|
|
277
|
+
return this.readModel.getSnapshot().rows;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
protected override getPalette(): PanelPalette {
|
|
281
|
+
return C;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
protected override getEmptyStateMessage(): string {
|
|
285
|
+
return ' No processes tracked yet.';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* W3.3 (cross-restart honesty) — investigation confirmed there is no
|
|
290
|
+
* bridge from a prior TUI process's in-memory registry (or the daemon's
|
|
291
|
+
* separate one) into this one: a restart always starts from an empty
|
|
292
|
+
* `AgentManager` Map, so a completed process from a previous session can
|
|
293
|
+
* never reappear as a tree row here, no matter how recently it ran. That
|
|
294
|
+
* is a real, permanent limitation (not a bug to fix in this item — see the
|
|
295
|
+
* W3.3 brief's design point 5), so it is documented plainly here rather
|
|
296
|
+
* than silently leaving the empty tree looking like "nothing has ever run"
|
|
297
|
+
* or, worse, implying a restart would bring prior processes back.
|
|
298
|
+
*/
|
|
299
|
+
protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
|
|
300
|
+
return [
|
|
301
|
+
{
|
|
302
|
+
command: 'previous sessions',
|
|
303
|
+
summary: "not shown here — this list resets on TUI restart; each agent's .goodvibes/tui/sessions/<id>.jsonl event log still persists on disk",
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private applyFollow(): void {
|
|
309
|
+
if (!this.follow) return;
|
|
310
|
+
const rows = this.getItems();
|
|
311
|
+
if (rows.length === 0) return;
|
|
312
|
+
let bestIdx = -1;
|
|
313
|
+
let bestStarted = -Infinity;
|
|
314
|
+
rows.forEach((row, idx) => {
|
|
315
|
+
if (!isRunningProcessState(row.node.state)) return;
|
|
316
|
+
const started = row.node.startedAt ?? -Infinity;
|
|
317
|
+
if (started >= bestStarted) {
|
|
318
|
+
bestStarted = started;
|
|
319
|
+
bestIdx = idx;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
if (bestIdx >= 0) {
|
|
323
|
+
this.selectedIndex = bestIdx;
|
|
324
|
+
this.selectedNodeId = rows[bestIdx]!.node.id;
|
|
325
|
+
this.needsRender = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Record the current selection's node id as the anchor for future snapshot updates. */
|
|
330
|
+
private captureSelectionAnchor(): void {
|
|
331
|
+
const item = this.getSelectedItem();
|
|
332
|
+
this.selectedNodeId = item ? item.node.id : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Re-locate `selectedNodeId` in the current (possibly just-changed)
|
|
337
|
+
* snapshot and repoint `selectedIndex` at its new position — this is what
|
|
338
|
+
* makes the selection follow a specific process across adds/removes/
|
|
339
|
+
* reorders instead of drifting onto whatever row now occupies the old
|
|
340
|
+
* index. When the anchored node is no longer present (completed and
|
|
341
|
+
* pruned, killed and pruned, etc.) — or no anchor has been established
|
|
342
|
+
* yet — falls back to the base class's clampSelection() (nearest valid
|
|
343
|
+
* index, never an out-of-bounds/vanished selection) and re-anchors to
|
|
344
|
+
* whatever that lands on.
|
|
345
|
+
*/
|
|
346
|
+
private reanchorSelection(): void {
|
|
347
|
+
const rows = this.getItems();
|
|
348
|
+
if (this.selectedNodeId !== null) {
|
|
349
|
+
const idx = rows.findIndex((row) => row.node.id === this.selectedNodeId);
|
|
350
|
+
if (idx >= 0) {
|
|
351
|
+
this.selectedIndex = idx;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this.clampSelection();
|
|
356
|
+
this.captureSelectionAnchor();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Find a node's live ProcessNode in the current snapshot, or null (pruned/never registered). */
|
|
360
|
+
private findLiveNode(nodeId: string): FleetTreeRow['node'] | null {
|
|
361
|
+
return this.getItems().find((row) => row.node.id === nodeId)?.node ?? null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Wave-3 (W3.2, risk #2 — "dropped inference"): called on every read-model
|
|
366
|
+
* snapshot update and on the panel's 1s tick. See fleet-steer.ts's
|
|
367
|
+
* reconcileSteerBadges doc for why this is needed (the SDK emits no
|
|
368
|
+
* cancelled/expired signal for a queued steer).
|
|
369
|
+
*/
|
|
370
|
+
private reconcileSteerBadges(): void {
|
|
371
|
+
if (this.tabsState.tabs.length === 0) return;
|
|
372
|
+
const changed = reconcileSteerBadgesPure(this.tabsState.tabs, (id) => this.findLiveNode(id), Date.now());
|
|
373
|
+
if (changed) this.markDirty();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Count of descendants of `nodeId` (in the current snapshot) that report `capabilities.killable` — the Wave-3 K-confirm child count (Part C7). */
|
|
377
|
+
private countKillableDescendants(nodeId: string): number {
|
|
378
|
+
const rows = this.getItems();
|
|
379
|
+
const byParent = new Map<string, string[]>();
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
if (!row.node.parentId) continue;
|
|
382
|
+
const siblings = byParent.get(row.node.parentId) ?? [];
|
|
383
|
+
siblings.push(row.node.id);
|
|
384
|
+
byParent.set(row.node.parentId, siblings);
|
|
385
|
+
}
|
|
386
|
+
const byId = new Map(rows.map((row) => [row.node.id, row] as const));
|
|
387
|
+
let count = 0;
|
|
388
|
+
const stack = [...(byParent.get(nodeId) ?? [])];
|
|
389
|
+
const seen = new Set<string>();
|
|
390
|
+
while (stack.length > 0) {
|
|
391
|
+
const id = stack.pop()!;
|
|
392
|
+
if (seen.has(id)) continue;
|
|
393
|
+
seen.add(id);
|
|
394
|
+
const row = byId.get(id);
|
|
395
|
+
if (row?.node.capabilities.killable) count++;
|
|
396
|
+
for (const child of byParent.get(id) ?? []) stack.push(child);
|
|
397
|
+
}
|
|
398
|
+
return count;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Kick off (once) the async ledger-fallback load for a tab whose conversation snapshot is unavailable. */
|
|
402
|
+
private ensureLedgerLoaded(tab: FleetTab): void {
|
|
403
|
+
if (tab.ledgerLoadStarted) return;
|
|
404
|
+
tab.ledgerLoadStarted = true;
|
|
405
|
+
const path = this.actions.resolveSessionLogPath(tab.agentId);
|
|
406
|
+
readFile(path, 'utf-8')
|
|
407
|
+
.then((raw) => {
|
|
408
|
+
tab.ledgerEntries = parseAgentLedger(raw);
|
|
409
|
+
this.markDirty();
|
|
410
|
+
})
|
|
411
|
+
.catch(() => {
|
|
412
|
+
tab.ledgerEntries = [];
|
|
413
|
+
this.markDirty();
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
public override handleInput(key: string): boolean {
|
|
418
|
+
if (this.lastError !== null) this.clearError();
|
|
419
|
+
|
|
420
|
+
// Confirm overlay owns input first (K-armed kill confirm).
|
|
421
|
+
if (this.confirmOverlay.handleInput(key)) return true;
|
|
422
|
+
|
|
423
|
+
// Wave-3 (W3.2): once the steer composer is open on the active tab, it
|
|
424
|
+
// owns EVERY key (same full-priority-while-composing contract as
|
|
425
|
+
// git-panel.ts's commitMessage entry) until Enter (submit) or Esc
|
|
426
|
+
// (cancel) — a burst/paste and single hotkeys like 'j'/'s'/'[' all land
|
|
427
|
+
// in the draft, never as tree/tab navigation (W0.8 focus rule).
|
|
428
|
+
const composingTab = activeFleetTab(this.tabsState);
|
|
429
|
+
if (composingTab && composingTab.steerDraft !== null) {
|
|
430
|
+
return this.handleSteerInput(composingTab, key);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Tab switching works from ANY view (root tree or an attached tab) once
|
|
434
|
+
// at least one tab is open — [ / ] step through Tree, tab 1, tab 2, ...
|
|
435
|
+
// Ctrl+X detach is handled by interceptPanelClose(), not handleInput (it
|
|
436
|
+
// must win a global-shortcut race — see that method's doc comment).
|
|
437
|
+
if ((key === '[' || key === ']') && this.tabsState.tabs.length > 0) {
|
|
438
|
+
this.tabsState = stepFleetTab(this.tabsState, key === ']' ? 1 : -1);
|
|
439
|
+
this.markDirty();
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (this.tabsState.activeTabIndex > 0) {
|
|
444
|
+
if (key === 's') {
|
|
445
|
+
return this.tryOpenSteerDraft(activeFleetTab(this.tabsState)!);
|
|
446
|
+
}
|
|
447
|
+
// A session tab is focused — the tree isn't visible, so tree-only keys
|
|
448
|
+
// (navigate/i/K/f/Enter) don't apply here.
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const selected = this.getSelectedItem();
|
|
453
|
+
|
|
454
|
+
if (key === 'i') {
|
|
455
|
+
// Guard: only consume on a real, non-terminal node (cockpit-panel.ts
|
|
456
|
+
// precedent) so the key falls through when there is nothing to act on.
|
|
457
|
+
if (!selected || isTerminalProcessState(selected.node.state)) return false;
|
|
458
|
+
// Not every kind can be interrupted (only 'agent' nodes ever are — see
|
|
459
|
+
// the SDK's fleet adapters: schedule/trigger/watcher/workflow/
|
|
460
|
+
// wrfc-chain/wrfc-subtask/background-process all report
|
|
461
|
+
// capabilities.interruptible: false unconditionally). Consume the key
|
|
462
|
+
// and say so rather than silently calling interrupt() on a node the
|
|
463
|
+
// registry has no interrupt route for.
|
|
464
|
+
if (!selected.node.capabilities.interruptible) {
|
|
465
|
+
this.setError(`${selected.node.kind} does not support interrupt.`);
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
this.actions.interrupt(selected.node.id);
|
|
469
|
+
this.stopTracker.mark(selected.node.id);
|
|
470
|
+
this.markDirty();
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (key === 'K') {
|
|
475
|
+
if (!selected || isTerminalProcessState(selected.node.state)) return false;
|
|
476
|
+
if (!selected.node.capabilities.killable) {
|
|
477
|
+
this.setError(`${selected.node.kind} does not support kill.`);
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
const node = selected.node;
|
|
481
|
+
const shortId = node.id.length > 8 ? node.id.slice(-8) : node.id;
|
|
482
|
+
const childCount = this.countKillableDescendants(node.id);
|
|
483
|
+
const suffix = childCount > 0 ? ` (+${childCount} child${childCount === 1 ? '' : 'ren'})` : '';
|
|
484
|
+
this.confirmOverlay.arm({
|
|
485
|
+
id: node.id,
|
|
486
|
+
label: `${node.kind} ${shortId}${suffix}`,
|
|
487
|
+
verb: 'Kill',
|
|
488
|
+
// d1 (W6.2): mark 'stopping…' only once the kill is confirmed, not while
|
|
489
|
+
// the confirm overlay is merely armed.
|
|
490
|
+
onConfirm: () => {
|
|
491
|
+
this.actions.kill(node.id, { cascade: true });
|
|
492
|
+
this.stopTracker.mark(node.id);
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// d2 (W6.2): 'p' toggles pause<->resume by the selected node's state (see
|
|
499
|
+
// toggleFleetPause in fleet-stop.ts).
|
|
500
|
+
if (key === 'p') {
|
|
501
|
+
if (!selected) return false;
|
|
502
|
+
return toggleFleetPause(selected.node, {
|
|
503
|
+
interrupt: (id) => this.actions.interrupt(id),
|
|
504
|
+
resume: (id) => this.actions.resume(id),
|
|
505
|
+
setError: (m) => this.setError(m),
|
|
506
|
+
markDirty: () => this.markDirty(),
|
|
507
|
+
tracker: this.stopTracker,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (key === 'f') {
|
|
512
|
+
this.follow = !this.follow;
|
|
513
|
+
if (this.follow) this.applyFollow();
|
|
514
|
+
this.markDirty();
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (key === 'enter' || key === 'return') {
|
|
519
|
+
if (!selected) return false;
|
|
520
|
+
const node = selected.node;
|
|
521
|
+
if (!isAttachableFleetKind(node.kind)) {
|
|
522
|
+
this.setError(`${node.kind} has no transcript to attach.`);
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
this.tabsState = attachFleetTab(this.tabsState, node);
|
|
526
|
+
this.markDirty();
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const consumed = super.handleInput(key);
|
|
531
|
+
if (consumed) this.captureSelectionAnchor();
|
|
532
|
+
return consumed;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Wave-3 (W3.2): the `s` gate — mirrors the i/K capability-gating template
|
|
537
|
+
* exactly (guard on the live node existing, non-terminal, and the specific
|
|
538
|
+
* capability; consume the key and say so rather than silently no-op-ing).
|
|
539
|
+
*/
|
|
540
|
+
private tryOpenSteerDraft(tab: FleetTab): boolean {
|
|
541
|
+
const node = this.findLiveNode(tab.nodeId);
|
|
542
|
+
if (!node || isTerminalProcessState(node.state) || !node.capabilities.steerable) {
|
|
543
|
+
this.setError(`${node?.kind ?? tab.kind} does not support steering.`);
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
tab.steerDraft = '';
|
|
547
|
+
this.markDirty();
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Dispatch for the open steer composer — mirrors git-panel.ts's handleCommitMessageInput. */
|
|
552
|
+
private handleSteerInput(tab: FleetTab, key: string): boolean {
|
|
553
|
+
if (isPanelSearchCancel(key)) {
|
|
554
|
+
tab.steerDraft = null;
|
|
555
|
+
this.markDirty();
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
if (isPanelSearchCommit(key)) {
|
|
559
|
+
const text = (tab.steerDraft ?? '').trim();
|
|
560
|
+
tab.steerDraft = null;
|
|
561
|
+
if (text.length > 0) this.submitSteer(tab, text);
|
|
562
|
+
this.markDirty();
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
if (isPanelSearchBackspace(key)) {
|
|
566
|
+
tab.steerDraft = (tab.steerDraft ?? '').slice(0, -1);
|
|
567
|
+
this.markDirty();
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
// Ordinary typing AND a pasted multi-line block's line-break characters
|
|
571
|
+
// (delivered one at a time through the same burst pipeline — see
|
|
572
|
+
// isCapturingTextBurst) both flow through appendSteerText, which
|
|
573
|
+
// normalizes \r/\n to a collapsed space instead of corrupting the
|
|
574
|
+
// one-line field or silently dropping the content.
|
|
575
|
+
if (key.length === 1 && (isPanelSearchPrintable(key) || key === '\r' || key === '\n')) {
|
|
576
|
+
tab.steerDraft = appendSteerText(tab.steerDraft ?? '', key);
|
|
577
|
+
this.markDirty();
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
return true; // absorb every other key while composing
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Submit a composed steer message: queued -> badge; refused -> honest inline error (setError), same convention as the i/K "not supported" messages. */
|
|
584
|
+
private submitSteer(tab: FleetTab, text: string): void {
|
|
585
|
+
const result = this.actions.steer(tab.nodeId, text);
|
|
586
|
+
if (result.queued) {
|
|
587
|
+
// queuedAt drives reconcileSteerBadges' TTL-expiry fallback (fleet-steer.ts).
|
|
588
|
+
tab.steerBadge = { messageId: result.messageId, status: 'queued', queuedAt: Date.now() };
|
|
589
|
+
} else {
|
|
590
|
+
this.setError(result.reason);
|
|
591
|
+
}
|
|
592
|
+
this.markDirty();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
protected renderItem(row: FleetTreeRow, _index: number, _selected: boolean, width: number): Line {
|
|
596
|
+
const node = row.node;
|
|
597
|
+
// d1: while a stop is in flight, fleetStateDisplay overrides the state
|
|
598
|
+
// glyph/label/tone with a display-only 'stopping…' so the row never claims
|
|
599
|
+
// the past-tense outcome before the state has actually flipped.
|
|
600
|
+
const stopping = this.stopTracker.isStopping(node.id);
|
|
601
|
+
const disp = fleetStateDisplay(node.state, stopping);
|
|
602
|
+
const color = toneColor(disp.tone, C);
|
|
603
|
+
const label = `${row.treePrefix}${node.label}`;
|
|
604
|
+
const badge = this.steerBadgeForNode(node.id);
|
|
605
|
+
const activity = stopping
|
|
606
|
+
? disp.label
|
|
607
|
+
: badge
|
|
608
|
+
? `${steerBadgeGlyph(badge.status)} ${node.currentActivity?.text ?? ''}`.trimEnd()
|
|
609
|
+
: (node.currentActivity?.text ?? '');
|
|
610
|
+
const activityColor = stopping ? color : badge ? steerBadgeTone(badge.status, C) : C.dim;
|
|
611
|
+
|
|
612
|
+
return buildAlignedRow(
|
|
613
|
+
width,
|
|
614
|
+
[
|
|
615
|
+
{ text: disp.glyph, fg: color },
|
|
616
|
+
{ text: fleetKindTag(node.kind), fg: C.dim },
|
|
617
|
+
{ text: label, fg: C.value },
|
|
618
|
+
{ text: formatElapsed(node.elapsedMs), fg: C.dim },
|
|
619
|
+
{ text: formatFleetTokens(node.usage), fg: C.dim },
|
|
620
|
+
{ text: formatFleetCost(node.costUsd, node.costState), fg: C.value },
|
|
621
|
+
{ text: activity, fg: activityColor },
|
|
622
|
+
],
|
|
623
|
+
planColumns(width),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** A node's steer badge, looked up via its attached tab (if any) — null when the node has no tab or no active/recent steer. */
|
|
628
|
+
private steerBadgeForNode(nodeId: string): SteerBadge | null {
|
|
629
|
+
return this.tabsState.tabs.find((t) => t.nodeId === nodeId)?.steerBadge ?? null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private renderDetail(row: FleetTreeRow, width: number): Line[] {
|
|
633
|
+
const node = row.node;
|
|
634
|
+
// d1: mirror the tree row's 'stopping…' override so the literal state text
|
|
635
|
+
// never claims 'killed' mid-write either.
|
|
636
|
+
const disp = fleetStateDisplay(node.state, this.stopTracker.isStopping(node.id));
|
|
637
|
+
const color = toneColor(disp.tone, C);
|
|
638
|
+
|
|
639
|
+
const line1 = buildPanelLine(width, [
|
|
640
|
+
[' ', C.dim],
|
|
641
|
+
[disp.glyph, color],
|
|
642
|
+
[` ${node.kind}`, C.dim],
|
|
643
|
+
[' id ', C.label],
|
|
644
|
+
[node.id, C.value],
|
|
645
|
+
[' state ', C.label],
|
|
646
|
+
[disp.label, color],
|
|
647
|
+
[' elapsed ', C.label],
|
|
648
|
+
[formatElapsed(node.elapsedMs), C.value],
|
|
649
|
+
]);
|
|
650
|
+
const line2 = buildPanelLine(width, [
|
|
651
|
+
[' model ', C.label],
|
|
652
|
+
[node.model ?? 'unknown', C.info],
|
|
653
|
+
[' tokens ', C.label],
|
|
654
|
+
[formatFleetTokens(node.usage), C.value],
|
|
655
|
+
[' cost ', C.label],
|
|
656
|
+
[formatFleetCost(node.costUsd, node.costState), C.value],
|
|
657
|
+
]);
|
|
658
|
+
const activityText = node.currentActivity
|
|
659
|
+
? `${node.currentActivity.kind}: ${node.currentActivity.text}`
|
|
660
|
+
: '(no recent activity)';
|
|
661
|
+
const line3 = buildPanelLine(width, [
|
|
662
|
+
[' activity ', C.label],
|
|
663
|
+
[truncateDisplay(activityText, Math.max(0, width - 11)), C.dim],
|
|
664
|
+
]);
|
|
665
|
+
// Wave 3 hook: approval history attaches here once session tabs land.
|
|
666
|
+
const line4 = buildPanelLine(width, [
|
|
667
|
+
[' approvals ', C.label],
|
|
668
|
+
['(Wave 3)', C.dim],
|
|
669
|
+
]);
|
|
670
|
+
return [line1, line2, line3, line4];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Renders the active session tab's content (transcript / chain summary / ledger fallback) in place of the tree. */
|
|
674
|
+
private renderTabView(width: number, height: number): Line[] {
|
|
675
|
+
const palette = this.getPalette();
|
|
676
|
+
const tab = activeFleetTab(this.tabsState);
|
|
677
|
+
if (!tab) return this.renderTreeView(width, height); // defensive; should be unreachable
|
|
678
|
+
const stripLine = renderFleetTabStrip(this.tabsState, width);
|
|
679
|
+
|
|
680
|
+
const snapshotRows = this.readModel.getSnapshot().rows;
|
|
681
|
+
const liveNode = snapshotRows.find((row) => row.node.id === tab.nodeId)?.node ?? null;
|
|
682
|
+
// Wave-3 (W3.2): the steer composer only ever applies to 'agent' tabs
|
|
683
|
+
// (wrfc-chain has no conversation loop of its own — see
|
|
684
|
+
// fleet-tabs.ts's FleetAttachableKind doc); the live-node lookup is
|
|
685
|
+
// shared with the isTerminal computation below either way.
|
|
686
|
+
const canSteer = liveNode !== null && !isTerminalProcessState(liveNode.state) && liveNode.capabilities.steerable;
|
|
687
|
+
|
|
688
|
+
// Wave-3 (W3.2): while composing, Enter/Esc replace the tab-switch/detach
|
|
689
|
+
// hints (mirrors git-panel.ts's renderCommitCompose footer) so the
|
|
690
|
+
// footer never advertises a key the composer itself has absorbed.
|
|
691
|
+
const hints: Array<{ keys: string; label: string }> = tab.steerDraft !== null
|
|
692
|
+
? [{ keys: 'Enter', label: 'send' }, { keys: 'Esc', label: 'cancel' }]
|
|
693
|
+
: [
|
|
694
|
+
{ keys: '[ ]', label: 'switch tab' },
|
|
695
|
+
{ keys: 'Ctrl+X', label: 'detach' },
|
|
696
|
+
...(canSteer ? [{ keys: 's', label: 'steer' }] : []),
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
const errorLine = this.renderErrorLine(width);
|
|
700
|
+
const composerLines: Line[] = [];
|
|
701
|
+
if (tab.steerDraft !== null) {
|
|
702
|
+
composerLines.push(buildSearchInputLine(
|
|
703
|
+
width,
|
|
704
|
+
'Steer: ',
|
|
705
|
+
`${tab.steerDraft}_`,
|
|
706
|
+
palette,
|
|
707
|
+
{ active: true, bg: palette.inputBg, valueColor: palette.info },
|
|
708
|
+
));
|
|
709
|
+
} else if (tab.steerBadge) {
|
|
710
|
+
composerLines.push(this.renderSteerBadgeLine(tab.steerBadge, width, palette));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const footerLines = [
|
|
714
|
+
...(errorLine ? [errorLine] : []),
|
|
715
|
+
...composerLines,
|
|
716
|
+
buildKeyboardHints(width, hints, palette),
|
|
717
|
+
];
|
|
718
|
+
const contentBudget = getPanelWorkspaceContentBudget(width, height, { footerLines, palette });
|
|
719
|
+
const contentHeight = Math.max(1, contentBudget - (stripLine ? 1 : 0));
|
|
720
|
+
|
|
721
|
+
let contentLines: Line[];
|
|
722
|
+
|
|
723
|
+
if (tab.kind === 'wrfc-chain') {
|
|
724
|
+
const memberRows = snapshotRows.filter((row) => row.node.parentId === tab.nodeId);
|
|
725
|
+
// d3 (W6.2): a completed chain prunes its wrapper node, so an empty member
|
|
726
|
+
// list can mean "finished", not "not started yet". Absent (pruned) OR
|
|
727
|
+
// terminal => the honest "completed" wording; present + non-terminal with
|
|
728
|
+
// no members yet => the "not started" wording.
|
|
729
|
+
const chainDoneOrAbsent = liveNode === null || isTerminalProcessState(liveNode.state);
|
|
730
|
+
contentLines = renderFleetChainSummary(memberRows, width, chainDoneOrAbsent);
|
|
731
|
+
} else {
|
|
732
|
+
const isTerminal = liveNode ? isTerminalProcessState(liveNode.state) : true;
|
|
733
|
+
const snapshot = this.actions.getConversationSnapshot(tab.agentId);
|
|
734
|
+
const result = renderFleetAgentTranscript(snapshot, isTerminal, tab.lineCache, width, contentHeight, this.configManager);
|
|
735
|
+
if (result.mode === 'unavailable') {
|
|
736
|
+
this.ensureLedgerLoaded(tab);
|
|
737
|
+
contentLines = tab.ledgerEntries !== null
|
|
738
|
+
? renderFleetLedgerFallback(tab.ledgerEntries, width, contentHeight)
|
|
739
|
+
: renderFleetTranscriptLoading(width);
|
|
740
|
+
} else {
|
|
741
|
+
contentLines = result.lines;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const sections = [
|
|
746
|
+
...(stripLine ? [{ lines: [stripLine] }] : []),
|
|
747
|
+
{ lines: contentLines },
|
|
748
|
+
];
|
|
749
|
+
return buildPanelWorkspace(width, height, { title: `Fleet — ${tab.label}`, sections, footerLines, palette });
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** One-line honest status for the active tab's steer badge (queued/consumed/dropped) — see fleet-tabs.ts's SteerBadgeStatus doc. */
|
|
753
|
+
private renderSteerBadgeLine(badge: SteerBadge, width: number, palette: PanelPalette): Line {
|
|
754
|
+
const glyph = steerBadgeGlyph(badge.status);
|
|
755
|
+
const tone = steerBadgeTone(badge.status, palette);
|
|
756
|
+
const label = badge.status === 'queued'
|
|
757
|
+
? 'steer queued — awaiting the agent\'s next turn'
|
|
758
|
+
: badge.status === 'consumed'
|
|
759
|
+
? 'steer consumed'
|
|
760
|
+
: `steer dropped — ${badge.note ?? 'the target ended before delivery'}`;
|
|
761
|
+
return buildPanelLine(width, [
|
|
762
|
+
[' ', palette.dim],
|
|
763
|
+
[glyph, tone],
|
|
764
|
+
[` ${label}`, palette.dim],
|
|
765
|
+
]);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private renderTreeView(width: number, height: number): Line[] {
|
|
769
|
+
const selected = this.getSelectedItem();
|
|
770
|
+
const footer: Line[] = [];
|
|
771
|
+
if (this.confirmOverlay.pending) {
|
|
772
|
+
footer.push(...(this.confirmOverlay.renderLines(width) ?? []));
|
|
773
|
+
} else if (selected) {
|
|
774
|
+
footer.push(...this.renderDetail(selected, width));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Capability-gated tree hints — only advertise i/K/p when the selected node
|
|
778
|
+
// can actually accept them (most kinds are never interruptible; nothing is
|
|
779
|
+
// killable once terminal), incl. the d2 state-dependent p pause/resume chip.
|
|
780
|
+
// Mirrors the handleInput guards above. See buildFleetTreeHints (fleet-stop.ts).
|
|
781
|
+
const hints = buildFleetTreeHints(selected?.node, this.follow, this.tabsState.tabs.length > 0);
|
|
782
|
+
|
|
783
|
+
// Tab strip renders only when tabs exist — omitting it entirely with no
|
|
784
|
+
// tabs attached keeps the pre-Wave-3 root-tree rendering byte-identical.
|
|
785
|
+
const stripLine = renderFleetTabStrip(this.tabsState, width);
|
|
786
|
+
const header = stripLine ? [stripLine] : undefined;
|
|
787
|
+
|
|
788
|
+
return this.renderList(width, height, {
|
|
789
|
+
title: 'Fleet',
|
|
790
|
+
header,
|
|
791
|
+
footer,
|
|
792
|
+
hints,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
public override render(width: number, height: number): Line[] {
|
|
797
|
+
if (this.tabsState.activeTabIndex > 0) return this.renderTabView(width, height);
|
|
798
|
+
return this.renderTreeView(width, height);
|
|
799
|
+
}
|
|
800
|
+
}
|