srcodex 0.2.0__py3-none-any.whl

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. srcodex/__init__.py +0 -0
  2. srcodex/backend/__init__.py +0 -0
  3. srcodex/backend/chat.py +79 -0
  4. srcodex/backend/main.py +98 -0
  5. srcodex/backend/services/__init__.py +0 -0
  6. srcodex/backend/services/claude_service.py +754 -0
  7. srcodex/backend/services/config_loader.py +113 -0
  8. srcodex/backend/services/file_access_tools.py +279 -0
  9. srcodex/backend/services/file_tree.py +480 -0
  10. srcodex/backend/services/graph_tools.py +874 -0
  11. srcodex/backend/services/logger_setup.py +91 -0
  12. srcodex/backend/services/session_manager.py +81 -0
  13. srcodex/backend/services/status_tracker.py +91 -0
  14. srcodex/cli.py +255 -0
  15. srcodex/core/__init__.py +0 -0
  16. srcodex/core/config.py +113 -0
  17. srcodex/core/logger.py +23 -0
  18. srcodex/indexer/__init__.py +0 -0
  19. srcodex/indexer/cscope_client.py +183 -0
  20. srcodex/indexer/ctags_compat.py +223 -0
  21. srcodex/indexer/ctags_parser.py +456 -0
  22. srcodex/indexer/explorer.py +135 -0
  23. srcodex/indexer/field_access_analyzer.py +436 -0
  24. srcodex/indexer/indexer.py +664 -0
  25. srcodex/indexer/reference_ingestor.py +293 -0
  26. srcodex/indexer/reference_resolver.py +544 -0
  27. srcodex/tui/__init__.py +0 -0
  28. srcodex/tui/app.py +103 -0
  29. srcodex/tui/app.tcss +24 -0
  30. srcodex/tui/components/__init__.py +0 -0
  31. srcodex/tui/components/bars/__init__.py +0 -0
  32. srcodex/tui/components/bars/chat_header.py +48 -0
  33. srcodex/tui/components/bars/code_tab_bar.py +157 -0
  34. srcodex/tui/components/bars/footer_bar.py +128 -0
  35. srcodex/tui/components/bars/left_tab.py +54 -0
  36. srcodex/tui/components/logger.py +57 -0
  37. srcodex/tui/components/panels/__init__.py +0 -0
  38. srcodex/tui/components/panels/chat_panel.py +523 -0
  39. srcodex/tui/components/panels/code_panel.py +229 -0
  40. srcodex/tui/components/panels/side_panel.py +128 -0
  41. srcodex/tui/components/views/__init__.py +0 -0
  42. srcodex/tui/components/views/explorer_view.py +20 -0
  43. srcodex/tui/components/views/search_view.py +148 -0
  44. srcodex/tui/components/widgets/__init__.py +0 -0
  45. srcodex/tui/components/widgets/file_browser.py +16 -0
  46. srcodex/tui/components/widgets/find_box.py +85 -0
  47. srcodex-0.2.0.dist-info/METADATA +170 -0
  48. srcodex-0.2.0.dist-info/RECORD +52 -0
  49. srcodex-0.2.0.dist-info/WHEEL +5 -0
  50. srcodex-0.2.0.dist-info/entry_points.txt +2 -0
  51. srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
  52. srcodex-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Reference Resolver - Convert raw references to semantic graph edges
