detached 2cb5bb3

feat(daemon): yank notification details via OSC52

Add the yank-last-notification (YLN) command and wire 'y' in the
notification history overlay to copy the selected entry. Both paths
share yankNotice, which formats a notification's timestamp, severity,
code slug, message, and cause chain, then sends it over OSC52 the same
way copy mode's own yank does, leaving a one-shot status-bar
confirmation. An empty history reports a warn toast instead of a
silent no-op.
commit
2cb5bb322c8c2c04db0d416b4fbdeedfef7a9b7a
author
brice · · 2026-07-20 13:53 UTC
parent
27d6799
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +265 -2
M internal/usecase/command/command.go +1 -0
@@ -52,6 +52,7 @@ type Context interface {
5252 RenameTab() error
5353 OpenSessionPicker() error
5454 OpenNotifications() error
55+ YankLastNotification() error
5556 JumpRecentSession(rank int) error
5657 }
5758
@@ -52,6 +52,7 @@ type Context interface {
52 RenameTab() error52 RenameTab() error
53 OpenSessionPicker() error53 OpenSessionPicker() error
54 OpenNotifications() error54 OpenNotifications() error
55+ YankLastNotification() error
55 JumpRecentSession(rank int) error56 JumpRecentSession(rank int) error
56 }57 }
57 58
M internal/usecase/command/context_mock_test.go +44 -0
@@ -1141,3 +1141,47 @@ func (_c *MockContext_ToggleStack_Call) RunAndReturn(run func() error) *MockCont
11411141 _c.Call.Return(run)
11421142 return _c
11431143 }
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+}
@@ -1141,3 +1141,47 @@ func (_c *MockContext_ToggleStack_Call) RunAndReturn(run func() error) *MockCont
1141 _c.Call.Return(run)1141 _c.Call.Return(run)
1142 return _c1142 return _c
1143 }1143 }
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 +1 -0
@@ -32,6 +32,7 @@ func Registry() []Command {
3232 }},
3333 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
3434 {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() }},
3536 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
3637 {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() }},
3738 {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
@@ -32,6 +32,7 @@ func Registry() []Command {
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: "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() }},
35 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},36 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
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() }},37 {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() }},
37 {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},38 {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", "NTC", "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))
@@ -57,6 +57,16 @@ func TestRegistryIncludesNotifications(t *testing.T) {
5757 }
5858 }
5959
60+func TestRegistryIncludesYankLastNotification(t *testing.T) {
61+ cmd, ok := ByCode("YLN")
62+ if !ok {
63+ t.Fatal("ByCode(\"YLN\") ok = false, want true")
64+ }
65+ if cmd.Slug != "yank-last-notification" || cmd.Code != "YLN" || cmd.Name != "Yank last notification" {
66+ t.Fatalf("yank command = %#v, want yank-last-notification/YLN/Yank last notification", cmd)
67+ }
68+}
69+
6070 func TestByCodeIsCaseInsensitive(t *testing.T) {
6171 for _, code := range []string{"CNT", "cnt", "CnT"} {
6272 cmd, ok := ByCode(code)
@@ -99,6 +109,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
99109 {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
100110 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
101111 {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }},
112+ {code: "YLN", expect: func(ctx *MockContext) { ctx.EXPECT().YankLastNotification().Return(nil).Once() }},
102113 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
103114 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
104115 {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", "NTC", "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"}
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))
@@ -57,6 +57,16 @@ func TestRegistryIncludesNotifications(t *testing.T) {
57 }57 }
58 }58 }
59 59
60+func TestRegistryIncludesYankLastNotification(t *testing.T) {
61+ cmd, ok := ByCode("YLN")
62+ if !ok {
63+ t.Fatal("ByCode(\"YLN\") ok = false, want true")
64+ }
65+ if cmd.Slug != "yank-last-notification" || cmd.Code != "YLN" || cmd.Name != "Yank last notification" {
66+ t.Fatalf("yank command = %#v, want yank-last-notification/YLN/Yank last notification", cmd)
67+ }
68+}
69+
60 func TestByCodeIsCaseInsensitive(t *testing.T) {70 func TestByCodeIsCaseInsensitive(t *testing.T) {
61 for _, code := range []string{"CNT", "cnt", "CnT"} {71 for _, code := range []string{"CNT", "cnt", "CnT"} {
62 cmd, ok := ByCode(code)72 cmd, ok := ByCode(code)
@@ -99,6 +109,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
99 {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},109 {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
100 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},110 {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() }},111 {code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }},
112+ {code: "YLN", expect: func(ctx *MockContext) { ctx.EXPECT().YankLastNotification().Return(nil).Once() }},
102 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},113 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
103 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},114 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
104 {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},115 {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
M internal/usecase/daemon/notices_overlay.go +70 -0
@@ -1,8 +1,13 @@
11 package daemon
22
33 import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
48 "github.com/bnema/vev/internal/domain"
59 "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
611 "github.com/bnema/vev/internal/usecase/keys"
712 "github.com/bnema/vev/internal/usecase/notices"
813 "github.com/bnema/vev/internal/usecase/ui"
@@ -44,6 +49,8 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
4449 }
4550 changed := false
4651 exit := false
52+ yank := false
53+ var yankTarget domain.Notification
4754 for i := 0; i < len(data); i++ {
4855 switch data[i] {
4956 case 'j':
@@ -52,6 +59,11 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
5259 case 'k':
5360 ac.overlays.noticesOverlay.Up()
5461 changed = true
62+ case 'y':
63+ if n, ok := ac.overlays.noticesOverlay.Selected(); ok {
64+ yankTarget = n
65+ yank = true
66+ }
5567 case 'q', 0x03, 0x1b:
5668 if data[i] == 0x1b {
5769 tail := data[i:]
@@ -78,11 +90,69 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
7890 if exit {
7991 d.closeNotices(ac)
8092 }
93+ if yank {
94+ // yankNotice sends over the wire and repaints itself, so it must run
95+ // with noticeMu released; a quick yank doesn't close the overlay the
96+ // way copy mode's own 'y' commits and exits.
97+ d.yankNotice(sess, ac, yankTarget)
98+ }
8199 if exit || changed {
82100 d.invalidateRender(sess, ac, true, "notices_overlay.go")
83101 }
84102 }
85103
104+// yankNotice copies n's formatted details to the client's clipboard via
105+// OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
106+// send each chunk, then leave a one-shot status-bar confirmation that the
107+// next repaint clears.
108+func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notification) {
109+ chunks := scopy.OSC52(noticeYankPayload(n))
110+ for _, chunk := range chunks {
111+ failed, err := d.boundedSendOutputErrTransport(ac, chunk)
112+ if err != nil {
113+ d.detachOnSendError(sess, ac, failed)
114+ return
115+ }
116+ }
117+ ac.overlays.copyMu.Lock()
118+ if len(chunks) > 0 {
119+ ac.overlays.copyFeedback = "copied notification details"
120+ } else {
121+ ac.overlays.copyFeedback = "notification too large to copy"
122+ }
123+ ac.overlays.copyMu.Unlock()
124+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
125+}
126+
127+// noticeYankPayload formats n the way the yank commands put it on the
128+// clipboard: a header line with timestamp, severity, code slug, and coalesce
129+// count, then the message, then the full cause chain when present.
130+func noticeYankPayload(n domain.Notification) string {
131+ b := &strings.Builder{}
132+ fmt.Fprintf(b, "[%s] %s %s", n.Time.Format(time.RFC3339), severityWord(n.Severity), n.Code.String())
133+ if n.Count > 1 {
134+ fmt.Fprintf(b, " ×%d", n.Count)
135+ }
136+ fmt.Fprintf(b, "\n%s\n", n.Message)
137+ if n.Details != "" {
138+ fmt.Fprintf(b, "details: %s\n", n.Details)
139+ }
140+ return b.String()
141+}
142+
143+// severityWord renders sev as the word used in yank payloads, distinct from
144+// the single-letter form the history list draws (notices.severityLetter).
145+func severityWord(sev domain.NoticeSeverity) string {
146+ switch sev {
147+ case domain.NoticeWarn:
148+ return "warn"
149+ case domain.NoticeError:
150+ return "error"
151+ default:
152+ return "info"
153+ }
154+}
155+
86156 func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) {
87157 ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC)
88158 ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
@@ -1,8 +1,13 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
4 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
6 "github.com/bnema/vev/internal/usecase/keys"11 "github.com/bnema/vev/internal/usecase/keys"
7 "github.com/bnema/vev/internal/usecase/notices"12 "github.com/bnema/vev/internal/usecase/notices"
8 "github.com/bnema/vev/internal/usecase/ui"13 "github.com/bnema/vev/internal/usecase/ui"
@@ -44,6 +49,8 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
44 }49 }
45 changed := false50 changed := false
46 exit := false51 exit := false
52+ yank := false
53+ var yankTarget domain.Notification
47 for i := 0; i < len(data); i++ {54 for i := 0; i < len(data); i++ {
48 switch data[i] {55 switch data[i] {
49 case 'j':56 case 'j':
@@ -52,6 +59,11 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
52 case 'k':59 case 'k':
53 ac.overlays.noticesOverlay.Up()60 ac.overlays.noticesOverlay.Up()
54 changed = true61 changed = true
62+ case 'y':
63+ if n, ok := ac.overlays.noticesOverlay.Selected(); ok {
64+ yankTarget = n
65+ yank = true
66+ }
55 case 'q', 0x03, 0x1b:67 case 'q', 0x03, 0x1b:
56 if data[i] == 0x1b {68 if data[i] == 0x1b {
57 tail := data[i:]69 tail := data[i:]
@@ -78,11 +90,69 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
78 if exit {90 if exit {
79 d.closeNotices(ac)91 d.closeNotices(ac)
80 }92 }
93+ if yank {
94+ // yankNotice sends over the wire and repaints itself, so it must run
95+ // with noticeMu released; a quick yank doesn't close the overlay the
96+ // way copy mode's own 'y' commits and exits.
97+ d.yankNotice(sess, ac, yankTarget)
98+ }
81 if exit || changed {99 if exit || changed {
82 d.invalidateRender(sess, ac, true, "notices_overlay.go")100 d.invalidateRender(sess, ac, true, "notices_overlay.go")
83 }101 }
84 }102 }
85 103
104+// yankNotice copies n's formatted details to the client's clipboard via
105+// OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
106+// send each chunk, then leave a one-shot status-bar confirmation that the
107+// next repaint clears.
108+func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notification) {
109+ chunks := scopy.OSC52(noticeYankPayload(n))
110+ for _, chunk := range chunks {
111+ failed, err := d.boundedSendOutputErrTransport(ac, chunk)
112+ if err != nil {
113+ d.detachOnSendError(sess, ac, failed)
114+ return
115+ }
116+ }
117+ ac.overlays.copyMu.Lock()
118+ if len(chunks) > 0 {
119+ ac.overlays.copyFeedback = "copied notification details"
120+ } else {
121+ ac.overlays.copyFeedback = "notification too large to copy"
122+ }
123+ ac.overlays.copyMu.Unlock()
124+ d.invalidateRender(sess, ac, true, "notices_overlay.go")
125+}
126+
127+// noticeYankPayload formats n the way the yank commands put it on the
128+// clipboard: a header line with timestamp, severity, code slug, and coalesce
129+// count, then the message, then the full cause chain when present.
130+func noticeYankPayload(n domain.Notification) string {
131+ b := &strings.Builder{}
132+ fmt.Fprintf(b, "[%s] %s %s", n.Time.Format(time.RFC3339), severityWord(n.Severity), n.Code.String())
133+ if n.Count > 1 {
134+ fmt.Fprintf(b, " ×%d", n.Count)
135+ }
136+ fmt.Fprintf(b, "\n%s\n", n.Message)
137+ if n.Details != "" {
138+ fmt.Fprintf(b, "details: %s\n", n.Details)
139+ }
140+ return b.String()
141+}
142+
143+// severityWord renders sev as the word used in yank payloads, distinct from
144+// the single-letter form the history list draws (notices.severityLetter).
145+func severityWord(sev domain.NoticeSeverity) string {
146+ switch sev {
147+ case domain.NoticeWarn:
148+ return "warn"
149+ case domain.NoticeError:
150+ return "error"
151+ default:
152+ return "info"
153+ }
154+}
155+
86 func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) {156 func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) {
87 ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC)157 ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC)
88 ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {158 ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
M internal/usecase/daemon/notices_overlay_test.go +123 -0
@@ -1,7 +1,9 @@
11 package daemon
22
33 import (
4+ "encoding/base64"
45 "fmt"
6+ "strings"
57 "testing"
68 "time"
79
@@ -126,3 +128,124 @@ func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
126128 require.Contains(t, string(msg.Data), "Notifications")
127129 require.Contains(t, string(msg.Data), "No notifications")
128130 }
131+
132+func TestNoticeYankPayload(t *testing.T) {
133+ at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
134+ tests := []struct {
135+ name string
136+ n domain.Notification
137+ want string
138+ }{
139+ {
140+ name: "single occurrence, no details",
141+ n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeInfo, Message: "opened pane", Time: at},
142+ want: "[2024-01-02T03:04:05Z] info pane-spawn\nopened pane\n",
143+ },
144+ {
145+ name: "coalesced count and details",
146+ n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom ← permission denied", Count: 3, Time: at},
147+ want: "[2024-01-02T03:04:05Z] error pane-spawn ×3\ncouldn't open pane\ndetails: boom ← permission denied\n",
148+ },
149+ {
150+ name: "warn severity",
151+ n: domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeWarn, Message: "selection too large", Time: at},
152+ want: "[2024-01-02T03:04:05Z] warn clipboard\nselection too large\n",
153+ },
154+ }
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ require.Equal(t, tt.want, noticeYankPayload(tt.n))
158+ })
159+ }
160+}
161+
162+func TestYankLastNotificationCommandCopiesOSC52AndShowsFeedback(t *testing.T) {
163+ p, release := newBlockingPTY(t)
164+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
165+ defer release()
166+ 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)}
167+ d.notices.record(n)
168+ want := noticeYankPayload(n)
169+
170+ d.enterPalette(sess, ac)
171+ awaitFrame(t, sends, ports.MsgOutput)
172+ d.handlePaletteInput(ac, []byte("YLN\r"))
173+
174+ out := awaitFrame(t, sends, ports.MsgOutput)
175+ msg, err := ports.UnmarshalOutput(out.Payload)
176+ require.NoError(t, err)
177+ payload := string(msg.Data)
178+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
179+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
180+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
181+ require.NoError(t, err)
182+ require.Equal(t, want, string(decoded))
183+
184+ live := awaitFrame(t, sends, ports.MsgOutput)
185+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
186+ require.NoError(t, err)
187+ require.Contains(t, string(liveMsg.Data), "copied notification details")
188+}
189+
190+func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
191+ p, release := newBlockingPTY(t)
192+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
193+ defer release()
194+
195+ d.enterPalette(sess, ac)
196+ awaitFrame(t, sends, ports.MsgOutput)
197+ d.handlePaletteInput(ac, []byte("YLN\r"))
198+
199+ out := awaitFrame(t, sends, ports.MsgOutput)
200+ msg, err := ports.UnmarshalOutput(out.Payload)
201+ require.NoError(t, err)
202+ require.Contains(t, string(msg.Data), "no notifications yet")
203+
204+ history := d.notices.history()
205+ require.Len(t, history, 1)
206+ require.Equal(t, domain.NoticeInternal, history[0].Code)
207+ require.Equal(t, domain.NoticeWarn, history[0].Severity)
208+ require.Equal(t, "no notifications yet", history[0].Message)
209+}
210+
211+func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
212+ p, release := newBlockingPTY(t)
213+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
214+ defer release()
215+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "first", Time: time.Unix(1, 0)})
216+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Severity: domain.NoticeError, Message: "second", Time: time.Unix(2, 0)})
217+
218+ d.enterNotices(sess, ac)
219+ awaitFrame(t, sends, ports.MsgOutput)
220+ selected, ok := ac.overlays.noticesOverlay.Selected()
221+ require.True(t, ok)
222+ require.Equal(t, "second", selected.Message)
223+ want := noticeYankPayload(selected)
224+
225+ d.handleInput(sess, ac, []byte("y"))
226+
227+ out := awaitFrame(t, sends, ports.MsgOutput)
228+ msg, err := ports.UnmarshalOutput(out.Payload)
229+ require.NoError(t, err)
230+ payload := string(msg.Data)
231+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
232+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
233+ require.NoError(t, err)
234+ require.Equal(t, want, string(decoded))
235+
236+ // The overlay stays open after a quick yank; it isn't a modal commit like
237+ // copy mode's own 'y'.
238+ require.True(t, ac.overlays.noticesActive())
239+}
240+
241+func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {
242+ p, release := newBlockingPTY(t)
243+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
244+ defer release()
245+
246+ require.NotPanics(t, func() { d.enterNotices(sess, ac) })
247+ awaitFrame(t, sends, ports.MsgOutput)
248+
249+ require.NotPanics(t, func() { d.handleInput(sess, ac, []byte("y")) })
250+ require.True(t, ac.overlays.noticesActive())
251+}
@@ -1,7 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "encoding/base64"
4 "fmt"5 "fmt"
6+ "strings"
5 "testing"7 "testing"
6 "time"8 "time"
7 9
@@ -126,3 +128,124 @@ func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
126 require.Contains(t, string(msg.Data), "Notifications")128 require.Contains(t, string(msg.Data), "Notifications")
127 require.Contains(t, string(msg.Data), "No notifications")129 require.Contains(t, string(msg.Data), "No notifications")
128 }130 }
131+
132+func TestNoticeYankPayload(t *testing.T) {
133+ at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
134+ tests := []struct {
135+ name string
136+ n domain.Notification
137+ want string
138+ }{
139+ {
140+ name: "single occurrence, no details",
141+ n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeInfo, Message: "opened pane", Time: at},
142+ want: "[2024-01-02T03:04:05Z] info pane-spawn\nopened pane\n",
143+ },
144+ {
145+ name: "coalesced count and details",
146+ n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom ← permission denied", Count: 3, Time: at},
147+ want: "[2024-01-02T03:04:05Z] error pane-spawn ×3\ncouldn't open pane\ndetails: boom ← permission denied\n",
148+ },
149+ {
150+ name: "warn severity",
151+ n: domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeWarn, Message: "selection too large", Time: at},
152+ want: "[2024-01-02T03:04:05Z] warn clipboard\nselection too large\n",
153+ },
154+ }
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ require.Equal(t, tt.want, noticeYankPayload(tt.n))
158+ })
159+ }
160+}
161+
162+func TestYankLastNotificationCommandCopiesOSC52AndShowsFeedback(t *testing.T) {
163+ p, release := newBlockingPTY(t)
164+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
165+ defer release()
166+ 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)}
167+ d.notices.record(n)
168+ want := noticeYankPayload(n)
169+
170+ d.enterPalette(sess, ac)
171+ awaitFrame(t, sends, ports.MsgOutput)
172+ d.handlePaletteInput(ac, []byte("YLN\r"))
173+
174+ out := awaitFrame(t, sends, ports.MsgOutput)
175+ msg, err := ports.UnmarshalOutput(out.Payload)
176+ require.NoError(t, err)
177+ payload := string(msg.Data)
178+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
179+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
180+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
181+ require.NoError(t, err)
182+ require.Equal(t, want, string(decoded))
183+
184+ live := awaitFrame(t, sends, ports.MsgOutput)
185+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
186+ require.NoError(t, err)
187+ require.Contains(t, string(liveMsg.Data), "copied notification details")
188+}
189+
190+func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
191+ p, release := newBlockingPTY(t)
192+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
193+ defer release()
194+
195+ d.enterPalette(sess, ac)
196+ awaitFrame(t, sends, ports.MsgOutput)
197+ d.handlePaletteInput(ac, []byte("YLN\r"))
198+
199+ out := awaitFrame(t, sends, ports.MsgOutput)
200+ msg, err := ports.UnmarshalOutput(out.Payload)
201+ require.NoError(t, err)
202+ require.Contains(t, string(msg.Data), "no notifications yet")
203+
204+ history := d.notices.history()
205+ require.Len(t, history, 1)
206+ require.Equal(t, domain.NoticeInternal, history[0].Code)
207+ require.Equal(t, domain.NoticeWarn, history[0].Severity)
208+ require.Equal(t, "no notifications yet", history[0].Message)
209+}
210+
211+func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
212+ p, release := newBlockingPTY(t)
213+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
214+ defer release()
215+ d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "first", Time: time.Unix(1, 0)})
216+ d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Severity: domain.NoticeError, Message: "second", Time: time.Unix(2, 0)})
217+
218+ d.enterNotices(sess, ac)
219+ awaitFrame(t, sends, ports.MsgOutput)
220+ selected, ok := ac.overlays.noticesOverlay.Selected()
221+ require.True(t, ok)
222+ require.Equal(t, "second", selected.Message)
223+ want := noticeYankPayload(selected)
224+
225+ d.handleInput(sess, ac, []byte("y"))
226+
227+ out := awaitFrame(t, sends, ports.MsgOutput)
228+ msg, err := ports.UnmarshalOutput(out.Payload)
229+ require.NoError(t, err)
230+ payload := string(msg.Data)
231+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
232+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
233+ require.NoError(t, err)
234+ require.Equal(t, want, string(decoded))
235+
236+ // The overlay stays open after a quick yank; it isn't a modal commit like
237+ // copy mode's own 'y'.
238+ require.True(t, ac.overlays.noticesActive())
239+}
240+
241+func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {
242+ p, release := newBlockingPTY(t)
243+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
244+ defer release()
245+
246+ require.NotPanics(t, func() { d.enterNotices(sess, ac) })
247+ awaitFrame(t, sends, ports.MsgOutput)
248+
249+ require.NotPanics(t, func() { d.handleInput(sess, ac, []byte("y")) })
250+ require.True(t, ac.overlays.noticesActive())
251+}
M internal/usecase/daemon/palette.go +13 -0
@@ -485,6 +485,19 @@ func (e paletteExec) OpenNotifications() error {
485485 return nil
486486 }
487487
488+func (e paletteExec) YankLastNotification() error {
489+ n, ok := e.d.notices.latest()
490+ if !ok {
491+ // Reported directly rather than returned: the generic cmd.Run error
492+ // path only logs, so a returned error here would never reach the
493+ // user as the warn toast this no-op is supposed to produce.
494+ e.d.reportError(e.sess, domain.UserWarn(domain.NoticeInternal, "no notifications yet", nil))
495+ return nil
496+ }
497+ e.d.yankNotice(e.sess, e.ac, n)
498+ return nil
499+}
500+
488501 func (e paletteExec) JumpRecentSession(rank int) error {
489502 if rank < 1 || rank > len(e.recent) {
490503 return command.ErrInvalidArguments
@@ -485,6 +485,19 @@ func (e paletteExec) OpenNotifications() error {
485 return nil485 return nil
486 }486 }
487 487
488+func (e paletteExec) YankLastNotification() error {
489+ n, ok := e.d.notices.latest()
490+ if !ok {
491+ // Reported directly rather than returned: the generic cmd.Run error
492+ // path only logs, so a returned error here would never reach the
493+ // user as the warn toast this no-op is supposed to produce.
494+ e.d.reportError(e.sess, domain.UserWarn(domain.NoticeInternal, "no notifications yet", nil))
495+ return nil
496+ }
497+ e.d.yankNotice(e.sess, e.ac, n)
498+ return nil
499+}
500+
488 func (e paletteExec) JumpRecentSession(rank int) error {501 func (e paletteExec) JumpRecentSession(rank int) error {
489 if rank < 1 || rank > len(e.recent) {502 if rank < 1 || rank > len(e.recent) {
490 return command.ErrInvalidArguments503 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", "NTC", "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", "YLN", "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", "NTC", "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", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
567 }567 }
568 568
569 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {569 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {

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