@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
@@ -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
+ }