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