diff --git a/internal/tui/coverage_test.go b/internal/tui/coverage_test.go index 1a06738..835d863 100644 --- a/internal/tui/coverage_test.go +++ b/internal/tui/coverage_test.go @@ -81,6 +81,38 @@ func TestAddNoteRingBuffer(t *testing.T) { } } +func TestTransientNoticeExpires(t *testing.T) { + m := wired(t) + m.addNote("sticky") + m.addTransientNote("skill · loaded") + if cmd := m.noticeTimer(0); cmd == nil { + t.Fatal("transient notice should schedule an expiry timer") + } + // Expiry sweep drops the transient trace but keeps the sticky note. + m.noticeExp[1] = time.Now().Add(-time.Second) + if _, cmd := m.Update(noticeExpireMsg{seq: m.noticeSeq}); cmd != nil { + t.Error("expiry sweep should not reschedule") + } + if got := strings.Join(m.notices, "\n"); got != "sticky" { + t.Errorf("notices after expiry = %q", got) + } + // A stale timer (superseded by a newer notice) must not clear anything. + m.addTransientNote("skill · saved") + m.Update(noticeExpireMsg{seq: m.noticeSeq - 1}) + if len(m.notices) != 2 { + t.Errorf("stale timer cleared notices: %v", m.notices) + } +} + +func TestRenderNoticesHidesExpired(t *testing.T) { + m := wired(t) + m.addTransientNote("skill · loaded") + m.noticeExp[0] = time.Now().Add(-time.Second) + if got := m.renderNotices(); got != "" { + t.Errorf("expired notice still rendered: %q", got) + } +} + func TestArgPreviewFallbacks(t *testing.T) { if got := argPreview(`{"foo":"bar"}`); got != "bar" { t.Errorf("argPreview value-join = %q", got) diff --git a/internal/tui/model.go b/internal/tui/model.go index 7c70b3c..115ad69 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -119,10 +119,12 @@ type Model struct { toolTotal int // cumulative tool calls this session sessionStart time.Time // first-prompt timestamp, for session wall-clock - status string - notices []string - disconn bool - quitting bool + status string + notices []string + noticeExp []time.Time // parallel to notices; zero = sticky, else expires at + noticeSeq int // bumped on each transient notice, to invalidate stale timers + disconn bool + quitting bool gradRule string // cached full-width gradient rule gradRuleW int @@ -164,6 +166,15 @@ func (a autocomplete) height() int { return a.rows() + 3 } +// noticeTTL is how long transient info traces (skill / memory / signal / +// subagent) stay on screen before fading out. +const noticeTTL = 5 * time.Second + +// noticeExpireMsg fires noticeTTL after the last transient notice was added. +type noticeExpireMsg struct { + seq int +} + // acResultMsg carries the result of an async resource search. type acResultMsg struct { seq int @@ -274,6 +285,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case eventMsg: return m.handleEvent(client.Event(msg)) + case noticeExpireMsg: + if msg.seq == m.noticeSeq { + m.pruneNotices(time.Now()) + m.refresh() + } + return m, nil + case tea.MouseMsg: var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) @@ -397,6 +415,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { + prevSeq := m.noticeSeq switch ev.Type { case "session": m.sessionID = ev.SessionID @@ -496,11 +515,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.relayout() // the panel is taller than the textarea — shrink the viewport case "skill_event": - m.addNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev)) + m.addTransientNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev)) case "memory_event": - m.addNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev)) + m.addTransientNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev)) case "agent_signal": - m.addNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev)) + m.addTransientNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev)) case "subagent_log": line := strings.TrimSpace(ev.SubType + " " + ev.Name) if d := collapse(ev.Detail); d != "" { @@ -512,7 +531,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { if i := m.cur(); i >= 0 && m.attachSubLog(i, line) { break } - m.addNote("subagent · " + line) + m.addTransientNote("subagent · " + line) case client.EventDisconnected: m.disconn = true @@ -527,7 +546,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.refresh() - return m, listen(m.events) + return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq)) } // ── actions ────────────────────────────────────────────────────────────── @@ -686,11 +705,51 @@ func (m *Model) finalize() { m.thinking.Reset() } +// addNote appends a sticky notice (errors, disconnects) that stays until +// pushed out by newer ones. func (m *Model) addNote(s string) { + m.pushNote(s, time.Time{}) +} + +// addTransientNote appends an info trace that fades after noticeTTL. +func (m *Model) addTransientNote(s string) { + m.pushNote(s, time.Now().Add(noticeTTL)) + m.noticeSeq++ +} + +func (m *Model) pushNote(s string, exp time.Time) { m.notices = append(m.notices, sanitize(s)) + m.noticeExp = append(m.noticeExp, exp) if len(m.notices) > 6 { m.notices = m.notices[len(m.notices)-6:] + m.noticeExp = m.noticeExp[len(m.noticeExp)-6:] + } +} + +// pruneNotices drops transient notices whose expiry has passed. +func (m *Model) pruneNotices(now time.Time) { + kept := m.notices[:0] + keptExp := m.noticeExp[:0] + for i, n := range m.notices { + if exp := m.noticeExp[i]; exp.IsZero() || now.Before(exp) { + kept = append(kept, n) + keptExp = append(keptExp, exp) + } + } + m.notices = kept + m.noticeExp = keptExp +} + +// noticeTimer schedules the expiry sweep when a transient notice was added +// since prevSeq; otherwise it returns nil. +func (m *Model) noticeTimer(prevSeq int) tea.Cmd { + if m.noticeSeq == prevSeq { + return nil } + seq := m.noticeSeq + return tea.Tick(noticeTTL, func(time.Time) tea.Msg { + return noticeExpireMsg{seq: seq} + }) } // render runs content through glamour; falls back to raw text on error. diff --git a/internal/tui/view.go b/internal/tui/view.go index 40aa62e..99cab4c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -236,7 +236,9 @@ func (m *Model) conversation() string { blocks = append(blocks, lipgloss.NewStyle().Width(m.vp.Width).Render(think)) } if len(m.notices) > 0 { - blocks = append(blocks, m.renderNotices()) + if notes := m.renderNotices(); notes != "" { + blocks = append(blocks, notes) + } } return strings.Join(blocks, "\n\n") } @@ -449,9 +451,13 @@ func resultExcerpt(result string) []string { func (m *Model) renderNotices() string { th := m.th - lines := make([]string, len(m.notices)) + now := time.Now() + lines := make([]string, 0, len(m.notices)) for i, n := range m.notices { - lines[i] = th.noticeStyle.Render("· " + n) + if exp := m.noticeExp[i]; !exp.IsZero() && !now.Before(exp) { + continue // expired transient, pending the next sweep + } + lines = append(lines, th.noticeStyle.Render("· "+n)) } return strings.Join(lines, "\n") }