pcode 0.1.4__tar.gz → 0.1.5__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 (172) hide show
  1. {pcode-0.1.4 → pcode-0.1.5}/PKG-INFO +1 -1
  2. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/aside_ui.py +15 -5
  3. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/messages.py +5 -6
  4. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/web.py +52 -17
  5. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/config_ui.py +1 -1
  6. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/controller.py +33 -2
  7. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edit_transcript.py +76 -9
  8. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edit_ui.py +3 -0
  9. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/ext.py +3 -1
  10. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extension_guide.md +2 -2
  11. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/plan_mode.py +35 -15
  12. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/web_research.py +30 -6
  13. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/inspector_ui.py +9 -3
  14. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/jobs_ui.py +7 -1
  15. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/popup_ui.py +70 -15
  16. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/preferences.py +10 -4
  17. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/session_ui.py +15 -2
  18. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/stream_events.py +3 -2
  19. pcode-0.1.5/src/pcode/text_selection.py +365 -0
  20. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tool_display.py +55 -16
  21. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tree_ui.py +7 -1
  22. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/worker_ui.py +9 -1
  23. {pcode-0.1.4 → pcode-0.1.5}/.gitignore +0 -0
  24. {pcode-0.1.4 → pcode-0.1.5}/LICENSE +0 -0
  25. {pcode-0.1.4 → pcode-0.1.5}/README.md +0 -0
  26. {pcode-0.1.4 → pcode-0.1.5}/pyproject.toml +0 -0
  27. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/__init__.py +0 -0
  28. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/_job_supervisor.py +0 -0
  29. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/agent.py +0 -0
  30. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/anthropic_oauth.py +0 -0
  31. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/app.py +0 -0
  32. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/appearance.py +0 -0
  33. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/aside.py +0 -0
  34. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/aside_guard.py +0 -0
  35. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/auth.py +0 -0
  36. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/background_delegation.py +0 -0
  37. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/block.py +0 -0
  38. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/browser.py +0 -0
  39. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cache_diagnostics.py +0 -0
  40. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cache_settings.py +0 -0
  41. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cache_warnings.py +0 -0
  42. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/__init__.py +0 -0
  43. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/cli.py +0 -0
  44. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/errors.py +0 -0
  45. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/model.py +0 -0
  46. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/resume.py +0 -0
  47. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/session.py +0 -0
  48. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/session_pool.py +0 -0
  49. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/workspace.py +0 -0
  50. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cli.py +0 -0
  51. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/clipboard.py +0 -0
  52. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/code_mode.py +0 -0
  53. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/codex_login.py +0 -0
  54. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/command_transcript.py +0 -0
  55. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/commands.py +0 -0
  56. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/compaction.py +0 -0
  57. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/completion.py +0 -0
  58. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/config.py +0 -0
  59. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/context_breakdown.py +0 -0
  60. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/context_usage.py +0 -0
  61. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/conversation_ids.py +0 -0
  62. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/conversation_tree.py +0 -0
  63. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/copy_ui.py +0 -0
  64. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/delegation.py +0 -0
  65. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/delta.py +0 -0
  66. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/diagnostics.py +0 -0
  67. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edit_preview.py +0 -0
  68. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edits.py +0 -0
  69. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/__init__.py +0 -0
  70. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/cli.py +0 -0
  71. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/listener.py +0 -0
  72. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/mailbox.py +0 -0
  73. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/outbound.py +0 -0
  74. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/parsing.py +0 -0
  75. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/render.py +0 -0
  76. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/state.py +0 -0
  77. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/error_report.py +0 -0
  78. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/browser.py +0 -0
  79. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/ponytail.py +0 -0
  80. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/sandbox.py +0 -0
  81. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/session_history.py +0 -0
  82. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/file_refs.py +0 -0
  83. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/filesystem.py +0 -0
  84. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/frame.py +0 -0
  85. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/gateway.py +0 -0
  86. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/git_diff.py +0 -0
  87. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/history.py +0 -0
  88. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/history_cursor.py +0 -0
  89. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/history_embeddings.py +0 -0
  90. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/host.py +0 -0
  91. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/host_protocol.py +0 -0
  92. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/host_ui.py +0 -0
  93. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/input_keys.py +0 -0
  94. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/inspection.py +0 -0
  95. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/isolated_delegation.py +0 -0
  96. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/job_notices.py +0 -0
  97. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/jobs.py +0 -0
  98. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/keyboard_protocol.py +0 -0
  99. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/keymap.py +0 -0
  100. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/layout_speed.py +0 -0
  101. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/links.py +0 -0
  102. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/links_ui.py +0 -0
  103. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/live.py +0 -0
  104. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/llm_proxy.py +0 -0
  105. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/logins.py +0 -0
  106. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/mcp.py +0 -0
  107. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/mcp_notice.py +0 -0
  108. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/mcp_oauth.py +0 -0
  109. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian.py +0 -0
  110. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian_process.py +0 -0
  111. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian_reminders.py +0 -0
  112. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian_setup.py +0 -0
  113. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/model_metadata.py +0 -0
  114. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/model_ui.py +0 -0
  115. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/models.py +0 -0
  116. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/native_results.py +0 -0
  117. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/oauth_pages.py +0 -0
  118. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/output_limits.py +0 -0
  119. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/paste.py +0 -0
  120. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/path_completion.py +0 -0
  121. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/plan_preview.py +0 -0
  122. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/plan_sizes.py +0 -0
  123. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/planning.py +0 -0
  124. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/prefix_keys.py +0 -0
  125. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/proctitle.py +0 -0
  126. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/profile_benchmark.py +0 -0
  127. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/profiling.py +0 -0
  128. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/project_trust.py +0 -0
  129. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/prompt_keys.py +0 -0
  130. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/remote.py +0 -0
  131. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/remote_print.py +0 -0
  132. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/remote_profile.py +0 -0
  133. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/replay_benchmark.py +0 -0
  134. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/repo_context.py +0 -0
  135. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/retries.py +0 -0
  136. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/rpc.py +0 -0
  137. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/runtime.py +0 -0
  138. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/sandbox.py +0 -0
  139. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/session_naming.py +0 -0
  140. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/sessions.py +0 -0
  141. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/shell.py +0 -0
  142. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/shell_mode.py +0 -0
  143. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/shell_tools.py +0 -0
  144. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/skills.py +0 -0
  145. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/stall_log.py +0 -0
  146. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/startup_input.py +0 -0
  147. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/steering.py +0 -0
  148. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/stream_display.py +0 -0
  149. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/strict_tools.py +0 -0
  150. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/syntax.py +0 -0
  151. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/syntax_colors.py +0 -0
  152. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/task_prompt.py +0 -0
  153. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/task_worktrees.py +0 -0
  154. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/terminal_notify.py +0 -0
  155. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/terminal_text.py +0 -0
  156. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/theme.py +0 -0
  157. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/theme_gallery.py +0 -0
  158. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/thinking_markdown.py +0 -0
  159. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/token_accounting.py +0 -0
  160. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tool_output_limits.py +0 -0
  161. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tool_panel.py +0 -0
  162. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/transcript_log.py +0 -0
  163. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/transcript_notice.py +0 -0
  164. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/turn.py +0 -0
  165. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/ui.py +0 -0
  166. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/update_check.py +0 -0
  167. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/usage.py +0 -0
  168. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/word_wrap.py +0 -0
  169. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/workers.py +0 -0
  170. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/workspace.py +0 -0
  171. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/workspace_filesystem.py +0 -0
  172. {pcode-0.1.4 → pcode-0.1.5}/src/pcode/worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pcode
