Skip to main content

max / shop

Answer DA1 and the kitty graphics query shop answered no terminal queries at all. The grid had no way to write back to the PTY, there was no DA1 arm, and `a=q` fell through the kitty parser as an unhandled action. Silence is not a no-op here: a program that asks waits out its own timeout first. yazi is the case that forced this. It identifies terminals from a hardcoded list of names, shop is in no list, and its fallback is to ask: it sends a kitty graphics query precisely *because* the name means nothing to it, then waits up to three seconds for DA1 before concluding the terminal cannot draw. Under shop that was a three-second stall on every launch followed by a fall back to chafa half-blocks, on the file manager Alloy calls its TUI primary. Grid gains a reply queue drained by the binary after each parse, in the shape take_pending_title already had. DA1 answers `\e[?62;22c`: VT220 with ANSI colour, and deliberately without attribute 4, because claiming sixel makes a client prefer a protocol shop does not implement over one it does. The arm is guarded on empty intermediates so `CSI > c`, which is a different question, does not get this answer. kittygfx gains `Command::Query` and `query_response`, which replies OK for the formats shop decodes over the direct medium and ENOTSUPP otherwise. An honest refusal still stops the client waiting, which is most of the value; saying OK to a file transfer would promise a picture that never arrives.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 19:33 UTC
Signed with PGP, not checked
Commit: 1c22eeeb66d0f1913b5c3650396acf4daedd2115
Parent: 1a32ff7
3 files changed, +257 insertions, -7 deletions
@@ -8,13 +8,17 @@
8 8 //! [Kitty terminal graphics protocol]: https://sw.kovidgoyal.net/kitty/graphics-protocol/
9 9 //!
10 10 //! MVP scope: `a=T` transmit+display, `a=t` transmit, `a=p` place, `a=d`
11 - //! delete, `a=f` frame append, `a=c` frame compose, formats `f=24` (RGB),
12 - //! `f=32` (RGBA), `f=100` (PNG), medium `t=d` (base64 inline), chunked
13 - //! payloads (`m=1`/`m=0`), placement in cells (`c=`, `r=`), don't-move-
14 - //! cursor (`C=1`), Unicode-placeholder flag (`U=1`).
11 + //! delete, `a=f` frame append, `a=c` frame compose, `a=q` query, formats
12 + //! `f=24` (RGB), `f=32` (RGBA), `f=100` (PNG), medium `t=d` (base64 inline),
13 + //! chunked payloads (`m=1`/`m=0`), placement in cells (`c=`, `r=`), don't-
14 + //! move-cursor (`C=1`), Unicode-placeholder flag (`U=1`).
15 15 //!
16 - //! Not covered yet: file/temp/shm media, `a=a` animation control, `a=q`
17 - //! query, placement IDs, z-order, delete sub-selectors.
16 + //! Not covered yet: file/temp/shm media, `a=a` animation control, placement
17 + //! IDs, z-order, delete sub-selectors.
18 + //!
19 + //! [`Command::Query`] is the one command the host must answer rather than
20 + //! merely act on. [`query_response`] builds the reply; the host writes it
21 + //! back to the PTY.
18 22 //!
19 23 //! Reference read: rio's `rio-backend/src/ansi/kitty_graphics_protocol.rs`
20 24 //! (MIT) — architecture consulted, no direct code copied.
@@ -95,6 +99,43 @@
95 99 /// Compose an already-transmitted frame from other frames (`a=c`).
96 100 /// Carries no payload — composition is metadata-only.
97 101 FrameCompose { control: Control },
102 + /// Capability query (`a=q`). The sender is asking whether a transmission
103 + /// shaped like this one would have worked; nothing is stored either way.
104 + ///
105 + /// This is how a terminal that no program has heard of still gets its
106 + /// graphics support noticed. Clients keep a list of terminals they know
107 + /// by name and fall back to querying when the name means nothing to
108 + /// them, so answering is the difference between being detected and being
109 + /// assumed incapable.
110 + ///
111 + /// The caller must reply. Unlike every other action, a query ignores
112 + /// `q=` suppression: silence is not a valid answer to it, and a client
113 + /// that asked will wait out its timeout before giving up.
114 + Query { control: Control },
115 + }
116 +
117 + /// What a terminal should answer a [`Command::Query`] with.
118 + ///
119 + /// The reply is addressed by the `i=` the query carried, so a client can
120 + /// match it to the question. A query with no id is answered with `i=0`,
121 + /// which is what the protocol's own examples do.
122 + #[must_use]
123 + pub fn query_response(control: &Control) -> Vec<u8> {
124 + let id = control.id.unwrap_or(0);
125 + // Direct is the only medium shop can satisfy: the others hand over a
126 + // path or a shared-memory name to read out of band, and none of that is
127 + // implemented. Saying OK to one would promise a picture that never
128 + // arrives.
129 + let supported =
130 + control.format.is_some() && matches!(control.medium, None | Some(Medium::Direct));
131 + if supported {
132 + format!("\x1b_Gi={id};OK\x1b\\").into_bytes()
133 + } else {
134 + // ENOTSUPP is the protocol's spelling for "understood, cannot do it".
135 + // Answering with an error still counts as answering: the client stops
136 + // waiting and picks another path, which is the whole point.
137 + format!("\x1b_Gi={id};ENOTSUPP\x1b\\").into_bytes()
138 + }
98 139 }
99 140
100 141 /// Parser for kitty-graphics APC payloads. Feed one APC body at a time via
@@ -161,7 +202,12 @@
161 202 // Payload-less actions. If a `;<payload>` was sent anyway, validate
162 203 // that it's decodable so a malformed one still errors — preserves
163 204 // the pre-refactor contract without keeping the decoded bytes.
164 - 'p' | 'c' | 'd' => {
205 + //
206 + // A query belongs here despite usually carrying a payload: it is
207 + // asking about a shape, not sending an image, so the bytes are
208 + // checked and dropped rather than reassembled. Probes send a
209 + // single pixel, so there is no chunking to honour either.
210 + 'p' | 'c' | 'd' | 'q' => {
165 211 if let Some(b) = payload_b64 {
166 212 if !b.is_empty() && B64.decode(b).is_err() {
167 213 return None;
@@ -171,6 +217,7 @@
171 217 'p' => Some(Command::Place { control }),
172 218 'c' => Some(Command::FrameCompose { control }),
173 219 'd' => Some(Command::Delete { control }),
220 + 'q' => Some(Command::Query { control }),
174 221 _ => unreachable!(),
175 222 }
176 223 }
@@ -402,6 +449,89 @@
402 449 assert_eq!(payload, b"xyz");
403 450 }
404 451
452 + fn query_of(body: &[u8]) -> Control {
453 + let mut p = Parser::new();
454 + match p.feed(body) {
455 + Some(Command::Query { control }) => control,
456 + other => panic!("expected a query, got {other:?}"),
457 + }
458 + }
459 +
460 + #[test]
461 + fn a_probe_query_parses_as_a_query() {
462 + // Verbatim shape of what a client probe sends: one pixel, direct,
463 + // 24-bit, asking rather than transmitting.
464 + let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA");
465 + assert_eq!(control.action, 'q');
466 + assert_eq!(control.id, Some(31));
467 + }
468 +
469 + #[test]
470 + fn a_query_is_answered_ok_and_addressed_to_its_id() {
471 + let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA");
472 + assert_eq!(query_response(&control), b"\x1b_Gi=31;OK\x1b\\".to_vec());
473 + }
474 +
475 + #[test]
476 + fn a_query_with_no_id_is_answered_against_zero() {
477 + let control = query_of(b"Ga=q,f=32,s=1,v=1;AAAA");
478 + assert_eq!(query_response(&control), b"\x1b_Gi=0;OK\x1b\\".to_vec());
479 + }
480 +
481 + #[test]
482 + fn every_format_shop_decodes_answers_ok() {
483 + for body in [
484 + b"Ga=q,i=1,f=24,s=1,v=1;AAAA".as_slice(),
485 + b"Ga=q,i=1,f=32,s=1,v=1;AAAA".as_slice(),
486 + b"Ga=q,i=1,f=100;AAAA".as_slice(),
487 + ] {
488 + let reply = query_response(&query_of(body));
489 + assert_eq!(reply, b"\x1b_Gi=1;OK\x1b\\".to_vec(), "for {body:?}");
490 + }
491 + }
492 +
493 + #[test]
494 + fn a_medium_shop_cannot_read_is_declined_rather_than_ignored() {
495 + // Saying OK to a file transfer promises a picture that never
496 + // arrives; saying nothing makes the client wait out its timeout.
497 + // Both are worse than an honest refusal.
498 + let control = query_of(b"Ga=q,i=7,f=100,t=f;L3RtcC94");
499 + assert_eq!(
500 + query_response(&control),
501 + b"\x1b_Gi=7;ENOTSUPP\x1b\\".to_vec()
502 + );
503 + }
504 +
505 + #[test]
506 + fn an_absent_medium_means_direct() {
507 + let control = query_of(b"Ga=q,i=2,f=24,s=1,v=1;AAAA");
508 + assert_eq!(query_response(&control), b"\x1b_Gi=2;OK\x1b\\".to_vec());
509 + }
510 +
511 + #[test]
512 + fn a_query_with_no_format_is_declined() {
513 + let control = query_of(b"Ga=q,i=3;AAAA");
514 + assert_eq!(
515 + query_response(&control),
516 + b"\x1b_Gi=3;ENOTSUPP\x1b\\".to_vec()
517 + );
518 + }
519 +
520 + #[test]
521 + fn a_query_with_an_undecodable_payload_is_rejected() {
522 + let mut p = Parser::new();
523 + assert!(p.feed(b"Ga=q,i=1,f=24;!!!!").is_none());
524 + }
525 +
526 + #[test]
527 + fn a_query_stores_nothing() {
528 + // A query must not leave a half-assembled transmission behind for
529 + // the next chunk to attach itself to.
530 + let mut p = Parser::new();
531 + assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some());
532 + assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some());
533 + }
534 +
405 535 #[test]
406 536 fn unknown_action_returns_none() {
407 537 let mut p = Parser::new();
@@ -287,6 +287,14 @@
287 287 cursor_keys_application: bool,
288 288 keypad_application: bool,
289 289 pending_title: Option<String>,
290 + // Bytes the terminal owes the program, from queries it answered. The grid
291 + // has no handle on the PTY, so it queues and the binary drains after every
292 + // parse, the way it already does for the title.
293 + //
294 + // A query with no answer is not a no-op: the asking program waits out its
295 + // timeout first. yazi gives DA1 three seconds before deciding the terminal
296 + // cannot draw, so silence here costs three seconds on every launch of it.
297 + pending_replies: Vec<u8>,
290 298 // Damage tracking — accumulated between take_damage() calls.
291 299 row_dirty: Vec<bool>,
292 300 pending_scroll: i16,
@@ -332,6 +340,7 @@
332 340 cursor_keys_application: false,
333 341 keypad_application: false,
334 342 pending_title: None,
343 + pending_replies: Vec::new(),
335 344 // Initial state: everything dirty so first render populates the
336 345 // per-row cache.
337 346 row_dirty: vec![true; rows as usize],
@@ -416,6 +425,18 @@
416 425 self.pending_title.take()
417 426 }
418 427
428 + /// Consume any bytes owed to the program in answer to its queries. Binary
429 + /// polls after each `parser.advance` and writes them to the PTY.
430 + ///
431 + /// Empty on almost every call: programs ask once, at startup.
432 + pub fn take_pending_replies(&mut self) -> Vec<u8> {
433 + std::mem::take(&mut self.pending_replies)
434 + }
435 +
436 + fn reply(&mut self, bytes: &[u8]) {
437 + self.pending_replies.extend_from_slice(bytes);
438 + }
439 +
419 440 pub fn cols(&self) -> u16 {
420 441 self.cols
421 442 }
@@ -1206,6 +1227,15 @@
1206 1227 ('K', false) => {
1207 1228 self.erase_line(param1(params, 0));
1208 1229 }
1230 + // DA1, "what are you". Guarded on empty intermediates because
1231 + // `CSI > c` is DA2, a different question, and the private-flag
1232 + // check above only screens for `?`.
1233 + //
1234 + // 62 is VT220, which is about what the VT side implements; 22 is
1235 + // ANSI colour. Sixel is 4 and is deliberately absent: shop has no
1236 + // sixel, and claiming it means a client picks sixel over kitty
1237 + // graphics and draws nothing.
1238 + ('c', false) if intermediates.is_empty() => self.reply(b"\x1b[?62;22c"),
1209 1239 ('S', false) => {
1210 1240 self.scroll_up_in_region(param1(params, 1));
1211 1241 }
@@ -1648,6 +1678,75 @@
1648 1678 assert!(c.row < 2 && c.col < 4);
1649 1679 }
1650 1680
1681 + // ---- device attributes ---------------------------------------------
1682 +
1683 + #[test]
1684 + fn da1_is_answered() {
1685 + let mut g = Grid::new(10, 3);
1686 + assert!(g.take_pending_replies().is_empty(), "nothing owed yet");
1687 + feed(&mut g, b"\x1b[c");
1688 + assert_eq!(g.take_pending_replies(), b"\x1b[?62;22c".to_vec());
1689 + }
1690 +
1691 + #[test]
1692 + fn da1_does_not_claim_sixel() {
1693 + // Attribute 4 is sixel. Claiming it makes a client prefer sixel over
1694 + // kitty graphics, and shop would then draw nothing at all.
1695 + let mut g = Grid::new(10, 3);
1696 + feed(&mut g, b"\x1b[c");
1697 + let reply = String::from_utf8(g.take_pending_replies()).unwrap();
1698 + let attrs: Vec<&str> = reply
1699 + .trim_start_matches("\x1b[?")
1700 + .trim_end_matches('c')
1701 + .split(';')
1702 + .collect();
1703 + assert!(!attrs.contains(&"4"), "claimed sixel in {reply:?}");
1704 + }
1705 +
1706 + #[test]
1707 + fn da1_with_an_explicit_zero_is_the_same_question() {
1708 + let mut g = Grid::new(10, 3);
1709 + feed(&mut g, b"\x1b[0c");
1710 + assert_eq!(g.take_pending_replies(), b"\x1b[?62;22c".to_vec());
1711 + }
1712 +
1713 + #[test]
1714 + fn da2_is_not_answered_with_da1() {
1715 + // `CSI > c` is a different question. The private-flag check only
1716 + // screens for `?`, so without the intermediates guard this arm would
1717 + // answer it, and answer it wrongly.
1718 + let mut g = Grid::new(10, 3);
1719 + feed(&mut g, b"\x1b[>c");
1720 + assert!(g.take_pending_replies().is_empty());
1721 + }
1722 +
1723 + #[test]
1724 + fn replies_are_drained_not_repeated() {
1725 + let mut g = Grid::new(10, 3);
1726 + feed(&mut g, b"\x1b[c");
1727 + assert!(!g.take_pending_replies().is_empty());
1728 + assert!(g.take_pending_replies().is_empty(), "drained once only");
1729 + }
1730 +
1731 + #[test]
1732 + fn two_queries_in_one_parse_both_get_answers() {
1733 + let mut g = Grid::new(10, 3);
1734 + feed(&mut g, b"\x1b[c\x1b[c");
1735 + assert_eq!(
1736 + g.take_pending_replies(),
1737 + b"\x1b[?62;22c\x1b[?62;22c".to_vec()
1738 + );
1739 + }
1740 +
1741 + #[test]
1742 + fn a_query_does_not_disturb_the_screen() {
1743 + let mut g = Grid::new(10, 3);
1744 + feed(&mut g, b"hi\x1b[c");
1745 + let _ = g.take_pending_replies();
1746 + assert_eq!(row_str(&g, 0), "hi");
1747 + assert_cursor(&g, 0, 2);
1748 + }
1749 +
1651 1750 #[test]
1652 1751 fn decckm_toggles_cursor_key_mode() {
1653 1752 let mut g = Grid::new(10, 3);
@@ -336,6 +336,16 @@
336 336 if let Some(title) = app.grid.take_pending_title() {
337 337 app.chrome.xdg_window.set_title(title);
338 338 }
339 + // Answers the program is waiting on, from queries in
340 + // the bytes just parsed. Written before the frame,
341 + // because a client blocked on a reply is not going to
342 + // draw anything for us to render.
343 + let replies = app.grid.take_pending_replies();
344 + if !replies.is_empty()
345 + && let Err(e) = app.pty.write(&replies)
346 + {
347 + warn!("pty write (reply): {e}");
348 + }
339 349 app.cursor_phase = !app.cursor_phase;
340 350 app.dirty = true;
341 351 }
@@ -544,6 +554,17 @@
544 554 app.images.drop_all();
545 555 app.image_placement = None;
546 556 }
557 + // "Can you draw this?" Answered rather than ignored, because that is
558 + // how a terminal nothing has heard of gets its graphics support
559 + // noticed: clients match a list of terminal names first and fall back
560 + // to asking when the name means nothing to them. yazi is the one that
561 + // matters here, and it asks precisely because shop is in no list.
562 + kgp::Command::Query { control } => {
563 + let reply = kgp::query_response(&control);
564 + if let Err(e) = app.pty.write(&reply) {
565 + warn!("pty write (kitty query): {e}");
566 + }
567 + }
547 568 // Multi-image placement + animation are parsed but not yet rendered
548 569 // (shop tasks 509fd8cb, e61fb2a1). Dropping them here matches the
549 570 // current single-image renderer.