@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 +50 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/input/handler-feed-routes.ts +10 -0
- package/src/panels/approval-panel.ts +13 -5
- package/src/panels/base-panel.ts +71 -0
- package/src/panels/confirm-state.ts +61 -0
- package/src/panels/git-panel.ts +9 -0
- package/src/panels/knowledge-panel.ts +63 -14
- package/src/panels/marketplace-panel.ts +19 -12
- package/src/panels/scrollable-list-panel.ts +26 -2
- package/src/panels/skills-panel.ts +44 -0
- package/src/panels/subscription-panel.ts +13 -1
- package/src/renderer/status-token.ts +71 -0
- package/src/version.ts +1 -1
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
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](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.
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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]])];
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/panels/git-panel.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
...(
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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;
|