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`,
6 6
7 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.7 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.
8 8
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+
9 ## Environment30 ## Environment
10 31
11 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.32 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 (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 3515+const ProtocolVersion uint16 = 36
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
19 const MaxFrameLen = 16 << 2019 const MaxFrameLen = 16 << 20
20 20
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+
21 // MsgType identifies the kind of payload carried by a Frame.25 // MsgType identifies the kind of payload carried by a Frame.
22 type MsgType uint826 type MsgType uint8
23 27
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 35 {6+ if ProtocolVersion != 36 {
7- t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/navigation_test.go +1 -1
@@ -152,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
153 {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},153 {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},
154 {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},154 {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"},
156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},158 {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) {
115 })115 })
116 },116 },
117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118- policyOffset: 8,118+ policyOffset: 10,
119 wantErr: ErrInvalidHello,119 wantErr: ErrInvalidHello,
120 },120 },
121 {121 {
M internal/ports/terminal.go +18 -7
@@ -52,9 +52,16 @@ const (
52 // attachment. Pane processes must not derive their terminal environment from52 // attachment. Pane processes must not derive their terminal environment from
53 // these capabilities.53 // these capabilities.
54 type TerminalCapabilities struct {54 type TerminalCapabilities struct {
55- ColorMode TerminalColorMode55+ ColorMode TerminalColorMode
56- ColorSource TerminalCapabilitySource56+ ColorSource TerminalCapabilitySource
57- Application TerminalApplication57+ 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
58 }65 }
59 66
60 // TrueColor reports whether this attachment can receive RGB ANSI output.67 // TrueColor reports whether this attachment can receive RGB ANSI output.
@@ -75,20 +82,24 @@ func DetectTerminalCapabilities(env []string) TerminalCapabilities {
75 caps.Application = TerminalApplicationKitty82 caps.Application = TerminalApplicationKitty
76 }83 }
77 84
85+ kittyIdentity := term == "xterm-kitty" && caps.Application == TerminalApplicationKitty
78 switch colorTerm {86 switch colorTerm {
79 case "truecolor", "24bit":87 case "truecolor", "24bit":
80 caps.ColorMode = TerminalColorTrueColor88 caps.ColorMode = TerminalColorTrueColor
81 caps.ColorSource = TerminalCapabilityDeclared89 caps.ColorSource = TerminalCapabilityDeclared
82- return caps
83 }90 }
84 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {91 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
85 caps.ColorMode = TerminalColorTrueColor92 caps.ColorMode = TerminalColorTrueColor
86 caps.ColorSource = TerminalCapabilityDeclared93 caps.ColorSource = TerminalCapabilityDeclared
87- return caps
88 }94 }
89- if term == "xterm-kitty" && caps.Application == TerminalApplicationKitty {95+ if kittyIdentity {
96+ if caps.ColorSource == TerminalCapabilityUnknown {
97+ caps.ColorSource = TerminalCapabilityHeuristic
98+ }
90 caps.ColorMode = TerminalColorTrueColor99 caps.ColorMode = TerminalColorTrueColor
91- caps.ColorSource = TerminalCapabilityHeuristic100+ return caps
101+ }
102+ if caps.ColorSource == TerminalCapabilityDeclared {
92 return caps103 return caps
93 }104 }
94 if strings.Contains(term, "256color") || term == "dumb" {105 if strings.Contains(term, "256color") || term == "dumb" {
M internal/ports/terminal_test.go +14 -5
@@ -8,11 +8,12 @@ import (
8 8
9 func TestDetectTerminalCapabilities(t *testing.T) {9 func TestDetectTerminalCapabilities(t *testing.T) {
10 tests := []struct {10 tests := []struct {
11- name string11+ name string
12- env []string12+ env []string
13- wantMode TerminalColorMode13+ wantMode TerminalColorMode
14- wantSource TerminalCapabilitySource14+ wantSource TerminalCapabilitySource
15- wantApp TerminalApplication15+ wantApp TerminalApplication
16+ wantGraphics bool
16 }{17 }{
17 {18 {
18 name: "declared truecolor",19 name: "declared truecolor",
@@ -27,6 +28,13 @@ func TestDetectTerminalCapabilities(t *testing.T) {
27 wantSource: TerminalCapabilityHeuristic,28 wantSource: TerminalCapabilityHeuristic,
28 wantApp: TerminalApplicationKitty,29 wantApp: TerminalApplicationKitty,
29 },30 },
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+ },
30 {38 {
31 name: "256 color terminal remains a constrained attachment",39 name: "256 color terminal remains a constrained attachment",
32 env: []string{"TERM=xterm-256color"},40 env: []string{"TERM=xterm-256color"},
@@ -54,6 +62,7 @@ func TestDetectTerminalCapabilities(t *testing.T) {
54 require.Equal(t, tt.wantMode, got.ColorMode)62 require.Equal(t, tt.wantMode, got.ColorMode)
55 require.Equal(t, tt.wantSource, got.ColorSource)63 require.Equal(t, tt.wantSource, got.ColorSource)
56 require.Equal(t, tt.wantApp, got.Application)64 require.Equal(t, tt.wantApp, got.Application)
65+ require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
57 })66 })
58 }67 }
59 }68 }
M internal/ports/wire.go +24 -2
@@ -174,6 +174,10 @@ type Hello struct {
174 TermEnv string174 TermEnv string
175 Cwd string175 Cwd string
176 TrueColor bool176 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
177 // MaxOutputInFlight is the requested maximum number of unacknowledged181 // MaxOutputInFlight is the requested maximum number of unacknowledged
178 // state-bearing output frames.182 // state-bearing output frames.
179 MaxOutputInFlight uint8183 MaxOutputInFlight uint8
@@ -194,6 +198,10 @@ type Hello struct {
194 EnvironmentPolicy EnvironmentPolicy198 EnvironmentPolicy EnvironmentPolicy
195 NavigationCapabilities NavigationCapabilities199 NavigationCapabilities NavigationCapabilities
196 StartupOverlay StartupOverlay200 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
197 }205 }
198 206
199 // Input carries raw bytes typed/pasted by the client, destined for the PTY.207 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -823,7 +831,7 @@ func ValidateOutput(m Output) error {
823 if err := ValidateSize(m.Size); err != nil {831 if err := ValidateSize(m.Size); err != nil {
824 return fmt.Errorf("%w: size", ErrInvalidOutput)832 return fmt.Errorf("%w: size", ErrInvalidOutput)
825 }833 }
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 {
827 return ErrInvalidOutput835 return ErrInvalidOutput
828 }836 }
829 return nil837 return nil
@@ -912,6 +920,8 @@ func MarshalHello(h Hello) []byte {
912 w.putString(string(h.PreferredTabID))920 w.putString(string(h.PreferredTabID))
913 w.putUint8(uint8(h.NavigationCapabilities))921 w.putUint8(uint8(h.NavigationCapabilities))
914 w.putUint8(uint8(h.StartupOverlay))922 w.putUint8(uint8(h.StartupOverlay))
923+ w.putBool(h.Remote)
924+ w.putBool(h.KittyDirectGraphics)
915 return w.b925 return w.b
916 }926 }
917 927
@@ -988,6 +998,12 @@ func preflightHello(b []byte) error {
988 if _, err := r.getUint8(); err != nil {998 if _, err := r.getUint8(); err != nil {
989 return err999 return err
990 }1000 }
1001+ if _, err := r.getBool(); err != nil {
1002+ return err
1003+ }
1004+ if _, err := r.getBool(); err != nil {
1005+ return err
1006+ }
991 return r.done()1007 return r.done()
992 }1008 }
993 1009
@@ -1091,6 +1107,12 @@ func UnmarshalHello(b []byte) (Hello, error) {
1091 return Hello{}, err1107 return Hello{}, err
1092 }1108 }
1093 h.StartupOverlay = StartupOverlay(overlay)1109 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+ }
1094 if err := r.done(); err != nil {1116 if err := r.done(); err != nil {
1095 return Hello{}, err1117 return Hello{}, err
1096 }1118 }
@@ -1568,7 +1590,7 @@ func UnmarshalOutput(b []byte) (Output, error) {
1568 if err != nil {1590 if err != nil {
1569 return Output{}, err1591 return Output{}, err
1570 }1592 }
1571- if int64(decodedLen) > int64(MaxFrameLen-1-outputPayloadOverhead) {1593+ if int64(decodedLen) > int64(MaxOutputDataLen) {
1572 return Output{}, ErrInvalidOutput1594 return Output{}, ErrInvalidOutput
1573 }1595 }
1574 data, err := r.getLongBytes()1596 data, err := r.getLongBytes()
M internal/ports/wire_final_test.go +37 -3
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 35 {14+ if ProtocolVersion != 36 {
15- t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,9 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 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)...)
30 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)30 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)
31 got := MarshalHello(msg)32 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {33 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)34 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -43,6 +44,21 @@ func TestFinalHelloGoldenStrict(t *testing.T) {
43 assertTrailingGarbageFails(t, got, UnmarshalHello)44 assertTrailingGarbageFails(t, got, UnmarshalHello)
44 }45 }
45 46
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+
46 func TestFinalHelloSemanticValidation(t *testing.T) {62 func TestFinalHelloSemanticValidation(t *testing.T) {
47 valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}63 valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
48 for _, tt := range []struct {64 for _, tt := range []struct {
@@ -250,6 +266,24 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
250 }(),266 }(),
251 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },267 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
252 },268 },
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+ },
253 {287 {
254 name: "welcome boolean",288 name: "welcome boolean",
255 payload: func() []byte {289 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
55 55
56 func appendNoNavigationTail(payload []byte) []byte {56 func appendNoNavigationTail(payload []byte) []byte {
57 // exact-target absent, preferred-tab absent, navigation capabilities none,57 // exact-target absent, preferred-tab absent, navigation capabilities none,
58- // startup overlay none.58+ // startup overlay none, local carriage.
59- return append(payload, 0, 0, 0, 0, 0)59+ return append(payload, 0, 0, 0, 0, 0, 0, 0)
60 }60 }
61 61
62 func TestHelloGoldenAndRoundTrip(t *testing.T) {62 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -135,13 +135,13 @@ func TestHelloEnvironmentCodec(t *testing.T) {
135 }135 }
136 got := MarshalHello(msg)136 got := MarshalHello(msg)
137 want := []byte{137 want := []byte{
138- 0x00, 0x23, 0x00, // version, intent, first client ID byte138+ 0x00, 0x24, 0x00, // version, intent, first client ID byte
139 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,139 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
142 0x00, 0x00, // name142 0x00, 0x00, // name
143 0x00, 0x01, 0x00, 0x01, // size143 0x00, 0x01, 0x00, 0x01, // size
144- 0x00, 0x00, 0x00, 0x00, // optional pixel size144+ 0x00, 0x00, 0x00, 0x00, // pixel width and height
145 0x00, 0x00, // TERM145 0x00, 0x00, // TERM
146 0x00, 0x00, // cwd146 0x00, 0x00, // cwd
147 0x00, 0x00, // true color, max output in flight147 0x00, 0x00, // true color, max output in flight
@@ -178,15 +178,15 @@ func TestHelloEnvironmentCodec(t *testing.T) {
178 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})178 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
179 withCount := func(count byte) []byte {179 withCount := func(count byte) []byte {
180 payload := append([]byte(nil), base...)180 payload := append([]byte(nil), base...)
181- // The uint32 environment count occupies bytes len-11 through len-8.181+ // The uint32 environment count occupies bytes len-13 through len-10.
182- payload[len(payload)-8] = count182+ payload[len(payload)-10] = count
183 return payload183 return payload
184 }184 }
185 withEntries := func(count byte, entries ...byte) []byte {185 withEntries := func(count byte, entries ...byte) []byte {
186 payload := withCount(count)186 payload := withCount(count)
187- // The seven-byte tail follows the environment-count field.187+ // The nine-byte tail follows the environment-count field.
188- tail := append([]byte(nil), payload[len(payload)-7:]...)188+ tail := append([]byte(nil), payload[len(payload)-9:]...)
189- payload = append(payload[:len(payload)-7], entries...)189+ payload = append(payload[:len(payload)-9], entries...)
190 return append(payload, tail...)190 return append(payload, tail...)
191 }191 }
192 tests := []struct {192 tests := []struct {
@@ -366,7 +366,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
366 }366 }
367 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)367 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
368 require.Equal(t, want, MarshalTheme(cleared))368 require.Equal(t, want, MarshalTheme(cleared))
369- require.Equal(t, uint16(35), ProtocolVersion)369+ require.Equal(t, uint16(36), ProtocolVersion)
370 }370 }
371 371
372 func TestResizeGoldenAndRoundTrip(t *testing.T) {372 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -527,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
527 name: "minimal",527 name: "minimal",
528 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},528 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
529 want: []byte{529 want: []byte{
530- 0x00, 0x23,530+ 0x00, 0x24,
531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
532 0x00, // attached532 0x00, // attached
533 0x00, // self533 0x00, // self
@@ -550,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
550 JSON: true,550 JSON: true,
551 },551 },
552 want: []byte{552 want: []byte{
553- 0x00, 0x23,553+ 0x00, 0x24,
554 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID554 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
555 0x00, // attached555 0x00, // attached
556 0x01, // self556 0x01, // self
@@ -947,7 +947,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
947 for _, window := range []uint8{0, 1, 8} {947 for _, window := range []uint8{0, 1, 8} {
948 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {948 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
949 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}949 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.
951 want := append(make([]byte, 42), window, 0, 0, 0, 0, 0, 0)951 want := append(make([]byte, 42), window, 0, 0, 0, 0, 0, 0)
952 want = appendNoNavigationTail(want)952 want = appendNoNavigationTail(want)
953 want[1] = 14953 want[1] = 14
@@ -979,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
979 name: "attached",979 name: "attached",
980 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},980 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
981 want: []byte{981 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,
983 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',983 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
984 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',984 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
985 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,985 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
989 name: "control",989 name: "control",
990 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},990 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
991 want: []byte{991 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,
993 0x00, 0x02, 'l', 's', 0x00, 0x00,993 0x00, 0x02, 'l', 's', 0x00, 0x00,
994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
995 },995 },
M internal/usecase/client/client.go +131 -16
@@ -20,6 +20,7 @@ import (
20 "time"20 "time"
21 21
22 renderer "github.com/bnema/vev-vt"22 renderer "github.com/bnema/vev-vt"
23+ "github.com/bnema/vev-vt/protocol/terminalquery"
23 "github.com/bnema/vev/internal/domain"24 "github.com/bnema/vev/internal/domain"
24 "github.com/bnema/vev/internal/ports"25 "github.com/bnema/vev/internal/ports"
25 "github.com/bnema/vev/internal/usecase/theme"26 "github.com/bnema/vev/internal/usecase/theme"
@@ -138,6 +139,8 @@ const preWelcomeTimeout = ports.HandshakeTimeout
138 // learner must not block shell prompt return indefinitely.139 // learner must not block shell prompt return indefinitely.
139 const remoteHostLearnerShutdownTimeout = time.Second140 const remoteHostLearnerShutdownTimeout = time.Second
140 141
142+const kittyCapabilityProbeTimeout = 150 * time.Millisecond
143+
141 var reconnectSleep = sleepReconnect144 var reconnectSleep = sleepReconnect
142 var reconnectSleepWithResize = sleepReconnectWithResizeEvents145 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
143 146
@@ -150,13 +153,16 @@ const (
150 type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error)153 type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error)
151 154
152 type Dependencies struct {155 type Dependencies struct {
153- Dialer ports.Dialer156+ Dialer ports.Dialer
154- Terminal ports.Terminal157+ Terminal ports.Terminal
155- Clock ports.Clock158+ Clock ports.Clock
156- Clipboard ports.ClipboardReader159+ // DisableCapabilityProbe skips active outer-terminal discovery for headless
157- Logger *slog.Logger160+ // embedders and deterministic tests. Interactive clients leave it false.
158- RuntimeObserver ports.SerializedRuntimeObserver161+ DisableCapabilityProbe bool
159- RemoteHostLearner ports.RemoteHostLearner162+ Clipboard ports.ClipboardReader
163+ Logger *slog.Logger
164+ RuntimeObserver ports.SerializedRuntimeObserver
165+ RemoteHostLearner ports.RemoteHostLearner
160 // AttachHandoff keeps one Runner and one terminal/input ownership across a166 // AttachHandoff keeps one Runner and one terminal/input ownership across a
161 // local daemon's structured remote handoff. It is nil for direct CLI attach.167 // local daemon's structured remote handoff. It is nil for direct CLI attach.
162 AttachHandoff AttachHandoffFunc168 AttachHandoff AttachHandoffFunc
@@ -211,6 +217,10 @@ type Runner struct {
211 217
212 inputMu sync.Mutex218 inputMu sync.Mutex
213 input *terminalInputPump219 input *terminalInputPump
220+
221+ capabilityMu sync.Mutex
222+ kittyDirectGraphics *bool
223+ probeCapabilities bool
214 }224 }
215 225
216 // NewRunner constructs a client runner. A nil logger uses the process default.226 // NewRunner constructs a client runner. A nil logger uses the process default.
@@ -230,6 +240,7 @@ func NewRunner(deps Dependencies) *Runner {
230 attachHandoff: deps.AttachHandoff,240 attachHandoff: deps.AttachHandoff,
231 remote: deps.Remote,241 remote: deps.Remote,
232 origin: normalizeRouteOrigin(deps.Origin, deps.Remote),242 origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
243+ probeCapabilities: !deps.DisableCapabilityProbe,
233 ledger: newRouteLedger(),244 ledger: newRouteLedger(),
234 }245 }
235 }246 }
@@ -249,6 +260,99 @@ func (r *Runner) terminalInput() *terminalInputPump {
249 return r.input260 return r.input
250 }261 }
251 262
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+
252 func validateAttachRequest(request AttachRequest) error {356 func validateAttachRequest(request AttachRequest) error {
253 if request.Origin != 0 {357 if request.Origin != 0 {
254 if err := request.Origin.Validate(); err != nil {358 if err := request.Origin.Validate(); err != nil {
@@ -1083,7 +1187,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1083 }1187 }
1084 return nil1188 return nil
1085 }1189 }
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+ }
1087 geometry, err := term.Geometry()1200 geometry, err := term.Geometry()
1088 if err != nil {1201 if err != nil {
1089 return attachResult{err: fmt.Errorf("vev: reading terminal geometry: %w", err)}1202 return attachResult{err: fmt.Errorf("vev: reading terminal geometry: %w", err)}
@@ -1117,6 +1230,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1117 TermEnv: termEnv,1230 TermEnv: termEnv,
1118 Cwd: cwd,1231 Cwd: cwd,
1119 TrueColor: trueColor,1232 TrueColor: trueColor,
1233+ KittyDirectGraphics: kittyDirectGraphics,
1120 MaxOutputInFlight: requestedOutputWindow(transport),1234 MaxOutputInFlight: requestedOutputWindow(transport),
1121 Env: os.Environ(),1235 Env: os.Environ(),
1122 RemoteTarget: request.RemoteTarget,1236 RemoteTarget: request.RemoteTarget,
@@ -1125,6 +1239,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1125 EnvironmentPolicy: request.EnvironmentPolicy,1239 EnvironmentPolicy: request.EnvironmentPolicy,
1126 NavigationCapabilities: request.NavigationCapabilities,1240 NavigationCapabilities: request.NavigationCapabilities,
1127 StartupOverlay: request.StartupOverlay,1241 StartupOverlay: request.StartupOverlay,
1242+ Remote: remote,
1128 }1243 }
1129 if err := sendHandshake(func() error {1244 if err := sendHandshake(func() error {
1130 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})1245 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
@@ -1508,12 +1623,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1508 defer clearParkedPending()1623 defer clearParkedPending()
1509 1624
1510 sendParkedRouteSize := func() error {1625 sendParkedRouteSize := func() error {
1511- current, err := term.Geometry()1626+ geometry, err := term.Geometry()
1512 if err != nil {1627 if err != nil {
1513 return fmt.Errorf("reading terminal geometry for parked route: %w", err)1628 return fmt.Errorf("reading terminal geometry for parked route: %w", err)
1514 }1629 }
1515- current = current.NormalizePixels()1630+ geometry = geometry.NormalizePixels()
1516- payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})1631+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
1517 if err != nil {1632 if err != nil {
1518 return fmt.Errorf("encoding terminal size for parked route: %w", err)1633 return fmt.Errorf("encoding terminal size for parked route: %w", err)
1519 }1634 }
@@ -1605,12 +1720,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1605 if awaitingReconnectReset {1720 if awaitingReconnectReset {
1606 return nil1721 return nil
1607 }1722 }
1608- current, serr := term.Geometry()1723+ geometry, serr := term.Geometry()
1609 if serr != nil {1724 if serr != nil {
1610 return fmt.Errorf("reading terminal geometry for reconnect reconciliation: %w", serr)1725 return fmt.Errorf("reading terminal geometry for reconnect reconciliation: %w", serr)
1611 }1726 }
1612- current = current.NormalizePixels()1727+ geometry = geometry.NormalizePixels()
1613- payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})1728+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
1614 if err != nil {1729 if err != nil {
1615 return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)1730 return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)
1616 }1731 }
@@ -1798,11 +1913,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1798 awaitingReconnectReset = false1913 awaitingReconnectReset = false
1799 }1914 }
1800 if reconnect.showing && reconnect.remote {1915 if reconnect.showing && reconnect.remote {
1801- current, serr := term.Geometry()1916+ geometry, serr := term.Geometry()
1802 if serr != nil {1917 if serr != nil {
1803 return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))1918 return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))
1804 }1919 }
1805- if rerr := reconnect.redraw(current.Size); rerr != nil {1920+ if rerr := reconnect.redraw(geometry.Size); rerr != nil {
1806 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))1921 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))
1807 }1922 }
1808 } else if ferr := term.Flush(); ferr != nil {1923 } 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
250 250
251 func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {251 func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {
252 return client.Dependencies{252 return client.Dependencies{
253- Dialer: dialer,253+ Dialer: dialer,
254- Terminal: terminal,254+ Terminal: terminal,
255- Clock: clock,255+ Clock: clock,
256- Clipboard: clipboard,256+ DisableCapabilityProbe: true,
257- Logger: slog.New(slog.DiscardHandler),257+ Clipboard: clipboard,
258- RuntimeObserver: observer,258+ Logger: slog.New(slog.DiscardHandler),
259+ RuntimeObserver: observer,
259 }260 }
260 }261 }
261 262
M internal/usecase/client/clipboard_intercept_test.go +1 -1
@@ -179,7 +179,7 @@ func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testi
179 179
180 result := make(chan error, 1)180 result := make(chan error, 1)
181 go func() {181 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})
183 }()183 }()
184 184
185 var gotNotice bool185 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) {
16 if output.Epoch == 0 {16 if output.Epoch == 0 {
17 return outputApplyState{}, false17 return outputApplyState{}, false
18 }18 }
19- if !s.initialized {19+ // State-independent terminal side effects are ordered on the same transport
20- if output.New == 0 {20+ // as ordinary output but deliberately do not advance or acknowledge the
21- return s, true21+ // 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
22 }27 }
28+ return s, true
29+ }
30+ if !s.initialized {
23 if output.Base != 0 || !output.Full {31 if output.Base != 0 || !output.Full {
24 return outputApplyState{}, false32 return outputApplyState{}, false
25 }33 }
@@ -32,12 +40,6 @@ func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) {
32 if output.ViewRevision != s.viewRevision {40 if output.ViewRevision != s.viewRevision {
33 return outputApplyState{}, false41 return outputApplyState{}, false
34 }42 }
35- if output.New == 0 {
36- if output.Base != 0 || output.Full {
37- return outputApplyState{}, false
38- }
39- return s, true
40- }
41 if output.Full || output.Base != s.state || output.New != output.Base+1 {43 if output.Full || output.Base != s.state || output.New != output.Base+1 {
42 return outputApplyState{}, false44 return outputApplyState{}, false
43 }45 }
M internal/usecase/client/output_state_test.go +4 -0
@@ -21,6 +21,10 @@ func TestOutputApplyStateTransitions(t *testing.T) {
21 {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}},21 {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}},
22 {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},22 {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},
23 {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},23 {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},
24 {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}},28 {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}},
25 }29 }
26 for _, tt := range tests {30 for _, tt := range tests {
M internal/usecase/client/reconnect_toast_test.go +20 -7
@@ -504,6 +504,19 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T)
504 _, sentAck := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck })504 _, sentAck := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck })
505 require.False(t, sentAck, "discarded output must not be ACKed")505 require.False(t, sentAck, "discarded output must not be ACKed")
506 506
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+
507 beforeResetFlushes := flushes.Load()520 beforeResetFlushes := flushes.Load()
508 tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{521 tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
509 Epoch: 2,522 Epoch: 2,
@@ -1362,7 +1375,7 @@ func TestReconnectRetryFailuresPreserveAttachError(t *testing.T) {
1362 )1375 )
1363 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}1376 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
1364 1377
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})
1366 require.ErrorIs(t, err, attachErr)1379 require.ErrorIs(t, err, attachErr)
1367 require.ErrorIs(t, err, retryErr)1380 require.ErrorIs(t, err, retryErr)
1368 })1381 })
@@ -1419,7 +1432,7 @@ func TestReconnectRetrySleepFailuresPreserveDialError(t *testing.T) {
1419 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()1432 dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
1420 dialer.EXPECT().Dial(mock.Anything).Run(func(context.Context) { armed.Store(true) }).Return(nil, dialErr).Once()1433 dialer.EXPECT().Dial(mock.Anything).Run(func(context.Context) { armed.Store(true) }).Return(nil, dialErr).Once()
1421 1434
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})
1423 require.ErrorIs(t, err, dialErr)1436 require.ErrorIs(t, err, dialErr)
1424 require.ErrorIs(t, err, retryErr)1437 require.ErrorIs(t, err, retryErr)
1425 })1438 })
@@ -1449,7 +1462,7 @@ func TestRemoteReconnectToastFailedDrawDoesNotBlankBounds(t *testing.T) {
1449 }}1462 }}
1450 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}1463 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
1451 1464
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})
1453 require.ErrorContains(t, err, "reconnect toast write failed")1466 require.ErrorContains(t, err, "reconnect toast write failed")
1454 require.True(t, out.failed)1467 require.True(t, out.failed)
1455 require.NotContains(t, out.String(), strings.Repeat(" ", reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)).Width))1468 require.NotContains(t, out.String(), strings.Repeat(" ", reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)).Width))
@@ -1526,7 +1539,7 @@ func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T)
1526 dialer := portsmocks.NewMockDialer(t)1539 dialer := portsmocks.NewMockDialer(t)
1527 tt.configure(dialer, tr)1540 tt.configure(dialer, tr)
1528 1541
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"})
1530 require.ErrorIs(t, err, context.Canceled)1543 require.ErrorIs(t, err, context.Canceled)
1531 require.ErrorIs(t, err, clearErr)1544 require.ErrorIs(t, err, clearErr)
1532 })1545 })
@@ -1562,7 +1575,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
1562 }}1575 }}
1563 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}1576 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
1564 1577
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})
1566 require.NoError(t, err)1579 require.NoError(t, err)
1567 require.True(t, tr1.wasClosed())1580 require.True(t, tr1.wasClosed())
1568 require.True(t, tr2.wasClosed())1581 require.True(t, tr2.wasClosed())
@@ -1606,7 +1619,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
1606 }}1619 }}
1607 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}1620 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
1608 1621
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})
1610 require.NoError(t, err)1623 require.NoError(t, err)
1611 require.Equal(t, 2, dialer.calls)1624 require.Equal(t, 2, dialer.calls)
1612 1625
@@ -1690,7 +1703,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
1690 dialer := portsmocks.NewMockDialer(t)1703 dialer := portsmocks.NewMockDialer(t)
1691 transports := tt.configure(t, dialer)1704 transports := tt.configure(t, dialer)
1692 1705
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})
1694 tt.wantErr(t, err)1707 tt.wantErr(t, err)
1695 for _, transport := range transports {1708 for _, transport := range transports {
1696 assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport)1709 assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport)
M internal/usecase/daemon/attachment_output.go +91 -25
@@ -21,20 +21,47 @@ import (
21 // Callers serialize access with attachedClient.sendMu. A prepared transaction21 // Callers serialize access with attachedClient.sendMu. A prepared transaction
22 // must be sent or committed as a no-op before the next transaction is prepared.22 // must be sent or committed as a no-op before the next transaction is prepared.
23 type attachmentOutput struct {23 type attachmentOutput struct {
24- renderer *renderer.Renderer24+ renderer *renderer.Renderer
25- epoch uint6425+ epoch uint64
26- next uint6426+ next uint64
27- acked uint6427+ acked uint64
28- generation uint6428+ generation uint64
29- maxOutstanding uint6429+ maxOutstanding uint64
30- outstandingAtomic atomic.Uint6430+ outstandingAtomic atomic.Uint64
31- maxOutstandingAtomic atomic.Uint6431+ maxOutstandingAtomic atomic.Uint64
32- forceSnapshot bool32+ forceSnapshot bool
33- initialized bool33+ initialized bool
34- lastCursor cursorOut34+ lastCursor cursorOut
35- lastRoutePosition ports.RoutePosition35+ lastRoutePosition ports.RoutePosition
36- attachment *attachedClient36+ graphicsOutput *graphicsOutputState
37- stateMu sync.Mutex37+ 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
38 }65 }
39 66
40 // lockView serializes an attachment's view publication with every output67 // lockView serializes an attachment's view publication with every output
@@ -98,33 +125,49 @@ type cursorCandidate struct {
98 // ANSI renderer state and cursor state advance together only after the output125 // ANSI renderer state and cursor state advance together only after the output
99 // frame is accepted by the attachment's state stream.126 // frame is accepted by the attachment's state stream.
100 type preparedAttachmentOutput struct {127 type preparedAttachmentOutput struct {
101- output *attachmentOutput128+ output *attachmentOutput
102- ansi *preparedOutput129+ ansi *preparedOutput
103- cursor cursorCandidate130+ graphics *preparedGraphicsOutput
104- data []byte131+ cursor cursorCandidate
132+ data []byte
105 }133 }
106 134
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) {
108 ansi, err := o.prepare(frame, damage, reset)136 ansi, err := o.prepare(frame, damage, reset)
109 if err != nil {137 if err != nil {
110 return nil, err138 return nil, err
111 }139 }
112 cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)140 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+ }
113 data := append([]byte(nil), ansi.data...)147 data := append([]byte(nil), ansi.data...)
148+ if preparedGraphics != nil {
149+ data = append(data, preparedGraphics.data...)
150+ }
114 data = append(data, cursor.data...)151 data = append(data, cursor.data...)
115- return &preparedAttachmentOutput{output: o, ansi: ansi, cursor: cursor, data: data}, nil152+ return &preparedAttachmentOutput{output: o, ansi: ansi, graphics: preparedGraphics, cursor: cursor, data: data}, nil
116 }153 }
117 154
118 func (p *preparedAttachmentOutput) send(echoAck uint64, send func(ports.Frame) error) error {155 func (p *preparedAttachmentOutput) send(echoAck uint64, send func(ports.Frame) error) error {
119 if p == nil || p.ansi == nil {156 if p == nil || p.ansi == nil {
120 return nil157 return nil
121 }158 }
122- if err := p.ansi.send(p.data, echoAck, send); err != nil {159+ sendFrame := send
123- return err160+ if p.graphics != nil {
161+ sendFrame = func(frame ports.Frame) error {
162+ p.graphics.markSendAttempted()
163+ return send(frame)
164+ }
124 }165 }
125- if p.ansi.sent && p.output != nil {166+ if err := p.ansi.send(p.data, echoAck, sendFrame); err != nil {
126- p.output.lastCursor = p.cursor.next167+ p.abortGraphics()
168+ return err
127 }169 }
170+ p.commitIfSent()
128 return nil171 return nil
129 }172 }
130 173
@@ -133,11 +176,34 @@ func (p *preparedAttachmentOutput) commitNoSend() {
133 return176 return
134 }177 }
135 p.ansi.commitNoSend()178 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 {
137 p.output.lastCursor = p.cursor.next191 p.output.lastCursor = p.cursor.next
138 }192 }
139 }193 }
140 194
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+
141 func (p *preparedAttachmentOutput) sent() bool {207 func (p *preparedAttachmentOutput) sent() bool {
142 return p != nil && p.ansi != nil && p.ansi.sent208 return p != nil && p.ansi != nil && p.ansi.sent
143 }209 }
M internal/usecase/daemon/attachment_output_test.go +32 -0
@@ -7,8 +7,10 @@ import (
7 7
8 vt "github.com/bnema/vev-vt"8 vt "github.com/bnema/vev-vt"
9 renderer "github.com/bnema/vev-vt/ansi"9 renderer "github.com/bnema/vev-vt/ansi"
10+ "github.com/bnema/vev-vt/graphics"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
13 )15 )
14 16
@@ -431,6 +433,36 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.
431 require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")433 require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")
432 }434 }
433 435
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+
434 func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) {466 func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) {
435 output := newOutputStateStream()467 output := newOutputStateStream()
436 output.next = 3468 output.next = 3
M internal/usecase/daemon/client.go +26 -3
@@ -170,7 +170,14 @@ func (ac *attachedClient) sizeSnapshot() domain.Size {
170 }170 }
171 171
172 func (ac *attachedClient) setSize(size domain.Size) {172 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()
174 }181 }
175 182
176 func (ac *attachedClient) geometrySnapshot() domain.Geometry {183 func (ac *attachedClient) geometrySnapshot() domain.Geometry {
@@ -662,7 +669,7 @@ type attachClientOptions struct {
662 669
663 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {670 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
664 d.mu.Lock()671 d.mu.Lock()
665- ac := d.prepareAttachedClientLocked(tr, domain.Geometry{Size: sz}, opts)672+ ac := d.prepareAttachedClientLocked(sess, tr, domain.Geometry{Size: sz}, opts)
666 d.mu.Unlock()673 d.mu.Unlock()
667 result, err := d.transitionAttachment(attachmentTransitionRequest{674 result, err := d.transitionAttachment(attachmentTransitionRequest{
668 target: sess,675 target: sess,
@@ -672,6 +679,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
672 ready: false,679 ready: false,
673 })680 })
674 if err != nil {681 if err != nil {
682+ d.discardAttachmentOutput(ac)
675 if tr != nil {683 if tr != nil {
676 _ = tr.Close()684 _ = tr.Close()
677 }685 }
@@ -696,12 +704,15 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
696 })704 })
697 }705 }
698 d.applyHostTheme(sess, ac, themeui.Theme{}, true)706 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
707+ if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
708+ d.warnUnsupportedGraphics(ac)
709+ }
699 }710 }
700 711
701 // prepareAttachedClientLocked allocates one detached attachment. Caller holds712 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
702 // d.mu only to allocate its resume token; attachment publication happens later after713 // d.mu only to allocate its resume token; attachment publication happens later after
703 // the caller releases every architecture lock.714 // 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 {
705 resumeToken := uint64(0)716 resumeToken := uint64(0)
706 if opts.resumeCapable {717 if opts.resumeCapable {
707 resumeToken = d.nextResumeTokenLocked()718 resumeToken = d.nextResumeTokenLocked()
@@ -711,6 +722,18 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, geometry domain
711 }722 }
712 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)723 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
713 geometry = geometry.NormalizePixels()724 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
714 ac := &attachedClient{737 ac := &attachedClient{
715 tr: tr,738 tr: tr,
716 output: output,739 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
97 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)97 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
98 d.closePicker(ac)98 d.closePicker(ac)
99 d.closePalette(ac)99 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+ }
100 103
101 d.resetScreenDefaultColors(sess)104 d.resetScreenDefaultColors(sess)
102 if explicit && notice {105 if explicit && notice {
@@ -187,6 +190,9 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
187 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)190 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
188 d.closePicker(ac)191 d.closePicker(ac)
189 d.closePalette(ac)192 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+ }
190 d.resetScreenDefaultColors(sess)196 d.resetScreenDefaultColors(sess)
191 _ = ac.closeCapturedTransport(failed)197 _ = ac.closeCapturedTransport(failed)
192 d.log.Warn("detached client after send error", "session", name)198 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) {
445 sess.clipboardWorkerRunning = true445 sess.clipboardWorkerRunning = true
446 sess.mu.Unlock()446 sess.mu.Unlock()
447 d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))447 d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
448+ sourceTab := testAttachmentTab(sess)
449+ p := sourceTab.focusedPane()
448 450
449 workerDone := make(chan struct{})451 workerDone := make(chan struct{})
450 go func() {452 go func() {
@@ -453,8 +455,6 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
453 }()455 }()
454 awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")456 awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")
455 457
456- sourceTab := testAttachmentTab(sess)
457- p := sourceTab.focusedPane()
458 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}458 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
459 publishPaneOwner(p, destination, &tab{}, 0)459 publishPaneOwner(p, destination, &tab{}, 0)
460 publishPaneOwner(p, sess, sourceTab, 0)460 publishPaneOwner(p, sess, sourceTab, 0)
M internal/usecase/daemon/daemon.go +65 -23
@@ -3,6 +3,8 @@ package daemon
3 3
4 import (4 import (
5 "context"5 "context"
6+ cryptorand "crypto/rand"
7+ "encoding/binary"
6 "errors"8 "errors"
7 "log/slog"9 "log/slog"
8 "math"10 "math"
@@ -109,6 +111,18 @@ type Daemon struct {
109 // with Wait.111 // with Wait.
110 notifies []chan struct{}112 notifies []chan struct{}
111 parked map[uint64]*parkedAttachment113 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
112 // parking tracks resume-capable attachments from before detach clears the126 // parking tracks resume-capable attachments from before detach clears the
113 // live seat until parkAttachment publishes the token into parked.127 // live seat until parkAttachment publishes the token into parked.
114 // IntentResume waits on the matching entry instead of treating the live128 // IntentResume waits on the matching entry instead of treating the live
@@ -604,6 +618,23 @@ func (d *Daemon) nowUnixNano() int64 {
604 type systemClock struct{}618 type systemClock struct{}
605 type systemTimer struct{ *time.Timer }619 type systemTimer struct{ *time.Timer }
606 620
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+
607 func (systemClock) Now() time.Time { return time.Now() }638 func (systemClock) Now() time.Time { return time.Now() }
608 func (systemClock) NewTimer(delay time.Duration) ports.Timer {639 func (systemClock) NewTimer(delay time.Duration) ports.Timer {
609 return systemTimer{Timer: time.NewTimer(delay)}640 return systemTimer{Timer: time.NewTimer(delay)}
@@ -621,28 +652,32 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
621 }652 }
622 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())653 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
623 d := &Daemon{654 d := &Daemon{
624- sessions: make(map[domain.SessionID]*session),655+ sessions: make(map[domain.SessionID]*session),
625- inactive: make(map[string]inactiveSession),656+ inactive: make(map[string]inactiveSession),
626- creating: make(map[string]struct{}),657+ creating: make(map[string]struct{}),
627- parked: make(map[uint64]*parkedAttachment),658+ parked: make(map[uint64]*parkedAttachment),
628- parking: make(map[uint64]*parkingAttachment),659+ graphicsNamespaces: make(map[uint64]struct{}),
629- paneProcessCtx: paneProcessCtx,660+ graphicsNamespaceFences: make(map[uint64]uint64),
630- paneProcessCancel: paneProcessCancel,661+ graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
631- ptys: ptys,662+ graphicsNamespaceSalt: newGraphicsNamespaceSalt(),
632- clock: clock,663+ parking: make(map[uint64]*parkingAttachment),
633- log: log,664+ paneProcessCtx: paneProcessCtx,
634- baseEnv: os.Environ(),665+ paneProcessCancel: paneProcessCancel,
635- shell: defaultShellCommand,666+ ptys: ptys,
636- dirOrHome: dirOrHome,667+ clock: clock,
637- done: make(chan struct{}),668+ log: log,
638- restoreDone: make(chan struct{}),669+ baseEnv: os.Environ(),
639- animWake: make(chan struct{}, 1),670+ shell: defaultShellCommand,
640- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),671+ dirOrHome: dirOrHome,
641- snapshotAdmitted: make(map[*snapshotCapture]struct{}),672+ done: make(chan struct{}),
642- snapshotWake: make(chan struct{}, 1),673+ restoreDone: make(chan struct{}),
643- notices: newNoticeCenter(),674+ animWake: make(chan struct{}, 1),
644- remoteCatalog: newRemoteCatalogState(),675+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
645- resumeParkGrace: defaultResumeParkGrace,676+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
677+ snapshotWake: make(chan struct{}, 1),
678+ notices: newNoticeCenter(),
679+ remoteCatalog: newRemoteCatalogState(),
680+ resumeParkGrace: defaultResumeParkGrace,
646 barScripts: &barScriptState{681 barScripts: &barScriptState{
647 cfg: barConfigFromDomain(domain.Defaults().Bar),682 cfg: barConfigFromDomain(domain.Defaults().Bar),
648 outputs: make(map[domain.SessionID]barScriptOutputs),683 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -1307,6 +1342,10 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1307 }1342 }
1308 sess.mu.Unlock()1343 sess.mu.Unlock()
1309 terminalCapabilities := ports.DetectTerminalCapabilities(h.Env)1344 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
1310 if h.TrueColor && !terminalCapabilities.TrueColor() {1349 if h.TrueColor && !terminalCapabilities.TrueColor() {
1311 terminalCapabilities.ColorMode = ports.TerminalColorTrueColor1350 terminalCapabilities.ColorMode = ports.TerminalColorTrueColor
1312 terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared1351 terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared
@@ -1324,7 +1363,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1324 if geometry.Size != sz {1363 if geometry.Size != sz {
1325 geometry = domain.Geometry{Size: sz}1364 geometry = domain.Geometry{Size: sz}
1326 }1365 }
1327- ac := d.prepareAttachedClientLocked(tr, geometry, opts)1366+ ac := d.prepareAttachedClientLocked(sess, tr, geometry, opts)
1328 d.mu.Unlock()1367 d.mu.Unlock()
1329 result, err := d.transitionAttachment(attachmentTransitionRequest{1368 result, err := d.transitionAttachment(attachmentTransitionRequest{
1330 target: sess,1369 target: sess,
@@ -1335,6 +1374,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1335 ready: false,1374 ready: false,
1336 })1375 })
1337 if err != nil {1376 if err != nil {
1377+ if ac.output != nil && ac.output.graphicsOutput != nil {
1378+ d.retireGraphicsOutput(ac, ac.output.graphicsOutput)
1379+ }
1338 if tr != nil {1380 if tr != nil {
1339 _ = tr.Close()1381 _ = tr.Close()
1340 }1382 }
M internal/usecase/daemon/daemon_test.go +1 -0
@@ -405,6 +405,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
405 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)405 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
406 tr, sends := newCapturingTransport(t)406 tr, sends := newCapturingTransport(t)
407 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}407 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
408+ ac.output.attachment = ac
408 ac.initOverlays()409 ac.initOverlays()
409 sctx, cancel := context.WithCancel(d.serveCtx)410 sctx, cancel := context.WithCancel(d.serveCtx)
410 tabs := make([]*tab, 0, len(ptys))411 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) {
143 for _, attachment := range p.retiredAttachments {143 for _, attachment := range p.retiredAttachments {
144 d.unregisterPreview(attachment.ac)144 d.unregisterPreview(attachment.ac)
145 attachment.ac.clearCaptureFrames()145 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+ }
146 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)149 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
147 }150 }
148 for _, retirement := range p.retiredParked {151 for _, retirement := range p.retiredParked {
M internal/usecase/daemon/pane.go +8 -1
@@ -110,7 +110,7 @@ type pane struct {
110 id layout.PaneID110 id layout.PaneID
111 stableID string111 stableID string
112 pty ports.PTY112 pty ports.PTY
113- mu sync.Mutex // guards screen, history, syncGen, rect, geometry, resize state, PTY side effects, title, and owner publication113+ mu sync.Mutex // guards screen, history, syncGen, rect, geometry, graphics scene geometry, resize state, PTY side effects, title, and owner publication
114 owner atomic.Pointer[paneOwner]114 owner atomic.Pointer[paneOwner]
115 // ownerGeneration is advanced only while mu is held. Readers consume the115 // ownerGeneration is advanced only while mu is held. Readers consume the
116 // generation through the immutable owner pointer above.116 // generation through the immutable owner pointer above.
@@ -121,6 +121,13 @@ type pane struct {
121 syncGen uint64121 syncGen uint64
122 rect domain.Rect122 rect domain.Rect
123 geometry domain.Geometry123 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
124 // resizeApplying gates VT parsing across PTY.Resize. The reader continues131 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
125 // draining into resizePending so output is replayed against the target (or132 // draining into resizePending so output is replayed against the target (or
126 // retained old) screen only after apply resolves.133 // 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
941 if samePeerEligible {941 if samePeerEligible {
942 token.ac.offerSamePeerTarget(*exactTarget)942 token.ac.offerSamePeerTarget(*exactTarget)
943 }943 }
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+ }
944 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {952 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
945 if samePeerEligible {953 if samePeerEligible {
946 token.ac.clearSamePeerOffer()954 token.ac.clearSamePeerOffer()
M internal/usecase/daemon/remote_route_test.go +81 -0
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "os"
5 "testing"6 "testing"
6 7
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
@@ -10,6 +11,86 @@ import (
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 )12 )
12 13
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+
13 func TestRouteRemoteTargetSelectsExactLiveTab(t *testing.T) {94 func TestRouteRemoteTargetSelectsExactLiveTab(t *testing.T) {
14 d := newTestDaemon(t, nil, stubClock{})95 d := newTestDaemon(t, nil, stubClock{})
15 sess := addControlSession(d, "work", "tab-1", "pane-1")96 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
160 effects.renderCoordinator = owner.session.renderCoordinator()160 effects.renderCoordinator = owner.session.renderCoordinator()
161 }161 }
162 effects.wasSyncing = p.screen.SyncUpdateActive()162 effects.wasSyncing = p.screen.SyncUpdateActive()
163- p.screen.Write(data)163+ writePaneScreenLocked(p, data)
164 p.refreshTerminalTitleLocked()164 p.refreshTerminalTitleLocked()
165 effects.isSyncing = p.screen.SyncUpdateActive()165 effects.isSyncing = p.screen.SyncUpdateActive()
166 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)166 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
@@ -187,6 +187,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
187 effects.attention = p.ptyAttention187 effects.attention = p.ptyAttention
188 p.ptyAttention = false188 p.ptyAttention = false
189 effects.responses = append([]byte(nil), p.ptyResponses...)189 effects.responses = append([]byte(nil), p.ptyResponses...)
190+ effects.responses = filterUnsupportedKittyAnimationResponses(effects.responses)
190 p.ptyResponses = p.ptyResponses[:0]191 p.ptyResponses = p.ptyResponses[:0]
191 effects.clipboards = append([]string(nil), p.ptyClipboards...)192 effects.clipboards = append([]string(nil), p.ptyClipboards...)
192 p.ptyClipboards = p.ptyClipboards[:0]193 p.ptyClipboards = p.ptyClipboards[:0]
@@ -195,6 +196,39 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool
195 d.applyPanePTYEffects(p, effects)196 d.applyPanePTYEffects(p, effects)
196 }197 }
197 198
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+
198 // applyPanePTYEffects emits only effects still bound to the owner generation232 // applyPanePTYEffects emits only effects still bound to the owner generation
199 // that parsed them. Each owner-sensitive boundary revalidates independently so233 // that parsed them. Each owner-sensitive boundary revalidates independently so
200 // ownership publication can retire the remaining effects without rerouting234 // ownership publication can retire the remaining effects without rerouting
M internal/usecase/daemon/render_capture.go +98 -16
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 renderer "github.com/bnema/vev-vt"4 renderer "github.com/bnema/vev-vt"
5+ vevgraphics "github.com/bnema/vev-vt/graphics"
5 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
6 scopy "github.com/bnema/vev/internal/usecase/copy"7 scopy "github.com/bnema/vev/internal/usecase/copy"
7 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
@@ -22,6 +23,7 @@ type renderCaptureScratch struct {
22 placements []layout.Placement23 placements []layout.Placement
23 dividers []layout.Divider24 dividers []layout.Divider
24 layoutTab *tab25 layoutTab *tab
26+ layoutRoot *layout.Node
25 layoutGeneration uint6427 layoutGeneration uint64
26 layoutArea domain.Rect28 layoutArea domain.Rect
27 layoutFocus layout.PaneID29 layoutFocus layout.PaneID
@@ -40,19 +42,25 @@ type damageReceipt struct {
40 }42 }
41 43
42 type capturedRenderState struct {44 type capturedRenderState struct {
43- attachment *attachedClient // identity only; never dereferenced by composition45+ attachment *attachedClient // identity only; never dereferenced by composition
44- lease *attachmentLease46+ sessionID domain.SessionID
45- view attachmentView47+ incarnation domain.IncarnationID
46- window domain.Size48+ lease *attachmentLease
47- reset bool49+ view attachmentView
48- layout capturedTabLayout50+ window domain.Size
49- panes []capturedPaneRenderState51+ reset bool
50- floating capturedFloatingRenderState52+ layout capturedTabLayout
51- bars barState53+ panes []capturedPaneRenderState
52- theme themeui.Theme54+ floating capturedFloatingRenderState
53- styles themeui.Styles55+ bars barState
54- styleGeneration uint6456+ theme themeui.Theme
55- overlays capturedOverlayRenderState57+ 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
56 preview picker.Preview64 preview picker.Preview
57 cursor capturedCursorInputs65 cursor capturedCursorInputs
58 tabGeneration uint6466 tabGeneration uint64
@@ -71,10 +79,15 @@ type capturedTabLayout struct {
71 79
72 type capturedPaneRenderState struct {80 type capturedPaneRenderState struct {
73 id layout.PaneID81 id layout.PaneID
82+ stableID domain.PaneStableID
74 frame renderer.Frame83 frame renderer.Frame
75 rawDamage []renderer.Damage84 rawDamage []renderer.Damage
76 damage []renderer.Damage85 damage []renderer.Damage
77 damageGeneration uint6486 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
78 title string91 title string
79 titleGeneration uint6492 titleGeneration uint64
80 placement layout.Placement93 placement layout.Placement
@@ -121,6 +134,33 @@ type capturedCursorInputs struct {
121 content domain.Rect134 content domain.Rect
122 }135 }
123 136
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+
124 // capturePaneRenderStateLocked copies only the visible rectangle required by164 // capturePaneRenderStateLocked copies only the visible rectangle required by
125 // composition. It never copies scrollback/history and never lets the mutable165 // composition. It never copies scrollback/history and never lets the mutable
126 // VT frame's Cells or row-offset slices escape pane.mu.166 // VT frame's Cells or row-offset slices escape pane.mu.
@@ -129,7 +169,30 @@ func capturePaneRenderStateLocked(p *pane, visible domain.Rect) capturedPaneRend
129 }169 }
130 170
131 func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {171 func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
132- out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation172+ 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+ }
133 damage := p.screen.CaptureDamage()196 damage := p.screen.CaptureDamage()
134 out.damageGeneration = damage.Generation197 out.damageGeneration = damage.Generation
135 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)198 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
@@ -212,6 +275,8 @@ func captureLocalRenderState(
212 }275 }
213 sess.mu.Lock()276 sess.mu.Lock()
214 _, owned := sess.attachments[ac]277 _, owned := sess.attachments[ac]
278+ sessionID := sess.id
279+ incarnation := sess.incarnation
215 sess.mu.Unlock()280 sess.mu.Unlock()
216 if !owned {281 if !owned {
217 return nil, false282 return nil, false
@@ -257,12 +322,17 @@ func captureLocalRenderState(
257 area: area, focus: focus, placements: scratch.placements,322 area: area, focus: focus, placements: scratch.placements,
258 dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,323 dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
259 }324 }
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 {
261 layoutSnap = solveTabLayoutLocked(tb)330 layoutSnap = solveTabLayoutLocked(tb)
262 layoutSnap.focus = focus331 layoutSnap.focus = focus
263 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)332 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
264 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)333 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
265 scratch.layoutTab = tb334 scratch.layoutTab = tb
335+ scratch.layoutRoot = layoutRoot
266 scratch.layoutGeneration = tb.layoutGeneration336 scratch.layoutGeneration = tb.layoutGeneration
267 scratch.layoutArea = area337 scratch.layoutArea = area
268 scratch.layoutFocus = focus338 scratch.layoutFocus = focus
@@ -278,7 +348,7 @@ func captureLocalRenderState(
278 window = ac.sizeSnapshot()348 window = ac.sizeSnapshot()
279 }349 }
280 *state = capturedRenderState{350 *state = capturedRenderState{
281- attachment: ac, lease: lease, view: view, window: window,351+ attachment: ac, sessionID: sessionID, incarnation: incarnation, lease: lease, view: view, window: window,
282 reset: reset, bars: bars, theme: bars.theme,352 reset: reset, bars: bars, theme: bars.theme,
283 styles: request.styles, styleGeneration: request.styleGeneration,353 styles: request.styles, styleGeneration: request.styleGeneration,
284 overlays: overlays, preview: preview,354 overlays: overlays, preview: preview,
@@ -302,6 +372,12 @@ func captureLocalRenderState(
302 }372 }
303 p.mu.Lock()373 p.mu.Lock()
304 captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])374 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+ }
305 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})381 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
306 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus382 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
307 if captured.focused {383 if captured.focused {
@@ -322,6 +398,12 @@ func captureLocalRenderState(
322 p.mu.Lock()398 p.mu.Lock()
323 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))399 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
324 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])400 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+ }
325 seen := false407 seen := false
326 for _, receipt := range state.receipts {408 for _, receipt := range state.receipts {
327 if receipt.pane == p {409 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
436 }436 }
437 }437 }
438 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)438 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)
440 var data []byte440 var data []byte
441 if prepared != nil {441 if prepared != nil {
442 data = prepared.data442 data = prepared.data
@@ -468,6 +468,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
468 }468 }
469 var sendTr ports.Transport469 var sendTr ports.Transport
470 var sendErr error470 var sendErr error
471+ suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics()
471 if len(data) > 0 {472 if len(data) > 0 {
472 sendTransport := ac.transportSnapshot()473 sendTransport := ac.transportSnapshot()
473 sendTr = sendTransport.transport474 sendTr = sendTransport.transport
@@ -502,6 +503,9 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
502 ac.sendMu.Unlock()503 ac.sendMu.Unlock()
503 return true504 return true
504 }505 }
506+ if sendErr != nil {
507+ prepared.abort()
508+ }
505 }509 }
506 if sendErr == nil {510 if sendErr == nil {
507 if len(data) == 0 {511 if len(data) == 0 {
@@ -564,6 +568,9 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
564 }568 }
565 }569 }
566 ac.sendMu.Unlock()570 ac.sendMu.Unlock()
571+ if suppressedGraphics && d.warnUnsupportedGraphics(ac) {
572+ d.repaintForNotice(ac)
573+ }
567 if sendErr != nil {574 if sendErr != nil {
568 // A transport failure may invalidate the role gate. Release this render's575 // A transport failure may invalidate the role gate. Release this render's
569 // admission first. Detachment freezes the gate and therefore cannot mutate576 // 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) {
330 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")330 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
331 331
332 stream := newOutputStateStream()332 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)
334 require.NoError(t, err)334 require.NoError(t, err)
335 var outputFrame ports.Frame335 var outputFrame ports.Frame
336 require.NoError(t, prepared.send(0, func(frame ports.Frame) error {336 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) {
991 d.waitNotifies()991 d.waitNotifies()
992 }992 }
993 993
994-func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {994+func TestPTYKittyIcatDetectionGetsResponsesWrittenBackToPTY(t *testing.T) {
995 d := newTestDaemon(t, nil, stubClock{})995 d := newTestDaemon(t, nil, stubClock{})
996 p := portsmocks.NewMockPTY(t)996 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")}
998 writes := make(chan []byte, 1)998 writes := make(chan []byte, 1)
999 p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {999 p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1000 if len(chunks) == 0 {1000 if len(chunks) == 0 {
@@ -1023,7 +1023,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1023 1023
1024 select {1024 select {
1025 case got := <-writes:1025 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)
1027 case <-time.After(time.Second):1027 case <-time.After(time.Second):
1028 t.Fatal("timed out waiting for PTY response write")1028 t.Fatal("timed out waiting for PTY response write")
1029 }1029 }
@@ -1034,6 +1034,47 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1034 }1034 }
1035 }1035 }
1036 1036
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+
1037 func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {1078 func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
1038 _, sess, _, _ := newManualSessionWithPTYs(t, nil)1079 _, sess, _, _ := newManualSessionWithPTYs(t, nil)
1039 p := sess.tabs[0].focusedPane()1080 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) {
304 d.mu.Unlock()304 d.mu.Unlock()
305 }305 }
306 306
307-// removeParkedLocked invalidates one parked attachment. Caller holds d.mu and307+// removeParkedLocked invalidates one non-nil parked attachment. Caller holds
308-// has verified d.parked[token] still points at parked when that matters.308+// d.mu and has verified d.parked[token] still points at parked when that matters.
309 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {309 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
310 delete(d.parked, token)310 delete(d.parked, token)
311+ d.discardAttachmentOutputLocked(parked.ac)
311 parked.ac.resumeToken = 0312 parked.ac.resumeToken = 0
312 parked.ac.parked = false313 parked.ac.parked = false
313 if parked.timer != nil {314 if parked.timer != nil {
@@ -410,6 +411,7 @@ type parkedAttachmentRetirement struct {
410 411
411 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {412 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
412 delete(d.parked, token)413 delete(d.parked, token)
414+ d.discardAttachmentOutputLocked(parked.ac)
413 parked.ac.resumeToken = 0415 parked.ac.resumeToken = 0
414 parked.ac.parked = false416 parked.ac.parked = false
415 parked.ac.connectionGeneration.Add(1)417 parked.ac.connectionGeneration.Add(1)
@@ -602,6 +604,9 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
602 // complete the claiming terminal's geometry before the resumed604 // complete the claiming terminal's geometry before the resumed
603 // attachment is allowed to produce its first frame.605 // attachment is allowed to produce its first frame.
604 d.recalculateSessionGeometry(sess, resumed)606 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)
605 }610 }
606 if err != nil && resumed != nil {611 if err != nil && resumed != nil {
607 d.abortResumeClaim(resumed)612 d.abortResumeClaim(resumed)
@@ -646,6 +651,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
646 ac.rebaseOutput()651 ac.rebaseOutput()
647 ac.output.setWindow(h.MaxOutputInFlight)652 ac.output.setWindow(h.MaxOutputInFlight)
648 ac.replaceTransport(tr)653 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)
649 geometry := h.Geometry()658 geometry := h.Geometry()
650 if geometry.Size != sz {659 if geometry.Size != sz {
651 geometry = domain.Geometry{Size: sz}660 geometry = domain.Geometry{Size: sz}
M internal/usecase/daemon/session.go +3 -0
@@ -1828,6 +1828,9 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
1828 }1828 }
1829 1829
1830 for _, attachment := range attachments {1830 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)
1831 d.notifyDetachedSnapshotAsync(attachment, reason)1834 d.notifyDetachedSnapshotAsync(attachment, reason)
1832 }1835 }
1833 return errors.Join(purgeErr, terminalSnapshotErr)1836 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
416 p.mu.Unlock()416 p.mu.Unlock()
417 if sess == nil {417 if sess == nil {
418 p.mu.Lock()418 p.mu.Lock()
419- p.screen.Write(data)419+ writePaneScreenLocked(p, data)
420 p.refreshTerminalTitleLocked()420 p.refreshTerminalTitleLocked()
421 p.mu.Unlock()421 p.mu.Unlock()
422 continue422 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