@@ -176,6 +176,10 @@ type (
176176
177177// UI represents the main user interface model.
178178type UI struct {
179+ // ctx is the program's lifecycle context (cancelled on SIGINT / program
180+ // exit). Async workspace operations launched from tea.Cmd closures use
181+ // it so they can be cancelled at teardown instead of orphaning.
182+ ctx context.Context
179183 com *common.Common
180184 session *session.Session
181185 sessionFiles []SessionFile
@@ -362,7 +366,7 @@ type UI struct {
362366}
363367
364368// New creates a new instance of the [UI] model.
365−func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
369+func New(ctx context.Context, com *common.Common, initialSessionID string, continueLast bool) *UI {
366370 // Editor components
367371 ta := textarea.New()
368372 ta.SetStyles(com.Styles.Editor.Textarea)
@@ -413,6 +417,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
413417 header := newHeader(com)
414418
415419 ui := &UI{
420+ ctx: ctx,
416421 com: com,
417422 dialog: dialog.NewOverlay(),
418423 keyMap: keyMap,
@@ -515,7 +520,7 @@ func (m *UI) loadInitialSession() tea.Cmd {
515520 return m.loadSession(m.initialSessionID)
516521 case m.continueLastSession:
517522 return func() tea.Msg {
518− sessions, err := m.com.Workspace.ListSessions(context.Background())
523+ sessions, err := m.com.Workspace.ListSessions(m.ctx)
519524 if err != nil || len(sessions) == 0 {
520525 return nil
521526 }
@@ -627,7 +632,7 @@ func (m *UI) loadCustomCommands() tea.Cmd {
627632 slog.Error("Failed to load custom commands", "error", err)
628633 }
629634 // Append user-invocable skills as commands.
630− skillEntries, err := m.com.Workspace.ListSkills(context.Background())
635+ skillEntries, err := m.com.Workspace.ListSkills(m.ctx)
631636 if err != nil {
632637 slog.Error("Failed to load skill commands", "error", err)
633638 }
@@ -688,7 +693,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
688693 m.session = msg.session
689694 m.sessionFiles = msg.files
690695 cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
691− msgs, err := m.com.Workspace.ListMessages(context.Background(), m.session.ID)
696+ msgs, err := m.com.Workspace.ListMessages(m.ctx, m.session.ID)
692697 if err != nil {
693698 cmds = append(cmds, util.ReportError(err))
694699 break
@@ -1262,7 +1267,7 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
12621267 agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
12631268
12641269 // Fetch nested messages.
1265− nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
1270+ nestedMsgs, err := m.com.Workspace.ListMessages(m.ctx, agentSessionID)
12661271 if err != nil || len(nestedMsgs) == 0 {
12671272 continue
12681273 }
@@ -1647,7 +1652,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
16471652 break
16481653 }
16491654 cmds = append(cmds, func() tea.Msg {
1650− err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1655+ err := m.com.Workspace.AgentSummarize(m.ctx, msg.SessionID)
16511656 if err != nil {
16521657 return util.ReportError(err)()
16531658 }
@@ -1693,7 +1698,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
16931698 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
16941699 return util.ReportError(err)()
16951700 }
1696− if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1701+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
16971702 slog.Warn("failed to update agent model", "error", err)
16981703 }
16991704 status := "disabled"
@@ -1770,7 +1775,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
17701775 }
17711776
17721777 cmds = append(cmds, func() tea.Msg {
1773− if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1778+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
17741779 slog.Warn("failed to update agent model", "error", err)
17751780 }
17761781 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
@@ -1863,7 +1868,7 @@ func substituteArgs(content string, args map[string]string) string {
18631868// OAuth dialog opens.
18641869func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
18651870 return func() tea.Msg {
1866− ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1871+ ctx, cancel := context.WithTimeout(m.ctx, 15*time.Second)
18671872 defer cancel()
18681873 if err := m.com.Workspace.RefreshOAuthToken(ctx, config.ScopeGlobal, "hyper"); err != nil {
18691874 slog.Warn("Hyper OAuth refresh failed, requesting re-auth", "error", err)
@@ -1898,7 +1903,7 @@ func (m *UI) fetchHyperCredits() tea.Cmd {
18981903 }
18991904
19001905 if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
1901− ctxRefresh, cancelRefresh := context.WithTimeout(context.Background(), 15*time.Second)
1906+ ctxRefresh, cancelRefresh := context.WithTimeout(m.ctx, 15*time.Second)
19021907 defer cancelRefresh()
19031908 if err := m.com.Workspace.RefreshOAuthToken(ctxRefresh, config.ScopeGlobal, hyper.Name); err != nil {
19041909 slog.Warn("Hyper OAuth refresh failed before fetching credits, trying with existing token", "error", err)
@@ -1907,7 +1912,7 @@ func (m *UI) fetchHyperCredits() tea.Cmd {
19071912 }
19081913 }
19091914
1910− ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1915+ ctx, cancel := context.WithTimeout(m.ctx, 10*time.Second)
19111916 defer cancel()
19121917 credits, err := hyper.FetchCredits(ctx, apiKey)
19131918 if err != nil {
@@ -1983,7 +1988,7 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
19831988 }
19841989
19851990 cmds = append(cmds, func() tea.Msg {
1986− if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1991+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
19871992 return util.ReportError(err)
19881993 }
19891994
@@ -2006,7 +2011,7 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
20062011 if isOnboarding {
20072012 m.setState(uiLanding, uiFocusEditor)
20082013 m.com.Config().SetupAgents()
2009− if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
2014+ if err := m.com.Workspace.InitCoderAgent(m.ctx); err != nil {
20102015 cmds = append(cmds, util.ReportError(err))
20112016 }
20122017 } else if m.com.IsHyper() {
@@ -3382,7 +3387,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
33823387
33833388 if m.hasSession() {
33843389 // Skip attachment if file was already read and hasn't been modified.
3385− lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
3390+ lastRead := m.com.Workspace.FileTrackerLastReadTime(m.ctx, m.session.ID, absPath)
33863391 if !lastRead.IsZero() {
33873392 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
33883393 return nil
@@ -3424,7 +3429,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
34243429
34253430 resourceCmd := func() tea.Msg {
34263431 contents, err := m.com.Workspace.ReadMCPResource(
3427− context.Background(),
3432+ m.ctx,
34283433 item.MCPName,
34293434 item.URI,
34303435 )
@@ -3633,7 +3638,7 @@ func (m *UI) refreshStyles() {
36333638// return one.
36343639func (m *UI) attachSkill(skillID, name string) tea.Cmd {
36353640 return func() tea.Msg {
3636− content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
3641+ content, result, err := m.com.Workspace.ReadSkill(m.ctx, skillID)
36373642 if err != nil {
36383643 return util.NewErrorMsg(err)
36393644 }
@@ -3658,7 +3663,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
36583663
36593664 var cmds []tea.Cmd
36603665 if !m.hasSession() {
3661− newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3666+ newSession, err := m.com.Workspace.CreateSession(m.ctx, "New Session")
36623667 if err != nil {
36633668 return util.ReportError(err)
36643669 }
@@ -3672,7 +3677,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
36723677 m.setState(uiChat, m.focus)
36733678 }
36743679
3675− ctx := context.Background()
3680+ ctx := m.ctx
36763681 cmds = append(cmds, func() tea.Msg {
36773682 for _, path := range m.sessionFileReads {
36783683 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
@@ -3688,7 +3693,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
36883693 // been accepted (HTTP 202) or synchronously with a validation
36893694 // or transport error. Run failures and cancellation surface
36903695 // through SSE-derived events, not this return value.
3691− err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
3696+ err := m.com.Workspace.AgentRun(m.ctx, sessionID, content, attachments...)
36923697 if err != nil {
36933698 return util.InfoMsg{
36943699 Type: util.InfoTypeError,
@@ -3712,7 +3717,7 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
37123717func (m *UI) runShellCommandInternal(command string, isFirstMessage bool) tea.Cmd {
37133718 var cmds []tea.Cmd
37143719 if !m.hasSession() {
3715− newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3720+ newSession, err := m.com.Workspace.CreateSession(m.ctx, "New Session")
37163721 if err != nil {
37173722 return util.ReportError(err)
37183723 }
@@ -3765,7 +3770,7 @@ func (m *UI) runShellCommandInternal(command string, isFirstMessage bool) tea.Cm
37653770 return shellStreamMsg{PendingID: pendingID, Chunk: chunk, streamCh: streamCh}
37663771 })
37673772
3768− ctx, cancel := context.WithCancel(context.Background())
3773+ ctx, cancel := context.WithCancel(m.ctx)
37693774 m.bangCancel = cancel
37703775
37713776 cmds = append(cmds, func() tea.Msg {
@@ -4107,7 +4112,7 @@ func (m *UI) newSession() tea.Cmd {
41074112 agenttools.ResetCache()
41084113 return tea.Batch(
41094114 func() tea.Msg {
4110− m.com.Workspace.LSPStopAll(context.Background())
4115+ m.com.Workspace.LSPStopAll(m.ctx)
41114116 return nil
41124117 },
41134118 m.loadPromptHistory(),
@@ -4427,7 +4432,7 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
44274432
44284433func (m *UI) handleStateChanged() tea.Cmd {
44294434 return func() tea.Msg {
4430− if err := m.com.Workspace.UpdateAgentModel(context.Background()); err != nil {
4435+ if err := m.com.Workspace.UpdateAgentModel(m.ctx); err != nil {
44314436 slog.Warn("failed to update agent model", "error", err)
44324437 }
44334438 return mcpStateChangedMsg{
@@ -4470,7 +4475,7 @@ func (m *UI) copyChatHighlight() tea.Cmd {
44704475}
44714476
44724477func (m *UI) enableDockerMCP() tea.Msg {
4473− ctx := context.Background()
4478+ ctx := m.ctx
44744479 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
44754480 return util.ReportError(err)()
44764481 }