code-context-control 2.28.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 (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
@@ -0,0 +1,82 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ from backend import get_c3_path
6
+ from screens.stats import Card
7
+ from textual import work
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.widgets import Button, Checkbox, Input, Label, Log, Select
11
+
12
+
13
+ class InitView(Vertical):
14
+ def compose(self) -> ComposeResult:
15
+ with Card("Project Initialization", ""):
16
+ yield Label("Initialize or update C3 in a project.", classes="card-title")
17
+ yield Label("IDE sets the MCP config target. Force skips prompts. Clear removes C3 files.", classes="info-text")
18
+ with Horizontal(classes="input-row"):
19
+ yield Label("Path:", classes="input-label")
20
+ yield Input(".", id="path_input")
21
+
22
+ with Horizontal(classes="input-row"):
23
+ yield Label("IDE:", classes="input-label")
24
+ ide_options = [(ide, ide) for ide in ["auto", "claude", "vscode", "cursor", "codex", "gemini", "antigravity"]]
25
+ yield Select(ide_options, value="auto", id="ide_select")
26
+ yield Label("MCP Mode:", classes="input-label")
27
+ yield Select([("direct", "direct"), ("proxy", "proxy")], value="direct", id="mcp_mode_select")
28
+
29
+ with Horizontal(classes="input-row"):
30
+ yield Checkbox("Force re-init", id="force_check")
31
+ yield Checkbox("Clear all C3 files", id="clear_check")
32
+ yield Checkbox("Init Git repo", id="git_check")
33
+
34
+ with Horizontal(classes="action-row"):
35
+ yield Button("Initialize", id="run_btn", variant="primary")
36
+
37
+ with Card("Setup Logs", ""):
38
+ yield Log(id="output_log", highlight=True)
39
+
40
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
41
+ if event.button.id == "run_btn":
42
+ path = self.query_one("#path_input").value
43
+ force = self.query_one("#force_check").value
44
+ clear = self.query_one("#clear_check").value
45
+ git = self.query_one("#git_check").value
46
+ ide = self.query_one("#ide_select").value
47
+ mcp_mode = self.query_one("#mcp_mode_select").value
48
+
49
+ self.query_one("#output_log").clear()
50
+ self.query_one("#run_btn").disabled = True
51
+
52
+ args = []
53
+ if force: args.append("--force")
54
+ if clear: args.append("--clear")
55
+ if git: args.append("--git")
56
+ if ide and ide != "auto":
57
+ args.extend(["--ide", ide])
58
+ if mcp_mode and mcp_mode != "direct":
59
+ args.extend(["--mcp-mode", mcp_mode])
60
+
61
+ self.run_init(path, args)
62
+
63
+ @work(exclusive=True)
64
+ async def run_init(self, path: str, additional_args: list) -> None:
65
+ c3_path, root_dir = get_c3_path()
66
+ args = ["init"] + additional_args
67
+ if path:
68
+ args.append(path)
69
+
70
+ cmd = [sys.executable, c3_path] + args
71
+ env = os.environ.copy()
72
+ env["PYTHONPATH"] = root_dir
73
+
74
+ process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env)
75
+ log_widget = self.query_one("#output_log")
76
+ while True:
77
+ line = await process.stdout.readline()
78
+ if not line: break
79
+ log_widget.write_line(line.decode(errors="replace").rstrip())
80
+ await process.wait()
81
+
82
+ self.query_one("#run_btn").disabled = False
@@ -0,0 +1,73 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ from backend import get_c3_path
6
+ from screens.stats import Card
7
+ from textual import work
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.widgets import Button, Input, Label, Log, Select
11
+
12
+
13
+ class MCPView(Vertical):
14
+ def compose(self) -> ComposeResult:
15
+ with Card("MCP Server Management", ""):
16
+ yield Label("Configure and register MCP servers.", classes="card-title")
17
+ yield Label("Targets: Optional project path and/or IDE shorthand (e.g. . claude)", classes="info-text")
18
+ with Horizontal(classes="input-row"):
19
+ yield Label("Targets (Path/IDE): ", classes="input-label")
20
+ yield Input(placeholder=". claude", id="targets_input")
21
+
22
+ with Horizontal(classes="input-row"):
23
+ yield Label("IDE:", classes="input-label")
24
+ ide_options = [(ide, ide) for ide in ["auto", "claude", "vscode", "cursor", "codex", "gemini", "antigravity"]]
25
+ yield Select(ide_options, value="auto", id="ide_select")
26
+ yield Label("MCP Mode:", classes="input-label")
27
+ yield Select([("direct", "direct"), ("proxy", "proxy")], value="direct", id="mcp_mode_select")
28
+
29
+ with Horizontal(classes="action-row"):
30
+ yield Button("Register Server", id="run_btn", variant="primary")
31
+
32
+ with Card("Status", ""):
33
+ yield Log(id="output_log", highlight=True)
34
+
35
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
36
+ if event.button.id == "run_btn":
37
+ targets_raw = self.query_one("#targets_input").value
38
+ ide = self.query_one("#ide_select").value
39
+ mcp_mode = self.query_one("#mcp_mode_select").value
40
+
41
+ self.query_one("#output_log").clear()
42
+ self.query_one("#run_btn").disabled = True
43
+
44
+ args = []
45
+ if ide and ide != "auto":
46
+ args.extend(["--ide", ide])
47
+ if mcp_mode and mcp_mode != "direct":
48
+ args.extend(["--mcp-mode", mcp_mode])
49
+
50
+ # Parse targets (space separated)
51
+ targets = []
52
+ if targets_raw:
53
+ targets = targets_raw.split()
54
+
55
+ self.run_mcp(args, targets)
56
+
57
+ @work(exclusive=True)
58
+ async def run_mcp(self, args: list, targets: list) -> None:
59
+ c3_path, root_dir = get_c3_path()
60
+ cmd_args = ["install-mcp"] + args + targets
61
+ cmd = [sys.executable, c3_path] + cmd_args
62
+ env = os.environ.copy()
63
+ env["PYTHONPATH"] = root_dir
64
+
65
+ process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env)
66
+ log_widget = self.query_one("#output_log")
67
+ while True:
68
+ line = await process.stdout.readline()
69
+ if not line: break
70
+ log_widget.write_line(line.decode(errors="replace").rstrip())
71
+ await process.wait()
72
+
73
+ self.query_one("#run_btn").disabled = False
@@ -0,0 +1,41 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ from backend import get_c3_path
6
+ from screens.stats import Card
7
+ from textual import work
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.widgets import Button, Label, Log
11
+
12
+
13
+ class OptimizeView(Vertical):
14
+ def compose(self) -> ComposeResult:
15
+ with Card("Context Optimization", ""):
16
+ yield Label("Analyzes and optimizes the project index and context usage.")
17
+ with Horizontal(classes="action-row"):
18
+ yield Button("Run Optimization", id="run_btn", variant="primary")
19
+
20
+ with Card("Analysis Report", ""):
21
+ yield Log(id="output_log", highlight=True)
22
+
23
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
24
+ if event.button.id == "run_btn":
25
+ self.query_one("#output_log").clear()
26
+ self.run_optimize()
27
+
28
+ @work(exclusive=True)
29
+ async def run_optimize(self) -> None:
30
+ c3_path, root_dir = get_c3_path()
31
+ cmd = [sys.executable, c3_path, "optimize"]
32
+ env = os.environ.copy()
33
+ env["PYTHONPATH"] = root_dir
34
+
35
+ process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env)
36
+ log_widget = self.query_one("#output_log")
37
+ while True:
38
+ line = await process.stdout.readline()
39
+ if not line: break
40
+ log_widget.write_line(line.decode(errors="replace").rstrip())
41
+ await process.wait()
@@ -0,0 +1,46 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ from backend import get_c3_path
6
+ from screens.stats import Card
7
+ from textual import work
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.widgets import Button, Input, Label, Log
11
+
12
+
13
+ class PipeView(Vertical):
14
+ def compose(self) -> ComposeResult:
15
+ with Card("Tool Pipeline", ""):
16
+ yield Label("Executes a full context-to-answer pipeline.")
17
+ with Horizontal(classes="input-row"):
18
+ yield Label("Query: ", classes="input-label")
19
+ yield Input(placeholder="How do I fix the TUI?", id="query_input")
20
+ with Horizontal(classes="action-row"):
21
+ yield Button("Run Pipeline", id="run_btn", variant="primary")
22
+
23
+ with Card("Pipeline Output", ""):
24
+ yield Log(id="output_log", highlight=True)
25
+
26
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
27
+ if event.button.id == "run_btn":
28
+ query = self.query_one("#query_input").value
29
+ if not query: return
30
+ self.query_one("#output_log").clear()
31
+ self.run_pipe(query)
32
+
33
+ @work(exclusive=True)
34
+ async def run_pipe(self, query: str) -> None:
35
+ c3_path, root_dir = get_c3_path()
36
+ cmd = [sys.executable, c3_path, "pipe", query]
37
+ env = os.environ.copy()
38
+ env["PYTHONPATH"] = root_dir
39
+
40
+ process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env)
41
+ log_widget = self.query_one("#output_log")
42
+ while True:
43
+ line = await process.stdout.readline()
44
+ if not line: break
45
+ log_widget.write_line(line.decode(errors="replace").rstrip())
46
+ await process.wait()
@@ -0,0 +1,355 @@
1
+ """Project Hub - central manager for all C3 projects and their sessions."""
2
+
3
+ import sys
4
+ import time
5
+ import webbrowser
6
+ from pathlib import Path
7
+
8
+ from rich.text import Text
9
+ from screens.stats import Card
10
+ from textual import work
11
+ from textual.app import ComposeResult
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.widgets import Button, DataTable, Input, Label, Static
14
+
15
+
16
+ class ProjectsView(Vertical):
17
+ """Central project tracker and session manager."""
18
+
19
+ def __init__(self, **kwargs):
20
+ super().__init__(**kwargs)
21
+ self._projects: list = []
22
+ self._view_mode = "table"
23
+ self._selected_project_path: str | None = None
24
+
25
+ def compose(self) -> ComposeResult:
26
+ with Card("Add / Register Project", ""):
27
+ with Horizontal(classes="proj-add-row"):
28
+ yield Label("Path:", classes="input-label")
29
+ yield Input(
30
+ placeholder="Absolute path to project (e.g. C:/projects/myapp)",
31
+ id="proj_path_input",
32
+ classes="proj-path-input",
33
+ )
34
+ yield Label("Name:", classes="input-label")
35
+ yield Input(
36
+ placeholder="Optional display name",
37
+ id="proj_name_input",
38
+ classes="proj-name-input",
39
+ )
40
+ yield Button("Add", id="add_btn", variant="primary")
41
+
42
+ with Card("Registered Projects", ""):
43
+ with Horizontal(classes="proj-toolbar-row"):
44
+ yield Static(
45
+ "Browse projects as a table or a card grid.",
46
+ classes="info-text",
47
+ )
48
+ yield Button("List View", id="table_view_btn", variant="primary")
49
+ yield Button("Grid View", id="grid_view_btn")
50
+ yield DataTable(id="projects_table")
51
+ yield Vertical(id="projects_grid")
52
+
53
+ with Card("Session Actions", ""):
54
+ with Horizontal(classes="proj-action-row"):
55
+ yield Button("Refresh", id="refresh_btn")
56
+ yield Button("Start Session", id="start_btn", variant="primary")
57
+ yield Button("Open Browser", id="open_btn")
58
+ yield Button("Stop Session", id="stop_btn", variant="error")
59
+ yield Button("Remove Project", id="remove_btn")
60
+ yield Static("Select a project row to manage it.", id="status_label")
61
+
62
+ def on_mount(self) -> None:
63
+ table = self.query_one("#projects_table", DataTable)
64
+ table.add_columns("Name", "Path", "IDE", "Status", "Port", "Last Session", "Added")
65
+ table.cursor_type = "row"
66
+ table.zebra_stripes = True
67
+ self.query_one("#projects_grid", Vertical).display = False
68
+ self.load_projects()
69
+ self.set_interval(15.0, self.load_projects)
70
+
71
+ @work(exclusive=True, thread=True)
72
+ def load_projects(self) -> None:
73
+ try:
74
+ pm = self._get_pm()
75
+ projects = pm.list_projects()
76
+ self.app.call_from_thread(self._render_projects, projects)
77
+ except Exception as e:
78
+ self.app.call_from_thread(
79
+ self._set_status, f"[red]Error loading projects: {e}[/]"
80
+ )
81
+
82
+ def _render_projects(self, projects: list) -> None:
83
+ self._projects = projects
84
+ table = self.query_one("#projects_table", DataTable)
85
+ grid = self.query_one("#projects_grid", Vertical)
86
+ table.clear()
87
+
88
+ if self._selected_project_path and not any(
89
+ p.get("path") == self._selected_project_path for p in projects
90
+ ):
91
+ self._selected_project_path = None
92
+ if not self._selected_project_path and projects:
93
+ self._selected_project_path = projects[0].get("path")
94
+
95
+ for project in projects:
96
+ status = (
97
+ Text("IN PROGRESS", style="bold green")
98
+ if project.get("session_active")
99
+ else Text("UI ACTIVE", style="bold green")
100
+ if project.get("ui_active")
101
+ else Text("stopped", style="dim #6B7280")
102
+ )
103
+ port_str = str(project["port"]) if project.get("port") else "-"
104
+ last = (project.get("last_session") or "never")
105
+ if last != "never":
106
+ last = last[:10]
107
+ added = (project.get("added_at") or "")[:10]
108
+ path = project.get("path", "")
109
+ display_path = ("..." + path[-37:]) if len(path) > 40 else path
110
+
111
+ table.add_row(
112
+ project.get("name", "?"),
113
+ display_path,
114
+ project.get("ide", "?"),
115
+ status,
116
+ port_str,
117
+ last,
118
+ added,
119
+ key=project.get("path", ""),
120
+ )
121
+
122
+ grid.query("*").remove()
123
+ if not projects:
124
+ grid.mount(
125
+ Static(
126
+ "No projects registered yet. Add one above to populate the hub.",
127
+ classes="projects-empty-state",
128
+ )
129
+ )
130
+ else:
131
+ for start in range(0, len(projects), 2):
132
+ row = Horizontal(classes="project-grid-row")
133
+ for index, project in enumerate(projects[start : start + 2], start=start):
134
+ row.mount(
135
+ Button(
136
+ self._project_card_label(project),
137
+ id=f"project_card__{index}",
138
+ classes="project-card",
139
+ variant=(
140
+ "primary"
141
+ if project.get("path") == self._selected_project_path
142
+ else "default"
143
+ ),
144
+ )
145
+ )
146
+ grid.mount(row)
147
+
148
+ active_count = sum(1 for p in projects if p.get("active"))
149
+ self._set_status(
150
+ f"{len(projects)} project(s) registered - "
151
+ f"[bold green]{active_count} active[/] session(s). "
152
+ f"{self._selection_hint()}"
153
+ )
154
+ self._sync_view_mode_buttons()
155
+ self._update_view_visibility()
156
+
157
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
158
+ btn_id = event.button.id
159
+
160
+ if btn_id == "refresh_btn":
161
+ self.load_projects()
162
+ return
163
+
164
+ if btn_id == "table_view_btn":
165
+ self._view_mode = "table"
166
+ self._sync_view_mode_buttons()
167
+ self._update_view_visibility()
168
+ self._set_status("[green]Projects view switched to list mode.[/]")
169
+ return
170
+
171
+ if btn_id == "grid_view_btn":
172
+ self._view_mode = "grid"
173
+ self._sync_view_mode_buttons()
174
+ self._update_view_visibility()
175
+ self._set_status("[green]Projects view switched to grid mode.[/]")
176
+ return
177
+
178
+ if btn_id == "add_btn":
179
+ path = self.query_one("#proj_path_input", Input).value.strip()
180
+ name = self.query_one("#proj_name_input", Input).value.strip() or None
181
+ if not path:
182
+ self._set_status("[yellow]Enter a project path first.[/]")
183
+ return
184
+ self._do_add(path, name)
185
+ return
186
+
187
+ if btn_id and btn_id.startswith("project_card__"):
188
+ index = int(btn_id.split("__", 1)[1])
189
+ if 0 <= index < len(self._projects):
190
+ project = self._projects[index]
191
+ self._selected_project_path = project.get("path")
192
+ self._render_projects(self._projects)
193
+ self._set_status(
194
+ f"[green]Selected [bold]{project.get('name', '?')}[/] in grid view.[/]"
195
+ )
196
+ return
197
+
198
+ proj = self._selected_project()
199
+ if proj is None:
200
+ self._set_status(f"[yellow]{self._selection_hint()}[/]")
201
+ return
202
+
203
+ if btn_id == "start_btn":
204
+ if proj.get("ui_active"):
205
+ self._set_status(
206
+ f"[yellow]Session already running on port {proj['port']}.[/]"
207
+ )
208
+ else:
209
+ self._do_launch(proj["path"])
210
+
211
+ elif btn_id == "open_btn":
212
+ if not proj.get("ui_active"):
213
+ self._set_status("[yellow]No active session. Start one first.[/]")
214
+ else:
215
+ webbrowser.open(f"http://localhost:{proj['port']}")
216
+ self._set_status(f"[green]Opened http://localhost:{proj['port']}[/]")
217
+
218
+ elif btn_id == "stop_btn":
219
+ if not proj.get("ui_active"):
220
+ self._set_status("[yellow]No active session to stop.[/]")
221
+ else:
222
+ self._do_stop(proj["port"])
223
+
224
+ elif btn_id == "remove_btn":
225
+ self._do_remove(proj["path"])
226
+
227
+ @work(exclusive=False, thread=True)
228
+ def _do_add(self, path: str, name) -> None:
229
+ try:
230
+ pm = self._get_pm()
231
+ entry = pm.add_project(path, name)
232
+ self.app.call_from_thread(
233
+ self._set_status,
234
+ f"[green]Registered: [bold]{entry['name']}[/] - {entry['path']}[/]",
235
+ )
236
+ self.app.call_from_thread(self._clear_add_inputs)
237
+ self.load_projects()
238
+ except Exception as e:
239
+ self.app.call_from_thread(self._set_status, f"[red]Error: {e}[/]")
240
+
241
+ @work(exclusive=False, thread=True)
242
+ def _do_launch(self, path: str) -> None:
243
+ try:
244
+ pm = self._get_pm()
245
+ ok = pm.launch_session(path)
246
+ if ok:
247
+ time.sleep(2.5)
248
+ self.load_projects()
249
+ self.app.call_from_thread(
250
+ self._set_status,
251
+ f"[green]Session launched for [bold]{path}[/][/]",
252
+ )
253
+ else:
254
+ self.app.call_from_thread(
255
+ self._set_status, "[red]Failed to launch session.[/]"
256
+ )
257
+ except Exception as e:
258
+ self.app.call_from_thread(self._set_status, f"[red]Error: {e}[/]")
259
+
260
+ @work(exclusive=False, thread=True)
261
+ def _do_stop(self, port: int) -> None:
262
+ try:
263
+ pm = self._get_pm()
264
+ pm.stop_session(port)
265
+ time.sleep(1)
266
+ self.load_projects()
267
+ self.app.call_from_thread(
268
+ self._set_status, f"[green]Session on port {port} stopped.[/]"
269
+ )
270
+ except Exception as e:
271
+ self.app.call_from_thread(self._set_status, f"[red]Error: {e}[/]")
272
+
273
+ @work(exclusive=False, thread=True)
274
+ def _do_remove(self, path: str) -> None:
275
+ try:
276
+ pm = self._get_pm()
277
+ removed = pm.remove_project(path)
278
+ msg = (
279
+ f"[green]Removed project: {path}[/]"
280
+ if removed
281
+ else "[yellow]Project not found in registry.[/]"
282
+ )
283
+ self.app.call_from_thread(self._set_status, msg)
284
+ self.load_projects()
285
+ except Exception as e:
286
+ self.app.call_from_thread(self._set_status, f"[red]Error: {e}[/]")
287
+
288
+ def _get_pm(self):
289
+ root = str(Path(__file__).parent.parent.parent)
290
+ if root not in sys.path:
291
+ sys.path.insert(0, root)
292
+ from services.project_manager import ProjectManager
293
+
294
+ return ProjectManager()
295
+
296
+ def _selected_project(self):
297
+ if self._view_mode == "grid":
298
+ if not self._selected_project_path:
299
+ return None
300
+ for project in self._projects:
301
+ if project.get("path") == self._selected_project_path:
302
+ return project
303
+ return None
304
+
305
+ table = self.query_one("#projects_table", DataTable)
306
+ idx = table.cursor_row
307
+ if idx is not None and self._projects and idx < len(self._projects):
308
+ return self._projects[idx]
309
+ return None
310
+
311
+ def _project_card_label(self, project: dict) -> str:
312
+ if project.get("session_active"):
313
+ status = "IN PROGRESS"
314
+ elif project.get("ui_active"):
315
+ status = "UI ACTIVE"
316
+ else:
317
+ status = "stopped"
318
+ port = project.get("port") or "-"
319
+ last = (project.get("last_session") or "never")[:10]
320
+ added = (project.get("added_at") or "")[:10] or "unknown"
321
+ return (
322
+ f"{project.get('name', '?')}\n"
323
+ f"{project.get('ide', '?').upper()} {status}\n"
324
+ f"Port: {port} Last: {last}\n"
325
+ f"{project.get('path', '')}\n"
326
+ f"Added: {added}"
327
+ )
328
+
329
+ def _selection_hint(self) -> str:
330
+ if self._view_mode == "grid":
331
+ return "Select a card, then use the buttons below."
332
+ return "Select a row, then use the buttons below."
333
+
334
+ def _sync_view_mode_buttons(self) -> None:
335
+ table_btn = self.query_one("#table_view_btn", Button)
336
+ grid_btn = self.query_one("#grid_view_btn", Button)
337
+ if self._view_mode == "table":
338
+ table_btn.variant = "primary"
339
+ grid_btn.variant = "default"
340
+ else:
341
+ table_btn.variant = "default"
342
+ grid_btn.variant = "primary"
343
+
344
+ def _update_view_visibility(self) -> None:
345
+ table = self.query_one("#projects_table", DataTable)
346
+ grid = self.query_one("#projects_grid", Vertical)
347
+ table.display = self._view_mode == "table"
348
+ grid.display = self._view_mode == "grid"
349
+
350
+ def _set_status(self, msg: str) -> None:
351
+ self.query_one("#status_label", Static).update(msg)
352
+
353
+ def _clear_add_inputs(self) -> None:
354
+ self.query_one("#proj_path_input", Input).value = ""
355
+ self.query_one("#proj_name_input", Input).value = ""
@@ -0,0 +1,55 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ from backend import get_c3_path
6
+ from screens.stats import Card
7
+ from textual import work
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.widgets import Button, Input, Label, Log
11
+
12
+
13
+ class SearchView(Vertical):
14
+ def compose(self) -> ComposeResult:
15
+ with Card("Context Search", ""):
16
+ with Horizontal(classes="input-row"):
17
+ yield Label("Query: ", classes="input-label")
18
+ yield Input(placeholder="e.g. how does auth work?", id="search_input")
19
+ with Horizontal(classes="action-row"):
20
+ yield Button("Search Context", id="search_btn", variant="primary")
21
+
22
+ with Card("Search Results", ""):
23
+ yield Log(id="search_log", highlight=True)
24
+
25
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
26
+ if event.button.id == "search_btn":
27
+ query = self.query_one("#search_input").value
28
+ if not query:
29
+ return
30
+ self.query_one("#search_log").clear()
31
+ self.query_one("#search_btn").disabled = True
32
+ self.run_search(query)
33
+
34
+ @work(exclusive=True)
35
+ async def run_search(self, query: str) -> None:
36
+ c3_path, root_dir = get_c3_path()
37
+ env = os.environ.copy()
38
+ env["PYTHONPATH"] = root_dir
39
+
40
+ cmd = [sys.executable, c3_path, "context", query]
41
+ process = await asyncio.create_subprocess_exec(
42
+ *cmd,
43
+ stdout=asyncio.subprocess.PIPE,
44
+ stderr=asyncio.subprocess.STDOUT,
45
+ env=env
46
+ )
47
+
48
+ log_widget = self.query_one("#search_log")
49
+ while True:
50
+ line = await process.stdout.readline()
51
+ if not line: break
52
+ log_widget.write_line(line.decode(errors="replace").rstrip())
53
+
54
+ await process.wait()
55
+ self.query_one("#search_btn").disabled = False