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

1"""Interactive terminal multi-select widget. 

2 

3Uses curses on Unix/WSL. Falls back to a Rich numbered-prompt selector on 

4Windows or anywhere ``_curses`` is unavailable. 

5""" 

6 

7from __future__ import annotations 

8 

9import sys 

10from dataclasses import dataclass 

11from typing import TYPE_CHECKING 

12 

13import click 

14 

15if TYPE_CHECKING: 

16 import curses as _curses_mod 

17 

18try: 

19 import curses 

20 

21 _CURSES_AVAILABLE = True 

22except ImportError: 

23 curses = None # type: ignore[assignment] 

24 _CURSES_AVAILABLE = False 

25 

26MAX_SELECTIONS = 4 

27 

28 

29@dataclass 

30class SelectionItem: 

31 """An item in the multi-select menu.""" 

32 

33 label: str 

34 value: str # e.g. repo path relative to CWD ("./woxom-crm") or "." 

35 description: str = "" 

36 

37 

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. 

45 

46 Uses curses when available (Linux/macOS/WSL), otherwise falls back to a 

47 Rich numbered-prompt selector (Windows). 

48 

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

54 

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

58 

59 return _rich_multi_select(items, title=title, max_selections=max_selections) 

60 

61 

62# ── Rich fallback (Windows) ─────────────────────────────────────────── 

63 

64 

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 

73 

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

82 

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 [] 

91 

92 raw = raw.strip() 

93 if not raw or raw.lower() == "q": 

94 return [] 

95 

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) 

112 

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 

120 

121 return [items[i] for i in sorted(indices)] 

122 

123 

124# ── Curses interactive selector (Unix/WSL) ───────────────────────────── 

125 

126 

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

136 

137 cursor = 0 

138 selected: set[int] = set() 

139 flash_msg = "" 

140 flash_countdown = 0 

141 

142 while True: 

143 stdscr.erase() 

144 h, w = stdscr.getmaxyx() 

145 

146 # Title 

147 _safe_addstr(stdscr, 0, 2, title, curses.A_BOLD, w) 

148 

149 # Items 

150 for i, item in enumerate(items): 

151 y = i + 2 

152 if y >= h - 2: 

153 break 

154 

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

160 

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) 

165 

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) 

170 

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) 

174 

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 

179 

180 stdscr.refresh() 

181 

182 key = stdscr.getch() 

183 

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

200 

201 return selected # unreachable, satisfies type checker 

202 

203 

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