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.
Files changed (61) hide show
  1. python_agent_harness/__init__.py +20 -0
  2. python_agent_harness/__main__.py +5 -0
  3. python_agent_harness/agent.py +703 -0
  4. python_agent_harness/cli.py +273 -0
  5. python_agent_harness/client.py +832 -0
  6. python_agent_harness/commands.py +181 -0
  7. python_agent_harness/config.py +464 -0
  8. python_agent_harness/context_manager.py +100 -0
  9. python_agent_harness/diffrender.py +84 -0
  10. python_agent_harness/mcp/__init__.py +21 -0
  11. python_agent_harness/mcp/client.py +161 -0
  12. python_agent_harness/mcp/config.py +130 -0
  13. python_agent_harness/mcp/manager.py +290 -0
  14. python_agent_harness/models.py +149 -0
  15. python_agent_harness/persistence.py +297 -0
  16. python_agent_harness/planmode.py +112 -0
  17. python_agent_harness/prompts/agent.md +362 -0
  18. python_agent_harness/prompts/build-switch.md +5 -0
  19. python_agent_harness/prompts/commands/explain.md +13 -0
  20. python_agent_harness/prompts/compact.md +33 -0
  21. python_agent_harness/prompts/initialize.md +66 -0
  22. python_agent_harness/prompts/plan-mode.md +70 -0
  23. python_agent_harness/prompts/plan.md +26 -0
  24. python_agent_harness/prompts/review.md +100 -0
  25. python_agent_harness/prompts/subagent.md +208 -0
  26. python_agent_harness/prompts/summary.md +11 -0
  27. python_agent_harness/prompts/task-completion-rules.md +50 -0
  28. python_agent_harness/prompts/title.md +44 -0
  29. python_agent_harness/prompts.py +498 -0
  30. python_agent_harness/session.py +781 -0
  31. python_agent_harness/subagent.py +61 -0
  32. python_agent_harness/token_estimator.py +125 -0
  33. python_agent_harness/tool_runner.py +247 -0
  34. python_agent_harness/tools/__init__.py +56 -0
  35. python_agent_harness/tools/agent_tool.py +75 -0
  36. python_agent_harness/tools/base.py +147 -0
  37. python_agent_harness/tools/bash.py +298 -0
  38. python_agent_harness/tools/edit.py +272 -0
  39. python_agent_harness/tools/filesystem.py +180 -0
  40. python_agent_harness/tools/glob.py +161 -0
  41. python_agent_harness/tools/grep.py +149 -0
  42. python_agent_harness/tools/insert.py +61 -0
  43. python_agent_harness/tools/mcp.py +203 -0
  44. python_agent_harness/tools/mkdir.py +30 -0
  45. python_agent_harness/tools/planexit.py +45 -0
  46. python_agent_harness/tools/question.py +70 -0
  47. python_agent_harness/tools/read.py +104 -0
  48. python_agent_harness/tools/skill.py +32 -0
  49. python_agent_harness/tools/todo.py +60 -0
  50. python_agent_harness/tools/write.py +56 -0
  51. python_agent_harness/tui/__init__.py +68 -0
  52. python_agent_harness/tui/commands.py +652 -0
  53. python_agent_harness/tui/core.py +385 -0
  54. python_agent_harness/tui/input.py +412 -0
  55. python_agent_harness/tui/render.py +535 -0
  56. python_agent_harness-1.5.0.dist-info/METADATA +251 -0
  57. python_agent_harness-1.5.0.dist-info/RECORD +61 -0
  58. python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
  59. python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
  60. python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
  61. 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"