mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Add terminal overlay and agent install flow
This commit is contained in:
+239
-1
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user