3
- Version: 0.1.4
3
+ Version: 0.1.5
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/
@@ -51,6 +51,7 @@ from pcode.popup_ui import (
51
51
  from pcode.prefix_keys import PrefixKeys
52
52
  from pcode.session_ui import literal
53
53
  from pcode.task_prompt import TaskPrompt
54
+ from pcode.text_selection import copy_drags
54
55
  from pcode.tool_display import plain
55
56
  from pcode.ui import StatusLine, status_parts, status_spinner
56
57
 
@@ -164,15 +165,22 @@ class AsideBrowser:
164
165
  self._refreshing = False
165
166
  # Enter on the list reads the selected thread full width; Esc brings
166
167
  # the list back. A viewer opened on a lone thread starts reading, so
167
- # one that arrives meanwhile does not pull the list in beside it.
168
- self.reading = len(self.threads) <= 1
168
+ # one that arrives meanwhile does not pull the list in beside it. One
169
+ # opened to type in (`edit`) starts reading the newest thread too, with
170
+ # the cursor in its editor; Esc shows the list.
171
+ self.reading = len(self.threads) <= 1 or self.edit
169
172
  self._ids: list[str] = []
170
173
  self.notice = ""
171
174
  # The copy or link picker over the viewer, while one is open.
172
175
  self.picker: CopyPicker | LinkPicker | None = None
173
176
  self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
177
+ copy_drags(self.list, lambda text: setattr(self, "notice", text))
174
178
  self.list.window.cursorline = Always()
175
- self.detail = RichPane(theme=rich_theme, color_system=color_system)
179
+ self.detail = RichPane(
180
+ theme=rich_theme,
181
+ color_system=color_system,
182
+ notify=lambda text: setattr(self, "notice", text),
183
+ )
176
184
  self.list.buffer.on_cursor_position_changed += lambda _: self.select()
177
185
  self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
178
186
  shortcuts.set_help(self.help)
@@ -243,7 +251,7 @@ class AsideBrowser:
243
251
  submit=lambda text: self.finish(Bridge(thread, "summary", text)),
244
252
  )
