Skip to content

Commit 5d0d674

Browse files
authored
Merge pull request #13 from compoundingtech/schickling-assistant/2026-09-04-kitty-graphics-state
feat(terminal): kitty graphics state, replay, child input encoding, and an honest scrollback
2 parents 137034a + 16fe0da commit 5d0d674

27 files changed

Lines changed: 4447 additions & 39 deletions

Cargo.lock

Lines changed: 81 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ serde = { version = "1.0.229", features = ["derive"] }
1919
serde_json = { version = "1.0.151", features = ["preserve_order"] }
2020
indexmap = { version = "2.14.0", features = ["serde"] }
2121
notify = "8.2.0"
22+
png = "0.18.1"
2223
toml = { version = "1.1.3", features = ["preserve_order"] }
2324

2425
# Workspace crates.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ Where the port stands against the Node `pty`, surface by surface, is in
7373
[docs/parity.md](docs/parity.md); the work packages that close the gap are in
7474
[docs/parity-plan.md](docs/parity-plan.md).
7575

76+
What must stay true of terminal images — the Kitty graphics protocol state a
77+
session holds and replays — is in
78+
[docs/vrs/01-images/requirements.md](docs/vrs/01-images/requirements.md); how
79+
the session meets it is in
80+
[docs/vrs/01-images/spec.md](docs/vrs/01-images/spec.md).
81+
7682
## Install
7783

7884
With Nix (flakes), from a checkout or straight from GitHub:

crates/pty-conformance/tests/output_activity.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
//! `lastOutputAtMs`: the daemon records when the child last printed.
22
//!
3-
//! The Node tool grew this field in its PR #168, merged on 2026-08-29
4-
//! (`docs/vrs/requirements.md` R14, `docs/disk-layout.md`). The contract is:
3+
//! The Node tool grew this field in its PR #168, merged on 2026-08-29 (the
4+
//! Node pty repository's `docs/vrs/requirements.md` R14 and
5+
//! `docs/disk-layout.md`, not this repository's `docs/`). The contract is:
56
//!
67
//! - absent until the session produces output, and absent on a record an
78
//! older daemon wrote — never zero, and never a claim of idleness;

crates/pty-core/src/protocol.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,35 @@ fn size_payload(rows: u16, cols: u16) -> [u8; 4] {
114114
[r[0], r[1], c[0], c[1]]
115115
}
116116

117+
/// `rows u16BE, cols u16BE, cell_width u16BE, cell_height u16BE`: the size
118+
/// payload with the client's cell pixel metrics appended.
119+
///
120+
/// The four extra bytes are an *optional suffix*, which is what makes this
121+
/// safe to send to any peer: every reader of a size payload takes rows and
122+
/// cols from the first four bytes and the frame carries its own length, so a
123+
/// daemon that predates this (the Node one included) reads the size it
124+
/// always read and ignores the rest.
125+
fn size_cell_payload(rows: u16, cols: u16, cell_width: u16, cell_height: u16) -> [u8; 8] {
126+
let s = size_payload(rows, cols);
127+
let w = cell_width.to_be_bytes();
128+
let h = cell_height.to_be_bytes();
129+
[s[0], s[1], s[2], s[3], w[0], w[1], h[0], h[1]]
130+
}
131+
117132
/// Encode an ATTACH with a terminal size (4-byte payload).
118133
pub fn encode_attach(rows: u16, cols: u16) -> Vec<u8> {
119134
encode_packet(MessageType::Attach, &size_payload(rows, cols))
120135
}
121136

