@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
@@ -1,42 +1,40 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
+ import type { PanelIntegrationContext } from './types.ts';
4
6
  import {
5
7
  type ServiceConfig,
6
8
  type ServiceInspection,
7
9
  type ServiceConnectionTestResult,
8
10
  } from '@pellux/goodvibes-sdk/platform/config';
11
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
9
12
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
10
13
  import {
11
- buildEmptyState,
14
+ buildDetailBlock,
15
+ buildKeyValueLine,
16
+ buildKeyboardHints,
12
17
  buildPanelLine,
13
18
  buildPanelWorkspace,
14
19
  buildStatusPill,
15
20
  DEFAULT_PANEL_PALETTE,
16
21
  } from './polish.ts';
17
22
 
18
- const C = {
19
- ...DEFAULT_PANEL_PALETTE,
20
- header: '#94a3b8',
21
- headerBg: '#1e293b',
22
- label: '#64748b',
23
- value: '#e2e8f0',
24
- dim: '#475569',
25
- ok: '#22c55e',
26
- warn: '#eab308',
27
- error: '#ef4444',
28
- info: '#38bdf8',
29
- selectBg: '#0f172a',
30
- empty: '#334155',
31
- } as const;
23
+ // Base chrome only — title band, state colors, and text tokens all come
24
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
25
+ const C = DEFAULT_PANEL_PALETTE;
32
26
 
33
27
  interface ServicePanelEntry {
34
28
  readonly name: string;
35
- readonly inspection: ServiceInspection;
29
+ // null when registry.inspect(name) resolved null (e.g. a config entry that
30
+ // disappeared or failed to resolve between getAll() and inspect()) — the
31
+ // row still renders, flagged, instead of crashing the whole refresh.
32
+ readonly inspection: ServiceInspection | null;
36
33
  readonly lastTest?: ServiceConnectionTestResult;
37
34
  }
38
35
 
39
36
  function statusLabel(entry: ServicePanelEntry): string {
37
+ if (!entry.inspection) return 'INSPECT FAILED';
40
38
  if (!entry.inspection.hasPrimaryCredential) return 'UNCONFIGURED';
41
39
  if (entry.lastTest?.ok) return 'HEALTHY';
42
40
  if (entry.lastTest && !entry.lastTest.ok) return 'ERROR';
@@ -45,8 +43,8 @@ function statusLabel(entry: ServicePanelEntry): string {
45
43
 
46
44
  function statusColor(entry: ServicePanelEntry): string {
47
45
  const label = statusLabel(entry);
48
- if (label === 'HEALTHY') return C.ok;
49
- if (label === 'ERROR') return C.error;
46
+ if (label === 'HEALTHY') return C.good;
47
+ if (label === 'ERROR' || label === 'INSPECT FAILED') return C.bad;
50
48
  if (label === 'CONFIGURED') return C.warn;
51
49
  return C.dim;
52
50
  }
@@ -73,18 +71,29 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
73
71
  private readonly subscriptionManager: SubscriptionAccessQuery;
74
72
  private entries: ServicePanelEntry[] = [];
75
73
  private loading = false;
74
+ // s = open the Subscription panel via the handleInput -> handlePanelIntegrationAction
75
+ // bridge (handleInput has no ctx.panelManager — same pattern used elsewhere in
76
+ // this wave, e.g. session-browser-panel.ts's pendingOpenPanels).
77
+ private pendingOpenSubscription = false;
76
78
 
77
79
  public constructor(
78
80
  registry: ServiceInspectionQuery,
79
81
  subscriptionManager: SubscriptionAccessQuery,
80
82
  ) {
81
- super('services', 'Services', 'V', 'monitoring');
83
+ super('services', 'Services', 'V', 'providers');
82
84
  this.showSelectionGutter = true; // I5: non-color selection affordance
85
+ this.filterEnabled = true;
86
+ this.filterLabel = 'Filter services';
83
87
  this.registry = registry;
84
88
  this.subscriptionManager = subscriptionManager;
85
89
  void this.refresh();
86
90
  }
87
91
 
92
+ protected override filterMatches(entry: ServicePanelEntry, q: string): boolean {
93
+ return entry.name.toLowerCase().includes(q)
94
+ || (entry.inspection?.config.baseUrl ?? '').toLowerCase().includes(q);
95
+ }
96
+
88
97
  public override onActivate(): void {
89
98
  super.onActivate();
90
99
  if (this.entries.length === 0 && !this.loading) {
@@ -97,7 +106,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
97
106
  protected override getEmptyStateActions() {
98
107
  return [
99
108
  { command: '/services auth-review', summary: 'inspect service auth posture and registry config' },
100
- { command: '/subscription', summary: 'review provider login state and override posture' },
109
+ { command: 's', summary: 'open the Subscription panel — review provider login state and override posture' },
101
110
  ];
102
111
  }
103
112
 
@@ -107,60 +116,106 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
107
116
 
108
117
  protected renderItem(entry: ServicePanelEntry, index: number, selected: boolean, width: number): Line {
109
118
  const bg = selected ? C.selectBg : undefined;
119
+ const authText = entry.inspection ? authSummary(entry.inspection.config, this.subscriptionManager) : 'n/a';
120
+ const baseUrlText = entry.inspection ? (entry.inspection.config.baseUrl ?? '(no baseUrl)') : '(inspection failed)';
110
121
  return buildPanelLine(width, [
111
122
  [' ', C.label, bg],
112
123
  [entry.name.padEnd(16), C.value, bg],
113
124
  [` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
114
- [` ${authSummary(entry.inspection.config, this.subscriptionManager).padEnd(18)}`, C.info, bg],
115
- [` ${entry.inspection.config.baseUrl ?? '(no baseUrl)'}`, C.dim, bg],
125
+ [` ${authText.padEnd(18)}`, C.info, bg],
126
+ [` ${truncateDisplay(baseUrlText, Math.max(0, width - 48))}`, C.dim, bg],
116
127
  ]);
117
128
  }
118
129
 
119
130
  public handleInput(key: string): boolean {
120
- if (key === 'r') {
121
- void this.refresh();
122
- return true;
123
- }
124
- if (key === 't') {
125
- void this.testSelected();
126
- return true;
131
+ if (!this.filterActive) {
132
+ if (key === 'r') {
133
+ void this.refresh();
134
+ return true;
135
+ }
136
+ if (key === 't') {
137
+ void this.testSelected();
138
+ return true;
139
+ }
140
+ if (key === 'T') {
141
+ void this.testAll();
142
+ return true;
143
+ }
144
+ if (key === 's') {
145
+ this.pendingOpenSubscription = true;
146
+ return true;
147
+ }
127
148
  }
128
149
  return super.handleInput(key);
129
150
  }
130
151
 
152
+ /** Drains the 's' key's pending open request via the ctx.panelManager bridge. */
153
+ handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
154
+ if (!this.pendingOpenSubscription) return false;
155
+ this.pendingOpenSubscription = false;
156
+ ctx.panelManager.open('subscription');
157
+ return true;
158
+ }
159
+
131
160
  private async refresh(): Promise<void> {
132
161
  this.loading = true;
133
162
  this.markDirty();
134
- const configs = this.registry.getAll();
135
- const names = Object.keys(configs).sort((a, b) => a.localeCompare(b));
136
- const inspections = await Promise.all(
137
- names.map(async (name) => ({
138
- name,
139
- inspection: (await this.registry.inspect(name))!,
140
- })),
141
- );
142
- const previousTests = new Map(this.entries.map((entry) => [entry.name, entry.lastTest] as const));
143
- this.entries = inspections.map((entry) => ({
144
- ...entry,
145
- ...(previousTests.get(entry.name) ? { lastTest: previousTests.get(entry.name) } : {}),
146
- }));
147
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.entries.length - 1));
148
- this.loading = false;
149
- this.markDirty();
163
+ try {
164
+ const configs = this.registry.getAll();
165
+ const names = Object.keys(configs).sort((a, b) => a.localeCompare(b));
166
+ const inspections = await Promise.all(
167
+ names.map(async (name) => ({
168
+ name,
169
+ inspection: await this.registry.inspect(name),
170
+ })),
171
+ );
172
+ const previousTests = new Map(this.entries.map((entry) => [entry.name, entry.lastTest] as const));
173
+ this.entries = inspections.map((entry) => ({
174
+ ...entry,
175
+ ...(previousTests.get(entry.name) ? { lastTest: previousTests.get(entry.name) } : {}),
176
+ }));
177
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.entries.length - 1));
178
+ } catch (e) {
179
+ this.setError(`Refresh failed: ${summarizeError(e)}`);
180
+ } finally {
181
+ // Always clear the loading flag — a thrown getAll()/inspect() must
182
+ // never leave the panel stuck on the "Loading configured services..." screen.
183
+ this.loading = false;
184
+ this.markDirty();
185
+ }
150
186
  }
151
187
 
152
188
  private async testSelected(): Promise<void> {
153
- const selected = this.entries[this.selectedIndex];
189
+ // selectedIndex tracks the filtered view, so the acted-on entry must come
190
+ // from getVisibleItems() — this.entries would desync under a '/' filter.
191
+ const selected = this.getSelectedItem();
154
192
  if (!selected) return;
155
- const result = await this.registry.testConnection(selected.name);
156
- this.entries = this.entries.map((entry) => (
157
- entry.name === selected.name
158
- ? { ...entry, lastTest: result }
159
- : entry
160
- ));
193
+ try {
194
+ const result = await this.registry.testConnection(selected.name);
195
+ this.entries = this.entries.map((entry) => (
196
+ entry.name === selected.name
197
+ ? { ...entry, lastTest: result }
198
+ : entry
199
+ ));
200
+ } catch (e) {
201
+ this.setError(`Test failed: ${summarizeError(e)}`);
202
+ }
161
203
  this.markDirty();
162
204
  }
163
205
 
206
+ /** T = test every configured service's connection, one call per row (services-runtime.ts's single-service test pattern, looped). */
207
+ private async testAll(): Promise<void> {
208
+ for (const entry of this.entries) {
209
+ try {
210
+ const result = await this.registry.testConnection(entry.name);
211
+ this.entries = this.entries.map((e) => (e.name === entry.name ? { ...e, lastTest: result } : e));
212
+ } catch (e) {
213
+ this.setError(`Test failed for ${entry.name}: ${summarizeError(e)}`);
214
+ }
215
+ this.markDirty();
216
+ }
217
+ }
218
+
164
219
  public render(width: number, height: number): Line[] {
165
220
  this.clampSelection();
166
221
  const intro = 'Credential posture, subscription overrides, and live connection checks for configured services.';
@@ -176,55 +231,98 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
176
231
  return lines;
177
232
  }
178
233
 
179
- const selected = this.entries[this.selectedIndex];
180
- const detailLines: Line[] = [];
234
+ // Health-summary header — surface the most important counts first.
235
+ const counts = { healthy: 0, error: 0, configured: 0, unconfigured: 0 };
236
+ for (const entry of this.entries) {
237
+ const label = statusLabel(entry);
238
+ if (label === 'HEALTHY') counts.healthy++;
239
+ else if (label === 'ERROR') counts.error++;
240
+ else if (label === 'CONFIGURED') counts.configured++;
241
+ else counts.unconfigured++;
242
+ }
243
+ const headerLines: Line[] = [
244
+ buildKeyValueLine(width, [
245
+ { label: 'services', value: String(this.entries.length), valueColor: this.entries.length > 0 ? C.info : C.dim },
246
+ { label: 'healthy', value: String(counts.healthy), valueColor: counts.healthy > 0 ? C.good : C.dim },
247
+ { label: 'errors', value: String(counts.error), valueColor: counts.error > 0 ? C.bad : C.dim },
248
+ { label: 'unconfigured', value: String(counts.unconfigured), valueColor: counts.unconfigured > 0 ? C.warn : C.dim },
249
+ ], C),
250
+ ];
251
+
252
+ // Detail must describe the row the (possibly filtered) list highlights.
253
+ const selected = this.getSelectedItem();
254
+ const detailRows: Line[] = [];
181
255
  if (selected) {
182
256
  const inspect = selected.inspection;
183
- detailLines.push(buildPanelLine(width, [
257
+ detailRows.push(buildPanelLine(width, [
184
258
  [' Service: ', C.label],
185
259
  [selected.name, C.value],
186
260
  [' State: ', C.label],
187
261
  [statusLabel(selected), statusColor(selected)],
188
262
  [' Auth: ', C.label],
189
- [authSummary(inspect.config, this.subscriptionManager), C.info],
190
- ]));
191
- detailLines.push(buildPanelLine(width, [
192
- [' Primary credential: ', C.label],
193
- ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
194
- [' Webhook URL: ', C.label],
195
- ...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
196
- [' Signing secret: ', C.label],
197
- ...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
198
- [' App token: ', C.label],
199
- ...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
263
+ [inspect ? authSummary(inspect.config, this.subscriptionManager) : 'n/a', C.info],
200
264
  ]));
265
+ if (!inspect) {
266
+ detailRows.push(buildPanelLine(width, [
267
+ [' Inspection failed — the registry could not resolve this service\'s config. Press r to retry.', C.bad],
268
+ ]));
269
+ } else {
270
+ detailRows.push(buildPanelLine(width, [
271
+ [' Base URL: ', C.label],
272
+ [truncateDisplay(inspect.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 13)), C.dim],
273
+ ]));
274
+ detailRows.push(buildPanelLine(width, [
275
+ [' Primary credential: ', C.label],
276
+ ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
277
+ [' Webhook URL: ', C.label],
278
+ ...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
279
+ [' Signing secret: ', C.label],
280
+ ...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
281
+ [' App token: ', C.label],
282
+ ...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
283
+ ]));
284
+ }
201
285
  if (selected.lastTest) {
202
- detailLines.push(buildPanelLine(width, [
286
+ detailRows.push(buildPanelLine(width, [
203
287
  [' Last test: ', C.label],
204
288
  ...buildStatusPill(selected.lastTest.ok ? 'good' : 'bad', selected.lastTest.ok ? 'ok' : 'failed'),
205
289
  [' Status: ', C.label],
206
290
  [selected.lastTest.status != null ? String(selected.lastTest.status) : 'n/a', C.value],
207
291
  [' URL: ', C.label],
208
- [(selected.lastTest.testedUrl ?? 'n/a').slice(0, Math.max(0, width - 34)), C.dim],
292
+ [truncateDisplay(selected.lastTest.testedUrl ?? 'n/a', Math.max(0, width - 34)), C.dim],
209
293
  ]));
210
294
  if (selected.lastTest.error) {
211
- detailLines.push(buildPanelLine(width, [
295
+ detailRows.push(buildPanelLine(width, [
212
296
  [' Error: ', C.label],
213
- [selected.lastTest.error.slice(0, Math.max(0, width - 10)), C.error],
297
+ [truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.bad],
214
298
  ]));
215
299
  }
216
300
  } else {
217
- detailLines.push(buildPanelLine(width, [[' Press t to test the selected service or r to refresh credential status.', C.dim]]));
301
+ detailRows.push(buildPanelLine(width, [[' Not tested yet press t to run a live connection check.', C.dim]]));
218
302
  }
219
- detailLines.push(buildPanelLine(width, [[' Services resolve credentials through hierarchy-aware secure storage, plaintext fallback policy, and project-local config.', C.dim]]));
220
303
  }
221
304
 
305
+ // Context-aware hints: filter mode swaps to filter-specific keys; the test
306
+ // key only makes sense when there is a service to test.
307
+ const hints = this.filterActive
308
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
309
+ : [
310
+ { keys: 'Up/Down', label: 'move' },
311
+ ...(selected ? [{ keys: 't', label: 'test selected' }] : []),
312
+ ...(this.entries.length > 0 ? [{ keys: 'T', label: 'test all' }] : []),
313
+ { keys: 'r', label: 'refresh' },
314
+ { keys: '/', label: 'filter' },
315
+ { keys: 's', label: 'subscription panel' },
316
+ ];
317
+
318
+ const footer: Line[] = selected
319
+ ? [...buildDetailBlock(width, `Service · ${selected.name}`, detailRows, C), buildKeyboardHints(width, hints, C)]
320
+ : [buildKeyboardHints(width, hints, C)];
321
+
222
322
  return this.renderList(width, height, {
223
323
  title: 'Service Control Room',
224
- footer: [
225
- ...detailLines,
226
- buildPanelLine(width, [[' Up/Down move t test selected service r refresh inspections', C.dim]]),
227
- ],
324
+ header: headerLines,
325
+ footer,
228
326
  emptyMessage: intro,
229
327
  });
230
328
  }
@@ -4,11 +4,13 @@
4
4
 
5
5
  import type { Line } from '../types/grid.ts';
6
6
  import { BasePanel } from './base-panel.ts';
7
+ import type { PanelIntegrationContext } from './types.ts';
7
8
  import type { SessionInfo } from '@pellux/goodvibes-sdk/platform/sessions';
8
9
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
10
  import type { SessionBrowserQuery } from '../runtime/ui-service-queries.ts';
10
11
  import {
11
12
  buildEmptyState,
13
+ buildKeyboardHints,
12
14
  buildPanelLine,
13
15
  buildSearchInputLine,
14
16
  buildStyledPanelLine,
@@ -18,7 +20,7 @@ import {
18
20
  extendPalette,
19
21
  type PanelWorkspaceSection,
20
22
  } from './polish.ts';
21
- import { truncateDisplay } from '../utils/terminal-width.ts';
23
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
22
24
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
25
  import {
24
26
  getPanelSearchFocusTransition,
@@ -39,6 +41,15 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
39
41
  countFg: '#88bbcc',
40
42
  });
41
43
 
44
+ // Splits a computed '/command arg1 arg2' string into the { name, args } shape
45
+ // ctx.executeCommand expects — same leading-slash-stripping approach used by
46
+ // remote-panel.ts for its own dispatched command.
47
+ function parseCommand(command: string): { name: string; args: string[] } | null {
48
+ const parts = command.replace(/^\//, '').split(/\s+/).filter(Boolean);
49
+ const [name, ...args] = parts;
50
+ return name ? { name, args } : null;
51
+ }
52
+
42
53
  function shortDate(ts: number): string {
43
54
  const d = new Date(ts);
44
55
  const Y = d.getFullYear();
@@ -84,7 +95,14 @@ export class SessionBrowserPanel extends BasePanel {
84
95
  private confirm: ConfirmState<string> | null = null;
85
96
  private deleteError = '';
86
97
  private loadError = '';
98
+ private hasLoaded = false;
87
99
  private refreshTimerId: ReturnType<typeof setInterval> | null = null;
100
+ // x = dispatch the computed next-step command ('/remote recover <runner>'
101
+ // or '/session resume <name>') via the same handleInput -> handlePanelIntegrationAction
102
+ // bridge remote-panel.ts uses — handleInput has no ctx.executeCommand.
103
+ private pendingCommand: { name: string; args: string[] } | null = null;
104
+ // On resume, restore any panels the session had open when it was saved.
105
+ private pendingOpenPanels: string[] | null = null;
88
106
 
89
107
  constructor(
90
108
  private readonly sessionManager: SessionBrowserQuery,
@@ -99,6 +117,21 @@ export class SessionBrowserPanel extends BasePanel {
99
117
  this.refreshTimerId = this.registerTimer(setInterval(() => { this._load(); }, 5000));
100
118
  }
101
119
 
120
+ /**
121
+ * Clears any active search/filter and moves the cursor to the session
122
+ * matching `sessionId` (SessionInfo.name) so a sibling panel (e.g.
123
+ * RoutesPanel's Enter-on-binding jump) can land directly on it. No-ops
124
+ * (beyond clearing the filter) if the session isn't found.
125
+ */
126
+ focusSession(sessionId: string): void {
127
+ this.searching = false;
128
+ this.searchQuery = '';
129
+ this._filter();
130
+ const index = this.filtered.findIndex((session) => session.name === sessionId);
131
+ if (index >= 0) this.cursorIndex = index;
132
+ this.markDirty();
133
+ }
134
+
102
135
  override onDeactivate(): void {
103
136
  if (this.refreshTimerId !== null) { this.clearTimer(this.refreshTimerId); this.refreshTimerId = null; }
104
137
  this.searching = false;
@@ -161,16 +194,47 @@ export class SessionBrowserPanel extends BasePanel {
161
194
  case 'return': this._resume(); return true;
162
195
  case 'd': this._promptDelete(); return true;
163
196
  case 'r': this._load(); return true;
197
+ case 'x': this._dispatchNextStep(); return true;
164
198
  default: return false;
165
199
  }
166
200
  }
167
201
 
202
+ /**
203
+ * Drains `pendingCommand` (set by the 'x' key) and `pendingOpenPanels`
204
+ * (set by `_resume()`) via the ctx.executeCommand / PanelManager bridge.
205
+ * Called by the input router immediately after handleInput() consumes the
206
+ * same key (panel-integration-actions.ts's onPanelInputConsumed).
207
+ */
208
+ handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
209
+ let consumed = false;
210
+ if (this.pendingCommand) {
211
+ const { name, args } = this.pendingCommand;
212
+ this.pendingCommand = null;
213
+ void ctx.executeCommand?.(name, args);
214
+ consumed = true;
215
+ }
216
+ if (this.pendingOpenPanels) {
217
+ const openPanels = this.pendingOpenPanels;
218
+ this.pendingOpenPanels = null;
219
+ for (const panelId of openPanels) ctx.panelManager.open(panelId);
220
+ consumed = true;
221
+ }
222
+ return consumed;
223
+ }
224
+
168
225
  render(width: number, height: number): Line[] {
169
226
  if (height <= 0 || width <= 0) return [];
170
227
  const intro = 'Browse, search, resume, and prune saved conversations.';
171
228
 
229
+ // Lazily load on first render so the panel works even when rendered without
230
+ // onActivate(). The session list is a synchronous source, so this resolves
231
+ // immediately rather than flashing a loading screen.
232
+ if (!this.hasLoaded) this._load();
233
+
172
234
  const count = this.filtered.length;
173
235
  const total = this.sessions.length;
236
+ const hasSelection = this.filtered.length > 0;
237
+ // Search/status line — surfaces the active query or any error.
174
238
  const searchLine = this.searching
175
239
  ? ` Search: ${this.searchQuery}_`
176
240
  : this.loadError
@@ -178,11 +242,21 @@ export class SessionBrowserPanel extends BasePanel {
178
242
  : this.deleteError
179
243
  ? ` Error: ${this.deleteError}`
180
244
  : this.searchQuery
181
- ? ` Filter: ${this.searchQuery} (/ or up at top to edit)`
182
- : ` / or up at top to search Enter: resume d: delete r: refresh`;
245
+ ? ` Filter: ${this.searchQuery}`
246
+ : ` (no filter)`;
183
247
  const statusFg = this.loadError || this.deleteError ? DEFAULT_PANEL_PALETTE.bad : this.searching ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim;
248
+ // Context-aware hints: only advertise keys that work in the current state.
249
+ const hints: Array<{ keys: string; label: string }> = [];
250
+ if (this.searching) {
251
+ hints.push({ keys: 'type', label: 'filter' }, { keys: 'Esc/Enter', label: 'apply' });
252
+ } else {
253
+ hints.push({ keys: '/', label: this.searchQuery ? 'edit search' : 'search' });
254
+ if (hasSelection) hints.push({ keys: 'Enter', label: 'resume' }, { keys: 'x', label: 'run next step' }, { keys: 'd', label: 'delete' });
255
+ hints.push({ keys: 'r', label: 'refresh' });
256
+ }
184
257
  const footerLines = [
185
258
  buildSearchInputLine(width, '', searchLine.trimStart(), DEFAULT_PANEL_PALETTE, { active: this.searching, valueColor: statusFg }),
259
+ buildKeyboardHints(width, hints, DEFAULT_PANEL_PALETTE),
186
260
  ];
187
261
 
188
262
  if (this.confirm) {
@@ -208,12 +282,15 @@ export class SessionBrowserPanel extends BasePanel {
208
282
  const emptyBody = this.searchQuery
209
283
  ? 'Clear or change the current filter to surface saved conversations again.'
210
284
  : 'Conversations are saved automatically. Once you have saved sessions, they appear here for review and resume.';
285
+ const emptyActions = this.searchQuery
286
+ ? [{ command: 'Esc', summary: 'clear the filter and show all sessions' }]
287
+ : [{ command: '/session save', summary: 'name and persist the current conversation' }];
211
288
  return buildPanelWorkspace(width, height, {
212
289
  title: ` Sessions [${count}/${total}]`,
213
290
  intro,
214
291
  sections: [
215
292
  {
216
- lines: buildEmptyState(width, emptyTitle, emptyBody, [], DEFAULT_PANEL_PALETTE),
293
+ lines: buildEmptyState(width, emptyTitle, emptyBody, emptyActions, DEFAULT_PANEL_PALETTE),
217
294
  },
218
295
  ],
219
296
  footerLines,
@@ -263,7 +340,7 @@ export class SessionBrowserPanel extends BasePanel {
263
340
  ...formatReturnContextLines(selected.returnContext).map((line) =>
264
341
  buildPanelLine(width, [[' ', DEFAULT_PANEL_PALETTE.dim], [truncateDisplay(line, Math.max(0, width - 2)), DEFAULT_PANEL_PALETTE.dim]])
265
342
  ),
266
- buildPanelLine(width, [[' Next ', DEFAULT_PANEL_PALETTE.label], [selected.returnContext?.remoteRunners?.length ? `/remote recover ${selected.returnContext.remoteRunners[0]}` : '/session resume', DEFAULT_PANEL_PALETTE.dim]]),
343
+ buildPanelLine(width, [[' Next ', DEFAULT_PANEL_PALETTE.label], [this._computeNextStepCommand(selected), DEFAULT_PANEL_PALETTE.dim], [' (x to run)', DEFAULT_PANEL_PALETTE.dim]]),
267
344
  ],
268
345
  }
269
346
  : { title: 'Selected', lines: [] };
@@ -303,7 +380,7 @@ export class SessionBrowserPanel extends BasePanel {
303
380
  const bg = isCursor ? C.selectBg : '';
304
381
  const date = shortDate(sess.timestamp);
305
382
  const cnt = String(sess.messageCount).padStart(3) + 'm ';
306
- const model = (sess.model || 'unknown').slice(0, 18).padEnd(18) + ' ';
383
+ const model = fitDisplay(sess.model || 'unknown', 18) + ' ';
307
384
  const prefixLength = 1 + 16 + 1 + 4 + 19;
308
385
  const title = truncateDisplay(sess.title || sess.name || '(untitled)', Math.max(0, width - prefixLength));
309
386
  return buildStyledPanelLine(width, [
@@ -321,10 +398,12 @@ export class SessionBrowserPanel extends BasePanel {
321
398
  this.sessions = this.sessionManager.list();
322
399
  this._filter();
323
400
  this.loadError = '';
401
+ this.hasLoaded = true;
324
402
  this.markDirty();
325
403
  } catch (e) {
326
404
  logger.debug('SessionBrowserPanel._load failed', { error: summarizeError(e) });
327
405
  this.loadError = 'Failed to load sessions';
406
+ this.hasLoaded = true;
328
407
  this.markDirty();
329
408
  }
330
409
  }
@@ -365,9 +444,25 @@ export class SessionBrowserPanel extends BasePanel {
365
444
  private _resume(): void {
366
445
  const sess = this.filtered[this.cursorIndex];
367
446
  if (!sess) return;
447
+ if (sess.returnContext?.openPanels?.length) {
448
+ this.pendingOpenPanels = [...sess.returnContext.openPanels];
449
+ }
368
450
  this.resumeSession?.(sess.name);
369
451
  }
370
452
 
453
+ /** The next-step command shown in the 'Selected' section and dispatched by 'x'. */
454
+ private _computeNextStepCommand(sess: SessionInfo): string {
455
+ const runner = sess.returnContext?.remoteRunners?.[0];
456
+ return runner ? `/remote recover ${runner}` : `/session resume ${sess.name}`;
457
+ }
458
+
459
+ private _dispatchNextStep(): void {
460
+ const sess = this.filtered[this.cursorIndex];
461
+ if (!sess) return;
462
+ const parsed = parseCommand(this._computeNextStepCommand(sess));
463
+ if (parsed) this.pendingCommand = parsed;
464
+ }
465
+
371
466
  private _promptDelete(): void {
372
467
  const sess = this.filtered[this.cursorIndex];
373
468
  if (!sess) return;