@pellux/goodvibes-tui 0.27.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -2,20 +2,31 @@ import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { BasePanel } from './base-panel.ts';
4
4
  import {
5
+ buildKeyboardHints,
5
6
  buildPanelLine,
7
+ buildPanelTitle,
6
8
  DEFAULT_PANEL_PALETTE,
9
+ extendPalette,
7
10
  } from './polish.ts';
11
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
12
+ import { truncateDisplay } from '../utils/terminal-width.ts';
8
13
  import { renderQrMatrix, generateQrMatrix } from '../renderer/qr-renderer.ts';
9
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';
10
17
 
11
- const C = {
12
- ...DEFAULT_PANEL_PALETTE,
13
- 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, {
14
26
  token: '#a78bfa',
15
- hint: '#64748b',
16
- qrFg: '#000000',
17
- qrBg: '#ffffff',
18
- } as const;
27
+ qrFg: '#000000',
28
+ qrBg: '#ffffff',
29
+ } as const);
19
30
 
20
31
  /**
21
32
  * Connection info passed to the QR panel.
@@ -59,27 +70,82 @@ export class QrPanel extends BasePanel {
59
70
  private connectionInfo: QrPanelConnectionInfo;
60
71
  private readonly regenerateToken: RegenerateTokenFn | undefined;
61
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;
62
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;
63
81
 
64
82
  public constructor(
65
83
  connectionInfo: QrPanelConnectionInfo,
66
84
  regenerateToken?: RegenerateTokenFn,
67
85
  copyToClipboard?: CopyToClipboardFn,
86
+ controlPlaneReadModel?: UiReadModel<UiControlPlaneSnapshot>,
87
+ localUserAuthManager?: Pick<UserAuthManager, 'inspect'>,
68
88
  ) {
69
89
  super('qr-code', 'QR Code', 'Q', 'session');
70
90
  this.connectionInfo = connectionInfo;
71
91
  this.regenerateToken = regenerateToken;
72
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();
73
111
  }
74
112
 
75
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
+ }
76
129
  if (key === 'r') {
77
- if (this.regenerateToken) {
78
- this.connectionInfo = this.regenerateToken();
79
- this.lastStatus = 'Token regenerated.';
80
- } else {
130
+ if (!this.regenerateToken) {
81
131
  this.lastStatus = 'Regeneration not available.';
132
+ this.markDirty();
133
+ return true;
82
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;
83
149
  this.markDirty();
84
150
  return true;
85
151
  }
@@ -101,32 +167,57 @@ export class QrPanel extends BasePanel {
101
167
  const lines: Line[] = [];
102
168
 
103
169
  const { url, token, username, password } = this.connectionInfo;
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;
173
+
174
+ // ── Title + purpose: tell the operator exactly what this code is for ────
175
+ lines.push(buildPanelTitle(width, 'Companion Pairing', C));
176
+ lines.push(
177
+ buildPanelLine(width, [
178
+ [' Scan with the GoodVibes companion app to pair this session.', C.label],
179
+ ]),
180
+ );
104
181
 
105
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.
106
186
  lines.push(createEmptyLine(width));
107
187
  lines.push(
108
188
  buildPanelLine(width, [
109
189
  [' URL ', C.label],
110
- [url.slice(0, Math.max(0, width - 12)), C.url],
190
+ [truncateDisplay(url, valueWidth), C.info],
111
191
  ]),
112
192
  );
113
193
  lines.push(
114
194
  buildPanelLine(width, [
115
195
  [' Token ', C.label],
116
- [token, C.token],
196
+ [truncateDisplay(displayToken, valueWidth), C.token],
117
197
  ]),
118
198
  );
119
199
  lines.push(
120
200
  buildPanelLine(width, [
121
201
  [' Username ', C.label],
122
- [username.slice(0, Math.max(0, width - 12)), C.value],
202
+ [truncateDisplay(username, valueWidth), C.value],
123
203
  ]),
124
204
  );
125
- if (password !== undefined) {
205
+ if (displayPassword !== undefined) {
126
206
  lines.push(
127
207
  buildPanelLine(width, [
128
208
  [' Password ', C.label],
129
- [password, 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],
130
221
  ]),
131
222
  );
132
223
  }
@@ -149,34 +240,41 @@ export class QrPanel extends BasePanel {
149
240
 
150
241
  lines.push(createEmptyLine(width));
151
242
 
152
- // ── Status message (ephemeral) ─────────────────────────────────────────
153
- if (this.lastStatus) {
154
- 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(
155
250
  buildPanelLine(width, [
156
- [` ${this.lastStatus} `, C.hint],
251
+ [` ${this.lastStatus} `, C.label],
157
252
  ]),
158
253
  );
159
254
  }
160
255
 
161
- // ── Hints ──────────────────────────────────────────────────────────────
162
- const hintsLine = buildPanelLine(width, [
163
- [' r ', C.hint],
164
- ['regenerate ', C.dim],
165
- [' c ', C.hint],
166
- ['copy token', C.dim],
167
- ]);
168
-
169
- // Push hints at the bottom if we have room, otherwise append after QR
170
- const remaining = height - lines.length;
171
- if (remaining > 2) {
172
- // Fill with empty lines to push hints toward bottom
173
- const fillCount = Math.max(0, remaining - 2);
174
- for (let i = 0; i < fillCount; i++) {
175
- lines.push(createEmptyLine(width));
176
- }
256
+ // ── Hints ── only advertise actions that are actually wired ─────────────
257
+ const hints: Array<{ keys: string; label: string }> = [];
258
+ if (this.regenerateToken) hints.push({ keys: 'r', label: 'regenerate token' });
259
+ if (this.copyToClipboard) hints.push({ keys: 'c', label: 'copy token' });
260
+ hints.push({ keys: 'v', label: this.revealed ? 'hide token' : 'reveal token' });
261
+ const hintsLine = hints.length > 0
262
+ ? buildKeyboardHints(width, hints, C)
263
+ : buildPanelLine(width, [[' Pairing is read-only in this surface.', C.dim]]);
264
+ footerLines.push(hintsLine);
265
+
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));
177
271
  }
178
- lines.push(hintsLine);
272
+ lines.push(...footerLines);
179
273
 
180
- 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);
181
279
  }
182
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,17 +275,18 @@ export class RemotePanel extends BasePanel {
200
275
  if (daemon.lastError) {
201
276
  postureLines.push(buildPanelLine(width, [
202
277
  [' daemon error ', C.label],
203
- [daemon.lastError.slice(0, 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
-
281
+ const canBrowse = activeConnections.length > 0 || contracts.length > 0;
282
+ const canSwitch = contracts.length > 0 && activeConnections.length > 0;
283
+ const navHint = !canBrowse
284
+ ? ` focus=${this.browseMode} idle - no connections or contracts to browse`
285
+ : canSwitch
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`;
211
288
  const footerLines = [
212
- buildGuidanceLine(width, '/remote setup', 'review bridge, tunnel, env, and bootstrap flows for self-hosted remote work', C),
213
- buildPanelLine(width, [[` focus=${this.browseMode} Up/Down move Tab switch connections/contracts`, C.dim]]),
289
+ buildPanelLine(width, [[navHint, C.dim]]),
214
290
  ] as const;
215
291
 
216
292
  if (activeConnections.length === 0 && contracts.length === 0) {
@@ -245,8 +321,8 @@ export class RemotePanel extends BasePanel {
245
321
  Math.max(0, (viewingConnections ? activeConnections.length : contracts.length) - 1),
246
322
  );
247
323
  const browseCount = viewingConnections ? activeConnections.length : contracts.length;
248
- const selected = viewingConnections ? activeConnections[this.selectedIndex] ?? null : null;
249
- 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;
250
326
  const detailRows: Line[] = [];
251
327
 
252
328
  if (selected) {
@@ -269,7 +345,7 @@ export class RemotePanel extends BasePanel {
269
345
  if (selected.lastError) {
270
346
  detailRows.push(buildPanelLine(width, [
271
347
  [' Last error: ', C.label],
272
- [selected.lastError.slice(0, Math.max(0, width - 13)), C.error],
348
+ [selected.lastError.slice(0, Math.max(0, width - 13)), C.bad],
273
349
  ]));
274
350
  }
275
351
 
@@ -312,13 +388,19 @@ export class RemotePanel extends BasePanel {
312
388
  if (supervisorEntry) {
313
389
  detailRows.push(buildPanelLine(width, [
314
390
  [' Heartbeat: ', C.label],
315
- [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],
316
392
  [' Protocol: ', C.label],
317
393
  [supervisorEntry.negotiation.executionProtocol, C.value],
318
394
  [' Review: ', C.label],
319
- [supervisorEntry.negotiation.reviewMode, supervisorEntry.negotiation.reviewMode === 'wrfc' ? C.ok : C.dim],
395
+ [supervisorEntry.negotiation.reviewMode, supervisorEntry.negotiation.reviewMode === 'wrfc' ? C.good : C.dim],
320
396
  ]));
321
- detailRows.push(buildPanelLine(width, [[` ${supervisorEntry.heartbeat.detail}`.slice(0, width), C.dim]]));
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
+ }
322
404
  }
323
405
 
324
406
  const recentArtifact = snapshot.artifacts.find((artifact) => artifact.runnerId === selected.agentId);
@@ -370,19 +452,22 @@ export class RemotePanel extends BasePanel {
370
452
  if (supervisorEntry) {
371
453
  detailRows.push(buildPanelLine(width, [
372
454
  [' Heartbeat: ', C.label],
373
- [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],
374
456
  [' Lane: ', C.label],
375
457
  [supervisorEntry.negotiation.communicationLane, C.info],
376
458
  ]));
377
- for (const action of supervisorEntry.recovery.slice(0, 2)) {
378
- 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
+ ]));
379
464
  }
380
465
  }
381
466
  const recentArtifact = snapshot.artifacts.find((artifact) => artifact.runnerId === selectedContract.runnerId);
382
467
  if (recentArtifact) {
383
468
  detailRows.push(buildPanelLine(width, [
384
469
  [' Recent artifact: ', C.label],
385
- [recentArtifact.id, C.ok],
470
+ [recentArtifact.id, C.good],
386
471
  [' Status: ', C.label],
387
472
  [recentArtifact.task.status, stateColor(recentArtifact.evidence.transportState)],
388
473
  ]));
@@ -400,7 +485,7 @@ export class RemotePanel extends BasePanel {
400
485
  { text: ` ${connection.transportState.padEnd(18)}`, fg: stateColor(connection.transportState) },
401
486
  { text: ` msgs=${String(connection.messageCount).padEnd(6)}`, fg: C.info },
402
487
  { text: ` errs=${String(connection.errorCount).padEnd(4)}`, fg: connection.errorCount > 0 ? C.warn : C.dim },
403
- { text: ` ${connection.label}`.slice(0, Math.max(0, width - 56)), fg: C.dim },
488
+ { text: truncateDisplay(` ${connection.label}`, Math.max(0, width - 56)), fg: C.dim },
404
489
  ], C, { selected: absolute === this.selectedIndex, selectedBg: C.headerBg });
405
490
  })
406
491
  : [
@@ -408,7 +493,7 @@ export class RemotePanel extends BasePanel {
408
493
  return buildPanelListRow(width, [
409
494
  { text: contract.runnerId.padEnd(18), fg: C.value },
410
495
  { text: ` ${contract.transport.state.padEnd(18)}`, fg: stateColor(contract.transport.state) },
411
- { text: ` ${contract.template}`.slice(0, Math.max(0, width - 42)), fg: C.dim },
496
+ { text: truncateDisplay(` ${contract.template}`, Math.max(0, width - 42)), fg: C.dim },
412
497
  ], C, { selected: absolute === this.selectedIndex, selectedBg: C.headerBg });
413
498
  }),
414
499
  buildPanelLine(width, [[' No active connection is currently attached to these contracts.', C.dim]]),