@pellux/goodvibes-tui 0.27.0 → 0.28.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 (101) hide show
  1. package/CHANGELOG.md +23 -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/eval.ts +4 -3
  9. package/src/input/handler-feed-routes.ts +61 -4
  10. package/src/input/handler-feed.ts +13 -0
  11. package/src/input/handler-picker-routes.ts +18 -13
  12. package/src/input/handler-ui-state.ts +4 -0
  13. package/src/input/keybindings.ts +5 -0
  14. package/src/input/model-picker.ts +5 -0
  15. package/src/input/panel-integration-actions.ts +10 -0
  16. package/src/main.ts +0 -1
  17. package/src/panels/agent-inspector-panel.ts +18 -13
  18. package/src/panels/agent-inspector-shared.ts +3 -3
  19. package/src/panels/agent-logs-panel.ts +22 -53
  20. package/src/panels/approval-panel.ts +146 -95
  21. package/src/panels/automation-control-panel.ts +24 -1
  22. package/src/panels/base-panel.ts +26 -2
  23. package/src/panels/builtin/development.ts +1 -1
  24. package/src/panels/cockpit-panel.ts +24 -5
  25. package/src/panels/cockpit-read-model.ts +2 -1
  26. package/src/panels/communication-panel.ts +108 -43
  27. package/src/panels/context-visualizer-panel.ts +25 -1
  28. package/src/panels/control-plane-panel.ts +27 -1
  29. package/src/panels/cost-tracker-panel.ts +82 -54
  30. package/src/panels/debug-panel.ts +51 -16
  31. package/src/panels/diff-panel.ts +59 -30
  32. package/src/panels/docs-panel.ts +19 -3
  33. package/src/panels/eval-panel.ts +130 -81
  34. package/src/panels/expandable-list-panel.ts +189 -0
  35. package/src/panels/file-explorer-panel.ts +40 -41
  36. package/src/panels/file-preview-panel.ts +9 -2
  37. package/src/panels/forensics-panel.ts +27 -13
  38. package/src/panels/git-panel.ts +58 -70
  39. package/src/panels/hooks-panel.ts +36 -2
  40. package/src/panels/incident-review-panel.ts +31 -10
  41. package/src/panels/intelligence-panel.ts +152 -71
  42. package/src/panels/knowledge-graph-panel.ts +89 -27
  43. package/src/panels/local-auth-panel.ts +17 -11
  44. package/src/panels/marketplace-panel.ts +31 -12
  45. package/src/panels/memory-panel.ts +5 -4
  46. package/src/panels/ops-control-panel.ts +69 -7
  47. package/src/panels/ops-strategy-panel.ts +61 -43
  48. package/src/panels/orchestration-panel.ts +34 -4
  49. package/src/panels/panel-list-panel.ts +33 -8
  50. package/src/panels/panel-manager.ts +23 -4
  51. package/src/panels/plan-dashboard-panel.ts +183 -66
  52. package/src/panels/plugins-panel.ts +48 -10
  53. package/src/panels/policy-panel.ts +60 -23
  54. package/src/panels/polish-core.ts +157 -0
  55. package/src/panels/polish-tables.ts +258 -0
  56. package/src/panels/polish.ts +44 -145
  57. package/src/panels/project-planning-panel.ts +22 -0
  58. package/src/panels/provider-accounts-panel.ts +55 -18
  59. package/src/panels/provider-health-panel.ts +65 -23
  60. package/src/panels/provider-stats-panel.ts +47 -22
  61. package/src/panels/qr-panel.ts +23 -11
  62. package/src/panels/remote-panel.ts +12 -5
  63. package/src/panels/routes-panel.ts +27 -5
  64. package/src/panels/sandbox-panel.ts +62 -27
  65. package/src/panels/schedule-panel.ts +40 -17
  66. package/src/panels/scrollable-list-panel.ts +124 -10
  67. package/src/panels/security-panel.ts +72 -20
  68. package/src/panels/services-panel.ts +68 -22
  69. package/src/panels/session-browser-panel.ts +29 -5
  70. package/src/panels/session-maintenance.ts +2 -2
  71. package/src/panels/settings-sync-panel.ts +30 -15
  72. package/src/panels/subscription-panel.ts +21 -3
  73. package/src/panels/symbol-outline-panel.ts +40 -47
  74. package/src/panels/system-messages-panel.ts +60 -27
  75. package/src/panels/tasks-panel.ts +34 -7
  76. package/src/panels/thinking-panel.ts +26 -25
  77. package/src/panels/token-budget-panel.ts +49 -38
  78. package/src/panels/tool-inspector-panel.ts +27 -21
  79. package/src/panels/types.ts +25 -0
  80. package/src/panels/watchers-panel.ts +25 -5
  81. package/src/panels/work-plan-panel.ts +125 -34
  82. package/src/panels/worktree-panel.ts +65 -20
  83. package/src/panels/wrfc-panel-format.ts +133 -0
  84. package/src/panels/wrfc-panel.ts +31 -127
  85. package/src/renderer/compaction-preview.ts +2 -1
  86. package/src/renderer/compositor.ts +46 -43
  87. package/src/renderer/model-picker-overlay.ts +9 -4
  88. package/src/renderer/model-workspace.ts +2 -3
  89. package/src/renderer/panel-composite.ts +7 -20
  90. package/src/renderer/panel-workspace-bar.ts +14 -8
  91. package/src/renderer/progress.ts +3 -2
  92. package/src/renderer/tab-strip.ts +148 -34
  93. package/src/renderer/ui-factory.ts +4 -6
  94. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  95. package/src/runtime/ui/model-picker/types.ts +4 -0
  96. package/src/utils/format-duration.ts +55 -0
  97. package/src/utils/format-number.ts +71 -0
  98. package/src/version.ts +1 -1
  99. package/src/panels/panel-picker.ts +0 -106
  100. package/src/renderer/panel-picker-overlay.ts +0 -202
  101. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -5,18 +5,31 @@ import type { UiEventFeed } from '../runtime/ui-events.ts';
