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/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
|
+
)
|
pcode/stream_display.py
ADDED
|
@@ -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()
|