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

1"""Tmux session builder for multi-pane Claude Code sessions. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import shlex 

11from dataclasses import dataclass 

12 

13from ai_shell.defaults import uv_venv_path 

14 

15# ── Data ───────────────────────────────────────────────────────────── 

16 

17TMUX_SESSION_PREFIX = "claude-multi" 

18 

19 

20@dataclass 

21class PaneSpec: 

22 """Specification for a single tmux pane.""" 

23 

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 

27 

28 

29# ── Command builders ───────────────────────────────────────────────── 

30 

31 

32def _build_dep_sync_prefix() -> str: 

33 """Build shell commands to sync project dependencies before launching Claude. 

34 

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. 

38 

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 ) 

46 

47 

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. 

59 

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. 

63 

64 When *worktree_name* is set, appends ``-wt-{worktree_name}`` to the venv 

65 path so each worktree gets its own isolated virtualenv. 

66 

67 When *sync_deps* is True (default), prepends ``uv sync`` / ``npm ci`` 

68 commands so the per-repo venv is initialised before Claude starts. 

69 

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. 

72 

73 When *team_env* is True, exports 

74 ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`` so this pane runs in Agent 

75 Teams mode. 

76 

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)}" 

82 

83 env_exports = f"export {uv_env};" 

84 if team_env: 

85 env_exports += " export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1;" 

86 

87 dep_prefix = _build_dep_sync_prefix() if sync_deps else "" 

88 

89 mcp_args: list[str] = [] 

90 if mcp_config_path: 

91 mcp_args = ["--mcp-config", mcp_config_path] 

92 

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}" 

100 

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 ] 

117 

118 extra_suffix = "" 

119 if extra_args: 

120 extra_suffix = " -- " + " ".join(shlex.quote(a) for a in extra_args) 

121 

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 

124 

125 return f"{env_exports} {dep_prefix}{continue_cmd} || {fresh_cmd}" 

126 

127 

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. 

130 

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] 

135 

136 

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. 

139 

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 ] 

153 

154 

155def select_layout(pane_count: int) -> str: 

156 """Return the tmux layout name for the given pane count. 

157 

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") 

168 

169 

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. 

176 

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. 

180 

181 Returns a list of argument lists suitable for ``subprocess.run()``. 

182 """ 

183 if not panes: 

184 return [] 

185 

186 cmds: list[list[str]] = [] 

187 

188 def _exec(*args: str) -> list[str]: 

189 return ["docker", "exec", container_name, *args] 

190 

191 # 1. Kill stale session (ignore errors -- caller should use check=False) 

192 cmds.append(_exec("tmux", "kill-session", "-t", session_name)) 

193 

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)) 

197 

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 ) 

203 

204 # 4. Apply layout 

205 layout = select_layout(len(panes)) 

206 cmds.append(_exec("tmux", "select-layout", "-t", f"{session_name}:0", layout)) 

207 

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)) 

213 

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)) 

217 

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 ) 

223 

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)) 

259 

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")) 

263 

264 # 9. Select first pane 

265 cmds.append(_exec("tmux", "select-pane", "-t", f"{session_name}:0.0")) 

266 

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 ) 

271 

272 return cmds