aboutsummaryrefslogtreecommitdiff
path: root/src/pty_callbacks.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-23 00:36:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-27 21:37:05 +0300
commita57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch)
tree25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/pty_callbacks.rs
initial release
terminal workspace manager for AI coding agents. single rust binary, runs inside your existing terminal. workspaces, tiled panes, intelligent agent state detection. https://herdr.dev
Diffstat (limited to 'src/pty_callbacks.rs')
-rw-r--r--src/pty_callbacks.rs374
1 files changed, 374 insertions, 0 deletions
diff --git a/src/pty_callbacks.rs b/src/pty_callbacks.rs
new file mode 100644
index 00000000..6d5971b0
--- /dev/null
+++ b/src/pty_callbacks.rs
@@ -0,0 +1,374 @@
+//! Terminal query responses.
+//!
+//! Programs like fzf, htop, and vim send escape sequences asking "what terminal am I?"
+//! and "where's the cursor?". A real terminal responds immediately. We need to do the same.
+//!
+//! There are ~15 query types that real programs use. We handle the common ones.
+//! The vt100 parser handles all OUTPUT sequences (formatting, cursor, etc.) — those
+//! don't need responses. Only QUERY sequences need us to reply.
+
+use std::sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc, Mutex,
+};
+
+/// Collects response bytes that need to be written back to the PTY.
+/// Also tracks whether the child has requested the Kitty keyboard protocol.
+#[derive(Clone, Default)]
+pub struct PtyResponses {
+ pending: Arc<Mutex<Vec<u8>>>,
+ /// Stack of kitty keyboard enhancement flags pushed by child programs.
+ /// Each push adds a flags value; pop removes the top. This correctly handles
+ /// nested programs (e.g. fish pushes mode 1, neovim pushes mode 3, neovim
+ /// pops → fish's mode 1 is restored).
+ kitty_stack: Arc<Mutex<Vec<u16>>>,
+ /// Derived from kitty_stack: true when stack is non-empty.
+ /// Shared with PaneRuntime for fast-path key encoding decisions.
+ pub kitty_keyboard: Arc<AtomicBool>,
+}
+
+impl PtyResponses {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Take any pending response bytes (clears the buffer).
+ pub fn take(&self) -> Vec<u8> {
+ let mut pending = self.pending.lock().unwrap();
+ std::mem::take(&mut *pending)
+ }
+
+ fn push(&self, bytes: &[u8]) {
+ self.pending.lock().unwrap().extend_from_slice(bytes);
+ }
+}
+
+impl vt100::Callbacks for PtyResponses {
+ fn unhandled_csi(
+ &mut self,
+ screen: &mut vt100::Screen,
+ i1: Option<u8>,
+ _i2: Option<u8>,
+ params: &[&[u16]],
+ c: char,
+ ) {
+ let param0 = params.first().and_then(|p| p.first()).copied().unwrap_or(0);
+
+ match (i1, c) {
+ // === Device Attributes ===
+
+ // DA1: \e[c or \e[0c → "what terminal are you?"
+ // Respond as VT220 with ANSI color
+ (None, 'c') if param0 == 0 => {
+ self.push(b"\x1b[?62;22c");
+ }
+
+ // DA2: \e[>c or \e[>0c → "secondary device attributes"
+ (Some(b'>'), 'c') => {
+ // Type 0 (VT100), firmware version 0, ROM version 0
+ self.push(b"\x1b[>0;0;0c");
+ }
+
+ // === Cursor / Status Reports ===
+
+ // DSR: \e[Nn where N selects the report type
+ (None, 'n') => match param0 {
+ // CPR: \e[6n → cursor position report
+ 5 => {
+ // Device status: "OK"
+ self.push(b"\x1b[0n");
+ }
+ 6 => {
+ let (row, col) = screen.cursor_position();
+ let response = format!("\x1b[{};{}R", row + 1, col + 1);
+ self.push(response.as_bytes());
+ }
+ _ => {}
+ },
+
+ // DECXCPR: \e[?6n → extended cursor position report (with page)
+ (Some(b'?'), 'n') if param0 == 6 => {
+ let (row, col) = screen.cursor_position();
+ let response = format!("\x1b[?{};{}R", row + 1, col + 1);
+ self.push(response.as_bytes());
+ }
+
+ // === Mode Queries (DECRQM) ===
+
+ // DECRQM: \e[?Np → "is DEC private mode N set?"
+ // Response: \e[?N;Ps$y where Ps = 1 (set), 2 (reset), 0 (unknown)
+ (Some(b'?'), 'p') => {
+ // Report all queried modes as "reset" (2) — safe default
+ let response = format!("\x1b[?{param0};2$y");
+ self.push(response.as_bytes());
+ }
+
+ // ANSI DECRQM: \e[Np → "is ANSI mode N set?"
+ (None, 'p') => {
+ let response = format!("\x1b[{param0};2$y");
+ self.push(response.as_bytes());
+ }
+
+ // === Keyboard Protocol ===
+
+ // Kitty keyboard query: \e[?u → "what keyboard flags are active?"
+ (Some(b'?'), 'u') => {
+ let stack = self.kitty_stack.lock().unwrap();
+ let flags = stack.last().copied().unwrap_or(0);
+ self.push(format!("\x1b[?{flags}u").as_bytes());
+ }
+
+ // Kitty keyboard push: \e[>Nu → child wants Kitty key encoding
+ (Some(b'>'), 'u') => {
+ let mut stack = self.kitty_stack.lock().unwrap();
+ stack.push(param0);
+ self.kitty_keyboard.store(true, Ordering::Relaxed);
+ }
+
+ // Kitty keyboard pop: \e[<Nu → child reverts to previous mode
+ // N = number of entries to pop (default 1)
+ (Some(b'<'), 'u') => {
+ let mut stack = self.kitty_stack.lock().unwrap();
+ let count = (param0 as usize).max(1);
+ for _ in 0..count {
+ if stack.pop().is_none() {
+ break;
+ }
+ }
+ self.kitty_keyboard.store(!stack.is_empty(), Ordering::Relaxed);
+ }
+
+ // === Terminal Identification ===
+
+ // XTVERSION: \e[>q → "what terminal version?"
+ (Some(b'>'), 'q') => {
+ // Respond in DCS format: \eP>|herdr 0.1\e\\
+ self.push(b"\x1bP>|herdr 0.1\x1b\\");
+ }
+
+ _ => {}
+ }
+ }
+
+ fn unhandled_osc(
+ &mut self,
+ _screen: &mut vt100::Screen,
+ params: &[&[u8]],
+ ) {
+ let Some(cmd) = params.first() else { return };
+
+ match *cmd {
+ // OSC 10 ; ? ST → query foreground color
+ b"10" => {
+ if params.get(1) == Some(&&b"?"[..]) {
+ // Respond with a default light foreground
+ self.push(b"\x1b]10;rgb:cccc/cccc/cccc\x1b\\");
+ }
+ }
+ // OSC 11 ; ? ST → query background color
+ b"11" => {
+ if params.get(1) == Some(&&b"?"[..]) {
+ // Respond with a default dark background
+ self.push(b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\");
+ }
+ }
+ // OSC 12 ; ? ST → query cursor color
+ b"12" => {
+ if params.get(1) == Some(&&b"?"[..]) {
+ self.push(b"\x1b]12;rgb:cccc/cccc/cccc\x1b\\");
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_parser(responses: PtyResponses) -> vt100::Parser<PtyResponses> {
+ vt100::Parser::new_with_callbacks(24, 80, 0, responses)
+ }
+
+ #[test]
+ fn responds_to_da1() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[c");
+ assert_eq!(r.take(), b"\x1b[?62;22c");
+ }
+
+ #[test]
+ fn responds_to_da1_explicit_zero() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[0c");
+ assert_eq!(r.take(), b"\x1b[?62;22c");
+ }
+
+ #[test]
+ fn responds_to_da2() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[>c");
+ assert_eq!(r.take(), b"\x1b[>0;0;0c");
+ }
+
+ #[test]
+ fn responds_to_cpr() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[5;10H"); // move cursor to row 5, col 10
+ p.process(b"\x1b[6n");
+ assert_eq!(r.take(), b"\x1b[5;10R");
+ }
+
+ #[test]
+ fn responds_to_dsr_status() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[5n"); // device status report
+ assert_eq!(r.take(), b"\x1b[0n"); // "OK"
+ }
+
+ #[test]
+ fn responds_to_extended_cpr() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[3;7H"); // move cursor
+ p.process(b"\x1b[?6n"); // extended CPR
+ assert_eq!(r.take(), b"\x1b[?3;7R");
+ }
+
+ #[test]
+ fn responds_to_decrqm_private() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[?25p"); // query: is cursor visible (mode 25)?
+ assert_eq!(r.take(), b"\x1b[?25;2$y"); // "reset" (2)
+ }
+
+ #[test]
+ fn responds_to_kitty_keyboard_query() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?0u");
+ }
+
+ #[test]
+ fn kitty_push_pop_stack() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+
+ // Initially off
+ assert!(!r.kitty_keyboard.load(Ordering::Relaxed));
+
+ // Fish pushes mode 1
+ p.process(b"\x1b[>1u");
+ assert!(r.kitty_keyboard.load(Ordering::Relaxed));
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?1u");
+
+ // Neovim pushes mode 3
+ p.process(b"\x1b[>3u");
+ assert!(r.kitty_keyboard.load(Ordering::Relaxed));
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?3u");
+
+ // Neovim pops → fish's mode 1 is restored
+ p.process(b"\x1b[<u");
+ assert!(r.kitty_keyboard.load(Ordering::Relaxed));
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?1u");
+
+ // Fish pops → back to legacy
+ p.process(b"\x1b[<u");
+ assert!(!r.kitty_keyboard.load(Ordering::Relaxed));
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?0u");
+ }
+
+ #[test]
+ fn kitty_pop_on_empty_is_harmless() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+
+ // Pop with nothing on stack — should not crash
+ p.process(b"\x1b[<u");
+ assert!(!r.kitty_keyboard.load(Ordering::Relaxed));
+ }
+
+ #[test]
+ fn kitty_pop_count() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+
+ // Push three times
+ p.process(b"\x1b[>1u");
+ p.process(b"\x1b[>3u");
+ p.process(b"\x1b[>5u");
+ assert!(r.kitty_keyboard.load(Ordering::Relaxed));
+
+ // Pop 2 at once
+ p.process(b"\x1b[<2u");
+ assert!(r.kitty_keyboard.load(Ordering::Relaxed));
+ p.process(b"\x1b[?u");
+ assert_eq!(r.take(), b"\x1b[?1u"); // only first push remains
+
+ // Pop last one
+ p.process(b"\x1b[<u");
+ assert!(!r.kitty_keyboard.load(Ordering::Relaxed));
+ }
+
+ #[test]
+ fn responds_to_xtversion() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[>q");
+ assert_eq!(r.take(), b"\x1bP>|herdr 0.1\x1b\\");
+ }
+
+ #[test]
+ fn responds_to_osc_fg_color_query() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b]10;?\x1b\\");
+ assert_eq!(r.take(), b"\x1b]10;rgb:cccc/cccc/cccc\x1b\\");
+ }
+
+ #[test]
+ fn responds_to_osc_bg_color_query() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b]11;?\x1b\\");
+ assert_eq!(r.take(), b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\");
+ }
+
+ #[test]
+ fn responds_to_osc_cursor_color_query() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b]12;?\x1b\\");
+ assert_eq!(r.take(), b"\x1b]12;rgb:cccc/cccc/cccc\x1b\\");
+ }
+
+ #[test]
+ fn no_response_for_regular_output() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"hello world\x1b[31mred\x1b[0m");
+ assert!(r.take().is_empty());
+ }
+
+ #[test]
+ fn multiple_queries_accumulate() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+ p.process(b"\x1b[c\x1b[6n");
+ let bytes = r.take();
+ assert!(bytes.starts_with(b"\x1b[?62;22c"));
+ assert!(bytes.ends_with(b"\x1b[1;1R"));
+ }
+}