Skip to content

Commit 8473f86

Browse files
committed
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.
1 parent 7f6ec08 commit 8473f86

3 files changed

Lines changed: 109 additions & 12 deletions

File tree

internal/tui/coverage_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,38 @@ func TestAddNoteRingBuffer(t *testing.T) {
8181
}
8282
}
8383

84+
func TestTransientNoticeExpires(t *testing.T) {
85+
m := wired(t)
86+
m.addNote("sticky")
87+
m.addTransientNote("skill · loaded")
88+
if cmd := m.noticeTimer(0); cmd == nil {
89+
t.Fatal("transient notice should schedule an expiry timer")
90+
}
91+
// Expiry sweep drops the transient trace but keeps the sticky note.
92+
m.noticeExp[1] = time.Now().Add(-time.Second)
93+
if _, cmd := m.Update(noticeExpireMsg{seq: m.noticeSeq}); cmd != nil {
94+
t.Error("expiry sweep should not reschedule")
95+
}
96+
if got := strings.Join(m.notices, "\n"); got != "sticky" {
97+
t.Errorf("notices after expiry = %q", got)
98+
}
99+
// A stale timer (superseded by a newer notice) must not clear anything.
100+
m.addTransientNote("skill · saved")
101+
m.Update(noticeExpireMsg{seq: m.noticeSeq - 1})
102+
if len(m.notices) != 2 {
103+
t.Errorf("stale timer cleared notices: %v", m.notices)
104+
}
105+
}
106+
107+
func TestRenderNoticesHidesExpired(t *testing.T) {
108+
m := wired(t)
109+
m.addTransientNote("skill · loaded")
110+
m.noticeExp[0] = time.Now().Add(-time.Second)
111+
if got := m.renderNotices(); got != "" {
112+
t.Errorf("expired notice still rendered: %q", got)
113+
}
114+
}
115+
84116
func TestArgPreviewFallbacks(t *testing.T) {
85117
if got := argPreview(`{"foo":"bar"}`); got != "bar" {
86118
t.Errorf("argPreview value-join = %q", got)

internal/tui/model.go

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,12 @@ type Model struct {
119119
toolTotal int // cumulative tool calls this session
120120
sessionStart time.Time // first-prompt timestamp, for session wall-clock
121121

122-
status string
123-
notices []string
124-
disconn bool
125-
quitting bool
122+
status string
123+
notices []string
124+
noticeExp []time.Time // parallel to notices; zero = sticky, else expires at
125+
noticeSeq int // bumped on each transient notice, to invalidate stale timers
126+
disconn bool
127+
quitting bool
126128

127129
gradRule string // cached full-width gradient rule
128130
gradRuleW int
@@ -164,6 +166,15 @@ func (a autocomplete) height() int {
164166
return a.rows() + 3
165167
}
166168

169+
// noticeTTL is how long transient info traces (skill / memory / signal /
170+
// subagent) stay on screen before fading out.
171+
const noticeTTL = 5 * time.Second
172+
173+
// noticeExpireMsg fires noticeTTL after the last transient notice was added.
174+
type noticeExpireMsg struct {
175+
seq int
176+
}
177+
167178
// acResultMsg carries the result of an async resource search.
168179
type acResultMsg struct {
169180
seq int
@@ -274,6 +285,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
274285
case eventMsg:
275286
return m.handleEvent(client.Event(msg))
276287

288+
case noticeExpireMsg:
289+
if msg.seq == m.noticeSeq {
290+
m.pruneNotices(time.Now())
291+
m.refresh()
292+
}
293+
return m, nil
294+
277295
case tea.MouseMsg:
278296
var cmd tea.Cmd
279297
m.vp, cmd = m.vp.Update(msg)
@@ -397,6 +415,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
397415
}
398416

399417
func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
418+
prevSeq := m.noticeSeq
400419
switch ev.Type {
401420
case "session":
402421
m.sessionID = ev.SessionID
@@ -496,11 +515,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
496515
m.relayout() // the panel is taller than the textarea — shrink the viewport
497516

498517
case "skill_event":
499-
m.addNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev))
518+
m.addTransientNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev))
500519
case "memory_event":
501-
m.addNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev))
520+
m.addTransientNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev))
502521
case "agent_signal":
503-
m.addNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev))
522+
m.addTransientNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev))
504523
case "subagent_log":
505524
line := strings.TrimSpace(ev.SubType + " " + ev.Name)
506525
if d := collapse(ev.Detail); d != "" {
@@ -512,7 +531,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
512531
if i := m.cur(); i >= 0 && m.attachSubLog(i, line) {
513532
break
514533
}
515-
m.addNote("subagent · " + line)
534+
m.addTransientNote("subagent · " + line)
516535

517536
case client.EventDisconnected:
518537
m.disconn = true
@@ -527,7 +546,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
527546
}
528547

529548
m.refresh()
530-
return m, listen(m.events)
549+
return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq))
531550
}
532551

533552
// ── actions ──────────────────────────────────────────────────────────────
@@ -686,11 +705,51 @@ func (m *Model) finalize() {
686705
m.thinking.Reset()
687706
}
688707

708+
// addNote appends a sticky notice (errors, disconnects) that stays until
709+
// pushed out by newer ones.
689710
func (m *Model) addNote(s string) {
711+
m.pushNote(s, time.Time{})
712+
}
713+
714+
// addTransientNote appends an info trace that fades after noticeTTL.
715+
func (m *Model) addTransientNote(s string) {
716+
m.pushNote(s, time.Now().Add(noticeTTL))
717+
m.noticeSeq++
718+
}
719+
720+
func (m *Model) pushNote(s string, exp time.Time) {
690721
m.notices = append(m.notices, sanitize(s))
722+
m.noticeExp = append(m.noticeExp, exp)
691723
if len(m.notices) > 6 {
692724
m.notices = m.notices[len(m.notices)-6:]
725+
m.noticeExp = m.noticeExp[len(m.noticeExp)-6:]
726+
}
727+
}
728+
729+
// pruneNotices drops transient notices whose expiry has passed.
730+
func (m *Model) pruneNotices(now time.Time) {
731+
kept := m.notices[:0]
732+
keptExp := m.noticeExp[:0]
733+
for i, n := range m.notices {
734+
if exp := m.noticeExp[i]; exp.IsZero() || now.Before(exp) {
735+
kept = append(kept, n)
736+
keptExp = append(keptExp, exp)
737+
}
738+
}
739+
m.notices = kept
740+
m.noticeExp = keptExp
741+
}
742+
743+
// noticeTimer schedules the expiry sweep when a transient notice was added
744+
// since prevSeq; otherwise it returns nil.
745+
func (m *Model) noticeTimer(prevSeq int) tea.Cmd {
746+
if m.noticeSeq == prevSeq {
747+
return nil
693748
}
749+
seq := m.noticeSeq
750+
return tea.Tick(noticeTTL, func(time.Time) tea.Msg {
751+
return noticeExpireMsg{seq: seq}
752+
})
694753
}
695754

696755
// render runs content through glamour; falls back to raw text on error.

internal/tui/view.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ func (m *Model) conversation() string {
236236
blocks = append(blocks, lipgloss.NewStyle().Width(m.vp.Width).Render(think))
237237
}
238238
if len(m.notices) > 0 {
239-
blocks = append(blocks, m.renderNotices())
239+
if notes := m.renderNotices(); notes != "" {
240+
blocks = append(blocks, notes)
241+
}
240242
}
241243
return strings.Join(blocks, "\n\n")
242244
}
@@ -449,9 +451,13 @@ func resultExcerpt(result string) []string {
449451

450452
func (m *Model) renderNotices() string {
451453
th := m.th
452-
lines := make([]string, len(m.notices))
454+
now := time.Now()
455+
lines := make([]string, 0, len(m.notices))
453456
for i, n := range m.notices {
454-
lines[i] = th.noticeStyle.Render("· " + n)
457+
if exp := m.noticeExp[i]; !exp.IsZero() && !now.Before(exp) {
458+
continue // expired transient, pending the next sweep
459+
}
460+
lines = append(lines, th.noticeStyle.Render("· "+n))
455461
}
456462
return strings.Join(lines, "\n")
457463
}

0 commit comments

Comments
 (0)