vtcode_ghostty_vt_sys/
lib.rs1use 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}