Skip to content

Commit bf1f605

Browse files
committed
Add tool elapsed timing, ✓/✗ result markers and polish TUI status/welcome rendering
1 parent 77c5ce9 commit bf1f605

4 files changed

Lines changed: 74 additions & 36 deletions

File tree

python_agent_harness/agent.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from __future__ import annotations
3333

3434
import json
35+
import time
3536
from typing import Any, Protocol
3637

3738
from . import config
@@ -385,9 +386,11 @@ def _run_tools(self, calls: list[ToolCall], results: dict[str, str]) -> None:
385386
# status bar can show the active tool name beside the spinner.
386387
if self.top_level:
387388
self.session.notify("tool_running", p.name)
389+
start = time.monotonic()
388390
try:
389391
result = self._execute_tool_call(p)
390392
except Exception as e: # noqa: BLE001 - containment boundary
393+
p.elapsed = time.monotonic() - start
391394
results[p.id] = f"Error: tool {p.name!r} crashed during execution — {e}"
392395
continue
393396
if isinstance(result, PendingToolResult):
@@ -397,12 +400,17 @@ def _run_tools(self, calls: list[ToolCall], results: dict[str, str]) -> None:
397400
# executing in the meantime
398401
async_calls.append((p, result))
399402
else:
403+
p.elapsed = time.monotonic() - start
400404
results[p.id] = sanitize_tool_result(result)
401405
for p, pending in async_calls:
406+
start = time.monotonic()
402407
try:
403-
results[p.id] = sanitize_tool_result(pending.wait())
408+
result = pending.wait()
404409
except Exception as e: # noqa: BLE001 - containment boundary
405410
results[p.id] = f"Error: tool {p.name!r} crashed during execution — {e}"
411+
else:
412+
p.elapsed = time.monotonic() - start
413+
results[p.id] = sanitize_tool_result(result)
406414

407415
def _execute_pending(self) -> None:
408416
"""TOOL state: run the round's pending tool calls.

python_agent_harness/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class ToolCall:
2222
arguments: dict[str, Any] | str
2323
result: str | None = None
2424
diff: str | None = None # unified diff for Edit/Write, for TUI rendering
25+
elapsed: float | None = None # execution wall-time in seconds (TUI display)
2526

2627

2728
@dataclass

python_agent_harness/tui.py

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from prompt_toolkit.history import FileHistory
2424
from prompt_toolkit.key_binding import KeyBindings
2525
from prompt_toolkit.patch_stdout import patch_stdout
26+
from rich import box
2627
from rich.console import Console, Group
2728
from rich.live import Live
2829
from rich.markdown import Markdown
@@ -630,30 +631,40 @@ def _build_history_rows(self, full: bool = False) -> list[Any]:
630631
else:
631632
params = ""
632633
label = f"tool: {tc.name}({params})" if params else f"tool: {tc.name}"
633-
rows.append(Text(label, style="magenta"))
634+
rows.append(Text(f"▶ {label}", style="magenta"))
634635
if body.strip():
635636
rows.append(Markdown(f"**assistant:** {body}", style=ASSISTANT_STYLE))
636637
elif m.role == "tool":
637638
preview = _tool_result_preview(m.text())
638639
name = (m.name or "tool").lower()
639-
rows.append(Text(f"{name} result:\n{preview}", style="dim"))
640640
call = calls_by_id.get(m.tool_call_id)
641+
# tool failures surface as "Error: ..." results (agent
642+
# containment, missing args, MCP-reported errors)
643+
failed = (m.text() or "").startswith("Error")
644+
marker = "✗" if failed else "✓"
645+
marker_style = "bold red" if failed else "green"
646+
elapsed = ""
647+
if call is not None and call.elapsed is not None:
648+
elapsed = f" ({call.elapsed:.1f}s)"
649+
row = Text(style="dim")
650+
row.append(f"{marker} {name} result{elapsed}:", style=marker_style)
651+
row.append(f"\n{preview}")
652+
rows.append(row)
641653
if call is not None and call.diff:
642654
rows.append(render_diff(call.diff))
643655
return rows
644656

645-
def _todos_panel(self) -> Panel | None:
646-
"""Todos panel — rebuilt every frame (not cached), so a
657+
def _todos_panel(self) -> Group | None:
658+
"""Todos section — rebuilt every frame (not cached), so a
647659
TodoWrite call shows up immediately even mid-run."""
648660
if not self.session.todos:
649661
return None
650-
title = "Todos"
651662
t = Table.grid(padding=(0, 1))
652663
for todo in self.session.todos[-8:]:
653664
status = todo.get("status", "")
654665
mark = {"completed": "✅", "in_progress": "⏳", "pending": "⬜"}.get(status, "•")
655666
t.add_row(mark, todo.get("content", ""))
656-
return Panel(t, title=title, border_style="blue", expand=False)
667+
return Group(Text("Todos", style="bold"), t)
657668

