Skip to content

terminal/osc: support iTerm2 OSC 1337 multipart File= transfers#378

Merged
deblasis merged 2 commits into
windowsfrom
kitten-into-wintty/iterm2-images-multipart
May 19, 2026
Merged

terminal/osc: support iTerm2 OSC 1337 multipart File= transfers#378
deblasis merged 2 commits into
windowsfrom
kitten-into-wintty/iterm2-images-multipart

Conversation

@deblasis
Copy link
Copy Markdown
Owner

Follow-up B to #377. The single-shot File= path can only carry images that fit in a single OSC payload (~2 KiB after base64); larger images use the multipart split:

OSC 1337;MultipartFile=options    -- start, options only, no chunk
OSC 1337;FilePart=base64_chunk    -- continue, repeat as needed
OSC 1337;FileEnd                  -- terminator

iTerm2's spec has no session identifier, so transfers are strictly serialized: one in flight at a time.

Wire-up

  • New osc.Command.Iterm2MultipartEvent union { start: hints, chunk: bytes, end } emitted one-per-OSC by the parser.
  • New Iterm2MultipartAssembler in osc/parsers/iterm2.zig stitches events across OSCs: stashes hints + ArrayList(u8) on start, appends chunks, returns an Iterm2ImageTransmit on end. Owned by the stream handler via a multipart_iterm2 field with deinit on shutdown.
  • The MultipartFile arm rejects without inline=1, matching the single-shot File= path; the parser walks options through the same shared parseFileOptions helper so the geometry hints from terminal/osc: honor iTerm2 OSC 1337 File= geometry hints #377 work on multipart too.
  • The single-shot iterm2ImageTransmit handler is reused unchanged. After FileEnd the multipart handler frees the assembled buffer immediately after the inner call returns, because synthKittyCommand copies the bytes through base64 decode into a fresh kitty Command buffer.

The payload type on Iterm2ImageTransmit relaxed from [:0]const u8 to []const u8. The single-shot parser still produces a slice into the parser's null-terminated capture buffer, and the multipart assembler now produces a heap-allocated slice without an artificial sentinel. No consumer used the sentinel (simd.base64 takes []const u8).

Error / edge cases

Case Behavior
FilePart with no active transfer log.warn, drop chunk
FileEnd with no active transfer log.warn, no-op
MultipartFile while one is in flight log.warn, discard previous, start fresh
Accumulated payload above 64 MiB (matches APC kitty cap) log.warn, drop entire transfer
StreamHandler.deinit frees the in-flight payload via the assembler

Tests

Parser (6 new):

  • MultipartFile with inline+hints
  • MultipartFile without inline rejected
  • FilePart with chunk
  • FilePart with empty value
  • FileEnd
  • FileEnd= tolerated

Assembler (5 new):

  • happy path stitches chunks + preserves hints
  • orphan chunk dropped
  • orphan end ignored
  • overlapping start discards previous
  • oversize transfer rejected

Verification

  • Windows: zig build test -> 2900/2955 pass, 55 skipped, 0 failures
  • Mac (alessandros-macbook-air, arm64, Zig 0.15.2): zig build test-lib-vt -Dtest-filter=iterm2 -> 145/147 pass, 2 skipped
  • Linux (ubuntinovm): offline at commit time

