python-codex 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycodex/__init__.py +139 -2
- pycodex/agent.py +290 -0
- pycodex/cli.py +641 -0
- pycodex/collaboration.py +21 -0
- pycodex/context.py +580 -0
- pycodex/doctor.py +360 -0
- pycodex/model.py +533 -0
- pycodex/prompts/collaboration_default.md +11 -0
- pycodex/prompts/collaboration_plan.md +128 -0
- pycodex/prompts/default_base_instructions.md +275 -0
- pycodex/prompts/exec_tools.json +411 -0
- pycodex/prompts/models.json +847 -0
- pycodex/prompts/permissions/approval_policy/never.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
- pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
- pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
- pycodex/prompts/subagent_tools.json +163 -0
- pycodex/protocol.py +347 -0
- pycodex/runtime.py +200 -0
- pycodex/runtime_services.py +408 -0
- pycodex/tools/__init__.py +58 -0
- pycodex/tools/agent_tool_schemas.py +70 -0
- pycodex/tools/apply_patch_tool.py +363 -0
- pycodex/tools/base_tool.py +168 -0
- pycodex/tools/close_agent_tool.py +55 -0
- pycodex/tools/code_mode_manager.py +519 -0
- pycodex/tools/exec_command_tool.py +96 -0
- pycodex/tools/exec_runtime.js +161 -0
- pycodex/tools/exec_tool.py +48 -0
- pycodex/tools/grep_files_tool.py +150 -0
- pycodex/tools/list_dir_tool.py +135 -0
- pycodex/tools/read_file_tool.py +217 -0
- pycodex/tools/request_permissions_tool.py +95 -0
- pycodex/tools/request_user_input_tool.py +167 -0
- pycodex/tools/resume_agent_tool.py +56 -0
- pycodex/tools/send_input_tool.py +106 -0
- pycodex/tools/shell_command_tool.py +107 -0
- pycodex/tools/shell_tool.py +112 -0
- pycodex/tools/spawn_agent_tool.py +97 -0
- pycodex/tools/unified_exec_manager.py +380 -0
- pycodex/tools/update_plan_tool.py +79 -0
- pycodex/tools/view_image_tool.py +111 -0
- pycodex/tools/wait_agent_tool.py +75 -0
- pycodex/tools/wait_tool.py +68 -0
- pycodex/tools/web_search_tool.py +30 -0
- pycodex/tools/write_stdin_tool.py +75 -0
- pycodex/utils/__init__.py +40 -0
- pycodex/utils/dotenv.py +64 -0
- pycodex/utils/get_env.py +218 -0
- pycodex/utils/random_ids.py +19 -0
- pycodex/utils/visualize.py +978 -0
- python_codex-0.1.0.dist-info/METADATA +267 -0
- python_codex-0.1.0.dist-info/RECORD +60 -0
- python_codex-0.1.0.dist-info/entry_points.txt +2 -0
- python_codex-0.1.0.dist-info/licenses/LICENSE +201 -0
- python_codex-0.0.1.dist-info/METADATA +0 -30
- python_codex-0.0.1.dist-info/RECORD +0 -4
- {python_codex-0.0.1.dist-info → python_codex-0.1.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from prompt_toolkit import PromptSession
|
|
11
|
+
from prompt_toolkit.patch_stdout import StdoutProxy
|
|
12
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
13
|
+
|
|
14
|
+
from ..protocol import AgentEvent, JSONDict, ToolCall, ToolResult
|
|
15
|
+
|
|
16
|
+
ANSI_RESET = "\x1b[0m"
|
|
17
|
+
ANSI_BOLD = "\x1b[1m"
|
|
18
|
+
ANSI_DIM = "\x1b[2m"
|
|
19
|
+
ANSI_GREEN = "\x1b[32m"
|
|
20
|
+
ANSI_BLUE = "\x1b[34m"
|
|
21
|
+
ANSI_CYAN = "\x1b[36m"
|
|
22
|
+
ANSI_YELLOW = "\x1b[33m"
|
|
23
|
+
ANSI_MAGENTA = "\x1b[35m"
|
|
24
|
+
ANSI_RED = "\x1b[31m"
|
|
25
|
+
SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def shorten_title(text: str, limit: int = 48) -> str:
|
|
29
|
+
compact = " ".join(text.split())
|
|
30
|
+
if len(compact) <= limit:
|
|
31
|
+
return compact
|
|
32
|
+
return compact[: limit - 3].rstrip() + "..."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cli_color_enabled() -> bool:
|
|
36
|
+
return os.environ.get("PYCODEX_NO_COLOR", "").strip().lower() not in {
|
|
37
|
+
"1",
|
|
38
|
+
"true",
|
|
39
|
+
"yes",
|
|
40
|
+
"on",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def colorize_cli_message(text: str, kind: str, enabled: bool) -> str:
|
|
45
|
+
if not enabled:
|
|
46
|
+
return text
|
|
47
|
+
palette = {
|
|
48
|
+
"assistant": ANSI_GREEN,
|
|
49
|
+
"plan": ANSI_CYAN,
|
|
50
|
+
"exec": ANSI_YELLOW,
|
|
51
|
+
"agent": ANSI_BLUE,
|
|
52
|
+
"web": ANSI_MAGENTA,
|
|
53
|
+
"status": ANSI_CYAN,
|
|
54
|
+
"tool": ANSI_DIM,
|
|
55
|
+
"error": ANSI_RED,
|
|
56
|
+
}
|
|
57
|
+
color = palette.get(kind)
|
|
58
|
+
if color is None:
|
|
59
|
+
return text
|
|
60
|
+
return f"{ANSI_BOLD}{color}{text}{ANSI_RESET}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_cli_plan_messages(
|
|
64
|
+
summary: str,
|
|
65
|
+
plan_items: list[JSONDict],
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
lines = [f"[plan] {summary}" if summary else "[plan] Plan updated"]
|
|
68
|
+
for item in plan_items:
|
|
69
|
+
step = str(item.get("step", "")).strip()
|
|
70
|
+
status = str(item.get("status", "")).strip()
|
|
71
|
+
if not step:
|
|
72
|
+
continue
|
|
73
|
+
marker = {
|
|
74
|
+
"completed": "[x]",
|
|
75
|
+
"in_progress": "[>]",
|
|
76
|
+
"pending": "[ ]",
|
|
77
|
+
}.get(status, "[ ]")
|
|
78
|
+
lines.append(f" {marker} {step}")
|
|
79
|
+
return lines
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_cli_spinner_frame(index: int, label: str) -> str:
|
|
83
|
+
suffix = f" {label}" if label else ""
|
|
84
|
+
return f"⏳{suffix} {SPINNER_FRAMES[index % len(SPINNER_FRAMES)]}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Spinner:
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
raw_write,
|
|
91
|
+
raw_flush,
|
|
92
|
+
terminal_lock: threading.RLock,
|
|
93
|
+
color_enabled: bool,
|
|
94
|
+
enabled: bool,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._raw_write = raw_write
|
|
97
|
+
self._raw_flush = raw_flush
|
|
98
|
+
self._terminal_lock = terminal_lock
|
|
99
|
+
self._color_enabled = color_enabled
|
|
100
|
+
self._enabled = enabled
|
|
101
|
+
self._visible = False
|
|
102
|
+
self._turn_active = False
|
|
103
|
+
self._paused = False
|
|
104
|
+
self._index = 0
|
|
105
|
+
self._label = "thinking"
|
|
106
|
+
self._stop = threading.Event()
|
|
107
|
+
self._thread: threading.Thread | None = None
|
|
108
|
+
if self._enabled:
|
|
109
|
+
self._thread = threading.Thread(
|
|
110
|
+
target=self._run,
|
|
111
|
+
name="pycodex-cli-spinner",
|
|
112
|
+
daemon=True,
|
|
113
|
+
)
|
|
114
|
+
self._thread.start()
|
|
115
|
+
|
|
116
|
+
def start_turn(self, label: str = "thinking") -> None:
|
|
117
|
+
with self._terminal_lock:
|
|
118
|
+
self._turn_active = True
|
|
119
|
+
self._paused = False
|
|
120
|
+
self._label = label
|
|
121
|
+
|
|
122
|
+
def set_label(self, label: str) -> None:
|
|
123
|
+
with self._terminal_lock:
|
|
124
|
+
self._label = label
|
|
125
|
+
|
|
126
|
+
def finish_turn(self) -> None:
|
|
127
|
+
with self._terminal_lock:
|
|
128
|
+
self._turn_active = False
|
|
129
|
+
self._paused = False
|
|
130
|
+
self.clear()
|
|
131
|
+
|
|
132
|
+
def pause(self) -> None:
|
|
133
|
+
with self._terminal_lock:
|
|
134
|
+
self._paused = True
|
|
135
|
+
self.clear()
|
|
136
|
+
|
|
137
|
+
def resume(self) -> None:
|
|
138
|
+
with self._terminal_lock:
|
|
139
|
+
self._paused = False
|
|
140
|
+
|
|
141
|
+
def clear(self) -> None:
|
|
142
|
+
if not self._enabled or not self._visible:
|
|
143
|
+
return
|
|
144
|
+
with self._terminal_lock:
|
|
145
|
+
self._raw_write("\r\x1b[2K")
|
|
146
|
+
self._raw_flush()
|
|
147
|
+
self._visible = False
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
self.finish_turn()
|
|
151
|
+
if self._thread is not None:
|
|
152
|
+
self._stop.set()
|
|
153
|
+
self._thread.join(timeout=0.5)
|
|
154
|
+
|
|
155
|
+
def prompt_line(self) -> str | None:
|
|
156
|
+
if not self._turn_active:
|
|
157
|
+
return None
|
|
158
|
+
with self._terminal_lock:
|
|
159
|
+
label = self._label
|
|
160
|
+
frame_index = int(time.monotonic() / 0.12)
|
|
161
|
+
return build_cli_spinner_frame(frame_index, label)
|
|
162
|
+
|
|
163
|
+
def _run(self) -> None:
|
|
164
|
+
while not self._stop.wait(0.12):
|
|
165
|
+
if not self._turn_active or self._paused:
|
|
166
|
+
continue
|
|
167
|
+
frame = colorize_cli_message(
|
|
168
|
+
build_cli_spinner_frame(self._index, self._label),
|
|
169
|
+
"status",
|
|
170
|
+
self._color_enabled,
|
|
171
|
+
)
|
|
172
|
+
self._index += 1
|
|
173
|
+
with self._terminal_lock:
|
|
174
|
+
if not self._turn_active or self._paused:
|
|
175
|
+
continue
|
|
176
|
+
self._raw_write(f"\r\x1b[2K{frame}")
|
|
177
|
+
self._raw_flush()
|
|
178
|
+
self._visible = True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def format_cli_tool_call_message(tool_name: str, payload: JSONDict) -> str | None:
|
|
182
|
+
if tool_name != "web_search":
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
action_type = str(payload.get("action_type", "")).strip()
|
|
186
|
+
if action_type == "search":
|
|
187
|
+
query = str(payload.get("query", "")).strip()
|
|
188
|
+
if not query:
|
|
189
|
+
queries = payload.get("queries")
|
|
190
|
+
if isinstance(queries, list) and queries:
|
|
191
|
+
query = str(queries[0]).strip()
|
|
192
|
+
return f"[web] searched: {query}" if query else "[web] searched"
|
|
193
|
+
|
|
194
|
+
if action_type == "open_page":
|
|
195
|
+
url = str(payload.get("url", "")).strip()
|
|
196
|
+
return f"[web] opened: {url}" if url else "[web] opened"
|
|
197
|
+
|
|
198
|
+
if action_type == "find_in_page":
|
|
199
|
+
pattern = str(payload.get("pattern", "")).strip()
|
|
200
|
+
url = str(payload.get("url", "")).strip()
|
|
201
|
+
if pattern and url:
|
|
202
|
+
return f"[web] found: {pattern} @ {url}"
|
|
203
|
+
if pattern:
|
|
204
|
+
return f"[web] found: {pattern}"
|
|
205
|
+
return "[web] found in page"
|
|
206
|
+
|
|
207
|
+
return "[web] browsing"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def short_id(value: str, limit: int = 8) -> str:
|
|
211
|
+
compact = value.strip()
|
|
212
|
+
if len(compact) <= limit + 4:
|
|
213
|
+
return compact
|
|
214
|
+
return f"{compact[:limit]}...{compact[-4:]}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def format_cli_tool_message(
|
|
218
|
+
tool_name: str,
|
|
219
|
+
summary: str,
|
|
220
|
+
is_error: bool,
|
|
221
|
+
) -> str:
|
|
222
|
+
if tool_name == "update_plan":
|
|
223
|
+
if is_error:
|
|
224
|
+
return f"[error] plan failed: {summary}" if summary else "[error] plan failed"
|
|
225
|
+
return f"[plan] {summary}" if summary else "[plan] Plan updated"
|
|
226
|
+
|
|
227
|
+
if tool_name in {
|
|
228
|
+
"exec_command",
|
|
229
|
+
"write_stdin",
|
|
230
|
+
"shell",
|
|
231
|
+
"shell_command",
|
|
232
|
+
"exec",
|
|
233
|
+
"wait",
|
|
234
|
+
}:
|
|
235
|
+
if is_error:
|
|
236
|
+
return f"[error] exec failed: {summary}" if summary else "[error] exec failed"
|
|
237
|
+
return f"[exec] {summary}" if summary else f"[exec] {tool_name}"
|
|
238
|
+
|
|
239
|
+
if tool_name == "spawn_agent":
|
|
240
|
+
if is_error:
|
|
241
|
+
return (
|
|
242
|
+
f"[error] agent spawn failed: {summary}"
|
|
243
|
+
if summary
|
|
244
|
+
else "[error] agent spawn failed"
|
|
245
|
+
)
|
|
246
|
+
return f"[agent] spawned {summary}" if summary else "[agent] spawned"
|
|
247
|
+
|
|
248
|
+
if tool_name == "send_input":
|
|
249
|
+
if is_error:
|
|
250
|
+
return (
|
|
251
|
+
f"[error] agent send failed: {summary}"
|
|
252
|
+
if summary
|
|
253
|
+
else "[error] agent send failed"
|
|
254
|
+
)
|
|
255
|
+
return f"[agent] send: {summary}" if summary else "[agent] send"
|
|
256
|
+
|
|
257
|
+
if tool_name == "wait_agent":
|
|
258
|
+
if is_error:
|
|
259
|
+
return (
|
|
260
|
+
f"[error] agent wait failed: {summary}"
|
|
261
|
+
if summary
|
|
262
|
+
else "[error] agent wait failed"
|
|
263
|
+
)
|
|
264
|
+
return f"[agent] wait: {summary}" if summary else "[agent] wait"
|
|
265
|
+
|
|
266
|
+
if tool_name == "resume_agent":
|
|
267
|
+
if is_error:
|
|
268
|
+
return (
|
|
269
|
+
f"[error] agent resume failed: {summary}"
|
|
270
|
+
if summary
|
|
271
|
+
else "[error] agent resume failed"
|
|
272
|
+
)
|
|
273
|
+
return f"[agent] resume: {summary}" if summary else "[agent] resume"
|
|
274
|
+
|
|
275
|
+
if tool_name == "close_agent":
|
|
276
|
+
if is_error:
|
|
277
|
+
return (
|
|
278
|
+
f"[error] agent close failed: {summary}"
|
|
279
|
+
if summary
|
|
280
|
+
else "[error] agent close failed"
|
|
281
|
+
)
|
|
282
|
+
return f"[agent] close: {summary}" if summary else "[agent] close"
|
|
283
|
+
|
|
284
|
+
if is_error:
|
|
285
|
+
return (
|
|
286
|
+
f"[error] {tool_name} failed: {summary}"
|
|
287
|
+
if summary
|
|
288
|
+
else f"[error] {tool_name} failed"
|
|
289
|
+
)
|
|
290
|
+
return f"[tool] {tool_name}: {summary}" if summary else f"[tool] {tool_name}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def extract_plan_items(arguments: object) -> list[JSONDict]:
|
|
294
|
+
if not isinstance(arguments, dict):
|
|
295
|
+
return []
|
|
296
|
+
raw_plan = arguments.get("plan")
|
|
297
|
+
if not isinstance(raw_plan, list):
|
|
298
|
+
return []
|
|
299
|
+
plan_items: list[JSONDict] = []
|
|
300
|
+
for item in raw_plan:
|
|
301
|
+
if not isinstance(item, dict):
|
|
302
|
+
continue
|
|
303
|
+
plan_items.append(
|
|
304
|
+
{
|
|
305
|
+
"step": str(item.get("step", "")).strip(),
|
|
306
|
+
"status": str(item.get("status", "")).strip(),
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
return plan_items
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def summarize_tool_event(call: ToolCall, result: ToolResult) -> str | None:
|
|
313
|
+
command_preview = _command_preview(call)
|
|
314
|
+
result_summary = _summarize_tool_result(result)
|
|
315
|
+
if call.name == "update_plan":
|
|
316
|
+
return command_preview or result_summary
|
|
317
|
+
if command_preview and result_summary:
|
|
318
|
+
return f"{command_preview} -> {result_summary}"
|
|
319
|
+
if command_preview:
|
|
320
|
+
return command_preview
|
|
321
|
+
return result_summary
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def extract_tool_event_display(
|
|
325
|
+
payload: dict[str, object],
|
|
326
|
+
) -> tuple[str, str, bool]:
|
|
327
|
+
tool_name = str(payload.get("tool_name", "")).strip()
|
|
328
|
+
is_error = bool(payload.get("is_error"))
|
|
329
|
+
call = payload.get("call")
|
|
330
|
+
result = payload.get("result")
|
|
331
|
+
if isinstance(call, ToolCall) and isinstance(result, ToolResult):
|
|
332
|
+
return tool_name, summarize_tool_event(call, result) or "", is_error
|
|
333
|
+
summary = str(payload.get("summary", "") or "").strip()
|
|
334
|
+
return tool_name, summary, is_error
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def extract_plan_event_items(payload: dict[str, object]) -> list[JSONDict]:
|
|
338
|
+
call = payload.get("call")
|
|
339
|
+
if isinstance(call, ToolCall):
|
|
340
|
+
return extract_plan_items(call.arguments)
|
|
341
|
+
raw_plan_items = payload.get("plan_items")
|
|
342
|
+
if isinstance(raw_plan_items, list):
|
|
343
|
+
return [item for item in raw_plan_items if isinstance(item, dict)]
|
|
344
|
+
return []
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _truncate_text(text: str, limit: int = 96) -> str:
|
|
348
|
+
compact = " ".join(text.split())
|
|
349
|
+
if len(compact) <= limit:
|
|
350
|
+
return compact
|
|
351
|
+
return compact[: limit - 3].rstrip() + "..."
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _extract_output_preview(text: str) -> str | None:
|
|
355
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
356
|
+
if "Output:" in lines:
|
|
357
|
+
output_index = lines.index("Output:")
|
|
358
|
+
for line in lines[output_index + 1 :]:
|
|
359
|
+
if line:
|
|
360
|
+
return _truncate_text(line)
|
|
361
|
+
|
|
362
|
+
for line in lines:
|
|
363
|
+
if not line:
|
|
364
|
+
continue
|
|
365
|
+
if line.startswith(("Exit code:", "Wall time:", "Command:")):
|
|
366
|
+
continue
|
|
367
|
+
return _truncate_text(line)
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _summarize_agent_status(status: object) -> str:
|
|
372
|
+
if isinstance(status, str):
|
|
373
|
+
return status
|
|
374
|
+
if isinstance(status, dict):
|
|
375
|
+
if "completed" in status:
|
|
376
|
+
completed = status.get("completed")
|
|
377
|
+
if completed is None:
|
|
378
|
+
return "completed"
|
|
379
|
+
return f"completed: {_truncate_text(str(completed), limit=48)}"
|
|
380
|
+
if "errored" in status:
|
|
381
|
+
return f"errored: {_truncate_text(str(status.get('errored', '')), limit=48)}"
|
|
382
|
+
return _truncate_text(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _summarize_tool_result(result: ToolResult) -> str | None:
|
|
386
|
+
if result.name == "spawn_agent" and isinstance(result.output, dict):
|
|
387
|
+
agent_id = str(result.output.get("agent_id", "")).strip()
|
|
388
|
+
nickname = str(result.output.get("nickname", "")).strip()
|
|
389
|
+
if nickname and agent_id:
|
|
390
|
+
return f"{nickname} ({short_id(agent_id)})"
|
|
391
|
+
if result.name == "send_input" and isinstance(result.output, dict):
|
|
392
|
+
submission_id = str(result.output.get("submission_id", "")).strip()
|
|
393
|
+
if submission_id:
|
|
394
|
+
return f"queued {short_id(submission_id)}"
|
|
395
|
+
if result.name in {"resume_agent", "close_agent"} and isinstance(result.output, dict):
|
|
396
|
+
return _summarize_agent_status(result.output.get("status"))
|
|
397
|
+
if result.name == "wait_agent" and isinstance(result.output, dict):
|
|
398
|
+
if result.output.get("timed_out") is True:
|
|
399
|
+
return "timed out"
|
|
400
|
+
status = result.output.get("status")
|
|
401
|
+
if isinstance(status, dict):
|
|
402
|
+
parts: list[str] = []
|
|
403
|
+
for agent_id, agent_status in status.items():
|
|
404
|
+
if not isinstance(agent_id, str):
|
|
405
|
+
continue
|
|
406
|
+
parts.append(
|
|
407
|
+
f"{short_id(agent_id)}={_summarize_agent_status(agent_status)}"
|
|
408
|
+
)
|
|
409
|
+
if parts:
|
|
410
|
+
return _truncate_text(", ".join(parts), limit=96)
|
|
411
|
+
if result.name == "update_plan" and isinstance(result.output, dict):
|
|
412
|
+
plan = result.output.get("plan")
|
|
413
|
+
if isinstance(plan, list):
|
|
414
|
+
return f"{len(plan)} steps"
|
|
415
|
+
if result.name == "view_image" and isinstance(result.output, list):
|
|
416
|
+
return f"{len(result.output)} image item(s)"
|
|
417
|
+
if isinstance(result.output, (dict, list)):
|
|
418
|
+
return _truncate_text(
|
|
419
|
+
json.dumps(result.output, ensure_ascii=False, separators=(",", ":"))
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
preview = _extract_output_preview(result.output_text())
|
|
423
|
+
if preview:
|
|
424
|
+
return preview
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _string_arg(arguments: object, key: str) -> str | None:
|
|
429
|
+
if not isinstance(arguments, dict):
|
|
430
|
+
return None
|
|
431
|
+
value = arguments.get(key)
|
|
432
|
+
if value in (None, ""):
|
|
433
|
+
return None
|
|
434
|
+
return str(value)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _int_arg(arguments: object, key: str) -> int | None:
|
|
438
|
+
if not isinstance(arguments, dict):
|
|
439
|
+
return None
|
|
440
|
+
value = arguments.get(key)
|
|
441
|
+
if value in (None, ""):
|
|
442
|
+
return None
|
|
443
|
+
return int(value)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _command_preview(call: ToolCall) -> str | None:
|
|
447
|
+
if call.name == "exec_command":
|
|
448
|
+
cmd = _string_arg(call.arguments, "cmd")
|
|
449
|
+
if cmd:
|
|
450
|
+
return _truncate_text(cmd, limit=72)
|
|
451
|
+
if call.name == "shell_command":
|
|
452
|
+
command = _string_arg(call.arguments, "command")
|
|
453
|
+
if command:
|
|
454
|
+
return _truncate_text(command, limit=72)
|
|
455
|
+
if call.name == "shell" and isinstance(call.arguments, dict):
|
|
456
|
+
command = call.arguments.get("command")
|
|
457
|
+
if isinstance(command, list) and command:
|
|
458
|
+
rendered = " ".join(shlex.quote(str(part)) for part in command)
|
|
459
|
+
return _truncate_text(rendered, limit=72)
|
|
460
|
+
if call.name == "write_stdin":
|
|
461
|
+
session_id = _int_arg(call.arguments, "session_id")
|
|
462
|
+
chars = _string_arg(call.arguments, "chars") or ""
|
|
463
|
+
if session_id is None:
|
|
464
|
+
return None
|
|
465
|
+
if not chars:
|
|
466
|
+
return f"poll session {session_id}"
|
|
467
|
+
return f"session {session_id} <- {_truncate_text(chars, limit=32)}"
|
|
468
|
+
if call.name == "read_file":
|
|
469
|
+
path = _string_arg(call.arguments, "file_path")
|
|
470
|
+
if path:
|
|
471
|
+
return _truncate_text(path, limit=72)
|
|
472
|
+
if call.name == "list_dir":
|
|
473
|
+
path = _string_arg(call.arguments, "dir_path")
|
|
474
|
+
if path:
|
|
475
|
+
return _truncate_text(path, limit=72)
|
|
476
|
+
if call.name == "grep_files":
|
|
477
|
+
pattern = _string_arg(call.arguments, "pattern")
|
|
478
|
+
path = _string_arg(call.arguments, "path")
|
|
479
|
+
if pattern and path:
|
|
480
|
+
return _truncate_text(f"{pattern} @ {path}", limit=72)
|
|
481
|
+
if pattern:
|
|
482
|
+
return _truncate_text(pattern, limit=72)
|
|
483
|
+
if call.name == "view_image":
|
|
484
|
+
path = _string_arg(call.arguments, "path")
|
|
485
|
+
if path:
|
|
486
|
+
return _truncate_text(path, limit=72)
|
|
487
|
+
if call.name == "update_plan" and isinstance(call.arguments, dict):
|
|
488
|
+
plan = call.arguments.get("plan")
|
|
489
|
+
if isinstance(plan, list):
|
|
490
|
+
return _plan_progress_summary(plan)
|
|
491
|
+
if call.name == "send_input":
|
|
492
|
+
agent_id = _string_arg(call.arguments, "id")
|
|
493
|
+
message = _string_arg(call.arguments, "message")
|
|
494
|
+
prefix = f"{short_id(agent_id)} <- " if agent_id else ""
|
|
495
|
+
if message:
|
|
496
|
+
return f"{prefix}{_truncate_text(message, limit=40)}"
|
|
497
|
+
if prefix:
|
|
498
|
+
return prefix.rstrip()
|
|
499
|
+
if call.name in {"resume_agent", "close_agent"}:
|
|
500
|
+
agent_id = _string_arg(call.arguments, "id")
|
|
501
|
+
if agent_id:
|
|
502
|
+
return short_id(agent_id)
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _plan_progress_summary(plan: list[object]) -> str:
|
|
507
|
+
total = len(plan)
|
|
508
|
+
completed = 0
|
|
509
|
+
in_progress = 0
|
|
510
|
+
|
|
511
|
+
for item in plan:
|
|
512
|
+
if not isinstance(item, dict):
|
|
513
|
+
continue
|
|
514
|
+
status = str(item.get("status", "")).strip()
|
|
515
|
+
if status == "completed":
|
|
516
|
+
completed += 1
|
|
517
|
+
elif status == "in_progress":
|
|
518
|
+
in_progress += 1
|
|
519
|
+
|
|
520
|
+
if total == 0:
|
|
521
|
+
return "0 steps"
|
|
522
|
+
if completed >= total:
|
|
523
|
+
return f"Done {completed}/{total}"
|
|
524
|
+
if in_progress:
|
|
525
|
+
return f"Working on {completed + in_progress}/{total}"
|
|
526
|
+
return f"Planned {completed}/{total}"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class CliSessionView:
|
|
530
|
+
"""Own the interactive CLI terminal surface for one session.
|
|
531
|
+
|
|
532
|
+
This class is the single place that knows how to:
|
|
533
|
+
- render `AgentEvent`s into human-facing terminal output;
|
|
534
|
+
- multiplex prompt input, streamed assistant output, and spinner state;
|
|
535
|
+
- keep lightweight session UI state such as title, history, and steer markers.
|
|
536
|
+
|
|
537
|
+
Public interface:
|
|
538
|
+
- `handle_event(event)`: feed runtime/agent events into the view.
|
|
539
|
+
- `poll_prompt(prompt)`: poll one prompt-toolkit input task; returns one input
|
|
540
|
+
line, or `None` when input is still pending. `EOFError` means the input
|
|
541
|
+
source has closed and the caller should end the session loop.
|
|
542
|
+
- `write_line(text)`, `finish_stream()`, `show_error(text)`: imperative output
|
|
543
|
+
helpers for CLI-side messages that do not come from `AgentEvent`.
|
|
544
|
+
- `show_history()`, `show_title()`, `show_steer_queued(...)`,
|
|
545
|
+
`schedule_steer_inserted(...)`: small session UI helpers used by the
|
|
546
|
+
interactive command loop.
|
|
547
|
+
- `close()`: release prompt/spinner resources at shutdown.
|
|
548
|
+
|
|
549
|
+
Typical usage from the CLI loop:
|
|
550
|
+
1. Create one `CliSessionView` for the whole interactive session.
|
|
551
|
+
2. Register `view.handle_event` as the runtime event handler.
|
|
552
|
+
3. Repeatedly call `await view.poll_prompt("pycodex> ")`.
|
|
553
|
+
4. On shutdown, call `view.close()`.
|
|
554
|
+
|
|
555
|
+
Notes:
|
|
556
|
+
- Treat this as a session-scoped object. It keeps mutable state across turns,
|
|
557
|
+
including prompt buffering and rendered history.
|
|
558
|
+
- `poll_prompt()` owns the prompt task lifecycle. Do not drive
|
|
559
|
+
`prompt_async()` concurrently from outside the view.
|
|
560
|
+
- Stream handoff is intentional: when assistant output starts while the user
|
|
561
|
+
prompt is active, the view moves buffered prompt-managed output back to the
|
|
562
|
+
normal terminal stream so the reply is not lost.
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
def __init__(self) -> None:
|
|
566
|
+
import sys
|
|
567
|
+
|
|
568
|
+
self._line_output = print
|
|
569
|
+
self._raw_write = sys.stdout.write
|
|
570
|
+
self._raw_flush = sys.stdout.flush
|
|
571
|
+
self._terminal_lock = threading.RLock()
|
|
572
|
+
self._title: str | None = None
|
|
573
|
+
self._pending_user_prompts: dict[str, str] = {}
|
|
574
|
+
self._queued_steer_prompts: dict[str, list[str]] = {}
|
|
575
|
+
self._inserted_steer_prompts: dict[str, list[str]] = {}
|
|
576
|
+
self._history: list[tuple[str, str]] = []
|
|
577
|
+
self._streaming = False
|
|
578
|
+
self._prompt_stream_buffer = ""
|
|
579
|
+
self._streaming_in_prompt = False
|
|
580
|
+
self._input_active = False
|
|
581
|
+
self._color_enabled = cli_color_enabled() and sys.stdout.isatty()
|
|
582
|
+
self._agent_names: dict[str, str] = {}
|
|
583
|
+
self._prompt_session: PromptSession | None = None
|
|
584
|
+
self._prompt_task: asyncio.Task[str] | None = None
|
|
585
|
+
self._stdout_proxy: StdoutProxy | None = None
|
|
586
|
+
self._spinner = Spinner(
|
|
587
|
+
self._raw_write,
|
|
588
|
+
self._raw_flush,
|
|
589
|
+
self._terminal_lock,
|
|
590
|
+
self._color_enabled,
|
|
591
|
+
False,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def handle_event(self, event: AgentEvent) -> None:
|
|
595
|
+
if event.kind == "turn_started":
|
|
596
|
+
submission_id = str(event.payload.get("submission_id", event.turn_id)).strip()
|
|
597
|
+
user_texts = event.payload.get("user_texts")
|
|
598
|
+
if isinstance(user_texts, list):
|
|
599
|
+
normalized_user_texts = [
|
|
600
|
+
str(text).strip() for text in user_texts if str(text).strip()
|
|
601
|
+
]
|
|
602
|
+
else:
|
|
603
|
+
normalized_user_texts = []
|
|
604
|
+
user_text = str(event.payload.get("user_text", "")).strip()
|
|
605
|
+
if not user_text and normalized_user_texts:
|
|
606
|
+
user_text = "\n".join(normalized_user_texts)
|
|
607
|
+
if self._title is None and user_text:
|
|
608
|
+
self._title = shorten_title(user_text)
|
|
609
|
+
self._print_line(f"Session: {self._title}")
|
|
610
|
+
if user_text:
|
|
611
|
+
self._pending_user_prompts[submission_id] = user_text
|
|
612
|
+
inserted_steer_prompts = self._inserted_steer_prompts.pop(submission_id, [])
|
|
613
|
+
for inserted_steer_prompt in inserted_steer_prompts:
|
|
614
|
+
self._print_line(
|
|
615
|
+
colorize_cli_message(
|
|
616
|
+
f"[steer] inserted: {inserted_steer_prompt}",
|
|
617
|
+
"status",
|
|
618
|
+
self._color_enabled,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
queued_steer_prompts = self._queued_steer_prompts.pop(submission_id, [])
|
|
622
|
+
for queued_steer_prompt in queued_steer_prompts:
|
|
623
|
+
self._print_line(
|
|
624
|
+
colorize_cli_message(
|
|
625
|
+
f"[steer] inserted: {queued_steer_prompt}",
|
|
626
|
+
"status",
|
|
627
|
+
self._color_enabled,
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
self._spinner.start_turn("thinking")
|
|
631
|
+
if self._input_active:
|
|
632
|
+
self._spinner.pause()
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
if event.kind == "model_called":
|
|
636
|
+
if self._input_active:
|
|
637
|
+
self._spinner.pause()
|
|
638
|
+
else:
|
|
639
|
+
self._spinner.resume()
|
|
640
|
+
self._spinner.set_label("waiting model")
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
if event.kind == "assistant_delta":
|
|
644
|
+
delta = str(event.payload.get("delta", ""))
|
|
645
|
+
if not delta:
|
|
646
|
+
return
|
|
647
|
+
if self._input_active:
|
|
648
|
+
if not self._streaming:
|
|
649
|
+
self._streaming = True
|
|
650
|
+
self._streaming_in_prompt = True
|
|
651
|
+
self._prompt_stream_buffer = ""
|
|
652
|
+
self._prompt_stream_buffer += delta
|
|
653
|
+
return
|
|
654
|
+
with self._terminal_lock:
|
|
655
|
+
# Pause the spinner before streaming assistant text to avoid interleaving.
|
|
656
|
+
if not self._streaming:
|
|
657
|
+
self._spinner.pause()
|
|
658
|
+
if not self._streaming:
|
|
659
|
+
self._raw_write(
|
|
660
|
+
"assistant> "
|
|
661
|
+
)
|
|
662
|
+
self._streaming = True
|
|
663
|
+
self._raw_write(delta)
|
|
664
|
+
self._raw_flush()
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
if event.kind == "tool_called":
|
|
668
|
+
tool_name = str(event.payload.get("tool_name", "")).strip()
|
|
669
|
+
message = format_cli_tool_call_message(tool_name, event.payload)
|
|
670
|
+
if message is not None:
|
|
671
|
+
self._finish_stream()
|
|
672
|
+
self._print_line(
|
|
673
|
+
colorize_cli_message(message, "web", self._color_enabled)
|
|
674
|
+
)
|
|
675
|
+
if self._input_active:
|
|
676
|
+
self._spinner.pause()
|
|
677
|
+
else:
|
|
678
|
+
self._spinner.resume()
|
|
679
|
+
self._spinner.set_label("running tools")
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
if event.kind == "tool_started":
|
|
683
|
+
self._finish_stream()
|
|
684
|
+
if self._input_active:
|
|
685
|
+
self._spinner.pause()
|
|
686
|
+
else:
|
|
687
|
+
self._spinner.resume()
|
|
688
|
+
self._spinner.set_label("running tools")
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
if event.kind == "tool_completed":
|
|
692
|
+
self._finish_stream()
|
|
693
|
+
if self._input_active:
|
|
694
|
+
self._spinner.pause()
|
|
695
|
+
else:
|
|
696
|
+
self._spinner.resume()
|
|
697
|
+
self._spinner.set_label("thinking")
|
|
698
|
+
tool_name, summary, is_error = extract_tool_event_display(event.payload)
|
|
699
|
+
summary = self._rewrite_agent_summary(tool_name, summary)
|
|
700
|
+
if tool_name == "update_plan" and not is_error:
|
|
701
|
+
plan_items = extract_plan_event_items(event.payload)
|
|
702
|
+
for line in format_cli_plan_messages(summary, plan_items):
|
|
703
|
+
self._print_line(
|
|
704
|
+
colorize_cli_message(line, "plan", self._color_enabled)
|
|
705
|
+
)
|
|
706
|
+
return
|
|
707
|
+
message = format_cli_tool_message(
|
|
708
|
+
tool_name,
|
|
709
|
+
summary,
|
|
710
|
+
is_error,
|
|
711
|
+
)
|
|
712
|
+
self._remember_agent_name(tool_name, summary)
|
|
713
|
+
self._print_line(self._colorize_formatted_tool_message(message))
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
if event.kind == "turn_completed":
|
|
717
|
+
submission_id = str(event.payload.get("submission_id", event.turn_id)).strip()
|
|
718
|
+
final_text = str(event.payload.get("output_text", "") or "")
|
|
719
|
+
self._finalize_turn_output(final_text, allow_standalone_output=True)
|
|
720
|
+
pending_prompt = self._pending_user_prompts.pop(submission_id, None)
|
|
721
|
+
if pending_prompt is not None:
|
|
722
|
+
self._history.append((pending_prompt, final_text))
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
if event.kind == "turn_failed":
|
|
726
|
+
submission_id = str(event.payload.get("submission_id", event.turn_id)).strip()
|
|
727
|
+
self._spinner.finish_turn()
|
|
728
|
+
self._finish_stream()
|
|
729
|
+
self._pending_user_prompts.pop(submission_id, None)
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
if event.kind == "turn_interrupted":
|
|
733
|
+
submission_id = str(event.payload.get("submission_id", event.turn_id)).strip()
|
|
734
|
+
final_text = str(event.payload.get("output_text", "") or "")
|
|
735
|
+
self._finalize_turn_output(final_text, allow_standalone_output=False)
|
|
736
|
+
pending_prompt = self._pending_user_prompts.pop(submission_id, None)
|
|
737
|
+
if pending_prompt is not None and final_text:
|
|
738
|
+
self._history.append((pending_prompt, final_text))
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
def show_history(self) -> None:
|
|
742
|
+
self._finish_stream()
|
|
743
|
+
if not self._history:
|
|
744
|
+
self._print_line("No history yet.")
|
|
745
|
+
return
|
|
746
|
+
|
|
747
|
+
self._print_line(f"Session: {self._title or 'untitled'}")
|
|
748
|
+
for index, (user_text, assistant_text) in enumerate(self._history, start=1):
|
|
749
|
+
self._print_line(f"[{index}] user> {user_text}")
|
|
750
|
+
self._print_line(f" assistant> {assistant_text}")
|
|
751
|
+
|
|
752
|
+
def show_title(self) -> None:
|
|
753
|
+
self._finish_stream()
|
|
754
|
+
self._print_line(f"Session: {self._title or 'untitled'}")
|
|
755
|
+
|
|
756
|
+
def pause_spinner(self) -> None:
|
|
757
|
+
self._spinner.pause()
|
|
758
|
+
|
|
759
|
+
def resume_spinner(self) -> None:
|
|
760
|
+
self._spinner.resume()
|
|
761
|
+
|
|
762
|
+
def set_input_active(self, active: bool, resume_spinner: bool = True) -> None:
|
|
763
|
+
self._input_active = active
|
|
764
|
+
if active:
|
|
765
|
+
self._spinner.pause()
|
|
766
|
+
elif resume_spinner:
|
|
767
|
+
self._spinner.resume()
|
|
768
|
+
|
|
769
|
+
def is_streaming_output(self) -> bool:
|
|
770
|
+
return self._streaming
|
|
771
|
+
|
|
772
|
+
def handoff_prompt_stream_to_output(self) -> None:
|
|
773
|
+
if not self._streaming or not self._streaming_in_prompt:
|
|
774
|
+
return
|
|
775
|
+
buffered = self._prompt_stream_buffer
|
|
776
|
+
self._prompt_stream_buffer = ""
|
|
777
|
+
self._streaming_in_prompt = False
|
|
778
|
+
if not buffered:
|
|
779
|
+
return
|
|
780
|
+
with self._terminal_lock:
|
|
781
|
+
self._raw_write("assistant> ")
|
|
782
|
+
self._raw_write(buffered)
|
|
783
|
+
self._raw_flush()
|
|
784
|
+
|
|
785
|
+
async def poll_prompt(self, prompt: str) -> str | None:
|
|
786
|
+
if self._prompt_task is None:
|
|
787
|
+
if self.is_streaming_output():
|
|
788
|
+
return None
|
|
789
|
+
self._prompt_task = asyncio.create_task(self.prompt_async(prompt))
|
|
790
|
+
|
|
791
|
+
done, _pending = await asyncio.wait(
|
|
792
|
+
{self._prompt_task},
|
|
793
|
+
timeout=0.05,
|
|
794
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
795
|
+
)
|
|
796
|
+
if self._prompt_task not in done:
|
|
797
|
+
if self.is_streaming_output():
|
|
798
|
+
await self._handoff_prompt_task_to_output()
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
prompt_task = self._prompt_task
|
|
802
|
+
self._prompt_task = None
|
|
803
|
+
try:
|
|
804
|
+
return prompt_task.result()
|
|
805
|
+
except asyncio.CancelledError:
|
|
806
|
+
return None
|
|
807
|
+
finally:
|
|
808
|
+
self.set_input_active(False, resume_spinner=False)
|
|
809
|
+
|
|
810
|
+
def build_input_prompt(self, prompt: str) -> str:
|
|
811
|
+
if not self._input_active:
|
|
812
|
+
return prompt
|
|
813
|
+
if self._streaming and self._streaming_in_prompt:
|
|
814
|
+
if self._prompt_stream_buffer:
|
|
815
|
+
return f"assistant> {self._prompt_stream_buffer}\n"
|
|
816
|
+
return "\n"
|
|
817
|
+
prompt_line = self._spinner.prompt_line()
|
|
818
|
+
if not prompt_line:
|
|
819
|
+
return prompt
|
|
820
|
+
return f"{prompt_line}\n{prompt}"
|
|
821
|
+
|
|
822
|
+
def show_steer_queued(self, turn_id: str, prompt: str) -> None:
|
|
823
|
+
preview = shorten_title(prompt, limit=72)
|
|
824
|
+
self._queued_steer_prompts.setdefault(turn_id, []).append(preview)
|
|
825
|
+
self._print_line(
|
|
826
|
+
colorize_cli_message(
|
|
827
|
+
f"[steer] queued: {preview}",
|
|
828
|
+
"status",
|
|
829
|
+
self._color_enabled,
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
|
|
834
|
+
self._inserted_steer_prompts.setdefault(turn_id, []).append(
|
|
835
|
+
shorten_title(prompt, limit=72)
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
def close(self) -> None:
|
|
839
|
+
if self._prompt_task is not None and not self._prompt_task.done():
|
|
840
|
+
self._prompt_task.cancel()
|
|
841
|
+
self._prompt_task = None
|
|
842
|
+
self._spinner.close()
|
|
843
|
+
if self._stdout_proxy is not None:
|
|
844
|
+
self._stdout_proxy.close()
|
|
845
|
+
|
|
846
|
+
def finish_stream(self) -> None:
|
|
847
|
+
self._finish_stream()
|
|
848
|
+
|
|
849
|
+
def write_line(self, text: str) -> None:
|
|
850
|
+
self._print_line(text)
|
|
851
|
+
|
|
852
|
+
def show_error(self, text: str) -> None:
|
|
853
|
+
self._spinner.finish_turn()
|
|
854
|
+
self._finish_stream()
|
|
855
|
+
self._print_line(
|
|
856
|
+
colorize_cli_message(
|
|
857
|
+
f"Error: {text}",
|
|
858
|
+
"error",
|
|
859
|
+
self._color_enabled,
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
def _finish_stream(self) -> None:
|
|
864
|
+
with self._terminal_lock:
|
|
865
|
+
self._spinner.clear()
|
|
866
|
+
if self._streaming:
|
|
867
|
+
self._raw_write("\n")
|
|
868
|
+
self._raw_flush()
|
|
869
|
+
self._streaming = False
|
|
870
|
+
self._streaming_in_prompt = False
|
|
871
|
+
self._prompt_stream_buffer = ""
|
|
872
|
+
|
|
873
|
+
def _finalize_turn_output(
|
|
874
|
+
self,
|
|
875
|
+
final_text: str,
|
|
876
|
+
allow_standalone_output: bool,
|
|
877
|
+
) -> None:
|
|
878
|
+
self._spinner.finish_turn()
|
|
879
|
+
if self._streaming and self._streaming_in_prompt:
|
|
880
|
+
streamed_text = self._prompt_stream_buffer
|
|
881
|
+
self._streaming = False
|
|
882
|
+
self._streaming_in_prompt = False
|
|
883
|
+
self._prompt_stream_buffer = ""
|
|
884
|
+
final_display_text = final_text or streamed_text
|
|
885
|
+
if final_display_text:
|
|
886
|
+
self._print_line(
|
|
887
|
+
colorize_cli_message(
|
|
888
|
+
f"assistant> {final_display_text}",
|
|
889
|
+
"assistant",
|
|
890
|
+
self._color_enabled,
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
return
|
|
894
|
+
if self._streaming:
|
|
895
|
+
self._finish_stream()
|
|
896
|
+
return
|
|
897
|
+
if allow_standalone_output and final_text:
|
|
898
|
+
self._print_line(
|
|
899
|
+
colorize_cli_message(
|
|
900
|
+
f"assistant> {final_text}",
|
|
901
|
+
"assistant",
|
|
902
|
+
self._color_enabled,
|
|
903
|
+
)
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
def _colorize_formatted_tool_message(self, message: str) -> str:
|
|
907
|
+
if message.startswith("[plan]"):
|
|
908
|
+
return colorize_cli_message(message, "plan", self._color_enabled)
|
|
909
|
+
if message.startswith("[exec]"):
|
|
910
|
+
return colorize_cli_message(message, "exec", self._color_enabled)
|
|
911
|
+
if message.startswith("[agent]"):
|
|
912
|
+
return colorize_cli_message(message, "agent", self._color_enabled)
|
|
913
|
+
if message.startswith("[web]"):
|
|
914
|
+
return colorize_cli_message(message, "web", self._color_enabled)
|
|
915
|
+
if message.startswith("[error]"):
|
|
916
|
+
return colorize_cli_message(message, "error", self._color_enabled)
|
|
917
|
+
return colorize_cli_message(message, "tool", self._color_enabled)
|
|
918
|
+
|
|
919
|
+
def _print_line(self, text: str) -> None:
|
|
920
|
+
with self._terminal_lock:
|
|
921
|
+
self._spinner.clear()
|
|
922
|
+
self._line_output(text)
|
|
923
|
+
|
|
924
|
+
def _remember_agent_name(self, tool_name: str, summary: str) -> None:
|
|
925
|
+
if tool_name != "spawn_agent":
|
|
926
|
+
return
|
|
927
|
+
if " (" not in summary or not summary.endswith(")"):
|
|
928
|
+
return
|
|
929
|
+
nickname, rest = summary.rsplit(" (", 1)
|
|
930
|
+
agent_short_id = rest[:-1].strip()
|
|
931
|
+
nickname = nickname.strip()
|
|
932
|
+
if not nickname or not agent_short_id:
|
|
933
|
+
return
|
|
934
|
+
self._agent_names[agent_short_id] = nickname
|
|
935
|
+
|
|
936
|
+
def _rewrite_agent_summary(self, tool_name: str, summary: str) -> str:
|
|
937
|
+
if tool_name not in {"wait_agent", "send_input", "resume_agent", "close_agent"}:
|
|
938
|
+
return summary
|
|
939
|
+
rewritten = summary
|
|
940
|
+
for agent_short_id, nickname in sorted(
|
|
941
|
+
self._agent_names.items(),
|
|
942
|
+
key=lambda item: len(item[0]),
|
|
943
|
+
reverse=True,
|
|
944
|
+
):
|
|
945
|
+
rewritten = rewritten.replace(agent_short_id, nickname)
|
|
946
|
+
return rewritten
|
|
947
|
+
|
|
948
|
+
async def prompt_async(self, prompt: str) -> str:
|
|
949
|
+
if self._prompt_session is None:
|
|
950
|
+
self._prompt_session = PromptSession(
|
|
951
|
+
erase_when_done=True,
|
|
952
|
+
enable_system_prompt=True,
|
|
953
|
+
)
|
|
954
|
+
if self._stdout_proxy is None:
|
|
955
|
+
self._stdout_proxy = StdoutProxy(raw=False)
|
|
956
|
+
self._raw_write = self._stdout_proxy.write
|
|
957
|
+
self._raw_flush = self._stdout_proxy.flush
|
|
958
|
+
|
|
959
|
+
self.set_input_active(True)
|
|
960
|
+
try:
|
|
961
|
+
with patch_stdout(raw=True):
|
|
962
|
+
return await self._prompt_session.prompt_async(
|
|
963
|
+
lambda: self.build_input_prompt(prompt),
|
|
964
|
+
refresh_interval=0.12,
|
|
965
|
+
)
|
|
966
|
+
finally:
|
|
967
|
+
self.set_input_active(False, resume_spinner=False)
|
|
968
|
+
|
|
969
|
+
async def _handoff_prompt_task_to_output(self) -> None:
|
|
970
|
+
if self._prompt_task is None:
|
|
971
|
+
return
|
|
972
|
+
prompt_task = self._prompt_task
|
|
973
|
+
self._prompt_task = None
|
|
974
|
+
prompt_task.cancel()
|
|
975
|
+
with suppress(asyncio.CancelledError):
|
|
976
|
+
await prompt_task
|
|
977
|
+
self.set_input_active(False, resume_spinner=False)
|
|
978
|
+
self.handoff_prompt_stream_to_output()
|