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