python-codex 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl

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