@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.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/handler-feed-routes.ts +61 -4
- package/src/input/handler-feed.ts +13 -0
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/keybindings.ts +5 -0
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +10 -0
- package/src/main.ts +0 -1
- package/src/panels/agent-inspector-panel.ts +18 -13
- package/src/panels/agent-inspector-shared.ts +3 -3
- package/src/panels/agent-logs-panel.ts +22 -53
- package/src/panels/approval-panel.ts +146 -95
- package/src/panels/automation-control-panel.ts +24 -1
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-panel.ts +24 -5
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +108 -43
- package/src/panels/context-visualizer-panel.ts +25 -1
- package/src/panels/control-plane-panel.ts +27 -1
- package/src/panels/cost-tracker-panel.ts +82 -54
- package/src/panels/debug-panel.ts +51 -16
- package/src/panels/diff-panel.ts +59 -30
- package/src/panels/docs-panel.ts +19 -3
- package/src/panels/eval-panel.ts +130 -81
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +40 -41
- package/src/panels/file-preview-panel.ts +9 -2
- package/src/panels/forensics-panel.ts +27 -13
- package/src/panels/git-panel.ts +58 -70
- package/src/panels/hooks-panel.ts +36 -2
- package/src/panels/incident-review-panel.ts +31 -10
- package/src/panels/intelligence-panel.ts +152 -71
- package/src/panels/knowledge-graph-panel.ts +89 -27
- package/src/panels/local-auth-panel.ts +17 -11
- package/src/panels/marketplace-panel.ts +31 -12
- package/src/panels/memory-panel.ts +5 -4
- package/src/panels/ops-control-panel.ts +69 -7
- package/src/panels/ops-strategy-panel.ts +61 -43
- package/src/panels/orchestration-panel.ts +34 -4
- package/src/panels/panel-list-panel.ts +33 -8
- package/src/panels/panel-manager.ts +23 -4
- package/src/panels/plan-dashboard-panel.ts +183 -66
- package/src/panels/plugins-panel.ts +48 -10
- package/src/panels/policy-panel.ts +60 -23
- package/src/panels/polish-core.ts +157 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +44 -145
- package/src/panels/project-planning-panel.ts +22 -0
- package/src/panels/provider-accounts-panel.ts +55 -18
- package/src/panels/provider-health-panel.ts +65 -23
- package/src/panels/provider-stats-panel.ts +47 -22
- package/src/panels/qr-panel.ts +23 -11
- package/src/panels/remote-panel.ts +12 -5
- package/src/panels/routes-panel.ts +27 -5
- package/src/panels/sandbox-panel.ts +62 -27
- package/src/panels/schedule-panel.ts +40 -17
- package/src/panels/scrollable-list-panel.ts +124 -10
- package/src/panels/security-panel.ts +72 -20
- package/src/panels/services-panel.ts +68 -22
- package/src/panels/session-browser-panel.ts +29 -5
- package/src/panels/session-maintenance.ts +2 -2
- package/src/panels/settings-sync-panel.ts +30 -15
- package/src/panels/subscription-panel.ts +21 -3
- package/src/panels/symbol-outline-panel.ts +40 -47
- package/src/panels/system-messages-panel.ts +60 -27
- package/src/panels/tasks-panel.ts +34 -7
- package/src/panels/thinking-panel.ts +26 -25
- package/src/panels/token-budget-panel.ts +49 -38
- package/src/panels/tool-inspector-panel.ts +27 -21
- package/src/panels/types.ts +25 -0
- package/src/panels/watchers-panel.ts +25 -5
- package/src/panels/work-plan-panel.ts +125 -34
- package/src/panels/worktree-panel.ts +65 -20
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +31 -127
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/model-picker-overlay.ts +9 -4
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/progress.ts +3 -2
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/ui-factory.ts +4 -6
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/version.ts +1 -1
- package/src/panels/panel-picker.ts +0 -106
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
package/src/panels/eval-panel.ts
CHANGED
|
@@ -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
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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: [
|
|
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
|
|
320
|
-
|
|
351
|
+
const passCount = suite.results.filter((r) => r.scorecard.passed).length;
|
|
352
|
+
const headerLines: Line[] = [
|
|
353
|
+
this._statusLine(width),
|
|
321
354
|
buildPanelLine(width, [
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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(
|
|
371
|
-
|
|
372
|
-
[
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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.
|
|
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
|
+
}
|