@pellux/goodvibes-tui 0.18.19 → 0.18.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.18.20] — 2026-04-16
8
+
9
+ ### Wave 3a / Tier 2 TUI UX Consistency Infrastructure
10
+
11
+ Six infrastructure items that make the TUI behave consistently across all panels.
12
+
13
+ ### I1 — Reusable inline confirm dialog
14
+
15
+ - **New file `src/panels/confirm-state.ts`**: exports `ConfirmState<T>`, `handleConfirmInput<T>`, and `renderConfirmLines<T>`. Identical y/n UX across all panels: y confirms, n/Esc cancels, any other key is absorbed while confirm is active.
16
+ - **SkillsPanel**: 'd' key now shows an inline `Confirmation` section before surfacing the shell delete hint. Pressing Esc on the confirm panel cancels it via the generic helper.
17
+ - **KnowledgePanel**: 's' (stale) and 'c' (contradicted) now prompt confirm before calling `registry.review()`. Error from review mutation is surfaced via I2 `setError()`.
18
+ - **SubscriptionPanel**: 'n' and Escape now cancel a pending logout confirmation via the confirm helper.
19
+
20
+ ### I2 — Error surface slot on BasePanel
21
+
22
+ - **`src/panels/base-panel.ts`**: added `protected lastError`, `setError()`, `clearError()`, `renderErrorLine(width)`. Auto-clear on next keypress in `ScrollableListPanel.handleInput()`.
23
+ - **`src/panels/scrollable-list-panel.ts`**: `renderList()` prepends the error line to the `effectiveFooter` — visible in both normal and empty states.
24
+ - **MarketplacePanel**: `refresh()` now wraps catalog load in try/catch and calls `setError()` on failure. Clears error on successful reload.
25
+ - **KnowledgePanel**: `registry.review()` call in confirm dispatch wrapped in try/catch, wired to `setError()`.
26
+
27
+ ### I3 — Loading spinner slot on BasePanel
28
+
29
+ - **`src/panels/base-panel.ts`**: added `loadingState: 'idle'|'loading'|'error'`, `startLoading(label?)`, `stopLoading()`, `renderLoadingLine(width, frame)`. Uses `SPINNER_FRAMES` from `src/renderer/progress.ts`.
30
+ - **`src/panels/scrollable-list-panel.ts`**: `renderList()` short-circuits to a spinner-only view when `loadingState === 'loading'`.
31
+ - **GitPanel**: `openDiff()` now calls `startLoading('Loading diff...')` before the await and `stopLoading()` in both success and error paths. The `render()` method checks `this.loadingState === 'loading'` to show the spinner while the diff is being fetched.
32
+
33
+ ### I4 — Accessible status tokens
34
+
35
+ - **New file `src/renderer/status-token.ts`**: exports `buildStatusToken(state, label, opts?)` → `Cell[]`. State map: `good=✓`, `warn=⚠`, `bad=✕`, `info=○`. Glyph + color together so colorblind users can distinguish states without relying on color alone.
36
+ - **ApprovalPanel**: recent approvals/denials/pending row now uses inline `✓ approvals (N) ✕ denials (N) ○ pending (N)` cells instead of bare color-only counts.
37
+
38
+ ### I6 — Two-stage Escape in panel focus
39
+
40
+ - **`src/input/handler-feed-routes.ts`**: `handlePanelFocusToken()` now passes `'escape'` to the active panel's `handleInput()` BEFORE deciding to unfocus. If the panel returns `true` (e.g. dismisses a confirm dialog or clears a search), the panel stays focused. Only if the panel returns `false` does the router set `panelFocused = false`.
41
+
42
+ ### Tests
43
+
44
+ - **`src/test/renderer/status-token.test.ts`** (8 tests): glyph/color/count/override coverage for `buildStatusToken`.
45
+ - **`src/test/panels/base-panel-ux.test.ts`** (16 tests): `setError`/`clearError`/`renderErrorLine` and `startLoading`/`stopLoading`/`renderLoadingLine` state transitions.
46
+ - **`src/test/panels/confirm-state.test.ts`** (13 tests): `handleConfirmInput` all four return values + `renderConfirmLines` width/content.
47
+ - **`src/test/panels/knowledge-panel.test.ts`**: updated to reflect I1 two-step confirm for `'s'` (stale) action.
48
+
49
+ ### Tests & Checks
50
+
51
+ - Test suite: 441/441 passing (3 new test files)
52
+ - Architecture check: passing (298 non-test source files)
53
+ - Typecheck: clean
54
+
55
+ ---
56
+
7
57
  ## [0.18.19] — 2026-04-16
