pcode 0.1.6__tar.gz → 0.1.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pcode-0.1.6 → pcode-0.1.8}/PKG-INFO +5 -5
- {pcode-0.1.6 → pcode-0.1.8}/README.md +1 -1
- {pcode-0.1.6 → pcode-0.1.8}/pyproject.toml +3 -3
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/agent.py +11 -7
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/app.py +58 -20
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/appearance.py +10 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/errors.py +1 -1
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/compaction.py +6 -39
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/config.py +2 -1
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/conversation_tree.py +8 -6
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/copy_ui.py +99 -35
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edits.py +4 -2
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Brief format.md +2 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/__init__.py +95 -46
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/plan_mode.py +129 -25
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/file_refs.py +1 -4
- pcode-0.1.8/src/pcode/meridian_reminders.py +48 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/popup_ui.py +65 -4
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/preferences.py +6 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/prompt_keys.py +30 -25
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/stall_log.py +8 -3
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/steering.py +2 -2
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/theme.py +21 -2
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tool_panel.py +1 -1
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tree_ui.py +181 -49
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/ui.py +210 -41
- pcode-0.1.6/src/pcode/meridian_reminders.py +0 -120
- pcode-0.1.6/src/pcode/paste.py +0 -45
- {pcode-0.1.6 → pcode-0.1.8}/.gitignore +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/LICENSE +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/__init__.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/_job_supervisor.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/anthropic_oauth.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/aside.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/aside_guard.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/aside_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/auth.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/background_delegation.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/block.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/browser.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cache_diagnostics.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cache_settings.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cache_warnings.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/__init__.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/cli.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/messages.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/model.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/resume.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/session.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/session_pool.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/web.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/claude_sdk/workspace.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/cli.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/clipboard.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/code_mode.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/codex_login.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/command_transcript.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/commands.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/completion.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/config_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/context_breakdown.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/context_usage.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/controller.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/conversation_ids.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/delegation.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/delta.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/diagnostics.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edit_preview.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edit_transcript.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/edit_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/__init__.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/cli.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/listener.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/mailbox.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/outbound.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/parsing.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/render.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/email_remote/state.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/error_report.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/ext.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extension_guide.md +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Briefs.base +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/Home.md +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/basic_memory/memory-states.css +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/browser.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/ponytail.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/sandbox.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/session_history.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/extensions/web_research.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/filesystem.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/frame.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/gateway.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/git_diff.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/history.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/history_cursor.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/history_embeddings.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/host.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/host_protocol.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/host_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/input_keys.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/inspection.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/inspector_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/isolated_delegation.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/job_notices.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/jobs.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/jobs_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/keyboard_protocol.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/keymap.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/layout_speed.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/links.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/links_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/live.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/llm_proxy.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/logins.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/mcp.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/mcp_notice.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/mcp_oauth.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/meridian.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/meridian_process.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/meridian_setup.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/model_metadata.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/model_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/models.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/native_results.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/oauth_pages.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/output_limits.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/path_completion.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/plan_preview.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/plan_sizes.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/planning.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/prefix_keys.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/proctitle.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/profile_benchmark.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/profiling.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/project_trust.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/remote.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/remote_print.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/remote_profile.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/replay_benchmark.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/repo_context.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/retries.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/rpc.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/runtime.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/sandbox.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/session_naming.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/session_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/sessions.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/shell.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/shell_mode.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/shell_tools.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/skills.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/startup_input.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/stream_display.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/stream_events.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/strict_tools.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/syntax.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/syntax_colors.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/task_prompt.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/task_worktrees.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/terminal_notify.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/terminal_text.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/text_selection.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/theme_gallery.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/thinking_markdown.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/token_accounting.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tool_display.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/tool_output_limits.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/transcript_log.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/transcript_notice.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/turn.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/update_check.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/usage.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/word_wrap.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/worker_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/workers.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/workspace.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/workspace_filesystem.py +0 -0
- {pcode-0.1.6 → pcode-0.1.8}/src/pcode/worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pcode
|
|
3
|
-
Version: 0.1.
|
|
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,
|
|
@@ -1227,25 +1233,43 @@ class PreviewApp:
|
|
|
1227
1233
|
self.transcript.syntax_gallery()
|
|
1228
1234
|
|
|
1229
1235
|
def theme(self, argument: str) -> None:
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
if
|
|
1236
|
-
|
|
1237
|
-
|
|
1236
|
+
"""Pick and save a palette; bare `/theme` flips it for this session only.
|
|
1237
|
+
|
|
1238
|
+
Saving the flip would pin every later session to it, and with it turn
|
|
1239
|
+
off following the terminal's light/dark switches.
|
|
1240
|
+
"""
|
|
1241
|
+
theme = argument or ("light" if self.transcript.resolved_theme == "dark" else "dark")
|
|
1242
|
+
self.transcript.theme = theme
|
|
1243
|
+
if not argument:
|
|
1244
|
+
message = f"{theme} for this session; /theme {theme} saves it"
|
|
1245
|
+
else:
|
|
1246
|
+
self.persist_defaults(theme=theme)
|
|
1247
|
+
message = theme
|
|
1248
|
+
if theme == "auto":
|
|
1249
|
+
message += f" ({self.transcript.resolved_theme})"
|
|
1250
|
+
if self._appearance is not None:
|
|
1251
|
+
# The detected value may be stale; the answer repaints if it differs.
|
|
1252
|
+
self._appearance.refresh()
|
|
1253
|
+
self.transcript.flash(f"Theme: {message}.")
|
|
1238
1254
|
self.transcript.regenerate()
|
|
1239
1255
|
|
|
1240
1256
|
def follow_appearance(self, detected: str) -> None:
|
|
1241
|
-
"""The terminal reported its
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1257
|
+
"""The terminal reported its appearance; repaint what follows it.
|
|
1258
|
+
|
|
1259
|
+
Always invalidates the editor: shaded frames mix from the reported
|
|
1260
|
+
background even when the theme stays (another dark profile), or under
|
|
1261
|
+
a fixed `/theme`. A mode 2031 flip carries no color, so it asks for
|
|
1262
|
+
one; the OSC 11 answer records a matching theme, so this asks once.
|
|
1263
|
+
"""
|
|
1264
|
+
reported = terminal_background()
|
|
1265
|
+
if self._appearance is not None and (reported is None or reported[1] != detected):
|
|
1266
|
+
self._appearance.refresh()
|
|
1267
|
+
if detected != self.transcript.detected_theme:
|
|
1268
|
+
self.transcript.detected_theme = detected
|
|
1269
|
+
if self.transcript.theme == "auto":
|
|
1270
|
+
self.transcript.regenerate()
|
|
1271
|
+
if self.prompt_session is not None:
|
|
1272
|
+
self.prompt_session.app.invalidate()
|
|
1249
1273
|
|
|
1250
1274
|
def syntax(self, argument: str) -> None:
|
|
1251
1275
|
"""Choose the Pygments style for code, the completion menu and the prompt.
|
|
@@ -2332,7 +2356,9 @@ class PreviewApp:
|
|
|
2332
2356
|
self.output = output
|
|
2333
2357
|
self.prompt_session = session
|
|
2334
2358
|
session.app.style = DynamicStyle(
|
|
2335
|
-
lambda: self.transcript.prompt_style(
|
|
2359
|
+
lambda: self.transcript.prompt_style(
|
|
2360
|
+
self.activity.task_style, self.activity.frame_style
|
|
2361
|
+
)
|
|
2336
2362
|
)
|
|
2337
2363
|
try:
|
|
2338
2364
|
terminal = session.app.output.fileno()
|
|
@@ -2709,6 +2735,9 @@ def main() -> None:
|
|
|
2709
2735
|
"hosts",
|
|
2710
2736
|
)
|
|
2711
2737
|
parser.add_argument("--hosts", action="store_true", help="List running session hosts and exit")
|
|
2738
|
+
parser.add_argument(
|
|
2739
|
+
"--json", action="store_true", help="With --hosts: one JSON array, for scripts and plugins"
|
|
2740
|
+
)
|
|
2712
2741
|
host_kinds = ("all", "stale", "idle")
|
|
2713
2742
|
parser.add_argument(
|
|
2714
2743
|
"--kill-hosts",
|
|
@@ -3179,6 +3208,8 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
|
|
3179
3208
|
parser.error("--fork cannot be combined with --attach")
|
|
3180
3209
|
if args.compact and not args.sessions:
|
|
3181
3210
|
parser.error("--compact applies to --sessions")
|
|
3211
|
+
if args.json and not args.hosts:
|
|
3212
|
+
parser.error("--json applies to --hosts")
|
|
3182
3213
|
if args.print:
|
|
3183
3214
|
if args.theme_preview or args.sessions:
|
|
3184
3215
|
parser.error("--print cannot be combined with --theme-preview or --sessions")
|
|
@@ -3225,6 +3256,13 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
|
|
3225
3256
|
if args.kill_hosts:
|
|
3226
3257
|
_kill_hosts(args.kill_hosts, entries, code)
|
|
3227
3258
|
return
|
|
3259
|
+
if args.json:
|
|
3260
|
+
import json
|
|
3261
|
+
|
|
3262
|
+
# A contract for callers such as the Obsidian bridge: add keys, never rename.
|
|
3263
|
+
keys = ("id", "session_id", "state", "workspace", "title", "last_prompt", "attached")
|
|
3264
|
+
print(json.dumps([{key: getattr(entry, key) for key in keys} for entry in entries]))
|
|
3265
|
+
return
|
|
3228
3266
|
for entry in entries:
|
|
3229
3267
|
print(f"{entry.id} {host_row(entry, None, code=code).strip()}")
|
|
3230
3268
|
if not entries:
|
|
@@ -144,6 +144,13 @@ class AppearanceWatch:
|
|
|
144
144
|
self.suspended = False
|
|
145
145
|
if enabled:
|
|
146
146
|
self.enabled = _write_all(self.fd, ENABLE_REPORTS)
|
|
147
|
+
# A switch while the other program had the terminal went unseen.
|
|
148
|
+
self.refresh()
|
|
149
|
+
|
|
150
|
+
def refresh(self) -> None:
|
|
151
|
+
"""Ask for the background now; the answer arrives as input like any other."""
|
|
152
|
+
if self.enabled and not self.suspended:
|
|
153
|
+
_write_all(self.fd, QUERY_BACKGROUND)
|
|
147
154
|
|
|
148
155
|
async def query(self) -> None:
|
|
149
156
|
for delay in QUERY_DELAYS:
|
|
@@ -155,6 +162,9 @@ class AppearanceWatch:
|
|
|
155
162
|
if self.fd is None or not _isatty(self.fd) or os.environ.get("TERM") == "dumb":
|
|
156
163
|
return
|
|
157
164
|
self.enabled = _write_all(self.fd, ENABLE_REPORTS)
|
|
165
|
+
# The startup probe is skipped when input is already waiting, leaving a
|
|
166
|
+
# guess from COLORFGBG that only a later switch would otherwise correct.
|
|
167
|
+
self.refresh()
|
|
158
168
|
reader = self.reader if self.reader is not None else desktop_reader()
|
|
159
169
|
pending = None
|
|
160
170
|
try:
|
|
@@ -5,7 +5,7 @@ from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError
|
|
|
5
5
|
MISSING_SDK = (
|
|
6
6
|
"claude: models need pcode's optional `claude` extra, which is not installed. "
|
|
7
7
|
"From a pcode checkout run `make install`, "
|
|
8
|
-
"or `uv tool install --python 3.
|
|
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):
|
|
@@ -52,6 +52,8 @@ A **brief** explains one project as it is now: what it is, where it's at, how it
|
|
|
52
52
|
| `plans` | links to open plans | | |
|
|
53
53
|
| `sources` | session IDs, plus code paths or commit ranges | same | |
|
|
54
54
|
|
|
55
|
+
Properties are YAML, and one bad value breaks the whole block: Obsidian then shows it as raw red text instead of properties. Wrap every free-text value (`scope`, `tldr`, `next` and each `sources` item) in single quotes, doubling any `'` inside: `tldr: 'Checks the org''s code: once per ticket'`. Unquoted, a `: ` anywhere or a leading `` ` ``, `#`, `*`, `[`, `{`, `&`, `!`, `|`, `>`, `%` or `@` breaks it.
|
|
56
|
+
|
|
55
57
|
## Sections
|
|
56
58
|
|
|
57
59
|
| Section | Answers | Budget |
|