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 Unverified
B5690EEEBB952194The 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 | 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 | 30 | ## Environment | |
| 10 | 31 | ||
| 11 | 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 | 12 | const HandshakeTimeout = 15 * time.Second | |
| 13 | 13 | ||
| 14 | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 15 | - | const ProtocolVersion uint16 = 35 | |
| 15 | + | const ProtocolVersion uint16 = 36 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| 19 | 19 | 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 | 25 | // MsgType identifies the kind of payload carried by a Frame. | |
| 22 | 26 | type MsgType uint8 | |
| 23 | 27 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 35 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 36 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
M internal/ports/navigation_test.go +1 -1
| @@ -152,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 152 | 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 | 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 | 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 | 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 | 157 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 158 | 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 | 117 | unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err }, | |
| 118 | - | policyOffset: 8, | |
| 118 | + | policyOffset: 10, | |
| 119 | 119 | wantErr: ErrInvalidHello, | |
| 120 | 120 | }, | |
| 121 | 121 | { | |
M internal/ports/terminal.go +18 -7
| @@ -52,9 +52,16 @@ const ( | |||
| 52 | 52 | // attachment. Pane processes must not derive their terminal environment from | |
| 53 | 53 | // these capabilities. | |
| 54 | 54 | type TerminalCapabilities struct { | |
| 55 | - | ColorMode TerminalColorMode | |
| 56 | - | ColorSource TerminalCapabilitySource | |
| 57 | - | Application TerminalApplication | |
| 55 | + | ColorMode TerminalColorMode | |
| 56 | + | ColorSource TerminalCapabilitySource | |
| 57 | + | Application TerminalApplication | |
| 58 | + | KittyGraphics bool | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // SupportsKittyGraphics reports whether this attachment's active outer-terminal | |
| 62 | + | // probe accepted the Kitty graphics protocol. Environment detection never sets it. | |
| 63 | + | func (c TerminalCapabilities) SupportsKittyGraphics() bool { | |
| 64 | + | return c.KittyGraphics | |
| 58 | 65 | } | |
| 59 | 66 | ||
| 60 | 67 | // TrueColor reports whether this attachment can receive RGB ANSI output. | |
| @@ -75,20 +82,24 @@ func DetectTerminalCapabilities(env []string) TerminalCapabilities { | |||
| 75 | 82 | caps.Application = TerminalApplicationKitty | |
| 76 | 83 | } | |
| 77 | 84 | ||
| 85 | + | kittyIdentity := term == "xterm-kitty" && caps.Application == TerminalApplicationKitty | |
| 78 | 86 | switch colorTerm { | |
| 79 | 87 | case "truecolor", "24bit": | |
| 80 | 88 | caps.ColorMode = TerminalColorTrueColor | |
| 81 | 89 | caps.ColorSource = TerminalCapabilityDeclared | |
| 82 | - | return caps | |
| 83 | 90 | } | |
| 84 | 91 | if term == "xterm-direct" || strings.HasSuffix(term, "-direct") { | |
| 85 | 92 | caps.ColorMode = TerminalColorTrueColor | |
| 86 | 93 | 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 | 99 | caps.ColorMode = TerminalColorTrueColor | |
| 91 | - | caps.ColorSource = TerminalCapabilityHeuristic | |
| 100 | + | return caps | |
| 101 | + | } | |
| 102 | + | if caps.ColorSource == TerminalCapabilityDeclared { | |
| 92 | 103 | return caps | |
| 93 | 104 | } | |
| 94 | 105 | if strings.Contains(term, "256color") || term == "dumb" { | |
M internal/ports/terminal_test.go +14 -5
| @@ -8,11 +8,12 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | func TestDetectTerminalCapabilities(t *testing.T) { | |
| 10 | 10 | tests := []struct { | |
| 11 | - | name string | |
| 12 | - | env []string | |
| 13 | - | wantMode TerminalColorMode | |
| 14 | - | wantSource TerminalCapabilitySource | |
| 15 | - | wantApp TerminalApplication | |
| 11 | + | name string | |
| 12 | + | env []string | |
| 13 | + | wantMode TerminalColorMode | |
| 14 | + | wantSource TerminalCapabilitySource | |
| 15 | + | wantApp TerminalApplication | |
| 16 | + | wantGraphics bool | |
| 16 | 17 | }{ | |
| 17 | 18 | { | |
| 18 | 19 | name: "declared truecolor", | |
| @@ -27,6 +28,13 @@ func TestDetectTerminalCapabilities(t *testing.T) { | |||
| 27 | 28 | wantSource: TerminalCapabilityHeuristic, | |
| 28 | 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 | 39 | name: "256 color terminal remains a constrained attachment", | |
| 32 | 40 | env: []string{"TERM=xterm-256color"}, | |
| @@ -54,6 +62,7 @@ func TestDetectTerminalCapabilities(t *testing.T) { | |||
| 54 | 62 | require.Equal(t, tt.wantMode, got.ColorMode) | |
| 55 | 63 | require.Equal(t, tt.wantSource, got.ColorSource) | |
| 56 | 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 | 174 | TermEnv string | |
| 175 | 175 | Cwd string | |
| 176 | 176 | 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 | 181 | // MaxOutputInFlight is the requested maximum number of unacknowledged | |
| 178 | 182 | // state-bearing output frames. | |
| 179 | 183 | MaxOutputInFlight uint8 | |
| @@ -194,6 +198,10 @@ type Hello struct { | |||
| 194 | 198 | EnvironmentPolicy EnvironmentPolicy | |
| 195 | 199 | NavigationCapabilities NavigationCapabilities | |
| 196 | 200 | 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 | 207 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| @@ -823,7 +831,7 @@ func ValidateOutput(m Output) error { | |||
| 823 | 831 | if err := ValidateSize(m.Size); err != nil { | |
| 824 | 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 | 835 | return ErrInvalidOutput | |
| 828 | 836 | } | |
| 829 | 837 | return nil | |
| @@ -912,6 +920,8 @@ func MarshalHello(h Hello) []byte { | |||
| 912 | 920 | w.putString(string(h.PreferredTabID)) | |
| 913 | 921 | w.putUint8(uint8(h.NavigationCapabilities)) | |
| 914 | 922 | w.putUint8(uint8(h.StartupOverlay)) | |
| 923 | + | w.putBool(h.Remote) | |
| 924 | + | w.putBool(h.KittyDirectGraphics) | |
| 915 | 925 | return w.b | |
| 916 | 926 | } | |
| 917 | 927 | ||
| @@ -988,6 +998,12 @@ func preflightHello(b []byte) error { | |||
| 988 | 998 | if _, err := r.getUint8(); err != nil { | |
| 989 | 999 | 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 | 1007 | return r.done() | |
| 992 | 1008 | } | |
| 993 | 1009 | ||
| @@ -1091,6 +1107,12 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 1091 | 1107 | return Hello{}, err | |
| 1092 | 1108 | } | |
| 1093 | 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 | 1116 | if err := r.done(); err != nil { | |
| 1095 | 1117 | return Hello{}, err | |
| 1096 | 1118 | } | |
| @@ -1568,7 +1590,7 @@ func UnmarshalOutput(b []byte) (Output, error) { | |||
| 1568 | 1590 | if err != nil { | |
| 1569 | 1591 | return Output{}, err | |
| 1570 | 1592 | } | |
| 1571 | - | if int64(decodedLen) > int64(MaxFrameLen-1-outputPayloadOverhead) { | |
| 1593 | + | if int64(decodedLen) > int64(MaxOutputDataLen) { | |
| 1572 | 1594 | return Output{}, ErrInvalidOutput | |
| 1573 | 1595 | } | |
| 1574 | 1596 | data, err := r.getLongBytes() | |
M internal/ports/wire_final_test.go +37 -3
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 35 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 36 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,8 +26,9 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 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 | 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 | 32 | got := MarshalHello(msg) | |
| 32 | 33 | if !bytes.Equal(got, want) { | |
| 33 | 34 | t.Fatalf("Hello bytes = %x, want %x", got, want) | |
| @@ -43,6 +44,21 @@ func TestFinalHelloGoldenStrict(t *testing.T) { | |||
| 43 | 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 | 62 | func TestFinalHelloSemanticValidation(t *testing.T) { | |
| 47 | 63 | valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 48 | 64 | for _, tt := range []struct { | |
| @@ -250,6 +266,24 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | |||
| 250 | 266 | }(), | |
| 251 | 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 | 288 | name: "welcome boolean", | |
| 255 | 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 | 56 | func appendNoNavigationTail(payload []byte) []byte { | |
| 57 | 57 | // exact-target absent, preferred-tab absent, navigation capabilities none, | |
| 58 | - | // startup overlay none. | |
| 59 | - | return append(payload, 0, 0, 0, 0, 0) | |
| 58 | + | // startup overlay none, local carriage. | |
| 59 | + | return append(payload, 0, 0, 0, 0, 0, 0, 0) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | |
| @@ -135,13 +135,13 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 135 | 135 | } | |
| 136 | 136 | got := MarshalHello(msg) | |
| 137 | 137 | want := []byte{ | |
| 138 | - | 0x00, 0x23, 0x00, // version, intent, first client ID byte | |
| 138 | + | 0x00, 0x24, 0x00, // version, intent, first client ID byte | |
| 139 | 139 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 140 | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 141 | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| 142 | 142 | 0x00, 0x00, // name | |
| 143 | 143 | 0x00, 0x01, 0x00, 0x01, // size | |
| 144 | - | 0x00, 0x00, 0x00, 0x00, // optional pixel size | |
| 144 | + | 0x00, 0x00, 0x00, 0x00, // pixel width and height | |
| 145 | 145 | 0x00, 0x00, // TERM | |
| 146 | 146 | 0x00, 0x00, // cwd | |
| 147 | 147 | 0x00, 0x00, // true color, max output in flight | |
| @@ -178,15 +178,15 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 178 | 178 | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | |
| 179 | 179 | withCount := func(count byte) []byte { | |
| 180 | 180 | payload := append([]byte(nil), base...) | |
| 181 | - | // The uint32 environment count occupies bytes len-11 through len-8. | |
| 182 | - | payload[len(payload)-8] = count | |
| 181 | + | // The uint32 environment count occupies bytes len-13 through len-10. | |
| 182 | + | payload[len(payload)-10] = count | |
| 183 | 183 | return payload | |
| 184 | 184 | } | |
| 185 | 185 | withEntries := func(count byte, entries ...byte) []byte { | |
| 186 | 186 | payload := withCount(count) | |
| 187 | - | // The seven-byte tail follows the environment-count field. | |
| 188 | - | tail := append([]byte(nil), payload[len(payload)-7:]...) | |
| 189 | - | payload = append(payload[:len(payload)-7], entries...) | |
| 187 | + | // The nine-byte tail follows the environment-count field. | |
| 188 | + | tail := append([]byte(nil), payload[len(payload)-9:]...) | |
| 189 | + | payload = append(payload[:len(payload)-9], entries...) | |
| 190 | 190 | return append(payload, tail...) | |
| 191 | 191 | } | |
| 192 | 192 | tests := []struct { | |
| @@ -366,7 +366,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 366 | 366 | } | |
| 367 | 367 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 368 | 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 | 372 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -527,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 527 | 527 | name: "minimal", | |
| 528 | 528 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 529 | 529 | want: []byte{ | |
| 530 | - | 0x00, 0x23, | |
| 530 | + | 0x00, 0x24, | |
| 531 | 531 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 532 | 532 | 0x00, // attached | |
| 533 | 533 | 0x00, // self | |
| @@ -550,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 550 | 550 | JSON: true, | |
| 551 | 551 | }, | |
| 552 | 552 | want: []byte{ | |
| 553 | - | 0x00, 0x23, | |
| 553 | + | 0x00, 0x24, | |
| 554 | 554 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 555 | 555 | 0x00, // attached | |
| 556 | 556 | 0x01, // self | |
| @@ -947,7 +947,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) { | |||
| 947 | 947 | for _, window := range []uint8{0, 1, 8} { | |
| 948 | 948 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | |
| 949 | 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 | 951 | want := append(make([]byte, 42), window, 0, 0, 0, 0, 0, 0) | |
| 952 | 952 | want = appendNoNavigationTail(want) | |
| 953 | 953 | want[1] = 14 | |
| @@ -979,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 979 | 979 | name: "attached", | |
| 980 | 980 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 981 | 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 | 983 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 984 | 984 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 985 | 985 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 989 | 989 | name: "control", | |
| 990 | 990 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 991 | 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 | 993 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 994 | 994 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 995 | 995 | }, | |
M internal/usecase/client/client.go +131 -16
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "time" | |
| 21 | 21 | ||
| 22 | 22 | renderer "github.com/bnema/vev-vt" | |
| 23 | + | "github.com/bnema/vev-vt/protocol/terminalquery" | |
| 23 | 24 | "github.com/bnema/vev/internal/domain" | |
| 24 | 25 | "github.com/bnema/vev/internal/ports" | |
| 25 | 26 | "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -138,6 +139,8 @@ const preWelcomeTimeout = ports.HandshakeTimeout | |||
| 138 | 139 | // learner must not block shell prompt return indefinitely. | |
| 139 | 140 | const remoteHostLearnerShutdownTimeout = time.Second | |
| 140 | 141 | ||
| 142 | + | const kittyCapabilityProbeTimeout = 150 * time.Millisecond | |
| 143 | + | ||
| 141 | 144 | var reconnectSleep = sleepReconnect | |
| 142 | 145 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | |
| 143 | 146 | ||
| @@ -150,13 +153,16 @@ const ( | |||
| 150 | 153 | type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error) | |
| 151 | 154 | ||
| 152 | 155 | type Dependencies struct { | |
| 153 | - | Dialer ports.Dialer | |
| 154 | - | Terminal ports.Terminal | |
| 155 | - | Clock ports.Clock | |
| 156 | - | Clipboard ports.ClipboardReader | |
| 157 | - | Logger *slog.Logger | |
| 158 | - | RuntimeObserver ports.SerializedRuntimeObserver | |
| 159 | - | RemoteHostLearner ports.RemoteHostLearner | |
| 156 | + | Dialer ports.Dialer | |
| 157 | + | Terminal ports.Terminal | |
| 158 | + | Clock ports.Clock | |
| 159 | + | // DisableCapabilityProbe skips active outer-terminal discovery for headless | |
| 160 | + | // embedders and deterministic tests. Interactive clients leave it false. | |
| 161 | + | DisableCapabilityProbe bool | |
| 162 | + | Clipboard ports.ClipboardReader | |
| 163 | + | Logger *slog.Logger | |
| 164 | + | RuntimeObserver ports.SerializedRuntimeObserver | |
| 165 | + | RemoteHostLearner ports.RemoteHostLearner | |
| 160 | 166 | // AttachHandoff keeps one Runner and one terminal/input ownership across a | |
| 161 | 167 | // local daemon's structured remote handoff. It is nil for direct CLI attach. | |
| 162 | 168 | AttachHandoff AttachHandoffFunc | |
| @@ -211,6 +217,10 @@ type Runner struct { | |||
| 211 | 217 | ||
| 212 | 218 | inputMu sync.Mutex | |
| 213 | 219 | input *terminalInputPump | |
| 220 | + | ||
| 221 | + | capabilityMu sync.Mutex | |
| 222 | + | kittyDirectGraphics *bool | |
| 223 | + | probeCapabilities bool | |
| 214 | 224 | } | |
| 215 | 225 | ||
| 216 | 226 | // NewRunner constructs a client runner. A nil logger uses the process default. | |
| @@ -230,6 +240,7 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 230 | 240 | attachHandoff: deps.AttachHandoff, | |
| 231 | 241 | remote: deps.Remote, | |
| 232 | 242 | origin: normalizeRouteOrigin(deps.Origin, deps.Remote), | |
| 243 | + | probeCapabilities: !deps.DisableCapabilityProbe, | |
| 233 | 244 | ledger: newRouteLedger(), | |
| 234 | 245 | } | |
| 235 | 246 | } | |
| @@ -249,6 +260,99 @@ func (r *Runner) terminalInput() *terminalInputPump { | |||
| 249 | 260 | 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 | 356 | func validateAttachRequest(request AttachRequest) error { | |
| 253 | 357 | if request.Origin != 0 { | |
| 254 | 358 | if err := request.Origin.Validate(); err != nil { | |
| @@ -1083,7 +1187,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1083 | 1187 | } | |
| 1084 | 1188 | 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 | 1200 | geometry, err := term.Geometry() | |
| 1088 | 1201 | if err != nil { | |
| 1089 | 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 | 1230 | TermEnv: termEnv, | |
| 1118 | 1231 | Cwd: cwd, | |
| 1119 | 1232 | TrueColor: trueColor, | |
| 1233 | + | KittyDirectGraphics: kittyDirectGraphics, | |
| 1120 | 1234 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 1121 | 1235 | Env: os.Environ(), | |
| 1122 | 1236 | RemoteTarget: request.RemoteTarget, | |
| @@ -1125,6 +1239,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1125 | 1239 | EnvironmentPolicy: request.EnvironmentPolicy, | |
| 1126 | 1240 | NavigationCapabilities: request.NavigationCapabilities, | |
| 1127 | 1241 | StartupOverlay: request.StartupOverlay, | |
| 1242 | + | Remote: remote, | |
| 1128 | 1243 | } | |
| 1129 | 1244 | if err := sendHandshake(func() error { | |
| 1130 | 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 | 1623 | defer clearParkedPending() | |
| 1509 | 1624 | ||
| 1510 | 1625 | sendParkedRouteSize := func() error { | |
| 1511 | - | current, err := term.Geometry() | |
| 1626 | + | geometry, err := term.Geometry() | |
| 1512 | 1627 | if err != nil { | |
| 1513 | 1628 | return fmt.Errorf("reading terminal geometry for parked route: %w", err) | |
| 1514 | 1629 | } | |
| 1515 | - | current = current.NormalizePixels() | |
| 1516 | - | payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight}) | |
| 1630 | + | geometry = geometry.NormalizePixels() | |
| 1631 | + | payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}) | |
| 1517 | 1632 | if err != nil { | |
| 1518 | 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 | 1720 | if awaitingReconnectReset { | |
| 1606 | 1721 | return nil | |
| 1607 | 1722 | } | |
| 1608 | - | current, serr := term.Geometry() | |
| 1723 | + | geometry, serr := term.Geometry() | |
| 1609 | 1724 | if serr != nil { | |
| 1610 | 1725 | return fmt.Errorf("reading terminal geometry for reconnect reconciliation: %w", serr) | |
| 1611 | 1726 | } | |
| 1612 | - | current = current.NormalizePixels() | |
| 1613 | - | payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight}) | |
| 1727 | + | geometry = geometry.NormalizePixels() | |
| 1728 | + | payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}) | |
| 1614 | 1729 | if err != nil { | |
| 1615 | 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 | 1913 | awaitingReconnectReset = false | |
| 1799 | 1914 | } | |
| 1800 | 1915 | if reconnect.showing && reconnect.remote { | |
| 1801 | - | current, serr := term.Geometry() | |
| 1916 | + | geometry, serr := term.Geometry() | |
| 1802 | 1917 | if serr != nil { | |
| 1803 | 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 | 1921 | return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr)) | |
| 1807 | 1922 | } | |
| 1808 | 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 | 251 | func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies { | |
| 252 | 252 | return client.Dependencies{ | |
| 253 | - | Dialer: dialer, | |
| 254 | - | Terminal: terminal, | |
| 255 | - | Clock: clock, | |
| 256 | - | Clipboard: clipboard, | |
| 257 | - | Logger: slog.New(slog.DiscardHandler), | |
| 258 | - | RuntimeObserver: observer, | |
| 253 | + | Dialer: dialer, | |
| 254 | + | Terminal: terminal, | |
| 255 | + | Clock: clock, | |
| 256 | + | DisableCapabilityProbe: true, | |
| 257 | + | Clipboard: clipboard, | |
| 258 | + | Logger: slog.New(slog.DiscardHandler), | |
| 259 | + | RuntimeObserver: observer, | |
| 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 | 180 | result := make(chan error, 1) | |
| 181 | 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 | 185 | 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 | 16 | if output.Epoch == 0 { | |
| 17 | 17 | return outputApplyState{}, false | |
| 18 | 18 | } | |
| 19 | - | if !s.initialized { | |
| 20 | - | if output.New == 0 { | |
| 21 | - | return s, true | |
| 19 | + | // State-independent terminal side effects are ordered on the same transport | |
| 20 | + | // as ordinary output but deliberately do not advance or acknowledge the | |
| 21 | + | // replay chain. They must still reach the terminal while a reset request is | |
| 22 | + | // gating state-bearing frames; handoff cleanup is the final such frame on | |
| 23 | + | // the old attachment and no later replay can make it up. | |
| 24 | + | if output.New == 0 { | |
| 25 | + | if output.Base != 0 || output.Full { | |
| 26 | + | return outputApplyState{}, false | |
| 22 | 27 | } | |
| 28 | + | return s, true | |
| 29 | + | } | |
| 30 | + | if !s.initialized { | |
| 23 | 31 | if output.Base != 0 || !output.Full { | |
| 24 | 32 | return outputApplyState{}, false | |
| 25 | 33 | } | |
| @@ -32,12 +40,6 @@ func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) { | |||
| 32 | 40 | if output.ViewRevision != s.viewRevision { | |
| 33 | 41 | 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 | 43 | if output.Full || output.Base != s.state || output.New != output.Base+1 { | |
| 42 | 44 | 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 | 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 | 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 | 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 | 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 | 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 | 504 | _, sentAck := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck }) | |
| 505 | 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 | 520 | beforeResetFlushes := flushes.Load() | |
| 508 | 521 | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ | |
| 509 | 522 | Epoch: 2, | |
| @@ -1362,7 +1375,7 @@ func TestReconnectRetryFailuresPreserveAttachError(t *testing.T) { | |||
| 1362 | 1375 | ) | |
| 1363 | 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 | 1379 | require.ErrorIs(t, err, attachErr) | |
| 1367 | 1380 | require.ErrorIs(t, err, retryErr) | |
| 1368 | 1381 | }) | |
| @@ -1419,7 +1432,7 @@ func TestReconnectRetrySleepFailuresPreserveDialError(t *testing.T) { | |||
| 1419 | 1432 | dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |
| 1420 | 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 | 1436 | require.ErrorIs(t, err, dialErr) | |
| 1424 | 1437 | require.ErrorIs(t, err, retryErr) | |
| 1425 | 1438 | }) | |
| @@ -1449,7 +1462,7 @@ func TestRemoteReconnectToastFailedDrawDoesNotBlankBounds(t *testing.T) { | |||
| 1449 | 1462 | }} | |
| 1450 | 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 | 1466 | require.ErrorContains(t, err, "reconnect toast write failed") | |
| 1454 | 1467 | require.True(t, out.failed) | |
| 1455 | 1468 | require.NotContains(t, out.String(), strings.Repeat(" ", reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)).Width)) | |
| @@ -1526,7 +1539,7 @@ func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T) | |||
| 1526 | 1539 | dialer := portsmocks.NewMockDialer(t) | |
| 1527 | 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 | 1543 | require.ErrorIs(t, err, context.Canceled) | |
| 1531 | 1544 | require.ErrorIs(t, err, clearErr) | |
| 1532 | 1545 | }) | |
| @@ -1562,7 +1575,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 1562 | 1575 | }} | |
| 1563 | 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 | 1579 | require.NoError(t, err) | |
| 1567 | 1580 | require.True(t, tr1.wasClosed()) | |
| 1568 | 1581 | require.True(t, tr2.wasClosed()) | |
| @@ -1606,7 +1619,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |||
| 1606 | 1619 | }} | |
| 1607 | 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 | 1623 | require.NoError(t, err) | |
| 1611 | 1624 | require.Equal(t, 2, dialer.calls) | |
| 1612 | 1625 | ||
| @@ -1690,7 +1703,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||
| 1690 | 1703 | dialer := portsmocks.NewMockDialer(t) | |
| 1691 | 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 | 1707 | tt.wantErr(t, err) | |
| 1695 | 1708 | for _, transport := range transports { | |
| 1696 | 1709 | assertReconnectToastAttemptPublishesOnlyClearedTheme(t, transport) | |
M internal/usecase/daemon/attachment_output.go +91 -25
| @@ -21,20 +21,47 @@ import ( | |||
| 21 | 21 | // Callers serialize access with attachedClient.sendMu. A prepared transaction | |
| 22 | 22 | // must be sent or committed as a no-op before the next transaction is prepared. | |
| 23 | 23 | type attachmentOutput struct { | |
| 24 | - | renderer *renderer.Renderer | |
| 25 | - | epoch uint64 | |
| 26 | - | next uint64 | |
| 27 | - | acked uint64 | |
| 28 | - | generation uint64 | |
| 29 | - | maxOutstanding uint64 | |
| 30 | - | outstandingAtomic atomic.Uint64 | |
| 31 | - | maxOutstandingAtomic atomic.Uint64 | |
| 32 | - | forceSnapshot bool | |
| 33 | - | initialized bool | |
| 34 | - | lastCursor cursorOut | |
| 35 | - | lastRoutePosition ports.RoutePosition | |
| 36 | - | attachment *attachedClient | |
| 37 | - | stateMu sync.Mutex | |
| 24 | + | renderer *renderer.Renderer | |
| 25 | + | epoch uint64 | |
| 26 | + | next uint64 | |
| 27 | + | acked uint64 | |
| 28 | + | generation uint64 | |
| 29 | + | maxOutstanding uint64 | |
| 30 | + | outstandingAtomic atomic.Uint64 | |
| 31 | + | maxOutstandingAtomic atomic.Uint64 | |
| 32 | + | forceSnapshot bool | |
| 33 | + | initialized bool | |
| 34 | + | lastCursor cursorOut | |
| 35 | + | lastRoutePosition ports.RoutePosition | |
| 36 | + | graphicsOutput *graphicsOutputState | |
| 37 | + | graphicsUnsupportedWarned atomic.Bool | |
| 38 | + | attachment *attachedClient | |
| 39 | + | stateMu sync.Mutex | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // reconfigureAttachmentOutput applies a replacement terminal's declared | |
| 43 | + | // capabilities to attachment-owned output state. Caller holds d.mu. | |
| 44 | + | func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, h ports.Hello) { | |
| 45 | + | if d == nil || ac == nil || ac.output == nil { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics | |
| 49 | + | ac.output.graphicsUnsupportedWarned.Store(false) | |
| 50 | + | // A replacement connection is a different outer terminal. Retire the old | |
| 51 | + | // attachment-local state without writing cleanup to the new terminal, then | |
| 52 | + | // allocate a fresh namespace for any replay it accepts. | |
| 53 | + | d.discardAttachmentOutputLocked(ac) | |
| 54 | + | if !h.KittyDirectGraphics { | |
| 55 | + | // The parked transport has already been retired. Do not emit cleanup into | |
| 56 | + | // a replacement connection that did not declare graphics support. | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | if namespace, fence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, ac.clientID)); namespace != 0 { | |
| 60 | + | ac.output.graphicsOutput = newGraphicsOutputStateWithLease(namespace, fence) | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | // Namespace exhaustion fails closed; ANSI output remains available. | |
| 64 | + | ac.terminalCapabilities.KittyGraphics = false | |
| 38 | 65 | } | |
| 39 | 66 | ||
| 40 | 67 | // lockView serializes an attachment's view publication with every output | |
| @@ -98,33 +125,49 @@ type cursorCandidate struct { | |||
| 98 | 125 | // ANSI renderer state and cursor state advance together only after the output | |
| 99 | 126 | // frame is accepted by the attachment's state stream. | |
| 100 | 127 | type preparedAttachmentOutput struct { | |
| 101 | - | output *attachmentOutput | |
| 102 | - | ansi *preparedOutput | |
| 103 | - | cursor cursorCandidate | |
| 104 | - | data []byte | |
| 128 | + | output *attachmentOutput | |
| 129 | + | ansi *preparedOutput | |
| 130 | + | graphics *preparedGraphicsOutput | |
| 131 | + | cursor cursorCandidate | |
| 132 | + | data []byte | |
| 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 | 136 | ansi, err := o.prepare(frame, damage, reset) | |
| 109 | 137 | if err != nil { | |
| 110 | 138 | return nil, err | |
| 111 | 139 | } | |
| 112 | 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 | 147 | data := append([]byte(nil), ansi.data...) | |
| 148 | + | if preparedGraphics != nil { | |
| 149 | + | data = append(data, preparedGraphics.data...) | |
| 150 | + | } | |
| 114 | 151 | data = append(data, cursor.data...) | |
| 115 | - | return &preparedAttachmentOutput{output: o, ansi: ansi, cursor: cursor, data: data}, nil | |
| 152 | + | return &preparedAttachmentOutput{output: o, ansi: ansi, graphics: preparedGraphics, cursor: cursor, data: data}, nil | |
| 116 | 153 | } | |
| 117 | 154 | ||
| 118 | 155 | func (p *preparedAttachmentOutput) send(echoAck uint64, send func(ports.Frame) error) error { | |
| 119 | 156 | if p == nil || p.ansi == nil { | |
| 120 | 157 | return nil | |
| 121 | 158 | } | |
| 122 | - | if err := p.ansi.send(p.data, echoAck, send); err != nil { | |
| 123 | - | return err | |
| 159 | + | sendFrame := send | |
| 160 | + | if p.graphics != nil { | |
| 161 | + | sendFrame = func(frame ports.Frame) error { | |
| 162 | + | p.graphics.markSendAttempted() | |
| 163 | + | return send(frame) | |
| 164 | + | } | |
| 124 | 165 | } | |
| 125 | - | if p.ansi.sent && p.output != nil { | |
| 126 | - | p.output.lastCursor = p.cursor.next | |
| 166 | + | if err := p.ansi.send(p.data, echoAck, sendFrame); err != nil { | |
| 167 | + | p.abortGraphics() | |
| 168 | + | return err | |
| 127 | 169 | } | |
| 170 | + | p.commitIfSent() | |
| 128 | 171 | return nil | |
| 129 | 172 | } | |
| 130 | 173 | ||
| @@ -133,11 +176,34 @@ func (p *preparedAttachmentOutput) commitNoSend() { | |||
| 133 | 176 | return | |
| 134 | 177 | } | |
| 135 | 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 | 191 | 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 | 207 | func (p *preparedAttachmentOutput) sent() bool { | |
| 142 | 208 | 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 | 8 | vt "github.com/bnema/vev-vt" | |
| 9 | 9 | renderer "github.com/bnema/vev-vt/ansi" | |
| 10 | + | "github.com/bnema/vev-vt/graphics" | |
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 14 | "github.com/stretchr/testify/require" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| @@ -431,6 +433,36 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing. | |||
| 431 | 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 | 466 | func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) { | |
| 435 | 467 | output := newOutputStateStream() | |
| 436 | 468 | 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 | 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 | 183 | func (ac *attachedClient) geometrySnapshot() domain.Geometry { | |
| @@ -662,7 +669,7 @@ type attachClientOptions struct { | |||
| 662 | 669 | ||
| 663 | 670 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | |
| 664 | 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 | 673 | d.mu.Unlock() | |
| 667 | 674 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 668 | 675 | target: sess, | |
| @@ -672,6 +679,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 672 | 679 | ready: false, | |
| 673 | 680 | }) | |
| 674 | 681 | if err != nil { | |
| 682 | + | d.discardAttachmentOutput(ac) | |
| 675 | 683 | if tr != nil { | |
| 676 | 684 | _ = tr.Close() | |
| 677 | 685 | } | |
| @@ -696,12 +704,15 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at | |||
| 696 | 704 | }) | |
| 697 | 705 | } | |
| 698 | 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 | 712 | // prepareAttachedClientLocked allocates one detached attachment. Caller holds | |
| 702 | 713 | // d.mu only to allocate its resume token; attachment publication happens later after | |
| 703 | 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 | 716 | resumeToken := uint64(0) | |
| 706 | 717 | if opts.resumeCapable { | |
| 707 | 718 | resumeToken = d.nextResumeTokenLocked() | |
| @@ -711,6 +722,18 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, geometry domain | |||
| 711 | 722 | } | |
| 712 | 723 | output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight) | |
| 713 | 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 | 737 | ac := &attachedClient{ | |
| 715 | 738 | tr: tr, | |
| 716 | 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 | 97 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 98 | 98 | d.closePicker(ac) | |
| 99 | 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 | 104 | d.resetScreenDefaultColors(sess) | |
| 102 | 105 | if explicit && notice { | |
| @@ -187,6 +190,9 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||
| 187 | 190 | d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac) | |
| 188 | 191 | d.closePicker(ac) | |
| 189 | 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 | 196 | d.resetScreenDefaultColors(sess) | |
| 191 | 197 | _ = ac.closeCapturedTransport(failed) | |
| 192 | 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 | 445 | sess.clipboardWorkerRunning = true | |
| 446 | 446 | sess.mu.Unlock() | |
| 447 | 447 | d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued"))) | |
| 448 | + | sourceTab := testAttachmentTab(sess) | |
| 449 | + | p := sourceTab.focusedPane() | |
| 448 | 450 | ||
| 449 | 451 | workerDone := make(chan struct{}) | |
| 450 | 452 | go func() { | |
| @@ -453,8 +455,6 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 453 | 455 | }() | |
| 454 | 456 | awaitTestCompletion(t, oldTransport.started, "clipboard send did not start") | |
| 455 | 457 | ||
| 456 | - | sourceTab := testAttachmentTab(sess) | |
| 457 | - | p := sourceTab.focusedPane() | |
| 458 | 458 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} | |
| 459 | 459 | publishPaneOwner(p, destination, &tab{}, 0) | |
| 460 | 460 | publishPaneOwner(p, sess, sourceTab, 0) | |
M internal/usecase/daemon/daemon.go +65 -23
| @@ -3,6 +3,8 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | cryptorand "crypto/rand" | |
| 7 | + | "encoding/binary" | |
| 6 | 8 | "errors" | |
| 7 | 9 | "log/slog" | |
| 8 | 10 | "math" | |
| @@ -109,6 +111,18 @@ type Daemon struct { | |||
| 109 | 111 | // with Wait. | |
| 110 | 112 | notifies []chan struct{} | |
| 111 | 113 | 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 | 126 | // parking tracks resume-capable attachments from before detach clears the | |
| 113 | 127 | // live seat until parkAttachment publishes the token into parked. | |
| 114 | 128 | // IntentResume waits on the matching entry instead of treating the live | |
| @@ -604,6 +618,23 @@ func (d *Daemon) nowUnixNano() int64 { | |||
| 604 | 618 | type systemClock struct{} | |
| 605 | 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 | 638 | func (systemClock) Now() time.Time { return time.Now() } | |
| 608 | 639 | func (systemClock) NewTimer(delay time.Duration) ports.Timer { | |
| 609 | 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 | 653 | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | |
| 623 | 654 | d := &Daemon{ | |
| 624 | - | sessions: make(map[domain.SessionID]*session), | |
| 625 | - | inactive: make(map[string]inactiveSession), | |
| 626 | - | creating: make(map[string]struct{}), | |
| 627 | - | parked: make(map[uint64]*parkedAttachment), | |
| 628 | - | parking: make(map[uint64]*parkingAttachment), | |
| 629 | - | paneProcessCtx: paneProcessCtx, | |
| 630 | - | paneProcessCancel: paneProcessCancel, | |
| 631 | - | ptys: ptys, | |
| 632 | - | clock: clock, | |
| 633 | - | log: log, | |
| 634 | - | baseEnv: os.Environ(), | |
| 635 | - | shell: defaultShellCommand, | |
| 636 | - | dirOrHome: dirOrHome, | |
| 637 | - | done: make(chan struct{}), | |
| 638 | - | restoreDone: make(chan struct{}), | |
| 639 | - | animWake: make(chan struct{}, 1), | |
| 640 | - | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 641 | - | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | |
| 642 | - | snapshotWake: make(chan struct{}, 1), | |
| 643 | - | notices: newNoticeCenter(), | |
| 644 | - | remoteCatalog: newRemoteCatalogState(), | |
| 645 | - | resumeParkGrace: defaultResumeParkGrace, | |
| 655 | + | sessions: make(map[domain.SessionID]*session), | |
| 656 | + | inactive: make(map[string]inactiveSession), | |
| 657 | + | creating: make(map[string]struct{}), | |
| 658 | + | parked: make(map[uint64]*parkedAttachment), | |
| 659 | + | graphicsNamespaces: make(map[uint64]struct{}), | |
| 660 | + | graphicsNamespaceFences: make(map[uint64]uint64), | |
| 661 | + | graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine), | |
| 662 | + | graphicsNamespaceSalt: newGraphicsNamespaceSalt(), | |
| 663 | + | parking: make(map[uint64]*parkingAttachment), | |
| 664 | + | paneProcessCtx: paneProcessCtx, | |
| 665 | + | paneProcessCancel: paneProcessCancel, | |
| 666 | + | ptys: ptys, | |
| 667 | + | clock: clock, | |
| 668 | + | log: log, | |
| 669 | + | baseEnv: os.Environ(), | |
| 670 | + | shell: defaultShellCommand, | |
| 671 | + | dirOrHome: dirOrHome, | |
| 672 | + | done: make(chan struct{}), | |
| 673 | + | restoreDone: make(chan struct{}), | |
| 674 | + | animWake: make(chan struct{}, 1), | |
| 675 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 676 | + | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | |
| 677 | + | snapshotWake: make(chan struct{}, 1), | |
| 678 | + | notices: newNoticeCenter(), | |
| 679 | + | remoteCatalog: newRemoteCatalogState(), | |
| 680 | + | resumeParkGrace: defaultResumeParkGrace, | |
| 646 | 681 | barScripts: &barScriptState{ | |
| 647 | 682 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 648 | 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 | 1343 | sess.mu.Unlock() | |
| 1309 | 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 | 1349 | if h.TrueColor && !terminalCapabilities.TrueColor() { | |
| 1311 | 1350 | terminalCapabilities.ColorMode = ports.TerminalColorTrueColor | |
| 1312 | 1351 | terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared | |
| @@ -1324,7 +1363,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1324 | 1363 | if geometry.Size != sz { | |
| 1325 | 1364 | geometry = domain.Geometry{Size: sz} | |
| 1326 | 1365 | } | |
| 1327 | - | ac := d.prepareAttachedClientLocked(tr, geometry, opts) | |
| 1366 | + | ac := d.prepareAttachedClientLocked(sess, tr, geometry, opts) | |
| 1328 | 1367 | d.mu.Unlock() | |
| 1329 | 1368 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1330 | 1369 | target: sess, | |
| @@ -1335,6 +1374,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1335 | 1374 | ready: false, | |
| 1336 | 1375 | }) | |
| 1337 | 1376 | if err != nil { | |
| 1377 | + | if ac.output != nil && ac.output.graphicsOutput != nil { | |
| 1378 | + | d.retireGraphicsOutput(ac, ac.output.graphicsOutput) | |
| 1379 | + | } | |
| 1338 | 1380 | if tr != nil { | |
| 1339 | 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 | 405 | d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup) | |
| 406 | 406 | tr, sends := newCapturingTransport(t) | |
| 407 | 407 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 408 | + | ac.output.attachment = ac | |
| 408 | 409 | ac.initOverlays() | |
| 409 | 410 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 410 | 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 | 143 | for _, attachment := range p.retiredAttachments { | |
| 144 | 144 | d.unregisterPreview(attachment.ac) | |
| 145 | 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 | 149 | d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled) | |
| 147 | 150 | } | |
| 148 | 151 | for _, retirement := range p.retiredParked { | |
M internal/usecase/daemon/pane.go +8 -1
| @@ -110,7 +110,7 @@ type pane struct { | |||
| 110 | 110 | id layout.PaneID | |
| 111 | 111 | stableID string | |
| 112 | 112 | pty ports.PTY | |
| 113 | - | mu sync.Mutex // guards screen, history, syncGen, rect, geometry, resize state, PTY side effects, title, and owner publication | |
| 113 | + | mu sync.Mutex // guards screen, history, syncGen, rect, geometry, graphics scene geometry, resize state, PTY side effects, title, and owner publication | |
| 114 | 114 | owner atomic.Pointer[paneOwner] | |
| 115 | 115 | // ownerGeneration is advanced only while mu is held. Readers consume the | |
| 116 | 116 | // generation through the immutable owner pointer above. | |
| @@ -121,6 +121,13 @@ type pane struct { | |||
| 121 | 121 | syncGen uint64 | |
| 122 | 122 | rect domain.Rect | |
| 123 | 123 | 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 | 131 | // resizeApplying gates VT parsing across PTY.Resize. The reader continues | |
| 125 | 132 | // draining into resizePending so output is replayed against the target (or | |
| 126 | 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 | 941 | if samePeerEligible { | |
| 942 | 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 | 952 | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { | |
| 945 | 953 | if samePeerEligible { | |
| 946 | 954 | token.ac.clearSamePeerOffer() | |
M internal/usecase/daemon/remote_route_test.go +81 -0
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "os" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/stretchr/testify/require" | |
| @@ -10,6 +11,86 @@ import ( | |||
| 10 | 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 | 94 | func TestRouteRemoteTargetSelectsExactLiveTab(t *testing.T) { | |
| 14 | 95 | d := newTestDaemon(t, nil, stubClock{}) | |
| 15 | 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 | 160 | effects.renderCoordinator = owner.session.renderCoordinator() | |
| 161 | 161 | } | |
| 162 | 162 | effects.wasSyncing = p.screen.SyncUpdateActive() | |
| 163 | - | p.screen.Write(data) | |
| 163 | + | writePaneScreenLocked(p, data) | |
| 164 | 164 | p.refreshTerminalTitleLocked() | |
| 165 | 165 | effects.isSyncing = p.screen.SyncUpdateActive() | |
| 166 | 166 | effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data) | |
| @@ -187,6 +187,7 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool | |||
| 187 | 187 | effects.attention = p.ptyAttention | |
| 188 | 188 | p.ptyAttention = false | |
| 189 | 189 | effects.responses = append([]byte(nil), p.ptyResponses...) | |
| 190 | + | effects.responses = filterUnsupportedKittyAnimationResponses(effects.responses) | |
| 190 | 191 | p.ptyResponses = p.ptyResponses[:0] | |
| 191 | 192 | effects.clipboards = append([]string(nil), p.ptyClipboards...) | |
| 192 | 193 | p.ptyClipboards = p.ptyClipboards[:0] | |
| @@ -195,6 +196,39 @@ func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool | |||
| 195 | 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 | 232 | // applyPanePTYEffects emits only effects still bound to the owner generation | |
| 199 | 233 | // that parsed them. Each owner-sensitive boundary revalidates independently so | |
| 200 | 234 | // 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 | 3 | import ( | |
| 4 | 4 | renderer "github.com/bnema/vev-vt" | |
| 5 | + | vevgraphics "github.com/bnema/vev-vt/graphics" | |
| 5 | 6 | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -22,6 +23,7 @@ type renderCaptureScratch struct { | |||
| 22 | 23 | placements []layout.Placement | |
| 23 | 24 | dividers []layout.Divider | |
| 24 | 25 | layoutTab *tab | |
| 26 | + | layoutRoot *layout.Node | |
| 25 | 27 | layoutGeneration uint64 | |
| 26 | 28 | layoutArea domain.Rect | |
| 27 | 29 | layoutFocus layout.PaneID | |
| @@ -40,19 +42,25 @@ type damageReceipt struct { | |||
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 44 | type capturedRenderState struct { | |
| 43 | - | attachment *attachedClient // identity only; never dereferenced by composition | |
| 44 | - | lease *attachmentLease | |
| 45 | - | view attachmentView | |
| 46 | - | window domain.Size | |
| 47 | - | reset bool | |
| 48 | - | layout capturedTabLayout | |
| 49 | - | panes []capturedPaneRenderState | |
| 50 | - | floating capturedFloatingRenderState | |
| 51 | - | bars barState | |
| 52 | - | theme themeui.Theme | |
| 53 | - | styles themeui.Styles | |
| 54 | - | styleGeneration uint64 | |
| 55 | - | overlays capturedOverlayRenderState | |
| 45 | + | attachment *attachedClient // identity only; never dereferenced by composition | |
| 46 | + | sessionID domain.SessionID | |
| 47 | + | incarnation domain.IncarnationID | |
| 48 | + | lease *attachmentLease | |
| 49 | + | view attachmentView | |
| 50 | + | window domain.Size | |
| 51 | + | reset bool | |
| 52 | + | layout capturedTabLayout | |
| 53 | + | panes []capturedPaneRenderState | |
| 54 | + | floating capturedFloatingRenderState | |
| 55 | + | bars barState | |
| 56 | + | theme themeui.Theme | |
| 57 | + | styles themeui.Styles | |
| 58 | + | styleGeneration uint64 | |
| 59 | + | overlays capturedOverlayRenderState | |
| 60 | + | // suppressedGraphics records a late Kitty scene observed on an attachment | |
| 61 | + | // that cannot emit graphics. Capture keeps the scene out of the output but | |
| 62 | + | // lets the daemon issue one bounded user warning after pane locks release. | |
| 63 | + | suppressedGraphics bool | |
| 56 | 64 | preview picker.Preview | |
| 57 | 65 | cursor capturedCursorInputs | |
| 58 | 66 | tabGeneration uint64 | |
| @@ -71,10 +79,15 @@ type capturedTabLayout struct { | |||
| 71 | 79 | ||
| 72 | 80 | type capturedPaneRenderState struct { | |
| 73 | 81 | id layout.PaneID | |
| 82 | + | stableID domain.PaneStableID | |
| 74 | 83 | frame renderer.Frame | |
| 75 | 84 | rawDamage []renderer.Damage | |
| 76 | 85 | damage []renderer.Damage | |
| 77 | 86 | 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 | 91 | title string | |
| 79 | 92 | titleGeneration uint64 | |
| 80 | 93 | placement layout.Placement | |
| @@ -121,6 +134,33 @@ type capturedCursorInputs struct { | |||
| 121 | 134 | 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 | 164 | // capturePaneRenderStateLocked copies only the visible rectangle required by | |
| 125 | 165 | // composition. It never copies scrollback/history and never lets the mutable | |
| 126 | 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 | 171 | func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState { | |
| 132 | - | out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation | |
| 172 | + | out.id, out.stableID, out.title, out.titleGeneration = p.id, domain.PaneStableID(p.stableID), p.displayTitleLocked(), p.title.generation | |
| 173 | + | // GraphicsSnapshot never allocates graphics state for a text-only screen and | |
| 174 | + | // returns an immutable scene reference when Kitty graphics were used. | |
| 175 | + | out.graphics = p.screen.GraphicsSnapshot() | |
| 176 | + | geometry := p.screen.Geometry() | |
| 177 | + | currentGraphicsGeometry := domain.Geometry{ | |
| 178 | + | Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows}, | |
| 179 | + | PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight, | |
| 180 | + | }.NormalizePixels() | |
| 181 | + | // The scene retains placement coordinates across SetGeometry. Capture the | |
| 182 | + | // unit basis when the first placement appears and keep it until the placement | |
| 183 | + | // scene becomes empty; otherwise an attachment resize silently reinterprets | |
| 184 | + | // old pixel coordinates using the new cell dimensions. | |
| 185 | + | hasPlacements := out.graphics != nil && out.graphics.Usage().Placements != 0 | |
| 186 | + | if hasPlacements && !p.graphicsPlacementScene { | |
| 187 | + | p.graphicsCoordinateGeometry = currentGraphicsGeometry | |
| 188 | + | } | |
| 189 | + | p.graphicsPlacementScene = hasPlacements | |
| 190 | + | if hasPlacements { | |
| 191 | + | out.graphicsGeometry = p.graphicsCoordinateGeometry | |
| 192 | + | } else { | |
| 193 | + | p.graphicsCoordinateGeometry = domain.Geometry{} | |
| 194 | + | out.graphicsGeometry = currentGraphicsGeometry | |
| 195 | + | } | |
| 133 | 196 | damage := p.screen.CaptureDamage() | |
| 134 | 197 | out.damageGeneration = damage.Generation | |
| 135 | 198 | out.rawDamage = append(out.rawDamage[:0], damage.Damage...) | |
| @@ -212,6 +275,8 @@ func captureLocalRenderState( | |||
| 212 | 275 | } | |
| 213 | 276 | sess.mu.Lock() | |
| 214 | 277 | _, owned := sess.attachments[ac] | |
| 278 | + | sessionID := sess.id | |
| 279 | + | incarnation := sess.incarnation | |
| 215 | 280 | sess.mu.Unlock() | |
| 216 | 281 | if !owned { | |
| 217 | 282 | return nil, false | |
| @@ -257,12 +322,17 @@ func captureLocalRenderState( | |||
| 257 | 322 | area: area, focus: focus, placements: scratch.placements, | |
| 258 | 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 | 330 | layoutSnap = solveTabLayoutLocked(tb) | |
| 262 | 331 | layoutSnap.focus = focus | |
| 263 | 332 | scratch.placements = append(scratch.placements[:0], layoutSnap.placements...) | |
| 264 | 333 | scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...) | |
| 265 | 334 | scratch.layoutTab = tb | |
| 335 | + | scratch.layoutRoot = layoutRoot | |
| 266 | 336 | scratch.layoutGeneration = tb.layoutGeneration | |
| 267 | 337 | scratch.layoutArea = area | |
| 268 | 338 | scratch.layoutFocus = focus | |
| @@ -278,7 +348,7 @@ func captureLocalRenderState( | |||
| 278 | 348 | window = ac.sizeSnapshot() | |
| 279 | 349 | } | |
| 280 | 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 | 352 | reset: reset, bars: bars, theme: bars.theme, | |
| 283 | 353 | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 284 | 354 | overlays: overlays, preview: preview, | |
| @@ -302,6 +372,12 @@ func captureLocalRenderState( | |||
| 302 | 372 | } | |
| 303 | 373 | p.mu.Lock() | |
| 304 | 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 | 381 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | |
| 306 | 382 | captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus | |
| 307 | 383 | if captured.focused { | |
| @@ -322,6 +398,12 @@ func captureLocalRenderState( | |||
| 322 | 398 | p.mu.Lock() | |
| 323 | 399 | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) | |
| 324 | 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 | 407 | seen := false | |
| 326 | 408 | for _, receipt := range state.receipts { | |
| 327 | 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 | 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 | 440 | var data []byte | |
| 441 | 441 | if prepared != nil { | |
| 442 | 442 | data = prepared.data | |
| @@ -468,6 +468,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 468 | 468 | } | |
| 469 | 469 | var sendTr ports.Transport | |
| 470 | 470 | var sendErr error | |
| 471 | + | suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics() | |
| 471 | 472 | if len(data) > 0 { | |
| 472 | 473 | sendTransport := ac.transportSnapshot() | |
| 473 | 474 | sendTr = sendTransport.transport | |
| @@ -502,6 +503,9 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 502 | 503 | ac.sendMu.Unlock() | |
| 503 | 504 | return true | |
| 504 | 505 | } | |
| 506 | + | if sendErr != nil { | |
| 507 | + | prepared.abort() | |
| 508 | + | } | |
| 505 | 509 | } | |
| 506 | 510 | if sendErr == nil { | |
| 507 | 511 | if len(data) == 0 { | |
| @@ -564,6 +568,9 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 564 | 568 | } | |
| 565 | 569 | } | |
| 566 | 570 | ac.sendMu.Unlock() | |
| 571 | + | if suppressedGraphics && d.warnUnsupportedGraphics(ac) { | |
| 572 | + | d.repaintForNotice(ac) | |
| 573 | + | } | |
| 567 | 574 | if sendErr != nil { | |
| 568 | 575 | // A transport failure may invalidate the role gate. Release this render's | |
| 569 | 576 | // 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 | 330 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | |
| 331 | 331 | ||
| 332 | 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 | 334 | require.NoError(t, err) | |
| 335 | 335 | var outputFrame ports.Frame | |
| 336 | 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 | 991 | d.waitNotifies() | |
| 992 | 992 | } | |
| 993 | 993 | ||
| 994 | - | func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |
| 994 | + | func TestPTYKittyIcatDetectionGetsResponsesWrittenBackToPTY(t *testing.T) { | |
| 995 | 995 | d := newTestDaemon(t, nil, stubClock{}) | |
| 996 | 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 | 998 | writes := make(chan []byte, 1) | |
| 999 | 999 | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 1000 | 1000 | if len(chunks) == 0 { | |
| @@ -1023,7 +1023,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||
| 1023 | 1023 | ||
| 1024 | 1024 | select { | |
| 1025 | 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 | 1027 | case <-time.After(time.Second): | |
| 1028 | 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 | 1078 | func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) { | |
| 1038 | 1079 | _, sess, _, _ := newManualSessionWithPTYs(t, nil) | |
| 1039 | 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 | 304 | d.mu.Unlock() | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | - | // removeParkedLocked invalidates one parked attachment. Caller holds d.mu and | |
| 308 | - | // has verified d.parked[token] still points at parked when that matters. | |
| 307 | + | // removeParkedLocked invalidates one non-nil parked attachment. Caller holds | |
| 308 | + | // d.mu and has verified d.parked[token] still points at parked when that matters. | |
| 309 | 309 | func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) { | |
| 310 | 310 | delete(d.parked, token) | |
| 311 | + | d.discardAttachmentOutputLocked(parked.ac) | |
| 311 | 312 | parked.ac.resumeToken = 0 | |
| 312 | 313 | parked.ac.parked = false | |
| 313 | 314 | if parked.timer != nil { | |
| @@ -410,6 +411,7 @@ type parkedAttachmentRetirement struct { | |||
| 410 | 411 | ||
| 411 | 412 | func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { | |
| 412 | 413 | delete(d.parked, token) | |
| 414 | + | d.discardAttachmentOutputLocked(parked.ac) | |
| 413 | 415 | parked.ac.resumeToken = 0 | |
| 414 | 416 | parked.ac.parked = false | |
| 415 | 417 | parked.ac.connectionGeneration.Add(1) | |
| @@ -602,6 +604,9 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) | |||
| 602 | 604 | // complete the claiming terminal's geometry before the resumed | |
| 603 | 605 | // attachment is allowed to produce its first frame. | |
| 604 | 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 | 611 | if err != nil && resumed != nil { | |
| 607 | 612 | d.abortResumeClaim(resumed) | |
| @@ -646,6 +651,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 646 | 651 | ac.rebaseOutput() | |
| 647 | 652 | ac.output.setWindow(h.MaxOutputInFlight) | |
| 648 | 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 | 658 | geometry := h.Geometry() | |
| 650 | 659 | if geometry.Size != sz { | |
| 651 | 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 | 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 | 1834 | d.notifyDetachedSnapshotAsync(attachment, reason) | |
| 1832 | 1835 | } | |
| 1833 | 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 | 416 | p.mu.Unlock() | |
| 417 | 417 | if sess == nil { | |
| 418 | 418 | p.mu.Lock() | |
| 419 | - | p.screen.Write(data) | |
| 419 | + | writePaneScreenLocked(p, data) | |
| 420 | 420 | p.refreshTerminalTitleLocked() | |
| 421 | 421 | p.mu.Unlock() | |
| 422 | 422 | continue | |