@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,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.';
@@ -1,6 +1,6 @@
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 {
5
5
  buildDetailBlock,
6
6
  buildGuidanceLine,
@@ -9,8 +9,7 @@ import {
9
9
  buildSummaryBlock,
10
10
  buildPanelWorkspace,
11
11
  DEFAULT_PANEL_PALETTE,
12
- resolvePrimaryScrollableSection,
13
- type PanelWorkspaceSection,
12
+ type PanelPalette,
14
13
  } from './polish.ts';
15
14
  import type { LocalAuthSnapshot } from '@pellux/goodvibes-sdk/platform/security/user-auth';
16
15
  import type { LocalAuthInspectionQuery } from '../runtime/ui-service-queries.ts';
@@ -27,9 +26,9 @@ function formatRoles(roles: readonly string[]): string {
27
26
  return roles.length > 0 ? roles.join(', ') : '(none)';
28
27
  }
29
28
 
30
- export class LocalAuthPanel extends BasePanel {
31
- private selectedIndex = 0;
32
- private scrollOffset = 0;
29
+ type LocalAuthUser = LocalAuthSnapshot['users'][number];
30
+
31
+ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
33
32
  private readonly authManager: LocalAuthInspectionQuery;
34
33
 
35
34
  public constructor(authManager: LocalAuthInspectionQuery) {
@@ -37,110 +36,94 @@ export class LocalAuthPanel extends BasePanel {
37
36
  this.authManager = authManager;
38
37
  }
39
38
 
40
- public handleInput(key: string): boolean {
41
- const users = this.authManager.inspect().users;
42
- if (users.length === 0) return false;
43
- if (key === 'up' || key === 'k') {
44
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
45
- this.markDirty();
46
- return true;
47
- }
48
- if (key === 'down' || key === 'j') {
49
- this.selectedIndex = Math.min(users.length - 1, this.selectedIndex + 1);
50
- this.markDirty();
51
- return true;
52
- }
53
- return false;
39
+ protected override getPalette(): PanelPalette {
40
+ return C;
41
+ }
42
+
43
+ protected getItems(): readonly LocalAuthUser[] {
44
+ return this.authManager.inspect().users;
45
+ }
46
+
47
+ protected renderItem(user: LocalAuthUser, _index: number, selected: boolean, width: number): Line {
48
+ return buildPanelListRow(width, [
49
+ { text: user.username.padEnd(20), fg: C.value },
50
+ { text: ` roles=${formatRoles(user.roles)}`.slice(0, Math.max(0, width - 24)), fg: C.info },
51
+ ], C, { selected });
52
+ }
53
+
54
+ protected override getEmptyStateMessage(): string {
55
+ return ' No local auth users configured.';
54
56
  }
55
57
 
56
58
  public render(width: number, height: number): Line[] {
57
- this.needsRender = false;
58
59
  const intro = 'Manage local daemon and HTTP-listener auth users, bootstrap state, and active sessions.';
59
- const footerLines = [buildPanelLine(width, [[' /auth local review /auth local add-user /auth local rotate-password /auth local revoke-session ', C.dim]])];
60
60
  const snapshot = this.authManager.inspect();
61
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, snapshot.users.length - 1));
62
- const selected = snapshot.users[this.selectedIndex];
61
+ const users = this.getItems();
62
+
63
63
  const issueMessages: string[] = [];
64
64
  if (snapshot.bootstrapCredentialPresent) issueMessages.push('Bootstrap credential file still exists and should be cleared after password rotation.');
65
65
  if (snapshot.userCount <= 1) issueMessages.push('Only one local auth user is configured.');
66
66
  if (snapshot.sessionCount === 0) issueMessages.push('No active local auth sessions are currently tracked.');
67
- const sections: PanelWorkspaceSection[] = [
68
- {
69
- lines: buildSummaryBlock(width, 'Local auth posture', [
70
- buildPanelLine(width, [
71
- [' users ', C.label],
72
- [String(snapshot.userCount), C.value],
73
- [' sessions ', C.label],
74
- [String(snapshot.sessionCount), snapshot.sessionCount > 0 ? C.info : C.dim],
75
- [' bootstrap ', C.label],
76
- [snapshot.bootstrapCredentialPresent ? 'present' : 'cleared', snapshot.bootstrapCredentialPresent ? C.warn : C.good],
77
- ]),
78
- buildPanelLine(width, [[' user store ', C.label], [snapshot.userStorePath.slice(0, Math.max(0, width - 13)), C.dim]]),
79
- buildPanelLine(width, [[' bootstrap file ', C.label], [snapshot.bootstrapCredentialPath.slice(0, Math.max(0, width - 18)), C.dim]]),
80
- ...(issueMessages.length > 0
81
- ? issueMessages.map((issue) => buildPanelLine(width, [[` issue: ${issue}`.slice(0, Math.max(0, width)), C.warn]]))
82
- : [buildPanelLine(width, [[' local auth posture looks healthy.', C.good]])]),
83
- buildGuidanceLine(width, '/auth local rotate-password <user> <password>', 'rotate bootstrap/default credentials and revoke older sessions as needed', C),
84
- ], C),
85
- },
67
+
68
+ const headerLines: Line[] = [
69
+ ...buildSummaryBlock(width, 'Local auth posture', [
70
+ buildPanelLine(width, [
71
+ [' users ', C.label],
72
+ [String(snapshot.userCount), C.value],
73
+ [' sessions ', C.label],
74
+ [String(snapshot.sessionCount), snapshot.sessionCount > 0 ? C.info : C.dim],
75
+ [' bootstrap ', C.label],
76
+ [snapshot.bootstrapCredentialPresent ? 'present' : 'cleared', snapshot.bootstrapCredentialPresent ? C.warn : C.good],
77
+ ]),
78
+ buildPanelLine(width, [[' user store ', C.label], [snapshot.userStorePath.slice(0, Math.max(0, width - 13)), C.dim]]),
79
+ buildPanelLine(width, [[' bootstrap file ', C.label], [snapshot.bootstrapCredentialPath.slice(0, Math.max(0, width - 18)), C.dim]]),
80
+ ...(issueMessages.length > 0
81
+ ? issueMessages.map((issue) => buildPanelLine(width, [[` issue: ${issue}`.slice(0, Math.max(0, width)), C.warn]]))
82
+ : [buildPanelLine(width, [[' local auth posture looks healthy.', C.good]])]),
83
+ buildGuidanceLine(width, '/auth local rotate-password <user> <password>', 'rotate bootstrap/default credentials and revoke older sessions as needed', C),
84
+ ], C),
86
85
  ];
87
86
 
88
- if (snapshot.users.length > 0) {
89
- const selectedUserSection: PanelWorkspaceSection | null = selected
90
- ? {
91
- lines: buildDetailBlock(width, 'Selected user', [
92
- buildPanelLine(width, [[' username ', C.label], [selected.username, C.value], [' roles ', C.label], [formatRoles(selected.roles).slice(0, Math.max(0, width - 23)), C.info]]),
93
- buildPanelLine(width, [[` next: /auth local rotate-password ${selected.username} <password>`.slice(0, Math.max(0, width)), C.dim]]),
94
- buildPanelLine(width, [[` next: /auth local delete-user ${selected.username}`.slice(0, Math.max(0, width)), C.dim]]),
95
- ], C),
96
- }
97
- : null;
98
- const activeSessionsSection: PanelWorkspaceSection | null = snapshot.sessions.length > 0
99
- ? {
100
- title: 'Active Sessions',
101
- lines: snapshot.sessions.slice(0, 8).map((session) => buildPanelLine(width, [
102
- [' ', C.label],
103
- [session.username.padEnd(18), C.value],
104
- [` expires ${new Date(session.expiresAt).toLocaleString()}`.slice(0, Math.max(0, width - 20)), C.dim],
105
- ])),
106
- }
107
- : null;
108
- const rawUserLines: Line[] = snapshot.users.map((user, absolute) => {
109
- return buildPanelListRow(width, [
110
- { text: user.username.padEnd(20), fg: C.value },
111
- { text: ` roles=${formatRoles(user.roles)}`.slice(0, Math.max(0, width - 24)), fg: C.info },
112
- ], C, { selected: absolute === this.selectedIndex });
113
- });
114
- const resolvedUsersSection = resolvePrimaryScrollableSection(width, height, {
87
+ if (users.length === 0) {
88
+ const workspace = buildPanelWorkspace(width, height, {
89
+ title: 'Local Auth Control Room',
115
90
  intro,
116
- footerLines,
91
+ sections: [{ lines: headerLines }],
117
92
  palette: C,
118
- beforeSections: sections,
119
- section: {
120
- title: 'Users',
121
- scrollableLines: rawUserLines,
122
- selectedIndex: this.selectedIndex,
123
- scrollOffset: this.scrollOffset,
124
- guardRows: 1,
125
- minRows: 4,
126
- appendWindowSummary: { dimColor: C.dim },
127
- },
128
- afterSections: [selectedUserSection, activeSessionsSection].filter(Boolean) as PanelWorkspaceSection[],
129
93
  });
130
- this.scrollOffset = resolvedUsersSection.scrollOffset;
131
- sections.push(resolvedUsersSection.section);
132
- if (selectedUserSection) sections.push(selectedUserSection);
133
- if (activeSessionsSection) sections.push(activeSessionsSection);
94
+ while (workspace.length < height) workspace.push(createEmptyLine(width));
95
+ return workspace;
96
+ }
97
+
98
+ this.clampSelection();
99
+ const selected = users[this.selectedIndex];
100
+
101
+ const footerLines: Line[] = [];
102
+ if (selected) {
103
+ footerLines.push(
104
+ ...buildDetailBlock(width, 'Selected user', [
105
+ buildPanelLine(width, [[' username ', C.label], [selected.username, C.value], [' roles ', C.label], [formatRoles(selected.roles).slice(0, Math.max(0, width - 23)), C.info]]),
106
+ buildPanelLine(width, [[` next: /auth local rotate-password ${selected.username} <password>`.slice(0, Math.max(0, width)), C.dim]]),
107
+ buildPanelLine(width, [[` next: /auth local delete-user ${selected.username}`.slice(0, Math.max(0, width)), C.dim]]),
108
+ ], C),
109
+ );
110
+ }
111
+
112
+ if (snapshot.sessions.length > 0) {
113
+ footerLines.push(
114
+ ...snapshot.sessions.slice(0, 8).map((session) => buildPanelLine(width, [
115
+ [' ', C.label],
116
+ [session.username.padEnd(18), C.value],
117
+ [` expires ${new Date(session.expiresAt).toLocaleString()}`.slice(0, Math.max(0, width - 20)), C.dim],
118
+ ])),
119
+ );
134
120
  }
121
+ footerLines.push(buildPanelLine(width, [[' /auth local review /auth local add-user /auth local rotate-password /auth local revoke-session ', C.dim]]));
135
122
 
136
- const lines = buildPanelWorkspace(width, height, {
123
+ return this.renderList(width, height, {
137
124
  title: 'Local Auth Control Room',
138
- intro,
139
- sections,
140
- footerLines,
141
- palette: C,
125
+ header: headerLines,
126
+ footer: footerLines,
142
127
  });
143
- while (lines.length < height) lines.push(createEmptyLine(width));
144
- return lines.slice(0, height);
145
128
  }
146
129
  }
@@ -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[] {