@pellux/goodvibes-tui 0.18.20 → 0.18.23

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 (34) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed.ts +44 -6
  8. package/src/input/handler-shortcuts.ts +138 -125
  9. package/src/input/handler.ts +121 -119
  10. package/src/input/keybindings.ts +30 -0
  11. package/src/panels/approval-panel.ts +54 -82
  12. package/src/panels/automation-control-panel.ts +119 -161
  13. package/src/panels/communication-panel.ts +68 -107
  14. package/src/panels/control-plane-panel.ts +116 -172
  15. package/src/panels/hooks-panel.ts +101 -138
  16. package/src/panels/incident-review-panel.ts +55 -107
  17. package/src/panels/local-auth-panel.ts +76 -93
  18. package/src/panels/mcp-panel.ts +108 -155
  19. package/src/panels/ops-control-panel.ts +50 -85
  20. package/src/panels/panel-manager.ts +22 -2
  21. package/src/panels/plugins-panel.ts +36 -60
  22. package/src/panels/routes-panel.ts +89 -141
  23. package/src/panels/scrollable-list-panel.ts +45 -14
  24. package/src/panels/security-panel.ts +101 -137
  25. package/src/panels/services-panel.ts +58 -102
  26. package/src/panels/settings-sync-panel.ts +76 -122
  27. package/src/panels/subscription-panel.ts +63 -86
  28. package/src/panels/tasks-panel.ts +129 -179
  29. package/src/panels/watchers-panel.ts +88 -137
  30. package/src/renderer/buffer.ts +11 -0
  31. package/src/renderer/diff.ts +8 -0
  32. package/src/renderer/help-overlay.ts +37 -28
  33. package/src/renderer/markdown.ts +3 -145
  34. package/src/version.ts +1 -1
@@ -1,16 +1,13 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
2
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
3
  import {
5
4
  buildDetailBlock,
6
5
  buildGuidanceLine,
7
6
  buildPanelListRow,
8
7
  buildPanelLine,
9
- buildPanelWorkspace,
10
8
  buildSummaryBlock,
11
9
  DEFAULT_PANEL_PALETTE,
12
- resolvePrimaryScrollableSection,
13
- type PanelWorkspaceSection,
10
+ type PanelPalette,
14
11
  } from './polish.ts';
15
12
  import { getSettingsControlPlaneSnapshot } from '@pellux/goodvibes-sdk/platform/runtime/settings/control-plane';
16
13
  import type { ConfigManager } from '../config/index.ts';
@@ -24,141 +21,98 @@ const C = {
24
21
  error: '#ef4444',
25
22
  } as const;
26
23
 
