@pellux/goodvibes-tui 0.28.0 → 0.29.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/input/commands/cost-runtime.ts +49 -0
  5. package/src/input/commands/operator-runtime.ts +5 -1
  6. package/src/input/commands.ts +2 -0
  7. package/src/input/handler-feed-routes.ts +12 -35
  8. package/src/input/handler-feed.ts +4 -8
  9. package/src/input/handler-shortcuts.ts +51 -0
  10. package/src/input/handler.ts +43 -6
  11. package/src/input/keybindings.ts +48 -8
  12. package/src/input/panel-integration-actions.ts +107 -1
  13. package/src/main.ts +5 -1
  14. package/src/panels/agent-inspector-panel.ts +125 -36
  15. package/src/panels/agent-inspector-shared.ts +144 -0
  16. package/src/panels/approval-panel.ts +67 -16
  17. package/src/panels/automation-control-panel.ts +368 -124
  18. package/src/panels/builtin/agent.ts +28 -37
  19. package/src/panels/builtin/development.ts +40 -32
  20. package/src/panels/builtin/knowledge.ts +15 -6
  21. package/src/panels/builtin/operations.ts +178 -117
  22. package/src/panels/builtin/session.ts +35 -9
  23. package/src/panels/builtin/shared.ts +98 -6
  24. package/src/panels/cockpit-panel.ts +232 -73
  25. package/src/panels/communication-panel.ts +58 -20
  26. package/src/panels/confirm-state.ts +8 -1
  27. package/src/panels/control-plane-panel.ts +351 -118
  28. package/src/panels/cost-tracker-panel.ts +165 -7
  29. package/src/panels/debug-panel.ts +312 -159
  30. package/src/panels/diff-panel.ts +203 -57
  31. package/src/panels/docs-panel.ts +152 -66
  32. package/src/panels/eval-panel.ts +220 -42
  33. package/src/panels/file-explorer-panel.ts +202 -122
  34. package/src/panels/file-preview-panel.ts +132 -57
  35. package/src/panels/git-panel.ts +309 -128
  36. package/src/panels/hooks-panel.ts +150 -22
  37. package/src/panels/incident-review-panel.ts +223 -35
  38. package/src/panels/index.ts +0 -4
  39. package/src/panels/intelligence-panel.ts +212 -86
  40. package/src/panels/knowledge-graph-panel.ts +461 -101
  41. package/src/panels/local-auth-panel.ts +240 -28
  42. package/src/panels/marketplace-panel.ts +193 -26
  43. package/src/panels/memory-panel.ts +78 -77
  44. package/src/panels/ops-control-panel.ts +146 -29
  45. package/src/panels/ops-strategy-panel.ts +72 -4
  46. package/src/panels/orchestration-panel.ts +231 -69
  47. package/src/panels/panel-list-panel.ts +154 -131
  48. package/src/panels/panel-manager.ts +97 -9
  49. package/src/panels/plan-dashboard-panel.ts +333 -17
  50. package/src/panels/plugins-panel.ts +184 -29
  51. package/src/panels/policy-panel.ts +210 -38
  52. package/src/panels/polish-core.ts +7 -2
  53. package/src/panels/polish.ts +23 -4
  54. package/src/panels/project-planning-answer-actions.ts +134 -0
  55. package/src/panels/project-planning-panel.ts +62 -113
  56. package/src/panels/provider-health-panel.ts +434 -518
  57. package/src/panels/provider-health-routes.ts +203 -0
  58. package/src/panels/provider-health-tracker.ts +194 -6
  59. package/src/panels/provider-health-views.ts +560 -0
  60. package/src/panels/qr-panel.ts +116 -30
  61. package/src/panels/remote-panel.ts +114 -36
  62. package/src/panels/routes-panel.ts +63 -22
  63. package/src/panels/sandbox-panel.ts +174 -42
  64. package/src/panels/scrollable-list-panel.ts +19 -135
  65. package/src/panels/security-panel.ts +133 -33
  66. package/src/panels/services-panel.ts +116 -64
  67. package/src/panels/session-browser-panel.ts +73 -2
  68. package/src/panels/session-maintenance.ts +4 -122
  69. package/src/panels/settings-sync-panel.ts +335 -72
  70. package/src/panels/skills-panel.ts +157 -89
  71. package/src/panels/subscription-panel.ts +86 -33
  72. package/src/panels/symbol-outline-panel.ts +248 -108
  73. package/src/panels/system-messages-panel.ts +114 -13
  74. package/src/panels/tasks-panel.ts +326 -139
  75. package/src/panels/thinking-panel.ts +43 -10
  76. package/src/panels/token-budget-panel.ts +194 -18
  77. package/src/panels/tool-inspector-panel.ts +144 -34
  78. package/src/panels/types.ts +40 -4
  79. package/src/panels/work-plan-panel.ts +280 -17
  80. package/src/panels/worktree-panel.ts +175 -42
  81. package/src/panels/wrfc-panel.ts +116 -24
  82. package/src/renderer/conversation-overlays.ts +25 -11
  83. package/src/renderer/footer-tips.ts +41 -0
  84. package/src/renderer/fullscreen-primitives.ts +22 -16
  85. package/src/renderer/help-overlay.ts +91 -14
  86. package/src/renderer/hint-grammar.ts +52 -0
  87. package/src/renderer/layout.ts +7 -7
  88. package/src/renderer/modal-factory.ts +23 -15
  89. package/src/renderer/model-picker-overlay.ts +21 -7
  90. package/src/renderer/overlay-box.ts +16 -10
  91. package/src/renderer/process-indicator.ts +3 -1
  92. package/src/renderer/progress.ts +5 -4
  93. package/src/renderer/search-overlay.ts +27 -6
  94. package/src/renderer/session-picker-modal.ts +6 -4
  95. package/src/renderer/settings-modal.ts +70 -10
  96. package/src/renderer/shell-surface.ts +41 -15
  97. package/src/renderer/status-glyphs.ts +11 -9
  98. package/src/renderer/theme.ts +60 -3
  99. package/src/renderer/ui-factory.ts +41 -30
  100. package/src/renderer/ui-primitives.ts +23 -2
  101. package/src/runtime/bootstrap-shell.ts +35 -18
  102. package/src/runtime/diagnostics/panels/index.ts +0 -3
  103. package/src/shell/ui-openers.ts +14 -22
  104. package/src/utils/format-duration.ts +2 -2
  105. package/src/utils/splash-lines.ts +44 -3
  106. package/src/version.ts +1 -1
  107. package/src/work-plans/work-plan-store.ts +13 -0
  108. package/src/panels/agent-logs-panel.ts +0 -635
  109. package/src/panels/agent-logs-shared.ts +0 -129
  110. package/src/panels/context-visualizer-panel.ts +0 -238
  111. package/src/panels/forensics-panel.ts +0 -378
  112. package/src/panels/provider-account-snapshot.ts +0 -259
  113. package/src/panels/provider-accounts-panel.ts +0 -255
  114. package/src/panels/provider-stats-panel.ts +0 -391
  115. package/src/panels/schedule-panel.ts +0 -365
  116. package/src/panels/watchers-panel.ts +0 -213
  117. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  118. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -3,20 +3,43 @@ import { createEmptyLine } from '../types/grid.ts';
