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/preferences.py ADDED
@@ -0,0 +1,1176 @@
1
+ """Non-secret, user-level defaults, separate from conversation storage."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shlex
7
+ import shutil
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from filelock import FileLock
13
+ from pygments.styles import get_all_styles
14
+ from rich.cells import cell_len
15
+ from rich.spinner import SPINNERS as RICH_SPINNERS
16
+
17
+ from pcode.delta import LAYOUTS as DIFF_LAYOUTS
18
+ from pcode.profiling import PROFILE_MODES
19
+ from pcode.transcript_log import CHAR_BUDGET
20
+
21
+ # Every Pygments style installed here, including any added by a plugin package.
22
+ # The scan costs a few milliseconds once; Rich imports Pygments regardless.
23
+ # `terminal` is not a Pygments style: it hands every color, code included, to
24
+ # the terminal's own ANSI palette, so pcode matches whatever scheme it runs in.
25
+ TERMINAL_SYNTAX = "terminal"
26
+ SYNTAX_THEMES = (TERMINAL_SYNTAX, *sorted(get_all_styles()))
27
+
28
+
29
+ def _steady_width(frames: list[str]) -> bool:
30
+ """Whether every frame takes the same few cells, so the row never shifts.
31
+
32
+ Emoji spinners are out: terminals disagree on their width, and the
33
+ variation-selector ones (`arrow2`) measure one cell but draw two.
34
+ """
35
+ widths = {cell_len(frame) for frame in frames}
36
+ return (
37
+ len(widths) == 1
38
+ and widths <= {1, 2, 3}
39
+ and all(ord(char) < 0x1F000 and char != "\ufe0f" for frame in frames for char in frame)
40
+ )
41
+
42
+
43
+ # Rich's named spinners that fit the status row: `pcode config set spinner NAME`.
44
+ SPINNERS = tuple(
45
+ sorted(name for name, spec in RICH_SPINNERS.items() if _steady_width(spec["frames"]))
46
+ )
47
+
48
+ EFFORTS = ("low", "medium", "high", "xhigh", "default")
49
+ # `/show-thinking` and Ctrl+T, in cycling order.
50
+ THINKING_MODES = ("off", "status-line", "scrollback")
51
+ OPENAI_PROVIDERS = ("openai", "openai-chat", "openai-responses", "openai-codex")
52
+ # Routes that reach a Claude model and so take Anthropic's own effort setting.
53
+ ANTHROPIC_PROVIDERS = ("anthropic", "meridian", "claude")
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Setting:
58
+ default: str | None
59
+ choices: tuple[str, ...] = ()
60
+ positive_integer: bool = False
61
+ # A count that may be zero, so it cannot reuse positive_integer's floor.
62
+ whole_number: bool = False
63
+ # An os.pathsep-separated list of directories; empty means "none".
64
+ path_list: bool = False
65
+ # A comma-separated list of names; empty meaning depends on the setting.
66
+ name_list: bool = False
67
+ # Terminal rows as a whole number, or a share of the screen below 1 (0.5).
68
+ height: bool = False
69
+ # `ctrl`, or the leader key(s) pressed before a shortcut's letter.
70
+ key_prefix: bool = False
71
+ # Escape, or literal printable characters to leave vi insert mode.
72
+ vi_escape_sequence: bool = False
73
+ # An additional, printable leader used only in vi normal mode.
74
+ vi_key_prefix: bool = False
75
+ # Tokens as `200000`, `200k`, or `1.5m`; see parse_token_count.
76
+ token_count: bool = False
77
+ # Command-line arguments, split as a shell would; empty means none.
78
+ arguments: bool = False
79
+ # One line shown beside the key in /config completions.
80
+ description: str = ""
81
+
82
+ def validate(self, key: str, value: str) -> None:
83
+ if self.arguments:
84
+ try:
85
+ shlex.split(value)
86
+ except ValueError as error:
87
+ raise ValueError(f"{key} must be shell-style arguments: {error}.") from None
88
+ elif self.path_list:
89
+ if value and any(not entry.strip() for entry in value.split(os.pathsep)):
90
+ raise ValueError(f"{key} must be directories separated by '{os.pathsep}'.")
91
+ elif self.name_list:
92
+ if value and any(
93
+ not entry.strip() or any(char.isspace() for char in entry.strip())
94
+ for entry in value.split(",")
95
+ ):
96
+ raise ValueError(f"{key} must be comma-separated names without whitespace.")
97
+ if key == "model_providers":
98
+ from pcode.models import PROVIDERS
99
+
100
+ unknown = {
101
+ entry.strip() for entry in value.split(",") if entry.strip()
102
+ } - PROVIDERS.keys()
103
+ if unknown:
104
+ raise ValueError(f"Unknown model providers: {', '.join(sorted(unknown))}")
105
+ elif self.vi_escape_sequence:
106
+ if not value or not value.isprintable() or any(char.isspace() for char in value):
107
+ raise ValueError(
108
+ f"{key} must be escape or printable characters without whitespace."
109
+ )
110
+ elif self.vi_key_prefix:
111
+ parse_vi_key_prefix(value)
112
+ elif self.key_prefix:
113
+ parse_key_prefix(value)
114
+ elif self.token_count:
115
+ parse_token_count(value)
116
+ elif self.height:
117
+ if parse_height(value) is None:
118
+ raise ValueError(
119
+ f"{key} must be a whole number of rows or a fraction of the screen "
120
+ "between 0 and 1 (0.5 is half)."
121
+ )
122
+ elif (self.positive_integer or self.whole_number) and value not in self.choices:
123
+ # `choices` beside a number are its named values, such as "off".
124
+ floor = 0 if self.whole_number else 1
125
+ if not value.isascii() or not value.isdecimal() or int(value) < floor:
126
+ raise ValueError(
127
+ f"{key} must be a whole number (0 or more)"
128
+ + "".join(f" or {choice}" for choice in self.choices)
129
+ + "."
130
+ if self.whole_number
131
+ else f"{key} must be a positive integer."
132
+ )
133
+ elif self.choices:
134
+ if value not in self.choices:
135
+ raise ValueError(f"{key} must be one of: {', '.join(self.choices)}")
136
+ elif not value or any(char.isspace() for char in value):
137
+ raise ValueError(f"{key} must be a non-empty model name without whitespace.")
138
+
139
+
140
+ # Compaction keeps up to an eighth of its budget verbatim plus a summary, on top
141
+ # of the fixed prompt (instructions, tool schemas, MCP), which alone can pass
142
+ # 10k. A cap much below this compacts every few requests or cannot make room.
143
+ MIN_AUTO_COMPACT_TOKENS = 50_000
144
+
145
+
146
+ def parse_token_count(text: str) -> int:
147
+ """Read `200000`, `200k`, or `1.5m` as an automatic compaction cap."""
148
+ value = text.strip().lower().replace("_", "").replace(",", "")
149
+ scale = {"k": 1_000, "m": 1_000_000}.get(value[-1:], 1)
150
+ if scale != 1:
151
+ value = value[:-1]
152
+ try:
153
+ tokens = int(float(value) * scale)
154
+ except (ValueError, OverflowError):
155
+ raise ValueError(f"Not a token count: {text!r}. Use e.g. 200000 or 200k.") from None
156
+ if tokens < MIN_AUTO_COMPACT_TOKENS:
157
+ raise ValueError(
158
+ f"Use at least {MIN_AUTO_COMPACT_TOKENS // 1000}k tokens: below that, compaction "
159
+ "cannot leave enough room."
160
+ )
161
+ return tokens
162
+
163
+
164
+ def parse_height(value: str | None) -> float | None:
165
+ """Whole rows (12), or a share of the screen below 1 (0.5); None if invalid."""
166
+ if not value:
167
+ return None
168
+ try:
169
+ height = float(value)
170
+ except ValueError:
171
+ return None
172
+ if height >= 1:
173
+ return height if value.isascii() and value.isdecimal() else None
174
+ return height if height > 0 else None
175
+
176
+
177
+ def screen_rows(height: float, rows: int) -> int:
178
+ """A `parse_height` value as rows of a screen `rows` tall, never more than it has."""
179
+ return min(rows, int(rows * height) if height < 1 else int(height))
180
+
181
+
182
+ # Ctrl chords every surface keeps for itself, or that the terminal sends as
183
+ # another key; a leader on one of them would take away something essential.
184
+ RESERVED_CHORDS = {
185
+ "c-c": "cancels and closes popups",
186
+ "c-d": "exits and half-pages",
187
+ "c-h": "is Backspace",
188
+ "c-i": "is Tab",
189
+ "c-j": "inserts a newline",
190
+ "c-m": "is Enter",
191
+ "c-[": "is Escape",
192
+ # Terminals send Ctrl+/ as Ctrl+_ (0x1f).
193
+ "c-_": "opens keybinding help (Ctrl+/)",
194
+ }
195
+ _CTRL_HEADS = ("ctrl+", "ctrl-", "control+", "control-", "c-")
196
+ _CTRL_NAMES = {"space": "@", "spc": "@"}
197
+ _CTRL_SYMBOLS = "@]\\^_"
198
+
199
+
200
+ def _prefix_key(token: str) -> str:
201
+ """One leader key as prompt_toolkit names it: ctrl+p is c-p, ctrl+space is c-@."""
202
+ text = token.casefold()
203
+ head = next((head for head in _CTRL_HEADS if text.startswith(head)), None)
204
+ if head is not None:
205
+ rest = _CTRL_NAMES.get(text[len(head) :], text[len(head) :])
206
+ if len(rest) == 1 and ("a" <= rest <= "z" or rest in _CTRL_SYMBOLS):
207
+ key = f"c-{rest}"
208
+ if key in RESERVED_CHORDS:
209
+ raise ValueError(
210
+ f"key_prefix cannot use {token}: it {RESERVED_CHORDS[key]} everywhere."
211
+ )
212
+ return key
213
+ elif text.startswith("f") and text[1:].isdecimal() and 1 <= int(text[1:]) <= 24:
214
+ return text
215
+ raise ValueError(
216
+ f"key_prefix: {token!r} is not a key pcode can use as a leader. "
217
+ "Use ctrl, or keys such as ctrl+p, ctrl+space, ctrl+] or f2."
218
+ )
219
+
220
+
221
+ def parse_vi_key_prefix(value: str) -> tuple[str, ...]:
222
+ """An optional single printable leader, separate from the global prefix."""
223
+ if value == "off":
224
+ return ()
225
+ if value == "<space>":
226
+ return (" ",)
227
+ if len(value) == 1 and value.isprintable() and not value.isspace():
228
+ return (value,)
229
+ raise ValueError(
230
+ "vi_key_prefix must be off, <space>, or one printable non-whitespace character."
231
+ )
232
+
233
+
234
+ def parse_key_prefix(value: str) -> tuple[str, ...]:
235
+ """`ctrl` is () and makes each shortcut a Ctrl chord; otherwise the leader's keys.
236
+
237
+ Several keys, separated by spaces, form one leader pressed in sequence
238
+ (`ctrl+x ctrl+p`), the way Emacs spells a prefix. Raises ValueError.
239
+ """
240
+ tokens = value.split()
241
+ if [token.casefold() for token in tokens] == ["ctrl"]:
242
+ return ()
243
+ if not tokens:
244
+ raise ValueError("key_prefix must be ctrl or a leader key such as ctrl+p.")
245
+ return tuple(_prefix_key(token) for token in tokens)
246
+
247
+
248
+ SEND_MODES = ("steering", "queue", "interrupt")
249
+ ANTHROPIC_AUTH_SOURCES = ("api-key", "oauth")
250
+
251
+ # A user-level directory shared across workspaces, plus the workspace's own
252
+ # `.agents/skills`, which the asset-root scan already covers but which belongs in
253
+ # the listed default so replacing the list is an informed choice.
254
+ DEFAULT_SKILL_DIRS = os.pathsep.join(("~/.agents/skills", ".agents/skills"))
255
+
256
+
257
+ SETTINGS = {
258
+ "model_providers": Setting(
259
+ "",
260
+ name_list=True,
261
+ description="Limit /model to comma-separated providers; empty shows all active providers",
262
+ ),
263
+ "send_mode": Setting(
264
+ "steering",
265
+ SEND_MODES,
266
+ description="Enter mid-turn: steer the running turn, queue for after, or interrupt",
267
+ ),
268
+ # PCODE_ANTHROPIC_AUTH still wins over the saved choice.
269
+ "anthropic_auth": Setting(
270
+ None,
271
+ ANTHROPIC_AUTH_SOURCES,
272
+ description="Anthropic credential /login selected; env PCODE_ANTHROPIC_AUTH overrides",
273
+ ),
274
+ "meridian_managed": Setting(
275
+ "auto",
276
+ ("auto", "on", "off"),
277
+ description=(
278
+ "Meridian: auto uses a running proxy, else starts a private one; "
279
+ "on always starts one; off uses the shared proxy only"
280
+ ),
281
+ ),
282
+ "repo_context_walk_up": Setting(
283
+ "on",
284
+ ("on", "off"),
285
+ description="Also load AGENTS.md files from directories above the workspace",
286
+ ),
287
+ "repo_context_nested": Setting(
288
+ "off",
289
+ ("off", "pointer", "contents"),
290
+ description="Nested AGENTS.md found while reading: ignore, mention its path, or inject it",
291
+ ),
292
+ "skill_commands": Setting(
293
+ "prefix",
294
+ ("prefix", "bare", "both", "off"),
295
+ description="SKILL.md assets as slash commands: /skill:NAME, /NAME, both, or none",
296
+ ),
297
+ # Relative entries resolve against the workspace; `~` expands to the user's home.
298
+ "skill_dirs": Setting(
299
+ DEFAULT_SKILL_DIRS,
300
+ path_list=True,
301
+ description=f"Extra skill directories, '{os.pathsep}'-separated, after workspace roots",
302
+ ),
303
+ # Extensions run arbitrary Python at launch, so a workspace's `.pcode/extensions`
304
+ # is opt-in; the user-level directory beside preferences.json always loads.
305
+ # `on` trusts every repository; `trusted_projects` is the per-repository grant
306
+ # the launch prompt appends to (primary checkout paths).
307
+ "project_extensions": Setting(
308
+ "off",
309
+ ("on", "off"),
310
+ description="Load every workspace's .pcode/extensions without asking (runs code at launch)",
311
+ ),
312
+ "trusted_projects": Setting(
313
+ "",
314
+ path_list=True,
315
+ description="Checkouts whose .pcode/extensions load without asking (launch prompt appends)",
316
+ ),
317
+ "extension_dirs": Setting(
318
+ "",
319
+ path_list=True,
320
+ description=f"Extra extension directories, '{os.pathsep}'-separated, after the user one",
321
+ ),
322
+ # Which discovered extensions run, by name. An extension loads unless it is
323
+ # named in `extensions_off`, or declares `DEFAULT_ENABLED = False` and is not
324
+ # named in `extensions_on`. `/extensions on|off NAME` writes both.
325
+ "extensions_off": Setting(
326
+ "",
327
+ name_list=True,
328
+ description="Extensions never loaded, comma-separated (/extensions off NAME)",
329
+ ),
330
+ "extensions_on": Setting(
331
+ "",
332
+ name_list=True,
333
+ description="Opt-in extensions to load, comma-separated (/extensions on NAME)",
334
+ ),
335
+ # `--worktree [NAME]` and `--no-worktree` override per run.
336
+ "worktree": Setting(
337
+ "off",
338
+ ("on", "off"),
339
+ description="Start each session in its own .worktrees/<session> git worktree",
340
+ ),
341
+ "worker_isolation": Setting(
342
+ "off",
343
+ ("on", "off"),
344
+ description="Isolate built-in worker tasks in git worktrees (requires worktree=on)",
345
+ ),
346
+ "worker_concurrency": Setting(
347
+ "0",
348
+ whole_number=True,
349
+ description="Concurrent worker cap; 0 is unlimited (reload to apply)",
350
+ ),
351
+ "subagent_models": Setting(
352
+ "",
353
+ name_list=True,
354
+ description="Models delegate_task lists for sub-agents, comma-separated (/subagents)",
355
+ ),
356
+ # An untouched worktree is always removed; uncommitted changes are always kept.
357
+ "session_host": Setting(
358
+ "on",
359
+ ("on", "off"),
360
+ description="Run each session in a background host that outlives the terminal (/switch); "
361
+ "off runs it inside the terminal",
362
+ ),
363
+ # 0 by default: a stopped host costs nothing, since /switch still lists one
364
+ # with an unseen result and resumes it.
365
+ "session_host_idle_minutes": Setting(
366
+ "0",
367
+ ("off",),
368
+ whole_number=True,
369
+ description="Stop a background session idle this long with no terminal; "
370
+ "0 stops it as soon as it is idle, off never",
371
+ ),
372
+ # `pcode --email-listen` (pcode.email_remote). All user-only: a repository
373
+ # must not choose who controls it by email or loosen the limits.
374
+ "email_owner": Setting(
375
+ None, description="The Gmail address that may control pcode by email (--email-setup)"
376
+ ),
377
+ "email_mcp": Setting(
378
+ "off", ("off", "on"), description="Start default MCP servers in email-started sessions"
379
+ ),
380
+ "email_turn_minutes": Setting(
381
+ "30", whole_number=True, description="Wall-clock limit per email turn; 0 is none"
382
+ ),
383
+ "email_turn_requests": Setting(
384
+ "100", whole_number=True, description="Model requests per email turn; 0 is no limit"
385
+ ),
386
+ "email_turn_tool_calls": Setting(
387
+ "100", whole_number=True, description="Tool calls per email turn; 0 is no limit"
388
+ ),
389
+ "email_concurrent_sessions": Setting(
390
+ "2", positive_integer=True, description="Email sessions working at once"
391
+ ),
392
+ "email_max_sessions": Setting(
393
+ "20", positive_integer=True, description="Sessions one --email-listen may start"
394
+ ),
395
+ "email_session_inputs": Setting(
396
+ "20", positive_integer=True, description="Emails waiting per session"
397
+ ),
398
+ "email_max_inputs": Setting(
399
+ "100", positive_integer=True, description="Emails waiting across every session"
400
+ ),
401
+ # Defaults mirror claude_sdk.session_pool.MAX_IDLE_SESSIONS and IDLE_SECONDS.
402
+ "claude_idle_processes": Setting(
403
+ "1",
404
+ whole_number=True,
405
+ description="claude: models: finished Claude Code processes kept warm (~120 MB each); "
406
+ "0 stops each after its turn",
407
+ ),
408
+ "claude_idle_minutes": Setting(
409
+ "10",
410
+ positive_integer=True,
411
+ description="claude: models: minutes a finished Claude Code process is kept warm",
412
+ ),
413
+ "desktop_notifications": Setting(
414
+ "on",
415
+ ("on", "off"),
416
+ description="Desktop notification when a background session finishes (OSC 9)",
417
+ ),
418
+ "session_naming": Setting(
419
+ "on",
420
+ ("on", "off"),
421
+ description="Title each new session from its first prompt, asking its own model "
422
+ "at low effort; /rename overrides",
423
+ ),
424
+ "terminal_title": Setting(
425
+ "on",
426
+ ("on", "off"),
427
+ description="Set the terminal tab title to the session's name (OSC 0)",
428
+ ),
429
+ "terminal_progress": Setting(
430
+ "auto",
431
+ ("auto", "on", "off"),
432
+ description="Tab progress bar while a turn runs (OSC 9;4); auto sends it only to "
433
+ "terminals known to draw it",
434
+ ),
435
+ "worktree_exit": Setting(
436
+ "ask",
437
+ ("ask", "merge", "keep"),
438
+ description="Worktree with unmerged commits on exit: ask, merge and remove, or keep",
439
+ ),
440
+ "retry_attempts": Setting(
441
+ "3",
442
+ whole_number=True,
443
+ description="Automatic retries after a dropped connection; 0 disables",
444
+ ),
445
+ # Pydantic AI's default of 1 ends the turn on a second malformed call, which a
446
+ # long `replacements` array can hit by itself.
447
+ "tool_retries": Setting(
448
+ "3",
449
+ whole_number=True,
450
+ description="Corrections offered to the model per turn when a tool call fails validation",
451
+ ),
452
+ # Anthropic mangles the `replacements` array roughly one call in ten. Strict
453
+ # mode was meant to make that unsamplable but measurably does not (see
454
+ # `pcode.strict_tools`); it stays on because nothing got worse. Models that
455
+ # cannot honor it, and schemas outside the subset it accepts, decline it on
456
+ # their own, so "on" costs nothing where it does not apply.
457
+ "strict_tools": Setting(
458
+ "on",
459
+ ("on", "off"),
460
+ description="Constrain edit_file arguments with Anthropic strict tool use",
461
+ ),
462
+ "cache_notices": Setting(
463
+ "on",
464
+ ("on", "off"),
465
+ description="Note in the transcript when a request reuses less of the prompt cache",
466
+ ),
467
+ "debug": Setting(
468
+ "off",
469
+ ("on", "off"),
470
+ description="Write prompt-cache request fingerprints with each cache notice",
471
+ ),
472
+ # Captures land in the state directory, oldest pruned; `--no-profile` skips
473
+ # one run and `--profile DIR` still names its own directory.
474
+ "profile": Setting(
475
+ "off",
476
+ PROFILE_MODES,
477
+ description="Record each session's resource use; cpu/memory add a slow tracer",
478
+ ),
479
+ # A heartbeat task and one polling thread; cheap enough to leave on so the
480
+ # freeze nobody expected is already logged when it happens.
481
+ "stall_log": Setting(
482
+ "on",
483
+ ("on", "off"),
484
+ description="Log what blocked the editor whenever typing stalls ~150 ms or more",
485
+ ),
486
+ "transcript_max_chars": Setting(
487
+ str(CHAR_BUDGET),
488
+ positive_integer=True,
489
+ description="Retained transcript text budget for resume and redraw (characters)",
490
+ ),
491
+ "error_scrollback_lines": Setting(
492
+ "20",
493
+ positive_integer=True,
494
+ description="Lines of an error notice kept in scrollback before it is clipped",
495
+ ),
496
+ "tool_error_scrollback": Setting(
497
+ "off",
498
+ ("on", "off"),
499
+ description="Keep a failed tool call's full diagnostic in scrollback, not one line",
500
+ ),
501
+ "regenerate_on_resize": Setting(
502
+ "on",
503
+ ("on", "off"),
504
+ description="Rebuild scrollback at the new width when the terminal resizes",
505
+ ),
506
+ "paced_scrollback": Setting(
507
+ "typed",
508
+ ("typed", "rows", "off"),
509
+ description="Type settled prose out, or roll blocks in by row, instead of landing at once",
510
+ ),
511
+ "show_edits": Setting(
512
+ "on",
513
+ ("on", "off"),
514
+ description="Show a diff preview of each file edit in the transcript",
515
+ ),
516
+ # Layout-only: /config applies it at once.
517
+ "live_edits": Setting(
518
+ "off",
519
+ ("on", "off"),
520
+ description="Preview an edit or run_code snippet at the bottom while the model writes it",
521
+ ),
522
+ # Read at launch, like show_edits.
523
+ "diff_renderer": Setting(
524
+ "delta",
525
+ ("delta", "rich"),
526
+ description="Diffs in scrollback and /diffs: delta when installed (else rich), or rich",
527
+ ),
528
+ "delta_args": Setting(
529
+ "",
530
+ arguments=True,
531
+ description="delta arguments, e.g. '--line-numbers'; git config is ignored, and these "
532
+ "win over pcode's own",
533
+ ),
534
+ "diff_layout": Setting(
535
+ "unified",
536
+ DIFF_LAYOUTS,
537
+ description="delta layout: unified, side-by-side, or auto (side-by-side at 180+ columns)",
538
+ ),
539
+ "diff_dedent": Setting(
540
+ "on",
541
+ ("on", "off"),
542
+ description="Strip the indentation every line of a diff hunk shares, delta or rich",
543
+ ),
544
+ "show_commands": Setting(
545
+ "off",
546
+ ("on", "off"),
547
+ description=(
548
+ "Mirror shell command output into scrollback (the g shortcut, Ctrl+G by default, "
549
+ "toggles it)"
550
+ ),
551
+ ),
552
+ "group_tools": Setting(
553
+ "on",
554
+ ("on", "off"),
555
+ description="Fold each run of consecutive tool calls into one summary line",
556
+ ),
557
+ "command_scrollback_lines": Setting(
558
+ "20",
559
+ positive_integer=True,
560
+ description="Lines of shell output mirrored into scrollback per command",
561
+ ),
562
+ # The pinned live preview competes with the editor for screen space, so it
563
+ # caps separately from the scrollback mirror.
564
+ "command_preview_lines": Setting(
565
+ "10",
566
+ height=True,
567
+ description="Lines of the pinned live preview of a running shell command, "
568
+ "or 0.25 for a quarter of the screen",
569
+ ),
570
+ "show_tasks": Setting(
571
+ "on", ("on", "off"), description="Show the model's plan as a pinned task list"
572
+ ),
573
+ "autohide_tasks": Setting(
574
+ "off", ("on", "off"), description="Hide the task list when a turn ends"
575
+ ),
576
+ # Checked against each terminal's own pane on every resize, so a split
577
+ # hides the list and unsplitting brings it back; 0 never hides.
578
+ "tasks_min_rows": Setting(
579
+ "30",
580
+ whole_number=True,
581
+ description="Hide the task list in a pane shorter than this (0: never)",
582
+ ),
583
+ "tasks_min_columns": Setting(
584
+ "100",
585
+ whole_number=True,
586
+ description="Hide the task list in a pane narrower than this (0: never)",
587
+ ),
588
+ "show_hints": Setting(
589
+ "on",
590
+ ("on", "off"),
591
+ description="Show the contextual keybindings indicator in the prompt status line",
592
+ ),
593
+ "attach_tasks": Setting(
594
+ "on", ("on", "off"), description="Draw the task list inside the editor box"
595
+ ),
596
+ "task_style": Setting(
597
+ "status",
598
+ ("status", "icons"),
599
+ description="Task rows: shade text by status, or one weight with coloured icons",
600
+ ),
601
+ "tool_glyphs": Setting(
602
+ "auto",
603
+ ("auto", "on", "off"),
604
+ description="Symbols on the tool row; off spells out verbs but shell's $ "
605
+ "(auto: off on the Linux console)",
606
+ ),
607
+ "tool_max_lines": Setting(
608
+ "3",
609
+ positive_integer=True,
610
+ description="Max tool calls listed above the status row, newest last; "
611
+ "a short pane shows fewer",
612
+ ),
613
+ "tool_linger_seconds": Setting(
614
+ "10",
615
+ whole_number=True,
616
+ description="Seconds a finished tool call stays listed above the status row (0: until "
617
+ "newer calls push it out)",
618
+ ),
619
+ "tasks_max_height": Setting(
620
+ None,
621
+ height=True,
622
+ description="Max height of the task list plus editor: rows, or 0.5 for half the screen",
623
+ ),
624
+ # Read when the prompt is built, so it applies on the next launch.
625
+ "spinner": Setting(
626
+ "dots",
627
+ SPINNERS,
628
+ description="Animation on the status row while the model works (a Rich spinner name)",
629
+ ),
630
+ # Distinct from `spinner` so a running command never reads as the model.
631
+ "tool_spinner": Setting(
632
+ "arc",
633
+ SPINNERS,
634
+ description="Animation on a running tool call's row (a Rich spinner name)",
635
+ ),
636
+ "show_thinking": Setting(
637
+ "status-line",
638
+ THINKING_MODES,
639
+ description="Where the model's thinking shows: its own rows above the status row, "
640
+ "streamed into scrollback, or nowhere",
641
+ ),
642
+ # Read when the prompt is built, so it applies on the next launch.
643
+ "thinking_max_lines": Setting(
644
+ "10",
645
+ height=True,
646
+ description="Max rows of thinking above the status row (status-line mode), "
647
+ "or 0.2 for a fifth of the screen; a fixed count is capped at a quarter of it",
648
+ ),
649
+ "editing_mode": Setting(
650
+ "emacs", ("emacs", "vi"), description="Key bindings for the prompt editor"
651
+ ),
652
+ "vi_key_prefix": Setting(
653
+ "off",
654
+ vi_key_prefix=True,
655
+ description="Additional vi normal-mode shortcut leader: off, <space>, or one printable "
656
+ "character (next launch)",
657
+ ),
658
+ "vi_escape_sequence": Setting(
659
+ "escape",
660
+ vi_escape_sequence=True,
661
+ description="Leave vi insert mode: escape or a literal sequence such as jj; "
662
+ "Escape always works (next launch)",
663
+ ),
664
+ "theme": Setting(
665
+ "auto",
666
+ ("dark", "light", "auto"),
667
+ description="Palette for the terminal background; auto detects it",
668
+ ),
669
+ # Chosen per palette so `theme auto` keeps highlighting legible on either
670
+ # background. `terminal` uses the terminal's ANSI colors for everything.
671
+ "syntax_dark": Setting(
672
+ TERMINAL_SYNTAX,
673
+ SYNTAX_THEMES,
674
+ description="terminal (ANSI colors) or a Pygments style, for the dark palette",
675
+ ),
676
+ "syntax_light": Setting(
677
+ TERMINAL_SYNTAX,
678
+ SYNTAX_THEMES,
679
+ description="terminal (ANSI colors) or a Pygments style, for the light palette",
680
+ ),
681
+ "autocompact": Setting(
682
+ "on",
683
+ ("on", "off"),
684
+ description="Compact the conversation automatically as the context window fills",
685
+ ),
686
+ "autocompact_tokens": Setting(
687
+ None,
688
+ token_count=True,
689
+ description="Compact automatically by this many context tokens (e.g. 200k), even if "
690
+ "the window has more room; unset uses about 90% of the window",
691
+ ),
692
+ # On by default so the wheel scrolls popups; plain drag-to-select then needs
693
+ # a modifier. Read when a popup opens, so no restart is needed.
694
+ # Read as each popup opens; the main prompt picks it up on the next launch.
695
+ "key_prefix": Setting(
696
+ "ctrl",
697
+ key_prefix=True,
698
+ description=(
699
+ "Shortcut prefix: ctrl for Ctrl+key chords, or a leader such as ctrl+p "
700
+ "pressed before the key"
701
+ ),
702
+ ),
703
+ "popup_mouse": Setting(
704
+ "on",
705
+ ("on", "off"),
706
+ description="Mouse clicks and wheel scrolling in popups; off keeps native text selection",
707
+ ),
708
+ # Read when a side answer settles, so `/config set` applies without a restart.
709
+ "btw_auto_open": Setting(
710
+ "on",
711
+ ("on", "off"),
712
+ description="Open the side-answer viewer as soon as a /btw answer is ready",
713
+ ),
714
+ # Read when a job finishes, so `/config set` applies without a restart.
715
+ "job_wake": Setting(
716
+ "on",
717
+ ("on", "off"),
718
+ description="Start a turn when a job the model backgrounded finishes while idle",
719
+ ),
720
+ # Edits, plans, shell, and delegation stay native so their transcript display survives.
721
+ "code_mode": Setting(
722
+ "off",
723
+ ("on", "off"),
724
+ description="Batch read-only tool calls through one sandboxed run_code snippet",
725
+ ),
726
+ # Read by the bundled web_research extension. Applies on /reload or next launch.
727
+ "web_search": Setting(
728
+ "auto",
729
+ ("auto", "local", "off"),
730
+ description="Web tools: provider-native when available, local-only, or none",
731
+ ),
732
+ "tool_output_mode": Setting(
733
+ "spill",
734
+ ("spill", "truncate", "off"),
735
+ description="Large tool results: spill to a file with a handle, truncate, or keep whole",
736
+ ),
737
+ "tool_output_threshold": Setting(
738
+ "10000",
739
+ positive_integer=True,
740
+ description="Characters a tool result may have before it is spilled or truncated",
741
+ ),
742
+ "tool_output_preview_chars": Setting(
743
+ "1000",
744
+ positive_integer=True,
745
+ description="Characters of a spilled result shown inline beside its handle",
746
+ ),
747
+ "tool_output_max_chars": Setting(
748
+ "4000",
749
+ positive_integer=True,
750
+ description="Characters kept when a tool result is truncated",
751
+ ),
752
+ "tool_output_strategy": Setting(
753
+ "head_tail",
754
+ ("head", "tail", "head_tail"),
755
+ description="Which part of a truncated tool result survives",
756
+ ),
757
+ "tool_output_retention_hours": Setting(
758
+ "0",
759
+ whole_number=True,
760
+ description="Hours to keep spilled results on disk; 0 keeps them forever",
761
+ ),
762
+ "effort": Setting(
763
+ "default", EFFORTS, description="Default reasoning effort for models /effort has not set"
764
+ ),
765
+ "model": Setting(None, description="Model name; unset uses the offline preview"),
766
+ }
767
+
768
+
769
+ # Settings a repository must not be able to set for whoever clones it: each
770
+ # either runs code on launch or chooses which credential or local process to
771
+ # use. They are read from the user file only; a project file setting them is
772
+ # reported by `rejected_project_keys` and otherwise ignored.
773
+ USER_ONLY = frozenset(
774
+ {
775
+ "project_extensions",
776
+ "trusted_projects",
777
+ "extension_dirs",
778
+ "extensions_off",
779
+ "extensions_on",
780
+ "meridian_managed",
781
+ "anthropic_auth",
782
+ *(key for key in SETTINGS if key.startswith("email_")),
783
+ }
784
+ )
785
+
786
+ PROJECT_PREFERENCES = Path(".pcode") / "preferences.json"
787
+
788
+ # The launch workspace, fixed once by the CLI so every later load_preferences()
789
+ # call sees the same overlay. Not the current directory: `-C` and worktrees
790
+ # make those differ, and the worktree carries the same committed file anyway.
791
+ _project_root: Path | None = None
792
+
793
+
794
+ def set_project_root(path: Path | None) -> None:
795
+ global _project_root
796
+ _project_root = path.resolve() if path is not None else None
797
+
798
+
799
+ def config_dir() -> Path:
800
+ """pcode's user config directory: `$PCODE_CONFIG_DIR`, else `$XDG_CONFIG_HOME/pcode`.
801
+
802
+ Preferences, `mcp.json`, extensions, the worktree setup script, and stored
803
+ logins all live here. `PCODE_CONFIG_DIR` names the directory directly so
804
+ independent instances can have their own login and settings without moving
805
+ every other XDG-aware program along with them.
806
+ """
807
+ override = os.environ.get("PCODE_CONFIG_DIR", "").strip()
808
+ if override:
809
+ return Path(override).expanduser()
810
+ root = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
811
+ return root / "pcode"
812
+
813
+
814
+ def preferences_path() -> Path:
815
+ return config_dir() / "preferences.json"
816
+
817
+
818
+ def project_preferences_path() -> Path | None:
819
+ """The workspace's overlay file, or None before the CLI has named a workspace."""
820
+ return _project_root / PROJECT_PREFERENCES if _project_root is not None else None
821
+
822
+
823
+ def _read_valid(path: Path | None) -> dict[str, str]:
824
+ if path is None:
825
+ return {}
826
+ try:
827
+ data = json.loads(path.read_text())
828
+ except (OSError, ValueError):
829
+ return {}
830
+ if not isinstance(data, dict):
831
+ return {}
832
+ result = {}
833
+ for key, setting in SETTINGS.items():
834
+ value = data.get(key)
835
+ if not isinstance(value, str):
836
+ continue
837
+ try:
838
+ setting.validate(key, value)
839
+ except ValueError:
840
+ continue
841
+ result[key] = value
842
+ return result
843
+
844
+
845
+ def load_preferences() -> dict[str, str]:
846
+ """User defaults with the workspace's `.pcode/preferences.json` layered on top."""
847
+ merged = _read_valid(preferences_path())
848
+ for key, value in _read_valid(project_preferences_path()).items():
849
+ if key not in USER_ONLY:
850
+ merged[key] = value
851
+ return merged
852
+
853
+
854
+ def subagent_models() -> list[str]:
855
+ """The `subagent_models` setting as model names, in order, without repeats."""
856
+ value = load_preferences().get("subagent_models", "")
857
+ return list(dict.fromkeys(entry.strip() for entry in value.split(",") if entry.strip()))
858
+
859
+
860
+ def from_project(key: str) -> bool:
861
+ """Whether the workspace's `.pcode/preferences.json` decides `key`."""
862
+ return key not in USER_ONLY and key in _read_valid(project_preferences_path())
863
+
864
+
865
+ def rejected_project_keys() -> list[str]:
866
+ """User-only keys the project file tries to set, for a launch warning."""
867
+ path = project_preferences_path()
868
+ if path is None:
869
+ return []
870
+ try:
871
+ data = json.loads(path.read_text())
872
+ except (OSError, ValueError):
873
+ return []
874
+ return sorted(USER_ONLY & set(data)) if isinstance(data, dict) else []
875
+
876
+
877
+ def read_preferences(path: Path | None = None) -> dict:
878
+ """Read without discarding unknown keys or silently repairing a broken file."""
879
+ path = path or preferences_path()
880
+ try:
881
+ data = json.loads(path.read_text())
882
+ except FileNotFoundError:
883
+ return {}
884
+ except ValueError:
885
+ raise ValueError(
886
+ f"Invalid preferences JSON: {path}. Repair the file before editing."
887
+ ) from None
888
+ if not isinstance(data, dict):
889
+ raise ValueError(f"Preferences must be a JSON object: {path}")
890
+ return data
891
+
892
+
893
+ def save_preferences(**updates: str) -> None:
894
+ update_preferences(updates)
895
+
896
+
897
+ def update_preferences(
898
+ updates: dict[str, str], *, remove: tuple[str, ...] = (), path: Path | None = None
899
+ ) -> None:
900
+ path = path or preferences_path()
901
+ path.parent.mkdir(parents=True, exist_ok=True)
902
+ # Serialize read/modify/write across terminals, including legacy shortcuts.
903
+ with FileLock(str(path) + ".lock", timeout=5):
904
+ data = read_preferences(path)
905
+ for key in remove:
906
+ data.pop(key, None)
907
+ data.update(updates)
908
+ write_json(path, data)
909
+
910
+
911
+ def write_json(path: Path, data: dict) -> None:
912
+ # Atomic replacement avoids leaving a partial file after an interrupted write.
913
+ name = None
914
+ try:
915
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
916
+ name = file.name
917
+ json.dump(data, file, indent=2, ensure_ascii=False)
918
+ file.write("\n")
919
+ # Keep an existing file's mode; a new one stays private (0600).
920
+ try:
921
+ shutil.copymode(path, name)
922
+ except FileNotFoundError:
923
+ pass
924
+ os.replace(name, path)
925
+ finally:
926
+ if name is not None:
927
+ Path(name).unlink(missing_ok=True)
928
+
929
+
930
+ # Per-model effort lives outside SETTINGS: it is a mapping, not a string, and
931
+ # the `effort` setting stays as the fallback for models never chosen explicitly.
932
+ MODEL_EFFORTS_KEY = "model_efforts"
933
+
934
+
935
+ def _model_efforts(path: Path | None) -> dict[str, str]:
936
+ if path is None:
937
+ return {}
938
+ try:
939
+ data = json.loads(path.read_text())
940
+ except (OSError, ValueError):
941
+ return {}
942
+ stored = data.get(MODEL_EFFORTS_KEY) if isinstance(data, dict) else None
943
+ if not isinstance(stored, dict):
944
+ return {}
945
+ return {
946
+ model: effort
947
+ for model, effort in stored.items()
948
+ if isinstance(model, str) and effort in EFFORTS
949
+ }
950
+
951
+
952
+ def model_efforts() -> dict[str, str]:
953
+ """Saved effort per model, with the workspace overlay layered on top."""
954
+ merged = _model_efforts(preferences_path())
955
+ merged.update(_model_efforts(project_preferences_path()))
956
+ return merged
957
+
958
+
959
+ def effort_for(model: str | None) -> str | None:
960
+ """The effort to request for `model`: its own, else the shared default."""
961
+ saved = model_efforts().get(model or "")
962
+ return saved if saved is not None else load_preferences().get("effort")
963
+
964
+
965
+ def save_model_effort(model: str, effort: str) -> None:
966
+ """Record `effort` for `model` alone, leaving other models untouched."""
967
+ with FileLock(str(preferences_path()) + ".lock", timeout=5):
968
+ path = preferences_path()
969
+ path.parent.mkdir(parents=True, exist_ok=True)
970
+ data = read_preferences(path)
971
+ stored = data.get(MODEL_EFFORTS_KEY)
972
+ stored = dict(stored) if isinstance(stored, dict) else {}
973
+ stored[model] = effort
974
+ data[MODEL_EFFORTS_KEY] = stored
975
+ write_json(path, data)
976
+
977
+
978
+ def anthropic_profile(model: str | None, resolved=None) -> dict:
979
+ """The profile for `model`, from `resolved` when it is a model object.
980
+
981
+ Only meaningful once the caller knows the model is an Anthropic one: the
982
+ object's own profile is returned whatever it is. Before /login a model is
983
+ still its name, and a real `Model` always has a non-empty profile, so a
984
+ missing attribute means the name lookup rather than credential loading.
985
+ """
986
+ profile = getattr(resolved, "profile", None)
987
+ if profile is not None:
988
+ return profile
989
+ from pydantic_ai.profiles.anthropic import anthropic_model_profile
990
+
991
+ return anthropic_model_profile((model or "").split(":", 1)[-1]) or {}
992
+
993
+
994
+ def effort_setting(model: str | None, resolved=None) -> str | None:
995
+ """The settings key /effort writes for `model`, or None where it has none.
996
+
997
+ Anthropic gates effort per model (Opus 4.5+, Sonnet 4.6+): the rest reject
998
+ `output_config.effort` outright, and the adapter forwards an explicitly set
999
+ `anthropic_effort` without consulting the profile. So asking Haiku for an
1000
+ effort is a 400 on the next turn, not a setting the provider ignores.
1001
+ """
1002
+ provider = (model or "").split(":", 1)[0]
1003
+ if provider in OPENAI_PROVIDERS:
1004
+ return "openai_reasoning_effort"
1005
+ if provider in ANTHROPIC_PROVIDERS:
1006
+ if anthropic_profile(model, resolved).get("anthropic_supports_effort"):
1007
+ return "anthropic_effort"
1008
+ return None
1009
+
1010
+
1011
+ def effort_unavailable(model: str | None) -> str:
1012
+ """Why /effort refuses `model`, naming the model where the provider is fine."""
1013
+ if (model or "").split(":", 1)[0] in ANTHROPIC_PROVIDERS:
1014
+ return (
1015
+ f"{model} has no effort control; Anthropic gates it to "
1016
+ "Opus 4.5+ and Sonnet 4.6+ models."
1017
+ )
1018
+ base = "Effort control requires an OpenAI/Codex, Anthropic, Claude, or Meridian model"
1019
+ return f"{base}; {model} is not one." if model else f"{base}."
1020
+
1021
+
1022
+ def current_effort(agent, model: str) -> str:
1023
+ """The effort `agent` will request next, as /effort names it.
1024
+
1025
+ `n/a` where the model has no effort control, which `default` would hide:
1026
+ nothing is sent either way, but only one of them can be changed.
1027
+ """
1028
+ resolved = getattr(agent, "model", None)
1029
+ key = effort_setting(model, resolved)
1030
+ if key is None:
1031
+ return "n/a"
1032
+ settings = getattr(resolved, "settings", None) or {}
1033
+ settings = {**settings, **(getattr(agent, "model_settings", None) or {})}
1034
+ effort = settings.get(key, "default")
1035
+ return "xhigh" if effort == "max" else effort
1036
+
1037
+
1038
+ def apply_effort(agent, model: str, effort: str | None) -> None:
1039
+ resolved = getattr(agent, "model", None)
1040
+ key = effort_setting(model, resolved)
1041
+ if effort not in EFFORTS or key is None:
1042
+ return
1043
+ settings = dict(agent.model_settings or {})
1044
+ if effort == "default":
1045
+ settings.pop(key, None)
1046
+ else:
1047
+ # Older Claude models call their highest effort "max", not "xhigh".
1048
+ if key == "anthropic_effort" and effort == "xhigh":
1049
+ supports = anthropic_profile(model, resolved).get("anthropic_supports_xhigh_effort")
1050
+ effort = "xhigh" if supports else "max"
1051
+ settings[key] = effort
1052
+ agent.model_settings = settings
1053
+
1054
+
1055
+ # Claude models whose thinking is on with no `thinking` field, per Anthropic's
1056
+ # thinking docs. The status line (the default mode) only asks these for
1057
+ # readable thinking: on the rest, asking would turn thinking on, which costs
1058
+ # tokens and is refused alongside a non-default temperature.
1059
+ ANTHROPIC_THINKS_BY_DEFAULT = re.compile(
1060
+ r"claude-(?:(?:opus|sonnet)-(?:[5-9]|\d{2,})\b|fable|mythos)"
1061
+ )
1062
+ # Models that write progress updates between tool calls, per Anthropic's
1063
+ # thinking docs. `display: "updates"` returns those short notes, written for
1064
+ # someone watching the agent, and nothing else: a status line's own format.
1065
+ # `(?!\d)`: `fable-5` must not also match a future `fable-50`, which would get
1066
+ # the beta (and a 400) without supporting it.
1067
+ ANTHROPIC_PROGRESS_UPDATES = re.compile(r"claude-(?:opus-5-5|sonnet-5-5|fable-5|mythos-5-1)(?!\d)")
1068
+ UPDATES_BETA = "thinking-display-updates-2026-08-18"
1069
+
1070
+
1071
+ def thinking_mode_preference() -> str:
1072
+ """The saved `/show-thinking` mode, or its default."""
1073
+ return load_preferences().get("show_thinking", SETTINGS["show_thinking"].default)
1074
+
1075
+
1076
+ def hints_preference() -> bool:
1077
+ """Whether the prompt's keybinding help indicator (`show_hints`) is on."""
1078
+ return load_preferences().get("show_hints", SETTINGS["show_hints"].default) == "on"
1079
+
1080
+
1081
+ def preference_count(key: str) -> int:
1082
+ """A saved whole-number setting, or its default when unset or not a number."""
1083
+ value = str(load_preferences().get(key, ""))
1084
+ if not value.isascii() or not value.isdecimal():
1085
+ value = SETTINGS[key].default
1086
+ return int(value)
1087
+
1088
+
1089
+ def tool_rows_preference() -> int:
1090
+ """The most tool rows above the status row (`tool_max_lines`)."""
1091
+ return int(load_preferences().get("tool_max_lines", SETTINGS["tool_max_lines"].default))
1092
+
1093
+
1094
+ def openai_profile(model: str, resolved=None) -> dict:
1095
+ """The profile for an OpenAI `model`, from `resolved` when it is a model object."""
1096
+ profile = getattr(resolved, "profile", None)
1097
+ if profile is not None:
1098
+ return profile
1099
+ from pydantic_ai.profiles.openai import openai_model_profile
1100
+
1101
+ return openai_model_profile(model.split(":", 1)[-1]) or {}
1102
+
1103
+
1104
+ def thinking_settings(model: str, resolved, mode: str) -> dict:
1105
+ """The request settings that make thinking readable in `mode`.
1106
+
1107
+ `off` asks for nothing. `status-line` wants short text: Anthropic's
1108
+ progress updates where the model writes them, else summaries.
1109
+ `scrollback` wants the fullest text the provider returns: Anthropic's
1110
+ summaries (it never returns raw thinking) and OpenAI's `auto`, which is
1111
+ each model's most detailed summarizer. `claude:` always gets summaries
1112
+ and `openai-codex:` detailed ones, whatever the mode (see
1113
+ `claude_sdk.SessionConfig` and `cache_settings`).
1114
+
1115
+ `claude:` is left on summaries on purpose. Its CLI rejects
1116
+ `--thinking-display updates`, and sends `updates` itself only on some
1117
+ logins (not a subscription's). `CLAUDE_CODE_EXTRA_BODY` plus
1118
+ `ANTHROPIC_BETAS` can force it, and the server accepts that, but under
1119
+ the CLI's harness Opus 5.5 writes its progress notes as ordinary text:
1120
+ forcing `updates` only hid the reasoning, and left the row empty
1121
+ (checked live, CLI 2.1.283).
1122
+ """
1123
+ if mode not in ("status-line", "scrollback"):
1124
+ return {}
1125
+ provider = model.split(":", 1)[0]
1126
+ if provider == "anthropic":
1127
+ if mode == "status-line" and not ANTHROPIC_THINKS_BY_DEFAULT.search(model):
1128
+ return {}
1129
+ if not anthropic_profile(model, resolved).get("anthropic_supports_adaptive_thinking"):
1130
+ # Older models think only when asked. The budget is below the
1131
+ # adapter's default max_tokens (4096); effort and output limits
1132
+ # are never changed as a side effect of visibility.
1133
+ return {
1134
+ "anthropic_thinking": {
1135
+ "type": "enabled",
1136
+ "budget_tokens": 2048,
1137
+ "display": "summarized",
1138
+ }
1139
+ }
1140
+ if mode == "status-line" and ANTHROPIC_PROGRESS_UPDATES.search(model):
1141
+ return {
1142
+ "anthropic_thinking": {"type": "adaptive", "display": "updates"},
1143
+ "anthropic_betas": [UPDATES_BETA],
1144
+ }
1145
+ return {"anthropic_thinking": {"type": "adaptive", "display": "summarized"}}
1146
+ if provider in ("openai", "openai-responses"):
1147
+ # One summarizer per model; `auto` picks it, and a model that does not
1148
+ # reason is never sent a reasoning setting. An API-key organisation
1149
+ # that is not verified gets a 400 for asking: `off` is the way out.
1150
+ if openai_profile(model, resolved).get("openai_supports_reasoning"):
1151
+ return {"openai_reasoning_summary": "auto"}
1152
+ return {}
1153
+
1154
+
1155
+ # The settings `thinking_settings` owns, by provider; others are left alone.
1156
+ # Nothing else in pcode sets `anthropic_betas`.
1157
+ THINKING_KEYS = {
1158
+ "anthropic": ("anthropic_thinking", "anthropic_betas"),
1159
+ "openai": ("openai_reasoning_summary",),
1160
+ "openai-responses": ("openai_reasoning_summary",),
1161
+ }
1162
+
1163
+
1164
+ def apply_thinking(agent, model: str, mode: str) -> None:
1165
+ """Request readable thinking for `mode` on future turns, not just a UI preview."""
1166
+ keys = THINKING_KEYS.get(model.split(":", 1)[0])
1167
+ if keys is None:
1168
+ return
1169
+ current = getattr(agent, "model_settings", None)
1170
+ wanted = thinking_settings(model, getattr(agent, "model", None), mode)
1171
+ if not wanted and not any(key in (current or {}) for key in keys):
1172
+ return
1173
+ settings = {k: v for k, v in (current or {}).items() if k not in keys}
1174
+ settings.update(wanted)
1175
+ # Replace instead of mutating settings captured by an in-flight run.
1176
+ agent.model_settings = settings or None