27
- export class SettingsSyncPanel extends BasePanel {
28
- private selectedIndex = 0;
29
- private scrollOffset = 0;
24
+ type ResolvedEntry = ReturnType<typeof getSettingsControlPlaneSnapshot>['resolvedEntries'][number];
30
25
 
26
+ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
31
27
  public constructor(private readonly configManager: ConfigManager) {
32
28
  super('settings-sync', 'Settings Sync', 'S', 'monitoring');
33
29
  }
34
30
 
35
- public handleInput(key: string): boolean {
36
- const total = getSettingsControlPlaneSnapshot(this.configManager).resolvedEntries.length;
37
- if (total <= 0) return false;
38
- if (key === 'up' || key === 'k') {
39
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
40
- this.markDirty();
41
- return true;
42
- }
43
- if (key === 'down' || key === 'j') {
44
- this.selectedIndex = Math.min(total - 1, this.selectedIndex + 1);
45
- this.markDirty();
46
- return true;
47
- }
48
- return false;
31
+ protected override getPalette(): PanelPalette {
32
+ return C;
33
+ }
34
+
35
+ protected getItems(): readonly ResolvedEntry[] {
36
+ return getSettingsControlPlaneSnapshot(this.configManager).resolvedEntries;
37
+ }
38
+
39
+ protected renderItem(entry: ResolvedEntry, _index: number, selected: boolean, width: number): Line {
40
+ return buildPanelListRow(width, [
41
+ { text: entry.key.padEnd(32), fg: C.value },
42
+ { text: ` ${entry.effectiveSource}`.padEnd(11), fg: entry.effectiveSource === 'managed' ? C.warn : entry.effectiveSource === 'synced' ? C.ok : entry.effectiveSource === 'local' ? C.info : C.dim },
43
+ { text: `${String(entry.effectiveValue)}`.slice(0, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
44
+ ], C, { selected });
45
+ }
46
+
47
+ protected override getEmptyStateMessage(): string {
48
+ return ' No resolved settings entries.';
49
49
  }
50
50
 
51
51
  public render(width: number, height: number): Line[] {
52
- this.needsRender = false;
53
52
  const snapshot = getSettingsControlPlaneSnapshot(this.configManager);
54
- const safeSelectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, snapshot.resolvedEntries.length - 1)));
55
- this.selectedIndex = safeSelectedIndex;
53
+
56
54
  const postureLines: Line[] = [
57
55
  buildPanelLine(width, [[' resolved keys ', C.label], [String(snapshot.resolvedEntries.length), C.value], [' conflicts ', C.label], [String(snapshot.conflicts.length), snapshot.conflicts.length > 0 ? C.error : C.good], [' failures ', C.label], [String(snapshot.recentFailures.length), snapshot.recentFailures.length > 0 ? C.warn : C.good]]),
58
56
  buildPanelLine(width, [[' managed locks ', C.label], [String(snapshot.managedLockCount), snapshot.managedLockCount > 0 ? C.warn : C.dim], [' staged bundle ', C.label], [snapshot.stagedManagedBundle ? snapshot.stagedManagedBundle.profileName : 'none', snapshot.stagedManagedBundle ? C.info : C.dim]]),
59
57
  buildGuidanceLine(width, '/settingssync conflicts', 'review conflicting synced values before they silently shape effective configuration', C),
60
58
  buildGuidanceLine(width, '/managed review', 'inspect staged managed changes, risk posture, and rollback records', C),
61
59
  ];
62
- const prefixSections: PanelWorkspaceSection[] = [
63
- {
64
- lines: buildSummaryBlock(width, 'Settings posture', postureLines, C),
65
- },
66
- {
67
- title: 'Layers',
68
- lines: [
69
- buildPanelLine(width, [[' local typed config ', C.label], [String(snapshot.liveKeyCount), C.value], [' saved profiles ', C.label], [String(snapshot.profileCount), C.info], [' managed locks ', C.label], [String(snapshot.managedLockCount), snapshot.managedLockCount > 0 ? C.warn : C.dim]]),
70
- buildPanelLine(width, [[' effective local ', C.label], [String(snapshot.resolvedCounts.local), C.info], [' synced ', C.label], [String(snapshot.resolvedCounts.synced), snapshot.resolvedCounts.synced > 0 ? C.ok : C.dim], [' managed ', C.label], [String(snapshot.resolvedCounts.managed), snapshot.resolvedCounts.managed > 0 ? C.warn : C.dim]]),
71
- buildPanelLine(width, [[' last sync ', C.label], [snapshot.lastSync ? `${snapshot.lastSync.surface}/${snapshot.lastSync.direction}` : 'none', snapshot.lastSync ? C.ok : C.dim], [' when ', C.label], [snapshot.lastSync ? new Date(snapshot.lastSync.timestamp).toLocaleString() : 'n/a', C.dim]]),
72
- ],
73
- },
74
- {
75
- title: 'Staged Bundle',
76
- lines: snapshot.stagedManagedBundle
77
- ? [
78
- buildPanelLine(width, [[' profile ', C.label], [snapshot.stagedManagedBundle.profileName, C.value], [' risk ', C.label], [snapshot.stagedManagedBundle.risk, snapshot.stagedManagedBundle.risk === 'high' ? C.error : snapshot.stagedManagedBundle.risk === 'medium' ? C.warn : C.ok], [' changes ', C.label], [String(snapshot.stagedManagedBundle.changeCount), C.info]]),
79
- buildPanelLine(width, [[' path ', C.label], [snapshot.stagedManagedBundle.path.slice(0, Math.max(0, width - 9)), C.dim]]),
80
- ]
81
- : [buildPanelLine(width, [[' No staged managed settings bundle.', C.dim]])],
82
- },
83
- {
84
- title: 'Recent Events',
85
- lines: snapshot.recentEvents.length > 0
86
- ? snapshot.recentEvents.map((event) => buildPanelLine(width, [[` ${event.surface}/${event.direction}`.padEnd(18), C.info], [` ${event.detail}`.slice(0, Math.max(0, width - 20)), C.dim]]))
87
- : [buildPanelLine(width, [[' No sync or managed-setting events recorded yet.', C.dim]])],
88
- },
89
- {
90
- title: 'Managed Locks',
91
- lines: snapshot.managedLocks.length > 0
92
- ? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[` ${lock.key}`.padEnd(30), C.value], [` source=${lock.source}`.padEnd(24), C.info], [` ${lock.reason}`.slice(0, Math.max(0, width - 56)), C.dim]]))
93
- : [buildPanelLine(width, [[' No managed locks are currently active.', C.dim]])],
94
- },
95
- {
96
- title: 'Failures',
97
- lines: snapshot.recentFailures.length > 0
98
- ? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [` ${failure.message}`.slice(0, Math.max(0, width - 12)), C.dim]]))
99
- : [buildPanelLine(width, [[' No recent sync or managed-setting failures.', C.dim]])],
100
- },
101
- {
102
- title: 'Conflicts',
103
- lines: snapshot.conflicts.length > 0
104
- ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}`.padEnd(30), C.value], [` ${conflict.source}`.padEnd(10), C.warn], [` resolve: /settingssync resolve ${conflict.key} local|synced`.slice(0, Math.max(0, width - 42)), C.dim]]))
105
- : [buildPanelLine(width, [[' No settings conflicts detected.', C.dim]])],
106
- },
107
- {
108
- title: 'Rollback History',
109
- lines: snapshot.rollbackHistory.length > 0
110
- ? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[` ${entry.token}`.padEnd(18), C.info], [` ${entry.profileName}`.padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [` ${new Date(entry.appliedAt).toLocaleString()}`.slice(0, Math.max(0, width - 46)), C.dim]]))
111
- : [buildPanelLine(width, [[' No managed rollback records yet.', C.dim]])],
112
- },
60
+
61
+ const headerLines: Line[] = [
62
+ ...buildSummaryBlock(width, 'Settings posture', postureLines, C),
63
+ buildPanelLine(width, [[' local typed config ', C.label], [String(snapshot.liveKeyCount), C.value], [' saved profiles ', C.label], [String(snapshot.profileCount), C.info], [' managed locks ', C.label], [String(snapshot.managedLockCount), snapshot.managedLockCount > 0 ? C.warn : C.dim]]),
64
+ buildPanelLine(width, [[' effective local ', C.label], [String(snapshot.resolvedCounts.local), C.info], [' synced ', C.label], [String(snapshot.resolvedCounts.synced), snapshot.resolvedCounts.synced > 0 ? C.ok : C.dim], [' managed ', C.label], [String(snapshot.resolvedCounts.managed), snapshot.resolvedCounts.managed > 0 ? C.warn : C.dim]]),
65
+ buildPanelLine(width, [[' last sync ', C.label], [snapshot.lastSync ? `${snapshot.lastSync.surface}/${snapshot.lastSync.direction}` : 'none', snapshot.lastSync ? C.ok : C.dim], [' when ', C.label], [snapshot.lastSync ? new Date(snapshot.lastSync.timestamp).toLocaleString() : 'n/a', C.dim]]),
66
+ // Staged Bundle
67
+ ...(snapshot.stagedManagedBundle
68
+ ? [
69
+ buildPanelLine(width, [[' profile ', C.label], [snapshot.stagedManagedBundle.profileName, C.value], [' risk ', C.label], [snapshot.stagedManagedBundle.risk, snapshot.stagedManagedBundle.risk === 'high' ? C.error : snapshot.stagedManagedBundle.risk === 'medium' ? C.warn : C.ok], [' changes ', C.label], [String(snapshot.stagedManagedBundle.changeCount), C.info]]),
70
+ buildPanelLine(width, [[' path ', C.label], [snapshot.stagedManagedBundle.path.slice(0, Math.max(0, width - 9)), C.dim]]),
71
+ ]
72
+ : [buildPanelLine(width, [[' No staged managed settings bundle.', C.dim]])]),
73
+ // Recent Events
74
+ ...(snapshot.recentEvents.length > 0
75
+ ? snapshot.recentEvents.map((event) => buildPanelLine(width, [[` ${event.surface}/${event.direction}`.padEnd(18), C.info], [` ${event.detail}`.slice(0, Math.max(0, width - 20)), C.dim]]))
76
+ : [buildPanelLine(width, [[' No sync or managed-setting events recorded yet.', C.dim]])]),
77
+ // Managed Locks
78
+ ...(snapshot.managedLocks.length > 0
79
+ ? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[` ${lock.key}`.padEnd(30), C.value], [` source=${lock.source}`.padEnd(24), C.info], [` ${lock.reason}`.slice(0, Math.max(0, width - 56)), C.dim]]))
80
+ : [buildPanelLine(width, [[' No managed locks are currently active.', C.dim]])]),
81
+ // Failures
82
+ ...(snapshot.recentFailures.length > 0
83
+ ? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [` ${failure.message}`.slice(0, Math.max(0, width - 12)), C.dim]]))
84
+ : [buildPanelLine(width, [[' No recent sync or managed-setting failures.', C.dim]])]),
85
+ // Conflicts
86
+ ...(snapshot.conflicts.length > 0
87
+ ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}`.padEnd(30), C.value], [` ${conflict.source}`.padEnd(10), C.warn], [` resolve: /settingssync resolve ${conflict.key} local|synced`.slice(0, Math.max(0, width - 42)), C.dim]]))
88
+ : [buildPanelLine(width, [[' No settings conflicts detected.', C.dim]])]),
89
+ // Rollback History
90
+ ...(snapshot.rollbackHistory.length > 0
91
+ ? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[` ${entry.token}`.padEnd(18), C.info], [` ${entry.profileName}`.padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [` ${new Date(entry.appliedAt).toLocaleString()}`.slice(0, Math.max(0, width - 46)), C.dim]]))
92
+ : [buildPanelLine(width, [[' No managed rollback records yet.', C.dim]])]),
113
93
  ];