4
+ Resolves (file, function) names → symbol IDs and stores typed edges in symbol_edges
5
+ """
6
+
7
+ import sqlite3
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Optional, List, Tuple, Dict
11
+ from dataclasses import dataclass
12
+ from collections import Counter
13
+ from tqdm import tqdm
14
+
15
+
16
+ @dataclass
17
+ class ResolutionStats:
18
+ """Statistics for resolution process"""
19
+ total_raw_refs: int = 0
20
+ resolved_edges: int = 0
21
+ unresolved_src: int = 0
22
+ unresolved_dst: int = 0
23
+ ambiguous_dst: int = 0
24
+ skipped_parsing: int = 0
25
+
26
+
27
+ class ReferenceResolver:
28
+ """Resolves raw cscope references into semantic graph edges with symbol IDs"""
29
+
30
+ # C keywords to exclude from callee extraction
31
+ C_KEYWORDS = {
32
+ 'if', 'for', 'while', 'switch', 'return', 'sizeof', 'typeof',
33
+ 'do', 'else', 'case', 'break', 'continue', 'goto', 'default'
34
+ }
35
+
36
+ def __init__(self, db_conn: sqlite3.Connection):
37
+ """
38
+ Initialize reference resolver
39
+
40
+ Args:
41
+ db_conn: SQLite database connection (must have raw_references and symbols tables)
42
+ """
43
+ self.conn = db_conn
44
+ self.stats = ResolutionStats()
45
+
46
+ # Symbol lookup caches for performance
47
+ self.symbol_cache = {} # (name, type) → [(id, file_path), ...]
48
+ self._build_symbol_cache()
49
+
50
+ def _build_symbol_cache(self):
51
+ """Build in-memory cache of all symbols for fast lookup"""
52
+ print("Building symbol lookup cache...")
53
+ cursor = self.conn.cursor()
54
+ cursor.execute("SELECT id, name, type, file_path FROM symbols")
55
+
56
+ for row in cursor.fetchall():
57
+ sym_id, name, sym_type, file_path = row['id'], row['name'], row['type'], row['file_path']
58
+ key = (name, sym_type)
59
+ if key not in self.symbol_cache:
60
+ self.symbol_cache[key] = []
61
+ self.symbol_cache[key].append((sym_id, file_path))
62
+
63
+ print(f" Cached {len(self.symbol_cache)} unique (name, type) pairs")
64
+
65
+ def _extract_callee_from_line(self, line_text: str) -> Optional[str]:
66
+ """
67
+ Extract callee function name from line_text using IDENT( pattern
68
+
69
+ Looks for patterns like: function_name(...)
70
+ Excludes: keywords, macro-ish ALL_CAPS (optional)
71
+
72
+ Args:
73
+ line_text: Raw line content from cscope
74
+
75
+ Returns:
76
+ Callee function name or None if not found/excluded
77
+ """
78
+ # Pattern: identifier followed by '('
79
+ # Match: alphanumeric + underscore, then opening paren
80
+ pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\('
81
+
82
+ matches = re.findall(pattern, line_text)
83
+
84
+ for match in matches:
85
+ # Exclude C keywords
86
+ if match in self.C_KEYWORDS:
87
+ continue
88
+
89
+ # Optional: exclude macro-ish ALL_CAPS
90
+ # Uncomment to enable:
91
+ # if match.isupper() and len(match) > 2:
92
+ # continue
93
+
94
+ # Return first valid match
95
+ return match
96
+
97
+ return None
98
+
99
+ def _resolve_src_symbol(self, query_symbol: str, source_file: str) -> Optional[int]:
100
+ """
101
+ Resolve source symbol (caller) by query_symbol name
102
+
103
+ Args:
104
+ query_symbol: Function name that was queried (caller)
105
+ source_file: File where caller is defined (for disambiguation)
106
+
107
+ Returns:
108
+ symbol_id or None if not found/ambiguous
109
+ """
110
+ # Fast lookup in cache (no database query)
111
+ results = self.symbol_cache.get((query_symbol, 'function'), [])
112
+
113
+ if len(results) == 0:
114
+ return None # Not found
115
+
116
+ if len(results) == 1:
117
+ return results[0][0] # Unique match
118
+
119
+ # Multiple matches: prefer same file
120
+ for row in results:
121
+ if row[1] == source_file:
122
+ return row[0]
123
+
124
+ # Still ambiguous: return None (could pick first, but being strict)
125
+ return None
126
+
127
+ def _resolve_dst_symbol(self, callee_name: str, source_file: str) -> Optional[int]:
128
+ """
129
+ Resolve destination symbol (callee) by function name with disambiguation
130
+
131
+ Disambiguation rules:
132
+ 1. Accept if unique
133
+ 2. Prefer same file
134
+ 3. Prefer .c file over .h (definition vs declaration)
135
+ 4. If still multiple: return None (unresolved)
136
+
137
+ Args:
138
+ callee_name: Function name being called
139
+ source_file: File where call occurs (for disambiguation)
140
+
141
+ Returns:
142
+ symbol_id or None if not found/ambiguous
143
+ """
144
+ # Fast lookup in cache (no database query)
145
+ results = self.symbol_cache.get((callee_name, 'function'), [])
146
+
147
+ if len(results) == 0:
148
+ return None # Not found
149
+
150
+ if len(results) == 1:
151
+ return results[0][0] # Unique match
152
+
153
+ # Multiple matches: prefer same file first
154
+ for row in results:
155
+ if row[1] == source_file:
156
+ return row[0]
157
+
158
+ # Still ambiguous: prefer .c file over .h (definition vs declaration)
159
+ c_files = [row for row in results if row[1].endswith('.c')]
160
+ if len(c_files) == 1:
161
+ return c_files[0][0]
162
+
163
+ # Still ambiguous: unresolved
164
+ return None
165
+
166
+ def resolve_callees(self, clear_existing: bool = False) -> Dict[str, int]:
167
+ """
168
+ Resolve callgraph edges: raw_references (query_type='callees') → symbol_edges
169
+
170
+ For each raw reference:
171
+ 1. Extract callee from line_text (IDENT( pattern)
172
+ 2. Resolve src_symbol_id (query_symbol → symbol.id)
173
+ 3. Resolve dst_symbol_id (callee → symbol.id)
174
+ 4. Insert into symbol_edges with edge_type='CALLS'
175
+
176
+ Args:
177
+ clear_existing: If True, delete existing CALLS edges before resolution
178
+
179
+ Returns:
180
+ Dictionary with resolution statistics
181
+ """
182
+ if clear_existing:
183
+ print("Clearing existing CALLS edges...")
184
+ self.conn.execute("DELETE FROM symbol_edges WHERE edge_type = 'CALLS'")
185
+ self.conn.commit()
186
+
187
+ # Fetch all raw references with query_type='callees'
188
+ cursor = self.conn.cursor()
189
+ cursor.execute(
190
+ """SELECT id, query_symbol, source_file, source_function, line_number, line_text
191
+ FROM raw_references
192
+ WHERE query_type = 'callees'
193
+ ORDER BY id"""
194
+ )
195
+
196
+ raw_refs = cursor.fetchall()
197
+ self.stats.total_raw_refs = len(raw_refs)
198
+
199
+ print(f"Found {self.stats.total_raw_refs} raw references to resolve")
200
+
201
+ if not raw_refs:
202
+ print("Warning: No raw references found")
203
+ return self._stats_dict()
204
+
205
+ # Prepare batch insert for resolved edges
206
+ edges_batch = []
207
+ batch_size = 10000 # Commit every 10k edges for safety
208
+
209
+ # Track unresolved reasons for reporting
210
+ unresolved_reasons = Counter()
211
+
212
+ print("Resolving symbol IDs...")
213
+ for i, row in enumerate(tqdm(raw_refs, desc="Resolving edges")):
214
+ raw_id, query_symbol, source_file, source_function, line_number, line_text = row
215
+
216
+ # Step 1: Extract callee from line_text
217
+ callee_name = self._extract_callee_from_line(line_text)
218
+ if not callee_name:
219
+ self.stats.skipped_parsing += 1
220
+ unresolved_reasons['no_callee_in_line'] += 1
221
+ continue
222
+
223
+ # Step 2: Resolve src_symbol_id (caller)
224
+ src_symbol_id = self._resolve_src_symbol(query_symbol, source_file)
225
+ if not src_symbol_id:
226
+ self.stats.unresolved_src += 1
227
+ unresolved_reasons['src_not_found'] += 1
228
+ continue
229
+
230
+ # Step 3: Resolve dst_symbol_id (callee)
231
+ dst_symbol_id = self._resolve_dst_symbol(callee_name, source_file)
232
+ if not dst_symbol_id:
233
+ self.stats.unresolved_dst += 1
234
+ unresolved_reasons['dst_not_found_or_ambiguous'] += 1
235
+ continue
236
+
237
+ # Both resolved: add to batch
238
+ edges_batch.append((
239
+ 'CALLS', # edge_type
240
+ src_symbol_id, # src_symbol_id (caller)
241
+ dst_symbol_id, # dst_symbol_id (callee)
242
+ source_file, # source_file (where edge occurs)
243
+ line_number, # line_number
244
+ ))
245
+
246
+ # Batch commit every N edges for safety (prevents losing all progress)
247
+ if len(edges_batch) >= batch_size:
248
+ cursor.executemany(
249
+ """INSERT OR IGNORE INTO symbol_edges
250
+ (edge_type, src_symbol_id, dst_symbol_id, source_file, line_number)
251
+ VALUES (?, ?, ?, ?, ?)""",
252
+ edges_batch
253
+ )
254
+ self.conn.commit()
255
+ self.stats.resolved_edges += len(edges_batch)
256
+ edges_batch.clear()
257
+
258
+ # Insert remaining edges
259
+ if edges_batch:
260
+ cursor.executemany(
261
+ """INSERT OR IGNORE INTO symbol_edges
262
+ (edge_type, src_symbol_id, dst_symbol_id, source_file, line_number)
263
+ VALUES (?, ?, ?, ?, ?)""",
264
+ edges_batch
265
+ )
266
+ self.conn.commit()
267
+ self.stats.resolved_edges += len(edges_batch)
268
+
269
+ print(f"Inserted {self.stats.resolved_edges} CALLS edges")
270
+
271
+ # Print resolution statistics
272
+ self._print_stats(unresolved_reasons)
273
+
274
+ return self._stats_dict()
275
+
276
+ def _stats_dict(self) -> Dict[str, int]:
277
+ """Convert stats to dictionary"""
278
+ return {
279
+ 'total_raw_refs': self.stats.total_raw_refs,
280
+ 'resolved_edges': self.stats.resolved_edges,
281
+ 'unresolved_src': self.stats.unresolved_src,
282
+ 'unresolved_dst': self.stats.unresolved_dst,
283
+ 'skipped_parsing': self.stats.skipped_parsing,
284
+ }
285
+
286
+ def _print_stats(self, unresolved_reasons: Counter):
287
+ """Print resolution statistics"""
288
+ print()
289
+ print("=" * 60)
290
+ print("Resolution Statistics")
291
+ print("=" * 60)
292
+ print(f"Total raw references: {self.stats.total_raw_refs}")
293
+ print(f"Resolved edges: {self.stats.resolved_edges}")
294
+ print(f"Unresolved (src): {self.stats.unresolved_src}")
295
+ print(f"Unresolved (dst): {self.stats.unresolved_dst}")
296
+ print(f"Skipped (no callee): {self.stats.skipped_parsing}")
297
+
298
+ if unresolved_reasons:
299
+ print()
300
+ print("Unresolved breakdown:")
301
+ for reason, count in unresolved_reasons.most_common():
302
+ print(f" {reason}: {count}")
303
+
304
+ # Calculate resolution rate
305
+ if self.stats.total_raw_refs > 0:
306
+ rate = (self.stats.resolved_edges / self.stats.total_raw_refs) * 100
307
+ print()
308
+ print(f"Resolution rate: {rate:.1f}%")
309
+
310
+ def resolve_includes(self, clear_existing: bool = False) -> Dict[str, int]:
311
+ """
312
+ Resolve raw includes references into file_edges (file-to-file INCLUDES relationships)
313
+
314
+ For each raw_references row with query_type='includes':
315
+ - source_file: file that includes the header
316
+ - query_symbol: header basename (e.g., "power.h")
317
+ - Resolve query_symbol → canonical repo-relative header path
318
+ - Insert INCLUDES edge into file_edges
319
+
320
+ Args:
321
+ clear_existing: If True, delete existing INCLUDES edges before resolution
322
+
323
+ Returns:
324
+ Dictionary with resolution statistics
325
+ """
326
+ if clear_existing:
327
+ print("Clearing existing INCLUDES file edges...")
328
+ self.conn.execute("DELETE FROM file_edges WHERE edge_type = 'INCLUDES'")
329
+ self.conn.commit()
330
+
331
+ # Get all includes raw references
332
+ cursor = self.conn.cursor()
333
+ cursor.execute(
334
+ """SELECT id, query_symbol, source_file, line_number
335
+ FROM raw_references
336
+ WHERE query_type = 'includes'
337
+ ORDER BY id"""
338
+ )
339
+ raw_refs = cursor.fetchall()
340
+
341
+ print(f"Found {len(raw_refs)} raw includes references to resolve")
342
+
343
+ if not raw_refs:
344
+ return {'total_raw_refs': 0, 'resolved_edges': 0, 'unresolved': 0, 'ambiguous': 0}
345
+
346
+ # Resolution statistics
347
+ stats = {
348
+ 'total_raw_refs': len(raw_refs),
349
+ 'resolved_edges': 0,
350
+ 'unresolved': 0,
351
+ 'ambiguous': 0
352
+ }
353
+
354
+ edges_batch = []
355
+ batch_size = 5000 # Commit every 5k edges
356
+ unresolved_headers = Counter()
357
+
358
+ print("Resolving header paths...")
359
+ for i, row in enumerate(tqdm(raw_refs, desc="Resolving includes")):
360
+ query_symbol = row['query_symbol'] # e.g., "power.h" or "common/power.h"
361
+ source_file = row['source_file']
362
+ line_number = row['line_number']
363
+
364
+ # Resolve header basename to canonical repo-relative path using directory proximity
365
+ resolved_path = self._resolve_header_path(query_symbol, source_file)
366
+
367
+ if resolved_path is None:
368
+ stats['unresolved'] += 1
369
+ unresolved_headers[query_symbol] += 1
370
+ continue
371
+ elif isinstance(resolved_path, list):
372
+ # Ambiguous: multiple matches (even after proximity heuristic)
373
+ stats['ambiguous'] += 1
374
+ unresolved_headers[f"{query_symbol} (ambiguous: {len(resolved_path)} matches)"] += 1
375
+ continue
376
+
377
+ # Valid resolution: add to batch
378
+ edges_batch.append((
379
+ 'INCLUDES', # edge_type
380
+ source_file, # src_file (includer)
381
+ resolved_path, # dst_file (included header)
382
+ line_number # line_number
383
+ ))
384
+
385
+ # Batch commit every N edges
386
+ if len(edges_batch) >= batch_size:
387
+ cursor.executemany(
388
+ """INSERT OR IGNORE INTO file_edges
389
+ (edge_type, src_file, dst_file, line_number)
390
+ VALUES (?, ?, ?, ?)""",
391
+ edges_batch
392
+ )
393
+ self.conn.commit()
394
+ stats['resolved_edges'] += len(edges_batch)
395
+ edges_batch.clear()
396
+
397
+ # Insert remaining edges
398
+ if edges_batch:
399
+ cursor.executemany(
400
+ """INSERT OR IGNORE INTO file_edges
401
+ (edge_type, src_file, dst_file, line_number)
402
+ VALUES (?, ?, ?, ?)""",
403
+ edges_batch
404
+ )
405
+ self.conn.commit()
406
+ stats['resolved_edges'] += len(edges_batch)
407
+
408
+ print(f"Inserted {stats['resolved_edges']} INCLUDES edges")
409
+
410
+ # Print resolution statistics
411
+ print()
412
+ print("=" * 60)
413
+ print("Includes Resolution Statistics")
414
+ print("=" * 60)
415
+ print(f"Total raw includes: {stats['total_raw_refs']}")
416
+ print(f"Resolved edges: {stats['resolved_edges']}")
417
+ print(f"Unresolved headers: {stats['unresolved']}")
418
+ print(f"Ambiguous headers: {stats['ambiguous']}")
419
+
420
+ if unresolved_headers:
421
+ print()
422
+ print("Unresolved breakdown:")
423
+ for header, count in unresolved_headers.most_common(10):
424
+ print(f" {header}: {count}")
425
+
426
+ # Calculate resolution rate
427
+ if stats['total_raw_refs'] > 0:
428
+ rate = (stats['resolved_edges'] / stats['total_raw_refs']) * 100
429
+ print()
430
+ print(f"Resolution rate: {rate:.1f}%")
431
+
432
+ return stats
433
+
434
+ def _resolve_header_path(self, query_symbol: str, source_file: str) -> Optional[str]:
435
+ """
436
+ Resolve header basename to canonical repo-relative path using directory proximity
437
+
438
+ Args:
439
+ query_symbol: Header name (e.g., "power.h" or "common/power.h")
440
+ source_file: Source file that includes this header (e.g., "mp1/src/app/acpi.c")
441
+
442
+ Returns:
443
+ - Canonical path string if exactly 1 match or best proximity match
444
+ - None if 0 matches (unresolved)
445
+ - List of paths if >1 match and can't disambiguate (ambiguous)
446
+ """
447
+ # If query_symbol contains '/', treat as path candidate
448
+ if '/' in query_symbol:
449
+ # Check if this exact path exists
450
+ cursor = self.conn.cursor()
451
+ cursor.execute("SELECT path FROM files WHERE path = ?", (query_symbol,))
452
+ result = cursor.fetchone()
453
+ if result:
454
+ return result['path']
455
+ else:
456
+ return None # Path with '/' but doesn't exist
457
+
458
+ # Otherwise, search for basename match
459
+ cursor = self.conn.cursor()
460
+ cursor.execute(
461
+ """SELECT path FROM files
462
+ WHERE path LIKE ? OR path = ?
463
+ ORDER BY path""",
464
+ (f'%/{query_symbol}', query_symbol)
465
+ )
466
+ matches = cursor.fetchall()
467
+
468
+ if len(matches) == 0:
469
+ return None # Unresolved
470
+ elif len(matches) == 1:
471
+ return matches[0]['path'] # Exact match
472
+ else:
473
+ # Ambiguous: use directory proximity heuristic
474
+ candidates = [row['path'] for row in matches]
475
+ best_match = self._pick_closest_header(source_file, candidates)
476
+ return best_match if best_match else candidates # Return best or list if can't decide
477
+
478
+ def _pick_closest_header(self, source_file: str, candidates: List[str]) -> Optional[str]:
479
+ """
480
+ Pick the closest header file using directory proximity heuristic
481
+
482
+ Mimics C compiler include resolution:
483
+ 1. Same directory (highest priority)
484
+ 2. Subdirectory of source
485
+ 3. Parent directory
486
+ 4. Closest common ancestor
487
+
488
+ Args:
489
+ source_file: Source file path (e.g., "mp1/src/app/acpi.c")
490
+ candidates: List of candidate header paths
491
+
492
+ Returns:
493
+ Best match path, or None if can't disambiguate
494
+ """
495
+ from pathlib import Path
496
+
497
+ source_dir = str(Path(source_file).parent) # e.g., "mp1/src/app"
498
+
499
+ # Priority 1: Same directory
500
+ for candidate in candidates:
501
+ candidate_dir = str(Path(candidate).parent)
502
+ if candidate_dir == source_dir:
503
+ return candidate
504
+
505
+ # Priority 2: Subdirectory of source directory
506
+ for candidate in candidates:
507
+ if candidate.startswith(source_dir + "/"):
508
+ return candidate
509
+
510
+ # Priority 3: Parent directory
511
+ source_parent = str(Path(source_dir).parent)
512
+ for candidate in candidates:
513
+ candidate_dir = str(Path(candidate).parent)
514
+ if candidate_dir == source_parent:
515
+ return candidate
516
+
517
+ # Priority 4: Closest common ancestor (minimum path distance)
518
+ def path_distance(path1: str, path2: str) -> int:
519
+ """Calculate directory distance between two paths"""
520
+ parts1 = path1.split('/')
521
+ parts2 = path2.split('/')
522
+
523
+ # Find common prefix length
524
+ common = 0
525
+ for p1, p2 in zip(parts1, parts2):
526
+ if p1 == p2:
527
+ common += 1
528
+ else:
529
+ break
530
+
531
+ # Distance = steps up + steps down
532
+ return (len(parts1) - common) + (len(parts2) - common)
533
+
534
+ best = None
535
+ min_distance = float('inf')
536
+
537
+ for candidate in candidates:
538
+ candidate_dir = str(Path(candidate).parent)
539
+ distance = path_distance(source_dir, candidate_dir)
540
+ if distance < min_distance:
541
+ min_distance = distance
542
+ best = candidate
543
+
544
+ return best
File without changes
srcodex/tui/app.py ADDED
@@ -0,0 +1,103 @@
1
+ from pathlib import Path
2
+ from textual.app import App
3
+ from textual.widgets import Label, DirectoryTree
4
+ from textual.containers import Container
5
+ import asyncio
6
+ import sys
7
+
8
+ # Add parent directory to path for imports
9
+ sys.path.insert(0, str(Path(__file__).parent))
10
+ # Add backend to path for config loader
11
+ backend_path = Path(__file__).parent.parent / "backend"
12
+ sys.path.insert(0, str(backend_path))
13
+
14
+ from components.panels.side_panel import SidePanel
15
+ from components.panels.code_panel import CodePanel
16
+ from components.panels.chat_panel import ChatPanel
17
+ from components.views.search_view import SearchView
18
+ from components.bars.footer_bar import FooterBar
19
+ from services.config_loader import get_config
20
+
21
+
22
+
23
+ class SrcodexApp(App):
24
+ CSS_PATH = "app.tcss"
25
+
26
+ # Panel width state (fractional units: left=0.8, middle=2, right=1.4, sum=4.2)
27
+ left_width = 0.8
28
+ middle_width = 2
29
+ right_width = 1.4
30
+
31
+ def __init__(self, *args, **kwargs):
32
+ super().__init__(*args, **kwargs)
33
+ # Load project configuration at runtime
34
+ config = get_config()
35
+ self.SOURCE_ROOT = str(config.source_root)
36
+ self.PROJECT_ROOT = str(config.project_root)
37
+
38
+ def compose(self):
39
+ yield SidePanel(self.SOURCE_ROOT, id="left")
40
+ yield CodePanel(self.SOURCE_ROOT, id="middle")
41
+ yield ChatPanel(id="right")
42
+ yield FooterBar(self.PROJECT_ROOT)
43
+
44
+ def on_key(self, event):
45
+ """Handle keyboard shortcuts for panel resizing"""
46
+ # Ctrl+Shift+Left: Grow chat panel, shrink code viewer
47
+ if event.key == "ctrl+shift+left":
48
+ if self.middle_width > 0.5:
49
+ self.right_width += 0.1
50
+ self.middle_width -= 0.1
51
+ self._update_panel_widths()
52
+ event.prevent_default()
53
+
54
+ # Ctrl+Shift+Right: Shrink chat panel, grow code viewer
55
+ elif event.key == "ctrl+shift+right":
56
+ if self.right_width > 0.5:
57
+ self.right_width -= 0.1
58
+ self.middle_width += 0.1
59
+ self._update_panel_widths()
60
+ event.prevent_default()
61
+
62
+ def _update_panel_widths(self):
63
+ """Update panel widths dynamically"""
64
+ left_panel = self.query_one("#left")
65
+ middle_panel = self.query_one("#middle")
66
+ right_panel = self.query_one("#right")
67
+
68
+ # Update styles with new fractional widths
69
+ left_panel.styles.width = f"{self.left_width}fr"
70
+ middle_panel.styles.width = f"{self.middle_width}fr"
71
+ right_panel.styles.width = f"{self.right_width}fr"
72
+
73
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected):
74
+ """Handle file selection from FileBrowser explorer view"""
75
+ code_panel = self.query_one("#middle", CodePanel)
76
+
77
+ absolute_path = Path(event.path)
78
+ source_root = Path(self.SOURCE_ROOT)
79
+
80
+ try:
81
+ relative_path = absolute_path.relative_to(source_root)
82
+ code_panel.open_file(str(relative_path))
83
+ except ValueError:
84
+ self.notify(f"Cannot open file outside source root: {event.path}", severity="error")
85
+
86
+ async def on_search_view_symbol_selected(self, event: SearchView.SymbolSelected):
87
+ """Handle symbol selection from search results"""
88
+ code_panel = self.query_one("#middle", CodePanel)
89
+ side_panel = self.query_one("#left", SidePanel)
90
+
91
+ # Open file directly with line number
92
+ code_panel.open_file(event.file_path, line_number=event.line_number)
93
+
94
+ # Also navigate tree (async) for visual feedback
95
+ await side_panel.on_search_view_file_selected(
96
+ SearchView.FileSelected(event.file_path)
97
+ )
98
+
99
+
100
+
101
+ if __name__ == "__main__":
102
+ app = SrcodexApp()
103
+ app.run()
srcodex/tui/app.tcss ADDED
@@ -0,0 +1,24 @@
1
+ /* Main screen layout - 3 columns */
2
+ Screen {
3
+ layout: horizontal;
4
+ }
5
+
6
+ /* Left panel - File Browser */
7
+ #left {
8
+ width: 0.8fr;
9
+ height: 100%;
10
+ border: grey;
11
+ overflow-y: auto;
12
+ }
13
+
14
+ /* Middle panel - Code Viewer */
15
+ #middle {
16
+ width: 2fr;
17
+ border: grey;
18
+ }
19
+
20
+ /* Right panel - AI Chat */
21
+ #right {
22
+ width: 1.4fr;
23
+ border: grey;
24
+ }
File without changes
File without changes