245
253
 
246
- @shortcuts.add("t", "Merge to /tree", filter=idle)
254
+ @shortcuts.add("t", "Merge into conversation", filter=idle)
247
255
  def merge(event):
248
256
  if self.bridgeable():
249
257
  self.finish(Bridge(self.selected, "merge"))
@@ -447,7 +455,9 @@ class AsideBrowser:
447
455
  lambda focus: bridge("summary", focus),
448
456
  argument="[focus]",
449
457
  ),
450
- PopupCommand("/merge", "Merge to /tree", lambda _: bridge("merge", "")),
458
+ PopupCommand(
459
+ "/merge", "Merge into the conversation", lambda _: bridge("merge", "")
460
+ ),
451
461
  ]
452
462
  commands.append(PopupCommand("/stop", "Stop running answers", lambda _: self.stop()))
453
463
  return commands
@@ -106,15 +106,14 @@ def _render_block(block: dict) -> str:
106
106
  def _web_result_text(content: Any) -> str:
107
107
  """A web search's hits or a fetched page, as `web.result_block` shaped them."""
108
108
  if isinstance(content, list):
109
- return "\n".join(
110
- f"{item.get('title') or item.get('url')}: {item.get('url')}"
111
- for item in content
112
- if isinstance(item, dict)
113
- )
109
+ hits = [item for item in content if isinstance(item, dict)]
110
+ lines = [f"{h.get('title') or h['url']}: {h['url']}" for h in hits if h.get("url")]
111
+ summary = next((hit["summary"] for hit in hits if hit.get("summary")), "")
112
+ return "\n\n".join(filter(None, ("\n".join(lines), summary)))
114
113
  if not isinstance(content, dict):
115
114
  return ""
116
115
  if "error_code" in content:
117
- return f"(error: {content['error_code']})"
116
+ return f"(error: {content.get('message') or content['error_code']})"
118
117
  document = content.get("content")
119
118
  source = document.get("source") if isinstance(document, dict) else None
120
119
  return str(source.get("data", "")) if isinstance(source, dict) else ""
@@ -8,10 +8,20 @@ The blocks are pcode's record only; the model reads the CLI's own transcript.
8
8
  They are tagged with the `claude` provider, so no other model is ever sent
9
9
  them, and they are used just for display and for a fresh replay
10
10
  (`messages.replay`).
