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.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. 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