pcode 0.1.6__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 (178) hide show
  1. {pcode-0.1.6 → pcode-0.1.8}/PKG-INFO +5 -5
  2. {pcode-0.1.6 → pcode-0.1.8}/README.md +1 -1
  3. {pcode-0.1.6 → pcode-0.1.8}/pyproject.toml +3 -3
  4. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/agent.py +11 -7
  5. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/app.py +58 -20
  6. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/appearance.py +10 -0
  7. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/errors.py +1 -1
  8. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/compaction.py +6 -39
  9. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/config.py +2 -1
  10. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/conversation_tree.py +8 -6
  11. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/copy_ui.py +99 -35
  12. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edits.py +4 -2
  13. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Brief format.md +2 -0
  14. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/__init__.py +95 -46
  15. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/plan_mode.py +129 -25
  16. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/file_refs.py +1 -4
  17. pcode-0.1.8/src/pcode/meridian_reminders.py +48 -0
  18. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/popup_ui.py +65 -4
  19. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/preferences.py +6 -0
  20. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/prompt_keys.py +30 -25
  21. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/stall_log.py +8 -3
  22. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/steering.py +2 -2
  23. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/theme.py +21 -2
  24. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tool_panel.py +1 -1
  25. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tree_ui.py +181 -49
  26. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/ui.py +210 -41
  27. pcode-0.1.6/src/pcode/meridian_reminders.py +0 -120
  28. pcode-0.1.6/src/pcode/paste.py +0 -45
  29. {pcode-0.1.6 → pcode-0.1.8}/.gitignore +0 -0
  30. {pcode-0.1.6 → pcode-0.1.8}/LICENSE +0 -0
  31. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/__init__.py +0 -0
  32. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/_job_supervisor.py +0 -0
  33. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/anthropic_oauth.py +0 -0
  34. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/aside.py +0 -0
  35. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/aside_guard.py +0 -0
  36. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/aside_ui.py +0 -0
  37. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/auth.py +0 -0
  38. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/background_delegation.py +0 -0
  39. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/block.py +0 -0
  40. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/browser.py +0 -0
  41. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cache_diagnostics.py +0 -0
  42. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cache_settings.py +0 -0
  43. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cache_warnings.py +0 -0
  44. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/__init__.py +0 -0
  45. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/cli.py +0 -0
  46. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/messages.py +0 -0
  47. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/model.py +0 -0
  48. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/resume.py +0 -0
  49. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/session.py +0 -0
  50. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/session_pool.py +0 -0
  51. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/web.py +0 -0
  52. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/workspace.py +0 -0
  53. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cli.py +0 -0
  54. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/clipboard.py +0 -0
  55. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/code_mode.py +0 -0
  56. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/codex_login.py +0 -0
  57. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/command_transcript.py +0 -0
  58. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/commands.py +0 -0
  59. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/completion.py +0 -0
  60. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/config_ui.py +0 -0
  61. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/context_breakdown.py +0 -0
  62. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/context_usage.py +0 -0
  63. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/controller.py +0 -0
  64. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/conversation_ids.py +0 -0
  65. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/delegation.py +0 -0
  66. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/delta.py +0 -0
  67. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/diagnostics.py +0 -0
  68. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edit_preview.py +0 -0
  69. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edit_transcript.py +0 -0
  70. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edit_ui.py +0 -0
  71. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/__init__.py +0 -0
  72. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/cli.py +0 -0
  73. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/listener.py +0 -0
  74. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/mailbox.py +0 -0
  75. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/outbound.py +0 -0
  76. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/parsing.py +0 -0
  77. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/render.py +0 -0
  78. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/state.py +0 -0
  79. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/error_report.py +0 -0
  80. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/ext.py +0 -0
  81. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extension_guide.md +0 -0
  82. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Briefs.base +0 -0
  83. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Home.md +0 -0
  84. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/memory-states.css +0 -0
  85. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/browser.py +0 -0
  86. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/ponytail.py +0 -0
  87. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/sandbox.py +0 -0
  88. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/session_history.py +0 -0
  89. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/web_research.py +0 -0
  90. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/filesystem.py +0 -0
  91. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/frame.py +0 -0
  92. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/gateway.py +0 -0
  93. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/git_diff.py +0 -0
  94. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/history.py +0 -0
  95. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/history_cursor.py +0 -0
  96. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/history_embeddings.py +0 -0
  97. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/host.py +0 -0
  98. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/host_protocol.py +0 -0
  99. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/host_ui.py +0 -0
  100. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/input_keys.py +0 -0
  101. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/inspection.py +0 -0
  102. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/inspector_ui.py +0 -0
  103. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/isolated_delegation.py +0 -0
  104. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/job_notices.py +0 -0
  105. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/jobs.py +0 -0
  106. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/jobs_ui.py +0 -0
  107. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/keyboard_protocol.py +0 -0
  108. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/keymap.py +0 -0
  109. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/layout_speed.py +0 -0
  110. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/links.py +0 -0
  111. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/links_ui.py +0 -0
  112. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/live.py +0 -0
  113. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/llm_proxy.py +0 -0
  114. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/logins.py +0 -0
  115. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/mcp.py +0 -0
  116. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/mcp_notice.py +0 -0
  117. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/mcp_oauth.py +0 -0
  118. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/meridian.py +0 -0
  119. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/meridian_process.py +0 -0
  120. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/meridian_setup.py +0 -0
  121. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/model_metadata.py +0 -0
  122. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/model_ui.py +0 -0
  123. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/models.py +0 -0
  124. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/native_results.py +0 -0
  125. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/oauth_pages.py +0 -0
  126. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/output_limits.py +0 -0
  127. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/path_completion.py +0 -0
  128. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/plan_preview.py +0 -0
  129. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/plan_sizes.py +0 -0
  130. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/planning.py +0 -0
  131. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/prefix_keys.py +0 -0
  132. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/proctitle.py +0 -0
  133. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/profile_benchmark.py +0 -0
  134. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/profiling.py +0 -0
  135. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/project_trust.py +0 -0
  136. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/remote.py +0 -0
  137. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/remote_print.py +0 -0
  138. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/remote_profile.py +0 -0
  139. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/replay_benchmark.py +0 -0
  140. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/repo_context.py +0 -0
  141. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/retries.py +0 -0
  142. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/rpc.py +0 -0
  143. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/runtime.py +0 -0
  144. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/sandbox.py +0 -0
  145. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/session_naming.py +0 -0
  146. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/session_ui.py +0 -0
  147. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/sessions.py +0 -0
  148. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/shell.py +0 -0
  149. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/shell_mode.py +0 -0
  150. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/shell_tools.py +0 -0
  151. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/skills.py +0 -0
  152. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/startup_input.py +0 -0
  153. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/stream_display.py +0 -0
  154. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/stream_events.py +0 -0
  155. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/strict_tools.py +0 -0
  156. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/syntax.py +0 -0
  157. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/syntax_colors.py +0 -0
  158. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/task_prompt.py +0 -0
  159. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/task_worktrees.py +0 -0
  160. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/terminal_notify.py +0 -0
  161. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/terminal_text.py +0 -0
  162. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/text_selection.py +0 -0
  163. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/theme_gallery.py +0 -0
  164. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/thinking_markdown.py +0 -0
  165. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/token_accounting.py +0 -0
  166. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tool_display.py +0 -0
  167. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tool_output_limits.py +0 -0
  168. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/transcript_log.py +0 -0
  169. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/transcript_notice.py +0 -0
  170. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/turn.py +0 -0
  171. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/update_check.py +0 -0
  172. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/usage.py +0 -0
  173. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/word_wrap.py +0 -0
  174. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/worker_ui.py +0 -0
  175. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/workers.py +0 -0
  176. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/workspace.py +0 -0
  177. {pcode-0.1.6 → pcode-0.1.8}/src/pcode/workspace_filesystem.py +0 -0
  178. {pcode-0.1.6 → 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.6
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,
@@ -1227,25 +1233,43 @@ class PreviewApp:
1227
1233
  self.transcript.syntax_gallery()
1228
1234
 
1229
1235
  def theme(self, argument: str) -> None:
1230
- self.transcript.theme = argument or (
1231
- "light" if self.transcript.resolved_theme == "dark" else "dark"
1232
- )
1233
- self.persist_defaults(theme=self.transcript.theme)
1234
- selected = self.transcript.theme
1235
- if selected == "auto":
1236
- selected += f" ({self.transcript.resolved_theme})"
1237
- self.transcript.flash(f"Theme: {selected}.")
1236
+ """Pick and save a palette; bare `/theme` flips it for this session only.
1237
+
1238
+ Saving the flip would pin every later session to it, and with it turn
1239
+ off following the terminal's light/dark switches.
1240
+ """
1241
+ theme = argument or ("light" if self.transcript.resolved_theme == "dark" else "dark")
1242
+ self.transcript.theme = theme
1243
+ if not argument:
1244
+ message = f"{theme} for this session; /theme {theme} saves it"
1245
+ else:
1246
+ self.persist_defaults(theme=theme)
1247
+ message = theme
1248
+ if theme == "auto":
1249
+ message += f" ({self.transcript.resolved_theme})"
1250
+ if self._appearance is not None:
1251
+ # The detected value may be stale; the answer repaints if it differs.
1252
+ self._appearance.refresh()
1253
+ self.transcript.flash(f"Theme: {message}.")
1238
1254
  self.transcript.regenerate()
1239
1255
 
1240
1256
  def follow_appearance(self, detected: str) -> None:
1241
- """The terminal reported its background; repaint if `theme auto` now differs."""
1242
- if detected == self.transcript.detected_theme:
1243
- return
1244
- self.transcript.detected_theme = detected
1245
- if self.transcript.theme == "auto":
1246
- self.transcript.regenerate()
1247
- if self.prompt_session is not None:
1248
- 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()
1249
1273
 
1250
1274
  def syntax(self, argument: str) -> None:
1251
1275
  """Choose the Pygments style for code, the completion menu and the prompt.
@@ -2332,7 +2356,9 @@ class PreviewApp:
2332
2356
  self.output = output
2333
2357
  self.prompt_session = session
2334
2358
  session.app.style = DynamicStyle(
2335
- 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
+ )
2336
2362
  )
2337
2363
  try:
2338
2364
  terminal = session.app.output.fileno()
@@ -2709,6 +2735,9 @@ def main() -> None:
2709
2735
  "hosts",
2710
2736
  )
2711
2737
  parser.add_argument("--hosts", action="store_true", help="List running session hosts and exit")
2738
+ parser.add_argument(
2739
+ "--json", action="store_true", help="With --hosts: one JSON array, for scripts and plugins"
2740
+ )
2712
2741
  host_kinds = ("all", "stale", "idle")
2713
2742
  parser.add_argument(
2714
2743
  "--kill-hosts",
@@ -3179,6 +3208,8 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
3179
3208
  parser.error("--fork cannot be combined with --attach")
3180
3209
  if args.compact and not args.sessions:
3181
3210
  parser.error("--compact applies to --sessions")
3211
+ if args.json and not args.hosts:
3212
+ parser.error("--json applies to --hosts")
3182
3213
  if args.print:
3183
3214
  if args.theme_preview or args.sessions:
3184
3215
  parser.error("--print cannot be combined with --theme-preview or --sessions")
@@ -3225,6 +3256,13 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
3225
3256
  if args.kill_hosts:
3226
3257
  _kill_hosts(args.kill_hosts, entries, code)
3227
3258
  return
3259
+ if args.json:
3260
+ import json
3261
+
3262
+ # A contract for callers such as the Obsidian bridge: add keys, never rename.
3263
+ keys = ("id", "session_id", "state", "workspace", "title", "last_prompt", "attached")
3264
+ print(json.dumps([{key: getattr(entry, key) for key in keys} for entry in entries]))
3265
+ return
3228
3266
  for entry in entries:
3229
3267
  print(f"{entry.id} {host_row(entry, None, code=code).strip()}")
3230
3268
  if not entries:
@@ -144,6 +144,13 @@ class AppearanceWatch:
144
144
  self.suspended = False
145
145
  if enabled:
146
146
  self.enabled = _write_all(self.fd, ENABLE_REPORTS)
147
+ # A switch while the other program had the terminal went unseen.
148
+ self.refresh()
149
+
150
+ def refresh(self) -> None:
151
+ """Ask for the background now; the answer arrives as input like any other."""
152
+ if self.enabled and not self.suspended:
153
+ _write_all(self.fd, QUERY_BACKGROUND)
147
154
 
148
155
  async def query(self) -> None:
149
156
  for delay in QUERY_DELAYS:
@@ -155,6 +162,9 @@ class AppearanceWatch:
155
162
  if self.fd is None or not _isatty(self.fd) or os.environ.get("TERM") == "dumb":
156
163
  return
157
164
  self.enabled = _write_all(self.fd, ENABLE_REPORTS)
165
+ # The startup probe is skipped when input is already waiting, leaving a
166
+ # guess from COLORFGBG that only a later switch would otherwise correct.
167
+ self.refresh()
158
168
  reader = self.reader if self.reader is not None else desktop_reader()
159
169
  pending = None
160
170
  try:
@@ -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):
@@ -52,6 +52,8 @@ A **brief** explains one project as it is now: what it is, where it's at, how it
52
52
  | `plans` | links to open plans | | |
53
53
  | `sources` | session IDs, plus code paths or commit ranges | same | |
54
54
 
55
+ Properties are YAML, and one bad value breaks the whole block: Obsidian then shows it as raw red text instead of properties. Wrap every free-text value (`scope`, `tldr`, `next` and each `sources` item) in single quotes, doubling any `'` inside: `tldr: 'Checks the org''s code: once per ticket'`. Unquoted, a `: ` anywhere or a leading `` ` ``, `#`, `*`, `[`, `{`, `&`, `!`, `|`, `>`, `%` or `@` breaks it.
56
+
55
57
  ## Sections
56
58
 
57
59
  | Section | Answers | Budget |