137+
/// Encode an ATTACH that also declares the client's cell pixel size
138+
/// (8-byte payload; see [`decode_cell`]).
139+
pub fn encode_attach_with_cell(rows: u16, cols: u16, cell_width: u16, cell_height: u16) -> Vec<u8> {
140+
encode_packet(
141+
MessageType::Attach,
142+
&size_cell_payload(rows, cols, cell_width, cell_height),
143+
)
144+
}
145+
122146
/// Encode a DETACH.
123147
pub fn encode_detach() -> Vec<u8> {
124148
encode_packet(MessageType::Detach, &[])
@@ -129,6 +153,14 @@ pub fn encode_resize(rows: u16, cols: u16) -> Vec<u8> {
129153
encode_packet(MessageType::Resize, &size_payload(rows, cols))
130154
}
131155

156+
/// Encode a RESIZE that also declares the client's cell pixel size.
157+
pub fn encode_resize_with_cell(rows: u16, cols: u16, cell_width: u16, cell_height: u16) -> Vec<u8> {
158+
encode_packet(
159+
MessageType::Resize,
160+
&size_cell_payload(rows, cols, cell_width, cell_height),
161+
)
162+
}
163+
132164
/// Encode a GEOMETRY (effective shared rows/cols, server → client).
133165
pub fn encode_geometry(rows: u16, cols: u16) -> Vec<u8> {
134166
encode_packet(MessageType::Geometry, &size_payload(rows, cols))
@@ -177,6 +209,21 @@ pub fn decode_size(payload: &[u8]) -> (u16, u16) {
177209
(rows, cols)
178210
}
179211

212+
/// The cell pixel size a client appended to an ATTACH or RESIZE payload, or
213+
/// `None` when it sent the plain 4-byte size or a degenerate zero.
214+
///
215+
/// Cell metrics are the client's to know — they come from its font, on its
216+
/// host — so a session daemon can only be told. `None` means nobody has, and
217+
/// the reader keeps its own deterministic fallback.
218+
pub fn decode_cell(payload: &[u8]) -> Option<(u16, u16)> {
219+
if payload.len() < 8 {
220+
return None;
221+
}
222+
let width = u16::from_be_bytes([payload[4], payload[5]]);
223+
let height = u16::from_be_bytes([payload[6], payload[7]]);
224+
(width > 0 && height > 0).then_some((width, height))
225+
}
226+
180227
/// Decode a GEOMETRY payload (rows, cols); same layout and fallback as
181228
/// [`decode_size`].
182229
pub fn decode_geometry(payload: &[u8]) -> (u16, u16) {

crates/pty-core/src/registry/metadata.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ pub struct SessionMetadata {
8989
/// nothing to take; it is persisted at most once a second while output
9090
/// flows.
9191
///
92-
/// node: src/sessions.ts (`lastOutputAtMs`), docs/vrs/requirements.md R14
92+
/// node: src/sessions.ts (`lastOutputAtMs`), the Node pty repository's
93+
/// `docs/vrs/requirements.md` R14 (not this repository's `docs/vrs`)
9394
#[serde(default, skip_serializing_if = "Option::is_none")]
9495
pub last_output_at_ms: Option<i64>,
9596
/// Every field this version does not model, round-tripped verbatim.

crates/pty-core/tests/protocol.rs

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
//! Port of the pty project's `tests/protocol.test.ts`.
22
33
use pty_core::protocol::{
4-
MAX_PACKET_LENGTH, MessageType, PacketReader, decode_exit, decode_geometry, decode_size,
5-
encode_attach, encode_data, encode_detach, encode_exit, encode_geometry, encode_packet,
6-
encode_resize, encode_screen, encode_status, encode_status_response,
4+
MAX_PACKET_LENGTH, MessageType, PacketReader, decode_cell, decode_exit, decode_geometry,
5+
decode_size, encode_attach, encode_attach_with_cell, encode_data, encode_detach, encode_exit,
6+
encode_geometry, encode_packet, encode_resize, encode_resize_with_cell, encode_screen,
7+
encode_status, encode_status_response,
78
};
89
use pty_core::stats::{ClientStats, ConnectionStats, Constrains, StatsResult};
910

@@ -36,6 +37,50 @@ fn attach_byte_identical_to_hand_built_packet() {
3637
);
3738
}
3839

40+
/// The cell pixel size is an optional suffix on a size payload: a reader that
41+
/// does not know about it takes the same rows and cols it always did, which
42+
/// is what makes this safe to send to any daemon.
43+
#[test]
44+
fn attach_can_declare_a_cell_size_without_changing_the_size_it_carries() {
45+
let with_cell = encode_attach_with_cell(24, 80, 9, 18);
46+
assert_eq!(
47+
with_cell,
48+
encode_packet(MessageType::Attach, &[0, 24, 0, 80, 0, 9, 0, 18])
49+
);
50+
51+
let mut reader = PacketReader::new();
52+
let packets = reader.feed(&with_cell).unwrap();
53+
assert_eq!(packets[0].type_, MessageType::Attach);
54+
assert_eq!(
55+
decode_size(&packets[0].payload),
56+
(24, 80),
57+
"the size is where it always was"
58+
);
59+
assert_eq!(decode_cell(&packets[0].payload), Some((9, 18)));
60+
}
61+
62+
#[test]
63+
fn resize_can_declare_a_cell_size() {
64+
let mut reader = PacketReader::new();
65+
let packets = reader.feed(&encode_resize_with_cell(30, 100, 7, 15)).unwrap();
66+
assert_eq!(packets[0].type_, MessageType::Resize);
67+
assert_eq!(decode_size(&packets[0].payload), (30, 100));
68+
assert_eq!(decode_cell(&packets[0].payload), Some((7, 15)));
69+
}
70+
71+
/// No declaration is the normal case, and it must not be mistaken for one.
72+
#[test]
73+
fn a_plain_size_payload_declares_no_cell() {
74+
assert_eq!(decode_cell(&encode_attach(24, 80)[5..]), None);
75+
assert_eq!(decode_cell(&encode_resize(24, 80)[5..]), None);
76+
assert_eq!(decode_cell(&[]), None);
77+
assert_eq!(
78+
decode_cell(&[0, 24, 0, 80, 0, 0, 0, 0]),
79+
None,
80+
"a zero cell is no cell"
81+
);
82+
}
83+
3984
/// node: tests/protocol.test.ts:288-297
4085
#[test]
4186
fn round_trips_geometry() {

crates/pty-terminal/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ libghostty-vt.workspace = true
1212
portable-pty.workspace = true
1313
pty-core.workspace = true
1414
libc.workspace = true
15+
png.workspace = true
1516

1617
[dev-dependencies]
1718
serde_json.workspace = true

0 commit comments

Comments
 (0)