Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions internal/tui/dispatch_test.go
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
package tui

import (
"strings"
"testing"
"time"

Expand DownExpand Up@@ -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)
}
}

Expand Down
10 changes: 10 additions & 0 deletions internal/tui/events.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
14 changes: 7 additions & 7 deletions internal/tui/model.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand All@@ -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

Expand DownExpand Up@@ -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()
Expand All@@ -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
Expand Down
18 changes: 10 additions & 8 deletions internal/tui/panels.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 != "" {
Expand All@@ -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 ────────────────────────────────────────────────────
Expand DownExpand Up@@ -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.
Expand All@@ -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
Expand Down