onecoder 0.0.2__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.
- onecoder/agent.py +95 -0
- onecoder/agentic_tool_search/__init__.py +0 -0
- onecoder/agentic_tool_search/dynamic_tool_search.py +64 -0
- onecoder/agentic_tool_search/registry.py +33 -0
- onecoder/agents/__init__.py +7 -0
- onecoder/agents/documentation_agent.py +12 -0
- onecoder/agents/file_reader_agent.py +19 -0
- onecoder/agents/file_writer_agent.py +19 -0
- onecoder/agents/orchestrator_agent.py +51 -0
- onecoder/agents/refactoring_agent.py +12 -0
- onecoder/agents/research_agent.py +31 -0
- onecoder/agents/task_suggestion_agent.py +88 -0
- onecoder/alignment.py +236 -0
- onecoder/api.py +162 -0
- onecoder/api_client.py +112 -0
- onecoder/backends/base.py +22 -0
- onecoder/backends/local_tui.py +65 -0
- onecoder/blackboard.py +102 -0
- onecoder/cli.py +108 -0
- onecoder/commands/__init__.py +1 -0
- onecoder/commands/auth.py +78 -0
- onecoder/commands/ci.py +29 -0
- onecoder/commands/delegate.py +557 -0
- onecoder/commands/doctor.py +40 -0
- onecoder/commands/issue.py +136 -0
- onecoder/commands/logs.py +45 -0
- onecoder/commands/project.py +270 -0
- onecoder/commands/server.py +170 -0
- onecoder/config_manager.py +87 -0
- onecoder/constants.py +9 -0
- onecoder/diagnostics/__init__.py +2 -0
- onecoder/diagnostics/env_scan.py +207 -0
- onecoder/discovery.py +101 -0
- onecoder/distillation.py +236 -0
- onecoder/evaluation/__init__.py +1 -0
- onecoder/evaluation/ttu.py +176 -0
- onecoder/governance/__init__.py +0 -0
- onecoder/governance/probllm.py +91 -0
- onecoder/hooks.py +74 -0
- onecoder/ipc_auth.py +200 -0
- onecoder/issues.py +188 -0
- onecoder/jules_client.py +343 -0
- onecoder/knowledge.py +106 -0
- onecoder/llm.py +61 -0
- onecoder/logger.py +42 -0
- onecoder/metrics.py +129 -0
- onecoder/models/delegation.py +46 -0
- onecoder/onboarding.py +264 -0
- onecoder/review.py +233 -0
- onecoder/services/delegation_service.py +209 -0
- onecoder/services/validation_service.py +104 -0
- onecoder/sessions.py +186 -0
- onecoder/sprint_collector.py +165 -0
- onecoder/sync.py +167 -0
- onecoder/tmux.py +86 -0
- onecoder/tools/__init__.py +10 -0
- onecoder/tools/executor.py +53 -0
- onecoder/tools/external_tools.py +106 -0
- onecoder/tools/file_tools.py +77 -0
- onecoder/tools/interface.py +25 -0
- onecoder/tools/jules_tools.py +122 -0
- onecoder/tools/kit_tools.py +122 -0
- onecoder/tools/registry.py +32 -0
- onecoder/tui/__init__.py +5 -0
- onecoder/tui/app.py +263 -0
- onecoder/tui/commands.py +150 -0
- onecoder/tui/widgets.py +92 -0
- onecoder/worktree.py +186 -0
- onecoder-0.0.2.dist-info/METADATA +17 -0
- onecoder-0.0.2.dist-info/RECORD +73 -0
- onecoder-0.0.2.dist-info/WHEEL +5 -0
- onecoder-0.0.2.dist-info/entry_points.txt +2 -0
- onecoder-0.0.2.dist-info/top_level.txt +1 -0
onecoder/tui/app.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""OneCoder Textual TUI Application."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import httpx
|
|
6
|
+
import os
|
|
7
|
+
from typing import Optional, AsyncGenerator
|
|
8
|
+
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.widgets import Header, Footer, Input, RichLog
|
|
11
|
+
from textual.containers import Vertical, Container
|
|
12
|
+
from textual import events
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.markdown import Markdown
|
|
17
|
+
|
|
18
|
+
from ..ipc_auth import get_token_from_ipc
|
|
19
|
+
from .widgets import ChatMessage, ToolCallStatus, ErrorMessage, WelcomeMessage
|
|
20
|
+
from .commands import CommandRegistry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OneCoderApp(App):
|
|
24
|
+
"""Modern Textual TUI for OneCoder agent system."""
|
|
25
|
+
|
|
26
|
+
CSS_PATH = "styles.tcss"
|
|
27
|
+
|
|
28
|
+
BINDINGS = [
|
|
29
|
+
Binding("ctrl+c", "quit", "Quit", show=True),
|
|
30
|
+
Binding("ctrl+l", "clear_log", "Clear Log", show=True),
|
|
31
|
+
Binding("ctrl+s", "toggle_theme", "Toggle Theme", show=True),
|
|
32
|
+
Binding("ctrl+d", "toggle_dark", "Dark Mode"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def __init__(self, api_url: Optional[str] = None):
|
|
36
|
+
super().__init__()
|
|
37
|
+
if api_url:
|
|
38
|
+
self.api_url = api_url
|
|
39
|
+
else:
|
|
40
|
+
self.api_url = os.getenv("ONECODER_API_URL", "http://127.0.0.1:8000")
|
|
41
|
+
self.session_id = "tui-session"
|
|
42
|
+
self.user_id = "local-user"
|
|
43
|
+
self.token = None
|
|
44
|
+
self.is_processing = False
|
|
45
|
+
self.command_registry = CommandRegistry(self)
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
yield Header(show_clock=True)
|
|
49
|
+
yield Vertical(
|
|
50
|
+
RichLog(id="chat-log", markup=True, wrap=True, highlight=True),
|
|
51
|
+
Input(
|
|
52
|
+
placeholder="Type your message... (or 'exit' to quit)", id="user-input"
|
|
53
|
+
),
|
|
54
|
+
id="main-container",
|
|
55
|
+
)
|
|
56
|
+
yield Footer()
|
|
57
|
+
|
|
58
|
+
async def on_mount(self) -> None:
|
|
59
|
+
self.chat_log = self.query_one("#chat-log", RichLog)
|
|
60
|
+
self.input_widget = self.query_one("#user-input", Input)
|
|
61
|
+
|
|
62
|
+
await self._initialize_session()
|
|
63
|
+
|
|
64
|
+
if self.token:
|
|
65
|
+
self.chat_log.write(WelcomeMessage())
|
|
66
|
+
self.chat_log.write("\n")
|
|
67
|
+
self.input_widget.focus()
|
|
68
|
+
|
|
69
|
+
async def _initialize_session(self) -> bool:
|
|
70
|
+
"""Initialize TUI and fetch authentication token."""
|
|
71
|
+
self.chat_log.write(
|
|
72
|
+
Panel(
|
|
73
|
+
"[bold blue]OneCoder TUI[/bold blue]",
|
|
74
|
+
title="Initializing secure session...",
|
|
75
|
+
border_style="dim",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self.token = await get_token_from_ipc()
|
|
80
|
+
|
|
81
|
+
if not self.token:
|
|
82
|
+
self.chat_log.write(
|
|
83
|
+
ErrorMessage(
|
|
84
|
+
"Could not fetch auth token from IPC.\n"
|
|
85
|
+
"Make sure OneCoder server is running: onecoder serve"
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
self.chat_log.write(
|
|
91
|
+
Panel(
|
|
92
|
+
"[bold green]✓[/bold green] Session initialized successfully!",
|
|
93
|
+
title="Success",
|
|
94
|
+
border_style="bold green",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
101
|
+
"""Handle user input submission."""
|
|
102
|
+
if self.is_processing:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
message = event.value.strip()
|
|
106
|
+
|
|
107
|
+
if not message:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Handle slash commands
|
|
111
|
+
if message.startswith("/"):
|
|
112
|
+
self.input_widget.clear()
|
|
113
|
+
await self.command_registry.handle(message)
|
|
114
|
+
if message.lower() in ["/quit", "/exit"]:
|
|
115
|
+
self.exit()
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if message.lower() in ["exit", "quit"]:
|
|
119
|
+
self.exit()
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
self.input_widget.disabled = True
|
|
123
|
+
self.is_processing = True
|
|
124
|
+
|
|
125
|
+
await self._process_message(message)
|
|
126
|
+
|
|
127
|
+
self.input_widget.disabled = False
|
|
128
|
+
self.is_processing = False
|
|
129
|
+
self.input_widget.focus()
|
|
130
|
+
self.input_widget.clear()
|
|
131
|
+
|
|
132
|
+
async def _process_message(self, message: str):
|
|
133
|
+
"""Process user message and stream agent response."""
|
|
134
|
+
await self._write_user_message(message)
|
|
135
|
+
|
|
136
|
+
current_response = ""
|
|
137
|
+
message_content = []
|
|
138
|
+
|
|
139
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
140
|
+
params = {
|
|
141
|
+
"user_id": self.user_id,
|
|
142
|
+
"session_id": self.session_id,
|
|
143
|
+
"message": message,
|
|
144
|
+
"token": self.token,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
async with client.stream(
|
|
149
|
+
"GET", f"{self.api_url}/stream", params=params
|
|
150
|
+
) as response:
|
|
151
|
+
if response.status_code != 200:
|
|
152
|
+
await self._write_error(f"Error: {response.status_code}")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
async for line in response.aiter_lines():
|
|
156
|
+
if line.startswith("data: "):
|
|
157
|
+
try:
|
|
158
|
+
data = json.loads(line[6:])
|
|
159
|
+
|
|
160
|
+
if "text" in data:
|
|
161
|
+
current_response += data["text"]
|
|
162
|
+
elif data.get("type") == "Error":
|
|
163
|
+
await self._write_error(
|
|
164
|
+
data.get("message", "Unknown error")
|
|
165
|
+
)
|
|
166
|
+
elif "tool_call" in data:
|
|
167
|
+
tool_name = data["tool_call"].get("name", "unknown")
|
|
168
|
+
await self._show_tool_call(
|
|
169
|
+
tool_name, {}, status="running"
|
|
170
|
+
)
|
|
171
|
+
elif "tool_result" in data:
|
|
172
|
+
tool_name = data.get("tool_name", "unknown")
|
|
173
|
+
await self._show_tool_call(
|
|
174
|
+
tool_name, {}, status="success"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
except json.JSONDecodeError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
if current_response:
|
|
181
|
+
await self._write_agent_message(current_response)
|
|
182
|
+
|
|
183
|
+
except httpx.ConnectError:
|
|
184
|
+
await self._write_error(
|
|
185
|
+
"Could not connect to OneCoder API.\n"
|
|
186
|
+
"Is the server running?\n"
|
|
187
|
+
"Run 'onecoder serve' in another terminal."
|
|
188
|
+
)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
await self._write_error(f"Stream Error: {e}")
|
|
191
|
+
|
|
192
|
+
async def _write_user_message(self, message: str):
|
|
193
|
+
"""Write user message to chat log."""
|
|
194
|
+
panel = Panel(message, title="You", border_style="bold green", padding=(0, 1))
|
|
195
|
+
self.chat_log.write(panel)
|
|
196
|
+
|
|
197
|
+
async def _write_agent_message(self, text: str):
|
|
198
|
+
"""Write agent message to chat log with markdown support."""
|
|
199
|
+
panel = Panel(
|
|
200
|
+
Markdown(text), title="OneCoder", border_style="bold blue", padding=(0, 1)
|
|
201
|
+
)
|
|
202
|
+
self.chat_log.write(panel)
|
|
203
|
+
|
|
204
|
+
async def _show_tool_call(
|
|
205
|
+
self, tool_name: str, tool_args: dict, status: str = "running"
|
|
206
|
+
):
|
|
207
|
+
"""Show tool call status in chat log."""
|
|
208
|
+
if status == "running":
|
|
209
|
+
content = f"Running [bold]{tool_name}[/bold]..."
|
|
210
|
+
border_style = "bold yellow"
|
|
211
|
+
elif status == "success":
|
|
212
|
+
content = f"[green]✓[/green] [bold]{tool_name}[/bold] finished."
|
|
213
|
+
border_style = "bold green"
|
|
214
|
+
else:
|
|
215
|
+
content = f"[red]✗[/red] [bold]{tool_name}[/bold] failed."
|
|
216
|
+
border_style = "bold red"
|
|
217
|
+
|
|
218
|
+
panel = Panel(
|
|
219
|
+
content, title="Tool Call", border_style=border_style, padding=(0, 1)
|
|
220
|
+
)
|
|
221
|
+
self.chat_log.write(panel)
|
|
222
|
+
|
|
223
|
+
async def _write_error(self, message: str):
|
|
224
|
+
"""Write error message to chat log."""
|
|
225
|
+
panel = Panel(
|
|
226
|
+
f"Error: {message}",
|
|
227
|
+
title="Error",
|
|
228
|
+
border_style="bold red",
|
|
229
|
+
style="red",
|
|
230
|
+
padding=(0, 1),
|
|
231
|
+
)
|
|
232
|
+
self.chat_log.write(panel)
|
|
233
|
+
|
|
234
|
+
async def action_clear_log(self) -> None:
|
|
235
|
+
"""Clear chat log."""
|
|
236
|
+
self.chat_log.clear()
|
|
237
|
+
|
|
238
|
+
async def action_toggle_theme(self) -> None:
|
|
239
|
+
"""Toggle between dark and light theme."""
|
|
240
|
+
if "light" in self.theme:
|
|
241
|
+
self.theme = "textual-dark"
|
|
242
|
+
else:
|
|
243
|
+
self.theme = "textual-light"
|
|
244
|
+
|
|
245
|
+
self.chat_log.write(
|
|
246
|
+
Panel(
|
|
247
|
+
f"Theme switched to: {self.theme}",
|
|
248
|
+
title="Theme",
|
|
249
|
+
border_style="dim",
|
|
250
|
+
padding=(0, 1),
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main():
|
|
257
|
+
"""Entry point for running OneCoder TUI."""
|
|
258
|
+
app = OneCoderApp()
|
|
259
|
+
app.run()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
onecoder/tui/commands.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from typing import Dict, Any, Callable, Awaitable
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.markdown import Markdown
|
|
4
|
+
from ..evaluation import TTUEvaluator
|
|
5
|
+
|
|
6
|
+
class CommandRegistry:
|
|
7
|
+
def __init__(self, app):
|
|
8
|
+
self.app = app
|
|
9
|
+
self.commands: Dict[str, Callable] = {}
|
|
10
|
+
self._register_default_commands()
|
|
11
|
+
|
|
12
|
+
def _register_default_commands(self):
|
|
13
|
+
self.register("help", self.cmd_help, "Show available commands")
|
|
14
|
+
self.register("clear", self.cmd_clear, "Clear the chat log")
|
|
15
|
+
self.register("status", self.cmd_status, "Show platform-wide sprint status")
|
|
16
|
+
self.register("ttu", self.cmd_ttu, "Evaluate Task Technical Understanding (TTU) for current context")
|
|
17
|
+
self.register("delegate", self.cmd_delegate, "Delegate a task to a local isolated agent. Usage: /delegate <task description>")
|
|
18
|
+
|
|
19
|
+
def register(self, name: str, handler: Callable, description: str):
|
|
20
|
+
self.commands[name] = {"handler": handler, "description": description}
|
|
21
|
+
|
|
22
|
+
async def handle(self, command_str: str) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Handles a command string (e.g., "/help").
|
|
25
|
+
Returns True if handled, False if command not found.
|
|
26
|
+
"""
|
|
27
|
+
parts = command_str.lstrip("/").split()
|
|
28
|
+
if not parts:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
cmd_name = parts[0].lower()
|
|
32
|
+
args = parts[1:]
|
|
33
|
+
|
|
34
|
+
if cmd_name in self.commands:
|
|
35
|
+
try:
|
|
36
|
+
await self.commands[cmd_name]["handler"](args)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
await self.app._write_error(f"Command error: {str(e)}")
|
|
39
|
+
return True
|
|
40
|
+
else:
|
|
41
|
+
await self.app._write_error(f"Unknown command: /{cmd_name}")
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
async def cmd_help(self, args):
|
|
45
|
+
lines = ["[bold]Available Commands:[/bold]"]
|
|
46
|
+
for name, data in self.commands.items():
|
|
47
|
+
lines.append(f"• [cyan]/{name}[/cyan]: {data['description']}")
|
|
48
|
+
|
|
49
|
+
panel = Panel("\n".join(lines), title="Help", border_style="blue")
|
|
50
|
+
self.app.chat_log.write(panel)
|
|
51
|
+
|
|
52
|
+
async def cmd_clear(self, args):
|
|
53
|
+
self.app.chat_log.clear()
|
|
54
|
+
|
|
55
|
+
async def cmd_status(self, args):
|
|
56
|
+
# We can implement a simple status check here, possibly calling the same logic as CLI status
|
|
57
|
+
# For now, let's mock or use basic discovery
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
import json
|
|
60
|
+
|
|
61
|
+
root_path = Path(".").resolve() # Or configure root
|
|
62
|
+
sprint_dirs = []
|
|
63
|
+
for p in root_path.rglob(".sprint"):
|
|
64
|
+
if "node_modules" in p.parts or ".git" in p.parts:
|
|
65
|
+
continue
|
|
66
|
+
sprint_dirs.append(p)
|
|
67
|
+
|
|
68
|
+
if not sprint_dirs:
|
|
69
|
+
self.app.chat_log.write(Panel("No active sprints found.", title="Status", border_style="yellow"))
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
status_lines = []
|
|
73
|
+
for sd in sprint_dirs:
|
|
74
|
+
component = sd.parent.name
|
|
75
|
+
subdirs = sorted([d for d in sd.iterdir() if d.is_dir()])
|
|
76
|
+
if subdirs:
|
|
77
|
+
active = subdirs[-1]
|
|
78
|
+
sprint_json = active / "sprint.json"
|
|
79
|
+
state_str = "Unknown"
|
|
80
|
+
if sprint_json.exists():
|
|
81
|
+
try:
|
|
82
|
+
with open(sprint_json) as f:
|
|
83
|
+
data = json.load(f)
|
|
84
|
+
state_str = data.get("status", {}).get("state", "unknown")
|
|
85
|
+
except:
|
|
86
|
+
pass
|
|
87
|
+
status_lines.append(f"• [bold]{component}[/bold] -> {active.name}: [green]{state_str}[/green]")
|
|
88
|
+
|
|
89
|
+
self.app.chat_log.write(Panel("\n".join(status_lines), title="Platform Status", border_style="blue"))
|
|
90
|
+
|
|
91
|
+
async def cmd_ttu(self, args):
|
|
92
|
+
evaluator = TTUEvaluator()
|
|
93
|
+
result = evaluator.evaluate()
|
|
94
|
+
|
|
95
|
+
color = "green" if result.passed else "red"
|
|
96
|
+
icon = "✓" if result.passed else "✗"
|
|
97
|
+
|
|
98
|
+
lines = [
|
|
99
|
+
f"**Score:** {result.score:.2f} / 1.0",
|
|
100
|
+
"",
|
|
101
|
+
"**Context Found:**",
|
|
102
|
+
*[f"- {c}" for c in result.context_found],
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
if result.failure_modes:
|
|
106
|
+
lines.append("")
|
|
107
|
+
lines.append("**Potential Failure Modes:**")
|
|
108
|
+
lines.extend([f"- [red]{m}[/red]" for m in result.failure_modes])
|
|
109
|
+
|
|
110
|
+
if result.missing_context:
|
|
111
|
+
lines.append("")
|
|
112
|
+
lines.append("**Missing Context:**")
|
|
113
|
+
lines.extend([f"- {c}" for c in result.missing_context])
|
|
114
|
+
|
|
115
|
+
if result.recommendations:
|
|
116
|
+
lines.append("")
|
|
117
|
+
lines.append("**Recommendations:**")
|
|
118
|
+
lines.extend([f"- {r}" for r in result.recommendations])
|
|
119
|
+
|
|
120
|
+
panel = Panel(
|
|
121
|
+
Markdown("\n".join(lines)),
|
|
122
|
+
title=f"{icon} TTU Evaluation",
|
|
123
|
+
border_style=f"bold {color}"
|
|
124
|
+
)
|
|
125
|
+
self.app.chat_log.write(panel)
|
|
126
|
+
async def cmd_delegate(self, args):
|
|
127
|
+
if not args:
|
|
128
|
+
await self.app._write_error("Usage: /delegate <task description>")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
task_desc = " ".join(args)
|
|
132
|
+
self.app.chat_log.write(Panel(f"Delegating task: [bold]{task_desc}[/bold]", title="Delegation", border_style="yellow"))
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
from ..services.delegation_service import DelegationService
|
|
136
|
+
service = DelegationService()
|
|
137
|
+
session = service.create_session(task_id=f"delegate-{task_desc[:10]}", command=task_desc)
|
|
138
|
+
|
|
139
|
+
# Start the session (async)
|
|
140
|
+
connection_info = await service.start_session(session)
|
|
141
|
+
|
|
142
|
+
self.app.chat_log.write(Panel(
|
|
143
|
+
f"Delegated successfully!\n\n"
|
|
144
|
+
f"**Backend:** local-tui\n"
|
|
145
|
+
f"**Worktree:** {session.worktree_path}\n"
|
|
146
|
+
f"**To Attach:** [cyan]{connection_info}[/cyan]",
|
|
147
|
+
title="Success", border_style="bold green"
|
|
148
|
+
))
|
|
149
|
+
except Exception as e:
|
|
150
|
+
await self.app._write_error(f"Delegation failed: {str(e)}")
|
onecoder/tui/widgets.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Custom widgets for OneCoder TUI."""
|
|
2
|
+
|
|
3
|
+
from textual.widgets import Static, LoadingIndicator
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.markdown import Markdown
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ChatMessage(Static):
|
|
10
|
+
"""Widget for displaying chat messages with markdown support."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, role: str = "user", **kwargs):
|
|
13
|
+
super().__init__(**kwargs)
|
|
14
|
+
self.message = message
|
|
15
|
+
self.role = role
|
|
16
|
+
|
|
17
|
+
def render(self):
|
|
18
|
+
if self.role == "user":
|
|
19
|
+
panel = Panel(
|
|
20
|
+
self.message, title="You", border_style="bold blue", padding=(0, 1)
|
|
21
|
+
)
|
|
22
|
+
return panel
|
|
23
|
+
else:
|
|
24
|
+
panel = Panel(
|
|
25
|
+
Markdown(self.message),
|
|
26
|
+
title="OneCoder",
|
|
27
|
+
border_style="bold green",
|
|
28
|
+
padding=(0, 1),
|
|
29
|
+
)
|
|
30
|
+
return panel
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ToolCallStatus(Static):
|
|
34
|
+
"""Widget for displaying tool call status."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, tool_name: str, status: str = "running", **kwargs):
|
|
37
|
+
super().__init__(**kwargs)
|
|
38
|
+
self.tool_name = tool_name
|
|
39
|
+
self.status = status
|
|
40
|
+
|
|
41
|
+
def render(self):
|
|
42
|
+
if self.status == "running":
|
|
43
|
+
content = f"Running {self.tool_name}..."
|
|
44
|
+
border_style = "bold yellow"
|
|
45
|
+
elif self.status == "success":
|
|
46
|
+
content = f"✓ {self.tool_name} finished"
|
|
47
|
+
border_style = "bold green"
|
|
48
|
+
else:
|
|
49
|
+
content = f"✗ {self.tool_name} failed"
|
|
50
|
+
border_style = "bold red"
|
|
51
|
+
|
|
52
|
+
panel = Panel(
|
|
53
|
+
content, title="Tool Call", border_style=border_style, padding=(0, 1)
|
|
54
|
+
)
|
|
55
|
+
return panel
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ErrorMessage(Static):
|
|
59
|
+
"""Widget for displaying error messages."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, **kwargs):
|
|
62
|
+
super().__init__(**kwargs)
|
|
63
|
+
self.message = message
|
|
64
|
+
|
|
65
|
+
def render(self):
|
|
66
|
+
panel = Panel(
|
|
67
|
+
self.message,
|
|
68
|
+
title="Error",
|
|
69
|
+
border_style="bold red",
|
|
70
|
+
style="red",
|
|
71
|
+
padding=(0, 1),
|
|
72
|
+
)
|
|
73
|
+
return panel
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class WelcomeMessage(Static):
|
|
77
|
+
"""Widget for displaying welcome message on startup."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, **kwargs):
|
|
80
|
+
super().__init__(**kwargs)
|
|
81
|
+
|
|
82
|
+
def render(self):
|
|
83
|
+
from rich.text import Text
|
|
84
|
+
from rich.console import Group
|
|
85
|
+
|
|
86
|
+
title = Text("OneCoder TUI", style="bold blue")
|
|
87
|
+
subtitle = Text(
|
|
88
|
+
"Modern terminal interface for AI-powered coding assistance", style="dim"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
panel = Panel(Group(title, "", subtitle), border_style="blue", padding=(1, 2))
|
|
92
|
+
return panel
|
onecoder/worktree.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import shutil
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class WorktreeManager:
|
|
11
|
+
"""
|
|
12
|
+
Manages Git worktrees for isolated task execution.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, project_root: Optional[str] = None):
|
|
16
|
+
if project_root:
|
|
17
|
+
self.project_root = Path(project_root).absolute()
|
|
18
|
+
else:
|
|
19
|
+
self.project_root = self._find_repo_root(Path.cwd())
|
|
20
|
+
|
|
21
|
+
self.worktrees_dir = self.project_root / ".adk" / "worktrees"
|
|
22
|
+
self.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
def get_current_branch(self) -> str:
|
|
25
|
+
"""Returns the name of the current active branch."""
|
|
26
|
+
return self._run_git(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
27
|
+
|
|
28
|
+
def _find_repo_root(self, start_path: Path) -> Path:
|
|
29
|
+
"""Traverses upwards to find the repository root."""
|
|
30
|
+
curr = start_path
|
|
31
|
+
while curr != curr.parent:
|
|
32
|
+
if (curr / ".git").exists() or (curr / ".sprint").exists():
|
|
33
|
+
return curr
|
|
34
|
+
curr = curr.parent
|
|
35
|
+
return start_path
|
|
36
|
+
|
|
37
|
+
def _run_git(self, args: List[str], cwd: Optional[Path] = None) -> str:
|
|
38
|
+
"""Helper to run git commands."""
|
|
39
|
+
command = ["git"] + args
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
command,
|
|
42
|
+
cwd=str(cwd or self.project_root),
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
check=False
|
|
46
|
+
)
|
|
47
|
+
if result.returncode != 0:
|
|
48
|
+
raise RuntimeError(f"Git command failed: {' '.join(command)}\nError: {result.stderr}")
|
|
49
|
+
return result.stdout.strip()
|
|
50
|
+
|
|
51
|
+
def create_worktree(self, task_id: str, base_ref: Optional[str] = None) -> Path:
|
|
52
|
+
"""
|
|
53
|
+
Creates a new git worktree for a task.
|
|
54
|
+
Defaults to branching off the current HEAD if base_ref is not provided.
|
|
55
|
+
"""
|
|
56
|
+
if base_ref is None:
|
|
57
|
+
base_ref = self.get_current_branch()
|
|
58
|
+
logger.info(f"No base_ref provided, using current branch: {base_ref}")
|
|
59
|
+
|
|
60
|
+
task_dir = self.worktrees_dir / task_id
|
|
61
|
+
if task_dir.exists():
|
|
62
|
+
logger.warning(f"Worktree directory already exists: {task_dir}")
|
|
63
|
+
# Try to remove it first if it's stale
|
|
64
|
+
self.remove_worktree(task_id)
|
|
65
|
+
|
|
66
|
+
branch_name = f"task/{task_id}"
|
|
67
|
+
|
|
68
|
+
# Check if branch exists, if so, use it, otherwise create it
|
|
69
|
+
try:
|
|
70
|
+
# Check if branch already exists
|
|
71
|
+
self._run_git(["rev-parse", "--verify", branch_name])
|
|
72
|
+
logger.info(f"Using existing branch: {branch_name}")
|
|
73
|
+
self._run_git(["worktree", "add", str(task_dir), branch_name])
|
|
74
|
+
except RuntimeError:
|
|
75
|
+
logger.info(f"Creating new worktree and branch: {branch_name} from {base_ref}")
|
|
76
|
+
self._run_git(["worktree", "add", "-b", branch_name, str(task_dir), base_ref])
|
|
77
|
+
|
|
78
|
+
return task_dir
|
|
79
|
+
|
|
80
|
+
def remove_worktree(self, task_id: str, delete_branch: bool = False) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Removes a git worktree.
|
|
83
|
+
"""
|
|
84
|
+
task_dir = self.worktrees_dir / task_id
|
|
85
|
+
if not task_dir.exists():
|
|
86
|
+
logger.warning(f"Worktree directory does not exist: {task_dir}")
|
|
87
|
+
# Still try to prune just in case
|
|
88
|
+
self._run_git(["worktree", "prune"])
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
logger.info(f"Removing worktree: {task_dir}")
|
|
92
|
+
try:
|
|
93
|
+
self._run_git(["worktree", "remove", "--force", str(task_dir)])
|
|
94
|
+
except RuntimeError as e:
|
|
95
|
+
logger.error(f"Failed to remove worktree via git: {e}")
|
|
96
|
+
# Fallback to manual removal if git remove fails
|
|
97
|
+
if task_dir.exists():
|
|
98
|
+
shutil.rmtree(task_dir, ignore_errors=True)
|
|
99
|
+
self._run_git(["worktree", "prune"])
|
|
100
|
+
|
|
101
|
+
if delete_branch:
|
|
102
|
+
branch_name = f"task/{task_id}"
|
|
103
|
+
try:
|
|
104
|
+
self._run_git(["branch", "-D", branch_name])
|
|
105
|
+
except RuntimeError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def merge_task_branch(self, task_id: str, target_branch: str) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Merges a task branch into a target branch.
|
|
111
|
+
Uses --no-ff to preserve atomic work history.
|
|
112
|
+
"""
|
|
113
|
+
branch_name = f"task/{task_id}"
|
|
114
|
+
logger.info(f"Merging {branch_name} into {target_branch}")
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# 1. Ensure we are on the target branch
|
|
118
|
+
current = self.get_current_branch()
|
|
119
|
+
if current != target_branch:
|
|
120
|
+
self._run_git(["checkout", target_branch])
|
|
121
|
+
|
|
122
|
+
# 2. Perform merge
|
|
123
|
+
self._run_git(["merge", "--no-ff", "-m", f"chore: merge delegated task {task_id}", branch_name])
|
|
124
|
+
return True
|
|
125
|
+
except RuntimeError as e:
|
|
126
|
+
logger.error(f"Merge failed: {e}")
|
|
127
|
+
# If we were on target branch, try to abort merge if it's stuck
|
|
128
|
+
try:
|
|
129
|
+
self._run_git(["merge", "--abort"])
|
|
130
|
+
except:
|
|
131
|
+
pass
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def rebase_onto(self, task_id: str, upstream: str) -> bool:
|
|
135
|
+
"""Rebases the task branch onto an upstream branch to ensure compatibility."""
|
|
136
|
+
branch_name = f"task/{task_id}"
|
|
137
|
+
logger.info(f"Rebasing {branch_name} onto {upstream}")
|
|
138
|
+
original_branch = self.get_current_branch()
|
|
139
|
+
try:
|
|
140
|
+
# Note: git rebase <upstream> <branch> switches HEAD to <branch>
|
|
141
|
+
self._run_git(["rebase", upstream, branch_name])
|
|
142
|
+
return True
|
|
143
|
+
except RuntimeError as e:
|
|
144
|
+
logger.error(f"Rebase failed: {e}")
|
|
145
|
+
try:
|
|
146
|
+
self._run_git(["rebase", "--abort"])
|
|
147
|
+
except:
|
|
148
|
+
pass
|
|
149
|
+
return False
|
|
150
|
+
finally:
|
|
151
|
+
# Always ensure we return to the original branch
|
|
152
|
+
if self.get_current_branch() != original_branch:
|
|
153
|
+
self._run_git(["checkout", original_branch])
|
|
154
|
+
|
|
155
|
+
def list_worktrees(self) -> List[dict]:
|
|
156
|
+
"""
|
|
157
|
+
Lists all active worktrees.
|
|
158
|
+
"""
|
|
159
|
+
output = self._run_git(["worktree", "list", "--porcelain"])
|
|
160
|
+
worktrees = []
|
|
161
|
+
current_wt = {}
|
|
162
|
+
|
|
163
|
+
for line in output.split("\n"):
|
|
164
|
+
if not line:
|
|
165
|
+
if current_wt:
|
|
166
|
+
worktrees.append(current_wt)
|
|
167
|
+
current_wt = {}
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
parts = line.split(" ", 1)
|
|
171
|
+
if len(parts) == 2:
|
|
172
|
+
current_wt[parts[0]] = parts[1]
|
|
173
|
+
|
|
174
|
+
if current_wt:
|
|
175
|
+
worktrees.append(current_wt)
|
|
176
|
+
|
|
177
|
+
return worktrees
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
# Quick test
|
|
181
|
+
logging.basicConfig(level=logging.INFO)
|
|
182
|
+
mgr = WorktreeManager()
|
|
183
|
+
path = mgr.create_worktree("test-task")
|
|
184
|
+
print(f"Worktree created at: {path}")
|
|
185
|
+
mgr.remove_worktree("test-task", delete_branch=True)
|
|
186
|
+
print("Worktree removed.")
|