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.
- srcodex/__init__.py +0 -0
- srcodex/backend/__init__.py +0 -0
- srcodex/backend/chat.py +79 -0
- srcodex/backend/main.py +98 -0
- srcodex/backend/services/__init__.py +0 -0
- srcodex/backend/services/claude_service.py +754 -0
- srcodex/backend/services/config_loader.py +113 -0
- srcodex/backend/services/file_access_tools.py +279 -0
- srcodex/backend/services/file_tree.py +480 -0
- srcodex/backend/services/graph_tools.py +874 -0
- srcodex/backend/services/logger_setup.py +91 -0
- srcodex/backend/services/session_manager.py +81 -0
- srcodex/backend/services/status_tracker.py +91 -0
- srcodex/cli.py +255 -0
- srcodex/core/__init__.py +0 -0
- srcodex/core/config.py +113 -0
- srcodex/core/logger.py +23 -0
- srcodex/indexer/__init__.py +0 -0
- srcodex/indexer/cscope_client.py +183 -0
- srcodex/indexer/ctags_compat.py +223 -0
- srcodex/indexer/ctags_parser.py +456 -0
- srcodex/indexer/explorer.py +135 -0
- srcodex/indexer/field_access_analyzer.py +436 -0
- srcodex/indexer/indexer.py +664 -0
- srcodex/indexer/reference_ingestor.py +293 -0
- srcodex/indexer/reference_resolver.py +544 -0
- srcodex/tui/__init__.py +0 -0
- srcodex/tui/app.py +103 -0
- srcodex/tui/app.tcss +24 -0
- srcodex/tui/components/__init__.py +0 -0
- srcodex/tui/components/bars/__init__.py +0 -0
- srcodex/tui/components/bars/chat_header.py +48 -0
- srcodex/tui/components/bars/code_tab_bar.py +157 -0
- srcodex/tui/components/bars/footer_bar.py +128 -0
- srcodex/tui/components/bars/left_tab.py +54 -0
- srcodex/tui/components/logger.py +57 -0
- srcodex/tui/components/panels/__init__.py +0 -0
- srcodex/tui/components/panels/chat_panel.py +523 -0
- srcodex/tui/components/panels/code_panel.py +229 -0
- srcodex/tui/components/panels/side_panel.py +128 -0
- srcodex/tui/components/views/__init__.py +0 -0
- srcodex/tui/components/views/explorer_view.py +20 -0
- srcodex/tui/components/views/search_view.py +148 -0
- srcodex/tui/components/widgets/__init__.py +0 -0
- srcodex/tui/components/widgets/file_browser.py +16 -0
- srcodex/tui/components/widgets/find_box.py +85 -0
- srcodex-0.2.0.dist-info/METADATA +170 -0
- srcodex-0.2.0.dist-info/RECORD +52 -0
- srcodex-0.2.0.dist-info/WHEEL +5 -0
- srcodex-0.2.0.dist-info/entry_points.txt +2 -0
- srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
- srcodex-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from textual.widgets import Static
|
|
2
|
+
from textual.containers import Horizontal
|
|
3
|
+
from textual.message import Message
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChatHeaderButton(Static):
|
|
7
|
+
def __init__(self, label: str, button_id: str, **kwargs):
|
|
8
|
+
super().__init__(label, **kwargs)
|
|
9
|
+
self.button_id = button_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ChatHeader(Horizontal):
|
|
13
|
+
class SettingsClicked(Message):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
ChatHeader {
|
|
18
|
+
height: 1;
|
|
19
|
+
width: 100%;
|
|
20
|
+
align: right middle;
|
|
21
|
+
dock: top;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ChatHeaderButton {
|
|
25
|
+
width: auto;
|
|
26
|
+
height: 1;
|
|
27
|
+
background: transparent;
|
|
28
|
+
padding: 0 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ChatHeaderButton:hover {
|
|
32
|
+
background: transparent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ChatHeaderButton#header-settings {
|
|
36
|
+
margin-right: 4;
|
|
37
|
+
}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def compose(self):
|
|
41
|
+
yield ChatHeaderButton("✳", "header-chat", id="header-chat")
|
|
42
|
+
yield ChatHeaderButton("⚙", "header-settings", id="header-settings")
|
|
43
|
+
|
|
44
|
+
def on_click(self, event) -> None:
|
|
45
|
+
clicked = event.widget
|
|
46
|
+
if isinstance(clicked, ChatHeaderButton):
|
|
47
|
+
if clicked.button_id == "header-settings":
|
|
48
|
+
self.post_message(self.SettingsClicked())
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from textual.widgets import Static
|
|
3
|
+
from textual.containers import Horizontal
|
|
4
|
+
from textual.message import Message
|
|
5
|
+
|
|
6
|
+
class CloseButton(Static):
|
|
7
|
+
"""Close button for file tab"""
|
|
8
|
+
def __init__(self, **kwargs):
|
|
9
|
+
super().__init__("✕", **kwargs)
|
|
10
|
+
|
|
11
|
+
class FileTab(Horizontal):
|
|
12
|
+
"""File tab with filename and close button"""
|
|
13
|
+
def __init__(self, file_path: str, active: bool = False, **kwargs):
|
|
14
|
+
super().__init__(**kwargs)
|
|
15
|
+
self.file_path = file_path
|
|
16
|
+
self.filename = Path(file_path).name
|
|
17
|
+
if active:
|
|
18
|
+
self.add_class("active")
|
|
19
|
+
|
|
20
|
+
def compose(self):
|
|
21
|
+
yield Static(self.filename, classes="tab-label")
|
|
22
|
+
yield CloseButton(classes="tab-close")
|
|
23
|
+
|
|
24
|
+
class CodeTabBar(Horizontal):
|
|
25
|
+
"""Horizonatal tab bar for open files"""
|
|
26
|
+
class TabClicked(Message):
|
|
27
|
+
"""Posted when file tab is clicked"""
|
|
28
|
+
def __init__(self, file_path: str):
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.file_path = file_path
|
|
31
|
+
|
|
32
|
+
class TabClosed(Message):
|
|
33
|
+
"""Posted when tab close button is clicked"""
|
|
34
|
+
def __init__(self, file_path: str):
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.file_path = file_path
|
|
37
|
+
|
|
38
|
+
DEFAULT_CSS = """
|
|
39
|
+
CodeTabBar {
|
|
40
|
+
height: 1;
|
|
41
|
+
width: 100%;
|
|
42
|
+
align: left middle;
|
|
43
|
+
dock: top;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
FileTab {
|
|
47
|
+
width: auto;
|
|
48
|
+
height: 1;
|
|
49
|
+
background: transparent;
|
|
50
|
+
padding: 0 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
FileTab:hover {
|
|
54
|
+
background: transparent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
FileTab.active {
|
|
58
|
+
text-style: bold;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
FileTab .tab-label {
|
|
62
|
+
width: auto;
|
|
63
|
+
height: 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
FileTab .tab-close {
|
|
67
|
+
width: auto;
|
|
68
|
+
height: 1;
|
|
69
|
+
color: $text-muted;
|
|
70
|
+
padding: 0 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
FileTab .tab-close:hover {
|
|
74
|
+
color: $error;
|
|
75
|
+
text-style: bold;
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, **kwargs):
|
|
80
|
+
self.open_files = [] #list of open files
|
|
81
|
+
self.active_file = None
|
|
82
|
+
super().__init__(**kwargs)
|
|
83
|
+
|
|
84
|
+
def compose(self):
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
def add_tab(self, file_path: str, set_active: bool = True):
|
|
88
|
+
"""add new file tab"""
|
|
89
|
+
if file_path in self.open_files:
|
|
90
|
+
if set_active:
|
|
91
|
+
self._set_active_tab(file_path)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# add to open files list
|
|
95
|
+
self.open_files.append(file_path)
|
|
96
|
+
# create and mount the tab
|
|
97
|
+
is_active = set_active or len(self.open_files) == 1
|
|
98
|
+
tab = FileTab(file_path, active=is_active)
|
|
99
|
+
self.mount(tab)
|
|
100
|
+
|
|
101
|
+
if is_active:
|
|
102
|
+
self.active_file = file_path
|
|
103
|
+
|
|
104
|
+
def _set_active_tab(self, file_path: str):
|
|
105
|
+
"""set which tab is active"""
|
|
106
|
+
self.active_file = file_path
|
|
107
|
+
|
|
108
|
+
for tab in self.query(FileTab):
|
|
109
|
+
if tab.file_path == file_path:
|
|
110
|
+
tab.add_class("active")
|
|
111
|
+
else:
|
|
112
|
+
tab.remove_class("active")
|
|
113
|
+
|
|
114
|
+
def close_tab(self, file_path: str):
|
|
115
|
+
"""Close a tab"""
|
|
116
|
+
if file_path not in self.open_files:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
self.open_files.remove(file_path)
|
|
120
|
+
|
|
121
|
+
# Remove the tab widget
|
|
122
|
+
for tab in self.query(FileTab):
|
|
123
|
+
if tab.file_path == file_path:
|
|
124
|
+
tab.remove()
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
# If this was the active tab, switch to another
|
|
128
|
+
if self.active_file == file_path:
|
|
129
|
+
if self.open_files:
|
|
130
|
+
new_active = self.open_files[-1]
|
|
131
|
+
self._set_active_tab(new_active)
|
|
132
|
+
self.post_message(self.TabClicked(new_active))
|
|
133
|
+
else:
|
|
134
|
+
self.active_file = None
|
|
135
|
+
|
|
136
|
+
def on_click(self, event):
|
|
137
|
+
"""Handle tab clicks and close button"""
|
|
138
|
+
clicked = event.widget
|
|
139
|
+
|
|
140
|
+
# Check if close button was clicked
|
|
141
|
+
if isinstance(clicked, CloseButton):
|
|
142
|
+
# Find parent FileTab
|
|
143
|
+
parent = clicked.parent
|
|
144
|
+
if isinstance(parent, FileTab):
|
|
145
|
+
self.close_tab(parent.file_path)
|
|
146
|
+
self.post_message(self.TabClosed(parent.file_path))
|
|
147
|
+
|
|
148
|
+
# Check if tab label or tab itself was clicked
|
|
149
|
+
elif isinstance(clicked, FileTab):
|
|
150
|
+
self._set_active_tab(clicked.file_path)
|
|
151
|
+
self.post_message(self.TabClicked(clicked.file_path))
|
|
152
|
+
|
|
153
|
+
# Check if tab label (Static) inside FileTab was clicked
|
|
154
|
+
elif isinstance(clicked, Static) and isinstance(clicked.parent, FileTab):
|
|
155
|
+
parent = clicked.parent
|
|
156
|
+
self._set_active_tab(parent.file_path)
|
|
157
|
+
self.post_message(self.TabClicked(parent.file_path))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from textual.widgets import Static
|
|
2
|
+
from textual.containers import Horizontal
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FooterItem(Static):
|
|
9
|
+
"""Individual item in the footer bar"""
|
|
10
|
+
def __init__(self, label: str, item_id: str, **kwargs):
|
|
11
|
+
super().__init__(label, **kwargs)
|
|
12
|
+
self.item_id = item_id
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FooterBar(Horizontal):
|
|
16
|
+
"""Footer bar spanning full width at bottom of screen
|
|
17
|
+
|
|
18
|
+
Left: version, project name, database name
|
|
19
|
+
Right: persistent token usage (lifetime stats, never resets)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
DEFAULT_CSS = """
|
|
23
|
+
FooterBar {
|
|
24
|
+
height: 1;
|
|
25
|
+
width: 100%;
|
|
26
|
+
align: left middle;
|
|
27
|
+
dock: bottom;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
FooterItem {
|
|
31
|
+
width: auto;
|
|
32
|
+
height: 1;
|
|
33
|
+
background: transparent;
|
|
34
|
+
padding: 0 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
FooterItem:hover {
|
|
38
|
+
background: transparent;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#footer-spacer {
|
|
42
|
+
width: 1fr;
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, project_root: str = None, **kwargs):
|
|
47
|
+
super().__init__(**kwargs)
|
|
48
|
+
self.project_root = Path(project_root) if project_root else Path.cwd()
|
|
49
|
+
self.query_count = 0
|
|
50
|
+
self.total_user_input = 0
|
|
51
|
+
self.total_output = 0
|
|
52
|
+
self.savings_percentage = 0.0
|
|
53
|
+
|
|
54
|
+
def compose(self):
|
|
55
|
+
# Get version from package metadata
|
|
56
|
+
try:
|
|
57
|
+
version = importlib.metadata.version("srcodex")
|
|
58
|
+
except Exception:
|
|
59
|
+
version = "0.3.0"
|
|
60
|
+
|
|
61
|
+
# Get project name and database name from metadata.json
|
|
62
|
+
metadata_path = self.project_root / ".srcodex" / "metadata.json"
|
|
63
|
+
project_name = self.project_root.name
|
|
64
|
+
db_name = "unknown.db"
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
with open(metadata_path, 'r') as f:
|
|
68
|
+
metadata = json.load(f)
|
|
69
|
+
project_name = metadata.get("project", {}).get("name", project_name)
|
|
70
|
+
db_path = metadata.get("paths", {}).get("database", "")
|
|
71
|
+
if db_path:
|
|
72
|
+
db_name = Path(db_path).name
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# Load token stats from session
|
|
77
|
+
self.load_stats()
|
|
78
|
+
|
|
79
|
+
# Left side: version, project, database
|
|
80
|
+
yield FooterItem(f"v{version}", "footer-version", id="footer-version")
|
|
81
|
+
yield FooterItem(f"🖿 {project_name}", "footer-project", id="footer-project")
|
|
82
|
+
yield FooterItem(f"☰ {db_name}", "footer-database", id="footer-database")
|
|
83
|
+
|
|
84
|
+
# Spacer (pushes token stats to the right)
|
|
85
|
+
yield FooterItem("", "footer-spacer", id="footer-spacer")
|
|
86
|
+
|
|
87
|
+
# Right side: token stats (persistent)
|
|
88
|
+
yield FooterItem(self._format_stats(), "footer-tokens", id="footer-tokens")
|
|
89
|
+
|
|
90
|
+
def load_stats(self):
|
|
91
|
+
"""Load persistent stats from conversation metadata"""
|
|
92
|
+
metadata_path = self.project_root / ".srcodex" / "conversations" / "latest.json"
|
|
93
|
+
try:
|
|
94
|
+
with open(metadata_path, 'r') as f:
|
|
95
|
+
session_data = json.load(f)
|
|
96
|
+
metadata = session_data.get("metadata", {})
|
|
97
|
+
self.query_count = metadata.get("query_count", 0)
|
|
98
|
+
self.total_user_input = metadata.get("total_user_input", 0)
|
|
99
|
+
self.total_output = metadata.get("total_output", 0)
|
|
100
|
+
self.savings_percentage = metadata.get("total_savings_percentage", 0.0)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def update_stats(self, query_count, total_user_input, total_output, savings_percentage):
|
|
105
|
+
"""Update footer token stats"""
|
|
106
|
+
self.query_count = query_count
|
|
107
|
+
self.total_user_input = total_user_input
|
|
108
|
+
self.total_output = total_output
|
|
109
|
+
self.savings_percentage = savings_percentage
|
|
110
|
+
|
|
111
|
+
# Update footer display (right side only)
|
|
112
|
+
footer_item = self.query_one("#footer-tokens", FooterItem)
|
|
113
|
+
footer_item.update(self._format_stats())
|
|
114
|
+
|
|
115
|
+
def _format_stats(self):
|
|
116
|
+
"""Format stats string"""
|
|
117
|
+
# Format tokens (12500 → 12K, 125000 → 125K)
|
|
118
|
+
def format_tokens(tokens):
|
|
119
|
+
if tokens >= 1000:
|
|
120
|
+
return f"{tokens // 1000}K"
|
|
121
|
+
return str(tokens)
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
f"{self.query_count} queries 🪙"
|
|
125
|
+
f"{format_tokens(self.total_user_input)} input 🪙"
|
|
126
|
+
f"{format_tokens(self.total_output)} output "
|
|
127
|
+
f"({self.savings_percentage:.0f}% savings)"
|
|
128
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from textual.widgets import Static
|
|
2
|
+
from textual.containers import Horizontal
|
|
3
|
+
from textual.message import Message
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TabButton(Static):
|
|
7
|
+
def __init__(self, label: str, tab_id: str, active: bool = False, **kwargs):
|
|
8
|
+
super().__init__(label, **kwargs)
|
|
9
|
+
self.tab_id = tab_id
|
|
10
|
+
if active:
|
|
11
|
+
self.add_class("active")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LeftTab(Horizontal):
|
|
15
|
+
class TabClicked(Message):
|
|
16
|
+
def __init__(self, tab_id: str):
|
|
17
|
+
super().__init__()
|
|
18
|
+
self.tab_id = tab_id
|
|
19
|
+
|
|
20
|
+
DEFAULT_CSS = """
|
|
21
|
+
LeftTab {
|
|
22
|
+
height: 1;
|
|
23
|
+
width: 100%;
|
|
24
|
+
align: left middle;
|
|
25
|
+
dock: top;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
TabButton {
|
|
29
|
+
width: auto;
|
|
30
|
+
height: 1;
|
|
31
|
+
background: transparent;
|
|
32
|
+
padding: 0 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
TabButton:hover {
|
|
36
|
+
background: transparent;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
TabButton.active {
|
|
40
|
+
text-style: bold;
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def compose(self):
|
|
45
|
+
yield TabButton("📁", "tab-explorer", active=True, id="tab-explorer")
|
|
46
|
+
yield TabButton("🔍", "tab-search", id="tab-search")
|
|
47
|
+
|
|
48
|
+
def on_click(self, event) -> None:
|
|
49
|
+
clicked = event.widget
|
|
50
|
+
if isinstance(clicked, TabButton):
|
|
51
|
+
for tab in self.query(TabButton):
|
|
52
|
+
tab.remove_class("active")
|
|
53
|
+
clicked.add_class("active")
|
|
54
|
+
self.post_message(self.TabClicked(clicked.tab_id))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TUI Logging Setup
|
|
3
|
+
Logs TUI events to .srcodex/.debug/tui.log
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
# Add backend to path for config loader
|
|
10
|
+
backend_path = Path(__file__).parent.parent.parent / "backend"
|
|
11
|
+
sys.path.insert(0, str(backend_path))
|
|
12
|
+
|
|
13
|
+
from services.config_loader import get_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_tui_logging():
|
|
17
|
+
"""
|
|
18
|
+
Configure TUI logging to write to .srcodex/.debug/tui.log
|
|
19
|
+
Returns the logger instance
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
config = get_config()
|
|
23
|
+
debug_dir = config.project_root / ".srcodex" / ".debug"
|
|
24
|
+
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
log_file = debug_dir / "tui.log"
|
|
27
|
+
|
|
28
|
+
# Create logger
|
|
29
|
+
logger = logging.getLogger('srcodex.tui')
|
|
30
|
+
logger.setLevel(logging.INFO)
|
|
31
|
+
|
|
32
|
+
# File handler (append mode)
|
|
33
|
+
file_handler = logging.FileHandler(log_file, mode='a')
|
|
34
|
+
file_handler.setLevel(logging.INFO)
|
|
35
|
+
file_handler.setFormatter(
|
|
36
|
+
logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger.addHandler(file_handler)
|
|
40
|
+
logger.info("=" * 80)
|
|
41
|
+
logger.info("TUI session started")
|
|
42
|
+
|
|
43
|
+
return logger
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
# Fallback logger
|
|
47
|
+
logger = logging.getLogger('srcodex.tui')
|
|
48
|
+
logger.setLevel(logging.INFO)
|
|
49
|
+
handler = logging.StreamHandler()
|
|
50
|
+
handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
|
|
51
|
+
logger.addHandler(handler)
|
|
52
|
+
logger.warning(f"Could not set up TUI file logging: {e}")
|
|
53
|
+
return logger
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Initialize logger module-level
|
|
57
|
+
logger = setup_tui_logging()
|
|
File without changes
|