@pellux/goodvibes-tui 0.26.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Line } from '../types/grid.ts';
6
6
  import { BasePanel } from './base-panel.ts';
7
- import { buildPanelLine, buildPanelWorkspace, buildSearchInputLine, resolveScrollablePanelSection, DEFAULT_PANEL_PALETTE } from './polish.ts';
7
+ import { buildKeyboardHints, buildPanelLine, buildPanelWorkspace, buildSearchInputLine, resolveScrollablePanelSection, extendPalette, DEFAULT_PANEL_PALETTE } from './polish.ts';
8
8
  import type { ProviderModelCatalogQuery, ToolCatalogQuery } from '../runtime/ui-service-queries.ts';
9
9
  import {
10
10
  getPanelSearchFocusTransition,
@@ -14,22 +14,12 @@ import {
14
14
  isPanelSearchPrintable,
15
15
  } from './search-focus.ts';
16
16
 
17
- const C = {
18
- headerBg: '#1a1a2e',
19
- headerFg: '#ffffff',
20
- statusBar: '#222233',
21
- statusFg: '#aaaaaa',
22
- sectionFg: '#00ffff',
23
- sectionBg: '#0d1a2a',
24
- toolFg: '#88ccff',
25
- descFg: '#aaaaaa',
26
- keyFg: '#ffcc44',
27
- valueFg: '#ccccdd',
28
- dimFg: '#555566',
29
- selected: '#00ffff',
30
- selectedBg: '#1a2a3a',
31
- searchFg: '#ffffff',
32
- } as const;
17
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
18
+ // Panel-specific domain colors with no clean shared equivalent.
19
+ sectionFg: '#00ffff',
20
+ toolFg: '#88ccff',
21
+ selected: '#00ffff',
22
+ });
33
23
 
34
24
  // ---------------------------------------------------------------------------
35
25
  // Hardcoded keyboard shortcut reference
@@ -68,7 +58,7 @@ interface FlatRow {
68
58
  }
69
59
 
