@pellux/goodvibes-agent 0.1.1 → 0.1.3

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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +12 -1
  3. package/docs/README.md +2 -0
  4. package/docs/getting-started.md +19 -1
  5. package/docs/release-and-publishing.md +3 -1
  6. package/package.json +10 -1
  7. package/src/agent/persona-registry.ts +379 -0
  8. package/src/agent/skill-registry.ts +360 -0
  9. package/src/audio/spoken-turn-model-routing.ts +2 -1
  10. package/src/cli/agent-knowledge-command.ts +525 -0
  11. package/src/cli/help.ts +35 -0
  12. package/src/cli/management-commands.ts +3 -1
  13. package/src/cli/management.ts +33 -9
  14. package/src/cli/parser.ts +7 -0
  15. package/src/cli/types.ts +3 -0
  16. package/src/config/surface.ts +1 -0
  17. package/src/input/agent-workspace.ts +33 -3
  18. package/src/input/command-registry.ts +4 -1
  19. package/src/input/commands/agent-skills-runtime.ts +216 -0
  20. package/src/input/commands/delegation-runtime.ts +129 -0
  21. package/src/input/commands/knowledge.ts +18 -18
  22. package/src/input/commands/personas-runtime.ts +219 -0
  23. package/src/input/commands/shell-core.ts +9 -6
  24. package/src/input/commands/skills-runtime.ts +7 -2
  25. package/src/input/commands.ts +6 -0
  26. package/src/input/panel-integration-actions.ts +0 -52
  27. package/src/input/submission-router.ts +1 -1
  28. package/src/main.ts +2 -1
  29. package/src/panels/builtin/agent.ts +0 -14
  30. package/src/panels/builtin/session.ts +4 -3
  31. package/src/panels/index.ts +0 -5
  32. package/src/panels/orchestration-panel.ts +4 -5
  33. package/src/panels/qr-panel.ts +3 -2
  34. package/src/panels/tasks-panel.ts +4 -4
  35. package/src/renderer/agent-workspace.ts +2 -0
  36. package/src/runtime/bootstrap-command-context.ts +3 -0
  37. package/src/runtime/bootstrap-command-parts.ts +6 -2
  38. package/src/runtime/bootstrap-core.ts +8 -4
  39. package/src/runtime/bootstrap-shell.ts +5 -2
  40. package/src/runtime/bootstrap.ts +10 -2
  41. package/src/runtime/cloudflare-control-plane.ts +2 -1
  42. package/src/version.ts +1 -1
  43. package/src/daemon/cli.ts +0 -55
  44. package/src/daemon/safe-serve.ts +0 -61
  45. package/src/panels/diff-panel.ts +0 -520
  46. package/src/panels/file-explorer-panel.ts +0 -584
  47. package/src/panels/file-preview-panel.ts +0 -434
  48. package/src/panels/git-panel.ts +0 -638
  49. package/src/panels/sandbox-panel.ts +0 -283
  50. package/src/panels/symbol-outline-panel.ts +0 -486
  51. package/src/panels/worktree-panel.ts +0 -182
  52. package/src/panels/wrfc-panel.ts +0 -609
