Skip to main content

vtcode_ghostty_vt_sys/
lib.rs

1use anyhow::{Context, Result, anyhow};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::sync::OnceLock;
6
7#[derive(Debug, Clone, Copy)]
8pub struct GhosttyRenderRequest {
9    pub cols: u16,
10    pub rows: u16,
11    pub scrollback_lines: usize,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct GhosttyRenderOutput {
16    pub screen_contents: String,
17    pub scrollback: String,
18}
19
20pub fn render_terminal_snapshot(
21    request: GhosttyRenderRequest,
22    vt_stream: &[u8],
23) -> Result<GhosttyRenderOutput> {
24    if vt_stream.is_empty() {
25        return Ok(GhosttyRenderOutput {
26            screen_contents: String::new(),
27            scrollback: String::new(),
28        });
29    }
30
31    let helper_path = ghostty_helper_path()
32        .ok_or_else(|| anyhow!("Ghostty VT helper is unavailable; falling back to legacy_vt100"))?;
33    let mut child = Command::new(helper_path)
34        .arg(request.cols.to_string())
35        .arg(request.rows.to_string())
36        .arg(request.scrollback_lines.to_string())
37        .stdin(Stdio::piped())
38        .stdout(Stdio::piped())
39        .stderr(Stdio::piped())
40        .spawn()
41        .with_context(|| {
42            format!(
43                "failed to spawn Ghostty VT host helper at {}",
44                helper_path.display()
45            )
46        })?;
47
48    if let Some(mut stdin) = child.stdin.take() {
49        stdin
50            .write_all(vt_stream)
51            .context("failed to write VT stream to Ghostty helper")?;
52    }
53
54    let output = child
55        .wait_with_output()
56        .context("failed to wait for Ghostty helper")?;
57    if !output.status.success() {
58        let stderr = String::from_utf8_lossy(&output.stderr);
59        return Err(anyhow!(
60            "Ghostty helper exited with status {}: {}",
61            output.status,
62            stderr.trim()
63        ));
64    }
65
66    parse_output(&output.stdout)
67}
68
69fn parse_output(bytes: &[u8]) -> Result<GhosttyRenderOutput> {
70    let (screen_len, rest) = parse_len(bytes).context("missing screen length")?;
71    if rest.len() < screen_len {
72        return Err(anyhow!("Ghostty helper truncated screen payload"));
73    }
74
75    let (screen_bytes, rest) = rest.split_at(screen_len);
76    let (scrollback_len, rest) = parse_len(rest).context("missing scrollback length")?;
77    if rest.len() < scrollback_len {
78        return Err(anyhow!("Ghostty helper truncated scrollback payload"));
79    }
80
81    let (scrollback_bytes, trailing) = rest.split_at(scrollback_len);
82    if !trailing.is_empty() {
83        return Err(anyhow!("Ghostty helper returned trailing bytes"));
84    }
85
86    Ok(GhosttyRenderOutput {
87        screen_contents: String::from_utf8(screen_bytes.to_vec())
88            .context("Ghostty helper returned invalid UTF-8 for screen contents")?,
89        scrollback: String::from_utf8(scrollback_bytes.to_vec())
90            .context("Ghostty helper returned invalid UTF-8 for scrollback")?,
91    })
92}
93
94fn parse_len(bytes: &[u8]) -> Result<(usize, &[u8])> {
95    if bytes.len() < 8 {
96        return Err(anyhow!("expected 8-byte length prefix"));
97    }
98
99    let mut raw = [0u8; 8];
100    raw.copy_from_slice(&bytes[..8]);
101    let len = u64::from_le_bytes(raw)
102        .try_into()
103        .map_err(|_| anyhow!("length prefix does not fit into usize"))?;
104    Ok((len, &bytes[8..]))
105}
106
107fn ghostty_helper_path() -> Option<&'static Path> {
108    static HELPER_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
109    HELPER_PATH
110        .get_or_init(|| {
111            resolve_helper_path(
112                std::env::var_os("VTCODE_GHOSTTY_VT_HOST").map(PathBuf::from),
113                std::env::var_os("VTCODE_GHOSTTY_VT_DIR").map(PathBuf::from),
114                std::env::current_exe().ok(),
115                option_env!("VTCODE_GHOSTTY_VT_HOST_BUILD").filter(|value| !value.is_empty()),
116            )
117        })
118        .as_deref()
119}
120
121fn resolve_helper_path(
122    env_helper: Option<PathBuf>,
123    env_dir: Option<PathBuf>,
124    current_exe: Option<PathBuf>,
125    build_helper: Option<&str>,
126) -> Option<PathBuf> {
127    candidate_helper_paths(env_helper, env_dir, current_exe, build_helper)
128        .into_iter()
129        .find(|path| path.is_file())
130}
131
132fn candidate_helper_paths(
133    env_helper: Option<PathBuf>,
134    env_dir: Option<PathBuf>,
135    current_exe: Option<PathBuf>,
136    build_helper: Option<&str>,
137) -> Vec<PathBuf> {
138    let mut candidates = Vec::with_capacity(5);
139    if let Some(path) = env_helper {
140        candidates.push(path);
141    }
142
143    if let Some(dir) = env_dir {
144        candidates.push(dir.join(helper_name()));
145    }
146
147    if let Some(exe) = current_exe
148        && let Some(exe_dir) = exe.parent()
149    {
150        candidates.push(exe_dir.join("ghostty-vt").join(helper_name()));
151        candidates.push(exe_dir.join(helper_name()));
152    }
153
154    if let Some(path) = build_helper {
155        candidates.push(PathBuf::from(path));
156    }
157
158    candidates
159}
160
161fn helper_name() -> &'static str {
162    if cfg!(windows) {
163        "ghostty_vt_host.exe"
164    } else {
165        "ghostty_vt_host"
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::path::PathBuf;
172
173    use tempfile::tempdir;
174
175    use super::{
176        GhosttyRenderOutput, GhosttyRenderRequest, candidate_helper_paths, parse_output,
177        render_terminal_snapshot, resolve_helper_path,
178    };
179
180    #[test]
181    fn empty_vt_stream_returns_empty_snapshot() {
182        let output = render_terminal_snapshot(
183            GhosttyRenderRequest {
184                cols: 80,
185                rows: 24,
186                scrollback_lines: 1000,
187            },
188            &[],
189        )
190        .expect("empty VT stream should not require helper");
191
192        assert_eq!(
193            output,
194            GhosttyRenderOutput {
195                screen_contents: String::new(),
196                scrollback: String::new(),
197            }
198        );
199    }
200
201    #[test]
202    fn parse_output_decodes_length_prefixed_payloads() {
203        let mut bytes = Vec::new();
204        bytes.extend_from_slice(&(5u64).to_le_bytes());
205        bytes.extend_from_slice(b"hello");
206        bytes.extend_from_slice(&(5u64).to_le_bytes());
207        bytes.extend_from_slice(b"world");
208
209        let output = parse_output(&bytes).expect("valid payload should parse");
210        assert_eq!(
211            output,
212            GhosttyRenderOutput {
213                screen_contents: "hello".to_string(),
214                scrollback: "world".to_string(),
215            }
216        );
217    }
218
219    #[test]
220    fn parse_output_rejects_trailing_bytes() {
221        let mut bytes = Vec::new();
222        bytes.extend_from_slice(&(1u64).to_le_bytes());
223        bytes.extend_from_slice(b"a");
224        bytes.extend_from_slice(&(1u64).to_le_bytes());
225        bytes.extend_from_slice(b"b");
226        bytes.extend_from_slice(b"extra");
227
228        let error = parse_output(&bytes).expect_err("trailing bytes must be rejected");
229        assert!(error.to_string().contains("trailing bytes"));
230    }
231
232    #[test]
233    fn resolve_helper_prefers_env_override() {
234        let temp_dir = tempdir().expect("tempdir");
235        let helper = temp_dir.path().join("custom-helper");
236        std::fs::write(&helper, b"").expect("helper file");
237
238        let resolved = resolve_helper_path(Some(helper.clone()), None, None, None)
239            .expect("env override should resolve");
240        assert_eq!(resolved, helper);
241    }
242
243    #[test]
244    fn candidate_helper_paths_include_sidecar_dir_next_to_binary() {
245        let candidates = candidate_helper_paths(
246            None,
247            None,
248            Some(PathBuf::from("/tmp/vtcode")),
249            Some("/tmp/build-helper"),
250        );
251
252        assert!(
253            candidates
254                .iter()
255                .any(|path| path == &PathBuf::from("/tmp/ghostty-vt").join(super::helper_name()))
256        );
257        assert!(
258            candidates
259                .iter()
260                .any(|path| path == &PathBuf::from("/tmp/build-helper"))
261        );
262    }
263
264    #[test]
265    fn build_helper_renders_snapshot_when_available() {
266        if option_env!("VTCODE_GHOSTTY_VT_HOST_BUILD")
267            .filter(|value| !value.is_empty())
268            .is_none()
269        {
270            return;
271        }
272
273        let output = render_terminal_snapshot(
274            GhosttyRenderRequest {
275                cols: 80,
276                rows: 24,
277                scrollback_lines: 1000,
278            },
279            b"hello from ghostty\r\n",
280        )
281        .expect("build helper should render VT stream");
282
283        assert!(output.screen_contents.contains("hello from ghostty"));
284    }
285}