yycode 0.3.2__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.
- agent/__init__.py +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- yycode-0.3.2.dist-info/top_level.txt +4 -0
agent/tui/state.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""State store for the terminal UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agent.message_context_manager import MessageContextSummary
|
|
10
|
+
from agent.streaming import StreamEvent
|
|
11
|
+
from agent.todo_manager import TodoManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MAX_TIMELINE_ITEMS = 500
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class GitHeader:
|
|
19
|
+
"""Compact git status shown in the top panel."""
|
|
20
|
+
|
|
21
|
+
branch: str = ""
|
|
22
|
+
dirty: bool = False
|
|
23
|
+
available: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class MessageContextHeader:
|
|
28
|
+
"""Compact session message context summary for the top panel."""
|
|
29
|
+
|
|
30
|
+
message_count: int = 0
|
|
31
|
+
total_tokens: int = 0
|
|
32
|
+
token_source: str = "estimated"
|
|
33
|
+
context_window_tokens: int = 0
|
|
34
|
+
remaining_tokens: int = 0
|
|
35
|
+
pressure: str = "low"
|
|
36
|
+
updated_at_ms: int | None = None
|
|
37
|
+
refreshing: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class TimelineItem:
|
|
42
|
+
"""A rendered timeline item derived from a stream event."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
session_id: str
|
|
46
|
+
event_type: str
|
|
47
|
+
title: str
|
|
48
|
+
detail: str
|
|
49
|
+
phase: str | None
|
|
50
|
+
status: str | None
|
|
51
|
+
source: str
|
|
52
|
+
role: str | None
|
|
53
|
+
tool_name: str | None = None
|
|
54
|
+
file_paths: list[str] = field(default_factory=list)
|
|
55
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
elapsed_ms: int | None = None
|
|
57
|
+
content: str = ""
|
|
58
|
+
start_time_ms: int | None = None
|
|
59
|
+
is_transient: bool = False # 标记是否为临时状态项,完成后可删除
|
|
60
|
+
usage: dict[str, int] | None = None
|
|
61
|
+
render_cache_key: tuple[Any, ...] | None = None
|
|
62
|
+
rendered_text: str | None = None
|
|
63
|
+
|
|
64
|
+
def invalidate_render_cache(self) -> None:
|
|
65
|
+
"""Clear cached TUI rendering for this item after mutation."""
|
|
66
|
+
self.render_cache_key = None
|
|
67
|
+
self.rendered_text = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class PendingApproval:
|
|
72
|
+
"""A user decision currently blocking execution."""
|
|
73
|
+
|
|
74
|
+
approval_id: str
|
|
75
|
+
title: str
|
|
76
|
+
detail: str
|
|
77
|
+
request_text: str
|
|
78
|
+
diff_preview: str = ""
|
|
79
|
+
tool_name: str | None = None
|
|
80
|
+
file_paths: list[str] = field(default_factory=list)
|
|
81
|
+
status: str = "waiting_for_user"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class SubagentStatus:
|
|
86
|
+
"""Current status snapshot for one subagent."""
|
|
87
|
+
|
|
88
|
+
session_id: str
|
|
89
|
+
role: str
|
|
90
|
+
title: str
|
|
91
|
+
detail: str
|
|
92
|
+
status: str
|
|
93
|
+
skills: list[str] = field(default_factory=list)
|
|
94
|
+
elapsed_ms: int | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ChangedFileDiff:
|
|
99
|
+
"""A changed file and its diff from the latest task."""
|
|
100
|
+
|
|
101
|
+
path: str
|
|
102
|
+
added: int
|
|
103
|
+
removed: int
|
|
104
|
+
diff: str
|
|
105
|
+
collapsed: bool = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TuiState:
|
|
109
|
+
"""Mutable state used by the TUI renderer."""
|
|
110
|
+
|
|
111
|
+
def __init__(self) -> None:
|
|
112
|
+
self.timeline: list[TimelineItem] = []
|
|
113
|
+
self.pending_approvals: dict[str, PendingApproval] = {}
|
|
114
|
+
self.subagents: dict[str, SubagentStatus] = {}
|
|
115
|
+
self.changed_files: list[str] = []
|
|
116
|
+
self.latest_changed_file_diffs: list[ChangedFileDiff] = []
|
|
117
|
+
self.active_phase: str = "planning"
|
|
118
|
+
self.status_line: str = "Initializing session"
|
|
119
|
+
self.model_name: str = "(unknown)"
|
|
120
|
+
self.session_id: str = ""
|
|
121
|
+
self.skills_text: str = "(none)"
|
|
122
|
+
self.workspace_path: str = ""
|
|
123
|
+
self.context_window_tokens: int = 0
|
|
124
|
+
self.restored_message_count: int = 0
|
|
125
|
+
self.message_context_header = MessageContextHeader()
|
|
126
|
+
self.git_header = GitHeader()
|
|
127
|
+
self.auto_mode: bool = False
|
|
128
|
+
self.latest_usage: dict[str, int] = {}
|
|
129
|
+
self.todo_manager: TodoManager | None = None
|
|
130
|
+
self._counter = 0
|
|
131
|
+
self._start_time_ms: int = int(time.time() * 1000)
|
|
132
|
+
# 任务运行状态跟踪
|
|
133
|
+
self.active_task: dict[str, Any] = {
|
|
134
|
+
'is_running': False,
|
|
135
|
+
'start_time_ms': None,
|
|
136
|
+
'intent': '',
|
|
137
|
+
'current_action': '',
|
|
138
|
+
'usage': {'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0},
|
|
139
|
+
}
|
|
140
|
+
self.last_task: dict[str, Any] = {
|
|
141
|
+
'elapsed_ms': None,
|
|
142
|
+
'usage': {'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0},
|
|
143
|
+
'status': '',
|
|
144
|
+
'finished_at_ms': None,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def set_startup_info(
|
|
148
|
+
self,
|
|
149
|
+
*,
|
|
150
|
+
session_id: str,
|
|
151
|
+
model_name: str,
|
|
152
|
+
skills_text: str,
|
|
153
|
+
workspace_path: str = "",
|
|
154
|
+
context_window_tokens: int = 0,
|
|
155
|
+
restored_message_count: int = 0,
|
|
156
|
+
auto_mode: bool = False,
|
|
157
|
+
todo_manager: TodoManager | None = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Set the startup/session summary shown in the header."""
|
|
160
|
+
self.session_id = session_id
|
|
161
|
+
self.model_name = model_name
|
|
162
|
+
self.skills_text = skills_text or "(none)"
|
|
163
|
+
self.workspace_path = workspace_path
|
|
164
|
+
self.context_window_tokens = max(0, int(context_window_tokens or 0))
|
|
165
|
+
self.restored_message_count = max(0, int(restored_message_count or 0))
|
|
166
|
+
self.message_context_header.context_window_tokens = self.context_window_tokens
|
|
167
|
+
self.message_context_header.message_count = self.restored_message_count
|
|
168
|
+
self.auto_mode = bool(auto_mode)
|
|
169
|
+
self.status_line = "Ready for input"
|
|
170
|
+
self.todo_manager = todo_manager
|
|
171
|
+
|
|
172
|
+
def update_message_context_header(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
message_count: int,
|
|
176
|
+
summary: MessageContextSummary | None = None,
|
|
177
|
+
refreshing: bool = False,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Update compact session message context metrics shown in the top panel."""
|
|
180
|
+
self.message_context_header.message_count = max(0, int(message_count or 0))
|
|
181
|
+
self.message_context_header.refreshing = bool(refreshing)
|
|
182
|
+
if summary is None:
|
|
183
|
+
return
|
|
184
|
+
self.message_context_header.total_tokens = max(0, int(summary.total_tokens or 0))
|
|
185
|
+
self.message_context_header.token_source = str(summary.token_source or "estimated")
|
|
186
|
+
self.message_context_header.context_window_tokens = max(0, int(summary.context_window_tokens or 0))
|
|
187
|
+
self.message_context_header.remaining_tokens = max(0, int(summary.remaining_tokens or 0))
|
|
188
|
+
self.message_context_header.pressure = str(summary.pressure or "low")
|
|
189
|
+
self.message_context_header.updated_at_ms = int(time.time() * 1000)
|
|
190
|
+
|
|
191
|
+
def clear_session_view(self) -> None:
|
|
192
|
+
"""Clear local transcript/session view after a TUI command resets history."""
|
|
193
|
+
self.timeline = []
|
|
194
|
+
self.pending_approvals.clear()
|
|
195
|
+
self.subagents.clear()
|
|
196
|
+
self.changed_files = []
|
|
197
|
+
self.latest_changed_file_diffs = []
|
|
198
|
+
self.latest_usage = {}
|
|
199
|
+
self.message_context_header = MessageContextHeader(context_window_tokens=self.context_window_tokens)
|
|
200
|
+
self.active_phase = "planning"
|
|
201
|
+
self.status_line = "Session history cleared"
|
|
202
|
+
self.end_active_task()
|
|
203
|
+
if self.todo_manager is not None:
|
|
204
|
+
self.todo_manager.reset()
|
|
205
|
+
|
|
206
|
+
def update_git_header(self, *, branch: str = "", dirty: bool = False, available: bool = False) -> None:
|
|
207
|
+
"""Update compact git status shown in the top panel."""
|
|
208
|
+
self.git_header = GitHeader(branch=branch, dirty=dirty, available=available)
|
|
209
|
+
|
|
210
|
+
def has_tasks(self) -> bool:
|
|
211
|
+
"""Return whether there is an active task state."""
|
|
212
|
+
if not self.todo_manager:
|
|
213
|
+
return False
|
|
214
|
+
return self.todo_manager.task_state_started
|
|
215
|
+
|
|
216
|
+
def add_user_input(self, session_id: str, text: str) -> TimelineItem:
|
|
217
|
+
"""Append the user's submitted prompt to the transcript."""
|
|
218
|
+
# 先结束之前的任务
|
|
219
|
+
self.end_active_task()
|
|
220
|
+
|
|
221
|
+
self._counter += 1
|
|
222
|
+
item = TimelineItem(
|
|
223
|
+
id=f"evt-{self._counter}",
|
|
224
|
+
session_id=session_id,
|
|
225
|
+
event_type="user_message",
|
|
226
|
+
title="You",
|
|
227
|
+
detail=text,
|
|
228
|
+
phase="planning",
|
|
229
|
+
status=None,
|
|
230
|
+
source="user",
|
|
231
|
+
role=None,
|
|
232
|
+
content=text,
|
|
233
|
+
)
|
|
234
|
+
self.timeline.append(item)
|
|
235
|
+
if len(self.timeline) > MAX_TIMELINE_ITEMS:
|
|
236
|
+
self.timeline = self.timeline[-MAX_TIMELINE_ITEMS:]
|
|
237
|
+
self.active_phase = "planning"
|
|
238
|
+
self.status_line = "Waiting for agent response"
|
|
239
|
+
|
|
240
|
+
# 开始跟踪新任务
|
|
241
|
+
self.active_task['is_running'] = True
|
|
242
|
+
self.active_task['start_time_ms'] = int(time.time() * 1000)
|
|
243
|
+
self.active_task['intent'] = text
|
|
244
|
+
self.active_task['current_action'] = "Starting..."
|
|
245
|
+
self.active_task['usage'] = {'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0}
|
|
246
|
+
|
|
247
|
+
return item
|
|
248
|
+
|
|
249
|
+
def apply_event(self, event: StreamEvent) -> TimelineItem:
|
|
250
|
+
"""Apply a stream event and return the appended timeline item."""
|
|
251
|
+
# 更新任务状态跟踪
|
|
252
|
+
self._update_active_task(event)
|
|
253
|
+
|
|
254
|
+
if event.event_type in {"text_delta", "tool_start", "tool_result", "tool_end"}:
|
|
255
|
+
self.complete_llm_waiting_items(event.session_id)
|
|
256
|
+
|
|
257
|
+
if event.event_type in {"llm_waiting", "llm_retry", "llm_timeout", "llm_error"}:
|
|
258
|
+
existing_waiting = self._find_latest_llm_waiting(event)
|
|
259
|
+
if existing_waiting is not None:
|
|
260
|
+
return self._merge_llm_status_event(existing_waiting, event)
|
|
261
|
+
|
|
262
|
+
# 模拟 ConsoleStreamRenderer 的 thinking 处理逻辑
|
|
263
|
+
# 如果在 thinking 过程中来了 tool_start、tool_result 或 text_delta,添加 thinking_end
|
|
264
|
+
needs_thinking_end = False
|
|
265
|
+
if self.timeline:
|
|
266
|
+
latest = self.timeline[-1]
|
|
267
|
+
if latest.event_type == "thinking_start" and event.event_type in {"tool_start", "tool_result", "text_delta", "llm_waiting", "llm_timeout", "llm_retry", "llm_error"}:
|
|
268
|
+
needs_thinking_end = True
|
|
269
|
+
|
|
270
|
+
if needs_thinking_end:
|
|
271
|
+
self._counter += 1
|
|
272
|
+
now_ms = int(time.time() * 1000)
|
|
273
|
+
thinking_end_item = TimelineItem(
|
|
274
|
+
id=f"evt-{self._counter}",
|
|
275
|
+
session_id=event.session_id,
|
|
276
|
+
event_type="thinking_end",
|
|
277
|
+
title="Thinking finished",
|
|
278
|
+
detail="",
|
|
279
|
+
phase=None,
|
|
280
|
+
status=None,
|
|
281
|
+
source=event.source,
|
|
282
|
+
role=event.role,
|
|
283
|
+
tool_name=None,
|
|
284
|
+
file_paths=[],
|
|
285
|
+
metadata={},
|
|
286
|
+
elapsed_ms=None,
|
|
287
|
+
content="",
|
|
288
|
+
start_time_ms=now_ms,
|
|
289
|
+
is_transient=False,
|
|
290
|
+
usage=None,
|
|
291
|
+
)
|
|
292
|
+
self.timeline.append(thinking_end_item)
|
|
293
|
+
|
|
294
|
+
# 移除临时状态项
|
|
295
|
+
if event.event_type not in {"agent_thinking", "thinking_delta", "text_delta"}:
|
|
296
|
+
self._remove_transient_items()
|
|
297
|
+
|
|
298
|
+
# 只有 text_delta 和 thinking_delta 能合并,其他都新建条目
|
|
299
|
+
# 确保所有信息都显示在时间线上
|
|
300
|
+
always_new = {"usage", "context_compressed", "context_summarized", "llm_timeout", "llm_retry", "llm_error", "file_changed", "files_changed_summary", "llm_waiting", "tool_start", "tool_end", "tool_result", "thinking_start", "thinking_end", "user_message", "agent_thinking"}
|
|
301
|
+
existing = None
|
|
302
|
+
if event.event_type == "text_delta" and self.timeline and self.timeline[-1].event_type == "text_delta":
|
|
303
|
+
existing = self.timeline[-1] if self.timeline[-1].session_id == event.session_id else None
|
|
304
|
+
elif event.event_type == "thinking_delta":
|
|
305
|
+
if self.timeline and self.timeline[-1].event_type in {"thinking_start", "thinking_delta"}:
|
|
306
|
+
existing = self.timeline[-1]
|
|
307
|
+
elif event.event_type not in always_new:
|
|
308
|
+
existing = self._find_merge_target(event)
|
|
309
|
+
|
|
310
|
+
if existing is not None:
|
|
311
|
+
return self._merge_event(existing, event)
|
|
312
|
+
|
|
313
|
+
self._counter += 1
|
|
314
|
+
now_ms = int(time.time() * 1000)
|
|
315
|
+
item = TimelineItem(
|
|
316
|
+
id=f"evt-{self._counter}",
|
|
317
|
+
session_id=event.session_id,
|
|
318
|
+
event_type=event.event_type,
|
|
319
|
+
title=event.title or self._default_title(event),
|
|
320
|
+
detail=event.detail or event.content,
|
|
321
|
+
phase=event.phase,
|
|
322
|
+
status=event.status,
|
|
323
|
+
source=event.source,
|
|
324
|
+
role=event.role,
|
|
325
|
+
tool_name=event.tool_name,
|
|
326
|
+
file_paths=list(event.file_paths or []),
|
|
327
|
+
metadata=dict(event.metadata or {}),
|
|
328
|
+
elapsed_ms=event.elapsed_ms,
|
|
329
|
+
content=event.content,
|
|
330
|
+
start_time_ms=now_ms,
|
|
331
|
+
is_transient=event.event_type in {"agent_thinking"}, # 只有 agent_thinking 是临时的
|
|
332
|
+
usage=event.usage,
|
|
333
|
+
)
|
|
334
|
+
self.timeline.append(item)
|
|
335
|
+
if len(self.timeline) > MAX_TIMELINE_ITEMS:
|
|
336
|
+
self.timeline = self.timeline[-MAX_TIMELINE_ITEMS:]
|
|
337
|
+
|
|
338
|
+
self._update_phase(item)
|
|
339
|
+
self._update_status_line(item)
|
|
340
|
+
self._update_usage(event)
|
|
341
|
+
self._update_changed_files(item)
|
|
342
|
+
self._update_approvals(item)
|
|
343
|
+
self._update_subagents(event, item)
|
|
344
|
+
return item
|
|
345
|
+
|
|
346
|
+
def _find_latest_llm_waiting(self, event: StreamEvent) -> TimelineItem | None:
|
|
347
|
+
"""Find an active model-wait timeline item for the same session."""
|
|
348
|
+
for item in reversed(self.timeline):
|
|
349
|
+
if item.session_id != event.session_id:
|
|
350
|
+
continue
|
|
351
|
+
if item.event_type == "llm_waiting" and item.status in {"running", "retrying", "timeout", "failed", None}:
|
|
352
|
+
return item
|
|
353
|
+
if item.event_type in {"text_delta", "tool_start", "tool_result", "tool_end", "user_message"}:
|
|
354
|
+
return None
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
def _merge_llm_status_event(self, item: TimelineItem, event: StreamEvent) -> TimelineItem:
|
|
358
|
+
"""Update one model-wait item instead of appending heartbeat rows."""
|
|
359
|
+
previous_start = item.start_time_ms
|
|
360
|
+
if event.event_type == "llm_waiting":
|
|
361
|
+
item.event_type = "llm_waiting"
|
|
362
|
+
item.status = event.status or "running"
|
|
363
|
+
elif event.event_type == "llm_retry":
|
|
364
|
+
item.status = event.status or "retrying"
|
|
365
|
+
elif event.event_type in {"llm_timeout", "llm_error"}:
|
|
366
|
+
item.status = event.status or ("timeout" if event.event_type == "llm_timeout" else "failed")
|
|
367
|
+
item.title = event.title or self._default_title(event)
|
|
368
|
+
item.detail = event.detail or event.content
|
|
369
|
+
item.content = event.content
|
|
370
|
+
item.phase = event.phase or item.phase
|
|
371
|
+
item.elapsed_ms = event.elapsed_ms if event.elapsed_ms is not None else item.elapsed_ms
|
|
372
|
+
item.metadata.update(dict(event.metadata or {}))
|
|
373
|
+
item.usage = event.usage or item.usage
|
|
374
|
+
item.invalidate_render_cache()
|
|
375
|
+
item.start_time_ms = previous_start
|
|
376
|
+
self._update_phase(item)
|
|
377
|
+
self._update_status_line(item)
|
|
378
|
+
self._update_usage(event)
|
|
379
|
+
return item
|
|
380
|
+
|
|
381
|
+
def complete_llm_waiting_items(self, session_id: str) -> None:
|
|
382
|
+
"""Mark active model-wait timeline items as completed."""
|
|
383
|
+
for item in reversed(self.timeline):
|
|
384
|
+
if item.session_id != session_id:
|
|
385
|
+
continue
|
|
386
|
+
if item.event_type == "llm_waiting" and item.status in {"running", "retrying", "timeout", None}:
|
|
387
|
+
item.status = "completed"
|
|
388
|
+
item.title = "Model response started"
|
|
389
|
+
item.invalidate_render_cache()
|
|
390
|
+
return
|
|
391
|
+
if item.event_type in {"text_delta", "tool_start", "tool_result", "tool_end", "user_message"}:
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
def _update_active_task(self, event: StreamEvent) -> None:
|
|
395
|
+
"""更新活动任务状态"""
|
|
396
|
+
if not self.active_task['is_running']:
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
# 更新当前活动
|
|
400
|
+
if event.event_type == "thinking_start":
|
|
401
|
+
self.active_task['current_action'] = "Thinking..."
|
|
402
|
+
elif event.event_type == "tool_start":
|
|
403
|
+
if event.tool_name == "grep":
|
|
404
|
+
self.active_task['current_action'] = "Searching code..."
|
|
405
|
+
else:
|
|
406
|
+
self.active_task['current_action'] = "Running tool"
|
|
407
|
+
elif event.event_type == "llm_waiting":
|
|
408
|
+
self.active_task['current_action'] = "Waiting for model..."
|
|
409
|
+
elif event.event_type == "text_delta":
|
|
410
|
+
self.active_task['current_action'] = "Generating response..."
|
|
411
|
+
|
|
412
|
+
# 更新 usage
|
|
413
|
+
if event.usage:
|
|
414
|
+
self.active_task['usage'] = event.usage
|
|
415
|
+
|
|
416
|
+
def end_active_task(self) -> None:
|
|
417
|
+
"""结束活动任务"""
|
|
418
|
+
if self.active_task.get('is_running'):
|
|
419
|
+
elapsed_ms = None
|
|
420
|
+
start_time_ms = self.active_task.get('start_time_ms')
|
|
421
|
+
if start_time_ms:
|
|
422
|
+
elapsed_ms = int(time.time() * 1000) - int(start_time_ms)
|
|
423
|
+
usage = self.active_task.get('usage') or self.latest_usage or {}
|
|
424
|
+
self.last_task = {
|
|
425
|
+
'elapsed_ms': elapsed_ms,
|
|
426
|
+
'usage': dict(usage),
|
|
427
|
+
'status': 'completed',
|
|
428
|
+
'finished_at_ms': int(time.time() * 1000),
|
|
429
|
+
}
|
|
430
|
+
self.active_task['is_running'] = False
|
|
431
|
+
self.active_task['start_time_ms'] = None
|
|
432
|
+
self.active_task['intent'] = ""
|
|
433
|
+
self.active_task['current_action'] = ""
|
|
434
|
+
self.active_task['usage'] = {'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0}
|
|
435
|
+
|
|
436
|
+
def _remove_transient_items(self) -> None:
|
|
437
|
+
"""Remove transient status items from timeline."""
|
|
438
|
+
self.timeline = [item for item in self.timeline if not item.is_transient]
|
|
439
|
+
|
|
440
|
+
def get_elapsed_ms(self, item: TimelineItem) -> int:
|
|
441
|
+
"""Get elapsed time for a timeline item."""
|
|
442
|
+
if item.elapsed_ms is not None:
|
|
443
|
+
return item.elapsed_ms
|
|
444
|
+
if item.start_time_ms is not None:
|
|
445
|
+
return int(time.time() * 1000) - item.start_time_ms
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
def latest_timeline_items(self, limit: int = 50) -> list[TimelineItem]:
|
|
449
|
+
"""Return the newest timeline items."""
|
|
450
|
+
return self.timeline[-limit:]
|
|
451
|
+
|
|
452
|
+
def latest_item(self) -> TimelineItem | None:
|
|
453
|
+
"""Return the newest timeline item, if any."""
|
|
454
|
+
if not self.timeline:
|
|
455
|
+
return None
|
|
456
|
+
return self.timeline[-1]
|
|
457
|
+
|
|
458
|
+
def next_pending_approval(self) -> PendingApproval | None:
|
|
459
|
+
"""Return the next pending approval, if any."""
|
|
460
|
+
for approval in self.pending_approvals.values():
|
|
461
|
+
if approval.status == "waiting_for_user":
|
|
462
|
+
return approval
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
def set_changed_file_diffs(self, files: list[dict]) -> None:
|
|
466
|
+
"""Store changed file diffs for the dedicated diff viewer."""
|
|
467
|
+
self.latest_changed_file_diffs = [
|
|
468
|
+
ChangedFileDiff(
|
|
469
|
+
path=str(item.get("path", "")),
|
|
470
|
+
added=int(item.get("added", 0) or 0),
|
|
471
|
+
removed=int(item.get("removed", 0) or 0),
|
|
472
|
+
diff=str(item.get("diff", "") or ""),
|
|
473
|
+
)
|
|
474
|
+
for item in files
|
|
475
|
+
if item.get("path")
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
def _default_title(self, event: StreamEvent) -> str:
|
|
479
|
+
mapping = {
|
|
480
|
+
"thinking_start": "Thinking",
|
|
481
|
+
"thinking_end": "Thinking finished",
|
|
482
|
+
"text_delta": "Assistant response",
|
|
483
|
+
"user_message": "You",
|
|
484
|
+
"tool_start": "Run tool",
|
|
485
|
+
"tool_end": "Tool finished",
|
|
486
|
+
"tool_result": "Tool result",
|
|
487
|
+
"usage": "Usage updated",
|
|
488
|
+
"context_compressed": "Context compressed",
|
|
489
|
+
"context_summarized": "Context summarized",
|
|
490
|
+
"llm_waiting": "Waiting for model response",
|
|
491
|
+
"llm_timeout": "Model request timed out",
|
|
492
|
+
"llm_retry": "Retrying model request",
|
|
493
|
+
"llm_error": "Model request failed",
|
|
494
|
+
"approval_required": "Approval required",
|
|
495
|
+
"approval_resolved": "Approval resolved",
|
|
496
|
+
"file_changed": "File changed",
|
|
497
|
+
"files_changed_summary": "Files changed",
|
|
498
|
+
"subagent_started": "Subagent started",
|
|
499
|
+
"subagent_finished": "Subagent finished",
|
|
500
|
+
}
|
|
501
|
+
return mapping.get(event.event_type, event.event_type.replace("_", " ").title())
|
|
502
|
+
|
|
503
|
+
def _find_merge_target(self, event: StreamEvent) -> TimelineItem | None:
|
|
504
|
+
if not self.timeline:
|
|
505
|
+
return None
|
|
506
|
+
latest = self.timeline[-1]
|
|
507
|
+
if event.event_type == "thinking_delta":
|
|
508
|
+
return latest if latest.event_type in {"thinking_start", "thinking_delta"} else None
|
|
509
|
+
if event.event_type == "text_delta":
|
|
510
|
+
if latest.event_type != "text_delta":
|
|
511
|
+
return None
|
|
512
|
+
if latest.session_id != event.session_id:
|
|
513
|
+
return None
|
|
514
|
+
return latest
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
def _merge_event(self, item: TimelineItem, event: StreamEvent) -> TimelineItem:
|
|
518
|
+
if event.event_type == "thinking_delta":
|
|
519
|
+
item.content += event.content
|
|
520
|
+
item.detail = event.content or item.detail
|
|
521
|
+
elif event.event_type == "text_delta":
|
|
522
|
+
item.content += event.content
|
|
523
|
+
item.detail = item.content
|
|
524
|
+
# 更新 usage
|
|
525
|
+
if event.usage:
|
|
526
|
+
item.usage = event.usage
|
|
527
|
+
item.invalidate_render_cache()
|
|
528
|
+
self._update_phase(item)
|
|
529
|
+
self._update_status_line(item)
|
|
530
|
+
self._update_approvals(item)
|
|
531
|
+
self._update_subagents(event, item)
|
|
532
|
+
return item
|
|
533
|
+
|
|
534
|
+
def _find_latest_matching_tool_start(self, event: StreamEvent) -> TimelineItem | None:
|
|
535
|
+
for item in reversed(self.timeline):
|
|
536
|
+
if item.event_type != "tool_start":
|
|
537
|
+
continue
|
|
538
|
+
if item.session_id != event.session_id:
|
|
539
|
+
continue
|
|
540
|
+
if item.tool_name and event.tool_name and item.tool_name != event.tool_name:
|
|
541
|
+
continue
|
|
542
|
+
return item
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
def _find_latest_matching_approval(self, event: StreamEvent) -> TimelineItem | None:
|
|
546
|
+
approval_id = str((event.metadata or {}).get("approval_id", ""))
|
|
547
|
+
if not approval_id:
|
|
548
|
+
return None
|
|
549
|
+
for item in reversed(self.timeline):
|
|
550
|
+
if item.event_type != "approval_required":
|
|
551
|
+
continue
|
|
552
|
+
if str(item.metadata.get("approval_id", "")) == approval_id:
|
|
553
|
+
return item
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
def _find_latest_matching_subagent(self, event: StreamEvent) -> TimelineItem | None:
|
|
557
|
+
for item in reversed(self.timeline):
|
|
558
|
+
if item.event_type != "subagent_started":
|
|
559
|
+
continue
|
|
560
|
+
if item.session_id == event.session_id:
|
|
561
|
+
return item
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
def _update_phase(self, item: TimelineItem) -> None:
|
|
565
|
+
if item.phase:
|
|
566
|
+
self.active_phase = item.phase
|
|
567
|
+
elif item.event_type.startswith("llm_"):
|
|
568
|
+
self.active_phase = "waiting"
|
|
569
|
+
|
|
570
|
+
def _update_status_line(self, item: TimelineItem) -> None:
|
|
571
|
+
detail = f": {item.detail}" if item.detail else ""
|
|
572
|
+
self.status_line = f"{item.title}{detail}"
|
|
573
|
+
|
|
574
|
+
def _update_usage(self, event: StreamEvent) -> None:
|
|
575
|
+
if event.usage:
|
|
576
|
+
self.latest_usage = dict(event.usage)
|
|
577
|
+
|
|
578
|
+
def _update_changed_files(self, item: TimelineItem) -> None:
|
|
579
|
+
if item.event_type != "file_changed":
|
|
580
|
+
return
|
|
581
|
+
for path in item.file_paths:
|
|
582
|
+
if path and path not in self.changed_files:
|
|
583
|
+
self.changed_files.append(path)
|
|
584
|
+
|
|
585
|
+
def _update_approvals(self, item: TimelineItem) -> None:
|
|
586
|
+
approval_id = str(item.metadata.get("approval_id", ""))
|
|
587
|
+
if not approval_id:
|
|
588
|
+
return
|
|
589
|
+
if item.event_type == "approval_required":
|
|
590
|
+
self.pending_approvals[approval_id] = PendingApproval(
|
|
591
|
+
approval_id=approval_id,
|
|
592
|
+
title=item.title,
|
|
593
|
+
detail=item.detail,
|
|
594
|
+
request_text=item.content,
|
|
595
|
+
diff_preview=str(item.metadata.get("diff_preview", "") or ""),
|
|
596
|
+
tool_name=item.tool_name,
|
|
597
|
+
file_paths=list(item.file_paths),
|
|
598
|
+
status=item.status or "waiting_for_user",
|
|
599
|
+
)
|
|
600
|
+
return
|
|
601
|
+
if item.event_type == "approval_resolved" and approval_id in self.pending_approvals:
|
|
602
|
+
approval = self.pending_approvals[approval_id]
|
|
603
|
+
approval.status = item.status or "resolved"
|
|
604
|
+
if approval.status != "waiting_for_user":
|
|
605
|
+
self.pending_approvals.pop(approval_id, None)
|
|
606
|
+
|
|
607
|
+
def _update_subagents(self, event: StreamEvent, item: TimelineItem) -> None:
|
|
608
|
+
if event.event_type == "subagent_started":
|
|
609
|
+
self.subagents[event.session_id] = SubagentStatus(
|
|
610
|
+
session_id=event.session_id,
|
|
611
|
+
role=event.role or "subagent",
|
|
612
|
+
title=item.title,
|
|
613
|
+
detail=item.detail,
|
|
614
|
+
status=item.status or "running",
|
|
615
|
+
skills=_metadata_skills(item.metadata),
|
|
616
|
+
elapsed_ms=item.elapsed_ms,
|
|
617
|
+
)
|
|
618
|
+
return
|
|
619
|
+
if event.event_type == "subagent_finished":
|
|
620
|
+
self.subagents[event.session_id] = SubagentStatus(
|
|
621
|
+
session_id=event.session_id,
|
|
622
|
+
role=event.role or "subagent",
|
|
623
|
+
title=item.title,
|
|
624
|
+
detail=item.detail,
|
|
625
|
+
status=item.status or "completed",
|
|
626
|
+
skills=_metadata_skills(item.metadata),
|
|
627
|
+
elapsed_ms=item.elapsed_ms,
|
|
628
|
+
)
|
|
629
|
+
return
|
|
630
|
+
if event.source == "subagent":
|
|
631
|
+
existing = self.subagents.get(event.session_id)
|
|
632
|
+
self.subagents[event.session_id] = SubagentStatus(
|
|
633
|
+
session_id=event.session_id,
|
|
634
|
+
role=event.role or "subagent",
|
|
635
|
+
title=item.title,
|
|
636
|
+
detail=item.detail,
|
|
637
|
+
status=item.status or (existing.status if existing else "running"),
|
|
638
|
+
skills=_metadata_skills(item.metadata) or (existing.skills if existing else []),
|
|
639
|
+
elapsed_ms=item.elapsed_ms,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _metadata_skills(metadata: dict[str, Any]) -> list[str]:
|
|
644
|
+
"""Return normalized skill names stored in event metadata."""
|
|
645
|
+
raw = metadata.get("skills") if isinstance(metadata, dict) else None
|
|
646
|
+
if not isinstance(raw, list):
|
|
647
|
+
return []
|
|
648
|
+
skills: list[str] = []
|
|
649
|
+
for item in raw:
|
|
650
|
+
name = str(item).strip().lstrip("/")
|
|
651
|
+
if name and name not in skills:
|
|
652
|
+
skills.append(name)
|
|
653
|
+
return skills
|