detached 1a97012

feat(daemon): add notification history overlay and command

Adds a `notifications` command (NTC) that opens a scrollable, newest-first
modal listing the daemon's recorded notice history, mirroring the session
picker's overlay pattern: a notices.Model list state, an overlayRuntime slot
guarded by the existing noticeMu, and j/k/arrow navigation with q/ESC/Ctrl-C
close. Yank (Task 6) is left unbuilt.
commit
1a97012936852ed38749096d0dd632a987d4073a
author
brice · · 2026-07-20 13:29 UTC
parent
af22256
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +748 -6
M internal/usecase/command/command.go +1 -0
@@ -51,6 +51,7 @@ type Context interface {
5151 RenameSession() error
5252 RenameTab() error
5353 OpenSessionPicker() error
54+ OpenNotifications() error
5455 JumpRecentSession(rank int) error
5556 }
5657
@@ -51,6 +51,7 @@ type Context interface {
51 RenameSession() error51 RenameSession() error
52 RenameTab() error52 RenameTab() error
53 OpenSessionPicker() error53 OpenSessionPicker() error
54+ OpenNotifications() error
54 JumpRecentSession(rank int) error55 JumpRecentSession(rank int) error
55 }56 }
56 57
M internal/usecase/command/context_mock_test.go +44 -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()
@@ -614,6 +614,50 @@ func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_
614 return _c614 return _c
615 }615 }
616 616
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+
617 // OpenSessionPicker provides a mock function for the type MockContext661 // OpenSessionPicker provides a mock function for the type MockContext
618 func (_mock *MockContext) OpenSessionPicker() error {662 func (_mock *MockContext) OpenSessionPicker() error {
619 ret := _mock.Called()663 ret := _mock.Called()
M internal/usecase/command/registry.go +1 -0
@@ -31,6 +31,7 @@ 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() }},
3435 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
3536 {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() }},
3637 {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
@@ -31,6 +31,7 @@ func Registry() []Command {
31 return ctx.JumpRecentSession(rank)31 return ctx.JumpRecentSession(rank)
32 }},32 }},
33 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},33 {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() }},
34 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},35 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
35 {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() }},36 {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() }},
36 {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},37 {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 +12 -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", "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,16 @@ func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
4747 }
4848 }
4949
50+func TestRegistryIncludesNotifications(t *testing.T) {
51+ cmd, ok := ByCode("NTC")
52+ if !ok {
53+ t.Fatal("ByCode(\"NTC\") ok = false, want true")
54+ }
55+ if cmd.Slug != "notifications" || cmd.Code != "NTC" || cmd.Name != "Notifications" {
56+ t.Fatalf("notifications command = %#v, want notifications/NTC/Notifications", cmd)
57+ }
58+}
59+
5060 func TestByCodeIsCaseInsensitive(t *testing.T) {
5161 for _, code := range []string{"CNT", "cnt", "CnT"} {
5262 cmd, ok := ByCode(code)
@@ -88,6 +98,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
8898 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
8999 {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
90100 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
101+ {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }},
91102 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
92103 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
93104 {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
@@ -7,7 +7,7 @@ import (
7 7
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 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", "VIS", "RNS", "RNT", "DET"}
11 11
12 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -47,6 +47,16 @@ func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
47 }47 }
48 }48 }
49 49
50+func TestRegistryIncludesNotifications(t *testing.T) {
51+ cmd, ok := ByCode("NTC")
52+ if !ok {
53+ t.Fatal("ByCode(\"NTC\") ok = false, want true")
54+ }
55+ if cmd.Slug != "notifications" || cmd.Code != "NTC" || cmd.Name != "Notifications" {
56+ t.Fatalf("notifications command = %#v, want notifications/NTC/Notifications", cmd)
57+ }
58+}
59+
50 func TestByCodeIsCaseInsensitive(t *testing.T) {60 func TestByCodeIsCaseInsensitive(t *testing.T) {
51 for _, code := range []string{"CNT", "cnt", "CnT"} {61 for _, code := range []string{"CNT", "cnt", "CnT"} {
52 cmd, ok := ByCode(code)62 cmd, ok := ByCode(code)
@@ -88,6 +98,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
88 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},98 {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
89 {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},99 {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
90 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},100 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
101+ {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }},
91 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},102 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
92 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},103 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
93 {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},104 {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
A internal/usecase/daemon/notices_overlay.go +130 -0
@@ -0,0 +1,130 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/usecase/keys"
7+ "github.com/bnema/vev/internal/usecase/notices"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+)
10+
11+var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter}
12+
13+// enterNotices opens the notification history overlay from the daemon's full
14+// notice center history, newest first.
15+func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
16+ history := d.notices.history()
17+ now := d.clock.Now()
18+ ac.overlays.noticeMu.Lock()
19+ ac.overlays.noticesOverlay = notices.New(history, now)
20+ ac.overlays.noticesPending = nil
21+ ac.overlays.noticeMu.Unlock()
22+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
23+}
24+
25+func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
26+ sess := ac.currentSession()
27+ if sess == nil {
28+ return
29+ }
30+ ac.overlays.noticeMu.Lock()
31+ if ac.overlays.noticesOverlay == nil {
32+ ac.overlays.noticesPending = nil
33+ d.stopNoticesPendingTimerLocked(ac)
34+ ac.overlays.noticeMu.Unlock()
35+ return
36+ }
37+ if len(ac.overlays.noticesPending) > 0 {
38+ d.stopNoticesPendingTimerLocked(ac)
39+ combined := make([]byte, 0, len(ac.overlays.noticesPending)+len(data))
40+ combined = append(combined, ac.overlays.noticesPending...)
41+ combined = append(combined, data...)
42+ data = combined
43+ ac.overlays.noticesPending = nil
44+ }
45+ changed := false
46+ exit := false
47+ for i := 0; i < len(data); i++ {
48+ switch data[i] {
49+ case 'j':
50+ ac.overlays.noticesOverlay.Down()
51+ changed = true
52+ case 'k':
53+ ac.overlays.noticesOverlay.Up()
54+ changed = true
55+ case 'q', 0x03, 0x1b:
56+ if data[i] == 0x1b {
57+ tail := data[i:]
58+ consumed, ok := routeNoticesEscape(ac.overlays.noticesOverlay, tail)
59+ if ok {
60+ i += consumed - 1
61+ changed = true
62+ continue
63+ }
64+ if len(tail) == 1 {
65+ d.retainNoticesESCLocked(ac)
66+ break
67+ }
68+ if isPickerEscapePrefix(tail) {
69+ ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], tail...)
70+ break
71+ }
72+ }
73+ exit = true
74+ }
75+ }
76+ ac.overlays.noticeMu.Unlock()
77+
78+ if exit {
79+ d.closeNotices(ac)
80+ }
81+ if exit || changed {
82+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
83+ }
84+}
85+
86+func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) {
87+ ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC)
88+ ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
89+ ac.overlays.noticeMu.Lock()
90+ if ac.overlays.noticesESC.timer != timer || len(ac.overlays.noticesPending) != 1 || ac.overlays.noticesPending[0] != keys.ESC || ac.overlays.noticesOverlay == nil {
91+ ac.overlays.noticeMu.Unlock()
92+ return
93+ }
94+ ac.overlays.noticesPending = nil
95+ ac.overlays.noticesESC.timer = nil
96+ ac.overlays.noticesESC.done = nil
97+ ac.overlays.noticesOverlay = nil
98+ ac.overlays.noticeMu.Unlock()
99+
100+ if sess := ac.currentSession(); sess != nil {
101+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
102+ }
103+ })
104+}
105+
106+func (d *Daemon) stopNoticesPendingTimerLocked(ac *attachedClient) {
107+ ac.overlays.noticesESC.stop()
108+}
109+
110+func (d *Daemon) closeNotices(ac *attachedClient) {
111+ ac.overlays.noticeMu.Lock()
112+ ac.overlays.noticesOverlay = nil
113+ ac.overlays.noticesPending = nil
114+ d.stopNoticesPendingTimerLocked(ac)
115+ ac.overlays.noticeMu.Unlock()
116+}
117+
118+func routeNoticesEscape(m *notices.Model, data []byte) (int, bool) {
119+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
120+ switch data[2] {
121+ case 'A':
122+ m.Up()
123+ return 3, true
124+ case 'B':
125+ m.Down()
126+ return 3, true
127+ }
128+ }
129+ return 0, false
130+}
@@ -0,0 +1,130 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/usecase/keys"
7+ "github.com/bnema/vev/internal/usecase/notices"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+)
10+
11+var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight: 8, Title: " Notifications ", Anchor: domain.AnchorCenter}
12+
13+// enterNotices opens the notification history overlay from the daemon's full
14+// notice center history, newest first.
15+func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
16+ history := d.notices.history()
17+ now := d.clock.Now()
18+ ac.overlays.noticeMu.Lock()
19+ ac.overlays.noticesOverlay = notices.New(history, now)
20+ ac.overlays.noticesPending = nil
21+ ac.overlays.noticeMu.Unlock()
22+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
23+}
24+
25+func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
26+ sess := ac.currentSession()
27+ if sess == nil {
28+ return
29+ }
30+ ac.overlays.noticeMu.Lock()
31+ if ac.overlays.noticesOverlay == nil {
32+ ac.overlays.noticesPending = nil
33+ d.stopNoticesPendingTimerLocked(ac)
34+ ac.overlays.noticeMu.Unlock()
35+ return
36+ }
37+ if len(ac.overlays.noticesPending) > 0 {
38+ d.stopNoticesPendingTimerLocked(ac)
39+ combined := make([]byte, 0, len(ac.overlays.noticesPending)+len(data))
40+ combined = append(combined, ac.overlays.noticesPending...)
41+ combined = append(combined, data...)
42+ data = combined
43+ ac.overlays.noticesPending = nil
44+ }
45+ changed := false
46+ exit := false
47+ for i := 0; i < len(data); i++ {
48+ switch data[i] {
49+ case 'j':
50+ ac.overlays.noticesOverlay.Down()
51+ changed = true
52+ case 'k':
53+ ac.overlays.noticesOverlay.Up()
54+ changed = true
55+ case 'q', 0x03, 0x1b:
56+ if data[i] == 0x1b {
57+ tail := data[i:]
58+ consumed, ok := routeNoticesEscape(ac.overlays.noticesOverlay, tail)
59+ if ok {
60+ i += consumed - 1
61+ changed = true
62+ continue
63+ }
64+ if len(tail) == 1 {
65+ d.retainNoticesESCLocked(ac)
66+ break
67+ }
68+ if isPickerEscapePrefix(tail) {
69+ ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], tail...)
70+ break
71+ }
72+ }
73+ exit = true
74+ }
75+ }
76+ ac.overlays.noticeMu.Unlock()
77+
78+ if exit {
79+ d.closeNotices(ac)
80+ }
81+ if exit || changed {
82+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
83+ }
84+}
85+
86+func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) {
87+ ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC)
88+ ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
89+ ac.overlays.noticeMu.Lock()
90+ if ac.overlays.noticesESC.timer != timer || len(ac.overlays.noticesPending) != 1 || ac.overlays.noticesPending[0] != keys.ESC || ac.overlays.noticesOverlay == nil {
91+ ac.overlays.noticeMu.Unlock()
92+ return
93+ }
94+ ac.overlays.noticesPending = nil
95+ ac.overlays.noticesESC.timer = nil
96+ ac.overlays.noticesESC.done = nil
97+ ac.overlays.noticesOverlay = nil
98+ ac.overlays.noticeMu.Unlock()
99+
100+ if sess := ac.currentSession(); sess != nil {
101+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
102+ }
103+ })
104+}
105+
106+func (d *Daemon) stopNoticesPendingTimerLocked(ac *attachedClient) {
107+ ac.overlays.noticesESC.stop()
108+}
109+
110+func (d *Daemon) closeNotices(ac *attachedClient) {
111+ ac.overlays.noticeMu.Lock()
112+ ac.overlays.noticesOverlay = nil
113+ ac.overlays.noticesPending = nil
114+ d.stopNoticesPendingTimerLocked(ac)
115+ ac.overlays.noticeMu.Unlock()
116+}
117+
118+func routeNoticesEscape(m *notices.Model, data []byte) (int, bool) {
119+ if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
120+ switch data[2] {
121+ case 'A':
122+ m.Up()
123+ return 3, true
124+ case 'B':
125+ m.Down()
126+ return 3, true
127+ }
128+ }
129+ return 0, false
130+}
A internal/usecase/daemon/notices_overlay_test.go +125 -0
@@ -0,0 +1,125 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) {
14+ p, release := newBlockingPTY(t)
15+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
16+ defer release()
17+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)})
18+
19+ d.enterPalette(sess, ac)
20+ awaitFrame(t, sends, ports.MsgOutput)
21+ d.handlePaletteInput(ac, []byte("NTC\r"))
22+ out := awaitFrame(t, sends, ports.MsgOutput)
23+
24+ require.False(t, ac.overlays.paletteActive())
25+ require.NotNil(t, ac.overlays.noticesOverlay)
26+ msg, err := ports.UnmarshalOutput(out.Payload)
27+ require.NoError(t, err)
28+ require.Contains(t, string(msg.Data), "Notifications")
29+ require.Contains(t, string(msg.Data), "pane-spawn")
30+ require.Contains(t, string(msg.Data), "couldn't open pane")
31+}
32+
33+func TestNoticesJKNavigatesSelection(t *testing.T) {
34+ p, release := newBlockingPTY(t)
35+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
36+ defer release()
37+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
38+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
39+ d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
40+
41+ d.enterNotices(sess, ac)
42+ awaitFrame(t, sends, ports.MsgOutput)
43+ selected, ok := ac.overlays.noticesOverlay.Selected()
44+ require.True(t, ok)
45+ require.Equal(t, "third", selected.Message)
46+
47+ d.handleInput(sess, ac, []byte("j"))
48+ awaitFrame(t, sends, ports.MsgOutput)
49+ selected, ok = ac.overlays.noticesOverlay.Selected()
50+ require.True(t, ok)
51+ require.Equal(t, "second", selected.Message)
52+
53+ d.handleInput(sess, ac, []byte("j"))
54+ awaitFrame(t, sends, ports.MsgOutput)
55+ selected, ok = ac.overlays.noticesOverlay.Selected()
56+ require.True(t, ok)
57+ require.Equal(t, "first", selected.Message)
58+
59+ // One more 'j' at the bottom must clamp rather than wrap.
60+ d.handleInput(sess, ac, []byte("j"))
61+ awaitFrame(t, sends, ports.MsgOutput)
62+ selected, ok = ac.overlays.noticesOverlay.Selected()
63+ require.True(t, ok)
64+ require.Equal(t, "first", selected.Message)
65+
66+ d.handleInput(sess, ac, []byte("k"))
67+ awaitFrame(t, sends, ports.MsgOutput)
68+ selected, ok = ac.overlays.noticesOverlay.Selected()
69+ require.True(t, ok)
70+ require.Equal(t, "second", selected.Message)
71+}
72+
73+func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) {
74+ for _, key := range []byte{'q', 0x03} {
75+ p, release := newBlockingPTY(t)
76+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
77+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
78+
79+ d.enterNotices(sess, ac)
80+ awaitFrame(t, sends, ports.MsgOutput)
81+ require.True(t, ac.overlays.noticesActive())
82+
83+ d.handleInput(sess, ac, []byte{key})
84+ awaitFrame(t, sends, ports.MsgOutput)
85+
86+ require.False(t, ac.overlays.noticesActive())
87+ require.Nil(t, ac.overlays.noticesOverlay)
88+ release()
89+ }
90+}
91+
92+func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) {
93+ p, release := newBlockingPTY(t)
94+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
95+ defer release()
96+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
97+
98+ d.enterNotices(sess, ac)
99+ awaitFrame(t, sends, ports.MsgOutput)
100+
101+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
102+ d.clock = clk
103+ d.handleInput(sess, ac, []byte("\x1b"))
104+ timer := <-clk.timers
105+ require.True(t, ac.overlays.noticesActive())
106+
107+ timer.ch <- time.Now()
108+ require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond)
109+ awaitFrame(t, sends, ports.MsgOutput)
110+}
111+
112+func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
113+ p, release := newBlockingPTY(t)
114+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
115+ defer release()
116+
117+ require.NotPanics(t, func() { d.enterNotices(sess, ac) })
118+ out := awaitFrame(t, sends, ports.MsgOutput)
119+
120+ require.NotNil(t, ac.overlays.noticesOverlay)
121+ msg, err := ports.UnmarshalOutput(out.Payload)
122+ require.NoError(t, err)
123+ require.Contains(t, string(msg.Data), "Notifications")
124+ require.Contains(t, string(msg.Data), "No notifications")
125+}
@@ -0,0 +1,125 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) {
14+ p, release := newBlockingPTY(t)
15+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
16+ defer release()
17+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)})
18+
19+ d.enterPalette(sess, ac)
20+ awaitFrame(t, sends, ports.MsgOutput)
21+ d.handlePaletteInput(ac, []byte("NTC\r"))
22+ out := awaitFrame(t, sends, ports.MsgOutput)
23+
24+ require.False(t, ac.overlays.paletteActive())
25+ require.NotNil(t, ac.overlays.noticesOverlay)
26+ msg, err := ports.UnmarshalOutput(out.Payload)
27+ require.NoError(t, err)
28+ require.Contains(t, string(msg.Data), "Notifications")
29+ require.Contains(t, string(msg.Data), "pane-spawn")
30+ require.Contains(t, string(msg.Data), "couldn't open pane")
31+}
32+
33+func TestNoticesJKNavigatesSelection(t *testing.T) {
34+ p, release := newBlockingPTY(t)
35+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
36+ defer release()
37+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
38+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
39+ d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
40+
41+ d.enterNotices(sess, ac)
42+ awaitFrame(t, sends, ports.MsgOutput)
43+ selected, ok := ac.overlays.noticesOverlay.Selected()
44+ require.True(t, ok)
45+ require.Equal(t, "third", selected.Message)
46+
47+ d.handleInput(sess, ac, []byte("j"))
48+ awaitFrame(t, sends, ports.MsgOutput)
49+ selected, ok = ac.overlays.noticesOverlay.Selected()
50+ require.True(t, ok)
51+ require.Equal(t, "second", selected.Message)
52+
53+ d.handleInput(sess, ac, []byte("j"))
54+ awaitFrame(t, sends, ports.MsgOutput)
55+ selected, ok = ac.overlays.noticesOverlay.Selected()
56+ require.True(t, ok)
57+ require.Equal(t, "first", selected.Message)
58+
59+ // One more 'j' at the bottom must clamp rather than wrap.
60+ d.handleInput(sess, ac, []byte("j"))
61+ awaitFrame(t, sends, ports.MsgOutput)
62+ selected, ok = ac.overlays.noticesOverlay.Selected()
63+ require.True(t, ok)
64+ require.Equal(t, "first", selected.Message)
65+
66+ d.handleInput(sess, ac, []byte("k"))
67+ awaitFrame(t, sends, ports.MsgOutput)
68+ selected, ok = ac.overlays.noticesOverlay.Selected()
69+ require.True(t, ok)
70+ require.Equal(t, "second", selected.Message)
71+}
72+
73+func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) {
74+ for _, key := range []byte{'q', 0x03} {
75+ p, release := newBlockingPTY(t)
76+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
77+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
78+
79+ d.enterNotices(sess, ac)
80+ awaitFrame(t, sends, ports.MsgOutput)
81+ require.True(t, ac.overlays.noticesActive())
82+
83+ d.handleInput(sess, ac, []byte{key})
84+ awaitFrame(t, sends, ports.MsgOutput)
85+
86+ require.False(t, ac.overlays.noticesActive())
87+ require.Nil(t, ac.overlays.noticesOverlay)
88+ release()
89+ }
90+}
91+
92+func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) {
93+ p, release := newBlockingPTY(t)
94+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
95+ defer release()
96+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
97+
98+ d.enterNotices(sess, ac)
99+ awaitFrame(t, sends, ports.MsgOutput)
100+
101+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
102+ d.clock = clk
103+ d.handleInput(sess, ac, []byte("\x1b"))
104+ timer := <-clk.timers
105+ require.True(t, ac.overlays.noticesActive())
106+
107+ timer.ch <- time.Now()
108+ require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond)
109+ awaitFrame(t, sends, ports.MsgOutput)
110+}
111+
112+func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
113+ p, release := newBlockingPTY(t)
114+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
115+ defer release()
116+
117+ require.NotPanics(t, func() { d.enterNotices(sess, ac) })
118+ out := awaitFrame(t, sends, ports.MsgOutput)
119+
120+ require.NotNil(t, ac.overlays.noticesOverlay)
121+ msg, err := ports.UnmarshalOutput(out.Payload)
122+ require.NoError(t, err)
123+ require.Contains(t, string(msg.Data), "Notifications")
124+ require.Contains(t, string(msg.Data), "No notifications")
125+}
M internal/usecase/daemon/overlay_runtime.go +29 -1
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/bnema/vev/internal/usecase/notices"
910 "github.com/bnema/vev/internal/usecase/palette"
1011 "github.com/bnema/vev/internal/usecase/picker"
1112 promptui "github.com/bnema/vev/internal/usecase/prompt"
@@ -53,12 +54,21 @@ type overlayRuntime struct {
5354 // noticeMu is the innermost overlay lock: it guards the toast fields below
5455 // and nothing is ever locked, sent, or rendered while it is held. The only
5556 // 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.
5660 noticeMu sync.Mutex
5761 noticeToasts []noticeToast
5862 noticeOverflow int
5963 // noticeSeq numbers toast entries so an already-fired expiry timer cannot
6064 // dismiss the refreshed entry that replaced the one it belonged to.
6165 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
6272 }
6373
6474 type copyPointerState struct {
@@ -88,7 +98,7 @@ func (rt *overlayRuntime) Active() bool {
8898 if rt == nil || rt.ac == nil {
8999 return false
90100 }
91- return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive()
101+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive()
92102 }
93103
94104 func (rt *overlayRuntime) promptActive() bool {
@@ -118,6 +128,15 @@ func (rt *overlayRuntime) pickerActive() bool {
118128 return rt.picker != nil
119129 }
120130
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+
121140 func (rt *overlayRuntime) copyActive() bool {
122141 if rt == nil {
123142 return false
@@ -212,6 +231,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
212231 d.handlePickerInput(ac, data)
213232 return true
214233 }
234+ if rt.noticesActive() {
235+ d.handleNoticesInput(ac, data)
236+ return true
237+ }
215238 if rt.copyActive() {
216239 d.handleCopyInput(ac, data)
217240 return true
@@ -232,6 +255,9 @@ type overlayRenderSnapshot struct {
232255 pickerModel *picker.Model
233256 previewTab *tab
234257
258+ noticesOverlayActive bool
259+ noticesOverlayModel *notices.Model
260+
235261 paletteActive bool
236262 paletteModel *palette.Model
237263 // paletteHints is a copy captured under paletteMu. Rendering must use this
@@ -271,6 +297,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
271297 }
272298 }
273299 snap.noticeOverflow = rt.noticeOverflow
300+ snap.noticesOverlayActive = rt.noticesOverlay != nil
301+ snap.noticesOverlayModel = rt.noticesOverlay.Clone()
274302 rt.noticeMu.Unlock()
275303
276304 rt.copyMu.Lock()
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 scopy "github.com/bnema/vev/internal/usecase/copy"8 scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/bnema/vev/internal/usecase/notices"
9 "github.com/bnema/vev/internal/usecase/palette"10 "github.com/bnema/vev/internal/usecase/palette"
10 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
11 promptui "github.com/bnema/vev/internal/usecase/prompt"12 promptui "github.com/bnema/vev/internal/usecase/prompt"
@@ -53,12 +54,21 @@ type overlayRuntime struct {
53 // noticeMu is the innermost overlay lock: it guards the toast fields below54 // noticeMu is the innermost overlay lock: it guards the toast fields below
54 // and nothing is ever locked, sent, or rendered while it is held. The only55 // and nothing is ever locked, sent, or rendered while it is held. The only
55 // permitted nesting is sendMu -> noticeMu (rendering reads the toasts).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.
56 noticeMu sync.Mutex60 noticeMu sync.Mutex
57 noticeToasts []noticeToast61 noticeToasts []noticeToast
58 noticeOverflow int62 noticeOverflow int
59 // noticeSeq numbers toast entries so an already-fired expiry timer cannot63 // noticeSeq numbers toast entries so an already-fired expiry timer cannot
60 // dismiss the refreshed entry that replaced the one it belonged to.64 // dismiss the refreshed entry that replaced the one it belonged to.
61 noticeSeq uint6465 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
62 }72 }
63 73
64 type copyPointerState struct {74 type copyPointerState struct {
@@ -88,7 +98,7 @@ func (rt *overlayRuntime) Active() bool {
88 if rt == nil || rt.ac == nil {98 if rt == nil || rt.ac == nil {
89 return false99 return false
90 }100 }
91- return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive()101+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive()
92 }102 }
93 103
94 func (rt *overlayRuntime) promptActive() bool {104 func (rt *overlayRuntime) promptActive() bool {
@@ -118,6 +128,15 @@ func (rt *overlayRuntime) pickerActive() bool {
118 return rt.picker != nil128 return rt.picker != nil
119 }129 }
120 130
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+
121 func (rt *overlayRuntime) copyActive() bool {140 func (rt *overlayRuntime) copyActive() bool {
122 if rt == nil {141 if rt == nil {
123 return false142 return false
@@ -212,6 +231,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
212 d.handlePickerInput(ac, data)231 d.handlePickerInput(ac, data)
213 return true232 return true
214 }233 }
234+ if rt.noticesActive() {
235+ d.handleNoticesInput(ac, data)
236+ return true
237+ }
215 if rt.copyActive() {238 if rt.copyActive() {
216 d.handleCopyInput(ac, data)239 d.handleCopyInput(ac, data)
217 return true240 return true
@@ -232,6 +255,9 @@ type overlayRenderSnapshot struct {
232 pickerModel *picker.Model255 pickerModel *picker.Model
233 previewTab *tab256 previewTab *tab
234 257
258+ noticesOverlayActive bool
259+ noticesOverlayModel *notices.Model
260+
235 paletteActive bool261 paletteActive bool
236 paletteModel *palette.Model262 paletteModel *palette.Model
237 // paletteHints is a copy captured under paletteMu. Rendering must use this263 // paletteHints is a copy captured under paletteMu. Rendering must use this
@@ -271,6 +297,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
271 }297 }
272 }298 }
273 snap.noticeOverflow = rt.noticeOverflow299 snap.noticeOverflow = rt.noticeOverflow
300+ snap.noticesOverlayActive = rt.noticesOverlay != nil
301+ snap.noticesOverlayModel = rt.noticesOverlay.Clone()
274 rt.noticeMu.Unlock()302 rt.noticeMu.Unlock()
275 303
276 rt.copyMu.Lock()304 rt.copyMu.Lock()
M internal/usecase/daemon/palette.go +5 -0
@@ -480,6 +480,11 @@ func (e paletteExec) OpenSessionPicker() error {
480480 return nil
481481 }
482482
483+func (e paletteExec) OpenNotifications() error {
484+ e.d.enterNotices(e.sess, e.ac)
485+ return nil
486+}
487+
483488 func (e paletteExec) JumpRecentSession(rank int) error {
484489 if rank < 1 || rank > len(e.recent) {
485490 return command.ErrInvalidArguments
@@ -480,6 +480,11 @@ func (e paletteExec) OpenSessionPicker() error {
480 return nil480 return nil
481 }481 }
482 482
483+func (e paletteExec) OpenNotifications() error {
484+ e.d.enterNotices(e.sess, e.ac)
485+ return nil
486+}
487+
483 func (e paletteExec) JumpRecentSession(rank int) error {488 func (e paletteExec) JumpRecentSession(rank int) error {
484 if rank < 1 || rank > len(e.recent) {489 if rank < 1 || rank > len(e.recent) {
485 return command.ErrInvalidArguments490 return command.ErrInvalidArguments
M internal/usecase/daemon/palette_test.go +1 -1
@@ -563,7 +563,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
563563 for i, cmd := range commands {
564564 codes[i] = cmd.Code
565565 }
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)
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", "NTC", "VIS", "RNS", "RNT", "DET"}, codes)
567567 }
568568
569569 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
@@ -563,7 +563,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
563 for i, cmd := range commands {563 for i, cmd := range commands {
564 codes[i] = cmd.Code564 codes[i] = cmd.Code
565 }565 }
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)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", "NTC", "VIS", "RNS", "RNT", "DET"}, codes)
567 }567 }
568 568
569 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {569 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
M internal/usecase/daemon/render_capture.go +3 -2
@@ -86,11 +86,12 @@ 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
9192 copyFeedback string
9293 paletteGuidance string
93- copySearch, picker, palette, prompt capturedModal
94+ copySearch, picker, palette, prompt, noticesOverlay capturedModal
9495 notices []domain.Notification
9596 noticeOverflow int
9697 }
@@ -102,7 +103,7 @@ type capturedModal struct {
102103 }
103104
104105 func (o capturedOverlayRenderState) active() bool {
105- 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
106107 }
107108
108109 type capturedCursorInputs struct {
@@ -86,11 +86,12 @@ type capturedFloatingRenderState struct {
86 86
87 type capturedOverlayRenderState struct {87 type capturedOverlayRenderState struct {
88 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool88 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
89+ noticesOverlayActive bool
89 copyMode *scopy.Mode90 copyMode *scopy.Mode
90 copyPaneID layout.PaneID91 copyPaneID layout.PaneID
91 copyFeedback string92 copyFeedback string
92 paletteGuidance string93 paletteGuidance string
93- copySearch, picker, palette, prompt capturedModal94+ copySearch, picker, palette, prompt, noticesOverlay capturedModal
94 notices []domain.Notification95 notices []domain.Notification
95 noticeOverflow int96 noticeOverflow int
96 }97 }
@@ -102,7 +103,7 @@ type capturedModal struct {
102 }103 }
103 104
104 func (o capturedOverlayRenderState) active() bool {105 func (o capturedOverlayRenderState) active() bool {
105- return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive106+ return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
106 }107 }
107 108
108 type capturedCursorInputs struct {109 type capturedCursorInputs struct {
M internal/usecase/daemon/render_pipeline.go +9 -1
@@ -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"
@@ -201,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
201202 }
202203 o := &state.overlays
203204 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
205+ o.noticesOverlayActive = snap.noticesOverlayActive
204206 o.copyMode = snap.copyMode
205207 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
206208 if snap.copyPane != nil {
@@ -222,6 +224,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222224 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}
223225 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
224226 }
227+ if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
228+ o.noticesOverlay.modal = noticesModal
229+ o.noticesOverlay.focused = true
230+ renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.SurfaceInactive, Selection: styles.PickerSelection, Text: styles.SurfaceInactive, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
231+ o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles)
232+ }
225233 if snap.paletteActive && snap.paletteModel != nil {
226234 o.palette.modal = paletteModalFor(size, paletteCfg)
227235 o.palette.focused = true
@@ -266,7 +274,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
266274 if o.paletteActive && !state.floating.visible {
267275 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
268276 }
269- for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
277+ for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
270278 if modal.inner.Width == 0 && modal.inner.Height == 0 {
271279 continue
272280 }
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/notices"
10 "github.com/bnema/vev/internal/usecase/palette"11 "github.com/bnema/vev/internal/usecase/palette"
11 "github.com/bnema/vev/internal/usecase/picker"12 "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/bnema/vev/internal/usecase/prompt"13 "github.com/bnema/vev/internal/usecase/prompt"
@@ -201,6 +202,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
201 }202 }
202 o := &state.overlays203 o := &state.overlays
203 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive204 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
205+ o.noticesOverlayActive = snap.noticesOverlayActive
204 o.copyMode = snap.copyMode206 o.copyMode = snap.copyMode
205 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow207 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
206 if snap.copyPane != nil {208 if snap.copyPane != nil {
@@ -222,6 +224,12 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222 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}224 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}
223 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)225 o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
224 }226 }
227+ if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
228+ o.noticesOverlay.modal = noticesModal
229+ o.noticesOverlay.focused = true
230+ renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.SurfaceInactive, Selection: styles.PickerSelection, Text: styles.SurfaceInactive, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
231+ o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles)
232+ }
225 if snap.paletteActive && snap.paletteModel != nil {233 if snap.paletteActive && snap.paletteModel != nil {
226 o.palette.modal = paletteModalFor(size, paletteCfg)234 o.palette.modal = paletteModalFor(size, paletteCfg)
227 o.palette.focused = true235 o.palette.focused = true
@@ -266,7 +274,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
266 if o.paletteActive && !state.floating.visible {274 if o.paletteActive && !state.floating.visible {
267 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)275 (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
268 }276 }
269- for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {277+ for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
270 if modal.inner.Width == 0 && modal.inner.Height == 0 {278 if modal.inner.Width == 0 && modal.inner.Height == 0 {
271 continue279 continue
272 }280 }
A internal/usecase/notices/model.go +178 -0
@@ -0,0 +1,178 @@
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, matching the toast
125+// overlay's own count formatting.
126+func rowMessage(n domain.Notification) string {
127+ if n.Count > 1 {
128+ return fmt.Sprintf("%s ×%d", n.Message, n.Count)
129+ }
130+ return n.Message
131+}
132+
133+func (m *Model) scrollOffset(visible int) int {
134+ if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
135+ return 0
136+ }
137+ if m.selected < visible {
138+ return 0
139+ }
140+ offset := m.selected - visible + 1
141+ return min(offset, len(m.rows)-visible)
142+}
143+
144+func severityLetter(sev domain.NoticeSeverity) byte {
145+ switch sev {
146+ case domain.NoticeWarn:
147+ return 'W'
148+ case domain.NoticeError:
149+ return 'E'
150+ default:
151+ return 'I'
152+ }
153+}
154+
155+// relativeTime renders how long ago t was, relative to now, in the coarsest
156+// unit that keeps the row compact. A non-positive delta (including clock
157+// skew that puts t in the future) reads as "just now" rather than negative.
158+func relativeTime(now, t time.Time) string {
159+ d := now.Sub(t)
160+ switch {
161+ case d < time.Minute:
162+ return "just now"
163+ case d < time.Hour:
164+ return fmt.Sprintf("%dm ago", int(d/time.Minute))
165+ case d < 24*time.Hour:
166+ return fmt.Sprintf("%dh ago", int(d/time.Hour))
167+ default:
168+ return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
169+ }
170+}
171+
172+func textWidth(text string) int {
173+ width := 0
174+ for _, r := range text {
175+ width += renderer.RuneWidth(r)
176+ }
177+ return width
178+}
@@ -0,0 +1,178 @@
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, matching the toast
125+// overlay's own count formatting.
126+func rowMessage(n domain.Notification) string {
127+ if n.Count > 1 {
128+ return fmt.Sprintf("%s ×%d", n.Message, n.Count)
129+ }
130+ return n.Message
131+}
132+
133+func (m *Model) scrollOffset(visible int) int {
134+ if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
135+ return 0
136+ }
137+ if m.selected < visible {
138+ return 0
139+ }
140+ offset := m.selected - visible + 1
141+ return min(offset, len(m.rows)-visible)
142+}
143+
144+func severityLetter(sev domain.NoticeSeverity) byte {
145+ switch sev {
146+ case domain.NoticeWarn:
147+ return 'W'
148+ case domain.NoticeError:
149+ return 'E'
150+ default:
151+ return 'I'
152+ }
153+}
154+
155+// relativeTime renders how long ago t was, relative to now, in the coarsest
156+// unit that keeps the row compact. A non-positive delta (including clock
157+// skew that puts t in the future) reads as "just now" rather than negative.
158+func relativeTime(now, t time.Time) string {
159+ d := now.Sub(t)
160+ switch {
161+ case d < time.Minute:
162+ return "just now"
163+ case d < time.Hour:
164+ return fmt.Sprintf("%dm ago", int(d/time.Minute))
165+ case d < 24*time.Hour:
166+ return fmt.Sprintf("%dh ago", int(d/time.Hour))
167+ default:
168+ return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
169+ }
170+}
171+
172+func textWidth(text string) int {
173+ width := 0
174+ for _, r := range text {
175+ width += renderer.RuneWidth(r)
176+ }
177+ return width
178+}
A internal/usecase/notices/model_test.go +210 -0
@@ -0,0 +1,210 @@
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+ sizes := []domain.Size{{Cols: 0, Rows: 0}, {Cols: -1, Rows: 5}, {Cols: 5, Rows: -1}, {Cols: 0, Rows: 5}, {Cols: 1, Rows: 1}}
161+ for _, size := range sizes {
162+ frame := m.Render(size, testStyles())
163+ if frame.Width < 0 || frame.Height < 0 {
164+ t.Fatalf("Render(%+v) produced negative frame dims", size)
165+ }
166+ }
167+}
168+
169+func TestRenderScrollsToKeepSelectionVisible(t *testing.T) {
170+ now := time.Unix(10000, 0)
171+ entries := make([]domain.Notification, 200)
172+ for i := range entries {
173+ entries[i] = domain.Notification{Message: fmt.Sprintf("n%d", i), Time: now}
174+ }
175+ m := New(entries, now)
176+ for range 150 {
177+ m.Down()
178+ }
179+ frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles())
180+ found := false
181+ for y := 0; y < frame.Height; y++ {
182+ if strings.Contains(rowText(frame, y), "n150") {
183+ found = true
184+ break
185+ }
186+ }
187+ if !found {
188+ t.Fatal("Render() scrolled the selected row out of view")
189+ }
190+}
191+
192+func TestCloneIsIndependentOfOriginal(t *testing.T) {
193+ now := time.Unix(10000, 0)
194+ m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}}, now)
195+ clone := m.Clone()
196+ m.Down()
197+ if got, _ := clone.Selected(); got.Message != "a" {
198+ t.Fatalf("clone.Selected() = %q, want a (clone must not observe later mutation)", got.Message)
199+ }
200+ if got, _ := m.Selected(); got.Message != "b" {
201+ t.Fatalf("m.Selected() = %q, want b", got.Message)
202+ }
203+}
204+
205+func TestCloneOfNilIsNil(t *testing.T) {
206+ var m *Model
207+ if clone := m.Clone(); clone != nil {
208+ t.Fatalf("Clone() of nil = %v, want nil", clone)
209+ }
210+}
@@ -0,0 +1,210 @@
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+ sizes := []domain.Size{{Cols: 0, Rows: 0}, {Cols: -1, Rows: 5}, {Cols: 5, Rows: -1}, {Cols: 0, Rows: 5}, {Cols: 1, Rows: 1}}
161+ for _, size := range sizes {
162+ frame := m.Render(size, testStyles())
163+ if frame.Width < 0 || frame.Height < 0 {
164+ t.Fatalf("Render(%+v) produced negative frame dims", size)
165+ }
166+ }
167+}
168+
169+func TestRenderScrollsToKeepSelectionVisible(t *testing.T) {
170+ now := time.Unix(10000, 0)
171+ entries := make([]domain.Notification, 200)
172+ for i := range entries {
173+ entries[i] = domain.Notification{Message: fmt.Sprintf("n%d", i), Time: now}
174+ }
175+ m := New(entries, now)
176+ for range 150 {
177+ m.Down()
178+ }
179+ frame := m.Render(domain.Size{Cols: 40, Rows: 10}, testStyles())
180+ found := false
181+ for y := 0; y < frame.Height; y++ {
182+ if strings.Contains(rowText(frame, y), "n150") {
183+ found = true
184+ break
185+ }
186+ }
187+ if !found {
188+ t.Fatal("Render() scrolled the selected row out of view")
189+ }
190+}
191+
192+func TestCloneIsIndependentOfOriginal(t *testing.T) {
193+ now := time.Unix(10000, 0)
194+ m := New([]domain.Notification{{Message: "a", Time: now}, {Message: "b", Time: now}}, now)
195+ clone := m.Clone()
196+ m.Down()
197+ if got, _ := clone.Selected(); got.Message != "a" {
198+ t.Fatalf("clone.Selected() = %q, want a (clone must not observe later mutation)", got.Message)
199+ }
200+ if got, _ := m.Selected(); got.Message != "b" {
201+ t.Fatalf("m.Selected() = %q, want b", got.Message)
202+ }
203+}
204+
205+func TestCloneOfNilIsNil(t *testing.T) {
206+ var m *Model
207+ if clone := m.Clone(); clone != nil {
208+ t.Fatalf("Clone() of nil = %v, want nil", clone)
209+ }
210+}

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