5
5
  import type { UiProvidersSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
6
6
  import {
7
7
  buildEmptyState,
8
+ buildKeyboardHints,
8
9
  buildKeyValueLine,
10
+ buildPanelLine,
9
11
  buildStyledPanelLine,
10
12
  buildPanelWorkspace,
11
13
  DEFAULT_PANEL_PALETTE,
14
+ extendPalette,
12
15
  type PanelWorkspaceSection,
13
16
  } from './polish.ts';
14
17
  import { truncateDisplay } from '../utils/terminal-width.ts';
18
+ import { formatLatencyMs } from '../utils/format-duration.ts';
15
19
 
16
20
  // ---------------------------------------------------------------------------
17
21
  // Constants
18
22
  // ---------------------------------------------------------------------------
19
23
 
24
+ // Domain-specific tones not covered by the base palette (latency/token greys).
25
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
26
+ muted2: '#6b7280', // secondary dim grey for unit labels
27
+ sep: '#374151', // inline separator grey
28
+ tokenDim: '#64748b', // token-count grey
29
+ p95: '#a78bfa', // p95 latency accent
30
+ latYellow: '#eab308', // mid-latency warning
31
+ });
32
+
20
33
  const SPARKLINE_CHARS = '._-:=+*#';
21
34
  const LATENCY_RING_SIZE = 20;
22
35
 
@@ -202,7 +215,10 @@ export class ProviderStatsPanel extends BasePanel {
202
215
  width,
203
216
  ' No providers registered',
204
217
  'Load or configure a provider to begin collecting per-provider latency and error metrics.',
205
- [],
218
+ [
219
+ { command: '/provider', summary: 'review current provider and model selection' },
220
+ { command: '/subscription', summary: 'sign in to a subscription-backed provider' },
221
+ ],
206
222
  DEFAULT_PANEL_PALETTE,
207
223
  ),
208
224
  },
@@ -245,6 +261,18 @@ export class ProviderStatsPanel extends BasePanel {
245
261
  title: ' Provider Stats',
246
262
  intro: 'Per-provider request performance, latency distribution, error pressure, and session totals.',
247
263
  sections: providerSections,
264
+ footerLines: [
265
+ buildPanelLine(width, [
266
+ [' sparkline ', C.muted2],
267
+ ['low', C.good],
268
+ [' → ', C.muted2],
269
+ ['high latency', C.bad],
270
+ ]),
271
+ buildKeyboardHints(width, [
272
+ { keys: '/provider', label: 'switch provider/model' },
273
+ { keys: '/health', label: 'live provider health' },
274
+ ], DEFAULT_PANEL_PALETTE),
275
+ ],
248
276
  palette: DEFAULT_PANEL_PALETTE,
249
277
  });
250
278
  }