3
3
  import { basename } from 'node:path';
4
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
5
  import { buildAlignedRow, buildKeyValueLine, buildKeyboardHints, buildPanelLine, buildPanelWorkspace, DEFAULT_PANEL_PALETTE, resolvePrimaryScrollableSection, type ColumnSpec, type PanelWorkspaceSection } from './polish.ts';
6
- import { summarizeWorktreeOwnership, type WorktreeRegistry, type WorktreeStatusRecord } from '@/runtime/index.ts';
6
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
7
+ import { summarizeWorktreeOwnership, type WorktreeStatusRecord } from '@/runtime/index.ts';
8
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
9
+ import type { PanelIntegrationContext } from './types.ts';
7
10
 
8
- const C = {
9
- ...DEFAULT_PANEL_PALETTE,
10
- dim: '#475569',
11
- info: '#38bdf8',
12
- ok: '#22c55e',
13
- warn: '#eab308',
14
- headerBg: '#1e293b',
15
- } as const;
11
+ // Base chrome only — state colors and text tokens come straight from
12
+ // DEFAULT_PANEL_PALETTE (WO-002).
13
+ const C = DEFAULT_PANEL_PALETTE;
14
+
15
+ /**
16
+ * Structural shape the panel actually needs from a worktree registry. The
17
+ * real `WorktreeRegistry` (from the SDK) satisfies this directly; it has no
18
+ * `subscribe()` method today, so the panel falls back to polling when one
19
+ * isn't provided (see constructor). Kept narrow and `Like`-suffixed per the
20
+ * project's duck-typing convention (see `agent-inspector-shared.ts`).
21
+ */
22
+ export interface WorktreeRegistryLike {
23
+ list(): Promise<WorktreeStatusRecord[]>;
24
+ attach(path: string, target: { sessionId?: string; taskId?: string }): void;
25
+ setState(path: string, state: WorktreeStatusRecord['state']): void;
26
+ cleanup(path: string): Promise<void>;
27
+ /** Optional live-update hook; when present the panel prefers it over polling. */
28
+ subscribe?(listener: () => void): () => void;
29
+ }
30
+
31
+ /** What a pending destructive confirm will do once the user confirms. */
32
+ interface WorktreeConfirmSubject {
33
+ readonly path: string;
34
+ readonly action: 'discard' | 'cleanup';
35
+ }
36
+
37
+ /** Poll cadence used only when the registry has no `subscribe()` method. */
38
+ const POLL_INTERVAL_MS = 5_000;
16
39
 
