voice-vibecoder 2.7.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.7.0 → voice_vibecoder-2.8.0}/CHANGELOG.md +11 -0
  2. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/PKG-INFO +1 -1
  3. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/pyproject.toml +1 -1
  4. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/app_config.py +4 -0
  6. voice_vibecoder-2.8.0/src/voice_vibecoder/code_providers/openclaw.py +369 -0
  7. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/registry.py +12 -0
  8. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/config.py +1 -1
  9. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/server.py +6 -0
  10. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/dispatch.py +3 -1
  11. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/callbacks.py +20 -2
  12. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/panels.py +46 -55
  13. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/.github/workflows/ci.yml +0 -0
  14. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/.github/workflows/release.yml +0 -0
  15. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/.gitignore +0 -0
  16. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/LICENSE +0 -0
  17. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/README.md +0 -0
  18. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/app.py +0 -0
  19. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/index.html +0 -0
  20. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/audio.py +0 -0
  21. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/cli_serve.py +0 -0
  22. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  23. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
  24. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  25. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/instances.py +0 -0
  26. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/session.py +0 -0
  27. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  28. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/agent_task.py +0 -0
  29. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  30. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
  31. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/tools/handlers.py +0 -0
  32. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  33. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  34. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  35. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/screen.py +0 -0
  36. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/setup.py +0 -0
  37. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/state.py +0 -0
  38. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/styles.py +0 -0
  39. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  40. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  41. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  42. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  43. {voice_vibecoder-2.7.0 → voice_vibecoder-2.8.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,17 @@
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
+
5
16
  ## v2.7.0 (2026-02-20)
6
17
 
7
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.7.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.7.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.7.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"
@@ -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:
@@ -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
 
@@ -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)
@@ -13,7 +13,7 @@ from textual.containers import Grid, Horizontal, Vertical
13
13
  from textual.widgets import Static, RichLog, Tree
14
14
 
15
15
  from voice_vibecoder.code_providers.registry import get_badge
16
- from voice_vibecoder.instances import AgentInstance, InstanceStatus
16
+ from voice_vibecoder.instances import AgentInstance, InstanceStatus, InstanceType
17
17
  from voice_vibecoder.ui.styles import _STATUS_ICONS
18
18
 
19
19
 
@@ -166,14 +166,31 @@ class PanelMixin:
166
166
  classes="agent-output",
167
167
  id=f"output-{inst.instance_id}",
168
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
+
169
188
  panel = Vertical(
170
189
  header,
171
- output,
190
+ body,
172
191
  classes="agent-panel",
173
192
  id=f"panel-{inst.instance_id}",
174
193
  )
175
- inst.header_widget = header
176
- inst.output_widget = output
177
194
  panels.append(panel)
178
195
 
179
196
  try:
@@ -200,17 +217,35 @@ class PanelMixin:
200
217
  classes="agent-output",
201
218
  id=f"output-{instance_id}",
202
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
+
203
242
  panel = Vertical(
204
243
  header,
205
- output,
244
+ body,
206
245
  classes="agent-panel",
207
246
  id=f"panel-{instance_id}",
208
247
  )
209
248
 
210
- if inst:
211
- inst.header_widget = header
212
- inst.output_widget = output
213
-
214
249
  try:
215
250
  grid = self.query_one("#agent-grid", Grid)
216
251
  grid.styles.display = "block"
@@ -325,61 +360,17 @@ class PanelMixin:
325
360
  grid.styles.grid_size_rows = 1
326
361
 
327
362
  def _update_file_tree(self, instance_id: str, data: dict) -> None:
328
- """Update (or create) the interactive file tree sidebar in a panel."""
329
- if not self._session:
330
- return
331
- inst = self._session.registry.get_by_id(instance_id)
332
- if not inst:
333
- return
334
-
363
+ """Update the file tree sidebar for a code instance panel."""
335
364
  tree_data = data.get("tree")
336
365
  if not tree_data or not tree_data.get("children"):
337
366
  return
338
367
 
339
- sidebar_id = f"file-tree-{instance_id}"
340
-
341
- # Try to update existing tree widget
342
368
  try:
343
- tree_widget = self.query_one(f"#{sidebar_id}", FileTreeWidget)
369
+ tree_widget = self.query_one(f"#file-tree-{instance_id}", FileTreeWidget)
344
370
  tree_widget.rebuild(tree_data)
345
- return
346
371
  except Exception:
347
372
  pass
348
373
 
349
- # First time — wrap output in a Horizontal with tree sidebar
350
- if not inst.output_widget:
351
- return
352
-
353
- try:
354
- panel = self.query_one(f"#panel-{instance_id}")
355
- except Exception:
356
- return
357
-
358
- tree_widget = FileTreeWidget(
359
- tree_data,
360
- classes="file-tree-sidebar",
361
- id=sidebar_id,
362
- )
363
-
364
- # Remove output from panel, wrap in Horizontal, re-mount
365
- output = inst.output_widget
366
- try:
367
- output.remove()
368
- container = Horizontal(
369
- tree_widget,
370
- output,
371
- classes="file-tree-container",
372
- id=f"file-tree-wrap-{instance_id}",
373
- )
374
- panel.mount(container)
375
- except Exception:
376
- logger.exception("Failed to mount file tree for %s", instance_id)
377
- # If something fails, try to restore output directly
378
- try:
379
- panel.mount(output)
380
- except Exception:
381
- pass
382
-
383
374
  def _remove_instance_panel(self, instance_id: str) -> None:
384
375
  """Remove a panel from the grid."""
385
376
  if self._session:
File without changes
File without changes