8
58
 
9
59
  ### Quality bump — address sub-10 dimensions from 0.18.18 review
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.18.19-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.18.20-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.18.19",
3
+ "version": "0.18.20",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -54,7 +54,17 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
54
54
  }
55
55
 
56
56
  if (token.type === 'key') {
57
+ // I6: two-stage Escape — give the panel a chance to consume escape first
58
+ // (e.g. dismiss a confirm dialog or clear search). Only unfocus if the
59
+ // panel returns false (unconsumed) or there is no active panel.
57
60
  if (token.logicalName === 'escape') {
61
+ const activePanel = state.panelManager.getActive();
62
+ const panelConsumedEscape = activePanel?.handleInput?.('escape') ?? false;
63
+ if (panelConsumedEscape) {
64
+ state.onPanelInputConsumed?.(activePanel!, 'escape');
65
+ state.requestRender();
66
+ return { handled: true, panelFocused };
67
+ }
58
68
  panelFocused = false;
59
69
  state.requestRender();
60
70
  return { handled: true, panelFocused };
@@ -81,11 +81,19 @@ export class ApprovalPanel extends BasePanel {
81
81
  { label: 'what-if', value: '/policy simulate + preflight', valueColor: C.info },
82
82
  { label: 'operator', value: '/security + /cockpit', valueColor: C.good },
83
83
  ], C),
84
- buildKeyValueLine(width, [
85
- { label: 'recent approvals', value: String(policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === true).length), valueColor: C.good },
86
- { label: 'recent denials', value: String(policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === false).length), valueColor: C.bad },
87
- { label: 'pending', value: String(policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === undefined).length), valueColor: C.info },
88
- ], C),
84
+ (() => {
85
+ const approvalCount = policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === true).length;
86
+ const denialCount = policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === false).length;
87
+ const pendingCount = policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === undefined).length;
88
+ return buildPanelLine(width, [
89
+ [' \u2713 ', C.good],
90
+ [`approvals (${approvalCount}) `, C.good],
91
+ ['\u2715 ', C.bad],
92
+ [`denials (${denialCount}) `, C.bad],
93
+ ['\u25cb ', C.info],
94
+ [`pending (${pendingCount})`, C.info],
95
+ ]);
96
+ })(),
89
97
  buildGuidanceLine(width, '/approval review shell', 'inspect the highest-risk approval lane and refine scoped review posture', C),
90
98
  ];
91
99
  const footerLines = [buildPanelLine(width, [[` Up/Down move Home/End jump selected lane opens the next command path`, C.dim]])];
@@ -2,6 +2,8 @@ import type { Line } from '../types/grid.ts';
2
2
  import type { Panel, PanelCategory } from './types.ts';
3
3
  import type { ComponentResourceContract, ComponentHealthState } from '../runtime/perf/panel-contracts.ts';
4
4
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
5
+ import { UIFactory } from '../renderer/ui-factory.ts';
6
+ import { SPINNER_FRAMES } from '../renderer/progress.ts';
5
7
 
