voice-vibecoder 2.5.3__tar.gz → 2.7.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.5.3 → voice_vibecoder-2.7.0}/CHANGELOG.md +16 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/PKG-INFO +1 -1
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/pyproject.toml +1 -1
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/__init__.py +1 -1
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/claude.py +54 -46
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/instances.py +8 -4
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/session.py +4 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/agent_task.py +53 -38
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/file_tree.py +30 -44
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/handlers.py +2 -1
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/panels.py +73 -32
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/screen.py +12 -7
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/.github/workflows/ci.yml +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/.github/workflows/release.yml +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/.gitignore +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/LICENSE +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/README.md +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/app.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/index.html +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/app_config.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/audio.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/cli_serve.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/config.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/server.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/__init__.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/definitions.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/__init__.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/setup.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/state.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/styles.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/wizard.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
- {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/worktrees.py +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v2.7.0 (2026-02-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## v2.6.0 (2026-02-20)
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
- **agent_task**: Support matching by display name in session lookup
|
|
13
|
+
([`a97e0e6`](https://github.com/snokam/voice-vibecoder/commit/a97e0e67fdf7178ddee182acc275957a471e2691))
|
|
14
|
+
|
|
15
|
+
### Refactoring
|
|
16
|
+
|
|
17
|
+
- **instances**: Simplify branch key assignment logic
|
|
18
|
+
([`58f5d34`](https://github.com/snokam/voice-vibecoder/commit/58f5d3475d1696dd3b92252e3843ee2e4a04826b))
|
|
19
|
+
|
|
20
|
+
|
|
5
21
|
## v2.5.3 (2026-02-20)
|
|
6
22
|
|
|
7
23
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voice-vibecoder
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.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.7.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"
|
{voice_vibecoder-2.5.3 → voice_vibecoder-2.7.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
|
|
|
@@ -125,7 +125,7 @@ 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
|
-
#
|
|
128
|
+
# Show file tree immediately when panel appears
|
|
129
129
|
from voice_vibecoder.tools.file_tree import start_file_tree_updates
|
|
130
130
|
start_file_tree_updates(instance.instance_id, worktree_path, self)
|
|
131
131
|
return instance
|
|
@@ -136,16 +136,20 @@ class InstanceRegistry:
|
|
|
136
136
|
if existing:
|
|
137
137
|
return existing
|
|
138
138
|
with self._lock:
|
|
139
|
+
# Re-check under lock to prevent race between concurrent creates
|
|
140
|
+
branch_key = f"_session_{name}"
|
|
141
|
+
if branch_key in self._branch_index:
|
|
142
|
+
return self._instances[self._branch_index[branch_key]]
|
|
139
143
|
instance = AgentInstance(
|
|
140
144
|
instance_id=uuid.uuid4().hex[:8],
|
|
141
|
-
branch=
|
|
145
|
+
branch=branch_key,
|
|
142
146
|
worktree_path="",
|
|
143
147
|
agent_type=agent_type,
|
|
144
148
|
instance_type=InstanceType.SESSION,
|
|
145
149
|
display_name=name,
|
|
146
150
|
)
|
|
147
151
|
self._instances[instance.instance_id] = instance
|
|
148
|
-
self._branch_index[
|
|
152
|
+
self._branch_index[branch_key] = instance.instance_id
|
|
149
153
|
self._save_state()
|
|
150
154
|
if self.on_status_change:
|
|
151
155
|
self.on_status_change(instance.instance_id, instance.status)
|
|
@@ -415,7 +419,7 @@ class InstanceRegistry:
|
|
|
415
419
|
self._branch_index[branch] = instance.instance_id
|
|
416
420
|
loaded.append(instance)
|
|
417
421
|
|
|
418
|
-
# Start file tree updates for code instances
|
|
422
|
+
# Start file tree updates for restored code instances
|
|
419
423
|
from voice_vibecoder.tools.file_tree import start_file_tree_updates
|
|
420
424
|
for inst in loaded:
|
|
421
425
|
if inst.instance_type == InstanceType.SESSION:
|
|
@@ -128,8 +128,12 @@ class RealtimeSession:
|
|
|
128
128
|
self._on_status("connected")
|
|
129
129
|
|
|
130
130
|
async def configure_session(self) -> None:
|
|
131
|
+
from voice_vibecoder.tools.agent_task import warm_mcp_cache
|
|
131
132
|
from voice_vibecoder.tools.handlers import get_active_instances_text, get_startup_context
|
|
132
133
|
|
|
134
|
+
# Pre-warm MCP server cache so first agent spawn doesn't pay the cost
|
|
135
|
+
warm_mcp_cache()
|
|
136
|
+
|
|
133
137
|
s = self._settings
|
|
134
138
|
overrides = self._voice_config.system_instructions if self._voice_config else None
|
|
135
139
|
instructions = get_system_instructions(
|
|
@@ -23,10 +23,11 @@ _CLAUDE_JSON = Path.home() / ".claude.json"
|
|
|
23
23
|
|
|
24
24
|
# Cached MCP servers — loaded once per session, files rarely change
|
|
25
25
|
_mcp_cache: dict[str, dict] | None = None
|
|
26
|
+
_mcp_lock = threading.Lock()
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def _load_mcp_servers() -> dict[str, dict]:
|
|
29
|
-
"""Load MCP servers (cached after first call).
|
|
30
|
+
"""Load MCP servers (cached after first call, thread-safe).
|
|
30
31
|
|
|
31
32
|
Priority order:
|
|
32
33
|
1. MCP_SERVERS_JSON environment variable (for Azure/production)
|
|
@@ -37,50 +38,60 @@ def _load_mcp_servers() -> dict[str, dict]:
|
|
|
37
38
|
if _mcp_cache is not None:
|
|
38
39
|
return _mcp_cache
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
with _mcp_lock:
|
|
42
|
+
# Double-check after acquiring the lock
|
|
43
|
+
if _mcp_cache is not None:
|
|
44
|
+
return _mcp_cache
|
|
45
|
+
|
|
46
|
+
import os
|
|
47
|
+
from voice_vibecoder.tools.dispatch import _repo_root
|
|
48
|
+
|
|
49
|
+
servers: dict[str, dict] = {}
|
|
42
50
|
|
|
43
|
-
|
|
51
|
+
# Load from .mcp.json in repo root
|
|
52
|
+
if _repo_root:
|
|
53
|
+
mcp_json = Path(_repo_root) / ".mcp.json"
|
|
54
|
+
if mcp_json.exists():
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(mcp_json.read_text())
|
|
57
|
+
servers.update(data.get("mcpServers", {}))
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.warning("Failed to read .mcp.json: %s", e)
|
|
44
60
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
mcp_json = Path(_repo_root) / ".mcp.json"
|
|
48
|
-
if mcp_json.exists():
|
|
61
|
+
# Load from ~/.claude.json
|
|
62
|
+
if _repo_root and _CLAUDE_JSON.exists():
|
|
49
63
|
try:
|
|
50
|
-
data = json.loads(
|
|
51
|
-
|
|
64
|
+
data = json.loads(_CLAUDE_JSON.read_text())
|
|
65
|
+
project_key = str(Path(_repo_root))
|
|
66
|
+
project_data = data.get("projects", {}).get(project_key, {})
|
|
67
|
+
local_servers = project_data.get("mcpServers", {})
|
|
68
|
+
servers.update(local_servers)
|
|
52
69
|
except Exception as e:
|
|
53
|
-
logger.warning("Failed to read .
|
|
70
|
+
logger.warning("Failed to read ~/.claude.json: %s", e)
|
|
54
71
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
except Exception as e:
|
|
74
|
-
logger.warning("Failed to parse MCP_SERVERS_JSON: %s", e)
|
|
72
|
+
# MCP_SERVERS_JSON takes precedence (for Azure Container Apps)
|
|
73
|
+
mcp_env = os.environ.get("MCP_SERVERS_JSON")
|
|
74
|
+
if mcp_env:
|
|
75
|
+
try:
|
|
76
|
+
env_servers = json.loads(mcp_env)
|
|
77
|
+
servers.update(env_servers.get("mcpServers", env_servers))
|
|
78
|
+
logger.info("Loaded MCP servers from MCP_SERVERS_JSON environment variable")
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.warning("Failed to parse MCP_SERVERS_JSON: %s", e)
|
|
81
|
+
|
|
82
|
+
# Ensure each MCP server inherits process env vars (the Claude CLI may
|
|
83
|
+
# not forward its own env to MCP server subprocesses).
|
|
84
|
+
for _name, cfg in servers.items():
|
|
85
|
+
if "env" not in cfg:
|
|
86
|
+
cfg["env"] = dict(os.environ)
|
|
87
|
+
|
|
88
|
+
_mcp_cache = servers
|
|
89
|
+
return servers
|
|
75
90
|
|
|
76
|
-
# Ensure each MCP server inherits process env vars (the Claude CLI may
|
|
77
|
-
# not forward its own env to MCP server subprocesses).
|
|
78
|
-
for _name, cfg in servers.items():
|
|
79
|
-
if "env" not in cfg:
|
|
80
|
-
cfg["env"] = dict(os.environ)
|
|
81
91
|
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
def warm_mcp_cache() -> None:
|
|
93
|
+
"""Pre-load MCP server config so it's ready when agents spawn."""
|
|
94
|
+
_load_mcp_servers()
|
|
84
95
|
|
|
85
96
|
|
|
86
97
|
def _create_runner(agent_type: str, permission_mode: str, mcp_servers: dict | None, env: dict | None = None) -> AgentRunner:
|
|
@@ -198,6 +209,10 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
|
|
|
198
209
|
session_name = "session"
|
|
199
210
|
|
|
200
211
|
instance = _registry.get_session_by_name(session_name)
|
|
212
|
+
# Also try matching by display_name (LLM may use different session keys
|
|
213
|
+
# for the same logical session, e.g. "calendar-today" vs "today-s-calendar")
|
|
214
|
+
if not instance and display_name:
|
|
215
|
+
instance = _registry.get_session_by_name(display_name)
|
|
201
216
|
is_new_session = instance is None
|
|
202
217
|
if not instance:
|
|
203
218
|
instance = _registry.create_session(session_name, agent_type=target_agent)
|
|
@@ -9,6 +9,7 @@ timer while an agent is running.
|
|
|
9
9
|
import logging
|
|
10
10
|
import subprocess
|
|
11
11
|
import threading
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
13
|
from pathlib import PurePosixPath
|
|
13
14
|
|
|
14
15
|
logger = logging.getLogger(__name__)
|
|
@@ -16,16 +17,15 @@ logger = logging.getLogger(__name__)
|
|
|
16
17
|
MAX_DEPTH = 10 # Maximum tree depth to prevent excessive nesting
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
def
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
["git",
|
|
23
|
-
cwd=
|
|
20
|
+
def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess:
|
|
21
|
+
"""Run a git command and return the CompletedProcess."""
|
|
22
|
+
return subprocess.run(
|
|
23
|
+
["git", *args],
|
|
24
|
+
cwd=cwd,
|
|
24
25
|
capture_output=True,
|
|
25
26
|
text=True,
|
|
26
27
|
timeout=10,
|
|
27
28
|
)
|
|
28
|
-
return result.stdout.strip() if result.returncode == 0 else "main"
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def _build_tree_node(path: str, status: str | None, is_folder: bool) -> dict:
|
|
@@ -112,26 +112,25 @@ def get_file_tree(worktree_path: str) -> dict:
|
|
|
112
112
|
"summary": {"modified": N, "new": N, "deleted": N, "unchanged": N}}
|
|
113
113
|
"""
|
|
114
114
|
try:
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
# Run independent git commands in parallel, then diff after merge-base
|
|
116
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
117
|
+
branch_fut = pool.submit(_git, ["rev-parse", "--abbrev-ref", "HEAD"], worktree_path)
|
|
118
|
+
base_fut = pool.submit(_git, ["merge-base", "main", "HEAD"], worktree_path)
|
|
119
|
+
untracked_fut = pool.submit(_git, ["ls-files", "--others", "--exclude-standard"], worktree_path)
|
|
120
|
+
all_files_fut = pool.submit(_git, ["ls-files"], worktree_path)
|
|
121
|
+
|
|
122
|
+
# Wait for merge-base, then submit diff (overlaps with ls-files)
|
|
123
|
+
base_result = base_fut.result()
|
|
124
|
+
base = base_result.stdout.strip() if base_result.returncode == 0 else "main"
|
|
125
|
+
diff_fut = pool.submit(_git, ["diff", "--name-status", base], worktree_path)
|
|
126
|
+
|
|
127
|
+
# Collect remaining results
|
|
128
|
+
branch_result = branch_fut.result()
|
|
129
|
+
diff_result = diff_fut.result()
|
|
130
|
+
untracked_result = untracked_fut.result()
|
|
131
|
+
all_files_result = all_files_fut.result()
|
|
126
132
|
|
|
127
|
-
|
|
128
|
-
diff_result = subprocess.run(
|
|
129
|
-
["git", "diff", "--name-status", base],
|
|
130
|
-
cwd=worktree_path,
|
|
131
|
-
capture_output=True,
|
|
132
|
-
text=True,
|
|
133
|
-
timeout=10,
|
|
134
|
-
)
|
|
133
|
+
root = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
|
|
135
134
|
|
|
136
135
|
changed: dict[str, str] = {} # path -> status
|
|
137
136
|
if diff_result.returncode == 0:
|
|
@@ -151,29 +150,12 @@ def get_file_tree(worktree_path: str) -> dict:
|
|
|
151
150
|
elif status_code in ("R", "C"):
|
|
152
151
|
changed[path] = "modified"
|
|
153
152
|
|
|
154
|
-
# Get untracked files
|
|
155
|
-
untracked_result = subprocess.run(
|
|
156
|
-
["git", "ls-files", "--others", "--exclude-standard"],
|
|
157
|
-
cwd=worktree_path,
|
|
158
|
-
capture_output=True,
|
|
159
|
-
text=True,
|
|
160
|
-
timeout=10,
|
|
161
|
-
)
|
|
162
153
|
if untracked_result.returncode == 0:
|
|
163
154
|
for path in untracked_result.stdout.strip().splitlines():
|
|
164
155
|
path = path.strip()
|
|
165
156
|
if path and path not in changed:
|
|
166
157
|
changed[path] = "new"
|
|
167
158
|
|
|
168
|
-
# Get all tracked files to show complete project structure
|
|
169
|
-
all_files_result = subprocess.run(
|
|
170
|
-
["git", "ls-files"],
|
|
171
|
-
cwd=worktree_path,
|
|
172
|
-
capture_output=True,
|
|
173
|
-
text=True,
|
|
174
|
-
timeout=10,
|
|
175
|
-
)
|
|
176
|
-
|
|
177
159
|
all_paths: dict[str, str] = {} # path -> status
|
|
178
160
|
if all_files_result.returncode == 0:
|
|
179
161
|
for f in all_files_result.stdout.strip().splitlines():
|
|
@@ -206,14 +188,18 @@ def get_file_tree(worktree_path: str) -> dict:
|
|
|
206
188
|
if status in summary:
|
|
207
189
|
summary[status] += 1
|
|
208
190
|
|
|
209
|
-
|
|
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}
|
|
210
195
|
|
|
211
196
|
except Exception as e:
|
|
212
197
|
logger.warning("Failed to build file tree: %s", e)
|
|
213
198
|
return {
|
|
214
199
|
"root": "unknown",
|
|
215
200
|
"tree": {"name": "unknown", "path": "", "type": "folder", "children": [], "expanded": True},
|
|
216
|
-
"summary": {"modified": 0, "new": 0, "deleted": 0, "unchanged": 0}
|
|
201
|
+
"summary": {"modified": 0, "new": 0, "deleted": 0, "unchanged": 0},
|
|
202
|
+
"entries": [],
|
|
217
203
|
}
|
|
218
204
|
|
|
219
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:
|
|
@@ -4,10 +4,13 @@ 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
16
|
from voice_vibecoder.instances import AgentInstance, InstanceStatus
|
|
@@ -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)
|
|
54
85
|
|
|
55
|
-
|
|
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
|
|
91
|
+
|
|
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:
|
|
@@ -284,24 +325,23 @@ class PanelMixin:
|
|
|
284
325
|
grid.styles.grid_size_rows = 1
|
|
285
326
|
|
|
286
327
|
def _update_file_tree(self, instance_id: str, data: dict) -> None:
|
|
287
|
-
"""Update (or create) the file tree sidebar in a panel."""
|
|
328
|
+
"""Update (or create) the interactive file tree sidebar in a panel."""
|
|
288
329
|
if not self._session:
|
|
289
330
|
return
|
|
290
331
|
inst = self._session.registry.get_by_id(instance_id)
|
|
291
332
|
if not inst:
|
|
292
333
|
return
|
|
293
334
|
|
|
294
|
-
|
|
295
|
-
if not
|
|
335
|
+
tree_data = data.get("tree")
|
|
336
|
+
if not tree_data or not tree_data.get("children"):
|
|
296
337
|
return
|
|
297
338
|
|
|
298
|
-
formatted = _format_file_tree(entries)
|
|
299
339
|
sidebar_id = f"file-tree-{instance_id}"
|
|
300
340
|
|
|
301
|
-
# Try to update existing
|
|
341
|
+
# Try to update existing tree widget
|
|
302
342
|
try:
|
|
303
|
-
|
|
304
|
-
|
|
343
|
+
tree_widget = self.query_one(f"#{sidebar_id}", FileTreeWidget)
|
|
344
|
+
tree_widget.rebuild(tree_data)
|
|
305
345
|
return
|
|
306
346
|
except Exception:
|
|
307
347
|
pass
|
|
@@ -315,8 +355,8 @@ class PanelMixin:
|
|
|
315
355
|
except Exception:
|
|
316
356
|
return
|
|
317
357
|
|
|
318
|
-
|
|
319
|
-
|
|
358
|
+
tree_widget = FileTreeWidget(
|
|
359
|
+
tree_data,
|
|
320
360
|
classes="file-tree-sidebar",
|
|
321
361
|
id=sidebar_id,
|
|
322
362
|
)
|
|
@@ -326,13 +366,14 @@ class PanelMixin:
|
|
|
326
366
|
try:
|
|
327
367
|
output.remove()
|
|
328
368
|
container = Horizontal(
|
|
329
|
-
|
|
369
|
+
tree_widget,
|
|
330
370
|
output,
|
|
331
371
|
classes="file-tree-container",
|
|
332
372
|
id=f"file-tree-wrap-{instance_id}",
|
|
333
373
|
)
|
|
334
374
|
panel.mount(container)
|
|
335
375
|
except Exception:
|
|
376
|
+
logger.exception("Failed to mount file tree for %s", instance_id)
|
|
336
377
|
# If something fails, try to restore output directly
|
|
337
378
|
try:
|
|
338
379
|
panel.mount(output)
|
|
@@ -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
|
|
File without changes
|
{voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/cursor.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/registry.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
|
|
File without changes
|
|
File without changes
|
{voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/voice_providers/__init__.py
RENAMED
|
File without changes
|
{voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/voice_providers/openai.py
RENAMED
|
File without changes
|
|
File without changes
|