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.
Files changed (42) hide show
  1. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/CHANGELOG.md +16 -0
  2. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/PKG-INFO +1 -1
  3. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/pyproject.toml +1 -1
  4. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/claude.py +54 -46
  6. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/instances.py +8 -4
  7. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/session.py +4 -0
  8. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/agent_task.py +53 -38
  9. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/file_tree.py +30 -44
  10. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/handlers.py +2 -1
  11. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/panels.py +73 -32
  12. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/screen.py +12 -7
  13. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/.github/workflows/ci.yml +0 -0
  14. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/.github/workflows/release.yml +0 -0
  15. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/.gitignore +0 -0
  16. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/LICENSE +0 -0
  17. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/README.md +0 -0
  18. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/app.py +0 -0
  19. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/index.html +0 -0
  20. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/app_config.py +0 -0
  21. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/audio.py +0 -0
  22. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/cli_serve.py +0 -0
  23. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  24. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  25. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  26. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/config.py +0 -0
  27. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/server.py +0 -0
  28. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  29. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  30. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
  31. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  32. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  33. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  34. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  35. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/setup.py +0 -0
  36. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/state.py +0 -0
  37. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/styles.py +0 -0
  38. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  39. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  40. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  41. {voice_vibecoder-2.5.3 → voice_vibecoder-2.7.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  42. {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.5.3
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.5.3"
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"
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.5.3"
6
+ __version__ = "2.7.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -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
 
@@ -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
- # Start file tree updates for code instances (skip for sessions)
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=f"_session_{name}",
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[instance.branch] = instance.instance_id
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 only
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
- import os
41
- from voice_vibecoder.tools.dispatch import _repo_root
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
- servers: dict[str, dict] = {}
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
- # Load from .mcp.json in repo root
46
- if _repo_root:
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(mcp_json.read_text())
51
- servers.update(data.get("mcpServers", {}))
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 .mcp.json: %s", e)
70
+ logger.warning("Failed to read ~/.claude.json: %s", e)
54
71
 
55
- # Load from ~/.claude.json
56
- if _repo_root and _CLAUDE_JSON.exists():
57
- try:
58
- data = json.loads(_CLAUDE_JSON.read_text())
59
- project_key = str(Path(_repo_root))
60
- project_data = data.get("projects", {}).get(project_key, {})
61
- local_servers = project_data.get("mcpServers", {})
62
- servers.update(local_servers)
63
- except Exception as e:
64
- logger.warning("Failed to read ~/.claude.json: %s", e)
65
-
66
- # MCP_SERVERS_JSON takes precedence (for Azure Container Apps)
67
- mcp_env = os.environ.get("MCP_SERVERS_JSON")
68
- if mcp_env:
69
- try:
70
- env_servers = json.loads(mcp_env)
71
- servers.update(env_servers.get("mcpServers", env_servers))
72
- logger.info("Loaded MCP servers from MCP_SERVERS_JSON environment variable")
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
- _mcp_cache = servers
83
- return servers
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 _merge_base(worktree_path: str) -> str:
20
- """Get the merge-base commit between main and HEAD."""
21
- result = subprocess.run(
22
- ["git", "merge-base", "main", "HEAD"],
23
- cwd=worktree_path,
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
- # Get branch name
116
- branch_result = subprocess.run(
117
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
118
- cwd=worktree_path,
119
- capture_output=True,
120
- text=True,
121
- timeout=10,
122
- )
123
- root = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
124
-
125
- base = _merge_base(worktree_path)
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
- # Get modified/deleted files vs merge-base
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
- 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}
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
- 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:
@@ -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
- 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)
54
85
 
55
- return "\n".join(lines)
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
- entries = data.get("entries", [])
295
- if not entries:
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 sidebar
341
+ # Try to update existing tree widget
302
342
  try:
303
- sidebar = self.query_one(f"#{sidebar_id}", Static)
304
- sidebar.update(formatted)
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
- sidebar = Static(
319
- formatted,
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
- sidebar,
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.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