2323from prompt_toolkit .history import FileHistory
2424from prompt_toolkit .key_binding import KeyBindings
2525from prompt_toolkit .patch_stdout import patch_stdout
26+ from rich import box
2627from rich .console import Console , Group
2728from rich .live import Live
2829from 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]" )
0 commit comments