@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
@@ -1,15 +1,18 @@
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,
5
+ buildKeyboardHints,
6
6
  buildPanelLine,
7
- buildSelectablePanelLine,
7
+ buildTreeRow,
8
8
  buildPanelWorkspace,
9
9
  resolveScrollablePanelSection,
10
10
  DEFAULT_PANEL_PALETTE,
11
+ extendPalette,
11
12
  } from './polish.ts';
12
- import { getDisplayWidth } from '../utils/terminal-width.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';
13
16
 
14
17
  // ── Symbol types ────────────────────────────────────────────────────────────
15
18
 
@@ -25,6 +28,10 @@ export interface SymbolEntry {
25
28
 
26
29
  // ── Rendering constants ──────────────────────────────────────────────────────
27
30
 
31
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
32
+ selectedBg: '236',
33
+ });
34
+
28
35
  /** ANSI 256-color fg codes per symbol kind. */
29
36
  const KIND_COLORS: Record<SymbolKind, string> = {
30
37
  function: '87', // cyan
@@ -36,43 +43,209 @@ const KIND_COLORS: Record<SymbolKind, string> = {
36
43
  const: '245', // grey
37
44
  };
38
45
 
39
- /** Short type indicator labels. */
40
- const KIND_LABELS: Record<SymbolKind, string> = {
41
- function: 'fn ',
42
- method: 'fn ',
43
- class: 'cls',
44
- namespace: 'ns ',
45
- interface: 'int',
46
- type: 'typ',
47
- const: 'cst',
46
+ /** Single-char type icon for the tree row (kept ASCII for column safety). */
47
+ const KIND_ICONS: Record<SymbolKind, string> = {
48
+ function: 'ƒ',
49
+ method: 'ƒ',
50
+ class: 'C',
51
+ namespace: 'N',
52
+ interface: 'I',
53
+ type: 'T',
54
+ const: 'k',
48
55
  };
49
56
 
50
- /** Regex patterns to extract symbols. Each produces named groups: kind, name, line. */
51
- const SYMBOL_PATTERNS: Array<{ re: RegExp; kind: SymbolKind; isContainer?: boolean }> = [
52
- // export class Foo / abstract class Foo
53
- { re: /^(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/, kind: 'class', isContainer: true },
54
- // export namespace Foo
55
- { re: /^(?:export\s+)?namespace\s+(\w+)/, kind: 'namespace', isContainer: true },
56
- // export interface Foo
57
- { re: /^(?:export\s+)?interface\s+(\w+)/, kind: 'interface' },
58
- // export type Foo =
59
- { re: /^(?:export\s+)?type\s+(\w+)\s*[=<{]/, kind: 'type' },
60
- // export function foo / export async function foo
61
- { re: /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/, kind: 'function' },
62
- // export const foo = ... (function expressions / arrow fns / values)
63
- { re: /^(?:export\s+)?const\s+(\w+)\s*(?::[^=]*)?=\s*(?:async\s+)?(?:function|\(|\w+\s*=>)/, kind: 'function' },
64
- // export const foo = <non-function>
65
- { re: /^(?:export\s+)?const\s+(\w+)\s*(?::[^=]*)?=/, kind: 'const' },
66
- // methods inside class: indented methodName(...)
67
- { re: /^\s{2,}(?:(?:public|private|protected|static|async|override|readonly|abstract)\s+)*(\w+)\s*\(/, kind: 'method' },
68
- ];
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
+ }
69
240
 
70
241
  // ── Panel ────────────────────────────────────────────────────────────────────
71
242
 
72
243
  /**
73
244
  * SymbolOutlinePanel — renders a hierarchical symbol outline of the current
74
- * file. Symbols are parsed from source text using lightweight regex heuristics
75
- * (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.
76
249
  */
77
250
  export class SymbolOutlinePanel extends BasePanel {
78
251
  /** Flat list of parsed symbols (methods nested after their parent class). */
@@ -81,6 +254,17 @@ export class SymbolOutlinePanel extends BasePanel {
81
254
  /** Index of the currently highlighted row in the visible flat list. */
82
255
  private selectedIndex: number = 0;
83
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
+
84
268
  /** Set of container names (class/namespace) that are collapsed. */
85
269
  private collapsed: Set<string> = new Set();
86
270
 
@@ -90,23 +274,40 @@ export class SymbolOutlinePanel extends BasePanel {
90
274
  /** Path of the file currently loaded. */
91
275
  private currentPath: string = '';
92
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
+
93
283
  constructor() {
94
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
+ });
95
288
  }
96
289
 
97
290
  // ── Public API ─────────────────────────────────────────────────────────────
98
291
 
99
292
  /**
100
- * Load and parse symbols from the given file source text.
101
- * 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.
102
297
  */
103
298
  loadFile(path: string, source: string): void {
104
299
  this.currentPath = path;
105
- this.symbols = parseSymbols(source);
300
+ this.symbols = [];
106
301
  this.selectedIndex = 0;
107
302
  this.scrollOffset = 0;
108
303
  this.collapsed.clear();
109
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);
110
311
  }
111
312
 
112
313
  /**
@@ -114,8 +315,7 @@ export class SymbolOutlinePanel extends BasePanel {
114
315
  * caller can jump to it in the file-preview panel.
115
316
  */
116
317
  getSelectedLocation(): { path: string; line: number } | null {
117
- const visible = this._visibleRows();
118
- const row = visible[this.selectedIndex];
318
+ const row = this.selectedRow();
119
319
  if (!row || row.kind === 'header') return null;
120
320
  return { path: this.currentPath, line: row.symbol.line };
121
321
  }
@@ -144,12 +344,13 @@ export class SymbolOutlinePanel extends BasePanel {
144
344
  }
145
345
 
146
346
  if (key === 'return' || key === 'enter') {
147
- // Caller should call getSelectedLocation() after this returns true.
148
- 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;
149
350
  }
150
351
 
151
352
  if (key === 'space' || key === 'right' || key === 'left') {
152
- const row = visible[this.selectedIndex];
353
+ const row = this.selectedRow();
153
354
  if (row?.kind === 'header') {
154
355
  const name = row.name;
155
356
  if (this.collapsed.has(name)) {
@@ -186,10 +387,12 @@ export class SymbolOutlinePanel extends BasePanel {
186
387
  width,
187
388
  this.currentPath ? ' No symbols found' : ' No file loaded',
188
389
  this.currentPath
189
- ? '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.'
190
391
  : 'Load a file in the preview panel to populate its outline here.',
191
- [],
192
- DEFAULT_PANEL_PALETTE,
392
+ this.currentPath
393
+ ? []
394
+ : [{ command: '/panel open explorer', summary: 'pick a file in the explorer, then Enter to preview and outline it' }],
395
+ C,
193
396
  ),
194
397
  },
195
398
  ],
@@ -201,7 +404,7 @@ export class SymbolOutlinePanel extends BasePanel {
201
404
  const outlineSection = resolveScrollablePanelSection(width, height, {
202
405
  intro: this.currentPath ? this.currentPath : 'Outline the current file into navigable symbols and lightweight parent/child structure.',
203
406
  footerLines: [
204
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' jump target', DEFAULT_PANEL_PALETTE.dim]]),
407
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Space/←/→', label: 'collapse' }, { keys: 'Enter', label: 'jump to source →' }], DEFAULT_PANEL_PALETTE),
205
408
  ],
206
409
  palette: DEFAULT_PANEL_PALETTE,
207
410
  beforeSections: [
@@ -234,7 +437,7 @@ export class SymbolOutlinePanel extends BasePanel {
234
437
  {
235
438
  title: 'Selected',
236
439
  lines: (() => {
237
- const selected = visible[this.selectedIndex];
440
+ const selected = this.selectedRow();
238
441
  return selected
239
442
  ? [
240
443
  buildPanelLine(width, [
@@ -255,7 +458,7 @@ export class SymbolOutlinePanel extends BasePanel {
255
458
  });
256
459
  this.scrollOffset = outlineSection.scrollOffset;
257
460
 
258
- const selected = visible[this.selectedIndex];
461
+ const selected = this.selectedRow();
259
462
  return buildPanelWorkspace(width, height, {
260
463
  title: ' Symbols',
261
464
  intro: this.currentPath ? this.currentPath : 'Outline the current file into navigable symbols and lightweight parent/child structure.',
@@ -291,7 +494,7 @@ export class SymbolOutlinePanel extends BasePanel {
291
494
  },
292
495
  ],
293
496
  footerLines: [
294
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' jump target', DEFAULT_PANEL_PALETTE.dim]]),
497
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Space/←/→', label: 'collapse' }, { keys: 'Enter', label: 'jump to source →' }], DEFAULT_PANEL_PALETTE),
295
498
  ],
296
499
  palette: DEFAULT_PANEL_PALETTE,
297
500
  });
@@ -299,6 +502,24 @@ export class SymbolOutlinePanel extends BasePanel {
299
502
 
300
503
  // ── Private helpers ────────────────────────────────────────────────────────
301
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
+
302
523
  private _clampScroll(totalRows: number): void {
303
524
  // Ensure selected is within scroll view (assumes last known height ~ 20)
304
525
  // We keep a conservative viewport window; render() uses this.scrollOffset.
@@ -322,80 +543,6 @@ type VisibleRow =
322
543
 
323
544
  // ── Pure helpers ─────────────────────────────────────────────────────────────
324
545
 
325
- /**
326
- * Parse symbols from source text. Returns a flat list ordered by line number.
327
- * Methods are tagged with their parent class name so the renderer can group them.
328
- */
329
- function parseSymbols(source: string): SymbolEntry[] {
330
- const lines = source.split('\n');
331
- const result: SymbolEntry[] = [];
332
- let currentContainer: string | undefined;
333
- let containerKind: SymbolKind | undefined;
334
- let containerBraceDepth = 0;
335
- let braceDepth = 0;
336
-
337
- for (let i = 0; i < lines.length; i++) {
338
- const raw = lines[i];
339
- const trimmed = raw.trimStart();
340
- const lineNo = i + 1; // 1-based
341
-
342
- // Strip string literals before counting braces to avoid false positives
343
- const rawNoBraceStrings = raw.replace(/("|\'|\`)(?:(?!\1|\\).|\\[\s\S])*\1/g, '');
344
-
345
- // Track brace depth for container scoping
346
- for (const ch of rawNoBraceStrings) {
347
- if (ch === '{') braceDepth++;
348
- else if (ch === '}') {
349
- braceDepth--;
350
- if (currentContainer !== undefined && braceDepth <= containerBraceDepth) {
351
- currentContainer = undefined;
352
- containerKind = undefined;
353
- containerBraceDepth = 0;
354
- }
355
- }
356
- }
357
-
358
- // Skip comment lines and blank lines
359
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed === '') continue;
360
-
361
- // Check container-level patterns first (class/namespace)
362
- let matched = false;
363
- for (const { re, kind, isContainer } of SYMBOL_PATTERNS) {
364
- const m = trimmed.match(re);
365
- if (!m) continue;
366
- const name = m[1];
367
- if (!name) continue;
368
-
369
- const entry: SymbolEntry = { kind, name, line: lineNo };
370
-
371
- if (currentContainer && kind === 'method') {
372
- entry.parentName = currentContainer;
373
- }
374
-
375
- // Don't add methods as top-level if they're inside a container
376
- if (kind === 'method' && !currentContainer) {
377
- // standalone function-like at wrong indent — skip
378
- matched = true;
379
- break;
380
- }
381
-
382
- result.push(entry);
383
-
384
- if (isContainer) {
385
- currentContainer = name;
386
- containerKind = kind;
387
- // Opening brace may be on this line or a subsequent line — track from current depth
388
- containerBraceDepth = braceDepth - (raw.includes('{') ? 1 : 0);
389
- }
390
-
391
- matched = true;
392
- break;
393
- }
394
- }
395
-
396
- return result;
397
- }
398
-
399
546
  /**
400
547
  * Build the flat list of rows to render, respecting collapse state.
401
548
  * Container symbols become header rows; their children are indented below.
@@ -434,10 +581,7 @@ function buildVisibleRows(symbols: SymbolEntry[], collapsed: Set<string>): Visib
434
581
  return rows;
435
582
  }
436
583
 
437
- /**
438
- * Write a string into a Line starting at column x, applying fg/bg/style.
439
- */
440
- /** Render a container header row (class / namespace). */
584
+ /** Render a container header row (class / namespace) via the shared tree row. */
441
585
  function _renderHeader(
442
586
  width: number,
443
587
  row: Extract<VisibleRow, { kind: 'header' }>,
@@ -445,24 +589,19 @@ function _renderHeader(
445
589
  bgColor: string,
446
590
  collapsed: Set<string>,
447
591
  ): Line {
448
- // Collapse indicator
449
592
  const isCollapsed = collapsed.has(row.name);
450
- const chevron = row.hasChildren ? (isCollapsed ? '▸ ' : '▾ ') : ' ';
451
- const lineNumStr = `:${row.line}`;
452
- const kindLabel = KIND_LABELS[row.symbolKind];
453
- const leadingWidth = 1 + getDisplayWidth(chevron) + getDisplayWidth(kindLabel) + 1 + getDisplayWidth(row.name);
454
- const gap = Math.max(1, width - leadingWidth - getDisplayWidth(lineNumStr) - 1);
455
- return buildSelectablePanelLine(width, [
456
- { text: ` ${chevron}`, fg: '245' },
457
- { text: kindLabel, fg: KIND_COLORS[row.symbolKind], bold: true },
458
- { text: ' ', fg: isSelected ? '255' : '252' },
459
- { text: row.name, fg: isSelected ? '255' : '252', bold: isSelected },
460
- { text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
461
- { text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
462
- ], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
593
+ return buildTreeRow(width, {
594
+ depth: 0,
595
+ label: row.name,
596
+ icon: KIND_ICONS[row.symbolKind],
597
+ expandable: row.hasChildren,
598
+ expanded: !isCollapsed,
599
+ labelColor: KIND_COLORS[row.symbolKind],
600
+ metadata: [{ text: `:${row.line}`, fg: DEFAULT_PANEL_PALETTE.dim }],
601
+ }, C, { selected: isSelected, selectedBg: bgColor || C.selectedBg });
463
602
  }
464
603
 
465
- /** Render a regular symbol row. */
604
+ /** Render a regular symbol row via the shared tree row. */
466
605
  function _renderSymbol(
467
606
  width: number,
468
607
  row: Extract<VisibleRow, { kind: 'symbol' }>,
@@ -470,17 +609,11 @@ function _renderSymbol(
470
609
  bgColor: string,
471
610
  ): Line {
472
611
  const { symbol, depth } = row;
473
- const indent = depth === 0 ? 1 : 3; // children indented by 3 (chevron + space)
474
- const kindLabel = KIND_LABELS[symbol.kind];
475
- const lineNumStr = `:${symbol.line}`;
476
- const leadingWidth = indent + getDisplayWidth(kindLabel) + 1 + getDisplayWidth(symbol.name);
477
- const gap = Math.max(1, width - leadingWidth - getDisplayWidth(lineNumStr) - 1);
478
- return buildSelectablePanelLine(width, [
479
- { text: ' '.repeat(indent), fg: DEFAULT_PANEL_PALETTE.dim },
480
- { text: kindLabel, fg: KIND_COLORS[symbol.kind] },
481
- { text: ' ', fg: isSelected ? '255' : '251' },
482
- { text: symbol.name, fg: isSelected ? '255' : '251', bold: isSelected },
483
- { text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
484
- { text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
485
- ], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
612
+ return buildTreeRow(width, {
613
+ depth,
614
+ label: symbol.name,
615
+ icon: KIND_ICONS[symbol.kind],
616
+ labelColor: KIND_COLORS[symbol.kind],
617
+ metadata: [{ text: `:${symbol.line}`, fg: DEFAULT_PANEL_PALETTE.dim }],
618
+ }, C, { selected: isSelected, selectedBg: bgColor || C.selectedBg });
486
619
  }