Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 44 additions & 12 deletions internal/command/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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})
}
Expand Down Expand Up @@ -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})
}
Expand Down Expand Up @@ -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})
}
Expand All @@ -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{
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -786,6 +816,7 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error {

st := &interactiveState{
ctx: ctx,
cancelFunc: cancelFunc,
cfg: cfg,
chatModel: chatModel,
env: env,
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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

Expand Down
16 changes: 8 additions & 8 deletions internal/tui/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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{}

Expand Down
44 changes: 42 additions & 2 deletions internal/tui/pickers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down
6 changes: 2 additions & 4 deletions internal/tui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading