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.
@@ -1,40 +1,32 @@
1
-
2
1
  import asyncio
3
- import json
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, ToolResult
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
- 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 = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
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: 'str', limit: 'int' = 48) -> 'str':
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() -> 'bool':
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: 'int',
91
- context_window_tokens: 'int',
92
- ) -> 'int':
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 Spinner:
107
- def __init__(
108
- self,
109
- raw_write,
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
- def start_turn(self, label: 'str' = "thinking") -> 'None':
136
- with self._terminal_lock:
137
- self._turn_active = True
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
- def set_label(self, label: 'str') -> 'None':
142
- with self._terminal_lock:
143
- self._label = label
60
+ self._prompt_task = None
144
61
 
145
- def finish_turn(self) -> 'None':
146
- with self._terminal_lock:
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 pause(self) -> 'None':
152
- with self._terminal_lock:
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 resume(self) -> 'None':
157
- with self._terminal_lock:
158
- self._paused = False
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
- def clear(self) -> 'None':
161
- with self._terminal_lock:
162
- if not self._visible:
163
- return
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
- self._index += 1
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
- with self._terminal_lock:
194
- label = self._label
195
- frame_index = int(time.monotonic() / 0.12)
196
- return build_cli_spinner_frame(frame_index, label)
197
-
198
- def _run(self) -> 'None':
199
- while not self._stop.wait(0.12):
200
- if not self._turn_active or self._paused:
201
- continue
202
- frame = colorize_cli_message(
203
- build_cli_spinner_frame(self._index, self._label),
204
- "status",
205
- self._color_enabled,
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
- self._index += 1
208
- with self._terminal_lock:
209
- if not self._turn_active or self._paused:
210
- continue
211
- self._raw_write(f"\r\x1b[2K{frame}")
212
- self._raw_flush()
213
- self._visible = True
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 format_cli_tool_call_message(tool_name: 'str', payload: 'JSONDict') -> 'typing.Union[str, None]':
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"[web] searched: {query}" if query else "[web] searched"
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"[web] opened: {url}" if url else "[web] opened"
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"[web] found: {pattern} @ {url}"
155
+ return f"[web_search] found: {pattern} @ {url}"
238
156
  if pattern:
239
- return f"[web] found: {pattern}"
240
- return "[web] found in page"
157
+ return f"[web_search] found: {pattern}"
158
+ return "[web_search] found in page"
241
159
 
242
- return "[web] browsing"
160
+ return "[web_search] browsing"
243
161
 
244
162
 
245
- def short_id(value: 'str', limit: 'int' = 8) -> 'str':
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 spinner state;
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/spinner resources at shutdown.
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: 'typing.Union[int, None]' = None,
603
- ) -> 'None':
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: 'typing.Union[str, None]' = None
611
- self._pending_user_prompts: 'typing.Dict[str, str]' = {}
612
- self._queued_steer_prompts: 'typing.Dict[str, typing.List[str]]' = {}
613
- self._inserted_steer_prompts: 'typing.Dict[str, typing.List[str]]' = {}
614
- self._history: 'typing.List[typing.Tuple[str, str]]' = []
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: 'typing.Union[int, None]' = (
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: 'typing.Dict[str, str]' = {}
625
- self._prompt_session: 'typing.Union[PromptSession, None]' = None
626
- self._prompt_task: 'typing.Union[asyncio.Task[str], None]' = None
627
- self._stdout_proxy: 'typing.Union[StdoutProxy, None]' = None
628
- self._spinner = Spinner(
629
- self._raw_write,
630
- self._raw_flush,
631
- self._terminal_lock,
632
- self._color_enabled,
633
- False,
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
- submission_id = str(event.payload.get("submission_id", event.turn_id)).strip()
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
- if self._input_active:
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
- if self._input_active:
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
- if self._input_active:
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
- if self._input_active:
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._finish_stream()
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 self._input_active:
809
- self._spinner.pause()
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._spinner.resume()
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
- self._finish_stream()
823
- tool_name, summary, is_error = extract_tool_event_display(event.payload)
824
- summary = self._rewrite_agent_summary(tool_name, summary)
825
- if tool_name == "update_plan" and not is_error:
826
- plan_items = extract_plan_event_items(event.payload)
827
- for line in format_cli_plan_messages(summary, plan_items):
828
- self._print_line(
829
- colorize_cli_message(line, "plan", self._color_enabled)
830
- )
831
- if self._input_active:
832
- self._spinner.pause()
833
- else:
834
- self._spinner.resume()
835
- self._spinner.set_label("thinking")
836
- self._spinner.render_now()
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(event.payload.get("submission_id", event.turn_id)).strip()
864
- self._spinner.finish_turn()
865
- self._finish_stream()
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(event.payload.get("submission_id", event.turn_id)).strip()
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) -> 'None':
879
- self._finish_stream()
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}] user> {user_text}")
887
- self._print_line(f" assistant> {assistant_text}")
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) -> 'None':
890
- self._finish_stream()
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: 'typing.Union[str, None]',
896
- history: 'typing.Tuple[typing.Tuple[str, str], ...]',
897
- ) -> 'None':
898
- self._spinner.finish_turn()
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 pause_spinner(self) -> 'None':
907
- self._spinner.pause()
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
- prompt_task = self._prompt_task
954
- self._prompt_task = None
955
- try:
956
- return prompt_task.result()
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 build_input_prompt(self, prompt: 'str') -> 'str':
963
- prompt = self._format_main_prompt(prompt)
964
- if not self._input_active:
965
- return prompt
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: 'object') -> 'None':
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: 'str') -> 'str':
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: 'str', prompt: 'str') -> 'None':
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: 'str', prompt: 'str') -> 'None':
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: 'typing.Union[int, None]',
1025
- ) -> 'None':
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) -> 'None':
1032
- self._finish_stream()
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: 'str') -> 'None':
488
+ def write_line(self, text: "str") -> "None":
1035
489
  self._print_line(text)
1036
490
 
1037
- def show_error(self, text: 'str') -> 'None':
1038
- self._spinner.finish_turn()
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 _finish_stream(self) -> 'None':
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: 'str') -> 'None':
1116
- self._print_line(f"user> {text}")
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: 'str', summary: 'str') -> 'None':
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 _rewrite_agent_summary(self, tool_name: 'str', summary: 'str') -> 'str':
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 summary
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
- rewritten = rewritten.replace(agent_short_id, nickname)
1140
- return rewritten
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