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
« 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``.
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"""
8from __future__ import annotations
10import enum
11import uuid
12from collections.abc import Callable
13from dataclasses import dataclass, field
14from typing import TYPE_CHECKING, Any
16import click
17from rich.console import Console
19if TYPE_CHECKING:
20 from ai_shell.tmux import PaneSpec
23# ── Data model ───────────────────────────────────────────────────────
26class PaneType(enum.Enum):
27 """Types of panes available in interactive mode."""
29 THIS_PROJECT = "this_project"
30 BASH = "bash"
31 WORKSPACE_REPO = "workspace_repo"
34@dataclass
35class PaneChoice:
36 """A user's selection for a single window."""
38 pane_type: PaneType
39 repo_name: str = ""
40 repo_path: str = ""
43@dataclass
44class InteractiveConfig:
45 """Collected results from the interactive wizard."""
47 pane_choices: list[PaneChoice] = field(default_factory=list)
48 team_mode: bool = False
49 shared_chrome: bool = False
51 @property
52 def pane_count(self) -> int:
53 """Return the number of panes requested by the user."""
54 return len(self.pane_choices)
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 )
65# ── Option builder ───────────────────────────────────────────────────
68@dataclass
69class _PaneOption:
70 """A single numbered option in the per-window menu."""
72 label: str
73 pane_type: PaneType
74 repo_name: str = ""
75 repo_path: str = ""
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
110# ── Wizard ───────────────────────────────────────────────────────────
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.
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)
131 options = _build_pane_options(project_name, workspace_repos)
132 if default_windows is None:
133 default_windows = min_windows
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 )
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}")
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 )
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?"
181 team_mode = click.confirm(team_prompt, default=False)
182 shared_chrome = click.confirm(chrome_prompt, default=default_shared_chrome)
184 except (EOFError, KeyboardInterrupt, click.Abort):
185 return None
187 return InteractiveConfig(
188 pane_choices=choices,
189 team_mode=team_mode,
190 shared_chrome=shared_chrome,
191 )
194# ── Pane builder ─────────────────────────────────────────────────────
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.
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
218 panes: list[PaneSpec] = []
219 team_assigned = False
220 bash_counter = 0
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 )
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 )
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 )
276 return panes