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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 52 | 52 | RenameTab() error | |
| 53 | 53 | OpenSessionPicker() error | |
| 54 | 54 | OpenNotifications() error | |
| 55 | + | YankLastNotification() error | |
| 55 | 56 | JumpRecentSession(rank int) error | |
| 56 | 57 | } | |
| 57 | 58 | ||
| @@ -52,6 +52,7 @@ type Context interface { | |||||
| 52 | RenameTab() error | 52 | RenameTab() error | ||
| 53 | OpenSessionPicker() error | 53 | OpenSessionPicker() error | ||
| 54 | OpenNotifications() error | 54 | OpenNotifications() error | ||
| 55 | + | YankLastNotification() error | |||
| 55 | JumpRecentSession(rank int) error | 56 | 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 | |||
| 1141 | 1141 | _c.Call.Return(run) | |
| 1142 | 1142 | 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 | + | } | |
| @@ -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 _c | 1142 | 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 { | |||
| 32 | 32 | }}, | |
| 33 | 33 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | |
| 34 | 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 | 36 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | |
| 36 | 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 | 38 | {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 ( | |||
| 7 | 7 | ||
| 8 | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | 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 | 12 | if len(commands) != len(wantCodes) { | |
| 13 | 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 | 70 | func TestByCodeIsCaseInsensitive(t *testing.T) { | |
| 61 | 71 | for _, code := range []string{"CNT", "cnt", "CnT"} { | |
| 62 | 72 | cmd, ok := ByCode(code) | |
| @@ -99,6 +109,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 99 | 109 | {code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }}, | |
| 100 | 110 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | |
| 101 | 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 | 113 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | |
| 103 | 114 | {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }}, | |
| 104 | 115 | {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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ||
| 4 | 8 | "github.com/bnema/vev/internal/domain" | |
| 5 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 6 | 11 | "github.com/bnema/vev/internal/usecase/keys" | |
| 7 | 12 | "github.com/bnema/vev/internal/usecase/notices" | |
| 8 | 13 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -44,6 +49,8 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||
| 44 | 49 | } | |
| 45 | 50 | changed := false | |
| 46 | 51 | exit := false | |
| 52 | + | yank := false | |
| 53 | + | var yankTarget domain.Notification | |
| 47 | 54 | for i := 0; i < len(data); i++ { | |
| 48 | 55 | switch data[i] { | |
| 49 | 56 | case 'j': | |
| @@ -52,6 +59,11 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||
| 52 | 59 | case 'k': | |
| 53 | 60 | ac.overlays.noticesOverlay.Up() | |
| 54 | 61 | changed = true | |
| 62 | + | case 'y': | |
| 63 | + | if n, ok := ac.overlays.noticesOverlay.Selected(); ok { | |
| 64 | + | yankTarget = n | |
| 65 | + | yank = true | |
| 66 | + | } | |
| 55 | 67 | case 'q', 0x03, 0x1b: | |
| 56 | 68 | if data[i] == 0x1b { | |
| 57 | 69 | tail := data[i:] | |
| @@ -78,11 +90,69 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||
| 78 | 90 | if exit { | |
| 79 | 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 | 99 | if exit || changed { | |
| 82 | 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 | 156 | func (d *Daemon) retainNoticesESCLocked(ac *attachedClient) { | |
| 87 | 157 | ac.overlays.noticesPending = append(ac.overlays.noticesPending[:0], keys.ESC) | |
| 88 | 158 | ac.overlays.noticesESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| @@ -1,8 +1,13 @@ | |||||
| 1 | package daemon | 1 | 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 := false | 50 | changed := false | ||
| 46 | exit := false | 51 | 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 = true | 61 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/base64" | |
| 4 | 5 | "fmt" | |
| 6 | + | "strings" | |
| 5 | 7 | "testing" | |
| 6 | 8 | "time" | |
| 7 | 9 | ||
| @@ -126,3 +128,124 @@ func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | |||
| 126 | 128 | require.Contains(t, string(msg.Data), "Notifications") | |
| 127 | 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 | + | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package daemon | 1 | 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 { | |||
| 485 | 485 | 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 | 501 | func (e paletteExec) JumpRecentSession(rank int) error { | |
| 489 | 502 | if rank < 1 || rank > len(e.recent) { | |
| 490 | 503 | return command.ErrInvalidArguments | |
| @@ -485,6 +485,19 @@ func (e paletteExec) OpenNotifications() error { | |||||
| 485 | return nil | 485 | 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.ErrInvalidArguments | 503 | return command.ErrInvalidArguments | ||
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -563,7 +563,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 563 | 563 | for i, cmd := range commands { | |
| 564 | 564 | 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 | 569 | 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.Code | 564 | 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) { | ||