@pellux/goodvibes-tui 0.18.23 → 0.19.1

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 (80) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +8 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/daemon/cli.ts +54 -0
  8. package/src/input/commands/diff-runtime.ts +6 -5
  9. package/src/input/commands/guidance-runtime.ts +1 -1
  10. package/src/input/commands/health-runtime.ts +2 -2
  11. package/src/input/commands/local-setup-review.ts +1 -1
  12. package/src/input/commands/session-content.ts +1 -1
  13. package/src/input/commands/shell-core.ts +3 -2
  14. package/src/input/commands/skills-runtime.ts +2 -2
  15. package/src/input/commands/subscription-runtime.ts +4 -4
  16. package/src/input/handler.ts +8 -10
  17. package/src/input/model-picker.ts +6 -2
  18. package/src/input/panel-integration-actions.ts +2 -1
  19. package/src/input/settings-modal-types.ts +60 -0
  20. package/src/input/settings-modal.ts +83 -65
  21. package/src/main.ts +52 -0
  22. package/src/panels/agent-inspector-panel.ts +10 -9
  23. package/src/panels/agent-logs-panel.ts +26 -6
  24. package/src/panels/approval-panel.ts +1 -0
  25. package/src/panels/automation-control-panel.ts +1 -0
  26. package/src/panels/base-panel.ts +108 -3
  27. package/src/panels/communication-panel.ts +1 -0
  28. package/src/panels/context-visualizer-panel.ts +2 -0
  29. package/src/panels/control-plane-panel.ts +1 -0
  30. package/src/panels/diff-panel.ts +2 -0
  31. package/src/panels/file-explorer-panel.ts +51 -31
  32. package/src/panels/file-preview-panel.ts +57 -35
  33. package/src/panels/git-panel.ts +12 -13
  34. package/src/panels/hooks-panel.ts +3 -1
  35. package/src/panels/incident-review-panel.ts +4 -2
  36. package/src/panels/knowledge-panel.ts +75 -107
  37. package/src/panels/local-auth-panel.ts +1 -0
  38. package/src/panels/marketplace-panel.ts +51 -69
  39. package/src/panels/mcp-panel.ts +3 -1
  40. package/src/panels/memory-panel.ts +90 -158
  41. package/src/panels/ops-control-panel.ts +1 -0
  42. package/src/panels/orchestration-panel.ts +70 -51
  43. package/src/panels/panel-list-panel.ts +5 -4
  44. package/src/panels/panel-manager.ts +3 -0
  45. package/src/panels/plan-dashboard-panel.ts +2 -0
  46. package/src/panels/plugins-panel.ts +1 -0
  47. package/src/panels/polish.ts +51 -2
  48. package/src/panels/provider-accounts-panel.ts +1 -0
  49. package/src/panels/provider-health-panel.ts +6 -8
  50. package/src/panels/routes-panel.ts +3 -1
  51. package/src/panels/schedule-panel.ts +7 -6
  52. package/src/panels/scrollable-list-panel.ts +19 -2
  53. package/src/panels/security-panel.ts +17 -15
  54. package/src/panels/services-panel.ts +6 -4
  55. package/src/panels/session-browser-panel.ts +19 -18
  56. package/src/panels/settings-sync-panel.ts +3 -1
  57. package/src/panels/skills-panel.ts +114 -230
  58. package/src/panels/subscription-panel.ts +1 -0
  59. package/src/panels/system-messages-panel.ts +147 -141
  60. package/src/panels/tasks-panel.ts +1 -0
  61. package/src/panels/token-budget-panel.ts +2 -0
  62. package/src/panels/watchers-panel.ts +1 -0
  63. package/src/panels/worktree-panel.ts +1 -0
  64. package/src/panels/wrfc-panel.ts +2 -0
  65. package/src/renderer/agent-detail-modal.ts +2 -2
  66. package/src/renderer/ansi-sanitize.ts +76 -0
  67. package/src/renderer/buffer.ts +12 -1
  68. package/src/renderer/help-overlay.ts +14 -3
  69. package/src/renderer/model-picker-overlay.ts +9 -2
  70. package/src/renderer/settings-modal-helpers.ts +27 -0
  71. package/src/renderer/settings-modal.ts +18 -1
  72. package/src/renderer/status-glyphs.ts +21 -0
  73. package/src/renderer/status-token.ts +4 -8
  74. package/src/renderer/tool-call.ts +4 -3
  75. package/src/runtime/bootstrap-core.ts +1 -1
  76. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  77. package/src/runtime/bootstrap.ts +7 -8
  78. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  79. package/src/shell/ui-openers.ts +44 -3
  80. package/src/version.ts +1 -1
