1use anyhow::{Context, Result};
2use tokio::process::Command;
3
4pub(crate) fn socket() -> &'static str {
13 if is_test_binary() { "ninox-test" } else { "ninox" }
14}
15
16fn is_test_binary() -> bool {
26 std::env::current_exe()
27 .ok()
28 .is_some_and(|p| p.components().any(|c| c.as_os_str() == "deps"))
29}
30
31fn parse_tmux_version(raw: &str) -> (u32, u32) {
35 let ver = raw.trim().strip_prefix("tmux ").unwrap_or(raw.trim());
36 let mut parts = ver.split(|c: char| !c.is_ascii_digit());
37 let major: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
38 let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
39 (major, minor)
40}
41
42pub fn detected_version_sync() -> (u32, u32) {
47 std::process::Command::new("tmux")
48 .arg("-V")
49 .output()
50 .ok()
51 .filter(|o| o.status.success())
52 .map(|o| parse_tmux_version(&String::from_utf8_lossy(&o.stdout)))
53 .unwrap_or((0, 0))
54}
55
56fn supports_extended_keys_format((major, minor): (u32, u32)) -> bool {
59 (major, minor) >= (3, 5)
60}
61
62fn server_config_for_version(version: (u32, u32)) -> String {
66 let mut cfg = String::from("# Managed by ninox — rewritten on every app start. Do not edit.\n");
67 cfg.push_str("set -g default-terminal \"tmux-256color\"\n");
68 cfg.push_str("set -as terminal-features \"xterm*:RGB:usstyle:extkeys:hyperlinks\"\n");
69 cfg.push_str("set -s extended-keys always\n");
70 if supports_extended_keys_format(version) {
71 cfg.push_str("set -s extended-keys-format csi-u\n");
72 }
73 cfg.push_str("set -g history-limit 100000\n");
74 cfg.push_str("set -g status off\n");
75 cfg.push_str("set -s escape-time 0\n");
76 cfg.push_str("set -g window-size latest\n");
77 cfg.push_str("set -g allow-passthrough on\n");
78 cfg.push_str("set -g focus-events on\n");
79 cfg.push_str("set -g exit-empty off\n");
83 cfg
84}
85
86fn config_path() -> std::path::PathBuf {
87 let file = if is_test_binary() { "tmux-test.conf" } else { "tmux.conf" };
88 dirs::config_dir()
89 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
90 .join("ninox")
91 .join(file)
92}
93
94pub fn write_server_config() -> Result<std::path::PathBuf> {
98 let path = config_path();
99 if let Some(dir) = path.parent() {
100 std::fs::create_dir_all(dir)?;
101 }
102 std::fs::write(&path, server_config_for_version(detected_version_sync()))?;
103 Ok(path)
104}
105
106fn socket_args() -> Vec<String> {
111 vec!["-L".into(), socket().into()]
112}
113
114pub async fn require_version() -> Result<()> {
116 let out = Command::new("tmux").arg("-V").output().await
117 .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
118 let v = String::from_utf8_lossy(&out.stdout);
119 let ver = v.trim().strip_prefix("tmux ").unwrap_or(v.trim());
120 let version = parse_tmux_version(&v);
121 anyhow::ensure!(
122 version >= (3, 2),
123 "ninox requires tmux >= 3.2 for extended keyboard support; found {ver}"
124 );
125 if !supports_extended_keys_format(version) {
126 tracing::warn!(
127 "tmux {ver} detected — extended-keys-format csi-u requires tmux >= 3.5; \
128 Shift+Enter and other disambiguated keys may not reach apps correctly \
129 on this version"
130 );
131 }
132 Ok(())
133}
134
135async fn ensure_server_ready() {
156 static READY: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
157 READY.get_or_init(|| async {
158 if let Err(e) = write_server_config() {
159 tracing::warn!("failed to write tmux config: {e}");
160 }
161 let conf = config_path().display().to_string();
162 let _ = run_raw(&["-L", socket(), "-f", &conf, "start-server"]).await;
169 let _ = run_raw(&["-L", socket(), "source-file", &conf]).await;
170 }).await;
171}
172
173fn is_missing_session(e: &anyhow::Error) -> bool {
174 let msg = e.to_string();
175 msg.contains("can't find session")
176 || msg.contains("session not found")
177 || msg.contains("no server running")
178 || msg.contains("no sessions")
179 || msg.contains("no current target")
186}
187
188#[derive(Debug, Clone)]
190pub struct TmuxSession {
191 pub id: String,
192 pub created_ms: i64,
193 pub pid: Option<u32>,
194 pub tty: Option<String>,
195}
196
197async fn run(args: &[&str]) -> Result<String> {
199 ensure_server_ready().await;
200 let prefix = socket_args();
201 let mut full: Vec<&str> = prefix.iter().map(String::as_str).collect();
202 full.extend_from_slice(args);
203 run_raw(&full).await
204}
205
206async fn run_default(args: &[&str]) -> Result<String> {
209 run_raw(args).await
210}
211
212async fn run_raw(args: &[&str]) -> Result<String> {
214 let out = Command::new("tmux")
215 .args(args)
216 .kill_on_drop(true)
217 .output()
218 .await
219 .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
220 if !out.status.success() {
221 let stderr = String::from_utf8_lossy(&out.stderr);
222 anyhow::bail!("tmux {:?} failed: {}", args, stderr.trim());
223 }
224 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
225}
226
227async fn run_session_scoped(args: &[&str]) -> Result<String> {
230 match run(args).await {
231 Err(e) if is_missing_session(&e) => run_default(args).await,
232 other => other,
233 }
234}
235
236async fn run_best_effort(args: &[&str]) -> String {
239 match run(args).await {
240 Ok(result) => result,
241 Err(e) => {
242 tracing::warn!("tmux {:?} failed (ignored): {}", args, e);
243 String::new()
244 }
245 }
246}
247
248async fn run_best_effort_default(args: &[&str]) -> String {
251 match run_default(args).await {
252 Ok(result) => result,
253 Err(e) => {
254 tracing::warn!("tmux (default socket) {:?} failed (ignored): {}", args, e);
255 String::new()
256 }
257 }
258}
259
260fn shell_quote(s: &str) -> String {
263 format!("'{}'", s.replace('\'', "'\\''"))
264}
265
266pub async fn create_session(
269 id: &str,
270 workspace: &str,
271 cmd: &str,
272 env: &[(&str, &str)],
273) -> Result<()> {
274 anyhow::ensure!(
280 std::path::Path::new(workspace).is_dir(),
281 "workspace directory does not exist: {workspace}"
282 );
283 let mut env_pairs: Vec<String> = Vec::new();
285 for (k, v) in env {
286 anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
287 env_pairs.push(format!("{k}={v}"));
288 }
289 let mut extra: Vec<&str> = Vec::new();
290 for pair in &env_pairs {
291 extra.push("-e");
293 extra.push(pair.as_str());
294 }
295
296 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
300 let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
301
302 let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
307 base.extend_from_slice(&extra);
308 base.push(&shell_cmd);
309
310 run(&base).await.map(|_| ())
315}
316
317pub async fn kill_session(id: &str) -> Result<()> {
321 match run_session_scoped(&["kill-session", "-t", id]).await {
322 Ok(_) => Ok(()),
323 Err(e) => {
324 if is_missing_session(&e) {
325 Ok(())
326 } else {
327 Err(e)
328 }
329 }
330 }
331}
332
333pub async fn has_session(id: &str) -> bool {
335 run_session_scoped(&["has-session", "-t", id]).await.is_ok()
336}
337
338pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
342 const SEP: &str = "|";
349 let fmt = format!("#{{session_name}}{SEP}#{{session_created}}{SEP}#{{pane_pid}}{SEP}#{{pane_tty}}");
350 let ninox_raw = run_best_effort(&["list-sessions", "-F", &fmt]).await;
351 let default_raw = run_best_effort_default(&["list-sessions", "-F", &fmt]).await;
352
353 let mut seen = std::collections::HashSet::new();
354 let mut sessions = Vec::new();
355 for raw in [ninox_raw, default_raw] {
356 for line in raw.lines().filter(|l| !l.is_empty()) {
357 let mut cols = line.splitn(4, SEP);
358 let Some(id) = cols.next().map(str::to_string) else { continue };
359 if !seen.insert(id.clone()) {
360 continue;
361 }
362 let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
363 let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
364 let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
365 sessions.push(TmuxSession { id, created_ms: sec * 1000, pid, tty });
366 }
367 }
368 Ok(sessions)
369}
370
371pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
373 let out = run_session_scoped(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
374 Ok(out
375 .lines()
376 .next()
377 .map(|s| s.trim().to_string())
378 .filter(|s| !s.is_empty()))
379}
380
381pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
384 run_session_scoped(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
385 Ok(())
386}
387
388pub async fn attach_args(session_id: &str) -> Vec<String> {
391 let mut argv = vec!["tmux".to_string()];
392 if run(&["has-session", "-t", session_id]).await.is_ok() {
393 argv.extend(socket_args());
394 } else {
395 tracing::warn!(
396 "session {session_id} predates the ninox socket — attaching on the \
397 legacy default tmux server without the managed config (extended \
398 keys / resize guarantees are degraded until it terminates naturally)"
399 );
400 }
401 argv.extend(["attach-session", "-t", session_id].map(String::from));
402 argv
403}
404
405pub async fn history_size(session_id: &str) -> i64 {
407 run_session_scoped(&["display-message", "-p", "-t", session_id, "#{history_size}"])
408 .await
409 .ok()
410 .and_then(|s| s.trim().parse().ok())
411 .unwrap_or(0)
412}
413
414pub async fn capture_history(session_id: &str, start: i64, end: i64) -> Vec<u8> {
418 run_session_scoped(&[
419 "capture-pane", "-p", "-e", "-t", session_id,
420 "-S", &start.to_string(), "-E", &end.to_string(),
421 ])
422 .await
423 .map(|s| s.into_bytes())
424 .unwrap_or_default()
425}
426
427const SEND_SUBMIT_DELAY_MS: u64 = 300;
431const SEND_VERIFY_ATTEMPTS: u32 = 3;
434const SEND_VERIFY_DELAY_MS: u64 = 500;
435
436pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
449 run_session_scoped(&["send-keys", "-t", session_id, "-l", text]).await?;
451 tokio::time::sleep(std::time::Duration::from_millis(SEND_SUBMIT_DELAY_MS)).await;
453 run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await?;
454
455 for attempt in 0..=SEND_VERIFY_ATTEMPTS {
460 tokio::time::sleep(std::time::Duration::from_millis(SEND_VERIFY_DELAY_MS)).await;
461 if !message_stuck_at_prompt(&capture_visible_plain(session_id).await, text) {
462 return Ok(());
463 }
464 if attempt < SEND_VERIFY_ATTEMPTS {
465 run_session_scoped(&["send-keys", "-t", session_id, "Enter"]).await?;
466 }
467 }
468 anyhow::bail!(
469 "message to {session_id} is still unsubmitted at its input prompt \
470 after {SEND_VERIFY_ATTEMPTS} Enter retries"
471 )
472}
473
474async fn capture_visible_plain(session_id: &str) -> String {
477 run_session_scoped(&["capture-pane", "-p", "-t", session_id])
478 .await
479 .unwrap_or_default()
480}
481
482fn message_stuck_at_prompt(pane: &str, text: &str) -> bool {
490 const PROMPT: char = '❯';
491 let Some(line) = pane.lines().rev().find(|l| l.contains(PROMPT)) else {
492 return false;
493 };
494 let after = &line[line.rfind(PROMPT).unwrap() + PROMPT.len_utf8()..];
495 let content = after.trim().trim_end_matches('│').trim();
497 if content.is_empty() {
498 return false;
499 }
500 if content.starts_with("[Pasted text") {
501 return true;
502 }
503 let first_line = text.lines().next().unwrap_or("").trim();
504 if first_line.is_empty() {
505 return false;
506 }
507 if content.starts_with(first_line) {
508 return true;
509 }
510 const MIN_TRUNCATED_MATCH_CHARS: usize = 10;
517 content.chars().count() >= MIN_TRUNCATED_MATCH_CHARS && first_line.starts_with(content)
518}
519
520pub async fn paste_buffer(session_id: &str, buf_name: &str, tmp_path: &str, bytes: &[u8]) -> Result<()> {
526 std::fs::write(tmp_path, bytes)?;
527 let result = run_session_scoped(&[
528 "load-buffer", "-b", buf_name, tmp_path, ";",
529 "paste-buffer", "-b", buf_name, "-t", session_id, "-d",
530 ]).await;
531 let _ = std::fs::remove_file(tmp_path);
532 result.map(|_| ())
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use tokio::time::{sleep, Duration};
539
540 fn tmux_available() -> bool {
541 std::process::Command::new("tmux")
542 .args(["-V"])
543 .output()
544 .map(|o| o.status.success())
545 .unwrap_or(false)
546 }
547
548 fn unique_id() -> String {
549 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
554 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
555 format!(
556 "test-{}-{n}",
557 std::time::SystemTime::now()
558 .duration_since(std::time::UNIX_EPOCH)
559 .unwrap()
560 .as_millis()
561 )
562 }
563
564 #[test]
570 fn tests_resolve_to_the_isolated_socket_not_the_real_apps() {
571 assert!(is_test_binary(), "the test binary itself must be detected as a test binary");
572 assert_eq!(socket(), "ninox-test");
573 assert_ne!(socket(), "ninox");
574 }
575
576 #[tokio::test]
577 async fn create_session_fails_when_workspace_dir_is_missing() {
578 if !tmux_available() { return; }
579 let id = unique_id();
580 let result = create_session(&id, "/definitely/not/a/real/dir", "sleep 30", &[]).await;
585 assert!(result.is_err(), "missing workspace must be an error, not a silent $HOME fallback");
586 assert!(!has_session(&id).await, "no session may be left behind on failure");
587 }
588
589 #[tokio::test]
590 async fn create_and_has_and_kill() {
591 if !tmux_available() { return; }
592 let id = unique_id();
593 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
594 assert!(has_session(&id).await);
595 kill_session(&id).await.unwrap();
596 assert!(!has_session(&id).await);
597 }
598
599 #[tokio::test]
600 async fn list_includes_created() {
601 if !tmux_available() { return; }
602 let id = unique_id();
603 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
604 let sessions = list_sessions().await.unwrap();
605 assert!(sessions.iter().any(|s| s.id == id));
606 kill_session(&id).await.unwrap();
607 }
608
609 #[tokio::test]
610 async fn get_pane_tty_returns_dev_path() {
611 if !tmux_available() { return; }
612 let id = unique_id();
613 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
614 let tty = get_pane_tty(&id).await.unwrap();
615 assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
616 kill_session(&id).await.unwrap();
617 }
618
619 const REACTION: &str = "[Ninox] CI is failing on your PR (1/3 checks). Please fix the following:\n - build\n\nRun the failing checks locally, fix the issues, and push your changes.";
625
626 #[test]
627 fn stuck_when_pasted_text_marker_sits_at_prompt() {
628 let pane = "\
629● Working on the auth refactor now.
630
631╭──────────────────────────────────────────────╮
632│ ❯ [Pasted text #1 +4 lines] │
633╰──────────────────────────────────────────────╯
634 ? for shortcuts";
635 assert!(message_stuck_at_prompt(pane, REACTION));
636 }
637
638 #[test]
639 fn stuck_when_message_text_sits_at_prompt() {
640 let pane = "\
641╭──────────────────────────────────────────────────────╮
642│ ❯ [Ninox] Worker `auth-fix`'s PR #12 merged. │
643╰──────────────────────────────────────────────────────╯";
644 assert!(message_stuck_at_prompt(pane, "[Ninox] Worker `auth-fix`'s PR #12 merged."));
645 }
646
647 #[test]
650 fn stuck_when_prompt_shows_truncated_prefix_of_message() {
651 let pane = "│ ❯ [Ninox] CI is failing on your PR (1/3 che│";
652 assert!(message_stuck_at_prompt(pane, REACTION));
653 }
654
655 #[test]
659 fn not_stuck_when_prompt_empty_and_message_echoed_in_transcript() {
660 let pane = "\
661❯ [Ninox] Worker `auth-fix`'s PR #12 merged.
662
663● Noted — spawning the follow-up worker.
664
665╭──────────────────────────────────────────────╮
666│ ❯ │
667╰──────────────────────────────────────────────╯";
668 assert!(!message_stuck_at_prompt(pane, "[Ninox] Worker `auth-fix`'s PR #12 merged."));
669 }
670
671 #[test]
675 fn not_stuck_when_prompt_holds_unrelated_text() {
676 let pane = "│ ❯ can you also update the readme while │";
677 assert!(!message_stuck_at_prompt(pane, REACTION));
678 }
679
680 #[test]
683 fn not_stuck_when_pane_has_no_prompt_marker() {
684 let pane = "$ echo hi\nhi\n$";
685 assert!(!message_stuck_at_prompt(pane, "echo hi"));
686 }
687
688 #[test]
694 fn not_stuck_when_prompt_holds_only_a_short_prefix_of_message() {
695 for typed in ["[", "[Ninox] C"] {
696 let pane = format!("│ ❯ {typed} │");
697 assert!(
698 !message_stuck_at_prompt(&pane, REACTION),
699 "short prompt content {typed:?} must not count as our stuck message",
700 );
701 }
702 }
703
704 #[tokio::test]
707 async fn send_keys_delivers_to_a_plain_pane() {
708 if !tmux_available() { return; }
709 let id = unique_id();
710 create_session(&id, "/tmp", "cat", &[]).await.unwrap();
711 sleep(Duration::from_millis(300)).await;
712 send_keys(&id, "hello from ninox").await.unwrap();
713 let pane = capture_visible_plain(&id).await;
714 assert!(pane.contains("hello from ninox"), "pane: {pane}");
715 kill_session(&id).await.unwrap();
716 }
717
718 async fn wait_for_pane_contains(id: &str, needle: &str) {
723 for _ in 0..50 {
724 if capture_visible_plain(id).await.contains(needle) {
725 return;
726 }
727 sleep(Duration::from_millis(100)).await;
728 }
729 panic!("pane {id} never showed {needle:?}");
730 }
731
732 #[tokio::test]
736 async fn send_keys_errors_when_message_stays_stuck_at_a_prompt() {
737 if !tmux_available() { return; }
738 let id = unique_id();
739 create_session(&id, "/tmp", "printf '\\xe2\\x9d\\xaf '; exec cat > /dev/null", &[]).await.unwrap();
742 wait_for_pane_contains(&id, "❯").await;
743 let err = send_keys(&id, "stuck message").await.unwrap_err();
744 assert!(err.to_string().contains("unsubmitted"), "err: {err}");
745 kill_session(&id).await.unwrap();
746 }
747
748 #[tokio::test]
752 async fn send_keys_succeeds_when_the_last_retry_enter_submits() {
753 if !tmux_available() { return; }
754 let id = unique_id();
755 create_session(
759 &id, "/tmp",
760 "printf '\\xe2\\x9d\\xaf '; read a; read b; read c; read d; printf '\\x1b[2J\\x1b[H'; exec sleep 30",
761 &[],
762 ).await.unwrap();
763 wait_for_pane_contains(&id, "❯").await;
764 send_keys(&id, "late message").await.unwrap();
765 kill_session(&id).await.unwrap();
766 }
767
768 #[tokio::test]
769 async fn send_keys_builds_correct_command() {
770 let quoted = shell_quote("hello world");
773 assert_eq!(quoted, "'hello world'");
774 let with_apostrophe = shell_quote("don't");
775 assert_eq!(with_apostrophe, "'don'\\''t'");
776 }
777
778 #[test]
779 fn server_config_is_written_and_contains_required_settings() {
780 let path = write_server_config().unwrap();
781 let body = std::fs::read_to_string(&path).unwrap();
782 for required in [
783 "default-terminal \"tmux-256color\"",
784 "extended-keys always",
785 "history-limit 100000",
786 "status off",
787 "window-size latest",
788 "allow-passthrough on",
789 "exit-empty off",
790 ] {
791 assert!(body.contains(required), "config missing {required:?}\n{body}");
792 }
793 if supports_extended_keys_format(detected_version_sync()) {
796 assert!(body.contains("extended-keys-format csi-u"),
797 "config missing extended-keys-format on tmux >= 3.5\n{body}");
798 } else {
799 assert!(!body.contains("extended-keys-format"),
800 "config must omit extended-keys-format on tmux < 3.5 (rejected as invalid)\n{body}");
801 }
802 }
803
804 #[tokio::test]
805 async fn require_version_passes_on_installed_tmux() {
806 if !tmux_available() { return; }
807 require_version().await.unwrap();
808 }
809
810 #[tokio::test]
811 async fn sessions_are_created_on_the_ninox_socket() {
812 if !tmux_available() { return; }
813 let id = unique_id();
814 create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
815 assert!(has_session(&id).await);
817 let default_out = std::process::Command::new("tmux")
819 .args(["has-session", "-t", &id])
820 .output()
821 .unwrap();
822 assert!(!default_out.status.success(), "session leaked onto the default socket");
823 kill_session(&id).await.unwrap();
824 }
825
826 #[tokio::test]
827 async fn legacy_default_socket_sessions_are_still_reachable() {
828 if !tmux_available() { return; }
829 let id = unique_id();
830 let st = std::process::Command::new("tmux")
832 .args(["new-session", "-d", "-s", &id, "-x", "80", "-y", "24", "sleep 30"])
833 .status()
834 .unwrap();
835 assert!(st.success());
836 assert!(has_session(&id).await, "has_session must fall back to the default socket");
837 let argv = attach_args(&id).await;
838 assert!(!argv.contains(&"-L".to_string()), "legacy session must attach without -L: {argv:?}");
839 kill_session(&id).await.unwrap(); assert!(!has_session(&id).await);
841 }
842
843 #[tokio::test]
844 async fn capture_history_returns_scrolled_off_lines() {
845 if !tmux_available() { return; }
846 let id = unique_id();
847 create_session(&id, "/tmp", "bash -c 'for i in $(seq 1 80); do echo line-$i; done; sleep 30'", &[]).await.unwrap();
849 sleep(Duration::from_millis(500)).await;
850 let hist = history_size(&id).await;
851 assert!(hist > 0, "expected history to accumulate, got {hist}");
852 let bytes = capture_history(&id, -hist, -1).await;
853 let text = String::from_utf8_lossy(&bytes);
854 assert!(text.contains("line-1"), "oldest line missing from history capture: {text}");
855 kill_session(&id).await.unwrap();
856 }
857}