detached 8e0f50e

fix(theme): keep escape sequences contiguous and guard detach color reset

commit
8e0f50ec3768aef8f923364dc71e2cc76121d037
author
brice · · 2026-07-03 14:40 UTC
committer
brice · · 2026-07-03 14:43 UTC
parent
ef01342
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +290 -25
M internal/usecase/client/client_test.go +59 -0
@@ -412,6 +412,65 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
412412 }
413413 }
414414
415+func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
416+ var out bytes.Buffer
417+ var restoreCount atomic.Int32
418+ resizeCh := make(chan domain.Size)
419+ input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M"))
420+ defer input.unblock()
421+
422+ tm := portsmocks.NewMockTerminal(t)
423+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
424+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
425+ tm.EXPECT().In().Return(input).Maybe()
426+ tm.EXPECT().Out().Return(&out).Maybe()
427+ tm.EXPECT().Flush().Return(nil).Maybe()
428+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
429+
430+ gotInput := make(chan []byte, 2)
431+ allowDetach := make(chan struct{})
432+ tr := portsmocks.NewMockTransport(t)
433+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
434+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
435+ in, err := ports.UnmarshalInput(f.Payload)
436+ require.NoError(t, err)
437+ gotInput <- in.Data
438+ close(allowDetach)
439+ return nil
440+ }).Once()
441+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
442+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
443+ recvCh := make(chan recvItem, 1)
444+ recvCh <- recvItem{f: welcome}
445+ closed := make(chan struct{})
446+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
447+ select {
448+ case it := <-recvCh:
449+ return it.f, it.err
450+ case <-allowDetach:
451+ select {
452+ case <-closed:
453+ return ports.Frame{}, io.EOF
454+ default:
455+ close(closed)
456+ return detached, nil
457+ }
458+ case <-closed:
459+ return ports.Frame{}, io.EOF
460+ }
461+ }).Maybe()
462+ tr.EXPECT().Close().Return(nil).Once()
463+
464+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
465+ require.NoError(t, err)
466+ select {
467+ case got := <-gotInput:
468+ require.Equal(t, []byte("\x1b[<0;1;1M"), got, "SGR mouse report must arrive as one intact MsgInput frame")
469+ case <-time.After(2 * time.Second):
470+ t.Fatal("mouse report input was not sent")
471+ }
472+}
473+
415474 func TestAttachForwardsResize(t *testing.T) {
416475 var out bytes.Buffer
417476 var restoreCount atomic.Int32
@@ -412,6 +412,65 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
412 }412 }
413 }413 }
414 414
415+func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
416+ var out bytes.Buffer
417+ var restoreCount atomic.Int32
418+ resizeCh := make(chan domain.Size)
419+ input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M"))
420+ defer input.unblock()
421+
422+ tm := portsmocks.NewMockTerminal(t)
423+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
424+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
425+ tm.EXPECT().In().Return(input).Maybe()
426+ tm.EXPECT().Out().Return(&out).Maybe()
427+ tm.EXPECT().Flush().Return(nil).Maybe()
428+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
429+
430+ gotInput := make(chan []byte, 2)
431+ allowDetach := make(chan struct{})
432+ tr := portsmocks.NewMockTransport(t)
433+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
434+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
435+ in, err := ports.UnmarshalInput(f.Payload)
436+ require.NoError(t, err)
437+ gotInput <- in.Data
438+ close(allowDetach)
439+ return nil
440+ }).Once()
441+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
442+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
443+ recvCh := make(chan recvItem, 1)
444+ recvCh <- recvItem{f: welcome}
445+ closed := make(chan struct{})
446+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
447+ select {
448+ case it := <-recvCh:
449+ return it.f, it.err
450+ case <-allowDetach:
451+ select {
452+ case <-closed:
453+ return ports.Frame{}, io.EOF
454+ default:
455+ close(closed)
456+ return detached, nil
457+ }
458+ case <-closed:
459+ return ports.Frame{}, io.EOF
460+ }
461+ }).Maybe()
462+ tr.EXPECT().Close().Return(nil).Once()
463+
464+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
465+ require.NoError(t, err)
466+ select {
467+ case got := <-gotInput:
468+ require.Equal(t, []byte("\x1b[<0;1;1M"), got, "SGR mouse report must arrive as one intact MsgInput frame")
469+ case <-time.After(2 * time.Second):
470+ t.Fatal("mouse report input was not sent")
471+ }
472+}
473+
415 func TestAttachForwardsResize(t *testing.T) {474 func TestAttachForwardsResize(t *testing.T) {
416 var out bytes.Buffer475 var out bytes.Buffer
417 var restoreCount atomic.Int32476 var restoreCount atomic.Int32
M internal/usecase/copy/mode.go +1 -1
@@ -180,7 +180,7 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
180180
181181 func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) {
182182 if !ok || selection.Equal(inverseStyle()) {
183- dst.Inverse = !dst.Inverse
183+ dst.Inverse = true
184184 return
185185 }
186186 *dst = selection
@@ -180,7 +180,7 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
180 180
181 func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) {181 func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) {
182 if !ok || selection.Equal(inverseStyle()) {182 if !ok || selection.Equal(inverseStyle()) {
183- dst.Inverse = !dst.Inverse183+ dst.Inverse = true
184 return184 return
185 }185 }
186 *dst = selection186 *dst = selection
M internal/usecase/copy/mode_test.go +17 -0
@@ -265,6 +265,23 @@ func TestCopyModeRenderLineCursorMarker(t *testing.T) {
265265 }
266266 }
267267
268+func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
269+ // A cell that was already rendered inverse by the child program (e.g. its
270+ // own reverse-video attribute) must stay visibly selected: the fallback
271+ // selection style is a solid "force inverse", not an XOR toggle that would
272+ // flip it back to non-inverse and hide the highlight.
273+ rows := [][]renderer.Cell{row("alpha "), row("beta ")}
274+ rows[0][0].Style.Inverse = true
275+ s := Snapshot{Rows: rows, Width: 16, Height: 2}
276+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
277+
278+ frame := m.Render(s)
279+
280+ if !frame.At(0, 0).Style.Inverse {
281+ t.Fatalf("already-inverse selected cell became non-inverse; want Inverse=true")
282+ }
283+}
284+
268285 func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) {
269286 s := snapshot([]string{"alpha ", "beta "}, 2)
270287 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
@@ -265,6 +265,23 @@ func TestCopyModeRenderLineCursorMarker(t *testing.T) {
265 }265 }
266 }266 }
267 267
268+func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
269+ // A cell that was already rendered inverse by the child program (e.g. its
270+ // own reverse-video attribute) must stay visibly selected: the fallback
271+ // selection style is a solid "force inverse", not an XOR toggle that would
272+ // flip it back to non-inverse and hide the highlight.
273+ rows := [][]renderer.Cell{row("alpha "), row("beta ")}
274+ rows[0][0].Style.Inverse = true
275+ s := Snapshot{Rows: rows, Width: 16, Height: 2}
276+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
277+
278+ frame := m.Render(s)
279+
280+ if !frame.At(0, 0).Style.Inverse {
281+ t.Fatalf("already-inverse selected cell became non-inverse; want Inverse=true")
282+ }
283+}
284+
268 func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) {285 func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) {
269 s := snapshot([]string{"alpha ", "beta "}, 2)286 s := snapshot([]string{"alpha ", "beta "}, 2)
270 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}287 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
M internal/usecase/daemon/client.go +28 -0
@@ -259,6 +259,32 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
259259 return ac, old
260260 }
261261
262+// resetScreenDefaultColors clears the known default foreground/background
263+// colors on every tab in sess. Called once a client has been detached, this
264+// makes child OSC 10/11 queries go back to being swallowed (Known=false)
265+// instead of being answered with the departed client's colors, which the
266+// next client (with a different terminal theme) may never have reported.
267+// Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
268+// release it, then take each tb.mu in turn — never holding sess.mu and
269+// tb.mu together. Guarded against a race with a newer attach: if sess.client
270+// is non-nil by the time sess.mu is taken, a new client has already attached
271+// (and run its own attach-time reset), so this call must leave the tabs
272+// alone rather than clobbering that client's freshly applied colors.
273+func (d *Daemon) resetScreenDefaultColors(sess *session) {
274+ sess.mu.Lock()
275+ if sess.client != nil {
276+ sess.mu.Unlock()
277+ return
278+ }
279+ tabs := append([]*tab(nil), sess.tabs...)
280+ sess.mu.Unlock()
281+ for _, tb := range tabs {
282+ tb.mu.Lock()
283+ tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
284+ tb.mu.Unlock()
285+ }
286+}
287+
262288 func (d *Daemon) detachReplacedClient(old *attachedClient) {
263289 if old == nil {
264290 return
@@ -339,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
339365 return // already displaced by a newer client; nothing to do
340366 }
341367 d.unregisterPreview(ac)
368+ d.resetScreenDefaultColors(sess)
342369 if explicit {
343370 // Synchronous so the ack is delivered before the transport closes
344371 // (the client is actively awaiting it), but deadline-bounded so a
@@ -414,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
414441 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
415442 if sess.detachIfCurrent(ac) {
416443 d.unregisterPreview(ac)
444+ d.resetScreenDefaultColors(sess)
417445 _ = ac.tr.Close()
418446 d.log.Warn("detached client after send error", "session", sess.name)
419447 if sess.ephemeral {
@@ -259,6 +259,32 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
259 return ac, old259 return ac, old
260 }260 }
261 261
262+// resetScreenDefaultColors clears the known default foreground/background
263+// colors on every tab in sess. Called once a client has been detached, this
264+// makes child OSC 10/11 queries go back to being swallowed (Known=false)
265+// instead of being answered with the departed client's colors, which the
266+// next client (with a different terminal theme) may never have reported.
267+// Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
268+// release it, then take each tb.mu in turn — never holding sess.mu and
269+// tb.mu together. Guarded against a race with a newer attach: if sess.client
270+// is non-nil by the time sess.mu is taken, a new client has already attached
271+// (and run its own attach-time reset), so this call must leave the tabs
272+// alone rather than clobbering that client's freshly applied colors.
273+func (d *Daemon) resetScreenDefaultColors(sess *session) {
274+ sess.mu.Lock()
275+ if sess.client != nil {
276+ sess.mu.Unlock()
277+ return
278+ }
279+ tabs := append([]*tab(nil), sess.tabs...)
280+ sess.mu.Unlock()
281+ for _, tb := range tabs {
282+ tb.mu.Lock()
283+ tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
284+ tb.mu.Unlock()
285+ }
286+}
287+
262 func (d *Daemon) detachReplacedClient(old *attachedClient) {288 func (d *Daemon) detachReplacedClient(old *attachedClient) {
263 if old == nil {289 if old == nil {
264 return290 return
@@ -339,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
339 return // already displaced by a newer client; nothing to do365 return // already displaced by a newer client; nothing to do
340 }366 }
341 d.unregisterPreview(ac)367 d.unregisterPreview(ac)
368+ d.resetScreenDefaultColors(sess)
342 if explicit {369 if explicit {
343 // Synchronous so the ack is delivered before the transport closes370 // Synchronous so the ack is delivered before the transport closes
344 // (the client is actively awaiting it), but deadline-bounded so a371 // (the client is actively awaiting it), but deadline-bounded so a
@@ -414,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
414 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {441 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
415 if sess.detachIfCurrent(ac) {442 if sess.detachIfCurrent(ac) {
416 d.unregisterPreview(ac)443 d.unregisterPreview(ac)
444+ d.resetScreenDefaultColors(sess)
417 _ = ac.tr.Close()445 _ = ac.tr.Close()
418 d.log.Warn("detached client after send error", "session", sess.name)446 d.log.Warn("detached client after send error", "session", sess.name)
419 if sess.ephemeral {447 if sess.ephemeral {
M internal/usecase/daemon/status_test.go +75 -0
@@ -110,6 +110,81 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
110110 require.Empty(t, got)
111111 }
112112
113+func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
114+ p, release := newBlockingPTY(t)
115+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
116+ defer release()
117+ d.applyTheme(sess, ac, ports.Theme{
118+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
119+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
120+ })
121+
122+ // Sanity: the tab answers OSC 10/11 while ac is attached.
123+ tb := sess.activeTab()
124+ var before []byte
125+ tb.screen.OnResponse = func(b []byte) { before = append(before, b...) }
126+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
127+ require.NotEmpty(t, before)
128+
129+ d.clientGone(sess, ac, true)
130+
131+ var got []byte
132+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
133+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
134+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
135+}
136+
137+func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
138+ p, release := newBlockingPTY(t)
139+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
140+ defer release()
141+ d.applyTheme(sess, ac, ports.Theme{
142+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
143+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
144+ })
145+
146+ // Simulate the race window inside clientGone/detachOnSendError: the old
147+ // client has already been detached (detachIfCurrent succeeded), but
148+ // before resetScreenDefaultColors runs a new client attaches and applies
149+ // its own theme.
150+ require.True(t, sess.detachIfCurrent(ac))
151+
152+ tr, _ := newCapturingTransport(t)
153+ newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
154+ d.applyTheme(sess, newAC, ports.Theme{
155+ HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
156+ HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
157+ })
158+
159+ // The late reset from the old client's detach path must not wipe the
160+ // new client's freshly applied colors.
161+ d.resetScreenDefaultColors(sess)
162+
163+ tb := sess.activeTab()
164+ var got []byte
165+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
166+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
167+ require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
168+}
169+
170+func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
171+ p, release := newBlockingPTY(t)
172+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
173+ defer release()
174+ d.applyTheme(sess, ac, ports.Theme{
175+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
176+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
177+ })
178+
179+ d.detachOnSendError(sess, ac)
180+
181+ tb := sess.activeTab()
182+ var got []byte
183+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
184+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
185+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
186+}
187+
113188 func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
114189 p, release := newBlockingPTY(t)
115190 d, sess, old, _ := newManualSessionWithPTYs(t, p)
@@ -110,6 +110,81 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
110 require.Empty(t, got)110 require.Empty(t, got)
111 }111 }
112 112
113+func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
114+ p, release := newBlockingPTY(t)
115+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
116+ defer release()
117+ d.applyTheme(sess, ac, ports.Theme{
118+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
119+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
120+ })
121+
122+ // Sanity: the tab answers OSC 10/11 while ac is attached.
123+ tb := sess.activeTab()
124+ var before []byte
125+ tb.screen.OnResponse = func(b []byte) { before = append(before, b...) }
126+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
127+ require.NotEmpty(t, before)
128+
129+ d.clientGone(sess, ac, true)
130+
131+ var got []byte
132+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
133+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
134+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
135+}
136+
137+func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
138+ p, release := newBlockingPTY(t)
139+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
140+ defer release()
141+ d.applyTheme(sess, ac, ports.Theme{
142+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
143+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
144+ })
145+
146+ // Simulate the race window inside clientGone/detachOnSendError: the old
147+ // client has already been detached (detachIfCurrent succeeded), but
148+ // before resetScreenDefaultColors runs a new client attaches and applies
149+ // its own theme.
150+ require.True(t, sess.detachIfCurrent(ac))
151+
152+ tr, _ := newCapturingTransport(t)
153+ newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
154+ d.applyTheme(sess, newAC, ports.Theme{
155+ HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
156+ HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
157+ })
158+
159+ // The late reset from the old client's detach path must not wipe the
160+ // new client's freshly applied colors.
161+ d.resetScreenDefaultColors(sess)
162+
163+ tb := sess.activeTab()
164+ var got []byte
165+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
166+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
167+ require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
168+}
169+
170+func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
171+ p, release := newBlockingPTY(t)
172+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
173+ defer release()
174+ d.applyTheme(sess, ac, ports.Theme{
175+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
176+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
177+ })
178+
179+ d.detachOnSendError(sess, ac)
180+
181+ tb := sess.activeTab()
182+ var got []byte
183+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
184+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
185+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
186+}
187+
113 func TestApplyThemeIgnoresReplacedClient(t *testing.T) {188 func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
114 p, release := newBlockingPTY(t)189 p, release := newBlockingPTY(t)
115 d, sess, old, _ := newManualSessionWithPTYs(t, p)190 d, sess, old, _ := newManualSessionWithPTYs(t, p)
M internal/usecase/theme/theme.go +38 -24
@@ -88,9 +88,13 @@ type Scanner struct {
8888 }
8989
9090 // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.
91-// All non-matching bytes are emitted through onBytes in original order. Partial
92-// OSC color responses are buffered across calls, but the buffer is bounded so an
93-// unterminated OSC cannot block unrelated input forever.
91+// All non-matching bytes are emitted through onBytes in original order, and a
92+// contiguous run of ordinary bytes (including any ESC that does not start a
93+// color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
94+// reports or arrow keys) is always delivered as a single onBytes call so
95+// callers never see it split. Partial OSC color responses are buffered across
96+// calls, but the buffer is bounded so an unterminated OSC cannot block
97+// unrelated input forever.
9498 func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {
9599 if len(s.pending) > 0 {
96100 combined := make([]byte, 0, len(s.pending)+len(data))
@@ -100,44 +104,54 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
100104 s.pending = nil
101105 }
102106
103- for pos := 0; pos < len(data); {
104- rel := bytes.IndexByte(data[pos:], '\x1b')
105- if rel < 0 {
106- onBytes(data[pos:])
107- return
108- }
109- start := pos + rel
110- if pos < start {
111- onBytes(data[pos:start])
107+ byteStart := 0
108+ for i := 0; i < len(data); i++ {
109+ if data[i] != '\x1b' {
110+ continue
112111 }
113112
114- completePrefix, possiblePrefix := colorOSCPrefix(data[start:])
113+ completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
115114 if !completePrefix {
116115 if possiblePrefix {
117- s.bufferOrFlush(data[start:], onBytes)
116+ if byteStart < i {
117+ onBytes(data[byteStart:i])
118+ }
119+ s.bufferOrFlush(data[i:], onBytes)
118120 return
119121 }
120- onBytes(data[start : start+1])
121- pos = start + 1
122+ // Not the start of a color-OSC sequence: leave this ESC inside
123+ // the current passthrough run instead of splitting it out. It
124+ // may be the introducer of an unrelated escape sequence (SGR
125+ // mouse report, arrow key, etc.) that must reach the reader
126+ // intact.
122127 continue
123128 }
124129
125- termStart, termEnd := findTerminator(data[start+5:])
130+ if byteStart < i {
131+ onBytes(data[byteStart:i])
132+ }
133+
134+ termStart, termEnd := findTerminator(data[i+5:])
126135 if termStart < 0 {
127- s.bufferOrFlush(data[start:], onBytes)
136+ s.bufferOrFlush(data[i:], onBytes)
128137 return
129138 }
130- termStart += start + 5
131- termEnd += start + 5
132- raw := data[start:termEnd]
133- kind := int(data[start+3]-'0') + 10
134- rgb, ok := ParseXColor(string(data[start+5 : termStart]))
139+ termStart += i + 5
140+ termEnd += i + 5
141+ raw := data[i:termEnd]
142+ kind := int(data[i+3]-'0') + 10
143+ rgb, ok := ParseXColor(string(data[i+5 : termStart]))
135144 if ok {
136145 onColor(kind, rgb)
137146 } else {
138147 onBytes(raw)
139148 }
140- pos = termEnd
149+ i = termEnd - 1
150+ byteStart = termEnd
151+ }
152+
153+ if byteStart < len(data) {
154+ onBytes(data[byteStart:])
141155 }
142156 }
143157
@@ -88,9 +88,13 @@ type Scanner struct {
88 }88 }
89 89
90 // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.90 // Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.
91-// All non-matching bytes are emitted through onBytes in original order. Partial91+// All non-matching bytes are emitted through onBytes in original order, and a
92-// OSC color responses are buffered across calls, but the buffer is bounded so an92+// contiguous run of ordinary bytes (including any ESC that does not start a
93-// unterminated OSC cannot block unrelated input forever.93+// color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
94+// reports or arrow keys) is always delivered as a single onBytes call so
95+// callers never see it split. Partial OSC color responses are buffered across
96+// calls, but the buffer is bounded so an unterminated OSC cannot block
97+// unrelated input forever.
94 func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {98 func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {
95 if len(s.pending) > 0 {99 if len(s.pending) > 0 {
96 combined := make([]byte, 0, len(s.pending)+len(data))100 combined := make([]byte, 0, len(s.pending)+len(data))
@@ -100,44 +104,54 @@ func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), on
100 s.pending = nil104 s.pending = nil
101 }105 }
102 106
103- for pos := 0; pos < len(data); {107+ byteStart := 0
104- rel := bytes.IndexByte(data[pos:], '\x1b')108+ for i := 0; i < len(data); i++ {
105- if rel < 0 {109+ if data[i] != '\x1b' {
106- onBytes(data[pos:])110+ continue
107- return
108- }
109- start := pos + rel
110- if pos < start {
111- onBytes(data[pos:start])
112 }111 }
113 112
114- completePrefix, possiblePrefix := colorOSCPrefix(data[start:])113+ completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
115 if !completePrefix {114 if !completePrefix {
116 if possiblePrefix {115 if possiblePrefix {
117- s.bufferOrFlush(data[start:], onBytes)116+ if byteStart < i {
117+ onBytes(data[byteStart:i])
118+ }
119+ s.bufferOrFlush(data[i:], onBytes)
118 return120 return
119 }121 }
120- onBytes(data[start : start+1])122+ // Not the start of a color-OSC sequence: leave this ESC inside
121- pos = start + 1123+ // the current passthrough run instead of splitting it out. It
124+ // may be the introducer of an unrelated escape sequence (SGR
125+ // mouse report, arrow key, etc.) that must reach the reader
126+ // intact.
122 continue127 continue
123 }128 }
124 129
125- termStart, termEnd := findTerminator(data[start+5:])130+ if byteStart < i {
131+ onBytes(data[byteStart:i])
132+ }
133+
134+ termStart, termEnd := findTerminator(data[i+5:])
126 if termStart < 0 {135 if termStart < 0 {
127- s.bufferOrFlush(data[start:], onBytes)136+ s.bufferOrFlush(data[i:], onBytes)
128 return137 return
129 }138 }
130- termStart += start + 5139+ termStart += i + 5
131- termEnd += start + 5140+ termEnd += i + 5
132- raw := data[start:termEnd]141+ raw := data[i:termEnd]
133- kind := int(data[start+3]-'0') + 10142+ kind := int(data[i+3]-'0') + 10
134- rgb, ok := ParseXColor(string(data[start+5 : termStart]))143+ rgb, ok := ParseXColor(string(data[i+5 : termStart]))
135 if ok {144 if ok {
136 onColor(kind, rgb)145 onColor(kind, rgb)
137 } else {146 } else {
138 onBytes(raw)147 onBytes(raw)
139 }148 }
140- pos = termEnd149+ i = termEnd - 1
150+ byteStart = termEnd
151+ }
152+
153+ if byteStart < len(data) {
154+ onBytes(data[byteStart:])
141 }155 }
142 }156 }
143 157
M internal/usecase/theme/theme_test.go +72 -0
@@ -113,6 +113,78 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
113113 }
114114 }
115115
116+func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
117+ var s Scanner
118+ var chunks [][]byte
119+ colors := 0
120+
121+ in := []byte("\x1b[<0;1;1M")
122+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {
123+ chunks = append(chunks, append([]byte(nil), b...))
124+ })
125+
126+ if colors != 0 {
127+ t.Fatalf("colors=%d want 0", colors)
128+ }
129+ if len(chunks) != 1 {
130+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
131+ }
132+ if !bytes.Equal(chunks[0], in) {
133+ t.Fatalf("chunk=%q want %q", chunks[0], in)
134+ }
135+}
136+
137+func TestScannerDoesNotSplitArrowKey(t *testing.T) {
138+ var s Scanner
139+ var chunks [][]byte
140+
141+ in := []byte("\x1b[A")
142+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {
143+ chunks = append(chunks, append([]byte(nil), b...))
144+ })
145+
146+ if len(chunks) != 1 {
147+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
148+ }
149+ if !bytes.Equal(chunks[0], in) {
150+ t.Fatalf("chunk=%q want %q", chunks[0], in)
151+ }
152+}
153+
154+func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
155+ var s Scanner
156+ var chunks [][]byte
157+ var colors []struct {
158+ kind int
159+ rgb renderer.RGB
160+ }
161+
162+ s.Scan([]byte("abc\x1b[<64;5;5Mdef\x1b]11;rgb:11/22/33\x07ghi"), func(kind int, rgb renderer.RGB) {
163+ colors = append(colors, struct {
164+ kind int
165+ rgb renderer.RGB
166+ }{kind: kind, rgb: rgb})
167+ }, func(b []byte) {
168+ chunks = append(chunks, append([]byte(nil), b...))
169+ })
170+
171+ if len(colors) != 1 || colors[0].kind != 11 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
172+ t.Fatalf("colors=%+v", colors)
173+ }
174+
175+ var joined bytes.Buffer
176+ for _, c := range chunks {
177+ joined.Write(c)
178+ }
179+ if want := "abc\x1b[<64;5;5Mdef" + "ghi"; joined.String() != want {
180+ t.Fatalf("joined=%q want %q", joined.String(), want)
181+ }
182+
183+ if len(chunks) == 0 || string(chunks[0]) != "abc\x1b[<64;5;5Mdef" {
184+ t.Fatalf("first chunk=%q want %q (must stay contiguous)", chunks[0], "abc\x1b[<64;5;5Mdef")
185+ }
186+}
187+
116188 func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
117189 var s Scanner
118190 var out bytes.Buffer
@@ -113,6 +113,78 @@ func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
113 }113 }
114 }114 }
115 115
116+func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
117+ var s Scanner
118+ var chunks [][]byte
119+ colors := 0
120+
121+ in := []byte("\x1b[<0;1;1M")
122+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {
123+ chunks = append(chunks, append([]byte(nil), b...))
124+ })
125+
126+ if colors != 0 {
127+ t.Fatalf("colors=%d want 0", colors)
128+ }
129+ if len(chunks) != 1 {
130+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
131+ }
132+ if !bytes.Equal(chunks[0], in) {
133+ t.Fatalf("chunk=%q want %q", chunks[0], in)
134+ }
135+}
136+
137+func TestScannerDoesNotSplitArrowKey(t *testing.T) {
138+ var s Scanner
139+ var chunks [][]byte
140+
141+ in := []byte("\x1b[A")
142+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {
143+ chunks = append(chunks, append([]byte(nil), b...))
144+ })
145+
146+ if len(chunks) != 1 {
147+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
148+ }
149+ if !bytes.Equal(chunks[0], in) {
150+ t.Fatalf("chunk=%q want %q", chunks[0], in)
151+ }
152+}
153+
154+func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
155+ var s Scanner
156+ var chunks [][]byte
157+ var colors []struct {
158+ kind int
159+ rgb renderer.RGB
160+ }
161+
162+ s.Scan([]byte("abc\x1b[<64;5;5Mdef\x1b]11;rgb:11/22/33\x07ghi"), func(kind int, rgb renderer.RGB) {
163+ colors = append(colors, struct {
164+ kind int
165+ rgb renderer.RGB
166+ }{kind: kind, rgb: rgb})
167+ }, func(b []byte) {
168+ chunks = append(chunks, append([]byte(nil), b...))
169+ })
170+
171+ if len(colors) != 1 || colors[0].kind != 11 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
172+ t.Fatalf("colors=%+v", colors)
173+ }
174+
175+ var joined bytes.Buffer
176+ for _, c := range chunks {
177+ joined.Write(c)
178+ }
179+ if want := "abc\x1b[<64;5;5Mdef" + "ghi"; joined.String() != want {
180+ t.Fatalf("joined=%q want %q", joined.String(), want)
181+ }
182+
183+ if len(chunks) == 0 || string(chunks[0]) != "abc\x1b[<64;5;5Mdef" {
184+ t.Fatalf("first chunk=%q want %q (must stay contiguous)", chunks[0], "abc\x1b[<64;5;5Mdef")
185+ }
186+}
187+
116 func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {188 func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
117 var s Scanner189 var s Scanner
118 var out bytes.Buffer190 var out bytes.Buffer

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