pcode 0.1.4__tar.gz → 0.1.6__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.4 → pcode-0.1.6}/PKG-INFO +1 -1
  2. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/app.py +12 -12
  3. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/aside_ui.py +16 -7
  4. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/messages.py +5 -6
  5. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/web.py +52 -17
  6. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/config_ui.py +1 -1
  7. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/controller.py +41 -10
  8. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/conversation_tree.py +2 -2
  9. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/delegation.py +2 -6
  10. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/diagnostics.py +11 -1
  11. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edit_preview.py +3 -7
  12. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edit_transcript.py +76 -9
  13. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edit_ui.py +4 -1
  14. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edits.py +4 -28
  15. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/ext.py +3 -1
  16. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extension_guide.md +2 -2
  17. pcode-0.1.6/src/pcode/extensions/basic_memory/Brief format.md +89 -0
  18. pcode-0.1.6/src/pcode/extensions/basic_memory/Briefs.base +50 -0
  19. pcode-0.1.6/src/pcode/extensions/basic_memory/Home.md +11 -0
  20. pcode-0.1.6/src/pcode/extensions/basic_memory/__init__.py +682 -0
  21. pcode-0.1.6/src/pcode/extensions/basic_memory/memory-states.css +58 -0
  22. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/plan_mode.py +74 -20
  23. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/web_research.py +30 -6
  24. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/filesystem.py +7 -14
  25. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/git_diff.py +12 -21
  26. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/inspection.py +2 -18
  27. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/inspector_ui.py +9 -3
  28. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/jobs_ui.py +7 -1
  29. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/popup_ui.py +70 -15
  30. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/preferences.py +10 -4
  31. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/session_ui.py +19 -6
  32. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/shell.py +5 -44
  33. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/stream_events.py +6 -10
  34. pcode-0.1.6/src/pcode/text_selection.py +365 -0
  35. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tool_display.py +63 -54
  36. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tool_output_limits.py +1 -2
  37. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tool_panel.py +1 -1
  38. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tree_ui.py +8 -2
  39. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/ui.py +1 -1
  40. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/worker_ui.py +9 -1
  41. {pcode-0.1.4 → pcode-0.1.6}/.gitignore +0 -0
  42. {pcode-0.1.4 → pcode-0.1.6}/LICENSE +0 -0
  43. {pcode-0.1.4 → pcode-0.1.6}/README.md +0 -0
  44. {pcode-0.1.4 → pcode-0.1.6}/pyproject.toml +0 -0
  45. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/__init__.py +0 -0
  46. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/_job_supervisor.py +0 -0
  47. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/agent.py +0 -0
  48. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/anthropic_oauth.py +0 -0
  49. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/appearance.py +0 -0
  50. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/aside.py +0 -0
  51. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/aside_guard.py +0 -0
  52. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/auth.py +0 -0
  53. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/background_delegation.py +0 -0
  54. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/block.py +0 -0
  55. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/browser.py +0 -0
  56. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cache_diagnostics.py +0 -0
  57. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cache_settings.py +0 -0
  58. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cache_warnings.py +0 -0
  59. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/__init__.py +0 -0
  60. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/cli.py +0 -0
  61. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/errors.py +0 -0
  62. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/model.py +0 -0
  63. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/resume.py +0 -0
  64. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/session.py +0 -0
  65. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/session_pool.py +0 -0
  66. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/workspace.py +0 -0
  67. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cli.py +0 -0
  68. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/clipboard.py +0 -0
  69. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/code_mode.py +0 -0
  70. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/codex_login.py +0 -0
  71. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/command_transcript.py +0 -0
  72. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/commands.py +0 -0
  73. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/compaction.py +0 -0
  74. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/completion.py +0 -0
  75. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/config.py +0 -0
  76. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/context_breakdown.py +0 -0
  77. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/context_usage.py +0 -0
  78. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/conversation_ids.py +0 -0
  79. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/copy_ui.py +0 -0
  80. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/delta.py +0 -0
  81. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/__init__.py +0 -0
  82. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/cli.py +0 -0
  83. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/listener.py +0 -0
  84. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/mailbox.py +0 -0
  85. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/outbound.py +0 -0
  86. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/parsing.py +0 -0
  87. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/render.py +0 -0
  88. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/state.py +0 -0
  89. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/error_report.py +0 -0
  90. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/browser.py +0 -0
  91. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/ponytail.py +0 -0
  92. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/sandbox.py +0 -0
  93. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/session_history.py +0 -0
  94. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/file_refs.py +0 -0
  95. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/frame.py +0 -0
  96. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/gateway.py +0 -0
  97. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/history.py +0 -0
  98. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/history_cursor.py +0 -0
  99. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/history_embeddings.py +0 -0
  100. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/host.py +0 -0
  101. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/host_protocol.py +0 -0
  102. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/host_ui.py +0 -0
  103. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/input_keys.py +0 -0
  104. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/isolated_delegation.py +0 -0
  105. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/job_notices.py +0 -0
  106. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/jobs.py +0 -0
  107. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/keyboard_protocol.py +0 -0
  108. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/keymap.py +0 -0
  109. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/layout_speed.py +0 -0
  110. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/links.py +0 -0
  111. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/links_ui.py +0 -0
  112. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/live.py +0 -0
  113. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/llm_proxy.py +0 -0
  114. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/logins.py +0 -0
  115. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/mcp.py +0 -0
  116. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/mcp_notice.py +0 -0
  117. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/mcp_oauth.py +0 -0
  118. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian.py +0 -0
  119. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian_process.py +0 -0
  120. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian_reminders.py +0 -0
  121. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian_setup.py +0 -0
  122. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/model_metadata.py +0 -0
  123. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/model_ui.py +0 -0
  124. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/models.py +0 -0
  125. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/native_results.py +0 -0
  126. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/oauth_pages.py +0 -0
  127. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/output_limits.py +0 -0
  128. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/paste.py +0 -0
  129. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/path_completion.py +0 -0
  130. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/plan_preview.py +0 -0
  131. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/plan_sizes.py +0 -0
  132. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/planning.py +0 -0
  133. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/prefix_keys.py +0 -0
  134. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/proctitle.py +0 -0
  135. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/profile_benchmark.py +0 -0
  136. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/profiling.py +0 -0
  137. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/project_trust.py +0 -0
  138. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/prompt_keys.py +0 -0
  139. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/remote.py +0 -0
  140. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/remote_print.py +0 -0
  141. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/remote_profile.py +0 -0
  142. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/replay_benchmark.py +0 -0
  143. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/repo_context.py +0 -0
  144. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/retries.py +0 -0
  145. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/rpc.py +0 -0
  146. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/runtime.py +0 -0
  147. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/sandbox.py +0 -0
  148. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/session_naming.py +0 -0
  149. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/sessions.py +0 -0
  150. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/shell_mode.py +0 -0
  151. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/shell_tools.py +0 -0
  152. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/skills.py +0 -0
  153. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/stall_log.py +0 -0
  154. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/startup_input.py +0 -0
  155. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/steering.py +0 -0
  156. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/stream_display.py +0 -0
  157. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/strict_tools.py +0 -0
  158. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/syntax.py +0 -0
  159. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/syntax_colors.py +0 -0
  160. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/task_prompt.py +0 -0
  161. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/task_worktrees.py +0 -0
  162. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/terminal_notify.py +0 -0
  163. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/terminal_text.py +0 -0
  164. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/theme.py +0 -0
  165. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/theme_gallery.py +0 -0
  166. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/thinking_markdown.py +0 -0
  167. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/token_accounting.py +0 -0
  168. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/transcript_log.py +0 -0
  169. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/transcript_notice.py +0 -0
  170. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/turn.py +0 -0
  171. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/update_check.py +0 -0
  172. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/usage.py +0 -0
  173. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/word_wrap.py +0 -0
  174. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/workers.py +0 -0
  175. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/workspace.py +0 -0
  176. {pcode-0.1.4 → pcode-0.1.6}/src/pcode/workspace_filesystem.py +0 -0
  177. {pcode-0.1.4 → pcode-0.1.6}/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.6
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/
@@ -554,15 +554,15 @@ class PreviewApp:
554
554
  if self.runtime.session:
