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,584 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.console import RenderableType
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize_path_for_display(path_str: str | None, project_root: Path | None) -> str | None:
|
|
14
|
+
"""将路径转换为相对于项目根目录的显示格式。"""
|
|
15
|
+
if path_str is None:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
path = Path(path_str)
|
|
19
|
+
if project_root is not None and path.is_absolute():
|
|
20
|
+
try:
|
|
21
|
+
relative = path.relative_to(project_root)
|
|
22
|
+
return relative.as_posix()
|
|
23
|
+
except ValueError:
|
|
24
|
+
# 路径不在 project_root 下,保持原样
|
|
25
|
+
pass
|
|
26
|
+
return path_str
|
|
27
|
+
except Exception:
|
|
28
|
+
return path_str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_cwd_for_display(cwd: str | None, project_root: Path | None) -> str | None:
|
|
32
|
+
"""将工作目录转换为相对于项目根目录的显示格式。"""
|
|
33
|
+
return _normalize_path_for_display(cwd, project_root)
|
|
34
|
+
|
|
35
|
+
from comate_cli.terminal_agent.message_style import (
|
|
36
|
+
TOOL_ERROR_PREFIX,
|
|
37
|
+
TOOL_ERROR_STYLE,
|
|
38
|
+
TOOL_RUNNING_PREFIX,
|
|
39
|
+
TOOL_RUNNING_STYLE,
|
|
40
|
+
TOOL_SUCCESS_PREFIX,
|
|
41
|
+
TOOL_SUCCESS_STYLE,
|
|
42
|
+
)
|
|
43
|
+
from comate_cli.terminal_agent.models import TodoItemState, ToolRunState
|
|
44
|
+
|
|
45
|
+
_PULSE_GLYPHS: tuple[str, ...] = ("◐", "◓", "◑", "◒")
|
|
46
|
+
_HIDDEN_ARG_TOOLS: frozenset[str] = frozenset({"askuserquestion"})
|
|
47
|
+
_TASK_PROMPT_FALLBACK_LEN = 40
|
|
48
|
+
_SWEEP_SPEED_MULTIPLIER = 2
|
|
49
|
+
_MAX_FANCY_TASKS = 2
|
|
50
|
+
_MAX_FANCY_LINE_LEN = 100
|
|
51
|
+
|
|
52
|
+
# Todo extraction helpers
|
|
53
|
+
_ALLOWED_STATUS = {"pending", "in_progress", "completed"}
|
|
54
|
+
_ALLOWED_PRIORITY = {"high", "medium", "low"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_todos_value(value: Any) -> list[dict[str, Any]] | None:
|
|
58
|
+
if isinstance(value, list):
|
|
59
|
+
return value
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
try:
|
|
62
|
+
parsed = json.loads(value)
|
|
63
|
+
except (json.JSONDecodeError, TypeError):
|
|
64
|
+
return None
|
|
65
|
+
if isinstance(parsed, list):
|
|
66
|
+
return parsed
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _as_todos(args: dict[str, Any]) -> list[dict[str, Any]] | None:
|
|
71
|
+
if "todos" in args:
|
|
72
|
+
parsed = _parse_todos_value(args["todos"])
|
|
73
|
+
if parsed is not None:
|
|
74
|
+
return parsed
|
|
75
|
+
params = args.get("params")
|
|
76
|
+
if isinstance(params, dict):
|
|
77
|
+
parsed = _parse_todos_value(params.get("todos"))
|
|
78
|
+
if parsed is not None:
|
|
79
|
+
return parsed
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _normalize_todo(item: dict[str, Any]) -> TodoItemState | None:
|
|
84
|
+
content = str(item.get("content", "")).strip()
|
|
85
|
+
if not content:
|
|
86
|
+
return None
|
|
87
|
+
status = str(item.get("status", "pending")).strip().lower()
|
|
88
|
+
if status not in _ALLOWED_STATUS:
|
|
89
|
+
status = "pending"
|
|
90
|
+
priority = str(item.get("priority", "medium")).strip().lower()
|
|
91
|
+
if priority not in _ALLOWED_PRIORITY:
|
|
92
|
+
priority = "medium"
|
|
93
|
+
return TodoItemState(content=content, status=status, priority=priority)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_todos(args: dict[str, Any]) -> list[TodoItemState] | None:
|
|
97
|
+
"""从 TodoWrite 工具参数中提取 todo 列表。"""
|
|
98
|
+
raw = _as_todos(args)
|
|
99
|
+
if raw is None:
|
|
100
|
+
return None
|
|
101
|
+
todos: list[TodoItemState] = []
|
|
102
|
+
for item in raw:
|
|
103
|
+
if not isinstance(item, dict):
|
|
104
|
+
continue
|
|
105
|
+
normalized = _normalize_todo(item)
|
|
106
|
+
if normalized is not None:
|
|
107
|
+
todos.append(normalized)
|
|
108
|
+
return todos
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _truncate(content: str, max_len: int = 280) -> str:
|
|
112
|
+
if len(content) <= max_len:
|
|
113
|
+
return content
|
|
114
|
+
return f"{content[:max_len]}..."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize_inline(content: str) -> str:
|
|
118
|
+
return " ".join(content.split())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _lookup_arg(args: dict[str, Any], *keys: str) -> Any:
|
|
122
|
+
for key in keys:
|
|
123
|
+
if key in args:
|
|
124
|
+
return args.get(key)
|
|
125
|
+
params = args.get("params")
|
|
126
|
+
if isinstance(params, dict):
|
|
127
|
+
for key in keys:
|
|
128
|
+
if key in params:
|
|
129
|
+
return params.get(key)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _compact_json(value: Any, max_len: int = 220) -> str:
|
|
134
|
+
try:
|
|
135
|
+
content = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
136
|
+
except Exception:
|
|
137
|
+
content = str(value)
|
|
138
|
+
return _truncate(content, max_len=max_len)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _should_hide_tool_args(tool_name: str) -> bool:
|
|
142
|
+
return tool_name.lower() in _HIDDEN_ARG_TOOLS
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _extract_task_identity(args: dict[str, Any]) -> tuple[str, str]:
|
|
146
|
+
raw_subagent = _lookup_arg(args, "subagent_type")
|
|
147
|
+
subagent_name = raw_subagent.strip() if isinstance(raw_subagent, str) else ""
|
|
148
|
+
if not subagent_name:
|
|
149
|
+
subagent_name = "Task"
|
|
150
|
+
|
|
151
|
+
raw_desc = _lookup_arg(args, "description")
|
|
152
|
+
description = raw_desc.strip() if isinstance(raw_desc, str) else ""
|
|
153
|
+
|
|
154
|
+
if not description:
|
|
155
|
+
raw_prompt = _lookup_arg(args, "prompt")
|
|
156
|
+
if isinstance(raw_prompt, str):
|
|
157
|
+
prompt_text = _normalize_inline(raw_prompt)
|
|
158
|
+
description = _truncate(prompt_text, _TASK_PROMPT_FALLBACK_LEN)
|
|
159
|
+
|
|
160
|
+
if not description:
|
|
161
|
+
description = subagent_name
|
|
162
|
+
|
|
163
|
+
return subagent_name, description
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _format_tokens(token_count: int) -> str:
|
|
167
|
+
tokens = max(int(token_count), 0)
|
|
168
|
+
if tokens < 1_000:
|
|
169
|
+
return f"{tokens} tok"
|
|
170
|
+
if tokens < 1_000_000:
|
|
171
|
+
compact = f"{tokens / 1_000:.1f}".rstrip("0").rstrip(".")
|
|
172
|
+
return f"{compact}k tok"
|
|
173
|
+
compact = f"{tokens / 1_000_000:.1f}".rstrip("0").rstrip(".")
|
|
174
|
+
return f"{compact}m tok"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _format_duration(seconds: float) -> str:
|
|
178
|
+
elapsed = max(seconds, 0.0)
|
|
179
|
+
if elapsed < 60:
|
|
180
|
+
return f"{elapsed:.1f}s"
|
|
181
|
+
minutes = int(elapsed // 60)
|
|
182
|
+
remaining_seconds = int(elapsed % 60)
|
|
183
|
+
if minutes < 60:
|
|
184
|
+
return f"{minutes}m{remaining_seconds:02d}s"
|
|
185
|
+
hours = minutes // 60
|
|
186
|
+
remaining_minutes = minutes % 60
|
|
187
|
+
return f"{hours}h{remaining_minutes:02d}m"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _lerp_rgb(
|
|
191
|
+
start_rgb: tuple[int, int, int],
|
|
192
|
+
end_rgb: tuple[int, int, int],
|
|
193
|
+
ratio: float,
|
|
194
|
+
) -> tuple[int, int, int]:
|
|
195
|
+
clamped = max(0.0, min(1.0, ratio))
|
|
196
|
+
r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * clamped)
|
|
197
|
+
g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * clamped)
|
|
198
|
+
b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * clamped)
|
|
199
|
+
return r, g, b
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _sweep_gradient_text(content: str, frame: int) -> Text:
|
|
203
|
+
text = Text()
|
|
204
|
+
if not content:
|
|
205
|
+
return text
|
|
206
|
+
|
|
207
|
+
total = len(content)
|
|
208
|
+
base_rgb = (96, 124, 156)
|
|
209
|
+
mid_rgb = (118, 195, 225)
|
|
210
|
+
high_rgb = (218, 246, 255)
|
|
211
|
+
|
|
212
|
+
window = max(4, total // 6)
|
|
213
|
+
cycle = max(total + window * 2, 20)
|
|
214
|
+
center = (frame % cycle) - window
|
|
215
|
+
|
|
216
|
+
for idx, ch in enumerate(content):
|
|
217
|
+
distance = abs(idx - center)
|
|
218
|
+
if distance <= window:
|
|
219
|
+
glow = 1.0 - (distance / window)
|
|
220
|
+
if glow >= 0.6:
|
|
221
|
+
r, g, b = _lerp_rgb(mid_rgb, high_rgb, (glow - 0.6) / 0.4)
|
|
222
|
+
else:
|
|
223
|
+
r, g, b = _lerp_rgb(base_rgb, mid_rgb, glow / 0.6)
|
|
224
|
+
else:
|
|
225
|
+
r, g, b = base_rgb
|
|
226
|
+
text.append(ch, style=f"bold rgb({r},{g},{b})")
|
|
227
|
+
return text
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def summarize_tool_args(
|
|
231
|
+
tool_name: str, args: dict[str, Any], project_root: Path | None = None
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Summarize tool arguments for display.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
tool_name: Name of the tool being called.
|
|
237
|
+
args: Tool arguments dictionary.
|
|
238
|
+
project_root: Optional project root path. If provided, absolute paths
|
|
239
|
+
will be converted to relative paths for display.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A compact string representation of the tool arguments.
|
|
243
|
+
"""
|
|
244
|
+
if _should_hide_tool_args(tool_name):
|
|
245
|
+
return ""
|
|
246
|
+
lowered = tool_name.lower()
|
|
247
|
+
if lowered == "write":
|
|
248
|
+
path = _lookup_arg(args, "file_path", "path")
|
|
249
|
+
path_display = _normalize_path_for_display(path, project_root)
|
|
250
|
+
return f"path={path_display}" if path_display else _compact_json(args)
|
|
251
|
+
if lowered == "edit":
|
|
252
|
+
path = _lookup_arg(args, "file_path", "path")
|
|
253
|
+
path_display = _normalize_path_for_display(path, project_root)
|
|
254
|
+
return path_display or _compact_json(args)
|
|
255
|
+
if lowered == "multiedit":
|
|
256
|
+
path = _lookup_arg(args, "file_path", "path")
|
|
257
|
+
path_display = _normalize_path_for_display(path, project_root)
|
|
258
|
+
return path_display or _compact_json(args)
|
|
259
|
+
if lowered == "read":
|
|
260
|
+
path = _lookup_arg(args, "file_path", "path")
|
|
261
|
+
path_display = _normalize_path_for_display(path, project_root)
|
|
262
|
+
offset = _lookup_arg(args, "offset_line")
|
|
263
|
+
limit = _lookup_arg(args, "limit_lines")
|
|
264
|
+
return f"path={path_display} offset={offset} limit={limit}" if path_display else _compact_json(args)
|
|
265
|
+
if lowered in {"grep", "glob", "ls"}:
|
|
266
|
+
pattern = _lookup_arg(args, "pattern")
|
|
267
|
+
path = _lookup_arg(args, "path")
|
|
268
|
+
path_display = _normalize_path_for_display(path, project_root)
|
|
269
|
+
return f"path={path_display} pattern={pattern}" if (path_display or pattern) else _compact_json(args)
|
|
270
|
+
if lowered == "bash":
|
|
271
|
+
command_args = _lookup_arg(args, "args")
|
|
272
|
+
if isinstance(command_args, list):
|
|
273
|
+
cmd = " ".join(str(part) for part in command_args)
|
|
274
|
+
cmd_display = _truncate(cmd, 180)
|
|
275
|
+
cwd = _lookup_arg(args, "cwd")
|
|
276
|
+
cwd_display = _normalize_cwd_for_display(cwd, project_root)
|
|
277
|
+
if cwd_display:
|
|
278
|
+
return f"cwd={cwd_display} {cmd_display}"
|
|
279
|
+
return cmd_display
|
|
280
|
+
# 回退:兼容非标准格式(如 command 字段)
|
|
281
|
+
command = _lookup_arg(args, "command")
|
|
282
|
+
cwd = _lookup_arg(args, "cwd")
|
|
283
|
+
cwd_display = _normalize_cwd_for_display(cwd, project_root)
|
|
284
|
+
if cwd_display:
|
|
285
|
+
return f"cwd={cwd_display} command={_truncate(str(command), 180)}" if command else _compact_json(args)
|
|
286
|
+
return f"command={_truncate(str(command), 180)}" if command else _compact_json(args)
|
|
287
|
+
if lowered == "webfetch":
|
|
288
|
+
url = _lookup_arg(args, "url")
|
|
289
|
+
return f"url={url}" if url else _compact_json(args)
|
|
290
|
+
if lowered == "todowrite":
|
|
291
|
+
todos = extract_todos(args)
|
|
292
|
+
if todos is None:
|
|
293
|
+
return _compact_json(args)
|
|
294
|
+
pending = sum(1 for todo in todos if todo.status == "pending")
|
|
295
|
+
in_progress = sum(1 for todo in todos if todo.status == "in_progress")
|
|
296
|
+
completed = sum(1 for todo in todos if todo.status == "completed")
|
|
297
|
+
return (
|
|
298
|
+
f"todos={len(todos)} pending={pending} "
|
|
299
|
+
f"in_progress={in_progress} completed={completed}"
|
|
300
|
+
)
|
|
301
|
+
return _compact_json(args)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ToolEventView:
|
|
305
|
+
"""Tool state tracker for loading layer + in-message mutable tool lines."""
|
|
306
|
+
|
|
307
|
+
def __init__(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
fancy_progress_effect: bool = True,
|
|
311
|
+
) -> None:
|
|
312
|
+
self._state_by_id: dict[str, ToolRunState] = {}
|
|
313
|
+
self._tool_text_refs: dict[str, Text] = {}
|
|
314
|
+
self._frame = 0
|
|
315
|
+
self._latest_total_tokens = 0
|
|
316
|
+
self._latest_tokens_by_source_prefix: dict[str, int] = {}
|
|
317
|
+
self._fancy_progress_effect = fancy_progress_effect
|
|
318
|
+
|
|
319
|
+
def reset_turn(self) -> None:
|
|
320
|
+
self._state_by_id.clear()
|
|
321
|
+
self._tool_text_refs.clear()
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _task_title(state: ToolRunState) -> str:
|
|
325
|
+
if state.task_desc and state.task_desc != state.subagent_name:
|
|
326
|
+
return f"{state.subagent_name}({state.task_desc})"
|
|
327
|
+
return state.subagent_name or "Task"
|
|
328
|
+
|
|
329
|
+
def _running_states(self, *, only_tasks: bool = False) -> list[ToolRunState]:
|
|
330
|
+
states = [state for state in self._state_by_id.values() if state.status == "running"]
|
|
331
|
+
if only_tasks:
|
|
332
|
+
return [state for state in states if state.is_task]
|
|
333
|
+
return states
|
|
334
|
+
|
|
335
|
+
def _running_line(self, state: ToolRunState, pulse: str, now: float) -> str:
|
|
336
|
+
elapsed = _format_duration(now - state.started_at_monotonic)
|
|
337
|
+
if state.is_task:
|
|
338
|
+
token_text = self._task_token_text(state)
|
|
339
|
+
return f"{pulse} {self._task_title(state)} · 运行中 · {elapsed} · {token_text}"
|
|
340
|
+
|
|
341
|
+
summary_suffix = (
|
|
342
|
+
f" {state.args_summary}" if state.args_summary and state.args_summary != "hidden" else ""
|
|
343
|
+
)
|
|
344
|
+
return f"{pulse} {state.tool_name}{summary_suffix} · 运行中 · {elapsed}"
|
|
345
|
+
|
|
346
|
+
def _should_use_fancy_effect(self, lines: list[str]) -> bool:
|
|
347
|
+
if not self._fancy_progress_effect:
|
|
348
|
+
return False
|
|
349
|
+
if len(lines) > _MAX_FANCY_TASKS:
|
|
350
|
+
return False
|
|
351
|
+
if any(len(line) > _MAX_FANCY_LINE_LEN for line in lines):
|
|
352
|
+
return False
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
def has_running_tasks(self) -> bool:
|
|
356
|
+
return any(state.status == "running" and state.is_task for state in self._state_by_id.values())
|
|
357
|
+
|
|
358
|
+
def tick_progress(self) -> None:
|
|
359
|
+
if not self.has_running_tasks():
|
|
360
|
+
return
|
|
361
|
+
self._frame += 1
|
|
362
|
+
|
|
363
|
+
def running_subagent_source_prefixes(self) -> set[str]:
|
|
364
|
+
return {
|
|
365
|
+
state.subagent_source_prefix
|
|
366
|
+
for state in self._state_by_id.values()
|
|
367
|
+
if state.status == "running" and state.is_task and state.subagent_source_prefix
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
def set_task_source_baseline(self, tool_call_id: str, source_total_tokens: int) -> None:
|
|
371
|
+
state = self._state_by_id.get(tool_call_id)
|
|
372
|
+
if state is None or not state.is_task or not state.subagent_source_prefix:
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
normalized = max(int(source_total_tokens), 0)
|
|
376
|
+
state.baseline_source_tokens = normalized
|
|
377
|
+
state.task_tokens = 0
|
|
378
|
+
state.last_progress_tokens = 0
|
|
379
|
+
self._latest_tokens_by_source_prefix[state.subagent_source_prefix] = normalized
|
|
380
|
+
|
|
381
|
+
def update_task_progress(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
tool_call_id: str,
|
|
385
|
+
tokens: int | None = None,
|
|
386
|
+
elapsed_ms: float | None = None,
|
|
387
|
+
) -> None:
|
|
388
|
+
state = self._state_by_id.get(tool_call_id)
|
|
389
|
+
if state is None or not state.is_task:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
if tokens is not None:
|
|
393
|
+
normalized_tokens = max(int(tokens), 0)
|
|
394
|
+
state.task_tokens = normalized_tokens
|
|
395
|
+
state.last_progress_tokens = normalized_tokens
|
|
396
|
+
|
|
397
|
+
if elapsed_ms is not None:
|
|
398
|
+
normalized_elapsed = max(float(elapsed_ms), 0.0)
|
|
399
|
+
state.started_at_monotonic = time.monotonic() - (normalized_elapsed / 1000)
|
|
400
|
+
|
|
401
|
+
def _task_token_text(self, state: ToolRunState) -> str:
|
|
402
|
+
if state.subagent_source_prefix:
|
|
403
|
+
return _format_tokens(state.task_tokens)
|
|
404
|
+
return _format_tokens(self._latest_total_tokens)
|
|
405
|
+
|
|
406
|
+
def update_usage_tokens(
|
|
407
|
+
self,
|
|
408
|
+
total_tokens: int,
|
|
409
|
+
source_totals: dict[str, int] | None = None,
|
|
410
|
+
) -> None:
|
|
411
|
+
self._latest_total_tokens = max(int(total_tokens), 0)
|
|
412
|
+
if source_totals:
|
|
413
|
+
for source_prefix, source_tokens in source_totals.items():
|
|
414
|
+
self._latest_tokens_by_source_prefix[source_prefix] = max(int(source_tokens), 0)
|
|
415
|
+
|
|
416
|
+
for state in self._state_by_id.values():
|
|
417
|
+
if state.status != "running" or not state.is_task or not state.subagent_source_prefix:
|
|
418
|
+
continue
|
|
419
|
+
current_total = self._latest_tokens_by_source_prefix.get(state.subagent_source_prefix)
|
|
420
|
+
if current_total is None:
|
|
421
|
+
continue
|
|
422
|
+
task_tokens = max(current_total - state.baseline_source_tokens, 0)
|
|
423
|
+
state.task_tokens = task_tokens
|
|
424
|
+
state.last_progress_tokens = task_tokens
|
|
425
|
+
|
|
426
|
+
def interrupt_running(self) -> None:
|
|
427
|
+
running_states = [state for state in self._state_by_id.values() if state.status == "running"]
|
|
428
|
+
if not running_states:
|
|
429
|
+
self._state_by_id.clear()
|
|
430
|
+
self._tool_text_refs.clear()
|
|
431
|
+
return
|
|
432
|
+
now = time.monotonic()
|
|
433
|
+
for state in running_states:
|
|
434
|
+
text_ref = self._tool_text_refs.get(state.tool_call_id)
|
|
435
|
+
if text_ref is None:
|
|
436
|
+
continue
|
|
437
|
+
if state.is_task:
|
|
438
|
+
elapsed = _format_duration(now - state.started_at_monotonic)
|
|
439
|
+
token_text = self._task_token_text(state)
|
|
440
|
+
self._overwrite_text_ref(
|
|
441
|
+
text_ref,
|
|
442
|
+
f"⏹ {self._task_title(state)} · 已中断 · {elapsed} · {token_text}",
|
|
443
|
+
"bold yellow",
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
summary_suffix = (
|
|
447
|
+
f" {state.args_summary}" if state.args_summary and state.args_summary != "hidden" else ""
|
|
448
|
+
)
|
|
449
|
+
self._overwrite_text_ref(
|
|
450
|
+
text_ref,
|
|
451
|
+
f"⏹ {state.tool_name}{summary_suffix} 已中断",
|
|
452
|
+
"bold yellow",
|
|
453
|
+
)
|
|
454
|
+
self._state_by_id.clear()
|
|
455
|
+
self._tool_text_refs.clear()
|
|
456
|
+
|
|
457
|
+
@staticmethod
|
|
458
|
+
def _overwrite_text_ref(text_ref: Text, content: str, style: str) -> None:
|
|
459
|
+
text_ref.truncate(0)
|
|
460
|
+
text_ref.append(" ")
|
|
461
|
+
text_ref.append(content, style=style)
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def _summary_suffix(summary: str) -> str:
|
|
465
|
+
return f" {summary}" if summary and summary != "hidden" else ""
|
|
466
|
+
|
|
467
|
+
def render_call(self, tool_name: str, args: dict[str, Any], tool_call_id: str) -> Text:
|
|
468
|
+
hide_args = _should_hide_tool_args(tool_name)
|
|
469
|
+
summary = summarize_tool_args(tool_name, args)
|
|
470
|
+
now = time.monotonic()
|
|
471
|
+
is_task = tool_name.lower() == "task"
|
|
472
|
+
|
|
473
|
+
subagent_name = ""
|
|
474
|
+
task_desc = ""
|
|
475
|
+
subagent_source_prefix = ""
|
|
476
|
+
baseline_source_tokens = 0
|
|
477
|
+
if is_task:
|
|
478
|
+
subagent_name, task_desc = _extract_task_identity(args)
|
|
479
|
+
subagent_source_prefix = f"subagent:{subagent_name}:{tool_call_id}" if subagent_name else ""
|
|
480
|
+
baseline_source_tokens = self._latest_tokens_by_source_prefix.get(subagent_source_prefix, 0)
|
|
481
|
+
|
|
482
|
+
state = ToolRunState(
|
|
483
|
+
tool_call_id=tool_call_id,
|
|
484
|
+
tool_name=tool_name,
|
|
485
|
+
args=args,
|
|
486
|
+
args_summary="hidden" if hide_args else summary,
|
|
487
|
+
status="running",
|
|
488
|
+
started_at_monotonic=now,
|
|
489
|
+
is_task=is_task,
|
|
490
|
+
subagent_name=subagent_name,
|
|
491
|
+
task_desc=task_desc,
|
|
492
|
+
subagent_source_prefix=subagent_source_prefix,
|
|
493
|
+
baseline_source_tokens=baseline_source_tokens,
|
|
494
|
+
task_tokens=0,
|
|
495
|
+
last_progress_render_ts=now,
|
|
496
|
+
last_progress_tokens=0 if is_task else self._latest_total_tokens,
|
|
497
|
+
)
|
|
498
|
+
self._state_by_id[tool_call_id] = state
|
|
499
|
+
|
|
500
|
+
text_ref = Text()
|
|
501
|
+
self._tool_text_refs[tool_call_id] = text_ref
|
|
502
|
+
if state.is_task:
|
|
503
|
+
line_content = f"{TOOL_RUNNING_PREFIX} {self._task_title(state)} · 运行中"
|
|
504
|
+
else:
|
|
505
|
+
line_content = f"{TOOL_RUNNING_PREFIX} {tool_name}{self._summary_suffix(state.args_summary)}"
|
|
506
|
+
self._overwrite_text_ref(text_ref, line_content, TOOL_RUNNING_STYLE)
|
|
507
|
+
return text_ref
|
|
508
|
+
|
|
509
|
+
def render_result(
|
|
510
|
+
self,
|
|
511
|
+
tool_name: str,
|
|
512
|
+
tool_call_id: str,
|
|
513
|
+
result: Any,
|
|
514
|
+
is_error: bool,
|
|
515
|
+
) -> None:
|
|
516
|
+
state = self._state_by_id.pop(tool_call_id, None)
|
|
517
|
+
text_ref = self._tool_text_refs.pop(tool_call_id, None)
|
|
518
|
+
if text_ref is None:
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
if state and state.is_task:
|
|
522
|
+
elapsed = _format_duration(time.monotonic() - state.started_at_monotonic)
|
|
523
|
+
token_text = self._task_token_text(state)
|
|
524
|
+
if is_error:
|
|
525
|
+
self._overwrite_text_ref(
|
|
526
|
+
text_ref,
|
|
527
|
+
f"{TOOL_ERROR_PREFIX} {self._task_title(state)} · 失败 · {elapsed} · {token_text}",
|
|
528
|
+
TOOL_ERROR_STYLE,
|
|
529
|
+
)
|
|
530
|
+
return
|
|
531
|
+
self._overwrite_text_ref(
|
|
532
|
+
text_ref,
|
|
533
|
+
f"{TOOL_SUCCESS_PREFIX} {self._task_title(state)} · 完成 · {elapsed} · {token_text}",
|
|
534
|
+
TOOL_SUCCESS_STYLE,
|
|
535
|
+
)
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
args_summary = state.args_summary if state else ""
|
|
539
|
+
summary_suffix = self._summary_suffix(args_summary)
|
|
540
|
+
if is_error:
|
|
541
|
+
self._overwrite_text_ref(
|
|
542
|
+
text_ref,
|
|
543
|
+
f"{TOOL_ERROR_PREFIX} {tool_name}{summary_suffix}",
|
|
544
|
+
TOOL_ERROR_STYLE,
|
|
545
|
+
)
|
|
546
|
+
return
|
|
547
|
+
self._overwrite_text_ref(
|
|
548
|
+
text_ref,
|
|
549
|
+
f"{TOOL_SUCCESS_PREFIX} {tool_name}{summary_suffix}",
|
|
550
|
+
TOOL_SUCCESS_STYLE,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def renderable(self, *, only_tasks: bool = True) -> RenderableType | None:
|
|
554
|
+
running_states = self._running_states(only_tasks=only_tasks)
|
|
555
|
+
if not running_states:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
now = time.monotonic()
|
|
559
|
+
lines = [
|
|
560
|
+
self._running_line(
|
|
561
|
+
state,
|
|
562
|
+
_PULSE_GLYPHS[(self._frame + idx) % len(_PULSE_GLYPHS)],
|
|
563
|
+
now,
|
|
564
|
+
)
|
|
565
|
+
for idx, state in enumerate(running_states)
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
composed = Text()
|
|
569
|
+
use_fancy_effect = self._should_use_fancy_effect(lines)
|
|
570
|
+
phase = self._frame * _SWEEP_SPEED_MULTIPLIER
|
|
571
|
+
for idx, line in enumerate(lines):
|
|
572
|
+
if use_fancy_effect:
|
|
573
|
+
composed.append_text(_sweep_gradient_text(line, frame=phase + idx * 5))
|
|
574
|
+
else:
|
|
575
|
+
composed.append(line, style="dim")
|
|
576
|
+
if idx < len(lines) - 1:
|
|
577
|
+
composed.append("\n")
|
|
578
|
+
|
|
579
|
+
return Panel(
|
|
580
|
+
composed,
|
|
581
|
+
title="⏳ Loading",
|
|
582
|
+
border_style="blue",
|
|
583
|
+
padding=(0, 1),
|
|
584
|
+
)
|