python-codex 0.1.2__py3-none-any.whl → 0.1.4__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 (60) hide show
  1. pycodex/__init__.py +5 -1
  2. pycodex/agent.py +89 -51
  3. pycodex/cli.py +152 -45
  4. pycodex/collaboration.py +6 -7
  5. pycodex/compat.py +99 -0
  6. pycodex/context.py +110 -87
  7. pycodex/doctor.py +40 -40
  8. pycodex/model.py +429 -90
  9. pycodex/portable.py +33 -33
  10. pycodex/portable_server.py +22 -21
  11. pycodex/prompts/models.json +30 -0
  12. pycodex/protocol.py +84 -86
  13. pycodex/runtime.py +36 -35
  14. pycodex/runtime_services.py +69 -69
  15. pycodex/tools/agent_tool_schemas.py +0 -2
  16. pycodex/tools/apply_patch_tool.py +45 -46
  17. pycodex/tools/base_tool.py +35 -36
  18. pycodex/tools/close_agent_tool.py +2 -4
  19. pycodex/tools/code_mode_manager.py +61 -61
  20. pycodex/tools/exec_command_tool.py +5 -6
  21. pycodex/tools/exec_runtime.js +3 -3
  22. pycodex/tools/exec_tool.py +2 -4
  23. pycodex/tools/grep_files_tool.py +10 -11
  24. pycodex/tools/list_dir_tool.py +8 -9
  25. pycodex/tools/read_file_tool.py +13 -14
  26. pycodex/tools/request_permissions_tool.py +2 -4
  27. pycodex/tools/request_user_input_tool.py +13 -14
  28. pycodex/tools/resume_agent_tool.py +2 -4
  29. pycodex/tools/send_input_tool.py +8 -9
  30. pycodex/tools/shell_command_tool.py +5 -6
  31. pycodex/tools/shell_tool.py +5 -6
  32. pycodex/tools/spawn_agent_tool.py +4 -5
  33. pycodex/tools/unified_exec_manager.py +62 -61
  34. pycodex/tools/update_plan_tool.py +4 -5
  35. pycodex/tools/view_image_tool.py +4 -5
  36. pycodex/tools/wait_agent_tool.py +2 -4
  37. pycodex/tools/wait_tool.py +4 -5
  38. pycodex/tools/web_search_tool.py +1 -3
  39. pycodex/tools/write_stdin_tool.py +4 -5
  40. pycodex/utils/__init__.py +4 -0
  41. pycodex/utils/compactor.py +189 -0
  42. pycodex/utils/dotenv.py +6 -6
  43. pycodex/utils/get_env.py +37 -33
  44. pycodex/utils/random_ids.py +1 -2
  45. pycodex/utils/session_persist.py +483 -0
  46. pycodex/utils/visualize.py +197 -83
  47. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/METADATA +32 -11
  48. python_codex-0.1.4.dist-info/RECORD +76 -0
  49. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/WHEEL +1 -1
  50. responses_server/app.py +32 -20
  51. responses_server/config.py +17 -17
  52. responses_server/payload_processors.py +26 -17
  53. responses_server/server.py +11 -11
  54. responses_server/session_store.py +10 -10
  55. responses_server/stream_router.py +83 -64
  56. responses_server/tools/custom_adapter.py +12 -12
  57. responses_server/tools/web_search.py +33 -33
  58. python_codex-0.1.2.dist-info/RECORD +0 -73
  59. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/entry_points.txt +0 -0
  60. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,3 @@
1
- from __future__ import annotations
2
1
 
3
2
  import asyncio
4
3
  import json
@@ -12,6 +11,7 @@ from prompt_toolkit.patch_stdout import StdoutProxy
12
11
  from prompt_toolkit.patch_stdout import patch_stdout
13
12
 
14
13
  from ..protocol import AgentEvent, JSONDict, ToolCall, ToolResult
14
+ import typing
15
15
 
16
16
  ANSI_RESET = "\x1b[0m"
17
17
  ANSI_BOLD = "\x1b[1m"
@@ -23,16 +23,18 @@ ANSI_YELLOW = "\x1b[33m"
23
23
  ANSI_MAGENTA = "\x1b[35m"
24
24
  ANSI_RED = "\x1b[31m"
25
25
  SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