70
60
  function renderRow(width: number, row: FlatRow, isCursor: boolean): Line {
71
- const bg = isCursor ? C.selectedBg : row.bg;
61
+ const bg = isCursor ? C.selectBg : row.bg;
72
62
  return buildPanelLine(width, [
73
63
  [isCursor ? '▸' : ' ', C.selected, bg],
74
64
  [row.text, isCursor ? C.selected : row.fg, bg],
@@ -162,9 +152,24 @@ export class DocsPanel extends BasePanel {
162
152
  buildSearchInputLine(width, '', searchLine.trimStart(), DEFAULT_PANEL_PALETTE, { active: this.searching }),
163
153
  ],
164
154
  } as const;
155
+ // Context-aware footer: while searching, surface only the keys that work in
156
+ // the search field; otherwise surface section + navigation keys.
157
+ const footerLines = [this.searching
158
+ ? buildKeyboardHints(width, [
159
+ { keys: 'type', label: 'filter' },
160
+ { keys: 'Enter/Esc', label: 'apply / exit search' },
161
+ { keys: '↓', label: 'back to list' },
162
+ ], DEFAULT_PANEL_PALETTE)
163
+ : buildKeyboardHints(width, [
164
+ { keys: 't/m/k', label: 'tools / models / shortcuts' },
165
+ { keys: '↑/↓', label: 'navigate' },
166
+ { keys: '/', label: 'search' },
167
+ ], DEFAULT_PANEL_PALETTE)];
168
+
165
169
  const sectionWindow = resolveScrollablePanelSection(width, height, {
166
170
  intro: 'Browse built-in tool docs, available models, and keyboard shortcuts from one shared reference surface.',
167
171
  palette: DEFAULT_PANEL_PALETTE,
172
+ footerLines,
168
173
  beforeSections: [controlsSection],
169
174
  section: {
170
175
  title: sectionLabel,
@@ -186,6 +191,7 @@ export class DocsPanel extends BasePanel {
186
191
  controlsSection,
187
192
  sectionWindow.section.lines.length > 0 ? sectionWindow.section : { title: sectionLabel, lines: [buildPanelLine(width, [[' No matching docs', DEFAULT_PANEL_PALETTE.dim]])] },
188
193
  ],
194
+ footerLines,
189
195
  palette: DEFAULT_PANEL_PALETTE,
190
196
  });
191
197
  }
@@ -217,13 +223,13 @@ export class DocsPanel extends BasePanel {
217
223
  const tools = this.toolRegistry?.list() ?? [];
218
224
  const filtered = q ? tools.filter(t => t.definition.name.toLowerCase().includes(q) || (t.definition.description ?? '').toLowerCase().includes(q)) : tools;
219
225
  if (filtered.length === 0) {
220
- rows.push({ kind: 'empty', text: ' No tools match.', fg: C.dimFg, bg: '' });
226
+ rows.push({ kind: 'empty', text: q ? ` No tools match "${this.searchQuery}". Press Esc to clear the filter.` : ' Tool registry not wired into this session.', fg: C.dim, bg: '' });
221
227
  } else {
222
228
  rows.push({ kind: 'header', text: ` Tools (${filtered.length})`, fg: C.sectionFg, bg: C.sectionBg, bold: true });
223
229
  for (const tool of filtered) {
224
230
  rows.push({ kind: 'item', text: ` ${tool.definition.name}`, fg: C.toolFg, bg: '', bold: true });
225
231
  if (tool.definition.description) {
226
- rows.push({ kind: 'detail', text: ` ${tool.definition.description}`, fg: C.descFg, bg: '' });
232
+ rows.push({ kind: 'detail', text: ` ${tool.definition.description}`, fg: C.label, bg: '' });
227
233
  }
228
234
  const metadata: string[] = [];
229
235
  if (tool.definition.sideEffects && tool.definition.sideEffects.length > 0) {
@@ -239,7 +245,7 @@ export class DocsPanel extends BasePanel {
239
245
  metadata.push('streaming');
240
246
  }
241
247
  if (metadata.length > 0) {
242
- rows.push({ kind: 'detail', text: ` ${metadata.join(' | ')}`, fg: C.dimFg, bg: '' });
248
+ rows.push({ kind: 'detail', text: ` ${metadata.join(' | ')}`, fg: C.dim, bg: '' });
243
249
  }
244
250
  }
245
251
  }
@@ -247,7 +253,7 @@ export class DocsPanel extends BasePanel {
247
253
  const models = this.providerRegistry?.listModels() ?? [];
248
254
  const filtered = q ? models.filter(m => m.id.toLowerCase().includes(q) || m.displayName.toLowerCase().includes(q) || m.provider.toLowerCase().includes(q)) : models;
249
255
  if (filtered.length === 0) {
250
- rows.push({ kind: 'empty', text: ' No models match.', fg: C.dimFg, bg: '' });
256
+ rows.push({ kind: 'empty', text: q ? ` No models match "${this.searchQuery}". Press Esc to clear the filter.` : ' Provider registry not wired into this session.', fg: C.dim, bg: '' });
251
257
  } else {
252
258
  // Group by provider
253
259
  const byProvider = new Map<string, typeof filtered>();
@@ -262,7 +268,7 @@ export class DocsPanel extends BasePanel {
262
268
  const ctxK = m.contextWindow > 0 ? `${(m.contextWindow / 1000).toFixed(0)}k` : '?';
263
269
  const caps = [m.contextWindow > 0 ? `ctx:${ctxK}` : ''].filter(Boolean).join(' ');
264
270
  rows.push({ kind: 'item', text: ` ${m.displayName} ${caps}`, fg: C.toolFg, bg: '' });
265
- rows.push({ kind: 'detail', text: ` ${m.id}`, fg: C.descFg, bg: '' });
271
+ rows.push({ kind: 'detail', text: ` ${m.id}`, fg: C.label, bg: '' });
266
272
  }
267
273
  }
268
274
  }
@@ -272,7 +278,7 @@ export class DocsPanel extends BasePanel {
272
278
  rows.push({ kind: 'header', text: ' Keyboard Shortcuts', fg: C.sectionFg, bg: C.sectionBg, bold: true });
273
279
  for (const s of filtered) {
274
280
  const key = s.key.padEnd(16);
275
- rows.push({ kind: 'item', text: ` ${key} ${s.desc}`, fg: C.valueFg, bg: '' });
281
+ rows.push({ kind: 'item', text: ` ${key} ${s.desc}`, fg: C.value, bg: '' });
276
282
  }
277
283
  }
278
284
 
@@ -6,15 +6,21 @@
6
6
  */
7
7
 
8
8
  import { BasePanel } from './base-panel.ts';
9
+ import { truncateDisplay } from '../utils/terminal-width.ts';
9
10
  import type { Line } from '../types/grid.ts';
10
11
  import type { KeyName } from './types.ts';
11
12
  import { createEmptyLine } from '../types/grid.ts';
12
13
  import {
14
+ buildAlignedRow,
13
15
  buildEmptyState,
16
+ buildKeyboardHints,
17
+ buildMeterLine,
14
18
  buildPanelLine,
15
19
  buildPanelWorkspace,
20
+ buildTable,
16
21
  resolveScrollablePanelSection,
17
22
  DEFAULT_PANEL_PALETTE,
23
+ extendPalette,
18
24
  } from './polish.ts';
19
25
 
20
26
  // ── EvalRegistry ─────────────────────────────────────────────────────────────
@@ -25,6 +31,7 @@ import type {
25
31
  EvalGateResult,
26
32
  EvalDimension,
27
33
  } from '@/runtime/index.ts';
34
+ import { formatShortDuration } from '../utils/format-duration.ts';
28
35
 
29
36
  /**
30
37
  * Holds the latest eval run state for display in EvalPanel.
@@ -78,24 +85,19 @@ export class EvalRegistry {
78
85
  }
79
86
  }
80
87
 
81
- // ── Colour palette (hex fg colours for createStyledCell) ─────────────────────
88
+ // ── Colour palette (no inline hex in render bodies) ──────────────────────────
82
89
 
83
- const C = {
84
- ...DEFAULT_PANEL_PALETTE,
90
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
85
91
  header: '#94a3b8',
86
92
  headerBg: '#1e293b',
87
93
  cyan: '#38bdf8',
88
94
  green: '#22c55e',
89
95
  yellow: '#eab308',
90
96
  red: '#ef4444',
91
- dim: '#4b5563',
92
- label: '#64748b',
93
- value: '#e2e8f0',
94
- selected: '#f1f5f9',
95
- sep: '#1e293b',
96
97
  white: '#cbd5e1',
98
+ selected: '#f1f5f9',
97
99
  selectBg: '#0f172a',
98
- } as const;
100
+ });
99
101
 
100
102
  // ── Helpers ───────────────────────────────────────────────────────────────────
101
103
 
@@ -106,8 +108,7 @@ function scoreColor(score: number): string {
106
108
  }
107
109
 
108
110
  function fmtTime(ms: number): string {
109
- if (ms < 1000) return `${ms.toFixed(0)}ms`;
110
- return `${(ms / 1000).toFixed(1)}s`;
111
+ return formatShortDuration(ms);
111
112
  }
112
113
 
113
114
  const DIMENSION_ORDER: EvalDimension[] = ['safety', 'quality', 'latency', 'cost', 'recovery'];
@@ -207,15 +208,6 @@ export class EvalPanel extends BasePanel {
207
208
  const gates = this._registry.getGateResults();
208
209
  const intro = 'Evaluation harness runs, gates, scenario scorecards, and regression indicators for model and product validation.';
209
210
 
210
- const running = this._registry.isRunning();
211
- const lastRun = this._registry.getLastRunAt();
212
- const summaryLine = buildPanelLine(width, [
213
- [' state: ', C.label],
214
- [running ? 'running' : 'idle', running ? C.yellow : C.dim],
215
- [' last: ', C.label],
216
- [lastRun ? new Date(lastRun).toLocaleTimeString() : 'n/a', C.dim],
217
- ]);
218
-
219
211
  if (suites.length === 0) {
220
212
  const workspace = buildPanelWorkspace(width, height, {
221
213
  title: 'Eval Harness',
@@ -223,7 +215,7 @@ export class EvalPanel extends BasePanel {
223
215
  sections: [{
224
216
  title: 'Status',
225
217
  lines: [
226
- summaryLine,
218
+ this._statusLine(width),
227
219
  ...buildEmptyState(
228
220
  width,
229
221
  ' No results yet.',
@@ -241,17 +233,37 @@ export class EvalPanel extends BasePanel {
241
233
 
242
234
  const lines: Line[] = [];
243
235
  if (this._mode === 'list') {
244
- this._renderList(lines, suites, gates, width, height, intro, summaryLine);
236
+ this._renderList(lines, suites, gates, width, height, intro);
245
237
  } else {
246
238
  const suite = suites[this._selectedSuiteIdx];
247
239
  if (suite) {
248
- this._renderDetail(lines, suite, width, height, intro, summaryLine);
240
+ this._renderDetail(lines, suite, width, height, intro);
249
241
  }
250
242
  }
251
243
 
252
244
  return lines;
253
245
  }
254
246
 
247
+ // ── Shared summary line ───────────────────────────────────────────────────
248
+
249
+ private _statusLine(width: number): Line {
250
+ const running = this._registry.isRunning();
251
+ const lastRun = this._registry.getLastRunAt();
252
+ const suites = this._registry.getSuiteResults();
253
+ const passed = suites.filter((s) => s.passed).length;
254
+ const failed = suites.length - passed;
255
+ return buildPanelLine(width, [
256
+ [' state ', C.label],
257
+ [running ? 'running' : 'idle', running ? C.yellow : C.dim],
258
+ [' passed ', C.label],
259
+ [String(passed), passed > 0 ? C.green : C.dim],
260
+ [' failed ', C.label],
261
+ [String(failed), failed > 0 ? C.red : C.dim],
262
+ [' last ', C.label],
263
+ [lastRun ? new Date(lastRun).toLocaleTimeString() : 'n/a', C.dim],
264
+ ]);
265
+ }
266
+
255
267
  // ── List view ────────────────────────────────────────────────────────────────
256
268
 
257
269
  private _renderList(
@@ -259,49 +271,70 @@ export class EvalPanel extends BasePanel {
259
271
  suites: EvalSuiteResult[],
260
272
  gates: EvalGateResult[],
261
273
  width: number,
262
- _height: number,
274
+ height: number,
263
275
  intro: string,
264
- summaryLine: Line,
265
276
  ): void {
266
277
  const gateMap = new Map(gates.map((g) => [g.suite, g]));
267
- const sectionLines: Line[] = [
268
- summaryLine,
269
- buildPanelLine(width, [
270
- ['Suite'.padEnd(28), C.header],
271
- ['Score'.padEnd(8), C.header],
272
- ['Pass'.padEnd(6), C.header],
273
- ['Gate'.padEnd(6), C.header],
274
- ['Duration', C.header],
275
- ]),
276
- ];
277
278
 
278
- suites.forEach((suite, idx) => {
279
+ const tableRows = suites.map((suite, idx) => {
279
280
  const selected = idx === this._selectedSuiteIdx;
280
281
  const gate = gateMap.get(suite.suite);
281
282
  const gateStr = gate ? (gate.passed ? 'ok' : 'FAIL') : '-';
282
283
  const gateColor = gate ? (gate.passed ? C.green : C.red) : C.dim;
283
284
  const durationMs = suite.finishedAt - suite.startedAt;
284
- const scoreC = scoreColor(suite.meanScore);
285
- const passC = suite.passed ? C.green : C.red;
286
- const nameColor = selected ? C.selected : C.white;
287
- const bg = selected ? C.selectBg : undefined;
288
- const prefix = selected ? '▸ ' : ' ';
289
- const name = suite.suite.slice(0, 24).padEnd(26);
290
-
291
- sectionLines.push(buildPanelLine(width, [
292
- [prefix + name, nameColor, bg],
293
- [suite.meanScore.toFixed(1).padEnd(8), scoreC, bg],
294
- [(suite.passed ? 'PASS' : 'FAIL').padEnd(6), passC, bg],
295
- [gateStr.padEnd(6), gateColor, bg],
296
- [fmtTime(durationMs), C.dim, bg],
297
- ]));
285
+ return {
286
+ selected,
287
+ cells: [
288
+ { text: suite.suite, fg: selected ? C.selected : C.white, bold: selected },
289
+ { text: suite.meanScore.toFixed(1), fg: scoreColor(suite.meanScore) },
290
+ { text: suite.passed ? 'PASS' : 'FAIL', fg: suite.passed ? C.green : C.red },
291
+ { text: gateStr, fg: gateColor },
292
+ { text: fmtTime(durationMs), fg: C.dim },
293
+ ],
294
+ };
298
295
  });
299
296
 
300
- sectionLines.push(buildPanelLine(width, [[' Enter/l: detail j/k: navigate', C.dim]]));
301
- lines.push(...buildPanelWorkspace(width, _height, {
297
+ const tableLines = buildTable(width, [
298
+ { label: 'Suite' },
299
+ { label: 'Score', width: 7, align: 'right' },
300
+ { label: 'Pass', width: 6 },
301
+ { label: 'Gate', width: 6 },
302
+ { label: 'Duration', width: 10, align: 'right' },
303
+ ], tableRows, C, { selectedBg: C.selectBg });
304
+
305
+ const selectedSuite = suites[this._selectedSuiteIdx];
306
+ const detailLines: Line[] = [];
307
+ if (selectedSuite) {
308
+ const passCount = selectedSuite.results.filter((r) => r.scorecard.passed).length;
309
+ detailLines.push(buildPanelLine(width, [
310
+ [' selected ', C.label],
311
+ [selectedSuite.suite, C.cyan],
312
+ [' scenarios ', C.label],
313
+ [`${passCount}/${selectedSuite.results.length} passed`, passCount === selectedSuite.results.length ? C.green : C.yellow],
314
+ ]));
315
+ const meterW = Math.max(10, Math.min(30, width - 24));
316
+ detailLines.push(buildMeterLine(width, Math.round((selectedSuite.meanScore / 100) * meterW), meterW, {
317
+ filled: scoreColor(selectedSuite.meanScore),
318
+ empty: C.empty,
319
+ label: C.label,
320
+ }, { prefix: ' mean ', suffix: ` ${selectedSuite.meanScore.toFixed(1)}/100 ` }));
321
+ }
322
+
323
+ const footer = buildKeyboardHints(width, [
324
+ { keys: `${this._selectedSuiteIdx + 1}/${suites.length}`, label: 'suite' },
325
+ { keys: '↑/↓', label: 'navigate' },
326
+ { keys: 'Enter', label: 'open detail' },
327
+ ], C);
328
+
329
+ lines.push(...buildPanelWorkspace(width, height, {
302
330
  title: 'Eval Harness',
303
331
  intro,
304
- sections: [{ title: 'Suites', lines: sectionLines }],
332
+ sections: [
333
+ { lines: [this._statusLine(width)] },
334
+ { title: 'Suites', lines: tableLines },
335
+ ...(detailLines.length > 0 ? [{ title: 'Selected Suite', lines: detailLines }] : []),
336
+ ],
337
+ footerLines: [footer],
305
338
  palette: C,
306
339
  }));
307
340
  }
@@ -314,16 +347,19 @@ export class EvalPanel extends BasePanel {
314
347
  width: number,
315
348
  height: number,
316
349
  intro: string,
317
- summaryLine: Line,
318
350
  ): void {
319
- const sectionLines: Line[] = [
320
- summaryLine,
351
+ const passCount = suite.results.filter((r) => r.scorecard.passed).length;
352
+ const headerLines: Line[] = [
353
+ this._statusLine(width),
321
354
  buildPanelLine(width, [
322
- [`Suite: ${suite.suite}`, C.cyan],
323
- [' mean=', C.label],
324
- [suite.meanScore.toFixed(1), scoreColor(suite.meanScore)],
325
- [' ', C.label],
326
- [suite.passed ? 'PASS' : 'FAIL', suite.passed ? C.green : C.red],
355
+ [' suite ', C.label],
356
+ [suite.suite, C.cyan],
357
+ [' mean ', C.label],
358
+ [suite.meanScore.toFixed(1), scoreColor(suite.meanScore)],
359
+ [' ', C.label],
360
+ [suite.passed ? 'PASS' : 'FAIL', suite.passed ? C.green : C.red],
361
+ [' scenarios ', C.label],
362
+ [`${passCount}/${suite.results.length} passed`, passCount === suite.results.length ? C.green : C.yellow],
327
363
  ]),
328
364
  ];
329
365
 
@@ -333,23 +369,32 @@ export class EvalPanel extends BasePanel {
333
369
  this._renderScenarioBlock(allDetailLines, result, selected, width);
334
370
  });
335
371
 
372
+ const footer = buildKeyboardHints(width, [
373
+ { keys: `${this._selectedScenarioIdx + 1}/${suite.results.length}`, label: 'scenario' },
374
+ { keys: 'Esc', label: 'back' },
375
+ { keys: '↑/↓', label: 'navigate' },
376
+ { keys: 'PgUp/PgDn', label: 'scroll' },
377
+ ], C);
378
+
336
379
  const detailSection = resolveScrollablePanelSection(width, height, {
337
380
  intro,
381
+ footerLines: [footer],
338
382
  palette: C,
339
- beforeSections: [{ title: 'Scenario Detail', lines: sectionLines }],
383
+ beforeSections: [{ title: `Scenario Detail · ${suite.suite}`, lines: headerLines }],
340
384
  section: {
341
385
  scrollableLines: allDetailLines,
342
386
  scrollOffset: this._scrollOffset,
343
387
  minRows: 1,
388
+ appendWindowSummary: { dimColor: C.dim },
344
389
  },
345
390
  });
346
391
  this._scrollOffset = detailSection.scrollOffset;
347
- sectionLines.push(...detailSection.section.lines);
348
- sectionLines.push(buildPanelLine(width, [[' Esc/q: back j/k: scenario PgUp/PgDn: scroll', C.dim]]));
392
+
349
393
  lines.push(...buildPanelWorkspace(width, height, {
350
394
  title: 'Eval Harness',
351
395
  intro,
352
- sections: [{ title: 'Scenario Detail', lines: sectionLines }],
396
+ sections: [{ title: `Scenario Detail · ${suite.suite}`, lines: [...headerLines, ...detailSection.section.lines] }],
397
+ footerLines: [footer],
353
398
  palette: C,
354
399
  }));
355
400
  }
@@ -361,37 +406,41 @@ export class EvalPanel extends BasePanel {
361
406
  width: number,
362
407
  ): void {
363
408
  const sc = result.scorecard;
364
- const prefix = selected ? '▸ ' : ' ';
365
- const nameColor = selected ? C.selected : C.white;
366
409
  const scoreC = scoreColor(sc.compositeScore);
367
410
  const passC = sc.passed ? C.green : C.red;
368
- const nameLen = Math.max(1, width - 22);
369
411
 
370
- lines.push(buildPanelLine(width, [
371
- [prefix + result.scenario.name.slice(0, nameLen).padEnd(nameLen + 2), nameColor, selected ? C.selectBg : undefined],
372
- [sc.compositeScore.toFixed(1).padStart(5), scoreC, selected ? C.selectBg : undefined],
373
- [' ', C.label, selected ? C.selectBg : undefined],
374
- [sc.passed ? 'PASS' : 'FAIL', passC, selected ? C.selectBg : undefined],
375
- ]));
412
+ lines.push(buildAlignedRow(
413
+ width,
414
+ [
415
+ { text: result.scenario.name, fg: selected ? C.selected : C.white, bold: selected },
416
+ { text: sc.compositeScore.toFixed(1), fg: scoreC },
417
+ { text: sc.passed ? 'PASS' : 'FAIL', fg: passC },
418
+ ],
419
+ [
420
+ { width: Math.max(8, width - 16) },
421
+ { width: 6, align: 'right' },
422
+ { width: 5 },
423
+ ],
424
+ { gap: 1, selected, selectedBg: C.selectBg, marker: '▸' },
425
+ ));
376
426
 
377
427
  if (selected) {
378
428
  for (const dim of DIMENSION_ORDER) {
379
429
  const d = sc.dimensions.find((x) => x.dimension === dim);
380
430
  if (!d) continue;
381
431
  const filled = Math.round(d.score / 10);
382
- const bar = '#'.repeat(filled) + '.'.repeat(10 - filled);
383
- lines.push(buildPanelLine(width, [
384
- [' ' + dim.padEnd(10) + ' ', C.label],
385
- [bar, scoreColor(d.score)],
386
- [` ${d.score.toFixed(0).padStart(3)}/100`, C.value],
387
- ]));
432
+ lines.push(buildMeterLine(width, filled, 10, {
433
+ filled: scoreColor(d.score),
434
+ empty: C.empty,
435
+ label: C.label,
436
+ }, { prefix: ` ${dim.padEnd(9)} `, suffix: ` ${d.score.toFixed(0).padStart(3)}/100 ` }));
388
437
  }
389
438
 
390
439
  if (sc.notes && sc.notes.length > 0) {
391
440
  for (const note of sc.notes) {
392
441
  lines.push(buildPanelLine(width, [
393
442
  [' ! ', C.yellow],
394
- [note.slice(0, width - 6), C.yellow],
443
+ [truncateDisplay(note, Math.max(8, width - 6)), C.yellow],
395
444
  ]));
396
445
  }
397
446
  }
@@ -0,0 +1,189 @@
1
+ import type { Line } from '../types/grid.ts';
2
+ import { createEmptyLine } from '../types/grid.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
+ import {
5
+ buildKeyboardHints,
6
+ buildPanelWorkspace,
7
+ resolveScrollablePanelSection,
8
+ } from './polish.ts';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // ExpandableListPanel<T>
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * A list panel with a built-in master → detail drill-down.
16
+ *
17
+ * Encapsulates the collapse/expand + detail-view pattern that was previously
18
+ * hand-rolled in git, tool-inspector, agent-inspector, approval, and wrfc
19
+ * panels: a scrollable list where pressing Enter/→ on a row opens a scrollable
20
+ * detail view for that item, and Esc/← returns to the list.
21
+ *
22
+ * Subclasses implement (in addition to the `ScrollableListPanel` contract):
23
+ * - `getDetailLines(item, width)` — the detail body for the expanded item.
24
+ *
25
+ * Optionally override:
26
+ * - `getDetailTitle(item)` — detail-view title (defaults to `this.name`).
27
+ * - `getListHints()` / `getDetailHints()` — footer keyboard hints per mode.
28
+ * - `renderListView(width, height)` — the list-mode body (defaults to
29
+ * `renderList` with the list hints footer).
30
+ *
31
+ * Do NOT override `render()`; it dispatches between list and detail modes.
32
+ */
33
+ export abstract class ExpandableListPanel<T> extends ScrollableListPanel<T> {
34
+ /** The item currently expanded into the detail view, or null in list mode. */
35
+ protected expandedItem: T | null = null;
36
+ /** First visible row of the detail view. */
37
+ protected detailScroll = 0;
38
+
39
+ /** Render the detail body lines for an expanded item. */
40
+ protected abstract getDetailLines(item: T, width: number): readonly Line[];
41
+
42
+ /** Title shown above the detail view. */
43
+ protected getDetailTitle(_item: T): string {
44
+ return this.name;
45
+ }
46
+
47
+ /** Keyboard hints shown in list mode. */
48
+ protected getListHints(): ReadonlyArray<{ keys: string; label: string }> {
49
+ return [
50
+ { keys: '↑/↓', label: 'move' },
51
+ { keys: 'Enter', label: 'open' },
52
+ ];
53
+ }
54
+
55
+ /** Keyboard hints shown in detail mode. */
56
+ protected getDetailHints(): ReadonlyArray<{ keys: string; label: string }> {
57
+ return [
58
+ { keys: '↑/↓', label: 'scroll' },
59
+ { keys: 'Esc', label: 'back' },
60
+ ];
61
+ }
62
+
63
+ protected isExpanded(): boolean {
64
+ return this.expandedItem !== null;
65
+ }
66
+
67
+ /** Expand the currently selected item into the detail view. */
68
+ protected expandSelected(): void {
69
+ const items = this.getItems();
70
+ const item = items[this.selectedIndex];
71
+ if (item === undefined) return;
72
+ this.expandedItem = item;
73
+ this.detailScroll = 0;
74
+ this.needsRender = true;
75
+ }
76
+
77
+ /** Collapse the detail view back to the list. */
78
+ protected collapse(): void {
79
+ if (this.expandedItem === null) return;
80
+ this.expandedItem = null;
81
+ this.detailScroll = 0;
82
+ this.needsRender = true;
83
+ }
84
+
85
+ // -------------------------------------------------------------------------
86
+ // Input
87
+ // -------------------------------------------------------------------------
88
+
89
+ handleInput(key: string): boolean {
90
+ if (this.expandedItem !== null) {
91
+ // Detail mode: scroll the detail body; Esc/← collapses.
92
+ if (this.lastError !== null) this.clearError();
93
+ switch (key) {
94
+ case 'escape':
95
+ case 'left':
96
+ case 'h':
97
+ this.collapse();
98
+ return true;
99
+ case 'up':
100
+ case 'k':
101
+ this.detailScroll = Math.max(0, this.detailScroll - 1);
102
+ this.needsRender = true;
103
+ return true;
104
+ case 'down':
105
+ case 'j':
106
+ this.detailScroll += 1;
107
+ this.needsRender = true;
108
+ return true;
109
+ case 'pageup':
110
+ this.detailScroll = Math.max(0, this.detailScroll - this.getPageSize());
111
+ this.needsRender = true;
112
+ return true;
113
+ case 'pagedown':
114
+ this.detailScroll += this.getPageSize();
115
+ this.needsRender = true;
116
+ return true;
117
+ case 'home':
118
+ case 'g':
119
+ this.detailScroll = 0;
120
+ this.needsRender = true;
121
+ return true;
122
+ default:
123
+ return false;
124
+ }
125
+ }
126
+
127
+ // List mode: Enter/→ expands; everything else is list navigation.
128
+ if (key === 'enter' || key === 'return' || key === 'right' || key === 'l') {
129
+ if (this.getItems().length === 0) return false;
130
+ this.expandSelected();
131
+ return true;
132
+ }
133
+ return super.handleInput(key);
134
+ }
135
+
136
+ handleScroll(deltaRows: number): boolean {
137
+ if (this.expandedItem !== null) {
138
+ const rows = Math.trunc(deltaRows);
139
+ if (rows === 0) return false;
140
+ this.detailScroll = Math.max(0, this.detailScroll + rows);
141
+ this.needsRender = true;
142
+ return true;
143
+ }
144
+ return super.handleScroll(deltaRows);
145
+ }
146
+
147
+ // -------------------------------------------------------------------------
148
+ // Render
149
+ // -------------------------------------------------------------------------
150
+
151
+ render(width: number, height: number): Line[] {
152
+ if (this.expandedItem !== null) {
153
+ return this.renderDetailView(width, height, this.expandedItem);
154
+ }
155
+ return this.renderListView(width, height);
156
+ }
157
+
158
+ /** List-mode body. Override for a custom header; defaults to renderList. */
159
+ protected renderListView(width: number, height: number): Line[] {
160
+ return this.renderList(width, height, { hints: this.getListHints() });
161
+ }
162
+
163
+ /** Detail-mode body — scrollable detail lines + standardized chrome. */
164
+ protected renderDetailView(width: number, height: number, item: T): Line[] {
165
+ this.needsRender = false;
166
+ const palette = this.getPalette();
167
+ const detailLines = [...this.getDetailLines(item, width)];
168
+ const footer = buildKeyboardHints(width, this.getDetailHints(), palette);
169
+
170
+ const resolved = resolveScrollablePanelSection(width, height, {
171
+ palette,
172
+ afterSections: [{ lines: [footer] }],
173
+ section: {
174
+ scrollableLines: detailLines,
175
+ scrollOffset: this.detailScroll,
176
+ appendWindowSummary: detailLines.length > 5 ? { dimColor: palette.dim } : undefined,
177
+ },
178
+ });
179
+ this.detailScroll = resolved.scrollOffset;
180
+
181
+ const lines = buildPanelWorkspace(width, height, {
182
+ title: this.getDetailTitle(item),
183
+ sections: [resolved.section, { lines: [footer] }],
184
+ palette,
185
+ });
186
+ while (lines.length < height) lines.push(createEmptyLine(width));
187
+ return lines.slice(0, height);
188
+ }
189
+ }