Coverage for src / ai_shell / tmux.py: 97%
71 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 22:12 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 22:12 +0000
1"""Tmux session builder for multi-pane Claude Code sessions.
3All public functions are pure -- they return command argument lists without
4executing anything. The caller (``tools.py``) is responsible for running
5them via ``subprocess.run`` / ``os.execvp``.
6"""
8from __future__ import annotations
10import shlex
11from dataclasses import dataclass
13from ai_shell.defaults import uv_venv_path
15# ── Data ─────────────────────────────────────────────────────────────
17TMUX_SESSION_PREFIX = "claude-multi"
20@dataclass
21class PaneSpec:
22 """Specification for a single tmux pane."""
24 name: str # Display name (pane title)
25 command: str # Shell command to run in the pane
26 working_dir: str # Container-side absolute path
29# ── Command builders ─────────────────────────────────────────────────
32def _build_dep_sync_prefix() -> str:
33 """Build shell commands to sync project dependencies before launching Claude.
35 Handles both Python (uv) and Node.js (npm) projects. Runs in the pane's
36 working directory (set by tmux ``-c``), so it detects the project type from
37 the files present there.
39 Output is intentionally terse -- ``tail -3`` keeps only the summary line(s).
40 """
41 return (
42 "if [ -f pyproject.toml ]; then uv sync 2>&1 | tail -3; fi; "
43 "if [ -f package-lock.json ]; then npm ci --loglevel=warn 2>&1 | tail -3; "
44 "elif [ -f package.json ]; then npm install --loglevel=warn 2>&1 | tail -3; fi; "
45 )
48def build_claude_pane_command(
49 *,
50 repo_name: str,
51 safe: bool = False,
52 extra_args: tuple[str, ...] = (),
53 worktree_name: str | None = None,
54 sync_deps: bool = True,
55 mcp_config_path: str | None = None,
56 team_env: bool = False,
57) -> str:
58 """Build the claude invocation string for a single tmux pane.
60 Runs directly inside the container. Exports ``UV_PROJECT_ENVIRONMENT``
61 so each repo gets an isolated virtualenv within the shared UV cache volume.
62 Uses Claude Code's ``-n`` flag for session naming.
64 When *worktree_name* is set, appends ``-wt-{worktree_name}`` to the venv
65 path so each worktree gets its own isolated virtualenv.
67 When *sync_deps* is True (default), prepends ``uv sync`` / ``npm ci``
68 commands so the per-repo venv is initialised before Claude starts.
70 When *mcp_config_path* is set, inserts ``--mcp-config <path>`` into the
71 claude argument list so the pane gets access to the MCP server.
73 When *team_env* is True, exports
74 ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`` so this pane runs in Agent
75 Teams mode.
77 In non-safe mode, tries ``claude -c`` (continue previous conversation)
78 first; falls back to a fresh session if that fails (e.g. no prior
79 conversation exists).
80 """
81 uv_env = f"UV_PROJECT_ENVIRONMENT={uv_venv_path(repo_name, worktree_name)}"
83 env_exports = f"export {uv_env};"
84 if team_env:
85 env_exports += " export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1;"
87 dep_prefix = _build_dep_sync_prefix() if sync_deps else ""
89 mcp_args: list[str] = []
90 if mcp_config_path:
91 mcp_args = ["--mcp-config", mcp_config_path]
93 if safe:
94 parts: list[str] = ["claude", *mcp_args, "-n", repo_name]
95 if extra_args:
96 parts.append("--")
97 parts.extend(extra_args)
98 cmd = " ".join(shlex.quote(p) for p in parts)
99 return f"{env_exports} {dep_prefix}{cmd}"
101 # Non-safe: build two commands -- continue attempt and fresh fallback
102 base_parts: list[str] = [
103 "claude",
104 "--dangerously-skip-permissions",
105 *mcp_args,
106 "-n",
107 repo_name,
108 ]
109 continue_parts: list[str] = [
110 "claude",
111 "--dangerously-skip-permissions",
112 "-c",
113 *mcp_args,
114 "-n",
115 repo_name,
116 ]
118 extra_suffix = ""
119 if extra_args:
120 extra_suffix = " -- " + " ".join(shlex.quote(a) for a in extra_args)
122 continue_cmd = " ".join(shlex.quote(p) for p in continue_parts) + extra_suffix
123 fresh_cmd = " ".join(shlex.quote(p) for p in base_parts) + extra_suffix
125 return f"{env_exports} {dep_prefix}{continue_cmd} || {fresh_cmd}"
128def build_check_session_command(container_name: str, session_name: str) -> list[str]:
129 """Build a command to check whether a tmux session exists in a container.
131 Returns an arg list for ``subprocess.run()``. Exit code 0 means the
132 session exists; non-zero means it does not.
133 """
134 return ["docker", "exec", container_name, "tmux", "has-session", "-t", session_name]
137def build_attach_command(container_name: str, session_name: str) -> list[str]:
138 """Build an interactive ``docker exec`` command to attach to a tmux session.
140 Intended to be the final command the host process runs (via
141 ``subprocess.run`` or ``os.execvp``).
142 """
143 return [
144 "docker",
145 "exec",
146 "-it",
147 container_name,
148 "tmux",
149 "attach-session",
150 "-t",
151 session_name,
152 ]
155def select_layout(pane_count: int) -> str:
156 """Return the tmux layout name for the given pane count.
158 2 panes: even-vertical (top / bottom)
159 3 panes: main-horizontal (1 large top, 2 split bottom -- first pane ~65%)
160 4 panes: tiled (even quarters)
161 """
162 layouts = {
163 2: "even-vertical",
164 3: "main-horizontal",
165 4: "tiled",
166 }
167 return layouts.get(pane_count, "tiled")
170def build_tmux_commands(
171 container_name: str,
172 session_name: str,
173 panes: list[PaneSpec],
174) -> list[list[str]]:
175 """Build the sequence of ``docker exec`` commands for the tmux session.
177 Every command except the **last** is non-interactive (no ``-it``).
178 The last command is an interactive ``docker exec -it ... tmux attach``
179 intended to be executed via ``os.execvp`` to replace the current process.
181 Returns a list of argument lists suitable for ``subprocess.run()``.
182 """
183 if not panes:
184 return []
186 cmds: list[list[str]] = []
188 def _exec(*args: str) -> list[str]:
189 return ["docker", "exec", container_name, *args]
191 # 1. Kill stale session (ignore errors -- caller should use check=False)
192 cmds.append(_exec("tmux", "kill-session", "-t", session_name))
194 # 2. Create session with first pane
195 first = panes[0]
196 cmds.append(_exec("tmux", "new-session", "-d", "-s", session_name, "-c", first.working_dir))
198 # 3. Split additional panes
199 for pane in panes[1:]:
200 cmds.append(
201 _exec("tmux", "split-window", "-t", f"{session_name}:0", "-c", pane.working_dir)
202 )
204 # 4. Apply layout
205 layout = select_layout(len(panes))
206 cmds.append(_exec("tmux", "select-layout", "-t", f"{session_name}:0", layout))
208 # 4b. For 3-pane main-horizontal: give the first pane ~65% of height
209 if len(panes) == 3:
210 cmds.append(_exec("tmux", "set-option", "-t", session_name, "main-pane-height", "65%"))
211 # Re-apply layout so the new main-pane-height takes effect
212 cmds.append(_exec("tmux", "select-layout", "-t", f"{session_name}:0", layout))
214 # 5. Set pane titles
215 for i, pane in enumerate(panes):
216 cmds.append(_exec("tmux", "select-pane", "-t", f"{session_name}:0.{i}", "-T", pane.name))
218 # 6. Send commands to each pane
219 for i, pane in enumerate(panes):
220 cmds.append(
221 _exec("tmux", "send-keys", "-t", f"{session_name}:0.{i}", pane.command, "Enter")
222 )
224 # 7. Configure session options -- warm amber/mauve scheme
225 #
226 # Colour palette (designed to complement Claude Code's warm UI):
227 # colour172 (#d78700) = amber -> active pane, session name
228 # colour95 (#875f5f) = mauve -> inactive panes, help text
229 # colour235 (#262626) = dark bg -> status bar background
230 # colour248 (#a8a8a8) = light -> status bar text
231 #
232 # Three distinct visual bands: amber=active, mauve=inactive,
233 # gray=Claude Code UI. No pair blends.
234 session_options: list[tuple[str, str]] = [
235 # Mouse & responsiveness
236 ("mouse", "on"),
237 ("escape-time", "10"),
238 ("history-limit", "50000"),
239 ("focus-events", "on"),
240 # Pane borders: amber active, dusty mauve inactive, heavy lines
241 ("pane-border-status", "top"),
242 ("pane-border-lines", "heavy"),
243 (
244 "pane-border-format",
245 "#{?pane_active,#[fg=colour172 bold] #{pane_title} ,#[fg=colour95] #{pane_title} }",
246 ),
247 ("pane-border-style", "fg=colour95"),
248 ("pane-active-border-style", "fg=colour172,bold"),
249 ("pane-border-indicators", "arrows"),
250 # Status bar
251 ("status-style", "bg=colour235 fg=colour248"),
252 ("status-left", "#[fg=colour172,bold] #S #[fg=colour248]| "),
253 ("status-right", "#[fg=colour95] C-b z=zoom C-b d=detach "),
254 ("status-left-length", "40"),
255 ("status-right-length", "40"),
256 ]
257 for key, value in session_options:
258 cmds.append(_exec("tmux", "set-option", "-t", session_name, key, value))
260 # 8. Server-level terminal options for true-color support
261 cmds.append(_exec("tmux", "set-option", "-s", "default-terminal", "tmux-256color"))
262 cmds.append(_exec("tmux", "set-option", "-sa", "terminal-overrides", ",xterm*:Tc"))
264 # 9. Select first pane
265 cmds.append(_exec("tmux", "select-pane", "-t", f"{session_name}:0.0"))
267 # 10. Final: interactive attach (caller should execvp this one)
268 cmds.append(
269 ["docker", "exec", "-it", container_name, "tmux", "attach-session", "-t", session_name]
270 )
272 return cmds