@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -9,29 +9,23 @@ import { createEmptyLine } from '../types/grid.ts';
9
9
  import { BasePanel } from './base-panel.ts';
10
10
  import {
11
11
  buildEmptyState,
12
+ buildKeyboardHints,
12
13
  buildPanelLine,
13
14
  buildSearchInputLine,
14
- buildSelectablePanelLine,
15
+ buildTreeRow,
15
16
  buildPanelWorkspace,
16
17
  resolveScrollablePanelSection,
17
18
  DEFAULT_PANEL_PALETTE,
19
+ extendPalette,
18
20
  } from './polish.ts';
19
- import { getDisplayWidth } from '../utils/terminal-width.ts';
20
- import {
21
- getPanelSearchFocusTransition,
22
- isPanelSearchBackspace,
23
- isPanelSearchCancel,
24
- isPanelSearchCommit,
25
- isPanelSearchPrintable,
26
- } from './search-focus.ts';
21
+ import { isPanelSearchBackspace, isPanelSearchPrintable } from './search-focus.ts';
27
22
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
+ import { GitService } from '@pellux/goodvibes-sdk/platform/git';
28
24
 
29
25
  // ---------------------------------------------------------------------------
30
26
  // Constants
31
27
  // ---------------------------------------------------------------------------
32
28
 
33
- const MAX_DEPTH = 5;
34
-
35
29
  /** Directories / files to skip (gitignore-style). */