@@ -262,7 +290,7 @@ export class ProviderStatsPanel extends BasePanel {
262
290
 
263
291
  // Determine health
264
292
  const hasErrors = rec !== undefined && rec.errors > 0;
265
- const dotColor = hasErrors ? '#ef4444' : '#22c55e';
293
+ const dotColor = hasErrors ? C.bad : C.good;
266
294
 
267
295
  // Model ID (truncated)
268
296
  const modelId = rec?.lastModelId ?? 'n/a';
@@ -271,18 +299,18 @@ export class ProviderStatsPanel extends BasePanel {
271
299
  // Header row: * provider model
272
300
  // Build as segments to avoid multi-byte char indexing issues
273
301
  const headerLine = buildStyledPanelLine(width, [
274
- { text: ' ', fg: '#94a3b8' },
302
+ { text: ' ', fg: C.label },
275
303
  { text: '●', fg: dotColor },
276
- { text: ' ', fg: '#94a3b8' },
277
- { text: `${truncateDisplay(provName, 14).padEnd(14)} `, fg: '#e2e8f0', bold: true },
278
- { text: modelDisplay, fg: '#cbd5e1' },
304
+ { text: ' ', fg: C.label },
305
+ { text: `${truncateDisplay(provName, 14).padEnd(14)} `, fg: C.value, bold: true },
306
+ { text: modelDisplay, fg: C.accent },
279
307
  ]);
280
308
 
281
309
  rows.push(headerLine);
282
310
 
283
311
  if (rec === undefined || rec.requests === 0) {
284
312
  rows.push(buildStyledPanelLine(width, [
285
- { text: ' No requests yet.', fg: '#6b7280' },
313
+ { text: ' No requests yet.', fg: C.muted2 },
286
314
  ]));
287
315
  } else {
288
316
  const avgLatency = this._avg(rec.latencies);
@@ -291,24 +319,24 @@ export class ProviderStatsPanel extends BasePanel {
291
319
  const sparkline = this._sparkline(rec.latencies);
292
320
 
293
321
  const latFg = avgLatency < LATENCY_GREEN
294
- ? '#22c55e'
322
+ ? C.good
295
323
  : avgLatency < LATENCY_YELLOW
296
- ? '#eab308'
297
- : '#ef4444';
324
+ ? C.latYellow
325
+ : C.bad;
298
326
 
299
327
  const segments = [
300
- { text: ' avg ', fg: '#6b7280' },
328
+ { text: ' avg ', fg: C.muted2 },
301
329
  { text: this._fmtMs(avgLatency).padStart(6), fg: latFg, bold: true },
302
- { text: ' p95 ', fg: '#6b7280' },
303
- { text: this._fmtMs(p95Latency).padStart(6), fg: '#a78bfa' },
304
- { text: ' ', fg: '#374151' },
330
+ { text: ' p95 ', fg: C.muted2 },
331
+ { text: this._fmtMs(p95Latency).padStart(6), fg: C.p95 },
332
+ { text: ' ', fg: C.sep },
305
333
  { text: sparkline, fg: latFg },
306
- { text: ' err ', fg: '#6b7280' },
307
- { text: `${errRate.toFixed(0).padStart(3)}%`, fg: errRate > 0 ? '#ef4444' : '#22c55e' },
308
- { text: ` ${rec.requests.toString().padStart(4)}r`, fg: '#94a3b8' },
334
+ { text: ' err ', fg: C.muted2 },
335
+ { text: `${errRate.toFixed(0).padStart(3)}%`, fg: errRate > 0 ? C.bad : C.good },
336
+ { text: ` ${rec.requests.toString().padStart(4)}r`, fg: C.label },
309
337
  ] as const;
310
338
  const tokenSegment = rec.totalTokens > 0
311
- ? [{ text: ` ${rec.totalTokens.toString().padStart(6)}tok`, fg: '#64748b' }]
339
+ ? [{ text: ` ${rec.totalTokens.toString().padStart(6)}tok`, fg: C.tokenDim }]
312
340
  : [];
313
341
  rows.push(buildStyledPanelLine(width, [...segments, ...tokenSegment]));
314
342
  }
@@ -351,10 +379,7 @@ export class ProviderStatsPanel extends BasePanel {
351
379
  }
352
380
 
353
381
  private _fmtMs(ms: number): string {
354
- if (ms <= 0) return 'n/a';
355
- if (ms >= 10000) return `${(ms / 1000).toFixed(1)}s`;
356
- if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
357
- return `${Math.round(ms)}ms`;
382
+ return formatLatencyMs(ms);
358
383
  }
359
384
 
360
385
  private _latencyColor(ms: number): string {
@@ -2,9 +2,12 @@ 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,
7
9
  } from './polish.ts';
10
+ import { truncateDisplay } from '../utils/terminal-width.ts';
8
11
  import { renderQrMatrix, generateQrMatrix } from '../renderer/qr-renderer.ts';
9
12
  import { encodeConnectionPayload } from '@pellux/goodvibes-sdk/platform/pairing';
10
13
 
@@ -101,32 +104,41 @@ export class QrPanel extends BasePanel {
101
104
  const lines: Line[] = [];
102
105
 
103
106
  const { url, token, username, password } = this.connectionInfo;
107
+ const valueWidth = Math.max(0, width - 12);
108
+
109
+ // ── Title + purpose: tell the operator exactly what this code is for ────
110
+ lines.push(buildPanelTitle(width, 'Companion Pairing', C));
111
+ lines.push(
112
+ buildPanelLine(width, [
113
+ [' Scan with the GoodVibes companion app to pair this session.', C.hint],
114
+ ]),
115
+ );
104
116
 
105
117
  // ── Connection info header ─────────────────────────────────────────────
106
118
  lines.push(createEmptyLine(width));
107
119
  lines.push(
108
120
  buildPanelLine(width, [
109
121
  [' URL ', C.label],
110
- [url.slice(0, Math.max(0, width - 12)), C.url],
122
+ [truncateDisplay(url, valueWidth), C.url],
111
123
  ]),
112
124
  );
113
125
  lines.push(
114
126
  buildPanelLine(width, [
115
127
  [' Token ', C.label],
116
- [token, C.token],
128
+ [truncateDisplay(token, valueWidth), C.token],
117
129
  ]),
118
130
  );
119
131
  lines.push(
120
132
  buildPanelLine(width, [
121
133
  [' Username ', C.label],
122
- [username.slice(0, Math.max(0, width - 12)), C.value],
134
+ [truncateDisplay(username, valueWidth), C.value],
123
135
  ]),
124
136
  );
125
137
  if (password !== undefined) {
126
138
  lines.push(
127
139
  buildPanelLine(width, [
128
140
  [' Password ', C.label],
129
- [password, C.value],
141
+ [truncateDisplay(password, valueWidth), C.value],
130
142
  ]),
131
143
  );
132
144
  }
@@ -158,13 +170,13 @@ export class QrPanel extends BasePanel {
158
170
  );
159
171
  }
160
172
 
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
- ]);
173
+ // ── Hints ── only advertise actions that are actually wired ─────────────
174
+ const hints: Array<{ keys: string; label: string }> = [];
175
+ if (this.regenerateToken) hints.push({ keys: 'r', label: 'regenerate token' });
176
+ if (this.copyToClipboard) hints.push({ keys: 'c', label: 'copy token' });
177
+ const hintsLine = hints.length > 0
178
+ ? buildKeyboardHints(width, hints, C)
179
+ : buildPanelLine(width, [[' Pairing is read-only in this surface.', C.dim]]);
168
180
 
169
181
  // Push hints at the bottom if we have room, otherwise append after QR
170
182
  const remaining = height - lines.length;
@@ -200,7 +200,7 @@ export class RemotePanel extends BasePanel {
200
200
  if (daemon.lastError) {
201
201
  postureLines.push(buildPanelLine(width, [
202
202
  [' daemon error ', C.label],
203
- [daemon.lastError.slice(0, Math.max(0, width - 14)), C.error],
203
+ [truncateDisplay(daemon.lastError, Math.max(0, width - 14)), C.error],
204
204
  ]));
205
205
  }
206
206
  postureLines.push(
@@ -208,9 +208,16 @@ export class RemotePanel extends BasePanel {
208
208
  buildGuidanceLine(width, '/remote capabilities', 'inspect transport support before routing remote work or reattaching a session', C),
209
209
  );
210
210
 
211
+ const canBrowse = activeConnections.length > 0 || contracts.length > 0;
212
+ const canSwitch = contracts.length > 0 && activeConnections.length > 0;
213
+ const navHint = !canBrowse
214
+ ? ` focus=${this.browseMode} idle - no connections or contracts to browse`
215
+ : canSwitch
216
+ ? ` focus=${this.browseMode} Up/Down move Tab switch connections/contracts`
217
+ : ` focus=${this.browseMode} Up/Down move`;
211
218
  const footerLines = [
212
219
  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]]),
220
+ buildPanelLine(width, [[navHint, C.dim]]),
214
221
  ] as const;