@@ -1,584 +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
- buildPanelLine,
13
- buildSearchInputLine,
14
- buildSelectablePanelLine,
15
- buildPanelWorkspace,
16
- resolveScrollablePanelSection,
17
- DEFAULT_PANEL_PALETTE,
18
- } 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';
27
-
28
- // ---------------------------------------------------------------------------
29
- // Constants
30
- // ---------------------------------------------------------------------------
31
-
32
- const MAX_DEPTH = 5;
33
-
34
- /** Directories / files to skip (gitignore-style). */
35
- const SKIP_NAMES = new Set([
36
- 'node_modules', '.git', '.svn', '.hg',
37
- 'dist', 'build', 'out', '.next', '.nuxt', '.output',
38
- '__pycache__', '.pytest_cache', '.mypy_cache',
39
- 'coverage', '.nyc_output',
40
- '.DS_Store', 'Thumbs.db',
41
- ]);
42
-
43
- const SKIP_PATTERNS: RegExp[] = [
44
- /^\..*\.sw[px]$/, // vim swap files
45
- ];
46
-
47
- // ---------------------------------------------------------------------------
48
- // File-type icons (single-char safe for TUI columns)
49
- // ---------------------------------------------------------------------------
50
- const EXT_ICONS: Record<string, string> = {
51
- ts: 'T', tsx: 'T', js: 'J', jsx: 'J',
52
- json: 'J', jsonc: 'J',
53
- md: 'M', mdx: 'M',
54
- css: 'S', scss: 'S', sass: 'S',
55
- html: 'H', htm: 'H',
56
- py: 'P', rb: 'R', go: 'G', rs: 'R',
57
- sh: '$', bash: '$', zsh: '$',
58
- yaml: 'Y', yml: 'Y', toml: 'C',
59
- lock: 'L', log: 'L',
60
- };
61
-
62
- function fileIcon(name: string): string {
63
- const ext = name.split('.').pop()?.toLowerCase() ?? '';
64
- return EXT_ICONS[ext] ?? 'f';
65
- }
66
-
67
- // ---------------------------------------------------------------------------
68
- // Tree node
69
- // ---------------------------------------------------------------------------
70
-
71
- interface TreeNode {
72
- path: string; // absolute path
73
- name: string; // display name
74
- isDir: boolean;
75
- depth: number;
76
- size: number; // bytes (0 for dirs)
77
- expanded: boolean;
78
- children: TreeNode[];
79
- /** Whether children have been loaded. */
80
- loaded: boolean;
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Colour palette
85
- // ---------------------------------------------------------------------------
86
- const CLR_DIR = '#00ffff'; // cyan — directories
87
- const CLR_FILE = '#e0e0e0'; // near-white — files
88
- const CLR_SIZE = '244'; // dim grey — sizes
89
- const CLR_CURSOR = '#1a2a3a'; // cursor background
90
- const CLR_CURSOR_FG = '#ffffff';
91
- const CLR_SEARCH_BG = '#2a1a3a';
92
- const CLR_SEARCH_FG = '#ff79c6';
93
- const CLR_TOGGLE = '244'; // ▶/▼ toggle arrows
94
- const CLR_ICON = '244'; // file type icon
95
-
96
- // ---------------------------------------------------------------------------
97
- // Helpers
98
- // ---------------------------------------------------------------------------
99
-
100
- function shouldSkip(name: string): boolean {
101
- if (SKIP_NAMES.has(name)) return true;
102
- for (const re of SKIP_PATTERNS) if (re.test(name)) return true;
103
- return false;
104
- }
105
-
106
- function formatSize(bytes: number): string {
107
- if (bytes < 1024) return `${bytes}B`;
108
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
109
- return `${(bytes / 1024 / 1024).toFixed(1)}M`;
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // FileExplorerPanel
114
- // ---------------------------------------------------------------------------
115
-
116
- export class FileExplorerPanel extends BasePanel {
117
- // --- tree state ---
118
- private root: TreeNode | null = null;
119
- private flat: TreeNode[] = []; // visible flattened list
120
- private rootPath: string;
121
- private readonly workingDirectory: string;
122
- private cacheValid: boolean = false;
123
- private readyPromise: Promise<void> | null = null;
124
-
125
- // --- navigation ---
126
- private cursor: number = 0;
127
- private scrollTop: number = 0;
128
-
129
- // --- search ---
130
- private searchMode: boolean = false;
131
- private searchQuery: string = '';
132
-
133
- constructor(rootPath: string | undefined, workingDirectory: string) {
134
- super('explorer', 'Explorer', 'E', 'development');
135
- this.workingDirectory = workingDirectory;
136
- this.rootPath = rootPath ?? workingDirectory;
137
- }
138
-
139
- // ── Lifecycle ──────────────────────────────────────────────────────────────
140
-
141
- override onActivate(): void {
142
- super.onActivate();
143
- if (!this.cacheValid) {
144
- void this._buildTreeAsync();
145
- }
146
- }
147
-
148
- override onDestroy(): void {
149
- this.root = null;
150
- this.flat = [];
151
- this.cacheValid = false;
152
- }
153
-
154
- // ── Public API ─────────────────────────────────────────────────────────────
155
-
156
- /** Force a full tree refresh from disk. */
157
- refresh(): void {
158
- this.cacheValid = false;
159
- void this._buildTreeAsync();
160
- }
161
-
162
- /** Currently focused node (or null). */
163
- getFocusedNode(): TreeNode | null {
164
- return this.flat[this.cursor] ?? null;
165
- }
166
-
167
- getFocusedFilePath(): string | null {
168
- const node = this.getFocusedNode();
169
- return node && !node.isDir ? node.path : null;
170
- }
171
-
172
- // ── Input ──────────────────────────────────────────────────────────────────
173
-
174
- handleInput(key: string): boolean {
175
- if (this.searchMode) return this._handleSearchInput(key);
176
-
177
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.cursor, itemCount: this.flat.length });
178
- if (transition === 'focus-search') {
179
- this._enterSearch();
180
- return true;
181
- }
182
-
183
- switch (key) {
184
- case 'up': case 'k': this._moveCursor(-1); return true;
185
- case 'down': case 'j': this._moveCursor(1); return true;
186
- case 'pageup': this._moveCursorPage(-1); return true;
187
- case 'pagedown': this._moveCursorPage(1); return true;
188
- case 'home': case 'g': this._setCursor(0); return true;
189
- case 'end': case 'G': this._setCursor(this.flat.length - 1); return true;
190
- case 'return': case 'enter': this._activateNode(); return true;
191
- case 'right': this._expandNode(); return true;
192
- case 'left': this._collapseNode(); return true;
193
- case '/': this._enterSearch(); return true;
194
- case 'r': this.refresh(); return true;
195
- default: return false;
196
- }
197
- }
198
-
199
- handleScroll(deltaRows: number): boolean {
200
- const rows = Math.trunc(deltaRows);
201
- if (this.flat.length === 0 || rows === 0) return false;
202
- const previous = this.cursor;
203
- this._setCursor(this.cursor + rows);
204
- return this.cursor !== previous;
205
- }
206
-
207
- // ── Render ─────────────────────────────────────────────────────────────────
208
-
209
- render(width: number, height: number): Line[] {
210
- this.needsRender = false;
211
- const searchLine = this.searchMode
212
- ? `/ ${this.searchQuery}_`
213
- : this.searchQuery
214
- ? `Filter: ${this.searchQuery} (/ or up at top to edit)`
215
- : `Root: ${relative(this.workingDirectory, this.rootPath) || '.'} (/ or up at top to search)`;
216
-
217
- if (this.flat.length === 0) {
218
- return buildPanelWorkspace(width, height, {
219
- title: ' Explorer',
220
- intro: 'Browse the project tree, expand directories, and search for paths.',
221
- sections: [
222
- {
223
- lines: buildEmptyState(
224
- width,
225
- ' No files found',
226
- this.searchQuery
227
- ? 'No files or directories match the current search.'
228
- : 'This root did not produce any visible files after the explorer filters were applied.',
229
- [],
230
- DEFAULT_PANEL_PALETTE,
231
- ),
232
- },
233
- ],
234
- footerLines: [
235
- buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
236
- active: this.searchMode,
237
- valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
238
- }),
239
- 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]]),
240
- ],
241
- palette: DEFAULT_PANEL_PALETTE,
242
- });
243
- }
244
-
245
- const summarySection = {
246
- title: 'Summary',
247
- lines: [
248
- buildPanelLine(width, [
249
- [' Visible ', DEFAULT_PANEL_PALETTE.label],
250
- [String(this.flat.length), DEFAULT_PANEL_PALETTE.value],
251
- [' Search ', DEFAULT_PANEL_PALETTE.label],
252
- [this.searchQuery || 'none', this.searchQuery ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
253
- ]),
254
- ],
255
- } as const;
256
- const selected = this.flat[this.cursor];
257
- const selectedSection = {
258
- title: 'Selected',
259
- lines: selected
260
- ? [
261
- buildPanelLine(width, [
262
- [' Name ', DEFAULT_PANEL_PALETTE.label],
263
- [selected.name, DEFAULT_PANEL_PALETTE.value],
264
- [' Type ', DEFAULT_PANEL_PALETTE.label],
265
- [selected.isDir ? 'directory' : 'file', selected.isDir ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.value],
266
- ]),
267
- buildPanelLine(width, [
268
- [' Path ', DEFAULT_PANEL_PALETTE.label],
269
- [selected.path, DEFAULT_PANEL_PALETTE.dim],
270
- ]),
271
- ]
272
- : [],
273
- } as const;
274
- const treeSection = resolveScrollablePanelSection(width, height, {
275
- intro: 'Browse the project tree, expand directories, and search for paths.',
276
- footerLines: [
277
- buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
278
- active: this.searchMode,
279
- valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
280
- }),
281
- 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]]),
282
- ],
283
- palette: DEFAULT_PANEL_PALETTE,
284
- beforeSections: [summarySection],
285
- section: {
286
- title: 'Tree',
287
- scrollableLines: this.flat.map((node, absoluteIdx) => {
288
- const isCursor = absoluteIdx === this.cursor;
289
- const baseBg = isCursor ? CLR_CURSOR : '';
290
- const baseFg = isCursor ? CLR_CURSOR_FG : (node.isDir ? CLR_DIR : CLR_FILE);
291
- const indent = ' '.repeat(node.depth);
292
- const segments = [
293
- { text: indent, fg: baseFg },
294
- node.isDir
295
- ? { text: node.expanded ? '▾ ' : '▸ ', fg: CLR_TOGGLE, bold: isCursor }
296
- : { text: `${fileIcon(node.name)} `, fg: CLR_ICON, dim: !isCursor },
297
- { text: node.name, fg: baseFg, bold: node.isDir || isCursor },
298
- ];
299
- if (!node.isDir && node.size > 0) {
300
- const sizeStr = ` ${formatSize(node.size)}`;
301
- const contentWidth = getDisplayWidth(indent) + 2 + getDisplayWidth(node.name);
302
- const gap = Math.max(1, width - contentWidth - getDisplayWidth(sizeStr));
303
- segments.push({ text: ' '.repeat(gap), fg: baseFg });
304
- segments.push({ text: sizeStr, fg: CLR_SIZE, dim: true });
305
- }
306
- return buildSelectablePanelLine(width, segments, { selected: isCursor, selectedBg: baseBg, fillFg: baseFg });
307
- }),
308
- selectedIndex: this.cursor,
309
- scrollOffset: this.scrollTop,
310
- minRows: 8,
311
- },
312
- afterSections: [selectedSection],
313
- });
314
- this.scrollTop = treeSection.scrollOffset;
315
- return buildPanelWorkspace(width, height, {
316
- title: ' Explorer',
317
- intro: 'Browse the project tree, expand directories, and search for paths.',
318
- sections: [
319
- summarySection,
320
- treeSection.section,
321
- selectedSection,
322
- ],
323
- 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
- }),
328
- 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]]),
329
- ],
330
- palette: DEFAULT_PANEL_PALETTE,
331
- });
332
- }
333
-
334
- // ── Private: tree building ─────────────────────────────────────────────────
335
-
336
- private _buildTreeAsync(): Promise<void> {
337
- const p = (async () => {
338
- try {
339
- await this.withLoading('Scanning directory\u2026', async () => {
340
- this.root = await this._scanDirAsync(this.rootPath, 0);
341
- this._rebuildFlat();
342
- this.cacheValid = true;
343
- });
344
- } catch (err) {
345
- this.setError(err instanceof Error ? err.message : String(err));
346
- }
347
- this.markDirty();
348
- })();
349
- this.readyPromise = p;
350
- return p;
351
- }
352
-
353
- /** Resolves when the current tree build has settled. */
354
- public awaitReady(): Promise<void> {
355
- return this.readyPromise ?? Promise.resolve();
356
- }
357
-
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;
372
-
373
- let entries: string[];
374
- try {
375
- entries = await fsPromises.readdir(dirPath);
376
- } catch {
377
- return node;
378
- }
379
-
380
- node.loaded = true;
381
-
382
- // Sort: dirs first, then files, alphabetically within each group
383
- const filtered = entries.filter(e => !shouldSkip(e));
384
- const statResults = await Promise.all(
385
- filtered.map(async (e) => {
386
- try {
387
- const s = await fsPromises.stat(join(dirPath, e));
388
- return { name: e, isDir: s.isDirectory(), size: s.size, stat: s };
389
- } catch {
390
- return { name: e, isDir: false, size: 0, stat: null };
391
- }
392
- }),
393
- );
394
-
395
- const sorted = statResults.sort((a, b) => {
396
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
397
- return a.name.localeCompare(b.name);
398
- });
399
-
400
- for (const entry of sorted) {
401
- 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
- });
416
- }
417
- }
418
-
419
- return node;
420
- }
421
-
422
- /**
423
- * Flatten the tree into a visible list based on expansion state
424
- * and the current search query.
425
- */
426
- private _rebuildFlat(): void {
427
- const q = this.searchQuery.trim().toLowerCase();
428
-
429
- if (q) {
430
- // In search mode: show all matching nodes (any depth), ignoring expand state
431
- const results: TreeNode[] = [];
432
- this._collectMatching(this.root, q, results);
433
- this.flat = results;
434
- } else {
435
- const rows: TreeNode[] = [];
436
- if (this.root) this._flatten(this.root, rows, /* skipSelf */ true);
437
- this.flat = rows;
438
- }
439
-
440
- // Clamp cursor
441
- if (this.cursor >= this.flat.length) {
442
- this.cursor = Math.max(0, this.flat.length - 1);
443
- }
444
- }
445
-
446
- private _flatten(node: TreeNode, out: TreeNode[], skipSelf: boolean): void {
447
- if (!skipSelf) out.push(node);
448
- if ((skipSelf || node.expanded) && node.children.length > 0) {
449
- for (const child of node.children) {
450
- this._flatten(child, out, false);
451
- }
452
- }
453
- }
454
-
455
- private _collectMatching(node: TreeNode | null, q: string, out: TreeNode[]): void {
456
- if (!node) return;
457
- if (node.name.toLowerCase().includes(q)) out.push(node);
458
- for (const child of node.children) this._collectMatching(child, q, out);
459
- }
460
-
461
- // ── Private: navigation ───────────────────────────────────────────────────
462
-
463
- private _moveCursor(delta: number): void {
464
- this._setCursor(this.cursor + delta);
465
- }
466
-
467
- private _moveCursorPage(direction: 1 | -1, pageSize = 10): void {
468
- this._setCursor(this.cursor + direction * pageSize);
469
- }
470
-
471
- private _setCursor(idx: number): void {
472
- this.cursor = Math.max(0, Math.min(idx, this.flat.length - 1));
473
- this.markDirty();
474
- }
475
-
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
- private _activateNode(): void {
486
- const node = this.flat[this.cursor];
487
- if (!node) return;
488
- if (node.isDir) {
489
- node.expanded = !node.expanded;
490
- this._rebuildFlat();
491
- this.markDirty();
492
- }
493
- // For files: callers can read getFocusedNode() after the input returns true
494
- }
495
-
496
- private _expandNode(): void {
497
- const node = this.flat[this.cursor];
498
- if (!node || !node.isDir || node.expanded) return;
499
- node.expanded = true;
500
- this._rebuildFlat();
501
- this.markDirty();
502
- }
503
-
504
- private _collapseNode(): void {
505
- const node = this.flat[this.cursor];
506
- if (!node) return;
507
- if (node.isDir && node.expanded) {
508
- node.expanded = false;
509
- this._rebuildFlat();
510
- this.markDirty();
511
- } else if (!node.isDir || !node.expanded) {
512
- // Jump to parent dir
513
- const parent = this._findParent(node);
514
- if (parent) {
515
- const idx = this.flat.indexOf(parent);
516
- if (idx >= 0) this._setCursor(idx);
517
- }
518
- }
519
- }
520
-
521
- private _findParent(node: TreeNode): TreeNode | null {
522
- return this._findParentIn(this.root, node);
523
- }
524
-
525
- private _findParentIn(candidate: TreeNode | null, target: TreeNode): TreeNode | null {
526
- if (!candidate) return null;
527
- for (const child of candidate.children) {
528
- if (child === target) return candidate;
529
- const found = this._findParentIn(child, target);
530
- if (found) return found;
531
- }
532
- return null;
533
- }
534
-
535
- // ── Private: search ───────────────────────────────────────────────────────
536
-
537
- private _enterSearch(): void {
538
- this.searchMode = true;
539
- this.searchQuery = '';
540
- this._rebuildFlat();
541
- this.markDirty();
542
- }
543
-
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;
583
- }
584
- }