detached 201f7b0

Merge pull request #90 from bnema/fix/remote-reconnect-theme

fix(client): preserve terminal theme across reconnects
commit
201f7b0e9d56b0741e820e43bbacd1052a06ba7f
author
bnema · · 2026-07-14 14:36 UTC
committer
GitHub · · 2026-07-14 14:36 UTC
merge of
3947362 + cd86ba3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +216 -22
M internal/usecase/client/client.go +56 -20
@@ -16,6 +16,7 @@ import (
1616 "log/slog"
1717 "os"
1818 "strings"
19+ "sync"
1920 "sync/atomic"
2021 "time"
2122
@@ -49,6 +50,34 @@ type DetachedError struct {
4950
5051 func (e *DetachedError) Error() string { return "vev: " + e.Text }
5152
53+// terminalThemeState retains the latest terminal-reported colors across
54+// transport attempts so a replacement attachment can restore the daemon theme
55+// without relying on another OSC response.
56+type terminalThemeState struct {
57+ mu sync.Mutex
58+ theme ports.Theme
59+}
60+
61+func (s *terminalThemeState) setTrueColor(enabled bool) {
62+ s.mu.Lock()
63+ s.theme.TrueColor = enabled
64+ s.mu.Unlock()
65+}
66+
67+func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ update(&s.theme)
71+ return s.theme
72+}
73+
74+func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
75+ s.mu.Lock()
76+ defer s.mu.Unlock()
77+ theme := s.theme
78+ return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown
79+}
80+
5281 // milestones tracks lifecycle progress so a failure can report which stages
5382 // were never reached — the key diagnostic when an attach dies early.
5483 type milestones struct {
@@ -187,6 +216,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
187216 backoff := defaultReconnectBackoff.initial
188217 showingStatus := false
189218 var reconnectToastRect domain.Rect
219+ themeState := &terminalThemeState{}
190220 statusStage := reconnectStageOfflineRetrying
191221 redrawRemoteStatus := func(size domain.Size) {
192222 if !rawEntered || !remote {
@@ -270,7 +300,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
270300 if reporter, ok := transport.(ports.LinkStateReporter); ok {
271301 linkEvents = reporter.LinkEvents()
272302 }
273- result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer)
303+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, themeState, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer)
274304 if result.welcomed {
275305 backoff = defaultReconnectBackoff.initial
276306 }
@@ -422,7 +452,7 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
422452 return 8
423453 }
424454
425-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult {
455+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, themeState *terminalThemeState, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult {
426456 var observer ports.RuntimeObserver
427457 if len(observers) != 0 {
428458 observer = observers[0]
@@ -439,6 +469,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
439469 termEnv := os.Getenv("TERM")
440470 colorTerm := os.Getenv("COLORTERM")
441471 trueColor := DetectTrueColor(termEnv, colorTerm)
472+ themeState.setTrueColor(trueColor)
442473 hello := ports.Hello{
443474 Version: ports.ProtocolVersion,
444475 Intent: intent,
@@ -497,6 +528,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
497528 return welcomedResult(err)
498529 }
499530 clearStatus()
531+ reportedTheme, restoreTheme := themeState.reportedTheme()
500532
501533 // 4. Derive a cancellable context so the pumps always unwind when the
502534 // loop returns. cancel runs first (LIFO): it signals the pumps; the
@@ -505,6 +537,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
505537 defer cancel()
506538
507539 sendCh := make(chan ports.Frame, sendQueueDepth)
540+ if restoreTheme {
541+ sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)}
542+ }
508543 ackQueue := newCumulativeAckQueue()
509544 sendErrCh := make(chan error, 1)
510545
@@ -526,7 +561,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
526561 clip = clipboard
527562 }
528563 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
529- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log)
564+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, themeState, requestColors, clip, log)
530565 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
531566
532567 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -726,12 +761,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
726761 // exits. That is harmless here — Attach has already returned and restored
727762 // the terminal — and matches the standard pattern for stdin pumps; a
728763 // closable stdin duplicate could lift it later if ever needed.
729-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
764+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, themeState *terminalThemeState, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
730765 defer log.Debug("stdin pump exited")
731766 buf := make([]byte, stdinBufSize)
732767 var scanner theme.Scanner
733768 var inputSeq uint64
734- current := ports.Theme{TrueColor: trueColor}
735769 var sendOK atomic.Bool
736770 sendOK.Store(true)
737771 send := func(frame ports.Frame) {
@@ -776,28 +810,30 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
776810 for {
777811 n, rerr := in.Read(buf)
778812 if n > 0 {
779- sendTheme := func() {
813+ sendTheme := func(current ports.Theme) {
780814 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
781815 }
782816 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
783- switch kind {
784- case 10:
785- current.HasForeground = true
786- current.Foreground = rgb
787- case 11:
788- current.HasBackground = true
789- current.Background = rgb
790- default:
791- return
792- }
793- sendTheme()
817+ current := themeState.update(func(current *ports.Theme) {
818+ switch kind {
819+ case 10:
820+ current.HasForeground = true
821+ current.Foreground = rgb
822+ case 11:
823+ current.HasBackground = true
824+ current.Background = rgb
825+ }
826+ })
827+ sendTheme(current)
794828 }, func(light bool) {
795- current.SchemeKnown = true
796- current.Light = light
829+ current := themeState.update(func(current *ports.Theme) {
830+ current.SchemeKnown = true
831+ current.Light = light
832+ })
797833 if requestColors != nil {
798834 requestColors()
799835 }
800- sendTheme()
836+ sendTheme(current)
801837 }, sink)
802838 if !sendOK.Load() {
803839 return
@@ -16,6 +16,7 @@ import (
16 "log/slog"16 "log/slog"
17 "os"17 "os"
18 "strings"18 "strings"
19+ "sync"
19 "sync/atomic"20 "sync/atomic"
20 "time"21 "time"
21 22
@@ -49,6 +50,34 @@ type DetachedError struct {
49 50
50 func (e *DetachedError) Error() string { return "vev: " + e.Text }51 func (e *DetachedError) Error() string { return "vev: " + e.Text }
51 52
53+// terminalThemeState retains the latest terminal-reported colors across
54+// transport attempts so a replacement attachment can restore the daemon theme
55+// without relying on another OSC response.
56+type terminalThemeState struct {
57+ mu sync.Mutex
58+ theme ports.Theme
59+}
60+
61+func (s *terminalThemeState) setTrueColor(enabled bool) {
62+ s.mu.Lock()
63+ s.theme.TrueColor = enabled
64+ s.mu.Unlock()
65+}
66+
67+func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ update(&s.theme)
71+ return s.theme
72+}
73+
74+func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) {
75+ s.mu.Lock()
76+ defer s.mu.Unlock()
77+ theme := s.theme
78+ return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown
79+}
80+
52 // milestones tracks lifecycle progress so a failure can report which stages81 // milestones tracks lifecycle progress so a failure can report which stages
53 // were never reached — the key diagnostic when an attach dies early.82 // were never reached — the key diagnostic when an attach dies early.
54 type milestones struct {83 type milestones struct {
@@ -187,6 +216,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
187 backoff := defaultReconnectBackoff.initial216 backoff := defaultReconnectBackoff.initial
188 showingStatus := false217 showingStatus := false
189 var reconnectToastRect domain.Rect218 var reconnectToastRect domain.Rect
219+ themeState := &terminalThemeState{}
190 statusStage := reconnectStageOfflineRetrying220 statusStage := reconnectStageOfflineRetrying
191 redrawRemoteStatus := func(size domain.Size) {221 redrawRemoteStatus := func(size domain.Size) {
192 if !rawEntered || !remote {222 if !rawEntered || !remote {
@@ -270,7 +300,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
270 if reporter, ok := transport.(ports.LinkStateReporter); ok {300 if reporter, ok := transport.(ports.LinkStateReporter); ok {
271 linkEvents = reporter.LinkEvents()301 linkEvents = reporter.LinkEvents()
272 }302 }
273- result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer)303+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, themeState, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer)
274 if result.welcomed {304 if result.welcomed {
275 backoff = defaultReconnectBackoff.initial305 backoff = defaultReconnectBackoff.initial
276 }306 }
@@ -422,7 +452,7 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
422 return 8452 return 8
423 }453 }
424 454
425-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult {455+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, themeState *terminalThemeState, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult {
426 var observer ports.RuntimeObserver456 var observer ports.RuntimeObserver
427 if len(observers) != 0 {457 if len(observers) != 0 {
428 observer = observers[0]458 observer = observers[0]
@@ -439,6 +469,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
439 termEnv := os.Getenv("TERM")469 termEnv := os.Getenv("TERM")
440 colorTerm := os.Getenv("COLORTERM")470 colorTerm := os.Getenv("COLORTERM")
441 trueColor := DetectTrueColor(termEnv, colorTerm)471 trueColor := DetectTrueColor(termEnv, colorTerm)
472+ themeState.setTrueColor(trueColor)
442 hello := ports.Hello{473 hello := ports.Hello{
443 Version: ports.ProtocolVersion,474 Version: ports.ProtocolVersion,
444 Intent: intent,475 Intent: intent,
@@ -497,6 +528,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
497 return welcomedResult(err)528 return welcomedResult(err)
498 }529 }
499 clearStatus()530 clearStatus()
531+ reportedTheme, restoreTheme := themeState.reportedTheme()
500 532
501 // 4. Derive a cancellable context so the pumps always unwind when the533 // 4. Derive a cancellable context so the pumps always unwind when the
502 // loop returns. cancel runs first (LIFO): it signals the pumps; the534 // loop returns. cancel runs first (LIFO): it signals the pumps; the
@@ -505,6 +537,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
505 defer cancel()537 defer cancel()
506 538
507 sendCh := make(chan ports.Frame, sendQueueDepth)539 sendCh := make(chan ports.Frame, sendQueueDepth)
540+ if restoreTheme {
541+ sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)}
542+ }
508 ackQueue := newCumulativeAckQueue()543 ackQueue := newCumulativeAckQueue()
509 sendErrCh := make(chan error, 1)544 sendErrCh := make(chan error, 1)
510 545
@@ -526,7 +561,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
526 clip = clipboard561 clip = clipboard
527 }562 }
528 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)563 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
529- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log)564+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, themeState, requestColors, clip, log)
530 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)565 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
531 566
532 // 5. Output/main loop: the only goroutine that touches the terminal.567 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -726,12 +761,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
726 // exits. That is harmless here — Attach has already returned and restored761 // exits. That is harmless here — Attach has already returned and restored
727 // the terminal — and matches the standard pattern for stdin pumps; a762 // the terminal — and matches the standard pattern for stdin pumps; a
728 // closable stdin duplicate could lift it later if ever needed.763 // closable stdin duplicate could lift it later if ever needed.
729-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {764+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, themeState *terminalThemeState, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
730 defer log.Debug("stdin pump exited")765 defer log.Debug("stdin pump exited")
731 buf := make([]byte, stdinBufSize)766 buf := make([]byte, stdinBufSize)
732 var scanner theme.Scanner767 var scanner theme.Scanner
733 var inputSeq uint64768 var inputSeq uint64
734- current := ports.Theme{TrueColor: trueColor}
735 var sendOK atomic.Bool769 var sendOK atomic.Bool
736 sendOK.Store(true)770 sendOK.Store(true)
737 send := func(frame ports.Frame) {771 send := func(frame ports.Frame) {
@@ -776,28 +810,30 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
776 for {810 for {
777 n, rerr := in.Read(buf)811 n, rerr := in.Read(buf)
778 if n > 0 {812 if n > 0 {
779- sendTheme := func() {813+ sendTheme := func(current ports.Theme) {
780 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})814 send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
781 }815 }
782 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {816 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
783- switch kind {817+ current := themeState.update(func(current *ports.Theme) {
784- case 10:818+ switch kind {
785- current.HasForeground = true819+ case 10:
786- current.Foreground = rgb820+ current.HasForeground = true
787- case 11:821+ current.Foreground = rgb
788- current.HasBackground = true822+ case 11:
789- current.Background = rgb823+ current.HasBackground = true
790- default:824+ current.Background = rgb
791- return825+ }
792- }826+ })
793- sendTheme()827+ sendTheme(current)
794 }, func(light bool) {828 }, func(light bool) {
795- current.SchemeKnown = true829+ current := themeState.update(func(current *ports.Theme) {
796- current.Light = light830+ current.SchemeKnown = true
831+ current.Light = light
832+ })
797 if requestColors != nil {833 if requestColors != nil {
798 requestColors()834 requestColors()
799 }835 }
800- sendTheme()836+ sendTheme(current)
801 }, sink)837 }, sink)
802 if !sendOK.Load() {838 if !sendOK.Load() {
803 return839 return
M internal/usecase/client/reconnect_toast_test.go +160 -2
@@ -202,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
202202 resultCh := make(chan attachResult, 1)
203203 ms := milestones{}
204204 go func() {
205- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
205+ resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {
206206 _, err := term.term.EnterRaw()
207207 return err
208208 }, func() { clears <- struct{}{} }, func(stage reconnectStage) {
@@ -241,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
241241 resultCh := make(chan attachResult, 1)
242242 ms := milestones{}
243243 go func() {
244- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
244+ resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {
245245 _, err := term.term.EnterRaw()
246246 return err
247247 }, func() {}, func(stage reconnectStage) {
@@ -466,6 +466,164 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
466466 require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
467467 }
468468
469+type reconnectThemeTransport struct {
470+ resumeToken uint64
471+ disconnectAfterTheme bool
472+ recvCount int
473+ fullTheme chan ports.Theme
474+ observedTheme ports.Theme
475+}
476+
477+func newReconnectThemeTransport(token uint64, disconnectAfterTheme bool) *reconnectThemeTransport {
478+ return &reconnectThemeTransport{
479+ resumeToken: token,
480+ disconnectAfterTheme: disconnectAfterTheme,
481+ fullTheme: make(chan ports.Theme, 1),
482+ }
483+}
484+
485+func (t *reconnectThemeTransport) Send(f ports.Frame) error {
486+ if f.Type != ports.MsgTheme {
487+ return nil
488+ }
489+ theme, err := ports.UnmarshalTheme(f.Payload)
490+ if err != nil {
491+ return err
492+ }
493+ if theme.HasForeground && theme.HasBackground {
494+ select {
495+ case t.fullTheme <- theme:
496+ default:
497+ }
498+ }
499+ return nil
500+}
501+
502+func (t *reconnectThemeTransport) Recv() (ports.Frame, error) {
503+ t.recvCount++
504+ if t.recvCount == 1 {
505+ return reconnectToastWelcome(t.resumeToken), nil
506+ }
507+ t.observedTheme = <-t.fullTheme
508+ if t.disconnectAfterTheme {
509+ return ports.Frame{}, io.EOF
510+ }
511+ return reconnectToastDetach(ports.ReasonDetach), nil
512+}
513+
514+func (t *reconnectThemeTransport) Close() error { return nil }
515+
516+type blockingThemeRestoreTransport struct {
517+ started chan struct{}
518+ closed chan struct{}
519+ recvs int
520+}
521+
522+func newBlockingThemeRestoreTransport() *blockingThemeRestoreTransport {
523+ return &blockingThemeRestoreTransport{started: make(chan struct{}), closed: make(chan struct{})}
524+}
525+
526+func (t *blockingThemeRestoreTransport) Send(f ports.Frame) error {
527+ if f.Type != ports.MsgTheme {
528+ return nil
529+ }
530+ close(t.started)
531+ <-t.closed
532+ return io.ErrClosedPipe
533+}
534+
535+func (t *blockingThemeRestoreTransport) Recv() (ports.Frame, error) {
536+ t.recvs++
537+ if t.recvs == 1 {
538+ return reconnectToastWelcome(44), nil
539+ }
540+ <-t.closed
541+ return ports.Frame{}, io.EOF
542+}
543+
544+func (t *blockingThemeRestoreTransport) Close() error {
545+ select {
546+ case <-t.closed:
547+ default:
548+ close(t.closed)
549+ }
550+ return nil
551+}
552+
553+func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) {
554+ term := newReconnectToastTerminalHarness(t)
555+ defer term.closeInput()
556+ themeState := &terminalThemeState{}
557+ themeState.setTrueColor(true)
558+ themeState.update(func(theme *ports.Theme) {
559+ theme.HasForeground = true
560+ theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
561+ theme.HasBackground = true
562+ theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
563+ })
564+ transport := newBlockingThemeRestoreTransport()
565+ ctx, cancel := context.WithCancel(context.Background())
566+ resultCh := make(chan attachResult, 1)
567+ ms := milestones{}
568+ go func() {
569+ resultCh <- attachOnce(ctx, transport, term.term, newReconnectHandshakeClock(t), ports.IntentResume, "main", 44, [16]byte{1}, &ms, themeState, func() error {
570+ _, err := term.term.EnterRaw()
571+ return err
572+ }, func() {}, func(reconnectStage) {}, nil, true, nil, slog.New(slog.DiscardHandler))
573+ }()
574+
575+ select {
576+ case <-transport.started:
577+ case <-time.After(time.Second):
578+ cancel()
579+ _ = transport.Close()
580+ t.Fatal("cached theme restoration did not start")
581+ }
582+ cancel()
583+ select {
584+ case result := <-resultCh:
585+ require.True(t, result.welcomed)
586+ require.NoError(t, result.err)
587+ _ = transport.Close()
588+ case <-time.After(100 * time.Millisecond):
589+ _ = transport.Close()
590+ <-resultCh
591+ t.Fatal("cached theme send blocked attach cancellation")
592+ }
593+}
594+
595+func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
596+ t.Setenv("TERM", "xterm-256color")
597+ t.Setenv("COLORTERM", "truecolor")
598+ oldSleep := reconnectSleep
599+ oldSleepWithResize := reconnectSleepWithResize
600+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
601+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
602+ return true
603+ }
604+ defer func() {
605+ reconnectSleep = oldSleep
606+ reconnectSleepWithResize = oldSleepWithResize
607+ }()
608+
609+ term := newReconnectToastTerminalHarness(t)
610+ defer term.closeInput()
611+ go func() {
612+ _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07"))
613+ }()
614+ tr1 := newReconnectThemeTransport(44, true)
615+ tr2 := newReconnectThemeTransport(55, false)
616+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
617+
618+ err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
619+ require.NoError(t, err)
620+ require.True(t, tr2.observedTheme.HasForeground)
621+ require.True(t, tr2.observedTheme.HasBackground)
622+ require.True(t, tr2.observedTheme.TrueColor)
623+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground)
624+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background)
625+}
626+
469627 func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
470628 linkDead := errors.New("remote link dead")
471629 oldSleep := reconnectSleep
@@ -202,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
202 resultCh := make(chan attachResult, 1)202 resultCh := make(chan attachResult, 1)
203 ms := milestones{}203 ms := milestones{}
204 go func() {204 go func() {
205- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {205+ resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {
206 _, err := term.term.EnterRaw()206 _, err := term.term.EnterRaw()
207 return err207 return err
208 }, func() { clears <- struct{}{} }, func(stage reconnectStage) {208 }, func() { clears <- struct{}{} }, func(stage reconnectStage) {
@@ -241,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
241 resultCh := make(chan attachResult, 1)241 resultCh := make(chan attachResult, 1)
242 ms := milestones{}242 ms := milestones{}
243 go func() {243 go func() {
244- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {244+ resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {
245 _, err := term.term.EnterRaw()245 _, err := term.term.EnterRaw()
246 return err246 return err
247 }, func() {}, func(stage reconnectStage) {247 }, func() {}, func(stage reconnectStage) {
@@ -466,6 +466,164 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
466 require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))466 require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
467 }467 }
468 468
469+type reconnectThemeTransport struct {
470+ resumeToken uint64
471+ disconnectAfterTheme bool
472+ recvCount int
473+ fullTheme chan ports.Theme
474+ observedTheme ports.Theme
475+}
476+
477+func newReconnectThemeTransport(token uint64, disconnectAfterTheme bool) *reconnectThemeTransport {
478+ return &reconnectThemeTransport{
479+ resumeToken: token,
480+ disconnectAfterTheme: disconnectAfterTheme,
481+ fullTheme: make(chan ports.Theme, 1),
482+ }
483+}
484+
485+func (t *reconnectThemeTransport) Send(f ports.Frame) error {
486+ if f.Type != ports.MsgTheme {
487+ return nil
488+ }
489+ theme, err := ports.UnmarshalTheme(f.Payload)
490+ if err != nil {
491+ return err
492+ }
493+ if theme.HasForeground && theme.HasBackground {
494+ select {
495+ case t.fullTheme <- theme:
496+ default:
497+ }
498+ }
499+ return nil
500+}
501+
502+func (t *reconnectThemeTransport) Recv() (ports.Frame, error) {
503+ t.recvCount++
504+ if t.recvCount == 1 {
505+ return reconnectToastWelcome(t.resumeToken), nil
506+ }
507+ t.observedTheme = <-t.fullTheme
508+ if t.disconnectAfterTheme {
509+ return ports.Frame{}, io.EOF
510+ }
511+ return reconnectToastDetach(ports.ReasonDetach), nil
512+}
513+
514+func (t *reconnectThemeTransport) Close() error { return nil }
515+
516+type blockingThemeRestoreTransport struct {
517+ started chan struct{}
518+ closed chan struct{}
519+ recvs int
520+}
521+
522+func newBlockingThemeRestoreTransport() *blockingThemeRestoreTransport {
523+ return &blockingThemeRestoreTransport{started: make(chan struct{}), closed: make(chan struct{})}
524+}
525+
526+func (t *blockingThemeRestoreTransport) Send(f ports.Frame) error {
527+ if f.Type != ports.MsgTheme {
528+ return nil
529+ }
530+ close(t.started)
531+ <-t.closed
532+ return io.ErrClosedPipe
533+}
534+
535+func (t *blockingThemeRestoreTransport) Recv() (ports.Frame, error) {
536+ t.recvs++
537+ if t.recvs == 1 {
538+ return reconnectToastWelcome(44), nil
539+ }
540+ <-t.closed
541+ return ports.Frame{}, io.EOF
542+}
543+
544+func (t *blockingThemeRestoreTransport) Close() error {
545+ select {
546+ case <-t.closed:
547+ default:
548+ close(t.closed)
549+ }
550+ return nil
551+}
552+
553+func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) {
554+ term := newReconnectToastTerminalHarness(t)
555+ defer term.closeInput()
556+ themeState := &terminalThemeState{}
557+ themeState.setTrueColor(true)
558+ themeState.update(func(theme *ports.Theme) {
559+ theme.HasForeground = true
560+ theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
561+ theme.HasBackground = true
562+ theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
563+ })
564+ transport := newBlockingThemeRestoreTransport()
565+ ctx, cancel := context.WithCancel(context.Background())
566+ resultCh := make(chan attachResult, 1)
567+ ms := milestones{}
568+ go func() {
569+ resultCh <- attachOnce(ctx, transport, term.term, newReconnectHandshakeClock(t), ports.IntentResume, "main", 44, [16]byte{1}, &ms, themeState, func() error {
570+ _, err := term.term.EnterRaw()
571+ return err
572+ }, func() {}, func(reconnectStage) {}, nil, true, nil, slog.New(slog.DiscardHandler))
573+ }()
574+
575+ select {
576+ case <-transport.started:
577+ case <-time.After(time.Second):
578+ cancel()
579+ _ = transport.Close()
580+ t.Fatal("cached theme restoration did not start")
581+ }
582+ cancel()
583+ select {
584+ case result := <-resultCh:
585+ require.True(t, result.welcomed)
586+ require.NoError(t, result.err)
587+ _ = transport.Close()
588+ case <-time.After(100 * time.Millisecond):
589+ _ = transport.Close()
590+ <-resultCh
591+ t.Fatal("cached theme send blocked attach cancellation")
592+ }
593+}
594+
595+func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
596+ t.Setenv("TERM", "xterm-256color")
597+ t.Setenv("COLORTERM", "truecolor")
598+ oldSleep := reconnectSleep
599+ oldSleepWithResize := reconnectSleepWithResize
600+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
601+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
602+ return true
603+ }
604+ defer func() {
605+ reconnectSleep = oldSleep
606+ reconnectSleepWithResize = oldSleepWithResize
607+ }()
608+
609+ term := newReconnectToastTerminalHarness(t)
610+ defer term.closeInput()
611+ go func() {
612+ _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07"))
613+ }()
614+ tr1 := newReconnectThemeTransport(44, true)
615+ tr2 := newReconnectThemeTransport(55, false)
616+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
617+
618+ err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
619+ require.NoError(t, err)
620+ require.True(t, tr2.observedTheme.HasForeground)
621+ require.True(t, tr2.observedTheme.HasBackground)
622+ require.True(t, tr2.observedTheme.TrueColor)
623+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground)
624+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background)
625+}
626+
469 func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {627 func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
470 linkDead := errors.New("remote link dead")628 linkDead := errors.New("remote link dead")
471 oldSleep := reconnectSleep629 oldSleep := reconnectSleep

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