python-agent-harness 1.5.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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Rendering helpers and mixin for the TUI.
|
|
2
|
+
|
|
3
|
+
Contains all text-trimming helpers, final-check / reasoning strippers,
|
|
4
|
+
and the RenderMixin that provides conversation panel, status bar,
|
|
5
|
+
Todos panel, and scrollback dump rendering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from rich.cells import cell_len
|
|
17
|
+
from rich.console import Console, Group
|
|
18
|
+
from rich.markdown import Markdown
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from .. import config
|
|
24
|
+
from ..diffrender import render_diff
|
|
25
|
+
from ..prompts import _is_mode_reminder_text
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import threading
|
|
29
|
+
|
|
30
|
+
from ..session import Session
|
|
31
|
+
from .input import UiQuestion
|
|
32
|
+
|
|
33
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
34
|
+
|
|
35
|
+
# message-body colors - distinct from tool colors (tool calls are magenta,
|
|
36
|
+
# tool results dim) so roles never blend into tool activity
|
|
37
|
+
USER_STYLE = "cyan"
|
|
38
|
+
ASSISTANT_STYLE = "green"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tail_lines(text: str, n: int) -> str:
|
|
42
|
+
"""Keep the last N lines of TEXT, marking the cut with an ellipsis."""
|
|
43
|
+
lines = text.splitlines()
|
|
44
|
+
if len(lines) <= n:
|
|
45
|
+
return text
|
|
46
|
+
return "…\n" + "\n".join(lines[-n:])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tail_chars(text: str, n: int) -> str:
|
|
50
|
+
"""Keep the last N chars of TEXT, marking the cut with an ellipsis."""
|
|
51
|
+
if len(text) <= n:
|
|
52
|
+
return text
|
|
53
|
+
return "…" + text[-n:]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _head_lines(text: str, n: int) -> str:
|
|
57
|
+
"""Keep the first N lines of TEXT, marking the cut with a count."""
|
|
58
|
+
lines = text.splitlines()
|
|
59
|
+
if len(lines) <= n:
|
|
60
|
+
return text
|
|
61
|
+
return "\n".join(lines[:n]) + f"\n… [{len(lines) - n} more lines]"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _head_chars(text: str, n: int) -> str:
|
|
65
|
+
"""Keep the first N chars of TEXT, marking the cut with an ellipsis."""
|
|
66
|
+
if len(text) <= n:
|
|
67
|
+
return text
|
|
68
|
+
return text[:n] + "…"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _tool_result_preview(content: str) -> str:
|
|
72
|
+
"""Preview of a tool result: first N lines, capped at N chars.
|
|
73
|
+
|
|
74
|
+
Tool results (file reads, command output) can be huge — showing only
|
|
75
|
+
a few lines keeps the TUI fast and readable. The beginning is kept
|
|
76
|
+
(it carries the result/error); the cut is marked explicitly.
|
|
77
|
+
"""
|
|
78
|
+
preview = _head_lines(content, config.TOOL_RESULT_PREVIEW_LINES)
|
|
79
|
+
return _head_chars(preview, config.TOOL_RESULT_PREVIEW_CHARS)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# the completion-check filter: a FINAL CHECK header followed by the
|
|
83
|
+
# Goal/Status/Evidence labels (anywhere in the block, any lines).
|
|
84
|
+
#
|
|
85
|
+
# Models reformat the block from task-completion-rules.md freely, so the
|
|
86
|
+
# pattern must tolerate markdown decoration. Seen in the wild:
|
|
87
|
+
# "[FINAL CHECK]", "**[FINAL CHECK]**", "## Final Check", and labels as
|
|
88
|
+
# "Goal:", "**Goal:**" or "**Goal**:" (colon outside the emphasis) —
|
|
89
|
+
# the last variant has no literal "Goal:" in it, which is what made the
|
|
90
|
+
# old literal pattern miss and leak the block into the panel.
|
|
91
|
+
#
|
|
92
|
+
# The header must be bracketed or start its own line: that keeps prose
|
|
93
|
+
# like "let me do the final check" from truncating a real reply.
|
|
94
|
+
_FC_LABEL = r"[*_`]*[ \t]*:" # "Goal:", "**Goal:**", "**Goal**:", "`Goal` :"
|
|
95
|
+
_FC_HEADER = (
|
|
96
|
+
r"(?:"
|
|
97
|
+
r"(?:\*\*|__|#{1,6}[ \t]*)?" # decoration before a bracketed header
|
|
98
|
+
r"\[[ \t]*final[ \t_]*check[ \t]*\]" # [FINAL CHECK], bracketed anywhere
|
|
99
|
+
r"|(?:^|\n)[ \t]*(?:#{1,6}[ \t]*)?(?:\*\*|__)?[ \t]*"
|
|
100
|
+
r"final[ \t_]+check\b" # ## Final Check / **FINAL CHECK**, line-anchored
|
|
101
|
+
r")"
|
|
102
|
+
)
|
|
103
|
+
_FINAL_CHECK_RE = re.compile(
|
|
104
|
+
_FC_HEADER + rf".*?Goal{_FC_LABEL}.*?Status{_FC_LABEL}.*?Evidence{_FC_LABEL}",
|
|
105
|
+
re.DOTALL | re.IGNORECASE,
|
|
106
|
+
)
|
|
107
|
+
# a line left holding nothing but markdown decoration once the block is
|
|
108
|
+
# cut away (e.g. the "> " or "**" in front of a decorated header)
|
|
109
|
+
_FC_DANGLING_RE = re.compile(r"(?:^|\n)[ \t]*[*_#>`\-]+[ \t]*$")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_injected_user_text(text: str) -> bool:
|
|
113
|
+
"""True for harness-injected user messages (not user input).
|
|
114
|
+
|
|
115
|
+
Live sessions flag injected messages directly; these content checks
|
|
116
|
+
cover restored sessions, where the flag is lost in the markdown
|
|
117
|
+
round-trip: the completion nudge, the <system-reminder>-wrapped
|
|
118
|
+
plan/build-switch prompts (plan.md, plan-mode.md, build-switch.md
|
|
119
|
+
all start with the reminder tag), and the plan-exit approval notice.
|
|
120
|
+
"""
|
|
121
|
+
if text == config.NUDGE_MESSAGE:
|
|
122
|
+
return True
|
|
123
|
+
return _is_mode_reminder_text(text)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _strip_final_check(text: str) -> str:
|
|
127
|
+
"""Drop a completion-check block from an assistant reply.
|
|
128
|
+
|
|
129
|
+
The task-completion rules make the model end with a [FINAL CHECK]
|
|
130
|
+
block (Goal / Status / Evidence) — verification bookkeeping, not
|
|
131
|
+
content the user wants to read. The filter is ``_FINAL_CHECK_RE``
|
|
132
|
+
(header + the three labels, markdown decoration tolerated):
|
|
133
|
+
everything from the header onward is dropped.
|
|
134
|
+
|
|
135
|
+
The block is hidden even when it is the reply's ONLY content —
|
|
136
|
+
check-only replies never render. Replies without the header are
|
|
137
|
+
untouched. The agent loop still produces and stores the message
|
|
138
|
+
unchanged; this only trims it from the TUI display.
|
|
139
|
+
"""
|
|
140
|
+
# Fast path: the regex below is expensive on large buffers (the live
|
|
141
|
+
# stream row can be ~100K chars and is re-stripped every frame), so
|
|
142
|
+
# gate it behind a cheap substring check. The header always contains
|
|
143
|
+
# both words, so a miss means no block to strip.
|
|
144
|
+
low = text.lower()
|
|
145
|
+
if "final" not in low or "check" not in low:
|
|
146
|
+
return text
|
|
147
|
+
m = _FINAL_CHECK_RE.search(text)
|
|
148
|
+
if m is not None:
|
|
149
|
+
head = text[: m.start()].rstrip()
|
|
150
|
+
return _FC_DANGLING_RE.sub("", head).rstrip()
|
|
151
|
+
return text
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _strip_reasoning(text: str, reasoning: str) -> str:
|
|
155
|
+
"""Remove the leading REASONING block from TEXT, or TEXT unchanged.
|
|
156
|
+
|
|
157
|
+
Reasoning content is streamed before the answer, so it forms the
|
|
158
|
+
leading part of the stored message content. The TUI collapses it
|
|
159
|
+
to a marker once the stream is done, so it stops eating the
|
|
160
|
+
visible-row budget; the stored message is never modified.
|
|
161
|
+
"""
|
|
162
|
+
if not reasoning:
|
|
163
|
+
return text
|
|
164
|
+
if text.startswith(reasoning):
|
|
165
|
+
return text[len(reasoning) :]
|
|
166
|
+
stripped = text.lstrip()
|
|
167
|
+
if stripped.startswith(reasoning):
|
|
168
|
+
return stripped[len(reasoning) :]
|
|
169
|
+
return text
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class RenderMixin:
|
|
173
|
+
"""Rendering methods for the TUI.
|
|
174
|
+
|
|
175
|
+
Expects the host class to provide: ``session``, ``console``,
|
|
176
|
+
``stream_text``, ``lock``, ``question``, ``agent_running``,
|
|
177
|
+
``status``, ``_current_tool``, ``round_start``, ``round_user_text``,
|
|
178
|
+
``_data_event``, ``_history_cache``, ``_history_dirty``,
|
|
179
|
+
``_round_times``, ``_run_start``.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
if TYPE_CHECKING:
|
|
183
|
+
session: Session
|
|
184
|
+
console: Console
|
|
185
|
+
stream_text: str
|
|
186
|
+
lock: threading.Lock
|
|
187
|
+
question: UiQuestion | None
|
|
188
|
+
agent_running: bool
|
|
189
|
+
status: str
|
|
190
|
+
_current_tool: str
|
|
191
|
+
round_start: int
|
|
192
|
+
round_user_text: str
|
|
193
|
+
_data_event: threading.Event
|
|
194
|
+
_history_cache: list[Any] | None
|
|
195
|
+
_history_dirty: bool
|
|
196
|
+
_round_times: list[float]
|
|
197
|
+
_run_start: float | None
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# rendering
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
def _build_history_rows(self, full: bool = False) -> list[Any]:
|
|
203
|
+
"""Rows for the stored conversation (messages + todos). No stream.
|
|
204
|
+
|
|
205
|
+
FULL=True renders the WHOLE conversation with uncapped
|
|
206
|
+
user/assistant bodies (the post-run scrollback dump, where every
|
|
207
|
+
round must be readable). FULL=False renders only the LATEST
|
|
208
|
+
round — the messages from ``self.round_start`` onward — with the
|
|
209
|
+
tail caps, so the live panel focuses on the current interaction
|
|
210
|
+
instead of replaying every previous round.
|
|
211
|
+
"""
|
|
212
|
+
rows: list[Any] = []
|
|
213
|
+
calls_by_id: dict[str, Any] = {}
|
|
214
|
+
all_messages = self.session.last_messages or []
|
|
215
|
+
# tool-call lookup spans the whole conversation so a diff still
|
|
216
|
+
# resolves even if its call landed in an earlier round
|
|
217
|
+
for m in all_messages:
|
|
218
|
+
if m.role == "assistant" and m.tool_calls:
|
|
219
|
+
for tc in m.tool_calls:
|
|
220
|
+
calls_by_id[tc.id] = tc
|
|
221
|
+
|
|
222
|
+
if full:
|
|
223
|
+
messages = all_messages
|
|
224
|
+
else:
|
|
225
|
+
# clamp: compaction / clear / restore can shrink
|
|
226
|
+
# last_messages below the recorded boundary
|
|
227
|
+
start = min(self.round_start, len(all_messages))
|
|
228
|
+
messages = all_messages[start:]
|
|
229
|
+
# before the round's user message is mirrored into
|
|
230
|
+
# last_messages (during the first assistant stream), show
|
|
231
|
+
# the text the user just submitted so the round isn't blank
|
|
232
|
+
if len(all_messages) <= self.round_start and self.round_user_text:
|
|
233
|
+
body = _tail_lines(self.round_user_text, 12)
|
|
234
|
+
if body.strip():
|
|
235
|
+
rows.append(Markdown(f"**user:** {body}", style=USER_STYLE))
|
|
236
|
+
|
|
237
|
+
for m in messages:
|
|
238
|
+
# compacted summaries live in the user turn (system prompt is
|
|
239
|
+
# separate); match on content so both live sessions and
|
|
240
|
+
# restored files (role lost in the markdown round-trip) render
|
|
241
|
+
if m.text().startswith("**[Compacted Summary]**"):
|
|
242
|
+
rows.append(Text("📦 " + _tail_chars(m.text(), 200), style="dim italic"))
|
|
243
|
+
continue
|
|
244
|
+
if m.role == "user":
|
|
245
|
+
# harness-injected messages (completion nudge,
|
|
246
|
+
# plan/build-mode prompts, build-switch notices,
|
|
247
|
+
# plan-exit approval): they drive the agent loop, but the
|
|
248
|
+
# user never typed them — keep them out of the
|
|
249
|
+
# conversation panel. The flag covers live sessions; the
|
|
250
|
+
# content checks catch restored sessions, where the flag
|
|
251
|
+
# is lost in the markdown round-trip.
|
|
252
|
+
if m.injected or _is_injected_user_text(m.text()):
|
|
253
|
+
continue
|
|
254
|
+
body = m.text() if full else _tail_lines(m.text(), 12)
|
|
255
|
+
if body.strip():
|
|
256
|
+
rows.append(Markdown(f"**user:** {body}", style=USER_STYLE))
|
|
257
|
+
elif m.role == "assistant":
|
|
258
|
+
body = m.text()
|
|
259
|
+
collapsed_reasoning = False
|
|
260
|
+
if isinstance(m.reasoning, str) and m.reasoning:
|
|
261
|
+
stripped = _strip_reasoning(body, m.reasoning)
|
|
262
|
+
if stripped != body:
|
|
263
|
+
body = stripped
|
|
264
|
+
collapsed_reasoning = True
|
|
265
|
+
body = _strip_final_check(body)
|
|
266
|
+
if not full:
|
|
267
|
+
body = _tail_lines(body, 12)
|
|
268
|
+
if collapsed_reasoning:
|
|
269
|
+
# the reasoning streamed live while it was being
|
|
270
|
+
# produced; once it is done it collapses to a marker
|
|
271
|
+
# so it doesn't eat the visible-row budget
|
|
272
|
+
rows.append(Text("reasoning ...", style="dim"))
|
|
273
|
+
if m.tool_calls:
|
|
274
|
+
for tc in m.tool_calls:
|
|
275
|
+
args = tc.arguments
|
|
276
|
+
if isinstance(args, str):
|
|
277
|
+
try:
|
|
278
|
+
args = json.loads(args)
|
|
279
|
+
except (json.JSONDecodeError, ValueError):
|
|
280
|
+
args = {}
|
|
281
|
+
if isinstance(args, dict):
|
|
282
|
+
params = " ".join(
|
|
283
|
+
f"{k}={v!r}"
|
|
284
|
+
for k, v in args.items()
|
|
285
|
+
if k != "content" and len(repr(v)) < 80
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
params = ""
|
|
289
|
+
label = f"tool: {tc.name}({params})" if params else f"tool: {tc.name}"
|
|
290
|
+
rows.append(Text(f"▶ {label}", style="magenta"))
|
|
291
|
+
if body.strip():
|
|
292
|
+
rows.append(Markdown(f"**assistant:** {body}", style=ASSISTANT_STYLE))
|
|
293
|
+
elif m.role == "tool":
|
|
294
|
+
preview = _tool_result_preview(m.text())
|
|
295
|
+
name = (m.name or "tool").lower()
|
|
296
|
+
call = calls_by_id.get(m.tool_call_id)
|
|
297
|
+
# tool failures surface as "Error: ..." results (agent
|
|
298
|
+
# containment, missing args, MCP-reported errors)
|
|
299
|
+
failed = (m.text() or "").startswith("Error")
|
|
300
|
+
marker = "✗" if failed else "✓"
|
|
301
|
+
marker_style = "bold red" if failed else "green"
|
|
302
|
+
elapsed = ""
|
|
303
|
+
if call is not None and call.elapsed is not None:
|
|
304
|
+
elapsed = f" ({call.elapsed:.1f}s)"
|
|
305
|
+
row = Text(style="dim")
|
|
306
|
+
row.append(f"{marker} {name} result{elapsed}:", style=marker_style)
|
|
307
|
+
row.append(f"\n{preview}")
|
|
308
|
+
rows.append(row)
|
|
309
|
+
if call is not None and call.diff:
|
|
310
|
+
rows.append(render_diff(call.diff))
|
|
311
|
+
return rows
|
|
312
|
+
|
|
313
|
+
def _todos_panel(self) -> Group | None:
|
|
314
|
+
"""Todos section — rebuilt every frame (not cached), so a
|
|
315
|
+
TodoWrite call shows up immediately even mid-run."""
|
|
316
|
+
if not self.session.todos:
|
|
317
|
+
return None
|
|
318
|
+
t = Table.grid(padding=(0, 1))
|
|
319
|
+
for todo in self.session.todos[-8:]:
|
|
320
|
+
status = todo.get("status", "")
|
|
321
|
+
mark = {"completed": "✅", "in_progress": "⏳", "pending": "⬜"}.get(status, "•")
|
|
322
|
+
t.add_row(mark, todo.get("content", ""))
|
|
323
|
+
return Group(Text("Todos", style="bold"), t)
|
|
324
|
+
|
|
325
|
+
def _history_rows(self) -> list[Any]:
|
|
326
|
+
"""Cached history rows; rebuilt only when the conversation changes.
|
|
327
|
+
|
|
328
|
+
During streaming only the stream row changes, so we must NOT
|
|
329
|
+
rebuild (and re-parse Markdown for) the whole conversation every
|
|
330
|
+
frame — that cost is what made the scroll lag behind the text.
|
|
331
|
+
"""
|
|
332
|
+
if self._history_dirty or self._history_cache is None:
|
|
333
|
+
self._history_cache = self._build_history_rows()
|
|
334
|
+
self._history_dirty = False
|
|
335
|
+
return list(self._history_cache)
|
|
336
|
+
|
|
337
|
+
def _stream_row(self) -> Text | None:
|
|
338
|
+
"""Live stream row (cheap Text, tail-capped)."""
|
|
339
|
+
with self.lock:
|
|
340
|
+
stream = self.stream_text
|
|
341
|
+
stream = _strip_final_check(stream)
|
|
342
|
+
if not stream:
|
|
343
|
+
return None
|
|
344
|
+
cap = self._visible_row_cap()
|
|
345
|
+
width = getattr(self.console, "width", None) or 80
|
|
346
|
+
lines = max(3, cap - 3)
|
|
347
|
+
preview = _tail_lines(stream, lines)
|
|
348
|
+
preview = _tail_chars(preview, lines * max(1, width))
|
|
349
|
+
row = Text(f"assistant: {preview}", style=ASSISTANT_STYLE)
|
|
350
|
+
# blinking block cursor, 2 Hz phase (same clock as the spinner)
|
|
351
|
+
if int(time.time() * 2) % 2 == 0:
|
|
352
|
+
row.append("▍")
|
|
353
|
+
return row
|
|
354
|
+
|
|
355
|
+
def _render_conversation(self) -> Group | Text:
|
|
356
|
+
rows = self._history_rows()
|
|
357
|
+
stream_row = self._stream_row()
|
|
358
|
+
if stream_row is not None:
|
|
359
|
+
rows.append(stream_row)
|
|
360
|
+
rows = self._apply_budget(rows)
|
|
361
|
+
return Group(*rows) if rows else Text("(empty)")
|
|
362
|
+
|
|
363
|
+
def _apply_budget(self, rows: list[Any]) -> list[Any]:
|
|
364
|
+
"""Keep the NEWEST rows that fit the visible terminal area.
|
|
365
|
+
|
|
366
|
+
rich's Live crops a too-tall frame from the bottom, which would
|
|
367
|
+
hide exactly the rows that matter (the latest progress), so we
|
|
368
|
+
drop old rows first and keep the newest content on screen.
|
|
369
|
+
"""
|
|
370
|
+
width = getattr(self.console, "width", None) or 80
|
|
371
|
+
budget = self._visible_row_cap()
|
|
372
|
+
kept: list[Any] = []
|
|
373
|
+
for row in reversed(rows):
|
|
374
|
+
est = self._est_lines(row, width)
|
|
375
|
+
if budget - est < 0:
|
|
376
|
+
continue # older rows are dropped once the budget is spent
|
|
377
|
+
kept.append(row)
|
|
378
|
+
budget -= est
|
|
379
|
+
return kept[::-1]
|
|
380
|
+
|
|
381
|
+
def _round_time(self, round_no: int) -> str | None:
|
|
382
|
+
"""Formatted HH:MM:SS start time of round N, if recorded.
|
|
383
|
+
|
|
384
|
+
Live runs record their start times here; for restored sessions
|
|
385
|
+
the times come back from the persisted session metadata
|
|
386
|
+
(``store.round_times``).
|
|
387
|
+
"""
|
|
388
|
+
idx = round_no - 1
|
|
389
|
+
times = self._round_times or self.session.store.round_times
|
|
390
|
+
if 0 <= idx < len(times):
|
|
391
|
+
return time.strftime("%H:%M:%S", time.localtime(times[idx]))
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
def _dump_conversation(self) -> None:
|
|
395
|
+
"""Print the full conversation into the terminal scrollback.
|
|
396
|
+
|
|
397
|
+
The Live display overwrites its frames in place and the frame
|
|
398
|
+
budget drops anything that doesn't fit the visible area, so the
|
|
399
|
+
conversation never reaches the terminal's scrollback during a
|
|
400
|
+
run. When the run finishes, print it again as plain lines so
|
|
401
|
+
the user can scroll back through everything that happened.
|
|
402
|
+
|
|
403
|
+
Each user message starts a new round; rounds after the first
|
|
404
|
+
are separated by a rule line (with the round's start time when
|
|
405
|
+
it was recorded live).
|
|
406
|
+
|
|
407
|
+
Unlike the live panel, message bodies are printed UNCAPPED
|
|
408
|
+
(``full=True``): the live panel tail-caps long replies to the
|
|
409
|
+
newest lines, but the dump is where the whole answer becomes
|
|
410
|
+
readable — a capped dump would hide the head of long
|
|
411
|
+
summaries exactly like the live view.
|
|
412
|
+
"""
|
|
413
|
+
rows = self._build_history_rows(full=True)
|
|
414
|
+
if not rows:
|
|
415
|
+
return
|
|
416
|
+
self.console.print()
|
|
417
|
+
self.console.print("[dim]— full conversation —[/dim]")
|
|
418
|
+
round_no = 0
|
|
419
|
+
for row in rows:
|
|
420
|
+
# a displayed user row starts a new round (injected prompts
|
|
421
|
+
# are already filtered out of the rows)
|
|
422
|
+
if isinstance(row, Markdown) and getattr(row, "markup", "").startswith("**user:**"):
|
|
423
|
+
round_no += 1
|
|
424
|
+
if round_no > 1:
|
|
425
|
+
title = f"round {round_no}"
|
|
426
|
+
ts = self._round_time(round_no)
|
|
427
|
+
if ts is not None:
|
|
428
|
+
title += f" · {ts}"
|
|
429
|
+
self.console.rule(title, style="dim")
|
|
430
|
+
self.console.print(row)
|
|
431
|
+
# report the total time spent on this run, mirroring the per-tool
|
|
432
|
+
# elapsed shown on tool results
|
|
433
|
+
if self._run_start is not None:
|
|
434
|
+
elapsed = time.time() - self._run_start
|
|
435
|
+
self.console.print(f"[green]✓ time spent ({elapsed:.1f}s):[/green]")
|
|
436
|
+
|
|
437
|
+
def _visible_row_cap(self) -> int:
|
|
438
|
+
"""Max conversation rows that fit the visible terminal area.
|
|
439
|
+
|
|
440
|
+
Uses the live terminal height when known (rich reports None for
|
|
441
|
+
non-terminals, e.g. tests), reserving lines for the status bar,
|
|
442
|
+
the input prompt and the pinned Todos section when visible.
|
|
443
|
+
"""
|
|
444
|
+
height = getattr(self.console, "height", None)
|
|
445
|
+
if not height or height <= 0:
|
|
446
|
+
return 60
|
|
447
|
+
# reserve: status bar (1) + input prompt (1)
|
|
448
|
+
# + the pinned Todos section when visible (its title line + rows)
|
|
449
|
+
reserved = 2
|
|
450
|
+
if self.session.todos:
|
|
451
|
+
reserved += min(len(self.session.todos), 8) + 1
|
|
452
|
+
return max(5, height - reserved)
|
|
453
|
+
|
|
454
|
+
@staticmethod
|
|
455
|
+
def _est_lines(row: Any, width: int) -> int:
|
|
456
|
+
"""Rough wrapped-line estimate for a row (used for the budget)."""
|
|
457
|
+
if isinstance(row, Text):
|
|
458
|
+
return max(1, math.ceil(len(row.plain) / max(1, width)))
|
|
459
|
+
if isinstance(row, Markdown):
|
|
460
|
+
return max(1, math.ceil(len(row.markup) / max(1, width)))
|
|
461
|
+
if isinstance(row, Panel):
|
|
462
|
+
inner = getattr(row.renderable, "renderables", None)
|
|
463
|
+
return 3 + (len(inner) if isinstance(inner, (list, tuple)) else 1)
|
|
464
|
+
return 1
|
|
465
|
+
|
|
466
|
+
def _render_frame(self) -> Group:
|
|
467
|
+
"""Full frame: status bar + Todos pinned on top, conversation below.
|
|
468
|
+
|
|
469
|
+
The status bar and the Todos panel are placed FIRST so they stay
|
|
470
|
+
visible no matter how tall the conversation gets — the Todos list
|
|
471
|
+
is pinned like a second mode line instead of competing with the
|
|
472
|
+
conversation rows for the visible budget.
|
|
473
|
+
"""
|
|
474
|
+
parts: list[Any] = [self._status_bar()]
|
|
475
|
+
todos = self._todos_panel()
|
|
476
|
+
if todos is not None:
|
|
477
|
+
parts.append(todos)
|
|
478
|
+
parts.append(self._render_conversation())
|
|
479
|
+
return Group(*parts)
|
|
480
|
+
|
|
481
|
+
def _status_bar(self) -> Text:
|
|
482
|
+
mode = self.session.plan_mode.mode.value
|
|
483
|
+
mode_style = "bold yellow" if mode == "plan" else "bold green"
|
|
484
|
+
ratio = self.session.context_ratio
|
|
485
|
+
ctx = ""
|
|
486
|
+
if ratio is not None:
|
|
487
|
+
pct = round(ratio * 100)
|
|
488
|
+
trigger = round(config.CONTEXT_TRIGGER * 100)
|
|
489
|
+
filled = round(ratio * 10)
|
|
490
|
+
bar = "▓" * filled + "░" * (10 - filled)
|
|
491
|
+
ctx = f" [Ctx:{bar} {pct}%/{trigger}%]"
|
|
492
|
+
t = Text()
|
|
493
|
+
t.append(f" [{mode.upper()}]", style=mode_style)
|
|
494
|
+
if ctx:
|
|
495
|
+
over = ratio is not None and ratio >= config.CONTEXT_TRIGGER
|
|
496
|
+
t.append(ctx, style="bold" if over else "")
|
|
497
|
+
if getattr(self.session, "_save_error", None):
|
|
498
|
+
t.append(" [!save]", style="red bold")
|
|
499
|
+
if self.agent_running:
|
|
500
|
+
frame = SPINNER_FRAMES[int(time.time() * 10) % len(SPINNER_FRAMES)]
|
|
501
|
+
t.append(f" {frame}", style="bold cyan")
|
|
502
|
+
if self._current_tool:
|
|
503
|
+
t.append(f" {self._current_tool}", style="bold cyan")
|
|
504
|
+
elif self.question is not None:
|
|
505
|
+
t.append(" ❓", style="yellow")
|
|
506
|
+
t.append(self._fit_status(cell_len(str(t)), self.status), style=self._status_style())
|
|
507
|
+
return t
|
|
508
|
+
|
|
509
|
+
def _fit_status(self, used: int, msg: str) -> str:
|
|
510
|
+
"""Fit the status message on the status-bar line: newlines are
|
|
511
|
+
flattened and the message is truncated with an ellipsis only
|
|
512
|
+
when it would overflow the terminal width."""
|
|
513
|
+
msg = " ".join(msg.splitlines())
|
|
514
|
+
width = self.console.width or 80
|
|
515
|
+
avail = max(1, width - used - 1)
|
|
516
|
+
if cell_len(msg) <= avail:
|
|
517
|
+
return msg
|
|
518
|
+
out = ""
|
|
519
|
+
used_cells = 0
|
|
520
|
+
for ch in msg:
|
|
521
|
+
w = cell_len(ch)
|
|
522
|
+
if used_cells + w > avail - 1:
|
|
523
|
+
break
|
|
524
|
+
out += ch
|
|
525
|
+
used_cells += w
|
|
526
|
+
return out + "…"
|
|
527
|
+
|
|
528
|
+
def _status_style(self) -> str:
|
|
529
|
+
"""Status-bar color by state: errors red, activity cyan, idle dim."""
|
|
530
|
+
s = self.status
|
|
531
|
+
if "error" in s or "failed" in s:
|
|
532
|
+
return "bold red"
|
|
533
|
+
if " ⏳" in s or " running" in s or "retrying" in s:
|
|
534
|
+
return "cyan"
|
|
535
|
+
return "dim"
|