fix(daemon): consume split list escape prefixes
- commit
- 4eadd472013c458dff8e81fa986785b1a50250bf
- author
- brice · · 2026-07-21 12:30 UTC
- parent
- 1dc131d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +84 -20
M internal/domain/notice.go +6 -0
| @@ -3,6 +3,9 @@ package domain | |||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | 5 | // NoticeSeverity ranks a user-facing notification. | |
| 6 | + | // | |
| 7 | + | // Its numeric values persist in JSON notice records and are a protocol/domain | |
| 8 | + | // contract. Add values only at the end; never reorder existing values. | |
| 6 | 9 | type NoticeSeverity uint8 | |
| 7 | 10 | ||
| 8 | 11 | const ( | |
| @@ -12,6 +15,9 @@ const ( | |||
| 12 | 15 | ) | |
| 13 | 16 | ||
| 14 | 17 | // NoticeCode identifies a failure meaning, not a call site. | |
| 18 | + | // | |
| 19 | + | // Its numeric values persist in JSON notice records and are a protocol/domain | |
| 20 | + | // contract. Add values only at the end; never reorder existing values. | |
| 15 | 21 | type NoticeCode uint16 | |
| 16 | 22 | ||
| 17 | 23 | const ( | |
| @@ -3,6 +3,9 @@ package domain | |||||
| 3 | import "time" | 3 | import "time" | ||
| 4 | 4 | ||||
| 5 | // NoticeSeverity ranks a user-facing notification. | 5 | // NoticeSeverity ranks a user-facing notification. | ||
| 6 | + | // | |||
| 7 | + | // Its numeric values persist in JSON notice records and are a protocol/domain | |||
| 8 | + | // contract. Add values only at the end; never reorder existing values. | |||
| 6 | type NoticeSeverity uint8 | 9 | type NoticeSeverity uint8 | ||
| 7 | 10 | ||||
| 8 | const ( | 11 | const ( | ||
| @@ -12,6 +15,9 @@ const ( | |||||
| 12 | ) | 15 | ) | ||
| 13 | 16 | ||||
| 14 | // NoticeCode identifies a failure meaning, not a call site. | 17 | // NoticeCode identifies a failure meaning, not a call site. | ||
| 18 | + | // | |||
| 19 | + | // Its numeric values persist in JSON notice records and are a protocol/domain | |||
| 20 | + | // contract. Add values only at the end; never reorder existing values. | |||
| 15 | type NoticeCode uint16 | 21 | type NoticeCode uint16 | ||
| 16 | 22 | ||||
| 17 | const ( | 23 | const ( | ||
M internal/domain/notice_test.go +17 -0
| @@ -13,7 +13,24 @@ func TestNoticeCodeString(t *testing.T) { | |||
| 13 | 13 | }{ | |
| 14 | 14 | {NoticeInternal, "internal"}, | |
| 15 | 15 | {NoticePaneSpawn, "pane-spawn"}, | |
| 16 | + | {NoticeTabSpawn, "tab-spawn"}, | |
| 17 | + | {NoticeFloatingSpawn, "floating-spawn"}, | |
| 18 | + | {NoticeSessionSpawn, "session-spawn"}, | |
| 19 | + | {NoticeLayoutTooSmall, "layout-too-small"}, | |
| 20 | + | {NoticePaneNotFound, "pane-not-found"}, | |
| 21 | + | {NoticeSessionUnavailable, "session-unavailable"}, | |
| 22 | + | {NoticePersistDisabled, "persist-disabled"}, | |
| 16 | 23 | {NoticeSnapshotWrite, "snapshot-write"}, | |
| 24 | + | {NoticeSnapshotRestore, "snapshot-restore"}, | |
| 25 | + | {NoticeSnapshotSaturated, "snapshot-saturated"}, | |
| 26 | + | {NoticePersistDelete, "persist-delete"}, | |
| 27 | + | {NoticeConfigReload, "config-reload"}, | |
| 28 | + | {NoticeInputDropped, "input-dropped"}, | |
| 29 | + | {NoticeResizeFailed, "resize-failed"}, | |
| 30 | + | {NoticeClipboard, "clipboard"}, | |
| 31 | + | {NoticeClipboardTooLarge, "clipboard-too-large"}, | |
| 32 | + | {NoticeAutoResume, "auto-resume"}, | |
| 33 | + | {NoticeConnection, "connection"}, | |
| 17 | 34 | {NoticeCode(9999), "unknown"}, | |
| 18 | 35 | } | |
| 19 | 36 | for _, tt := range tests { | |
| @@ -13,7 +13,24 @@ func TestNoticeCodeString(t *testing.T) { | |||||
| 13 | }{ | 13 | }{ | ||
| 14 | {NoticeInternal, "internal"}, | 14 | {NoticeInternal, "internal"}, | ||
| 15 | {NoticePaneSpawn, "pane-spawn"}, | 15 | {NoticePaneSpawn, "pane-spawn"}, | ||
| 16 | + | {NoticeTabSpawn, "tab-spawn"}, | |||
| 17 | + | {NoticeFloatingSpawn, "floating-spawn"}, | |||
| 18 | + | {NoticeSessionSpawn, "session-spawn"}, | |||
| 19 | + | {NoticeLayoutTooSmall, "layout-too-small"}, | |||
| 20 | + | {NoticePaneNotFound, "pane-not-found"}, | |||
| 21 | + | {NoticeSessionUnavailable, "session-unavailable"}, | |||
| 22 | + | {NoticePersistDisabled, "persist-disabled"}, | |||
| 16 | {NoticeSnapshotWrite, "snapshot-write"}, | 23 | {NoticeSnapshotWrite, "snapshot-write"}, | ||
| 24 | + | {NoticeSnapshotRestore, "snapshot-restore"}, | |||
| 25 | + | {NoticeSnapshotSaturated, "snapshot-saturated"}, | |||
| 26 | + | {NoticePersistDelete, "persist-delete"}, | |||
| 27 | + | {NoticeConfigReload, "config-reload"}, | |||
| 28 | + | {NoticeInputDropped, "input-dropped"}, | |||
| 29 | + | {NoticeResizeFailed, "resize-failed"}, | |||
| 30 | + | {NoticeClipboard, "clipboard"}, | |||
| 31 | + | {NoticeClipboardTooLarge, "clipboard-too-large"}, | |||
| 32 | + | {NoticeAutoResume, "auto-resume"}, | |||
| 33 | + | {NoticeConnection, "connection"}, | |||
| 17 | {NoticeCode(9999), "unknown"}, | 34 | {NoticeCode(9999), "unknown"}, | ||
| 18 | } | 35 | } | ||
| 19 | for _, tt := range tests { | 36 | for _, tt := range tests { | ||
M internal/usecase/command/registry_test.go +18 -17
| @@ -47,23 +47,24 @@ 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 | - | ||
| 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) | |
| 50 | + | func TestRegistryIncludesNotificationCommands(t *testing.T) { | |
| 51 | + | for _, tt := range []struct { | |
| 52 | + | code string | |
| 53 | + | slug string | |
| 54 | + | name string | |
| 55 | + | }{ | |
| 56 | + | {code: "NTC", slug: "notifications", name: "Notifications"}, | |
| 57 | + | {code: "YLN", slug: "yank-last-notification", name: "Yank last notification"}, | |
| 58 | + | } { | |
| 59 | + | t.Run(tt.code, func(t *testing.T) { | |
| 60 | + | cmd, ok := ByCode(tt.code) | |
| 61 | + | if !ok { | |
| 62 | + | t.Fatalf("ByCode(%q) ok = false, want true", tt.code) | |
| 63 | + | } | |
| 64 | + | if cmd.Slug != tt.slug || cmd.Code != tt.code || cmd.Name != tt.name { | |
| 65 | + | t.Fatalf("command = %#v, want %s/%s/%s", cmd, tt.slug, tt.code, tt.name) | |
| 66 | + | } | |
| 67 | + | }) | |
| 67 | 68 | } | |
| 68 | 69 | } | |
| 69 | 70 | ||
| @@ -47,23 +47,24 @@ func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |||||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | - | func TestRegistryIncludesNotifications(t *testing.T) { | 50 | + | func TestRegistryIncludesNotificationCommands(t *testing.T) { |
| 51 | - | cmd, ok := ByCode("NTC") | 51 | + | for _, tt := range []struct { |
| 52 | - | if !ok { | 52 | + | code string |
| 53 | - | t.Fatal("ByCode(\"NTC\") ok = false, want true") | 53 | + | slug string |
| 54 | - | } | 54 | + | name string |
| 55 | - | if cmd.Slug != "notifications" || cmd.Code != "NTC" || cmd.Name != "Notifications" { | 55 | + | }{ |
| 56 | - | t.Fatalf("notifications command = %#v, want notifications/NTC/Notifications", cmd) | 56 | + | {code: "NTC", slug: "notifications", name: "Notifications"}, |
| 57 | - | } | 57 | + | {code: "YLN", slug: "yank-last-notification", name: "Yank last notification"}, |
| 58 | - | } | 58 | + | } { |
| 59 | - | 59 | + | t.Run(tt.code, func(t *testing.T) { | |
| 60 | - | func TestRegistryIncludesYankLastNotification(t *testing.T) { | 60 | + | cmd, ok := ByCode(tt.code) |
| 61 | - | cmd, ok := ByCode("YLN") | 61 | + | if !ok { |
| 62 | - | if !ok { | 62 | + | t.Fatalf("ByCode(%q) ok = false, want true", tt.code) |
| 63 | - | t.Fatal("ByCode(\"YLN\") ok = false, want true") | 63 | + | } |
| 64 | - | } | 64 | + | if cmd.Slug != tt.slug || cmd.Code != tt.code || cmd.Name != tt.name { |
| 65 | - | if cmd.Slug != "yank-last-notification" || cmd.Code != "YLN" || cmd.Name != "Yank last notification" { | 65 | + | t.Fatalf("command = %#v, want %s/%s/%s", cmd, tt.slug, tt.code, tt.name) |
| 66 | - | t.Fatalf("yank command = %#v, want yank-last-notification/YLN/Yank last notification", cmd) | 66 | + | } |
| 67 | + | }) | |||
| 67 | } | 68 | } | ||
| 68 | } | 69 | } | ||
| 69 | 70 | ||||
M internal/usecase/daemon/notices_overlay.go +2 -1
| @@ -84,7 +84,8 @@ func handleListInputLocked(clock ports.Clock, data []byte, state listInputState, | |||
| 84 | 84 | } | |
| 85 | 85 | if isListEscapePrefix(tail) { | |
| 86 | 86 | *state.pending = append((*state.pending)[:0], tail...) | |
| 87 | - | break | |
| 87 | + | i += len(tail) - 1 | |
| 88 | + | continue | |
| 88 | 89 | } | |
| 89 | 90 | result.exit = true | |
| 90 | 91 | default: | |
| @@ -84,7 +84,8 @@ func handleListInputLocked(clock ports.Clock, data []byte, state listInputState, | |||||
| 84 | } | 84 | } | ||
| 85 | if isListEscapePrefix(tail) { | 85 | if isListEscapePrefix(tail) { | ||
| 86 | *state.pending = append((*state.pending)[:0], tail...) | 86 | *state.pending = append((*state.pending)[:0], tail...) | ||
| 87 | - | break | 87 | + | i += len(tail) - 1 |
| 88 | + | continue | |||
| 88 | } | 89 | } | ||
| 89 | result.exit = true | 90 | result.exit = true | ||
| 90 | default: | 91 | default: | ||
M internal/usecase/daemon/notices_overlay_test.go +38 -0
| @@ -147,6 +147,44 @@ func TestNoticesSplitArrowNavigatesWithoutClosing(t *testing.T) { | |||
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | + | func TestHandleListInputConsumesSplitEscapePrefix(t *testing.T) { | |
| 151 | + | for _, tt := range []struct { | |
| 152 | + | name string | |
| 153 | + | prefix []byte | |
| 154 | + | tail byte | |
| 155 | + | wantUp int | |
| 156 | + | wantDown int | |
| 157 | + | }{ | |
| 158 | + | {name: "CSI up", prefix: []byte("\x1b["), tail: 'A', wantUp: 1}, | |
| 159 | + | {name: "CSI down", prefix: []byte("\x1b["), tail: 'B', wantDown: 1}, | |
| 160 | + | {name: "SS3 up", prefix: []byte("\x1bO"), tail: 'A', wantUp: 1}, | |
| 161 | + | {name: "SS3 down", prefix: []byte("\x1bO"), tail: 'B', wantDown: 1}, | |
| 162 | + | } { | |
| 163 | + | t.Run(tt.name, func(t *testing.T) { | |
| 164 | + | var pending, custom []byte | |
| 165 | + | var up, down int | |
| 166 | + | state := listInputState{ | |
| 167 | + | pending: &pending, | |
| 168 | + | esc: &pendingByteTimer{}, | |
| 169 | + | moveUp: func() { up++ }, | |
| 170 | + | moveDown: func() { down++ }, | |
| 171 | + | } | |
| 172 | + | action := func(b byte) listInputResult { | |
| 173 | + | custom = append(custom, b) | |
| 174 | + | return listInputResult{} | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | handleListInputLocked(nil, tt.prefix, state, action) | |
| 178 | + | handleListInputLocked(nil, []byte{tt.tail}, state, action) | |
| 179 | + | ||
| 180 | + | require.Equal(t, tt.wantUp, up) | |
| 181 | + | require.Equal(t, tt.wantDown, down) | |
| 182 | + | require.Empty(t, custom, "escape prefix bytes must not reach the custom action") | |
| 183 | + | require.Empty(t, pending) | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 150 | 188 | func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | |
| 151 | 189 | p, release := newBlockingPTY(t) | |
| 152 | 190 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -147,6 +147,44 @@ func TestNoticesSplitArrowNavigatesWithoutClosing(t *testing.T) { | |||||
| 147 | } | 147 | } | ||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | + | func TestHandleListInputConsumesSplitEscapePrefix(t *testing.T) { | |||
| 151 | + | for _, tt := range []struct { | |||
| 152 | + | name string | |||
| 153 | + | prefix []byte | |||
| 154 | + | tail byte | |||
| 155 | + | wantUp int | |||
| 156 | + | wantDown int | |||
| 157 | + | }{ | |||
| 158 | + | {name: "CSI up", prefix: []byte("\x1b["), tail: 'A', wantUp: 1}, | |||
| 159 | + | {name: "CSI down", prefix: []byte("\x1b["), tail: 'B', wantDown: 1}, | |||
| 160 | + | {name: "SS3 up", prefix: []byte("\x1bO"), tail: 'A', wantUp: 1}, | |||
| 161 | + | {name: "SS3 down", prefix: []byte("\x1bO"), tail: 'B', wantDown: 1}, | |||
| 162 | + | } { | |||
| 163 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 164 | + | var pending, custom []byte | |||
| 165 | + | var up, down int | |||
| 166 | + | state := listInputState{ | |||
| 167 | + | pending: &pending, | |||
| 168 | + | esc: &pendingByteTimer{}, | |||
| 169 | + | moveUp: func() { up++ }, | |||
| 170 | + | moveDown: func() { down++ }, | |||
| 171 | + | } | |||
| 172 | + | action := func(b byte) listInputResult { | |||
| 173 | + | custom = append(custom, b) | |||
| 174 | + | return listInputResult{} | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | handleListInputLocked(nil, tt.prefix, state, action) | |||
| 178 | + | handleListInputLocked(nil, []byte{tt.tail}, state, action) | |||
| 179 | + | ||||
| 180 | + | require.Equal(t, tt.wantUp, up) | |||
| 181 | + | require.Equal(t, tt.wantDown, down) | |||
| 182 | + | require.Empty(t, custom, "escape prefix bytes must not reach the custom action") | |||
| 183 | + | require.Empty(t, pending) | |||
| 184 | + | }) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 150 | func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | 188 | func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) { | ||
| 151 | p, release := newBlockingPTY(t) | 189 | p, release := newBlockingPTY(t) | ||
| 152 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 190 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
M internal/usecase/ui/notice.go +3 -2
| @@ -23,8 +23,9 @@ type NoticeView struct { | |||
| 23 | 23 | Count int | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | // NoticeStyles selects the box border style by severity; Text styles both the | |
| 27 | - | // title (drawn on the border) and the wrapped message body. | |
| 26 | + | // NoticeStyles selects severity-colored styles for each box's border and title. | |
| 27 | + | // Text styles the wrapped message body and overflow indicator; it excludes the | |
| 28 | + | // title, which is drawn with the box's severity style. | |
| 28 | 29 | type NoticeStyles struct { | |
| 29 | 30 | Text renderer.Style | |
| 30 | 31 | BoxInfo renderer.Style | |
| @@ -23,8 +23,9 @@ type NoticeView struct { | |||||
| 23 | Count int | 23 | Count int | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | // NoticeStyles selects the box border style by severity; Text styles both the | 26 | + | // NoticeStyles selects severity-colored styles for each box's border and title. |
| 27 | - | // title (drawn on the border) and the wrapped message body. | 27 | + | // Text styles the wrapped message body and overflow indicator; it excludes the |
| 28 | + | // title, which is drawn with the box's severity style. | |||
| 28 | type NoticeStyles struct { | 29 | type NoticeStyles struct { | ||
| 29 | Text renderer.Style | 30 | Text renderer.Style | ||
| 30 | BoxInfo renderer.Style | 31 | BoxInfo renderer.Style | ||