detached 5eef83d

Merge pull request #21 from bnema/feature/osc94-attention-mru-bar

feat(daemon): add OSC progress attention MRU bar
commit
5eef83d6e8390443bde425b4b51238dc51169cc1
author
bnema · · 2026-07-05 08:16 UTC
committer
GitHub · · 2026-07-05 08:16 UTC
merge of
e87efc2 + e8cafa1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +420 -97
M internal/usecase/daemon/client.go +1 -0
@@ -327,6 +327,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
327327 name := sess.name
328328 tabs := append([]*tab(nil), sess.tabs...)
329329 sess.mu.Unlock()
330+ d.touchMRU(sess)
330331 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
331332 for _, tb := range tabs {
332333 tb.mu.Lock()
@@ -327,6 +327,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
327 name := sess.name327 name := sess.name
328 tabs := append([]*tab(nil), sess.tabs...)328 tabs := append([]*tab(nil), sess.tabs...)
329 sess.mu.Unlock()329 sess.mu.Unlock()
330+ d.touchMRU(sess)
330 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)331 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
331 for _, tb := range tabs {332 for _, tb := range tabs {
332 tb.mu.Lock()333 tb.mu.Lock()
M internal/usecase/daemon/daemon.go +2 -0
@@ -31,6 +31,7 @@ import (
3131 "os"
3232 "sort"
3333 "sync"
34+ "sync/atomic"
3435 "time"
3536
3637 "github.com/bnema/vev/internal/domain"
@@ -72,6 +73,7 @@ type Daemon struct {
7273 sessions map[domain.SessionID]*session
7374 stopped map[string]stoppedSession
7475 nextID uint64
76+ mruSeq atomic.Uint64
7577 // closing marks that shutdown has irreversibly begun. It is set under mu,
7678 // atomically with the event that makes shutdown inevitable (the registry
7779 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -31,6 +31,7 @@ import (
31 "os"31 "os"
32 "sort"32 "sort"
33 "sync"33 "sync"
34+ "sync/atomic"
34 "time"35 "time"
35 36
36 "github.com/bnema/vev/internal/domain"37 "github.com/bnema/vev/internal/domain"
@@ -72,6 +73,7 @@ type Daemon struct {
72 sessions map[domain.SessionID]*session73 sessions map[domain.SessionID]*session
73 stopped map[string]stoppedSession74 stopped map[string]stoppedSession
74 nextID uint6475 nextID uint64
76+ mruSeq atomic.Uint64
75 // closing marks that shutdown has irreversibly begun. It is set under mu,77 // closing marks that shutdown has irreversibly begun. It is set under mu,
76 // atomically with the event that makes shutdown inevitable (the registry78 // atomically with the event that makes shutdown inevitable (the registry
77 // emptying in killSession, or shutdownAll starting), and checked by route79 // emptying in killSession, or shutdownAll starting), and checked by route
M internal/usecase/daemon/picker.go +2 -0
@@ -401,6 +401,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
401401 targetSess.active = target.TabIndex
402402 }
403403 targetSess.mu.Unlock()
404+ d.touchMRU(targetSess)
404405 ac.setSession(targetSess)
405406 d.mu.Unlock()
406407 return old
@@ -435,6 +436,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
435436 targetSess.mu.Lock()
436437 targetSess.client = ac
437438 targetSess.mu.Unlock()
439+ d.touchMRU(targetSess)
438440 ac.setSession(targetSess)
439441 d.mu.Unlock()
440442 d.firstPaint(targetSess, ac, ac.size)
@@ -401,6 +401,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
401 targetSess.active = target.TabIndex401 targetSess.active = target.TabIndex
402 }402 }
403 targetSess.mu.Unlock()403 targetSess.mu.Unlock()
404+ d.touchMRU(targetSess)
404 ac.setSession(targetSess)405 ac.setSession(targetSess)
405 d.mu.Unlock()406 d.mu.Unlock()
406 return old407 return old
@@ -435,6 +436,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
435 targetSess.mu.Lock()436 targetSess.mu.Lock()
436 targetSess.client = ac437 targetSess.client = ac
437 targetSess.mu.Unlock()438 targetSess.mu.Unlock()
439+ d.touchMRU(targetSess)
438 ac.setSession(targetSess)440 ac.setSession(targetSess)
439 d.mu.Unlock()441 d.mu.Unlock()
440 d.firstPaint(targetSess, ac, ac.size)442 d.firstPaint(targetSess, ac, ac.size)
M internal/usecase/daemon/render.go +1 -0
@@ -59,6 +59,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
5959 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
6060 p.screen.OnBell = func() { signal(attentionCh) }
6161 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
62+ p.screen.OnProgress = func(bool) { signal(attentionCh) }
6263 p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
6364 p.mu.Unlock()
6465 for {
@@ -59,6 +59,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
59 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }59 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
60 p.screen.OnBell = func() { signal(attentionCh) }60 p.screen.OnBell = func() { signal(attentionCh) }
61 p.screen.OnNotify = func(string, string) { signal(attentionCh) }61 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
62+ p.screen.OnProgress = func(bool) { signal(attentionCh) }
62 p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }63 p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
63 p.mu.Unlock()64 p.mu.Unlock()
64 for {65 for {
M internal/usecase/daemon/resume.go +1 -0
@@ -150,6 +150,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
150150 sess.mu.Lock()
151151 sess.client = ac
152152 sess.mu.Unlock()
153+ d.touchMRU(sess)
153154 d.log.Info("client resumed", "session", sess.name)
154155 return sess, ac, true, nil
155156 }
@@ -150,6 +150,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
150 sess.mu.Lock()150 sess.mu.Lock()
151 sess.client = ac151 sess.client = ac
152 sess.mu.Unlock()152 sess.mu.Unlock()
153+ d.touchMRU(sess)
153 d.log.Info("client resumed", "session", sess.name)154 d.log.Info("client resumed", "session", sess.name)
154 return sess, ac, true, nil155 return sess, ac, true, nil
155 }156 }
M internal/usecase/daemon/session.go +20 -0
@@ -32,6 +32,7 @@ import (
3232 "strconv"
3333 "strings"
3434 "sync"
35+ "sync/atomic"
3536 "time"
3637
3738 "github.com/bnema/vev/internal/domain"
@@ -56,6 +57,7 @@ type session struct {
5657 clipboardWorkerRunning bool
5758 cwd string
5859 createdAt int64
60+ mruAt atomic.Uint64
5961 // clipFiles records clipboard-image-transfer temp file paths (see
6062 // clipboard.go) written for this session, removed best-effort in
6163 // killSession.
@@ -86,6 +88,22 @@ type tab struct {
8688 // what it has actually seen). sendMu serialises the two senders — the render
8789 // scheduler and the connection handler — so the transport's single-writer
8890
91+func (d *Daemon) touchMRU(sess *session) {
92+ if d == nil || sess == nil {
93+ return
94+ }
95+ seq := d.mruSeq.Add(1)
96+ for {
97+ old := sess.mruAt.Load()
98+ if old >= seq {
99+ return
100+ }
101+ if sess.mruAt.CompareAndSwap(old, seq) {
102+ return
103+ }
104+ }
105+}
106+
89107 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
90108 tbSize := tabSize(sz)
91109 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
@@ -120,6 +138,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
120138 delete(d.stopped, name)
121139 }
122140 d.sessions[id] = sess
141+ d.touchMRU(sess)
123142 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
124143 d.log.Info("tab created", "session", name, "tab", 0)
125144 d.startTabGoroutines(sess, tb)
@@ -168,6 +187,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
168187 newSess.mu.Lock()
169188 newSess.client = ac
170189 newSess.mu.Unlock()
190+ d.touchMRU(newSess)
171191 ac.setSession(newSess)
172192 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
173193 d.mu.Unlock()
@@ -32,6 +32,7 @@ import (
32 "strconv"32 "strconv"
33 "strings"33 "strings"
34 "sync"34 "sync"
35+ "sync/atomic"
35 "time"36 "time"
36 37
37 "github.com/bnema/vev/internal/domain"38 "github.com/bnema/vev/internal/domain"
@@ -56,6 +57,7 @@ type session struct {
56 clipboardWorkerRunning bool57 clipboardWorkerRunning bool
57 cwd string58 cwd string
58 createdAt int6459 createdAt int64
60+ mruAt atomic.Uint64
59 // clipFiles records clipboard-image-transfer temp file paths (see61 // clipFiles records clipboard-image-transfer temp file paths (see
60 // clipboard.go) written for this session, removed best-effort in62 // clipboard.go) written for this session, removed best-effort in
61 // killSession.63 // killSession.
@@ -86,6 +88,22 @@ type tab struct {
86 // what it has actually seen). sendMu serialises the two senders — the render88 // what it has actually seen). sendMu serialises the two senders — the render
87 // scheduler and the connection handler — so the transport's single-writer89 // scheduler and the connection handler — so the transport's single-writer
88 90
91+func (d *Daemon) touchMRU(sess *session) {
92+ if d == nil || sess == nil {
93+ return
94+ }
95+ seq := d.mruSeq.Add(1)
96+ for {
97+ old := sess.mruAt.Load()
98+ if old >= seq {
99+ return
100+ }
101+ if sess.mruAt.CompareAndSwap(old, seq) {
102+ return
103+ }
104+ }
105+}
106+
89 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {107 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
90 tbSize := tabSize(sz)108 tbSize := tabSize(sz)
91 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)109 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
@@ -120,6 +138,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
120 delete(d.stopped, name)138 delete(d.stopped, name)
121 }139 }
122 d.sessions[id] = sess140 d.sessions[id] = sess
141+ d.touchMRU(sess)
123 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)142 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
124 d.log.Info("tab created", "session", name, "tab", 0)143 d.log.Info("tab created", "session", name, "tab", 0)
125 d.startTabGoroutines(sess, tb)144 d.startTabGoroutines(sess, tb)
@@ -168,6 +187,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
168 newSess.mu.Lock()187 newSess.mu.Lock()
169 newSess.client = ac188 newSess.client = ac
170 newSess.mu.Unlock()189 newSess.mu.Unlock()
190+ d.touchMRU(newSess)
171 ac.setSession(newSess)191 ac.setSession(newSess)
172 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)192 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
173 d.mu.Unlock()193 d.mu.Unlock()
M internal/usecase/daemon/session_test.go +50 -0
@@ -5,6 +5,7 @@ import (
55 "io"
66 "log/slog"
77 "sync"
8+ "sync/atomic"
89 "testing"
910 "time"
1011
@@ -659,6 +660,55 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
659660 require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
660661 }
661662
663+func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
664+ d := &Daemon{}
665+ sess := &session{}
666+ const goroutines = 16
667+ const iterations = 1000
668+ start := make(chan struct{})
669+ done := make(chan struct{})
670+ var decreased atomic.Bool
671+ var wg sync.WaitGroup
672+ var observer sync.WaitGroup
673+
674+ observer.Add(1)
675+ go func() {
676+ defer observer.Done()
677+ previous := sess.mruAt.Load()
678+ for {
679+ select {
680+ case <-done:
681+ return
682+ default:
683+ }
684+ current := sess.mruAt.Load()
685+ if current < previous {
686+ decreased.Store(true)
687+ return
688+ }
689+ previous = current
690+ }
691+ }()
692+
693+ for range goroutines {
694+ wg.Add(1)
695+ go func() {
696+ defer wg.Done()
697+ <-start
698+ for range iterations {
699+ d.touchMRU(sess)
700+ }
701+ }()
702+ }
703+ close(start)
704+ wg.Wait()
705+ close(done)
706+ observer.Wait()
707+
708+ require.False(t, decreased.Load())
709+ require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())
710+}
711+
662712 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
663713 sz := domain.Size{Cols: 80, Rows: 24}
664714 p, release := newBlockingPTY(t)
@@ -5,6 +5,7 @@ import (
5 "io"5 "io"
6 "log/slog"6 "log/slog"
7 "sync"7 "sync"
8+ "sync/atomic"
8 "testing"9 "testing"
9 "time"10 "time"
10 11
@@ -659,6 +660,55 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
659 require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)660 require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
660 }661 }
661 662
663+func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
664+ d := &Daemon{}
665+ sess := &session{}
666+ const goroutines = 16
667+ const iterations = 1000
668+ start := make(chan struct{})
669+ done := make(chan struct{})
670+ var decreased atomic.Bool
671+ var wg sync.WaitGroup
672+ var observer sync.WaitGroup
673+
674+ observer.Add(1)
675+ go func() {
676+ defer observer.Done()
677+ previous := sess.mruAt.Load()
678+ for {
679+ select {
680+ case <-done:
681+ return
682+ default:
683+ }
684+ current := sess.mruAt.Load()
685+ if current < previous {
686+ decreased.Store(true)
687+ return
688+ }
689+ previous = current
690+ }
691+ }()
692+
693+ for range goroutines {
694+ wg.Add(1)
695+ go func() {
696+ defer wg.Done()
697+ <-start
698+ for range iterations {
699+ d.touchMRU(sess)
700+ }
701+ }()
702+ }
703+ close(start)
704+ wg.Wait()
705+ close(done)
706+ observer.Wait()
707+
708+ require.False(t, decreased.Load())
709+ require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())
710+}
711+
662 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {712 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
663 sz := domain.Size{Cols: 80, Rows: 24}713 sz := domain.Size{Cols: 80, Rows: 24}
664 p, release := newBlockingPTY(t)714 p, release := newBlockingPTY(t)
M internal/usecase/daemon/status.go +73 -58
@@ -27,7 +27,6 @@ package daemon
2727 import (
2828 "sort"
2929 "strconv"
30- "strings"
3130 "time"
3231
3332 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -36,6 +35,7 @@ import (
3635
3736 const (
3837 attentionGlyph = ''
38+ maxMRUSessions = 9
3939 pulseFrameCount = 30
4040 pulseFrameInterval = 120 * time.Millisecond
4141 )
@@ -76,27 +76,27 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s
7676
7777 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
7878 clearStatusRow(row)
79- leftText := " " + state.status.session + " "
8079 x := 0
81- writeStatusText(row, &x, leftText, styles.statusBar)
80+ writeStatusText(row, &x, " "+state.status.session+" ", styles.statusBar)
8281
83- reservedLeft := len([]rune(leftText))
84- if state.copyFeedback != "" {
85- drawRightPlainText(row, state.copyFeedback, reservedLeft, styles.statusBar)
86- return
87- }
88- rightText := state.attentionStackText(len(row), reservedLeft)
89- if rightText == "" {
90- return
82+ fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback)
83+ for i, sess := range fittedMRU {
84+ style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
85+ name := sess.name
86+ if sess.ephemeral {
87+ name += "*"
88+ }
89+ writeStatusText(row, &x, " "+name, style)
90+ if sess.attention {
91+ writeStatusText(row, &x, " ", style)
92+ writeBell(row, &x, state.attentionFrame)
93+ }
9194 }
92- rightWidth := len([]rune(rightText)) + 1
93- x = len(row) - rightWidth
94- writeStatusText(row, &x, " ", styles.statusBar)
95- writeAttentionText(row, &x, rightText, state.attentionFrame, styles.statusBar)
95+ drawRightPlainText(row, state.copyFeedback, x, styles.statusBar)
9696 }
9797
9898 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
99- if len([]rune(text))+1+reservedLeft > len(row) {
99+ if text == "" || len([]rune(text))+1+reservedLeft > len(row) {
100100 return
101101 }
102102 x := len(row) - len([]rune(text)) - 1
@@ -112,7 +112,7 @@ func clearStatusRow(row []renderer.Cell) {
112112 type barState struct {
113113 status statusSnapshot
114114 copyFeedback string
115- otherAttention []string
115+ mru []mruSession
116116 attentionFrame int
117117 // theme is the client's terminal theme, if reported. Its zero value
118118 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -120,6 +120,14 @@ type barState struct {
120120 theme themeui.Theme
121121 }
122122
123+type mruSession struct {
124+ name string
125+ ephemeral bool
126+ attention bool
127+ // mruAt orders entries in barStateFor (freshest first); drawing ignores it.
128+ mruAt uint64
129+}
130+
123131 type statusSnapshot struct {
124132 session string
125133 tabs []statusTab
@@ -154,72 +162,79 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
154162 if cur != nil {
155163 state.status = cur.statusSegments()
156164 }
157- if d == nil || copyFeedback != "" {
165+ if d == nil {
158166 return state
159167 }
160- type attentionSession struct {
161- name string
162- at time.Time
163- }
164168 d.mu.Lock()
165- attention := make([]attentionSession, 0, len(d.sessions))
169+ mru := make([]mruSession, 0, len(d.sessions))
166170 for _, sess := range d.sessions {
167171 if sess == cur {
168172 continue
169173 }
174+ at := sess.mruAt.Load()
170175 sess.mu.Lock()
171- var oldest time.Time
172- found := false
176+ entry := mruSession{name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
173177 for _, tb := range sess.tabs {
174- if !tb.attention {
175- continue
176- }
177- if !found || tb.attentionAt.Before(oldest) {
178- oldest = tb.attentionAt
179- found = true
178+ if tb.attention {
179+ entry.attention = true
180+ break
180181 }
181182 }
182- if found {
183- attention = append(attention, attentionSession{name: sess.name, at: oldest})
184- }
185183 sess.mu.Unlock()
184+ mru = append(mru, entry)
186185 }
187186 d.mu.Unlock()
188- sort.SliceStable(attention, func(i, j int) bool { return attention[i].at.Before(attention[j].at) })
189- state.otherAttention = make([]string, len(attention))
190- for i := range attention {
191- state.otherAttention[i] = attention[i].name
187+ sort.SliceStable(mru, func(i, j int) bool {
188+ if mru[i].mruAt == mru[j].mruAt {
189+ return mru[i].name < mru[j].name
190+ }
191+ return mru[i].mruAt > mru[j].mruAt
192+ })
193+ if len(mru) > maxMRUSessions {
194+ mru = mru[:maxMRUSessions]
192195 }
196+ state.mru = mru
193197 return state
194198 }
195199
196-func (s barState) attentionStackText(width, reservedLeft int) string {
197- if len(s.otherAttention) == 0 {
198- return ""
200+func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
201+ // With no feedback, keep one blank trailing cell; with feedback, reserve
202+ // its " text" width plus a one-cell gap so drawRightPlainText always fits.
203+ copyReserve := 1
204+ if feedback != "" {
205+ copyReserve = len([]rune(feedback)) + 2
199206 }
200- parts := make([]string, len(s.otherAttention))
201- for i, name := range s.otherAttention {
202- parts[i] = string(attentionGlyph) + " " + name
207+ budget := rowLen - leftUsed - copyReserve
208+ if budget <= 0 || len(entries) == 0 {
209+ return nil
203210 }
204- full := strings.Join(parts, " ")
205- if len([]rune(full))+1+reservedLeft <= width {
206- return full
211+ cost := func(e mruSession) int {
212+ n := 1 + len([]rune(e.name))
213+ if e.ephemeral {
214+ n++
215+ }
216+ if e.attention {
217+ n += 2
218+ }
219+ return n
207220 }
208- collapsed := string(attentionGlyph) + " ×" + strconv.Itoa(len(s.otherAttention))
209- if len([]rune(collapsed))+1+reservedLeft <= width {
210- return collapsed
221+ used := 0
222+ for i, e := range entries {
223+ used += cost(e)
224+ if used > budget {
225+ return entries[:i]
226+ }
211227 }
212- return ""
228+ return entries
213229 }
214230
215-func writeAttentionText(row []renderer.Cell, x *int, text string, frame int, style renderer.Style) {
216- for _, r := range text {
217- if r == attentionGlyph {
218- writeBell(row, x, frame)
219- continue
220- }
221- writeStatusText(row, x, string(r), style)
231+func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
232+ if count <= 1 || !base.HasForegroundRGB {
233+ return base
222234 }
235+ amount := (float64(i) / float64(count-1)) * 0.6
236+ base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
237+ return base
223238 }
224239
225240 func writeBell(row []renderer.Cell, x *int, frame int) {
@@ -27,7 +27,6 @@ package daemon
27 import (27 import (
28 "sort"28 "sort"
29 "strconv"29 "strconv"
30- "strings"
31 "time"30 "time"
32 31
33 themeui "github.com/bnema/vev/internal/usecase/theme"32 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -36,6 +35,7 @@ import (
36 35
37 const (36 const (
38 attentionGlyph = ''37 attentionGlyph = ''
38+ maxMRUSessions = 9
39 pulseFrameCount = 3039 pulseFrameCount = 30
40 pulseFrameInterval = 120 * time.Millisecond40 pulseFrameInterval = 120 * time.Millisecond
41 )41 )
@@ -76,27 +76,27 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s
76 76
77 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {77 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
78 clearStatusRow(row)78 clearStatusRow(row)
79- leftText := " " + state.status.session + " "
80 x := 079 x := 0
81- writeStatusText(row, &x, leftText, styles.statusBar)80+ writeStatusText(row, &x, " "+state.status.session+" ", styles.statusBar)
82 81
83- reservedLeft := len([]rune(leftText))82+ fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback)
84- if state.copyFeedback != "" {83+ for i, sess := range fittedMRU {
85- drawRightPlainText(row, state.copyFeedback, reservedLeft, styles.statusBar)84+ style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
86- return85+ name := sess.name
87- }86+ if sess.ephemeral {
88- rightText := state.attentionStackText(len(row), reservedLeft)87+ name += "*"
89- if rightText == "" {88+ }
90- return89+ writeStatusText(row, &x, " "+name, style)
90+ if sess.attention {
91+ writeStatusText(row, &x, " ", style)
92+ writeBell(row, &x, state.attentionFrame)
93+ }
91 }94 }
92- rightWidth := len([]rune(rightText)) + 195+ drawRightPlainText(row, state.copyFeedback, x, styles.statusBar)
93- x = len(row) - rightWidth
94- writeStatusText(row, &x, " ", styles.statusBar)
95- writeAttentionText(row, &x, rightText, state.attentionFrame, styles.statusBar)
96 }96 }
97 97
98 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {98 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
99- if len([]rune(text))+1+reservedLeft > len(row) {99+ if text == "" || len([]rune(text))+1+reservedLeft > len(row) {
100 return100 return
101 }101 }
102 x := len(row) - len([]rune(text)) - 1102 x := len(row) - len([]rune(text)) - 1
@@ -112,7 +112,7 @@ func clearStatusRow(row []renderer.Cell) {
112 type barState struct {112 type barState struct {
113 status statusSnapshot113 status statusSnapshot
114 copyFeedback string114 copyFeedback string
115- otherAttention []string115+ mru []mruSession
116 attentionFrame int116 attentionFrame int
117 // theme is the client's terminal theme, if reported. Its zero value117 // theme is the client's terminal theme, if reported. Its zero value
118 // (Theme{}, Known: false) is a valid "no theme" default that resolves to118 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -120,6 +120,14 @@ type barState struct {
120 theme themeui.Theme120 theme themeui.Theme
121 }121 }
122 122
123+type mruSession struct {
124+ name string
125+ ephemeral bool
126+ attention bool
127+ // mruAt orders entries in barStateFor (freshest first); drawing ignores it.
128+ mruAt uint64
129+}
130+
123 type statusSnapshot struct {131 type statusSnapshot struct {
124 session string132 session string
125 tabs []statusTab133 tabs []statusTab
@@ -154,72 +162,79 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
154 if cur != nil {162 if cur != nil {
155 state.status = cur.statusSegments()163 state.status = cur.statusSegments()
156 }164 }
157- if d == nil || copyFeedback != "" {165+ if d == nil {
158 return state166 return state
159 }167 }
160- type attentionSession struct {
161- name string
162- at time.Time
163- }
164 d.mu.Lock()168 d.mu.Lock()
165- attention := make([]attentionSession, 0, len(d.sessions))169+ mru := make([]mruSession, 0, len(d.sessions))
166 for _, sess := range d.sessions {170 for _, sess := range d.sessions {
167 if sess == cur {171 if sess == cur {
168 continue172 continue
169 }173 }
174+ at := sess.mruAt.Load()
170 sess.mu.Lock()175 sess.mu.Lock()
171- var oldest time.Time176+ entry := mruSession{name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
172- found := false
173 for _, tb := range sess.tabs {177 for _, tb := range sess.tabs {
174- if !tb.attention {178+ if tb.attention {
175- continue179+ entry.attention = true
176- }180+ break
177- if !found || tb.attentionAt.Before(oldest) {
178- oldest = tb.attentionAt
179- found = true
180 }181 }
181 }182 }
182- if found {
183- attention = append(attention, attentionSession{name: sess.name, at: oldest})
184- }
185 sess.mu.Unlock()183 sess.mu.Unlock()
184+ mru = append(mru, entry)
186 }185 }
187 d.mu.Unlock()186 d.mu.Unlock()
188- sort.SliceStable(attention, func(i, j int) bool { return attention[i].at.Before(attention[j].at) })187+ sort.SliceStable(mru, func(i, j int) bool {
189- state.otherAttention = make([]string, len(attention))188+ if mru[i].mruAt == mru[j].mruAt {
190- for i := range attention {189+ return mru[i].name < mru[j].name
191- state.otherAttention[i] = attention[i].name190+ }
191+ return mru[i].mruAt > mru[j].mruAt
192+ })
193+ if len(mru) > maxMRUSessions {
194+ mru = mru[:maxMRUSessions]
192 }195 }
196+ state.mru = mru
193 return state197 return state
194 }198 }
195 199
196-func (s barState) attentionStackText(width, reservedLeft int) string {200+func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
197- if len(s.otherAttention) == 0 {201+ // With no feedback, keep one blank trailing cell; with feedback, reserve
198- return ""202+ // its " text" width plus a one-cell gap so drawRightPlainText always fits.
203+ copyReserve := 1
204+ if feedback != "" {
205+ copyReserve = len([]rune(feedback)) + 2
199 }206 }
200- parts := make([]string, len(s.otherAttention))207+ budget := rowLen - leftUsed - copyReserve
201- for i, name := range s.otherAttention {208+ if budget <= 0 || len(entries) == 0 {
202- parts[i] = string(attentionGlyph) + " " + name209+ return nil
203 }210 }
204- full := strings.Join(parts, " ")211+ cost := func(e mruSession) int {
205- if len([]rune(full))+1+reservedLeft <= width {212+ n := 1 + len([]rune(e.name))
206- return full213+ if e.ephemeral {
214+ n++
215+ }
216+ if e.attention {
217+ n += 2
218+ }
219+ return n
207 }220 }
208- collapsed := string(attentionGlyph) + " ×" + strconv.Itoa(len(s.otherAttention))221+ used := 0
209- if len([]rune(collapsed))+1+reservedLeft <= width {222+ for i, e := range entries {
210- return collapsed223+ used += cost(e)
224+ if used > budget {
225+ return entries[:i]
226+ }
211 }227 }
212- return ""228+ return entries
213 }229 }
214 230
215-func writeAttentionText(row []renderer.Cell, x *int, text string, frame int, style renderer.Style) {231+func mruStyle(base renderer.Style, t themeui.Theme, i, count int) renderer.Style {
216- for _, r := range text {232+ if count <= 1 || !base.HasForegroundRGB {
217- if r == attentionGlyph {233+ return base
218- writeBell(row, x, frame)
219- continue
220- }
221- writeStatusText(row, x, string(r), style)
222 }234 }
235+ amount := (float64(i) / float64(count-1)) * 0.6
236+ base.ForegroundRGB = themeui.Blend(base.ForegroundRGB, t.Background, amount)
237+ return base
223 }238 }
224 239
225 func writeBell(row []renderer.Cell, x *int, frame int) {240 func writeBell(row []renderer.Cell, x *int, frame int) {
M internal/usecase/daemon/status_test.go +104 -35
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "strconv"
45 "strings"
56 "sync"
67 "testing"
@@ -283,61 +284,129 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
283284 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
284285 }
285286
286-func TestStatusBarRendersOtherAttentionOldestFirstAndCollapses(t *testing.T) {
287+func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
287288 p, releasePTY := newBlockingPTY(t)
288289 d, sess, _, _ := newManualSessionWithPTYs(t, p)
289290 defer releasePTY()
290291 sess.name = "current"
291- base := time.Unix(100, 0)
292- newAttendedSession := func(id, name string, at time.Time) *session {
293- tb := &tab{attention: true, attentionAt: at}
294- return &session{id: domain.SessionID(id), name: name, tabs: []*tab{tb}}
292+ sess.mruAt.Store(100)
293+ for i := 0; i < 10; i++ {
294+ other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}}
295+ other.mruAt.Store(uint64(i + 1))
296+ d.sessions[other.id] = other
295297 }
296- d.sessions["old"] = newAttendedSession("old", "old", base)
297- d.sessions["new"] = newAttendedSession("new", "new", base.Add(time.Minute))
298298
299299 state := d.barStateFor(sess, "")
300- state.attentionFrame = 1
301- require.Equal(t, []string{"old", "new"}, state.otherAttention)
302300
303- styles := resolveThemeStyles(nil)
304- wide := make([]renderer.Cell, 24)
305- drawStatusBarState(wide, state, styles)
306- require.Equal(t, " current  old  new", rowText(wide))
307-
308- narrow := make([]renderer.Cell, 14)
309- drawStatusBarState(narrow, state, styles)
310- require.Equal(t, " current  ×2", rowText(narrow))
311-
312- drawStatusBarState(narrow, barState{status: state.status, copyFeedback: "ok", otherAttention: state.otherAttention}, styles)
313- require.Equal(t, " current ok", rowText(narrow))
301+ require.Len(t, state.mru, 9)
302+ require.Equal(t, "s9", state.mru[0].name)
303+ require.Equal(t, "s1", state.mru[8].name)
304+ for _, got := range state.mru {
305+ require.NotEqual(t, "current", got.name)
306+ }
307+ require.True(t, state.mru[1].attention, "s8 attention should be carried into MRU state")
314308 }
315309
316-func TestBarStateForIncludesZeroTimeAttention(t *testing.T) {
310+func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
317311 p, releasePTY := newBlockingPTY(t)
318312 d, sess, _, _ := newManualSessionWithPTYs(t, p)
319313 defer releasePTY()
320- d.sessions["zero"] = &session{id: "zero", name: "zero", tabs: []*tab{{attention: true}}}
314+ for _, name := range []string{"bravo", "alpha", "charlie"} {
315+ d.sessions[domain.SessionID(name)] = &session{id: domain.SessionID(name), name: name, tabs: []*tab{{}}}
316+ }
321317
322318 state := d.barStateFor(sess, "")
323319
324- require.Equal(t, []string{"zero"}, state.otherAttention)
320+ require.GreaterOrEqual(t, len(state.mru), 3)
321+ require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
325322 }
326323
327-func TestBarStateForExcludesCurrentSession(t *testing.T) {
328- p, releasePTY := newBlockingPTY(t)
329- d, sess, _, _ := newManualSessionWithPTYs(t, p)
330- defer releasePTY()
331- sess.name = "current"
332- sess.mu.Lock()
333- sess.tabs[0].attention = true
334- sess.tabs[0].attentionAt = time.Unix(1, 0)
335- sess.mu.Unlock()
336- d.sessions["other"] = &session{id: "other", name: "other", tabs: []*tab{{attention: true, attentionAt: time.Unix(2, 0)}}}
324+func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
325+ state := barState{
326+ status: statusSnapshot{session: "cur"},
327+ attentionFrame: 1,
328+ mru: []mruSession{
329+ {name: "fresh"},
330+ {name: "tmp", ephemeral: true, attention: true},
331+ },
332+ }
333+ row := make([]renderer.Cell, 24)
337334
338- state := d.barStateFor(sess, "")
335+ drawStatusBarState(row, state, resolveThemeStyles(nil))
336+
337+ require.Equal(t, " cur fresh tmp*  ", rowText(row))
338+ for _, c := range row {
339+ if c.Rune == attentionGlyph {
340+ require.True(t, c.Style.Bold)
341+ return
342+ }
343+ }
344+ t.Fatalf("inline bell not rendered: %q", rowText(row))
345+}
346+
347+func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
348+ theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
349+ styles := newThemeStyles(theme)
350+ state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
351+ row := make([]renderer.Cell, 16)
352+
353+ drawStatusBarState(row, state, styles)
354+
355+ first := row[3].Style.ForegroundRGB.R
356+ second := row[5].Style.ForegroundRGB.R
357+ third := row[7].Style.ForegroundRGB.R
358+ require.Equal(t, styles.statusBar.ForegroundRGB.R, first)
359+ require.Greater(t, first, second)
360+ require.Greater(t, second, third)
361+
362+ plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)
363+ require.False(t, plain.HasForegroundRGB)
364+}
365+
366+func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
367+ state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
368+ row := make([]renderer.Cell, 19)
369+
370+ drawStatusBarState(row, state, resolveThemeStyles(nil))
371+
372+ text := rowText(row)
373+ require.Contains(t, text, "fresh")
374+ require.Contains(t, text, "middle")
375+ require.NotContains(t, text, "old")
376+}
377+
378+func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
379+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
380+ row := make([]renderer.Cell, 20)
381+
382+ drawStatusBarState(row, state, resolveThemeStyles(nil))
383+
384+ text := rowText(row)
385+ require.Contains(t, text, " a")
386+ require.Contains(t, text, " b")
387+ require.Contains(t, text, " c")
388+ require.True(t, strings.HasSuffix(text, " copied"), text)
389+}
339390
340- require.Equal(t, []string{"other"}, state.otherAttention)
391+func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
392+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
393+ tests := []struct {
394+ name string
395+ cols int
396+ want string
397+ }{
398+ {name: "just wide enough for feedback after dropping MRU", cols: 12, want: " cur copied"},
399+ {name: "too narrow drops feedback instead of clipping", cols: 11, want: " cur "},
400+ }
401+ for _, tt := range tests {
402+ t.Run(tt.name, func(t *testing.T) {
403+ row := make([]renderer.Cell, tt.cols)
404+
405+ drawStatusBarState(row, state, resolveThemeStyles(nil))
406+
407+ require.Equal(t, tt.want, rowText(row))
408+ })
409+ }
341410 }
342411
343412 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "strconv"
4 "strings"5 "strings"
5 "sync"6 "sync"
6 "testing"7 "testing"
@@ -283,61 +284,129 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
283 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))284 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
284 }285 }
285 286
286-func TestStatusBarRendersOtherAttentionOldestFirstAndCollapses(t *testing.T) {287+func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
287 p, releasePTY := newBlockingPTY(t)288 p, releasePTY := newBlockingPTY(t)
288 d, sess, _, _ := newManualSessionWithPTYs(t, p)289 d, sess, _, _ := newManualSessionWithPTYs(t, p)
289 defer releasePTY()290 defer releasePTY()
290 sess.name = "current"291 sess.name = "current"
291- base := time.Unix(100, 0)292+ sess.mruAt.Store(100)
292- newAttendedSession := func(id, name string, at time.Time) *session {293+ for i := 0; i < 10; i++ {
293- tb := &tab{attention: true, attentionAt: at}294+ other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}}
294- return &session{id: domain.SessionID(id), name: name, tabs: []*tab{tb}}295+ other.mruAt.Store(uint64(i + 1))
296+ d.sessions[other.id] = other
295 }297 }
296- d.sessions["old"] = newAttendedSession("old", "old", base)
297- d.sessions["new"] = newAttendedSession("new", "new", base.Add(time.Minute))
298 298
299 state := d.barStateFor(sess, "")299 state := d.barStateFor(sess, "")
300- state.attentionFrame = 1
301- require.Equal(t, []string{"old", "new"}, state.otherAttention)
302 300
303- styles := resolveThemeStyles(nil)301+ require.Len(t, state.mru, 9)
304- wide := make([]renderer.Cell, 24)302+ require.Equal(t, "s9", state.mru[0].name)
305- drawStatusBarState(wide, state, styles)303+ require.Equal(t, "s1", state.mru[8].name)
306- require.Equal(t, " current  old  new", rowText(wide))304+ for _, got := range state.mru {
307-305+ require.NotEqual(t, "current", got.name)
308- narrow := make([]renderer.Cell, 14)306+ }
309- drawStatusBarState(narrow, state, styles)307+ require.True(t, state.mru[1].attention, "s8 attention should be carried into MRU state")
310- require.Equal(t, " current  ×2", rowText(narrow))
311-
312- drawStatusBarState(narrow, barState{status: state.status, copyFeedback: "ok", otherAttention: state.otherAttention}, styles)
313- require.Equal(t, " current ok", rowText(narrow))
314 }308 }
315 309
316-func TestBarStateForIncludesZeroTimeAttention(t *testing.T) {310+func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
317 p, releasePTY := newBlockingPTY(t)311 p, releasePTY := newBlockingPTY(t)
318 d, sess, _, _ := newManualSessionWithPTYs(t, p)312 d, sess, _, _ := newManualSessionWithPTYs(t, p)
319 defer releasePTY()313 defer releasePTY()
320- d.sessions["zero"] = &session{id: "zero", name: "zero", tabs: []*tab{{attention: true}}}314+ for _, name := range []string{"bravo", "alpha", "charlie"} {
315+ d.sessions[domain.SessionID(name)] = &session{id: domain.SessionID(name), name: name, tabs: []*tab{{}}}
316+ }
321 317
322 state := d.barStateFor(sess, "")318 state := d.barStateFor(sess, "")
323 319
324- require.Equal(t, []string{"zero"}, state.otherAttention)320+ require.GreaterOrEqual(t, len(state.mru), 3)
321+ require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
325 }322 }
326 323
327-func TestBarStateForExcludesCurrentSession(t *testing.T) {324+func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
328- p, releasePTY := newBlockingPTY(t)325+ state := barState{
329- d, sess, _, _ := newManualSessionWithPTYs(t, p)326+ status: statusSnapshot{session: "cur"},
330- defer releasePTY()327+ attentionFrame: 1,
331- sess.name = "current"328+ mru: []mruSession{
332- sess.mu.Lock()329+ {name: "fresh"},
333- sess.tabs[0].attention = true330+ {name: "tmp", ephemeral: true, attention: true},
334- sess.tabs[0].attentionAt = time.Unix(1, 0)331+ },
335- sess.mu.Unlock()332+ }
336- d.sessions["other"] = &session{id: "other", name: "other", tabs: []*tab{{attention: true, attentionAt: time.Unix(2, 0)}}}333+ row := make([]renderer.Cell, 24)
337 334
338- state := d.barStateFor(sess, "")335+ drawStatusBarState(row, state, resolveThemeStyles(nil))
336+
337+ require.Equal(t, " cur fresh tmp*  ", rowText(row))
338+ for _, c := range row {
339+ if c.Rune == attentionGlyph {
340+ require.True(t, c.Style.Bold)
341+ return
342+ }
343+ }
344+ t.Fatalf("inline bell not rendered: %q", rowText(row))
345+}
346+
347+func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
348+ theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
349+ styles := newThemeStyles(theme)
350+ state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
351+ row := make([]renderer.Cell, 16)
352+
353+ drawStatusBarState(row, state, styles)
354+
355+ first := row[3].Style.ForegroundRGB.R
356+ second := row[5].Style.ForegroundRGB.R
357+ third := row[7].Style.ForegroundRGB.R
358+ require.Equal(t, styles.statusBar.ForegroundRGB.R, first)
359+ require.Greater(t, first, second)
360+ require.Greater(t, second, third)
361+
362+ plain := mruStyle(renderer.DefaultStyle(), themeui.Theme{}, 1, 3)
363+ require.False(t, plain.HasForegroundRGB)
364+}
365+
366+func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
367+ state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
368+ row := make([]renderer.Cell, 19)
369+
370+ drawStatusBarState(row, state, resolveThemeStyles(nil))
371+
372+ text := rowText(row)
373+ require.Contains(t, text, "fresh")
374+ require.Contains(t, text, "middle")
375+ require.NotContains(t, text, "old")
376+}
377+
378+func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
379+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
380+ row := make([]renderer.Cell, 20)
381+
382+ drawStatusBarState(row, state, resolveThemeStyles(nil))
383+
384+ text := rowText(row)
385+ require.Contains(t, text, " a")
386+ require.Contains(t, text, " b")
387+ require.Contains(t, text, " c")
388+ require.True(t, strings.HasSuffix(text, " copied"), text)
389+}
339 390
340- require.Equal(t, []string{"other"}, state.otherAttention)391+func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
392+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
393+ tests := []struct {
394+ name string
395+ cols int
396+ want string
397+ }{
398+ {name: "just wide enough for feedback after dropping MRU", cols: 12, want: " cur copied"},
399+ {name: "too narrow drops feedback instead of clipping", cols: 11, want: " cur "},
400+ }
401+ for _, tt := range tests {
402+ t.Run(tt.name, func(t *testing.T) {
403+ row := make([]renderer.Cell, tt.cols)
404+
405+ drawStatusBarState(row, state, resolveThemeStyles(nil))
406+
407+ require.Equal(t, tt.want, rowText(row))
408+ })
409+ }
341 }410 }
342 411
343 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {412 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
M pkg/vt/screen.go +50 -4
@@ -31,6 +31,14 @@ type screenState struct {
3131 // so keep this value in sync if that payload cap changes.
3232 const maxEscapeBufferLen = 128 * 1024
3333
34+const (
35+ progressStateClear = iota
36+ progressStateNormal
37+ progressStateError
38+ progressStateIndeterminate
39+ progressStatePaused
40+)
41+
3442 type Screen struct {
3543 Frame renderer.Frame
3644 Row int
@@ -51,6 +59,10 @@ type Screen struct {
5159 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
5260 // Other non-clipboard OSC payloads remain discarded. Nil disables it.
5361 OnNotify func(title, body string)
62+ // OnProgress is called synchronously from Write for OSC 9;4 progress
63+ // transitions that request attention: active progress cleared or first entry
64+ // into error state. Nil disables progress reporting.
65+ OnProgress func(errored bool)
5466 // OnClipboard is called synchronously from Write for a complete OSC 52
5567 // clipboard set request from the child. The OSC 52 selection field is
5668 // accepted but ignored; the callback receives only the raw base64 payload.
@@ -70,6 +82,7 @@ type Screen struct {
7082 savedCursor cursorState
7183 alternate *screenState
7284 syncUpdateActive bool
85+ progressState int
7386 cursorVisible bool
7487 cursorStyle int
7588 cursorStyleSet bool
@@ -643,15 +656,16 @@ func (s *Screen) handleOSC(payload []byte) {
643656 s.handleOSC52(string(payload[len("52;"):]))
644657 return
645658 }
659+ p := string(payload)
660+ if p == "9;4" || strings.HasPrefix(p, "9;4;") {
661+ s.handleProgress(p[len("9;4"):])
662+ return
663+ }
646664 if s.OnNotify == nil {
647665 return
648666 }
649- p := string(payload)
650667 switch {
651668 case strings.HasPrefix(p, "9;"):
652- if strings.HasPrefix(p, "9;4;") {
653- return
654- }
655669 s.OnNotify("", p[len("9;"):])
656670 case strings.HasPrefix(p, "777;"):
657671 parts := strings.SplitN(p[len("777;"):], ";", 3)
@@ -669,6 +683,38 @@ func (s *Screen) handleOSC(payload []byte) {
669683 }
670684 }
671685
686+func (s *Screen) handleProgress(rest string) {
687+ rest = strings.TrimPrefix(rest, ";")
688+ if rest == "" {
689+ return
690+ }
691+ token, _, _ := strings.Cut(rest, ";")
692+ state, err := strconv.Atoi(token)
693+ if err != nil {
694+ return
695+ }
696+ switch state {
697+ case progressStateNormal, progressStateIndeterminate, progressStatePaused:
698+ s.progressState = state
699+ case progressStateClear:
700+ previous := s.progressState
701+ s.progressState = state
702+ if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
703+ if s.OnProgress != nil {
704+ s.OnProgress(false)
705+ }
706+ }
707+ case progressStateError:
708+ previous := s.progressState
709+ s.progressState = state
710+ if previous != progressStateError {
711+ if s.OnProgress != nil {
712+ s.OnProgress(true)
713+ }
714+ }
715+ }
716+}
717+
672718 // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
673719 // clipboard payload (selection may be empty; split on the first ";"). A
674720 // clipboard query (data == "?") is always ignored — vev never answers
@@ -31,6 +31,14 @@ type screenState struct {
31 // so keep this value in sync if that payload cap changes.31 // so keep this value in sync if that payload cap changes.
32 const maxEscapeBufferLen = 128 * 102432 const maxEscapeBufferLen = 128 * 1024
33 33
34+const (
35+ progressStateClear = iota
36+ progressStateNormal
37+ progressStateError
38+ progressStateIndeterminate
39+ progressStatePaused
40+)
41+
34 type Screen struct {42 type Screen struct {
35 Frame renderer.Frame43 Frame renderer.Frame
36 Row int44 Row int
@@ -51,6 +59,10 @@ type Screen struct {
51 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).59 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
52 // Other non-clipboard OSC payloads remain discarded. Nil disables it.60 // Other non-clipboard OSC payloads remain discarded. Nil disables it.
53 OnNotify func(title, body string)61 OnNotify func(title, body string)
62+ // OnProgress is called synchronously from Write for OSC 9;4 progress
63+ // transitions that request attention: active progress cleared or first entry
64+ // into error state. Nil disables progress reporting.
65+ OnProgress func(errored bool)
54 // OnClipboard is called synchronously from Write for a complete OSC 5266 // OnClipboard is called synchronously from Write for a complete OSC 52
55 // clipboard set request from the child. The OSC 52 selection field is67 // clipboard set request from the child. The OSC 52 selection field is
56 // accepted but ignored; the callback receives only the raw base64 payload.68 // accepted but ignored; the callback receives only the raw base64 payload.
@@ -70,6 +82,7 @@ type Screen struct {
70 savedCursor cursorState82 savedCursor cursorState
71 alternate *screenState83 alternate *screenState
72 syncUpdateActive bool84 syncUpdateActive bool
85+ progressState int
73 cursorVisible bool86 cursorVisible bool
74 cursorStyle int87 cursorStyle int
75 cursorStyleSet bool88 cursorStyleSet bool
@@ -643,15 +656,16 @@ func (s *Screen) handleOSC(payload []byte) {
643 s.handleOSC52(string(payload[len("52;"):]))656 s.handleOSC52(string(payload[len("52;"):]))
644 return657 return
645 }658 }
659+ p := string(payload)
660+ if p == "9;4" || strings.HasPrefix(p, "9;4;") {
661+ s.handleProgress(p[len("9;4"):])
662+ return
663+ }
646 if s.OnNotify == nil {664 if s.OnNotify == nil {
647 return665 return
648 }666 }
649- p := string(payload)
650 switch {667 switch {
651 case strings.HasPrefix(p, "9;"):668 case strings.HasPrefix(p, "9;"):
652- if strings.HasPrefix(p, "9;4;") {
653- return
654- }
655 s.OnNotify("", p[len("9;"):])669 s.OnNotify("", p[len("9;"):])
656 case strings.HasPrefix(p, "777;"):670 case strings.HasPrefix(p, "777;"):
657 parts := strings.SplitN(p[len("777;"):], ";", 3)671 parts := strings.SplitN(p[len("777;"):], ";", 3)
@@ -669,6 +683,38 @@ func (s *Screen) handleOSC(payload []byte) {
669 }683 }
670 }684 }
671 685
686+func (s *Screen) handleProgress(rest string) {
687+ rest = strings.TrimPrefix(rest, ";")
688+ if rest == "" {
689+ return
690+ }
691+ token, _, _ := strings.Cut(rest, ";")
692+ state, err := strconv.Atoi(token)
693+ if err != nil {
694+ return
695+ }
696+ switch state {
697+ case progressStateNormal, progressStateIndeterminate, progressStatePaused:
698+ s.progressState = state
699+ case progressStateClear:
700+ previous := s.progressState
701+ s.progressState = state
702+ if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
703+ if s.OnProgress != nil {
704+ s.OnProgress(false)
705+ }
706+ }
707+ case progressStateError:
708+ previous := s.progressState
709+ s.progressState = state
710+ if previous != progressStateError {
711+ if s.OnProgress != nil {
712+ s.OnProgress(true)
713+ }
714+ }
715+ }
716+}
717+
672 // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52718 // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
673 // clipboard payload (selection may be empty; split on the first ";"). A719 // clipboard payload (selection may be empty; split on the first ";"). A
674 // clipboard query (data == "?") is always ignored — vev never answers720 // clipboard query (data == "?") is always ignored — vev never answers
M pkg/vt/screen_test.go +116 -0
@@ -120,14 +120,130 @@ func TestOnNotifyOSC9(t *testing.T) {
120120 require.Equal(t, "agent done", gotBody)
121121 }
122122
123+func TestOSC9ProgressTransitions(t *testing.T) {
124+ tests := []struct {
125+ name string
126+ seqs []string
127+ want []bool
128+ }{
129+ {
130+ name: "finish fires once",
131+ seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
132+ want: []bool{false},
133+ },
134+ {
135+ name: "error fires once repeat no fire",
136+ seqs: []string{"9;4;2;0", "9;4;2;50"},
137+ want: []bool{true},
138+ },
139+ {
140+ name: "indeterminate clear fires",
141+ seqs: []string{"9;4;3", "9;4;0"},
142+ want: []bool{false},
143+ },
144+ {
145+ name: "paused clear fires",
146+ seqs: []string{"9;4;4;80", "9;4;0;80"},
147+ want: []bool{false},
148+ },
149+ {
150+ name: "ongoing updates silent",
151+ seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
152+ },
153+ {
154+ name: "bare clear silent",
155+ seqs: []string{"9;4;0"},
156+ },
157+ {
158+ name: "bare progress ignored",
159+ seqs: []string{"9;4"},
160+ },
161+ {
162+ name: "unparseable ignored",
163+ seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
164+ want: []bool{false},
165+ },
166+ {
167+ name: "error active clear fires error then finish",
168+ seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
169+ want: []bool{true, false},
170+ },
171+ {
172+ name: "error clear only fires error",
173+ seqs: []string{"9;4;2;0", "9;4;0;100"},
174+ want: []bool{true},
175+ },
176+ }
177+
178+ for _, tt := range tests {
179+ t.Run(tt.name, func(t *testing.T) {
180+ s := NewScreen(10, 2)
181+ var got []bool
182+ s.OnProgress = func(errored bool) { got = append(got, errored) }
183+ for _, seq := range tt.seqs {
184+ s.Write([]byte("\x1b]" + seq + "\x07"))
185+ }
186+ require.Equal(t, tt.want, got)
187+ })
188+ }
189+}
190+
191+func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
192+ s := NewScreen(10, 2)
193+ calls := 0
194+ s.OnProgress = func(bool) { calls++ }
195+ s.Write([]byte("\x1b]9;4;1"))
196+ s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
197+ require.Equal(t, 1, calls)
198+}
199+
200+func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
201+ s := NewScreen(10, 2)
202+ require.NotPanics(t, func() {
203+ s.Write([]byte("\x1b]9;4;1;50\x07"))
204+ s.Write([]byte("\x1b]9;4;0;100\x07"))
205+ s.Write([]byte("\x1b]9;4;2;0\x07"))
206+ })
207+}
208+
209+func TestOSC9ProgressSTTerminated(t *testing.T) {
210+ s := NewScreen(10, 2)
211+ var got []bool
212+ s.OnProgress = func(errored bool) { got = append(got, errored) }
213+ s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
214+ require.Equal(t, []bool{false}, got)
215+}
216+
217+func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
218+ s := NewScreen(10, 2)
219+ s.Write([]byte("\x1b]9;4;1;50\x07"))
220+ var got []bool
221+ s.OnProgress = func(errored bool) { got = append(got, errored) }
222+ s.Write([]byte("\x1b]9;4;0;100\x07"))
223+ require.Equal(t, []bool{false}, got)
224+}
225+
123226 func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
124227 s := NewScreen(10, 2)
125228 calls := 0
126229 s.OnNotify = func(string, string) { calls++ }
127230 s.Write([]byte("\x1b]9;4;1;50\x07"))
231+ s.Write([]byte("\x1b]9;4;0;100\x07"))
232+ s.Write([]byte("\x1b]9;4;2;0\x07"))
128233 require.Equal(t, 0, calls)
129234 }
130235
236+func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
237+ s := NewScreen(10, 2)
238+ var gotTitle, gotBody string
239+ calls := 0
240+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
241+ s.Write([]byte("\x1b]9;40;not progress\x07"))
242+ require.Equal(t, 1, calls)
243+ require.Equal(t, "", gotTitle)
244+ require.Equal(t, "40;not progress", gotBody)
245+}
246+
131247 func TestOnNotifyOSC777STTerminated(t *testing.T) {
132248 s := NewScreen(10, 2)
133249 var gotTitle, gotBody string
@@ -120,14 +120,130 @@ func TestOnNotifyOSC9(t *testing.T) {
120 require.Equal(t, "agent done", gotBody)120 require.Equal(t, "agent done", gotBody)
121 }121 }
122 122
123+func TestOSC9ProgressTransitions(t *testing.T) {
124+ tests := []struct {
125+ name string
126+ seqs []string
127+ want []bool
128+ }{
129+ {
130+ name: "finish fires once",
131+ seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
132+ want: []bool{false},
133+ },
134+ {
135+ name: "error fires once repeat no fire",
136+ seqs: []string{"9;4;2;0", "9;4;2;50"},
137+ want: []bool{true},
138+ },
139+ {
140+ name: "indeterminate clear fires",
141+ seqs: []string{"9;4;3", "9;4;0"},
142+ want: []bool{false},
143+ },
144+ {
145+ name: "paused clear fires",
146+ seqs: []string{"9;4;4;80", "9;4;0;80"},
147+ want: []bool{false},
148+ },
149+ {
150+ name: "ongoing updates silent",
151+ seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
152+ },
153+ {
154+ name: "bare clear silent",
155+ seqs: []string{"9;4;0"},
156+ },
157+ {
158+ name: "bare progress ignored",
159+ seqs: []string{"9;4"},
160+ },
161+ {
162+ name: "unparseable ignored",
163+ seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
164+ want: []bool{false},
165+ },
166+ {
167+ name: "error active clear fires error then finish",
168+ seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
169+ want: []bool{true, false},
170+ },
171+ {
172+ name: "error clear only fires error",
173+ seqs: []string{"9;4;2;0", "9;4;0;100"},
174+ want: []bool{true},
175+ },
176+ }
177+
178+ for _, tt := range tests {
179+ t.Run(tt.name, func(t *testing.T) {
180+ s := NewScreen(10, 2)
181+ var got []bool
182+ s.OnProgress = func(errored bool) { got = append(got, errored) }
183+ for _, seq := range tt.seqs {
184+ s.Write([]byte("\x1b]" + seq + "\x07"))
185+ }
186+ require.Equal(t, tt.want, got)
187+ })
188+ }
189+}
190+
191+func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
192+ s := NewScreen(10, 2)
193+ calls := 0
194+ s.OnProgress = func(bool) { calls++ }
195+ s.Write([]byte("\x1b]9;4;1"))
196+ s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
197+ require.Equal(t, 1, calls)
198+}
199+
200+func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
201+ s := NewScreen(10, 2)
202+ require.NotPanics(t, func() {
203+ s.Write([]byte("\x1b]9;4;1;50\x07"))
204+ s.Write([]byte("\x1b]9;4;0;100\x07"))
205+ s.Write([]byte("\x1b]9;4;2;0\x07"))
206+ })
207+}
208+
209+func TestOSC9ProgressSTTerminated(t *testing.T) {
210+ s := NewScreen(10, 2)
211+ var got []bool
212+ s.OnProgress = func(errored bool) { got = append(got, errored) }
213+ s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
214+ require.Equal(t, []bool{false}, got)
215+}
216+
217+func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
218+ s := NewScreen(10, 2)
219+ s.Write([]byte("\x1b]9;4;1;50\x07"))
220+ var got []bool
221+ s.OnProgress = func(errored bool) { got = append(got, errored) }
222+ s.Write([]byte("\x1b]9;4;0;100\x07"))
223+ require.Equal(t, []bool{false}, got)
224+}
225+
123 func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {226 func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
124 s := NewScreen(10, 2)227 s := NewScreen(10, 2)
125 calls := 0228 calls := 0
126 s.OnNotify = func(string, string) { calls++ }229 s.OnNotify = func(string, string) { calls++ }
127 s.Write([]byte("\x1b]9;4;1;50\x07"))230 s.Write([]byte("\x1b]9;4;1;50\x07"))
231+ s.Write([]byte("\x1b]9;4;0;100\x07"))
232+ s.Write([]byte("\x1b]9;4;2;0\x07"))
128 require.Equal(t, 0, calls)233 require.Equal(t, 0, calls)
129 }234 }
130 235
236+func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
237+ s := NewScreen(10, 2)
238+ var gotTitle, gotBody string
239+ calls := 0
240+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
241+ s.Write([]byte("\x1b]9;40;not progress\x07"))
242+ require.Equal(t, 1, calls)
243+ require.Equal(t, "", gotTitle)
244+ require.Equal(t, "40;not progress", gotBody)
245+}
246+
131 func TestOnNotifyOSC777STTerminated(t *testing.T) {247 func TestOnNotifyOSC777STTerminated(t *testing.T) {
132 s := NewScreen(10, 2)248 s := NewScreen(10, 2)
133 var gotTitle, gotBody string249 var gotTitle, gotBody string

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close