From 3672a46cfd400fd4a376a41eccc4f3c51797bb32 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 11:40:59 +0530 Subject: [PATCH] feat(hitl): wire up human-in-the-loop approval for system terminal commands --- cmd/chat.go | 7 +++- internal/chat/messages.go | 6 ++++ internal/chat/model.go | 18 +++++++++- internal/chat/update.go | 53 +++++++++++++++++++++++++++-- internal/chat/view.go | 44 ++++++++++++++++-------- internal/tools/defaults/defaults.go | 4 +-- internal/tools/system/command.go | 18 ++++++++-- 7 files changed, 126 insertions(+), 24 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index 042da8b..825df39 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -47,7 +47,12 @@ var chatCmd = &cobra.Command{ } manager := tools.NewManager() - defaults.RegisterAll(manager) + defaults.RegisterAll(manager, func(cmd string) bool { + fmt.Printf("\n⚠️ Agent wants to run: %s\nAllow? (y/N): ", cmd) + var ans string + fmt.Scanln(&ans) + return ans == "y" || ans == "Y" + }) ag := agent.New(provider, manager, agent.Config{}) diff --git a/internal/chat/messages.go b/internal/chat/messages.go index ede9faa..613e89b 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -34,3 +34,9 @@ type switchCancelMsg struct{} type switchErrorMsg struct { Err error } + +// ApprovalRequestMsg is sent when a tool requires user approval. +type ApprovalRequestMsg struct { + Command string + ResponseChan chan bool +} diff --git a/internal/chat/model.go b/internal/chat/model.go index f3a3c3d..985e629 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -33,6 +33,10 @@ type Model struct { loading bool streaming bool + waitingApproval bool + approvalCommand string + approvalChan chan bool + viewport viewport.Model markdown *ui.MarkdownRenderer @@ -54,7 +58,17 @@ func New() Model { } manager := tools.NewManager() - defaults.RegisterAll(manager) + defaults.RegisterAll(manager, func(cmd string) bool { + if program == nil { + return false + } + ch := make(chan bool) + program.Send(ApprovalRequestMsg{ + Command: cmd, + ResponseChan: ch, + }) + return <-ch + }) ag := agent.New(provider, manager, agent.Config{}) renderer, err := ui.NewMarkdownRenderer() @@ -99,6 +113,8 @@ func New() Model { loading: false, streaming: false, + waitingApproval: false, + viewport: vp, markdown: renderer, diff --git a/internal/chat/update.go b/internal/chat/update.go index 314dda8..fbffee3 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -56,6 +56,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Handle approval keys + if m.waitingApproval { + switch msg.String() { + case "y", "Y": + if m.approvalChan != nil { + m.approvalChan <- true + } + m.waitingApproval = false + m.refreshViewport() + return m, nil + case "n", "N": + if m.approvalChan != nil { + m.approvalChan <- false + } + m.waitingApproval = false + m.refreshViewport() + return m, nil + case "ctrl+c", "esc": + if m.approvalChan != nil { + m.approvalChan <- false + } + m.waitingApproval = false + return m, tea.Quit + } + // Block other inputs + return m, nil + } + switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit @@ -162,6 +190,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + case ApprovalRequestMsg: + m.waitingApproval = true + m.approvalCommand = msg.Command + m.approvalChan = msg.ResponseChan + m.refreshViewport() + return m, nil + case StreamingMsg: if msg.Err != nil { @@ -201,7 +236,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err == nil { m.provider = provider manager := tools.NewManager() - defaults.RegisterAll(manager) + defaults.RegisterAll(manager, func(cmd string) bool { + if program == nil { + return false + } + ch := make(chan bool) + program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) + return <-ch + }) m.agent = agent.New(provider, manager, agent.Config{}) } @@ -218,7 +260,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err == nil { m.provider = provider manager := tools.NewManager() - defaults.RegisterAll(manager) + defaults.RegisterAll(manager, func(cmd string) bool { + if program == nil { + return false + } + ch := make(chan bool) + program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) + return <-ch + }) m.agent = agent.New(provider, manager, agent.Config{}) } diff --git a/internal/chat/view.go b/internal/chat/view.go index 816f63d..f1c85e3 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -1,6 +1,7 @@ package chat import ( + "fmt" "strings" "github.com/Nithwin/WindMist/internal/ui" @@ -28,21 +29,34 @@ func (m Model) View() string { } } - // Input row (label and textarea joined horizontally at Top so cursor is next to user ›) - promptLabel := lipgloss.JoinHorizontal( - lipgloss.Center, - ui.PromptStyle.Render(" user"), - lipgloss.NewStyle().Foreground(ui.Muted).Render(" › "), - ) - - inputRow := lipgloss.JoinHorizontal( - lipgloss.Top, - promptLabel, - m.input.View(), - ) - - b.WriteString(inputRow) - b.WriteString("\n") + if m.waitingApproval { + approvalBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("220")). + Padding(1, 2). + Render( + lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true).Render(fmt.Sprintf("⚠️ Agent wants to run: %s", m.approvalCommand)) + "\n\n" + + lipgloss.NewStyle().Render("Allow execution? (y/N)"), + ) + b.WriteString(approvalBox) + b.WriteString("\n") + } else { + // Input row (label and textarea joined horizontally at Top so cursor is next to user ›) + promptLabel := lipgloss.JoinHorizontal( + lipgloss.Center, + ui.PromptStyle.Render(" user"), + lipgloss.NewStyle().Foreground(ui.Muted).Render(" › "), + ) + + inputRow := lipgloss.JoinHorizontal( + lipgloss.Top, + promptLabel, + m.input.View(), + ) + + b.WriteString(inputRow) + b.WriteString("\n") + } return b.String() } diff --git a/internal/tools/defaults/defaults.go b/internal/tools/defaults/defaults.go index 2e45d42..e25c081 100644 --- a/internal/tools/defaults/defaults.go +++ b/internal/tools/defaults/defaults.go @@ -8,7 +8,7 @@ import ( ) // RegisterAll registers all built-in filesystem and editing tools onto the manager. -func RegisterAll(m *tools.Manager) { +func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback) { if m == nil { return } @@ -33,5 +33,5 @@ func RegisterAll(m *tools.Manager) { m.Register(editing.NewSearchTool()) // System tools - m.Register(system.NewCommandTool()) + m.Register(system.NewCommandTool(approvalCb)) } diff --git a/internal/tools/system/command.go b/internal/tools/system/command.go index 24045e0..f630fb2 100644 --- a/internal/tools/system/command.go +++ b/internal/tools/system/command.go @@ -9,12 +9,17 @@ import ( "github.com/Nithwin/WindMist/internal/tools" ) +// ApprovalCallback is a function that asks the user for permission. +type ApprovalCallback func(cmd string) bool + // CommandTool allows the agent to execute terminal commands. -type CommandTool struct{} +type CommandTool struct { + AskApproval ApprovalCallback +} // NewCommandTool creates a new instance of the CommandTool. -func NewCommandTool() *CommandTool { - return &CommandTool{} +func NewCommandTool(cb ApprovalCallback) *CommandTool { + return &CommandTool{AskApproval: cb} } // Definition returns the schema for this tool. @@ -40,6 +45,13 @@ func (t *CommandTool) Run(ctx context.Context, call tools.Call) tools.Result { return tools.Result{Error: fmt.Errorf("invalid or missing 'command' parameter")} } + if t.AskApproval != nil { + approved := t.AskApproval(cmdStr) + if !approved { + return tools.Result{Error: fmt.Errorf("user rejected execution of command")} + } + } + // Apply a timeout to prevent hanging commands (e.g., waiting for input) ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel()