Coverage for src / ai_shell / selector.py: 48%
124 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"""Interactive terminal multi-select widget.
3Uses curses on Unix/WSL. Falls back to a Rich numbered-prompt selector on
4Windows or anywhere ``_curses`` is unavailable.
5"""
7from __future__ import annotations
9import sys
10from dataclasses import dataclass
11from typing import TYPE_CHECKING
13import click
15if TYPE_CHECKING:
16 import curses as _curses_mod
18try:
19 import curses
21 _CURSES_AVAILABLE = True
22except ImportError:
23 curses = None # type: ignore[assignment]
24 _CURSES_AVAILABLE = False
26MAX_SELECTIONS = 4
29@dataclass
30class SelectionItem:
31 """An item in the multi-select menu."""
33 label: str
34 value: str # e.g. repo path relative to CWD ("./woxom-crm") or "."
35 description: str = ""
38def interactive_multi_select(
39 items: list[SelectionItem],
40 *,
41 title: str = "Select repos (up to 4)",
42 max_selections: int = MAX_SELECTIONS,
43) -> list[SelectionItem]:
44 """Show a multi-select menu and return chosen items.
46 Uses curses when available (Linux/macOS/WSL), otherwise falls back to a
47 Rich numbered-prompt selector (Windows).
49 Raises ``click.ClickException`` when stdin is not a TTY.
50 Returns an empty list if the user cancels (q / Ctrl-C).
51 """
52 if not sys.stdin.isatty():
53 raise click.ClickException("--multi requires an interactive terminal (TTY).")
55 if _CURSES_AVAILABLE:
56 selected_indices = curses.wrapper(_curses_main, items, title, max_selections)
57 return [items[i] for i in sorted(selected_indices)]
59 return _rich_multi_select(items, title=title, max_selections=max_selections)
62# ── Rich fallback (Windows) ───────────────────────────────────────────
65def _rich_multi_select(
66 items: list[SelectionItem],
67 *,
68 title: str,
69 max_selections: int,
70) -> list[SelectionItem]:
71 """Numbered-prompt selector using Rich. Works on all platforms."""
72 from rich.console import Console
74 console = Console()
75 console.print()
76 console.print(f" [bold]{title}[/bold]")
77 console.print()
78 for i, item in enumerate(items, 1):
79 desc = f" [dim]({item.description})[/dim]" if item.description else ""
80 console.print(f" {i}. {item.label}{desc}")
81 console.print()
83 while True:
84 try:
85 raw = console.input(
86 f" [dim]Enter numbers separated by commas (max {max_selections}), "
87 "or 'q' to cancel:[/dim] "
88 )
89 except (EOFError, KeyboardInterrupt):
90 return []
92 raw = raw.strip()
93 if not raw or raw.lower() == "q":
94 return []
96 parts = [p.strip() for p in raw.split(",") if p.strip()]
97 indices: list[int] = []
98 valid = True
99 for part in parts:
100 if not part.isdigit():
101 console.print(f" [red]'{part}' is not a number.[/red]")
102 valid = False
103 break
104 num = int(part)
105 if num < 1 or num > len(items):
106 console.print(f" [red]{num} is out of range (1-{len(items)}).[/red]")
107 valid = False
108 break
109 idx = num - 1
110 if idx not in indices:
111 indices.append(idx)
113 if not valid:
114 continue
115 if len(indices) > max_selections:
116 console.print(f" [red]Max {max_selections} selections.[/red]")
117 continue
118 if not indices:
119 continue
121 return [items[i] for i in sorted(indices)]
124# ── Curses interactive selector (Unix/WSL) ─────────────────────────────
127def _curses_main(
128 stdscr: _curses_mod.window,
129 items: list[SelectionItem],
130 title: str,
131 max_selections: int,
132) -> set[int]:
133 """Curses inner loop. Returns set of selected indices."""
134 curses.curs_set(0) # hide cursor
135 stdscr.clear()
137 cursor = 0
138 selected: set[int] = set()
139 flash_msg = ""
140 flash_countdown = 0
142 while True:
143 stdscr.erase()
144 h, w = stdscr.getmaxyx()
146 # Title
147 _safe_addstr(stdscr, 0, 2, title, curses.A_BOLD, w)
149 # Items
150 for i, item in enumerate(items):
151 y = i + 2
152 if y >= h - 2:
153 break
155 check = "[x]" if i in selected else "[ ]"
156 pointer = ">" if i == cursor else " "
157 line = f" {pointer} {check} {item.label}"
158 if item.description:
159 line += f" ({item.description})"
161 attr = curses.A_REVERSE if i == cursor else 0
162 if i in selected and i != cursor:
163 attr = curses.A_BOLD
164 _safe_addstr(stdscr, y, 0, line, attr, w)
166 # Status line
167 status_y = min(len(items) + 3, h - 2)
168 status = f" {len(selected)}/{max_selections} selected"
169 _safe_addstr(stdscr, status_y, 0, status, 0, w)
171 # Help line
172 help_text = " space=toggle enter=confirm q=cancel"
173 _safe_addstr(stdscr, status_y + 1, 0, help_text, curses.A_DIM, w)
175 # Flash message (e.g. "Max selections reached")
176 if flash_countdown > 0:
177 _safe_addstr(stdscr, status_y + 2, 2, flash_msg, curses.A_BOLD, w)
178 flash_countdown -= 1
180 stdscr.refresh()
182 key = stdscr.getch()
184 if key == curses.KEY_UP and cursor > 0:
185 cursor -= 1
186 elif key == curses.KEY_DOWN and cursor < len(items) - 1:
187 cursor += 1
188 elif key == ord(" "):
189 if cursor in selected:
190 selected.discard(cursor)
191 elif len(selected) < max_selections:
192 selected.add(cursor)
193 else:
194 flash_msg = f"Max {max_selections} selections"
195 flash_countdown = 3
196 elif key in (curses.KEY_ENTER, 10, 13):
197 return selected
198 elif key in (ord("q"), ord("Q"), 27, 3): # q, Q, Esc, Ctrl-C
199 return set()
201 return selected # unreachable, satisfies type checker
204def _safe_addstr(
205 stdscr: _curses_mod.window,
206 y: int,
207 x: int,
208 text: str,
209 attr: int,
210 max_width: int,
211) -> None:
212 """Write text to curses window, truncating to fit and ignoring overflow errors."""
213 h, _ = stdscr.getmaxyx()
214 if y >= h or y < 0:
215 return
216 truncated = text[: max_width - x - 1] if len(text) + x >= max_width else text
217 try:
218 stdscr.addstr(y, x, truncated, attr)
219 except curses.error:
220 pass # writing to bottom-right corner raises in some terminals