pcode 0.1.7__tar.gz → 0.1.8__tar.gz

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 (177) hide show
  1. {pcode-0.1.7 → pcode-0.1.8}/PKG-INFO +5 -5
  2. {pcode-0.1.7 → pcode-0.1.8}/README.md +1 -1
  3. {pcode-0.1.7 → pcode-0.1.8}/pyproject.toml +3 -3
  4. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/agent.py +11 -7
  5. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/app.py +28 -12
  6. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/errors.py +1 -1
  7. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/compaction.py +6 -39
  8. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/config.py +2 -1
  9. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/conversation_tree.py +8 -6
  10. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/copy_ui.py +99 -35
  11. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/edits.py +4 -2
  12. pcode-0.1.8/src/pcode/meridian_reminders.py +48 -0
  13. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/popup_ui.py +65 -4
  14. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/preferences.py +6 -0
  15. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/prompt_keys.py +25 -0
  16. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/stall_log.py +8 -3
  17. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/steering.py +2 -2
  18. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/theme.py +21 -2
  19. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/tool_panel.py +1 -1
  20. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/tree_ui.py +181 -49
  21. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/ui.py +209 -36
  22. pcode-0.1.7/src/pcode/meridian_reminders.py +0 -120
  23. {pcode-0.1.7 → pcode-0.1.8}/.gitignore +0 -0
  24. {pcode-0.1.7 → pcode-0.1.8}/LICENSE +0 -0
  25. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/__init__.py +0 -0
  26. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/_job_supervisor.py +0 -0
  27. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/anthropic_oauth.py +0 -0
  28. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/appearance.py +0 -0
  29. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/aside.py +0 -0
  30. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/aside_guard.py +0 -0
  31. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/aside_ui.py +0 -0
  32. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/auth.py +0 -0
  33. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/background_delegation.py +0 -0
  34. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/block.py +0 -0
  35. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/browser.py +0 -0
  36. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/cache_diagnostics.py +0 -0
  37. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/cache_settings.py +0 -0
  38. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/cache_warnings.py +0 -0
  39. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/__init__.py +0 -0
  40. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/cli.py +0 -0
  41. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/messages.py +0 -0
  42. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/model.py +0 -0
  43. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/resume.py +0 -0
  44. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/session.py +0 -0
  45. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/session_pool.py +0 -0
  46. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/web.py +0 -0
  47. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/claude_sdk/workspace.py +0 -0
  48. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/cli.py +0 -0
  49. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/clipboard.py +0 -0
  50. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/code_mode.py +0 -0
  51. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/codex_login.py +0 -0
  52. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/command_transcript.py +0 -0
  53. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/commands.py +0 -0
  54. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/completion.py +0 -0
  55. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/config_ui.py +0 -0
  56. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/context_breakdown.py +0 -0
  57. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/context_usage.py +0 -0
  58. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/controller.py +0 -0
  59. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/conversation_ids.py +0 -0
  60. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/delegation.py +0 -0
  61. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/delta.py +0 -0
  62. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/diagnostics.py +0 -0
  63. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/edit_preview.py +0 -0
  64. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/edit_transcript.py +0 -0
  65. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/edit_ui.py +0 -0
  66. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/__init__.py +0 -0
  67. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/cli.py +0 -0
  68. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/listener.py +0 -0
  69. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/mailbox.py +0 -0
  70. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/outbound.py +0 -0
  71. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/parsing.py +0 -0
  72. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/render.py +0 -0
  73. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/email_remote/state.py +0 -0
  74. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/error_report.py +0 -0
  75. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/ext.py +0 -0
  76. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extension_guide.md +0 -0
  77. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Brief format.md +0 -0
  78. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Briefs.base +0 -0
  79. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Home.md +0 -0
  80. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/basic_memory/__init__.py +0 -0
  81. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/basic_memory/memory-states.css +0 -0
  82. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/browser.py +0 -0
  83. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/plan_mode.py +0 -0
  84. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/ponytail.py +0 -0
  85. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/sandbox.py +0 -0
  86. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/session_history.py +0 -0
  87. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/extensions/web_research.py +0 -0
  88. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/file_refs.py +0 -0
  89. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/filesystem.py +0 -0
  90. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/frame.py +0 -0
  91. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/gateway.py +0 -0
  92. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/git_diff.py +0 -0
  93. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/history.py +0 -0
  94. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/history_cursor.py +0 -0
  95. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/history_embeddings.py +0 -0
  96. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/host.py +0 -0
  97. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/host_protocol.py +0 -0
  98. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/host_ui.py +0 -0
  99. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/input_keys.py +0 -0
  100. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/inspection.py +0 -0
  101. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/inspector_ui.py +0 -0
  102. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/isolated_delegation.py +0 -0
  103. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/job_notices.py +0 -0
  104. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/jobs.py +0 -0
  105. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/jobs_ui.py +0 -0
  106. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/keyboard_protocol.py +0 -0
  107. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/keymap.py +0 -0
  108. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/layout_speed.py +0 -0
  109. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/links.py +0 -0
  110. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/links_ui.py +0 -0
  111. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/live.py +0 -0
  112. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/llm_proxy.py +0 -0
  113. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/logins.py +0 -0
  114. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/mcp.py +0 -0
  115. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/mcp_notice.py +0 -0
  116. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/mcp_oauth.py +0 -0
  117. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/meridian.py +0 -0
  118. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/meridian_process.py +0 -0
  119. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/meridian_setup.py +0 -0
  120. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/model_metadata.py +0 -0
  121. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/model_ui.py +0 -0
  122. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/models.py +0 -0
  123. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/native_results.py +0 -0
  124. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/oauth_pages.py +0 -0
  125. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/output_limits.py +0 -0
  126. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/path_completion.py +0 -0
  127. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/plan_preview.py +0 -0
  128. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/plan_sizes.py +0 -0
  129. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/planning.py +0 -0
  130. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/prefix_keys.py +0 -0
  131. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/proctitle.py +0 -0
  132. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/profile_benchmark.py +0 -0
  133. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/profiling.py +0 -0
  134. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/project_trust.py +0 -0
  135. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/remote.py +0 -0
  136. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/remote_print.py +0 -0
  137. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/remote_profile.py +0 -0
  138. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/replay_benchmark.py +0 -0
  139. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/repo_context.py +0 -0
  140. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/retries.py +0 -0
  141. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/rpc.py +0 -0
  142. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/runtime.py +0 -0
  143. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/sandbox.py +0 -0
  144. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/session_naming.py +0 -0
  145. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/session_ui.py +0 -0
  146. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/sessions.py +0 -0
  147. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/shell.py +0 -0
  148. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/shell_mode.py +0 -0
  149. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/shell_tools.py +0 -0
  150. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/skills.py +0 -0
  151. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/startup_input.py +0 -0
  152. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/stream_display.py +0 -0
  153. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/stream_events.py +0 -0
  154. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/strict_tools.py +0 -0
  155. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/syntax.py +0 -0
  156. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/syntax_colors.py +0 -0
  157. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/task_prompt.py +0 -0
  158. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/task_worktrees.py +0 -0
  159. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/terminal_notify.py +0 -0
  160. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/terminal_text.py +0 -0
  161. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/text_selection.py +0 -0
  162. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/theme_gallery.py +0 -0
  163. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/thinking_markdown.py +0 -0
  164. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/token_accounting.py +0 -0
  165. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/tool_display.py +0 -0
  166. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/tool_output_limits.py +0 -0
  167. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/transcript_log.py +0 -0
  168. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/transcript_notice.py +0 -0
  169. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/turn.py +0 -0
  170. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/update_check.py +0 -0
  171. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/usage.py +0 -0
  172. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/word_wrap.py +0 -0
  173. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/worker_ui.py +0 -0
  174. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/workers.py +0 -0
  175. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/workspace.py +0 -0
  176. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/workspace_filesystem.py +0 -0
  177. {pcode-0.1.7 → pcode-0.1.8}/src/pcode/worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pcode
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Terminal-native AI coding agent with background jobs, parallel worktrees, forkable sessions, and multi-model support
5
5
  Project-URL: Homepage, https://github.com/cruxwell/pcode
