@pellux/goodvibes-tui 0.18.20 → 0.19.0

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 (83) hide show
  1. package/CHANGELOG.md +154 -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 +22 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/session.ts +0 -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/feed-context-factory.ts +236 -0
  17. package/src/input/handler-feed.ts +44 -6
  18. package/src/input/handler-shortcuts.ts +138 -125
  19. package/src/input/handler.ts +119 -119
  20. package/src/input/keybindings.ts +30 -0
  21. package/src/input/panel-integration-actions.ts +2 -1
  22. package/src/input/settings-modal-types.ts +60 -0
  23. package/src/input/settings-modal.ts +83 -65
  24. package/src/panels/agent-inspector-panel.ts +10 -9
  25. package/src/panels/agent-logs-panel.ts +26 -6
  26. package/src/panels/approval-panel.ts +55 -82
  27. package/src/panels/automation-control-panel.ts +120 -161
  28. package/src/panels/base-panel.ts +108 -3
  29. package/src/panels/communication-panel.ts +69 -107
  30. package/src/panels/context-visualizer-panel.ts +2 -0
  31. package/src/panels/control-plane-panel.ts +117 -172
  32. package/src/panels/diff-panel.ts +2 -0
  33. package/src/panels/file-explorer-panel.ts +51 -31
  34. package/src/panels/file-preview-panel.ts +57 -35
  35. package/src/panels/git-panel.ts +12 -13
  36. package/src/panels/hooks-panel.ts +103 -138
  37. package/src/panels/incident-review-panel.ts +59 -109
  38. package/src/panels/knowledge-panel.ts +75 -107
  39. package/src/panels/local-auth-panel.ts +77 -93
  40. package/src/panels/marketplace-panel.ts +51 -69
  41. package/src/panels/mcp-panel.ts +110 -155
  42. package/src/panels/memory-panel.ts +90 -158
  43. package/src/panels/ops-control-panel.ts +51 -85
  44. package/src/panels/orchestration-panel.ts +70 -51
  45. package/src/panels/panel-list-panel.ts +5 -4
  46. package/src/panels/panel-manager.ts +25 -2
  47. package/src/panels/plan-dashboard-panel.ts +2 -0
  48. package/src/panels/plugins-panel.ts +37 -60
  49. package/src/panels/polish.ts +51 -2
  50. package/src/panels/provider-accounts-panel.ts +1 -0
  51. package/src/panels/provider-health-panel.ts +6 -8
  52. package/src/panels/routes-panel.ts +91 -141
  53. package/src/panels/schedule-panel.ts +7 -6
  54. package/src/panels/scrollable-list-panel.ts +64 -16
  55. package/src/panels/security-panel.ts +118 -152
  56. package/src/panels/services-panel.ts +63 -105
  57. package/src/panels/session-browser-panel.ts +19 -18
  58. package/src/panels/settings-sync-panel.ts +79 -123
  59. package/src/panels/skills-panel.ts +114 -230
  60. package/src/panels/subscription-panel.ts +64 -86
  61. package/src/panels/system-messages-panel.ts +147 -141
  62. package/src/panels/tasks-panel.ts +130 -179
  63. package/src/panels/token-budget-panel.ts +2 -0
  64. package/src/panels/watchers-panel.ts +89 -137
  65. package/src/panels/worktree-panel.ts +1 -0
  66. package/src/panels/wrfc-panel.ts +2 -0
  67. package/src/renderer/agent-detail-modal.ts +2 -2
  68. package/src/renderer/ansi-sanitize.ts +76 -0
  69. package/src/renderer/buffer.ts +23 -1
  70. package/src/renderer/diff.ts +8 -0
  71. package/src/renderer/help-overlay.ts +48 -28
  72. package/src/renderer/markdown.ts +3 -145
  73. package/src/renderer/settings-modal-helpers.ts +27 -0
  74. package/src/renderer/settings-modal.ts +18 -1
  75. package/src/renderer/status-glyphs.ts +21 -0
  76. package/src/renderer/status-token.ts +4 -8
  77. package/src/renderer/tool-call.ts +4 -3
  78. package/src/runtime/bootstrap-core.ts +1 -1
  79. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  80. package/src/runtime/bootstrap.ts +7 -8
  81. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  82. package/src/shell/ui-openers.ts +1 -1
  83. package/src/version.ts +1 -1
