diff --git a/internal/tui/dispatch_test.go b/internal/tui/dispatch_test.go index 441835e..542e9c3 100644 --- a/internal/tui/dispatch_test.go +++ b/internal/tui/dispatch_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "time" @@ -81,14 +82,43 @@ func TestPanelKeyBoundsAndQuit(t *testing.T) { func TestCancelRunGuards(t *testing.T) { m := wired(t) - // Not busy → nil. - if m.cancelRun() != nil { - t.Error("cancelRun not busy should be nil") + // Not busy → no cancel API call, just the "nothing to cancel" note and + // its expiry sweep. + if cmd := m.cancelRun(); cmd == nil { + t.Error("cancelRun not busy should still return the notice sweep") } - // Busy but no session id → nil. + if n := len(m.notices); n == 0 || m.notices[n-1] != "nothing to cancel" || + m.noticeExp[n-1].IsZero() { + t.Errorf("idle cancelRun should post a transient note, got %v", m.notices) + } + // Busy but no session id → same note path, no cancel. m.busy = true - if m.cancelRun() != nil { - t.Error("cancelRun without session should be nil") + m.notices = nil + m.noticeExp = nil + if cmd := m.cancelRun(); cmd == nil { + t.Error("cancelRun without session should still return the notice sweep") + } + if n := len(m.notices); n == 0 || m.notices[n-1] != "nothing to cancel" { + t.Errorf("sessionless cancelRun should post the note, got %v", m.notices) + } +} + +func TestResumedSessionNoteFades(t *testing.T) { + m := wired(t) + cmd := m.handleSessionDetail(sessionDetailMsg{sess: client.Session{ID: "20260808-f25bd6fd-9f2a"}}) + if cmd == nil { + t.Fatal("resume should arm the notice expiry sweep") + } + n := len(m.notices) + if n == 0 || !strings.Contains(m.notices[n-1], "resumed session") { + t.Fatalf("resume note missing: %v", m.notices) + } + if m.noticeExp[n-1].IsZero() { + t.Fatal("resume note must be transient (expiry set), not sticky") + } + m.pruneNotices(time.Now().Add(noticeTTL + time.Second)) + if len(m.notices) != 0 { + t.Errorf("resume note should fade after the TTL, got %v", m.notices) } } diff --git a/internal/tui/events.go b/internal/tui/events.go index 3bf1a4e..553aea3 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -395,6 +395,16 @@ func (m *Model) addTransientNote(s string) { m.noticeSeq++ } +// transientNoteCmd adds a transient note and returns the cmd that sweeps it +// after noticeTTL. handleEvent arms the sweep itself; every other caller +// (key handlers, async results) must batch this cmd or the note only fades +// on the next unrelated render. +func (m *Model) transientNoteCmd(s string) tea.Cmd { + prev := m.noticeSeq + m.addTransientNote(s) + return m.noticeTimer(prev) +} + func (m *Model) pushNote(s string, exp time.Time) { m.notices = append(m.notices, sanitize(s)) m.noticeExp = append(m.noticeExp, exp) diff --git a/internal/tui/model.go b/internal/tui/model.go index 62ba995..bf1cf9e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -289,8 +289,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case sessionDetailMsg: - m.handleSessionDetail(msg) - return m, nil + return m, m.handleSessionDetail(msg) case sessionDeletedMsg: return m, m.handleSessionDeleted(msg) @@ -302,8 +301,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { // The API accepted the abort; the turn's done event settles the // status shortly after. Acknowledge the keypress in the meantime. - m.addTransientNote("cancelled") + cmd := m.transientNoteCmd("cancelled") m.refresh() + return m, cmd } return m, nil @@ -403,9 +403,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.thinkOn { state = "on" } - m.addTransientNote("thinking " + state) + cmd := m.transientNoteCmd("thinking " + state) m.refresh() - return m, nil + return m, cmd case "ctrl+l": if !m.busy { m.clearConversation() @@ -419,10 +419,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.expandAll { state = "on" } - m.addTransientNote("tool details " + state) + cmd := m.transientNoteCmd("tool details " + state) m.convCount = -1 // re-render the cached transcript prefix too m.refresh() - return m, nil + return m, cmd case "ctrl+p": // Prompt-history recall lives on this dedicated readline-style // binding so bare ↑/↓ are free to scroll the transcript — the far diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 604a53a..1be766f 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -179,10 +179,11 @@ func (m *Model) deleteSelected() tea.Cmd { // for editing instead of firing them into a cancelled session. func (m *Model) cancelRun() tea.Cmd { if !m.busy || m.sessionID == "" { - m.addTransientNote("nothing to cancel") + cmd := m.transientNoteCmd("nothing to cancel") m.refresh() - return nil + return cmd } + var note tea.Cmd if len(m.queue) > 0 { draft := strings.Join(m.queue, "\n") if cur := m.ta.Value(); cur != "" { @@ -192,15 +193,15 @@ func (m *Model) cancelRun() tea.Cmd { m.ta.CursorEnd() m.queue = nil // The textarea content just changed out from under the user — say why. - m.addTransientNote("queued prompts returned to the input") + note = m.transientNoteCmd("queued prompts returned to the input") } m.status = "cancelling" m.refresh() cl := m.cl sid, tok := m.sessionID, m.authToken - return func() tea.Msg { + return tea.Batch(note, func() tea.Msg { return cancelDoneMsg{err: cl.Cancel(sid, tok)} - } + }) } // ── async result handling ──────────────────────────────────────────────────── @@ -244,10 +245,10 @@ func (m *Model) handleLimitsMsg(msg limitsMsg) { m.limits = msg.resp.Limits } -func (m *Model) handleSessionDetail(msg sessionDetailMsg) { +func (m *Model) handleSessionDetail(msg sessionDetailMsg) tea.Cmd { if msg.err != nil { m.panelMsg = "error: " + msg.err.Error() - return + return nil } // Replay the saved transcript into the local view and resume server-side // on the next prompt via session_id + auth_token. @@ -274,8 +275,9 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) { m.msgs = m.msgs[:0] m.convCount = -1 // transcript swapped for the resumed one — drop the cache m.replayTranscript(msg.sess.Messages) - m.addNote("resumed session " + shortID(msg.sess.ID)) + note := m.transientNoteCmd("resumed session " + shortID(msg.sess.ID)) m.closePanel() + return note } // replayTranscript rebuilds a saved transcript turn by turn so a resumed