26
+ PROMPT_CONTEXT_BASELINE_TOKENS = 12_000
27
+ DEFAULT_MAIN_PROMPT = "pycodex> "
26
28
 
27
29
 
28
- def shorten_title(text: str, limit: int = 48) -> str:
30
+ def shorten_title(text: 'str', limit: 'int' = 48) -> 'str':
29
31
  compact = " ".join(text.split())
30
32
  if len(compact) <= limit:
31
33
  return compact
32
34
  return compact[: limit - 3].rstrip() + "..."
33
35
 
34
36
 
35
- def cli_color_enabled() -> bool:
37
+ def cli_color_enabled() -> 'bool':
36
38
  return os.environ.get("PYCODEX_NO_COLOR", "").strip().lower() not in {
37
39
  "1",
38
40
  "true",
@@ -41,7 +43,7 @@ def cli_color_enabled() -> bool:
41
43
  }
42
44
 
43
45
 
44
- def colorize_cli_message(text: str, kind: str, enabled: bool) -> str:
46
+ def colorize_cli_message(text: 'str', kind: 'str', enabled: 'bool') -> 'str':
45
47
  if not enabled:
46
48
  return text
47
49
  palette = {
@@ -61,9 +63,9 @@ def colorize_cli_message(text: str, kind: str, enabled: bool) -> str:
61
63
 
62
64
 
63
65
  def format_cli_plan_messages(
64
- summary: str,
65
- plan_items: list[JSONDict],
66
- ) -> list[str]:
66
+ summary: 'str',
67
+ plan_items: 'typing.List[JSONDict]',
68
+ ) -> 'typing.List[str]':
67
69
  lines = [f"[plan] {summary}" if summary else "[plan] Plan updated"]
68
70
  for item in plan_items:
69
71
  step = str(item.get("step", "")).strip()
@@ -79,20 +81,37 @@ def format_cli_plan_messages(
79
81
  return lines
80
82
 
81
83
 
82
- def build_cli_spinner_frame(index: int, label: str) -> str:
84
+ def build_cli_spinner_frame(index: 'int', label: 'str') -> 'str':
83
85
  suffix = f" {label}" if label else ""
84
86
  return f"⏳{suffix} {SPINNER_FRAMES[index % len(SPINNER_FRAMES)]}"
85
87
 
86
88
 
89
+ def percent_of_context_window_remaining(
90
+ total_tokens: 'int',
91
+ context_window_tokens: 'int',
92
+ ) -> 'int':
93
+ if context_window_tokens <= PROMPT_CONTEXT_BASELINE_TOKENS:
94
+ return 0
95
+
96
+ effective_window = context_window_tokens - PROMPT_CONTEXT_BASELINE_TOKENS
97
+ used = max(total_tokens - PROMPT_CONTEXT_BASELINE_TOKENS, 0)
98
+ 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
+ )
104
+
105
+
87
106
  class Spinner:
88
107
  def __init__(
89
108
  self,
90
109
  raw_write,
91
110
  raw_flush,
92
- terminal_lock: threading.RLock,
93
- color_enabled: bool,
94
- enabled: bool,
95
- ) -> None:
111
+ terminal_lock: 'threading.RLock',
112
+ color_enabled: 'bool',
113
+ enabled: 'bool',
114
+ ) -> 'None':
96
115
  self._raw_write = raw_write
97
116
  self._raw_flush = raw_flush
98
117
  self._terminal_lock = terminal_lock
@@ -104,7 +123,7 @@ class Spinner:
104
123
  self._index = 0
105
124
  self._label = "thinking"
106
125
  self._stop = threading.Event()
107
- self._thread: threading.Thread | None = None
126
+ self._thread: 'typing.Union[threading.Thread, None]' = None
108
127
  if self._enabled:
109
128
  self._thread = threading.Thread(
110
129
  target=self._run,
@@ -113,32 +132,32 @@ class Spinner:
113
132
  )
114
133
  self._thread.start()
115
134
 
116
- def start_turn(self, label: str = "thinking") -> None:
135
+ def start_turn(self, label: 'str' = "thinking") -> 'None':
117
136
  with self._terminal_lock:
118
137
  self._turn_active = True
119
138
  self._paused = False
120
139
  self._label = label
121
140
 
