From 66a141931fe1c40ec1ae8816b9e9c2a9527fdc1f Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Mon, 20 Jul 2026 08:11:38 +0700 Subject: [PATCH] feat(tmux): add compact session and pane output --- README.md | 2 + src/cmds/README.md | 2 +- src/cmds/system/README.md | 1 + src/cmds/system/tmux_cmd.rs | 260 ++++++++++++++++++++++++++++++++++++ src/discover/registry.rs | 33 +++++ src/discover/rules.rs | 15 +++ src/main.rs | 35 ++++- 7 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 src/cmds/system/tmux_cmd.rs diff --git a/README.md b/README.md index e019e80e82..3a7a16b1c4 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,8 @@ rtk env -f AWS # Filtered env vars rtk log app.log # Deduplicated logs rtk curl # Truncate + save full output rtk wget # Download, strip progress bars +rtk tmux list-sessions # Compact session state +rtk tmux capture-pane -p -t dev # Trim and cap captured pane output rtk summary # Heuristic summary rtk proxy # Raw passthrough + tracking ``` diff --git a/src/cmds/README.md b/src/cmds/README.md index 31a8e339bc..bd32060073 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -32,7 +32,7 @@ Each subdirectory has its own README with file descriptions, parsing strategies, - **[`go/`](go/README.md)** — go test/build/vet, golangci-lint — NDJSON streaming, Go sub-enum pattern - **[`dotnet/`](dotnet/README.md)** — dotnet, binlog, trx, format_report — DotnetCommands sub-enum, internal helper modules - **[`cloud/`](cloud/README.md)** — aws, docker/kubectl, curl, wget, psql — Docker/Kubectl sub-enums, JSON forced output -- **[`system/`](system/README.md)** — ls, tree, read, grep, find, wc, env, json, log, deps, summary, format, smart — format_cmd routing, filter levels, language detection +- **[`system/`](system/README.md)** — ls, tree, read, grep, find, wc, tmux, env, json, log, deps, summary, format, smart — format_cmd routing, filter levels, language detection - **[`ruby/`](ruby/README.md)** — rake/rails test, rspec, rubocop — JSON injection pattern, `ruby_exec()` bundle exec auto-detection ## Execution Flow diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index ae475befbd..c967721ed1 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -8,6 +8,7 @@ - `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw. - `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization - `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module) +- `tmux_cmd.rs` compacts standard session listings and caps printed pane captures; custom formats and state-changing commands pass through unchanged ## Cross-command diff --git a/src/cmds/system/tmux_cmd.rs b/src/cmds/system/tmux_cmd.rs new file mode 100644 index 0000000000..0f18592d9e --- /dev/null +++ b/src/cmds/system/tmux_cmd.rs @@ -0,0 +1,260 @@ +//! Compact output for common tmux automation commands. + +use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_INVENTORY; +use crate::core::utils::resolved_command; +use anyhow::Result; +use std::ffi::OsString; + +const GLOBAL_OPTIONS_WITH_VALUE: &[&str] = &["-c", "-f", "-L", "-S", "-T"]; + +pub fn run(args: &[String], verbose: u8) -> Result { + let Some((subcommand_index, subcommand)) = find_subcommand(args) else { + return run_passthrough(args, verbose); + }; + + match subcommand { + "list-sessions" | "ls" + if !has_custom_format(&args[subcommand_index.saturating_add(1)..]) => + { + let mut cmd = resolved_command("tmux"); + cmd.args(args); + if verbose > 0 { + eprintln!("Running: tmux {}", args.join(" ")); + } + runner::run_filtered( + cmd, + "tmux", + &args.join(" "), + filter_list_sessions, + RunOptions::default().early_exit_on_failure(), + ) + } + "capture-pane" | "capturep" if capture_prints_stdout(&args[subcommand_index + 1..]) => { + let mut cmd = resolved_command("tmux"); + cmd.args(args); + if verbose > 0 { + eprintln!("Running: tmux {}", args.join(" ")); + } + runner::run_filtered( + cmd, + "tmux", + &args.join(" "), + filter_capture_pane, + RunOptions::default().early_exit_on_failure(), + ) + } + _ => run_passthrough(args, verbose), + } +} + +fn run_passthrough(args: &[String], verbose: u8) -> Result { + let args: Vec = args.iter().map(OsString::from).collect(); + runner::run_passthrough("tmux", &args, verbose) +} + +fn find_subcommand(args: &[String]) -> Option<(usize, &str)> { + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if GLOBAL_OPTIONS_WITH_VALUE.contains(&arg) { + index += 2; + continue; + } + if GLOBAL_OPTIONS_WITH_VALUE + .iter() + .any(|option| arg.starts_with(option) && arg.len() > option.len()) + { + index += 1; + continue; + } + if arg == "--" { + index += 1; + return args.get(index).map(|command| (index, command.as_str())); + } + if arg.starts_with('-') { + index += 1; + continue; + } + return Some((index, arg)); + } + None +} + +fn has_custom_format(args: &[String]) -> bool { + args.iter().any(|arg| { + arg == "-F" + || arg == "--format" + || arg.starts_with("-F") + || arg.starts_with("--format=") + }) +} + +fn capture_prints_stdout(args: &[String]) -> bool { + args.iter().any(|arg| { + arg == "-p" + || (arg.starts_with('-') + && !arg.starts_with("--") + && arg.chars().skip(1).any(|flag| flag == 'p')) + }) +} + +fn filter_list_sessions(raw: &str) -> String { + let lines: Vec<&str> = raw.lines().filter(|line| !line.trim().is_empty()).collect(); + if lines.is_empty() { + return String::new(); + } + + let compact: Option> = lines + .iter() + .map(|line| compact_session_line(line)) + .collect(); + compact + .map(|lines| lines.join("\n")) + .unwrap_or_else(|| raw.trim_end_matches(['\r', '\n']).to_string()) +} + +fn compact_session_line(line: &str) -> Option { + let (name, details) = line.split_once(": ")?; + let (window_count, remainder) = details.split_once(' ')?; + window_count.parse::().ok()?; + if !(remainder.starts_with("window (created ") + || remainder.starts_with("windows (created ")) + { + return None; + } + + let attached = details.ends_with(" (attached)"); + Some(format!( + "{} {}w{}", + name, + window_count, + if attached { " attached" } else { "" } + )) +} + +fn filter_capture_pane(raw: &str) -> String { + let hint = (visible_capture_line_count(raw) > CAP_INVENTORY) + .then(|| crate::core::tee::force_tee_hint(raw, "tmux_capture_pane")) + .flatten(); + compact_capture_pane(raw, hint.as_deref()) +} + +fn visible_capture_line_count(raw: &str) -> usize { + let lines: Vec<&str> = raw.lines().collect(); + let Some(start) = lines.iter().position(|line| !line.trim().is_empty()) else { + return 0; + }; + let end = lines + .iter() + .rposition(|line| !line.trim().is_empty()) + .unwrap_or(start); + end - start + 1 +} + +fn compact_capture_pane(raw: &str, recovery_hint: Option<&str>) -> String { + let lines: Vec<&str> = raw.lines().collect(); + let Some(start) = lines.iter().position(|line| !line.trim().is_empty()) else { + return String::new(); + }; + let end = lines + .iter() + .rposition(|line| !line.trim().is_empty()) + .unwrap_or(start); + let visible = &lines[start..=end]; + + if visible.len() <= CAP_INVENTORY { + return visible.join("\n"); + } + + let head_count = CAP_INVENTORY / 2; + let tail_count = CAP_INVENTORY - head_count; + let omitted = visible.len() - CAP_INVENTORY; + let mut output = Vec::with_capacity(CAP_INVENTORY + 2); + output.extend(visible.iter().take(head_count).copied()); + let marker = format!("... {omitted} lines omitted ..."); + output.push(&marker); + output.extend(visible.iter().skip(visible.len() - tail_count).copied()); + + let mut output = output.join("\n"); + if let Some(hint) = recovery_hint { + output.push('\n'); + output.push_str(hint); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_subcommand_after_global_options() { + let args = vec![ + "-L".to_string(), + "agent".to_string(), + "-fconfig.conf".to_string(), + "list-sessions".to_string(), + ]; + assert_eq!(find_subcommand(&args), Some((3, "list-sessions"))); + } + + #[test] + fn compacts_standard_session_rows() { + let raw = concat!( + "build: 2 windows (created Sun Jul 19 10:00:00 2026) (attached)\n", + "worker: 1 window (created Sun Jul 19 11:00:00 2026)\n" + ); + assert_eq!(filter_list_sessions(raw), "build 2w attached\nworker 1w"); + } + + #[test] + fn preserves_unknown_session_formats() { + let raw = "build|2|attached\n"; + assert_eq!(filter_list_sessions(raw), raw.trim()); + } + + #[test] + fn detects_custom_list_format() { + assert!(has_custom_format(&[ + "-F".into(), + "#{session_name}".into() + ])); + assert!(has_custom_format(&["--format=#{session_name}".into()])); + assert!(!has_custom_format(&["-t".into(), "build".into()])); + } + + #[test] + fn capture_requires_print_flag() { + assert!(capture_prints_stdout(&[ + "-ep".into(), + "-t".into(), + "build".into() + ])); + assert!(!capture_prints_stdout(&["-t".into(), "build".into()])); + } + + #[test] + fn capture_trims_padding_and_preserves_short_output() { + assert_eq!(visible_capture_line_count("\n\nline 1\nline 2\n\n"), 2); + assert_eq!( + compact_capture_pane("\n\nline 1\nline 2\n\n", None), + "line 1\nline 2" + ); + } + + #[test] + fn capture_caps_output_with_head_tail_and_recovery() { + let raw = (1..=60) + .map(|line| format!("line {line}")) + .collect::>() + .join("\n"); + let filtered = compact_capture_pane(&raw, Some("[full output: /tmp/tmux.log]")); + + assert!(filtered.starts_with("line 1\n")); + assert!(filtered.contains("... 10 lines omitted ...")); + assert!(filtered.contains("\nline 60\n")); + assert!(filtered.ends_with("[full output: /tmp/tmux.log]")); + assert!(!filtered.contains("line 30\n")); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 81b170785a..0d8de88d54 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -4357,6 +4357,39 @@ mod tests { ); } + // --- #2315: tmux proxy and output filtering --- + + #[test] + fn test_classify_tmux_commands() { + for command in [ + "tmux list-sessions", + "tmux capture-pane -p -t build", + "tmux new-session -d -s worker", + "tmux kill-session -t worker", + "tmux -L agent list-sessions", + ] { + assert!(matches!( + classify_command(command), + Classification::Supported { + rtk_equivalent: "rtk tmux", + .. + } + )); + } + } + + #[test] + fn test_rewrite_tmux_commands() { + assert_eq!( + rewrite_command_no_prefixes("tmux list-sessions", &[]), + Some("rtk tmux list-sessions".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("tmux -L agent capture-pane -p -t build", &[]), + Some("rtk tmux -L agent capture-pane -p -t build".into()) + ); + } + #[test] fn test_classify_command_substitution_passthrough() { assert_eq!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 49c0ff740a..6dca312ecc 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -143,6 +143,21 @@ pub const RULES: &[RtkRule] = &[ savings_pct: 70.0, ..RtkRule::DEFAULT }, + RtkRule { + pattern: r"^tmux\s+(?:(?:-(?:[cLfST])(?:\s+\S+|[^\s]+)|-[2CDluvV]+)\s+)*(list-sessions|ls|new-session|new|kill-session|kill|capture-pane|capturep)(?:\s|$)", + rtk_cmd: "rtk tmux", + rewrite_prefixes: &["tmux"], + category: "Infra", + savings_pct: 65.0, + subcmd_savings: &[("capture-pane", 80.0), ("capturep", 80.0)], + subcmd_status: &[ + ("new-session", RtkStatus::Passthrough), + ("new", RtkStatus::Passthrough), + ("kill-session", RtkStatus::Passthrough), + ("kill", RtkStatus::Passthrough), + ], + pipeline_final_safe: false, + }, RtkRule { pattern: r"^((p?np(m|x)|p?npm\s+(exec|run|run-script)|npm\s+(rum|urn|x)|pnpm\s+dlx)\s+)?tsc(\s|$)", rtk_cmd: "rtk tsc", diff --git a/src/main.rs b/src/main.rs index d1e0269f5a..8e96de6ad9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use cmds::rust::{cargo_cmd, runner}; use cmds::scala::sbt_cmd; use cmds::system::{ deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, read, search, - summary, tree, wc_cmd, + summary, tmux_cmd, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -416,6 +416,13 @@ enum Commands { args: Vec, }, + /// tmux commands with compact session and pane output + Tmux { + /// Arguments passed to tmux + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show token savings summary and history Gain { /// Filter statistics to current project (current working directory) // added @@ -2124,6 +2131,8 @@ fn run_cli() -> Result { Commands::Wc { args } => wc_cmd::run(&args, cli.verbose)?, + Commands::Tmux { args } => tmux_cmd::run(&args, cli.verbose)?, + Commands::Gain { project, // added graph, @@ -2749,6 +2758,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Npm { .. } | Commands::Npx { .. } | Commands::Curl { .. } + | Commands::Tmux { .. } | Commands::Ruff { .. } | Commands::Pytest { .. } | Commands::Php { .. } @@ -3119,6 +3129,7 @@ mod tests { "grep", "wget", "wc", + "tmux", "jest", "vitest", "prisma", @@ -3600,6 +3611,28 @@ mod tests { } } + #[test] + fn test_tmux_capture_pane_parses_native_flags() { + let cli = Cli::try_parse_from([ + "rtk", + "tmux", + "capture-pane", + "-p", + "-S", + "-200", + "-t", + "build", + ]) + .unwrap(); + match cli.command { + Commands::Tmux { args } => assert_eq!( + args, + vec!["capture-pane", "-p", "-S", "-200", "-t", "build"] + ), + _ => panic!("Expected Tmux command"), + } + } + #[test] fn test_init_uninstall_agent_pi_parses() { let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--agent", "pi", "--global"])