Skip to content

Commit cf6a867

Browse files
authored
Merge pull request #8 from BackendStack21/feat/tui-transient-notices
feat(tui): fade engine-event traces after 5s
2 parents 7f6ec08 + 8473f86 commit cf6a867

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)