215
222
 
216
223
  if (activeConnections.length === 0 && contracts.length === 0) {
@@ -318,7 +325,7 @@ export class RemotePanel extends BasePanel {
318
325
  [' Review: ', C.label],
319
326
  [supervisorEntry.negotiation.reviewMode, supervisorEntry.negotiation.reviewMode === 'wrfc' ? C.ok : C.dim],
320
327
  ]));
321
- detailRows.push(buildPanelLine(width, [[` ${supervisorEntry.heartbeat.detail}`.slice(0, width), C.dim]]));
328
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` ${supervisorEntry.heartbeat.detail}`, width), C.dim]]));
322
329
  }
323
330
 
324
331
  const recentArtifact = snapshot.artifacts.find((artifact) => artifact.runnerId === selected.agentId);
@@ -400,7 +407,7 @@ export class RemotePanel extends BasePanel {
400
407
  { text: ` ${connection.transportState.padEnd(18)}`, fg: stateColor(connection.transportState) },
401
408
  { text: ` msgs=${String(connection.messageCount).padEnd(6)}`, fg: C.info },
402
409
  { 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 },
410
+ { text: truncateDisplay(` ${connection.label}`, Math.max(0, width - 56)), fg: C.dim },
404
411
  ], C, { selected: absolute === this.selectedIndex, selectedBg: C.headerBg });