658669
def _history_rows(self) -> list[Any]:
659670
"""Cached history rows; rebuilt only when the conversation changes.
@@ -679,16 +690,19 @@ def _stream_row(self) -> Text | None:
679690
lines = max(3, cap - 3)
680691
preview = _tail_lines(stream, lines)
681692
preview = _tail_chars(preview, lines * max(1, width))
682-
return Text(f"assistant: {preview}", style=ASSISTANT_STYLE)
693+
row = Text(f"assistant: {preview}", style=ASSISTANT_STYLE)
694+
# blinking block cursor, 2 Hz phase (same clock as the spinner)
695+
if int(time.time() * 2) % 2 == 0:
696+
row.append("▍")
697+
return row
683698

684-
def _render_conversation(self) -> Panel:
699+
def _render_conversation(self) -> Group | Text:
685700
rows = self._history_rows()
686701
stream_row = self._stream_row()
687702
if stream_row is not None:
688703
rows.append(stream_row)
689704
rows = self._apply_budget(rows)
690-
group = Group(*rows) if rows else Text("(empty)")
691-
return Panel(group, title="python-agent-harness", border_style="blue")
705+
return Group(*rows) if rows else Text("(empty)")
692706

693707
def _apply_budget(self, rows: list[Any]) -> list[Any]:
694708
"""Keep the NEWEST rows that fit the visible terminal area.
@@ -741,11 +755,11 @@ def _visible_row_cap(self) -> int:
741755
height = getattr(self.console, "height", None)
742756
if not height or height <= 0:
743757
return 60
744-
# reserve: status bar (1) + panel borders (2) + input prompt (1)
745-
# + the pinned Todos panel when visible (its rows + 2 borders)
746-
reserved = 4
758+
# reserve: status bar (1) + input prompt (1)
759+
# + the pinned Todos section when visible (its title line + rows)
760+
reserved = 2
747761
if self.session.todos:
748-
reserved += min(len(self.session.todos), 8) + 2
762+
reserved += min(len(self.session.todos), 8) + 1
749763
return max(5, height - reserved)
750764

751765
@staticmethod
@@ -777,48 +791,60 @@ def _render_frame(self) -> Group:
777791