114
- const selected = snapshot.resolvedEntries[this.selectedIndex];
115
- const selectedSections = !selected ? [] as PanelWorkspaceSection[] : [{
116
- lines: buildDetailBlock(width, 'Selected setting', [
117
- buildPanelLine(width, [[' key ', C.label], [selected.key, C.value], [' category ', C.label], [selected.category, C.info]]),
118
- buildPanelLine(width, [[' effective ', C.label], [selected.effectiveSource, selected.effectiveSource === 'managed' ? C.warn : selected.effectiveSource === 'synced' ? C.ok : selected.effectiveSource === 'local' ? C.info : C.dim], [' locked ', C.label], [selected.locked ? 'yes' : 'no', selected.locked ? C.warn : C.dim], [' conflict ', C.label], [selected.conflict ? 'yes' : 'no', selected.conflict ? C.error : C.good]]),
119
- buildPanelLine(width, [[' source ', C.label], [(selected.sourceLabel ?? 'local/default').slice(0, Math.max(0, width - 10)), C.dim]]),
120
- buildPanelLine(width, [[' overrides ', C.label], [(selected.overriddenSources.length > 0 ? selected.overriddenSources.join(', ') : 'none').slice(0, Math.max(0, width - 13)), C.dim]]),
121
- buildPanelLine(width, [[' local ', C.label], [String(selected.localValue).slice(0, Math.max(0, width - 9)), C.dim]]),
122
- buildPanelLine(width, [[' synced ', C.label], [String(selected.syncedValue ?? '(unset)').slice(0, Math.max(0, width - 10)), C.ok]]),
123
- buildPanelLine(width, [[' managed ', C.label], [String(selected.managedValue ?? '(unset)').slice(0, Math.max(0, width - 11)), C.warn]]),
124
- ], C),
125
- }];
126
- const resolvedEntriesSection = resolvePrimaryScrollableSection(width, height, {
127
- intro: 'Local typed config, synced and managed layers, staged bundle review, conflicts, and control-plane failures.',
128
- footerLines: [buildPanelLine(width, [[' ↑/↓ browse /settingssync show <key> /settingssync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]])],
129
- palette: C,
130
- beforeSections: prefixSections,
131
- section: {
132
- title: 'Resolved Entries',
133
- scrollableLines: snapshot.resolvedEntries.map((entry, absolute) => {
134
- return buildPanelListRow(width, [
135
- { text: entry.key.padEnd(32), fg: C.value },
136
- { text: ` ${entry.effectiveSource}`.padEnd(11), fg: entry.effectiveSource === 'managed' ? C.warn : entry.effectiveSource === 'synced' ? C.ok : entry.effectiveSource === 'local' ? C.info : C.dim },
137
- { text: `${String(entry.effectiveValue)}`.slice(0, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
138
- ], C, { selected: absolute === this.selectedIndex });
139
- }),
140
- selectedIndex: this.selectedIndex,
141
- scrollOffset: this.scrollOffset,
142
- guardRows: 1,
143
- minRows: 4,
144
- appendWindowSummary: { dimColor: C.dim },
145
- },
146
- afterSections: selectedSections,
147
- });
148
- this.scrollOffset = resolvedEntriesSection.scrollOffset;
149
- const sections: PanelWorkspaceSection[] = [
150
- ...prefixSections,
151
- resolvedEntriesSection.section,
152
- ...selectedSections,
94
+
95
+ this.clampSelection();
96
+ const selectedEntry = snapshot.resolvedEntries[this.selectedIndex];
97
+ const footerLines: Line[] = [
98
+ ...(selectedEntry
99
+ ? buildDetailBlock(width, 'Selected setting', [
100
+ buildPanelLine(width, [[' key ', C.label], [selectedEntry.key, C.value], [' category ', C.label], [selectedEntry.category, C.info]]),
101
+ buildPanelLine(width, [[' effective ', C.label], [selectedEntry.effectiveSource, selectedEntry.effectiveSource === 'managed' ? C.warn : selectedEntry.effectiveSource === 'synced' ? C.ok : selectedEntry.effectiveSource === 'local' ? C.info : C.dim], [' locked ', C.label], [selectedEntry.locked ? 'yes' : 'no', selectedEntry.locked ? C.warn : C.dim], [' conflict ', C.label], [selectedEntry.conflict ? 'yes' : 'no', selectedEntry.conflict ? C.error : C.good]]),
102
+ buildPanelLine(width, [[' source ', C.label], [(selectedEntry.sourceLabel ?? 'local/default').slice(0, Math.max(0, width - 10)), C.dim]]),
103
+ buildPanelLine(width, [[' overrides ', C.label], [(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none').slice(0, Math.max(0, width - 13)), C.dim]]),
104
+ buildPanelLine(width, [[' local ', C.label], [String(selectedEntry.localValue).slice(0, Math.max(0, width - 9)), C.dim]]),
105
+ buildPanelLine(width, [[' synced ', C.label], [String(selectedEntry.syncedValue ?? '(unset)').slice(0, Math.max(0, width - 10)), C.ok]]),
106
+ buildPanelLine(width, [[' managed ', C.label], [String(selectedEntry.managedValue ?? '(unset)').slice(0, Math.max(0, width - 11)), C.warn]]),
107
+ ], C)
108
+ : []),
109
+ buildPanelLine(width, [[' ↑/↓ browse /settingssync show <key> /settingssync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]]),
153
110
  ];
154
- const lines = buildPanelWorkspace(width, height, {
111
+
112
+ return this.renderList(width, height, {
155
113
  title: 'Settings Sync',
156
- intro: 'Local typed config, synced and managed layers, staged bundle review, conflicts, and control-plane failures.',
157
- sections,
158
- footerLines: [buildPanelLine(width, [[' ↑/↓ browse /settingssync show <key> /settingssync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]])],
159
- palette: C,
114
+ header: headerLines,
115
+ footer: footerLines,
160
116
  });
161
- while (lines.length < height) lines.push(createEmptyLine(width));
162
- return lines.slice(0, height);
163
117
  }
164
118
  }
@@ -1,12 +1,10 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
4
- import { handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
4
  import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
6
5
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
7
6
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
8
7
  import {
9
- buildDetailBlock,
10
8
  buildEmptyState,
11
9
  buildGuidanceLine,
12
10
  buildKeyValueLine,
@@ -15,8 +13,6 @@ import {
15
13
  buildSummaryBlock,
16
14
  buildPanelWorkspace,
17
15
  DEFAULT_PANEL_PALETTE,
18
- resolvePrimaryScrollableSection,
19
- type PanelWorkspaceSection,
20
16
  } from './polish.ts';
21
17
 
22
18
  const C = {
@@ -57,12 +53,10 @@ function statusColor(status: ReturnType<typeof statusOf>): string {
57
53
  }
58
54
  }
59
55
 
60
- export class SubscriptionPanel extends BasePanel {
56
+ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
61
57
  private readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
62
58
  private readonly subscriptionManager: SubscriptionAccessQuery;
63
59
  private rows: SubscriptionRow[] = [];
64
- private selectedIndex = 0;
65
- private scrollOffset = 0;
66
60
  private logoutConfirmationTarget: string | null = null;
67
61
 
68
62
  public constructor(
@@ -79,6 +73,30 @@ export class SubscriptionPanel extends BasePanel {
79
73
  this.refresh();
80
74
  }
81
75
 
76
+ protected override getPalette() { return C; }
77
+ protected override getEmptyStateMessage() { return ' No provider subscriptions are active yet.'; }
78
+ protected override getEmptyStateActions() {
79
+ return [
80
+ { command: '/subscription login openai start', summary: 'start the first-class OpenAI subscription flow' },
81
+ { command: '/login provider <name> start', summary: 'use the front-door auth surface for supported providers' },
82
+ { command: '/services auth-review', summary: 'inspect configured service auth posture and stored secrets' },
83
+ ];
84
+ }
85
+
86
+ protected getItems(): readonly SubscriptionRow[] {
87
+ return this.rows;
88
+ }
89
+
90
+ protected renderItem(row: SubscriptionRow, index: number, selected: boolean, width: number): Line {
91
+ const status = statusOf(row);
92
+ return buildPanelListRow(width, [
93
+ { text: row.provider.padEnd(16).slice(0, 16), fg: C.value },
94
+ { text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
95
+ { text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
96
+ { text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
97
+ ], C, { selected, selectedBg: C.selectedBg });
98
+ }
99
+
82
100
  public handleInput(key: string): boolean {
83
101
  if (this.rows.length === 0) return false;
84
102
  const selected = this.rows[this.selectedIndex] ?? null;
@@ -96,11 +114,6 @@ export class SubscriptionPanel extends BasePanel {
96
114
  }
97
115
  if (key === 'enter' || key === 'x') {
98
116
  if (!selected?.subscription) return false;
99
- // I1: use confirm helper — first press sets target, second (y) executes
100
- const confirmResult = handleConfirmInput(
101
- this.logoutConfirmationTarget ? { subject: this.logoutConfirmationTarget, label: this.logoutConfirmationTarget } : null,
102
- key,
103
- );
104
117
  if (this.logoutConfirmationTarget === null || this.logoutConfirmationTarget !== selected.provider) {
105
118
  this.logoutConfirmationTarget = selected.provider;
106
119
  this.markDirty();
@@ -112,7 +125,6 @@ export class SubscriptionPanel extends BasePanel {
112
125
  this.markDirty();
113
126
  return true;
114
127
  }
115
- // Allow n/Esc to cancel pending logout confirm
116
128
  if ((key === 'n' || key === 'escape') && this.logoutConfirmationTarget) {
117
129
  this.logoutConfirmationTarget = null;
118
130
  this.markDirty();
@@ -157,8 +169,9 @@ export class SubscriptionPanel extends BasePanel {
157
169
  }
158
170
 
159
171
  public render(width: number, height: number): Line[] {
160
- this.needsRender = false;
161
172
  this.refresh();
173
+ this.clampSelection();
174
+ const intro = 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.';
162
175
 
163
176
  const activeCount = this.rows.filter((row) => row.subscription).length;
164
177
  const pendingCount = this.rows.filter((row) => row.pending).length;
@@ -175,111 +188,75 @@ export class SubscriptionPanel extends BasePanel {
175
188
  ], C),
176
189
  buildGuidanceLine(width, '/subscription login <provider> start', 'start or repair browser login for the selected provider route', C),
177
190
  ];
178
- const footerLines = [
179
- buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
180
- buildPanelLine(width, [[' Up/Down move Enter/X sign out selected provider r refresh', C.dim]]),
181
- ] as const;
182
191
 
192
+ // Empty state: render posture + base empty state
183
193
  if (this.rows.length === 0) {
184
- const lines: Line[] = [];
185
- lines.push(...buildSummaryBlock(width, 'Subscription posture', postureLines, C));
186
- lines.push(...buildEmptyState(
194
+ const summaryLines = buildSummaryBlock(width, 'Subscription posture', postureLines, C);
195
+ const emptyLines = buildEmptyState(
187
196
  width,
188
- ' No provider subscriptions are active yet.',
197
+ this.getEmptyStateMessage(),
189
198
  'Built-in OAuth-capable providers and configured service providers will appear here once available for browser login or session import.',
190
- [
191
- { command: '/subscription login openai start', summary: 'start the first-class OpenAI subscription flow' },
192
- { command: '/login provider <name> start', summary: 'use the front-door auth surface for supported providers' },
193
- { command: '/services auth-review', summary: 'inspect configured service auth posture and stored secrets' },
194
- ],
199
+ this.getEmptyStateActions(),
195
200
  C,
196
- ));
201
+ );
197
202
  const workspace = buildPanelWorkspace(width, height, {
198
203
  title: 'Provider Subscriptions',
199
- intro: 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.',
200
- sections: [{ lines }] satisfies readonly PanelWorkspaceSection[],
201
- footerLines,
204
+ intro,
205
+ sections: [{ lines: [...summaryLines, ...emptyLines] }],
206
+ footerLines: [
207
+ buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
208
+ buildPanelLine(width, [[' Up/Down move Enter/X sign out selected provider r refresh', C.dim]]),
209
+ ],
202
210
  palette: C,
203
211
  });
204
212
  while (workspace.length < height) workspace.push(createEmptyLine(width));
205
213
  return workspace.slice(0, height);
206
214
  }
207
215
 
208
- const selected = this.rows[this.selectedIndex];
216
+ const selectedRow = this.rows[this.selectedIndex];
209
217
  const detailRows: Line[] = [];
210
- if (selected) {
218
+ if (selectedRow) {
211
219
  detailRows.push(buildKeyValueLine(width, [
212
- { label: 'provider', value: selected.provider, valueColor: C.value },
213
- { label: 'status', value: statusOf(selected), valueColor: statusColor(statusOf(selected)) },
214
- { label: 'oauth config', value: selected.hasOAuthConfig ? 'present' : 'missing', valueColor: selected.hasOAuthConfig ? C.good : C.bad },
220
+ { label: 'provider', value: selectedRow.provider, valueColor: C.value },
221
+ { label: 'status', value: statusOf(selectedRow), valueColor: statusColor(statusOf(selectedRow)) },
222
+ { label: 'oauth config', value: selectedRow.hasOAuthConfig ? 'present' : 'missing', valueColor: selectedRow.hasOAuthConfig ? C.good : C.bad },
215
223
  ], C));
216
- if (selected.subscription) {
217
- const expires = selected.subscription.expiresAt
218
- ? new Date(selected.subscription.expiresAt).toISOString()
224
+ if (selectedRow.subscription) {
225
+ const expires = selectedRow.subscription.expiresAt
226
+ ? new Date(selectedRow.subscription.expiresAt).toISOString()
219
227
  : 'n/a';
220
228
  detailRows.push(buildKeyValueLine(width, [
221
- { label: 'token type', value: selected.subscription.tokenType, valueColor: C.info },
229
+ { label: 'token type', value: selectedRow.subscription.tokenType, valueColor: C.info },
222
230
  { label: 'expires', value: expires, valueColor: C.dim },
223
231
  ], C));
224
232
  detailRows.push(buildPanelLine(width, [[
225
- ` ${selected.subscription.overrideAmbientApiKeys
233
+ ` ${selectedRow.subscription.overrideAmbientApiKeys
226
234
  ? 'Provider subscription overrides ambient API-key resolution for this provider.'
227
235
  : 'Stored for subscription-backed flows. Ambient API-key resolution remains unchanged.'}`,
228
236
  C.dim,
229
237
  ]]));
230
- if (this.logoutConfirmationTarget === selected.provider) {
231
- detailRows.push(buildPanelLine(width, [[` Press Enter or X again to sign out ${selected.provider}.`, C.warn]]));
238
+ if (this.logoutConfirmationTarget === selectedRow.provider) {
239
+ detailRows.push(buildPanelLine(width, [[` Press Enter or X again to sign out ${selectedRow.provider}.`, C.warn]]));
232
240
  }
233
- } else if (selected.pending) {
241
+ } else if (selectedRow.pending) {
234
242
  detailRows.push(buildPanelLine(width, [[' Login is pending. Finish with /subscription login <provider> finish <code>.', C.warn]]));
235
- } else if (selected.hasOAuthConfig) {
243
+ } else if (selectedRow.hasOAuthConfig) {
236
244
  detailRows.push(buildPanelLine(width, [[' Ready for login. Start with /subscription login <provider> start.', C.dim]]));
237
245
  } else {
238
246
  detailRows.push(buildPanelLine(width, [[' Add a provider-specific OAuth config or enable a built-in subscription provider to use subscription login.', C.bad]]));
239
247
  }
240
248
  }
241
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Subscription posture', postureLines, C) };
242
- const detailSection: PanelWorkspaceSection = { lines: buildDetailBlock(width, 'Selected provider', detailRows, C) };
243
- const rawProviderLines: Line[] = this.rows.map((row, absolute) => {
244
- const status = statusOf(row);
245
- return buildPanelListRow(width, [
246
- { text: row.provider.padEnd(16).slice(0, 16), fg: C.value },
247
- { text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
248
- { text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
249
- { text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
250
- ], C, { selected: absolute === this.selectedIndex, selectedBg: C.selectedBg });
251
- });
252
- const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
253
- intro: 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.',
254
- footerLines,
255
- palette: C,
256
- beforeSections: [postureSection],
257
- section: {
258
- title: 'Providers',
259
- scrollableLines: rawProviderLines,
260
- selectedIndex: this.selectedIndex,
261
- scrollOffset: this.scrollOffset,
262
- guardRows: 1,
263
- minRows: 4,
264
- appendWindowSummary: { dimColor: C.dim },
265
- },
266
- afterSections: [detailSection],
267
- });
268
- this.scrollOffset = resolvedProvidersSection.scrollOffset;
269
249
 
270
- const sections: PanelWorkspaceSection[] = [
271
- postureSection,
272
- resolvedProvidersSection.section,
273
- detailSection,
274
- ];
275
- const lines = buildPanelWorkspace(width, height, {
250
+ const headerLines: Line[] = buildSummaryBlock(width, 'Subscription posture', postureLines, C);
251
+
252
+ return this.renderList(width, height, {
276
253
  title: 'Provider Subscriptions',
277
- intro: 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.',
278
- sections,
279
- footerLines,
280
- palette: C,
254
+ header: headerLines,
255
+ footer: [
256
+ ...detailRows,
257
+ buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
258
+ buildPanelLine(width, [[' Up/Down move Enter/X sign out selected provider r refresh', C.dim]]),
259
+ ],
281
260
  });
282
- while (lines.length < height) lines.push(createEmptyLine(width));
283
- return lines.slice(0, height);
284
261
  }
285
262
  }