on main help sign in
refactor(ui): thread program lifecycle context through workspace calls

The root UI model called every async workspace operation with context.Background()/TODO() from inside tea.Cmd closures, so a slow DB/LSP/ MCP/network call could not be cancelled on SIGINT or program exit. Pass the program's context (cmd.Context(), already used for tea.WithContext) into ui.New and thread it to those calls. The normal path is unchanged (the context is only cancelled at teardown), so behavior is identical until shutdown, when in-flight work now aborts promptly instead of orphaning.

commit36d571e0740d153274301945e8cda3f37cf2c471 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 20:27 (3 months ago) parent6c2ba62
4 files changed+36−33
internal/cmd/root.go+1−1 internal/ui/model/history.go+1−2 internal/ui/model/session.go+5−6 internal/ui/model/ui.go+29−24
internal/cmd/root.go+1−1
@@ -124,7 +124,7 @@ crush --continue
124124 event.AppInitialized()
125125
126126 com := common.DefaultCommon(ws)
127− model := ui.New(com, sessionID, continueLast)
127+ model := ui.New(cmd.Context(), com, sessionID, continueLast)
128128
129129 inputFilter := ui.NewFilter()
130130 var env uv.Environ = os.Environ()
internal/ui/model/history.go+1−2
@@ -1,7 +1,6 @@
11package model
22
33import (
4− "context"
54 "log/slog"
65 "strings"
76
@@ -18,7 +17,7 @@ type promptHistoryLoadedMsg struct {
1817// loadPromptHistory loads user messages for history navigation.
1918func (m *UI) loadPromptHistory() tea.Cmd {
2019 return func() tea.Msg {
21− ctx := context.Background()
20+ ctx := m.ctx
2221 var messages []message.Message
2322 var err error
2423
internal/ui/model/session.go+5−6
@@ -1,7 +1,6 @@
11package model
22
33import (
4− "context"
54 "fmt"
65 "log/slog"
76 "path/filepath"
@@ -71,7 +70,7 @@ type SessionFile struct {
7170// UI never blocks on the call.
7271func (m *UI) loadSession(sessionID string) tea.Cmd {
7372 load := func() tea.Msg {
74− session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
73+ session, err := m.com.Workspace.GetSession(m.ctx, sessionID)
7574 if err != nil {
7675 return util.ReportError(err)
7776 }
@@ -81,7 +80,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
8180 return util.ReportError(err)
8281 }
8382
84− readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
83+ readFiles, err := m.com.Workspace.FileTrackerListReadFiles(m.ctx, sessionID)
8584 if err != nil {
8685 slog.Error("Failed to load read files for session", "error", err)
8786 }
@@ -102,7 +101,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
102101// state.
103102func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
104103 return func() tea.Msg {
105− if err := m.com.Workspace.SetCurrentSession(context.Background(), sessionID); err != nil {
104+ if err := m.com.Workspace.SetCurrentSession(m.ctx, sessionID); err != nil {
106105 slog.Debug("Failed to report current session", "session_id", sessionID, "error", err)
107106 }
108107 return nil
@@ -110,7 +109,7 @@ func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
110109}
111110
112111func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
113− files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
112+ files, err := m.com.Workspace.ListSessionHistory(m.ctx, sessionID)
114113 if err != nil {
115114 return nil, err
116115 }
@@ -264,7 +263,7 @@ func (m *UI) startLSPs(paths []string) tea.Cmd {
264263 }
265264
266265 return func() tea.Msg {
267− ctx := context.Background()
266+ ctx := m.ctx
268267 for _, path := range paths {
269268 m.com.Workspace.LSPStart(ctx, path)
270269 }
internal/ui/model/ui.go+29−24
@@ -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 }