@pellux/goodvibes-tui 0.18.19 → 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 (42) hide show
  1. package/CHANGELOG.md +170 -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-routes.ts +10 -0
  8. package/src/input/handler-feed.ts +44 -6
  9. package/src/input/handler-shortcuts.ts +138 -125
  10. package/src/input/handler.ts +121 -119
  11. package/src/input/keybindings.ts +30 -0
  12. package/src/panels/approval-panel.ts +54 -74
  13. package/src/panels/automation-control-panel.ts +119 -161
  14. package/src/panels/base-panel.ts +71 -0
  15. package/src/panels/communication-panel.ts +68 -107
  16. package/src/panels/confirm-state.ts +61 -0
  17. package/src/panels/control-plane-panel.ts +116 -172
  18. package/src/panels/git-panel.ts +9 -0
  19. package/src/panels/hooks-panel.ts +101 -138
  20. package/src/panels/incident-review-panel.ts +55 -107
  21. package/src/panels/knowledge-panel.ts +63 -14
  22. package/src/panels/local-auth-panel.ts +76 -93
  23. package/src/panels/marketplace-panel.ts +19 -12
  24. package/src/panels/mcp-panel.ts +108 -155
  25. package/src/panels/ops-control-panel.ts +50 -85
  26. package/src/panels/panel-manager.ts +22 -2
  27. package/src/panels/plugins-panel.ts +36 -60
  28. package/src/panels/routes-panel.ts +89 -141
  29. package/src/panels/scrollable-list-panel.ts +71 -16
  30. package/src/panels/security-panel.ts +101 -137
  31. package/src/panels/services-panel.ts +58 -102
  32. package/src/panels/settings-sync-panel.ts +76 -122
  33. package/src/panels/skills-panel.ts +44 -0
  34. package/src/panels/subscription-panel.ts +69 -80
  35. package/src/panels/tasks-panel.ts +129 -179
  36. package/src/panels/watchers-panel.ts +88 -137
  37. package/src/renderer/buffer.ts +11 -0
  38. package/src/renderer/diff.ts +8 -0
  39. package/src/renderer/help-overlay.ts +37 -28
  40. package/src/renderer/markdown.ts +3 -145
  41. package/src/renderer/status-token.ts +71 -0
  42. 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
  }
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { Line } from '../types/grid.ts';
4
4
  import { createEmptyLine } from '../types/grid.ts';
5
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
5
6
  import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
6
7
  import { BasePanel } from './base-panel.ts';
