voice-vibecoder 2.6.0__tar.gz → 2.8.0__tar.gz
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.
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/CHANGELOG.md +14 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/PKG-INFO +1 -1
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/pyproject.toml +1 -1
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/__init__.py +1 -1
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/app_config.py +4 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/claude.py +54 -46
- voice_vibecoder-2.8.0/src/voice_vibecoder/code_providers/openclaw.py +369 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/registry.py +12 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/config.py +1 -1
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/instances.py +10 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/server.py +6 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/dispatch.py +3 -1
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/file_tree.py +6 -2
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/handlers.py +2 -1
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/callbacks.py +20 -2
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/panels.py +112 -80
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/screen.py +12 -7
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/.github/workflows/ci.yml +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/.github/workflows/release.yml +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/.gitignore +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/LICENSE +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/README.md +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/app.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/index.html +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/audio.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/cli_serve.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/session.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/__init__.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/definitions.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/__init__.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/setup.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/state.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/styles.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/wizard.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
- {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/worktrees.py +0 -0
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v2.8.0 (2026-02-20)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Add OpenClaw code provider
|
|
10
|
+
([`910dc31`](https://github.com/snokam/voice-vibecoder/commit/910dc311df6a49f0e36e6bb53c98c2ea94753a25))
|
|
11
|
+
|
|
12
|
+
- **server**: Add text message support
|
|
13
|
+
([`86873ca`](https://github.com/snokam/voice-vibecoder/commit/86873cade8ec8feaa8479d120b86222fe106abdc))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## v2.7.0 (2026-02-20)
|
|
17
|
+
|
|
18
|
+
|
|
5
19
|
## v2.6.0 (2026-02-20)
|
|
6
20
|
|
|
7
21
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voice-vibecoder
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code
|
|
5
5
|
Project-URL: Homepage, https://github.com/snokam/voice-vibecoder
|
|
6
6
|
Project-URL: Repository, https://github.com/snokam/voice-vibecoder
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "voice-vibecoder"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.8.0"
|
|
8
8
|
description = "Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -72,3 +72,7 @@ class VoiceConfig:
|
|
|
72
72
|
# Raises ValueError on validation failure.
|
|
73
73
|
# When None, auth is disabled (no auth frame expected from clients).
|
|
74
74
|
authenticate: Callable[[str], Awaitable[tuple[str, str]]] | None = None
|
|
75
|
+
|
|
76
|
+
# Default agent type for new instances ("claude", "cursor", "openclaw").
|
|
77
|
+
# Can be overridden per-instance via send_to_agent/send_to_session.
|
|
78
|
+
default_agent: str = "claude"
|
{voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/claude.py
RENAMED
|
@@ -242,6 +242,7 @@ class ClaudeRunner:
|
|
|
242
242
|
) -> AgentResult:
|
|
243
243
|
result_text = ""
|
|
244
244
|
result_session_id: str | None = None
|
|
245
|
+
got_content = False
|
|
245
246
|
|
|
246
247
|
async def _prompt():
|
|
247
248
|
yield {
|
|
@@ -251,54 +252,61 @@ class ClaudeRunner:
|
|
|
251
252
|
"session_id": session_id,
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if isinstance(block, TextBlock):
|
|
258
|
-
text = block.text.strip()
|
|
259
|
-
if text and on_output:
|
|
260
|
-
for line in text.splitlines():
|
|
261
|
-
if line.strip():
|
|
262
|
-
on_output(AgentOutput(
|
|
263
|
-
category="text", content=line,
|
|
264
|
-
))
|
|
265
|
-
|
|
266
|
-
elif isinstance(block, ToolUseBlock):
|
|
267
|
-
if not block.name:
|
|
268
|
-
continue
|
|
269
|
-
desc = describe_tool_call(block.name, block.input)
|
|
270
|
-
if on_output:
|
|
271
|
-
category = (
|
|
272
|
-
"file_edit" if block.name in ("Edit", "Write")
|
|
273
|
-
else "bash" if block.name == "Bash"
|
|
274
|
-
else "tool_call"
|
|
275
|
-
)
|
|
276
|
-
on_output(AgentOutput(
|
|
277
|
-
category=category, content=desc,
|
|
278
|
-
))
|
|
279
|
-
if block.name in ("Edit", "Write"):
|
|
280
|
-
_emit_edit_diff(on_output, block.name, block.input)
|
|
281
|
-
|
|
282
|
-
elif isinstance(block, ToolResultBlock):
|
|
283
|
-
content = block.content or ""
|
|
284
|
-
if isinstance(content, str) and content.strip() and on_output:
|
|
285
|
-
preview = content[:200] + ("..." if len(content) > 200 else "")
|
|
286
|
-
on_output(AgentOutput(
|
|
287
|
-
category="tool_result", content=preview,
|
|
288
|
-
))
|
|
289
|
-
|
|
290
|
-
elif isinstance(msg, UserMessage):
|
|
291
|
-
if isinstance(msg.content, list):
|
|
255
|
+
try:
|
|
256
|
+
async for msg in query(prompt=_prompt(), options=options):
|
|
257
|
+
if isinstance(msg, AssistantMessage):
|
|
292
258
|
for block in msg.content:
|
|
293
|
-
if isinstance(block,
|
|
259
|
+
if isinstance(block, TextBlock):
|
|
260
|
+
text = block.text.strip()
|
|
261
|
+
if text and on_output:
|
|
262
|
+
got_content = True
|
|
263
|
+
for line in text.splitlines():
|
|
264
|
+
if line.strip():
|
|
265
|
+
on_output(AgentOutput(
|
|
266
|
+
category="text", content=line,
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
elif isinstance(block, ToolUseBlock):
|
|
270
|
+
if not block.name:
|
|
271
|
+
continue
|
|
272
|
+
got_content = True
|
|
273
|
+
desc = describe_tool_call(block.name, block.input)
|
|
274
|
+
if on_output:
|
|
275
|
+
category = (
|
|
276
|
+
"file_edit" if block.name in ("Edit", "Write")
|
|
277
|
+
else "bash" if block.name == "Bash"
|
|
278
|
+
else "tool_call"
|
|
279
|
+
)
|
|
280
|
+
on_output(AgentOutput(
|
|
281
|
+
category=category, content=desc,
|
|
282
|
+
))
|
|
283
|
+
if block.name in ("Edit", "Write"):
|
|
284
|
+
_emit_edit_diff(on_output, block.name, block.input)
|
|
285
|
+
|
|
286
|
+
elif isinstance(block, ToolResultBlock):
|
|
294
287
|
content = block.content or ""
|
|
295
|
-
if isinstance(content, str) and content.strip():
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
288
|
+
if isinstance(content, str) and content.strip() and on_output:
|
|
289
|
+
preview = content[:200] + ("..." if len(content) > 200 else "")
|
|
290
|
+
on_output(AgentOutput(
|
|
291
|
+
category="tool_result", content=preview,
|
|
292
|
+
))
|
|
293
|
+
|
|
294
|
+
elif isinstance(msg, UserMessage):
|
|
295
|
+
if isinstance(msg.content, list):
|
|
296
|
+
for block in msg.content:
|
|
297
|
+
if isinstance(block, ToolResultBlock):
|
|
298
|
+
content = block.content or ""
|
|
299
|
+
if isinstance(content, str) and content.strip():
|
|
300
|
+
pass # Already routed via AssistantMessage
|
|
301
|
+
|
|
302
|
+
elif isinstance(msg, ResultMessage):
|
|
303
|
+
result_text = msg.result or ""
|
|
304
|
+
if msg.session_id:
|
|
305
|
+
result_session_id = msg.session_id
|
|
306
|
+
except ProcessError:
|
|
307
|
+
if not got_content and not result_text:
|
|
308
|
+
raise
|
|
309
|
+
logger.warning("CLI process error after content was already streamed")
|
|
302
310
|
|
|
303
311
|
return AgentResult(text=result_text, session_id=result_session_id)
|
|
304
312
|
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""OpenClaw Agent code provider.
|
|
2
|
+
|
|
3
|
+
Implements the AgentRunner protocol using the OpenClaw Gateway WebSocket
|
|
4
|
+
for streaming code agent interactions. Routes coding tasks to the
|
|
5
|
+
OpenClaw gateway which can use any configured model and tools.
|
|
6
|
+
|
|
7
|
+
This provider enables voice-vibecoder to use OpenClaw (Olaf) as the
|
|
8
|
+
coding agent instead of Claude Code CLI. Benefits:
|
|
9
|
+
- Same agent identity across all channels (Slack, voice, web)
|
|
10
|
+
- Access to all MCP tools (Jira, GitHub, CVPartner, Snøkam APIs)
|
|
11
|
+
- Persistent memory and context
|
|
12
|
+
- Any model backend (Claude, GPT-4, etc.)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import secrets
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Any, Awaitable, Callable
|
|
25
|
+
|
|
26
|
+
from voice_vibecoder.code_providers import AgentOutput, AgentResult
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Default OpenClaw Gateway URL (loopback)
|
|
31
|
+
DEFAULT_GATEWAY_URL = "ws://localhost:18789"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_tool_info(line: str) -> tuple[str, str] | None:
|
|
35
|
+
"""Extract tool name and description from agent output lines."""
|
|
36
|
+
match = re.match(r"^\[(\w+)\]\s*(.*)$", line.strip())
|
|
37
|
+
if match:
|
|
38
|
+
return match.group(1), match.group(2)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenClawRunner:
|
|
43
|
+
"""AgentRunner implementation using OpenClaw Gateway WebSocket.
|
|
44
|
+
|
|
45
|
+
This provider routes coding tasks to OpenClaw, which can:
|
|
46
|
+
- Use any configured model (Claude, GPT-4, etc.)
|
|
47
|
+
- Access all MCP tools (Jira, GitHub, CVPartner, etc.)
|
|
48
|
+
- Maintain conversation context across sessions
|
|
49
|
+
- Use the same identity/persona as the main agent
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
session_id: str | None = None,
|
|
55
|
+
agent_id: str | None = None,
|
|
56
|
+
env: dict[str, str] | None = None,
|
|
57
|
+
gateway_url: str | None = None,
|
|
58
|
+
gateway_token: str | None = None,
|
|
59
|
+
**_kwargs,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._session_id = session_id or f"voice-{uuid.uuid4().hex[:8]}"
|
|
62
|
+
self._agent_id = agent_id
|
|
63
|
+
self._env = env
|
|
64
|
+
self._gateway_url = gateway_url or os.environ.get("OPENCLAW_GATEWAY_URL", DEFAULT_GATEWAY_URL)
|
|
65
|
+
self._gateway_token = gateway_token or os.environ.get("OPENCLAW_GATEWAY_TOKEN")
|
|
66
|
+
self._task: asyncio.Task | None = None
|
|
67
|
+
self._ws = None
|
|
68
|
+
|
|
69
|
+
async def run(
|
|
70
|
+
self,
|
|
71
|
+
message: str,
|
|
72
|
+
cwd: str,
|
|
73
|
+
session_id: str | None = None,
|
|
74
|
+
on_output: Callable[[AgentOutput], None] | None = None,
|
|
75
|
+
can_use_tool: Callable[[str, dict, Any], Awaitable[Any]] | None = None,
|
|
76
|
+
) -> AgentResult:
|
|
77
|
+
"""Run a coding task via OpenClaw Gateway WebSocket."""
|
|
78
|
+
self._task = asyncio.current_task()
|
|
79
|
+
effective_session = session_id or self._session_id
|
|
80
|
+
|
|
81
|
+
# Add working directory context
|
|
82
|
+
full_message = f"[Working directory: {cwd}]\n\n{message}"
|
|
83
|
+
|
|
84
|
+
if on_output:
|
|
85
|
+
on_output(AgentOutput(
|
|
86
|
+
category="text",
|
|
87
|
+
content=f"🦞 Connecting to OpenClaw...",
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Try WebSocket first, fall back to CLI if it fails
|
|
92
|
+
result = await self._run_via_websocket(
|
|
93
|
+
full_message, effective_session, on_output, can_use_tool
|
|
94
|
+
)
|
|
95
|
+
except Exception as ws_error:
|
|
96
|
+
logger.warning("WebSocket connection failed, falling back to CLI: %s", ws_error)
|
|
97
|
+
if on_output:
|
|
98
|
+
on_output(AgentOutput(
|
|
99
|
+
category="text",
|
|
100
|
+
content="WebSocket unavailable, using CLI...",
|
|
101
|
+
))
|
|
102
|
+
result = await self._run_via_cli(
|
|
103
|
+
full_message, cwd, effective_session, on_output
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
async def _run_via_websocket(
|
|
109
|
+
self,
|
|
110
|
+
message: str,
|
|
111
|
+
session_id: str,
|
|
112
|
+
on_output: Callable[[AgentOutput], None] | None,
|
|
113
|
+
can_use_tool: Callable[[str, dict, Any], Awaitable[Any]] | None,
|
|
114
|
+
) -> AgentResult:
|
|
115
|
+
"""Connect to OpenClaw Gateway via WebSocket and stream the response."""
|
|
116
|
+
import websockets
|
|
117
|
+
|
|
118
|
+
result_text = ""
|
|
119
|
+
result_session_id = session_id
|
|
120
|
+
|
|
121
|
+
async with websockets.connect(self._gateway_url) as ws:
|
|
122
|
+
self._ws = ws
|
|
123
|
+
|
|
124
|
+
# Wait for challenge
|
|
125
|
+
challenge_raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
126
|
+
challenge = json.loads(challenge_raw)
|
|
127
|
+
|
|
128
|
+
if challenge.get("event") != "connect.challenge":
|
|
129
|
+
raise ValueError(f"Expected connect.challenge, got: {challenge}")
|
|
130
|
+
|
|
131
|
+
# Generate device identity
|
|
132
|
+
device_id = f"voice-vibecoder-{uuid.uuid4().hex[:8]}"
|
|
133
|
+
|
|
134
|
+
# Send connect request
|
|
135
|
+
connect_req = {
|
|
136
|
+
"type": "req",
|
|
137
|
+
"id": secrets.token_hex(8),
|
|
138
|
+
"method": "connect",
|
|
139
|
+
"params": {
|
|
140
|
+
"minProtocol": 3,
|
|
141
|
+
"maxProtocol": 3,
|
|
142
|
+
"client": {
|
|
143
|
+
"id": "voice-vibecoder",
|
|
144
|
+
"version": "1.0.0",
|
|
145
|
+
"platform": "linux",
|
|
146
|
+
"mode": "operator",
|
|
147
|
+
},
|
|
148
|
+
"role": "operator",
|
|
149
|
+
"scopes": ["operator.read", "operator.write"],
|
|
150
|
+
"caps": [],
|
|
151
|
+
"commands": [],
|
|
152
|
+
"permissions": {},
|
|
153
|
+
"auth": {"token": self._gateway_token} if self._gateway_token else {},
|
|
154
|
+
"locale": "en-US",
|
|
155
|
+
"userAgent": "voice-vibecoder/1.0.0",
|
|
156
|
+
"device": {
|
|
157
|
+
"id": device_id,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
await ws.send(json.dumps(connect_req))
|
|
162
|
+
|
|
163
|
+
# Wait for hello-ok
|
|
164
|
+
hello_raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
165
|
+
hello = json.loads(hello_raw)
|
|
166
|
+
|
|
167
|
+
if not hello.get("ok"):
|
|
168
|
+
error = hello.get("error", {}).get("message", "Unknown error")
|
|
169
|
+
raise ValueError(f"Connect failed: {error}")
|
|
170
|
+
|
|
171
|
+
if on_output:
|
|
172
|
+
on_output(AgentOutput(category="text", content="Connected to OpenClaw"))
|
|
173
|
+
|
|
174
|
+
# Send chat message
|
|
175
|
+
chat_req = {
|
|
176
|
+
"type": "req",
|
|
177
|
+
"id": secrets.token_hex(8),
|
|
178
|
+
"method": "chat.send",
|
|
179
|
+
"params": {
|
|
180
|
+
"message": message,
|
|
181
|
+
"sessionKey": session_id,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if self._agent_id:
|
|
186
|
+
chat_req["params"]["agentId"] = self._agent_id
|
|
187
|
+
|
|
188
|
+
await ws.send(json.dumps(chat_req))
|
|
189
|
+
|
|
190
|
+
# Stream response
|
|
191
|
+
while True:
|
|
192
|
+
try:
|
|
193
|
+
msg_raw = await asyncio.wait_for(ws.recv(), timeout=600.0)
|
|
194
|
+
msg = json.loads(msg_raw)
|
|
195
|
+
except asyncio.TimeoutError:
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
msg_type = msg.get("type")
|
|
199
|
+
|
|
200
|
+
if msg_type == "event":
|
|
201
|
+
event = msg.get("event", "")
|
|
202
|
+
payload = msg.get("payload", {})
|
|
203
|
+
|
|
204
|
+
if event == "chat.chunk":
|
|
205
|
+
# Streaming text chunk
|
|
206
|
+
text = payload.get("text", "")
|
|
207
|
+
if text and on_output:
|
|
208
|
+
for line in text.splitlines():
|
|
209
|
+
if line.strip():
|
|
210
|
+
tool_info = _extract_tool_info(line)
|
|
211
|
+
if tool_info:
|
|
212
|
+
tool_name, tool_desc = tool_info
|
|
213
|
+
category = (
|
|
214
|
+
"file_edit" if tool_name in ("Edit", "Write")
|
|
215
|
+
else "bash" if tool_name == "Bash"
|
|
216
|
+
else "tool_call"
|
|
217
|
+
)
|
|
218
|
+
on_output(AgentOutput(category=category, content=line))
|
|
219
|
+
else:
|
|
220
|
+
on_output(AgentOutput(category="text", content=line))
|
|
221
|
+
result_text += text
|
|
222
|
+
|
|
223
|
+
elif event == "chat.done":
|
|
224
|
+
# Final response
|
|
225
|
+
result_text = payload.get("text", result_text)
|
|
226
|
+
result_session_id = payload.get("sessionKey", session_id)
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
elif event == "chat.error":
|
|
230
|
+
error = payload.get("message", "Unknown error")
|
|
231
|
+
raise RuntimeError(f"OpenClaw error: {error}")
|
|
232
|
+
|
|
233
|
+
elif msg_type == "res":
|
|
234
|
+
# Response to our request
|
|
235
|
+
if not msg.get("ok"):
|
|
236
|
+
error = msg.get("error", {}).get("message", "Unknown error")
|
|
237
|
+
raise RuntimeError(f"Request failed: {error}")
|
|
238
|
+
|
|
239
|
+
# chat.send returns immediately, response comes via events
|
|
240
|
+
payload = msg.get("payload", {})
|
|
241
|
+
if "text" in payload:
|
|
242
|
+
result_text = payload["text"]
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
self._ws = None
|
|
246
|
+
|
|
247
|
+
return AgentResult(
|
|
248
|
+
text=result_text,
|
|
249
|
+
session_id=result_session_id,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def _run_via_cli(
|
|
253
|
+
self,
|
|
254
|
+
message: str,
|
|
255
|
+
cwd: str,
|
|
256
|
+
session_id: str,
|
|
257
|
+
on_output: Callable[[AgentOutput], None] | None,
|
|
258
|
+
) -> AgentResult:
|
|
259
|
+
"""Fallback: run via openclaw CLI."""
|
|
260
|
+
cmd = [
|
|
261
|
+
"openclaw", "agent",
|
|
262
|
+
"--session-id", session_id,
|
|
263
|
+
"--message", message,
|
|
264
|
+
"--json",
|
|
265
|
+
"--local",
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
if self._agent_id:
|
|
269
|
+
cmd.extend(["--agent", self._agent_id])
|
|
270
|
+
|
|
271
|
+
env = os.environ.copy()
|
|
272
|
+
if self._env:
|
|
273
|
+
env.update(self._env)
|
|
274
|
+
if self._gateway_token:
|
|
275
|
+
env["OPENCLAW_GATEWAY_TOKEN"] = self._gateway_token
|
|
276
|
+
|
|
277
|
+
process = await asyncio.create_subprocess_exec(
|
|
278
|
+
*cmd,
|
|
279
|
+
stdout=asyncio.subprocess.PIPE,
|
|
280
|
+
stderr=asyncio.subprocess.PIPE,
|
|
281
|
+
env=env,
|
|
282
|
+
cwd=cwd,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
result_text = ""
|
|
286
|
+
|
|
287
|
+
async def read_stdout():
|
|
288
|
+
nonlocal result_text
|
|
289
|
+
assert process.stdout
|
|
290
|
+
|
|
291
|
+
buffer = ""
|
|
292
|
+
while True:
|
|
293
|
+
chunk = await process.stdout.read(1024)
|
|
294
|
+
if not chunk:
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
298
|
+
buffer += text
|
|
299
|
+
|
|
300
|
+
while "\n" in buffer:
|
|
301
|
+
line, buffer = buffer.split("\n", 1)
|
|
302
|
+
line = line.strip()
|
|
303
|
+
|
|
304
|
+
if not line:
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
data = json.loads(line)
|
|
309
|
+
if isinstance(data, dict):
|
|
310
|
+
if "result" in data:
|
|
311
|
+
result_text = data["result"]
|
|
312
|
+
elif "text" in data:
|
|
313
|
+
result_text = data["text"]
|
|
314
|
+
continue
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
if on_output:
|
|
319
|
+
tool_info = _extract_tool_info(line)
|
|
320
|
+
if tool_info:
|
|
321
|
+
tool_name, tool_desc = tool_info
|
|
322
|
+
category = (
|
|
323
|
+
"file_edit" if tool_name in ("Edit", "Write")
|
|
324
|
+
else "bash" if tool_name == "Bash"
|
|
325
|
+
else "tool_call"
|
|
326
|
+
)
|
|
327
|
+
on_output(AgentOutput(category=category, content=f"[{tool_name}] {tool_desc}"))
|
|
328
|
+
else:
|
|
329
|
+
on_output(AgentOutput(category="text", content=line))
|
|
330
|
+
|
|
331
|
+
if buffer.strip():
|
|
332
|
+
try:
|
|
333
|
+
data = json.loads(buffer)
|
|
334
|
+
if isinstance(data, dict) and "result" in data:
|
|
335
|
+
result_text = data["result"]
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
if on_output:
|
|
338
|
+
on_output(AgentOutput(category="text", content=buffer.strip()))
|
|
339
|
+
|
|
340
|
+
async def read_stderr():
|
|
341
|
+
assert process.stderr
|
|
342
|
+
async for line in process.stderr:
|
|
343
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
344
|
+
if text and on_output:
|
|
345
|
+
on_output(AgentOutput(category="text", content=f"[stderr] {text}"))
|
|
346
|
+
|
|
347
|
+
await asyncio.gather(read_stdout(), read_stderr())
|
|
348
|
+
await process.wait()
|
|
349
|
+
|
|
350
|
+
if process.returncode != 0 and not result_text:
|
|
351
|
+
raise RuntimeError(f"OpenClaw CLI failed with exit code {process.returncode}")
|
|
352
|
+
|
|
353
|
+
return AgentResult(
|
|
354
|
+
text=result_text,
|
|
355
|
+
session_id=session_id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def cancel(self) -> bool:
|
|
359
|
+
"""Cancel the running task."""
|
|
360
|
+
if self._ws:
|
|
361
|
+
asyncio.create_task(self._ws.close())
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
task = self._task
|
|
365
|
+
if task and not task.done():
|
|
366
|
+
task.cancel()
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
return False
|
{voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/registry.py
RENAMED
|
@@ -34,6 +34,11 @@ def _make_cursor_runner(**_kw):
|
|
|
34
34
|
return CursorRunner()
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def _make_openclaw_runner(session_id: str | None = None, agent_id: str | None = None, env: dict | None = None, **_kw):
|
|
38
|
+
from voice_vibecoder.code_providers.openclaw import OpenClawRunner
|
|
39
|
+
return OpenClawRunner(session_id=session_id, agent_id=agent_id, env=env)
|
|
40
|
+
|
|
41
|
+
|
|
37
42
|
AGENTS: dict[str, AgentInfo] = {
|
|
38
43
|
"claude": AgentInfo(
|
|
39
44
|
id="claude",
|
|
@@ -49,6 +54,13 @@ AGENTS: dict[str, AgentInfo] = {
|
|
|
49
54
|
color="green",
|
|
50
55
|
runner_factory=_make_cursor_runner,
|
|
51
56
|
),
|
|
57
|
+
"openclaw": AgentInfo(
|
|
58
|
+
id="openclaw",
|
|
59
|
+
label="OpenClaw",
|
|
60
|
+
cli_command="openclaw",
|
|
61
|
+
color="red",
|
|
62
|
+
runner_factory=_make_openclaw_runner,
|
|
63
|
+
),
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
|
|
@@ -15,7 +15,7 @@ CHUNK_SIZE = AUDIO_SAMPLE_RATE * CHUNK_DURATION_MS // 1000
|
|
|
15
15
|
Language = Literal["no", "en", "sv"]
|
|
16
16
|
PermissionMode = Literal["bypass", "acceptEdits", "default"]
|
|
17
17
|
InputMode = Literal["vad", "ptt"]
|
|
18
|
-
AgentType = Literal["claude", "cursor"]
|
|
18
|
+
AgentType = Literal["claude", "cursor", "openclaw"]
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _agent_names(enabled: list[str] | None = None) -> str:
|
|
@@ -125,6 +125,9 @@ class InstanceRegistry:
|
|
|
125
125
|
self.on_status_change(instance.instance_id, instance.status)
|
|
126
126
|
if self.on_instance_change:
|
|
127
127
|
self.on_instance_change()
|
|
128
|
+
# Show file tree immediately when panel appears
|
|
129
|
+
from voice_vibecoder.tools.file_tree import start_file_tree_updates
|
|
130
|
+
start_file_tree_updates(instance.instance_id, worktree_path, self)
|
|
128
131
|
return instance
|
|
129
132
|
|
|
130
133
|
def create_session(self, name: str, agent_type: str = "claude") -> AgentInstance:
|
|
@@ -416,4 +419,11 @@ class InstanceRegistry:
|
|
|
416
419
|
self._branch_index[branch] = instance.instance_id
|
|
417
420
|
loaded.append(instance)
|
|
418
421
|
|
|
422
|
+
# Start file tree updates for restored code instances
|
|
423
|
+
from voice_vibecoder.tools.file_tree import start_file_tree_updates
|
|
424
|
+
for inst in loaded:
|
|
425
|
+
if inst.instance_type == InstanceType.SESSION:
|
|
426
|
+
continue
|
|
427
|
+
start_file_tree_updates(inst.instance_id, inst.worktree_path, self)
|
|
428
|
+
|
|
419
429
|
return loaded
|
|
@@ -136,6 +136,12 @@ class WebSocketBridge:
|
|
|
136
136
|
elif msg_type == "audio.commit":
|
|
137
137
|
if self._session:
|
|
138
138
|
await self._session.commit_audio()
|
|
139
|
+
elif msg_type == "text":
|
|
140
|
+
# Text-only mode: inject user message directly
|
|
141
|
+
text = msg.get("text", "") or msg.get("message", "")
|
|
142
|
+
if text and self._session:
|
|
143
|
+
self._send({"type": "transcript", "role": "user", "text": text})
|
|
144
|
+
await self._session.inject_user_message(text)
|
|
139
145
|
elif msg_type == "sync_state":
|
|
140
146
|
# Frontend requests full state sync from backend
|
|
141
147
|
self._send({"type": "status", "status": "syncing"})
|
|
@@ -97,7 +97,9 @@ def configure(
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def _default_agent_type() -> str:
|
|
100
|
-
"""Return the first enabled agent
|
|
100
|
+
"""Return the default agent type from config, or first enabled agent."""
|
|
101
|
+
if _voice_config and _voice_config.default_agent:
|
|
102
|
+
return _voice_config.default_agent
|
|
101
103
|
return _enabled_agents[0] if _enabled_agents else "claude"
|
|
102
104
|
|
|
103
105
|
|
|
@@ -188,14 +188,18 @@ def get_file_tree(worktree_path: str) -> dict:
|
|
|
188
188
|
if status in summary:
|
|
189
189
|
summary[status] += 1
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
# Flat entries list for the TUI sidebar
|
|
192
|
+
entries = [{"path": p, "status": s} for p, s in sorted(all_paths.items())]
|
|
193
|
+
|
|
194
|
+
return {"root": root, "tree": tree, "summary": summary, "entries": entries}
|
|
192
195
|
|
|
193
196
|
except Exception as e:
|
|
194
197
|
logger.warning("Failed to build file tree: %s", e)
|
|
195
198
|
return {
|
|
196
199
|
"root": "unknown",
|
|
197
200
|
"tree": {"name": "unknown", "path": "", "type": "folder", "children": [], "expanded": True},
|
|
198
|
-
"summary": {"modified": 0, "new": 0, "deleted": 0, "unchanged": 0}
|
|
201
|
+
"summary": {"modified": 0, "new": 0, "deleted": 0, "unchanged": 0},
|
|
202
|
+
"entries": [],
|
|
199
203
|
}
|
|
200
204
|
|
|
201
205
|
|
|
@@ -395,7 +395,8 @@ def _handle_show_diff(branch: str | None = None, file: str | None = None) -> str
|
|
|
395
395
|
return f"No changes on '{instance.branch}' vs main."
|
|
396
396
|
if matched_file:
|
|
397
397
|
return f"Showing diff for '{matched_file}' on '{instance.branch}'."
|
|
398
|
-
|
|
398
|
+
file_list = ", ".join(f.path for f in files[:15])
|
|
399
|
+
return f"Showing diff for '{instance.branch}': {file_count} changed file(s): {file_list}"
|
|
399
400
|
|
|
400
401
|
|
|
401
402
|
def _handle_show_output(branch: str | None = None, session_name: str | None = None) -> str:
|
|
@@ -47,7 +47,7 @@ class CallbackMixin:
|
|
|
47
47
|
return
|
|
48
48
|
|
|
49
49
|
inst = self._session.registry.get_by_id(instance_id)
|
|
50
|
-
if not inst
|
|
50
|
+
if not inst:
|
|
51
51
|
return
|
|
52
52
|
|
|
53
53
|
category = None
|
|
@@ -71,9 +71,27 @@ class CallbackMixin:
|
|
|
71
71
|
styled = _format_output_line(category, display_line, inst.agent_type, show_badge=show_badge)
|
|
72
72
|
|
|
73
73
|
self._state.add_instance_line(inst.display_key, styled)
|
|
74
|
-
self.app.call_from_thread(self.
|
|
74
|
+
self.app.call_from_thread(self._ensure_output, instance_id, inst, styled)
|
|
75
75
|
self._write_log_file(f"[{inst.display_key}] {display_line}")
|
|
76
76
|
|
|
77
|
+
def _ensure_output(self, instance_id: str, inst, styled: str) -> None:
|
|
78
|
+
"""Write output to panel, recovering the widget reference if needed."""
|
|
79
|
+
recovered = False
|
|
80
|
+
if not inst.output_widget:
|
|
81
|
+
# Widget reference lost (e.g. after restore) — recover from DOM
|
|
82
|
+
try:
|
|
83
|
+
inst.output_widget = self.query_one(f"#output-{instance_id}", RichLog)
|
|
84
|
+
recovered = True
|
|
85
|
+
except Exception:
|
|
86
|
+
# Panel doesn't exist yet — create it
|
|
87
|
+
self._create_instance_panel(instance_id, inst.display_key)
|
|
88
|
+
recovered = True
|
|
89
|
+
if not inst.output_widget:
|
|
90
|
+
return
|
|
91
|
+
if recovered:
|
|
92
|
+
self._write_to_output(inst.output_widget, "[dim]─── session resumed ───[/dim]")
|
|
93
|
+
self._write_to_output(inst.output_widget, styled)
|
|
94
|
+
|
|
77
95
|
def _write_to_output(self, widget: RichLog, text: str) -> None:
|
|
78
96
|
try:
|
|
79
97
|
widget.write(text)
|
|
@@ -4,13 +4,16 @@ Handles creating, updating, toggling fullscreen, and removing agent
|
|
|
4
4
|
instance panels in the grid layout.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import logging
|
|
7
8
|
import math
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
9
12
|
from textual.containers import Grid, Horizontal, Vertical
|
|
10
|
-
from textual.widgets import Static, RichLog
|
|
13
|
+
from textual.widgets import Static, RichLog, Tree
|
|
11
14
|
|
|
12
15
|
from voice_vibecoder.code_providers.registry import get_badge
|
|
13
|
-
from voice_vibecoder.instances import AgentInstance, InstanceStatus
|
|
16
|
+
from voice_vibecoder.instances import AgentInstance, InstanceStatus, InstanceType
|
|
14
17
|
from voice_vibecoder.ui.styles import _STATUS_ICONS
|
|
15
18
|
|
|
16
19
|
|
|
@@ -29,30 +32,68 @@ _FILE_STATUS_COLORS = {
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
class FileTreeWidget(Tree):
|
|
36
|
+
"""Interactive file tree sidebar with clickable expand/collapse folders."""
|
|
37
|
+
|
|
38
|
+
show_root = False
|
|
39
|
+
|
|
40
|
+
DEFAULT_CSS = """
|
|
41
|
+
FileTreeWidget {
|
|
42
|
+
scrollbar-size: 1 1;
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, tree_data: dict, **kwargs):
|
|
47
|
+
super().__init__(tree_data.get("name", "files"), **kwargs)
|
|
48
|
+
self._user_toggled: dict[str, bool] = {}
|
|
49
|
+
self._rebuilding = False
|
|
50
|
+
self._build_from_data(tree_data)
|
|
51
|
+
|
|
52
|
+
def _build_from_data(self, tree_data: dict) -> None:
|
|
53
|
+
self._rebuilding = True
|
|
54
|
+
self.clear()
|
|
55
|
+
self.root.expand()
|
|
56
|
+
for child in tree_data.get("children", []):
|
|
57
|
+
self._insert_node(self.root, child)
|
|
58
|
+
self._rebuilding = False
|
|
59
|
+
|
|
60
|
+
def _insert_node(self, parent, node_data: dict) -> None:
|
|
61
|
+
path = node_data.get("path", "")
|
|
62
|
+
name = node_data.get("name", "")
|
|
63
|
+
|
|
64
|
+
if node_data["type"] == "folder":
|
|
65
|
+
if path in self._user_toggled:
|
|
66
|
+
should_expand = self._user_toggled[path]
|
|
67
|
+
else:
|
|
68
|
+
should_expand = node_data.get("expanded", False)
|
|
69
|
+
|
|
70
|
+
label = f"[bold dim]{name}/[/bold dim]"
|
|
71
|
+
folder_node = parent.add(label, data={"path": path, "type": "folder"}, expand=should_expand)
|
|
72
|
+
|
|
73
|
+
for child in node_data.get("children", []):
|
|
74
|
+
self._insert_node(folder_node, child)
|
|
41
75
|
else:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
lines = []
|
|
46
|
-
for folder in sorted(tree.keys()):
|
|
47
|
-
if folder:
|
|
48
|
-
lines.append(f"[bold dim]{folder}/[/bold dim]")
|
|
49
|
-
for name, status in sorted(tree[folder], key=lambda x: x[0]):
|
|
50
|
-
icon = _FILE_STATUS_ICONS.get(status, "[dim]?[/dim]")
|
|
76
|
+
status = node_data.get("status", "unchanged")
|
|
77
|
+
icon = _FILE_STATUS_ICONS.get(status, "[dim]·[/dim]")
|
|
51
78
|
color = _FILE_STATUS_COLORS.get(status, "dim")
|
|
52
|
-
|
|
53
|
-
|
|
79
|
+
label = f"{icon} [{color}]{name}[/{color}]"
|
|
80
|
+
parent.add_leaf(label, data={"path": path, "type": "file", "status": status})
|
|
81
|
+
|
|
82
|
+
def rebuild(self, tree_data: dict) -> None:
|
|
83
|
+
"""Rebuild tree from new data, preserving user expand/collapse state."""
|
|
84
|
+
self._build_from_data(tree_data)
|
|
85
|
+
|
|
86
|
+
def on_tree_node_expanded(self, event) -> None:
|
|
87
|
+
if self._rebuilding:
|
|
88
|
+
return
|
|
89
|
+
if event.node.data and event.node.data.get("type") == "folder":
|
|
90
|
+
self._user_toggled[event.node.data["path"]] = True
|
|
54
91
|
|
|
55
|
-
|
|
92
|
+
def on_tree_node_collapsed(self, event) -> None:
|
|
93
|
+
if self._rebuilding:
|
|
94
|
+
return
|
|
95
|
+
if event.node.data and event.node.data.get("type") == "folder":
|
|
96
|
+
self._user_toggled[event.node.data["path"]] = False
|
|
56
97
|
|
|
57
98
|
|
|
58
99
|
def _format_header(inst: AgentInstance, status: InstanceStatus | None = None) -> str:
|
|
@@ -125,14 +166,31 @@ class PanelMixin:
|
|
|
125
166
|
classes="agent-output",
|
|
126
167
|
id=f"output-{inst.instance_id}",
|
|
127
168
|
)
|
|
169
|
+
inst.header_widget = header
|
|
170
|
+
inst.output_widget = output
|
|
171
|
+
|
|
172
|
+
if inst.instance_type == InstanceType.CODE:
|
|
173
|
+
_empty_tree = {"name": "", "path": "", "type": "folder", "children": [], "expanded": True}
|
|
174
|
+
tree_widget = FileTreeWidget(
|
|
175
|
+
_empty_tree,
|
|
176
|
+
classes="file-tree-sidebar",
|
|
177
|
+
id=f"file-tree-{inst.instance_id}",
|
|
178
|
+
)
|
|
179
|
+
body = Horizontal(
|
|
180
|
+
tree_widget,
|
|
181
|
+
output,
|
|
182
|
+
classes="file-tree-container",
|
|
183
|
+
id=f"file-tree-wrap-{inst.instance_id}",
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
body = output
|
|
187
|
+
|
|
128
188
|
panel = Vertical(
|
|
129
189
|
header,
|
|
130
|
-
|
|
190
|
+
body,
|
|
131
191
|
classes="agent-panel",
|
|
132
192
|
id=f"panel-{inst.instance_id}",
|
|
133
193
|
)
|
|
134
|
-
inst.header_widget = header
|
|
135
|
-
inst.output_widget = output
|
|
136
194
|
panels.append(panel)
|
|
137
195
|
|
|
138
196
|
try:
|
|
@@ -159,17 +217,35 @@ class PanelMixin:
|
|
|
159
217
|
classes="agent-output",
|
|
160
218
|
id=f"output-{instance_id}",
|
|
161
219
|
)
|
|
220
|
+
|
|
221
|
+
if inst:
|
|
222
|
+
inst.header_widget = header
|
|
223
|
+
inst.output_widget = output
|
|
224
|
+
|
|
225
|
+
is_code = inst and inst.instance_type == InstanceType.CODE
|
|
226
|
+
if is_code:
|
|
227
|
+
_empty_tree = {"name": "", "path": "", "type": "folder", "children": [], "expanded": True}
|
|
228
|
+
tree_widget = FileTreeWidget(
|
|
229
|
+
_empty_tree,
|
|
230
|
+
classes="file-tree-sidebar",
|
|
231
|
+
id=f"file-tree-{instance_id}",
|
|
232
|
+
)
|
|
233
|
+
body = Horizontal(
|
|
234
|
+
tree_widget,
|
|
235
|
+
output,
|
|
236
|
+
classes="file-tree-container",
|
|
237
|
+
id=f"file-tree-wrap-{instance_id}",
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
body = output
|
|
241
|
+
|
|
162
242
|
panel = Vertical(
|
|
163
243
|
header,
|
|
164
|
-
|
|
244
|
+
body,
|
|
165
245
|
classes="agent-panel",
|
|
166
246
|
id=f"panel-{instance_id}",
|
|
167
247
|
)
|
|
168
248
|
|
|
169
|
-
if inst:
|
|
170
|
-
inst.header_widget = header
|
|
171
|
-
inst.output_widget = output
|
|
172
|
-
|
|
173
249
|
try:
|
|
174
250
|
grid = self.query_one("#agent-grid", Grid)
|
|
175
251
|
grid.styles.display = "block"
|
|
@@ -284,61 +360,17 @@ class PanelMixin:
|
|
|
284
360
|
grid.styles.grid_size_rows = 1
|
|
285
361
|
|
|
286
362
|
def _update_file_tree(self, instance_id: str, data: dict) -> None:
|
|
287
|
-
"""Update
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
inst = self._session.registry.get_by_id(instance_id)
|
|
291
|
-
if not inst:
|
|
292
|
-
return
|
|
293
|
-
|
|
294
|
-
entries = data.get("entries", [])
|
|
295
|
-
if not entries:
|
|
363
|
+
"""Update the file tree sidebar for a code instance panel."""
|
|
364
|
+
tree_data = data.get("tree")
|
|
365
|
+
if not tree_data or not tree_data.get("children"):
|
|
296
366
|
return
|
|
297
367
|
|
|
298
|
-
formatted = _format_file_tree(entries)
|
|
299
|
-
sidebar_id = f"file-tree-{instance_id}"
|
|
300
|
-
|
|
301
|
-
# Try to update existing sidebar
|
|
302
368
|
try:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return
|
|
369
|
+
tree_widget = self.query_one(f"#file-tree-{instance_id}", FileTreeWidget)
|
|
370
|
+
tree_widget.rebuild(tree_data)
|
|
306
371
|
except Exception:
|
|
307
372
|
pass
|
|
308
373
|
|
|
309
|
-
# First time — wrap output in a Horizontal with tree sidebar
|
|
310
|
-
if not inst.output_widget:
|
|
311
|
-
return
|
|
312
|
-
|
|
313
|
-
try:
|
|
314
|
-
panel = self.query_one(f"#panel-{instance_id}")
|
|
315
|
-
except Exception:
|
|
316
|
-
return
|
|
317
|
-
|
|
318
|
-
sidebar = Static(
|
|
319
|
-
formatted,
|
|
320
|
-
classes="file-tree-sidebar",
|
|
321
|
-
id=sidebar_id,
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
# Remove output from panel, wrap in Horizontal, re-mount
|
|
325
|
-
output = inst.output_widget
|
|
326
|
-
try:
|
|
327
|
-
output.remove()
|
|
328
|
-
container = Horizontal(
|
|
329
|
-
sidebar,
|
|
330
|
-
output,
|
|
331
|
-
classes="file-tree-container",
|
|
332
|
-
id=f"file-tree-wrap-{instance_id}",
|
|
333
|
-
)
|
|
334
|
-
panel.mount(container)
|
|
335
|
-
except Exception:
|
|
336
|
-
# If something fails, try to restore output directly
|
|
337
|
-
try:
|
|
338
|
-
panel.mount(output)
|
|
339
|
-
except Exception:
|
|
340
|
-
pass
|
|
341
|
-
|
|
342
374
|
def _remove_instance_panel(self, instance_id: str) -> None:
|
|
343
375
|
"""Remove a panel from the grid."""
|
|
344
376
|
if self._session:
|
|
@@ -361,6 +361,13 @@ class VoiceCodingScreen(PanelMixin, CallbackMixin, Screen):
|
|
|
361
361
|
asyncio.set_event_loop(self._loop)
|
|
362
362
|
self._loop.run_until_complete(self._session_lifecycle())
|
|
363
363
|
|
|
364
|
+
def _call_ui(self, callback, *args) -> None:
|
|
365
|
+
"""Safely call a UI callback from a background thread."""
|
|
366
|
+
try:
|
|
367
|
+
self.app.call_from_thread(callback, *args)
|
|
368
|
+
except Exception:
|
|
369
|
+
pass # App not active (shutting down or not yet ready)
|
|
370
|
+
|
|
364
371
|
async def _session_lifecycle(self) -> None:
|
|
365
372
|
try:
|
|
366
373
|
await self._session.connect()
|
|
@@ -371,12 +378,10 @@ class VoiceCodingScreen(PanelMixin, CallbackMixin, Screen):
|
|
|
371
378
|
self._audio = AudioManager(on_audio_chunk=self._send_audio_chunk)
|
|
372
379
|
self._audio.start()
|
|
373
380
|
|
|
374
|
-
self.
|
|
381
|
+
self._call_ui(self._log, "[green]Connected — start speaking[/green]")
|
|
375
382
|
if self._log_path:
|
|
376
|
-
self.
|
|
377
|
-
|
|
378
|
-
)
|
|
379
|
-
self.app.call_from_thread(self._update_status_bar, "ready")
|
|
383
|
+
self._call_ui(self._log, f"[dim]Log: {self._log_path}[/dim]")
|
|
384
|
+
self._call_ui(self._update_status_bar, "ready")
|
|
380
385
|
|
|
381
386
|
# Run custom startup callback if configured
|
|
382
387
|
if self._config.on_startup:
|
|
@@ -384,10 +389,10 @@ class VoiceCodingScreen(PanelMixin, CallbackMixin, Screen):
|
|
|
384
389
|
|
|
385
390
|
await self._session.handle_messages()
|
|
386
391
|
except Exception as e:
|
|
387
|
-
self.
|
|
392
|
+
self._call_ui(
|
|
388
393
|
self._log, f"[bold red]Connection error: {e}[/bold red]"
|
|
389
394
|
)
|
|
390
|
-
self.
|
|
395
|
+
self._call_ui(
|
|
391
396
|
self._set_status,
|
|
392
397
|
f"[red]Error[/red] | [dim]s[/dim] settings | [dim]q[/dim] quit",
|
|
393
398
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/cursor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/openai.py
RENAMED
|
File without changes
|
|
File without changes
|