Merge pull request #21 from bnema/feature/osc94-attention-mru-bar
feat(daemon): add OSC progress attention MRU bar
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, | |||
| 327 | 327 | name := sess.name | |
| 328 | 328 | tabs := append([]*tab(nil), sess.tabs...) | |
| 329 | 329 | sess.mu.Unlock() | |
| 330 | + | d.touchMRU(sess) | |
| 330 | 331 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 331 | 332 | for _, tb := range tabs { | |
| 332 | 333 | tb.mu.Lock() | |
| @@ -327,6 +327,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 327 | name := sess.name | 327 | 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 ( | |||
| 31 | 31 | "os" | |
| 32 | 32 | "sort" | |
| 33 | 33 | "sync" | |
| 34 | + | "sync/atomic" | |
| 34 | 35 | "time" | |
| 35 | 36 | ||
| 36 | 37 | "github.com/bnema/vev/internal/domain" | |
| @@ -72,6 +73,7 @@ type Daemon struct { | |||
| 72 | 73 | sessions map[domain.SessionID]*session | |
| 73 | 74 | stopped map[string]stoppedSession | |
| 74 | 75 | nextID uint64 | |
| 76 | + | mruSeq atomic.Uint64 | |
| 75 | 77 | // closing marks that shutdown has irreversibly begun. It is set under mu, | |
| 76 | 78 | // atomically with the event that makes shutdown inevitable (the registry | |
| 77 | 79 | // 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]*session | 73 | sessions map[domain.SessionID]*session | ||
| 73 | stopped map[string]stoppedSession | 74 | stopped map[string]stoppedSession | ||
| 74 | nextID uint64 | 75 | 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 registry | 78 | // atomically with the event that makes shutdown inevitable (the registry | ||
| 77 | // emptying in killSession, or shutdownAll starting), and checked by route | 79 | // 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 | |||
| 401 | 401 | targetSess.active = target.TabIndex | |
| 402 | 402 | } | |
| 403 | 403 | targetSess.mu.Unlock() | |
| 404 | + | d.touchMRU(targetSess) | |
| 404 | 405 | ac.setSession(targetSess) | |
| 405 | 406 | d.mu.Unlock() | |
| 406 | 407 | return old | |
| @@ -435,6 +436,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 435 | 436 | targetSess.mu.Lock() | |
| 436 | 437 | targetSess.client = ac | |
| 437 | 438 | targetSess.mu.Unlock() | |
| 439 | + | d.touchMRU(targetSess) | |
| 438 | 440 | ac.setSession(targetSess) | |
| 439 | 441 | d.mu.Unlock() | |
| 440 | 442 | d.firstPaint(targetSess, ac, ac.size) | |
| @@ -401,6 +401,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 401 | targetSess.active = target.TabIndex | 401 | 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 old | 407 | 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 = ac | 437 | 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) { | |||
| 59 | 59 | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 60 | 60 | p.screen.OnBell = func() { signal(attentionCh) } | |
| 61 | 61 | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | |
| 62 | + | p.screen.OnProgress = func(bool) { signal(attentionCh) } | |
| 62 | 63 | p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) } | |
| 63 | 64 | p.mu.Unlock() | |
| 64 | 65 | 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 | |||
| 150 | 150 | sess.mu.Lock() | |
| 151 | 151 | sess.client = ac | |
| 152 | 152 | sess.mu.Unlock() | |
| 153 | + | d.touchMRU(sess) | |
| 153 | 154 | d.log.Info("client resumed", "session", sess.name) | |
| 154 | 155 | return sess, ac, true, nil | |
| 155 | 156 | } | |
| @@ -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 = ac | 151 | 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, nil | 155 | return sess, ac, true, nil | ||
| 155 | } | 156 | } | ||
M internal/usecase/daemon/session.go +20 -0
| @@ -32,6 +32,7 @@ import ( | |||
| 32 | 32 | "strconv" | |
| 33 | 33 | "strings" | |
| 34 | 34 | "sync" | |
| 35 | + | "sync/atomic" | |
| 35 | 36 | "time" | |
| 36 | 37 | ||
| 37 | 38 | "github.com/bnema/vev/internal/domain" | |
| @@ -56,6 +57,7 @@ type session struct { | |||
| 56 | 57 | clipboardWorkerRunning bool | |
| 57 | 58 | cwd string | |
| 58 | 59 | createdAt int64 | |
| 60 | + | mruAt atomic.Uint64 | |
| 59 | 61 | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 60 | 62 | // clipboard.go) written for this session, removed best-effort in | |
| 61 | 63 | // killSession. | |
| @@ -86,6 +88,22 @@ type tab struct { | |||
| 86 | 88 | // what it has actually seen). sendMu serialises the two senders — the render | |
| 87 | 89 | // 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 | 107 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { | |
| 90 | 108 | tbSize := tabSize(sz) | |
| 91 | 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 | 138 | delete(d.stopped, name) | |
| 121 | 139 | } | |
| 122 | 140 | d.sessions[id] = sess | |
| 141 | + | d.touchMRU(sess) | |
| 123 | 142 | d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral) | |
| 124 | 143 | d.log.Info("tab created", "session", name, "tab", 0) | |
| 125 | 144 | d.startTabGoroutines(sess, tb) | |
| @@ -168,6 +187,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 168 | 187 | newSess.mu.Lock() | |
| 169 | 188 | newSess.client = ac | |
| 170 | 189 | newSess.mu.Unlock() | |
| 190 | + | d.touchMRU(newSess) | |
| 171 | 191 | ac.setSession(newSess) | |
| 172 | 192 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | |
| 173 | 193 | 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 bool | 57 | clipboardWorkerRunning bool | ||
| 57 | cwd string | 58 | cwd string | ||
| 58 | createdAt int64 | 59 | createdAt int64 | ||
| 60 | + | mruAt atomic.Uint64 | |||
| 59 | // clipFiles records clipboard-image-transfer temp file paths (see | 61 | // clipFiles records clipboard-image-transfer temp file paths (see | ||
| 60 | // clipboard.go) written for this session, removed best-effort in | 62 | // 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 render | 88 | // what it has actually seen). sendMu serialises the two senders — the render | ||
| 87 | // scheduler and the connection handler — so the transport's single-writer | 89 | // 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] = sess | 140 | 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 = ac | 188 | 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 ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | "log/slog" | |
| 7 | 7 | "sync" | |
| 8 | + | "sync/atomic" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| @@ -659,6 +660,55 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |||
| 659 | 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 | 712 | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |
| 663 | 713 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 664 | 714 | 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 | |||
| 27 | 27 | import ( | |
| 28 | 28 | "sort" | |
| 29 | 29 | "strconv" | |
| 30 | - | "strings" | |
| 31 | 30 | "time" | |
| 32 | 31 | ||
| 33 | 32 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -36,6 +35,7 @@ import ( | |||
| 36 | 35 | ||
| 37 | 36 | const ( | |
| 38 | 37 | attentionGlyph = '' | |
| 38 | + | maxMRUSessions = 9 | |
| 39 | 39 | pulseFrameCount = 30 | |
| 40 | 40 | pulseFrameInterval = 120 * time.Millisecond | |
| 41 | 41 | ) | |
| @@ -76,27 +76,27 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s | |||
| 76 | 76 | ||
| 77 | 77 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) { | |
| 78 | 78 | clearStatusRow(row) | |
| 79 | - | leftText := " " + state.status.session + " " | |
| 80 | 79 | 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)) | |
| 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 | + | } | |
| 91 | 94 | } | |
| 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) | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 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 | 100 | return | |
| 101 | 101 | } | |
| 102 | 102 | x := len(row) - len([]rune(text)) - 1 | |
| @@ -112,7 +112,7 @@ func clearStatusRow(row []renderer.Cell) { | |||
| 112 | 112 | type barState struct { | |
| 113 | 113 | status statusSnapshot | |
| 114 | 114 | copyFeedback string | |
| 115 | - | otherAttention []string | |
| 115 | + | mru []mruSession | |
| 116 | 116 | attentionFrame int | |
| 117 | 117 | // theme is the client's terminal theme, if reported. Its zero value | |
| 118 | 118 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | |
| @@ -120,6 +120,14 @@ type barState struct { | |||
| 120 | 120 | 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 | 131 | type statusSnapshot struct { | |
| 124 | 132 | session string | |
| 125 | 133 | tabs []statusTab | |
| @@ -154,72 +162,79 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 154 | 162 | if cur != nil { | |
| 155 | 163 | state.status = cur.statusSegments() | |
| 156 | 164 | } | |
| 157 | - | if d == nil || copyFeedback != "" { | |
| 165 | + | if d == nil { | |
| 158 | 166 | return state | |
| 159 | 167 | } | |
| 160 | - | type attentionSession struct { | |
| 161 | - | name string | |
| 162 | - | at time.Time | |
| 163 | - | } | |
| 164 | 168 | d.mu.Lock() | |
| 165 | - | attention := make([]attentionSession, 0, len(d.sessions)) | |
| 169 | + | mru := make([]mruSession, 0, len(d.sessions)) | |
| 166 | 170 | for _, sess := range d.sessions { | |
| 167 | 171 | if sess == cur { | |
| 168 | 172 | continue | |
| 169 | 173 | } | |
| 174 | + | at := sess.mruAt.Load() | |
| 170 | 175 | sess.mu.Lock() | |
| 171 | - | var oldest time.Time | |
| 172 | - | found := false | |
| 176 | + | entry := mruSession{name: sess.name, ephemeral: sess.ephemeral, mruAt: at} | |
| 173 | 177 | 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 | |
| 180 | 181 | } | |
| 181 | 182 | } | |
| 182 | - | if found { | |
| 183 | - | attention = append(attention, attentionSession{name: sess.name, at: oldest}) | |
| 184 | - | } | |
| 185 | 183 | sess.mu.Unlock() | |
| 184 | + | mru = append(mru, entry) | |
| 186 | 185 | } | |
| 187 | 186 | 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] | |
| 192 | 195 | } | |
| 196 | + | state.mru = mru | |
| 193 | 197 | return state | |
| 194 | 198 | } | |
| 195 | 199 | ||
| 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 | |
| 199 | 206 | } | |
| 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 | |
| 203 | 210 | } | |
| 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 | |
| 207 | 220 | } | |
| 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 | + | } | |
| 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) { | |
| 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 | |
| 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 | 240 | 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 = 30 | 39 | pulseFrameCount = 30 | ||
| 40 | pulseFrameInterval = 120 * time.Millisecond | 40 | 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 := 0 | 79 | 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 | - | return | 85 | + | name := sess.name |
| 87 | - | } | 86 | + | if sess.ephemeral { |
| 88 | - | rightText := state.attentionStackText(len(row), reservedLeft) | 87 | + | name += "*" |
| 89 | - | if rightText == "" { | 88 | + | } |
| 90 | - | return | 89 | + | 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)) + 1 | 95 | + | 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 | return | 100 | return | ||
| 101 | } | 101 | } | ||
| 102 | x := len(row) - len([]rune(text)) - 1 | 102 | 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 statusSnapshot | 113 | status statusSnapshot | ||
| 114 | copyFeedback string | 114 | copyFeedback string | ||
| 115 | - | otherAttention []string | 115 | + | mru []mruSession |
| 116 | attentionFrame int | 116 | attentionFrame int | ||
| 117 | // theme is the client's terminal theme, if reported. Its zero value | 117 | // theme is the client's terminal theme, if reported. Its zero value | ||
| 118 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | 118 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | ||
| @@ -120,6 +120,14 @@ type barState struct { | |||||
| 120 | theme themeui.Theme | 120 | 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 string | 132 | session string | ||
| 125 | tabs []statusTab | 133 | 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 state | 166 | 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 | continue | 172 | continue | ||
| 169 | } | 173 | } | ||
| 174 | + | at := sess.mruAt.Load() | |||
| 170 | sess.mu.Lock() | 175 | sess.mu.Lock() | ||
| 171 | - | var oldest time.Time | 176 | + | 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 | - | continue | 179 | + | 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].name | 190 | + | } |
| 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 state | 197 | 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) + " " + name | 209 | + | 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 full | 213 | + | 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 collapsed | 223 | + | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| @@ -283,61 +284,129 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 283 | 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 | 288 | p, releasePTY := newBlockingPTY(t) | |
| 288 | 289 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 289 | 290 | defer releasePTY() | |
| 290 | 291 | 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 | |
| 295 | 297 | } | |
| 296 | - | d.sessions["old"] = newAttendedSession("old", "old", base) | |
| 297 | - | d.sessions["new"] = newAttendedSession("new", "new", base.Add(time.Minute)) | |
| 298 | 298 | ||
| 299 | 299 | state := d.barStateFor(sess, "") | |
| 300 | - | state.attentionFrame = 1 | |
| 301 | - | require.Equal(t, []string{"old", "new"}, state.otherAttention) | |
| 302 | 300 | ||
| 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") | |
| 314 | 308 | } | |
| 315 | 309 | ||
| 316 | - | func TestBarStateForIncludesZeroTimeAttention(t *testing.T) { | |
| 310 | + | func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) { | |
| 317 | 311 | p, releasePTY := newBlockingPTY(t) | |
| 318 | 312 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 319 | 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 | 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) { | |
| 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) | |
| 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 | 412 | func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | 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 = true | 330 | + | {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 { | |||
| 31 | 31 | // so keep this value in sync if that payload cap changes. | |
| 32 | 32 | const maxEscapeBufferLen = 128 * 1024 | |
| 33 | 33 | ||
| 34 | + | const ( | |
| 35 | + | progressStateClear = iota | |
| 36 | + | progressStateNormal | |
| 37 | + | progressStateError | |
| 38 | + | progressStateIndeterminate | |
| 39 | + | progressStatePaused | |
| 40 | + | ) | |
| 41 | + | ||
| 34 | 42 | type Screen struct { | |
| 35 | 43 | Frame renderer.Frame | |
| 36 | 44 | Row int | |
| @@ -51,6 +59,10 @@ type Screen struct { | |||
| 51 | 59 | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | |
| 52 | 60 | // Other non-clipboard OSC payloads remain discarded. Nil disables it. | |
| 53 | 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 | 66 | // OnClipboard is called synchronously from Write for a complete OSC 52 | |
| 55 | 67 | // clipboard set request from the child. The OSC 52 selection field is | |
| 56 | 68 | // accepted but ignored; the callback receives only the raw base64 payload. | |
| @@ -70,6 +82,7 @@ type Screen struct { | |||
| 70 | 82 | savedCursor cursorState | |
| 71 | 83 | alternate *screenState | |
| 72 | 84 | syncUpdateActive bool | |
| 85 | + | progressState int | |
| 73 | 86 | cursorVisible bool | |
| 74 | 87 | cursorStyle int | |
| 75 | 88 | cursorStyleSet bool | |
| @@ -643,15 +656,16 @@ func (s *Screen) handleOSC(payload []byte) { | |||
| 643 | 656 | s.handleOSC52(string(payload[len("52;"):])) | |
| 644 | 657 | 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 | 664 | if s.OnNotify == nil { | |
| 647 | 665 | return | |
| 648 | 666 | } | |
| 649 | - | p := string(payload) | |
| 650 | 667 | switch { | |
| 651 | 668 | case strings.HasPrefix(p, "9;"): | |
| 652 | - | if strings.HasPrefix(p, "9;4;") { | |
| 653 | - | return | |
| 654 | - | } | |
| 655 | 669 | s.OnNotify("", p[len("9;"):]) | |
| 656 | 670 | case strings.HasPrefix(p, "777;"): | |
| 657 | 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 | 718 | // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52 | |
| 673 | 719 | // clipboard payload (selection may be empty; split on the first ";"). A | |
| 674 | 720 | // 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 * 1024 | 32 | 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.Frame | 43 | Frame renderer.Frame | ||
| 36 | Row int | 44 | 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 52 | 66 | // OnClipboard is called synchronously from Write for a complete OSC 52 | ||
| 55 | // clipboard set request from the child. The OSC 52 selection field is | 67 | // 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 cursorState | 82 | savedCursor cursorState | ||
| 71 | alternate *screenState | 83 | alternate *screenState | ||
| 72 | syncUpdateActive bool | 84 | syncUpdateActive bool | ||
| 85 | + | progressState int | |||
| 73 | cursorVisible bool | 86 | cursorVisible bool | ||
| 74 | cursorStyle int | 87 | cursorStyle int | ||
| 75 | cursorStyleSet bool | 88 | 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 | return | 657 | 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 | return | 665 | 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 52 | 718 | // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52 | ||
| 673 | // clipboard payload (selection may be empty; split on the first ";"). A | 719 | // clipboard payload (selection may be empty; split on the first ";"). A | ||
| 674 | // clipboard query (data == "?") is always ignored — vev never answers | 720 | // 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) { | |||
| 120 | 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 | 226 | func TestOnNotifyIgnoresOSC9Progress(t *testing.T) { | |
| 124 | 227 | s := NewScreen(10, 2) | |
| 125 | 228 | calls := 0 | |
| 126 | 229 | s.OnNotify = func(string, string) { calls++ } | |
| 127 | 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 | 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 | 247 | func TestOnNotifyOSC777STTerminated(t *testing.T) { | |
| 132 | 248 | s := NewScreen(10, 2) | |
| 133 | 249 | 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 := 0 | 228 | 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 string | 249 | var gotTitle, gotBody string | ||