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,523 @@
1
+ from textual.widgets import Static, Markdown, ListView, ListItem, Label
2
+ from textual.app import ComposeResult
3
+ from textual.containers import Vertical, VerticalScroll
4
+ from textual.widgets import Input, TextArea
5
+ from textual.message import Message
6
+ import httpx
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from components.bars.chat_header import ChatHeader
12
+
13
+ backend_path = Path(__file__).parent.parent.parent.parent / "backend"
14
+ sys.path.insert(0, str(backend_path))
15
+ from services.session_manager import SessionManager
16
+ from services.config_loader import get_config
17
+
18
+ class ChatInput(TextArea):
19
+ """Custom TextArea that sends message on Enter"""
20
+
21
+ class Submit(Message):
22
+ """Posted when user presses Enter (not Shift+Enter)"""
23
+ def __init__(self, text: str):
24
+ super().__init__()
25
+ self.text = text
26
+
27
+ def on_key(self, event):
28
+ """Intercept Enter key - use Ctrl+Enter for new line"""
29
+ if event.key == "ctrl+enter":
30
+ # Ctrl+Enter - insert new line manually
31
+ cursor = self.cursor_location
32
+ current_text = self.text
33
+ # Insert newline at cursor position
34
+ line, col = cursor
35
+ lines = current_text.split('\n')
36
+ if line < len(lines):
37
+ lines[line] = lines[line][:col] + '\n' + lines[line][col:]
38
+ self.text = '\n'.join(lines)
39
+ self.cursor_location = (line + 1, 0)
40
+ event.prevent_default()
41
+ event.stop()
42
+ elif event.key == "enter":
43
+ # Plain Enter - submit
44
+ event.prevent_default()
45
+ event.stop()
46
+ text = self.text.strip()
47
+ if text:
48
+ self.post_message(self.Submit(text))
49
+ self.text = ""
50
+
51
+ class ChatSettingsMenu(Vertical):
52
+ """Dropdown menu for chat settings"""
53
+
54
+ DEFAULT_CSS = """
55
+ ChatSettingsMenu {
56
+ width: 20;
57
+ height: auto;
58
+ background: transparent;
59
+ layer: overlay;
60
+ }
61
+
62
+ ChatSettingsMenu ListView {
63
+ height: auto;
64
+ width: 100%;
65
+ background: transparent;
66
+ border: none;
67
+ }
68
+
69
+ ChatSettingsMenu ListItem {
70
+ height: 1;
71
+ padding: 0 1;
72
+ background: transparent;
73
+ }
74
+
75
+ ChatSettingsMenu ListItem:hover {
76
+ background: $boost;
77
+ }
78
+ """
79
+
80
+ def compose(self):
81
+ yield ListView(
82
+ ListItem(Label("New Session"), id="new-session"),
83
+ ListItem(Label("Clear History"), id="clear-history"),
84
+ ListItem(Label("Export Chat"), id="export-chat"),
85
+ ListItem(Label("Change Model"), id="change-model"),
86
+ )
87
+
88
+
89
+ class ChatPanel(Vertical):
90
+ """ AI chat panel (right) - Claude (LLM) conversation interface"""
91
+
92
+ def __init__(self, **kwargs):
93
+ super().__init__(**kwargs)
94
+ self.session_input_tokens = 0
95
+ self.session_output_tokens = 0
96
+ self.session_cache_read_tokens = 0
97
+ self.session_cache_write_tokens = 0
98
+ self.last_query_input_tokens = 0
99
+ self.last_query_output_tokens = 0
100
+ self.last_query_cache_read = 0
101
+ self.last_query_cache_write = 0
102
+ self.settings_menu_visible = False
103
+
104
+ config = get_config()
105
+ self.session_manager = SessionManager(str(config.project_root))
106
+
107
+ self.conversation_history = self.session_manager.load_session()
108
+ self.session_loaded = len(self.conversation_history) > 0
109
+
110
+ # Load persistent metrics
111
+ metadata = self.session_manager.load_metadata()
112
+ self.query_count = metadata.get("query_count", 0)
113
+ self.total_user_input = metadata.get("total_user_input", 0)
114
+ self.total_output = metadata.get("total_output", 0)
115
+ self.total_files_accessed = metadata.get("total_files_accessed", 0)
116
+ self.total_savings_percentage = metadata.get("total_savings_percentage", 0.0)
117
+
118
+ # Load cache tokens from session
119
+ self.session_cache_read_tokens = metadata.get("cache_read", 0)
120
+ self.session_cache_write_tokens = metadata.get("cache_write", 0)
121
+
122
+ DEFAULT_CSS = """
123
+ ChatPanel {
124
+ width: 100%;
125
+ height: 100%;
126
+ }
127
+
128
+ #settings-menu {
129
+ offset: 55 1;
130
+ display: none;
131
+ border: white;
132
+ }
133
+
134
+ #settings-menu.visible {
135
+ display: block;
136
+ }
137
+
138
+ #conversation-scroll {
139
+ height: 1fr;
140
+ border: none;
141
+ }
142
+
143
+ #conversation-history {
144
+ width: 100%;
145
+ padding: 1;
146
+ background: transparent;
147
+ }
148
+
149
+ /* Remove markdown header coloring only */
150
+ #conversation-history MarkdownH1,
151
+ #conversation-history MarkdownH2,
152
+ #conversation-history MarkdownH3,
153
+ #conversation-history MarkdownH4,
154
+ #conversation-history MarkdownH5,
155
+ #conversation-history MarkdownH6 {
156
+ color: $text;
157
+ }
158
+
159
+ #conversation-history MarkdownHorizontalRule {
160
+ display: none;
161
+ }
162
+
163
+ ChatPanel Scrollbar {
164
+ width: 1;
165
+ }
166
+
167
+ #chat-input-container {
168
+ width: 100%;
169
+ height: auto;
170
+ padding: 0;
171
+ border: grey;
172
+ }
173
+
174
+ #chat-input {
175
+ width: 1fr;
176
+ height: auto;
177
+ min-height: 3;
178
+ border: none;
179
+ margin: 0;
180
+ padding: 0;
181
+ background: transparent;
182
+ }
183
+
184
+ #chat-input:focus {
185
+ background: transparent;
186
+ }
187
+
188
+ #chat-input > .text-area--cursor-line {
189
+ background: transparent;
190
+ }
191
+
192
+ #status-line {
193
+ height: 1;
194
+ width: 100%;
195
+ background: transparent;
196
+ color: $accent;
197
+ text-align: right;
198
+ padding-right: 1;
199
+ display: none;
200
+ }
201
+
202
+ #status-line.visible {
203
+ display: block;
204
+ }
205
+
206
+ #status-line.completed {
207
+ color: $success;
208
+ }
209
+
210
+ #token-counter {
211
+ height: 2;
212
+ width: 100%;
213
+ background: transparent;
214
+ color: $text-muted;
215
+ text-align: right;
216
+ padding-right: 1;
217
+ }
218
+ """
219
+
220
+ def compose(self):
221
+ """Build the chat panel UI"""
222
+ yield ChatHeader()
223
+ yield ChatSettingsMenu(id="settings-menu")
224
+ with VerticalScroll(id="conversation-scroll"):
225
+ yield Markdown("", id="conversation-history")
226
+ yield Static("", id="status-line")
227
+ yield Static("", id="token-counter")
228
+ with Vertical(id="chat-input-container"):
229
+ yield ChatInput(id="chat-input", show_line_numbers=False)
230
+
231
+ def on_mount(self) -> None:
232
+ """When panel is mounted, show welcome message"""
233
+ conversation = self.query_one("#conversation-history", Markdown)
234
+
235
+ if self.session_loaded:
236
+ markdown_text = self._build_conversation_markdown()
237
+ else:
238
+ markdown_text = "*Type below and press Enter to chat. Ctrl+L to clear history.*\n\n"
239
+
240
+ conversation.update(markdown_text)
241
+ self.set_timer(0.3, self._scroll_to_bottom)
242
+
243
+ # Display cached token values on startup
244
+ self._update_token_display()
245
+
246
+ def _build_conversation_markdown(self) -> str:
247
+ """Build markdown string from conversation history"""
248
+ if not self.conversation_history:
249
+ return "*Ask a question below (Enter to send, Ctrl+L to clear)*"
250
+
251
+ lines = []
252
+ for msg in self.conversation_history:
253
+ if msg["role"] == "user":
254
+ lines.append(f"> **You:** {msg['content']}")
255
+ lines.append("")
256
+ elif msg["role"] == "assistant":
257
+ lines.append(f"**Claude:**")
258
+ lines.append(msg['content'])
259
+ lines.append("")
260
+
261
+ return "\n".join(lines)
262
+
263
+ def on_chat_header_settings_clicked(self, event: ChatHeader.SettingsClicked):
264
+ """Handle settings button click - toggle menu"""
265
+ menu = self.query_one("#settings-menu", ChatSettingsMenu)
266
+ if self.settings_menu_visible:
267
+ menu.remove_class("visible")
268
+ self.settings_menu_visible = False
269
+ else:
270
+ menu.add_class("visible")
271
+ self.settings_menu_visible = True
272
+
273
+ def on_list_view_selected(self, event: ListView.Selected):
274
+ """Handle menu item selection"""
275
+ menu = self.query_one("#settings-menu", ChatSettingsMenu)
276
+ menu.remove_class("visible")
277
+ self.settings_menu_visible = False
278
+
279
+ if event.item.id == "clear-history":
280
+ self._clear_history()
281
+
282
+ def _clear_history(self):
283
+ """Clear conversation history"""
284
+ self.conversation_history = []
285
+ self.session_manager.clear_session()
286
+ self.session_input_tokens = 0
287
+ self.session_output_tokens = 0
288
+ self.session_cache_read_tokens = 0
289
+ self.session_cache_write_tokens = 0
290
+ self.last_query_input_tokens = 0
291
+ self.last_query_output_tokens = 0
292
+ self.last_query_cache_read = 0
293
+ self.last_query_cache_write = 0
294
+
295
+ # Reset persistent footer metrics
296
+ self.query_count = 0
297
+ self.total_user_input = 0
298
+ self.total_output = 0
299
+ self.total_files_accessed = 0
300
+ self.total_savings_percentage = 0.0
301
+
302
+ self._update_token_display()
303
+ self._update_footer()
304
+ self._refresh_conversation()
305
+
306
+ def on_key(self, event):
307
+ """Handle keyboard shortcuts"""
308
+ if event.key == "ctrl+l":
309
+ self._clear_history()
310
+ event.prevent_default()
311
+ event.stop()
312
+
313
+ async def on_chat_input_submit(self, event: ChatInput.Submit):
314
+ """Handle message submission from ChatInput"""
315
+ self.conversation_history.append({
316
+ "role": "user",
317
+ "content": event.text
318
+ })
319
+ self._refresh_conversation()
320
+ await self.send_message(event.text)
321
+
322
+ async def send_message(self, user_message: str):
323
+ """Send message to Claude backend"""
324
+ conversation = self.query_one("#conversation-history", Markdown)
325
+ token_counter = self.query_one("#token-counter", Static)
326
+ status_line = self.query_one("#status-line", Static)
327
+
328
+ # Show status line and start timer
329
+ status_line.add_class("visible")
330
+ self.status_start_time = 0
331
+ self.current_status_text = "Analyzing..."
332
+ self._update_status_display()
333
+
334
+ # Start update timer (update every second)
335
+ self.status_timer = self.set_interval(1.0, self._update_status_timer)
336
+
337
+ try:
338
+ async with httpx.AsyncClient() as client:
339
+ async with client.stream(
340
+ "POST",
341
+ "http://localhost:8000/api/chat/stream",
342
+ json={
343
+ "message": user_message,
344
+ "conversation_history": self.conversation_history
345
+ },
346
+ timeout=None
347
+ ) as response:
348
+ response.raise_for_status()
349
+
350
+ full_response = ""
351
+ async for line in response.aiter_lines():
352
+ if not line.strip():
353
+ continue
354
+
355
+ try:
356
+ data = json.loads(line)
357
+
358
+ if data["type"] == "status":
359
+ # Update status line
360
+ self.current_status_text = data["content"]
361
+ self.status_start_time = data.get("elapsed", 0)
362
+
363
+ # Mark as completed if status is "Complete"
364
+ status_line = self.query_one("#status-line", Static)
365
+ if data["content"] == "Complete":
366
+ status_line.add_class("completed")
367
+ else:
368
+ status_line.remove_class("completed")
369
+
370
+ self._update_status_display()
371
+
372
+ elif data["type"] == "text":
373
+ full_response += data["content"]
374
+
375
+ elif data["type"] == "tokens":
376
+ self.last_query_input_tokens = data["input"]
377
+ self.last_query_output_tokens = data["output"]
378
+ self.last_query_cache_read = data.get("cache_read", 0)
379
+ self.last_query_cache_write = data.get("cache_write", 0)
380
+
381
+ self.session_input_tokens += data["input"]
382
+ self.session_output_tokens += data["output"]
383
+ self.session_cache_read_tokens += data.get("cache_read", 0)
384
+ self.session_cache_write_tokens += data.get("cache_write", 0)
385
+
386
+ # Update persistent metrics
387
+ self.query_count += 1
388
+ self.total_user_input += data.get("user_input_only", 0)
389
+ self.total_output += data["output"]
390
+ self.total_files_accessed += data.get("files_accessed", 0)
391
+ # Recalculate average savings
392
+ if self.query_count > 0:
393
+ self.total_savings_percentage = data.get("savings_percentage", 0.0)
394
+
395
+ self._update_token_display()
396
+ self._update_footer()
397
+
398
+ except json.JSONDecodeError:
399
+ continue
400
+
401
+ # Stop status timer but KEEP status line visible with final state
402
+ if hasattr(self, 'status_timer'):
403
+ self.status_timer.stop()
404
+ # Status line stays visible showing "Complete | Xs"
405
+
406
+ self.conversation_history.append({
407
+ "role": "assistant",
408
+ "content": full_response
409
+ })
410
+ self._refresh_conversation()
411
+ self.set_timer(0.5, self._scroll_to_bottom)
412
+
413
+ max_messages = 10
414
+ if len(self.conversation_history) > max_messages:
415
+ self.conversation_history = self.conversation_history[-max_messages:]
416
+
417
+ self.session_manager.save_session(
418
+ messages=self.conversation_history,
419
+ metadata={
420
+ "total_tokens": self.session_input_tokens + self.session_output_tokens,
421
+ "input_tokens": self.session_input_tokens,
422
+ "output_tokens": self.session_output_tokens,
423
+ "cache_read": self.session_cache_read_tokens,
424
+ "cache_write": self.session_cache_write_tokens,
425
+ "query_count": self.query_count,
426
+ "total_user_input": self.total_user_input,
427
+ "total_output": self.total_output,
428
+ "total_files_accessed": self.total_files_accessed,
429
+ "total_savings_percentage": self.total_savings_percentage
430
+ }
431
+ )
432
+
433
+ except httpx.HTTPError as e:
434
+ self.conversation_history.append({
435
+ "role": "assistant",
436
+ "content": f"**Error:** {type(e).__name__}: {str(e)}"
437
+ })
438
+ self._refresh_conversation()
439
+ except Exception as e:
440
+ self.conversation_history.append({
441
+ "role": "assistant",
442
+ "content": f"**Error:** {type(e).__name__}: {str(e)}"
443
+ })
444
+ self._refresh_conversation()
445
+
446
+ def _refresh_conversation(self):
447
+ """Rebuild and update the conversation display"""
448
+ conversation = self.query_one("#conversation-history", Markdown)
449
+ conversation.update(self._build_conversation_markdown())
450
+ self.call_after_refresh(self._scroll_to_bottom)
451
+ self.set_timer(0.2, self._scroll_to_bottom)
452
+
453
+ def _scroll_to_bottom(self):
454
+ """Scroll conversation to bottom"""
455
+ try:
456
+ scroll = self.query_one("#conversation-scroll", VerticalScroll)
457
+ scroll.scroll_end(duration=0)
458
+ scroll.scroll_to(y=scroll.max_scroll_y, animate=False)
459
+ except:
460
+ pass
461
+
462
+ def _update_token_display(self):
463
+ """Update the token counter display (2 lines)"""
464
+ token_counter = self.query_one("#token-counter", Static)
465
+
466
+ # Only show cache tokens with yellow color
467
+ line1 = f"Last: [yellow]💰{self.last_query_cache_read:,} cache read, 💰{self.last_query_cache_write:,} cache write[/yellow]"
468
+ line2 = f"Session: [yellow]💰{self.session_cache_read_tokens:,} cache read, 💰{self.session_cache_write_tokens:,} cache write[/yellow]"
469
+
470
+ token_counter.update(f"{line1}\n{line2}")
471
+
472
+ def _update_status_display(self):
473
+ """Update the status line with current status and elapsed time"""
474
+ if not hasattr(self, 'current_status_text') or not self.current_status_text:
475
+ return
476
+
477
+ status_line = self.query_one("#status-line", Static)
478
+ elapsed = self.status_start_time
479
+
480
+ # Format time: "5s" or "1m 30s"
481
+ if elapsed < 60:
482
+ time_str = f"{elapsed}s"
483
+ else:
484
+ minutes = elapsed // 60
485
+ seconds = elapsed % 60
486
+ time_str = f"{minutes}m {seconds}s"
487
+
488
+ # Different format for completed vs active
489
+ if self.current_status_text == "Complete":
490
+ # Vary completion message based on duration
491
+ if elapsed < 5:
492
+ verb = "Completed"
493
+ elif elapsed < 15:
494
+ verb = "Finished"
495
+ elif elapsed < 30:
496
+ verb = "Churned"
497
+ elif elapsed < 60:
498
+ verb = "Cooked"
499
+ else:
500
+ verb = "Grinded"
501
+ status_line.update(f"{verb} in {time_str}")
502
+ else:
503
+ status_line.update(f"{self.current_status_text} | {time_str}")
504
+
505
+ def _update_status_timer(self):
506
+ """Called every second to update elapsed time"""
507
+ if hasattr(self, 'status_start_time'):
508
+ self.status_start_time += 1
509
+ self._update_status_display()
510
+
511
+ def _update_footer(self):
512
+ """Update the footer bar with persistent stats"""
513
+ try:
514
+ from components.bars.footer_bar import FooterBar
515
+ footer = self.app.query_one(FooterBar)
516
+ footer.update_stats(
517
+ self.query_count,
518
+ self.total_user_input,
519
+ self.total_output,
520
+ self.total_savings_percentage
521
+ )
522
+ except Exception:
523
+ pass