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,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
+ )