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.
Files changed (43) hide show
  1. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/CHANGELOG.md +14 -0
  2. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/PKG-INFO +1 -1
  3. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/pyproject.toml +1 -1
  4. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/app_config.py +4 -0
  6. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/claude.py +54 -46
  7. voice_vibecoder-2.8.0/src/voice_vibecoder/code_providers/openclaw.py +369 -0
  8. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/registry.py +12 -0
  9. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/config.py +1 -1
  10. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/instances.py +10 -0
  11. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/server.py +6 -0
  12. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/dispatch.py +3 -1
  13. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/file_tree.py +6 -2
  14. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/handlers.py +2 -1
  15. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/callbacks.py +20 -2
  16. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/panels.py +112 -80
  17. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/screen.py +12 -7
  18. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/.github/workflows/ci.yml +0 -0
  19. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/.github/workflows/release.yml +0 -0
  20. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/.gitignore +0 -0
  21. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/LICENSE +0 -0
  22. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/README.md +0 -0
  23. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/app.py +0 -0
  24. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/index.html +0 -0
  25. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/audio.py +0 -0
  26. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/cli_serve.py +0 -0
  27. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  28. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  29. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/session.py +0 -0
  30. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  31. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
  32. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  33. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  34. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  35. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  36. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/setup.py +0 -0
  37. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/state.py +0 -0
  38. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/styles.py +0 -0
  39. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  40. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  41. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  42. {voice_vibecoder-2.6.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  43. {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.6.0
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.6.0"
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"
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.6.0"
6
+ __version__ = "2.8.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -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"
@@ -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
- async for msg in query(prompt=_prompt(), options=options):
255
- if isinstance(msg, AssistantMessage):
256
- for block in msg.content:
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, ToolResultBlock):
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
- pass # Already routed via AssistantMessage
297
-
298
- elif isinstance(msg, ResultMessage):
299
- result_text = msg.result or ""
300
- if msg.session_id:
301
- result_session_id = msg.session_id
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
@@ -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 type."""
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
- return {"root": root, "tree": tree, "summary": summary}
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
- return f"Showing diff for '{instance.branch}': {file_count} file{'s' if file_count != 1 else ''} changed."
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 or not inst.output_widget:
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._write_to_output, inst.output_widget, styled)
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
- def _format_file_tree(entries: list[dict]) -> str:
33
- """Format file tree entries into Rich markup for the TUI sidebar."""
34
- # Group by directory
35
- tree: dict[str, list[tuple[str, str]]] = {} # dir -> [(filename, status)]
36
- for entry in entries:
37
- path = entry.get("path", "")
38
- status = entry.get("status", "unchanged")
39
- if "/" in path:
40
- folder, name = path.rsplit("/", 1)
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
- folder, name = "", path
43
- tree.setdefault(folder, []).append((name, status))
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
- indent = " " if folder else ""
53
- lines.append(f"{indent}{icon} [{color}]{name}[/{color}]")
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
- return "\n".join(lines)
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
- output,
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
- output,
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 (or create) the file tree sidebar in a panel."""
288
- if not self._session:
289
- return
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
- sidebar = self.query_one(f"#{sidebar_id}", Static)
304
- sidebar.update(formatted)
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.app.call_from_thread(self._log, "[green]Connected — start speaking[/green]")
381
+ self._call_ui(self._log, "[green]Connected — start speaking[/green]")
375
382
  if self._log_path:
376
- self.app.call_from_thread(
377
- self._log, f"[dim]Log: {self._log_path}[/dim]"
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.app.call_from_thread(
392
+ self._call_ui(
388
393
  self._log, f"[bold red]Connection error: {e}[/bold red]"
389
394
  )
390
- self.app.call_from_thread(
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