36
30
  const SKIP_NAMES = new Set([
37
31
  'node_modules', '.git', '.svn', '.hg',
@@ -84,15 +78,12 @@ interface TreeNode {
84
78
  // ---------------------------------------------------------------------------
85
79
  // Colour palette
86
80
  // ---------------------------------------------------------------------------
87
- const CLR_DIR = '#00ffff'; // cyan — directories
88
- const CLR_FILE = '#e0e0e0'; // near-whitefiles
89
- const CLR_SIZE = '244'; // dim grey sizes
90
- const CLR_CURSOR = '#1a2a3a'; // cursor background
91
- const CLR_CURSOR_FG = '#ffffff';
92
- const CLR_SEARCH_BG = '#2a1a3a';
93
- const CLR_SEARCH_FG = '#ff79c6';
94
- const CLR_TOGGLE = '244'; // ▶/▼ toggle arrows
95
- const CLR_ICON = '244'; // file type icon
81
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
82
+ dirColor: '#00ffff', // cyandirectories
83
+ fileColor: '#e0e0e0', // near-whitefiles
84
+ sizeColor: '244', // dim grey — sizes
85
+ cursorBg: '#1a2a3a', // cursor background
86
+ });
96
87
 
97
88
  // ---------------------------------------------------------------------------
98
89
  // Helpers
@@ -123,6 +114,12 @@ export class FileExplorerPanel extends BasePanel {
123
114
  private cacheValid: boolean = false;
124
115
  private readyPromise: Promise<void> | null = null;
125
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
+
126
123
  // --- navigation ---
127
124
  private cursor: number = 0;
128
125
  private scrollTop: number = 0;
@@ -135,6 +132,7 @@ export class FileExplorerPanel extends BasePanel {
135
132
  super('explorer', 'Explorer', 'E', 'development');
136
133
  this.workingDirectory = workingDirectory;
137
134
  this.rootPath = rootPath ?? workingDirectory;
135
+ this.git = new GitService(this.workingDirectory);
138
136
  }
139
137
 
140
138
  // ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -172,14 +170,54 @@ export class FileExplorerPanel extends BasePanel {
172
170
 
173
171
  // ── Input ──────────────────────────────────────────────────────────────────
174
172
 
175
- handleInput(key: string): boolean {
176
- if (this.searchMode) return this._handleSearchInput(key);
177
-
178
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.cursor, itemCount: this.flat.length });
179
- if (transition === 'focus-search') {
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 === '/') {
180
212
  this._enterSearch();
181
213
  return true;
182
214
  }
215
+ return null;
216
+ }
217
+
218
+ handleInput(key: string): boolean {
219
+ const filterResult = this._handleFilterKey(key);
220
+ if (filterResult !== null) return filterResult;
183
221
 
184
222
  switch (key) {
185
223
  case 'up': case 'k': this._moveCursor(-1); return true;
@@ -191,8 +229,8 @@ export class FileExplorerPanel extends BasePanel {
191
229
  case 'return': case 'enter': this._activateNode(); return true;
192
230
  case 'right': this._expandNode(); return true;
193
231
  case 'left': this._collapseNode(); return true;
194
- case '/': this._enterSearch(); return true;
195
232
  case 'r': this.refresh(); return true;
233
+ case 'd': return this.getFocusedFilePath() !== null;
196
234
  default: return false;
197
235
  }
198
236
  }
@@ -209,11 +247,23 @@ export class FileExplorerPanel extends BasePanel {
209
247
 
210
248
  render(width: number, height: number): Line[] {
211
249
  this.needsRender = false;
212
- const searchLine = this.searchMode
213
- ? `/ ${this.searchQuery}_`
214
- : this.searchQuery
215
- ? `Filter: ${this.searchQuery} (/ or up at top to edit)`
216
- : `Root: ${relative(this.workingDirectory, this.rootPath) || '.'} (/ or up at top to search)`;
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);
217
267
 
218
268
  if (this.flat.length === 0) {
219
269
  return buildPanelWorkspace(width, height, {
@@ -221,23 +271,12 @@ export class FileExplorerPanel extends BasePanel {
221
271
  intro: 'Browse the project tree, expand directories, and search for paths.',
222
272
  sections: [
223
273
  {
224
- lines: buildEmptyState(
225
- width,
226
- ' No files found',
227
- this.searchQuery
228
- ? 'No files or directories match the current search.'
229
- : 'This root did not produce any visible files after the explorer filters were applied.',
230
- [],
231
- DEFAULT_PANEL_PALETTE,
232
- ),
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),
233
275
  },
234
276
  ],
235
277
  footerLines: [
236
- buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
237
- active: this.searchMode,
238
- valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
239
- }),
240
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
278
+ this._buildFilterLine(width),
279
+ hintsLine,
241
280
  ],
242
281
  palette: DEFAULT_PANEL_PALETTE,
243
282
  });
@@ -247,7 +286,9 @@ export class FileExplorerPanel extends BasePanel {
247
286
  title: 'Summary',
248
287
  lines: [
249
288
  buildPanelLine(width, [
250
- [' Visible ', DEFAULT_PANEL_PALETTE.label],
289
+ [' Root ', DEFAULT_PANEL_PALETTE.label],
290
+ [relative(this.workingDirectory, this.rootPath) || '.', DEFAULT_PANEL_PALETTE.value],
291
+ [' Visible ', DEFAULT_PANEL_PALETTE.label],
251
292
  [String(this.flat.length), DEFAULT_PANEL_PALETTE.value],
252
293
  [' Search ', DEFAULT_PANEL_PALETTE.label],
253
294
  [this.searchQuery || 'none', this.searchQuery ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
@@ -275,11 +316,8 @@ export class FileExplorerPanel extends BasePanel {
275
316
  const treeSection = resolveScrollablePanelSection(width, height, {
276
317
  intro: 'Browse the project tree, expand directories, and search for paths.',
277
318
  footerLines: [
278
- buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
279
- active: this.searchMode,
280
- valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
281
- }),
282
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
319
+ this._buildFilterLine(width),
320
+ hintsLine,
283
321
  ],
284
322
  palette: DEFAULT_PANEL_PALETTE,
285
323
  beforeSections: [summarySection],
@@ -287,24 +325,15 @@ export class FileExplorerPanel extends BasePanel {
287
325
  title: 'Tree',
288
326
  scrollableLines: this.flat.map((node, absoluteIdx) => {
289
327
  const isCursor = absoluteIdx === this.cursor;
290
- const baseBg = isCursor ? CLR_CURSOR : '';
291
- const baseFg = isCursor ? CLR_CURSOR_FG : (node.isDir ? CLR_DIR : CLR_FILE);
292
- const indent = ' '.repeat(node.depth);
293
- const segments = [
294
- { text: indent, fg: baseFg },
295
- node.isDir
296
- ? { text: node.expanded ? '▾ ' : '▸ ', fg: CLR_TOGGLE, bold: isCursor }
297
- : { text: `${fileIcon(node.name)} `, fg: CLR_ICON, dim: !isCursor },
298
- { text: node.name, fg: baseFg, bold: node.isDir || isCursor },
299
- ];
300
- if (!node.isDir && node.size > 0) {
301
- const sizeStr = ` ${formatSize(node.size)}`;
302
- const contentWidth = getDisplayWidth(indent) + 2 + getDisplayWidth(node.name);
303
- const gap = Math.max(1, width - contentWidth - getDisplayWidth(sizeStr));
304
- segments.push({ text: ' '.repeat(gap), fg: baseFg });
305
- segments.push({ text: sizeStr, fg: CLR_SIZE, dim: true });
306
- }
307
- return buildSelectablePanelLine(width, segments, { selected: isCursor, selectedBg: baseBg, fillFg: baseFg });
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 });
308
337
  }),
309
338
  selectedIndex: this.cursor,
310
339
  scrollOffset: this.scrollTop,
@@ -322,11 +351,8 @@ export class FileExplorerPanel extends BasePanel {
322
351
  selectedSection,
323
352
  ],
324
353
  footerLines: [
325
- buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
326
- active: this.searchMode,
327
- valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
328
- }),
329
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
354
+ this._buildFilterLine(width),
355
+ hintsLine,
330
356
  ],
331
357
  palette: DEFAULT_PANEL_PALETTE,
332
358
  });
@@ -334,14 +360,35 @@ export class FileExplorerPanel extends BasePanel {
334
360
 
335
361
  // ── Private: tree building ─────────────────────────────────────────────────
336
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
+ */
337
371
  private _buildTreeAsync(): Promise<void> {
338
372
  const p = (async () => {
339
373
  try {
340
374
  await this.withLoading('Scanning directory\u2026', async () => {
341
- this.root = await this._scanDirAsync(this.rootPath, 0);
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;
342
387
  this._rebuildFlat();
343
388
  this.cacheValid = true;
344
389
  });
390
+ // Best-effort \u2014 a missing/failed git status just means no decorations.
391
+ await this._loadGitStatus();
345
392
  } catch (err) {
346
393
  this.setError(summarizeError(err));
347
394
  }
@@ -356,36 +403,27 @@ export class FileExplorerPanel extends BasePanel {
356
403
  return this.readyPromise ?? Promise.resolve();
357
404
  }
358
405
 
359
- private async _scanDirAsync(dirPath: string, depth: number): Promise<TreeNode> {
360
- const name = basename(dirPath);
361
- const node: TreeNode = {
362
- path: dirPath,
363
- name,
364
- isDir: true,
365
- depth,
366
- size: 0,
367
- expanded: depth === 0,
368
- children: [],
369
- loaded: false,
370
- };
371
-
372
- if (depth >= MAX_DEPTH) return node;
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;
373
413
 
374
414
  let entries: string[];
375
415
  try {
376
- entries = await fsPromises.readdir(dirPath);
416
+ entries = await fsPromises.readdir(node.path);
377
417
  } catch {
378
- return node;
418
+ node.loaded = true;
419
+ return;
379
420
  }
380
421
 
381
- node.loaded = true;
382
-
383
- // Sort: dirs first, then files, alphabetically within each group
384
422
  const filtered = entries.filter(e => !shouldSkip(e));
385
423
  const statResults = await Promise.all(
386
424
  filtered.map(async (e) => {
387
425
  try {
388
- const s = await fsPromises.stat(join(dirPath, e));
426
+ const s = await fsPromises.stat(join(node.path, e));
389
427
  return { name: e, isDir: s.isDirectory(), size: s.size, stat: s };
390
428
  } catch {
391
429
  return { name: e, isDir: false, size: 0, stat: null };
@@ -398,26 +436,81 @@ export class FileExplorerPanel extends BasePanel {
398
436
  return a.name.localeCompare(b.name);
399
437
  });
400
438
 
439
+ const children: TreeNode[] = [];
401
440
  for (const entry of sorted) {
402
441
  if (entry.stat === null) continue;
403
- const fullPath = join(dirPath, entry.name);
404
- if (entry.isDir) {
405
- node.children.push(await this._scanDirAsync(fullPath, depth + 1));
406
- } else {
407
- node.children.push({
408
- path: fullPath,
409
- name: entry.name,
410
- isDir: false,
411
- depth: depth + 1,
412
- size: entry.size,
413
- expanded: false,
414
- children: [],
415
- loaded: true,
416
- });
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 });
417
492
  }
493
+ this.gitStatus = next;
494
+ } catch {
495
+ // Not a git repo (or git unavailable) \u2014 decorations are optional.
496
+ this.gitStatus = new Map();
418
497
  }
498
+ }
419
499
 
420
- return node;
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;
421
514
  }
422
515
 
423
516
  /**
@@ -474,15 +567,6 @@ export class FileExplorerPanel extends BasePanel {
474
567
  this.markDirty();
475
568
  }
476
569
 
477
- private _clampScroll(viewHeight: number): void {
478
- if (this.cursor < this.scrollTop) {
479
- this.scrollTop = this.cursor;
480
- } else if (this.cursor >= this.scrollTop + viewHeight) {
481
- this.scrollTop = this.cursor - viewHeight + 1;
482
- }
483
- this.scrollTop = Math.max(0, this.scrollTop);
484
- }
485
-
486
570
  private _activateNode(): void {
487
571
  const node = this.flat[this.cursor];
488
572
  if (!node) return;
@@ -490,6 +574,7 @@ export class FileExplorerPanel extends BasePanel {
490
574
  node.expanded = !node.expanded;
491
575
  this._rebuildFlat();
492
576
  this.markDirty();
577
+ if (node.expanded) this._loadNodeChildrenAndRefresh(node);
493
578
  }
494
579
  // For files: callers can read getFocusedNode() after the input returns true
495
580
  }
@@ -500,6 +585,20 @@ export class FileExplorerPanel extends BasePanel {
500
585
  node.expanded = true;
501
586
  this._rebuildFlat();
502
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
+ });
503
602
  }
504
603
 
505
604
  private _collapseNode(): void {
@@ -536,50 +635,30 @@ export class FileExplorerPanel extends BasePanel {
536
635
  // ── Private: search ───────────────────────────────────────────────────────
537
636
 
538
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.
539
642
  this.searchMode = true;
540
- this.searchQuery = '';
541
643
  this._rebuildFlat();
542
644
  this.markDirty();
543
645
  }
544
646
 
545
- private _handleSearchInput(key: string): boolean {
546
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.cursor, itemCount: this.flat.length });
547
- if (transition === 'focus-list') {
548
- this.searchMode = false;
549
- this.cursor = 0;
550
- this.scrollTop = 0;
551
- this.markDirty();
552
- return true;
553
- }
554
- if (isPanelSearchCancel(key)) {
555
- this.searchMode = false;
556
- this.searchQuery = '';
557
- this._rebuildFlat();
558
- this.markDirty();
559
- return true;
560
- }
561
- if (isPanelSearchCommit(key)) {
562
- // Confirm search, stay in results, exit search-input mode
563
- this.searchMode = false;
564
- this.markDirty();
565
- return true;
566
- }
567
- if (isPanelSearchBackspace(key)) {
568
- this.searchQuery = this.searchQuery.slice(0, -1);
569
- this._rebuildFlat();
570
- this.markDirty();
571
- return true;
572
- }
573
- // Printable single characters
574
- if (isPanelSearchPrintable(key)) {
575
- this.searchQuery += key;
576
- this._rebuildFlat();
577
- this.markDirty();
578
- return true;
579
- }
580
- // Navigation still works during search
581
- if (key === 'up' || key === 'k') { this._moveCursor(-1); return true; }
582
- if (key === 'down' || key === 'j') { this._moveCursor(1); return true; }
583
- return false;
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
+ });
584
663
  }
585
664
  }