@pellux/goodvibes-tui 1.1.0 → 1.7.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 (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. package/src/renderer/qr-renderer.ts +0 -120
@@ -1,673 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // FileExplorerPanel — collapsible project tree view
3
- // ---------------------------------------------------------------------------
4
-
5
- import { promises as fsPromises } from 'node:fs';
6
- import { join, relative, basename } from 'node:path';
7
- import type { Line } from '../types/grid.ts';
8
- import { createEmptyLine } from '../types/grid.ts';
9
- import { BasePanel } from './base-panel.ts';
10
- import {
11
- buildEmptyState,
12
- buildKeyboardHints,
13
- buildPanelLine,
14
- buildSearchInputLine,
15
- buildTreeRow,
16
- buildPanelWorkspace,
17
- resolveScrollablePanelSection,
18
- DEFAULT_PANEL_PALETTE,
19
- extendPalette,
20
- } from './polish.ts';
21
- import { isPanelSearchBackspace, isPanelSearchPrintable } from './search-focus.ts';
22
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
- import { GitService } from '@pellux/goodvibes-sdk/platform/git';
24
-
25
- // ---------------------------------------------------------------------------
26
- // Constants
27
- // ---------------------------------------------------------------------------
28
-
29
- /** Directories / files to skip (gitignore-style). */
30
- const SKIP_NAMES = new Set([
31
- 'node_modules', '.git', '.svn', '.hg',
32
- 'dist', 'build', 'out', '.next', '.nuxt', '.output',
33
- '__pycache__', '.pytest_cache', '.mypy_cache',
34
- 'coverage', '.nyc_output',
35
- '.DS_Store', 'Thumbs.db',
36
- ]);
37
-
38
- const SKIP_PATTERNS: RegExp[] = [
39
- /^\..*\.sw[px]$/, // vim swap files
40
- ];
41
-
42
- // ---------------------------------------------------------------------------
43
- // File-type icons (single-char safe for TUI columns)
44
- // ---------------------------------------------------------------------------
45
- const EXT_ICONS: Record<string, string> = {
46
- ts: 'T', tsx: 'T', js: 'J', jsx: 'J',
47
- json: 'J', jsonc: 'J',
48
- md: 'M', mdx: 'M',
49
- css: 'S', scss: 'S', sass: 'S',
50
- html: 'H', htm: 'H',
51
- py: 'P', rb: 'R', go: 'G', rs: 'R',
52
- sh: '$', bash: '$', zsh: '$',
53
- yaml: 'Y', yml: 'Y', toml: 'C',
54
- lock: 'L', log: 'L',
55
- };
56
-
57
- function fileIcon(name: string): string {
58
- const ext = name.split('.').pop()?.toLowerCase() ?? '';
59
- return EXT_ICONS[ext] ?? 'f';
60
- }
61
-
62
- // ---------------------------------------------------------------------------
63
- // Tree node
64
- // ---------------------------------------------------------------------------
65
-
66
- interface TreeNode {
67
- path: string; // absolute path
68
- name: string; // display name
69
- isDir: boolean;
70
- depth: number;
71
- size: number; // bytes (0 for dirs)
72
- expanded: boolean;
73
- children: TreeNode[];
74
- /** Whether children have been loaded. */
75
- loaded: boolean;
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // Colour palette
80
- // ---------------------------------------------------------------------------
81
- const C = extendPalette(DEFAULT_PANEL_PALETTE, {
82
- dirColor: '#00ffff', // cyan — directories
83
- fileColor: '#e0e0e0', // near-white — files
84
- sizeColor: '244', // dim grey — sizes
85
- cursorBg: '#1a2a3a', // cursor background
86
- });
87
-
88
- // ---------------------------------------------------------------------------
89
- // Helpers
90
- // ---------------------------------------------------------------------------
91
-
92
- function shouldSkip(name: string): boolean {
93
- if (SKIP_NAMES.has(name)) return true;
94
- for (const re of SKIP_PATTERNS) if (re.test(name)) return true;
95
- return false;
96
- }
97
-
98
- function formatSize(bytes: number): string {
99
- if (bytes < 1024) return `${bytes}B`;
100
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
101
- return `${(bytes / 1024 / 1024).toFixed(1)}M`;
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // FileExplorerPanel
106
- // ---------------------------------------------------------------------------
107
-
108
- export class FileExplorerPanel extends BasePanel {
109
- // --- tree state ---
110
- private root: TreeNode | null = null;
111
- private flat: TreeNode[] = []; // visible flattened list
112
- private rootPath: string;
113
- private readonly workingDirectory: string;
114
- private cacheValid: boolean = false;
115
- private readyPromise: Promise<void> | null = null;
116
-
117
- // --- git status decorations ---
118
- // Reuse one GitService instance across refreshes (I2 pattern, git-panel.ts).
119
- private readonly git: GitService;
120
- /** absolute file path -> single-char status code (git-panel.ts:146-151 classification). */
121
- private gitStatus: Map<string, { code: string; tone: 'good' | 'warn' | 'bad' | 'dim' }> = new Map();
122
-
123
- // --- navigation ---
124
- private cursor: number = 0;
125
- private scrollTop: number = 0;
126
-
127
- // --- search ---
128
- private searchMode: boolean = false;
129
- private searchQuery: string = '';
130
-
131
- constructor(rootPath: string | undefined, workingDirectory: string) {
132
- super('explorer', 'Explorer', 'E', 'development');
133
- this.workingDirectory = workingDirectory;
134
- this.rootPath = rootPath ?? workingDirectory;
135
- this.git = new GitService(this.workingDirectory);
136
- }
137
-
138
- // ── Lifecycle ──────────────────────────────────────────────────────────────
139
-
140
- override onActivate(): void {
141
- super.onActivate();
142
- if (!this.cacheValid) {
143
- void this._buildTreeAsync();
144
- }
145
- }
146
-
147
- override onDestroy(): void {
148
- this.root = null;
149
- this.flat = [];
150
- this.cacheValid = false;
151
- }
152
-
153
- // ── Public API ─────────────────────────────────────────────────────────────
154
-
155
- /** Force a full tree refresh from disk. */
156
- refresh(): void {
157
- this.cacheValid = false;
158
- void this._buildTreeAsync();
159
- }
160
-
161
- /** Currently focused node (or null). */
162
- getFocusedNode(): TreeNode | null {
163
- return this.flat[this.cursor] ?? null;
164
- }
165
-
166
- getFocusedFilePath(): string | null {
167
- const node = this.getFocusedNode();
168
- return node && !node.isDir ? node.path : null;
169
- }
170
-
171
- // ── Input ──────────────────────────────────────────────────────────────────
172
-
173
- /**
174
- * WO-153: converged modal '/' filter (mirrors ScrollableListPanel's
175
- * _handleFilterKey). Returns `true`/`false` when consumed/ignored in
176
- * filter context, or `null` to fall through to navigation/action keys.
177
- */
178
- private _handleFilterKey(key: string): boolean | null {
179
- if (this.searchMode) {
180
- if (key === 'escape') {
181
- this.searchMode = false;
182
- this.searchQuery = '';
183
- this._rebuildFlat();
184
- this.markDirty();
185
- return true;
186
- }
187
- if (key === 'return' || key === 'enter') {
188
- this.searchMode = false; // commit; keep the query applied
189
- this.markDirty();
190
- return true;
191
- }
192
- if (isPanelSearchBackspace(key)) {
193
- this.searchQuery = this.searchQuery.slice(0, -1);
194
- this._rebuildFlat();
195
- this.markDirty();
196
- return true;
197
- }
198
- // Arrow/paging keys navigate the filtered tree — fall through.
199
- if (key === 'up' || key === 'down' || key === 'pageup' || key === 'pagedown' || key === 'home' || key === 'end') {
200
- return null;
201
- }
202
- // Any printable character (including single-letter action keys like r/d) extends the query.
203
- if (isPanelSearchPrintable(key)) {
204
- this.searchQuery += key;
205
- this._rebuildFlat();
206
- this.markDirty();
207
- return true;
208
- }
209
- return false;
210
- }
211
- if (key === '/') {
212
- this._enterSearch();
213
- return true;
214
- }
215
- return null;
216
- }
217
-
218
- /**
219
- * The `/`-to-search buffer wants every character of a burst (paste, or a
220
- * fast-typed query landing in one input.feed() call), same as it always
221
- * has — see the interface doc on `Panel.isCapturingTextBurst`.
222
- */
223
- isCapturingTextBurst(): boolean {
224
- return this.searchMode;
225
- }
226
-
227
- handleInput(key: string): boolean {
228
- const filterResult = this._handleFilterKey(key);
229
- if (filterResult !== null) return filterResult;
230
-
231
- switch (key) {
232
- case 'up': case 'k': this._moveCursor(-1); return true;
233
- case 'down': case 'j': this._moveCursor(1); return true;
234
- case 'pageup': this._moveCursorPage(-1); return true;
235
- case 'pagedown': this._moveCursorPage(1); return true;
236
- case 'home': case 'g': this._setCursor(0); return true;
237
- case 'end': case 'G': this._setCursor(this.flat.length - 1); return true;
238
- case 'return': case 'enter': this._activateNode(); return true;
239
- case 'right': this._expandNode(); return true;
240
- case 'left': this._collapseNode(); return true;
241
- case 'r': this.refresh(); return true;
242
- case 'd': return this.getFocusedFilePath() !== null;
243
- default: return false;
244
- }
245
- }
246
-
247
- handleScroll(deltaRows: number): boolean {
248
- const rows = Math.trunc(deltaRows);
249
- if (this.flat.length === 0 || rows === 0) return false;
250
- const previous = this.cursor;
251
- this._setCursor(this.cursor + rows);
252
- return this.cursor !== previous;
253
- }
254
-
255
- // ── Render ─────────────────────────────────────────────────────────────────
256
-
257
- render(width: number, height: number): Line[] {
258
- this.needsRender = false;
259
-
260
- // Context-aware hints: only surface keys that act in the current mode.
261
- const hintsLine = this.searchMode
262
- ? buildKeyboardHints(width, [
263
- { keys: 'type', label: 'filter' },
264
- { keys: '↑/↓', label: 'move' },
265
- { keys: 'Enter', label: 'keep results' },
266
- { keys: 'Esc', label: 'clear' },
267
- ], C)
268
- : buildKeyboardHints(width, [
269
- { keys: '↑/↓', label: 'navigate' },
270
- { keys: 'Enter/→', label: 'expand' },
271
- { keys: '←', label: 'collapse' },
272
- { keys: '/', label: 'filter' },
273
- { keys: 'r', label: 'refresh' },
274
- { keys: 'd', label: 'diff' },
275
- ], C);
276
-
277
- if (this.flat.length === 0) {
278
- return buildPanelWorkspace(width, height, {
279
- title: ' Explorer',
280
- intro: 'Browse the project tree, expand directories, and search for paths.',
281
- sections: [
282
- {
283
- lines: buildEmptyState(width, ' No files found', this.searchQuery ? 'No files or directories match the current search.' : 'This root did not produce any visible files after the explorer filters were applied.', this.searchQuery ? [{ command: 'Esc', summary: 'clear the filter to show the full tree again' }] : [{ command: 'r', summary: 'rescan this root from disk' }], C),
284
- },
285
- ],
286
- footerLines: [
287
- this._buildFilterLine(width),
288
- hintsLine,
289
- ],
290
- palette: DEFAULT_PANEL_PALETTE,
291
- });
292
- }
293
-
294
- const summarySection = {
295
- title: 'Summary',
296
- lines: [
297
- buildPanelLine(width, [
298
- [' Root ', DEFAULT_PANEL_PALETTE.label],
299
- [relative(this.workingDirectory, this.rootPath) || '.', DEFAULT_PANEL_PALETTE.value],
300
- [' Visible ', DEFAULT_PANEL_PALETTE.label],
301
- [String(this.flat.length), DEFAULT_PANEL_PALETTE.value],
302
- [' Search ', DEFAULT_PANEL_PALETTE.label],
303
- [this.searchQuery || 'none', this.searchQuery ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
304
- ]),
305
- ],
306
- } as const;
307
- const selected = this.flat[this.cursor];
308
- const selectedSection = {
309
- title: 'Selected',
310
- lines: selected
311
- ? [
312
- buildPanelLine(width, [
313
- [' Name ', DEFAULT_PANEL_PALETTE.label],
314
- [selected.name, DEFAULT_PANEL_PALETTE.value],
315
- [' Type ', DEFAULT_PANEL_PALETTE.label],
316
- [selected.isDir ? 'directory' : 'file', selected.isDir ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.value],
317
- ]),
318
- buildPanelLine(width, [
319
- [' Path ', DEFAULT_PANEL_PALETTE.label],
320
- [selected.path, DEFAULT_PANEL_PALETTE.dim],
321
- ]),
322
- ]
323
- : [],
324
- } as const;
325
- const treeSection = resolveScrollablePanelSection(width, height, {
326
- intro: 'Browse the project tree, expand directories, and search for paths.',
327
- footerLines: [
328
- this._buildFilterLine(width),
329
- hintsLine,
330
- ],
331
- palette: DEFAULT_PANEL_PALETTE,
332
- beforeSections: [summarySection],
333
- section: {
334
- title: 'Tree',
335
- scrollableLines: this.flat.map((node, absoluteIdx) => {
336
- const isCursor = absoluteIdx === this.cursor;
337
- return buildTreeRow(width, {
338
- depth: node.depth,
339
- label: node.name,
340
- icon: node.isDir ? undefined : fileIcon(node.name),
341
- expandable: node.isDir,
342
- expanded: node.expanded,
343
- labelColor: node.isDir ? C.dirColor : C.fileColor,
344
- metadata: this._buildRowMetadata(node),
345
- }, C, { selected: isCursor, selectedBg: C.cursorBg });
346
- }),
347
- selectedIndex: this.cursor,
348
- scrollOffset: this.scrollTop,
349
- minRows: 8,
350
- },
351
- afterSections: [selectedSection],
352
- });
353
- this.scrollTop = treeSection.scrollOffset;
354
- return buildPanelWorkspace(width, height, {
355
- title: ' Explorer',
356
- intro: 'Browse the project tree, expand directories, and search for paths.',
357
- sections: [
358
- summarySection,
359
- treeSection.section,
360
- selectedSection,
361
- ],
362
- footerLines: [
363
- this._buildFilterLine(width),
364
- hintsLine,
365
- ],
366
- palette: DEFAULT_PANEL_PALETTE,
367
- });
368
- }
369
-
370
- // ── Private: tree building ─────────────────────────────────────────────────
371
-
372
- /**
373
- * Build (or rebuild) the tree root and load ONLY its immediate children.
374
- * Deeper directories stay unloaded (`loaded: false`, empty `children`)
375
- * until the user actually expands them via _expandNode()/_activateNode() \u2014
376
- * this replaces the old eager depth-5 recursive scan, which walked (and
377
- * silently truncated at a hardcoded depth cutoff) the entire visible tree
378
- * up front and blocked opening the panel on large repos.
379
- */
380
- private _buildTreeAsync(): Promise<void> {
381
- const p = (async () => {
382
- try {
383
- await this.withLoading('Scanning directory\u2026', async () => {
384
- const root: TreeNode = {
385
- path: this.rootPath,
386
- name: basename(this.rootPath) || this.rootPath,
387
- isDir: true,
388
- depth: 0,
389
- size: 0,
390
- expanded: true,
391
- children: [],
392
- loaded: false,
393
- };
394
- await this._loadChildren(root);
395
- this.root = root;
396
- this._rebuildFlat();
397
- this.cacheValid = true;
398
- });
399
- // Best-effort \u2014 a missing/failed git status just means no decorations.
400
- await this._loadGitStatus();
401
- } catch (err) {
402
- this.setError(summarizeError(err));
403
- }
404
- this.markDirty();
405
- })();
406
- this.readyPromise = p;
407
- return p;
408
- }
409
-
410
- /** Resolves when the current tree build has settled. */
411
- public awaitReady(): Promise<void> {
412
- return this.readyPromise ?? Promise.resolve();
413
- }
414
-
415
- /**
416
- * Read one directory's immediate entries and populate `node.children` with
417
- * fully-resolved file nodes and unloaded directory stubs. Idempotent and
418
- * safe to call on an already-loaded node (no-ops).
419
- */
420
- private async _loadChildren(node: TreeNode): Promise<void> {
421
- if (node.loaded || !node.isDir) return;
422
-
423
- let entries: string[];
424
- try {
425
- entries = await fsPromises.readdir(node.path);
426
- } catch {
427
- node.loaded = true;
428
- return;
429
- }
430
-
431
- const filtered = entries.filter(e => !shouldSkip(e));
432
- const statResults = await Promise.all(
433
- filtered.map(async (e) => {
434
- try {
435
- const s = await fsPromises.stat(join(node.path, e));
436
- return { name: e, isDir: s.isDirectory(), size: s.size, stat: s };
437
- } catch {
438
- return { name: e, isDir: false, size: 0, stat: null };
439
- }
440
- }),
441
- );
442
-
443
- const sorted = statResults.sort((a, b) => {
444
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
445
- return a.name.localeCompare(b.name);
446
- });
447
-
448
- const children: TreeNode[] = [];
449
- for (const entry of sorted) {
450
- if (entry.stat === null) continue;
451
- const fullPath = join(node.path, entry.name);
452
- children.push(entry.isDir
453
- ? {
454
- path: fullPath,
455
- name: entry.name,
456
- isDir: true,
457
- depth: node.depth + 1,
458
- size: 0,
459
- expanded: false,
460
- children: [],
461
- loaded: false,
462
- }
463
- : {
464
- path: fullPath,
465
- name: entry.name,
466
- isDir: false,
467
- depth: node.depth + 1,
468
- size: entry.size,
469
- expanded: false,
470
- children: [],
471
- loaded: true,
472
- });
473
- }
474
- node.children = children;
475
- node.loaded = true;
476
- }
477
-
478
- /**
479
- * Fetch git status for the working directory and index it by absolute
480
- * path for O(1) lookup from the tree renderer. Classification mirrors
481
- * git-panel's staged/unstaged split from the raw porcelain index/
482
- * working_dir columns (git-panel.ts:146-151), collapsed to a single
483
- * decoration badge per file: working-tree changes take priority over
484
- * staged-only changes, since that's the state most relevant while
485
- * browsing the tree.
486
- */
487
- private async _loadGitStatus(): Promise<void> {
488
- try {
489
- const status = await this.git.status();
490
- const next = new Map<string, { code: string; tone: 'good' | 'warn' | 'bad' | 'dim' }>();
491
- for (const f of status.files) {
492
- const fullPath = join(this.workingDirectory, f.path);
493
- if (f.index === '?' && f.working_dir === '?') {
494
- next.set(fullPath, { code: '?', tone: 'dim' }); // untracked
495
- continue;
496
- }
497
- const code = f.working_dir !== ' ' ? f.working_dir : f.index;
498
- if (!code || code === ' ') continue;
499
- const tone = code === 'D' ? 'bad' : code === 'A' ? 'good' : 'warn';
500
- next.set(fullPath, { code, tone });
501
- }
502
- this.gitStatus = next;
503
- } catch {
504
- // Not a git repo (or git unavailable) \u2014 decorations are optional.
505
- this.gitStatus = new Map();
506
- }
507
- }
508
-
509
- /** Right-aligned row metadata: git status badge (files only) + file size. */
510
- private _buildRowMetadata(node: TreeNode): Array<{ text: string; fg: string }> | undefined {
511
- if (node.isDir) return undefined;
512
- const meta: Array<{ text: string; fg: string }> = [];
513
- const status = this.gitStatus.get(node.path);
514
- if (status) {
515
- const fg = status.tone === 'good' ? DEFAULT_PANEL_PALETTE.good
516
- : status.tone === 'bad' ? DEFAULT_PANEL_PALETTE.bad
517
- : status.tone === 'warn' ? DEFAULT_PANEL_PALETTE.warn
518
- : DEFAULT_PANEL_PALETTE.dim;
519
- meta.push({ text: status.code, fg });
520
- }
521
- if (node.size > 0) meta.push({ text: formatSize(node.size), fg: C.sizeColor });
522
- return meta.length > 0 ? meta : undefined;
523
- }
524
-
525
- /**
526
- * Flatten the tree into a visible list based on expansion state
527
- * and the current search query.
528
- */
529
- private _rebuildFlat(): void {
530
- const q = this.searchQuery.trim().toLowerCase();
531
-
532
- if (q) {
533
- // In search mode: show all matching nodes (any depth), ignoring expand state
534
- const results: TreeNode[] = [];
535
- this._collectMatching(this.root, q, results);
536
- this.flat = results;
537
- } else {
538
- const rows: TreeNode[] = [];
539
- if (this.root) this._flatten(this.root, rows, /* skipSelf */ true);
540
- this.flat = rows;
541
- }
542
-
543
- // Clamp cursor
544
- if (this.cursor >= this.flat.length) {
545
- this.cursor = Math.max(0, this.flat.length - 1);
546
- }
547
- }
548
-
549
- private _flatten(node: TreeNode, out: TreeNode[], skipSelf: boolean): void {
550
- if (!skipSelf) out.push(node);
551
- if ((skipSelf || node.expanded) && node.children.length > 0) {
552
- for (const child of node.children) {
553
- this._flatten(child, out, false);
554
- }
555
- }
556
- }
557
-
558
- private _collectMatching(node: TreeNode | null, q: string, out: TreeNode[]): void {
559
- if (!node) return;
560
- if (node.name.toLowerCase().includes(q)) out.push(node);
561
- for (const child of node.children) this._collectMatching(child, q, out);
562
- }
563
-
564
- // ── Private: navigation ───────────────────────────────────────────────────
565
-
566
- private _moveCursor(delta: number): void {
567
- this._setCursor(this.cursor + delta);
568
- }
569
-
570
- private _moveCursorPage(direction: 1 | -1, pageSize = 10): void {
571
- this._setCursor(this.cursor + direction * pageSize);
572
- }
573
-
574
- private _setCursor(idx: number): void {
575
- this.cursor = Math.max(0, Math.min(idx, this.flat.length - 1));
576
- this.markDirty();
577
- }
578
-
579
- private _activateNode(): void {
580
- const node = this.flat[this.cursor];
581
- if (!node) return;
582
- if (node.isDir) {
583
- node.expanded = !node.expanded;
584
- this._rebuildFlat();
585
- this.markDirty();
586
- if (node.expanded) this._loadNodeChildrenAndRefresh(node);
587
- }
588
- // For files: callers can read getFocusedNode() after the input returns true
589
- }
590
-
591
- private _expandNode(): void {
592
- const node = this.flat[this.cursor];
593
- if (!node || !node.isDir || node.expanded) return;
594
- node.expanded = true;
595
- this._rebuildFlat();
596
- this.markDirty();
597
- this._loadNodeChildrenAndRefresh(node);
598
- }
599
-
600
- /**
601
- * Lazily populate a directory's children (if not already loaded) and
602
- * refresh the flattened view once they arrive, without blocking the
603
- * expand keystroke itself.
604
- */
605
- private _loadNodeChildrenAndRefresh(node: TreeNode): void {
606
- if (node.loaded) return;
607
- void this._loadChildren(node).then(() => {
608
- this._rebuildFlat();
609
- this.markDirty();
610
- });
611
- }
612
-
613
- private _collapseNode(): void {
614
- const node = this.flat[this.cursor];
615
- if (!node) return;
616
- if (node.isDir && node.expanded) {
617
- node.expanded = false;
618
- this._rebuildFlat();
619
- this.markDirty();
620
- } else if (!node.isDir || !node.expanded) {
621
- // Jump to parent dir
622
- const parent = this._findParent(node);
623
- if (parent) {
624
- const idx = this.flat.indexOf(parent);
625
- if (idx >= 0) this._setCursor(idx);
626
- }
627
- }
628
- }
629
-
630
- private _findParent(node: TreeNode): TreeNode | null {
631
- return this._findParentIn(this.root, node);
632
- }
633
-
634
- private _findParentIn(candidate: TreeNode | null, target: TreeNode): TreeNode | null {
635
- if (!candidate) return null;
636
- for (const child of candidate.children) {
637
- if (child === target) return candidate;
638
- const found = this._findParentIn(child, target);
639
- if (found) return found;
640
- }
641
- return null;
642
- }
643
-
644
- // ── Private: search ───────────────────────────────────────────────────────
645
-
646
- private _enterSearch(): void {
647
- // Re-entering search (e.g. pressing '/' again) must EDIT the existing
648
- // query, not silently clear it — the bug this fixes unconditionally
649
- // reset searchQuery to '' on every entry, discarding whatever filter
650
- // was already active.
651
- this.searchMode = true;
652
- this._rebuildFlat();
653
- this.markDirty();
654
- }
655
-
656
- /**
657
- * The filter input line — pinned rendering contract shared with
658
- * ScrollableListPanel.buildFilterLine: 'Filter: ' unfocused / '[Filter] '
659
- * focused, literal trailing '_' cursor while active (active:false is
660
- * passed to buildSearchInputLine to suppress its block-glyph cursor
661
- * substitution).
662
- */
663
- private _buildFilterLine(width: number): Line {
664
- const label = this.searchMode ? '[Filter] ' : 'Filter: ';
665
- const value = this.searchMode ? `${this.searchQuery}_` : this.searchQuery;
666
- return buildSearchInputLine(width, label, value, DEFAULT_PANEL_PALETTE, {
667
- active: false,
668
- bg: this.searchMode ? DEFAULT_PANEL_PALETTE.inputBg : DEFAULT_PANEL_PALETTE.sectionBg,
669
- emptyLabel: '(/ to filter)',
670
- valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : (this.searchQuery ? DEFAULT_PANEL_PALETTE.value : DEFAULT_PANEL_PALETTE.dim),
671
- });
672
- }
673
- }