@pellux/goodvibes-tui 0.28.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 (118) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/input/commands/cost-runtime.ts +49 -0
  5. package/src/input/commands/operator-runtime.ts +5 -1
  6. package/src/input/commands.ts +2 -0
  7. package/src/input/handler-feed-routes.ts +12 -35
  8. package/src/input/handler-feed.ts +4 -8
  9. package/src/input/handler-shortcuts.ts +51 -0
  10. package/src/input/handler.ts +43 -6
  11. package/src/input/keybindings.ts +48 -8
  12. package/src/input/panel-integration-actions.ts +107 -1
  13. package/src/main.ts +5 -1
  14. package/src/panels/agent-inspector-panel.ts +125 -36
  15. package/src/panels/agent-inspector-shared.ts +144 -0
  16. package/src/panels/approval-panel.ts +67 -16
  17. package/src/panels/automation-control-panel.ts +368 -124
  18. package/src/panels/builtin/agent.ts +28 -37
  19. package/src/panels/builtin/development.ts +40 -32
  20. package/src/panels/builtin/knowledge.ts +15 -6
  21. package/src/panels/builtin/operations.ts +178 -117
  22. package/src/panels/builtin/session.ts +35 -9
  23. package/src/panels/builtin/shared.ts +98 -6
  24. package/src/panels/cockpit-panel.ts +232 -73
  25. package/src/panels/communication-panel.ts +58 -20
  26. package/src/panels/confirm-state.ts +8 -1
  27. package/src/panels/control-plane-panel.ts +351 -118
  28. package/src/panels/cost-tracker-panel.ts +165 -7
  29. package/src/panels/debug-panel.ts +312 -159
  30. package/src/panels/diff-panel.ts +203 -57
  31. package/src/panels/docs-panel.ts +152 -66
  32. package/src/panels/eval-panel.ts +220 -42
  33. package/src/panels/file-explorer-panel.ts +202 -122
  34. package/src/panels/file-preview-panel.ts +132 -57
  35. package/src/panels/git-panel.ts +309 -128
  36. package/src/panels/hooks-panel.ts +150 -22
  37. package/src/panels/incident-review-panel.ts +223 -35
  38. package/src/panels/index.ts +0 -4
  39. package/src/panels/intelligence-panel.ts +212 -86
  40. package/src/panels/knowledge-graph-panel.ts +461 -101
  41. package/src/panels/local-auth-panel.ts +240 -28
  42. package/src/panels/marketplace-panel.ts +193 -26
  43. package/src/panels/memory-panel.ts +78 -77
  44. package/src/panels/ops-control-panel.ts +146 -29
  45. package/src/panels/ops-strategy-panel.ts +72 -4
  46. package/src/panels/orchestration-panel.ts +231 -69
  47. package/src/panels/panel-list-panel.ts +154 -131
  48. package/src/panels/panel-manager.ts +97 -9
  49. package/src/panels/plan-dashboard-panel.ts +333 -17
  50. package/src/panels/plugins-panel.ts +184 -29
  51. package/src/panels/policy-panel.ts +210 -38
  52. package/src/panels/polish-core.ts +7 -2
  53. package/src/panels/polish.ts +23 -4
  54. package/src/panels/project-planning-answer-actions.ts +134 -0
  55. package/src/panels/project-planning-panel.ts +62 -113
  56. package/src/panels/provider-health-panel.ts +434 -518
  57. package/src/panels/provider-health-routes.ts +203 -0
  58. package/src/panels/provider-health-tracker.ts +194 -6
  59. package/src/panels/provider-health-views.ts +560 -0
  60. package/src/panels/qr-panel.ts +116 -30
  61. package/src/panels/remote-panel.ts +114 -36
  62. package/src/panels/routes-panel.ts +63 -22
  63. package/src/panels/sandbox-panel.ts +174 -42
  64. package/src/panels/scrollable-list-panel.ts +19 -135
  65. package/src/panels/security-panel.ts +133 -33
  66. package/src/panels/services-panel.ts +116 -64
  67. package/src/panels/session-browser-panel.ts +73 -2
  68. package/src/panels/session-maintenance.ts +4 -122
  69. package/src/panels/settings-sync-panel.ts +335 -72
  70. package/src/panels/skills-panel.ts +157 -89
  71. package/src/panels/subscription-panel.ts +86 -33
  72. package/src/panels/symbol-outline-panel.ts +248 -108
  73. package/src/panels/system-messages-panel.ts +114 -13
  74. package/src/panels/tasks-panel.ts +326 -139
  75. package/src/panels/thinking-panel.ts +43 -10
  76. package/src/panels/token-budget-panel.ts +194 -18
  77. package/src/panels/tool-inspector-panel.ts +144 -34
  78. package/src/panels/types.ts +40 -4
  79. package/src/panels/work-plan-panel.ts +280 -17
  80. package/src/panels/worktree-panel.ts +175 -42
  81. package/src/panels/wrfc-panel.ts +116 -24
  82. package/src/renderer/conversation-overlays.ts +25 -11
  83. package/src/renderer/footer-tips.ts +41 -0
  84. package/src/renderer/fullscreen-primitives.ts +22 -16
  85. package/src/renderer/help-overlay.ts +91 -14
  86. package/src/renderer/hint-grammar.ts +52 -0
  87. package/src/renderer/layout.ts +7 -7
  88. package/src/renderer/modal-factory.ts +23 -15
  89. package/src/renderer/model-picker-overlay.ts +21 -7
  90. package/src/renderer/overlay-box.ts +16 -10
  91. package/src/renderer/process-indicator.ts +3 -1
  92. package/src/renderer/progress.ts +5 -4
  93. package/src/renderer/search-overlay.ts +27 -6
  94. package/src/renderer/session-picker-modal.ts +6 -4
  95. package/src/renderer/settings-modal.ts +70 -10
  96. package/src/renderer/shell-surface.ts +41 -15
  97. package/src/renderer/status-glyphs.ts +11 -9
  98. package/src/renderer/theme.ts +60 -3
  99. package/src/renderer/ui-factory.ts +41 -30
  100. package/src/renderer/ui-primitives.ts +23 -2
  101. package/src/runtime/bootstrap-shell.ts +35 -18
  102. package/src/runtime/diagnostics/panels/index.ts +0 -3
  103. package/src/shell/ui-openers.ts +14 -22
  104. package/src/utils/format-duration.ts +2 -2
  105. package/src/utils/splash-lines.ts +44 -3
  106. package/src/version.ts +1 -1
  107. package/src/work-plans/work-plan-store.ts +13 -0
  108. package/src/panels/agent-logs-panel.ts +0 -635
  109. package/src/panels/agent-logs-shared.ts +0 -129
  110. package/src/panels/context-visualizer-panel.ts +0 -238
  111. package/src/panels/forensics-panel.ts +0 -378
  112. package/src/panels/provider-account-snapshot.ts +0 -259
  113. package/src/panels/provider-accounts-panel.ts +0 -255
  114. package/src/panels/provider-stats-panel.ts +0 -391
  115. package/src/panels/schedule-panel.ts +0 -365
  116. package/src/panels/watchers-panel.ts +0 -213
  117. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  118. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,5 +1,4 @@
