Skip to content
Open
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
9 changes: 9 additions & 0 deletions crates/openshell-tui/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ impl EventHandler {
self.rx.recv().await
}

/// Discard events queued before or while the TUI was suspended.
///
/// The input reader is paused before this is called, so terminal input
/// remains in the TTY for the resumed reader. This primarily removes stale
/// tick events that would otherwise run refresh work before new key input.
pub fn discard_pending(&mut self) {
while self.rx.try_recv().is_ok() {}
}

/// Get a sender handle for dispatching events from background tasks.
pub fn sender(&self) -> mpsc::UnboundedSender<Event> {
self.keepalive.clone()
Expand Down
88 changes: 50 additions & 38 deletions crates/openshell-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,7 @@ pub async fn run(
}
if app.pending_shell_connect {
app.pending_shell_connect = false;
handle_shell_connect(&mut app, &mut terminal, &events).await;
refresh_data(&mut app).await;
handle_shell_connect(&mut app, &mut terminal, &mut events).await?;
}
// --- Draft actions ---
if app.pending_draft_approve {
Expand Down Expand Up @@ -366,11 +365,11 @@ pub async fn run(
handle_exec_command(
&mut app,
&mut terminal,
&events,
&mut events,
&name,
&command,
)
.await;
.await?;
}
}
Some(Err(msg)) => {
Expand Down Expand Up @@ -841,11 +840,11 @@ async fn fetch_sandbox_detail(app: &mut App) {
async fn handle_shell_connect(
app: &mut App,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
events: &EventHandler,
) {
events: &mut EventHandler,
) -> Result<()> {
let sandbox_name = match app.selected_sandbox_name() {
Some(n) => n.to_string(),
None => return,
None => return Ok(()),
};

// Step 1: Get sandbox ID.
Expand All @@ -859,16 +858,16 @@ async fn handle_shell_connect(
s.object_id().to_string()
} else {
app.status_text = "sandbox not found".to_string();
return;
return Ok(());
}
}
Ok(Err(e)) => {
app.status_text = format!("failed to get sandbox: {}", e.message());
return;
return Ok(());
}
Err(_) => {
app.status_text = "get sandbox timed out".to_string();
return;
return Ok(());
}
}
};
Expand All @@ -883,17 +882,17 @@ async fn handle_shell_connect(
Ok(Ok(resp)) => resp.into_inner(),
Ok(Err(e)) => {
app.status_text = format!("SSH session failed: {}", e.message());
return;
return Ok(());
}
Err(_) => {
app.status_text = "SSH session request timed out".to_string();
return;
return Ok(());
}
}
};
if let Err(err) = validate_ssh_session_response(&session) {
app.status_text = format!("gateway returned invalid SSH session response: {err}");
return;
return Ok(());
}

// Step 3: Resolve gateway address (handle loopback override).
Expand All @@ -908,7 +907,7 @@ async fn handle_shell_connect(
Ok(p) => p,
Err(e) => {
app.status_text = format!("failed to find executable: {e}");
return;
return Ok(());
}
};
let proxy_command = build_proxy_command(
Expand Down Expand Up @@ -948,12 +947,13 @@ async fn handle_shell_connect(
tokio::time::sleep(Duration::from_millis(100)).await;

// Step 7: Suspend TUI — leave alternate screen, disable raw mode.
let _ = execute!(
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = disable_raw_mode();
)
.into_diagnostic()?;
disable_raw_mode().into_diagnostic()?;

// Step 8: Spawn SSH as child process and wait.
let status = tokio::task::spawn_blocking(move || command.status()).await;
Expand All @@ -972,15 +972,22 @@ async fn handle_shell_connect(
}
}

// Step 9: Resume TUI — re-enter alternate screen, enable raw mode, unpause events.
let _ = enable_raw_mode();
let _ = execute!(
// Step 9: Resume and draw the TUI before accepting new terminal input.
enable_raw_mode().into_diagnostic()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
);
let _ = terminal.clear();
)
.into_diagnostic()?;
terminal.clear().into_diagnostic()?;
terminal
.draw(|frame| ui::draw(frame, app))
.into_diagnostic()?;
events.discard_pending();
events.resume();

Ok(())
}

/// Suspend the TUI, execute a command on a sandbox via SSH, then resume.
Expand All @@ -991,10 +998,10 @@ async fn handle_shell_connect(
async fn handle_exec_command(
app: &mut App,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
events: &EventHandler,
events: &mut EventHandler,
sandbox_name: &str,
command: &str,
) {
) -> Result<()> {
// Step 1: Resolve sandbox → SSH session (same as handle_shell_connect).
let sandbox_id = {
let req = openshell_core::proto::GetSandboxRequest {
Expand All @@ -1006,16 +1013,16 @@ async fn handle_exec_command(
s.object_id().to_string()
} else {
app.status_text = format!("exec: sandbox {sandbox_name} not found");
return;
return Ok(());
}
}
Ok(Err(e)) => {
app.status_text = format!("exec: failed to get sandbox: {}", e.message());
return;
return Ok(());
}
Err(_) => {
app.status_text = "exec: get sandbox timed out".to_string();
return;
return Ok(());
}
}
};
Expand All @@ -1029,17 +1036,17 @@ async fn handle_exec_command(
Ok(Ok(resp)) => resp.into_inner(),
Ok(Err(e)) => {
app.status_text = format!("exec: SSH session failed: {}", e.message());
return;
return Ok(());
}
Err(_) => {
app.status_text = "exec: SSH session timed out".to_string();
return;
return Ok(());
}
}
};
if let Err(err) = validate_ssh_session_response(&session) {
app.status_text = format!("exec: gateway returned invalid SSH session response: {err}");
return;
return Ok(());
}

// Step 2: Resolve gateway and build ProxyCommand (same as handle_shell_connect).
Expand All @@ -1053,7 +1060,7 @@ async fn handle_exec_command(
Ok(p) => p,
Err(e) => {
app.status_text = format!("exec: failed to find executable: {e}");
return;
return Ok(());
}
};
let proxy_command = build_proxy_command(
Expand Down Expand Up @@ -1099,12 +1106,13 @@ async fn handle_exec_command(
events.pause();
tokio::time::sleep(Duration::from_millis(100)).await;

let _ = execute!(
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = disable_raw_mode();
)
.into_diagnostic()?;
disable_raw_mode().into_diagnostic()?;

// Step 5: Run command — blocks until user Ctrl-C's or command exits.
let status = tokio::task::spawn_blocking(move || ssh.status()).await;
Expand All @@ -1124,14 +1132,18 @@ async fn handle_exec_command(
}

// Step 6: Resume TUI.
let _ = enable_raw_mode();
let _ = execute!(
enable_raw_mode().into_diagnostic()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
);
let _ = terminal.clear();
)
.into_diagnostic()?;
terminal.clear().into_diagnostic()?;
events.discard_pending();
events.resume();

Ok(())
}

// SSH utility functions are shared via openshell_core::forward.
Expand Down
Loading