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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ one `Esc`.
spinner, smart autoscroll that never yanks you while you read history, and a
scroll-position indicator.
- **Engine notices** — skill loads, memory merges, and agent signals appear as
quiet status lines.
quiet status lines. Nothing lingers: info traces fade after 3s, and
errors, warnings, and disconnect notes autoclose after 10s (connection
state stays visible in the header badge).
- **Version display** — the header shows bodek's own version next to the logo
and the spawned odek's version next to the model name.
- **Update hint** — at startup, a quiet note appears when a newer bodek release
Expand Down
25 changes: 10 additions & 15 deletions internal/tui/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,25 +145,20 @@ func TestAddNoteRingBuffer(t *testing.T) {

func TestTransientNoticeExpires(t *testing.T) {
m := wired(t)
m.addNote("sticky")
m.addNote("alert tier")
m.addTransientNote("skill · loaded")
if cmd := m.noticeTimer(0); cmd == nil {
t.Fatal("transient notice should schedule an expiry timer")
if cmd := m.noticeSweep(); cmd == nil {
t.Fatal("pending notices should arm the expiry sweep")
}
// Expiry sweep drops the transient trace but keeps the sticky note.
// A sweep landing at the info TTL drops the transient trace but keeps
// the alert — and re-arms the sweep for the alert's own expiry.
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 _, cmd := m.Update(noticeExpireMsg{}); cmd == nil {
t.Error("expiry sweep should reschedule while an alert is pending")
}
if got := strings.Join(m.notices, "\n"); got != "sticky" {
if got := strings.Join(m.notices, "\n"); got != "alert tier" {
t.Errorf("notices after expiry = %q", got)
}
// A stale timer must not clear notices that have not expired yet.
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) {
Expand Down Expand Up @@ -244,8 +239,8 @@ func TestSubmitGuards(t *testing.T) {
}
m.disconn = true
m.ta.SetValue("hi")
if cmd := m.submit(); cmd != nil {
t.Error("submit while disconnected should be nil (the warning is sticky, no expiry to arm)")
if cmd := m.submit(); cmd == nil {
t.Error("submit while disconnected should arm the warning's expiry sweep")
}
if m.ta.Value() != "hi" {
t.Error("submit while disconnected must keep the draft")
Expand Down
73 changes: 43 additions & 30 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ import (
// subagent) stay on screen before fading out.
const noticeTTL = 3 * time.Second

// noticeExpireMsg fires noticeTTL after a transient notice was added.
type noticeExpireMsg struct {
seq int
}
// alertTTL is how long alert-tier notices (errors, warnings, disconnects,
// shutdown / upgrade hints) dwell before fading — longer than the info
// traces so a glance away doesn't miss them, but bounded like everything
// else in the strip. Durable state (disconnected, server shut down) lives
// in the header badge, not here.
const alertTTL = 10 * time.Second

// noticeExpireMsg fires when the earliest pending notice expiry passes; the
// handler prunes expired notices and re-arms the sweep while any remain.
type noticeExpireMsg struct{}

func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
prevSeq := m.noticeSeq
stream := false // high-frequency event: coalesce the render (see queueRender)
switch ev.Type {
case "session":
Expand Down Expand Up @@ -296,7 +301,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
m.status = "server shut down"
m.addNote("server shut down · ⏎ starts a fresh instance")
m.refresh()
return m, nil
return m, m.noticeSweep()
}
// A turn in flight when the socket drops will never finish: close it
// out with an interrupted marker instead of leaving it streaming
Expand All @@ -311,26 +316,25 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
m.status = "reconnecting…"
m.addTransientNote("connection lost — reconnecting…")
m.refresh()
// The interim note fades via the sweep armed by noticeTimer; the
// reconnect outcome (success or the sticky ⏎-retry hint) replaces
// it within seconds either way.
return m, tea.Batch(cmd, m.noticeTimer(prevSeq))
// The interim note fades via the sweep; the reconnect outcome
// (success or the ⏎-retry hint) replaces it within seconds.
return m, tea.Batch(cmd, m.noticeSweep())
}
m.status = "disconnected"
m.addNote("disconnected from odek serve")
if m.opts.LogPath != "" {
m.addNote("server log · " + m.opts.LogPath)
}
m.refresh()
return m, nil
return m, m.noticeSweep()
}

if stream {
return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.queueRender())
return m, tea.Batch(listen(m.events), m.noticeSweep(), m.queueRender())
}
m.refresh()
// A turn that just ended (done / error) drains the next queued prompt.
return m, tea.Batch(listen(m.events), m.noticeTimer(prevSeq), m.sendQueued(), m.planFollowup())
return m, tea.Batch(listen(m.events), m.noticeSweep(), m.sendQueued(), m.planFollowup())
}

// stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in
Expand Down Expand Up @@ -545,26 +549,24 @@ func (m *Model) closeTurn(msg *message) {
msg.rendered = m.render(msg.content)
}

// addNote appends a sticky notice (errors, disconnects) that stays until
// pushed out by newer ones.
// addNote appends an alert-tier notice (errors, warnings, disconnects) that
// dwells for alertTTL before fading — long enough to read, bounded like
// every notice in the strip.
func (m *Model) addNote(s string) {
m.pushNote(s, time.Time{})
m.pushNote(s, time.Now().Add(alertTTL))
}

// addTransientNote appends an info trace that fades after noticeTTL.
func (m *Model) addTransientNote(s string) {
m.pushNote(s, time.Now().Add(noticeTTL))
m.noticeSeq++
}

// transientNoteCmd adds a transient note and returns the cmd that sweeps it
// after noticeTTL. handleEvent arms the sweep itself; every other caller
// (key handlers, async results) must batch this cmd or the note only fades
// on the next unrelated render.
// transientNoteCmd adds a transient note and returns the sweep cmd. Every
// caller outside handleEvent must batch this cmd or the note only fades on
// the next unrelated render.
func (m *Model) transientNoteCmd(s string) tea.Cmd {
prev := m.noticeSeq
m.addTransientNote(s)
return m.noticeTimer(prev)
return m.noticeSweep()
}

func (m *Model) pushNote(s string, exp time.Time) {
Expand All @@ -590,15 +592,26 @@ func (m *Model) pruneNotices(now time.Time) {
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 {
// noticeSweep schedules the next expiry sweep at the earliest pending
// notice expiry; nil when the strip has nothing pending. The tick handler
// prunes and re-arms, so expired notes disappear even on an idle TUI and
// the timer chain stops itself once the strip is clean.
func (m *Model) noticeSweep() tea.Cmd {
var earliest time.Time
for _, exp := range m.noticeExp {
if earliest.IsZero() || exp.Before(earliest) {
earliest = exp
}
}
if earliest.IsZero() {
return nil
}
seq := m.noticeSeq
return tea.Tick(noticeTTL, func(time.Time) tea.Msg {
return noticeExpireMsg{seq: seq}
d := time.Until(earliest)
if d < 0 {
d = 0
}
return tea.Tick(d, func(time.Time) tea.Msg {
return noticeExpireMsg{}
})
}

Expand Down
9 changes: 4 additions & 5 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,15 @@ func (m *Model) submit() tea.Cmd {
}
if m.disconn {
// Keep the draft — swallowing it silently reads as a lost message.
// Sticky (not transient): the warning must outlive a glance away, so
// it stays until newer notices push it out. Deduped, since every
// enter re-posts it. Retry is ⏎ on an empty input — the hint spells
// that out.
// Alert tier: it dwells past a glance away but still autocloses,
// and is re-posted (deduped) on every enter while disconnected.
// Retry is ⏎ on an empty input — the hint spells that out.
const warn = "disconnected — your draft is kept · clear the input, then ⏎ to retry"
if n := len(m.notices); n == 0 || m.notices[n-1] != warn {
m.addNote(warn)
}
m.refresh()
return nil
return m.noticeSweep()
}
if m.busy {
// Queue mid-turn prompts instead of dropping them; the queue drains
Expand Down
13 changes: 6 additions & 7 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,7 @@ type Model struct {

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
noticeExp []time.Time // parallel to notices; when each one fades
disconn bool
quitting bool

Expand Down Expand Up @@ -375,7 +374,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.finalize()
m.relayout() // the busy status line releases its row
m.refresh()
return m, m.sendQueued()
return m, tea.Batch(m.sendQueued(), m.noticeSweep())

case acResultMsg:
if msg.seq != m.ac.seq || m.ac.mode != acRef {
Expand Down Expand Up @@ -478,7 +477,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.addNote("shutdown failed: " + msg.err.Error())
m.refresh()
}
return m, nil
return m, m.noticeSweep()

case mgmtMsg:
m.handleMgmtMsg(msg)
Expand Down Expand Up @@ -512,7 +511,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refresh()
return m, cmd
}
return m, nil
return m, m.noticeSweep()

case updateCheckMsg:
// Silent on error or when already current: the hint only ever nags
Expand All @@ -521,7 +520,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.addNote(fmt.Sprintf("⬆ bodek %s available — run `bodek upgrade`", msg.latest))
m.refresh()
}
return m, nil
return m, m.noticeSweep()

case eventMsg:
return m.handleEvent(client.Event(msg))
Expand All @@ -532,7 +531,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case noticeExpireMsg:
m.pruneNotices(time.Now())
m.refresh()
return m, nil
return m, m.noticeSweep() // re-arm while pending notices remain

case tea.MouseMsg:
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft && m.panel == panelNone && !m.ac.open {
Expand Down
Loading