From 9bf38846443c118d25c880e6446b6df08be1d9bf Mon Sep 17 00:00:00 2001 From: Adolfo Usier Date: Sun, 19 Oct 2025 13:19:42 +0100 Subject: [PATCH 1/3] Fix escape twice interrupt to properly kill operations - Add AbortedSessions map to track aborted sessions - Ignore SSE events for aborted sessions to stop UI streaming - Mark last assistant message as completed on abort - Clear aborted flag when starting new operations to allow subsequent requests This fix ensures cancellation works properly. --- packages/tui/internal/app/app.go | 19 +++++++++++++++++++ packages/tui/internal/tui/tui.go | 25 +++++++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 4a891f2827..70c0ccc7a1 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -49,6 +49,7 @@ type App struct { InitialAgent *string InitialSession *string compactCancel context.CancelFunc + AbortedSessions map[string]bool IsLeaderSequence bool IsBashMode bool ScrollSpeed int @@ -791,6 +792,11 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) { cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session})) } + // Clear aborted flag for new operation + if a.AbortedSessions != nil { + delete(a.AbortedSessions, a.Session.ID) + } + messageID := id.Ascending(id.Message) message := prompt.ToMessage(messageID, a.Session.ID) @@ -895,6 +901,19 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error { a.compactCancel = nil } + if a.AbortedSessions == nil { + a.AbortedSessions = make(map[string]bool) + } + a.AbortedSessions[sessionID] = true + + // Mark the last assistant message as completed to update IsBusy + if len(a.Messages) > 0 { + if lastMsg, ok := a.Messages[len(a.Messages)-1].Info.(opencode.AssistantMessage); ok && lastMsg.Time.Completed == 0 { + lastMsg.Time.Completed = float64(time.Now().UnixMilli()) + a.Messages[len(a.Messages)-1].Info = lastMsg + } + } + _, err := a.Client.Session.Abort(ctx, sessionID, opencode.SessionAbortParams{}) if err != nil { slog.Error("Failed to cancel session", "error", err) diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 50b503c66b..fcd4e02724 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -54,7 +54,7 @@ const ( ExitKeyFirstPress ) -const interruptDebounceTimeout = 1 * time.Second +const interruptDebounceTimeout = 300 * time.Millisecond const exitDebounceTimeout = 1 * time.Second type Model struct { @@ -282,15 +282,20 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 7. Handle interrupt key debounce for session interrupt interruptCommand := a.app.Commands[commands.SessionInterruptCommand] - if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() { + if interruptCommand.Matches(msg, a.app.IsLeaderSequence) { + if !a.app.IsBusy() { + return a, nil + } switch a.interruptKeyState { case InterruptKeyIdle: // First interrupt key press - start debounce timer a.interruptKeyState = InterruptKeyFirstPress a.editor.SetInterruptKeyInDebounce(true) - return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg { + cmds = append(cmds, toast.NewInfoToast("Press again to interrupt")) + cmds = append(cmds, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg { return InterruptDebounceTimeoutMsg{} - }) + })) + return a, tea.Batch(cmds...) case InterruptKeyFirstPress: // Second interrupt key press within timeout - actually interrupt a.interruptKeyState = InterruptKeyIdle @@ -479,6 +484,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case opencode.EventListResponseEventMessagePartUpdated: slog.Debug("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID) + if a.app.AbortedSessions[msg.Properties.Part.SessionID] { + return a, nil + } if msg.Properties.Part.SessionID == a.app.Session.ID { messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool { switch casted := m.Info.(type) { @@ -519,6 +527,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case opencode.EventListResponseEventMessagePartRemoved: slog.Debug("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID) + if a.app.AbortedSessions[msg.Properties.SessionID] { + return a, nil + } if msg.Properties.SessionID == a.app.Session.ID { messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool { switch casted := m.Info.(type) { @@ -557,6 +568,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case opencode.EventListResponseEventMessageRemoved: slog.Debug("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID) + if a.app.AbortedSessions[msg.Properties.SessionID] { + return a, nil + } if msg.Properties.SessionID == a.app.Session.ID { messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool { switch casted := m.Info.(type) { @@ -572,6 +586,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case opencode.EventListResponseEventMessageUpdated: + if a.app.AbortedSessions[msg.Properties.Info.SessionID] { + return a, nil + } if msg.Properties.Info.SessionID == a.app.Session.ID { matchIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool { switch casted := m.Info.(type) { From d4fa1caee94179149be1addf06b7b367a359d8d3 Mon Sep 17 00:00:00 2001 From: Adolfo Usier Date: Mon, 20 Oct 2025 18:57:10 +0100 Subject: [PATCH 2/3] Fix escape interrupt by removing aborted session check in message update --- packages/tui/internal/tui/tui.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 265c1ec475..6704fe7f9e 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -592,9 +592,6 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case opencode.EventListResponseEventMessageUpdated: - if a.app.AbortedSessions[msg.Properties.Info.SessionID] { - return a, nil - } if msg.Properties.Info.SessionID == a.app.Session.ID { matchIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool { switch casted := m.Info.(type) { From 12a09acce6d8d9a5a998d57c7f220d63c6cdca78 Mon Sep 17 00:00:00 2001 From: Adolfo Usier Date: Thu, 23 Oct 2025 16:51:05 +0100 Subject: [PATCH 3/3] feat: atomic ESC interrupt reliability improvements - tui.go: ESC double-press with 1s timeout, consistent debounce behavior - textarea.go: cache reset for memory management during active typing - app.go: AbortedSessions tracking + enhanced Cancel method (already present) --- packages/tui/internal/components/textarea/textarea.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 6e6695917d..12ea436f55 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -1183,6 +1183,8 @@ func (m *Model) Reset() { m.col = 0 m.row = 0 m.SetCursorColumn(0) + // Clear memoization cache to prevent memory leak during active typing + m.cache = NewMemoCache[line, [][]any](maxLines) } // san initializes or retrieves the rune sanitizer.