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
32 changes: 32 additions & 0 deletions internal/tui/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
77 changes: 68 additions & 9 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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
Expand All @@ -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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down