Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions crates/alacritty-driver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ use blit_remote::FrameState;

const CELL_SIZE: usize = blit_remote::CELL_SIZE;

// Kitty keyboard protocol flags live in TermMode bits 18-22
// (DISAMBIGUATE_ESC_CODES .. REPORT_ASSOCIATED_TEXT). We surface them to the
// browser as a small integer by shifting the masked bits down to bit 0.
const KITTY_FLAGS_SHIFT: u32 = 18;

// ── Search scoring constants ────────────────────────────────────────────

const SEARCH_TITLE_BASE: u32 = 1400;
Expand Down Expand Up @@ -312,13 +317,18 @@ impl alacritty_terminal::vte::ansi::Timeout for NoSyncTimeout {
struct BlitEventProxy {
title: Arc<Mutex<Option<String>>>,
clipboard_stores: Arc<Mutex<Vec<String>>>,
/// Bytes the terminal wants written back to the PTY (query replies such as
/// the kitty `CSI ? u` capability response). The server drains these after
/// each `process()` and writes them to the PTY master.
pty_writes: Arc<Mutex<Vec<String>>>,
}

impl BlitEventProxy {
fn new() -> Self {
Self {
title: Arc::new(Mutex::new(None)),
clipboard_stores: Arc::new(Mutex::new(Vec::new())),
pty_writes: Arc::new(Mutex::new(Vec::new())),
}
}
fn take_title(&self) -> Option<String> {
Expand All @@ -327,6 +337,9 @@ impl BlitEventProxy {
fn take_clipboard_stores(&self) -> Vec<String> {
std::mem::take(&mut *self.clipboard_stores.lock().unwrap())
}
fn take_pty_writes(&self) -> Vec<String> {
std::mem::take(&mut *self.pty_writes.lock().unwrap())
}
}

impl EventListener for BlitEventProxy {
Expand All @@ -341,6 +354,12 @@ impl EventListener for BlitEventProxy {
Event::ClipboardStore(_, text) => {
self.clipboard_stores.lock().unwrap().push(text);
}
// The terminal replies to some queries (kitty `CSI ? u`, DSR, etc.)
// by asking the host to write bytes back to the PTY. We used to
// drop these; capture them so the server can forward them.
Event::PtyWrite(text) => {
self.pty_writes.lock().unwrap().push(text);
}
_ => {}
}
}
Expand Down Expand Up @@ -415,6 +434,12 @@ impl TerminalDriver {
pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self {
let config = Config {
scrolling_history: scrollback,
// Enable the kitty keyboard protocol machinery in the fork: it
// early-returns on every push/pop/query unless this is set (the
// fork's default is `false`). With it on, `Term` tracks the
// push/pop flag stack in `TermMode` bits 18-22 and emits the
// `CSI ? u` capability reply via `Event::PtyWrite`.
kitty_keyboard: true,
..Config::default()
};
let dims = TermDims {
Expand Down Expand Up @@ -584,6 +609,19 @@ impl TerminalDriver {
self.event_proxy.take_clipboard_stores()
}

/// Bytes the terminal wants written back to the PTY since the last drain
/// (e.g. the kitty `CSI ? u` capability reply). The server writes these to
/// the PTY master after each `process()`.
pub fn take_pty_writes(&mut self) -> Vec<String> {
self.event_proxy.take_pty_writes()
}

/// Active kitty keyboard protocol flags (bits 18-22 of `TermMode`, shifted
/// to bit 0). 0 when the protocol is inactive.
pub fn kitty_flags(&self) -> u8 {
((*self.term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL).bits() >> KITTY_FLAGS_SHIFT) as u8
}

pub fn synced_output(&self) -> bool {
self.modes.synced_output
}
Expand Down Expand Up @@ -617,6 +655,7 @@ impl TerminalDriver {
mode,
);
frame.set_scrollback_lines(scrollback_lines.min(u32::MAX as usize) as u32);
frame.set_kitty_flags(self.kitty_flags());
frame
}

Expand All @@ -628,6 +667,9 @@ impl TerminalDriver {

let mut frame = self.build_frame(offset, rows as usize, cols as usize, 0, 0, 0);
frame.set_scrollback_lines(scrollback_lines.min(u32::MAX as usize) as u32);
// Keep the real kitty flags while scrolled back — the keyboard mode is
// a property of the running app, not of the viewport position.
frame.set_kitty_flags(self.kitty_flags());
frame
}

Expand Down Expand Up @@ -1240,6 +1282,28 @@ mod tests {
driver.resize(40, 120);
assert_eq!(driver.size(), (40, 120));
}

#[test]
fn kitty_keyboard_protocol() {
let mut driver = TerminalDriver::new(24, 80, 1000);
assert_eq!(driver.kitty_flags(), 0);

// Push the disambiguate flag (CSI > 1 u).
driver.process(b"\x1b[>1u");
assert_eq!(driver.kitty_flags(), 1);
// The snapshot must carry the live flags too.
assert_eq!(driver.snapshot(true, true).kitty_flags(), 1);

// The capability query echoes the current flags as CSI ? <flags> u and
// is exposed as a pty write (drained once).
driver.process(b"\x1b[?u");
assert_eq!(driver.take_pty_writes(), vec!["\x1b[?1u".to_string()]);
assert!(driver.take_pty_writes().is_empty());

// Pop (CSI < u) restores the empty flag set.
driver.process(b"\x1b[<u");
assert_eq!(driver.kitty_flags(), 0);
}
}

#[cfg(test)]
Expand Down
4 changes: 4 additions & 0 deletions crates/browser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,10 @@ impl Terminal {
pub fn scrollback_lines(&self) -> u32 {
self.inner.frame().scrollback_lines()
}
/// Active kitty keyboard protocol flags (0 when inactive).
pub fn kitty_flags(&self) -> u8 {
self.inner.frame().kitty_flags()
}
pub fn cursor_visible(&self) -> bool {
self.inner.mode() & 1 != 0
}
Expand Down
83 changes: 81 additions & 2 deletions crates/remote/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@ pub struct FrameState {
line_flags: Vec<u8>,
/// Total scrollback lines available for this PTY.
scrollback_lines: u32,
/// Active kitty keyboard protocol flags (bits 18-22 of the terminal mode,
/// shifted to bit 0). 0 when the protocol is inactive. Surfaced to the
/// browser so `keyToBytes` can emit CSI-u encoding.
kitty_flags: u8,
}

impl FrameState {
Expand All @@ -392,6 +396,7 @@ impl FrameState {
overflow: BTreeMap::new(),
line_flags: vec![0; rows as usize],
scrollback_lines: 0,
kitty_flags: 0,
}
}

Expand Down Expand Up @@ -471,6 +476,14 @@ impl FrameState {
self.scrollback_lines = lines;
}

pub fn kitty_flags(&self) -> u8 {
self.kitty_flags
}

pub fn set_kitty_flags(&mut self, flags: u8) {
self.kitty_flags = flags;
}

pub fn is_wrapped(&self, row: u16) -> bool {
self.line_flags.get(row as usize).copied().unwrap_or(0) & ROW_FLAG_WRAPPED != 0
}
Expand Down Expand Up @@ -1012,6 +1025,11 @@ impl TerminalState {
payload[after_line_flags + 3],
]);
}
// Trailing kitty keyboard flags (backward-compatible extension).
// Guard on length so short payloads from older servers are tolerated.
if payload.len() >= after_line_flags + 5 {
self.frame.kitty_flags = payload[after_line_flags + 4];
}

self.frame.cursor_row = new_cursor_row.min(self.frame.rows.saturating_sub(1));
self.frame.cursor_col = new_cursor_col.min(self.frame.cols.saturating_sub(1));
Expand Down Expand Up @@ -2543,11 +2561,14 @@ pub fn build_update_msg(
}

if op_count == 0 {
// No cell changes — still emit a frame if cursor/mode/title changed.
// No cell changes — still emit a frame if cursor/mode/title/kitty
// changed. A bare kitty push/pop (`CSI > 1 u`) alters no cells, so
// without this gate the flag change would never reach the client.
if !title_changed
&& current.cursor_row == previous.cursor_row
&& current.cursor_col == previous.cursor_col
&& current.mode == previous.mode
&& current.kitty_flags == previous.kitty_flags
{
return None;
}
Expand Down Expand Up @@ -2597,7 +2618,8 @@ pub fn build_update_msg(
} else {
0
}
+ 4,
+ 4
+ 1,
);
payload.extend_from_slice(&current.rows.to_le_bytes());
payload.extend_from_slice(&current.cols.to_le_bytes());
Expand All @@ -2616,6 +2638,9 @@ pub fn build_update_msg(
}
// Trailing scrollback count — old clients ignore extra bytes.
payload.extend_from_slice(&current.scrollback_lines.to_le_bytes());
// Trailing kitty keyboard flags — another backward-compatible extension;
// old clients stop reading after the scrollback count and ignore it.
payload.push(current.kitty_flags);

let compressed = compress_prepend_size(&payload);
let mut msg = Vec::with_capacity(3 + compressed.len());
Expand Down Expand Up @@ -3051,6 +3076,60 @@ mod tests {
assert_eq!(term.title(), "");
}

#[test]
fn kitty_flags_change_emits_frame_and_round_trips() {
// A bare kitty flag change touches no cells, cursor, mode, or title, so
// the diff gate must still force an update.
let style = CellStyle::default();
let mut prev = FrameState::new(1, 4);
prev.write_text(0, 0, "hi", style);
let mut next = prev.clone();
next.set_kitty_flags(1);
let delta = build_update_msg(3, &next, &prev).expect("flags-only change must emit a frame");

let mut term = TerminalState::new(1, 4);
let baseline = build_update_msg(3, &prev, &FrameState::default()).unwrap();
let ServerMsg::Update { payload, .. } = parse_server_msg(&baseline).unwrap() else {
panic!("expected update");
};
term.feed_compressed(payload);
assert_eq!(term.frame().kitty_flags(), 0);

let ServerMsg::Update { payload, .. } = parse_server_msg(&delta).unwrap() else {
panic!("expected update");
};
// The frame carries no visible change, only the flag, so the return
// bool may be false; the flag itself must land.
term.feed_compressed(payload);
assert_eq!(term.frame().kitty_flags(), 1);
}

#[test]
fn kitty_flags_short_payload_tolerated() {
// Simulate an older server that never appended the kitty byte: drop the
// trailing byte and confirm the client applies cleanly, leaving flags 0.
let style = CellStyle::default();
let mut next = FrameState::new(1, 4);
next.write_text(0, 0, "hi", style);
next.set_kitty_flags(1);
let msg = build_update_msg(1, &next, &FrameState::default()).unwrap();
let ServerMsg::Update { payload, .. } = parse_server_msg(&msg).unwrap() else {
panic!("expected update");
};
let raw = decompress_size_prepended(payload).unwrap();

// Full payload → flags applied.
let mut full = TerminalState::new(1, 4);
full.apply_payload(&raw);
assert_eq!(full.frame().kitty_flags(), 1);

// Truncated payload (kitty byte missing) → tolerated, no panic, flags
// left at their default 0.
let mut truncated = TerminalState::new(1, 4);
truncated.apply_payload(&raw[..raw.len() - 1]);
assert_eq!(truncated.frame().kitty_flags(), 0);
}

#[test]
fn scroll_heavy_update_can_use_ops_payload() {
let style = CellStyle::default();
Expand Down
46 changes: 44 additions & 2 deletions crates/server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ trait PtyDriver: Send {
fn search_result(&self, query: &str) -> Option<PtySearchResult>;
fn take_title_dirty(&mut self) -> bool;
fn take_clipboard_stores(&mut self) -> Vec<String>;
fn take_pty_writes(&mut self) -> Vec<String>;
fn used_rows(&self) -> u16;
fn take_used_rows_dirty(&mut self) -> bool;
fn cursor_position(&self) -> (u16, u16);
Expand Down Expand Up @@ -154,6 +155,10 @@ impl PtyDriver for AlacrittyDriver {
AlacrittyDriver::take_clipboard_stores(self)
}

fn take_pty_writes(&mut self) -> Vec<String> {
AlacrittyDriver::take_pty_writes(self)
}

fn used_rows(&self) -> u16 {
AlacrittyDriver::used_rows(self)
}
Expand Down Expand Up @@ -369,6 +374,16 @@ struct Pty {
cwd: Option<String>,
}

/// Forward any bytes the terminal wants written back to the PTY (kitty
/// `CSI ? u` capability reply, etc.). Called right after `driver.process()`
/// and before `respond_to_queries` so terminal-generated replies precede our
/// own DA1/DSR answers.
fn drain_pty_writes(pty: &mut Pty) {
for text in pty.driver.take_pty_writes() {
pty::pty_write_handle(&pty.handle, text.as_bytes());
}
}

impl Pty {
fn mark_dirty(&mut self) {
self.dirty = true;
Expand Down Expand Up @@ -4331,24 +4346,32 @@ async fn tick(state: &AppState) -> TickOutcome {
};
match input {
PtyInput::Data(data) => {
// Process first so the terminal can push/pop kitty modes and
// generate its own replies (the `CSI ? u` capability
// response), then drain those replies to the PTY, and only
// then answer our own DA1/DSR queries. Kitty probes send
// `CSI ? u` + `CSI c` together; a DA1-first reply reads as
// "no kitty", so the kitty reply must go out first.
pty.driver.process(&data);
drain_pty_writes(pty);
pty::respond_to_queries(
&pty.handle,
&data,
pty.driver.size(),
pty.driver.cursor_position(),
);
pty.driver.process(&data);
pty.mark_dirty();
}
PtyInput::SyncBoundary { before } => {
if !before.is_empty() {
pty.driver.process(&before);
drain_pty_writes(pty);
pty::respond_to_queries(
&pty.handle,
&before,
pty.driver.size(),
pty.driver.cursor_position(),
);
pty.driver.process(&before);
pty.mark_dirty();
}
if !pty.driver.synced_output() {
Expand Down Expand Up @@ -8353,6 +8376,25 @@ mod tests {
assert!(results.is_empty());
}

#[test]
fn parse_tq_kitty_keyboard_sequences_ignored() {
// The kitty keyboard protocol reuses the `u` final byte with `?`, `=`,
// `>`, and `<` intermediates. The terminal driver owns those replies;
// parse_terminal_queries must never answer them (or it would race the
// driver's `CSI ? u` reply and DA1).
for seq in [
&b"\x1b[?u"[..], // capability query
&b"\x1b[=1;1u"[..], // set flags
&b"\x1b[>1u"[..], // push flags
&b"\x1b[<u"[..], // pop flags
] {
assert!(
parse_terminal_queries(seq, (24, 80), (0, 0)).is_empty(),
"should ignore {seq:?}",
);
}
}

#[test]
fn parse_tq_empty_input() {
let results = parse_terminal_queries(b"", (24, 80), (0, 0));
Expand Down
6 changes: 6 additions & 0 deletions crates/server/src/pty/pty_unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,12 @@ pub fn respond_to_queries(handle: &PtyHandle, data: &[u8], size: (u16, u16), cur
}
}

/// Write raw bytes to the PTY master. Used to forward terminal-generated
/// replies (e.g. the kitty `CSI ? u` capability response) back to the child.
pub fn pty_write_handle(handle: &PtyHandle, data: &[u8]) {
pty_write_all(handle.master_fd, data);
}

pub fn pty_reader(fd: PtyWriteTarget, tx: mpsc::Sender<PtyInput>, notify: Arc<Notify>) {
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
Expand Down
Loading
Loading