@@ -2,7 +2,7 @@
2
2
  // FileExplorerPanel — collapsible project tree view
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
- import { readdirSync, statSync } from 'node:fs';
5
+ import { promises as fsPromises } from 'node:fs';
6
6
  import { join, relative, basename } from 'node:path';
7
7
  import type { Line } from '../types/grid.ts';
8
8
  import { createEmptyLine } from '../types/grid.ts';
@@ -120,6 +120,7 @@ export class FileExplorerPanel extends BasePanel {
120
120
  private rootPath: string;
121
121
  private readonly workingDirectory: string;
122
122
  private cacheValid: boolean = false;
123
+ private readyPromise: Promise<void> | null = null;
123
124
 
124
125
  // --- navigation ---
125
126
  private cursor: number = 0;
@@ -139,7 +140,9 @@ export class FileExplorerPanel extends BasePanel {
139
140
 
140
141
  override onActivate(): void {
141
142
  super.onActivate();
142
- if (!this.cacheValid) this._buildTree();
143
+ if (!this.cacheValid) {
144
+ void this._buildTreeAsync();
145
+ }
143
146
  }
144
147
 
145
148
  override onDestroy(): void {
@@ -153,8 +156,7 @@ export class FileExplorerPanel extends BasePanel {
153
156
  /** Force a full tree refresh from disk. */
154
157
  refresh(): void {
155
158
  this.cacheValid = false;
156
- this._buildTree();
157
- this.markDirty();
159
+ void this._buildTreeAsync();
158
160
  }
159
161
 
160
162
  /** Currently focused node (or null). */
@@ -197,7 +199,6 @@ export class FileExplorerPanel extends BasePanel {
197
199
  // ── Render ─────────────────────────────────────────────────────────────────
198
200
 
199
201
  render(width: number, height: number): Line[] {
200
- if (!this.cacheValid) this._buildTree();
201
202
  this.needsRender = false;
202
203
  const searchLine = this.searchMode
203
204
  ? `/ ${this.searchQuery}_`
@@ -324,14 +325,29 @@ export class FileExplorerPanel extends BasePanel {
324
325
 
325
326
  // ── Private: tree building ─────────────────────────────────────────────────
326
327
 
327
- private _buildTree(): void {
328
- this.root = this._scanDir(this.rootPath, 0);
329
- this._rebuildFlat();
330
- this.cacheValid = true;
331
- this.markDirty();
328
+ private _buildTreeAsync(): Promise<void> {
329
+ const p = (async () => {
330
+ try {
331
+ await this.withLoading('Scanning directory\u2026', async () => {
332
+ this.root = await this._scanDirAsync(this.rootPath, 0);
333
+ this._rebuildFlat();
334
+ this.cacheValid = true;
335
+ });
336
+ } catch (err) {
337
+ this.setError(err instanceof Error ? err.message : String(err));
338
+ }
339
+ this.markDirty();
340
+ })();
341
+ this.readyPromise = p;
342
+ return p;
343
+ }
344
+
345
+ /** Resolves when the current tree build has settled. */
346
+ public awaitReady(): Promise<void> {
347
+ return this.readyPromise ?? Promise.resolve();
332
348
  }
333
349
 
334
- private _scanDir(dirPath: string, depth: number): TreeNode {
350
+ private async _scanDirAsync(dirPath: string, depth: number): Promise<TreeNode> {
335
351
  const name = basename(dirPath);
336
352
  const node: TreeNode = {
337
353
  path: dirPath,
@@ -339,7 +355,7 @@ export class FileExplorerPanel extends BasePanel {
339
355
  isDir: true,
340
356
  depth,
341
357
  size: 0,
342
- expanded: depth === 0, // root starts expanded
358
+ expanded: depth === 0,
343
359
  children: [],
344
360
  loaded: false,
345
361
  };
@@ -348,7 +364,7 @@ export class FileExplorerPanel extends BasePanel {
348
364
 
349
365
  let entries: string[];
350
366
  try {
351
- entries = readdirSync(dirPath);
367
+ entries = await fsPromises.readdir(dirPath);
352
368
  } catch {
353
369
  return node;
354
370
  }
@@ -356,31 +372,35 @@ export class FileExplorerPanel extends BasePanel {
356
372
  node.loaded = true;
357
373
 
358
374
  // Sort: dirs first, then files, alphabetically within each group
359
- const sorted = entries
360
- .filter(e => !shouldSkip(e))
361
- .sort((a, b) => {
362
- let aIsDir = false;
363
- let bIsDir = false;
364
- try { aIsDir = statSync(join(dirPath, a)).isDirectory(); } catch { /* ignore */ }
365
- try { bIsDir = statSync(join(dirPath, b)).isDirectory(); } catch { /* ignore */ }
366
- if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
367
- return a.localeCompare(b);
368
- });
375
+ const filtered = entries.filter(e => !shouldSkip(e));
376
+ const statResults = await Promise.all(
377
+ filtered.map(async (e) => {
378
+ try {
379
+ const s = await fsPromises.stat(join(dirPath, e));
380
+ return { name: e, isDir: s.isDirectory(), size: s.size, stat: s };
381
+ } catch {
382
+ return { name: e, isDir: false, size: 0, stat: null };
383
+ }
384
+ }),
385
+ );
386
+
387
+ const sorted = statResults.sort((a, b) => {
388
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
389
+ return a.name.localeCompare(b.name);
390
+ });
369
391
 
370
392
  for (const entry of sorted) {
371
- const fullPath = join(dirPath, entry);
372
- let stat;
373
- try { stat = statSync(fullPath); } catch { continue; }
374
-
375
- if (stat.isDirectory()) {
376
- node.children.push(this._scanDir(fullPath, depth + 1));
393
+ if (entry.stat === null) continue;
394
+ const fullPath = join(dirPath, entry.name);
395
+ if (entry.isDir) {
396
+ node.children.push(await this._scanDirAsync(fullPath, depth + 1));
377
397
  } else {
378
398
  node.children.push({
379
399
  path: fullPath,
380
- name: entry,
400
+ name: entry.name,
381
401
  isDir: false,
382
402
  depth: depth + 1,
383
- size: stat.size,
403
+ size: entry.size,
384
404
  expanded: false,
385
405
  children: [],
386
406
  loaded: true,
@@ -1,4 +1,5 @@
1
- import * as fs from 'node:fs';
1
+ import type { Stats } from 'node:fs';
2
+ import { promises as fsPromises, readFileSync, statSync } from 'node:fs';
2
3
  import * as path from 'node:path';
3
4
  import type { Line, Cell } from '../types/grid.ts';
4
5
  import { createStyledCell, createEmptyLine } from '../types/grid.ts';
@@ -68,7 +69,7 @@ export class FilePreviewPanel extends BasePanel {
68
69
  // ─── Public API ─────────────────────────────────────────────────────────────
69
70
 
70
71
  /**
71
- * Load a file into the preview. Reads synchronously (small files only).
72
+ * Load a file into the preview. Reads asynchronously.
72
73
  * Files larger than 100 KB show a warning instead of content.
73
74
  */
74
75
  openFile(filePath: string): void {
@@ -79,51 +80,72 @@ export class FilePreviewPanel extends BasePanel {
79
80
 
80
81
  this.filePath = filePath;
81
82
  this.oversized = false;
82
- this.fileLines = [];
83
- this.fenceTag = '';
83
+ this.fenceTag = extToFenceTag(filePath);
84
84
 
85
85
  // Restore scroll position for this file, or start at top
86
86
  this.scrollOffset = this.scrollMemory.get(filePath) ?? 0;
87
87
 
88
- let stat: fs.Stats;
88
+ // Synchronously pre-populate fileLines for small files so that callers
89
+ // (e.g. syncSymbolOutlineFromPreview) can read getSource() immediately.
89
90
  try {
90
- stat = fs.statSync(filePath);
91
+ const stat = statSync(filePath);
92
+ if (stat.size <= MAX_FILE_SIZE) {
93
+ const content = readFileSync(filePath, 'utf-8');
94
+ this.fileLines = content.split('\n');
95
+ } else {
96
+ this.fileLines = [];
97
+ this.oversized = true;
98
+ }
91
99
  } catch {
92
100
  this.fileLines = [`(cannot open: ${filePath})`];
93
- this.markDirty();
94
- return;
95
101
  }
96
102
 
97
- if (stat.size > MAX_FILE_SIZE) {
98
- this.oversized = true;
99
- this.markDirty();
100
- return;
101
- }
103
+ void this._loadFileAsync(filePath);
104
+ }
102
105
 
103
- let content: string;
106
+ private async _loadFileAsync(filePath: string): Promise<void> {
104
107
  try {
105
- content = fs.readFileSync(filePath, 'utf-8');
106
- } catch {
107
- this.fileLines = [`(read error: ${filePath})`];
108
- this.markDirty();
109
- return;
110
- }
111
-
112
- this.fileLines = content.split('\n');
113
- // Strip trailing empty line from final newline
114
- if (this.fileLines.length > 0 && this.fileLines[this.fileLines.length - 1] === '') {
115
- this.fileLines.pop();
116
- }
117
-
118
- this.fenceTag = extToFenceTag(filePath);
119
-
120
- // Kick off async tree-sitter parse so subsequent renders get highlighting
121
- if (this.fenceTag) {
122
- this.syntaxHighlighter.highlight(content, this.fenceTag);
108
+ await this.withLoading('Loading…', async () => {
109
+ let stat: Stats;
110
+ try {
111
+ stat = await fsPromises.stat(filePath);
112
+ } catch {
113
+ this.fileLines = [`(cannot open: ${filePath})`];
114
+ return;
115
+ }
116
+
117
+ if (stat.size > MAX_FILE_SIZE) {
118
+ this.oversized = true;
119
+ return;
120
+ }
121
+
122
+ let content: string;
123
+ try {
124
+ content = await fsPromises.readFile(filePath, 'utf-8');
125
+ } catch {
126
+ this.fileLines = [`(read error: ${filePath})`];
127
+ return;
128
+ }
129
+
130
+ this.fileLines = content.split('\n');
131
+ // Strip trailing empty line from final newline
132
+ if (this.fileLines.length > 0 && this.fileLines[this.fileLines.length - 1] === '') {
133
+ this.fileLines.pop();
134
+ }
135
+
136
+ this.fenceTag = extToFenceTag(filePath);
137
+
138
+ // Kick off async tree-sitter parse so subsequent renders get highlighting
139
+ if (this.fenceTag) {
140
+ this.syntaxHighlighter.highlight(content, this.fenceTag);
141
+ }
142
+
143
+ // Clamp scroll in case the new file is shorter
144
+ this.clampScroll(0);
145
+ });
146
+ } catch (err) {
147
+ this.setError(err instanceof Error ? err.message : String(err));
123
148
  }
124
-
125
- // Clamp scroll in case the new file is shorter
126
- this.clampScroll(0);
127
149
  this.markDirty();
128
150
  }
129
151
 
@@ -103,7 +103,7 @@ export class GitPanel extends BasePanel {
103
103
  /** Scroll offset for both main view and diff view. */
104
104
  private scrollOffset = 0;
105
105
 
106
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
106
+ private refreshTimerId: ReturnType<typeof setInterval> | null = null;
107
107
  private loading = true;
108
108
  private error: string | null = null;
109
109
 
@@ -119,20 +119,21 @@ export class GitPanel extends BasePanel {
119
119
  override onActivate(): void {
120
120
  super.onActivate();
121
121
  void this.refresh();
122
- this.refreshTimer = setInterval(() => {
122
+ this.refreshTimerId = this.registerTimer(setInterval(() => {
123
123
  void this.refresh();
124
- }, 5_000);
124
+ }, 5_000));
125
125
  }
126
126
 
127
127
  override onDeactivate(): void {
128
- if (this.refreshTimer !== null) {
129
- clearInterval(this.refreshTimer);
130
- this.refreshTimer = null;
128
+ if (this.refreshTimerId !== null) {
129
+ this.clearTimer(this.refreshTimerId);
130
+ this.refreshTimerId = null;
131
131
  }
132
132
  }
133
133
 
134
134
  override onDestroy(): void {
135
135
  this.onDeactivate();
136
+ super.onDestroy();
136
137
  }
137
138
 
138
139
  // ---------------------------------------------------------------------------
@@ -326,18 +327,16 @@ export class GitPanel extends BasePanel {
326
327
  const item = this.items[this.selectedIndex];
327
328
  if (!item || item.kind !== 'file') return;
328
329
 
329
- // I3: show base-class spinner while awaiting diff
330
- this.startLoading('Loading diff...');
331
- this.markDirty();
330
+ // I3: withLoading guarantees spinner is cleared even if diffFile throws
332
331
  try {
333
- const git = new GitService(this.workingDirectory);
334
- const raw = await git.diffFile(item.entry.path, item.entry.staged);
335
- this.stopLoading();
332
+ const raw = await this.withLoading('Loading diff…', async () => {
333
+ const git = new GitService(this.workingDirectory);
334
+ return git.diffFile(item.entry.path, item.entry.staged);
335
+ });
336
336
  this.expandedDiff = raw ? raw.split('\n') : ['(no diff available)'];
337
337
  this.scrollOffset = 0;
338
338
  this.markDirty();
339
339
  } catch (err) {
340
- this.stopLoading();
341
340
  this.expandedDiff = [`Error: ${summarizeError(err)}`];
342
341
  this.scrollOffset = 0;
343
342
  this.markDirty();
@@ -10,6 +10,7 @@ import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks/workben
10
10
  import { truncateDisplay } from '../utils/terminal-width.ts';
11
11
  import {
12
12
  buildPanelLine,
13
+ buildStatusPill,
13
14
  DEFAULT_PANEL_PALETTE,
14
15
  } from './polish.ts';
15
16
 
@@ -66,6 +67,7 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
66
67
  dataSource: HooksPanelDataSource = createDefaultDataSource(hookDispatcher, hookWorkbench, hookActivityTracker),
67
68
  ) {
68
69
  super('hooks', 'Hooks', 'H', 'monitoring');
70
+ this.showSelectionGutter = true; // I5: non-color selection affordance
69
71
  this.dataSource = dataSource;
70
72
  }
71
73
 
@@ -88,7 +90,7 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
88
90
  [' ', C.label, bg],
89
91
  [truncateDisplay(entry.hook.name ?? '(unnamed)', 20).padEnd(20), C.value, bg],
90
92
  [` ${truncateDisplay(entry.pattern, 28).padEnd(28)}`, C.info, bg],
91
- [` ${(entry.hook.enabled === false ? 'DISABLED' : 'ENABLED').padEnd(8)}`, entry.hook.enabled === false ? C.warn : C.ok, bg],
93
+ ...buildStatusPill(entry.hook.enabled === false ? 'warn' : 'good', ` ${(entry.hook.enabled === false ? 'DISABLED' : 'ENABLED').padEnd(8)}`, { bg }),
92
94
  [` ${entry.hook.type}`, C.dim, bg],
93
95
  ]);
94
96
  }
@@ -8,6 +8,7 @@ import {
8
8
  buildKeyValueLine,
9
9
  buildPanelLine,
10
10
  buildPanelWorkspace,
11
+ buildStatusPill,
11
12
  DEFAULT_PANEL_PALETTE,
12
13
  type PanelPalette,
13
14
  } from './polish.ts';
@@ -40,6 +41,7 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
40
41
 
41
42
  public constructor(registry?: ForensicsRegistry) {
42
43
  super('incident', 'Incident Review', 'N', 'monitoring');
44
+ this.showSelectionGutter = true; // I5: non-color selection affordance
43
45
  this.registry = registry;
44
46
  this.unsub = registry ? registry.subscribe(() => this.markDirty()) : null;
45
47
  }
@@ -137,7 +139,7 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
137
139
  if (bundle.evidence.slowPhases.length > 0) {
138
140
  footerLines.push(buildPanelLine(width, [
139
141
  [' Slow phases: ', C.label],
140
- [bundle.evidence.slowPhases.join(', ').slice(0, Math.max(0, width - 15)), C.warn],
142
+ ...buildStatusPill('warn', bundle.evidence.slowPhases.join(', ').slice(0, Math.max(0, width - 15))),
141
143
  ]));
142
144
  }
143
145
  const rootCause = selected.causalChain.find((entry) => entry.isRootCause);
@@ -166,7 +168,7 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
166
168
  : `Replay link: ${mismatch.kind}${mismatch.ownerDomain ? `/${mismatch.ownerDomain}` : ''} - ${mismatch.description}`;
167
169
  footerLines.push(buildPanelLine(width, [
168
170
  [' ', C.label],
169
- [replayDetail.slice(0, Math.max(0, width - 2)), C.bad],
171
+ ...buildStatusPill('bad', replayDetail.slice(0, Math.max(0, width - 2))),
170
172
  ]));
171
173
  } else {
172
174
  const ownerBreakdown = Object.entries(bundle.replay.mismatchBreakdown.byOwnerDomain)