comate-cli 0.1.0__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.
Files changed (44) hide show
  1. comate_cli/__init__.py +5 -0
  2. comate_cli/__main__.py +5 -0
  3. comate_cli/main.py +128 -0
  4. comate_cli/terminal_agent/__init__.py +2 -0
  5. comate_cli/terminal_agent/animations.py +283 -0
  6. comate_cli/terminal_agent/app.py +261 -0
  7. comate_cli/terminal_agent/assistant_render.py +243 -0
  8. comate_cli/terminal_agent/env_utils.py +37 -0
  9. comate_cli/terminal_agent/error_display.py +46 -0
  10. comate_cli/terminal_agent/event_renderer.py +867 -0
  11. comate_cli/terminal_agent/fragment_utils.py +25 -0
  12. comate_cli/terminal_agent/history_printer.py +150 -0
  13. comate_cli/terminal_agent/input_geometry.py +92 -0
  14. comate_cli/terminal_agent/layout_coordinator.py +188 -0
  15. comate_cli/terminal_agent/logging_adapter.py +147 -0
  16. comate_cli/terminal_agent/logo.py +58 -0
  17. comate_cli/terminal_agent/markdown_render.py +24 -0
  18. comate_cli/terminal_agent/mention_completer.py +293 -0
  19. comate_cli/terminal_agent/message_style.py +33 -0
  20. comate_cli/terminal_agent/models.py +89 -0
  21. comate_cli/terminal_agent/question_view.py +584 -0
  22. comate_cli/terminal_agent/rewind_store.py +712 -0
  23. comate_cli/terminal_agent/rpc_protocol.py +103 -0
  24. comate_cli/terminal_agent/rpc_stdio.py +280 -0
  25. comate_cli/terminal_agent/selection_menu.py +305 -0
  26. comate_cli/terminal_agent/session_view.py +99 -0
  27. comate_cli/terminal_agent/slash_commands.py +142 -0
  28. comate_cli/terminal_agent/startup.py +77 -0
  29. comate_cli/terminal_agent/status_bar.py +258 -0
  30. comate_cli/terminal_agent/text_effects.py +30 -0
  31. comate_cli/terminal_agent/tool_view.py +584 -0
  32. comate_cli/terminal_agent/tui.py +1006 -0
  33. comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
  34. comate_cli/terminal_agent/tui_parts/commands.py +759 -0
  35. comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
  36. comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
  37. comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
  38. comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
  39. comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
  40. comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
  41. comate_cli-0.1.0.dist-info/METADATA +37 -0
  42. comate_cli-0.1.0.dist-info/RECORD +44 -0
  43. comate_cli-0.1.0.dist-info/WHEEL +4 -0
  44. comate_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, is_dataclass
