Skip to content
19 changes: 19 additions & 0 deletions packages/tui/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type App struct {
InitialAgent *string
InitialSession *string
compactCancel context.CancelFunc
AbortedSessions map[string]bool
IsLeaderSequence bool
IsBashMode bool
ScrollSpeed int
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/internal/components/textarea/textarea.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 18 additions & 4 deletions packages/tui/internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const (
ExitKeyFirstPress
)

const interruptDebounceTimeout = 1 * time.Second
const interruptDebounceTimeout = 300 * time.Millisecond
const exitDebounceTimeout = 1 * time.Second

type Model struct {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down