From 27b198a3afb52dfd1c40bfdd931b2934198e2c2c Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 20 Apr 2026 01:00:24 +0800 Subject: [PATCH] feat(tui): add cancel agent confirmation dialog and related functionality --- Makefile | 6 +- internal/command/interactive.go | 56 ++++++++--- internal/tui/messages.go | 16 +-- internal/tui/pickers.go | 44 ++++++++- internal/tui/styles.go | 6 +- internal/tui/tui.go | 166 ++++++++++++++++++++++++++------ 6 files changed, 235 insertions(+), 59 deletions(-) diff --git a/Makefile b/Makefile index cbfa1dc..4dcadc9 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,11 @@ LDFLAGS := -s -w \ export GOFLAGS := -buildvcs=false -.PHONY: build build-binary run doctor version install clean build-web lint lint-go lint-web generate +.PHONY: build build-binary run doctor version install clean build-web fmt lint lint-go lint-web generate + +fmt: + @echo "Formatting Go..." + goimports -w . lint: lint-go lint-web diff --git a/internal/command/interactive.go b/internal/command/interactive.go index bcb1059..17b5f23 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "os" "os/exec" "path/filepath" "strconv" @@ -73,6 +72,10 @@ type interactiveState struct { // WeChat channel wechatClient *weixin.Client agentRunning atomic.Bool + + // Agent cancellation + cancelFunc context.CancelFunc // used to cancel a running agent job + runCtx context.Context // per-run context, non-nil while agent is running } func (s *interactiveState) buildAllTools() []tool.BaseTool { @@ -287,6 +290,16 @@ func (s *interactiveState) handlePrompt(userPrompt string) { s.agentRunning.Store(true) defer s.agentRunning.Store(false) + // Create a per-run cancellable context so cancelling one run + // does not prevent future runs. + runCtx, runCancel := context.WithCancel(s.ctx) + s.cancelFunc = runCancel + s.runCtx = runCtx + defer func() { + runCancel() + s.runCtx = nil + }() + if s.sessionResumeWarning != "" { userPrompt = s.sessionResumeWarning + "\n\n" + userPrompt s.sessionResumeWarning = "" @@ -299,7 +312,7 @@ func (s *interactiveState) handlePrompt(userPrompt string) { } s.history = append(s.history, schema.UserMessage(userPrompt)) s.history = agent.DrainBgNotifications(s.bgManager, s.history) - resp := runner.Run(s.ctx, s.ag, s.history, s.h, s.rec, s.env.TodoStore, s.langfuseTracer, s.agentTokenUsage) + resp := runner.Run(runCtx, s.ag, s.history, s.h, s.rec, s.env.TodoStore, s.langfuseTracer, s.agentTokenUsage) if resp != "" { s.history = append(s.history, &schema.Message{Role: schema.Assistant, Content: resp}) } @@ -341,7 +354,7 @@ func (s *interactiveState) handlePlanCompletion(resp string) { s.rec.RecordUser(revisePrompt) } s.history = append(s.history, schema.UserMessage(revisePrompt)) - newResp := runner.Run(s.ctx, s.ag, s.history, s.h, s.rec, s.env.TodoStore, s.langfuseTracer, s.agentTokenUsage) + newResp := runner.Run(s.runCtx, s.ag, s.history, s.h, s.rec, s.env.TodoStore, s.langfuseTracer, s.agentTokenUsage) if newResp != "" { s.history = append(s.history, &schema.Message{Role: schema.Assistant, Content: newResp}) } @@ -624,8 +637,15 @@ func (s *interactiveState) runEventLoop(initialHistory []adk.Message, initialRes if s.rec != nil { s.rec.RecordUser(prompt) } + runCtx, runCancel := context.WithCancel(s.ctx) + s.cancelFunc = runCancel + s.runCtx = runCtx + s.agentRunning.Store(true) s.history = append(s.history, schema.UserMessage(prompt)) - resp := runner.Run(s.ctx, s.ag, s.history, s.h, s.rec, s.env.TodoStore, s.langfuseTracer, s.agentTokenUsage) + resp := runner.Run(runCtx, s.ag, s.history, s.h, s.rec, s.env.TodoStore, s.langfuseTracer, s.agentTokenUsage) + runCancel() + s.runCtx = nil + s.agentRunning.Store(false) if resp != "" { s.history = append(s.history, &schema.Message{Role: schema.Assistant, Content: resp}) } @@ -638,10 +658,22 @@ func (s *interactiveState) runEventLoop(initialHistory []adk.Message, initialRes configCh := tui.GetConfigChannel() addModelCh := tui.GetAddModelChannel() resumeCh := tui.GetResumeChannel() - autoApproveCh := tui.GetAutoApproveChannel() compactCh := tui.GetCompactChannel() planModeCh := tui.GetPlanModeChannel() channelActionCh := tui.GetChannelActionChannel() + cancelAgentCh := tui.GetCancelAgentChannel() + + // Background goroutine to handle agent cancellation requests. + // This is necessary because the main event loop blocks on handlePrompt/runner.Run, + // so the cancel channel must be consumed independently. + go func() { + for range cancelAgentCh { + if s.cancelFunc != nil { + config.Logger().Printf("[interactive] cancelling agent job via Ctrl+C") + s.cancelFunc() + } + } + }() // Send initial WeChat state to TUI s.p.Send(tui.ChannelStateMsg{ @@ -651,9 +683,6 @@ func (s *interactiveState) runEventLoop(initialHistory []adk.Message, initialRes for { select { - case enabled := <-autoApproveCh: - s.approvalState.SetSessionApproval(enabled) - case newMode := <-planModeCh: s.applyModeSwitch(newMode) @@ -713,7 +742,8 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { return fmt.Errorf("config error: %v\nconfig file: %s", err, config.ConfigPath()) } - ctx := context.Background() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() pwd := util.GetWorkDir() platform := util.GetSystemInfo() envInfo := util.CollectEnvInfo(pwd) @@ -786,6 +816,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { st := &interactiveState{ ctx: ctx, + cancelFunc: cancelFunc, cfg: cfg, chatModel: chatModel, env: env, @@ -887,7 +918,9 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { approvalState := runner.NewApprovalState(pwd, autoApprove) st.approvalState = approvalState - p, _ := tui.RunTUI(hasPrompt, pwd, env.TodoStore) + p, _ := tui.RunTUI(hasPrompt, pwd, env.TodoStore, tui.WithApprovalModeChange(func(enabled bool) { + approvalState.SetSessionApproval(enabled) + })) st.p = p bgManager.SetNotifier(func(taskID, cmd, status string) { p.Send(tui.BgTaskDoneMsg{TaskID: taskID, Command: cmd, Status: status}) @@ -941,8 +974,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { ag, err := st.createAgent() if err != nil { - fmt.Fprintf(os.Stderr, "Error creating agent: %v\n", err) - os.Exit(1) + return fmt.Errorf("error creating agent: %w", err) } st.ag = ag diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 49ad77d..f95e183 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -55,14 +55,6 @@ func GetApprovalChannel() chan ToolApprovalRequestMsg { return approvalCh } -// autoApproveCh is used to notify main goroutine when auto-approve state changes. -var autoApproveCh = make(chan bool, 1) - -// GetAutoApproveChannel returns the channel that receives auto-approve mode changes. -func GetAutoApproveChannel() <-chan bool { - return autoApproveCh -} - // --- Messages --- type AgentTextMsg struct{ Text string } @@ -295,6 +287,14 @@ type AskUserResponse struct { Answer string } +// cancelAgentCh is used to signal the main goroutine to cancel a running agent job. +var cancelAgentCh = make(chan struct{}, 1) + +// GetCancelAgentChannel returns the channel that receives agent cancellation requests. +func GetCancelAgentChannel() <-chan struct{} { + return cancelAgentCh +} + // ExitTimeoutMsg is sent after 5s to clear exit confirmation type ExitTimeoutMsg struct{} diff --git a/internal/tui/pickers.go b/internal/tui/pickers.go index 194f920..8e4af35 100644 --- a/internal/tui/pickers.go +++ b/internal/tui/pickers.go @@ -381,7 +381,7 @@ func (m Model) approvalDialogView() string { {text: " Approve ", selected: m.approvalSelected == 0}, {text: " Approve All ", selected: m.approvalSelected == 1}, {text: " Reject ", selected: m.approvalSelected == 2}, - }, " ") + }) // Keyboard hints hintText := lipgloss.NewStyle().Foreground(colorMuted).Italic(true). @@ -434,7 +434,7 @@ func (m Model) exitDialogView() string { buttons := buttonGroup([]buttonOpts{ {text: " Yes ", selected: m.exitSelected == 0}, {text: " No ", selected: m.exitSelected == 1}, - }, " ") + }) hintText := lipgloss.NewStyle().Foreground(colorMuted).Italic(true). Render("←/→ switch · Enter confirm · y/n") @@ -451,6 +451,46 @@ func (m Model) exitDialogView() string { return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, boxStyle.Render(content)) } +func (m Model) cancelDialogView() string { + w, h := m.width, m.height + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + + contentW := 48 + if contentW > w-12 { + contentW = w - 12 + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorWarning). + Padding(1, 2). + Width(contentW) + + headerText := lipgloss.NewStyle().Bold(true).Foreground(colorWarning). + Render("⏹ Cancel agent?") + + statusText := lipgloss.NewStyle().Foreground(colorMuted).Italic(true). + Render("Agent is still running.") + + buttons := buttonGroup([]buttonOpts{ + {text: " Cancel ", selected: m.cancelSelected == 0}, + {text: " Wait ", selected: m.cancelSelected == 1}, + }) + + hintText := lipgloss.NewStyle().Foreground(colorMuted).Italic(true). + Render("←/→ switch · Enter confirm · y/n") + + content := lipgloss.JoinVertical(lipgloss.Center, + headerText, statusText, "", buttons, "", hintText) + + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, boxStyle.Render(content)) +} + func (m Model) sessionPickerView() string { w, h := m.width, m.height if w <= 0 { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 616dbff..9dd91ec 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -139,13 +139,11 @@ func divider(width int) string { } // buttonGroup renders a row of selectable buttons. -func buttonGroup(buttons []buttonOpts, spacing string) string { +func buttonGroup(buttons []buttonOpts) string { if len(buttons) == 0 { return "" } - if spacing == "" { - spacing = " " - } + const spacing = " " parts := make([]string, 0, len(buttons)*2-1) for i, b := range buttons { if i > 0 { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 98fc293..208e72f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -132,14 +132,22 @@ type Model struct { subagentProgress []string // tool call progress lines for box display subagentTokens int64 // cumulative tokens used by current subagent - // Exit confirmation + // Exit / cancel confirmation exitPending bool // true when quit dialog is showing exitWarningTime time.Time // when the warning was shown exitSelected int // 0=Yes, 1=No (default No for safety) + // Cancel-agent confirmation + cancelPending bool // true when cancel-agent dialog is showing + cancelSelected int // 0=Cancel, 1=Wait + // Copy notice copyNotice string + // OnApprovalModeChange is called when the user toggles approval mode via Ctrl+A. + // It directly updates the backend ApprovalState atomically, bypassing the event loop. + OnApprovalModeChange func(enabled bool) + // Command autocomplete suggestions cmdSuggestionActive bool cmdSuggestionIndex int @@ -275,7 +283,7 @@ func NewModel(hasPrompt bool, pwd string, todoStore *tools.TodoStore) Model { lipgloss.NewStyle().Foreground(colorText).PaddingLeft(2).Render("📁 I can read, write, and edit files in your project"), lipgloss.NewStyle().Foreground(colorText).PaddingLeft(2).Render("⚡ I can execute shell commands for you"), "", - lipgloss.NewStyle().Foreground(colorMuted).PaddingLeft(2).Render("Ctrl+P: Plan │ Ctrl+A: Approval │ Ctrl+L: Model │ Drag: Select & Copy"), + lipgloss.NewStyle().Foreground(colorMuted).PaddingLeft(2).Render("Ctrl+P: Plan │ Ctrl+A: Approval │ Ctrl+L: Model │ Ctrl+C: Cancel │ Drag: Select & Copy"), "", } } @@ -393,6 +401,26 @@ func (m Model) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, textarea.Blink) } +// cancelAgent shows a confirmation dialog to cancel the running agent. +// The actual cancellation happens when the user confirms. +func (m *Model) requestCancelAgent() { + if !m.thinking || m.agentDone { + return + } + m.cancelPending = true + m.cancelSelected = 0 // default to "Cancel" + m.refreshViewport() +} + +// confirmCancelAgent executes the agent cancellation after user confirms. +func (m *Model) confirmCancelAgent() { + m.cancelPending = false + select { + case cancelAgentCh <- struct{}{}: + default: + } +} + func (m Model) inputActive() bool { return (m.mode == ModeAgent || m.sshStep > 0 || m.sshSavePrompt) && !m.pickingModel && !m.showingSetting && !m.pickingSSHAlias && !m.pickingSession && !m.approvalPending && !m.planReviewActive && !m.askUserActive } @@ -499,9 +527,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen if m.approvalRespChan != nil { m.approvalRespChan <- ToolApprovalResponse{Approved: true, Mode: ModeAuto} } - select { - case autoApproveCh <- true: - default: + if m.OnApprovalModeChange != nil { + m.OnApprovalModeChange(true) } case 2: // Reject m.approvalPending = false @@ -525,15 +552,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen m.refreshViewport() return m, tea.Batch(cmds...) case "a", "A": - // Event: ApproveAll - approve current and switch to AUTO mode m.approvalPending = false m.approvalMode = ModeAuto if m.approvalRespChan != nil { m.approvalRespChan <- ToolApprovalResponse{Approved: true, Mode: ModeAuto} } - select { - case autoApproveCh <- true: - default: + if m.OnApprovalModeChange != nil { + m.OnApprovalModeChange(true) } m.textarea.Focus() m.refreshViewport() @@ -959,6 +984,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen } if m.inputActive() { + // Cancel-agent dialog is an overlay — handle its keys first + if m.cancelPending { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "left", "right", "tab": + m.cancelSelected = 1 - m.cancelSelected + return m, tea.Batch(cmds...) + case "enter", " ": + if m.cancelSelected == 0 { + m.confirmCancelAgent() + } else { + m.cancelPending = false + } + return m, tea.Batch(cmds...) + case "y", "Y": + m.confirmCancelAgent() + return m, tea.Batch(cmds...) + case "n", "N", "esc": + m.cancelPending = false + return m, tea.Batch(cmds...) + default: + m.cancelPending = false + } + } + // Exit dialog is an overlay — handle its keys first if m.exitPending && msg.String() != "ctrl+c" { switch msg.String() { @@ -984,6 +1035,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen switch msg.String() { case "ctrl+c": + // If agent is running, show cancel dialog instead of quit dialog + if m.thinking && !m.agentDone { + m.requestCancelAgent() + return m, tea.Batch(cmds...) + } // Check if already pending (2nd Ctrl+C = force quit) if m.exitPending { return m, tea.Quit @@ -1010,15 +1066,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen m.refreshViewport() return m, tea.Batch(cmds...) case "ctrl+a": - // Event: ToggleMode - switch between MANUAL and AUTO approval modes if m.approvalMode == ModeManual { m.approvalMode = ModeAuto } else { m.approvalMode = ModeManual } - select { - case autoApproveCh <- (m.approvalMode == ModeAuto): - default: + if m.OnApprovalModeChange != nil { + m.OnApprovalModeChange(m.approvalMode == ModeAuto) } m.refreshViewport() return m, tea.Batch(cmds...) @@ -1300,10 +1354,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen } return m, tea.Batch(cmds...) } - // Agent running — handle exit dialog or ctrl+c - if m.exitPending { + // Agent running — handle cancel/exit dialogs or ctrl+c + switch { + case m.cancelPending: switch msg.String() { case "ctrl+c": + // 2nd Ctrl+C while cancel dialog → just quit + return m, tea.Quit + case "left", "right", "tab": + m.cancelSelected = 1 - m.cancelSelected + return m, tea.Batch(cmds...) + case "enter", " ": + if m.cancelSelected == 0 { + m.confirmCancelAgent() + } else { + m.cancelPending = false + } + return m, tea.Batch(cmds...) + case "y", "Y": + m.confirmCancelAgent() + return m, tea.Batch(cmds...) + case "n", "N", "esc": + m.cancelPending = false + return m, tea.Batch(cmds...) + default: + m.cancelPending = false + } + case m.exitPending: + switch msg.String() { + case "ctrl+c": + // 2nd Ctrl+C while exit dialog is showing during agent run: force quit return m, tea.Quit case "left", "right", "tab": m.exitSelected = 1 - m.exitSelected @@ -1323,13 +1403,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen default: m.exitPending = false } - } else if msg.String() == "ctrl+c" { - m.exitPending = true - m.exitSelected = 1 // Default to "No" - m.exitWarningTime = time.Now() - return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { - return ExitTimeoutMsg{} - }) + case msg.String() == "ctrl+c": + // Show cancel-agent confirmation dialog + m.requestCancelAgent() + return m, tea.Batch(cmds...) } case tea.WindowSizeMsg: @@ -1623,7 +1700,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen m.thinking = false m.flushText() if msg.Err != nil { - m.lines = append(m.lines, errorStyle.Render("Error: "+msg.Err.Error())) + if msg.Err.Error() == "context canceled" { + // User-initiated cancellation — show a clean message, not an error. + m.lines = append(m.lines, lipgloss.NewStyle().Foreground(colorMuted).Render("⏹ Agent cancelled.")) + } else { + m.lines = append(m.lines, errorStyle.Render("Error: "+msg.Err.Error())) + } } m.lines = append(m.lines, "") m.agentDone = true @@ -1912,23 +1994,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen case tea.InterruptMsg: // Handle ctrl+c from non-TTY / signal handler path. - if m.exitPending { + if m.exitPending || m.cancelPending { return m, tea.Quit } + // If agent is running, show cancel dialog instead of quit dialog. + if m.thinking && !m.agentDone { + m.requestCancelAgent() + return m, tea.Batch(cmds...) + } m.exitPending = true m.exitSelected = 1 m.exitWarningTime = time.Now() quitButtons := buttonGroup([]buttonOpts{ {text: " Yes ", selected: false}, {text: " No ", selected: true}, - }, " ") - hint := "" - if m.thinking && !m.agentDone { - hint = " " + lipgloss.NewStyle().Foreground(colorMuted).Italic(true).Render("(agent is running)") - } - m.lines = append(m.lines, fmt.Sprintf(" %s %s%s", + }) + m.lines = append(m.lines, fmt.Sprintf(" %s %s", lipgloss.NewStyle().Foreground(colorWarning).Bold(true).Render("Quit?"), - quitButtons, hint)) + quitButtons)) m.refreshViewport() return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return ExitTimeoutMsg{} @@ -2026,6 +2109,10 @@ func (m Model) View() tea.View { return m.newView(m.approvalDialogView()) } + if m.cancelPending { + return m.newView(m.cancelDialogView()) + } + if m.exitPending { return m.newView(m.exitDialogView()) } @@ -2229,8 +2316,23 @@ func (m *Model) renderSubagentBox() string { return box } -func RunTUI(hasPrompt bool, pwd string, todoStore *tools.TodoStore) (*tea.Program, Model) { +// ModelOption configures a Model before the BubbleTea program starts. +type ModelOption func(*Model) + +// WithApprovalModeChange sets the callback invoked when the user toggles +// approval mode via Ctrl+A or the approval dialog. The callback directly +// updates the backend ApprovalState atomically, bypassing the event loop. +func WithApprovalModeChange(fn func(bool)) ModelOption { + return func(m *Model) { + m.OnApprovalModeChange = fn + } +} + +func RunTUI(hasPrompt bool, pwd string, todoStore *tools.TodoStore, opts ...ModelOption) (*tea.Program, Model) { m := NewModel(hasPrompt, pwd, todoStore) + for _, opt := range opts { + opt(&m) + } p := tea.NewProgram(m) return p, m }