405
412
  })
406
413
  : [
@@ -408,7 +415,7 @@ export class RemotePanel extends BasePanel {
408
415
  return buildPanelListRow(width, [
409
416
  { text: contract.runnerId.padEnd(18), fg: C.value },
410
417
  { 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 },
418
+ { text: truncateDisplay(` ${contract.template}`, Math.max(0, width - 42)), fg: C.dim },
412
419
  ], C, { selected: absolute === this.selectedIndex, selectedBg: C.headerBg });
413
420
  }),
414
421
  buildPanelLine(width, [[' No active connection is currently attached to these contracts.', C.dim]]),
@@ -4,9 +4,11 @@ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { UiReadModel, UiRoutesSnapshot } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
6
  import {
7
+ buildDetailBlock,
7
8
  buildEmptyState,
8
9
  buildGuidanceLine,
9
10
  buildKeyValueLine,
11
+ buildKeyboardHints,
10
12
  buildPanelLine,
11
13
  buildPanelWorkspace,
12
14
  buildStatusPill,
@@ -39,10 +41,20 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
39
41
  public constructor(readModel?: UiReadModel<UiRoutesSnapshot>) {
40
42
  super('routes', 'Routes', 'R', 'monitoring');
41
43
  this.showSelectionGutter = true; // I5: non-color selection affordance
44
+ this.filterEnabled = true;
45
+ this.filterLabel = 'Filter routes';
42
46
  this.readModel = readModel;
43
47
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
44
48
  }
45
49
 
50
+ protected override filterMatches(binding: RouteBinding, q: string): boolean {
51
+ return binding.surfaceKind.toLowerCase().includes(q)
52
+ || (binding.title ?? '').toLowerCase().includes(q)
53
+ || binding.externalId.toLowerCase().includes(q)
54
+ || (binding.sessionId ?? '').toLowerCase().includes(q)
55
+ || (binding.runId ?? '').toLowerCase().includes(q);
56
+ }
57
+
46
58
  public override onDestroy(): void {
47
59
  this.unsub?.();
48
60
  }
@@ -127,7 +139,7 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
127
139
  this.clampSelection();
128
140
  const selected = bindings[this.selectedIndex]!;
129
141
 
130
- const footerLines: Line[] = [
142
+ const detailRows: Line[] = [
131
143
  buildPanelLine(width, [
132
144
  [' Binding: ', C.label],
133
145
  [selected.id, C.value],
@@ -159,20 +171,30 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
159
171
  ];
160
172
 
161
173
  if (surfaceEntries.length > 0) {
162
- footerLines.push(
163
- ...surfaceEntries.slice(0, 6).map(([surface, ids]) => buildPanelLine(width, [
174
+ detailRows.push(
175
+ ...surfaceEntries.slice(0, 4).map(([surface, ids]) => buildPanelLine(width, [
164
176
  [' ', C.label],
165
177
  [surface.padEnd(10), C.info],
166
178
  [` ${String(ids.length)} binding(s)`, C.value],
167
179
  ])),
168
180
  );
169
181
  }
170
- footerLines.push(buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]]));
182
+
183
+ const hints = this.filterActive
184
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
185
+ : [
186
+ { keys: 'Up/Down', label: 'move' },
187
+ { keys: '/communication', label: 'inspect flow' },
188
+ { keys: '/', label: 'filter' },
189
+ ];
171
190
 
172
191
  return this.renderList(width, height, {
173
192
  title: 'Route Bindings',
174
193
  header: headerLines,
175
- footer: footerLines,
194
+ footer: [
195
+ ...buildDetailBlock(width, `Binding · ${selected.surfaceKind}`, detailRows, C),
196
+ buildKeyboardHints(width, hints, C),
197
+ ],
176
198
  });
177
199
  }
178
200
  }
@@ -5,9 +5,11 @@ import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
5
5
  import { buildSandboxReview, listSandboxPresets, listSandboxProfiles } from '@/runtime/index.ts';
6
6
  import type { SandboxSessionRegistry } from '@/runtime/index.ts';
7
7
  import {
8
+ buildAlignedRow,
8
9
  buildBodyText,
9
10
  buildEmptyState,
10
11
  buildGuidanceLine,
12
+ buildKeyboardHints,
11
13
  buildKeyValueLine,
12
14
  buildPanelLine,
13
15
  buildPanelWorkspace,
@@ -125,7 +127,10 @@ export class SandboxPanel extends BasePanel {
125
127
  buildGuidanceLine(width, '/sandbox wrapper-test <profile>', 'validate the wrapper bridge contract before wiring a real guest transport', C),
126
128
  buildGuidanceLine(width, '/sandbox session run <id> <command> [args...]', 'execute through a tracked sandbox session and capture runtime metadata on the session record', C),
127
129
  buildGuidanceLine(width, 'GV_SANDBOX_WRAPPER_MODE=host-exec', 'validate the wrapper contract on the host before wiring a real guest transport', C),
128
- buildPanelLine(width, [[` Up/Down move Home/End jump focus=profiles+sessions`, C.dim]]),
130
+ buildKeyboardHints(width, [
131
+ { keys: '↑/↓', label: 'select profile/session' },
132
+ { keys: 'Home/End', label: 'jump to first profile / first session' },
133
+ ], C),
129
134
  ];
130
135
 
131
136
  const selectionLines: Line[] = [];
@@ -185,41 +190,71 @@ export class SandboxPanel extends BasePanel {
185
190
  ));
186
191
  } else {
187
192
  for (const session of sessions) {
188
- const bg = selectedSession?.id === session.id ? C.headerBg : undefined;
189
- sessionLines.push(buildPanelLine(width, [
190
- [' ', C.label],
191
- [session.profileId.padEnd(15), C.info, bg],
192
- [session.state.padEnd(10), session.state === 'running' ? C.good : session.state === 'failed' ? C.bad : C.warn, bg],
193
- [(session.shared ? 'shared' : 'dedicated').padEnd(12), C.value, bg],
194
- [String(session.resolvedBackend ?? session.backend).padEnd(8), C.dim, bg],
195
- [` ${(session.startupStatus ?? 'n/a').slice(0, 8).padEnd(8)}`, session.startupStatus === 'verified' ? C.good : session.startupStatus === 'failed' ? C.bad : C.warn, bg],
196
- [` ${String(session.executionCount ?? 0).padStart(3)}x`, C.info, bg],
197
- [` ${session.id.slice(0, Math.max(8, Math.min(14, width - 64)))}`, C.dim, bg],
198
- ]));
193
+ const selectedRow = selectedSession?.id === session.id;
194
+ sessionLines.push(buildAlignedRow(
195
+ width,
196
+ [
197
+ { text: session.profileId, fg: C.info },
198
+ { text: session.state, fg: session.state === 'running' ? C.good : session.state === 'failed' ? C.bad : C.warn },
199
+ { text: session.shared ? 'shared' : 'dedicated', fg: C.value },
200
+ { text: String(session.resolvedBackend ?? session.backend), fg: C.dim },
201
+ { text: session.startupStatus ?? 'n/a', fg: session.startupStatus === 'verified' ? C.good : session.startupStatus === 'failed' ? C.bad : C.warn },
202
+ { text: `${session.executionCount ?? 0}x`, fg: C.info },
203
+ { text: session.id, fg: C.dim },
204
+ ],
205
+ [
206
+ { width: 15 },
207
+ { width: 10 },
208
+ { width: 12 },
209
+ { width: 8 },
210
+ { width: 9 },
211
+ { width: 5, align: 'right' },
212
+ { width: Math.max(8, width - 72) },
213
+ ],
214
+ { selected: selectedRow, selectedBg: C.headerBg },
215
+ ));
199
216
  }
200
217
  }
201
218
 
202
219
  const presetLines: Line[] = [];
203
220
  for (const preset of presets.slice(0, 2)) {
204
- presetLines.push(buildPanelLine(width, [
205
- [' ', C.label],
206
- [preset.id.padEnd(18), C.info],
207
- [preset.config.replIsolation.padEnd(16), C.value],
208
- [preset.config.mcpIsolation.padEnd(16), C.dim],
209
- [preset.config.windowsMode, C.warn],
210
- ]));
221
+ presetLines.push(buildAlignedRow(
222
+ width,
223
+ [
224
+ { text: preset.id, fg: C.info },
225
+ { text: preset.config.replIsolation, fg: C.value },
226
+ { text: preset.config.mcpIsolation, fg: C.dim },
227
+ { text: preset.config.windowsMode, fg: C.warn },
228
+ ],
229
+ [
230
+ { width: 18 },
231
+ { width: 16 },
232
+ { width: 16 },
233
+ { width: Math.max(8, width - 62) },
234
+ ],
235
+ {},
236
+ ));
211
237
  }
212
238
 
213
239
  const profileLines: Line[] = [];
214
240
  for (const profile of profiles) {
215
- const bg = selectedProfile?.id === profile.id ? C.headerBg : undefined;
216
- profileLines.push(buildPanelLine(width, [
217
- [' ', C.label],
218
- [profile.id.padEnd(15), C.info, bg],
219
- [profile.isolation.padEnd(14), C.value, bg],
220
- [profile.kind.padEnd(12), C.dim, bg],
221
- [` vm=${profile.requiresVm ? 'yes' : 'no'}`, profile.requiresVm ? C.good : C.warn, bg],
222
- ]));
241
+ const selectedRow = selectedProfile?.id === profile.id;
242
+ profileLines.push(buildAlignedRow(
243
+ width,
244
+ [
245
+ { text: profile.id, fg: C.info },
246
+ { text: profile.isolation, fg: C.value },
247
+ { text: profile.kind, fg: C.dim },
248
+ { text: `vm=${profile.requiresVm ? 'yes' : 'no'}`, fg: profile.requiresVm ? C.good : C.warn },
249
+ ],
250
+ [
251
+ { width: 15 },
252
+ { width: 14 },
253
+ { width: 12 },
254
+ { width: Math.max(6, width - 53) },
255
+ ],
256
+ { selected: selectedRow, selectedBg: C.headerBg },
257
+ ));
223
258
  }
224
259
  const postureSection: PanelWorkspaceSection = { title: 'Sandbox posture', lines: overviewLines };
225
260
  const selectedSection: PanelWorkspaceSection = { title: selectedProfile ? 'Selected Profile' : 'Selected Session', lines: selectionLines };
@@ -1,11 +1,13 @@
1
1
  import { BasePanel } from './base-panel.ts';
2
2
  import { type Line } from '../types/grid.ts';
3
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automation';
4
5
  import type { AutomationJob } from '@pellux/goodvibes-sdk/platform/automation';
5
6
  import type { AutomationRun } from '@pellux/goodvibes-sdk/platform/automation';
6
7
  import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
7
8
  import {
8
9
  buildEmptyState,
10
+ buildKeyboardHints,
9
11
  buildPanelLine,
10
12
  buildPanelWorkspace,
11
13
  extendPalette,
@@ -87,11 +89,16 @@ export class SchedulePanel extends BasePanel {
87
89
  private refreshTimerId: ReturnType<typeof setInterval> | null = null;
88
90
  private readonly automationManager: ScheduleAutomationManager;
89
91
 
90
- constructor(automationManager: ScheduleAutomationManager) {
92
+ constructor(automationManager: ScheduleAutomationManager, private readonly requestRender: () => void = () => {}) {
91
93
  super('schedule', 'Schedule', 'Z', 'agent');
92
94
  this.automationManager = automationManager;
93
95
  }
94
96
 
97
+ private _markDirtyAndRender(): void {
98
+ this.markDirty();
99
+ this.requestRender();
100
+ }
101
+
95
102
  // -------------------------------------------------------------------------
96
103
  // Lifecycle
97
104
  // -------------------------------------------------------------------------
@@ -100,12 +107,12 @@ export class SchedulePanel extends BasePanel {
100
107
  super.onActivate();
101
108
  void this.automationManager.start().then(() => {
102
109
  this.rebuild();
103
- this.markDirty();
110
+ this._markDirtyAndRender();
104
111
  });
105
112
  this.rebuild();
106
113
  this.refreshTimerId = this.registerTimer(setInterval(() => {
107
114
  this.rebuild();
108
- this.markDirty();
115
+ this._markDirtyAndRender();
109
116
  }, 5_000));
110
117
  }
111
118
 
@@ -221,14 +228,19 @@ export class SchedulePanel extends BasePanel {
221
228
  lines: buildEmptyState(
222
229
  width,
223
230
  ' No scheduled tasks',
224
- 'Use /schedule add to create a recurring task. Scheduled runs and history will appear here.',
225
- [],
231
+ 'Create a recurring task and its next-run timing, run history, and enablement state will appear here.',
232
+ [
233
+ { command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring cron task' },
234
+ { command: '/schedule list', summary: 'inspect scheduled tasks from the shell' },
235
+ ],
226
236
  DEFAULT_PANEL_PALETTE,
227
237
  ),
228
238
  },
229
239
  ],
230
240
  footerLines: [
231
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
241
+ buildKeyboardHints(width, [
242
+ { keys: '/schedule add', label: 'create a task' },
243
+ ], DEFAULT_PANEL_PALETTE),
232
244
  ],
233
245
  palette: DEFAULT_PANEL_PALETTE,
234
246
  });
@@ -236,6 +248,7 @@ export class SchedulePanel extends BasePanel {
236
248
 
237
249
  const taskItems = this.items.filter((item): item is Extract<ViewItem, { kind: 'task' }> => item.kind === 'task');
238
250
  this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, taskItems.length - 1));
251
+ const dueSoon = tasks.filter((task: AutomationJob) => typeof task.nextRunAt === 'number').length;
239
252
  const summarySection: PanelWorkspaceSection = {
240
253
  title: 'Summary',
241
254
  lines: [
@@ -244,14 +257,28 @@ export class SchedulePanel extends BasePanel {
244
257
  [String(tasks.length), DEFAULT_PANEL_PALETTE.value],
245
258
  [' Enabled ', DEFAULT_PANEL_PALETTE.label],
246
259
  [String(enabled), enabled > 0 ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.dim],
260
+ [' Paused ', DEFAULT_PANEL_PALETTE.label],
261
+ [String(tasks.length - enabled), tasks.length - enabled > 0 ? DEFAULT_PANEL_PALETTE.warn : DEFAULT_PANEL_PALETTE.dim],
262
+ [' Scheduled ', DEFAULT_PANEL_PALETTE.label],
263
+ [String(dueSoon), dueSoon > 0 ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
247
264
  ]),
248
265
  ],
249
266
  };
267
+ const selectedTask = taskItems[this.selectedIndex]?.task;
268
+ const toggleLabel = selectedTask
269
+ ? (selectedTask.enabled ? 'pause task' : 'enable task')
270
+ : 'toggle';
271
+ const footerLines = [
272
+ buildKeyboardHints(width, [
273
+ { keys: 'Up/Down', label: 'navigate' },
274
+ { keys: 'Space', label: toggleLabel },
275
+ { keys: 'r', label: 'run now' },
276
+ { keys: 'R', label: 'refresh' },
277
+ ], DEFAULT_PANEL_PALETTE),
278
+ ];
250
279
  const scheduledTasksSection = resolveScrollablePanelSection(width, height, {
251
280
  intro: 'Review recurring scheduled tasks, next run timing, recent history, and enablement state.',
252
- footerLines: [
253
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
254
- ],
281
+ footerLines,
255
282
  palette: DEFAULT_PANEL_PALETTE,
256
283
  beforeSections: [summarySection],
257
284
  section: {
@@ -272,9 +299,7 @@ export class SchedulePanel extends BasePanel {
272
299
  title: ' Schedule',
273
300
  intro: 'Review recurring scheduled tasks, next run timing, recent history, and enablement state.',
274
301
  sections,
275
- footerLines: [
276
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
277
- ],
302
+ footerLines,
278
303
  palette: DEFAULT_PANEL_PALETTE,
279
304
  });
280
305
  }
@@ -291,11 +316,11 @@ export class SchedulePanel extends BasePanel {
291
316
 
292
317
  const bullet = task.enabled ? '* ' : 'o ';
293
318
  const bulletFg = task.enabled ? C.enabled : C.disabled;
294
- const nameStr = task.name.length > 28 ? task.name.slice(0, 25) + '...' : task.name.padEnd(28);
319
+ const nameStr = fitDisplay(task.name, 28);
295
320
  const scheduleText = formatSchedule(task.schedule);
296
321
  const row1 = buildPanelLine(width, [
297
322
  [bullet, bulletFg, bg],
298
- [task.id.slice(0, 12), fgBase ?? C.id, bg],
323
+ [fitDisplay(task.id, 12), fgBase ?? C.id, bg],
299
324
  [' ', fgBase ?? C.prompt, bg],
300
325
  [nameStr, fgBase ?? C.prompt, bg],
301
326
  [' ', fgBase ?? C.prompt, bg],
@@ -318,9 +343,7 @@ export class SchedulePanel extends BasePanel {
318
343
 
319
344
  const maxPromptLen = Math.max(20, width - indent.length - 30);
320
345
  const prompt = task.execution.prompt ?? task.description ?? '';
321
- const promptPreview = prompt.length > maxPromptLen
322
- ? prompt.slice(0, maxPromptLen - 1) + '\u2026'
323
- : prompt;
346
+ const promptPreview = truncateDisplay(prompt, maxPromptLen);
324
347
 
325
348
  // Show last 3 run statuses as colored dots
326
349
  const recentRuns = history.slice(-3);