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,867 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from comate_agent_sdk.agent.events import (
10
+ PlanApprovalRequiredEvent,
11
+ PreCompactEvent,
12
+ SessionInitEvent,
13
+ StopEvent,
14
+ SubagentProgressEvent,
15
+ SubagentStartEvent,
16
+ SubagentStopEvent,
17
+ SubagentToolCallEvent,
18
+ SubagentToolResultEvent,
19
+ TextEvent,
20
+ ThinkingEvent,
21
+ TodoUpdatedEvent,
22
+ ToolCallEvent,
23
+ ToolResultEvent,
24
+ UsageDeltaEvent,
25
+ UserQuestionEvent,
26
+ )
27
+
28
+ from rich.console import RenderableType
29
+ from rich.text import Text
30
+
31
+ from comate_cli.terminal_agent.models import HistoryEntry, LoadingState
32
+ from comate_cli.terminal_agent.tool_view import summarize_tool_args
33
+ from comate_cli.terminal_agent.env_utils import read_env_int
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _DEFAULT_TOOL_ERROR_SUMMARY_MAX_LEN = 160
38
+ _DEFAULT_TOOL_PANEL_MAX_LINES = 4
39
+ _DEFAULT_TODO_PANEL_MAX_LINES = 6
40
+
41
+
42
+ def _truncate(content: str, max_len: int = 120) -> str:
43
+ if len(content) <= max_len:
44
+ return content
45
+ return f"{content[:max_len]}..."
46
+
47
+
48
+ def _format_duration(seconds: float) -> str:
49
+ elapsed = max(seconds, 0.0)
50
+ if elapsed < 60:
51
+ return f"{elapsed:.1f}s"
52
+ minutes = int(elapsed // 60)
53
+ remaining_seconds = int(elapsed % 60)
54
+ if minutes < 60:
55
+ return f"{minutes}m{remaining_seconds:02d}s"
56
+ hours = minutes // 60
57
+ remaining_minutes = minutes % 60
58
+ return f"{hours}h{remaining_minutes:02d}m"
59
+
60
+
61
+ def _format_tokens(token_count: int) -> str:
62
+ tokens = max(int(token_count), 0)
63
+ if tokens < 1_000:
64
+ return f"{tokens} tok"
65
+ compact = f"{tokens / 1_000:.1f}".rstrip("0").rstrip(".")
66
+ return f"{compact}k tok"
67
+
68
+
69
+ def _extract_task_title(args: dict[str, Any]) -> str:
70
+ description = str(args.get("description", "")).strip()
71
+ if description:
72
+ return description
73
+
74
+ subagent_name = str(args.get("subagent_type", "")).strip() or "Task"
75
+ return subagent_name
76
+
77
+
78
+ def _one_line(text: str) -> str:
79
+ return " ".join(str(text).split())
80
+
81
+
82
+ def _tool_signature(tool_name: str, args_summary: str) -> str:
83
+ normalized = args_summary.strip()
84
+ if normalized:
85
+ return f"{tool_name}({normalized})"
86
+ return f"{tool_name}()"
87
+
88
+
89
+ @dataclass
90
+ class _SubagentTool:
91
+ """Subagent 内部的工具调用"""
92
+ tool_name: str
93
+ args_summary: str
94
+ started_at_monotonic: float
95
+ status: Literal["running", "completed", "error"] = "running"
96
+ duration_ms: float = 0.0
97
+
98
+
99
+ @dataclass
100
+ class _RunningTool:
101
+ tool_name: str
102
+ title: str
103
+ started_at_monotonic: float
104
+ is_task: bool
105
+ args_summary: str
106
+ progress_tokens: int = 0
107
+ subagent_name: str = ""
108
+ subagent_status: str = ""
109
+ subagent_description: str = ""
110
+ nested_tools: list[tuple[str, _SubagentTool]] = field(default_factory=list)
111
+ show_init: bool = False
112
+
113
+
114
+ class EventRenderer:
115
+ """Convert SDK events to lightweight terminal state for prompt_toolkit UI."""
116
+
117
+ def __init__(self, project_root: Path | None = None) -> None:
118
+ self._history: list[HistoryEntry] = []
119
+ self._running_tools: dict[str, _RunningTool] = {}
120
+ self._thinking_content: str = ""
121
+ self._assistant_buffer = ""
122
+ self._loading_state: LoadingState = LoadingState.idle()
123
+ self._current_todos: list[dict[str, Any]] = []
124
+ self._todo_started_at_monotonic: float | None = None
125
+ self._project_root = project_root
126
+ self._tool_error_summary_max_len = read_env_int(
127
+ "AGENT_SDK_TUI_TOOL_ERROR_SUMMARY_MAX_LEN",
128
+ _DEFAULT_TOOL_ERROR_SUMMARY_MAX_LEN,
129
+ )
130
+ self._tool_panel_max_lines = read_env_int(
131
+ "AGENT_SDK_TUI_TOOL_PANEL_MAX_LINES",
132
+ _DEFAULT_TOOL_PANEL_MAX_LINES,
133
+ )
134
+ self._todo_panel_max_lines = read_env_int(
135
+ "AGENT_SDK_TUI_TODO_PANEL_MAX_LINES",
136
+ _DEFAULT_TODO_PANEL_MAX_LINES,
137
+ )
138
+ self._diff_max_lines = read_env_int(
139
+ "AGENT_SDK_TUI_DIFF_MAX_LINES",
140
+ 50,
141
+ )
142
+ self._latest_diff_lines: list[str] | None = None
143
+
144
+ def start_turn(self) -> None:
145
+ self._flush_assistant_segment()
146
+ self._thinking_content = ""
147
+ self._rebuild_loading_line()
148
+
149
+ def seed_user_message(self, content: str) -> None:
150
+ normalized = content.strip()
151
+ if not normalized:
152
+ return
153
+ self._flush_assistant_segment()
154
+ self._history.append(HistoryEntry(entry_type="user", text=normalized))
155
+
156
+ def close(self) -> None:
157
+ return
158
+
159
+ def finalize_turn(self) -> None:
160
+ self._flush_assistant_segment()
161
+ self._rebuild_loading_line()
162
+
163
+ def tick_progress(self) -> None:
164
+ self._rebuild_loading_line()
165
+
166
+ def refresh_loading_animation(self) -> None:
167
+ self._rebuild_loading_line()
168
+
169
+ def interrupt_turn(self) -> None:
170
+ if self._running_tools:
171
+ self._history.append(
172
+ HistoryEntry(
173
+ entry_type="system",
174
+ text=f"已中断当前任务({len(self._running_tools)} 个运行中工具)",
175
+ severity="warning",
176
+ )
177
+ )
178
+ self._running_tools.clear()
179
+ self._flush_assistant_segment()
180
+ self._thinking_content = ""
181
+ self._rebuild_loading_line()
182
+
183
+ def history_entries(self) -> list[HistoryEntry]:
184
+ return list(self._history)
185
+
186
+ def reset_history_view(self) -> None:
187
+ """重置 history 视图状态(用于会话切换后的重新加载)。"""
188
+ self._history = []
189
+ self._running_tools.clear()
190
+ self._thinking_content = ""
191
+ self._assistant_buffer = ""
192
+ self._loading_state = LoadingState.idle()
193
+ self._current_todos = []
194
+ self._todo_started_at_monotonic = None
195
+ self._latest_diff_lines = None
196
+
197
+ @property
198
+ def latest_diff_lines(self) -> list[str] | None:
199
+ return self._latest_diff_lines
200
+
201
+ def has_running_tools(self) -> bool:
202
+ return bool(self._running_tools)
203
+
204
+ def has_running_subagents(self) -> bool:
205
+ """Return whether any running tool is a Task(subagent) tool."""
206
+ return any(state.is_task for state in self._running_tools.values())
207
+
208
+ def compute_required_tool_panel_lines(self) -> int:
209
+ """计算显示所有 running tools 所需的最小行数。"""
210
+ if not self._running_tools:
211
+ return 0
212
+ total_lines = 0
213
+ for tool_call_id, state in self._running_tools.items():
214
+ if state.is_task:
215
+ total_lines += 1 # 主标题行
216
+ # 嵌套工具(最多 3 个)
217
+ total_lines += min(len(state.nested_tools), 3)
218
+ # init 行(仅在创建后、首个有效子事件前显示)
219
+ if state.show_init:
220
+ total_lines += 1
221
+ else:
222
+ total_lines += 1
223
+ return total_lines
224
+
225
+ def has_active_todos(self) -> bool:
226
+ return bool(self._current_todos)
227
+
228
+ def loading_state(self) -> LoadingState:
229
+ """返回语义化的 loading 状态,用于 UI 层决定渲染策略。"""
230
+ return self._loading_state
231
+
232
+ def loading_line(self) -> str:
233
+ """兼容旧接口,返回 loading 状态的文本内容。"""
234
+ return self._loading_state.text
235
+
236
+ def append_system_message(
237
+ self,
238
+ content: str,
239
+ *,
240
+ severity: Literal["info", "warning", "error"] = "info",
241
+ ) -> None:
242
+ normalized = content.strip()
243
+ if not normalized:
244
+ return
245
+ self._flush_assistant_segment()
246
+ self._history.append(
247
+ HistoryEntry(entry_type="system", text=normalized, severity=severity)
248
+ )
249
+
250
+ def append_elapsed_message(self, content: str) -> None:
251
+ """追加一条灰色无前缀的计时统计行到 history scrollback."""
252
+ normalized = content.strip()
253
+ if not normalized:
254
+ return
255
+ self._flush_assistant_segment()
256
+ self._history.append(HistoryEntry(entry_type="elapsed", text=normalized))
257
+
258
+ def append_assistant_message(self, content: str) -> None:
259
+ normalized = content.strip()
260
+ if not normalized:
261
+ return
262
+ self._flush_assistant_segment()
263
+ self._history.append(HistoryEntry(entry_type="assistant", text=normalized))
264
+
265
+ def tool_panel_entries(self, *, max_lines: int | None = None) -> list[tuple[int, str]]:
266
+ """Return panel entries for running tools.
267
+
268
+ Each entry is a tuple: (indent_level, text_without_dot_prefix).
269
+ indent_level == 0 means a primary tool line; >0 means nested status.
270
+ indent_level < 0 means a meta line (no dot prefix).
271
+ """
272
+ limit = max_lines if max_lines is not None else self._tool_panel_max_lines
273
+ normalized_limit = max(1, int(limit))
274
+ if not self._running_tools:
275
+ return []
276
+
277
+ now = time.monotonic()
278
+ entries: list[tuple[int, str]] = []
279
+ tool_items = list(self._running_tools.items())
280
+ for idx, (tool_call_id, state) in enumerate(tool_items):
281
+ elapsed = _format_duration(now - state.started_at_monotonic)
282
+ lines_to_add = 1
283
+ if state.is_task:
284
+ tokens_suffix = (
285
+ f" · {_format_tokens(state.progress_tokens)}"
286
+ if state.progress_tokens > 0
287
+ else ""
288
+ )
289
+ # 主标题行 + 嵌套工具(最多 3 个)+ 状态行(如果有状态)
290
+ lines_to_add = 1 + min(len(state.nested_tools), 3)
291
+ if state.show_init:
292
+ lines_to_add += 1
293
+ else:
294
+ lines_to_add = 1
295
+
296
+ if len(entries) + lines_to_add > normalized_limit:
297
+ remaining_tools = len(tool_items) - idx
298
+ entries.append((-1, f"… (+{remaining_tools})"))
299
+ break
300
+
301
+ if state.is_task:
302
+ tokens_suffix = (
303
+ f" · {_format_tokens(state.progress_tokens)}"
304
+ if state.progress_tokens > 0
305
+ else ""
306
+ )
307
+ # 格式:SubagentName(描述)
308
+ subagent_name = state.subagent_name or "Task"
309
+ description = state.subagent_description or state.title
310
+ title = f"{subagent_name}({description})"
311
+ tool_count = len(state.nested_tools)
312
+ tool_count_suffix = f" · +{tool_count} tool uses" if tool_count > 0 else ""
313
+ entries.append((0, f"{title} · {elapsed}{tool_count_suffix}{tokens_suffix}"))
314
+
315
+ # 嵌套工具调用(最多显示最近 3 个)
316
+ for child_id, child_tool in state.nested_tools[-3:]:
317
+ signature = _tool_signature(child_tool.tool_name, child_tool.args_summary)
318
+ if child_tool.status == "running":
319
+ entries.append((1, f"|_ {signature}"))
320
+ else:
321
+ icon = "✓" if child_tool.status == "completed" else "✗"
322
+ entries.append((1, f"|_ {icon} {signature}"))
323
+
324
+ if state.show_init:
325
+ entries.append((1, "|_ init"))
326
+ else:
327
+ signature = _tool_signature(state.tool_name, state.args_summary)
328
+ entries.append((0, f"{signature} · {elapsed}"))
329
+
330
+ return entries[:normalized_limit]
331
+
332
+ def todo_panel_lines(self, *, max_lines: int | None = None) -> list[str]:
333
+ limit = max_lines if max_lines is not None else self._todo_panel_max_lines
334
+ normalized_limit = max(1, int(limit))
335
+ todos = list(self._current_todos)
336
+ if not todos:
337
+ return []
338
+
339
+ total = len(todos)
340
+ completed = sum(1 for item in todos if item.get("status") == "completed")
341
+ in_progress = sum(1 for item in todos if item.get("status") == "in_progress")
342
+ pending = sum(1 for item in todos if item.get("status") == "pending")
343
+ header = f"📋 Todo ({completed}/{total} completed · {in_progress} in_progress · {pending} pending)"
344
+
345
+ lines: list[str] = [header]
346
+ for item in todos:
347
+ content = str(item.get("content", "")).strip()
348
+ if not content:
349
+ continue
350
+ status = str(item.get("status", "pending")).strip().lower()
351
+ if status == "completed":
352
+ lines.append(f" ✓ {content}")
353
+ elif status == "in_progress":
354
+ lines.append(f" ◉ {content} ⏳")
355
+ else:
356
+ lines.append(f" ○ {content}")
357
+
358
+ if len(lines) <= normalized_limit:
359
+ return lines
360
+
361
+ clipped = lines[: normalized_limit - 1]
362
+ clipped.append(f" … (+{len(lines) - (normalized_limit - 1)})")
363
+ return clipped
364
+
365
+ def _flush_assistant_segment(self) -> None:
366
+ if not self._assistant_buffer:
367
+ return
368
+ self._history.append(HistoryEntry(entry_type="assistant", text=self._assistant_buffer))
369
+ self._assistant_buffer = ""
370
+
371
+ def _append_assistant_text(self, text: str) -> None:
372
+ self._assistant_buffer += text
373
+
374
+ def _append_tool_call(self, tool_name: str, args: dict[str, Any], tool_call_id: str) -> None:
375
+ self._running_tools[tool_call_id] = self._make_running_tool(tool_name, args)
376
+
377
+ def append_static_tool_result(
378
+ self,
379
+ signature: str,
380
+ is_error: bool = False,
381
+ diff_lines: list[str] | None = None,
382
+ ) -> None:
383
+ """Append a tool result to history as a static entry (no timer).
384
+
385
+ Args:
386
+ signature: 工具签名,例如 "Read(path=xxx)"
387
+ is_error: 是否为错误结果
388
+ diff_lines: optional diff lines for Edit/MultiEdit
389
+ """
390
+ sev: Literal["info", "warning", "error"] = "error" if is_error else "info"
391
+ if not is_error and diff_lines and len(diff_lines) > 0:
392
+ self._latest_diff_lines = diff_lines
393
+ text_obj = Text(signature)
394
+ text_obj.append("\n")
395
+ text_obj.append(self._render_diff_text(diff_lines, max_lines=self._diff_max_lines))
396
+ self._history.append(
397
+ HistoryEntry(entry_type="tool_result", text=text_obj, severity="info")
398
+ )
399
+ return
400
+ self._history.append(
401
+ HistoryEntry(
402
+ entry_type="tool_result",
403
+ text=signature,
404
+ severity=sev,
405
+ )
406
+ )
407
+
408
+ def _make_running_tool(self, tool_name: str, args: dict[str, Any]) -> _RunningTool:
409
+ """Create a _RunningTool from tool name and args dict."""
410
+ title = tool_name
411
+ is_task = tool_name.lower() == "task"
412
+ summary = summarize_tool_args(tool_name, args, self._project_root).strip()
413
+ subagent_name = ""
414
+ subagent_description = ""
415
+ if is_task:
416
+ title = _extract_task_title(args)
417
+ summary = ""
418
+ subagent_name = str(args.get("subagent_type", "")).strip() or "Task"
419
+ subagent_description = str(args.get("description", "")).strip()
420
+
421
+ return _RunningTool(
422
+ tool_name=tool_name,
423
+ title=title,
424
+ started_at_monotonic=time.monotonic(),
425
+ is_task=is_task,
426
+ args_summary=summary,
427
+ show_init=is_task,
428
+ subagent_name=subagent_name,
429
+ subagent_description=subagent_description,
430
+ )
431
+
432
+ def _append_tool_result(
433
+ self,
434
+ tool_name: str,
435
+ tool_call_id: str,
436
+ is_error: bool,
437
+ result: Any,
438
+ metadata: dict[str, Any] | None = None,
439
+ ) -> None:
440
+ sev: Literal["info", "warning", "error"] = "error" if is_error else "info"
441
+ state = self._running_tools.pop(tool_call_id, None)
442
+ if state is None:
443
+ signature = _tool_signature(tool_name, "")
444
+ error_suffix = ""
445
+ if is_error:
446
+ error_suffix = f" · {_truncate(_one_line(result), self._tool_error_summary_max_len)}"
447
+ self._history.append(HistoryEntry(entry_type="tool_result", text=f"{signature}{error_suffix}", severity=sev))
448
+ return
449
+
450
+ if state.is_task:
451
+ subagent_name = state.subagent_name or "Task"
452
+ description = state.subagent_description or state.title
453
+ if description and description != subagent_name:
454
+ task_title = f"{subagent_name}({description})"
455
+ else:
456
+ task_title = subagent_name
457
+ tool_count = len(state.nested_tools)
458
+ tool_count_suffix = f" · +{tool_count} tool uses" if tool_count > 0 else ""
459
+ tokens_suffix = (
460
+ f" · {_format_tokens(state.progress_tokens)}"
461
+ if state.progress_tokens > 0
462
+ else ""
463
+ )
464
+ base = f"{task_title}{tool_count_suffix}{tokens_suffix}"
465
+ else:
466
+ display_name = "Update" if state.tool_name in ("Edit", "MultiEdit") else state.tool_name
467
+ summary = state.args_summary
468
+ # Append line range for Edit/MultiEdit
469
+ if display_name == "Update" and metadata:
470
+ sl = metadata.get("start_line")
471
+ el = metadata.get("end_line")
472
+ if isinstance(sl, int) and sl > 0:
473
+ line_suffix = f":L{sl}-L{el}" if isinstance(el, int) and el > sl else f":L{sl}"
474
+ summary = f"{summary}{line_suffix}" if summary else line_suffix
475
+ signature = _tool_signature(display_name, summary)
476
+ base = f"{signature}"
477
+
478
+ error_suffix = ""
479
+ if is_error:
480
+ error_suffix = f" · {_truncate(_one_line(result), self._tool_error_summary_max_len)}"
481
+
482
+ # Render diff for Edit/MultiEdit if metadata contains diff lines
483
+ if not is_error and metadata:
484
+ diff_lines = metadata.get("diff")
485
+ if isinstance(diff_lines, list) and len(diff_lines) > 0:
486
+ self._latest_diff_lines = diff_lines
487
+ text_obj = Text(f"{base}{error_suffix}")
488
+ text_obj.append("\n")
489
+ text_obj.append(self._render_diff_text(diff_lines, max_lines=self._diff_max_lines))
490
+ self._history.append(
491
+ HistoryEntry(entry_type="tool_result", text=text_obj, severity="info")
492
+ )
493
+ return
494
+
495
+ self._history.append(HistoryEntry(entry_type="tool_result", text=f"{base}{error_suffix}", severity=sev))
496
+
497
+ @staticmethod
498
+ def _render_diff_text(diff_lines: list[str], max_lines: int = 50) -> Text:
499
+ """Render diff lines as colored Rich Text with line numbers."""
500
+ import re
501
+ import shutil
502
+
503
+ result = Text()
504
+ total_lines = len(diff_lines)
505
+ displayed_lines = diff_lines[:max_lines] if max_lines > 0 else diff_lines
506
+
507
+ # Get terminal width for padding
508
+ try:
509
+ terminal_width = shutil.get_terminal_size().columns
510
+ except Exception:
511
+ terminal_width = 120
512
+
513
+ old_line = 0
514
+ new_line = 0
515
+ hunk_pattern = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
516
+
517
+ for line in displayed_lines:
518
+ if line.startswith("---") or line.startswith("+++"):
519
+ result.append(line, style="dim")
520
+ result.append("\n")
521
+ continue
522
+
523
+ m = hunk_pattern.match(line)
524
+ if m:
525
+ old_line = int(m.group(1))
526
+ new_line = int(m.group(2))
527
+ result.append(line, style="cyan")
528
+ result.append("\n")
529
+ continue
530
+
531
+ if line.startswith("-"):
532
+ result.append(f"{old_line:>4} ", style="#ffffff on #4a0202")
533
+ result.append(line, style="#ffffff on #4a0202")
534
+ # Calculate padding to fill the entire line
535
+ content_width = 5 + len(line) # line number (4) + space (1) + content
536
+ padding = max(0, terminal_width - content_width)
537
+ result.append(" " * padding, style="#ffffff on #4a0202")
538
+ result.append("\n")
539
+ old_line += 1
540
+ elif line.startswith("+"):
541
+ result.append(f"{new_line:>4} ", style="#c6c6c6 on #2e502e")
542
+ result.append(line, style="#ffffff on #2e502e")
543
+ # Calculate padding to fill the entire line
544
+ content_width = 5 + len(line) # line number (4) + space (1) + content
545
+ padding = max(0, terminal_width - content_width)
546
+ result.append(" " * padding, style="#ffffff on #2e502e")
547
+ result.append("\n")
548
+ new_line += 1
549
+ else:
550
+ result.append(f"{new_line:>4} ", style="dim")
551
+ result.append(line, style="dim")
552
+ result.append("\n")
553
+ old_line += 1
554
+ new_line += 1
555
+
556
+ if max_lines > 0 and total_lines > max_lines:
557
+ result.append(
558
+ f"... ({total_lines - max_lines} more lines, Ctrl+O to expand)",
559
+ style="dim italic",
560
+ )
561
+
562
+ return result
563
+
564
+ def _rebuild_loading_line(self) -> None:
565
+ if self._thinking_content:
566
+ text = f"🤔 {_truncate(self._thinking_content, 90)}"
567
+ self._loading_state = LoadingState.thinking(
568
+ text=text,
569
+ content=self._thinking_content,
570
+ )
571
+ return
572
+
573
+ self._loading_state = LoadingState.idle()
574
+
575
+ def _append_questions(self, questions: list[dict[str, Any]]) -> None:
576
+ if not questions:
577
+ return
578
+ self._history.append(
579
+ HistoryEntry(
580
+ entry_type="tool_result",
581
+ text=f"需要输入:共有 {len(questions)} 个问题待回答。",
582
+ )
583
+ )
584
+ for idx, question in enumerate(questions, 1):
585
+ question_text = str(question.get("question", "")).strip()
586
+ header = str(question.get("header", f"问题{idx}")).strip()
587
+ options = question.get("options", [])
588
+ labels: list[str] = []
589
+ if isinstance(options, list):
590
+ for option in options[:3]:
591
+ if not isinstance(option, dict):
592
+ continue
593
+ label = str(option.get("label", "")).strip()
594
+ if label:
595
+ labels.append(label)
596
+ choice_preview = f"(可选: {' / '.join(labels)})" if labels else ""
597
+ self._history.append(
598
+ HistoryEntry(
599
+ entry_type="tool_result",
600
+ text=f" {idx}. {header}: {question_text} {choice_preview}".strip(),
601
+ )
602
+ )
603
+
604
+ def _update_todos(self, todos: list[dict[str, Any]]) -> None:
605
+ """更新当前 todo 列表状态。
606
+
607
+ Todo panel is rendered in prompt_toolkit layout and should not spam scrollback.
608
+ When all todos transition to completed, append a single summary line to history.
609
+ """
610
+ normalized = list(todos) if todos else []
611
+ if not normalized:
612
+ self._current_todos = []
613
+ self._todo_started_at_monotonic = None
614
+ return
615
+
616
+ all_completed = all(str(item.get("status", "")).strip().lower() == "completed" for item in normalized)
617
+ if not all_completed:
618
+ if not self._current_todos:
619
+ self._todo_started_at_monotonic = time.monotonic()
620
+ self._current_todos = normalized
621
+ return
622
+
623
+ # All completed: hide panel and write a summary entry once.
624
+ started = self._todo_started_at_monotonic
625
+ elapsed_suffix = ""
626
+ if started is not None:
627
+ elapsed_suffix = f" · {_format_duration(time.monotonic() - started)}"
628
+ total = len(normalized)
629
+ self._history.append(
630
+ HistoryEntry(
631
+ entry_type="tool_result",
632
+ text=f"todo {total}/{total} completed{elapsed_suffix}",
633
+ severity="info",
634
+ )
635
+ )
636
+ self._current_todos = []
637
+ self._todo_started_at_monotonic = None
638
+
639
+ def todo_renderable(self) -> RenderableType | None:
640
+ """将当前 todo 列表渲染为 Rich 组件。
641
+
642
+ Returns:
643
+ Rich RenderableType 或 None(如果没有 todo)
644
+ """
645
+ if not self._current_todos:
646
+ return None
647
+
648
+ todos = self._current_todos
649
+ total = len(todos)
650
+ completed = sum(1 for t in todos if t.get("status") == "completed")
651
+ in_progress = sum(1 for t in todos if t.get("status") == "in_progress")
652
+
653
+ # 构建 Rich Text 组件
654
+ result = Text()
655
+
656
+ # 标题
657
+ result.append(f"📋 任务列表 ({completed}/{total} 完成)\n")
658
+
659
+ # 按状态分组:进行中 -> 待处理 -> 已完成
660
+ open_items = [t for t in todos if t.get("status") != "completed"]
661
+ done_items = [t for t in todos if t.get("status") == "completed"]
662
+
663
+ for item in open_items:
664
+ content = str(item.get("content", "")).strip()
665
+ if not content:
666
+ continue
667
+ status = item.get("status", "pending")
668
+ if status == "in_progress":
669
+ # 进行中:黄色 + 进度图标
670
+ result.append(f" ◉ ")
671
+ result.append(f"{content}", style="yellow")
672
+ result.append(" ⏳\n")
673
+ else:
674
+ # 待处理:默认颜色
675
+ result.append(f" ○ {content}\n")
676
+
677
+ if open_items and done_items:
678
+ result.append(f" {'─' * 15}\n")
679
+
680
+ for item in done_items:
681
+ content = str(item.get("content", "")).strip()
682
+ if not content:
683
+ continue
684
+ # 已完成:绿色 + 删除线
685
+ result.append(f" ✓ ")
686
+ result.append(f"{content}", style="green strike")
687
+ result.append("\n")
688
+
689
+ # 移除末尾的换行
690
+ if result.plain.endswith("\n"):
691
+ result = result[:-1]
692
+
693
+ return result
694
+
695
+ def todo_lines(self) -> list[str]:
696
+ """返回当前 todo 列表的行列表(用于测试)。"""
697
+ return self.todo_panel_lines(max_lines=6)
698
+
699
+ def handle_event(self, event: Any) -> tuple[bool, list[dict[str, Any]] | None]:
700
+ if not isinstance(event, TextEvent):
701
+ self._flush_assistant_segment()
702
+
703
+ match event:
704
+ case SessionInitEvent(session_id=_):
705
+ pass
706
+ case ThinkingEvent(content=thinking):
707
+ self._thinking_content = thinking
708
+ self._history.append(HistoryEntry(entry_type="thinking", text=thinking))
709
+ case PreCompactEvent(current_tokens=_, threshold=_, trigger=_):
710
+ pass
711
+ case ToolCallEvent(tool=tool_name, args=arguments, tool_call_id=tool_call_id):
712
+ args_dict = arguments if isinstance(arguments, dict) else {"_raw": str(arguments)}
713
+ self._thinking_content = ""
714
+ # TodoWrite/AskUserQuestion/ExitPlanMode: skip normal tool tracking.
715
+ if tool_name.lower() in ("todowrite", "askuserquestion", "exitplanmode"):
716
+ self._rebuild_loading_line()
717
+ return (False, None)
718
+ self._append_tool_call(tool_name, args_dict, tool_call_id)
719
+ case ToolResultEvent(tool=tool_name, result=result, tool_call_id=tool_call_id, is_error=is_error, metadata=metadata):
720
+ if tool_name.lower() == "todowrite" and not is_error:
721
+ # Successful TodoWrite should not spam scrollback.
722
+ self._rebuild_loading_line()
723
+ return (False, None)
724
+ if tool_name.lower() in ("askuserquestion", "exitplanmode") and not is_error:
725
+ # Success is already visible via UserQuestionEvent / PlanApprovalRequiredEvent.
726
+ self._running_tools.pop(tool_call_id, None)
727
+ self._rebuild_loading_line()
728
+ return (False, None)
729
+ self._append_tool_result(
730
+ tool_name=tool_name,
731
+ tool_call_id=tool_call_id,
732
+ is_error=is_error,
733
+ result=result,
734
+ metadata=metadata,
735
+ )
736
+ case UsageDeltaEvent(
737
+ source=_,
738
+ model=_,
739
+ level=_,
740
+ delta_prompt_tokens=_,
741
+ delta_prompt_cached_tokens=_,
742
+ delta_completion_tokens=_,
743
+ delta_total_tokens=_,
744
+ ):
745
+ pass
746
+ case SubagentStartEvent(tool_call_id=_, subagent_name=_, description=_):
747
+ pass
748
+ case SubagentProgressEvent(
749
+ tool_call_id=tool_call_id,
750
+ subagent_name=subagent_name,
751
+ description=description,
752
+ status=status,
753
+ elapsed_ms=elapsed_ms,
754
+ tokens=tokens,
755
+ ):
756
+ state = self._running_tools.get(tool_call_id)
757
+ if state is not None:
758
+ if tokens is not None:
759
+ state.progress_tokens = max(int(tokens), 0)
760
+ if elapsed_ms is not None:
761
+ normalized = max(float(elapsed_ms), 0.0)
762
+ state.started_at_monotonic = time.monotonic() - (normalized / 1000)
763
+ # Subagent-specific status for task tool panel.
764
+ state.subagent_name = str(subagent_name or "").strip()
765
+ state.subagent_status = str(status or "").strip()
766
+ state.subagent_description = str(description or "").strip()
767
+ # 首个有效子事件后移除 init 占位。
768
+ progress_status = str(status or "").strip().lower()
769
+ has_activity = bool(tokens and int(tokens) > 0) or bool(
770
+ elapsed_ms and float(elapsed_ms) > 0.0
771
+ )
772
+ if has_activity or progress_status in {"completed", "error", "timeout", "cancelled"}:
773
+ state.show_init = False
774
+ case SubagentStopEvent(tool_call_id=_, subagent_name=_, status=_, duration_ms=_, error=_):
775
+ pass
776
+ case SubagentToolCallEvent(
777
+ parent_tool_call_id=parent_tool_call_id,
778
+ subagent_name=_,
779
+ tool=tool,
780
+ args=args,
781
+ tool_call_id=tool_call_id,
782
+ ):
783
+ # 将嵌套工具调用添加到父 Task 的 nested_tools
784
+ parent_state = self._running_tools.get(parent_tool_call_id)
785
+ if parent_state is not None:
786
+ args_summary = summarize_tool_args(tool, args, self._project_root).strip()
787
+ nested_tool = _SubagentTool(
788
+ tool_name=tool,
789
+ args_summary=args_summary,
790
+ started_at_monotonic=time.monotonic(),
791
+ status="running",
792
+ )
793
+ parent_state.nested_tools.append((tool_call_id, nested_tool))
794
+ parent_state.show_init = False
795
+ case SubagentToolResultEvent(
796
+ parent_tool_call_id=parent_tool_call_id,
797
+ subagent_name=_,
798
+ tool=_,
799
+ tool_call_id=tool_call_id,
800
+ is_error=is_error,
801
+ duration_ms=duration_ms,
802
+ ):
803
+ # 更新嵌套工具的状态
804
+ parent_state = self._running_tools.get(parent_tool_call_id)
805
+ if parent_state is not None:
806
+ for nested_id, nested_tool in parent_state.nested_tools:
807
+ if nested_id == tool_call_id:
808
+ nested_tool.status = "error" if is_error else "completed"
809
+ nested_tool.duration_ms = duration_ms
810
+ parent_state.show_init = False
811
+ break
812
+ case TodoUpdatedEvent(todos=todos):
813
+ self._update_todos(todos)
814
+ case UserQuestionEvent(questions=questions, tool_call_id=_):
815
+ self._append_questions(questions)
816
+ self._rebuild_loading_line()
817
+ return (True, questions)
818
+ case TextEvent(content=text):
819
+ self._thinking_content = ""
820
+ if text:
821
+ self._append_assistant_text(text)
822
+ case StopEvent(reason=reason):
823
+ self._flush_assistant_segment()
824
+ self._thinking_content = ""
825
+ self._rebuild_loading_line()
826
+ if reason == "waiting_for_input":
827
+ return (True, None)
828
+ if reason == "waiting_for_plan_approval":
829
+ return (False, None)
830
+ if reason == "interrupted":
831
+ self._history.append(
832
+ HistoryEntry(entry_type="system", text="当前任务已中断。", severity="warning")
833
+ )
834
+ case PlanApprovalRequiredEvent(
835
+ plan_path=plan_path,
836
+ summary=summary,
837
+ execution_prompt=_,
838
+ plan_markdown=plan_markdown,
839
+ ):
840
+ # 在 scrollback 渲染计划内容,让用户审阅后再决策。
841
+ # 优先使用事件携带正文,避免依赖本地二次读文件。
842
+ plan_content = str(plan_markdown or "")
843
+ if not plan_content:
844
+ try:
845
+ plan_content = Path(plan_path).read_text(encoding="utf-8")
846
+ except Exception:
847
+ logger.warning("ExitPlanMode: 无法读取计划文件 %s", plan_path)
848
+ if plan_content:
849
+ self._history.append(
850
+ HistoryEntry(
851
+ entry_type="system",
852
+ text="─── Here is the plan, please review and approve or reject ───",
853
+ )
854
+ )
855
+ self._history.append(HistoryEntry(entry_type="assistant", text=plan_content))
856
+
857
+ text = f"Plan ready for review: {plan_path}"
858
+ if summary:
859
+ text = f"{text} | {summary}"
860
+ self._history.append(
861
+ HistoryEntry(entry_type="system", text=text)
862
+ )
863
+ case _:
864
+ logger.debug("Unhandled event type: %s", type(event).__name__)
865
+
866
+ self._rebuild_loading_line()
867
+ return (False, None)