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,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]]
@@ -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