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/renderers.py ADDED
@@ -0,0 +1,1961 @@
1
+ """Formatting helpers for TUI widgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from agent.plan_snapshot import PlanEntry, build_plan_snapshot
9
+
10
+ from .state import MAX_TIMELINE_ITEMS, PendingApproval, SubagentStatus, TimelineItem, TuiState
11
+
12
+
13
+ import re
14
+
15
+
16
+ PROGRESS_TRACK_WIDTH = 18
17
+ PROGRESS_PULSE_WIDTH = 6
18
+ PROGRESS_COLORS = (
19
+ "#3b82f6",
20
+ "#06b6d4",
21
+ "#22d3ee",
22
+ "#8b5cf6",
23
+ "#c084fc",
24
+ "#f472b6",
25
+ "#fb7185",
26
+ "#f97316",
27
+ "#facc15",
28
+ )
29
+
30
+
31
+ def _safe_text(value: object, limit: int | None = None) -> str:
32
+ """Return dynamic content escaped for Textual/Rich markup."""
33
+ text = str(value)
34
+ if limit is not None and len(text) > limit:
35
+ text = text[: max(0, limit - 3)] + "..."
36
+ return text.replace("[", r"\[")
37
+
38
+
39
+ def _safe_repr(value: object, limit: int = 160) -> str:
40
+ """Return a bounded repr escaped for Textual/Rich markup."""
41
+ text = repr(value).replace("\n", "\\n")
42
+ if len(text) > limit:
43
+ text = text[: max(0, limit - 3)] + "..."
44
+ return text.replace("[", r"\[")
45
+
46
+
47
+ def _format_duration(ms: int) -> str:
48
+ """Format milliseconds to human readable duration."""
49
+ if ms <= 0:
50
+ return "0s"
51
+ if ms < 1000:
52
+ return f"{ms}ms"
53
+ seconds = ms / 1000
54
+ if seconds < 60:
55
+ return f"{seconds:.1f}s"
56
+ minutes = int(seconds // 60)
57
+ secs = int(seconds % 60)
58
+ return f"{minutes}m{secs}s"
59
+
60
+
61
+ def _format_tokens(num: int) -> str:
62
+ """Format token count with k/m units."""
63
+ if num < 1000:
64
+ return f"{num}"
65
+ if num < 1_000_000:
66
+ return _format_compact_number(num / 1000, "k")
67
+ return _format_compact_number(num / 1_000_000, "m")
68
+
69
+
70
+ def _format_compact_number(value: float, suffix: str) -> str:
71
+ """Format a compact number and trim a trailing .0."""
72
+ formatted = f"{value:.1f}"
73
+ if formatted.endswith(".0"):
74
+ formatted = formatted[:-2]
75
+ return f"{formatted}{suffix}"
76
+
77
+
78
+ def _usage_total(usage: dict[str, int] | None) -> int:
79
+ """Return the total token count for a usage payload."""
80
+ return int((usage or {}).get("total_tokens", 0) or 0)
81
+
82
+
83
+ def _render_progress_pulse(frame: int) -> str:
84
+ """Render a compact colorful progress pulse."""
85
+ scan_width = min(PROGRESS_PULSE_WIDTH, PROGRESS_TRACK_WIDTH)
86
+ travel = max(1, PROGRESS_TRACK_WIDTH - scan_width)
87
+ cycle = travel * 2
88
+ normalized = frame % cycle
89
+ position = normalized if normalized <= travel else cycle - normalized
90
+ cells = ["[#2b3038]·[/]" for _ in range(PROGRESS_TRACK_WIDTH)]
91
+ color_offset = frame % len(PROGRESS_COLORS)
92
+ for index in range(scan_width):
93
+ cell = position + index
94
+ color = PROGRESS_COLORS[(color_offset + index) % len(PROGRESS_COLORS)]
95
+ style = "bold " if index >= scan_width - 2 else ""
96
+ cells[cell] = f"[{style}{color}]━[/]"
97
+ return "".join(cells)
98
+
99
+
100
+ def colorize_diff_for_tui(diff: str) -> str:
101
+ """Return a diff with Rich markup colors for TUI display."""
102
+ lines = []
103
+ in_diff = False
104
+ for line in diff.splitlines():
105
+ # 检测 diff 开始
106
+ if not in_diff and (line.startswith("diff --git") or line.startswith("--- ") or line.startswith("@@")):
107
+ in_diff = True
108
+
109
+ if in_diff:
110
+ if line.startswith("@@"):
111
+ lines.append(f"[bold cyan]{_safe_text(line)}[/]")
112
+ elif line.startswith("diff --git") or line.startswith("index "):
113
+ lines.append(f"[dim]{_safe_text(line)}[/]")
114
+ elif line.startswith("+++") or line.startswith("---"):
115
+ lines.append(f"[dim]{_safe_text(line)}[/]")
116
+ elif line.startswith("+"):
117
+ lines.append(f"[bold green]{_safe_text(line)}[/]")
118
+ elif line.startswith("-"):
119
+ lines.append(f"[bold red]{_safe_text(line)}[/]")
120
+ else:
121
+ lines.append(_safe_text(line))
122
+ else:
123
+ # diff 之前的普通文本
124
+ lines.append(_safe_text(line))
125
+ if diff.endswith("\n"):
126
+ return "\n".join(lines) + "\n"
127
+ return "\n".join(lines)
128
+
129
+
130
+ def render_markdown_for_tui(markdown: str) -> str:
131
+ """Render a small, safe Markdown subset as Rich markup for timeline text."""
132
+ return _render_markdown_for_tui(markdown, full=True)
133
+
134
+
135
+ def render_markdown_light_for_tui(markdown: str) -> str:
136
+ """Render Markdown cheaply while the model is still streaming."""
137
+ return _render_markdown_for_tui(markdown, full=False)
138
+
139
+
140
+ def _render_markdown_for_tui(markdown: str, *, full: bool) -> str:
141
+ """Render a small, safe Markdown subset as Rich markup for timeline text."""
142
+ lines: list[str] = []
143
+ in_fence = False
144
+ fence_lang = ""
145
+
146
+ for raw_line in markdown.splitlines():
147
+ stripped = raw_line.strip()
148
+ if stripped.startswith("```"):
149
+ if not in_fence:
150
+ in_fence = True
151
+ fence_lang = stripped[3:].strip()
152
+ label = f" {fence_lang}" if fence_lang else ""
153
+ lines.append(f"[#7f8794]code{_safe_text(label)}[/]")
154
+ else:
155
+ in_fence = False
156
+ fence_lang = ""
157
+ continue
158
+
159
+ if in_fence:
160
+ rendered_code = _highlight_code_line_for_tui(raw_line, fence_lang) if full else f"[#cfd3dc]{_safe_text(raw_line)}[/]"
161
+ lines.append(f" {rendered_code}")
162
+ continue
163
+
164
+ if not stripped:
165
+ lines.append("")
166
+ continue
167
+
168
+ heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
169
+ if heading:
170
+ level = len(heading.group(1))
171
+ color = "#c9a6ff" if level <= 2 else "#d7ba7d"
172
+ heading_text = _render_inline_markdown(heading.group(2)) if full else _safe_text(heading.group(2))
173
+ lines.append(f"[bold {color}]{heading_text}[/]")
174
+ continue
175
+
176
+ quote = re.match(r"^>\s?(.*)$", stripped)
177
+ if quote:
178
+ quote_text = _render_inline_markdown(quote.group(1)) if full else _safe_text(quote.group(1))
179
+ lines.append(f"[#7f8794]│[/] [#aeb6c2]{quote_text}[/]")
180
+ continue
181
+
182
+ task = re.match(r"^[-*]\s+\[([ xX])\]\s+(.+)$", stripped)
183
+ if task:
184
+ checked = task.group(1).lower() == "x"
185
+ marker = "[#8fd6a3]✓[/]" if checked else "[#7f8794]○[/]"
186
+ task_text = _render_inline_markdown(task.group(2)) if full else _safe_text(task.group(2))
187
+ lines.append(f" {marker} {task_text}")
188
+ continue
189
+
190
+ bullet = re.match(r"^([-*])\s+(.+)$", stripped)
191
+ if bullet:
192
+ bullet_text = _render_inline_markdown(bullet.group(2)) if full else _safe_text(bullet.group(2))
193
+ lines.append(f" [#7f8794]•[/] {bullet_text}")
194
+ continue
195
+
196
+ numbered = re.match(r"^(\d+)[.)]\s+(.+)$", stripped)
197
+ if numbered:
198
+ numbered_text = _render_inline_markdown(numbered.group(2)) if full else _safe_text(numbered.group(2))
199
+ lines.append(f" [#7f8794]{numbered.group(1)}.[/] {numbered_text}")
200
+ continue
201
+
202
+ line_text = _render_inline_markdown(raw_line) if full else _safe_text(raw_line)
203
+ lines.append(f"[#d7dae0]{line_text}[/]")
204
+
205
+ if markdown.endswith("\n"):
206
+ return "\n".join(lines) + "\n"
207
+ return "\n".join(lines)
208
+
209
+
210
+ def _render_inline_markdown(text: str) -> str:
211
+ """Render safe inline Markdown markers after escaping Rich markup."""
212
+ escaped = _safe_text(text)
213
+
214
+ code_spans: list[str] = []
215
+
216
+ def replace_code(match: re.Match) -> str:
217
+ code_spans.append(f"[#9cdcfe]`{_safe_text(match.group(1))}`[/]")
218
+ return f"\0CODE{len(code_spans) - 1}\0"
219
+
220
+ escaped = re.sub(r"`([^`]+)`", replace_code, escaped)
221
+ escaped = re.sub(r"\*\*([^*]+)\*\*", r"[bold #f0f2f5]\1[/]", escaped)
222
+ escaped = re.sub(r"__([^_]+)__", r"[bold #f0f2f5]\1[/]", escaped)
223
+ escaped = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"[italic]\1[/]", escaped)
224
+ escaped = re.sub(r"(?<!_)_([^_\n]+)_(?!_)", r"[italic]\1[/]", escaped)
225
+
226
+ for index, rendered in enumerate(code_spans):
227
+ escaped = escaped.replace(f"\0CODE{index}\0", rendered)
228
+ return escaped
229
+
230
+
231
+ def _highlight_code_line_for_tui(line: str, lang: str) -> str:
232
+ """Apply lightweight syntax coloring to one fenced code line."""
233
+ normalized = _normalize_code_lang(lang)
234
+ if normalized == "diff":
235
+ return colorize_diff_for_tui(line)
236
+
237
+ text = _safe_text(line)
238
+ if normalized in {"python", "py"}:
239
+ return _highlight_keyword_line(
240
+ text,
241
+ {
242
+ "and", "as", "assert", "async", "await", "break", "class", "continue",
243
+ "def", "elif", "else", "except", "False", "finally", "for", "from",
244
+ "if", "import", "in", "is", "lambda", "None", "not", "or", "pass",
245
+ "raise", "return", "True", "try", "while", "with", "yield",
246
+ },
247
+ comment_prefix="#",
248
+ )
249
+ if normalized in {"bash", "sh", "shell", "zsh"}:
250
+ return _highlight_bash_line(text)
251
+ if normalized == "json":
252
+ return _highlight_json_line(text)
253
+ if normalized in {"javascript", "js", "typescript", "ts", "tsx", "jsx"}:
254
+ return _highlight_keyword_line(
255
+ text,
256
+ {
257
+ "async", "await", "break", "case", "catch", "class", "const", "continue",
258
+ "default", "else", "export", "false", "finally", "for", "from", "function",
259
+ "if", "import", "let", "new", "null", "return", "switch", "this", "throw",
260
+ "true", "try", "type", "undefined", "var", "while",
261
+ },
262
+ comment_prefix="//",
263
+ )
264
+ if normalized in {"java"}:
265
+ return _highlight_keyword_line(
266
+ text,
267
+ {
268
+ "abstract", "boolean", "break", "case", "catch", "class", "continue",
269
+ "else", "extends", "false", "final", "finally", "for", "if", "implements",
270
+ "import", "interface", "new", "null", "private", "protected", "public",
271
+ "return", "static", "super", "switch", "this", "throw", "true", "try",
272
+ "void", "while",
273
+ },
274
+ comment_prefix="//",
275
+ )
276
+ if normalized in {"csharp", "cs", "c#"}:
277
+ return _highlight_keyword_line(
278
+ text,
279
+ {
280
+ "abstract", "async", "await", "bool", "break", "case", "catch", "class",
281
+ "const", "continue", "else", "false", "finally", "for", "foreach", "if",
282
+ "interface", "namespace", "new", "null", "private", "protected", "public",
283
+ "return", "static", "string", "switch", "this", "throw", "true", "try",
284
+ "using", "var", "void", "while",
285
+ },
286
+ comment_prefix="//",
287
+ )
288
+ if normalized in {"go", "golang"}:
289
+ return _highlight_keyword_line(
290
+ text,
291
+ {
292
+ "break", "case", "chan", "const", "continue", "default", "defer", "else",
293
+ "fallthrough", "false", "for", "func", "go", "goto", "if", "import",
294
+ "interface", "map", "nil", "package", "range", "return", "select",
295
+ "struct", "switch", "true", "type", "var",
296
+ },
297
+ comment_prefix="//",
298
+ )
299
+ if normalized in {"css"}:
300
+ return _highlight_css_line(text)
301
+ if normalized in {"html", "xml"}:
302
+ return _highlight_html_line(text)
303
+ return f"[#cfd3dc]{text}[/]"
304
+
305
+
306
+ def _normalize_code_lang(lang: str) -> str:
307
+ parts = (lang or "").strip().lower().split(None, 1)
308
+ return parts[0] if parts else ""
309
+
310
+
311
+ def _highlight_keyword_line(
312
+ text: str,
313
+ keywords: set[str],
314
+ *,
315
+ comment_prefix: str | None = None,
316
+ ) -> str:
317
+ code, comment = _split_comment(text, comment_prefix)
318
+ placeholders: list[str] = []
319
+
320
+ def stash(style: str, value: str) -> str:
321
+ placeholders.append(f"[{style}]{value}[/]")
322
+ return f"\0TOK{len(placeholders) - 1}\0"
323
+
324
+ code = re.sub(r"(&quot;.*?&quot;|'.*?')", lambda m: stash("#ce9178", m.group(0)), code)
325
+ code = re.sub(r"\b\d+(?:\.\d+)?\b", lambda m: stash("#b5cea8", m.group(0)), code)
326
+
327
+ pattern = r"\b(" + "|".join(re.escape(word) for word in sorted(keywords, key=len, reverse=True)) + r")\b"
328
+ code = re.sub(pattern, lambda m: stash("bold #c586c0", m.group(0)), code)
329
+
330
+ for index, rendered in enumerate(placeholders):
331
+ code = code.replace(f"\0TOK{index}\0", rendered)
332
+ if comment:
333
+ code += f"[#6a9955]{comment}[/]"
334
+ return f"[#cfd3dc]{code}[/]"
335
+
336
+
337
+ def _split_comment(text: str, comment_prefix: str | None) -> tuple[str, str]:
338
+ if not comment_prefix:
339
+ return text, ""
340
+ index = text.find(comment_prefix)
341
+ if index < 0:
342
+ return text, ""
343
+ return text[:index], text[index:]
344
+
345
+
346
+ def _highlight_bash_line(text: str) -> str:
347
+ stripped = text.lstrip()
348
+ indent = text[: len(text) - len(stripped)]
349
+ if stripped.startswith("#"):
350
+ return f"[#6a9955]{text}[/]"
351
+ parts = stripped.split(" ", 1)
352
+ command = parts[0]
353
+ rest = parts[1] if len(parts) > 1 else ""
354
+ rest = re.sub(r"(--?[A-Za-z0-9][A-Za-z0-9_-]*)", r"[#9cdcfe]\1[/]", rest)
355
+ rest = re.sub(r"(&quot;.*?&quot;|'.*?')", r"[#ce9178]\1[/]", rest)
356
+ return f"[#cfd3dc]{indent}[bold #dcdcaa]{command}[/] {rest}[/]" if rest else f"[#cfd3dc]{indent}[bold #dcdcaa]{command}[/][/]"
357
+
358
+
359
+ def _highlight_json_line(text: str) -> str:
360
+ text = re.sub(r"(&quot;[^&]*?&quot;)(\s*:)", r"[#9cdcfe]\1[/]\2", text)
361
+ text = re.sub(r":\s*(&quot;[^&]*?&quot;)", r": [#ce9178]\1[/]", text)
362
+ text = re.sub(r"\b(true|false|null)\b", r"[bold #569cd6]\1[/]", text)
363
+ text = re.sub(r"\b\d+(?:\.\d+)?\b", r"[#b5cea8]\g<0>[/]", text)
364
+ return f"[#cfd3dc]{text}[/]"
365
+
366
+
367
+ def _highlight_css_line(text: str) -> str:
368
+ text = re.sub(r"([.#]?[A-Za-z_][A-Za-z0-9_-]*)(\s*\{)", r"[#d7ba7d]\1[/]\2", text)
369
+ text = re.sub(r"([A-Za-z-]+)(\s*:)", r"[#9cdcfe]\1[/]\2", text)
370
+ text = re.sub(r"(#[0-9A-Fa-f]{3,8})", r"[#ce9178]\1[/]", text)
371
+ return f"[#cfd3dc]{text}[/]"
372
+
373
+
374
+ def _highlight_html_line(text: str) -> str:
375
+ text = re.sub(r"(&lt;/?)([A-Za-z][A-Za-z0-9-]*)", r"\1[bold #569cd6]\2[/]", text)
376
+ text = re.sub(r"\s([A-Za-z_:][-A-Za-z0-9_:.]*)(=)", r" [#9cdcfe]\1[/]\2", text)
377
+ text = re.sub(r"=(&quot;.*?&quot;|'.*?')", r"=[#ce9178]\1[/]", text)
378
+ return f"[#cfd3dc]{text}[/]"
379
+
380
+
381
+ def _visible_len(text: str) -> int:
382
+ """Return visible character count, stripping Rich markup tags."""
383
+ return len(re.sub(r"(?<!\\)\[/?[^]]*\]", "", text))
384
+
385
+
386
+ def _plain_truncate(text: str, limit: int) -> str:
387
+ """Truncate plain text to a visible character limit."""
388
+ if limit <= 0:
389
+ return ""
390
+ if len(text) <= limit:
391
+ return text
392
+ if limit <= 3:
393
+ return "." * limit
394
+ return text[: limit - 3] + "..."
395
+
396
+
397
+ def _pad_line(left: str, right: str, width: int, border: str) -> str:
398
+ """Build one bordered line: left content, pad to width, right border."""
399
+ visible = _visible_len(left + right)
400
+ return f"{left}{right}{' ' * max(0, width - visible)}{border}"
401
+
402
+
403
+ def _two_column_line(left: str, right: str, left_width: int, right_width: int) -> str:
404
+ """Build a simple two-column line without box borders."""
405
+ left_visible = _visible_len(left)
406
+ left_pad = " " * max(1, left_width - left_visible)
407
+ return f"{left}{left_pad} {right}"
408
+
409
+
410
+ def _join_status_parts(parts: list[str]) -> str:
411
+ """Join status bar parts with a consistent compact separator."""
412
+ return " ".join(part for part in parts if part)
413
+
414
+
415
+ def _parts_visible_len(parts: list[str]) -> int:
416
+ """Return visible length for status parts including separators."""
417
+ if not parts:
418
+ return 0
419
+ return sum(_visible_len(part) for part in parts) + (len(parts) - 1) * 2
420
+
421
+
422
+ def render_brand_text(state: TuiState | None = None, width: int = 100) -> str:
423
+ """Render the compact app brand block."""
424
+ W = max(72, min(width, 180))
425
+ brand_line = (
426
+ "[bold #c9a6ff]YOYOAGENT[/] "
427
+ "[#7f8794]code assistant[/] "
428
+ "[#3f4652]" + ("─" * max(4, W - 29)) + "[/]"
429
+ )
430
+ if state is None:
431
+ return brand_line
432
+ workspace_text = _safe_text(state.workspace_path if state.workspace_path else "(not set)", max(12, W - 18))
433
+ session_text = _safe_text(state.session_id if state.session_id else "(starting)", max(12, W - 10))
434
+ restored = int(getattr(state, "restored_message_count", 0) or 0)
435
+ header = getattr(state, "message_context_header", None)
436
+ message_count = int(getattr(header, "message_count", 0) or 0)
437
+ session_line = _join_status_parts(
438
+ [
439
+ f"[#7f8794]session[/] [#cfd3dc]{session_text}[/]",
440
+ f"[#7f8794]msgs[/] [#cfd3dc]{message_count}[/]",
441
+ f"[#7f8794]restored[/] [#cfd3dc]{restored}[/]" if restored else "",
442
+ _render_header_context(header),
443
+ ]
444
+ )
445
+ return "\n".join(
446
+ [
447
+ brand_line,
448
+ f"{_render_git_header(getattr(state, 'git_header', None))} [#cfd3dc]{workspace_text}[/]",
449
+ session_line,
450
+ ]
451
+ )
452
+
453
+
454
+ def _render_git_header(git_header) -> str:
455
+ """Render branch/status marker for the top panel."""
456
+ if git_header is None or not getattr(git_header, "available", False):
457
+ return "[#7f8794]git -[/]"
458
+ branch = _safe_text(getattr(git_header, "branch", "") or "-", 32)
459
+ dirty = bool(getattr(git_header, "dirty", False))
460
+ status = "[#facc15]±[/]" if dirty else "[#8fd6a3]✓[/]"
461
+ return f"[#7f8794][/] [#7dd3fc]{branch}[/] {status}"
462
+
463
+
464
+ def _render_header_context(header) -> str:
465
+ """Render compact session context usage for the top panel."""
466
+ if header is None:
467
+ return "[#7f8794]Ctx[/] [#cfd3dc]calculating...[/]"
468
+ total = int(getattr(header, "total_tokens", 0) or 0)
469
+ window = int(getattr(header, "context_window_tokens", 0) or 0)
470
+ refreshing = bool(getattr(header, "refreshing", False))
471
+ if total <= 0 and refreshing:
472
+ return "[#7f8794]Ctx[/] [#cfd3dc]calculating...[/]"
473
+ if total <= 0:
474
+ return "[#7f8794]Ctx[/] [#cfd3dc]not measured[/]"
475
+ pressure = str(getattr(header, "pressure", "low") or "low")
476
+ pressure_color = {
477
+ "low": "#8fd6a3",
478
+ "medium": "#d7ba7d",
479
+ "high": "#f97316",
480
+ "critical": "#ff8f8f",
481
+ }.get(pressure, "#cfd3dc")
482
+ source = str(getattr(header, "token_source", "estimated") or "estimated")
483
+ source_text = "exact" if source == "exact" else "est"
484
+ if window > 0:
485
+ percent = min(int(total / window * 100), 100)
486
+ usage = f"{_format_tokens(total)}/{_format_tokens(window)} {percent}%"
487
+ else:
488
+ usage = f"{_format_tokens(total)}/-"
489
+ suffix = " ↻" if refreshing else ""
490
+ return f"[#7f8794]Ctx[/] [#cfd3dc]{usage}[/] [{pressure_color}]{pressure.upper()}[/] [#7f8794]{source_text}{suffix}[/]"
491
+
492
+
493
+ def render_status_bar_text(
494
+ state: TuiState,
495
+ width: int = 100,
496
+ *,
497
+ progress_frame: int = 0,
498
+ ) -> str:
499
+ """Render session and task status for the input-adjacent status bar."""
500
+ W = max(72, min(width, 180))
501
+
502
+ model_text = _safe_text(state.model_name if state.session_id else "(initializing)", 24)
503
+ usage = {}
504
+ if state.active_task and state.active_task.get("is_running"):
505
+ usage = state.active_task.get("usage", {}) or {}
506
+ if _usage_total(usage) <= 0 and getattr(state, "last_task", None):
507
+ usage = state.last_task.get("usage", {}) or {}
508
+ if _usage_total(usage) <= 0:
509
+ usage = state.latest_usage or {}
510
+ total = usage.get("total_tokens", 0)
511
+ input_tokens = usage.get("input_tokens", 0)
512
+ output_tokens = usage.get("output_tokens", 0)
513
+
514
+ status_text = "[#8fd6a3]⏺ Ready[/]"
515
+ elapsed_label = "Last run"
516
+ elapsed_text = "0s"
517
+ if getattr(state, "last_task", None) and state.last_task.get("elapsed_ms") is not None:
518
+ elapsed_text = _format_duration(int(state.last_task["elapsed_ms"]))
519
+ goal_text = ""
520
+ if state.active_task and state.active_task['is_running']:
521
+ task = state.active_task
522
+ elapsed_ms = 0
523
+ if task.get('start_time_ms'):
524
+ elapsed_ms = int(time.time() * 1000) - int(task['start_time_ms'])
525
+ status_text = "[#d7ba7d]⏺ Task running[/]"
526
+ status_text = f"{status_text} {_render_progress_pulse(progress_frame)}"
527
+ elapsed_label = "Elapsed"
528
+ elapsed_text = _format_duration(elapsed_ms)
529
+ goal_text = _safe_text(task.get("intent") or "", max(12, W - 9))
530
+
531
+ model_part = f"[#7f8794]Model[/] [#cfd3dc]{model_text}[/]"
532
+ status_part = f"[#7f8794]Status[/] {status_text}"
533
+ elapsed_part = f"[#7f8794]{elapsed_label}[/] [#cfd3dc]{elapsed_text}[/]"
534
+ tokens_part = (
535
+ f"[#7f8794]Tokens[/] [#cfd3dc]{_format_tokens(total)}[/] "
536
+ f"[#7f8794](input[/] [#cfd3dc]{_format_tokens(input_tokens)}[/][#7f8794], "
537
+ f"output[/] [#cfd3dc]{_format_tokens(output_tokens)}[/][#7f8794])[/]"
538
+ )
539
+ todo_items = getattr(state.todo_manager, "todo_items", []) if state.todo_manager else []
540
+ todo_reserve = 40 if todo_items else 8
541
+
542
+ parts = [status_part, elapsed_part, tokens_part]
543
+ if _parts_visible_len([model_part, *parts]) + 2 + todo_reserve <= W:
544
+ parts.insert(0, model_part)
545
+
546
+ todo_width = max(8, W - _parts_visible_len(parts) - 2)
547
+ todo_summary = _render_todo_header_summary(state.todo_manager, todo_width)
548
+ if not todo_summary:
549
+ todo_summary = "[#7f8794]Todo[/] [#cfd3dc]-[/]"
550
+ parts.insert(1 if parts and parts[0] == status_part else len(parts), todo_summary)
551
+
552
+ while _parts_visible_len(parts) > W and len(parts) > 3:
553
+ for candidate in (model_part, elapsed_part):
554
+ if candidate in parts:
555
+ parts.remove(candidate)
556
+ break
557
+ todo_index = parts.index(todo_summary)
558
+ todo_width = max(8, W - _parts_visible_len([part for part in parts if part != todo_summary]) - 2)
559
+ todo_summary = _render_todo_header_summary(state.todo_manager, todo_width) or "[#7f8794]Todo[/] [#cfd3dc]-[/]"
560
+ parts[todo_index] = todo_summary
561
+
562
+ if goal_text:
563
+ goal_width = max(8, W - _parts_visible_len(parts) - 8)
564
+ goal_part = f"[#7f8794]Goal[/] [#cfd3dc]{_safe_text(goal_text, goal_width)}[/]"
565
+ if _parts_visible_len([*parts, goal_part]) <= W:
566
+ parts.append(goal_part)
567
+ return _join_status_parts(parts)
568
+
569
+
570
+ def render_status_text(state: TuiState, width: int = 100) -> str:
571
+ """Render the legacy combined brand/status block."""
572
+ return "\n".join(
573
+ [
574
+ render_brand_text(state, width),
575
+ render_status_bar_text(state, width),
576
+ ]
577
+ )
578
+
579
+
580
+ def _render_todo_header_summary(todo_manager, width: int) -> str:
581
+ """Render a one-line todo progress summary for the header."""
582
+ width = max(8, width)
583
+ if not todo_manager:
584
+ return ""
585
+ snapshot = build_plan_snapshot(todo_manager)
586
+ items = snapshot.entries
587
+ if snapshot.task_completed:
588
+ return "[#7f8794]Todo[/] [#8fd6a3]completed[/]"
589
+ if not items:
590
+ if snapshot.task_completed:
591
+ return "[#7f8794]Todo[/] [#8fd6a3]completed[/]"
592
+ if snapshot.task_started:
593
+ return "[#7f8794]Todo[/] [#8fd6a3]completed[/]"
594
+ return "[#7f8794]Todo[/] [#cfd3dc]-[/]"
595
+
596
+ total = len(items)
597
+ completed = len([item for item in items if item.status == "completed"])
598
+ active = next((item for item in items if item.status == "in_progress"), None)
599
+ pending = next((item for item in items if item.status != "completed"), None)
600
+ current = active or pending
601
+ current_text = ""
602
+ if current:
603
+ current_text = current.title or current.id
604
+
605
+ prefix = f"Todo {completed}/{total}"
606
+ if current_text:
607
+ status = "doing" if active else "next"
608
+ fixed_len = len(prefix) + len(status) + 3
609
+ remaining = max(0, width - fixed_len)
610
+ clipped = _plain_truncate(current_text, remaining)
611
+ if clipped:
612
+ return f"[#7f8794]{prefix}[/] [#d7ba7d]{status}:[/] [#cfd3dc]{_safe_text(clipped)}[/]"
613
+ return f"[#7f8794]{prefix}[/] [#d7ba7d]{status}[/]"
614
+ return f"[#7f8794]{prefix}[/] [#8fd6a3]done[/]"
615
+
616
+
617
+ def render_timeline_lines(
618
+ state: TuiState,
619
+ limit: int = MAX_TIMELINE_ITEMS,
620
+ *,
621
+ offset_from_end: int = 0,
622
+ max_lines: int | None = None,
623
+ header_mode: str = "history",
624
+ ) -> str:
625
+ """Render the main activity transcript."""
626
+ if header_mode == "main":
627
+ return render_main_timeline_lines(state, limit=limit, max_lines=max_lines)
628
+
629
+ rendered_items = _render_timeline_blocks(state)
630
+
631
+ if rendered_items:
632
+ total = len(rendered_items)
633
+ page_size = max(1, min(limit, total))
634
+ end = max(0, total - max(0, offset_from_end))
635
+ start = max(0, end - page_size)
636
+
637
+ if max_lines is not None:
638
+ body_budget = max(4, max_lines - 2)
639
+ start = end
640
+ used_lines = 0
641
+ while start > 0:
642
+ candidate = rendered_items[start - 1]
643
+ candidate_lines = candidate.count("\n") + 1
644
+ separator_lines = 2 if used_lines else 0
645
+ if used_lines and used_lines + separator_lines + candidate_lines > body_budget:
646
+ break
647
+ if not used_lines and candidate_lines > body_budget:
648
+ start -= 1
649
+ break
650
+ used_lines += separator_lines + candidate_lines
651
+ start -= 1
652
+
653
+ visible = rendered_items[start:end]
654
+ if not visible:
655
+ visible = rendered_items[:1]
656
+ start = 0
657
+ end = 1
658
+
659
+ header = _timeline_window_header(start, end, total, mode=header_mode)
660
+ return "\n\n".join([header, *visible])
661
+ if not state.session_id:
662
+ return (
663
+ "[#d7ba7d]Starting yoyoagent[/]\n"
664
+ "\n"
665
+ "[#7f8794]The workspace is loading and the session is being prepared.[/]"
666
+ )
667
+ return (
668
+ "[#8fd6a3]Ready[/]\n"
669
+ "\n"
670
+ "[#7f8794]Ask yoyoagent to inspect code, make a change, run verification, or explain a result.[/]\n"
671
+ "[#7f8794]Ctrl+T opens task plan. Ctrl+Enter sends. Ctrl+Q quits.[/]"
672
+ )
673
+
674
+
675
+ def render_main_timeline_lines(
676
+ state: TuiState,
677
+ limit: int = MAX_TIMELINE_ITEMS,
678
+ max_lines: int | None = None,
679
+ ) -> str:
680
+ """Render ALL activity for the main UI (unlimited, scrollable)."""
681
+ rendered_items = _render_timeline_blocks(state)
682
+
683
+ sections = []
684
+
685
+ if not rendered_items:
686
+ sections.append(
687
+ "[#8fd6a3]Ready[/]\n"
688
+ "\n"
689
+ "[#7f8794]Ask yoyoagent to inspect code, make a change, run verification, or explain a result.[/]\n"
690
+ "[#7f8794]PageUp/PageDown scroll | Ctrl+T task plan | Ctrl+Enter send | Ctrl+Q quit[/]"
691
+ )
692
+ else:
693
+ total = len(rendered_items)
694
+ start = 0
695
+ visible_items = rendered_items
696
+ if max_lines is not None:
697
+ body_budget = max(4, max_lines - 2)
698
+ start = total
699
+ used_lines = 0
700
+ while start > 0:
701
+ candidate = rendered_items[start - 1]
702
+ candidate_lines = candidate.count("\n") + 1
703
+ separator_lines = 2 if used_lines else 0
704
+ if used_lines and used_lines + separator_lines + candidate_lines > body_budget:
705
+ break
706
+ if not used_lines and candidate_lines > body_budget:
707
+ start -= 1
708
+ break
709
+ used_lines += separator_lines + candidate_lines
710
+ start -= 1
711
+ visible_items = rendered_items[start:]
712
+ header = _timeline_window_header(start, total, total, mode="main")
713
+ sections.append("\n\n".join([header, *visible_items]))
714
+
715
+ return "\n\n".join(sections)
716
+
717
+
718
+ def render_task_plan_panel(state: TuiState) -> str:
719
+ """Render the full task plan for the dedicated task plan screen."""
720
+ if not state.todo_manager:
721
+ return "[#7f8794]No task plan is available for this session yet.[/]"
722
+ return _render_todo_section(state.todo_manager)
723
+
724
+
725
+ def _render_timeline_blocks(state: TuiState) -> list[str]:
726
+ """Render timeline items as human-readable activity blocks."""
727
+ blocks: list[str] = []
728
+ items = state.latest_timeline_items(MAX_TIMELINE_ITEMS)
729
+ index = 0
730
+ while index < len(items):
731
+ item = items[index]
732
+ role_prefix = _role_prefix(item)
733
+
734
+ if item.event_type == "tool_start":
735
+ rendered, next_index = _render_tool_run(items, index)
736
+ blocks.extend(rendered)
737
+ index = next_index
738
+ continue
739
+ elif item.event_type == "tool_end":
740
+ if item.tool_name != "todo":
741
+ rendered = _render_cached_timeline_item(item, state, role_prefix=role_prefix)
742
+ if rendered:
743
+ blocks.append(rendered)
744
+ else:
745
+ rendered = _render_cached_timeline_item(item, state)
746
+ if rendered:
747
+ blocks.append(rendered)
748
+ index += 1
749
+ return blocks
750
+
751
+
752
+ def _render_cached_timeline_item(
753
+ item: TimelineItem,
754
+ state: TuiState | None = None,
755
+ *,
756
+ role_prefix: str | None = None,
757
+ ) -> str | None:
758
+ mode = _timeline_item_render_mode(item, state)
759
+ key = _timeline_item_cache_key(item, mode)
760
+ if item.render_cache_key == key:
761
+ return item.rendered_text
762
+ rendered = (
763
+ _render_tool_activity(None, item, role_prefix)
764
+ if role_prefix is not None and item.event_type == "tool_end"
765
+ else _render_timeline_item(item, state, markdown_mode=mode)
766
+ )
767
+ item.render_cache_key = key
768
+ item.rendered_text = rendered
769
+ return rendered
770
+
771
+
772
+ def _timeline_item_render_mode(item: TimelineItem, state: TuiState | None) -> str:
773
+ if item.event_type == "text_delta" and state and state.active_task.get("is_running"):
774
+ return "light"
775
+ return "full"
776
+
777
+
778
+ def _timeline_item_cache_key(item: TimelineItem, mode: str) -> tuple[Any, ...]:
779
+ metadata_items = tuple(sorted((str(key), repr(value)) for key, value in item.metadata.items()))
780
+ usage_items = tuple(sorted((str(key), int(value or 0)) for key, value in (item.usage or {}).items()))
781
+ return (
782
+ mode,
783
+ item.event_type,
784
+ item.title,
785
+ item.detail,
786
+ item.phase,
787
+ item.status,
788
+ item.source,
789
+ item.role,
790
+ item.tool_name,
791
+ tuple(item.file_paths),
792
+ metadata_items,
793
+ item.elapsed_ms,
794
+ item.content,
795
+ item.start_time_ms,
796
+ usage_items,
797
+ )
798
+
799
+
800
+ def _render_tool_run(items: list[TimelineItem], start_index: int) -> tuple[list[str], int]:
801
+ """Render one contiguous run of tool activity as lightweight phase blocks."""
802
+ blocks: list[str] = []
803
+ activities: list[tuple[TimelineItem, TimelineItem | None, str]] = []
804
+ index = start_index
805
+ while index < len(items):
806
+ item = items[index]
807
+ if item.event_type != "tool_start":
808
+ break
809
+ role_prefix = _role_prefix(item)
810
+ matching_end_index = _find_matching_tool_end(items, index)
811
+ end_item = items[matching_end_index] if matching_end_index is not None else None
812
+ if item.tool_name != "todo":
813
+ activities.append((item, end_item, role_prefix))
814
+ if matching_end_index is None:
815
+ index += 1
816
+ else:
817
+ index = matching_end_index + 1
818
+
819
+ for phase, phase_activities in _group_tool_activities_by_phase(activities):
820
+ rendered = _render_phase_activity_block(phase, phase_activities)
821
+ if rendered:
822
+ blocks.append(rendered)
823
+ return blocks, index
824
+
825
+
826
+ def _group_tool_activities_by_phase(
827
+ activities: list[tuple[TimelineItem, TimelineItem | None, str]],
828
+ ) -> list[tuple[str, list[tuple[TimelineItem, TimelineItem | None, str]]]]:
829
+ """Group adjacent tool activities by display phase."""
830
+ max_group_size = 2
831
+ groups: list[tuple[str, list[tuple[TimelineItem, TimelineItem | None, str]]]] = []
832
+ for activity in activities:
833
+ phase = _tool_activity_phase(activity[0])
834
+ if groups and groups[-1][0] == phase and len(groups[-1][1]) < max_group_size:
835
+ groups[-1][1].append(activity)
836
+ else:
837
+ groups.append((phase, [activity]))
838
+ return groups
839
+
840
+
841
+ def _render_phase_activity_block(
842
+ phase: str,
843
+ activities: list[tuple[TimelineItem, TimelineItem | None, str]],
844
+ ) -> str | None:
845
+ if not activities:
846
+ return None
847
+
848
+ role_prefix = activities[0][2]
849
+ summary = _activity_summary(activities)
850
+ lines = [f"{role_prefix}[bold #c9a6ff]◇ {_safe_text(phase)}[/]"]
851
+ if summary:
852
+ lines.append(f" [#7f8794]{_safe_text(summary)}[/]")
853
+ usage = _usage_for_activities(activities)
854
+ if usage:
855
+ lines.append(f" [#7f8794]{_format_usage_inline(usage)}[/]")
856
+ lines.append("")
857
+
858
+ for activity_index, (start, end, _role_prefix) in enumerate(activities):
859
+ lines.extend(
860
+ _render_tool_activity_tree_lines(
861
+ start,
862
+ end,
863
+ is_last=activity_index == len(activities) - 1,
864
+ )
865
+ )
866
+ return "\n".join(lines).rstrip()
867
+
868
+
869
+ def _render_tool_activity_tree_lines(
870
+ start: TimelineItem | None,
871
+ end: TimelineItem | None,
872
+ *,
873
+ is_last: bool,
874
+ ) -> list[str]:
875
+ item = start or end
876
+ if item is None or item.tool_name == "todo":
877
+ return []
878
+
879
+ status = (end.status if end is not None else item.status) or "running"
880
+ title = _tool_activity_tree_title(start, end, status)
881
+ branch = "└─" if is_last else "├─"
882
+ detail_prefix = " " if is_last else "│ "
883
+ color = "#ff8f8f" if status == "failed" else "#d7dae0"
884
+ lines = [f" [#7f8794]{branch}[/] [{color}]{title}[/]"]
885
+
886
+ for detail in _tool_activity_tree_details(start, end, status):
887
+ lines.append(f" [#7f8794]{detail_prefix}[/] [#7f8794]{_safe_text(detail)}[/]")
888
+ return lines
889
+
890
+
891
+ def _tool_activity_tree_title(
892
+ start: TimelineItem | None,
893
+ end: TimelineItem | None,
894
+ status: str,
895
+ ) -> str:
896
+ item = start or end
897
+ if item is None:
898
+ return "Use tool"
899
+ tool_name = item.tool_name or ""
900
+ title_text = (item.title or "").lower()
901
+ if status == "failed":
902
+ return _safe_text(f"Failed {item.title or tool_name or 'tool'}")
903
+ if tool_name.startswith("lsp_"):
904
+ return _safe_text(item.title or "LSP semantic lookup")
905
+ if tool_name in {"read_file", "read_many_files"} or "read file" in title_text:
906
+ return "Inspect file"
907
+ if tool_name == "list_files":
908
+ return "List files"
909
+ if tool_name == "grep" or "search" in title_text:
910
+ return "Search code"
911
+ if tool_name in {"git_diff", "git_show", "workspace_state"}:
912
+ return "Inspect workspace"
913
+ if tool_name in {"apply_patch", "edit_file", "write_file"}:
914
+ return "Edit file"
915
+ if tool_name in {"bash", "verify"}:
916
+ command = _command_for_tool(item)
917
+ lowered = command.lower()
918
+ if any(name in lowered for name in ("pytest", "ruff", "mypy", "compileall")):
919
+ return "Run verification"
920
+ return "Run command"
921
+ if tool_name == "subagent":
922
+ return "Delegate work"
923
+ return _safe_text(item.title or tool_name or "Use tool")
924
+
925
+
926
+ def _tool_activity_tree_details(
927
+ start: TimelineItem | None,
928
+ end: TimelineItem | None,
929
+ status: str,
930
+ ) -> list[str]:
931
+ item = start or end
932
+ if item is None:
933
+ return []
934
+
935
+ details: list[str] = []
936
+ args = item.metadata.get("args") if isinstance(item.metadata, dict) else {}
937
+ tool_name = item.tool_name or ""
938
+
939
+ if tool_name.startswith("lsp_"):
940
+ if isinstance(args, dict):
941
+ path = args.get("path")
942
+ query = args.get("query")
943
+ line = args.get("line")
944
+ character = args.get("character")
945
+ if path:
946
+ details.append(str(path))
947
+ if query:
948
+ details.append(f"query={query}")
949
+ if line is not None and character is not None:
950
+ details.append(f"position={line}:{character}")
951
+ if not details:
952
+ target = _tool_target_plain(item)
953
+ if target:
954
+ details.append(target)
955
+ elif tool_name == "grep" or "search" in (item.title or "").lower():
956
+ details.extend(_search_activity_details(item, args))
957
+ if not details:
958
+ target = _tool_target_plain(item)
959
+ if target:
960
+ details.append(target)
961
+ elif tool_name in {"bash", "verify"}:
962
+ command = _command_for_tool(item)
963
+ if command:
964
+ details.append(command)
965
+ else:
966
+ target = _tool_target_plain(item)
967
+ if target:
968
+ details.append(target)
969
+ elif item.title:
970
+ details.append(item.title)
971
+
972
+ if start and _should_show_tool_input(start):
973
+ details.append(f"Input {_format_args_plain(args)}")
974
+ if end and end.elapsed_ms is not None:
975
+ details.append(_format_duration(end.elapsed_ms))
976
+ elif status in {"running", "in_progress"}:
977
+ details.append(f"running {_task_spinner()}")
978
+ elif status:
979
+ details.append(status.replace("_", " "))
980
+ return details
981
+
982
+
983
+ def _tool_activity_phase(item: TimelineItem) -> str:
984
+ tool = item.tool_name or ""
985
+ title = (item.title or "").lower()
986
+ command = _command_for_tool(item).lower() if tool in {"bash", "verify"} else ""
987
+ phase = (item.phase or "").lower()
988
+
989
+ if tool.startswith("lsp_") or "semantic" in phase:
990
+ return "Semantic Navigation"
991
+ if tool in {"grep"} or "search" in title:
992
+ return "Search"
993
+ if tool in {"apply_patch", "edit_file", "write_file"}:
994
+ return "Edit"
995
+ if tool in {"verify"} or any(name in command for name in ("pytest", "ruff", "mypy", "compileall")):
996
+ return "Verification"
997
+ if tool in {"read_file", "read_many_files", "list_files", "workspace_state", "git_diff", "git_show"}:
998
+ return "Exploration"
999
+ if tool == "bash":
1000
+ return "Verification" if any(name in command for name in ("pytest", "ruff", "mypy", "compileall")) else "Exploration"
1001
+ if "implement" in phase or "edit" in phase:
1002
+ return "Edit"
1003
+ if "verify" in phase or "test" in phase:
1004
+ return "Verification"
1005
+ if "search" in phase:
1006
+ return "Search"
1007
+ return "Exploration"
1008
+
1009
+
1010
+ def _usage_for_activities(
1011
+ activities: list[tuple[TimelineItem, TimelineItem | None, str]],
1012
+ ) -> dict[str, int] | None:
1013
+ for start, end, _role_prefix in reversed(activities):
1014
+ for item in (end, start):
1015
+ if item and item.usage:
1016
+ return item.usage
1017
+ return None
1018
+
1019
+
1020
+ def _format_usage_inline(usage: dict[str, int]) -> str:
1021
+ input_tok = int(usage.get("input_tokens", 0) or 0)
1022
+ output_tok = int(usage.get("output_tokens", 0) or 0)
1023
+ total_tok = int(usage.get("total_tokens", input_tok + output_tok) or 0)
1024
+ return (
1025
+ f"tokens {_format_tokens(total_tok)} · "
1026
+ f"input {_format_tokens(input_tok)} · output {_format_tokens(output_tok)}"
1027
+ )
1028
+
1029
+
1030
+ def _find_matching_tool_end(items: list[TimelineItem], start_index: int) -> int | None:
1031
+ """Return the next matching tool_end index before another tool_start appears."""
1032
+ start = items[start_index]
1033
+ for index in range(start_index + 1, len(items)):
1034
+ candidate = items[index]
1035
+ if candidate.event_type == "tool_start":
1036
+ return None
1037
+ if candidate.event_type != "tool_end":
1038
+ continue
1039
+ if candidate.tool_name == "todo":
1040
+ continue
1041
+ if start.tool_name and candidate.tool_name and start.tool_name != candidate.tool_name:
1042
+ continue
1043
+ if start.detail and candidate.detail and start.detail != candidate.detail:
1044
+ continue
1045
+ return index
1046
+ return None
1047
+
1048
+
1049
+ def _render_todo_section(todo_manager) -> str:
1050
+ """Render the full task plan panel in a scannable dashboard style."""
1051
+ snapshot = build_plan_snapshot(todo_manager)
1052
+ items = snapshot.entries
1053
+ memory = snapshot.memory or {}
1054
+ lines: list[str] = []
1055
+
1056
+ goal = str(memory.get("user_goal") or "").strip()
1057
+ if goal:
1058
+ lines.extend(["[bold #c9a6ff]Goal[/]", f" [#d7dae0]{_safe_text(goal)}[/]", ""])
1059
+
1060
+ lines.extend(_render_checklist_panel(items))
1061
+
1062
+ context_lines = _render_task_memory_context(memory)
1063
+ if context_lines:
1064
+ if lines and lines[-1] != "":
1065
+ lines.append("")
1066
+ lines.extend(["[bold #c9a6ff]Context[/]", *context_lines])
1067
+
1068
+ if not lines:
1069
+ return "[#7f8794]No task context is available yet.[/]"
1070
+ return "\n".join(lines).rstrip()
1071
+
1072
+
1073
+ def _render_checklist_panel(items: list[PlanEntry]) -> list[str]:
1074
+ if not items:
1075
+ return [
1076
+ "[bold #c9a6ff]Status[/]",
1077
+ " [#7f8794]No active checklist yet.[/]",
1078
+ " [#7f8794]Task memory below is from the current context.[/]",
1079
+ ]
1080
+
1081
+ total = len(items)
1082
+ completed = len([item for item in items if item.status == "completed"])
1083
+ remaining = total - completed
1084
+ active_index = next(
1085
+ (index for index, item in enumerate(items) if item.status == "in_progress"),
1086
+ None,
1087
+ )
1088
+ summary = f"{completed}/{total} done"
1089
+ if remaining:
1090
+ summary += f" · {remaining} remaining"
1091
+ else:
1092
+ summary += " · complete"
1093
+
1094
+ lines = ["[bold #c9a6ff]Checklist[/]", f" [#7f8794]{summary}[/]", ""]
1095
+ for index, item in enumerate(items):
1096
+ status = item.status
1097
+ item_id = item.id or str(index + 1)
1098
+ text = _safe_text(item.title)
1099
+ if status == "completed":
1100
+ marker = "[#8fd6a3]✓[/]"
1101
+ style = "#8fd6a3"
1102
+ elif status == "in_progress":
1103
+ marker = f"[#d7ba7d]{_task_spinner()}[/]"
1104
+ style = "bold #d7ba7d"
1105
+ else:
1106
+ marker = "[#7f8794]○[/]"
1107
+ style = "#d7dae0"
1108
+ current = " [#7f8794]current[/]" if active_index == index else ""
1109
+ lines.append(f" {marker} [#7f8794]{_safe_text(item_id)}[/] [{style}]{text}[/]{current}")
1110
+ return lines
1111
+
1112
+
1113
+ def _task_spinner() -> str:
1114
+ frames = ("◐", "◓", "◑", "◒")
1115
+ return frames[int(time.time() * 2) % len(frames)]
1116
+
1117
+
1118
+ def _render_task_memory_context(memory: dict) -> list[str]:
1119
+ sections = [
1120
+ ("constraints", "Constraints", 4),
1121
+ ("files_inspected", "Files", 5),
1122
+ ("files_modified", "Modified", 5),
1123
+ ("decisions", "Decisions", 4),
1124
+ ("test_results", "Tests", 3),
1125
+ ("open_risks", "Risks", 3),
1126
+ ("next_steps", "Next", 4),
1127
+ ]
1128
+ lines: list[str] = []
1129
+ for field, label, limit in sections:
1130
+ values = _dedupe_text_list(memory.get(field))
1131
+ if not values:
1132
+ continue
1133
+ if lines:
1134
+ lines.append("")
1135
+ lines.append(f" [#7f8794]{label}[/]")
1136
+ visible = values[:limit]
1137
+ for value in visible:
1138
+ lines.append(f" [#d7dae0]{_safe_text(value)}[/]")
1139
+ hidden = len(values) - len(visible)
1140
+ if hidden > 0:
1141
+ lines.append(f" [#7f8794]+{hidden} more[/]")
1142
+ return lines
1143
+
1144
+
1145
+ def _dedupe_text_list(value: object) -> list[str]:
1146
+ if value is None:
1147
+ return []
1148
+ values = value if isinstance(value, list) else [value]
1149
+ result: list[str] = []
1150
+ seen: set[str] = set()
1151
+ for item in values:
1152
+ text = str(item).strip()
1153
+ if not text or text in seen:
1154
+ continue
1155
+ seen.add(text)
1156
+ result.append(text)
1157
+ return result
1158
+
1159
+
1160
+ def _timeline_window_header(start: int, end: int, total: int, *, mode: str) -> str:
1161
+ if total <= 0:
1162
+ return ""
1163
+ position = "latest" if end >= total else "history"
1164
+ if mode == "main":
1165
+ extra = "Ctrl+T task plan | PageUp/PageDown scroll | Home/End jump | Ctrl+Enter send | Ctrl+Q quit"
1166
+ else:
1167
+ extra = "Up older | Down newer | PageUp/PageDown jump | Home first | End latest | Esc back"
1168
+ return (
1169
+ f"[#7f8794]\\[{position}] showing {start + 1}-{end} of {total} "
1170
+ f"events | {extra}[/]"
1171
+ )
1172
+
1173
+
1174
+ def _render_active_task_as_item(task: dict[str, Any], state: TuiState) -> str:
1175
+ """渲染活动任务状态为 item 形式"""
1176
+ lines = []
1177
+
1178
+ # 第一行:任务标题
1179
+ intent_text = task.get('intent', '') or ''
1180
+ intent = _safe_text(intent_text[:60])
1181
+ if len(intent_text) > 60:
1182
+ intent += "..."
1183
+ lines.append(f"[bold #c9a6ff]Task in Progress[/]: {intent}")
1184
+
1185
+ # 第二行:当前活动
1186
+ action = _safe_text(task.get('current_action', ''))
1187
+ if action:
1188
+ lines.append(f" [#d7ba7d]●[/] {action}")
1189
+
1190
+ usage = task.get('usage', {}) or {}
1191
+ total = usage.get('total_tokens', 0)
1192
+ input_tok = usage.get('input_tokens', 0)
1193
+ output_tok = usage.get('output_tokens', 0)
1194
+
1195
+ usage_str = f"Tokens: {_format_tokens(total)} (in: {_format_tokens(input_tok)}, out: {_format_tokens(output_tok)})"
1196
+
1197
+ lines.append(f" [#7f8794]{usage_str}[/]")
1198
+
1199
+ return "\n".join(lines)
1200
+
1201
+
1202
+ def render_approval_text(approval: PendingApproval) -> str:
1203
+ """Render one approval request."""
1204
+ lines = [
1205
+ f"[bold #e6e8ee]{_safe_text(approval.title)}[/]",
1206
+ f"[#aeb6c2]{_safe_text(approval.detail)}[/]",
1207
+ "",
1208
+ _safe_text(approval.request_text),
1209
+ ]
1210
+ if approval.diff_preview:
1211
+ lines.extend(["", "[#7f8794]diff_preview:[/]", _safe_text(approval.diff_preview)])
1212
+ return "\n".join(lines)
1213
+
1214
+
1215
+ def _render_subagent(subagent: SubagentStatus) -> str:
1216
+ elapsed = f" ({subagent.elapsed_ms} ms)" if subagent.elapsed_ms is not None else ""
1217
+ skills = _skills_suffix(subagent.skills)
1218
+ return (
1219
+ f"[#9cdcfe]@{_safe_text(subagent.role)}[/] "
1220
+ f"{skills}"
1221
+ f"{_status_badge(subagent.status)} "
1222
+ f"{_safe_text(subagent.detail)}{elapsed}"
1223
+ )
1224
+
1225
+
1226
+ def _role_prefix(item: TimelineItem) -> str:
1227
+ return f"[#7f8794]@{_safe_text(item.role)}[/] " if item.role and item.source == "subagent" else ""
1228
+
1229
+
1230
+ def _render_timeline_item(item: TimelineItem, state: TuiState | None = None, *, markdown_mode: str = "full") -> str | None:
1231
+ role_prefix = _role_prefix(item)
1232
+
1233
+ if item.event_type == "user_message":
1234
+ content = _safe_text(item.content.strip() or item.detail.strip())
1235
+ return f"[bold #f0f2f5]You[/]\n [#d7dae0]{content}[/]"
1236
+ if item.event_type in {"text_delta"}:
1237
+ content = render_markdown_light_for_tui(item.content) if markdown_mode == "light" else render_markdown_for_tui(item.content)
1238
+ return f"{role_prefix}{content}"
1239
+ if item.event_type == "thinking_start":
1240
+ return f"{role_prefix}[#7f8794]Thinking...[/]"
1241
+ if item.event_type == "thinking_end":
1242
+ return f"{role_prefix}[#7f8794][done][/]"
1243
+ if item.event_type == "thinking_delta":
1244
+ return None # 跳过,不单独显示
1245
+ if item.event_type == "tool_start":
1246
+ return _render_tool_call(item, role_prefix)
1247
+ if item.event_type == "tool_end":
1248
+ if item.tool_name == "todo":
1249
+ return None
1250
+ return _render_tool_return(item, role_prefix)
1251
+ if item.event_type == "tool_result":
1252
+ if item.content and item.content.strip():
1253
+ if _is_task_state_result(item):
1254
+ return _render_task_state_summary(role_prefix, item)
1255
+ title = _safe_text(_human_tool_result_title(item))
1256
+ lines = [f"{role_prefix}[bold #8fd6a3]{title}[/]"]
1257
+ detail = item.detail or ""
1258
+ if detail and detail != item.title and detail != item.content:
1259
+ lines.append(f" [#8b949e]{_safe_text(detail)}[/]")
1260
+ lines.append(_indent_block(colorize_diff_for_tui(item.content)))
1261
+ files_changed = _render_changed_files_summary(item.content)
1262
+ if files_changed:
1263
+ lines.extend(["", files_changed])
1264
+ return "\n".join(lines)
1265
+ return None
1266
+ if item.event_type == "files_changed_summary":
1267
+ files = item.metadata.get("files") if isinstance(item.metadata, dict) else None
1268
+ return _render_files_changed_table(item.content, files if isinstance(files, list) else None)
1269
+ if item.event_type == "usage":
1270
+ usage = item.usage or {}
1271
+ input_tok = usage.get('input_tokens', 0)
1272
+ output_tok = usage.get('output_tokens', 0)
1273
+ total_tok = usage.get('total_tokens', input_tok + output_tok)
1274
+ return f"{role_prefix}[#7f8794][usage] input={_format_tokens(input_tok)} output={_format_tokens(output_tok)} total={_format_tokens(total_tok)}[/]"
1275
+ if item.event_type == "context_compressed":
1276
+ return f"{role_prefix}[#7f8794][context] {_safe_text(item.content)}[/]"
1277
+ if item.event_type == "context_summarized":
1278
+ return f"{role_prefix}[#8fd6a3][context] {_safe_text(item.content)}[/]"
1279
+ if item.event_type == "llm_waiting":
1280
+ return _render_llm_waiting_item(item, role_prefix, state)
1281
+ if item.event_type == "llm_timeout":
1282
+ return f"{role_prefix}[#d7ba7d][timeout] {_safe_text(item.content)}[/]"
1283
+ if item.event_type == "llm_retry":
1284
+ return f"{role_prefix}[#9cdcfe][retry] {_safe_text(item.content)}[/]"
1285
+ if item.event_type == "llm_error":
1286
+ return f"{role_prefix}[#ff8f8f][error] {_safe_text(item.content)}[/]"
1287
+ if item.event_type == "file_changed":
1288
+ files = ", ".join(_safe_text(fp) for fp in item.file_paths) if item.file_paths else _safe_text(item.content or "file")
1289
+ return f"{role_prefix}[#8fd6a3]+[/] [#cfd3dc]modified[/] {files}"
1290
+ if item.event_type == "approval_required":
1291
+ if isinstance(item.metadata, dict) and item.metadata.get("diff_preview"):
1292
+ return None
1293
+ status = _status_badge(item.status)
1294
+ lines = [
1295
+ f"{role_prefix}[bold #d7ba7d]Needs your approval[/] {status}",
1296
+ ]
1297
+ if item.detail:
1298
+ lines.append(f" [#cfd3dc]{_safe_text(item.detail)}[/]")
1299
+ if item.content:
1300
+ lines.append(_indent_block(colorize_diff_for_tui(item.content)))
1301
+ return "\n".join(lines)
1302
+ if item.event_type == "approval_resolved":
1303
+ status = _status_badge(item.status)
1304
+ title = _approval_resolved_title(item.status)
1305
+ color = "#ff8f8f" if item.status == "denied" else "#8fd6a3"
1306
+ lines = [f"{role_prefix}[bold {color}]{title}[/] {status}"]
1307
+ if item.detail:
1308
+ lines.append(f" [#8b949e]{_safe_text(item.detail)}[/]")
1309
+ if item.status == "denied":
1310
+ lines.append(" [#ff8f8f]Task stopped because this approval was denied.[/]")
1311
+ return "\n".join(lines)
1312
+ if item.event_type in {"subagent_started", "subagent_finished"}:
1313
+ status = _status_badge(item.status)
1314
+ detail = _detail_line(item.detail)
1315
+ skills = _skills_suffix(item.metadata.get("skills") if isinstance(item.metadata, dict) else None)
1316
+ return _activity_line("@", "#9cdcfe", role_prefix, item, f"{skills}{status}", detail)
1317
+ if item.event_type in {"agent_thinking"}:
1318
+ return None # 临时状态不显示在最终时间线中
1319
+ return None
1320
+
1321
+
1322
+ def _render_llm_waiting_item(
1323
+ item: TimelineItem,
1324
+ role_prefix: str,
1325
+ state: TuiState | None = None,
1326
+ ) -> str:
1327
+ """Render the model waiting item with live timing and token details."""
1328
+ status = item.status or "running"
1329
+ color = {
1330
+ "running": "#d7ba7d",
1331
+ "retrying": "#9cdcfe",
1332
+ "timeout": "#d7ba7d",
1333
+ "failed": "#ff8f8f",
1334
+ "completed": "#8fd6a3",
1335
+ }.get(status, "#7f8794")
1336
+ title = _safe_text(item.title or "Waiting for model response")
1337
+ marker = "●" if status == "completed" else "⏺"
1338
+
1339
+ metadata = item.metadata or {}
1340
+ attempt = metadata.get("attempt")
1341
+ attempts = metadata.get("attempts")
1342
+ attempt_text = f"attempt {attempt}/{attempts}" if attempt and attempts else ""
1343
+
1344
+ if status in {"running", "retrying"} and item.start_time_ms is not None:
1345
+ elapsed_ms = int(time.time() * 1000) - item.start_time_ms
1346
+ else:
1347
+ elapsed_ms = item.elapsed_ms or 0
1348
+ if metadata.get("elapsed_ms") and status not in {"running", "retrying"}:
1349
+ elapsed_ms = int(metadata.get("elapsed_ms") or elapsed_ms)
1350
+
1351
+ since_ms = metadata.get("since_last_token_ms")
1352
+ if since_ms is None and metadata.get("idle_seconds") is not None:
1353
+ since_ms = int(metadata.get("idle_seconds") or 0) * 1000
1354
+
1355
+ usage = item.usage or (state.latest_usage if state else {}) or {}
1356
+ total = _usage_total(usage)
1357
+ if total > 0:
1358
+ token_text = (
1359
+ f"Tokens {_format_tokens(total)} "
1360
+ f"(input {_format_tokens(int(usage.get('input_tokens', 0) or 0))}, "
1361
+ f"output {_format_tokens(int(usage.get('output_tokens', 0) or 0))})"
1362
+ )
1363
+ else:
1364
+ token_text = "Tokens -"
1365
+
1366
+ details = [f"elapsed {_format_duration(elapsed_ms)}"]
1367
+ if since_ms is not None and status in {"running", "retrying"}:
1368
+ details.append(f"last token {_format_duration(int(since_ms))}")
1369
+ if attempt_text:
1370
+ details.append(attempt_text)
1371
+ details.append(token_text)
1372
+
1373
+ return (
1374
+ f"{role_prefix}[{color}]{marker}[/] [bold {color}]{title}[/]\n"
1375
+ f" [#8b949e]{' · '.join(details)}[/]"
1376
+ )
1377
+
1378
+
1379
+ def _activity_summary(
1380
+ activities: list[tuple[TimelineItem, TimelineItem | None, str]],
1381
+ ) -> str:
1382
+ """Return a Codex-like summary for a contiguous run of tool activity."""
1383
+ explored_files: set[str] = set()
1384
+ edited_files: set[str] = set()
1385
+ searches = 0
1386
+ search_terms = 0
1387
+ search_paths: set[str] = set()
1388
+ commands = 0
1389
+ git_checks = 0
1390
+ other = 0
1391
+
1392
+ for start, _end, _role_prefix in activities:
1393
+ tool = start.tool_name or ""
1394
+ title = (start.title or "").lower()
1395
+ files = [path for path in start.file_paths if path]
1396
+ if tool in {"read_file", "read_many_files", "list_files"} or "read file" in title:
1397
+ explored_files.update(files or [_tool_target_plain(start)])
1398
+ elif tool == "grep" or "search" in title:
1399
+ searches += 1
1400
+ search_terms += int(start.metadata.get("term_count", 0) or 0) if isinstance(start.metadata, dict) else 0
1401
+ path = str(start.metadata.get("path", "") or "") if isinstance(start.metadata, dict) else ""
1402
+ if path:
1403
+ search_paths.add(path)
1404
+ elif tool in {"apply_patch", "edit_file", "write_file"}:
1405
+ edited_files.update(files or [_tool_target_plain(start)])
1406
+ elif tool in {"bash", "verify"}:
1407
+ commands += 1
1408
+ elif tool in {"git_diff", "git_show", "workspace_state"}:
1409
+ git_checks += 1
1410
+ elif tool != "todo":
1411
+ other += 1
1412
+
1413
+ parts: list[str] = []
1414
+ if edited_files:
1415
+ parts.append(_plural(len([path for path in edited_files if path]), "Edited {n} file", "Edited {n} files"))
1416
+ if explored_files:
1417
+ parts.append(_plural(len([path for path in explored_files if path]), "explored {n} file", "explored {n} files"))
1418
+ if searches:
1419
+ parts.append(_search_summary(searches, search_terms, search_paths))
1420
+ if commands:
1421
+ parts.append(_plural(commands, "ran {n} command", "ran {n} commands"))
1422
+ if git_checks:
1423
+ parts.append(_plural(git_checks, "inspected git", "inspected git {n} times"))
1424
+ if other:
1425
+ parts.append(_plural(other, "used {n} tool", "used {n} tools"))
1426
+ return ", ".join(parts)
1427
+
1428
+
1429
+ def _plural(count: int, singular: str, plural: str) -> str:
1430
+ template = singular if count == 1 else plural
1431
+ return template.format(n=count)
1432
+
1433
+
1434
+ def _search_activity_details(item: TimelineItem, args: object) -> list[str]:
1435
+ details: list[str] = []
1436
+ metadata = item.metadata if isinstance(item.metadata, dict) else {}
1437
+ display = metadata.get("search_display")
1438
+ if display:
1439
+ details.append(str(display))
1440
+ elif isinstance(args, dict):
1441
+ path = args.get("path") or "."
1442
+ pattern = args.get("pattern")
1443
+ display_path = "workspace" if str(path) == "." else str(path)
1444
+ details.append(f"Searching {display_path}")
1445
+ if pattern:
1446
+ details.append(f"query: {_truncate_plain(str(pattern), 80)}")
1447
+
1448
+ terms = metadata.get("search_terms")
1449
+ if isinstance(terms, list) and terms:
1450
+ term_text = ", ".join(str(term) for term in terms[:3])
1451
+ term_count = int(metadata.get("term_count", len(terms)) or len(terms))
1452
+ if term_count > 3:
1453
+ term_text += "..."
1454
+ details.append(f"terms: {term_text}")
1455
+
1456
+ pattern_preview = metadata.get("pattern_preview")
1457
+ if pattern_preview and not terms:
1458
+ details.append(f"query: {pattern_preview}")
1459
+ return details
1460
+
1461
+
1462
+ def _search_summary(searches: int, term_count: int, paths: set[str]) -> str:
1463
+ if searches > 1:
1464
+ return _plural(searches, "ran {n} code search", "ran {n} code searches")
1465
+ if term_count > 0:
1466
+ path_count = len([path for path in paths if path])
1467
+ path_text = "workspace" if paths == {"workspace"} else _plural(path_count or 1, "1 file", "{n} files")
1468
+ return f"searched {term_count} {'keyword' if term_count == 1 else 'keywords'} in {path_text}"
1469
+ return "ran 1 code search"
1470
+
1471
+
1472
+ def _truncate_plain(text: str, limit: int) -> str:
1473
+ if len(text) <= limit:
1474
+ return text
1475
+ return text[: max(0, limit - 3)] + "..."
1476
+
1477
+
1478
+ def _render_tool_activity(
1479
+ start: TimelineItem | None,
1480
+ end: TimelineItem | None,
1481
+ role_prefix: str,
1482
+ ) -> str | None:
1483
+ """Render a tool start/end pair as one readable activity block."""
1484
+ item = start or end
1485
+ if item is None or item.tool_name == "todo":
1486
+ return None
1487
+
1488
+ status = (end.status if end is not None else item.status) or "running"
1489
+ title = _tool_activity_line(start, end, status)
1490
+ color = "#ff8f8f" if status == "failed" else "#cfd3dc"
1491
+ lines = [f"{role_prefix}[{color}]{title}[/]"]
1492
+ if start and _should_show_tool_input(start):
1493
+ args = start.metadata.get("args") if isinstance(start.metadata, dict) else None
1494
+ lines.append(f" [#7f8794]Input[/] {_format_args(args)}")
1495
+ if end and end.elapsed_ms is not None:
1496
+ lines.append(f" [#7f8794]{_format_duration(end.elapsed_ms)}[/]")
1497
+ elif status == "running":
1498
+ lines.append(" [#7f8794]in progress[/]")
1499
+ return "\n".join(lines)
1500
+
1501
+
1502
+ def _tool_activity_line(
1503
+ start: TimelineItem | None,
1504
+ end: TimelineItem | None,
1505
+ status: str,
1506
+ ) -> str:
1507
+ item = start or end
1508
+ if item is None:
1509
+ return "Used tool"
1510
+ tool_name = item.tool_name or ""
1511
+ title_text = (item.title or "").lower()
1512
+ target = _tool_target_plain(item)
1513
+ args = item.metadata.get("args") if isinstance(item.metadata, dict) else {}
1514
+ if status == "failed":
1515
+ prefix = "Failed"
1516
+ elif tool_name in {"apply_patch", "edit_file", "write_file"}:
1517
+ prefix = "Edited"
1518
+ elif tool_name in {"read_file", "read_many_files"} or "read file" in title_text:
1519
+ prefix = "Read"
1520
+ elif tool_name == "grep" or "search" in title_text:
1521
+ pattern = args.get("pattern") if isinstance(args, dict) else None
1522
+ path = args.get("path") if isinstance(args, dict) else None
1523
+ scope = f" in {path}" if path else ""
1524
+ return _safe_text(f"Searched for {pattern or target}{scope}")
1525
+ elif tool_name in {"bash", "verify"}:
1526
+ command = _command_for_tool(item)
1527
+ return _safe_text(f"Ran {command or target or item.title or tool_name}")
1528
+ elif tool_name in {"git_diff", "git_show", "workspace_state"}:
1529
+ prefix = "Inspected"
1530
+ elif tool_name == "list_files":
1531
+ prefix = "Listed"
1532
+ elif tool_name == "subagent":
1533
+ prefix = "Delegated"
1534
+ else:
1535
+ prefix = "Used"
1536
+ return _safe_text(" ".join(part for part in [prefix, target or item.title or tool_name] if part))
1537
+
1538
+
1539
+ def _should_show_tool_input(item: TimelineItem) -> bool:
1540
+ """Return whether args are useful enough to show in the compact activity line."""
1541
+ if not isinstance(item.metadata, dict):
1542
+ return False
1543
+ args = item.metadata.get("args")
1544
+ if not isinstance(args, dict) or not args:
1545
+ return False
1546
+ return (item.tool_name or "") not in {
1547
+ "read_file",
1548
+ "read_many_files",
1549
+ "grep",
1550
+ "apply_patch",
1551
+ "edit_file",
1552
+ "write_file",
1553
+ "bash",
1554
+ "verify",
1555
+ "git_diff",
1556
+ "git_show",
1557
+ "workspace_state",
1558
+ "list_files",
1559
+ }
1560
+
1561
+
1562
+ def _command_for_tool(item: TimelineItem) -> str:
1563
+ if isinstance(item.metadata, dict):
1564
+ command = item.metadata.get("command")
1565
+ if command:
1566
+ return str(command)
1567
+ args = item.metadata.get("args")
1568
+ if isinstance(args, dict):
1569
+ return str(args.get("command") or args.get("target") or args.get("kind") or "")
1570
+ return item.detail or item.content
1571
+
1572
+
1573
+ def _tool_target_plain(item: TimelineItem) -> str:
1574
+ if item.file_paths:
1575
+ return ", ".join(path for path in item.file_paths if path)
1576
+ if item.detail and item.detail != item.tool_name:
1577
+ return item.detail
1578
+ if item.content and item.content != item.tool_name:
1579
+ return item.content
1580
+ return ""
1581
+
1582
+
1583
+ def _human_tool_result_title(item: TimelineItem) -> str:
1584
+ title = (item.title or "").lower()
1585
+ if item.metadata.get("approval_preview") if isinstance(item.metadata, dict) else False:
1586
+ return "Review full diff before approval"
1587
+ if "diff" in title or item.content.startswith(("diff --git", "--- ", "@@")):
1588
+ return "Review full diff"
1589
+ if "task state" in title or "task plan" in title:
1590
+ return "Task plan"
1591
+ return item.title or "Tool result"
1592
+
1593
+
1594
+ def _is_task_state_result(item: TimelineItem) -> bool:
1595
+ title = (item.title or "").strip().lower()
1596
+ return title in {"task state", "task plan"} or item.content.startswith("Task State:")
1597
+
1598
+
1599
+ def _render_task_state_summary(role_prefix: str, item: TimelineItem) -> str:
1600
+ counts = _task_state_counts(item.content)
1601
+ summary = "Task plan"
1602
+ if counts["total"]:
1603
+ summary = f"{counts['completed']}/{counts['total']} done"
1604
+ if counts["active"]:
1605
+ summary += " · current"
1606
+ lines = [
1607
+ f"{role_prefix}[bold #8fd6a3]● Task Plan[/]",
1608
+ f" [#7f8794]{_safe_text(summary)}[/]",
1609
+ ]
1610
+ if counts["active"]:
1611
+ lines.append(f" [#d7dae0]{_safe_text(counts['active'])}[/]")
1612
+ lines.append(" [#7f8794]Ctrl+T full plan[/]")
1613
+ return "\n".join(lines)
1614
+
1615
+
1616
+ def _task_state_counts(content: str) -> dict[str, int | str]:
1617
+ total = 0
1618
+ completed = 0
1619
+ active = ""
1620
+ for line in content.splitlines():
1621
+ match = re.match(r"\[(?P<status>[Xx~ ])\]\s+\[(?P<id>[^\]]+)\]\s+(?P<text>.+)", line.strip())
1622
+ if not match:
1623
+ continue
1624
+ total += 1
1625
+ status = match.group("status")
1626
+ if status.lower() == "x":
1627
+ completed += 1
1628
+ elif status == "~" and not active:
1629
+ active = match.group("text").strip()
1630
+ return {"total": total, "completed": completed, "active": active}
1631
+
1632
+
1633
+ def _render_changed_files_summary(diff: str) -> str:
1634
+ stats = _diff_file_stats(diff)
1635
+ if not stats:
1636
+ return ""
1637
+ total_added = sum(item["added"] for item in stats)
1638
+ total_removed = sum(item["removed"] for item in stats)
1639
+ file_label = "file" if len(stats) == 1 else "files"
1640
+ lines = [
1641
+ (
1642
+ f"[bold #f0f2f5]{len(stats)} {file_label} changed[/] "
1643
+ f"[#8fd6a3]+{total_added}[/] [#ff8f8f]-{total_removed}[/] "
1644
+ f"[#7f8794]Review[/]"
1645
+ )
1646
+ ]
1647
+ path_width = min(max(len(item["path"]) for item in stats), 48)
1648
+ for item in stats:
1649
+ path = _safe_text(_plain_truncate_middle(item["path"], path_width), path_width + 3)
1650
+ padding = " " * max(2, path_width - len(_strip_markup(path)) + 2)
1651
+ lines.append(
1652
+ f"[#d7dae0]{path}[/]{padding}"
1653
+ f"[#8fd6a3]+{item['added']}[/] [#ff8f8f]-{item['removed']}[/]"
1654
+ )
1655
+ return "\n".join(lines)
1656
+
1657
+
1658
+ def _render_files_changed_table(diff: str, files: list[dict] | None = None) -> str:
1659
+ stats = files or _diff_file_stats(diff)
1660
+ if not stats:
1661
+ return ""
1662
+ total_added = sum(int(item.get("added", 0) or 0) for item in stats)
1663
+ total_removed = sum(int(item.get("removed", 0) or 0) for item in stats)
1664
+ file_label = "file" if len(stats) == 1 else "files"
1665
+ lines = [
1666
+ (
1667
+ f"[bold #f0f2f5]{len(stats)} {file_label} changed[/] "
1668
+ f"[#8fd6a3]+{total_added}[/] [#ff8f8f]-{total_removed}[/]"
1669
+ ),
1670
+ "[#7f8794]Ctrl+D open changed files and diffs[/]",
1671
+ "",
1672
+ ]
1673
+ path_width = min(max(len(str(item.get("path", ""))) for item in stats), 56)
1674
+ for item in stats:
1675
+ path = _safe_text(_plain_truncate_middle(str(item.get("path", "")), path_width), path_width + 3)
1676
+ padding = " " * max(2, path_width - len(_strip_markup(path)) + 2)
1677
+ lines.append(
1678
+ f"[#d7dae0]{path}[/]{padding}"
1679
+ f"[#8fd6a3]+{int(item.get('added', 0) or 0)}[/] [#ff8f8f]-{int(item.get('removed', 0) or 0)}[/]"
1680
+ )
1681
+ return "\n".join(lines)
1682
+
1683
+
1684
+ def _diff_file_stats(diff: str) -> list[dict[str, int | str]]:
1685
+ """Return per-file added/removed counts from a unified diff."""
1686
+ stats: list[dict[str, int | str]] = []
1687
+ current: dict[str, int | str] | None = None
1688
+ for line in diff.splitlines():
1689
+ if line.startswith("diff --git "):
1690
+ if current is not None:
1691
+ stats.append(current)
1692
+ current = {"path": _path_from_diff_header(line), "added": 0, "removed": 0}
1693
+ continue
1694
+ if line.startswith("--- "):
1695
+ if current is not None and (int(current["added"]) or int(current["removed"])):
1696
+ stats.append(current)
1697
+ current = {"path": _strip_diff_prefix(line[4:].split("\t", 1)[0].strip()), "added": 0, "removed": 0}
1698
+ elif current is None:
1699
+ current = {"path": _strip_diff_prefix(line[4:].split("\t", 1)[0].strip()), "added": 0, "removed": 0}
1700
+ continue
1701
+ if current is None:
1702
+ continue
1703
+ if line.startswith("+++ "):
1704
+ path = _path_from_file_marker(line, "+++ ")
1705
+ if path != "/dev/null":
1706
+ current["path"] = path
1707
+ continue
1708
+ if line.startswith("--- ") or line.startswith("@@") or line.startswith("index "):
1709
+ continue
1710
+ if line.startswith("+"):
1711
+ current["added"] = int(current["added"]) + 1
1712
+ elif line.startswith("-"):
1713
+ current["removed"] = int(current["removed"]) + 1
1714
+ if current is not None:
1715
+ stats.append(current)
1716
+ return [item for item in stats if int(item["added"]) or int(item["removed"])]
1717
+
1718
+
1719
+ def _path_from_diff_header(line: str) -> str:
1720
+ parts = line.split()
1721
+ if len(parts) >= 4:
1722
+ return _strip_diff_prefix(parts[3])
1723
+ return "file"
1724
+
1725
+
1726
+ def _path_from_file_marker(line: str, prefix: str) -> str:
1727
+ raw = line[len(prefix):].split("\t", 1)[0].strip()
1728
+ return _strip_diff_prefix(raw)
1729
+
1730
+
1731
+ def _strip_diff_prefix(path: str) -> str:
1732
+ if path.startswith("a/") or path.startswith("b/"):
1733
+ return path[2:]
1734
+ return path
1735
+
1736
+
1737
+ def _plain_truncate_middle(text: str, limit: int) -> str:
1738
+ if limit <= 0 or len(text) <= limit:
1739
+ return text
1740
+ if limit <= 3:
1741
+ return "." * limit
1742
+ head = max(1, (limit - 3) // 2)
1743
+ tail = max(1, limit - 3 - head)
1744
+ return f"{text[:head]}...{text[-tail:]}"
1745
+
1746
+
1747
+ def _strip_markup(text: str) -> str:
1748
+ return re.sub(r"(?<!\\)\[/?[^]]*\]", "", text)
1749
+
1750
+
1751
+ def _approval_resolved_title(status: str | None) -> str:
1752
+ if status in {"approved", "cached_approved"}:
1753
+ return "Approved"
1754
+ if status == "denied":
1755
+ return "Denied"
1756
+ return "Approval resolved"
1757
+
1758
+
1759
+ def _render_tool_call(item: TimelineItem, role_prefix: str) -> str:
1760
+ tool_name = _safe_text(item.tool_name or item.metadata.get("tool_name", "") or "tool")
1761
+ title = _safe_text(item.title or "Tool call")
1762
+ detail = _safe_text(item.detail or item.content or "")
1763
+ args = item.metadata.get("args") if isinstance(item.metadata, dict) else None
1764
+ lines = [
1765
+ f"{role_prefix}[#9cdcfe]⏺[/] [bold #9cdcfe]Tool call[/] [#7f8794]{tool_name}[/] {_status_badge(item.status)}",
1766
+ f" [#cfd3dc]{title}[/]",
1767
+ ]
1768
+ if detail:
1769
+ lines.append(f" [#8b949e]{detail}[/]")
1770
+ if args:
1771
+ lines.append(f" [#7f8794]args[/] {_format_args(args)}")
1772
+ return "\n".join(lines)
1773
+
1774
+
1775
+ def _render_tool_return(item: TimelineItem, role_prefix: str) -> str:
1776
+ tool_name = _safe_text(item.tool_name or item.metadata.get("tool_name", "") or "tool")
1777
+ title = _safe_text(item.title or "Tool finished")
1778
+ detail = _safe_text(item.detail or item.content or "")
1779
+ elapsed = f" [#7f8794]elapsed[/] [#cfd3dc]{_format_duration(item.elapsed_ms)}[/]" if item.elapsed_ms is not None else ""
1780
+ status = _status_badge(item.status)
1781
+ lines = [
1782
+ f"{role_prefix}[#8fd6a3]⏺[/] [bold #8fd6a3]Tool returned[/] [#7f8794]{tool_name}[/] {status}",
1783
+ f" [#cfd3dc]{title}[/]",
1784
+ ]
1785
+ if detail:
1786
+ lines.append(f" [#8b949e]{detail}[/]")
1787
+ if elapsed:
1788
+ lines.append(elapsed)
1789
+ return "\n".join(lines)
1790
+
1791
+
1792
+ def _format_args(args: object) -> str:
1793
+ formatted = _format_args_plain(args)
1794
+ if formatted == "{}":
1795
+ return "[#7f8794]{}[/]"
1796
+ return f"[#8b949e]{_safe_text(formatted)}[/]"
1797
+
1798
+
1799
+ def _format_args_plain(args: object) -> str:
1800
+ if not isinstance(args, dict) or not args:
1801
+ return "{}"
1802
+ parts = []
1803
+ for key, value in list(args.items())[:5]:
1804
+ parts.append(f"{_safe_text(key)}={_format_arg_value(value)}")
1805
+ suffix = " ..." if len(args) > 5 else ""
1806
+ return f"{', '.join(parts)}{suffix}"
1807
+
1808
+
1809
+ def _format_arg_value(value: object) -> str:
1810
+ """Format tool argument values for compact human-readable display."""
1811
+ if isinstance(value, str):
1812
+ return _safe_text(value, 120)
1813
+ if isinstance(value, (int, float, bool)) or value is None:
1814
+ return _safe_text(value)
1815
+ if isinstance(value, list):
1816
+ items = [_format_arg_value(item) for item in value[:4]]
1817
+ suffix = ", ..." if len(value) > 4 else ""
1818
+ return f"{', '.join(items)}{suffix}"
1819
+ if isinstance(value, tuple):
1820
+ items = [_format_arg_value(item) for item in value[:4]]
1821
+ suffix = ", ..." if len(value) > 4 else ""
1822
+ return f"{', '.join(items)}{suffix}"
1823
+ if isinstance(value, dict):
1824
+ parts = []
1825
+ for key, item in list(value.items())[:4]:
1826
+ parts.append(f"{_safe_text(key)}: {_format_arg_value(item)}")
1827
+ suffix = ", ..." if len(value) > 4 else ""
1828
+ return "{" + ", ".join(parts) + suffix + "}"
1829
+ return _safe_repr(value, 120)
1830
+
1831
+
1832
+ def _indent_block(text: str) -> str:
1833
+ if not text:
1834
+ return ""
1835
+ return "\n".join(f" {line}" if line else "" for line in text.splitlines())
1836
+
1837
+
1838
+ def _activity_line(
1839
+ marker: str,
1840
+ marker_color: str,
1841
+ role_prefix: str,
1842
+ item: TimelineItem,
1843
+ status: str,
1844
+ detail: str,
1845
+ ) -> str:
1846
+ title = _safe_text(str(item.title).lower())
1847
+ phase = f" [#7f8794]{_safe_text(item.phase)}[/]" if item.phase else ""
1848
+ return f"{role_prefix}[{marker_color}]{marker}[/] [#cfd3dc]{title}[/]{phase} {status}{detail}"
1849
+
1850
+
1851
+ def _skills_suffix(skills: object) -> str:
1852
+ if not isinstance(skills, list) or not skills:
1853
+ return ""
1854
+ names = []
1855
+ for skill in skills:
1856
+ name = str(skill).strip().lstrip("/")
1857
+ if name and name not in names:
1858
+ names.append(name)
1859
+ if not names:
1860
+ return ""
1861
+ rendered = " ".join(f"/{_safe_text(name)}" for name in names)
1862
+ return f"[#7f8794]using[/] [#c9a6ff]{rendered}[/] "
1863
+
1864
+
1865
+ def _task_running_line(
1866
+ role_prefix: str,
1867
+ item: TimelineItem,
1868
+ status: str,
1869
+ detail: str,
1870
+ state: TuiState | None = None,
1871
+ ) -> str:
1872
+ phase = f" [#7f8794]{_safe_text(item.phase)}[/]" if item.phase else ""
1873
+
1874
+ # 从 metadata 或 todo_manager 获取当前意图
1875
+ intent = ""
1876
+ if item.metadata and item.metadata.get('intent'):
1877
+ intent = item.metadata['intent']
1878
+ elif state and state.todo_manager and state.todo_manager.get_memory().get('user_goal'):
1879
+ intent = state.todo_manager.get_memory()['user_goal']
1880
+ elif item.detail and item.detail != "Thinking / waiting for model response":
1881
+ intent = item.detail
1882
+
1883
+ # 获取当前进行中的任务描述
1884
+ current_activity = _safe_text(item.title)
1885
+ if item.event_type == "llm_waiting":
1886
+ current_activity = "等待模型响应"
1887
+ elif item.event_type == "agent_thinking" and item.title == "Task running":
1888
+ current_activity = "处理中"
1889
+
1890
+ # 计算耗时
1891
+ elapsed_ms = 0
1892
+ if state and hasattr(state, 'get_elapsed_ms'):
1893
+ elapsed_ms = state.get_elapsed_ms(item)
1894
+ elif item.elapsed_ms:
1895
+ elapsed_ms = item.elapsed_ms
1896
+ elapsed_str = _format_duration(elapsed_ms)
1897
+
1898
+ # 构建信息行
1899
+ lines = [
1900
+ f"{role_prefix}[#d7ba7d]⏺[/] [bold #f0d48a]{current_activity}[/]{phase}"
1901
+ ]
1902
+
1903
+ # 添加意图
1904
+ if intent:
1905
+ # 截断过长的意图
1906
+ intent_display = _safe_text(intent[:80])
1907
+ if len(intent) > 80:
1908
+ intent_display += "..."
1909
+ lines.append(f" [#cfd3dc]{intent_display}[/]")
1910
+
1911
+ # 添加详细信息
1912
+ details = []
1913
+ if elapsed_str:
1914
+ details.append(f"耗时: {elapsed_str}")
1915
+
1916
+ # 添加 token 使用信息
1917
+ if state and state.latest_usage:
1918
+ usage = state.latest_usage
1919
+ input_tok = usage.get('input_tokens', 0)
1920
+ output_tok = usage.get('output_tokens', 0)
1921
+ total_tok = usage.get('total_tokens', input_tok + output_tok)
1922
+ if total_tok > 0:
1923
+ details.append(f"Tokens: {total_tok} (in:{input_tok}, out:{output_tok})")
1924
+
1925
+ if details:
1926
+ lines.append(f" [#8b949e]{' | '.join(details)}[/]")
1927
+
1928
+ return "\n".join(lines)
1929
+
1930
+
1931
+ def _detail_line(detail: str) -> str:
1932
+ if not detail:
1933
+ return ""
1934
+ return f"\n [#8b949e]{_safe_text(detail)}[/]"
1935
+
1936
+
1937
+ def _status_badge(status: str | None) -> str:
1938
+ if not status:
1939
+ return ""
1940
+ color = {
1941
+ "running": "#d7ba7d",
1942
+ "completed": "#8fd6a3",
1943
+ "approved": "#8fd6a3",
1944
+ "cached_approved": "#8fd6a3",
1945
+ "waiting_for_user": "#d7ba7d",
1946
+ "denied": "#ff8f8f",
1947
+ "failed": "#ff8f8f",
1948
+ }.get(status, "#7f8794")
1949
+ return f"[{color}]⏺[/] [#7f8794]{_safe_text(status)}[/]"
1950
+
1951
+
1952
+ def _marker_for_event(item: TimelineItem) -> tuple[str, str]:
1953
+ if item.event_type == "file_changed":
1954
+ return "+", "#8fd6a3"
1955
+ if item.event_type.startswith("approval"):
1956
+ return "?", "#d7ba7d"
1957
+ if item.event_type == "tool_end":
1958
+ if item.status == "failed":
1959
+ return "!", "#ff8f8f"
1960
+ return "<", "#8fd6a3"
1961
+ return ">", "#9cdcfe"