@@ -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 type { UiControlPlaneSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
6
  import {
@@ -10,8 +10,7 @@ import {
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
12
  DEFAULT_PANEL_PALETTE,
13
- resolvePrimaryScrollableSection,
14
- type PanelWorkspaceSection,
13
+ type PanelPalette,
15
14
  } from './polish.ts';
16
15
 
17
16
  const C = {
@@ -37,13 +36,14 @@ function connectionColor(state: string): string {
37
36
  return C.dim;
38
37
  }
39
38
 
40
- export class ControlPlanePanel extends BasePanel {
39
+ type ControlPlaneClient = UiControlPlaneSnapshot['clients'][number];
40
+
41
+ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
41
42
  private readonly unsub: (() => void) | null;
42
- private selectedIndex = 0;
43
- private scrollOffset = 0;
44
43
 
45
44
  public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
46
45
  super('control-plane', 'Control Plane', 'C', 'monitoring');
46
+ this.showSelectionGutter = true; // I5: non-color selection affordance
47
47
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
48
48
  }
49
49
 
@@ -51,29 +51,38 @@ export class ControlPlanePanel extends BasePanel {
51
51
  this.unsub?.();
52
52
  }
53
53
 
54
- public handleInput(key: string): boolean {
55
- const clients = this.clients();
56
- if (clients.length === 0) return false;
57
- if (key === 'up' || key === 'k') {
58
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
59
- this.markDirty();
60
- return true;
61
- }
62
- if (key === 'down' || key === 'j') {
63
- this.selectedIndex = Math.min(clients.length - 1, this.selectedIndex + 1);
64
- this.markDirty();
65
- return true;
66
- }
67
- return false;
54
+ protected override getPalette(): PanelPalette {
55
+ return C;
68
56
  }
69
57
 
70
- private clients() {
58
+ protected getItems(): readonly ControlPlaneClient[] {
71
59
  if (!this.readModel) return [];
72
- return [...this.readModel.getSnapshot().clients];
60
+ return this.readModel.getSnapshot().clients;
61
+ }
62
+
63
+ protected renderItem(client: ControlPlaneClient, _index: number, selected: boolean, width: number): Line {
64
+ const bg = selected ? C.selectBg : undefined;
65
+ return buildPanelLine(width, [
66
+ [' ', C.label, bg],
67
+ [client.kind.padEnd(10), C.info, bg],
68
+ [` ${truncateDisplay(client.label, 20).padEnd(20)}`, C.value, bg],
69
+ [` ${client.transport.padEnd(12)}`, C.dim, bg],
70
+ [` ${truncateDisplay(formatTime(client.lastSeenAt), Math.max(0, width - 46))}`, C.dim, bg],
71
+ ]);
72
+ }
73
+
74
+ protected override getEmptyStateMessage(): string {
75
+ return ' No control-plane activity recorded.';
76
+ }
77
+
78
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
79
+ return [
80
+ { command: '/cockpit', summary: 'watch operator posture from the terminal' },
81
+ { command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
82
+ ];
73
83
  }
74
84
 
75
85
  public render(width: number, height: number): Line[] {
76
- this.needsRender = false;
77
86
  const intro = 'Shared daemon control plane state, live clients, approval pressure, and recent omnichannel session posture.';
78
87
 
79
88
  if (!this.readModel) {
@@ -99,168 +108,104 @@ export class ControlPlanePanel extends BasePanel {
99
108
  const approvals = snapshot.approvals;
100
109
  const sessions = snapshot.sessions;
101
110
  const recentEvents = snapshot.recentEvents;
102
- const clients = this.clients();
103
-
104
- const summarySection: PanelWorkspaceSection = {
105
- title: 'Posture',
106
- lines: [
107
- buildKeyValueLine(width, [
108
- { label: 'state', value: snapshot.connectionState, valueColor: connectionColor(snapshot.connectionState) },
109
- { label: 'clients', value: String(snapshot.activeClientIds.length), valueColor: snapshot.activeClientIds.length > 0 ? C.ok : C.dim },
110
- { label: 'requests', value: String(snapshot.requestCount), valueColor: snapshot.requestCount > 0 ? C.info : C.dim },
111
- { label: 'errors', value: String(snapshot.errorCount), valueColor: snapshot.errorCount > 0 ? C.error : C.dim },
112
- ], C),
113
- buildKeyValueLine(width, [
114
- { label: 'host', value: `${snapshot.host}:${snapshot.port}`, valueColor: C.value },
115
- { label: 'approvals', value: String(approvals.filter((entry) => entry.status === 'pending').length), valueColor: approvals.some((entry) => entry.status === 'pending') ? C.warn : C.dim },
116
- { label: 'sessions', value: String(sessions.length), valueColor: sessions.length > 0 ? C.info : C.dim },
117
- { label: 'events', value: String(recentEvents.length), valueColor: recentEvents.length > 0 ? C.info : C.dim },
118
- ], C),
119
- buildGuidanceLine(width, '/cockpit', 'use the web operator surface or daemon APIs for direct interventions while this panel tracks overall posture', C),
120
- ],
121
- };
111
+ const clients = this.getItems();
112
+
113
+ const headerLines: Line[] = [
114
+ buildKeyValueLine(width, [
115
+ { label: 'state', value: snapshot.connectionState, valueColor: connectionColor(snapshot.connectionState) },
116
+ { label: 'clients', value: String(snapshot.activeClientIds.length), valueColor: snapshot.activeClientIds.length > 0 ? C.ok : C.dim },
117
+ { label: 'requests', value: String(snapshot.requestCount), valueColor: snapshot.requestCount > 0 ? C.info : C.dim },
118
+ { label: 'errors', value: String(snapshot.errorCount), valueColor: snapshot.errorCount > 0 ? C.error : C.dim },
119
+ ], C),
120
+ buildKeyValueLine(width, [
121
+ { label: 'host', value: `${snapshot.host}:${snapshot.port}`, valueColor: C.value },
122
+ { label: 'approvals', value: String(approvals.filter((entry) => entry.status === 'pending').length), valueColor: approvals.some((entry) => entry.status === 'pending') ? C.warn : C.dim },
123
+ { label: 'sessions', value: String(sessions.length), valueColor: sessions.length > 0 ? C.info : C.dim },
124
+ { label: 'events', value: String(recentEvents.length), valueColor: recentEvents.length > 0 ? C.info : C.dim },
125
+ ], C),
126
+ buildGuidanceLine(width, '/cockpit', 'use the web operator surface or daemon APIs for direct interventions while this panel tracks overall posture', C),
127
+ ];
122
128
 
123
129
  if (clients.length === 0 && approvals.length === 0 && sessions.length === 0) {
124
- const workspace = buildPanelWorkspace(width, height, {
130
+ return this.renderList(width, height, {
125
131
  title: 'Control Plane',
126
- intro,
127
- sections: [
128
- summarySection,
129
- {
130
- lines: buildEmptyState(
131
- width,
132
- ' No control-plane activity recorded.',
133
- 'Start the daemon, connect a surface, or trigger an approval to populate this operator panel.',
134
- [
135
- { command: '/cockpit', summary: 'watch operator posture from the terminal' },
136
- { command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
137
- ],
138
- C,
139
- ),
140
- },
141
- ],
142
- palette: C,
132
+ header: headerLines,
133
+ emptyMessage: ' No control-plane activity recorded.',
143
134
  });
144
- while (workspace.length < height) workspace.push(createEmptyLine(width));
145
- return workspace;
146
135
  }
147
136
 
148
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, clients.length - 1));
137
+ this.clampSelection();
149
138
  const selected = clients[this.selectedIndex];
150
139
 
151
- const detailSection: PanelWorkspaceSection = selected
152
- ? {
153
- title: 'Selected Client',
154
- lines: [
155
- buildPanelLine(width, [
156
- [' Client: ', C.label],
157
- [selected.label, C.value],
158
- [' Kind: ', C.label],
159
- [selected.kind, C.info],
160
- ]),
161
- buildPanelLine(width, [
162
- [' Transport: ', C.label],
163
- [selected.transport, C.value],
164
- [' Connected: ', C.label],
165
- [selected.connected ? 'yes' : 'no', selected.connected ? C.ok : C.warn],
166
- ]),
167
- buildPanelLine(width, [
168
- [' Route: ', C.label],
169
- [selected.routeId ?? 'n/a', C.dim],
170
- [' Session: ', C.label],
171
- [selected.sessionId ?? 'n/a', C.dim],
172
- ]),
173
- buildPanelLine(width, [
174
- [' Last seen: ', C.label],
175
- [formatTime(selected.lastSeenAt), C.dim],
176
- [' Remote: ', C.label],
177
- [truncateDisplay(selected.remoteAddress ?? 'n/a', Math.max(0, width - 36)), C.dim],
178
- ]),
179
- ],
180
- }
181
- : {
182
- title: 'Selected Client',
183
- lines: [buildPanelLine(width, [[' No connected client selected.', C.dim]])],
184
- };
185
-
186
- const approvalsSection: PanelWorkspaceSection = {
187
- title: 'Approvals',
188
- lines: approvals.length > 0
189
- ? approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
190
- [' ', C.label],
191
- [approval.status.padEnd(10), approval.status === 'pending' ? C.warn : approval.status === 'approved' ? C.ok : approval.status === 'denied' ? C.error : C.dim],
192
- [` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value],
193
- [` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim],
194
- ]))
195
- : [buildPanelLine(width, [[' No recent approvals.', C.dim]])],
196
- };
140
+ const footerLines: Line[] = [];
141
+ if (selected) {
142
+ footerLines.push(
143
+ buildPanelLine(width, [
144
+ [' Client: ', C.label],
145
+ [selected.label, C.value],
146
+ [' Kind: ', C.label],
147
+ [selected.kind, C.info],
148
+ ]),
149
+ buildPanelLine(width, [
150
+ [' Transport: ', C.label],
151
+ [selected.transport, C.value],
152
+ [' Connected: ', C.label],
153
+ [selected.connected ? 'yes' : 'no', selected.connected ? C.ok : C.warn],
154
+ ]),
155
+ buildPanelLine(width, [
156
+ [' Route: ', C.label],
157
+ [selected.routeId ?? 'n/a', C.dim],
158
+ [' Session: ', C.label],
159
+ [selected.sessionId ?? 'n/a', C.dim],
160
+ ]),
161
+ buildPanelLine(width, [
162
+ [' Last seen: ', C.label],
163
+ [formatTime(selected.lastSeenAt), C.dim],
164
+ [' Remote: ', C.label],
165
+ [truncateDisplay(selected.remoteAddress ?? 'n/a', Math.max(0, width - 36)), C.dim],
166
+ ]),
167
+ );
168
+ } else {
169
+ footerLines.push(buildPanelLine(width, [[' No connected client selected.', C.dim]]));
170
+ }
197
171
 
198
- const sessionsSection: PanelWorkspaceSection = {
199
- title: 'Sessions',
200
- lines: sessions.length > 0
201
- ? sessions.slice(0, 6).map((session) => buildPanelLine(width, [
202
- [' ', C.label],
203
- [session.status.padEnd(10), session.status === 'active' ? C.ok : C.dim],
204
- [` ${truncateDisplay(session.title, 20).padEnd(20)}`, C.value],
205
- [` ${truncateDisplay(session.activeAgentId ?? session.id, Math.max(0, width - 34))}`, C.dim],
206
- ]))
207
- : [buildPanelLine(width, [[' No shared sessions recorded.', C.dim]])],
208
- };
172
+ if (approvals.length > 0) {
173
+ footerLines.push(
174
+ ...approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
175
+ [' ', C.label],
176
+ [approval.status.padEnd(10), approval.status === 'pending' ? C.warn : approval.status === 'approved' ? C.ok : approval.status === 'denied' ? C.error : C.dim],
177
+ [` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value],
178
+ [` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim],
179
+ ])),
180
+ );
181
+ }
209
182
 
210
- const eventsSection: PanelWorkspaceSection = {
211
- title: 'Recent Events',
212
- lines: recentEvents.length > 0
213
- ? recentEvents.slice(0, 6).map((event) => buildPanelLine(width, [
214
- [' ', C.label],
215
- [truncateDisplay(event.event, 16).padEnd(16), C.info],
216
- [` ${truncateDisplay(typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload) ?? '', Math.max(0, width - 19))}`, C.dim],
217
- ]))
218
- : [buildPanelLine(width, [[' No recent control-plane events.', C.dim]])],
219
- };
183
+ if (sessions.length > 0) {
184
+ footerLines.push(
185
+ ...sessions.slice(0, 6).map((session) => buildPanelLine(width, [
186
+ [' ', C.label],
187
+ [session.status.padEnd(10), session.status === 'active' ? C.ok : C.dim],
188
+ [` ${truncateDisplay(session.title, 20).padEnd(20)}`, C.value],
189
+ [` ${truncateDisplay(session.activeAgentId ?? session.id, Math.max(0, width - 34))}`, C.dim],
190
+ ])),
191
+ );
192
+ }
220
193
 
221
- const resolvedClients = resolvePrimaryScrollableSection(width, height, {
222
- intro,
223
- footerLines: [buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]])],
224
- palette: C,
225
- beforeSections: [summarySection],
226
- section: {
227
- title: 'Clients',
228
- scrollableLines: clients.map((client, absolute) => {
229
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
230
- return buildPanelLine(width, [
231
- [' ', C.label, bg],
232
- [client.kind.padEnd(10), C.info, bg],
233
- [` ${truncateDisplay(client.label, 20).padEnd(20)}`, C.value, bg],
234
- [` ${client.transport.padEnd(12)}`, C.dim, bg],
235
- [` ${truncateDisplay(formatTime(client.lastSeenAt), Math.max(0, width - 46))}`, C.dim, bg],
236
- ]);
237
- }),
238
- selectedIndex: this.selectedIndex,
239
- scrollOffset: this.scrollOffset,
240
- guardRows: 1,
241
- minRows: 4,
242
- appendWindowSummary: { dimColor: C.dim },
243
- },
244
- afterSections: [detailSection, approvalsSection, sessionsSection, eventsSection],
245
- });
246
- this.scrollOffset = resolvedClients.scrollOffset;
194
+ if (recentEvents.length > 0) {
195
+ footerLines.push(
196
+ ...recentEvents.slice(0, 6).map((event) => buildPanelLine(width, [
197
+ [' ', C.label],
198
+ [truncateDisplay(event.event, 16).padEnd(16), C.info],
199
+ [` ${truncateDisplay(typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload) ?? '', Math.max(0, width - 19))}`, C.dim],
200
+ ])),
201
+ );
202
+ }
203
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]]));
247
204
 
