diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 708b92577f..cbe60c2d62 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 @@ -779,6 +780,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) @@ -883,6 +889,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/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. diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 69fa7bdb85..5743dc16b2 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 { @@ -288,15 +288,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 @@ -485,6 +490,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) { @@ -525,6 +533,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) { @@ -563,6 +574,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) {