7
8
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
@@ -241,6 +242,8 @@ export class SkillsPanel extends BasePanel {
241
242
  private scrollOffset = 0;
242
243
  private cached: SkillRecord[] | null = null;
243
244
  private cacheDirty = true;
245
+ // I1: confirm state for destructive delete
246
+ private confirm: ConfirmState | null = null;
244
247
 
245
248
  public constructor(options: SkillsPanelOptions) {
246
249
  super('skills', 'Skills', 'K', 'monitoring', options.componentHealthMonitor);
@@ -259,6 +262,24 @@ export class SkillsPanel extends BasePanel {
259
262
  public override onDestroy(): void {}
260
263
 
261
264
  public handleInput(key: string): boolean {
265
+ // I1: y/n confirmation dialog for delete
266
+ const confirmResult = handleConfirmInput(this.confirm, key);
267
+ if (confirmResult === 'confirmed') {
268
+ const toDelete = this.confirm!.subject;
269
+ this.confirm = null;
270
+ // Skills are read from the filesystem — deletion requires a shell command.
271
+ // Surface an error directing the user to remove the file manually.
272
+ this.setError(`Delete via shell: rm "${toDelete}"`);
273
+ this.markDirty();
274
+ return true;
275
+ }
276
+ if (confirmResult === 'cancelled') {
277
+ this.confirm = null;
278
+ this.markDirty();
279
+ return true;
280
+ }
281
+ if (confirmResult === 'absorbed') return true;
282
+
262
283
  const records = this._filteredSkills();
263
284
  if (this.filterFocused) {
264
285
  const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: records.length });
@@ -329,6 +350,15 @@ export class SkillsPanel extends BasePanel {
329
350
  this.markDirty();
330
351
  return true;
331
352
  }
353
+ // I1: 'd' prompts delete confirmation
354
+ if (key === 'd') {
355
+ const skill = records[this.selectedIndex];
356
+ if (skill) {
357
+ this.confirm = { subject: skill.path, label: skill.name };
358
+ this.markDirty();
359
+ }
360
+ return true;
361
+ }
332
362
  if (isPanelSearchBackspace(key)) {
333
363
  if (this.query.length === 0) return false;
334
364
  this.query = this.query.slice(0, -1);
@@ -355,6 +385,20 @@ export class SkillsPanel extends BasePanel {
355
385
 
356
386
  const start = Date.now();
357
387
  this.needsRender = false;
388
+
389
+ // I1: show confirm dialog in place of normal content
390
+ if (this.confirm) {
391
+ const lines = buildPanelWorkspace(width, height, {
392
+ title: 'Skills - confirm action',
393
+ intro: '',
394
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
395
+ palette: C,
396
+ });
397
+ while (lines.length < height) lines.push(createEmptyLine(width));
398
+ this.reportRenderDuration(Date.now() - start);
399
+ return lines.slice(0, height);
400
+ }
401
+
358
402
  const intro = 'Discover project-local and global skill packs, filter by name or description, and inspect path, dependencies, and includes.';
359
403
  const skills = this._filteredSkills();
360
404
 
@@ -1,11 +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';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
5
5
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
6
6
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
7
7
  import {
8
- buildDetailBlock,
9
8
  buildEmptyState,
10
9
  buildGuidanceLine,
11
10
  buildKeyValueLine,
@@ -14,8 +13,6 @@ import {
14
13
  buildSummaryBlock,
15
14
  buildPanelWorkspace,
16
15
  DEFAULT_PANEL_PALETTE,
17
- resolvePrimaryScrollableSection,
18
- type PanelWorkspaceSection,
19
16
  } from './polish.ts';
20
17
 
21
18
  const C = {
@@ -56,12 +53,10 @@ function statusColor(status: ReturnType<typeof statusOf>): string {
56
53
  }
57
54
  }
58
55
 
59
- export class SubscriptionPanel extends BasePanel {
56
+ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
60
57
  private readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
61
58
  private readonly subscriptionManager: SubscriptionAccessQuery;
62
59
  private rows: SubscriptionRow[] = [];
63
- private selectedIndex = 0;
64
- private scrollOffset = 0;
65
60
  private logoutConfirmationTarget: string | null = null;
66
61
 
67
62
  public constructor(
@@ -78,6 +73,30 @@ export class SubscriptionPanel extends BasePanel {
78
73
  this.refresh();
79
74
  }
80
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
+
81
100
  public handleInput(key: string): boolean {
82
101
  if (this.rows.length === 0) return false;
83
102
  const selected = this.rows[this.selectedIndex] ?? null;
@@ -95,7 +114,7 @@ export class SubscriptionPanel extends BasePanel {
95
114
  }
96
115
  if (key === 'enter' || key === 'x') {
97
116
  if (!selected?.subscription) return false;
98
- if (this.logoutConfirmationTarget !== selected.provider) {
117
+ if (this.logoutConfirmationTarget === null || this.logoutConfirmationTarget !== selected.provider) {
99
118
  this.logoutConfirmationTarget = selected.provider;
100
119
  this.markDirty();
101
120
  return true;
@@ -106,6 +125,11 @@ export class SubscriptionPanel extends BasePanel {
106
125
  this.markDirty();
107
126
  return true;
108
127
  }
128
+ if ((key === 'n' || key === 'escape') && this.logoutConfirmationTarget) {
129
+ this.logoutConfirmationTarget = null;
130
+ this.markDirty();
131
+ return true;
132
+ }
109
133
  if (key === 'r') {
110
134
  this.refresh();
111
135
  this.logoutConfirmationTarget = null;
@@ -145,8 +169,9 @@ export class SubscriptionPanel extends BasePanel {
145
169
  }
146
170
 
147
171
  public render(width: number, height: number): Line[] {
148
- this.needsRender = false;
149
172
  this.refresh();
173
+ this.clampSelection();
174
+ const intro = 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.';
150
175
 
151
176
  const activeCount = this.rows.filter((row) => row.subscription).length;
152
177
  const pendingCount = this.rows.filter((row) => row.pending).length;
@@ -163,111 +188,75 @@ export class SubscriptionPanel extends BasePanel {
163
188
  ], C),
164
189
  buildGuidanceLine(width, '/subscription login <provider> start', 'start or repair browser login for the selected provider route', C),
165
190
  ];
166
- const footerLines = [
167
- buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
168
- buildPanelLine(width, [[' Up/Down move Enter/X sign out selected provider r refresh', C.dim]]),
169
- ] as const;
170
191
 
192
+ // Empty state: render posture + base empty state
171
193
  if (this.rows.length === 0) {
172
- const lines: Line[] = [];
173
- lines.push(...buildSummaryBlock(width, 'Subscription posture', postureLines, C));
174
- lines.push(...buildEmptyState(
194
+ const summaryLines = buildSummaryBlock(width, 'Subscription posture', postureLines, C);
195
+ const emptyLines = buildEmptyState(
175
196
  width,
176
- ' No provider subscriptions are active yet.',
197
+ this.getEmptyStateMessage(),
177
198
  'Built-in OAuth-capable providers and configured service providers will appear here once available for browser login or session import.',
178
- [
179
- { command: '/subscription login openai start', summary: 'start the first-class OpenAI subscription flow' },
180
- { command: '/login provider <name> start', summary: 'use the front-door auth surface for supported providers' },
181
- { command: '/services auth-review', summary: 'inspect configured service auth posture and stored secrets' },
182
- ],
199
+ this.getEmptyStateActions(),
183
200
  C,
184
- ));
201
+ );
185
202
  const workspace = buildPanelWorkspace(width, height, {
186
203
  title: 'Provider Subscriptions',
187
- intro: 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.',
188
- sections: [{ lines }] satisfies readonly PanelWorkspaceSection[],
189
- 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
+ ],
190
210
  palette: C,
191
211
  });
192
212
  while (workspace.length < height) workspace.push(createEmptyLine(width));
193
213
  return workspace.slice(0, height);
194
214
  }
195
215
 
196
- const selected = this.rows[this.selectedIndex];
216
+ const selectedRow = this.rows[this.selectedIndex];
197
217
  const detailRows: Line[] = [];
198
- if (selected) {
218
+ if (selectedRow) {
199
219
  detailRows.push(buildKeyValueLine(width, [
200
- { label: 'provider', value: selected.provider, valueColor: C.value },
201
- { label: 'status', value: statusOf(selected), valueColor: statusColor(statusOf(selected)) },
202
- { 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 },
203
223
  ], C));
204
- if (selected.subscription) {
205
- const expires = selected.subscription.expiresAt
206
- ? new Date(selected.subscription.expiresAt).toISOString()
224
+ if (selectedRow.subscription) {
225
+ const expires = selectedRow.subscription.expiresAt
226
+ ? new Date(selectedRow.subscription.expiresAt).toISOString()
207
227
  : 'n/a';
208
228
  detailRows.push(buildKeyValueLine(width, [
209
- { label: 'token type', value: selected.subscription.tokenType, valueColor: C.info },
229
+ { label: 'token type', value: selectedRow.subscription.tokenType, valueColor: C.info },
210
230
  { label: 'expires', value: expires, valueColor: C.dim },
211
231
  ], C));
212
232
  detailRows.push(buildPanelLine(width, [[
213
- ` ${selected.subscription.overrideAmbientApiKeys
233
+ ` ${selectedRow.subscription.overrideAmbientApiKeys
214
234
  ? 'Provider subscription overrides ambient API-key resolution for this provider.'
215
235
  : 'Stored for subscription-backed flows. Ambient API-key resolution remains unchanged.'}`,
216
236
  C.dim,
217
237
  ]]));
218
- if (this.logoutConfirmationTarget === selected.provider) {
219
- 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]]));
220
240
  }
221
- } else if (selected.pending) {
241
+ } else if (selectedRow.pending) {
222
242
  detailRows.push(buildPanelLine(width, [[' Login is pending. Finish with /subscription login <provider> finish <code>.', C.warn]]));
223
- } else if (selected.hasOAuthConfig) {
243
+ } else if (selectedRow.hasOAuthConfig) {
224
244
  detailRows.push(buildPanelLine(width, [[' Ready for login. Start with /subscription login <provider> start.', C.dim]]));
225
245
  } else {
226
246
  detailRows.push(buildPanelLine(width, [[' Add a provider-specific OAuth config or enable a built-in subscription provider to use subscription login.', C.bad]]));
227
247
  }
228
248
  }
229
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Subscription posture', postureLines, C) };
230
- const detailSection: PanelWorkspaceSection = { lines: buildDetailBlock(width, 'Selected provider', detailRows, C) };
231
- const rawProviderLines: Line[] = this.rows.map((row, absolute) => {
232
- const status = statusOf(row);
233
- return buildPanelListRow(width, [
234
- { text: row.provider.padEnd(16).slice(0, 16), fg: C.value },
235
- { text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
236
- { text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
237
- { text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
238
- ], C, { selected: absolute === this.selectedIndex, selectedBg: C.selectedBg });
239
- });
240
- const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
241
- intro: 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.',
242
- footerLines,
243
- palette: C,
244
- beforeSections: [postureSection],
245
- section: {
246
- title: 'Providers',
247
- scrollableLines: rawProviderLines,
248
- selectedIndex: this.selectedIndex,
249
- scrollOffset: this.scrollOffset,
250
- guardRows: 1,
251
- minRows: 4,
252
- appendWindowSummary: { dimColor: C.dim },
253
- },
254
- afterSections: [detailSection],
255
- });
256
- this.scrollOffset = resolvedProvidersSection.scrollOffset;
257
249
 
258
- const sections: PanelWorkspaceSection[] = [
259
- postureSection,
260
- resolvedProvidersSection.section,
261
- detailSection,
262
- ];
263
- const lines = buildPanelWorkspace(width, height, {
250
+ const headerLines: Line[] = buildSummaryBlock(width, 'Subscription posture', postureLines, C);
251
+
252
+ return this.renderList(width, height, {
264
253
  title: 'Provider Subscriptions',
265
- intro: 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.',
266
- sections,
267
- footerLines,
268
- 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
+ ],
269
260
  });
270
- while (lines.length < height) lines.push(createEmptyLine(width));
271
- return lines.slice(0, height);
272
261
  }
273
262
  }