248
- const sections: PanelWorkspaceSection[] = [
249
- summarySection,
250
- resolvedClients.section,
251
- detailSection,
252
- approvalsSection,
253
- sessionsSection,
254
- eventsSection,
255
- ];
256
- const lines = buildPanelWorkspace(width, height, {
205
+ return this.renderList(width, height, {
257
206
  title: 'Control Plane',
258
- intro,
259
- sections,
260
- footerLines: [buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]])],
261
- palette: C,
207
+ header: headerLines,
208
+ footer: footerLines,
262
209
  });
263
- while (lines.length < height) lines.push(createEmptyLine(width));
264
- return lines.slice(0, height);
265
210
  }
266
211
  }
@@ -345,6 +345,7 @@ export class DiffPanel extends BasePanel {
345
345
  // -------------------------------------------------------------------------
346
346
 
347
347
  render(width: number, height: number): Line[] {
348
+ return this.trackedRender(() => {
348
349
  if (height <= 0 || width <= 0) return [];
349
350
 
350
351
  if (this.entries.length === 0) {
@@ -440,6 +441,7 @@ export class DiffPanel extends BasePanel {
440
441
  sections,
441
442
  footerLines: [this.renderStatusBar(width, entry)],
442
443
  });
444
+ });
443
445
  }
444
446
 
445
447
  // ── Tab bar ──────────────────────────────────────────────────────────────
@@ -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