@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
@@ -2,152 +2,31 @@ import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
4
4
  import { getSurfaceContentRows, getTrackedVisibleWindow, getVisibleWindow, type VisibleWindow } from '../renderer/surface-layout.ts';
5
- import { GLYPHS, UI_TONES } from '../renderer/ui-primitives.ts';
5
+ import { GLYPHS } from '../renderer/ui-primitives.ts';
6
6
  import { type StatusState, STATE_GLYPHS } from '../renderer/status-glyphs.ts';
7
-
8
- export interface PanelPalette {
9
- readonly label: string;
10
- readonly value: string;
11
- readonly dim: string;
12
- readonly info: string;
13
- readonly good?: string;
14
- readonly warn?: string;
15
- readonly bad?: string;
16
- readonly empty: string;
17
- readonly header?: string;
18
- readonly headerBg?: string;
19
- readonly surfaceBg?: string;
20
- readonly sectionBg?: string;
21
- readonly summaryBg?: string;
22
- readonly inputBg?: string;
23
- readonly accent?: string;
24
- readonly selectBg?: string;
25
- }
26
-
27
- export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = {
28
- header: UI_TONES.fg.primary,
29
- headerBg: UI_TONES.bg.title,
30
- label: UI_TONES.fg.muted,
31
- value: UI_TONES.fg.primary,
32
- dim: UI_TONES.fg.dim,
33
- info: UI_TONES.state.info,
34
- good: UI_TONES.state.good,
35
- warn: UI_TONES.state.warn,
36
- bad: UI_TONES.state.bad,
37
- empty: '#334155',
38
- surfaceBg: UI_TONES.bg.surface,
39
- sectionBg: UI_TONES.bg.section,
40
- summaryBg: UI_TONES.bg.summary,
41
- inputBg: UI_TONES.bg.input,
42
- accent: UI_TONES.fg.secondary,
43
- selectBg: UI_TONES.bg.selected,
44
- } as const;
45
-
46
- /**
47
- * Extend the base panel palette with domain-specific colors.
48
- *
49
- * Convention: raw hex colors may only live inside a palette constant declared
50
- * at the top of a panel file, not inline in render calls.
51
- *
52
- * @example
53
- * ```ts
54
- * const C = extendPalette(DEFAULT_PANEL_PALETTE, {
55
- * decision: '#38bdf8',
56
- * incident: '#ef4444',
57
- * });
58
- * ```
59
- */
60
- export function extendPalette<T extends Record<string, string>>(
61
- base: typeof DEFAULT_PANEL_PALETTE,
62
- extras: T,
63
- ): typeof DEFAULT_PANEL_PALETTE & T {
64
- return { ...base, ...extras };
65
- }
66
-
67
- export function buildPanelLine(
68
- width: number,
69
- segments: Array<StyledPanelSegment | [string, string, string?]>,
70
- ): Line {
71
- return buildStyledPanelLine(
72
- width,
73
- segments.map((seg) =>
74
- Array.isArray(seg) ? { text: seg[0], fg: seg[1], bg: seg[2] } : seg,
75
- ),
76
- );
77
- }
78
-
79
- export interface StyledPanelSegment {
80
- readonly text: string;
81
- readonly fg: string;
82
- readonly bg?: string;
83
- readonly bold?: boolean;
84
- readonly dim?: boolean;
85
- }
86
-
87
- export function buildSelectablePanelLine(
88
- width: number,
89
- segments: ReadonlyArray<StyledPanelSegment>,
90
- options: { selected?: boolean; selectedBg?: string; fillFg?: string; fillBg?: string; leadingMarker?: string } = {},
91
- ): Line {
92
- const selected = options.selected ?? false;
93
- const selectedBg = selected ? (options.selectedBg ?? DEFAULT_PANEL_PALETTE.selectBg) : '';
94
- const fillBg = selectedBg || options.fillBg || '';
95
- const fillFg = options.fillFg ?? '';
96
- const cells = createEmptyLine(width);
97
- if (fillBg) {
98
- for (let col = 0; col < width; col++) {
99
- cells[col] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
100
- }
101
- }
102
-
103
- let col = 0;
104
- if (selected && options.leadingMarker) {
105
- for (const ch of options.leadingMarker) {
106
- const charWidth = getDisplayWidth(ch);
107
- if (charWidth <= 0 || col + charWidth > width) break;
108
- cells[col] = createStyledCell(ch, { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
109
- if (charWidth > 1 && col + 1 < width) cells[col + 1] = createStyledCell(' ', { fg: DEFAULT_PANEL_PALETTE.info, bg: selectedBg, bold: true });
110
- col += charWidth;
111
- }
112
- }
113
- for (const segment of segments) {
114
- const fg = segment.fg;
115
- const bg = segment.bg ?? fillBg;
116
- for (const ch of segment.text) {
117
- const charWidth = getDisplayWidth(ch);
118
- if (charWidth <= 0) continue;
119
- if (col + charWidth > width) return cells;
120
- cells[col] = createStyledCell(ch, {
121
- fg,
122
- bg,
123
- bold: segment.bold ?? false,
124
- dim: segment.dim ?? false,
125
- });
126
- if (charWidth > 1 && col + 1 < width) {
127
- cells[col + 1] = createStyledCell(' ', {
128
- fg,
129
- bg,
130
- bold: segment.bold ?? false,
131
- dim: segment.dim ?? false,
132
- });
133
- }
134
- col += charWidth;
135
- }
136
- }
137
-
138
- while (col < width) {
139
- cells[col++] = createStyledCell(' ', { bg: fillBg, fg: fillFg });
140
- }
141
- return cells;
142
- }
143
-
144
- export function buildStyledPanelLine(
145
- width: number,
146
- segments: ReadonlyArray<StyledPanelSegment>,
147
- options: { fillBg?: string; fillFg?: string } = {},
148
- ): Line {
149
- return buildSelectablePanelLine(width, segments, options);
150
- }
7
+ import { buildPanelLine, buildStyledPanelLine, DEFAULT_PANEL_PALETTE } from './polish-core.ts';
8
+ import type { PanelPalette, StyledPanelSegment } from './polish-core.ts';
9
+
10
+ // The panel formatting toolkit is split across three modules to stay under the
11
+ // architecture line-count cap; every symbol remains importable from './polish.ts':
12
+ // - ./polish-core.ts palette + base line primitives (leaf)
13
+ // - ./polish-tables.ts table / tree / badge / aligned-row primitives
14
+ // This module keeps the higher-level builders + workspace layout.
15
+ export * from './polish-core.ts';
16
+ export {
17
+ buildAlignedRow,
18
+ buildStatusBadge,
19
+ buildTreeRow,
20
+ buildTable,
21
+ type ColumnAlign,
22
+ type ColumnSpec,
23
+ type AlignedCell,
24
+ type StatusBadgeKind,
25
+ type TreeItemSpec,
26
+ type TableColumn,
27
+ type TableCell,
28
+ type TableRow,
29
+ } from './polish-tables.ts';
151
30
 
152
31
  export function buildGuidanceLine(
153
32
  width: number,
@@ -680,6 +559,26 @@ export function resolveStackedScrollableSections(
680
559
  });
681
560
  }
682
561
 
562
+ /**
563
+ * Standardized footer keyboard-hint row so every panel's hints look identical.
564
+ * Each hint renders as `keys` (info color) + `label` (dim).
565
+ */
566
+ export function buildKeyboardHints(
567
+ width: number,
568
+ hints: ReadonlyArray<{ keys: string; label: string }>,
569
+ palette: PanelPalette,
570
+ ): Line {
571
+ const segments: Array<[string, string, string?]> = [];
572
+ hints.forEach((hint, i) => {
573
+ if (i > 0) segments.push([' ', palette.dim]);
574
+ else segments.push([' ', palette.dim]);
575
+ segments.push([hint.keys, palette.info]);
576
+ segments.push([' ', palette.dim]);
577
+ segments.push([hint.label, palette.dim]);
578
+ });
579
+ return buildPanelLine(width, segments);
580
+ }
581
+
683
582
  export function buildPanelWorkspace(
684
583
  width: number,
685
584
  height: number,
@@ -367,12 +367,34 @@ export class ProjectPlanningPanel extends BasePanel {
367
367
  : readiness === 'needs-user-input'
368
368
  ? C.blocked
369
369
  : C.dim;
370
+ const blockingGaps = (evaluation?.gaps ?? []).filter((gap) => gap.severity === 'blocking').length;
371
+ const openQuestions = state.openQuestions.filter((q) => (q.status ?? 'open') === 'open').length;
372
+ // Surface the most important thing first: is this plan executable, and what
373
+ // is the single blocker to getting there.
374
+ const nextStep = state.executionApproved
375
+ ? 'approved — execution may proceed'
376
+ : openQuestions > 0
377
+ ? `answer ${openQuestions} open question${openQuestions === 1 ? '' : 's'}`
378
+ : blockingGaps > 0
379
+ ? `resolve ${blockingGaps} blocking gap${blockingGaps === 1 ? '' : 's'}`
380
+ : readiness === 'executable'
381
+ ? 'press a to approve execution'
382
+ : 'continue the planning interview';
370
383
  const lines: Line[] = [
371
384
  buildKeyValueLine(width, [
372
385
  { label: 'readiness', value: readiness, valueColor: readinessColor },
373
386
  { label: 'approved', value: state.executionApproved ? 'yes' : 'no', valueColor: state.executionApproved ? C.approved : C.blocked },
374
387
  { label: 'questions', value: `${state.openQuestions.length} open / ${state.answeredQuestions.length} answered`, valueColor: C.value },
375
388
  ], C),
389
+ buildKeyValueLine(width, [
390
+ { label: 'blocking gaps', value: String(blockingGaps), valueColor: blockingGaps > 0 ? C.blocked : C.good },
391
+ { label: 'tasks', value: String(state.tasks.length), valueColor: state.tasks.length > 0 ? C.value : C.dim },
392
+ { label: 'gates', value: String(state.verificationGates.length), valueColor: state.verificationGates.length > 0 ? C.good : C.dim },
393
+ ], C),
394
+ buildPanelLine(width, [
395
+ [' Next step ', C.label],
396
+ [nextStep, state.executionApproved ? C.approved : C.planning],
397
+ ]),
376
398
  ...buildBodyText(width, `Goal: ${state.goal || '(not set)'}`, C, state.goal ? C.value : C.blocked),
377
399
  ];
378
400
  if (state.scope) lines.push(...buildBodyText(width, `Scope: ${state.scope}`, C, C.value));
@@ -1,12 +1,14 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
5
  import {
6
+ buildAlignedRow,
5
7
  buildDetailBlock,
6
8
  buildEmptyState,
7
9
  buildGuidanceLine,
10
+ buildKeyboardHints,
8
11
  buildKeyValueLine,
9
- buildPanelListRow,
10
12
  buildPanelLine,
11
13
  buildSummaryBlock,
12
14
  buildPanelWorkspace,
@@ -50,14 +52,32 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
50
52
  return this.records;
51
53
  }
52
54
 
55
+ private readonly listColumns = [
56
+ { width: 16 },
57
+ { width: 14 },
58
+ { width: 8, align: 'right' as const },
59
+ { width: 11 },
60
+ { width: 8, align: 'right' as const },
61
+ ];
62
+
53
63
  protected renderItem(item: ProviderAccountRecord, _index: number, selected: boolean, width: number): Line {
54
- return buildPanelListRow(width, [
55
- { text: item.providerId.padEnd(16), fg: item.active ? C.good : C.value },
56
- { text: ` ${item.activeRoute.padEnd(14)}`, fg: item.activeRoute === 'subscription' ? C.info : item.activeRoute === 'api-key' ? C.warn : item.activeRoute === 'service-oauth' ? C.value : C.dim },
57
- { text: ` models=${String(item.modelCount).padEnd(4)}`, fg: C.dim },
58
- { text: ` ${item.authFreshness.padEnd(10)}`, fg: item.authFreshness === 'expired' ? C.bad : item.authFreshness === 'expiring' || item.authFreshness === 'pending' ? C.warn : C.dim },
59
- { text: ` issues=${String(item.issues.length).padEnd(2)}`, fg: item.issues.length > 0 ? C.bad : C.good },
60
- ], C, { selected });
64
+ const routeFg = item.activeRoute === 'subscription' ? C.info
65
+ : item.activeRoute === 'api-key' ? C.warn
66
+ : item.activeRoute === 'service-oauth' ? C.value : C.dim;
67
+ const freshFg = item.authFreshness === 'expired' ? C.bad
68
+ : item.authFreshness === 'expiring' || item.authFreshness === 'pending' ? C.warn : C.dim;
69
+ return buildAlignedRow(
70
+ width,
71
+ [
72
+ { text: item.providerId, fg: item.active ? C.good : C.value, bold: selected },
73
+ { text: item.activeRoute, fg: routeFg },
74
+ { text: `${item.modelCount} mdl`, fg: C.dim },
75
+ { text: item.authFreshness, fg: freshFg },
76
+ { text: item.issues.length > 0 ? `${item.issues.length} !` : 'ok', fg: item.issues.length > 0 ? C.bad : C.good },
77
+ ],
78
+ this.listColumns,
79
+ { selected, selectedBg: C.selectBg, marker: '▸' },
80
+ );
61
81
  }
62
82
 
63
83
  public handleInput(key: string): boolean {
@@ -85,7 +105,11 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
85
105
  public render(width: number, height: number): Line[] {
86
106
  this.needsRender = false;
87
107
  const intro = 'Provider auth routes, subscription posture, quota-window hints, and routing-safety notes.';
88
- const footerLines = [buildPanelLine(width, [[' Up/Down move r refresh /accounts routes <provider> /accounts repair <provider>', C.dim]])];
108
+ const footerLines = [buildKeyboardHints(width, [
109
+ { keys: 'Up/Down', label: 'select' },
110
+ { keys: 'r', label: 'refresh' },
111
+ { keys: '/accounts repair <provider>', label: 'recover routing' },
112
+ ], C)];
89
113
  if (this.loading && this.records.length === 0) {
90
114
  const lines = buildPanelWorkspace(width, height, {
91
115
  title: 'Provider Account Control Room',
@@ -140,8 +164,8 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
140
164
  { label: 'oauth ready', value: selected.oauthReady ? 'yes' : 'no', valueColor: selected.oauthReady ? C.info : C.dim },
141
165
  { label: 'pending login', value: selected.pendingLogin ? 'yes' : 'no', valueColor: selected.pendingLogin ? C.warn : C.dim },
142
166
  ], C),
143
- buildPanelLine(width, [[` Active route reason: ${selected.activeRouteReason}`.slice(0, width), C.dim]]),
144
- buildPanelLine(width, [[` Available routes: ${selected.availableRoutes.join(', ') || 'unconfigured'}`.slice(0, width), C.dim]]),
167
+ buildPanelLine(width, [[truncateDisplay(` Active route reason: ${selected.activeRouteReason}`, width), C.dim]]),
168
+ buildPanelLine(width, [[truncateDisplay(` Available routes: ${selected.availableRoutes.join(', ') || 'unconfigured'}`, width), C.dim]]),
145
169
  ];
146
170
  if (selected.expiresAt) {
147
171
  detailRows.push(buildPanelLine(width, [
@@ -152,28 +176,28 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
152
176
  ]));
153
177
  }
154
178
  if (selected.fallbackRisk) {
155
- detailRows.push(buildPanelLine(width, [[` fallback: ${selected.fallbackRisk}`.slice(0, width), C.warn]]));
179
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` fallback: ${selected.fallbackRisk}`, width), C.warn]]));
156
180
  }
