Coverage for src/ai_shell/interactive.py: 98%

102 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-05 22:06 +0000

1"""Interactive launcher wizard for ``ai-shell claude -i``. 

2 

3Walks the user through a guided menu to configure each pane, then converts the 

4collected choices into :class:`~ai_shell.tmux.PaneSpec` objects when a tmux 

5session is needed. 

6""" 

7 

8from __future__ import annotations 

9 

10import enum 

11import uuid 

12from collections.abc import Callable 

13from dataclasses import dataclass, field 

14from typing import TYPE_CHECKING, Any 

15 

16import click 

17from rich.console import Console 

18 

19if TYPE_CHECKING: 

20 from ai_shell.tmux import PaneSpec 

21 

22 

23# ── Data model ─────────────────────────────────────────────────────── 

24 

25 

26class PaneType(enum.Enum): 

27 """Types of panes available in interactive mode.""" 

28 

29 THIS_PROJECT = "this_project" 

30 BASH = "bash" 

31 WORKSPACE_REPO = "workspace_repo" 

32 

33 

34@dataclass 

35class PaneChoice: 

36 """A user's selection for a single window.""" 

37 

38 pane_type: PaneType 

39 repo_name: str = "" 

40 repo_path: str = "" 

41 

42 

43@dataclass 

44class InteractiveConfig: 

45 """Collected results from the interactive wizard.""" 

46 

47 pane_choices: list[PaneChoice] = field(default_factory=list) 

48 team_mode: bool = False 

49 shared_chrome: bool = False 

50 

51 @property 

52 def pane_count(self) -> int: 

53 """Return the number of panes requested by the user.""" 

54 return len(self.pane_choices) 

55 

56 @property 

57 def has_claude_panes(self) -> bool: 

58 """Return ``True`` when at least one pane launches Claude.""" 

59 return any( 

60 choice.pane_type in (PaneType.THIS_PROJECT, PaneType.WORKSPACE_REPO) 

61 for choice in self.pane_choices 

62 ) 

63 

64 

65# ── Option builder ─────────────────────────────────────────────────── 

66 

67 

68@dataclass 

69class _PaneOption: 

70 """A single numbered option in the per-window menu.""" 

71 

72 label: str 

73 pane_type: PaneType 

74 repo_name: str = "" 

75 repo_path: str = "" 

76 

77 

78def _build_pane_options( 

79 project_name: str, 

80 workspace_repos: list[dict[str, Any]] | None, 

81) -> list[_PaneOption]: 

82 """Build the numbered option list for per-window type selection.""" 

83 options: list[_PaneOption] = [ 

84 _PaneOption( 

85 label=f"This project ({project_name}) - Claude session", 

86 pane_type=PaneType.THIS_PROJECT, 

87 ), 

88 _PaneOption( 

89 label="Bash shell", 

90 pane_type=PaneType.BASH, 

91 ), 

92 ] 

93 if workspace_repos: 

94 for repo in workspace_repos: 

95 name = repo["name"] 

96 path = repo.get("path", f"./{name}") 

97 repo_type = repo.get("repo_type", "") 

98 label = f"{name} ({repo_type})" if repo_type else name 

99 options.append( 

100 _PaneOption( 

101 label=label, 

102 pane_type=PaneType.WORKSPACE_REPO, 

103 repo_name=name, 

104 repo_path=path, 

105 ) 

106 ) 

107 return options 

108 

109 

110# ── Wizard ─────────────────────────────────────────────────────────── 

111 

112 

113def run_interactive_wizard( 

114 *, 

115 project_name: str, 

116 workspace_repos: list[dict[str, Any]] | None = None, 

117 console: Console | None = None, 

118 min_windows: int = 1, 

119 max_windows: int = 4, 

120 default_windows: int | None = None, 

121 default_shared_chrome: bool = False, 

122) -> InteractiveConfig | None: 

123 """Walk the user through the interactive launcher flow. 

124 

125 Returns :class:`InteractiveConfig` with all choices, or ``None`` if the 

126 user cancels (Ctrl-C / EOFError). 

127 """ 

128 if console is None: 

129 console = Console(stderr=True) 

130 

131 options = _build_pane_options(project_name, workspace_repos) 

132 if default_windows is None: 

133 default_windows = min_windows 

134 

135 try: 

136 # Step 1: number of windows 

137 num_windows: int = click.prompt( 

138 "How many windows?", 

139 type=click.IntRange(min_windows, max_windows), 

140 default=default_windows, 

141 ) 

142 

143 # Step 2: per-window type 

144 choices: list[PaneChoice] = [] 

145 for win_idx in range(1, num_windows + 1): 

146 console.print() 

