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