pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/ui.py ADDED
@@ -0,0 +1,3745 @@
1
+ """Editable prompt with replayable output in the terminal's normal scrollback."""
2
+
3
+ import asyncio
4
+ import os
5
+ import re
6
+ from asyncio import Future
7
+ from collections import Counter
8
+ from collections.abc import Callable
9
+ from contextlib import asynccontextmanager, contextmanager, nullcontext
10
+ from dataclasses import asdict, dataclass, field, replace
11
+ from functools import cache, cached_property, lru_cache, partial, wraps
12
+ from io import StringIO
13
+ from time import monotonic
14
+
15
+ from prompt_toolkit import PromptSession
16
+ from prompt_toolkit.application import Application, get_app, get_app_or_none
17
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
18
+ from prompt_toolkit.completion import merge_completers
19
+ from prompt_toolkit.enums import EditingMode
20
+ from prompt_toolkit.filters import Always, Condition, has_focus
21
+ from prompt_toolkit.formatted_text import ANSI, StyleAndTextTuples
22
+ from prompt_toolkit.key_binding.vi_state import InputMode
23
+ from prompt_toolkit.layout import (
24
+ ConditionalContainer,
25
+ HSplit,
26
+ Layout,
27
+ VSplit,
28
+ Window,
29
+ )
30
+ from prompt_toolkit.layout.containers import VerticalAlign
31
+ from prompt_toolkit.layout.controls import FormattedTextControl
32
+ from prompt_toolkit.layout.menus import CompletionsMenu
33
+ from prompt_toolkit.output import Output, create_output
34
+ from prompt_toolkit.renderer import Renderer
35
+ from prompt_toolkit.styles import Style
36
+ from prompt_toolkit.utils import get_cwidth
37
+ from prompt_toolkit.widgets import Label
38
+ from rich.cells import cell_len
39
+ from rich.console import Console
40
+ from rich.markdown import Markdown
41
+ from rich.padding import Padding
42
+ from rich.spinner import Spinner
43
+ from rich.table import Table
44
+ from rich.text import Text
45
+ from rich.theme import Theme
46
+
47
+ from pcode.block import INDENT, RULE, RUNNING, block_heading
48
+ from pcode.command_transcript import CommandTranscript
49
+ from pcode.commands import CommandRegistry, SlashCompleter
50
+ from pcode.delta import Delta
51
+ from pcode.delta import from_preferences as delta_from_preferences
52
+ from pcode.edit_transcript import (
53
+ EditTranscript,
54
+ LiveDeltaPreview,
55
+ edit_preview_rows,
56
+ prefetch_edits,
57
+ )
58
+ from pcode.file_refs import FileReferenceCompleter, ReferenceLexer, reference_fragment
59
+ from pcode.frame import TITLE_CHROME, Frame
60
+ from pcode.input_keys import configure_newline_keys
61
+ from pcode.jobs import WATCHED_PREFIX
62
+ from pcode.keymap import PromptKeymap
63
+ from pcode.layout_speed import install_fast_layout_division
64
+ from pcode.paste import MARKER_PATTERN
65
+ from pcode.popup_ui import shortcut_hint
66
+ from pcode.preferences import (
67
+ SETTINGS,
68
+ SYNTAX_THEMES,
69
+ TERMINAL_SYNTAX,
70
+ load_preferences,
71
+ parse_height,
72
+ screen_rows,
73
+ )
74
+ from pcode.prefix_keys import PrefixKeys, shortcut_label
75
+ from pcode.prompt_keys import PromptCallbacks, prompt_key_bindings
76
+ from pcode.runtime import CacheBust, CommandOutput, Event, Message, Thinking, ToolSummary
77
+ from pcode.shell_mode import SHELL_PREFIX
78
+ from pcode.syntax_colors import derive_colors
79
+ from pcode.task_prompt import TaskPrompt
80
+ from pcode.theme import detect_theme
81
+ from pcode.theme_gallery import SyntaxGallery
82
+ from pcode.thinking_markdown import ThinkingMarkdown
83
+ from pcode.tool_display import (
84
+ COMMAND_TOOLS,
85
+ EDIT_TOOLS,
86
+ PLAN_TOOLS,
87
+ command_preview,
88
+ command_text,
89
+ label,
90
+ plain,
91
+ split_outcome,
92
+ tool_summary_lines,
93
+ )
94
+ from pcode.tool_panel import (
95
+ ACTIVE_TASK_ICON,
96
+ DELEGATE,
97
+ TASK_ROWS,
98
+ ToolCall,
99
+ ToolHistory,
100
+ panel_fragments,
101
+ task_panel_rows,
102
+ )
103
+ from pcode.transcript_log import RetainedMarkdown, TranscriptLog, recorded
104
+ from pcode.transcript_notice import Note, TranscriptNotice
105
+ from pcode.word_wrap import WordWrapProcessor
106
+ from pcode.workers import Workers
107
+
108
+ # `task_style icons`: task text has one weight whatever its status, and the
109
+ # coloured icon alone says how a task stands. Cancelled keeps its strike: a
110
+ # mark, not a shade. A delegate's hue still colours its rows, since its class
111
+ # is named after these in the row's style string.
112
+ ICON_TASK_STYLES = {
113
+ "plan.pending": "nodim nobold fg:default",
114
+ "plan.in_progress": "nodim nobold fg:default",
115
+ "plan.completed": "nodim nobold fg:default",
116
+ "plan.cancelled": "nodim nobold fg:default strike",
117
+ "plan.blocked": "nodim nobold fg:default",
118
+ }
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class Palette:
123
+ accent: str
124
+ muted: str
125
+ surface: str
126
+ foreground: str
127
+ selected: str
128
+ task_heading: str
129
+ # A finished task's tick.
130
+ success: str
131
+ # The hue ring for running sub-agents: each takes the first free slot and
132
+ # keeps it, so its header and plan rows read as one block.
133
+ agents: tuple[str, str, str]
134
+
135
+ @cache
136
+ def rich_theme(self) -> Theme:
137
+ # Body text/background remain terminal-native; accents and bounded code
138
+ # surfaces are explicitly paired for the selected appearance.
139
+ return Theme(
140
+ {
141
+ "pcode.accent": self.accent,
142
+ "pcode.brand": f"bold {self.accent}",
143
+ "pcode.muted": self.muted,
144
+ # Scrollback notes: italic like the live panel's own notices,
145
+ # and dimmed so they recede behind the model's prose.
146
+ "pcode.note": f"italic dim {self.muted}",
147
+ "pcode.thinking": f"dim {self.muted}",
148
+ "pcode.error": "bold red",
149
+ "pcode.warning": "bold yellow",
150
+ "markdown.code": f"{self.foreground} on {self.surface}",
151
+ "markdown.code_block": self.foreground,
152
+ "markdown.block_quote": f"italic {self.muted}",
153
+ "markdown.h1": f"bold underline {self.accent}",
154
+ "markdown.h2": f"bold {self.accent}",
155
+ "markdown.h3": f"bold {self.accent}",
156
+ "markdown.h4": f"italic {self.accent}",
157
+ "markdown.h5": f"italic {self.accent}",
158
+ "markdown.h6": self.muted,
159
+ "markdown.h7": f"italic {self.muted}",
160
+ "markdown.hr": self.muted,
161
+ "markdown.link": f"underline {self.accent}",
162
+ "markdown.link_url": f"underline {self.accent}",
163
+ "markdown.list": self.accent,
164
+ "markdown.item.number": self.accent,
165
+ "markdown.table.border": self.muted,
166
+ "markdown.table.header": f"bold {self.accent}",
167
+ }
168
+ )
169
+
170
+ @cache
171
+ def prompt_style(self, menu: "Palette | None" = None, task_style: str = "status") -> Style:
172
+ # Palette is immutable. Reuse the Style so DynamicStyle's identity-based
173
+ # invalidation hash changes only with the palette, not on every redraw.
174
+ # `menu` colors the completion popup, which follows the selected syntax
175
+ # style rather than this palette; it is immutable and cached too.
176
+ # `task_style` is the `task_style` setting (see ICON_TASK_STYLES).
177
+ menu = self if menu is None else menu
178
+ highlight = (
179
+ f"reverse bg:default {menu.accent}"
180
+ if menu.selected == "reverse"
181
+ else f"noreverse bg:{menu.selected} {menu.accent}"
182
+ )
183
+ return Style.from_dict(
184
+ {
185
+ "plan": self.muted,
186
+ "plan.heading": f"nodim {self.task_heading} bold",
187
+ # Every task finished: the count turns the colour of the turn's tick.
188
+ "plan.heading.done": f"nodim {self.success} bold",
189
+ "plan.hint": f"nodim nobold {self.muted}",
190
+ # Task rows by status, in three weights: the active one is
191
+ # loud, what is left is muted, and settled work recedes.
192
+ # `tool_panel.panel_fragments` gives the icon and tree guides
193
+ # classes of their own, so the colour that says how a task
194
+ # stands survives a delegate's hue on its text.
195
+ "plan.pending": self.muted,
196
+ "plan.in_progress": f"nodim {self.accent} bold",
197
+ "plan.completed": f"{self.muted} dim",
198
+ "plan.cancelled": f"{self.muted} dim strike",
199
+ "plan.blocked": "nodim ansiyellow",
200
+ # A running sub-agent's row. Its colour comes from its hue.
201
+ "plan.agent": "nodim",
202
+ # Colour only. A row's style string names its hue class after
203
+ # its status class (`tool_panel.plan_row`), so the hue wins on
204
+ # colour and leaves the status's weight (bold, dim, strike)
205
+ # alone. It lives outside `plan.`: a `plan.*` class would
206
+ # re-apply the base `plan` rule, which is `dim` in the
207
+ # terminal palette, over the status.
208
+ **{f"agent.hue.{index}": hue for index, hue in enumerate(self.agents)},
209
+ "plan.icon.pending": self.muted,
210
+ "plan.icon.in_progress": f"nodim {self.accent} bold",
211
+ "plan.icon.completed": f"nodim {self.success}",
212
+ "plan.icon.cancelled": f"{self.muted} dim",
213
+ "plan.icon.blocked": "nodim ansiyellow bold",
214
+ "plan.tree": self.muted,
215
+ "prompt": f"{self.accent} bold",
216
+ # The live area has three weights. Live: the spinner and the
217
+ # phase word, the one thing that says the turn is moving.
218
+ # Content: what it is doing, in the session name's hue, so the
219
+ # border's text keeps one colour whether it holds the name or
220
+ # the turn's detail, and never reads as prose. A sub-agent's
221
+ # line there takes its own hue instead (`activity.agent`).
222
+ # Chrome: counts, clocks, notices, jobs and queues, muted.
223
+ "activity.spinner": self.accent,
224
+ "activity.badge": self.accent,
225
+ "activity.phase": f"nodim {self.accent} bold",
226
+ "activity.detail": f"nodim {self.task_heading}",
227
+ # Weight only: the colour comes from its `agent.hue.N` class.
228
+ "activity.agent": "nodim",
229
+ "activity.meta": self.muted,
230
+ # The thinking rows above the status row: the model's newest
231
+ # thought, faded like scrollback thinking (`pcode.thinking`) and
232
+ # italic so it reads as a quote rather than as the turn's phase
233
+ # (accent) or what it is doing (the task heading's hue). Muted
234
+ # and hue-less, so a thought never reads as either.
235
+ "activity.thinking": f"italic {self.muted}",
236
+ # Keep the reasoning marker upright beside the italic text.
237
+ "activity.thinking.icon": f"noitalic {self.muted}",
238
+ # The tool row under the thought: upright where the thought is
239
+ # italic, and muted whether its call runs or has settled, so it
240
+ # never flips shade; a running call's mark is the status row's
241
+ # spinner in this shade, not its accent. Only a success takes a
242
+ # colour, dimmed so it never reads as a plan's completed step,
243
+ # which uses the same `✓` at full strength. A failure is dimmed
244
+ # without a hue: plenty of commands are meant to fail (a grep
245
+ # with no match, a probe), so the row notes it without alarm.
246
+ "activity.tool": self.muted,
247
+ "activity.tool.done": f"dim {self.success}",
248
+ "activity.tool.failed": f"dim {self.muted}",
249
+ # System work is pcode's own: the badge and accent mark it, and
250
+ # its queued rows keep an italic detail.
251
+ "activity.system": self.accent,
252
+ "activity.system.detail": f"italic {self.muted}",
253
+ # Short-lived answers to a keystroke live above the spinner
254
+ # rather than in scrollback; italics mark them as chrome.
255
+ "activity.notice": f"italic {self.muted}",
256
+ # A run's pending group line, shown only when no status row
257
+ # carries its tally. Still live, so muted rather than dimmed
258
+ # like the settled scrollback line it becomes.
259
+ "activity.group": self.muted,
260
+ # A side question runs beside the turn, not as part of it, so its
261
+ # row spins in the muted shade rather than the accent.
262
+ "activity.aside": self.muted,
263
+ # The live preview block, drawn the way scrollback draws a
264
+ # settled one: a heading on the opening line, rules around it.
265
+ "block.rule": self.muted,
266
+ "block.heading": self.accent,
267
+ "frame.border": self.muted,
268
+ # The editor box's session name is the session's own metadata,
269
+ # in the task heading's hue (unbolded) so it never reads as the
270
+ # reply above it, nor as the accent's busy status row. The last
271
+ # turn's mark leads it: a quiet tick, a muted stop for a cancel
272
+ # the user asked for, and red only for a failure.
273
+ "session.name": f"nodim {self.task_heading}",
274
+ "session.done": f"nodim {self.success}",
275
+ "session.cancelled": self.muted,
276
+ "session.failed": "nodim ansired bold",
277
+ # The keybinding overlay: keys in the accent so the eye finds
278
+ # them first, its way out muted in the bottom border.
279
+ "hint.key": f"{self.accent} bold",
280
+ "frame.footer": self.muted,
281
+ "hint.message": "yellow",
282
+ "editor.mode": "noreverse nodim bg:#b8b8b8 fg:#ffffff",
283
+ "editor.mode.normal": "bg:#2563eb",
284
+ "editor.mode.visual": "bg:#7c3aed",
285
+ "editor.mode.replace": "bg:#b91c1c",
286
+ # Keep foreground and background paired with the terminal theme:
287
+ # the app palette may still be dark on a light terminal.
288
+ "bottom-toolbar": "noreverse nodim bg:default fg:default",
289
+ "bottom-toolbar.text": "fg:default",
290
+ # Same roles as the prompt chrome: what matters (where, what is
291
+ # running) in the accent, labels muted so the row stays quiet.
292
+ "bottom-toolbar.sep": self.muted,
293
+ "bottom-toolbar.location": f"{self.accent} bold",
294
+ "bottom-toolbar.mode": self.task_heading,
295
+ "bottom-toolbar.hint": self.muted,
296
+ "bottom-toolbar.model": self.accent,
297
+ "bottom-toolbar.context": self.muted,
298
+ "bottom-toolbar.context-value": self.accent,
299
+ "bottom-toolbar.activity": f"{self.task_heading} bold",
300
+ "bottom-toolbar.cache": self.muted,
301
+ "completion-menu": f"bg:{menu.surface} {menu.foreground}",
302
+ "completion-menu.completion": f"bg:{menu.surface} {menu.foreground}",
303
+ # The toolkit's selected-row default uses reverse; explicitly
304
+ # disable it so light themes keep dark text on a light surface.
305
+ "completion-menu.completion.current": f"{highlight} bold",
306
+ "completion-menu scrollbar.background": f"bg:{menu.surface}",
307
+ "completion-menu scrollbar.button": (
308
+ "reverse bg:default" if menu.selected == "reverse" else f"bg:{menu.selected}"
309
+ ),
310
+ # Full-screen popups keep native body surfaces, but share the
311
+ # completion menu's paired highlight colors, even when a syntax
312
+ # style has a different appearance from the terminal.
313
+ "popup selected": f"{highlight} nodim nounderline",
314
+ "popup cursor-line": f"{highlight} nodim nounderline",
315
+ "popup scrollbar.background": f"noreverse bg:{menu.surface} fg:default",
316
+ "popup scrollbar.button": f"noreverse bg:{menu.accent} fg:default",
317
+ "popup scrollbar.arrow": f"noreverse bg:default {self.accent} bold",
318
+ "completion-menu.meta.completion": f"bg:{menu.surface} {menu.muted}",
319
+ "completion-menu.meta.completion.current": (
320
+ "reverse bg:default fg:default"
321
+ if menu.selected == "reverse"
322
+ else f"bg:{menu.selected} {menu.foreground}"
323
+ ),
324
+ # A file reference is neither prose nor a command: underlining
325
+ # it marks the token without competing with the prompt chevron.
326
+ "reference": f"{self.task_heading} underline",
327
+ # The marker stands in for hidden text; make it impossible to
328
+ # mistake for something the user typed.
329
+ "paste-marker": "bold reverse",
330
+ "auto-suggestion": self.muted,
331
+ **(ICON_TASK_STYLES if task_style == "icons" else {}),
332
+ }
333
+ )
334
+
335
+
336
+ PALETTES = {
337
+ "dark": Palette(
338
+ "#88c0d0",
339
+ "#8994a6",
340
+ "#242933",
341
+ "#e5e9f0",
342
+ "#384457",
343
+ "#c4b5fd",
344
+ "#a3be8c",
345
+ ("#c4b5fd", "#f5a97f", "#f5bde6"),
346
+ ),
347
+ "light": Palette(
348
+ "#006b80",
349
+ "#586575",
350
+ "#edf0f4",
351
+ "#202630",
352
+ "#d0e7ef",
353
+ "#7c3aed",
354
+ "#2f7d32",
355
+ ("#7c3aed", "#c2410c", "#be185d"),
356
+ ),
357
+ }
358
+
359
+ # `/syntax terminal`: named ANSI colors, so the prompt, plan rows and popup
360
+ # follow the terminal's own scheme. Muted text is `dim` rather than bright
361
+ # black, which several schemes (Solarized) paint as the background itself, and
362
+ # the selected row is reversed for the same reason. `fg:default` keeps the
363
+ # toolkit's own RGB defaults (black popup metadata, grey suggestions) from
364
+ # showing through.
365
+ # Cyan, green and yellow already mean active, done and blocked, which leaves
366
+ # magenta, blue and red for the sub-agents.
367
+ TERMINAL_PALETTE = Palette(
368
+ "ansicyan",
369
+ "fg:default dim",
370
+ "default",
371
+ "default",
372
+ "reverse",
373
+ "ansimagenta",
374
+ "ansigreen",
375
+ ("ansimagenta", "ansiblue", "ansired"),
376
+ )
377
+
378
+
379
+ @cache
380
+ def syntax_palette(style_name: str, fallback: Palette, backdrop: str | None = None) -> Palette:
381
+ """The palette a Pygments style implies, backed by `fallback`'s colors.
382
+
383
+ `backdrop` is the background the colors will be painted on when it is not
384
+ the style's own; see `derive_colors`.
385
+
386
+ Cached because prompt_toolkit's DynamicStyle invalidates on the identity of
387
+ the object it is handed: a fresh Palette on every redraw would rebuild the
388
+ whole style tree. Palette is frozen, so it is a usable cache key.
389
+ """
390
+ return Palette(**derive_colors(style_name, asdict(fallback), backdrop))
391
+
392
+
393
+ def syntax_themes(preferences: dict[str, str] | None = None) -> dict[str, str]:
394
+ """The saved Pygments style for each palette, by palette name.
395
+
396
+ Fenced code keeps its own background, so the style has to be chosen per
397
+ palette rather than derived from one: a dark style on a light terminal is
398
+ readable but jarring, and `theme auto` can pick either at startup.
399
+ """
400
+ preferences = load_preferences() if preferences is None else preferences
401
+ return {
402
+ name: preferences.get(f"syntax_{name}", SETTINGS[f"syntax_{name}"].default)
403
+ for name in PALETTES
404
+ }
405
+
406
+
407
+ # Rich owns scrollback, not the prompt palette. Use terminal-defined ANSI colors
408
+ # and leave the background alone so output fits either terminal appearance.
409
+ # In particular, Rich's default inline code paints a black background.
410
+ TERMINAL_THEME = Theme(
411
+ {
412
+ "pcode.accent": "cyan",
413
+ "pcode.brand": "bold cyan",
414
+ "pcode.muted": "default",
415
+ "pcode.note": "italic dim default",
416
+ "pcode.thinking": "dim default",
417
+ "pcode.error": "bold red",
418
+ "pcode.warning": "bold yellow",
419
+ "markdown.code": "bold cyan",
420
+ "markdown.code_block": "default",
421
+ "markdown.block_quote": "italic default",
422
+ "markdown.h1": "bold underline",
423
+ "markdown.h2": "bold",
424
+ "markdown.h3": "bold cyan",
425
+ "markdown.h4": "italic cyan",
426
+ "markdown.h5": "italic",
427
+ "markdown.h6": "italic",
428
+ "markdown.h7": "italic",
429
+ "markdown.hr": "default",
430
+ "markdown.link": "underline blue",
431
+ "markdown.link_url": "underline blue",
432
+ "markdown.table.border": "cyan",
433
+ "markdown.table.header": "bold",
434
+ }
435
+ )
436
+
437
+
438
+ # Marks rows pcode drives itself. The diamond reads as a system marker rather
439
+ # than the "❯" prompt chevron, and the arrows suggest folding history inward.
440
+ SYSTEM_BADGE = "◈"
441
+ SYSTEM_SEPARATOR = "▸"
442
+ # Slash commands pcode runs itself, labelled the same whether queued or running.
443
+ SYSTEM_COMMAND_LABELS = {"/compact": "Compacting context"}
444
+
445
+
446
+ def system_command(text: str) -> tuple[str, str] | None:
447
+ """Split a slash command into its badge label and detail, or None if it is a prompt."""
448
+ name, _, detail = plain(text, limit=None).strip().partition(" ")
449
+ label = SYSTEM_COMMAND_LABELS.get(name)
450
+ return (label, detail.strip()) if label else None
451
+
452
+
453
+ # A notice answers a keystroke, so it only has to outlast reading it once.
454
+ NOTICE_SECONDS = 5.0
455
+ # Scrollback columns a sub-agent's calls sit in from their delegate's row.
456
+ CHILD_INDENT = 4
457
+ NOTICE_ROWS = 6
458
+ # Running side questions likewise; `/btw` has the full list.
459
+ ASIDE_ROWS = 3
460
+ # A wait on the session host shorter than this never gets a row: most answer
461
+ # within a frame or two, and a row that flashes for them reads as a glitch.
462
+ WAIT_GRACE_SECONDS = 0.25
463
+
464
+
465
+ # The status row is redrawn several times a second while a turn runs, so a
466
+ # longer gap means the row went away; its phase clock starts over.
467
+ PHASE_GAP_SECONDS = 1.0
468
+ # Least time the status row keeps what it says before saying something else.
469
+ # A turn can change phase or tool several times a second, and a row that
470
+ # rewrites itself that fast cannot be read. Once the hold is up the row jumps
471
+ # to whatever is current, skipping anything that came and went meanwhile.
472
+ STATUS_HOLD_SECONDS = 2.5
473
+ # Status text the row leads with verbatim; anything longer is detail.
474
+ PHASE_WORDS = 3
475
+ # Cells of detail worth more than the status row's tally and clock.
476
+ DETAIL_MIN_CELLS = 16
477
+ # Characters of streamed thinking kept for the thinking rows: its latest line.
478
+ THINKING_KEEP = 2000
479
+ # Default for `thinking_max_lines`: rows the newest thought may wrap to above
480
+ # the status row. A thought is a sentence or two, which one row rarely holds.
481
+ THOUGHT_ROWS = 10
482
+ # Default for `tool_max_lines`: the turn's latest calls listed above the
483
+ # status row, so parallel calls each show and a finished one lingers.
484
+ TOOL_ROWS = 3
485
+ # Default for `tool_linger_seconds`: a finished call's row clears after this,
486
+ # so a long wait on the model does not keep showing stale calls.
487
+ TOOL_LINGER_SECONDS = 10
488
+ # Marks the first thought row in the spinner's column, without resembling
489
+ # a stalled frame of the spinners below it.
490
+ THOUGHT_ICON = "∴"
491
+ # The tool row's stand-ins for common verbs (`tool_display.LABELS`), so it
492
+ # says `$ make test` rather than `Run shell · make test`. A tool not listed
493
+ # keeps its verb spelled out. Each is one cell wide, ambiguous-width or not.
494
+ # ASCII, in every font, so `tool_glyphs off` keeps it.
495
+ PLAIN_TOOL_GLYPHS = dict.fromkeys(("Run shell", "Run code", "Start command", "Check command"), "$")
496
+ TOOL_GLYPHS = {
497
+ **PLAIN_TOOL_GLYPHS,
498
+ **dict.fromkeys(("Search code", "Find files", "List directory", "Find tools"), "⌕"),
499
+ **dict.fromkeys(("Edit file", "Write file"), "✎"),
500
+ **dict.fromkeys(("Read file", "Read results", "Read job output", "File info"), "⎘"),
501
+ "Wait for job": "⧖",
502
+ }
503
+
504
+
505
+ def tool_glyphs_on(setting: str) -> bool:
506
+ """Whether the `tool_glyphs` setting (`auto`, `on`, `off`) draws the symbols.
507
+
508
+ Almost no monospace font has `⌕`, `⎘` or `⧖`: the terminal draws
509
+ them from a fallback font, which on macOS and most desktops looks fine
510
+ and on the Linux console is a box, so `auto` leaves them off where
511
+ `TERM` says it is that console (tmux on it says otherwise). A terminal
512
+ cannot say which fonts it has, so anywhere else a missing symbol is the
513
+ user's to turn off.
514
+ """
515
+ if setting == "auto":
516
+ return os.environ.get("TERM") != "linux"
517
+ return setting == "on"
518
+
519
+
520
+ def status_parts(status: str) -> tuple[str, str]:
521
+ """Split free-text status into the row's phase word and its detail.
522
+
523
+ Several modules write `Activity.status`; the convention is `Phase · detail…`
524
+ (`Running shell · src…`, `Retrying · Overloaded…`). A short bare status
525
+ is all phase (`Thinking…`); a sentence with no phase becomes detail, so it
526
+ never renders as one long highlighted word.
527
+ """
528
+ text = plain(status, limit=None).strip().removesuffix("…").strip()
529
+ phase, separator, detail = text.partition(" · ")
530
+ if separator:
531
+ return phase, detail.removesuffix("…").strip()
532
+ if not text:
533
+ return "Working", ""
534
+ if len(text.split()) <= PHASE_WORDS:
535
+ return text, ""
536
+ return "Working", text
537
+
538
+
539
+ def status_spinner(setting: str = "spinner") -> Spinner:
540
+ """A spinner for the bottom block, as the `setting` preference names it.
541
+
542
+ `spinner` is the status row's (waits on a model), `tool_spinner` running
543
+ tool calls'.
544
+ """
545
+ spinner = Spinner(load_preferences().get(setting, SETTINGS[setting].default))
546
+ # Every frame is a full layout pass (~2-3ms), so the animation loop alone
547
+ # costs a few percent of a core for the length of a turn. Rich's built-in
548
+ # interval is tuned for a dedicated terminal spinner, not for driving
549
+ # pcode's whole bottom block; slow it ~1.6x, which still reads as motion
550
+ # but noticeably cuts render frequency.
551
+ spinner.interval = round(spinner.interval * 1.6)
552
+ return spinner
553
+
554
+
555
+ def clock(seconds: float) -> str:
556
+ """`8s`, `2m05s`: whole seconds, since the row is not a stopwatch."""
557
+ seconds = max(0, int(seconds))
558
+ return f"{seconds}s" if seconds < 60 else f"{seconds // 60}m{seconds % 60:02d}s"
559
+
560
+
561
+ @dataclass
562
+ class StatusLine:
563
+ """The status row's parts, in the one order every state uses.
564
+
565
+ Left: spinner, optional badge, the phase (the live word, accented), then
566
+ the detail. Right, in a fixed column: the run's tool tally and the phase
567
+ clock, muted. Narrow panes drop the tally, then the clock, then cut the
568
+ detail, and only then the phase. A tool call's command or path never
569
+ rides this row: it gets the tool rows above (`Activity.tool_rows`).
570
+ """
571
+
572
+ phase: str
573
+ detail: str = ""
574
+ tally: str = ""
575
+ elapsed: float | None = None
576
+ badge: str = ""
577
+ separator: str = "·"
578
+ # The tool call the line reports, running or just finished; the tool row
579
+ # shows what it acts on.
580
+ call: ToolCall | None = None
581
+ # Whether `call` had finished when the line was taken.
582
+ settled: bool = False
583
+ # The hue slot of the sub-agent the detail describes, if it describes one.
584
+ hue: int | None = None
585
+
586
+ def fragments(self, spinner: str, width: int, *, rule: bool = False) -> list[tuple[str, str]]:
587
+ """The row, `width` cells wide when it has meta; `rule` draws the gap as border."""
588
+ if width < 1:
589
+ return []
590
+ head = [("class:activity.spinner", f"{spinner} ")]
591
+ if self.badge:
592
+ head.append(("class:activity.badge", f"{self.badge} "))
593
+ head.append(("class:activity.phase", self.phase))
594
+ needed = sum(cell_len(text) for _, text in head)
595
+ detail = f" {self.separator} {plain(self.detail, limit=None)}" if self.detail else ""
596
+ # The meta column gives way before the detail is cut to a stub.
597
+ wanted = needed + min(cell_len(detail), DETAIL_MIN_CELLS)
598
+ meta = [part for part in (self.tally, self._clock()) if part]
599
+ while meta and wanted + 2 + cell_len(" · ".join(meta)) > width:
600
+ meta.pop(0)
601
+ suffix = " · ".join(meta)
602
+ room = width - (cell_len(suffix) + 2 if suffix else 0)
603
+ # A detail with no room to say anything is dropped, not left as `·…`.
604
+ if detail and room - needed >= DETAIL_MIN_CELLS // 2:
605
+ style = "class:activity.detail"
606
+ if self.hue is not None:
607
+ style = f"class:activity.agent,agent.hue.{self.hue}"
608
+ head.append((style, detail))
609
+ fitted = fit_fragments(head, room)
610
+ if not suffix:
611
+ return fitted
612
+ pad = room - sum(cell_len(text) for _, text in fitted) + 2
613
+ gap = ("class:frame.border", f" {'─' * (pad - 2)} ") if rule else ("", " " * pad)
614
+ return [*fitted, gap, ("class:activity.meta", suffix)]
615
+
616
+ def _clock(self) -> str:
617
+ return "" if self.elapsed is None else clock(self.elapsed)
618
+
619
+
620
+ @dataclass
621
+ class _HeldStatus:
622
+ """The status line on screen, and what decides how long it may stay."""
623
+
624
+ line: StatusLine
625
+ since: float # When this text first showed.
626
+ taken: float # When `line`, and so its clock, was computed.
627
+ seen: float # Last drawn.
628
+ turn: tuple[str, str] # The prompt it belongs to.
629
+ call: ToolCall | None # The running tool call it reports, if any.
630
+
631
+
632
+ def _status_text(line: StatusLine) -> tuple:
633
+ """What a status line says, apart from its ticking clock and tally."""
634
+ return (line.badge, line.phase, line.detail, line.call, line.settled)
635
+
636
+
637
+ def _urgent(line: StatusLine) -> bool:
638
+ """News the status row shows at once, whatever it is holding."""
639
+ failed = line.settled and line.call is not None and line.call.failed
640
+ return line.phase.startswith("Retrying") or failed
641
+
642
+
643
+ def call_parts(call: ToolCall) -> tuple[str, str]:
644
+ """A call's verb (`Run shell`) and what it acts on (`ls -la`), if anything."""
645
+ label, _, detail = call.line(timed=False).partition(" · ")
646
+ return label, detail
647
+
648
+
649
+ def _row_key(call: ToolCall) -> tuple:
650
+ """What a tool row says of `call`, so back-to-back lookalikes can share it.
651
+
652
+ Its state is part of it: a failure, or a call still running, never hides
653
+ behind a lookalike's mark.
654
+ """
655
+ return (*call_parts(call), call.change, call.failed, call.settled is None)
656
+
657
+
658
+ def head_rows(text: str, width: int, rows: int) -> list[str]:
659
+ """`text` wrapped to `width`, keeping its first `rows`; a cut gets a trailing ellipsis."""
660
+ text = plain(text, limit=None)
661
+ if width < 1 or rows < 1 or not text:
662
+ return []
663
+ console = Console(width=width)
664
+ wrapped = [
665
+ row.plain.rstrip() # Rich keeps the space a row broke at.
666
+ for row in Text(text).wrap(console, width, overflow="fold", no_wrap=False)
667
+ ]
668
+ if len(wrapped) <= rows:
669
+ return wrapped
670
+ kept = wrapped[:rows]
671
+ # Mark the cut on the last row kept; a full row gives up a cell for it.
672
+ last = Text(kept[-1] + "…")
673
+ last.truncate(width, overflow="ellipsis")
674
+ kept[-1] = last.plain
675
+ return kept
676
+
677
+
678
+ # A summary section's title: `**Tracing the resize path**` or `## Tracing...`.
679
+ # One bold run only: `**A** and **B**` is prose with emphasis, not a title.
680
+ THOUGHT_HEADING = re.compile(r"\*\*(?P<bold>[^*]+)\*\*|#{1,6}\s+(?P<hash>.+)")
681
+
682
+
683
+ def latest_thought(text: str) -> str:
684
+ """What the thinking row says for one block of streamed thinking.
685
+
686
+ Detailed summaries (OpenAI's, and many of Anthropic's) come in titled
687
+ sections; the newest title reads at a glance where the prose under it
688
+ would scroll past. Untitled text (Anthropic's summaries, progress
689
+ updates) shows its newest line.
690
+ """
691
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
692
+ title, fenced = "", False
693
+ for line in lines:
694
+ if line.startswith("```"):
695
+ fenced = not fenced # A `# comment` in a code block is not a title.
696
+ elif not fenced and (heading := THOUGHT_HEADING.fullmatch(line)):
697
+ title = (heading["bold"] or heading["hash"]).strip()
698
+ if title:
699
+ return title
700
+ # The buffer keeps the block's tail only (THINKING_KEEP), so a long
701
+ # section can outlive its title; its newest line stands in then.
702
+ return lines[-1].replace("**", "") if lines else ""
703
+
704
+
705
+ # How the last turn ended, at the head of the idle editor box's title.
706
+ TURN_MARKS = {"done": "✓", "cancelled": "⊘", "failed": "✗"}
707
+
708
+
709
+ def fit_fragments(fragments: list[tuple[str, str]], width: int) -> list[tuple[str, str]]:
710
+ """Cut styled fragments to `width` cells, marking a cut with an ellipsis.
711
+
712
+ Newlines and control characters are flattened first, so a pasted command
713
+ cannot break the row. The ellipsis goes on the last fragment kept; a
714
+ spinner or badge that cannot fit whole is cropped without one.
715
+ """
716
+ fragments = [(style, plain(text, limit=None)) for style, text in fragments]
717
+ if sum(cell_len(text) for _, text in fragments) <= width:
718
+ return fragments
719
+ fitted = []
720
+ remaining = max(0, width)
721
+ for style, text in fragments:
722
+ # Strictly less: the fragment that overflows needs a cell for the ellipsis.
723
+ if cell_len(text) < remaining:
724
+ fitted.append((style, text))
725
+ remaining -= cell_len(text)
726
+ continue
727
+ part = Text(text)
728
+ part.truncate(remaining, overflow="ellipsis" if remaining > 1 else "crop")
729
+ if part.plain:
730
+ fitted.append((style, part.plain))
731
+ break
732
+ return fitted
733
+
734
+
735
+ @dataclass(eq=False)
736
+ class Wait:
737
+ """Something this terminal is waiting on, and since when."""
738
+
739
+ label: str
740
+ started: float = field(default_factory=monotonic)
741
+
742
+
743
+ def _invalidate() -> None:
744
+ """Repaint the running editor, if there is one; state can change outside it."""
745
+ if (app := get_app_or_none()) is not None:
746
+ app.invalidate()
747
+
748
+
749
+ def chrome_rows(text: str, width: int, style: str) -> list[tuple[str, str]]:
750
+ """Wrap transient chrome text to the pane, bounded so it cannot take the screen."""
751
+ if width < 1:
752
+ return []
753
+ console = Console(width=width)
754
+ rows = [
755
+ (style, row.plain)
756
+ for line in text.splitlines()
757
+ for row in Text(plain(line, limit=None)).wrap(
758
+ console, width, overflow="fold", no_wrap=False
759
+ )
760
+ ]
761
+ return rows[:NOTICE_ROWS]
762
+
763
+
764
+ @dataclass
765
+ class Activity:
766
+ show_tasks: bool = True
767
+ # Hide the widget again as soon as a turn ends, without forgetting that the
768
+ # user wants it shown while the model works.
769
+ autohide_tasks: bool = False
770
+ # Draw the widget as the top section of the editor box instead of its own box.
771
+ attach_tasks: bool = True
772
+ # The `task_style` setting: `status` shades task text by status, `icons`
773
+ # gives it one weight, colours only the icon, and pads rows off the frame.
774
+ task_style: str = "status"
775
+ # The `tool_glyphs` setting: `auto`, `on` or `off` (see `tool_glyphs_on`).
776
+ tool_glyphs: str = "auto"
777
+ # The `tool_max_lines` setting: the most tool rows above the status row.
778
+ tool_max_rows: int = TOOL_ROWS
779
+ # The `tool_linger_seconds` setting: how long a finished call keeps its
780
+ # tool row; 0 keeps it until newer calls push it out.
781
+ tool_linger_seconds: int = TOOL_LINGER_SECONDS
782
+ # Cap on the task widget plus the editor box: whole rows, or a share of the
783
+ # screen below 1 (0.5 is half). None keeps the default layout.
784
+ tasks_max_height: float | None = None
785
+ tasks_autohidden: bool = False
786
+ # Hide the widget in a pane shorter or narrower than these (0 never hides),
787
+ # so one preference suits a full screen and a split alike. Each terminal
788
+ # measures its own pane: see `fit_screen`.
789
+ tasks_min_rows: int = 0
790
+ tasks_min_columns: int = 0
791
+ screen_small: bool = False
792
+ # Ctrl+O showed the widget in a small pane; dropped when the pane crosses
793
+ # the threshold, so the next split or unsplit decides afresh.
794
+ tasks_unhidden: bool = False
795
+ # Inline shortcut hints, such as the key that hides the task list.
796
+ show_hints: bool = True
797
+ # Where the model's thinking shows: `off`, `status-line` (its own rows
798
+ # above the status row), or `scrollback`. See THINKING_MODES.
799
+ thinking_mode: str = "status-line"
800
+ busy: bool = False
801
+ status: str = ""
802
+ queued: int = 0
803
+ queued_prompts: list[str] = field(default_factory=list)
804
+ queued_modes: list[str] = field(default_factory=list)
805
+ prompt: str = ""
806
+ prompt_state: str = ""
807
+ # How the conversation's last model turn ended (`done`, `cancelled`,
808
+ # `failed`), for the editor box's title. Unlike `prompt_state`, a `!command`,
809
+ # `/compact` or slow slash command never touches it, and a switch of
810
+ # conversation clears it.
811
+ turn_outcome: str = ""
812
+ # True while a `!command` typed at the prompt is running.
813
+ user_command: bool = False
814
+ # "user" echoes what was typed; "system" marks work pcode runs on its own
815
+ # behalf (compaction, for example) so it never reads as part of the prompt.
816
+ prompt_kind: str = "user"
817
+ prompt_detail: str = ""
818
+ plan: list[dict] = field(default_factory=list)
819
+ plan_preview: list[dict] | None = None
820
+ tools: ToolHistory = field(default_factory=ToolHistory)
821
+ workers: Workers = field(default_factory=Workers)
822
+ command_outputs: dict[str, CommandOutput] = field(default_factory=dict)
823
+ edit_previews: dict = field(default_factory=dict)
824
+ notice: str = ""
825
+ notice_expires: float = 0.0
826
+ # The footer's note of this turn's latest prompt-cache drop, e.g.
827
+ # `cache miss 0/166k`; the full notice is only in the session journal.
828
+ cache_note: str = ""
829
+ # All running shell jobs, including those a tool is waiting on. The footer
830
+ # shows the count; `/jobs` holds the details. Updated by the job watcher.
831
+ job_count: int = 0
832
+ # A job whose output tail is pinned into the command preview by `/jobs watch`.
833
+ watched_job: str = ""
834
+ # The session's side-question records (`Asides.items`, shared, not copied):
835
+ # the running ones get a spinner row below the prompt's.
836
+ asides: list = field(default_factory=list)
837
+ # What this terminal is waiting on (the session host starting, a command
838
+ # it has not finished): its own state, never synced from the host.
839
+ waits: list[Wait] = field(default_factory=list)
840
+ # The tail of the newest thinking block, for the thinking row. Derived
841
+ # from the events this terminal renders, never synced from the host.
842
+ thought: str = ""
843
+ # No thinking block is open: the next delta (or whole block) starts one.
844
+ thought_done: bool = True
845
+ # The status row's phase clock: (phase, since, last drawn). Drawing state,
846
+ # so it is never compared, copied into a repr, or synced from the host.
847
+ _phase: tuple[str, float, float] = field(
848
+ default=("", 0.0, 0.0), init=False, repr=False, compare=False
849
+ )
850
+ # The status line on screen while a hold keeps it. Drawing state like `_phase`.
851
+ _held: _HeldStatus | None = field(default=None, init=False, repr=False, compare=False)
852
+ # The turn's latest call on the tool row, kept there once it finishes.
853
+ _last_call: ToolCall | None = field(default=None, init=False, repr=False, compare=False)
854
+ # The turn's calls in the order the tool rows first saw them, so finished
855
+ # ones keep their rows while the next calls run. Drawing state like `_held`.
856
+ _seen_calls: list[ToolCall] = field(default_factory=list, init=False, repr=False, compare=False)
857
+
858
+ @property
859
+ def show_thinking(self) -> bool:
860
+ """Whether scrollback carries the model's thinking."""
861
+ return self.thinking_mode == "scrollback"
862
+
863
+ def think(self, text: str) -> None:
864
+ """Add streamed thinking; a new block replaces the last one."""
865
+ if not text:
866
+ return
867
+ if self.thought_done:
868
+ self.thought, self.thought_done = "", False
869
+ self.thought = (self.thought + text)[-THINKING_KEEP:]
870
+
871
+ def forget_thought(self) -> None:
872
+ """Drop the thinking rows: the answer they led to is streaming now."""
873
+ self.thought, self.thought_done = "", True
874
+
875
+ @property
876
+ def thought_shown(self) -> bool:
877
+ """Whether the thinking rows have a thought to show above the status row."""
878
+ return (
879
+ self.thinking_mode == "status-line"
880
+ and self.status_shown
881
+ and bool(latest_thought(self.thought).strip())
882
+ )
883
+
884
+ def thought_fragments(
885
+ self, width: int, rows: int = THOUGHT_ROWS
886
+ ) -> list[list[tuple[str, str]]]:
887
+ """The thinking rows above the status row, in `status-line` mode.
888
+
889
+ Held through the tool calls that follow a thought, which it usually
890
+ explains, and gone once the answer streams into scrollback: thinking
891
+ is the lead-up, not a caption on the result. Above the editor box
892
+ whose border carries the status, so the status holds its place as
893
+ the thought wraps to more or fewer rows.
894
+ """
895
+ if not self.thought_shown or width < 3:
896
+ return []
897
+ thought = latest_thought(self.thought)
898
+ # Wrap the marker with the text so continuation rows use the full
899
+ # width, without a hanging indent. Only the first row has an upright
900
+ # marker; the thought itself stays italic.
901
+ wrapped = head_rows(f"{THOUGHT_ICON} {thought}", width, rows)
902
+ return [
903
+ [
904
+ ("class:activity.thinking.icon", f"{THOUGHT_ICON} " if index == 0 else ""),
905
+ (
906
+ "class:activity.thinking",
907
+ row.removeprefix(THOUGHT_ICON).lstrip() if index == 0 else row,
908
+ ),
909
+ ]
910
+ for index, row in enumerate(wrapped)
911
+ ]
912
+
913
+ def begin_wait(self, label: str) -> Wait:
914
+ """Start a wait that shows a spinner row once it outlasts the grace period."""
915
+ wait = Wait(label)
916
+ self.waits.append(wait)
917
+ _invalidate() # Starts the animation timer that draws the row later.
918
+ return wait
919
+
920
+ def end_wait(self, wait: Wait) -> None:
921
+ if wait in self.waits:
922
+ self.waits.remove(wait)
923
+ _invalidate()
924
+
925
+ @contextmanager
926
+ def waiting(self, label: str):
927
+ wait = self.begin_wait(label)
928
+ try:
929
+ yield wait
930
+ finally:
931
+ self.end_wait(wait)
932
+
933
+ def wait_fragments(self, spinner: str, width: int):
934
+ """The newest wait past its grace period, as a system row; empty otherwise.
935
+
936
+ A running status row already says what the host is doing (a turn, or a
937
+ command's `◈ label ▸ detail` job), so the terminal's own wait on that
938
+ same work stays out of the way: one spinner at a time.
939
+ """
940
+ if self.status_shown:
941
+ return []
942
+ now = monotonic()
943
+ shown = [wait for wait in self.waits if now - wait.started >= WAIT_GRACE_SECONDS]
944
+ if not shown or width < 1:
945
+ return []
946
+ wait = shown[-1]
947
+ text = Text(f"{spinner} {SYSTEM_BADGE} {plain(wait.label, limit=None)}")
948
+ text.append(f" \u00b7 {now - shown[0].started:.0f}s")
949
+ text.truncate(width, overflow="ellipsis")
950
+ return [("class:activity.system", text.plain)]
951
+
952
+ @property
953
+ def asides_running(self) -> bool:
954
+ return any(aside.running for aside in self.asides)
955
+
956
+ def aside_rows(self, spinner: str, width: int, budget: int = ASIDE_ROWS):
957
+ """One muted spinner row per running side question, folded to the budget."""
958
+ running = [aside for aside in self.asides if aside.running]
959
+ if budget <= 0 or not running or width < 1:
960
+ return []
961
+ shown = running if len(running) <= budget else running[: max(0, budget - 1)]
962
+ rows = []
963
+ for aside in shown:
964
+ parts = [f"{spinner} btw", plain(aside.question, limit=None)]
965
+ if aside.label:
966
+ parts.insert(1, aside.label)
967
+ if aside.activity:
968
+ parts.append(plain(aside.activity, limit=None))
969
+ parts.append(f"{aside.elapsed:.0f}s")
970
+ text = Text(" \u00b7 ".join(parts))
971
+ text.truncate(width, overflow="ellipsis")
972
+ rows.append(("class:activity.aside", text.plain))
973
+ if len(shown) < len(running):
974
+ rows.append(("class:activity.aside", f"\u2026 {len(running) - len(shown)} more (/btw)"))
975
+ return rows
976
+
977
+ def flash(self, text: str, seconds: float = NOTICE_SECONDS) -> None:
978
+ """Replace the transient notice shown above the spinner.
979
+
980
+ One slot, not a queue: the newest answer is the one being waited for,
981
+ and stacking acknowledgements would push the editor down the screen.
982
+ """
983
+ self.notice = text
984
+ self.notice_expires = monotonic() + seconds
985
+
986
+ @property
987
+ def notice_shown(self) -> bool:
988
+ return bool(self.notice) and monotonic() < self.notice_expires
989
+
990
+ def notice_rows(self, width: int) -> list[tuple[str, str]]:
991
+ """Wrap the notice to the pane, bounded so chrome cannot take the screen."""
992
+ if not self.notice_shown:
993
+ return []
994
+ return chrome_rows(self.notice, width, "class:activity.notice")
995
+
996
+ def panel_heading(self) -> str:
997
+ return self.panel_title()
998
+
999
+ def reset(self) -> None:
1000
+ """Clear the panel for a new conversation, keeping the draft and queue."""
1001
+ self.command_outputs.clear()
1002
+ self.edit_previews.clear()
1003
+ self.plan = []
1004
+ self.plan_preview = None
1005
+ self.tools.clear()
1006
+ self.workers = Workers()
1007
+ self.prompt = ""
1008
+ self.prompt_state = ""
1009
+ self.turn_outcome = ""
1010
+ self.prompt_kind = "user"
1011
+ self.prompt_detail = ""
1012
+ self.status = ""
1013
+ self._held = None
1014
+ self.forget_calls()
1015
+ self.thought, self.thought_done = "", True
1016
+ self.tasks_autohidden = False
1017
+
1018
+ def forget_calls(self) -> None:
1019
+ """Empty the tool rows: a new turn never opens on the last one's calls.
1020
+
1021
+ Its own step, since a terminal attached to a host learns of a new
1022
+ turn from `turn_started` alone; `start_prompt` runs only on the host.
1023
+ """
1024
+ self._last_call = None
1025
+ self._seen_calls = []
1026
+
1027
+ def height_cap(self, rows: int) -> int | None:
1028
+ """The task widget plus editor box's row limit on a screen this tall."""
1029
+ cap = self.tasks_max_height
1030
+ return None if cap is None else screen_rows(cap, rows)
1031
+
1032
+ def fit_screen(self, columns: int, rows: int) -> None:
1033
+ """Note whether this pane is below the widget's size thresholds."""
1034
+ small = rows < self.tasks_min_rows or columns < self.tasks_min_columns
1035
+ if small != self.screen_small:
1036
+ self.screen_small = small
1037
+ self.tasks_unhidden = False
1038
+
1039
+ @property
1040
+ def tasks_shown(self) -> bool:
1041
+ """Visible when enabled, not auto-hidden after the last turn, and the pane fits."""
1042
+ return self.show_tasks and not self.tasks_autohidden and not self.tasks_too_big
1043
+
1044
+ @property
1045
+ def tasks_too_big(self) -> bool:
1046
+ """The pane is below the size thresholds and Ctrl+O has not overridden them."""
1047
+ return self.screen_small and not self.tasks_unhidden
1048
+
1049
+ def toggle_tasks(self) -> bool:
1050
+ """Ctrl+O acts on what is on screen, so auto-hidden reads as hidden.
1051
+
1052
+ In a small pane it only overrides the size rule for that pane, leaving
1053
+ `show_tasks` on so a larger pane still shows the widget.
1054
+ """
1055
+ shown = self.tasks_shown
1056
+ self.tasks_autohidden = False
1057
+ if self.screen_small and self.show_tasks:
1058
+ self.tasks_unhidden = not shown
1059
+ return self.show_tasks
1060
+ self.show_tasks = not shown
1061
+ self.tasks_unhidden = self.show_tasks and self.screen_small
1062
+ return self.show_tasks
1063
+
1064
+ def finish_prompt(self, state: str) -> None:
1065
+ """End the turn, auto-hiding the widget when that option is enabled."""
1066
+ self.prompt_state = state
1067
+ if self.autohide_tasks:
1068
+ self.tasks_autohidden = True
1069
+
1070
+ def start_prompt(self, text: str, *, kind: str = "user", detail: str = "") -> None:
1071
+ """Show a running row, tagged so system work never looks like typed input."""
1072
+ self.tasks_autohidden = False
1073
+ self.thought, self.thought_done = "", True
1074
+ self._held = None # A new turn never opens on the last one's line.
1075
+ self.forget_calls()
1076
+ self.prompt = text
1077
+ self.prompt_kind = kind
1078
+ self.prompt_detail = detail
1079
+ self.prompt_state = "running"
1080
+
1081
+ @property
1082
+ def displayed_plan(self) -> list[dict]:
1083
+ return self.plan if self.plan_preview is None else self.plan_preview
1084
+
1085
+ def plan_rows(self, budget: int):
1086
+ if not self.tasks_shown:
1087
+ return []
1088
+ # Persisted task status describes unfinished work, not a live request.
1089
+ # Use the turn lifecycle rather than busy, which also includes queued input.
1090
+ icon = ACTIVE_TASK_ICON if self.status_shown else "○"
1091
+ # A configured height is room the user asked the tasks to fill.
1092
+ max_tasks = TASK_ROWS if self.tasks_max_height is None else budget
1093
+ return task_panel_rows(self.displayed_plan, self.tools, budget, icon, max_tasks)
1094
+
1095
+ def hidden_step(self) -> str:
1096
+ """The active plan step, when the task panel is not showing it.
1097
+
1098
+ Its present-continuous form (`Adding the migration`), else its text:
1099
+ `active_form` is optional, and the status row's detail is a gerund.
1100
+ Judged by the widget's visibility alone: the row is sized from
1101
+ activity state, so a preview that squeezes the plan rows out of a
1102
+ short pane does not count as hiding it.
1103
+ """
1104
+ if self.tasks_shown:
1105
+ return ""
1106
+ step = next((i for i in self.displayed_plan if i.get("status") == "in_progress"), None)
1107
+ if step is None:
1108
+ return ""
1109
+ return step.get("active_form") or step.get("content") or ""
1110
+
1111
+ @property
1112
+ def plan_done(self) -> bool:
1113
+ items = self.displayed_plan
1114
+ return bool(items) and all(item.get("status") == "completed" for item in items)
1115
+
1116
+ def panel_title(self) -> str:
1117
+ items = self.displayed_plan
1118
+ if not items:
1119
+ return "Tools"
1120
+ completed = sum(item.get("status") == "completed" for item in items)
1121
+ return f"Tasks {completed}/{len(items)}"
1122
+
1123
+ @property
1124
+ def status_shown(self) -> bool:
1125
+ """The live row exists only while a turn runs; the prompt is in scrollback."""
1126
+ return self.prompt_state == "running"
1127
+
1128
+ def _phase_seconds(self, phase: str) -> float:
1129
+ """Seconds the row has shown this phase: a stall reads as `Thinking · 40s`.
1130
+
1131
+ Kept by whoever draws the row rather than synced from the host, and
1132
+ restarted after a gap in drawing, which only happens between turns.
1133
+ """
1134
+ now = monotonic()
1135
+ shown, since, seen = self._phase
1136
+ if shown != phase or now - seen > PHASE_GAP_SECONDS:
1137
+ since = now
1138
+ self._phase = (phase, since, now)
1139
+ return now - since
1140
+
1141
+ def status_line(self, tally: str = "") -> StatusLine:
1142
+ """What the status row says, before it is fitted to a width."""
1143
+ if self.prompt_kind != "user":
1144
+ phase = plain(self.prompt, limit=None)
1145
+ return StatusLine(
1146
+ phase,
1147
+ plain(self.prompt_detail, limit=None),
1148
+ badge=SYSTEM_BADGE,
1149
+ separator=SYSTEM_SEPARATOR,
1150
+ elapsed=self._phase_seconds("\0system" + phase),
1151
+ )
1152
+ phase, detail = status_parts(self.status)
1153
+ call = self.tools.active
1154
+ running = self.tools.running
1155
+ if call is not None and call.settled is None:
1156
+ # A tool's label is already a verb (`Run shell`, `Read file`), so
1157
+ # one call is its own phase; parallel calls are counted instead.
1158
+ # The clock is the call's own, whatever the model said last.
1159
+ self._phase_seconds("")
1160
+ phase = call_parts(call)[0] if running < 2 else f"Running {running} tools"
1161
+ return StatusLine(
1162
+ phase, self.hidden_step(), tally=tally, elapsed=call.elapsed, call=call
1163
+ )
1164
+ clock, hue = phase, None
1165
+ if agents := self.tools.delegates:
1166
+ # The panel lists the agents themselves, so this row just says
1167
+ # the turn is waiting on them, unless the panel is hidden. One
1168
+ # clock for the whole wait, however the count changes.
1169
+ count = len(agents)
1170
+ phase = f"Waiting for {count} agent{'s' * (count > 1)}"
1171
+ if not self.tasks_shown:
1172
+ detail, hue = agents[-1].line(timed=False), agents[-1].hue
1173
+ else:
1174
+ detail = ""
1175
+ clock = "\0sub-agents"
1176
+ elif phase.startswith("Running") and not running and not self.user_command:
1177
+ # Written for a call that has since finished; the model has the turn.
1178
+ phase = clock = "Waiting for model"
1179
+ detail = ""
1180
+ # With the panel hidden, the active step says what the turn is for.
1181
+ detail = detail or self.hidden_step()
1182
+ line = StatusLine(phase, detail, tally=tally, elapsed=self._phase_seconds(clock), hue=hue)
1183
+ if call is not None:
1184
+ # Just finished: held briefly and marked done, so a burst of fast
1185
+ # calls reads as progress rather than strobing.
1186
+ line.call, line.settled = call, True
1187
+ return line
1188
+
1189
+ def tool_rows(
1190
+ self, line: StatusLine, width: int, limit: int | None = None, spinner: str = "·"
1191
+ ) -> list[list[tuple[str, str]]]:
1192
+ """The tool rows between the thought and the status row: `◜ $ ls -la`.
1193
+
1194
+ `spinner` marks a running call: the tool spinner's current frame.
1195
+
1196
+ The turn's latest calls, oldest first, at most `tool_max_rows` (and
1197
+ `limit`, the pane's share). Parallel calls each get a row, and a
1198
+ finished call keeps its row, marked `✓` or `✗`, until newer calls
1199
+ push it out or `tool_linger_seconds` passes, so the editor box does
1200
+ not jump with every call yet a long wait on the model does not show
1201
+ stale ones. The call the status row's verb acts on always keeps its
1202
+ row, so at one row this is that call alone. Back-to-back calls that
1203
+ would draw the same row (a retried search, a run of edits with the
1204
+ same counts) share one, counted `×2`.
1205
+ """
1206
+ # Work pcode runs itself (`prompt_kind` system) has no tool rows.
1207
+ if not self.status_shown or self.prompt_kind != "user" or width < 1:
1208
+ return []
1209
+ current = line.call or self._last_call
1210
+ seen = self._seen_calls
1211
+ for call in (*self.tools.calls, current):
1212
+ # Delegates have panel rows, and a sub-agent's calls are its own:
1213
+ # one shows only while the status row reports it.
1214
+ if (
1215
+ call is not None
1216
+ and call.event.name != DELEGATE
1217
+ and not call.event.parent_call_id
1218
+ and all(call is not other for other in seen)
1219
+ ):
1220
+ seen.append(call)
1221
+ groups: list[list[ToolCall]] = []
1222
+ for call in seen:
1223
+ if groups and _row_key(groups[-1][-1]) == _row_key(call):
1224
+ groups[-1].append(call)
1225
+ else:
1226
+ groups.append([call])
1227
+ if linger := self.tool_linger_seconds:
1228
+ cutoff = monotonic() - linger
1229
+ groups = [
1230
+ group
1231
+ for group in groups
1232
+ if any(
1233
+ call is line.call or call.settled is None or call.settled > cutoff
1234
+ for call in group
1235
+ )
1236
+ ]
1237
+ # `_last_call` lingers like any other; the status row's own call stays.
1238
+ if current is not line.call and current is not None and current.settled is not None:
1239
+ current = current if current.settled > cutoff else None
1240
+
1241
+ def has_current(group: list[ToolCall]) -> bool:
1242
+ return any(call is current for call in group)
1243
+
1244
+ count = max(1, self.tool_max_rows if limit is None else min(self.tool_max_rows, limit))
1245
+ room = count - (current is not None)
1246
+ others = [group for group in groups if not has_current(group)]
1247
+ kept = others[-room:] if room else []
1248
+ shown = [g for g in groups if has_current(g) or any(g is k for k in kept)]
1249
+ if current is not None and not any(has_current(group) for group in shown):
1250
+ shown.append([current]) # A sub-agent's call, newest of all.
1251
+ return [
1252
+ self._tool_row(
1253
+ current if has_current(group) else group[-1], line, width, spinner, len(group)
1254
+ )
1255
+ for group in shown
1256
+ ]
1257
+
1258
+ def _tool_row(
1259
+ self, call: ToolCall, line: StatusLine, width: int, spinner: str, repeats: int = 1
1260
+ ) -> list[tuple[str, str]]:
1261
+ """One call's row: its state mark, then its verb's glyph (`◜ $ ls -la`).
1262
+
1263
+ Every row has the same columns, flush with the thought's `∴` or with
1264
+ none, so parallel calls line up and nothing shifts as a thought comes
1265
+ and goes: the mark (the tool spinner's frame in the muted shade while
1266
+ it runs, `✓` or `✗` once settled), then the glyph (`TOOL_GLYPHS`). A
1267
+ verb with no glyph is spelled out whenever the status row does not
1268
+ say it for this call.
1269
+ """
1270
+ label, detail = call_parts(call)
1271
+ glyph = (TOOL_GLYPHS if tool_glyphs_on(self.tool_glyphs) else PLAIN_TOOL_GLYPHS).get(label)
1272
+ if glyph:
1273
+ text = detail or label
1274
+ elif detail and call is line.call and line.phase == label:
1275
+ text = detail
1276
+ else:
1277
+ text = " · ".join(filter(None, (label, detail)))
1278
+ # What tells lookalike rows apart, kept whole when a long path is cut.
1279
+ tail = "".join(
1280
+ filter(None, (call.change and f" · {call.change}", repeats > 1 and f" ×{repeats}"))
1281
+ )
1282
+ if call.settled is None:
1283
+ mark = ("class:activity.tool", spinner)
1284
+ elif call.failed:
1285
+ mark = ("class:activity.tool.failed", "✗")
1286
+ else:
1287
+ mark = ("class:activity.tool.done", "✓")
1288
+ # A wide spinner (`dots12`) would push running rows' text past
1289
+ # settled ones'; pad the settled mark's column to its width.
1290
+ pad = "" if call.settled is None else " " * max(0, cell_len(spinner) - 1)
1291
+ body = ("class:activity.tool", pad + " " + " ".join(filter(None, (glyph, text))))
1292
+ if not tail:
1293
+ return fit_fragments([mark, body], width)
1294
+ if cell_len(tail) >= width // 2:
1295
+ return fit_fragments([mark, body, ("class:activity.tool", tail)], width)
1296
+ return [*fit_fragments([mark, body], width - cell_len(tail)), ("class:activity.tool", tail)]
1297
+
1298
+ def held_status_line(self, tally: str = "", hold: float = 0.0) -> StatusLine:
1299
+ """The status line, keeping what the row said for at least `hold` seconds.
1300
+
1301
+ Only a change of text counts: the clock and tally keep moving on a
1302
+ held line. The hold gives way at once to a new turn, a gap in drawing,
1303
+ a retry or failure, and a held tool call that has finished, so it only
1304
+ ever skips churn, never news.
1305
+ """
1306
+ line = self._held_line(tally, hold)
1307
+ # The tool row keeps the last call this row showed, so the two agree.
1308
+ if line.call is not None:
1309
+ self._last_call = line.call
1310
+ return line
1311
+
1312
+ def _held_line(self, tally: str, hold: float) -> StatusLine:
1313
+ line = self.status_line(tally)
1314
+ if hold <= 0:
1315
+ return line
1316
+ now = monotonic()
1317
+ turn = (self.prompt_kind, self.prompt)
1318
+ active = self.tools.active
1319
+ call = active if active is not None and active.settled is None else None
1320
+ shown = self._held
1321
+ same = shown is not None and _status_text(line) == _status_text(shown.line)
1322
+ fresh = (
1323
+ shown is None
1324
+ or shown.turn != turn
1325
+ or now - shown.seen > PHASE_GAP_SECONDS
1326
+ or (shown.call is not None and shown.call.settled is not None)
1327
+ )
1328
+ if fresh or same or now - shown.since >= hold or _urgent(line):
1329
+ since = shown.since if same and not fresh else now
1330
+ self._held = _HeldStatus(line, since, now, now, turn, call)
1331
+ return line
1332
+ shown.seen = now
1333
+ held = shown.line
1334
+ elapsed = None if held.elapsed is None else held.elapsed + now - shown.taken
1335
+ return replace(held, tally=line.tally, elapsed=elapsed)
1336
+
1337
+ def status_fragments(
1338
+ self, spinner: str, width: int, tally: str = "", hold: float = 0.0, *, rule: bool = False
1339
+ ):
1340
+ """The editor's top border: `⠋ Phase · detail ── ✓ 7 tools · 12s`."""
1341
+ return self.held_status_line(tally, hold).fragments(spinner, width, rule=rule)
1342
+
1343
+ def queue_rows(self, budget: int):
1344
+ """Show the next queued prompts, leaving room for the editor on short panes."""
1345
+ if budget <= 0:
1346
+ return []
1347
+ visible = budget if len(self.queued_prompts) <= budget else budget - 1
1348
+ rows = []
1349
+ for index, text in enumerate(self.queued_prompts[:visible]):
1350
+ mode = self.queued_modes[index] if index < len(self.queued_modes) else "queue"
1351
+ prefix = {
1352
+ "steering": "Steering",
1353
+ "interrupt": "Interrupting",
1354
+ }.get(mode, "Queued")
1355
+ system = system_command(text)
1356
+ if system is None:
1357
+ rows.append(("class:plan", f"{prefix}: {text}"))
1358
+ continue
1359
+ # Pending system work reads as a labelled action, matching the row
1360
+ # it becomes once it starts, rather than as an echoed command.
1361
+ label, detail = system
1362
+ body = f"{SYSTEM_BADGE} {label}"
1363
+ if detail:
1364
+ body += f" {SYSTEM_SEPARATOR} {detail}"
1365
+ rows.append(("class:activity.system.detail", f"{prefix} {body}"))
1366
+ remaining = len(self.queued_prompts) - visible
1367
+ if remaining > 0:
1368
+ state = "pending" if "steering" in self.queued_modes else "queued"
1369
+ rows.append(("class:plan", f"… {remaining} more {state}"))
1370
+ return rows
1371
+
1372
+
1373
+ def command_heading(activity: Activity, event: CommandOutput) -> str:
1374
+ """Name a running command the way scrollback will name it once it settles.
1375
+
1376
+ Same marker, title and elapsed layout as `CommandTranscript`; only the
1377
+ marker differs, because nothing has finished yet. A watched job and a
1378
+ `!command` typed at the prompt have no live tool call to name them.
1379
+ """
1380
+ call = next((c for c in activity.tools.calls if c.event.call_id == event.call_id), None)
1381
+ if call is not None:
1382
+ purpose = plain(call.event.purpose, limit=60) if call.event.purpose else ""
1383
+ name = label(call.event.name)
1384
+ return block_heading(
1385
+ RUNNING, f"{name} · {purpose}" if purpose else name, monotonic() - call.started
1386
+ )
1387
+ if event.call_id.startswith(WATCHED_PREFIX):
1388
+ return block_heading(RUNNING, f"Job {event.call_id[len(WATCHED_PREFIX) :]}")
1389
+ return block_heading(RUNNING, "Shell")
1390
+
1391
+
1392
+ @Output.register
1393
+ class CursorSafeOutput:
1394
+ """Keep renderer erases out of history and hide the cursor during handoffs."""
1395
+
1396
+ def __init__(self, output: Output):
1397
+ self.output = output
1398
+ self._hidden = 0
1399
+ self._visible = True
1400
+
1401
+ def __getattr__(self, name):
1402
+ return getattr(self.output, name)
1403
+
1404
+ def erase_down(self) -> None:
1405
+ # Avoid ED at column zero: terminals such as tmux preserve a full-screen
1406
+ # erase in scrollback, including the supposedly transient editor frame.
1407
+ # prompt_toolkit calls this at column zero. Split the erase into ED from
1408
+ # column one plus EL from column zero, keeping the final cursor unchanged.
1409
+ if self.output.get_size().columns < 2:
1410
+ self.output.erase_down()
1411
+ return
1412
+ self.output.cursor_forward(1)
1413
+ self.output.erase_down()
1414
+ self.output.cursor_backward(1)
1415
+ self.output.erase_end_of_line()
1416
+
1417
+ def show_cursor(self) -> None:
1418
+ self._visible = True
1419
+ if not self._hidden:
1420
+ self.output.show_cursor()
1421
+
1422
+ def hide_cursor(self) -> None:
1423
+ self._visible = False
1424
+ self.output.hide_cursor()
1425
+
1426
+ @contextmanager
1427
+ def hidden_cursor(self):
1428
+ self._hidden += 1
1429
+ self.output.hide_cursor()
1430
+ self.output.flush()
1431
+ try:
1432
+ yield
1433
+ finally:
1434
+ self._hidden -= 1
1435
+ if not self._hidden:
1436
+ if self._visible:
1437
+ self.output.show_cursor()
1438
+ self.output.flush()
1439
+
1440
+
1441
+ # DEC private mode 2026: the terminal holds the frame between these, so the
1442
+ # editor's erase, the transcript write, and the repaint appear as one change.
1443
+ # Terminals without it ignore both sequences.
1444
+ SYNC_START = "\x1b[?2026h"
1445
+ SYNC_END = "\x1b[?2026l"
1446
+
1447
+
1448
+ class Handoff:
1449
+ """What the body of an atomic ``suspended_editor`` reports for the repaint.
1450
+
1451
+ ``top_row`` is the 1-based terminal row the erase left the cursor on; the
1452
+ body overrides it when it homes the cursor itself. ``rows_written`` is how
1453
+ many rows the body's output advanced the cursor; leaving it ``None`` falls
1454
+ back to asking the terminal where the cursor ended up.
1455
+ """
1456
+
1457
+ def __init__(self, top_row: int | None):
1458
+ self.top_row = top_row
1459
+ self.rows_written: int | None = None
1460
+
1461
+
1462
+ def layout_top_row(app: Application) -> int | None:
1463
+ """The terminal row the editor starts on, if the renderer still knows it."""
1464
+ renderer = app.renderer
1465
+ if (
1466
+ renderer._in_alternate_screen
1467
+ or renderer._min_available_height <= 0
1468
+ or renderer._last_size != app.output.get_size()
1469
+ ):
1470
+ return None
1471
+ return renderer.rows_above_layout + 1
1472
+
1473
+
1474
+ @asynccontextmanager
1475
+ async def suspended_editor(app: Application, *, atomic: bool = False):
1476
+ """Hand the terminal to direct output, then repaint the editor exactly once.
1477
+
1478
+ With ``atomic`` the handoff is wrapped in synchronized output and, when the
1479
+ body reports how many rows it wrote (``Handoff.rows_written``), the editor
1480
+ is repainted immediately from the computed cursor row instead of after a
1481
+ cursor position report. The whole erase, write, and repaint then reach the
1482
+ terminal as one frame with no await in between, so the editor and live
1483
+ panel never visibly disappear. A report is still requested and lands
1484
+ before the next handoff; the renderer's cursor bookkeeping is relative, so
1485
+ a miscount (Rich and the terminal disagreeing on a glyph's width) only
1486
+ misjudges the free rows below the editor until that reply corrects it,
1487
+ exactly as prompt_toolkit tolerates a layout taller than its report.
1488
+ Popups and external programs must not use ``atomic``: a frame held across
1489
+ them would freeze the terminal until its guard timeout.
1490
+
1491
+ An atomic handoff also keeps the terminal in raw mode. Only an external
1492
+ program needs cooked mode, and in cooked mode the tty echoes anything typed
1493
+ during the handoff and turns a Return into a newline, which the editor then
1494
+ reads as Ctrl+J. Paced scrollback makes handoffs frequent while the user
1495
+ may well be typing, so the window has to be harmless.
1496
+
1497
+ This mirrors prompt_toolkit's ``in_terminal``, except for when the editor
1498
+ is painted again. ``in_terminal`` repaints immediately after the handoff,
1499
+ before the cursor position report it has just requested arrives, so that
1500
+ paint knows nothing about the space below the cursor and lands the editor
1501
+ at its preferred height, directly under the new output. The report then
1502
+ re-renders the layout across the remaining screen ~1/30 s later (bounded
1503
+ by ``min_redraw_interval``), which moves the editor back to the bottom.
1504
+ Whenever the transcript leaves rows free below it (startup, the first tool
1505
+ calls of a session) every write makes the editor visibly jump up and back.
1506
+ Waiting for the report first paints the editor at its final position.
1507
+
1508
+ To check for a regression, do not trust ``tmux capture-pane``: it shows only
1509
+ the settled frame and cannot see two paints inside one redraw interval.
1510
+ Record the raw byte stream with timestamps instead
1511
+ (``tmux pipe-pane -o 'python3 stamp.py >> out.bin'``) and look for a second
1512
+ editor paint after a scrollback write.
1513
+ """
1514
+ # Offline harnesses pass a bare stand-in for the app; nothing to suspend.
1515
+ if not isinstance(app, Application) or not app._is_running:
1516
+ yield Handoff(None)
1517
+ return
1518
+ # Chain to any handoff already in progress, as in_terminal does.
1519
+ previous = app._running_in_terminal_f
1520
+ done: Future[None] = Future()
1521
+ app._running_in_terminal_f = done
1522
+ try:
1523
+ if previous is not None:
1524
+ await previous
1525
+ if app.output.responds_to_cpr:
1526
+ # Also collects the report an atomic exit left outstanding, before
1527
+ # cooked mode could echo it onto the screen as text.
1528
+ await app.renderer.wait_for_cpr_responses()
1529
+ handoff = Handoff(layout_top_row(app) if atomic else None)
1530
+ if atomic:
1531
+ app.output.write_raw(SYNC_START) # erase() flushes it
1532
+ app.renderer.erase()
1533
+ app._running_in_terminal = True
1534
+ # A popup takes over SIGWINCH, but the editor's ``_poll_output_size``
1535
+ # task keeps calling ``_on_resize`` on any size change. That erases
1536
+ # from the cursor down (over the popup) and requests a CPR the popup
1537
+ # then reads as input. Ignore resizes until the handoff repaints below.
1538
+ app._on_resize = lambda: None
1539
+ try:
1540
+ with app.input.detach(), nullcontext() if atomic else app.input.cooked_mode():
1541
+ yield handoff
1542
+ finally:
1543
+ del app._on_resize
1544
+ app.renderer.reset()
1545
+ if handoff.top_row is not None and handoff.rows_written is not None:
1546
+ rows = app.output.get_size().rows
1547
+ row = min(handoff.top_row + handoff.rows_written, rows)
1548
+ app.renderer._min_available_height = rows - row + 1
1549
+ # Sent with the cursor still on the editor's top row, as the
1550
+ # renderer requires; the reply only refines the guess above.
1551
+ app._request_absolute_cursor_position()
1552
+ app._running_in_terminal = False
1553
+ app._redraw()
1554
+ app.output.write_raw(SYNC_END)
1555
+ app.output.flush()
1556
+ else:
1557
+ if atomic:
1558
+ # Nothing to hold the frame for across the report round trip.
1559
+ app.output.write_raw(SYNC_END)
1560
+ app.output.flush()
1561
+ app._request_absolute_cursor_position()
1562
+ try:
1563
+ # Input is attached again, so the report can be read here.
1564
+ # Rendering stays disabled meanwhile: an invalidation from a
1565
+ # keystroke or the spinner would otherwise paint the early frame.
1566
+ if app.output.responds_to_cpr:
1567
+ await app.renderer.wait_for_cpr_responses()
1568
+ finally:
1569
+ app._running_in_terminal = False
1570
+ app._redraw()
1571
+ finally:
1572
+ if not done.done():
1573
+ done.set_result(None)
1574
+
1575
+
1576
+ class ReflowAwareRenderer(Renderer):
1577
+ """Erase every physical row tmux produced from the last layout on narrowing.
1578
+
1579
+ prompt_toolkit paints full-width rows with autowrap disabled, so tmux does
1580
+ not flag them as wrapped. On a narrowing resize tmux still splits any row
1581
+ whose used cells exceed the new width, before the app sees SIGWINCH, and
1582
+ keeps the cursor inside its split row. The stock erase then moves up by the
1583
+ old logical row count and leaves the top of the old layout on screen. Count
1584
+ the split rows instead. Partial clears never shrink tmux's used-cell count;
1585
+ only the column-zero full erase does, so track the widest write per row
1586
+ since the last erase.
1587
+ """
1588
+
1589
+ def __init__(self, *args, **kwargs) -> None:
1590
+ self._row_extents: dict[int, int] = {}
1591
+ super().__init__(*args, **kwargs)
1592
+
1593
+ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> None:
1594
+ self._row_extents = {}
1595
+ super().reset(_scroll, leave_alternate_screen)
1596
+
1597
+ def render(self, app, layout, is_done: bool = False) -> None:
1598
+ super().render(app, layout, is_done)
1599
+ screen, size, has_style = self._last_screen, self._last_size, self._style_string_has_style
1600
+ if screen is None or size is None or has_style is None:
1601
+ return
1602
+ for y in range(screen.height):
1603
+ extent = 0
1604
+ for x, cell in screen.data_buffer[y].items():
1605
+ if cell.char != " " or has_style[cell.style]:
1606
+ extent = max(extent, min(x, size.columns - 1) + (cell.width or 1))
1607
+ if extent > self._row_extents.get(y, 0):
1608
+ self._row_extents[y] = extent
1609
+
1610
+ def _reflowed_rows_above_cursor(self) -> int | None:
1611
+ """Physical rows above the cursor after tmux narrowed the pane, else None."""
1612
+ if not os.environ.get("TMUX") or self._last_size is None or self._in_alternate_screen:
1613
+ return None
1614
+ columns = self.output.get_size().columns
1615
+ if columns < 1 or columns >= self._last_size.columns:
1616
+ return None
1617
+ cursor = self._cursor_pos
1618
+ rows = 0
1619
+ for y in range(cursor.y):
1620
+ rows += max(1, -(-self._row_extents.get(y, 0) // columns))
1621
+ extent = self._row_extents.get(cursor.y, 0)
1622
+ if extent > columns:
1623
+ # tmux keeps the cursor in its split piece, or after the last one.
1624
+ rows += cursor.x // columns if cursor.x < extent else (extent - 1) // columns
1625
+ return rows
1626
+
1627
+ def erase(self, leave_alternate_screen: bool = True) -> None:
1628
+ rows = self._reflowed_rows_above_cursor()
1629
+ if rows is None:
1630
+ super().erase(leave_alternate_screen)
1631
+ return
1632
+ output = self.output
1633
+ output.cursor_backward(self._cursor_pos.x)
1634
+ output.cursor_up(rows)
1635
+ output.erase_down()
1636
+ output.reset_attributes()
1637
+ output.enable_autowrap()
1638
+ output.flush()
1639
+ self.reset(leave_alternate_screen=leave_alternate_screen)
1640
+
1641
+
1642
+ def editor_mode_label(app: Application) -> str:
1643
+ """Read vi state at render time, without showing a badge for Emacs editing."""
1644
+ if app.editing_mode != EditingMode.VI:
1645
+ return ""
1646
+ if app.current_buffer.selection_state:
1647
+ return " VISUAL "
1648
+ return {
1649
+ InputMode.INSERT: " INSERT ",
1650
+ InputMode.INSERT_MULTIPLE: " INSERT ",
1651
+ InputMode.NAVIGATION: " NORMAL ",
1652
+ InputMode.REPLACE: " REPLACE ",
1653
+ InputMode.REPLACE_SINGLE: " REPLACE ",
1654
+ }[app.vi_state.input_mode]
1655
+
1656
+
1657
+ def editor_mode_badge(app: Application) -> list[tuple[str, str]]:
1658
+ """Keep the badge's text and color tied to the same live editor state."""
1659
+ label = editor_mode_label(app)
1660
+ if not label:
1661
+ return []
1662
+ return [(f"class:editor.mode.{label.strip().lower()}", label)]
1663
+
1664
+
1665
+ def install_reflow_renderer(app: Application) -> None:
1666
+ """Install the reflow-aware erase; only needed without resize regeneration.
1667
+
1668
+ Regeneration clears the screen and scrollback before replaying, so the
1669
+ careful physical-row accounting is wasted work when it is enabled.
1670
+ """
1671
+ app.renderer = ReflowAwareRenderer(
1672
+ app._merged_style,
1673
+ app.output,
1674
+ full_screen=False,
1675
+ mouse_support=False,
1676
+ cpr_not_supported_callback=app.cpr_not_supported_callback,
1677
+ )
1678
+
1679
+
1680
+ # Frames a paced backlog takes to drain, so a settled block rolls out row by
1681
+ # row when small and lands within about a second however big it is.
1682
+ PACED_DRAIN_FRAMES = 30
1683
+ # Typed prose advances every TYPED_STEP_FRAMES frames (every frame: 30 a
1684
+ # second). A step reveals TYPED_CHARS_PER_STEP characters (about 360 a second,
1685
+ # near a model's own pace, so a steady stream reads as one), sped up so a
1686
+ # backlog types out within TYPED_DRAIN_STEPS steps (about two seconds).
1687
+ TYPED_STEP_FRAMES = 1
1688
+ TYPED_CHARS_PER_STEP = 12
1689
+ TYPED_DRAIN_STEPS = 60
1690
+
1691
+ # Rich writes only SGR styles and OSC 8 hyperlinks. prompt_toolkit's ANSI parser
1692
+ # knows SGR but would print an OSC's payload as text, so the live row drops them.
1693
+ OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
1694
+ ESCAPES = re.compile(OSC.pattern + r"|\x1b\[[0-?]*[ -/]*[@-~]")
1695
+ # Blocks whose rows only make sense whole: typing them out character by
1696
+ # character would show a half-drawn rule, table border, or code line.
1697
+ UNTYPED_TOKENS = frozenset({"fence", "code_block", "table_open", "html_block", "hr"})
1698
+
1699
+
1700
+ def typed_prose(objects: tuple) -> bool:
1701
+ """Whether a write is prose that reads well typed out rather than rolled in."""
1702
+ if not objects:
1703
+ return False
1704
+ for obj in objects:
1705
+ if isinstance(obj, Markdown):
1706
+ tokens = obj.parsed
1707
+ elif isinstance(obj, ThinkingMarkdown):
1708
+ tokens = Markdown(obj.source).parsed
1709
+ else:
1710
+ return False
1711
+ if any(token.type in UNTYPED_TOKENS for token in tokens):
1712
+ return False
1713
+ return True
1714
+
1715
+
1716
+ @dataclass
1717
+ class QueuedRow:
1718
+ """One rendered row waiting for scrollback.
1719
+
1720
+ ``visible`` counts what a reader sees, without escapes or the trailing
1721
+ padding Rich adds, so a blank row costs nothing to reveal. ``lead`` is its
1722
+ leading indentation, shown at once so a centred heading or indented list
1723
+ item does not spend frames typing spaces. Both are computed on first use:
1724
+ a replay queues every row but writes them all without asking.
1725
+ """
1726
+
1727
+ text: str
1728
+ typed: bool = False
1729
+
1730
+ @cached_property
1731
+ def _plain(self) -> str:
1732
+ return ESCAPES.sub("", self.text).rstrip()
1733
+
1734
+ @property
1735
+ def visible(self) -> int:
1736
+ return len(self._plain)
1737
+
1738
+ @property
1739
+ def lead(self) -> int:
1740
+ return len(self._plain) - len(self._plain.lstrip())
1741
+
1742
+
1743
+ def split_rows(text: str) -> list[str]:
1744
+ """Split rendered output into rows, each keeping its newline.
1745
+
1746
+ Only ``\\n`` ends a row; ``str.splitlines`` would also split on control
1747
+ characters that escape sequences never contain but text could.
1748
+ """
1749
+ rows = text.split("\n")
1750
+ last = rows.pop()
1751
+ result = [row + "\n" for row in rows]
1752
+ if last:
1753
+ result.append(last)
1754
+ return result
1755
+
1756
+
1757
+ class RowCounter:
1758
+ """Tee for the Rich console's file that counts the rows a batch advances.
1759
+
1760
+ Rich wraps every line to the width it is given and ends it with a newline,
1761
+ so newlines are terminal rows as long as Rich and the terminal agree on
1762
+ cell widths; see ``suspended_editor`` for what a disagreement costs.
1763
+ """
1764
+
1765
+ def __init__(self, file):
1766
+ self.file = file
1767
+ self.rows = 0
1768
+
1769
+ def write(self, text: str) -> int:
1770
+ self.rows += text.count("\n")
1771
+ return self.file.write(text)
1772
+
1773
+ def __getattr__(self, name):
1774
+ return getattr(self.file, name)
1775
+
1776
+
1777
+ class TerminalOutput:
1778
+ """Commit completed Markdown blocks once; buffer unfinished text.
1779
+
1780
+ All writes run through one batched terminal handoff. Never hold the handoff
1781
+ across a network await: the editor must keep receiving input while streaming.
1782
+ """
1783
+
1784
+ def __init__(
1785
+ self,
1786
+ console: Console,
1787
+ app: Application,
1788
+ *,
1789
+ code_theme=None,
1790
+ rich_theme=None,
1791
+ ):
1792
+ self.console = console
1793
+ self.app = app
1794
+ self.tail = ""
1795
+ self.streamed = False
1796
+ self.code_theme = code_theme or (lambda: "ansi_dark")
1797
+ self.rich_theme = rich_theme or PALETTES["dark"].rich_theme
1798
+ self.pending: list[tuple[tuple[object, ...], str, bool]] = []
1799
+ self.transient_pending: list[tuple[tuple[object, ...], str, bool]] = []
1800
+ self.changed = asyncio.Event()
1801
+ self.lock = asyncio.Lock()
1802
+ self.commit_user = self.user
1803
+ self.commit_message = self.message
1804
+ self.commit_thinking = lambda text: self.print(
1805
+ ThinkingMarkdown(text, code_theme=self.code_theme(), style="dim"), end=""
1806
+ )
1807
+ self._thinking_tail = ""
1808
+ self._thinking_streamed = False
1809
+ self._regenerate = None
1810
+ self.resize_replay = None
1811
+ # Rendered rows not yet written. Pacing rolls a settled block out over
1812
+ # successive frames instead of landing it in one; off writes them all.
1813
+ self.rows: list[QueuedRow] = []
1814
+ self.paced = False
1815
+ # Typing reveals prose rows a few characters per frame in the live area
1816
+ # and writes each to scrollback once it is complete. Needs ``paced``.
1817
+ self.typed = False
1818
+ # Characters of ``rows[0]`` already shown in the live area.
1819
+ self._typed = 0
1820
+ self._preview: tuple[tuple[str, int], StyleAndTextTuples] | None = None
1821
+ # Frames skipped since the last typing step; see TYPED_STEP_FRAMES.
1822
+ self._skipped = 0
1823
+ # Rows and characters per step for the current backlog. Fixed when rows
1824
+ # arrive rather than recomputed as they leave, or the tail would crawl.
1825
+ self._reveal_rate = 0
1826
+ self._type_rate = 0
1827
+ # Transient writes already rendered into ``rows`` but not yet written,
1828
+ # with the absolute index just past their last row. A replay drops the
1829
+ # queued rows, so these must join it, just as ``transient_pending`` does.
1830
+ self._queued_transient: list[tuple[tuple, int]] = []
1831
+ # Absolute index of ``rows[0]``: rows written or dropped so far.
1832
+ self._row_base = 0
1833
+
1834
+ def regenerate(self, replay) -> None:
1835
+ # Coalesce requests. Snapshot only inside the handoff so arrivals during
1836
+ # the CPR await are included exactly once, including queued writes.
1837
+ self._regenerate = replay
1838
+ self.changed.set()
1839
+
1840
+ def print(self, *objects, end="\n", transient=False) -> None:
1841
+ entry = (objects, end, False)
1842
+ self.pending.append(entry)
1843
+ if transient:
1844
+ self.transient_pending.append(entry)
1845
+ self.changed.set()
1846
+
1847
+ def begin_turn(self, prompt: str) -> None:
1848
+ # The live row shows the running tool, not the prompt, so the quote goes
1849
+ # to scrollback as soon as the turn starts rather than waiting for output.
1850
+ self.commit_user(prompt)
1851
+
1852
+ def user(self, prompt: str) -> None:
1853
+ self.print()
1854
+ self.print(TaskPrompt(prompt))
1855
+ self.print()
1856
+
1857
+ def message(self, source: str) -> None:
1858
+ self.print(Markdown(source, code_theme=self.code_theme()))
1859
+ self.print()
1860
+
1861
+ def end_turn(self) -> None:
1862
+ self.finish_thinking()
1863
+ self.finish()
1864
+
1865
+ def _commit(self, source: str) -> None:
1866
+ if source.strip():
1867
+ self.commit_message(source)
1868
+
1869
+ def _commit_thinking(self, source: str) -> None:
1870
+ if source.strip():
1871
+ self.commit_thinking(source)
1872
+
1873
+ def thinking_delta(self, text: str) -> None:
1874
+ """Buffer incomplete Markdown containers, just like the public answer."""
1875
+ if not text:
1876
+ return
1877
+ self._thinking_streamed = True
1878
+ for part in text.splitlines(keepends=True):
1879
+ self._thinking_tail += part
1880
+ if part.endswith("\n"):
1881
+ self._commit_blocks(thinking=True)
1882
+
1883
+ def finish_thinking(self, fallback: str = "") -> None:
1884
+ if not self._thinking_streamed and fallback:
1885
+ self.thinking_delta(fallback)
1886
+ if self._thinking_streamed:
1887
+ self._commit_thinking(self._thinking_tail.rstrip("\n") + "\n\n")
1888
+ self._thinking_tail = ""
1889
+ self._thinking_streamed = False
1890
+
1891
+ def delta(self, text: str) -> None:
1892
+ if not text:
1893
+ return
1894
+ self.streamed = True
1895
+ # Model output is text, never terminal control sequences.
1896
+ text = "".join(
1897
+ " "
1898
+ if c == "\t"
1899
+ else c
1900
+ if c == "\n" or ord(c) >= 32 and not 127 <= ord(c) < 160
1901
+ else "�"
1902
+ for c in text
1903
+ )
1904
+ # Examine boundaries independently of provider chunk sizes. Do not parse
1905
+ # every token: a newline can complete a block, a partial line cannot.
1906
+ for part in text.splitlines(keepends=True):
1907
+ self.tail += part
1908
+ if part.endswith("\n"):
1909
+ self._commit_blocks()
1910
+ self.changed.set()
1911
+
1912
+ def _commit_blocks(self, *, thinking: bool = False) -> None:
1913
+ source = self._thinking_tail if thinking else self.tail
1914
+ lines = source.splitlines(keepends=True)
1915
+ tokens = Markdown(source).parsed
1916
+ blocks = [token for token in tokens if token.level == 0 and token.map]
1917
+ if not blocks:
1918
+ return
1919
+ last = blocks[-1]
1920
+ # Retain the last container: blank lines can belong to lists, quotes,
1921
+ # indented code, or fenced code. A following top-level block settles it.
1922
+ end = last.map[0]
1923
+ if lines[-1].strip() == "" and last.type in {
1924
+ "paragraph_open",
1925
+ "heading_open",
1926
+ "table_open",
1927
+ "hr",
1928
+ }:
1929
+ end = len(lines)
1930
+ elif last.type == "fence":
1931
+ closing = lines[last.map[1] - 1].rstrip("\r\n")
1932
+ if last.map[1] - last.map[0] > 1 and re.fullmatch(
1933
+ r" {0,3}" + re.escape(last.markup[0]) + "{" + str(len(last.markup)) + r",} *",
1934
+ closing,
1935
+ ):
1936
+ end = last.map[1]
1937
+ if end:
1938
+ if thinking:
1939
+ self._commit_thinking("".join(lines[:end]))
1940
+ self._thinking_tail = "".join(lines[end:])
1941
+ else:
1942
+ self._commit("".join(lines[:end]))
1943
+ self.tail = "".join(lines[end:])
1944
+
1945
+ def finish(self, fallback: str = "") -> None:
1946
+ # Message is a completion marker, not a second copy of streamed text.
1947
+ if not self.streamed and fallback:
1948
+ self.delta(fallback)
1949
+ if self.streamed:
1950
+ self._commit(self.tail)
1951
+ self.tail = ""
1952
+ self.streamed = False
1953
+ self.changed.set()
1954
+
1955
+ def _enqueue(self, pending: list, width: int, *, replay: bool = False) -> None:
1956
+ """Render writes into rows, marking the prose ones to type out.
1957
+
1958
+ A row is a self-contained unit (Rich closes styles and links per
1959
+ segment), so any prefix of the queue can be written now and the rest on
1960
+ later frames without splitting an escape. Each write renders on its own
1961
+ so its rows know whether they came from prose. A replay is written whole
1962
+ at once, so it skips both the prose check and transient tracking.
1963
+ """
1964
+ transient = [] if replay else self.transient_pending
1965
+ self.transient_pending = []
1966
+ prefetch_edits((obj for objects, _, _ in pending for obj in objects), width)
1967
+ pieces = []
1968
+ previous_file = self.console._file
1969
+ try:
1970
+ with self.console.use_theme(self.rich_theme()):
1971
+ for entry in pending:
1972
+ objects, end, soft_wrap = entry
1973
+ self.console.file = rendered = StringIO()
1974
+ with self.console:
1975
+ self.console.print(*objects, end=end, soft_wrap=soft_wrap, width=width)
1976
+ typed = self.typed and not replay and typed_prose(objects)
1977
+ pieces.append((entry, rendered.getvalue(), typed))
1978
+ finally:
1979
+ self.console.file = previous_file
1980
+ # A write can end mid-row and the next complete it; the row is typed
1981
+ # only if every write it holds is.
1982
+ text, typed = "", True
1983
+ for entry, piece, piece_typed in pieces:
1984
+ for part in split_rows(piece):
1985
+ text, typed = text + part, typed and piece_typed
1986
+ if text.endswith("\n"):
1987
+ self.rows.append(QueuedRow(text, typed))
1988
+ text, typed = "", True
1989
+ if any(entry is note for note in transient):
1990
+ # Its last character is in the open row, or the one just closed.
1991
+ last = len(self.rows) + (1 if text else 0)
1992
+ self._queued_transient.append((entry, self._row_base + last))
1993
+ if text:
1994
+ self.rows.append(QueuedRow(text, typed))
1995
+
1996
+ def _take_pending(self) -> list:
1997
+ pending, self.pending = self.pending, []
1998
+ return pending
1999
+
2000
+ def _written(self, count: int) -> None:
2001
+ """Drop ``count`` rows from the queue's front as written or replaced."""
2002
+ self.rows = self.rows[count:]
2003
+ self._row_base += count
2004
+ self._queued_transient = [
2005
+ (entry, end) for entry, end in self._queued_transient if end > self._row_base
2006
+ ]
2007
+
2008
+ def _advance(self) -> tuple[int, int]:
2009
+ """One paced frame: whole rows now due, and characters shown of the next.
2010
+
2011
+ Rows roll in one at a time and prose types out a few characters a step;
2012
+ both speed up so a backlog never falls far behind. Blank rows are free,
2013
+ so the gap between paragraphs does not stall the reveal, and so is a
2014
+ typed row's indentation.
2015
+ """
2016
+ self._reveal_rate = max(self._reveal_rate, -(-len(self.rows) // PACED_DRAIN_FRAMES))
2017
+ if self._typed and self.rows[0].typed and self._skipped < TYPED_STEP_FRAMES - 1:
2018
+ # Mid-row and between steps: nothing moves this frame.
2019
+ self._skipped += 1
2020
+ return 0, self._typed
2021
+ self._skipped = 0
2022
+ backlog = sum(row.visible for row in self.rows if row.typed) - self._typed
2023
+ self._type_rate = max(
2024
+ self._type_rate, TYPED_CHARS_PER_STEP, -(-backlog // TYPED_DRAIN_STEPS)
2025
+ )
2026
+ rows, chars, typed = self._reveal_rate, self._type_rate, self._typed
2027
+ due = 0
2028
+ for row in self.rows:
2029
+ if row.typed:
2030
+ typed = max(typed, row.lead)
2031
+ if row.visible - typed > chars:
2032
+ return due, typed + chars
2033
+ chars -= row.visible - typed
2034
+ typed = 0
2035
+ elif row.visible:
2036
+ if not rows:
2037
+ break
2038
+ rows -= 1
2039
+ due += 1
2040
+ return due, 0
2041
+
2042
+ def typing_fragments(self) -> StyleAndTextTuples:
2043
+ """The prose row being typed out, as far as it has got."""
2044
+ if not self._typed or not self.rows:
2045
+ return []
2046
+ row = self.rows[0]
2047
+ key = (row.text, self._typed)
2048
+ if self._preview is None or self._preview[0] != key:
2049
+ fragments, left = [], self._typed
2050
+ for style, text in ANSI(OSC.sub("", row.text.rstrip("\n"))).__pt_formatted_text__():
2051
+ if left <= 0:
2052
+ break
2053
+ fragments.append((style, text[:left]))
2054
+ left -= len(text)
2055
+ self._preview = (key, fragments)
2056
+ return self._preview[1]
2057
+
2058
+ async def flush(self, *, drain: bool = False) -> None:
2059
+ """Write queued output; ``drain`` writes it all, as before a popup or exit."""
2060
+ async with self.lock:
2061
+ paced = self.paced and not drain
2062
+ due = typed = 0
2063
+ if paced and self._regenerate is None:
2064
+ # Decide what this frame reveals before paying for a handoff: a
2065
+ # frame that only types further repaints the live row instead.
2066
+ if self.pending:
2067
+ width = max(1, self.app.output.get_size().columns)
2068
+ self._enqueue(self._take_pending(), width)
2069
+ if self.rows:
2070
+ due, typed = self._advance()
2071
+ if not due and typed != self._typed:
2072
+ self._typed = typed
2073
+ self.app.invalidate()
2074
+ if self._regenerate is not None or due or not paced and (self.pending or self.rows):
2075
+ # Renderer.reset() shows the cursor at the transcript position
2076
+ # both when erasing and before repainting. Suppress those shows
2077
+ # until the handoff has restored the editor and its cursor.
2078
+ with self.app.output.hidden_cursor():
2079
+ async with suspended_editor(self.app, atomic=True) as handoff:
2080
+ # Snapshot after entering: input/model events can arrive while
2081
+ # the handoff waits for CPR, but not during these sync writes.
2082
+ width = max(1, self.app.output.get_size().columns)
2083
+ if self._regenerate is not None:
2084
+ # Transient writes are not in the replay; keep those
2085
+ # still queued as rows as well as those not rendered.
2086
+ notes = [entry for entry, _ in self._queued_transient]
2087
+ pending = self._regenerate() + notes + self.transient_pending
2088
+ self._regenerate = None
2089
+ self.pending.clear()
2090
+ # Replay covers the rows still queued; drop them.
2091
+ self._written(len(self.rows))
2092
+ self._queued_transient = []
2093
+ # The handoff has erased the editor. Clear the
2094
+ # normal-screen history and home before replay; its
2095
+ # exit will request fresh CPR and restore the draft.
2096
+ self.app.output.write_raw("\x1b[H\x1b[2J\x1b[3J")
2097
+ self.app.output.flush()
2098
+ handoff.top_row = 1
2099
+ self._enqueue(pending, width, replay=True)
2100
+ # A rebuilt screen lands whole; pacing is for new output.
2101
+ due, typed = len(self.rows), 0
2102
+ else:
2103
+ self._enqueue(self._take_pending(), width)
2104
+ if not paced:
2105
+ due, typed = len(self.rows), 0
2106
+ # Rows arriving during the handoff queue behind this frame's.
2107
+ chunk = self.rows[:due]
2108
+ self._written(due)
2109
+ self._typed = typed
2110
+ counter = RowCounter(self.console.file)
2111
+ counter.write("".join(row.text for row in chunk))
2112
+ counter.flush()
2113
+ handoff.rows_written = counter.rows
2114
+ # The handoff already repainted the editor on exit.
2115
+ self.changed.clear()
2116
+ if self.rows:
2117
+ # Keep the run loop ticking at its frame rate until drained.
2118
+ self.changed.set()
2119
+ else:
2120
+ self._reveal_rate = self._type_rate = self._typed = self._skipped = 0
2121
+
2122
+ async def run(self) -> None:
2123
+ size = self.app.output.get_size()
2124
+ resized_at = None
2125
+ while True:
2126
+ # Poll only when resize replay is enabled. Debounce resize storms;
2127
+ # the renderer continues handling the editor normally meanwhile.
2128
+ if self.resize_replay is None:
2129
+ await self.changed.wait()
2130
+ else:
2131
+ # Not asyncio.wait_for: on 3.11 it swallows a cancel that lands
2132
+ # as the event fires, so this loop outlived the editor and
2133
+ # Ctrl+D never exited.
2134
+ try:
2135
+ async with asyncio.timeout(0.1):
2136
+ await self.changed.wait()
2137
+ except TimeoutError:
2138
+ pass
2139
+ # Height changes can scroll pieces of the live preview into
2140
+ # history too; replay must clear those just like width reflow.
2141
+ current = self.app.output.get_size()
2142
+ if current != size:
2143
+ size, resized_at = current, monotonic()
2144
+ elif resized_at is not None and monotonic() - resized_at >= 0.25:
2145
+ self.regenerate(self.resize_replay)
2146
+ resized_at = None
2147
+ await asyncio.sleep(1 / 30)
2148
+ await self.flush()
2149
+
2150
+
2151
+ PROMPT_PREFIX = "❯ "
2152
+ # Same width as PROMPT_PREFIX, so wrapping is unchanged when the draft flips.
2153
+ SHELL_PROMPT_PREFIX = "$ "
2154
+ CONTINUATION_PREFIX = "· "
2155
+
2156
+
2157
+ def prompt_prefix(text: str) -> str:
2158
+ """The prompt marker for a draft: `$` once it is a `!command`, else the chevron."""
2159
+ return SHELL_PROMPT_PREFIX if text.lstrip().startswith(SHELL_PREFIX) else PROMPT_PREFIX
2160
+
2161
+
2162
+ def _completes_while_typing() -> bool:
2163
+ text = get_app().current_buffer.text
2164
+ return (
2165
+ (text.startswith("/") and "\n" not in text)
2166
+ # A leading `$MODEL` or `+EFFORT` while it is still the only word.
2167
+ or (text.startswith(("$", "+")) and not any(char.isspace() for char in text))
2168
+ or reference_fragment(get_app().current_buffer.document.text_before_cursor) is not None
2169
+ )
2170
+
2171
+
2172
+ def _fit_editor(session: PromptSession) -> Window:
2173
+ """The session's editor window, sized to its content and without search."""
2174
+ # Retain PromptSession's editor/processors, but give its frame a content-sized
2175
+ # height. The default frame expands into the CPR-reported space below the
2176
+ # cursor, which can be almost the whole pane after a tmux split.
2177
+ editor = session.layout.current_window
2178
+ editor.height = None
2179
+ editor.dont_extend_height = Always()
2180
+ # No incremental search here: dropping the editor's search control makes
2181
+ # prompt_toolkit's `control_is_searchable` false, so Ctrl+R, Ctrl+S, and vi's
2182
+ # `/` and `?` never open an `I-search:` prompt this layout has no room for.
2183
+ editor.content._search_buffer_control = None
2184
+ return editor
2185
+
2186
+
2187
+ def _per_render(method):
2188
+ """Cache a layout callback for the length of one redraw.
2189
+
2190
+ Layout callbacks are queried repeatedly during a single synchronous redraw.
2191
+ Never retain their results across redraws: editor/menu/CPR and mutable
2192
+ activity state can all change without going through one revision counter.
2193
+ """
2194
+
2195
+ @wraps(method)
2196
+ def cached(self, *args):
2197
+ if self.render_cache is None:
2198
+ return method(self, *args)
2199
+ key = (method, self.size(), args)
2200
+ if key not in self.render_cache:
2201
+ self.render_cache[key] = method(self, *args)
2202
+ return self.render_cache[key]
2203
+
2204
+ return cached
2205
+
2206
+
2207
+ def _preview_body(diff: bool, body: str, width: int, theme: str):
2208
+ # Only the most recent body is retained. Titles and height/tail allocation
2209
+ # stay outside this cache; width, kind and syntax theme affect rendering.
2210
+ if diff:
2211
+ return edit_preview_rows(body, width, theme)
2212
+ return [
2213
+ ("class:bottom-toolbar.text", row.plain)
2214
+ for row in Text(command_text(body)).wrap(
2215
+ Console(width=width), width, overflow="fold", no_wrap=False
2216
+ )
2217
+ ]
2218
+
2219
+
2220
+ def _spinner_rows(fragments, height) -> VSplit:
2221
+ """Chrome rows outside a frame: spinners, notices and queued prompts.
2222
+
2223
+ One column of left padding, so the rows sit off the terminal edge and
2224
+ their text starts where the frames' contents do.
2225
+ """
2226
+ return VSplit(
2227
+ [
2228
+ Window(width=1),
2229
+ Window(
2230
+ FormattedTextControl(fragments, show_cursor=False),
2231
+ height=height,
2232
+ wrap_lines=False,
2233
+ dont_extend_height=True,
2234
+ ),
2235
+ ],
2236
+ height=height,
2237
+ )
2238
+
2239
+
2240
+ class PromptLayout:
2241
+ """The live block under scrollback: activity rows, tasks, previews and the editor.
2242
+
2243
+ Row callbacks read the session's current app on every call, since a
2244
+ transcript prompt swaps in its own Application after the layout is built.
2245
+ """
2246
+
2247
+ def __init__(
2248
+ self,
2249
+ session: PromptSession,
2250
+ activity: Activity,
2251
+ transcript: "Transcript | None",
2252
+ shortcuts: PrefixKeys,
2253
+ session_title: Callable[[], str] = lambda: "",
2254
+ ):
2255
+ self.session = session
2256
+ self.activity = activity
2257
+ self.transcript = transcript
2258
+ self.shortcuts = shortcuts
2259
+ self.session_title = session_title
2260
+ self.editor = _fit_editor(session)
2261
+ self.render_cache = None
2262
+ self.animation_task = None
2263
+ self.preview_body = lru_cache(maxsize=1)(_preview_body)
2264
+ self.live_delta = LiveDeltaPreview(lambda: self.session.app.invalidate())
2265
+ # The status row and side questions (waits on a model) share one
2266
+ # spinner; running tool calls and the terminal's own waits (a command)
2267
+ # share another, so the two kinds of wait look different at a glance.
2268
+ # Plan steps use a static marker, so motion only ever means
2269
+ # "the turn is waiting on this". Who owns the work is the badge and colour.
2270
+ self.spinner = status_spinner()
2271
+ self.tool_spinner = status_spinner("tool_spinner")
2272
+ self.thought_max_rows = parse_height(load_preferences().get("thinking_max_lines")) or float(
2273
+ THOUGHT_ROWS
2274
+ )
2275
+ self.menu = CompletionsMenu(
2276
+ max_height=20, scroll_offset=1, extra_filter=has_focus(session.default_buffer)
2277
+ )
2278
+ self.menu.content.dont_extend_height = Always()
2279
+ # Whether the open menu made the layout taller than the measured rows.
2280
+ self.menu_overflowed = False
2281
+
2282
+ def size(self):
2283
+ size = self.session.app.output.get_size()
2284
+ # Every layout read comes through here, so the task widget's size rule
2285
+ # follows a resize or split before anything is measured against it.
2286
+ self.activity.fit_screen(size.columns, size.rows)
2287
+ return size
2288
+
2289
+ # Heights and rows, each computed at most once per redraw.
2290
+
2291
+ @_per_render
2292
+ def frame_height(self) -> int:
2293
+ """The editor box, including any tasks drawn inside it above a divider."""
2294
+ tasks = self.attached_height()
2295
+ live = self.preview_layout()
2296
+ if live is not None:
2297
+ return live[3] + tasks
2298
+ size = self.size()
2299
+ available = max(1, size.rows - 4 - self.activity_height() - tasks - len(self.queue_rows()))
2300
+ cap = self.activity.height_cap(size.rows)
2301
+ if cap is not None:
2302
+ available = min(available, max(1, cap - 2 - self.tasks_height()))
2303
+ text_height = self.editor.preferred_height(max(1, size.columns - 2), available).preferred
2304
+ return min(text_height, available) + 2 + tasks
2305
+
2306
+ def refresh_interval(self) -> float:
2307
+ """Seconds until the next frame."""
2308
+ return min(self.spinner.interval, self.tool_spinner.interval) / 1000
2309
+
2310
+ @_per_render
2311
+ def spinner_frame(self) -> str:
2312
+ """This redraw's model-wait frame, shared so every such spinner turns together."""
2313
+ return self.spinner.render(monotonic()).plain
2314
+
2315
+ @_per_render
2316
+ def tool_spinner_frame(self) -> str:
2317
+ """This redraw's frame for running tool calls and commands."""
2318
+ return self.tool_spinner.render(monotonic()).plain
2319
+
2320
+ @_per_render
2321
+ def base_plan_rows(self, budget: int | None = None):
2322
+ if budget is None:
2323
+ rows = self.size().rows
2324
+ cap = self.activity.height_cap(rows)
2325
+ budget = (
2326
+ min(10, max(1, rows // 2 - 2))
2327
+ if cap is None
2328
+ # The editor box keeps one text row inside its two borders.
2329
+ else max(1, cap - self.task_chrome() - 3)
2330
+ )
2331
+ return self.activity.plan_rows(budget)
2332
+
2333
+ @_per_render
2334
+ def preview_layout(self):
2335
+ """Allocate actual chrome/editor height first, then give output the remainder.
2336
+
2337
+ Keep the normal task viewport unless it would leave no output at all.
2338
+ Only in that case trim task rows to preserve a one-line output tail.
2339
+ Calculate all three heights together so editor wrapping cannot create a
2340
+ circular dependency between frame_height and command_rows.
2341
+ """
2342
+ activity, transcript = self.activity, self.transcript
2343
+ if transcript is None:
2344
+ return None
2345
+ edits = transcript.live_edit_previews and activity.edit_previews
2346
+ if not edits:
2347
+ # The preview ended, so the next edit never flashes this one's diff.
2348
+ self.live_delta.forget()
2349
+ # A `!command` the user typed is shown while it runs whatever the
2350
+ # scrollback setting for the model's commands says, and so is a job
2351
+ # the user asked to watch; the model's own commands follow the setting.
2352
+ commands = activity.command_outputs
2353
+ if not (transcript.command_scrollback or activity.user_command):
2354
+ commands = {
2355
+ key: event for key, event in commands.items() if key.startswith(WATCHED_PREFIX)
2356
+ }
2357
+ if not edits and not commands:
2358
+ return None
2359
+ size = self.size()
2360
+ width = max(1, size.columns - 2)
2361
+ # One terminal row stays free for the non-full-screen renderer/CPR.
2362
+ fixed = (
2363
+ 1
2364
+ + int(self.session.bottom_toolbar is not None)
2365
+ + self.status_height()
2366
+ + len(self.queue_rows())
2367
+ + self.menu.preferred_height(size.columns, size.rows).preferred
2368
+ )
2369
+ room = max(0, size.rows - fixed)
2370
+ # Parallel calls share the preview; show the most recently updated call.
2371
+ event = next(reversed((activity.edit_previews if edits else commands).values()))
2372
+ # A sandboxed snippet is pending arguments like an edit, but it is code
2373
+ # rather than a diff: no +/- coloring, and nothing has run yet.
2374
+ code = bool(edits) and event.kind == "code"
2375
+ heading = (
2376
+ block_heading(
2377
+ RUNNING,
2378
+ "Preparing code · not yet run" if code else f"Editing · {event.path}",
2379
+ )
2380
+ if edits
2381
+ else command_heading(activity, event)
2382
+ )
2383
+ plans = self.base_plan_rows()
2384
+ # Editor: two borders and at least one text row. Preview: two rule
2385
+ # lines, the command, and at least one output row. Keep one task when
2386
+ # possible.
2387
+ # Attached tasks share the editor's top border and add only a divider.
2388
+ chrome_rows = 1 if activity.attach_tasks else 2
2389
+ task_floor = 1 + chrome_rows if plans else 0
2390
+ editor_room = max(1, room - 2 - 4 - task_floor)
2391
+ cap = activity.height_cap(size.rows)
2392
+ if cap is not None:
2393
+ tasks = len(plans) + chrome_rows if plans else 0
2394
+ editor_room = min(editor_room, max(1, cap - 2 - tasks))
2395
+ editor_rows = min(editor_room, self.editor.preferred_height(width, editor_room).preferred)
2396
+ editor_height = editor_rows + 2
2397
+ plan_budget = max(0, room - editor_height - 4 - chrome_rows)
2398
+ if len(plans) > plan_budget:
2399
+ plans = self.base_plan_rows(plan_budget)
2400
+ plan_height = len(plans) + chrome_rows if plans else 0
2401
+ # Chrome: the two rule lines, plus the indented `$ command` a shell
2402
+ # preview repeats below its heading, exactly as scrollback does.
2403
+ chrome = 2 if edits else 3
2404
+ budget = min(
2405
+ max(1, screen_rows(transcript.command_preview_lines, size.rows)),
2406
+ room - editor_height - plan_height - chrome,
2407
+ )
2408
+ if budget <= 0:
2409
+ return plans, "", [], editor_height
2410
+ body = event.text if edits else event.output
2411
+ delta = transcript.delta if edits and not code else None
2412
+ rows = (
2413
+ self.live_delta.rows(
2414
+ delta, event.call_id, event.path, body, width, transcript.code_theme
2415
+ )
2416
+ if delta is not None
2417
+ else None
2418
+ ) or self.preview_body(bool(edits) and not code, body, width, transcript.code_theme)
2419
+ if edits:
2420
+ # A diff keeps its +/- gutter flush left, as the settled block does.
2421
+ return plans, heading, rows[-budget:], editor_height
2422
+ block = [("class:plan", "$ " + command_preview(event.command)), *rows[-budget:]]
2423
+ return plans, heading, [(style, INDENT + text) for style, text in block], editor_height
2424
+
2425
+ def plan_rows(self):
2426
+ live = self.preview_layout()
2427
+ return live[0] if live is not None else self.base_plan_rows()
2428
+
2429
+ def preview_heading(self):
2430
+ live = self.preview_layout()
2431
+ return live[1] if live is not None else ""
2432
+
2433
+ def command_rows(self):
2434
+ live = self.preview_layout()
2435
+ return live[2] if live is not None else []
2436
+
2437
+ @_per_render
2438
+ def notice_rows(self):
2439
+ """Freeze the expiring notice for this render so height matches content."""
2440
+ width = self.size().columns - 1
2441
+ return self.activity.notice_rows(width)
2442
+
2443
+ @_per_render
2444
+ def group_rows(self):
2445
+ """The run's group line so far, when no status row carries its tally.
2446
+
2447
+ While a turn runs the count rides the status row instead, next to the
2448
+ spinner that says it is still going; the full line reaches scrollback
2449
+ when the run closes.
2450
+ """
2451
+ if self.transcript is None or self.activity.status_shown:
2452
+ return []
2453
+ row = self.transcript.pending_group_row(self.size().columns - 1)
2454
+ return [("class:activity.group", row)] if row else []
2455
+
2456
+ @_per_render
2457
+ def thought_rows(self):
2458
+ """The newest thought, on fewer rows in a short pane.
2459
+
2460
+ The tool rows count against the same cap, keeping at least one row
2461
+ for the thought.
2462
+ """
2463
+ screen, cap = self.size().rows, self.thought_max_rows
2464
+ # A fixed count still yields to a short pane (a quarter of it, less
2465
+ # the tool rows); a share of the screen is that share less the tool rows.
2466
+ share = screen_rows(cap, screen) if cap < 1 else screen // 4
2467
+ rows = max(1, share - len(self.tool_rows()))
2468
+ if cap >= 1:
2469
+ rows = min(int(cap), rows)
2470
+ return self.activity.thought_fragments(self.size().columns - 1, rows)
2471
+
2472
+ @_per_render
2473
+ def status_line(self) -> StatusLine:
2474
+ """The held status line, read once per redraw by the status row and the tool row.
2475
+
2476
+ Without its tally, which the hold ignores anyway: the tally reads the
2477
+ task widget, whose height depends on `preview_layout`, which in turn
2478
+ sizes the live rows this line feeds. Like `status_gap`, it must read
2479
+ only activity state.
2480
+ """
2481
+ return self.activity.held_status_line("", STATUS_HOLD_SECONDS)
2482
+
2483
+ @_per_render
2484
+ def tool_rows(self):
2485
+ """The turn's latest tool calls, under the thought.
2486
+
2487
+ They share a quarter of the pane with the thought, which keeps one
2488
+ row of it when there is one to show.
2489
+ """
2490
+ limit = self.size().rows // 4 - int(self.activity.thought_shown)
2491
+ return self.activity.tool_rows(
2492
+ self.status_line(), self.size().columns - 1, limit, self.tool_spinner_frame()
2493
+ )
2494
+
2495
+ @_per_render
2496
+ def aside_rows(self):
2497
+ return self.activity.aside_rows(self.spinner_frame(), self.size().columns - 1)
2498
+
2499
+ @_per_render
2500
+ def wait_rows(self):
2501
+ """The terminal's own wait (the host starting, a command running there)."""
2502
+ return self.activity.wait_fragments(self.tool_spinner_frame(), self.size().columns - 1)
2503
+
2504
+ @_per_render
2505
+ def typing_row(self):
2506
+ """Prose still being typed out: the next scrollback row, drawn live."""
2507
+ output = self.transcript.output if self.transcript is not None else None
2508
+ return output.typing_fragments() if output is not None else []
2509
+
2510
+ @_per_render
2511
+ def queue_rows(self):
2512
+ budget = min(4, max(1, self.size().rows // 4))
2513
+ return self.activity.queue_rows(budget)
2514
+
2515
+ def status_gap(self) -> bool:
2516
+ """Whether the live panel needs its own blank row above it.
2517
+
2518
+ Scrollback separates blocks with a blank row, but the panel is not
2519
+ scrollback: without this the live rows, or the editor box carrying the
2520
+ status, sit flush against the last tool line. Depend only on state
2521
+ preview_layout already reads, so asking for the gap cannot re-enter
2522
+ the layout calculation.
2523
+ """
2524
+ shown = (
2525
+ self.activity.status_shown
2526
+ or bool(self.group_rows())
2527
+ or bool(self.notice_rows())
2528
+ or bool(self.wait_rows())
2529
+ or bool(self.aside_rows())
2530
+ )
2531
+ # A typed row is not written yet, so scrollback's own gap sits above it.
2532
+ transcript = self.transcript
2533
+ return (
2534
+ shown
2535
+ and transcript is not None
2536
+ and (bool(self.typing_row()) or not transcript.ends_blank)
2537
+ )
2538
+
2539
+ def status_height(self) -> int:
2540
+ """Live rows above the editor box. The status rides the box's own border."""
2541
+ return (
2542
+ bool(self.typing_row())
2543
+ + len(self.thought_rows())
2544
+ + len(self.tool_rows())
2545
+ + len(self.group_rows())
2546
+ + len(self.notice_rows())
2547
+ + len(self.wait_rows())
2548
+ + len(self.aside_rows())
2549
+ + self.status_gap()
2550
+ )
2551
+
2552
+ def task_chrome(self) -> int:
2553
+ """Rows the widget adds around its tasks: a divider attached, else a frame."""
2554
+ return 1 if self.activity.attach_tasks else 2
2555
+
2556
+ def tasks_height(self) -> int:
2557
+ """The widget's full height, wherever it is drawn."""
2558
+ rows = self.plan_rows()
2559
+ return len(rows) + self.task_chrome() if rows else 0
2560
+
2561
+ def plan_attached(self) -> bool:
2562
+ return self.activity.attach_tasks and bool(self.plan_rows())
2563
+
2564
+ def attached_height(self) -> int:
2565
+ """Rows attached tasks add to the editor box: the tasks plus a divider."""
2566
+ return len(self.plan_rows()) + 1 if self.plan_attached() else 0
2567
+
2568
+ def activity_height(self) -> int:
2569
+ rows = [] if self.activity.attach_tasks else self.plan_rows()
2570
+ commands = self.command_rows()
2571
+ return (
2572
+ self.status_height()
2573
+ + (len(rows) + 2 if rows else 0)
2574
+ + (len(commands) + 2 if commands else 0)
2575
+ )
2576
+
2577
+ def task_padding(self) -> int:
2578
+ return int(self.activity.task_style == "icons")
2579
+
2580
+ def plan_text(self):
2581
+ return panel_fragments(self.plan_rows(), self.size().columns - 2 - self.task_padding())
2582
+
2583
+ # Containers.
2584
+
2585
+ def panel_rows(self, rows) -> ConditionalContainer:
2586
+ """Padded chrome rows for one row source, shown while it has any."""
2587
+ return ConditionalContainer(
2588
+ _spinner_rows(
2589
+ lambda: panel_fragments(rows(), self.size().columns - 1),
2590
+ lambda: len(rows()),
2591
+ ),
2592
+ filter=Condition(lambda: bool(rows())),
2593
+ )
2594
+
2595
+ def plan_body(self) -> VSplit:
2596
+ # `task_style icons` sets rows one column off the frame's side.
2597
+ return VSplit(
2598
+ [
2599
+ Window(width=self.task_padding),
2600
+ Window(
2601
+ FormattedTextControl(self.plan_text),
2602
+ height=lambda: len(self.plan_rows()),
2603
+ dont_extend_height=True,
2604
+ wrap_lines=False,
2605
+ ),
2606
+ ]
2607
+ )
2608
+
2609
+ def status_border(self) -> Window:
2610
+ """The editor's top border carrying the status: `┌─ ⠋ Phase ── ✓ 1 tool · 4s ─┐`.
2611
+
2612
+ It takes the place of the task heading while a turn runs: the tasks
2613
+ hang under what the turn is doing, and the status costs no row. The
2614
+ heading's count moves to the right-hand meta, so a plan longer than
2615
+ its window still says how much there is (`Tasks 2/14 · ✓ 1 tool`).
2616
+ """
2617
+
2618
+ def fragments():
2619
+ columns = self.size().columns
2620
+ if columns < 2:
2621
+ return []
2622
+ transcript = self.transcript
2623
+ meta = [transcript.pending_tally() if transcript is not None else ""]
2624
+ activity = self.activity
2625
+ # A plan hidden only for the pane's size still says it exists.
2626
+ hidden = activity.show_tasks and activity.tasks_too_big
2627
+ if (self.plan_attached() or hidden) and activity.displayed_plan:
2628
+ meta.insert(0, activity.panel_title())
2629
+ line = replace(self.status_line(), tally=" · ".join(part for part in meta if part))
2630
+ status = line.fragments(self.spinner_frame(), columns - 6, rule=True)
2631
+ if not status:
2632
+ return [("class:frame.border", "┌" + "─" * max(0, columns - 2) + "┐")]
2633
+ used = 3 + sum(cell_len(text) for _, text in status)
2634
+ return [
2635
+ ("class:frame.border", "┌─ "),
2636
+ *status,
2637
+ ("class:frame.border", f" {'─' * max(0, columns - used - 2)}┐"),
2638
+ ]
2639
+
2640
+ return Window(FormattedTextControl(fragments, show_cursor=False), height=1)
2641
+
2642
+ def plan_heading(self):
2643
+ """The task heading, without permanent shortcut instructions."""
2644
+ activity = self.activity
2645
+ width = self.size().columns - 8
2646
+ style = "bold"
2647
+ if activity.displayed_plan:
2648
+ style = "class:plan.heading.done" if activity.plan_done else "class:plan.heading"
2649
+ heading = [(style, activity.panel_heading())]
2650
+ # Attached, the heading takes the editor's top border, so it carries
2651
+ # the session's name too (`Tasks 2/5 · Fix the login test`). A detached
2652
+ # task box sits above an editor box already showing it.
2653
+ if self.plan_attached() and (label := self.session_label()):
2654
+ return fit_fragments([*heading, ("class:activity.meta", " · "), *label], width)
2655
+ return panel_fragments(heading, width)
2656
+
2657
+ def plan_heading_border(self) -> VSplit:
2658
+ """A top border with the heading at the left, cut to fit the row.
2659
+
2660
+ Drawn like pcode's ``Frame`` title, but truncated by ``plan_heading``
2661
+ and swapped into the editor frame as well as the plan's own.
2662
+ """
2663
+ return VSplit(
2664
+ [
2665
+ Window(FormattedTextControl("┌─ "), width=3, style="class:frame.border"),
2666
+ Label(
2667
+ self.plan_heading,
2668
+ style="class:frame.label",
2669
+ dont_extend_width=True,
2670
+ ),
2671
+ Window(FormattedTextControl(" "), width=1, style="class:frame.border"),
2672
+ Window(char="─", style="class:frame.border"),
2673
+ Window(char="┐", width=1, style="class:frame.border"),
2674
+ ],
2675
+ height=1,
2676
+ )
2677
+
2678
+ def commands_block(self) -> ConditionalContainer:
2679
+ # Framed the way scrollback frames the same run once it settles: the
2680
+ # heading rides the opening rule, the body is indented, a rule closes it.
2681
+ return ConditionalContainer(
2682
+ HSplit(
2683
+ [
2684
+ VSplit(
2685
+ [
2686
+ Label(
2687
+ lambda: panel_fragments(
2688
+ [("class:block.heading", self.preview_heading())],
2689
+ self.size().columns - 4,
2690
+ ),
2691
+ style="class:block.heading",
2692
+ dont_extend_width=True,
2693
+ ),
2694
+ Window(FormattedTextControl(" "), width=1, style="class:block.rule"),
2695
+ Window(char=RULE, style="class:block.rule"),
2696
+ ],
2697
+ height=1,
2698
+ ),
2699
+ Window(
2700
+ FormattedTextControl(
2701
+ lambda: panel_fragments(self.command_rows(), self.size().columns),
2702
+ show_cursor=False,
2703
+ ),
2704
+ height=lambda: len(self.command_rows()),
2705
+ dont_extend_height=True,
2706
+ wrap_lines=False,
2707
+ ),
2708
+ Window(char=RULE, height=1, style="class:block.rule"),
2709
+ ],
2710
+ height=lambda: len(self.command_rows()) + 2,
2711
+ ),
2712
+ filter=Condition(lambda: bool(self.command_rows())),
2713
+ )
2714
+
2715
+ def activity_panel(self) -> HSplit:
2716
+ """Everything live between scrollback and the editor, top to bottom."""
2717
+ activity = self.activity
2718
+ # Its own rows, so a running tool taking the status never hides it;
2719
+ # above the editor box whose border carries the status, where it reads
2720
+ # as what led to the call.
2721
+ thought = self.panel_rows(self.thought_rows)
2722
+ # Between the thought and the status row: why, what, then how it is going.
2723
+ tool = self.panel_rows(self.tool_rows)
2724
+ plan_frame = Frame(self.plan_body(), height=lambda: len(self.plan_rows()) + 2)
2725
+ plan_frame.container.children[0] = self.plan_heading_border()
2726
+ plan = ConditionalContainer(
2727
+ plan_frame,
2728
+ filter=Condition(lambda: bool(self.plan_rows()) and not activity.attach_tasks),
2729
+ )
2730
+ # Keep the turn and its activity adjacent even when the root layout justifies
2731
+ # the transcript and editor across the remaining terminal height.
2732
+ commands = self.commands_block()
2733
+ status_spacer = ConditionalContainer(Window(height=1), filter=Condition(self.status_gap))
2734
+ # Flush left, where scrollback will draw the same line once the run closes.
2735
+ group = ConditionalContainer(
2736
+ Window(
2737
+ FormattedTextControl(self.group_rows, show_cursor=False),
2738
+ height=lambda: len(self.group_rows()),
2739
+ wrap_lines=False,
2740
+ dont_extend_height=True,
2741
+ ),
2742
+ filter=Condition(lambda: bool(self.group_rows())),
2743
+ )
2744
+ # Above the spinner and the thought: a notice answers the keystroke that
2745
+ # caused it without ever reaching scrollback, and vanishes on its own.
2746
+ notice = self.panel_rows(self.notice_rows)
2747
+ # This terminal's own wait on the session host, hidden while a turn row covers it.
2748
+ waits = self.panel_rows(self.wait_rows)
2749
+ # Side questions outlive the turn. Their rows stay out here, so only
2750
+ # the main status rides the editor box with its tasks.
2751
+ asides = self.panel_rows(self.aside_rows)
2752
+ return HSplit([status_spacer, group, notice, thought, tool, waits, asides, commands, plan])
2753
+
2754
+ def queued(self) -> ConditionalContainer:
2755
+ return ConditionalContainer(
2756
+ _spinner_rows(
2757
+ lambda: panel_fragments(self.queue_rows(), self.size().columns - 1),
2758
+ lambda: len(self.queue_rows()),
2759
+ ),
2760
+ filter=Condition(lambda: bool(self.activity.queued_prompts)),
2761
+ )
2762
+
2763
+ def typing(self) -> ConditionalContainer:
2764
+ # Flush left like the scrollback row it becomes, and placed above the
2765
+ # layout's justifying filler so it sits directly beneath scrollback rather
2766
+ # than jumping up a row when it is written.
2767
+ return ConditionalContainer(
2768
+ Window(
2769
+ FormattedTextControl(self.typing_row, show_cursor=False),
2770
+ height=1,
2771
+ wrap_lines=False,
2772
+ dont_extend_height=True,
2773
+ ),
2774
+ filter=Condition(lambda: bool(self.typing_row())),
2775
+ )
2776
+
2777
+ def session_name(self) -> str:
2778
+ """The session's /rename name or model-given title, on one line; else empty.
2779
+
2780
+ Never a fallback such as the first prompt, so a new session keeps a
2781
+ plain rule.
2782
+ """
2783
+ return " ".join((self.session_title() or "").split())
2784
+
2785
+ def session_label(self) -> list[tuple[str, str]]:
2786
+ """The last turn's mark, then the session's name; either may be absent.
2787
+
2788
+ A new session, with no name and no finished turn, gets nothing.
2789
+ """
2790
+ label = []
2791
+ outcome = self.activity.turn_outcome
2792
+ if mark := TURN_MARKS.get(outcome):
2793
+ label.append((f"class:session.{outcome}", mark))
2794
+ if name := self.session_name():
2795
+ if label:
2796
+ label.append(("", " "))
2797
+ label.append(("class:session.name", name))
2798
+ return label
2799
+
2800
+ def idle_title(self) -> list[tuple[str, str]]:
2801
+ """The idle editor box's heading: how the last turn ended, and the name.
2802
+
2803
+ Coming back to a finished turn, it says what the session is about and
2804
+ whether the turn worked; while a turn runs, the status row takes the
2805
+ border instead.
2806
+ """
2807
+ # Corners, stub, a space either side, and at least one rule cell.
2808
+ return fit_fragments(self.session_label(), self.size().columns - TITLE_CHROME - 1)
2809
+
2810
+ def editor_frame(self) -> Frame:
2811
+ session = self.session
2812
+ editor_frame = Frame(self.editor, title=self.idle_title, height=self.frame_height)
2813
+ # Replace only the bottom border: the badge must not add a row or alter CPR sizing.
2814
+ editor_frame.container.children[-1] = VSplit(
2815
+ [
2816
+ Window(char="└", width=1, style="class:frame.border"),
2817
+ Window(char="─", style="class:frame.border"),
2818
+ ConditionalContainer(
2819
+ Label(
2820
+ lambda: editor_mode_badge(session.app),
2821
+ dont_extend_width=True,
2822
+ ),
2823
+ filter=Condition(lambda: session.app.editing_mode == EditingMode.VI),
2824
+ ),
2825
+ Window(FormattedTextControl("─┘"), width=2, style="class:frame.border"),
2826
+ ],
2827
+ height=1,
2828
+ )
2829
+ # The top border carries the running status, else the attached tasks'
2830
+ # heading. Attached tasks sit under it, and a divider separates them
2831
+ # from the text; frame_height counts both.
2832
+ side = partial(Window, char="│", width=1, style="class:frame.border")
2833
+ status = Condition(lambda: self.activity.status_shown)
2834
+ attached = Condition(self.plan_attached)
2835
+ plain_top = editor_frame.container.children[0]
2836
+ editor_frame.container.children[0] = HSplit(
2837
+ [
2838
+ ConditionalContainer(self.status_border(), filter=status),
2839
+ ConditionalContainer(self.plan_heading_border(), filter=attached & ~status),
2840
+ ConditionalContainer(plain_top, filter=~attached & ~status),
2841
+ ConditionalContainer(
2842
+ HSplit(
2843
+ [
2844
+ VSplit([side(), self.plan_body(), side()]),
2845
+ VSplit(
2846
+ [
2847
+ Window(char="├", width=1, style="class:frame.border"),
2848
+ Window(char="─", style="class:frame.border"),
2849
+ Window(char="┤", width=1, style="class:frame.border"),
2850
+ ],
2851
+ height=1,
2852
+ ),
2853
+ ]
2854
+ ),
2855
+ filter=attached,
2856
+ ),
2857
+ ]
2858
+ )
2859
+ return editor_frame
2860
+
2861
+ def bottom_toolbar(self) -> Window:
2862
+ return Window(
2863
+ FormattedTextControl(
2864
+ lambda: self.session.bottom_toolbar, style="class:bottom-toolbar.text"
2865
+ ),
2866
+ style="class:bottom-toolbar",
2867
+ height=1,
2868
+ )
2869
+
2870
+ def layout(self) -> Layout:
2871
+ children = [
2872
+ self.menu,
2873
+ self.activity_panel(),
2874
+ self.queued(),
2875
+ # This prompt can be only three rows tall. A float would inherit
2876
+ # that height and clip the menu, so reserve space only while open.
2877
+ shortcut_hint(self.shortcuts),
2878
+ self.editor_frame(),
2879
+ ]
2880
+ if self.transcript is not None:
2881
+ children[:0] = [self.typing(), Window()]
2882
+ if self.session.bottom_toolbar is not None:
2883
+ children.append(self.bottom_toolbar())
2884
+ return Layout(
2885
+ HSplit(
2886
+ children,
2887
+ align=VerticalAlign.JUSTIFY if self.transcript else VerticalAlign.BOTTOM,
2888
+ ),
2889
+ focused_element=self.editor,
2890
+ )
2891
+
2892
+ # Redraw hooks: the per-render cache and the spinner's animation timer.
2893
+
2894
+ def needs_animation(self) -> bool:
2895
+ activity = self.activity
2896
+ return (
2897
+ activity.busy
2898
+ or activity.status_shown
2899
+ # Keep redrawing while a notice is live: nothing else will ask for
2900
+ # the frame that finally removes it.
2901
+ or activity.notice_shown
2902
+ or activity.asides_running
2903
+ or bool(activity.waits)
2904
+ or (activity.tasks_shown and activity.tools.animating)
2905
+ )
2906
+
2907
+ async def animate(self, app) -> None:
2908
+ await asyncio.sleep(self.refresh_interval())
2909
+ self.animation_task = None
2910
+ # Repaint unconditionally: this timer only exists because the previous
2911
+ # render was animated, and the frame that removes an expired notice or
2912
+ # a finished spinner is the one nothing else asks for.
2913
+ app.invalidate()
2914
+
2915
+ def before_render(self, app) -> None:
2916
+ self.render_cache = {}
2917
+ transcript, activity = self.transcript, self.activity
2918
+ if transcript is None or not (
2919
+ (transcript.live_edit_previews and activity.edit_previews)
2920
+ or (transcript.command_scrollback and activity.command_outputs)
2921
+ or any(key.startswith(WATCHED_PREFIX) for key in activity.command_outputs)
2922
+ ):
2923
+ self.preview_body.cache_clear()
2924
+
2925
+ def after_render(self, app) -> None:
2926
+ self.render_cache = None
2927
+ # A redraw caused by input or application events starts animation again.
2928
+ # Idle prompts have no timer; toolkit owns cancellation at app shutdown.
2929
+ if self.needs_animation() and app.is_running:
2930
+ if self.animation_task is None or self.animation_task.done():
2931
+ self.animation_task = app.create_background_task(self.animate(app))
2932
+ elif self.animation_task is not None:
2933
+ self.animation_task.cancel()
2934
+ self.animation_task = None
2935
+ self.replay_after_menu(app)
2936
+
2937
+ def replay_after_menu(self, app) -> None:
2938
+ """Rebuild scrollback, as a resize does, once an overflowing menu closes.
2939
+
2940
+ A completion menu taller than the rows below the editor scrolls the
2941
+ terminal, and prompt_toolkit never shrinks its screen again (each render
2942
+ keeps at least the last height), so closing the menu leaves blank rows
2943
+ under the editor where scrolled-away transcript used to be. The replay
2944
+ resets the renderer and repaints that history.
2945
+ """
2946
+ transcript, renderer = self.transcript, app.renderer
2947
+ if transcript is None or not transcript.replays_on_resize:
2948
+ return
2949
+ screen, available = renderer._last_screen, renderer._min_available_height
2950
+ if renderer._in_alternate_screen or screen is None or available <= 0:
2951
+ return
2952
+ if self.session.default_buffer.complete_state is not None:
2953
+ self.menu_overflowed |= screen.height > available
2954
+ elif self.menu_overflowed:
2955
+ self.menu_overflowed = False
2956
+ transcript.regenerate()
2957
+
2958
+
2959
+ def create_prompt(
2960
+ registry: CommandRegistry,
2961
+ *,
2962
+ activity: Activity | None = None,
2963
+ transcript: "Transcript | None" = None,
2964
+ workspace=None,
2965
+ on_submit=None,
2966
+ on_command=None,
2967
+ on_cancel=None,
2968
+ on_effort=None,
2969
+ on_model=None,
2970
+ on_tasks=None,
2971
+ on_thinking=None,
2972
+ on_commands=None,
2973
+ on_send_mode=None,
2974
+ on_previous_session=None,
2975
+ on_copy_response=None,
2976
+ key_prefix: str | None = None,
2977
+ session_title: Callable[[], str] = lambda: "",
2978
+ **kwargs,
2979
+ ) -> PromptSession:
2980
+ configure_newline_keys()
2981
+ install_fast_layout_division()
2982
+ activity = activity or Activity()
2983
+ callbacks = PromptCallbacks(
2984
+ on_cancel=on_cancel,
2985
+ on_effort=on_effort,
2986
+ on_model=on_model,
2987
+ on_tasks=on_tasks,
2988
+ on_thinking=on_thinking,
2989
+ on_commands=on_commands,
2990
+ on_send_mode=on_send_mode,
2991
+ on_previous_session=on_previous_session,
2992
+ on_copy_response=on_copy_response,
2993
+ )
2994
+ keys, shortcuts = prompt_key_bindings(
2995
+ activity, transcript, callbacks, key_prefix, lambda: session.default_buffer
2996
+ )
2997
+
2998
+ output = kwargs.pop("output", None)
2999
+ if not isinstance(output, CursorSafeOutput):
3000
+ output = CursorSafeOutput(output if output is not None else create_output())
3001
+ session = PromptSession(
3002
+ output=output,
3003
+ message=lambda: [("class:prompt", prompt_prefix(session.default_buffer.text))],
3004
+ prompt_continuation=lambda width, line, soft: [
3005
+ ("class:prompt", " " if soft else CONTINUATION_PREFIX)
3006
+ ],
3007
+ # Every prefix is the same width, so wrapped rows all get the same
3008
+ # amount of text space.
3009
+ input_processors=[WordWrapProcessor(prefix_width=get_cwidth(PROMPT_PREFIX))],
3010
+ multiline=True,
3011
+ erase_when_done=True,
3012
+ completer=merge_completers([SlashCompleter(registry), FileReferenceCompleter(workspace)]),
3013
+ lexer=ReferenceLexer(extra=[(MARKER_PATTERN, "class:paste-marker")]),
3014
+ complete_while_typing=Condition(_completes_while_typing),
3015
+ reserve_space_for_menu=0,
3016
+ auto_suggest=AutoSuggestFromHistory(),
3017
+ key_bindings=shortcuts.key_bindings(keys),
3018
+ mouse_support=False,
3019
+ **kwargs,
3020
+ )
3021
+ session.shortcuts = shortcuts
3022
+ session.command_bindings = (
3023
+ PromptKeymap(
3024
+ shortcuts,
3025
+ registry,
3026
+ on_command,
3027
+ transcript.warning if transcript is not None else lambda message: None,
3028
+ )
3029
+ if on_command is not None
3030
+ else None
3031
+ )
3032
+
3033
+ prompt_layout = PromptLayout(session, activity, transcript, shortcuts, session_title)
3034
+ if transcript is not None:
3035
+
3036
+ def accept(buffer):
3037
+ text = buffer.text
3038
+ buffer.append_to_history()
3039
+ on_submit(text)
3040
+ return False
3041
+
3042
+ session.default_buffer.accept_handler = accept
3043
+ session.layout = prompt_layout.layout()
3044
+ session.app.layout = session.layout
3045
+ if transcript is not None:
3046
+ # The app runs for the whole session, redrawn by streaming output and
3047
+ # animation; cap that at the paced output's frame rate.
3048
+ session.app.min_redraw_interval = 1 / 30
3049
+ session.app.before_render += prompt_layout.before_render
3050
+ session.app.after_render += prompt_layout.after_render
3051
+ if session.app.editing_mode == EditingMode.VI:
3052
+ # Allow terminal escape sequences to arrive, without a half-second pause.
3053
+ session.app.ttimeoutlen = 0.1
3054
+ if transcript is None or not transcript.replays_on_resize:
3055
+ install_reflow_renderer(session.app)
3056
+
3057
+ return session
3058
+
3059
+
3060
+ def tally(succeeded: int, failed: int) -> str:
3061
+ """`✓ 2 ✗ 1`, dropping a zero side, so each mark only ever counts its own outcome."""
3062
+ return " ".join(f"{mark} {count}" for mark, count in (("✓", succeeded), ("✗", failed)) if count)
3063
+
3064
+
3065
+ def tool_count(name: str, count: int, failed: int) -> str:
3066
+ """One tool's part of a group line: `Read file ✓ 3`, `Search code ✓ 2 ✗ 1`."""
3067
+ return f"{name} {tally(count - failed, failed)}"
3068
+
3069
+
3070
+ class Transcript:
3071
+ """Persistent Rich output: anything written here belongs in terminal scrollback.
3072
+
3073
+ Mutable activity and event routing belong to the application, not this writer.
3074
+ """
3075
+
3076
+ def __init__(
3077
+ self,
3078
+ console: Console,
3079
+ theme: str = "dark",
3080
+ *,
3081
+ activity: Activity | None = None,
3082
+ preferences: dict[str, str] | None = None,
3083
+ detected_theme: str | None = None,
3084
+ ) -> None:
3085
+ preferences = load_preferences() if preferences is None else preferences
3086
+ self.error_scrollback_lines = int(preferences.get("error_scrollback_lines", "20"))
3087
+ self.tool_error_scrollback = preferences.get("tool_error_scrollback", "off") == "on"
3088
+ self.show_edits = preferences.get("show_edits", "on") == "on"
3089
+ self.live_edits = preferences.get("live_edits", SETTINGS["live_edits"].default) == "on"
3090
+ self.command_scrollback = preferences.get("show_commands", "off") == "on"
3091
+ self.group_tools = preferences.get("group_tools", SETTINGS["group_tools"].default) == "on"
3092
+ self.command_scrollback_lines = int(preferences.get("command_scrollback_lines", "20"))
3093
+ # Rows, or a share of the screen; resolved against the pane when drawn.
3094
+ self.command_preview_lines = parse_height(
3095
+ preferences.get("command_preview_lines")
3096
+ ) or float(SETTINGS["command_preview_lines"].default)
3097
+ self.activity = activity
3098
+ self.console = console
3099
+ self.theme = theme
3100
+ self.detected_theme = detect_theme() if detected_theme is None else detected_theme
3101
+ self.syntax_themes = syntax_themes(preferences)
3102
+ self._delta = delta_from_preferences(preferences)
3103
+ self.diff_dedent = preferences.get("diff_dedent", SETTINGS["diff_dedent"].default) == "on"
3104
+ self._output: TerminalOutput | None = None
3105
+ self.regenerate_on_resize = preferences.get("regenerate_on_resize", "on") == "on"
3106
+ self.paced_scrollback = preferences.get(
3107
+ "paced_scrollback", SETTINGS["paced_scrollback"].default
3108
+ )
3109
+ self.log = TranscriptLog(
3110
+ max_chars=int(
3111
+ preferences.get("transcript_max_chars", SETTINGS["transcript_max_chars"].default)
3112
+ )
3113
+ )
3114
+ self._replay_sink: list | None = None
3115
+ self._block: str | None = None
3116
+ # A sub-agent's calls settle before its delegate does. They wait here,
3117
+ # keyed by the delegate's call id, to be written beneath it.
3118
+ self._children: dict[str, list[ToolSummary]] = {}
3119
+ # With `group_tools`, the settled calls of the run in progress: they
3120
+ # reach scrollback as one line when anything else is written or the
3121
+ # turn ends, and the live panel counts them until then.
3122
+ self._group: list[ToolSummary] = []
3123
+
3124
+ @property
3125
+ def replays_on_resize(self) -> bool:
3126
+ """Whether a width change rebuilds scrollback instead of reflowing in place."""
3127
+ return self.regenerate_on_resize and self.console.is_terminal
3128
+
3129
+ @property
3130
+ def output(self) -> TerminalOutput | None:
3131
+ return self._output
3132
+
3133
+ @output.setter
3134
+ def output(self, output: TerminalOutput | None) -> None:
3135
+ self._output = output
3136
+ if output is not None:
3137
+ output.commit_user = self.user
3138
+ output.commit_message = self.message
3139
+ output.commit_thinking = self.thinking
3140
+ # Pacing spreads handoffs over frames; only a real application has
3141
+ # either, so offline harnesses and stand-ins write at once.
3142
+ output.paced = (
3143
+ self.paced_scrollback != "off"
3144
+ and self.console.is_terminal
3145
+ and isinstance(output.app, Application)
3146
+ )
3147
+ output.typed = output.paced and self.paced_scrollback == "typed"
3148
+ if self.replays_on_resize:
3149
+ output.resize_replay = self.replay
3150
+
3151
+ @recorded
3152
+ def print(self, *objects, end="\n", tool_line: bool = False) -> None:
3153
+ """Write scrollback, keeping tool lines one block apart from other output."""
3154
+ if self._group:
3155
+ # Anything written after a run of calls closes it, so the run's
3156
+ # line lands where the calls happened.
3157
+ self._flush_group()
3158
+ # Resolve theme-dependent renderables again on every replay.
3159
+ objects = tuple(
3160
+ Markdown(obj.markup, code_theme=self.code_theme)
3161
+ if isinstance(obj, (Markdown, RetainedMarkdown))
3162
+ else replace(obj, code_theme=self.code_theme, delta=self.delta, dedent=self.diff_dedent)
3163
+ if isinstance(obj, EditTranscript)
3164
+ else replace(obj, code_theme=self.code_theme)
3165
+ if isinstance(obj, (TranscriptNotice, CommandTranscript))
3166
+ else obj
3167
+ for obj in objects
3168
+ )
3169
+ block = "tools" if tool_line else "blank" if self._ends_blank(objects) else "other"
3170
+ # Consecutive tool lines stay flush; entering or leaving that run gets a
3171
+ # blank row, unless the preceding write already ended with one.
3172
+ if self._block not in (None, "blank", block) and "tools" in (self._block, block):
3173
+ self._write((), "\n")
3174
+ self._block = block
3175
+ self._write(objects, end)
3176
+
3177
+ @property
3178
+ def ends_blank(self) -> bool:
3179
+ """Whether scrollback already ends with a blank row.
3180
+
3181
+ The live activity panel uses this to keep itself one block apart from
3182
+ the last thing written, the same way consecutive writes do.
3183
+ """
3184
+ return self._block in (None, "blank")
3185
+
3186
+ @staticmethod
3187
+ def _ends_blank(objects: tuple) -> bool:
3188
+ """Report whether this write already leaves a blank row behind it.
3189
+
3190
+ A bare ``print()`` is the usual separator; thinking is the one
3191
+ renderable here that pads itself, so only it needs naming.
3192
+ """
3193
+ return not objects or isinstance(objects[-1], ThinkingMarkdown)
3194
+
3195
+ def _write(self, objects: tuple, end: str) -> None:
3196
+ if self._replay_sink is not None:
3197
+ self._replay_sink.append((objects, end, False))
3198
+ elif self.output is not None:
3199
+ self.output.print(*objects, end=end)
3200
+ else:
3201
+ with self.console.use_theme(self.rich_theme):
3202
+ self.console.print(*objects, end=end)
3203
+
3204
+ @recorded
3205
+ def message(self, text: str) -> None:
3206
+ """Retain a Markdown block and its separator atomically, even at tiny budgets."""
3207
+ self.print(Markdown(text, code_theme=self.code_theme))
3208
+ self.print()
3209
+
3210
+ @recorded
3211
+ def thinking(self, text: str) -> None:
3212
+ """Retain readable provider text, choosing visibility again on every redraw."""
3213
+ if self.activity is not None and self.activity.show_thinking:
3214
+ self.print(ThinkingMarkdown(command_text(text), code_theme=self.code_theme), end="")
3215
+
3216
+ @recorded
3217
+ def tool_result(self, event: ToolSummary) -> None:
3218
+ """Retain hidden results too; choose one representation on each replay."""
3219
+ if event.parent_call_id:
3220
+ if self.summarizes(event):
3221
+ self._children.setdefault(event.parent_call_id, []).append(event)
3222
+ return
3223
+ if self.writes_tool_result(event):
3224
+ self.events((event,))
3225
+ for line in self.child_lines(self._children.pop(event.call_id, []), CHILD_INDENT):
3226
+ self.print(Padding(line, (0, 0, 0, CHILD_INDENT), expand=False), tool_line=True)
3227
+
3228
+ def child_lines(self, children: list[ToolSummary], indent: int = 0) -> list[Text]:
3229
+ """A sub-agent's calls, folded the way the parent's are when grouping."""
3230
+ if self.group_tools:
3231
+ return self.group_lines(children, indent=indent)
3232
+ return [line for child in children for line in self.summary_lines(child, indent=indent)]
3233
+
3234
+ def groups(self, event: ToolSummary) -> bool:
3235
+ """Report whether this call's summary line folds into the run's group line.
3236
+
3237
+ A delegate heads its own calls, and a background job's exit is a
3238
+ delayed notice, not part of the run. A failure folds in like any call.
3239
+ """
3240
+ return self.group_tools and event.name != DELEGATE and event.execution != "background"
3241
+
3242
+ def group_lines(self, events: list[ToolSummary], *, indent: int = 0) -> list[Text]:
3243
+ """Fold a run of calls into one line; a run of one keeps the call's own line."""
3244
+ if len(events) == 1:
3245
+ return self.summary_lines(events[0], indent=indent)
3246
+ if not events:
3247
+ return []
3248
+ return [self.group_line(events, width=max(1, self.console.width - indent))]
3249
+
3250
+ @staticmethod
3251
+ def group_line(events: list[ToolSummary], *, width: int) -> Text:
3252
+ """`✓ 15 tools · Edit file ✓ 10 · Run shell ✓ 5`, most used first.
3253
+
3254
+ Failures split the run's count and their tool's, so a run that hit
3255
+ one still stands out: `✓ 5 ✗ 1 tools · Read file ✓ 3 · Search code ✓ 2 ✗ 1`.
3256
+ """
3257
+ counts = Counter(label(event.name) for event in events)
3258
+ failures = Counter(label(event.name) for event in events if event.failed)
3259
+ failed = failures.total()
3260
+ parts = [tool_count(name, count, failures[name]) for name, count in counts.most_common()]
3261
+ head = tally(len(events) - failed, failed)
3262
+ line = Text(f"{head} tools · " + " · ".join(parts), style="pcode.thinking")
3263
+ line.no_wrap = True
3264
+ line.overflow = "ellipsis"
3265
+ line.truncate(width, overflow="ellipsis")
3266
+ return line
3267
+
3268
+ def _flush_group(self) -> None:
3269
+ group, self._group = self._group, []
3270
+ # The retained tool results already reproduce this line on replay.
3271
+ recording, self.log.recording = self.log.recording, False
3272
+ try:
3273
+ for line in self.group_lines(group):
3274
+ self.print(line, tool_line=True)
3275
+ finally:
3276
+ self.log.recording = recording
3277
+
3278
+ def settle_tools(self) -> None:
3279
+ """Write the pending group line; the turn ended with nothing after it.
3280
+
3281
+ Not recorded: whatever is written next closes the run at the same
3282
+ place, and `replay` closes a trailing one unless it is still live.
3283
+ """
3284
+ if self._group:
3285
+ self._flush_group()
3286
+
3287
+ def pending_group_row(self, width: int) -> str:
3288
+ """The group line so far, for the live panel; empty when nothing is pending."""
3289
+ if not self._group or width < 1:
3290
+ return ""
3291
+ line = self.group_lines(self._group)[-1].copy()
3292
+ line.truncate(width, overflow="ellipsis")
3293
+ return line.plain
3294
+
3295
+ def pending_tally(self) -> str:
3296
+ """The open run's count for the status row: `✓ 7 ✗ 1 tools`, or empty."""
3297
+ if not self._group:
3298
+ return ""
3299
+ failed = sum(event.failed for event in self._group)
3300
+ noun = "tool" if len(self._group) == 1 else "tools"
3301
+ return f"{tally(len(self._group) - failed, failed)} {noun}"
3302
+
3303
+ def settle_orphans(self) -> None:
3304
+ """Write sub-agent calls whose delegate never settled, e.g. a cancelled turn.
3305
+
3306
+ Without a delegate row to sit under they are written flush, so the
3307
+ steps a sub-agent did take are not silently lost.
3308
+ """
3309
+ orphans, self._children = self._children, {}
3310
+ for children in orphans.values():
3311
+ for line in self.child_lines(children):
3312
+ self.print(line, tool_line=True)
3313
+
3314
+ @property
3315
+ def live_edit_previews(self) -> bool:
3316
+ """Whether a not-yet-applied edit is previewed in the live panel."""
3317
+ return self.show_edits and self.live_edits
3318
+
3319
+ @recorded
3320
+ def edit(self, event) -> None:
3321
+ if self.show_edits:
3322
+ self.print(EditTranscript(event))
3323
+
3324
+ def replay(self) -> list:
3325
+ """Project the retained log with current settings, without recording again."""
3326
+ sink = []
3327
+ self._replay_sink = sink
3328
+ self._block = None
3329
+ # Replaying the log's own tool results rebuilds whatever is pending.
3330
+ self._children = {}
3331
+ live_group, self._group = bool(self._group), []
3332
+ self.log.recording = False
3333
+ try:
3334
+ if self.log.dropped:
3335
+ self.note("Earlier transcript entries omitted from this regenerated view.")
3336
+ for entry in self.log.entries:
3337
+ getattr(self, entry.method)(*entry.args, **entry.kwargs)
3338
+ # A run still going stays in the live panel; any other was closed.
3339
+ if not live_group:
3340
+ self.settle_tools()
3341
+ finally:
3342
+ self.log.recording = True
3343
+ self._replay_sink = None
3344
+ return sink
3345
+
3346
+ @contextmanager
3347
+ def restore(self):
3348
+ """Replace history without rendering discarded entries, then redraw once.
3349
+
3350
+ Saved history goes through the same recorded methods and retention budget
3351
+ as live output. Redirected output receives the retained slice once, without
3352
+ terminal escapes; subsequent redraw requests remain a no-op there.
3353
+ """
3354
+ previous = self.log
3355
+ self.log = TranscriptLog(limit=previous.limit, max_chars=previous.max_chars)
3356
+ self.log.capture_only = True
3357
+ # Restored history is settled; nothing from it is still running.
3358
+ self._group = []
3359
+ try:
3360
+ yield
3361
+ except BaseException:
3362
+ self.log = previous
3363
+ raise
3364
+ finally:
3365
+ self.log.capture_only = False
3366
+ if self.output is not None and self.console.is_terminal:
3367
+ self.regenerate()
3368
+ else:
3369
+ for objects, end, _ in self.replay():
3370
+ self._write(objects, end)
3371
+
3372
+ def regenerate(self) -> None:
3373
+ """Request an atomic rebuild; never emit terminal escapes into redirected output."""
3374
+ if self.output is not None and self.console.is_terminal:
3375
+ self.output.regenerate(self.replay)
3376
+
3377
+ def clear(self) -> None:
3378
+ """Drop retained scrollback and rebuild the screen from what comes next.
3379
+
3380
+ The rebuild is deferred to the next flush, so writes made after this
3381
+ call are replayed onto the cleared screen rather than erased with it.
3382
+ """
3383
+ self.log.clear()
3384
+ self.regenerate()
3385
+
3386
+ @property
3387
+ def resolved_theme(self) -> str:
3388
+ return self.detected_theme if self.theme == "auto" else self.theme
3389
+
3390
+ @property
3391
+ def delta(self) -> Delta | None:
3392
+ """delta for diffs, told the current palette; None renders them with Rich."""
3393
+ if self._delta is None:
3394
+ return None
3395
+ return replace(self._delta, light=self.resolved_theme == "light")
3396
+
3397
+ @property
3398
+ def palette(self) -> Palette:
3399
+ return PALETTES[self.resolved_theme]
3400
+
3401
+ @property
3402
+ def terminal_colors(self) -> bool:
3403
+ """Whether `/syntax terminal` hands every color to the terminal's ANSI palette."""
3404
+ return self.syntax_themes[self.resolved_theme] == TERMINAL_SYNTAX
3405
+
3406
+ @property
3407
+ def menu_palette(self) -> Palette:
3408
+ """The palette implied by the syntax style in use, for the popup."""
3409
+ if self.terminal_colors:
3410
+ return TERMINAL_PALETTE
3411
+ return syntax_palette(self.syntax_themes[self.resolved_theme], self.palette)
3412
+
3413
+ @property
3414
+ def chrome_palette(self) -> Palette:
3415
+ """The same style read for text drawn straight onto the terminal.
3416
+
3417
+ The popup brings its own background; the prompt, the plan rows and the
3418
+ frame do not, so the palette's surface stands in for the terminal's
3419
+ background and any color that would be lost against it is dropped.
3420
+ """
3421
+ if self.terminal_colors:
3422
+ return TERMINAL_PALETTE
3423
+ style = self.syntax_themes[self.resolved_theme]
3424
+ return syntax_palette(style, self.palette, self.palette.surface)
3425
+
3426
+ def prompt_style(self, task_style: str = "status") -> Style:
3427
+ """The prompt_toolkit style for the current theme, syntax and task style."""
3428
+ return self.chrome_palette.prompt_style(self.menu_palette, task_style)
3429
+
3430
+ @property
3431
+ def rich_theme(self) -> Theme:
3432
+ return TERMINAL_THEME if self.terminal_colors else self.palette.rich_theme()
3433
+
3434
+ @property
3435
+ def code_theme(self) -> str:
3436
+ return (
3437
+ f"ansi_{self.resolved_theme}"
3438
+ if self.terminal_colors
3439
+ else self.syntax_themes[self.resolved_theme]
3440
+ )
3441
+
3442
+ def welcome(self, model: str | None = None, workspace: str = "") -> None:
3443
+ self.print()
3444
+ self.print(
3445
+ Text.assemble(
3446
+ ("pcode", "pcode.brand"),
3447
+ (f" / {model or 'UI preview'}", "pcode.muted"),
3448
+ )
3449
+ )
3450
+ if model:
3451
+ self.retained_note(f"Coder · workspace: {workspace}")
3452
+ self.retained_note("Live model · file edits and shell tools enabled · not a sandbox")
3453
+ else:
3454
+ self.retained_note("Local only · no model connected · no files or shell tools")
3455
+ self.retained_note(
3456
+ "Type / for commands, /theme-preview for a sample response, /help for keys."
3457
+ )
3458
+ self.print()
3459
+
3460
+ @recorded
3461
+ def syntax_gallery(self) -> None:
3462
+ """Preview every selectable style, marking the one in use.
3463
+
3464
+ Recorded without arguments so a replay re-reads the current palette and
3465
+ saved styles: the marked row still answers "what am I looking at?"
3466
+ after `/theme` or `/syntax` rebuilds scrollback.
3467
+ """
3468
+ self.print(
3469
+ SyntaxGallery(
3470
+ SYNTAX_THEMES,
3471
+ palette=self.resolved_theme,
3472
+ dark=self.syntax_themes["dark"],
3473
+ light=self.syntax_themes["light"],
3474
+ )
3475
+ )
3476
+
3477
+ @recorded
3478
+ def retained_note(self, text: str) -> None:
3479
+ """Show a notice that belongs to scrollback, so a redraw keeps it.
3480
+
3481
+ Most notices answer a keystroke and are dismissed by the next redraw.
3482
+ The opening banner and what it reports about this session are history,
3483
+ not an answer, so a resize must not wipe them.
3484
+ """
3485
+ self.print(Note(text))
3486
+
3487
+ def flash(self, text: str) -> None:
3488
+ """Answer a keystroke in the live panel instead of in scrollback.
3489
+
3490
+ Acknowledgements ("Show thinking: off") are worth a glance and nothing
3491
+ more; writing them to scrollback leaves them between the model's output
3492
+ forever. Without a live panel there is nowhere to put one, so fall back
3493
+ to a plain notice.
3494
+ """
3495
+ if self.activity is None or self.output is None:
3496
+ self.note(text)
3497
+ return
3498
+ self.activity.flash(text)
3499
+ self.output.app.invalidate()
3500
+
3501
+ def note(self, text: str) -> None:
3502
+ """Show an informational notice once, without retaining it for redraws."""
3503
+ notice = Note(text)
3504
+ if self._replay_sink is not None:
3505
+ self._replay_sink.append(((notice,), "\n", False))
3506
+ elif self.output is not None:
3507
+ # Preserve new notices across an already queued redraw, but never
3508
+ # replay notices that have previously reached the terminal.
3509
+ self.output.print(notice, transient=True)
3510
+ else:
3511
+ with self.console.use_theme(self.rich_theme):
3512
+ self.console.print(notice)
3513
+
3514
+ @recorded
3515
+ def error(self, text: str, *, title: str = "Error") -> None:
3516
+ self.print(
3517
+ TranscriptNotice(text, "error", title, self.error_scrollback_lines, self.code_theme)
3518
+ )
3519
+
3520
+ def streams_command(self, event: Event) -> bool:
3521
+ """Report whether this settled tool will be mirrored into scrollback.
3522
+
3523
+ Captured output is the bulky part, so mirroring a failed command also
3524
+ needs the failure option; without it the completion still shows, as the
3525
+ summary line a successful call would leave.
3526
+ """
3527
+ if not isinstance(event, ToolSummary) or event.name not in COMMAND_TOOLS:
3528
+ return False
3529
+ return self.command_scrollback and (self.tool_error_scrollback or not event.failed)
3530
+
3531
+ def writes_tool_result(self, event: Event) -> bool:
3532
+ """Report whether this settled tool is written to scrollback as it arrives.
3533
+
3534
+ A sub-agent's call is not: it waits to be written beneath its delegate.
3535
+ """
3536
+ return (
3537
+ isinstance(event, ToolSummary) and not event.parent_call_id and self.summarizes(event)
3538
+ )
3539
+
3540
+ def summarizes(self, event: Event) -> bool:
3541
+ """Report whether this settled tool reaches scrollback at all.
3542
+
3543
+ Omit calls whose results already have a home: planning in the task
3544
+ panel, shown edits in their diff, and job inspection in the job's own
3545
+ completion notice. Actual tool errors still get their own entry; only
3546
+ how much of them, a summary or diagnostic, is configurable.
3547
+ """
3548
+ if not isinstance(event, ToolSummary):
3549
+ return False
3550
+ # Inspecting/waiting on a job is not another command completion. A
3551
+ # nonzero job exit sets `failed`, but the helper call itself succeeded;
3552
+ # only actual helper errors/retries need their own scrollback entry.
3553
+ if event.name in {"wait_for_job", "job_output"} and event.outcome == "success":
3554
+ return False
3555
+ # A command always leaves at least the summary line every other tool
3556
+ # leaves; `show_commands` governs only its mirrored output.
3557
+ if event.name in COMMAND_TOOLS:
3558
+ return True
3559
+ if event.failed:
3560
+ return True
3561
+ return event.name not in PLAN_TOOLS and not (event.name in EDIT_TOOLS and self.show_edits)
3562
+
3563
+ @recorded
3564
+ def command_output(self, event: ToolSummary) -> bool:
3565
+ """Mirror a command and its captured output; report whether anything printed."""
3566
+ if not self.streams_command(event):
3567
+ return False
3568
+ invocation = (
3569
+ command_text(event.command) if event.command else plain(event.detail, limit=None)
3570
+ )
3571
+ # Live results arrive redacted and length-bounded from the capture step;
3572
+ # sanitize again so replayed or synthesized events cannot emit controls.
3573
+ output = command_text(event.result or event.error or "").rstrip("\n")
3574
+ # The job's closing marker repeats the heading's marker and elapsed
3575
+ # time; only its id is new, so the heading takes that and drops the rest.
3576
+ output, job = split_outcome(output)
3577
+ output = output.rstrip("\n")
3578
+ if not output.strip():
3579
+ output = "(no output)"
3580
+ title = label(event.name)
3581
+ if event.execution == "background":
3582
+ # This is the delayed exit notice, not just a tool result: keep the
3583
+ # exact outcome even when the footer is absorbed into the heading.
3584
+ title += " · " + plain(event.detail.rsplit(" → ", 1)[-1], limit=None)
3585
+ elif job:
3586
+ title += f" · {job}"
3587
+ self.print(
3588
+ CommandTranscript(
3589
+ command=invocation,
3590
+ output=output,
3591
+ # The purpose belongs in the title, not the command line: that
3592
+ # line is syntax-highlighted as shell and should stay runnable.
3593
+ title=f"{title} · {event.purpose}" if event.purpose else title,
3594
+ failed=event.failed,
3595
+ elapsed_seconds=event.elapsed_seconds,
3596
+ max_lines=self.command_scrollback_lines,
3597
+ code_theme=self.code_theme,
3598
+ shell_command=bool(event.command),
3599
+ )
3600
+ )
3601
+ return True
3602
+
3603
+ @recorded
3604
+ def shell_result(
3605
+ self, command: str, output: str, *, failed: bool, elapsed_seconds: float | None
3606
+ ) -> None:
3607
+ """Mirror a `!command` the user ran; always shown, since they asked for it."""
3608
+ output = command_text(output).rstrip("\n")
3609
+ self.print(
3610
+ CommandTranscript(
3611
+ command=command_text(command),
3612
+ output=output if output.strip() else "(no output)",
3613
+ title="Shell",
3614
+ failed=failed,
3615
+ elapsed_seconds=elapsed_seconds,
3616
+ max_lines=self.command_scrollback_lines,
3617
+ code_theme=self.code_theme,
3618
+ )
3619
+ )
3620
+
3621
+ def warning(self, text: str) -> None:
3622
+ self.print(TranscriptNotice(text, "warning", "Warning"))
3623
+
3624
+ @recorded
3625
+ def cancelled(self) -> None:
3626
+ self.settle_orphans()
3627
+ self.print(TranscriptNotice("", "cancelled", "Run cancelled"))
3628
+
3629
+ @recorded
3630
+ def user(self, text: str) -> None:
3631
+ self.settle_orphans()
3632
+ self.print()
3633
+ self.print(TaskPrompt(text))
3634
+ self.print()
3635
+
3636
+ def summary_lines(self, event: ToolSummary, *, indent: int = 0) -> list[Text]:
3637
+ """The compact rows a settled call leaves in scrollback."""
3638
+ if event.name not in COMMAND_TOOLS and not event.command:
3639
+ return [
3640
+ Text.assemble(
3641
+ (f"{'✗' if event.failed else '✓'} {label(event.name)} ", "pcode.thinking"),
3642
+ (plain(event.detail, limit=None), "pcode.thinking"),
3643
+ (
3644
+ f" {event.elapsed_seconds:.1f}s"
3645
+ if event.elapsed_seconds is not None
3646
+ else "",
3647
+ "pcode.thinking",
3648
+ ),
3649
+ )
3650
+ ]
3651
+ # Scrollback shows the outcome only: the live panel already named the
3652
+ # target while the call ran. The session browser, which has no such
3653
+ # panel, passes the whole detail to the same renderer.
3654
+ result = (
3655
+ " · " + plain(event.detail.rsplit(" → ", 1)[-1], limit=60)
3656
+ if event.failed or (event.name != "run_command" and " → " in event.detail)
3657
+ else ""
3658
+ )
3659
+ return tool_summary_lines(
3660
+ event.name,
3661
+ result,
3662
+ failed=event.failed,
3663
+ elapsed_seconds=event.elapsed_seconds,
3664
+ command=event.command,
3665
+ width=max(1, self.console.width - indent),
3666
+ )
3667
+
3668
+ def command_summary(self, event: ToolSummary) -> None:
3669
+ """Write a settled call's summary line, or hold it for the run's group line."""
3670
+ if self.groups(event):
3671
+ self._group.append(event)
3672
+ return
3673
+ for line in self.summary_lines(event):
3674
+ self.print(line, tool_line=True)
3675
+
3676
+ @recorded
3677
+ def events(self, events: tuple[Event, ...], *, show_tools: bool = False) -> None:
3678
+ for event in events:
3679
+ if isinstance(event, CacheBust):
3680
+ # Informational, like a retained note: muted, no marker or title.
3681
+ self.print(Text(command_text(event.text), style="pcode.muted"))
3682
+ elif isinstance(event, Thinking):
3683
+ self.thinking(event.text.rstrip("\n") + "\n\n")
3684
+ elif isinstance(event, Message):
3685
+ self.message(event.markdown)
3686
+ elif isinstance(event, ToolSummary):
3687
+ if event.name in COMMAND_TOOLS:
3688
+ # Mirroring owns command completions. A call whose
3689
+ # captured output is withheld still reports itself.
3690
+ if not self.command_output(event):
3691
+ self.command_summary(event)
3692
+ continue
3693
+ if event.failed and self.tool_error_scrollback:
3694
+ detail = (
3695
+ command_preview(event.command)
3696
+ if event.command
3697
+ else plain(event.detail, limit=None)
3698
+ )
3699
+ if event.error:
3700
+ detail += "\n" + "\n".join(
3701
+ plain(line, limit=None) for line in event.error.splitlines()
3702
+ )
3703
+ self.error(detail, title=f"{label(event.name)} failed")
3704
+ continue
3705
+ if event.command:
3706
+ if show_tools:
3707
+ self.print(Text(f"{label(event.name)} · {plain(event.detail, limit=None)}"))
3708
+ self.print(Text(command_text(event.command)))
3709
+ else:
3710
+ self.command_summary(event)
3711
+ continue
3712
+ self.command_summary(event)
3713
+
3714
+ def help(self, registry: CommandRegistry, shortcut: Callable[[str], str] | None = None) -> None:
3715
+ """List the commands, then the keys; ``shortcut`` spells the prompt's own."""
3716
+ key = shortcut or shortcut_label
3717
+ previous = key("^") + (" (Ctrl+6)" if key("^") == "Ctrl+^" else "")
3718
+ table = Table(box=None, padding=(0, 2), show_header=False)
3719
+ table.add_column(style="pcode.accent", no_wrap=True)
3720
+ table.add_column()
3721
+ for group, commands in registry.grouped():
3722
+ table.add_row(Text(group, style="pcode.muted"), "")
3723
+ for command in commands:
3724
+ name = " ".join((command.name, *command.aliases))
3725
+ table.add_row(name, command.description)
3726
+ self.print(table)
3727
+ self.print()
3728
+ self.note("Enter send · ↓ on last line or Ctrl+J newline · Tab/↑/↓ complete")
3729
+ self.note("Enter accepts a selected completion; press again to send.")
3730
+ self.note(
3731
+ f"{key('o')} tasks widget · {key('t')} thinking · {key('g')} command output "
3732
+ "(each redraws)"
3733
+ )
3734
+ self.note(
3735
+ f"{key('l')} choose model · {key('n')} raise effort · {key('p')} lower effort "
3736
+ "(next turn)"
3737
+ )
3738
+ self.note(f"{previous} back to the previous session (/switch -)")
3739
+ self.note(f"{key('y')} copy the draft · Ctrl+C discard input · Ctrl+D exit on empty input")
3740
+ self.note(
3741
+ f"During a run: Enter sends · {key('s')} picks steering/queue/interrupt for the "
3742
+ "next send. Ctrl+C discards a draft first, then cancels · Ctrl+D cancels, keeps draft."
3743
+ )
3744
+ self.note("Cancellation clears queued messages. Use terminal/tmux scrollback for history.")
3745
+ self.print()