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/completion.py ADDED
@@ -0,0 +1,263 @@
1
+ """Shell completion scripts generated from the argparse parser.
2
+
3
+ Generating from the live parser (rather than checking in hand-written scripts)
4
+ keeps the completions honest: a new flag shows up without a second edit.
5
+
6
+ Values that only exist at runtime (running session hosts) cannot be baked into
7
+ the script, so an option marked with `complete_with` makes the script call
8
+ back into `pcode __complete KIND`, which prints `value<TAB>description` lines.
9
+ `pcode.cli` answers that before importing the frontend, so a Tab stays quick.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from collections.abc import Callable
17
+ from pathlib import Path
18
+
19
+ SHELLS = ("zsh", "fish", "bash")
20
+
21
+ # The hidden first argument the generated scripts call back with.
22
+ COMPLETE_COMMAND = "__complete"
23
+
24
+
25
+ _TITLE_WIDTH = 50
26
+
27
+
28
+ def _hosts() -> list[tuple[str, str]]:
29
+ from pcode.host_protocol import list_hosts
30
+
31
+ candidates = []
32
+ for entry in list_hosts():
33
+ title = " ".join(entry.label().split())
34
+ if len(title) > _TITLE_WIDTH:
35
+ title = title[: _TITLE_WIDTH - 1] + "…"
36
+ candidates.append((entry.id, f"{entry.state} · {Path(entry.workspace).name} · {title}"))
37
+ return candidates
38
+
39
+
40
+ # Kinds of runtime value an option can complete, by the name the scripts pass.
41
+ _DYNAMIC: dict[str, Callable[[], list[tuple[str, str]]]] = {"hosts": _hosts}
42
+
43
+
44
+ def complete_with(action: argparse.Action, kind: str) -> argparse.Action:
45
+ """Mark `action` as completing `kind` values, looked up when Tab is pressed."""
46
+ if kind not in _DYNAMIC:
47
+ raise ValueError(f"unknown completion kind: {kind}")
48
+ action.complete_kind = kind # type: ignore[attr-defined]
49
+ return action
50
+
51
+
52
+ def _dynamic(action: argparse.Action) -> str | None:
53
+ return getattr(action, "complete_kind", None)
54
+
55
+
56
+ def print_candidates(argv: list[str], stream=None) -> int:
57
+ """`pcode __complete KIND`: one `value<TAB>description` line per candidate."""
58
+ stream = stream or sys.stdout
59
+ if len(argv) != 1 or argv[0] not in _DYNAMIC:
60
+ print(f"usage: pcode {COMPLETE_COMMAND} {{{','.join(_DYNAMIC)}}}", file=sys.stderr)
61
+ return 2
62
+ for value, description in _DYNAMIC[argv[0]]():
63
+ # A tab or newline would split the line the shells read.
64
+ print(f"{value}\t{' '.join(description.split())}", file=stream)
65
+ return 0
66
+
67
+
68
+ _INSTALL_HINTS = {
69
+ "zsh": "pcode --completions zsh > ~/.zsh/completions/_pcode # dir must be on $fpath",
70
+ "fish": "pcode --completions fish > ~/.config/fish/completions/pcode.fish",
71
+ "bash": 'eval "$(pcode --completions bash)" # add to ~/.bashrc',
72
+ }
73
+
74
+
75
+ def install_hint(shell: str) -> str:
76
+ return _INSTALL_HINTS[shell]
77
+
78
+
79
+ def _visible_actions(parser: argparse.ArgumentParser) -> list[argparse.Action]:
80
+ return [
81
+ action
82
+ for action in parser._actions
83
+ if action.option_strings and action.help is not argparse.SUPPRESS
84
+ ]
85
+
86
+
87
+ def _takes_value(action: argparse.Action) -> bool:
88
+ return action.nargs != 0 and not isinstance(
89
+ action, argparse._StoreTrueAction | argparse._StoreFalseAction | argparse._CountAction
90
+ )
91
+
92
+
93
+ def _optional_value(action: argparse.Action) -> bool:
94
+ return action.nargs == "?"
95
+
96
+
97
+ def _choices(action: argparse.Action) -> list[str]:
98
+ return [str(choice) for choice in (action.choices or [])]
99
+
100
+
101
+ def _wants_path(action: argparse.Action) -> bool:
102
+ return action.type is Path
103
+
104
+
105
+ def _help(action: argparse.Action) -> str:
106
+ return " ".join((action.help or "").split())
107
+
108
+
109
+ def _zsh_quote(text: str) -> str:
110
+ return text.replace("\\", "\\\\").replace("'", "'\\''").replace("[", "\\[").replace("]", "\\]")
111
+
112
+
113
+ def _zsh(parser: argparse.ArgumentParser, prog: str) -> str:
114
+ lines = [f"#compdef {prog}", ""]
115
+ kinds = sorted({kind for action in _visible_actions(parser) if (kind := _dynamic(action))})
116
+ for kind in kinds:
117
+ # Helpers are defined at the top level so both the autoloaded and the
118
+ # sourced form of this file have them before `_{prog}` first runs.
119
+ lines += [
120
+ f"_{prog}_{kind}() {{",
121
+ " local -a values",
122
+ f' values=(${{(f)"$({prog} {COMPLETE_COMMAND} {kind} 2>/dev/null)"}})',
123
+ # _describe splits value from description on the first colon.
124
+ " values=(\"${(@)values//$'\\t'/:}\")",
125
+ f" _describe -t {kind} {kind} values",
126
+ "}",
127
+ "",
128
+ ]
129
+ lines += [f"_{prog}() {{", " local -a specs", " specs=("]
130
+ for action in _visible_actions(parser):
131
+ # Repeating an option is almost never meaningful here, so tell zsh to
132
+ # drop every spelling of a flag once any of them is on the line.
133
+ exclusion = f"({' '.join(action.option_strings)})" if len(action.option_strings) > 1 else ""
134
+ kind = _dynamic(action)
135
+ for option in action.option_strings:
136
+ name = option
137
+ if _takes_value(action) and _optional_value(action):
138
+ # `=-` marks an option whose value, if any, must be glued on as
139
+ # `--flag=value`, so the next word still completes as a prompt.
140
+ # A runtime value is the reason to type the option at all, so
141
+ # there `=` also completes it as the next word.
142
+ name = f"{option}=" if kind else f"{option}=-"
143
+ spec = f"{exclusion}{name}[{_zsh_quote(_help(action))}]"
144
+ if _takes_value(action):
145
+ metavar = str(action.metavar or action.dest)
146
+ if kind:
147
+ optional = ":" if _optional_value(action) else ""
148
+ spec += f":{optional}{metavar}:_{prog}_{kind}"
149
+ elif choices := _choices(action):
150
+ spec += f":{metavar}:({' '.join(choices)})"
151
+ elif _wants_path(action):
152
+ spec += f":{metavar}:_files"
153
+ else:
154
+ spec += f":{metavar}: "
155
+ lines.append(f" '{spec}'")
156
+ lines += [
157
+ " '*:prompt:_files'",
158
+ " )",
159
+ " _arguments -s -S $specs && return 0",
160
+ "}",
161
+ "",
162
+ # In $fpath the file is autoloaded *as* the completion function, so it
163
+ # must call itself; when sourced from .zshrc it must register instead.
164
+ f'if [ "$funcstack[1]" = "_{prog}" ]; then',
165
+ f' _{prog} "$@"',
166
+ "else",
167
+ f" compdef _{prog} {prog}",
168
+ "fi",
169
+ "",
170
+ ]
171
+ return "\n".join(lines)
172
+
173
+
174
+ def _fish(parser: argparse.ArgumentParser, prog: str) -> str:
175
+ lines = ["# fish completions for pcode", ""]
176
+ for action in _visible_actions(parser):
177
+ flags = []
178
+ for option in action.option_strings:
179
+ if option.startswith("--"):
180
+ flags += ["-l", option[2:]]
181
+ else:
182
+ flags += ["-s", option[1:]]
183
+ parts = [f"complete -c {prog}", *flags]
184
+ if help_text := _help(action):
185
+ parts += ["-d", _fish_quote(help_text)]
186
+ kind = _dynamic(action)
187
+ if _takes_value(action):
188
+ # -r demands a value; an optional value stays -f so fish still
189
+ # offers the other flags. A runtime value is the reason to type the
190
+ # option at all, so it takes -r anyway (a `-` still lists flags).
191
+ parts.append("-r" if kind or not _optional_value(action) else "")
192
+ if kind:
193
+ parts += ["-f", "-a", _fish_quote(f"({prog} {COMPLETE_COMMAND} {kind})")]
194
+ elif choices := _choices(action):
195
+ parts += ["-f", "-a", _fish_quote(" ".join(choices))]
196
+ elif _wants_path(action):
197
+ parts.append("-F")
198
+ else:
199
+ parts.append("-f")
200
+ else:
201
+ parts.append("-f")
202
+ lines.append(" ".join(part for part in parts if part))
203
+ lines.append("")
204
+ return "\n".join(lines)
205
+
206
+
207
+ def _fish_quote(text: str) -> str:
208
+ return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
209
+
210
+
211
+ def _bash(parser: argparse.ArgumentParser, prog: str) -> str:
212
+ options = [option for action in _visible_actions(parser) for option in action.option_strings]
213
+ choice_cases = []
214
+ for action in _visible_actions(parser):
215
+ if not _takes_value(action):
216
+ continue
217
+ if kind := _dynamic(action):
218
+ pattern = "|".join(action.option_strings)
219
+ values = f"$({prog} {COMPLETE_COMMAND} {kind} 2>/dev/null | cut -f1)"
220
+ # An optional value leaves a `-` word to the flag list below.
221
+ choice_cases.append(
222
+ f' {pattern})\n if [[ "$cur" != -* ]]; then\n'
223
+ f' COMPREPLY=($(compgen -W "{values}" -- "$cur")); return 0\n'
224
+ " fi ;;"
225
+ )
226
+ elif choices := _choices(action):
227
+ pattern = "|".join(action.option_strings)
228
+ choice_cases.append(
229
+ f" {pattern})\n"
230
+ f' COMPREPLY=($(compgen -W "{" ".join(choices)}" -- "$cur")); return 0 ;;'
231
+ )
232
+ elif _wants_path(action):
233
+ pattern = "|".join(action.option_strings)
234
+ choice_cases.append(
235
+ f' {pattern})\n COMPREPLY=($(compgen -f -- "$cur")); return 0 ;;'
236
+ )
237
+ cases = "\n".join(choice_cases)
238
+ return f"""_{prog}() {{
239
+ local cur prev
240
+ cur="${{COMP_WORDS[COMP_CWORD]}}"
241
+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
242
+ case "$prev" in
243
+ {cases}
244
+ esac
245
+ if [[ "$cur" == -* ]]; then
246
+ COMPREPLY=($(compgen -W "{" ".join(options)}" -- "$cur"))
247
+ return 0
248
+ fi
249
+ COMPREPLY=($(compgen -f -- "$cur"))
250
+ }}
251
+ complete -F _{prog} {prog}
252
+ """
253
+
254
+
255
+ def render(parser: argparse.ArgumentParser, shell: str, prog: str = "pcode") -> str:
256
+ """Return a completion script for `shell` describing `parser`."""
257
+ if shell == "zsh":
258
+ return _zsh(parser, prog)
259
+ if shell == "fish":
260
+ return _fish(parser, prog)
261
+ if shell == "bash":
262
+ return _bash(parser, prog)
263
+ raise ValueError(f"unsupported shell: {shell}")
pcode/config.py ADDED
@@ -0,0 +1,223 @@
1
+ """Defaults commands, shared by the CLI and interactive terminal.
2
+
3
+ Editing defaults deliberately does not mutate a running conversation. In-session
4
+ shortcuts change both the active setting and its saved default; the terminal also
5
+ applies layout-only settings (`attach_tasks`, `tasks_max_height`, `task_style`,
6
+ `tool_glyphs`, `tool_max_lines`, `tool_linger_seconds` and `show_hints`) immediately.
7
+
8
+ `config project ...` edits the workspace's `.pcode/preferences.json`, which is
9
+ layered over the user file at launch (except for `USER_ONLY` keys).
10
+ """
11
+
12
+ import json
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+
16
+ from pcode.preferences import (
17
+ MODEL_EFFORTS_KEY,
18
+ SETTINGS,
19
+ USER_ONLY,
20
+ Setting,
21
+ preferences_path,
22
+ project_preferences_path,
23
+ read_preferences,
24
+ update_preferences,
25
+ )
26
+
27
+ USAGE = "config [list | diff | path | get KEY | set KEY VALUE | unset KEY | reset | project ...]"
28
+ PROJECT_USAGE = "config project [list | path | set KEY VALUE | unset KEY | reset]"
29
+
30
+ # Resetting clears the recorded /login choice and every per-repository extension
31
+ # grant, so say so rather than leaving the next launch to re-prompt unexplained.
32
+ _RESET_NOTICE = {
33
+ "anthropic_auth": "re-run /login to choose a credential",
34
+ "trusted_projects": "repositories must be trusted again",
35
+ }
36
+
37
+
38
+ def _setting(key: str) -> Setting:
39
+ if key not in SETTINGS:
40
+ raise ValueError(f"Unknown setting '{key}'. Available: {', '.join(SETTINGS)}")
41
+ return SETTINGS[key]
42
+
43
+
44
+ def _effective(data: dict, key: str) -> str | None:
45
+ setting = SETTINGS[key]
46
+ value = data.get(key)
47
+ if isinstance(value, str):
48
+ try:
49
+ setting.validate(key, value)
50
+ except ValueError:
51
+ pass
52
+ else:
53
+ return value
54
+ return setting.default
55
+
56
+
57
+ def _effective_data() -> dict:
58
+ """Both files read strictly, so a broken one is reported instead of hidden."""
59
+ data = read_preferences()
60
+ project = project_preferences_path()
61
+ if project is not None:
62
+ data.update({k: v for k, v in read_preferences(project).items() if k not in USER_ONLY})
63
+ return data
64
+
65
+
66
+ def _reset(path: Path | None, label: str) -> str:
67
+ """Drop every known setting, leaving unrecognized keys the file may carry."""
68
+ # Per-model efforts are a setting in every sense a user cares about, so a
69
+ # reset must clear them too even though they live outside SETTINGS.
70
+ known = set(SETTINGS) | {MODEL_EFFORTS_KEY}
71
+ removed = sorted(set(read_preferences(path)) & known)
72
+ if not removed:
73
+ return f"No {label} defaults to reset."
74
+ update_preferences({}, remove=tuple(removed), path=path)
75
+ notice = "".join(f" {_RESET_NOTICE[key]}." for key in removed if key in _RESET_NOTICE)
76
+ return f"Reset {label} defaults: {', '.join(removed)}. Applies on next launch.{notice}"
77
+
78
+
79
+ def _diff() -> str:
80
+ """Only the settings actually changed, and which file changed them.
81
+
82
+ A stored value equal to the default is not a difference, and neither is an
83
+ invalid one: both leave the effective value where `list` shows it.
84
+ """
85
+ user = read_preferences()
86
+ project_path = project_preferences_path()
87
+ project = read_preferences(project_path) if project_path is not None else {}
88
+ data = _effective_data()
89
+ lines = []
90
+ for key, setting in SETTINGS.items():
91
+ value = _effective(data, key)
92
+ if value == setting.default:
93
+ continue
94
+ source = "project" if key in project and key not in USER_ONLY else "user"
95
+ default = "unset" if setting.default is None else setting.default
96
+ lines.append(f"{key} = {value} (default {default}, from {source})")
97
+ efforts = user.get(MODEL_EFFORTS_KEY)
98
+ if isinstance(efforts, dict) and efforts:
99
+ chosen = ", ".join(f"{model}={effort}" for model, effort in sorted(efforts.items()))
100
+ lines.append(f"{MODEL_EFFORTS_KEY} = {chosen} (default none, from user)")
101
+ if not lines:
102
+ return "Every setting is at its default."
103
+ return "\n".join(lines)
104
+
105
+
106
+ def _project_path() -> Path:
107
+ path = project_preferences_path()
108
+ if path is None:
109
+ raise ValueError("No workspace is selected, so there is no project config to edit.")
110
+ return path
111
+
112
+
113
+ def _configure_project(args: list[str]) -> str:
114
+ action = args[0] if args else "list"
115
+ if action == "path" and len(args) == 1:
116
+ return str(_project_path())
117
+ if action == "list" and len(args) == 1:
118
+ return json.dumps(read_preferences(_project_path()), indent=2)
119
+ if action == "set" and len(args) == 3:
120
+ key, value = args[1:]
121
+ if key in USER_ONLY:
122
+ raise ValueError(f"{key} is user-only; a repository cannot set it. Use config set.")
123
+ _setting(key).validate(key, value)
124
+ update_preferences({key: value}, path=_project_path())
125
+ return f"Saved project default: {key} = {value}. Applies on next launch."
126
+ if action == "unset" and len(args) == 2:
127
+ key = args[1]
128
+ _setting(key)
129
+ update_preferences({}, remove=(key,), path=_project_path())
130
+ return f"Removed project default: {key}. Applies on next launch."
131
+ if action == "reset" and len(args) == 1:
132
+ return _reset(_project_path(), "project")
133
+ raise ValueError(f"Usage: {PROJECT_USAGE}")
134
+
135
+
136
+ def configure(arguments: Sequence[str]) -> str:
137
+ """Inspect or edit saved defaults without starting a model or session.
138
+
139
+ `list`, `diff`, and `get` report the effective value, with the project
140
+ overlay applied; `set`, `unset`, and `reset` edit the user file.
141
+ """
142
+ args = list(arguments) or ["list"]
143
+ action = args[0]
144
+ if action == "project":
145
+ return _configure_project(args[1:])
146
+ if action == "path" and len(args) == 1:
147
+ return str(preferences_path())
148
+ if action == "list" and len(args) == 1:
149
+ data = _effective_data()
150
+ return json.dumps({key: _effective(data, key) for key in SETTINGS}, indent=2)
151
+ if action == "diff" and len(args) == 1:
152
+ return _diff()
153
+ if action == "get" and len(args) == 2:
154
+ key = args[1]
155
+ _setting(key)
156
+ value = _effective(_effective_data(), key)
157
+ return "null" if value is None else value
158
+ if action == "set" and len(args) == 3:
159
+ key, value = args[1:]
160
+ _setting(key).validate(key, value)
161
+ update_preferences({key: value})
162
+ return f"Saved global default: {key} = {value}. Applies on next launch."
163
+ if action == "unset" and len(args) == 2:
164
+ key = args[1]
165
+ setting = _setting(key)
166
+ update_preferences({}, remove=(key,))
167
+ value = setting.default if setting.default is not None else "null (offline preview)"
168
+ return f"Reset global default: {key} = {value}. Applies on next launch."
169
+ if action == "reset" and len(args) == 1:
170
+ return _reset(None, "global")
171
+ raise ValueError(f"Usage: {USAGE}")
172
+
173
+
174
+ def config_argument_descriptions() -> dict[str, str]:
175
+ """Completion-menu text: the setting's purpose beside each key, its default beside values."""
176
+ described: dict[str, str] = {
177
+ "list": "Show every effective setting",
178
+ "diff": "Show only settings that differ from their defaults",
179
+ "path": "Print the user preferences file",
180
+ "get": "Show one effective setting",
181
+ "set": "Save a user default (applies on next launch)",
182
+ "unset": "Remove a user default",
183
+ "reset": "Remove every user default",
184
+ "project": "Edit the workspace's .pcode/preferences.json",
185
+ "project list": "Show the project file",
186
+ "project path": "Print the project preferences file",
187
+ "project reset": "Remove every project default",
188
+ }
189
+ for key, setting in SETTINGS.items():
190
+ default = "unset" if setting.default is None else setting.default
191
+ text = f"{setting.description} (default {default})" if setting.description else ""
192
+ for action in ("get", "unset", "set", "project set", "project unset"):
193
+ described[f"{action} {key}"] = text
194
+ for value in setting.choices:
195
+ marker = " (default)" if value == setting.default else ""
196
+ described[f"set {key} {value}"] = f"{setting.description}{marker}"
197
+ described[f"project set {key} {value}"] = described[f"set {key} {value}"]
198
+ return described
199
+
200
+
201
+ def config_arguments() -> tuple[str, ...]:
202
+ """Complete subcommands, keys, and enum values with the existing completer."""
203
+ project_keys = [key for key in SETTINGS if key not in USER_ONLY]
204
+ return (
205
+ "list",
206
+ "diff",
207
+ "path",
208
+ "get",
209
+ "set",
210
+ "unset",
211
+ "reset",
212
+ "project",
213
+ *(f"get {key}" for key in SETTINGS),
214
+ *(f"unset {key}" for key in SETTINGS),
215
+ *(f"set {key}" for key in SETTINGS),
216
+ *(f"set {key} {value}" for key, setting in SETTINGS.items() for value in setting.choices),
217
+ "project list",
218
+ "project path",
219
+ "project reset",
220
+ *(f"project set {key}" for key in project_keys),
221
+ *(f"project unset {key}" for key in project_keys),
222
+ *(f"project set {key} {value}" for key in project_keys for value in SETTINGS[key].choices),
223
+ )
@@ -0,0 +1,178 @@
1
+ """Attribute fixed prompt overhead to the thing that contributed it.
2
+
3
+ `context_usage` reports one number for the whole request, which says nothing
4
+ about *why* it is that large. Pydantic AI already tags every instruction part
5
+ with the capability that authored it, so the breakdown here is a grouping of
6
+ `ModelRequestParameters`, not a second estimate of the prompt.
7
+
8
+ Repository instructions arrive as one part holding several `<context-file>`
9
+ blocks; they are split for display only. Nothing in this module changes what is
10
+ sent, so a wrong label can never cost a cache hit.
11
+ """
12
+
13
+ import json
14
+ import re
15
+ from collections.abc import Iterator, Sequence
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from pcode.context_usage import compact_tokens
20
+
21
+ # `compaction.schema_tokens` uses the same divisor. Provider tokenizers disagree
22
+ # with each other anyway; a consistent estimate keeps these rows comparable with
23
+ # the compaction threshold that acts on them.
24
+ CHARS_PER_TOKEN = 4
25
+
26
+ # An instruction part is attributed to its source only when that source has an
27
+ # `id`, so pcode names the ones it constructs (see `agent.create_coder`) and reads
28
+ # Harness's own ids for the rest. Anything unlisted falls back to the bare id.
29
+ REPO_CONTEXT = "repo_context"
30
+ SOURCE_LABELS = {
31
+ REPO_CONTEXT: "Repository context",
32
+ "code_mode": "Code mode",
33
+ "file_tools": "File tools",
34
+ "mcp_servers": "MCP servers",
35
+ "planning": "Planning tool",
36
+ "sub_agents": "Sub-agents",
37
+ "tool_output_limits": "Tool output limits",
38
+ "ext.web_research": "Web research",
39
+ }
40
+ AGENT_LABEL = "Terminal instructions"
41
+ # Coder's base prompt is a concrete `Capability`, which attributes its
42
+ # instructions at construction and so cannot be named after the fact.
43
+ ANONYMOUS_LABEL = "Harness base prompts"
44
+
45
+ CONTEXT_FILE = re.compile(r'<context-file path="(?P<path>[^"]*)">\n.*?\n</context-file>', re.DOTALL)
46
+ ASSISTANT_CONFIG = re.compile(
47
+ r"<assistant-configuration>\n.*?\n</assistant-configuration>", re.DOTALL
48
+ )
49
+
50
+
51
+ def estimate_tokens(text: str) -> int:
52
+ return len(text) // CHARS_PER_TOKEN
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Row:
57
+ """One attributed slice of the fixed prompt overhead."""
58
+
59
+ label: str
60
+ tokens: int
61
+ detail: str = ""
62
+
63
+
64
+ def source_id(part) -> str | None:
65
+ """Name the capability that authored `part`; None for the agent's own or anonymous."""
66
+ return getattr(getattr(getattr(part, "id", None), "source", None), "id", None)
67
+
68
+
69
+ def _short(path: str) -> str:
70
+ """Shorten a home-relative instruction path the way a shell prompt would."""
71
+ candidate = Path(path)
72
+ return (
73
+ f"~/{candidate.relative_to(Path.home())}"
74
+ if candidate.is_absolute() and candidate.is_relative_to(Path.home())
75
+ else path
76
+ )
77
+
78
+
79
+ def _asset_detail(block: str) -> str:
80
+ """Count the discovered assets the block lists, without re-scanning the disk."""
81
+ try:
82
+ inventory = next(
83
+ (json.loads(line) for line in block.splitlines() if line.startswith("{")), None
84
+ )
85
+ except ValueError:
86
+ # An unreadable inventory is still a measurable number of tokens, and
87
+ # /status must not be the thing that fails on it.
88
+ inventory = None
89
+ if inventory is None:
90
+ return "paths only"
91
+ counts = [
92
+ (sum(len(root.get(key, ())) for root in inventory.get("roots", ())), noun)
93
+ for key, noun in (("skills", "skill"), ("agents", "agent"))
94
+ ]
95
+ listed = ", ".join(f"{count} {noun}" for count, noun in counts if count)
96
+ return f"paths only · {listed}" if listed else "paths only · nothing discovered"
97
+
98
+
99
+ def repo_rows(text: str) -> Iterator[Row]:
100
+ """Split repository context into the instruction files and the asset inventory."""
101
+ remainder = text
102
+ for match in CONTEXT_FILE.finditer(text):
103
+ remainder = remainder.replace(match.group(), "", 1)
104
+ yield Row(_short(match["path"]), estimate_tokens(match.group()), "instructions")
105
+ if config := ASSISTANT_CONFIG.search(text):
106
+ remainder = remainder.replace(config.group(), "", 1)
107
+ yield Row(
108
+ "Assistant config", estimate_tokens(config.group()), _asset_detail(config.group())
109
+ )
110
+ if leftover := remainder.strip():
111
+ yield Row(SOURCE_LABELS[REPO_CONTEXT], estimate_tokens(leftover))
112
+
113
+
114
+ def instruction_rows(parts: Sequence) -> list[Row]:
115
+ """One row per instruction source, largest first, with repository context expanded."""
116
+ rows: list[Row] = []
117
+ anonymous = 0
118
+ for part in parts or ():
119
+ identity = source_id(part)
120
+ if identity == REPO_CONTEXT:
121
+ rows.extend(repo_rows(part.content))
122
+ elif identity:
123
+ rows.append(Row(SOURCE_LABELS.get(identity, identity), estimate_tokens(part.content)))
124
+ elif getattr(part, "id", None) is None:
125
+ anonymous += estimate_tokens(part.content)
126
+ else:
127
+ rows.append(Row(AGENT_LABEL, estimate_tokens(part.content)))
128
+ if anonymous:
129
+ rows.append(Row(ANONYMOUS_LABEL, anonymous))
130
+ return sorted(rows, key=lambda row: -row.tokens)
131
+
132
+
133
+ def tool_rows(parameters) -> list[Row]:
134
+ """Size each tool the way `compaction.schema_tokens` sizes them in aggregate."""
135
+ return sorted(
136
+ (
137
+ Row(
138
+ tool.name,
139
+ estimate_tokens(json.dumps(tool.parameters_json_schema) + (tool.description or "")),
140
+ )
141
+ for tool in [*parameters.function_tools, *parameters.output_tools]
142
+ ),
143
+ key=lambda row: -row.tokens,
144
+ )
145
+
146
+
147
+ def overhead_rows(parameters, *, window: int | None = None) -> list[tuple[str, str]]:
148
+ """Label/value rows describing the fixed cost of the last request's prompt.
149
+
150
+ Fixed means what the provider is re-sent whatever the conversation did:
151
+ instructions and tool schemas, never message history.
152
+ """
153
+ instructions = instruction_rows(parameters.instruction_parts or [])
154
+ tools = tool_rows(parameters)
155
+ prompt = sum(row.tokens for row in instructions)
156
+ schemas = sum(row.tokens for row in tools)
157
+ total = prompt + schemas
158
+ share = f" · {total * 100 // window}% of {compact_tokens(window)}" if window else ""
159
+ rows = [
160
+ ("Prompt overhead", f"~{compact_tokens(total)} tokens{share} · estimated"),
161
+ (" Instructions", f"~{compact_tokens(prompt)}"),
162
+ ]
163
+ for row in instructions:
164
+ rows.append(
165
+ (
166
+ f" {row.label}",
167
+ f"~{compact_tokens(row.tokens)}" + (f" · {row.detail}" if row.detail else ""),
168
+ )
169
+ )
170
+ rows.append((" Tool schemas", f"~{compact_tokens(schemas)} · {len(tools)} tools"))
171
+ if tools:
172
+ rows.append(
173
+ (
174
+ " Largest",
175
+ " · ".join(f"{row.label} {compact_tokens(row.tokens)}" for row in tools[:5]),
176
+ )
177
+ )
178
+ return rows