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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|