pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/stall_log.py ADDED
@@ -0,0 +1,187 @@
1
+ """Log event-loop stalls in the terminal: the moments typed keys wait to be read.
2
+
3
+ Keystrokes and model output share one asyncio loop. When synchronous work holds
4
+ it, keys queue in the tty and land together once it frees, so typing seems to
5
+ freeze and then a whole word pops up. A heartbeat task notes when the loop last
6
+ ran; a watcher thread samples the loop thread's stack while the heartbeat is
7
+ overdue and appends one JSON line per stall to `stalls.jsonl` in the state
8
+ directory. A freeze with no stall logged at that time means the loop was free
9
+ and the terminal itself held the frame back.
10
+
11
+ A stall that holds the GIL throughout (garbage collection, one long C call)
12
+ leaves the watcher no chance to sample, so it is logged from the late heartbeat
13
+ alone, with no stacks; `gc_ms` says how much of it was collection.
14
+
15
+ Records hold file names, line numbers, and function names, never arguments or
16
+ locals, as `pcode.profiling` does.
17
+ """
18
+
19
+ import asyncio
20
+ import gc
21
+ import json
22
+ import os
23
+ import sys
24
+ import threading
25
+ import time
26
+ from collections import Counter
27
+ from pathlib import Path
28
+
29
+ THRESHOLD = 0.1
30
+ """Seconds late before a heartbeat counts as a stall; ~100 ms is where typing visibly lags."""
31
+ BEAT = 0.05
32
+ POLL = 0.02
33
+ MAX_BYTES = 1_000_000
34
+ """The log is renamed to `stalls.jsonl.1` past this size, replacing the previous one."""
35
+ FRAMES_KEPT = 30
36
+ STACKS_KEPT = 3
37
+ BUSY_SHARE = 0.1
38
+ """Process CPU per wall second above which an unsampled stall was work, not a suspended
39
+ process (Ctrl+Z, a sleeping laptop), which stops the watcher thread as well. Low,
40
+ because a busy process on a loaded machine is still a stall the user feels yet may
41
+ get only a fraction of a core (a quarter was measured); a suspended one gets none."""
42
+
43
+
44
+ def stall_log_path() -> Path:
45
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
46
+ return state / "pcode" / "stalls.jsonl"
47
+
48
+
49
+ def _where(frame) -> str:
50
+ path = frame.f_code.co_filename
51
+ for marker in ("site-packages/", "/src/"):
52
+ if marker in path:
53
+ path = path.rsplit(marker, 1)[1]
54
+ break
55
+ else:
56
+ path = os.path.basename(path)
57
+ return f"{path}:{frame.f_lineno} {frame.f_code.co_qualname}"
58
+
59
+
60
+ def _idle(frame) -> bool:
61
+ """The event loop waiting for I/O, so it is late only because the process was
62
+ suspended. A blocking `subprocess.run` also waits in a selector, but not one
63
+ the loop called, and that is a stall."""
64
+ caller = frame.f_back
65
+ return (
66
+ frame.f_code.co_filename.endswith("selectors.py")
67
+ and caller is not None
68
+ and caller.f_code.co_name == "_run_once"
69
+ )
70
+
71
+
72
+ class StallWatch:
73
+ """Run `heartbeat()` as a task on the loop to watch; `stop()` when done."""
74
+
75
+ def __init__(self, path: Path | None = None, *, threshold: float = THRESHOLD, context=None):
76
+ self.path = path or stall_log_path()
77
+ self.threshold = threshold
78
+ # Called from the watcher thread when a stall begins; must only read.
79
+ self.context = context
80
+ self._due: float | None = None
81
+ # The latest late beat, (due, how late it ran); beats that late are at
82
+ # least `threshold` apart, so the watcher sees every one.
83
+ self._late: tuple[float, float] | None = None
84
+ self._loop_thread: int | None = None
85
+ self._gc_started = 0.0
86
+ self._gc_seconds = 0.0
87
+ self._stop = threading.Event()
88
+ self._watcher: threading.Thread | None = None
89
+
90
+ async def heartbeat(self) -> None:
91
+ self._loop_thread = threading.get_ident()
92
+ if self._watcher is None:
93
+ gc.callbacks.append(self._gc)
94
+ self._watcher = threading.Thread(target=self._watch, name="stall-watch", daemon=True)
95
+ self._watcher.start()
96
+ try:
97
+ while True:
98
+ now = time.monotonic()
99
+ if self._due is not None and now - self._due >= self.threshold:
100
+ self._late = (self._due, now - self._due)
101
+ self._due = now + BEAT
102
+ await asyncio.sleep(BEAT)
103
+ finally:
104
+ # Shutdown work after cancellation is not a stall.
105
+ self._due = None
106
+
107
+ def stop(self) -> None:
108
+ self._stop.set()
109
+ if self._watcher is not None:
110
+ self._watcher.join(timeout=1)
111
+ if self._gc in gc.callbacks:
112
+ gc.callbacks.remove(self._gc)
113
+
114
+ def _gc(self, phase: str, info: dict) -> None:
115
+ if phase == "start":
116
+ self._gc_started = time.monotonic()
117
+ else:
118
+ self._gc_seconds += time.monotonic() - self._gc_started
119
+
120
+ def _watch(self) -> None:
121
+ stalled = None # The overdue `_due` being sampled.
122
+ samples: Counter[tuple[str, ...]] = Counter()
123
+ context: dict = {}
124
+ settled = None # The late beat last accounted for.
125
+ # (wall, process CPU, GC seconds) at the last poll before the stall.
126
+ mark = (time.monotonic(), time.process_time(), self._gc_seconds)
127
+ while not self._stop.wait(POLL):
128
+ now = time.monotonic()
129
+ late = self._late
130
+ if late is not None and late[0] != settled:
131
+ settled = late[0]
132
+ wall, cpu, _ = mark
133
+ busy = time.process_time() - cpu >= BUSY_SHARE * (now - wall)
134
+ if samples or busy:
135
+ self._write(late[1], samples, context or self._context(), mark)
136
+ stalled, samples, context = None, Counter(), {}
137
+ due = self._due
138
+ overdue = due is not None and now >= due + self.threshold
139
+ if overdue:
140
+ frame = sys._current_frames().get(self._loop_thread)
141
+ if frame is not None and not _idle(frame):
142
+ if stalled is None:
143
+ stalled, context = due, self._context()
144
+ stack = []
145
+ while frame is not None and len(stack) < FRAMES_KEPT:
146
+ stack.append(_where(frame))
147
+ frame = frame.f_back
148
+ samples[tuple(stack)] += 1
149
+ # Hold the mark while overdue: the first poll after a GIL-held stall
150
+ # can run before the heartbeat does, with the loop already idle.
151
+ if stalled is None and not overdue:
152
+ mark = (now, time.process_time(), self._gc_seconds)
153
+ if samples:
154
+ # Stopped mid-stall: the loop is late by at least this much.
155
+ self._write(time.monotonic() - stalled, samples, context, mark)
156
+
157
+ def _context(self) -> dict:
158
+ if self.context is None:
159
+ return {}
160
+ try:
161
+ return dict(self.context())
162
+ except Exception:
163
+ return {}
164
+
165
+ def _write(self, late: float, samples: Counter, context: dict, mark: tuple) -> None:
166
+ record = {
167
+ "at": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(time.time() - late)),
168
+ "pid": os.getpid(),
169
+ "stall_ms": round(late * 1000),
170
+ "gc_ms": round((self._gc_seconds - mark[2]) * 1000, 1),
171
+ "samples": sum(samples.values()),
172
+ **context,
173
+ # Innermost frame first: the top line is what was running. Empty
174
+ # when the stall held the GIL throughout.
175
+ "stacks": [
176
+ {"samples": count, "frames": list(stack)}
177
+ for stack, count in samples.most_common(STACKS_KEPT)
178
+ ],
179
+ }
180
+ try:
181
+ self.path.parent.mkdir(parents=True, exist_ok=True)
182
+ if self.path.exists() and self.path.stat().st_size > MAX_BYTES:
183
+ os.replace(self.path, self.path.with_name(self.path.name + ".1"))
184
+ with self.path.open("a") as file:
185
+ file.write(json.dumps(record) + "\n")
186
+ except OSError:
187
+ pass # Diagnostics must never disturb the session.
pcode/startup_input.py ADDED
@@ -0,0 +1,96 @@
1
+ """Return startup probe bytes through prompt_toolkit's normal input callback."""
2
+
3
+ import asyncio
4
+ import re
5
+ from collections.abc import Callable
6
+ from contextlib import contextmanager
7
+
8
+ from prompt_toolkit.input.vt100 import Vt100Input
9
+ from prompt_toolkit.input.vt100_parser import Vt100Parser
10
+
11
+ # An OSC 11 background reply, or a mode 2031 color-scheme report (1 dark, 2 light).
12
+ _REPLY = re.compile(r"\x1b\]11;[^\x07\x1b]{0,64}(?:\x07|\x1b\\)|\x1b\[\?997;([12])n")
13
+ # A tail that may still become one of those: held until the next read. Only
14
+ # characters a color reply contains, so typing after a truncated reply is
15
+ # released at its first keystroke instead of being held for the flush to drop.
16
+ _OPEN = re.compile(r"\x1b\]11;[0-9A-Fa-f:/rgb]{0,40}\x1b?|\x1b\[\?997;[12]?")
17
+ _HEADERS = ("\x1b]11;", "\x1b[?997;")
18
+
19
+
20
+ class TerminalReplyParser(Vt100Parser):
21
+ """Take terminal appearance replies out of the key stream, even split across reads.
22
+
23
+ The startup probe's reply can arrive after the editor starts, and later
24
+ queries (see `pcode.appearance`) always do. A terminated OSC 11 reply is
25
+ dropped even when its color can't be read; anything else, including an
26
+ overlong or unterminated reply, reaches prompt_toolkit as ordinary input.
27
+ """
28
+
29
+ def __init__(self, callback, on_theme: Callable[[str], None] | None = None):
30
+ super().__init__(callback)
31
+ self.pending = ""
32
+ self.on_theme = on_theme
33
+
34
+ def feed(self, data: str) -> None:
35
+ text, self.pending = self.pending + data, ""
36
+ while match := _REPLY.search(text):
37
+ super().feed(text[: match.start()])
38
+ text = text[match.end() :]
39
+ self._report(match)
40
+ hold = _held(text)
41
+ super().feed(text[:hold])
42
+ self.pending = text[hold:]
43
+
44
+ def _report(self, match: re.Match) -> None:
45
+ from pcode.theme import background_theme
46
+
47
+ if match.group(1):
48
+ detected = "dark" if match.group(1) == "1" else "light"
49
+ else:
50
+ detected = background_theme(match.group().encode())
51
+ if detected is not None and self.on_theme is not None:
52
+ self.on_theme(detected)
53
+
54
+ def flush(self) -> None:
55
+ text, self.pending = self.pending, ""
56
+ # An incomplete terminal reply must not hold the next prompt hostage.
57
+ # Anything shorter than a whole header (Escape, Alt-], Alt-[ then ?)
58
+ # is more likely typing, and must reach the parser.
59
+ if not text.startswith(_HEADERS):
60
+ super().feed(text)
61
+ super().flush()
62
+
63
+
64
+ def _held(text: str) -> int:
65
+ """Where an unfinished reply may start in `text`; len(text) when none can."""
66
+ start = text.find("\x1b")
67
+ while start >= 0:
68
+ tail = text[start:]
69
+ if any(header.startswith(tail) for header in _HEADERS) or _OPEN.fullmatch(tail):
70
+ return start
71
+ start = text.find("\x1b", start + 1)
72
+ return len(text)
73
+
74
+
75
+ class StartupInput(Vt100Input):
76
+ def __init__(self, stdin, pending: bytes, on_theme: Callable[[str], None] | None = None):
77
+ super().__init__(stdin)
78
+ self.pending = pending
79
+ self.vt100_parser = TerminalReplyParser(lambda key: self._buffer.append(key), on_theme)
80
+
81
+ @contextmanager
82
+ def attach(self, input_ready_callback):
83
+ with super().attach(input_ready_callback):
84
+ if self.pending:
85
+ # Use the application's callback so it also schedules its normal
86
+ # escape timeout. Feeding keys in pre_run would bypass that timer.
87
+ asyncio.get_running_loop().call_soon(input_ready_callback)
88
+ yield
89
+
90
+ def read_keys(self):
91
+ if self.pending:
92
+ # Keep the decoder shared with later reads, including split UTF-8.
93
+ text = self.stdin_reader._stdin_decoder.decode(self.pending)
94
+ self.pending = b""
95
+ self.vt100_parser.feed(text)
96
+ return super().read_keys()
pcode/steering.py ADDED
@@ -0,0 +1,27 @@
1
+ """Deliver pending steering between model requests; never skip tools."""
2
+
3
+ from pydantic_ai.capabilities import AbstractCapability
4
+ from pydantic_ai.messages import UserPromptPart
5
+
6
+
7
+ class Steering(AbstractCapability):
8
+ def __init__(self, take_messages, has_messages=lambda: False):
9
+ self.take_messages = take_messages
10
+ # A peek that consumes nothing, for waits that should hand back early.
11
+ self.has_messages = has_messages
12
+
13
+ async def before_model_request(self, ctx, request_context):
14
+ # Append to the framework's request so tool results remain ahead of the
15
+ # new user input and the input is included in persisted message history.
16
+ for text in self.take_messages():
17
+ request_context.messages[-1].parts.append(UserPromptPart(text))
18
+ return request_context
19
+
20
+
21
+ def steering_pending(ctx) -> bool:
22
+ """Inspect this run only, never a parent's or a concurrent side question's input."""
23
+ return any(
24
+ capability.has_messages()
25
+ for capability in ctx.capabilities.values()
26
+ if isinstance(capability, Steering)
27
+ )
@@ -0,0 +1,188 @@
1
+ """Presentation-only event routing, shared by live streaming and offline benchmarks."""
2
+
3
+ from rich.console import Console
4
+ from rich.markdown import Markdown
5
+
6
+ from pcode.runtime import (
7
+ CacheBust,
8
+ ChildPlan,
9
+ ChildText,
10
+ CommandOutput,
11
+ EditCompleted,
12
+ EditPreview,
13
+ Message,
14
+ PlanPreview,
15
+ PlanUpdated,
16
+ RunStatus,
17
+ TextDelta,
18
+ Thinking,
19
+ ThinkingDelta,
20
+ ToolStarted,
21
+ ToolSummary,
22
+ )
23
+ from pcode.terminal_notify import TabProgress, terminal_fd
24
+
25
+
26
+ def present_events(events, *, activity, transcript, edits) -> None:
27
+ """Route live tool activity separately from permanent transcript writes."""
28
+ for event in events:
29
+ if isinstance(event, EditPreview):
30
+ activity.edit_previews.pop(event.call_id, None)
31
+ if event.path:
32
+ activity.edit_previews[event.call_id] = event
33
+ elif isinstance(event, EditCompleted):
34
+ edits.append(event)
35
+ activity.tools.record_edit(event)
36
+ transcript.edit(event)
37
+ elif isinstance(event, CommandOutput):
38
+ activity.command_outputs.pop(event.call_id, None)
39
+ activity.command_outputs[event.call_id] = event
40
+ elif isinstance(event, (ToolStarted, ToolSummary)):
41
+ activity.tools.record(event)
42
+ activity.workers.record(event)
43
+ if transcript.output is not None:
44
+ transcript.output.app.invalidate()
45
+ # The adapter's failed flag includes non-zero exits and tool retries.
46
+ if isinstance(event, ToolSummary):
47
+ activity.command_outputs.pop(event.call_id, None)
48
+ transcript.tool_result(event)
49
+ else:
50
+ transcript.events((event,))
51
+
52
+
53
+ class PrintedReply:
54
+ """`--print`'s output: the reply on `stdout`, every other event through `present`.
55
+
56
+ The transcript is expected to be on stderr, so a pipe reading stdout sees
57
+ the reply alone. A terminal gets rendered Markdown; a pipe gets its source,
58
+ which is what a reader downstream can work with.
59
+ """
60
+
61
+ # Live-panel state, with no live panel to show it.
62
+ SKIPPED = (ThinkingDelta, RunStatus, PlanPreview, PlanUpdated, ChildPlan, ChildText)
63
+
64
+ def __init__(self, stdout, *, transcript, present) -> None:
65
+ self.stdout = stdout
66
+ self.transcript = transcript
67
+ self.present = present
68
+ reply = Console(file=stdout, theme=transcript.rich_theme)
69
+ # Rendering replaces token-by-token output with settled blocks, so it
70
+ # must not be chosen for a destination that cannot display it.
71
+ self.console = reply if reply.is_terminal else None
72
+ # Text streamed since the last settled message, so a turn that ends
73
+ # mid-block still prints what arrived.
74
+ self.block = ""
75
+
76
+ def tab_progress(self, activity) -> TabProgress:
77
+ """The terminal's tab progress, following `activity`, as the editor shows it.
78
+
79
+ Sent to the transcript's stream first: a reply piped elsewhere leaves
80
+ that one on the terminal. Neither being one sends nothing.
81
+ """
82
+ from pcode.preferences import load_preferences
83
+
84
+ fd = terminal_fd(self.transcript.console.file, self.stdout)
85
+ return TabProgress(activity, fd, load_preferences().get("terminal_progress", "auto"))
86
+
87
+ def write(self, markdown: str, *, streamed: bool = False) -> None:
88
+ """Settle one block of reply text; `streamed` means its source is already out."""
89
+ # The reply goes elsewhere, so close the run of calls it follows first.
90
+ self.transcript.settle_tools()
91
+ if self.console is not None:
92
+ self.console.print(Markdown(markdown, code_theme=self.transcript.code_theme))
93
+ self.console.print()
94
+ return
95
+ if not streamed:
96
+ self.stdout.write(markdown)
97
+ if not markdown.endswith("\n"):
98
+ self.stdout.write("\n")
99
+ self.stdout.write("\n")
100
+ self.stdout.flush()
101
+
102
+ def event(self, event) -> None:
103
+ if isinstance(event, TextDelta):
104
+ self.block += event.text
105
+ if self.console is None:
106
+ self.stdout.write(event.text)
107
+ self.stdout.flush()
108
+ elif isinstance(event, Message):
109
+ # Deltas usually carried this text already; a message without them
110
+ # (a structured result) is written whole.
111
+ self.write(
112
+ event.markdown or self.block,
113
+ streamed=self.console is None and bool(self.block),
114
+ )
115
+ self.block = ""
116
+ elif isinstance(event, Thinking):
117
+ self.transcript.events((event,))
118
+ elif not isinstance(event, self.SKIPPED):
119
+ self.present((event,))
120
+
121
+ def settle(self) -> None:
122
+ """Write out a block the turn ended (or failed, or retried) in the middle of."""
123
+ self.transcript.settle_tools()
124
+ if self.block:
125
+ self.write(self.block, streamed=self.console is None)
126
+ self.block = ""
127
+
128
+
129
+ def present_stream_event(event, *, output, transcript, activity, present) -> None:
130
+ """Apply one display event without contacting a model or executing a tool."""
131
+ if isinstance(event, ThinkingDelta):
132
+ output.finish()
133
+ output.thinking_delta(event.text)
134
+ activity.think(event.text)
135
+ elif isinstance(event, Thinking):
136
+ output.finish_thinking(event.text)
137
+ if activity.thought_done and event.text:
138
+ # A block that arrived whole, with no deltas before it.
139
+ activity.think(event.text)
140
+ # Held on the thinking rows through the tool calls it explains; the
141
+ # next block, or the answer, replaces it.
142
+ activity.thought_done = True
143
+ elif isinstance(event, TextDelta):
144
+ output.finish_thinking()
145
+ output.delta(event.text)
146
+ activity.status = "Responding…"
147
+ if event.text.strip(): # An empty part start says nothing yet.
148
+ activity.forget_thought()
149
+ elif isinstance(event, CacheBust):
150
+ # A footer note, not a scrollback line: the session journal keeps the
151
+ # full notice (and its cause) for anyone diagnosing it later.
152
+ from pcode.cache_warnings import footer_label
153
+
154
+ activity.cache_note = footer_label(event.text)
155
+ elif isinstance(event, EditCompleted):
156
+ output.finish_thinking()
157
+ output.finish()
158
+ present((event,))
159
+ elif isinstance(event, (CommandOutput, EditPreview)):
160
+ present((event,))
161
+ elif isinstance(event, RunStatus):
162
+ activity.status = event.text
163
+ elif isinstance(event, PlanUpdated):
164
+ activity.plan = event.items
165
+ elif isinstance(event, PlanPreview):
166
+ activity.plan_preview = event.items
167
+ elif isinstance(event, ChildPlan):
168
+ activity.tools.record_plan(event.call_id, event.items)
169
+ activity.workers.record(event)
170
+ elif isinstance(event, ChildText):
171
+ # A worker's own prose feeds `/agents`; the transcript never shows it.
172
+ activity.workers.record(event)
173
+ return
174
+ elif isinstance(event, (ToolStarted, ToolSummary)):
175
+ output.finish_thinking()
176
+ # Settled calls land in scrollback, so prose must be committed first.
177
+ # Suppressed calls, such as successful planning, do not interrupt it.
178
+ if transcript.writes_tool_result(event):
179
+ output.finish()
180
+ present((event,))
181
+ elif isinstance(event, Message):
182
+ output.finish(event.markdown)
183
+ if event.markdown.strip(): # An answer that arrived whole, with no deltas before it.
184
+ activity.forget_thought()
185
+ else:
186
+ output.finish()
187
+ transcript.events((event,))
188
+ output.app.invalidate()