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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Configure Harness's production-time tool reduction, independently of compaction."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic_ai.messages import ToolReturn
|
|
9
|
+
from pydantic_ai_harness.tool_output_limits import (
|
|
10
|
+
Band,
|
|
11
|
+
LocalFileStore,
|
|
12
|
+
Spill,
|
|
13
|
+
ToolOutputLimits,
|
|
14
|
+
Truncate,
|
|
15
|
+
TruncationStrategy,
|
|
16
|
+
indented_json,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from pcode.preferences import SETTINGS, load_preferences
|
|
20
|
+
from pcode.shell import REDUCED_SHELL_OUTPUT, split_envelope
|
|
21
|
+
|
|
22
|
+
# Tools that bound and page their own output (character offsets, cursors). A
|
|
23
|
+
# spill pages by line instead, and their JSON keeps a turn's text on one line,
|
|
24
|
+
# so the spill is unreadable through read_tool_result and only adds a hop.
|
|
25
|
+
# Exempt means not truncated either: their own caps (read_session's max_chars,
|
|
26
|
+
# search_sessions' limit) are all that bound them.
|
|
27
|
+
SELF_PAGING_TOOLS = ("search_sessions", "read_session")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tool_results_path() -> Path:
|
|
31
|
+
"""Stable across workspaces and restarts so saved spill handles remain readable."""
|
|
32
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
33
|
+
return state / "pcode" / "tool-results"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CodingToolOutputLimits(ToolOutputLimits):
|
|
37
|
+
"""Keep a job's control envelope outside the reduction budget.
|
|
38
|
+
|
|
39
|
+
The envelope is how the model reaches a command that is still running. It
|
|
40
|
+
is a few short lines and must never be the part that gets spilled to a file.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def get_instructions(self):
|
|
44
|
+
# Harness pages by line and caps reads at 50k chars, so one very long line
|
|
45
|
+
# cannot be fully retrieved that way. Both agents have unrestricted shell.
|
|
46
|
+
return (
|
|
47
|
+
"Large tool results may be stored with a read_tool_result handle. "
|
|
48
|
+
"Read only the slices needed. If a single long line hits its output cap, "
|
|
49
|
+
f"use shell to read a character range from {tool_results_path()}/<handle>."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def after_tool_execute(self, ctx, *, call, tool_def, args, result):
|
|
53
|
+
split = (
|
|
54
|
+
split_envelope(result)
|
|
55
|
+
if isinstance(result, str) and call.tool_name in {"shell", "wait_for_job", "job_output"}
|
|
56
|
+
else None
|
|
57
|
+
)
|
|
58
|
+
if split is None:
|
|
59
|
+
return await super().after_tool_execute(
|
|
60
|
+
ctx, call=call, tool_def=tool_def, args=args, result=result
|
|
61
|
+
)
|
|
62
|
+
output, envelope = split
|
|
63
|
+
reduced = await super().after_tool_execute(
|
|
64
|
+
ctx, call=call, tool_def=tool_def, args=args, result=output
|
|
65
|
+
)
|
|
66
|
+
if reduced == output:
|
|
67
|
+
return result
|
|
68
|
+
value = reduced.return_value if isinstance(reduced, ToolReturn) else reduced
|
|
69
|
+
# The marker also tells display projection that clipping lost redaction
|
|
70
|
+
# context, including for delegated calls without CommandFinishedEvent.
|
|
71
|
+
value = f"{REDUCED_SHELL_OUTPUT}\n{value}\n{envelope}"
|
|
72
|
+
return replace(reduced, return_value=value) if isinstance(reduced, ToolReturn) else value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_tool_output_limits() -> ToolOutputLimits:
|
|
76
|
+
"""Snapshot saved settings; all reduction and retrieval behavior stays upstream."""
|
|
77
|
+
preferences = load_preferences()
|
|
78
|
+
|
|
79
|
+
def value(suffix: str) -> str:
|
|
80
|
+
key = f"tool_output_{suffix}"
|
|
81
|
+
result = preferences.get(key, SETTINGS[key].default)
|
|
82
|
+
assert result is not None
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
mode = value("mode")
|
|
86
|
+
threshold = int(value("threshold"))
|
|
87
|
+
truncate = Truncate(
|
|
88
|
+
max_chars=int(value("max_chars")),
|
|
89
|
+
strategy=TruncationStrategy(value("strategy")),
|
|
90
|
+
)
|
|
91
|
+
action = (
|
|
92
|
+
Spill(preview_chars=int(value("preview_chars")), then=truncate)
|
|
93
|
+
if mode == "spill"
|
|
94
|
+
else truncate
|
|
95
|
+
)
|
|
96
|
+
retention = int(value("retention_hours"))
|
|
97
|
+
return CodingToolOutputLimits(
|
|
98
|
+
bands=[] if mode == "off" else [Band(over=threshold, action=action)],
|
|
99
|
+
per_tool={name: [] for name in SELF_PAGING_TOOLS},
|
|
100
|
+
store=LocalFileStore(
|
|
101
|
+
base_dir=tool_results_path(),
|
|
102
|
+
cleanup_after=timedelta(hours=retention) if retention else None,
|
|
103
|
+
),
|
|
104
|
+
# Structured results (e.g. MCP) need real lines for useful read-back paging.
|
|
105
|
+
serializer=indented_json,
|
|
106
|
+
# Keep read_tool_result even when reduction is off or truncate-only:
|
|
107
|
+
# resumed history can still contain handles produced in spill mode.
|
|
108
|
+
)
|
pcode/tool_panel.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Live tool activity: running calls only, since settled ones reach scrollback."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from time import monotonic
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from pcode.runtime import EditCompleted, ToolStarted, ToolSummary
|
|
10
|
+
from pcode.tool_display import JOB_HANDLE_TOOLS, PLAN_TOOLS, command_preview, label, plain
|
|
11
|
+
|
|
12
|
+
# Delegates outlive their own chatter, so they keep the panel's first rows.
|
|
13
|
+
DELEGATE = "delegate_task"
|
|
14
|
+
# Marks a row as a sub-agent rather than a tool. One terminal cell wide in
|
|
15
|
+
# common fonts, unlike emoji, so the panel's width math still holds.
|
|
16
|
+
AGENT_ICON = "»"
|
|
17
|
+
# On the status row, a command that finishes in milliseconds appears and
|
|
18
|
+
# vanishes before it can be read, and a burst of them strobes. A finished call
|
|
19
|
+
# keeps the row, marked done, for this long after it settles, unless real work
|
|
20
|
+
# starts first. The tally on the same row is the lasting record, so this only
|
|
21
|
+
# has to stop the flicker.
|
|
22
|
+
STATUS_DWELL = 0.6
|
|
23
|
+
# A sub-agent's plan is a window around its active task, like the parent's, but
|
|
24
|
+
# shorter: several delegates share the panel with the parent's own tasks.
|
|
25
|
+
CHILD_PLAN_ROWS = 3
|
|
26
|
+
# Rows for delegates beside the parent's tasks, before any child plans.
|
|
27
|
+
DELEGATE_ROWS = 3
|
|
28
|
+
# The parent's task window at the default height. With `tasks_max_height`
|
|
29
|
+
# set, the tasks fill whatever budget the tools leave instead.
|
|
30
|
+
TASK_ROWS = 5
|
|
31
|
+
ACTIVE_TASK_ICON = "↺"
|
|
32
|
+
PLAN_ICONS = {
|
|
33
|
+
"pending": "○",
|
|
34
|
+
"completed": "✓",
|
|
35
|
+
"cancelled": "–",
|
|
36
|
+
"blocked": "!",
|
|
37
|
+
}
|
|
38
|
+
# Slots in the palette's sub-agent hue ring (`Palette.agents`).
|
|
39
|
+
AGENT_HUES = 3
|
|
40
|
+
# Leading tree guides, then the icon, of a row `panel_fragments` colours in
|
|
41
|
+
# parts. Only task and sub-agent rows qualify: other rows pass through whole.
|
|
42
|
+
_TREE_GUIDE = re.compile(r"[│├└─ ]*")
|
|
43
|
+
_PART_STYLED = re.compile(rf"plan\.({'|'.join(['agent', 'in_progress', *PLAN_ICONS])})\b")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class _PanelNode:
|
|
48
|
+
row: tuple[str, str]
|
|
49
|
+
children: list["_PanelNode"] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tree_rows(nodes: list[_PanelNode], prefix: str | None = None) -> list[tuple[str, str]]:
|
|
53
|
+
"""Draw guides for the visible tree, leaving unparented roots undecorated."""
|
|
54
|
+
rows = []
|
|
55
|
+
for index, node in enumerate(nodes):
|
|
56
|
+
last = index == len(nodes) - 1
|
|
57
|
+
style, text = node.row
|
|
58
|
+
branch = "" if prefix is None else prefix + ("└── " if last else "├── ")
|
|
59
|
+
rows.append((style, branch + text))
|
|
60
|
+
stem = "" if prefix is None else prefix + (" " if last else "│ ")
|
|
61
|
+
rows.extend(_tree_rows(node.children, stem))
|
|
62
|
+
return rows
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ToolCall:
|
|
67
|
+
event: ToolStarted
|
|
68
|
+
started: float = field(default_factory=monotonic)
|
|
69
|
+
settled: float | None = None
|
|
70
|
+
failed: bool = False
|
|
71
|
+
# A delegate's slot in the hue ring, kept for its whole run.
|
|
72
|
+
hue: int = 0
|
|
73
|
+
# What an edit changed (`+3 −1`), once its `EditCompleted` arrives: two
|
|
74
|
+
# edits to one file are otherwise the same row.
|
|
75
|
+
change: str = ""
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def elapsed(self) -> float:
|
|
79
|
+
"""Seconds so far; a settled call keeps the duration it finished with."""
|
|
80
|
+
return (self.settled if self.settled is not None else monotonic()) - self.started
|
|
81
|
+
|
|
82
|
+
def line(self, *, timed: bool = True) -> str:
|
|
83
|
+
"""The call without a status icon; each surface supplies its own.
|
|
84
|
+
|
|
85
|
+
`timed=False` leaves the duration out, for the status row, which
|
|
86
|
+
keeps its clock in a fixed column of its own.
|
|
87
|
+
"""
|
|
88
|
+
event = self.event
|
|
89
|
+
elapsed = self.elapsed if timed else None
|
|
90
|
+
if event.name == DELEGATE:
|
|
91
|
+
return self._delegate_line(elapsed)
|
|
92
|
+
# A stated purpose is what this row is for: the widget is the one place
|
|
93
|
+
# that shows a job while it runs, when the command has not paid off yet.
|
|
94
|
+
command = (
|
|
95
|
+
f"{event.purpose} · {command_preview(event.command)}"
|
|
96
|
+
if event.command and event.purpose
|
|
97
|
+
else command_preview(event.command)
|
|
98
|
+
if event.command
|
|
99
|
+
else ""
|
|
100
|
+
)
|
|
101
|
+
detail = plain(event.detail, limit=None)
|
|
102
|
+
if event.name in JOB_HANDLE_TOOLS:
|
|
103
|
+
# The id matches the job's own row and notices; the command says
|
|
104
|
+
# what it runs. Neither is enough alone.
|
|
105
|
+
detail = " · ".join(part for part in (detail, command) if part)
|
|
106
|
+
else:
|
|
107
|
+
detail = command or detail
|
|
108
|
+
state = plain(event.activity) if event.activity else ""
|
|
109
|
+
clock = "" if elapsed is None else f"{elapsed:.1f}s"
|
|
110
|
+
return " · ".join(part for part in (label(event.name), state, clock, detail) if part)
|
|
111
|
+
|
|
112
|
+
def _delegate_line(self, elapsed: float | None) -> str:
|
|
113
|
+
"""`» Worker · 5.5s · Thinking · <task>`: the agent is what tells delegates apart.
|
|
114
|
+
|
|
115
|
+
A settled delegate's last phase is stale (nearly always "Responding"),
|
|
116
|
+
so it says how it ended instead.
|
|
117
|
+
"""
|
|
118
|
+
event = self.event
|
|
119
|
+
if self.settled is not None:
|
|
120
|
+
state = "Failed" if self.failed else "Done"
|
|
121
|
+
else:
|
|
122
|
+
state = plain(event.activity) or "Starting"
|
|
123
|
+
name = event.agent[:1].upper() + event.agent[1:] or label(event.name)
|
|
124
|
+
task = event.task or plain(event.detail, limit=None)
|
|
125
|
+
clock = "" if elapsed is None else f"{elapsed:.1f}s"
|
|
126
|
+
return " · ".join(part for part in (f"{AGENT_ICON} {name}", clock, state, task) if part)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class ToolHistory:
|
|
131
|
+
"""Calls still in flight, oldest first. A result removes its call.
|
|
132
|
+
|
|
133
|
+
The two surfaces split the work: the status row reports tool calls, and
|
|
134
|
+
the task panel lists running delegates with their plans. A delegate
|
|
135
|
+
leaves when it settles, taking its plan and its own calls with it, and
|
|
136
|
+
scrollback keeps the record.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
calls: list[ToolCall] = field(default_factory=list)
|
|
140
|
+
# The last tool call (never a delegate) to leave, kept only so the status
|
|
141
|
+
# row can hold it.
|
|
142
|
+
recent: ToolCall | None = None
|
|
143
|
+
# Each running delegate's plan, keyed by its call id.
|
|
144
|
+
plans: dict[str, list[dict]] = field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
def record_edit(self, change: EditCompleted) -> None:
|
|
147
|
+
"""Note an edit's line counts on its call, running or just settled.
|
|
148
|
+
|
|
149
|
+
A change with no diff (binary, sensitive, too large) has no counts to
|
|
150
|
+
give: `+0 −0` would say it changed nothing.
|
|
151
|
+
"""
|
|
152
|
+
if change.omitted or change.operation == "unchanged":
|
|
153
|
+
return
|
|
154
|
+
for call in (*self.calls, self.recent):
|
|
155
|
+
if call is not None and change.call_id and call.event.call_id == change.call_id:
|
|
156
|
+
call.change = f"+{change.added} −{change.removed}"
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
def record_plan(self, call_id: str, items: list[dict]) -> None:
|
|
160
|
+
if any(c.event.call_id == call_id for c in self.calls):
|
|
161
|
+
self.plans[call_id] = items
|
|
162
|
+
|
|
163
|
+
def record(self, event: ToolStarted | ToolSummary) -> None:
|
|
164
|
+
# Planning operations have their own panel. Settled calls leave the
|
|
165
|
+
# live view regardless of whether they need a scrollback entry.
|
|
166
|
+
if event.name in PLAN_TOOLS:
|
|
167
|
+
return
|
|
168
|
+
existing = next(
|
|
169
|
+
(c for c in self.calls if event.call_id and c.event.call_id == event.call_id), None
|
|
170
|
+
)
|
|
171
|
+
if isinstance(event, ToolSummary):
|
|
172
|
+
if existing is None:
|
|
173
|
+
return
|
|
174
|
+
existing.failed = event.failed
|
|
175
|
+
existing.settled = monotonic()
|
|
176
|
+
if existing.event.name != DELEGATE:
|
|
177
|
+
self.recent = existing
|
|
178
|
+
# A sub-agent's calls leave the way the parent's do: scrollback
|
|
179
|
+
# keeps them under their delegate. A finished delegate takes any
|
|
180
|
+
# still listed, and its plan, with it.
|
|
181
|
+
call_id = existing.event.call_id
|
|
182
|
+
self.calls = [
|
|
183
|
+
c for c in self.calls if c is not existing and c.event.parent_call_id != call_id
|
|
184
|
+
]
|
|
185
|
+
self.plans.pop(call_id, None)
|
|
186
|
+
elif existing is not None:
|
|
187
|
+
# A restated start carries fresh progress, not a new invocation.
|
|
188
|
+
existing.event = event
|
|
189
|
+
else:
|
|
190
|
+
call = ToolCall(event)
|
|
191
|
+
if event.name == DELEGATE:
|
|
192
|
+
# The first free slot, not the delegate's position: a slot that
|
|
193
|
+
# followed position would recolour a worker when one before it
|
|
194
|
+
# finished.
|
|
195
|
+
used = [c.hue for c in self.delegates]
|
|
196
|
+
call.hue = min(range(AGENT_HUES), key=lambda hue: (used.count(hue), hue))
|
|
197
|
+
self.calls.append(call)
|
|
198
|
+
|
|
199
|
+
def clear(self) -> None:
|
|
200
|
+
self.calls.clear()
|
|
201
|
+
self.plans.clear()
|
|
202
|
+
self.recent = None
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def animating(self) -> bool:
|
|
206
|
+
"""Every listed call is live, so a delegate's panel row or the status row ticks."""
|
|
207
|
+
return bool(self.calls)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def active(self) -> ToolCall | None:
|
|
211
|
+
"""What the status row above the tasks reports.
|
|
212
|
+
|
|
213
|
+
The newest running tool call, or else the one that just finished, for
|
|
214
|
+
as long as its dwell lasts. Anything that starts meanwhile wins the
|
|
215
|
+
row: holding a stale line over live work would be the worse lie.
|
|
216
|
+
Delegates never take it; they have rows of their own on the panel.
|
|
217
|
+
"""
|
|
218
|
+
running = next(
|
|
219
|
+
(c for c in reversed(self.calls) if c.settled is None and c.event.name != DELEGATE),
|
|
220
|
+
None,
|
|
221
|
+
)
|
|
222
|
+
if running is not None:
|
|
223
|
+
return running
|
|
224
|
+
held = self.recent
|
|
225
|
+
if held is not None and monotonic() - (held.settled or held.started) < STATUS_DWELL:
|
|
226
|
+
return held
|
|
227
|
+
self.recent = None
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def running(self) -> int:
|
|
232
|
+
"""This agent's tool calls in flight, for the status row's `Running N tools`.
|
|
233
|
+
|
|
234
|
+
Delegates are counted by `delegates` instead, and the calls a
|
|
235
|
+
sub-agent makes are its own.
|
|
236
|
+
"""
|
|
237
|
+
return sum(
|
|
238
|
+
c.settled is None and not c.event.parent_call_id and c.event.name != DELEGATE
|
|
239
|
+
for c in self.calls
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def delegates(self) -> list[ToolCall]:
|
|
244
|
+
"""Every running delegate, oldest first: each keeps its panel row for its whole run."""
|
|
245
|
+
return [c for c in self.calls if c.event.name == DELEGATE and c.settled is None]
|
|
246
|
+
|
|
247
|
+
def plan_rows(self) -> int:
|
|
248
|
+
"""Rows the running delegates' plans would fill, before any budget."""
|
|
249
|
+
return sum(
|
|
250
|
+
min(CHILD_PLAN_ROWS, len(self.plans.get(c.event.call_id, []))) for c in self.delegates
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def wanted(self) -> int:
|
|
254
|
+
"""Rows the delegates and their plans would fill, before any budget."""
|
|
255
|
+
return len(self.delegates) + self.plan_rows()
|
|
256
|
+
|
|
257
|
+
def rows(self, count: int, *, nested: bool = False, icon: str = ACTIVE_TASK_ICON):
|
|
258
|
+
return _tree_rows(self._nodes(count, icon), "" if nested else None)
|
|
259
|
+
|
|
260
|
+
def _nodes(self, count: int, icon: str) -> list[_PanelNode]:
|
|
261
|
+
"""Running sub-agents, each with a window of its plan beneath it.
|
|
262
|
+
|
|
263
|
+
Only delegates get rows here. Ordinary calls, the parent's or a
|
|
264
|
+
sub-agent's, mostly settle in milliseconds, so listing the ones running
|
|
265
|
+
beside the status row made rows strobe in and out under the tasks. The
|
|
266
|
+
status row and its `Running N tools` tally already cover them.
|
|
267
|
+
"""
|
|
268
|
+
if count <= 0:
|
|
269
|
+
return []
|
|
270
|
+
delegates = self.delegates[:count]
|
|
271
|
+
remaining = count - len(delegates)
|
|
272
|
+
nodes = []
|
|
273
|
+
for parent in delegates:
|
|
274
|
+
# `»` stands in for a task icon: with a task's icon in front, a
|
|
275
|
+
# sub-agent would read as one of the parent's tasks. Its hue,
|
|
276
|
+
# shared with its plan rows, tells parallel sub-agents apart.
|
|
277
|
+
node = _PanelNode((f"class:plan.agent,agent.hue.{parent.hue}", parent.line()))
|
|
278
|
+
nodes.append(node)
|
|
279
|
+
plan = self.plans.get(parent.event.call_id, [])
|
|
280
|
+
steps, _ = plan_window(plan, min(CHILD_PLAN_ROWS, len(plan), remaining))
|
|
281
|
+
remaining -= len(steps)
|
|
282
|
+
node.children = [_PanelNode(plan_row(plan[index], icon, parent.hue)) for index in steps]
|
|
283
|
+
return nodes
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def plan_window(items: list[dict], count: int) -> tuple[range, int | None]:
|
|
287
|
+
"""The `count` steps worth showing, centred on the active one, and its index."""
|
|
288
|
+
active = next((i for i, item in enumerate(items) if item["status"] == "in_progress"), None)
|
|
289
|
+
anchor = active if active is not None else 0
|
|
290
|
+
start = min(max(0, anchor - count // 2), max(0, len(items) - count))
|
|
291
|
+
return range(start, start + count), active
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def plan_row(item: dict, active_icon: str, hue: int | None = None) -> tuple[str, str]:
|
|
295
|
+
"""A task row, styled by its status, and by its sub-agent's hue when `hue` is set."""
|
|
296
|
+
status = item["status"] if item["status"] in (*PLAN_ICONS, "in_progress") else "pending"
|
|
297
|
+
style = f"class:plan.{status}" + ("" if hue is None else f",agent.hue.{hue}")
|
|
298
|
+
icon = active_icon if status == "in_progress" else PLAN_ICONS[status]
|
|
299
|
+
return style, f"{icon} {plain(item['content'], limit=None)}"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def task_panel_rows(
|
|
303
|
+
items: list[dict],
|
|
304
|
+
tools: ToolHistory,
|
|
305
|
+
budget: int,
|
|
306
|
+
active_icon: str,
|
|
307
|
+
max_tasks: int = TASK_ROWS,
|
|
308
|
+
):
|
|
309
|
+
"""A bounded task viewport, with running delegates below the active task.
|
|
310
|
+
|
|
311
|
+
Each delegate brings a window of its own plan. Tool calls stay on the
|
|
312
|
+
status row. Keep at least one task visible, even on short panes, and
|
|
313
|
+
never add headers or empty rows.
|
|
314
|
+
"""
|
|
315
|
+
if budget <= 0:
|
|
316
|
+
return []
|
|
317
|
+
room = max(0, budget - bool(items))
|
|
318
|
+
agent_rows = min(DELEGATE_ROWS + tools.plan_rows(), tools.wanted(), room)
|
|
319
|
+
steps, active = plan_window(items, min(max_tasks, len(items), budget - agent_rows))
|
|
320
|
+
nodes = []
|
|
321
|
+
agent_nodes = tools._nodes(agent_rows, active_icon)
|
|
322
|
+
for index in steps:
|
|
323
|
+
node = _PanelNode(plan_row(items[index], active_icon))
|
|
324
|
+
nodes.append(node)
|
|
325
|
+
if index == active:
|
|
326
|
+
node.children = agent_nodes
|
|
327
|
+
if active is None or active not in steps:
|
|
328
|
+
nodes.extend(agent_nodes)
|
|
329
|
+
return _tree_rows(nodes)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def panel_fragments(lines: list, width: int):
|
|
333
|
+
"""Clip by terminal cells before prompt_toolkit renders non-wrapping rows.
|
|
334
|
+
|
|
335
|
+
A row is one `(style, text)` pair, or a list of fragments already styled
|
|
336
|
+
and fitted to the width (delta's edit preview, the barred thinking rows),
|
|
337
|
+
which passes through.
|
|
338
|
+
"""
|
|
339
|
+
fragments = []
|
|
340
|
+
for index, row in enumerate(lines):
|
|
341
|
+
if isinstance(row, list):
|
|
342
|
+
fragments.append(("", "\n" if index else ""))
|
|
343
|
+
fragments.extend(row)
|
|
344
|
+
continue
|
|
345
|
+
style, line = row
|
|
346
|
+
text = Text(plain(line, limit=None))
|
|
347
|
+
text.truncate(max(1, width), overflow="ellipsis")
|
|
348
|
+
if index:
|
|
349
|
+
fragments.append(("", "\n"))
|
|
350
|
+
fragments.extend(_row_parts(style, text.plain))
|
|
351
|
+
return fragments
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _row_parts(style: str, line: str) -> list[tuple[str, str]]:
|
|
355
|
+
"""Split a task or sub-agent row into guides, icon and text, each styled apart.
|
|
356
|
+
|
|
357
|
+
The guides stay muted whatever the row's status, so a dimmed row keeps
|
|
358
|
+
its place in the tree. A task's icon takes its status's colour even under
|
|
359
|
+
a sub-agent's hue; a sub-agent's `»` and name go bold in that hue.
|
|
360
|
+
"""
|
|
361
|
+
kind = _PART_STYLED.search(style)
|
|
362
|
+
if kind is None:
|
|
363
|
+
return [(style, line)]
|
|
364
|
+
guides = _TREE_GUIDE.match(line).end()
|
|
365
|
+
icon_end = line.find(" ", guides)
|
|
366
|
+
icon_end = len(line) if icon_end < 0 else icon_end
|
|
367
|
+
if kind[1] == "agent":
|
|
368
|
+
name_end = line.find(" · ", icon_end)
|
|
369
|
+
icon_end, icon_style = (len(line) if name_end < 0 else name_end), f"{style} bold"
|
|
370
|
+
else:
|
|
371
|
+
icon_style = f"class:plan.icon.{kind[1]}"
|
|
372
|
+
parts = [
|
|
373
|
+
("class:plan.tree", line[:guides]),
|
|
374
|
+
(icon_style, line[guides:icon_end]),
|
|
375
|
+
(style, line[icon_end:]),
|
|
376
|
+
]
|
|
377
|
+
return [part for part in parts if part[1]]
|
pcode/transcript_log.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Bounded presentation history, independent of model/session persistence."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import wraps
|
|
7
|
+
|
|
8
|
+
# A regenerated view replaces the terminal's scrollback, so the budget has to
|
|
9
|
+
# cover what a terminal keeps. Counting entries alone is misleading: tiny writes
|
|
10
|
+
# and large Markdown blocks have very different costs, so an entry cap can evict
|
|
11
|
+
# visible history long before memory is a concern. Bound retained text instead, well
|
|
12
|
+
# past any terminal's scrollback, and keep a loose entry cap so a flood of tiny
|
|
13
|
+
# writes cannot grow the deque without limit.
|
|
14
|
+
CHAR_BUDGET = 2_000_000
|
|
15
|
+
# Scale the tiny-write guard with the one configurable text budget (20K by default).
|
|
16
|
+
CHARS_PER_ENTRY = 100
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RetainedMarkdown:
|
|
21
|
+
"""Markdown kept as its source: replay rebuilds the renderable and its theme.
|
|
22
|
+
|
|
23
|
+
A parsed token tree costs tens of times the text it came from, and replay
|
|
24
|
+
rebuilds it from the source anyway, so retaining it would bound the log by
|
|
25
|
+
Rich's parse output instead of by the transcript.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
markup: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def snapshot(value):
|
|
32
|
+
"""Copy retained arguments so later mutation cannot change replayed history."""
|
|
33
|
+
if isinstance(value, tuple):
|
|
34
|
+
return tuple(snapshot(item) for item in value)
|
|
35
|
+
markup = getattr(value, "markup", None)
|
|
36
|
+
if isinstance(markup, str) and hasattr(value, "parsed"):
|
|
37
|
+
return RetainedMarkdown(markup)
|
|
38
|
+
return deepcopy(value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def retained_chars(value, depth: int = 0) -> int:
|
|
42
|
+
"""Estimate the text one retained argument replays, without rendering it."""
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
return len(value)
|
|
45
|
+
if depth >= 4:
|
|
46
|
+
return 0
|
|
47
|
+
if isinstance(value, (tuple, list, set, frozenset)):
|
|
48
|
+
return sum(retained_chars(item, depth + 1) for item in value)
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
return sum(retained_chars(item, depth + 1) for item in value.values())
|
|
51
|
+
markup = getattr(value, "markup", None)
|
|
52
|
+
if isinstance(markup, str): # Markdown replays from its source, not its tokens.
|
|
53
|
+
return len(markup)
|
|
54
|
+
attributes = getattr(value, "__dict__", None)
|
|
55
|
+
if attributes is None: # Numbers, None, and slot-only objects carry no text.
|
|
56
|
+
return 0
|
|
57
|
+
return sum(retained_chars(item, depth + 1) for item in attributes.values())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Entry:
|
|
62
|
+
"""One recorded presentation call, with the text it will replay."""
|
|
63
|
+
|
|
64
|
+
method: str
|
|
65
|
+
args: tuple
|
|
66
|
+
kwargs: dict
|
|
67
|
+
chars: int = field(init=False)
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
self.chars = retained_chars(self.args) + retained_chars(self.kwargs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TranscriptLog:
|
|
74
|
+
"""Keep recent semantic writes, including writes hidden by display settings."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, limit: int | None = None, max_chars: int = CHAR_BUDGET):
|
|
77
|
+
self.entries: deque[Entry] = deque()
|
|
78
|
+
self.limit = max(1, max_chars // CHARS_PER_ENTRY) if limit is None else limit
|
|
79
|
+
self.max_chars = max_chars
|
|
80
|
+
self.chars = 0
|
|
81
|
+
self.dropped = False
|
|
82
|
+
self.recording = True
|
|
83
|
+
self.capture_only = False
|
|
84
|
+
|
|
85
|
+
def append(self, method, args, kwargs):
|
|
86
|
+
if method == "thinking" and self.entries and self.entries[-1].method == method:
|
|
87
|
+
previous = self.entries.pop()
|
|
88
|
+
self.chars -= previous.chars
|
|
89
|
+
# Both halves are already-copied strings; merging needs no new copy.
|
|
90
|
+
args, kwargs = (previous.args[0] + args[0],), {}
|
|
91
|
+
else:
|
|
92
|
+
args, kwargs = snapshot(args), deepcopy(kwargs)
|
|
93
|
+
entry = Entry(method, args, kwargs)
|
|
94
|
+
self.entries.append(entry)
|
|
95
|
+
self.chars += entry.chars
|
|
96
|
+
# Always keep the newest entry, even when it alone exceeds the budget.
|
|
97
|
+
while len(self.entries) > self.limit or (
|
|
98
|
+
self.chars > self.max_chars and len(self.entries) > 1
|
|
99
|
+
):
|
|
100
|
+
self.chars -= self.entries.popleft().chars
|
|
101
|
+
self.dropped = True
|
|
102
|
+
|
|
103
|
+
def clear(self) -> None:
|
|
104
|
+
"""Forget retained history so the next rebuild starts from an empty screen."""
|
|
105
|
+
self.entries.clear()
|
|
106
|
+
self.chars = 0
|
|
107
|
+
self.dropped = False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def recorded(method):
|
|
111
|
+
"""Record the outermost presentation operation, never its nested prints."""
|
|
112
|
+
|
|
113
|
+
@wraps(method)
|
|
114
|
+
def write(self, *args, **kwargs):
|
|
115
|
+
log = self.log
|
|
116
|
+
if not log.recording:
|
|
117
|
+
return method(self, *args, **kwargs)
|
|
118
|
+
log.append(method.__name__, args, kwargs)
|
|
119
|
+
if log.capture_only:
|
|
120
|
+
return None
|
|
121
|
+
log.recording = False
|
|
122
|
+
try:
|
|
123
|
+
return method(self, *args, **kwargs)
|
|
124
|
+
finally:
|
|
125
|
+
log.recording = True
|
|
126
|
+
|
|
127
|
+
return write
|