555
555
  self.replay()
556
556
  return
557
- from pcode.diagnostics import redact
557
+ from pcode.diagnostics import strip_controls
558
558
 
559
559
  tree = self.runtime.tree
560
560
  with self.transcript.restore():
561
561
  for node_id in tree.path(tree.active):
562
562
  node = tree.nodes[node_id]
563
- self.transcript.user(redact(node.prompt))
563
+ self.transcript.user(strip_controls(node.prompt))
564
564
  if node.response:
565
- self.transcript.events((Message(redact(node.response)),))
565
+ self.transcript.events((Message(strip_controls(node.response)),))
566
566
  self.activity.plan = tree.nodes[tree.active].plan if tree.active else []
567
567
 
568
568
  def aside_changed(self, aside) -> None:
@@ -1884,7 +1884,7 @@ class PreviewApp:
1884
1884
 
1885
1885
  `end` stops at that journal offset: a host sends the rest as it happened.
1886
1886
  """
1887
- from pcode.diagnostics import redact
1887
+ from pcode.diagnostics import strip_controls
1888
1888
 
1889
1889
  saved = saved or self.runtime.session
1890
1890
  self.activity.plan = saved.latest_plan()
@@ -1898,9 +1898,9 @@ class PreviewApp:
1898
1898
  for record in saved.transcript_records(end):
1899
1899
  kind = record["kind"]
1900
1900
  if kind in ("turn_started", "steering"):
1901
- self.transcript.user(redact(record["prompt"]))
1901
+ self.transcript.user(strip_controls(record["prompt"]))
1902
1902
  elif kind in ("Thinking", "thinking_partial"):
1903
- self.transcript.thinking(redact(record["text"]).rstrip("\n") + "\n\n")
1903
+ self.transcript.thinking(strip_controls(record["text"]).rstrip("\n") + "\n\n")
1904
1904
  elif kind == "CacheBust":
1905
1905
  continue # Kept in the journal; live runs show it in the footer.
1906
1906
  elif kind == "EditCompleted":
@@ -1912,21 +1912,21 @@ class PreviewApp:
1912
1912
  self.transcript.tool_result(
1913
1913
  ToolSummary(
1914
1914
  record["name"],
1915
- redact(record["detail"]),
1915
+ strip_controls(record["detail"]),
1916
1916
  record.get("failed", False),
1917
1917
  record.get("call_id", ""),
1918
1918
  record.get("elapsed_seconds"),
1919
- redact(record.get("error", "")),
1920
- redact(record.get("command", "")),
1921
- result=redact(result) if isinstance(result, str) else None,
1919
+ strip_controls(record.get("error", "")),
1920
+ strip_controls(record.get("command", "")),
1921
+ result=strip_controls(result) if isinstance(result, str) else None,
1922
1922
  outcome=record.get("outcome", ""),
1923
1923
  parent_call_id=record.get("parent_call_id", ""),
1924
- purpose=redact(record.get("purpose", "")),
1924
+ purpose=strip_controls(record.get("purpose", "")),
1925
1925
  execution=record.get("execution", ""),
1926
1926
  )
1927
1927
  )
1928
1928
  elif kind in ("Message", "partial"):
1929
- self.transcript.events((Message(redact(record["markdown"])),))
1929
+ self.transcript.events((Message(strip_controls(record["markdown"])),))
1930
1930
  if kind == "partial":
1931
1931
  self.transcript.warning("[Partial output from an interrupted run]")
1932
1932
 
@@ -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
@@ -578,8 +588,7 @@ class AsideBrowser:
578
588
  def links(self) -> list[Link]:
579
589
  """URLs in the selected thread's questions and answers, oldest first.