147 console.print(f" [bold]Window {win_idx}:[/bold]") 

148 for i, opt in enumerate(options, 1): 

149 console.print(f" {i}. {opt.label}") 

150 

151 selection: int = click.prompt( 

152 f" Select type for window {win_idx}", 

153 type=click.IntRange(1, len(options)), 

154 default=1, 

155 ) 

156 chosen = options[selection - 1] 

157 choices.append( 

158 PaneChoice( 

159 pane_type=chosen.pane_type, 

160 repo_name=chosen.repo_name, 

161 repo_path=chosen.repo_path, 

162 ) 

163 ) 

164 

165 # Step 3: pre-launch options 

166 team_mode = False 

167 shared_chrome = False 

168 has_claude_panes = any( 

169 choice.pane_type in (PaneType.THIS_PROJECT, PaneType.WORKSPACE_REPO) 

170 for choice in choices 

171 ) 

172 if has_claude_panes: 

173 console.print() 

174 if num_windows == 1: 

175 team_prompt = "Enable teams mode for this Claude pane?" 

176 chrome_prompt = "Enable Chrome browser for this Claude pane?" 

177 else: 

178 team_prompt = "Enable teams mode on the primary Claude pane?" 

179 chrome_prompt = "Enable shared Chrome browser for all Claude panes?" 

180 

181 team_mode = click.confirm(team_prompt, default=False) 

182 shared_chrome = click.confirm(chrome_prompt, default=default_shared_chrome) 

183 

184 except (EOFError, KeyboardInterrupt, click.Abort): 

185 return None 

186 

187 return InteractiveConfig( 

188 pane_choices=choices, 

189 team_mode=team_mode, 

190 shared_chrome=shared_chrome, 

191 ) 

192 

193 

194# ── Pane builder ───────────────────────────────────────────────────── 

195 

196 

197def build_interactive_panes( 

198 *, 

199 config: InteractiveConfig, 

200 project_name: str, 

201 container_name: str, 

202 container_project_root: str, 

203 safe: bool, 

204 extra_args: tuple[str, ...], 

205 mcp_config_path: str | None = None, 

206 setup_worktree_fn: Callable[[str, str, str], str], 

207) -> list[PaneSpec]: 

208 """Convert :class:`InteractiveConfig` into pane specs for tmux. 

209 

210 Parameters 

211 ---------- 

212 setup_worktree_fn 

213 ``(container_name, project_dir, worktree_name) -> worktree_abs_path``. 

214 In production this is ``tools._setup_worktree``; in tests, a mock. 

215 """ 

216 from ai_shell.tmux import PaneSpec, build_claude_pane_command 

217 

218 panes: list[PaneSpec] = [] 

219 team_assigned = False 

220 bash_counter = 0 

221 

222 for choice in config.pane_choices: 

223 if choice.pane_type == PaneType.THIS_PROJECT: 

224 wt_name = uuid.uuid4().hex[:8] 

225 worktree_dir = setup_worktree_fn(container_name, container_project_root, wt_name) 

226 use_team = config.team_mode and not team_assigned 

227 pane_cmd = build_claude_pane_command( 

228 repo_name=project_name, 

229 safe=safe, 

230 extra_args=extra_args, 

231 worktree_name=wt_name, 

232 mcp_config_path=mcp_config_path if config.shared_chrome else None, 

233 team_env=use_team, 

234 ) 

235 if use_team: 

236 team_assigned = True 

237 panes.append( 

238 PaneSpec( 

239 name=f"{project_name}-wt-{wt_name}", 

240 command=pane_cmd, 

241 working_dir=worktree_dir, 

242 ) 

243 ) 

244 

245 elif choice.pane_type == PaneType.BASH: 

246 bash_counter += 1 

247 panes.append( 

248 PaneSpec( 

249 name=f"bash-{bash_counter}", 

250 command="/bin/bash", 

251 working_dir=container_project_root, 

252 ) 

253 ) 

254 

255 elif choice.pane_type == PaneType.WORKSPACE_REPO: 

256 rel = choice.repo_path.lstrip("./") 

257 working_dir = f"{container_project_root}/{rel}" 

258 use_team = config.team_mode and not team_assigned 

259 pane_cmd = build_claude_pane_command( 

260 repo_name=choice.repo_name, 

261 safe=safe, 

262 extra_args=extra_args, 

263 mcp_config_path=mcp_config_path if config.shared_chrome else None, 

264 team_env=use_team, 

265 ) 

266 if use_team: 

267 team_assigned = True 

268 panes.append( 

269 PaneSpec( 

270 name=choice.repo_name, 

271 command=pane_cmd, 

272 working_dir=working_dir, 

273 ) 

274 ) 

275 

276 return panes