6
6
  Project-URL: Documentation, https://cruxwell.github.io/pcode/
@@ -25,13 +25,13 @@ Requires-Dist: genai-prices<0.2,>=0.1.7
25
25
  Requires-Dist: httpx2<3,>=2.13.1
26
26
  Requires-Dist: prompt-toolkit==3.0.53
27
27
  Requires-Dist: psutil<8,>=6
28
- Requires-Dist: pydantic-ai-harness[codemode,coder,exa,playwright]<0.56,>=0.55.0
29
- Requires-Dist: pydantic-ai-slim[anthropic,bedrock,duckduckgo,google,groq,mcp,openai,web-fetch,xai]<3,>=2.55.0
28
+ Requires-Dist: pydantic-ai-harness[codemode,coder,exa,playwright]<0.57,>=0.56.0
29
+ Requires-Dist: pydantic-ai-slim[anthropic,bedrock,duckduckgo,google,groq,mcp,openai,web-fetch,xai]<3,>=2.56.0
30
30
  Requires-Dist: rich<16,>=15
31
31
  Requires-Dist: websockets<17,>=15.0.1
32
32
  Requires-Dist: yappi<2,>=1.7.6
33
33
  Provides-Extra: claude
34
- Requires-Dist: claude-agent-sdk<0.3,>=0.2.164; extra == 'claude'
34
+ Requires-Dist: claude-agent-sdk<0.3,>=0.2.167; extra == 'claude'
35
35
  Description-Content-Type: text/markdown
36
36
 
37
37
  <img width="1392" height="1522" alt="pcode in a dark terminal" src="docs/assets/screenshots/tabs-dark.png" />