580
590
 
581
- From the raw text, as `/links` reads the conversation: redaction would
582
- cut a URL holding a token short, and the browser would get the stub.
591
+ From the raw text, as `/links` reads the conversation.
583
592
  """
584
593
  found: dict[str, Link] = {}
585
594
  for aside in self.current():
@@ -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.
@@ -901,7 +905,7 @@ class SessionController:
901
905
  registry = getattr(self.runtime, "jobs", None)
902
906
  if registry is None:
903
907
  return []
904
- from pcode.shell import REDUCED_SHELL_OUTPUT, result_projection
908
+ from pcode.shell import REDUCED_SHELL_OUTPUT
905
909
 
906
910
  finished = registry.take_announcements("ui", job_id)
907
911
  for job in finished:
@@ -915,7 +919,7 @@ class SessionController:
915
919
  failed=job.stopped or job.exit_code != 0,
916
920
  elapsed_seconds=job.elapsed,
917
921
  command=command_text(job.command),
918
- result=command_text(result_projection(result)),
922
+ result=command_text(result),
919
923
  purpose=command_text(job.purpose),
920
924
  )
921
925
  saved = getattr(self.runtime, "session", None)
@@ -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 ()
@@ -3420,7 +3451,7 @@ class SessionController:
3420
3451
 
3421
3452
  async def merge_thread(self, thread: str) -> None:
3422
3453
  """Add a side thread to the conversation tree where it was asked."""
3423
- from pcode.diagnostics import redact
3454
+ from pcode.diagnostics import strip_controls
3424
3455
 
3425
3456
  follows = self.check_bridge(thread)
3426
3457
  asked = self.asides.thread(thread)
@@ -3437,8 +3468,8 @@ class SessionController:
3437
3468
  count = f"{len(steps)} side question{'s' if len(steps) > 1 else ''}"
3438
3469
  if moved:
3439
3470
  for question, answer, _ in steps:
3440
- self.view.user(redact(question))
3441
- self.view.show_events((Message(redact(answer)),))
3471
+ self.view.user(strip_controls(question))
3472
+ self.view.show_events((Message(strip_controls(answer)),))
3442
3473
  self.view.note(
3443
3474
  f"Merged {count} into the conversation, which continues from the last answer."
3444
3475
  )
@@ -3450,7 +3481,7 @@ class SessionController:
3450
3481
 
3451
3482
  async def summarize_thread(self, follows: Aside, instructions: str) -> None:
3452
3483
  """Add a summary of `follows`'s thread to the conversation, and show it."""
