@pellux/goodvibes-tui 1.1.0 → 1.9.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 (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. 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
- }