@@ -76,7 +76,7 @@ brew install cruxwell/pcode/pcode
76
76
  Or from [PyPI](https://pypi.org/project/pcode/) with [uv](https://docs.astral.sh/uv/):
77
77
 
78
78
  ```sh
79
- uv tool install --python 3.14 'pcode[claude]'
79
+ uv tool install --python 3.15 'pcode[claude]'
80
80
  ```
81
81
 
82
82
  `brew install --HEAD` tracks `master` instead of the latest release.
@@ -40,7 +40,7 @@ brew install cruxwell/pcode/pcode
40
40
  Or from [PyPI](https://pypi.org/project/pcode/) with [uv](https://docs.astral.sh/uv/):
41
41
 
42
42
  ```sh
43
- uv tool install --python 3.14 'pcode[claude]'
43
+ uv tool install --python 3.15 'pcode[claude]'
44
44
  ```
45
45
 
46
46
  `brew install --HEAD` tracks `master` instead of the latest release.
@@ -33,11 +33,11 @@ dependencies = [
33
33
  # itself is downloaded on first use, not at install.
34
34
  # Harness ships from the pydantic-ai repo with every Pydantic AI release and
35
35
  # pins pydantic-ai-slim to the same minor; bump both together.
36
- "pydantic-ai-harness[exa,coder,codemode,playwright]>=0.55.0,<0.56",
36
+ "pydantic-ai-harness[exa,coder,codemode,playwright]>=0.56.0,<0.57",
37
37
  # duckduckgo and web-fetch back the bundled web_research extension when no
38
38
  # provider-native search or Exa key is available.
39
39
  # google covers both google and google-cloud providers; bedrock pulls boto3.
40
- "pydantic-ai-slim[anthropic,bedrock,duckduckgo,google,groq,mcp,openai,web-fetch,xai]>=2.55.0,<3",
40
+ "pydantic-ai-slim[anthropic,bedrock,duckduckgo,google,groq,mcp,openai,web-fetch,xai]>=2.56.0,<3",
41
41
  "rich>=15,<16",
42
42
  # FastMCP 4.x OAuth callback server selects Uvicorn's websockets-sansio protocol.
43
43
  # Capped below 17 by google-genai (still <17 as of 2.29), not by pcode.
@@ -49,7 +49,7 @@ dependencies = [
49
49
  # (about 95 MB to download, 215 MB installed); the sdist would run whatever
50
50
  # `claude` is on PATH instead of the CLI version pcode was verified against.
51
51
  claude = [
52
- "claude-agent-sdk>=0.2.164,<0.3",
52
+ "claude-agent-sdk>=0.2.167,<0.3",
53
53
  ]
54
54
 
55
55
  [project.urls]
@@ -9,6 +9,7 @@ from contextvars import ContextVar
9
9
  from copy import copy
10
10
  from dataclasses import dataclass, fields, replace
11
11
  from pathlib import Path
12
+ from typing import TYPE_CHECKING
12
13
 
13
14
  from pydantic_ai import Agent
14
15
  from pydantic_ai.capabilities import (
@@ -18,13 +19,11 @@ from pydantic_ai.capabilities import (
18
19
  LocalWorkspace,
19
20
  )
20
21
  from pydantic_ai.models import Model
21
- from pydantic_ai.models.openai_codex import OpenAICodexModel
22
22
  from pydantic_ai.profiles.openai import OpenAIModelProfile
23
- from pydantic_ai.providers.openai_codex import OpenAICodexProvider
24
23
  from pydantic_ai.toolsets import CombinedToolset
25
24
  from pydantic_ai.usage import UsageLimits
26
25
  from pydantic_ai_harness.coder import Coder
27
- from pydantic_ai_harness.compaction import ClearToolResults, WarnNearLimits
26
+ from pydantic_ai_harness.compaction import ClearToolResults
28
27
  from pydantic_ai_harness.filesystem import FileSystem
29
28
  from pydantic_ai_harness.repo_context import RepoContext
30
29
  from pydantic_ai_harness.shell import LLM_API_KEY_ENV_PATTERNS, Shell
@@ -43,11 +42,9 @@ from pcode.filesystem import DisplayFileSystem
43
42
  from pcode.isolated_delegation import WorkspaceSubAgents
44
43
  from pcode.job_notices import JobNotices
45
44
  from pcode.jobs import isolated_registry
46
- from pcode.llm_proxy import ProxiedCodexProvider
47
45
  from pcode.mcp import configured_servers
48
46
  from pcode.mcp_notice import MCPServers
49
47
  from pcode.meridian import MeridianSessionIdentity
50
- from pcode.meridian_reminders import MeridianLimitWarnings
51
48
  from pcode.output_limits import ModelOutputLimits
52
49
  from pcode.planning import IdentifiedPlanning
53
50
  from pcode.preferences import SETTINGS, load_preferences, subagent_models
@@ -57,6 +54,9 @@ from pcode.strict_tools import create_strict_tools
57
54
  from pcode.tool_output_limits import create_tool_output_limits
58
55
  from pcode.workspace import WorkspaceGuard
59
56
 
57
+ if TYPE_CHECKING:
58
+ from pydantic_ai.models.openai_codex import OpenAICodexModel
59
+
60
60
  # Harness gives a child its own usage counter only when its `SubAgent` carries
61
61
  # `usage_limits`; without one the child shares the parent's counter and silently
62
62
  # gets the library's 50-request default, which a busy session has already spent.
@@ -199,8 +199,6 @@ def _adapt(capability, workspace: Path, output_limits):
199
199
  # Replace Coder's 64k truncation, so it cannot cut data before spilling.
200
200
  case ToolOutputLimits():
201
201
  return output_limits
202
- case WarnNearLimits():
203
- return MeridianLimitWarnings(**_init_fields(capability))
204
202
  case Shell():
205
203
  return _job_shell(capability, workspace)
206
204
  case _:
@@ -446,7 +444,13 @@ def codex_model(model: str) -> OpenAICodexModel:
446
444
  Without a pcode store the provider reads the CLI file at construction time
447
445
  and keeps refreshed tokens in memory only.
448
446
  """
447
+ # Imported here, not at module level: `openai_codex` loads the whole OpenAI
448
+ # SDK (about 0.3 s), which a session on any other provider never uses.
449
+ from pydantic_ai.models.openai_codex import OpenAICodexModel
450
+ from pydantic_ai.providers.openai_codex import OpenAICodexProvider
451
+
449
452
  from pcode.codex_login import credential_source
453
+ from pcode.llm_proxy import ProxiedCodexProvider
450
454
 
451
455
  proxy = os.environ.get("PCODE_LLM_PROXY", "").strip()
452
456
  source = credential_source()
@@ -70,7 +70,7 @@ from pcode.shell_mode import shell_command
70
70
  from pcode.startup_input import StartupInput
71
71
  from pcode.stream_display import PrintedReply, present_events, present_stream_event
72
72
  from pcode.terminal_notify import TabProgress, TabTitle
73
- from pcode.theme import THEMES, replay_pending_input
73
+ from pcode.theme import THEMES, replay_pending_input, terminal_background
74
74
  from pcode.tool_display import plain
75
75
  from pcode.ui import (
76
76
  WATCHED_PREFIX,
@@ -155,6 +155,7 @@ class PreviewApp:
155
155
  attach_tasks=load_preferences().get("attach_tasks", SETTINGS["attach_tasks"].default)
156
156
  == "on",
157
157
  task_style=load_preferences().get("task_style", SETTINGS["task_style"].default),
158
+ frame_style=load_preferences().get("frame_style", SETTINGS["frame_style"].default),
158
159
  tool_glyphs=load_preferences().get("tool_glyphs", SETTINGS["tool_glyphs"].default),
159
160
  tool_max_rows=tool_rows_preference(),
160
161
  tool_linger_seconds=preference_count("tool_linger_seconds"),
@@ -769,6 +770,7 @@ class PreviewApp:
769
770
  self.activity.tasks_min_rows = preference_count("tasks_min_rows")
770
771
  self.activity.tasks_min_columns = preference_count("tasks_min_columns")
771
772
  self.activity.task_style = preferences.get("task_style", SETTINGS["task_style"].default)
773
+ self.activity.frame_style = preferences.get("frame_style", SETTINGS["frame_style"].default)
772
774
  self.activity.tool_glyphs = preferences.get("tool_glyphs", SETTINGS["tool_glyphs"].default)
773
775
  self.activity.tool_max_rows = tool_rows_preference()
774
776
  self.activity.tool_linger_seconds = preference_count("tool_linger_seconds")
@@ -1070,7 +1072,8 @@ class PreviewApp:
1070
1072
 
1071
1073
  async def choose_copy(self, output: TerminalOutput, session) -> None:
1072
1074
  from pcode.clipboard import copy as copy_to_clipboard
1073
- from pcode.copy_ui import answers, copy_dialog, snippets
1075
+ from pcode.copy_ui import answers, snippets
1076
+ from pcode.tree_ui import copy_dialog
1074
1077
 
1075
1078
  self.copy_requested = False
1076
1079
  tree = getattr(self.runtime, "tree", None)
@@ -1083,7 +1086,10 @@ class PreviewApp:
1083
1086
  if len(history) > 1 or len(choices) > 1:
1084
1087
  async with self.popup(output, session) as modal_input:
1085
1088
  dialog = copy_dialog(
1086
- history,
1089
+ tree,
1090
+ rich_theme=self.transcript.rich_theme,
1091
+ code_theme=self.transcript.code_theme,
1092
+ color_system=self.transcript.console.color_system,
1087
1093
  input=modal_input,
1088
1094
  output=session.app.output,
1089
1095
  style=session.app.style,
@@ -1248,14 +1254,22 @@ class PreviewApp:
1248
1254
  self.transcript.regenerate()
1249
1255
 
1250
1256
  def follow_appearance(self, detected: str) -> None:
1251
- """The terminal reported its background; repaint if `theme auto` now differs."""
1252
- if detected == self.transcript.detected_theme:
1253
- return
1254
- self.transcript.detected_theme = detected
1255
- if self.transcript.theme == "auto":
1256
- self.transcript.regenerate()
1257
- if self.prompt_session is not None:
1258
- self.prompt_session.app.invalidate() # The editor's style follows too.
1257
+ """The terminal reported its appearance; repaint what follows it.
1258
+
1259
+ Always invalidates the editor: shaded frames mix from the reported
1260
+ background even when the theme stays (another dark profile), or under
1261
+ a fixed `/theme`. A mode 2031 flip carries no color, so it asks for
1262
+ one; the OSC 11 answer records a matching theme, so this asks once.
1263
+ """
1264
+ reported = terminal_background()
1265
+ if self._appearance is not None and (reported is None or reported[1] != detected):
1266
+ self._appearance.refresh()
1267
+ if detected != self.transcript.detected_theme:
1268
+ self.transcript.detected_theme = detected
1269
+ if self.transcript.theme == "auto":
1270
+ self.transcript.regenerate()
1271
+ if self.prompt_session is not None:
1272
+ self.prompt_session.app.invalidate()
1259
1273
 
1260
1274
  def syntax(self, argument: str) -> None:
1261
1275
  """Choose the Pygments style for code, the completion menu and the prompt.
@@ -2342,7 +2356,9 @@ class PreviewApp:
2342
2356
  self.output = output
2343
2357
  self.prompt_session = session
2344
2358
  session.app.style = DynamicStyle(
2345
- lambda: self.transcript.prompt_style(self.activity.task_style)
2359
+ lambda: self.transcript.prompt_style(
2360
+ self.activity.task_style, self.activity.frame_style
2361
+ )
2346
2362
  )
2347
2363
  try:
2348
2364
  terminal = session.app.output.fileno()
@@ -5,7 +5,7 @@ from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError
5
5
  MISSING_SDK = (
6
6
  "claude: models need pcode's optional `claude` extra, which is not installed. "
7
7
  "From a pcode checkout run `make install`, "
8
- "or `uv tool install --python 3.14 --editable '.[claude]'`."
8
+ "or `uv tool install --python 3.15 --editable '.[claude]'`."
9
9
  )
10
10
 
11
11
 
@@ -14,9 +14,7 @@ from pydantic_ai.messages import (
14
14
  BinaryContent,
15
15
  FilePart,
16
16
  FileUrl,
17
- ModelRequest,
18
17
  ModelResponse,
19
- SystemPromptPart,
20
18
  ToolReturnPart,
21
19
  UploadedFile,
22
20
  UserPromptPart,
@@ -140,37 +138,6 @@ def context_estimate(messages, parameters=None) -> int:
140
138
  return estimate
141
139
 
142
140
 
143
- def newest_summary_only(messages) -> list:
144
- """Drop every compaction summary but the newest, which supersedes the rest.
145
-
146
- Harness keeps the first user request verbatim, and pydantic-ai merges the
147
- summary request into it on the next run, so without this each compaction
148
- re-keeps every earlier summary inside that "first user message". Harness
149
- also anchors its update on the *last* summary part it finds, which in that
150
- merged request is the oldest one. A new summary always opens the history,
151
- so the first summary part in order is the newest. Reported upstream as
152
- pydantic/pydantic-ai#9686; drop this once a Harness release fixes it.
153
- """
154
- seen = False
155
- kept = []
156
- for message in messages:
157
- if not isinstance(message, ModelRequest):
158
- kept.append(message)
159
- continue
160
- parts = []
161
- for part in message.parts:
162
- if isinstance(part, SystemPromptPart) and part.content.startswith(SUMMARY_PREFIX):
163
- if seen:
164
- continue
165
- seen = True
166
- parts.append(part)
167
- if len(parts) == len(message.parts):
168
- kept.append(message)
169
- elif parts:
170
- kept.append(replace(message, parts=parts))
171
- return kept
172
-
173
-
174
141
  def text_and_media(messages) -> tuple[int, int]:
175
142
  """The heuristic's token count of `messages` without media, and the media items held."""
176
143
  media = 0
@@ -230,9 +197,8 @@ async def summarize(messages, *, model, focus=None, usage=None, window=None, par
230
197
  # a compaction to ~244k whose next request was billed 67k.
231
198
  text, media = text_and_media(messages)
232
199
  density = max(1.0, (before - schemas - media * MEDIA_TOKENS) / text) if text else 1.0
233
- source = newest_summary_only(messages)
234
- cutoff = find_token_cutoff(source, keep)
235
- oversized_tail = estimate_token_count(source[cutoff:]) > keep
200
+ cutoff = find_token_cutoff(messages, keep)
201
+ oversized_tail = estimate_token_count(messages[cutoff:]) > keep
236
202
 
237
203
  def summarizer(tool_return_max_chars: int) -> SummarizingCompaction:
238
204
  return SummarizingCompaction(
@@ -271,10 +237,11 @@ async def summarize(messages, *, model, focus=None, usage=None, window=None, par
271
237
  *(tightened(tool_chars, part_chars) for tool_chars, part_chars in TIGHTENED_ATTEMPTS),
272
238
  ]
273
239
  )
274
- candidate = await compact_now(strategy, deepcopy(source), model=model, focus=focus, usage=usage)
275
- if candidate == source:
240
+ candidate = await compact_now(
241
+ strategy, deepcopy(messages), model=model, focus=focus, usage=usage
242
+ )
243
+ if candidate == messages:
276
244
  return CompactionResult(messages, before, before, False)
277
- candidate = newest_summary_only(candidate)
278
245
  if not is_provider_valid(candidate):
279
246
  raise CompactionError("Compaction produced invalid tool-call pairs; history unchanged.")
280
247
  # Harness returns text, not structured output: reject empty summaries explicitly.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Editing defaults deliberately does not mutate a running conversation. In-session
4
4
  shortcuts change both the active setting and its saved default; the terminal also
5
- applies layout-only settings (`attach_tasks`, `tasks_max_height`, `task_style`,
5
+ applies layout-only settings (`attach_tasks`, `tasks_max_height`, `task_style`, `frame_style`,
6
6
  `tool_glyphs`, `tool_max_lines`, `tool_linger_seconds` and `show_hints`) immediately.
7
7
 
8
8
  `config project ...` edits the workspace's `.pcode/preferences.json`, which is
@@ -38,6 +38,7 @@ IMMEDIATE_SETTINGS = frozenset(
38
38
  "tasks_min_rows",
39
39
  "tasks_min_columns",
40
40
  "task_style",
41
+ "frame_style",
41
42
  "tool_glyphs",
42
43
  "tool_max_lines",
43
44
  "tool_linger_seconds",
@@ -6,6 +6,14 @@ from dataclasses import dataclass, field
6
6
  from pcode.links import Link, extract_links, remember_link
7
7
 
8
8
 
9
+ def excerpt(text: str) -> str:
10
+ """One safe line of ``text`` for a list row."""
11
+ from pcode.diagnostics import strip_controls
12
+ from pcode.ui import plain
13
+
14
+ return plain(strip_controls(" ".join(text.split())), 100)
15
+
16
+
9
17
  @dataclass
10
18
  class TurnNode:
11
19
  id: str
@@ -113,12 +121,6 @@ class ConversationTree:
113
121
 
114
122
  def rows(self) -> list[tuple[tuple[str | None, bool], str]]:
115
123
  """Depth-first user/edit and assistant/continue rows, with stable IDs."""
116
- from pcode.diagnostics import strip_controls
117
- from pcode.ui import plain
118
-
119
- def excerpt(text):
120
- return plain(strip_controls(" ".join(text.split())), 100)
121
-
122
124
  rows = [
123
125
  ((None, False), "Conversation start" + (" ← active" if self.active is None else ""))
124
126
  ]
@@ -3,11 +3,12 @@
3
3
  The transcript draws a Markdown quote with a `▌` rail and hard-wraps it to the
4
4
  terminal, so selecting it with the mouse picks up both. The source still has
5
5
  the text as written: this pulls each top-level quote and fenced code block out
6
- of it for a small picker, shared by `/copy` and `/tree`.
6
+ of it, for `/copy`'s rows (see `tree_ui.CopyBrowser`, which `SnippetMarkdown`
7
+ highlights them for) and for the small pickers in `/tree` and `/btw`.
7
8
  """
8
9
 
9
10
  import re
10
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
11
12
 
12
13
  from markdown_it import MarkdownIt
13
14
  from prompt_toolkit.application import Application, get_app
@@ -18,11 +19,14 @@ from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
18
19
  from prompt_toolkit.layout import DynamicContainer, HSplit, Layout
19
20
  from prompt_toolkit.layout.dimension import Dimension
20
21
  from prompt_toolkit.widgets import Label, TextArea
22
+ from rich.console import Group
23
+ from rich.markdown import BlockQuote, CodeBlock, Markdown
21
24
 
22
25
  from pcode.frame import Dialog
23
26
  from pcode.popup_ui import (
24
27
  bind_list_paging,
25
28
  focus_overlay,
29
+ marked,
26
30
  popup_container,
27
31
  popup_mouse,
28
32
  popup_style,
@@ -62,9 +66,11 @@ def answers(tree) -> list[Answer]:
62
66
 
63
67
  @dataclass(frozen=True)
64
68
  class Snippet:
65
- kind: str # "response", "quote", or "code"
69
+ kind: str # "prompt", "response", "quote", or "code"
66
70
  text: str
67
71
  language: str = ""
72
+ # Source lines [start, end) of a quote or code block, for `SnippetMarkdown`.
73
+ lines: tuple[int, int] | None = field(default=None, compare=False)
68
74
 
69
75
  @property
70
76
  def label(self) -> str:
@@ -83,6 +89,43 @@ def _unquote(lines: list[str]) -> str:
83
89
  ).strip("\n")
84
90
 
85
91
 
92
+ _HARD_BREAK = re.compile(r"( |\\)$")
93
+
94
+
95
+ def _unwrap(markdown: str) -> str:
96
+ """``markdown`` with each paragraph's soft line breaks joined by spaces.
97
+
98
+ Markdown draws a lone newline inside a paragraph as a space, so a quote
99
+ copied as written would break where nothing on screen does. Hard breaks,
100
+ blank lines, code, and nested quotes keep their lines.
101
+ """
102
+ lines = markdown.splitlines()
103
+ depth = 0
104
+ joined: dict[int, int] = {} # paragraph's first line -> its end
105
+ for token in MarkdownIt("commonmark").parse(markdown):
106
+ if token.type == "blockquote_open":
107
+ depth += 1
108
+ elif token.type == "blockquote_close":
109
+ depth -= 1
110
+ elif token.type == "paragraph_open" and depth == 0 and token.map:
111
+ joined[token.map[0]] = token.map[1]
112
+ out: list[str] = []
113
+ index = 0
114
+ while index < len(lines):
115
+ end = joined.get(index, index + 1)
116
+ paragraph = lines[index:end]
117
+ line = paragraph[0]
118
+ for following in paragraph[1:]:
119
+ if _HARD_BREAK.search(line):
120
+ out.append(_HARD_BREAK.sub("", line).rstrip())
121
+ line = following
122
+ else:
123
+ line = f"{line.rstrip()} {following.lstrip()}"
124
+ out.append(line)
125
+ index = end
126
+ return "\n".join(out)
127
+
128
+
86
129
  def snippets(markdown: str) -> list[Snippet]:
87
130
  """The whole response, then each top-level quote and code block in order."""
88
131
  if not markdown.strip():
@@ -94,8 +137,13 @@ def snippets(markdown: str) -> list[Snippet]:
94
137
  if token.type == "blockquote_open":
95
138
  if depth == 0 and token.map:
96
139
  start, end = token.map
97
- if text := _unquote(lines[start:end]):
98
- found.append(Snippet("quote", text))
140
+ # Its first line can open inside a list item, e.g. "2. > text":
141
+ # with no enclosing quote, the first `>` is this quote's own.
142
+ quoted = lines[start:end]
143
+ if (marker := quoted[0].find(">")) > 0:
144
+ quoted[0] = quoted[0][marker:]
145
+ if text := _unwrap(_unquote(quoted)):
146
+ found.append(Snippet("quote", text, lines=(start, end)))
99
147
  depth += 1
100
148
  elif token.type == "blockquote_close":
101
149
  depth -= 1
@@ -103,10 +151,55 @@ def snippets(markdown: str) -> list[Snippet]:
103
151
  language = token.info.split()[0] if token.info.strip() else ""
104
152
  # No trailing newline: pasted into a shell, it would run the command.
105
153
  text = token.content.rstrip("\n")
106
- found.append(Snippet("code", text, language))
154
+ found.append(Snippet("code", text, language, tuple(token.map) if token.map else None))
107
155
  return found
108
156
 
109
157
 
158
+ class _Marked:
159
+ """A Markdown element tagged with its snippet's pane mark, when it has one."""
160
+
161
+ mark: str | None = None
162
+
163
+ @classmethod
164
+ def create(cls, markdown, token):
165
+ element = super().create(markdown, token)
166
+ element.mark = markdown.marks.get(tuple(token.map)) if token.map else None
167
+ return element
168
+
169
+ def __rich_console__(self, console, options):
170
+ rendered = super().__rich_console__(console, options)
171
+ if self.mark is None:
172
+ yield from rendered
173
+ else:
174
+ yield marked(Group(*rendered), self.mark)
175
+
176
+
177
+ class _MarkedCodeBlock(_Marked, CodeBlock):
178
+ pass
179
+
180
+
181
+ class _MarkedBlockQuote(_Marked, BlockQuote):
182
+ pass
183
+
184
+
185
+ class SnippetMarkdown(Markdown):
186
+ """Markdown whose quotes and code blocks carry their snippets' `marked` keys.
187
+
188
+ ``marks`` maps a snippet's source lines to its key. Rich parses the same
189
+ source as `snippets`, so a block's line map identifies the snippet.
190
+ """
191
+
192
+ elements = {
193
+ **Markdown.elements,
194
+ "fence": _MarkedCodeBlock,
195
+ "blockquote_open": _MarkedBlockQuote,
196
+ }
197
+
198
+ def __init__(self, markup: str, marks: dict[tuple[int, int], str], **options) -> None:
199
+ super().__init__(markup, **options)
200
+ self.marks = marks
201
+
202
+
110
203
  def last_response(tree) -> str:
111
204
  """The newest assistant text on the active branch, skipping compactions."""
112
205
  for identity in reversed(tree.path(tree.active)):
@@ -337,35 +430,6 @@ class CopyPicker:
337
430
  app.invalidate()
338
431
 
339
432
 
340
- def copy_dialog(choices: list[Answer], *, input=None, output=None, style=None):
341
- """One popup application whose result is the picked Snippet, or None."""
342
- app: Application
343
- shortcuts = PrefixKeys()
344
- picker = CopyPicker(
345
- choices,
346
- on_pick=lambda snippet: app.exit(result=snippet),
347
- on_cancel=lambda: app.exit(result=None),
348
- shortcuts=shortcuts,
349
- )
350
- shortcuts.set_help(picker.help)
351
- dialog = Dialog(
352
- title=lambda: picker.title,
353
- body=HSplit([picker.container, Label(shortcuts.summary)], padding=1),
354
- )
355
- app = Application(
356
- layout=Layout(
357
- popup_container(dialog, shortcuts), focused_element=picker.query or picker.list
358
- ),
359
- key_bindings=shortcuts.key_bindings(KeyBindings()),
360
- full_screen=True,
361
- mouse_support=popup_mouse(shortcuts),
362
- input=input,
363
- output=output,
364
- style=popup_style(style),
365
- )
366
- return app
367
-
368
-
369
433
  def snippet_dialog(choices: list[Snippet], *, input=None, output=None, style=None):
370
434
  """A popup application whose result is the picked snippet, or None."""
371
435
  app: Application
@@ -1,7 +1,6 @@
1
1
  """Bounded, sanitized evidence of completed file mutations, not workspace diffs."""
2
2
 
3
3
  import difflib
4
- import os
5
4
  import re
6
5
 
7
6
  from pcode.runtime import EditCompleted
@@ -72,7 +71,10 @@ def _dedent(lines: list[str], body: list[int]) -> None:
72
71
  indents = [c[: len(c) - len(c.lstrip())] for c in contents if c.strip()]
73
72
  if not indents:
74
73
  return
75
- shared = os.path.commonprefix(indents)
74
+ # The prefix the lexicographic extremes share is shared by every indent.
75
+ # (`os.path.commonprefix` did this, but Python 3.15 deprecates it.)
76
+ low, high = min(indents), max(indents)
77
+ shared = low[: next((i for i, (a, b) in enumerate(zip(low, high)) if a != b), len(low))]
76
78
  if not shared:
77
79
  return
78
80
  for i, content in zip(body, contents, strict=True):
@@ -0,0 +1,48 @@
1
+ """Durable, append-only reminders that keep a provider's cached prefix intact."""
2
+
3
+ from pydantic_ai.messages import ModelRequest, UserPromptPart
4
+
5
+
6
+ def last_reminder(messages, tag: str) -> str | None:
7
+ """Return the most recent reminder text for `tag`, or None when none was sent.
8
+
9
+ Detection is by content, not message metadata: Pydantic AI merges consecutive
10
+ `ModelRequest`s when history is resumed and keeps only its own reserved
11
+ metadata namespace, so a marker stored there survives a run but not a resume.
12
+ Only a `UserPromptPart` that *starts* with the tag counts, so a tool result or
13
+ quoted prompt that merely mentions it is not mistaken for a sent reminder.
14
+ """
15
+ for message in reversed(messages):
16
+ if not isinstance(message, ModelRequest):
17
+ continue
18
+ for part in reversed(message.parts):
19
+ content = getattr(part, "content", None)
20
+ if isinstance(part, UserPromptPart) and isinstance(content, str):
21
+ if content.startswith(tag):
22
+ return content
23
+ return None
24
+
25
+
26
+ def append_reminder(ctx, request_context, tag: str, text: str) -> None:
27
+ """Append `text` as durable history unless the last reminder already says it.
28
+
29
+ Appending, rather than moving a mutable tail, keeps previously sent messages
30
+ byte-identical so the provider's cached prefix stays reusable as the
31
+ conversation grows.
32
+ """
33
+ messages = request_context.messages
34
+ if not messages or not isinstance(messages[-1], ModelRequest):
35
+ return
36
+ previous = last_reminder(messages, tag)
37
+ if previous == text:
38
+ return
39
+ # Stamped here: the framework fills these in only after every before-hook
40
+ # has run, which is after the request checkpoint has copied the request.
41
+ reminder = ModelRequest(
42
+ parts=[UserPromptPart(content=text)],
43
+ run_id=ctx.run_id,
44
+ conversation_id=ctx.conversation_id,
45
+ )
46
+ # Both lists: the request is what gets sent, `ctx.messages` the run's history.
47
+ request_context.messages = [*messages, reminder]
48
+ ctx.messages.append(reminder)