1use anyhow::{Context, Result};
2use tokio::process::Command;
3
4const SOCKET: &str = "ninox";
7
8fn parse_tmux_version(raw: &str) -> (u32, u32) {
12 let ver = raw.trim().strip_prefix("tmux ").unwrap_or(raw.trim());
13 let mut parts = ver.split(|c: char| !c.is_ascii_digit());
14 let major: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
15 let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
16 (major, minor)
17}
18
19pub fn detected_version_sync() -> (u32, u32) {
24 std::process::Command::new("tmux")
25 .arg("-V")
26 .output()
27 .ok()
28 .filter(|o| o.status.success())
29 .map(|o| parse_tmux_version(&String::from_utf8_lossy(&o.stdout)))
30 .unwrap_or((0, 0))
31}
32
33fn supports_extended_keys_format((major, minor): (u32, u32)) -> bool {
36 (major, minor) >= (3, 5)
37}
38
39fn server_config_for_version(version: (u32, u32)) -> String {
43 let mut cfg = String::from("# Managed by ninox — rewritten on every app start. Do not edit.\n");
44 cfg.push_str("set -g default-terminal \"tmux-256color\"\n");
45 cfg.push_str("set -as terminal-features \"xterm*:RGB:usstyle:extkeys:hyperlinks\"\n");
46 cfg.push_str("set -s extended-keys always\n");
47 if supports_extended_keys_format(version) {
48 cfg.push_str("set -s extended-keys-format csi-u\n");
49 }
50 cfg.push_str("set -g history-limit 100000\n");
51 cfg.push_str("set -g status off\n");
52 cfg.push_str("set -s escape-time 0\n");
53 cfg.push_str("set -g window-size latest\n");
54 cfg.push_str("set -g allow-passthrough on\n");
55 cfg.push_str("set -g focus-events on\n");
56 cfg.push_str("set -g exit-empty off\n");
60 cfg
61}
62
63fn config_path() -> std::path::PathBuf {
64 dirs::config_dir()
65 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
66 .join("ninox")
67 .join("tmux.conf")
68}
69
70pub fn write_server_config() -> Result<std::path::PathBuf> {
74 let path = config_path();
75 if let Some(dir) = path.parent() {
76 std::fs::create_dir_all(dir)?;
77 }
78 std::fs::write(&path, server_config_for_version(detected_version_sync()))?;
79 Ok(path)
80}
81
82fn socket_args() -> Vec<String> {
87 vec!["-L".into(), SOCKET.into()]
88}
89
90pub async fn require_version() -> Result<()> {
92 let out = Command::new("tmux").arg("-V").output().await
93 .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
94 let v = String::from_utf8_lossy(&out.stdout);
95 let ver = v.trim().strip_prefix("tmux ").unwrap_or(v.trim());
96 let version = parse_tmux_version(&v);
97 anyhow::ensure!(
98 version >= (3, 2),
99 "ninox requires tmux >= 3.2 for extended keyboard support; found {ver}"
100 );
101 if !supports_extended_keys_format(version) {
102 tracing::warn!(
103 "tmux {ver} detected — extended-keys-format csi-u requires tmux >= 3.5; \
104 Shift+Enter and other disambiguated keys may not reach apps correctly \
105 on this version"
106 );
107 }
108 Ok(())
109}
110
111async fn ensure_server_ready() {
132 static READY: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
133 READY.get_or_init(|| async {
134 if let Err(e) = write_server_config() {
135 tracing::warn!("failed to write tmux config: {e}");
136 }
137 let conf = config_path().display().to_string();
138 let _ = run_raw(&["-L", SOCKET, "-f", &conf, "start-server"]).await;
145 let _ = run_raw(&["-L", SOCKET, "source-file", &conf]).await;
146 }).await;
147}
148
149fn is_missing_session(e: &anyhow::Error) -> bool {
150 let msg = e.to_string();
151 msg.contains("can't find session")
152 || msg.contains("session not found")
153 || msg.contains("no server running")
154 || msg.contains("no sessions")
155 || msg.contains("no current target")
162}
163
164#[derive(Debug, Clone)]
166pub struct TmuxSession {
167 pub id: String,
168 pub created_ms: i64,
169 pub pid: Option<u32>,
170 pub tty: Option<String>,
171}
172
173async fn run(args: &[&str]) -> Result<String> {
175 ensure_server_ready().await;
176 let prefix = socket_args();
177 let mut full: Vec<&str> = prefix.iter().map(String::as_str).collect();
178 full.extend_from_slice(args);
179 run_raw(&full).await
180}
181
182async fn run_default(args: &[&str]) -> Result<String> {
185 run_raw(args).await
186}
187
188async fn run_raw(args: &[&str]) -> Result<String> {
190 let out = Command::new("tmux")
191 .args(args)
192 .kill_on_drop(true)
193 .output()
194 .await
195 .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
196 if !out.status.success() {
197 let stderr = String::from_utf8_lossy(&out.stderr);
198 anyhow::bail!("tmux {:?} failed: {}", args, stderr.trim());
199 }
200 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
201}
202
203async fn run_session_scoped(args: &[&str]) -> Result<String> {
206 match run(args).await {
207 Err(e) if is_missing_session(&e) => run_default(args).await,
208 other => other,
209 }
210}
211
212async fn run_best_effort(args: &[&str]) -> String {
215 match run(args).await {
216 Ok(result) => result,
217 Err(e) => {
218 tracing::warn!("tmux {:?} failed (ignored): {}", args, e);
219 String::new()
220 }
221 }
222}
223
224async fn run_best_effort_default(args: &[&str]) -> String {
227 match run_default(args).await {
228 Ok(result) => result,
229 Err(e) => {
230 tracing::warn!("tmux (default socket) {:?} failed (ignored): {}", args, e);
231 String::new()
232 }
233 }
234}
235
236fn shell_quote(s: &str) -> String {
239 format!("'{}'", s.replace('\'', "'\\''"))
240}
241
242pub async fn create_session(
245 id: &str,
246 workspace: &str,
247 cmd: &str,
248 env: &[(&str, &str)],
249) -> Result<()> {
250 let mut env_pairs: Vec<String> = Vec::new();
252 for (k, v) in env {
253 anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
254 env_pairs.push(format!("{k}={v}"));
255 }
256 let mut extra: Vec<&str> = Vec::new();
257 for pair in &env_pairs {
258 extra.push("-e");
260 extra.push(pair.as_str());
261 }
262
263 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
267 let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
268
269 let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
274 base.extend_from_slice(&extra);
275 base.push(&shell_cmd);
276
277 run(&base).await.map(|_| ())
282}
283
284pub async fn kill_session(id: &str) -> Result<()> {
288 match run_session_scoped(&["kill-session", "-t", id]).await {
289 Ok(_) => Ok(()),
290 Err(e) => {
291 if is_missing_session(&e) {
292 Ok(())
293 } else {
294 Err(e)
295 }
296 }
297 }
298}
299
300pub async fn has_session(id: &str) -> bool {
302 run_session_scoped(&["has-session", "-t", id]).await.is_ok()
303}
304
305pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
309 const SEP: &str = "|";
316 let fmt = format!("#{{session_name}}{SEP}#{{session_created}}{SEP}#{{pane_pid}}{SEP}#{{pane_tty}}");
317 let ninox_raw = run_best_effort(&["list-sessions", "-F", &fmt]).await;
318 let default_raw = run_best_effort_default(&["list-sessions", "-F", &fmt]).await;
319
320 let mut seen = std::collections::HashSet::new();
321 let mut sessions = Vec::new();
322 for raw in [ninox_raw, default_raw] {
323 for line in raw.lines().filter(|l| !l.is_empty()) {
324 let mut cols = line.splitn(4, SEP);
325 let Some(id) = cols.next().map(str::to_string) else { continue };
326 if !seen.insert(id.clone()) {
327 continue;
328 }
329 let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
330 let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
331 let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
332 sessions.push(TmuxSession { id, created_ms: sec * 1000, pid, tty });
333 }
334 }
335 Ok(sessions)
336}
337
338pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
340 let out = run_session_scoped(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
341 Ok(out
342 .lines()
343 .next()
344 .map(|s| s.trim().to_string())
345 .filter(|s| !s.is_empty()))
346}
347
348pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
351 run_session_scoped(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
352 Ok(())
353}
354
355pub async fn attach_args(session_id: &str) -> Vec<String> {
358 let mut argv = vec!["tmux".to_string()];
359 if run(&["has-session", "-t", session_id]).await.is_ok() {
360 argv.extend(socket_args());
361 } else {
362 tracing::warn!(
363 "session {session_id} predates the ninox socket — attaching on the \
364 legacy default tmux server without the managed config (extended \
365 keys / resize guarantees are degraded until it terminates naturally)"
366 );
367 }
368 argv.extend(["attach-session", "-t", session_id].map(String::from));
369 argv
370}
371
372pub async fn history_size(session_id: &str) -> i64 {
374 run_session_scoped(&["display-message", "-p", "-t", session_id, "#{history_size}"])
375 .await
376 .ok()
377 .and_then(|s| s.trim().parse().ok())
378 .unwrap_or(0)
379}
380
381pub async fn capture_history(session_id: &str, start: i64, end: i64) -> Vec<u8> {
385 run_session_scoped(&[
386 "capture-pane", "-p", "-e", "-t", session_id,
387 "-S", &start.to_string(), "-E", &end.to_string(),
388 ])
389 .await
390 .map(|s| s.into_bytes())
391 .unwrap_or_default()
392}
393
394pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
399 run_session_scoped(&["send-keys", "-t", session_id, "-l", text]).await?;
401 run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await?;
403 Ok(())
404}
405
406pub async fn paste_buffer(session_id: &str, buf_name: &str, tmp_path: &str, bytes: &[u8]) -> Result<()> {
412 std::fs::write(tmp_path, bytes)?;
413 let result = run_session_scoped(&[
414 "load-buffer", "-b", buf_name, tmp_path, ";",
415 "paste-buffer", "-b", buf_name, "-t", session_id, "-d",
416 ]).await;
417 let _ = std::fs::remove_file(tmp_path);
418 result.map(|_| ())
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use tokio::time::{sleep, Duration};
425
426 fn tmux_available() -> bool {
427 std::process::Command::new("tmux")
428 .args(["-V"])
429 .output()
430 .map(|o| o.status.success())
431 .unwrap_or(false)
432 }
433
434 fn unique_id() -> String {
435 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
440 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
441 format!(
442 "test-{}-{n}",
443 std::time::SystemTime::now()
444 .duration_since(std::time::UNIX_EPOCH)
445 .unwrap()
446 .as_millis()
447 )
448 }
449
450 #[tokio::test]
451 async fn create_and_has_and_kill() {
452 if !tmux_available() { return; }
453 let id = unique_id();
454 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
455 assert!(has_session(&id).await);
456 kill_session(&id).await.unwrap();
457 assert!(!has_session(&id).await);
458 }
459
460 #[tokio::test]
461 async fn list_includes_created() {
462 if !tmux_available() { return; }
463 let id = unique_id();
464 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
465 let sessions = list_sessions().await.unwrap();
466 assert!(sessions.iter().any(|s| s.id == id));
467 kill_session(&id).await.unwrap();
468 }
469
470 #[tokio::test]
471 async fn get_pane_tty_returns_dev_path() {
472 if !tmux_available() { return; }
473 let id = unique_id();
474 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
475 let tty = get_pane_tty(&id).await.unwrap();
476 assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
477 kill_session(&id).await.unwrap();
478 }
479
480 #[tokio::test]
481 async fn send_keys_builds_correct_command() {
482 let quoted = shell_quote("hello world");
485 assert_eq!(quoted, "'hello world'");
486 let with_apostrophe = shell_quote("don't");
487 assert_eq!(with_apostrophe, "'don'\\''t'");
488 }
489
490 #[test]
491 fn server_config_is_written_and_contains_required_settings() {
492 let path = write_server_config().unwrap();
493 let body = std::fs::read_to_string(&path).unwrap();
494 for required in [
495 "default-terminal \"tmux-256color\"",
496 "extended-keys always",
497 "history-limit 100000",
498 "status off",
499 "window-size latest",
500 "allow-passthrough on",
501 "exit-empty off",
502 ] {
503 assert!(body.contains(required), "config missing {required:?}\n{body}");
504 }
505 if supports_extended_keys_format(detected_version_sync()) {
508 assert!(body.contains("extended-keys-format csi-u"),
509 "config missing extended-keys-format on tmux >= 3.5\n{body}");
510 } else {
511 assert!(!body.contains("extended-keys-format"),
512 "config must omit extended-keys-format on tmux < 3.5 (rejected as invalid)\n{body}");
513 }
514 }
515
516 #[tokio::test]
517 async fn require_version_passes_on_installed_tmux() {
518 if !tmux_available() { return; }
519 require_version().await.unwrap();
520 }
521
522 #[tokio::test]
523 async fn sessions_are_created_on_the_ninox_socket() {
524 if !tmux_available() { return; }
525 let id = unique_id();
526 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
527 assert!(has_session(&id).await);
529 let default_out = std::process::Command::new("tmux")
531 .args(["has-session", "-t", &id])
532 .output()
533 .unwrap();
534 assert!(!default_out.status.success(), "session leaked onto the default socket");
535 kill_session(&id).await.unwrap();
536 }
537
538 #[tokio::test]
539 async fn legacy_default_socket_sessions_are_still_reachable() {
540 if !tmux_available() { return; }
541 let id = unique_id();
542 let st = std::process::Command::new("tmux")
544 .args(["new-session", "-d", "-s", &id, "-x", "80", "-y", "24", "sleep 30"])
545 .status()
546 .unwrap();
547 assert!(st.success());
548 assert!(has_session(&id).await, "has_session must fall back to the default socket");
549 let argv = attach_args(&id).await;
550 assert!(!argv.contains(&"-L".to_string()), "legacy session must attach without -L: {argv:?}");
551 kill_session(&id).await.unwrap(); assert!(!has_session(&id).await);
553 }
554
555 #[tokio::test]
556 async fn capture_history_returns_scrolled_off_lines() {
557 if !tmux_available() { return; }
558 let id = unique_id();
559 create_session(&id, "/tmp", "bash -c 'for i in $(seq 1 80); do echo line-$i; done; sleep 30'", &[]).await.unwrap();
561 sleep(Duration::from_millis(500)).await;
562 let hist = history_size(&id).await;
563 assert!(hist > 0, "expected history to accumulate, got {hist}");
564 let bytes = capture_history(&id, -hist, -1).await;
565 let text = String::from_utf8_lossy(&bytes);
566 assert!(text.contains("line-1"), "oldest line missing from history capture: {text}");
567 kill_session(&id).await.unwrap();
568 }
569}