17
40
  function stateColor(state: WorktreeStatusRecord['state']): string {
18
41
  switch (state) {
19
- case 'active': return C.ok;
42
+ case 'active': return C.good;
20
43
  case 'paused':
21
44
  case 'kept': return C.warn;
22
45
  default: return C.dim;
@@ -61,16 +84,37 @@ function buildWorktreeRow(width: number, row: WorktreeStatusRecord, selected: bo
61
84
  );
62
85
  }
63
86
 
87
+ const WORKTREE_HINTS = [
88
+ { keys: '↑/↓', label: 'select' },
89
+ { keys: 'p/u/k', label: 'pause/resume/keep' },
90
+ { keys: 'd/c', label: 'discard/cleanup' },
91
+ { keys: 'enter', label: 'jump to session/task' },
92
+ { keys: 'r', label: 'refresh' },
93
+ ] as const;
94
+
64
95
  export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
65
96
  private rows: WorktreeStatusRecord[] = [];
66
97
  private loading = false;
67
- private readonly worktreeRegistry: WorktreeRegistry;
98
+ private confirm: ConfirmState<WorktreeConfirmSubject> | null = null;
99
+ private pendingJump: { targetPanel: 'sessions' | 'tasks' } | null = null;
100
+ private readonly worktreeRegistry: WorktreeRegistryLike;
101
+ private readonly requestRender: () => void;
102
+ private unsubscribe: (() => void) | null = null;
68
103
 
69
- public constructor(worktreeRegistry: WorktreeRegistry) {
70
- super('worktrees', 'Worktrees', 'W', 'monitoring');
104
+ public constructor(worktreeRegistry: WorktreeRegistryLike, requestRender: () => void = () => {}) {
105
+ super('worktrees', 'Worktrees', '', 'automation-control');
71
106
  this.showSelectionGutter = true; // I5: non-color selection affordance
72
107
  this.worktreeRegistry = worktreeRegistry;
108
+ this.requestRender = requestRender;
73
109
  void this.refresh();
110
+ // Live state: prefer a registry subscription when the caller provides one
111
+ // (matches the mock shape used by contract tests); otherwise poll, so the
112
+ // real SDK WorktreeRegistry (which has no subscribe() today) still stays live.
113
+ if (typeof this.worktreeRegistry.subscribe === 'function') {
114
+ this.unsubscribe = this.worktreeRegistry.subscribe(() => { void this.refresh(); });
115
+ } else {
116
+ this.registerTimer(setInterval(() => { void this.refresh(); }, POLL_INTERVAL_MS));
117
+ }
74
118
  }
75
119
 
76
120
  public override onActivate(): void {
@@ -78,14 +122,102 @@ export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
78
122
  if (!this.loading) void this.refresh();
79
123
  }
80
124
 
81
- public handleInput(key: string): boolean {
125
+ public override onDestroy(): void {
126
+ super.onDestroy();
127
+ if (this.unsubscribe) {
128
+ this.unsubscribe();
129
+ this.unsubscribe = null;
130
+ }
131
+ }
132
+
133
+ public override handleInput(key: string): boolean {
134
+ if (this.lastError !== null) this.clearError();
135
+
136
+ const confirmResult = handleConfirmInput(this.confirm, key);
137
+ if (confirmResult === 'confirmed') {
138
+ const subject = this.confirm!.subject;
139
+ this.confirm = null;
140
+ if (subject.action === 'discard') {
141
+ this.worktreeRegistry.setState(subject.path, 'discard');
142
+ void this.refresh();
143
+ } else {
144
+ void this._cleanup(subject.path);
145
+ }
146
+ this.markDirty();
147
+ return true;
148
+ }
149
+ if (confirmResult === 'cancelled') {
150
+ this.confirm = null;
151
+ this.markDirty();
152
+ return true;
153
+ }
154
+ if (confirmResult === 'absorbed') return true;
155
+
82
156
  if (key === 'r') {
83
157
  void this.refresh();
84
158
  return true;
85
159
  }
160
+
161
+ const selected = this.getSelectedItem();
162
+
163
+ if (key === 'p' && selected && selected.state !== 'paused') {
164
+ this.worktreeRegistry.setState(selected.path, 'paused');
165
+ void this.refresh();
166
+ return true;
167
+ }
168
+ if (key === 'u' && selected && selected.state !== 'active') {
169
+ this.worktreeRegistry.setState(selected.path, 'active');
170
+ void this.refresh();
171
+ return true;
172
+ }
173
+ if (key === 'k' && selected && selected.state !== 'kept') {
174
+ this.worktreeRegistry.setState(selected.path, 'kept');
175
+ void this.refresh();
176
+ return true;
177
+ }
178
+ if (key === 'd' && selected && selected.state !== 'discard') {
179
+ this.confirm = {
180
+ subject: { path: selected.path, action: 'discard' },
181
+ label: `worktree ${basename(selected.path)} (${selected.branch})`,
182
+ verb: 'Discard',
183
+ };
184
+ this.markDirty();
185
+ return true;
186
+ }
187
+ if (key === 'c' && selected) {
188
+ this.confirm = {
189
+ subject: { path: selected.path, action: 'cleanup' },
190
+ label: `worktree ${basename(selected.path)} (${selected.branch})`,
191
+ verb: 'Clean up',
192
+ };
193
+ this.markDirty();
194
+ return true;
195
+ }
196
+ if ((key === 'enter' || key === 'return') && selected && (selected.sessionId || selected.taskId)) {
197
+ this.pendingJump = { targetPanel: selected.sessionId ? 'sessions' : 'tasks' };
198
+ return true;
199
+ }
200
+
86
201
  return super.handleInput(key);
87
202
  }
88
203
 
204
+ /**
205
+ * Enter on an attached worktree row jumps to the owning session/task panel.
206
+ * The actual `PanelManager.open()` call requires the integration context,
207
+ * which is only available here (invoked right after `handleInput` returns
208
+ * `true` for the same key) — same staged-pending-action pattern as
209
+ * `incident-review-panel.ts`.
210
+ */
211
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
212
+ if (this.pendingJump) {
213
+ const jump = this.pendingJump;
214
+ this.pendingJump = null;
215
+ ctx.panelManager.open(jump.targetPanel);
216
+ return true;
217
+ }
218
+ return false;
219
+ }
220
+
89
221
  protected getItems(): readonly WorktreeStatusRecord[] {
90
222
  return this.rows;
91
223
  }
@@ -103,11 +235,33 @@ export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
103
235
  } finally {
104
236
  this.loading = false;
105
237
  this.markDirty();
238
+ this.requestRender();
239
+ }
240
+ }
241
+
242
+ private async _cleanup(path: string): Promise<void> {
243
+ try {
244
+ await this.worktreeRegistry.cleanup(path);
245
+ } catch (err) {
246
+ this.setError(summarizeError(err));
247
+ } finally {
248
+ await this.refresh();
106
249
  }
107
250
  }
