tunacode-cli 0.0.71__py3-none-any.whl → 0.0.73__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

@@ -0,0 +1,394 @@
1
+ """Interactive model selector UI component."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from prompt_toolkit.application import Application
6
+ from prompt_toolkit.buffer import Buffer
7
+ from prompt_toolkit.formatted_text import HTML, StyleAndTextTuples
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+ from prompt_toolkit.layout import (
10
+ FormattedTextControl,
11
+ HSplit,
12
+ Layout,
13
+ VSplit,
14
+ Window,
15
+ WindowAlign,
16
+ )
17
+ from prompt_toolkit.layout.controls import BufferControl
18
+ from prompt_toolkit.layout.dimension import Dimension
19
+ from prompt_toolkit.search import SearchState
20
+ from prompt_toolkit.styles import Style
21
+ from prompt_toolkit.widgets import Frame
22
+
23
+ from ..utils.models_registry import ModelInfo, ModelsRegistry
24
+
25
+
26
+ class ModelSelector:
27
+ """Interactive model selector with search and navigation."""
28
+
29
+ def __init__(self, registry: ModelsRegistry):
30
+ """Initialize the model selector."""
31
+ self.registry = registry
32
+ self.models: List[ModelInfo] = []
33
+ self.filtered_models: List[ModelInfo] = []
34
+ self.selected_index = 0
35
+ self.search_text = ""
36
+ self.selected_model: Optional[ModelInfo] = None
37
+
38
+ # Create key bindings
39
+ self.kb = self._create_key_bindings()
40
+
41
+ # Create search buffer
42
+ self.search_buffer = Buffer(on_text_changed=self._on_search_changed)
43
+
44
+ # Search state
45
+ self.search_state = SearchState()
46
+
47
+ def _create_key_bindings(self) -> KeyBindings:
48
+ """Create key bindings for the selector."""
49
+ kb = KeyBindings()
50
+
51
+ @kb.add("up", "k")
52
+ def move_up(event):
53
+ """Move selection up."""
54
+ if self.selected_index > 0:
55
+ self.selected_index -= 1
56
+ self._update_display()
57
+
58
+ @kb.add("down", "j")
59
+ def move_down(event):
60
+ """Move selection down."""
61
+ if self.selected_index < len(self.filtered_models) - 1:
62
+ self.selected_index += 1
63
+ self._update_display()
64
+
65
+ @kb.add("pageup")
66
+ def page_up(event):
67
+ """Move selection up by page."""
68
+ self.selected_index = max(0, self.selected_index - 10)
69
+ self._update_display()
70
+
71
+ @kb.add("pagedown")
72
+ def page_down(event):
73
+ """Move selection down by page."""
74
+ self.selected_index = min(len(self.filtered_models) - 1, self.selected_index + 10)
75
+ self._update_display()
76
+
77
+ @kb.add("enter")
78
+ def select_model(event):
79
+ """Select the current model."""
80
+ if 0 <= self.selected_index < len(self.filtered_models):
81
+ self.selected_model = self.filtered_models[self.selected_index]
82
+ event.app.exit(result=self.selected_model)
83
+
84
+ @kb.add("c-c", "escape", "q")
85
+ def cancel(event):
86
+ """Cancel selection."""
87
+ event.app.exit(result=None)
88
+
89
+ @kb.add("/")
90
+ def focus_search(event):
91
+ """Focus the search input."""
92
+ event.app.layout.focus(self.search_buffer)
93
+
94
+ @kb.add("tab")
95
+ def next_provider(event):
96
+ """Jump to next provider."""
97
+ if not self.filtered_models:
98
+ return
99
+
100
+ current_provider = self.filtered_models[self.selected_index].provider
101
+ for i in range(self.selected_index + 1, len(self.filtered_models)):
102
+ if self.filtered_models[i].provider != current_provider:
103
+ self.selected_index = i
104
+ self._update_display()
105
+ break
106
+
107
+ @kb.add("s-tab")
108
+ def prev_provider(event):
109
+ """Jump to previous provider."""
110
+ if not self.filtered_models:
111
+ return
112
+
113
+ current_provider = self.filtered_models[self.selected_index].provider
114
+ for i in range(self.selected_index - 1, -1, -1):
115
+ if self.filtered_models[i].provider != current_provider:
116
+ self.selected_index = i
117
+ self._update_display()
118
+ break
119
+
120
+ return kb
121
+
122
+ def _on_search_changed(self, buffer: Buffer) -> None:
123
+ """Handle search text changes."""
124
+ self.search_text = buffer.text
125
+ self._filter_models()
126
+ self._update_display()
127
+
128
+ def _filter_models(self) -> None:
129
+ """Filter models based on search text."""
130
+ if not self.search_text:
131
+ self.filtered_models = self.models.copy()
132
+ else:
133
+ # Search and sort by relevance
134
+ self.filtered_models = self.registry.search_models(self.search_text)
135
+
136
+ # Reset selection
137
+ self.selected_index = 0 if self.filtered_models else -1
138
+
139
+ def _get_model_lines(self) -> List[StyleAndTextTuples]:
140
+ """Get formatted lines for model display."""
141
+ lines = []
142
+
143
+ if not self.filtered_models:
144
+ lines.append([("class:muted", "No models found")])
145
+ return lines
146
+
147
+ # Group models by provider
148
+ current_provider = None
149
+ for i, model in enumerate(self.filtered_models):
150
+ # Add provider header if changed
151
+ if model.provider != current_provider:
152
+ if current_provider is not None:
153
+ lines.append([]) # Empty line between providers
154
+
155
+ provider_info = self.registry.providers.get(model.provider)
156
+ provider_name = provider_info.name if provider_info else model.provider
157
+ lines.append([("class:provider", f"▼ {provider_name}")])
158
+ current_provider = model.provider
159
+
160
+ # Model line
161
+ is_selected = i == self.selected_index
162
+
163
+ # Build model display
164
+ parts = []
165
+
166
+ # Selection indicator
167
+ if is_selected:
168
+ parts.append(("class:selected", "→ "))
169
+ else:
170
+ parts.append(("", " "))
171
+
172
+ # Model ID and name
173
+ parts.append(
174
+ ("class:model-id" if not is_selected else "class:selected-id", f"{model.id}")
175
+ )
176
+ parts.append(("class:muted", " - "))
177
+ parts.append(
178
+ ("class:model-name" if not is_selected else "class:selected-name", model.name)
179
+ )
180
+
181
+ # Cost and limits
182
+ details = []
183
+ if model.cost.input is not None:
184
+ details.append(f"${model.cost.input}/{model.cost.output}")
185
+ if model.limits.context:
186
+ details.append(f"{model.limits.context // 1000}k")
187
+
188
+ if details:
189
+ parts.append(("class:muted", f" ({', '.join(details)})"))
190
+
191
+ # Capabilities badges
192
+ badges = []
193
+ if model.capabilities.attachment:
194
+ badges.append("📎")
195
+ if model.capabilities.reasoning:
196
+ badges.append("🧠")
197
+ if model.capabilities.tool_call:
198
+ badges.append("🔧")
199
+
200
+ if badges:
201
+ parts.append(("class:badges", " " + "".join(badges)))
202
+
203
+ lines.append(parts)
204
+
205
+ return lines
206
+
207
+ def _get_details_panel(self) -> StyleAndTextTuples:
208
+ """Get the details panel content for selected model."""
209
+ if not self.filtered_models or self.selected_index < 0:
210
+ return [("", "Select a model to see details")]
211
+
212
+ model = self.filtered_models[self.selected_index]
213
+ lines = []
214
+
215
+ # Model name and ID
216
+ lines.append([("class:title", model.name)])
217
+ lines.append([("class:muted", f"{model.full_id}")])
218
+ lines.append([])
219
+
220
+ # Pricing
221
+ lines.append([("class:section", "Pricing:")])
222
+ if model.cost.input is not None:
223
+ lines.append([("", f" Input: ${model.cost.input} per 1M tokens")])
224
+ lines.append([("", f" Output: ${model.cost.output} per 1M tokens")])
225
+ else:
226
+ lines.append([("class:muted", " Not available")])
227
+ lines.append([])
228
+
229
+ # Limits
230
+ lines.append([("class:section", "Limits:")])
231
+ if model.limits.context:
232
+ lines.append([("", f" Context: {model.limits.context:,} tokens")])
233
+ if model.limits.output:
234
+ lines.append([("", f" Output: {model.limits.output:,} tokens")])
235
+ if not model.limits.context and not model.limits.output:
236
+ lines.append([("class:muted", " Not specified")])
237
+ lines.append([])
238
+
239
+ # Capabilities
240
+ lines.append([("class:section", "Capabilities:")])
241
+ caps = []
242
+ if model.capabilities.attachment:
243
+ caps.append("Attachments")
244
+ if model.capabilities.reasoning:
245
+ caps.append("Reasoning")
246
+ if model.capabilities.tool_call:
247
+ caps.append("Tool calling")
248
+ if model.capabilities.temperature:
249
+ caps.append("Temperature control")
250
+
251
+ if caps:
252
+ for cap in caps:
253
+ lines.append([("", f" ✓ {cap}")])
254
+ else:
255
+ lines.append([("class:muted", " Basic text generation")])
256
+
257
+ if model.capabilities.knowledge:
258
+ lines.append([])
259
+ lines.append([("class:section", "Knowledge cutoff:")])
260
+ lines.append([("", f" {model.capabilities.knowledge}")])
261
+
262
+ # Modalities
263
+ if model.modalities:
264
+ lines.append([])
265
+ lines.append([("class:section", "Modalities:")])
266
+ if "input" in model.modalities:
267
+ lines.append([("", f" Input: {', '.join(model.modalities['input'])}")])
268
+ if "output" in model.modalities:
269
+ lines.append([("", f" Output: {', '.join(model.modalities['output'])}")])
270
+
271
+ return lines
272
+
273
+ def _update_display(self) -> None:
274
+ """Update the display (called on changes)."""
275
+ # This will trigger a redraw through prompt_toolkit's event system
276
+ if hasattr(self, "app"):
277
+ self.app.invalidate()
278
+
279
+ def _create_layout(self) -> Layout:
280
+ """Create the application layout."""
281
+ # Model list
282
+ model_list = FormattedTextControl(self._get_model_lines, focusable=False, show_cursor=False)
283
+
284
+ model_window = Window(
285
+ content=model_list,
286
+ width=Dimension(min=40, preferred=60),
287
+ height=Dimension(min=10, preferred=20),
288
+ scroll_offsets=True,
289
+ wrap_lines=False,
290
+ )
291
+
292
+ # Details panel
293
+ details_control = FormattedTextControl(
294
+ self._get_details_panel, focusable=False, show_cursor=False
295
+ )
296
+
297
+ details_window = Window(
298
+ content=details_control, width=Dimension(min=30, preferred=40), wrap_lines=True
299
+ )
300
+
301
+ # Search bar
302
+ search_field = Window(
303
+ BufferControl(buffer=self.search_buffer, focus_on_click=True), height=1
304
+ )
305
+
306
+ search_label = Window(
307
+ FormattedTextControl(HTML("<b>Search:</b> ")), width=8, height=1, dont_extend_width=True
308
+ )
309
+
310
+ search_bar = VSplit([search_label, search_field])
311
+
312
+ # Help text
313
+ help_text = Window(
314
+ FormattedTextControl(
315
+ HTML(
316
+ "<muted>↑↓: Navigate | Enter: Select | /: Search | Tab: Next provider | Esc: Cancel</muted>"
317
+ )
318
+ ),
319
+ height=1,
320
+ align=WindowAlign.CENTER,
321
+ )
322
+
323
+ # Main content
324
+ content = VSplit(
325
+ [Frame(model_window, title="Select Model"), Frame(details_window, title="Details")]
326
+ )
327
+
328
+ # Root layout
329
+ root = HSplit(
330
+ [
331
+ search_bar,
332
+ Window(height=1), # Spacer
333
+ content,
334
+ Window(height=1), # Spacer
335
+ help_text,
336
+ ]
337
+ )
338
+
339
+ return Layout(root)
340
+
341
+ async def select_model(self, initial_query: str = "") -> Optional[ModelInfo]:
342
+ """Show the model selector and return selected model."""
343
+ # Load all models
344
+ self.models = list(self.registry.models.values())
345
+ self.search_buffer.text = initial_query
346
+
347
+ # Filter initially
348
+ self._filter_models()
349
+
350
+ # Create application
351
+ self.app = Application(
352
+ layout=self._create_layout(),
353
+ key_bindings=self.kb,
354
+ mouse_support=True,
355
+ full_screen=False,
356
+ style=self._get_style(),
357
+ )
358
+
359
+ # Run the selector
360
+ result = await self.app.run_async()
361
+ return result
362
+
363
+ def _get_style(self) -> Style:
364
+ """Get the style for the selector."""
365
+ return Style.from_dict(
366
+ {
367
+ "provider": "bold cyan",
368
+ "model-id": "white",
369
+ "model-name": "ansiwhite",
370
+ "selected": "reverse bold",
371
+ "selected-id": "reverse bold white",
372
+ "selected-name": "reverse bold ansiwhite",
373
+ "muted": "gray",
374
+ "badges": "yellow",
375
+ "title": "bold ansiwhite",
376
+ "section": "bold cyan",
377
+ }
378
+ )
379
+
380
+
381
+ async def select_model_interactive(
382
+ registry: Optional[ModelsRegistry] = None, initial_query: str = ""
383
+ ) -> Optional[str]:
384
+ """Show interactive model selector and return selected model ID."""
385
+ if registry is None:
386
+ registry = ModelsRegistry()
387
+ await registry.load()
388
+
389
+ selector = ModelSelector(registry)
390
+ model = await selector.select_model(initial_query)
391
+
392
+ if model:
393
+ return model.full_id
394
+ return None