terminal: parse kitty drag and drop protocol (OSC 72)

Adds an OSC 72 parser following the kitty drag and drop protocol
specification. Parses metadata and payload into a Command.kitty_dnd_protocol
variant. Reassembly of chunked transfers and any action handling are
intentionally out of scope here; stream.zig logs the command as
unimplemented for now.

Includes a walkthrough document covering the design and each touched file.
This commit is contained in:
AJ Khullar
2026-06-15 17:41:05 +08:00
parent fdbf9ff3a3
commit 91c87e2cf3
5 changed files with 673 additions and 0 deletions

View File

@@ -0,0 +1,462 @@
# Kitty DnD Parser — Walkthrough
A line-by-line walkthrough of the OSC 72 (kitty drag and drop protocol)
parser added to ghostty.
## Big picture: how ghostty parses OSC
Before any of these changes, ghostty's OSC handling looked like this:
1. **`src/terminal/Parser.zig`** is the VT escape sequence parser. When it
sees `ESC ]` (OSC start), it begins feeding characters to an OSC
sub-parser.
2. **`src/terminal/osc.zig`** holds that sub-parser. It's a character-level
state machine that walks the digits of the OSC number (so `5522`
advances through states `@"5"``@"55"``@"552"``@"5522"`), then
captures the trailing data after the first `;`.
3. When the OSC terminator (`ST` or `BEL`) arrives, `Parser.end()`
dispatches to one of the helpers in **`src/terminal/osc/parsers/`** based
on the final state. Those helpers turn the raw captured bytes into a
typed `Command` variant.
4. **`src/terminal/stream.zig`** receives the final `Command` and decides
what handler to call (set window title, write to clipboard, etc.).
Unimplemented commands just get a debug log.
The job here was to plug OSC 72 into this pipeline. The user-visible
result: when a TUI app sends `OSC 72 ; t=a:i=5 ; text/plain text/uri-list
ST`, ghostty parses it and produces a `Command.kitty_dnd_protocol` value
with `metadata="t=a:i=5"` and `payload="text/plain text/uri-list"`. The
actual action — actually accepting drops — is **not** wired yet.
Four files were touched. Each is walked through below.
---
## File 1: `src/terminal/osc/parsers/kitty_dnd_protocol.zig` (new, ~155 lines)
This is the bulk of the work. It's modeled directly on the existing
`kitty_clipboard_protocol.zig`.
### Imports
```zig
const std = @import("std");
const assert = @import("../../../quirks.zig").inlineAssert;
const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
const Terminator = @import("../../osc.zig").Terminator;
```
- `std`: Zig stdlib.
- `inlineAssert`: ghostty's own assert helper from `src/quirks.zig`
(probably wraps `std.debug.assert` with some compile-time behavior
tuning). Used to assert the parser state matches what's expected.
- `Parser`, `Command`, `Terminator`: types from the parent `osc.zig`. This
is a leaf parser, so these aren't defined here — we hand back data the
parent uses.
### `pub const OSC = struct` (the output value)
```zig
pub const OSC = struct {
metadata: []const u8,
payload: ?[]const u8,
terminator: Terminator,
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
return key.read(self.metadata);
}
};
```
This is what gets stored in the `Command` union when an OSC 72 is parsed.
Three fields:
- **`metadata`**: the raw bytes between the first and second `;`. For
`OSC 72;t=m:x=5:y=3;text/plain ST`, this is `"t=m:x=5:y=3"`. Not
pre-parsed — kept as a slice into the capture buffer.
- **`payload`**: an *optional* slice of everything after the second `;`.
Optional because some OSCs have no payload at all (`OSC 72;t=A ST`
stop accepting drops, no payload needed).
- **`terminator`**: was it terminated by `ST` (`ESC \`) or `BEL` (`0x07`)?
Recorded so when we respond, we match what the client used. This is a
convention across all of ghostty's OSC parsers.
`readOption` is a thin wrapper around `Option.read`. It's syntactic sugar
so callers write `osc.readOption(.t)` instead of `Option.read(.t,
osc.metadata)`. The `comptime key: Option` parameter means the key is
known at compile time — that lets the return type vary per key (look at
`key.Type()`), giving you `EventType` for `.t` and `i32` for the rest.
### `pub const EventType = enum`
```zig
pub const EventType = enum {
accept_drops, // t=a
stop_accepting_drops, // t=A
drop_move, // t=m
drop_dropped, // t=M
request_data, // t=r
request_error, // t=R
offer_drag, // t=o
present_data, // t=p
change_drag_image, // t=P
drag_offer_event, // t=e
drag_offer_error, // t=E
uri_list_data, // t=k
query, // t=q
pub fn init(str: []const u8) ?EventType { ... }
};
```
The typed representation of the `t` metadata key — every event type the
protocol defines. There are 13 of them, mapped from single ASCII
characters (case-sensitive: `m` and `M` are different events).
`init` takes the string value of `t` (e.g. `"a"` or `"M"`), checks it's
exactly one char long, and switches on that char. Returns `null` if the
value is unknown or wrong length — null means "this key is not parseable
as EventType."
### `pub const Option = enum`
```zig
pub const Option = enum {
t, // event type
m, // chunking indicator (0 or 1)
i, // multiplexer id
o, // operation (0 reject, 1 copy, 2 move, 3 either; also reused for opacity etc.)
x, // cell column / 1-based index
y, // cell row / 1-based subindex
X, // pixel x / flag / handle
Y, // pixel y / handle / image height
...
};
```
The set of all metadata keys the protocol uses. **Case-sensitive**: `x`
and `X` are distinct keys with different meanings, which is one of the
protocol's subtle gotchas. Zig enum tag names are case-sensitive
identifiers, so this works naturally.
#### `pub fn Type(comptime key: Option) type`
```zig
pub fn Type(comptime key: Option) type {
return switch (key) {
.t => EventType,
.m, .i, .o, .x, .y, .X, .Y => i32,
};
}
```
A compile-time function that returns a *type*. It says: "if you ask for
the value of key `.t`, you'll get back an `EventType`; for any other key,
you'll get back an `i32`."
Why `i32`? The spec says "32-bit signed or unsigned integers". Signed was
chosen because some location keys legitimately take `-1` (e.g.
`x=-1, y=-1` means "the drag has left the window"). Using `i32` everywhere
avoids needing two types.
#### `pub fn read(comptime key: Option, metadata: []const u8) ?key.Type()`
The workhorse. Walks the `key=value:key=value:...` metadata string looking
for a specific key, returns the parsed value or null.
Step by step:
```zig
const name = @tagName(key); // "t", "x", "X", etc.
```
`@tagName` is a Zig builtin that returns the string form of an enum tag at
compile time. For `.X` it returns `"X"`.
```zig
const value: []const u8 = value: {
var pos: usize = 0;
while (pos < metadata.len) {
```
A labeled block (`value: { ... }`) is used so we can `break :value <slice>`
from inside the loop. Cursor `pos` tracks where we are in the metadata.
```zig
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
if (pos >= metadata.len) return null;
```
Skip any whitespace at the start of an option. The spec doesn't explicitly
require this but the clipboard parser does it and it's harmless.
```zig
if (!std.mem.startsWith(u8, metadata[pos..], name)) {
pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null;
pos += 1;
continue;
}
```
Try to match our key name at the current position. **Critical for this
protocol:** `std.mem.startsWith` is case-sensitive, so `"x"` will not
match `"X="`. If we don't match, jump past the next `:` to the start of
the next option. If there's no next `:`, bail (key not present).
```zig
pos += name.len;
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
if (pos >= metadata.len) return null;
if (metadata[pos] != '=') return null;
```
The key matched. Skip past it, skip whitespace, expect `=`. If not, this
isn't actually a `key=value` pair — bail.
```zig
const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len;
const start = pos + 1;
break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace);
```
The value runs from just after `=` to either the next `:` or end of
metadata. Trim whitespace and `break :value` with the slice. This slice
is still backed by the parser's capture buffer — no allocation.
```zig
return switch (key) {
.t => .init(value),
.m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null,
};
```
Once we have the value string, parse it according to the key's type. For
`.t`, hand to `EventType.init`. For integers, `std.fmt.parseInt` does the
work and returns null on garbage.
### `pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command`
```zig
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
assert(parser.state == .@"72");
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
const data = cap.trailing();
const metadata: []const u8, const payload: ?[]const u8 = result: {
const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
break :result .{ data[0..sep], data[sep + 1 .. data.len] };
};
parser.command = .{
.kitty_dnd_protocol = .{
.metadata = metadata,
.payload = payload,
.terminator = .init(terminator_ch),
},
};
return &parser.command;
}
```
This is what `osc.zig` calls when it sees the OSC has finished and the
state machine is in `.@"72"`.
- `assert(parser.state == .@"72")`: sanity check — we should only ever be
called for an OSC 72.
- Pull the capture buffer (the bytes between the OSC number and the
terminator).
- Split on the first `;` — everything before is metadata, everything
after is payload. If there's no `;`, the entire thing is metadata and
payload is null.
- Stuff the result into the parser's `command` union, marking it as our
variant.
- Return a pointer back to the union. Caller (the stream) reads it.
The destructuring syntax `const a: T1, const b: T2 = ...` is Zig's
tuple-style multiple assignment.
---
## File 2: `src/terminal/osc/parsers.zig` (1 line added)
```zig
pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig");
```
This module is just an index — it re-exports all the parser submodules so
`osc.zig` can write `parsers.kitty_dnd_protocol.parse(...)`.
---
## File 3: `src/terminal/osc.zig` (small edits)
### Add to the `Command` union (around line ~157)
```zig
kitty_clipboard_protocol: KittyClipboardProtocol,
/// Kitty drag and drop protocol (OSC 72)
/// https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
kitty_dnd_protocol: KittyDndProtocol,
```
`Command` is a tagged union — one variant per OSC type. A new variant is
added. Its payload type is `KittyDndProtocol`, declared right below.
### Type alias
```zig
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC;
```
So the union field type has a friendly name.
`parsers.kitty_dnd_protocol.OSC` is the struct from file 1.
### Add to the `Key` enum list
```zig
"kitty_clipboard_protocol",
"kitty_dnd_protocol",
"context_signal",
```
`Key` is generated by ghostty's `LibEnum` helper, which produces an enum
from a string list (deterministic ordering for ABI stability across the
C/Zig boundary). Order matters per the comment in the file. Adding the
tag here keeps the union and the key enum in sync.
### Add to `reset()` switch
```zig
.kitty_text_sizing,
.kitty_clipboard_protocol,
.kitty_dnd_protocol,
.context_signal,
=> {},
```
`reset()` deinits any allocated memory a command variant owns. Most
variants (including this one) own no allocations — their slices point
into the parser's capture buffer, which the parser itself manages. So
this lands in the `=> {}` (do-nothing) arm. The switch must be exhaustive
across all `Key` tags, so it has to land somewhere — and "do nothing" is
correct.
### State machine — add `@"72"` and extend `@"7"`
In the `State` enum:
```zig
@"66",
@"72",
@"77",
```
In the `next()` function, extending the existing `@"7"` handler:
```zig
.@"7" => switch (c) {
';' => self.captureTrailing(.fixed), // OSC 7 alone = report_pwd
'2' => self.state = .@"72", // NEW: OSC 72
'7' => self.state = .@"77", // OSC 777 bridge
else => self.state = .invalid,
},
.@"72" => switch (c) {
';' => self.captureTrailing(.allocating),
else => self.state = .invalid,
},
```
What this is doing:
- In state `@"7"` after seeing the `7` digit. If the next char is `2`,
transition to `@"72"`. (Previously `@"7"` only accepted `;` for OSC 7
and `7` for the OSC 77 bridge.)
- In state `@"72"`, the only valid next char is `;`, which kicks off
**capturing** the trailing data.
- `captureTrailing(.allocating)` chooses **allocating mode** for the
capture. The default fixed buffer is 2048 bytes, but the protocol
allows payloads up to 4096 bytes per chunk (after base64), plus
metadata. Allocating mode grows as needed up to whatever the allocator
gives. If no allocator is configured, it falls back gracefully to the
fixed buffer.
### Dispatch in `end()`
```zig
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
.@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch),
.@"77" => null,
```
When the parser sees the OSC terminator, `end()` looks at the final state
and hands off to the right helper. For state `@"72"`, that's the `parse`
function we wrote in file 1.
---
## File 4: `src/terminal/stream.zig` (added to unimplemented list)
```zig
.kitty_text_sizing,
.kitty_clipboard_protocol,
.kitty_dnd_protocol,
.context_signal,
=> {
log.debug("unimplemented OSC callback: {}", .{cmd});
},
```
`stream.zig` is downstream of the parser — when a fully-parsed `Command`
arrives, it dispatches to a real handler (set the title, do the clipboard
op, etc.). For this protocol, there is no handler yet (intentional — that's
the next step). So it lands in the "unimplemented" arm, which just logs.
**The compiler enforces exhaustive switches**, so adding a new union
variant without adding it to this switch would have been a build error.
It had to go somewhere; this is the most honest place.
---
## Design decisions worth understanding
1. **Thin parser, lazy field reads.** Kept raw `metadata` and `payload`
slices and provided a `readOption` accessor. Alternative: eagerly parse
every key into a struct at parse time. The lazy approach matches
`kitty_clipboard_protocol`, lets callers pay parse cost only for keys
they care about, and is dead simple. Downside: every `readOption` call
scans the metadata string. Tradeoff is fine because metadata is tiny
(~30 chars typical).
2. **No chunking reassembly.** The protocol says payloads >4096 bytes get
chunked across multiple OSC 72 messages. The parser deliberately does
**not** reassemble these — each chunk surfaces as its own `Command`.
Why: chunking is stateful across multiple escape codes, which is the
*action layer's* concern (which buffer to append into, what to do when
out of order, how to handle errors mid-stream). Putting that in the
OSC parser would conflate two responsibilities.
3. **No semantic validation.** The structure is parsed but it isn't
validated that e.g. a `t=m` event actually has a sensible `x/y`, or
that a `t=q` query has the keys it should. The spec lets us be lax,
and the action layer is better-positioned to validate against
context.
4. **`i32` for all integer keys.** The spec says "32-bit signed or
unsigned." Signed was chosen because `-1` is a real sentinel value
(drag leave, drag cancel). Unsigned would force casting everywhere.
5. **No reset of capture in `parse`.** The parser's main loop handles
capture lifecycle — `reset()` (in `osc.zig`) cleans up the capture
between OSCs. The parse function just consumes `cap.trailing()` and
returns.

View File

@@ -156,6 +156,10 @@ pub const Command = union(Key) {
kitty_clipboard_protocol: KittyClipboardProtocol,
/// Kitty drag and drop protocol (OSC 72)
/// https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
kitty_dnd_protocol: KittyDndProtocol,
/// OSC 3008. Hierarchical context signalling (UAPI spec).
/// https://uapi-group.org/specifications/specs/osc_context/
context_signal: parsers.context_signal.Command,
@@ -164,6 +168,8 @@ pub const Command = union(Key) {
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC;
pub const Key = LibEnum(
lib.target,
// NOTE: Order matters, see LibEnum documentation.
@@ -192,6 +198,7 @@ pub const Command = union(Key) {
"conemu_comment",
"kitty_text_sizing",
"kitty_clipboard_protocol",
"kitty_dnd_protocol",
"context_signal",
},
);
@@ -344,6 +351,7 @@ pub const Parser = struct {
@"52",
@"55",
@"66",
@"72",
@"77",
@"104",
@"110",
@@ -421,6 +429,7 @@ pub const Parser = struct {
.show_desktop_notification,
.kitty_text_sizing,
.kitty_clipboard_protocol,
.kitty_dnd_protocol,
.context_signal,
=> {},
}
@@ -691,10 +700,16 @@ pub const Parser = struct {
.@"7" => switch (c) {
';' => self.captureTrailing(.fixed),
'2' => self.state = .@"72",
'7' => self.state = .@"77",
else => self.state = .invalid,
},
.@"72" => switch (c) {
';' => self.captureTrailing(.allocating),
else => self.state = .invalid,
},
.@"77" => switch (c) {
'7' => self.state = .@"777",
else => self.state = .invalid,
@@ -805,6 +820,8 @@ pub const Parser = struct {
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
.@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch),
.@"77" => null,
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),

View File

@@ -9,6 +9,7 @@ pub const hyperlink = @import("parsers/hyperlink.zig");
pub const iterm2 = @import("parsers/iterm2.zig");
pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig");
pub const kitty_color = @import("parsers/kitty_color.zig");
pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig");
pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig");
pub const mouse_shape = @import("parsers/mouse_shape.zig");
pub const osc9 = @import("parsers/osc9.zig");

View File

@@ -0,0 +1,192 @@
//! Kitty's drag and drop protocol (OSC 72)
//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
//!
//! The OSC 72 escape has the form:
//!
//! OSC 72 ; metadata ; payload ST
//!
//! Where `metadata` is a colon separated list of `key=value` pairs and
//! `payload` is event-type specific (a space separated MIME list, base64
//! encoded binary data, or absent). The protocol is chunked at 4096 bytes
//! per payload; chunked transfers are signalled via the `m` metadata key.
//!
//! This file only parses individual OSC 72 events. Reassembly of chunked
//! transfers and event semantics (drag state machine, file I/O for the
//! remote machine subprotocols, etc.) are responsibilities of the
//! action/dispatch layer above.
const std = @import("std");
const assert = @import("../../../quirks.zig").inlineAssert;
const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
const Terminator = @import("../../osc.zig").Terminator;
const log = std.log.scoped(.kitty_dnd_protocol);
pub const OSC = struct {
/// The raw metadata that was received. Parse individual values with
/// the `readOption` method.
metadata: []const u8,
/// The raw payload. Its meaning depends on the event type (`t` key)
/// and may be base64 encoded.
payload: ?[]const u8,
/// The terminator used for the inbound OSC, recorded so that any
/// response we emit can match it.
terminator: Terminator,
pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
return key.read(self.metadata);
}
};
/// The set of values the `t` (event type) key may take. Each variant maps
/// to a single ASCII character per the spec.
pub const EventType = enum {
/// `t=a` — client begins accepting drops, payload is space-separated MIME list.
accept_drops,
/// `t=A` — client no longer wishes to accept drops.
stop_accepting_drops,
/// `t=m` — drop move event (terminal → client).
drop_move,
/// `t=M` — drop committed event (terminal → client).
drop_dropped,
/// `t=r` — request data (or response data, or end-of-drop sentinel).
request_data,
/// `t=R` — error response for a data request.
request_error,
/// `t=o` — start offering drags / drag-start gesture.
offer_drag,
/// `t=p` — pre-send data for an offered MIME type or drag image.
present_data,
/// `t=P` — change drag image or finalize start-drag.
change_drag_image,
/// `t=e` — drag offer status event (terminal → client).
drag_offer_event,
/// `t=E` — drag offer error or cancel.
drag_offer_error,
/// `t=k` — data for entries in the offered text/uri-list (drag-out).
uri_list_data,
/// `t=q` — query protocol support.
query,
pub fn init(str: []const u8) ?EventType {
if (str.len != 1) return null;
return switch (str[0]) {
'a' => .accept_drops,
'A' => .stop_accepting_drops,
'm' => .drop_move,
'M' => .drop_dropped,
'r' => .request_data,
'R' => .request_error,
'o' => .offer_drag,
'p' => .present_data,
'P' => .change_drag_image,
'e' => .drag_offer_event,
'E' => .drag_offer_error,
'k' => .uri_list_data,
'q' => .query,
else => null,
};
}
};
/// All metadata keys defined by the protocol. Keys are case-sensitive —
/// `x` and `X` (and `y`/`Y`, `m`/no `M` key but `M` is only a `t` value)
/// are distinct.
pub const Option = enum {
/// Event type.
t,
/// Chunking indicator: 0 or 1 (1 means more chunks follow).
m,
/// Multiplexer id, echoed in all replies for that session.
i,
/// Operation: 0 reject, 1 copy, 2 move, 3 either; also reused for
/// other meanings (e.g. opacity scaled by 1024 in drag images).
o,
/// Cell column (or generic 1-based index in request/data events).
x,
/// Cell row (or generic 1-based sub-index in request/data events).
y,
/// Pixel column (or flag, or directory handle depending on event).
X,
/// Pixel row (or directory handle, or image dimension depending on event).
Y,
pub fn Type(comptime key: Option) type {
return switch (key) {
.t => EventType,
// The spec uses 32-bit signed or unsigned; we standardize on
// i32 because the location keys legitimately take -1 (drag
// leaves the window) and other keys never exceed i32 range.
.m, .i, .o, .x, .y, .X, .Y => i32,
};
}
/// Look up an option in the metadata string. Returns null if the key
/// is absent, malformed, or its value cannot be parsed as the target
/// type. The default values from the spec are *not* substituted here;
/// callers should apply defaults via `orelse` so missing-vs-present
/// can still be distinguished where it matters.
pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() {
const name = @tagName(key);
const value: []const u8 = value: {
var pos: usize = 0;
while (pos < metadata.len) {
// Skip whitespace between options.
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
if (pos >= metadata.len) return null;
// Case-sensitive match: x and X must not be confused.
if (!std.mem.startsWith(u8, metadata[pos..], name)) {
pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null;
pos += 1;
continue;
}
pos += name.len;
while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
if (pos >= metadata.len) return null;
if (metadata[pos] != '=') return null;
const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len;
const start = pos + 1;
break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace);
}
return null;
};
return switch (key) {
.t => .init(value),
.m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null,
};
}
};
pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
assert(parser.state == .@"72");
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
const data = cap.trailing();
const metadata: []const u8, const payload: ?[]const u8 = result: {
const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
break :result .{ data[0..sep], data[sep + 1 .. data.len] };
};
parser.command = .{
.kitty_dnd_protocol = .{
.metadata = metadata,
.payload = payload,
.terminator = .init(terminator_ch),
},
};
return &parser.command;
}

View File

@@ -2058,6 +2058,7 @@ pub fn Stream(comptime H: type) type {
.conemu_run_process,
.kitty_text_sizing,
.kitty_clipboard_protocol,
.kitty_dnd_protocol,
.context_signal,
=> {
log.debug("unimplemented OSC callback: {}", .{cmd});