1010 /unload Unload the current routine
1111 /show Show current routine details
1212 /validate Validate the current routine
13- /execute [params.json] Execute the loaded routine
13+ /execute [params.json] Execute the loaded routine
14+ /diff Show pending suggested edit diff
15+ /accept Accept pending suggested edit
16+ /reject Reject pending suggested edit
1417 /status Show current state
18+ /chats Show all messages in the thread
1519 /reset Start a new conversation
1620 /help Show help
1721 /quit Exit
1822"""
1923
2024import argparse
25+ import difflib
2126import json
2227import logging
2328import sys
4146from web_hacker .data_models .llms .vendors import OpenAIModel
4247from web_hacker .data_models .llms .interaction import (
4348 ChatMessageType ,
44- EmittedChatMessage ,
49+ EmittedMessage ,
4550 PendingToolInvocation ,
51+ SuggestedEditRoutine ,
4652 ToolInvocationStatus ,
4753)
4854from web_hacker .data_models .routine .routine import Routine
@@ -109,11 +115,18 @@ def print_welcome(model: str) -> None:
109115
110116[bold]Commands:[/bold]
111117 [cyan]/load <routine.json>[/cyan] Load a routine file (auto-reloads on edits)
118+ [cyan]/unload[/cyan] Unload the current routine
112119 [cyan]/show[/cyan] Show current routine details
113120 [cyan]/validate[/cyan] Validate the current routine
114121 [cyan]/execute \[params.json][/cyan] Execute the loaded routine
122+ [cyan]/diff[/cyan] Show pending suggested edit diff
123+ [cyan]/accept[/cyan] Accept pending suggested edit
124+ [cyan]/reject[/cyan] Reject pending suggested edit
115125 [cyan]/status[/cyan] Show current state
126+ [cyan]/chats[/cyan] Show all messages in the thread
127+ [cyan]/reset[/cyan] Start a new conversation
116128 [cyan]/help[/cyan] Show all commands
129+ [cyan]/quit[/cyan] Exit
117130
118131[bold]Links:[/bold]
119132 [link=https://vectorly.app/docs]https://vectorly.app/docs[/link]
@@ -175,7 +188,8 @@ def print_tool_result(
175188 elif invocation .status == ToolInvocationStatus .FAILED :
176189 console .print ("[bold red]✗ Tool execution failed[/bold red]" )
177190 if error :
178- console .print (f"[red] Error: { error } [/red]" )
191+ console .print ()
192+ console .print (Panel (error , title = "Error" , style = "red" , box = box .ROUNDED ))
179193
180194 console .print ()
181195
@@ -286,6 +300,7 @@ def __init__(
286300 ) -> None :
287301 """Initialize the terminal chat interface."""
288302 self ._pending_invocation : PendingToolInvocation | None = None
303+ self ._pending_suggested_edit : SuggestedEditRoutine | None = None
289304 self ._streaming_started : bool = False
290305 self ._data_store = data_store
291306 self ._loaded_routine_path : Path | None = None
@@ -297,6 +312,70 @@ def __init__(
297312 data_store = data_store ,
298313 )
299314
315+ def _persist_routine (self , routine_dict : dict [str , Any ]) -> None :
316+ """
317+ Persist the routine to the loaded file path.
318+
319+ Called when user approves the update_routine tool call.
320+ Shows a diff and overwrites the file with the new routine JSON.
321+ """
322+ if self ._loaded_routine_path is None :
323+ console .print ()
324+ console .print ("[yellow]⚠ No file loaded - routine updated in memory only[/yellow]" )
325+ console .print ()
326+ return
327+
328+ try :
329+ # Read old content for diff
330+ old_content = ""
331+ if self ._loaded_routine_path .exists ():
332+ with open (self ._loaded_routine_path , encoding = "utf-8" ) as f :
333+ old_content = f .read ()
334+
335+ # Format new content
336+ new_content = json .dumps (routine_dict , indent = 2 )
337+
338+ # Generate and display diff
339+ old_lines = old_content .splitlines (keepends = True )
340+ new_lines = new_content .splitlines (keepends = True )
341+ diff = difflib .unified_diff (
342+ old_lines ,
343+ new_lines ,
344+ fromfile = str (self ._loaded_routine_path ),
345+ tofile = str (self ._loaded_routine_path ),
346+ lineterm = "" ,
347+ )
348+
349+ diff_lines = list (diff )
350+ if diff_lines :
351+ console .print ()
352+ console .print ("[bold cyan]Diff:[/bold cyan]" )
353+ for line in diff_lines :
354+ line = line .rstrip ("\n " )
355+ if line .startswith ("+++" ) or line .startswith ("---" ):
356+ console .print (f"[bold]{ line } [/bold]" )
357+ elif line .startswith ("@@" ):
358+ console .print (f"[cyan]{ line } [/cyan]" )
359+ elif line .startswith ("+" ):
360+ console .print (f"[green]{ line } [/green]" )
361+ elif line .startswith ("-" ):
362+ console .print (f"[red]{ line } [/red]" )
363+ else :
364+ console .print (line )
365+ console .print ()
366+
367+ # Write new content
368+ with open (self ._loaded_routine_path , "w" , encoding = "utf-8" ) as f :
369+ f .write (new_content )
370+
371+ console .print (f"[green]✓ Routine saved to { self ._loaded_routine_path } [/green]" )
372+ console .print ()
373+
374+ except Exception as e :
375+ console .print ()
376+ console .print (f"[red]✗ Failed to save routine: { e } [/red]" )
377+ console .print ()
378+
300379 def _get_prompt (self ) -> str :
301380 """Get the input prompt with routine name if loaded."""
302381 routine_str = self ._agent .routine_state .current_routine_str
@@ -323,7 +402,7 @@ def _handle_stream_chunk(self, chunk: str) -> None:
323402 # Use plain print for streaming - Rich console.print breaks on char-by-char output
324403 print (chunk , end = "" , flush = True )
325404
326- def _handle_message (self , message : EmittedChatMessage ) -> None :
405+ def _handle_message (self , message : EmittedMessage ) -> None :
327406 """Handle messages emitted by the Guide Agent."""
328407 if message .type == ChatMessageType .CHAT_RESPONSE :
329408 if self ._streaming_started :
@@ -346,6 +425,14 @@ def _handle_message(self, message: EmittedChatMessage) -> None:
346425 message .error ,
347426 )
348427
428+ elif message .type == ChatMessageType .SUGGESTED_EDIT :
429+ if message .suggested_edit :
430+ self ._pending_suggested_edit = message .suggested_edit
431+ console .print ()
432+ console .print ("[bold yellow]📝 Agent suggested a routine edit[/bold yellow]" )
433+ console .print ("[dim]Use /diff to see changes, /accept to apply, /reject to discard[/dim]" )
434+ console .print ()
435+
349436 elif message .type == ChatMessageType .ERROR :
350437 print_error (message .error or "Unknown error" )
351438
@@ -504,6 +591,46 @@ def _handle_validate_command(self) -> None:
504591 console .print (Panel (error , title = "Validation Error" , style = "red" , box = box .ROUNDED ))
505592 console .print ()
506593
594+ def _handle_chats_command (self ) -> None :
595+ """Handle /chats command to show all messages in the thread."""
596+ chats = self ._agent .get_chats ()
597+ if not chats :
598+ console .print ()
599+ console .print ("[yellow]No messages in thread yet.[/yellow]" )
600+ console .print ()
601+ return
602+
603+ console .print ()
604+ console .print (f"[bold cyan]Chat History ({ len (chats )} messages)[/bold cyan]" )
605+ console .print ()
606+
607+ role_styles = {"user" : "green" , "assistant" : "cyan" , "system" : "yellow" , "tool" : "magenta" }
608+
609+ for i , chat in enumerate (chats , 1 ):
610+ role_style = role_styles .get (chat .role .value , "white" )
611+
612+ # TOOL role = tool result
613+ if chat .role .value == "tool" :
614+ content = chat .content .replace ("\n " , " " )[:50 ]
615+ tool_id = chat .tool_call_id [:8 ] + "..." if chat .tool_call_id else "?"
616+ console .print (f"[dim]{ i } .[/dim] [{ role_style } ]TOOL_RESULT[/{ role_style } ] [dim]({ tool_id } )[/dim] { escape (content )} ..." )
617+ continue
618+
619+ # ASSISTANT with tool_calls = tool request
620+ if chat .role .value == "assistant" and chat .tool_calls :
621+ tool_names = ", " .join (tc .tool_name for tc in chat .tool_calls )
622+ content = chat .content .replace ("\n " , " " )[:30 ] if chat .content else ""
623+ prefix = f"{ escape (content )} ... " if content else ""
624+ console .print (f"[dim]{ i } .[/dim] [{ role_style } ]ASSISTANT[/{ role_style } ] { prefix } [yellow]→ { tool_names } [/yellow]" )
625+ continue
626+
627+ # Regular message - single line, ~50 chars
628+ content = chat .content .replace ("\n " , " " )[:50 ]
629+ suffix = "..." if len (chat .content ) > 50 else ""
630+ console .print (f"[dim]{ i } .[/dim] [{ role_style } ]{ chat .role .value .upper ()} [/{ role_style } ] { escape (content )} { suffix } " )
631+
632+ console .print ()
633+
507634 def _handle_status_command (self ) -> None :
508635 """Handle /status command to show current state."""
509636 table = Table (box = box .ROUNDED , show_header = False )
@@ -681,6 +808,89 @@ def _handle_execute_command(self, params_path: str | None) -> None:
681808 console .print (f"[bold red]✗ Execution error: { e } [/bold red]" )
682809 console .print ()
683810
811+ def _handle_diff_command (self ) -> None :
812+ """Handle /diff command to show pending suggested edit."""
813+ if not self ._pending_suggested_edit :
814+ console .print ()
815+ console .print ("[yellow]No pending suggested edit.[/yellow]" )
816+ console .print ()
817+ return
818+
819+ # Get current routine
820+ current_str = self ._agent .routine_state .current_routine_str or "{}"
821+ # Serialize Routine object to JSON for diff
822+ new_str = json .dumps (self ._pending_suggested_edit .routine .model_dump (), indent = 2 )
823+
824+ # Generate unified diff
825+ current_lines = current_str .splitlines (keepends = True )
826+ new_lines = new_str .splitlines (keepends = True )
827+ diff = difflib .unified_diff (
828+ current_lines ,
829+ new_lines ,
830+ fromfile = "current" ,
831+ tofile = "suggested" ,
832+ lineterm = "" ,
833+ )
834+
835+ diff_lines = list (diff )
836+ if diff_lines :
837+ console .print ()
838+ console .print ("[bold cyan]Suggested Edit Diff:[/bold cyan]" )
839+ for line in diff_lines :
840+ line = line .rstrip ("\n " )
841+ if line .startswith ("+++" ) or line .startswith ("---" ):
842+ console .print (f"[bold]{ line } [/bold]" )
843+ elif line .startswith ("@@" ):
844+ console .print (f"[cyan]{ line } [/cyan]" )
845+ elif line .startswith ("+" ):
846+ console .print (f"[green]{ line } [/green]" )
847+ elif line .startswith ("-" ):
848+ console .print (f"[red]{ line } [/red]" )
849+ else :
850+ console .print (line )
851+ console .print ()
852+ else :
853+ console .print ()
854+ console .print ("[yellow]No differences found.[/yellow]" )
855+ console .print ()
856+
857+ def _handle_accept_command (self ) -> None :
858+ """Handle /accept command to approve pending edit."""
859+ if not self ._pending_suggested_edit :
860+ console .print ()
861+ console .print ("[yellow]No pending suggested edit to accept.[/yellow]" )
862+ console .print ()
863+ return
864+
865+ # Get routine from pending edit
866+ routine = self ._pending_suggested_edit .routine
867+ routine_dict = routine .model_dump ()
868+ routine_str = json .dumps (routine_dict )
869+
870+ # Update agent's routine state
871+ self ._agent .routine_state .update_current_routine (routine_str )
872+
873+ # Persist to file (reuses existing _persist_routine method)
874+ self ._persist_routine (routine_dict )
875+
876+ console .print ()
877+ console .print ("[bold green]✓ Edit accepted and applied[/bold green]" )
878+ console .print ()
879+ self ._pending_suggested_edit = None
880+
881+ def _handle_reject_command (self ) -> None :
882+ """Handle /reject command to reject pending edit."""
883+ if not self ._pending_suggested_edit :
884+ console .print ()
885+ console .print ("[yellow]No pending suggested edit to reject.[/yellow]" )
886+ console .print ()
887+ return
888+
889+ console .print ()
890+ console .print ("[yellow]✗ Edit rejected[/yellow]" )
891+ console .print ()
892+ self ._pending_suggested_edit = None
893+
684894 def run (self ) -> None :
685895 """Run the interactive chat loop."""
686896 print_welcome (str (self ._agent .llm_model ))
@@ -720,7 +930,11 @@ def run(self) -> None:
720930 [cyan]/show[/cyan] Show current routine details
721931 [cyan]/validate[/cyan] Validate the current routine
722932 [cyan]/execute \[params.json][/cyan] Execute the loaded routine
933+ [cyan]/diff[/cyan] Show pending suggested edit diff
934+ [cyan]/accept[/cyan] Accept pending suggested edit
935+ [cyan]/reject[/cyan] Reject pending suggested edit
723936 [cyan]/status[/cyan] Show current state
937+ [cyan]/chats[/cyan] Show all messages in the thread
724938 [cyan]/reset[/cyan] Start a new conversation
725939 [cyan]/help[/cyan] Show this help message
726940 [cyan]/quit[/cyan] Exit
@@ -729,7 +943,8 @@ def run(self) -> None:
729943 - After /load, edit the file externally and changes are picked up automatically
730944 - The prompt shows the loaded routine name: [dim]You (routine_name)>[/dim]
731945 - Use /execute without params to enter values interactively
732- - Ask the agent to validate, explain, or debug your routine""" ,
946+ - Ask the agent to validate, explain, or debug your routine
947+ - When agent suggests edits, use /diff, /accept, or /reject to review them""" ,
733948 title = "[bold magenta]Help[/bold magenta]" ,
734949 border_style = "magenta" ,
735950 box = box .ROUNDED ,
@@ -740,6 +955,7 @@ def run(self) -> None:
740955 if cmd == "/reset" :
741956 self ._agent .reset ()
742957 self ._pending_invocation = None
958+ self ._pending_suggested_edit = None
743959 self ._loaded_routine_path = None
744960 self ._last_execution_ok = None
745961 console .print ()
@@ -751,6 +967,10 @@ def run(self) -> None:
751967 self ._handle_status_command ()
752968 continue
753969
970+ if cmd == "/chats" :
971+ self ._handle_chats_command ()
972+ continue
973+
754974 if cmd == "/show" :
755975 self ._handle_show_command ()
756976 continue
@@ -759,6 +979,18 @@ def run(self) -> None:
759979 self ._handle_validate_command ()
760980 continue
761981
982+ if cmd == "/diff" :
983+ self ._handle_diff_command ()
984+ continue
985+
986+ if cmd == "/accept" :
987+ self ._handle_accept_command ()
988+ continue
989+
990+ if cmd == "/reject" :
991+ self ._handle_reject_command ()
992+ continue
993+
762994 if cmd == "/unload" :
763995 self ._handle_unload_command ()
764996 continue
0 commit comments