diff --git a/crates/turborepo-lib/src/config/env.rs b/crates/turborepo-lib/src/config/env.rs index 5f4ae854995fa..160c6ba066251 100644 --- a/crates/turborepo-lib/src/config/env.rs +++ b/crates/turborepo-lib/src/config/env.rs @@ -42,6 +42,7 @@ const TURBO_MAPPING: &[(&str, &str)] = [ ("turbo_run_summary", "run_summary"), ("turbo_allow_no_turbo_json", "allow_no_turbo_json"), ("turbo_cache", "cache"), + ("turbo_tui_scrollback_length", "tui_scrollback_length"), ] .as_slice(); @@ -148,7 +149,15 @@ impl ResolvedConfigurationOptions for EnvVars { .transpose() .map_err(Error::InvalidUploadTimeout)?; - // Process experimentalUI + let tui_scrollback_length = self + .output_map + .get("tui_scrollback_length") + .filter(|s| !s.is_empty()) + .map(|s| s.parse()) + .transpose() + .map_err(Error::InvalidTuiScrollbackLength)?; + + // Process ui let ui = self.truthy_value("ui") .flatten() @@ -218,6 +227,8 @@ impl ResolvedConfigurationOptions for EnvVars { // Processed numbers timeout, upload_timeout, + tui_scrollback_length, + env_mode, cache_dir, root_turbo_json_path, @@ -268,7 +279,7 @@ mod test { use super::*; use crate::{ cli::LogOrder, - config::{DEFAULT_API_URL, DEFAULT_LOGIN_URL}, + config::{DEFAULT_API_URL, DEFAULT_LOGIN_URL, DEFAULT_TUI_SCROLLBACK_LENGTH}, }; #[test] @@ -314,6 +325,7 @@ mod test { env.insert("turbo_run_summary".into(), "true".into()); env.insert("turbo_allow_no_turbo_json".into(), "true".into()); env.insert("turbo_remote_cache_upload_timeout".into(), "200".into()); + env.insert("turbo_tui_scrollback_length".into(), "2048".into()); let config = EnvVars::new(&env) .unwrap() @@ -365,6 +377,7 @@ mod test { env.insert("turbo_remote_cache_read_only".into(), "".into()); env.insert("turbo_run_summary".into(), "".into()); env.insert("turbo_allow_no_turbo_json".into(), "".into()); + env.insert("turbo_tui_scrollback_length".into(), "".into()); let config = EnvVars::new(&env) .unwrap() @@ -388,5 +401,9 @@ mod test { assert!(!config.remote_cache_read_only()); assert!(!config.run_summary()); assert!(!config.allow_no_turbo_json()); + assert_eq!( + config.tui_scrollback_length(), + DEFAULT_TUI_SCROLLBACK_LENGTH + ); } } diff --git a/crates/turborepo-lib/src/config/mod.rs b/crates/turborepo-lib/src/config/mod.rs index e21647b62ed81..d32c408affbe8 100644 --- a/crates/turborepo-lib/src/config/mod.rs +++ b/crates/turborepo-lib/src/config/mod.rs @@ -225,12 +225,18 @@ pub enum Error { #[source_code] text: NamedSource, }, + #[error( + "TURBO_TUI_SCROLLBACK_LENGTH: Invalid value. Use a number for how many lines to keep in \ + scrollback." + )] + InvalidTuiScrollbackLength(#[source] std::num::ParseIntError), } const DEFAULT_API_URL: &str = "https://vercel.com/api"; const DEFAULT_LOGIN_URL: &str = "https://vercel.com"; const DEFAULT_TIMEOUT: u64 = 30; const DEFAULT_UPLOAD_TIMEOUT: u64 = 60; +const DEFAULT_TUI_SCROLLBACK_LENGTH: u64 = 2048; // We intentionally don't derive Serialize so that different parts // of the code that want to display the config can tune how they @@ -290,6 +296,7 @@ pub struct ConfigurationOptions { pub(crate) remote_cache_read_only: Option, pub(crate) run_summary: Option, pub(crate) allow_no_turbo_json: Option, + pub(crate) tui_scrollback_length: Option, } #[derive(Default)] @@ -346,6 +353,11 @@ impl ConfigurationOptions { self.upload_timeout.unwrap_or(DEFAULT_UPLOAD_TIMEOUT) } + pub fn tui_scrollback_length(&self) -> u64 { + self.tui_scrollback_length + .unwrap_or(DEFAULT_TUI_SCROLLBACK_LENGTH) + } + pub fn ui(&self) -> UIMode { // If we aren't hooked up to a TTY, then do not use TUI if !atty::is(atty::Stream::Stdout) { diff --git a/crates/turborepo-lib/src/opts.rs b/crates/turborepo-lib/src/opts.rs index 9c098802e5a60..20e4a99250e6f 100644 --- a/crates/turborepo-lib/src/opts.rs +++ b/crates/turborepo-lib/src/opts.rs @@ -75,6 +75,7 @@ pub struct Opts { pub run_opts: RunOpts, pub runcache_opts: RunCacheOpts, pub scope_opts: ScopeOpts, + pub tui_opts: TuiOpts, } impl Opts { @@ -177,6 +178,7 @@ impl Opts { let runcache_opts = RunCacheOpts::from(inputs); let api_client_opts = APIClientOpts::from(inputs); let repo_opts = RepoOpts::from(inputs); + let tui_opts = TuiOpts::from(inputs); Ok(Self { repo_opts, @@ -185,6 +187,7 @@ impl Opts { scope_opts, runcache_opts, api_client_opts, + tui_opts, }) } } @@ -539,6 +542,19 @@ impl ScopeOpts { } } +#[derive(Clone, Debug, Serialize)] +pub struct TuiOpts { + pub(crate) scrollback_length: u64, +} + +impl<'a> From> for TuiOpts { + fn from(inputs: OptsInputs) -> Self { + TuiOpts { + scrollback_length: inputs.config.tui_scrollback_length(), + } + } +} + #[cfg(test)] mod test { use clap::Parser; @@ -555,7 +571,7 @@ mod test { cli::{Command, ContinueMode, DryRunMode, RunArgs}, commands::CommandBase, config::ConfigurationOptions, - opts::{Opts, RunCacheOpts, ScopeOpts}, + opts::{Opts, RunCacheOpts, ScopeOpts, TuiOpts}, run::task_id::TaskId, turbo_json::{UIMode, CONFIG_FILE}, Args, @@ -702,6 +718,10 @@ mod test { .root_turbo_json_path(&AbsoluteSystemPathBuf::default()) .unwrap_or_else(|_| AbsoluteSystemPathBuf::default().join_component(CONFIG_FILE)); + let tui_opts = TuiOpts { + scrollback_length: 2048, + }; + let opts = Opts { repo_opts: RepoOpts { root_turbo_json_path, @@ -722,6 +742,7 @@ mod test { run_opts, cache_opts, runcache_opts, + tui_opts, }; let synthesized = opts.synthesize_command(); assert_eq!(synthesized, expected); diff --git a/crates/turborepo-lib/src/run/mod.rs b/crates/turborepo-lib/src/run/mod.rs index ba1fd74533323..77f18f1a3054a 100644 --- a/crates/turborepo-lib/src/run/mod.rs +++ b/crates/turborepo-lib/src/run/mod.rs @@ -268,9 +268,17 @@ impl Run { let (sender, receiver) = TuiSender::new(); let color_config = self.color_config; + let scrollback_len = self.opts.tui_opts.scrollback_length; let repo_root = self.repo_root.clone(); let handle = tokio::task::spawn(async move { - Ok(tui::run_app(task_names, receiver, color_config, &repo_root).await?) + Ok(tui::run_app( + task_names, + receiver, + color_config, + &repo_root, + scrollback_len, + ) + .await?) }); Ok(Some((sender, handle))) diff --git a/crates/turborepo-ui/Cargo.toml b/crates/turborepo-ui/Cargo.toml index 9d9bdc5b659ea..049512a3fc9cc 100644 --- a/crates/turborepo-ui/Cargo.toml +++ b/crates/turborepo-ui/Cargo.toml @@ -28,7 +28,7 @@ crossterm = { version = "0.27.0", features = ["event-stream"] } dialoguer = { workspace = true } futures = { workspace = true } indicatif = { workspace = true } -itertools.workspace = true +itertools = { workspace = true } lazy_static = { workspace = true } nix = { version = "0.26.2", features = ["signal"] } ratatui = { workspace = true } diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 816c75c771da9..6a8c7402c4781 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -59,10 +59,17 @@ pub struct App { showing_help_popup: bool, done: bool, preferences: PreferenceLoader, + scrollback_len: u64, } impl App { - pub fn new(rows: u16, cols: u16, tasks: Vec, preferences: PreferenceLoader) -> Self { + pub fn new( + rows: u16, + cols: u16, + tasks: Vec, + preferences: PreferenceLoader, + scrollback_len: u64, + ) -> Self { debug!("tasks: {tasks:?}"); let size = SizeInfo::new(rows, cols, tasks.iter().map(|s| s.as_str())); @@ -97,7 +104,7 @@ impl App { .map(|task_name| { ( task_name.to_owned(), - TerminalOutput::new(pane_rows, pane_cols, None), + TerminalOutput::new(pane_rows, pane_cols, None, scrollback_len), ) }) .collect(), @@ -107,6 +114,7 @@ impl App { showing_help_popup: false, is_task_selection_pinned: preferences.active_task().is_some(), preferences, + scrollback_len, } } @@ -404,7 +412,12 @@ impl App { // Make sure all tasks have a terminal output for task in &tasks { self.tasks.entry(task.clone()).or_insert_with(|| { - TerminalOutput::new(self.size.pane_rows(), self.size.pane_cols(), None) + TerminalOutput::new( + self.size.pane_rows(), + self.size.pane_cols(), + None, + self.scrollback_len, + ) }); } // Trim the terminal output to only tasks that exist in new list @@ -440,7 +453,12 @@ impl App { // Make sure all tasks have a terminal output for task in &tasks { self.tasks.entry(task.clone()).or_insert_with(|| { - TerminalOutput::new(self.size.pane_rows(), self.size.pane_cols(), None) + TerminalOutput::new( + self.size.pane_rows(), + self.size.pane_cols(), + None, + self.scrollback_len, + ) }); } @@ -604,13 +622,14 @@ pub async fn run_app( receiver: AppReceiver, color_config: ColorConfig, repo_root: &AbsoluteSystemPathBuf, + scrollback_len: u64, ) -> Result<(), Error> { let mut terminal = startup(color_config)?; let size = terminal.size()?; let preferences = PreferenceLoader::new(repo_root); let mut app: App> = - App::new(size.height, size.width, tasks, preferences); + App::new(size.height, size.width, tasks, preferences, scrollback_len); let (crossterm_tx, crossterm_rx) = mpsc::channel(1024); input::start_crossterm_stream(crossterm_tx); @@ -934,6 +953,7 @@ mod test { 100, vec!["foo".to_string(), "bar".to_string(), "baz".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); assert_eq!( app.task_list_scroll.selected(), @@ -981,6 +1001,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); assert_eq!(app.task_list_scroll.selected(), Some(1), "selected b"); @@ -1008,6 +1029,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); app.next(); @@ -1079,6 +1101,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); app.next(); @@ -1130,6 +1153,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); assert_eq!(app.task_list_scroll.selected(), Some(1), "selected b"); @@ -1172,6 +1196,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); assert!(!app.is_focusing_pane(), "app starts focused on table"); app.insert_stdin("a", Some(Vec::new()))?; @@ -1203,6 +1228,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); assert_eq!(app.task_list_scroll.selected(), Some(1), "selected b"); @@ -1229,6 +1255,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); assert_eq!(app.task_list_scroll.selected(), Some(0), "selected a"); assert_eq!(app.tasks_by_status.task_name(0)?, "a", "selected a"); @@ -1264,6 +1291,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); assert_eq!(app.task_list_scroll.selected(), Some(1), "selected b"); @@ -1300,6 +1328,7 @@ mod test { 24, vec!["a".to_string(), "b".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); let pane_rows = app.size.pane_rows(); let pane_cols = app.size.pane_cols(); @@ -1340,6 +1369,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); app.update_tasks(Vec::new())?; @@ -1359,6 +1389,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); app.restart_tasks(vec!["d".to_string()])?; @@ -1380,6 +1411,7 @@ mod test { 100, vec!["a".to_string(), "b".to_string(), "c".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.enter_search()?; assert!(matches!(app.section_focus, LayoutSections::Search { .. })); @@ -1406,6 +1438,7 @@ mod test { 100, vec!["a".to_string(), "ab".to_string(), "abc".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.enter_search()?; app.search_enter_char('a')?; @@ -1434,6 +1467,7 @@ mod test { 100, vec!["a".to_string(), "ab".to_string(), "abc".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.enter_search()?; app.search_enter_char('b')?; @@ -1466,6 +1500,7 @@ mod test { 100, vec!["a".to_string(), "abc".to_string(), "b".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); assert_eq!(app.active_task()?, "abc"); @@ -1490,6 +1525,7 @@ mod test { 100, vec!["a".to_string(), "abc".to_string(), "b".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.next(); assert_eq!(app.active_task()?, "abc"); @@ -1514,6 +1550,7 @@ mod test { 100, vec!["a".to_string(), "ab".to_string(), "abc".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.enter_search()?; app.search_enter_char('b')?; @@ -1535,6 +1572,7 @@ mod test { 100, vec!["a".to_string(), "ab".to_string(), "abc".to_string()], PreferenceLoader::new(&repo_root), + 2048, ); app.enter_search()?; app.search_enter_char('b')?; diff --git a/crates/turborepo-ui/src/tui/pane.rs b/crates/turborepo-ui/src/tui/pane.rs index 74b1b94949e0f..0fcf42ad04bd2 100644 --- a/crates/turborepo-ui/src/tui/pane.rs +++ b/crates/turborepo-ui/src/tui/pane.rs @@ -100,7 +100,7 @@ mod test { #[test] fn test_footer_interactive() { - let term: TerminalOutput> = TerminalOutput::new(16, 16, Some(Vec::new())); + let term: TerminalOutput> = TerminalOutput::new(16, 16, Some(Vec::new()), 2048); let pane = TerminalPane::new(&term, "foo", &LayoutSections::TaskList, true); assert_eq!( String::from(pane.footer()), @@ -110,7 +110,7 @@ mod test { #[test] fn test_footer_non_interactive() { - let term: TerminalOutput> = TerminalOutput::new(16, 16, None); + let term: TerminalOutput> = TerminalOutput::new(16, 16, None, 2048); let pane = TerminalPane::new(&term, "foo", &LayoutSections::TaskList, true); assert_eq!(String::from(pane.footer()), " u/d - Scroll logs"); } diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index aa63fbcd67ef3..fb7556551b501 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -7,8 +7,6 @@ use super::{ Error, }; -const SCROLLBACK_LEN: usize = 2048; - pub struct TerminalOutput { output: Vec, pub parser: vt100::Parser, @@ -17,6 +15,7 @@ pub struct TerminalOutput { pub output_logs: Option, pub task_result: Option, pub cache_result: Option, + pub scrollback_len: u64, } #[derive(Debug, Clone, Copy)] @@ -27,15 +26,16 @@ enum LogBehavior { } impl TerminalOutput { - pub fn new(rows: u16, cols: u16, stdin: Option) -> Self { + pub fn new(rows: u16, cols: u16, stdin: Option, scrollback_len: u64) -> Self { Self { output: Vec::new(), - parser: vt100::Parser::new(rows, cols, SCROLLBACK_LEN), + parser: vt100::Parser::new(rows, cols, scrollback_len as usize), stdin, status: None, output_logs: None, task_result: None, cache_result: None, + scrollback_len, } } @@ -58,7 +58,8 @@ impl TerminalOutput { pub fn resize(&mut self, rows: u16, cols: u16) { if self.parser.screen().size() != (rows, cols) { let scrollback = self.parser.screen().scrollback(); - let mut new_parser = vt100::Parser::new(rows, cols, SCROLLBACK_LEN); + let scrollback_len = self.scrollback_len as usize; + let mut new_parser = vt100::Parser::new(rows, cols, scrollback_len); new_parser.process(&self.output); new_parser.screen_mut().set_scrollback(scrollback); // Completely swap out the old vterm with a new correctly sized one