@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
@@ -6,19 +6,27 @@ import {
6
6
  buildPanelLine,
7
7
  buildPanelTitle,
8
8
  DEFAULT_PANEL_PALETTE,
9
+ extendPalette,
9
10
  } from './polish.ts';
11
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
10
12
  import { truncateDisplay } from '../utils/terminal-width.ts';
11
13
  import { renderQrMatrix, generateQrMatrix } from '../renderer/qr-renderer.ts';
12
14
  import { encodeConnectionPayload } from '@pellux/goodvibes-sdk/platform/pairing';
15
+ import type { UserAuthManager } from '@pellux/goodvibes-sdk/platform/security';
16
+ import type { UiControlPlaneSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
13
17
 
14
- const C = {
15
- ...DEFAULT_PANEL_PALETTE,
16
- url: '#38bdf8',
18
+ // Fixed-width placeholder — deliberately NOT derived from the real secret's
19
+ // length, so masking doesn't leak how long the token/password is.
20
+ const SECRET_MASK = '••••••••••••';
21
+
22
+ // Domain accents only; base chrome (header/headerBg/label/info) comes from
23
+ // DEFAULT_PANEL_PALETTE. qrFg/qrBg stay pure black/white — scanner contrast
24
+ // requirements override theming.
25
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
17
26
  token: '#a78bfa',
18
- hint: '#64748b',
19
- qrFg: '#000000',
20
- qrBg: '#ffffff',
21
- } as const;
27
+ qrFg: '#000000',
28
+ qrBg: '#ffffff',
29
+ } as const);
22
30
 
23
31
  /**
24
32
  * Connection info passed to the QR panel.
@@ -62,27 +70,82 @@ export class QrPanel extends BasePanel {
62
70
  private connectionInfo: QrPanelConnectionInfo;
63
71
  private readonly regenerateToken: RegenerateTokenFn | undefined;
64
72
  private readonly copyToClipboard: CopyToClipboardFn | undefined;
73
+ private readonly controlPlaneReadModel: UiReadModel<UiControlPlaneSnapshot> | undefined;
74
+ private readonly localUserAuthManager: Pick<UserAuthManager, 'inspect'> | undefined;
75
+ private readonly unsub: (() => void) | null;
65
76
  private lastStatus = '';
77
+ /** v toggles whether the token/password render in the clear. */
78
+ private revealed = false;
79
+ /** Set when r is pressed while a companion session is live — confirmed with y/Enter, cancelled with n/Esc. */
80
+ private confirmRegenerate: ConfirmState<null> | null = null;
66
81
 
67
82
  public constructor(
68
83
  connectionInfo: QrPanelConnectionInfo,
69
84
  regenerateToken?: RegenerateTokenFn,
70
85
  copyToClipboard?: CopyToClipboardFn,
86
+ controlPlaneReadModel?: UiReadModel<UiControlPlaneSnapshot>,
87
+ localUserAuthManager?: Pick<UserAuthManager, 'inspect'>,
71
88
  ) {
72
89
  super('qr-code', 'QR Code', 'Q', 'session');
73
90
  this.connectionInfo = connectionInfo;
74
91
  this.regenerateToken = regenerateToken;
75
92
  this.copyToClipboard = copyToClipboard;
93
+ this.controlPlaneReadModel = controlPlaneReadModel;
94
+ this.localUserAuthManager = localUserAuthManager;
95
+ this.unsub = controlPlaneReadModel ? controlPlaneReadModel.subscribe(() => this.markDirty()) : null;
96
+ }
97
+
98
+ public override onDestroy(): void {
99
+ this.unsub?.();
100
+ }
101
+
102
+ private hasLiveCompanionSession(): boolean {
103
+ return (this.localUserAuthManager?.inspect().sessionCount ?? 0) > 0;
104
+ }
105
+
106
+ private doRegenerate(): void {
107
+ if (!this.regenerateToken) return;
108
+ this.connectionInfo = this.regenerateToken();
109
+ this.lastStatus = 'Token regenerated.';
110
+ this.markDirty();
76
111
  }
