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