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
@@ -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()