77
112
 
78
113
  public handleInput(key: string): boolean {
114
+ if (this.confirmRegenerate) {
115
+ const result = handleConfirmInput(this.confirmRegenerate, key);
116
+ if (result === 'confirmed') {
117
+ this.confirmRegenerate = null;
118
+ this.doRegenerate();
119
+ return true;
120
+ }
121
+ if (result === 'cancelled') {
122
+ this.confirmRegenerate = null;
123
+ this.lastStatus = 'Regeneration cancelled.';
124
+ this.markDirty();
125
+ return true;
126
+ }
127
+ return true; // absorbed
128
+ }
79
129
  if (key === 'r') {
80
- if (this.regenerateToken) {
81
- this.connectionInfo = this.regenerateToken();
82
- this.lastStatus = 'Token regenerated.';
83
- } else {
130
+ if (!this.regenerateToken) {
84
131
  this.lastStatus = 'Regeneration not available.';
132
+ this.markDirty();
133
+ return true;
85
134
  }
135
+ if (this.hasLiveCompanionSession()) {
136
+ this.confirmRegenerate = {
137
+ subject: null,
138
+ label: 'pairing token — a companion session is live and will be disconnected',
139
+ verb: 'Regenerate',
140
+ };
141
+ this.markDirty();
142
+ return true;
143
+ }
144
+ this.doRegenerate();
145
+ return true;
146
+ }
147
+ if (key === 'v') {
148
+ this.revealed = !this.revealed;
86
149
  this.markDirty();
87
150
  return true;
88
151
  }
@@ -105,27 +168,32 @@ export class QrPanel extends BasePanel {
105
168
 
106
169
  const { url, token, username, password } = this.connectionInfo;
107
170
  const valueWidth = Math.max(0, width - 12);
171
+ const displayToken = this.revealed ? token : SECRET_MASK;
172
+ const displayPassword = password !== undefined ? (this.revealed ? password : SECRET_MASK) : undefined;
108
173
 
109
174
  // ── Title + purpose: tell the operator exactly what this code is for ────
110
175
  lines.push(buildPanelTitle(width, 'Companion Pairing', C));
111
176
  lines.push(
112
177
  buildPanelLine(width, [
113
- [' Scan with the GoodVibes companion app to pair this session.', C.hint],
178
+ [' Scan with the GoodVibes companion app to pair this session.', C.label],
114
179
  ]),
115
180
  );
116
181
 
117
182
  // ── Connection info header ─────────────────────────────────────────────
183
+ // Token and (if present) bootstrap password are masked by default — they
184
+ // are credentials, not display text — and only render in the clear while
185
+ // `revealed` is toggled on via v.
118
186
  lines.push(createEmptyLine(width));
119
187
  lines.push(
120
188
  buildPanelLine(width, [
121
189
  [' URL ', C.label],
122
- [truncateDisplay(url, valueWidth), C.url],
190
+ [truncateDisplay(url, valueWidth), C.info],
123
191
  ]),
124
192
  );
125
193
  lines.push(
126
194
  buildPanelLine(width, [
127
195
  [' Token ', C.label],
128
- [truncateDisplay(token, valueWidth), C.token],
196
+ [truncateDisplay(displayToken, valueWidth), C.token],
129
197
  ]),
130
198
  );
131
199
  lines.push(
@@ -134,11 +202,22 @@ export class QrPanel extends BasePanel {
134
202
  [truncateDisplay(username, valueWidth), C.value],
135
203
  ]),
136
204
  );
137
- if (password !== undefined) {
205
+ if (displayPassword !== undefined) {
138
206
  lines.push(
139
207
  buildPanelLine(width, [
140
208
  [' Password ', C.label],
141
- [truncateDisplay(password, valueWidth), C.value],
209
+ [truncateDisplay(displayPassword, valueWidth), C.value],
210
+ ]),
211
+ );
212
+ }
213
+
214
+ // ── Connected companions ──────────────────────────────────────────────
215
+ if (this.controlPlaneReadModel) {
216
+ const connected = this.controlPlaneReadModel.getSnapshot().activeClientIds.length;
217
+ lines.push(
218
+ buildPanelLine(width, [
219
+ [' Companions ', C.label],
220
+ [`connected: ${connected}`, connected > 0 ? C.good : C.dim],
142
221
  ]),
143
222
  );
144
223
  }
@@ -161,11 +240,15 @@ export class QrPanel extends BasePanel {
161
240
 
162
241
  lines.push(createEmptyLine(width));
163
242
 
164
- // ── Status message (ephemeral) ─────────────────────────────────────────
165
- if (this.lastStatus) {
166
- lines.push(
243
+ // ── Footer: confirm overlay (regenerate while a companion is live) takes
244
+ // priority over the ephemeral status message ──────────────────────────
245
+ const footerLines: Line[] = [];
246
+ if (this.confirmRegenerate) {
247
+ footerLines.push(...renderConfirmLines(width, this.confirmRegenerate));
248
+ } else if (this.lastStatus) {
249
+ footerLines.push(
167
250
  buildPanelLine(width, [
168
- [` ${this.lastStatus} `, C.hint],
251
+ [` ${this.lastStatus} `, C.label],
169
252
  ]),
170
253
  );
171
254
  }
@@ -174,21 +257,24 @@ export class QrPanel extends BasePanel {
174
257
  const hints: Array<{ keys: string; label: string }> = [];
175
258
  if (this.regenerateToken) hints.push({ keys: 'r', label: 'regenerate token' });
176
259
  if (this.copyToClipboard) hints.push({ keys: 'c', label: 'copy token' });
260
+ hints.push({ keys: 'v', label: this.revealed ? 'hide token' : 'reveal token' });
177
261
  const hintsLine = hints.length > 0
178
262
  ? buildKeyboardHints(width, hints, C)
179
263
  : buildPanelLine(width, [[' Pairing is read-only in this surface.', C.dim]]);
264
+ footerLines.push(hintsLine);
180
265
 
181
- // Push hints at the bottom if we have room, otherwise append after QR
182
- const remaining = height - lines.length;
183
- if (remaining > 2) {
184
- // Fill with empty lines to push hints toward bottom
185
- const fillCount = Math.max(0, remaining - 2);
186
- for (let i = 0; i < fillCount; i++) {
187
- lines.push(createEmptyLine(width));
188
- }
266
+ // Push the footer at the bottom if we have room, otherwise append it
267
+ // directly after the QR code.
268
+ const remaining = height - lines.length - footerLines.length;
269
+ if (remaining > 0) {
270
+ for (let i = 0; i < remaining; i++) lines.push(createEmptyLine(width));
189
271
  }
190
- lines.push(hintsLine);
272
+ lines.push(...footerLines);
191
273
 
192
- return lines;
274
+ // Clamp to the exact requested height — the QR matrix's row count is
275
+ // driven by payload size, not by `height`, so it can legitimately
276
+ // overflow a small pane; never hand back more (or fewer) lines than asked.
277
+ while (lines.length < height) lines.push(createEmptyLine(width));
278
+ return lines.slice(0, height);
193
279
  }
194
280
  }
@@ -1,11 +1,11 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { BasePanel } from './base-panel.ts';
4
+ import type { PanelIntegrationContext } from './types.ts';
4
5
  import type { UiReadModel, UiRemoteSnapshot } from '../runtime/ui-read-models.ts';
5
6
  import {
6
7
  buildDetailBlock,
7
8
  buildEmptyState,
8
- buildGuidanceLine,
9
9
  buildPanelListRow,
10
10
  buildPanelLine,
11
11
  buildSummaryBlock,
@@ -17,28 +17,22 @@ import {
17
17
  import { truncateDisplay } from '../utils/terminal-width.ts';
18
18
  import { getTrackedVisibleWindow } from '../renderer/surface-layout.ts';
19
19
 
20
- const C = {
21
- ...DEFAULT_PANEL_PALETTE,
22
- header: '#94a3b8',
23
- headerBg: '#1e293b',
24
- dim: '#475569',
25
- ok: '#22c55e',
26
- warn: '#eab308',
27
- error: '#ef4444',
28
- } as const;
20
+ // Base chrome only — title band, state colors, and text tokens all come
21
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
22
+ const C = DEFAULT_PANEL_PALETTE;
29
23
 
30
24
  function stateColor(state: string): string {
31
25
  switch (state) {
32
26
  case 'connected':
33
27
  case 'syncing':
34
- return C.ok;
28
+ return C.good;
35
29
  case 'degraded':
36
30
  case 'reconnecting':
37
31
  case 'authenticating':
38
32
  case 'initializing':
39
33
  return C.warn;
40
34
  case 'terminal_failure':
41
- return C.error;
35
+ return C.bad;
42
36
  default:
43
37
  return C.dim;
44
38
  }
@@ -52,15 +46,39 @@ function truncate(text: string, width: number): string {
52
46
  return truncateDisplay(text, width);
53
47
  }
54
48
 
49
+ // Splits a machine-generated slash-command string (e.g. `/remote show foo`)
50
+ // into the { name, args } shape ctx.executeCommand expects — the same
51
+ // leading-slash-stripping approach panel-integration-actions.ts uses for
52
+ // ApprovalPanel's dispatched command.
53
+ function parseCommand(command: string): { name: string; args: string[] } | null {
54
+ const parts = command.replace(/^\//, '').split(/\s+/).filter(Boolean);
55
+ const [name, ...args] = parts;
56
+ return name ? { name, args } : null;
57
+ }
58
+
59
+ // Mirrors RemoteSupervisor's own stateIsDegraded()/degradedConnections
60
+ // definition (sdk/platform/runtime/remote/supervisor.js) so "degraded/stale"
61
+ // means the same thing here as it does in the posture summary above.
62
+ function isSupervisorEntryDegraded(entry: { transportState: string; heartbeat: { status: string } }): boolean {
63
+ const degradedTransport = entry.transportState === 'degraded'
64
+ || entry.transportState === 'reconnecting'
65
+ || entry.transportState === 'terminal_failure';
66
+ return degradedTransport || entry.heartbeat.status !== 'fresh';
67
+ }
68
+
55
69
  export class RemotePanel extends BasePanel {
56
70
  private readonly readModel?: UiReadModel<UiRemoteSnapshot>;
57
71
  private readonly unsub: (() => void) | null;
58
72
  private selectedIndex = 0;
59
73
  private scrollOffset = 0;
60
74
  private browseMode: 'connections' | 'contracts' = 'connections';
75
+ // Set by handleInput (enter/r) and consumed on the very next
76
+ // handlePanelIntegrationAction dispatch of that same key, mirroring the
77
+ // token-budget-panel pattern — handleInput has no ctx.executeCommand.
78
+ private pendingCommand: { name: string; args: string[] } | null = null;
61
79
 
62
80
  public constructor(readModel?: UiReadModel<UiRemoteSnapshot>) {
63
- super('remote', 'Remote', 'R', 'monitoring');
81
+ super('remote', 'Remote', '', 'providers');
64
82
  this.readModel = readModel;
65
83
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
66
84
  }
@@ -70,6 +88,29 @@ export class RemotePanel extends BasePanel {
70
88
  }
71
89
 
72
90
  public handleInput(key: string): boolean {
91
+ // r = dispatch /remote recover for whatever is currently selected (or
92
+ // the runtime's first non-fresh session when nothing is selected).
93
+ if (this.readModel && key === 'r') {
94
+ const runnerId = this.getSelectedRunnerId();
95
+ const parsed = parseCommand(runnerId ? `/remote recover ${runnerId}` : '/remote recover');
96
+ if (parsed) {
97
+ this.pendingCommand = parsed;
98
+ return true;
99
+ }
100
+ }
101
+ // Enter on a degraded/stale supervisor selection dispatches its
102
+ // top recovery action (already machine-readable via .recovery[0].command)
103
+ // instead of requiring the operator to retype it.
104
+ if (this.readModel && (key === 'enter' || key === 'return')) {
105
+ const entry = this.getSelectedSupervisorEntry();
106
+ if (entry && isSupervisorEntryDegraded(entry) && entry.recovery.length > 0) {
107
+ const parsed = parseCommand(entry.recovery[0].command);
108
+ if (parsed) {
109
+ this.pendingCommand = parsed;
110
+ return true;
111
+ }
112
+ }
113
+ }
73
114
  const activeConnections = this.getActiveConnections();
74
115
  const contracts = this.readModel?.getSnapshot().contracts ?? [];
75
116
  const browseCount = this.browseMode === 'connections' && activeConnections.length > 0
@@ -109,6 +150,40 @@ export class RemotePanel extends BasePanel {
109
150
  return this.readModel?.getSnapshot().acp.activeConnections ?? [];
110
151
  }
111
152
 
153
+ // Runner id backing the currently browsed row, whichever browse mode is
154
+ // active — shared by the Enter/r dispatch logic and render()'s own
155
+ // selection lookups so they never drift apart.
156
+ private getSelectedRunnerId(): string | null {
157
+ const snapshot = this.readModel?.getSnapshot();
158
+ if (!snapshot) return null;
159
+ const activeConnections = this.getActiveConnections();
160
+ const contracts = snapshot.contracts;
161
+ const viewingConnections = this.browseMode === 'connections' && activeConnections.length > 0;
162
+ // selectedIndex indexes only the mode's active list (activeConnections when
163
+ // viewing connections, otherwise contracts); .at() keeps that read off any
164
+ // raw source array (no-raw-selectedindex-read rule).
165
+ if (viewingConnections) {
166
+ return activeConnections.at(this.selectedIndex)?.agentId ?? null;
167
+ }
168
+ return contracts.at(this.selectedIndex)?.runnerId ?? null;
169
+ }
170
+
171
+ private getSelectedSupervisorEntry() {
172
+ const snapshot = this.readModel?.getSnapshot();
173
+ if (!snapshot) return null;
174
+ const runnerId = this.getSelectedRunnerId();
175
+ if (!runnerId) return null;
176
+ return snapshot.supervisor.sessions.find((entry) => entry.runnerId === runnerId) ?? null;
177
+ }
178
+
179
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
180
+ if (!this.pendingCommand) return false;
181
+ const { name, args } = this.pendingCommand;
182
+ this.pendingCommand = null;
183
+ void ctx.executeCommand?.(name, args);
184
+ return true;
185
+ }
186
+
112
187
  public render(width: number, height: number): Line[] {
113
188
  this.needsRender = false;
114
189
  const intro = 'Bridge, ACP, runner-contract, and artifact posture for self-hosted remote work.';
@@ -161,9 +236,9 @@ export class RemotePanel extends BasePanel {
161
236
  [' daemon ', C.label],
162
237
  [daemon.transportState.toUpperCase(), stateColor(daemon.transportState)],
163
238
  [' running ', C.label],
164
- [daemon.isRunning ? 'yes' : 'no', daemon.isRunning ? C.ok : C.dim],
239
+ [daemon.isRunning ? 'yes' : 'no', daemon.isRunning ? C.good : C.dim],
165
240
  [' reconnects ', C.label],
166
- [String(daemon.reconnectAttempts), daemon.reconnectAttempts > 0 ? C.warn : C.ok],
241
+ [String(daemon.reconnectAttempts), daemon.reconnectAttempts > 0 ? C.warn : C.good],
167
242
  [' jobs ', C.label],
168
243
  [String(daemon.runningJobCount), daemon.runningJobCount > 0 ? C.info : C.dim],
169
244
  ]),
@@ -181,17 +256,17 @@ export class RemotePanel extends BasePanel {
181
256
  [' pools ', C.label],
182
257
  [String(pools.length), pools.length > 0 ? C.info : C.dim],
183
258
  [' review artifacts ', C.label],
184
- [String(artifactCount), artifactCount > 0 ? C.ok : C.dim],
259
+ [String(artifactCount), artifactCount > 0 ? C.good : C.dim],
185
260
  ]),
186
261
  buildPanelLine(width, [
187
262
  [' supervisor ', C.label],
188
263
  [String(supervisor.sessions.length), C.info],
189
264
  [' degraded ', C.label],
190
- [String(supervisor.degradedConnections), supervisor.degradedConnections > 0 ? C.warn : C.ok],
265
+ [String(supervisor.degradedConnections), supervisor.degradedConnections > 0 ? C.warn : C.good],
191
266
  [' distributed peers ', C.label],
192
267
  [String(distributed.peers?.total ?? 0), C.info],
193
268
  [' connected ', C.label],
194
- [String(distributed.peers?.connected ?? 0), (distributed.peers?.connected ?? 0) > 0 ? C.ok : C.dim],
269
+ [String(distributed.peers?.connected ?? 0), (distributed.peers?.connected ?? 0) > 0 ? C.good : C.dim],
195
270
  [' queued work ', C.label],
196
271
  [String(distributed.work?.queued ?? 0), (distributed.work?.queued ?? 0) > 0 ? C.info : C.dim],
197
272
  ]),
@@ -200,23 +275,17 @@ export class RemotePanel extends BasePanel {
200
275
  if (daemon.lastError) {
201
276
  postureLines.push(buildPanelLine(width, [
202
277
  [' daemon error ', C.label],
203
- [truncateDisplay(daemon.lastError, Math.max(0, width - 14)), C.error],
278
+ [truncateDisplay(daemon.lastError, Math.max(0, width - 14)), C.bad],
204
279
  ]));
205
280
  }
206
- postureLines.push(
207
- buildGuidanceLine(width, '/remote recover', 'resume remote state with runner, capability, and disconnect recovery hints', C),
208
- buildGuidanceLine(width, '/remote capabilities', 'inspect transport support before routing remote work or reattaching a session', C),
209
- );
210
-
211
281
  const canBrowse = activeConnections.length > 0 || contracts.length > 0;
212
282
  const canSwitch = contracts.length > 0 && activeConnections.length > 0;
213
283
  const navHint = !canBrowse
214
284
  ? ` focus=${this.browseMode} idle - no connections or contracts to browse`
215
285
  : canSwitch
216
- ? ` focus=${this.browseMode} Up/Down move Tab switch connections/contracts`
217
- : ` focus=${this.browseMode} Up/Down move`;
286
+ ? ` focus=${this.browseMode} Up/Down move Tab switch connections/contracts r=recover Enter=recover selection`
287
+ : ` focus=${this.browseMode} Up/Down move r=recover Enter=recover selection`;
218
288
  const footerLines = [
219
- buildGuidanceLine(width, '/remote setup', 'review bridge, tunnel, env, and bootstrap flows for self-hosted remote work', C),
220
289
  buildPanelLine(width, [[navHint, C.dim]]),
221
290
  ] as const;
222
291
 
@@ -252,8 +321,8 @@ export class RemotePanel extends BasePanel {
252
321
  Math.max(0, (viewingConnections ? activeConnections.length : contracts.length) - 1),
253
322
  );
254
323
  const browseCount = viewingConnections ? activeConnections.length : contracts.length;
255
- const selected = viewingConnections ? activeConnections[this.selectedIndex] ?? null : null;
256
- const selectedContract = !viewingConnections ? contracts[this.selectedIndex] ?? null : null;
324
+ const selected = viewingConnections ? activeConnections.at(this.selectedIndex) ?? null : null;
325
+ const selectedContract = !viewingConnections ? contracts.at(this.selectedIndex) ?? null : null;
257
326
  const detailRows: Line[] = [];
258
327
 
259
328
  if (selected) {
@@ -276,7 +345,7 @@ export class RemotePanel extends BasePanel {
276
345
  if (selected.lastError) {
277
346
  detailRows.push(buildPanelLine(width, [
278
347
  [' Last error: ', C.label],
279
- [selected.lastError.slice(0, Math.max(0, width - 13)), C.error],
348
+ [selected.lastError.slice(0, Math.max(0, width - 13)), C.bad],
280
349
  ]));
281
350
  }
282
351
 
@@ -319,13 +388,19 @@ export class RemotePanel extends BasePanel {
319
388
  if (supervisorEntry) {
320
389
  detailRows.push(buildPanelLine(width, [
321
390
  [' Heartbeat: ', C.label],
322
- [supervisorEntry.heartbeat.status, supervisorEntry.heartbeat.status === 'fresh' ? C.ok : supervisorEntry.heartbeat.status === 'stale' ? C.warn : C.error],
391
+ [supervisorEntry.heartbeat.status, supervisorEntry.heartbeat.status === 'fresh' ? C.good : supervisorEntry.heartbeat.status === 'stale' ? C.warn : C.bad],
323
392
  [' Protocol: ', C.label],
324
393
  [supervisorEntry.negotiation.executionProtocol, C.value],
325
394
  [' Review: ', C.label],
326
- [supervisorEntry.negotiation.reviewMode, supervisorEntry.negotiation.reviewMode === 'wrfc' ? C.ok : C.dim],
395
+ [supervisorEntry.negotiation.reviewMode, supervisorEntry.negotiation.reviewMode === 'wrfc' ? C.good : C.dim],
327
396
  ]));
328
397
  detailRows.push(buildPanelLine(width, [[truncateDisplay(` ${supervisorEntry.heartbeat.detail}`, width), C.dim]]));
398
+ if (isSupervisorEntryDegraded(supervisorEntry) && supervisorEntry.recovery.length > 0) {
399
+ detailRows.push(buildPanelLine(width, [
400
+ [' Enter recovers: ', C.label],
401
+ [supervisorEntry.recovery[0].reason, C.warn],
402
+ ]));
403
+ }
329
404
  }
330
405
 
331
406
  const recentArtifact = snapshot.artifacts.find((artifact) => artifact.runnerId === selected.agentId);
@@ -377,19 +452,22 @@ export class RemotePanel extends BasePanel {
377
452
  if (supervisorEntry) {
378
453
  detailRows.push(buildPanelLine(width, [
379
454
  [' Heartbeat: ', C.label],
380
- [supervisorEntry.heartbeat.status, supervisorEntry.heartbeat.status === 'fresh' ? C.ok : supervisorEntry.heartbeat.status === 'stale' ? C.warn : C.error],
455
+ [supervisorEntry.heartbeat.status, supervisorEntry.heartbeat.status === 'fresh' ? C.good : supervisorEntry.heartbeat.status === 'stale' ? C.warn : C.bad],
381
456
  [' Lane: ', C.label],
382
457
  [supervisorEntry.negotiation.communicationLane, C.info],
383
458
  ]));
384
- for (const action of supervisorEntry.recovery.slice(0, 2)) {
385
- detailRows.push(buildGuidanceLine(width, action.command, action.reason, C));
459
+ if (isSupervisorEntryDegraded(supervisorEntry) && supervisorEntry.recovery.length > 0) {
460
+ detailRows.push(buildPanelLine(width, [
461
+ [' Enter recovers: ', C.label],
462
+ [supervisorEntry.recovery[0].reason, C.warn],
463
+ ]));
386
464
  }
387
465
  }
388
466
  const recentArtifact = snapshot.artifacts.find((artifact) => artifact.runnerId === selectedContract.runnerId);
389
467
  if (recentArtifact) {
390
468
  detailRows.push(buildPanelLine(width, [
391
469
  [' Recent artifact: ', C.label],
392
- [recentArtifact.id, C.ok],
470
+ [recentArtifact.id, C.good],
393
471
  [' Status: ', C.label],
394
472
  [recentArtifact.task.status, stateColor(recentArtifact.evidence.transportState)],
395
473
  ]));