108
251
 
109
252
  public render(width: number, height: number): Line[] {
110
253
  this.needsRender = false;
254
+
255
+ if (this.confirm) {
256
+ const lines = buildPanelWorkspace(width, height, {
257
+ title: 'Worktree Control Room',
258
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
259
+ palette: C,
260
+ });
261
+ while (lines.length < height) lines.push(createEmptyLine(width));
262
+ return lines.slice(0, height);
263
+ }
264
+
111
265
  const sections: PanelWorkspaceSection[] = [];
112
266
 
113
267
  if (this.loading && this.rows.length === 0) {
@@ -127,7 +281,7 @@ export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
127
281
  lines: [
128
282
  buildKeyValueLine(width, [
129
283
  { label: 'total', value: String(summary.total), valueColor: C.value },
130
- { label: 'active', value: String(summary.active), valueColor: C.ok },
284
+ { label: 'active', value: String(summary.active), valueColor: C.good },
131
285
  { label: 'paused', value: String(summary.paused), valueColor: summary.paused > 0 ? C.warn : C.dim },
132
286
  { label: 'cleanup', value: String(summary.pendingCleanup), valueColor: summary.pendingCleanup > 0 ? C.warn : C.dim },
133
287
  ], C),
@@ -139,19 +293,7 @@ export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
139
293
  ], C),
140
294
  ],
141
295
  });
142
- sections.push({
143
- title: 'Next Actions',
144
- lines: [
145
- buildPanelLine(width, [[
146
- summary.pendingCleanup > 0 || summary.discard > 0
147
- ? ' Review pending-cleanup and discard-marked worktrees before they drift from orchestrator ownership.'
148
- : ' Worktree ownership is healthy. Use the task and session links below for restore, merge, or cleanup review.',
149
- summary.pendingCleanup > 0 || summary.discard > 0 ? C.warn : C.dim,
150
- ]]),
151
- buildPanelLine(width, [[' /worktree task <task-id> /worktree session <session-id> /worktree inspect <path>', C.info]]),
152
- ],
153
- });
154
- const selected = this.rows[this.selectedIndex]!;
296
+ const selected = this.getSelectedItem()!;
155
297
  const detailSection: PanelWorkspaceSection = {
156
298
  title: 'Details',
157
299
  lines: [
@@ -165,23 +307,11 @@ export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
165
307
  : ' Unattached worktree detected. Review whether it should be attached, kept, discarded, or cleaned up.',
166
308
  selected.sessionId || selected.taskId ? C.info : C.warn,
167
309
  ]]),
168
- buildPanelLine(width, [[
169
- selected.state === 'paused'
170
- ? ` Next: /worktree resume ${selected.path}`
171
- : selected.state === 'discard' || selected.state === 'pending-cleanup'
172
- ? ` Next: /worktree cleanup ${selected.path}`
173
- : selected.taskId
174
- ? ` Next: /worktree task ${selected.taskId}`
175
- : selected.sessionId
176
- ? ` Next: /worktree session ${selected.sessionId}`
177
- : ` Next: /worktree inspect ${selected.path}`,
178
- C.dim,
179
- ]]),
180
310
  ],
