pcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/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()
|