122
- def set_label(self, label: str) -> None:
141
+ def set_label(self, label: 'str') -> 'None':
123
142
  with self._terminal_lock:
124
143
  self._label = label
125
144
 
126
- def finish_turn(self) -> None:
145
+ def finish_turn(self) -> 'None':
127
146
  with self._terminal_lock:
128
147
  self._turn_active = False
129
148
  self._paused = False
130
149
  self.clear()
131
150
 
132
- def pause(self) -> None:
151
+ def pause(self) -> 'None':
133
152
  with self._terminal_lock:
134
153
  self._paused = True
135
154
  self.clear()
136
155
 
137
- def resume(self) -> None:
156
+ def resume(self) -> 'None':
138
157
  with self._terminal_lock:
139
158
  self._paused = False
140
159
 
141
- def clear(self) -> None:
160
+ def clear(self) -> 'None':
142
161
  if not self._enabled or not self._visible:
143
162
  return
144
163
  with self._terminal_lock:
@@ -146,13 +165,13 @@ class Spinner:
146
165
  self._raw_flush()
147
166
  self._visible = False
148
167
 
149
- def close(self) -> None:
168
+ def close(self) -> 'None':
150
169
  self.finish_turn()
151
170
  if self._thread is not None:
152
171
  self._stop.set()
153
172
  self._thread.join(timeout=0.5)
154
173
 
155
- def prompt_line(self) -> str | None:
174
+ def prompt_line(self) -> 'typing.Union[str, None]':
156
175
  if not self._turn_active:
157
176
  return None
158
177
  with self._terminal_lock:
@@ -160,7 +179,7 @@ class Spinner:
160
179
  frame_index = int(time.monotonic() / 0.12)
161
180
  return build_cli_spinner_frame(frame_index, label)
162
181
 
163
- def _run(self) -> None:
182
+ def _run(self) -> 'None':
164
183
  while not self._stop.wait(0.12):
165
184
  if not self._turn_active or self._paused:
166
185
  continue
@@ -178,7 +197,7 @@ class Spinner:
178
197
  self._visible = True
179
198
 
180
199
 
181
- def format_cli_tool_call_message(tool_name: str, payload: JSONDict) -> str | None:
200
+ def format_cli_tool_call_message(tool_name: 'str', payload: 'JSONDict') -> 'typing.Union[str, None]':
182
201
  if tool_name != "web_search":
183
202
  return None
184
203
 
@@ -207,7 +226,7 @@ def format_cli_tool_call_message(tool_name: str, payload: JSONDict) -> str | Non
207
226
  return "[web] browsing"
208
227
 
209
228
 
210
- def short_id(value: str, limit: int = 8) -> str:
229
+ def short_id(value: 'str', limit: 'int' = 8) -> 'str':
211
230
  compact = value.strip()
212
231
  if len(compact) <= limit + 4:
213
232
  return compact
@@ -215,10 +234,10 @@ def short_id(value: str, limit: int = 8) -> str:
215
234
 
216
235
 
217
236
  def format_cli_tool_message(
218
- tool_name: str,
219
- summary: str,
220
- is_error: bool,
221
- ) -> str:
237
+ tool_name: 'str',
238
+ summary: 'str',
239
+ is_error: 'bool',
240
+ ) -> 'str':
222
241
  if tool_name == "update_plan":
223
242
  if is_error:
224
243
  return f"[error] plan failed: {summary}" if summary else "[error] plan failed"
