From 6d55c12d25522d2eb95f4a7c9832c41088fb712d Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 1 Nov 2025 09:06:18 -0600 Subject: [PATCH] feat(notification): cancel on chat interaction Prevent completion notifications from showing when users are actively interacting with the chat interface (typing, clicking, scrolling, opening dialogs). Co-Authored-By: Crush --- internal/agent/agent.go | 20 ++++++++++++ internal/agent/coordinator.go | 22 +++++++++++++ internal/tui/components/chat/chat.go | 12 +++++++ internal/tui/components/chat/editor/editor.go | 11 +++++++ internal/tui/page/chat/chat.go | 31 +++++++++++++++++++ internal/tui/tui.go | 15 ++++++++- 6 files changed, 110 insertions(+), 1 deletion(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9d4af25d96a7102732febe80921094d4fd2d065f..85394f17728ca4d01088bbcb2eff16e3136ffd55 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -59,6 +59,12 @@ type SessionAgent interface { ClearQueue(sessionID string) Summarize(context.Context, string, fantasy.ProviderOptions) error Model() Model + // CancelCompletionNotification cancels any scheduled "turn ended" + // notification for the provided sessionID. + CancelCompletionNotification(sessionID string) + // HasPendingCompletionNotification returns true if a turn-end + // notification has been scheduled and not yet cancelled/shown. + HasPendingCompletionNotification(sessionID string) bool } const completionNotificationDelay = 5 * time.Second @@ -546,6 +552,20 @@ func (a *sessionAgent) cancelCompletionNotification(sessionID string) { } } +// CancelCompletionNotification implements SessionAgent. +func (a *sessionAgent) CancelCompletionNotification(sessionID string) { + a.cancelCompletionNotification(sessionID) +} + +// HasPendingCompletionNotification implements SessionAgent. +func (a *sessionAgent) HasPendingCompletionNotification(sessionID string) bool { + if a.IsSessionBusy(sessionID) { + return false + } + _, ok := a.completionCancels.Get(sessionID) + return ok +} + func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error { if a.IsSessionBusy(sessionID) { return ErrSessionBusy diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index db92e49386285908aac47a5765af47f31eeb1d43..4d2d2110cdb661372761a8622a8313cdeddd8ad9 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -53,6 +53,12 @@ type Coordinator interface { Summarize(context.Context, string) error Model() Model UpdateModels(ctx context.Context) error + // CancelCompletionNotification cancels any scheduled "turn ended" + // notification for the provided sessionID. + CancelCompletionNotification(sessionID string) + // HasPendingCompletionNotification reports whether there is a pending + // turn-end notification for the given session. + HasPendingCompletionNotification(sessionID string) bool } type coordinator struct { @@ -153,6 +159,22 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, }) } +// CancelCompletionNotification implements Coordinator. +func (c *coordinator) CancelCompletionNotification(sessionID string) { + if c.currentAgent == nil || sessionID == "" { + return + } + c.currentAgent.CancelCompletionNotification(sessionID) +} + +// HasPendingCompletionNotification implements Coordinator. +func (c *coordinator) HasPendingCompletionNotification(sessionID string) bool { + if c.currentAgent == nil || sessionID == "" { + return false + } + return c.currentAgent.HasPendingCompletionNotification(sessionID) +} + func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions { options := fantasy.ProviderOptions{} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 139549a1f2221dc6e76bc40aac28bbe7a731f438..e694ad7c94bee1893f152782df915458fb8faaec 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -129,6 +129,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, nil // Ignore clicks outside the component } if msg.Button == tea.MouseLeft { + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } cmds = append(cmds, m.handleMouseClick(x, y)) return m, tea.Batch(cmds...) } @@ -148,6 +151,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, nil // Ignore clicks outside the component } if msg.Button == tea.MouseLeft { + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } m.listCmp.EndSelection(x, y) } return m, tea.Batch(cmds...) @@ -185,6 +191,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { if msg.endSelection { m.listCmp.EndSelection(msg.x, msg.y) } + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } m.listCmp.SelectionStop() cmds = append(cmds, m.CopySelectedText(true)) return m, tea.Batch(cmds...) @@ -207,6 +216,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { return m, tea.Batch(cmds...) case tea.MouseWheelMsg: + if m.app != nil && m.app.AgentCoordinator != nil && m.session.ID != "" && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } u, cmd := m.listCmp.Update(msg) m.listCmp = u.(list.List[list.Item]) cmds = append(cmds, cmd) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index d6b3528e7b91e8feded25b374b20f9a11dffc067..9f70b2b44216bf0bba89f842fde542bb1d7683ef 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -215,11 +215,18 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) { return m, util.ReportWarn("Agent is working, please wait...") } + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } return m, m.openEditor(m.textarea.Value()) case OpenEditorMsg: m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() case tea.PasteMsg: + // Interaction: cancel any pending turn-end notification for this session. + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } path := strings.ReplaceAll(string(msg), "\\ ", " ") // try to get an image path, err := filepath.Abs(strings.TrimSpace(path)) @@ -261,6 +268,10 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.setEditorPrompt() return m, nil case tea.KeyPressMsg: + // Interaction: cancel any pending turn-end notification for this session. + if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) { + m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID) + } cur := m.textarea.Cursor() curIdx := m.textarea.Width()*cur.Y + cur.X switch { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 6039f17a469400d823078583f153e909f2650209..b7f1b1c147be2a0de61921e27b7d0fdde9c57b51 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -174,6 +174,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { msg.Y -= 1 } if p.isMouseOverChat(msg.X, msg.Y) { + // Interaction: cancel any pending turn-end notification for this session. + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } u, cmd := p.chat.Update(msg) p.chat = u.(chat.MessageListCmp) return p, cmd @@ -246,6 +250,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { cmds = append(cmds, cmd) return p, tea.Batch(cmds...) case commands.ToggleCompactModeMsg: + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } p.forceCompact = !p.forceCompact var cmd tea.Cmd if p.forceCompact { @@ -257,8 +264,14 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { } return p, tea.Batch(p.SetSize(p.width, p.height), cmd) case commands.ToggleThinkingMsg: + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } return p, p.toggleThinking() case commands.OpenReasoningDialogMsg: + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } return p, p.openReasoningDialog() case reasoning.ReasoningEffortSelectedMsg: return p, p.handleReasoningEffortSelected(msg.Effort) @@ -316,6 +329,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { return p, tea.Batch(cmds...) case commands.ToggleYoloModeMsg: // update the editor style + if p.app != nil && p.app.AgentCoordinator != nil && p.session.ID != "" && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } u, cmd := p.editor.Update(msg) p.editor = u.(editor.Editor) return p, cmd @@ -360,6 +376,11 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { } return p, p.newSession() case tea.KeyPressMsg: + // If the chat pane is focused, any key-based navigation in chat counts + // as interaction; cancel any pending turn-end notification. + if p.focusedPane == PanelTypeChat && p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } switch { case key.Matches(msg, p.keyMap.NewSession): // if we have no agent do nothing @@ -374,6 +395,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { agentCfg := config.Get().Agents[config.AgentCoder] model := config.Get().GetModelByType(agentCfg.Model) if model.SupportsImages { + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } return p, util.CmdHandler(commands.OpenFilePickerMsg{}) } else { return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) @@ -384,6 +408,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { p.splash = u.(splash.Splash) return p, cmd } + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } p.changeFocus() return p, nil case key.Matches(msg, p.keyMap.Cancel): @@ -391,6 +418,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { return p, p.cancel() } case key.Matches(msg, p.keyMap.Details): + // Opening/closing the sidebar counts as interaction; cancel pending notification. + if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) { + p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID) + } p.toggleDetails() return p, nil } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d9dc0eb4e502a7a01c258e6a5c1e2f3dcf29ed46..a803605c9e998ba13dc0667723059cc5ff4a3511 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -191,6 +191,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case commands.SwitchModelMsg: + // Opening model dialog is interaction; cancel pending turn-end notif. + if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) { + a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID) + } return a, util.CmdHandler( dialogs.OpenDialogMsg{ Model: models.NewModelDialogCmp(), @@ -424,8 +428,13 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { a.status.ToggleFullHelp() a.showingFullHelp = !a.showingFullHelp return a.handleWindowResize(a.wWidth, a.wHeight) - // dialogs + // dialogs case key.Matches(msg, a.keyMap.Commands): + // Opening the command palette counts as interaction; cancel pending + // turn-end notification for the selected session if any. + if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) { + a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID) + } // if the app is not configured show no commands if !a.isConfigured { return nil @@ -444,6 +453,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { if !a.isConfigured { return nil } + // Opening sessions dialog is interaction; cancel pending turn-end notif. + if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) { + a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID) + } if a.dialog.ActiveDialogID() == sessions.SessionsDialogID { return util.CmdHandler(dialogs.CloseDialogMsg{}) }