1
1
  import { BasePanel } from './base-panel.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
2
  import type { Line } from '../types/grid.ts';
4
3
  import {
5
4
  buildEmptyState,
@@ -11,6 +10,9 @@ import {
11
10
  DEFAULT_PANEL_PALETTE,
12
11
  extendPalette,
13
12
  } from './polish.ts';
13
+ import { TreeSitterService } from '@pellux/goodvibes-sdk/platform/intelligence';
14
+ import type { Node, Tree, Language } from 'web-tree-sitter';
15
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
14
16
 
15
17
  // ── Symbol types ────────────────────────────────────────────────────────────
16
18
 
@@ -52,32 +54,198 @@ const KIND_ICONS: Record<SymbolKind, string> = {
52
54
  const: 'k',
53
55
  };
54
56
 
55
- /** Regex patterns to extract symbols. Each produces named groups: kind, name, line. */
56
- const SYMBOL_PATTERNS: Array<{ re: RegExp; kind: SymbolKind; isContainer?: boolean }> = [
57
- // export class Foo / abstract class Foo
58
- { re: /^(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/, kind: 'class', isContainer: true },
59
- // export namespace Foo
60
- { re: /^(?:export\s+)?namespace\s+(\w+)/, kind: 'namespace', isContainer: true },
61
- // export interface Foo
62
- { re: /^(?:export\s+)?interface\s+(\w+)/, kind: 'interface' },
63
- // export type Foo =
64
- { re: /^(?:export\s+)?type\s+(\w+)\s*[=<{]/, kind: 'type' },
65
- // export function foo / export async function foo
66
- { re: /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/, kind: 'function' },
67
- // export const foo = ... (function expressions / arrow fns / values)
68
- { re: /^(?:export\s+)?const\s+(\w+)\s*(?::[^=]*)?=\s*(?:async\s+)?(?:function|\(|\w+\s*=>)/, kind: 'function' },
69
- // export const foo = <non-function>
70
- { re: /^(?:export\s+)?const\s+(\w+)\s*(?::[^=]*)?=/, kind: 'const' },
71
- // methods inside class: indented methodName(...)
72
- { re: /^\s{2,}(?:(?:public|private|protected|static|async|override|readonly|abstract)\s+)*(\w+)\s*\(/, kind: 'method' },
73
- ];
57
+ // ── Tree-sitter symbol extraction ────────────────────────────────────────────
58
+
59
+ type LangId = 'typescript' | 'tsx' | 'javascript';
60
+
61
+ /** Map a file extension to the tree-sitter grammar id used to parse it. */
62
+ function detectLangId(filePath: string): LangId | null {
63
+ const dot = filePath.lastIndexOf('.');
64
+ const ext = dot >= 0 ? filePath.slice(dot + 1).toLowerCase() : '';
65
+ if (ext === 'ts' || ext === 'mts' || ext === 'cts') return 'typescript';
66
+ if (ext === 'tsx') return 'tsx';
67
+ if (ext === 'js' || ext === 'mjs' || ext === 'cjs' || ext === 'jsx') return 'javascript';
68
+ return null;
69
+ }
70
+
71
+ // Tree-sitter queries capturing the declaration shapes the outline surfaces.
72
+ // Class members (methods, getters/setters, decorated members, and
73
+ // arrow-function class fields) are captured structurally rather than by
74
+ // indentation, so decorators and modifiers never throw off matching.
75
+ const TS_QUERY = `
76
+ (class_declaration name: (type_identifier) @class.name) @class.def
77
+ (abstract_class_declaration name: (type_identifier) @class.name) @class.def
78
+ (interface_declaration name: (type_identifier) @interface.name) @interface.def
79
+ (type_alias_declaration name: (type_identifier) @type.name) @type.def
80
+ (internal_module name: (identifier) @namespace.name) @namespace.def
81
+ (function_declaration name: (identifier) @function.name) @function.def
82
+ (method_definition name: (property_identifier) @method.name) @method.def
83
+ (public_field_definition
84
+ name: (property_identifier) @field.name
85
+ value: (arrow_function)) @field.def
86
+ (lexical_declaration
87
+ (variable_declarator name: (identifier) @const.name) @const.declarator) @const.def
88
+ `;
89
+
90
+ const JS_QUERY = `
91
+ (class_declaration name: (identifier) @class.name) @class.def
92
+ (function_declaration name: (identifier) @function.name) @function.def
93
+ (method_definition name: (property_identifier) @method.name) @method.def
94
+ (field_definition
95
+ property: (property_identifier) @field.name
96
+ value: (arrow_function)) @field.def
97
+ (lexical_declaration
98
+ (variable_declarator name: (identifier) @const.name) @const.declarator) @const.def
99
+ `;
100
+
101
+ const QUERY_BY_LANG: Record<LangId, string> = {
102
+ typescript: TS_QUERY,
103
+ tsx: TS_QUERY,
104
+ javascript: JS_QUERY,
105
+ };
106
+
107
+ const CLASS_NODE_TYPES = new Set(['class_declaration', 'abstract_class_declaration', 'class_expression']);
108
+
109
+ /** True when `node` sits directly at module scope (optionally wrapped in `export`). */
110
+ function isTopLevelDeclaration(node: Node): boolean {
111
+ const parent = node.parent;
112
+ if (!parent) return true;
113
+ if (parent.type === 'program') return true;
114
+ if (parent.type === 'export_statement') return parent.parent?.type === 'program';
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Walk up from a class member to its nearest enclosing class. Returns the
120
+ * class name only when that class is itself a top-level declaration — this
121
+ * keeps the outline to a class + its direct members (no deep nesting), the
122
+ * same depth the previous regex-based parser supported.
123
+ */
124
+ function findEnclosingClassName(node: Node): string | null {
125
+ let cursor: Node | null = node.parent;
126
+ while (cursor) {
127
+ if (CLASS_NODE_TYPES.has(cursor.type)) {
128
+ if (!isTopLevelDeclaration(cursor)) return null;
129
+ const nameNode = cursor.childForFieldName('name');
130
+ return nameNode ? nameNode.text : null;
131
+ }
132
+ cursor = cursor.parent;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Extract a flat, line-ordered list of symbols from a parsed tree using a
139
+ * tree-sitter query over the real AST (no regex heuristics). Class members
140
+ * (methods, getters/setters, decorated members, arrow-function fields) carry
141
+ * `parentName` so the renderer can group them under their class header.
142
+ */
143
+ function extractSymbolsFromTree(tree: Tree, language: Language, langId: LangId, service: TreeSitterService): SymbolEntry[] {
144
+ const query = QUERY_BY_LANG[langId];
145
+ const matches = service.query(tree, language, query);
146
+ const result: SymbolEntry[] = [];
147
+
148
+ for (const match of matches) {
149
+ const captures = new Map<string, Node>(match.captures.map((c) => [c.name, c.node]));
150
+
151
+ const classDef = captures.get('class.def');
152
+ if (classDef) {
153
+ const nameNode = captures.get('class.name');
154
+ if (nameNode && isTopLevelDeclaration(classDef)) {
155
+ result.push({ kind: 'class', name: nameNode.text, line: classDef.startPosition.row + 1 });
156
+ }
157
+ continue;
158
+ }
159
+
160
+ const interfaceDef = captures.get('interface.def');
161
+ if (interfaceDef) {
162
+ const nameNode = captures.get('interface.name');
163
+ if (nameNode && isTopLevelDeclaration(interfaceDef)) {
164
+ result.push({ kind: 'interface', name: nameNode.text, line: interfaceDef.startPosition.row + 1 });
165
+ }
166
+ continue;
167
+ }
168
+
169
+ const typeDef = captures.get('type.def');
170
+ if (typeDef) {
171
+ const nameNode = captures.get('type.name');
172
+ if (nameNode && isTopLevelDeclaration(typeDef)) {
173
+ result.push({ kind: 'type', name: nameNode.text, line: typeDef.startPosition.row + 1 });
174
+ }
175
+ continue;
176
+ }
177
+
178
+ const namespaceDef = captures.get('namespace.def');
179
+ if (namespaceDef) {
180
+ const nameNode = captures.get('namespace.name');
181
+ if (nameNode && isTopLevelDeclaration(namespaceDef)) {
182
+ result.push({ kind: 'namespace', name: nameNode.text, line: namespaceDef.startPosition.row + 1 });
183
+ }
184
+ continue;
185
+ }
186
+
187
+ const functionDef = captures.get('function.def');
188
+ if (functionDef) {
189
+ const nameNode = captures.get('function.name');
190
+ if (nameNode && isTopLevelDeclaration(functionDef)) {
191
+ result.push({ kind: 'function', name: nameNode.text, line: functionDef.startPosition.row + 1 });
192
+ }
193
+ continue;
194
+ }
195
+
196
+ // Methods, getters/setters, and constructors — all `method_definition`
197
+ // regardless of decorators (decorators are preceding siblings, not
198
+ // wrappers, so they never block the match).
199
+ const methodDef = captures.get('method.def');
200
+ if (methodDef) {
201
+ const nameNode = captures.get('method.name');
202
+ const parentName = findEnclosingClassName(methodDef);
203
+ if (nameNode && parentName) {
204
+ result.push({ kind: 'method', name: nameNode.text, line: methodDef.startPosition.row + 1, parentName });
205
+ }
206
+ continue;
207
+ }
208
+
209
+ // Arrow-function class fields (`onClick = () => {...}`), decorated or not.
210
+ const fieldDef = captures.get('field.def');
211
+ if (fieldDef) {
212
+ const nameNode = captures.get('field.name');
213
+ const parentName = findEnclosingClassName(fieldDef);
214
+ if (nameNode && parentName) {
215
+ result.push({ kind: 'method', name: nameNode.text, line: fieldDef.startPosition.row + 1, parentName });
216
+ }
217
+ continue;
218
+ }
219
+
220
+ const constDef = captures.get('const.def');
221
+ if (constDef) {
222
+ const nameNode = captures.get('const.name');
223
+ const declarator = captures.get('const.declarator');
224
+ if (nameNode && isTopLevelDeclaration(constDef)) {
225
+ const valueNode = declarator?.childForFieldName('value') ?? null;
226
+ const isFunctionValued = valueNode?.type === 'arrow_function' || valueNode?.type === 'function_expression';
227
+ result.push({
228
+ kind: isFunctionValued ? 'function' : 'const',
229
+ name: nameNode.text,
230
+ line: constDef.startPosition.row + 1,
231
+ });
232
+ }
233
+ continue;
234
+ }
235
+ }
236
+
237
+ result.sort((a, b) => a.line - b.line);
238
+ return result;
239
+ }
74
240
 
75
241
  // ── Panel ────────────────────────────────────────────────────────────────────
76
242
 
77
243
  /**
78
244
  * SymbolOutlinePanel — renders a hierarchical symbol outline of the current
79
- * file. Symbols are parsed from source text using lightweight regex heuristics
80
- * (no tree-sitter or LSP required).
245
+ * file. Symbols are parsed from the real AST via a tree-sitter query (the
246
+ * same parser infrastructure syntax-highlighter.ts uses), not regex
247
+ * heuristics — so class fields assigned arrow functions, getters/setters,
248
+ * and decorated members all appear correctly.
81
249
  */
82
250
  export class SymbolOutlinePanel extends BasePanel {
83
251
  /** Flat list of parsed symbols (methods nested after their parent class). */
@@ -86,6 +254,17 @@ export class SymbolOutlinePanel extends BasePanel {
86
254
  /** Index of the currently highlighted row in the visible flat list. */
87
255
  private selectedIndex: number = 0;
88
256
 
257
+ /**
258
+ * The row under the cursor. This panel owns its own selection state
259
+ * (`selectedIndex` navigates the filtered `_visibleRows()` flat list), so
260
+ * every selected-row read routes through this one accessor — indexing the
261
+ * `_visibleRows()` list by the cursor directly is banned by the
262
+ * no-raw-selectedindex-read architecture rule.
263
+ */
264
+ private selectedRow(): VisibleRow | undefined {
265
+ return this._visibleRows().at(this.selectedIndex);
266
+ }
267
+
89
268
  /** Set of container names (class/namespace) that are collapsed. */
90
269
  private collapsed: Set<string> = new Set();
91
270
 
@@ -95,23 +274,40 @@ export class SymbolOutlinePanel extends BasePanel {
95
274
  /** Path of the file currently loaded. */
96
275
  private currentPath: string = '';
97
276
 
277
+ /** Shared tree-sitter service for this panel instance (mirrors syntax-highlighter.ts). */
278
+ private readonly treeSitter = new TreeSitterService();
279
+
280
+ /** Bumped on every loadFile() call so a superseded async parse is discarded. */
281
+ private parseGeneration: number = 0;
282
+
98
283
  constructor() {
99
284
  super('symbols', 'Symbols', 'S', 'development');
285
+ this.treeSitter.initialize().catch((err: unknown) => {
286
+ logger.warn('SymbolOutlinePanel: tree-sitter init failed', { error: summarizeError(err) });
287
+ });
100
288
  }
101
289
 
102
290
  // ── Public API ─────────────────────────────────────────────────────────────
103
291
 
104
292
  /**
105
- * Load and parse symbols from the given file source text.
106
- * Call this when the active file changes in the file-preview panel.
293
+ * Load and parse symbols from the given file source text via tree-sitter.
294
+ * Call this when the active file changes in the file-preview panel, and
295
+ * again whenever the preview reloads the same file so the outline re-syncs
296
+ * with on-disk edits.
107
297
  */
108
298
  loadFile(path: string, source: string): void {
109
299
  this.currentPath = path;
110
- this.symbols = parseSymbols(source);
300
+ this.symbols = [];
111
301
  this.selectedIndex = 0;
112
302
  this.scrollOffset = 0;
113
303
  this.collapsed.clear();
114
304
  this.markDirty();
305
+
306
+ const generation = ++this.parseGeneration;
307
+ const langId = detectLangId(path);
308
+ if (!langId) return; // unsupported language — honest "no symbols" empty state
309
+
310
+ void this._parseAndSync(path, source, langId, generation);
115
311
  }
116
312
 
117
313
  /**
@@ -119,8 +315,7 @@ export class SymbolOutlinePanel extends BasePanel {
119
315
  * caller can jump to it in the file-preview panel.
120
316
  */
121
317
  getSelectedLocation(): { path: string; line: number } | null {
122
- const visible = this._visibleRows();
123
- const row = visible[this.selectedIndex];
318
+ const row = this.selectedRow();
124
319
  if (!row || row.kind === 'header') return null;
125
320
  return { path: this.currentPath, line: row.symbol.line };
126
321
  }
@@ -149,12 +344,13 @@ export class SymbolOutlinePanel extends BasePanel {
149
344
  }
150
345
 
151
346
  if (key === 'return' || key === 'enter') {
152
- // Caller should call getSelectedLocation() after this returns true.
153
- return true;
347
+ // Only consume Enter when there is a real symbol to jump to — otherwise
348
+ // let the key fall through instead of swallowing it for nothing.
349
+ return this.getSelectedLocation() !== null;
154
350
  }
155
351
 
156
352
  if (key === 'space' || key === 'right' || key === 'left') {
157
- const row = visible[this.selectedIndex];
353
+ const row = this.selectedRow();
158
354
  if (row?.kind === 'header') {
159
355
  const name = row.name;
160
356
  if (this.collapsed.has(name)) {
@@ -191,11 +387,11 @@ export class SymbolOutlinePanel extends BasePanel {
191
387
  width,
192
388
  this.currentPath ? ' No symbols found' : ' No file loaded',
193
389
  this.currentPath
194
- ? 'The current file did not produce outline entries with the lightweight parser heuristics.'
390
+ ? 'The current file did not produce outline entries from the tree-sitter parse.'
195
391
  : 'Load a file in the preview panel to populate its outline here.',
196
392
  this.currentPath
197
393
  ? []
198
- : [{ command: '/explorer', summary: 'pick a file in the explorer, then Enter to preview and outline it' }],
394
+ : [{ command: '/panel open explorer', summary: 'pick a file in the explorer, then Enter to preview and outline it' }],
199
395
  C,
200
396
  ),
201
397
  },
@@ -241,7 +437,7 @@ export class SymbolOutlinePanel extends BasePanel {
241
437
  {
242
438
  title: 'Selected',
243
439
  lines: (() => {
244
- const selected = visible[this.selectedIndex];
440
+ const selected = this.selectedRow();
245
441
  return selected
246
442
  ? [
247
443
  buildPanelLine(width, [
@@ -262,7 +458,7 @@ export class SymbolOutlinePanel extends BasePanel {
262
458
  });
263
459
  this.scrollOffset = outlineSection.scrollOffset;
264
460
 
265
- const selected = visible[this.selectedIndex];
461
+ const selected = this.selectedRow();
266
462
  return buildPanelWorkspace(width, height, {
267
463
  title: ' Symbols',
268
464
  intro: this.currentPath ? this.currentPath : 'Outline the current file into navigable symbols and lightweight parent/child structure.',
@@ -306,6 +502,24 @@ export class SymbolOutlinePanel extends BasePanel {
306
502
 
307
503
  // ── Private helpers ────────────────────────────────────────────────────────
308
504
 
505
+ private async _parseAndSync(path: string, source: string, langId: LangId, generation: number): Promise<void> {
506
+ try {
507
+ await this.treeSitter.initialize();
508
+ const language = await this.treeSitter.loadLanguage(langId);
509
+ if (!language) return;
510
+ const tree = await this.treeSitter.parse(path, source, langId);
511
+ if (!tree) return;
512
+ // A newer loadFile() call superseded this one — discard the stale result.
513
+ if (generation !== this.parseGeneration) return;
514
+ this.symbols = extractSymbolsFromTree(tree, language, langId, this.treeSitter);
515
+ this.selectedIndex = 0;
516
+ this.scrollOffset = 0;
517
+ this.markDirty();
518
+ } catch (err) {
519
+ logger.warn('SymbolOutlinePanel: tree-sitter parse failed', { path, error: summarizeError(err) });
520
+ }
521
+ }
522
+
309
523
  private _clampScroll(totalRows: number): void {
310
524
  // Ensure selected is within scroll view (assumes last known height ~ 20)
311
525
  // We keep a conservative viewport window; render() uses this.scrollOffset.
@@ -329,80 +543,6 @@ type VisibleRow =
329
543
 
330
544
  // ── Pure helpers ─────────────────────────────────────────────────────────────
331
545
 
332
- /**
333
- * Parse symbols from source text. Returns a flat list ordered by line number.
334
- * Methods are tagged with their parent class name so the renderer can group them.
335
- */
336
- function parseSymbols(source: string): SymbolEntry[] {
337
- const lines = source.split('\n');
338
- const result: SymbolEntry[] = [];
339
- let currentContainer: string | undefined;
340
- let containerKind: SymbolKind | undefined;
341
- let containerBraceDepth = 0;
342
- let braceDepth = 0;
343
-
344
- for (let i = 0; i < lines.length; i++) {
345
- const raw = lines[i];
346
- const trimmed = raw.trimStart();
347
- const lineNo = i + 1; // 1-based
348
-
349
- // Strip string literals before counting braces to avoid false positives
350
- const rawNoBraceStrings = raw.replace(/("|\'|\`)(?:(?!\1|\\).|\\[\s\S])*\1/g, '');
351
-
352
- // Track brace depth for container scoping
353
- for (const ch of rawNoBraceStrings) {
354
- if (ch === '{') braceDepth++;
355
- else if (ch === '}') {
356
- braceDepth--;
357
- if (currentContainer !== undefined && braceDepth <= containerBraceDepth) {
358
- currentContainer = undefined;
359
- containerKind = undefined;
360
- containerBraceDepth = 0;
361
- }
362
- }
363
- }
364
-
365
- // Skip comment lines and blank lines
366
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed === '') continue;
367
-
368
- // Check container-level patterns first (class/namespace)
369
- let matched = false;
370
- for (const { re, kind, isContainer } of SYMBOL_PATTERNS) {
371
- const m = trimmed.match(re);
372
- if (!m) continue;
373
- const name = m[1];
374
- if (!name) continue;
375
-
376
- const entry: SymbolEntry = { kind, name, line: lineNo };
377
-
378
- if (currentContainer && kind === 'method') {
379
- entry.parentName = currentContainer;
380
- }
381
-
382
- // Don't add methods as top-level if they're inside a container
383
- if (kind === 'method' && !currentContainer) {
384
- // standalone function-like at wrong indent — skip
385
- matched = true;
386
- break;
387
- }
388
-
389
- result.push(entry);
390
-
391
- if (isContainer) {
392
- currentContainer = name;
393
- containerKind = kind;
394
- // Opening brace may be on this line or a subsequent line — track from current depth
395
- containerBraceDepth = braceDepth - (raw.includes('{') ? 1 : 0);
396
- }
397
-
398
- matched = true;
399
- break;
400
- }
401
- }
402
-
403
- return result;
404
- }
405
-
406
546
  /**
407
547
  * Build the flat list of rows to render, respecting collapse state.
408
548
  * Container symbols become header rows; their children are indented below.