@@ -290,13 +309,13 @@ def format_cli_tool_message(
290
309
  return f"[tool] {tool_name}: {summary}" if summary else f"[tool] {tool_name}"
291
310
 
292
311
 
293
- def extract_plan_items(arguments: object) -> list[JSONDict]:
312
+ def extract_plan_items(arguments: 'object') -> 'typing.List[JSONDict]':
294
313
  if not isinstance(arguments, dict):
295
314
  return []
296
315
  raw_plan = arguments.get("plan")
297
316
  if not isinstance(raw_plan, list):
298
317
  return []
299
- plan_items: list[JSONDict] = []
318
+ plan_items: 'typing.List[JSONDict]' = []
300
319
  for item in raw_plan:
301
320
  if not isinstance(item, dict):
302
321
  continue
@@ -309,7 +328,7 @@ def extract_plan_items(arguments: object) -> list[JSONDict]:
309
328
  return plan_items
310
329
 
311
330
 
312
- def summarize_tool_event(call: ToolCall, result: ToolResult) -> str | None:
331
+ def summarize_tool_event(call: 'ToolCall', result: 'ToolResult') -> 'typing.Union[str, None]':
313
332
  command_preview = _command_preview(call)
314
333
  result_summary = _summarize_tool_result(result)
315
334
  if call.name == "update_plan":
@@ -322,8 +341,8 @@ def summarize_tool_event(call: ToolCall, result: ToolResult) -> str | None:
322
341
 
323
342
 
324
343
  def extract_tool_event_display(
325
- payload: dict[str, object],
326
- ) -> tuple[str, str, bool]:
344
+ payload: 'typing.Dict[str, object]',
345
+ ) -> 'typing.Tuple[str, str, bool]':
327
346
  tool_name = str(payload.get("tool_name", "")).strip()
328
347
  is_error = bool(payload.get("is_error"))
329
348
  call = payload.get("call")
