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/terminal_notify.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Escape sequences for the terminal emulator itself, not the screen: desktop
|
|
2
|
+
notifications (OSC 9), tab progress (OSC 9;4), and the tab title (OSC 0).
|
|
3
|
+
|
|
4
|
+
Ghostty shows both by default (`desktop-notifications`, `progress-style`);
|
|
5
|
+
iTerm2 and WezTerm take OSC 9 too, and terminals that know neither ignore an
|
|
6
|
+
unknown OSC. tmux drops them unless wrapped for passthrough and the server has
|
|
7
|
+
`allow-passthrough on`.
|
|
8
|
+
|
|
9
|
+
Tab progress is only sent where it is known to be drawn (`progress_transport`):
|
|
10
|
+
iTerm2 before 3.6.6 reads any OSC 9 as a notification and would pop up "4;3"
|
|
11
|
+
on every turn, which is why pytest turned the same feature off by default.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from time import monotonic
|
|
20
|
+
|
|
21
|
+
from pcode.plan_sizes import plan_progress
|
|
22
|
+
|
|
23
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
|
24
|
+
|
|
25
|
+
# OSC 9;4 states (ConEmu's numbering, which every terminal kept).
|
|
26
|
+
CLEAR, NORMAL, ERROR, INDETERMINATE, PAUSED = 0, 1, 2, 3, 4
|
|
27
|
+
|
|
28
|
+
# Ghostty drops a report nobody refreshes after about 15 seconds, and
|
|
29
|
+
# recommends re-sending well inside that; other terminals do not mind.
|
|
30
|
+
KEEPALIVE_SECONDS = 5.0
|
|
31
|
+
TICK_SECONDS = 1.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _passthrough(sequence: str) -> str:
|
|
35
|
+
"""tmux's DCS passthrough: the sequence reaches the outer terminal as is."""
|
|
36
|
+
return "\x1bPtmux;" + sequence.replace("\x1b", "\x1b\x1b") + "\x1b\\"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _outer(sequence: str) -> str:
|
|
40
|
+
"""Wrap for tmux, which otherwise swallows OSC sequences it does not know."""
|
|
41
|
+
return _passthrough(sequence) if os.environ.get("TMUX") else sequence
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def notification(text: str) -> str:
|
|
45
|
+
# Control characters would end the sequence early; and a body starting
|
|
46
|
+
# with "4;" would be read as a progress report, which "pcode" never is.
|
|
47
|
+
body = _CONTROL.sub(" ", text).strip()[:200]
|
|
48
|
+
return _outer(f"\x1b]9;{body}\x07")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def progress(state: int, value: int | None = None) -> str:
|
|
52
|
+
"""One OSC 9;4 report, unwrapped: `progress_transport` says how it travels."""
|
|
53
|
+
return f"\x1b]9;4;{state}" + ("" if value is None else f";{value}") + "\x07"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Which terminals draw OSC 9;4
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version(text: str) -> tuple[int, ...]:
|
|
60
|
+
return tuple(int(part) for part in re.findall(r"\d+", text)[:3])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _number(text: str | None) -> int:
|
|
64
|
+
return int(text) if text and text.isdigit() else 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# kitty is left to `on`: before 0.38 it shows the report as a desktop
|
|
68
|
+
# notification, before 0.47 it draws nothing, and it exports no version.
|
|
69
|
+
_DRAWS = ("ghostty", "wezterm", "vscode", "mintty", "warpterminal")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _iterm(name: str) -> bool:
|
|
73
|
+
return name.lower() in ("iterm.app", "iterm2")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _known(name: str, version: str = "") -> bool:
|
|
77
|
+
"""A terminal, by the name it gives itself, that draws the bar."""
|
|
78
|
+
if name.lower() in _DRAWS:
|
|
79
|
+
return True
|
|
80
|
+
# Before 3.6.6 iTerm2 shows a notification instead: never guess its version.
|
|
81
|
+
return _iterm(name) and _version(version) >= (3, 6, 6)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _old_iterm(name: str, version: str) -> bool:
|
|
85
|
+
return _iterm(name) and not _known(name, version)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _progress_feature(features: str) -> bool:
|
|
89
|
+
"""iTerm2's TERM_FEATURES is a run of capitalised codes; "P" is progress."""
|
|
90
|
+
return "P" in re.findall(r"[A-Z][a-z0-9]*", features.split()[0] if features else "")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def environment_supports(env: Mapping[str, str]) -> bool:
|
|
94
|
+
"""Whether the terminal pcode runs in directly draws the bar.
|
|
95
|
+
|
|
96
|
+
The same checks as Cargo's (anstyle-progress), plus variables that survive
|
|
97
|
+
where TERM_PROGRAM does not: ssh keeps TERM, and inside tmux both TERM and
|
|
98
|
+
TERM_PROGRAM name tmux while the server's environment still names the
|
|
99
|
+
terminal it was started from.
|
|
100
|
+
"""
|
|
101
|
+
# A terminal that says it is an iTerm2 too old for the bar would show a
|
|
102
|
+
# notification, whatever else a tmux server's stale environment claims.
|
|
103
|
+
if _old_iterm(env.get("TERM_PROGRAM", ""), env.get("TERM_PROGRAM_VERSION", "")):
|
|
104
|
+
return False
|
|
105
|
+
if _old_iterm(env.get("LC_TERMINAL", ""), env.get("LC_TERMINAL_VERSION", "")):
|
|
106
|
+
return False
|
|
107
|
+
if _progress_feature(env.get("TERM_FEATURES", "")):
|
|
108
|
+
return True
|
|
109
|
+
if _known(env.get("TERM_PROGRAM", ""), env.get("TERM_PROGRAM_VERSION", "")):
|
|
110
|
+
return True
|
|
111
|
+
# iTerm2 sets these too, and ssh forwards LC_* by default.
|
|
112
|
+
if _known(env.get("LC_TERMINAL", ""), env.get("LC_TERMINAL_VERSION", "")):
|
|
113
|
+
return True
|
|
114
|
+
if env.get("TERM") == "xterm-ghostty" or env.get("GHOSTTY_RESOURCES_DIR"):
|
|
115
|
+
return True
|
|
116
|
+
if env.get("WEZTERM_EXECUTABLE"):
|
|
117
|
+
return True
|
|
118
|
+
if env.get("WT_SESSION") or env.get("ConEmuANSI") == "ON":
|
|
119
|
+
return True
|
|
120
|
+
# VTE 0.79 (GNOME Terminal, Ptyxis) and Konsole 26.04 added it.
|
|
121
|
+
return _number(env.get("VTE_VERSION")) >= 7900 or _number(env.get("KONSOLE_VERSION")) >= 260400
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _into_tmux(sequence: str) -> str:
|
|
125
|
+
"""Both ways through tmux, so neither needs asking about.
|
|
126
|
+
|
|
127
|
+
The passthrough copy reaches the outer terminal when `allow-passthrough`
|
|
128
|
+
is on, and is the only one that carries a keep-alive: tmux 3.7 forwards
|
|
129
|
+
the raw copy itself, but only when the active pane's state changes. Older
|
|
130
|
+
tmux drops the raw copy, and a terminal that gets both sees one state.
|
|
131
|
+
"""
|
|
132
|
+
return _passthrough(sequence) + sequence
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def progress_transport(
|
|
136
|
+
mode: str, env: Mapping[str, str], *, tty: bool
|
|
137
|
+
) -> Callable[[str], str] | None:
|
|
138
|
+
"""How a report reaches the terminal (a wrapper), or None to send nothing.
|
|
139
|
+
|
|
140
|
+
`mode` is the preference: "off", "on" (send whatever the terminal), or
|
|
141
|
+
"auto" (only where the bar is known to be drawn).
|
|
142
|
+
"""
|
|
143
|
+
if mode == "off" or not tty or env.get("TERM") == "dumb":
|
|
144
|
+
return None
|
|
145
|
+
if mode != "on" and not environment_supports(env):
|
|
146
|
+
return None
|
|
147
|
+
return _into_tmux if env.get("TMUX") else str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Keeping the bar in step with the turn
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TabProgress:
|
|
154
|
+
"""Mirror the turn into the terminal's tab progress, and keep it alive.
|
|
155
|
+
|
|
156
|
+
Blue (indeterminate) while a turn runs, filling as plan steps complete;
|
|
157
|
+
orange (paused) while a failed provider request is retried; red (error)
|
|
158
|
+
after a failed turn until a key is pressed here or the next turn starts.
|
|
159
|
+
|
|
160
|
+
Sampled from the activity once a second rather than pushed from each hook,
|
|
161
|
+
so a hosted session's mirrored state needs nothing extra, and written
|
|
162
|
+
straight to the terminal: an invisible OSC has no reason to wait for, or
|
|
163
|
+
cause, a repaint, and never lands inside a frame prompt_toolkit is still
|
|
164
|
+
building. With no terminal (`fd` None) the hooks still work and nothing
|
|
165
|
+
is sent.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self, activity, fd: int | None, mode: str, env: Mapping[str, str] | None = None):
|
|
169
|
+
self.activity = activity
|
|
170
|
+
self.fd = fd
|
|
171
|
+
self.mode = mode
|
|
172
|
+
self.env = os.environ if env is None else env
|
|
173
|
+
self.retrying = False
|
|
174
|
+
# The last failure has been seen: its red bar is down.
|
|
175
|
+
self.dismissed = False
|
|
176
|
+
self.wrap: Callable[[str], str] | None = None
|
|
177
|
+
self.sent: str | None = None
|
|
178
|
+
self.sent_at = 0.0
|
|
179
|
+
|
|
180
|
+
def report(self) -> tuple[int, int | None]:
|
|
181
|
+
activity = self.activity
|
|
182
|
+
if activity.prompt_state == "running" or activity.user_command:
|
|
183
|
+
if self.retrying:
|
|
184
|
+
return PAUSED, None
|
|
185
|
+
# An empty bar reads as nothing happening; bounce until a step starts.
|
|
186
|
+
done = plan_progress(activity.plan)
|
|
187
|
+
if done:
|
|
188
|
+
return NORMAL, max(1, round(100 * done))
|
|
189
|
+
return INDETERMINATE, None
|
|
190
|
+
if activity.prompt_state == "failed" and not self.dismissed:
|
|
191
|
+
return ERROR, 100
|
|
192
|
+
return CLEAR, None
|
|
193
|
+
|
|
194
|
+
# The turn's hooks, from the terminal's view.
|
|
195
|
+
|
|
196
|
+
def turn_started(self) -> None:
|
|
197
|
+
self.retrying = False
|
|
198
|
+
self.dismissed = False
|
|
199
|
+
|
|
200
|
+
def turn_retry(self) -> None:
|
|
201
|
+
self.retrying = True
|
|
202
|
+
|
|
203
|
+
def turn_event(self) -> None:
|
|
204
|
+
self.retrying = False
|
|
205
|
+
|
|
206
|
+
def turn_ended(self) -> None:
|
|
207
|
+
# A turn that failed mid-retry never sends the event that ends it.
|
|
208
|
+
self.retrying = False
|
|
209
|
+
|
|
210
|
+
def switched(self) -> None:
|
|
211
|
+
"""Another session is on screen: nothing seen or retried here is its."""
|
|
212
|
+
self.retrying = False
|
|
213
|
+
self.dismissed = False
|
|
214
|
+
|
|
215
|
+
def key_pressed(self, _event=None) -> None:
|
|
216
|
+
# Only once the red bar is up: typing ahead while a turn fails must
|
|
217
|
+
# not dismiss it before it was ever shown.
|
|
218
|
+
if self.sent == progress(ERROR, 100):
|
|
219
|
+
self.dismissed = True
|
|
220
|
+
|
|
221
|
+
def tick(self, now: float) -> None:
|
|
222
|
+
sequence = progress(*self.report())
|
|
223
|
+
idle = sequence == progress(CLEAR)
|
|
224
|
+
if sequence != self.sent or (not idle and now - self.sent_at >= KEEPALIVE_SECONDS):
|
|
225
|
+
self._write(sequence, now)
|
|
226
|
+
|
|
227
|
+
def close(self) -> None:
|
|
228
|
+
"""Take the bar down on the way out; a dead pcode must not look busy."""
|
|
229
|
+
if self.sent not in (None, progress(CLEAR)):
|
|
230
|
+
self._write(progress(CLEAR), monotonic())
|
|
231
|
+
|
|
232
|
+
async def run(self) -> None:
|
|
233
|
+
tty = self.fd is not None and _isatty(self.fd)
|
|
234
|
+
self.wrap = progress_transport(self.mode, self.env, tty=tty)
|
|
235
|
+
if self.wrap is None:
|
|
236
|
+
return
|
|
237
|
+
try:
|
|
238
|
+
while True:
|
|
239
|
+
self.tick(monotonic())
|
|
240
|
+
await asyncio.sleep(TICK_SECONDS)
|
|
241
|
+
finally:
|
|
242
|
+
self.close()
|
|
243
|
+
|
|
244
|
+
@asynccontextmanager
|
|
245
|
+
async def shown(self):
|
|
246
|
+
"""Keep the bar in step while the block runs, for a caller with no event loop of its own."""
|
|
247
|
+
task = asyncio.create_task(self.run())
|
|
248
|
+
try:
|
|
249
|
+
yield self
|
|
250
|
+
finally:
|
|
251
|
+
task.cancel()
|
|
252
|
+
# Now, not when the cancelled task next runs: the loop may be closing.
|
|
253
|
+
self.close()
|
|
254
|
+
|
|
255
|
+
def _write(self, sequence: str, now: float) -> None:
|
|
256
|
+
if self.wrap is None:
|
|
257
|
+
return
|
|
258
|
+
# On failure the terminal is gone; the next tick tries again.
|
|
259
|
+
if _write_all(self.fd, self.wrap(sequence).encode()):
|
|
260
|
+
self.sent, self.sent_at = sequence, now
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# The tab title
|
|
264
|
+
|
|
265
|
+
# xterm's title stack (XTWINOPS 22/23): save the title the shell left, put it
|
|
266
|
+
# back on the way out. xterm, iTerm2, kitty, Ghostty and VTE keep one;
|
|
267
|
+
# elsewhere the title stays until the shell's prompt next sets its own.
|
|
268
|
+
SAVE_TITLE = "\x1b[22;0t"
|
|
269
|
+
RESTORE_TITLE = "\x1b[23;0t"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def title(text: str) -> str:
|
|
273
|
+
"""OSC 0, which sets the tab (icon) title and the window title alike.
|
|
274
|
+
|
|
275
|
+
OSC 2 alone leaves iTerm2's tab as it was, and OSC 1 alone is not read
|
|
276
|
+
by most other terminals. Inside tmux it names the pane, unwrapped: the
|
|
277
|
+
outer tab belongs to tmux, which shows it with `set-titles on`.
|
|
278
|
+
"""
|
|
279
|
+
return f"\x1b]0;{' '.join(_CONTROL.sub(' ', text).split())[:120]}\x07"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class TabTitle:
|
|
283
|
+
"""Keep the terminal's title on the session's name, sampled once a second.
|
|
284
|
+
|
|
285
|
+
`source` is read on each tick, so a name given by /rename, by the model
|
|
286
|
+
during the first turn, or by the host a hosted terminal follows needs no
|
|
287
|
+
hook of its own. Nothing is sent until there is a name, so a session
|
|
288
|
+
without one leaves the title to the shell.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(self, source: Callable[[], str], fd: int | None, mode: str = "on") -> None:
|
|
292
|
+
self.source = source
|
|
293
|
+
self.fd = fd
|
|
294
|
+
self.mode = mode
|
|
295
|
+
self.sent = ""
|
|
296
|
+
self.saved = False
|
|
297
|
+
|
|
298
|
+
def tick(self) -> None:
|
|
299
|
+
try:
|
|
300
|
+
text = " ".join((self.source() or "").split())
|
|
301
|
+
except Exception: # noqa: BLE001 - a title is never worth an error.
|
|
302
|
+
return
|
|
303
|
+
if text == self.sent:
|
|
304
|
+
return
|
|
305
|
+
if text:
|
|
306
|
+
data = ("" if self.saved else SAVE_TITLE) + title(text)
|
|
307
|
+
else:
|
|
308
|
+
# The name went (/new): hand the shell's title back, and keep it
|
|
309
|
+
# saved for the next name.
|
|
310
|
+
data = RESTORE_TITLE + SAVE_TITLE
|
|
311
|
+
if _write_all(self.fd, data.encode()):
|
|
312
|
+
self.sent, self.saved = text, True
|
|
313
|
+
|
|
314
|
+
def close(self) -> None:
|
|
315
|
+
if self.saved and _write_all(self.fd, RESTORE_TITLE.encode()):
|
|
316
|
+
self.saved, self.sent = False, ""
|
|
317
|
+
|
|
318
|
+
async def run(self) -> None:
|
|
319
|
+
if self.mode != "on" or self.fd is None or not _isatty(self.fd):
|
|
320
|
+
return
|
|
321
|
+
if os.environ.get("TERM") == "dumb":
|
|
322
|
+
return
|
|
323
|
+
try:
|
|
324
|
+
while True:
|
|
325
|
+
self.tick()
|
|
326
|
+
await asyncio.sleep(TICK_SECONDS)
|
|
327
|
+
finally:
|
|
328
|
+
self.close()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _write_all(fd: int | None, data: bytes) -> bool:
|
|
332
|
+
"""Write all of `data` or report failure; False when the terminal is gone.
|
|
333
|
+
|
|
334
|
+
All of it or nothing: a truncated OSC leaves the terminal inside a string
|
|
335
|
+
that swallows the next frame. The fd may have been made non-blocking under
|
|
336
|
+
us; prompt_toolkit flushes frames the same way.
|
|
337
|
+
"""
|
|
338
|
+
if fd is None:
|
|
339
|
+
return False
|
|
340
|
+
try:
|
|
341
|
+
blocking = os.get_blocking(fd)
|
|
342
|
+
os.set_blocking(fd, True)
|
|
343
|
+
try:
|
|
344
|
+
while data:
|
|
345
|
+
data = data[os.write(fd, data) :]
|
|
346
|
+
finally:
|
|
347
|
+
os.set_blocking(fd, blocking)
|
|
348
|
+
except OSError:
|
|
349
|
+
return False
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _isatty(fd: int) -> bool:
|
|
354
|
+
try:
|
|
355
|
+
return os.isatty(fd)
|
|
356
|
+
except OSError:
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def terminal_fd(*streams) -> int | None:
|
|
361
|
+
"""The descriptor of the first stream that is a terminal, or None."""
|
|
362
|
+
for stream in streams:
|
|
363
|
+
try:
|
|
364
|
+
fd = stream.fileno()
|
|
365
|
+
except (AttributeError, OSError, ValueError):
|
|
366
|
+
continue # In memory (StringIO), or closed.
|
|
367
|
+
if _isatty(fd):
|
|
368
|
+
return fd
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def send(output, sequence: str) -> None:
|
|
373
|
+
"""Queue on a prompt_toolkit output; it goes out with the next repaint.
|
|
374
|
+
|
|
375
|
+
Never flushed here: scrollback handoffs build one atomic write across
|
|
376
|
+
awaits, and flushing in the middle of one sends half a frame early (the
|
|
377
|
+
tmux cursor tests caught exactly that). An invisible OSC riding along
|
|
378
|
+
with a repaint changes nothing on screen.
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
output.write_raw(sequence)
|
|
382
|
+
except Exception: # noqa: BLE001 - a notification is never worth an error.
|
|
383
|
+
pass
|
pcode/theme.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Best-effort startup detection of the terminal's default background.
|
|
2
|
+
|
|
3
|
+
OSC 11 is an xterm query, not a command to change the terminal's colors.
|
|
4
|
+
Never query while prompt_toolkit owns input; cache the result at startup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import select
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
THEMES = ("dark", "light", "auto")
|
|
14
|
+
_pending_input = bytearray()
|
|
15
|
+
_BACKGROUND = re.compile(
|
|
16
|
+
rb"\x1b\]11;rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def background_theme(response: bytes) -> str | None:
|
|
21
|
+
"""Classify an OSC 11 RGB response (components may use 1–4 hex digits)."""
|
|
22
|
+
match = _BACKGROUND.search(response)
|
|
23
|
+
if match is None:
|
|
24
|
+
return None
|
|
25
|
+
rgb = [int(value, 16) / (16 ** len(value) - 1) for value in match.groups()]
|
|
26
|
+
# Perceived brightness, rather than the unweighted RGB average.
|
|
27
|
+
brightness = sum(value * weight for value, weight in zip(rgb, (0.299, 0.587, 0.114)))
|
|
28
|
+
return "light" if brightness >= 0.5 else "dark"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _terminal():
|
|
32
|
+
"""Descriptors to read the reply on and write the query to, plus any handle to close.
|
|
33
|
+
|
|
34
|
+
Standard streams first, so an ordinary session queries exactly what it
|
|
35
|
+
talks to. `--print` can be fed its prompt on stdin and still render to a
|
|
36
|
+
terminal, so a redirected stream falls back to the controlling terminal
|
|
37
|
+
instead of costing detection. Reading and writing are separate because
|
|
38
|
+
stdin is not always opened for writing.
|
|
39
|
+
"""
|
|
40
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
41
|
+
return sys.stdin.fileno(), sys.stdout.fileno(), None
|
|
42
|
+
try:
|
|
43
|
+
handle = open("/dev/tty", "r+b", buffering=0)
|
|
44
|
+
except OSError: # No controlling terminal: a daemon, or a CI runner.
|
|
45
|
+
return None, None, None
|
|
46
|
+
return handle.fileno(), handle.fileno(), handle
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _query_background(timeout: float = 0.15) -> str | None:
|
|
50
|
+
# Nothing will be colored, so there is no palette to match: stay quiet
|
|
51
|
+
# rather than write an escape sequence into whatever stdout is.
|
|
52
|
+
if os.environ.get("TERM") == "dumb" or not (sys.stdout.isatty() or sys.stderr.isatty()):
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
import termios
|
|
56
|
+
except ImportError:
|
|
57
|
+
return None
|
|
58
|
+
fd, query_fd, handle = _terminal()
|
|
59
|
+
if fd is None:
|
|
60
|
+
return None
|
|
61
|
+
response = bytearray()
|
|
62
|
+
try:
|
|
63
|
+
# Don't steal already queued input, or flush it on entering/leaving cbreak.
|
|
64
|
+
if select.select([fd], [], [], 0)[0]:
|
|
65
|
+
return None
|
|
66
|
+
original = termios.tcgetattr(fd)
|
|
67
|
+
mode = termios.tcgetattr(fd)
|
|
68
|
+
mode[3] &= ~(termios.ICANON | termios.ECHO)
|
|
69
|
+
mode[6][termios.VMIN] = 0
|
|
70
|
+
mode[6][termios.VTIME] = 0
|
|
71
|
+
try:
|
|
72
|
+
termios.tcsetattr(fd, termios.TCSANOW, mode)
|
|
73
|
+
# Canonical mode hides an unfinished line from select(). Check again
|
|
74
|
+
# now that every queued character is visible, without consuming it.
|
|
75
|
+
if select.select([fd], [], [], 0)[0]:
|
|
76
|
+
return None
|
|
77
|
+
if handle is None:
|
|
78
|
+
# Keep the query behind any text already queued on stdout.
|
|
79
|
+
sys.stdout.flush()
|
|
80
|
+
os.write(query_fd, b"\x1b]11;?\x1b\\")
|
|
81
|
+
deadline = time.monotonic() + timeout
|
|
82
|
+
while True:
|
|
83
|
+
remaining = deadline - time.monotonic()
|
|
84
|
+
if remaining <= 0 or not select.select([fd], [], [], remaining)[0]:
|
|
85
|
+
break
|
|
86
|
+
chunk = os.read(fd, 1)
|
|
87
|
+
if not chunk:
|
|
88
|
+
break
|
|
89
|
+
response.extend(chunk)
|
|
90
|
+
result = background_theme(response)
|
|
91
|
+
if result is not None:
|
|
92
|
+
response[:] = _BACKGROUND.sub(b"", response)
|
|
93
|
+
return result
|
|
94
|
+
finally:
|
|
95
|
+
termios.tcsetattr(fd, termios.TCSANOW, original)
|
|
96
|
+
except (OSError, ValueError, termios.error):
|
|
97
|
+
pass
|
|
98
|
+
finally:
|
|
99
|
+
if handle is not None:
|
|
100
|
+
handle.close()
|
|
101
|
+
else:
|
|
102
|
+
# Typing can race the query. Only the OSC reply belongs to us;
|
|
103
|
+
# replay everything else through the editor's normal input parser.
|
|
104
|
+
_pending_input.extend(response)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def replay_pending_input(app, on_theme=None) -> None:
|
|
109
|
+
"""Return bytes read during the probe before the editor reads newer input.
|
|
110
|
+
|
|
111
|
+
Also keeps filtering appearance replies out of typing for the whole
|
|
112
|
+
session, passing each to `on_theme`: a late answer to the startup probe,
|
|
113
|
+
and every later one `pcode.appearance` asks for.
|
|
114
|
+
"""
|
|
115
|
+
from prompt_toolkit.input.vt100 import Vt100Input
|
|
116
|
+
|
|
117
|
+
from pcode.startup_input import StartupInput
|
|
118
|
+
|
|
119
|
+
if not isinstance(app.input, Vt100Input):
|
|
120
|
+
return
|
|
121
|
+
app.input = StartupInput(app.input.stdin, bytes(_pending_input), on_theme)
|
|
122
|
+
_pending_input.clear()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def detect_theme() -> str:
|
|
126
|
+
"""Use OSC 11, then COLORFGBG (when supplied by the terminal), then dark."""
|
|
127
|
+
detected = _query_background()
|
|
128
|
+
if detected is not None:
|
|
129
|
+
return detected
|
|
130
|
+
try:
|
|
131
|
+
background = int(os.environ.get("COLORFGBG", "").split(";")[-1])
|
|
132
|
+
except ValueError:
|
|
133
|
+
return "dark"
|
|
134
|
+
# COLORFGBG describes ANSI palette indices, not RGB values.
|
|
135
|
+
return "light" if background in (7, 15) else "dark"
|
pcode/theme_gallery.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""One-line samples of every installed Pygments style, plus how to select one."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import cache
|
|
5
|
+
|
|
6
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
7
|
+
from rich.syntax import Syntax
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from pcode.syntax import transparent_theme
|
|
11
|
+
|
|
12
|
+
# Short enough to survive a narrow terminal, wide enough to show a keyword, a
|
|
13
|
+
# function name, a string, a number, and a comment in one row.
|
|
14
|
+
SAMPLE = 'def greet(name="world", times=2): # sample'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cache
|
|
18
|
+
def _sample(style: str) -> Text:
|
|
19
|
+
"""The sample line highlighted by one style, with its background dropped.
|
|
20
|
+
|
|
21
|
+
Fenced code is rendered on the terminal's own background, so the gallery
|
|
22
|
+
has to preview the same transparent rendering rather than each style's
|
|
23
|
+
designed background: a row that looked right on its own block could still
|
|
24
|
+
be unreadable in the transcript.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
text = Syntax(SAMPLE, "python", theme=transparent_theme(style)).highlight(SAMPLE)
|
|
28
|
+
except Exception: # A broken plugin style must not take the gallery with it.
|
|
29
|
+
return Text(SAMPLE)
|
|
30
|
+
text.rstrip()
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SyntaxGallery:
|
|
36
|
+
"""Every selectable syntax style, marking the one the transcript is using."""
|
|
37
|
+
|
|
38
|
+
styles: tuple[str, ...]
|
|
39
|
+
palette: str = "dark"
|
|
40
|
+
dark: str = ""
|
|
41
|
+
light: str = ""
|
|
42
|
+
|
|
43
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
44
|
+
yield Text("Syntax styles", style="pcode.accent")
|
|
45
|
+
yield Text(
|
|
46
|
+
f"dark: {self.dark} · light: {self.light}",
|
|
47
|
+
style="pcode.muted",
|
|
48
|
+
no_wrap=True,
|
|
49
|
+
overflow="ellipsis",
|
|
50
|
+
)
|
|
51
|
+
yield from self._samples()
|
|
52
|
+
yield Text()
|
|
53
|
+
yield from self._instructions()
|
|
54
|
+
|
|
55
|
+
def _samples(self) -> RenderResult:
|
|
56
|
+
current = self.dark if self.palette == "dark" else self.light
|
|
57
|
+
width = max(len(name) for name in self.styles) + 2
|
|
58
|
+
for name in self.styles:
|
|
59
|
+
active = name == current
|
|
60
|
+
row = Text(
|
|
61
|
+
"▸ " if active else " ",
|
|
62
|
+
style="pcode.accent",
|
|
63
|
+
no_wrap=True,
|
|
64
|
+
overflow="ellipsis",
|
|
65
|
+
)
|
|
66
|
+
row.append(name.ljust(width), style="pcode.accent" if active else "pcode.muted")
|
|
67
|
+
# `terminal` is not a Pygments style; it renders with the ANSI one.
|
|
68
|
+
row.append_text(_sample(f"ansi_{self.palette}" if name == "terminal" else name))
|
|
69
|
+
yield row
|
|
70
|
+
|
|
71
|
+
def _instructions(self) -> RenderResult:
|
|
72
|
+
for command, note in (
|
|
73
|
+
("/syntax NAME", f"style the palette in use ({self.palette})"),
|
|
74
|
+
("/theme dark|light", "switch palettes, then /syntax for that one"),
|
|
75
|
+
("/syntax terminal", "use the terminal's own colors everywhere"),
|
|
76
|
+
("/config set syntax_dark NAME", "save the dark-palette style"),
|
|
77
|
+
("/config set syntax_light NAME", "save the light-palette style"),
|
|
78
|
+
):
|
|
79
|
+
line = Text(" ", no_wrap=True, overflow="ellipsis")
|
|
80
|
+
line.append(command.ljust(30), style="pcode.accent")
|
|
81
|
+
line.append(note, style="pcode.muted")
|
|
82
|
+
yield line
|
|
83
|
+
yield Text(
|
|
84
|
+
" Outside the editor: pcode config set syntax_dark NAME",
|
|
85
|
+
style="pcode.muted",
|
|
86
|
+
no_wrap=True,
|
|
87
|
+
overflow="ellipsis",
|
|
88
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Muted Markdown for readable thinking with normal block spacing."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from rich.markdown import Markdown
|
|
6
|
+
from rich.segment import Segment
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ThinkingMarkdown:
|
|
11
|
+
source: str
|
|
12
|
+
code_theme: str = "monokai"
|
|
13
|
+
style: str = "pcode.thinking"
|
|
14
|
+
|
|
15
|
+
def __rich_console__(self, console, options):
|
|
16
|
+
# Codex also emits heading-only Markdown summaries:
|
|
17
|
+
# https://github.com/openai/codex/issues/34873
|
|
18
|
+
markdown = Markdown(self.source, code_theme=self.code_theme)
|
|
19
|
+
# Keep provider line boundaries rather than folding adjacent thoughts
|
|
20
|
+
# into a single paragraph. Markdown's other inline/block rules still apply.
|
|
21
|
+
for token in markdown.parsed:
|
|
22
|
+
for child in token.children or ():
|
|
23
|
+
if child.type == "softbreak":
|
|
24
|
+
child.type = "hardbreak"
|
|
25
|
+
style = console.get_style(self.style)
|
|
26
|
+
# Keep one spacer row between blocks, including streamed commits. Rich
|
|
27
|
+
# also pads lists/code with blank rows; don't double those on replay.
|
|
28
|
+
pending_blank = False
|
|
29
|
+
rendered = False
|
|
30
|
+
for line in Segment.split_lines(console.render(markdown, options)):
|
|
31
|
+
if not any(segment.text.strip() for segment in line):
|
|
32
|
+
pending_blank = rendered
|
|
33
|
+
continue
|
|
34
|
+
if pending_blank:
|
|
35
|
+
yield Segment.line()
|
|
36
|
+
yield from Segment.apply_style(line, post_style=style)
|
|
37
|
+
yield Segment.line()
|
|
38
|
+
rendered = True
|
|
39
|
+
pending_blank = False
|
|
40
|
+
if rendered:
|
|
41
|
+
yield Segment.line()
|