11
+
12
+ What the CLI returns has no slot in Anthropic's shapes, so it rides in extra
13
+ fields, which Pydantic AI keeps: a search's summary on its first hit
14
+ (`summary`), and a failure's full text beside its code (`message`). Pydantic
15
+ AI also serializes these blocks against the SDK types, so `error_code` must
16
+ stay one of Anthropic's codes or every result logs a serializer warning.
11
17
  """
12
18
 
13
19
  import json
20
+ import logging
14
21
  import re
22
+ from datetime import datetime, timezone
23
+
24
+ logger = logging.getLogger(__name__)
15
25
 
16
26
  # CLI tool name -> Anthropic server tool name (also the native tool's `kind`).
17
27
  CLI_WEB_TOOLS = {"WebSearch": "web_search", "WebFetch": "web_fetch"}
@@ -19,6 +29,9 @@ CLI_WEB_TOOLS = {"WebSearch": "web_search", "WebFetch": "web_fetch"}
19
29
  # The CLI's search result opens with its query, then this JSON list of hits,
20
30
  # then a summary its own sub-request wrote.
21
31
  _LINKS = re.compile(r"^Links: (\[.*\])$", re.MULTILINE)
32
+ _QUERY = re.compile(r"\AWeb search results for query: .*$", re.MULTILINE)
33
+ # The CLI's closing instruction to its model, not part of the summary.
34
+ _REMINDER = re.compile(r"\n*REMINDER: You MUST include the sources.*\Z", re.DOTALL)
22
35
 
23
36
 
24
37
  def cli_tool_names(native_tools) -> tuple[str, ...]:
@@ -40,24 +53,44 @@ def _text(content: object) -> str:
40
53
  return ""
41
54
 
42
55
 
56
+ def _hit(title: str, url: str) -> dict:
57
+ return {
58
+ "type": "web_search_result",
59
+ "title": title,
60
+ "url": url,
61
+ # Pydantic AI's result model requires both; the CLI has neither.
62
+ "encrypted_content": "",
63
+ "page_age": None,
64
+ }
65
+
66
+
43
67
  def _hits(text: str) -> list[dict]:
68
+ """The hits in the CLI's search text, the first carrying its summary.
69
+
70
+ Text with no parseable `Links:` line means the CLI changed its format (an
71
+ empty search still prints `Links: []`), so that is logged rather than
72
+ shown as a quiet "0 results"; the text is kept on a URL-less hit.
73
+ """
44
74
  match = _LINKS.search(text)
45
75
  try:
46
- links = json.loads(match.group(1)) if match else []
76
+ links = json.loads(match.group(1)) if match else None
47
77
  except ValueError:
48
- links = []
49
- return [
50
- {
51
- "type": "web_search_result",
52
- "title": str(link.get("title") or ""),
53
- "url": str(link["url"]),
54
- # Pydantic AI's result model requires both; the CLI has neither.
55
- "encrypted_content": "",
56
- "page_age": None,
57
- }
58
- for link in links
78
+ links = None
79
+ if links is None:
80
+ logger.warning("Claude Code's WebSearch result has no parseable Links line: %.200r", text)
81
+ summary = _QUERY.sub("", text)
82
+ else:
83
+ summary = text[match.end() :] if match else ""
84
+ summary = _REMINDER.sub("", summary).strip()
85
+ hits = [
86
+ _hit(str(link.get("title") or ""), str(link["url"]))
87
+ for link in links or []
59
88
  if isinstance(link, dict) and link.get("url")
60
89
  ]
90
+ if summary:
91
+ hits = hits or [_hit("", "")]
92
+ hits[0]["summary"] = summary
93
+ return hits
61
94
 
62
95
 
63
96
  def result_block(name: str, tool_use_id: str, args: dict, content: object, is_error: bool) -> dict:
@@ -65,17 +98,19 @@ def result_block(name: str, tool_use_id: str, args: dict, content: object, is_er
65
98
  server = CLI_WEB_TOOLS[name]
66
99
  text = _text(content)
67
100
  if is_error:
68
- # Anthropic's error block holds a code only; the CLI's message is more
69
- # use to whoever reads the failure, so it travels in that field.
70
- first = text.strip().splitlines()[0] if text.strip() else "unavailable"
71
- body: object = {"type": f"{server}_tool_result_error", "error_code": first[:200]}
101
+ body: object = {
102
+ "type": f"{server}_tool_result_error",
103
+ "error_code": "unavailable",
104
+ "message": text.strip(),
105
+ }
72
106
  elif server == "web_search":
73
107
  body = _hits(text)
74
108
  else:
75
109
  body = {
76
110
  "type": "web_fetch_result",
77
111
  "url": str(args.get("url") or ""),
78
- "retrieved_at": None,
112
+ # When the CLI handed the answer over, which is when it fetched.
113
+ "retrieved_at": datetime.now(timezone.utc).isoformat(),
79
114
  "content": {
80
115
  "type": "document",
81
116
  "source": {"type": "text", "media_type": "text/plain", "data": text},
@@ -86,7 +86,7 @@ class ConfigBrowser:
86
86
  ),
87
87
  always_hide_cursor=True,
88
88
  )
89
- self.detail = RichPane()
89
+ self.detail = RichPane(notify=lambda text: setattr(self, "message", text))
90
90
  self._detail_text = None
91
91
  keys = KeyBindings()
92
92
  self.detail.bind_scrolling(keys)
@@ -516,6 +516,10 @@ class SessionController:
516
516
  # under a system row, a skill's prompt, an extension reload, a sign-in.
517
517
  self.job_requested: tuple[str, str, Callable[[], list[str]]] | None = None
518
518
  self.skill_requested: str | None = None
519
+ # An extension command's prompt waits for its own turn: steered into a
520
+ # running one, it would miss what the command set up (plan mode's
521
+ # reminder only joins a turn's opening request).
522
+ self.skill_queued = False
519
523
  self.skill_mcp_requested: tuple[str, tuple[str, ...]] | None = None
520
524
  self.reload_requested = False
521
525
  self.reload_pending = False # Asked for mid-turn; `consume` runs it between turns.
@@ -1204,9 +1208,11 @@ class SessionController:
1204
1208
  # for it on mcp_idle.
1205
1209
  self.start_skill_mcp(skill, names)
1206
1210
  prompt, self.skill_requested = self.skill_requested, None
1211
+ queued, self.skill_queued = self.skill_queued, False
1207
1212
  # Queued like a typed message so send mode, steering, and
1208
1213
  # cancellation keep their usual meaning.
1209
- self.prompts.put(prompt, load_preferences().get("send_mode", "steering"))
1214
+ mode = "queue" if queued else load_preferences().get("send_mode", "steering")
1215
+ self.prompts.put(prompt, mode)
1210
1216
  self.activity.busy = True
1211
1217
  if self.mcp_defaults_requested:
1212
1218
  self.start_mcp_defaults()
@@ -2254,10 +2260,35 @@ class SessionController:
2254
2260
  f"Extension {extension.name}: {', '.join(taken)} already exists; skipped."
2255
2261
  )
2256
2262
  continue
2257
- self.registry.register(command)
2263
+ self.registry.register(replace(command, handler=self.prompting(command)))
2258
2264
  self.extension_command_names.append(command.name)
2259
2265
  self.view.commands_changed()
2260
2266
 
2267
+ def prompting(self, command):
2268
+ """Send the text an extension command returns as a prompt, like a skill.
2269
+
2270
+ A sync handler stays sync, so callers that never await still run it whole.
2271
+ """
2272
+
2273
+ def request(result) -> None:
2274
+ if isinstance(result, str) and result.strip():
2275
+ if not self.model:
2276
+ raise ValueError(f"{command.name} requires a live model session.")
2277
+ self.skill_requested = result
2278
+ self.skill_queued = True
2279
+
2280
+ def run(argument: str):
2281
+ result = command.handler(argument)
2282
+ if not inspect.isawaitable(result):
2283
+ return request(result)
2284
+
2285
+ async def finish() -> None:
2286
+ request(await result)
2287
+
2288
+ return finish()
2289
+
2290
+ return run
2291
+
2261
2292
  def extension_arguments(self) -> tuple[str, ...]:
2262
2293
  """Complete `on`/`off` against the extensions this workspace discovered."""
2263
2294
  found = self.extensions.extensions if self.extensions else ()
@@ -5,10 +5,12 @@ from collections.abc import Callable
5
5
  from dataclasses import dataclass
6
6
  from functools import cache
7
7
 
8
+ from markdown_it import MarkdownIt
8
9
  from prompt_toolkit.formatted_text import ANSI, to_formatted_text
9
10
  from prompt_toolkit.lexers import Lexer
10
11
  from pygments.token import Generic, Token
11
12
  from rich.console import Console, ConsoleOptions, RenderResult
13
+ from rich.markdown import Markdown
12
14
  from rich.segment import Segment
13
15
  from rich.style import Style
14
16
  from rich.syntax import Syntax
@@ -19,6 +21,7 @@ from pcode.delta import Delta, preview_patch
19
21
  from pcode.edits import edit_text, patch_text
20
22
  from pcode.runtime import EditCompleted
21
23
  from pcode.syntax import transparent_theme
24
+ from pcode.terminal_text import safe_text
22
25
  from pcode.tool_display import command_text
23
26
 
24
27
 
@@ -40,29 +43,93 @@ class EditTranscript:
40
43
  )
41
44
  )
42
45
  if change.patch:
43
- text = patch_text(change.patch, dedent=self.dedent)
44
- lines = self.delta.render(text, options.max_width) if self.delta else None
45
- patch = (
46
- Text("\n").join(lines)
47
- if lines is not None
48
- else Syntax(text, "diff", theme=transparent_theme(self.code_theme), word_wrap=True)
49
- )
46
+ if (source := created_markdown(change)) is not None:
47
+ # Link targets print beside their text: a file's URLs are part
48
+ # of what was written, not something to hide in a hyperlink.
49
+ patch = Markdown(safe_text(source), code_theme=self.code_theme, hyperlinks=False)
50
+ omitted = "… rest of file omitted"
51
+ else:
52
+ omitted = "… additional diff rows omitted"
53
+ text = patch_text(change.patch, dedent=self.dedent)
54
+ lines = self.delta.render(text, options.max_width) if self.delta else None
55
+ patch = (
56
+ Text("\n").join(lines)
57
+ if lines is not None
58
+ else Syntax(
59
+ text, "diff", theme=transparent_theme(self.code_theme), word_wrap=True
60
+ )
61
+ )
50
62
  rows = console.render_lines(patch, options, pad=False)
51
63
  for row in rows[: self.max_rows]:
52
64
  yield from row
53
65
  yield Segment.line()
54
66
  if len(rows) > self.max_rows or change.truncated:
55
- yield Text("… additional diff rows omitted", style="pcode.muted")
67
+ yield Text(omitted, style="pcode.muted")
56
68
  if change.omitted:
57
69
  yield Text(f"Diff unavailable: {edit_text(change.omitted)}", style="pcode.muted")
58
70
  yield block_rule()
59
71
 
60
72
 
73
+ MARKDOWN_SUFFIXES = (".md", ".markdown")
74
+
75
+
76
+ def created_markdown(change: EditCompleted) -> str | None:
77
+ """A new Markdown file's redacted source, to show rendered rather than as a diff.
78
+
79
+ Every line of a created file is an addition, so the `+` column says
80
+ nothing and the raw source buries the document. The patch is the only
81
+ copy of the content the transcript keeps; each `+` line after the hunk
82
+ header is a line of the file; a truncated patch's last one may be cut
83
+ short, so it is dropped. None keeps the diff, including for a document
84
+ whose rendering would hide some of what was written (see `_hides_text`).
85
+ """
86
+ if (
87
+ change.operation != "created"
88
+ or change.removed
89
+ or not change.patch
90
+ or not change.path.lower().endswith(MARKDOWN_SUFFIXES)
91
+ ):
92
+ return None
93
+ lines = patch_text(change.patch, dedent=False).split("\n")
94
+ start = next((i for i, line in enumerate(lines) if line.startswith("@@")), len(lines))
95
+ body = [line[1:] for line in lines[start + 1 :] if line.startswith("+")]
96
+ if change.truncated:
97
+ body = body[:-1]
98
+ source = "\n".join(body)
99
+ return None if _hides_text(source) else source
100
+
101
+
102
+ _MARKDOWN = MarkdownIt("commonmark").enable(["strikethrough", "table"])
103
+
104
+
105
+ def _hides_text(source: str) -> bool:
106
+ """Whether rendering drops written text: HTML, comments, or link definitions.
107
+
108
+ Rich skips HTML blocks and inline tags and never shows a reference
109
+ definition, so an instruction in an `<!-- -->` comment would vanish from
110
+ the record of the write. Such a file shows as its diff instead.
111
+ """
112
+ env: dict = {}
113
+ tokens = _MARKDOWN.parse(source, env)
114
+ if env.get("references"):
115
+ return True
116
+ return any(
117
+ token.type == "html_block"
118
+ or any(child.type == "html_inline" for child in token.children or ())
119
+ for token in tokens
120
+ )
121
+
122
+
61
123
  def prefetch_edits(objects, width: int) -> None:
62
124
  """Render every delta edit block in `objects` through one delta process each."""
63
125
  patches: dict[Delta, list[str]] = {}
64
126
  for obj in objects:
65
- if isinstance(obj, EditTranscript) and obj.delta is not None and obj.change.patch:
127
+ if (
128
+ isinstance(obj, EditTranscript)
129
+ and obj.delta is not None
130
+ and obj.change.patch
131
+ and created_markdown(obj.change) is None
132
+ ):
66
133
  text = patch_text(obj.change.patch, dedent=obj.dedent)
67
134
  patches.setdefault(obj.delta, []).append(text)
68
135
  for delta, texts in patches.items():
@@ -44,6 +44,7 @@ from pcode.popup_ui import (
44
44
  )
45
45
  from pcode.prefix_keys import PrefixKeys
46
46
  from pcode.runtime import EditCompleted
47
+ from pcode.text_selection import copy_drags
47
48
 
48
49
  NO_MATCH = "No matching files."
49
50
  PROMPTS = {"paths": "Search paths: ", "diffs": "Search diff lines: "}
@@ -398,9 +399,11 @@ class DiffBrowser:
398
399
  self.query = TextArea(height=1, prompt=lambda: PROMPTS[self.scope], multiline=False)
399
400
  self.note = TextArea(height=1, prompt=self.note_prompt, multiline=False)
400
401
  self.files = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
402
+ copy_drags(self.files, lambda text: setattr(self, "notice", text))
401
403
  self.files.window.cursorline = Always()
402
404
  self.lexer = DiffLexer(code_theme)
403
405
  self.diff = TextArea(read_only=True, wrap_lines=False, scrollbar=True, lexer=self.lexer)
406
+ copy_drags(self.diff, lambda text: setattr(self, "notice", text))
404
407
  self.diff.window.cursorline = Always()
405
408
  self.query.buffer.on_text_changed += lambda _: self.refresh(first_match=True)
406
409
  self.files.buffer.on_cursor_position_changed += lambda _: self.follow_files()
@@ -245,7 +245,7 @@ class ExtensionAPI:
245
245
  self,
246
246
  name: str,
247
247
  description: str,
248
- handler: Callable[[str], None],
248
+ handler: Callable[[str], str | None],
249
249
  *,
250
250
  arguments: tuple[str, ...] = (),
251
251
  aliases: tuple[str, ...] = (),
@@ -255,6 +255,8 @@ class ExtensionAPI:
255
255
  ) -> None:
256
256
  """Add a slash command. `handler(argument)` runs on the terminal's event loop.
