use anyhow::Result;
use crossterm::event::{self, KeyCode, KeyModifiers};
use tokio::sync::mpsc;
use super::types::{CachedShellLine, ShellCommandResult, ShellPhase, ShellState};
use super::App;
const MAX_OUTPUT_BYTES: usize = 1024 * 1024;
impl App {
pub(crate) fn enter_shell_command_mode(&mut self) {
self.shell_state = Some(ShellState {
input: String::new(),
cursor: 0,
phase: ShellPhase::Input,
scroll_offset: 0,
});
}
pub(crate) fn handle_shell_input(&mut self, key: event::KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => {
self.shell_state = None;
}
KeyCode::Enter => {
self.start_shell_command();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(ref mut shell) = self.shell_state {
shell.input.clear();
shell.cursor = 0;
}
}
KeyCode::Char(c) => {
if let Some(ref mut shell) = self.shell_state {
let byte_pos = shell
.input
.char_indices()
.nth(shell.cursor)
.map(|(i, _)| i)
.unwrap_or(shell.input.len());
shell.input.insert(byte_pos, c);
shell.cursor += 1;
}
}
KeyCode::Backspace => {
if let Some(ref mut shell) = self.shell_state {
if shell.cursor > 0 {
shell.cursor -= 1;
let byte_pos = shell
.input
.char_indices()
.nth(shell.cursor)
.map(|(i, _)| i)
.unwrap_or(shell.input.len());
let next_byte = shell
.input
.char_indices()
.nth(shell.cursor + 1)
.map(|(i, _)| i)
.unwrap_or(shell.input.len());
shell.input.replace_range(byte_pos..next_byte, "");
}
}
}
KeyCode::Delete => {
if let Some(ref mut shell) = self.shell_state {
let max = shell.input.chars().count();
if shell.cursor < max {
let byte_pos = shell
.input
.char_indices()
.nth(shell.cursor)
.map(|(i, _)| i)
.unwrap_or(shell.input.len());
let next_byte = shell
.input
.char_indices()
.nth(shell.cursor + 1)
.map(|(i, _)| i)
.unwrap_or(shell.input.len());
shell.input.replace_range(byte_pos..next_byte, "");
}
}
}
KeyCode::Left => {
if let Some(ref mut shell) = self.shell_state {
shell.cursor = shell.cursor.saturating_sub(1);
}
}
KeyCode::Right => {
if let Some(ref mut shell) = self.shell_state {
let max = shell.input.chars().count();
if shell.cursor < max {
shell.cursor += 1;
}
}
}
KeyCode::Home => {
if let Some(ref mut shell) = self.shell_state {
shell.cursor = 0;
}
}
KeyCode::End => {
if let Some(ref mut shell) = self.shell_state {
shell.cursor = shell.input.chars().count();
}
}
_ => {}
}
Ok(())
}
fn start_shell_command(&mut self) {
let command = match self.shell_state.as_ref() {
Some(shell) if !shell.input.trim().is_empty() => shell.input.clone(),
_ => {
self.shell_state = None;
return;
}
};
if let Some(ref mut shell) = self.shell_state {
shell.phase = ShellPhase::Running;
}
let working_dir = self.working_dir.clone().unwrap_or_else(|| ".".to_string());
let (tx, rx) = mpsc::channel(1);
self.shell_result_receiver = Some(rx);
#[cfg(target_os = "windows")]
let (shell_bin, flag) = ("cmd", "/C");
#[cfg(not(target_os = "windows"))]
let (shell_bin, flag) = ("sh", "-c");
let timeout_secs = self.config.shell.timeout_secs;
let handle = tokio::spawn(async move {
let timeout_duration = std::time::Duration::from_secs(timeout_secs);
let result = tokio::time::timeout(
timeout_duration,
tokio::process::Command::new(shell_bin)
.arg(flag)
.arg(&command)
.current_dir(&working_dir)
.output(),
)
.await;
let shell_result = match result {
Ok(Ok(output)) => {
let mut stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let mut stderr = String::from_utf8_lossy(&output.stderr).into_owned();
truncate_at_char_boundary(&mut stdout, MAX_OUTPUT_BYTES);
truncate_at_char_boundary(&mut stderr, MAX_OUTPUT_BYTES);
let (cached_lines, total_lines) = build_cached_lines(&stdout, &stderr);
ShellCommandResult {
command,
stdout,
stderr,
exit_code: output.status.code(),
cached_lines,
total_lines,
}
}
Ok(Err(e)) => {
let stderr = format!("Failed to execute: {}", e);
let (cached_lines, total_lines) = build_cached_lines("", &stderr);
ShellCommandResult {
command,
stdout: String::new(),
stderr,
exit_code: None,
cached_lines,
total_lines,
}
}
Err(_) => {
let stderr = format!("Timed out after {}s", timeout_secs);
let (cached_lines, total_lines) = build_cached_lines("", &stderr);
ShellCommandResult {
command,
stdout: String::new(),
stderr,
exit_code: None,
cached_lines,
total_lines,
}
}
};
let _ = tx.send(shell_result).await;
});
self.shell_abort_handle = Some(handle.abort_handle());
}
pub(crate) fn handle_shell_output(&mut self, key: event::KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.shell_state = None;
}
KeyCode::Char('j') | KeyCode::Down => {
self.shell_scroll_by(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.shell_scroll_by(-1);
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.shell_scroll_by(10);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.shell_scroll_by(-10);
}
KeyCode::Char('g') => {
if let Some(ref mut shell) = self.shell_state {
shell.scroll_offset = 0;
}
}
KeyCode::Char('G') => {
if let Some(ref mut shell) = self.shell_state {
if let ShellPhase::Done(ref result) = shell.phase {
shell.scroll_offset = result.total_lines.saturating_sub(1);
}
}
}
_ => {}
}
}
fn shell_scroll_by(&mut self, delta: i32) {
let Some(ref mut shell) = self.shell_state else {
return;
};
let ShellPhase::Done(ref result) = shell.phase else {
return;
};
let max = result.total_lines.saturating_sub(1);
if delta > 0 {
shell.scroll_offset = shell
.scroll_offset
.saturating_add(delta as usize)
.min(max);
} else {
shell.scroll_offset = shell.scroll_offset.saturating_sub((-delta) as usize);
}
}
pub(crate) fn poll_shell_result(&mut self) {
let Some(ref mut rx) = self.shell_result_receiver else {
if self
.shell_state
.as_ref()
.is_some_and(|s| matches!(s.phase, ShellPhase::Cancelling))
{
self.shell_state = None;
self.cmt.submission_result =
Some((false, "Shell command cancelled".to_string()));
self.cmt.submission_result_time = Some(std::time::Instant::now());
}
return;
};
match rx.try_recv() {
Ok(result) => {
if let Some(ref mut shell) = self.shell_state {
shell.scroll_offset = 0;
shell.phase = ShellPhase::Done(result);
}
self.shell_result_receiver = None;
self.shell_abort_handle = None;
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
self.shell_result_receiver = None;
self.shell_abort_handle = None;
if self
.shell_state
.as_ref()
.is_some_and(|s| matches!(s.phase, ShellPhase::Cancelling))
{
self.shell_state = None;
self.cmt.submission_result =
Some((false, "Shell command cancelled".to_string()));
self.cmt.submission_result_time = Some(std::time::Instant::now());
}
}
}
}
pub(crate) fn cancel_shell_command(&mut self) {
if let Some(handle) = self.shell_abort_handle.take() {
handle.abort();
}
if let Some(ref mut shell) = self.shell_state {
shell.phase = ShellPhase::Cancelling;
}
}
}
fn truncate_at_char_boundary(s: &mut String, max_bytes: usize) {
if s.len() <= max_bytes {
return;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
s.push_str("\n... (output truncated)");
}
fn build_cached_lines(stdout: &str, stderr: &str) -> (Vec<CachedShellLine>, usize) {
let mut lines = Vec::new();
for line in stdout.lines() {
lines.push(CachedShellLine {
text: line.to_string(),
is_stderr: false,
});
}
if !stderr.is_empty() {
if !stdout.is_empty() {
lines.push(CachedShellLine {
text: "--- stderr ---".to_string(),
is_stderr: true,
});
}
for line in stderr.lines() {
lines.push(CachedShellLine {
text: line.to_string(),
is_stderr: true,
});
}
}
if lines.is_empty() {
lines.push(CachedShellLine {
text: "(no output)".to_string(),
is_stderr: false,
});
}
let total = lines.len();
(lines, total)
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};
fn make_key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
}
}
fn make_ctrl_key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
}
}
#[test]
fn test_enter_shell_command_mode() {
let mut app = App::new_for_test();
let original_state = app.state;
app.enter_shell_command_mode();
assert!(app.shell_state.is_some());
let shell = app.shell_state.as_ref().unwrap();
assert!(shell.input.is_empty());
assert_eq!(shell.cursor, 0);
assert!(matches!(shell.phase, ShellPhase::Input));
assert_eq!(app.state, original_state);
}
#[test]
fn test_shell_input_char_insertion() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Char('l')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('s')))
.unwrap();
let shell = app.shell_state.as_ref().unwrap();
assert_eq!(shell.input, "ls");
assert_eq!(shell.cursor, 2);
}
#[test]
fn test_shell_input_backspace() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Char('a')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('b')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('c')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Backspace))
.unwrap();
let shell = app.shell_state.as_ref().unwrap();
assert_eq!(shell.input, "ab");
assert_eq!(shell.cursor, 2);
}
#[test]
fn test_shell_input_delete() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Char('a')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('b')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('c')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Home)).unwrap();
app.handle_shell_input(make_key(KeyCode::Delete)).unwrap();
let shell = app.shell_state.as_ref().unwrap();
assert_eq!(shell.input, "bc");
assert_eq!(shell.cursor, 0);
}
#[test]
fn test_shell_input_cursor_movement() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Char('a')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('b')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('c')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Left)).unwrap();
assert_eq!(app.shell_state.as_ref().unwrap().cursor, 2);
app.handle_shell_input(make_key(KeyCode::Home)).unwrap();
assert_eq!(app.shell_state.as_ref().unwrap().cursor, 0);
app.handle_shell_input(make_key(KeyCode::Right)).unwrap();
assert_eq!(app.shell_state.as_ref().unwrap().cursor, 1);
app.handle_shell_input(make_key(KeyCode::End)).unwrap();
assert_eq!(app.shell_state.as_ref().unwrap().cursor, 3);
}
#[test]
fn test_shell_input_ctrl_u_clear() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Char('a')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('b')))
.unwrap();
app.handle_shell_input(make_ctrl_key(KeyCode::Char('u')))
.unwrap();
let shell = app.shell_state.as_ref().unwrap();
assert!(shell.input.is_empty());
assert_eq!(shell.cursor, 0);
}
#[test]
fn test_shell_input_esc_cancels() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Char('x')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Esc)).unwrap();
assert!(app.shell_state.is_none());
}
#[test]
fn test_shell_input_empty_enter_closes() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Enter)).unwrap();
assert!(app.shell_state.is_none());
}
#[test]
fn test_shell_input_unicode() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
app.handle_shell_input(make_key(KeyCode::Char('日')))
.unwrap();
app.handle_shell_input(make_key(KeyCode::Char('本')))
.unwrap();
let shell = app.shell_state.as_ref().unwrap();
assert_eq!(shell.input, "日本");
assert_eq!(shell.cursor, 2);
app.handle_shell_input(make_key(KeyCode::Backspace))
.unwrap();
let shell = app.shell_state.as_ref().unwrap();
assert_eq!(shell.input, "日");
assert_eq!(shell.cursor, 1);
}
#[test]
fn test_truncate_at_char_boundary_ascii() {
let mut s = "hello world".to_string();
truncate_at_char_boundary(&mut s, 5);
assert_eq!(s, "hello\n... (output truncated)");
}
#[test]
fn test_truncate_at_char_boundary_multibyte() {
let mut s = "あいうえお".to_string(); truncate_at_char_boundary(&mut s, 7); assert_eq!(s, "あい\n... (output truncated)"); }
#[test]
fn test_truncate_at_char_boundary_no_truncation() {
let mut s = "short".to_string();
truncate_at_char_boundary(&mut s, 100);
assert_eq!(s, "short");
}
#[test]
fn test_build_cached_lines_stdout_only() {
let (lines, total) = build_cached_lines("line1\nline2", "");
assert_eq!(total, 2);
assert!(!lines[0].is_stderr);
assert_eq!(lines[0].text, "line1");
}
#[test]
fn test_build_cached_lines_both() {
let (lines, total) = build_cached_lines("out", "err");
assert_eq!(total, 3); assert!(!lines[0].is_stderr);
assert!(lines[1].is_stderr); assert!(lines[2].is_stderr);
}
#[test]
fn test_build_cached_lines_empty() {
let (lines, total) = build_cached_lines("", "");
assert_eq!(total, 1);
assert_eq!(lines[0].text, "(no output)");
}
#[test]
fn test_shell_scroll_clamped() {
let mut app = App::new_for_test();
app.enter_shell_command_mode();
let result = ShellCommandResult {
command: "test".to_string(),
stdout: "a\nb\nc".to_string(),
stderr: String::new(),
exit_code: Some(0),
cached_lines: vec![
CachedShellLine { text: "a".into(), is_stderr: false },
CachedShellLine { text: "b".into(), is_stderr: false },
CachedShellLine { text: "c".into(), is_stderr: false },
],
total_lines: 3,
};
if let Some(ref mut shell) = app.shell_state {
shell.phase = ShellPhase::Done(result);
}
for _ in 0..10 {
app.handle_shell_output(make_key(KeyCode::Char('j')));
}
assert_eq!(app.shell_state.as_ref().unwrap().scroll_offset, 2);
}
}