157
181
  for (const route of selected.routeRecords) {
158
182
  detailRows.push(buildPanelLine(width, [[
159
- ` route ${route.route}: ${route.usable ? 'usable' : 'blocked'} • ${route.freshness} • ${route.detail}`.slice(0, width),
183
+ truncateDisplay(` route ${route.route}: ${route.usable ? 'usable' : 'blocked'} • ${route.freshness} • ${route.detail}`, width),
160
184
  route.usable ? C.dim : C.bad,
161
185
  ]]));
162
186
  for (const issue of route.issues) {
163
- detailRows.push(buildPanelLine(width, [[` issue: ${issue}`.slice(0, width), C.bad]]));
187
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.bad]]));
164
188
  }
165
189
  }
166
190
  for (const windowHint of selected.usageWindows) {
167
- detailRows.push(buildPanelLine(width, [[` ${windowHint.label}: ${windowHint.detail}`.slice(0, width), C.dim]]));
191
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` ${windowHint.label}: ${windowHint.detail}`, width), C.dim]]));
168
192
  }
169
193
  for (const issue of selected.issues) {
170
- detailRows.push(buildPanelLine(width, [[` issue: ${issue}`.slice(0, width), C.bad]]));
194
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.bad]]));
171
195
  }
172
196
  for (const note of selected.notes) {
173
- detailRows.push(buildPanelLine(width, [[` note: ${note}`.slice(0, width), C.info]]));
197
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` note: ${note}`, width), C.info]]));
174
198
  }