@@ -334,7 +353,7 @@ def extract_tool_event_display(
334
353
  return tool_name, summary, is_error
335
354
 
336
355
 
337
- def extract_plan_event_items(payload: dict[str, object]) -> list[JSONDict]:
356
+ def extract_plan_event_items(payload: 'typing.Dict[str, object]') -> 'typing.List[JSONDict]':
338
357
  call = payload.get("call")
339
358
  if isinstance(call, ToolCall):
340
359
  return extract_plan_items(call.arguments)
@@ -344,14 +363,14 @@ def extract_plan_event_items(payload: dict[str, object]) -> list[JSONDict]:
344
363
  return []
345
364
 
346
365
 
347
- def _truncate_text(text: str, limit: int = 96) -> str:
366
+ def _truncate_text(text: 'str', limit: 'int' = 96) -> 'str':
348
367
  compact = " ".join(text.split())
349
368
  if len(compact) <= limit:
350
369
  return compact
351
370
  return compact[: limit - 3].rstrip() + "..."
352
371
 
353
372
 
354
- def _extract_output_preview(text: str) -> str | None:
373
+ def _extract_output_preview(text: 'str') -> 'typing.Union[str, None]':
355
374
  lines = [line.strip() for line in text.splitlines()]
356
375
  if "Output:" in lines:
357
376
  output_index = lines.index("Output:")
@@ -368,7 +387,7 @@ def _extract_output_preview(text: str) -> str | None:
368
387
  return None
369
388
 
370
389
 
371
- def _summarize_agent_status(status: object) -> str:
390
+ def _summarize_agent_status(status: 'object') -> 'str':
372
391
  if isinstance(status, str):
373
392
  return status
374
393
  if isinstance(status, dict):
@@ -382,7 +401,7 @@ def _summarize_agent_status(status: object) -> str:
382
401
  return _truncate_text(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
383
402
 
384
403
 
385
- def _summarize_tool_result(result: ToolResult) -> str | None:
404
+ def _summarize_tool_result(result: 'ToolResult') -> 'typing.Union[str, None]':
386
405
  if result.name == "spawn_agent" and isinstance(result.output, dict):
387
406
  agent_id = str(result.output.get("agent_id", "")).strip()
388
407
  nickname = str(result.output.get("nickname", "")).strip()
@@ -399,7 +418,7 @@ def _summarize_tool_result(result: ToolResult) -> str | None:
399
418
  return "timed out"
400
419
  status = result.output.get("status")
401
420
  if isinstance(status, dict):
402
- parts: list[str] = []
421
+ parts: 'typing.List[str]' = []
403
422
  for agent_id, agent_status in status.items():
404
423
  if not isinstance(agent_id, str):
405
424
  continue
@@ -425,7 +444,7 @@ def _summarize_tool_result(result: ToolResult) -> str | None:
425
444
  return None
426
445
 
427
446
 
428
- def _string_arg(arguments: object, key: str) -> str | None:
447
+ def _string_arg(arguments: 'object', key: 'str') -> 'typing.Union[str, None]':
429
448
  if not isinstance(arguments, dict):
430
449
  return None
431
450
  value = arguments.get(key)
@@ -434,7 +453,7 @@ def _string_arg(arguments: object, key: str) -> str | None:
434
453
  return str(value)
435
454
 
436
455
 
437
- def _int_arg(arguments: object, key: str) -> int | None:
456
+ def _int_arg(arguments: 'object', key: 'str') -> 'typing.Union[int, None]':
438
457
  if not isinstance(arguments, dict):
439
458
  return None
440
459
  value = arguments.get(key)
@@ -443,7 +462,7 @@ def _int_arg(arguments: object, key: str) -> int | None:
443
462
  return int(value)
444
463
 
445
464
 
446
- def _command_preview(call: ToolCall) -> str | None:
465
+ def _command_preview(call: 'ToolCall') -> 'typing.Union[str, None]':
447
466
  if call.name == "exec_command":
448
467
  cmd = _string_arg(call.arguments, "cmd")
449
468
  if cmd:
@@ -503,7 +522,7 @@ def _command_preview(call: ToolCall) -> str | None:
503
522
  return None
504
523
 
505
524
 
506
- def _plan_progress_summary(plan: list[object]) -> str:
525
+ def _plan_progress_summary(plan: 'typing.List[object]') -> 'str':
507
526
  total = len(plan)
508
527
  completed = 0
509
528
  in_progress = 0
@@ -541,7 +560,7 @@ class CliSessionView:
541
560
  source has closed and the caller should end the session loop.
542
561
  - `write_line(text)`, `finish_stream()`, `show_error(text)`: imperative output
543
562
  helpers for CLI-side messages that do not come from `AgentEvent`.
544
- - `show_history()`, `show_title()`, `show_steer_queued(...)`,
563
+ - `show_history()`, `show_title()`, `load_session_history(...)`, `show_steer_queued(...)`,
545
564
  `schedule_steer_inserted(...)`: small session UI helpers used by the
546
565
  interactive command loop.
547
566
  - `close()`: release prompt/spinner resources at shutdown.
@@ -562,27 +581,34 @@ class CliSessionView:
562
581
  normal terminal stream so the reply is not lost.
563
582
  """
564
583
 
565
- def __init__(self) -> None:
584
+ def __init__(
585
+ self,
586
+ context_window_tokens: 'typing.Union[int, None]' = None,
587
+ ) -> 'None':
566
588
  import sys
567
589
 
568
590
  self._line_output = print
569
591
  self._raw_write = sys.stdout.write
570
592
  self._raw_flush = sys.stdout.flush
571
593
  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]] = []
594
+ self._title: 'typing.Union[str, None]' = None
595
+ self._pending_user_prompts: 'typing.Dict[str, str]' = {}
596
+ self._queued_steer_prompts: 'typing.Dict[str, typing.List[str]]' = {}
597
+ self._inserted_steer_prompts: 'typing.Dict[str, typing.List[str]]' = {}
598
+ self._history: 'typing.List[typing.Tuple[str, str]]' = []
577
599
  self._streaming = False
578
600
  self._prompt_stream_buffer = ""
579
601
  self._streaming_in_prompt = False
580
602
  self._input_active = False
603
+ self._context_window_tokens = context_window_tokens
604
+ self._context_remaining_percent: 'typing.Union[int, None]' = (
605
+ 100 if context_window_tokens is not None else None
606
+ )
581
607
  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
608
+ self._agent_names: 'typing.Dict[str, str]' = {}
609
+ self._prompt_session: 'typing.Union[PromptSession, None]' = None
610
+ self._prompt_task: 'typing.Union[asyncio.Task[str], None]' = None
611
+ self._stdout_proxy: 'typing.Union[StdoutProxy, None]' = None
586
612
  self._spinner = Spinner(
587
613
  self._raw_write,
588
614
  self._raw_flush,
@@ -591,7 +617,7 @@ class CliSessionView:
591
617
  False,
592
618
  )
593
619
 
594
- def handle_event(self, event: AgentEvent) -> None:
620
+ def handle_event(self, event: 'AgentEvent') -> 'None':
595
621
  if event.kind == "turn_started":
596
622
  submission_id = str(event.payload.get("submission_id", event.turn_id)).strip()
597
623
  user_texts = event.payload.get("user_texts")
@@ -627,6 +653,8 @@ class CliSessionView:
627
653
  self._color_enabled,
628
654
  )
629
655
  )
656
+ if user_text:
657
+ self._print_user_turn(user_text)
630
658
  self._spinner.start_turn("thinking")
631
659
  if self._input_active:
632
660
  self._spinner.pause()
@@ -640,6 +668,27 @@ class CliSessionView:
640
668
  self._spinner.set_label("waiting model")
641
669
  return
642
670
 
671
+ if event.kind == "token_count":
672
+ self._update_context_window(event.payload.get("usage"))
673
+ return
674
+
675
+ if event.kind == "stream_error":
676
+ self._finish_stream()
677
+ message = str(event.payload.get("message", "")).strip() or "Reconnecting..."
678
+ self._print_line(
679
+ colorize_cli_message(
680
+ f"[status] {message}",
681
+ "status",
682
+ self._color_enabled,
683
+ )
684
+ )
685
+ if self._input_active:
686
+ self._spinner.pause()
687
+ else:
688
+ self._spinner.resume()
689
+ self._spinner.set_label("reconnecting")
690
+ return
691
+
643
692
  if event.kind == "assistant_delta":
644
693
  delta = str(event.payload.get("delta", ""))
645
694
  if not delta:
@@ -676,16 +725,26 @@ class CliSessionView:
676
725
  self._spinner.pause()
677
726
  else:
678
727
  self._spinner.resume()
679
- self._spinner.set_label("running tools")
728
+ self._spinner.set_label("running provider tools")
680
729
  return
681
730
 
682
731
  if event.kind == "tool_started":
683
732
  self._finish_stream()
733
+ tool_name = str(event.payload.get("tool_name", "")).strip()
734
+ call = event.payload.get("call")
735
+ args = None
736
+ if isinstance(call, ToolCall):
737
+ args = call.arguments
684
738
  if self._input_active:
685
739
  self._spinner.pause()
686
740
  else:
687
741
  self._spinner.resume()
688
- self._spinner.set_label("running tools")
742
+ if tool_name and args is not None:
743
+ self._spinner.set_label(f"running {tool_name}({args})")
744
+ elif tool_name:
745
+ self._spinner.set_label(f"running {tool_name}")
746
+ else:
747
+ self._spinner.set_label("running provider tools")
689
748
  return
690
749
 
691
750
  if event.kind == "tool_completed":
@@ -738,7 +797,7 @@ class CliSessionView:
738
797
  self._history.append((pending_prompt, final_text))
739
798
  return
740
799
 
741
- def show_history(self) -> None:
800
+ def show_history(self) -> 'None':
742
801
  self._finish_stream()
743
802
  if not self._history:
744
803
  self._print_line("No history yet.")
@@ -749,27 +808,40 @@ class CliSessionView:
749
808
  self._print_line(f"[{index}] user> {user_text}")
750
809
  self._print_line(f" assistant> {assistant_text}")
751
810
 
752
- def show_title(self) -> None:
811
+ def show_title(self) -> 'None':
753
812
  self._finish_stream()
754
813
  self._print_line(f"Session: {self._title or 'untitled'}")
755
814
 
756
- def pause_spinner(self) -> None:
815
+ def load_session_history(
816
+ self,
817
+ title: 'typing.Union[str, None]',
818
+ history: 'typing.Tuple[typing.Tuple[str, str], ...]',
819
+ ) -> 'None':
820
+ self._spinner.finish_turn()
821
+ self._finish_stream()
822
+ self._title = title or None
823
+ self._history = list(history)
824
+ self._pending_user_prompts.clear()
825
+ self._queued_steer_prompts.clear()
826
+ self._inserted_steer_prompts.clear()
827
+
828
+ def pause_spinner(self) -> 'None':
757
829
  self._spinner.pause()
758
830
 
759
- def resume_spinner(self) -> None:
831
+ def resume_spinner(self) -> 'None':
760
832
  self._spinner.resume()
761
833
 
762
- def set_input_active(self, active: bool, resume_spinner: bool = True) -> None:
834
+ def set_input_active(self, active: 'bool', resume_spinner: 'bool' = True) -> 'None':
763
835
  self._input_active = active
764
836
  if active:
765
837
  self._spinner.pause()
766
838
  elif resume_spinner:
767
839
  self._spinner.resume()
768
840
 
769
- def is_streaming_output(self) -> bool:
841
+ def is_streaming_output(self) -> 'bool':
770
842
  return self._streaming
771
843
 
772
- def handoff_prompt_stream_to_output(self) -> None:
844
+ def handoff_prompt_stream_to_output(self) -> 'None':
773
845
  if not self._streaming or not self._streaming_in_prompt:
774
846
  return
775
847
  buffered = self._prompt_stream_buffer
@@ -782,7 +854,7 @@ class CliSessionView:
782
854
  self._raw_write(buffered)
783
855
  self._raw_flush()
784
856
 
785
- async def poll_prompt(self, prompt: str) -> str | None:
857
+ async def poll_prompt(self, prompt: 'str') -> 'typing.Union[str, None]':
786
858
  if self._prompt_task is None:
787
859
  if self.is_streaming_output():
788
860
  return None
@@ -807,7 +879,8 @@ class CliSessionView:
807
879
  finally:
808
880
  self.set_input_active(False, resume_spinner=False)
809
881
 
810
- def build_input_prompt(self, prompt: str) -> str:
882
+ def build_input_prompt(self, prompt: 'str') -> 'str':
883
+ prompt = self._format_main_prompt(prompt)
811
884
  if not self._input_active:
812
885
  return prompt
813
886
  if self._streaming and self._streaming_in_prompt:
@@ -819,7 +892,30 @@ class CliSessionView:
819
892
  return prompt
820
893
  return f"{prompt_line}\n{prompt}"
821
894
 
822
- def show_steer_queued(self, turn_id: str, prompt: str) -> None:
895
+ def _update_context_window(self, usage: 'object') -> 'None':
896
+ if self._context_window_tokens is None:
897
+ return
898
+ if not isinstance(usage, dict):
899
+ self._context_remaining_percent = None
900
+ return
901
+ try:
902
+ total_tokens = int(usage["total_tokens"])
903
+ except (KeyError, TypeError, ValueError):
904
+ self._context_remaining_percent = None
905
+ return
906
+ self._context_remaining_percent = percent_of_context_window_remaining(
907
+ total_tokens,
908
+ self._context_window_tokens,
909
+ )
910
+
911
+ def _format_main_prompt(self, prompt: 'str') -> 'str':
912
+ if prompt != DEFAULT_MAIN_PROMPT:
913
+ return prompt
914
+ if self._context_remaining_percent is None:
915
+ return prompt
916
+ return f"pyco({self._context_remaining_percent}%)> "
917
+
918
+ def show_steer_queued(self, turn_id: 'str', prompt: 'str') -> 'None':
823
919
  preview = shorten_title(prompt, limit=72)
824
920
  self._queued_steer_prompts.setdefault(turn_id, []).append(preview)
825
921
  self._print_line(
@@ -830,12 +926,12 @@ class CliSessionView:
830
926
  )
831
927
  )
832
928
 
833
- def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
929
+ def schedule_steer_inserted(self, turn_id: 'str', prompt: 'str') -> 'None':
834
930
  self._inserted_steer_prompts.setdefault(turn_id, []).append(
835
931
  shorten_title(prompt, limit=72)
836
932
  )
837
933
 
838
- def close(self) -> None:
934
+ def close(self) -> 'None':
839
935
  if self._prompt_task is not None and not self._prompt_task.done():
840
936
  self._prompt_task.cancel()
841
937
  self._prompt_task = None
@@ -843,24 +939,39 @@ class CliSessionView:
843
939
  if self._stdout_proxy is not None:
844
940
  self._stdout_proxy.close()
845
941
 
846
- def finish_stream(self) -> None:
942
+ def set_context_window_tokens(
943
+ self,
944
+ context_window_tokens: 'typing.Union[int, None]',
945
+ ) -> 'None':
946
+ self._context_window_tokens = context_window_tokens
947
+ self._context_remaining_percent = (
948
+ 100 if context_window_tokens is not None else None
949
+ )
950
+
951
+ def finish_stream(self) -> 'None':
847
952
  self._finish_stream()
848
953
 
849
- def write_line(self, text: str) -> None:
954
+ def write_line(self, text: 'str') -> 'None':
850
955
  self._print_line(text)
851
956
 
852
- def show_error(self, text: str) -> None:
957
+ def show_error(self, text: 'str') -> 'None':
853
958
  self._spinner.finish_turn()
854
959
  self._finish_stream()
960
+ lines = str(text).splitlines() or [""]
961
+ formatted = [f"Error: {lines[0]}"]
962
+ formatted.extend(
963
+ f" {line}" if line else ""
964
+ for line in lines[1:]
965
+ )
855
966
  self._print_line(
856
967
  colorize_cli_message(
857
- f"Error: {text}",
968
+ "\n".join(formatted),
858
969
  "error",
859
970
  self._color_enabled,
860
971
  )
861
972
  )
862
973
 
863
- def _finish_stream(self) -> None:
974
+ def _finish_stream(self) -> 'None':
864
975
  with self._terminal_lock:
865
976
  self._spinner.clear()
866
977
  if self._streaming:
@@ -872,9 +983,9 @@ class CliSessionView:
872
983
 
873
984
  def _finalize_turn_output(
874
985
  self,
875
- final_text: str,
876
- allow_standalone_output: bool,
877
- ) -> None:
986
+ final_text: 'str',
987
+ allow_standalone_output: 'bool',
988
+ ) -> 'None':
878
989
  self._spinner.finish_turn()
879
990
  if self._streaming and self._streaming_in_prompt:
880
991
  streamed_text = self._prompt_stream_buffer
@@ -903,7 +1014,7 @@ class CliSessionView:
903
1014
  )
904
1015
  )
905
1016
 
906
- def _colorize_formatted_tool_message(self, message: str) -> str:
1017
+ def _colorize_formatted_tool_message(self, message: 'str') -> 'str':
907
1018
  if message.startswith("[plan]"):
908
1019
  return colorize_cli_message(message, "plan", self._color_enabled)
909
1020
  if message.startswith("[exec]"):
@@ -916,12 +1027,15 @@ class CliSessionView:
916
1027
  return colorize_cli_message(message, "error", self._color_enabled)
917
1028
  return colorize_cli_message(message, "tool", self._color_enabled)
918
1029
 
919
- def _print_line(self, text: str) -> None:
1030
+ def _print_line(self, text: 'str') -> 'None':
920
1031
  with self._terminal_lock:
921
1032
  self._spinner.clear()
922
1033
  self._line_output(text)
923
1034
 
924
- def _remember_agent_name(self, tool_name: str, summary: str) -> None:
1035
+ def _print_user_turn(self, text: 'str') -> 'None':
1036
+ self._print_line(f"user> {text}")
1037
+
1038
+ def _remember_agent_name(self, tool_name: 'str', summary: 'str') -> 'None':
925
1039
  if tool_name != "spawn_agent":
926
1040
  return
927
1041
  if " (" not in summary or not summary.endswith(")"):
@@ -933,7 +1047,7 @@ class CliSessionView:
933
1047
  return
934
1048
  self._agent_names[agent_short_id] = nickname
935
1049
 
936
- def _rewrite_agent_summary(self, tool_name: str, summary: str) -> str:
1050
+ def _rewrite_agent_summary(self, tool_name: 'str', summary: 'str') -> 'str':
937
1051
  if tool_name not in {"wait_agent", "send_input", "resume_agent", "close_agent"}:
938
1052
  return summary
939
1053
  rewritten = summary
@@ -945,7 +1059,7 @@ class CliSessionView:
945
1059
  rewritten = rewritten.replace(agent_short_id, nickname)
946
1060
  return rewritten
947
1061
 
948
- async def prompt_async(self, prompt: str) -> str:
1062
+ async def prompt_async(self, prompt: 'str') -> 'str':
949
1063
  if self._prompt_session is None:
950
1064
  self._prompt_session = PromptSession(
951
1065
  erase_when_done=True,
@@ -966,7 +1080,7 @@ class CliSessionView:
966
1080
  finally:
967
1081
  self.set_input_active(False, resume_spinner=False)
968
1082
 
969
- async def _handoff_prompt_task_to_output(self) -> None:
1083
+ async def _handoff_prompt_task_to_output(self) -> 'None':
970
1084
  if self._prompt_task is None:
971
1085
  return
972
1086
  prompt_task = self._prompt_task