python-codex 0.1.12__py3-none-any.whl → 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.
- pycodex/__init__.py +10 -8
- pycodex/agent.py +41 -17
- pycodex/cli.py +198 -145
- pycodex/compat.py +8 -4
- pycodex/feishu_card.py +693 -0
- pycodex/feishu_link.py +342 -0
- pycodex/model.py +89 -7
- pycodex/prompts/models.json +4 -4
- pycodex/protocol.py +17 -17
- pycodex/runtime.py +9 -14
- pycodex/runtime_services.py +45 -23
- pycodex/tools/apply_patch_tool.py +11 -12
- pycodex/tools/ipython_tool.py +144 -0
- pycodex/tools/unified_exec_manager.py +3 -0
- pycodex/utils/__init__.py +2 -13
- pycodex/utils/async_bridge.py +54 -0
- pycodex/utils/compactor.py +22 -9
- pycodex/utils/session_persist.py +57 -38
- pycodex/utils/toolcall_visualize.py +713 -0
- pycodex/utils/visualize.py +223 -861
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/METADATA +1 -1
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/RECORD +25 -20
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/WHEEL +0 -0
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/entry_points.txt +0 -0
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/licenses/LICENSE +0 -0
pycodex/utils/visualize.py
CHANGED
|
@@ -1,40 +1,32 @@
|
|
|
1
|
-
|
|
2
1
|
import asyncio
|
|
3
|
-
import
|
|
2
|
+
import inspect
|
|
4
3
|
import os
|
|
5
|
-
import shlex
|
|
6
4
|
import threading
|
|
7
|
-
import time
|
|
8
5
|
from contextlib import suppress
|
|
9
6
|
from prompt_toolkit import PromptSession
|
|
10
|
-
from prompt_toolkit.patch_stdout import StdoutProxy
|
|
11
7
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
12
8
|
|
|
13
|
-
from ..protocol import AgentEvent, JSONDict, ToolCall
|
|
9
|
+
from ..protocol import AgentEvent, JSONDict, ToolCall
|
|
10
|
+
from .toolcall_visualize import (
|
|
11
|
+
colorize_cli_message,
|
|
12
|
+
colorize_tool_message,
|
|
13
|
+
tool_summary,
|
|
14
|
+
)
|
|
14
15
|
import typing
|
|
15
16
|
|
|
16
|
-
|
|
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 = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
17
|
+
STATUS_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
26
18
|
PROMPT_CONTEXT_BASELINE_TOKENS = 12_000
|
|
27
19
|
DEFAULT_MAIN_PROMPT = "pycodex> "
|
|
28
20
|
|
|
29
21
|
|
|
30
|
-
def shorten_title(text:
|
|
22
|
+
def shorten_title(text: "str", limit: "int" = 48) -> "str":
|
|
31
23
|
compact = " ".join(text.split())
|
|
32
24
|
if len(compact) <= limit:
|
|
33
25
|
return compact
|
|
34
26
|
return compact[: limit - 3].rstrip() + "..."
|
|
35
27
|
|
|
36
28
|
|
|
37
|
-
def cli_color_enabled() ->
|
|
29
|
+
def cli_color_enabled() -> "bool":
|
|
38
30
|
return os.environ.get("PYCODEX_NO_COLOR", "").strip().lower() not in {
|
|
39
31
|
"1",
|
|
40
32
|
"true",
|
|
@@ -43,177 +35,103 @@ def cli_color_enabled() -> 'bool':
|
|
|
43
35
|
}
|
|
44
36
|
|
|
45
37
|
|
|
46
|
-
def colorize_cli_message(text: 'str', kind: 'str', enabled: 'bool') -> 'str':
|
|
47
|
-
if not enabled:
|
|
48
|
-
return text
|
|
49
|
-
palette = {
|
|
50
|
-
"assistant": ANSI_GREEN,
|
|
51
|
-
"plan": ANSI_CYAN,
|
|
52
|
-
"exec": ANSI_YELLOW,
|
|
53
|
-
"agent": ANSI_BLUE,
|
|
54
|
-
"web": ANSI_MAGENTA,
|
|
55
|
-
"status": ANSI_CYAN,
|
|
56
|
-
"tool": ANSI_DIM,
|
|
57
|
-
"error": ANSI_RED,
|
|
58
|
-
}
|
|
59
|
-
color = palette.get(kind)
|
|
60
|
-
if color is None:
|
|
61
|
-
return text
|
|
62
|
-
return f"{ANSI_BOLD}{color}{text}{ANSI_RESET}"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def format_cli_plan_messages(
|
|
66
|
-
summary: 'str',
|
|
67
|
-
plan_items: 'typing.List[JSONDict]',
|
|
68
|
-
) -> 'typing.List[str]':
|
|
69
|
-
lines = [f"[plan] {summary}" if summary else "[plan] Plan updated"]
|
|
70
|
-
for item in plan_items:
|
|
71
|
-
step = str(item.get("step", "")).strip()
|
|
72
|
-
status = str(item.get("status", "")).strip()
|
|
73
|
-
if not step:
|
|
74
|
-
continue
|
|
75
|
-
marker = {
|
|
76
|
-
"completed": "[x]",
|
|
77
|
-
"in_progress": "[>]",
|
|
78
|
-
"pending": "[ ]",
|
|
79
|
-
}.get(status, "[ ]")
|
|
80
|
-
lines.append(f" {marker} {step}")
|
|
81
|
-
return lines
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def build_cli_spinner_frame(index: 'int', label: 'str') -> 'str':
|
|
85
|
-
suffix = f" {label}" if label else ""
|
|
86
|
-
return f"{SPINNER_FRAMES[index % len(SPINNER_FRAMES)]}{suffix}"
|
|
87
|
-
|
|
88
|
-
|
|
89
38
|
def percent_of_context_window_remaining(
|
|
90
|
-
total_tokens:
|
|
91
|
-
context_window_tokens:
|
|
92
|
-
) ->
|
|
39
|
+
total_tokens: "int",
|
|
40
|
+
context_window_tokens: "int",
|
|
41
|
+
) -> "int":
|
|
93
42
|
if context_window_tokens <= PROMPT_CONTEXT_BASELINE_TOKENS:
|
|
94
43
|
return 0
|
|
95
44
|
|
|
96
45
|
effective_window = context_window_tokens - PROMPT_CONTEXT_BASELINE_TOKENS
|
|
97
46
|
used = max(total_tokens - PROMPT_CONTEXT_BASELINE_TOKENS, 0)
|
|
98
47
|
remaining = max(effective_window - used, 0)
|
|
99
|
-
return int(
|
|
100
|
-
round(
|
|
101
|
-
max(0.0, min(100.0, (remaining / effective_window) * 100.0))
|
|
102
|
-
)
|
|
103
|
-
)
|
|
48
|
+
return int(round(max(0.0, min(100.0, (remaining / effective_window) * 100.0))))
|
|
104
49
|
|
|
105
50
|
|
|
106
|
-
class
|
|
107
|
-
def __init__(
|
|
108
|
-
self
|
|
109
|
-
|
|
110
|
-
raw_flush,
|
|
111
|
-
terminal_lock: 'threading.RLock',
|
|
112
|
-
color_enabled: 'bool',
|
|
113
|
-
enabled: 'bool',
|
|
114
|
-
) -> 'None':
|
|
115
|
-
self._raw_write = raw_write
|
|
116
|
-
self._raw_flush = raw_flush
|
|
117
|
-
self._terminal_lock = terminal_lock
|
|
118
|
-
self._color_enabled = color_enabled
|
|
119
|
-
self._enabled = enabled
|
|
120
|
-
self._visible = False
|
|
121
|
-
self._turn_active = False
|
|
122
|
-
self._paused = False
|
|
123
|
-
self._index = 0
|
|
124
|
-
self._label = "thinking"
|
|
125
|
-
self._stop = threading.Event()
|
|
126
|
-
self._thread: 'typing.Union[threading.Thread, None]' = None
|
|
127
|
-
if self._enabled:
|
|
128
|
-
self._thread = threading.Thread(
|
|
129
|
-
target=self._run,
|
|
130
|
-
name="pycodex-cli-spinner",
|
|
131
|
-
daemon=True,
|
|
132
|
-
)
|
|
133
|
-
self._thread.start()
|
|
51
|
+
class Prompter:
|
|
52
|
+
def __init__(self, prompt: str = DEFAULT_MAIN_PROMPT, lock=None):
|
|
53
|
+
self.lock = lock or threading.Lock()
|
|
54
|
+
self._prompt_session = PromptSession(**prompt_session_kwargs())
|
|
134
55
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
self._paused = False
|
|
139
|
-
self._label = label
|
|
56
|
+
self.prompt = prompt
|
|
57
|
+
self._status = None
|
|
58
|
+
self._status_frame_index = 0
|
|
140
59
|
|
|
141
|
-
|
|
142
|
-
with self._terminal_lock:
|
|
143
|
-
self._label = label
|
|
60
|
+
self._prompt_task = None
|
|
144
61
|
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
self._turn_active = False
|
|
148
|
-
self._paused = False
|
|
149
|
-
self.clear()
|
|
62
|
+
def set_prompt(self, prompt):
|
|
63
|
+
self.prompt = prompt
|
|
150
64
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
self._paused = True
|
|
154
|
-
self.clear()
|
|
65
|
+
def set_status(self, text=None, active=True):
|
|
66
|
+
self._status = text if active else None
|
|
155
67
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
self.
|
|
68
|
+
async def poll_input(self) -> "typing.Union[str, None]":
|
|
69
|
+
if self._prompt_task is None:
|
|
70
|
+
self._prompt_task = asyncio.create_task(self._block_prompt())
|
|
159
71
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
self._raw_write("\r\x1b[2K")
|
|
165
|
-
self._raw_flush()
|
|
166
|
-
self._visible = False
|
|
167
|
-
|
|
168
|
-
def render_now(self) -> 'None':
|
|
169
|
-
if not self._turn_active or self._paused:
|
|
170
|
-
return
|
|
171
|
-
frame = colorize_cli_message(
|
|
172
|
-
build_cli_spinner_frame(self._index, self._label),
|
|
173
|
-
"status",
|
|
174
|
-
self._color_enabled,
|
|
72
|
+
done, _pending = await asyncio.wait(
|
|
73
|
+
{self._prompt_task},
|
|
74
|
+
timeout=0.05,
|
|
75
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
175
76
|
)
|
|
176
|
-
|
|
177
|
-
with self._terminal_lock:
|
|
178
|
-
if not self._turn_active or self._paused:
|
|
179
|
-
return
|
|
180
|
-
self._raw_write(f"\r\x1b[2K{frame}")
|
|
181
|
-
self._raw_flush()
|
|
182
|
-
self._visible = True
|
|
183
|
-
|
|
184
|
-
def close(self) -> 'None':
|
|
185
|
-
self.finish_turn()
|
|
186
|
-
if self._thread is not None:
|
|
187
|
-
self._stop.set()
|
|
188
|
-
self._thread.join(timeout=0.5)
|
|
189
|
-
|
|
190
|
-
def prompt_line(self) -> 'typing.Union[str, None]':
|
|
191
|
-
if not self._turn_active:
|
|
77
|
+
if not done:
|
|
192
78
|
return None
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
79
|
+
|
|
80
|
+
prompt_task, self._prompt_task = self._prompt_task, None
|
|
81
|
+
try:
|
|
82
|
+
return prompt_task.result()
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
async def require_input(self):
|
|
87
|
+
if self._prompt_task and not self._prompt_task.done():
|
|
88
|
+
self._prompt_task.cancel()
|
|
89
|
+
with suppress(asyncio.CancelledError):
|
|
90
|
+
await self._prompt_task
|
|
91
|
+
|
|
92
|
+
return await self._block_prompt()
|
|
93
|
+
|
|
94
|
+
async def _block_prompt(self):
|
|
95
|
+
with patch_stdout(raw=True):
|
|
96
|
+
return await self._prompt_session.prompt_async(
|
|
97
|
+
lambda: self.prompt,
|
|
98
|
+
refresh_interval=0.12,
|
|
99
|
+
bottom_toolbar=self._get_status,
|
|
206
100
|
)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
101
|
+
|
|
102
|
+
def _get_status(self):
|
|
103
|
+
self._status_frame_index += 1
|
|
104
|
+
if self._status is None:
|
|
105
|
+
return None
|
|
106
|
+
else:
|
|
107
|
+
frame = STATUS_FRAMES[self._status_frame_index % len(STATUS_FRAMES)]
|
|
108
|
+
status = f"{frame} {self._status}"
|
|
109
|
+
return status
|
|
110
|
+
|
|
111
|
+
def close(self) -> "None":
|
|
112
|
+
if self._prompt_task is not None and not self._prompt_task.done():
|
|
113
|
+
self._prompt_task.cancel()
|
|
114
|
+
self._prompt_task = None
|
|
214
115
|
|
|
215
116
|
|
|
216
|
-
def
|
|
117
|
+
def prompt_session_kwargs() -> "typing.Dict[str, object]":
|
|
118
|
+
kwargs = {
|
|
119
|
+
"erase_when_done": True,
|
|
120
|
+
"enable_system_prompt": True,
|
|
121
|
+
}
|
|
122
|
+
try:
|
|
123
|
+
parameters = inspect.signature(PromptSession.__init__).parameters
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
return kwargs
|
|
126
|
+
|
|
127
|
+
if "show_frame" in parameters:
|
|
128
|
+
kwargs["show_frame"] = True
|
|
129
|
+
return kwargs
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_cli_tool_call_message(
|
|
133
|
+
tool_name: "str", payload: "JSONDict"
|
|
134
|
+
) -> "typing.Union[str, None]":
|
|
217
135
|
if tool_name != "web_search":
|
|
218
136
|
return None
|
|
219
137
|
|
|
@@ -224,418 +142,108 @@ def format_cli_tool_call_message(tool_name: 'str', payload: 'JSONDict') -> 'typi
|
|
|
224
142
|
queries = payload.get("queries")
|
|
225
143
|
if isinstance(queries, list) and queries:
|
|
226
144
|
query = str(queries[0]).strip()
|
|
227
|
-
return f"[
|
|
145
|
+
return f"[web_search] searched: {query}" if query else "[web_search] searched"
|
|
228
146
|
|
|
229
147
|
if action_type == "open_page":
|
|
230
148
|
url = str(payload.get("url", "")).strip()
|
|
231
|
-
return f"[
|
|
149
|
+
return f"[web_search] opened: {url}" if url else "[web_search] opened"
|
|
232
150
|
|
|
233
151
|
if action_type == "find_in_page":
|
|
234
152
|
pattern = str(payload.get("pattern", "")).strip()
|
|
235
153
|
url = str(payload.get("url", "")).strip()
|
|
236
154
|
if pattern and url:
|
|
237
|
-
return f"[
|
|
155
|
+
return f"[web_search] found: {pattern} @ {url}"
|
|
238
156
|
if pattern:
|
|
239
|
-
return f"[
|
|
240
|
-
return "[
|
|
157
|
+
return f"[web_search] found: {pattern}"
|
|
158
|
+
return "[web_search] found in page"
|
|
241
159
|
|
|
242
|
-
return "[
|
|
160
|
+
return "[web_search] browsing"
|
|
243
161
|
|
|
244
162
|
|
|
245
|
-
def short_id(value:
|
|
163
|
+
def short_id(value: "str", limit: "int" = 8) -> "str":
|
|
246
164
|
compact = value.strip()
|
|
247
165
|
if len(compact) <= limit + 4:
|
|
248
166
|
return compact
|
|
249
167
|
return f"{compact[:limit]}...{compact[-4:]}"
|
|
250
168
|
|
|
251
169
|
|
|
252
|
-
def format_cli_tool_message(
|
|
253
|
-
tool_name: 'str',
|
|
254
|
-
summary: 'str',
|
|
255
|
-
is_error: 'bool',
|
|
256
|
-
) -> 'str':
|
|
257
|
-
if tool_name == "update_plan":
|
|
258
|
-
if is_error:
|
|
259
|
-
return f"[error] plan failed: {summary}" if summary else "[error] plan failed"
|
|
260
|
-
return f"[plan] {summary}" if summary else "[plan] Plan updated"
|
|
261
|
-
|
|
262
|
-
if tool_name in {
|
|
263
|
-
"exec_command",
|
|
264
|
-
"write_stdin",
|
|
265
|
-
"shell",
|
|
266
|
-
"shell_command",
|
|
267
|
-
"exec",
|
|
268
|
-
"wait",
|
|
269
|
-
}:
|
|
270
|
-
if is_error:
|
|
271
|
-
return f"[error] exec failed: {summary}" if summary else "[error] exec failed"
|
|
272
|
-
return f"[exec] {summary}" if summary else f"[exec] {tool_name}"
|
|
273
|
-
|
|
274
|
-
if tool_name == "spawn_agent":
|
|
275
|
-
if is_error:
|
|
276
|
-
return (
|
|
277
|
-
f"[error] agent spawn failed: {summary}"
|
|
278
|
-
if summary
|
|
279
|
-
else "[error] agent spawn failed"
|
|
280
|
-
)
|
|
281
|
-
return f"[agent] spawned {summary}" if summary else "[agent] spawned"
|
|
282
|
-
|
|
283
|
-
if tool_name == "send_input":
|
|
284
|
-
if is_error:
|
|
285
|
-
return (
|
|
286
|
-
f"[error] agent send failed: {summary}"
|
|
287
|
-
if summary
|
|
288
|
-
else "[error] agent send failed"
|
|
289
|
-
)
|
|
290
|
-
return f"[agent] send: {summary}" if summary else "[agent] send"
|
|
291
|
-
|
|
292
|
-
if tool_name == "wait_agent":
|
|
293
|
-
if is_error:
|
|
294
|
-
return (
|
|
295
|
-
f"[error] agent wait failed: {summary}"
|
|
296
|
-
if summary
|
|
297
|
-
else "[error] agent wait failed"
|
|
298
|
-
)
|
|
299
|
-
return f"[agent] wait: {summary}" if summary else "[agent] wait"
|
|
300
|
-
|
|
301
|
-
if tool_name == "resume_agent":
|
|
302
|
-
if is_error:
|
|
303
|
-
return (
|
|
304
|
-
f"[error] agent resume failed: {summary}"
|
|
305
|
-
if summary
|
|
306
|
-
else "[error] agent resume failed"
|
|
307
|
-
)
|
|
308
|
-
return f"[agent] resume: {summary}" if summary else "[agent] resume"
|
|
309
|
-
|
|
310
|
-
if tool_name == "close_agent":
|
|
311
|
-
if is_error:
|
|
312
|
-
return (
|
|
313
|
-
f"[error] agent close failed: {summary}"
|
|
314
|
-
if summary
|
|
315
|
-
else "[error] agent close failed"
|
|
316
|
-
)
|
|
317
|
-
return f"[agent] close: {summary}" if summary else "[agent] close"
|
|
318
|
-
|
|
319
|
-
if is_error:
|
|
320
|
-
return (
|
|
321
|
-
f"[error] {tool_name} failed: {summary}"
|
|
322
|
-
if summary
|
|
323
|
-
else f"[error] {tool_name} failed"
|
|
324
|
-
)
|
|
325
|
-
return f"[tool] {tool_name}: {summary}" if summary else f"[tool] {tool_name}"
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def extract_plan_items(arguments: 'object') -> 'typing.List[JSONDict]':
|
|
329
|
-
if not isinstance(arguments, dict):
|
|
330
|
-
return []
|
|
331
|
-
raw_plan = arguments.get("plan")
|
|
332
|
-
if not isinstance(raw_plan, list):
|
|
333
|
-
return []
|
|
334
|
-
plan_items: 'typing.List[JSONDict]' = []
|
|
335
|
-
for item in raw_plan:
|
|
336
|
-
if not isinstance(item, dict):
|
|
337
|
-
continue
|
|
338
|
-
plan_items.append(
|
|
339
|
-
{
|
|
340
|
-
"step": str(item.get("step", "")).strip(),
|
|
341
|
-
"status": str(item.get("status", "")).strip(),
|
|
342
|
-
}
|
|
343
|
-
)
|
|
344
|
-
return plan_items
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def summarize_tool_event(call: 'ToolCall', result: 'ToolResult') -> 'typing.Union[str, None]':
|
|
348
|
-
command_preview = _command_preview(call)
|
|
349
|
-
result_summary = _summarize_tool_result(result)
|
|
350
|
-
if call.name == "update_plan":
|
|
351
|
-
return command_preview or result_summary
|
|
352
|
-
if command_preview and result_summary:
|
|
353
|
-
return f"{command_preview} -> {result_summary}"
|
|
354
|
-
if command_preview:
|
|
355
|
-
return command_preview
|
|
356
|
-
return result_summary
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def extract_tool_event_display(
|
|
360
|
-
payload: 'typing.Dict[str, object]',
|
|
361
|
-
) -> 'typing.Tuple[str, str, bool]':
|
|
362
|
-
tool_name = str(payload.get("tool_name", "")).strip()
|
|
363
|
-
is_error = bool(payload.get("is_error"))
|
|
364
|
-
call = payload.get("call")
|
|
365
|
-
result = payload.get("result")
|
|
366
|
-
if isinstance(call, ToolCall) and isinstance(result, ToolResult):
|
|
367
|
-
return tool_name, summarize_tool_event(call, result) or "", is_error
|
|
368
|
-
summary = str(payload.get("summary", "") or "").strip()
|
|
369
|
-
return tool_name, summary, is_error
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def extract_plan_event_items(payload: 'typing.Dict[str, object]') -> 'typing.List[JSONDict]':
|
|
373
|
-
call = payload.get("call")
|
|
374
|
-
if isinstance(call, ToolCall):
|
|
375
|
-
return extract_plan_items(call.arguments)
|
|
376
|
-
raw_plan_items = payload.get("plan_items")
|
|
377
|
-
if isinstance(raw_plan_items, list):
|
|
378
|
-
return [item for item in raw_plan_items if isinstance(item, dict)]
|
|
379
|
-
return []
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
def _truncate_text(text: 'str', limit: 'int' = 96) -> 'str':
|
|
383
|
-
compact = " ".join(text.split())
|
|
384
|
-
if len(compact) <= limit:
|
|
385
|
-
return compact
|
|
386
|
-
return compact[: limit - 3].rstrip() + "..."
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def _extract_output_preview(text: 'str') -> 'typing.Union[str, None]':
|
|
390
|
-
lines = [line.strip() for line in text.splitlines()]
|
|
391
|
-
if "Output:" in lines:
|
|
392
|
-
output_index = lines.index("Output:")
|
|
393
|
-
for line in lines[output_index + 1 :]:
|
|
394
|
-
if line:
|
|
395
|
-
return _truncate_text(line)
|
|
396
|
-
|
|
397
|
-
for line in lines:
|
|
398
|
-
if not line:
|
|
399
|
-
continue
|
|
400
|
-
if line.startswith(("Exit code:", "Wall time:", "Command:")):
|
|
401
|
-
continue
|
|
402
|
-
return _truncate_text(line)
|
|
403
|
-
return None
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def _summarize_agent_status(status: 'object') -> 'str':
|
|
407
|
-
if isinstance(status, str):
|
|
408
|
-
return status
|
|
409
|
-
if isinstance(status, dict):
|
|
410
|
-
if "completed" in status:
|
|
411
|
-
completed = status.get("completed")
|
|
412
|
-
if completed is None:
|
|
413
|
-
return "completed"
|
|
414
|
-
return f"completed: {_truncate_text(str(completed), limit=48)}"
|
|
415
|
-
if "errored" in status:
|
|
416
|
-
return f"errored: {_truncate_text(str(status.get('errored', '')), limit=48)}"
|
|
417
|
-
return _truncate_text(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def _summarize_tool_result(result: 'ToolResult') -> 'typing.Union[str, None]':
|
|
421
|
-
if result.name == "spawn_agent" and isinstance(result.output, dict):
|
|
422
|
-
agent_id = str(result.output.get("agent_id", "")).strip()
|
|
423
|
-
nickname = str(result.output.get("nickname", "")).strip()
|
|
424
|
-
if nickname and agent_id:
|
|
425
|
-
return f"{nickname} ({short_id(agent_id)})"
|
|
426
|
-
if result.name == "send_input" and isinstance(result.output, dict):
|
|
427
|
-
submission_id = str(result.output.get("submission_id", "")).strip()
|
|
428
|
-
if submission_id:
|
|
429
|
-
return f"queued {short_id(submission_id)}"
|
|
430
|
-
if result.name in {"resume_agent", "close_agent"} and isinstance(result.output, dict):
|
|
431
|
-
return _summarize_agent_status(result.output.get("status"))
|
|
432
|
-
if result.name == "wait_agent" and isinstance(result.output, dict):
|
|
433
|
-
if result.output.get("timed_out") is True:
|
|
434
|
-
return "timed out"
|
|
435
|
-
status = result.output.get("status")
|
|
436
|
-
if isinstance(status, dict):
|
|
437
|
-
parts: 'typing.List[str]' = []
|
|
438
|
-
for agent_id, agent_status in status.items():
|
|
439
|
-
if not isinstance(agent_id, str):
|
|
440
|
-
continue
|
|
441
|
-
parts.append(
|
|
442
|
-
f"{short_id(agent_id)}={_summarize_agent_status(agent_status)}"
|
|
443
|
-
)
|
|
444
|
-
if parts:
|
|
445
|
-
return _truncate_text(", ".join(parts), limit=96)
|
|
446
|
-
if result.name == "update_plan" and isinstance(result.output, dict):
|
|
447
|
-
plan = result.output.get("plan")
|
|
448
|
-
if isinstance(plan, list):
|
|
449
|
-
return f"{len(plan)} steps"
|
|
450
|
-
if result.name == "view_image" and isinstance(result.output, list):
|
|
451
|
-
return f"{len(result.output)} image item(s)"
|
|
452
|
-
if isinstance(result.output, (dict, list)):
|
|
453
|
-
return _truncate_text(
|
|
454
|
-
json.dumps(result.output, ensure_ascii=False, separators=(",", ":"))
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
preview = _extract_output_preview(result.output_text())
|
|
458
|
-
if preview:
|
|
459
|
-
return preview
|
|
460
|
-
return None
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def _string_arg(arguments: 'object', key: 'str') -> 'typing.Union[str, None]':
|
|
464
|
-
if not isinstance(arguments, dict):
|
|
465
|
-
return None
|
|
466
|
-
value = arguments.get(key)
|
|
467
|
-
if value in (None, ""):
|
|
468
|
-
return None
|
|
469
|
-
return str(value)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
def _int_arg(arguments: 'object', key: 'str') -> 'typing.Union[int, None]':
|
|
473
|
-
if not isinstance(arguments, dict):
|
|
474
|
-
return None
|
|
475
|
-
value = arguments.get(key)
|
|
476
|
-
if value in (None, ""):
|
|
477
|
-
return None
|
|
478
|
-
return int(value)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def _command_preview(call: 'ToolCall') -> 'typing.Union[str, None]':
|
|
482
|
-
if call.name == "exec_command":
|
|
483
|
-
cmd = _string_arg(call.arguments, "cmd")
|
|
484
|
-
if cmd:
|
|
485
|
-
return _truncate_text(cmd, limit=72)
|
|
486
|
-
if call.name == "shell_command":
|
|
487
|
-
command = _string_arg(call.arguments, "command")
|
|
488
|
-
if command:
|
|
489
|
-
return _truncate_text(command, limit=72)
|
|
490
|
-
if call.name == "shell" and isinstance(call.arguments, dict):
|
|
491
|
-
command = call.arguments.get("command")
|
|
492
|
-
if isinstance(command, list) and command:
|
|
493
|
-
rendered = " ".join(shlex.quote(str(part)) for part in command)
|
|
494
|
-
return _truncate_text(rendered, limit=72)
|
|
495
|
-
if call.name == "write_stdin":
|
|
496
|
-
session_id = _int_arg(call.arguments, "session_id")
|
|
497
|
-
chars = _string_arg(call.arguments, "chars") or ""
|
|
498
|
-
if session_id is None:
|
|
499
|
-
return None
|
|
500
|
-
if not chars:
|
|
501
|
-
return f"poll session {session_id}"
|
|
502
|
-
return f"session {session_id} <- {_truncate_text(chars, limit=32)}"
|
|
503
|
-
if call.name == "read_file":
|
|
504
|
-
path = _string_arg(call.arguments, "file_path")
|
|
505
|
-
if path:
|
|
506
|
-
return _truncate_text(path, limit=72)
|
|
507
|
-
if call.name == "list_dir":
|
|
508
|
-
path = _string_arg(call.arguments, "dir_path")
|
|
509
|
-
if path:
|
|
510
|
-
return _truncate_text(path, limit=72)
|
|
511
|
-
if call.name == "grep_files":
|
|
512
|
-
pattern = _string_arg(call.arguments, "pattern")
|
|
513
|
-
path = _string_arg(call.arguments, "path")
|
|
514
|
-
if pattern and path:
|
|
515
|
-
return _truncate_text(f"{pattern} @ {path}", limit=72)
|
|
516
|
-
if pattern:
|
|
517
|
-
return _truncate_text(pattern, limit=72)
|
|
518
|
-
if call.name == "view_image":
|
|
519
|
-
path = _string_arg(call.arguments, "path")
|
|
520
|
-
if path:
|
|
521
|
-
return _truncate_text(path, limit=72)
|
|
522
|
-
if call.name == "update_plan" and isinstance(call.arguments, dict):
|
|
523
|
-
plan = call.arguments.get("plan")
|
|
524
|
-
if isinstance(plan, list):
|
|
525
|
-
return _plan_progress_summary(plan)
|
|
526
|
-
if call.name == "send_input":
|
|
527
|
-
agent_id = _string_arg(call.arguments, "id")
|
|
528
|
-
message = _string_arg(call.arguments, "message")
|
|
529
|
-
prefix = f"{short_id(agent_id)} <- " if agent_id else ""
|
|
530
|
-
if message:
|
|
531
|
-
return f"{prefix}{_truncate_text(message, limit=40)}"
|
|
532
|
-
if prefix:
|
|
533
|
-
return prefix.rstrip()
|
|
534
|
-
if call.name in {"resume_agent", "close_agent"}:
|
|
535
|
-
agent_id = _string_arg(call.arguments, "id")
|
|
536
|
-
if agent_id:
|
|
537
|
-
return short_id(agent_id)
|
|
538
|
-
return None
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def _plan_progress_summary(plan: 'typing.List[object]') -> 'str':
|
|
542
|
-
total = len(plan)
|
|
543
|
-
completed = 0
|
|
544
|
-
in_progress = 0
|
|
545
|
-
|
|
546
|
-
for item in plan:
|
|
547
|
-
if not isinstance(item, dict):
|
|
548
|
-
continue
|
|
549
|
-
status = str(item.get("status", "")).strip()
|
|
550
|
-
if status == "completed":
|
|
551
|
-
completed += 1
|
|
552
|
-
elif status == "in_progress":
|
|
553
|
-
in_progress += 1
|
|
554
|
-
|
|
555
|
-
if total == 0:
|
|
556
|
-
return "0 steps"
|
|
557
|
-
if completed >= total:
|
|
558
|
-
return f"Done {completed}/{total}"
|
|
559
|
-
if in_progress:
|
|
560
|
-
return f"Working on {completed + in_progress}/{total}"
|
|
561
|
-
return f"Planned {completed}/{total}"
|
|
562
|
-
|
|
563
|
-
|
|
564
170
|
class CliSessionView:
|
|
565
171
|
"""Own the interactive CLI terminal surface for one session.
|
|
566
172
|
|
|
567
173
|
This class is the single place that knows how to:
|
|
568
174
|
- render `AgentEvent`s into human-facing terminal output;
|
|
569
|
-
- multiplex prompt input, streamed assistant output, and
|
|
175
|
+
- multiplex prompt input, streamed assistant output, and status state;
|
|
570
176
|
- keep lightweight session UI state such as title, history, and steer markers.
|
|
571
177
|
|
|
572
178
|
Public interface:
|
|
573
179
|
- `handle_event(event)`: feed runtime/agent events into the view.
|
|
574
|
-
- `poll_prompt(prompt)`: poll one prompt-toolkit input task; returns one input
|
|
575
|
-
line, or `None` when input is still pending. `EOFError` means the input
|
|
576
|
-
source has closed and the caller should end the session loop.
|
|
577
180
|
- `write_line(text)`, `finish_stream()`, `show_error(text)`: imperative output
|
|
578
181
|
helpers for CLI-side messages that do not come from `AgentEvent`.
|
|
579
182
|
- `show_history()`, `show_title()`, `load_session_history(...)`, `show_steer_queued(...)`,
|
|
580
183
|
`schedule_steer_inserted(...)`: small session UI helpers used by the
|
|
581
184
|
interactive command loop.
|
|
582
|
-
- `close()`: release prompt
|
|
583
|
-
|
|
584
|
-
Typical usage from the CLI loop:
|
|
585
|
-
1. Create one `CliSessionView` for the whole interactive session.
|
|
586
|
-
2. Register `view.handle_event` as the runtime event handler.
|
|
587
|
-
3. Repeatedly call `await view.poll_prompt("pycodex> ")`.
|
|
588
|
-
4. On shutdown, call `view.close()`.
|
|
589
|
-
|
|
590
|
-
Notes:
|
|
591
|
-
- Treat this as a session-scoped object. It keeps mutable state across turns,
|
|
592
|
-
including prompt buffering and rendered history.
|
|
593
|
-
- `poll_prompt()` owns the prompt task lifecycle. Do not drive
|
|
594
|
-
`prompt_async()` concurrently from outside the view.
|
|
595
|
-
- Stream handoff is intentional: when assistant output starts while the user
|
|
596
|
-
prompt is active, the view moves buffered prompt-managed output back to the
|
|
597
|
-
normal terminal stream so the reply is not lost.
|
|
185
|
+
- `close()`: release prompt resources at shutdown.
|
|
186
|
+
|
|
598
187
|
"""
|
|
599
188
|
|
|
600
189
|
def __init__(
|
|
601
190
|
self,
|
|
602
|
-
context_window_tokens:
|
|
603
|
-
) ->
|
|
191
|
+
context_window_tokens: "typing.Union[int, None]" = None,
|
|
192
|
+
) -> "None":
|
|
604
193
|
import sys
|
|
605
194
|
|
|
606
195
|
self._line_output = print
|
|
607
|
-
self._raw_write = sys.stdout.write
|
|
608
|
-
self._raw_flush = sys.stdout.flush
|
|
609
196
|
self._terminal_lock = threading.RLock()
|
|
610
|
-
self._title:
|
|
611
|
-
self._pending_user_prompts:
|
|
612
|
-
self._queued_steer_prompts:
|
|
613
|
-
self._inserted_steer_prompts:
|
|
614
|
-
self._history:
|
|
615
|
-
self._streaming = False
|
|
616
|
-
self._prompt_stream_buffer = ""
|
|
617
|
-
self._streaming_in_prompt = False
|
|
618
|
-
self._input_active = False
|
|
197
|
+
self._title: "typing.Union[str, None]" = None
|
|
198
|
+
self._pending_user_prompts: "typing.Dict[str, str]" = {}
|
|
199
|
+
self._queued_steer_prompts: "typing.Dict[str, typing.List[str]]" = {}
|
|
200
|
+
self._inserted_steer_prompts: "typing.Dict[str, typing.List[str]]" = {}
|
|
201
|
+
self._history: "typing.List[typing.Tuple[str, str]]" = []
|
|
619
202
|
self._context_window_tokens = context_window_tokens
|
|
620
|
-
self._context_remaining_percent:
|
|
203
|
+
self._context_remaining_percent: "typing.Union[int, None]" = (
|
|
621
204
|
100 if context_window_tokens is not None else None
|
|
622
205
|
)
|
|
623
206
|
self._color_enabled = cli_color_enabled() and sys.stdout.isatty()
|
|
624
|
-
self._agent_names:
|
|
625
|
-
|
|
626
|
-
self.
|
|
627
|
-
|
|
628
|
-
self.
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
self.
|
|
633
|
-
|
|
634
|
-
|
|
207
|
+
self._agent_names: "typing.Dict[str, str]" = {}
|
|
208
|
+
|
|
209
|
+
self.prompter = Prompter(lock=self._terminal_lock)
|
|
210
|
+
|
|
211
|
+
self._stream_buffer = ""
|
|
212
|
+
|
|
213
|
+
def handle_event(self, event: "AgentEvent") -> "None":
|
|
214
|
+
if event.kind == "assistant_delta":
|
|
215
|
+
self._stream_buffer += str(event.payload.get("delta", ""))
|
|
216
|
+
self.prompter.set_status("talking")
|
|
217
|
+
return
|
|
218
|
+
if event.kind == "token_count":
|
|
219
|
+
self._update_context_window(event.payload.get("usage"))
|
|
220
|
+
self.prompter.set_prompt(self._format_main_prompt(DEFAULT_MAIN_PROMPT))
|
|
221
|
+
return
|
|
222
|
+
if event.kind == "model_completed":
|
|
223
|
+
return
|
|
224
|
+
if event.kind == "turn_completed":
|
|
225
|
+
submission_id = str(
|
|
226
|
+
event.payload.get("submission_id", event.turn_id)
|
|
227
|
+
).strip()
|
|
228
|
+
final_text = str(event.payload.get("output_text", "") or "")
|
|
229
|
+
if final_text:
|
|
230
|
+
if final_text.startswith(self._stream_buffer):
|
|
231
|
+
self._stream_buffer += final_text[len(self._stream_buffer) :]
|
|
232
|
+
else:
|
|
233
|
+
self._stream_buffer += final_text
|
|
234
|
+
self.finish_stream()
|
|
235
|
+
pending_prompt = self._pending_user_prompts.pop(submission_id, None)
|
|
236
|
+
if pending_prompt is not None:
|
|
237
|
+
self._history.append((pending_prompt, final_text))
|
|
238
|
+
self.prompter.set_status(active=False)
|
|
239
|
+
return
|
|
240
|
+
self.finish_stream()
|
|
635
241
|
|
|
636
|
-
def handle_event(self, event: 'AgentEvent') -> 'None':
|
|
637
242
|
if event.kind == "turn_started":
|
|
638
|
-
|
|
243
|
+
self.prompter.set_status(event.kind)
|
|
244
|
+
submission_id = str(
|
|
245
|
+
event.payload.get("submission_id", event.turn_id)
|
|
246
|
+
).strip()
|
|
639
247
|
user_texts = event.payload.get("user_texts")
|
|
640
248
|
if isinstance(user_texts, list):
|
|
641
249
|
normalized_user_texts = [
|
|
@@ -671,25 +279,13 @@ class CliSessionView:
|
|
|
671
279
|
)
|
|
672
280
|
if user_text:
|
|
673
281
|
self._print_user_turn(user_text)
|
|
674
|
-
self._spinner.start_turn("thinking")
|
|
675
|
-
if self._input_active:
|
|
676
|
-
self._spinner.pause()
|
|
677
282
|
return
|
|
678
283
|
|
|
679
284
|
if event.kind == "model_called":
|
|
680
|
-
|
|
681
|
-
self._spinner.pause()
|
|
682
|
-
else:
|
|
683
|
-
self._spinner.resume()
|
|
684
|
-
self._spinner.set_label("waiting model")
|
|
685
|
-
return
|
|
686
|
-
|
|
687
|
-
if event.kind == "token_count":
|
|
688
|
-
self._update_context_window(event.payload.get("usage"))
|
|
285
|
+
# self.prompter.set_status("thinking")
|
|
689
286
|
return
|
|
690
287
|
|
|
691
288
|
if event.kind == "stream_error":
|
|
692
|
-
self._finish_stream()
|
|
693
289
|
message = str(event.payload.get("message", "")).strip() or "Reconnecting..."
|
|
694
290
|
self._print_line(
|
|
695
291
|
colorize_cli_message(
|
|
@@ -698,15 +294,10 @@ class CliSessionView:
|
|
|
698
294
|
self._color_enabled,
|
|
699
295
|
)
|
|
700
296
|
)
|
|
701
|
-
|
|
702
|
-
self._spinner.pause()
|
|
703
|
-
else:
|
|
704
|
-
self._spinner.resume()
|
|
705
|
-
self._spinner.set_label("reconnecting")
|
|
297
|
+
self.prompter.set_status("reconnecting")
|
|
706
298
|
return
|
|
707
299
|
|
|
708
300
|
if event.kind == "auto_compact_started":
|
|
709
|
-
self._finish_stream()
|
|
710
301
|
total_tokens = event.payload.get("total_tokens")
|
|
711
302
|
token_limit = event.payload.get("token_limit")
|
|
712
303
|
if total_tokens is not None and token_limit is not None:
|
|
@@ -716,31 +307,19 @@ class CliSessionView:
|
|
|
716
307
|
self._print_line(
|
|
717
308
|
colorize_cli_message(message, "status", self._color_enabled)
|
|
718
309
|
)
|
|
719
|
-
|
|
720
|
-
self._spinner.pause()
|
|
721
|
-
else:
|
|
722
|
-
self._spinner.resume()
|
|
723
|
-
self._spinner.set_label("compacting context")
|
|
724
|
-
self._spinner.render_now()
|
|
310
|
+
self.prompter.set_status("compacting")
|
|
725
311
|
return
|
|
726
312
|
|
|
727
313
|
if event.kind == "auto_compact_completed":
|
|
728
|
-
self._finish_stream()
|
|
729
314
|
summary = str(event.payload.get("summary", "")).strip()
|
|
730
315
|
message = f"[status] {summary}" if summary else "[status] context compacted"
|
|
731
316
|
self._print_line(
|
|
732
317
|
colorize_cli_message(message, "status", self._color_enabled)
|
|
733
318
|
)
|
|
734
|
-
|
|
735
|
-
self._spinner.pause()
|
|
736
|
-
else:
|
|
737
|
-
self._spinner.resume()
|
|
738
|
-
self._spinner.set_label("thinking")
|
|
739
|
-
self._spinner.render_now()
|
|
319
|
+
self.prompter.set_status("compacted")
|
|
740
320
|
return
|
|
741
321
|
|
|
742
322
|
if event.kind == "auto_compact_failed":
|
|
743
|
-
self._finish_stream()
|
|
744
323
|
error = str(event.payload.get("error", "")).strip()
|
|
745
324
|
message = (
|
|
746
325
|
f"[error] auto-compact failed: {error}"
|
|
@@ -750,229 +329,109 @@ class CliSessionView:
|
|
|
750
329
|
self._print_line(
|
|
751
330
|
colorize_cli_message(message, "error", self._color_enabled)
|
|
752
331
|
)
|
|
753
|
-
if self._input_active:
|
|
754
|
-
self._spinner.pause()
|
|
755
|
-
else:
|
|
756
|
-
self._spinner.resume()
|
|
757
|
-
self._spinner.set_label("thinking")
|
|
758
|
-
self._spinner.render_now()
|
|
759
|
-
return
|
|
760
|
-
|
|
761
|
-
if event.kind == "assistant_delta":
|
|
762
|
-
delta = str(event.payload.get("delta", ""))
|
|
763
|
-
if not delta:
|
|
764
|
-
return
|
|
765
|
-
if self._input_active:
|
|
766
|
-
if not self._streaming:
|
|
767
|
-
self._streaming = True
|
|
768
|
-
self._streaming_in_prompt = True
|
|
769
|
-
self._prompt_stream_buffer = ""
|
|
770
|
-
self._prompt_stream_buffer += delta
|
|
771
|
-
return
|
|
772
|
-
with self._terminal_lock:
|
|
773
|
-
# Pause the spinner before streaming assistant text to avoid interleaving.
|
|
774
|
-
if not self._streaming:
|
|
775
|
-
self._spinner.pause()
|
|
776
|
-
if not self._streaming:
|
|
777
|
-
self._raw_write(
|
|
778
|
-
"assistant> "
|
|
779
|
-
)
|
|
780
|
-
self._streaming = True
|
|
781
|
-
self._raw_write(delta)
|
|
782
|
-
self._raw_flush()
|
|
783
332
|
return
|
|
784
333
|
|
|
785
334
|
if event.kind == "tool_called":
|
|
786
335
|
tool_name = str(event.payload.get("tool_name", "")).strip()
|
|
787
336
|
message = format_cli_tool_call_message(tool_name, event.payload)
|
|
788
337
|
if message is not None:
|
|
789
|
-
self.
|
|
790
|
-
self._print_line(
|
|
791
|
-
colorize_cli_message(message, "web", self._color_enabled)
|
|
792
|
-
)
|
|
793
|
-
if self._input_active:
|
|
794
|
-
self._spinner.pause()
|
|
795
|
-
else:
|
|
796
|
-
self._spinner.resume()
|
|
797
|
-
self._spinner.set_label("running provider tools")
|
|
798
|
-
self._spinner.render_now()
|
|
338
|
+
self._print_line(colorize_tool_message(message, self._color_enabled))
|
|
799
339
|
return
|
|
800
340
|
|
|
801
341
|
if event.kind == "tool_started":
|
|
802
|
-
self._finish_stream()
|
|
803
342
|
tool_name = str(event.payload.get("tool_name", "")).strip()
|
|
804
343
|
call = event.payload.get("call")
|
|
805
344
|
args = None
|
|
806
345
|
if isinstance(call, ToolCall):
|
|
807
346
|
args = call.arguments
|
|
808
|
-
if
|
|
809
|
-
self.
|
|
347
|
+
if tool_name and args is not None:
|
|
348
|
+
self.prompter.set_status(
|
|
349
|
+
shorten_title(f"calling {tool_name}({args})", limit=72)
|
|
350
|
+
)
|
|
351
|
+
elif tool_name:
|
|
352
|
+
self.prompter.set_status(f"calling {tool_name}")
|
|
810
353
|
else:
|
|
811
|
-
self.
|
|
812
|
-
if tool_name and args is not None:
|
|
813
|
-
self._spinner.set_label(shorten_title(f"running {tool_name}({args})", limit=72))
|
|
814
|
-
elif tool_name:
|
|
815
|
-
self._spinner.set_label(f"running {tool_name}")
|
|
816
|
-
else:
|
|
817
|
-
self._spinner.set_label("running provider tools")
|
|
818
|
-
self._spinner.render_now()
|
|
354
|
+
self.prompter.set_status("calling provider tools")
|
|
819
355
|
return
|
|
820
356
|
|
|
821
357
|
if event.kind == "tool_completed":
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
self.
|
|
836
|
-
|
|
837
|
-
return
|
|
838
|
-
message = format_cli_tool_message(
|
|
839
|
-
tool_name,
|
|
840
|
-
summary,
|
|
841
|
-
is_error,
|
|
842
|
-
)
|
|
843
|
-
self._remember_agent_name(tool_name, summary)
|
|
844
|
-
self._print_line(self._colorize_formatted_tool_message(message))
|
|
845
|
-
if self._input_active:
|
|
846
|
-
self._spinner.pause()
|
|
847
|
-
else:
|
|
848
|
-
self._spinner.resume()
|
|
849
|
-
self._spinner.set_label("thinking")
|
|
850
|
-
self._spinner.render_now()
|
|
851
|
-
return
|
|
852
|
-
|
|
853
|
-
if event.kind == "turn_completed":
|
|
854
|
-
submission_id = str(event.payload.get("submission_id", event.turn_id)).strip()
|
|
855
|
-
final_text = str(event.payload.get("output_text", "") or "")
|
|
856
|
-
self._finalize_turn_output(final_text, allow_standalone_output=True)
|
|
857
|
-
pending_prompt = self._pending_user_prompts.pop(submission_id, None)
|
|
858
|
-
if pending_prompt is not None:
|
|
859
|
-
self._history.append((pending_prompt, final_text))
|
|
358
|
+
tool_name = str(event.payload.get("tool_name", "")).strip()
|
|
359
|
+
message = tool_summary(event.payload)
|
|
360
|
+
if tool_name:
|
|
361
|
+
self.prompter.set_status(f"called {tool_name}")
|
|
362
|
+
message = self._replace_agent_ids_with_names(tool_name, message)
|
|
363
|
+
if tool_name == "spawn_agent":
|
|
364
|
+
agent_summary = message
|
|
365
|
+
prefix = "[spawn_agent] spawned "
|
|
366
|
+
if agent_summary.startswith(prefix):
|
|
367
|
+
agent_summary = agent_summary[len(prefix) :]
|
|
368
|
+
self._remember_agent_name(tool_name, agent_summary)
|
|
369
|
+
for line in message.splitlines() or [""]:
|
|
370
|
+
self._print_line(
|
|
371
|
+
colorize_tool_message(line, self._color_enabled, tool_name)
|
|
372
|
+
)
|
|
860
373
|
return
|
|
861
374
|
|
|
862
375
|
if event.kind == "turn_failed":
|
|
863
|
-
submission_id = str(
|
|
864
|
-
|
|
865
|
-
|
|
376
|
+
submission_id = str(
|
|
377
|
+
event.payload.get("submission_id", event.turn_id)
|
|
378
|
+
).strip()
|
|
866
379
|
self._pending_user_prompts.pop(submission_id, None)
|
|
380
|
+
self.prompter.set_status(active=False)
|
|
867
381
|
return
|
|
868
382
|
|
|
869
383
|
if event.kind == "turn_interrupted":
|
|
870
|
-
submission_id = str(
|
|
384
|
+
submission_id = str(
|
|
385
|
+
event.payload.get("submission_id", event.turn_id)
|
|
386
|
+
).strip()
|
|
871
387
|
final_text = str(event.payload.get("output_text", "") or "")
|
|
872
|
-
self._finalize_turn_output(final_text, allow_standalone_output=False)
|
|
873
388
|
pending_prompt = self._pending_user_prompts.pop(submission_id, None)
|
|
874
389
|
if pending_prompt is not None and final_text:
|
|
875
390
|
self._history.append((pending_prompt, final_text))
|
|
391
|
+
self.prompter.set_status(active=False)
|
|
876
392
|
return
|
|
877
393
|
|
|
878
|
-
def show_history(self) ->
|
|
879
|
-
self.
|
|
394
|
+
def show_history(self) -> "None":
|
|
395
|
+
self.finish_stream()
|
|
880
396
|
if not self._history:
|
|
881
397
|
self._print_line("No history yet.")
|
|
882
398
|
return
|
|
883
399
|
|
|
884
400
|
self._print_line(f"Session: {self._title or 'untitled'}")
|
|
885
401
|
for index, (user_text, assistant_text) in enumerate(self._history, start=1):
|
|
886
|
-
self._print_line(f"[{index}]
|
|
887
|
-
self._print_line(f"
|
|
402
|
+
self._print_line(f"[{index}]U> {user_text}")
|
|
403
|
+
self._print_line(f"[{index}]A> {assistant_text}")
|
|
888
404
|
|
|
889
|
-
def show_title(self) ->
|
|
890
|
-
self.
|
|
405
|
+
def show_title(self) -> "None":
|
|
406
|
+
self.finish_stream()
|
|
891
407
|
self._print_line(f"Session: {self._title or 'untitled'}")
|
|
892
408
|
|
|
893
409
|
def load_session_history(
|
|
894
410
|
self,
|
|
895
|
-
title:
|
|
896
|
-
history:
|
|
897
|
-
) ->
|
|
898
|
-
self.
|
|
899
|
-
self._finish_stream()
|
|
411
|
+
title: "typing.Union[str, None]",
|
|
412
|
+
history: "typing.Tuple[typing.Tuple[str, str], ...]",
|
|
413
|
+
) -> "None":
|
|
414
|
+
self.finish_stream()
|
|
900
415
|
self._title = title or None
|
|
901
416
|
self._history = list(history)
|
|
902
417
|
self._pending_user_prompts.clear()
|
|
903
418
|
self._queued_steer_prompts.clear()
|
|
904
419
|
self._inserted_steer_prompts.clear()
|
|
905
420
|
|
|
906
|
-
def
|
|
907
|
-
self.
|
|
908
|
-
|
|
909
|
-
def resume_spinner(self) -> 'None':
|
|
910
|
-
self._spinner.resume()
|
|
911
|
-
if not self._input_active:
|
|
912
|
-
self._spinner.render_now()
|
|
913
|
-
|
|
914
|
-
def set_input_active(self, active: 'bool', resume_spinner: 'bool' = True) -> 'None':
|
|
915
|
-
self._input_active = active
|
|
916
|
-
if active:
|
|
917
|
-
self._spinner.pause()
|
|
918
|
-
elif resume_spinner:
|
|
919
|
-
self._spinner.resume()
|
|
920
|
-
|
|
921
|
-
def is_streaming_output(self) -> 'bool':
|
|
922
|
-
return self._streaming
|
|
923
|
-
|
|
924
|
-
def handoff_prompt_stream_to_output(self) -> 'None':
|
|
925
|
-
if not self._streaming or not self._streaming_in_prompt:
|
|
926
|
-
return
|
|
927
|
-
buffered = self._prompt_stream_buffer
|
|
928
|
-
self._prompt_stream_buffer = ""
|
|
929
|
-
self._streaming_in_prompt = False
|
|
930
|
-
if not buffered:
|
|
931
|
-
return
|
|
932
|
-
with self._terminal_lock:
|
|
933
|
-
self._raw_write("assistant> ")
|
|
934
|
-
self._raw_write(buffered)
|
|
935
|
-
self._raw_flush()
|
|
936
|
-
|
|
937
|
-
async def poll_prompt(self, prompt: 'str') -> 'typing.Union[str, None]':
|
|
938
|
-
if self._prompt_task is None:
|
|
939
|
-
if self.is_streaming_output():
|
|
940
|
-
return None
|
|
941
|
-
self._prompt_task = asyncio.create_task(self.prompt_async(prompt))
|
|
942
|
-
|
|
943
|
-
done, _pending = await asyncio.wait(
|
|
944
|
-
{self._prompt_task},
|
|
945
|
-
timeout=0.05,
|
|
946
|
-
return_when=asyncio.FIRST_COMPLETED,
|
|
947
|
-
)
|
|
948
|
-
if self._prompt_task not in done:
|
|
949
|
-
if self.is_streaming_output():
|
|
950
|
-
await self._handoff_prompt_task_to_output()
|
|
951
|
-
return None
|
|
421
|
+
def close(self):
|
|
422
|
+
self.prompter.close()
|
|
952
423
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
except asyncio.CancelledError:
|
|
958
|
-
return None
|
|
959
|
-
finally:
|
|
960
|
-
self.set_input_active(False, resume_spinner=False)
|
|
424
|
+
async def poll_prompt(self, prompt: "str" = None) -> "typing.Union[str, None]":
|
|
425
|
+
if prompt:
|
|
426
|
+
self.prompter.set_prompt(prompt)
|
|
427
|
+
return await self.prompter.poll_input()
|
|
961
428
|
|
|
962
|
-
def
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if self._streaming and self._streaming_in_prompt:
|
|
967
|
-
if self._prompt_stream_buffer:
|
|
968
|
-
return f"assistant> {self._prompt_stream_buffer}\n"
|
|
969
|
-
return "\n"
|
|
970
|
-
prompt_line = self._spinner.prompt_line()
|
|
971
|
-
if not prompt_line:
|
|
972
|
-
return prompt
|
|
973
|
-
return f"{prompt_line}\n{prompt}"
|
|
429
|
+
async def get_prompt(self, prompt: "str" = None) -> "str":
|
|
430
|
+
if prompt:
|
|
431
|
+
self.prompter.set_prompt(prompt)
|
|
432
|
+
return await self.prompter.require_input()
|
|
974
433
|
|
|
975
|
-
def _update_context_window(self, usage:
|
|
434
|
+
def _update_context_window(self, usage: "object") -> "None":
|
|
976
435
|
if self._context_window_tokens is None:
|
|
977
436
|
return
|
|
978
437
|
if not isinstance(usage, dict):
|
|
@@ -988,14 +447,14 @@ class CliSessionView:
|
|
|
988
447
|
self._context_window_tokens,
|
|
989
448
|
)
|
|
990
449
|
|
|
991
|
-
def _format_main_prompt(self, prompt:
|
|
450
|
+
def _format_main_prompt(self, prompt: "str") -> "str":
|
|
992
451
|
if prompt != DEFAULT_MAIN_PROMPT:
|
|
993
452
|
return prompt
|
|
994
453
|
if self._context_remaining_percent is None:
|
|
995
454
|
return prompt
|
|
996
455
|
return f"pyco({self._context_remaining_percent}%)> "
|
|
997
456
|
|
|
998
|
-
def show_steer_queued(self, turn_id:
|
|
457
|
+
def show_steer_queued(self, turn_id: "str", prompt: "str") -> "None":
|
|
999
458
|
preview = shorten_title(prompt, limit=72)
|
|
1000
459
|
self._queued_steer_prompts.setdefault(turn_id, []).append(preview)
|
|
1001
460
|
self._print_line(
|
|
@@ -1006,43 +465,34 @@ class CliSessionView:
|
|
|
1006
465
|
)
|
|
1007
466
|
)
|
|
1008
467
|
|
|
1009
|
-
def schedule_steer_inserted(self, turn_id:
|
|
468
|
+
def schedule_steer_inserted(self, turn_id: "str", prompt: "str") -> "None":
|
|
1010
469
|
self._inserted_steer_prompts.setdefault(turn_id, []).append(
|
|
1011
470
|
shorten_title(prompt, limit=72)
|
|
1012
471
|
)
|
|
1013
472
|
|
|
1014
|
-
def close(self) -> 'None':
|
|
1015
|
-
if self._prompt_task is not None and not self._prompt_task.done():
|
|
1016
|
-
self._prompt_task.cancel()
|
|
1017
|
-
self._prompt_task = None
|
|
1018
|
-
self._spinner.close()
|
|
1019
|
-
if self._stdout_proxy is not None:
|
|
1020
|
-
self._stdout_proxy.close()
|
|
1021
|
-
|
|
1022
473
|
def set_context_window_tokens(
|
|
1023
474
|
self,
|
|
1024
|
-
context_window_tokens:
|
|
1025
|
-
) ->
|
|
475
|
+
context_window_tokens: "typing.Union[int, None]",
|
|
476
|
+
) -> "None":
|
|
1026
477
|
self._context_window_tokens = context_window_tokens
|
|
1027
478
|
self._context_remaining_percent = (
|
|
1028
479
|
100 if context_window_tokens is not None else None
|
|
1029
480
|
)
|
|
1030
481
|
|
|
1031
|
-
def finish_stream(self) ->
|
|
1032
|
-
self.
|
|
482
|
+
def finish_stream(self) -> "None":
|
|
483
|
+
with self._terminal_lock:
|
|
484
|
+
if self._stream_buffer:
|
|
485
|
+
self._print_line("assistant> " + self._stream_buffer)
|
|
486
|
+
self._stream_buffer = ""
|
|
1033
487
|
|
|
1034
|
-
def write_line(self, text:
|
|
488
|
+
def write_line(self, text: "str") -> "None":
|
|
1035
489
|
self._print_line(text)
|
|
1036
490
|
|
|
1037
|
-
def show_error(self, text:
|
|
1038
|
-
self.
|
|
1039
|
-
self._finish_stream()
|
|
491
|
+
def show_error(self, text: "str") -> "None":
|
|
492
|
+
self.finish_stream()
|
|
1040
493
|
lines = str(text).splitlines() or [""]
|
|
1041
494
|
formatted = [f"Error: {lines[0]}"]
|
|
1042
|
-
formatted.extend(
|
|
1043
|
-
f" {line}" if line else ""
|
|
1044
|
-
for line in lines[1:]
|
|
1045
|
-
)
|
|
495
|
+
formatted.extend(f" {line}" if line else "" for line in lines[1:])
|
|
1046
496
|
self._print_line(
|
|
1047
497
|
colorize_cli_message(
|
|
1048
498
|
"\n".join(formatted),
|
|
@@ -1051,71 +501,16 @@ class CliSessionView:
|
|
|
1051
501
|
)
|
|
1052
502
|
)
|
|
1053
503
|
|
|
1054
|
-
def
|
|
1055
|
-
with self._terminal_lock:
|
|
1056
|
-
self._spinner.clear()
|
|
1057
|
-
if self._streaming:
|
|
1058
|
-
self._raw_write("\n")
|
|
1059
|
-
self._raw_flush()
|
|
1060
|
-
self._streaming = False
|
|
1061
|
-
self._streaming_in_prompt = False
|
|
1062
|
-
self._prompt_stream_buffer = ""
|
|
1063
|
-
|
|
1064
|
-
def _finalize_turn_output(
|
|
1065
|
-
self,
|
|
1066
|
-
final_text: 'str',
|
|
1067
|
-
allow_standalone_output: 'bool',
|
|
1068
|
-
) -> 'None':
|
|
1069
|
-
self._spinner.finish_turn()
|
|
1070
|
-
if self._streaming and self._streaming_in_prompt:
|
|
1071
|
-
streamed_text = self._prompt_stream_buffer
|
|
1072
|
-
self._streaming = False
|
|
1073
|
-
self._streaming_in_prompt = False
|
|
1074
|
-
self._prompt_stream_buffer = ""
|
|
1075
|
-
final_display_text = final_text or streamed_text
|
|
1076
|
-
if final_display_text:
|
|
1077
|
-
self._print_line(
|
|
1078
|
-
colorize_cli_message(
|
|
1079
|
-
f"assistant> {final_display_text}",
|
|
1080
|
-
"assistant",
|
|
1081
|
-
self._color_enabled,
|
|
1082
|
-
)
|
|
1083
|
-
)
|
|
1084
|
-
return
|
|
1085
|
-
if self._streaming:
|
|
1086
|
-
self._finish_stream()
|
|
1087
|
-
return
|
|
1088
|
-
if allow_standalone_output and final_text:
|
|
1089
|
-
self._print_line(
|
|
1090
|
-
colorize_cli_message(
|
|
1091
|
-
f"assistant> {final_text}",
|
|
1092
|
-
"assistant",
|
|
1093
|
-
self._color_enabled,
|
|
1094
|
-
)
|
|
1095
|
-
)
|
|
1096
|
-
|
|
1097
|
-
def _colorize_formatted_tool_message(self, message: 'str') -> 'str':
|
|
1098
|
-
if message.startswith("[plan]"):
|
|
1099
|
-
return colorize_cli_message(message, "plan", self._color_enabled)
|
|
1100
|
-
if message.startswith("[exec]"):
|
|
1101
|
-
return colorize_cli_message(message, "exec", self._color_enabled)
|
|
1102
|
-
if message.startswith("[agent]"):
|
|
1103
|
-
return colorize_cli_message(message, "agent", self._color_enabled)
|
|
1104
|
-
if message.startswith("[web]"):
|
|
1105
|
-
return colorize_cli_message(message, "web", self._color_enabled)
|
|
1106
|
-
if message.startswith("[error]"):
|
|
1107
|
-
return colorize_cli_message(message, "error", self._color_enabled)
|
|
1108
|
-
return colorize_cli_message(message, "tool", self._color_enabled)
|
|
1109
|
-
|
|
1110
|
-
def _print_line(self, text: 'str') -> 'None':
|
|
504
|
+
def _print_line(self, text: "str") -> "None":
|
|
1111
505
|
with self._terminal_lock:
|
|
1112
|
-
self._spinner.clear()
|
|
1113
506
|
self._line_output(text)
|
|
1114
507
|
|
|
1115
|
-
def _print_user_turn(self, text:
|
|
1116
|
-
self._print_line(
|
|
508
|
+
def _print_user_turn(self, text: "str") -> "None":
|
|
509
|
+
self._print_line(
|
|
510
|
+
colorize_cli_message(f"user> {text}", "assistant", self._color_enabled)
|
|
511
|
+
)
|
|
1117
512
|
|
|
1118
|
-
def _remember_agent_name(self, tool_name:
|
|
513
|
+
def _remember_agent_name(self, tool_name: "str", summary: "str") -> "None":
|
|
1119
514
|
if tool_name != "spawn_agent":
|
|
1120
515
|
return
|
|
1121
516
|
if " (" not in summary or not summary.endswith(")"):
|
|
@@ -1127,46 +522,13 @@ class CliSessionView:
|
|
|
1127
522
|
return
|
|
1128
523
|
self._agent_names[agent_short_id] = nickname
|
|
1129
524
|
|
|
1130
|
-
def
|
|
525
|
+
def _replace_agent_ids_with_names(self, tool_name: "str", message: "str") -> "str":
|
|
1131
526
|
if tool_name not in {"wait_agent", "send_input", "resume_agent", "close_agent"}:
|
|
1132
|
-
return
|
|
1133
|
-
rewritten = summary
|
|
527
|
+
return message
|
|
1134
528
|
for agent_short_id, nickname in sorted(
|
|
1135
529
|
self._agent_names.items(),
|
|
1136
530
|
key=lambda item: len(item[0]),
|
|
1137
531
|
reverse=True,
|
|
1138
532
|
):
|
|
1139
|
-
|
|
1140
|
-
return
|
|
1141
|
-
|
|
1142
|
-
async def prompt_async(self, prompt: 'str') -> 'str':
|
|
1143
|
-
if self._prompt_session is None:
|
|
1144
|
-
self._prompt_session = PromptSession(
|
|
1145
|
-
erase_when_done=True,
|
|
1146
|
-
enable_system_prompt=True,
|
|
1147
|
-
)
|
|
1148
|
-
if self._stdout_proxy is None:
|
|
1149
|
-
self._stdout_proxy = StdoutProxy(raw=False)
|
|
1150
|
-
self._raw_write = self._stdout_proxy.write
|
|
1151
|
-
self._raw_flush = self._stdout_proxy.flush
|
|
1152
|
-
|
|
1153
|
-
self.set_input_active(True)
|
|
1154
|
-
try:
|
|
1155
|
-
with patch_stdout(raw=True):
|
|
1156
|
-
return await self._prompt_session.prompt_async(
|
|
1157
|
-
lambda: self.build_input_prompt(prompt),
|
|
1158
|
-
refresh_interval=0.12,
|
|
1159
|
-
)
|
|
1160
|
-
finally:
|
|
1161
|
-
self.set_input_active(False, resume_spinner=False)
|
|
1162
|
-
|
|
1163
|
-
async def _handoff_prompt_task_to_output(self) -> 'None':
|
|
1164
|
-
if self._prompt_task is None:
|
|
1165
|
-
return
|
|
1166
|
-
prompt_task = self._prompt_task
|
|
1167
|
-
self._prompt_task = None
|
|
1168
|
-
prompt_task.cancel()
|
|
1169
|
-
with suppress(asyncio.CancelledError):
|
|
1170
|
-
await prompt_task
|
|
1171
|
-
self.set_input_active(False, resume_spinner=False)
|
|
1172
|
-
self.handoff_prompt_stream_to_output()
|
|
533
|
+
message = message.replace(agent_short_id, nickname)
|
|
534
|
+
return message
|