175
199
  for (const action of selected.recommendedActions) {
176
- detailRows.push(buildPanelLine(width, [[` next: ${action}`.slice(0, width), C.value]]));
200
+ detailRows.push(buildPanelLine(width, [[truncateDisplay(` next: ${action}`, width), C.value]]));
177
201
  }
178
202
  if (selected.issues.length === 0 && selected.notes.length === 0 && selected.usageWindows.length === 0 && selected.recommendedActions.length === 0) {
179
203
  detailRows.push(buildPanelLine(width, [[' No active account warnings for this provider.', C.dim]]));
@@ -183,6 +207,18 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
183
207
  const rawProviderLines: Line[] = this.records.map((record, absolute) =>
184
208
  this.renderItem(record, absolute, absolute === this.selectedIndex, width),
185
209
  );
210
+ const columnHeader = buildAlignedRow(
211
+ width,
212
+ [
213
+ { text: 'provider', fg: C.label, bold: true },
214
+ { text: 'route', fg: C.label, bold: true },
215
+ { text: 'models', fg: C.label, bold: true },
216
+ { text: 'auth', fg: C.label, bold: true },
217
+ { text: 'issues', fg: C.label, bold: true },
218
+ ],
219
+ this.listColumns,
220
+ { marker: '▸' },
221
+ );
186
222
  const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
187
223
  intro,
188
224
  footerLines,
@@ -190,6 +226,7 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
190
226
  beforeSections: [postureSection],
191
227
  section: {
192
228
  title: 'Providers',
229
+ fixedLines: [columnHeader],
193
230
  scrollableLines: rawProviderLines,
194
231
  selectedIndex: this.selectedIndex,
195
232
  scrollOffset: this.scrollStart,
@@ -1,4 +1,5 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
+ import { formatLatencyMs } from '../utils/format-duration.ts';
2
3
  import { BasePanel } from './base-panel.ts';
3
4
  import { createEmptyLine, createStyledCell, type Line } from '../types/grid.ts';
4
5
  import type { ProviderAuthRouteDescriptor } from '@pellux/goodvibes-sdk/platform/providers';
@@ -26,13 +27,13 @@ import type {
26
27
  } from '../runtime/ui-read-models.ts';
27
28
  import { evaluateSessionMaintenance } from '@/runtime/index.ts';
28
29
  import {
30
+ buildAlignedRow,
29
31
  buildBodyText,
30
32
  buildDetailBlock,
31
33
  buildEmptyState,
32
34
  buildGuidanceLine,
35
+ buildKeyboardHints,
33
36
  buildKeyValueLine,
34
- buildPanelListRow,
35
- buildPanelLine,
36
37
  buildPanelWorkspace,
37
38
  buildSummaryBlock,
38
39
  DEFAULT_PANEL_PALETTE,
@@ -66,6 +67,7 @@ export interface ProviderHealthPanelDeps {
66
67
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
67
68
  title: '#00ffff',
68
69
  unknown: '244',
70
+ rowSelectBg: '#111827',
69
71
  });
70
72
 
71
73
  const LATENCY_WARN_MS = 2_000;
@@ -104,10 +106,7 @@ function latencyColor(ms: number): string {
104
106
  }
105
107
 
106
108
  function fmtMs(ms: number): string {
107
- if (ms <= 0) return 'n/a';
108
- if (ms >= 10_000) return `${(ms / 1000).toFixed(1)}s`;
109
- if (ms >= 1_000) return `${(ms / 1000).toFixed(2)}s`;
110
- return `${Math.round(ms)}ms`;
109
+ return formatLatencyMs(ms);
111
110
  }
112
111
 
113
112
  function fmtAgo(ts: number | undefined): string {
@@ -592,13 +591,19 @@ export class ProviderHealthPanel extends BasePanel {
592
591
  worktrees: this.deps.worktrees.getSnapshot(),
593
592
  session: this.deps.session.getSnapshot(),
594
593
  })) {
595
- domainLines.push(buildPanelLine(width, [
596
- [' ', C.label],
597
- [domain.name.padEnd(14), C.value],
598
- [domain.summary.slice(0, Math.max(0, width - 36)).padEnd(Math.max(0, width - 36)), domainColor(domain.level)],
599
- [' ', C.label],
600
- [domain.next.slice(0, 20), C.dim],
601
- ]));
594
+ domainLines.push(buildAlignedRow(
595
+ width,
596
+ [
597
+ { text: domain.name, fg: C.value, bold: true },
598
+ { text: domain.summary, fg: domainColor(domain.level) },
599
+ { text: domain.next, fg: C.dim },
600
+ ],
601
+ [
602
+ { width: 14 },
603
+ { width: Math.max(10, width - 38) },
604
+ { width: 20 },
605
+ ],
606
+ ));
602
607
  for (const detail of domain.details.slice(0, 2)) {
603
608
  domainLines.push(...buildBodyText(width, ` ${detail}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.dim));
604
609
  }
@@ -672,26 +677,63 @@ export class ProviderHealthPanel extends BasePanel {
672
677
  const domainsSection: PanelWorkspaceSection = { title: 'Repair Domains', lines: domainLines };
673
678
  const maintenanceSections = maintenanceLines.length > 0 ? [{ title: 'Session Maintenance', lines: maintenanceLines } satisfies PanelWorkspaceSection] : [];
674
679
  const selectedSections = selectedLines.length > 0 ? [{ lines: buildDetailBlock(width, 'Selected provider', selectedLines, { ...DEFAULT_PANEL_PALETTE, header: C.title }) } satisfies PanelWorkspaceSection] : [];
680
+ const footerHint = buildKeyboardHints(width, [
681
+ { keys: 'j/k or Up/Down', label: 'select provider' },
682
+ { keys: '/provider', label: 'switch model' },
683
+ { keys: '/accounts', label: 'auth routes' },
684
+ ], { ...DEFAULT_PANEL_PALETTE, header: C.title });
685
+ const providerColumnHeader = buildAlignedRow(
686
+ width,
687
+ [
688
+ { text: 'provider', fg: C.label, bold: true },
689
+ { text: 'status', fg: C.label, bold: true },
690
+ { text: 'lat', fg: C.label, bold: true },
691
+ { text: 'last ok', fg: C.label, bold: true },
692
+ { text: 'auth', fg: C.label, bold: true },
693
+ ],
694
+ [
695
+ { width: 18 },
696
+ { width: 13 },
697
+ { width: 8, align: 'right' },
698
+ { width: 10, align: 'right' },
699
+ { width: 12 },
700
+ ],
701
+ { marker: '▸' },
702
+ );
675
703
  const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
676
704
  intro,
677
- footerLines: [buildPanelLine(width, [[' j/k or Up/Down move live cooldowns refresh while active', C.dim]])],
705
+ footerLines: [footerHint],
678
706
  palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
679
707
  beforeSections: [postureSection, domainsSection, ...maintenanceSections],
680
708
  section: {
681
709
  title: 'Providers',
710
+ fixedLines: [providerColumnHeader],
682
711
  scrollableLines: providers.map((name, absolute) => {
683
712
  const health = this.providerHealthTracker.get(name);
684
713
  const status = health?.status ?? 'unknown';
714
+ const dot = statusDot(status);
685
715
  const latency = health?.lastLatencyMs !== undefined ? fmtMs(health.lastLatencyMs) : 'n/a';
686
716
  const latencyFg = health?.lastLatencyMs !== undefined ? latencyColor(health.lastLatencyMs) : C.dim;
687
- return buildPanelListRow(width, [
688
- { text: name.padEnd(16), fg: C.value },
689
- { text: statusLabel(status).padEnd(14), fg: statusDot(status).color },
690
- { text: ' lat ', fg: C.label },
691
- { text: latency.padEnd(8), fg: latencyFg },
692
- { text: ' ok ', fg: C.label },
693
- { text: fmtAgo(health?.lastSuccessAt).padEnd(10), fg: C.value },
694
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }, { selected: absolute === this._selectedIndex, selectedBg: '#111827' });
717
+ const account = this._accountRecords.get(name);
718
+ const authFg = account ? freshnessColor(account.authFreshness) : C.dim;
719
+ return buildAlignedRow(
720
+ width,
721
+ [
722
+ { text: `${dot.char} ${name}`, fg: C.value, bold: absolute === this._selectedIndex },
723
+ { text: statusLabel(status), fg: dot.color },
724
+ { text: latency, fg: latencyFg },
725
+ { text: fmtAgo(health?.lastSuccessAt), fg: C.value },
726
+ { text: account ? account.authFreshness : '-', fg: authFg },
727
+ ],
728
+ [
729
+ { width: 18 },
730
+ { width: 13 },
731
+ { width: 8, align: 'right' },
732
+ { width: 10, align: 'right' },
733
+ { width: 12 },
734
+ ],
735
+ { selected: absolute === this._selectedIndex, selectedBg: C.rowSelectBg, marker: '▸' },
736
+ );
695
737
  }),
696
738
  selectedIndex: this._selectedIndex,
697
739
  scrollOffset: this._scrollOffset,
@@ -713,7 +755,7 @@ export class ProviderHealthPanel extends BasePanel {
713
755
  title: 'Health',
714
756
  intro,
715
757
  sections,
716
- footerLines: [buildPanelLine(width, [[' j/k or Up/Down move live cooldowns refresh while active', C.dim]])],
758
+ footerLines: [footerHint],
717
759
  palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
718
760
  });
719
761
  }