778792
def _status_bar(self) -> Text:
779793
mode = self.session.plan_mode.mode.value
780-
mode_style = "yellow" if mode == "plan" else "green"
794+
mode_style = "bold yellow" if mode == "plan" else "bold green"
781795
ratio = self.session.context_ratio
782796
ctx = ""
783797
if ratio is not None:
784798
pct = round(ratio * 100)
785-
ctx = f" [Ctx:{pct}%/{round(config.CONTEXT_TRIGGER * 100)}%]"
799+
trigger = round(config.CONTEXT_TRIGGER * 100)
800+
filled = round(ratio * 10)
801+
bar = "▓" * filled + "░" * (10 - filled)
802+
ctx = f" [Ctx:{bar} {pct}%/{trigger}%]"
786803
t = Text()
787804
t.append(f" [{mode.upper()}]", style=mode_style)
788-
t.append(ctx)
805+
if ctx:
806+
over = ratio is not None and ratio >= config.CONTEXT_TRIGGER
807+
t.append(ctx, style="bold" if over else "")
789808
if getattr(self.session, "_save_error", None):
790809
t.append(" [!save]", style="red bold")
791810
if self.agent_running:
792811
frame = SPINNER_FRAMES[int(time.time() * 10) % len(SPINNER_FRAMES)]
793812
t.append(f" {frame}", style="bold cyan")
794813
if self._current_tool:
795-
t.append(f" {self._current_tool}", style="cyan")
814+
t.append(f" {self._current_tool}", style="bold cyan")
796815
elif self.question is not None:
797816
t.append(" ❓", style="yellow")
798-
t.append(f"{self.status}", style="dim")
817+
t.append(f"{self.status}", style=self._status_style())
799818
return t
800819

820+
def _status_style(self) -> str:
821+
"""Status-bar color by state: errors red, activity cyan, idle dim."""
822+
s = self.status
823+
if "error" in s or "failed" in s:
824+
return "bold red"
825+
if " ⏳" in s or " running" in s or "retrying" in s:
826+
return "cyan"
827+
return "dim"
828+
801829
# ------------------------------------------------------------------
802830
# main loop
803831
# ------------------------------------------------------------------
804832
def run(self) -> None:
805833
self.console.print(
806834
Panel(
807-
"python-agent-harness — agent execution harness\n"
808-
"Commands: /plan /build /init /review /explain /compact "
809-
"/save /summary /sessions /restore /help /exit\n"
810-
"/init [project] [--extra TEXT] create/update AGENTS.md\n"
811-
"/review [project] [commit|branch|PR] review code changes\n"
812-
"/explain [project] [target] explain code\n"
813-
"/sessions list saved sessions\n"
814-
"/restore [path | title | --latest] restore a saved session\n"
815-
"Ctrl-C cancels the current execution (the app stays open); "
816-
"Ctrl-D or /exit quits.\n"
817-
"Type a message — Enter for a new line, Esc then Enter "
818-
"(or Alt+Enter) to submit. Up/Down recall history.",
819-
border_style="blue",
820-
),
821-
markup=False,
835+
Text.from_markup(
836+
"[bold]Commands:[/bold] /plan /build /init /review /explain "
837+
"/compact /save /summary /sessions /restore /help /exit\n\n"
838+
"Ctrl-C cancels the current execution (the app stays open); "
839+
"Ctrl-D or /exit quits.\n"
840+
"Type a message — Enter for a new line, Esc then Enter "
841+
"(or Alt+Enter) to submit. Up/Down recall history.\n\n"
842+
"[dim]Type [bold]/help[/bold] for the full command reference.[/dim]"
843+
),
844+
title="[bold cyan]python-agent-harness — agent execution harness[/bold cyan]",
845+
border_style="cyan",
846+
box=box.ROUNDED,
847+
)
822848
)
823849
if config.LLM_LOG_ENABLED:
824850
self.console.print(f"[dim]LLM logs: {self.session.client.log_path}[/dim]")

tests/test_tui.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ def test_frame_includes_status_bar(self):
6565
out = buf.getvalue()
6666
self.assertIn("hello agent", out)
6767
self.assertIn("[BUILD]", out)
68-
self.assertIn("Ctx:55%", out)
68+
self.assertIn("Ctx:", out)
69+
self.assertIn("55%", out) # context percentage
70+
self.assertIn("▓", out) # context mini progress bar
71+
self.assertIn("░", out)
6972

7073
def test_status_bar_pinned_on_top(self):
7174
"""The status bar must come BEFORE the conversation panel so a

0 commit comments

Comments
 (0)