Deferred (still tracked from #375)

  • JPEG/GIF: needs Ghostty kitty image decoder extension (PNG-only today)
  • iTerm2 query/response sequences

Test plan

  • zig build test passes on Windows
  • zig build test-lib-vt -Dtest-filter=iterm2 passes on Mac
  • zig build test-lib-vt passes on Linux (when VM is back online)
  • smoke probe-iterm2-multipart.ps1 emits a 3-OSC chunked PNG and Wintty.exe renders it

deblasis added 2 commits May 19, 2026 05:23
Follow-up B to #377. The single-shot File= path can only carry images
that fit in a single OSC payload (~2 KiB after base64); larger images
use the multipart split:

  OSC 1337;MultipartFile=options    -- start, options only, no chunk
  OSC 1337;FilePart=base64_chunk    -- continue, repeat as needed
  OSC 1337;FileEnd                  -- terminator

iTerm2's spec has no session identifier, so transfers are strictly
serialized: one in flight at a time.

Wire-up:

- New osc.Command.Iterm2MultipartEvent union { start: hints, chunk:
  bytes, end } emitted one-per-OSC by the parser.
- New Iterm2MultipartAssembler in osc/parsers/iterm2.zig stitches
  events across OSCs: stashes hints + ArrayList(u8) on start, appends
  chunks, returns an Iterm2ImageTransmit on end. Owned by the stream
  handler via a multipart_iterm2 field with deinit on shutdown.
- The MultipartFile arm rejects without inline=1, matching the
  single-shot File= path; the parser walks options through the same
  shared parseFileOptions helper so the geometry hints from #377 work
  on multipart too.
- The single-shot iterm2ImageTransmit handler is reused unchanged.
  After FileEnd the multipart handler frees the assembled buffer
  immediately after the inner call returns, because synthKittyCommand
  copies the bytes through base64 decode into a fresh kitty Command
  buffer.

The payload type on Iterm2ImageTransmit relaxed from [:0]const u8 to
[]const u8. The single-shot parser still produces a slice into the
parser's null-terminated capture buffer, and the multipart assembler
now produces a heap-allocated slice without an artificial sentinel.
No consumer used the sentinel (simd.base64 takes []const u8).

Error / edge cases:

- FilePart with no active transfer: log.warn, drop chunk
- FileEnd with no active transfer: log.warn, no-op
- MultipartFile while one is in flight: log.warn, discard previous,
  start fresh
- Accumulated payload above 64 MiB (matches APC kitty cap): log.warn,
  drop entire transfer
- StreamHandler.deinit frees the in-flight payload via the assembler

Tests:

Parser (6): MultipartFile with inline+hints, MultipartFile without
inline rejected, FilePart with chunk, FilePart with empty value,
FileEnd, FileEnd= tolerated.

Assembler (5): happy path stitches chunks + preserves hints, orphan
chunk dropped, orphan end ignored, overlapping start discards
previous, oversize transfer rejected.

Verified:

- Windows: zig build test -> 2900/2955 pass, 55 skipped, 0 failures
- Mac (alessandros-macbook-air, arm64, Zig 0.15.2):
  zig build test-lib-vt -Dtest-filter=iterm2 -> 145/147, 2 skipped
- Linux: ubuntinovm still offline at commit time
- max_payload_bytes now references apc.Protocol.defaultMaxBytes(.kitty)
  instead of a hard-coded 64 * 1024 * 1024 literal. The literal had
  drifted by 1 MiB from the actual APC kitty cap (65 MiB), and the
  comment claimed "matches" when it did not. Sourcing the value keeps
  the two image-transport ceilings in lockstep automatically.
- Drop the "we cheat" framing in the oversize test comment; the
  ArrayList.resize call does a real 65 MiB allocation, which is what
  we want so the boundary check exercises the production arithmetic.
- Reword the FileEnd= tolerance comment so it stands on its own
  ("defensive because the key=value parse loop produces an empty
  value for the bare key") rather than citing phantom emitters that
  round-trip the key as `key=`.

No behavior changes; 116/117 still green on Windows.
@deblasis deblasis marked this pull request as ready for review May 19, 2026 03:41
@deblasis deblasis merged commit 43f8beb into windows May 19, 2026
98 checks passed
deblasis added a commit that referenced this pull request May 20, 2026
PR #377 added Iterm2ImageTransmit + Iterm2ImageHints + Iterm2MultipartEvent
as stream.zig Action variants. The stream.zig TaggedUnion meta-programming
builds an extern union over all Action payload types (lib/union.zig:90),
and Zig 0.15.2 rejects extern unions whose fields are not C-ABI compatible.

Three specific incompatibilities:

* Iterm2ImageTransmit.payload is `[]const u8` -- a Zig slice with a length
  field that has no stable C layout.
* Iterm2MultipartEvent is a `union(enum)` with `.chunk: []const u8` -- a
  slice inside an unnamed Zig union tag, doubly non-extern.
* Iterm2ImageHints is a plain Zig struct whose fields are individually
  extern-compatible but the struct itself carries no layout guarantee.

Surfaced via `zig build` (the libghostty `combine_archives` step exercises
the meta-programming); `zig build test` was happy because the test target
doesn't pull lib/. CI on Linux/Mac didn't catch it because Windows is the
only target that runs the full DX12 + iTerm2 path; #377 documented the
"Linux+Mac unverified" caveat.

Per the canonical precedent at apprt/action.zig `KeyTable` (lines 794-833),
three localized changes:

* Promote Iterm2ImageHints to `extern struct`. Field set, defaults and
  API are unchanged; runtime layout is identical (u32, u32, bool packs
  to 12 bytes either way), so the @sizeof(Command) == 64 assertion at
  osc.zig:368 still holds.
* Add `Iterm2ImageTransmit.C` (`extern struct` with nested ptr+len
  payload and an embedded Iterm2ImageHints) plus `cval()`. Includes a
  borrowed-pointer LIFETIME comment so consumers know to copy the bytes
  before the dispatch callback returns.
* Add `Iterm2MultipartEvent.{Tag, CValue, C}` matching KeyTable's
  `{Tag, CValue, C}` shape exactly: `Tag` is an `enum(c_int)`, `CValue`
  is the `extern union`, `C` wraps both. `cval()` uses `undefined` for
  `.end` (no payload) following the KeyTable convention. Wire-format
  mirror comments (`Sync with: ghostty_osc_iterm2_*`) make the eventual
  C header generation discoverable.

After this, both `zig build` and `zig build test` are clean on Windows.

Closes the pre-existing build break introduced by #377/#378.

Co-Authored-By: Alessandro De Blasis <alex@deblasis.net>
deblasis added a commit that referenced this pull request May 20, 2026
PR #377 added Iterm2ImageTransmit + Iterm2ImageHints + Iterm2MultipartEvent
as stream.zig Action variants. The stream.zig TaggedUnion meta-programming
builds an extern union over all Action payload types (lib/union.zig:90),
and Zig 0.15.2 rejects extern unions whose fields are not C-ABI compatible.

Three specific incompatibilities:

* Iterm2ImageTransmit.payload is `[]const u8` -- a Zig slice with a length
  field that has no stable C layout.
* Iterm2MultipartEvent is a `union(enum)` with `.chunk: []const u8` -- a
  slice inside an unnamed Zig union tag, doubly non-extern.
* Iterm2ImageHints is a plain Zig struct whose fields are individually
  extern-compatible but the struct itself carries no layout guarantee.

Surfaced via `zig build` (the libghostty `combine_archives` step exercises
the meta-programming); `zig build test` was happy because the test target
doesn't pull lib/. CI on Linux/Mac didn't catch it because Windows is the
only target that runs the full DX12 + iTerm2 path; #377 documented the
"Linux+Mac unverified" caveat.

Per the canonical precedent at apprt/action.zig `KeyTable` (lines 794-833),
three localized changes:

* Promote Iterm2ImageHints to `extern struct`. Field set, defaults and
  API are unchanged; runtime layout is identical (u32, u32, bool packs
  to 12 bytes either way), so the @sizeof(Command) == 64 assertion at
  osc.zig:368 still holds.
* Add `Iterm2ImageTransmit.C` (`extern struct` with nested ptr+len
  payload and an embedded Iterm2ImageHints) plus `cval()`. Includes a
  borrowed-pointer LIFETIME comment so consumers know to copy the bytes
  before the dispatch callback returns.
* Add `Iterm2MultipartEvent.{Tag, CValue, C}` matching KeyTable's
  `{Tag, CValue, C}` shape exactly: `Tag` is an `enum(c_int)`, `CValue`
  is the `extern union`, `C` wraps both. `cval()` uses `undefined` for
  `.end` (no payload) following the KeyTable convention. Wire-format
  mirror comments (`Sync with: ghostty_osc_iterm2_*`) make the eventual
  C header generation discoverable.

After this, both `zig build` and `zig build test` are clean on Windows.

Closes the pre-existing build break introduced by #377/#378.
deblasis added a commit that referenced this pull request May 21, 2026
* terminal/osc: support iTerm2 OSC 1337 multipart File= transfers

Follow-up B to #377. The single-shot File= path can only carry images
that fit in a single OSC payload (~2 KiB after base64); larger images
use the multipart split:

  OSC 1337;MultipartFile=options    -- start, options only, no chunk
  OSC 1337;FilePart=base64_chunk    -- continue, repeat as needed
  OSC 1337;FileEnd                  -- terminator

iTerm2's spec has no session identifier, so transfers are strictly
serialized: one in flight at a time.

Wire-up:

- New osc.Command.Iterm2MultipartEvent union { start: hints, chunk:
  bytes, end } emitted one-per-OSC by the parser.
- New Iterm2MultipartAssembler in osc/parsers/iterm2.zig stitches
  events across OSCs: stashes hints + ArrayList(u8) on start, appends
  chunks, returns an Iterm2ImageTransmit on end. Owned by the stream
  handler via a multipart_iterm2 field with deinit on shutdown.
- The MultipartFile arm rejects without inline=1, matching the
  single-shot File= path; the parser walks options through the same
  shared parseFileOptions helper so the geometry hints from #377 work
  on multipart too.
- The single-shot iterm2ImageTransmit handler is reused unchanged.
  After FileEnd the multipart handler frees the assembled buffer
  immediately after the inner call returns, because synthKittyCommand
  copies the bytes through base64 decode into a fresh kitty Command
  buffer.

The payload type on Iterm2ImageTransmit relaxed from [:0]const u8 to
[]const u8. The single-shot parser still produces a slice into the
parser's null-terminated capture buffer, and the multipart assembler
now produces a heap-allocated slice without an artificial sentinel.
No consumer used the sentinel (simd.base64 takes []const u8).

Error / edge cases:

- FilePart with no active transfer: log.warn, drop chunk
- FileEnd with no active transfer: log.warn, no-op
- MultipartFile while one is in flight: log.warn, discard previous,
  start fresh
- Accumulated payload above 64 MiB (matches APC kitty cap): log.warn,
  drop entire transfer
- StreamHandler.deinit frees the in-flight payload via the assembler

Tests:

Parser (6): MultipartFile with inline+hints, MultipartFile without
inline rejected, FilePart with chunk, FilePart with empty value,
FileEnd, FileEnd= tolerated.

Assembler (5): happy path stitches chunks + preserves hints, orphan
chunk dropped, orphan end ignored, overlapping start discards
previous, oversize transfer rejected.

Verified:

- Windows: zig build test -> 2900/2955 pass, 55 skipped, 0 failures
- Mac (alessandros-macbook-air, arm64, Zig 0.15.2):
  zig build test-lib-vt -Dtest-filter=iterm2 -> 145/147, 2 skipped
- Linux: ubuntinovm still offline at commit time

* terminal/osc: fold reviewer feedback on multipart assembler

- max_payload_bytes now references apc.Protocol.defaultMaxBytes(.kitty)
  instead of a hard-coded 64 * 1024 * 1024 literal. The literal had
  drifted by 1 MiB from the actual APC kitty cap (65 MiB), and the
  comment claimed "matches" when it did not. Sourcing the value keeps
  the two image-transport ceilings in lockstep automatically.
- Drop the "we cheat" framing in the oversize test comment; the
  ArrayList.resize call does a real 65 MiB allocation, which is what
  we want so the boundary check exercises the production arithmetic.
- Reword the FileEnd= tolerance comment so it stands on its own
  ("defensive because the key=value parse loop produces an empty
  value for the bare key") rather than citing phantom emitters that
  round-trip the key as `key=`.

No behavior changes; 116/117 still green on Windows.
deblasis added a commit that referenced this pull request May 21, 2026
PR #377 added Iterm2ImageTransmit + Iterm2ImageHints + Iterm2MultipartEvent
as stream.zig Action variants. The stream.zig TaggedUnion meta-programming
builds an extern union over all Action payload types (lib/union.zig:90),
and Zig 0.15.2 rejects extern unions whose fields are not C-ABI compatible.

Three specific incompatibilities:

* Iterm2ImageTransmit.payload is `[]const u8` -- a Zig slice with a length
  field that has no stable C layout.
* Iterm2MultipartEvent is a `union(enum)` with `.chunk: []const u8` -- a
  slice inside an unnamed Zig union tag, doubly non-extern.
* Iterm2ImageHints is a plain Zig struct whose fields are individually
  extern-compatible but the struct itself carries no layout guarantee.

Surfaced via `zig build` (the libghostty `combine_archives` step exercises
the meta-programming); `zig build test` was happy because the test target
doesn't pull lib/. CI on Linux/Mac didn't catch it because Windows is the
only target that runs the full DX12 + iTerm2 path; #377 documented the
"Linux+Mac unverified" caveat.

Per the canonical precedent at apprt/action.zig `KeyTable` (lines 794-833),
three localized changes:

* Promote Iterm2ImageHints to `extern struct`. Field set, defaults and
  API are unchanged; runtime layout is identical (u32, u32, bool packs
  to 12 bytes either way), so the @sizeof(Command) == 64 assertion at
  osc.zig:368 still holds.
* Add `Iterm2ImageTransmit.C` (`extern struct` with nested ptr+len
  payload and an embedded Iterm2ImageHints) plus `cval()`. Includes a
  borrowed-pointer LIFETIME comment so consumers know to copy the bytes
  before the dispatch callback returns.
* Add `Iterm2MultipartEvent.{Tag, CValue, C}` matching KeyTable's
  `{Tag, CValue, C}` shape exactly: `Tag` is an `enum(c_int)`, `CValue`
  is the `extern union`, `C` wraps both. `cval()` uses `undefined` for
  `.end` (no payload) following the KeyTable convention. Wire-format
  mirror comments (`Sync with: ghostty_osc_iterm2_*`) make the eventual
  C header generation discoverable.

After this, both `zig build` and `zig build test` are clean on Windows.

Closes the pre-existing build break introduced by #377/#378.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant