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.
- comate_cli/__init__.py +5 -0
- comate_cli/__main__.py +5 -0
- comate_cli/main.py +128 -0
- comate_cli/terminal_agent/__init__.py +2 -0
- comate_cli/terminal_agent/animations.py +283 -0
- comate_cli/terminal_agent/app.py +261 -0
- comate_cli/terminal_agent/assistant_render.py +243 -0
- comate_cli/terminal_agent/env_utils.py +37 -0
- comate_cli/terminal_agent/error_display.py +46 -0
- comate_cli/terminal_agent/event_renderer.py +867 -0
- comate_cli/terminal_agent/fragment_utils.py +25 -0
- comate_cli/terminal_agent/history_printer.py +150 -0
- comate_cli/terminal_agent/input_geometry.py +92 -0
- comate_cli/terminal_agent/layout_coordinator.py +188 -0
- comate_cli/terminal_agent/logging_adapter.py +147 -0
- comate_cli/terminal_agent/logo.py +58 -0
- comate_cli/terminal_agent/markdown_render.py +24 -0
- comate_cli/terminal_agent/mention_completer.py +293 -0
- comate_cli/terminal_agent/message_style.py +33 -0
- comate_cli/terminal_agent/models.py +89 -0
- comate_cli/terminal_agent/question_view.py +584 -0
- comate_cli/terminal_agent/rewind_store.py +712 -0
- comate_cli/terminal_agent/rpc_protocol.py +103 -0
- comate_cli/terminal_agent/rpc_stdio.py +280 -0
- comate_cli/terminal_agent/selection_menu.py +305 -0
- comate_cli/terminal_agent/session_view.py +99 -0
- comate_cli/terminal_agent/slash_commands.py +142 -0
- comate_cli/terminal_agent/startup.py +77 -0
- comate_cli/terminal_agent/status_bar.py +258 -0
- comate_cli/terminal_agent/text_effects.py +30 -0
- comate_cli/terminal_agent/tool_view.py +584 -0
- comate_cli/terminal_agent/tui.py +1006 -0
- comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
- comate_cli/terminal_agent/tui_parts/commands.py +759 -0
- comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
- comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
- comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
- comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
- comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
- comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
- comate_cli-0.1.0.dist-info/METADATA +37 -0
- comate_cli-0.1.0.dist-info/RECORD +44 -0
- comate_cli-0.1.0.dist-info/WHEEL +4 -0
- 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
|