Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ versions (0.x) may contain breaking changes; they are always listed under a

### Fixed

- **`oxide tui` now rejects non-interactive stdin/stdout before terminal setup.**
It prints a clear hint to use `oxide sql` or `oxide explain` for scripted output
and exits with usage code 2 instead of leaking a raw ENXIO-style terminal error.

- **The dashboard's Describe panel no longer prints a truncated percentile as
if it were the value.** `approx_percentile_cont` renders full precision, and
the panel's six-character columns were clipped from the right, so the 2M-row
Expand Down
4 changes: 4 additions & 0 deletions crates/oxidelake-runtime/src/bin/oxide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ async fn main() -> anyhow::Result<()> {
tables,
target,
} => {
if !oxidelake_tui::is_interactive_terminal() {
eprintln!("{}", oxidelake_tui::NON_INTERACTIVE_TERMINAL_MESSAGE);
std::process::exit(2);
}
let model = match &query {
Some(sql) => {
let session = session(None, target, &tables).await?;
Expand Down
9 changes: 9 additions & 0 deletions crates/oxidelake-runtime/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ fn oxide() -> Command {
Command::cargo_bin("oxide").unwrap()
}

#[test]
fn tui_rejects_non_interactive_terminal() {
oxide()
.arg("tui")
.assert()
.code(2)
.stderr("oxide tui needs an interactive terminal; use oxide sql or oxide explain for non-interactive output\n");
}

fn gen_data(dir: &Path, rows: u64) {
let assert = oxide()
.args([
Expand Down
16 changes: 15 additions & 1 deletion crates/oxidelake-tui/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The real-terminal event loop (crossterm) with DEC 2026 synchronized updates.

use std::io::{self, Write};
use std::io::{self, IsTerminal, Write};
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
Expand All @@ -12,6 +12,14 @@ use crate::model::DashboardModel;
use crate::render::render;
use crate::state::{AppState, KeyInput, Transition};

/// Message shown when the dashboard is started without an interactive terminal.
pub const NON_INTERACTIVE_TERMINAL_MESSAGE: &str = "oxide tui needs an interactive terminal; use oxide sql or oxide explain for non-interactive output";

/// Whether both input and output are attached to an interactive terminal.
pub fn is_interactive_terminal() -> bool {
io::stdin().is_terminal() && io::stdout().is_terminal()
}

/// Maps a crossterm key event onto the dashboard's input alphabet. Key
/// releases (reported by some terminals) are ignored.
pub fn key_input_from_crossterm(key: &KeyEvent) -> Option<KeyInput> {
Expand Down Expand Up @@ -52,6 +60,12 @@ pub fn run_terminal(
tick: Option<Duration>,
mut refresh: impl FnMut(&mut DashboardModel),
) -> io::Result<()> {
if !is_interactive_terminal() {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
NON_INTERACTIVE_TERMINAL_MESSAGE,
));
}
let mut terminal = ratatui::try_init()?;
let mut state = AppState::new(model.plan().len());
let result = (|| -> io::Result<()> {
Expand Down
5 changes: 4 additions & 1 deletion crates/oxidelake-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ pub mod model;
pub mod render;
pub mod state;

pub use app::{draw_frame, key_input_from_crossterm, run_terminal};
pub use app::{
NON_INTERACTIVE_TERMINAL_MESSAGE, draw_frame, is_interactive_terminal,
key_input_from_crossterm, run_terminal,
};
pub use model::{ColumnProfile, DashboardModel, demo_model};
pub use render::render;
pub use state::{AppState, KeyInput, Panel, Transition};
Loading