181
311
  };
182
312
  const resolvedWorktreesSection = resolvePrimaryScrollableSection(width, height, {
183
313
  intro: 'Orchestrator-owned worktree lifecycle, attachments, pause/resume posture, and cleanup state.',
184
- footerLines: [buildKeyboardHints(width, [{ keys: '↑/↓', label: 'select' }, { keys: 'r', label: 'refresh' }, { keys: '/worktree inspect', label: '<path>' }, { keys: 'attach·pause·resume·keep·discard·cleanup', label: '' }], C)],
314
+ footerLines: [buildKeyboardHints(width, WORKTREE_HINTS, C)],
185
315
  palette: C,
186
316
  beforeSections: sections,
187
317
  section: {
@@ -214,11 +344,14 @@ export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
214
344
  sections.push(detailSection);
215
345
  }
216
346
 
347
+ const errorLine = this.renderErrorLine(width);
348
+ if (errorLine) sections.push({ title: 'Error', lines: [errorLine] });
349
+
217
350
  const lines = buildPanelWorkspace(width, height, {
218
351
  title: 'Worktree Control Room',
219
352
  intro: 'Orchestrator-owned worktree lifecycle, attachments, pause/resume posture, and cleanup state.',
220
353
  sections,
221
- footerLines: [buildKeyboardHints(width, [{ keys: '↑/↓', label: 'select' }, { keys: 'r', label: 'refresh' }, { keys: '/worktree inspect', label: '<path>' }, { keys: 'attach·pause·resume·keep·discard·cleanup', label: '' }], C)],
354
+ footerLines: [buildKeyboardHints(width, WORKTREE_HINTS, C)],
222
355
  palette: C,
223
356
  });
224
357
  while (lines.length < height) lines.push(createEmptyLine(width));
@@ -21,6 +21,9 @@ import {
21
21
  handleConfirmInput,
22
22
  } from './confirm-state.ts';
23
23
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
24
+ import { formatDuration } from '../utils/format-duration.ts';
25
+ import { AgentInspectorPanel } from './agent-inspector-panel.ts';
26
+ import type { PanelIntegrationContext } from './types.ts';
24
27
 
25
28
  // ---------------------------------------------------------------------------
26
29
  // Constants
@@ -73,6 +76,17 @@ export class WrfcPanel extends BasePanel {
73
76
  private chains: WrfcChain[] = [];
74
77
  private selectedIndex = 0;
75
78
  private scrollOffset = 0;
79
+
80
+ /**
81
+ * The chain under the cursor. This panel owns its own selection state
82
+ * (`selectedIndex` navigates `this.chains` directly), so every selected-row
83
+ * read routes through this one accessor — indexing `this.chains` by the
84
+ * cursor directly is banned by the no-raw-selectedindex-read architecture rule.
85
+ */
86
+ private selectedChain(): WrfcChain | undefined {
87
+ return this.chains.at(this.selectedIndex);
88
+ }
89
+
76
90
  private expandedChainIds = new Set<string>();
77
91
  private unsubscribers: Array<() => void> = [];
78
92
  /** Last event timestamp per chain id, for stall detection. */
@@ -137,10 +151,64 @@ export class WrfcPanel extends BasePanel {
137
151
  case 'enter': this.toggleExpanded(); return true;
138
152
  case 'c': this.beginCancelConfirm(); return true;
139
153
  case 'r': this.doResume(); return true;
154
+ // 'a' jumps to the selected chain's owner agent in the Inspector. The
155
+ // actual navigation happens in handlePanelIntegrationAction (it needs
156
+ // the PanelManager); consuming the key here just requires a selected
157
+ // chain to exist so the router below fires next.
158
+ case 'a': return this.selectedChain() !== undefined;
140
159
  default: return false;
141
160
  }
142
161
  }
143
162
 
163
+ /**
164
+ * Cross-panel jump: 'a' on a selected chain opens the Inspector focused on
165
+ * that chain's owner agent (inspectAgent, reverse of the forward jump
166
+ * agent-detail-modal.ts makes from an agent record's wrfcId to its chain).
167
+ */
168
+ handlePanelIntegrationAction(key: string, ctx: PanelIntegrationContext): boolean {
169
+ if (key !== 'a') return false;
170
+ const chain = this.selectedChain();
171
+ if (!chain) return false;
172
+ const inspector = ctx.panelManager.open('inspector');
173
+ if (inspector instanceof AgentInspectorPanel) {
174
+ inspector.inspectAgent(chain.ownerAgentId);
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ /**
181
+ * Mouse-wheel scroll: pan the chain list viewport without moving the
182
+ * selection cursor (BasePanel's default forwards wheel deltas into
183
+ * handleInput('up'/'down'), which would also move the selected row).
184
+ * scrollOffset is a hint consumed by resolveScrollablePanelSection's
185
+ * tracked window on the next render, which clamps it into range and keeps
186
+ * the selected row inside the guard band — so an over-large delta here is
187
+ * always safe.
188
+ */
189
+ override handleScroll(deltaRows: number): boolean {
190
+ if (this.confirmCancel) return false;
191
+ const rows = Math.trunc(deltaRows);
192
+ if (rows === 0) return false;
193
+ const next = Math.max(0, this.scrollOffset + rows);
194
+ if (next === this.scrollOffset) return false;
195
+ this.scrollOffset = next;
196
+ this.markDirty();
197
+ return true;
198
+ }
199
+
200
+ /**
201
+ * Cross-panel jump target: select the chain by id and expand it. Used by
202
+ * WorkPlanPanel's 'w' jump key when an item links a wrfcId.
203
+ */
204
+ selectChain(chainId: string): void {
205
+ const index = this.chains.findIndex((chain) => chain.id === chainId);
206
+ if (index < 0) return;
207
+ this.selectedIndex = index;
208
+ this.expandedChainIds.add(chainId);
209
+ this.markDirty();
210
+ }
211
+
144
212
  // -------------------------------------------------------------------------
145
213
  // Render
146
214
  // -------------------------------------------------------------------------
@@ -159,7 +227,8 @@ export class WrfcPanel extends BasePanel {
159
227
  ' No WRFC chains yet',
160
228
  'WRFC chains appear here as review/fix cycles execute. Expanded rows show scores, gates, issues, and failure detail.',
161
229
  [
162
- { command: '/wrfc run <task>', summary: 'start a write-review-fix-commit chain for a task' },
230
+ { command: '/teamwork create-mode local-engineer <task>', summary: 'run an engineer task through the write-review-fix-commit loop (reviewMode: wrfc)' },
231
+ { command: '/teamwork modes', summary: 'list every teamwork mode and see which ones use reviewMode wrfc' },
163
232
  ],
164
233
  DEFAULT_PANEL_PALETTE,
165
234
  ),
@@ -194,21 +263,25 @@ export class WrfcPanel extends BasePanel {
194
263
  const stalled = this.isStalled(chain, now);
195
264
 
196
265
  if (isSelected) selectedLineIndex = chainLines.length;
197
- chainLines.push(...this.renderChainRow(chain, width, isSelected, isExpanded, rowBg, rowFg, stalled));
266
+ chainLines.push(...this.renderChainRow(chain, width, isSelected, isExpanded, rowBg, rowFg, stalled, now));
198
267
 
199
268
  if (isExpanded) {
200
269
  chainLines.push(...this.renderChainDetail(chain, width, 12));
201
270
  }
202
271
  }
203
272
 
204
- const selectedChain = this.chains[this.selectedIndex];
273
+ const selectedChain = this.selectedChain();
205
274
  const selectedLines: Line[] = selectedChain
206
275
  ? [
207
276
  buildPanelLine(width, [
208
277
  [' State ', DEFAULT_PANEL_PALETTE.label],
209
278
  [stateLabel(selectedChain.state), stateColor(selectedChain.state)],
210
- [' Task ', DEFAULT_PANEL_PALETTE.label],
211
- [truncate(selectedChain.task, Math.max(8, width - 24)), DEFAULT_PANEL_PALETTE.value],
279
+ [TERMINAL_STATES.includes(selectedChain.state) ? ' Duration ' : ' Elapsed ', DEFAULT_PANEL_PALETTE.label],
280
+ [this.durationLabel(selectedChain, now), DEFAULT_PANEL_PALETTE.info],
281
+ ]),
282
+ buildPanelLine(width, [
283
+ [' Task ', DEFAULT_PANEL_PALETTE.label],
284
+ [truncate(selectedChain.task, Math.max(8, width - 8)), DEFAULT_PANEL_PALETTE.value],
212
285
  ]),
213
286
  buildPanelLine(width, [
214
287
  [' Reviews ', DEFAULT_PANEL_PALETTE.label],
@@ -283,7 +356,7 @@ export class WrfcPanel extends BasePanel {
283
356
  } : null;
284
357
 
285
358
  // Footer: show resume-disabled reason for the selected chain.
286
- const selectedForFooter = this.chains[this.selectedIndex];
359
+ const selectedForFooter = this.selectedChain();
287
360
  const resumeReason = selectedForFooter ? this.resumeDisabledReason(selectedForFooter) : null;
288
361
  const footerLines: Line[] = [
289
362
  buildPanelLine(width, [
@@ -292,6 +365,7 @@ export class WrfcPanel extends BasePanel {
292
365
  [' c', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
293
366
  [' r', resumeReason ? DEFAULT_PANEL_PALETTE.dim : DEFAULT_PANEL_PALETTE.info],
294
367
  [resumeReason ? ` resume (${resumeReason})` : ' resume', DEFAULT_PANEL_PALETTE.dim],
368
+ [' a', DEFAULT_PANEL_PALETTE.info], [' jump to agent', DEFAULT_PANEL_PALETTE.dim],
295
369
  ]),
296
370
  ];
297
371
 
@@ -353,6 +427,7 @@ export class WrfcPanel extends BasePanel {
353
427
  bg: string,
354
428
  fg: string,
355
429
  stalled = false,
430
+ now: number = Date.now(),
356
431
  ): Line[] {
357
432
  const stateCol = stalled ? C.warn : stateColor(chain.state);
358
433
  const stateTag = stalled
@@ -367,6 +442,7 @@ export class WrfcPanel extends BasePanel {
367
442
  ? ` ${chain.reviewScores[chain.reviewScores.length - 1].toFixed(1)}/10`
368
443
  : '';
369
444
  const stalledBadge = stalled ? ' [STALLED]' : '';
445
+ const durationTag = ` ${this.durationLabel(chain, now)}`;
370
446
  // Constraint badge: c:sat/total — only when constraints exist
371
447
  let constraintBadge = '';
372
448
  if (chain.constraints.length > 0) {
@@ -375,7 +451,7 @@ export class WrfcPanel extends BasePanel {
375
451
  const satisfied = findings ? findings.filter(f => f.satisfied).length : 0;
376
452
  constraintBadge = ` c:${satisfied}/${total}`;
377
453
  }
378
- const rightInfo = `${stalledBadge}${latestScore}${fixes}${cycles}${constraintBadge} `;
454
+ const rightInfo = `${stalledBadge}${durationTag}${latestScore}${fixes}${cycles}${constraintBadge} `;
379
455
 
380
456
  // Compute how much space the task text can use, then check if rightInfo fits.
381
457
  // If the terminal is narrow and rightInfo would overflow, omit it entirely
@@ -433,6 +509,11 @@ export class WrfcPanel extends BasePanel {
433
509
  }
434
510
 
435
511
  private renderChainDetail(chain: WrfcChain, width: number, maxLines: number): Line[] {
512
+ // Build the FULL detail content first (no per-section maxLines guards
513
+ // below), then apply one cap at the end so a truncation always ends with
514
+ // a visible "+N more" indicator instead of silently dropping content —
515
+ // the constraint sub-list keeps its own independent (+N more) cap since
516
+ // that is documented, unrelated badge/marker behavior (PRESERVE).
436
517
  const lines: Line[] = [];
437
518
  const indent = ' ';
438
519
 
@@ -459,13 +540,12 @@ export class WrfcPanel extends BasePanel {
459
540
  }
460
541
 
461
542
  // Constraints section (between Cycles and Gates)
462
- if (chain.constraints.length > 0 && lines.length < maxLines) {
543
+ if (chain.constraints.length > 0) {
463
544
  lines.push(buildStyledPanelLine(width, [{ text: `${indent}Constraints`, fg: C.label }]));
464
545
  const MAX_CONSTRAINTS = 10;
465
546
  const findings = chain.reviewerReport?.constraintFindings;
466
547
  const displayed = chain.constraints.slice(0, MAX_CONSTRAINTS);
467
548
  for (const constraint of displayed) {
468
- if (lines.length >= maxLines) break;
469
549
  const marker = constraintStatusMarker(constraint, findings);
470
550
  const statusTag = marker.tag;
471
551
  const rowPrefix = `${indent} ${statusTag} `;
@@ -479,7 +559,7 @@ export class WrfcPanel extends BasePanel {
479
559
  ]));
480
560
  }
481
561
  const remaining = chain.constraints.length - MAX_CONSTRAINTS;
482
- if (remaining > 0 && lines.length < maxLines) {
562
+ if (remaining > 0) {
483
563
  lines.push(buildStyledPanelLine(width, [
484
564
  { text: `${indent} (+${remaining} more)`, fg: C.dim, dim: true },
485
565
  ]));
@@ -490,7 +570,6 @@ export class WrfcPanel extends BasePanel {
490
570
  if (chain.gateResults && chain.gateResults.length > 0) {
491
571
  lines.push(buildStyledPanelLine(width, [{ text: `${indent}Gates`, fg: C.label }]));
492
572
  for (const gate of chain.gateResults) {
493
- if (lines.length >= maxLines) break;
494
573
  lines.push(this.renderGateResult(gate, width, indent + ' '));
495
574
  }
496
575
  }
@@ -503,10 +582,9 @@ export class WrfcPanel extends BasePanel {
503
582
  }
504
583
 
505
584
  // Synthetic issues injected by controller (continuity violations)
506
- if (chain.syntheticIssues && chain.syntheticIssues.length > 0 && lines.length < maxLines) {
585
+ if (chain.syntheticIssues && chain.syntheticIssues.length > 0) {
507
586
  lines.push(buildStyledPanelLine(width, [{ text: `${indent}Controller flags`, fg: C.issueCrit, bold: true }]));
508
587
  for (const synthetic of chain.syntheticIssues) {
509
- if (lines.length >= maxLines) break;
510
588
  const prefix = `${indent} [CRIT] `;
511
589
  const descMax = Math.max(8, width - prefix.length);
512
590
  const desc = truncate(synthetic.description, descMax);
@@ -519,10 +597,9 @@ export class WrfcPanel extends BasePanel {
519
597
 
520
598
  // Issues from reviewer
521
599
  const issues = chain.reviewerReport?.issues ?? [];
522
- if (issues.length > 0 && lines.length < maxLines) {
600
+ if (issues.length > 0) {
523
601
  lines.push(buildStyledPanelLine(width, [{ text: `${indent}Issues`, fg: C.label }]));
524
602
  for (const issue of issues) {
525
- if (lines.length >= maxLines) break;
526
603
  const prefix = `${indent} ${issuePrefix(issue.severity)}`;
527
604
  const descMax = width - prefix.length;
528
605
  const desc = truncate(issue.description, Math.max(8, descMax));
@@ -534,7 +611,7 @@ export class WrfcPanel extends BasePanel {
534
611
  }
535
612
 
536
613
  // Error
537
- if (chain.error && lines.length < maxLines) {
614
+ if (chain.error) {
538
615
  const errPrefix = `${indent}Error `;
539
616
  lines.push(buildStyledPanelLine(width, [
540
617
  { text: errPrefix, fg: C.failed, bold: true },
@@ -543,11 +620,15 @@ export class WrfcPanel extends BasePanel {
543
620
  }
544
621
 
545
622
  // Divider after expanded section
546
- if (lines.length < maxLines) {
547
- lines.push(buildStyledPanelLine(width, [{ text: ' ' + '-'.repeat(Math.max(0, width - 4)) + ' ', fg: C.border, dim: true }]));
548
- }
549
-
550
- return lines.slice(0, maxLines);
623
+ lines.push(buildStyledPanelLine(width, [{ text: ' ' + '-'.repeat(Math.max(0, width - 4)) + ' ', fg: C.border, dim: true }]));
624
+
625
+ if (lines.length <= maxLines) return lines;
626
+ const visible = lines.slice(0, Math.max(0, maxLines - 1));
627
+ const hidden = lines.length - visible.length;
628
+ visible.push(buildStyledPanelLine(width, [
629
+ { text: `${indent}+${hidden} more (press a to inspect the owner agent for full detail)`, fg: C.dim, dim: true },
630
+ ]));
631
+ return visible;
551
632
  }
552
633
 
553
634
  private renderGateResult(gate: QualityGateResult, width: number, indent: string): Line {
@@ -575,7 +656,7 @@ export class WrfcPanel extends BasePanel {
575
656
  }
576
657
 
577
658
  private toggleExpanded(): void {
578
- const chain = this.chains[this.selectedIndex];
659
+ const chain = this.selectedChain();
579
660
  if (!chain) return;
580
661
  if (this.expandedChainIds.has(chain.id)) {
581
662
  this.expandedChainIds.delete(chain.id);
@@ -658,7 +739,7 @@ export class WrfcPanel extends BasePanel {
658
739
 
659
740
  /** Initiate cancel-confirm flow for the selected chain (noop if terminal). */
660
741
  private beginCancelConfirm(): void {
661
- const chain = this.chains[this.selectedIndex];
742
+ const chain = this.selectedChain();
662
743
  if (!chain || TERMINAL_STATES.includes(chain.state)) return;
663
744
  this.confirmCancel = {
664
745
  subject: chain.id,
@@ -673,7 +754,7 @@ export class WrfcPanel extends BasePanel {
673
754
  * Emits a visible noop reason when the chain is not resumable.
674
755
  */
675
756
  private doResume(): void {
676
- const chain = this.chains[this.selectedIndex];
757
+ const chain = this.selectedChain();
677
758
  if (!chain) return;
678
759
  if (!RESUMABLE_STATES.includes(chain.state)) return;
679
760
  this.deps.controller.resumeChain(chain.id);
@@ -695,4 +776,15 @@ export class WrfcPanel extends BasePanel {
695
776
  const last = this.lastEventAt.get(chain.id) ?? chain.createdAt;
696
777
  return (now - last) >= STALL_THRESHOLD_MS;
697
778
  }
779
+
780
+ /**
781
+ * Elapsed time since createdAt for an active chain, or total duration from
782
+ * createdAt to completedAt for a terminal one. createdAt/completedAt are
783
+ * already consumed for chain sort order (syncFromController) — this just
784
+ * surfaces the same fields as a human-readable label.
785
+ */
786
+ private durationLabel(chain: WrfcChain, now: number): string {
787
+ const end = chain.completedAt ?? now;
788
+ return formatDuration(Math.max(0, end - chain.createdAt));
789
+ }
698
790
  }