pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/models.py ADDED
@@ -0,0 +1,209 @@
1
+ """Local model suggestions for configured providers; no credential or network reads."""
2
+
3
+ import importlib.util
4
+ import os
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import get_args
9
+
10
+ # Every prefix the picker accepts, with a display label. Each provider's SDK
11
+ # extra must be installed (see pyproject.toml) or infer_model raises on switch.
12
+ PROVIDERS = {
13
+ "alibaba": "Alibaba",
14
+ "anthropic": "Anthropic",
15
+ "azure": "Azure OpenAI",
16
+ "bedrock": "Bedrock",
17
+ "bedrock-mantle": "Bedrock Mantle",
18
+ "cerebras": "Cerebras",
19
+ "claude": "Claude Code",
20
+ "crusoe": "Crusoe",
21
+ "deepseek": "DeepSeek",
22
+ "fireworks": "Fireworks",
23
+ "github-copilot": "GitHub Copilot",
24
+ "google": "Google",
25
+ "google-cloud": "Google Cloud",
26
+ "groq": "Groq",
27
+ "heroku": "Heroku",
28
+ "meridian": "Meridian",
29
+ "moonshotai": "Moonshot",
30
+ "nebius": "Nebius",
31
+ "ollama": "Ollama",
32
+ "openai": "OpenAI",
33
+ "openai-chat": "OpenAI (chat)",
34
+ "openai-codex": "OpenAI Codex",
35
+ "openai-responses": "OpenAI (responses)",
36
+ "openrouter": "OpenRouter",
37
+ "ovhcloud": "OVHcloud",
38
+ "sambanova": "SambaNova",
39
+ "snowflake": "Snowflake",
40
+ "together": "Together",
41
+ "vercel": "Vercel",
42
+ "vllm": "vLLM",
43
+ "xai": "xAI",
44
+ "zai": "Z.ai",
45
+ }
46
+
47
+ # Providers activated by environment. Each inner tuple is one requirement
48
+ # satisfied by any listed variable; every requirement must hold. Only names
49
+ # are inspected, never values. Providers needing an endpoint as well as a key
50
+ # (azure, github-copilot) list both so a lone key is not a false positive.
51
+ ENV_PROVIDERS: dict[str, tuple[tuple[str, ...], ...]] = {
52
+ "alibaba": (("ALIBABA_API_KEY", "DASHSCOPE_API_KEY"),),
53
+ "azure": (("AZURE_OPENAI_API_KEY",), ("AZURE_OPENAI_ENDPOINT",)),
54
+ "bedrock": (("AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_PROFILE"),),
55
+ "bedrock-mantle": (("AWS_BEARER_TOKEN_BEDROCK",),),
56
+ "cerebras": (("CEREBRAS_API_KEY",),),
57
+ "crusoe": (("CRUSOE_API_KEY",),),
58
+ "deepseek": (("DEEPSEEK_API_KEY",),),
59
+ "fireworks": (("FIREWORKS_API_KEY",),),
60
+ "github-copilot": (
61
+ ("GITHUB_COPILOT_API_KEY", "GITHUB_COPILOT_API_TOKEN", "COPILOT_GITHUB_TOKEN"),
62
+ ("GITHUB_COPILOT_BASE_URL", "GITHUB_COPILOT_API_BASE", "COPILOT_API_URL"),
63
+ ),
64
+ "google": (("GOOGLE_API_KEY", "GEMINI_API_KEY"),),
65
+ "google-cloud": (("GOOGLE_CLOUD_PROJECT", "GOOGLE_APPLICATION_CREDENTIALS"),),
66
+ "groq": (("GROQ_API_KEY",),),
67
+ "heroku": (("HEROKU_INFERENCE_KEY",),),
68
+ "moonshotai": (("MOONSHOTAI_API_KEY",),),
69
+ "nebius": (("NEBIUS_API_KEY",),),
70
+ "ollama": (("OLLAMA_BASE_URL",),),
71
+ "openai": (("OPENAI_API_KEY",),),
72
+ "openrouter": (("OPENROUTER_API_KEY",),),
73
+ "ovhcloud": (("OVHCLOUD_API_KEY",),),
74
+ "sambanova": (("SAMBANOVA_API_KEY",),),
75
+ "snowflake": (("SNOWFLAKE_TOKEN",), ("SNOWFLAKE_ACCOUNT",)),
76
+ "together": (("TOGETHER_API_KEY",),),
77
+ "vercel": (("VERCEL_AI_GATEWAY_API_KEY", "VERCEL_OIDC_TOKEN"),),
78
+ "vllm": (("VLLM_BASE_URL",),),
79
+ "xai": (("XAI_API_KEY",),),
80
+ "zai": (("ZAI_API_KEY",),),
81
+ }
82
+
83
+ # The subscription routes that predate `claude:` models (the Claude Agent SDK):
84
+ # `meridian:` models and pcode's own Anthropic browser sign-in. Off while
85
+ # `claude:` is tried as the way forward; True restores both unchanged.
86
+ # `anthropic:` models on ANTHROPIC_API_KEY work either way.
87
+ LEGACY_ANTHROPIC_AUTH = False
88
+
89
+
90
+ def login_sources() -> tuple[str, ...]:
91
+ """What `/login` accepts; the first is what a bare `/login` signs in to."""
92
+ if LEGACY_ANTHROPIC_AUTH:
93
+ return ("anthropic", "openai-codex", "claude", "meridian")
94
+ return ("claude", "openai-codex")
95
+
96
+
97
+ def anthropic_credential_hint() -> str:
98
+ """How to give an `anthropic:` model that has no credential one."""
99
+ if LEGACY_ANTHROPIC_AUTH:
100
+ return "Run /login, or set ANTHROPIC_API_KEY."
101
+ return "Set ANTHROPIC_API_KEY, or switch to a claude: model."
102
+
103
+
104
+ # Catalog prefix -> KnownModelName prefixes whose IDs it accepts. Codex has no
105
+ # separate SDK catalog, so expose every OpenAI model ID and let the provider
106
+ # enforce account access; Meridian and Claude Code front Anthropic models.
107
+ CATALOG_SOURCES = {"openai-codex": "openai", "meridian": "anthropic", "claude": "anthropic"}
108
+
109
+
110
+ def claude_code_configured() -> bool:
111
+ """Whether this machine has used Claude Code, whose login `claude:` models share.
112
+
113
+ The Agent SDK bundles the CLI, so an install is not required; its config
114
+ is the signal, checked for existence only.
115
+ """
116
+ config = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
117
+ try:
118
+ return bool(
119
+ (Path(config).expanduser() if config else Path.home() / ".claude").is_dir()
120
+ or (Path.home() / ".claude.json").is_file()
121
+ or shutil.which("claude")
122
+ )
123
+ except OSError:
124
+ return False
125
+
126
+
127
+ def claude_sdk_installed() -> bool:
128
+ """Whether pcode was installed with its `claude` extra (claude-agent-sdk)."""
129
+ return importlib.util.find_spec("claude_agent_sdk") is not None
130
+
131
+
132
+ def _configured(requirements: tuple[tuple[str, ...], ...]) -> bool:
133
+ return all(any(os.environ.get(name, "").strip() for name in group) for group in requirements)
134
+
135
+
136
+ def active_providers(current: str | None) -> set[str]:
137
+ active = set()
138
+ if current and current.partition(":")[0] in PROVIDERS:
139
+ active.add(current.partition(":")[0])
140
+ if os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip() or shutil.which("meridian"):
141
+ active.add("meridian")
142
+ if claude_sdk_installed() and claude_code_configured():
143
+ active.add("claude")
144
+ active.update(name for name, needs in ENV_PROVIDERS.items() if _configured(needs))
145
+ from pcode.anthropic_oauth import anthropic_auth_source
146
+
147
+ # Resolution checks for a stored login file, never its contents.
148
+ source = anthropic_auth_source()
149
+ if source == "oauth" or (
150
+ source == "api-key" and os.environ.get("ANTHROPIC_API_KEY", "").strip()
151
+ ):
152
+ active.add("anthropic")
153
+ from pcode.codex_login import have_credentials
154
+
155
+ # Only check existence, never read a credential to populate the picker.
156
+ codex_home = os.environ.get("CODEX_HOME", "").strip()
157
+ directory = Path(codex_home).expanduser() if codex_home else Path.home() / ".codex"
158
+ try:
159
+ if have_credentials() or (directory / "auth.json").is_file():
160
+ active.add("openai-codex")
161
+ except OSError:
162
+ pass
163
+ from pcode.preferences import load_preferences
164
+
165
+ allowed = load_preferences().get("model_providers", "")
166
+ if allowed:
167
+ active.intersection_update(name.strip() for name in allowed.split(","))
168
+ if not LEGACY_ANTHROPIC_AUTH:
169
+ # Even when the current model is a saved `meridian:` one.
170
+ active.discard("meridian")
171
+ return active
172
+
173
+
174
+ def model_catalog(providers: set[str], current: str | None = None) -> list[str]:
175
+ # Imported here so the terminal can read this module's settings without
176
+ # loading the agent stack. KnownModelName is a TypeAliasType on supported
177
+ # Pydantic versions.
178
+ from pydantic_ai.models import KnownModelName
179
+
180
+ known = get_args(getattr(KnownModelName, "__value__", KnownModelName))
181
+ by_source: dict[str, set[str]] = {}
182
+ for name in known:
183
+ if isinstance(name, str):
184
+ source, _, model = name.partition(":")
185
+ by_source.setdefault(source, set()).add(model)
186
+ models = set()
187
+ for provider in providers:
188
+ source = CATALOG_SOURCES.get(provider, provider)
189
+ # Providers without a catalog (openrouter, ollama, vllm...) take any
190
+ # model ID typed into the picker instead.
191
+ models.update(f"{provider}:{model}" for model in by_source.get(source, ()))
192
+ if current and current.partition(":")[0] in providers:
193
+ models.add(current)
194
+ return sorted(models, key=model_sort_key)
195
+
196
+
197
+ def model_sort_key(name: str):
198
+ """Group providers/families alphabetically, then numeric versions newest first.
199
+
200
+ Compare numeric components as integers so 4.10 precedes 4.9. Undated aliases
201
+ sort before dated snapshots of the same version. Do not pin the current model
202
+ above newer suggestions; the picker marks it separately.
203
+ """
204
+ provider, _, model = name.partition(":")
205
+ parts = tuple(
206
+ (1, -int(part)) if part.isdigit() else (0, part.casefold())
207
+ for part in re.split(r"(\d+)", model)
208
+ )
209
+ return provider, parts
@@ -0,0 +1,119 @@
1
+ """Repair a history whose native-tool results the current login cannot read.
2
+
3
+ Anthropic returns each server-side `web_search` hit as an opaque
4
+ `encrypted_content` blob that only the organization it was issued to can
5
+ decrypt. Replay is therefore credential-bound: sign in as a different account
6
+ -- a second `/login`, another `PCODE_CONFIG_DIR`, an API key in place of a
7
+ subscription -- and the request comes back as
8
+ `400 Invalid encrypted_content in search_result block`. The blobs live in the
9
+ message history, so without a repair every later request in that session fails
10
+ the same way and the conversation is stranded.
11
+
12
+ Dropping them is the whole repair. The page text inside a blob is unrecoverable,
13
+ but the titles and URLs beside it are plaintext, and the model's own reading of
14
+ the search sits in the same response. Thinking signatures cross accounts
15
+ untouched (verified against the live endpoint), so they are left alone.
16
+ """
17
+
18
+ from collections.abc import Iterable
19
+
20
+ from pydantic_ai.exceptions import ModelHTTPError
21
+ from pydantic_ai.messages import (
22
+ ModelMessage,
23
+ ModelResponse,
24
+ ModelResponsePart,
25
+ NativeToolCallPart,
26
+ NativeToolReturnPart,
27
+ TextPart,
28
+ )
29
+
30
+ # Enough to place the search without turning one dead result set into a wall.
31
+ MAX_SOURCES = 10
32
+
33
+
34
+ def unreadable_native_results(error: BaseException) -> bool:
35
+ """Whether the provider rejected replayed results it cannot decrypt.
36
+
37
+ Deliberately narrow: a 400 naming `encrypted_content` is the provider
38
+ saying this history is unsendable as it stands, which no amount of retrying
39
+ changes. Every other 400 is a request pcode built wrong.
40
+ """
41
+ return (
42
+ isinstance(error, ModelHTTPError)
43
+ and error.status_code == 400
44
+ and "encrypted_content" in str(error.body)
45
+ )
46
+
47
+
48
+ def drop_unreadable_results(messages: Iterable[ModelMessage]) -> int:
49
+ """Replace credential-bound native results with a plain trace, in place.
50
+
51
+ Returns how many results were dropped. The part lists are rewritten through
52
+ a slice assignment because the same `ModelResponse` objects are shared by
53
+ the turn's history, the request already checkpointed, and the next
54
+ snapshot: replacing the messages would repair only one of them.
55
+ """
56
+ dropped = 0
57
+ for message in messages:
58
+ if not isinstance(message, ModelResponse):
59
+ continue
60
+ stale = {
61
+ part.tool_call_id
62
+ for part in message.parts
63
+ if isinstance(part, NativeToolReturnPart) and _encrypted(part.content)
64
+ }
65
+ if not stale:
66
+ continue
67
+ queries = {
68
+ part.tool_call_id: _query(part)
69
+ for part in message.parts
70
+ if isinstance(part, NativeToolCallPart) and part.tool_call_id in stale
71
+ }
72
+ kept: list[ModelResponsePart] = []
73
+ for part in message.parts:
74
+ if isinstance(part, NativeToolCallPart) and part.tool_call_id in stale:
75
+ # The call cannot outlive its result: an unpaired server tool
76
+ # use is rejected in its own right.
77
+ continue
78
+ if isinstance(part, NativeToolReturnPart) and part.tool_call_id in stale:
79
+ kept.append(TextPart(content=_note(part, queries.get(part.tool_call_id))))
80
+ dropped += 1
81
+ continue
82
+ kept.append(part)
83
+ message.parts[:] = kept
84
+ return dropped
85
+
86
+
87
+ def _encrypted(content: object) -> bool:
88
+ """Whether anything in a result carries provider-encrypted text."""
89
+ if isinstance(content, dict):
90
+ return "encrypted_content" in content or any(_encrypted(v) for v in content.values())
91
+ if isinstance(content, list):
92
+ return any(_encrypted(item) for item in content)
93
+ return False
94
+
95
+
96
+ def _query(part: NativeToolCallPart) -> str | None:
97
+ try:
98
+ args = part.args_as_dict()
99
+ except Exception:
100
+ return None
101
+ query = args.get("query")
102
+ return query if isinstance(query, str) else None
103
+
104
+
105
+ def _note(part: NativeToolReturnPart, query: str | None) -> str:
106
+ items = part.content if isinstance(part.content, list) else [part.content]
107
+ sources = [
108
+ f"{item.get('title') or item['url']}: {item['url']}"
109
+ for item in items
110
+ if isinstance(item, dict) and item.get("url")
111
+ ]
112
+ searched = f" for {query!r}" if query else ""
113
+ listed = "".join(f"\n- {source}" for source in sources[:MAX_SOURCES])
114
+ found = f" Pages found:{listed}" if listed else ""
115
+ return (
116
+ f"[pcode: the {part.tool_name} results{searched} were encrypted for a different "
117
+ f"account and cannot be replayed, so they were dropped from this conversation. "
118
+ f"Search again if you still need them.{found}]"
119
+ )
pcode/oauth_pages.py ADDED
@@ -0,0 +1,66 @@
1
+ """Browser-facing HTML for the loopback OAuth callback.
2
+
3
+ The loopback server answers exactly one request and then shuts down, so the page
4
+ has to be a single self-contained document: no stylesheet fetch, no fonts, no
5
+ scripts that outlive the response.
6
+
7
+ Adapted from the MIT-licensed templates in kriasoft/oauth-callback
8
+ (https://github.com/kriasoft/oauth-callback), Copyright (c) 2025-present
9
+ Konstantin Tarkus, Kriasoft.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from html import escape
15
+
16
+ _STYLE = """
17
+ :root{color-scheme:light dark}
18
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
19
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,"Helvetica Neue",sans-serif;
20
+ background:linear-gradient(180deg,#fafafa 0%,#f0f0f0 100%);color:#1a1a1a}
21
+ .card{background:#fff;border-radius:16px;padding:48px 40px 40px;max-width:420px;width:90%;
22
+ text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.04),0 6px 16px rgba(0,0,0,.08);
23
+ animation:fadeUp .4s ease-out}
24
+ h1{font-size:24px;font-weight:600;margin:0 0 8px;letter-spacing:-.02em;line-height:1.2}
25
+ p{font-size:15px;color:#666;margin:0;line-height:1.5}
26
+ svg{width:56px;height:56px;margin:0 auto 24px;display:block;fill:none;stroke-width:3;
27
+ stroke-linecap:round;stroke-linejoin:round}
28
+ circle{stroke-width:2;stroke-dasharray:157;stroke-dashoffset:157;
29
+ animation:stroke .6s cubic-bezier(.65,0,.45,1) forwards}
30
+ path{stroke-dasharray:60;stroke-dashoffset:60;
31
+ animation:stroke .3s cubic-bezier(.65,0,.45,1) .4s forwards}
32
+ .ok svg{stroke:#10b981}
33
+ .bad svg{stroke:#ef4444}
34
+ @keyframes stroke{to{stroke-dashoffset:0}}
35
+ @keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
36
+ @media (prefers-color-scheme:dark){
37
+ body{background:linear-gradient(180deg,#0a0a0a 0%,#1a1a1a 100%);color:#fafafa}
38
+ .card{background:#1f1f1f;box-shadow:0 1px 3px rgba(0,0,0,.2),0 8px 24px rgba(0,0,0,.4)}
39
+ p{color:#999}}
40
+ @media (prefers-reduced-motion:reduce){*{animation:none!important}
41
+ circle,path{stroke-dashoffset:0}}
42
+ """
43
+
44
+ _CHECK = '<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>'
45
+ _CROSS = '<path d="M17 17l18 18M35 17L17 35"/>'
46
+
47
+ _PAGE = (
48
+ "<!doctype html><html lang=en><head><meta charset=utf-8>"
49
+ '<meta name=viewport content="width=device-width, initial-scale=1">'
50
+ "<title>{title} &middot; pcode</title><style>{style}</style></head>"
51
+ '<body class="{kind}"><main class=card>'
52
+ '<svg viewBox="0 0 52 52" role=img aria-hidden=true>'
53
+ "<circle cx=26 cy=26 r=25/>{icon}</svg>"
54
+ "<h1>{title}</h1><p>{message}</p></main></body></html>"
55
+ )
56
+
57
+
58
+ def callback_page(title: str, message: str, *, ok: bool) -> str:
59
+ """Render the single page the browser sees after the OAuth redirect."""
60
+ return _PAGE.format(
61
+ title=escape(title),
62
+ message=escape(message),
63
+ style=_STYLE,
64
+ kind="ok" if ok else "bad",
65
+ icon=_CHECK if ok else _CROSS,
66
+ )
pcode/output_limits.py ADDED
@@ -0,0 +1,41 @@
1
+ """Model-aware output defaults, separate from reasoning effort and visibility."""
2
+
3
+ from dataclasses import replace
4
+
5
+ from pydantic_ai.capabilities import AbstractCapability
6
+ from pydantic_ai.models.anthropic import AnthropicModel
7
+
8
+ from pcode.model_metadata import catalog, refresh_context
9
+
10
+ # Anthropic requires max_tokens. Pydantic AI before 2.52 sent 4096, which thinking
11
+ # or one file-writing tool call can exhaust; 2.52 asks for the model's maximum
12
+ # output when its own profile knows it. pcode resolves the serving route's limit
13
+ # (Meridian ids, Claude Code aliases) from its catalog instead, and unknown or
14
+ # offline routes cannot safely borrow another endpoint's model limits.
15
+ FALLBACK_OUTPUT_TOKENS = 16_384
16
+
17
+
18
+ class ModelOutputLimits(AbstractCapability):
19
+ """Resolve each request's default, including model switches and subagents.
20
+
21
+ Use the serving model's advertised maximum, not a guessed family-name table.
22
+ A maximum is a ceiling, not a requested response length or thinking budget.
23
+ Other adapters retain their provider defaults; explicit settings always win.
24
+ """
25
+
26
+ async def before_model_request(self, ctx, request_context):
27
+ if not isinstance(request_context.model, AnthropicModel):
28
+ return request_context
29
+ settings = request_context.model_settings or {}
30
+ if settings.get("max_tokens") is not None:
31
+ return request_context
32
+ await refresh_context(request_context.model)
33
+ limits = catalog.limits(request_context.model)
34
+ maximum = limits.output if limits else None
35
+ if maximum is None and request_context.model.system == "claude":
36
+ # The CLI's own default beats a guess below it (a `claude:opus` alias).
37
+ return request_context
38
+ return replace(
39
+ request_context,
40
+ model_settings={**settings, "max_tokens": maximum or FALLBACK_OUTPUT_TOKENS},
41
+ )
pcode/paste.py ADDED
@@ -0,0 +1,45 @@
1
+ """Collapse large pastes in the editor to a short preview plus a size marker.
2
+
3
+ A multi-kilobyte paste turns the prompt into a wall of text that hides the
4
+ question being typed around it. Pastes longer than ``PASTE_THRESHOLD`` are
5
+ inserted as ``<preview>…[+N chars]`` instead; the full text is kept aside and
6
+ substituted back into the buffer when the prompt is submitted, so the model
7
+ still receives everything. A placeholder the user has edited no longer matches
8
+ and is sent verbatim, which is deliberate: the marker is visible and the
9
+ transcript shows exactly what went out.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ PASTE_THRESHOLD = 100
17
+ PREVIEW_CHARS = 60
18
+ MARKER_PATTERN = re.compile(r"…\[\+[\d,]+ chars\]")
19
+
20
+ __all__ = ["MARKER_PATTERN", "PASTE_THRESHOLD", "PastedText"]
21
+
22
+
23
+ class PastedText:
24
+ """Placeholders currently standing in for pasted text in the editor."""
25
+
26
+ def __init__(self) -> None:
27
+ self._full: dict[str, str] = {}
28
+
29
+ def collapse(self, text: str) -> str:
30
+ """Return what to insert for ``text``: itself, or a placeholder."""
31
+ if len(text) <= PASTE_THRESHOLD:
32
+ return text
33
+ preview = text[:PREVIEW_CHARS].replace("\n", "⏎")
34
+ placeholder = f"{preview}…[+{len(text) - PREVIEW_CHARS:,} chars]"
35
+ self._full[placeholder] = text
36
+ return placeholder
37
+
38
+ def expand(self, text: str) -> str:
39
+ """Substitute every intact placeholder back with its full paste."""
40
+ for placeholder, full in self._full.items():
41
+ text = text.replace(placeholder, full)
42
+ return text
43
+
44
+ def clear(self) -> None:
45
+ self._full.clear()
@@ -0,0 +1,66 @@
1
+ """Complete a filesystem path typed as a slash command's argument.
2
+
3
+ Only the directory being typed is listed, never walked, and a huge directory
4
+ is read only up to `SCAN_LIMIT` entries, so completion stays instant anywhere.
5
+ """
6
+
7
+ import os
8
+ from collections.abc import Iterator
9
+ from itertools import islice
10
+ from pathlib import Path
11
+
12
+ from prompt_toolkit.completion import Completion
13
+
14
+ SCAN_LIMIT = 2000
15
+ SHOWN = 100
16
+
17
+
18
+ def path_fragment(argument: str) -> str:
19
+ """The path being typed: the argument after any leading `--option` words."""
20
+ words = argument.lstrip()
21
+ while words.startswith("--"):
22
+ _option, space, words = words.partition(" ")
23
+ if not space: # Still typing the option itself.
24
+ return ""
25
+ words = words.lstrip()
26
+ return words
27
+
28
+
29
+ def complete_paths(argument: str, workspace: Path) -> Iterator[Completion]:
30
+ """Entries of the typed directory starting with the typed name, directories first.
31
+
32
+ Relative paths resolve against `workspace`. Nothing completes until
33
+ something is typed, and a bare `/` does not list the filesystem root.
34
+ """
35
+ fragment = path_fragment(argument)
36
+ if not fragment or fragment == "/":
37
+ return
38
+ if fragment == "~":
39
+ yield Completion("~/", start_position=-1)
40
+ return
41
+ head, _, name = fragment.rpartition("/")
42
+ if "/" in fragment:
43
+ directory = Path(os.path.expanduser(head or "/"))
44
+ else:
45
+ directory = Path()
46
+ if not directory.is_absolute():
47
+ directory = workspace / directory
48
+ needle = name.casefold()
49
+ found: list[tuple[bool, str]] = []
50
+ try:
51
+ with os.scandir(directory) as entries:
52
+ for entry in islice(entries, SCAN_LIMIT):
53
+ if entry.name.startswith(".") and not name.startswith("."):
54
+ continue
55
+ if entry.name.casefold().startswith(needle):
56
+ try:
57
+ is_dir = entry.is_dir()
58
+ except OSError:
59
+ is_dir = False
60
+ found.append((not is_dir, entry.name))
61
+ except OSError:
62
+ return
63
+ found.sort(key=lambda item: (item[0], item[1].casefold()))
64
+ for is_file, entry in found[:SHOWN]:
65
+ suffix = "" if is_file else "/"
66
+ yield Completion(entry + suffix, start_position=-len(name), display=entry + suffix)
pcode/plan_preview.py ADDED
@@ -0,0 +1,131 @@
1
+ """Ephemeral task-pane projections of streamed planning calls.
2
+
3
+ These never mutate Harness's store. Complete strings/fields can be displayed while
4
+ JSON is still arriving; validation, IDs, and persistence belong to the real tools.
5
+ """
6
+
7
+ from copy import deepcopy
8
+
9
+ from pydantic import ValidationError
10
+ from pydantic_ai.messages import (
11
+ FunctionToolCallEvent,
12
+ FunctionToolResultEvent,
13
+ PartDeltaEvent,
14
+ PartEndEvent,
15
+ PartStartEvent,
16
+ ToolCallPart,
17
+ ToolCallPartDelta,
18
+ )
19
+ from pydantic_core import from_json
20
+
21
+ from pcode.planning import SizedPlanItem
22
+ from pcode.runtime import PlanPreview
23
+
24
+ PLANNING_MUTATIONS = {
25
+ "write_plan",
26
+ "add_task",
27
+ "update_task_status",
28
+ "update_task_statuses",
29
+ "remove_task",
30
+ }
31
+
32
+
33
+ def project_plan(items: list[dict], part: ToolCallPart) -> list[dict]:
34
+ """Best-effort display only; malformed/incomplete fields leave the plan alone."""
35
+ try:
36
+ args = from_json(part.args, allow_partial=True) if isinstance(part.args, str) else part.args
37
+ except ValueError:
38
+ return items
39
+ if not isinstance(args, dict):
40
+ return items
41
+
42
+ def row(value, index):
43
+ if not isinstance(value, dict):
44
+ return None
45
+ try:
46
+ return SizedPlanItem.model_validate(
47
+ {"id": f"preview:{part.tool_call_id}:{index}", **value}
48
+ ).model_dump(mode="json")
49
+ except ValidationError:
50
+ return None
51
+
52
+ if part.tool_name == "write_plan":
53
+ values = args.get("items")
54
+ if isinstance(values, list):
55
+ rows = [item for i, value in enumerate(values) if (item := row(value, i)) is not None]
56
+ if rows:
57
+ return rows
58
+ # An unfinished `items: [` is not an instruction to clear the pane.
59
+ if values == []:
60
+ try:
61
+ part.args_as_dict(raise_if_invalid=True)
62
+ except (ValueError, TypeError):
63
+ return items
64
+ return []
65
+ elif part.tool_name == "add_task":
66
+ if (item := row(args, 0)) is not None:
67
+ return [*items, item]
68
+ elif part.tool_name in {"update_task_status", "update_task_statuses"}:
69
+ updates = args.get("updates") if part.tool_name == "update_task_statuses" else [args]
70
+ if isinstance(updates, list):
71
+ items = deepcopy(items)
72
+ for update in updates:
73
+ if not isinstance(update, dict):
74
+ continue
75
+ for i, item in enumerate(items):
76
+ if item["id"] == update.get("task_id"):
77
+ changed = row({**item, "status": update.get("status")}, i)
78
+ if changed is not None:
79
+ items[i] = changed
80
+ elif part.tool_name == "remove_task":
81
+ return [item for item in items if item["id"] != args.get("task_id")]
82
+ return items
83
+
84
+
85
+ class StreamingPlanPreview:
86
+ def __init__(self):
87
+ self.parts: dict[int, ToolCallPart] = {}
88
+ self.items: list[dict] | None = None
89
+ self.executing = False
90
+ self.confirmed: list[dict] = []
91
+
92
+ def update(self, event, confirmed: list[dict]) -> PlanPreview | None:
93
+ store_changed = confirmed != self.confirmed
94
+ self.confirmed = confirmed
95
+ if isinstance(event, PartStartEvent):
96
+ if self.executing or event.index == 0:
97
+ self.parts.clear()
98
+ self.executing = False
99
+ if isinstance(event.part, ToolCallPart):
100
+ self.parts[event.index] = event.part
101
+ elif isinstance(event, PartDeltaEvent):
102
+ if isinstance(event.delta, ToolCallPartDelta) and event.index in self.parts:
103
+ self.parts[event.index] = event.delta.apply(self.parts[event.index])
104
+ elif isinstance(event, PartEndEvent) and isinstance(event.part, ToolCallPart):
105
+ self.parts[event.index] = event.part
106
+ elif isinstance(event, FunctionToolCallEvent):
107
+ self.executing = True
108
+ elif isinstance(event, FunctionToolResultEvent):
109
+ if store_changed:
110
+ # The shared store can already contain sibling mutations whose
111
+ # result events have not arrived. Do not replay their arguments
112
+ # over that snapshot (e.g. duplicating concurrent add_task calls).
113
+ self.parts.clear()
114
+ else:
115
+ self.parts = {
116
+ index: part
117
+ for index, part in self.parts.items()
118
+ if part.tool_call_id != event.tool_call_id
119
+ }
120
+ else:
121
+ return None
122
+
123
+ items = confirmed
124
+ for part in self.parts.values():
125
+ if part.tool_name in PLANNING_MUTATIONS:
126
+ items = project_plan(items, part)
127
+ preview = items if items != confirmed else None
128
+ if preview != self.items:
129
+ self.items = preview
130
+ return PlanPreview(preview)
131
+ return None