From 8473f8663f6540decb7ea32a6de76029f53fd9ac Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 25 Jul 2026 17:35:01 +0200 Subject: [PATCH] feat(tui): fade engine-event traces after 5s Skill, memory, signal, and subagent notices stuck around for the whole session, cluttering the transcript. They now carry an expiry timestamp and are swept 5s after the most recent trace; errors and disconnect notices stay sticky. --- internal/tui/coverage_test.go | 32 +++++++++++++++ internal/tui/model.go | 77 +++++++++++++++++++++++++++++++---- internal/tui/view.go | 12 ++++-- 3 files changed, 109 insertions(+), 12 deletions(-) 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") }