From eec8ef0a02e7c62b0aa44b68f4bfe99e1c33bcf1 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:03:06 -0400 Subject: [PATCH] Fall back to xterm-256color when the client TERM isn't on the server dosh propagates the client's TERM, but a server lacking that terminal's terminfo (e.g. xterm-ghostty, xterm-kitty) made tmux/vim fail with "missing or unsuitable terminal". build_shell_command now keeps the requested TERM only when its terminfo exists on this host, otherwise falls back to xterm-256color so remote apps always work. Affects both the in-process and holder-spawned shells. Co-Authored-By: Claude Opus 4.8 --- src/pty.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/pty.rs b/src/pty.rs index 5a86237..2dd64cc 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use std::io::{Read, Write}; use std::os::unix::io::{AsRawFd, FromRawFd, RawFd}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::thread; use tokio::sync::mpsc; @@ -148,16 +148,65 @@ pub fn spawn_pty_session( }) } +/// Whether this host has a terminfo entry for `term`, searching the standard +/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the +/// hashed (`78/xterm`) subdirectory layouts are checked. +fn terminfo_available(term: &str) -> bool { + let Some(first) = term.chars().next() else { + return false; + }; + let letter = first.to_string(); + let hashed = format!("{:x}", first as u32); + let mut dirs: Vec = Vec::new(); + if let Ok(t) = std::env::var("TERMINFO") + && !t.is_empty() + { + dirs.push(PathBuf::from(t)); + } + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".terminfo")); + } + if let Ok(td) = std::env::var("TERMINFO_DIRS") { + for d in td.split(':').filter(|d| !d.is_empty()) { + dirs.push(PathBuf::from(d)); + } + } + for d in [ + "/etc/terminfo", + "/lib/terminfo", + "/usr/share/terminfo", + "/usr/lib/terminfo", + "/usr/share/lib/terminfo", + ] { + dirs.push(PathBuf::from(d)); + } + dirs.iter() + .any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists()) +} + /// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process /// `spawn_pty_session` and the out-of-process holder, so a persistent session's /// environment matches a non-persistent one. pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder { let mut cmd = CommandBuilder::new(shell); - cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); for (name, value) in env { cmd.env(name, value); } + // The client's TERM is propagated, but a server that lacks that terminal's + // terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like + // tmux/vim with "missing or unsuitable terminal". Keep the requested TERM + // only when this host actually has its terminfo; otherwise fall back to a + // universally available entry so remote apps always work. + let term = match env + .iter() + .find(|(n, _)| n == "TERM") + .map(|(_, v)| v.as_str()) + { + Some(requested) if terminfo_available(requested) => requested.to_string(), + _ => "xterm-256color".to_string(), + }; + cmd.env("TERM", term); cmd.env("SHELL", shell); if let Some(home) = dirs::home_dir() { cmd.env("HOME", home.as_os_str()); @@ -235,3 +284,17 @@ fn spawn_reader_thread( .context("spawn pty reader")?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminfo_available_detects_known_and_unknown() { + // A near-universal entry should be present on any host with ncurses. + assert!(terminfo_available("xterm") || terminfo_available("xterm-256color")); + // Bogus / empty names must report missing so we fall back. + assert!(!terminfo_available("definitely-not-a-real-terminal-xyz")); + assert!(!terminfo_available("")); + } +}