synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
synapse/ui/timeline.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Cursor-style tool timeline pure model (no Textual dependency).
|
|
2
|
+
|
|
3
|
+
Used by TUI (and optionally stream) to:
|
|
4
|
+
- classify tools
|
|
5
|
+
- build single-item labels (``Read README.md``)
|
|
6
|
+
- aggregate group headers (``Read 22 files, Searched 1 pattern``)
|
|
7
|
+
- truncate tool-result previews
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import PurePosixPath, PureWindowsPath
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# Default preview budget (matches docs/tui-cursor-refactor.md).
|
|
19
|
+
DEFAULT_PREVIEW_CHARS = 2000
|
|
20
|
+
DEFAULT_PREVIEW_LINES = 40
|
|
21
|
+
|
|
22
|
+
_PATH_KEYS = (
|
|
23
|
+
"path",
|
|
24
|
+
"file_path",
|
|
25
|
+
"filename",
|
|
26
|
+
"file",
|
|
27
|
+
"target",
|
|
28
|
+
"target_file",
|
|
29
|
+
"source",
|
|
30
|
+
"src",
|
|
31
|
+
"directory",
|
|
32
|
+
"dir",
|
|
33
|
+
"glob",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_CMD_KEYS = ("command", "cmd", "code", "script")
|
|
37
|
+
_PATTERN_KEYS = ("pattern", "query", "regex", "needle")
|
|
38
|
+
_INTENT_KEYS = ("intent", "purpose", "reason")
|
|
39
|
+
_TODO_TOOL_NAMES = frozenset({"write_todos", "todo_write", "todos"})
|
|
40
|
+
_TODO_DONE = frozenset({"completed", "done", "complete", "finished"})
|
|
41
|
+
_TODO_ACTIVE = frozenset({"in_progress", "in-progress", "running", "active", "doing"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ToolItem:
|
|
46
|
+
id: str
|
|
47
|
+
name: str
|
|
48
|
+
category: str
|
|
49
|
+
label: str
|
|
50
|
+
path: str | None = None
|
|
51
|
+
status: str = "running"
|
|
52
|
+
preview: str | None = None
|
|
53
|
+
error: bool = False
|
|
54
|
+
sub: bool = False
|
|
55
|
+
parent_id: str | None = None
|
|
56
|
+
call_id: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ToolGroup:
|
|
61
|
+
id: str
|
|
62
|
+
summary: str
|
|
63
|
+
items: list[ToolItem] = field(default_factory=list)
|
|
64
|
+
collapsed: bool = False
|
|
65
|
+
running: bool = True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ThoughtBlock:
|
|
70
|
+
elapsed_s: float
|
|
71
|
+
body: str
|
|
72
|
+
collapsed: bool = False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def tool_category(name: str) -> str:
|
|
76
|
+
n = (name or "").lower()
|
|
77
|
+
if n in {"read_file", "read", "read_file_lines"}:
|
|
78
|
+
return "read"
|
|
79
|
+
if n in {"write_file", "edit_file", "write", "edit", "create_file"}:
|
|
80
|
+
return "edit"
|
|
81
|
+
if n in {"ls", "list_dir", "list_directory"}:
|
|
82
|
+
return "list"
|
|
83
|
+
if n in {"glob", "find"}:
|
|
84
|
+
return "glob"
|
|
85
|
+
if n in {"grep", "search", "rg"}:
|
|
86
|
+
return "search"
|
|
87
|
+
if n in {"execute", "run", "shell", "bash"}:
|
|
88
|
+
return "run"
|
|
89
|
+
if n in {"task", "subagent"}:
|
|
90
|
+
return "task"
|
|
91
|
+
return "other"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _basename(path: str | None) -> str | None:
|
|
95
|
+
if not path:
|
|
96
|
+
return None
|
|
97
|
+
s = str(path).strip().replace("\\", "/")
|
|
98
|
+
if not s:
|
|
99
|
+
return None
|
|
100
|
+
# Keep trailing slash dirs readable.
|
|
101
|
+
if s.endswith("/") and s != "/":
|
|
102
|
+
s = s.rstrip("/")
|
|
103
|
+
base = PurePosixPath(s).name
|
|
104
|
+
return f"{base}/" if base else s
|
|
105
|
+
name = PurePosixPath(s).name
|
|
106
|
+
if not name:
|
|
107
|
+
# Windows drive edge
|
|
108
|
+
name = PureWindowsPath(path).name
|
|
109
|
+
return name or s
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def extract_path(args: Any) -> str | None:
|
|
113
|
+
if not isinstance(args, dict):
|
|
114
|
+
return None
|
|
115
|
+
for key in _PATH_KEYS:
|
|
116
|
+
val = args.get(key)
|
|
117
|
+
if isinstance(val, str) and val.strip():
|
|
118
|
+
return val.strip()
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def extract_command(args: Any) -> str | None:
|
|
123
|
+
if not isinstance(args, dict):
|
|
124
|
+
return None
|
|
125
|
+
for key in _CMD_KEYS:
|
|
126
|
+
val = args.get(key)
|
|
127
|
+
if val is None:
|
|
128
|
+
continue
|
|
129
|
+
s = str(val).replace("\n", " ").strip()
|
|
130
|
+
if s:
|
|
131
|
+
return s if len(s) <= 72 else s[:71] + "…"
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_pattern(args: Any) -> str | None:
|
|
136
|
+
if not isinstance(args, dict):
|
|
137
|
+
return None
|
|
138
|
+
for key in _PATTERN_KEYS:
|
|
139
|
+
val = args.get(key)
|
|
140
|
+
if isinstance(val, str) and val.strip():
|
|
141
|
+
s = val.strip()
|
|
142
|
+
return s if len(s) <= 40 else s[:39] + "…"
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def extract_intent(args: Any, *, limit: int = 96) -> str | None:
|
|
148
|
+
"""Return the model-provided user-facing intent string, if any."""
|
|
149
|
+
if not isinstance(args, dict):
|
|
150
|
+
return None
|
|
151
|
+
for key in _INTENT_KEYS:
|
|
152
|
+
val = args.get(key)
|
|
153
|
+
if not isinstance(val, str):
|
|
154
|
+
continue
|
|
155
|
+
s = " ".join(val.split()).strip()
|
|
156
|
+
if not s:
|
|
157
|
+
continue
|
|
158
|
+
return s if len(s) <= limit else s[: limit - 1].rstrip() + "…"
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_todo_tool(name: str | None) -> bool:
|
|
163
|
+
return (name or "").lower() in _TODO_TOOL_NAMES
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _todo_status_key(raw: Any) -> str:
|
|
167
|
+
return str(raw or "pending").strip().casefold().replace(" ", "_").replace("-", "_")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def extract_todos(args: Any) -> list[dict[str, Any]]:
|
|
171
|
+
"""Normalize ``write_todos`` args into ``{content,status}`` dicts."""
|
|
172
|
+
if not isinstance(args, dict):
|
|
173
|
+
return []
|
|
174
|
+
todos = args.get("todos")
|
|
175
|
+
if not isinstance(todos, list):
|
|
176
|
+
return []
|
|
177
|
+
out: list[dict[str, Any]] = []
|
|
178
|
+
for t in todos:
|
|
179
|
+
if isinstance(t, dict):
|
|
180
|
+
content = str(
|
|
181
|
+
t.get("content") or t.get("text") or t.get("title") or ""
|
|
182
|
+
).strip()
|
|
183
|
+
status = _todo_status_key(t.get("status")) or "pending"
|
|
184
|
+
out.append({"content": content, "status": status})
|
|
185
|
+
elif isinstance(t, str) and t.strip():
|
|
186
|
+
out.append({"content": t.strip(), "status": "pending"})
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def todo_counts(todos: list[dict[str, Any]]) -> tuple[int, int, int]:
|
|
191
|
+
"""Return ``(done, active, pending)`` counts."""
|
|
192
|
+
done = active = pending = 0
|
|
193
|
+
for t in todos:
|
|
194
|
+
st = _todo_status_key(t.get("status"))
|
|
195
|
+
if st in _TODO_DONE:
|
|
196
|
+
done += 1
|
|
197
|
+
elif st in _TODO_ACTIVE:
|
|
198
|
+
active += 1
|
|
199
|
+
else:
|
|
200
|
+
pending += 1
|
|
201
|
+
return done, active, pending
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def summarize_todos(todos: list[dict[str, Any]] | None) -> str | None:
|
|
205
|
+
"""One-line label: ``Todos 1/4 · in progress: explore agent``."""
|
|
206
|
+
if not todos:
|
|
207
|
+
return None
|
|
208
|
+
done, active, _pending = todo_counts(todos)
|
|
209
|
+
total = len(todos)
|
|
210
|
+
label = f"Todos {done}/{total}"
|
|
211
|
+
if active:
|
|
212
|
+
current = next(
|
|
213
|
+
(
|
|
214
|
+
str(t.get("content") or "").strip()
|
|
215
|
+
for t in todos
|
|
216
|
+
if _todo_status_key(t.get("status")) in _TODO_ACTIVE
|
|
217
|
+
),
|
|
218
|
+
"",
|
|
219
|
+
)
|
|
220
|
+
if current:
|
|
221
|
+
if len(current) > 42:
|
|
222
|
+
current = current[:41].rstrip() + "…"
|
|
223
|
+
return f"{label} · in progress: {current}"
|
|
224
|
+
return f"{label} · {active} in progress"
|
|
225
|
+
if done == total and total:
|
|
226
|
+
return f"{label} · all done"
|
|
227
|
+
return label
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Status marks for plain-text preview + TUI checklist widget.
|
|
231
|
+
# Prefer common Unicode (not emoji): ✓ done, ● active, ○ pending.
|
|
232
|
+
TODO_MARK_DONE = "✓"
|
|
233
|
+
TODO_MARK_ACTIVE = "●"
|
|
234
|
+
TODO_MARK_PENDING = "○"
|
|
235
|
+
_TODO_MARK_BY_KIND = {
|
|
236
|
+
"done": TODO_MARK_DONE,
|
|
237
|
+
"active": TODO_MARK_ACTIVE,
|
|
238
|
+
"pending": TODO_MARK_PENDING,
|
|
239
|
+
}
|
|
240
|
+
# Accept older ASCII marks when re-parsing stored previews.
|
|
241
|
+
_TODO_KIND_BY_MARK = {
|
|
242
|
+
TODO_MARK_DONE: "done",
|
|
243
|
+
TODO_MARK_ACTIVE: "active",
|
|
244
|
+
TODO_MARK_PENDING: "pending",
|
|
245
|
+
"x": "done",
|
|
246
|
+
"X": "done",
|
|
247
|
+
"~": "active",
|
|
248
|
+
"…": "active",
|
|
249
|
+
"·": "pending",
|
|
250
|
+
" ": "pending",
|
|
251
|
+
"-": "pending",
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def todo_status_kind(status: Any) -> str:
|
|
256
|
+
"""Map raw status to ``done`` / ``active`` / ``pending``."""
|
|
257
|
+
st = _todo_status_key(status)
|
|
258
|
+
if st in _TODO_DONE:
|
|
259
|
+
return "done"
|
|
260
|
+
if st in _TODO_ACTIVE:
|
|
261
|
+
return "active"
|
|
262
|
+
return "pending"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def todo_mark(kind_or_status: Any) -> str:
|
|
266
|
+
"""Return the display mark for a kind or raw status string."""
|
|
267
|
+
kind = str(kind_or_status or "").strip().casefold()
|
|
268
|
+
if kind in _TODO_MARK_BY_KIND:
|
|
269
|
+
return _TODO_MARK_BY_KIND[kind]
|
|
270
|
+
return _TODO_MARK_BY_KIND[todo_status_kind(kind_or_status)]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class TodoRow:
|
|
275
|
+
"""One checklist row for rendering."""
|
|
276
|
+
|
|
277
|
+
content: str
|
|
278
|
+
status: str # raw / normalized status text
|
|
279
|
+
kind: str # done | active | pending
|
|
280
|
+
mark: str
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def line(self) -> str:
|
|
284
|
+
return f"{self.mark} {self.content}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def iter_todo_rows(
|
|
288
|
+
todos: list[dict[str, Any]] | None,
|
|
289
|
+
*,
|
|
290
|
+
max_items: int | None = None,
|
|
291
|
+
) -> list[TodoRow]:
|
|
292
|
+
"""Build structured checklist rows from normalized todos."""
|
|
293
|
+
if not todos:
|
|
294
|
+
return []
|
|
295
|
+
rows: list[TodoRow] = []
|
|
296
|
+
items = todos if max_items is None else todos[:max_items]
|
|
297
|
+
for t in items:
|
|
298
|
+
content = str(t.get("content") or "").strip() or "(empty)"
|
|
299
|
+
status = _todo_status_key(t.get("status")) or "pending"
|
|
300
|
+
kind = todo_status_kind(status)
|
|
301
|
+
rows.append(
|
|
302
|
+
TodoRow(
|
|
303
|
+
content=content,
|
|
304
|
+
status=status,
|
|
305
|
+
kind=kind,
|
|
306
|
+
mark=todo_mark(kind),
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return rows
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def format_todos_preview(
|
|
313
|
+
todos: list[dict[str, Any]] | None,
|
|
314
|
+
*,
|
|
315
|
+
max_items: int = 16,
|
|
316
|
+
) -> str | None:
|
|
317
|
+
"""Multi-line checklist: ``✓`` done / ``●`` active / ``○`` pending."""
|
|
318
|
+
if not todos:
|
|
319
|
+
return None
|
|
320
|
+
rows = iter_todo_rows(todos, max_items=max_items)
|
|
321
|
+
lines = [r.line for r in rows]
|
|
322
|
+
if len(todos) > max_items:
|
|
323
|
+
lines.append(f"… +{len(todos) - max_items} more")
|
|
324
|
+
done, active, pending = todo_counts(todos)
|
|
325
|
+
lines.append(f"— done {done} · doing {active} · todo {pending}")
|
|
326
|
+
return "\n".join(lines)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_todo_preview_lines(preview: str | None) -> list[TodoRow]:
|
|
330
|
+
"""Best-effort parse of a stored checklist preview back into rows."""
|
|
331
|
+
if not preview or not str(preview).strip():
|
|
332
|
+
return []
|
|
333
|
+
rows: list[TodoRow] = []
|
|
334
|
+
for raw in str(preview).splitlines():
|
|
335
|
+
line = raw.strip()
|
|
336
|
+
if not line or line.startswith("—") or line.startswith("…"):
|
|
337
|
+
continue
|
|
338
|
+
# New form: "✓ content" / "● content" / "○ content"
|
|
339
|
+
if line[0] in _TODO_KIND_BY_MARK and (len(line) == 1 or line[1] == " "):
|
|
340
|
+
mark = line[0]
|
|
341
|
+
kind = _TODO_KIND_BY_MARK[mark]
|
|
342
|
+
content = line[1:].strip() or "(empty)"
|
|
343
|
+
rows.append(TodoRow(content=content, status=kind, kind=kind, mark=todo_mark(kind)))
|
|
344
|
+
continue
|
|
345
|
+
# Legacy form: "[x] content" / "[~] content" / "[ ] content"
|
|
346
|
+
if line.startswith("[") and "]" in line[:4]:
|
|
347
|
+
close = line.find("]")
|
|
348
|
+
mark_raw = line[1:close]
|
|
349
|
+
kind = _TODO_KIND_BY_MARK.get(mark_raw, "pending")
|
|
350
|
+
content = line[close + 1 :].strip() or "(empty)"
|
|
351
|
+
rows.append(
|
|
352
|
+
TodoRow(
|
|
353
|
+
content=content,
|
|
354
|
+
status=kind,
|
|
355
|
+
kind=kind,
|
|
356
|
+
mark=todo_mark(kind),
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return rows
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def category_phrase(cat: str, n: int, *, running: bool = False) -> str:
|
|
363
|
+
if cat == "read":
|
|
364
|
+
return f"Read {n} file{'s' if n != 1 else ''}"
|
|
365
|
+
if cat == "edit":
|
|
366
|
+
return f"Edited {n} file{'s' if n != 1 else ''}"
|
|
367
|
+
if cat == "list":
|
|
368
|
+
return f"Listed {n} dir{'s' if n != 1 else ''}"
|
|
369
|
+
if cat == "glob":
|
|
370
|
+
return f"Matched {n} glob{'s' if n != 1 else ''}"
|
|
371
|
+
if cat == "search":
|
|
372
|
+
return f"Searched {n} pattern{'s' if n != 1 else ''}"
|
|
373
|
+
if cat == "run":
|
|
374
|
+
return f"Ran {n} command{'s' if n != 1 else ''}"
|
|
375
|
+
if cat == "task":
|
|
376
|
+
verb = "Running" if running else "Launched"
|
|
377
|
+
return f"{verb} {n} subagent{'s' if n != 1 else ''}"
|
|
378
|
+
return f"{n} tools"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def item_label(name: str, args: Any = None) -> str:
|
|
382
|
+
"""Single-line Cursor label, e.g. ``Read README.md`` / ``Run pytest``."""
|
|
383
|
+
n = (name or "").lower()
|
|
384
|
+
|
|
385
|
+
# Todos: prefer checklist state over generic intent.
|
|
386
|
+
if is_todo_tool(n):
|
|
387
|
+
summary = summarize_todos(extract_todos(args))
|
|
388
|
+
if summary:
|
|
389
|
+
return summary
|
|
390
|
+
intent = extract_intent(args)
|
|
391
|
+
if intent:
|
|
392
|
+
return intent
|
|
393
|
+
return "Updated todos"
|
|
394
|
+
|
|
395
|
+
intent = extract_intent(args)
|
|
396
|
+
if intent:
|
|
397
|
+
return intent
|
|
398
|
+
|
|
399
|
+
cat = tool_category(name)
|
|
400
|
+
path = extract_path(args)
|
|
401
|
+
base = _basename(path)
|
|
402
|
+
|
|
403
|
+
if cat == "task" and isinstance(args, dict):
|
|
404
|
+
desc = args.get("description") or args.get("prompt")
|
|
405
|
+
if isinstance(desc, str) and desc.strip():
|
|
406
|
+
s = " ".join(desc.split()).strip()
|
|
407
|
+
return s if len(s) <= 96 else s[:95].rstrip() + "…"
|
|
408
|
+
|
|
409
|
+
if cat == "read":
|
|
410
|
+
return f"Read {base}" if base else "Read file"
|
|
411
|
+
if cat == "edit":
|
|
412
|
+
return f"Edited {base}" if base else "Edited file"
|
|
413
|
+
if cat == "list":
|
|
414
|
+
target = base or (path or "dir")
|
|
415
|
+
return f"Listed {target}"
|
|
416
|
+
if cat == "glob":
|
|
417
|
+
return f"Matched {base or path or 'glob'}"
|
|
418
|
+
if cat == "search":
|
|
419
|
+
pat = extract_pattern(args)
|
|
420
|
+
return f"Searched {pat}" if pat else "Searched pattern"
|
|
421
|
+
if cat == "run":
|
|
422
|
+
cmd = extract_command(args)
|
|
423
|
+
return f"Run {cmd}" if cmd else f"Run {name}"
|
|
424
|
+
if cat == "task":
|
|
425
|
+
return "Launched subagent"
|
|
426
|
+
return name or "tool"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def summarize_categories(
|
|
430
|
+
names: list[str],
|
|
431
|
+
*,
|
|
432
|
+
running: bool = False,
|
|
433
|
+
) -> str:
|
|
434
|
+
"""Group header: ``Read 22 files, Searched 1 pattern``."""
|
|
435
|
+
order: OrderedDict[str, int] = OrderedDict()
|
|
436
|
+
for name in names:
|
|
437
|
+
cat = tool_category(name)
|
|
438
|
+
order[cat] = order.get(cat, 0) + 1
|
|
439
|
+
|
|
440
|
+
parts: list[str] = []
|
|
441
|
+
others: list[str] = []
|
|
442
|
+
for cat, n in order.items():
|
|
443
|
+
if cat == "other":
|
|
444
|
+
continue
|
|
445
|
+
parts.append(category_phrase(cat, n, running=running))
|
|
446
|
+
for name in names:
|
|
447
|
+
if tool_category(name) == "other" and name not in others:
|
|
448
|
+
others.append(name)
|
|
449
|
+
if others:
|
|
450
|
+
shown = ", ".join(others[:3])
|
|
451
|
+
if len(others) > 3:
|
|
452
|
+
shown += "…"
|
|
453
|
+
parts.append(shown)
|
|
454
|
+
|
|
455
|
+
body = ", ".join(parts) if parts else f"{len(names)} tools"
|
|
456
|
+
has_task = any(tool_category(name) == "task" for name in names)
|
|
457
|
+
return f"Running {body}" if running and not has_task else body
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def summarize_items(items: list[ToolItem], *, running: bool = False) -> str:
|
|
461
|
+
top_level = [it for it in items if not it.sub]
|
|
462
|
+
if not top_level:
|
|
463
|
+
return summarize_categories([], running=running)
|
|
464
|
+
|
|
465
|
+
task_items = [it for it in top_level if tool_category(it.name) == "task"]
|
|
466
|
+
other_items = [it for it in top_level if tool_category(it.name) != "task"]
|
|
467
|
+
parts: list[str] = []
|
|
468
|
+
if task_items:
|
|
469
|
+
task_running = running and any(it.status == "running" for it in task_items)
|
|
470
|
+
parts.append(
|
|
471
|
+
category_phrase("task", len(task_items), running=task_running)
|
|
472
|
+
)
|
|
473
|
+
if other_items:
|
|
474
|
+
other_running = running and any(it.status == "running" for it in other_items)
|
|
475
|
+
parts.append(
|
|
476
|
+
summarize_categories(
|
|
477
|
+
[it.name for it in other_items], running=other_running
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
return ", ".join(parts)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
_WS = re.compile(r"\s+")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def content_to_text(content: Any) -> str:
|
|
487
|
+
if content is None:
|
|
488
|
+
return ""
|
|
489
|
+
if isinstance(content, str):
|
|
490
|
+
return content
|
|
491
|
+
if isinstance(content, (bytes, bytearray)):
|
|
492
|
+
return bytes(content).decode("utf-8", errors="replace")
|
|
493
|
+
if isinstance(content, list):
|
|
494
|
+
parts: list[str] = []
|
|
495
|
+
for part in content:
|
|
496
|
+
if isinstance(part, str):
|
|
497
|
+
parts.append(part)
|
|
498
|
+
elif isinstance(part, dict):
|
|
499
|
+
if part.get("type") == "text" and part.get("text"):
|
|
500
|
+
parts.append(str(part["text"]))
|
|
501
|
+
elif "text" in part:
|
|
502
|
+
parts.append(str(part["text"]))
|
|
503
|
+
else:
|
|
504
|
+
parts.append(str(part))
|
|
505
|
+
return "".join(parts)
|
|
506
|
+
return str(content)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def is_error_status(status: str, content: str = "") -> bool:
|
|
510
|
+
s = (status or "").lower()
|
|
511
|
+
c = (content or "").lower()
|
|
512
|
+
if s.startswith("error"):
|
|
513
|
+
return True
|
|
514
|
+
if c.startswith("error") or "traceback" in c or "not supported" in c:
|
|
515
|
+
return True
|
|
516
|
+
return False
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def truncate_preview(
|
|
520
|
+
content: Any,
|
|
521
|
+
*,
|
|
522
|
+
max_chars: int = DEFAULT_PREVIEW_CHARS,
|
|
523
|
+
max_lines: int = DEFAULT_PREVIEW_LINES,
|
|
524
|
+
) -> str | None:
|
|
525
|
+
"""Truncate tool result body for UI preview. None if empty."""
|
|
526
|
+
text = content_to_text(content)
|
|
527
|
+
if not text or not text.strip():
|
|
528
|
+
return None
|
|
529
|
+
# Drop NULs / control noise lightly.
|
|
530
|
+
text = text.replace("\x00", "")
|
|
531
|
+
lines = text.splitlines()
|
|
532
|
+
truncated = False
|
|
533
|
+
if len(lines) > max_lines:
|
|
534
|
+
lines = lines[:max_lines]
|
|
535
|
+
truncated = True
|
|
536
|
+
body = "\n".join(lines)
|
|
537
|
+
if len(body) > max_chars:
|
|
538
|
+
body = body[: max_chars - 1].rstrip() + "…"
|
|
539
|
+
truncated = True
|
|
540
|
+
elif truncated:
|
|
541
|
+
body = body.rstrip() + "\n…"
|
|
542
|
+
return body
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def format_preview_with_lines(preview: str, *, max_lines: int = 12) -> str:
|
|
546
|
+
"""Render a small line-numbered preview block."""
|
|
547
|
+
lines = preview.splitlines()
|
|
548
|
+
out: list[str] = []
|
|
549
|
+
for i, line in enumerate(lines[:max_lines], start=1):
|
|
550
|
+
out.append(f"{i:>3} {line}")
|
|
551
|
+
if len(lines) > max_lines:
|
|
552
|
+
out.append(" …")
|
|
553
|
+
return "\n".join(out)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def build_tool_item(
|
|
557
|
+
call: Any,
|
|
558
|
+
*,
|
|
559
|
+
item_id: str,
|
|
560
|
+
index: int = 0,
|
|
561
|
+
sub: bool = False,
|
|
562
|
+
) -> ToolItem:
|
|
563
|
+
"""Build a ToolItem from a LangChain-style tool call object/dict."""
|
|
564
|
+
if isinstance(call, dict):
|
|
565
|
+
name = str(call.get("name") or "?")
|
|
566
|
+
args = call.get("args")
|
|
567
|
+
call_id = call.get("id") or call.get("tool_call_id")
|
|
568
|
+
else:
|
|
569
|
+
name = str(getattr(call, "name", "?") or "?")
|
|
570
|
+
args = getattr(call, "args", {}) or {}
|
|
571
|
+
call_id = getattr(call, "id", None) or getattr(call, "tool_call_id", None)
|
|
572
|
+
|
|
573
|
+
cat = tool_category(name)
|
|
574
|
+
path = extract_path(args)
|
|
575
|
+
label = item_label(name, args)
|
|
576
|
+
preview = None
|
|
577
|
+
if is_todo_tool(name):
|
|
578
|
+
preview = format_todos_preview(extract_todos(args))
|
|
579
|
+
return ToolItem(
|
|
580
|
+
id=item_id or f"{name}-{index}",
|
|
581
|
+
name=name,
|
|
582
|
+
category=cat,
|
|
583
|
+
label=label,
|
|
584
|
+
path=path,
|
|
585
|
+
status="running",
|
|
586
|
+
preview=preview,
|
|
587
|
+
error=False,
|
|
588
|
+
sub=sub,
|
|
589
|
+
call_id=str(call_id) if call_id else None,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def match_tool_result(
|
|
594
|
+
items: list[ToolItem],
|
|
595
|
+
name: str,
|
|
596
|
+
call_id: str | None = None,
|
|
597
|
+
) -> ToolItem | None:
|
|
598
|
+
"""Pick the unfinished item matching call ID first, then tool name.
|
|
599
|
+
|
|
600
|
+
Do **not** fall back to an arbitrary running item. Nested subagent tool
|
|
601
|
+
results (``read_file`` while the parent only has ``task`` pending) would
|
|
602
|
+
otherwise finish the parent task early and leave a trail of empty groups.
|
|
603
|
+
"""
|
|
604
|
+
want = (name or "").strip()
|
|
605
|
+
wanted_id = str(call_id or "").strip()
|
|
606
|
+
if wanted_id:
|
|
607
|
+
for it in items:
|
|
608
|
+
if it.status == "running" and it.call_id == wanted_id:
|
|
609
|
+
return it
|
|
610
|
+
return None
|
|
611
|
+
for it in items:
|
|
612
|
+
if it.status != "running":
|
|
613
|
+
continue
|
|
614
|
+
if it.name == want:
|
|
615
|
+
return it
|
|
616
|
+
# Only tolerate a missing/empty tool name when exactly one item is running.
|
|
617
|
+
if not want:
|
|
618
|
+
running = [it for it in items if it.status == "running"]
|
|
619
|
+
if len(running) == 1:
|
|
620
|
+
return running[0]
|
|
621
|
+
return None
|