detached a539e25

feat(graphics): add Kitty image protocol support (#220)

* feat(render): replay Kitty graphics per attachment

* fix(render): complete local Kitty graphics replay

* fix(render): harden Phase 5 Kitty output

Signed-off-by: brice <b@bnema.dev>

* fix(render): isolate Phase 5 Kitty graphics

Signed-off-by: brice <b@bnema.dev>

* fix(render): bound Kitty graphics namespace lifecycle

* fix(render): quarantine ambiguous Kitty namespaces

* fix(render): quarantine emitted Kitty namespaces

* feat(render): replay Kitty graphics across pane layouts

* fix(render): bound kitty graphics to attachment content

* feat(protocol): negotiate declared Kitty graphics capability

* fix(phase7): close Kitty and input handoff review gaps

* docs(terminal): describe Kitty graphics support

* fix(render): close final Kitty lifecycle gaps

* feat(graphics): relay Kitty image protocol

* fix(graphics): address wrap-up review findings

* fix(graphics): handle cleanup review findings

* refactor(graphics): centralize attachment output lifecycle

* fix(graphics): relay remote icat images cleanly

* fix(graphics): defer oversized cleanup

---------

Signed-off-by: brice <b@bnema.dev>
commit
a539e2590e3ae9d955282cdcc1952510af892137
author
bnema · · 2026-08-26 21:49 UTC
committer
GitHub · · 2026-08-26 21:49 UTC
parent
0933959
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
40 files · +3284 -155

this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.

M docs/terminal.md +21 -0
@@ -6,6 +6,27 @@ Panes always receive vev's stable compatibility contract: `TERM=xterm-256color`,
66
77 Each attachment independently detects its output color support from terminal environment signals. vev preserves RGB pane state and converts it to the xterm 256-color palette only for attachments that do not advertise truecolor. An attachment reporting a 256-color terminal receives a one-time warning before its first paint. Environment signals are advisory—especially through nested multiplexers—and do not advertise Kitty graphics support.
88
9+## Kitty graphics
10+
11+Kitty graphics support is attachment-local and explicitly declared only after a
12+bounded Kitty query and DA1 probe run through the client's existing terminal
13+input pump. Environment variables never enable graphics by themselves. The
14+probe preserves unrelated input, and late probe replies are quarantined rather
15+than sent to a pane.
16+
17+A capable attachment renders the supported static/direct subset: current
18+streamed `kitten icat` PNG input, JPEG input after `kitten icat` converts it to
19+RGB/RGBA, bounded RGB/RGBA/PNG assets, ordinary placements, crop, offsets,
20+z-index, and supported deletes. vev allocates attachment-owned Kitty IDs, never
21+sends global deletes, clips placements to pane and attachment content, and
22+replays authoritative graphics through the same ordered output state chain as
23+ANSI text. Unsupported attachments retain text output and receive one bounded
24+warning when graphics are suppressed.
25+
26+File, temporary-file, shared-memory, composition, relative placements, Unicode
27+placeholders, and graphics scrollback are unsupported. Animated images retain
28+their first frame as a static fallback.
29+
930 ## Environment
1031
1132 On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
M internal/ports/frame.go +5 -1
@@ -12,12 +12,16 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 35
15+const ProtocolVersion uint16 = 36
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
1919 const MaxFrameLen = 16 << 20
2020
21+// MaxOutputDataLen is the largest decoded terminal byte stream that fits in
22+// one Output frame after its type byte and wire metadata.
23+const MaxOutputDataLen = MaxFrameLen - 1 - outputPayloadOverhead
24+
2125 // MsgType identifies the kind of payload carried by a Frame.
2226 type MsgType uint8
2327
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 35 {
7- t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)
6+ if ProtocolVersion != 36 {
7+ t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion)
88 }
99 }
1010
M internal/ports/navigation_test.go +1 -1
@@ -152,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
152152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
153153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
154154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
155- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002302" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
155+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002402" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
156156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
157157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
158158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/remote_target_wire_test.go +1 -1
@@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
115115 })
116116 },
117117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118- policyOffset: 8,
118+ policyOffset: 10,
119119 wantErr: ErrInvalidHello,
120120 },
121121 {
M internal/ports/terminal.go +18 -7
@@ -52,9 +52,16 @@ const (
5252 // attachment. Pane processes must not derive their terminal environment from
5353 // these capabilities.
5454 type TerminalCapabilities struct {
55- ColorMode TerminalColorMode
56- ColorSource TerminalCapabilitySource
57- Application TerminalApplication
55+ ColorMode TerminalColorMode
56+ ColorSource TerminalCapabilitySource
57+ Application TerminalApplication
58+ KittyGraphics bool
59+}
60+
61+// SupportsKittyGraphics reports whether this attachment's active outer-terminal
62+// probe accepted the Kitty graphics protocol. Environment detection never sets it.
63+func (c TerminalCapabilities) SupportsKittyGraphics() bool {
64+ return c.KittyGraphics
5865 }
5966
6067 // TrueColor reports whether this attachment can receive RGB ANSI output.
@@ -75,20 +82,24 @@ func DetectTerminalCapabilities(env []string) TerminalCapabilities {
7582 caps.Application = TerminalApplicationKitty
7683 }
7784
85+ kittyIdentity := term == "xterm-kitty" && caps.Application == TerminalApplicationKitty
7886 switch colorTerm {
7987 case "truecolor", "24bit":
8088 caps.ColorMode = TerminalColorTrueColor
8189 caps.ColorSource = TerminalCapabilityDeclared
82- return caps
8390 }
8491 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
8592 caps.ColorMode = TerminalColorTrueColor
8693 caps.ColorSource = TerminalCapabilityDeclared
87- return caps
8894 }
89- if term == "xterm-kitty" && caps.Application == TerminalApplicationKitty {
95+ if kittyIdentity {
96+ if caps.ColorSource == TerminalCapabilityUnknown {
97+ caps.ColorSource = TerminalCapabilityHeuristic
98+ }
9099 caps.ColorMode = TerminalColorTrueColor
91- caps.ColorSource = TerminalCapabilityHeuristic
100+ return caps
101+ }
102+ if caps.ColorSource == TerminalCapabilityDeclared {
92103 return caps
93104 }
94105 if strings.Contains(term, "256color") || term == "dumb" {
M internal/ports/terminal_test.go +14 -5
@@ -8,11 +8,12 @@ import (
88
99 func TestDetectTerminalCapabilities(t *testing.T) {
1010 tests := []struct {
11- name string
12- env []string
13- wantMode TerminalColorMode
14- wantSource TerminalCapabilitySource
15- wantApp TerminalApplication
11+ name string
12+ env []string
13+ wantMode TerminalColorMode
14+ wantSource TerminalCapabilitySource
15+ wantApp TerminalApplication
16+ wantGraphics bool
1617 }{
1718 {
1819 name: "declared truecolor",
@@ -27,6 +28,13 @@ func TestDetectTerminalCapabilities(t *testing.T) {
2728 wantSource: TerminalCapabilityHeuristic,
2829 wantApp: TerminalApplicationKitty,
2930 },
31+ {
32+ name: "kitty environment with declared truecolor remains color-only",
33+ env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"},
34+ wantMode: TerminalColorTrueColor,
35+ wantSource: TerminalCapabilityDeclared,
36+ wantApp: TerminalApplicationKitty,
37+ },
3038 {
3139 name: "256 color terminal remains a constrained attachment",
3240 env: []string{"TERM=xterm-256color"},
@@ -54,6 +62,7 @@ func TestDetectTerminalCapabilities(t *testing.T) {
5462 require.Equal(t, tt.wantMode, got.ColorMode)
5563 require.Equal(t, tt.wantSource, got.ColorSource)
5664 require.Equal(t, tt.wantApp, got.Application)
65+ require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
5766 })
5867 }
5968 }
M internal/ports/wire.go +24 -2
@@ -174,6 +174,10 @@ type Hello struct {
174174 TermEnv string
175175 Cwd string
176176 TrueColor bool
177+ // KittyDirectGraphics is an explicit declaration that the client has
178+ // probed its direct outer terminal and it accepts Kitty graphics output.
179+ // Environment values are never sufficient to set this capability.
180+ KittyDirectGraphics bool
177181 // MaxOutputInFlight is the requested maximum number of unacknowledged
178182 // state-bearing output frames.
179183 MaxOutputInFlight uint8
@@ -194,6 +198,10 @@ type Hello struct {
194198 EnvironmentPolicy EnvironmentPolicy
195199 NavigationCapabilities NavigationCapabilities
196200 StartupOverlay StartupOverlay
201+ // Remote identifies a direct remote carriage even when no exact picker
202+ // target is present. It is daemon-facing so remote rendering backends can
203+ // be disabled consistently for both direct and picker attaches.
204+ Remote bool
197205 }
198206
199207 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -823,7 +831,7 @@ func ValidateOutput(m Output) error {
823831 if err := ValidateSize(m.Size); err != nil {
824832 return fmt.Errorf("%w: size", ErrInvalidOutput)
825833 }
826- if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-outputPayloadOverhead {
834+ if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
827835 return ErrInvalidOutput
828836 }
829837 return nil
@@ -912,6 +920,8 @@ func MarshalHello(h Hello) []byte {
912920 w.putString(string(h.PreferredTabID))
913921 w.putUint8(uint8(h.NavigationCapabilities))
914922 w.putUint8(uint8(h.StartupOverlay))
923+ w.putBool(h.Remote)
924+ w.putBool(h.KittyDirectGraphics)
915925 return w.b
916926 }
917927
@@ -988,6 +998,12 @@ func preflightHello(b []byte) error {
988998 if _, err := r.getUint8(); err != nil {
989999 return err
9901000 }
1001+ if _, err := r.getBool(); err != nil {
1002+ return err
1003+ }
1004+ if _, err := r.getBool(); err != nil {
1005+ return err
1006+ }
9911007 return r.done()
9921008 }
9931009
@@ -1091,6 +1107,12 @@ func UnmarshalHello(b []byte) (Hello, error) {
10911107 return Hello{}, err
10921108 }
10931109 h.StartupOverlay = StartupOverlay(overlay)
1110+ if h.Remote, err = r.getBool(); err != nil {
1111+ return Hello{}, err
1112+ }
1113+ if h.KittyDirectGraphics, err = r.getBool(); err != nil {
1114+ return Hello{}, err
1115+ }
10941116 if err := r.done(); err != nil {
10951117 return Hello{}, err
10961118 }
@@ -1568,7 +1590,7 @@ func UnmarshalOutput(b []byte) (Output, error) {
15681590 if err != nil {
15691591 return Output{}, err
15701592 }
1571- if int64(decodedLen) > int64(MaxFrameLen-1-outputPayloadOverhead) {
1593+ if int64(decodedLen) > int64(MaxOutputDataLen) {
15721594 return Output{}, ErrInvalidOutput
15731595 }
15741596 data, err := r.getLongBytes()
M internal/ports/wire_final_test.go +37 -3
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 35 {
15- t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)
14+ if ProtocolVersion != 36 {
15+ t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,8 +26,9 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 35, 2}, make([]byte, 16+8)...)
29+ want := append([]byte{0, 36, 2}, make([]byte, 16+8)...)
3030 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31+ want = append(want, 0, 0)
3132 got := MarshalHello(msg)
3233 if !bytes.Equal(got, want) {
3334 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -43,6 +44,21 @@ func TestFinalHelloGoldenStrict(t *testing.T) {
4344 assertTrailingGarbageFails(t, got, UnmarshalHello)
4445 }
4546
47+func TestHelloDeclaresKittyDirectGraphicsAtWireTail(t *testing.T) {
48+ msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true}
49+ payload := MarshalHello(msg)
50+ if len(payload) == 0 || payload[len(payload)-1] != 1 {
51+ t.Fatalf("Hello capability tail = %x, want trailing true declaration", payload)
52+ }
53+ decoded, err := UnmarshalHello(payload)
54+ if err != nil {
55+ t.Fatalf("UnmarshalHello() error = %v", err)
56+ }
57+ if !decoded.KittyDirectGraphics {
58+ t.Fatal("decoded KittyDirectGraphics = false, want true")
59+ }
60+}
61+
4662 func TestFinalHelloSemanticValidation(t *testing.T) {
4763 valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
4864 for _, tt := range []struct {
@@ -250,6 +266,24 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
250266 }(),
251267 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
252268 },
269+ {
270+ name: "hello remote boolean",
271+ payload: func() []byte {
272+ b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
273+ b[len(b)-2] = 2
274+ return b
275+ }(),
276+ decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
277+ },
278+ {
279+ name: "hello Kitty graphics boolean",
280+ payload: func() []byte {
281+ b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
282+ b[len(b)-1] = 2
283+ return b
284+ }(),
285+ decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
286+ },
253287 {
254288 name: "welcome boolean",
255289 payload: func() []byte {
M internal/ports/wire_test.go +15 -15
@@ -55,8 +55,8 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
5555
5656 func appendNoNavigationTail(payload []byte) []byte {
5757 // exact-target absent, preferred-tab absent, navigation capabilities none,
58- // startup overlay none.
59- return append(payload, 0, 0, 0, 0, 0)
58+ // startup overlay none, local carriage.
59+ return append(payload, 0, 0, 0, 0, 0, 0, 0)
6060 }
6161
6262 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -135,13 +135,13 @@ func TestHelloEnvironmentCodec(t *testing.T) {
135135 }
136136 got := MarshalHello(msg)
137137 want := []byte{
138- 0x00, 0x23, 0x00, // version, intent, first client ID byte
138+ 0x00, 0x24, 0x00, // version, intent, first client ID byte
139139 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
142142 0x00, 0x00, // name
143143 0x00, 0x01, 0x00, 0x01, // size
144- 0x00, 0x00, 0x00, 0x00, // optional pixel size
144+ 0x00, 0x00, 0x00, 0x00, // pixel width and height
145145 0x00, 0x00, // TERM
146146 0x00, 0x00, // cwd
147147 0x00, 0x00, // true color, max output in flight
@@ -178,15 +178,15 @@ func TestHelloEnvironmentCodec(t *testing.T) {
178178 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
179179 withCount := func(count byte) []byte {
180180 payload := append([]byte(nil), base...)
181- // The uint32 environment count occupies bytes len-11 through len-8.
182- payload[len(payload)-8] = count
181+ // The uint32 environment count occupies bytes len-13 through len-10.
182+ payload[len(payload)-10] = count
183183 return payload
184184 }
185185 withEntries := func(count byte, entries ...byte) []byte {
186186 payload := withCount(count)
187- // The seven-byte tail follows the environment-count field.
188- tail := append([]byte(nil), payload[len(payload)-7:]...)
189- payload = append(payload[:len(payload)-7], entries...)
187+ // The nine-byte tail follows the environment-count field.
188+ tail := append([]byte(nil), payload[len(payload)-9:]...)
189+ payload = append(payload[:len(payload)-9], entries...)
190190 return append(payload, tail...)
191191 }
192192 tests := []struct {
@@ -366,7 +366,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
366366 }
367367 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
368368 require.Equal(t, want, MarshalTheme(cleared))
369- require.Equal(t, uint16(35), ProtocolVersion)
369+ require.Equal(t, uint16(36), ProtocolVersion)
370370 }
371371
372372 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -527,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
527527 name: "minimal",
528528 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
529529 want: []byte{
530- 0x00, 0x23,
530+ 0x00, 0x24,
531531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
532532 0x00, // attached
533533 0x00, // self
@@ -550,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
550550 JSON: true,
551551 },
552552 want: []byte{
553- 0x00, 0x23,
553+ 0x00, 0x24,
554554 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
555555 0x00, // attached
556556 0x01, // self
@@ -947,7 +947,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
947947 for _, window := range []uint8{0, 1, 8} {
948948 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
949949 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
950- // The empty Hello has a fixed 1x1 size before the negotiated output window.
950+ // The empty Hello has a fixed 1x1 size and zero pixel geometry before the negotiated output window.
951951 want := append(make([]byte, 42), window, 0, 0, 0, 0, 0, 0)
952952 want = appendNoNavigationTail(want)
953953 want[1] = 14
@@ -979,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
979979 name: "attached",
980980 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
981981 want: []byte{
982- 0x00, 0x23, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
982+ 0x00, 0x24, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
983983 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
984984 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
985985 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
989989 name: "control",
990990 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
991991 want: []byte{
992- 0x00, 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
992+ 0x00, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
993993 0x00, 0x02, 'l', 's', 0x00, 0x00,
994994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
995995 },
M internal/usecase/client/client.go +131 -16
@@ -20,6 +20,7 @@ import (
2020 "time"
2121
2222 renderer "github.com/bnema/vev-vt"
23+ "github.com/bnema/vev-vt/protocol/terminalquery"
2324 "github.com/bnema/vev/internal/domain"
2425 "github.com/bnema/vev/internal/ports"
2526 "github.com/bnema/vev/internal/usecase/theme"
@@ -138,6 +139,8 @@ const preWelcomeTimeout = ports.HandshakeTimeout
138139 // learner must not block shell prompt return indefinitely.
139140 const remoteHostLearnerShutdownTimeout = time.Second
140141
142+const kittyCapabilityProbeTimeout = 150 * time.Millisecond
143+
141144 var reconnectSleep = sleepReconnect
142145 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
143146
@@ -150,13 +153,16 @@ const (
150153 type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error)
151154
152155 type Dependencies struct {
153- Dialer ports.Dialer
154- Terminal ports.Terminal
155- Clock ports.Clock
156- Clipboard ports.ClipboardReader
157- Logger *slog.Logger
158- RuntimeObserver ports.SerializedRuntimeObserver
159- RemoteHostLearner ports.RemoteHostLearner
156+ Dialer ports.Dialer
157+ Terminal ports.Terminal
158+ Clock ports.Clock
159+ // DisableCapabilityProbe skips active outer-terminal discovery for headless
160+ // embedders and deterministic tests. Interactive clients leave it false.
161+ DisableCapabilityProbe bool
162+ Clipboard ports.ClipboardReader
163+ Logger *slog.Logger
164+ RuntimeObserver ports.SerializedRuntimeObserver
165+ RemoteHostLearner ports.RemoteHostLearner
160166 // AttachHandoff keeps one Runner and one terminal/input ownership across a
161167 // local daemon's structured remote handoff. It is nil for direct CLI attach.
162168 AttachHandoff AttachHandoffFunc
@@ -211,6 +217,10 @@ type Runner struct {
211217
212218 inputMu sync.Mutex
213219 input *terminalInputPump
220+
221+ capabilityMu sync.Mutex
222+ kittyDirectGraphics *bool
223+ probeCapabilities bool
214224 }
215225
216226 // NewRunner constructs a client runner. A nil logger uses the process default.
@@ -230,6 +240,7 @@ func NewRunner(deps Dependencies) *Runner {
230240 attachHandoff: deps.AttachHandoff,
231241 remote: deps.Remote,
232242 origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
243+ probeCapabilities: !deps.DisableCapabilityProbe,
233244 ledger: newRouteLedger(),
234245 }
235246 }
@@ -249,6 +260,99 @@ func (r *Runner) terminalInput() *terminalInputPump {
249260 return r.input
250261 }
251262
263+func (r *Runner) kittyProbeEnabled() bool {
264+ return r != nil && r.probeCapabilities
265+}
266+
267+// probeKittyDirectGraphics performs one bounded probe for the outer terminal.
268+// It claims the existing lifecycle input pump instead of creating a second
269+// reader, removes only its two responses, and requeues unrelated bytes for the
270+// attach scanner in their original order.
271+func (r *Runner) probeKittyDirectGraphics(ctx context.Context, input *terminalInputPump) bool {
272+ if r == nil || input == nil || !r.kittyProbeEnabled() {
273+ return false
274+ }
275+ r.capabilityMu.Lock()
276+ if r.kittyDirectGraphics != nil {
277+ value := *r.kittyDirectGraphics
278+ r.capabilityMu.Unlock()
279+ return value
280+ }
281+ r.capabilityMu.Unlock()
282+
283+ consumer := input.claim()
284+ defer input.revoke(consumer)
285+ probe := &terminalquery.Probe{}
286+ var replay []byte
287+ query := terminalquery.KittyGraphicsQuery + terminalquery.DeviceAttributesQuery
288+ log := r.logger
289+ if log == nil {
290+ log = slog.Default()
291+ }
292+ if _, err := r.term.Out().Write([]byte(query)); err != nil {
293+ log.Warn("kitty graphics probe write failed", "err", err)
294+ return r.rememberKittyDirectGraphics(false)
295+ }
296+ if err := r.term.Flush(); err != nil {
297+ log.Warn("kitty graphics probe flush failed", "err", err)
298+ return r.rememberKittyDirectGraphics(false)
299+ }
300+
301+ clock := r.clock
302+ if clock == nil {
303+ clock = systemClock{}
304+ }
305+ timer := clock.NewTimer(kittyCapabilityProbeTimeout)
306+ defer timer.Stop()
307+ // DA1 is the FIFO sentinel for the preceding graphics query. Once it
308+ // arrives, the presence or absence of the Kitty response is definitive.
309+ for !probe.DA1() {
310+ select {
311+ case <-ctx.Done():
312+ replay = append(replay, probe.Finish()...)
313+ goto done
314+ case <-timer.C():
315+ replay = append(replay, probe.Finish()...)
316+ goto done
317+ case <-input.ready:
318+ result, ok := input.take(ctx, consumer)
319+ if !ok {
320+ if ctx.Err() != nil {
321+ replay = append(replay, probe.Finish()...)
322+ goto done
323+ }
324+ continue
325+ }
326+ replay = append(replay, probe.Feed(result.data)...)
327+ input.ack(consumer)
328+ if result.err != nil {
329+ replay = append(replay, probe.Finish()...)
330+ goto done
331+ }
332+ }
333+ }
334+ replay = append(replay, probe.Finish()...)
335+
336+done:
337+ if len(replay) != 0 {
338+ // The lease is no longer delivering after ack. Preserve before revoke so
339+ // the replacement attach consumes these bytes before another OS read.
340+ input.preserveResidual(consumer, replay)
341+ }
342+ return r.rememberKittyDirectGraphics(probe.KittyGraphics() && probe.DA1())
343+}
344+
345+func (r *Runner) rememberKittyDirectGraphics(value bool) bool {
346+ r.capabilityMu.Lock()
347+ if r.kittyDirectGraphics == nil {
348+ r.kittyDirectGraphics = new(bool)
349+ *r.kittyDirectGraphics = value
350+ }
351+ value = *r.kittyDirectGraphics
352+ r.capabilityMu.Unlock()
353+ return value
354+}
355+
252356 func validateAttachRequest(request AttachRequest) error {
253357 if request.Origin != 0 {
254358 if err := request.Origin.Validate(); err != nil {
@@ -1083,7 +1187,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10831187 }
10841188 return nil
10851189 }
1086- // 1. Handshake: send Hello with our geometry and TERM.
1190+ // Actively probe the direct outer terminal before Hello. The probe uses the
1191+ // lifecycle input pump; unsupported and silent terminals fail closed within
1192+ // its bounded deadline.
1193+ kittyDirectGraphics := false
1194+ if a.runner != nil && a.runner.kittyProbeEnabled() && a.terminalInput != nil {
1195+ if err := enterRaw(); err != nil {
1196+ return attachResult{err: err}
1197+ }
1198+ kittyDirectGraphics = a.runner.probeKittyDirectGraphics(handshakeCtx, a.terminalInput())
1199+ }
10871200 geometry, err := term.Geometry()
10881201 if err != nil {
10891202 return attachResult{err: fmt.Errorf("vev: reading terminal geometry: %w", err)}
@@ -1117,6 +1230,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11171230 TermEnv: termEnv,
11181231 Cwd: cwd,
11191232 TrueColor: trueColor,
1233+ KittyDirectGraphics: kittyDirectGraphics,
11201234 MaxOutputInFlight: requestedOutputWindow(transport),
11211235 Env: os.Environ(),
11221236 RemoteTarget: request.RemoteTarget,
@@ -1125,6 +1239,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11251239 EnvironmentPolicy: request.EnvironmentPolicy,
11261240 NavigationCapabilities: request.NavigationCapabilities,
11271241 StartupOverlay: request.StartupOverlay,
1242+ Remote: remote,
11281243 }
11291244 if err := sendHandshake(func() error {
11301245 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
@@ -1508,12 +1623,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15081623 defer clearParkedPending()
15091624
15101625 sendParkedRouteSize := func() error {
1511- current, err := term.Geometry()
1626+ geometry, err := term.Geometry()
15121627 if err != nil {
15131628 return fmt.Errorf("reading terminal geometry for parked route: %w", err)
15141629 }
1515- current = current.NormalizePixels()
1516- payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})
1630+ geometry = geometry.NormalizePixels()
1631+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
15171632 if err != nil {
15181633 return fmt.Errorf("encoding terminal size for parked route: %w", err)
15191634 }
@@ -1605,12 +1720,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
16051720 if awaitingReconnectReset {
16061721 return nil
16071722 }
1608- current, serr := term.Geometry()
1723+ geometry, serr := term.Geometry()
16091724 if serr != nil {
16101725 return fmt.Errorf("reading terminal geometry for reconnect reconciliation: %w", serr)
16111726 }
1612- current = current.NormalizePixels()
1613- payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})
1727+ geometry = geometry.NormalizePixels()
1728+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
16141729 if err != nil {
16151730 return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)
16161731 }
@@ -1798,11 +1913,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17981913 awaitingReconnectReset = false
17991914 }
18001915 if reconnect.showing && reconnect.remote {
1801- current, serr := term.Geometry()
1916+ geometry, serr := term.Geometry()
18021917 if serr != nil {
18031918 return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))
18041919 }
1805- if rerr := reconnect.redraw(current.Size); rerr != nil {
1920+ if rerr := reconnect.redraw(geometry.Size); rerr != nil {
18061921 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))
18071922 }
18081923 } else if ferr := term.Flush(); ferr != nil {
M internal/usecase/client/client_test.go +7 -6
@@ -250,12 +250,13 @@ func runTestClient(ctx context.Context, deps client.Dependencies, request client
250250
251251 func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {
252252 return client.Dependencies{
253- Dialer: dialer,
254- Terminal: terminal,
255- Clock: clock,
256- Clipboard: clipboard,
257- Logger: slog.New(slog.DiscardHandler),
258- RuntimeObserver: observer,
253+ Dialer: dialer,
254+ Terminal: terminal,
255+ Clock: clock,
256+ DisableCapabilityProbe: true,
257+ Clipboard: clipboard,
258+ Logger: slog.New(slog.DiscardHandler),
259+ RuntimeObserver: observer,
259260 }
260261 }
261262
M internal/usecase/client/clipboard_intercept_test.go +1 -1
@@ -179,7 +179,7 @@ func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testi
179179
180180 result := make(chan error, 1)
181181 go func() {
182- result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true})
182+ result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, DisableCapabilityProbe: true, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true})
183183 }()
184184
185185 var gotNotice bool
A internal/usecase/client/kitty_probe_test.go +59 -0
@@ -0,0 +1,59 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev-vt/protocol/terminalquery"
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type kittyProbeTimer struct{ *time.Timer }
17+
18+func (t kittyProbeTimer) C() <-chan time.Time { return t.Timer.C }
19+func (t kittyProbeTimer) Reset(d time.Duration) bool { return t.Timer.Reset(d) }
20+func (t kittyProbeTimer) Stop() bool { return t.Timer.Stop() }
21+
22+type kittyProbeClock struct{}
23+
24+func (kittyProbeClock) Now() time.Time { return time.Now() }
25+func (kittyProbeClock) NewTimer(d time.Duration) ports.Timer {
26+ return kittyProbeTimer{Timer: time.NewTimer(d)}
27+}
28+
29+func TestKittyProbeEnabledIsTerminalAgnostic(t *testing.T) {
30+ for _, term := range []string{"", "arbitrary", "unknown-terminal"} {
31+ t.Run(term, func(t *testing.T) {
32+ t.Setenv("TERM", term)
33+ runner := &Runner{probeCapabilities: true}
34+ require.True(t, runner.kittyProbeEnabled())
35+ })
36+ }
37+}
38+
39+func TestKittyProbeUsesOneInputPumpAndReplaysUnrelatedInput(t *testing.T) {
40+ term := portsmocks.NewMockTerminal(t)
41+ term.EXPECT().In().Return(strings.NewReader("typed\x1b_Gi=31;OK\x1b\\\x1b[?1;2c")).Once()
42+ input := newTerminalInputPump(term.In())
43+ input.start()
44+ defer input.stop()
45+ var out bytes.Buffer
46+ term.EXPECT().Out().Return(&out).Once()
47+ term.EXPECT().Flush().Return(nil).Once()
48+ runner := &Runner{term: term, clock: kittyProbeClock{}, probeCapabilities: true}
49+
50+ require.True(t, runner.probeKittyDirectGraphics(context.Background(), input))
51+ require.Equal(t, terminalquery.KittyGraphicsQuery+terminalquery.DeviceAttributesQuery, out.String())
52+
53+ consumer := input.claim()
54+ defer input.revoke(consumer)
55+ result, ok := input.take(context.Background(), consumer)
56+ require.True(t, ok)
57+ require.Equal(t, []byte("typed"), result.data)
58+ input.ack(consumer)
59+}
M internal/usecase/client/output_state.go +11 -9
@@ -16,10 +16,18 @@ func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) {
1616 if output.Epoch == 0 {
1717 return outputApplyState{}, false
1818 }
19- if !s.initialized {
20- if output.New == 0 {
21- return s, true
19+ // State-independent terminal side effects are ordered on the same transport
20+ // as ordinary output but deliberately do not advance or acknowledge the
21+ // replay chain. They must still reach the terminal while a reset request is
22+ // gating state-bearing frames; handoff cleanup is the final such frame on
23+ // the old attachment and no later replay can make it up.
24+ if output.New == 0 {
25+ if output.Base != 0 || output.Full {
26+ return outputApplyState{}, false
2227 }
28+ return s, true
29+ }
30+ if !s.initialized {
2331 if output.Base != 0 || !output.Full {
2432 return outputApplyState{}, false
2533 }
@@ -32,12 +40,6 @@ func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) {
3240 if output.ViewRevision != s.viewRevision {
3341 return outputApplyState{}, false
3442 }
35- if output.New == 0 {
36- if output.Base != 0 || output.Full {
37- return outputApplyState{}, false
38- }
39- return s, true
40- }
4143 if output.Full || output.Base != s.state || output.New != output.Base+1 {
4244 return outputApplyState{}, false
4345 }
M internal/usecase/client/output_state_test.go +4 -0
@@ -21,6 +21,10 @@ func TestOutputApplyStateTransitions(t *testing.T) {
2121 {name: "increment", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: true, want: outputApplyState{epoch: 1, state: 2, viewRevision: 2, initialized: true}},
2222 {name: "base gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 0, New: 2, Full: true, ViewRevision: 2}, accepted: false},
2323 {name: "revision gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 3}, accepted: false},
24+ {name: "handoff side effect crosses newer epoch replay gate", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, ViewRevision: 3}, accepted: true, want: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}},
25+ {name: "handoff side effect crosses stale epoch replay gate", state: outputApplyState{epoch: 2, state: 4, viewRevision: 5, initialized: true}, output: ports.Output{Epoch: 1, ViewRevision: 2}, accepted: true, want: outputApplyState{epoch: 2, state: 4, viewRevision: 5, initialized: true}},
26+ {name: "side effect cannot invent state", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, Base: 1}, accepted: false},
27+ {name: "side effect cannot claim full replay", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, Full: true}, accepted: false},
2428 {name: "new epoch reset", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, Base: 0, New: 1, Full: true, ViewRevision: 3}, accepted: true, want: outputApplyState{epoch: 2, state: 1, viewRevision: 3, initialized: true}},
2529 }
2630 for _, tt := range tests {
M internal/usecase/client/reconnect_toast_test.go +20 -7
@@ -504,6 +504,19 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T)
504504 _, sentAck := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck })
505505 require.False(t, sentAck, "discarded output must not be ACKed")
506506
507+ // Handoff cleanup is an ordered terminal side effect, not a replay state.
508+ // It must cross the outstanding reset gate, flush before the control handoff,
509+ // and must not manufacture an independent ACK.
510+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
511+ Epoch: 2,
512+ Size: domain.Size{Cols: 1, Rows: 1},
513+ Data: []byte("handoff graphics cleanup"),
514+ })}}
515+ cleanup := requireReconnectToastOutput(t, out.completed)
516+ require.Contains(t, cleanup, "handoff graphics cleanup")
517+ _, sentAck = tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck })
518+ require.False(t, sentAck, "state-independent cleanup must not be ACKed")
519+
507520 beforeResetFlushes := flushes.Load()
508521 tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
509522 Epoch: 2,
@@ -1362,7 +1375,7 @@ func TestReconnectRetryFailuresPreserveAttachError(t *testing.T) {
13621375 )
13631376 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
13641377
1365- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
1378+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
13661379 require.ErrorIs(t, err, attachErr)
13671380 require.ErrorIs(t, err, retryErr)
13681381 })
@@ -1419,7 +1432,7 @@ func TestReconnectRetrySleepFailuresPreserveDialError(t *testing.T) {
14191432 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
14201433 dialer.EXPECT().Dial(mock.Anything).Run(func(context.Context) { armed.Store(true) }).Return(nil, dialErr).Once()
14211434
1422- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
1435+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
14231436 require.ErrorIs(t, err, dialErr)
14241437 require.ErrorIs(t, err, retryErr)
14251438 })
@@ -1449,7 +1462,7 @@ func TestRemoteReconnectToastFailedDrawDoesNotBlankBounds(t *testing.T) {
14491462 }}
14501463 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
14511464
1452- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
1465+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
14531466 require.ErrorContains(t, err, "reconnect toast write failed")
14541467 require.True(t, out.failed)
14551468 require.NotContains(t, out.String(), strings.Repeat(" ", reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)).Width))
@@ -1526,7 +1539,7 @@ func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T)
15261539 dialer := portsmocks.NewMockDialer(t)
15271540 tt.configure(dialer, tr)
15281541
1529- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main"})
1542+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main"})
15301543 require.ErrorIs(t, err, context.Canceled)
15311544 require.ErrorIs(t, err, clearErr)
15321545 })
@@ -1562,7 +1575,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
15621575 }}
15631576 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
15641577
1565- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
1578+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
15661579 require.NoError(t, err)
15671580 require.True(t, tr1.wasClosed())
15681581 require.True(t, tr2.wasClosed())
@@ -1606,7 +1619,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
16061619 }}
16071620 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
16081621
1609- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: true})
1622+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: true})
16101623 require.NoError(t, err)
16111624 require.Equal(t, 2, dialer.calls)
16121625
@@ -1690,7 +1703,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
16901703 dialer := portsmocks.NewMockDialer(t)
16911704 transports := tt.configure(t, dialer)
16921705
1693- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
1706+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), DisableCapabilityProbe: true, Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
16941707 tt.wantErr(t, err)
16951708 for _, transport := range transports {
16961709 assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport)
M internal/usecase/daemon/attachment_output.go +91 -25
@@ -21,20 +21,47 @@ import (
2121 // Callers serialize access with attachedClient.sendMu. A prepared transaction
2222 // must be sent or committed as a no-op before the next transaction is prepared.
2323 type attachmentOutput struct {
24- renderer *renderer.Renderer
25- epoch uint64
26- next uint64
27- acked uint64
28- generation uint64
29- maxOutstanding uint64
30- outstandingAtomic atomic.Uint64
31- maxOutstandingAtomic atomic.Uint64
32- forceSnapshot bool
33- initialized bool
34- lastCursor cursorOut
35- lastRoutePosition ports.RoutePosition
36- attachment *attachedClient
37- stateMu sync.Mutex
24+ renderer *renderer.Renderer
25+ epoch uint64
26+ next uint64
27+ acked uint64
28+ generation uint64
29+ maxOutstanding uint64
30+ outstandingAtomic atomic.Uint64
31+ maxOutstandingAtomic atomic.Uint64
32+ forceSnapshot bool
33+ initialized bool
34+ lastCursor cursorOut
35+ lastRoutePosition ports.RoutePosition
36+ graphicsOutput *graphicsOutputState
37+ graphicsUnsupportedWarned atomic.Bool
38+ attachment *attachedClient
39+ stateMu sync.Mutex
40+}
41+
42+// reconfigureAttachmentOutput applies a replacement terminal's declared
43+// capabilities to attachment-owned output state. Caller holds d.mu.
44+func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, h ports.Hello) {
45+ if d == nil || ac == nil || ac.output == nil {
46+ return
47+ }
48+ ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
49+ ac.output.graphicsUnsupportedWarned.Store(false)
50+ // A replacement connection is a different outer terminal. Retire the old
51+ // attachment-local state without writing cleanup to the new terminal, then
52+ // allocate a fresh namespace for any replay it accepts.
53+ d.discardAttachmentOutputLocked(ac)
54+ if !h.KittyDirectGraphics {
55+ // The parked transport has already been retired. Do not emit cleanup into
56+ // a replacement connection that did not declare graphics support.
57+ return
58+ }
59+ if namespace, fence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, ac.clientID)); namespace != 0 {
60+ ac.output.graphicsOutput = newGraphicsOutputStateWithLease(namespace, fence)
61+ return
62+ }
63+ // Namespace exhaustion fails closed; ANSI output remains available.
64+ ac.terminalCapabilities.KittyGraphics = false
3865 }
3966
4067 // lockView serializes an attachment's view publication with every output
@@ -98,33 +125,49 @@ type cursorCandidate struct {
98125 // ANSI renderer state and cursor state advance together only after the output
99126 // frame is accepted by the attachment's state stream.
100127 type preparedAttachmentOutput struct {
101- output *attachmentOutput
102- ansi *preparedOutput
103- cursor cursorCandidate
104- data []byte
128+ output *attachmentOutput
129+ ansi *preparedOutput
130+ graphics *preparedGraphicsOutput
131+ cursor cursorCandidate
132+ data []byte
105133 }
106134
107-func (o *attachmentOutput) prepareFrame(frame renderer.Frame, damage []renderer.Damage, reset bool, desired cursorOut) (*preparedAttachmentOutput, error) {
135+func (o *attachmentOutput) prepareFrame(d *Daemon, state *capturedRenderState, frame renderer.Frame, damage []renderer.Damage, reset bool, desired cursorOut) (*preparedAttachmentOutput, error) {
108136 ansi, err := o.prepare(frame, damage, reset)
109137 if err != nil {
110138 return nil, err
111139 }
112140 cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)
141+ graphicsReset := reset || o.forceSnapshot || !o.initialized
142+ graphicsBudget := max(ports.MaxOutputDataLen-len(ansi.data)-len(cursor.data), 0)
143+ preparedGraphics, err := graphicsOutputDataWithDaemonLimit(d, state, o.attachment, graphicsReset, graphicsBudget)
144+ if err != nil {
145+ return nil, err
146+ }
113147 data := append([]byte(nil), ansi.data...)
148+ if preparedGraphics != nil {
149+ data = append(data, preparedGraphics.data...)
150+ }
114151 data = append(data, cursor.data...)
115- return &preparedAttachmentOutput{output: o, ansi: ansi, cursor: cursor, data: data}, nil
152+ return &preparedAttachmentOutput{output: o, ansi: ansi, graphics: preparedGraphics, cursor: cursor, data: data}, nil
116153 }
117154
118155 func (p *preparedAttachmentOutput) send(echoAck uint64, send func(ports.Frame) error) error {
119156 if p == nil || p.ansi == nil {
120157 return nil
121158 }
122- if err := p.ansi.send(p.data, echoAck, send); err != nil {
123- return err
159+ sendFrame := send
160+ if p.graphics != nil {
161+ sendFrame = func(frame ports.Frame) error {
162+ p.graphics.markSendAttempted()
163+ return send(frame)
164+ }
124165 }
125- if p.ansi.sent && p.output != nil {
126- p.output.lastCursor = p.cursor.next
166+ if err := p.ansi.send(p.data, echoAck, sendFrame); err != nil {
167+ p.abortGraphics()
168+ return err
127169 }
170+ p.commitIfSent()
128171 return nil
129172 }
130173
@@ -133,11 +176,34 @@ func (p *preparedAttachmentOutput) commitNoSend() {
133176 return
134177 }
135178 p.ansi.commitNoSend()
136- if p.ansi.sent && p.output != nil {
179+ p.commitIfSent()
180+}
181+
182+func (p *preparedAttachmentOutput) commitIfSent() {
183+ if p == nil || p.ansi == nil || !p.ansi.sent {
184+ p.abortGraphics()
185+ return
186+ }
187+ if p.graphics != nil {
188+ p.graphics.commit()
189+ }
190+ if p.output != nil {
137191 p.output.lastCursor = p.cursor.next
138192 }
139193 }
140194
195+func (p *preparedAttachmentOutput) abort() {
196+ if p != nil {
197+ p.abortGraphics()
198+ }
199+}
200+
201+func (p *preparedAttachmentOutput) abortGraphics() {
202+ if p != nil && p.graphics != nil {
203+ p.graphics.abort()
204+ }
205+}
206+
141207 func (p *preparedAttachmentOutput) sent() bool {
142208 return p != nil && p.ansi != nil && p.ansi.sent
143209 }
M internal/usecase/daemon/attachment_output_test.go +32 -0
@@ -7,8 +7,10 @@ import (
77
88 vt "github.com/bnema/vev-vt"
99 renderer "github.com/bnema/vev-vt/ansi"
10+ "github.com/bnema/vev-vt/graphics"
1011 "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
1214 "github.com/stretchr/testify/require"
1315 )
1416
@@ -431,6 +433,36 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.
431433 require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")
432434 }
433435
436+func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *testing.T) {
437+ scene := graphics.NewScene(graphics.Limits{})
438+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("asset"), Width: 1, Height: 1})
439+ require.NoError(t, err)
440+ _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
441+ require.NoError(t, err)
442+ state := &capturedRenderState{panes: []capturedPaneRenderState{{
443+ graphics: scene.Snapshot(),
444+ graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1},
445+ placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
446+ }}}
447+ graphicsState := newGraphicsOutputState()
448+ output := attachmentOutputWithGraphics(graphicsState)
449+ output.lastCursor = cursorOut{valid: true, row: 1, col: 1}
450+ ac := &attachedClient{output: output, terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true}}
451+ output.attachment = ac
452+ frame := renderer.NewFrame(1, 1)
453+ prepared, err := output.prepareFrame(nil, state, frame, []renderer.Damage{renderer.FullRedraw()}, true, cursorOut{row: 2, col: 3})
454+ require.NoError(t, err)
455+
456+ sendErr := errors.New("send failed")
457+ require.ErrorIs(t, prepared.send(0, func(ports.Frame) error { return sendErr }), sendErr)
458+
459+ require.Equal(t, cursorOut{valid: true, row: 1, col: 1}, output.lastCursor)
460+ require.Empty(t, graphicsState.assets)
461+ require.Empty(t, graphicsState.placements)
462+ require.NotEmpty(t, graphicsState.pendingImages)
463+ require.NotEmpty(t, graphicsState.pendingPlaces)
464+}
465+
434466 func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) {
435467 output := newOutputStateStream()
436468 output.next = 3
M internal/usecase/daemon/client.go +26 -3
@@ -170,7 +170,14 @@ func (ac *attachedClient) sizeSnapshot() domain.Size {
170170 }
171171
172172 func (ac *attachedClient) setSize(size domain.Size) {
173- ac.setGeometry(domain.Geometry{Size: size})
173+ if ac == nil {
174+ return
175+ }
176+ ac.sizeMu.Lock()
177+ ac.size = size
178+ ac.geometry.Size = size
179+ ac.geometry = ac.geometry.NormalizePixels()
180+ ac.sizeMu.Unlock()
174181 }
175182
176183 func (ac *attachedClient) geometrySnapshot() domain.Geometry {
@@ -662,7 +669,7 @@ type attachClientOptions struct {
662669
663670 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
664671 d.mu.Lock()
665- ac := d.prepareAttachedClientLocked(tr, domain.Geometry{Size: sz}, opts)
672+ ac := d.prepareAttachedClientLocked(sess, tr, domain.Geometry{Size: sz}, opts)
666673 d.mu.Unlock()
667674 result, err := d.transitionAttachment(attachmentTransitionRequest{
668675 target: sess,
@@ -672,6 +679,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
672679 ready: false,
673680 })
674681 if err != nil {
682+ d.discardAttachmentOutput(ac)
675683 if tr != nil {
676684 _ = tr.Close()
677685 }
@@ -696,12 +704,15 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
696704 })
697705 }
698706 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
707+ if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
708+ d.warnUnsupportedGraphics(ac)
709+ }
699710 }
700711
701712 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
702713 // d.mu only to allocate its resume token; attachment publication happens later after
703714 // the caller releases every architecture lock.
704-func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, geometry domain.Geometry, opts attachClientOptions) *attachedClient {
715+func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.Transport, geometry domain.Geometry, opts attachClientOptions) *attachedClient {
705716 resumeToken := uint64(0)
706717 if opts.resumeCapable {
707718 resumeToken = d.nextResumeTokenLocked()
@@ -711,6 +722,18 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, geometry domain
711722 }
712723 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
713724 geometry = geometry.NormalizePixels()
725+ var graphicsOutput *graphicsOutputState
726+ if opts.terminalCapabilities.SupportsKittyGraphics() {
727+ if namespace, fence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, opts.clientID)); namespace != 0 {
728+ graphicsOutput = newGraphicsOutputStateWithLease(namespace, fence)
729+ } else {
730+ // Namespace leases that may have emitted are never reused without a
731+ // terminal ACK. The bounded pool therefore fails closed to the normal
732+ // text renderer instead of risking another attachment's objects.
733+ opts.terminalCapabilities.KittyGraphics = false
734+ }
735+ }
736+ output.graphicsOutput = graphicsOutput
714737 ac := &attachedClient{
715738 tr: tr,
716739 output: output,
M internal/usecase/daemon/client_transport_errors.go +6 -0
@@ -97,6 +97,9 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
9797 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
9898 d.closePicker(ac)
9999 d.closePalette(ac)
100+ if err := d.cleanupAttachmentOutput(ac); err != nil {
101+ d.log.Warn("output cleanup failed during detach", "err", err, "session", name)
102+ }
100103
101104 d.resetScreenDefaultColors(sess)
102105 if explicit && notice {
@@ -187,6 +190,9 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
187190 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
188191 d.closePicker(ac)
189192 d.closePalette(ac)
193+ if err := d.cleanupAttachmentOutput(ac); err != nil {
194+ d.log.Warn("output cleanup failed after send error", "err", err, "session", name)
195+ }
190196 d.resetScreenDefaultColors(sess)
191197 _ = ac.closeCapturedTransport(failed)
192198 d.log.Warn("detached client after send error", "session", name)
M internal/usecase/daemon/clipboard_test.go +2 -2
@@ -445,6 +445,8 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
445445 sess.clipboardWorkerRunning = true
446446 sess.mu.Unlock()
447447 d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
448+ sourceTab := testAttachmentTab(sess)
449+ p := sourceTab.focusedPane()
448450
449451 workerDone := make(chan struct{})
450452 go func() {
@@ -453,8 +455,6 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
453455 }()
454456 awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")
455457
456- sourceTab := testAttachmentTab(sess)
457- p := sourceTab.focusedPane()
458458 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
459459 publishPaneOwner(p, destination, &tab{}, 0)
460460 publishPaneOwner(p, sess, sourceTab, 0)
M internal/usecase/daemon/daemon.go +65 -23
@@ -3,6 +3,8 @@ package daemon
33
44 import (
55 "context"
6+ cryptorand "crypto/rand"
7+ "encoding/binary"
68 "errors"
79 "log/slog"
810 "math"
@@ -109,6 +111,18 @@ type Daemon struct {
109111 // with Wait.
110112 notifies []chan struct{}
111113 parked map[uint64]*parkedAttachment
114+ // graphicsNamespaces reserves deterministic, attachment/session-scoped Kitty
115+ // ID blocks. Once a block may have reached an outer terminal it remains in
116+ // this bounded table for the daemon lifetime: side-effect Output frames have
117+ // no terminal ACK. Pool exhaustion disables graphics for new attachments and
118+ // leaves their ordinary text output intact.
119+ graphicsNamespaces map[uint64]struct{}
120+ graphicsNamespaceFences map[uint64]uint64
121+ graphicsNamespaceQuarantines map[uint64]*graphicsNamespaceQuarantine
122+ // graphicsNamespaceSalt separates daemon lifetimes before attachment keys
123+ // are hashed. Kitty IDs are terminal-global, so a restarted or neighboring
124+ // daemon must not deterministically reopen the previous daemon's block.
125+ graphicsNamespaceSalt uint64
112126 // parking tracks resume-capable attachments from before detach clears the
113127 // live seat until parkAttachment publishes the token into parked.
114128 // IntentResume waits on the matching entry instead of treating the live
@@ -604,6 +618,23 @@ func (d *Daemon) nowUnixNano() int64 {
604618 type systemClock struct{}
605619 type systemTimer struct{ *time.Timer }
606620
621+var graphicsNamespaceFallbackSalt atomic.Uint64
622+
623+func newGraphicsNamespaceSalt() uint64 {
624+ var bytes [8]byte
625+ if _, err := cryptorand.Read(bytes[:]); err == nil {
626+ if salt := binary.BigEndian.Uint64(bytes[:]); salt != 0 {
627+ return salt
628+ }
629+ }
630+ for {
631+ salt := graphicsNamespaceFallbackSalt.Add(1)
632+ if salt != 0 {
633+ return salt
634+ }
635+ }
636+}
637+
607638 func (systemClock) Now() time.Time { return time.Now() }
608639 func (systemClock) NewTimer(delay time.Duration) ports.Timer {
609640 return systemTimer{Timer: time.NewTimer(delay)}
@@ -621,28 +652,32 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
621652 }
622653 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
623654 d := &Daemon{
624- sessions: make(map[domain.SessionID]*session),
625- inactive: make(map[string]inactiveSession),
626- creating: make(map[string]struct{}),
627- parked: make(map[uint64]*parkedAttachment),
628- parking: make(map[uint64]*parkingAttachment),
629- paneProcessCtx: paneProcessCtx,
630- paneProcessCancel: paneProcessCancel,
631- ptys: ptys,
632- clock: clock,
633- log: log,
634- baseEnv: os.Environ(),
635- shell: defaultShellCommand,
636- dirOrHome: dirOrHome,
637- done: make(chan struct{}),
638- restoreDone: make(chan struct{}),
639- animWake: make(chan struct{}, 1),
640- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
641- snapshotAdmitted: make(map[*snapshotCapture]struct{}),
642- snapshotWake: make(chan struct{}, 1),
643- notices: newNoticeCenter(),
644- remoteCatalog: newRemoteCatalogState(),
645- resumeParkGrace: defaultResumeParkGrace,
655+ sessions: make(map[domain.SessionID]*session),
656+ inactive: make(map[string]inactiveSession),
657+ creating: make(map[string]struct{}),
658+ parked: make(map[uint64]*parkedAttachment),
659+ graphicsNamespaces: make(map[uint64]struct{}),
660+ graphicsNamespaceFences: make(map[uint64]uint64),
661+ graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
662+ graphicsNamespaceSalt: newGraphicsNamespaceSalt(),
663+ parking: make(map[uint64]*parkingAttachment),
664+ paneProcessCtx: paneProcessCtx,
665+ paneProcessCancel: paneProcessCancel,
666+ ptys: ptys,
667+ clock: clock,
668+ log: log,
669+ baseEnv: os.Environ(),
670+ shell: defaultShellCommand,
671+ dirOrHome: dirOrHome,
672+ done: make(chan struct{}),
673+ restoreDone: make(chan struct{}),
674+ animWake: make(chan struct{}, 1),
675+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
676+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
677+ snapshotWake: make(chan struct{}, 1),
678+ notices: newNoticeCenter(),
679+ remoteCatalog: newRemoteCatalogState(),
680+ resumeParkGrace: defaultResumeParkGrace,
646681 barScripts: &barScriptState{
647682 cfg: barConfigFromDomain(domain.Defaults().Bar),
648683 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -1307,6 +1342,10 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
13071342 }
13081343 sess.mu.Unlock()
13091344 terminalCapabilities := ports.DetectTerminalCapabilities(h.Env)
1345+ // Kitty graphics are enabled only by the explicit direct-terminal
1346+ // declaration in Hello. Environment values remain useful for color and
1347+ // diagnostics, but cannot authorize terminal-global graphics side effects.
1348+ terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
13101349 if h.TrueColor && !terminalCapabilities.TrueColor() {
13111350 terminalCapabilities.ColorMode = ports.TerminalColorTrueColor
13121351 terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared
@@ -1324,7 +1363,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
13241363 if geometry.Size != sz {
13251364 geometry = domain.Geometry{Size: sz}
13261365 }
1327- ac := d.prepareAttachedClientLocked(tr, geometry, opts)
1366+ ac := d.prepareAttachedClientLocked(sess, tr, geometry, opts)
13281367 d.mu.Unlock()
13291368 result, err := d.transitionAttachment(attachmentTransitionRequest{
13301369 target: sess,
@@ -1335,6 +1374,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
13351374 ready: false,
13361375 })
13371376 if err != nil {
1377+ if ac.output != nil && ac.output.graphicsOutput != nil {
1378+ d.retireGraphicsOutput(ac, ac.output.graphicsOutput)
1379+ }
13381380 if tr != nil {
13391381 _ = tr.Close()
13401382 }
M internal/usecase/daemon/daemon_test.go +1 -0
@@ -405,6 +405,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
405405 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
406406 tr, sends := newCapturingTransport(t)
407407 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
408+ ac.output.attachment = ac
408409 ac.initOverlays()
409410 sctx, cancel := context.WithCancel(d.serveCtx)
410411 tabs := make([]*tab, 0, len(ptys))
A internal/usecase/daemon/graphics_capability.go +62 -0
@@ -0,0 +1,62 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+)
6+
7+// sessionHasKittyGraphics reports whether a session currently contains any
8+// decoded Kitty scene. It is used only for an attachment-local warning; the
9+// renderer still suppresses graphics for attachments that did not declare
10+// direct support.
11+func sessionHasKittyGraphics(sess *session) bool {
12+ if sess == nil {
13+ return false
14+ }
15+ sess.mu.Lock()
16+ tabs := append([]*tab(nil), sess.tabs...)
17+ sess.mu.Unlock()
18+ for _, tb := range tabs {
19+ if tb == nil {
20+ continue
21+ }
22+ tb.mu.Lock()
23+ panes := make([]*pane, 0, len(tb.panes)+1)
24+ for _, p := range tb.panes {
25+ panes = append(panes, p)
26+ }
27+ if tb.floating.pane != nil {
28+ panes = append(panes, tb.floating.pane)
29+ }
30+ tb.mu.Unlock()
31+ for _, p := range panes {
32+ if p == nil {
33+ continue
34+ }
35+ p.mu.Lock()
36+ graphics := p.screen.GraphicsSnapshot()
37+ p.mu.Unlock()
38+ if graphics != nil && graphics.Usage().Placements != 0 {
39+ return true
40+ }
41+ }
42+ }
43+ return false
44+}
45+
46+func (d *Daemon) warnUnsupportedGraphics(ac *attachedClient) bool {
47+ if d == nil || ac == nil || ac.output == nil || ac.terminalCapabilities.SupportsKittyGraphics() {
48+ return false
49+ }
50+ sess := ac.currentAttachmentSession()
51+ if sess == nil || !sessionHasKittyGraphics(sess) || ac.output.graphicsUnsupportedWarned.Swap(true) {
52+ return false
53+ }
54+ return d.publishToast(ac, domain.Notification{
55+ Code: domain.NoticeUser,
56+ Severity: domain.NoticeWarn,
57+ Message: "Kitty graphics are unavailable on this attachment; images are suppressed.",
58+ Time: d.clock.Now(),
59+ Count: 1,
60+ SessionID: sess.id,
61+ })
62+}
A internal/usecase/daemon/graphics_output.go +1319 -0
@@ -0,0 +1,1319 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "compress/zlib"
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "encoding/binary"
9+ "errors"
10+ "fmt"
11+ "sort"
12+ "strconv"
13+ "sync/atomic"
14+
15+ renderer "github.com/bnema/vev-vt"
16+ "github.com/bnema/vev-vt/graphics"
17+ "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
19+ "github.com/bnema/vev/internal/usecase/layout"
20+ themeui "github.com/bnema/vev/internal/usecase/theme"
21+)
22+
23+// Graphics output is deliberately a small attachment-local backend. It emits
24+// direct uploads and ordinary placements from immutable pane snapshots. The
25+// attachment-local state is also the ownership boundary for Kitty IDs: raw
26+// scene IDs are never sent to the outer terminal.
27+const (
28+ maxGraphicsRecordBytes = ports.MaxOutputDataLen
29+ maxGraphicsOutputBytes = ports.MaxOutputDataLen
30+ graphicsUploadChunkBytes = 128 << 10
31+)
32+
33+var (
34+ errGraphicsOutputTooLarge = errors.New("kitty graphics output exceeds bound")
35+ errGraphicsIDExhausted = errors.New("kitty graphics output ID exhausted")
36+)
37+
38+type graphicsOutputAsset struct {
39+ id uint64
40+}
41+
42+type graphicsOutputPlacement struct {
43+ id uint64
44+ asset string
45+ source graphics.PixelRect
46+ dest graphics.PixelRect
47+ cells graphics.CellRect
48+ layer int64
49+ // transform is set for composed multi-pane placements. Keeping it on the
50+ // placement (rather than on graphicsOutputState) lets panes with distinct
51+ // pixel geometries share one attachment-local output state.
52+ transform graphicsOutputTransform
53+ transformSet bool
54+}
55+
56+type graphicsOutputState struct {
57+ assets map[string]graphicsOutputAsset
58+ placements map[string]graphicsOutputPlacement
59+ pendingImages map[uint64]struct{}
60+ pendingPlaces map[uint64]struct{}
61+ // nextID is a per-attachment cursor in Kitty's terminal-global 32-bit ID
62+ // namespace. Image and placement IDs share the cursor so neither kind can
63+ // collide within an attachment. namespaceBase is reserved by the daemon for
64+ // the attachment/session until cleanup has retired its outer objects.
65+ nextID uint64
66+ namespaceBase uint64
67+ namespaceFence uint64
68+ // mayHaveEmitted is sticky once a Kitty record reaches the transport
69+ // boundary. Output side effects are not terminal-ACKed, so neither a
70+ // successful socket send nor an empty committed scene can make this
71+ // namespace safe for another attachment.
72+ mayHaveEmitted bool
73+ transform graphicsOutputTransform
74+ transformSet bool
75+}
76+
77+type preparedGraphicsOutput struct {
78+ owner *graphicsOutputState
79+ state graphicsOutputState
80+ data []byte
81+ maxBytes int
82+ valid bool
83+ sendAttempted bool
84+}
85+
86+type graphicsOutputTransform struct {
87+ originX, originY int
88+ sourceGeometry domain.Geometry
89+}
90+
91+const (
92+ maxKittyGraphicsID = uint64(^uint32(0))
93+ maxGraphicsIDs = uint64(1024 + 65536)
94+ // A namespace is deliberately larger than the maximum bounded scene so an
95+ // attachment can replace/delete objects without sharing IDs with another
96+ // attachment. The high bits identify the attachment/session namespace and
97+ // the low bits are its monotonically allocated object IDs. IDs start at one,
98+ // so reserve only complete blocks and leave the short tail of Kitty's
99+ // uint32 ID space unused.
100+ graphicsIDNamespaceSize = uint64(1 << 17)
101+ graphicsIDNamespaceCount = maxKittyGraphicsID / graphicsIDNamespaceSize
102+)
103+
104+var (
105+ standaloneGraphicsNamespace atomic.Uint64
106+ graphicsNamespaceFence atomic.Uint64
107+)
108+
109+type graphicsNamespaceQuarantine struct {
110+ base uint64
111+ fence uint64
112+}
113+
114+func nextGraphicsNamespaceFence() uint64 {
115+ fence := graphicsNamespaceFence.Add(1)
116+ if fence == 0 {
117+ fence = graphicsNamespaceFence.Add(1)
118+ }
119+ return fence
120+}
121+
122+func newGraphicsOutputState() *graphicsOutputState {
123+ block := (standaloneGraphicsNamespace.Add(1) - 1) % graphicsIDNamespaceCount
124+ base := block*graphicsIDNamespaceSize + 1
125+ return newGraphicsOutputStateWithLease(base, nextGraphicsNamespaceFence())
126+}
127+
128+func newGraphicsOutputStateWithBase(base uint64) *graphicsOutputState {
129+ return newGraphicsOutputStateWithLease(base, nextGraphicsNamespaceFence())
130+}
131+
132+func newGraphicsOutputStateWithLease(base, fence uint64) *graphicsOutputState {
133+ maxBase := (graphicsIDNamespaceCount-1)*graphicsIDNamespaceSize + 1
134+ if base == 0 || (base-1)%graphicsIDNamespaceSize != 0 || base > maxBase {
135+ base = 1
136+ }
137+ if fence == 0 {
138+ fence = nextGraphicsNamespaceFence()
139+ }
140+ return &graphicsOutputState{
141+ assets: make(map[string]graphicsOutputAsset),
142+ placements: make(map[string]graphicsOutputPlacement),
143+ pendingImages: make(map[uint64]struct{}),
144+ pendingPlaces: make(map[uint64]struct{}),
145+ nextID: base,
146+ namespaceBase: base,
147+ namespaceFence: fence,
148+ }
149+}
150+
151+func graphicsNamespaceKey(sess *session, clientID [16]byte) string {
152+ if sess == nil {
153+ return fmt.Sprintf("attachment:%x", clientID)
154+ }
155+ return fmt.Sprintf("session:%s:%x:%x", sess.id, sess.incarnation, clientID)
156+}
157+
158+// reserveGraphicsNamespaceLocked chooses a deterministic preferred block and
159+// linearly probes the bounded namespace table for a free block. Caller holds
160+// d.mu; probing makes equal session/client identities collision-safe too.
161+func (d *Daemon) reserveGraphicsNamespaceLocked(key string) uint64 {
162+ base, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
163+ return base
164+}
165+
166+func (d *Daemon) reserveGraphicsNamespaceLeaseLocked(key string) (uint64, uint64) {
167+ if d == nil {
168+ return 0, 0
169+ }
170+ if d.graphicsNamespaces == nil {
171+ d.graphicsNamespaces = make(map[uint64]struct{})
172+ }
173+ if d.graphicsNamespaceFences == nil {
174+ d.graphicsNamespaceFences = make(map[uint64]uint64)
175+ }
176+ if d.graphicsNamespaceQuarantines == nil {
177+ d.graphicsNamespaceQuarantines = make(map[uint64]*graphicsNamespaceQuarantine)
178+ }
179+ hashInput := []byte(key)
180+ if d.graphicsNamespaceSalt != 0 {
181+ salt := make([]byte, 8, 8+len(key))
182+ binary.BigEndian.PutUint64(salt, d.graphicsNamespaceSalt)
183+ hashInput = append(salt, hashInput...)
184+ }
185+ digest := sha256.Sum256(hashInput)
186+ preferred := binary.BigEndian.Uint64(digest[:8]) % graphicsIDNamespaceCount
187+ for offset := uint64(0); offset < graphicsIDNamespaceCount; offset++ {
188+ block := (preferred + offset) % graphicsIDNamespaceCount
189+ if _, exists := d.graphicsNamespaces[block]; exists {
190+ continue
191+ }
192+ fence := nextGraphicsNamespaceFence()
193+ d.graphicsNamespaces[block] = struct{}{}
194+ d.graphicsNamespaceFences[block] = fence
195+ return block*graphicsIDNamespaceSize + 1, fence
196+ }
197+ return 0, 0
198+}
199+
200+func (d *Daemon) releaseGraphicsNamespace(base uint64) {
201+ if d == nil || base == 0 || base%graphicsIDNamespaceSize != 1 {
202+ return
203+ }
204+ d.mu.Lock()
205+ d.releaseGraphicsNamespaceLocked(base)
206+ d.mu.Unlock()
207+}
208+
209+// releaseGraphicsNamespaceLocked is used by resume publication while d.mu is
210+// already held. A quarantined block is intentionally not released by this
211+// legacy base-only helper; only its fenced cleanup lifecycle may retire it.
212+func (d *Daemon) releaseGraphicsNamespaceLocked(base uint64) {
213+ if d == nil || base == 0 || base%graphicsIDNamespaceSize != 1 {
214+ return
215+ }
216+ block := (base - 1) / graphicsIDNamespaceSize
217+ if _, quarantined := d.graphicsNamespaceQuarantines[block]; quarantined {
218+ return
219+ }
220+ delete(d.graphicsNamespaces, block)
221+ delete(d.graphicsNamespaceFences, block)
222+}
223+
224+func (d *Daemon) releaseGraphicsNamespaceLeaseLocked(state *graphicsOutputState) {
225+ if d == nil || state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
226+ return
227+ }
228+ block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
229+ if _, quarantined := d.graphicsNamespaceQuarantines[block]; quarantined {
230+ return
231+ }
232+ if current := d.graphicsNamespaceFences[block]; current != 0 && current != state.namespaceFence {
233+ return
234+ }
235+ delete(d.graphicsNamespaces, block)
236+ delete(d.graphicsNamespaceFences, block)
237+}
238+
239+// quarantineGraphicsNamespaceLocked fences one exact namespace instance for
240+// the daemon lifetime. Output side effects have no terminal ACK, so transport
241+// completion must never release a block that may have reached an outer terminal.
242+// The caller holds d.mu.
243+func (d *Daemon) quarantineGraphicsNamespaceLocked(state *graphicsOutputState) *graphicsNamespaceQuarantine {
244+ if d == nil || state == nil || state.namespaceBase == 0 || state.namespaceBase%graphicsIDNamespaceSize != 1 {
245+ return nil
246+ }
247+ block := (state.namespaceBase - 1) / graphicsIDNamespaceSize
248+ if d.graphicsNamespaces == nil {
249+ d.graphicsNamespaces = make(map[uint64]struct{})
250+ }
251+ if d.graphicsNamespaceFences == nil {
252+ d.graphicsNamespaceFences = make(map[uint64]uint64)
253+ }
254+ if d.graphicsNamespaceQuarantines == nil {
255+ d.graphicsNamespaceQuarantines = make(map[uint64]*graphicsNamespaceQuarantine)
256+ }
257+ if _, exists := d.graphicsNamespaceQuarantines[block]; exists {
258+ return nil
259+ }
260+ d.graphicsNamespaces[block] = struct{}{}
261+ d.graphicsNamespaceFences[block] = state.namespaceFence
262+ q := &graphicsNamespaceQuarantine{base: state.namespaceBase, fence: state.namespaceFence}
263+ d.graphicsNamespaceQuarantines[block] = q
264+ return q
265+}
266+
267+func cloneGraphicsOutputState(in *graphicsOutputState) graphicsOutputState {
268+ out := graphicsOutputState{
269+ assets: make(map[string]graphicsOutputAsset),
270+ placements: make(map[string]graphicsOutputPlacement),
271+ pendingImages: make(map[uint64]struct{}),
272+ pendingPlaces: make(map[uint64]struct{}),
273+ nextID: 1,
274+ }
275+ if in == nil {
276+ return out
277+ }
278+ out.nextID, out.namespaceBase = in.nextID, in.namespaceBase
279+ out.namespaceFence = in.namespaceFence
280+ out.mayHaveEmitted = in.mayHaveEmitted
281+ out.transform, out.transformSet = in.transform, in.transformSet
282+ for key, asset := range in.assets {
283+ out.assets[key] = asset
284+ }
285+ for key, placement := range in.placements {
286+ out.placements[key] = placement
287+ }
288+ for id := range in.pendingImages {
289+ out.pendingImages[id] = struct{}{}
290+ }
291+ for id := range in.pendingPlaces {
292+ out.pendingPlaces[id] = struct{}{}
293+ }
294+ return out
295+}
296+
297+// prepare speculatively computes the records needed to make an outer Kitty
298+// terminal match snapshot. The maps are copied and become visible only when
299+// commit is called after the enclosing Output record has been sent.
300+func (s *graphicsOutputState) prepare(snapshot *graphics.Snapshot, reset bool) (*preparedGraphicsOutput, error) {
301+ return s.prepareWithTransform(snapshot, reset, graphicsOutputTransform{})
302+}
303+
304+func (s *graphicsOutputState) prepareWithTransform(snapshot *graphics.Snapshot, reset bool, transform graphicsOutputTransform, viewports ...graphics.Viewport) (*preparedGraphicsOutput, error) {
305+ assets, placements := graphicsSnapshotRecords(snapshot, viewports...)
306+ return s.prepareWithRecords(assets, placements, reset, transform)
307+}
308+
309+// prepareWithRecords is the same speculative state transition as the Phase 5
310+// single-scene path, but accepts a composed set of immutable pane records. It
311+// intentionally does not write to any graphics.Scene.
312+func (s *graphicsOutputState) prepareWithRecords(assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, reset bool, transform graphicsOutputTransform) (*preparedGraphicsOutput, error) {
313+ return s.prepareWithRecordsLimit(assets, placements, reset, transform, maxGraphicsOutputBytes)
314+}
315+
316+func (s *graphicsOutputState) prepareWithRecordsLimit(assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, reset bool, transform graphicsOutputTransform, maxBytes int) (*preparedGraphicsOutput, error) {
317+ maxBytes = max(maxBytes, 0)
318+ prepared, err := s.prepareWithRecordsOnce(assets, placements, reset, transform, false, maxBytes)
319+ if !errors.Is(err, errGraphicsIDExhausted) {
320+ return prepared, err
321+ }
322+ // A bounded attachment can safely recycle its own IDs only after deleting
323+ // the objects it owns. Keep this retry attachment-local: it never advances
324+ // into the next reserved namespace block.
325+ return s.prepareWithRecordsOnce(assets, placements, true, transform, true, maxBytes)
326+}
327+
328+func (s *graphicsOutputState) prepareWithRecordsOnce(assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, reset bool, transform graphicsOutputTransform, resetIDs bool, maxBytes int) (*preparedGraphicsOutput, error) {
329+ if s == nil {
330+ return nil, nil
331+ }
332+ candidate := cloneGraphicsOutputState(s)
333+ transformChanged := !s.transformSet || s.transform != transform
334+ replay := reset || transformChanged
335+ candidate.transform, candidate.transformSet = transform, true
336+ out := &preparedGraphicsOutput{owner: s, state: candidate, maxBytes: maxBytes, valid: true}
337+ if replay {
338+ for _, id := range sortedUint64Keys(s.pendingPlaces) {
339+ if err := out.record(deletePlacementRecord(id)); err != nil {
340+ return nil, err
341+ }
342+ }
343+ for _, id := range sortedUint64Keys(s.pendingImages) {
344+ if err := out.record(deleteImageRecord(id)); err != nil {
345+ return nil, err
346+ }
347+ }
348+ for _, placement := range sortedOutputPlacements(s.placements) {
349+ if err := out.record(deletePlacementRecord(placement.id)); err != nil {
350+ return nil, err
351+ }
352+ }
353+ for _, asset := range sortedOutputAssets(s.assets) {
354+ if err := out.record(deleteImageRecord(asset.id)); err != nil {
355+ return nil, err
356+ }
357+ }
358+ // Keep attachment-owned IDs stable across a rebase. The old remote
359+ // objects are deleted first, then the same IDs are uploaded and placed
360+ // again in the replay records below.
361+ candidate.pendingPlaces = make(map[uint64]struct{})
362+ candidate.pendingImages = make(map[uint64]struct{})
363+ if resetIDs {
364+ candidate.assets = make(map[string]graphicsOutputAsset)
365+ candidate.placements = make(map[string]graphicsOutputPlacement)
366+ candidate.nextID = candidate.namespaceBase
367+ }
368+ }
369+
370+ for key, placement := range candidate.placements {
371+ if _, ok := placements[key]; ok {
372+ continue
373+ }
374+ if err := out.record(deletePlacementRecord(placement.id)); err != nil {
375+ return nil, err
376+ }
377+ delete(candidate.placements, key)
378+ }
379+ for key, asset := range candidate.assets {
380+ if _, ok := assets[key]; ok {
381+ continue
382+ }
383+ if err := out.record(deleteImageRecord(asset.id)); err != nil {
384+ return nil, err
385+ }
386+ delete(candidate.assets, key)
387+ }
388+
389+ assetKeys := sortedAssetKeys(assets)
390+ for _, key := range assetKeys {
391+ if _, ok := candidate.assets[key]; ok && !replay {
392+ continue
393+ }
394+ asset, exists := candidate.assets[key]
395+ id := asset.id
396+ if !exists {
397+ var ok bool
398+ id, ok = takeGraphicsIDInNamespace(&candidate.nextID, candidate.namespaceBase)
399+ if !ok {
400+ return nil, errGraphicsIDExhausted
401+ }
402+ candidate.assets[key] = graphicsOutputAsset{id: id}
403+ }
404+ if err := out.record(uploadRecord(id, assets[key])); err != nil {
405+ return nil, err
406+ }
407+ }
408+
409+ placementKeys := sortedPlacementKeys(placements)
410+ for _, key := range placementKeys {
411+ want := placements[key]
412+ got, exists := candidate.placements[key]
413+ if exists && equalGraphicsPlacement(got, want) && !replay {
414+ continue
415+ }
416+ if exists {
417+ if !replay {
418+ if err := out.record(deletePlacementRecord(got.id)); err != nil {
419+ return nil, err
420+ }
421+ }
422+ want.id = got.id
423+ } else {
424+ id, ok := takeGraphicsIDInNamespace(&candidate.nextID, candidate.namespaceBase)
425+ if !ok {
426+ return nil, errGraphicsIDExhausted
427+ }
428+ want.id = id
429+ }
430+ asset, ok := candidate.assets[want.asset]
431+ if !ok {
432+ // A malformed scene cannot make the outer terminal emit a dangling
433+ // placement. The VT graphics adapter normally prevents this state.
434+ delete(candidate.placements, key)
435+ continue
436+ }
437+ candidate.placements[key] = want
438+ placementTransform := transform
439+ if want.transformSet {
440+ placementTransform = want.transform
441+ }
442+ if err := out.record(placementRecordWithTransform(asset.id, want, placementTransform)); err != nil {
443+ return nil, err
444+ }
445+ }
446+ // candidate.nextID and any maps replaced during namespace recovery changed
447+ // after out was initialized; publish the complete speculative value before
448+ // commit copies it back to the attachment owner.
449+ out.state = candidate
450+ if len(out.data) == 0 && !reset {
451+ out.data = nil
452+ }
453+ return out, nil
454+}
455+
456+func (p *preparedGraphicsOutput) record(record []byte) error {
457+ if len(record) > maxGraphicsRecordBytes || len(record) > p.maxBytes || len(p.data) > p.maxBytes-len(record) {
458+ return errGraphicsOutputTooLarge
459+ }
460+ p.data = append(p.data, record...)
461+ return nil
462+}
463+
464+func (p *preparedGraphicsOutput) commit() {
465+ if p == nil || !p.valid || p.owner == nil {
466+ return
467+ }
468+ *p.owner = p.state
469+ p.valid = false
470+}
471+
472+func (p *preparedGraphicsOutput) markSendAttempted() {
473+ if p != nil && p.valid && p.owner != nil && len(p.data) > 0 {
474+ p.sendAttempted = true
475+ p.owner.mayHaveEmitted = true
476+ p.state.mayHaveEmitted = true
477+ }
478+}
479+
480+func (p *preparedGraphicsOutput) abort() {
481+ if p == nil || !p.valid || p.owner == nil {
482+ return
483+ }
484+ // A preparation can be abandoned before the enclosing Output frame ever
485+ // reaches the transport (for example when ANSI preparation fails). Such IDs
486+ // were never emitted and must not become cleanup records. Once the transport
487+ // was actually called, its result is ambiguous: the terminal may have
488+ // consumed a prefix, so retain those speculative IDs for the next reset.
489+ if p.sendAttempted {
490+ for key, asset := range p.state.assets {
491+ if committed, exists := p.owner.assets[key]; !exists || committed.id != asset.id {
492+ p.owner.pendingImages[asset.id] = struct{}{}
493+ }
494+ }
495+ for key, placement := range p.state.placements {
496+ if committed, exists := p.owner.placements[key]; !exists || committed.id != placement.id {
497+ p.owner.pendingPlaces[placement.id] = struct{}{}
498+ }
499+ }
500+ }
501+ p.valid = false
502+}
503+
504+func graphicsSnapshotRecords(snapshot *graphics.Snapshot, viewports ...graphics.Viewport) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
505+ return graphicsSnapshotRecordsForSource(snapshot, "", viewports...)
506+}
507+
508+// graphicsSnapshotRecordsForSource projects one immutable pane scene into
509+// attachment-local records. source is part of every key because a child VT
510+// scene is allowed to issue the same raw a1/p1 IDs as every other pane.
511+// viewports are a deterministic union of visible regions; this is used to
512+// subtract a floating pane from tiled content without mutating the scene.
513+func graphicsSnapshotRecordsForSource(snapshot *graphics.Snapshot, source string, viewports ...graphics.Viewport) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
514+ return graphicsSnapshotRecordsForSourceOrdered(snapshot, source, 0, viewports...)
515+}
516+
517+func graphicsSnapshotRecordsForSourceOrdered(snapshot *graphics.Snapshot, source string, paneOrder int, viewports ...graphics.Viewport) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
518+ assets := make(map[string]graphics.AssetView)
519+ placements := make(map[string]graphicsOutputPlacement)
520+ if snapshot == nil {
521+ return assets, placements
522+ }
523+ assetKeys := make(map[string]string)
524+ generation := snapshot.Generation()
525+ for _, asset := range snapshot.Assets() {
526+ key := graphicsAssetKeyForSource(source, generation, asset)
527+ assets[key] = asset
528+ assetKeys[asset.ID().String()] = key
529+ }
530+
531+ placementViews := snapshot.Placements()
532+ if len(viewports) == 0 {
533+ for order, placement := range placementViews {
534+ addGraphicsOutputPlacementForSourceOrdered(placements, source, paneOrder, order, generation, assetKeys, placement.ID().String(), placement.AssetID().String(), placement.Source(), placement.Destination(), placement.Cells(), placement.Layer())
535+ }
536+ return assets, placements
537+ }
538+
539+ byPlacementID := make(map[string]graphics.PlacementView, len(placementViews))
540+ placementOrders := make(map[string]int, len(placementViews))
541+ for order, placement := range placementViews {
542+ id := placement.ID().String()
543+ byPlacementID[id] = placement
544+ placementOrders[id] = order
545+ }
546+ for _, viewport := range viewports {
547+ cellFragments := make(map[string]graphics.VisibleFragment)
548+ if viewport.Cells.Valid() && !viewport.Cells.Empty() {
549+ for _, fragment := range snapshot.VisibleFragments(viewport) {
550+ cellFragments[fragment.PlacementID.String()] = fragment
551+ }
552+ }
553+ if viewport.Pixels.Valid() && !viewport.Pixels.Empty() {
554+ for _, fragment := range snapshot.VisiblePixelFragments(viewport.Pixels) {
555+ placement, ok := byPlacementID[fragment.PlacementID.String()]
556+ if !ok {
557+ continue
558+ }
559+ if placement.Cells().Valid() && !placement.Cells().Empty() {
560+ fragment, ok = cellFragments[fragment.PlacementID.String()]
561+ if !ok {
562+ continue
563+ }
564+ }
565+ order := placementOrders[fragment.PlacementID.String()]
566+ addGraphicsOutputPlacementForSourceOrdered(placements, source, paneOrder, order, generation, assetKeys, fragment.PlacementID.String(), fragment.AssetID.String(), fragment.Source, fragment.Destination, fragment.Cells, placement.Layer())
567+ }
568+ continue
569+ }
570+ // A cell-only viewport is useful to callers that do not know pixel
571+ // geometry. Cell-aware placements already carry enough information for
572+ // the graphics package to map the clipped destination back to pixels.
573+ for _, fragment := range cellFragments {
574+ placement, ok := byPlacementID[fragment.PlacementID.String()]
575+ if !ok {
576+ continue
577+ }
578+ order := placementOrders[fragment.PlacementID.String()]
579+ addGraphicsOutputPlacementForSourceOrdered(placements, source, paneOrder, order, generation, assetKeys, fragment.PlacementID.String(), fragment.AssetID.String(), fragment.Source, fragment.Destination, fragment.Cells, placement.Layer())
580+ }
581+ }
582+ // A viewport is an attachment-local visibility boundary. Do not upload
583+ // assets that have no visible placement in that boundary: a pane can retain
584+ // decoded Kitty images after its last placement was erased, but those image
585+ // records must not leak through a covered or collapsed projection.
586+ if len(viewports) != 0 {
587+ usedAssets := make(map[string]struct{}, len(placements))
588+ for _, placement := range placements {
589+ usedAssets[placement.asset] = struct{}{}
590+ }
591+ for key := range assets {
592+ if _, ok := usedAssets[key]; !ok {
593+ delete(assets, key)
594+ }
595+ }
596+ }
597+ return assets, placements
598+}
599+
600+func addGraphicsOutputPlacement(dst map[string]graphicsOutputPlacement, generation uint64, assetKeys map[string]string, placementID, assetID string, source, destination graphics.PixelRect, cells graphics.CellRect, layer int64) {
601+ addGraphicsOutputPlacementForSource(dst, "", 0, generation, assetKeys, placementID, assetID, source, destination, cells, layer)
602+}
603+
604+func addGraphicsOutputPlacementForSource(dst map[string]graphicsOutputPlacement, source string, order int, generation uint64, assetKeys map[string]string, placementID, assetID string, sourceRect, destination graphics.PixelRect, cells graphics.CellRect, layer int64) {
605+ addGraphicsOutputPlacementForSourceOrdered(dst, source, 0, order, generation, assetKeys, placementID, assetID, sourceRect, destination, cells, layer)
606+}
607+
608+func addGraphicsOutputPlacementForSourceOrdered(dst map[string]graphicsOutputPlacement, source string, paneOrder, order int, generation uint64, assetKeys map[string]string, placementID, assetID string, sourceRect, destination graphics.PixelRect, cells graphics.CellRect, layer int64) {
609+ assetKey := assetKeys[assetID]
610+ if assetKey == "" {
611+ return
612+ }
613+ key := graphicsPlacementKeyPartsForSourceOrdered(source, paneOrder, order, generation, placementID, assetKey, sourceRect, destination, cells, layer)
614+ dst[key] = graphicsOutputPlacement{asset: assetKey, source: sourceRect, dest: destination, cells: cells, layer: layer}
615+}
616+
617+func graphicsAssetKey(generation uint64, asset graphics.AssetView) string {
618+ return graphicsAssetKeyForSource("", generation, asset)
619+}
620+
621+func graphicsAssetKeyForSource(source string, generation uint64, asset graphics.AssetView) string {
622+ digest := sha256.Sum256(asset.Encoded())
623+ if source == "" {
624+ return fmt.Sprintf("g%d:%s:%d:%d:%x", generation, asset.ID().String(), asset.Width(), asset.Height(), digest)
625+ }
626+ return fmt.Sprintf("s:%s:g%d:%s:%d:%d:%x", source, generation, asset.ID().String(), asset.Width(), asset.Height(), digest)
627+}
628+
629+func graphicsPlacementKey(generation uint64, placement graphics.PlacementView, assetKey string) string {
630+ return graphicsPlacementKeyParts(generation, placement.ID().String(), assetKey, placement.Source(), placement.Destination(), placement.Cells(), placement.Layer())
631+}
632+
633+func graphicsPlacementKeyParts(generation uint64, placementID, assetKey string, source, dest graphics.PixelRect, cells graphics.CellRect, layer int64) string {
634+ return graphicsPlacementKeyPartsForSource("", 0, generation, placementID, assetKey, source, dest, cells, layer)
635+}
636+
637+func graphicsPlacementKeyPartsForSource(source string, order int, generation uint64, placementID, assetKey string, sourceRect, dest graphics.PixelRect, cells graphics.CellRect, layer int64) string {
638+ return graphicsPlacementKeyPartsForSourceOrdered(source, 0, order, generation, placementID, assetKey, sourceRect, dest, cells, layer)
639+}
640+
641+func graphicsPlacementKeyPartsForSourceOrdered(source string, paneOrder, order int, generation uint64, placementID, assetKey string, sourceRect, dest graphics.PixelRect, cells graphics.CellRect, layer int64) string {
642+ if source == "" {
643+ return fmt.Sprintf("g%d:%s:%s:%v:%v:%v:%d", generation, placementID, assetKey, sourceRect, dest, cells, layer)
644+ }
645+ // Put the fixed-width pane/scene order first so map iteration is irrelevant:
646+ // placements are replayed in layout/scene order, while the remaining fields
647+ // still distinguish clipped fragments and scene generations. The source
648+ // identity itself deliberately excludes order so moving a pane can retain
649+ // its attachment-local uploaded asset and only replace its placement.
650+ return fmt.Sprintf("o%08d:%08d:s:%s:g%d:%s:%s:%v:%v:%v:%d", paneOrder, order, source, generation, placementID, assetKey, sourceRect, dest, cells, layer)
651+}
652+
653+func sortedOutputAssets(values map[string]graphicsOutputAsset) []graphicsOutputAsset {
654+ out := make([]graphicsOutputAsset, 0, len(values))
655+ for _, value := range values {
656+ out = append(out, value)
657+ }
658+ sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
659+ return out
660+}
661+
662+func sortedOutputPlacements(values map[string]graphicsOutputPlacement) []graphicsOutputPlacement {
663+ out := make([]graphicsOutputPlacement, 0, len(values))
664+ for _, value := range values {
665+ out = append(out, value)
666+ }
667+ sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
668+ return out
669+}
670+
671+func sortedAssetKeys(values map[string]graphics.AssetView) []string {
672+ keys := make([]string, 0, len(values))
673+ for key := range values {
674+ keys = append(keys, key)
675+ }
676+ sort.Strings(keys)
677+ return keys
678+}
679+
680+func sortedPlacementKeys(values map[string]graphicsOutputPlacement) []string {
681+ keys := make([]string, 0, len(values))
682+ for key := range values {
683+ keys = append(keys, key)
684+ }
685+ sort.Strings(keys)
686+ return keys
687+}
688+
689+func sortedUint64Keys(values map[uint64]struct{}) []uint64 {
690+ keys := make([]uint64, 0, len(values))
691+ for key := range values {
692+ keys = append(keys, key)
693+ }
694+ sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
695+ return keys
696+}
697+
698+func takeGraphicsID(next *uint64) (uint64, bool) {
699+ if next == nil || *next == 0 || *next > maxKittyGraphicsID {
700+ return 0, false
701+ }
702+ base := ((*next - 1) / graphicsIDNamespaceSize) * graphicsIDNamespaceSize
703+ return takeGraphicsIDInNamespace(next, base+1)
704+}
705+
706+func takeGraphicsIDInNamespace(next *uint64, namespaceBase uint64) (uint64, bool) {
707+ if next == nil || namespaceBase == 0 || (namespaceBase-1)%graphicsIDNamespaceSize != 0 {
708+ return 0, false
709+ }
710+ // IDs are derived from the attachment's reserved block, never from the
711+ // global Kitty ceiling. Exhaustion marks the cursor unusable; it must never
712+ // wrap or consume the first ID reserved for another attachment.
713+ limit := namespaceBase + graphicsIDNamespaceSize
714+ if limit < namespaceBase || limit > maxKittyGraphicsID+1 || *next < namespaceBase || *next >= limit {
715+ return 0, false
716+ }
717+ id := *next
718+ if id+1 >= limit {
719+ *next = 0
720+ } else {
721+ (*next)++
722+ }
723+ return id, true
724+}
725+
726+func equalGraphicsPlacement(a, b graphicsOutputPlacement) bool {
727+ return a.asset == b.asset && a.source == b.source && a.dest == b.dest && a.cells == b.cells && a.layer == b.layer && a.transformSet == b.transformSet && (!a.transformSet || a.transform == b.transform)
728+}
729+
730+func kittyRecord(header string, payload []byte) []byte {
731+ // q=2 asks Kitty to suppress protocol responses. Outer terminal replies
732+ // would otherwise be interpreted as pane input or pollute the attachment's
733+ // byte-only transport.
734+ if header != "" {
735+ header += ",q=2"
736+ }
737+ out := make([]byte, 0, len(header)+len(payload)+8)
738+ out = append(out, "\x1b_G"...)
739+ out = append(out, header...)
740+ out = append(out, ';')
741+ out = append(out, payload...)
742+ out = append(out, "\x1b\\"...)
743+ return out
744+}
745+
746+func deletePlacementRecord(id uint64) []byte {
747+ return kittyRecord("a=d,d=p,p="+strconv.FormatUint(id, 10), nil)
748+}
749+
750+func deleteImageRecord(id uint64) []byte {
751+ return kittyRecord("a=d,d=i,i="+strconv.FormatUint(id, 10), nil)
752+}
753+
754+func uploadRecord(id uint64, asset graphics.AssetView) []byte {
755+ data := asset.Encoded()
756+ format := "32"
757+ switch asset.Format() {
758+ case graphics.AssetFormatRGB:
759+ format = "24"
760+ case graphics.AssetFormatPNG:
761+ format = "100"
762+ case graphics.AssetFormatRGBA, graphics.AssetFormatUnknown:
763+ format = "32"
764+ }
765+ header := "a=t,i=" + strconv.FormatUint(id, 10) + ",f=" + format +
766+ ",s=" + strconv.FormatInt(asset.Width(), 10) + ",v=" + strconv.FormatInt(asset.Height(), 10)
767+ if format != "100" && len(data) > 2048 {
768+ var compressed bytes.Buffer
769+ zw := zlib.NewWriter(&compressed)
770+ _, writeErr := zw.Write(data)
771+ closeErr := zw.Close()
772+ if writeErr == nil && closeErr == nil && compressed.Len() < len(data) {
773+ data = compressed.Bytes()
774+ header += ",o=z"
775+ }
776+ }
777+ payload := base64.RawStdEncoding.EncodeToString(data)
778+ if len(payload) <= graphicsUploadChunkBytes {
779+ return kittyRecord(header, []byte(payload))
780+ }
781+ out := make([]byte, 0, len(payload)+len(payload)/graphicsUploadChunkBytes*32)
782+ first := true
783+ for len(payload) != 0 {
784+ n := min(len(payload), graphicsUploadChunkBytes)
785+ chunk := payload[:n]
786+ payload = payload[n:]
787+ chunkHeader := "a=t"
788+ if first {
789+ chunkHeader = header
790+ first = false
791+ }
792+ if len(payload) != 0 {
793+ chunkHeader += ",m=1"
794+ } else {
795+ chunkHeader += ",m=0"
796+ }
797+ out = append(out, kittyRecord(chunkHeader, []byte(chunk))...)
798+ }
799+ return out
800+}
801+
802+func placementRecord(assetID uint64, placement graphicsOutputPlacement) []byte {
803+ return placementRecordWithTransform(assetID, placement, graphicsOutputTransform{})
804+}
805+
806+func placementRecordWithTransform(assetID uint64, placement graphicsOutputPlacement, transform graphicsOutputTransform) []byte {
807+ column, row, offsetX, offsetY, cells := transformedGraphicsPlacement(placement, transform)
808+ header := "a=p,i=" + strconv.FormatUint(assetID, 10) + ",p=" + strconv.FormatUint(placement.id, 10)
809+ if placement.source.X != 0 {
810+ header += ",x=" + strconv.FormatInt(placement.source.X, 10)
811+ }
812+ if placement.source.Y != 0 {
813+ header += ",y=" + strconv.FormatInt(placement.source.Y, 10)
814+ }
815+ header += ",w=" + strconv.FormatInt(placement.source.Width, 10) + ",h=" + strconv.FormatInt(placement.source.Height, 10)
816+ if offsetX != 0 {
817+ header += ",X=" + strconv.FormatInt(offsetX, 10)
818+ }
819+ if offsetY != 0 {
820+ header += ",Y=" + strconv.FormatInt(offsetY, 10)
821+ }
822+ if cells.Valid() && !cells.Empty() {
823+ header += ",c=" + strconv.FormatInt(cells.Width, 10) + ",r=" + strconv.FormatInt(cells.Height, 10)
824+ if cells.X != 0 {
825+ header += ",H=" + strconv.FormatInt(cells.X, 10)
826+ }
827+ if cells.Y != 0 {
828+ header += ",V=" + strconv.FormatInt(cells.Y, 10)
829+ }
830+ }
831+ if placement.layer != 0 {
832+ header += ",z=" + strconv.FormatInt(placement.layer, 10)
833+ }
834+ // The relayed placement must not advance or scroll the outer text cursor.
835+ header += ",C=1"
836+ record := kittyRecord(header, nil)
837+ if column < 0 || row < 0 {
838+ return record
839+ }
840+ out := make([]byte, 0, len(record)+32)
841+ out = append(out, "\x1b7\x1b["...)
842+ out = strconv.AppendInt(out, row+1, 10)
843+ out = append(out, ';')
844+ out = strconv.AppendInt(out, column+1, 10)
845+ out = append(out, 'H')
846+ out = append(out, record...)
847+ out = append(out, "\x1b8"...)
848+ return out
849+}
850+
851+// transformedGraphicsPlacement converts pane-local destination pixels into an
852+// outer cursor cell plus Kitty X/Y sub-cell pixel offsets. Lowercase x/y are
853+// source-crop controls and are therefore handled separately by the encoder.
854+func transformedGraphicsPlacement(placement graphicsOutputPlacement, transform graphicsOutputTransform) (column, row, offsetX, offsetY int64, cells graphics.CellRect) {
855+ column, row = placement.dest.X, placement.dest.Y
856+ cells = placement.cells
857+ if transform.sourceGeometry.PixelsKnown() && transform.sourceGeometry.Cols > 0 && transform.sourceGeometry.Rows > 0 {
858+ cellWidth := transform.sourceGeometry.PixelWidth / transform.sourceGeometry.Cols
859+ cellHeight := transform.sourceGeometry.PixelHeight / transform.sourceGeometry.Rows
860+ if cellWidth > 0 && cellHeight > 0 && column >= 0 && row >= 0 {
861+ offsetX = column % int64(cellWidth)
862+ offsetY = row % int64(cellHeight)
863+ column /= int64(cellWidth)
864+ row /= int64(cellHeight)
865+ }
866+ }
867+ if column >= 0 {
868+ column += int64(transform.originX)
869+ }
870+ if row >= 0 {
871+ row += int64(transform.originY)
872+ }
873+ return column, row, offsetX, offsetY, cells
874+}
875+
876+func (s *graphicsOutputState) cleanup() {
877+ if s == nil {
878+ return
879+ }
880+ s.assets = make(map[string]graphicsOutputAsset)
881+ s.placements = make(map[string]graphicsOutputPlacement)
882+ s.pendingImages = make(map[uint64]struct{})
883+ s.pendingPlaces = make(map[uint64]struct{})
884+ s.transform = graphicsOutputTransform{}
885+ s.transformSet = false
886+}
887+
888+func graphicsOutputViewport(content domain.Rect, geometry domain.Geometry) (graphics.Viewport, bool) {
889+ return graphicsOutputViewportRect(domain.Rect{Width: content.Width, Height: content.Height}, geometry)
890+}
891+
892+func graphicsOutputViewportRect(content domain.Rect, geometry domain.Geometry) (graphics.Viewport, bool) {
893+ if content.Width <= 0 || content.Height <= 0 || content.X < 0 || content.Y < 0 {
894+ return graphics.Viewport{}, false
895+ }
896+ cells := graphics.CellRect{X: int64(content.X), Y: int64(content.Y), Width: int64(content.Width), Height: int64(content.Height)}
897+ if !geometry.PixelsKnown() || geometry.Cols <= 0 || geometry.Rows <= 0 {
898+ // Cell dimensions are not pixel dimensions. When the controlling
899+ // terminal did not report pixels, keep clipping in cell space so a
900+ // pixel-space placement without cell metadata is suppressed by the
901+ // graphics scene rather than clipped to a made-up pixel rectangle.
902+ return graphics.Viewport{Cells: cells}, true
903+ }
904+ cellWidth := geometry.PixelWidth / geometry.Cols
905+ cellHeight := geometry.PixelHeight / geometry.Rows
906+ if cellWidth <= 0 || cellHeight <= 0 {
907+ return graphics.Viewport{Cells: cells}, true
908+ }
909+ return graphics.Viewport{
910+ Pixels: graphics.PixelRect{
911+ X: int64(content.X) * int64(cellWidth),
912+ Y: int64(content.Y) * int64(cellHeight),
913+ Width: int64(content.Width) * int64(cellWidth),
914+ Height: int64(content.Height) * int64(cellHeight),
915+ },
916+ Cells: cells,
917+ }, true
918+}
919+
920+func graphicsOutputNamespaceMustQuarantine(state *graphicsOutputState) bool {
921+ return state != nil && (state.mayHaveEmitted || len(state.assets) != 0 || len(state.placements) != 0 || len(state.pendingImages) != 0 || len(state.pendingPlaces) != 0)
922+}
923+
924+func (d *Daemon) discardAttachmentOutput(ac *attachedClient) {
925+ if d == nil || ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
926+ return
927+ }
928+ d.mu.Lock()
929+ d.retireGraphicsOutputLocked(ac, ac.output.graphicsOutput)
930+ d.mu.Unlock()
931+}
932+
933+func (d *Daemon) retireGraphicsOutput(ac *attachedClient, state *graphicsOutputState) {
934+ if d == nil || state == nil {
935+ return
936+ }
937+ d.mu.Lock()
938+ d.retireGraphicsOutputLocked(ac, state)
939+ d.mu.Unlock()
940+}
941+
942+// retireGraphicsOutputLocked fences exactly state, not whatever graphics state
943+// a later attachment may have installed on the same client object. Any lease
944+// that may have crossed the transport boundary stays quarantined for the daemon
945+// lifetime; only a provably unused reservation is released. The caller holds
946+// d.mu.
947+func (d *Daemon) retireGraphicsOutputLocked(ac *attachedClient, state *graphicsOutputState) *graphicsNamespaceQuarantine {
948+ if d == nil || state == nil {
949+ return nil
950+ }
951+ if ac != nil && ac.output != nil && ac.output.graphicsOutput == state {
952+ ac.output.graphicsOutput = nil
953+ }
954+ quarantine := graphicsOutputNamespaceMustQuarantine(state)
955+ state.cleanup()
956+ if !quarantine {
957+ d.releaseGraphicsNamespaceLeaseLocked(state)
958+ return nil
959+ }
960+ return d.quarantineGraphicsNamespaceLocked(state)
961+}
962+
963+// discardAttachmentOutputLocked is used by lifecycle publication paths that
964+// already hold d.mu. Since those paths cannot establish terminal receipt, any
965+// namespace that may have emitted remains quarantined.
966+func (d *Daemon) discardAttachmentOutputLocked(ac *attachedClient) {
967+ if d == nil || ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
968+ return
969+ }
970+ d.retireGraphicsOutputLocked(ac, ac.output.graphicsOutput)
971+}
972+
973+// cleanupAttachmentOutput removes terminal objects owned by a closing
974+// attachment before its transport is closed. Parked resumable attachments
975+// intentionally do not call this: their state remains attached to the parked
976+// link and the next epoch deletes and replays it as needed. Even a successful
977+// cleanup socket send cannot release an exposed namespace because side-effect
978+// Output frames are not terminal-ACKed.
979+func (d *Daemon) cleanupAttachmentOutput(ac *attachedClient) error {
980+ if d == nil || ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
981+ return nil
982+ }
983+ // Keep preparation, the side-effect frame, and retirement under one
984+ // attachment send fence. A concurrent paint must not commit a newer scene
985+ // between the delete preparation and the handoff control frame.
986+ ac.sendMu.Lock()
987+ defer ac.sendMu.Unlock()
988+ state := ac.output.graphicsOutput
989+ if state == nil {
990+ return nil
991+ }
992+ prepared, err := state.prepare(nil, true)
993+ if err != nil || prepared == nil {
994+ d.retireGraphicsOutput(ac, state)
995+ return err
996+ }
997+ if len(prepared.data) == 0 {
998+ prepared.commit()
999+ d.retireGraphicsOutput(ac, state)
1000+ return nil
1001+ }
1002+ expected := ac.transportSnapshot()
1003+ if expected.transport == nil {
1004+ prepared.markSendAttempted()
1005+ prepared.abort()
1006+ d.retireGraphicsOutput(ac, state)
1007+ return errors.New("client transport is nil")
1008+ }
1009+ ac.output.lockView()
1010+ frame, frameErr := ac.output.sideEffectLocked(prepared.data, ac.echoAck.Load())
1011+ if frameErr != nil {
1012+ ac.output.unlockView()
1013+ prepared.abort()
1014+ d.retireGraphicsOutput(ac, state)
1015+ return frameErr
1016+ }
1017+ prepared.markSendAttempted()
1018+ var sendErr error
1019+ if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
1020+ sendErr = owned.SendSynchronous(frame)
1021+ } else {
1022+ _, sendErr = d.boundedSendWith(expected.transport, func() error {
1023+ if !ac.transportSnapshotCurrent(expected) {
1024+ return errTransportReplaced
1025+ }
1026+ return expected.transport.Send(frame)
1027+ })
1028+ if errors.Is(sendErr, errSendTimedOut) {
1029+ _ = ac.closeCapturedTransport(expected.transport)
1030+ }
1031+ }
1032+ ac.output.unlockView()
1033+ if sendErr == nil {
1034+ prepared.commit()
1035+ } else {
1036+ prepared.abort()
1037+ }
1038+ d.retireGraphicsOutput(ac, state)
1039+ return sendErr
1040+}
1041+
1042+func (d *Daemon) disableGraphicsOutput(ac *attachedClient) {
1043+ if ac == nil || ac.output == nil || ac.output.graphicsOutput == nil {
1044+ return
1045+ }
1046+ ac.terminalCapabilities.KittyGraphics = false
1047+ state := ac.output.graphicsOutput
1048+ if d == nil {
1049+ ac.output.graphicsOutput = nil
1050+ state.cleanup()
1051+ return
1052+ }
1053+ d.retireGraphicsOutput(ac, state)
1054+}
1055+
1056+func graphicsOutputData(state *capturedRenderState, ac *attachedClient, reset bool) (*preparedGraphicsOutput, error) {
1057+ return graphicsOutputDataWithDaemon(nil, state, ac, reset)
1058+}
1059+
1060+// composedGraphicsRecords is the pane-layout projection boundary. It reads
1061+// only immutable captured snapshots and produces attachment-local keys and
1062+// placements. In particular, it never joins or mutates the generic VT scenes.
1063+func composedGraphicsRecords(state *capturedRenderState) (map[string]graphics.AssetView, map[string]graphicsOutputPlacement) {
1064+ assets := make(map[string]graphics.AssetView)
1065+ placements := make(map[string]graphicsOutputPlacement)
1066+ if state == nil {
1067+ return assets, placements
1068+ }
1069+ // Kitty placements are emitted after the ANSI frame. Any opaque overlay
1070+ // would therefore be painted over by an old image unless its covered
1071+ // fragments are temporarily removed. Modal overlays suppress the complete
1072+ // scene; compact notices and floating panes contribute exact coverage
1073+ // rectangles without mutating their source panes.
1074+ if state.overlays.active() {
1075+ return assets, placements
1076+ }
1077+ coverage := graphicsOpaqueCoverage(state)
1078+ outerContent, hasOuterContent := graphicsOuterContentRect(state)
1079+
1080+ for index, pane := range state.panes {
1081+ if pane.graphics == nil || pane.placement.Collapsed || pane.placement.Content.Width <= 0 || pane.placement.Content.Height <= 0 {
1082+ continue
1083+ }
1084+ var viewports []graphics.Viewport
1085+ if hasOuterContent {
1086+ viewports = paneGraphicsViewports(pane.placement.Content, pane.graphicsGeometry, coverage, outerContent)
1087+ } else {
1088+ viewports = paneGraphicsViewports(pane.placement.Content, pane.graphicsGeometry, coverage)
1089+ }
1090+ if len(viewports) == 0 {
1091+ continue
1092+ }
1093+ transform := graphicsOutputTransform{originX: pane.placement.Content.X, originY: pane.placement.Content.Y + 1, sourceGeometry: pane.graphicsGeometry}
1094+ source := graphicsSourceKey(state, pane.id, pane.stableID, false)
1095+ paneAssets, panePlacements := graphicsSnapshotRecordsForSourceOrdered(pane.graphics, source, index, viewports...)
1096+ mergeGraphicsRecords(assets, placements, paneAssets, panePlacements, transform)
1097+ }
1098+
1099+ if state.floating.visible && state.floating.pane.graphics != nil && state.floating.geometry.Inner.Width > 0 && state.floating.geometry.Inner.Height > 0 {
1100+ inner := state.floating.geometry.Inner
1101+ var viewports []graphics.Viewport
1102+ if hasOuterContent {
1103+ viewports = paneGraphicsViewports(inner, state.floating.pane.graphicsGeometry, nil, outerContent)
1104+ } else {
1105+ viewports = paneGraphicsViewports(inner, state.floating.pane.graphicsGeometry, nil)
1106+ }
1107+ if len(viewports) != 0 {
1108+ transform := graphicsOutputTransform{originX: inner.X, originY: inner.Y + 1, sourceGeometry: state.floating.pane.graphicsGeometry}
1109+ source := graphicsSourceKey(state, state.floating.pane.id, state.floating.pane.stableID, true)
1110+ paneAssets, panePlacements := graphicsSnapshotRecordsForSourceOrdered(state.floating.pane.graphics, source, len(state.panes), viewports...)
1111+ mergeGraphicsRecords(assets, placements, paneAssets, panePlacements, transform)
1112+ }
1113+ }
1114+ return assets, placements
1115+}
1116+
1117+func graphicsSourceKey(state *capturedRenderState, paneID layout.PaneID, stableID domain.PaneStableID, floating bool) string {
1118+ kind := "pane"
1119+ if floating {
1120+ kind = "floating"
1121+ }
1122+ if stableID == "" {
1123+ stableID = domain.PaneStableID(paneID)
1124+ }
1125+ return fmt.Sprintf("%s:%q:%x:%q:%q:%q", kind, string(state.sessionID), state.incarnation, string(state.view.tabID), string(stableID), string(paneID))
1126+}
1127+
1128+func mergeGraphicsRecords(dstAssets map[string]graphics.AssetView, dstPlacements map[string]graphicsOutputPlacement, assets map[string]graphics.AssetView, placements map[string]graphicsOutputPlacement, transform graphicsOutputTransform) {
1129+ for key, asset := range assets {
1130+ dstAssets[key] = asset
1131+ }
1132+ for key, placement := range placements {
1133+ placement.transform = transform
1134+ placement.transformSet = true
1135+ dstPlacements[key] = placement
1136+ }
1137+}
1138+
1139+// paneGraphicsViewports returns pane content minus each opaque coverage
1140+// rectangle. Each subtraction is ordered top, bottom, left, right, so a union
1141+// of clipped fragments remains deterministic.
1142+func graphicsOpaqueCoverage(state *capturedRenderState) []domain.Rect {
1143+ if state == nil {
1144+ return nil
1145+ }
1146+ coverage := make([]domain.Rect, 0, 1+len(state.overlays.notices))
1147+ if state.floating.visible && state.floating.geometry.Bounds.Width > 0 && state.floating.geometry.Bounds.Height > 0 {
1148+ coverage = append(coverage, state.floating.geometry.Bounds)
1149+ }
1150+ if len(state.overlays.notices) == 0 && state.overlays.noticeOverflow == 0 {
1151+ return coverage
1152+ }
1153+ width, rows := state.layout.area.Width, state.layout.area.Height
1154+ if state.window.Valid() {
1155+ window := contentSize(state.window)
1156+ width, rows = window.Cols, window.Rows
1157+ }
1158+ height := rows + tabChromeRows
1159+ if width <= 0 || height <= 0 {
1160+ for _, pane := range state.panes {
1161+ if pane.placement.Content.X+pane.placement.Content.Width > width {
1162+ width = pane.placement.Content.X + pane.placement.Content.Width
1163+ }
1164+ if pane.placement.Content.Y+pane.placement.Content.Height+tabChromeRows > height {
1165+ height = pane.placement.Content.Y + pane.placement.Content.Height + tabChromeRows
1166+ }
1167+ }
1168+ }
1169+ if width <= 0 || height <= 0 {
1170+ return coverage
1171+ }
1172+ styles := state.styles
1173+ if styles == (themeui.Styles{}) {
1174+ styles = fallbackChromeStyles
1175+ }
1176+ frame := renderer.NewFrame(width, height)
1177+ for _, footprint := range composeCapturedNotices(state.overlays, frame, styles) {
1178+ // Notices are laid out in complete-frame coordinates. The graphics
1179+ // content origin is one row below that frame's top bar.
1180+ footprint.Y--
1181+ coverage = append(coverage, footprint)
1182+ }
1183+ return coverage
1184+}
1185+
1186+func graphicsOuterContentRect(state *capturedRenderState) (domain.Rect, bool) {
1187+ if state == nil {
1188+ return domain.Rect{}, false
1189+ }
1190+ if state.window.Valid() {
1191+ content := contentSize(state.window)
1192+ return domain.Rect{Width: content.Cols, Height: content.Rows}, content.Valid()
1193+ }
1194+ if state.layout.area.Width > 0 && state.layout.area.Height > 0 {
1195+ return domain.Rect{Width: state.layout.area.Width, Height: state.layout.area.Height}, true
1196+ }
1197+ return domain.Rect{}, false
1198+}
1199+
1200+func paneGraphicsViewports(content domain.Rect, geometry domain.Geometry, coverage []domain.Rect, outerContent ...domain.Rect) []graphics.Viewport {
1201+ full := domain.Rect{Width: content.Width, Height: content.Height}
1202+ if content.Width <= 0 || content.Height <= 0 {
1203+ return nil
1204+ }
1205+ regions := []domain.Rect{full}
1206+ if len(outerContent) > 0 {
1207+ visible, ok := intersectGraphicsDomainRect(content, outerContent[0])
1208+ if !ok {
1209+ return nil
1210+ }
1211+ visible.X -= content.X
1212+ visible.Y -= content.Y
1213+ regions[0] = visible
1214+ }
1215+ for _, opaque := range coverage {
1216+ cover, ok := intersectGraphicsDomainRect(content, opaque)
1217+ if !ok {
1218+ continue
1219+ }
1220+ cover.X -= content.X
1221+ cover.Y -= content.Y
1222+ next := make([]domain.Rect, 0, len(regions)*4)
1223+ for _, region := range regions {
1224+ localCover, intersects := intersectGraphicsDomainRect(region, cover)
1225+ if !intersects {
1226+ next = append(next, region)
1227+ continue
1228+ }
1229+ next = append(next, subtractGraphicsDomainRect(region, localCover)...)
1230+ }
1231+ regions = next
1232+ if len(regions) == 0 {
1233+ break
1234+ }
1235+ }
1236+ viewports := make([]graphics.Viewport, 0, len(regions))
1237+ for _, region := range regions {
1238+ if region.Width <= 0 || region.Height <= 0 {
1239+ continue
1240+ }
1241+ viewport, valid := graphicsOutputViewportRect(region, geometry)
1242+ if valid {
1243+ viewports = append(viewports, viewport)
1244+ }
1245+ }
1246+ return viewports
1247+}
1248+
1249+func subtractGraphicsDomainRect(region, cover domain.Rect) []domain.Rect {
1250+ return []domain.Rect{
1251+ {X: region.X, Y: region.Y, Width: region.Width, Height: cover.Y - region.Y},
1252+ {X: region.X, Y: cover.Y + cover.Height, Width: region.Width, Height: region.Y + region.Height - (cover.Y + cover.Height)},
1253+ {X: region.X, Y: cover.Y, Width: cover.X - region.X, Height: cover.Height},
1254+ {X: cover.X + cover.Width, Y: cover.Y, Width: region.X + region.Width - (cover.X + cover.Width), Height: cover.Height},
1255+ }
1256+}
1257+
1258+func intersectGraphicsDomainRect(a, b domain.Rect) (domain.Rect, bool) {
1259+ left, top := a.X, a.Y
1260+ if b.X > left {
1261+ left = b.X
1262+ }
1263+ if b.Y > top {
1264+ top = b.Y
1265+ }
1266+ right, bottom := a.X+a.Width, a.Y+a.Height
1267+ if b.X+b.Width < right {
1268+ right = b.X + b.Width
1269+ }
1270+ if b.Y+b.Height < bottom {
1271+ bottom = b.Y + b.Height
1272+ }
1273+ if right <= left || bottom <= top {
1274+ return domain.Rect{}, false
1275+ }
1276+ return domain.Rect{X: left, Y: top, Width: right - left, Height: bottom - top}, true
1277+}
1278+
1279+func graphicsOutputDataWithDaemon(d *Daemon, state *capturedRenderState, ac *attachedClient, reset bool) (*preparedGraphicsOutput, error) {
1280+ return graphicsOutputDataWithDaemonLimit(d, state, ac, reset, maxGraphicsOutputBytes)
1281+}
1282+
1283+func graphicsOutputDataWithDaemonLimit(d *Daemon, state *capturedRenderState, ac *attachedClient, reset bool, maxBytes int) (*preparedGraphicsOutput, error) {
1284+ if ac == nil || ac.output == nil || ac.output.graphicsOutput == nil || state == nil || !ac.terminalCapabilities.SupportsKittyGraphics() {
1285+ return nil, nil
1286+ }
1287+ assets, placements := composedGraphicsRecords(state)
1288+ prepared, err := ac.output.graphicsOutput.prepareWithRecordsLimit(assets, placements, reset, graphicsOutputTransform{}, maxBytes)
1289+ if errors.Is(err, errGraphicsIDExhausted) {
1290+ d.disableGraphicsOutput(ac)
1291+ return nil, nil
1292+ }
1293+ if !errors.Is(err, errGraphicsOutputTooLarge) {
1294+ return prepared, err
1295+ }
1296+ // Graphics are optional decoration. If an upload exceeds the bounded output
1297+ // budget, suppress it and still return the bounded cleanup for objects that
1298+ // were already proven to be owned by this attachment. ANSI composition is
1299+ // prepared and emitted by the caller independently.
1300+ prepared, err = ac.output.graphicsOutput.prepareWithRecordsLimit(nil, nil, reset, graphicsOutputTransform{}, maxBytes)
1301+ if errors.Is(err, errGraphicsIDExhausted) {
1302+ d.disableGraphicsOutput(ac)
1303+ return nil, nil
1304+ }
1305+ if errors.Is(err, errGraphicsOutputTooLarge) {
1306+ // Preparation is speculative, so the attachment still owns every object
1307+ // that needs deletion. Leave that state intact for a later frame with more
1308+ // graphics budget instead of withholding the already-prepared ANSI frame.
1309+ return nil, nil
1310+ }
1311+ return prepared, err
1312+}
1313+
1314+func graphicsOutputError(err error) error {
1315+ if err == nil {
1316+ return nil
1317+ }
1318+ return fmt.Errorf("kitty graphics output: %w", err)
1319+}
A internal/usecase/daemon/graphics_output_test.go +1007 -0
@@ -0,0 +1,1007 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "io"
6+ "os"
7+ "strconv"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ vt "github.com/bnema/vev-vt"
14+ "github.com/bnema/vev-vt/graphics"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+func attachmentOutputWithGraphics(state *graphicsOutputState) *attachmentOutput {
22+ output := newOutputStateStream()
23+ output.graphicsOutput = state
24+ return output
25+}
26+
27+func kittenGraphicsSnapshot(t *testing.T) *graphics.Snapshot {
28+ t.Helper()
29+ data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
30+ require.NoError(t, err)
31+ screen := vt.NewScreen(80, 24)
32+ for len(data) > 0 {
33+ n := 791
34+ if n > len(data) {
35+ n = len(data)
36+ }
37+ screen.Write(data[:n])
38+ data = data[n:]
39+ }
40+ snapshot := screen.GraphicsSnapshot()
41+ require.NotNil(t, snapshot)
42+ require.Equal(t, uint64(1), snapshot.Usage().Assets)
43+ require.Equal(t, uint64(1), snapshot.Usage().Placements)
44+ return snapshot
45+}
46+
47+func recordIndex(data []byte, record string) int {
48+ return bytes.Index(data, []byte(record))
49+}
50+
51+type lateGraphicsCleanupTransport struct {
52+ started chan struct{}
53+ release chan struct{}
54+ finished chan struct{}
55+ sends chan ports.Frame
56+ startedOnce sync.Once
57+ finishOnce sync.Once
58+}
59+
60+func newLateGraphicsCleanupTransport() *lateGraphicsCleanupTransport {
61+ return &lateGraphicsCleanupTransport{
62+ started: make(chan struct{}),
63+ release: make(chan struct{}),
64+ finished: make(chan struct{}),
65+ sends: make(chan ports.Frame, 1),
66+ }
67+}
68+
69+func (t *lateGraphicsCleanupTransport) Send(frame ports.Frame) error {
70+ t.startedOnce.Do(func() { close(t.started) })
71+ <-t.release
72+ t.sends <- frame
73+ t.finishOnce.Do(func() { close(t.finished) })
74+ return nil
75+}
76+
77+func (*lateGraphicsCleanupTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
78+func (*lateGraphicsCleanupTransport) Close() error { return nil }
79+
80+func TestGraphicsOutputReplaysStaticKittenIcatFixture(t *testing.T) {
81+ state := newGraphicsOutputState()
82+ snapshot := kittenGraphicsSnapshot(t)
83+
84+ first, err := state.prepare(snapshot, true)
85+ require.NoError(t, err)
86+ require.NotNil(t, first)
87+ require.Contains(t, string(first.data), "\x1b_Ga=t,i=")
88+ require.Contains(t, string(first.data), ",f=100")
89+ require.Contains(t, string(first.data), "\x1b_Ga=p,i=")
90+ require.Contains(t, string(first.data), ",q=2")
91+ require.Less(t, recordIndex(first.data, "\x1b_Ga=t,"), recordIndex(first.data, "\x1b_Ga=p,"), "upload must precede placement")
92+ outer := vt.NewScreen(80, 24)
93+ outer.Write(first.data)
94+ require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
95+ require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
96+ first.commit()
97+
98+ unchanged, err := state.prepare(snapshot, false)
99+ require.NoError(t, err)
100+ require.Empty(t, unchanged.data, "unchanged graphics must not enter ordinary text output")
101+ unchanged.commit()
102+
103+ replay, err := state.prepare(snapshot, true)
104+ require.NoError(t, err)
105+ text := string(replay.data)
106+ require.Less(t, strings.Index(text, "a=d,d=p,p="), strings.Index(text, "a=d,d=i,i="), "placement cleanup must precede image cleanup")
107+ require.Less(t, strings.Index(text, "a=d,d=i,i="), strings.Index(text, "a=t,i="), "delete must precede replay upload")
108+ require.Less(t, strings.Index(text, "a=t,i="), strings.Index(text, "a=p,i="), "upload must precede replay placement")
109+}
110+
111+func TestKittyAttachmentPaintCarriesGraphicsInTheOutputStateRecord(t *testing.T) {
112+ p, releasePTY := newBlockingPTY(t)
113+ defer releasePTY()
114+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
115+ ac.terminalCapabilities.KittyGraphics = true
116+ ac.output.graphicsOutput = newGraphicsOutputState()
117+ fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
118+ require.NoError(t, err)
119+ pane := sess.tabs[0].focusedPane()
120+ // This fixture contains pixel-space Kitty output without cell metadata;
121+ // provide the terminal pixel geometry explicitly so attachment clipping can
122+ // map its placement safely.
123+ geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 23}, PixelWidth: 800, PixelHeight: 460}
124+ pane.geometry = geometry
125+ setScreenGeometry(pane.screen, geometry)
126+ pane.screen.Write(fixture)
127+
128+ d.paint(sess, ac, true, nil)
129+ frame := awaitFrame(t, sends, ports.MsgOutput)
130+ output, err := ports.UnmarshalOutput(frame.Payload)
131+ require.NoError(t, err)
132+ require.Contains(t, string(output.Data), "\x1b_Ga=t,i=")
133+ require.Contains(t, string(output.Data), ",f=100")
134+ require.Contains(t, string(output.Data), "\x1b_Ga=p,i=")
135+ require.Contains(t, string(output.Data), ",q=2")
136+ require.Positive(t, output.New, "graphics must ride the ordinary state-bearing output")
137+
138+ outer := vt.NewScreen(output.Size.Cols, output.Size.Rows)
139+ outer.Write(output.Data)
140+ require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
141+ require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
142+}
143+
144+func TestFreshCapabilityDowngradePreservesForeignTerminalGraphics(t *testing.T) {
145+ p, releasePTY := newBlockingPTY(t)
146+ defer releasePTY()
147+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
148+ ac.terminalCapabilities.KittyGraphics = true
149+ ac.output.graphicsOutput = newGraphicsOutputState()
150+ ac.output.graphicsUnsupportedWarned.Store(true)
151+ d.mu.Lock()
152+ d.reconfigureAttachmentOutput(sess, ac, ports.Hello{KittyDirectGraphics: false})
153+ d.mu.Unlock()
154+ require.False(t, ac.terminalCapabilities.SupportsKittyGraphics())
155+ require.Nil(t, ac.output.graphicsOutput)
156+ require.False(t, ac.output.graphicsUnsupportedWarned.Load(), "replacement terminal must receive its own unsupported-graphics warning")
157+
158+ d.paint(sess, ac, true, nil)
159+ frame := awaitFrame(t, sends, ports.MsgOutput)
160+ output, err := ports.UnmarshalOutput(frame.Payload)
161+ require.NoError(t, err)
162+ require.Positive(t, output.New)
163+ require.NotContains(t, string(output.Data), "\x1b_Ga=d,d=A", "vev must never globally delete terminal images")
164+
165+ outer := vt.NewScreen(80, 24)
166+ foreign := newGraphicsOutputState()
167+ foreignOutput, err := foreign.prepare(kittenGraphicsSnapshot(t), true)
168+ require.NoError(t, err)
169+ outer.Write(foreignOutput.data)
170+ require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
171+ outer.Write(output.Data)
172+ require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
173+ require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
174+}
175+
176+func TestReplacementKittyTerminalGetsFreshGraphicsState(t *testing.T) {
177+ d := newTestDaemon(t, nil, stubClock{})
178+ sess := &session{sessionCore: sessionCore{id: "work"}}
179+ ac := &attachedClient{clientID: [16]byte{1}, output: newOutputStateStream()}
180+
181+ d.mu.Lock()
182+ oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, ac.clientID))
183+ oldState := newGraphicsOutputStateWithLease(oldBase, oldFence)
184+ oldState.mayHaveEmitted = true
185+ ac.output.graphicsOutput = oldState
186+ ac.output.graphicsUnsupportedWarned.Store(true)
187+ d.reconfigureAttachmentOutput(sess, ac, ports.Hello{KittyDirectGraphics: true})
188+ d.mu.Unlock()
189+
190+ require.True(t, ac.terminalCapabilities.SupportsKittyGraphics())
191+ require.NotNil(t, ac.output.graphicsOutput)
192+ require.NotSame(t, oldState, ac.output.graphicsOutput)
193+ require.NotEqual(t, oldBase, ac.output.graphicsOutput.namespaceBase, "replacement terminal must not reuse terminal-global Kitty IDs")
194+ require.False(t, ac.output.graphicsUnsupportedWarned.Load())
195+}
196+
197+func TestPaneGraphicsCoordinateGeometrySurvivesAttachmentResize(t *testing.T) {
198+ fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
199+ require.NoError(t, err)
200+ p := newPane("pane", nil, domain.Size{Cols: 80, Rows: 24})
201+ oldGeometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
202+ newGeometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 1600, PixelHeight: 960}
203+
204+ p.mu.Lock()
205+ setScreenGeometry(p.screen, oldGeometry)
206+ writePaneScreenLocked(p, fixture)
207+ first := capturePaneRenderStateLocked(p, domain.Rect{Width: 80, Height: 24})
208+ setScreenGeometry(p.screen, newGeometry)
209+ second := capturePaneRenderStateLocked(p, domain.Rect{Width: 80, Height: 24})
210+ p.mu.Unlock()
211+
212+ require.Equal(t, oldGeometry, first.graphicsGeometry)
213+ require.Equal(t, oldGeometry, second.graphicsGeometry, "retained scene pixels must not be reinterpreted as the resized cell units")
214+}
215+
216+func TestGraphicsOutputChunksLargeRGBUpload(t *testing.T) {
217+ const width, height = 300, 300
218+ raw := make([]byte, width*height*3)
219+ state := uint32(1)
220+ for i := range raw {
221+ state = state*1664525 + 1013904223
222+ raw[i] = byte(state >> 24)
223+ }
224+ scene := graphics.NewScene(graphics.Limits{})
225+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
226+ require.NoError(t, err)
227+ _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
228+ require.NoError(t, err)
229+
230+ prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
231+ require.NoError(t, err)
232+ var uploadHeaders []string
233+ for data := prepared.data; ; {
234+ start := bytes.Index(data, []byte("\x1b_G"))
235+ if start < 0 {
236+ break
237+ }
238+ end := bytes.Index(data[start+3:], []byte("\x1b\\"))
239+ require.NotEqual(t, -1, end)
240+ body := data[start+3 : start+3+end]
241+ header, payload, _ := bytes.Cut(body, []byte(";"))
242+ if bytes.Contains(header, []byte("a=t")) {
243+ uploadHeaders = append(uploadHeaders, string(header))
244+ require.LessOrEqual(t, len(payload), graphicsUploadChunkBytes)
245+ }
246+ data = data[start+3+end+2:]
247+ }
248+ require.Greater(t, len(uploadHeaders), 1, "large RGB data must use Kitty continuation chunks")
249+ require.Contains(t, uploadHeaders[0], "f=24")
250+ require.Contains(t, uploadHeaders[0], "m=1")
251+ require.Contains(t, uploadHeaders[len(uploadHeaders)-1], "m=0")
252+}
253+
254+func TestGraphicsOutputCompressesLargeRGBUpload(t *testing.T) {
255+ const width, height = 100, 100
256+ raw := bytes.Repeat([]byte{0x7f}, width*height*3)
257+ scene := graphics.NewScene(graphics.Limits{})
258+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
259+ require.NoError(t, err)
260+ _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
261+ require.NoError(t, err)
262+
263+ prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
264+ require.NoError(t, err)
265+ require.Contains(t, string(prepared.data), ",f=24,s=100,v=100,o=z")
266+}
267+
268+func TestGraphicsOutputRelaysLargeKittenIcatRGBWithinWireBudget(t *testing.T) {
269+ const width, height = 2048, 1200
270+ raw := make([]byte, width*height*3)
271+ state := uint32(1)
272+ for i := range raw {
273+ state = state*1664525 + 1013904223
274+ raw[i] = byte(state >> 24)
275+ }
276+ scene := graphics.NewScene(graphics.Limits{})
277+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
278+ require.NoError(t, err)
279+ _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
280+ require.NoError(t, err)
281+
282+ prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
283+ require.NoError(t, err)
284+ require.Greater(t, len(prepared.data), 8<<20, "valid icat RGB replay must not be suppressed by the old graphics-only cap")
285+ _, err = marshalOutputState(prepared.data, 1, 0, 1, 0, 0, domain.Size{Cols: 80, Rows: 24}, true)
286+ require.NoError(t, err, "the relayed graphics stream must fit the real Output wire budget")
287+}
288+
289+func TestGraphicsOutputDefersCleanupThatDoesNotFitFrameBudget(t *testing.T) {
290+ graphicsState := newGraphicsOutputState()
291+ seed, err := graphicsState.prepare(kittenGraphicsSnapshot(t), true)
292+ require.NoError(t, err)
293+ seed.commit()
294+ ac := &attachedClient{
295+ output: attachmentOutputWithGraphics(graphicsState),
296+ terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
297+ }
298+
299+ deferred, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, 0)
300+ require.NoError(t, err, "graphics cleanup must not abort an ANSI frame when no graphics budget remains")
301+ require.Nil(t, deferred)
302+ require.NotEmpty(t, graphicsState.assets, "required image deletes must remain pending for a later frame")
303+ require.NotEmpty(t, graphicsState.placements, "required placement deletes must remain pending for a later frame")
304+
305+ cleanup, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, maxGraphicsOutputBytes)
306+ require.NoError(t, err)
307+ require.NotNil(t, cleanup)
308+ require.Contains(t, string(cleanup.data), "a=d,d=p,p=")
309+ require.Contains(t, string(cleanup.data), "a=d,d=i,i=")
310+ cleanup.commit()
311+ require.Empty(t, graphicsState.assets)
312+ require.Empty(t, graphicsState.placements)
313+}
314+
315+func TestGraphicsOutputIDsAreIsolatedAcrossAttachments(t *testing.T) {
316+ snapshot := kittenGraphicsSnapshot(t)
317+ left, right := newGraphicsOutputState(), newGraphicsOutputState()
318+ leftPrepared, err := left.prepare(snapshot, true)
319+ require.NoError(t, err)
320+ rightPrepared, err := right.prepare(snapshot, true)
321+ require.NoError(t, err)
322+ var leftID, rightID uint64
323+ for _, asset := range leftPrepared.state.assets {
324+ leftID = asset.id
325+ break
326+ }
327+ for _, asset := range rightPrepared.state.assets {
328+ rightID = asset.id
329+ break
330+ }
331+ require.NotZero(t, leftID)
332+ require.NotZero(t, rightID)
333+ require.NotEqual(t, leftID, rightID, "terminal-global image IDs must not be reused by another attachment")
334+}
335+
336+func TestUnsupportedGraphicsWarningIsBoundedForLateSceneCreation(t *testing.T) {
337+ pty, releasePTY := newBlockingPTY(t)
338+ defer releasePTY()
339+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
340+ ac.terminalCapabilities.KittyGraphics = false
341+ fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
342+ require.NoError(t, err)
343+ sess.tabs[0].focusedPane().screen.Write(fixture)
344+
345+ require.True(t, d.warnUnsupportedGraphics(ac), "late graphics creation should explain suppression")
346+ require.False(t, d.warnUnsupportedGraphics(ac), "suppression warning must be bounded per attachment")
347+ ac.overlays.noticeMu.Lock()
348+ require.Len(t, ac.overlays.noticeToasts, 1)
349+ ac.overlays.noticeMu.Unlock()
350+}
351+
352+func TestGraphicsNamespacesAreDeterministicAndCollisionSafe(t *testing.T) {
353+ d := &Daemon{}
354+ d.mu.Lock()
355+ first := d.reserveGraphicsNamespaceLocked("session:work:attachment")
356+ second := d.reserveGraphicsNamespaceLocked("session:work:attachment")
357+ d.mu.Unlock()
358+ require.NotZero(t, first)
359+ require.NotZero(t, second)
360+ require.NotEqual(t, first, second, "equal attachment/session keys still need a collision-safe namespace")
361+ d.releaseGraphicsNamespace(first)
362+ d.mu.Lock()
363+ reused := d.reserveGraphicsNamespaceLocked("session:work:attachment")
364+ d.mu.Unlock()
365+ require.Equal(t, first, reused, "released namespaces should deterministically return to their preferred block")
366+}
367+
368+func TestGraphicsOutputExhaustionResetsInsideItsNamespace(t *testing.T) {
369+ base := graphicsIDNamespaceSize + 1
370+ state := newGraphicsOutputStateWithBase(base)
371+ state.nextID = base + graphicsIDNamespaceSize - 1
372+ snapshot := kittenGraphicsSnapshot(t)
373+ prepared, err := state.prepare(snapshot, false)
374+ require.NoError(t, err, "namespace exhaustion must recover through an attachment-local replay")
375+ require.NotNil(t, prepared)
376+ limit := base + graphicsIDNamespaceSize - 1
377+ for _, asset := range prepared.state.assets {
378+ require.GreaterOrEqual(t, asset.id, base)
379+ require.LessOrEqual(t, asset.id, limit)
380+ }
381+ for _, placement := range prepared.state.placements {
382+ require.GreaterOrEqual(t, placement.id, base)
383+ require.LessOrEqual(t, placement.id, limit)
384+ }
385+ require.Equal(t, base+2, prepared.state.nextID, "the replay should restart at the reserved block base")
386+}
387+
388+func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T) {
389+ d := newTestDaemon(t, nil, stubClock{})
390+ base := graphicsIDNamespaceSize + 1
391+ block := (base - 1) / graphicsIDNamespaceSize
392+
393+ parkedState := newGraphicsOutputStateWithBase(base)
394+ parked := &parkedAttachment{
395+ sess: &session{sessionCore: sessionCore{name: "parked"}},
396+ ac: &attachedClient{output: attachmentOutputWithGraphics(parkedState)},
397+ done: make(chan struct{}),
398+ }
399+ d.mu.Lock()
400+ d.graphicsNamespaces[block] = struct{}{}
401+ d.parked[1] = parked
402+ d.mu.Unlock()
403+ d.expireParked(1, parked)
404+ require.Nil(t, parked.ac.output.graphicsOutput)
405+ d.mu.Lock()
406+ _, reserved := d.graphicsNamespaces[block]
407+ d.mu.Unlock()
408+ require.False(t, reserved, "park expiry with no outer objects may release the attachment namespace")
409+
410+ failedState := newGraphicsOutputStateWithBase(base)
411+ seed, err := failedState.prepare(kittenGraphicsSnapshot(t), true)
412+ require.NoError(t, err)
413+ seed.commit()
414+ failed := &attachedClient{
415+ tr: failingOutputTransport{},
416+ output: attachmentOutputWithGraphics(failedState),
417+ }
418+ d.mu.Lock()
419+ d.graphicsNamespaces[block] = struct{}{}
420+ d.mu.Unlock()
421+ require.Error(t, d.cleanupAttachmentOutput(failed), "failing transport must surface the cleanup send error")
422+ require.Nil(t, failed.output.graphicsOutput)
423+ d.mu.Lock()
424+ _, reserved = d.graphicsNamespaces[block]
425+ d.mu.Unlock()
426+ require.True(t, reserved, "failed terminal cleanup must quarantine the namespace")
427+}
428+
429+func TestGraphicsNamespaceStaysQuarantinedAfterSocketSendSuccessWithoutTerminalFlush(t *testing.T) {
430+ d := newTestDaemon(t, nil, stubClock{})
431+ key := "session:unflushed:attachment"
432+ d.mu.Lock()
433+ oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
434+ d.mu.Unlock()
435+
436+ state := newGraphicsOutputStateWithLease(oldBase, oldFence)
437+ seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
438+ require.NoError(t, err)
439+ seed.markSendAttempted()
440+ seed.commit()
441+
442+ // This transport models only successful delivery to the client socket. It
443+ // deliberately provides no evidence that the client flushed the side effect
444+ // to its outer terminal before being lost.
445+ transport := &closeTrackingTransport{}
446+ ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(state)}
447+ require.NoError(t, d.cleanupAttachmentOutput(ac))
448+ require.Nil(t, ac.output.graphicsOutput)
449+ sends := transport.Sends()
450+ require.Len(t, sends, 1)
451+ cleanup, err := ports.UnmarshalOutput(sends[0].Payload)
452+ require.NoError(t, err)
453+ require.Zero(t, cleanup.New, "graphics cleanup is an unacknowledged side-effect frame")
454+
455+ d.mu.Lock()
456+ _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
457+ newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
458+ d.mu.Unlock()
459+ require.True(t, quarantined, "socket-send success cannot prove terminal cleanup")
460+ require.NotEqual(t, oldBase, newBase, "an unflushed cleanup must not expose the old IDs to another attachment")
461+}
462+
463+func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testing.T) {
464+ d := newTestDaemon(t, nil, stubClock{})
465+ key := "session:empty-park:attachment"
466+ d.mu.Lock()
467+ oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
468+ d.mu.Unlock()
469+
470+ state := newGraphicsOutputStateWithLease(oldBase, oldFence)
471+ seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
472+ require.NoError(t, err)
473+ seed.markSendAttempted()
474+ seed.commit()
475+ finalDelete, err := state.prepare(nil, false)
476+ require.NoError(t, err)
477+ require.NotEmpty(t, finalDelete.data)
478+ finalDelete.markSendAttempted()
479+ finalDelete.commit()
480+ require.Empty(t, state.assets)
481+ require.Empty(t, state.placements)
482+
483+ parked := &parkedAttachment{
484+ sess: &session{sessionCore: sessionCore{name: "parked"}},
485+ ac: &attachedClient{output: attachmentOutputWithGraphics(state)},
486+ done: make(chan struct{}),
487+ }
488+ d.mu.Lock()
489+ d.parked[1] = parked
490+ d.mu.Unlock()
491+ d.expireParked(1, parked)
492+
493+ d.mu.Lock()
494+ _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
495+ newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
496+ d.mu.Unlock()
497+ require.True(t, quarantined, "an apparently empty parked state still has unacknowledged terminal history")
498+ require.NotEqual(t, oldBase, newBase, "park expiry must not recycle IDs after the final delete")
499+}
500+
501+func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
502+ d := newTestDaemon(t, nil, stubClock{})
503+ d.mu.Lock()
504+ for block := uint64(0); block < graphicsIDNamespaceCount; block++ {
505+ d.graphicsNamespaces[block] = struct{}{}
506+ }
507+ ac := d.prepareAttachedClientLocked(
508+ &session{sessionCore: sessionCore{id: "work"}},
509+ &closeTrackingTransport{},
510+ domain.Geometry{Size: defaultSize},
511+ attachClientOptions{
512+ capabilitiesSet: true,
513+ terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true, ColorMode: ports.TerminalColorTrueColor},
514+ },
515+ )
516+ d.mu.Unlock()
517+
518+ require.Nil(t, ac.output.graphicsOutput)
519+ require.False(t, ac.terminalCapabilities.SupportsKittyGraphics())
520+ require.NotNil(t, ac.output, "namespace exhaustion must preserve the ordinary text renderer")
521+}
522+
523+func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
524+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
525+ d := newTestDaemon(t, nil, clock)
526+ key := "session:stale:attachment"
527+ d.mu.Lock()
528+ oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
529+ d.mu.Unlock()
530+ oldState := newGraphicsOutputStateWithLease(oldBase, oldFence)
531+ seed, err := oldState.prepare(kittenGraphicsSnapshot(t), true)
532+ require.NoError(t, err)
533+ seed.commit()
534+ var oldID uint64
535+ for _, asset := range oldState.assets {
536+ oldID = asset.id
537+ break
538+ }
539+ require.NotZero(t, oldID)
540+
541+ transport := newLateGraphicsCleanupTransport()
542+ ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(oldState)}
543+ cleanupDone := make(chan struct{})
544+ go func() {
545+ _ = d.cleanupAttachmentOutput(ac)
546+ close(cleanupDone)
547+ }()
548+ awaitTestCompletion(t, transport.started, "timed-out cleanup did not reach the stale transport")
549+ timer := awaitTestValue(t, clock.timers, "cleanup watchdog timer was not armed")
550+ timer.ch <- time.Time{}
551+ awaitTestCompletion(t, cleanupDone, "timed-out cleanup did not retire the attachment")
552+
553+ d.mu.Lock()
554+ newBase, newFence := d.reserveGraphicsNamespaceLeaseLocked(key)
555+ _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
556+ d.mu.Unlock()
557+ require.True(t, quarantined, "timed-out cleanup must keep its namespace quarantined")
558+ require.NotEqual(t, oldBase, newBase, "new allocation must skip the quarantined namespace")
559+
560+ newState := newGraphicsOutputStateWithLease(newBase, newFence)
561+ fresh, err := newState.prepare(kittenGraphicsSnapshot(t), true)
562+ require.NoError(t, err)
563+ var newID uint64
564+ for _, asset := range fresh.state.assets {
565+ newID = asset.id
566+ break
567+ }
568+ require.NotZero(t, newID)
569+ require.NotEqual(t, oldID, newID, "fresh attachment IDs must not share the quarantined block")
570+
571+ close(transport.release)
572+ staleFrame := awaitTestValue(t, transport.sends, "late stale cleanup did not execute")
573+ awaitTestCompletion(t, transport.finished, "late stale cleanup did not return")
574+ staleOutput, err := ports.UnmarshalOutput(staleFrame.Payload)
575+ require.NoError(t, err)
576+ require.Contains(t, string(staleOutput.Data), "i="+strconv.FormatUint(oldID, 10))
577+ require.NotContains(t, string(staleOutput.Data), "i="+strconv.FormatUint(newID, 10), "late cleanup must never target the fresh attachment")
578+ d.mu.Lock()
579+ _, retained := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
580+ d.mu.Unlock()
581+ require.True(t, retained, "late transport success is not a terminal ACK and must not release the namespace")
582+}
583+
584+func TestGraphicsOutputKeysIncludeSourceSceneContent(t *testing.T) {
585+ makeSnapshot := func(data []byte) *graphics.Snapshot {
586+ scene := graphics.NewScene(graphics.Limits{})
587+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: data, Width: 1, Height: 1})
588+ require.NoError(t, err)
589+ _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
590+ require.NoError(t, err)
591+ return scene.Snapshot()
592+ }
593+ state := newGraphicsOutputState()
594+ first, err := state.prepare(makeSnapshot([]byte("first")), true)
595+ require.NoError(t, err)
596+ first.commit()
597+ second, err := state.prepare(makeSnapshot([]byte("second")), false)
598+ require.NoError(t, err)
599+ require.Contains(t, string(second.data), "a=t,i=", "a new a1/p1 scene must not alias the stale outer image")
600+}
601+
602+func TestGraphicsOutputPreSendAbortDoesNotCreateCleanupRecords(t *testing.T) {
603+ state := newGraphicsOutputState()
604+ prepared, err := state.prepare(kittenGraphicsSnapshot(t), true)
605+ require.NoError(t, err)
606+ prepared.abort()
607+
608+ cleanup, err := state.prepare(nil, true)
609+ require.NoError(t, err)
610+ require.Empty(t, cleanup.data, "IDs allocated by a preparation that never reached the transport are not owned by the terminal")
611+}
612+
613+func TestGraphicsOutputOversizeSuppressesGraphicsWithoutCompositionError(t *testing.T) {
614+ scene := graphics.NewScene(graphics.Limits{})
615+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: bytes.Repeat([]byte{'x'}, maxGraphicsOutputBytes), Width: 1, Height: 1})
616+ require.NoError(t, err)
617+ _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
618+ require.NoError(t, err)
619+ state := &capturedRenderState{panes: []capturedPaneRenderState{{
620+ graphics: scene.Snapshot(),
621+ placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
622+ }}}
623+ ac := &attachedClient{
624+ output: attachmentOutputWithGraphics(newGraphicsOutputState()),
625+ terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
626+ }
627+ prepared, err := graphicsOutputData(state, ac, true)
628+ require.NoError(t, err, "ANSI composition must continue when optional graphics exceed the bound")
629+ require.Empty(t, prepared.data, "oversized graphics are suppressed")
630+}
631+
632+func TestGraphicsOutputClipsPlacementToPaneContent(t *testing.T) {
633+ scene := graphics.NewScene(graphics.Limits{})
634+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("asset"), Width: 10, Height: 10})
635+ require.NoError(t, err)
636+ _, err = scene.PlaceAsset(asset, graphics.PixelRect{X: -2, Y: -1, Width: 10, Height: 10})
637+ require.NoError(t, err)
638+ state := &capturedRenderState{panes: []capturedPaneRenderState{{
639+ graphics: scene.Snapshot(),
640+ graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 4, Rows: 3}, PixelWidth: 4, PixelHeight: 3},
641+ placement: layout.Placement{Content: domain.Rect{X: 3, Y: 4, Width: 4, Height: 3}},
642+ }}}
643+ ac := &attachedClient{
644+ output: attachmentOutputWithGraphics(newGraphicsOutputState()),
645+ terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
646+ }
647+ prepared, err := graphicsOutputData(state, ac, true)
648+ require.NoError(t, err)
649+ text := string(prepared.data)
650+ require.Contains(t, text, "\x1b[6;4H", "placement must move to the clipped pane-local destination")
651+ require.Contains(t, text, ",x=2,y=1,w=4,h=3", "lowercase coordinates must preserve the source crop")
652+ require.NotContains(t, text, ",X=2,Y=1", "source coordinates must not become destination offsets")
653+}
654+
655+func TestGraphicsOutputClipsEachAttachmentToOuterContentFrame(t *testing.T) {
656+ scene := graphics.NewScene(graphics.Limits{})
657+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("attachment-frame"), Width: 80, Height: 24})
658+ require.NoError(t, err)
659+ _, err = scene.Place(graphics.PlacementSpec{
660+ Asset: asset,
661+ Destination: graphics.PixelRect{Width: 800, Height: 480},
662+ Cells: graphics.CellRect{Width: 80, Height: 24},
663+ })
664+ require.NoError(t, err)
665+
666+ makeState := func(window domain.Size) *capturedRenderState {
667+ return &capturedRenderState{
668+ window: window,
669+ layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 24}, valid: true},
670+ panes: []capturedPaneRenderState{{
671+ id: "pane",
672+ graphics: scene.Snapshot(),
673+ graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
674+ placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 24}},
675+ }},
676+ }
677+ }
678+
679+ small, err := graphicsOutputData(makeState(domain.Size{Cols: 40, Rows: 10}), graphicsPaneTestClient(), true)
680+ require.NoError(t, err)
681+ require.Len(t, small.state.placements, 1)
682+ for _, placement := range small.state.placements {
683+ require.Equal(t, graphics.PixelRect{Width: 400, Height: 160}, placement.dest, "small attachment must clip pixels to its content frame")
684+ require.Equal(t, graphics.CellRect{Width: 40, Height: 8}, placement.cells, "status bar row must stay outside the graphics cells")
685+ }
686+ require.Contains(t, string(small.data), ",c=40,r=8")
687+ require.NotContains(t, string(small.data), ",c=80,r=24")
688+
689+ large, err := graphicsOutputData(makeState(domain.Size{Cols: 100, Rows: 30}), graphicsPaneTestClient(), true)
690+ require.NoError(t, err)
691+ require.Len(t, large.state.placements, 1)
692+ for _, placement := range large.state.placements {
693+ require.Equal(t, graphics.PixelRect{Width: 800, Height: 480}, placement.dest, "a larger attachment must retain the pane portion it can display")
694+ }
695+ require.Contains(t, string(large.data), ",c=80,r=24")
696+}
697+
698+func TestGraphicsOutputCellOnlyViewportPreservesSourceMapping(t *testing.T) {
699+ scene := graphics.NewScene(graphics.Limits{})
700+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("cell-only"), Width: 100, Height: 50})
701+ require.NoError(t, err)
702+ _, err = scene.Place(graphics.PlacementSpec{
703+ Asset: asset,
704+ Destination: graphics.PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
705+ Cells: graphics.CellRect{X: 2, Y: 3, Width: 20, Height: 10},
706+ })
707+ require.NoError(t, err)
708+
709+ viewport, ok := graphicsOutputViewportRect(domain.Rect{X: 7, Y: 5, Width: 5, Height: 5}, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
710+ require.True(t, ok)
711+ require.Equal(t, graphics.PixelRect{}, viewport.Pixels, "cell-only geometry must not become a pixel viewport")
712+ require.Equal(t, graphics.CellRect{X: 7, Y: 5, Width: 5, Height: 5}, viewport.Cells)
713+
714+ _, placements := graphicsSnapshotRecords(scene.Snapshot(), viewport)
715+ require.Len(t, placements, 1)
716+ for _, placement := range placements {
717+ require.Equal(t, graphics.PixelRect{X: 25, Y: 10, Width: 25, Height: 25}, placement.source)
718+ require.Equal(t, graphics.PixelRect{X: 60, Y: 40, Width: 50, Height: 50}, placement.dest)
719+ }
720+
721+ pixelOnly := graphics.NewScene(graphics.Limits{})
722+ pixelAsset, err := pixelOnly.AddAsset(graphics.AssetBlob{Encoded: []byte("pixel-only"), Width: 10, Height: 10})
723+ require.NoError(t, err)
724+ _, err = pixelOnly.PlaceAsset(pixelAsset, graphics.PixelRect{Width: 10, Height: 10})
725+ require.NoError(t, err)
726+ _, unsuitable := graphicsSnapshotRecords(pixelOnly.Snapshot(), viewport)
727+ require.Empty(t, unsuitable, "pixel-space fragments without cell metadata must be suppressed")
728+}
729+
730+func TestGraphicsOutputComposesPaneOriginAndPixelGeometry(t *testing.T) {
731+ placement := graphicsOutputPlacement{
732+ id: 1,
733+ source: graphics.PixelRect{Width: 20, Height: 10},
734+ dest: graphics.PixelRect{X: 103, Y: 50, Width: 20, Height: 10},
735+ cells: graphics.CellRect{Width: 2, Height: 1},
736+ }
737+ record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
738+ originX: 3,
739+ originY: 2,
740+ sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
741+ })
742+ text := string(record)
743+ require.Contains(t, text, "\x1b7\x1b[5;14H", "destination cells must be expressed through cursor position")
744+ require.Contains(t, text, ",X=3,Y=10", "destination pixel remainder must use uppercase offsets")
745+ require.NotContains(t, text, ",x=13,y=4", "destination cells must never become source crop controls")
746+ require.Contains(t, text, ",C=1", "placement must not move or scroll the text cursor")
747+ require.True(t, strings.HasSuffix(text, "\x1b8"), "placement must restore the outer text cursor")
748+}
749+
750+func TestGraphicsOutputPreservesKittenIcatCenteredPlacement(t *testing.T) {
751+ placement := graphicsOutputPlacement{
752+ id: 1,
753+ source: graphics.PixelRect{Width: 103, Height: 128},
754+ dest: graphics.PixelRect{X: 343, Width: 103, Height: 128},
755+ }
756+ record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
757+ originY: 1,
758+ sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
759+ })
760+ text := string(record)
761+ require.Contains(t, text, "\x1b[2;35H", "icat's centered cursor column must survive composition")
762+ require.Contains(t, text, ",w=103,h=128", "natural image dimensions must be preserved")
763+ require.Contains(t, text, ",X=3", "sub-cell destination offset must be preserved")
764+ require.NotContains(t, text, ",x=34", "center position must not crop the image source")
765+}
766+
767+func TestGraphicsOutputFailedSendRetainsSpeculativeIDsForCleanup(t *testing.T) {
768+ state := newGraphicsOutputState()
769+ first, err := state.prepare(kittenGraphicsSnapshot(t), true)
770+ require.NoError(t, err)
771+ first.markSendAttempted()
772+ first.abort()
773+
774+ retry, err := state.prepare(kittenGraphicsSnapshot(t), true)
775+ require.NoError(t, err)
776+ text := string(retry.data)
777+ require.Less(t, strings.Index(text, "a=d,d=p,p="), strings.Index(text, "a=d,d=i,i="))
778+ require.Less(t, strings.Index(text, "a=d,d=i,i="), strings.Index(text, "a=t,i="))
779+ require.Contains(t, text, "a=p,i=")
780+}
781+
782+func TestGraphicsOutputCleanupRemovesPlacementsBeforeImages(t *testing.T) {
783+ state := newGraphicsOutputState()
784+ snapshot := kittenGraphicsSnapshot(t)
785+ prepared, err := state.prepare(snapshot, true)
786+ require.NoError(t, err)
787+ prepared.commit()
788+
789+ // A nil snapshot models the attachment losing its visible graphics scene.
790+ cleared, err := state.prepare(nil, false)
791+ require.NoError(t, err)
792+ require.Less(t, strings.Index(string(cleared.data), "a=d,d=p,p="), strings.Index(string(cleared.data), "a=d,d=i,i="))
793+ cleared.commit()
794+ require.Empty(t, state.assets)
795+ require.Empty(t, state.placements)
796+}
797+
798+func graphicsPaneTestScene(t *testing.T, data string, destination graphics.PixelRect, layer int64) *graphics.Snapshot {
799+ t.Helper()
800+ scene := graphics.NewScene(graphics.Limits{})
801+ asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte(data), Width: 20, Height: 10})
802+ require.NoError(t, err)
803+ _, err = scene.Place(graphics.PlacementSpec{Asset: asset, Destination: destination, Layer: layer, HasLayer: true})
804+ require.NoError(t, err)
805+ return scene.Snapshot()
806+}
807+
808+func graphicsPaneTestState(tab domain.TabStableID, panes ...capturedPaneRenderState) *capturedRenderState {
809+ for i := range panes {
810+ if panes[i].graphicsGeometry.PixelsKnown() || panes[i].placement.Content.Width <= 0 || panes[i].placement.Content.Height <= 0 {
811+ continue
812+ }
813+ content := panes[i].placement.Content
814+ panes[i].graphicsGeometry = domain.Geometry{
815+ Size: domain.Size{Cols: content.Width, Rows: content.Height},
816+ PixelWidth: content.Width,
817+ PixelHeight: content.Height,
818+ }
819+ }
820+ return &capturedRenderState{view: attachmentView{tabID: tab}, panes: panes}
821+}
822+
823+func graphicsPaneTestClient() *attachedClient {
824+ return &attachedClient{
825+ output: attachmentOutputWithGraphics(newGraphicsOutputState()),
826+ terminalCapabilities: ports.TerminalCapabilities{KittyGraphics: true},
827+ }
828+}
829+
830+func TestGraphicsOutputComposesSplitPanesWithStableOrderAndChildIDIsolation(t *testing.T) {
831+ left := graphicsPaneTestScene(t, "left", graphics.PixelRect{X: -2, Y: 0, Width: 8, Height: 4}, 2)
832+ right := graphicsPaneTestScene(t, "right", graphics.PixelRect{X: 1, Y: 0, Width: 8, Height: 4}, -1)
833+ state := graphicsPaneTestState("tab-a",
834+ capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 0, Width: 20, Height: 4}}},
835+ capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
836+ )
837+ ac := graphicsPaneTestClient()
838+ prepared, err := graphicsOutputData(state, ac, true)
839+ require.NoError(t, err)
840+ require.Len(t, prepared.state.assets, 2, "raw a1 IDs from separate child scenes must not alias")
841+ require.Len(t, prepared.state.placements, 2)
842+ text := string(prepared.data)
843+ require.Less(t, strings.Index(text, "\x1b[2;1H"), strings.Index(text, "\x1b[2;23H"), "pane traversal order must be deterministic")
844+ var ids []uint64
845+ for _, asset := range prepared.state.assets {
846+ ids = append(ids, asset.id)
847+ }
848+ require.NotEqual(t, ids[0], ids[1])
849+}
850+
851+func TestGraphicsOutputSplitResizeReplacesTranslatedPlacementWithoutSceneMutation(t *testing.T) {
852+ snapshot := graphicsPaneTestScene(t, "resize", graphics.PixelRect{Width: 8, Height: 4}, 0)
853+ ac := graphicsPaneTestClient()
854+ firstState := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}})
855+ first, err := graphicsOutputData(firstState, ac, true)
856+ require.NoError(t, err)
857+ first.commit()
858+
859+ resized := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 7, Width: 20, Height: 4}}})
860+ second, err := graphicsOutputData(resized, ac, false)
861+ require.NoError(t, err)
862+ require.Contains(t, string(second.data), "a=d,d=p,p=")
863+ require.Contains(t, string(second.data), "a=p,i=")
864+ require.NotContains(t, string(second.data), "a=t,i=", "layout-only recomputation must retain the immutable asset")
865+}
866+
867+func TestGraphicsOutputPaneReorderRetainsAssetsButReplaysPlacementsInLayoutOrder(t *testing.T) {
868+ left := graphicsPaneTestScene(t, "left-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
869+ right := graphicsPaneTestScene(t, "right-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
870+ ac := graphicsPaneTestClient()
871+ firstState := graphicsPaneTestState("tab-a",
872+ capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}},
873+ capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
874+ )
875+ first, err := graphicsOutputData(firstState, ac, true)
876+ require.NoError(t, err)
877+ first.commit()
878+
879+ reordered := graphicsPaneTestState("tab-a",
880+ capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{Width: 20, Height: 4}}},
881+ capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
882+ )
883+ second, err := graphicsOutputData(reordered, ac, false)
884+ require.NoError(t, err)
885+ require.NotContains(t, string(second.data), "a=t,i=", "moving panes must not re-upload immutable assets")
886+ require.Contains(t, string(second.data), "a=d,d=p,p=")
887+ require.Contains(t, string(second.data), "a=p,i=")
888+ require.Less(t, strings.Index(string(second.data), "\x1b[2;1H"), strings.Index(string(second.data), "\x1b[2;22H"))
889+}
890+
891+func TestGraphicsOutputTabSwitchReplaysSameRawChildIDsInNewSourceNamespace(t *testing.T) {
892+ snapshot := graphicsPaneTestScene(t, "tab", graphics.PixelRect{Width: 8, Height: 4}, 0)
893+ ac := graphicsPaneTestClient()
894+ first, err := graphicsOutputData(graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}}), ac, true)
895+ require.NoError(t, err)
896+ first.commit()
897+ second, err := graphicsOutputData(graphicsPaneTestState("tab-b", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}}), ac, false)
898+ require.NoError(t, err)
899+ require.Contains(t, string(second.data), "a=d,d=p,p=")
900+ require.Contains(t, string(second.data), "a=d,d=i,i=")
901+ require.Contains(t, string(second.data), "a=t,i=")
902+}
903+
904+func TestGraphicsOutputFloatingPaneCoversTiledGraphicsAndRetainsItsOwnScene(t *testing.T) {
905+ tiled := graphicsPaneTestScene(t, "tiled", graphics.PixelRect{Width: 20, Height: 6}, 0)
906+ floating := graphicsPaneTestScene(t, "floating", graphics.PixelRect{Width: 3, Height: 2}, 3)
907+ state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "tiled", graphics: tiled, placement: layout.Placement{ID: "tiled", Content: domain.Rect{Width: 20, Height: 6}}})
908+ state.floating = capturedFloatingRenderState{
909+ visible: true,
910+ pane: capturedPaneRenderState{id: "floating", graphics: floating, graphicsGeometry: domain.Geometry{
911+ Size: domain.Size{Cols: 3, Rows: 2}, PixelWidth: 3, PixelHeight: 2,
912+ }},
913+ geometry: floatingGeometry{Bounds: domain.Rect{X: 5, Y: 1, Width: 6, Height: 4}, Inner: domain.Rect{X: 6, Y: 2, Width: 3, Height: 2}},
914+ }
915+ ac := graphicsPaneTestClient()
916+ prepared, err := graphicsOutputData(state, ac, true)
917+ require.NoError(t, err)
918+ require.Len(t, prepared.state.assets, 2)
919+ require.GreaterOrEqual(t, len(prepared.state.placements), 2, "floating and uncovered tiled fragments both remain visible")
920+ for _, placement := range prepared.state.placements {
921+ if strings.Contains(placement.asset, "floating") {
922+ continue
923+ }
924+ cover := graphics.PixelRect{X: 5, Y: 1, Width: 6, Height: 4}
925+ _, overlaps := placement.dest.Intersect(cover)
926+ require.False(t, overlaps, "tiled placement must not survive under floating coverage")
927+ }
928+}
929+
930+func TestGraphicsOutputOverlayDismissalHidesAndRestoresImmutableSnapshots(t *testing.T) {
931+ snapshot := graphicsPaneTestScene(t, "overlay", graphics.PixelRect{Width: 8, Height: 4}, 0)
932+ ac := graphicsPaneTestClient()
933+ state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
934+ first, err := graphicsOutputData(state, ac, true)
935+ require.NoError(t, err)
936+ first.commit()
937+
938+ state.overlays.copyActive = true
939+ hidden, err := graphicsOutputData(state, ac, true)
940+ require.NoError(t, err)
941+ require.Contains(t, string(hidden.data), "a=d,d=p,p=")
942+ hidden.commit()
943+ state.overlays.copyActive = false
944+ restored, err := graphicsOutputData(state, ac, false)
945+ require.NoError(t, err)
946+ require.Contains(t, string(restored.data), "a=t,i=")
947+ require.Equal(t, uint64(1), snapshot.Usage().Assets, "composition must not mutate the child scene")
948+}
949+
950+func TestGraphicsOutputMixedTextAndGraphicsKeepsANSIBeforeGraphics(t *testing.T) {
951+ snapshot := graphicsPaneTestScene(t, "mixed", graphics.PixelRect{Width: 8, Height: 4}, 0)
952+ ac := graphicsPaneTestClient()
953+ state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
954+ prepared, err := graphicsOutputData(state, ac, true)
955+ require.NoError(t, err)
956+ ansi := []byte("text-before-graphics")
957+ combined := append(append([]byte(nil), ansi...), prepared.data...)
958+ require.Less(t, bytes.Index(combined, ansi), bytes.Index(combined, []byte("\x1b_Ga=t,")))
959+}
960+
961+func TestGraphicsOutputCollapsedAndHiddenPanesDoNotUploadRetainedAssets(t *testing.T) {
962+ snapshot := graphicsPaneTestScene(t, "collapsed", graphics.PixelRect{Width: 8, Height: 4}, 0)
963+ ac := graphicsPaneTestClient()
964+ state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
965+ id: "pane",
966+ graphics: snapshot,
967+ placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}},
968+ })
969+ first, err := graphicsOutputData(state, ac, true)
970+ require.NoError(t, err)
971+ first.commit()
972+
973+ state.panes[0].placement.Collapsed = true
974+ collapsed, err := graphicsOutputData(state, ac, false)
975+ require.NoError(t, err)
976+ require.Empty(t, collapsed.state.assets)
977+ require.Empty(t, collapsed.state.placements)
978+ require.Contains(t, string(collapsed.data), "a=d,d=p,p=")
979+ collapsed.commit()
980+
981+ state.panes = nil
982+ hidden, err := graphicsOutputData(state, ac, false)
983+ require.NoError(t, err)
984+ require.Empty(t, hidden.data, "a collapsed scene was already retired")
985+ hidden.commit()
986+}
987+
988+func TestGraphicsOutputViewportProjectionDropsUnplacedAssets(t *testing.T) {
989+ scene := graphics.NewScene(graphics.Limits{})
990+ placed, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("placed"), Width: 4, Height: 2})
991+ require.NoError(t, err)
992+ _, err = scene.AddAsset(graphics.AssetBlob{Encoded: []byte("retained"), Width: 4, Height: 2})
993+ require.NoError(t, err)
994+ _, err = scene.PlaceAsset(placed, graphics.PixelRect{Width: 4, Height: 2})
995+ require.NoError(t, err)
996+
997+ state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
998+ id: "pane",
999+ graphics: scene.Snapshot(),
1000+ placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 4}},
1001+ })
1002+ ac := graphicsPaneTestClient()
1003+ prepared, err := graphicsOutputData(state, ac, true)
1004+ require.NoError(t, err)
1005+ require.Len(t, prepared.state.assets, 1, "viewport projection must not upload an unplaced retained image")
1006+ require.Len(t, prepared.state.placements, 1)
1007+}
M internal/usecase/daemon/move_pane_postcommit.go +3 -0
@@ -143,6 +143,9 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
143143 for _, attachment := range p.retiredAttachments {
144144 d.unregisterPreview(attachment.ac)
145145 attachment.ac.clearCaptureFrames()
146+ if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {
147+ d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.source.name)
148+ }
146149 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
147150 }
148151 for _, retirement := range p.retiredParked {
M internal/usecase/daemon/pane.go +8 -1
@@ -110,7 +110,7 @@ type pane struct {
110110 id layout.PaneID
111111 stableID string
112112 pty ports.PTY
113- mu sync.Mutex // guards screen, history, syncGen, rect, geometry, resize state, PTY side effects, title, and owner publication
113+ mu sync.Mutex // guards screen, history, syncGen, rect, geometry, graphics scene geometry, resize state, PTY side effects, title, and owner publication
114114 owner atomic.Pointer[paneOwner]
115115 // ownerGeneration is advanced only while mu is held. Readers consume the
116116 // generation through the immutable owner pointer above.
@@ -121,6 +121,13 @@ type pane struct {
121121 syncGen uint64
122122 rect domain.Rect
123123 geometry domain.Geometry
124+ // graphicsCoordinateGeometry is the pixel-to-cell unit basis of the current
125+ // non-empty placement scene. Resizing the attachment changes screen geometry
126+ // but must not reinterpret coordinates retained by that scene.
127+ graphicsCoordinateGeometry domain.Geometry
128+ // graphicsPlacementScene is meaningful only with the coordinate geometry
129+ // above and records whether that frozen basis currently owns placements.
130+ graphicsPlacementScene bool
124131 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
125132 // draining into resizePending so output is replayed against the target (or
126133 // retained old) screen only after apply resolves.
M internal/usecase/daemon/picker.go +8 -0
@@ -941,6 +941,14 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
941941 if samePeerEligible {
942942 token.ac.offerSamePeerTarget(*exactTarget)
943943 }
944+ // A close-and-dial handoff leaves this daemon's Kitty namespace. A
945+ // same-peer transition keeps the attachment and its namespace; the target
946+ // scene diff deletes and replaces the source session's placements.
947+ if !samePeerEligible {
948+ if err := d.cleanupAttachmentOutput(token.ac); err != nil {
949+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err)
950+ }
951+ }
944952 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
945953 if samePeerEligible {
946954 token.ac.clearSamePeerOffer()
M internal/usecase/daemon/remote_route_test.go +81 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "os"
56 "testing"
67
78 "github.com/stretchr/testify/require"
@@ -10,6 +11,86 @@ import (
1011 "github.com/bnema/vev/internal/ports"
1112 )
1213
14+func TestMixedAttachmentCapabilitiesKeepGraphicsAttachmentLocal(t *testing.T) {
15+ d := newTestDaemon(t, nil, stubClock{})
16+ sess := addControlSession(d, "work", "tab-1", "pane-1")
17+ sess.ephemeral = false
18+ kittyTransport, _ := newCapturingTransport(t)
19+ _, kitty, err := d.route(ports.Hello{
20+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
21+ ClientID: [16]byte{1}, KittyDirectGraphics: true,
22+ }, kittyTransport)
23+ require.NoError(t, err)
24+ require.NotNil(t, kitty.output.graphicsOutput)
25+
26+ fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
27+ require.NoError(t, err)
28+ pane := sess.tabs[0].focusedPane()
29+ pane.mu.Lock()
30+ pane.screen.Write(fixture)
31+ pane.mu.Unlock()
32+
33+ textTransport, _ := newCapturingTransport(t)
34+ _, text, err := d.route(ports.Hello{
35+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
36+ ClientID: [16]byte{2},
37+ }, textTransport)
38+ require.NoError(t, err)
39+ require.Nil(t, text.output.graphicsOutput)
40+ text.overlays.noticeMu.Lock()
41+ var warning string
42+ for _, toast := range text.overlays.noticeToasts {
43+ warning = toast.n.Message
44+ }
45+ text.overlays.noticeMu.Unlock()
46+ require.Contains(t, warning, "Kitty graphics are unavailable")
47+ d.clientGone(sess, kitty, kittyTransport, false)
48+ d.clientGone(sess, text, textTransport, false)
49+}
50+
51+func TestDirectRemoteAttachSuppressesUndeclaredGraphicsBackend(t *testing.T) {
52+ d := newTestDaemon(t, nil, stubClock{})
53+ sess := addControlSession(d, "work", "tab-1", "pane-1")
54+ sess.ephemeral = false
55+ tr, _ := newCapturingTransport(t)
56+ _, ac, err := d.routeWithContext(context.Background(), ports.Hello{
57+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
58+ Env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, Remote: true,
59+ }, tr)
60+ require.NoError(t, err)
61+ require.NotNil(t, ac)
62+ require.Nil(t, ac.output.graphicsOutput, "environment heuristics do not declare direct graphics")
63+ d.clientGone(sess, ac, tr, false)
64+}
65+
66+func TestResumeRemoteRoutePreservesDeclaredGraphicsCapability(t *testing.T) {
67+ d := newTestDaemon(t, nil, stubClock{})
68+ sess := addControlSession(d, "work", "tab-1", "pane-1")
69+ sess.ephemeral = false
70+ local := ports.Hello{
71+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
72+ ClientID: [16]byte{1, 2, 3, 4}, Env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, KittyDirectGraphics: true,
73+ }
74+ oldTransport, _ := newCapturingTransport(t)
75+ _, ac, err := d.route(local, oldTransport)
76+ require.NoError(t, err)
77+ require.NotNil(t, ac.output.graphicsOutput)
78+ token := ac.resumeToken
79+ d.clientGone(sess, ac, oldTransport, false)
80+
81+ remote := helloResumeCapable(ports.IntentResume, "work", token)
82+ remote.Remote = true
83+ remote.KittyDirectGraphics = true
84+ replacement, _ := newCapturingTransport(t)
85+ _, resumed, ok, err := d.resumeParked(remote, replacement, defaultSize)
86+ require.NoError(t, err)
87+ require.True(t, ok)
88+ require.Same(t, ac, resumed)
89+ require.NotNil(t, resumed.output.graphicsOutput, "remote resume must retain the declared direct graphics backend")
90+ require.True(t, resumed.terminalCapabilities.SupportsKittyGraphics())
91+ d.clientGone(sess, resumed, replacement, false)
92+}
93+
1394 func TestRouteRemoteTargetSelectsExactLiveTab(t *testing.T) {
1495 d := newTestDaemon(t, nil, stubClock{})
1596 sess := addControlSession(d, "work", "tab-1", "pane-1")
M internal/usecase/daemon/render.go +35 -1
@@ -160,7 +160,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
160160 effects.renderCoordinator = owner.session.renderCoordinator()
161161 }
162162 effects.wasSyncing = p.screen.SyncUpdateActive()
163- p.screen.Write(data)
163+ writePaneScreenLocked(p, data)
164164 p.refreshTerminalTitleLocked()
165165 effects.isSyncing = p.screen.SyncUpdateActive()
166166 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
@@ -187,6 +187,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
187187 effects.attention = p.ptyAttention
188188 p.ptyAttention = false
189189 effects.responses = append([]byte(nil), p.ptyResponses...)
190+ effects.responses = filterUnsupportedKittyAnimationResponses(effects.responses)
190191 p.ptyResponses = p.ptyResponses[:0]
191192 effects.clipboards = append([]string(nil), p.ptyClipboards...)
192193 p.ptyClipboards = p.ptyClipboards[:0]
@@ -195,6 +196,39 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
195196 d.applyPanePTYEffects(p, effects)
196197 }
197198
199+// filterUnsupportedKittyAnimationResponses keeps older icat releases from
200+// leaving vev's static-animation fallback replies in the shared PTY input
201+// queue. Current Kitty sends animation controls with q=2, but older releases
202+// requested error replies and then exited without consuming them. Transfer
203+// capability probes use ordinary image IDs and retain their ENOTSUP replies.
204+func filterUnsupportedKittyAnimationResponses(data []byte) []byte {
205+ if !bytes.Contains(data, []byte("i=0,I=")) || !bytes.Contains(data, []byte(";ENOTSUP")) {
206+ return data
207+ }
208+ out := make([]byte, 0, len(data))
209+ for len(data) != 0 {
210+ start := bytes.Index(data, []byte("\x1b_G"))
211+ if start < 0 {
212+ out = append(out, data...)
213+ break
214+ }
215+ out = append(out, data[:start]...)
216+ endOffset := bytes.Index(data[start+3:], []byte("\x1b\\"))
217+ if endOffset < 0 {
218+ out = append(out, data[start:]...)
219+ break
220+ }
221+ end := start + 3 + endOffset + 2
222+ record := data[start:end]
223+ body := record[3 : len(record)-2]
224+ if !bytes.Contains(body, []byte("i=0,I=")) || !bytes.HasSuffix(body, []byte(";ENOTSUP")) {
225+ out = append(out, record...)
226+ }
227+ data = data[end:]
228+ }
229+ return out
230+}
231+
198232 // applyPanePTYEffects emits only effects still bound to the owner generation
199233 // that parsed them. Each owner-sensitive boundary revalidates independently so
200234 // ownership publication can retire the remaining effects without rerouting
M internal/usecase/daemon/render_capture.go +98 -16
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 renderer "github.com/bnema/vev-vt"
5+ vevgraphics "github.com/bnema/vev-vt/graphics"
56 "github.com/bnema/vev/internal/domain"
67 scopy "github.com/bnema/vev/internal/usecase/copy"
78 "github.com/bnema/vev/internal/usecase/layout"
@@ -22,6 +23,7 @@ type renderCaptureScratch struct {
2223 placements []layout.Placement
2324 dividers []layout.Divider
2425 layoutTab *tab
26+ layoutRoot *layout.Node
2527 layoutGeneration uint64
2628 layoutArea domain.Rect
2729 layoutFocus layout.PaneID
@@ -40,19 +42,25 @@ type damageReceipt struct {
4042 }
4143
4244 type capturedRenderState struct {
43- attachment *attachedClient // identity only; never dereferenced by composition
44- lease *attachmentLease
45- view attachmentView
46- window domain.Size
47- reset bool
48- layout capturedTabLayout
49- panes []capturedPaneRenderState
50- floating capturedFloatingRenderState
51- bars barState
52- theme themeui.Theme
53- styles themeui.Styles
54- styleGeneration uint64
55- overlays capturedOverlayRenderState
45+ attachment *attachedClient // identity only; never dereferenced by composition
46+ sessionID domain.SessionID
47+ incarnation domain.IncarnationID
48+ lease *attachmentLease
49+ view attachmentView
50+ window domain.Size
51+ reset bool
52+ layout capturedTabLayout
53+ panes []capturedPaneRenderState
54+ floating capturedFloatingRenderState
55+ bars barState
56+ theme themeui.Theme
57+ styles themeui.Styles
58+ styleGeneration uint64
59+ overlays capturedOverlayRenderState
60+ // suppressedGraphics records a late Kitty scene observed on an attachment
61+ // that cannot emit graphics. Capture keeps the scene out of the output but
62+ // lets the daemon issue one bounded user warning after pane locks release.
63+ suppressedGraphics bool
5664 preview picker.Preview
5765 cursor capturedCursorInputs
5866 tabGeneration uint64
@@ -71,10 +79,15 @@ type capturedTabLayout struct {
7179
7280 type capturedPaneRenderState struct {
7381 id layout.PaneID
82+ stableID domain.PaneStableID
7483 frame renderer.Frame
7584 rawDamage []renderer.Damage
7685 damage []renderer.Damage
7786 damageGeneration uint64
87+ // graphics is an immutable copy-on-write reference owned by the VT screen.
88+ // It is nil for ordinary text panes, preserving the fast text path.
89+ graphics *vevgraphics.Snapshot
90+ graphicsGeometry domain.Geometry
7891 title string
7992 titleGeneration uint64
8093 placement layout.Placement
@@ -121,6 +134,33 @@ type capturedCursorInputs struct {
121134 content domain.Rect
122135 }
123136
137+// writePaneScreenLocked is the production VT write boundary. The first
138+// placement fixes the current scene's coordinate units before a later resize
139+// can change Screen.Geometry; deleting the last placement ends that boundary.
140+// Caller holds p.mu.
141+func writePaneScreenLocked(p *pane, data []byte) {
142+ if p == nil || p.screen == nil {
143+ return
144+ }
145+ before := p.screen.GraphicsSnapshot()
146+ beforeHasPlacements := before != nil && before.Usage().Placements != 0
147+ p.screen.Write(data)
148+ after := p.screen.GraphicsSnapshot()
149+ afterHasPlacements := after != nil && after.Usage().Placements != 0
150+ if !beforeHasPlacements && afterHasPlacements {
151+ geometry := p.screen.Geometry()
152+ p.graphicsCoordinateGeometry = domain.Geometry{
153+ Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
154+ PixelWidth: geometry.PixelWidth,
155+ PixelHeight: geometry.PixelHeight,
156+ }.NormalizePixels()
157+ }
158+ p.graphicsPlacementScene = afterHasPlacements
159+ if !afterHasPlacements {
160+ p.graphicsCoordinateGeometry = domain.Geometry{}
161+ }
162+}
163+
124164 // capturePaneRenderStateLocked copies only the visible rectangle required by
125165 // composition. It never copies scrollback/history and never lets the mutable
126166 // VT frame's Cells or row-offset slices escape pane.mu.
@@ -129,7 +169,30 @@ func capturePaneRenderStateLocked(p *pane, visible domain.Rect) capturedPaneRend
129169 }
130170
131171 func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
132- out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
172+ out.id, out.stableID, out.title, out.titleGeneration = p.id, domain.PaneStableID(p.stableID), p.displayTitleLocked(), p.title.generation
173+ // GraphicsSnapshot never allocates graphics state for a text-only screen and
174+ // returns an immutable scene reference when Kitty graphics were used.
175+ out.graphics = p.screen.GraphicsSnapshot()
176+ geometry := p.screen.Geometry()
177+ currentGraphicsGeometry := domain.Geometry{
178+ Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
179+ PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
180+ }.NormalizePixels()
181+ // The scene retains placement coordinates across SetGeometry. Capture the
182+ // unit basis when the first placement appears and keep it until the placement
183+ // scene becomes empty; otherwise an attachment resize silently reinterprets
184+ // old pixel coordinates using the new cell dimensions.
185+ hasPlacements := out.graphics != nil && out.graphics.Usage().Placements != 0
186+ if hasPlacements && !p.graphicsPlacementScene {
187+ p.graphicsCoordinateGeometry = currentGraphicsGeometry
188+ }
189+ p.graphicsPlacementScene = hasPlacements
190+ if hasPlacements {
191+ out.graphicsGeometry = p.graphicsCoordinateGeometry
192+ } else {
193+ p.graphicsCoordinateGeometry = domain.Geometry{}
194+ out.graphicsGeometry = currentGraphicsGeometry
195+ }
133196 damage := p.screen.CaptureDamage()
134197 out.damageGeneration = damage.Generation
135198 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
@@ -212,6 +275,8 @@ func captureLocalRenderState(
212275 }
213276 sess.mu.Lock()
214277 _, owned := sess.attachments[ac]
278+ sessionID := sess.id
279+ incarnation := sess.incarnation
215280 sess.mu.Unlock()
216281 if !owned {
217282 return nil, false
@@ -257,12 +322,17 @@ func captureLocalRenderState(
257322 area: area, focus: focus, placements: scratch.placements,
258323 dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
259324 }
260- if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
325+ var layoutRoot *layout.Node
326+ if tb.tree != nil {
327+ layoutRoot = tb.tree.Root
328+ }
329+ if scratch.layoutTab != tb || scratch.layoutRoot != layoutRoot || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
261330 layoutSnap = solveTabLayoutLocked(tb)
262331 layoutSnap.focus = focus
263332 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
264333 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
265334 scratch.layoutTab = tb
335+ scratch.layoutRoot = layoutRoot
266336 scratch.layoutGeneration = tb.layoutGeneration
267337 scratch.layoutArea = area
268338 scratch.layoutFocus = focus
@@ -278,7 +348,7 @@ func captureLocalRenderState(
278348 window = ac.sizeSnapshot()
279349 }
280350 *state = capturedRenderState{
281- attachment: ac, lease: lease, view: view, window: window,
351+ attachment: ac, sessionID: sessionID, incarnation: incarnation, lease: lease, view: view, window: window,
282352 reset: reset, bars: bars, theme: bars.theme,
283353 styles: request.styles, styleGeneration: request.styleGeneration,
284354 overlays: overlays, preview: preview,
@@ -302,6 +372,12 @@ func captureLocalRenderState(
302372 }
303373 p.mu.Lock()
304374 captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])
375+ if !ac.terminalCapabilities.SupportsKittyGraphics() {
376+ if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
377+ state.suppressedGraphics = true
378+ }
379+ captured.graphics = nil
380+ }
305381 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
306382 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
307383 if captured.focused {
@@ -322,6 +398,12 @@ func captureLocalRenderState(
322398 p.mu.Lock()
323399 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
324400 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])
401+ if !ac.terminalCapabilities.SupportsKittyGraphics() {
402+ if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
403+ state.suppressedGraphics = true
404+ }
405+ captured.graphics = nil
406+ }
325407 seen := false
326408 for _, receipt := range state.receipts {
327409 if receipt.pane == p {
M internal/usecase/daemon/render_pipeline.go +8 -1
@@ -436,7 +436,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
436436 }
437437 }
438438 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
439- prepared, err := ac.output.prepareFrame(composed.frame, composed.damage, composed.reset, composed.cursor)
439+ prepared, err := ac.output.prepareFrame(d, state, composed.frame, composed.damage, composed.reset, composed.cursor)
440440 var data []byte
441441 if prepared != nil {
442442 data = prepared.data
@@ -468,6 +468,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
468468 }
469469 var sendTr ports.Transport
470470 var sendErr error
471+ suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics()
471472 if len(data) > 0 {
472473 sendTransport := ac.transportSnapshot()
473474 sendTr = sendTransport.transport
@@ -502,6 +503,9 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
502503 ac.sendMu.Unlock()
503504 return true
504505 }
506+ if sendErr != nil {
507+ prepared.abort()
508+ }
505509 }
506510 if sendErr == nil {
507511 if len(data) == 0 {
@@ -564,6 +568,9 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
564568 }
565569 }
566570 ac.sendMu.Unlock()
571+ if suppressedGraphics && d.warnUnsupportedGraphics(ac) {
572+ d.repaintForNotice(ac)
573+ }
567574 if sendErr != nil {
568575 // A transport failure may invalidate the role gate. Release this render's
569576 // admission first. Detachment freezes the gate and therefore cannot mutate
M internal/usecase/daemon/render_pipeline_test.go +1 -1
@@ -330,7 +330,7 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
330330 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
331331
332332 stream := newOutputStateStream()
333- prepared, err := stream.prepareFrame(composed.frame, composed.damage, composed.reset, composed.cursor)
333+ prepared, err := stream.prepareFrame(nil, &state, composed.frame, composed.damage, composed.reset, composed.cursor)
334334 require.NoError(t, err)
335335 var outputFrame ports.Frame
336336 require.NoError(t, prepared.send(0, func(frame ports.Frame) error {
M internal/usecase/daemon/render_test.go +44 -3
@@ -991,10 +991,10 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
991991 d.waitNotifies()
992992 }
993993
994-func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
994+func TestPTYKittyIcatDetectionGetsResponsesWrittenBackToPTY(t *testing.T) {
995995 d := newTestDaemon(t, nil, stubClock{})
996996 p := portsmocks.NewMockPTY(t)
997- chunks := [][]byte{[]byte("\x1b[6n")}
997+ chunks := [][]byte{[]byte("\x1b_Ga=q,f=24,s=1,v=1,S=3,i=1;MTIz\x1b\\\x1b_Ga=q,f=24,t=t,s=1,v=1,S=47,i=2;L2Rldi9zaG0va2l0dHktdHR5LWdyYXBoaWNzLXByb3RvY29sLTMzMTU3NTkxNjc\x1b\\\x1b_Ga=q,f=24,t=s,s=1,v=1,S=18,i=3;aWNhdC1aQlJCWFdNQ0lIQ0ZD\x1b\\\x1b[c")}
998998 writes := make(chan []byte, 1)
999999 p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
10001000 if len(chunks) == 0 {
@@ -1023,7 +1023,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
10231023
10241024 select {
10251025 case got := <-writes:
1026- require.Equal(t, []byte("\x1b[1;1R"), got)
1026+ require.Equal(t, []byte("\x1b_Gi=1;OK\x1b\\\x1b_Gi=2;ENOTSUP\x1b\\\x1b_Gi=3;ENOTSUP\x1b\\\x1b[?62;22c"), got)
10271027 case <-time.After(time.Second):
10281028 t.Fatal("timed out waiting for PTY response write")
10291029 }
@@ -1034,6 +1034,47 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
10341034 }
10351035 }
10361036
1037+func TestPTYKittyAnimationFallbackKeepsProtocolErrorsOutOfShell(t *testing.T) {
1038+ d := newTestDaemon(t, nil, stubClock{})
1039+ p := portsmocks.NewMockPTY(t)
1040+ const imageNumber = "622840581"
1041+ chunks := [][]byte{[]byte(
1042+ "\x1b_Ga=T,q=1,f=24,s=1,v=1,I=" + imageNumber + ";AAAA\x1b\\" +
1043+ "\x1b_Ga=f,q=1,f=24,s=1,v=1,I=" + imageNumber + ";AAAA\x1b\\" +
1044+ "\x1b_Ga=a,q=1,s=3,I=" + imageNumber + ";\x1b\\",
1045+ )}
1046+ writes := make(chan []byte, 1)
1047+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1048+ if len(chunks) == 0 {
1049+ return 0, io.EOF
1050+ }
1051+ n := copy(buf, chunks[0])
1052+ chunks = chunks[1:]
1053+ return n, nil
1054+ })
1055+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
1056+ writes <- append([]byte(nil), b...)
1057+ return len(b), nil
1058+ }).Maybe()
1059+ p.EXPECT().Close().Return(nil).Maybe()
1060+
1061+ sctx, cancel := context.WithCancel(context.Background())
1062+ win := newTestTabWithContext(p, sctx, cancel)
1063+ sess := &session{sessionCore: sessionCore{id: "animation", name: "animation"}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1064+ publishTiledPaneOwners(sess, win)
1065+ d.sessWg.Add(1)
1066+ d.ptyReader(sess, win, win.focusedPane())
1067+
1068+ select {
1069+ case got := <-writes:
1070+ t.Fatalf("unsupported animation response leaked into shell input: %q", got)
1071+ default:
1072+ }
1073+ snapshot := win.focusedPane().screen.GraphicsSnapshot()
1074+ require.NotNil(t, snapshot)
1075+ require.Equal(t, uint64(1), snapshot.Usage().Placements, "the supported first frame remains as a static fallback")
1076+}
1077+
10371078 func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
10381079 _, sess, _, _ := newManualSessionWithPTYs(t, nil)
10391080 p := sess.tabs[0].focusedPane()
M internal/usecase/daemon/resume.go +11 -2
@@ -304,10 +304,11 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
304304 d.mu.Unlock()
305305 }
306306
307-// removeParkedLocked invalidates one parked attachment. Caller holds d.mu and
308-// has verified d.parked[token] still points at parked when that matters.
307+// removeParkedLocked invalidates one non-nil parked attachment. Caller holds
308+// d.mu and has verified d.parked[token] still points at parked when that matters.
309309 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
310310 delete(d.parked, token)
311+ d.discardAttachmentOutputLocked(parked.ac)
311312 parked.ac.resumeToken = 0
312313 parked.ac.parked = false
313314 if parked.timer != nil {
@@ -410,6 +411,7 @@ type parkedAttachmentRetirement struct {
410411
411412 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
412413 delete(d.parked, token)
414+ d.discardAttachmentOutputLocked(parked.ac)
413415 parked.ac.resumeToken = 0
414416 parked.ac.parked = false
415417 parked.ac.connectionGeneration.Add(1)
@@ -602,6 +604,9 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
602604 // complete the claiming terminal's geometry before the resumed
603605 // attachment is allowed to produce its first frame.
604606 d.recalculateSessionGeometry(sess, resumed)
607+ // A resumed unsupported attachment bypasses finishAttachedClient. Check
608+ // the restored scene at this boundary so suppression is explained once.
609+ d.warnUnsupportedGraphics(resumed)
605610 }
606611 if err != nil && resumed != nil {
607612 d.abortResumeClaim(resumed)
@@ -646,6 +651,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
646651 ac.rebaseOutput()
647652 ac.output.setWindow(h.MaxOutputInFlight)
648653 ac.replaceTransport(tr)
654+ // A parked attachment can resume through any route. Reapply the declared
655+ // direct-terminal capability before resumed capture or first paint observes
656+ // the replacement transport.
657+ d.reconfigureAttachmentOutput(sess, ac, h)
649658 geometry := h.Geometry()
650659 if geometry.Size != sz {
651660 geometry = domain.Geometry{Size: sz}
M internal/usecase/daemon/session.go +3 -0
@@ -1828,6 +1828,9 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
18281828 }
18291829
18301830 for _, attachment := range attachments {
1831+ // The transport is still available after membership publication. Retire
1832+ // attachment-owned terminal output before sending Detached.
1833+ d.cleanupAttachmentOutput(attachment.ac)
18311834 d.notifyDetachedSnapshotAsync(attachment, reason)
18321835 }
18331836 return errors.Join(purgeErr, terminalSnapshotErr)
A internal/usecase/daemon/testdata/kitten-icat-stream-chunk.bin +1 -0 too large to show in the browser

too large to show in the browser view raw

M internal/usecase/daemon/transactional_resize.go +1 -1
@@ -416,7 +416,7 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo
416416 p.mu.Unlock()
417417 if sess == nil {
418418 p.mu.Lock()
419- p.screen.Write(data)
419+ writePaneScreenLocked(p, data)
420420 p.refreshTerminalTitleLocked()
421421 p.mu.Unlock()
422422 continue

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