5
+ from typing import Any
6
+
7
+
8
+ class ErrorCodes:
9
+ PARSE_ERROR = -32700
10
+ INVALID_REQUEST = -32600
11
+ METHOD_NOT_FOUND = -32601
12
+ INVALID_PARAMS = -32602
13
+ INTERNAL_ERROR = -32603
14
+ INVALID_STATE = -32000
15
+
16
+
17
+ class JSONRPCProtocolError(Exception):
18
+ def __init__(
19
+ self,
20
+ *,
21
+ code: int,
22
+ message: str,
23
+ request_id: str | int | None = None,
24
+ data: Any = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.code = code
28
+ self.message = message
29
+ self.request_id = request_id
30
+ self.data = data
31
+
32
+
33
+ def parse_jsonrpc_message(raw_line: str) -> dict[str, Any]:
34
+ try:
35
+ parsed = json.loads(raw_line)
36
+ except json.JSONDecodeError as exc:
37
+ raise JSONRPCProtocolError(code=ErrorCodes.PARSE_ERROR, message=f"invalid json: {exc.msg}") from exc
38
+
39
+ if not isinstance(parsed, dict):
40
+ raise JSONRPCProtocolError(
41
+ code=ErrorCodes.INVALID_REQUEST,
42
+ message="json-rpc payload must be an object",
43
+ )
44
+
45
+ if parsed.get("jsonrpc") != "2.0":
46
+ raise JSONRPCProtocolError(
47
+ code=ErrorCodes.INVALID_REQUEST,
48
+ message="jsonrpc must be '2.0'",
49
+ request_id=_coerce_request_id(parsed.get("id")),
50
+ )
51
+
52
+ return parsed
53
+
54
+
55
+ def build_success_response(request_id: str | int, result: Any) -> dict[str, Any]:
56
+ return {"jsonrpc": "2.0", "id": request_id, "result": _to_jsonable(result)}
57
+
58
+
59
+ def build_error_response(
60
+ *,
61
+ request_id: str | int | None,
62
+ code: int,
63
+ message: str,
64
+ data: Any = None,
65
+ ) -> dict[str, Any]:
66
+ payload: dict[str, Any] = {
67
+ "jsonrpc": "2.0",
68
+ "id": request_id,
69
+ "error": {"code": int(code), "message": str(message)},
70
+ }
71
+ if data is not None:
72
+ payload["error"]["data"] = _to_jsonable(data)
73
+ return payload
74
+
75
+
76
+ def build_event_notification(event: Any) -> dict[str, Any]:
77
+ params = _to_jsonable(event)
78
+ if isinstance(params, dict):
79
+ params = {"event_type": type(event).__name__, **params}
80
+ else:
81
+ params = {"event_type": type(event).__name__, "value": params}
82
+ return {"jsonrpc": "2.0", "method": "event", "params": params}
83
+
84
+
85
+ def _coerce_request_id(raw_value: Any) -> str | int | None:
86
+ if isinstance(raw_value, str | int):
87
+ return raw_value
88
+ return None
89
+
90
+
91
+ def _to_jsonable(value: Any) -> Any:
92
+ if value is None or isinstance(value, bool | int | float | str):
93
+ return value
94
+ if is_dataclass(value):
95
+ return {key: _to_jsonable(item) for key, item in asdict(value).items()}
96
+ if isinstance(value, dict):
97
+ normalized: dict[str, Any] = {}
98
+ for key, item in value.items():
99
+ normalized[str(key)] = _to_jsonable(item)
100
+ return normalized
101
+ if isinstance(value, list | tuple | set):
102
+ return [_to_jsonable(item) for item in value]
103
+ return str(value)
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import sys
6
+ from typing import Any
7
+
8
+ from comate_agent_sdk.agent import ChatSession
9
+
10
+ from comate_cli.terminal_agent.rpc_protocol import (
11
+ ErrorCodes,
12
+ JSONRPCProtocolError,
13
+ build_error_response,
14
+ build_event_notification,
15
+ build_success_response,
16
+ parse_jsonrpc_message,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class StdioRPCBridge:
23
+ """JSON-RPC 2.0 bridge over stdin/stdout (NDJSON)."""
24
+
25
+ def __init__(self, session: ChatSession) -> None:
26
+ self._session = session
27
+ self._write_lock = asyncio.Lock()
28
+ self._closing = False
29
+ self._active_prompt_task: asyncio.Task[dict[str, Any]] | None = None
30
+ self._active_prompt_request_id: str | int | None = None
31
+
32
+ async def run(self) -> None:
33
+ while not self._closing:
34
+ raw_line = await asyncio.to_thread(sys.stdin.readline)
35
+ if raw_line == "":
36
+ break
37
+
38
+ line = raw_line.strip()
39
+ if not line:
40
+ continue
41
+
42
+ await self._handle_incoming_line(line)
43
+
44
+ await self._cancel_active_prompt()
45
+
46
+ async def _handle_incoming_line(self, line: str) -> None:
47
+ try:
48
+ message = parse_jsonrpc_message(line)
49
+ except JSONRPCProtocolError as exc:
50
+ await self._send(
51
+ build_error_response(
52
+ request_id=exc.request_id,
53
+ code=exc.code,
54
+ message=exc.message,
55
+ data=exc.data,
56
+ )
57
+ )
58
+ return
59
+
60
+ method = message.get("method")
61
+ request_id = message.get("id")
62
+ params = message.get("params") or {}
63
+
64
+ if method is None:
65
+ await self._send(
66
+ build_error_response(
67
+ request_id=request_id if isinstance(request_id, str | int) else None,
68
+ code=ErrorCodes.INVALID_REQUEST,
69
+ message="response payload is not supported on this endpoint",
70
+ )
71
+ )
72
+ return
73
+
74
+ if method == "initialize":
75
+ await self._handle_initialize(request_id)
76
+ return
77
+ if method == "prompt":
78
+ await self._handle_prompt(request_id, params)
79
+ return
80
+ if method == "cancel":
81
+ await self._handle_cancel(request_id)
82
+ return
83
+ if method == "replay":
84
+ await self._handle_replay(request_id)
85
+ return
86
+
87
+ await self._send(
88
+ build_error_response(
89
+ request_id=request_id if isinstance(request_id, str | int) else None,
90
+ code=ErrorCodes.METHOD_NOT_FOUND,
91
+ message=f"method not found: {method}",
92
+ )
93
+ )
94
+
95
+ async def _handle_initialize(self, request_id: Any) -> None:
96
+ if not isinstance(request_id, str | int):
97
+ await self._send(
98
+ build_error_response(
99
+ request_id=None,
100
+ code=ErrorCodes.INVALID_REQUEST,
101
+ message="initialize requires request id",
102
+ )
103
+ )
104
+ return
105
+
106
+ await self._send(
107
+ build_success_response(
108
+ request_id,
109
+ {
110
+ "status": "ok",
111
+ "protocol_version": "1.0",
112
+ "session_id": self._session.session_id,
113
+ },
114
+ )
115
+ )
116
+
117
+ async def _handle_prompt(self, request_id: Any, params: Any) -> None:
118
+ if not isinstance(request_id, str | int):
119
+ await self._send(
120
+ build_error_response(
121
+ request_id=None,
122
+ code=ErrorCodes.INVALID_REQUEST,
123
+ message="prompt requires request id",
124
+ )
125
+ )
126
+ return
127
+
128
+ if self._active_prompt_task is not None and not self._active_prompt_task.done():
129
+ await self._send(
130
+ build_error_response(
131
+ request_id=request_id,
132
+ code=ErrorCodes.INVALID_STATE,
133
+ message="prompt already running",
134
+ )
135
+ )
136
+ return
137
+
138
+ if not isinstance(params, dict):
139
+ await self._send(
140
+ build_error_response(
141
+ request_id=request_id,
142
+ code=ErrorCodes.INVALID_PARAMS,
143
+ message="params must be an object",
144
+ )
145
+ )
146
+ return
147
+
148
+ user_input = params.get("user_input")
149
+ if not isinstance(user_input, str) or not user_input.strip():
150
+ await self._send(
151
+ build_error_response(
152
+ request_id=request_id,
153
+ code=ErrorCodes.INVALID_PARAMS,
154
+ message="params.user_input must be a non-empty string",
155
+ )
156
+ )
157
+ return
158
+
159
+ task = asyncio.create_task(
160
+ self._run_prompt_stream(user_input),
161
+ name=f"rpc-prompt-{request_id}",
162
+ )
163
+ self._active_prompt_task = task
164
+ self._active_prompt_request_id = request_id
165
+ asyncio.create_task(
166
+ self._finalize_prompt_task(task=task, request_id=request_id),
167
+ name=f"rpc-prompt-finalize-{request_id}",
168
+ )
169
+
170
+ async def _handle_cancel(self, request_id: Any) -> None:
171
+ if not isinstance(request_id, str | int):
172
+ await self._send(
173
+ build_error_response(
174
+ request_id=None,
175
+ code=ErrorCodes.INVALID_REQUEST,
176
+ message="cancel requires request id",
177
+ )
178
+ )
179
+ return
180
+
181
+ cancelled = await self._cancel_active_prompt()
182
+ await self._send(build_success_response(request_id, {"cancelled": cancelled}))
183
+
184
+ async def _handle_replay(self, request_id: Any) -> None:
185
+ if not isinstance(request_id, str | int):
186
+ await self._send(
187
+ build_error_response(
188
+ request_id=None,
189
+ code=ErrorCodes.INVALID_REQUEST,
190
+ message="replay requires request id",
191
+ )
192
+ )
193
+ return
194
+
195
+ await self._send(
196
+ build_success_response(
197
+ request_id,
198
+ {
199
+ "supported": False,
200
+ "reason": "replay is not implemented for stdio bridge",
201
+ },
202
+ )
203
+ )
204
+
205
+ async def _run_prompt_stream(self, user_input: str) -> dict[str, Any]:
206
+ waiting_for_input = False
207
+ stop_reason = "completed"
208
+ try:
209
+ async for event in self._session.query_stream(user_input):
210
+ await self._send(build_event_notification(event))
211
+ reason = getattr(event, "reason", None)
212
+ if reason == "waiting_for_input":
213
+ waiting_for_input = True
214
+ stop_reason = "waiting_for_input"
215
+ if reason == "waiting_for_plan_approval":
216
+ stop_reason = "waiting_for_plan_approval"
217
+ except asyncio.CancelledError:
218
+ stop_reason = "cancelled"
219
+ raise
220
+ except Exception as exc:
221
+ logger.exception("rpc prompt stream failed")
222
+ raise RuntimeError(f"stream failed: {exc}") from exc
223
+
224
+ if stop_reason == "waiting_for_plan_approval":
225
+ status = "waiting_for_plan_approval"
226
+ else:
227
+ status = "waiting_for_input" if waiting_for_input else "completed"
228
+ return {"status": status, "stop_reason": stop_reason}
229
+
230
+ async def _finalize_prompt_task(
231
+ self,
232
+ *,
233
+ task: asyncio.Task[dict[str, Any]],
234
+ request_id: str | int,
235
+ ) -> None:
236
+ try:
237
+ result = await task
238
+ await self._send(build_success_response(request_id, result))
239
+ except asyncio.CancelledError:
240
+ await self._send(
241
+ build_success_response(
242
+ request_id,
243
+ {"status": "cancelled", "stop_reason": "cancelled"},
244
+ )
245
+ )
246
+ except Exception as exc:
247
+ await self._send(
248
+ build_error_response(
249
+ request_id=request_id,
250
+ code=ErrorCodes.INTERNAL_ERROR,
251
+ message=str(exc),
252
+ )
253
+ )
254
+ finally:
255
+ if self._active_prompt_task is task:
256
+ self._active_prompt_task = None
257
+ self._active_prompt_request_id = None
258
+
259
+ async def _cancel_active_prompt(self) -> bool:
260
+ task = self._active_prompt_task
261
+ if task is None or task.done():
262
+ return False
263
+ task.cancel()
264
+ try:
265
+ await task
266
+ except asyncio.CancelledError:
267
+ pass
268
+ return True
269
+
270
+ async def _send(self, payload: dict[str, Any]) -> None:
271
+ encoded = f"{self._dump_json(payload)}\n"
272
+ async with self._write_lock:
273
+ await asyncio.to_thread(sys.stdout.write, encoded)
274
+ await asyncio.to_thread(sys.stdout.flush)
275
+
276
+ @staticmethod
277
+ def _dump_json(payload: dict[str, Any]) -> str:
278
+ import json
279
+
280
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
@@ -0,0 +1,305 @@
1
+ """通用选择菜单 UI 组件。
2
+
3
+ 提供简单的单选菜单界面,支持上下选择、Enter 确认、Esc 取消。
4
+ 可用于模型切换、配置选择等二级选择场景。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable
11
+
12
+ from prompt_toolkit.layout import HSplit, Window
13
+ from prompt_toolkit.layout.controls import FormattedTextControl
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class SelectionOption:
18
+ """单个选项。"""
19
+
20
+ value: str
21
+ label: str
22
+ description: str = ""
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class SelectionMenuState:
27
+ """选择菜单状态。"""
28
+
29
+ title: str = ""
30
+ options: list[SelectionOption] = field(default_factory=list)
31
+ selected_index: int = 0
32
+ is_confirmed: bool = False
33
+ is_cancelled: bool = False
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class SelectionResult:
38
+ """选择结果。"""
39
+
40
+ confirmed: bool
41
+ value: str | None = None
42
+ label: str | None = None
43
+
44
+
45
+ class SelectionMenuUI:
46
+ """通用选择菜单 UI 组件。
47
+
48
+ 适用于简单的单选场景,如:
49
+ - 模型级别切换 (LOW/MID/HIGH)
50
+ - 配置选项选择
51
+ - 快捷操作菜单
52
+
53
+ 按键:
54
+ - ↑/↓ 或 k/j: 移动选择
55
+ - Enter: 确认选择
56
+ - Esc: 取消
57
+ """
58
+
59
+ def __init__(self) -> None:
60
+ self._state = SelectionMenuState()
61
+ self._on_confirm: Callable[[str], None] | None = None
62
+ self._on_cancel: Callable[[], None] | None = None
63
+
64
+ self._title_control = FormattedTextControl(text=self._title_fragments)
65
+ self._options_control = FormattedTextControl(text=self._options_fragments)
66
+
67
+ self._title_window = Window(
68
+ content=self._title_control,
69
+ height=1,
70
+ dont_extend_height=True,
71
+ style="class:selection.title",
72
+ )
73
+ self._options_window = Window(
74
+ content=self._options_control,
75
+ wrap_lines=True,
76
+ dont_extend_height=False,
77
+ style="class:selection.body",
78
+ )
79
+
80
+ self._root = HSplit(
81
+ [
82
+ self._title_window,
83
+ Window(height=1, char="─", style="class:selection.divider"),
84
+ self._options_window,
85
+ ]
86
+ )
87
+
88
+ @property
89
+ def container(self) -> HSplit:
90
+ return self._root
91
+
92
+ def set_options(
93
+ self,
94
+ title: str,
95
+ options: list[dict[str, str]],
96
+ on_confirm: Callable[[str], None] | None = None,
97
+ on_cancel: Callable[[], None] | None = None,
98
+ ) -> bool:
99
+ """设置选项。
100
+
101
+ Args:
102
+ title: 菜单标题
103
+ options: 选项列表,每个选项为 {"value": "xxx", "label": "显示文本", "description": "描述"}
104
+ on_confirm: 确认回调,参数为选中的 value
105
+ on_cancel: 取消回调
106
+
107
+ Returns:
108
+ 是否成功设置(至少有一个有效选项)
109
+ """
110
+ parsed_options: list[SelectionOption] = []
111
+ for opt in options:
112
+ if not isinstance(opt, dict):
113
+ continue
114
+ value = str(opt.get("value", "")).strip()
115
+ label = str(opt.get("label", "")).strip()
116
+ if not value or not label:
117
+ continue
118
+ description = str(opt.get("description", "")).strip()
119
+ parsed_options.append(SelectionOption(value=value, label=label, description=description))
120
+
121
+ if not parsed_options:
122
+ return False
123
+
124
+ self._state.title = title
125
+ self._state.options = parsed_options
126
+ self._state.selected_index = 0
127
+ self._state.is_confirmed = False
128
+ self._state.is_cancelled = False
129
+ self._on_confirm = on_confirm
130
+ self._on_cancel = on_cancel
131
+ return True
132
+
133
+ def clear(self) -> None:
134
+ """清除状态。"""
135
+ self._state = SelectionMenuState()
136
+ self._on_confirm = None
137
+ self._on_cancel = None
138
+
139
+ def has_options(self) -> bool:
140
+ """是否有选项。"""
141
+ return bool(self._state.options)
142
+
143
+ def move_selection(self, delta: int) -> None:
144
+ """移动选择。"""
145
+ if not self._state.options:
146
+ return
147
+ count = len(self._state.options)
148
+ self._state.selected_index = (self._state.selected_index + delta) % count
149
+
150
+ def confirm(self) -> SelectionResult | None:
151
+ """确认当前选择。
152
+
153
+ Returns:
154
+ 选择结果,如果没有选项则返回 None
155
+ """
156
+ if not self._state.options:
157
+ return None
158
+
159
+ option = self._state.options[self._state.selected_index]
160
+ self._state.is_confirmed = True
161
+
162
+ if self._on_confirm:
163
+ self._on_confirm(option.value)
164
+
165
+ return SelectionResult(confirmed=True, value=option.value, label=option.label)
166
+
167
+ def cancel(self) -> SelectionResult:
168
+ """取消选择。
169
+
170
+ Returns:
171
+ 取消结果
172
+ """
173
+ self._state.is_cancelled = True
174
+
175
+ if self._on_cancel:
176
+ self._on_cancel()
177
+
178
+ return SelectionResult(confirmed=False)
179
+
180
+ def get_selected(self) -> SelectionOption | None:
181
+ """获取当前选中的选项。"""
182
+ if not self._state.options:
183
+ return None
184
+ return self._state.options[self._state.selected_index]
185
+
186
+ def _title_fragments(self) -> list[tuple[str, str]]:
187
+ if not self._state.title:
188
+ return [("class:selection.title", " Select an option")]
189
+ return [("class:selection.title", f" {self._state.title}")]
190
+
191
+ def _options_fragments(self) -> list[tuple[str, str]]:
192
+ if not self._state.options:
193
+ return [("class:selection.body", " No options available")]
194
+
195
+ fragments: list[tuple[str, str]] = []
196
+ for idx, option in enumerate(self._state.options):
197
+ is_selected = idx == self._state.selected_index
198
+ cursor = "▶" if is_selected else " "
199
+ marker = "●" if is_selected else "○"
200
+
201
+ line_style = "class:selection.option.selected" if is_selected else "class:selection.option"
202
+ desc_style = "class:selection.description.selected" if is_selected else "class:selection.description"
203
+
204
+ # 主选项行
205
+ fragments.append((line_style, f" {cursor} {marker} {option.label}"))
206
+ fragments.append(("", "\n"))
207
+
208
+ # 描述行(如果有)
209
+ if option.description:
210
+ fragments.append((desc_style, f" {option.description}"))
211
+ fragments.append(("", "\n"))
212
+
213
+ # 底部提示
214
+ fragments.append(("", "\n"))
215
+ fragments.append(
216
+ ("class:selection.hint", " ↑/↓ or k/j: Move Enter: Confirm Esc: Cancel")
217
+ )
218
+ return fragments
219
+
220
+ def refresh(self) -> None:
221
+ """刷新显示(触发重绘)。"""
222
+ self._title_control.text = self._title_fragments
223
+ self._options_control.text = self._options_fragments
224
+
225
+ def focus_target(self) -> Window:
226
+ """获取焦点目标窗口。"""
227
+ return self._options_window
228
+
229
+ def __pt_formatted_text__(self) -> list[tuple[str, str]]:
230
+ """支持直接作为 formatted text 使用。"""
231
+ return self._options_fragments()
232
+
233
+
234
+ # 便捷函数:创建模型级别选择菜单
235
+ def create_model_level_menu(
236
+ current_level: str | None,
237
+ llm_levels: dict[str, Any] | None,
238
+ on_confirm: Callable[[str], None],
239
+ on_cancel: Callable[[], None],
240
+ ) -> SelectionMenuUI:
241
+ """创建模型级别选择菜单。
242
+
243
+ Args:
244
+ current_level: 当前级别 (LOW/MID/HIGH)
245
+ llm_levels: LLM 级别配置字典,包含实际的模型实例
246
+ on_confirm: 确认回调
247
+ on_cancel: 取消回调
248
+
249
+ Returns:
250
+ 配置好的 SelectionMenuUI 实例
251
+ """
252
+ ui = SelectionMenuUI()
253
+
254
+ title, options = build_model_level_options(
255
+ current_level=current_level or "MID",
256
+ llm_levels=llm_levels,
257
+ )
258
+
259
+ ui.set_options(
260
+ title=title,
261
+ options=options,
262
+ on_confirm=on_confirm,
263
+ on_cancel=on_cancel,
264
+ )
265
+ return ui
266
+
267
+
268
+ def build_model_level_options(
269
+ *,
270
+ current_level: str,
271
+ llm_levels: dict[str, Any] | None,
272
+ ) -> tuple[str, list[dict[str, str]]]:
273
+ def get_model_name(level: str) -> str:
274
+ if llm_levels and level in llm_levels:
275
+ llm = llm_levels[level]
276
+ model = getattr(llm, "model", None)
277
+ if model:
278
+ return str(model)
279
+ return "unknown"
280
+
281
+ options = [
282
+ {
283
+ "value": "LOW",
284
+ "label": "LOW - Fast & Cheap",
285
+ "description": f"{get_model_name('LOW')}",
286
+ },
287
+ {
288
+ "value": "MID",
289
+ "label": "MID - Balanced",
290
+ "description": f"{get_model_name('MID')}",
291
+ },
292
+ {
293
+ "value": "HIGH",
294
+ "label": "HIGH - Best Quality",
295
+ "description": f"{get_model_name('HIGH')}",
296
+ },
297
+ ]
298
+
299
+ normalized = current_level.upper().strip() or "MID"
300
+ for opt in options:
301
+ if opt["value"] == normalized:
302
+ opt["label"] = f"{opt['label']} (current)"
303
+ break
304
+
305
+ return "Select Model Level", options