257
257
 
258
+ A handler that returns text sends it to the model as a prompt.
259
+
258
260
  Without `arguments`, any text is accepted; with them, only those values
259
261
  unless `free_arguments=True` keeps them as completions for free text.
260
262
  `argument_descriptions` labels each in the completion menu.
@@ -153,8 +153,8 @@ Pass `complete_paths=True` to Tab-complete the argument (after any leading
153
153
 
154
154
  Handlers run on the terminal's event loop, so keep them quick. Raise
155
155
  `ValueError("message")` to show a usage error. A name pcode already uses is
156
- reported and skipped. Commands cannot send a prompt to the model; to do that,
157
- write a skill (`SKILL.md`) instead.
156
+ reported and skipped. A handler that returns a string sends it to the model as
157
+ a prompt of its own turn, after any running one; return `None` to send nothing.
158
158
 
159
159
  ### Sub-agents
160
160
 
@@ -1,10 +1,13 @@
1
1
  """Plan mode: think a change through with the user before building it.
2
2
 
3
- `/plan [topic]` turns it on. While it is on, each user turn carries a reminder
4
- to investigate, keep a plan in `.pcode/plans/<slug>.md`, and hold off on
5
- implementing until the user approves. Approval is the model's call: when the
6
- user says "go ahead" (in whatever words), it calls `exit_plan_mode` and starts
7
- building in the same turn. `/plan off` is the manual way out.
3
+ `/plan [request]` turns it on and sends the request, if any, as the first
4
+ prompt (its opening words also name the plan file). While it is on, each user
5
+ turn carries a reminder to investigate, keep a plan in
6
+ `.pcode/plans/<slug>.md`, and hold off on implementing until the user approves.
7
+ Approval is the model's call: when the user says "go ahead" (in whatever
8
+ words), or answers the last open questions without raising new ones, it calls
9
+ `exit_plan_mode` and starts building in the same turn.
10
+ `/plan off` is the manual way out.
8
11
 
9
12
  The model is trusted rather than fenced in: nothing blocks edits, the reminder
10
13
  just says what the user wants. The active plan lives in a marker file beside
@@ -30,9 +33,13 @@ Plan mode is on. The user wants to agree on a plan before any implementation.
30
33
  - Do not start implementing. A small throwaway experiment to answer a question
31
34
  is fine; making the change itself is not.
32
35
  - End each turn with the open questions or a clear "ready to proceed?".
33
- - When the user approves (for example "go ahead", "do it", "lgtm", "ship it"),
34
- call `exit_plan_mode`, then implement the plan in that same turn. Approval
35
- with changes ("yes, but skip step 3") counts: fold the change in, then exit.
36
+ - When the user approves, call `exit_plan_mode`, then implement the plan in
37
+ that same turn. Approval can be explicit ("go ahead", "lgtm", "ship it") or
38
+ implicit: if the user's reply answers every remaining open question and
39
+ raises no new concerns, that is the go-ahead; don't ask again for a
40
+ separate confirmation. Approval with changes ("yes, but skip step 3")
41
+ counts too: fold the change in, then exit. Stay in plan mode only while
42
+ something is genuinely unresolved or the user asks to keep planning.
36
43
  </plan-mode>"""
37
44
 
38
45
 
@@ -43,7 +50,15 @@ def is_worker(ctx) -> bool:
43
50
 
44
51
  def slugify(text: str) -> str:
45
52
  slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
46
- return slug[:60].rstrip("-") or "plan"
53
+ if len(slug) > 60: # Cut at a word boundary when there is one.
54
+ cut = slug[:61]
55
+ slug = cut.rsplit("-", 1)[0] if "-" in cut else slug[:60]
56
+ return slug or "plan"
57
+
58
+
59
+ def topic_slug(request: str) -> str:
60
+ """Name the plan after the request's opening words."""
61
+ return slugify(" ".join(request.split("\n", 1)[0].split()[:8]))
47
62
 
48
63
 
49
64
  def setup(pcode) -> None:
@@ -74,7 +89,7 @@ def setup(pcode) -> None:
74
89
  ignore.write_text("*\n")
75
90
  marker.write_text(slug + "\n")
76
91
 
77
- def plan_command(argument: str) -> None:
92
+ def plan_command(argument: str) -> str | None:
78
93
  argument = argument.strip()
79
94
  current = active()
80
95
  if argument == "off":
@@ -89,21 +104,25 @@ def setup(pcode) -> None:
89
104
  f"Plan mode on: {relative(current)}" if current else "Plan mode is off."
90
105
  )
