feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`: Today, Codex CLI is written in TypeScript and requires Node.js 22+ to run it. For a number of users, this runtime requirement inhibits adoption: they would be better served by a standalone executable. As maintainers, we want Codex to run efficiently in a wide range of environments with minimal overhead. We also want to take advantage of operating system-specific APIs to provide better sandboxing, where possible. To that end, we are moving forward with a Rust implementation of Codex CLI contained in this folder, which has the following benefits: - The CLI compiles to small, standalone, platform-specific binaries. - Can make direct, native calls to [seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and [landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in order to support sandboxing on Linux. - No runtime garbage collection, resulting in lower memory consumption and better, more predictable performance. Currently, the Rust implementation is materially behind the TypeScript implementation in functionality, so continue to use the TypeScript implmentation for the time being. We will publish native executables via GitHub Releases as soon as we feel the Rust version is usable.
This commit is contained in:
194
codex-rs/tui/src/app.rs
Normal file
194
codex-rs/tui/src/app.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
use crate::tui;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
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;
|
||||
use std::sync::mpsc::channel;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
/// Top‑level application state – which full‑screen view is currently active.
|
||||
enum AppState {
|
||||
/// The main chat UI is visible.
|
||||
Chat,
|
||||
/// The start‑up warning that recommends running codex inside a Git repo.
|
||||
GitWarning { screen: GitWarningScreen },
|
||||
}
|
||||
|
||||
pub(crate) struct App<'a> {
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_rx: Receiver<AppEvent>,
|
||||
chat_widget: ChatWidget<'a>,
|
||||
app_state: AppState,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
pub(crate) fn new(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
initial_prompt: Option<String>,
|
||||
show_git_warning: bool,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
model: Option<String>,
|
||||
) -> Self {
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
|
||||
// Spawn a dedicated thread for reading the crossterm event loop and
|
||||
// re-publishing the events as AppEvents, as appropriate.
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(event) = crossterm::event::read() {
|
||||
let app_event = match event {
|
||||
crossterm::event::Event::Key(key_event) => AppEvent::KeyEvent(key_event),
|
||||
crossterm::event::Event::Resize(_, _) => AppEvent::Redraw,
|
||||
crossterm::event::Event::FocusGained
|
||||
| crossterm::event::Event::FocusLost
|
||||
| crossterm::event::Event::Mouse(_)
|
||||
| crossterm::event::Event::Paste(_) => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = app_event_tx.send(app_event) {
|
||||
tracing::error!("failed to send event: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let chat_widget = ChatWidget::new(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
app_event_tx.clone(),
|
||||
initial_prompt.clone(),
|
||||
initial_images,
|
||||
model,
|
||||
);
|
||||
|
||||
let app_state = if show_git_warning {
|
||||
AppState::GitWarning {
|
||||
screen: GitWarningScreen::new(),
|
||||
}
|
||||
} else {
|
||||
AppState::Chat
|
||||
};
|
||||
|
||||
Self {
|
||||
app_event_tx,
|
||||
app_event_rx,
|
||||
chat_widget,
|
||||
app_state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone of the internal event sender so external tasks (e.g. log bridge)
|
||||
/// can inject `AppEvent`s.
|
||||
pub fn event_sender(&self) -> Sender<AppEvent> {
|
||||
self.app_event_tx.clone()
|
||||
}
|
||||
|
||||
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();
|
||||
app_event_tx.send(AppEvent::Redraw).unwrap();
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
match event {
|
||||
AppEvent::Redraw => {
|
||||
self.draw_next_frame(terminal)?;
|
||||
}
|
||||
AppEvent::KeyEvent(key_event) => {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.chat_widget.submit_op(Op::Interrupt);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest).unwrap();
|
||||
}
|
||||
_ => {
|
||||
self.dispatch_key_event(key_event);
|
||||
}
|
||||
};
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.dispatch_codex_event(event);
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
break;
|
||||
}
|
||||
AppEvent::CodexOp(op) => {
|
||||
if matches!(self.app_state, AppState::Chat) {
|
||||
self.chat_widget.submit_op(op);
|
||||
}
|
||||
}
|
||||
AppEvent::LatestLog(line) => {
|
||||
if matches!(self.app_state, AppState::Chat) {
|
||||
let _ = self.chat_widget.update_latest_log(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat => {
|
||||
terminal.draw(|frame| frame.render_widget_ref(&self.chat_widget, frame.area()))?;
|
||||
}
|
||||
AppState::GitWarning { screen } => {
|
||||
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a KeyEvent to the current view and let it decide what to do
|
||||
/// with it.
|
||||
fn dispatch_key_event(&mut self, key_event: KeyEvent) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat => {
|
||||
if let Err(e) = self.chat_widget.handle_key_event(key_event) {
|
||||
tracing::error!("SendError: {e}");
|
||||
}
|
||||
}
|
||||
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
|
||||
GitWarningOutcome::Continue => {
|
||||
self.app_state = AppState::Chat;
|
||||
let _ = self.app_event_tx.send(AppEvent::Redraw);
|
||||
}
|
||||
GitWarningOutcome::Quit => {
|
||||
let _ = self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
GitWarningOutcome::None => {
|
||||
// do nothing
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
if matches!(self.app_state, AppState::Chat) {
|
||||
if let Err(e) = self.chat_widget.handle_codex_event(event) {
|
||||
tracing::error!("SendError: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
codex-rs/tui/src/app_event.rs
Normal file
17
codex-rs/tui/src/app_event.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use codex_core::protocol::Event;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
Redraw,
|
||||
KeyEvent(KeyEvent),
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
|
||||
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(codex_core::protocol::Op),
|
||||
|
||||
/// Latest formatted log line emitted by `tracing`.
|
||||
LatestLog(String),
|
||||
}
|
||||
303
codex-rs/tui/src/bottom_pane.rs
Normal file
303
codex-rs/tui/src/bottom_pane.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! Bottom pane widget for the chat UI.
|
||||
//!
|
||||
//! This widget owns everything that is rendered in the terminal's lower
|
||||
//! portion: either the multiline [`TextArea`] for user input or an active
|
||||
//! [`UserApprovalWidget`] modal. All state and key-handling logic that is
|
||||
//! specific to those UI elements lives here so that the parent
|
||||
//! [`ChatWidget`] only has to forward events and render calls.
|
||||
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tui_textarea::Input;
|
||||
use tui_textarea::Key;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crate::user_approval_widget::UserApprovalWidget;
|
||||
|
||||
/// Minimum number of visible text rows inside the textarea.
|
||||
const MIN_TEXTAREA_ROWS: usize = 3;
|
||||
/// Number of terminal rows consumed by the textarea border (top + bottom).
|
||||
const TEXTAREA_BORDER_LINES: u16 = 2;
|
||||
|
||||
/// Result returned by [`BottomPane::handle_key_event`].
|
||||
pub enum InputResult {
|
||||
/// The user pressed <Enter> - the contained string is the message that
|
||||
/// should be forwarded to the agent and appended to the conversation
|
||||
/// history.
|
||||
Submitted(String),
|
||||
None,
|
||||
}
|
||||
|
||||
/// Internal state of the bottom pane.
|
||||
///
|
||||
/// `ApprovalModal` owns a `current` widget that is guaranteed to exist while
|
||||
/// this variant is active. Additional queued modals are stored in `queue`.
|
||||
enum PaneState<'a> {
|
||||
StatusIndicator {
|
||||
view: StatusIndicatorWidget,
|
||||
},
|
||||
TextInput,
|
||||
ApprovalModal {
|
||||
current: UserApprovalWidget<'a>,
|
||||
queue: Vec<UserApprovalWidget<'a>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Everything that is drawn in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane<'a> {
|
||||
/// Multiline input widget (always kept around so its history/yank buffer
|
||||
/// is preserved even while a modal is open).
|
||||
textarea: TextArea<'a>,
|
||||
|
||||
/// Current state (text input vs. approval modal).
|
||||
state: PaneState<'a>,
|
||||
|
||||
/// Channel used to notify the application that a redraw is required.
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
|
||||
has_input_focus: bool,
|
||||
|
||||
is_task_running: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
pub(crate) app_event_tx: Sender<AppEvent>,
|
||||
pub(crate) has_input_focus: bool,
|
||||
}
|
||||
|
||||
impl BottomPane<'_> {
|
||||
pub fn new(
|
||||
BottomPaneParams {
|
||||
app_event_tx,
|
||||
has_input_focus,
|
||||
}: BottomPaneParams,
|
||||
) -> Self {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.set_placeholder_text("send a message");
|
||||
textarea.set_cursor_line_style(Style::default());
|
||||
update_border_for_input_focus(&mut textarea, has_input_focus);
|
||||
|
||||
Self {
|
||||
textarea,
|
||||
state: PaneState::TextInput,
|
||||
app_event_tx,
|
||||
has_input_focus,
|
||||
is_task_running: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status indicator with the latest log line. Only effective
|
||||
/// when the pane is currently in `StatusIndicator` mode.
|
||||
pub(crate) fn update_status_text(&mut self, text: String) -> Result<(), SendError<AppEvent>> {
|
||||
if let PaneState::StatusIndicator { view } = &mut self.state {
|
||||
view.update_text(text);
|
||||
self.request_redraw()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_input_focus(&mut self, has_input_focus: bool) {
|
||||
self.has_input_focus = has_input_focus;
|
||||
update_border_for_input_focus(&mut self.textarea, has_input_focus);
|
||||
}
|
||||
|
||||
/// Forward a key event to the appropriate child widget.
|
||||
pub fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> Result<InputResult, SendError<AppEvent>> {
|
||||
match &mut self.state {
|
||||
PaneState::StatusIndicator { view } => {
|
||||
if view.handle_key_event(key_event)? {
|
||||
self.request_redraw()?;
|
||||
}
|
||||
Ok(InputResult::None)
|
||||
}
|
||||
PaneState::ApprovalModal { current, queue } => {
|
||||
// While in modal mode we always consume the Event.
|
||||
current.handle_key_event(key_event)?;
|
||||
|
||||
// If the modal has finished, either advance to the next one
|
||||
// in the queue or fall back to the textarea.
|
||||
if current.is_complete() {
|
||||
if !queue.is_empty() {
|
||||
// Replace `current` with the first queued modal and
|
||||
// drop the old value.
|
||||
*current = queue.remove(0);
|
||||
} else if self.is_task_running {
|
||||
let desired_height = {
|
||||
let text_rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS);
|
||||
text_rows as u16 + TEXTAREA_BORDER_LINES
|
||||
};
|
||||
|
||||
self.state = PaneState::StatusIndicator {
|
||||
view: StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
desired_height,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
self.state = PaneState::TextInput;
|
||||
}
|
||||
}
|
||||
|
||||
// Always request a redraw while a modal is up to ensure the
|
||||
// UI stays responsive.
|
||||
self.request_redraw()?;
|
||||
Ok(InputResult::None)
|
||||
}
|
||||
PaneState::TextInput => {
|
||||
match key_event.into() {
|
||||
Input {
|
||||
key: Key::Enter,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
} => {
|
||||
let text = self.textarea.lines().join("\n");
|
||||
// Clear the textarea (there is no dedicated clear API).
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
self.request_redraw()?;
|
||||
Ok(InputResult::Submitted(text))
|
||||
}
|
||||
input => {
|
||||
self.textarea.input(input);
|
||||
self.request_redraw()?;
|
||||
Ok(InputResult::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_task_running(&mut self, is_task_running: bool) -> Result<(), SendError<AppEvent>> {
|
||||
self.is_task_running = is_task_running;
|
||||
|
||||
match self.state {
|
||||
PaneState::TextInput => {
|
||||
if is_task_running {
|
||||
self.state = PaneState::StatusIndicator {
|
||||
view: StatusIndicatorWidget::new(self.app_event_tx.clone(), {
|
||||
let text_rows =
|
||||
self.textarea.lines().len().max(MIN_TEXTAREA_ROWS) as u16;
|
||||
text_rows + TEXTAREA_BORDER_LINES
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
PaneState::StatusIndicator { .. } => {
|
||||
if is_task_running {
|
||||
return Ok(());
|
||||
} else {
|
||||
self.state = PaneState::TextInput;
|
||||
}
|
||||
}
|
||||
PaneState::ApprovalModal { .. } => {
|
||||
// Do not change state if a modal is showing.
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
self.request_redraw()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue a new approval request coming from the agent.
|
||||
///
|
||||
/// Returns `true` when this is the *first* modal - in that case the caller
|
||||
/// should trigger a redraw so that the modal becomes visible.
|
||||
pub fn push_approval_request(&mut self, request: ApprovalRequest) -> bool {
|
||||
let widget = UserApprovalWidget::new(request, self.app_event_tx.clone());
|
||||
|
||||
match &mut self.state {
|
||||
PaneState::StatusIndicator { .. } => {
|
||||
self.state = PaneState::ApprovalModal {
|
||||
current: widget,
|
||||
queue: Vec::new(),
|
||||
};
|
||||
true // Needs redraw so the modal appears.
|
||||
}
|
||||
PaneState::TextInput => {
|
||||
// Transition to modal state with an empty queue.
|
||||
self.state = PaneState::ApprovalModal {
|
||||
current: widget,
|
||||
queue: Vec::new(),
|
||||
};
|
||||
true // Needs redraw so the modal appears.
|
||||
}
|
||||
PaneState::ApprovalModal { queue, .. } => {
|
||||
queue.push(widget);
|
||||
false // Already in modal mode - no redraw required.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_redraw(&self) -> Result<(), SendError<AppEvent>> {
|
||||
self.app_event_tx.send(AppEvent::Redraw)
|
||||
}
|
||||
|
||||
/// Height (terminal rows) required to render the pane in its current
|
||||
/// state (modal or textarea).
|
||||
pub fn required_height(&self, area: &Rect) -> u16 {
|
||||
match &self.state {
|
||||
PaneState::StatusIndicator { view } => view.get_height(),
|
||||
PaneState::ApprovalModal { current, .. } => current.get_height(area),
|
||||
PaneState::TextInput => {
|
||||
let text_rows = self.textarea.lines().len();
|
||||
std::cmp::max(text_rows, MIN_TEXTAREA_ROWS) as u16 + TEXTAREA_BORDER_LINES
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match &self.state {
|
||||
PaneState::StatusIndicator { view } => view.render_ref(area, buf),
|
||||
PaneState::ApprovalModal { current, .. } => current.render(area, buf),
|
||||
PaneState::TextInput => self.textarea.render(area, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_border_for_input_focus(textarea: &mut TextArea, has_input_focus: bool) {
|
||||
let (title, border_style) = if has_input_focus {
|
||||
(
|
||||
"use Enter to send for now (Ctrl‑D to quit)",
|
||||
Style::default().dim(),
|
||||
)
|
||||
} else {
|
||||
("", Style::default())
|
||||
};
|
||||
let right_title = if has_input_focus {
|
||||
Line::from("press enter to send").alignment(Alignment::Right)
|
||||
} else {
|
||||
Line::from("")
|
||||
};
|
||||
|
||||
textarea.set_block(
|
||||
ratatui::widgets::Block::default()
|
||||
.title_bottom(title)
|
||||
.title_bottom(right_title)
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style),
|
||||
);
|
||||
}
|
||||
387
codex-rs/tui/src/chatwidget.rs
Normal file
387
codex-rs/tui/src/chatwidget.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Direction;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::conversation_history_widget::ConversationHistoryWidget;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
conversation_history: ConversationHistoryWidget,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
input_focus: InputFocus,
|
||||
approval_policy: AskForApproval,
|
||||
cwd: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum InputFocus {
|
||||
HistoryPane,
|
||||
BottomPane,
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
pub(crate) fn new(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
model: Option<String>,
|
||||
) -> Self {
|
||||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||||
|
||||
// Determine the current working directory up‑front so we can display
|
||||
// it alongside the Session information when the session is
|
||||
// initialised.
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
|
||||
let app_event_tx_clone = app_event_tx.clone();
|
||||
// Create the Codex asynchronously so the UI loads as quickly as possible.
|
||||
tokio::spawn(async move {
|
||||
let (codex, session_event, _ctrl_c) =
|
||||
match init_codex(approval_policy, sandbox_policy, model).await {
|
||||
Ok(vals) => vals,
|
||||
Err(e) => {
|
||||
// TODO(mbolin): This error needs to be surfaced to the user.
|
||||
tracing::error!("failed to initialize codex: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Forward the captured `SessionInitialized` event that was consumed
|
||||
// inside `init_codex()` so it can be rendered in the UI.
|
||||
if let Err(e) = app_event_tx_clone.send(AppEvent::CodexEvent(session_event.clone())) {
|
||||
tracing::error!("failed to send SessionInitialized event: {e}");
|
||||
}
|
||||
let codex = Arc::new(codex);
|
||||
let codex_clone = codex.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(op) = codex_op_rx.recv().await {
|
||||
let id = codex_clone.submit(op).await;
|
||||
if let Err(e) = id {
|
||||
tracing::error!("failed to submit op: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Ok(event) = codex.next_event().await {
|
||||
app_event_tx_clone
|
||||
.send(AppEvent::CodexEvent(event))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send event: {e}");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let mut chat_widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
conversation_history: ConversationHistoryWidget::new(),
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
}),
|
||||
input_focus: InputFocus::BottomPane,
|
||||
approval_policy,
|
||||
cwd: cwd.clone(),
|
||||
};
|
||||
|
||||
let _ = chat_widget.submit_welcome_message();
|
||||
|
||||
if initial_prompt.is_some() || !initial_images.is_empty() {
|
||||
let text = initial_prompt.unwrap_or_default();
|
||||
let _ = chat_widget.submit_user_message_with_images(text, initial_images);
|
||||
}
|
||||
|
||||
chat_widget
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
// Special-case <tab>: does not get dispatched to child components.
|
||||
if matches!(key_event.code, crossterm::event::KeyCode::Tab) {
|
||||
self.input_focus = match self.input_focus {
|
||||
InputFocus::HistoryPane => InputFocus::BottomPane,
|
||||
InputFocus::BottomPane => InputFocus::HistoryPane,
|
||||
};
|
||||
self.conversation_history
|
||||
.set_input_focus(self.input_focus == InputFocus::HistoryPane);
|
||||
self.bottom_pane
|
||||
.set_input_focus(self.input_focus == InputFocus::BottomPane);
|
||||
self.request_redraw()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.input_focus {
|
||||
InputFocus::HistoryPane => {
|
||||
let needs_redraw = self.conversation_history.handle_key_event(key_event);
|
||||
if needs_redraw {
|
||||
self.request_redraw()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
InputFocus::BottomPane => {
|
||||
match self.bottom_pane.handle_key_event(key_event)? {
|
||||
InputResult::Submitted(text) => {
|
||||
// Special client‑side commands start with a leading slash.
|
||||
let trimmed = text.trim();
|
||||
|
||||
match trimmed {
|
||||
"q" => {
|
||||
// Gracefully request application shutdown.
|
||||
let _ = self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
"/clear" => {
|
||||
// Clear the current conversation history without exiting.
|
||||
self.conversation_history.clear();
|
||||
self.request_redraw()?;
|
||||
}
|
||||
_ => {
|
||||
self.submit_user_message(text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
InputResult::None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_welcome_message(&mut self) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
self.handle_codex_event(Event {
|
||||
id: "welcome".to_string(),
|
||||
msg: EventMsg::AgentMessage {
|
||||
message: "Welcome to codex!".to_string(),
|
||||
},
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn submit_user_message(
|
||||
&mut self,
|
||||
text: String,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
// Forward to codex and update conversation history.
|
||||
self.submit_user_message_with_images(text, vec![])
|
||||
}
|
||||
|
||||
fn submit_user_message_with_images(
|
||||
&mut self,
|
||||
text: String,
|
||||
image_paths: Vec<std::path::PathBuf>,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
|
||||
if !text.is_empty() {
|
||||
items.push(InputItem::Text { text: text.clone() });
|
||||
}
|
||||
|
||||
for path in image_paths {
|
||||
items.push(InputItem::LocalImage { path });
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.codex_op_tx
|
||||
.send(Op::UserInput { items })
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send message: {e}");
|
||||
});
|
||||
|
||||
// Only show text portion in conversation history for now.
|
||||
if !text.is_empty() {
|
||||
self.conversation_history.add_user_message(text);
|
||||
}
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(
|
||||
&mut self,
|
||||
event: Event,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
let Event { id, msg } = event;
|
||||
match msg {
|
||||
EventMsg::SessionConfigured { model } => {
|
||||
// Record session information at the top of the conversation.
|
||||
self.conversation_history.add_session_info(
|
||||
model,
|
||||
self.cwd.clone(),
|
||||
self.approval_policy,
|
||||
);
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::AgentMessage { message } => {
|
||||
self.conversation_history.add_agent_message(message);
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
self.bottom_pane.set_task_running(true)?;
|
||||
self.conversation_history
|
||||
.add_background_event(format!("task {id} started"));
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::TaskComplete => {
|
||||
self.bottom_pane.set_task_running(false)?;
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::Error { message } => {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("Error: {message}"));
|
||||
self.bottom_pane.set_task_running(false)?;
|
||||
}
|
||||
EventMsg::ExecApprovalRequest {
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
} => {
|
||||
let request = ApprovalRequest::Exec {
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
};
|
||||
let needs_redraw = self.bottom_pane.push_approval_request(request);
|
||||
if needs_redraw {
|
||||
self.request_redraw()?;
|
||||
}
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest {
|
||||
changes,
|
||||
reason,
|
||||
grant_root,
|
||||
} => {
|
||||
// ------------------------------------------------------------------
|
||||
// Before we even prompt the user for approval we surface the patch
|
||||
// summary in the main conversation so that the dialog appears in a
|
||||
// sensible chronological order:
|
||||
// (1) codex → proposes patch (HistoryCell::PendingPatch)
|
||||
// (2) UI → asks for approval (BottomPane)
|
||||
// This mirrors how command execution is shown (command begins →
|
||||
// approval dialog) and avoids surprising the user with a modal
|
||||
// prompt before they have seen *what* is being requested.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
self.conversation_history
|
||||
.add_patch_event(PatchEventType::ApprovalRequest, changes);
|
||||
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
|
||||
// Now surface the approval request in the BottomPane as before.
|
||||
let request = ApprovalRequest::ApplyPatch {
|
||||
id,
|
||||
reason,
|
||||
grant_root,
|
||||
};
|
||||
let _needs_redraw = self.bottom_pane.push_approval_request(request);
|
||||
// Redraw is always need because the history has changed.
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::ExecCommandBegin {
|
||||
call_id, command, ..
|
||||
} => {
|
||||
self.conversation_history
|
||||
.add_active_exec_command(call_id, command);
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::PatchApplyBegin {
|
||||
call_id: _,
|
||||
auto_approved,
|
||||
changes,
|
||||
} => {
|
||||
// Even when a patch is auto‑approved we still display the
|
||||
// summary so the user can follow along.
|
||||
self.conversation_history
|
||||
.add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes);
|
||||
if !auto_approved {
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
}
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::ExecCommandEnd {
|
||||
call_id,
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
..
|
||||
} => {
|
||||
self.conversation_history
|
||||
.record_completed_exec_command(call_id, stdout, stderr, exit_code);
|
||||
self.request_redraw()?;
|
||||
}
|
||||
event => {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("{event:?}"));
|
||||
self.request_redraw()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the live log preview while a task is running.
|
||||
pub(crate) fn update_latest_log(
|
||||
&mut self,
|
||||
line: String,
|
||||
) -> std::result::Result<(), std::sync::mpsc::SendError<AppEvent>> {
|
||||
// Forward only if we are currently showing the status indicator.
|
||||
self.bottom_pane.update_status_text(line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
self.app_event_tx.send(AppEvent::Redraw)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forward an `Op` directly to codex.
|
||||
pub(crate) fn submit_op(&self, op: Op) {
|
||||
if let Err(e) = self.codex_op_tx.send(op) {
|
||||
tracing::error!("failed to submit op: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let bottom_height = self.bottom_pane.required_height(&area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(bottom_height)])
|
||||
.split(area);
|
||||
|
||||
self.conversation_history.render(chunks[0], buf);
|
||||
(&self.bottom_pane).render(chunks[1], buf);
|
||||
}
|
||||
}
|
||||
41
codex-rs/tui/src/cli.rs
Normal file
41
codex-rs/tui/src/cli.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use clap::Parser;
|
||||
use codex_core::ApprovalModeCliArg;
|
||||
use codex_core::SandboxModeCliArg;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Optional user prompt to start the session.
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Optional image(s) to attach to the initial prompt.
|
||||
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
|
||||
pub images: Vec<PathBuf>,
|
||||
|
||||
/// Model the agent should use.
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Configure when the model requires human approval before executing a command.
|
||||
#[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)]
|
||||
pub approval_policy: ApprovalModeCliArg,
|
||||
|
||||
/// Configure the process restrictions when a command is executed.
|
||||
///
|
||||
/// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux.
|
||||
#[arg(long = "sandbox", short = 's', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)]
|
||||
pub sandbox_policy: SandboxModeCliArg,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, -s network-and-file-write-restricted)
|
||||
#[arg(long = "full-auto", default_value_t = true)]
|
||||
pub full_auto: bool,
|
||||
|
||||
/// Convenience alias for supervised sandboxed execution (-a unless-allow-listed, -s network-and-file-write-restricted)
|
||||
#[arg(long = "suggest", default_value_t = false)]
|
||||
pub suggest: bool,
|
||||
}
|
||||
379
codex-rs/tui/src/conversation_history_widget.rs
Normal file
379
codex-rs/tui/src/conversation_history_widget.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use codex_core::protocol::FileChange;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::*;
|
||||
use std::cell::Cell as StdCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct ConversationHistoryWidget {
|
||||
history: Vec<HistoryCell>,
|
||||
scroll_position: usize,
|
||||
/// Number of lines the last time render_ref() was called
|
||||
num_rendered_lines: StdCell<usize>,
|
||||
/// The height of the viewport last time render_ref() was called
|
||||
last_viewport_height: StdCell<usize>,
|
||||
has_input_focus: bool,
|
||||
}
|
||||
|
||||
impl ConversationHistoryWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
history: Vec::new(),
|
||||
scroll_position: usize::MAX,
|
||||
num_rendered_lines: StdCell::new(0),
|
||||
last_viewport_height: StdCell::new(0),
|
||||
has_input_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_input_focus(&mut self, has_input_focus: bool) {
|
||||
self.has_input_focus = has_input_focus;
|
||||
}
|
||||
|
||||
/// Returns true if it needs a redraw.
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> bool {
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.scroll_up();
|
||||
true
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.scroll_down();
|
||||
true
|
||||
}
|
||||
KeyCode::PageUp | KeyCode::Char('b') | KeyCode::Char('u') | KeyCode::Char('U') => {
|
||||
self.scroll_page_up();
|
||||
true
|
||||
}
|
||||
KeyCode::PageDown | KeyCode::Char(' ') | KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
self.scroll_page_down();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self) {
|
||||
// If a user is scrolling up from the "stick to bottom" mode, we
|
||||
// need to scroll them back such that they move just one line up.
|
||||
// This requires us to care about how tall the screen is.
|
||||
if self.scroll_position == usize::MAX {
|
||||
self.scroll_position = self
|
||||
.num_rendered_lines
|
||||
.get()
|
||||
.saturating_sub(self.last_viewport_height.get());
|
||||
}
|
||||
|
||||
self.scroll_position = self.scroll_position.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
// If we're already pinned to the bottom there's nothing to do.
|
||||
if self.scroll_position == usize::MAX {
|
||||
return;
|
||||
}
|
||||
|
||||
let viewport_height = self.last_viewport_height.get().max(1);
|
||||
let num_lines = self.num_rendered_lines.get();
|
||||
|
||||
// Compute the maximum explicit scroll offset that still shows a full
|
||||
// viewport. This mirrors the calculation in `scroll_page_down()` and
|
||||
// in the render path.
|
||||
let max_scroll = num_lines.saturating_sub(viewport_height).saturating_add(1);
|
||||
|
||||
let new_pos = self.scroll_position.saturating_add(1);
|
||||
|
||||
if new_pos >= max_scroll {
|
||||
// Reached (or passed) the bottom – switch to stick‑to‑bottom mode
|
||||
// so that additional output keeps the view pinned automatically.
|
||||
self.scroll_position = usize::MAX;
|
||||
} else {
|
||||
self.scroll_position = new_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll up by one full viewport height (Page Up).
|
||||
fn scroll_page_up(&mut self) {
|
||||
let viewport_height = self.last_viewport_height.get().max(1);
|
||||
|
||||
// If we are currently in the "stick to bottom" mode, first convert the
|
||||
// implicit scroll position (`usize::MAX`) into an explicit offset that
|
||||
// represents the very bottom of the scroll region. This mirrors the
|
||||
// logic from `scroll_up()`.
|
||||
if self.scroll_position == usize::MAX {
|
||||
self.scroll_position = self
|
||||
.num_rendered_lines
|
||||
.get()
|
||||
.saturating_sub(viewport_height);
|
||||
}
|
||||
|
||||
// Move up by a full page.
|
||||
self.scroll_position = self.scroll_position.saturating_sub(viewport_height);
|
||||
}
|
||||
|
||||
/// Scroll down by one full viewport height (Page Down).
|
||||
fn scroll_page_down(&mut self) {
|
||||
// Nothing to do if we're already stuck to the bottom.
|
||||
if self.scroll_position == usize::MAX {
|
||||
return;
|
||||
}
|
||||
|
||||
let viewport_height = self.last_viewport_height.get().max(1);
|
||||
let num_lines = self.num_rendered_lines.get();
|
||||
|
||||
// Calculate the maximum explicit scroll offset that is still within
|
||||
// range. This matches the logic in `scroll_down()` and the render
|
||||
// method.
|
||||
let max_scroll = num_lines.saturating_sub(viewport_height).saturating_add(1);
|
||||
|
||||
// Attempt to move down by a full page.
|
||||
let new_pos = self.scroll_position.saturating_add(viewport_height);
|
||||
|
||||
if new_pos >= max_scroll {
|
||||
// We have reached (or passed) the bottom – switch back to
|
||||
// automatic stick‑to‑bottom mode so that subsequent output keeps
|
||||
// the viewport pinned.
|
||||
self.scroll_position = usize::MAX;
|
||||
} else {
|
||||
self.scroll_position = new_pos;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_position = usize::MAX;
|
||||
}
|
||||
|
||||
pub fn add_user_message(&mut self, message: String) {
|
||||
self.add_to_history(HistoryCell::new_user_prompt(message));
|
||||
}
|
||||
|
||||
pub fn add_agent_message(&mut self, message: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_message(message));
|
||||
}
|
||||
|
||||
pub fn add_background_event(&mut self, message: String) {
|
||||
self.add_to_history(HistoryCell::new_background_event(message));
|
||||
}
|
||||
|
||||
/// Add a pending patch entry (before user approval).
|
||||
pub fn add_patch_event(
|
||||
&mut self,
|
||||
event_type: PatchEventType,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
) {
|
||||
self.add_to_history(HistoryCell::new_patch_event(event_type, changes));
|
||||
}
|
||||
|
||||
pub fn add_session_info(
|
||||
&mut self,
|
||||
model: String,
|
||||
cwd: std::path::PathBuf,
|
||||
approval_policy: codex_core::protocol::AskForApproval,
|
||||
) {
|
||||
self.add_to_history(HistoryCell::new_session_info(model, cwd, approval_policy));
|
||||
}
|
||||
|
||||
pub fn add_active_exec_command(&mut self, call_id: String, command: Vec<String>) {
|
||||
self.add_to_history(HistoryCell::new_active_exec_command(call_id, command));
|
||||
}
|
||||
|
||||
fn add_to_history(&mut self, cell: HistoryCell) {
|
||||
self.history.push(cell);
|
||||
}
|
||||
|
||||
/// Remove all history entries and reset scrolling.
|
||||
pub fn clear(&mut self) {
|
||||
self.history.clear();
|
||||
self.scroll_position = usize::MAX;
|
||||
}
|
||||
|
||||
pub fn record_completed_exec_command(
|
||||
&mut self,
|
||||
call_id: String,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
exit_code: i32,
|
||||
) {
|
||||
for cell in self.history.iter_mut() {
|
||||
if let HistoryCell::ActiveExecCommand {
|
||||
call_id: history_id,
|
||||
command,
|
||||
start,
|
||||
..
|
||||
} = cell
|
||||
{
|
||||
if &call_id == history_id {
|
||||
*cell = HistoryCell::new_completed_exec_command(
|
||||
command.clone(),
|
||||
CommandOutput {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for ConversationHistoryWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (title, border_style) = if self.has_input_focus {
|
||||
(
|
||||
"Messages (↑/↓ or j/k = line, b/u = PgUp, space/d = PgDn)",
|
||||
Style::default().fg(Color::LightYellow),
|
||||
)
|
||||
} else {
|
||||
("Messages (tab to focus)", Style::default().dim())
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Build a *window* into the history instead of cloning the entire
|
||||
// history into a brand‑new Vec every time we are asked to render.
|
||||
//
|
||||
// There can be an unbounded number of `Line` objects in the history,
|
||||
// but the terminal will only ever display `height` of them at once.
|
||||
// By materialising only the `height` lines that are scrolled into
|
||||
// view we avoid the potentially expensive clone of the full
|
||||
// conversation every frame.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Compute the inner area that will be available for the list after
|
||||
// the surrounding `Block` is drawn.
|
||||
let inner = block.inner(area);
|
||||
let viewport_height = inner.height as usize;
|
||||
|
||||
// Collect the lines that will actually be visible in the viewport
|
||||
// while keeping track of the total number of lines so the scrollbar
|
||||
// stays correct.
|
||||
let num_lines: usize = self.history.iter().map(|c| c.lines().len()).sum();
|
||||
|
||||
let max_scroll = num_lines.saturating_sub(viewport_height) + 1;
|
||||
let scroll_pos = if self.scroll_position == usize::MAX {
|
||||
max_scroll
|
||||
} else {
|
||||
self.scroll_position.min(max_scroll)
|
||||
};
|
||||
|
||||
let mut visible_lines: Vec<Line<'static>> = Vec::with_capacity(viewport_height);
|
||||
|
||||
if self.scroll_position == usize::MAX {
|
||||
// Stick‑to‑bottom mode: walk the history backwards and keep the
|
||||
// most recent `height` lines. This touches at most `height`
|
||||
// lines regardless of how large the conversation grows.
|
||||
'outer_rev: for cell in self.history.iter().rev() {
|
||||
for line in cell.lines().iter().rev() {
|
||||
visible_lines.push(line.clone());
|
||||
if visible_lines.len() == viewport_height {
|
||||
break 'outer_rev;
|
||||
}
|
||||
}
|
||||
}
|
||||
visible_lines.reverse();
|
||||
} else {
|
||||
// Arbitrary scroll position. Skip lines until we reach the
|
||||
// desired offset, then emit the next `height` lines.
|
||||
let start_line = scroll_pos;
|
||||
let mut current_index = 0usize;
|
||||
'outer_fwd: for cell in &self.history {
|
||||
for line in cell.lines() {
|
||||
if current_index >= start_line {
|
||||
visible_lines.push(line.clone());
|
||||
if visible_lines.len() == viewport_height {
|
||||
break 'outer_fwd;
|
||||
}
|
||||
}
|
||||
current_index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We track the number of lines in the struct so can let the user take over from
|
||||
// something other than usize::MAX when they start scrolling up. This could be
|
||||
// removed once we have the vec<Lines> in self.
|
||||
self.num_rendered_lines.set(num_lines);
|
||||
self.last_viewport_height.set(viewport_height);
|
||||
|
||||
// The widget takes care of drawing the `block` and computing its own
|
||||
// inner area, so we render it over the full `area`.
|
||||
// We *manually* sliced the set of `visible_lines` to fit within the
|
||||
// viewport above, so there is no need to ask the `Paragraph` widget
|
||||
// to apply an additional scroll offset. Doing so would cause the
|
||||
// content to be shifted *twice* – once by our own logic and then a
|
||||
// second time by the widget – which manifested as the entire block
|
||||
// drifting off‑screen when the user attempted to scroll.
|
||||
|
||||
let paragraph = Paragraph::new(visible_lines)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
paragraph.render(area, buf);
|
||||
|
||||
let needs_scrollbar = num_lines > viewport_height;
|
||||
if needs_scrollbar {
|
||||
let mut scroll_state = ScrollbarState::default()
|
||||
// TODO(ragona):
|
||||
// I don't totally understand this, but it appears to work exactly as expected
|
||||
// if we set the content length as the lines minus the height. Maybe I was supposed
|
||||
// to use viewport_content_length or something, but this works and I'm backing away.
|
||||
.content_length(num_lines.saturating_sub(viewport_height))
|
||||
.position(scroll_pos);
|
||||
|
||||
// Choose a thumb colour that stands out only when this pane has focus so that the
|
||||
// user’s attention is naturally drawn to the active viewport. When unfocused we show
|
||||
// a low‑contrast thumb so the scrollbar fades into the background without becoming
|
||||
// invisible.
|
||||
|
||||
let thumb_style = if self.has_input_focus {
|
||||
Style::reset().fg(Color::LightYellow)
|
||||
} else {
|
||||
Style::reset().fg(Color::Gray)
|
||||
};
|
||||
|
||||
StatefulWidget::render(
|
||||
// By default the Scrollbar widget inherits the style that was already present
|
||||
// in the underlying buffer cells. That means if a coloured line (for example a
|
||||
// background task notification that we render in blue) happens to be underneath
|
||||
// the scrollbar, the track and thumb adopt that colour and the scrollbar appears
|
||||
// to “change colour”. Explicitly setting the *track* and *thumb* styles ensures
|
||||
// we always draw the scrollbar with the same palette regardless of what content
|
||||
// is behind it.
|
||||
//
|
||||
// N.B. Only the *foreground* colour matters here because the scrollbar symbols
|
||||
// themselves are filled‐in block glyphs that completely overwrite the prior
|
||||
// character cells. We therefore leave the background at its default value so it
|
||||
// blends nicely with the surrounding `Block`.
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("↑"))
|
||||
.end_symbol(Some("↓"))
|
||||
.begin_style(Style::reset().fg(Color::DarkGray))
|
||||
.end_style(Style::reset().fg(Color::DarkGray))
|
||||
// A solid thumb so that we can colour it distinctly from the track.
|
||||
.thumb_symbol("█")
|
||||
// Apply the dynamic thumb colour computed above. We still start from
|
||||
// Style::reset() to clear any inherited modifiers.
|
||||
.thumb_style(thumb_style)
|
||||
// Thin vertical line for the track.
|
||||
.track_symbol(Some("│"))
|
||||
.track_style(Style::reset().fg(Color::DarkGray)),
|
||||
inner,
|
||||
buf,
|
||||
&mut scroll_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
62
codex-rs/tui/src/exec_command.rs
Normal file
62
codex-rs/tui/src/exec_command.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use shlex::try_join;
|
||||
|
||||
pub(crate) fn escape_command(command: &[String]) -> String {
|
||||
try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "))
|
||||
}
|
||||
|
||||
pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
|
||||
match command {
|
||||
// exactly three items
|
||||
[first, second, third]
|
||||
// first two must be "bash", "-lc"
|
||||
if first == "bash" && second == "-lc" =>
|
||||
{
|
||||
third.clone() // borrow `third`
|
||||
}
|
||||
_ => escape_command(command),
|
||||
}
|
||||
}
|
||||
|
||||
/// If `path` is absolute and inside $HOME, return the part *after* the home
|
||||
/// directory; otherwise, return the path as-is. Note if `path` is the homedir,
|
||||
/// this will return and empty path.
|
||||
pub(crate) fn relativize_to_home<P>(path: P) -> Option<PathBuf>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let path = path.as_ref();
|
||||
if !path.is_absolute() {
|
||||
// If the path is not absolute, we can’t do anything with it.
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(home_dir) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
if let Ok(rel) = path.strip_prefix(&home_dir) {
|
||||
return Some(rel.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_escape_command() {
|
||||
let args = vec!["foo".into(), "bar baz".into(), "weird&stuff".into()];
|
||||
let cmdline = escape_command(&args);
|
||||
assert_eq!(cmdline, "foo 'bar baz' 'weird&stuff'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_bash_lc_and_escape() {
|
||||
let args = vec!["bash".into(), "-lc".into(), "echo hello".into()];
|
||||
let cmdline = strip_bash_lc_and_escape(&args);
|
||||
assert_eq!(cmdline, "echo hello");
|
||||
}
|
||||
}
|
||||
122
codex-rs/tui/src/git_warning_screen.rs
Normal file
122
codex-rs/tui/src/git_warning_screen.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Full‑screen warning displayed when Codex is started outside a Git
|
||||
//! repository (unless the user passed `--allow-no-git-exec`). The screen
|
||||
//! blocks all input until the user explicitly decides whether to continue or
|
||||
//! quit.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Direction;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
const NO_GIT_ERROR: &str = "We recommend running codex inside a git repository. \
|
||||
This helps ensure that changes can be tracked and easily rolled back if necessary. \
|
||||
Do you wish to proceed?";
|
||||
|
||||
/// Result of handling a key event while the warning screen is active.
|
||||
pub(crate) enum GitWarningOutcome {
|
||||
/// User chose to proceed – switch to the main Chat UI.
|
||||
Continue,
|
||||
/// User opted to quit the application.
|
||||
Quit,
|
||||
/// No actionable key was pressed – stay on the warning screen.
|
||||
None,
|
||||
}
|
||||
|
||||
pub(crate) struct GitWarningScreen;
|
||||
|
||||
impl GitWarningScreen {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Handle a key event, returning an outcome indicating whether the user
|
||||
/// chose to continue, quit, or neither.
|
||||
pub(crate) fn handle_key_event(&self, key_event: KeyEvent) -> GitWarningOutcome {
|
||||
match key_event.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => GitWarningOutcome::Continue,
|
||||
KeyCode::Char('n') | KeyCode::Char('q') | KeyCode::Esc => GitWarningOutcome::Quit,
|
||||
_ => GitWarningOutcome::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &GitWarningScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
const MIN_WIDTH: u16 = 35;
|
||||
const MIN_HEIGHT: u16 = 15;
|
||||
// Check if the available area is too small for our popup.
|
||||
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||
// Fallback rendering: a simple abbreviated message that fits the available area.
|
||||
let fallback_message = Paragraph::new(NO_GIT_ERROR)
|
||||
.wrap(Wrap { trim: true })
|
||||
.alignment(Alignment::Center);
|
||||
fallback_message.render(area, buf);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the popup (modal) size – aim for 60 % width, 30 % height
|
||||
// but keep a sensible minimum so the content is always readable.
|
||||
let popup_width = std::cmp::max(MIN_WIDTH, (area.width as f32 * 0.6) as u16);
|
||||
let popup_height = std::cmp::max(MIN_HEIGHT, (area.height as f32 * 0.3) as u16);
|
||||
|
||||
// Center the popup in the available area.
|
||||
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
|
||||
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
|
||||
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
|
||||
|
||||
// The modal block that contains everything.
|
||||
let popup_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.title(Span::styled(
|
||||
"Warning: Not a Git repository", // bold warning title
|
||||
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
|
||||
));
|
||||
|
||||
// Obtain the inner area before rendering (render consumes the block).
|
||||
let inner = popup_block.inner(popup_area);
|
||||
popup_block.render(popup_area, buf);
|
||||
|
||||
// Split the inner area vertically into two boxes: one for the warning
|
||||
// explanation, one for the user action instructions.
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(3), Constraint::Length(3)])
|
||||
.split(inner);
|
||||
|
||||
// ----- First box: detailed warning text --------------------------------
|
||||
let text_block = Block::default().borders(Borders::ALL);
|
||||
let text_inner = text_block.inner(chunks[0]);
|
||||
text_block.render(chunks[0], buf);
|
||||
|
||||
let warning_paragraph = Paragraph::new(NO_GIT_ERROR)
|
||||
.wrap(Wrap { trim: true })
|
||||
.alignment(Alignment::Left);
|
||||
warning_paragraph.render(text_inner, buf);
|
||||
|
||||
// ----- Second box: "proceed? y/n" instructions --------------------------
|
||||
let action_block = Block::default().borders(Borders::ALL);
|
||||
let action_inner = action_block.inner(chunks[1]);
|
||||
action_block.render(chunks[1], buf);
|
||||
|
||||
let action_text = Paragraph::new("press 'y' to continue, 'n' to quit")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().add_modifier(Modifier::BOLD));
|
||||
action_text.render(action_inner, buf);
|
||||
}
|
||||
}
|
||||
271
codex-rs/tui/src/history_cell.rs
Normal file
271
codex-rs/tui/src/history_cell.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_core::protocol::FileChange;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line as RtLine;
|
||||
use ratatui::text::Span as RtSpan;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::exec_command::escape_command;
|
||||
|
||||
pub(crate) struct CommandOutput {
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) stdout: String,
|
||||
pub(crate) stderr: String,
|
||||
pub(crate) duration: Duration,
|
||||
}
|
||||
|
||||
pub(crate) enum PatchEventType {
|
||||
ApprovalRequest,
|
||||
ApplyBegin { auto_approved: bool },
|
||||
}
|
||||
|
||||
/// Represents an event to display in the conversation history. Returns its
|
||||
/// `Vec<Line<'static>>` representation to make it easier to display in a
|
||||
/// scrollable list.
|
||||
pub(crate) enum HistoryCell {
|
||||
/// Message from the user.
|
||||
UserPrompt { lines: Vec<Line<'static>> },
|
||||
|
||||
/// Message from the agent.
|
||||
AgentMessage { lines: Vec<Line<'static>> },
|
||||
|
||||
/// An exec tool call that has not finished yet.
|
||||
ActiveExecCommand {
|
||||
call_id: String,
|
||||
/// The shell command, escaped and formatted.
|
||||
command: String,
|
||||
start: Instant,
|
||||
lines: Vec<Line<'static>>,
|
||||
},
|
||||
|
||||
/// Completed exec tool call.
|
||||
CompletedExecCommand { lines: Vec<Line<'static>> },
|
||||
|
||||
/// Background event
|
||||
BackgroundEvent { lines: Vec<Line<'static>> },
|
||||
|
||||
/// Info describing the newly‑initialized session.
|
||||
SessionInfo { lines: Vec<Line<'static>> },
|
||||
|
||||
/// A pending code patch that is awaiting user approval. Mirrors the
|
||||
/// behaviour of `ActiveExecCommand` so the user sees *what* patch the
|
||||
/// model wants to apply before being prompted to approve or deny it.
|
||||
PendingPatch {
|
||||
/// Identifier so that a future `PatchApplyEnd` can update the entry
|
||||
/// with the final status (not yet implemented).
|
||||
lines: Vec<Line<'static>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl HistoryCell {
|
||||
pub(crate) fn new_user_prompt(message: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("user".cyan().bold()));
|
||||
lines.extend(message.lines().map(|l| Line::from(l.to_string())));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::UserPrompt { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_agent_message(message: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("codex".magenta().bold()));
|
||||
lines.extend(message.lines().map(|l| Line::from(l.to_string())));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::AgentMessage { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_exec_command(call_id: String, command: Vec<String>) -> Self {
|
||||
let command_escaped = escape_command(&command);
|
||||
let start = Instant::now();
|
||||
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from(vec!["command".magenta(), " running...".dim()]),
|
||||
Line::from(format!("$ {command_escaped}")),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
HistoryCell::ActiveExecCommand {
|
||||
call_id,
|
||||
command: command_escaped,
|
||||
start,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_completed_exec_command(command: String, output: CommandOutput) -> Self {
|
||||
let CommandOutput {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
duration,
|
||||
} = output;
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
// Title depends on whether we have output yet.
|
||||
let title_line = Line::from(vec![
|
||||
"command".magenta(),
|
||||
format!(" (code: {}, duration: {:?})", exit_code, duration).dim(),
|
||||
]);
|
||||
lines.push(title_line);
|
||||
|
||||
const MAX_LINES: usize = 5;
|
||||
|
||||
let src = if exit_code == 0 { stdout } else { stderr };
|
||||
|
||||
lines.push(Line::from(format!("$ {command}")));
|
||||
let mut lines_iter = src.lines();
|
||||
for raw in lines_iter.by_ref().take(MAX_LINES) {
|
||||
lines.push(ansi_escape_line(raw).dim());
|
||||
}
|
||||
let remaining = lines_iter.count();
|
||||
if remaining > 0 {
|
||||
lines.push(Line::from(format!("... {} additional lines", remaining)).dim());
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::CompletedExecCommand { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_background_event(message: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("event".dim()));
|
||||
lines.extend(message.lines().map(|l| Line::from(l.to_string()).dim()));
|
||||
lines.push(Line::from(""));
|
||||
HistoryCell::BackgroundEvent { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_session_info(
|
||||
model: String,
|
||||
cwd: std::path::PathBuf,
|
||||
approval_policy: codex_core::protocol::AskForApproval,
|
||||
) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
lines.push(Line::from("codex session:".magenta().bold()));
|
||||
lines.push(Line::from(vec!["↳ model: ".bold(), model.into()]));
|
||||
lines.push(Line::from(vec![
|
||||
"↳ cwd: ".bold(),
|
||||
cwd.display().to_string().into(),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
"↳ approval: ".bold(),
|
||||
format!("{:?}", approval_policy).into(),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::SessionInfo { lines }
|
||||
}
|
||||
|
||||
/// Create a new `PendingPatch` cell that lists the file‑level summary of
|
||||
/// a proposed patch. The summary lines should already be formatted (e.g.
|
||||
/// "A path/to/file.rs").
|
||||
pub(crate) fn new_patch_event(
|
||||
event_type: PatchEventType,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
) -> Self {
|
||||
let title = match event_type {
|
||||
PatchEventType::ApprovalRequest => "proposed patch",
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
} => "applying patch",
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: false,
|
||||
} => {
|
||||
let lines = vec![Line::from("patch applied".magenta().bold())];
|
||||
return Self::PendingPatch { lines };
|
||||
}
|
||||
};
|
||||
|
||||
let summary_lines = create_diff_summary(changes);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
// Header similar to the command formatter so patches are visually
|
||||
// distinct while still fitting the overall colour scheme.
|
||||
lines.push(Line::from(title.magenta().bold()));
|
||||
|
||||
for line in summary_lines {
|
||||
if line.starts_with('+') {
|
||||
lines.push(line.green().into());
|
||||
} else if line.starts_with('-') {
|
||||
lines.push(line.red().into());
|
||||
} else if let Some(space_idx) = line.find(' ') {
|
||||
let kind_owned = line[..space_idx].to_string();
|
||||
let rest_owned = line[space_idx + 1..].to_string();
|
||||
|
||||
let style_for = |fg: Color| Style::default().fg(fg).add_modifier(Modifier::BOLD);
|
||||
|
||||
let styled_kind = match kind_owned.as_str() {
|
||||
"A" => RtSpan::styled(kind_owned.clone(), style_for(Color::Green)),
|
||||
"D" => RtSpan::styled(kind_owned.clone(), style_for(Color::Red)),
|
||||
"M" => RtSpan::styled(kind_owned.clone(), style_for(Color::Yellow)),
|
||||
"R" | "C" => RtSpan::styled(kind_owned.clone(), style_for(Color::Cyan)),
|
||||
_ => RtSpan::raw(kind_owned.clone()),
|
||||
};
|
||||
|
||||
let styled_line =
|
||||
RtLine::from(vec![styled_kind, RtSpan::raw(" "), RtSpan::raw(rest_owned)]);
|
||||
lines.push(styled_line);
|
||||
} else {
|
||||
lines.push(Line::from(line));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::PendingPatch { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn lines(&self) -> &Vec<Line<'static>> {
|
||||
match self {
|
||||
HistoryCell::UserPrompt { lines, .. }
|
||||
| HistoryCell::AgentMessage { lines, .. }
|
||||
| HistoryCell::BackgroundEvent { lines, .. }
|
||||
| HistoryCell::SessionInfo { lines, .. }
|
||||
| HistoryCell::ActiveExecCommand { lines, .. }
|
||||
| HistoryCell::CompletedExecCommand { lines, .. }
|
||||
| HistoryCell::PendingPatch { lines, .. } => lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
|
||||
// Build a concise, human‑readable summary list similar to the
|
||||
// `git status` short format so the user can reason about the
|
||||
// patch without scrolling.
|
||||
let mut summaries: Vec<String> = Vec::new();
|
||||
for (path, change) in &changes {
|
||||
use codex_core::protocol::FileChange::*;
|
||||
match change {
|
||||
Add { content } => {
|
||||
let added = content.lines().count();
|
||||
summaries.push(format!("A {} (+{added})", path.display()));
|
||||
}
|
||||
Delete => {
|
||||
summaries.push(format!("D {}", path.display()));
|
||||
}
|
||||
Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
} => {
|
||||
if let Some(new_path) = move_path {
|
||||
summaries.push(format!("R {} → {}", path.display(), new_path.display(),));
|
||||
} else {
|
||||
summaries.push(format!("M {}", path.display(),));
|
||||
}
|
||||
summaries.extend(unified_diff.lines().map(|s| s.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summaries
|
||||
}
|
||||
165
codex-rs/tui/src/lib.rs
Normal file
165
codex-rs/tui/src/lib.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
// Forbid accidental stdout/stderr writes in the *library* portion of the TUI.
|
||||
// The standalone `codex-tui` binary prints a short help message before the
|
||||
// alternate‑screen mode starts; that file opts‑out locally via `allow`.
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
use app::App;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use log_layer::TuiLogLayer;
|
||||
use std::fs::OpenOptions;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod app;
|
||||
mod app_event;
|
||||
mod bottom_pane;
|
||||
mod chatwidget;
|
||||
mod cli;
|
||||
mod conversation_history_widget;
|
||||
mod exec_command;
|
||||
mod git_warning_screen;
|
||||
mod history_cell;
|
||||
mod log_layer;
|
||||
mod status_indicator_widget;
|
||||
mod tui;
|
||||
mod user_approval_widget;
|
||||
|
||||
pub use cli::Cli;
|
||||
|
||||
pub fn run_main(cli: Cli) -> std::io::Result<()> {
|
||||
assert_env_var_set();
|
||||
|
||||
// Open (or create) your log file, appending to it.
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("/tmp/codex-rs.log")?;
|
||||
|
||||
// Wrap file in non‑blocking writer.
|
||||
let (non_blocking, _guard) = non_blocking(file);
|
||||
|
||||
// use RUST_LOG env var, default to trace for codex crates.
|
||||
let env_filter = || {
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("codex=trace,codex_tui=trace"))
|
||||
};
|
||||
|
||||
// Build layered subscriber:
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_target(false)
|
||||
.with_filter(env_filter());
|
||||
|
||||
// Channel that carries formatted log lines to the UI.
|
||||
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let tui_layer = TuiLogLayer::new(log_tx.clone(), 120).with_filter(env_filter());
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(file_layer)
|
||||
.with(tui_layer)
|
||||
.try_init();
|
||||
|
||||
// Determine whether we need to display the "not a git repo" warning
|
||||
// modal. The flag is shown when the current working directory is *not*
|
||||
// inside a Git repository **and** the user did *not* pass the
|
||||
// `--allow-no-git-exec` flag.
|
||||
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo();
|
||||
|
||||
try_run_ratatui_app(cli, show_git_warning, log_rx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "Resort to stderr in exceptional situations."
|
||||
)]
|
||||
fn try_run_ratatui_app(
|
||||
cli: Cli,
|
||||
show_git_warning: bool,
|
||||
log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
) {
|
||||
if let Err(report) = run_ratatui_app(cli, show_git_warning, log_rx) {
|
||||
eprintln!("Error: {report:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_ratatui_app(
|
||||
cli: Cli,
|
||||
show_git_warning: bool,
|
||||
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
) -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
// Forward panic reports through the tracing stack so that they appear in
|
||||
// the status indicator instead of breaking the alternate screen – the
|
||||
// normal colour‑eyre hook writes to stderr which would corrupt the UI.
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
tracing::error!("panic: {info}");
|
||||
}));
|
||||
let mut terminal = tui::init()?;
|
||||
terminal.clear()?;
|
||||
|
||||
let Cli {
|
||||
prompt,
|
||||
images,
|
||||
approval_policy,
|
||||
sandbox_policy: sandbox,
|
||||
model,
|
||||
..
|
||||
} = cli;
|
||||
|
||||
let approval_policy = approval_policy.into();
|
||||
let sandbox_policy = sandbox.into();
|
||||
|
||||
let mut app = App::new(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
prompt,
|
||||
show_git_warning,
|
||||
images,
|
||||
model,
|
||||
);
|
||||
|
||||
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
|
||||
{
|
||||
let app_event_tx = app.event_sender();
|
||||
tokio::spawn(async move {
|
||||
while let Some(line) = log_rx.recv().await {
|
||||
let _ = app_event_tx.send(crate::app_event::AppEvent::LatestLog(line));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let app_result = app.run(&mut terminal);
|
||||
|
||||
restore();
|
||||
app_result
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "TUI should not have been displayed yet, so we can write to stderr."
|
||||
)]
|
||||
fn assert_env_var_set() {
|
||||
if std::env::var("OPENAI_API_KEY").is_err() {
|
||||
eprintln!("Welcome to codex! It looks like you're missing: `OPENAI_API_KEY`");
|
||||
eprintln!(
|
||||
"Create an API key (https://platform.openai.com) and export as an environment variable"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "TUI should no longer be displayed, so we can write to stderr."
|
||||
)]
|
||||
fn restore() {
|
||||
if let Err(err) = tui::restore() {
|
||||
eprintln!(
|
||||
"failed to restore terminal. Run `reset` or restart your terminal to recover: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
94
codex-rs/tui/src/log_layer.rs
Normal file
94
codex-rs/tui/src/log_layer.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Custom `tracing_subscriber` layer that forwards every formatted log event to the
|
||||
//! TUI so the status indicator can display the *latest* log line while a task is
|
||||
//! running.
|
||||
//!
|
||||
//! The layer is intentionally extremely small: we implement `on_event()` only and
|
||||
//! ignore spans/metadata because we only care about the already‑formatted output
|
||||
//! that the default `fmt` layer would print. We therefore borrow the same
|
||||
//! formatter (`tracing_subscriber::fmt::format::FmtSpan`) used by the default
|
||||
//! fmt layer so the text matches what is written to the log file.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tracing::field::Field;
|
||||
use tracing::field::Visit;
|
||||
use tracing::Event;
|
||||
use tracing::Subscriber;
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::Layer;
|
||||
|
||||
/// Maximum characters forwarded to the TUI. Longer messages are truncated so the
|
||||
/// single‑line status indicator cannot overflow the viewport.
|
||||
#[allow(dead_code)]
|
||||
const _DEFAULT_MAX_LEN: usize = 120;
|
||||
|
||||
pub struct TuiLogLayer {
|
||||
tx: UnboundedSender<String>,
|
||||
max_len: usize,
|
||||
}
|
||||
|
||||
impl TuiLogLayer {
|
||||
pub fn new(tx: UnboundedSender<String>, max_len: usize) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
max_len: max_len.max(8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for TuiLogLayer
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
// Build a terse line like `[TRACE core::session] message …` by visiting
|
||||
// fields into a buffer. This avoids pulling in the heavyweight
|
||||
// formatter machinery.
|
||||
|
||||
struct Visitor<'a> {
|
||||
buf: &'a mut String,
|
||||
}
|
||||
|
||||
impl Visit for Visitor<'_> {
|
||||
fn record_debug(&mut self, _field: &Field, value: &dyn std::fmt::Debug) {
|
||||
let _ = write!(self.buf, " {:?}", value);
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = String::new();
|
||||
let _ = write!(
|
||||
buf,
|
||||
"[{} {}]",
|
||||
event.metadata().level(),
|
||||
event.metadata().target()
|
||||
);
|
||||
|
||||
event.record(&mut Visitor { buf: &mut buf });
|
||||
|
||||
// `String::truncate` operates on UTF‑8 code‑point boundaries and will
|
||||
// panic if the provided index is not one. Because we limit the log
|
||||
// line by its **byte** length we can not guarantee that the index we
|
||||
// want to cut at happens to be on a boundary. Therefore we fall back
|
||||
// to a simple, boundary‑safe loop that pops complete characters until
|
||||
// the string is within the designated size.
|
||||
|
||||
if buf.len() > self.max_len {
|
||||
// Attempt direct truncate at the byte index. If that is not a
|
||||
// valid boundary we advance to the next one ( ≤3 bytes away ).
|
||||
if buf.is_char_boundary(self.max_len) {
|
||||
buf.truncate(self.max_len);
|
||||
} else {
|
||||
let mut idx = self.max_len;
|
||||
while idx < buf.len() && !buf.is_char_boundary(idx) {
|
||||
idx += 1;
|
||||
}
|
||||
buf.truncate(idx);
|
||||
}
|
||||
}
|
||||
|
||||
let sanitized = buf.replace(['\n', '\r'], " ");
|
||||
let _ = self.tx.send(sanitized);
|
||||
}
|
||||
}
|
||||
10
codex-rs/tui/src/main.rs
Normal file
10
codex-rs/tui/src/main.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use clap::Parser;
|
||||
use codex_tui::run_main;
|
||||
use codex_tui::Cli;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
run_main(cli)?;
|
||||
Ok(())
|
||||
}
|
||||
214
codex-rs/tui/src/status_indicator_widget.rs
Normal file
214
codex-rs/tui/src/status_indicator_widget.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
//! A live status indicator that shows the *latest* log line emitted by the
|
||||
//! application while the agent is processing a long‑running task.
|
||||
//!
|
||||
//! It replaces the old spinner animation with real log feedback so users can
|
||||
//! watch Codex “think” in real‑time. Whenever new text is provided via
|
||||
//! [`StatusIndicatorWidget::update_text`], the parent widget triggers a
|
||||
//! redraw so the change is visible immediately.
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Padding;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
|
||||
pub(crate) struct StatusIndicatorWidget {
|
||||
/// Latest text to display (truncated to the available width at render
|
||||
/// time).
|
||||
text: String,
|
||||
|
||||
/// Height in terminal rows – matches the height of the textarea at the
|
||||
/// moment the task started so the UI does not jump when we toggle between
|
||||
/// input mode and loading mode.
|
||||
height: u16,
|
||||
|
||||
frame_idx: std::sync::Arc<AtomicUsize>,
|
||||
running: std::sync::Arc<AtomicBool>,
|
||||
// Keep one sender alive to prevent the channel from closing while the
|
||||
// animation thread is still running. The field itself is currently not
|
||||
// accessed anywhere, therefore the leading underscore silences the
|
||||
// `dead_code` warning without affecting behavior.
|
||||
_app_event_tx: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl StatusIndicatorWidget {
|
||||
/// Create a new status indicator and start the animation timer.
|
||||
pub(crate) fn new(app_event_tx: Sender<AppEvent>, height: u16) -> Self {
|
||||
let frame_idx = Arc::new(AtomicUsize::new(0));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
// Animation thread.
|
||||
{
|
||||
let frame_idx_clone = Arc::clone(&frame_idx);
|
||||
let running_clone = Arc::clone(&running);
|
||||
let app_event_tx_clone = app_event_tx.clone();
|
||||
thread::spawn(move || {
|
||||
let mut counter = 0usize;
|
||||
while running_clone.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
counter = counter.wrapping_add(1);
|
||||
frame_idx_clone.store(counter, Ordering::Relaxed);
|
||||
if app_event_tx_clone.send(AppEvent::Redraw).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
text: String::from("waiting for logs…"),
|
||||
height: height.max(3),
|
||||
frame_idx,
|
||||
running,
|
||||
_app_event_tx: app_event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(
|
||||
&mut self,
|
||||
_key: KeyEvent,
|
||||
) -> Result<bool, std::sync::mpsc::SendError<AppEvent>> {
|
||||
// The indicator does not handle any input – always return `false`.
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Preferred height in terminal rows.
|
||||
pub(crate) fn get_height(&self) -> u16 {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// Update the line that is displayed in the widget.
|
||||
pub(crate) fn update_text(&mut self, text: String) {
|
||||
self.text = text.replace(['\n', '\r'], " ");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StatusIndicatorWidget {
|
||||
fn drop(&mut self) {
|
||||
use std::sync::atomic::Ordering;
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for StatusIndicatorWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let widget_style = Style::default();
|
||||
let block = Block::default()
|
||||
.padding(Padding::new(1, 0, 0, 0))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(widget_style);
|
||||
// Animated 3‑dot pattern inside brackets. The *active* dot is bold
|
||||
// white, the others are dim.
|
||||
const DOT_COUNT: usize = 3;
|
||||
let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let phase = idx % (DOT_COUNT * 2 - 2);
|
||||
let active = if phase < DOT_COUNT {
|
||||
phase
|
||||
} else {
|
||||
(DOT_COUNT * 2 - 2) - phase
|
||||
};
|
||||
|
||||
let mut header_spans: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
header_spans.push(Span::styled(
|
||||
"Working ",
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
header_spans.push(Span::styled(
|
||||
"[",
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
for i in 0..DOT_COUNT {
|
||||
let style = if i == active {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().dim()
|
||||
};
|
||||
header_spans.push(Span::styled(".", style));
|
||||
}
|
||||
|
||||
header_spans.push(Span::styled(
|
||||
"] ",
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
// Ensure we do not overflow width.
|
||||
let inner_width = block.inner(area).width as usize;
|
||||
|
||||
// Sanitize and colour‑strip the potentially colourful log text. This
|
||||
// ensures that **no** raw ANSI escape sequences leak into the
|
||||
// back‑buffer which would otherwise cause cursor jumps or stray
|
||||
// artefacts when the terminal is resized.
|
||||
let line = ansi_escape_line(&self.text);
|
||||
let mut sanitized_tail: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
// Truncate *after* stripping escape codes so width calculation is
|
||||
// accurate. See UTF‑8 boundary comments above.
|
||||
let header_len: usize = header_spans.iter().map(|s| s.content.len()).sum();
|
||||
|
||||
if header_len + sanitized_tail.len() > inner_width {
|
||||
let available_bytes = inner_width.saturating_sub(header_len);
|
||||
|
||||
if sanitized_tail.is_char_boundary(available_bytes) {
|
||||
sanitized_tail.truncate(available_bytes);
|
||||
} else {
|
||||
let mut idx = available_bytes;
|
||||
while idx < sanitized_tail.len() && !sanitized_tail.is_char_boundary(idx) {
|
||||
idx += 1;
|
||||
}
|
||||
sanitized_tail.truncate(idx);
|
||||
}
|
||||
}
|
||||
|
||||
let mut spans = header_spans;
|
||||
|
||||
// Re‑apply the DIM modifier so the tail appears visually subdued
|
||||
// irrespective of the colour information preserved by
|
||||
// `ansi_escape_line`.
|
||||
spans.push(Span::styled(sanitized_tail, Style::default().dim()));
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans))
|
||||
.block(block)
|
||||
.alignment(Alignment::Left);
|
||||
paragraph.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
37
codex-rs/tui/src/tui.rs
Normal file
37
codex-rs/tui/src/tui.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use std::io::stdout;
|
||||
use std::io::Stdout;
|
||||
use std::io::{self};
|
||||
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::disable_raw_mode;
|
||||
use ratatui::crossterm::terminal::enable_raw_mode;
|
||||
use ratatui::crossterm::terminal::EnterAlternateScreen;
|
||||
use ratatui::crossterm::terminal::LeaveAlternateScreen;
|
||||
use ratatui::Terminal;
|
||||
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
/// Initialize the terminal
|
||||
pub fn init() -> io::Result<Tui> {
|
||||
execute!(stdout(), EnterAlternateScreen)?;
|
||||
enable_raw_mode()?;
|
||||
set_panic_hook();
|
||||
Terminal::new(CrosstermBackend::new(stdout()))
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let _ = restore(); // ignore any errors as we are already failing
|
||||
hook(panic_info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Restore the terminal to its original state
|
||||
pub fn restore() -> io::Result<()> {
|
||||
execute!(stdout(), LeaveAlternateScreen)?;
|
||||
disable_raw_mode()?;
|
||||
Ok(())
|
||||
}
|
||||
395
codex-rs/tui/src/user_approval_widget.rs
Normal file
395
codex-rs/tui/src/user_approval_widget.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
//! A modal widget that prompts the user to approve or deny an action
|
||||
//! requested by the agent.
|
||||
//!
|
||||
//! This is a (very) rough port of
|
||||
//! `src/components/chat/terminal-chat-command-review.tsx` from the TypeScript
|
||||
//! UI to Rust using [`ratatui`]. The goal is feature‑parity for the keyboard
|
||||
//! driven workflow – a fully‑fledged visual match is not required.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::List;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
use tui_input::Input;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
pub(crate) enum ApprovalRequest {
|
||||
Exec {
|
||||
id: String,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
reason: Option<String>,
|
||||
},
|
||||
ApplyPatch {
|
||||
id: String,
|
||||
reason: Option<String>,
|
||||
grant_root: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Options displayed in the *select* mode.
|
||||
struct SelectOption {
|
||||
label: &'static str,
|
||||
decision: Option<ReviewDecision>,
|
||||
/// `true` when this option switches the widget to *input* mode.
|
||||
enters_input_mode: bool,
|
||||
}
|
||||
|
||||
// keep in same order as in the TS implementation
|
||||
const SELECT_OPTIONS: &[SelectOption] = &[
|
||||
SelectOption {
|
||||
label: "Yes (y)",
|
||||
decision: Some(ReviewDecision::Approved),
|
||||
|
||||
enters_input_mode: false,
|
||||
},
|
||||
SelectOption {
|
||||
label: "Yes, always approve this exact command for this session (a)",
|
||||
decision: Some(ReviewDecision::ApprovedForSession),
|
||||
|
||||
enters_input_mode: false,
|
||||
},
|
||||
SelectOption {
|
||||
label: "Edit or give feedback (e)",
|
||||
decision: None,
|
||||
|
||||
enters_input_mode: true,
|
||||
},
|
||||
SelectOption {
|
||||
label: "No, and keep going (n)",
|
||||
decision: Some(ReviewDecision::Denied),
|
||||
|
||||
enters_input_mode: false,
|
||||
},
|
||||
SelectOption {
|
||||
label: "No, and stop for now (esc)",
|
||||
decision: Some(ReviewDecision::Abort),
|
||||
|
||||
enters_input_mode: false,
|
||||
},
|
||||
];
|
||||
|
||||
/// Internal mode the widget is in – mirrors the TypeScript component.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Select,
|
||||
Input,
|
||||
}
|
||||
|
||||
/// A modal prompting the user to approve or deny the pending request.
|
||||
pub(crate) struct UserApprovalWidget<'a> {
|
||||
approval_request: ApprovalRequest,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
confirmation_prompt: Paragraph<'a>,
|
||||
|
||||
/// Currently selected index in *select* mode.
|
||||
selected_option: usize,
|
||||
|
||||
/// State for the optional input widget.
|
||||
input: Input,
|
||||
|
||||
/// Current mode.
|
||||
mode: Mode,
|
||||
|
||||
/// Set to `true` once a decision has been sent – the parent view can then
|
||||
/// remove this widget from its queue.
|
||||
done: bool,
|
||||
}
|
||||
|
||||
// Number of lines automatically added by ratatui’s [`Block`] when
|
||||
// borders are enabled (one at the top, one at the bottom).
|
||||
const BORDER_LINES: u16 = 2;
|
||||
|
||||
impl UserApprovalWidget<'_> {
|
||||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: Sender<AppEvent>) -> Self {
|
||||
let input = Input::default();
|
||||
let confirmation_prompt = match &approval_request {
|
||||
ApprovalRequest::Exec {
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
..
|
||||
} => {
|
||||
let cmd = strip_bash_lc_and_escape(command);
|
||||
// Maybe try to relativize to the cwd of this process first?
|
||||
// Will make cwd_str shorter in the common case.
|
||||
let cwd_str = match relativize_to_home(cwd) {
|
||||
Some(rel) => format!("~/{}", rel.display()),
|
||||
None => cwd.display().to_string(),
|
||||
};
|
||||
let mut contents: Vec<Line> = vec![
|
||||
Line::from("Shell Command".bold()),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
format!("{cwd_str}$").dim(),
|
||||
Span::from(format!(" {cmd}")),
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
if let Some(reason) = reason {
|
||||
contents.push(Line::from(reason.clone().italic()));
|
||||
contents.push(Line::from(""));
|
||||
}
|
||||
contents.extend(vec![Line::from("Allow command?"), Line::from("")]);
|
||||
Paragraph::new(contents)
|
||||
}
|
||||
ApprovalRequest::ApplyPatch {
|
||||
reason, grant_root, ..
|
||||
} => {
|
||||
let mut contents: Vec<Line> =
|
||||
vec![Line::from("Apply patch".bold()), Line::from("")];
|
||||
|
||||
if let Some(r) = reason {
|
||||
contents.push(Line::from(r.clone().italic()));
|
||||
contents.push(Line::from(""));
|
||||
}
|
||||
|
||||
if let Some(root) = grant_root {
|
||||
contents.push(Line::from(format!(
|
||||
"This will grant write access to {} for the remainder of this session.",
|
||||
root.display()
|
||||
)));
|
||||
contents.push(Line::from(""));
|
||||
}
|
||||
|
||||
contents.push(Line::from("Allow changes?"));
|
||||
contents.push(Line::from(""));
|
||||
|
||||
Paragraph::new(contents)
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
approval_request,
|
||||
app_event_tx,
|
||||
confirmation_prompt,
|
||||
selected_option: 0,
|
||||
input,
|
||||
mode: Mode::Select,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_height(&self, area: &Rect) -> u16 {
|
||||
let confirmation_prompt_height =
|
||||
self.get_confirmation_prompt_height(area.width - BORDER_LINES);
|
||||
|
||||
match self.mode {
|
||||
Mode::Select => {
|
||||
let num_option_lines = SELECT_OPTIONS.len() as u16;
|
||||
confirmation_prompt_height + num_option_lines + BORDER_LINES
|
||||
}
|
||||
Mode::Input => {
|
||||
// 1. "Give the model feedback ..." prompt
|
||||
// 2. A single‑line input field (we allocate exactly one row;
|
||||
// the `tui-input` widget will scroll horizontally if the
|
||||
// text exceeds the width).
|
||||
const INPUT_PROMPT_LINES: u16 = 1;
|
||||
const INPUT_FIELD_LINES: u16 = 1;
|
||||
|
||||
confirmation_prompt_height + INPUT_PROMPT_LINES + INPUT_FIELD_LINES + BORDER_LINES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
|
||||
// Should cache this for last value of width.
|
||||
self.confirmation_prompt.line_count(width) as u16
|
||||
}
|
||||
|
||||
/// Process a `KeyEvent` coming from crossterm. Always consumes the event
|
||||
/// while the modal is visible.
|
||||
/// Process a key event originating from crossterm. As the modal fully
|
||||
/// captures input while visible, we don’t need to report whether the event
|
||||
/// was consumed—callers can assume it always is.
|
||||
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), SendError<AppEvent>> {
|
||||
match self.mode {
|
||||
Mode::Select => self.handle_select_key(key)?,
|
||||
Mode::Input => self.handle_input_key(key)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_select_key(&mut self, key_event: KeyEvent) -> Result<(), SendError<AppEvent>> {
|
||||
match key_event.code {
|
||||
KeyCode::Up => {
|
||||
if self.selected_option == 0 {
|
||||
self.selected_option = SELECT_OPTIONS.len() - 1;
|
||||
} else {
|
||||
self.selected_option -= 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.selected_option = (self.selected_option + 1) % SELECT_OPTIONS.len();
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char('y') => {
|
||||
self.send_decision(ReviewDecision::Approved)?;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.send_decision(ReviewDecision::ApprovedForSession)?;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
self.send_decision(ReviewDecision::Denied)?;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
self.mode = Mode::Input;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let opt = &SELECT_OPTIONS[self.selected_option];
|
||||
if opt.enters_input_mode {
|
||||
self.mode = Mode::Input;
|
||||
} else if let Some(decision) = opt.decision {
|
||||
self.send_decision(decision)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.send_decision(ReviewDecision::Abort)?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input_key(&mut self, key_event: KeyEvent) -> Result<(), SendError<AppEvent>> {
|
||||
// Handle special keys first.
|
||||
match key_event.code {
|
||||
KeyCode::Enter => {
|
||||
let feedback = self.input.value().to_string();
|
||||
self.send_decision_with_feedback(ReviewDecision::Denied, feedback)?;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
// Cancel input – treat as deny without feedback.
|
||||
self.send_decision(ReviewDecision::Denied)?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Feed into input widget for normal editing.
|
||||
let ct_event = crossterm::event::Event::Key(key_event);
|
||||
self.input.handle_event(&ct_event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_decision(&mut self, decision: ReviewDecision) -> Result<(), SendError<AppEvent>> {
|
||||
self.send_decision_with_feedback(decision, String::new())
|
||||
}
|
||||
|
||||
fn send_decision_with_feedback(
|
||||
&mut self,
|
||||
decision: ReviewDecision,
|
||||
_feedback: String,
|
||||
) -> Result<(), SendError<AppEvent>> {
|
||||
let op = match &self.approval_request {
|
||||
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
|
||||
id: id.clone(),
|
||||
decision,
|
||||
},
|
||||
ApprovalRequest::ApplyPatch { id, .. } => Op::PatchApproval {
|
||||
id: id.clone(),
|
||||
decision,
|
||||
},
|
||||
};
|
||||
|
||||
// Ignore feedback for now – the current `Op` variants do not carry it.
|
||||
|
||||
// Forward the Op to the agent. The caller (ChatWidget) will trigger a
|
||||
// redraw after it processes the resulting state change, so we avoid
|
||||
// issuing an extra Redraw here to prevent a transient frame where the
|
||||
// modal is still visible.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(op))?;
|
||||
self.done = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` once the user has made a decision and the widget no
|
||||
/// longer needs to be displayed.
|
||||
pub(crate) fn is_complete(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
||||
const PLAIN: Style = Style::new();
|
||||
const BLUE_FG: Style = Style::new().fg(Color::Blue);
|
||||
|
||||
impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Take the area, wrap it in a block with a border, and divide up the
|
||||
// remaining area into two chunks: one for the confirmation prompt and
|
||||
// one for the response.
|
||||
let outer = Block::default()
|
||||
.title("Review")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded);
|
||||
let inner = outer.inner(area);
|
||||
let prompt_height = self.get_confirmation_prompt_height(inner.width);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||||
.split(inner);
|
||||
let prompt_chunk = chunks[0];
|
||||
let response_chunk = chunks[1];
|
||||
|
||||
// Build the inner lines based on the mode. Collect them into a List of
|
||||
// non-wrapping lines rather than a Paragraph because get_height(Rect)
|
||||
// depends on this behavior for its calculation.
|
||||
let lines = match self.mode {
|
||||
Mode::Select => SELECT_OPTIONS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, opt)| {
|
||||
let (prefix, style) = if idx == self.selected_option {
|
||||
("▶", BLUE_FG)
|
||||
} else {
|
||||
(" ", PLAIN)
|
||||
};
|
||||
Line::styled(format!(" {prefix} {}", opt.label), style)
|
||||
})
|
||||
.collect(),
|
||||
Mode::Input => {
|
||||
vec![
|
||||
Line::from("Give the model feedback on this command:"),
|
||||
Line::from(self.input.value()),
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
outer.render(area, buf);
|
||||
self.confirmation_prompt.clone().render(prompt_chunk, buf);
|
||||
Widget::render(List::new(lines), response_chunk, buf);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user