Add terminal overlay and agent install flow

This commit is contained in:
James Peter
2025-09-17 08:49:23 +10:00
parent 736293a90a
commit 104a5f9fd5
7 changed files with 1092 additions and 14 deletions
+239 -1
View File
@@ -11,11 +11,13 @@ use crate::slash_command::SlashCommand;
use crate::transcript_app::TranscriptApp;
use crate::tui;
use crate::tui::TerminalInfo;
use crate::history_cell;
use codex_core::ConversationManager;
use codex_login::{AuthManager, AuthMode};
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -23,6 +25,7 @@ use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::supports_keyboard_enhancement;
use crossterm::SynchronizedUpdate; // trait for stdout().sync_update
use std::collections::HashMap;
use std::path::PathBuf;
use ratatui::prelude::Rect;
use ratatui::text::Line;
@@ -34,6 +37,11 @@ use std::sync::mpsc::channel;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use std::process::Stdio;
use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio::sync::oneshot;
/// Time window for debouncing redraw requests.
///
@@ -56,6 +64,12 @@ enum AppState<'a> {
},
}
struct TerminalRunState {
command: Vec<String>,
cancel_tx: Option<oneshot::Sender<()>>,
running: bool,
}
pub(crate) struct App<'a> {
_server: Arc<ConversationManager>,
app_event_tx: AppEventSender,
@@ -118,6 +132,7 @@ pub(crate) struct App<'a> {
/// True when TUI is currently rendering in the terminal's alternate screen.
alt_screen_active: bool,
terminal_runs: HashMap<u64, TerminalRunState>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -301,6 +316,7 @@ impl App<'_> {
timing_enabled: enable_perf,
timing: TimingStats::default(),
alt_screen_active: start_in_alt,
terminal_runs: HashMap::new(),
}
}
@@ -348,6 +364,152 @@ impl App<'_> {
});
}
fn start_terminal_run(&mut self, id: u64, command: Vec<String>) {
if command.is_empty() {
self.app_event_tx.send(AppEvent::TerminalChunk {
id,
chunk: b"Install command not resolved".to_vec(),
is_stderr: true,
});
self.app_event_tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
duration: Duration::from_millis(0),
});
return;
}
let (cancel_tx, cancel_rx) = oneshot::channel();
self.terminal_runs.insert(
id,
TerminalRunState {
command: command.clone(),
cancel_tx: Some(cancel_tx),
running: true,
},
);
let cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let mut cmd = Command::new(&command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
cmd.current_dir(&cwd);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let start_time = Instant::now();
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(err) => {
let msg = format!("Failed to spawn command: {err}\n");
tx.send(AppEvent::TerminalChunk {
id,
chunk: msg.into_bytes(),
is_stderr: true,
});
tx.send(AppEvent::TerminalExit {
id,
exit_code: Some(1),
duration: Duration::from_millis(0),
});
return;
}
};
let mut stdout_task = None;
if let Some(stdout) = child.stdout.take() {
let mut reader = BufReader::new(stdout);
let tx_stdout = tx.clone();
stdout_task = Some(tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => tx_stdout.send(AppEvent::TerminalChunk {
id,
chunk: buf[..n].to_vec(),
is_stderr: false,
}),
Err(err) => {
tx_stdout.send(AppEvent::TerminalChunk {
id,
chunk: format!("Error reading stdout: {err}\n").into_bytes(),
is_stderr: true,
});
break;
}
}
}
}));
}
let mut stderr_task = None;
if let Some(stderr) = child.stderr.take() {
let mut reader = BufReader::new(stderr);
let tx_stderr = tx.clone();
stderr_task = Some(tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => tx_stderr.send(AppEvent::TerminalChunk {
id,
chunk: buf[..n].to_vec(),
is_stderr: true,
}),
Err(err) => {
tx_stderr.send(AppEvent::TerminalChunk {
id,
chunk: format!("Error reading stderr: {err}\n").into_bytes(),
is_stderr: true,
});
break;
}
}
}
}));
}
let mut cancel_rx = cancel_rx;
let mut cancel_triggered = false;
let wait_status = loop {
tokio::select! {
res = child.wait() => break res,
res = &mut cancel_rx, if !cancel_triggered => {
if res.is_ok() {
cancel_triggered = true;
let _ = child.start_kill();
}
}
}
};
if let Some(task) = stdout_task {
let _ = task.await;
}
if let Some(task) = stderr_task {
let _ = task.await;
}
let (exit_code, duration) = match wait_status {
Ok(status) => (status.code(), start_time.elapsed()),
Err(err) => {
tx.send(AppEvent::TerminalChunk {
id,
chunk: format!("Process wait failed: {err}\n").into_bytes(),
is_stderr: true,
});
(None, start_time.elapsed())
}
};
tx.send(AppEvent::TerminalExit { id, exit_code, duration });
});
}
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Insert an event to trigger the first render.
let app_event_tx = self.app_event_tx.clone();
@@ -728,6 +890,78 @@ impl App<'_> {
widget.cancel_running_task_from_approval();
}
}
AppEvent::OpenTerminal(launch) => {
let mut spawn = None;
if let AppState::Chat { widget } = &mut self.app_state {
if !matches!(self.config.sandbox_policy, SandboxPolicy::DangerFullAccess) {
widget.history_push(history_cell::new_error_event(
"Terminal requires danger-full-access sandbox to run install commands.".to_string(),
));
widget.show_agents_overview_ui();
} else {
widget.terminal_open(&launch);
spawn = Some((launch.id, launch.command.clone()));
}
}
if let Some((id, command)) = spawn {
self.start_terminal_run(id, command);
}
}
AppEvent::TerminalChunk { id, chunk, is_stderr } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_append_chunk(id, &chunk, is_stderr);
}
}
AppEvent::TerminalExit { id, exit_code, duration } => {
let after = if let AppState::Chat { widget } = &mut self.app_state {
widget.terminal_finalize(id, exit_code, duration)
} else {
None
};
if let Some(run) = self.terminal_runs.get_mut(&id) {
run.running = false;
run.cancel_tx = None;
}
if exit_code == Some(0) {
self.terminal_runs.remove(&id);
}
if let Some(after) = after {
self.app_event_tx.send(AppEvent::TerminalAfter(after));
}
}
AppEvent::TerminalCancel { id } => {
if let Some(run) = self.terminal_runs.get_mut(&id) {
if let Some(tx) = run.cancel_tx.take() {
let _ = tx.send(());
}
}
}
AppEvent::TerminalRerun { id } => {
let command = self
.terminal_runs
.get(&id)
.and_then(|run| (!run.running).then(|| run.command.clone()));
if let Some(command) = command {
self.start_terminal_run(id, command);
}
}
AppEvent::TerminalAfter(after) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_terminal_after(after);
}
}
AppEvent::RequestAgentInstall { name, selected_index } => {
if let AppState::Chat { widget } = &mut self.app_state {
if let Some(launch) = widget.launch_agent_install(name, selected_index) {
self.app_event_tx.send(AppEvent::OpenTerminal(launch));
}
}
}
AppEvent::AgentsOverviewSelectionChanged { index } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_agents_overview_selection(index);
}
}
// fallthrough handled by break
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
@@ -784,6 +1018,7 @@ impl App<'_> {
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
self.terminal_runs.clear();
self.app_event_tx.send(AppEvent::RequestRedraw);
}
SlashCommand::Init => {
@@ -972,6 +1207,7 @@ impl App<'_> {
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
self.terminal_runs.clear();
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
@@ -1184,7 +1420,8 @@ impl App<'_> {
show_order_overlay,
);
w.enable_perf(enable_perf);
self.app_state = AppState::Chat { widget: Box::new(w) }
self.app_state = AppState::Chat { widget: Box::new(w) };
self.terminal_runs.clear();
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
@@ -1268,6 +1505,7 @@ impl App<'_> {
new_widget.check_for_initial_animations();
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
self.terminal_runs.clear();
// Reset any transient state from the previous widget/session
self.commit_anim_running.store(false, Ordering::Release);
self.last_esc_time = None;
+21
View File
@@ -25,6 +25,19 @@ impl<T> fmt::Debug for Redacted<T> {
}
}
#[derive(Debug, Clone)]
pub(crate) struct TerminalLaunch {
pub id: u64,
pub title: String,
pub command: Vec<String>,
pub command_display: String,
}
#[derive(Debug, Clone)]
pub(crate) enum TerminalAfter {
RefreshAgentsAndClose { selected_index: usize },
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@@ -190,6 +203,14 @@ pub(crate) enum AppEvent {
/// (clear spinner/status, finalize running exec/tool cells) while the core
/// continues its own abort/cleanup in parallel.
CancelRunningTask,
OpenTerminal(TerminalLaunch),
TerminalChunk { id: u64, chunk: Vec<u8>, is_stderr: bool },
TerminalExit { id: u64, exit_code: Option<i32>, duration: Duration },
TerminalCancel { id: u64 },
TerminalRerun { id: u64 },
TerminalAfter(TerminalAfter),
RequestAgentInstall { name: String, selected_index: usize },
AgentsOverviewSelectionChanged { index: usize },
/// Add or update an agent's settings (enabled, params, instructions)
UpdateAgentConfig {
name: String,
@@ -24,9 +24,17 @@ impl AgentsOverviewView {
pub fn new(
agents: Vec<(String, bool, bool, String)>,
commands: Vec<String>,
selected_index: usize,
app_event_tx: AppEventSender,
) -> Self {
Self { agents, commands, selected: 0, is_complete: false, app_event_tx }
let mut view = Self { agents, commands, selected: 0, is_complete: false, app_event_tx };
let total = view.total_rows();
if total == 0 {
view.selected = 0;
} else {
view.selected = selected_index.min(total.saturating_sub(1));
}
view
}
fn total_rows(&self) -> usize { self.agents.len().saturating_add(self.commands.len()).saturating_add(1) /* Add new… */ }
@@ -66,7 +74,12 @@ impl AgentsOverviewView {
];
if sel {
spans.push(Span::raw(" "));
spans.push(Span::styled("(press Enter to configure)", Style::default().fg(crate::colors::text_dim())));
let hint = if !*installed {
"(press Enter to install)"
} else {
"(press Enter to configure)"
};
spans.push(Span::styled(hint, Style::default().fg(crate::colors::text_dim())));
}
lines.push(Line::from(spans));
}
@@ -126,18 +139,25 @@ impl<'a> BottomPaneView<'a> for AgentsOverviewView {
KeyEvent { code: KeyCode::Up, .. } => {
if self.total_rows() == 0 { return; }
if self.selected == 0 { self.selected = self.total_rows() - 1; } else { self.selected -= 1; }
self.app_event_tx.send(AppEvent::AgentsOverviewSelectionChanged { index: self.selected });
}
KeyEvent { code: KeyCode::Down, .. } => {
if self.total_rows() == 0 { return; }
self.selected = (self.selected + 1) % self.total_rows();
self.app_event_tx.send(AppEvent::AgentsOverviewSelectionChanged { index: self.selected });
}
KeyEvent { code: KeyCode::Enter, .. } => {
let idx = self.selected;
if idx < self.agents.len() {
// Open Agent editor
let (name, _en, _inst, _cmd) = self.agents[idx].clone();
self.app_event_tx.send(AppEvent::ShowAgentEditor { name });
self.is_complete = true;
let (name, _en, installed, _cmd) = self.agents[idx].clone();
if !installed {
self.app_event_tx.send(AppEvent::RequestAgentInstall { name, selected_index: idx });
self.is_complete = true;
} else {
self.app_event_tx.send(AppEvent::ShowAgentEditor { name });
self.is_complete = true;
}
} else {
// Commands region: specific name or Add new…
let cmd_idx = idx - self.agents.len();
+2 -1
View File
@@ -121,9 +121,10 @@ impl BottomPane<'_> {
&mut self,
agents: Vec<(String, bool, bool, String)>,
commands: Vec<String>,
selected_index: usize,
) {
use agents_overview_view::AgentsOverviewView;
let view = AgentsOverviewView::new(agents, commands, self.app_event_tx.clone());
let view = AgentsOverviewView::new(agents, commands, selected_index, self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
+737 -6
View File
@@ -1,12 +1,14 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use ratatui::style::Modifier;
use ratatui::style::Style;
@@ -29,6 +31,7 @@ mod diff_ui;
mod exec_tools;
mod gh_actions;
mod help_handlers;
mod terminal_handlers;
mod interrupts;
mod layout_scroll;
mod message;
@@ -82,7 +85,7 @@ use tokio::sync::mpsc::unbounded_channel;
use tracing::info;
// use image::GenericImageView;
use crate::app_event::AppEvent;
use crate::app_event::{AppEvent, TerminalAfter, TerminalLaunch};
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
@@ -91,6 +94,7 @@ use crate::bottom_pane::InputResult;
use crate::height_manager::HeightEvent;
use crate::height_manager::HeightManager;
use crate::history_cell;
use crate::history_cell::normalize_overwrite_sequences;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
@@ -98,6 +102,8 @@ use crate::live_wrap::RowBuilder;
use crate::streaming::StreamKind;
use crate::streaming::controller::AppEventHistorySink;
use crate::user_approval_widget::ApprovalRequest;
use crate::sanitize::{sanitize_for_tui, Mode as SanitizeMode, Options as SanitizeOptions};
use codex_ansi_escape::ansi_escape_line;
use codex_browser::BrowserManager;
use codex_core::config::find_codex_home;
use codex_core::config::set_github_check_on_push;
@@ -227,6 +233,12 @@ pub(crate) struct ChatWidget<'a> {
// Help overlay state
help: HelpState,
// Terminal overlay state
terminal: TerminalState,
// Persisted selection for Agents overview
agents_overview_selected_index: usize,
// Cache for expensive height calculations per cell and width
height_cache: std::cell::RefCell<std::collections::HashMap<(usize, u16), u16>>,
// Track last width used to opportunistically clear cache when layout changes
@@ -1831,6 +1843,8 @@ impl ChatWidget<'_> {
overlay: None,
body_visible_rows: std::cell::Cell::new(0),
},
terminal: TerminalState::default(),
agents_overview_selected_index: 0,
height_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
height_cache_last_width: std::cell::Cell::new(0),
height_manager: RefCell::new(HeightManager::new(
@@ -2010,6 +2024,8 @@ impl ChatWidget<'_> {
overlay: None,
body_visible_rows: std::cell::Cell::new(0),
},
terminal: TerminalState::default(),
agents_overview_selected_index: 0,
height_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
height_cache_last_width: std::cell::Cell::new(0),
height_manager: RefCell::new(HeightManager::new(
@@ -2175,6 +2191,9 @@ impl ChatWidget<'_> {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if terminal_handlers::handle_terminal_key(self, key_event) {
return;
}
// Intercept keys for overlays when active (help first, then diff)
if help_handlers::handle_help_key(self, key_event) {
return;
@@ -4599,7 +4618,285 @@ impl ChatWidget<'_> {
.collect();
commands.extend(custom);
self.bottom_pane.show_agents_overview(agent_rows, commands);
let total_rows = agent_rows.len().saturating_add(commands.len()).saturating_add(1);
let selected = if total_rows == 0 {
0
} else {
self.agents_overview_selected_index.min(total_rows.saturating_sub(1))
};
self.agents_overview_selected_index = selected;
self.bottom_pane
.show_agents_overview(agent_rows, commands, selected);
}
pub(crate) fn set_agents_overview_selection(&mut self, index: usize) {
self.agents_overview_selected_index = index;
}
fn resolve_agent_install_command(
&self,
agent_name: &str,
) -> Option<(Vec<String>, String)> {
let cmd = self
.config
.agents
.iter()
.find(|a| a.name.eq_ignore_ascii_case(agent_name))
.map(|cfg| cfg.command.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| agent_name.to_string());
if cmd.trim().is_empty() {
return None;
}
#[cfg(target_os = "windows")]
{
let script = format!(
"if (Get-Command {cmd} -ErrorAction SilentlyContinue) {{ Write-Output \"{cmd} already installed\"; exit 0 }} else {{ Write-Warning \"{cmd} is not installed.\"; Write-Output \"Please install {cmd} via winget, Chocolatey, or the vendor installer.\"; exit 1 }}",
cmd = cmd
);
let command = vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-Command".to_string(),
script.clone(),
];
return Some((command, format!("PowerShell install check for {cmd}")));
}
#[cfg(target_os = "macos")]
{
let script = format!("brew install {cmd}");
let command = vec!["/bin/bash".to_string(), "-lc".to_string(), script.clone()];
return Some((command, script));
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let script = format!(
"{cmd} --version || (echo \"Please install {cmd} via your package manager\" && false)",
cmd = cmd
);
let command = vec!["/bin/bash".to_string(), "-lc".to_string(), script.clone()];
return Some((command, script));
}
#[allow(unreachable_code)]
{
None
}
}
pub(crate) fn launch_agent_install(
&mut self,
name: String,
selected_index: usize,
) -> Option<TerminalLaunch> {
self.agents_overview_selected_index = selected_index;
let Some((command, display)) = self.resolve_agent_install_command(&name) else {
self.history_push(history_cell::new_error_event(format!(
"No install command available for agent '{name}' on this platform."
)));
self.show_agents_overview_ui();
return None;
};
let id = self.terminal.alloc_id();
self.terminal.after = Some(TerminalAfter::RefreshAgentsAndClose {
selected_index,
});
Some(TerminalLaunch {
id,
title: format!("Install {name}"),
command,
command_display: display,
})
}
pub(crate) fn terminal_open(&mut self, launch: &TerminalLaunch) {
let mut overlay = TerminalOverlay::new(
launch.id,
launch.title.clone(),
launch.command_display.clone(),
);
let visible = self.terminal.last_visible_rows.get();
overlay.visible_rows = visible;
overlay.clamp_scroll();
self.terminal.overlay = Some(overlay);
self.request_redraw();
}
pub(crate) fn terminal_append_chunk(&mut self, id: u64, chunk: &[u8], is_stderr: bool) {
let mut needs_redraw = false;
let visible = self.terminal.last_visible_rows.get();
if let Some(overlay) = self.terminal.overlay_mut() {
if overlay.id == id {
if visible != overlay.visible_rows {
overlay.visible_rows = visible;
overlay.clamp_scroll();
}
overlay.append_chunk(chunk, is_stderr);
needs_redraw = true;
}
}
if needs_redraw {
self.request_redraw();
}
}
pub(crate) fn terminal_finalize(
&mut self,
id: u64,
exit_code: Option<i32>,
duration: Duration,
) -> Option<TerminalAfter> {
let mut success = false;
let mut after = None;
let mut needs_redraw = false;
let mut should_close = false;
let visible = self.terminal.last_visible_rows.get();
if let Some(overlay) = self.terminal.overlay_mut() {
if overlay.id == id {
if visible != overlay.visible_rows {
overlay.visible_rows = visible;
overlay.clamp_scroll();
}
let was_following = overlay.is_following();
overlay.finalize(exit_code, duration);
overlay.auto_follow(was_following);
needs_redraw = true;
if exit_code == Some(0) {
success = true;
after = self.terminal.after.take();
should_close = true;
}
}
}
if should_close {
self.terminal.overlay = None;
}
if needs_redraw {
self.request_redraw();
}
if success {
after
} else {
None
}
}
pub(crate) fn terminal_prepare_rerun(&mut self, id: u64) -> bool {
let mut reset = false;
let visible = self.terminal.last_visible_rows.get();
if let Some(overlay) = self.terminal.overlay_mut() {
if overlay.id == id && !overlay.running {
overlay.reset_for_rerun();
overlay.visible_rows = visible;
overlay.clamp_scroll();
reset = true;
}
}
if reset {
self.request_redraw();
}
reset
}
pub(crate) fn close_terminal_overlay(&mut self) {
self.terminal.clear();
self.request_redraw();
}
pub(crate) fn terminal_overlay_id(&self) -> Option<u64> {
self.terminal.overlay().map(|o| o.id)
}
pub(crate) fn terminal_is_running(&self) -> bool {
self.terminal
.overlay()
.map(|o| o.running)
.unwrap_or(false)
}
pub(crate) fn terminal_scroll_lines(&mut self, delta: i32) {
let mut updated = false;
let visible = self.terminal.last_visible_rows.get();
if let Some(overlay) = self.terminal.overlay_mut() {
if visible != overlay.visible_rows {
overlay.visible_rows = visible;
}
let current = overlay.scroll as i32;
let max_scroll = overlay.max_scroll() as i32;
let mut next = current + delta;
if next < 0 {
next = 0;
} else if next > max_scroll {
next = max_scroll;
}
if next as u16 != overlay.scroll {
overlay.scroll = next as u16;
updated = true;
}
}
if updated {
self.request_redraw();
}
}
pub(crate) fn terminal_scroll_page(&mut self, direction: i32) {
let mut delta = None;
let visible_value = self.terminal.last_visible_rows.get();
if let Some(overlay) = self.terminal.overlay_mut() {
let visible = visible_value.max(1);
if visible != overlay.visible_rows {
overlay.visible_rows = visible;
}
delta = Some((visible.saturating_sub(1)) as i32 * direction);
}
if let Some(amount) = delta {
self.terminal_scroll_lines(amount);
}
}
pub(crate) fn terminal_scroll_to_top(&mut self) {
let mut updated = false;
if let Some(overlay) = self.terminal.overlay_mut() {
if overlay.scroll != 0 {
overlay.scroll = 0;
updated = true;
}
}
if updated {
self.request_redraw();
}
}
pub(crate) fn terminal_scroll_to_bottom(&mut self) {
let mut updated = false;
let visible = self.terminal.last_visible_rows.get();
if let Some(overlay) = self.terminal.overlay_mut() {
if visible != overlay.visible_rows {
overlay.visible_rows = visible;
}
let max_scroll = overlay.max_scroll();
if overlay.scroll != max_scroll {
overlay.scroll = max_scroll;
updated = true;
}
}
if updated {
self.request_redraw();
}
}
pub(crate) fn handle_terminal_after(&mut self, after: TerminalAfter) {
match after {
TerminalAfter::RefreshAgentsAndClose { selected_index } => {
self.agents_overview_selected_index = selected_index;
self.show_agents_overview_ui();
}
}
}
// show_subagent_editor_ui removed; use show_subagent_editor_for_name or show_new_subagent_editor
@@ -5974,6 +6271,7 @@ impl ChatWidget<'_> {
self.bottom_pane.has_active_modal_view()
|| self.diffs.overlay.is_some()
|| self.help.overlay.is_some()
|| self.terminal.overlay.is_some()
}
/// Forward an `Op` directly to codex.
@@ -11743,12 +12041,206 @@ impl WidgetRef for &ChatWidget<'_> {
// The composer has its own layout with hints at the bottom
(&self.bottom_pane).render(bottom_pane_area, buf);
if let Some(overlay) = self.terminal.overlay() {
let scrim_style = Style::default()
.bg(crate::colors::overlay_scrim())
.fg(crate::colors::text_dim());
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
buf[(x, y)].set_style(scrim_style);
}
}
let padding = 1u16;
let window_area = Rect {
x: history_area.x + padding,
y: history_area.y,
width: history_area.width.saturating_sub(padding * 2),
height: history_area.height,
};
Clear.render(window_area, buf);
let block = Block::default()
.borders(Borders::ALL)
.title(ratatui::text::Line::from(vec![
ratatui::text::Span::styled(
" ",
Style::default().fg(crate::colors::text_dim()),
),
ratatui::text::Span::styled(
overlay.title.clone(),
Style::default().fg(crate::colors::text()),
),
]))
.style(Style::default().bg(crate::colors::background()))
.border_style(
Style::default()
.fg(crate::colors::border())
.bg(crate::colors::background()),
);
let inner = block.inner(window_area);
block.render(window_area, buf);
let inner_bg = Style::default().bg(crate::colors::background());
for y in inner.y..inner.y + inner.height {
for x in inner.x..inner.x + inner.width {
buf[(x, y)].set_style(inner_bg);
}
}
let content = inner.inner(ratatui::layout::Margin::new(1, 1));
if content.height == 0 || content.width == 0 {
self.terminal.last_visible_rows.set(0);
} else {
let header_area = Rect {
x: content.x,
y: content.y,
width: content.width,
height: 1.min(content.height),
};
let footer_area = if content.height >= 2 {
Rect {
x: content.x,
y: content.y + content.height.saturating_sub(1),
width: content.width,
height: 1,
}
} else {
header_area
};
let body_height = content
.height
.saturating_sub(if content.height >= 2 { 2 } else { 1 });
let body_area = Rect {
x: content.x,
y: header_area.y.saturating_add(header_area.height),
width: content.width,
height: body_height,
};
// Header content
let mut status_text = if overlay.running {
"Running…".to_string()
} else if let Some(code) = overlay.exit_code {
format!("Exit {code}")
} else {
"Idle".to_string()
};
if let Some(duration) = overlay.duration {
status_text.push(' ');
status_text.push('(');
status_text.push_str(&format_duration_short(duration));
status_text.push(')');
}
let status_color = if overlay.running {
crate::colors::function()
} else if overlay.exit_code == Some(0) {
crate::colors::success()
} else if overlay.exit_code.is_some() {
crate::colors::error()
} else {
crate::colors::text_dim()
};
let header_line = ratatui::text::Line::from(vec![
ratatui::text::Span::styled(
overlay.command_display.clone(),
Style::default().fg(crate::colors::text()),
),
ratatui::text::Span::raw(" "),
ratatui::text::Span::styled(
status_text,
Style::default().fg(status_color),
),
]);
Paragraph::new(RtText::from(vec![header_line]))
.wrap(ratatui::widgets::Wrap { trim: true })
.render(header_area, buf);
// Body content
self.terminal.last_visible_rows.set(body_area.height);
if body_area.height > 0 && body_area.width > 0 {
let mut rows: Vec<RtLine<'static>> = Vec::new();
if overlay.truncated {
rows.push(ratatui::text::Line::from(vec![ratatui::text::Span::styled(
"… output truncated (showing last 10,000 lines)",
Style::default().fg(crate::colors::text_dim()),
)]));
}
rows.extend(overlay.lines.iter().cloned());
let total = rows.len();
let visible = body_area.height as usize;
if visible > 0 {
let max_scroll = total.saturating_sub(visible);
let scroll = (overlay.scroll as usize).min(max_scroll);
let end = (scroll + visible).min(total);
let window = rows.get(scroll..end).unwrap_or(&[]);
Paragraph::new(RtText::from(window.to_vec()))
.wrap(ratatui::widgets::Wrap { trim: false })
.render(body_area, buf);
}
}
// Footer hints
let mut footer_spans = vec![
ratatui::text::Span::styled(
"↑↓",
Style::default().fg(crate::colors::function()),
),
ratatui::text::Span::styled(
" Scroll ",
Style::default().fg(crate::colors::text_dim()),
),
ratatui::text::Span::styled(
"PgUp/PgDn",
Style::default().fg(crate::colors::function()),
),
ratatui::text::Span::styled(
" Page ",
Style::default().fg(crate::colors::text_dim()),
),
ratatui::text::Span::styled(
"Esc",
Style::default().fg(crate::colors::error()),
),
ratatui::text::Span::styled(
" Close ",
Style::default().fg(crate::colors::text_dim()),
),
];
if overlay.running {
footer_spans.push(ratatui::text::Span::styled(
"Ctrl+C",
Style::default().fg(crate::colors::warning()),
));
footer_spans.push(ratatui::text::Span::styled(
" Cancel",
Style::default().fg(crate::colors::text_dim()),
));
} else {
footer_spans.push(ratatui::text::Span::styled(
"R",
Style::default().fg(crate::colors::primary()),
));
footer_spans.push(ratatui::text::Span::styled(
" Rerun",
Style::default().fg(crate::colors::text_dim()),
));
}
Paragraph::new(RtText::from(vec![ratatui::text::Line::from(footer_spans)]))
.wrap(ratatui::widgets::Wrap { trim: true })
.render(footer_area, buf);
}
// Terminal overlay takes precedence over other overlays
}
// Welcome animation is kept as a normal cell in history; no overlay.
// The welcome animation is no longer rendered as an overlay.
// Render diff overlay (covering the history area, aligned with padding) if active
if let Some(overlay) = &self.diffs.overlay {
if self.terminal.overlay().is_none() {
// Render diff overlay (covering the history area, aligned with padding) if active
if let Some(overlay) = &self.diffs.overlay {
// Global scrim: dim the whole background to draw focus to the viewer
// We intentionally do this across the entire widget area rather than just the
// history area so the viewer stands out even with browser HUD or status bars.
@@ -12090,8 +12582,8 @@ impl WidgetRef for &ChatWidget<'_> {
}
}
// Render help overlay (covering the history area) if active
if let Some(overlay) = &self.help.overlay {
// Render help overlay (covering the history area) if active
if let Some(overlay) = &self.help.overlay {
// Global scrim across widget
let scrim_bg = Style::default()
.bg(crate::colors::overlay_scrim())
@@ -12161,6 +12653,7 @@ impl WidgetRef for &ChatWidget<'_> {
let paragraph = Paragraph::new(RtText::from(visible.to_vec()))
.wrap(ratatui::widgets::Wrap { trim: false });
ratatui::widgets::Widget::render(paragraph, body, buf);
}
}
// Finalize widget render timing
if let Some(t0) = _perf_widget_start {
@@ -12362,6 +12855,244 @@ impl HelpOverlay {
Self { lines, scroll: 0 }
}
}
#[derive(Default)]
struct TerminalState {
overlay: Option<TerminalOverlay>,
next_id: u64,
after: Option<TerminalAfter>,
last_visible_rows: std::cell::Cell<u16>,
}
impl TerminalState {
fn alloc_id(&mut self) -> u64 {
let id = self.next_id;
self.next_id = self.next_id.saturating_add(1);
id
}
fn overlay(&self) -> Option<&TerminalOverlay> { self.overlay.as_ref() }
fn overlay_mut(&mut self) -> Option<&mut TerminalOverlay> { self.overlay.as_mut() }
fn clear(&mut self) {
self.overlay = None;
self.after = None;
}
}
const TERMINAL_MAX_LINES: usize = 10_000;
struct TerminalOverlay {
id: u64,
title: String,
command_display: String,
lines: VecDeque<RtLine<'static>>,
scroll: u16,
visible_rows: u16,
running: bool,
exit_code: Option<i32>,
duration: Option<Duration>,
truncated: bool,
pending_utf8: Vec<u8>,
pending_line: String,
pending_line_is_stderr: bool,
}
impl TerminalOverlay {
fn new(id: u64, title: String, command_display: String) -> Self {
Self {
id,
title,
command_display,
lines: VecDeque::new(),
scroll: 0,
visible_rows: 0,
running: true,
exit_code: None,
duration: None,
truncated: false,
pending_utf8: Vec::new(),
pending_line: String::new(),
pending_line_is_stderr: false,
}
}
fn total_render_lines(&self) -> usize {
let base = self.lines.len();
if self.truncated { base.saturating_add(1) } else { base }
}
fn max_scroll(&self) -> u16 {
let visible = self.visible_rows.max(1) as usize;
let total = self.total_render_lines();
total
.saturating_sub(visible)
.min(u16::MAX as usize) as u16
}
fn clamp_scroll(&mut self) {
let max_scroll = self.max_scroll();
if self.scroll > max_scroll {
self.scroll = max_scroll;
}
}
fn is_following(&self) -> bool {
let visible = self.visible_rows.max(1) as usize;
let total = self.total_render_lines();
(self.scroll as usize).saturating_add(visible) >= total
}
fn auto_follow(&mut self, was_following: bool) {
if !was_following {
return;
}
let visible = self.visible_rows.max(1) as usize;
let total = self.total_render_lines();
let max_scroll = total.saturating_sub(visible);
self.scroll = max_scroll.min(u16::MAX as usize) as u16;
}
fn reset_for_rerun(&mut self) {
self.lines.clear();
self.scroll = 0;
self.visible_rows = 0;
self.running = true;
self.exit_code = None;
self.duration = None;
self.truncated = false;
self.pending_utf8.clear();
self.pending_line.clear();
self.pending_line_is_stderr = false;
}
fn append_chunk(&mut self, chunk: &[u8], is_stderr: bool) {
if chunk.is_empty() && self.pending_utf8.is_empty() && self.pending_line.is_empty() {
return;
}
let was_following = self.is_following();
let mut appended = false;
if self.pending_line_is_stderr != is_stderr && !self.pending_line.is_empty() {
appended |= self.flush_pending_line();
}
self.pending_line_is_stderr = is_stderr;
self.pending_utf8.extend_from_slice(chunk);
loop {
match std::str::from_utf8(&self.pending_utf8) {
Ok(valid) => {
self.pending_line.push_str(valid);
self.pending_utf8.clear();
break;
}
Err(err) => {
let valid_up_to = err.valid_up_to();
if valid_up_to > 0 {
if let Ok(valid) = std::str::from_utf8(&self.pending_utf8[..valid_up_to]) {
self.pending_line.push_str(valid);
} else {
let slice = &self.pending_utf8[..valid_up_to];
let owned = String::from_utf8_lossy(slice);
self.pending_line.push_str(&owned);
}
self.pending_utf8.drain(..valid_up_to);
}
if let Some(err_len) = err.error_len() {
self.pending_line.push('�');
let drain_len = err_len.min(self.pending_utf8.len());
self.pending_utf8.drain(..drain_len);
} else {
break;
}
}
}
}
while let Some(pos) = self.pending_line.find('\n') {
let mut segment = self.pending_line[..pos].to_string();
self.pending_line.drain(..=pos);
segment.push('\n');
appended |= self.push_segment(&segment, is_stderr);
}
if appended {
self.auto_follow(was_following);
}
}
fn flush_pending_line(&mut self) -> bool {
let was_following = self.is_following();
let mut appended = false;
if !self.pending_utf8.is_empty() {
if let Ok(valid) = std::str::from_utf8(&self.pending_utf8) {
self.pending_line.push_str(valid);
} else {
let owned = String::from_utf8_lossy(&self.pending_utf8);
self.pending_line.push_str(&owned);
}
self.pending_utf8.clear();
}
if self.pending_line.is_empty() {
return false;
}
let segment = std::mem::take(&mut self.pending_line);
appended |= self.push_segment(&segment, self.pending_line_is_stderr);
if appended {
self.auto_follow(was_following);
}
appended
}
fn finalize(&mut self, exit_code: Option<i32>, duration: Duration) {
let _ = self.flush_pending_line();
self.running = false;
self.exit_code = exit_code;
self.duration = Some(duration);
}
fn push_segment(&mut self, segment: &str, is_stderr: bool) -> bool {
let mut appended = false;
let normalized = normalize_overwrite_sequences(segment);
for raw_line in normalized.split_inclusive('\n') {
let line_text = raw_line.trim_end_matches('\n');
let sanitized = sanitize_for_tui(
line_text,
SanitizeMode::AnsiPreserving,
SanitizeOptions { expand_tabs: true, ..Default::default() },
);
let mut line = ansi_escape_line(&sanitized);
if is_stderr {
let warn = crate::colors::warning();
for span in line.spans.iter_mut() {
if span.style.fg.is_none() {
span.style.fg = Some(warn);
}
}
}
self.lines.push_back(line);
appended = true;
if self.lines.len() > TERMINAL_MAX_LINES {
self.lines.pop_front();
self.truncated = true;
}
}
appended
}
}
fn format_duration_short(duration: Duration) -> String {
if duration.as_secs() >= 60 {
format!("{:.1}m", duration.as_secs_f32() / 60.0)
} else if duration.as_secs() >= 1 {
format!("{:.1}s", duration.as_secs_f32())
} else if duration.as_millis() >= 1 {
format!("{}ms", duration.as_millis())
} else {
format!("{}µs", duration.as_micros())
}
}
#[derive(Default)]
struct PerfState {
enabled: bool,
@@ -0,0 +1,67 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::ChatWidget;
use crate::app_event::AppEvent;
pub(super) fn handle_terminal_key(chat: &mut ChatWidget<'_>, key_event: KeyEvent) -> bool {
let Some(id) = chat.terminal_overlay_id() else {
return false;
};
match key_event.code {
KeyCode::Up => {
chat.terminal_scroll_lines(-1);
true
}
KeyCode::Down => {
chat.terminal_scroll_lines(1);
true
}
KeyCode::PageUp => {
chat.terminal_scroll_page(-1);
true
}
KeyCode::PageDown => {
chat.terminal_scroll_page(1);
true
}
KeyCode::Home => {
chat.terminal_scroll_to_top();
true
}
KeyCode::End => {
chat.terminal_scroll_to_bottom();
true
}
KeyCode::Esc => {
if chat.terminal_is_running() {
// Ignore Esc while running; cancellation is managed via Ctrl+C.
true
} else {
chat.close_terminal_overlay();
true
}
}
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
if chat.terminal_is_running() {
chat.app_event_tx.send(AppEvent::TerminalCancel { id });
true
} else {
false
}
}
KeyCode::Char('r') | KeyCode::Char('R') => {
if chat.terminal_is_running() {
// Ignore while running; rerun only when idle.
true
} else if chat.terminal_prepare_rerun(id) {
chat.app_event_tx.send(AppEvent::TerminalRerun { id });
true
} else {
true
}
}
_ => false,
}
}
+1 -1
View File
@@ -4886,7 +4886,7 @@ const STREAMING_EXIT_CODE: i32 = i32::MIN;
/// Normalize common TTY overwrite sequences within a text block so that
/// progress lines using carriage returns, backspaces, or ESC[K erase behave as
/// expected when rendered in a pure-buffered UI (no cursor movement).
fn normalize_overwrite_sequences(input: &str) -> String {
pub(crate) fn normalize_overwrite_sequences(input: &str) -> String {
// Process per line, but keep CR/BS/CSI semantics within logical lines.
// Treat "\n" as committing a line and resetting the cursor.
let mut out = String::with_capacity(input.len());