From 961ac03ad2dc73e1ea33bace66760c42b95c55ac Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 17 May 2026 10:56:37 +0800 Subject: [PATCH] feat: render TUI on /dev/tty for editor embedding (Helix) When stdout is not a terminal (e.g. Helix :insert-output), use /dev/tty on Unix so gitui can run interactively. Add --tty to force this behavior. Fixes #2556 Co-authored-by: Cursor --- src/args.rs | 12 ++++ src/gitui.rs | 1 + src/main.rs | 40 ++++++++------ src/popups/externaleditor.rs | 13 ++--- src/terminal_io.rs | 104 +++++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 26 deletions(-) create mode 100644 src/terminal_io.rs diff --git a/src/args.rs b/src/args.rs index 22c6cc8d..416eec60 100644 --- a/src/args.rs +++ b/src/args.rs @@ -20,6 +20,7 @@ const WORKDIR_FLAG_ID: &str = "workdir"; const FILE_FLAG_ID: &str = "file"; const GIT_DIR_FLAG_ID: &str = "directory"; const WATCHER_FLAG_ID: &str = "watcher"; +const TTY_FLAG_ID: &str = "tty"; const KEY_BINDINGS_FLAG_ID: &str = "key_bindings"; const KEY_SYMBOLS_FLAG_ID: &str = "key_symbols"; const DEFAULT_THEME: &str = "theme.ron"; @@ -33,6 +34,8 @@ pub struct CliArgs { pub notify_watcher: bool, pub key_bindings_path: Option, pub key_symbols_path: Option, + /// Render the TUI on `/dev/tty` instead of stdout (Unix only). + pub use_tty: bool, } pub fn process_cmdline() -> Result { @@ -92,6 +95,8 @@ pub fn process_cmdline() -> Result { .get_one::(KEY_SYMBOLS_FLAG_ID) .map(PathBuf::from); + let use_tty = arg_matches.get_flag(TTY_FLAG_ID); + Ok(CliArgs { theme, select_file, @@ -99,6 +104,7 @@ pub fn process_cmdline() -> Result { notify_watcher, key_bindings_path, key_symbols_path, + use_tty, }) } @@ -161,6 +167,12 @@ fn app() -> ClapApp { .long("watcher") .action(clap::ArgAction::SetTrue), ) + .arg( + Arg::new(TTY_FLAG_ID) + .help("Render on /dev/tty instead of stdout (Unix). When stdout is not a terminal, /dev/tty is used automatically for editor embedding (e.g. Helix :insert-output).") + .long("tty") + .action(clap::ArgAction::SetTrue), + ) .arg( Arg::new(BUG_REPORT_FLAG_ID) .help("Generate a bug report") diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11..20955216 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -244,6 +244,7 @@ mod tests { notify_watcher: false, key_bindings_path: None, key_symbols_path: None, + use_tty: false, }; let theme = Theme::init(&PathBuf::new()); diff --git a/src/main.rs b/src/main.rs index fd662950..df42f429 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,12 +77,14 @@ mod spinner; mod string_utils; mod strings; mod tabs; +mod terminal_io; mod ui; mod watcher; use crate::{ app::App, args::{process_cmdline, CliArgs}, + terminal_io::{SharedTerminalWriter, TerminalWriter}, }; use anyhow::{anyhow, bail, Result}; use app::QuitState; @@ -90,10 +92,7 @@ use asyncgit::{sync::RepoPath, AsyncGitNotification}; use backtrace::Backtrace; use crossbeam_channel::{Receiver, Select}; use crossterm::{ - terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, - LeaveAlternateScreen, - }, + terminal::{disable_raw_mode, enable_raw_mode}, ExecutableCommand, }; use gitui::Gitui; @@ -102,14 +101,14 @@ use keys::KeyConfig; use ratatui::backend::CrosstermBackend; use scopeguard::defer; use std::{ - io::{self, Stdout}, panic, path::Path, + sync::{Arc, Mutex}, time::{Duration, Instant}, }; use ui::style::Theme; -type Terminal = ratatui::Terminal>; +type Terminal = ratatui::Terminal>; static TICK_INTERVAL: Duration = Duration::from_secs(5); static SPINNER_INTERVAL: Duration = Duration::from_millis(80); @@ -174,6 +173,12 @@ fn main() -> Result<()> { .unwrap_or_default(); let theme = Theme::init(&cliargs.theme); + let terminal_writer = Arc::new(Mutex::new(TerminalWriter::open( + cliargs.use_tty, + )?)); + terminal_io::init(Arc::clone(&terminal_writer)) + .map_err(|_| anyhow!("terminal writer already initialized"))?; + setup_terminal()?; defer! { shutdown_terminal(); @@ -181,8 +186,10 @@ fn main() -> Result<()> { set_panic_handler()?; - let mut terminal = - start_terminal(io::stdout(), &cliargs.repo_path)?; + let mut terminal = start_terminal( + SharedTerminalWriter(Arc::clone(&terminal_writer)), + &cliargs.repo_path, + )?; let updater = if cliargs.notify_watcher { Updater::NotifyWatcher @@ -211,6 +218,7 @@ fn main() -> Result<()> { notify_watcher: args.notify_watcher, key_bindings_path: args.key_bindings_path, key_symbols_path: args.key_symbols_path, + use_tty: args.use_tty, } } _ => break, @@ -237,21 +245,17 @@ fn run_app( fn setup_terminal() -> Result<()> { enable_raw_mode()?; - io::stdout().execute(EnterAlternateScreen)?; + terminal_io::execute(crossterm::terminal::EnterAlternateScreen)?; Ok(()) } fn shutdown_terminal() { - let leave_screen = - io::stdout().execute(LeaveAlternateScreen).map(|_f| ()); - - if let Err(e) = leave_screen { + if let Err(e) = terminal_io::execute(crossterm::terminal::LeaveAlternateScreen) + { log::error!("leave_screen failed:\n{e}"); } - let leave_raw_mode = disable_raw_mode(); - - if let Err(e) = leave_raw_mode { + if let Err(e) = disable_raw_mode() { log::error!("leave_raw_mode failed:\n{e}"); } } @@ -321,7 +325,7 @@ fn select_event( } fn start_terminal( - buf: Stdout, + writer: SharedTerminalWriter, repo_path: &RepoPath, ) -> Result { let mut path = repo_path.gitpath().canonicalize()?; @@ -335,7 +339,7 @@ fn start_terminal( path = Path::new("~").join(relative_part); } - let mut backend = CrosstermBackend::new(buf); + let mut backend = CrosstermBackend::new(writer); backend.execute(crossterm::terminal::SetTitle(format!( "gitui ({})", path.display() diff --git a/src/popups/externaleditor.rs b/src/popups/externaleditor.rs index 52a7327b..84996543 100644 --- a/src/popups/externaleditor.rs +++ b/src/popups/externaleditor.rs @@ -12,11 +12,7 @@ use anyhow::{anyhow, bail, Result}; use asyncgit::sync::{ get_config_string, utils::repo_work_dir, RepoPath, }; -use crossterm::{ - event::Event, - terminal::{EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, -}; +use crossterm::event::Event; use ratatui::{ layout::Rect, text::{Line, Span}, @@ -25,7 +21,7 @@ use ratatui::{ }; use scopeguard::defer; use std::ffi::OsStr; -use std::{env, io, path::Path, process::Command}; +use std::{env, path::Path, process::Command}; /// pub struct ExternalEditorPopup { @@ -61,9 +57,10 @@ impl ExternalEditorPopup { bail!("file not found: {path:?}"); } - io::stdout().execute(LeaveAlternateScreen)?; + crate::terminal_io::execute(crossterm::terminal::LeaveAlternateScreen)?; defer! { - io::stdout().execute(EnterAlternateScreen).expect("reset terminal"); + crate::terminal_io::execute(crossterm::terminal::EnterAlternateScreen) + .expect("reset terminal"); } let environment_options = ["GIT_EDITOR", "VISUAL", "EDITOR"]; diff --git a/src/terminal_io.rs b/src/terminal_io.rs new file mode 100644 index 00000000..48b87f5a --- /dev/null +++ b/src/terminal_io.rs @@ -0,0 +1,104 @@ +//! Terminal output selection for embedding gitui in editors (e.g. Helix). + +use crossterm::{Command, ExecutableCommand}; +use std::{ + io::{self, IsTerminal, Stdout, Write}, + sync::{Arc, Mutex, OnceLock}, +}; + +/// The output stream used for the TUI (stdout or `/dev/tty` on Unix). +pub enum TerminalWriter { + Stdout(Stdout), + #[cfg(unix)] + Tty(std::fs::File), +} + +impl TerminalWriter { + /// Opens the terminal output stream. + /// + /// On Unix, when `force_tty` is set or stdout is not a terminal, `/dev/tty` + /// is used so interactive programs work when stdout is captured (e.g. Helix + /// `:insert-output`). + pub fn open(force_tty: bool) -> io::Result { + #[cfg(unix)] + { + let use_tty = force_tty || !io::stdout().is_terminal(); + if use_tty { + if let Ok(file) = std::fs::File::open("/dev/tty") { + return Ok(Self::Tty(file)); + } + } + } + + #[cfg(not(unix))] + if force_tty { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "--tty is only supported on Unix", + )); + } + + Ok(Self::Stdout(io::stdout())) + } +} + +impl Write for TerminalWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Stdout(writer) => writer.write(buf), + #[cfg(unix)] + Self::Tty(writer) => writer.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Stdout(writer) => writer.flush(), + #[cfg(unix)] + Self::Tty(writer) => writer.flush(), + } + } +} + +/// Shared handle to the terminal writer for crossterm/ratatui and shutdown hooks. +#[derive(Clone)] +pub struct SharedTerminalWriter(pub Arc>); + +impl Write for SharedTerminalWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().expect("terminal writer poisoned").write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.0 + .lock() + .expect("terminal writer poisoned") + .flush() + } +} + +static TERMINAL_WRITER: OnceLock>> = OnceLock::new(); + +/// Registers the process-wide terminal writer (call once at startup). +pub fn init(writer: Arc>) -> Result<(), Arc>> { + TERMINAL_WRITER.set(writer) +} + +/// Runs a closure against the terminal writer. +pub fn with_writer(f: F) -> io::Result +where + F: FnOnce(&mut TerminalWriter) -> io::Result, +{ + let writer = TERMINAL_WRITER + .get() + .ok_or_else(|| io::Error::other("terminal writer not initialized"))?; + f(&mut writer.lock().expect("terminal writer poisoned")) +} + +/// Executes a crossterm command on the active terminal writer. +pub fn execute(cmd: C) -> io::Result<()> { + with_writer(|writer| { + writer.execute(cmd)?; + Ok(()) + }) +}