From 5935b518aed267b586639d19c2ab68cb968ce41b Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Tue, 7 Jul 2026 16:45:38 -0700 Subject: [PATCH 1/2] feat: integrate dedicated command bar Refs #68 Signed-off-by: Jason Matthew Suhari --- README.md | 7 +- .../2026-07-07-dedicated-command-bar.md | 29 + src/app.rs | 609 +++++++++++++++++- src/ui.rs | 175 ++++- 4 files changed, 787 insertions(+), 33 deletions(-) create mode 100644 docs/devlogs/2026-07-07-dedicated-command-bar.md diff --git a/README.md b/README.md index c0e9c70..c77e8f8 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ GridBash captures drag selection so selected text stays inside the pane where th | Input | Action | | --- | --- | | Drag mouse | Select/copy terminal text within the source pane | -| Alt+Left / Alt+Right | Focus previous / next pane | -| Alt+Up / Alt+Down | Focus pane above / below | +| Alt+Left / Alt+Right | Focus previous / next pane or command line | +| Alt+Up / Alt+Down | Focus pane above / below, or move to/from the command line | | Alt+Shift+Up / Alt+Shift+Down | Remove / add a row when safe | | Alt+Shift+Left / Alt+Shift+Right | Remove / add a column when safe | | Alt+s | Toggle focused pane selection | @@ -175,10 +175,11 @@ GridBash captures drag selection so selected text stays inside the pane where th | Alt+r | Rename the focused pane | | Alt+z | Put the focused pane to sleep; when multiple panes are selected, sleep the selected panes | | Hover sleeping pane | Wake the pane and make its terminal contents visible again | +| Alt+e | Expand or hide command output | | Alt+o | Open sample settings | | Alt+q | Quit | -Typing goes to selected panes whenever multiple panes are selected. With zero or one pane selected, input goes to the focused pane. +Typing goes to selected panes whenever multiple panes are selected. With zero or one pane selected, input goes to the focused pane. When the one-line command bar is focused, typing stays in that bar; Enter runs the command from the cwd shown in the prompt and keeps output hidden until expanded. Renamed pane headers replace the numeric prefix for the current session. Saving a blank name restores the default number. diff --git a/docs/devlogs/2026-07-07-dedicated-command-bar.md b/docs/devlogs/2026-07-07-dedicated-command-bar.md new file mode 100644 index 0000000..72cb7d1 --- /dev/null +++ b/docs/devlogs/2026-07-07-dedicated-command-bar.md @@ -0,0 +1,29 @@ +# Dedicated command bar + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Added a dedicated one-line command bar above the GridBash status footer. + +## What Changed + +- The live view now reserves a command prompt line that starts in the directory where GridBash was launched. +- Alt-arrow focus navigation can move into and out of the command line alongside panes. +- Commands run through the host shell with output captured into a hidden buffer by default. +- `Alt+e` toggles the captured command output panel, leaving `Alt+x` for pane swapping. +- Built-in `cd`, `pwd`, `clear`, and `cls` behavior keeps the command cwd useful without requiring a persistent shell. +- The command line was ported onto the current grid behavior without regressing pane sleep, swap, mouse wake, usage labels, runtime resize, or worktree labels. + +## Why It Matters + +- Users can run quick workspace commands without stealing input from a live agent pane or dedicating a full pane to command output. + +## Validation + +- `npm test` + +## Release Notes + +- Adds a focused command bar with hidden output capture and Alt-key output expansion. diff --git a/src/app.rs b/src/app.rs index d141963..78b0499 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,12 @@ use std::{ collections::{BTreeMap, BTreeSet}, env, + ffi::OsString, io::{self, Stdout, Write}, + path::{Path, PathBuf}, + process::Command, sync::mpsc as std_mpsc, + thread, time::{Duration, Instant}, }; @@ -38,6 +42,7 @@ pub type Tui = Terminal>; const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(16); const MAX_PANE_NAME_CHARS: usize = 32; const CONVERSATION_SUMMARY_MAX_CHARS: usize = 120; +const COMMAND_OUTPUT_MAX_LINES: usize = 2000; pub struct App { config: Config, @@ -53,6 +58,9 @@ pub struct App { sleeping: BTreeSet, rects: Vec, mouse_enabled: bool, + command_line: CommandLineState, + command_tx: mpsc::UnboundedSender, + command_rx: mpsc::UnboundedReceiver, settings: SettingsState, rename: RenamePaneState, status: String, @@ -261,6 +269,143 @@ struct SettingsState { accent_index: usize, } +#[derive(Debug, Clone)] +struct CommandLineState { + focused: bool, + cwd: PathBuf, + input: String, + cursor: usize, + output_lines: Vec, + output_expanded: bool, + running: bool, +} + +#[derive(Debug, Clone)] +struct CommandRunEvent { + command: String, + stdout: String, + stderr: String, + exit_code: Option, + error: Option, +} + +impl CommandLineState { + fn new(cwd: PathBuf) -> Self { + Self { + focused: false, + cwd, + input: String::new(), + cursor: 0, + output_lines: Vec::new(), + output_expanded: false, + running: false, + } + } + + fn insert_text(&mut self, text: &str) { + for ch in text.chars() { + if matches!(ch, '\r' | '\n') { + self.insert_char(' '); + } else if !ch.is_control() { + self.insert_char(ch); + } + } + } + + fn insert_char(&mut self, ch: char) { + self.input.insert(self.cursor, ch); + self.cursor += ch.len_utf8(); + } + + fn backspace(&mut self) -> bool { + let Some(previous) = previous_char_boundary(&self.input, self.cursor) else { + return false; + }; + self.input.replace_range(previous..self.cursor, ""); + self.cursor = previous; + true + } + + fn delete(&mut self) -> bool { + if self.cursor >= self.input.len() { + return false; + } + let next = next_char_boundary(&self.input, self.cursor); + self.input.replace_range(self.cursor..next, ""); + true + } + + fn move_left(&mut self) -> bool { + let Some(previous) = previous_char_boundary(&self.input, self.cursor) else { + return false; + }; + self.cursor = previous; + true + } + + fn move_right(&mut self) -> bool { + if self.cursor >= self.input.len() { + return false; + } + self.cursor = next_char_boundary(&self.input, self.cursor); + true + } + + fn move_home(&mut self) -> bool { + if self.cursor == 0 { + return false; + } + self.cursor = 0; + true + } + + fn move_end(&mut self) -> bool { + if self.cursor == self.input.len() { + return false; + } + self.cursor = self.input.len(); + true + } + + fn clear_input(&mut self) -> bool { + if self.input.is_empty() { + return false; + } + self.input.clear(); + self.cursor = 0; + true + } + + fn take_submission(&mut self) -> Option { + let command = self.input.trim().to_string(); + self.input.clear(); + self.cursor = 0; + (!command.is_empty()).then_some(command) + } + + fn cursor_chars(&self) -> usize { + self.input[..self.cursor].chars().count() + } + + fn push_output_line(&mut self, line: impl Into) { + self.output_lines.push(line.into()); + if self.output_lines.len() > COMMAND_OUTPUT_MAX_LINES { + let excess = self.output_lines.len() - COMMAND_OUTPUT_MAX_LINES; + self.output_lines.drain(0..excess); + } + } + + fn push_output_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + + for line in text.replace("\r\n", "\n").replace('\r', "\n").lines() { + self.push_output_line(line.to_string()); + } + } +} + impl Default for SettingsState { fn default() -> Self { Self { @@ -394,6 +539,7 @@ fn switch_value(enabled: bool) -> String { impl App { pub fn new(cli: Cli, config: Config) -> Result { + let startup_cwd = resolved_current_dir()?; let worktrees = cli .worktrees .then(|| ManagedWorktreeOptions::new(cli.worktree_prefix.clone())) @@ -409,6 +555,7 @@ impl App { }); let (event_tx, event_rx) = mpsc::unbounded_channel(); let (usage_tx, usage_rx) = std_mpsc::channel(); + let (command_tx, command_rx) = mpsc::unbounded_channel(); Ok(Self { config, @@ -424,13 +571,16 @@ impl App { sleeping: BTreeSet::new(), rects: Vec::new(), mouse_enabled, + command_line: CommandLineState::new(startup_cwd), + command_tx, + command_rx, settings: SettingsState::default(), rename: RenamePaneState::default(), status: if mouse_enabled { - "Drag copies within pane | Alt+arrows move | Alt+Shift+arrows resize | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+o settings" + "Drag copies within pane | Alt+arrows move | Alt+Shift+arrows resize | Alt+r rename | Alt+e output | Alt+x swap | Alt+z sleep | Alt+o settings" .into() } else { - "Alt+arrows move | Alt+Shift+arrows resize | Alt+s select | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+o settings" + "Alt+arrows move | Alt+Shift+arrows resize | Alt+s select | Alt+r rename | Alt+e output | Alt+x swap | Alt+z sleep | Alt+o settings" .into() }, next_pane_id: 0, @@ -529,6 +679,7 @@ impl App { loop { needs_render |= self.drain_pty_events(); needs_render |= self.drain_usage_events(); + needs_render |= self.drain_command_events(); needs_render |= self.decay_activity(); if needs_render { @@ -557,7 +708,12 @@ impl App { needs_render = true; } Event::Paste(text) if !self.settings.open => { - self.route_input(text.as_bytes())?; + if self.command_line.focused { + self.command_line.insert_text(&text); + needs_render = true; + } else { + self.route_input(text.as_bytes())?; + } } Event::Mouse(mouse) if (self.mouse_enabled || !self.sleeping.is_empty()) @@ -659,6 +815,44 @@ impl App { changed } + fn drain_command_events(&mut self) -> bool { + let mut changed = false; + + while let Ok(event) = self.command_rx.try_recv() { + self.command_line.running = false; + + if let Some(error) = event.error { + self.command_line + .push_output_line(format!("error: {error}")); + self.status = format!("command failed: {error}"); + changed = true; + continue; + } + + self.command_line.push_output_text(&event.stdout); + if !event.stderr.is_empty() { + self.command_line.push_output_text(&event.stderr); + } + + match event.exit_code { + Some(0) => { + self.status = format!("command done: {}", event.command); + } + Some(code) => { + self.command_line.push_output_line(format!("[exit {code}]")); + self.status = format!("command exited {code}: {}", event.command); + } + None => { + self.command_line.push_output_line("[terminated]"); + self.status = format!("command terminated: {}", event.command); + } + } + changed = true; + } + + changed + } + fn decay_activity(&mut self) -> bool { if self.last_activity_decay.elapsed() < Duration::from_millis(250) { return false; @@ -694,6 +888,14 @@ impl App { }); } + if self.command_line.focused { + return Ok(if self.handle_command_key(key)? { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }); + } + if let Some(bytes) = terminal_key_bytes(key) { self.route_input(&bytes)?; } @@ -725,22 +927,22 @@ impl App { } KeyCode::Left => { self.focus_previous(); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } KeyCode::Right => { self.focus_next(); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } KeyCode::Up => { self.focus_in_grid(-1); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } KeyCode::Down => { self.focus_in_grid(1); - self.status = format!("focused pane {}", self.focus + 1); + self.status = self.focus_status(); Ok(Some(false)) } _ => Ok(None), @@ -752,8 +954,12 @@ impl App { match lower { 'q' => Ok(Some(true)), 's' => { - toggle_selection(&mut self.selected, self.focus); - self.status = format!("selected {} panes", self.selected.len()); + if self.command_line.focused { + self.status = "command line focused".into(); + } else { + toggle_selection(&mut self.selected, self.focus); + self.status = format!("selected {} panes", self.selected.len()); + } Ok(Some(false)) } 'a' => { @@ -773,6 +979,15 @@ impl App { self.swap_selected_tiles(); Ok(Some(false)) } + 'e' => { + self.command_line.output_expanded = !self.command_line.output_expanded; + self.status = if self.command_line.output_expanded { + "command output expanded".into() + } else { + "command output hidden".into() + }; + Ok(Some(false)) + } 'o' => { self.settings.open = true; self.status = "settings open".into(); @@ -1303,6 +1518,102 @@ impl App { self.status = format!("{} {} {}", action, targets.len(), pane_word(targets.len())); } + fn handle_command_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::ALT) { + return Ok(false); + } + + let changed = match key.code { + KeyCode::Enter => { + self.submit_command_line()?; + true + } + KeyCode::Backspace => self.command_line.backspace(), + KeyCode::Delete => self.command_line.delete(), + KeyCode::Left => self.command_line.move_left(), + KeyCode::Right => self.command_line.move_right(), + KeyCode::Home => self.command_line.move_home(), + KeyCode::End => self.command_line.move_end(), + KeyCode::Esc => self.command_line.clear_input(), + KeyCode::Char(ch) if key.modifiers.contains(KeyModifiers::CONTROL) => { + match ch.to_ascii_lowercase() { + 'a' => self.command_line.move_home(), + 'e' => self.command_line.move_end(), + 'u' => self.command_line.clear_input(), + _ => false, + } + } + KeyCode::Char(ch) => { + self.command_line.insert_char(ch); + true + } + _ => false, + }; + + Ok(changed) + } + + fn submit_command_line(&mut self) -> Result<()> { + if self.command_line.running { + self.status = "command still running".into(); + return Ok(()); + } + + let Some(command) = self.command_line.take_submission() else { + return Ok(()); + }; + + self.command_line.push_output_line(format!("> {command}")); + if self.handle_builtin_command(&command) { + return Ok(()); + } + + self.command_line.running = true; + self.status = format!("running: {command}"); + spawn_hidden_command( + command, + self.command_line.cwd.clone(), + self.command_tx.clone(), + ); + Ok(()) + } + + fn handle_builtin_command(&mut self, command: &str) -> bool { + if let Some(target) = parse_cd_target(command) { + match resolve_cd_target(&self.command_line.cwd, target.as_deref()) { + Ok(Some(cwd)) => { + self.command_line.cwd = cwd; + self.status = format!("cwd: {}", self.command_line.cwd.display()); + } + Ok(None) => { + self.command_line + .push_output_line(self.command_line.cwd.display().to_string()); + self.status = format!("cwd: {}", self.command_line.cwd.display()); + } + Err(error) => { + self.command_line.push_output_line(format!("cd: {error:#}")); + self.status = format!("cd failed: {error:#}"); + } + } + return true; + } + + match command.trim().to_ascii_lowercase().as_str() { + "pwd" => { + self.command_line + .push_output_line(self.command_line.cwd.display().to_string()); + self.status = format!("cwd: {}", self.command_line.cwd.display()); + true + } + "clear" | "cls" => { + self.command_line.output_lines.clear(); + self.status = "command output cleared".into(); + true + } + _ => false, + } + } + fn route_input(&mut self, bytes: &[u8]) -> Result<()> { let targets = self.input_targets(); for index in targets { @@ -1327,13 +1638,24 @@ impl App { return; } - for offset in 1..=self.panes.len() { - let candidate = (self.focus + offset) % self.panes.len(); - if !self.sleeping.contains(&candidate) { + if self.command_line.focused { + if let Some(candidate) = + (0..self.panes.len()).find(|index| !self.sleeping.contains(index)) + { + self.command_line.focused = false; self.focus = candidate; - return; } + return; } + + if let Some(candidate) = + ((self.focus + 1)..self.panes.len()).find(|index| !self.sleeping.contains(index)) + { + self.focus = candidate; + return; + } + + self.command_line.focused = true; } fn focus_previous(&mut self) { @@ -1341,13 +1663,26 @@ impl App { return; } - for offset in 1..=self.panes.len() { - let candidate = (self.focus + self.panes.len() - offset) % self.panes.len(); - if !self.sleeping.contains(&candidate) { + if self.command_line.focused { + if let Some(candidate) = (0..self.panes.len()) + .rev() + .find(|index| !self.sleeping.contains(index)) + { + self.command_line.focused = false; self.focus = candidate; - return; } + return; + } + + if let Some(candidate) = (0..self.focus) + .rev() + .find(|index| !self.sleeping.contains(index)) + { + self.focus = candidate; + return; } + + self.command_line.focused = true; } fn focus_in_grid(&mut self, row_delta: isize) { @@ -1355,6 +1690,19 @@ impl App { return; } + if self.command_line.focused { + if row_delta.is_negative() { + if let Some(candidate) = (0..self.panes.len()) + .rev() + .find(|index| !self.sleeping.contains(index)) + { + self.command_line.focused = false; + self.focus = candidate; + } + } + return; + } + let columns = self.layout.size().columns; let candidate = if row_delta.is_negative() { self.focus.saturating_sub(columns) @@ -1363,6 +1711,16 @@ impl App { }; if candidate < self.panes.len() && !self.sleeping.contains(&candidate) { self.focus = candidate; + } else if row_delta.is_positive() { + self.command_line.focused = true; + } + } + + fn focus_status(&self) -> String { + if self.command_line.focused { + "focused command line".into() + } else { + format!("focused pane {}", self.focus + 1) } } @@ -1384,8 +1742,8 @@ impl App { &self.panes } - pub fn focus(&self) -> usize { - self.focus + pub fn focused_pane(&self) -> Option { + (!self.command_line.focused).then_some(self.focus) } pub fn selected(&self) -> &BTreeSet { @@ -1432,6 +1790,44 @@ impl App { }) } + pub fn command_focused(&self) -> bool { + self.command_line.focused + } + + pub fn command_cwd(&self) -> &Path { + &self.command_line.cwd + } + + pub fn command_input(&self) -> &str { + &self.command_line.input + } + + pub fn command_cursor_chars(&self) -> usize { + self.command_line.cursor_chars() + } + + pub fn command_output_expanded(&self) -> bool { + self.command_line.output_expanded + } + + pub fn command_output_lines(&self) -> &[String] { + &self.command_line.output_lines + } + + pub fn command_running(&self) -> bool { + self.command_line.running + } + + pub fn input_scope_label(&self) -> &'static str { + if self.command_line.focused { + "command line" + } else if self.selected.len() > 1 { + "selected panes" + } else { + "focused pane" + } + } + pub fn pane_folder(&self, index: usize) -> Option<&str> { self.launch_plan .as_ref() @@ -1494,7 +1890,7 @@ impl App { fn sync_initial_pane_sizes(&mut self, terminal: &Tui) -> Result<()> { let size = terminal.size().context("failed to read terminal size")?; - self.grid_area = Rect::new(0, 0, size.width, size.height.saturating_sub(1)); + self.grid_area = Rect::new(0, 0, size.width, size.height.saturating_sub(2)); self.rects = self.pane_rects(self.grid_area); self.sync_pane_sizes(); Ok(()) @@ -1570,6 +1966,155 @@ fn resolved_current_dir() -> Result { Ok(current.canonicalize().unwrap_or(current)) } +fn spawn_hidden_command( + command: String, + cwd: PathBuf, + event_tx: mpsc::UnboundedSender, +) { + thread::spawn(move || { + let event = match run_shell_command(&command, &cwd) { + Ok(output) => CommandRunEvent { + command, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code(), + error: None, + }, + Err(error) => CommandRunEvent { + command, + stdout: String::new(), + stderr: String::new(), + exit_code: None, + error: Some(format!("{error:#}")), + }, + }; + let _ = event_tx.send(event); + }); +} + +fn run_shell_command(command: &str, cwd: &Path) -> io::Result { + let mut shell = if cfg!(windows) { + let mut shell = + Command::new(env::var_os("COMSPEC").unwrap_or_else(|| OsString::from("cmd.exe"))); + shell.arg("/C").arg(command); + shell + } else { + let mut shell = Command::new(env::var_os("SHELL").unwrap_or_else(|| OsString::from("sh"))); + shell.arg("-c").arg(command); + shell + }; + + shell.current_dir(cwd).output() +} + +fn parse_cd_target(command: &str) -> Option> { + let trimmed = command.trim(); + let lower = trimmed.to_ascii_lowercase(); + + if matches!(lower.as_str(), "cd" | "chdir") { + return Some(None); + } + if lower == "cd.." { + return Some(Some("..".into())); + } + if lower.starts_with("cd ") { + return Some(Some(normalize_cd_target(&trimmed[2..]))); + } + if lower.starts_with("chdir ") { + return Some(Some(normalize_cd_target(&trimmed[5..]))); + } + + None +} + +fn normalize_cd_target(raw: &str) -> String { + let mut value = raw.trim(); + if value + .get(..3) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("/d ")) + { + value = value[3..].trim(); + } + trim_matching_quotes(value).to_string() +} + +fn trim_matching_quotes(value: &str) -> &str { + if value.len() >= 2 { + let bytes = value.as_bytes(); + if matches!( + (bytes[0], bytes[value.len() - 1]), + (b'"', b'"') | (b'\'', b'\'') + ) { + return &value[1..value.len() - 1]; + } + } + value +} + +fn resolve_cd_target(current: &Path, target: Option<&str>) -> Result> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + + let path = if target == "~" { + home_dir().ok_or_else(|| anyhow!("home directory is not available"))? + } else if let Some(rest) = target + .strip_prefix("~/") + .or_else(|| target.strip_prefix("~\\")) + { + home_dir() + .ok_or_else(|| anyhow!("home directory is not available"))? + .join(rest) + } else { + let path = PathBuf::from(target); + if path.is_absolute() { + path + } else { + current.join(path) + } + }; + + let canonical = path + .canonicalize() + .with_context(|| format!("directory not found: {}", path.display()))?; + if !canonical.is_dir() { + return Err(anyhow!("not a directory: {}", canonical.display())); + } + Ok(Some(canonical)) +} + +fn home_dir() -> Option { + env::var_os("USERPROFILE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + let drive = env::var_os("HOMEDRIVE")?; + let path = env::var_os("HOMEPATH")?; + let mut home = PathBuf::from(drive); + home.push(path); + Some(home) + }) + .or_else(|| env::var_os("HOME").map(PathBuf::from)) +} + +fn previous_char_boundary(value: &str, cursor: usize) -> Option { + if cursor == 0 { + return None; + } + value[..cursor] + .char_indices() + .last() + .map(|(index, _)| index) +} + +fn next_char_boundary(value: &str, cursor: usize) -> usize { + value[cursor..] + .char_indices() + .nth(1) + .map(|(offset, _)| cursor + offset) + .unwrap_or(value.len()) +} + fn toggle_selection(selected: &mut BTreeSet, index: usize) { if !selected.insert(index) { selected.remove(&index); @@ -1949,6 +2494,30 @@ mod tests { vec![3] ); } + + #[test] + fn command_line_edits_at_cursor() { + let mut command = CommandLineState::new(PathBuf::from("C:\\repo")); + command.insert_text("abc"); + assert!(command.move_left()); + command.insert_char('X'); + + assert_eq!(command.input, "abXc"); + assert_eq!(command.cursor_chars(), 3); + assert!(command.backspace()); + assert_eq!(command.input, "abc"); + } + + #[test] + fn parses_cd_commands_without_treating_other_commands_as_cd() { + assert_eq!(parse_cd_target("cd"), Some(None)); + assert_eq!(parse_cd_target("cd.."), Some(Some("..".into()))); + assert_eq!( + parse_cd_target("cd /d \"C:\\Users\\Jason\""), + Some(Some("C:\\Users\\Jason".into())) + ); + assert_eq!(parse_cd_target("cargo test"), None); + } } fn conversation_summary(screen: &Screen) -> Option { diff --git a/src/ui.rs b/src/ui.rs index 4cac790..bb4e51c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -26,13 +26,25 @@ pub struct DrawState { pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { let area = frame.area(); + let output_height = if app.command_output_expanded() { + command_output_height(area.height, app.command_output_lines().len()) + } else { + 0 + }; let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) + .constraints([ + Constraint::Min(1), + Constraint::Length(output_height), + Constraint::Length(1), + Constraint::Length(1), + ]) .split(area); let grid_area = chunks[0]; - let status_area = chunks[1]; + let command_output_area = chunks[1]; + let command_area = chunks[2]; + let status_area = chunks[3]; let rects = app.pane_rects(grid_area); let rename_view = app.rename_pane_view(); let modal_open = app.settings_open() || rename_view.is_some(); @@ -42,7 +54,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { continue; }; - let focused = app.focus() == index; + let focused = app.focused_pane() == Some(index); let selected = app.selected().contains(&index); let sleeping = app.pane_sleeping(index); let chrome = pane_chrome(selected, focused, pane.active, pane.exited, sleeping); @@ -83,11 +95,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { } } - let input_scope = if app.selected().len() > 1 { - "selected panes" - } else { - "focused pane" - }; + if output_height > 0 { + render_command_output(frame, command_output_area, app); + } + render_command_line(frame, command_area, app); + + let input_scope = app.input_scope_label(); let status = Line::from(vec![ Span::styled( " GridBash ", @@ -106,7 +119,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { Span::raw(" | "), Span::styled( input_scope, - Style::default().fg(if app.selected().len() > 1 { + Style::default().fg(if app.command_focused() || app.selected().len() > 1 { Color::Cyan } else { Color::Gray @@ -116,7 +129,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { Span::raw(format!("{} selected", app.selected().len())), Span::raw(" | "), Span::raw(app.status().to_string()), - Span::raw(" | Alt+x swap | Alt+z sleep | hover wakes | Alt+q quit"), + Span::raw(" | Alt+e output | Alt+x swap | Alt+z sleep | hover wakes | Alt+q quit"), ]); frame.render_widget( Paragraph::new(status).style(Style::default().bg(APP_BG)), @@ -151,6 +164,148 @@ fn pane_title( } } +fn command_output_height(total_height: u16, line_count: usize) -> u16 { + let available = total_height.saturating_sub(3); + if available < 3 { + return 0; + } + + let max_height = (total_height / 3).clamp(3, 12).min(available); + (line_count as u16).saturating_add(2).clamp(3, max_height) +} + +fn render_command_output(frame: &mut Frame<'_>, area: Rect, app: &App) { + if area.width == 0 || area.height == 0 { + return; + } + + let border_style = if app.command_focused() { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let title = if app.command_running() { + " Command output | running " + } else { + " Command output " + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = app.command_output_lines(); + let start = lines.len().saturating_sub(inner.height as usize); + let visible = lines[start..] + .iter() + .cloned() + .map(Line::from) + .collect::>(); + + frame.render_widget( + Paragraph::new(visible).style( + Style::default() + .fg(Color::Rgb(230, 237, 243)) + .bg(Color::Rgb(11, 15, 20)), + ), + inner, + ); +} + +fn render_command_line(frame: &mut Frame<'_>, area: Rect, app: &App) { + if area.width == 0 || area.height == 0 { + return; + } + + let width = area.width as usize; + let cwd = app.command_cwd().display().to_string(); + let cwd_budget = command_cwd_budget(width, app.command_input()); + let cwd = truncate_start(&cwd, cwd_budget); + let prompt = format!(" {cwd} > "); + let prompt_width = prompt.chars().count(); + let input_width = width.saturating_sub(prompt_width); + let (input, cursor_offset) = + visible_input(app.command_input(), app.command_cursor_chars(), input_width); + let focused = app.command_focused(); + + let prompt_style = if focused { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + let input_style = if focused { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::Rgb(180, 190, 202)) + }; + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(prompt, prompt_style), + Span::styled(input, input_style), + ])) + .style(Style::default().bg(Color::Rgb(14, 20, 28))), + area, + ); + + if focused { + let x = area + .x + .saturating_add((prompt_width + cursor_offset).min(width.saturating_sub(1)) as u16); + frame.set_cursor_position((x, area.y)); + } +} + +fn command_cwd_budget(width: usize, input: &str) -> usize { + if width <= 4 { + return 0; + } + if input.is_empty() { + return width.saturating_sub(4); + } + + width.saturating_sub(14).min((width * 2) / 3) +} + +fn truncate_start(value: &str, max_chars: usize) -> String { + let chars = value.chars().collect::>(); + if chars.len() <= max_chars { + return value.to_string(); + } + if max_chars <= 3 { + return chars[chars.len().saturating_sub(max_chars)..] + .iter() + .collect(); + } + + let tail = chars[chars.len() - (max_chars - 3)..] + .iter() + .collect::(); + format!("...{tail}") +} + +fn visible_input(input: &str, cursor_chars: usize, width: usize) -> (String, usize) { + if width == 0 { + return (String::new(), 0); + } + + let chars = input.chars().collect::>(); + let cursor = cursor_chars.min(chars.len()); + if chars.len() <= width { + return (input.to_string(), cursor); + } + + let start = cursor.saturating_sub(width.saturating_sub(1)); + let end = (start + width).min(chars.len()); + (chars[start..end].iter().collect(), cursor - start) +} + #[derive(Debug, PartialEq)] struct PaneChrome { border_style: Style, From 520b1f3783d49e40a3ed8f3f8393168269afdc54 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Tue, 7 Jul 2026 17:05:29 -0700 Subject: [PATCH 2/2] fix: satisfy clippy for command line focus Signed-off-by: Jason Matthew Suhari --- src/app.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 78b0499..a3d7bc3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1691,14 +1691,13 @@ impl App { } if self.command_line.focused { - if row_delta.is_negative() { - if let Some(candidate) = (0..self.panes.len()) + if row_delta.is_negative() + && let Some(candidate) = (0..self.panes.len()) .rev() .find(|index| !self.sleeping.contains(index)) - { - self.command_line.focused = false; - self.focus = candidate; - } + { + self.command_line.focused = false; + self.focus = candidate; } return; }