91
106
  return
92
- slug = slugify(argument) if argument else (current or latest() or "plan")
107
+ slug = topic_slug(argument) if argument else (current or latest() or "plan")
93
108
  start(slug)
94
109
  exists = (pcode.workspace / relative(slug)).exists()
95
110
  verb = "Resuming" if exists else "Planning in"
111
+ ask = "Say" if argument else "Describe the change; say"
96
112
  pcode.ui.notify(
97
- f"Plan mode on. {verb} {relative(slug)}. Describe the change; "
98
- "say 'go ahead' when the plan looks right."
113
+ f"Plan mode on. {verb} {relative(slug)}. {ask} 'go ahead' "
114
+ "(or just answer its questions) when the plan looks right."
99
115
  )
116
+ # The text is the request itself, so it goes to the model now rather
117
+ # than making the user send it again.
118
+ return argument or None
100
119
 
101
120
  pcode.register_command(
102
121
  "/plan",
103
122
  "Plan before implementing; the model exits when you approve",
104
123
  plan_command,
105
124
  arguments=("off", "status"),
106
- free_arguments=True, # anything else names the plan's topic
125
+ free_arguments=True, # anything else is the request to plan
107
126
  argument_descriptions={
108
127
  "off": "Leave plan mode (the plan file is kept)",
109
128
  "status": "Show whether plan mode is on and its file",
@@ -114,7 +133,8 @@ def setup(pcode) -> None:
114
133
  def exit_plan_mode() -> str:
115
134
  """Leave plan mode once the user has approved the plan, then implement it.
116
135
 
117
- Call this only when plan mode is on and the user has said to go ahead.
136
+ Call this only when plan mode is on and the user has approved, either by
137
+ saying so or by answering every open question without raising new ones.
118
138
  """
119
139
  current = active()
120
140
  if current is None:
@@ -9,8 +9,10 @@ plain HTTP fetch otherwise.
9
9
 
10
10
  The `web_search` preference picks the policy: `auto` (native when supported),
11
11
  `local` (never advertise native tools), or `off`. Under `auto`, `web_fetch`
12
- set to `local` keeps fetching on the local tool, whose page text is verbatim
13
- (the `claude:` CLI's fetch answers with a summary). Copy this file to
12
+ picks the fetch: `auto` uses a native fetch only where it returns the page
13
+ text, so `claude:` models read pages through `get_page` (the CLI's WebFetch
14
+ answers with a small model's summary instead); `native` opts into that summary
15
+ anyway, and `local` always fetches locally. Copy this file to
14
16
  `~/.config/pcode/extensions/web_research.py` to replace the defaults; an empty
15
17
  `setup` removes web tools entirely.
16
18
  """
@@ -18,7 +20,14 @@ set to `local` keeps fetching on the local tool, whose page text is verbatim
18
20
  import os
19
21
 
20
22
  SEARCH_RESULTS = 5
21
- PAGE_CHARS = 10_000
23
+ # Long enough for most documentation pages. With the default `spill` tool
24
+ # output mode, a page past the threshold is saved to disk and read back in
25
+ # slices, so the cap bounds the fetch, not what lands in context.
26
+ PAGE_CHARS = 100_000
27
+ # Harness's ceiling for Exa page text (`EXA_MAX_PAGE_TEXT_CHARS`, which it takes
28
+ # for Exa's limit; Exa's API reference documents 1,000,000), less the character
29
+ # of headroom it needs to notice a longer page and mark it truncated.
30
+ EXA_PAGE_CHARS = min(PAGE_CHARS, 9_999)
22
31
 
23
32
  INSTRUCTIONS = (
24
33
  "You can research the web: search for pages, then fetch the most promising "
@@ -43,7 +52,7 @@ def _exa_tools():
43
52
  exa = ExaSearchToolset(
44
53
  client=None, # Harness builds the client from EXA_API_KEY; the model never sees it.
45
54
  num_results=SEARCH_RESULTS,
46
- max_text_chars=PAGE_CHARS,
55
+ max_text_chars=EXA_PAGE_CHARS,
47
56
  include_deep_search=False,
48
57
  )
49
58
  return Tool(exa.web_search, name="web_search"), Tool(exa.get_page, name="get_page")
@@ -75,6 +84,22 @@ def _local_tools():
75
84
  return Tool(web_search, name="web_search"), fetch
76
85
 
77
86
 
87
+ def _native_fetch(native: bool):
88
+ """The `native` argument for page fetching, per the `web_fetch` preference."""
89
+ fetch = _mode("web_fetch")
90
+ if not native or fetch == "local":
91
+ return False
92
+ if fetch == "native":
93
+ return True
94
+ from pydantic_ai.native_tools import WebFetchTool
95
+
96
+ def page_text_only(ctx):
97
+ # Omitting the native tool leaves `get_page` in the request instead.
98
+ return None if ctx.model.system == "claude" else WebFetchTool()
99
+
100
+ return page_text_only
101
+
102
+
78
103
  def setup(pcode) -> None:
79
104
  mode = _mode()
80
105
  if mode == "off":
@@ -88,6 +113,5 @@ def setup(pcode) -> None:
88
113
  native = mode == "auto"
89
114
  # Ids name the prompt sources in /status; they never reach the model.
90
115
  pcode.add_capability(WebSearch(id="web_research", native=native, local=search))
91
- native_fetch = native and _mode("web_fetch") == "auto"
92
- pcode.add_capability(WebFetch(id="web_fetch", native=native_fetch, local=fetch))
116
+ pcode.add_capability(WebFetch(id="web_fetch", native=_native_fetch(native), local=fetch))
93
117
  pcode.instructions(INSTRUCTIONS)