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.
- {pcode-0.1.4 → pcode-0.1.6}/PKG-INFO +1 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/app.py +12 -12
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/aside_ui.py +16 -7
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/messages.py +5 -6
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/web.py +52 -17
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/config_ui.py +1 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/controller.py +41 -10
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/conversation_tree.py +2 -2
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/delegation.py +2 -6
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/diagnostics.py +11 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edit_preview.py +3 -7
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edit_transcript.py +76 -9
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edit_ui.py +4 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/edits.py +4 -28
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/ext.py +3 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extension_guide.md +2 -2
- pcode-0.1.6/src/pcode/extensions/basic_memory/Brief format.md +89 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/Briefs.base +50 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/Home.md +11 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/__init__.py +682 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/memory-states.css +58 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/plan_mode.py +74 -20
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/web_research.py +30 -6
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/filesystem.py +7 -14
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/git_diff.py +12 -21
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/inspection.py +2 -18
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/inspector_ui.py +9 -3
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/jobs_ui.py +7 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/popup_ui.py +70 -15
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/preferences.py +10 -4
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/session_ui.py +19 -6
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/shell.py +5 -44
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/stream_events.py +6 -10
- pcode-0.1.6/src/pcode/text_selection.py +365 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tool_display.py +63 -54
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tool_output_limits.py +1 -2
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tool_panel.py +1 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/tree_ui.py +8 -2
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/ui.py +1 -1
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/worker_ui.py +9 -1
- {pcode-0.1.4 → pcode-0.1.6}/.gitignore +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/LICENSE +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/README.md +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/pyproject.toml +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/__init__.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/_job_supervisor.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/agent.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/anthropic_oauth.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/appearance.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/aside.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/aside_guard.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/auth.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/background_delegation.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/block.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/browser.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cache_diagnostics.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cache_settings.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cache_warnings.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/__init__.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/cli.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/errors.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/model.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/resume.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/session.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/session_pool.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/claude_sdk/workspace.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/cli.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/clipboard.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/code_mode.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/codex_login.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/command_transcript.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/commands.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/compaction.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/completion.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/config.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/context_breakdown.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/context_usage.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/conversation_ids.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/copy_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/delta.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/__init__.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/cli.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/listener.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/mailbox.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/outbound.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/parsing.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/render.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/email_remote/state.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/error_report.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/browser.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/ponytail.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/sandbox.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/extensions/session_history.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/file_refs.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/frame.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/gateway.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/history.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/history_cursor.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/history_embeddings.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/host.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/host_protocol.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/host_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/input_keys.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/isolated_delegation.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/job_notices.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/jobs.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/keyboard_protocol.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/keymap.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/layout_speed.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/links.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/links_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/live.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/llm_proxy.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/logins.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/mcp.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/mcp_notice.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/mcp_oauth.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian_process.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian_reminders.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/meridian_setup.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/model_metadata.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/model_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/models.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/native_results.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/oauth_pages.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/output_limits.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/paste.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/path_completion.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/plan_preview.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/plan_sizes.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/planning.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/prefix_keys.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/proctitle.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/profile_benchmark.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/profiling.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/project_trust.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/prompt_keys.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/remote.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/remote_print.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/remote_profile.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/replay_benchmark.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/repo_context.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/retries.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/rpc.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/runtime.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/sandbox.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/session_naming.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/sessions.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/shell_mode.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/shell_tools.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/skills.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/stall_log.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/startup_input.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/steering.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/stream_display.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/strict_tools.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/syntax.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/syntax_colors.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/task_prompt.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/task_worktrees.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/terminal_notify.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/terminal_text.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/theme.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/theme_gallery.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/thinking_markdown.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/token_accounting.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/transcript_log.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/transcript_notice.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/turn.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/update_check.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/usage.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/word_wrap.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/workers.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/workspace.py +0 -0
- {pcode-0.1.4 → pcode-0.1.6}/src/pcode/workspace_filesystem.py +0 -0
- {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.
|
|
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
|
|
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(
|
|
563
|
+
self.transcript.user(strip_controls(node.prompt))
|
|
564
564
|
if node.response:
|
|
565
|
-
self.transcript.events((Message(
|
|
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
|
|
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(
|
|
1901
|
+
self.transcript.user(strip_controls(record["prompt"]))
|
|
1902
1902
|
elif kind in ("Thinking", "thinking_partial"):
|
|
1903
|
-
self.transcript.thinking(
|
|
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
|
-
|
|
1915
|
+
strip_controls(record["detail"]),
|
|
1916
1916
|
record.get("failed", False),
|
|
1917
1917
|
record.get("call_id", ""),
|
|
1918
1918
|
record.get("elapsed_seconds"),
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
result=
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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},
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
3441
|
-
self.view.show_events((Message(
|
|
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
|
|
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(
|
|
3462
|
-
self.view.show_events((Message(
|
|
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
|
|
116
|
+
from pcode.diagnostics import strip_controls
|
|
117
117
|
from pcode.ui import plain
|
|
118
118
|
|
|
119
119
|
def excerpt(text):
|
|
120
|
-
return plain(
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|