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
@@ -0,0 +1,451 @@
1
+ """Context and output metadata, not pricing. Refreshes never run during rendering.
2
+
3
+ Public Models.dev metadata is cached on disk. Authenticated metadata is cached
4
+ per model instance only: one account/proxy must not inherit another's limits.
5
+ Failures retain last-known values; unknown serving routes stay unknown.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ from dataclasses import asdict, dataclass, field
14
+ from pathlib import Path
15
+ from tempfile import NamedTemporaryFile
16
+ from typing import Any
17
+ from urllib.parse import quote, urlsplit
18
+ from weakref import finalize
19
+
20
+ import httpx2
21
+ from pydantic_ai.models import Model
22
+
23
+ CATALOG_URL = "https://models.dev/api.json"
24
+ CATALOG_TTL = 24 * 60 * 60
25
+ NATIVE_TTL = 60 * 60
26
+ RETRY_DELAY = 60
27
+ REQUEST_TIMEOUT = 3
28
+ # Protocol version verified against OpenAI's models endpoint implementation.
29
+ # This is NOT the pcode version; the backend uses it to select its catalog schema.
30
+ CODEX_CATALOG_VERSION = "0.154.0"
31
+
32
+
33
+ def codex_catalog_version() -> str:
34
+ """Use the newest verified/catalog client version, not pcode's own version.
35
+
36
+ The backend filters out newer models for older clients. The local Codex cache
37
+ is useful for its protocol version only: never borrow its account's limits,
38
+ instructions, credentials, or model availability. No CLI subprocess needed.
39
+ """
40
+ version = CODEX_CATALOG_VERSION
41
+ home = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser()
42
+ try:
43
+ with (home / "models_cache.json").open() as file:
44
+ raw = file.read(8_000_001)
45
+ if len(raw) > 8_000_000:
46
+ return version
47
+ data = json.loads(raw)
48
+ candidate = data.get("client_version") if isinstance(data, dict) else None
49
+ if isinstance(candidate, str) and re.fullmatch(r"\d{1,3}\.\d{1,4}\.\d{1,4}", candidate):
50
+ if tuple(map(int, candidate.split("."))) > tuple(map(int, version.split("."))):
51
+ version = candidate
52
+ except (OSError, ValueError):
53
+ pass
54
+ return version
55
+
56
+
57
+ class ContextWindowError(ValueError):
58
+ """Invalid explicit working-window configuration (safe to display)."""
59
+
60
+
61
+ def positive_int(value) -> int | None:
62
+ return value if type(value) is int and 0 < value <= 100_000_000 else None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ModelLimits:
67
+ context: int | None = None
68
+ input: int | None = None
69
+ output: int | None = None
70
+ maximum: int | None = None
71
+ source: str = ""
72
+ fetched_at: float = 0
73
+
74
+ def working_window(self, override: int | None = None) -> int | None:
75
+ # Input ceilings are distinct from the combined input+output context.
76
+ # Codex's default window may be smaller than its opt-in maximum.
77
+ if override is not None:
78
+ limits = [override, self.maximum or self.context, self.input]
79
+ else:
80
+ limits = [self.context, self.input]
81
+ known = [limit for limit in limits if limit is not None]
82
+ return min(known) if known else None
83
+
84
+
85
+ def configured_window() -> int | None:
86
+ value = os.environ.get("PCODE_CONTEXT_WINDOW", "").strip()
87
+ if not value:
88
+ return None
89
+ try:
90
+ window = int(value)
91
+ except ValueError:
92
+ raise ContextWindowError("PCODE_CONTEXT_WINDOW must be a positive token count.") from None
93
+ if window < 4096:
94
+ raise ContextWindowError("PCODE_CONTEXT_WINDOW must be at least 4096 tokens.")
95
+ return window
96
+
97
+
98
+ def parse_catalog(data, fetched_at: float) -> dict[str, ModelLimits]:
99
+ result = {}
100
+ if not isinstance(data, dict):
101
+ return result
102
+ for provider, entry in data.items():
103
+ if not isinstance(provider, str) or not isinstance(entry, dict):
104
+ continue
105
+ models = entry.get("models")
106
+ if not isinstance(models, dict):
107
+ continue
108
+ for name, model in models.items():
109
+ if not isinstance(name, str) or not isinstance(model, dict):
110
+ continue
111
+ limits = model.get("limit")
112
+ if not isinstance(limits, dict):
113
+ continue
114
+ context = positive_int(limits.get("context"))
115
+ input_limit = positive_int(limits.get("input"))
116
+ if context or input_limit:
117
+ result[f"{provider}:{name}"] = ModelLimits(
118
+ context=context,
119
+ input=input_limit,
120
+ output=positive_int(limits.get("output")),
121
+ source="models.dev",
122
+ fetched_at=fetched_at,
123
+ )
124
+ return result
125
+
126
+
127
+ def parse_codex(data, name: str, fetched_at: float) -> ModelLimits | None:
128
+ models = data.get("models") if isinstance(data, dict) else None
129
+ if not isinstance(models, list):
130
+ return None
131
+ for model in models:
132
+ if not isinstance(model, dict) or model.get("slug") != name:
133
+ continue
134
+ context = positive_int(model.get("context_window"))
135
+ maximum = positive_int(model.get("max_context_window"))
136
+ if context:
137
+ return ModelLimits(
138
+ context=context,
139
+ maximum=max(context, maximum or context),
140
+ output=positive_int(model.get("max_output_tokens")),
141
+ source="codex",
142
+ fetched_at=fetched_at,
143
+ )
144
+ return None
145
+
146
+
147
+ def parse_meridian(data, name: str, fetched_at: float) -> ModelLimits | None:
148
+ """Meridian's account-aware /v1/models list (not Anthropic model detail)."""
149
+ models = data.get("data") if isinstance(data, dict) else None
150
+ if not isinstance(models, list):
151
+ return None
152
+ for model in models:
153
+ if not isinstance(model, dict) or model.get("id") != name:
154
+ continue
155
+ context = positive_int(model.get("context_window"))
156
+ if context:
157
+ return ModelLimits(context=context, source="meridian", fetched_at=fetched_at)
158
+ return None
159
+
160
+
161
+ def parse_anthropic(data, fetched_at: float) -> ModelLimits | None:
162
+ if not isinstance(data, dict):
163
+ return None
164
+ input_limit = positive_int(data.get("max_input_tokens"))
165
+ output_limit = positive_int(data.get("max_tokens"))
166
+ if input_limit or output_limit:
167
+ return ModelLimits(
168
+ input=input_limit,
169
+ output=output_limit,
170
+ source="anthropic",
171
+ fetched_at=fetched_at,
172
+ )
173
+ return None
174
+
175
+
176
+ def catalog_routes(data) -> dict[str, str]:
177
+ if not isinstance(data, dict):
178
+ return {}
179
+ return {
180
+ name: entry["api"]
181
+ for name, entry in data.items()
182
+ if isinstance(name, str)
183
+ and isinstance(entry, dict)
184
+ and isinstance(entry.get("api"), str)
185
+ and entry["api"].startswith("https://")
186
+ }
187
+
188
+
189
+ def catalog_key(model: str | Model, routes: dict[str, str] | None = None) -> str | None:
190
+ """Exact provider/model match, never guess from a model's family name."""
191
+ identity = model if isinstance(model, str) else model.model_id
192
+ provider, sep, name = identity.partition(":")
193
+ if not sep:
194
+ return None
195
+ if provider == "claude":
196
+ # Claude Code serves Anthropic's models under their own names.
197
+ return f"anthropic:{name}"
198
+ if not isinstance(model, str):
199
+ endpoint = model.provider
200
+ if endpoint is None:
201
+ return None
202
+ # Some catalogs omit `api` for native SDK providers. These are verified
203
+ # canonical endpoints, not model limits. Other routes come from the feed.
204
+ canonical = {
205
+ "anthropic": "https://api.anthropic.com",
206
+ "openai": "https://api.openai.com/v1",
207
+ "openai-codex": "https://chatgpt.com/backend-api/codex",
208
+ }.get(endpoint.name) or (routes or {}).get(endpoint.name)
209
+ if canonical is None:
210
+ return None
211
+ # Use the client's actual URL, not an SDK default that may hide an
212
+ # injected client/proxy. Require scheme, port and path to match too.
213
+ actual = str(getattr(endpoint.client, "base_url", ""))
214
+ try:
215
+ url, expected = urlsplit(actual), urlsplit(canonical)
216
+ if (
217
+ (url.scheme, url.netloc, url.path.rstrip("/"))
218
+ != (expected.scheme, expected.netloc, expected.path.rstrip("/"))
219
+ or url.query
220
+ or url.fragment
221
+ ):
222
+ return None
223
+ except ValueError:
224
+ return None
225
+ provider = endpoint.name
226
+ # OAuth subscription access is not the ordinary Anthropic API catalog.
227
+ if getattr(model, "_subscription_oauth", False):
228
+ return None
229
+ provider = {"openai-responses": "openai", "openai-chat": "openai"}.get(provider, provider)
230
+ # In particular, openai-codex NEVER maps to openai.
231
+ return f"{provider}:{name}"
232
+
233
+
234
+ @dataclass
235
+ class NativeState:
236
+ limits: ModelLimits | None = None
237
+ attempted_at: float = 0
238
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
239
+
240
+
241
+ class ContextCatalog:
242
+ def __init__(self, cache_path: Path | None = None):
243
+ self.cache_path = cache_path
244
+ self.public: dict[str, ModelLimits] = {}
245
+ self.routes: dict[str, str] = {}
246
+ self._states: dict[int, NativeState] = {}
247
+ self._public_lock = asyncio.Lock()
248
+ self._public_attempt = 0.0
249
+ self._public_updated = 0.0
250
+ self._loaded = False
251
+
252
+ def state(self, model: Model) -> NativeState:
253
+ # Pydantic models are unhashable. Key by identity with weak cleanup, not
254
+ # by model name: credentials and endpoints belong to this exact instance.
255
+ key = id(model)
256
+ if key not in self._states:
257
+ self._states[key] = NativeState()
258
+ finalize(model, self._states.pop, key, None)
259
+ return self._states[key]
260
+
261
+ def _path(self) -> Path:
262
+ if self.cache_path is not None:
263
+ return self.cache_path
264
+ root = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
265
+ return root / "pcode" / "model-context-v1.json"
266
+
267
+ def _load(self) -> None:
268
+ try:
269
+ data = json.loads(self._path().read_text())
270
+ if data.get("version") != 1:
271
+ return
272
+ updated = data["fetched_at"]
273
+ if type(updated) not in (int, float) or not 0 < updated <= time.time():
274
+ return
275
+ entries = data["models"]
276
+ if not isinstance(entries, dict):
277
+ return
278
+ parsed = {}
279
+ for key, row in entries.items():
280
+ if not isinstance(key, str) or not isinstance(row, dict):
281
+ continue
282
+ limits = ModelLimits(
283
+ context=positive_int(row.get("context")),
284
+ input=positive_int(row.get("input")),
285
+ output=positive_int(row.get("output")),
286
+ source="models.dev",
287
+ fetched_at=updated,
288
+ )
289
+ if limits.working_window():
290
+ parsed[key] = limits
291
+ if parsed:
292
+ self.public = parsed
293
+ routes = data.get("routes", {})
294
+ if isinstance(routes, dict):
295
+ self.routes = {
296
+ key: value
297
+ for key, value in routes.items()
298
+ if isinstance(key, str)
299
+ and isinstance(value, str)
300
+ and value.startswith("https://")
301
+ }
302
+ self._public_updated = updated
303
+ except (OSError, ValueError, TypeError, KeyError, AttributeError):
304
+ pass
305
+
306
+ def _save(self) -> None:
307
+ path = self._path()
308
+ temporary = None
309
+ try:
310
+ path.parent.mkdir(parents=True, exist_ok=True)
311
+ with NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
312
+ temporary = Path(file.name)
313
+ json.dump(
314
+ {
315
+ "version": 1,
316
+ "fetched_at": self._public_updated,
317
+ "models": {key: asdict(value) for key, value in self.public.items()},
318
+ "routes": self.routes,
319
+ },
320
+ file,
321
+ )
322
+ temporary.replace(path)
323
+ except OSError:
324
+ pass
325
+ finally:
326
+ if temporary is not None:
327
+ try:
328
+ temporary.unlink(missing_ok=True)
329
+ except OSError:
330
+ pass
331
+
332
+ def limits(self, model: str | Model) -> ModelLimits | None:
333
+ if not isinstance(model, str) and (native := self.state(model).limits) is not None:
334
+ return native
335
+ key = catalog_key(model, self.routes)
336
+ return self.public.get(key) if key else None
337
+
338
+ def window(self, model: str | Model) -> int | None:
339
+ override = configured_window()
340
+ limits = self.limits(model)
341
+ return limits.working_window(override) if limits else override
342
+
343
+ async def refresh_public(self) -> None:
344
+ async with self._public_lock:
345
+ if not self._loaded:
346
+ await asyncio.to_thread(self._load)
347
+ self._loaded = True
348
+ now = time.time()
349
+ if now - self._public_updated < CATALOG_TTL:
350
+ return
351
+ if now - self._public_attempt < RETRY_DELAY:
352
+ return
353
+ self._public_attempt = now
354
+ try:
355
+ # Dedicated anonymous client: never send provider auth to models.dev.
356
+ async with asyncio.timeout(REQUEST_TIMEOUT):
357
+ async with httpx2.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
358
+ response = await client.get(CATALOG_URL)
359
+ response.raise_for_status()
360
+ data = response.json()
361
+ parsed = parse_catalog(data, now)
362
+ if parsed:
363
+ self.public = parsed
364
+ self.routes = catalog_routes(data)
365
+ self._public_updated = now
366
+ await asyncio.to_thread(self._save)
367
+ except Exception:
368
+ # Metadata is optional. Do not expose bodies, credentials, or login
369
+ # failures in the UI; the actual model request owns auth errors.
370
+ pass
371
+
372
+ async def refresh_native(self, model: Model) -> None:
373
+ provider = model.provider
374
+ if provider is None or provider.name not in {"openai-codex", "anthropic", "meridian"}:
375
+ return
376
+ state = self.state(model)
377
+ async with state.lock:
378
+ now = time.time()
379
+ previous = self.state(model).limits
380
+ if previous and now - previous.fetched_at < NATIVE_TTL:
381
+ return
382
+ if now - state.attempted_at < RETRY_DELAY:
383
+ return
384
+ state.attempted_at = now
385
+ try:
386
+ # The SDK client carries the real base URL, proxy, credential
387
+ # rotation and 401 replay. No parallel auth implementation here.
388
+ async with asyncio.timeout(REQUEST_TIMEOUT), model:
389
+ client = provider.client
390
+ options = {"max_retries": 0, "timeout": REQUEST_TIMEOUT}
391
+ if provider.name == "meridian":
392
+ data = await client.get(
393
+ "/v1/models", cast_to=dict[str, Any], options=options
394
+ )
395
+ limits = parse_meridian(data, model.model_name, now)
396
+ elif provider.name == "openai-codex":
397
+ version = await asyncio.to_thread(codex_catalog_version)
398
+ data = await client.get(
399
+ "/models",
400
+ cast_to=dict[str, Any],
401
+ options={
402
+ **options,
403
+ "params": {"client_version": version},
404
+ },
405
+ )
406
+ limits = parse_codex(data, model.model_name, now)
407
+ else:
408
+ headers = {}
409
+ if getattr(model, "_subscription_oauth", False):
410
+ from pcode.auth import OAUTH_BETAS, oauth_user_agent
411
+
412
+ headers = {
413
+ "anthropic-beta": ",".join(sorted(OAUTH_BETAS)),
414
+ "User-Agent": oauth_user_agent(),
415
+ "x-app": "cli",
416
+ }
417
+ # Raw JSON works with installed SDKs predating these fields.
418
+ data = await client.get(
419
+ f"/v1/models/{quote(model.model_name, safe='')}",
420
+ cast_to=dict[str, Any],
421
+ options={**options, "headers": headers},
422
+ )
423
+ limits = parse_anthropic(data, now)
424
+ if limits is not None:
425
+ state.limits = limits
426
+ except Exception:
427
+ pass
428
+
429
+ async def refresh(self, model: str | Model | None) -> None:
430
+ if model is None:
431
+ return
432
+ if isinstance(model, str):
433
+ # Native state belongs to a retained model instance, never a
434
+ # temporary inferred model or a shared provider:name auth cache.
435
+ await self.refresh_public()
436
+ return
437
+ if model.provider is None:
438
+ return
439
+ await asyncio.gather(self.refresh_public(), self.refresh_native(model))
440
+
441
+
442
+ catalog = ContextCatalog()
443
+
444
+
445
+ def context_window(model: str | Model) -> int | None:
446
+ """Synchronous, memory-only lookup shared by the UI and compaction."""
447
+ return catalog.window(model)
448
+
449
+
450
+ async def refresh_context(model: str | Model | None) -> None:
451
+ await catalog.refresh(model)
pcode/model_ui.py ADDED
@@ -0,0 +1,168 @@
1
+ """Searchable, temporary model picker; never owns the main editor's buffer."""
2
+
3
+ import re
4
+
5
+ from prompt_toolkit.application import Application
6
+ from prompt_toolkit.data_structures import Point
7
+ from prompt_toolkit.key_binding import KeyBindings
8
+ from prompt_toolkit.layout import HSplit, Layout, Window
9
+ from prompt_toolkit.layout.controls import FormattedTextControl
10
+ from prompt_toolkit.layout.dimension import Dimension
11
+ from prompt_toolkit.widgets import Label, TextArea
12
+
13
+ from pcode.frame import Dialog
14
+ from pcode.models import PROVIDERS
15
+ from pcode.popup_ui import fuzzy_match, popup_container, popup_style
16
+ from pcode.prefix_keys import PrefixKeys
17
+
18
+
19
+ def matches_model(term: str, model: str) -> bool:
20
+ """Match the id or the provider's display name plus model, e.g. anth + opus."""
21
+ provider, _, name = model.partition(":")
22
+ return any(
23
+ fuzzy_match(term, text)
24
+ for text in (model.casefold(), f"{PROVIDERS.get(provider, provider)} {name}".casefold())
25
+ )
26
+
27
+
28
+ class ModelPicker:
29
+ def __init__(self, models, providers, *, current=None, input=None, output=None, style=None):
30
+ self.models = list(models)
31
+ self.providers = set(providers)
32
+ self.current = current
33
+ self.selected = 0
34
+ self.matches = list(models)
35
+ self.search = TextArea(height=1, prompt="Filter: ", multiline=False)
36
+ self.search.buffer.on_text_changed += self.filter
37
+ rows = FormattedTextControl(
38
+ self.fragments, get_cursor_position=lambda: Point(x=0, y=self.selected)
39
+ )
40
+ self.rows = Window(
41
+ rows,
42
+ height=Dimension(min=3, max=14),
43
+ dont_extend_height=True,
44
+ wrap_lines=False,
45
+ always_hide_cursor=True,
46
+ )
47
+ keys = KeyBindings()
48
+ shortcuts = self.shortcuts = PrefixKeys()
49
+ shortcuts.set_help(
50
+ lambda: [
51
+ ("Type", "Filter models or enter provider:model-id"),
52
+ ("↑/↓ / Ctrl+P/N", "Select model"),
53
+ ("PgUp/PgDn", "Page"),
54
+ ("Ctrl+U/D", "Half page"),
55
+ ("Enter", "Apply selected model"),
56
+ ("Esc / Ctrl+C / Ctrl+L", "Cancel"),
57
+ ]
58
+ )
59
+
60
+ @keys.add("up", eager=True)
61
+ @keys.add("c-p", eager=True)
62
+ def previous(event):
63
+ self.move(-1)
64
+
65
+ @keys.add("down", eager=True)
66
+ @keys.add("c-n", eager=True)
67
+ def next_model(event):
68
+ self.move(1)
69
+
70
+ # The same paging keys as every other popup, moving the selection.
71
+ @keys.add("pageup", eager=True)
72
+ def previous_page(event):
73
+ self.move(-self.page())
74
+
75
+ @keys.add("pagedown", eager=True)
76
+ def next_page(event):
77
+ self.move(self.page())
78
+
79
+ @keys.add("c-u", eager=True)
80
+ def previous_half(event):
81
+ self.move(-max(1, (self.page() + 1) // 2))
82
+
83
+ @keys.add("c-d", eager=True)
84
+ def next_half(event):
85
+ self.move(max(1, (self.page() + 1) // 2))
86
+
87
+ @keys.add("enter", eager=True)
88
+ def accept(event):
89
+ if self.matches:
90
+ event.app.exit(result=self.matches[self.selected])
91
+
92
+ @keys.add("escape", eager=True)
93
+ @keys.add("c-c")
94
+ @keys.add("c-l")
95
+ def cancel(event):
96
+ event.app.exit(result=None)
97
+
98
+ dialog = Dialog(
99
+ title="Choose model",
100
+ body=HSplit(
101
+ [
102
+ self.search,
103
+ self.rows,
104
+ Label(
105
+ "Type provider:model-id for a custom model.",
106
+ dont_extend_height=True,
107
+ ),
108
+ Label(shortcuts.summary, dont_extend_height=True),
109
+ ],
110
+ padding=1,
111
+ ),
112
+ )
113
+ self.app = Application(
114
+ layout=Layout(popup_container(dialog, shortcuts), focused_element=self.search),
115
+ key_bindings=shortcuts.key_bindings(keys),
116
+ full_screen=True,
117
+ input=input,
118
+ output=output,
119
+ style=popup_style(style),
120
+ )
121
+
122
+ def move(self, rows: int) -> None:
123
+ self.selected = max(0, min(len(self.matches) - 1, self.selected + rows))
124
+
125
+ def page(self) -> int:
126
+ """Rows actually visible, falling back before the first render."""
127
+ info = self.rows.render_info
128
+ return max(1, info.window_height - 1) if info is not None else 10
129
+
130
+ def filter(self, buffer):
131
+ query = buffer.text.strip()
132
+ terms = query.casefold().split()
133
+ self.matches = [name for name in self.models if all(matches_model(t, name) for t in terms)]
134
+ provider, _, name = query.partition(":")
135
+ # Any supported provider, not only configured ones: the key may be
136
+ # exported after launch, and infer_model reports a missing key clearly.
137
+ if (
138
+ provider in PROVIDERS
139
+ and name
140
+ and re.fullmatch(r"[A-Za-z0-9_.:/-]+", name)
141
+ and query not in self.matches
142
+ ):
143
+ self.matches.insert(0, query)
144
+ self.selected = 0
145
+
146
+ def fragments(self):
147
+ if not self.matches:
148
+ return [("class:plan", "No matches. Try another filter or provider:model-id.")]
149
+ result = []
150
+ for index, name in enumerate(self.matches):
151
+ provider, _, model = name.partition(":")
152
+ marker = " · current" if name == self.current else ""
153
+ if name not in self.models:
154
+ marker += " · custom"
155
+ style = "class:selected" if index == self.selected else ""
156
+ result.append(
157
+ (
158
+ style,
159
+ f"{'›' if index == self.selected else ' '} "
160
+ f"{PROVIDERS.get(provider, provider)} · {model}{marker}",
161
+ )
162
+ )
163
+ if index < len(self.matches) - 1:
164
+ result.append(("", "\n"))
165
+ return result
166
+
167
+ async def run(self):
168
+ return await self.app.run_async()