6
8
  export abstract class BasePanel implements Panel {
7
9
  public needsRender = true;
@@ -9,6 +11,75 @@ export abstract class BasePanel implements Panel {
9
11
  public isPinned = false;
10
12
  protected readonly componentHealthMonitor?: ComponentHealthMonitor;
11
13
 
14
+ // -------------------------------------------------------------------------
15
+ // I2: Error surface slot
16
+ // -------------------------------------------------------------------------
17
+
18
+ /** Last error message to surface in the panel footer. Auto-cleared on next input. */
19
+ protected lastError: string | null = null;
20
+
21
+ /** Set a transient error message. Triggers a re-render. */
22
+ protected setError(msg: string): void {
23
+ this.lastError = msg;
24
+ this.needsRender = true;
25
+ }
26
+
27
+ /** Clear the current error. */
28
+ protected clearError(): void {
29
+ this.lastError = null;
30
+ }
31
+
32
+ /**
33
+ * Build a single error Line for display above the hints footer.
34
+ * Returns null when there is no active error.
35
+ *
36
+ * Color: bold red foreground (palette-consistent: #ef4444).
37
+ */
38
+ protected renderErrorLine(width: number): Line | null {
39
+ if (!this.lastError) return null;
40
+ return UIFactory.stringToLine(
41
+ ` ✕ ${this.lastError}`.padEnd(width).slice(0, width),
42
+ width,
43
+ { fg: '#ef4444', bold: true },
44
+ );
45
+ }
46
+
47
+ // -------------------------------------------------------------------------
48
+ // I3: Loading spinner slot
49
+ // -------------------------------------------------------------------------
50
+
51
+ /** Tracks the loading label for the spinner (undefined = no spinner active). */
52
+ protected loadingState: 'idle' | 'loading' | 'error' = 'idle';
53
+ private _loadingLabel = '';
54
+
55
+ /** Begin loading. Triggers a re-render. */
56
+ protected startLoading(label = 'Loading...'): void {
57
+ this.loadingState = 'loading';
58
+ this._loadingLabel = label;
59
+ this.needsRender = true;
60
+ }
61
+
62
+ /** End loading (returns to idle). Triggers a re-render. */
63
+ protected stopLoading(): void {
64
+ this.loadingState = 'idle';
65
+ this._loadingLabel = '';
66
+ this.needsRender = true;
67
+ }
68
+
69
+ /**
70
+ * Build a spinner Line for the loading state.
71
+ * Returns null when loadingState is not 'loading'.
72
+ *
73
+ * @param width Panel width in columns.
74
+ * @param frame Current animation frame index (caller increments each render).
75
+ */
76
+ protected renderLoadingLine(width: number, frame = 0): Line | null {
77
+ if (this.loadingState !== 'loading') return null;
78
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0]!;
79
+ const text = ` ${spinner} ${this._loadingLabel}`;
80
+ return UIFactory.stringToLine(text.padEnd(width).slice(0, width), width, { fg: '135', bold: true });
81
+ }
82
+
12
83
  /**
13
84
  * Optional resource contract for this panel.
14
85
  * Override in subclasses to declare a custom contract; leave undefined
@@ -0,0 +1,61 @@
1
+ // ---------------------------------------------------------------------------
2
+ // useConfirmState<T> — reusable inline y/n confirmation helper
3
+ //
4
+ // Pattern (chosen over ConfirmableListPanel base class):
5
+ // - Composable: any panel holds a ConfirmState field, not a new base class
6
+ // - Identical y/n UX everywhere: y confirms, n/Esc cancels, any other key
7
+ // is absorbed (does nothing) while confirm is active
8
+ // - Render: caller calls renderConfirmLines(width, state) to get the two
9
+ // lines that replace the normal content area when confirming
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import type { Line } from '../types/grid.ts';
13
+ import { buildPanelLine } from './polish.ts';
14
+ import { DEFAULT_PANEL_PALETTE } from './polish.ts';
15
+
16
+ export interface ConfirmState<T = string> {
17
+ /** The subject of the confirmation (e.g. item name or id). */
18
+ readonly subject: T;
19
+ /** Human-readable label for the item being destroyed. */
20
+ readonly label: string;
21
+ }
22
+
23
+ /**
24
+ * Call this from a panel's handleInput() BEFORE any other key handling.
25
+ *
26
+ * Returns:
27
+ * - `'confirmed'` — user pressed y; caller must execute the action and
28
+ * clear state (set confirm to null)
29
+ * - `'cancelled'` — user pressed n or Esc; caller must clear state
30
+ * - `'absorbed'` — any other key while confirm is active; caller returns true
31
+ * - `'inactive'` — no confirm pending; caller continues normal dispatch
32
+ */
33
+ export function handleConfirmInput<T = string>(
34
+ confirm: ConfirmState<T> | null,
35
+ key: string,
36
+ ): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
37
+ if (!confirm) return 'inactive';
38
+ if (key === 'y') return 'confirmed';
39
+ if (key === 'n' || key === 'escape') return 'cancelled';
40
+ return 'absorbed';
41
+ }
42
+
43
+ /**
44
+ * Build the two confirmation lines to show in place of the normal list body.
45
+ * Callers embed these lines in a workspace section titled 'Confirmation'.
46
+ */
47
+ export function renderConfirmLines<T = string>(width: number, state: ConfirmState<T>): Line[] {
48
+ const palette = DEFAULT_PANEL_PALETTE;
49
+ return [
50
+ buildPanelLine(width, [[
51
+ ` Delete "${state.label}"?`,
52
+ palette.warn,
53
+ ]]),
54
+ buildPanelLine(width, [
55
+ [' y', palette.info],
56
+ [' confirm delete', palette.dim],
57
+ [' n / Esc', palette.info],
58
+ [' cancel', palette.dim],
59
+ ]),
60
+ ];
61
+ }
@@ -326,13 +326,18 @@ export class GitPanel extends BasePanel {
326
326
  const item = this.items[this.selectedIndex];
327
327
  if (!item || item.kind !== 'file') return;
328
328
 
329
+ // I3: show base-class spinner while awaiting diff
330
+ this.startLoading('Loading diff...');
331
+ this.markDirty();
329
332
  try {
330
333
  const git = new GitService(this.workingDirectory);
331
334
  const raw = await git.diffFile(item.entry.path, item.entry.staged);
335
+ this.stopLoading();
332
336
  this.expandedDiff = raw ? raw.split('\n') : ['(no diff available)'];
333
337
  this.scrollOffset = 0;
334
338
  this.markDirty();
335
339
  } catch (err) {
340
+ this.stopLoading();
336
341
  this.expandedDiff = [`Error: ${summarizeError(err)}`];
337
342
  this.scrollOffset = 0;
338
343
  this.markDirty();
@@ -350,6 +355,10 @@ export class GitPanel extends BasePanel {
350
355
  if (this.error) {
351
356
  return this.renderMessage(width, height, `Git error: ${this.error}`, C.unstaged);
352
357
  }
358
+ // I3: spinner during openDiff() async fetch
359
+ if (this.loadingState === 'loading') {
360
+ return this.renderMessage(width, height, 'Loading diff...', C.branch);
361
+ }
353
362
  if (this.expandedDiff !== null) {
354
363
  return this.renderDiff(width, height);
355
364
  }
@@ -1,5 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { BasePanel } from './base-panel.ts';
3
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
3
4
  import type { MemoryClass, MemoryRecord, MemoryRegistry, MemoryReviewState } from '@pellux/goodvibes-sdk/platform/state/memory-store';
4
5
  import {
5
6
  buildBodyText,
@@ -47,6 +48,8 @@ export class KnowledgePanel extends BasePanel {
47
48
  private selectedIndex = 0;
48
49
  private scrollOffset = 0;
49
50
  private records: MemoryRecord[] = [];
51
+ // I1: confirm for destructive review-state mutations
52
+ private confirm: ConfirmState<{ id: string; action: 'stale' | 'contradicted' }> | null = null;
50
53
 
51
54
  public constructor(registry: MemoryRegistry) {
52
55
  super('knowledge', 'Knowledge', 'K', 'agent');
@@ -72,6 +75,50 @@ export class KnowledgePanel extends BasePanel {
72
75
  }
73
76
 
74
77
  public handleInput(key: string): boolean {
78
+ // I1: y/n confirm for stale/contradict
79
+ if (this.confirm) {
80
+ const result = handleConfirmInput(this.confirm, key);
81
+ if (result === 'confirmed') {
82
+ const { id, action } = this.confirm.subject;
83
+ this.confirm = null;
84
+ const selected = this.records.find((r) => r.id === id);
85
+ if (selected) {
86
+ try {
87
+ if (action === 'stale') {
88
+ this.registry.review(id, {
89
+ state: 'stale',
90
+ confidence: Math.min(selected.confidence, 40),
91
+ reviewedBy: 'operator',
92
+ staleReason: 'marked stale from the knowledge panel',
93
+ });
94
+ } else {
95
+ this.registry.review(id, {
96
+ state: 'contradicted',
97
+ confidence: 0,
98
+ reviewedBy: 'operator',
99
+ staleReason: 'marked contradicted from the knowledge panel',
100
+ });
101
+ }
102
+ } catch (e) {
103
+ // I2: surface async failure
104
+ this.setError(`Review update failed: ${e instanceof Error ? e.message : String(e)}`);
105
+ }
106
+ }
107
+ this.refresh();
108
+ this.markDirty();
109
+ return true;
110
+ }
111
+ if (result === 'cancelled') {
112
+ this.confirm = null;
113
+ this.markDirty();
114
+ return true;
115
+ }
116
+ if (result === 'absorbed') return true;
117
+ }
118
+
119
+ // I2: auto-clear error on next keypress
120
+ if (this.lastError) this.clearError();
121
+
75
122
  if (this.records.length === 0) return false;
76
123
  if (key === 'ArrowUp' || key === 'k') {
77
124
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
@@ -98,24 +145,14 @@ export class KnowledgePanel extends BasePanel {
98
145
  return true;
99
146
  }
100
147
  if (key === 's') {
101
- this.registry.review(selected.id, {
102
- state: 'stale',
103
- confidence: Math.min(selected.confidence, 40),
104
- reviewedBy: 'operator',
105
- staleReason: 'marked stale from the knowledge panel',
106
- });
107
- this.refresh();
148
+ // I1: prompt confirm before marking stale
149
+ this.confirm = { subject: { id: selected.id, action: 'stale' }, label: selected.summary.slice(0, 40) };
108
150
  this.markDirty();
109
151
  return true;
110
152
  }
111
153
  if (key === 'c') {
112
- this.registry.review(selected.id, {
113
- state: 'contradicted',
114
- confidence: 0,
115
- reviewedBy: 'operator',
116
- staleReason: 'marked contradicted from the knowledge panel',
117
- });
118
- this.refresh();
154
+ // I1: prompt confirm before marking contradicted
155
+ this.confirm = { subject: { id: selected.id, action: 'contradicted' }, label: selected.summary.slice(0, 40) };
119
156
  this.markDirty();
120
157
  return true;
121
158
  }
@@ -141,6 +178,18 @@ export class KnowledgePanel extends BasePanel {
141
178
 
142
179
  public render(width: number, height: number): Line[] {
143
180
  this.needsRender = false;
181
+
182
+ // I1: show confirm dialog in place of normal content
183
+ if (this.confirm) {
184
+ return buildPanelWorkspace(width, height, {
185
+ title: 'Knowledge Control Room',
186
+ intro: '',
187
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
188
+ footerLines: [buildPanelLine(width, [[' y confirm n / Esc cancel', C.dim]])],
189
+ palette: C,
190
+ });
191
+ }
192
+
144
193
  if (this.records.length === 0) this.refresh();
145
194
 
146
195
  const intro = 'Typed project knowledge, reviewed evidence, and operator-governed memory across session, project, and team scopes.';
@@ -81,18 +81,25 @@ export class MarketplacePanel extends BasePanel {
81
81
  this.scrollOffset = 0;
82
82
  return;
83
83
  }
84
- const installedPlugins = new Set(listInstalledEcosystemEntries('plugin', this.ecosystemPaths).map((receipt) => receipt.entry.id));
85
- const installedSkills = new Set(listInstalledEcosystemEntries('skill', this.ecosystemPaths).map((receipt) => receipt.entry.id));
86
- const installedHookPacks = new Set(listInstalledEcosystemEntries('hook-pack', this.ecosystemPaths).map((receipt) => receipt.entry.id));
87
- const installedPolicyPacks = new Set(listInstalledEcosystemEntries('policy-pack', this.ecosystemPaths).map((receipt) => receipt.entry.id));
88
- const rows: MarketplaceRow[] = [
89
- ...loadEcosystemCatalog('plugin', this.ecosystemPaths).map((entry) => ({ kind: 'plugin' as const, entry, installed: installedPlugins.has(entry.id) })),
90
- ...loadEcosystemCatalog('skill', this.ecosystemPaths).map((entry) => ({ kind: 'skill' as const, entry, installed: installedSkills.has(entry.id) })),
91
- ...loadEcosystemCatalog('hook-pack', this.ecosystemPaths).map((entry) => ({ kind: 'hook-pack' as const, entry, installed: installedHookPacks.has(entry.id) })),
92
- ...loadEcosystemCatalog('policy-pack', this.ecosystemPaths).map((entry) => ({ kind: 'policy-pack' as const, entry, installed: installedPolicyPacks.has(entry.id) })),
93
- ];
94
- this.rows = rows.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
95
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
84
+ try {
85
+ const installedPlugins = new Set(listInstalledEcosystemEntries('plugin', this.ecosystemPaths).map((receipt) => receipt.entry.id));
86
+ const installedSkills = new Set(listInstalledEcosystemEntries('skill', this.ecosystemPaths).map((receipt) => receipt.entry.id));
87
+ const installedHookPacks = new Set(listInstalledEcosystemEntries('hook-pack', this.ecosystemPaths).map((receipt) => receipt.entry.id));
88
+ const installedPolicyPacks = new Set(listInstalledEcosystemEntries('policy-pack', this.ecosystemPaths).map((receipt) => receipt.entry.id));
89
+ const rows: MarketplaceRow[] = [
90
+ ...loadEcosystemCatalog('plugin', this.ecosystemPaths).map((entry) => ({ kind: 'plugin' as const, entry, installed: installedPlugins.has(entry.id) })),
91
+ ...loadEcosystemCatalog('skill', this.ecosystemPaths).map((entry) => ({ kind: 'skill' as const, entry, installed: installedSkills.has(entry.id) })),
92
+ ...loadEcosystemCatalog('hook-pack', this.ecosystemPaths).map((entry) => ({ kind: 'hook-pack' as const, entry, installed: installedHookPacks.has(entry.id) })),
93
+ ...loadEcosystemCatalog('policy-pack', this.ecosystemPaths).map((entry) => ({ kind: 'policy-pack' as const, entry, installed: installedPolicyPacks.has(entry.id) })),
94
+ ];
95
+ this.rows = rows.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
96
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
97
+ // I2: clear any previous catalog load error on successful refresh
98
+ this.clearError();
99
+ } catch (e) {
100
+ // I2: surface catalog load failure
101
+ this.setError(`Catalog load failed: ${e instanceof Error ? e.message : String(e)}`);
102
+ }
96
103
  }
97
104
 
98
105
  public render(width: number, height: number): Line[] {
@@ -115,6 +115,9 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
115
115
  // -------------------------------------------------------------------------
116
116
 
117
117
  handleInput(key: string): boolean {
118
+ // I2: auto-clear error on next keypress
119
+ if (this.lastError) this.clearError();
120
+
118
121
  const items = this.getItems();
119
122
  const total = items.length;
120
123
 
@@ -205,6 +208,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
205
208
  * @param options.footer Lines appended as the last workspace section.
206
209
  * @param options.emptyMessage Override for the empty-state title text.
207
210
  * @param options.title Workspace title (defaults to `this.name`).
211
+ * @param options.spinnerFrame Animation frame for the loading spinner.
208
212
  */
209
213
  protected renderList(
210
214
  width: number,
@@ -214,6 +218,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
214
218
  readonly footer?: readonly Line[];
215
219
  readonly emptyMessage?: string;
216
220
  readonly title?: string;
221
+ readonly spinnerFrame?: number;
217
222
  } = {},
218
223
  ): Line[] {
219
224
  this.needsRender = false;
@@ -221,6 +226,25 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
221
226
  const items = this.getItems();
222
227
  const title = options.title ?? this.name;
223
228
 
229
+ // I2: inject error line into footer when present
230
+ const errorLine = this.renderErrorLine(width);
231
+ const baseFooter = options.footer ? [...options.footer as Line[]] : [];
232
+ const effectiveFooter: Line[] = errorLine ? [errorLine, ...baseFooter] : baseFooter;
233
+
234
+ // I3: if loading, show spinner in place of normal content
235
+ const spinnerLine = this.renderLoadingLine(width, options.spinnerFrame ?? 0);
236
+ if (spinnerLine) {
237
+ const loadingSection = { lines: [spinnerLine] };
238
+ const headerSection = options.header ? [{ lines: options.header as Line[] }] : [];
239
+ const lines = buildPanelWorkspace(width, height, {
240
+ title,
241
+ sections: [...headerSection, loadingSection],
242
+ palette,
243
+ });
244
+ while (lines.length < height) lines.push(createEmptyLine(width));
245
+ return lines.slice(0, height);
246
+ }
247
+
224
248
  // Build all item lines (pre-render for resolveScrollablePanelSection)
225
249
  const scrollableLines: Line[] = items.map((item, index) =>
226
250
  this.renderItem(item, index, index === this.selectedIndex, width),
@@ -240,7 +264,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
240
264
  sections: [
241
265
  ...(options.header ? [{ lines: options.header as Line[] }] : []),
242
266
  { lines: emptyLines },
243
- ...(options.footer ? [{ lines: options.footer as Line[] }] : []),
267
+ ...(effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : []),
244
268
  ],
245
269
  palette,
246
270
  });
@@ -250,7 +274,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
250
274
 
251
275
  // Resolve scrollable section (updates scrollStart)
252
276
  const beforeSections = options.header ? [{ lines: options.header as Line[] }] : [];
253
- const afterSections = options.footer ? [{ lines: options.footer as Line[] }] : [];
277
+ const afterSections = effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : [];
254
278
 
255
279
  const resolved = resolveScrollablePanelSection(width, height, {
256
280
  palette,
@@ -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,6 +1,7 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { BasePanel } from './base-panel.ts';
4
+ import { handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
4
5
  import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
5
6
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
6
7
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
@@ -95,7 +96,12 @@ export class SubscriptionPanel extends BasePanel {
95
96
  }
96
97
  if (key === 'enter' || key === 'x') {
97
98
  if (!selected?.subscription) return false;
98
- if (this.logoutConfirmationTarget !== selected.provider) {
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
+ if (this.logoutConfirmationTarget === null || this.logoutConfirmationTarget !== selected.provider) {
99
105
  this.logoutConfirmationTarget = selected.provider;
100
106
  this.markDirty();
101
107
  return true;
@@ -106,6 +112,12 @@ export class SubscriptionPanel extends BasePanel {
106
112
  this.markDirty();
107
113
  return true;
108
114
  }
115
+ // Allow n/Esc to cancel pending logout confirm
116
+ if ((key === 'n' || key === 'escape') && this.logoutConfirmationTarget) {
117
+ this.logoutConfirmationTarget = null;
118
+ this.markDirty();
119
+ return true;
120
+ }
109
121
  if (key === 'r') {
110
122
  this.refresh();
111
123
  this.logoutConfirmationTarget = null;
@@ -0,0 +1,71 @@
1
+ // ---------------------------------------------------------------------------
2
+ // buildStatusToken — always glyph + color, never color-only.
3
+ //
4
+ // Maps a semantic state to a Unicode glyph AND a palette color so that
5
+ // colorblind users and screen readers can distinguish states without color.
6
+ //
7
+ // Glyphs:
8
+ // good ✓ (CHECK MARK)
9
+ // warn ⚠ (WARNING SIGN)
10
+ // bad ✕ (MULTIPLICATION X)
11
+ // info ○ (WHITE CIRCLE)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import type { Cell } from '../types/grid.ts';
15
+ import { DEFAULT_PANEL_PALETTE } from '../panels/polish.ts';
16
+
17
+ export type StatusState = 'good' | 'warn' | 'bad' | 'info';
18
+
19
+ const STATE_GLYPHS: Record<StatusState, string> = {
20
+ good: '\u2713', // ✓
21
+ warn: '\u26a0', // ⚠
22
+ bad: '\u2715', // ✕
23
+ info: '\u25cb', // ○
24
+ };
25
+
26
+ const STATE_COLORS: Record<StatusState, string> = {
27
+ good: DEFAULT_PANEL_PALETTE.good,
28
+ warn: DEFAULT_PANEL_PALETTE.warn,
29
+ bad: DEFAULT_PANEL_PALETTE.bad,
30
+ info: DEFAULT_PANEL_PALETTE.info,
31
+ };
32
+
33
+ export interface StatusTokenOpts {
34
+ /** Append a numeric count after the label, e.g. "label (3)". */
35
+ count?: number;
36
+ /** Override the default glyph for this state. */
37
+ glyph?: string;
38
+ }
39
+
40
+ /**
41
+ * Build a small sequence of styled cells: [glyph, space, label, optional count]
42
+ *
43
+ * Always prepends the glyph so colorblind users can parse the state.
44
+ * The color is applied to both glyph and label as a redundant cue.
45
+ *
46
+ * @param state Semantic state — controls default glyph and color.
47
+ * @param label Human-readable label string.
48
+ * @param opts Optional count suffix or glyph override.
49
+ * @returns Array of Cell objects ready to embed in a Line.
50
+ */
51
+ export function buildStatusToken(
52
+ state: StatusState,
53
+ label: string,
54
+ opts?: StatusTokenOpts,
55
+ ): Cell[] {
56
+ const glyph = opts?.glyph ?? STATE_GLYPHS[state];
57
+ const color = STATE_COLORS[state];
58
+ const suffix = opts?.count !== undefined ? ` (${opts.count})` : '';
59
+ const text = `${glyph} ${label}${suffix}`;
60
+
61
+ return text.split('').map((char): Cell => ({
62
+ char,
63
+ fg: color,
64
+ bg: '',
65
+ bold: false,
66
+ dim: false,
67
+ underline: false,
68
+ italic: false,
69
+ strikethrough: false,
70
+ }));
71
+ }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.18.19';
9
+ let _version = '0.18.20';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;