From 0908e1a6ae47074230375ba90c0f11e23845d438 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Feb 2026 00:39:25 +0900 Subject: [PATCH 1/6] feat: --write-to-stdout to write result to stdout (UNIX only) --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 5 +++ src/main.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/prompt.rs | 16 ++++++-- 5 files changed, 118 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 460d149..2a80d21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,6 +856,7 @@ dependencies = [ "jaq-core", "jaq-json", "jaq-std", + "libc", "promkit-widgets", "serde", "termcfg", diff --git a/Cargo.toml b/Cargo.toml index eef4861..ab0accf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ duration-string = { version = "0.5.3", features = ["serde"] } derive_builder = "0.20.2" dirs = "6.0.0" futures = "0.3.32" +libc = "0.2.177" serde = "1.0.228" termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.49.0", features = ["full"] } diff --git a/README.md b/README.md index bea138c..5f461be 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,12 @@ cargo install jnv ```bash cat data.json | jnv + # or jnv data.json + +# or write current result to stdout on exit (UNIX only) +cat data.json | jnv --write-to-stdout | some-command ``` ## Keymap @@ -180,6 +184,7 @@ Arguments: Options: -c, --config Path to the configuration file. --default-filter Default jq filter to apply to the input data + --write-to-stdout Write the current JSON result to stdout when exiting -h, --help Print help (see more with '--help') -V, --version Print version ``` diff --git a/src/main.rs b/src/main.rs index 6d900b4..1801a56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::{ fs::File, - io::{self, Read, Write}, + io::{self, IsTerminal, Read, Write}, path::PathBuf, }; @@ -70,6 +72,12 @@ pub struct Args { " )] default_filter: Option, + + #[arg( + long = "write-to-stdout", + help = "Write the current JSON result to stdout when exiting" + )] + write_to_stdout: bool, } /// Parses the input based on the provided arguments. @@ -141,6 +149,80 @@ fn determine_config_file(config_path: Option) -> anyhow::Result, +} + +impl StdoutRedirect { + fn for_tui(write_to_stdout: bool) -> anyhow::Result { + if !write_to_stdout || io::stdout().is_terminal() { + return Ok(Self { + #[cfg(unix)] + saved_stdout: None, + }); + } + + #[cfg(unix)] + { + let tty = File::options() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|e| anyhow!("Failed to open /dev/tty for TUI rendering: {e}"))?; + + let saved_fd = unsafe { libc::dup(libc::STDOUT_FILENO) }; + if saved_fd < 0 { + return Err(anyhow!( + "Failed to duplicate stdout: {}", + io::Error::last_os_error() + )); + } + + let redirected = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDOUT_FILENO) }; + if redirected < 0 { + let _ = unsafe { libc::close(saved_fd) }; + return Err(anyhow!( + "Failed to redirect stdout to /dev/tty: {}", + io::Error::last_os_error() + )); + } + + Ok(Self { + saved_stdout: Some(unsafe { OwnedFd::from_raw_fd(saved_fd) }), + }) + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "`--write-to-stdout` with piped stdout is not supported on this platform" + )) + } + } + + fn restore(&mut self) -> anyhow::Result<()> { + #[cfg(unix)] + if let Some(saved_stdout) = self.saved_stdout.take() { + let restored = unsafe { libc::dup2(saved_stdout.as_raw_fd(), libc::STDOUT_FILENO) }; + if restored < 0 { + return Err(anyhow!( + "Failed to restore stdout: {}", + io::Error::last_os_error() + )); + } + } + + Ok(()) + } +} + +impl Drop for StdoutRedirect { + fn drop(&mut self) { + let _ = self.restore(); + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let args = Args::parse(); @@ -194,8 +276,10 @@ async fn main() -> anyhow::Result<()> { config.keybinds.on_editor.clone(), ); + let mut stdout_redirect = StdoutRedirect::for_tui(args.write_to_stdout)?; + // TODO: put all logics here. - prompt::run( + let maybe_output = prompt::run( item, config.reactivity_control, provider, @@ -203,8 +287,20 @@ async fn main() -> anyhow::Result<()> { loading_suggestions_task, config.no_hint, config.keybinds, + args.write_to_stdout, ) - .await?; + .await; + + stdout_redirect.restore()?; + let maybe_output = maybe_output?; + + if let Some(output) = maybe_output { + let mut stdout = io::stdout(); + stdout.write_all(output.as_bytes())?; + if !output.ends_with('\n') { + stdout.write_all(b"\n")?; + } + } Ok(()) } diff --git a/src/prompt.rs b/src/prompt.rs index 911d67d..2f55685 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -123,7 +123,8 @@ pub async fn run( loading_suggestions_task: JoinHandle>, no_hint: bool, keybinds: Keybinds, -) -> anyhow::Result<()> { + write_to_stdout: bool, +) -> anyhow::Result> { enable_raw_mode()?; execute!(io::stdout(), cursor::Hide)?; @@ -385,11 +386,11 @@ pub async fn run( }) }; + let shared_visualizer = Arc::new(Mutex::new(initializing.await?)); let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); - let visualizer = initializing.await?; - let shared_visualizer = Arc::new(Mutex::new(visualizer)); + let shared_visualizer = shared_visualizer.clone(); tokio::spawn(async move { loop { tokio::select! { @@ -460,6 +461,13 @@ pub async fn run( main_task.await??; + let output = if write_to_stdout { + let visualizer = shared_visualizer.lock().await; + Some(visualizer.content_to_copy().await) + } else { + None + }; + loading_suggestions_task.abort(); spinning.abort(); query_debouncer.abort(); @@ -470,5 +478,5 @@ pub async fn run( execute!(io::stdout(), cursor::Show, DisableMouseCapture)?; disable_raw_mode()?; - Ok(()) + Ok(output) } From d8686c3334fb5b6f21ce8b02c87914d5480bf6b1 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Feb 2026 01:54:26 +0900 Subject: [PATCH 2/6] chore: use rustix instead of libc --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 38 ++++++++++++-------------------------- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a80d21..95f0730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,8 +856,8 @@ dependencies = [ "jaq-core", "jaq-json", "jaq-std", - "libc", "promkit-widgets", + "rustix", "serde", "termcfg", "tokio", diff --git a/Cargo.toml b/Cargo.toml index ab0accf..5d5743e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ duration-string = { version = "0.5.3", features = ["serde"] } derive_builder = "0.20.2" dirs = "6.0.0" futures = "0.3.32" -libc = "0.2.177" +rustix = { version = "1.1.4", features = ["stdio"] } serde = "1.0.228" termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.49.0", features = ["full"] } diff --git a/src/main.rs b/src/main.rs index 1801a56..d2c56dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -#[cfg(unix)] -use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::{ fs::File, io::{self, IsTerminal, Read, Write}, @@ -14,6 +12,14 @@ use promkit_widgets::{ text_editor::{self, TextEditor}, }; +#[cfg(unix)] +use std::os::fd::OwnedFd; +#[cfg(unix)] +use rustix::{ + io::dup, + stdio::{dup2_stdout, stdout}, +}; + mod editor; use editor::Editor; mod config; @@ -171,25 +177,11 @@ impl StdoutRedirect { .open("/dev/tty") .map_err(|e| anyhow!("Failed to open /dev/tty for TUI rendering: {e}"))?; - let saved_fd = unsafe { libc::dup(libc::STDOUT_FILENO) }; - if saved_fd < 0 { - return Err(anyhow!( - "Failed to duplicate stdout: {}", - io::Error::last_os_error() - )); - } - - let redirected = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDOUT_FILENO) }; - if redirected < 0 { - let _ = unsafe { libc::close(saved_fd) }; - return Err(anyhow!( - "Failed to redirect stdout to /dev/tty: {}", - io::Error::last_os_error() - )); - } + let saved_fd = dup(stdout()).map_err(|e| anyhow!("Failed to duplicate stdout: {e}"))?; + dup2_stdout(&tty).map_err(|e| anyhow!("Failed to redirect stdout to /dev/tty: {e}"))?; Ok(Self { - saved_stdout: Some(unsafe { OwnedFd::from_raw_fd(saved_fd) }), + saved_stdout: Some(saved_fd), }) } @@ -204,13 +196,7 @@ impl StdoutRedirect { fn restore(&mut self) -> anyhow::Result<()> { #[cfg(unix)] if let Some(saved_stdout) = self.saved_stdout.take() { - let restored = unsafe { libc::dup2(saved_stdout.as_raw_fd(), libc::STDOUT_FILENO) }; - if restored < 0 { - return Err(anyhow!( - "Failed to restore stdout: {}", - io::Error::last_os_error() - )); - } + dup2_stdout(&saved_stdout).map_err(|e| anyhow!("Failed to restore stdout: {e}"))?; } Ok(()) From aba97927733fd2cb12284147c31028c9ae59bddf Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Feb 2026 02:10:21 +0900 Subject: [PATCH 3/6] chore: move StdoutRedirect to stdout_redirect.rs --- src/main.rs | 68 +++-------------------------------- src/stdout_redirect.rs | 82 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 64 deletions(-) create mode 100644 src/stdout_redirect.rs diff --git a/src/main.rs b/src/main.rs index d2c56dd..393d505 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use std::{ fs::File, - io::{self, IsTerminal, Read, Write}, + io::{self, Read, Write}, path::PathBuf, }; @@ -12,19 +12,13 @@ use promkit_widgets::{ text_editor::{self, TextEditor}, }; -#[cfg(unix)] -use std::os::fd::OwnedFd; -#[cfg(unix)] -use rustix::{ - io::dup, - stdio::{dup2_stdout, stdout}, -}; - mod editor; use editor::Editor; mod config; mod json; use json::JsonStreamProvider; +mod stdout_redirect; +use stdout_redirect::StdoutRedirect; mod processor; use processor::{ init::ViewInitializer, monitor::ContextMonitor, spinner::SpinnerSpawner, Context, Processor, @@ -155,60 +149,6 @@ fn determine_config_file(config_path: Option) -> anyhow::Result, -} - -impl StdoutRedirect { - fn for_tui(write_to_stdout: bool) -> anyhow::Result { - if !write_to_stdout || io::stdout().is_terminal() { - return Ok(Self { - #[cfg(unix)] - saved_stdout: None, - }); - } - - #[cfg(unix)] - { - let tty = File::options() - .read(true) - .write(true) - .open("/dev/tty") - .map_err(|e| anyhow!("Failed to open /dev/tty for TUI rendering: {e}"))?; - - let saved_fd = dup(stdout()).map_err(|e| anyhow!("Failed to duplicate stdout: {e}"))?; - dup2_stdout(&tty).map_err(|e| anyhow!("Failed to redirect stdout to /dev/tty: {e}"))?; - - Ok(Self { - saved_stdout: Some(saved_fd), - }) - } - - #[cfg(not(unix))] - { - Err(anyhow!( - "`--write-to-stdout` with piped stdout is not supported on this platform" - )) - } - } - - fn restore(&mut self) -> anyhow::Result<()> { - #[cfg(unix)] - if let Some(saved_stdout) = self.saved_stdout.take() { - dup2_stdout(&saved_stdout).map_err(|e| anyhow!("Failed to restore stdout: {e}"))?; - } - - Ok(()) - } -} - -impl Drop for StdoutRedirect { - fn drop(&mut self) { - let _ = self.restore(); - } -} - #[tokio::main] async fn main() -> anyhow::Result<()> { let args = Args::parse(); @@ -262,7 +202,7 @@ async fn main() -> anyhow::Result<()> { config.keybinds.on_editor.clone(), ); - let mut stdout_redirect = StdoutRedirect::for_tui(args.write_to_stdout)?; + let mut stdout_redirect = StdoutRedirect::try_new_for_tui(args.write_to_stdout)?; // TODO: put all logics here. let maybe_output = prompt::run( diff --git a/src/stdout_redirect.rs b/src/stdout_redirect.rs new file mode 100644 index 0000000..a5d9b4f --- /dev/null +++ b/src/stdout_redirect.rs @@ -0,0 +1,82 @@ +use std::{ + fs::File, + io::{self, IsTerminal}, +}; + +use anyhow::anyhow; + +#[cfg(unix)] +use rustix::{ + io::dup, + stdio::{dup2_stdout, stdout}, +}; +#[cfg(unix)] +use std::os::fd::OwnedFd; + +/// Redirects `stdout` to the controlling TTY while the TUI is running. +/// +/// This is needed when `--write-to-stdout` is used with piped stdout, e.g. +/// `cat data.json | jnv --write-to-stdout | pbcopy`. +/// During interactive rendering, cursor controls and screen output must go to +/// a terminal, not to the downstream pipe. +/// +/// Unix flow: +/// 1. Save current `fd=1` with `dup` (`saved_stdout`). +/// 2. Replace `fd=1` with `/dev/tty` via `dup2_stdout` for TUI rendering. +/// 3. Restore the original `fd=1` on exit. +/// +/// After restore, writing to `io::stdout()` again goes to the original pipe +/// (for example `pbcopy`) so the final JSON can be emitted there. +pub(crate) struct StdoutRedirect { + #[cfg(unix)] + saved_stdout: Option, +} + +impl StdoutRedirect { + pub(crate) fn try_new_for_tui(write_to_stdout: bool) -> anyhow::Result { + if !write_to_stdout || io::stdout().is_terminal() { + return Ok(Self { + #[cfg(unix)] + saved_stdout: None, + }); + } + + #[cfg(unix)] + { + let tty = File::options() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|e| anyhow!("Failed to open /dev/tty for TUI rendering: {e}"))?; + + let saved_fd = dup(stdout()).map_err(|e| anyhow!("Failed to duplicate stdout: {e}"))?; + dup2_stdout(&tty).map_err(|e| anyhow!("Failed to redirect stdout to /dev/tty: {e}"))?; + + Ok(Self { + saved_stdout: Some(saved_fd), + }) + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "`--write-to-stdout` with piped stdout is not supported on this platform" + )) + } + } + + pub(crate) fn restore(&mut self) -> anyhow::Result<()> { + #[cfg(unix)] + if let Some(saved_stdout) = self.saved_stdout.take() { + dup2_stdout(&saved_stdout).map_err(|e| anyhow!("Failed to restore stdout: {e}"))?; + } + + Ok(()) + } +} + +impl Drop for StdoutRedirect { + fn drop(&mut self) { + let _ = self.restore(); + } +} From 074eccfbd0fd592268d42c57bda11f4b11a330c3 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Feb 2026 02:21:40 +0900 Subject: [PATCH 4/6] docs: enable to write output to file --- README.md | 2 ++ src/stdout_redirect.rs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5f461be..6962146 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ jnv data.json # or write current result to stdout on exit (UNIX only) cat data.json | jnv --write-to-stdout | some-command +# and also output to file +cat data.json | jnv -- --write-to-stdout > result.json ``` ## Keymap diff --git a/src/stdout_redirect.rs b/src/stdout_redirect.rs index a5d9b4f..21575ba 100644 --- a/src/stdout_redirect.rs +++ b/src/stdout_redirect.rs @@ -27,13 +27,13 @@ use std::os::fd::OwnedFd; /// /// After restore, writing to `io::stdout()` again goes to the original pipe /// (for example `pbcopy`) so the final JSON can be emitted there. -pub(crate) struct StdoutRedirect { +pub struct StdoutRedirect { #[cfg(unix)] saved_stdout: Option, } impl StdoutRedirect { - pub(crate) fn try_new_for_tui(write_to_stdout: bool) -> anyhow::Result { + pub fn try_new_for_tui(write_to_stdout: bool) -> anyhow::Result { if !write_to_stdout || io::stdout().is_terminal() { return Ok(Self { #[cfg(unix)] @@ -65,7 +65,7 @@ impl StdoutRedirect { } } - pub(crate) fn restore(&mut self) -> anyhow::Result<()> { + pub fn restore(&mut self) -> anyhow::Result<()> { #[cfg(unix)] if let Some(saved_stdout) = self.saved_stdout.take() { dup2_stdout(&saved_stdout).map_err(|e| anyhow!("Failed to restore stdout: {e}"))?; From 522d04770e8285e6c59d945c2492898bcc10cd09 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Feb 2026 02:26:15 +0900 Subject: [PATCH 5/6] docs: what is stdout --- src/stdout_redirect.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/stdout_redirect.rs b/src/stdout_redirect.rs index 21575ba..dc0e67d 100644 --- a/src/stdout_redirect.rs +++ b/src/stdout_redirect.rs @@ -27,6 +27,13 @@ use std::os::fd::OwnedFd; /// /// After restore, writing to `io::stdout()` again goes to the original pipe /// (for example `pbcopy`) so the final JSON can be emitted there. +/// +/// Note: +/// `stdout` is file descriptor 1 (FD 1), not the screen itself. +/// Its destination is chosen by the shell when the process starts: +/// terminal (`cmd`), file (`cmd > out.txt`), or pipe (`cmd | next`). +/// Therefore, we cannot just write to `stdout` for TUI rendering when it's piped. +/// Instead, we must write directly to the terminal device (`/dev/tty` on Unix). pub struct StdoutRedirect { #[cfg(unix)] saved_stdout: Option, From 7e78520a781d52cd78471eb3e442495e41dab8ac Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 17 Mar 2026 22:45:03 +0900 Subject: [PATCH 6/6] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6962146..e6a0226 100644 --- a/README.md +++ b/README.md @@ -396,9 +396,9 @@ on_completion.down = ["Down", "Tab"] # Keybindings for JSON viewer operations [keybinds.on_json_viewer] # Move up in JSON viewer -up = ["Up", "Ctrl+K"] +up = ["Up", "Ctrl+K", "ScrollUp"] # Move down in JSON viewer -down = ["Down", "Ctrl+J"] +down = ["Down", "Ctrl+J", "ScrollDown"] # Move to the top of JSON viewer move_to_head = ["Ctrl+L"] # Move to the bottom of JSON viewer