3453
- from pcode.diagnostics import redact
3484
+ from pcode.diagnostics import strip_controls
3454
3485
 
3455
3486
  asked = self.asides.thread(follows.thread)
3456
3487
  questions = [aside.question for aside, _ in exchanges(asked, follows.reply.messages)]
@@ -3458,8 +3489,8 @@ class SessionController:
3458
3489
  summary = await self.runtime.summarize_aside(follows.reply, request, instructions)
3459
3490
  follows.bridged = "summarized"
3460
3491
  self.view.aside_changed(follows)
3461
- self.view.user(redact(request))
3462
- self.view.show_events((Message(redact(summary)),))
3492
+ self.view.user(strip_controls(request))
3493
+ self.view.show_events((Message(strip_controls(summary)),))
3463
3494
 
3464
3495
  def aside_completions(self, argument: str):
3465
3496
  """Complete a `$MODEL` word in `/btw` arguments from the /model catalog.
@@ -113,11 +113,11 @@ class ConversationTree:
113
113
 
114
114
  def rows(self) -> list[tuple[tuple[str | None, bool], str]]:
115
115
  """Depth-first user/edit and assistant/continue rows, with stable IDs."""
116
- from pcode.diagnostics import redact
116
+ from pcode.diagnostics import strip_controls
117
117
  from pcode.ui import plain
118
118
 
119
119
  def excerpt(text):
120
- return plain(redact(" ".join(text.split())), 100)
120
+ return plain(strip_controls(" ".join(text.split())), 100)
121
121
 
122
122
  rows = [
123
123
  ((None, False), "Conversation start" + (" ← active" if self.active is None else ""))
@@ -39,7 +39,6 @@ from pcode.planning import PlanSnapshot
39
39
  from pcode.preferences import SETTINGS, load_preferences
40
40
  from pcode.retries import RequestCheckpoint
41
41
  from pcode.runtime import ToolStarted, ToolSummary
42
- from pcode.shell import result_projection
43
42
  from pcode.tool_display import (
44
43
  COMMAND_TOOLS,
45
44
  assignment,
@@ -208,9 +207,6 @@ async def stream_child_activity(_ctx, events):
208
207
  detail, failed = result_detail(
209
208
  name, args, event.part.content, outcome, stored_source(args, called)
210
209
  )
211
- content = (
212
- result_projection(event.part.content) if name == "shell" else event.part.content
213
- )
214
210
  child = ToolSummary(
215
211
  name,
216
212
  detail,
@@ -219,8 +215,8 @@ async def stream_child_activity(_ctx, events):
219
215
  elapsed_seconds=max(0, monotonic() - started),
220
216
  command=invocation(name, args),
221
217
  purpose=stated_purpose(args),
222
- error=command_error(content) if failed and name in COMMAND_TOOLS else "",
223
- result=capture(content),
218
+ error=command_error(event.part.content) if failed and name in COMMAND_TOOLS else "",
219
+ result=capture(event.part.content),
224
220
  run_id=parent.run_id or "",
225
221
  outcome=outcome,
226
222
  parent_call_id=parent_id,
@@ -44,7 +44,17 @@ _ASSIGNMENT = re.compile(
44
44
  )
45
45
 
46
46
 
47
+ def strip_controls(text: str) -> str:
48
+ """Drop control characters other than newline and tab."""
49
+ return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text)
50
+
51
+
47
52
  def redact(text: str) -> str:
53
+ """Best-effort credential removal for error reports and text sent off the machine.
54
+
55
+ Not for the screen: the model has already seen what pcode displays, and
56
+ keyword matching rewrites ordinary code.
57
+ """
48
58
  for name, value in os.environ.items():
49
59
  if len(value) >= 8 and re.search(r"TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY", name):
50
60
  text = text.replace(value, "[redacted]")
@@ -52,7 +62,7 @@ def redact(text: str) -> str:
52
62
  text = re.sub(r"(?i)(https?://)[^/\s@]+@", r"\1[redacted]@", text)
53
63
  text = _TOKEN.sub("[redacted]", text)
54
64
  text = _ASSIGNMENT.sub(r"\1[redacted]", text)
55
- return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text)
65
+ return strip_controls(text)
56
66
 
57
67
 
58
68
  # Enough for a deep cause chain through the agent graph, small enough that a
@@ -15,7 +15,7 @@ from pydantic_ai.messages import (
15
15
  )
16
16
  from pydantic_core import from_json
17
17
 
18
- from pcode.edits import MAX_SOURCE, edit_text, sensitive_path
18
+ from pcode.edits import MAX_SOURCE, edit_text
19
19
  from pcode.runtime import EditPreview
20
20
 
21
21
 
@@ -88,15 +88,11 @@ class StreamingEditPreview:
88
88
  return clear + self._code_preview(index, partial, complete)
89
89
  path = complete.get("path")
90
90
  # Never expose content until the complete path is known and checked.
91
- permitted = isinstance(path, str) and not sensitive_path(path)
91
+ permitted = isinstance(path, str)
92
92
  if permitted:
93
93
  try:
94
94
  resolved = (self.root / path).resolve()
95
- permitted = (
96
- not Path(path).is_absolute()
97
- and resolved.is_relative_to(self.root)
98
- and not sensitive_path(str(resolved.relative_to(self.root)))
99
- )
95
+ permitted = not Path(path).is_absolute() and resolved.is_relative_to(self.root)
100
96
  except OSError, ValueError, RuntimeError:
101
97
  permitted = False
102
98
  if not permitted:
@@ -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 sanitized 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()
@@ -744,7 +747,7 @@ class DiffBrowser:
744
747
  terms = self.terms()
745
748
  if self.scope == "paths":
746
749
  return all(fuzzy_match(term, change.path.casefold()) for term in terms)
747
- # Match the redacted text, so redaction cannot hide the row a query matched.
750
+ # Match the text as shown, so sanitizing cannot hide the row a query matched.
748
751
  text = "\n".join([change_heading(change), patch_text(change.patch, dedent=self.dedent)])
749
752
  return not terms or bool(matching_rows(text, terms))
750
753