detached 0d707da

Merge pull request #118 from bnema/feat/notifications

feat(notifications): add user-facing notification system
commit
0d707da26f910a931b84e445c823a210b2585d93
author
bnema · · 2026-07-21 14:30 UTC
committer
GitHub · · 2026-07-21 14:30 UTC
merge of
731fc1d + 81c1931 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
77 files · +6724 -290
A internal/adapters/noticefile/noticefile.go +365 -0
@@ -0,0 +1,365 @@
1+// Package noticefile persists undeliverable daemon notices as newline-
2+// delimited JSON so they survive a daemon restart and can be shown at the
3+// next client attach.
4+package noticefile
5+
6+import (
7+ "bufio"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os"
13+ "path/filepath"
14+ "sync"
15+ "syscall"
16+
17+ "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
19+ "github.com/bnema/vev/pkg/safedir"
20+)
21+
22+const (
23+ fileName = "pending-notices.jsonl"
24+ inFlightFileName = "pending-notices.inflight.jsonl"
25+ lockFileName = "pending-notices.lock"
26+ claimLockFileName = "pending-notices.claim.lock"
27+ maxNoticeRecordSize = 4 << 20
28+ noticeReadBufferSize = 64 << 10
29+)
30+
31+// Store persists notices as JSON-lines under dir.
32+type Store struct {
33+ dir string
34+
35+ claimMu sync.Mutex
36+ claimLock *os.File
37+
38+ // afterAppendOpen is used by package tests to pause an append after it has
39+ // opened the pending file while it owns the interprocess lock. It is nil in
40+ // production.
41+ afterAppendOpen func()
42+
43+ // removeFile, syncDirectory, and beforeAckRelease make Ack cleanup and
44+ // claim-release ordering deterministic in package tests. They are nil in
45+ // production.
46+ removeFile func(string) error
47+ syncDirectory func(string) error
48+ beforeAckRelease func()
49+}
50+
51+var (
52+ // ErrClaimInProgress means another live Store owns the in-flight claim.
53+ // Callers must leave it for that owner rather than replaying or acknowledging
54+ // its notices.
55+ ErrClaimInProgress = errors.New("pending notice claim in progress")
56+ // ErrNoClaimOwner means Ack was called by a Store that did not make the
57+ // current claim.
58+ ErrNoClaimOwner = errors.New("pending notice claim is not owned by this store")
59+)
60+
61+var _ ports.NoticeStore = (*Store)(nil)
62+
63+// New creates a file-backed notice store rooted at dir.
64+func New(dir string) *Store {
65+ return &Store{dir: dir}
66+}
67+
68+func (s *Store) path() string {
69+ return filepath.Join(s.dir, fileName)
70+}
71+
72+func (s *Store) inFlightPath() string {
73+ return filepath.Join(s.dir, inFlightFileName)
74+}
75+
76+func (s *Store) lockPath() string {
77+ return filepath.Join(s.dir, lockFileName)
78+}
79+
80+func (s *Store) claimLockPath() string {
81+ return filepath.Join(s.dir, claimLockFileName)
82+}
83+
84+// withLock serializes Append, Claim, and Ack across Store instances and
85+// processes. Holding this lock while Claim rotates and reads the file prevents
86+// an appender with an already-open file descriptor from writing into a claim
87+// after its reader reached EOF.
88+func (s *Store) withLock(fn func() error) error {
89+ if err := safedir.EnsurePrivate(s.dir); err != nil {
90+ return fmt.Errorf("create notice dir: %w", err)
91+ }
92+ f, err := os.OpenFile(s.lockPath(), os.O_CREATE|os.O_RDWR, 0o600)
93+ if err != nil {
94+ return fmt.Errorf("open notice lock: %w", err)
95+ }
96+ defer func() { _ = f.Close() }()
97+ if err := f.Chmod(0o600); err != nil {
98+ return fmt.Errorf("secure notice lock: %w", err)
99+ }
100+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
101+ return fmt.Errorf("lock pending notices: %w", err)
102+ }
103+ defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
104+ return fn()
105+}
106+
107+// acquireClaimLock reserves the current in-flight file for this Store until
108+// Ack. The flock is released by the kernel if this process exits, allowing a
109+// later daemon to recover an abandoned claim.
110+func (s *Store) acquireClaimLock() error {
111+ s.claimMu.Lock()
112+ defer s.claimMu.Unlock()
113+ if s.claimLock != nil {
114+ return ErrClaimInProgress
115+ }
116+ f, err := os.OpenFile(s.claimLockPath(), os.O_CREATE|os.O_RDWR, 0o600)
117+ if err != nil {
118+ return fmt.Errorf("open notice claim lock: %w", err)
119+ }
120+ if err := f.Chmod(0o600); err != nil {
121+ _ = f.Close()
122+ return fmt.Errorf("secure notice claim lock: %w", err)
123+ }
124+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
125+ _ = f.Close()
126+ if errors.Is(err, syscall.EWOULDBLOCK) {
127+ return ErrClaimInProgress
128+ }
129+ return fmt.Errorf("lock pending notice claim: %w", err)
130+ }
131+ s.claimLock = f
132+ return nil
133+}
134+
135+func (s *Store) releaseClaimLock() {
136+ s.claimMu.Lock()
137+ f := s.claimLock
138+ s.claimLock = nil
139+ s.claimMu.Unlock()
140+ if f == nil {
141+ return
142+ }
143+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
144+ _ = f.Close()
145+}
146+
147+func (s *Store) ownsClaim() bool {
148+ s.claimMu.Lock()
149+ defer s.claimMu.Unlock()
150+ return s.claimLock != nil
151+}
152+
153+func syncDir(dir string) error {
154+ f, err := os.Open(dir)
155+ if err != nil {
156+ return err
157+ }
158+ defer func() { _ = f.Close() }()
159+ return f.Sync()
160+}
161+
162+// Append writes n as one JSON line, creating the private state dir if needed.
163+// A successful append is synced before it is made available to a later claim.
164+func (s *Store) Append(n domain.Notification) error {
165+ data, err := json.Marshal(n)
166+ if err != nil {
167+ return fmt.Errorf("marshal notice: %w", err)
168+ }
169+ data = append(data, '\n')
170+
171+ return s.withLock(func() error {
172+ f, err := os.OpenFile(s.path(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
173+ if err != nil {
174+ return fmt.Errorf("open pending notices: %w", err)
175+ }
176+ if err := f.Chmod(0o600); err != nil {
177+ _ = f.Close()
178+ return fmt.Errorf("secure pending notices: %w", err)
179+ }
180+ if s.afterAppendOpen != nil {
181+ s.afterAppendOpen()
182+ }
183+ if _, err := f.Write(data); err != nil {
184+ _ = f.Close()
185+ return fmt.Errorf("write notice: %w", err)
186+ }
187+ if err := f.Sync(); err != nil {
188+ _ = f.Close()
189+ return fmt.Errorf("sync pending notices: %w", err)
190+ }
191+ if err := f.Close(); err != nil {
192+ return fmt.Errorf("close pending notices: %w", err)
193+ }
194+ if err := syncDir(s.dir); err != nil {
195+ return fmt.Errorf("sync notice dir: %w", err)
196+ }
197+ return nil
198+ })
199+}
200+
201+// Claim atomically moves pending notices into an in-flight file and returns
202+// their valid JSONL entries in order. Repeating Claim on its owning Store
203+// replays that Store's existing claim; a concurrent Store cannot replay or
204+// acknowledge it. The ownership flock is released on process exit, allowing
205+// an unacknowledged import to be replayed after a crash. A missing pending
206+// file means there is nothing to claim.
207+func (s *Store) Claim() ([]domain.Notification, error) {
208+ var notices []domain.Notification
209+ err := s.withLock(func() error {
210+ if s.ownsClaim() {
211+ var err error
212+ notices, err = readNotices(s.inFlightPath())
213+ return err
214+ }
215+ if err := s.acquireClaimLock(); err != nil {
216+ return err
217+ }
218+ keepClaim := false
219+ defer func() {
220+ if !keepClaim {
221+ s.releaseClaimLock()
222+ }
223+ }()
224+
225+ inFlight := s.inFlightPath()
226+ if _, err := os.Stat(inFlight); err != nil {
227+ if !errors.Is(err, os.ErrNotExist) {
228+ return fmt.Errorf("stat in-flight notices: %w", err)
229+ }
230+ if err := os.Rename(s.path(), inFlight); err != nil {
231+ if errors.Is(err, os.ErrNotExist) {
232+ return nil
233+ }
234+ return fmt.Errorf("claim pending notices: %w", err)
235+ }
236+ // fsync the parent directory so the atomic claim is recoverable after
237+ // power loss, not merely after a clean process restart.
238+ if err := syncDir(s.dir); err != nil {
239+ return fmt.Errorf("sync claimed notices: %w", err)
240+ }
241+ }
242+
243+ var err error
244+ notices, err = readNotices(inFlight)
245+ if err == nil {
246+ keepClaim = true
247+ }
248+ return err
249+ })
250+ if err != nil {
251+ return nil, err
252+ }
253+ return notices, nil
254+}
255+
256+func readNotices(path string) ([]domain.Notification, error) {
257+ f, err := os.Open(path)
258+ if err != nil {
259+ return nil, fmt.Errorf("open claimed notices: %w", err)
260+ }
261+ defer func() { _ = f.Close() }()
262+
263+ var notices []domain.Notification
264+ reader := bufio.NewReaderSize(f, noticeReadBufferSize)
265+ for {
266+ line, oversized, eof, err := readNoticeRecord(reader)
267+ if err != nil {
268+ return nil, fmt.Errorf("read claimed notices: %w", err)
269+ }
270+ if len(line) > 0 && !oversized {
271+ var n domain.Notification
272+ if err := json.Unmarshal(line, &n); err == nil {
273+ notices = append(notices, n)
274+ }
275+ }
276+ if eof {
277+ return notices, nil
278+ }
279+ }
280+}
281+
282+// readNoticeRecord reads one newline-delimited record without retaining more
283+// than maxNoticeRecordSize bytes. Oversized records are discarded through
284+// their delimiter so a later valid record can still be read.
285+func readNoticeRecord(reader *bufio.Reader) (line []byte, oversized, eof bool, err error) {
286+ for {
287+ fragment, readErr := reader.ReadSlice('\n')
288+ if len(fragment) > 0 && fragment[len(fragment)-1] == '\n' {
289+ fragment = fragment[:len(fragment)-1]
290+ }
291+ if !oversized {
292+ line, oversized = appendNoticeFragment(line, fragment)
293+ }
294+
295+ switch {
296+ case readErr == nil:
297+ return line, oversized, false, nil
298+ case errors.Is(readErr, bufio.ErrBufferFull):
299+ continue
300+ case errors.Is(readErr, io.EOF):
301+ return line, oversized, true, nil
302+ default:
303+ return nil, false, false, readErr
304+ }
305+ }
306+}
307+
308+func appendNoticeFragment(line, fragment []byte) ([]byte, bool) {
309+ if len(fragment) > maxNoticeRecordSize-len(line) {
310+ return nil, true
311+ }
312+ required := len(line) + len(fragment)
313+ if required > cap(line) {
314+ capacity := cap(line) * 2
315+ if capacity < noticeReadBufferSize {
316+ capacity = noticeReadBufferSize
317+ }
318+ if capacity > maxNoticeRecordSize {
319+ capacity = maxNoticeRecordSize
320+ }
321+ grown := make([]byte, len(line), capacity)
322+ copy(grown, line)
323+ line = grown
324+ }
325+ return append(line, fragment...), false
326+}
327+
328+// Ack permanently removes this Store's in-flight claim. It is only called once
329+// the daemon has recorded and queued every claimed notice.
330+func (s *Store) Ack() error {
331+ if !s.ownsClaim() {
332+ return ErrNoClaimOwner
333+ }
334+ return s.withLock(func() error {
335+ if !s.ownsClaim() {
336+ return ErrNoClaimOwner
337+ }
338+ // Ack is terminal for this Store's claim even when removing or syncing
339+ // the record fails. Release while the operation lock is still held so a
340+ // concurrent Claim on this Store cannot see a removed in-flight file
341+ // while it still appears to own the claim.
342+ defer func() {
343+ if s.beforeAckRelease != nil {
344+ s.beforeAckRelease()
345+ }
346+ s.releaseClaimLock()
347+ }()
348+
349+ remove := os.Remove
350+ if s.removeFile != nil {
351+ remove = s.removeFile
352+ }
353+ if err := remove(s.inFlightPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
354+ return fmt.Errorf("ack claimed notices: %w", err)
355+ }
356+ sync := syncDir
357+ if s.syncDirectory != nil {
358+ sync = s.syncDirectory
359+ }
360+ if err := sync(s.dir); err != nil {
361+ return fmt.Errorf("sync acknowledged notices: %w", err)
362+ }
363+ return nil
364+ })
365+}
A internal/adapters/noticefile/noticefile_test.go +349 -0
@@ -0,0 +1,349 @@
1+package noticefile
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "syscall"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+)
17+
18+func TestStoreClaimAckRoundTrip(t *testing.T) {
19+ t.Parallel()
20+ dir := filepath.Join(t.TempDir(), "vev")
21+ store := New(dir)
22+
23+ first := domain.Notification{
24+ Code: domain.NoticeSnapshotWrite,
25+ Severity: domain.NoticeError,
26+ Message: "session foo shut down without saving terminal state",
27+ Details: "boom: disk full",
28+ Time: time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC),
29+ Count: 1,
30+ SessionID: "sess-1",
31+ }
32+ second := domain.Notification{
33+ Code: domain.NoticeSnapshotRestore,
34+ Severity: domain.NoticeWarn,
35+ Message: "session bar could not be restored",
36+ Time: time.Date(2026, 7, 20, 10, 5, 0, 0, time.UTC),
37+ Count: 2,
38+ }
39+ require.NoError(t, store.Append(first))
40+ require.NoError(t, store.Append(second))
41+
42+ claimed, err := store.Claim()
43+ require.NoError(t, err)
44+ require.Equal(t, []domain.Notification{first, second}, claimed)
45+
46+ // Releasing the claim lock simulates the kernel closing it when the daemon
47+ // crashes. The in-flight file remains and a fresh process/store replays it.
48+ store.releaseClaimLock()
49+ recovered := New(dir)
50+ replayed, err := recovered.Claim()
51+ require.NoError(t, err)
52+ require.Equal(t, claimed, replayed)
53+
54+ require.NoError(t, recovered.Ack())
55+ again, err := New(dir).Claim()
56+ require.NoError(t, err)
57+ require.Empty(t, again)
58+}
59+
60+func TestStoreClaimWaitsForAppendInCriticalSection(t *testing.T) {
61+ t.Parallel()
62+ dir := filepath.Join(t.TempDir(), "vev")
63+ store := New(dir)
64+ first := domain.Notification{Message: "before claim"}
65+ second := domain.Notification{Message: "append holds pending file open"}
66+ require.NoError(t, store.Append(first))
67+
68+ appendEntered := make(chan struct{})
69+ allowAppend := make(chan struct{})
70+ store.afterAppendOpen = func() {
71+ close(appendEntered)
72+ <-allowAppend
73+ }
74+ appendDone := make(chan error, 1)
75+ go func() { appendDone <- store.Append(second) }()
76+ <-appendEntered // Append owns the operation lock and an open pending file.
77+
78+ claimant := New(dir)
79+ claimDone := make(chan []domain.Notification, 1)
80+ errDone := make(chan error, 1)
81+ go func() {
82+ claimed, err := claimant.Claim()
83+ claimDone <- claimed
84+ errDone <- err
85+ }()
86+ close(allowAppend)
87+ require.NoError(t, <-appendDone)
88+ require.NoError(t, <-errDone)
89+ require.Equal(t, []domain.Notification{first, second}, <-claimDone)
90+ require.NoError(t, claimant.Ack())
91+}
92+
93+func TestStoreRepeatedClaimReplaysOwnerAndBlocksCompetitor(t *testing.T) {
94+ t.Parallel()
95+ dir := filepath.Join(t.TempDir(), "vev")
96+ owner := New(dir)
97+ want := domain.Notification{Message: "owned"}
98+ require.NoError(t, owner.Append(want))
99+
100+ first, err := owner.Claim()
101+ require.NoError(t, err)
102+ require.Equal(t, []domain.Notification{want}, first)
103+
104+ replayed, err := owner.Claim()
105+ require.NoError(t, err)
106+ require.Equal(t, first, replayed)
107+
108+ other := New(dir)
109+ blocked, err := other.Claim()
110+ require.ErrorIs(t, err, ErrClaimInProgress)
111+ require.Nil(t, blocked)
112+
113+ require.NoError(t, owner.Ack())
114+ empty, err := other.Claim()
115+ require.NoError(t, err)
116+ require.Empty(t, empty)
117+}
118+
119+func TestStoreAckReleasesClaimAfterCleanupFailure(t *testing.T) {
120+ t.Parallel()
121+
122+ for _, tt := range []struct {
123+ name string
124+ inject func(*Store, error)
125+ want []domain.Notification
126+ }{
127+ {
128+ name: "remove",
129+ inject: func(store *Store, cause error) {
130+ store.removeFile = func(string) error { return cause }
131+ },
132+ want: []domain.Notification{{Message: "owned"}},
133+ },
134+ {
135+ name: "sync directory",
136+ inject: func(store *Store, cause error) {
137+ store.syncDirectory = func(string) error { return cause }
138+ },
139+ want: nil,
140+ },
141+ } {
142+ t.Run(tt.name, func(t *testing.T) {
143+ dir := filepath.Join(t.TempDir(), "vev")
144+ owner := New(dir)
145+ require.NoError(t, owner.Append(domain.Notification{Message: "owned"}))
146+ _, err := owner.Claim()
147+ require.NoError(t, err)
148+
149+ cause := errors.New("cleanup failed")
150+ tt.inject(owner, cause)
151+ require.ErrorIs(t, owner.Ack(), cause)
152+
153+ // A new Store proves the failed Ack released the ownership flock.
154+ recovered, err := New(dir).Claim()
155+ require.NoError(t, err)
156+ require.Equal(t, tt.want, recovered)
157+ })
158+ }
159+}
160+
161+func TestStoreAckSerializesClaimReleaseWithSameStoreReclaim(t *testing.T) {
162+ dir := filepath.Join(t.TempDir(), "vev")
163+ store := New(dir)
164+ require.NoError(t, store.Append(domain.Notification{Message: "owned"}))
165+ _, err := store.Claim()
166+ require.NoError(t, err)
167+
168+ releaseEntered := make(chan struct{})
169+ allowRelease := make(chan struct{})
170+ lockHeld := make(chan error, 1)
171+ store.beforeAckRelease = func() {
172+ held, err := operationLockHeld(store.lockPath())
173+ if err != nil {
174+ lockHeld <- err
175+ } else if !held {
176+ lockHeld <- errors.New("Ack released the operation lock before claim ownership")
177+ } else {
178+ lockHeld <- nil
179+ }
180+ close(releaseEntered)
181+ <-allowRelease
182+ }
183+
184+ ackDone := make(chan error, 1)
185+ go func() { ackDone <- store.Ack() }()
186+ <-releaseEntered // The in-flight file is gone, but Ack still owns the operation lock.
187+ require.NoError(t, <-lockHeld)
188+
189+ claimStarted := make(chan struct{})
190+ claimDone := make(chan struct {
191+ notices []domain.Notification
192+ err error
193+ }, 1)
194+ go func() {
195+ close(claimStarted)
196+ notices, err := store.Claim()
197+ claimDone <- struct {
198+ notices []domain.Notification
199+ err error
200+ }{notices, err}
201+ }()
202+ <-claimStarted
203+ close(allowRelease)
204+
205+ require.NoError(t, <-ackDone)
206+ reclaimed := <-claimDone
207+ require.NoError(t, reclaimed.err)
208+ require.Empty(t, reclaimed.notices)
209+}
210+
211+func operationLockHeld(path string) (bool, error) {
212+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
213+ if err != nil {
214+ return false, err
215+ }
216+ defer func() { _ = f.Close() }()
217+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
218+ if errors.Is(err, syscall.EWOULDBLOCK) {
219+ return true, nil
220+ }
221+ return false, err
222+ }
223+ defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
224+ return false, nil
225+}
226+
227+func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) {
228+ t.Parallel()
229+ dir := filepath.Join(t.TempDir(), "vev")
230+ owner := New(dir)
231+ require.NoError(t, owner.Append(domain.Notification{Message: "owned"}))
232+ claimed, err := owner.Claim()
233+ require.NoError(t, err)
234+ require.Len(t, claimed, 1)
235+
236+ other := New(dir)
237+ replayed, err := other.Claim()
238+ require.ErrorIs(t, err, ErrClaimInProgress)
239+ require.Nil(t, replayed)
240+ require.ErrorIs(t, other.Ack(), ErrNoClaimOwner)
241+
242+ // The rejected Ack must leave the owner's in-flight record intact.
243+ require.NoError(t, owner.Ack())
244+ empty, err := New(dir).Claim()
245+ require.NoError(t, err)
246+ require.Empty(t, empty)
247+}
248+
249+func TestStoreClaimMissingFileReturnsNil(t *testing.T) {
250+ t.Parallel()
251+
252+ got, err := New(filepath.Join(t.TempDir(), "vev")).Claim()
253+ require.NoError(t, err)
254+ require.Nil(t, got)
255+}
256+
257+func TestStoreClaimSkipsGarbageLinesInOrder(t *testing.T) {
258+ t.Parallel()
259+ dir := filepath.Join(t.TempDir(), "vev")
260+ store := New(dir)
261+ path := filepath.Join(dir, fileName)
262+ require.NoError(t, os.MkdirAll(dir, 0o700))
263+
264+ good := `{"Code":9,"Severity":2,"Message":"first"}` + "\n"
265+ garbage := "not json at all\n"
266+ good2 := `{"Code":10,"Severity":1,"Message":"second"}` + "\n"
267+ require.NoError(t, os.WriteFile(path, []byte(good+garbage+good2), 0o600))
268+
269+ got, err := store.Claim()
270+ require.NoError(t, err)
271+ require.Len(t, got, 2)
272+ require.Equal(t, "first", got[0].Message)
273+ require.Equal(t, "second", got[1].Message)
274+ require.NoError(t, store.Ack())
275+}
276+
277+func TestStoreClaimSkipsOversizedLines(t *testing.T) {
278+ t.Parallel()
279+
280+ validLine := func(message string) string {
281+ t.Helper()
282+ data, err := json.Marshal(domain.Notification{Message: message})
283+ require.NoError(t, err)
284+ return string(data)
285+ }
286+ // Derive JSON overhead from the encoder rather than duplicating its current
287+ // field layout here. The boundary payload uses ASCII so each added message
288+ // byte remains one marshaled byte.
289+ boundaryMessage := strings.Repeat("x", maxNoticeRecordSize-len(validLine("")))
290+
291+ for _, tt := range []struct {
292+ name string
293+ lines []string
294+ want []string
295+ }{
296+ {
297+ name: "recovers after oversized line",
298+ lines: []string{validLine("first"), validLine(strings.Repeat("x", len(boundaryMessage)+1)), validLine("second")},
299+ want: []string{"first", "second"},
300+ },
301+ {
302+ name: "skips oversized final unterminated line",
303+ lines: []string{validLine("first"), validLine(strings.Repeat("x", len(boundaryMessage)+1))},
304+ want: []string{"first"},
305+ },
306+ {
307+ name: "accepts record at size limit",
308+ lines: []string{validLine(boundaryMessage)},
309+ want: []string{boundaryMessage},
310+ },
311+ } {
312+ t.Run(tt.name, func(t *testing.T) {
313+ dir := filepath.Join(t.TempDir(), "vev")
314+ records := strings.Join(tt.lines, "\n")
315+ if tt.name != "skips oversized final unterminated line" {
316+ records += "\n"
317+ }
318+ require.NoError(t, os.MkdirAll(dir, 0o700))
319+ require.NoError(t, os.WriteFile(filepath.Join(dir, fileName), []byte(records), 0o600))
320+
321+ got, err := New(dir).Claim()
322+ require.NoError(t, err)
323+ require.Len(t, got, len(tt.want))
324+ for i, want := range tt.want {
325+ require.Equal(t, want, got[i].Message)
326+ }
327+ })
328+ }
329+}
330+
331+func TestStoreAppendUsesPrivatePermissions(t *testing.T) {
332+ t.Parallel()
333+ dir := filepath.Join(t.TempDir(), "vev")
334+ store := New(dir)
335+
336+ require.NoError(t, store.Append(domain.Notification{Message: "x"}))
337+ info, err := os.Stat(dir)
338+ require.NoError(t, err)
339+ require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
340+ fi, err := os.Stat(filepath.Join(dir, fileName))
341+ require.NoError(t, err)
342+ require.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
343+
344+ _, err = store.Claim()
345+ require.NoError(t, err)
346+ fi, err = os.Stat(filepath.Join(dir, inFlightFileName))
347+ require.NoError(t, err)
348+ require.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
349+}
M internal/app/run.go +8 -0
@@ -32,6 +32,7 @@ import (
3232 "github.com/bnema/vev/internal/adapters/config"
3333 "github.com/bnema/vev/internal/adapters/dgram"
3434 "github.com/bnema/vev/internal/adapters/ipc"
35+ "github.com/bnema/vev/internal/adapters/noticefile"
3536 "github.com/bnema/vev/internal/adapters/observability"
3637 "github.com/bnema/vev/internal/adapters/pty"
3738 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
@@ -450,14 +451,21 @@ func runDaemon() (retErr error) {
450451 daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
451452 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
452453 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
454+ daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))
453455 storePath := persist.StorePath(platform.StateDir())
456+ var storeErr error
454457 if store, err := kv.Open(storePath); err != nil {
455458 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
459+ storeErr = err
456460 } else {
457461 log.Info("session persistence enabled", "path", storePath)
458462 daemonOpts = append(daemonOpts, daemon.WithStore(store))
459463 }
460464 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
465+ if storeErr != nil {
466+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
467+ "session persistence is disabled; sessions will not survive daemon restarts", storeErr)
468+ }
461469 watchCtx, stopWatch := context.WithCancel(ctx)
462470 defer stopWatch()
463471 go func() {
A internal/domain/notice.go +117 -0
@@ -0,0 +1,117 @@
1+package domain
2+
3+import "time"
4+
5+// NoticeSeverity ranks a user-facing notification.
6+//
7+// Its numeric values persist in JSON notice records and are a protocol/domain
8+// contract. Add values only at the end; never reorder existing values.
9+type NoticeSeverity uint8
10+
11+const (
12+ NoticeInfo NoticeSeverity = iota
13+ NoticeWarn
14+ NoticeError
15+)
16+
17+// NoticeCode identifies a failure meaning, not a call site.
18+//
19+// Its numeric values persist in JSON notice records and are a protocol/domain
20+// contract. Add values only at the end; never reorder existing values.
21+type NoticeCode uint16
22+
23+const (
24+ NoticeInternal NoticeCode = iota
25+ NoticePaneSpawn
26+ NoticeTabSpawn
27+ NoticeFloatingSpawn
28+ NoticeSessionSpawn
29+ NoticeLayoutTooSmall
30+ NoticePaneNotFound
31+ NoticeSessionUnavailable
32+ NoticePersistDisabled
33+ NoticeSnapshotWrite
34+ NoticeSnapshotRestore
35+ NoticeSnapshotSaturated
36+ NoticePersistDelete
37+ NoticeConfigReload
38+ NoticeInputDropped
39+ NoticeResizeFailed
40+ NoticeClipboard
41+ NoticeClipboardTooLarge
42+ NoticeAutoResume
43+ NoticeConnection
44+
45+ // noticeCodeLimit is an append-only declaration sentinel, not a persisted
46+ // valid notice code. Keep it last when adding a NoticeCode.
47+ noticeCodeLimit
48+)
49+
50+var noticeSlugs = map[NoticeCode]string{
51+ NoticeInternal: "internal",
52+ NoticePaneSpawn: "pane-spawn",
53+ NoticeTabSpawn: "tab-spawn",
54+ NoticeFloatingSpawn: "floating-spawn",
55+ NoticeSessionSpawn: "session-spawn",
56+ NoticeLayoutTooSmall: "layout-too-small",
57+ NoticePaneNotFound: "pane-not-found",
58+ NoticeSessionUnavailable: "session-unavailable",
59+ NoticePersistDisabled: "persist-disabled",
60+ NoticeSnapshotWrite: "snapshot-write",
61+ NoticeSnapshotRestore: "snapshot-restore",
62+ NoticeSnapshotSaturated: "snapshot-saturated",
63+ NoticePersistDelete: "persist-delete",
64+ NoticeConfigReload: "config-reload",
65+ NoticeInputDropped: "input-dropped",
66+ NoticeResizeFailed: "resize-failed",
67+ NoticeClipboard: "clipboard",
68+ NoticeClipboardTooLarge: "clipboard-too-large",
69+ NoticeAutoResume: "auto-resume",
70+ NoticeConnection: "connection",
71+}
72+
73+func (c NoticeCode) String() string {
74+ if s, ok := noticeSlugs[c]; ok {
75+ return s
76+ }
77+ return "unknown"
78+}
79+
80+// UserError is an error whose message is written for the attached user.
81+// The wrapped cause is shown only in yank details, never in the toast.
82+type UserError struct {
83+ Code NoticeCode
84+ Severity NoticeSeverity
85+ Msg string
86+ Err error
87+}
88+
89+func (e *UserError) Error() string {
90+ if e.Err == nil {
91+ return e.Msg
92+ }
93+ return e.Msg + ": " + e.Err.Error()
94+}
95+
96+func (e *UserError) Unwrap() error { return e.Err }
97+
98+// UserErr wraps err as an error-severity user-facing failure.
99+func UserErr(code NoticeCode, msg string, err error) *UserError {
100+ return &UserError{Code: code, Severity: NoticeError, Msg: msg, Err: err}
101+}
102+
103+// UserWarn wraps err as a warn-severity user-facing failure.
104+func UserWarn(code NoticeCode, msg string, err error) *UserError {
105+ return &UserError{Code: code, Severity: NoticeWarn, Msg: msg, Err: err}
106+}
107+
108+// Notification is one entry in the daemon's user-facing notice history.
109+type Notification struct {
110+ Code NoticeCode
111+ Severity NoticeSeverity
112+ Message string
113+ Details string // formatted error chain for yank
114+ Time time.Time
115+ Count int // >1 when coalesced
116+ SessionID SessionID // "" = daemon-global
117+}
A internal/domain/notice_test.go +50 -0
@@ -0,0 +1,50 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "testing"
7+)
8+
9+func TestNoticeCodeString(t *testing.T) {
10+ for code := range noticeCodeLimit {
11+ if got := code.String(); got == "unknown" {
12+ t.Errorf("NoticeCode(%d).String() = unknown, want declared code slug", code)
13+ }
14+ }
15+ if got := NoticeCode(9999).String(); got != "unknown" {
16+ t.Errorf("NoticeCode(9999).String() = %q, want unknown", got)
17+ }
18+}
19+
20+func TestUserError(t *testing.T) {
21+ cause := errors.New("boom")
22+ tests := []struct {
23+ name string
24+ err *UserError
25+ wantMsg string
26+ wantSev NoticeSeverity
27+ wantWrap error
28+ }{
29+ {"err with cause", UserErr(NoticePaneSpawn, "couldn't open pane", cause), "couldn't open pane: boom", NoticeError, cause},
30+ {"err without cause", UserErr(NoticeSessionUnavailable, "session is unavailable", nil), "session is unavailable", NoticeError, nil},
31+ {"warn", UserWarn(NoticeResizeFailed, "pane resize failed", cause), "pane resize failed: boom", NoticeWarn, cause},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.name, func(t *testing.T) {
35+ if got := tt.err.Error(); got != tt.wantMsg {
36+ t.Errorf("Error() = %q, want %q", got, tt.wantMsg)
37+ }
38+ if tt.err.Severity != tt.wantSev {
39+ t.Errorf("Severity = %d, want %d", tt.err.Severity, tt.wantSev)
40+ }
41+ if !errors.Is(tt.err, tt.wantWrap) && tt.wantWrap != nil {
42+ t.Errorf("errors.Is(err, cause) = false, want true")
43+ }
44+ var ue *UserError
45+ if !errors.As(fmt.Errorf("wrapped: %w", tt.err), &ue) {
46+ t.Errorf("errors.As through wrapping = false, want true")
47+ }
48+ })
49+ }
50+}
M internal/ports/frame.go +12 -11
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 16
4+const ProtocolVersion uint16 = 17
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -10,16 +10,17 @@ type MsgType uint8
1010 // through 15, server-originated messages from 16, leaving room for growth in
1111 // each band.
1212 const (
13- MsgHello MsgType = 1
14- MsgInput MsgType = 2
15- MsgResize MsgType = 3
16- MsgDetach MsgType = 4
17- MsgPing MsgType = 5
18- MsgList MsgType = 6
19- MsgKill MsgType = 7
20- MsgTheme MsgType = 8
21- MsgAck MsgType = 9
22- MsgImagePush MsgType = 10
13+ MsgHello MsgType = 1
14+ MsgInput MsgType = 2
15+ MsgResize MsgType = 3
16+ MsgDetach MsgType = 4
17+ MsgPing MsgType = 5
18+ MsgList MsgType = 6
19+ MsgKill MsgType = 7
20+ MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
22+ MsgImagePush MsgType = 10
23+ MsgClientNotice MsgType = 11
2324
2425 MsgWelcome MsgType = 16
2526 MsgError MsgType = 17
M internal/ports/frame_test.go +3 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 16 {
7- t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion)
6+ if ProtocolVersion != 17 {
7+ t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion)
88 }
99 }
1010
@@ -23,6 +23,7 @@ func TestMsgTypeUnique(t *testing.T) {
2323 {"MsgTheme", MsgTheme},
2424 {"MsgAck", MsgAck},
2525 {"MsgImagePush", MsgImagePush},
26+ {"MsgClientNotice", MsgClientNotice},
2627 {"MsgWelcome", MsgWelcome},
2728 {"MsgError", MsgError},
2829 {"MsgOutput", MsgOutput},
A internal/ports/mocks/notice_mock.go +187 -0
@@ -0,0 +1,187 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/vev/internal/domain"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockNoticeStore creates a new instance of MockNoticeStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockNoticeStore(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockNoticeStore {
18+ mock := &MockNoticeStore{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockNoticeStore is an autogenerated mock type for the NoticeStore type
27+type MockNoticeStore struct {
28+ mock.Mock
29+}
30+
31+type MockNoticeStore_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockNoticeStore) EXPECT() *MockNoticeStore_Expecter {
36+ return &MockNoticeStore_Expecter{mock: &_m.Mock}
37+}
38+
39+// Ack provides a mock function for the type MockNoticeStore
40+func (_mock *MockNoticeStore) Ack() error {
41+ ret := _mock.Called()
42+
43+ if len(ret) == 0 {
44+ panic("no return value specified for Ack")
45+ }
46+
47+ var r0 error
48+ if returnFunc, ok := ret.Get(0).(func() error); ok {
49+ r0 = returnFunc()
50+ } else {
51+ r0 = ret.Error(0)
52+ }
53+ return r0
54+}
55+
56+// MockNoticeStore_Ack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ack'
57+type MockNoticeStore_Ack_Call struct {
58+ *mock.Call
59+}
60+
61+// Ack is a helper method to define mock.On call
62+func (_e *MockNoticeStore_Expecter) Ack() *MockNoticeStore_Ack_Call {
63+ return &MockNoticeStore_Ack_Call{Call: _e.mock.On("Ack")}
64+}
65+
66+func (_c *MockNoticeStore_Ack_Call) Run(run func()) *MockNoticeStore_Ack_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ run()
69+ })
70+ return _c
71+}
72+
73+func (_c *MockNoticeStore_Ack_Call) Return(err error) *MockNoticeStore_Ack_Call {
74+ _c.Call.Return(err)
75+ return _c
76+}
77+
78+func (_c *MockNoticeStore_Ack_Call) RunAndReturn(run func() error) *MockNoticeStore_Ack_Call {
79+ _c.Call.Return(run)
80+ return _c
81+}
82+
83+// Append provides a mock function for the type MockNoticeStore
84+func (_mock *MockNoticeStore) Append(n domain.Notification) error {
85+ ret := _mock.Called(n)
86+
87+ if len(ret) == 0 {
88+ panic("no return value specified for Append")
89+ }
90+
91+ var r0 error
92+ if returnFunc, ok := ret.Get(0).(func(domain.Notification) error); ok {
93+ r0 = returnFunc(n)
94+ } else {
95+ r0 = ret.Error(0)
96+ }
97+ return r0
98+}
99+
100+// MockNoticeStore_Append_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Append'
101+type MockNoticeStore_Append_Call struct {
102+ *mock.Call
103+}
104+
105+// Append is a helper method to define mock.On call
106+// - n domain.Notification
107+func (_e *MockNoticeStore_Expecter) Append(n any) *MockNoticeStore_Append_Call {
108+ return &MockNoticeStore_Append_Call{Call: _e.mock.On("Append", n)}
109+}
110+
111+func (_c *MockNoticeStore_Append_Call) Run(run func(n domain.Notification)) *MockNoticeStore_Append_Call {
112+ _c.Call.Run(func(args mock.Arguments) {
113+ var arg0 domain.Notification
114+ if args[0] != nil {
115+ arg0 = args[0].(domain.Notification)
116+ }
117+ run(
118+ arg0,
119+ )
120+ })
121+ return _c
122+}
123+
124+func (_c *MockNoticeStore_Append_Call) Return(err error) *MockNoticeStore_Append_Call {
125+ _c.Call.Return(err)
126+ return _c
127+}
128+
129+func (_c *MockNoticeStore_Append_Call) RunAndReturn(run func(n domain.Notification) error) *MockNoticeStore_Append_Call {
130+ _c.Call.Return(run)
131+ return _c
132+}
133+
134+// Claim provides a mock function for the type MockNoticeStore
135+func (_mock *MockNoticeStore) Claim() ([]domain.Notification, error) {
136+ ret := _mock.Called()
137+
138+ if len(ret) == 0 {
139+ panic("no return value specified for Claim")
140+ }
141+
142+ var r0 []domain.Notification
143+ var r1 error
144+ if returnFunc, ok := ret.Get(0).(func() ([]domain.Notification, error)); ok {
145+ return returnFunc()
146+ }
147+ if returnFunc, ok := ret.Get(0).(func() []domain.Notification); ok {
148+ r0 = returnFunc()
149+ } else {
150+ if ret.Get(0) != nil {
151+ r0 = ret.Get(0).([]domain.Notification)
152+ }
153+ }
154+ if returnFunc, ok := ret.Get(1).(func() error); ok {
155+ r1 = returnFunc()
156+ } else {
157+ r1 = ret.Error(1)
158+ }
159+ return r0, r1
160+}
161+
162+// MockNoticeStore_Claim_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Claim'
163+type MockNoticeStore_Claim_Call struct {
164+ *mock.Call
165+}
166+
167+// Claim is a helper method to define mock.On call
168+func (_e *MockNoticeStore_Expecter) Claim() *MockNoticeStore_Claim_Call {
169+ return &MockNoticeStore_Claim_Call{Call: _e.mock.On("Claim")}
170+}
171+
172+func (_c *MockNoticeStore_Claim_Call) Run(run func()) *MockNoticeStore_Claim_Call {
173+ _c.Call.Run(func(args mock.Arguments) {
174+ run()
175+ })
176+ return _c
177+}
178+
179+func (_c *MockNoticeStore_Claim_Call) Return(notifications []domain.Notification, err error) *MockNoticeStore_Claim_Call {
180+ _c.Call.Return(notifications, err)
181+ return _c
182+}
183+
184+func (_c *MockNoticeStore_Claim_Call) RunAndReturn(run func() ([]domain.Notification, error)) *MockNoticeStore_Claim_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
A internal/ports/notice.go +15 -0
@@ -0,0 +1,15 @@
1+package ports
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// NoticeStore persists undeliverable notices across daemon restarts.
6+type NoticeStore interface {
7+ Append(n domain.Notification) error
8+ // Claim atomically reserves pending notices for import. Repeating Claim on
9+ // the owning store replays its live claim; another store cannot replay or
10+ // acknowledge it. The claim remains recoverable after the claimant process
11+ // crashes, until its owning store calls Ack.
12+ Claim() ([]domain.Notification, error)
13+ // Ack permanently removes the notices returned by this store's claim.
14+ Ack() error
15+}
M internal/ports/wire.go +46 -0
@@ -53,6 +53,7 @@ const (
5353 ReasonDetach uint8 = 0
5454 ReasonSessionKilled uint8 = 1
5555 ReasonServerShutdown uint8 = 2
56+ ReasonReplaced uint8 = 3
5657 )
5758
5859 // Hello is sent by the client immediately after connecting.
@@ -108,6 +109,29 @@ type ImagePush struct {
108109 Data []byte
109110 }
110111
112+// ClientNotice reports a fixed client-side event for daemon-rendered user
113+// feedback. Action values are deliberately closed so a client cannot inject
114+// arbitrary display text into a shared session.
115+type ClientNotice struct {
116+ Action uint8
117+}
118+
119+const (
120+ ClientNoticeClipboardFallback uint8 = 1
121+ ClientNoticeClipboardTooLarge uint8 = 2
122+ ClientNoticeLinkDegraded uint8 = 3
123+ ClientNoticeLinkConnected uint8 = 4
124+)
125+
126+func validClientNoticeAction(action uint8) bool {
127+ switch action {
128+ case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
129+ return true
130+ default:
131+ return false
132+ }
133+}
134+
111135 // Detach asks the daemon to detach the current client without killing the
112136 // session.
113137 type Detach struct{}
@@ -458,6 +482,28 @@ func UnmarshalImagePush(b []byte) (ImagePush, error) {
458482 return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
459483 }
460484
485+// MarshalClientNotice encodes a fixed one-byte client notice action.
486+func MarshalClientNotice(m ClientNotice) []byte {
487+ return []byte{m.Action}
488+}
489+
490+// UnmarshalClientNotice decodes a fixed client notice action and rejects both
491+// unknown values and any trailing bytes.
492+func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
493+ r := payloadReader{b: b}
494+ action, err := r.getUint8()
495+ if err != nil {
496+ return ClientNotice{}, err
497+ }
498+ if !validClientNoticeAction(action) {
499+ return ClientNotice{}, errors.New("ports: unknown client notice action")
500+ }
501+ if err := r.done(); err != nil {
502+ return ClientNotice{}, err
503+ }
504+ return ClientNotice{Action: action}, nil
505+}
506+
461507 // MarshalResize encodes m into a Resize message payload.
462508 func MarshalResize(m Resize) []byte {
463509 w := payloadWriter{}
M internal/ports/wire_test.go +33 -6
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121121 Env: []string{"A=B", "XY=123"},
122122 })
123123 want := []byte{
124- 0x00, 0x10, 0x00, // version, intent
124+ 0x00, 0x11, 0x00, // version, intent
125125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
335335 }
336336 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
337337 require.Equal(t, want, MarshalTheme(cleared))
338- require.Equal(t, uint16(16), ProtocolVersion)
338+ require.Equal(t, uint16(17), ProtocolVersion)
339339 }
340340
341341 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -502,8 +502,8 @@ func TestAckGoldenAndRoundTrip(t *testing.T) {
502502 }
503503
504504 func TestDetachedGoldenAndRoundTrip(t *testing.T) {
505- msg := Detached{Reason: ReasonSessionKilled}
506- want := []byte{0x01}
505+ msg := Detached{Reason: ReasonReplaced}
506+ want := []byte{0x03}
507507
508508 got := MarshalDetached(msg)
509509 if !bytes.Equal(got, want) {
@@ -521,6 +521,32 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) {
521521 assertTrailingGarbageFails(t, got, UnmarshalDetached)
522522 }
523523
524+func TestClientNoticeGoldenAndStrict(t *testing.T) {
525+ tests := []struct {
526+ name string
527+ msg ClientNotice
528+ want []byte
529+ }{
530+ {name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}},
531+ {name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}},
532+ {name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}},
533+ {name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}},
534+ }
535+ for _, tt := range tests {
536+ t.Run(tt.name, func(t *testing.T) {
537+ got := MarshalClientNotice(tt.msg)
538+ require.Equal(t, tt.want, got)
539+ back, err := UnmarshalClientNotice(got)
540+ require.NoError(t, err)
541+ require.Equal(t, tt.msg, back)
542+ assertAllPrefixesFail(t, got, UnmarshalClientNotice)
543+ assertTrailingGarbageFails(t, got, UnmarshalClientNotice)
544+ })
545+ }
546+ _, err := UnmarshalClientNotice([]byte{0xff})
547+ require.Error(t, err, "unknown actions must not become display text")
548+}
549+
524550 func TestListGoldenAndRoundTrip(t *testing.T) {
525551 got := MarshalList(List{})
526552 if len(got) != 0 {
@@ -661,9 +687,10 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
661687 ReasonDetach: "ReasonDetach",
662688 ReasonSessionKilled: "ReasonSessionKilled",
663689 ReasonServerShutdown: "ReasonServerShutdown",
690+ ReasonReplaced: "ReasonReplaced",
664691 }
665- if len(vals) != 3 {
666- t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals))
692+ if len(vals) != 4 {
693+ t.Fatalf("expected 4 distinct detached reasons, got %d", len(vals))
667694 }
668695 })
669696 }
M internal/usecase/client/client.go +15 -1
@@ -798,11 +798,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
798798 }
799799 if ev.State == ports.LinkStateConnected {
800800 reconnect.clear()
801+ select {
802+ case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})}:
803+ case <-loopCtx.Done():
804+ return welcomedResult(nil)
805+ }
801806 continue
802807 }
803808 if ev.State == ports.LinkStateDegraded {
804809 log.Warn("UDP link degraded")
805- reconnect.clear()
810+ select {
811+ case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})}:
812+ case <-loopCtx.Done():
813+ return welcomedResult(nil)
814+ }
806815 continue
807816 }
808817 reconnect.drawStage(stageForLinkState(ev.State))
@@ -1366,6 +1375,9 @@ func (p *stdinPump) run() {
13661375 coalescer: coalescer,
13671376 reader: p.clipboard,
13681377 log: p.logger,
1378+ sendNotice: func(action uint8) {
1379+ send(ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: action})})
1380+ },
13691381 sendImage: func(mime string, data []byte) {
13701382 inputSeq++
13711383 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
@@ -1562,6 +1574,8 @@ func detachedResult(reason uint8) error {
15621574 return &DetachedError{Reason: reason, Text: "session was killed"}
15631575 case ports.ReasonServerShutdown:
15641576 return &DetachedError{Reason: reason, Text: "daemon shut down"}
1577+ case ports.ReasonReplaced:
1578+ return &DetachedError{Reason: reason, Text: "session taken over by another client"}
15651579 default:
15661580 return &DetachedError{Reason: reason, Text: "detached by daemon"}
15671581 }
M internal/usecase/client/clipboard_intercept.go +12 -5
@@ -25,11 +25,12 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB
2525 // text may legitimately contain it) — is forwarded to next unchanged, so it
2626 // still gets the coalescer's marker-splitting treatment.
2727 type clipboardIntercept struct {
28- coalescer *pasteCoalescer
29- reader ports.ClipboardReader
30- log *slog.Logger
31- sendImage func(mime string, data []byte)
32- next func([]byte)
28+ coalescer *pasteCoalescer
29+ reader ports.ClipboardReader
30+ log *slog.Logger
31+ sendImage func(mime string, data []byte)
32+ sendNotice func(action uint8)
33+ next func([]byte)
3334 }
3435
3536 // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
@@ -99,12 +100,18 @@ func (c *clipboardIntercept) handleCtrlV() {
99100 if err != nil {
100101 if !errors.Is(err, ports.ErrNoClipboardImage) {
101102 c.log.Warn("clipboard image read failed", "err", err)
103+ if c.sendNotice != nil {
104+ c.sendNotice(ports.ClientNoticeClipboardFallback)
105+ }
102106 }
103107 c.next([]byte{ctrlV})
104108 return
105109 }
106110 if len(data) > maxClipboardImagePush {
107111 c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
112+ if c.sendNotice != nil {
113+ c.sendNotice(ports.ClientNoticeClipboardTooLarge)
114+ }
108115 c.next([]byte{ctrlV})
109116 return
110117 }
M internal/usecase/client/clipboard_intercept_internal_test.go +25 -0
@@ -79,6 +79,31 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
7979 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
8080 }
8181
82+func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) {
83+ tests := []struct {
84+ name string
85+ reader ports.ClipboardReader
86+ want uint8
87+ }{
88+ {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback},
89+ {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge},
90+ }
91+ for _, tt := range tests {
92+ t.Run(tt.name, func(t *testing.T) {
93+ ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)
94+ defer pc.Close()
95+ var actions []uint8
96+ ci.sendNotice = func(action uint8) { actions = append(actions, action) }
97+
98+ ci.Scan([]byte{ctrlV})
99+
100+ require.Equal(t, []uint8{tt.want}, actions)
101+ require.Empty(t, *images)
102+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
103+ })
104+ }
105+}
106+
82107 func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
83108 huge := make([]byte, maxClipboardImagePush+1)
84109 reader := &ciFakeReader{mime: "image/png", data: huge}
M internal/usecase/client/clipboard_intercept_test.go +74 -1
@@ -3,6 +3,7 @@ package client_test
33 import (
44 "bytes"
55 "context"
6+ "errors"
67 "io"
78 "testing"
89 "time"
@@ -30,7 +31,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
3031 defer input.unblock()
3132
3233 tm := portsmocks.NewMockTerminal(t)
33- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
34+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
3435 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
3536 tm.EXPECT().In().Return(input).Maybe()
3637 tm.EXPECT().Out().Return(&out).Maybe()
@@ -44,6 +45,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
4445 tr := portsmocks.NewMockTransport(t)
4546 tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
4647 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
48+ tr.EXPECT().Send(isType(ports.MsgResize)).Return(nil).Maybe()
4749 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
4850 in, err := ports.UnmarshalInput(f.Payload)
4951 require.NoError(t, err)
@@ -59,6 +61,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
5961 gotImage <- ip
6062 return nil
6163 }).Maybe()
64+ tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe()
6265
6366 welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
6467 detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
@@ -137,6 +140,76 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
137140 require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
138141 }
139142
143+type clipboardToastLifecycleTransport struct {
144+ recv chan ports.Frame
145+ sends chan ports.Frame
146+}
147+
148+func (t *clipboardToastLifecycleTransport) Send(f ports.Frame) error {
149+ t.sends <- f
150+ return nil
151+}
152+
153+func (t *clipboardToastLifecycleTransport) Recv() (ports.Frame, error) { return <-t.recv, nil }
154+func (*clipboardToastLifecycleTransport) Close() error { return nil }
155+
156+type clipboardToastLifecycleDialer struct{ transport ports.Transport }
157+
158+func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, error) {
159+ return d.transport, nil
160+}
161+
162+func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {
163+ var out bytes.Buffer
164+ resizeEvents := make(chan domain.Size)
165+ input := newOneShotBlockingReader([]byte{0x16})
166+ defer input.unblock()
167+ term := portsmocks.NewMockTerminal(t)
168+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
169+ term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()
170+ term.EXPECT().In().Return(input).Maybe()
171+ term.EXPECT().Out().Return(&out).Maybe()
172+ term.EXPECT().Flush().Return(nil).Maybe()
173+ term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe()
174+
175+ clipboard := portsmocks.NewMockClipboardReader(t)
176+ clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()
177+ transport := &clipboardToastLifecycleTransport{recv: make(chan ports.Frame, 8), sends: make(chan ports.Frame, 16)}
178+ transport.recv <- frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
179+
180+ result := make(chan error, 1)
181+ go func() {
182+ result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true})
183+ }()
184+
185+ var gotNotice bool
186+ for !gotNotice {
187+ select {
188+ case sent := <-transport.sends:
189+ if sent.Type != ports.MsgClientNotice {
190+ continue
191+ }
192+ notice, err := ports.UnmarshalClientNotice(sent.Payload)
193+ require.NoError(t, err)
194+ require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action)
195+ gotNotice = true
196+ case <-time.After(time.Second):
197+ t.Fatal("client did not notify daemon of clipboard failure")
198+ }
199+ }
200+ beforeOutput := out.String()
201+ transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")}))
202+ transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
203+
204+ select {
205+ case err := <-result:
206+ require.NoError(t, err)
207+ case <-time.After(time.Second):
208+ t.Fatal("client did not finish")
209+ }
210+ require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes")
211+}
212+
140213 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
141214 huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
142215 clip := portsmocks.NewMockClipboardReader(t)
M internal/usecase/client/reconnect_toast.go +7 -1
@@ -33,7 +33,13 @@ func drawReconnectToast(out io.Writer, size domain.Size) error {
3333 }
3434
3535 func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
36- message := reconnectStageMessage(stage)
36+ return drawClientToast(out, size, reconnectStageMessage(stage))
37+}
38+
39+// drawClientToast draws a client-local toast without changing terminal state.
40+// The attach main loop owns both this write and the later daemon-frame
41+// reconciliation; input pumps must only publish a request for it.
42+func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Rect, error) {
3743 bounds := reconnectToastBoundsFor(size, message)
3844 if bounds.Width <= 0 || bounds.Height <= 0 {
3945 return domain.Rect{}, nil
M internal/usecase/client/reconnect_toast_test.go +69 -19
@@ -141,22 +141,30 @@ func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnect
141141 }
142142
143143 type reconnectToastLinkTransport struct {
144- recvCh chan reconnectToastRecv
145- sends []ports.Frame
146- events chan ports.LinkEvent
147- state ports.LinkState
144+ recvCh chan reconnectToastRecv
145+ clientNotices chan ports.ClientNotice
146+ events chan ports.LinkEvent
147+ state ports.LinkState
148148 }
149149
150150 func newReconnectToastLinkTransport() *reconnectToastLinkTransport {
151151 return &reconnectToastLinkTransport{
152- recvCh: make(chan reconnectToastRecv, 4),
153- events: make(chan ports.LinkEvent, 4),
154- state: ports.LinkStateConnected,
152+ recvCh: make(chan reconnectToastRecv, 4),
153+ clientNotices: make(chan ports.ClientNotice, 2),
154+ events: make(chan ports.LinkEvent, 4),
155+ state: ports.LinkStateConnected,
155156 }
156157 }
157158
158159 func (t *reconnectToastLinkTransport) Send(f ports.Frame) error {
159- t.sends = append(t.sends, f)
160+ if f.Type != ports.MsgClientNotice {
161+ return nil
162+ }
163+ notice, err := ports.UnmarshalClientNotice(f.Payload)
164+ if err != nil {
165+ return err
166+ }
167+ t.clientNotices <- notice
160168 return nil
161169 }
162170
@@ -282,7 +290,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
282290 }
283291 }
284292
285-func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
293+func TestReconnectLinkEventsNotifyDaemonWithoutLocalTerminalWrites(t *testing.T) {
286294 out := newReconnectToastOutputRecorder()
287295 term := newReconnectToastTerminalHarnessWithOutput(t, out)
288296 defer term.closeInput()
@@ -294,17 +302,15 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
294302 attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)
295303 go func() { resultCh <- attempt.run(context.Background()) }()
296304
297- tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
298- probed := <-out.completed
299- require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
300-
301305 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
302- cleared := <-out.completed
303- assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
304-
305- tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
306- probed = <-out.completed
307- require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
306+ tr.events <- ports.LinkEvent{State: ports.LinkStateConnected}
307+ require.Equal(t, ports.ClientNoticeLinkDegraded, (<-tr.clientNotices).Action)
308+ require.Equal(t, ports.ClientNoticeLinkConnected, (<-tr.clientNotices).Action)
309+ select {
310+ case bytes := <-out.completed:
311+ t.Fatalf("link notices wrote client-local terminal bytes: %q", bytes)
312+ default:
313+ }
308314
309315 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
310316 result := <-resultCh
@@ -330,6 +336,28 @@ func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) {
330336 require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
331337 }
332338
339+func TestDetachedResultReplacementAndCleanDetach(t *testing.T) {
340+ for _, tt := range []struct {
341+ name string
342+ reason uint8
343+ wantText string
344+ }{
345+ {name: "clean detach", reason: ports.ReasonDetach},
346+ {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"},
347+ } {
348+ t.Run(tt.name, func(t *testing.T) {
349+ err := detachedResult(tt.reason)
350+ if tt.wantText == "" {
351+ require.NoError(t, err)
352+ return
353+ }
354+ var detached *DetachedError
355+ require.ErrorAs(t, err, &detached)
356+ require.Equal(t, tt.wantText, detached.Text)
357+ })
358+ }
359+}
360+
333361 func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) {
334362 bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24})
335363 require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds)
@@ -359,6 +387,28 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) {
359387 require.Contains(t, strings.Join(lines, "\n"), "…")
360388 }
361389
390+func TestReconnectToastStageBytesUnchangedAfterClientHelperExtraction(t *testing.T) {
391+ for _, stage := range []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying} {
392+ t.Run(reconnectStageMessage(stage), func(t *testing.T) {
393+ var got, want bytes.Buffer
394+ gotBounds, gotErr := drawReconnectToastStage(&got, domain.Size{Cols: 80, Rows: 24}, stage)
395+ wantBounds, wantErr := legacyReconnectToastStage(&want, domain.Size{Cols: 80, Rows: 24}, stage)
396+ require.Equal(t, wantErr, gotErr)
397+ require.Equal(t, wantBounds, gotBounds)
398+ require.Equal(t, want.String(), got.String())
399+ })
400+ }
401+}
402+
403+func legacyReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
404+ message := reconnectStageMessage(stage)
405+ bounds := reconnectToastBoundsFor(size, message)
406+ if bounds.Width <= 0 || bounds.Height <= 0 {
407+ return domain.Rect{}, nil
408+ }
409+ return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
410+}
411+
362412 func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {
363413 size := domain.Size{Cols: 80, Rows: 24}
364414 tests := []struct {
M internal/usecase/command/command.go +2 -0
@@ -51,6 +51,8 @@ type Context interface {
5151 RenameSession() error
5252 RenameTab() error
5353 OpenSessionPicker() error
54+ OpenNotifications() error
55+ YankLastNotification() error
5456 JumpRecentSession(rank int) error
5557 }
5658
M internal/usecase/command/context_mock_test.go +88 -0
@@ -614,6 +614,50 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_
614614 return _c
615615 }
616616
617+// OpenNotifications provides a mock function for the type MockContext
618+func (_mock *MockContext) OpenNotifications() error {
619+ ret := _mock.Called()
620+
621+ if len(ret) == 0 {
622+ panic("no return value specified for OpenNotifications")
623+ }
624+
625+ var r0 error
626+ if returnFunc, ok := ret.Get(0).(func() error); ok {
627+ r0 = returnFunc()
628+ } else {
629+ r0 = ret.Error(0)
630+ }
631+ return r0
632+}
633+
634+// MockContext_OpenNotifications_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenNotifications'
635+type MockContext_OpenNotifications_Call struct {
636+ *mock.Call
637+}
638+
639+// OpenNotifications is a helper method to define mock.On call
640+func (_e *MockContext_Expecter) OpenNotifications() *MockContext_OpenNotifications_Call {
641+ return &MockContext_OpenNotifications_Call{Call: _e.mock.On("OpenNotifications")}
642+}
643+
644+func (_c *MockContext_OpenNotifications_Call) Run(run func()) *MockContext_OpenNotifications_Call {
645+ _c.Call.Run(func(args mock.Arguments) {
646+ run()
647+ })
648+ return _c
649+}
650+
651+func (_c *MockContext_OpenNotifications_Call) Return(err error) *MockContext_OpenNotifications_Call {
652+ _c.Call.Return(err)
653+ return _c
654+}
655+
656+func (_c *MockContext_OpenNotifications_Call) RunAndReturn(run func() error) *MockContext_OpenNotifications_Call {
657+ _c.Call.Return(run)
658+ return _c
659+}
660+
617661 // OpenSessionPicker provides a mock function for the type MockContext
618662 func (_mock *MockContext) OpenSessionPicker() error {
619663 ret := _mock.Called()
@@ -1097,3 +1141,47 @@ func (_c *MockContext_ToggleStack_Call) RunAndReturn(run func() error) *MockCont
10971141 _c.Call.Return(run)
10981142 return _c
10991143 }
1144+
1145+// YankLastNotification provides a mock function for the type MockContext
1146+func (_mock *MockContext) YankLastNotification() error {
1147+ ret := _mock.Called()
1148+
1149+ if len(ret) == 0 {
1150+ panic("no return value specified for YankLastNotification")
1151+ }
1152+
1153+ var r0 error
1154+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1155+ r0 = returnFunc()
1156+ } else {
1157+ r0 = ret.Error(0)
1158+ }
1159+ return r0
1160+}
1161+
1162+// MockContext_YankLastNotification_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'YankLastNotification'
1163+type MockContext_YankLastNotification_Call struct {
1164+ *mock.Call
1165+}
1166+
1167+// YankLastNotification is a helper method to define mock.On call
1168+func (_e *MockContext_Expecter) YankLastNotification() *MockContext_YankLastNotification_Call {
1169+ return &MockContext_YankLastNotification_Call{Call: _e.mock.On("YankLastNotification")}
1170+}
1171+
1172+func (_c *MockContext_YankLastNotification_Call) Run(run func()) *MockContext_YankLastNotification_Call {
1173+ _c.Call.Run(func(args mock.Arguments) {
1174+ run()
1175+ })
1176+ return _c
1177+}
1178+
1179+func (_c *MockContext_YankLastNotification_Call) Return(err error) *MockContext_YankLastNotification_Call {
1180+ _c.Call.Return(err)
1181+ return _c
1182+}
1183+
1184+func (_c *MockContext_YankLastNotification_Call) RunAndReturn(run func() error) *MockContext_YankLastNotification_Call {
1185+ _c.Call.Return(run)
1186+ return _c
1187+}
M internal/usecase/command/registry.go +2 -0
@@ -31,6 +31,8 @@ func Registry() []Command {
3131 return ctx.JumpRecentSession(rank)
3232 }},
3333 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
34+ {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }},
35+ {Slug: "yank-last-notification", Code: "YLN", Name: "Yank last notification", Desc: "Copy the most recent notification's details to the clipboard", Run: func(ctx Context, _ []string) error { return ctx.YankLastNotification() }},
3436 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
3537 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }},
3638 {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
M internal/usecase/command/registry_test.go +24 -1
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "VIS", "RNS", "RNT", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -47,6 +47,27 @@ func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
4747 }
4848 }
4949
50+func TestRegistryIncludesNotificationCommands(t *testing.T) {
51+ for _, tt := range []struct {
52+ code string
53+ slug string
54+ name string
55+ }{
56+ {code: "NTC", slug: "notifications", name: "Notifications"},
57+ {code: "YLN", slug: "yank-last-notification", name: "Yank last notification"},
58+ } {
59+ t.Run(tt.code, func(t *testing.T) {
60+ cmd, ok := ByCode(tt.code)
61+ if !ok {
62+ t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
63+ }
64+ if cmd.Slug != tt.slug || cmd.Code != tt.code || cmd.Name != tt.name {
65+ t.Fatalf("command = %#v, want %s/%s/%s", cmd, tt.slug, tt.code, tt.name)
66+ }
67+ })
68+ }
69+}
70+
5071 func TestByCodeIsCaseInsensitive(t *testing.T) {
5172 for _, code := range []string{"CNT", "cnt", "CnT"} {
5273 cmd, ok := ByCode(code)
@@ -88,6 +109,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
88109 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
89110 {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
90111 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
112+ {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }},
113+ {code: "YLN", expect: func(ctx *MockContext) { ctx.EXPECT().YankLastNotification().Return(nil).Once() }},
91114 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
92115 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
93116 {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
M internal/usecase/daemon/attention.go +9 -5
@@ -42,23 +42,27 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
4242 d.pokeAttentionTicker()
4343 }
4444
45-func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
45+// jumpAttention switches to the oldest pending attention target, local tab
46+// first and then another session's. Returning nil never implies a switch
47+// happened — it also covers "no target exists", which is routine and not an
48+// error. Only a failure to reach a target that does exist is a genuine error.
49+func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
4650 if sess == nil || ac == nil {
47- return
51+ return nil
4852 }
4953 if idx, ok := oldestAttentionTab(sess); ok {
5054 if sess.switchTab(idx) {
5155 d.activateTab(sess, sess.activeTab())
5256 d.invalidateRender(sess, ac, true, "attention.go")
5357 }
54- return
58+ return nil
5559 }
5660
5761 target, ok := d.oldestOtherSessionAttention(sess)
5862 if !ok {
59- return
63+ return nil
6064 }
61- d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
65+ return d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
6266 }
6367
6468 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/attention_test.go +37 -2
@@ -511,7 +511,7 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
511511 }
512512 d.sessions[sess2.id] = sess2
513513
514- d.jumpAttention(sess1, ac)
514+ require.NoError(t, d.jumpAttention(sess1, ac))
515515
516516 require.Same(t, sess2, ac.currentSession())
517517 require.Same(t, ac, sess2.client)
@@ -526,7 +526,7 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
526526 defer releases[1]()
527527 sess.active = 0
528528
529- d.jumpAttention(sess, ac)
529+ require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure")
530530
531531 require.Same(t, sess, ac.currentSession())
532532 require.Equal(t, 0, activeTabIndex(sess))
@@ -537,6 +537,41 @@ func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
537537 }
538538 }
539539
540+// TestJumpAttentionSwitchFailureReportsNotice drives jumpAttention's
541+// cross-session path once a target has already been chosen, then makes the
542+// commit itself fail (the origin session's client no longer matches ac —
543+// simulating a client that detached between target selection and commit).
544+// Unlike the no-target case, a target existed and the switch to it failed:
545+// that is a genuine error, not a benign no-op, and must reach the user.
546+func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
547+ p1, release1 := newBlockingPTY(t)
548+ p2, release2 := newBlockingPTY(t)
549+ defer release1()
550+ defer release2()
551+ d, sess1, ac, _ := newManualSessionWithPTYs(t, p1)
552+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
553+ defer cancel2()
554+ tab2 := newTestTabWithContext(p2, sctx2, cancel2)
555+ tab2.attention = true
556+ tab2.attentionAt = time.Unix(9, 0)
557+ sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}}
558+ d.sessions[sess2.id] = sess2
559+
560+ sess1.mu.Lock()
561+ sess1.client = nil
562+ sess1.mu.Unlock()
563+
564+ err := d.jumpAttention(sess1, ac)
565+
566+ require.Error(t, err)
567+ require.Same(t, sess1, ac.currentSession(), "a failed switch must leave the client on its origin session")
568+
569+ d.reportError(sess1, err)
570+ history := d.notices.history()
571+ require.Len(t, history, 1)
572+ require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
573+}
574+
540575 func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
541576 d, sess, _, sends, releases := newManualTabSession(t, 2)
542577 defer releases[0]()
M internal/usecase/daemon/client.go +64 -5
@@ -43,6 +43,10 @@ type attachedClient struct {
4343 resumeToken uint64
4444 parked bool
4545 echoAck atomic.Uint64
46+ // prepareFailureFallback prevents a direct fallback paint from recursively
47+ // reporting the same failed prepare through its notice repaint. It is only
48+ // needed while no render coordinator is installed.
49+ prepareFailureFallback atomic.Bool
4650 // pipelineCache is the last successfully emitted composition. pipelineScratch
4751 // is its attachment-owned alternate buffer; both are only touched under
4852 // sendMu and must never share mutable backing storage.
@@ -443,8 +447,10 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
443447 ac.setSession(sess)
444448 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
445449 // Daemon attachment callers hold d.mu, serialising this preparation with
446- // other publications. Bind coordinator ownership before sess.client becomes
447- // visible: an old deadline can then never target this new output chain.
450+ // other publications. routingMu makes the whole replacement atomic with
451+ // global notice selection: a selected old client cannot become stale before
452+ // its toast is published, and a new client is never missed after publication.
453+ d.notices.routingMu.Lock()
448454 sess.mu.Lock()
449455 old := sess.client
450456 name := sess.name
@@ -453,6 +459,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
453459 sess.mu.Lock()
454460 sess.client = ac
455461 sess.mu.Unlock()
462+ d.notices.routingMu.Unlock()
456463 d.touchMRU(sess)
457464 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
458465 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
@@ -571,7 +578,7 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc
571578 if !blockedRender {
572579 // A healthy idle client observes the required replacement notice. Its
573580 // watchdog still bounds a peer that stops draining after this check.
574- d.boundedSend(old, frameDetached(ports.ReasonDetach))
581+ d.boundedSend(old, frameDetached(ports.ReasonReplaced))
575582 }
576583 _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))
577584 d.unregisterPreview(old)
@@ -589,6 +596,10 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc
589596 // emits a full redraw. Attach must not wait for the resize-idle fallback
590597 // timer.
591598 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
599+ // Global notices raised while nothing was attached surface on this client.
600+ // Drained before the early return below so a session without an active tab
601+ // cannot swallow the queue.
602+ d.drainPendingForFirstPaint(sess, ac)
592603 tb := sess.activeTab()
593604 if tb == nil {
594605 return
@@ -655,6 +666,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
655666 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
656667 d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
657668 }
669+ case ports.MsgClientNotice:
670+ if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
671+ d.handleClientNotice(sess, ac, notice)
672+ } else {
673+ d.log.Warn("malformed client notice", "err", derr)
674+ }
658675 case ports.MsgDetach:
659676 d.clientGone(sess, ac, tr, true)
660677 return
@@ -680,7 +697,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
680697 if failed != nil && !ac.currentTransportIs(failed) {
681698 return // stale connection loop; a newer transport owns this client
682699 }
683- if !sess.detachIfCurrent(ac) {
700+ if !d.detachIfCurrent(sess, ac) {
684701 return // already displaced by a newer client; nothing to do
685702 }
686703 if rc := sess.renderCoordinator(); rc != nil {
@@ -744,6 +761,48 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
744761 d.requestTransactionalResize(sess, ac, sz, false)
745762 }
746763
764+// handleClientNotice maps the closed client-event enum to daemon-owned notice
765+// content. routingMu makes ownership validation and toast mutation one atomic
766+// attachment-routing operation: replacement also takes routingMu before
767+// publishing sess.client. Never retain sess.mu while touching notice or overlay
768+// state, and retain the routingMu -> sess.mu order used by attachment paths.
769+func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
770+ d.notices.routingMu.Lock()
771+ defer d.notices.routingMu.Unlock()
772+
773+ sess.mu.Lock()
774+ current := sess.client == ac
775+ sess.mu.Unlock()
776+ if !current {
777+ return
778+ }
779+ if d.notices.beforeClientNoticeMutation != nil {
780+ d.notices.beforeClientNoticeMutation()
781+ }
782+
783+ switch notice.Action {
784+ case ports.ClientNoticeClipboardFallback:
785+ d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
786+ case ports.ClientNoticeClipboardTooLarge:
787+ d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
788+ case ports.ClientNoticeLinkDegraded:
789+ d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
790+ case ports.ClientNoticeLinkConnected:
791+ d.dismissToast(ac, domain.NoticeConnection, sess.id)
792+ }
793+}
794+
795+func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) {
796+ n := d.notices.record(domain.Notification{
797+ Code: code,
798+ Severity: sev,
799+ Message: message,
800+ Time: d.clock.Now(),
801+ SessionID: sess.id,
802+ })
803+ d.showToast(ac, n)
804+}
805+
747806 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
748807 // returned value reports whether the synchronous request was accepted.
749808 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
@@ -756,7 +815,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
756815 if failed != nil && !ac.currentTransportIs(failed) {
757816 return
758817 }
759- if sess.detachIfCurrent(ac) {
818+ if d.detachIfCurrent(sess, ac) {
760819 if rc := sess.renderCoordinator(); rc != nil {
761820 rc.noteDetach(ac)
762821 }
M internal/usecase/daemon/config.go +45 -9
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "fmt"
56 "maps"
67 "regexp"
@@ -40,10 +41,11 @@ func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
4041 // ApplyConfig validates and atomically swaps daemon runtime configuration.
4142 func (d *Daemon) ApplyConfig(cfg domain.Config) {
4243 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
43- for _, warning := range warnings {
44+ overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes)
45+ allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...)
46+ for _, warning := range allWarnings {
4447 d.logConfigWarning(warning)
4548 }
46- overrides := d.buildCodeOverrides(cfg.Codes)
4749 allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
4850 d.bindings.Store(bindings)
4951 d.codeOverrides.Store(&overrides)
@@ -80,24 +82,53 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
8082 }
8183 d.reapplyThemeAllSessions()
8284 d.repaintAllAttachedClients()
85+
86+ if len(allWarnings) > 0 {
87+ d.notify(nil, domain.NoticeWarn, domain.NoticeConfigReload,
88+ configReloadNoticeMessage(allWarnings), configWarningsError(allWarnings))
89+ }
90+}
91+
92+func configReloadNoticeMessage(warnings []domain.Warning) string {
93+ first := warnings[0]
94+ if first.Line > 0 {
95+ return fmt.Sprintf("config reloaded with %d warning(s): line %d: %s", len(warnings), first.Line, first.Msg)
96+ }
97+ return fmt.Sprintf("config reloaded with %d warning(s): %s", len(warnings), first.Msg)
8398 }
8499
85-func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string {
100+// configWarningsError joins every config warning into a single error whose
101+// message lists them all, so noticeDetails (which renders a cause's own
102+// Error() string) surfaces every warning line in one notice's Details.
103+func configWarningsError(warnings []domain.Warning) error {
104+ lines := make([]string, len(warnings))
105+ for i, w := range warnings {
106+ if w.Line > 0 {
107+ lines[i] = fmt.Sprintf("line %d: %s", w.Line, w.Msg)
108+ } else {
109+ lines[i] = w.Msg
110+ }
111+ }
112+ return errors.New(strings.Join(lines, "; "))
113+}
114+
115+func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) {
86116 commands := command.Registry()
87117 bySlug := make(map[string]command.Command, len(commands))
88118 for _, cmd := range commands {
89119 bySlug[cmd.Slug] = cmd
90120 }
91121
122+ var warnings []domain.Warning
92123 desired := make(map[string]string)
93124 for _, slug := range sortedKeys(configured) {
94125 if _, ok := bySlug[slug]; !ok {
95- d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
126+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
96127 continue
97128 }
98129 code := strings.ToUpper(strings.TrimSpace(configured[slug]))
99130 if !commandCodePattern.MatchString(code) {
100- d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
131+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
101132 continue
102133 }
103134 desired[slug] = code
@@ -126,10 +157,15 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
126157 }
127158
128159 dropped := false
160+ conflictCodes := make([]string, 0, len(claimants))
129161 for code, slugs := range claimants {
130- if len(slugs) < 2 {
131- continue
162+ if len(slugs) > 1 {
163+ conflictCodes = append(conflictCodes, code)
132164 }
165+ }
166+ sort.Strings(conflictCodes)
167+ for _, code := range conflictCodes {
168+ slugs := claimants[code]
133169 sort.Strings(slugs)
134170
135171 // If one claimant holds this code by default (no accepted
@@ -153,7 +189,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
153189 if _, isOverride := accepted[slug]; !isOverride {
154190 continue
155191 }
156- d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
192+ warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
157193 delete(accepted, slug)
158194 dropped = true
159195 }
@@ -162,7 +198,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
162198 break
163199 }
164200 }
165- return accepted
201+ return accepted, warnings
166202 }
167203
168204 func sortedKeys(values map[string]string) []string {
M internal/usecase/daemon/config_test.go +72 -0
@@ -520,6 +520,22 @@ func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T)
520520 require.Equal(t, "new-session", byCode["CNT"])
521521 }
522522
523+func TestBuildCodeOverridesOrdersIndependentConflictWarnings(t *testing.T) {
524+ d := newTestDaemon(t, nil, stubClock{})
525+
526+ _, warnings := d.buildCodeOverrides(map[string]string{
527+ "new-session": "ZZ",
528+ "new-tab": "ZZ",
529+ "split-left": "YY",
530+ "split-right": "YY",
531+ })
532+
533+ require.Equal(t, []domain.Warning{
534+ {Msg: `command code "YY" for "split-right" conflicts with "split-left"`},
535+ {Msg: `command code "ZZ" for "new-tab" conflicts with "new-session"`},
536+ }, warnings)
537+}
538+
523539 func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
524540 d := newTestDaemon(t, nil, stubClock{})
525541 require.NotPanics(t, func() {
@@ -543,3 +559,59 @@ func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
543559 _, ok = d.commandByEffectiveCode("TOOLONG")
544560 require.False(t, ok)
545561 }
562+
563+func TestConfigReloadNoticeMessageIncludesLineOnlyWhenKnown(t *testing.T) {
564+ tests := []struct {
565+ name string
566+ warnings []domain.Warning
567+ want string
568+ }{
569+ {
570+ name: "no source line",
571+ warnings: []domain.Warning{{Msg: "unknown action"}},
572+ want: "config reloaded with 1 warning(s): unknown action",
573+ },
574+ {
575+ name: "source line",
576+ warnings: []domain.Warning{{Line: 12, Msg: "unknown action"}},
577+ want: "config reloaded with 1 warning(s): line 12: unknown action",
578+ },
579+ }
580+ for _, tt := range tests {
581+ t.Run(tt.name, func(t *testing.T) {
582+ require.Equal(t, tt.want, configReloadNoticeMessage(tt.warnings))
583+ })
584+ }
585+}
586+
587+func TestApplyConfigSurfacesOneNoticeAggregatingAllWarnings(t *testing.T) {
588+ d := newTestDaemon(t, nil, stubClock{})
589+ d.ApplyConfig(domain.Config{
590+ Theme: domain.ThemeAuto,
591+ BindingEntries: []domain.ConfigEntry{
592+ {Key: "unknown-action", Value: "alt+x"},
593+ },
594+ Codes: map[string]string{
595+ "missing-command": "OK",
596+ "new-tab": "toolong",
597+ },
598+ })
599+
600+ history := d.notices.history()
601+ var reloadNotices []domain.Notification
602+ for _, n := range history {
603+ if n.Code == domain.NoticeConfigReload {
604+ reloadNotices = append(reloadNotices, n)
605+ }
606+ }
607+ require.Len(t, reloadNotices, 1, "expected exactly one config-reload notice per ApplyConfig call")
608+
609+ n := reloadNotices[0]
610+ require.Equal(t, domain.NoticeWarn, n.Severity)
611+ require.Equal(t, domain.SessionID(""), n.SessionID, "config reload notice must be global")
612+ require.Contains(t, n.Message, "config reloaded with 3 warning(s):")
613+ require.NotContains(t, n.Message, "line 0:")
614+ require.Contains(t, n.Details, `unknown action "unknown-action"`)
615+ require.Contains(t, n.Details, `unknown command code slug "missing-command"`)
616+ require.Contains(t, n.Details, `invalid command code for "new-tab"`)
617+}
M internal/usecase/daemon/copymode.go +2 -2
@@ -327,9 +327,9 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
327327 }
328328 rt.copyMu.Lock()
329329 if len(chunks) > 0 {
330- rt.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
330+ rt.statusFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
331331 } else {
332- rt.copyFeedback = "selection too large to copy"
332+ rt.statusFeedback = "selection too large to copy"
333333 }
334334 rt.copyMu.Unlock()
335335 }
M internal/usecase/daemon/daemon.go +9 -0
@@ -114,6 +114,7 @@ type Daemon struct {
114114 persistEnabled bool
115115 snaps ports.SnapshotStore
116116 snapsEnabled bool
117+ noticeStore ports.NoticeStore
117118 snapshotMarshal func(snapcodec.Session) ([]byte, error)
118119 snapshotJobs chan *snapshotCapture
119120 snapshotWorkerMu sync.Mutex
@@ -146,6 +147,7 @@ type Daemon struct {
146147 tabsConfig atomic.Pointer[domain.TabsConfig]
147148 themeConfig atomic.Pointer[themeConfigSnapshot]
148149 barScripts *barScriptState
150+ notices *noticeCenter
149151 resumeParkGrace time.Duration
150152 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
151153 // (see clipboard.go); empty means use os.TempDir().
@@ -239,6 +241,12 @@ func WithSnapshotStore(store ports.SnapshotStore) Option {
239241 }
240242 }
241243
244+// WithNoticeStore enables persisting undeliverable notices across daemon
245+// restarts. A nil store keeps the daemon in no-op notice-persistence mode.
246+func WithNoticeStore(store ports.NoticeStore) Option {
247+ return func(d *Daemon) { d.noticeStore = store }
248+}
249+
242250 // WithCwdReader overrides the process cwd reader used for persistence tests.
243251 func WithCwdReader(fn func(int) (string, error)) Option {
244252 return func(d *Daemon) {
@@ -350,6 +358,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
350358 animWake: make(chan struct{}, 1),
351359 snapshotMarshal: snapcodec.Marshal,
352360 snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
361+ notices: newNoticeCenter(),
353362 resumeParkGrace: defaultResumeParkGrace,
354363 barScripts: &barScriptState{
355364 cfg: barConfigFromDomain(domain.Defaults().Bar),
M internal/usecase/daemon/daemon_test.go +20 -0
@@ -105,6 +105,26 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M
105105 return p, unblock
106106 }
107107
108+// newBlockingPTYWithFailingWrite returns a MockPTY whose Write always fails
109+// with writeErr, simulating a wedged pty (or a dead child) that cannot
110+// accept forwarded keystrokes.
111+func newBlockingPTYWithFailingWrite(t *testing.T, writeErr error) (*portsmocks.MockPTY, func()) {
112+ t.Helper()
113+ p := portsmocks.NewMockPTY(t)
114+ release := make(chan struct{})
115+ var once sync.Once
116+ unblock := func() { once.Do(func() { close(release) }) }
117+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
118+ <-release
119+ return 0, io.EOF
120+ }).Maybe()
121+ p.EXPECT().Write(mock.Anything).Return(0, writeErr).Maybe()
122+ p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
123+ p.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Maybe()
124+ p.EXPECT().Pid().Return(4242).Maybe()
125+ return p, unblock
126+}
127+
108128 // quietPTY is an independently closable PTY for background floating prewarms.
109129 // It intentionally has no testify expectations: tests using the factories below
110130 // are asserting their regular panes, not incidental asynchronous prewarms.
M internal/usecase/daemon/floating.go +15 -7
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "context"
5- "fmt"
65 "path/filepath"
76 "strings"
87
@@ -192,11 +191,11 @@ func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccep
192191 // launches asynchronously; installed slots are retained when hidden.
193192 func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
194193 if d == nil || sess == nil {
195- return fmt.Errorf("floating pane: session required")
194+ return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil)
196195 }
197196 tb := sess.activeTab()
198197 if tb == nil {
199- return layout.ErrNotFound
198+ return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound)
200199 }
201200 cfg := d.currentFloatingConfig()
202201 tb.mu.Lock()
@@ -293,12 +292,12 @@ type floatingLaunchSpec struct {
293292 func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
294293 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)
295294 if err != nil {
296- d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
295+ d.failFloatingLaunch(sess, tb, generation, userOpen, spec.sessionName, err)
297296 return
298297 }
299298 launch, ok := sess.registerFloatingLaunch()
300299 if !ok {
301- d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled)
300+ d.failFloatingLaunch(sess, tb, generation, userOpen, spec.sessionName, context.Canceled)
302301 return
303302 }
304303 // Count the launch before its goroutine starts. It remains counted through
@@ -382,7 +381,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
382381 }()
383382 pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
384383 if err != nil {
385- d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
384+ d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
386385 return
387386 }
388387 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
@@ -403,7 +402,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
403402 d.installFloating(sess, tb, p, generation)
404403 }
405404
406-func (d *Daemon) failFloatingLaunch(tb *tab, generation uint64, userOpen bool, sessionName string, err error) {
405+func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, userOpen bool, sessionName string, err error) {
407406 tb.mu.Lock()
408407 current := tb.failFloatingLocked(generation)
409408 tb.mu.Unlock()
@@ -413,6 +412,15 @@ func (d *Daemon) failFloatingLaunch(tb *tab, generation uint64, userOpen bool, s
413412 kind = "user-open"
414413 }
415414 d.log.Warn("floating pty spawn failed", "err", err, "session", sessionName, "kind", kind)
415+ // A background prewarm is speculative: the user never asked for it, so
416+ // a toast here would be confusing noise. Only a user-initiated open
417+ // (the user pressed the toggle-floating key) earns a toast; if a
418+ // prewarm failure matters, the user's later open attempt will fail
419+ // too and surface then.
420+ if userOpen {
421+ d.reportError(sess, domain.UserErr(domain.NoticeFloatingSpawn,
422+ "couldn't open floating pane: command failed to start", err))
423+ }
416424 }
417425 }
418426
M internal/usecase/daemon/floating_test.go +88 -4
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "bytes"
55 "context"
6+ "errors"
67 "io"
78 "log/slog"
89 "strings"
@@ -500,11 +501,18 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
500501 type floatingLogHandler struct {
501502 slog.Handler
502503 handled chan struct{}
504+ mu sync.Mutex
503505 }
504506
505-func (h floatingLogHandler) Handle(ctx context.Context, r slog.Record) error {
507+func (h *floatingLogHandler) Handle(ctx context.Context, r slog.Record) error {
506508 err := h.Handler.Handle(ctx, r)
507- close(h.handled)
509+ h.mu.Lock()
510+ select {
511+ case <-h.handled:
512+ default:
513+ close(h.handled)
514+ }
515+ h.mu.Unlock()
508516 return err
509517 }
510518
@@ -513,7 +521,7 @@ func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testi
513521 var logs bytes.Buffer
514522 handled := make(chan struct{})
515523 d := newTestDaemon(t, factory, stubClock{})
516- d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
524+ d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
517525 tb := newFloatingTestTab(t)
518526 sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}
519527 d.startFloating(sess, tb, false)
@@ -551,7 +559,7 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) {
551559 var logs bytes.Buffer
552560 handled := make(chan struct{})
553561 d := newTestDaemon(t, factory, stubClock{})
554- d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
562+ d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
555563 tb := newFloatingTestTab(t)
556564 sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}
557565
@@ -592,6 +600,82 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) {
592600 d.sessWg.Wait()
593601 }
594602
603+// TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen drives a failed
604+// asynchronous PTY open through failFloatingLaunch. A user-initiated open
605+// must surface a toast; a background prewarm must stay silent (the user
606+// never asked for it). The failure completes on a worker goroutine, so the
607+// test waits on d.sessWg (which the launch worker joins) before asserting on
608+// the test goroutine -- no require inside the worker, no sleeps.
609+func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
610+ tests := []struct {
611+ name string
612+ userOpen bool
613+ }{
614+ {name: "user-initiated open toasts", userOpen: true},
615+ {name: "background prewarm stays silent", userOpen: false},
616+ }
617+ for _, tc := range tests {
618+ t.Run(tc.name, func(t *testing.T) {
619+ spawnErr := errors.New("fork/exec: no such file or directory")
620+ factory := portsmocks.NewMockPTYFactory(t)
621+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
622+ Return(nil, spawnErr).Once()
623+ d := newTestDaemon(t, factory, stubClock{})
624+ tb := newFloatingTestTab(t)
625+ tr, _ := newCapturingTransport(t)
626+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
627+ ac.initOverlays()
628+ sess := &session{id: "floating-spawn", name: "work", tabs: []*tab{tb}, ctx: t.Context(), client: ac}
629+ ac.setSession(sess)
630+
631+ d.startFloating(sess, tb, tc.userOpen)
632+
633+ // The launch worker (registered via d.sessWg.Go in launchFloating)
634+ // runs failFloatingLaunch synchronously before returning, so
635+ // waiting on the group is a deterministic completion signal.
636+ d.sessWg.Wait()
637+
638+ if tc.userOpen {
639+ toasts := awaitToastCount(t, ac, 1)
640+ require.Equal(t, domain.NoticeFloatingSpawn, toasts[0].Code)
641+ require.Equal(t, "couldn't open floating pane: command failed to start", toasts[0].Message)
642+ } else {
643+ ns, _ := visibleToasts(ac)
644+ require.Empty(t, ns, "background prewarm failure must not toast")
645+ }
646+ })
647+ }
648+}
649+
650+func TestToggleFloatingStructuralErrorsAreUserErrors(t *testing.T) {
651+ tests := []struct {
652+ name string
653+ d *Daemon
654+ sess *session
655+ wantMsg string
656+ cause error
657+ }{
658+ {"nil session", newTestDaemon(t, nil, stubClock{}), nil, "couldn't open floating pane: no active session", nil},
659+ {"no active tab", newTestDaemon(t, nil, stubClock{}), &session{}, "couldn't open floating pane: no active tab", layout.ErrNotFound},
660+ }
661+ for _, tc := range tests {
662+ t.Run(tc.name, func(t *testing.T) {
663+ err := tc.d.toggleFloating(tc.sess, nil)
664+ require.Error(t, err)
665+
666+ var ue *domain.UserError
667+ require.ErrorAs(t, err, &ue)
668+ require.Equal(t, domain.NoticeFloatingSpawn, ue.Code)
669+ require.Equal(t, domain.NoticeError, ue.Severity)
670+ require.Equal(t, tc.wantMsg, ue.Msg)
671+ if tc.cause != nil {
672+ require.NotContains(t, ue.Msg, tc.cause.Error())
673+ require.ErrorIs(t, ue, tc.cause)
674+ }
675+ })
676+ }
677+}
678+
595679 func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
596680 d := newTestDaemon(t, nil, stubClock{})
597681 tb := newFloatingTestTab(t)
M internal/usecase/daemon/input.go +20 -6
@@ -5,6 +5,7 @@ import (
55 "bytes"
66 "strconv"
77
8+ "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/usecase/keys"
910 "github.com/bnema/vev/internal/usecase/layout"
1011 "github.com/bnema/vev/internal/usecase/mouse"
@@ -35,7 +36,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
3536 frameEvent := ev
3637 ac.initOverlays()
3738 rt := ac.overlays
38- if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
39+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() {
3940 return
4041 }
4142 sess := ac.currentSession()
@@ -212,6 +213,8 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
212213 sess.mu.Unlock()
213214 }
214215 d.log.Error("pty write failed", "err", err, "session", name)
216+ d.notify(sess, domain.NoticeError, domain.NoticeInputDropped,
217+ "input not delivered to pane", err)
215218 }
216219 }
217220
@@ -286,19 +289,30 @@ func (h daemonKeyHandler) Action(action keys.Action) {
286289 case keys.ActionOpenPalette:
287290 h.d.enterPalette(sess, h.ac)
288291 case keys.ActionJumpAttention:
289- h.d.jumpAttention(sess, h.ac)
292+ if err := h.d.jumpAttention(sess, h.ac); err != nil {
293+ h.d.reportError(sess, err)
294+ }
290295 case keys.ActionToggleFloatingPane:
291296 if err := h.d.toggleFloating(sess, h.ac); err != nil {
292297 h.d.log.Warn("toggle floating pane failed", "err", err)
298+ h.d.reportError(sess, err)
293299 }
294300 case keys.ActionFocusPaneLeft:
295- _ = h.d.focusDir(sess, h.ac, layout.Left)
301+ if err := h.d.focusDir(sess, h.ac, layout.Left); err != nil {
302+ h.d.reportError(sess, err)
303+ }
296304 case keys.ActionFocusPaneRight:
297- _ = h.d.focusDir(sess, h.ac, layout.Right)
305+ if err := h.d.focusDir(sess, h.ac, layout.Right); err != nil {
306+ h.d.reportError(sess, err)
307+ }
298308 case keys.ActionFocusPaneUp:
299- _ = h.d.focusDir(sess, h.ac, layout.Up)
309+ if err := h.d.focusDir(sess, h.ac, layout.Up); err != nil {
310+ h.d.reportError(sess, err)
311+ }
300312 case keys.ActionFocusPaneDown:
301- _ = h.d.focusDir(sess, h.ac, layout.Down)
313+ if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil {
314+ h.d.reportError(sess, err)
315+ }
302316 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
303317 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
304318 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +120 -2
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "encoding/base64"
6+ "errors"
67 "fmt"
78 "strings"
89 "sync"
@@ -172,6 +173,31 @@ func TestAltCForwardsToPTY(t *testing.T) {
172173 releasePTY()
173174 }
174175
176+// TestWriteToPaneFailureNotifiesDroppedInput drives forwarded keystrokes into
177+// a pane whose pty.Write always fails (a wedged pty, or a dead child). The
178+// user's typing must not silently vanish: each failed write records a
179+// NoticeInputDropped notice, and repeated failures on the same code coalesce
180+// into a single toast with a rising count rather than spamming one per key.
181+func TestWriteToPaneFailureNotifiesDroppedInput(t *testing.T) {
182+ writeErr := errors.New("write /dev/ptmx: input/output error")
183+ p, releasePTY := newBlockingPTYWithFailingWrite(t, writeErr)
184+ defer releasePTY()
185+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
186+
187+ for range 5 {
188+ d.handleInput(sess, ac, []byte("x"))
189+ }
190+
191+ toasts := awaitToastCount(t, ac, 1)
192+ require.Equal(t, domain.NoticeInputDropped, toasts[0].Code)
193+ require.Equal(t, domain.NoticeError, toasts[0].Severity)
194+ require.Equal(t, "input not delivered to pane", toasts[0].Message)
195+ require.Equal(t, 5, toasts[0].Count, "5 failed writes must coalesce into one toast, not one per keystroke")
196+
197+ hist := d.notices.history()
198+ require.Len(t, hist, 5, "coalescing is display-only; history keeps every occurrence")
199+}
200+
175201 func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
176202 normal, releaseNormal := newBlockingPTY(t)
177203 floatingPTY, releaseFloating := newBlockingPTY(t)
@@ -329,6 +355,38 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
329355 requirePTYWrite(t, floatingWrites, []byte("x"))
330356 }
331357
358+// TestActionFocusPaneAtEdgeStaysSilent drives a directional focus move with
359+// no pane on that side (the session has a single, unsplit pane). This is
360+// routine navigation, not a failure, and must never produce a notice.
361+func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {
362+ d, _, ac, _ := newManualSessionWithPTYs(t, nil)
363+
364+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft)
365+
366+ require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent")
367+}
368+
369+// TestActionFocusPaneGenuineErrorReportsNoticeInternal drives a directional
370+// focus move against a tab with no layout tree at all — a genuine structural
371+// error distinct from "no pane in that direction" — and asserts it reaches
372+// the user as a notice instead of being silently discarded.
373+func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
374+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
375+ tb := sess.activeTab()
376+ tb.mu.Lock()
377+ tb.tree = nil
378+ tb.mu.Unlock()
379+
380+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft)
381+
382+ history := d.notices.history()
383+ require.Len(t, history, 2, "the layout error and its failed direct display update must both surface")
384+ require.Equal(t, domain.NoticeInternal, history[0].Code)
385+ require.Equal(t, "display update failed", history[0].Message)
386+ require.Equal(t, domain.NoticeInternal, history[1].Code)
387+ require.Equal(t, "internal error", history[1].Message)
388+}
389+
332390 func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {
333391 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
334392 first := sess.activeTab()
@@ -560,7 +618,7 @@ func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
560618 recent := d.sessions[domain.SessionID("recent")]
561619
562620 // Picker-style successful transition records its origin.
563- d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})
621+ require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
564622 for _, sess := range []*session{current, recent, current} {
565623 runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
566624 require.Same(t, sess, ac.currentSession())
@@ -590,6 +648,27 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
590648 }
591649 }
592650
651+func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
652+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
653+ defer releaseAll(releases)
654+ target := d.sessions[domain.SessionID("recent")]
655+ ac.previousSession.Set(target)
656+
657+ // A displaced attachment makes the previously valid handoff stale by the
658+ // time switchToTarget commits it.
659+ current.mu.Lock()
660+ current.client = nil
661+ current.mu.Unlock()
662+
663+ d.backSession(current, ac)
664+
665+ require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")
666+ require.Same(t, target, ac.previousSession.Get(), "a failed handoff remains retryable")
667+ history := d.notices.history()
668+ require.Len(t, history, 1, "the switch failure must be reported exactly once")
669+ require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
670+}
671+
593672 func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
594673 ac := &attachedClient{}
595674 stale := &session{id: "stale"}
@@ -688,7 +767,11 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
688767 defer releaseAll(releases)
689768 recent := d.sessions[domain.SessionID("recent")]
690769
691- d.switchToTarget(current, ac, tc.target(current, recent))
770+ if tc.name == "missing target does not record" {
771+ require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent)))
772+ } else {
773+ require.NoError(t, d.switchToTarget(current, ac, tc.target(current, recent)))
774+ }
692775 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
693776 if tc.wantPrev == "" {
694777 require.Nil(t, ac.previousSession.Get())
@@ -1444,6 +1527,41 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
14441527 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
14451528 }
14461529
1530+// TestMouseGatedWhileNoticesOverlayActive guards against a click on the
1531+// notifications modal falling through to pane hit-testing underneath it: the
1532+// modal has no mouse handler of its own, so without the gate a click would
1533+// silently retarget focus and forward SGR bytes to whichever pane sits under
1534+// the modal's coordinates.
1535+func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1536+ p1 := portsmocks.NewMockPTY(t)
1537+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1538+ p2 := portsmocks.NewMockPTY(t)
1539+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1540+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1541+ d.procComm = nil
1542+ tb := sess.activeTab()
1543+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1544+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1545+ tb.mu.Lock()
1546+ tb.size = domain.Size{Cols: 41, Rows: 5}
1547+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1548+ tb.tree.Focus = "pane-1"
1549+ tb.panes["pane-2"] = p2pane
1550+ tb.mu.Unlock()
1551+
1552+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
1553+ d.enterNotices(sess, ac)
1554+ awaitFrame(t, sends, ports.MsgOutput)
1555+ require.True(t, ac.overlays.noticesActive())
1556+
1557+ // Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns
1558+ // proves would otherwise focus pane-2 and forward the SGR report to it.
1559+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1560+
1561+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open")
1562+ require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay")
1563+}
1564+
14471565 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
14481566 p1 := portsmocks.NewMockPTY(t)
14491567 p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
A internal/usecase/daemon/notice_persist_test.go +142 -0
@@ -0,0 +1,142 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "sync/atomic"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+// TestKillSessionServerShutdownSaturatedAppendsNotice proves that when the
18+// daemon is shutting down and the snapshot worker is saturated — so there is
19+// no way to retain the session's terminal state and no attached client to
20+// toast, because the daemon is dying — the failure is handed to the
21+// NoticeStore instead of being silently lost.
22+func TestKillSessionServerShutdownSaturatedAppendsNotice(t *testing.T) {
23+ store := discardSnapshotStore{}
24+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
25+ WithSnapshotStore(store)(d)
26+ startSnapshotEncodeWorker(t, d)
27+
28+ entered := make(chan struct{})
29+ release := make(chan struct{})
30+ defer close(release)
31+ var calls atomic.Int32
32+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
33+ if calls.Add(1) == 1 {
34+ close(entered)
35+ <-release
36+ }
37+ return snapcodec.Marshal(s)
38+ }
39+
40+ active := newSnapshotTestSession(t, "active", false, "/work")
41+ queued := newSnapshotTestSession(t, "queued", false, "/work")
42+ markSnapshotDirty(active)
43+ markSnapshotDirty(queued)
44+ require.True(t, d.scheduleSnapshot(active))
45+ <-entered // the worker is now parked inside marshal for active
46+ require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
47+ for i := range snapshotFinalQueueCapacity {
48+ s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
49+ markSnapshotDirty(s)
50+ require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
51+ }
52+
53+ target := newSnapshotTestSession(t, "target", false, "/work")
54+ targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
55+ require.True(t, ok, "target pane PTY must be a MockPTY")
56+ targetPTY.EXPECT().Close().Return(nil).Maybe()
57+ d.mu.Lock()
58+ d.sessions[target.id] = target
59+ d.mu.Unlock()
60+ markSnapshotDirty(target)
61+
62+ notices := portsmocks.NewMockNoticeStore(t)
63+ var appended domain.Notification
64+ notices.EXPECT().Append(mock.Anything).RunAndReturn(func(n domain.Notification) error {
65+ appended = n
66+ return nil
67+ }).Once()
68+ d.noticeStore = notices
69+
70+ err := d.killSession(target, ports.ReasonServerShutdown, false)
71+ require.ErrorContains(t, err, "snapshot worker unavailable or saturated")
72+
73+ d.mu.Lock()
74+ _, stillPresent := d.sessions[target.id]
75+ d.mu.Unlock()
76+ require.False(t, stillPresent, "the session must be torn down even though its state could not be saved")
77+
78+ require.Equal(t, domain.NoticeSnapshotWrite, appended.Code)
79+ require.Equal(t, domain.NoticeError, appended.Severity)
80+ require.Equal(t, "session target shut down without saving terminal state", appended.Message)
81+ require.NotEmpty(t, appended.Details)
82+}
83+
84+// TestKillSessionServerShutdownNotSaturatedSkipsAppend proves the notice store
85+// is left untouched when the final snapshot is retained normally.
86+func TestKillSessionServerShutdownNotSaturatedSkipsAppend(t *testing.T) {
87+ store := discardSnapshotStore{}
88+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
89+ WithSnapshotStore(store)(d)
90+ startSnapshotEncodeWorker(t, d)
91+
92+ notices := portsmocks.NewMockNoticeStore(t) // no .EXPECT(): Append must not be called
93+ d.noticeStore = notices
94+
95+ target := newSnapshotTestSession(t, "target", false, "/work")
96+ targetPTY, ok := target.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
97+ require.True(t, ok, "target pane PTY must be a MockPTY")
98+ targetPTY.EXPECT().Close().Return(nil).Maybe()
99+ d.mu.Lock()
100+ d.sessions[target.id] = target
101+ d.mu.Unlock()
102+ markSnapshotDirty(target)
103+
104+ err := d.killSession(target, ports.ReasonServerShutdown, false)
105+ require.NoError(t, err)
106+}
107+
108+// TestStartupClaimRecordsAndToastsPendingNotices proves notices persisted by
109+// a previous daemon are recorded to history and toasted to the first client
110+// that attaches after this daemon starts before their claim is acknowledged.
111+func TestStartupClaimRecordsAndToastsPendingNotices(t *testing.T) {
112+ claimed := []domain.Notification{
113+ {Code: domain.NoticeSnapshotWrite, Severity: domain.NoticeError, Message: "session a shut down without saving terminal state"},
114+ {Code: domain.NoticeSnapshotWrite, Severity: domain.NoticeError, Message: "session b shut down without saving terminal state"},
115+ }
116+ notices := portsmocks.NewMockNoticeStore(t)
117+ notices.EXPECT().Claim().Return(claimed, nil).Once()
118+ notices.EXPECT().Ack().Return(nil).Once()
119+
120+ p, releasePTY := newBlockingPTY(t)
121+ defer releasePTY()
122+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
123+ d.noticeStore = notices
124+
125+ d.restoreSnapshots(t.Context())
126+
127+ require.Len(t, d.notices.history(), 2, "each drained notice is recorded individually")
128+
129+ tr, _ := newCapturingTransport(t)
130+ sess, ac, err := d.route(ports.Hello{
131+ Version: ports.ProtocolVersion,
132+ Intent: ports.IntentEphemeral,
133+ Size: domain.Size{Cols: 80, Rows: 24},
134+ }, tr)
135+ require.NoError(t, err)
136+ d.firstPaint(sess, ac, ac.size)
137+
138+ toasts := awaitToastCount(t, ac, 1)
139+ require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
140+ require.Equal(t, 2, toasts[0].Count, "two same-code drained notices dedup into one toast counted twice")
141+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
142+}
A internal/usecase/daemon/notice_render_test.go +108 -0
@@ -0,0 +1,108 @@
1+package daemon
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes
16+// into screen, growing its shadow the same way a real client would.
17+func replayOutputFrames(t *testing.T, screen *vt.Screen, frames []ports.Frame) {
18+ t.Helper()
19+ for _, f := range frames {
20+ require.Equal(t, ports.MsgOutput, f.Type)
21+ out, err := ports.UnmarshalOutput(f.Payload)
22+ require.NoError(t, err)
23+ screen.Write(out.Data)
24+ }
25+}
26+
27+// drainAllFrames collects every frame already queued on sends without blocking.
28+func drainAllFrames(sends chan ports.Frame) []ports.Frame {
29+ var out []ports.Frame
30+ for {
31+ select {
32+ case f := <-sends:
33+ out = append(out, f)
34+ default:
35+ return out
36+ }
37+ }
38+}
39+
40+// runeColumn returns the rune-indexed column at which text first occurs in
41+// row, or -1. Rows are exactly one rune per terminal column, so this must not
42+// use strings.Index: box-drawing borders are multi-byte and would inflate a
43+// byte offset past the real column.
44+func runeColumn(row, text string) int {
45+ runes, sub := []rune(row), []rune(text)
46+ for i := 0; i+len(sub) <= len(runes); i++ {
47+ match := true
48+ for j, r := range sub {
49+ if runes[i+j] != r {
50+ match = false
51+ break
52+ }
53+ }
54+ if match {
55+ return i
56+ }
57+ }
58+ return -1
59+}
60+
61+// containsTopRight reports whether text appears anywhere at or past minCol in
62+// the frame's top half.
63+func containsTopRight(rows []string, text string, minCol int) bool {
64+ for y, row := range rows {
65+ if y > len(rows)/2 {
66+ break
67+ }
68+ if runeColumn(row, text) >= minCol {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+
75+// TestPaintComposesNoticeToastTopRightAndExpiresOnTTL drives an actual
76+// notify -> showToast -> paint -> emit round trip and decodes the resulting
77+// wire bytes through a terminal emulator, the same way a real client would,
78+// so this proves the composed frame (not just internal toast state) carries
79+// the message top-right, and that it disappears once the fake clock crosses
80+// the toast's TTL.
81+func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
82+ clk := newNoticeClock()
83+ d, sess, ac, sends := newNoticeFixture(t, clk)
84+ screen := vt.NewScreen(ac.size.Cols, ac.size.Rows)
85+
86+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil)
87+ awaitToastCount(t, ac, 1)
88+ replayOutputFrames(t, screen, drainAllFrames(sends))
89+
90+ rows := frameRows(screen.Frame)
91+ if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
92+ t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
93+ }
94+
95+ clk.advance(noticeTTL(domain.NoticeError))
96+ awaitToastCount(t, ac, 0)
97+ select {
98+ case f := <-sends:
99+ replayOutputFrames(t, screen, append([]ports.Frame{f}, drainAllFrames(sends)...))
100+ case <-time.After(2 * time.Second):
101+ t.Fatal("expiry did not invalidate the render")
102+ }
103+
104+ rows = frameRows(screen.Frame)
105+ if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
106+ t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107+ }
108+}
A internal/usecase/daemon/notices_overlay.go +278 -0
@@ -0,0 +1,278 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/keys"
12+ "github.com/bnema/vev/internal/usecase/notices"
13+ "github.com/bnema/vev/internal/usecase/ui"
14+)
15+
16+var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter}
17+
18+// enterNotices opens the notification history overlay from the daemon's full
19+// notice center history, newest first.
20+func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
21+ history := d.notices.history()
22+ now := d.clock.Now()
23+ ac.overlays.noticeMu.Lock()
24+ ac.overlays.noticesOverlay = notices.New(history, now)
25+ ac.overlays.noticesPending = nil
26+ ac.overlays.noticeMu.Unlock()
27+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
28+}
29+
30+// listInputState owns the common pending-ESC lifecycle for list overlays.
31+// The caller holds the overlay lock while handleListInputLocked runs. The
32+// timer callback takes the same lock before checking its identity, so a later
33+// input chunk cannot let a stale lone-ESC close a reopened overlay.
34+type listInputState struct {
35+ pending *[]byte
36+ esc *pendingByteTimer
37+ moveUp func()
38+ moveDown func()
39+ lock func()
40+ unlock func()
41+ active func() bool
42+ closeLocked func()
43+ afterClose func()
44+}
45+
46+type listInputResult struct {
47+ changed bool
48+ exit bool
49+ action byte
50+ stop bool
51+}
52+
53+func handleListInputLocked(clock ports.Clock, data []byte, state listInputState, action func(byte) listInputResult) listInputResult {
54+ if len(*state.pending) > 0 {
55+ state.esc.stop()
56+ combined := make([]byte, 0, len(*state.pending)+len(data))
57+ combined = append(combined, (*state.pending)...)
58+ combined = append(combined, data...)
59+ data = combined
60+ *state.pending = nil
61+ }
62+
63+ var result listInputResult
64+ for i := 0; i < len(data); i++ {
65+ switch data[i] {
66+ case 'j':
67+ state.moveDown()
68+ result.changed = true
69+ case 'k':
70+ state.moveUp()
71+ result.changed = true
72+ case 'q', 0x03:
73+ result.exit = true
74+ return result
75+ case keys.ESC:
76+ tail := data[i:]
77+ if consumed, move := routeListEscape(tail); consumed > 0 {
78+ switch move {
79+ case 'A':
80+ state.moveUp()
81+ result.changed = true
82+ case 'B':
83+ state.moveDown()
84+ result.changed = true
85+ }
86+ i += consumed - 1
87+ continue
88+ }
89+ if len(tail) == 1 {
90+ retainListESCLocked(clock, state)
91+ break
92+ }
93+ if isListEscapePrefix(tail) {
94+ *state.pending = append((*state.pending)[:0], tail...)
95+ i += len(tail) - 1
96+ continue
97+ }
98+ result.exit = true
99+ return result
100+ default:
101+ custom := action(data[i])
102+ if custom.action != 0 {
103+ result.action = custom.action
104+ }
105+ if custom.exit {
106+ result.exit = true
107+ }
108+ if custom.stop {
109+ result.stop = true
110+ return result
111+ }
112+ if custom.exit {
113+ return result
114+ }
115+ }
116+ }
117+ return result
118+}
119+
120+// routeListEscape consumes one complete CSI or SS3 sequence. It returns its
121+// byte length and the simple arrow final when it is an Up/Down sequence. Other
122+// complete terminal sequences are deliberately ignored so their bytes cannot
123+// trigger list actions or close the overlay.
124+func routeListEscape(data []byte) (consumed int, move byte) {
125+ if len(data) < 2 || data[0] != keys.ESC || (data[1] != '[' && data[1] != 'O') {
126+ return 0, 0
127+ }
128+ for i := 2; i < len(data); i++ {
129+ if data[i] < 0x40 || data[i] > 0x7e {
130+ continue
131+ }
132+ if i == 2 && (data[i] == 'A' || data[i] == 'B') {
133+ return i + 1, data[i]
134+ }
135+ return i + 1, 0
136+ }
137+ return 0, 0
138+}
139+
140+func isListEscapePrefix(data []byte) bool {
141+ return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
142+}
143+
144+func retainListESCLocked(clock ports.Clock, state listInputState) {
145+ *state.pending = append((*state.pending)[:0], keys.ESC)
146+ state.esc.retain(clock, keys.ESCDelay, func(timer ports.Timer) {
147+ state.lock()
148+ if state.esc.timer != timer || len(*state.pending) != 1 || (*state.pending)[0] != keys.ESC || !state.active() {
149+ state.unlock()
150+ return
151+ }
152+ *state.pending = nil
153+ state.esc.timer = nil
154+ state.esc.done = nil
155+ state.closeLocked()
156+ state.unlock()
157+ state.afterClose()
158+ })
159+}
160+
161+func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState {
162+ rt := ac.overlays
163+ return listInputState{
164+ pending: &rt.noticesPending,
165+ esc: &rt.noticesESC,
166+ moveUp: rt.noticesOverlay.Up,
167+ moveDown: rt.noticesOverlay.Down,
168+ lock: rt.noticeMu.Lock,
169+ unlock: rt.noticeMu.Unlock,
170+ active: func() bool { return rt.noticesOverlay != nil },
171+ closeLocked: func() {
172+ rt.noticesOverlay = nil
173+ },
174+ afterClose: func() {
175+ if sess := ac.currentSession(); sess != nil {
176+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
177+ }
178+ },
179+ }
180+}
181+
182+func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
183+ sess := ac.currentSession()
184+ if sess == nil {
185+ return
186+ }
187+ rt := ac.overlays
188+ rt.noticeMu.Lock()
189+ if rt.noticesOverlay == nil {
190+ rt.noticesPending = nil
191+ rt.noticesESC.stop()
192+ rt.noticeMu.Unlock()
193+ return
194+ }
195+ var yankTarget domain.Notification
196+ var yank bool
197+ result := handleListInputLocked(d.clock, data, d.noticesListInputState(ac), func(b byte) listInputResult {
198+ if b == 'y' {
199+ yankTarget, yank = rt.noticesOverlay.Selected()
200+ return listInputResult{action: b}
201+ }
202+ return listInputResult{}
203+ })
204+ rt.noticeMu.Unlock()
205+
206+ if result.exit {
207+ d.closeNotices(ac)
208+ }
209+ if yank {
210+ // yankNotice sends over the wire and repaints itself, so it must run
211+ // with noticeMu released; a quick yank doesn't close the overlay the
212+ // way copy mode's own 'y' commits and exits.
213+ d.yankNotice(sess, ac, yankTarget)
214+ }
215+ if result.exit || result.changed {
216+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
217+ }
218+}
219+
220+// yankNotice copies n's formatted details to the client's clipboard via
221+// OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222+// send each chunk, then leave a one-shot status-bar confirmation that the
223+// next repaint clears.
224+func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notification) {
225+ chunks := scopy.OSC52(noticeYankPayload(n))
226+ for _, chunk := range chunks {
227+ failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228+ if err != nil {
229+ d.detachOnSendError(sess, ac, failed)
230+ return
231+ }
232+ }
233+ ac.overlays.copyMu.Lock()
234+ if len(chunks) > 0 {
235+ ac.overlays.statusFeedback = "copied notification details"
236+ } else {
237+ ac.overlays.statusFeedback = "notification too large to copy"
238+ }
239+ ac.overlays.copyMu.Unlock()
240+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
241+}
242+
243+// noticeYankPayload formats n the way the yank commands put it on the
244+// clipboard: a header line with timestamp, severity, code slug, and coalesce
245+// count, then the message, then the full cause chain when present.
246+func noticeYankPayload(n domain.Notification) string {
247+ b := &strings.Builder{}
248+ fmt.Fprintf(b, "[%s] %s %s", n.Time.Format(time.RFC3339), severityWord(n.Severity), n.Code.String())
249+ if n.Count > 1 {
250+ fmt.Fprintf(b, " ×%d", n.Count)
251+ }
252+ fmt.Fprintf(b, "\n%s\n", n.Message)
253+ if n.Details != "" {
254+ fmt.Fprintf(b, "details: %s\n", n.Details)
255+ }
256+ return b.String()
257+}
258+
259+// severityWord renders sev as the word used in yank payloads, distinct from
260+// the single-letter form the history list draws (notices.severityLetter).
261+func severityWord(sev domain.NoticeSeverity) string {
262+ switch sev {
263+ case domain.NoticeWarn:
264+ return "warn"
265+ case domain.NoticeError:
266+ return "error"
267+ default:
268+ return "info"
269+ }
270+}
271+
272+func (d *Daemon) closeNotices(ac *attachedClient) {
273+ ac.overlays.noticeMu.Lock()
274+ ac.overlays.noticesOverlay = nil
275+ ac.overlays.noticesPending = nil
276+ ac.overlays.noticesESC.stop()
277+ ac.overlays.noticeMu.Unlock()
278+}
A internal/usecase/daemon/notices_overlay_test.go +485 -0
@@ -0,0 +1,485 @@
1+package daemon
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) {
17+ p, release := newBlockingPTY(t)
18+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
19+ defer release()
20+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)})
21+
22+ d.enterPalette(sess, ac)
23+ awaitFrame(t, sends, ports.MsgOutput)
24+ d.handlePaletteInput(ac, []byte("NTC\r"))
25+ out := awaitFrame(t, sends, ports.MsgOutput)
26+
27+ require.False(t, ac.overlays.paletteActive())
28+ require.NotNil(t, ac.overlays.noticesOverlay)
29+ msg, err := ports.UnmarshalOutput(out.Payload)
30+ require.NoError(t, err)
31+ require.Contains(t, string(msg.Data), "Notifications")
32+ require.Contains(t, string(msg.Data), "pane-spawn")
33+ require.Contains(t, string(msg.Data), "couldn't open pane")
34+}
35+
36+func TestNoticesJKNavigatesSelection(t *testing.T) {
37+ p, release := newBlockingPTY(t)
38+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
39+ defer release()
40+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
41+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
42+ d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
43+
44+ d.enterNotices(sess, ac)
45+ awaitFrame(t, sends, ports.MsgOutput)
46+ selected, ok := ac.overlays.noticesOverlay.Selected()
47+ require.True(t, ok)
48+ require.Equal(t, "third", selected.Message)
49+
50+ d.handleInput(sess, ac, []byte("j"))
51+ awaitFrame(t, sends, ports.MsgOutput)
52+ selected, ok = ac.overlays.noticesOverlay.Selected()
53+ require.True(t, ok)
54+ require.Equal(t, "second", selected.Message)
55+
56+ d.handleInput(sess, ac, []byte("j"))
57+ awaitFrame(t, sends, ports.MsgOutput)
58+ selected, ok = ac.overlays.noticesOverlay.Selected()
59+ require.True(t, ok)
60+ require.Equal(t, "first", selected.Message)
61+
62+ // One more 'j' at the bottom must clamp rather than wrap.
63+ d.handleInput(sess, ac, []byte("j"))
64+ awaitFrame(t, sends, ports.MsgOutput)
65+ selected, ok = ac.overlays.noticesOverlay.Selected()
66+ require.True(t, ok)
67+ require.Equal(t, "first", selected.Message)
68+
69+ d.handleInput(sess, ac, []byte("k"))
70+ awaitFrame(t, sends, ports.MsgOutput)
71+ selected, ok = ac.overlays.noticesOverlay.Selected()
72+ require.True(t, ok)
73+ require.Equal(t, "second", selected.Message)
74+}
75+
76+func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) {
77+ for _, key := range []byte{'q', 0x03} {
78+ t.Run(fmt.Sprintf("key=%q", key), func(t *testing.T) {
79+ p, release := newBlockingPTY(t)
80+ defer release()
81+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
82+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
83+
84+ d.enterNotices(sess, ac)
85+ awaitFrame(t, sends, ports.MsgOutput)
86+ require.True(t, ac.overlays.noticesActive())
87+
88+ d.handleInput(sess, ac, []byte{key})
89+ awaitFrame(t, sends, ports.MsgOutput)
90+
91+ require.False(t, ac.overlays.noticesActive())
92+ require.Nil(t, ac.overlays.noticesOverlay)
93+ })
94+ }
95+}
96+
97+func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) {
98+ p, release := newBlockingPTY(t)
99+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
100+ defer release()
101+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
102+
103+ d.enterNotices(sess, ac)
104+ awaitFrame(t, sends, ports.MsgOutput)
105+
106+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
107+ d.clock = clk
108+ d.handleInput(sess, ac, []byte("\x1b"))
109+ timer := <-clk.timers
110+ require.True(t, ac.overlays.noticesActive())
111+
112+ timer.ch <- time.Now()
113+ require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond)
114+ awaitFrame(t, sends, ports.MsgOutput)
115+}
116+
117+func TestNoticesSplitArrowNavigatesWithoutClosing(t *testing.T) {
118+ cases := []struct {
119+ name string
120+ input [][]byte
121+ want string
122+ }{
123+ {name: "escape then CSI down", input: [][]byte{[]byte("\x1b"), []byte("[B")}, want: "first"},
124+ {name: "split CSI down", input: [][]byte{[]byte("\x1b["), []byte("B")}, want: "first"},
125+ {name: "split SS3 down", input: [][]byte{[]byte("\x1bO"), []byte("B")}, want: "first"},
126+ {name: "split CSI up", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, want: "second"},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ p, release := newBlockingPTY(t)
131+ defer release()
132+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
133+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
134+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
135+
136+ d.enterNotices(sess, ac)
137+ awaitFrame(t, sends, ports.MsgOutput)
138+ for _, input := range tc.input {
139+ d.handleInput(sess, ac, input)
140+ }
141+
142+ require.True(t, ac.overlays.noticesActive())
143+ selected, ok := ac.overlays.noticesOverlay.Selected()
144+ require.True(t, ok)
145+ require.Equal(t, tc.want, selected.Message)
146+ })
147+ }
148+}
149+
150+func TestHandleListInputConsumesSplitEscapePrefix(t *testing.T) {
151+ for _, tt := range []struct {
152+ name string
153+ prefix []byte
154+ tail byte
155+ wantUp int
156+ wantDown int
157+ }{
158+ {name: "CSI up", prefix: []byte("\x1b["), tail: 'A', wantUp: 1},
159+ {name: "CSI down", prefix: []byte("\x1b["), tail: 'B', wantDown: 1},
160+ {name: "SS3 up", prefix: []byte("\x1bO"), tail: 'A', wantUp: 1},
161+ {name: "SS3 down", prefix: []byte("\x1bO"), tail: 'B', wantDown: 1},
162+ } {
163+ t.Run(tt.name, func(t *testing.T) {
164+ var pending, custom []byte
165+ var up, down int
166+ state := listInputState{
167+ pending: &pending,
168+ esc: &pendingByteTimer{},
169+ moveUp: func() { up++ },
170+ moveDown: func() { down++ },
171+ }
172+ action := func(b byte) listInputResult {
173+ custom = append(custom, b)
174+ return listInputResult{}
175+ }
176+
177+ handleListInputLocked(nil, tt.prefix, state, action)
178+ handleListInputLocked(nil, []byte{tt.tail}, state, action)
179+
180+ require.Equal(t, tt.wantUp, up)
181+ require.Equal(t, tt.wantDown, down)
182+ require.Empty(t, custom, "escape prefix bytes must not reach the custom action")
183+ require.Empty(t, pending)
184+ })
185+ }
186+}
187+
188+func TestHandleListInputConsumesUnsupportedCompleteEscapeSequences(t *testing.T) {
189+ for _, tt := range []struct {
190+ name string
191+ input [][]byte
192+ }{
193+ {name: "complete CSI right", input: [][]byte{[]byte("\x1b[C")}},
194+ {name: "complete SS3 left", input: [][]byte{[]byte("\x1bOD")}},
195+ {name: "parameterized CSI", input: [][]byte{[]byte("\x1b[1;5C")}},
196+ {name: "split CSI right", input: [][]byte{[]byte("\x1b["), []byte("C")}},
197+ {name: "split SS3 left", input: [][]byte{[]byte("\x1bO"), []byte("D")}},
198+ } {
199+ t.Run(tt.name, func(t *testing.T) {
200+ var pending, custom []byte
201+ state := listInputState{
202+ pending: &pending,
203+ esc: &pendingByteTimer{},
204+ moveUp: func() {},
205+ moveDown: func() {},
206+ }
207+ action := func(b byte) listInputResult {
208+ custom = append(custom, b)
209+ return listInputResult{action: b}
210+ }
211+
212+ var result listInputResult
213+ for _, input := range tt.input {
214+ result = handleListInputLocked(nil, input, state, action)
215+ }
216+
217+ require.False(t, result.exit)
218+ require.Zero(t, result.action)
219+ require.Empty(t, custom, "complete escape bytes must not reach the custom action")
220+ require.Empty(t, pending)
221+ })
222+ }
223+}
224+
225+func TestHandleListInputStopsAfterExitInBatchedInput(t *testing.T) {
226+ for _, tt := range []struct {
227+ name string
228+ input []byte
229+ custom func(byte) listInputResult
230+ wantExit bool
231+ wantStop bool
232+ wantAction byte
233+ wantCalls []byte
234+ }{
235+ {
236+ name: "q ignores trailing navigation and actions",
237+ input: []byte("qjy"),
238+ custom: func(b byte) listInputResult { return listInputResult{action: b} },
239+ wantExit: true,
240+ },
241+ {
242+ name: "ctrl-c ignores trailing navigation and actions",
243+ input: []byte{0x03, 'j', 'y'},
244+ custom: func(b byte) listInputResult { return listInputResult{action: b} },
245+ wantExit: true,
246+ },
247+ {
248+ name: "invalid escape exit ignores trailing navigation and actions",
249+ input: []byte{'\x1b', 'x', 'j', 'y'},
250+ custom: func(b byte) listInputResult { return listInputResult{action: b} },
251+ wantExit: true,
252+ wantCalls: nil,
253+ wantAction: 0,
254+ },
255+ {
256+ name: "custom exit retains action and ignores trailing bytes",
257+ input: []byte("xjy"),
258+ custom: func(b byte) listInputResult {
259+ if b == 'x' {
260+ return listInputResult{action: b, exit: true}
261+ }
262+ return listInputResult{action: b}
263+ },
264+ wantExit: true, wantAction: 'x', wantCalls: []byte("x"),
265+ },
266+ {
267+ name: "custom stop retains action and ignores trailing bytes",
268+ input: []byte("xjy"),
269+ custom: func(b byte) listInputResult {
270+ if b == 'x' {
271+ return listInputResult{action: b, stop: true}
272+ }
273+ return listInputResult{action: b}
274+ },
275+ wantStop: true, wantAction: 'x', wantCalls: []byte("x"),
276+ },
277+ {
278+ name: "custom exit and stop retain both flags and action",
279+ input: []byte("xjy"),
280+ custom: func(b byte) listInputResult {
281+ if b == 'x' {
282+ return listInputResult{action: b, exit: true, stop: true}
283+ }
284+ return listInputResult{action: b}
285+ },
286+ wantExit: true, wantStop: true, wantAction: 'x', wantCalls: []byte("x"),
287+ },
288+ } {
289+ t.Run(tt.name, func(t *testing.T) {
290+ var pending, calls []byte
291+ var down int
292+ state := listInputState{
293+ pending: &pending,
294+ esc: &pendingByteTimer{},
295+ moveUp: func() {},
296+ moveDown: func() { down++ },
297+ }
298+
299+ result := handleListInputLocked(nil, tt.input, state, func(b byte) listInputResult {
300+ calls = append(calls, b)
301+ return tt.custom(b)
302+ })
303+
304+ require.Equal(t, tt.wantExit, result.exit)
305+ require.Equal(t, tt.wantStop, result.stop)
306+ require.Equal(t, tt.wantAction, result.action)
307+ require.Equal(t, tt.wantCalls, calls)
308+ require.Zero(t, down, "trailing j must not be processed")
309+ })
310+ }
311+}
312+
313+func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
314+ p, release := newBlockingPTY(t)
315+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
316+ defer release()
317+
318+ require.NotPanics(t, func() { d.enterNotices(sess, ac) })
319+ out := awaitFrame(t, sends, ports.MsgOutput)
320+
321+ require.NotNil(t, ac.overlays.noticesOverlay)
322+ msg, err := ports.UnmarshalOutput(out.Payload)
323+ require.NoError(t, err)
324+ require.Contains(t, string(msg.Data), "Notifications")
325+ require.Contains(t, string(msg.Data), "No notifications")
326+}
327+
328+func TestNoticeYankPayload(t *testing.T) {
329+ at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
330+ tests := []struct {
331+ name string
332+ n domain.Notification
333+ want string
334+ }{
335+ {
336+ name: "single occurrence, no details",
337+ n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeInfo, Message: "opened pane", Time: at},
338+ want: "[2024-01-02T03:04:05Z] info pane-spawn\nopened pane\n",
339+ },
340+ {
341+ name: "coalesced count and details",
342+ n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom ← permission denied", Count: 3, Time: at},
343+ want: "[2024-01-02T03:04:05Z] error pane-spawn ×3\ncouldn't open pane\ndetails: boom ← permission denied\n",
344+ },
345+ {
346+ name: "warn severity",
347+ n: domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeWarn, Message: "selection too large", Time: at},
348+ want: "[2024-01-02T03:04:05Z] warn clipboard\nselection too large\n",
349+ },
350+ }
351+ for _, tt := range tests {
352+ t.Run(tt.name, func(t *testing.T) {
353+ require.Equal(t, tt.want, noticeYankPayload(tt.n))
354+ })
355+ }
356+}
357+
358+func TestYankLastNotificationCommandCopiesOSC52AndShowsFeedback(t *testing.T) {
359+ p, release := newBlockingPTY(t)
360+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
361+ defer release()
362+ n := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom", Time: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}
363+ d.notices.record(n)
364+ want := noticeYankPayload(n)
365+
366+ d.enterPalette(sess, ac)
367+ awaitFrame(t, sends, ports.MsgOutput)
368+ d.handlePaletteInput(ac, []byte("YLN\r"))
369+
370+ out := awaitFrame(t, sends, ports.MsgOutput)
371+ msg, err := ports.UnmarshalOutput(out.Payload)
372+ require.NoError(t, err)
373+ payload := string(msg.Data)
374+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
375+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
376+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
377+ require.NoError(t, err)
378+ require.Equal(t, want, string(decoded))
379+
380+ live := awaitFrame(t, sends, ports.MsgOutput)
381+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
382+ require.NoError(t, err)
383+ require.Contains(t, string(liveMsg.Data), "copied notification details")
384+}
385+
386+func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
387+ p, release := newBlockingPTY(t)
388+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
389+ defer release()
390+
391+ d.enterPalette(sess, ac)
392+ awaitFrame(t, sends, ports.MsgOutput)
393+ d.handlePaletteInput(ac, []byte("YLN\r"))
394+
395+ out := awaitFrame(t, sends, ports.MsgOutput)
396+ msg, err := ports.UnmarshalOutput(out.Payload)
397+ require.NoError(t, err)
398+ require.Contains(t, string(msg.Data), "no notifications yet")
399+
400+ history := d.notices.history()
401+ require.Len(t, history, 1)
402+ require.Equal(t, domain.NoticeClipboard, history[0].Code)
403+ require.Equal(t, domain.NoticeWarn, history[0].Severity)
404+ require.Equal(t, "no notifications yet", history[0].Message)
405+}
406+
407+func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
408+ p, release := newBlockingPTY(t)
409+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
410+ defer release()
411+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "first", Time: time.Unix(1, 0)})
412+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Severity: domain.NoticeError, Message: "second", Time: time.Unix(2, 0)})
413+
414+ d.enterNotices(sess, ac)
415+ awaitFrame(t, sends, ports.MsgOutput)
416+ selected, ok := ac.overlays.noticesOverlay.Selected()
417+ require.True(t, ok)
418+ require.Equal(t, "second", selected.Message)
419+ want := noticeYankPayload(selected)
420+
421+ d.handleInput(sess, ac, []byte("y"))
422+
423+ out := awaitFrame(t, sends, ports.MsgOutput)
424+ msg, err := ports.UnmarshalOutput(out.Payload)
425+ require.NoError(t, err)
426+ payload := string(msg.Data)
427+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
428+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
429+ require.NoError(t, err)
430+ require.Equal(t, want, string(decoded))
431+
432+ // The overlay stays open after a quick yank; it isn't a modal commit like
433+ // copy mode's own 'y'.
434+ require.True(t, ac.overlays.noticesActive())
435+}
436+
437+func TestNoticesBatchedYankCapturesSelectionAtY(t *testing.T) {
438+ tests := []struct {
439+ name string
440+ input []byte
441+ wantYanked string
442+ wantSelected string
443+ }{
444+ {name: "yank then down", input: []byte("yj"), wantYanked: "third", wantSelected: "second"},
445+ {name: "down then yank", input: []byte("jy"), wantYanked: "second", wantSelected: "second"},
446+ }
447+ for _, tt := range tests {
448+ t.Run(tt.name, func(t *testing.T) {
449+ p, release := newBlockingPTY(t)
450+ defer release()
451+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
452+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
453+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
454+ d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
455+
456+ d.enterNotices(sess, ac)
457+ awaitFrame(t, sends, ports.MsgOutput)
458+ d.handleInput(sess, ac, tt.input)
459+
460+ out := awaitFrame(t, sends, ports.MsgOutput)
461+ msg, err := ports.UnmarshalOutput(out.Payload)
462+ require.NoError(t, err)
463+ payload := strings.TrimSuffix(strings.TrimPrefix(string(msg.Data), "\x1b]52;c;"), "\a")
464+ decoded, err := base64.StdEncoding.DecodeString(payload)
465+ require.NoError(t, err)
466+ require.Contains(t, string(decoded), "\n"+tt.wantYanked+"\n")
467+
468+ selected, ok := ac.overlays.noticesOverlay.Selected()
469+ require.True(t, ok)
470+ require.Equal(t, tt.wantSelected, selected.Message)
471+ })
472+ }
473+}
474+
475+func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {
476+ p, release := newBlockingPTY(t)
477+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
478+ defer release()
479+
480+ require.NotPanics(t, func() { d.enterNotices(sess, ac) })
481+ awaitFrame(t, sends, ports.MsgOutput)
482+
483+ require.NotPanics(t, func() { d.handleInput(sess, ac, []byte("y")) })
484+ require.True(t, ac.overlays.noticesActive())
485+}
A internal/usecase/daemon/notify.go +403 -0
@@ -0,0 +1,403 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+const (
16+ noticeHistoryCap = 200
17+ noticePendingCap = 32
18+ // maxVisibleToasts bounds the stack drawn over a client's screen; anything
19+ // trimmed is counted in noticeOverflow and stays reachable in history.
20+ maxVisibleToasts = 3
21+)
22+
23+// errNoNeighbor reports a directional focus move with nothing to move to. It is
24+// an ordinary outcome of navigation, never a user-facing failure.
25+var errNoNeighbor = errors.New("no pane in that direction")
26+
27+// noticeCenter owns the daemon-wide notification history and the queue of
28+// global notices awaiting a first attached client. Its mu is leaf-level: no
29+// other lock is ever taken while holding it.
30+type noticeCenter struct {
31+ mu sync.Mutex
32+ // routingMu serializes global deliver-or-queue with the first-paint drain.
33+ // Attachment removal also takes it, so a client selected for a global toast
34+ // remains current until that toast is published.
35+ routingMu sync.Mutex
36+ ring []domain.Notification
37+ pending []domain.Notification
38+ // beforeQueueGlobal is a deterministic test seam after routing has observed
39+ // no attachments and before it queues the notice.
40+ beforeQueueGlobal func()
41+ // beforeGlobalDelivery is a deterministic test seam after targets have been
42+ // selected and before their toast stacks are touched.
43+ beforeGlobalDelivery func()
44+ // beforeClientNoticeMutation is a deterministic test seam after a client
45+ // notice's ownership validation and before its toast state is changed.
46+ beforeClientNoticeMutation func()
47+}
48+
49+func newNoticeCenter() *noticeCenter { return &noticeCenter{} }
50+
51+func (nc *noticeCenter) record(n domain.Notification) domain.Notification {
52+ if n.Count == 0 {
53+ n.Count = 1
54+ }
55+ nc.mu.Lock()
56+ defer nc.mu.Unlock()
57+ nc.ring = append(nc.ring, n)
58+ if len(nc.ring) > noticeHistoryCap {
59+ nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:]
60+ }
61+ return n
62+}
63+
64+func (nc *noticeCenter) history() []domain.Notification {
65+ nc.mu.Lock()
66+ defer nc.mu.Unlock()
67+ out := make([]domain.Notification, len(nc.ring))
68+ for i, n := range nc.ring {
69+ out[len(nc.ring)-1-i] = n
70+ }
71+ return out
72+}
73+
74+func (nc *noticeCenter) latest() (domain.Notification, bool) {
75+ nc.mu.Lock()
76+ defer nc.mu.Unlock()
77+ if len(nc.ring) == 0 {
78+ return domain.Notification{}, false
79+ }
80+ return nc.ring[len(nc.ring)-1], true
81+}
82+
83+func (nc *noticeCenter) queueGlobal(n domain.Notification) {
84+ if n.Count == 0 {
85+ n.Count = 1
86+ }
87+ nc.mu.Lock()
88+ defer nc.mu.Unlock()
89+ for i := range nc.pending {
90+ if nc.pending[i].Code == n.Code {
91+ nc.pending[i].Count += n.Count
92+ nc.pending[i].Time = n.Time
93+ return
94+ }
95+ }
96+ if len(nc.pending) >= noticePendingCap {
97+ return // history already has it via record(); toast is dropped
98+ }
99+ nc.pending = append(nc.pending, n)
100+}
101+
102+func (nc *noticeCenter) drainPending() []domain.Notification {
103+ nc.mu.Lock()
104+ defer nc.mu.Unlock()
105+ out := nc.pending
106+ nc.pending = nil
107+ return out
108+}
109+
110+// noticeToast is one entry in a client's visible toast stack. seq identifies the
111+// entry across coalescing so a stale expiry timer removes only its own toast.
112+type noticeToast struct {
113+ n domain.Notification
114+ seq uint64
115+ timer pendingByteTimer
116+}
117+
118+// noticeTTL is how long a toast of this severity stays visible.
119+func noticeTTL(sev domain.NoticeSeverity) time.Duration {
120+ switch sev {
121+ case domain.NoticeInfo:
122+ return 4 * time.Second
123+ case domain.NoticeWarn:
124+ return 6 * time.Second
125+ default:
126+ return 8 * time.Second
127+ }
128+}
129+
130+// slogLevelFor maps a notice severity to the slog level it should log at, so
131+// routine info notices (e.g. a successful clipboard copy) don't drown out
132+// real warnings at the default VEV_LOG=info verbosity.
133+func slogLevelFor(sev domain.NoticeSeverity) slog.Level {
134+ switch sev {
135+ case domain.NoticeInfo:
136+ return slog.LevelInfo
137+ case domain.NoticeWarn:
138+ return slog.LevelWarn
139+ default:
140+ return slog.LevelError
141+ }
142+}
143+
144+// noticeDetails renders the full Unwrap chain for the yank/history views. The
145+// toast itself never shows this — only the UserError's own message.
146+func noticeDetails(err error) string {
147+ if err == nil {
148+ return ""
149+ }
150+ var parts []string
151+ for e := err; e != nil; e = errors.Unwrap(e) {
152+ parts = append(parts, e.Error())
153+ }
154+ return strings.Join(parts, " ← ")
155+}
156+
157+// benignNoticeError reports errors that are expected control flow rather than
158+// something the user needs to be told about.
159+func benignNoticeError(err error) bool {
160+ return errors.Is(err, context.Canceled) ||
161+ errors.Is(err, errNoNeighbor) ||
162+ errors.Is(err, ports.ErrNoClipboardImage)
163+}
164+
165+// persistShutdownSnapshotFailure records a terminal snapshot failure that
166+// cannot be toasted because daemon shutdown has already begun. It belongs to
167+// notification ownership so session teardown only decides whether shutdown
168+// makes the failure non-retryable.
169+func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) {
170+ if d.noticeStore == nil {
171+ return
172+ }
173+ if err := d.noticeStore.Append(domain.Notification{
174+ Code: domain.NoticeSnapshotWrite,
175+ Severity: domain.NoticeError,
176+ Message: "session " + name + " shut down without saving terminal state",
177+ Details: noticeDetails(cause),
178+ Time: d.clock.Now(),
179+ }); err != nil {
180+ d.log.Warn("persisting shutdown notice failed", "err", err, "session", name)
181+ }
182+}
183+
184+// reportError turns any error into a user-facing notice. Unclassified errors
185+// become NoticeInternal: an error reaching here is never silently dropped
186+// unless benignNoticeError says it is routine.
187+func (d *Daemon) reportError(sess *session, err error) {
188+ if err == nil || benignNoticeError(err) {
189+ return
190+ }
191+ var ue *domain.UserError
192+ if errors.As(err, &ue) {
193+ d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)
194+ return
195+ }
196+ d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
197+}
198+
199+// notify records a notice and routes it to whoever can see it. A nil sess means
200+// daemon-global: every attached client gets it, and if none is attached the
201+// notice waits in the pending queue for the next attach.
202+//
203+// Locking: sess.mu and d.mu are only held to snapshot; showToast is always
204+// called with no daemon, session, or pane lock held. Global routing holds the
205+// notice center's routingMu across selection and publication, which serializes
206+// it with attachment detach and pending-queue drains.
207+func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
208+ n := domain.Notification{
209+ Code: code,
210+ Severity: sev,
211+ Message: msg,
212+ Details: noticeDetails(cause),
213+ Time: d.clock.Now(),
214+ }
215+ if sess != nil {
216+ n.SessionID = sess.id
217+ }
218+ n = d.notices.record(n)
219+ d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
220+
221+ if sess != nil {
222+ sess.mu.Lock()
223+ ac := sess.client
224+ sess.mu.Unlock()
225+ if ac != nil {
226+ d.showToast(ac, n)
227+ }
228+ return
229+ }
230+
231+ d.deliverGlobal(n)
232+}
233+
234+// deliverGlobal atomically selects all current attachments and either publishes
235+// to all of them or queues the notice. routingMu is deliberately held through
236+// showToast, but never with d.mu or sess.mu held: detach cannot turn a selected
237+// target stale between selection and publication, and firstPaint cannot drain
238+// before an unattached route has queued its notice.
239+func (d *Daemon) deliverGlobal(n domain.Notification) {
240+ d.mu.Lock()
241+ d.notices.routingMu.Lock()
242+ sessions := make([]*session, 0, len(d.sessions))
243+ for _, s := range d.sessions {
244+ sessions = append(sessions, s)
245+ }
246+ d.mu.Unlock()
247+ defer d.notices.routingMu.Unlock()
248+
249+ if d.notices.beforeGlobalDelivery != nil {
250+ d.notices.beforeGlobalDelivery()
251+ }
252+ delivered := false
253+ for _, s := range sessions {
254+ s.mu.Lock()
255+ ac := s.client
256+ s.mu.Unlock()
257+ if ac == nil {
258+ continue
259+ }
260+ d.showToast(ac, n)
261+ delivered = true
262+ }
263+ if !delivered {
264+ if d.notices.beforeQueueGlobal != nil {
265+ d.notices.beforeQueueGlobal()
266+ }
267+ d.notices.queueGlobal(n)
268+ }
269+}
270+
271+// drainPendingForFirstPaint publishes pending globals only while ac remains the
272+// session's current attachment. It shares routingMu with deliverGlobal, so an
273+// unattached global route cannot queue after this drain has already happened.
274+func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
275+ d.notices.routingMu.Lock()
276+ defer d.notices.routingMu.Unlock()
277+
278+ sess.mu.Lock()
279+ current := sess.client == ac
280+ sess.mu.Unlock()
281+ if !current {
282+ return
283+ }
284+ for _, n := range d.notices.drainPending() {
285+ d.showToast(ac, n)
286+ }
287+}
288+
289+// NotifyGlobal raises a daemon-wide notice from outside the daemon package.
290+func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
291+ d.notify(nil, sev, code, msg, cause)
292+}
293+
294+// showToast publishes a notice into one client's toast stack. An identical
295+// code within the same scope coalesces into the existing entry and restarts its
296+// TTL; otherwise the notice is prepended and the stack trimmed. The repaint is
297+// issued after noticeMu is released.
298+func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
299+ if ac == nil {
300+ return
301+ }
302+ ac.initOverlays()
303+ rt := ac.overlays
304+
305+ rt.noticeMu.Lock()
306+ if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {
307+ rt.noticeToasts[i].n.Count += n.Count
308+ rt.noticeToasts[i].n.Time = n.Time
309+ rt.noticeToasts[i].n.Details = n.Details
310+ rt.noticeToasts[i].timer.stop()
311+ d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
312+ rt.noticeMu.Unlock()
313+ d.repaintForNotice(ac)
314+ return
315+ }
316+
317+ toast := noticeToast{n: n}
318+ rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...)
319+ if len(rt.noticeToasts) > maxVisibleToasts {
320+ for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ {
321+ rt.noticeToasts[i].timer.stop()
322+ rt.noticeOverflow++
323+ }
324+ rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
325+ }
326+ d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
327+ rt.noticeMu.Unlock()
328+ d.repaintForNotice(ac)
329+}
330+
331+// indexOfToastLocked finds a visible toast with the same code and scope.
332+// Callers must hold noticeMu.
333+func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int {
334+ for i := range rt.noticeToasts {
335+ if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid {
336+ return i
337+ }
338+ }
339+ return -1
340+}
341+
342+// dismissToast removes only the specified visible toast. In particular, a
343+// link-connected event must not clear clipboard or other notice state.
344+func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) {
345+ if ac == nil || ac.overlays == nil {
346+ return
347+ }
348+ rt := ac.overlays
349+ rt.noticeMu.Lock()
350+ i := rt.indexOfToastLocked(code, sid)
351+ if i < 0 {
352+ rt.noticeMu.Unlock()
353+ return
354+ }
355+ rt.noticeToasts[i].timer.stop()
356+ rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...)
357+ if len(rt.noticeToasts) == 0 {
358+ rt.noticeOverflow = 0
359+ }
360+ rt.noticeMu.Unlock()
361+ d.repaintForNotice(ac)
362+}
363+
364+// retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
365+// timer goroutine re-acquires it and releases it before repainting, so the
366+// callback never runs with noticeMu held.
367+func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
368+ rt := ac.overlays
369+ rt.noticeSeq++
370+ t.seq = rt.noticeSeq
371+ seq := t.seq
372+ t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) {
373+ rt.noticeMu.Lock()
374+ kept := rt.noticeToasts[:0]
375+ for _, tt := range rt.noticeToasts {
376+ if tt.seq == seq {
377+ continue
378+ }
379+ kept = append(kept, tt)
380+ }
381+ rt.noticeToasts = kept
382+ if len(rt.noticeToasts) == 0 {
383+ rt.noticeOverflow = 0
384+ }
385+ rt.noticeMu.Unlock()
386+ d.repaintForNotice(ac)
387+ })
388+}
389+
390+// repaintForNotice asks for an urgent redraw. It must be called with noticeMu
391+// released: invalidateRender can paint inline, which takes sendMu.
392+func (d *Daemon) repaintForNotice(ac *attachedClient) {
393+ sess := ac.currentSession()
394+ if sess == nil {
395+ // Mid-handoff or torn down: whoever attaches next repaints anyway, and
396+ // invalidateRender cannot paint without a session.
397+ return
398+ }
399+ // Toast footprints carry their own old/new damage, so a normal notice
400+ // transition can remain incremental. First paint and transport recovery
401+ // still provide their independent reset invariants.
402+ d.invalidateRender(sess, ac, false, "notify.go")
403+}
A internal/usecase/daemon/notify_test.go +909 -0
@@ -0,0 +1,909 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+ "sync"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/pkg/vt"
19+)
20+
21+func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
22+ d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
23+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
24+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardTooLarge})
25+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})
26+
27+ toasts, _ := visibleToasts(ac)
28+ require.Len(t, toasts, 3)
29+ require.Equal(t, []domain.Notification{
30+ {Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", SessionID: sess.id, Count: 1},
31+ {Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", SessionID: sess.id, Count: 1},
32+ {Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", SessionID: sess.id, Count: 1},
33+ }, stripNoticeTimes(toasts))
34+
35+ d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})
36+ toasts, _ = visibleToasts(ac)
37+ require.Len(t, toasts, 2)
38+ for _, toast := range toasts {
39+ require.NotEqual(t, domain.NoticeConnection, toast.Code)
40+ }
41+}
42+
43+func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
44+ d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
45+
46+ enteredMutation := make(chan struct{})
47+ releaseMutation := make(chan struct{})
48+ d.notices.beforeClientNoticeMutation = func() {
49+ close(enteredMutation)
50+ <-releaseMutation
51+ }
52+ t.Cleanup(func() {
53+ select {
54+ case <-releaseMutation:
55+ default:
56+ close(releaseMutation)
57+ }
58+ })
59+
60+ noticeDone := make(chan struct{})
61+ go func() {
62+ d.handleClientNotice(sess, old, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
63+ close(noticeDone)
64+ }()
65+ <-enteredMutation // old has validated ownership and holds routingMu
66+ if d.notices.routingMu.TryLock() {
67+ d.notices.routingMu.Unlock()
68+ t.Fatal("client notice mutation must retain routingMu through publication")
69+ }
70+
71+ tr, _ := newCapturingTransport(t)
72+ replaced := make(chan *attachedClient, 1)
73+ go func() {
74+ current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
75+ replaced <- current
76+ }()
77+
78+ // Releasing the mutation lets the already-validated notice publish before
79+ // replacement can own the session. Thus no stale attachment can mutate a
80+ // toast stack after sess.client changes.
81+ close(releaseMutation)
82+ <-noticeDone
83+ current := <-replaced
84+
85+ history := d.notices.history()
86+ require.Len(t, history, 1)
87+ require.Equal(t, domain.NoticeClipboard, history[0].Code)
88+ oldToasts, _ := visibleToasts(old)
89+ require.Len(t, oldToasts, 1)
90+ require.Equal(t, domain.NoticeClipboard, oldToasts[0].Code)
91+ newToasts, _ := visibleToasts(current)
92+ require.Empty(t, newToasts)
93+ sess.mu.Lock()
94+ require.Same(t, current, sess.client)
95+ sess.mu.Unlock()
96+}
97+
98+func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
99+ for _, tt := range []struct {
100+ name string
101+ setup func(*Daemon, *attachedClient, domain.SessionID)
102+ notice ports.ClientNotice
103+ wantToast domain.NoticeCode
104+ }{
105+ {
106+ name: "record",
107+ notice: ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback},
108+ },
109+ {
110+ name: "dismiss",
111+ setup: func(d *Daemon, ac *attachedClient, sid domain.SessionID) {
112+ d.showToast(ac, notice(domain.NoticeConnection, "connection degraded", sid))
113+ },
114+ notice: ports.ClientNotice{Action: ports.ClientNoticeLinkConnected},
115+ wantToast: domain.NoticeConnection,
116+ },
117+ } {
118+ t.Run(tt.name, func(t *testing.T) {
119+ d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
120+ tr, _ := newCapturingTransport(t)
121+ current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
122+ if tt.setup != nil {
123+ tt.setup(d, old, sess.id)
124+ }
125+
126+ d.handleClientNotice(sess, old, tt.notice)
127+
128+ require.Empty(t, d.notices.history(), "a replaced client must not record notices")
129+ oldToasts, _ := visibleToasts(old)
130+ if tt.setup == nil {
131+ require.Empty(t, oldToasts)
132+ } else {
133+ require.Len(t, oldToasts, 1)
134+ require.Equal(t, tt.wantToast, oldToasts[0].Code, "a replaced client must not dismiss toasts")
135+ }
136+ currentToasts, _ := visibleToasts(current)
137+ require.Empty(t, currentToasts)
138+ })
139+ }
140+}
141+
142+func TestMalformedClientNoticeIsIgnored(t *testing.T) {
143+ d, _, ac, _ := newNoticeFixture(t, &noticeClock{})
144+ tr, ok := ac.tr.(*portsmocks.MockTransport)
145+ require.True(t, ok, "attached client transport must be a MockTransport")
146+ frames := []ports.Frame{
147+ {Type: ports.MsgClientNotice, Payload: []byte{0xff}},
148+ {Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
149+ }
150+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
151+ frame := frames[0]
152+ frames = frames[1:]
153+ return frame, nil
154+ }).Times(2)
155+ d.runConnLoop(ac)
156+ toasts, _ := visibleToasts(ac)
157+ require.Empty(t, toasts)
158+ require.Empty(t, d.notices.history())
159+}
160+
161+func stripNoticeTimes(notices []domain.Notification) []domain.Notification {
162+ out := append([]domain.Notification(nil), notices...)
163+ for i := range out {
164+ out[i].Time = time.Time{}
165+ }
166+ return out
167+}
168+
169+func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
170+ return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid}
171+}
172+
173+func TestNoticeCenterRingEviction(t *testing.T) {
174+ nc := newNoticeCenter()
175+ for i := 0; i < 205; i++ {
176+ nc.record(notice(domain.NoticeInternal, fmt.Sprintf("n%d", i), ""))
177+ }
178+ h := nc.history()
179+ if len(h) != 200 {
180+ t.Fatalf("history len = %d, want 200", len(h))
181+ }
182+ if h[0].Message != "n204" {
183+ t.Fatalf("history[0] = %q, want n204 (newest insert)", h[0].Message)
184+ }
185+ if h[199].Message != "n5" {
186+ t.Fatalf("history[199] = %q, want n5 (oldest retained after eviction)", h[199].Message)
187+ }
188+ // Every slot must hold exactly the expected message: strictly newest-first,
189+ // no gaps, and none of the evicted n0..n4 leaked back in.
190+ for i, n := range h {
191+ want := fmt.Sprintf("n%d", 204-i)
192+ if n.Message != want {
193+ t.Fatalf("history[%d] = %q, want %q", i, n.Message, want)
194+ }
195+ }
196+}
197+
198+func TestNoticeCenterHistoryNewestFirst(t *testing.T) {
199+ nc := newNoticeCenter()
200+ nc.record(notice(domain.NoticePaneSpawn, "first", ""))
201+ nc.record(notice(domain.NoticeTabSpawn, "second", ""))
202+ h := nc.history()
203+ if h[0].Message != "second" || h[1].Message != "first" {
204+ t.Fatalf("history order = %q,%q; want second,first", h[0].Message, h[1].Message)
205+ }
206+ last, ok := nc.latest()
207+ if !ok || last.Message != "second" {
208+ t.Fatalf("latest = %v,%v", last.Message, ok)
209+ }
210+}
211+
212+func TestNoticeCenterPendingQueue(t *testing.T) {
213+ t.Run("dedup coalesces by code without growing the queue", func(t *testing.T) {
214+ nc := newNoticeCenter()
215+ for i := 0; i < 40; i++ {
216+ nc.queueGlobal(notice(domain.NoticeSnapshotRestore, "restore failed", ""))
217+ }
218+ nc.queueGlobal(notice(domain.NoticePersistDisabled, "persistence disabled", ""))
219+ pending := nc.drainPending()
220+ if len(pending) != 2 {
221+ t.Fatalf("pending len = %d, want 2 (deduped by code)", len(pending))
222+ }
223+ if pending[0].Count != 40 {
224+ t.Fatalf("coalesced Count = %d, want 40", pending[0].Count)
225+ }
226+ if got := nc.drainPending(); len(got) != 0 {
227+ t.Fatalf("second drain len = %d, want 0", len(got))
228+ }
229+ })
230+
231+ t.Run("cap overflow drops the 33rd distinct code", func(t *testing.T) {
232+ nc := newNoticeCenter()
233+ for i := 0; i < 33; i++ {
234+ nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), ""))
235+ }
236+ pending := nc.drainPending()
237+ if len(pending) != 32 {
238+ t.Fatalf("pending len = %d, want 32 (bounded at cap)", len(pending))
239+ }
240+ for i, n := range pending {
241+ if n.Code != domain.NoticeCode(i) {
242+ t.Fatalf("pending[%d].Code = %v, want %v (first 32 distinct codes retained, in order)", i, n.Code, domain.NoticeCode(i))
243+ }
244+ }
245+ for _, n := range pending {
246+ if n.Code == domain.NoticeCode(32) {
247+ t.Fatalf("pending contains code %v, which should have been dropped on overflow", domain.NoticeCode(32))
248+ }
249+ }
250+ })
251+
252+ t.Run("dedup on an already-queued code still increments Count at the cap", func(t *testing.T) {
253+ nc := newNoticeCenter()
254+ for i := 0; i < 32; i++ {
255+ nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), ""))
256+ }
257+ // The queue is now full. Re-queuing an existing code must coalesce
258+ // in place, not hit the overflow-drop branch.
259+ nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", ""))
260+ nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", ""))
261+ pending := nc.drainPending()
262+ if len(pending) != 32 {
263+ t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending))
264+ }
265+ if pending[0].Code != domain.NoticeCode(0) || pending[0].Count != 3 {
266+ t.Fatalf("pending[0] = {Code:%v Count:%d}, want {Code:%v Count:3}", pending[0].Code, pending[0].Count, domain.NoticeCode(0))
267+ }
268+ })
269+}
270+
271+// --- Task 3: routing, per-client toast state, attach drain ------------------
272+
273+// noticeClock hands out timers the test fires explicitly by advancing time.
274+// Toast TTLs are asserted from the durations recorded here, so the tests never
275+// depend on wall-clock progress.
276+type noticeClock struct {
277+ mu sync.Mutex
278+ now time.Time
279+ timers []*noticeTimer
280+}
281+
282+type noticeTimer struct {
283+ ch chan time.Time
284+ duration time.Duration
285+ deadline time.Time
286+ stopped atomic.Bool
287+}
288+
289+func newNoticeClock() *noticeClock {
290+ return &noticeClock{now: time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)}
291+}
292+
293+func (c *noticeClock) Now() time.Time {
294+ c.mu.Lock()
295+ defer c.mu.Unlock()
296+ return c.now
297+}
298+
299+func (c *noticeClock) NewTimer(d time.Duration) ports.Timer {
300+ c.mu.Lock()
301+ defer c.mu.Unlock()
302+ t := &noticeTimer{ch: make(chan time.Time, 1), duration: d, deadline: c.now.Add(d)}
303+ c.timers = append(c.timers, t)
304+ return t
305+}
306+
307+func (t *noticeTimer) C() <-chan time.Time { return t.ch }
308+
309+func (t *noticeTimer) Reset(time.Duration) bool { return false }
310+
311+func (t *noticeTimer) Stop() bool {
312+ t.stopped.Store(true)
313+ return true
314+}
315+
316+// advance moves the clock forward and fires every live timer that came due.
317+// Sends are non-blocking on a buffered channel, so no timer goroutine can wedge
318+// the test goroutine.
319+func (c *noticeClock) advance(d time.Duration) {
320+ c.mu.Lock()
321+ c.now = c.now.Add(d)
322+ due := make([]*noticeTimer, 0, len(c.timers))
323+ kept := make([]*noticeTimer, 0, len(c.timers))
324+ for _, t := range c.timers {
325+ if !t.stopped.Load() && !t.deadline.After(c.now) {
326+ t.stopped.Store(true)
327+ due = append(due, t)
328+ continue
329+ }
330+ kept = append(kept, t)
331+ }
332+ c.timers = kept
333+ now := c.now
334+ c.mu.Unlock()
335+ for _, t := range due {
336+ select {
337+ case t.ch <- now:
338+ default:
339+ }
340+ }
341+}
342+
343+// durations returns every TTL requested so far, in creation order.
344+func (c *noticeClock) durations() []time.Duration {
345+ c.mu.Lock()
346+ defer c.mu.Unlock()
347+ out := make([]time.Duration, 0, len(c.timers))
348+ for _, t := range c.timers {
349+ out = append(out, t.duration)
350+ }
351+ return out
352+}
353+
354+// newNoticeFixture builds a manual session owned by one attached client, with a
355+// caller-supplied clock so toast TTLs are deterministic. The session has no
356+// render coordinator, so invalidateRender paints directly and every repaint is
357+// observable as a frame on the returned channel.
358+func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attachedClient, chan ports.Frame) {
359+ t.Helper()
360+ p, _ := newBlockingPTY(t)
361+ d := newTestDaemon(t, nil, clk)
362+ tr, sends := newCapturingTransport(t)
363+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
364+ ac.initOverlays()
365+ sctx, cancel := context.WithCancel(d.serveCtx)
366+ wctx, wcancel := context.WithCancel(sctx)
367+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
368+ tb.ctx, tb.cancel = wctx, wcancel
369+ for _, pane := range tb.panes {
370+ pane.ctx, pane.cancel = wctx, wcancel
371+ }
372+ sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac}
373+ ac.setSession(sess)
374+ d.sessions[sess.id] = sess
375+ t.Cleanup(cancel)
376+ return d, sess, ac, sends
377+}
378+
379+// visibleToasts copies the client's toast state under noticeMu.
380+func visibleToasts(ac *attachedClient) ([]domain.Notification, int) {
381+ rt := ac.overlays
382+ rt.noticeMu.Lock()
383+ defer rt.noticeMu.Unlock()
384+ out := make([]domain.Notification, 0, len(rt.noticeToasts))
385+ for _, toast := range rt.noticeToasts {
386+ out = append(out, toast.n)
387+ }
388+ return out, rt.noticeOverflow
389+}
390+
391+// awaitToastCount waits for the toast slice to reach want. The polled condition
392+// only reads state; assertions stay on the test goroutine.
393+func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notification {
394+ t.Helper()
395+ require.Eventually(t, func() bool {
396+ ns, _ := visibleToasts(ac)
397+ return len(ns) == want
398+ }, 2*time.Second, time.Millisecond, "toast count never reached %d", want)
399+ got, _ := visibleToasts(ac)
400+ return got
401+}
402+
403+func TestReportErrorUserErrorBecomesNotification(t *testing.T) {
404+ tests := []struct {
405+ name string
406+ err *domain.UserError
407+ wantSev domain.NoticeSeverity
408+ }{
409+ {
410+ name: "error severity",
411+ err: domain.UserErr(domain.NoticePaneSpawn, "could not open pane", errors.New("fork/exec: no such file")),
412+ wantSev: domain.NoticeError,
413+ },
414+ {
415+ name: "warn severity",
416+ err: domain.UserWarn(domain.NoticeConfigReload, "config reload skipped", errors.New("parse: line 4")),
417+ wantSev: domain.NoticeWarn,
418+ },
419+ }
420+ for _, tc := range tests {
421+ t.Run(tc.name, func(t *testing.T) {
422+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
423+
424+ d.reportError(sess, tc.err)
425+
426+ hist := d.notices.history()
427+ require.Len(t, hist, 1)
428+ require.Equal(t, tc.err.Code, hist[0].Code)
429+ require.Equal(t, tc.wantSev, hist[0].Severity)
430+ require.Equal(t, tc.err.Msg, hist[0].Message)
431+ require.Equal(t, sess.id, hist[0].SessionID)
432+ require.Equal(t, 1, hist[0].Count)
433+ // Details carry the cause chain; the message never does.
434+ require.Contains(t, hist[0].Details, tc.err.Err.Error())
435+ require.NotContains(t, hist[0].Message, tc.err.Err.Error())
436+
437+ toasts := awaitToastCount(t, ac, 1)
438+ require.Equal(t, tc.err.Msg, toasts[0].Message)
439+ require.Equal(t, tc.err.Code, toasts[0].Code)
440+ })
441+ }
442+}
443+
444+func TestReportErrorDetailsJoinUnwrapChain(t *testing.T) {
445+ d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
446+ root := errors.New("permission denied")
447+ mid := fmt.Errorf("open /dev/pts/3: %w", root)
448+
449+ d.reportError(sess, domain.UserErr(domain.NoticePaneSpawn, "could not open pane", mid))
450+
451+ hist := d.notices.history()
452+ require.Len(t, hist, 1)
453+ require.Equal(t, mid.Error()+" ← "+root.Error(), hist[0].Details)
454+}
455+
456+func TestReportErrorUnknownErrorBecomesInternal(t *testing.T) {
457+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
458+ raw := errors.New("something unclassified broke")
459+
460+ d.reportError(sess, raw)
461+
462+ hist := d.notices.history()
463+ require.Len(t, hist, 1)
464+ require.Equal(t, domain.NoticeInternal, hist[0].Code)
465+ require.Equal(t, domain.NoticeError, hist[0].Severity)
466+ require.Equal(t, "internal error", hist[0].Message)
467+ require.Equal(t, raw.Error(), hist[0].Details)
468+
469+ toasts := awaitToastCount(t, ac, 1)
470+ require.Equal(t, "internal error", toasts[0].Message)
471+}
472+
473+func TestReportErrorBenignFiltered(t *testing.T) {
474+ tests := []struct {
475+ name string
476+ err error
477+ }{
478+ {name: "nil", err: nil},
479+ {name: "context canceled", err: context.Canceled},
480+ {name: "wrapped context canceled", err: fmt.Errorf("pane read: %w", context.Canceled)},
481+ {name: "no neighbor", err: errNoNeighbor},
482+ {name: "wrapped no neighbor", err: fmt.Errorf("focus: %w", errNoNeighbor)},
483+ {name: "no clipboard image", err: ports.ErrNoClipboardImage},
484+ {name: "wrapped no clipboard image", err: fmt.Errorf("yank: %w", ports.ErrNoClipboardImage)},
485+ }
486+ for _, tc := range tests {
487+ t.Run(tc.name, func(t *testing.T) {
488+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
489+
490+ d.reportError(sess, tc.err)
491+
492+ require.Empty(t, d.notices.history())
493+ toasts, _ := visibleToasts(ac)
494+ require.Empty(t, toasts)
495+ })
496+ }
497+}
498+
499+// TestReportErrorNonBenignIsNeverDropped guards the inverse of the filter: an
500+// error that merely reads like a sentinel must still reach history.
501+func TestReportErrorNonBenignIsNeverDropped(t *testing.T) {
502+ d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
503+
504+ d.reportError(sess, errors.New("no pane in that direction"))
505+
506+ require.Len(t, d.notices.history(), 1, "a look-alike error must not be filtered by message text")
507+}
508+
509+func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
510+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
511+
512+ other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
513+ other.initOverlays()
514+ sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: other}
515+ other.setSession(sess2)
516+ d.mu.Lock()
517+ d.sessions[sess2.id] = sess2
518+ d.mu.Unlock()
519+
520+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
521+
522+ toasts := awaitToastCount(t, ac, 1)
523+ require.Equal(t, sess.id, toasts[0].SessionID)
524+ otherToasts, _ := visibleToasts(other)
525+ require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")
526+}
527+
528+func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) {
529+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
530+ sess.mu.Lock()
531+ sess.client = nil
532+ sess.mu.Unlock()
533+
534+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
535+
536+ require.Len(t, d.notices.history(), 1)
537+ toasts, _ := visibleToasts(ac)
538+ require.Empty(t, toasts, "a detached session must not paint a toast")
539+ require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals")
540+}
541+
542+func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
543+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
544+
545+ second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
546+ second.initOverlays()
547+ sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second}
548+ second.setSession(sess2)
549+ d.mu.Lock()
550+ d.sessions[sess2.id] = sess2
551+ d.mu.Unlock()
552+
553+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
554+
555+ first := awaitToastCount(t, ac, 1)
556+ require.Equal(t, domain.SessionID(""), first[0].SessionID, "global notices carry an empty session id")
557+ secondToasts := awaitToastCount(t, second, 1)
558+ require.Equal(t, domain.NoticeConfigReload, secondToasts[0].Code)
559+ require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued")
560+}
561+
562+func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
563+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
564+ sess.mu.Lock()
565+ sess.client = nil
566+ sess.mu.Unlock()
567+
568+ routeObserved := make(chan struct{})
569+ releaseRoute := make(chan struct{})
570+ d.notices.beforeQueueGlobal = func() {
571+ close(routeObserved)
572+ <-releaseRoute
573+ }
574+ globalDone := make(chan struct{})
575+ go func() {
576+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
577+ close(globalDone)
578+ }()
579+ <-routeObserved // global routing has observed the detached session
580+
581+ // Publish the attachment and start firstPaint while global routing still
582+ // owns its gate. firstPaint must wait for the queue, then drain it.
583+ sess.mu.Lock()
584+ sess.client = ac
585+ sess.mu.Unlock()
586+ paintDone := make(chan struct{})
587+ go func() {
588+ d.firstPaint(sess, ac, domain.Size{})
589+ close(paintDone)
590+ }()
591+
592+ close(releaseRoute)
593+ <-globalDone
594+ <-paintDone
595+ toasts, _ := visibleToasts(ac)
596+ require.Len(t, toasts, 1)
597+ require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
598+ require.Empty(t, d.notices.drainPending())
599+}
600+
601+func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) {
602+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
603+
604+ routeObserved := make(chan struct{})
605+ releaseRoute := make(chan struct{})
606+ d.notices.beforeGlobalDelivery = func() {
607+ close(routeObserved)
608+ <-releaseRoute
609+ }
610+ globalDone := make(chan struct{})
611+ go func() {
612+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
613+ close(globalDone)
614+ }()
615+ <-routeObserved // ac is selected and routingMu is held
616+
617+ detached := make(chan bool, 1)
618+ go func() { detached <- d.detachIfCurrent(sess, ac) }()
619+ close(releaseRoute)
620+ <-globalDone
621+ require.True(t, <-detached)
622+
623+ // The selected attachment received the toast before it could become stale.
624+ toasts, _ := visibleToasts(ac)
625+ require.Len(t, toasts, 1)
626+ sess.mu.Lock()
627+ require.Nil(t, sess.client)
628+ sess.mu.Unlock()
629+}
630+
631+func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
632+ d, sess, old, _ := newNoticeFixture(t, newNoticeClock())
633+
634+ routeObserved := make(chan struct{})
635+ releaseRoute := make(chan struct{})
636+ d.notices.beforeGlobalDelivery = func() {
637+ close(routeObserved)
638+ <-releaseRoute
639+ }
640+ globalDone := make(chan struct{})
641+ go func() {
642+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
643+ close(globalDone)
644+ }()
645+ <-routeObserved // old is selected and routingMu is held
646+
647+ tr, _ := newCapturingTransport(t)
648+ replaced := make(chan *attachedClient, 1)
649+ go func() {
650+ ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
651+ replaced <- ac
652+ }()
653+ close(releaseRoute)
654+ <-globalDone
655+ current := <-replaced
656+
657+ // Replacement cannot make old a stale delivery target: it publishes only
658+ // after the selected attachment has received the global toast.
659+ oldToasts, _ := visibleToasts(old)
660+ require.Len(t, oldToasts, 1)
661+ newToasts, _ := visibleToasts(current)
662+ require.Empty(t, newToasts)
663+ sess.mu.Lock()
664+ require.Same(t, current, sess.client)
665+ sess.mu.Unlock()
666+}
667+
668+func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
669+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
670+ sess.mu.Lock()
671+ sess.client = nil
672+ sess.mu.Unlock()
673+
674+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
675+
676+ require.Len(t, d.notices.history(), 1)
677+ toasts, _ := visibleToasts(ac)
678+ require.Empty(t, toasts, "no attached client means nothing to paint yet")
679+
680+ // Re-attach and let firstPaint drain the pending queue.
681+ sess.mu.Lock()
682+ sess.client = ac
683+ sess.mu.Unlock()
684+ d.firstPaint(sess, ac, domain.Size{})
685+
686+ drained := awaitToastCount(t, ac, 1)
687+ require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code)
688+ require.Equal(t, "snapshot write failed", drained[0].Message)
689+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
690+}
691+
692+// TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open
693+// failure contract (Task 13): app/run.go queues this exact code and message
694+// via NotifyGlobal before any client has attached, and the first attach must
695+// see it via firstPaint's pending-queue drain.
696+func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
697+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
698+ sess.mu.Lock()
699+ sess.client = nil
700+ sess.mu.Unlock()
701+
702+ cause := errors.New("open store.db: permission denied")
703+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
704+ "session persistence is disabled; sessions will not survive daemon restarts", cause)
705+
706+ require.Len(t, d.notices.history(), 1)
707+ toasts, _ := visibleToasts(ac)
708+ require.Empty(t, toasts, "no attached client means nothing to paint yet")
709+
710+ // Re-attach and let firstPaint drain the pending queue.
711+ sess.mu.Lock()
712+ sess.client = ac
713+ sess.mu.Unlock()
714+ d.firstPaint(sess, ac, domain.Size{})
715+
716+ drained := awaitToastCount(t, ac, 1)
717+ require.Equal(t, domain.NoticePersistDisabled, drained[0].Code)
718+ require.Equal(t, domain.NoticeWarn, drained[0].Severity)
719+ require.Equal(t, "session persistence is disabled; sessions will not survive daemon restarts", drained[0].Message)
720+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
721+}
722+
723+// levelCaptureHandler records the slog.Level of every record it's handed, so
724+// tests can assert on log verbosity without parsing formatted output.
725+type levelCaptureHandler struct {
726+ mu sync.Mutex
727+ levels []slog.Level
728+}
729+
730+func (h *levelCaptureHandler) Enabled(context.Context, slog.Level) bool { return true }
731+
732+func (h *levelCaptureHandler) Handle(_ context.Context, r slog.Record) error {
733+ h.mu.Lock()
734+ defer h.mu.Unlock()
735+ h.levels = append(h.levels, r.Level)
736+ return nil
737+}
738+
739+func (h *levelCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
740+func (h *levelCaptureHandler) WithGroup(string) slog.Handler { return h }
741+
742+func TestNotifyLogsAtSeverityLevel(t *testing.T) {
743+ tests := []struct {
744+ name string
745+ sev domain.NoticeSeverity
746+ want slog.Level
747+ }{
748+ {"info severity logs at info", domain.NoticeInfo, slog.LevelInfo},
749+ {"warn severity logs at warn", domain.NoticeWarn, slog.LevelWarn},
750+ {"error severity logs at error", domain.NoticeError, slog.LevelError},
751+ }
752+ for _, tt := range tests {
753+ t.Run(tt.name, func(t *testing.T) {
754+ d := newTestDaemon(t, nil, newNoticeClock())
755+ capture := &levelCaptureHandler{}
756+ d.log = slog.New(capture)
757+
758+ d.notify(nil, tt.sev, domain.NoticeClipboard, "msg", nil)
759+
760+ require.Equal(t, []slog.Level{tt.want}, capture.levels)
761+ })
762+ }
763+}
764+
765+func TestShowToastCoalesceAndTrim(t *testing.T) {
766+ t.Run("same code and scope coalesces", func(t *testing.T) {
767+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
768+
769+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
770+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
771+
772+ toasts := awaitToastCount(t, ac, 1)
773+ require.Equal(t, 2, toasts[0].Count)
774+ _, overflow := visibleToasts(ac)
775+ require.Zero(t, overflow)
776+ require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")
777+ })
778+
779+ t.Run("same code different scope does not coalesce", func(t *testing.T) {
780+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
781+
782+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
783+ d.notify(nil, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
784+
785+ toasts := awaitToastCount(t, ac, 2)
786+ require.Equal(t, 1, toasts[0].Count)
787+ require.Equal(t, 1, toasts[1].Count)
788+ })
789+
790+ t.Run("four distinct codes trim to three newest first", func(t *testing.T) {
791+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
792+ codes := []domain.NoticeCode{
793+ domain.NoticePaneSpawn,
794+ domain.NoticeTabSpawn,
795+ domain.NoticeSnapshotWrite,
796+ domain.NoticeClipboard,
797+ }
798+ for _, c := range codes {
799+ d.notify(sess, domain.NoticeError, c, c.String(), nil)
800+ }
801+
802+ toasts := awaitToastCount(t, ac, maxVisibleToasts)
803+ require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{
804+ toasts[0].Code, toasts[1].Code, toasts[2].Code,
805+ }, "newest first, oldest evicted")
806+ _, overflow := visibleToasts(ac)
807+ require.Equal(t, 1, overflow)
808+ })
809+}
810+
811+func TestNoticeTTLPerSeverity(t *testing.T) {
812+ tests := []struct {
813+ name string
814+ sev domain.NoticeSeverity
815+ want time.Duration
816+ }{
817+ {name: "info", sev: domain.NoticeInfo, want: 4 * time.Second},
818+ {name: "warn", sev: domain.NoticeWarn, want: 6 * time.Second},
819+ {name: "error", sev: domain.NoticeError, want: 8 * time.Second},
820+ }
821+ for _, tc := range tests {
822+ t.Run(tc.name, func(t *testing.T) {
823+ require.Equal(t, tc.want, noticeTTL(tc.sev))
824+
825+ clk := newNoticeClock()
826+ d, sess, ac, _ := newNoticeFixture(t, clk)
827+ d.notify(sess, tc.sev, domain.NoticeClipboard, "msg", nil)
828+ awaitToastCount(t, ac, 1)
829+ require.Equal(t, []time.Duration{tc.want}, clk.durations(), "the retained toast timer uses the severity TTL")
830+ })
831+ }
832+}
833+
834+func TestToastExpiresOnFakeClock(t *testing.T) {
835+ clk := newNoticeClock()
836+ d, sess, ac, sends := newNoticeFixture(t, clk)
837+
838+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
839+ awaitToastCount(t, ac, 1)
840+ shown := awaitFrame(t, sends, ports.MsgOutput)
841+ shownOutput, err := ports.UnmarshalOutput(shown.Payload)
842+ require.NoError(t, err)
843+ terminal := vt.NewScreen(80, 25)
844+ terminal.Write(shownOutput.Data)
845+
846+ // Short of the 8s error TTL nothing expires.
847+ clk.advance(7 * time.Second)
848+ toasts, _ := visibleToasts(ac)
849+ require.Len(t, toasts, 1, "toast must survive until its TTL elapses")
850+
851+ clk.advance(2 * time.Second)
852+ awaitToastCount(t, ac, 0)
853+ _, overflow := visibleToasts(ac)
854+ require.Zero(t, overflow, "overflow resets once no toast is visible")
855+
856+ expired := awaitFrame(t, sends, ports.MsgOutput)
857+ expiredOutput, err := ports.UnmarshalOutput(expired.Payload)
858+ require.NoError(t, err)
859+ terminal.Write(expiredOutput.Data)
860+ ac.sendMu.Lock()
861+ base := ac.pipelineCache.frame.Clone()
862+ ac.sendMu.Unlock()
863+ require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell")
864+}
865+
866+// TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
867+// toast that outlived it.
868+func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) {
869+ clk := newNoticeClock()
870+ d, sess, ac, _ := newNoticeFixture(t, clk)
871+
872+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) // 4s TTL
873+ awaitToastCount(t, ac, 1)
874+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "boom", nil) // 8s TTL
875+ awaitToastCount(t, ac, 2)
876+
877+ clk.advance(5 * time.Second)
878+
879+ toasts := awaitToastCount(t, ac, 1)
880+ require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed")
881+}
882+
883+// TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an
884+// expiry timer identifies its toast by entry sequence, not by code and scope.
885+// Coalescing re-arms the entry under a fresh sequence, so a timer that was
886+// stopped but had already slipped past its select cannot dismiss the refreshed
887+// toast that replaced the one it belonged to.
888+func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) {
889+ clk := newNoticeClock()
890+ d, sess, ac, _ := newNoticeFixture(t, clk)
891+
892+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
893+ awaitToastCount(t, ac, 1)
894+
895+ // Re-stamp the live entry's sequence, exactly as coalescing does, without
896+ // disturbing the already-armed timer. Its code and scope are unchanged.
897+ rt := ac.overlays
898+ rt.noticeMu.Lock()
899+ rt.noticeSeq++
900+ rt.noticeToasts[0].seq = rt.noticeSeq
901+ rt.noticeMu.Unlock()
902+
903+ clk.advance(4 * time.Second)
904+
905+ require.Never(t, func() bool {
906+ ns, _ := visibleToasts(ac)
907+ return len(ns) == 0
908+ }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed")
909+}
M internal/usecase/daemon/overlay_runtime.go +60 -6
@@ -4,7 +4,9 @@ import (
44 "sync"
55 "time"
66
7+ "github.com/bnema/vev/internal/domain"
78 scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/bnema/vev/internal/usecase/notices"
810 "github.com/bnema/vev/internal/usecase/palette"
911 "github.com/bnema/vev/internal/usecase/picker"
1012 promptui "github.com/bnema/vev/internal/usecase/prompt"
@@ -44,10 +46,29 @@ type overlayRuntime struct {
4446 copyESC pendingByteTimer
4547 copySearch *visualsearch.Model
4648 copySearchPending []byte
47- copyFeedback string
49+ statusFeedback string
4850 copyPointer copyPointerState
4951 copyClick copyClickCandidate
5052 copyPointerEpoch uint64
53+
54+ // noticeMu is the innermost overlay lock: it guards the toast fields below
55+ // and nothing is ever locked, sent, or rendered while it is held. The only
56+ // permitted nesting is sendMu -> noticeMu (rendering reads the toasts).
57+ // The notification history overlay's fields share this lock rather than
58+ // adding one of their own: both concern notice presentation for this
59+ // client and neither is ever held across a render call.
60+ noticeMu sync.Mutex
61+ noticeToasts []noticeToast
62+ noticeOverflow int
63+ // noticeSeq numbers toast entries so an already-fired expiry timer cannot
64+ // dismiss the refreshed entry that replaced the one it belonged to.
65+ noticeSeq uint64
66+
67+ // noticesOverlay is the `notifications` command's history modal. nil when
68+ // closed.
69+ noticesOverlay *notices.Model
70+ noticesPending []byte
71+ noticesESC pendingByteTimer
5172 }
5273
5374 type copyPointerState struct {
@@ -77,7 +98,7 @@ func (rt *overlayRuntime) Active() bool {
7798 if rt == nil || rt.ac == nil {
7899 return false
79100 }
80- return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive()
101+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive()
81102 }
82103
83104 func (rt *overlayRuntime) promptActive() bool {
@@ -107,6 +128,15 @@ func (rt *overlayRuntime) pickerActive() bool {
107128 return rt.picker != nil
108129 }
109130
131+func (rt *overlayRuntime) noticesActive() bool {
132+ if rt == nil {
133+ return false
134+ }
135+ rt.noticeMu.Lock()
136+ defer rt.noticeMu.Unlock()
137+ return rt.noticesOverlay != nil
138+}
139+
110140 func (rt *overlayRuntime) copyActive() bool {
111141 if rt == nil {
112142 return false
@@ -201,6 +231,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
201231 d.handlePickerInput(ac, data)
202232 return true
203233 }
234+ if rt.noticesActive() {
235+ d.handleNoticesInput(ac, data)
236+ return true
237+ }
204238 if rt.copyActive() {
205239 d.handleCopyInput(ac, data)
206240 return true
@@ -215,12 +249,15 @@ type overlayRenderSnapshot struct {
215249 copyMode *scopy.Mode
216250 copyPane *pane
217251 copySearchModel *visualsearch.Model
218- copyFeedback string
252+ statusFeedback string
219253
220254 pickerActive bool
221255 pickerModel *picker.Model
222256 previewTab *tab
223257
258+ noticesOverlayActive bool
259+ noticesOverlayModel *notices.Model
260+
224261 paletteActive bool
225262 paletteModel *palette.Model
226263 // paletteHints is a copy captured under paletteMu. Rendering must use this
@@ -233,6 +270,9 @@ type overlayRenderSnapshot struct {
233270 promptActive bool
234271 promptModel *promptui.Model
235272 promptLocked bool
273+
274+ notices []domain.Notification
275+ noticeOverflow int
236276 }
237277
238278 // SnapshotForRender captures the overlay state needed by paint.
@@ -247,6 +287,20 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
247287 return snap
248288 }
249289
290+ // noticeMu is innermost and never held across render, so it is taken and
291+ // released here rather than tracked like paletteMu/promptMu below.
292+ rt.noticeMu.Lock()
293+ if len(rt.noticeToasts) > 0 {
294+ snap.notices = make([]domain.Notification, len(rt.noticeToasts))
295+ for i, t := range rt.noticeToasts {
296+ snap.notices[i] = t.n
297+ }
298+ }
299+ snap.noticeOverflow = rt.noticeOverflow
300+ snap.noticesOverlayActive = rt.noticesOverlay != nil
301+ snap.noticesOverlayModel = rt.noticesOverlay.Clone()
302+ rt.noticeMu.Unlock()
303+
250304 rt.copyMu.Lock()
251305 snap.copyActive = rt.copyMode != nil
252306 snap.copyPane = rt.copyPane
@@ -256,9 +310,9 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
256310 snap.copyMode = &copyModeValue
257311 }
258312 snap.copySearchModel = rt.copySearch.Clone()
259- snap.copyFeedback = rt.copyFeedback
260- if snap.copyFeedback != "" && !snap.copyActive {
261- rt.copyFeedback = ""
313+ snap.statusFeedback = rt.statusFeedback
314+ if snap.statusFeedback != "" && !snap.copyActive {
315+ rt.statusFeedback = ""
262316 }
263317 rt.copyMu.Unlock()
264318
M internal/usecase/daemon/palette.go +21 -2
@@ -270,7 +270,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
270270 } else {
271271 target.Stopped = true
272272 }
273- if !d.switchToTarget(sess, ac, target) {
273+ if err := d.switchToTarget(sess, ac, target); err != nil {
274274 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
275275 d.invalidateRender(sess, ac, true, "palette.go")
276276 return
@@ -305,6 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
305305 }
306306 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil {
307307 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
308+ d.reportError(sess, err)
308309 } else {
309310 d.recordPaletteUse(cmd.Code)
310311 }
@@ -480,6 +481,24 @@ func (e paletteExec) OpenSessionPicker() error {
480481 return nil
481482 }
482483
484+func (e paletteExec) OpenNotifications() error {
485+ e.d.enterNotices(e.sess, e.ac)
486+ return nil
487+}
488+
489+func (e paletteExec) YankLastNotification() error {
490+ n, ok := e.d.notices.latest()
491+ if !ok {
492+ // Reported directly rather than returned: the generic cmd.Run error
493+ // path only logs, so a returned error here would never reach the
494+ // user as the warn toast this no-op is supposed to produce.
495+ e.d.reportError(e.sess, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
496+ return nil
497+ }
498+ e.d.yankNotice(e.sess, e.ac, n)
499+ return nil
500+}
501+
483502 func (e paletteExec) JumpRecentSession(rank int) error {
484503 if rank < 1 || rank > len(e.recent) {
485504 return command.ErrInvalidArguments
@@ -491,7 +510,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
491510 if e.d.beforeRecentSessionHandoff != nil {
492511 e.d.beforeRecentSessionHandoff()
493512 }
494- if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) {
513+ if err := e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {
495514 return command.ErrInvalidArguments
496515 }
497516 return nil
M internal/usecase/daemon/palette_test.go +30 -5
@@ -119,6 +119,29 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
119119 awaitFrame(t, sends, ports.MsgOutput)
120120 }
121121
122+// TestPaletteCommandFailureSurfacesAsNotice drives a palette command whose Run
123+// target fails and asserts the failure reaches the user as a notice instead of
124+// only a log line. The new-tab spawn failure is wrapped as a domain.UserError
125+// with NoticeTabSpawn (session.go createTab), so it surfaces under that code
126+// rather than the NoticeInternal catch-all.
127+func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
128+ p, release := newBlockingPTY(t)
129+ defer release()
130+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
131+ ptys := portsmocks.NewMockPTYFactory(t)
132+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
133+ d.ptys = ptys
134+
135+ d.handleInput(sess, ac, []byte("\x1b "))
136+ awaitFrame(t, sends, ports.MsgOutput)
137+ d.handleInput(sess, ac, []byte("CNT\r"))
138+ awaitFrame(t, sends, ports.MsgOutput)
139+
140+ history := d.notices.history()
141+ require.NotEmpty(t, history, "failed palette command must record a notice")
142+ require.Equal(t, domain.NoticeTabSpawn, history[0].Code)
143+}
144+
122145 func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
123146 p, release := newBlockingPTY(t)
124147 defer release()
@@ -563,7 +586,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
563586 for i, cmd := range commands {
564587 codes[i] = cmd.Code
565588 }
566- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes)
589+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
567590 }
568591
569592 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
@@ -915,10 +938,12 @@ func TestPaletteExecMethods(t *testing.T) {
915938 require.NoError(t, exec.StackPane())
916939 require.NoError(t, exec.ToggleStack())
917940 require.NoError(t, exec.ClosePane())
918- require.NoError(t, exec.FocusPaneLeft())
919- require.NoError(t, exec.FocusPaneRight())
920- require.NoError(t, exec.FocusPaneUp())
921- require.NoError(t, exec.FocusPaneDown())
941+ // A single pane remains: every direction is a benign no-neighbor move,
942+ // not an error the palette should ever surface (Task 10).
943+ require.ErrorIs(t, exec.FocusPaneLeft(), errNoNeighbor)
944+ require.ErrorIs(t, exec.FocusPaneRight(), errNoNeighbor)
945+ require.ErrorIs(t, exec.FocusPaneUp(), errNoNeighbor)
946+ require.ErrorIs(t, exec.FocusPaneDown(), errNoNeighbor)
922947 require.NoError(t, exec.CloseTab())
923948 require.Len(t, sess.tabs, 2)
924949 // Drain paints generated by methods above.
M internal/usecase/daemon/pane_actions.go +6 -3
@@ -48,6 +48,9 @@ func (d *Daemon) spawnPaneOp(
4848 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
4949 if err := mutate(tb.tree, oldFocus, newID, area); err != nil {
5050 tb.mu.Unlock()
51+ if errors.Is(err, layout.ErrTooSmall) {
52+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)
53+ }
5154 return err
5255 }
5356 tb.nextPaneID++
@@ -56,7 +59,7 @@ func (d *Daemon) spawnPaneOp(
5659 _ = tb.tree.Close(newID)
5760 tb.tree.Focus = oldFocus
5861 tb.mu.Unlock()
59- return layout.ErrTooSmall
62+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)
6063 }
6164 newRect := placementContent(placements, newID)
6265 tabStableID := tb.stableID
@@ -78,7 +81,7 @@ func (d *Daemon) spawnPaneOp(
7881 _ = tb.tree.Close(newID)
7982 tb.tree.Focus = oldFocus
8083 tb.mu.Unlock()
81- return err
84+ return domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
8285 }
8386
8487 pctx, cancel := context.WithCancel(tb.ctx)
@@ -314,7 +317,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
314317 d.invalidateRender(sess, ac, true, "pane_actions.go")
315318 }
316319 if errors.Is(err, layout.ErrNoPane) {
317- return nil
320+ return errNoNeighbor
318321 }
319322 return err
320323 }
M internal/usecase/daemon/pane_actions_test.go +21 -3
@@ -85,14 +85,24 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
8585
8686 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
8787 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
88- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
88+ cause := errors.New("open failed")
89+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once()
8990
9091 tb := sess.activeTab()
9192 tb.mu.Lock()
9293 beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes)
9394 tb.mu.Unlock()
9495
95- require.Error(t, d.splitPane(sess, nil, layout.Right))
96+ err := d.splitPane(sess, nil, layout.Right)
97+ require.Error(t, err)
98+
99+ var ue *domain.UserError
100+ require.ErrorAs(t, err, &ue)
101+ require.Equal(t, domain.NoticePaneSpawn, ue.Code)
102+ require.Equal(t, domain.NoticeError, ue.Severity)
103+ require.Equal(t, "couldn't open pane: shell failed to start", ue.Msg)
104+ require.ErrorIs(t, err, cause)
105+ require.NotContains(t, ue.Msg, cause.Error())
96106
97107 tb.mu.Lock()
98108 defer tb.mu.Unlock()
@@ -263,8 +273,16 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
263273 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
264274 thirdPTY := portsmocks.NewMockPTY(t)
265275 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
266- require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
276+ err := d.stackPane(sess, nil)
277+ require.ErrorIs(t, err, layout.ErrTooSmall)
267278 require.Len(t, tb.panes, 2)
279+
280+ var ue *domain.UserError
281+ require.ErrorAs(t, err, &ue)
282+ require.Equal(t, domain.NoticeLayoutTooSmall, ue.Code)
283+ require.Equal(t, domain.NoticeWarn, ue.Severity)
284+ require.Equal(t, "not enough space to split", ue.Msg)
285+ require.NotContains(t, ue.Msg, layout.ErrTooSmall.Error())
268286 }
269287
270288 func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) {
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -132,7 +132,7 @@ func TestTransactionalResizeMetrics(t *testing.T) {
132132 require.Equal(t, uint64(1), metrics.resizeCommits)
133133 require.Equal(t, uint64(1), metrics.ptyFailures)
134134 require.Equal(t, uint64(1), metrics.ptyRetries)
135- require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")
135+ require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry")
136136 require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
137137 require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
138138 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
M internal/usecase/daemon/picker.go +82 -122
@@ -6,7 +6,6 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/keys"
109 "github.com/bnema/vev/internal/usecase/layout"
1110 "github.com/bnema/vev/internal/usecase/picker"
1211 "github.com/bnema/vev/internal/usecase/ui"
@@ -93,136 +92,87 @@ func attentionSuffix(label string) string {
9392 return label + " " + string(ui.AttentionGlyph)
9493 }
9594
95+func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
96+ rt := ac.overlays
97+ var generation uint64
98+ return listInputState{
99+ pending: &rt.pickerPending,
100+ esc: &rt.pickerESC,
101+ moveUp: rt.picker.Up,
102+ moveDown: rt.picker.Down,
103+ lock: rt.pickerMu.Lock,
104+ unlock: rt.pickerMu.Unlock,
105+ active: func() bool { return rt.picker != nil },
106+ closeLocked: func() {
107+ rt.picker = nil
108+ generation = rt.pickerPreviewGeneration
109+ },
110+ afterClose: func() {
111+ d.clearPreviewGeneration(ac, generation)
112+ if sess := ac.currentSession(); sess != nil {
113+ d.invalidateRender(sess, ac, true, "picker.go")
114+ }
115+ },
116+ }
117+}
118+
96119 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
97120 sess := ac.currentSession()
98121 if sess == nil {
99122 return
100123 }
101- ac.overlays.pickerMu.Lock()
102- if ac.overlays.picker == nil {
103- ac.overlays.pickerPending = nil
104- d.stopPickerPendingTimerLocked(ac)
105- ac.overlays.pickerMu.Unlock()
124+ rt := ac.overlays
125+ rt.pickerMu.Lock()
126+ if rt.picker == nil {
127+ rt.pickerPending = nil
128+ rt.pickerESC.stop()
129+ rt.pickerMu.Unlock()
106130 return
107131 }
108- if len(ac.overlays.pickerPending) > 0 {
109- d.stopPickerPendingTimerLocked(ac)
110- combined := make([]byte, 0, len(ac.overlays.pickerPending)+len(data))
111- combined = append(combined, ac.overlays.pickerPending...)
112- combined = append(combined, data...)
113- data = combined
114- ac.overlays.pickerPending = nil
115- }
116- changed := false
117- exit := false
118- switchTarget := false
119- for i := 0; i < len(data); i++ {
120- switch data[i] {
132+ result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult {
133+ switch b {
121134 case 'x':
122- target, ok := ac.overlays.picker.Selected()
123- ac.overlays.pickerMu.Unlock()
124- if ok {
125- d.killPickerTarget(target)
126- }
127- d.refreshPicker(ac)
128- d.invalidateRender(sess, ac, true, "picker.go")
129- return
130- case 'j':
131- ac.overlays.picker.Down()
132- changed = true
133- case 'k':
134- ac.overlays.picker.Up()
135- changed = true
135+ return listInputResult{action: b, stop: true}
136136 case '\r', '\n':
137- switchTarget = true
138- exit = true
139- case 'q', 0x03, 0x1b:
140- if data[i] == 0x1b {
141- tail := data[i:]
142- consumed, ok := routePickerEscape(ac.overlays.picker, tail)
143- if ok {
144- i += consumed - 1
145- changed = true
146- continue
147- }
148- if len(tail) == 1 {
149- d.retainPickerESCLocked(ac)
150- break
151- }
152- if isPickerEscapePrefix(tail) {
153- ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], tail...)
154- break
155- }
156- }
157- exit = true
137+ return listInputResult{action: b, exit: true}
138+ default:
139+ return listInputResult{}
158140 }
159- }
141+ })
160142 var target picker.Target
161143 var ok bool
162- if switchTarget {
163- target, ok = ac.overlays.picker.Selected()
144+ if result.action == 'x' || result.action == '\r' || result.action == '\n' {
145+ target, ok = rt.picker.Selected()
164146 }
165- ac.overlays.pickerMu.Unlock()
147+ rt.pickerMu.Unlock()
166148
167- if changed {
149+ if result.action == 'x' {
150+ if ok {
151+ if err := d.killPickerTarget(target); err != nil {
152+ d.reportError(sess, err)
153+ }
154+ }
155+ d.refreshPicker(ac)
156+ d.invalidateRender(sess, ac, true, "picker.go")
157+ return
158+ }
159+ if result.changed {
168160 d.registerPreviewForSelection(ac)
169161 }
170- if exit {
162+ if result.exit {
171163 d.closePicker(ac)
172164 }
173- if switchTarget && ok {
174- d.switchToTarget(sess, ac, target)
165+ if (result.action == '\r' || result.action == '\n') && ok {
166+ if err := d.switchToTarget(sess, ac, target); err != nil {
167+ d.reportError(sess, err)
168+ }
175169 return
176170 }
177- if exit || changed {
171+ if result.exit || result.changed {
178172 d.invalidateRender(sess, ac, true, "picker.go")
179173 }
180174 }
181175
182-func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
183- ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], keys.ESC)
184- ac.overlays.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
185- ac.overlays.pickerMu.Lock()
186- if ac.overlays.pickerESC.timer != timer || len(ac.overlays.pickerPending) != 1 || ac.overlays.pickerPending[0] != keys.ESC || ac.overlays.picker == nil {
187- ac.overlays.pickerMu.Unlock()
188- return
189- }
190- ac.overlays.pickerPending = nil
191- ac.overlays.pickerESC.timer = nil
192- ac.overlays.pickerESC.done = nil
193- ac.overlays.picker = nil
194- generation := ac.overlays.pickerPreviewGeneration
195- ac.overlays.pickerMu.Unlock()
196-
197- d.clearPreviewGeneration(ac, generation)
198- if sess := ac.currentSession(); sess != nil {
199- d.invalidateRender(sess, ac, true, "picker.go")
200- }
201- })
202-}
203-
204-func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
205- ac.overlays.pickerESC.stop()
206-}
207-
208-func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
209- if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
210- switch data[2] {
211- case 'A':
212- m.Up()
213- return 3, true
214- case 'B':
215- m.Down()
216- return 3, true
217- }
218- }
219- return 0, false
220-}
221-
222-func isPickerEscapePrefix(data []byte) bool {
223- return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O')
224-}
225-
226176 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
227177 if ac == nil || ac.overlays == nil {
228178 return
@@ -364,7 +314,7 @@ func (d *Daemon) closePicker(ac *attachedClient) {
364314 ac.overlays.pickerMu.Lock()
365315 ac.overlays.picker = nil
366316 ac.overlays.pickerPending = nil
367- d.stopPickerPendingTimerLocked(ac)
317+ ac.overlays.pickerESC.stop()
368318 generation := ac.overlays.pickerPreviewGeneration
369319 ac.overlays.pickerMu.Unlock()
370320 d.clearPreviewGeneration(ac, generation)
@@ -394,19 +344,20 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
394344 // switchToTarget resolves named lifecycle targets and commits their transition
395345 // while d.mu is held. A named palette result is allowed to cross an
396346 // active/stopped transition, but it never follows a same-name replacement.
397-func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool {
347+func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
398348 d.mu.Lock()
399349 var (
400350 targetSess *session
401351 old *attachedClient
402352 cleanups []renderLifecycleCleanup
403353 switched bool
354+ cause error
404355 )
405356 if target.Name != "" {
406357 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
407358 if ok {
408359 if isStopped {
409- targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
360+ targetSess, cleanups, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
410361 } else {
411362 // The snapshot ID may describe the stopped representation. The
412363 // locked name/lifecycle resolver chose this active incarnation.
@@ -422,14 +373,14 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
422373
423374 if !switched {
424375 d.invalidateRender(from, ac, true, "picker.go")
425- return false
376+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
426377 }
427378 finishRenderLifecycleCleanups(cleanups)
428379 if old != nil && old != ac {
429380 d.unregisterPreview(old)
430381 old.clearPreviousSession()
431382 old.setSession(nil)
432- d.notifyDetachedAsync(old, ports.ReasonDetach)
383+ d.notifyDetachedAsync(old, ports.ReasonReplaced)
433384 }
434385 if targetSess == from {
435386 d.activateTab(from, from.activeTab())
@@ -437,7 +388,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
437388 } else {
438389 d.firstPaint(targetSess, ac, ac.size)
439390 }
440- return true
391+ return nil
441392 }
442393
443394 // resolveNamedLifecycleTargetLocked chooses exactly one current representation
@@ -463,6 +414,10 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
463414 // switchToActiveTargetLocked commits an active target handoff. Caller holds
464415 // d.mu and releases it before completing render cleanup or first paint.
465416 func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
417+ // The caller already holds d.mu. Keep handoff ownership atomic with global
418+ // routing so it cannot select an attachment midway between sessions.
419+ d.notices.routingMu.Lock()
420+ defer d.notices.routingMu.Unlock()
466421 if d.sessions[target.Session] != targetSess {
467422 return nil, nil, nil, false
468423 }
@@ -517,11 +472,15 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
517472 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
518473 // the handoff while d.mu is held. Creation failure leaves the source client
519474 // and stopped record untouched.
520-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) {
475+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
476+ // The caller already holds d.mu. Keep handoff ownership atomic with global
477+ // routing so it cannot select an attachment midway between sessions.
478+ d.notices.routingMu.Lock()
479+ defer d.notices.routingMu.Unlock()
521480 from.mu.Lock()
522481 if from.client != ac {
523482 from.mu.Unlock()
524- return nil, nil, false
483+ return nil, nil, false, nil
525484 }
526485 term := from.terminal
527486 env := copyEnvironment(from.env)
@@ -530,7 +489,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
530489 if err != nil {
531490 from.mu.Unlock()
532491 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
533- return nil, nil, false
492+ return nil, nil, false, err
534493 }
535494 from.client = nil
536495 ac.setSession(nil)
@@ -543,7 +502,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
543502 targetSess.mu.Unlock()
544503 d.touchMRU(targetSess)
545504 ac.recordPreviousSession(from)
546- return targetSess, cleanups, true
505+ return targetSess, cleanups, true, nil
547506 }
548507
549508 func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
@@ -574,7 +533,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
574533 switched bool
575534 )
576535 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
577- targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
536+ targetSess, cleanups, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
578537 }
579538 d.mu.Unlock()
580539 if !switched {
@@ -586,7 +545,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
586545 return true
587546 }
588547
589-func (d *Daemon) killPickerTarget(target picker.Target) {
548+func (d *Daemon) killPickerTarget(target picker.Target) error {
590549 if target.Stopped {
591550 d.mu.Lock()
592551 stopped, ok := d.stopped[target.Name]
@@ -594,19 +553,20 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
594553 if err := d.persist.Delete(target.Name); err != nil {
595554 d.mu.Unlock()
596555 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
597- return
556+ return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
598557 }
599558 delete(d.stopped, target.Name)
600559 }
601560 d.mu.Unlock()
602- return
561+ return nil
603562 }
604563 d.mu.Lock()
605564 targetSess := d.sessions[target.Session]
606565 d.mu.Unlock()
607566 if targetSess != nil {
608- _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
567+ return d.killSession(targetSess, ports.ReasonSessionKilled, true)
609568 }
569+ return nil
610570 }
611571
612572 func (d *Daemon) refreshPicker(ac *attachedClient) {
M internal/usecase/daemon/picker_test.go +121 -3
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "strings"
67 "testing"
78 "time"
@@ -22,6 +23,24 @@ import (
2223
2324 // --- test doubles -----------------------------------------------------------
2425
26+// failingDeleteStore is a ports.Store whose Delete always fails, used to
27+// exercise the picker's kill-target error path without disturbing the
28+// generated mock's default Delete expectation.
29+type failingDeleteStore struct{ err error }
30+
31+func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
32+func (failingDeleteStore) Set([]byte, []byte) error { return nil }
33+func (s failingDeleteStore) Delete([]byte) error { return s.err }
34+func (failingDeleteStore) Range(func(k, v []byte) bool) {}
35+func (failingDeleteStore) Sync() error { return nil }
36+func (failingDeleteStore) Close() error { return nil }
37+
38+type refusingSnapshotDeleteStore struct{ err error }
39+
40+func (refusingSnapshotDeleteStore) Write(string, []byte) error { return nil }
41+func (refusingSnapshotDeleteStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
42+func (s refusingSnapshotDeleteStore) Delete(string) error { return s.err }
43+
2544 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
2645 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
2746 tb.ctx, tb.cancel = ctx, cancel
@@ -140,7 +159,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
140159 replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
141160 d.sessions[replacement.id] = replacement
142161
143- require.False(t, d.switchToTarget(current, ac, target))
162+ require.Error(t, d.switchToTarget(current, ac, target))
144163 require.Same(t, current, ac.currentSession())
145164 }
146165
@@ -152,7 +171,7 @@ func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
152171 d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
153172 current.client = &attachedClient{}
154173
155- require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
174+ require.Error(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
156175 require.Equal(t, 0, current.active)
157176 }
158177
@@ -386,7 +405,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
386405 det := awaitFrame(t, sends2, ports.MsgDetached)
387406 dm, err := ports.UnmarshalDetached(det.Payload)
388407 require.NoError(t, err)
389- require.Equal(t, ports.ReasonDetach, dm.Reason)
408+ require.Equal(t, ports.ReasonReplaced, dm.Reason)
390409 awaitFrame(t, sends1, ports.MsgOutput)
391410 }
392411
@@ -945,3 +964,102 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
945964 t.Fatal("floating prewarm PTY was not closed")
946965 }
947966 }
967+
968+// TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives
969+// Enter on a stopped picker entry whose restore spawn fails (mock PTY Open
970+// error). The failure must reach the user as a NoticeSessionUnavailable
971+// notice and toast, and the client must remain attached to its origin
972+// session rather than being left in limbo.
973+func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) {
974+ p, release := newBlockingPTY(t)
975+ defer release()
976+ d, from, ac, sends := newManualSessionWithPTYs(t, p)
977+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
978+ cause := errors.New("open failed")
979+ ptys := portsmocks.NewMockPTYFactory(t)
980+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
981+ d.ptys = ptys
982+
983+ d.enterPicker(from, ac)
984+ awaitFrame(t, sends, ports.MsgOutput)
985+ d.handleInput(from, ac, []byte("j"))
986+ awaitFrame(t, sends, ports.MsgOutput)
987+ d.handleInput(from, ac, []byte("\r"))
988+ awaitFrame(t, sends, ports.MsgOutput)
989+
990+ history := d.notices.history()
991+ require.Len(t, history, 1, "failed stopped-session restore must record exactly one notice")
992+ require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
993+ require.Equal(t, domain.NoticeError, history[0].Severity)
994+
995+ toasts := awaitToastCount(t, ac, 1)
996+ require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code)
997+
998+ require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
999+}
1000+
1001+func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {
1002+ d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
1003+ defer releaseAll(releases)
1004+ cause := errors.New("snapshot delete refused")
1005+ WithSnapshotStore(refusingSnapshotDeleteStore{err: cause})(d)
1006+ target := d.sessions[domain.SessionID("recent")]
1007+
1008+ d.enterPicker(from, ac)
1009+ awaitFrame(t, sends, ports.MsgOutput)
1010+ d.handleInput(from, ac, []byte("k"))
1011+ awaitFrame(t, sends, ports.MsgOutput)
1012+ d.handleInput(from, ac, []byte("x"))
1013+
1014+ history := d.notices.history()
1015+ require.Len(t, history, 1, "the refused purge must be reported exactly once")
1016+ require.Equal(t, domain.NoticeInternal, history[0].Code)
1017+ require.Contains(t, history[0].Details, cause.Error())
1018+ require.Len(t, awaitToastCount(t, ac, 1), 1, "the refusal must produce one visible notice")
1019+
1020+ require.Same(t, from, ac.currentSession(), "purging another session must preserve the attachment")
1021+ require.True(t, ac.overlays.pickerActive(), "x refreshes the picker instead of closing it")
1022+ d.mu.Lock()
1023+ _, stillActive := d.sessions[target.id]
1024+ d.mu.Unlock()
1025+ require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure")
1026+}
1027+
1028+// TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1029+// drives 'x' on a stopped picker entry whose persisted-record delete fails
1030+// (mock store error). The failure must reach the user as a
1031+// NoticePersistDelete notice and toast, and the entry must remain listed
1032+// after the picker refreshes rather than silently vanishing from the daemon's
1033+// bookkeeping while still present on disk.
1034+func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) {
1035+ p, release := newBlockingPTY(t)
1036+ defer release()
1037+ d, from, ac, sends := newManualSessionWithPTYs(t, p)
1038+ cause := errors.New("delete failed")
1039+ WithStore(failingDeleteStore{err: cause})(d)
1040+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1041+
1042+ d.enterPicker(from, ac)
1043+ awaitFrame(t, sends, ports.MsgOutput)
1044+ d.handleInput(from, ac, []byte("j"))
1045+ awaitFrame(t, sends, ports.MsgOutput)
1046+ d.handleInput(from, ac, []byte("x"))
1047+ awaitFrame(t, sends, ports.MsgOutput)
1048+
1049+ history := d.notices.history()
1050+ require.NotEmpty(t, history, "failed persisted-record delete must record a notice")
1051+ require.Equal(t, domain.NoticePersistDelete, history[0].Code)
1052+ require.Equal(t, domain.NoticeError, history[0].Severity)
1053+
1054+ toasts := awaitToastCount(t, ac, 1)
1055+ require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
1056+
1057+ views, _ := d.pickerViews(from)
1058+ var stillListed bool
1059+ for _, v := range views {
1060+ if v.Stopped && v.Name == "stopped" {
1061+ stillListed = true
1062+ }
1063+ }
1064+ require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed")
1065+}
M internal/usecase/daemon/prompt.go +12 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "strings"
56
67 "github.com/bnema/vev/internal/domain"
@@ -30,6 +31,14 @@ func (d *Daemon) closePrompt(ac *attachedClient) {
3031 ac.overlays.promptMu.Unlock()
3132 }
3233
34+// promptValidationError identifies expected prompt feedback that should stay
35+// inline rather than creating a history entry or toast.
36+func promptValidationError(err error) bool {
37+ return errors.Is(err, domain.ErrInvalidSessionName) ||
38+ errors.Is(err, errSessionNameRequired) ||
39+ errors.Is(err, errSessionNameInUse)
40+}
41+
3342 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
3443 sess := ac.currentSession()
3544 if sess == nil {
@@ -76,6 +85,9 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
7685 ac.overlays.prompt.SetError(err.Error())
7786 }
7887 ac.overlays.promptMu.Unlock()
88+ if !promptValidationError(err) {
89+ d.reportError(sess, err)
90+ }
7991 d.invalidateRender(sess, ac, true, "prompt.go")
8092 return
8193 }
M internal/usecase/daemon/prompt_test.go +82 -1
@@ -2,13 +2,21 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "testing"
67
8+ "github.com/stretchr/testify/require"
9+
710 "github.com/bnema/vev/internal/domain"
811 "github.com/bnema/vev/internal/ports"
9- "github.com/stretchr/testify/require"
1012 )
1113
14+type failingPTYFactory struct{ err error }
15+
16+func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
17+ return nil, f.err
18+}
19+
1220 func TestPromptModalGeometry(t *testing.T) {
1321 base := domain.Size{Cols: 100, Rows: 40}
1422
@@ -72,6 +80,79 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
7280 require.Contains(t, string(out.Data), "name already in use")
7381 }
7482
83+func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {
84+ p, release := newBlockingPTY(t)
85+ defer release()
86+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
87+ cause := errors.New("fork/exec /bin/sh: permission denied")
88+ d.ptys = failingPTYFactory{err: cause}
89+
90+ // Submit through the actual prompt input boundary rather than calling its
91+ // callback directly.
92+ d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
93+ return d.createSessionAndSwitch(sess, ac, name)
94+ })
95+ awaitFrame(t, sends, ports.MsgOutput)
96+ d.handlePromptInput(ac, []byte("new\r"))
97+ repaint := awaitFrame(t, sends, ports.MsgOutput)
98+ output, err := ports.UnmarshalOutput(repaint.Payload)
99+ require.NoError(t, err)
100+ require.Contains(t, string(output.Data), "couldn't create session: shell failed to start")
101+
102+ history := d.notices.history()
103+ require.Len(t, history, 1, "the prompt boundary must report the failure once")
104+ notice := history[0]
105+ require.Equal(t, domain.NoticeSessionSpawn, notice.Code)
106+ require.Equal(t, "couldn't create session: shell failed to start", notice.Message)
107+ require.NotContains(t, notice.Message, cause.Error())
108+ require.Contains(t, notice.Details, cause.Error())
109+ require.True(t, ac.overlays.promptActive(), "failed submission stays inline")
110+}
111+
112+func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) {
113+ tests := []struct {
114+ name string
115+ input []byte
116+ wantErr error
117+ setup func(*Daemon)
118+ }{
119+ {name: "invalid name", input: []byte("invalid name\r"), wantErr: domain.ErrInvalidSessionName},
120+ {name: "required name", input: []byte("\r"), wantErr: errSessionNameRequired},
121+ {
122+ name: "name in use",
123+ input: []byte("taken\r"),
124+ wantErr: errSessionNameInUse,
125+ setup: func(d *Daemon) {
126+ d.sessions["taken"] = &session{id: "taken", name: "taken"}
127+ },
128+ },
129+ }
130+
131+ for _, tt := range tests {
132+ t.Run(tt.name, func(t *testing.T) {
133+ p, release := newBlockingPTY(t)
134+ defer release()
135+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
136+ if tt.setup != nil {
137+ tt.setup(d)
138+ }
139+
140+ d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
141+ return d.createSessionAndSwitch(sess, ac, name)
142+ })
143+ awaitFrame(t, sends, ports.MsgOutput)
144+ d.handlePromptInput(ac, tt.input)
145+ repaint := awaitFrame(t, sends, ports.MsgOutput)
146+ output, err := ports.UnmarshalOutput(repaint.Payload)
147+ require.NoError(t, err)
148+ require.Contains(t, string(output.Data), tt.wantErr.Error())
149+
150+ require.True(t, ac.overlays.promptActive())
151+ require.Empty(t, d.notices.history())
152+ })
153+ }
154+}
155+
75156 func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
76157 p, release := newBlockingPTY(t)
77158 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/render.go +6 -2
@@ -101,6 +101,10 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
101101 d.processPTYData(sess, tb, p, buf[:n], true)
102102 }
103103 if err != nil {
104+ sess.mu.Lock()
105+ sessionName := sess.name
106+ sess.mu.Unlock()
107+ d.log.Info("pane pty closed", "err", err, "session", sessionName)
104108 if p.onExit != nil {
105109 p.onExit()
106110 } else {
@@ -294,7 +298,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
294298 d.refreshSessionFocusedTitles(sess)
295299 // Contextual ranks are completely captured under paletteMu. Compose them
296300 // without reading the live MRU, whose order may have changed mid-interaction.
297- bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
301+ bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent)
298302 applied := ac.getAppliedTheme()
299303 bars.theme = applied.Raw
300304 attentionVisible := pulseVisible(bars.attentionFrame)
@@ -323,7 +327,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
323327 capturedOverlays := capturedOverlayRenderState{
324328 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
325329 pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
326- copyFeedback: overlays.copyFeedback,
330+ statusFeedback: overlays.statusFeedback,
327331 }
328332 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
329333 state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
M internal/usecase/daemon/render_capture.go +7 -4
@@ -86,11 +86,14 @@ type capturedFloatingRenderState struct {
8686
8787 type capturedOverlayRenderState struct {
8888 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
89+ noticesOverlayActive bool
8990 copyMode *scopy.Mode
9091 copyPaneID layout.PaneID
91- copyFeedback string
92+ statusFeedback string
9293 paletteGuidance string
93- copySearch, picker, palette, prompt capturedModal
94+ copySearch, picker, palette, prompt, noticesOverlay capturedModal
95+ notices []domain.Notification
96+ noticeOverflow int
9497 }
9598
9699 type capturedModal struct {
@@ -100,7 +103,7 @@ type capturedModal struct {
100103 }
101104
102105 func (o capturedOverlayRenderState) active() bool {
103- return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive
106+ return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
104107 }
105108
106109 type capturedCursorInputs struct {
@@ -329,7 +332,7 @@ func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch)
329332
330333 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
331334 style, hasStyle := p.screen.CursorStyle()
332- hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
335+ hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
333336 return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
334337 }
335338
M internal/usecase/daemon/render_damage_receipt_test.go +65 -0
@@ -104,6 +104,10 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
104104 } {
105105 t.Run(tt.name, func(t *testing.T) {
106106 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
107+ // Prepare failures now schedule recovery through the coordinator;
108+ // retain the failed transaction here until this test explicitly retries.
109+ d.clock = newNoticeClock()
110+ d.attachCoordinator(sess, nil, ac, true)
107111 healthy := ac.transport()
108112 p := sess.tabs[0].focusedPane()
109113
@@ -136,6 +140,67 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
136140 }
137141 }
138142
143+func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
144+ clock := newNoticeClock()
145+ d, sess, ac, _ := newNoticeFixture(t, clock)
146+ rc := d.attachCoordinator(sess, nil, ac, true)
147+ var producers []string
148+ rc.opts.onInvalidate = func(inv renderInvalidation) {
149+ // Both the notice repaint and the explicit recovery must re-enter only
150+ // after emitFrame releases its attachment locks.
151+ require.True(t, ac.sendMu.TryLock(), "invalidation must not run under sendMu")
152+ ac.sendMu.Unlock()
153+ producers = append(producers, inv.producer)
154+ }
155+ state, composed := captureComposeForReceiptTest(t, sess, ac)
156+ // An invalid frame makes output.prepare fail after capture. emitFrame owns
157+ // sendMu on entry and releases it before reporting and rescheduling.
158+ composed.frame = renderer.Frame{Width: 1}
159+
160+ require.True(t, d.emitFrame(sess, ac, state, composed))
161+
162+ history := d.notices.history()
163+ require.Len(t, history, 1)
164+ require.Equal(t, domain.NoticeInternal, history[0].Code)
165+ require.Equal(t, "display update failed", history[0].Message)
166+ toasts, _ := visibleToasts(ac)
167+ require.Len(t, toasts, 1)
168+ require.Equal(t, "display update failed", toasts[0].Message)
169+
170+ rc.mu.Lock()
171+ pending, reset := rc.pending, rc.pendingReset
172+ rc.mu.Unlock()
173+ require.True(t, pending, "prepare failure must schedule a recovery render")
174+ require.True(t, reset, "recovery must invalidate the full render state")
175+ require.Contains(t, producers, "render_pipeline.go:prepare-failed", "prepare failure must explicitly schedule recovery")
176+ require.Contains(t, clock.durations(), urgentRenderDeadline, "notice/recovery invalidation must arm the coordinator")
177+}
178+
179+func TestPrepareFailureFallbackOnlySuppressesRecursiveNoticePaint(t *testing.T) {
180+ d, sess, ac, sends := newNoticeFixture(t, newNoticeClock())
181+
182+ // A failure while reportError is repainting its own toast is intentionally
183+ // ignored. It must not start another notification/repaint cycle.
184+ ac.prepareFailureFallback.Store(true)
185+ state, composed := captureComposeForReceiptTest(t, sess, ac)
186+ composed.frame = renderer.Frame{Width: 1}
187+ require.True(t, d.emitFrame(sess, ac, state, composed))
188+ require.Empty(t, d.notices.history())
189+ ac.prepareFailureFallback.Store(false)
190+
191+ // Later outer failures each notify and repaint after sendMu has been
192+ // released; neither may be hidden by the prior recursive guard.
193+ for want := 1; want <= 2; want++ {
194+ state, composed = captureComposeForReceiptTest(t, sess, ac)
195+ composed.frame = renderer.Frame{Width: 1}
196+ require.True(t, d.emitFrame(sess, ac, state, composed))
197+ require.Len(t, d.notices.history(), want)
198+ frame := <-sends
199+ require.Equal(t, ports.MsgOutput, frame.Type, "outer failure %d must repaint its notice", want)
200+ require.False(t, ac.prepareFailureFallback.Load())
201+ }
202+}
203+
139204 func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
140205 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
141206 d.paint(sess, ac, true, nil)
M internal/usecase/daemon/render_pipeline.go +78 -5
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
99 "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/notices"
1011 "github.com/bnema/vev/internal/usecase/palette"
1112 "github.com/bnema/vev/internal/usecase/picker"
1213 "github.com/bnema/vev/internal/usecase/prompt"
@@ -43,6 +44,7 @@ type composeCacheInput struct {
4344 floatingGeometry floatingGeometry
4445 floatingTitleGeneration uint64
4546 bars barCache
47+ toastFootprints []domain.Rect
4648 theme themeui.Theme
4749 styleGeneration uint64
4850 }
@@ -151,7 +153,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
151153 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
152154 }
153155 }
154- if state.overlays.active() {
156+ // The cache stays toast-free. The terminal shadow, in contrast, includes
157+ // toasts, so every render damages both the last and current toast coverage.
158+ // This restores cells exposed by dismissal and redraws a stable toast over
159+ // any underlying pane update without promoting either case to a full frame.
160+ overlaysActive := state.overlays.active()
161+ toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
162+ if overlaysActive || toastsVisible {
155163 // Without a floating frame, overlay composition would otherwise mutate
156164 // the base cache in place. Floating composition already owns a clone.
157165 if !state.floating.visible {
@@ -159,13 +167,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
159167 }
160168 frame, damage = composeCapturedOverlays(state, frame, damage, content)
161169 }
162- if full || state.overlays.active() {
170+ toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles)
171+ if full || overlaysActive {
163172 damage = []renderer.Damage{renderer.FullRedraw()}
173+ } else {
174+ damage = appendToastDamage(damage, in.toastFootprints)
175+ damage = appendToastDamage(damage, toastFootprints)
164176 }
165177 cursorInputs := state.cursor
166- cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
178+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
167179 cursor := desiredCapturedCursor(cursorInputs)
168- outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
180+ outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
169181 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
170182 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
171183 }
@@ -198,7 +210,9 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
198210 }
199211 o := &state.overlays
200212 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
213+ o.noticesOverlayActive = snap.noticesOverlayActive
201214 o.copyMode = snap.copyMode
215+ o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
202216 if snap.copyPane != nil {
203217 o.copyPaneID = snap.copyPane.id
204218 }
@@ -218,6 +232,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
218232 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.SurfaceInactive, Detail: styles.PickerDescription, Base: styles.SurfaceInactive, Separator: styles.PickerSeparator}
219233 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
220234 }
235+ if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
236+ o.noticesOverlay.modal = noticesModal
237+ o.noticesOverlay.focused = true
238+ renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.SurfaceInactive, Selection: styles.PickerSelection, Text: styles.SurfaceInactive, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
239+ o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles)
240+ }
221241 if snap.paletteActive && snap.paletteModel != nil {
222242 o.palette.modal = paletteModalFor(size, paletteCfg)
223243 o.palette.focused = true
@@ -262,7 +282,15 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
262282 if o.paletteActive && !state.floating.visible {
263283 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
264284 }
265- for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
285+ // This paint order intentionally differs from HandleInput's keyboard
286+ // priority (prompt > palette > picker > notices > copy, see
287+ // overlay_runtime.go), which paints the picker under notices instead of
288+ // over it. Currently unreachable: notices only opens via the palette, and
289+ // HandleInput short-circuits to the first active overlay, so picker and
290+ // notices are never simultaneously active. If that ever changes, this
291+ // mismatch would let the picker own the keyboard while notices visually
292+ // covers it.
293+ for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
266294 if modal.inner.Width == 0 && modal.inner.Height == 0 {
267295 continue
268296 }
@@ -279,6 +307,38 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
279307 return frame, damage
280308 }
281309
310+func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311+ if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312+ return nil
313+ }
314+ views := make([]ui.NoticeView, len(overlays.notices))
315+ for i, n := range overlays.notices {
316+ views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count}
317+ }
318+ return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
319+}
320+
321+func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage {
322+ for _, footprint := range footprints {
323+ if footprint.Width > 0 && footprint.Height > 0 {
324+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
325+ }
326+ }
327+ return damage
328+}
329+
330+// noticeStylesFrom picks the toast box color per severity from the theme's
331+// chrome roles. Warn uses the dedicated BorderWarn role so it reads as
332+// distinct from Info instead of sharing BorderMuted with it.
333+func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
334+ return ui.NoticeStyles{
335+ Text: styles.PickerBase,
336+ BoxError: styles.BorderActive,
337+ BoxWarn: styles.BorderWarn,
338+ BoxInfo: styles.BorderMuted,
339+ }
340+}
341+
282342 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
283343 changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
284344 if !changed {
@@ -368,6 +428,19 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
368428 ac.sess.mu.Unlock()
369429 ac.sendMu.Unlock()
370430 d.log.Error("render draw failed", "err", err, "session", sess.name)
431+ // Without a coordinator reportError repaints synchronously. Suppress only
432+ // that nested notice repaint; leave the guard before returning so a later,
433+ // independent failed transaction can still notify the user.
434+ if sess.renderCoordinator() == nil {
435+ if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
436+ return true
437+ }
438+ d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
439+ ac.prepareFailureFallback.Store(false)
440+ return true
441+ }
442+ d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
443+ d.invalidateRender(sess, ac, true, "render_pipeline.go:prepare-failed")
371444 return true
372445 }
373446 data := append([]byte(nil), prepared.data...)
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -0
@@ -300,6 +300,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput {
300300 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
301301 maps.Copy(out.titleGenerations, in.titleGenerations)
302302 out.damage = append([]renderer.Damage(nil), in.damage...)
303+ out.toastFootprints = append([]domain.Rect(nil), in.toastFootprints...)
303304 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
304305 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
305306 return out
M internal/usecase/daemon/render_pipeline_test.go +17 -0
@@ -319,3 +319,20 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
319319 })
320320 }
321321 }
322+
323+func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324+ muted := renderer.Style{Foreground: 1}
325+ active := renderer.Style{Foreground: 2}
326+ warn := renderer.Style{Foreground: 3}
327+ styles := themeui.Styles{BorderMuted: muted, BorderActive: active, BorderWarn: warn}
328+
329+ got := noticeStylesFrom(styles)
330+
331+ require.Equal(t, active, got.BoxError)
332+ require.Equal(t, muted, got.BoxInfo)
333+ // BoxWarn must use the dedicated BorderWarn role, not fall back to the
334+ // same muted role BoxInfo uses - that was the pre-existing bug this
335+ // role fixes (Warn and Info toasts were visually identical).
336+ require.Equal(t, warn, got.BoxWarn)
337+ require.NotEqual(t, got.BoxInfo, got.BoxWarn)
338+}
M internal/usecase/daemon/render_test.go +90 -0
@@ -8,6 +8,7 @@ import (
88 "log/slog"
99 "strconv"
1010 "strings"
11+ "sync"
1112 "sync/atomic"
1213 "testing"
1314 "time"
@@ -218,6 +219,84 @@ func fireCoordinatorTimer(t *testing.T, rc *renderCoordinator, timers []*coordin
218219 t.Fatalf("coordinator did not arm %s timer", duration)
219220 }
220221
222+func TestPTYReaderLogsClosureWithoutNotification(t *testing.T) {
223+ pty := portsmocks.NewMockPTY(t)
224+ readErr := errors.New("pty master closed")
225+ pty.EXPECT().Read(mock.Anything).Return(0, readErr).Once()
226+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
227+ var logs bytes.Buffer
228+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
229+ pane := sess.tabs[0].focusedPane()
230+ pane.onExit = func() {}
231+
232+ d.sessWg.Add(1)
233+ d.ptyReader(sess, sess.tabs[0], pane)
234+
235+ require.Contains(t, logs.String(), "level=INFO")
236+ require.Contains(t, logs.String(), "msg=\"pane pty closed\"")
237+ require.Contains(t, logs.String(), "err=\"pty master closed\"")
238+ require.Contains(t, logs.String(), "session=work")
239+ require.Empty(t, d.notices.history(), "PTY closure is diagnostic only")
240+ toasts, _ := visibleToasts(ac)
241+ require.Empty(t, toasts, "PTY closure must not notify the user")
242+}
243+
244+type concurrentExitPTY struct {
245+ started chan struct{}
246+ release chan struct{}
247+}
248+
249+func newConcurrentExitPTY() *concurrentExitPTY {
250+ return &concurrentExitPTY{started: make(chan struct{}), release: make(chan struct{})}
251+}
252+
253+func (p *concurrentExitPTY) Read([]byte) (int, error) {
254+ close(p.started)
255+ <-p.release
256+ return 0, io.EOF
257+}
258+func (*concurrentExitPTY) Write(b []byte) (int, error) { return len(b), nil }
259+func (*concurrentExitPTY) Close() error { return nil }
260+func (*concurrentExitPTY) Resize(domain.Size) error { return nil }
261+func (*concurrentExitPTY) Pid() int { return 0 }
262+func (*concurrentExitPTY) ForegroundPgid() (int, error) {
263+ return 0, nil
264+}
265+
266+func TestPTYReaderReadsSessionNameUnderLockDuringConcurrentRename(t *testing.T) {
267+ // Repeated synchronized exits make the reader's closure log contend with a
268+ // rename. Running this under -race proves the name snapshot uses sess.mu.
269+ for range 100 {
270+ pty := newConcurrentExitPTY()
271+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
272+ pane := sess.tabs[0].focusedPane()
273+ pane.onExit = func() {}
274+
275+ d.sessWg.Add(1)
276+ go d.ptyReader(sess, sess.tabs[0], pane)
277+ <-pty.started
278+
279+ start := make(chan struct{})
280+ renameErr := make(chan error, 1)
281+ var wg sync.WaitGroup
282+ wg.Add(2)
283+ go func() {
284+ defer wg.Done()
285+ <-start
286+ close(pty.release)
287+ }()
288+ go func() {
289+ defer wg.Done()
290+ <-start
291+ renameErr <- d.renameSession(sess, "renamed")
292+ }()
293+ close(start)
294+ wg.Wait()
295+ require.NoError(t, <-renameErr)
296+ d.sessWg.Wait()
297+ }
298+}
299+
221300 func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
222301 t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) {
223302 inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t)
@@ -1066,6 +1145,17 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
10661145 }
10671146 }
10681147
1148+func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
1149+ _, sess, _, _ := newManualSessionWithPTYs(t, nil)
1150+ p := sess.tabs[0].focusedPane()
1151+
1152+ p.mu.Lock()
1153+ cursor := captureCursorInputsLocked(p, domain.Rect{Width: 80, Height: 23}, capturedOverlayRenderState{noticesOverlayActive: true})
1154+ p.mu.Unlock()
1155+
1156+ require.True(t, cursor.hiddenByOverlay)
1157+}
1158+
10691159 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
10701160 p, _ := newBlockingPTY(t)
10711161 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/session.go +43 -11
@@ -19,6 +19,11 @@ import (
1919 "github.com/bnema/vev/internal/usecase/layout"
2020 )
2121
22+var (
23+ errSessionNameRequired = errors.New("name required")
24+ errSessionNameInUse = errors.New("name already in use")
25+)
26+
2227 type terminalEnv struct {
2328 TrueColor bool
2429 }
@@ -145,7 +150,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
145150 var err error
146151 createdAt, err = d.allocateLifecycleCreatedAtLocked()
147152 if err != nil {
148- return nil, err
153+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
149154 }
150155 }
151156 }
@@ -160,14 +165,14 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
160165 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
161166 if err != nil {
162167 closeTabs(tabs)
163- return nil, err
168+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
164169 }
165170 command, args := d.ptyCommand(env)
166171 pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
167172 if err != nil {
168173 closeTabs(tabs)
169174 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
170- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
175+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
171176 }
172177 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
173178 if i < len(names) {
@@ -207,7 +212,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
207212 if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
208213 closeTabs(tabs)
209214 cancel()
210- return nil, err
215+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
211216 }
212217 delete(d.stopped, name)
213218 }
@@ -233,7 +238,7 @@ func closeTabs(tabs []*tab) {
233238
234239 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
235240 if name == "" {
236- return errors.New("name required")
241+ return errSessionNameRequired
237242 }
238243 if err := domain.ValidateSessionName(name); err != nil {
239244 return err
@@ -246,14 +251,19 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
246251 }
247252 if d.nameLiveOrStoppedLocked(name) {
248253 d.mu.Unlock()
249- return errors.New("name already in use")
254+ return errSessionNameInUse
250255 }
256+ // The caller owns d.mu. Make the entire ownership handoff atomic with
257+ // global routing so it cannot select a client between source removal and
258+ // destination publication.
259+ d.notices.routingMu.Lock()
251260 from.mu.Lock()
252261 cwd := from.cwd
253262 term := from.terminal
254263 env := copyEnvironment(from.env)
255264 if from.client != ac {
256265 from.mu.Unlock()
266+ d.notices.routingMu.Unlock()
257267 d.mu.Unlock()
258268 return errors.New("client detached")
259269 }
@@ -261,12 +271,14 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
261271
262272 newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
263273 if err != nil {
274+ d.notices.routingMu.Unlock()
264275 d.mu.Unlock()
265276 return err
266277 }
267278 from.mu.Lock()
268279 if from.client != ac {
269280 from.mu.Unlock()
281+ d.notices.routingMu.Unlock()
270282 d.mu.Unlock()
271283 _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
272284 return errors.New("client detached")
@@ -286,6 +298,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
286298 d.touchMRU(newSess)
287299 ac.recordPreviousSession(from)
288300 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
301+ d.notices.routingMu.Unlock()
289302 d.mu.Unlock()
290303 finishRenderLifecycleCleanups(cleanups)
291304 d.firstPaint(newSess, ac, sz)
@@ -309,7 +322,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
309322 pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
310323 if err != nil {
311324 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
312- return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
325+ return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
313326 }
314327 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
315328 if client != nil {
@@ -459,6 +472,15 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
459472 return false
460473 }
461474
475+// detachIfCurrent serializes client removal with global notice routing. The
476+// routing lock is released before the caller performs teardown, so it covers
477+// only attachment ownership and is never held across transport work.
478+func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
479+ d.notices.routingMu.Lock()
480+ defer d.notices.routingMu.Unlock()
481+ return sess.detachIfCurrent(ac)
482+}
483+
462484 func (s *session) activeTab() *tab {
463485 s.mu.Lock()
464486 defer s.mu.Unlock()
@@ -494,7 +516,7 @@ func (s *session) switchRelative(delta int) bool {
494516
495517 func (d *Daemon) renameSession(sess *session, name string) error {
496518 if name == "" {
497- return errors.New("name required")
519+ return errSessionNameRequired
498520 }
499521 if err := domain.ValidateSessionName(name); err != nil {
500522 return err
@@ -502,10 +524,10 @@ func (d *Daemon) renameSession(sess *session, name string) error {
502524 d.mu.Lock()
503525 defer d.mu.Unlock()
504526 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
505- return errors.New("name already in use")
527+ return errSessionNameInUse
506528 }
507529 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
508- return errors.New("name already in use")
530+ return errSessionNameInUse
509531 }
510532 sess.mu.Lock()
511533 oldName := sess.name
@@ -632,8 +654,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
632654 name := sess.name
633655 sess.mu.Unlock()
634656 d.mu.Unlock()
657+ if err := d.killSession(sess, ports.ReasonSessionKilled, false); err != nil {
658+ d.log.Warn("closing last tab failed", "session", name, "err", err)
659+ d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
660+ "couldn't close tab: session state not yet saved; try again", err))
661+ return
662+ }
635663 d.log.Info("tab closed", "session", name, "last", true)
636- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
637664 return
638665 }
639666 ringing := tb.attention
@@ -718,6 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
718745 if reason != ports.ReasonServerShutdown {
719746 return terminalSnapshotErr
720747 }
748+ d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
721749 }
722750 }
723751 }
@@ -770,10 +798,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
770798 }
771799 }
772800
801+ // A global route that selected this attachment must publish before terminal
802+ // teardown makes it stale.
803+ d.notices.routingMu.Lock()
773804 sess.mu.Lock()
774805 ac := sess.client
775806 sess.client = nil
776807 sess.mu.Unlock()
808+ d.notices.routingMu.Unlock()
777809 if rc := sess.renderCoordinator(); rc != nil {
778810 // Terminal teardown has two phases: this session owner first prevents any
779811 // new worker registration and detaches all tokens, then stops and waits
M internal/usecase/daemon/session_back.go +3 -1
@@ -14,7 +14,9 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
1414 d.invalidateRender(current, ac, true, "session_back.go")
1515 return
1616 }
17- d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
17+ if err := d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {
18+ d.reportError(current, err)
19+ }
1820 }
1921
2022 // clearPreviousSessionIf clears target only if it has not been replaced since
M internal/usecase/daemon/session_test.go +31 -7
@@ -304,7 +304,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
304304 // A is notified it was detached.
305305 dA := awaitFrame(t, sendsA, ports.MsgDetached)
306306 det, _ := ports.UnmarshalDetached(dA.Payload)
307- require.Equal(t, ports.ReasonDetach, det.Reason)
307+ require.Equal(t, ports.ReasonReplaced, det.Reason)
308308
309309 // B is now the current client.
310310 sess.mu.Lock()
@@ -1079,7 +1079,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
10791079 require.NoError(t, d.renameSession(second, "named"))
10801080 require.NotEqual(t, staleCreatedAt, second.createdAt)
10811081
1082- require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
1082+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
10831083 require.Same(t, from, ac.currentSession())
10841084 releaseSecond()
10851085 d.sessWg.Wait()
@@ -1211,7 +1211,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
12111211 WithStore(store)(d)
12121212 require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
12131213 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
1214- d.killPickerTarget(picker.Target{Name: "old", Stopped: true})
1214+ require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
12151215 require.False(t, state.has("old"))
12161216 d.mu.Lock()
12171217 _, ok := d.stopped["old"]
@@ -1304,6 +1304,30 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) {
13041304 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
13051305 }
13061306
1307+func TestCreateTabPtyFailureIsUserError(t *testing.T) {
1308+ sz := domain.Size{Cols: 80, Rows: 24}
1309+ p, release := newBlockingPTY(t)
1310+ defer release()
1311+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
1312+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1313+ require.NoError(t, err)
1314+
1315+ cause := errors.New("fork/exec: no such file")
1316+ failing := portsmocks.NewMockPTYFactory(t)
1317+ failing.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
1318+ d.ptys = failing
1319+
1320+ err = d.createTab(sess, sz)
1321+
1322+ var ue *domain.UserError
1323+ require.ErrorAs(t, err, &ue)
1324+ require.Equal(t, domain.NoticeTabSpawn, ue.Code)
1325+ require.Equal(t, domain.NoticeError, ue.Severity)
1326+ require.Equal(t, "couldn't open tab: shell failed to start", ue.Msg)
1327+ require.ErrorIs(t, err, cause)
1328+ require.NotContains(t, ue.Msg, cause.Error())
1329+}
1330+
13071331 func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
13081332 sz := domain.Size{Cols: 80, Rows: 24}
13091333 p1, release1 := newBlockingPTY(t)
@@ -1705,7 +1729,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17051729 target.createdAt = 22
17061730 expected := int64(21)
17071731
1708- require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1732+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
17091733 require.Same(t, from, ac.currentSession())
17101734 })
17111735
@@ -1719,7 +1743,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17191743 expected := int64(31)
17201744 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
17211745
1722- require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1746+ require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
17231747 require.Equal(t, "target", ac.currentSession().name)
17241748 require.Equal(t, expected, ac.currentSession().createdAt)
17251749 })
@@ -1731,7 +1755,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17311755 target.createdAt = 41
17321756 expected := int64(41)
17331757
1734- require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1758+ require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
17351759 require.Same(t, target, ac.currentSession())
17361760 })
17371761
@@ -1742,7 +1766,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17421766 d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
17431767 expected := int64(51)
17441768
1745- require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1769+ require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
17461770 require.Same(t, from, ac.currentSession())
17471771 })
17481772 }
M internal/usecase/daemon/snapshot.go +35 -1
@@ -62,13 +62,34 @@ func (d *Daemon) closeRestoreDone() {
6262 }
6363
6464 func (d *Daemon) restoreSnapshots(ctx context.Context) {
65+ if d == nil {
66+ return
67+ }
6568 defer d.closeRestoreDone()
66- if d == nil || !d.snapsEnabled || d.snaps == nil {
69+ if ns := d.noticeStore; ns != nil {
70+ claimed, err := ns.Claim()
71+ if err != nil {
72+ d.log.Warn("claiming pending notices failed", "err", err)
73+ } else {
74+ for _, n := range claimed {
75+ d.notices.record(n)
76+ d.notices.queueGlobal(n)
77+ }
78+ // Do not discard the claim until every notice has been recorded and
79+ // queued. An Ack failure is safe: the claim is replayed at startup.
80+ if err := ns.Ack(); err != nil {
81+ d.log.Warn("acknowledging pending notices failed", "err", err)
82+ }
83+ }
84+ }
85+ if !d.snapsEnabled || d.snaps == nil {
6786 return
6887 }
6988 blobs, err := d.snaps.Load()
7089 if err != nil {
7190 d.log.Warn("loading session snapshots failed", "err", err)
91+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
92+ "couldn't load saved sessions after restart", err)
7293 return
7394 }
7495 for _, blob := range blobs {
@@ -80,10 +101,16 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
80101 snap, err := snapcodec.Unmarshal(blob.Data)
81102 if err != nil {
82103 d.log.Warn("decoding session snapshot failed", "err", err, "session", blob.Name)
104+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
105+ "couldn't restore session "+blob.Name+" after restart", err)
83106 continue
84107 }
85108 if err := d.restoreSession(ctx, snap); err != nil {
86109 d.log.Warn("restoring session snapshot failed", "err", err, "session", snap.Name)
110+ if ctx.Err() == nil {
111+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore,
112+ "couldn't restore session "+snap.Name+" after restart", err)
113+ }
87114 }
88115 }
89116 }
@@ -209,6 +236,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
209236 if restoreCommand != "" {
210237 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
211238 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
239+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume,
240+ "couldn't restore the running program in session "+snap.Name, err)
212241 }
213242 }
214243 tb.panes[paneSnap.ID] = p
@@ -579,6 +608,11 @@ func (d *Daemon) startSnapshotEncodeWorker() {
579608 d.clearSnapshotWorkerInFlight(workerID, capture)
580609 if err != nil && workerCtx.Err() == nil {
581610 d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
611+ // Global, not session-scoped: by now the session may already be
612+ // torn down, so a session notice would be dead-on-arrival. No lock
613+ // is held here (clearSnapshotWorkerInFlight released its own).
614+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite,
615+ "couldn't save session "+capture.name+"; recent state may be lost on restart", err)
582616 }
583617 d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
584618 return workerCtx.Err() == nil
A internal/usecase/daemon/snapshot_notice_test.go +121 -0
@@ -0,0 +1,121 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "sync/atomic"
7+ "testing"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+// TestSnapshotWriteFailureNotifiesGlobally proves a failed snapshot write surfaces
18+// as a GLOBAL notice: the failing session is never registered (it stands in for a
19+// session already torn down by the time its async write fails), yet a client
20+// attached to a DIFFERENT, surviving session still receives the toast. This is the
21+// whole point of routing through NotifyGlobal instead of a session-scoped notice.
22+func TestSnapshotWriteFailureNotifiesGlobally(t *testing.T) {
23+ // newNoticeFixture registers a survivor session "work"/"manual" owned by ac.
24+ d, _, survivorClient, _ := newNoticeFixture(t, newNoticeClock())
25+ store := portsmocks.NewMockSnapshotStore(t)
26+ WithSnapshotStore(store)(d)
27+ d.procCwd = func(int) (string, error) { return "/pane", nil }
28+ startSnapshotEncodeWorker(t, d)
29+
30+ // The failing session is deliberately NOT added to d.sessions.
31+ failing := newSnapshotTestSession(t, "failing", false, "/fallback")
32+
33+ store.EXPECT().Write("failing", mock.Anything).RunAndReturn(func(string, []byte) error {
34+ return errors.New("disk full")
35+ }).Once()
36+
37+ require.True(t, d.captureSession(failing))
38+
39+ // The write fails on the worker goroutine; awaitToastCount is the bounded,
40+ // poll-only completion signal. No require runs on the worker goroutine.
41+ toasts := awaitToastCount(t, survivorClient, 1)
42+ require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
43+ require.Equal(t, "couldn't save session failing; recent state may be lost on restart", toasts[0].Message)
44+ require.Equal(t, domain.NoticeError, toasts[0].Severity)
45+ require.Equal(t, domain.SessionID(""), toasts[0].SessionID,
46+ "snapshot write failures are global, never scoped to the (possibly gone) failing session")
47+ require.Contains(t, toasts[0].Details, "disk full")
48+}
49+
50+// TestCloseLastTabSaturatedSnapshotSurfacesNotice proves that when closing the
51+// last tab of a named session is refused because the snapshot worker cannot
52+// retain the final state, the session survives for a retry and the still
53+// attached client is told why, instead of the close silently no-oping.
54+//
55+// The snapshot worker is saturated deterministically with the same fixture as
56+// the kill-session ordering tests: one capture blocked in marshal, one in the
57+// bounded queue, and the terminal-retention fallback filled to capacity. No
58+// assertion runs on the worker goroutine; the marshal gate is released only on
59+// cleanup, after every assertion has run on the test goroutine.
60+func TestCloseLastTabSaturatedSnapshotSurfacesNotice(t *testing.T) {
61+ store := discardSnapshotStore{}
62+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
63+ WithSnapshotStore(store)(d)
64+ startSnapshotEncodeWorker(t, d)
65+
66+ entered := make(chan struct{})
67+ release := make(chan struct{})
68+ // Release the sole blocked marshal on the test goroutine before the worker
69+ // is stopped in cleanup, so teardown never waits on the gate.
70+ defer close(release)
71+ var calls atomic.Int32
72+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
73+ if calls.Add(1) == 1 {
74+ close(entered)
75+ <-release
76+ }
77+ return snapcodec.Marshal(s)
78+ }
79+
80+ active := newSnapshotTestSession(t, "active", false, "/work")
81+ queued := newSnapshotTestSession(t, "queued", false, "/work")
82+ markSnapshotDirty(active)
83+ markSnapshotDirty(queued)
84+ require.True(t, d.scheduleSnapshot(active))
85+ <-entered // the worker is now parked inside marshal for active
86+ require.True(t, d.scheduleSnapshot(queued), "one capture waits in the bounded queue")
87+ for i := range snapshotFinalQueueCapacity {
88+ s := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
89+ markSnapshotDirty(s)
90+ require.True(t, d.scheduleFinalSnapshot(s), "fill the terminal-retention fallback to capacity")
91+ }
92+
93+ // A single-tab named session whose client is still attached.
94+ target := newSnapshotTestSession(t, "target", false, "/work")
95+ tr, _ := newCapturingTransport(t)
96+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
97+ ac.initOverlays()
98+ ac.setSession(target)
99+ target.client = ac
100+ d.mu.Lock()
101+ d.sessions[target.id] = target
102+ d.mu.Unlock()
103+ require.Len(t, target.tabs, 1)
104+
105+ d.closeTab(target, target.tabs[0], false)
106+
107+ // The refused close retains the session for a retry.
108+ d.mu.Lock()
109+ require.Same(t, target, d.sessions[target.id], "a refused close of the last tab must retain the session")
110+ d.mu.Unlock()
111+
112+ // The refusal is recorded in history and shown on the still-attached client.
113+ hist := d.notices.history()
114+ require.NotEmpty(t, hist, "the refused close must be recorded as a notice")
115+ require.Equal(t, domain.NoticeSnapshotSaturated, hist[0].Code)
116+ require.Equal(t, target.id, hist[0].SessionID)
117+
118+ toasts := awaitToastCount(t, ac, 1)
119+ require.Equal(t, domain.NoticeSnapshotSaturated, toasts[0].Code)
120+ require.Equal(t, "couldn't close tab: session state not yet saved; try again", toasts[0].Message)
121+}
M internal/usecase/daemon/snapshot_restore_test.go +126 -1
@@ -59,6 +59,11 @@ func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T
5959 }
6060 }
6161
62+func TestRestoreSnapshotsNilDaemonDoesNotPanic(t *testing.T) {
63+ var d *Daemon
64+ require.NotPanics(t, func() { d.restoreSnapshots(t.Context()) })
65+}
66+
6267 func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
6368 factory := &restorePTYFactory{}
6469 d := newTestDaemon(t, factory, stubClock{})
@@ -497,6 +502,121 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
497502 require.Nil(t, restored)
498503 }
499504
505+// TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots proves each
506+// unrestorable snapshot is recorded to history individually, while the
507+// pending-global queue dedups them by code into a single toast with the
508+// combined Count. Both blobs decode fine (valid snapcodec bytes, Marshal
509+// itself validates Active/tab range) but fail in restoreSession itself: an
510+// empty embedded session name is rejected deterministically, before any lock
511+// is taken or PTY is opened.
512+func TestRestoreSnapshotsNotifiesGlobalOnUnrestorableSnapshots(t *testing.T) {
513+ badSnap := mustSnapshotBytes(t, snapcodec.Session{Name: ""})
514+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{
515+ {Name: "bad-1", Data: badSnap},
516+ {Name: "bad-2", Data: badSnap},
517+ }}
518+ factory := &restorePTYFactory{}
519+ d := newTestDaemon(t, factory, newNoticeClock())
520+ WithSnapshotStore(store)(d)
521+
522+ // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
523+ // caller goroutine and signals completion by closing d.restoreDone.
524+ d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
525+
526+ select {
527+ case <-d.restoreDone:
528+ case <-time.After(2 * time.Second):
529+ t.Fatal("restoreSnapshots did not close restoreDone in time")
530+ }
531+
532+ require.Empty(t, factory.opens, "both snapshots must fail before any PTY is opened")
533+ require.Len(t, d.notices.history(), 2, "each failure is recorded individually")
534+ for _, n := range d.notices.history() {
535+ require.Equal(t, domain.NoticeSnapshotRestore, n.Code)
536+ require.Equal(t, domain.NoticeError, n.Severity)
537+ }
538+
539+ tr, _ := newCapturingTransport(t)
540+ sess, ac, err := d.route(ports.Hello{
541+ Version: ports.ProtocolVersion,
542+ Intent: ports.IntentEphemeral,
543+ Size: domain.Size{Cols: 80, Rows: 24},
544+ }, tr)
545+ require.NoError(t, err)
546+ d.firstPaint(sess, ac, ac.size)
547+
548+ toasts := awaitToastCount(t, ac, 1)
549+ require.Equal(t, domain.NoticeSnapshotRestore, toasts[0].Code)
550+ require.Equal(t, 2, toasts[0].Count, "two same-code failures dedup into one toast counted twice")
551+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
552+}
553+
554+func TestRestoreSnapshotsDoesNotNotifyForCancelledRestore(t *testing.T) {
555+ ctx, cancel := context.WithCancel(context.Background())
556+ factory := &restorePTYFactory{onOpen: cancel}
557+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "cancelled", Data: mustSnapshotBytes(t, snapcodec.Session{
558+ Name: "cancelled",
559+ Tabs: []snapcodec.Tab{{
560+ Cols: 80, Rows: 24,
561+ Tree: &layout.Tree{Focus: "one", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
562+ layout.NewLeaf("one"), layout.NewLeaf("two"),
563+ }}},
564+ Panes: []snapcodec.Pane{
565+ emptyTerminalPane(t, "one", "/one"),
566+ emptyTerminalPane(t, "two", "/two"),
567+ },
568+ }},
569+ })}}}
570+ d := newTestDaemon(t, factory, stubClock{})
571+ WithSnapshotStore(store)(d)
572+
573+ d.restoreSnapshots(ctx)
574+
575+ require.Len(t, factory.snapshotOpens(), 1, "cancellation stops restore before the next pane")
576+ require.Empty(t, d.notices.history(), "shutdown cancellation is not a user-visible restore failure")
577+}
578+
579+func TestRestoreSnapshotsNotifiesGlobalOnResumeCommandWriteFailure(t *testing.T) {
580+ proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
581+ store := processRestoreTestStore(t, "agent-session", proc)
582+ factory := &restorePTYFactory{writeErr: errors.New("boom")}
583+ d := newTestDaemon(t, factory, newNoticeClock())
584+ WithSnapshotStore(store)(d)
585+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
586+
587+ // Mirrors real startup (daemon.go's Serve): restoreSnapshots runs off the
588+ // caller goroutine and signals completion by closing d.restoreDone.
589+ d.sessWg.Go(func() { d.restoreSnapshots(d.serveCtx) })
590+
591+ select {
592+ case <-d.restoreDone:
593+ case <-time.After(2 * time.Second):
594+ t.Fatal("restoreSnapshots did not close restoreDone in time")
595+ }
596+
597+ require.Len(t, factory.opens, 1, "the pane still opens even though the resume write fails")
598+ require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
599+
600+ history := d.notices.history()
601+ require.Len(t, history, 1)
602+ require.Equal(t, domain.NoticeAutoResume, history[0].Code)
603+ require.Equal(t, domain.NoticeWarn, history[0].Severity)
604+ require.Equal(t, "couldn't restore the running program in session agent-session", history[0].Message)
605+
606+ tr, _ := newCapturingTransport(t)
607+ sess, ac, err := d.route(ports.Hello{
608+ Version: ports.ProtocolVersion,
609+ Intent: ports.IntentEphemeral,
610+ Size: domain.Size{Cols: 80, Rows: 24},
611+ }, tr)
612+ require.NoError(t, err)
613+ d.firstPaint(sess, ac, ac.size)
614+
615+ toasts := awaitToastCount(t, ac, 1)
616+ require.Equal(t, domain.NoticeAutoResume, toasts[0].Code)
617+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
618+}
619+
500620 func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
501621 releaseLoad := make(chan struct{})
502622 loaded := make(chan struct{})
@@ -599,6 +719,7 @@ type restorePTYFactory struct {
599719 mu sync.Mutex
600720 opens []restorePTYOpen
601721 writeErr error
722+ onOpen func()
602723 }
603724
604725 type restorePTYOpen struct {
@@ -612,10 +733,14 @@ type restorePTYOpen struct {
612733
613734 func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
614735 f.mu.Lock()
615- defer f.mu.Unlock()
616736 pty := newRestorePTY()
617737 pty.writeErr = f.writeErr
618738 f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
739+ onOpen := f.onOpen
740+ f.mu.Unlock()
741+ if onOpen != nil {
742+ onOpen()
743+ }
619744 return pty, nil
620745 }
621746
M internal/usecase/daemon/status.go +15 -15
@@ -72,7 +72,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
7272 func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
7373 clearStatusRow(row, styles.SurfaceBar)
7474 x := 0
75- rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
75+ rightText := composeBottomRightText(state.bottomRight, state.statusFeedback)
7676 writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive)
7777 if state.rankedRecent != nil {
7878 for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
@@ -120,14 +120,14 @@ func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, attention
120120 writeStatusText(row, x, " ", style)
121121 }
122122
123-func composeBottomRightText(scriptText, copyFeedback string) string {
123+func composeBottomRightText(scriptText, statusFeedback string) string {
124124 if scriptText == "" {
125- return copyFeedback
125+ return statusFeedback
126126 }
127- if copyFeedback == "" {
127+ if statusFeedback == "" {
128128 return scriptText
129129 }
130- return scriptText + " " + copyFeedback
130+ return scriptText + " " + statusFeedback
131131 }
132132
133133 func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
@@ -158,7 +158,7 @@ type barState struct {
158158 status statusSnapshot
159159 topRight string
160160 bottomRight string
161- copyFeedback string
161+ statusFeedback string
162162 mru []recentSession
163163 rankedRecent []rankedRecent
164164 attentionFrame int
@@ -224,31 +224,31 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
224224 // barStateForPaletteHints selects snapshot-only contextual composition when a
225225 // palette interaction has captured recent-session hints. Normal palette state
226226 // continues to use the canonical, live MRU list.
227-func (d *Daemon) barStateForPaletteHints(cur *session, copyFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
227+func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
228228 ranked := rankedRecentForHints(hints, recent)
229229 if ranked != nil {
230- return d.barStateForContextual(cur, copyFeedback, ranked)
230+ return d.barStateForContextual(cur, statusFeedback, ranked)
231231 }
232- return d.barStateFor(cur, copyFeedback)
232+ return d.barStateFor(cur, statusFeedback)
233233 }
234234
235235 // barStateForContextual composes ranks captured for the palette interaction.
236236 // It deliberately does not call recentSessions: live MRU changes must not
237237 // affect contextual rendering, and palette rendering must not acquire d.mu.
238-func (d *Daemon) barStateForContextual(cur *session, copyFeedback string, ranked []rankedRecent) barState {
239- state := d.barStateBase(cur, copyFeedback)
238+func (d *Daemon) barStateForContextual(cur *session, statusFeedback string, ranked []rankedRecent) barState {
239+ state := d.barStateBase(cur, statusFeedback)
240240 state.rankedRecent = ranked
241241 return state
242242 }
243243
244-func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
245- state := d.barStateBase(cur, copyFeedback)
244+func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
245+ state := d.barStateBase(cur, statusFeedback)
246246 state.mru = d.recentSessions(cur)
247247 return state
248248 }
249249
250-func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState {
251- state := barState{copyFeedback: copyFeedback}
250+func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
251+ state := barState{statusFeedback: statusFeedback}
252252 if d != nil {
253253 state.attentionFrame = d.attentionFrame()
254254 }
M internal/usecase/daemon/status_test.go +8 -8
@@ -868,17 +868,17 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
868868 win.size = domain.Size{Cols: 30, Rows: 2}
869869
870870 frame := composeFrame(capturedRenderState{
871- reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "ok"},
871+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "ok"},
872872 }, composeCacheInput{}).frame
873873 require.Equal(t, " work ok", rowText(frame.Row(3)))
874874
875875 frame = composeFrame(capturedRenderState{
876- reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "1234567890123456789"},
876+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "1234567890123456789"},
877877 }, composeCacheInput{}).frame
878878 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
879879
880880 frame = composeFrame(capturedRenderState{
881- reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "selection too large to copy"},
881+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "selection too large to copy"},
882882 }, composeCacheInput{}).frame
883883 require.Equal(t, " work ", rowText(frame.Row(3)))
884884 }
@@ -1295,21 +1295,21 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
12951295 {
12961296 name: "script text and copy feedback",
12971297 width: 32,
1298- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "a"}}},
1298+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "a"}}},
12991299 wantContains: []string{" a"},
13001300 wantSuffix: " main ↑3 * copied",
13011301 },
13021302 {
13031303 name: "hide on overlap",
13041304 width: 16,
1305- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
1305+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
13061306 want: " cur ",
13071307 wantNotContains: []string{"main", "copied"},
13081308 },
13091309 {
13101310 name: "empty script keeps copy feedback behavior",
13111311 width: 12,
1312- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
1312+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
13131313 want: " cur copied",
13141314 },
13151315 {
@@ -1364,7 +1364,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
13641364 }
13651365
13661366 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1367- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1367+ state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
13681368 row := make([]renderer.Cell, 20)
13691369
13701370 drawStatusBarState(row, state, resolveStyles(nil))
@@ -1377,7 +1377,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
13771377 }
13781378
13791379 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1380- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}}
1380+ state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}
13811381 tests := []struct {
13821382 name string
13831383 cols int
A internal/usecase/daemon/toast_damage_test.go +156 -0
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) {
14+ baseState := toastDamageState(nil, nil, true)
15+ base := composeFrame(baseState, composeCacheInput{})
16+ stream := newOutputStateStream()
17+ terminal := vt.NewScreen(base.frame.Width, base.frame.Height)
18+ replayToastFrame(t, stream, terminal, base)
19+
20+ one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
21+ shownState := toastDamageState([]domain.Notification{one}, nil, false)
22+ shown := composeFrame(shownState, base.cache)
23+ requireNoFullRedraw(t, shown.damage)
24+ requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints)
25+ replayToastFrame(t, stream, terminal, shown)
26+
27+ // A one-cell PTY update while the toast is stable redraws only the pane cell
28+ // and toast footprint, never the whole terminal.
29+ updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
30+ updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
31+ updated := composeFrame(updatedState, shown.cache)
32+ requireNoFullRedraw(t, updated.damage)
33+ require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
34+ requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
35+ requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)
36+ incremental, err := stream.render(updated.frame, updated.damage, updated.reset)
37+ require.NoError(t, err)
38+ fullRenderer := renderer.New(renderer.Capabilities{})
39+ full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
40+ require.NoError(t, err)
41+ require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")
42+ terminal.Write(incremental)
43+ require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame))
44+
45+ // Stacking and overflow stay visually identical while their complete old and
46+ // new coverage remains incremental damage.
47+ two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1}
48+ three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1}
49+ stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false)
50+ stackedState.overlays.noticeOverflow = 2
51+ stacked := composeFrame(stackedState, updated.cache)
52+ requireNoFullRedraw(t, stacked.damage)
53+ requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints)
54+ requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints)
55+ replayToastFrame(t, stream, terminal, stacked)
56+
57+ // Coalescing changes the title count in place. Both old and new coverage is
58+ // damaged even where their geometry happens to be identical.
59+ coalescedTwo := two
60+ coalescedTwo.Count = 12
61+ coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false)
62+ coalescedState.overlays.noticeOverflow = 2
63+ coalesced := composeFrame(coalescedState, stacked.cache)
64+ requireNoFullRedraw(t, coalesced.damage)
65+ requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints)
66+ requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints)
67+ replayToastFrame(t, stream, terminal, coalesced)
68+
69+ // Dismissal and expiry have the same rendering transition: no current toast
70+ // exists, so the previous footprint alone must restore every exposed cell.
71+ for _, tt := range []struct {
72+ name string
73+ from composedRenderFrame
74+ }{
75+ {name: "dismiss", from: coalesced},
76+ {name: "expiry", from: shown},
77+ } {
78+ t.Run(tt.name, func(t *testing.T) {
79+ cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache)
80+ requireNoFullRedraw(t, cleared.damage)
81+ requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints)
82+ require.Empty(t, cleared.cache.toastFootprints)
83+ })
84+ }
85+
86+ cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache)
87+ replayToastFrame(t, stream, terminal, cleared)
88+ require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts")
89+}
90+
91+func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) {
92+ base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{})
93+ notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
94+ shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{})
95+ cache, scratch := shown.cache, base.cache
96+ state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
97+
98+ b.ReportAllocs()
99+ for i := 0; b.Loop(); i++ {
100+ cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()}
101+ state.panes[0].frame.Set(0, 0, cell)
102+ out := composeFrame(state, cache, scratch)
103+ if hasFullRedraw(out.damage) {
104+ b.Fatal("stable toast forced a full redraw")
105+ }
106+ scratch, cache = cache, out.cache
107+ }
108+}
109+
110+func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState {
111+ paneFrame := renderer.NewFrame(80, 20)
112+ for y := 0; y < paneFrame.Height; y++ {
113+ for x := 0; x < paneFrame.Width; x++ {
114+ paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
115+ }
116+ }
117+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}}
118+ return capturedRenderState{
119+ reset: reset,
120+ layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true},
121+ panes: []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}},
122+ overlays: capturedOverlayRenderState{notices: notices},
123+ styles: resolveStyles(nil),
124+ }
125+}
126+
127+func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
128+ t.Helper()
129+ data, err := stream.render(composed.frame, composed.damage, composed.reset)
130+ require.NoError(t, err)
131+ terminal.Write(data)
132+ require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))
133+}
134+
135+func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {
136+ t.Helper()
137+ for _, d := range damage {
138+ require.NotEqual(t, renderer.DamageFullRedraw, d.Kind)
139+ }
140+}
141+
142+func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) {
143+ t.Helper()
144+ for _, footprint := range footprints {
145+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
146+ }
147+}
148+
149+func hasFullRedraw(damage []renderer.Damage) bool {
150+ for _, d := range damage {
151+ if d.Kind == renderer.DamageFullRedraw {
152+ return true
153+ }
154+ }
155+ return false
156+}
M internal/usecase/daemon/transactional_resize.go +17 -0
@@ -71,8 +71,22 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
7171 // applyResize deliberately holds only a pane's resizeMu around its PTY call;
7272 // no daemon, session, tab, or pane lock crosses the external boundary.
7373 func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
74+ // Collect degradation across the loop and report once from the explicit
75+ // lock-free return points. notify acquires sess.mu/d.mu and repaints, so it
76+ // must never run while a pane's resizeMu is held.
77+ failed := 0
78+ var lastErr error
79+ reportFailure := func() {
80+ if failed == 0 {
81+ return
82+ }
83+ // A failure can only come from a plan member, so members[0] is safe.
84+ d.notify(plan.members[0].session, domain.NoticeWarn, domain.NoticeResizeFailed,
85+ "pane resize failed; retrying in background", lastErr)
86+ }
7487 for i := range plan.members {
7588 if len(current) != 0 && !current[0]() {
89+ reportFailure()
7690 return false
7791 }
7892 m := &plan.members[i]
@@ -91,6 +105,8 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
91105 m.ok = true
92106 } else if err := pty.Resize(rectSize(m.rect)); err != nil {
93107 d.log.Warn("pty resize failed", "err", err)
108+ failed++
109+ lastErr = err
94110 d.replayResizePending(m.session, m.tab, m.pane, false, m.rect)
95111 } else {
96112 m.ok = true
@@ -99,6 +115,7 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
99115 }
100116 m.pane.resizeMu.Unlock()
101117 }
118+ reportFailure()
102119 return true
103120 }
104121
M internal/usecase/daemon/transactional_resize_test.go +96 -0
@@ -204,6 +204,102 @@ func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
204204 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
205205 }
206206
207+// P3 degradation: a failing PTY resize is surfaced as exactly one warn notice
208+// per applyResize call, collected inside the resizeMu loop and reported once
209+// after it returns to lock-free code. The count is the meaningful assertion:
210+// notify acquires sess.mu/d.mu and must never run while any resizeMu is held.
211+func TestTransactionalResizeApplyReportsFailureOncePerCall(t *testing.T) {
212+ countResizeFailed := func(d *Daemon) (int, domain.Notification) {
213+ var last domain.Notification
214+ count := 0
215+ for _, h := range d.notices.history() {
216+ if h.Code == domain.NoticeResizeFailed {
217+ count++
218+ last = h
219+ }
220+ }
221+ return count, last
222+ }
223+
224+ t.Run("one failed member yields one notice after return", func(t *testing.T) {
225+ failing := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
226+ d, sess, _, _ := newManualSessionWithPTYs(t, failing)
227+ tb := sess.activeTab()
228+ p := tb.focusedPane()
229+ plan := resizePlan{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: domain.Rect{Width: 100, Height: 20}, retry: true}}}
230+
231+ require.Empty(t, d.notices.history(), "no notice is recorded before applyResize")
232+ require.True(t, d.applyResize(&plan))
233+
234+ count, n := countResizeFailed(d)
235+ require.Equal(t, 1, count, "exactly one NoticeResizeFailed per applyResize call")
236+ require.Equal(t, domain.NoticeWarn, n.Severity)
237+ require.Equal(t, "pane resize failed; retrying in background", n.Message)
238+ require.False(t, plan.members[0].ok, "failed member is not marked ok")
239+ })
240+
241+ for _, tt := range []struct {
242+ name string
243+ firstErr error
244+ secondErr error
245+ current func() bool
246+ wantResult bool
247+ wantCount int
248+ }{
249+ {
250+ name: "normal completion reports one combined failure",
251+ firstErr: errors.New("first fails"),
252+ secondErr: errors.New("second fails"),
253+ wantResult: true,
254+ wantCount: 1,
255+ },
256+ {
257+ name: "stale current after failure still reports once",
258+ firstErr: errors.New("first fails"),
259+ secondErr: errors.New("must not be resized"),
260+ current: func() func() bool {
261+ checks := 0
262+ return func() bool {
263+ checks++
264+ return checks == 1
265+ }
266+ }(),
267+ wantResult: false,
268+ wantCount: 1,
269+ },
270+ {
271+ name: "normal completion without failure does not report",
272+ wantResult: true,
273+ wantCount: 0,
274+ },
275+ } {
276+ t.Run(tt.name, func(t *testing.T) {
277+ first := &transactionalResizePTY{errs: []error{tt.firstErr}}
278+ second := &transactionalResizePTY{errs: []error{tt.secondErr}}
279+ d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
280+ tabs := sess.tabs
281+ plan := resizePlan{members: []resizeMember{
282+ {session: sess, tab: tabs[0], pane: tabs[0].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
283+ {session: sess, tab: tabs[1], pane: tabs[1].focusedPane(), rect: domain.Rect{Width: 100, Height: 20}, retry: true},
284+ }}
285+
286+ var got bool
287+ if tt.current == nil {
288+ got = d.applyResize(&plan)
289+ } else {
290+ got = d.applyResize(&plan, tt.current)
291+ }
292+ require.Equal(t, tt.wantResult, got)
293+
294+ count, _ := countResizeFailed(d)
295+ require.Equal(t, tt.wantCount, count, "one notice per applyResize call, including stale returns")
296+ if !tt.wantResult {
297+ require.Equal(t, []domain.Size(nil), second.requested(), "stale current check must stop before the next member")
298+ }
299+ })
300+ }
301+}
302+
207303 // S3 acceptance: a failed member never publishes speculative parser/screen or
208304 // rectangle state. Successful members and the client layout commit together;
209305 // composition clips/pads the retained failed screen against that new layout.
A internal/usecase/notices/model.go +180 -0
@@ -0,0 +1,180 @@
1+// Package notices renders the notification history overlay: a scrollable,
2+// newest-first list of the daemon's recorded notifications.
3+package notices
4+
5+import (
6+ "fmt"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/ui"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+// RenderStyles are the styles Render uses to draw list rows. The zero value
15+// is never used directly by daemon composition, which always supplies a
16+// theme-derived RenderStyles the way picker.RenderStyles is supplied.
17+type RenderStyles struct {
18+ Background renderer.Style // fills unused interior rows
19+ Base renderer.Style // non-selected row fill
20+ Selection renderer.Style // selected row fill
21+ Text renderer.Style // non-selected message segment
22+ SelectionText renderer.Style // selected row message segment
23+ Muted renderer.Style // non-selected severity/time/slug segment
24+ SelectionMuted renderer.Style // selected row severity/time/slug segment
25+}
26+
27+// Model is the history overlay's list state: a snapshot of notifications,
28+// newest first, with a navigable selection. now anchors the relative
29+// timestamps rendered next to each row and is fixed at construction so the
30+// overlay's times don't drift while it stays open.
31+type Model struct {
32+ rows []domain.Notification
33+ selected int
34+ now time.Time
35+}
36+
37+// New builds a Model from entries, expected newest-first as returned by the
38+// notice center's history(). now is the instant relative row times render
39+// against.
40+func New(entries []domain.Notification, now time.Time) *Model {
41+ m := &Model{rows: append([]domain.Notification(nil), entries...), now: now, selected: -1}
42+ if len(m.rows) > 0 {
43+ m.selected = 0
44+ }
45+ return m
46+}
47+
48+func (m *Model) Up() {
49+ m.move(-1)
50+}
51+
52+func (m *Model) Down() {
53+ m.move(1)
54+}
55+
56+func (m *Model) move(delta int) {
57+ if m == nil || len(m.rows) == 0 {
58+ return
59+ }
60+ next := m.selected + delta
61+ if next < 0 {
62+ next = 0
63+ }
64+ if next >= len(m.rows) {
65+ next = len(m.rows) - 1
66+ }
67+ m.selected = next
68+}
69+
70+// Selected returns the highlighted notification, or false when history is
71+// empty.
72+func (m *Model) Selected() (domain.Notification, bool) {
73+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
74+ return domain.Notification{}, false
75+ }
76+ return m.rows[m.selected], true
77+}
78+
79+// Clone returns an independent copy safe to render outside the lock that
80+// guards the live model.
81+func (m *Model) Clone() *Model {
82+ if m == nil {
83+ return nil
84+ }
85+ clone := *m
86+ clone.rows = append([]domain.Notification(nil), m.rows...)
87+ return &clone
88+}
89+
90+// Render draws the history list into inner, scrolling to keep the selection
91+// visible. Degenerate dimensions draw nothing; an empty history draws a
92+// centered placeholder instead of a blank box.
93+func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
94+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
95+ if frame.Width <= 0 || frame.Height <= 0 {
96+ return frame
97+ }
98+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Background})
99+ if m == nil || len(m.rows) == 0 {
100+ text := "No notifications"
101+ x := max(0, (frame.Width-textWidth(text))/2)
102+ ui.DrawText(frame, x, frame.Height/2, frame.Width, text, styles.Text)
103+ return frame
104+ }
105+ offset := m.scrollOffset(frame.Height)
106+ for y := 0; y < frame.Height; y++ {
107+ idx := offset + y
108+ if idx >= len(m.rows) {
109+ break
110+ }
111+ n := m.rows[idx]
112+ base, mutedStyle, textStyle := styles.Base, styles.Muted, styles.Text
113+ if idx == m.selected {
114+ base, mutedStyle, textStyle = styles.Selection, styles.SelectionMuted, styles.SelectionText
115+ }
116+ ui.FillRect(frame, domain.Rect{X: 0, Y: y, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
117+ meta := fmt.Sprintf("[%c] %s %s ", severityLetter(n.Severity), relativeTime(m.now, n.Time), n.Code.String())
118+ x := ui.DrawText(frame, 0, y, frame.Width, meta, mutedStyle)
119+ ui.DrawText(frame, x, y, frame.Width, rowMessage(n), textStyle)
120+ }
121+ return frame
122+}
123+
124+// rowMessage appends a "×N" coalesce count suffix. The format string matches
125+// the toast overlay's own count formatting (internal/usecase/ui.ComposeNotices),
126+// but the field it is appended to differs: the toast appends to Title, while
127+// this row appends to Message, matching this overlay's row layout.
128+func rowMessage(n domain.Notification) string {
129+ if n.Count > 1 {
130+ return fmt.Sprintf("%s ×%d", n.Message, n.Count)
131+ }
132+ return n.Message
133+}
134+
135+func (m *Model) scrollOffset(visible int) int {
136+ if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
137+ return 0
138+ }
139+ if m.selected < visible {
140+ return 0
141+ }
142+ offset := m.selected - visible + 1
143+ return min(offset, len(m.rows)-visible)
144+}
145+
146+func severityLetter(sev domain.NoticeSeverity) byte {
147+ switch sev {
148+ case domain.NoticeWarn:
149+ return 'W'
150+ case domain.NoticeError:
151+ return 'E'
152+ default:
153+ return 'I'
154+ }
155+}
156+
157+// relativeTime renders how long ago t was, relative to now, in the coarsest
158+// unit that keeps the row compact. A non-positive delta (including clock
159+// skew that puts t in the future) reads as "just now" rather than negative.
160+func relativeTime(now, t time.Time) string {
161+ d := now.Sub(t)
162+ switch {
163+ case d < time.Minute:
164+ return "just now"
165+ case d < time.Hour:
166+ return fmt.Sprintf("%dm ago", int(d/time.Minute))
167+ case d < 24*time.Hour:
168+ return fmt.Sprintf("%dh ago", int(d/time.Hour))
169+ default:
170+ return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
171+ }
172+}
173+
174+func textWidth(text string) int {
175+ width := 0
176+ for _, r := range text {
177+ width += renderer.RuneWidth(r)
178+ }
179+ return width
180+}
A internal/usecase/notices/model_test.go +224 -0
@@ -0,0 +1,224 @@
1+package notices
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+func testStyles() RenderStyles {
14+ selection := renderer.DefaultStyle()
15+ selection.Inverse = true
16+ base := renderer.DefaultStyle()
17+ return RenderStyles{Background: base, Base: base, Selection: selection, Text: base, SelectionText: selection, Muted: base, SelectionMuted: selection}
18+}
19+
20+// rowText flattens one rendered row back to text, skipping wide-rune
21+// continuation cells, so assertions can check substrings.
22+func rowText(f renderer.Frame, y int) string {
23+ var b strings.Builder
24+ for _, c := range f.Row(y) {
25+ if c.Continuation {
26+ continue
27+ }
28+ if c.Rune == 0 {
29+ b.WriteRune(' ')
30+ continue
31+ }
32+ b.WriteRune(c.Rune)
33+ }
34+ return b.String()
35+}
36+
37+func TestNewSelectsNewestFirstEntry(t *testing.T) {
38+ now := time.Unix(1000, 0)
39+ entries := []domain.Notification{
40+ {Code: domain.NoticePaneSpawn, Message: "newest", Time: now},
41+ {Code: domain.NoticeTabSpawn, Message: "oldest", Time: now.Add(-time.Hour)},
42+ }
43+ m := New(entries, now)
44+ got, ok := m.Selected()
45+ if !ok || got.Message != "newest" {
46+ t.Fatalf("Selected() = %+v, %v, want newest, true", got, ok)
47+ }
48+}
49+
50+func TestNavigationClampsAtBothEnds(t *testing.T) {
51+ tests := []struct {
52+ name string
53+ moves func(m *Model)
54+ want string
55+ }{
56+ {"up from top stays at top", func(m *Model) { m.Up(); m.Up() }, "a"},
57+ {"down past bottom stays at bottom", func(m *Model) { m.Down(); m.Down(); m.Down() }, "c"},
58+ {"down then up returns to top", func(m *Model) { m.Down(); m.Down(); m.Up(); m.Up() }, "a"},
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ now := time.Unix(1000, 0)
63+ m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}, {Message: "c", Time: now}}, now)
64+ tt.moves(m)
65+ got, ok := m.Selected()
66+ if !ok || got.Message != tt.want {
67+ t.Fatalf("Selected() = %q, %v, want %q, true", got.Message, ok, tt.want)
68+ }
69+ })
70+ }
71+}
72+
73+func TestSelectedAndNavigationOnEmptyHistory(t *testing.T) {
74+ m := New(nil, time.Unix(1000, 0))
75+ if _, ok := m.Selected(); ok {
76+ t.Fatal("Selected() ok = true on empty history, want false")
77+ }
78+ m.Up()
79+ m.Down()
80+ if _, ok := m.Selected(); ok {
81+ t.Fatal("Selected() ok = true after navigating empty history, want false")
82+ }
83+}
84+
85+func TestRelativeTime(t *testing.T) {
86+ now := time.Unix(10000, 0)
87+ tests := []struct {
88+ name string
89+ at time.Time
90+ want string
91+ }{
92+ {"seconds ago", now.Add(-30 * time.Second), "just now"},
93+ {"two minutes ago", now.Add(-2 * time.Minute), "2m ago"},
94+ {"fifty nine minutes ago", now.Add(-59 * time.Minute), "59m ago"},
95+ {"two hours ago", now.Add(-2 * time.Hour), "2h ago"},
96+ {"two days ago", now.Add(-48 * time.Hour), "2d ago"},
97+ {"future clock skew", now.Add(time.Minute), "just now"},
98+ }
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ if got := relativeTime(now, tt.at); got != tt.want {
102+ t.Errorf("relativeTime(now, %v) = %q, want %q", tt.at, got, tt.want)
103+ }
104+ })
105+ }
106+}
107+
108+func TestRenderRowContainsSeverityCodeSlugAndCount(t *testing.T) {
109+ now := time.Unix(10000, 0)
110+ entries := []domain.Notification{
111+ {Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Count: 3, Time: now.Add(-2 * time.Minute)},
112+ }
113+ m := New(entries, now)
114+ frame := m.Render(domain.Size{Cols: 60, Rows: 5}, testStyles())
115+ row := rowText(frame, 0)
116+ for _, want := range []string{"[E]", "2m ago", "pane-spawn", "couldn't open pane", "×3"} {
117+ if !strings.Contains(row, want) {
118+ t.Errorf("row %q does not contain %q", row, want)
119+ }
120+ }
121+}
122+
123+func TestRenderSeverityLetterPerSeverity(t *testing.T) {
124+ now := time.Unix(10000, 0)
125+ tests := []struct {
126+ sev domain.NoticeSeverity
127+ want string
128+ }{
129+ {domain.NoticeInfo, "[I]"},
130+ {domain.NoticeWarn, "[W]"},
131+ {domain.NoticeError, "[E]"},
132+ }
133+ for _, tt := range tests {
134+ m := New([]domain.Notification{{Severity: tt.sev, Message: "m", Time: now}}, now)
135+ frame := m.Render(domain.Size{Cols: 40, Rows: 3}, testStyles())
136+ row := rowText(frame, 0)
137+ if !strings.Contains(row, tt.want) {
138+ t.Errorf("severity %v: row %q does not contain %q", tt.sev, row, tt.want)
139+ }
140+ }
141+}
142+
143+func TestRenderEmptyHistoryShowsPlaceholderNotBlank(t *testing.T) {
144+ m := New(nil, time.Unix(0, 0))
145+ frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles())
146+ blank := true
147+ for y := 0; y < frame.Height; y++ {
148+ if strings.TrimSpace(rowText(frame, y)) != "" {
149+ blank = false
150+ break
151+ }
152+ }
153+ if blank {
154+ t.Fatal("Render() on empty history drew nothing, want a placeholder")
155+ }
156+}
157+
158+func TestRenderDegenerateDimensionsDoesNotPanic(t *testing.T) {
159+ m := New([]domain.Notification{{Message: "x", Time: time.Unix(0, 0)}}, time.Unix(0, 0))
160+ tests := []struct {
161+ size domain.Size
162+ wantWidth int
163+ wantHeight int
164+ }{
165+ // Render clamps each negative dimension to 0 independently via
166+ // max(inner.Cols, 0) / max(inner.Rows, 0); it never clamps the pair
167+ // together, so a negative Cols with a valid Rows still yields a
168+ // zero-width, full-height frame (and vice versa).
169+ {domain.Size{Cols: 0, Rows: 0}, 0, 0},
170+ {domain.Size{Cols: -1, Rows: 5}, 0, 5},
171+ {domain.Size{Cols: 5, Rows: -1}, 5, 0},
172+ {domain.Size{Cols: 0, Rows: 5}, 0, 5},
173+ {domain.Size{Cols: 1, Rows: 1}, 1, 1},
174+ }
175+ for _, tt := range tests {
176+ frame := m.Render(tt.size, testStyles())
177+ if frame.Width != tt.wantWidth || frame.Height != tt.wantHeight {
178+ t.Fatalf("Render(%+v) = frame{Width: %d, Height: %d}, want {Width: %d, Height: %d}", tt.size, frame.Width, frame.Height, tt.wantWidth, tt.wantHeight)
179+ }
180+ }
181+}
182+
183+func TestRenderScrollsToKeepSelectionVisible(t *testing.T) {
184+ now := time.Unix(10000, 0)
185+ entries := make([]domain.Notification, 200)
186+ for i := range entries {
187+ entries[i] = domain.Notification{Message: fmt.Sprintf("n%d", i), Time: now}
188+ }
189+ m := New(entries, now)
190+ for range 150 {
191+ m.Down()
192+ }
193+ frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles())
194+ found := false
195+ for y := 0; y < frame.Height; y++ {
196+ if strings.Contains(rowText(frame, y), "n150") {
197+ found = true
198+ break
199+ }
200+ }
201+ if !found {
202+ t.Fatal("Render() scrolled the selected row out of view")
203+ }
204+}
205+
206+func TestCloneIsIndependentOfOriginal(t *testing.T) {
207+ now := time.Unix(10000, 0)
208+ m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}}, now)
209+ clone := m.Clone()
210+ m.Down()
211+ if got, _ := clone.Selected(); got.Message != "a" {
212+ t.Fatalf("clone.Selected() = %q, want a (clone must not observe later mutation)", got.Message)
213+ }
214+ if got, _ := m.Selected(); got.Message != "b" {
215+ t.Fatalf("m.Selected() = %q, want b", got.Message)
216+ }
217+}
218+
219+func TestCloneOfNilIsNil(t *testing.T) {
220+ var m *Model
221+ if clone := m.Clone(); clone != nil {
222+ t.Fatalf("Clone() of nil = %v, want nil", clone)
223+ }
224+}
M internal/usecase/theme/color_oklab.go +34 -0
@@ -52,6 +52,40 @@ func okLabChroma(color oklab) float64 {
5252 return math.Hypot(color.A, color.B)
5353 }
5454
55+// oklch is the polar (lightness, chroma, hue) form of oklab. Hue is degrees
56+// in [0, 360).
57+type oklch struct {
58+ L float64
59+ C float64
60+ H float64
61+}
62+
63+// oklabToOKLCh converts to the polar form used to isolate hue from
64+// lightness and chroma.
65+func oklabToOKLCh(color oklab) oklch {
66+ hue := math.Atan2(color.B, color.A) * 180 / math.Pi
67+ if hue < 0 {
68+ hue += 360
69+ }
70+ return oklch{L: color.L, C: okLabChroma(color), H: hue}
71+}
72+
73+// oklchToOKLab converts back from the polar form.
74+func oklchToOKLab(color oklch) oklab {
75+ rad := color.H * math.Pi / 180
76+ return oklab{L: color.L, A: color.C * math.Cos(rad), B: color.C * math.Sin(rad)}
77+}
78+
79+// shiftHue replaces color's OKLCh hue with targetDegrees while preserving
80+// its lightness and chroma, so the result stays in the same value/intensity
81+// band as the source. A near-neutral source (chroma close to zero) yields a
82+// near-neutral result regardless of the target: hue is not meaningful there.
83+func shiftHue(color renderer.RGB, targetDegrees float64) renderer.RGB {
84+ lch := oklabToOKLCh(rgbToOKLab(color))
85+ lch.H = targetDegrees
86+ return okLabToRGB(oklchToOKLab(lch))
87+}
88+
5589 // okLabLerp interpolates sRGB endpoints in OKLab. Exact endpoints avoid
5690 // round-trip quantization changing terminal-provided colors.
5791 func okLabLerp(a, b renderer.RGB, weight float64) renderer.RGB {
M internal/usecase/theme/color_oklab_test.go +75 -0
@@ -93,6 +93,81 @@ func TestOKLabToRGBClampsOutOfGamutChannels(t *testing.T) {
9393 }
9494 }
9595
96+func TestOKLabOKLChRoundTrip(t *testing.T) {
97+ colors := []oklab{
98+ rgbToOKLab(renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}),
99+ rgbToOKLab(renderer.RGB{R: 0xff}),
100+ rgbToOKLab(renderer.RGB{B: 0xff}),
101+ rgbToOKLab(renderer.RGB{R: 128, G: 128, B: 128}),
102+ }
103+ for _, want := range colors {
104+ got := oklchToOKLab(oklabToOKLCh(want))
105+ if math.Abs(got.L-want.L) > 1e-9 || math.Abs(got.A-want.A) > 1e-9 || math.Abs(got.B-want.B) > 1e-9 {
106+ t.Fatalf("oklch round trip %+v = %+v, want %+v", want, got, want)
107+ }
108+ }
109+}
110+
111+func TestOKLabToOKLChHueIsDegreesInRange(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ rgb renderer.RGB
115+ want float64
116+ }{
117+ // Known reference hues, computed with the same conversion this
118+ // package uses: red sits near 29 degrees, blue near 264, amber
119+ // (#FFBF00) near 84.
120+ {name: "red", rgb: renderer.RGB{R: 255}, want: 29.2},
121+ {name: "amber #FFBF00", rgb: renderer.RGB{R: 0xff, G: 0xbf, B: 0x00}, want: 84.1},
122+ }
123+ for _, tt := range tests {
124+ t.Run(tt.name, func(t *testing.T) {
125+ got := oklabToOKLCh(rgbToOKLab(tt.rgb))
126+ if got.H < 0 || got.H >= 360 {
127+ t.Fatalf("hue %v out of [0, 360) range", got.H)
128+ }
129+ if math.Abs(got.H-tt.want) > 0.5 {
130+ t.Fatalf("hue = %v, want ~%v", got.H, tt.want)
131+ }
132+ })
133+ }
134+}
135+
136+func TestShiftHuePreservesLightnessAndChromaButReplacesHue(t *testing.T) {
137+ tests := []struct {
138+ name string
139+ source renderer.RGB
140+ }{
141+ {name: "blue accent", source: renderer.RGB{R: 0x6c, G: 0x9b, B: 0xd9}},
142+ {name: "red accent", source: renderer.RGB{R: 0xcc, G: 0x66, B: 0x66}},
143+ {name: "green accent", source: renderer.RGB{R: 0x6c, G: 0xae, B: 0x6c}},
144+ }
145+ const target = warnHueDegrees
146+
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ sourceLCh := oklabToOKLCh(rgbToOKLab(tt.source))
150+ shifted := shiftHue(tt.source, target)
151+ shiftedLCh := oklabToOKLCh(rgbToOKLab(shifted))
152+
153+ // sRGB gamut clamping in okLabToRGB can perturb L/C slightly for
154+ // out-of-gamut combinations; allow a small tolerance. These three
155+ // sources sit safely inside the gamut at the amber target hue
156+ // (verified independently), so a tight tolerance still catches a
157+ // broken implementation.
158+ if math.Abs(shiftedLCh.L-sourceLCh.L) > 0.03 {
159+ t.Fatalf("lightness drifted: got %v, want ~%v", shiftedLCh.L, sourceLCh.L)
160+ }
161+ if math.Abs(shiftedLCh.C-sourceLCh.C) > 0.03 {
162+ t.Fatalf("chroma drifted: got %v, want ~%v", shiftedLCh.C, sourceLCh.C)
163+ }
164+ if math.Abs(shiftedLCh.H-target) > 1 {
165+ t.Fatalf("hue = %v, want ~%v", shiftedLCh.H, target)
166+ }
167+ })
168+ }
169+}
170+
96171 func channelDelta(a, b uint8) uint8 {
97172 if a > b {
98173 return a - b
M internal/usecase/theme/ramp.go +28 -0
@@ -5,6 +5,15 @@ import "github.com/bnema/vev/pkg/renderer"
55 const (
66 normalTextContrast = 4.5
77 borderContrast = 3.0
8+
9+ // warnHueDegrees is the fixed OKLCh hue target for the Warn border role.
10+ // Reference amber hues under this package's own rgbToOKLab: conventional
11+ // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at
12+ // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in
13+ // that band, so a warn border reads as amber/gold regardless of which
14+ // hue the user's single accent happens to occupy (blue ~256, red ~29,
15+ // green ~145 under the same conversion).
16+ warnHueDegrees = 75.0
817 )
918
1019 // Ramp is the contrast-safe set of semantic terminal chrome colors.
@@ -15,6 +24,7 @@ type Ramp struct {
1524 SurfaceActive renderer.Style
1625 BorderMuted renderer.Style
1726 BorderActive renderer.Style
27+ BorderWarn renderer.Style
1828
1929 background renderer.RGB
2030 accent renderer.RGB
@@ -52,6 +62,7 @@ func BuildRamp(t Theme, accent Accent) Ramp {
5262 SurfaceActive: active,
5363 BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB),
5464 BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB),
65+ BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
5566 background: t.Background,
5667 accent: accent.RGB,
5768 rgb: true,
@@ -130,6 +141,22 @@ func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
130141 return neutralBorder(t)
131142 }
132143
144+// warnBorder searches the same accent-derived weight scale activeBorder
145+// uses, but replaces each candidate's hue with the fixed amber target before
146+// checking contrast: the result keeps the accent's lightness/chroma band
147+// (so it belongs to the ramp) while always reading as amber, regardless of
148+// the user's accent hue.
149+func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
150+ for weight := 100; weight >= 0; weight-- {
151+ candidate := okLabLerp(t.Background, accent, float64(weight)/100)
152+ amber := shiftHue(candidate, warnHueDegrees)
153+ if ContrastRatio(amber, adjacent) >= borderContrast {
154+ return foregroundStyle(amber)
155+ }
156+ }
157+ return neutralBorder(t)
158+}
159+
133160 func foregroundStyle(color renderer.RGB) renderer.Style {
134161 style := renderer.DefaultStyle()
135162 style.HasForegroundRGB = true
@@ -146,6 +173,7 @@ func neutralRamp(t Theme) Ramp {
146173 SurfaceActive: styles.SurfaceActive,
147174 BorderMuted: styles.BorderMuted,
148175 BorderActive: styles.BorderActive,
176+ BorderWarn: styles.BorderWarn,
149177 }
150178 }
151179
M internal/usecase/theme/ramp_test.go +50 -0
@@ -1,6 +1,7 @@
11 package theme
22
33 import (
4+ "math"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
@@ -74,6 +75,52 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
7475 require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
7576 }
7677
78+func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
79+ theme := rampTheme(false)
80+
81+ for name, accentRGB := range map[string]renderer.RGB{
82+ "blue accent": {R: 0x6c, G: 0x9b, B: 0xd9},
83+ "red accent": {R: 0xcc, G: 0x66, B: 0x66},
84+ } {
85+ t.Run(name, func(t *testing.T) {
86+ accent := Accent{RGB: accentRGB, Known: true}
87+ ramp := BuildRamp(theme, accent)
88+
89+ require.True(t, ramp.BorderWarn.HasForegroundRGB)
90+ require.False(t, ramp.BorderWarn.HasBackgroundRGB)
91+
92+ accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H
93+ warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H
94+ distanceToTarget := func(h float64) float64 {
95+ d := math.Abs(h - warnHueDegrees)
96+ if d > 180 {
97+ d = 360 - d
98+ }
99+ return d
100+ }
101+ // Warn must land measurably closer to the amber target than the
102+ // accent itself sits, in both directions (blue and the
103+ // adversarial case of a red accent, which sits close to zero
104+ // degrees and could otherwise get confused with an
105+ // under-rotated "warm" accent border).
106+ require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue))
107+ require.Less(t, distanceToTarget(warnHue), 5.0)
108+
109+ // Distinct from the sibling borders by more than a rounding
110+ // difference: use the same OKLab distance scale accent
111+ // clustering already treats as "different colors"
112+ // (accentClusterDistance groups colors within 0.04).
113+ warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB)
114+ mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB)
115+ activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB)
116+ require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance)
117+ require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance)
118+
119+ require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast)
120+ })
121+ }
122+}
123+
77124 func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
78125 theme := rampTheme(false)
79126 ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
@@ -94,6 +141,7 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
94141 require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
95142 require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
96143 require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
144+ require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
97145 require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
98146 require.True(t, resolved.Styles.TabActive.Bold)
99147 require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
@@ -112,6 +160,8 @@ func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
112160 require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
113161 require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
114162 require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
163+ require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground)
164+ require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB)
115165
116166 theme.TrueColor = true
117167 theme.UsePalette = false
M internal/usecase/theme/resolved_styles.go +3 -0
@@ -45,6 +45,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles {
4545 SurfaceActive: ramp.SurfaceActive,
4646 BorderMuted: ramp.BorderMuted,
4747 BorderActive: ramp.BorderActive,
48+ BorderWarn: ramp.BorderWarn,
4849 NeutralBorder: neutralBorder(t),
4950
5051 TabInactive: ramp.SurfaceInactive,
@@ -81,6 +82,7 @@ func secondarySurface(base renderer.Style) renderer.Style {
8182 func indexedStyles(styles Styles, slot uint8) Styles {
8283 styles.BorderMuted = indexedForeground(styles.BorderMuted, slot)
8384 styles.BorderActive = indexedForeground(styles.BorderActive, slot)
85+ styles.BorderWarn = indexedForeground(styles.BorderWarn, slot)
8486 styles.TabInactiveTitle = indexedForeground(styles.TabInactiveTitle, slot)
8587 styles.TabActiveTitle = indexedForeground(styles.TabActiveTitle, slot)
8688 styles.PickerDescription = indexedForeground(styles.PickerDescription, slot)
@@ -131,6 +133,7 @@ func neutralStyles(t Theme) Styles {
131133 SurfaceActive: accent,
132134 BorderMuted: border,
133135 BorderActive: border,
136+ BorderWarn: border,
134137 NeutralBorder: border,
135138
136139 TabInactive: EmphasisStyle(status, t),
M internal/usecase/theme/styles.go +6 -0
@@ -72,6 +72,12 @@ type Styles struct {
7272 SurfaceActive renderer.Style
7373 BorderMuted renderer.Style
7474 BorderActive renderer.Style
75+ // BorderWarn is the amber-hued warn-severity border. Unlike every other
76+ // role in this camaïeu palette it does not blend toward the resolved
77+ // accent color: it keeps the accent's lightness/chroma band but shifts
78+ // hue to a fixed amber target (see warnHueDegrees), so Warn reads as
79+ // amber and stays distinct from Error regardless of the accent hue.
80+ BorderWarn renderer.Style
7581 // NeutralBorder is the non-accent structural border for pane dividers and
7682 // unfocused pane title bars. It is resolved with the rest of the immutable
7783 // chrome snapshot so composition never derives theme colors.
A internal/usecase/ui/notice.go +164 -0
@@ -0,0 +1,164 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const (
12+ noticeMargin = 1
13+ noticeMaxWidth = 60
14+ noticeMinWidth = 24
15+ noticeMaxLines = 3
16+)
17+
18+// NoticeView is a plain rendering view of one notification.
19+type NoticeView struct {
20+ Severity domain.NoticeSeverity
21+ Title string
22+ Message string
23+ Count int
24+}
25+
26+// NoticeStyles selects severity-colored styles for each box's border and title.
27+// Text styles the wrapped message body and overflow indicator; it excludes the
28+// title, which is drawn with the box's severity style.
29+type NoticeStyles struct {
30+ Text renderer.Style
31+ BoxInfo renderer.Style
32+ BoxWarn renderer.Style
33+ BoxError renderer.Style
34+}
35+
36+// ComposeNotices stacks notice boxes top-right of frame, newest first. Each
37+// box's width starts at frame.Width*2/5, is capped at noticeMaxWidth (60),
38+// then floored at noticeMinWidth (24), then clamped down to frame.Width so it
39+// never exceeds the frame — on frames narrower than the floor, that final
40+// clamp overrides the floor. Each box is titled with the notice's slug and an
41+// optional " ×N" count suffix, and its message is word-wrapped to at most 3
42+// lines. Boxes stack downward with one blank row between them; once the
43+// count behind overflow is nonzero, a right-aligned "+N more" line follows
44+// the last box.
45+func ComposeNotices(frame renderer.Frame, notices []NoticeView, overflow int, styles NoticeStyles) []domain.Rect {
46+ if frame.Width <= 0 || frame.Height <= 0 || (len(notices) == 0 && overflow <= 0) {
47+ return nil
48+ }
49+
50+ footprints := make([]domain.Rect, 0, len(notices)+1)
51+
52+ width := frame.Width * 2 / 5
53+ if width > noticeMaxWidth {
54+ width = noticeMaxWidth
55+ }
56+ if width < noticeMinWidth {
57+ width = noticeMinWidth
58+ }
59+ if width > frame.Width {
60+ width = frame.Width
61+ }
62+ if width <= 0 {
63+ return nil
64+ }
65+ x := frame.Width - noticeMargin - width
66+ y := noticeMargin
67+
68+ for _, n := range notices {
69+ if y >= frame.Height {
70+ break
71+ }
72+ innerWidth := width - 2
73+ lines := wrapNoticeText(n.Message, innerWidth, noticeMaxLines)
74+ if len(lines) == 0 {
75+ lines = []string{""}
76+ }
77+ height := len(lines) + 2
78+ bounds := domain.Rect{X: x, Y: y, Width: width, Height: height}
79+ footprints = append(footprints, bounds)
80+ box := noticeBoxStyle(styles, n.Severity)
81+ DrawBox(frame, bounds, box)
82+
83+ title := n.Title
84+ if n.Count > 1 {
85+ title = fmt.Sprintf("%s ×%d", n.Title, n.Count)
86+ }
87+ if title != "" && bounds.Width > 2 {
88+ left := bounds.X + 1
89+ right := bounds.X + bounds.Width - 1
90+ start := max(left, bounds.X+(bounds.Width-textWidth(title))/2)
91+ DrawText(frame, start, bounds.Y, right, title, box)
92+ }
93+
94+ inner := domain.Rect{X: bounds.X + 1, Y: bounds.Y + 1, Width: max(0, bounds.Width-2), Height: max(0, bounds.Height-2)}
95+ if inner.Width > 0 && inner.Height > 0 {
96+ fill := renderer.BlankCell()
97+ fill.Style = styles.Text
98+ FillRect(frame, inner, fill)
99+ for i, line := range lines {
100+ if i >= inner.Height {
101+ break
102+ }
103+ DrawText(frame, inner.X, inner.Y+i, inner.X+inner.Width, line, styles.Text)
104+ }
105+ }
106+
107+ y += height + 1
108+ }
109+
110+ if overflow > 0 && y < frame.Height {
111+ text := fmt.Sprintf("+%d more", overflow)
112+ start := x + width - textWidth(text)
113+ if start < x {
114+ start = x
115+ }
116+ DrawText(frame, start, y, x+width, text, styles.Text)
117+ footprints = append(footprints, domain.Rect{X: start, Y: y, Width: x + width - start, Height: 1})
118+ }
119+ return footprints
120+}
121+
122+func noticeBoxStyle(styles NoticeStyles, severity domain.NoticeSeverity) renderer.Style {
123+ switch severity {
124+ case domain.NoticeWarn:
125+ return styles.BoxWarn
126+ case domain.NoticeError:
127+ return styles.BoxError
128+ default:
129+ return styles.BoxInfo
130+ }
131+}
132+
133+// wrapNoticeText greedily fills lines up to width, then caps at maxLines by
134+// collapsing any remainder into the last line and truncating it, so an
135+// overlong message is clipped visibly rather than silently dropped mid-word.
136+func wrapNoticeText(message string, width, maxLines int) []string {
137+ if width <= 0 || maxLines <= 0 {
138+ return nil
139+ }
140+ words := strings.Fields(message)
141+ if len(words) == 0 {
142+ return nil
143+ }
144+
145+ lines := make([]string, 0, maxLines)
146+ current := words[0]
147+ for _, w := range words[1:] {
148+ candidate := current + " " + w
149+ if textWidth(candidate) <= width {
150+ current = candidate
151+ continue
152+ }
153+ lines = append(lines, current)
154+ current = w
155+ }
156+ lines = append(lines, current)
157+
158+ if len(lines) <= maxLines {
159+ return lines
160+ }
161+ rest := strings.Join(lines[maxLines-1:], " ")
162+ lines = lines[:maxLines-1]
163+ return append(lines, TruncateText(rest, width))
164+}
A internal/usecase/ui/notice_test.go +280 -0
@@ -0,0 +1,280 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+// frameText joins every row of frame into one newline-separated string so
13+// tests can search for substrings without caring which cells they land on.
14+func frameText(f renderer.Frame) string {
15+ rows := make([]string, f.Height)
16+ for y := 0; y < f.Height; y++ {
17+ var b strings.Builder
18+ for x := 0; x < f.Width; x++ {
19+ cell := f.At(x, y)
20+ if cell.Continuation {
21+ continue
22+ }
23+ if cell.Rune == 0 {
24+ b.WriteByte(' ')
25+ continue
26+ }
27+ b.WriteRune(cell.Rune)
28+ }
29+ rows[y] = b.String()
30+ }
31+ return strings.Join(rows, "\n")
32+}
33+
34+// firstRowContaining returns the index of the first row in f whose rendered
35+// text contains s, or -1 if no row does.
36+func firstRowContaining(f renderer.Frame, s string) int {
37+ for y, row := range strings.Split(frameText(f), "\n") {
38+ if strings.Contains(row, s) {
39+ return y
40+ }
41+ }
42+ return -1
43+}
44+
45+func TestComposeNotices(t *testing.T) {
46+ styles := NoticeStyles{
47+ Text: renderer.Style{Foreground: 7, Background: -1},
48+ BoxInfo: renderer.Style{Foreground: 4, Background: -1},
49+ BoxWarn: renderer.Style{Foreground: 3, Background: -1},
50+ BoxError: renderer.Style{Foreground: 1, Background: -1},
51+ }
52+ isBorderRune := func(r rune) bool {
53+ switch r {
54+ case '┌', '┐', '└', '┘', '─', '│':
55+ return true
56+ default:
57+ return false
58+ }
59+ }
60+
61+ tests := []struct {
62+ name string
63+ notices []NoticeView
64+ overflow int
65+ check func(t *testing.T, f renderer.Frame)
66+ }{
67+ {
68+ name: "single error notice draws a bordered box top-right with title and message",
69+ notices: []NoticeView{
70+ {Severity: domain.NoticeError, Title: "pane-spawn", Message: "couldn't open pane", Count: 3},
71+ },
72+ check: func(t *testing.T, f renderer.Frame) {
73+ found := false
74+ for y := 0; y <= 8 && !found; y++ {
75+ for x := 40; x < f.Width; x++ {
76+ if isBorderRune(f.At(x, y).Rune) {
77+ found = true
78+ break
79+ }
80+ }
81+ }
82+ if !found {
83+ t.Fatalf("no box border found in top-right quadrant (x>=40, y<=8):\n%s", frameText(f))
84+ }
85+ text := frameText(f)
86+ if !strings.Contains(text, "pane-spawn ×3") {
87+ t.Fatalf("frame missing title %q:\n%s", "pane-spawn ×3", text)
88+ }
89+ if !strings.Contains(text, "couldn't open pane") {
90+ t.Fatalf("frame missing message %q:\n%s", "couldn't open pane", text)
91+ }
92+ },
93+ },
94+ {
95+ name: "three notices with overflow renders a +N more line",
96+ notices: []NoticeView{
97+ {Severity: domain.NoticeError, Title: "pane-spawn", Message: "one"},
98+ {Severity: domain.NoticeWarn, Title: "tab-spawn", Message: "two"},
99+ {Severity: domain.NoticeInfo, Title: "clipboard", Message: "three"},
100+ },
101+ overflow: 2,
102+ check: func(t *testing.T, f renderer.Frame) {
103+ text := frameText(f)
104+ if !strings.Contains(text, "+2 more") {
105+ t.Fatalf("frame missing overflow line %q:\n%s", "+2 more", text)
106+ }
107+ rowOne := firstRowContaining(f, "one")
108+ rowTwo := firstRowContaining(f, "two")
109+ rowThree := firstRowContaining(f, "three")
110+ if rowOne < 0 || rowTwo < 0 || rowThree < 0 {
111+ t.Fatalf("could not locate all three notice messages in frame: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text)
112+ }
113+ if rowOne >= rowTwo || rowTwo >= rowThree {
114+ t.Fatalf("notices not stacked newest-first top-down: one=%d two=%d three=%d\n%s", rowOne, rowTwo, rowThree, text)
115+ }
116+ },
117+ },
118+ {
119+ name: "no notices and no overflow draws nothing",
120+ notices: nil,
121+ overflow: 0,
122+ check: func(t *testing.T, f renderer.Frame) {
123+ want := renderer.NewFrame(f.Width, f.Height)
124+ if frameText(f) != frameText(want) {
125+ t.Fatalf("frame changed with no notices and no overflow:\n%s", frameText(f))
126+ }
127+ },
128+ },
129+ }
130+
131+ for _, tt := range tests {
132+ t.Run(tt.name, func(t *testing.T) {
133+ f := renderer.NewFrame(80, 24)
134+ ComposeNotices(f, tt.notices, tt.overflow, styles)
135+ tt.check(t, f)
136+ })
137+ }
138+}
139+
140+func TestComposeNoticesSeverityStylingIsDistinguishable(t *testing.T) {
141+ styles := NoticeStyles{
142+ Text: renderer.Style{Foreground: 7, Background: -1},
143+ BoxInfo: renderer.Style{Foreground: 4, Background: -1},
144+ BoxWarn: renderer.Style{Foreground: 3, Background: -1},
145+ BoxError: renderer.Style{Foreground: 1, Background: -1},
146+ }
147+
148+ tests := []struct {
149+ name string
150+ sev domain.NoticeSeverity
151+ want renderer.Style
152+ }{
153+ {"info", domain.NoticeInfo, styles.BoxInfo},
154+ {"warn", domain.NoticeWarn, styles.BoxWarn},
155+ {"error", domain.NoticeError, styles.BoxError},
156+ }
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ f := renderer.NewFrame(80, 24)
160+ ComposeNotices(f, []NoticeView{{Severity: tt.sev, Title: "x", Message: "y"}}, 0, styles)
161+ corner := findRune(f, '┌')
162+ if corner == nil {
163+ t.Fatalf("no box top-left corner found:\n%s", frameText(f))
164+ }
165+ if !corner.Style.Equal(tt.want) {
166+ t.Fatalf("box border style = %+v, want %s style %+v", corner.Style, tt.name, tt.want)
167+ }
168+ })
169+ }
170+}
171+
172+// findRune returns the first cell in f carrying r, or nil if none does.
173+func findRune(f renderer.Frame, r rune) *renderer.Cell {
174+ for y := 0; y < f.Height; y++ {
175+ for x := 0; x < f.Width; x++ {
176+ if cell := f.At(x, y); cell.Rune == r {
177+ return &cell
178+ }
179+ }
180+ }
181+ return nil
182+}
183+
184+// findRunePos returns the coordinates of the first cell in f carrying r, or
185+// (-1, -1) if no cell does.
186+func findRunePos(f renderer.Frame, r rune) (int, int) {
187+ for y := 0; y < f.Height; y++ {
188+ for x := 0; x < f.Width; x++ {
189+ if f.At(x, y).Rune == r {
190+ return x, y
191+ }
192+ }
193+ }
194+ return -1, -1
195+}
196+
197+// TestComposeNoticesWidth pins the box's actual width and left edge across
198+// representative terminal widths, so the test fails if the *2/5 ratio, the
199+// 60-column cap, the 24-column floor, or the final frame-width clamp changes.
200+// Each expected x and width below is worked out by hand from the formula
201+// documented on ComposeNotices, not recomputed from the implementation.
202+func TestComposeNoticesWidth(t *testing.T) {
203+ styles := NoticeStyles{
204+ Text: renderer.Style{Foreground: 7, Background: -1},
205+ BoxInfo: renderer.Style{Foreground: 4, Background: -1},
206+ BoxWarn: renderer.Style{Foreground: 3, Background: -1},
207+ BoxError: renderer.Style{Foreground: 1, Background: -1},
208+ }
209+
210+ tests := []struct {
211+ name string
212+ frameWidth int
213+ wantWidth int
214+ wantX int // -1 means the box's left edge falls off-frame (x < 0)
215+ }{
216+ // 200 cols: 200*2/5=80, capped at 60. x = 200 - 1(margin) - 60 = 139.
217+ {name: "60-column cap binds on a wide terminal", frameWidth: 200, wantWidth: 60, wantX: 139},
218+ // 100 cols: 100*2/5=40, under the cap and floor. x = 100 - 1 - 40 = 59.
219+ {name: "cols*2/5 ratio binds on a mid terminal", frameWidth: 100, wantWidth: 40, wantX: 59},
220+ // 40 cols: 40*2/5=16, floored to 24. x = 40 - 1 - 24 = 15.
221+ {name: "24-column floor binds on a narrow terminal", frameWidth: 40, wantWidth: 24, wantX: 15},
222+ // 20 cols: 20*2/5=8, floored to 24, then clamped down to frame.Width
223+ // (20) since 24 > 20. x = 20 - 1(margin) - 20 = -1, so the left edge
224+ // (and its corner rune) fall one column off-frame.
225+ {name: "frame-width clamp overrides the floor on a tiny terminal", frameWidth: 20, wantWidth: 20, wantX: -1},
226+ }
227+
228+ for _, tt := range tests {
229+ t.Run(tt.name, func(t *testing.T) {
230+ f := renderer.NewFrame(tt.frameWidth, 10)
231+ ComposeNotices(f, []NoticeView{{Severity: domain.NoticeInfo, Title: "x", Message: "y"}}, 0, styles)
232+
233+ wantRight := tt.wantX + tt.wantWidth - 1
234+ rightX, rightY := findRunePos(f, '┐')
235+ if rightX < 0 {
236+ t.Fatalf("no top-right corner found:\n%s", frameText(f))
237+ }
238+ if rightY != noticeMargin || rightX != wantRight {
239+ t.Fatalf("top-right corner at (%d,%d), want (%d,%d):\n%s", rightX, rightY, wantRight, noticeMargin, frameText(f))
240+ }
241+
242+ // The message body starts one column right of the box's left
243+ // edge (wantX+1) and one row below its top edge. Pinning this
244+ // rune's exact column catches width/x regressions that the
245+ // top-right corner can't: right = x+width-1 = frame.Width-margin-1
246+ // no matter what width is, so the right corner alone can't tell
247+ // a correctly clamped box from one whose x drifted further
248+ // off-frame (both still land off-screen).
249+ msgX, msgY := tt.wantX+1, noticeMargin+1
250+ if got := f.At(msgX, msgY).Rune; got != 'y' {
251+ t.Fatalf("message rune at (%d,%d) = %q, want 'y':\n%s", msgX, msgY, got, frameText(f))
252+ }
253+
254+ leftX, leftY := findRunePos(f, '┌')
255+ if tt.wantX < 0 {
256+ if leftX >= 0 {
257+ t.Fatalf("expected left corner to be clipped off-frame, found one at (%d,%d):\n%s", leftX, leftY, frameText(f))
258+ }
259+ return
260+ }
261+ if leftX != tt.wantX || leftY != noticeMargin {
262+ t.Fatalf("top-left corner at (%d,%d), want (%d,%d):\n%s", leftX, leftY, tt.wantX, noticeMargin, frameText(f))
263+ }
264+ if gotWidth := rightX - leftX + 1; gotWidth != tt.wantWidth {
265+ t.Fatalf("box width = %d, want %d:\n%s", gotWidth, tt.wantWidth, frameText(f))
266+ }
267+ })
268+ }
269+}
270+
271+func TestComposeNoticesHandlesTinyFrames(t *testing.T) {
272+ styles := NoticeStyles{Text: renderer.DefaultStyle(), BoxInfo: renderer.DefaultStyle(), BoxWarn: renderer.DefaultStyle(), BoxError: renderer.DefaultStyle()}
273+ for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}, {Cols: 24, Rows: 1}} {
274+ t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
275+ f := renderer.NewFrame(size.Cols, size.Rows)
276+ notices := []NoticeView{{Severity: domain.NoticeError, Title: "pane-spawn", Message: "a very long message that should wrap across several lines of text"}}
277+ ComposeNotices(f, notices, 5, styles)
278+ })
279+ }
280+}

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