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/usage.py ADDED
@@ -0,0 +1,293 @@
1
+ """`/usage`: plan limits and spend for the Claude Code and Codex logins, on demand.
2
+
3
+ Both sources are undocumented endpoints the vendors' own CLIs call, read with
4
+ the logins pcode already runs on, so no admin key is involved:
5
+
6
+ - Claude: `GET api.anthropic.com/api/oauth/usage` with Claude Code's OAuth
7
+ token. Subscription seats get `limits` (session and weekly percentages);
8
+ Enterprise seats billed at API rates get `spend` (used of the monthly cap).
9
+ - Codex: `GET chatgpt.com/backend-api/wham/usage` through the Codex provider's
10
+ auth, which injects the token, refreshes it on a 401, and never writes the
11
+ Codex CLI's `auth.json`.
12
+
13
+ Claude Code's token is read, never refreshed: its refresh tokens are single
14
+ use, so rotating one here would sign Claude Code out.
15
+ """
16
+
17
+ import asyncio
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ from collections.abc import Callable
25
+ from pathlib import Path
26
+
27
+ import httpx2
28
+
29
+ CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
30
+ CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
31
+ TIMEOUT = httpx2.Timeout(15, connect=5)
32
+ KEYCHAIN_SERVICE = "Claude Code-credentials"
33
+
34
+ CLAUDE_LIMIT_LABELS = {
35
+ "session": "Session (5h)",
36
+ "weekly_all": "Weekly",
37
+ "weekly_scoped": "Weekly",
38
+ }
39
+ CODEX_WINDOW_LABELS = {5 * 3600: "Session (5h)", 7 * 86400: "Weekly"}
40
+
41
+
42
+ class UsageUnavailable(Exception):
43
+ """A provider's usage could not be read; the message says why."""
44
+
45
+
46
+ def usage_report() -> list[str]:
47
+ """Transcript lines for every provider; one failing never hides the other."""
48
+ lines: list[str] = []
49
+ sources: tuple[tuple[str, Callable[[], list[str]]], ...] = (
50
+ ("Claude Code", claude_usage),
51
+ ("Codex", codex_usage),
52
+ )
53
+ for title, fetch in sources:
54
+ try:
55
+ body = fetch()
56
+ except UsageUnavailable as error:
57
+ body = [str(error)]
58
+ lines.append(title)
59
+ lines.extend(f" {line}" for line in body)
60
+ return lines
61
+
62
+
63
+ # Claude Code
64
+
65
+
66
+ def keychain_service(config_dir: str | None) -> str:
67
+ """Claude Code's Keychain item for a config dir.
68
+
69
+ Setting `CLAUDE_CONFIG_DIR` suffixes the name with a hash of its value,
70
+ even when the value is the default `~/.claude`, so the unsuffixed item can
71
+ belong to a different (often stale) login.
72
+ """
73
+ if not config_dir:
74
+ return KEYCHAIN_SERVICE
75
+ return f"{KEYCHAIN_SERVICE}-{hashlib.sha256(config_dir.encode()).hexdigest()[:8]}"
76
+
77
+
78
+ def claude_login(config_dir: str | None = None) -> dict:
79
+ """Claude Code's `claudeAiOauth` record: the macOS Keychain, else its credentials file."""
80
+ if config_dir is None:
81
+ config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "").strip() or None
82
+ raw = ""
83
+ if sys.platform == "darwin":
84
+ try:
85
+ raw = subprocess.run(
86
+ ["security", "find-generic-password", "-s", keychain_service(config_dir), "-w"],
87
+ capture_output=True,
88
+ text=True,
89
+ timeout=10,
90
+ ).stdout
91
+ except (OSError, subprocess.SubprocessError):
92
+ raw = ""
93
+ if not raw.strip():
94
+ path = Path(config_dir).expanduser() if config_dir else Path.home() / ".claude"
95
+ try:
96
+ raw = (path / ".credentials.json").read_text()
97
+ except OSError:
98
+ raw = ""
99
+ try:
100
+ login = json.loads(raw).get("claudeAiOauth") if raw.strip() else None
101
+ except (ValueError, AttributeError):
102
+ login = None
103
+ if not isinstance(login, dict) or not login.get("accessToken"):
104
+ where = f" for {config_dir}" if config_dir else ""
105
+ raise UsageUnavailable(f"No Claude Code login{where}; run `claude` and sign in.")
106
+ return login
107
+
108
+
109
+ def claude_usage() -> list[str]:
110
+ from pcode.auth import oauth_user_agent
111
+
112
+ login = claude_login()
113
+ expires = login.get("expiresAt")
114
+ if isinstance(expires, int | float) and expires / 1000 < time.time():
115
+ raise UsageUnavailable(
116
+ "Claude Code's token has expired; run `claude` (or a claude: model) to refresh it."
117
+ )
118
+ try:
119
+ response = httpx2.get(
120
+ CLAUDE_USAGE_URL,
121
+ timeout=TIMEOUT,
122
+ headers={
123
+ "Authorization": f"Bearer {login['accessToken']}",
124
+ "anthropic-beta": "oauth-2025-04-20",
125
+ # A generic client lands in a much stricter rate-limit bucket.
126
+ "User-Agent": oauth_user_agent(),
127
+ },
128
+ )
129
+ except httpx2.HTTPError as error:
130
+ raise UsageUnavailable(f"Request failed: {type(error).__name__}") from None
131
+ if response.status_code in (401, 403):
132
+ raise UsageUnavailable("Claude Code's login was rejected; run `claude` to sign in again.")
133
+ if response.status_code == 429:
134
+ raise UsageUnavailable("The usage endpoint is rate limiting; try again in a minute.")
135
+ if response.status_code != 200:
136
+ raise UsageUnavailable(f"The usage endpoint answered HTTP {response.status_code}.")
137
+ plan = login.get("subscriptionType")
138
+ return ([f"Plan: {plan}"] if plan else []) + claude_lines(response.json())
139
+
140
+
141
+ def claude_lines(data: dict, now: float | None = None) -> list[str]:
142
+ now = time.time() if now is None else now
143
+ lines = []
144
+ limits = data.get("limits")
145
+ if limits:
146
+ for limit in limits:
147
+ kind = limit.get("kind", "")
148
+ label = CLAUDE_LIMIT_LABELS.get(kind) or kind.replace("_", " ").capitalize()
149
+ model = ((limit.get("scope") or {}).get("model") or {}).get("display_name")
150
+ if model:
151
+ label = f"{label} ({model})"
152
+ resets = iso_time(limit.get("resets_at"))
153
+ lines.append(percent_line(label, limit.get("percent"), resets, now))
154
+ else:
155
+ # Older responses carry only the windows, with no `limits` list.
156
+ for key, label in (("five_hour", "Session (5h)"), ("seven_day", "Weekly")):
157
+ window = data.get(key)
158
+ if window:
159
+ resets = iso_time(window.get("resets_at"))
160
+ lines.append(percent_line(label, window.get("utilization"), resets, now))
161
+ spend = data.get("spend") or {}
162
+ used, limit = money(spend.get("used")), money(spend.get("limit"))
163
+ if used is not None and limit is not None:
164
+ line = f"Spend: {used} of {limit}"
165
+ if spend.get("percent") is not None:
166
+ line += f" ({spend['percent']:.0f}%)"
167
+ if not spend.get("enabled"):
168
+ reason = spend.get("disabled_reason")
169
+ line += f", off ({reason.replace('_', ' ')})" if reason else ", off"
170
+ lines.append(line)
171
+ return lines or ["No limits reported for this login."]
172
+
173
+
174
+ def money(amount: dict | None) -> str | None:
175
+ if not amount or amount.get("amount_minor") is None:
176
+ return None
177
+ value = amount["amount_minor"] / 10 ** amount.get("exponent", 2)
178
+ currency = amount.get("currency", "USD")
179
+ return f"${value:,.2f}" if currency == "USD" else f"{value:,.2f} {currency}"
180
+
181
+
182
+ # Codex
183
+
184
+
185
+ def codex_usage() -> list[str]:
186
+ try:
187
+ data = asyncio.run(_fetch_codex())
188
+ except UsageUnavailable:
189
+ raise
190
+ except httpx2.HTTPError as error:
191
+ raise UsageUnavailable(f"Request failed: {type(error).__name__}") from None
192
+ except Exception as error: # noqa: BLE001 - credential errors are the provider's own types.
193
+ raise UsageUnavailable(str(error).split("\n")[0]) from None
194
+ return codex_lines(data)
195
+
196
+
197
+ async def _fetch_codex() -> dict:
198
+ from pydantic_ai.providers.openai_codex import OpenAICodexProvider
199
+
200
+ from pcode.codex_login import credential_source
201
+
202
+ # The same route Codex model requests take: PCODE_LLM_PROXY when set.
203
+ proxy = os.environ.get("PCODE_LLM_PROXY", "").strip()
204
+ options = {"proxy": proxy, "trust_env": False} if proxy else {}
205
+ async with httpx2.AsyncClient(timeout=TIMEOUT, **options) as client:
206
+ source = credential_source()
207
+ # Attaches the provider's auth to `client`; without a pcode login it
208
+ # reads the Codex CLI's auth.json read-only.
209
+ if source is not None:
210
+ OpenAICodexProvider(credential_source=source, http_client=client)
211
+ else:
212
+ OpenAICodexProvider(http_client=client)
213
+ try:
214
+ response = await client.get(CODEX_USAGE_URL, headers={"Accept": "application/json"})
215
+ except (httpx2.ConnectError, httpx2.ConnectTimeout, httpx2.ProxyError) as error:
216
+ route = " through PCODE_LLM_PROXY" if proxy else ""
217
+ raise UsageUnavailable(
218
+ f"Could not reach chatgpt.com{route} ({type(error).__name__})."
219
+ ) from None
220
+ if response.status_code == 403 and "json" not in response.headers.get("content-type", ""):
221
+ hint = "" if proxy else "; set PCODE_LLM_PROXY if chatgpt.com needs a proxy here"
222
+ raise UsageUnavailable(f"chatgpt.com blocked the request (HTTP 403){hint}.")
223
+ if response.status_code != 200:
224
+ raise UsageUnavailable(f"The usage endpoint answered HTTP {response.status_code}.")
225
+ return response.json()
226
+
227
+
228
+ def codex_lines(data: dict, now: float | None = None) -> list[str]:
229
+ now = time.time() if now is None else now
230
+ lines = []
231
+ if data.get("plan_type"):
232
+ lines.append(f"Plan: {data['plan_type']}")
233
+ lines.extend(codex_window_lines(data.get("rate_limit"), "", now))
234
+ for extra in data.get("additional_rate_limits") or ():
235
+ name = extra.get("limit_name") or extra.get("metered_feature") or "Other"
236
+ lines.extend(codex_window_lines(extra.get("rate_limit"), f"{name} ", now))
237
+ credits = data.get("credits") or {}
238
+ if credits.get("unlimited"):
239
+ lines.append("Credits: unlimited")
240
+ elif credits.get("has_credits") and credits.get("balance") is not None:
241
+ lines.append(f"Credits: {credits['balance']}")
242
+ spend = data.get("spend_control") or {}
243
+ cap = spend.get("individual_limit")
244
+ if cap:
245
+ line = f"Spend: {cap.get('used')} of {cap.get('limit')} ({cap.get('used_percent', 0)}%)"
246
+ lines.append(line + (", limit reached" if spend.get("reached") else ""))
247
+ return lines or ["No limits reported for this login."]
248
+
249
+
250
+ def codex_window_lines(details: dict | None, prefix: str, now: float) -> list[str]:
251
+ lines = []
252
+ for key in ("primary_window", "secondary_window"):
253
+ window = (details or {}).get(key)
254
+ if not window:
255
+ continue
256
+ seconds = window.get("limit_window_seconds") or 0
257
+ label = CODEX_WINDOW_LABELS.get(seconds) or f"{duration(seconds)} window"
258
+ lines.append(
259
+ percent_line(prefix + label, window.get("used_percent"), window.get("reset_at"), now)
260
+ )
261
+ return lines
262
+
263
+
264
+ # Formatting
265
+
266
+
267
+ def percent_line(label: str, percent: float | None, resets: float | None, now: float) -> str:
268
+ line = f"{label}: {percent:.0f}% used" if percent is not None else f"{label}: unknown"
269
+ if resets is not None and resets > now:
270
+ line += f", resets in {duration(resets - now)}"
271
+ return line
272
+
273
+
274
+ def iso_time(value: str | None) -> float | None:
275
+ from datetime import datetime
276
+
277
+ if not value:
278
+ return None
279
+ try:
280
+ return datetime.fromisoformat(value).timestamp()
281
+ except ValueError:
282
+ return None
283
+
284
+
285
+ def duration(seconds: float) -> str:
286
+ minutes = int(seconds // 60)
287
+ days, minutes = divmod(minutes, 1440)
288
+ hours, minutes = divmod(minutes, 60)
289
+ if days:
290
+ return f"{days}d {hours}h" if hours else f"{days}d"
291
+ if hours:
292
+ return f"{hours}h {minutes}m" if minutes else f"{hours}h"
293
+ return f"{max(minutes, 1)}m"
pcode/word_wrap.py ADDED
@@ -0,0 +1,107 @@
1
+ """Word-aware soft wrapping for the editor buffer.
2
+
3
+ prompt_toolkit wraps a buffer line strictly by column: the character that no
4
+ longer fits moves to the next row, so a word straddles the wrap point. There is
5
+ no word-wrap option on ``Window``/``BufferControl`` -- the break is decided
6
+ character by character deep inside ``Window._copy_body``.
7
+
8
+ Rather than reimplement that rendering, this pads the *display* text: when a
9
+ word would straddle the wrap point, enough spaces are inserted before it to
10
+ fill the current row, so prompt_toolkit's own column wrapping lands on the word
11
+ boundary. The buffer text is untouched; the cursor mappings below keep editing
12
+ positions correct, the same technique ``TabsProcessor`` uses to expand tabs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from prompt_toolkit.layout.processors import Processor, Transformation, TransformationInput
18
+ from prompt_toolkit.layout.utils import explode_text_fragments
19
+ from prompt_toolkit.utils import get_cwidth
20
+
21
+ __all__ = ["WordWrapProcessor", "wrap_padding"]
22
+
23
+
24
+ def wrap_padding(text: str, width: int) -> dict[int, int]:
25
+ """Map index -> number of spaces to insert before ``text[index]``.
26
+
27
+ ``width`` is the number of columns available for the text itself (the line
28
+ prefix is already subtracted). Words wider than a full row are left alone:
29
+ they have to be split somewhere, and padding them would only waste a row.
30
+ """
31
+ if width < 2 or not text:
32
+ return {}
33
+
34
+ padding: dict[int, int] = {}
35
+ column = 0
36
+ index = 0
37
+ length = len(text)
38
+ while index < length:
39
+ char = text[index]
40
+ starts_word = not char.isspace() and (index == 0 or text[index - 1].isspace())
41
+ # A word starting at column 0, or one whose row is already full, wraps
42
+ # correctly on its own.
43
+ if starts_word and 0 < column < width:
44
+ end = index
45
+ while end < length and not text[end].isspace():
46
+ end += 1
47
+ word = get_cwidth(text[index:end])
48
+ if column + word > width >= word:
49
+ padding[index] = width - column
50
+ column = 0
51
+ char_width = get_cwidth(char)
52
+ if column + char_width > width:
53
+ column = 0
54
+ column += char_width
55
+ index += 1
56
+ return padding
57
+
58
+
59
+ class WordWrapProcessor(Processor):
60
+ """Pad display lines so soft wraps fall between words.
61
+
62
+ :param prefix_width: Columns taken by the window's line prefix (the prompt
63
+ and its continuation), which ``TransformationInput.width`` includes.
64
+ """
65
+
66
+ def __init__(self, prefix_width: int = 0) -> None:
67
+ self.prefix_width = prefix_width
68
+
69
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
70
+ fragments = explode_text_fragments(ti.fragments)
71
+ padding = wrap_padding(
72
+ "".join(text for _, text, *_ in fragments), ti.width - self.prefix_width
73
+ )
74
+ if not padding:
75
+ return Transformation(ti.fragments)
76
+
77
+ result = []
78
+ positions = {}
79
+ position = 0
80
+ for index, fragment in enumerate(fragments):
81
+ pad = padding.get(index)
82
+ if pad:
83
+ result.append(("", " " * pad))
84
+ position += pad
85
+ positions[index] = position
86
+ result.append(fragment)
87
+ position += 1
88
+ # The cursor can also sit right after the line, and one past that.
89
+ positions[len(fragments)] = position
90
+ positions[len(fragments) + 1] = position + 1
91
+
92
+ def source_to_display(from_position: int) -> int:
93
+ return positions.get(from_position, from_position)
94
+
95
+ def display_to_source(display_position: int) -> int:
96
+ reversed_positions = {v: k for k, v in positions.items()}
97
+ while display_position >= 0:
98
+ if display_position in reversed_positions:
99
+ return reversed_positions[display_position]
100
+ display_position -= 1
101
+ return 0
102
+
103
+ return Transformation(
104
+ result,
105
+ source_to_display=source_to_display,
106
+ display_to_source=display_to_source,
107
+ )
pcode/worker_ui.py ADDED
@@ -0,0 +1,235 @@
1
+ """Read-only popup following delegated agents' own streams; never steers them."""
2
+
3
+ import asyncio
4
+
5
+ from prompt_toolkit.application import Application, get_app
6
+ from prompt_toolkit.document import Document
7
+ from prompt_toolkit.filters import Always, has_focus
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+ from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
10
+ from prompt_toolkit.layout import DynamicContainer, HSplit, Layout, VSplit
11
+ from prompt_toolkit.layout.dimension import Dimension
12
+ from prompt_toolkit.widgets import Label, TextArea
13
+ from rich.markdown import Markdown
14
+ from rich.text import Text
15
+ from rich.theme import Theme
16
+
17
+ from pcode.frame import Frame
18
+ from pcode.popup_ui import (
19
+ RichPane,
20
+ bind_list_paging,
21
+ list_pane_height,
22
+ popup_container,
23
+ popup_mouse,
24
+ popup_style,
25
+ )
26
+ from pcode.prefix_keys import PrefixKeys
27
+ from pcode.runtime import ToolSummary
28
+ from pcode.session_ui import literal
29
+ from pcode.task_prompt import TaskPrompt
30
+ from pcode.tool_display import label, plain
31
+ from pcode.tool_panel import ACTIVE_TASK_ICON, AGENT_ICON, plan_row
32
+ from pcode.workers import Worker, Workers
33
+
34
+ # Streaming should look live without repainting the pane every token.
35
+ REFRESH_SECONDS = 0.3
36
+
37
+
38
+ def row(worker: Worker, width: int = 90) -> str:
39
+ """One list line: who, how long, what state, and the assignment."""
40
+ name = worker.agent[:1].upper() + worker.agent[1:]
41
+ task = plain(" ".join(worker.task.split()), width)
42
+ return f"{AGENT_ICON} {name} · {worker.elapsed():.0f}s · {worker.state()} · {task}"
43
+
44
+
45
+ def tool_line(event) -> Text:
46
+ if isinstance(event, ToolSummary):
47
+ icon, style = ("✗", "red") if event.failed else ("✓", "dim")
48
+ elapsed = f" · {event.elapsed_seconds:.1f}s" if event.elapsed_seconds is not None else ""
49
+ else:
50
+ icon, style, elapsed = "⟳", "bold", ""
51
+ detail = plain(event.detail, limit=None)
52
+ return Text(f"{icon} {label(event.name)} · {detail}{elapsed}", style=style)
53
+
54
+
55
+ def details(worker: Worker | None, *, code_theme: str, show_thinking: bool) -> list:
56
+ """The worker's assignment, plan, then everything it said and did, in order."""
57
+ if worker is None:
58
+ return [Text("Agents appear here once the model delegates a task.", style="dim")]
59
+ blocks: list = [
60
+ TaskPrompt(literal(worker.task)),
61
+ Text(f" {worker.agent} · {worker.elapsed():.0f}s · {worker.state()}", style="dim"),
62
+ ]
63
+ if worker.plan:
64
+ done = sum(item.get("status") == "completed" for item in worker.plan)
65
+ blocks += [Text(""), Text(f"Tasks {done}/{len(worker.plan)}", style="bold")]
66
+ for item in worker.plan:
67
+ style, line = plan_row(item, ACTIVE_TASK_ICON)
68
+ active = style == "class:plan.in_progress"
69
+ blocks.append(Text(f" {line}", style="bold" if active else "dim"))
70
+ for entry in worker.entries:
71
+ if entry.kind == "tool":
72
+ blocks.append(tool_line(entry.tool))
73
+ elif entry.kind == "thinking":
74
+ if show_thinking and entry.text.strip():
75
+ blocks += [Text(""), Text(entry.text.strip(), style="dim italic"), Text("")]
76
+ elif text := literal(entry.text).strip():
77
+ blocks += [Text(""), Markdown(text, code_theme=code_theme), Text("")]
78
+ if worker.running:
79
+ blocks += [Text(""), Text(f" ({worker.state()}…)", style="dim")]
80
+ return blocks
81
+
82
+
83
+ class WorkerBrowser:
84
+ """Follow delegated workers beside a conversation that may still be running."""
85
+
86
+ def __init__(
87
+ self,
88
+ workers: Workers,
89
+ *,
90
+ rich_theme: Theme | None = None,
91
+ code_theme: str = "ansi_dark",
92
+ color_system: str | None = "truecolor",
93
+ show_thinking: bool = False,
94
+ key_prefix: str | None = None,
95
+ **app_options,
96
+ ) -> None:
97
+ self.workers = workers
98
+ self.code_theme = code_theme
99
+ self.show_thinking = show_thinking
100
+ self.items = list(workers.items)
101
+ latest = workers.latest()
102
+ self.selected = latest.call_id if latest else None
103
+ self._rendered: tuple | None = None
104
+ self._refreshing = False
105
+ self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
106
+ self.list.window.cursorline = Always()
107
+ self.detail = RichPane(theme=rich_theme, color_system=color_system)
108
+ self.list.buffer.on_cursor_position_changed += lambda _: self.select()
109
+ keys = KeyBindings()
110
+ self.detail.bind_scrolling(keys)
111
+ bind_list_paging(keys, self.list, has_focus(self.list))
112
+
113
+ @keys.add("escape", eager=True)
114
+ @keys.add("c-c")
115
+ @keys.add("enter")
116
+ def close(event):
117
+ event.app.exit(result=None)
118
+
119
+ self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
120
+ shortcuts.set_help(
121
+ lambda: [
122
+ ("↑/↓", "Select agent / scroll output"),
123
+ ("PgUp/PgDn", "Page"),
124
+ ("Ctrl+U/D", "Half page"),
125
+ ("Tab/Shift+Tab", "Change focus"),
126
+ ("Enter/Esc/Ctrl+C", "Close"),
127
+ ]
128
+ )
129
+
130
+ @shortcuts.add("t", "Thinking")
131
+ def toggle_thinking(event):
132
+ self.show_thinking = not self.show_thinking
133
+ self.select(force=True)
134
+
135
+ keys.add("tab")(focus_next)
136
+ keys.add("s-tab")(focus_previous)
137
+
138
+ header = Label(
139
+ lambda: (
140
+ f"Agents · {len(self.items)} this session · {self.workers.running()} running"
141
+ f" · read-only · thinking {'on' if self.show_thinking else 'off'}"
142
+ )
143
+ )
144
+ wide = VSplit(
145
+ [
146
+ Frame(self.list, title="Agents", width=Dimension(weight=2)),
147
+ Frame(self.detail, title="Output", width=Dimension(weight=3)),
148
+ ]
149
+ )
150
+ narrow = HSplit(
151
+ [
152
+ Frame(self.list, title="Agents", height=lambda: list_pane_height(len(self.items))),
153
+ Frame(self.detail, title="Output"),
154
+ ]
155
+ )
156
+ body = DynamicContainer(
157
+ lambda: wide if get_app().output.get_size().columns >= 100 else narrow
158
+ )
159
+ root_container = HSplit(
160
+ [
161
+ header,
162
+ body,
163
+ Label(shortcuts.summary),
164
+ ]
165
+ )
166
+ self.app = Application(
167
+ layout=Layout(popup_container(root_container, shortcuts), focused_element=self.list),
168
+ key_bindings=shortcuts.key_bindings(keys),
169
+ full_screen=True,
170
+ mouse_support=popup_mouse(shortcuts),
171
+ style=popup_style(app_options.pop("style", None)),
172
+ **app_options,
173
+ )
174
+ self.refresh()
175
+
176
+ def refresh(self, *, force: bool = True) -> None:
177
+ """Rebuild the list from the current records, keeping the selection."""
178
+ self.items = list(self.workers.items)
179
+ lines = [row(worker) for worker in self.items] or ["No agents yet"]
180
+ index = next(
181
+ (i for i, worker in enumerate(self.items) if worker.call_id == self.selected),
182
+ max(0, len(self.items) - 1),
183
+ )
184
+ position = sum(len(line) + 1 for line in lines[:index])
185
+ self._refreshing = True
186
+ self.list.buffer.set_document(Document("\n".join(lines), position), bypass_readonly=True)
187
+ self._refreshing = False
188
+ self.select(force=force)
189
+
190
+ def current(self) -> Worker | None:
191
+ return next((w for w in self.items if w.call_id == self.selected), None)
192
+
193
+ def select(self, force: bool = False) -> None:
194
+ if self._refreshing:
195
+ return
196
+ index = self.list.document.cursor_position_row
197
+ if index < len(self.items):
198
+ self.selected = self.items[index].call_id
199
+ worker = self.current()
200
+ # Elapsed time moves every second even when nothing else changes.
201
+ state = (
202
+ (worker.call_id, worker.version, int(worker.elapsed()), self.show_thinking)
203
+ if worker
204
+ else (None,)
205
+ )
206
+ if state == self._rendered and not force:
207
+ return
208
+ switched = self._rendered is None or state[0] != self._rendered[0]
209
+ self._rendered = state
210
+ blocks = details(worker, code_theme=self.code_theme, show_thinking=self.show_thinking)
211
+ if switched and worker is not None and worker.running:
212
+ # A running worker opens on its newest output, then follows it.
213
+ self.detail.set(blocks, anchor=len(blocks) - 1)
214
+ elif switched:
215
+ self.detail.set(blocks)
216
+ else:
217
+ self.detail.follow(blocks)
218
+
219
+ async def run(self) -> None:
220
+ async def follow():
221
+ while True:
222
+ await asyncio.sleep(REFRESH_SECONDS)
223
+ if [w.call_id for w in self.workers.items] != [w.call_id for w in self.items]:
224
+ self.refresh()
225
+ elif any(w.running for w in self.items):
226
+ # List rows carry elapsed time and state; keep them moving too.
227
+ self.refresh(force=False)
228
+ else:
229
+ self.select()
230
+ self.app.invalidate()
231
+
232
+ def start():
233
+ self.app.create_background_task(follow())
234
+
235
+ await self.app.run_async(pre_run=start)