detached 4eadd47

fix(daemon): consume split list escape prefixes

commit
4eadd472013c458dff8e81fa986785b1a50250bf
author
brice · · 2026-07-21 12:30 UTC
parent
1dc131d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +84 -20
M internal/domain/notice.go +6 -0
@@ -3,6 +3,9 @@ package domain
33 import "time"
44
55 // 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.
69 type NoticeSeverity uint8
710
811 const (
@@ -12,6 +15,9 @@ const (
1215 )
1316
1417 // 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.
1521 type NoticeCode uint16
1622
1723 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 uint89 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 uint1621 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) {
1313 }{
1414 {NoticeInternal, "internal"},
1515 {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"},
1623 {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"},
1734 {NoticeCode(9999), "unknown"},
1835 }
1936 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) {
4747 }
4848 }
4949
50-func TestRegistryIncludesNotifications(t *testing.T) {
51- cmd, ok := ByCode("NTC")
52- if !ok {
53- t.Fatal("ByCode(\"NTC\") ok = false, want true")
54- }
55- if cmd.Slug != "notifications" || cmd.Code != "NTC" || cmd.Name != "Notifications" {
56- t.Fatalf("notifications command = %#v, want notifications/NTC/Notifications", cmd)
57- }
58-}
59-
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+ })
6768 }
6869 }
6970
@@ -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,
8484 }
8585 if isListEscapePrefix(tail) {
8686 *state.pending = append((*state.pending)[:0], tail...)
87- break
87+ i += len(tail) - 1
88+ continue
8889 }
8990 result.exit = true
9091 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- break87+ i += len(tail) - 1
88+ continue
88 }89 }
89 result.exit = true90 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) {
147147 }
148148 }
149149
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+
150188 func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
151189 p, release := newBlockingPTY(t)
152190 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 {
2323 Count int
2424 }
2525
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.
2829 type NoticeStyles struct {
2930 Text renderer.Style
3031 BoxInfo renderer.Style
@@ -23,8 +23,9 @@ type NoticeView struct {
23 Count int23 Count int
24 }24 }
25 25
26-// NoticeStyles selects the box border style by severity; Text styles both the26+// 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.Style30 Text renderer.Style
30 BoxInfo renderer.Style31 BoxInfo renderer.Style

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