Easily Selectable History (#1672)
This update replaces the previous ratatui history widget with an append-only log so that the terminal can handle text selection and scrolling. It also disables streaming responses, which we'll do our best to bring back in a later PR. It also adds a small summary of token use after the TUI exits.
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -852,6 +852,7 @@ dependencies = [
|
|||||||
"tui-markdown",
|
"tui-markdown",
|
||||||
"tui-textarea",
|
"tui-textarea",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
|||||||
None => {
|
None => {
|
||||||
let mut tui_cli = cli.interactive;
|
let mut tui_cli = cli.interactive;
|
||||||
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
|
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
|
||||||
codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
|
let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
|
||||||
|
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||||
}
|
}
|
||||||
Some(Subcommand::Exec(mut exec_cli)) => {
|
Some(Subcommand::Exec(mut exec_cli)) => {
|
||||||
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);
|
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);
|
||||||
|
|||||||
@@ -498,14 +498,5 @@ Options that are specific to the TUI.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[tui]
|
[tui]
|
||||||
# This will make it so that Codex does not try to process mouse events, which
|
# More to come here
|
||||||
# means your Terminal's native drag-to-text to text selection and copy/paste
|
|
||||||
# should work. The tradeoff is that Codex will not receive any mouse events, so
|
|
||||||
# it will not be possible to use the mouse to scroll conversation history.
|
|
||||||
#
|
|
||||||
# Note that most terminals support holding down a modifier key when using the
|
|
||||||
# mouse to support text selection. For example, even if Codex mouse capture is
|
|
||||||
# enabled (i.e., this is set to `false`), you can still hold down alt while
|
|
||||||
# dragging the mouse to select text.
|
|
||||||
disable_mouse_capture = true # defaults to `false`
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -76,20 +76,7 @@ pub enum HistoryPersistence {
|
|||||||
|
|
||||||
/// Collection of settings that are specific to the TUI.
|
/// Collection of settings that are specific to the TUI.
|
||||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||||
pub struct Tui {
|
pub struct Tui {}
|
||||||
/// By default, mouse capture is enabled in the TUI so that it is possible
|
|
||||||
/// to scroll the conversation history with a mouse. This comes at the cost
|
|
||||||
/// of not being able to use the mouse to select text in the TUI.
|
|
||||||
/// (Most terminals support a modifier key to allow this. For example,
|
|
||||||
/// text selection works in iTerm if you hold down the `Option` key while
|
|
||||||
/// clicking and dragging.)
|
|
||||||
///
|
|
||||||
/// Setting this option to `true` disables mouse capture, so scrolling with
|
|
||||||
/// the mouse is not possible, though the keyboard shortcuts e.g. `b` and
|
|
||||||
/// `space` still work. This allows the user to select text in the TUI
|
|
||||||
/// using the mouse without needing to hold down a modifier key.
|
|
||||||
pub disable_mouse_capture: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)]
|
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
//! between user and agent.
|
//! between user and agent.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr; // Added for FinalOutput Display implementation
|
||||||
|
|
||||||
use mcp_types::CallToolResult;
|
use mcp_types::CallToolResult;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -358,6 +359,36 @@ pub struct TokenUsage {
|
|||||||
pub total_tokens: u64,
|
pub total_tokens: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct FinalOutput {
|
||||||
|
pub token_usage: TokenUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TokenUsage> for FinalOutput {
|
||||||
|
fn from(token_usage: TokenUsage) -> Self {
|
||||||
|
Self { token_usage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for FinalOutput {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let u = &self.token_usage;
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"Token usage: total={} input={}{} output={}{}",
|
||||||
|
u.total_tokens,
|
||||||
|
u.input_tokens,
|
||||||
|
u.cached_input_tokens
|
||||||
|
.map(|c| format!(" (cached {c})"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
u.output_tokens,
|
||||||
|
u.reasoning_output_tokens
|
||||||
|
.map(|r| format!(" (reasoning {r})"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct AgentMessageEvent {
|
pub struct AgentMessageEvent {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ tui-input = "0.14.0"
|
|||||||
tui-markdown = "0.3.3"
|
tui-markdown = "0.3.3"
|
||||||
tui-textarea = "0.7.0"
|
tui-textarea = "0.7.0"
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
|
unicode-width = "0.1"
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use crate::get_git_diff::get_git_diff;
|
|||||||
use crate::git_warning_screen::GitWarningOutcome;
|
use crate::git_warning_screen::GitWarningOutcome;
|
||||||
use crate::git_warning_screen::GitWarningScreen;
|
use crate::git_warning_screen::GitWarningScreen;
|
||||||
use crate::login_screen::LoginScreen;
|
use crate::login_screen::LoginScreen;
|
||||||
use crate::mouse_capture::MouseCapture;
|
|
||||||
use crate::scroll_event_helper::ScrollEventHelper;
|
use crate::scroll_event_helper::ScrollEventHelper;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
@@ -197,17 +196,17 @@ impl App<'_> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn run(
|
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||||
&mut self,
|
|
||||||
terminal: &mut tui::Tui,
|
|
||||||
mouse_capture: &mut MouseCapture,
|
|
||||||
) -> Result<()> {
|
|
||||||
// Insert an event to trigger the first render.
|
// Insert an event to trigger the first render.
|
||||||
let app_event_tx = self.app_event_tx.clone();
|
let app_event_tx = self.app_event_tx.clone();
|
||||||
app_event_tx.send(AppEvent::RequestRedraw);
|
app_event_tx.send(AppEvent::RequestRedraw);
|
||||||
|
|
||||||
while let Ok(event) = self.app_event_rx.recv() {
|
while let Ok(event) = self.app_event_rx.recv() {
|
||||||
match event {
|
match event {
|
||||||
|
AppEvent::InsertHistory(lines) => {
|
||||||
|
crate::insert_history::insert_history_lines(terminal, lines);
|
||||||
|
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||||
|
}
|
||||||
AppEvent::RequestRedraw => {
|
AppEvent::RequestRedraw => {
|
||||||
self.schedule_redraw();
|
self.schedule_redraw();
|
||||||
}
|
}
|
||||||
@@ -287,11 +286,6 @@ impl App<'_> {
|
|||||||
self.app_state = AppState::Chat { widget: new_widget };
|
self.app_state = AppState::Chat { widget: new_widget };
|
||||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||||
}
|
}
|
||||||
SlashCommand::ToggleMouseMode => {
|
|
||||||
if let Err(e) = mouse_capture.toggle() {
|
|
||||||
tracing::error!("Failed to toggle mouse mode: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SlashCommand::Quit => {
|
SlashCommand::Quit => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -332,6 +326,15 @@ impl App<'_> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||||||
|
match &self.app_state {
|
||||||
|
AppState::Chat { widget } => widget.token_usage().clone(),
|
||||||
|
AppState::Login { .. } | AppState::GitWarning { .. } => {
|
||||||
|
codex_core::protocol::TokenUsage::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||||
// TODO: add a throttle to avoid redrawing too often
|
// TODO: add a throttle to avoid redrawing too often
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
|
|
||||||
@@ -49,4 +50,6 @@ pub(crate) enum AppEvent {
|
|||||||
query: String,
|
query: String,
|
||||||
matches: Vec<FileMatch>,
|
matches: Vec<FileMatch>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
InsertHistory(Vec<Line<'static>>),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,6 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
|||||||
self.current.is_complete() && self.queue.is_empty()
|
self.current.is_complete() && self.queue.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_required_height(&self, area: &Rect) -> u16 {
|
|
||||||
self.current.get_height(area)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
(&self.current).render_ref(area, buf);
|
(&self.current).render_ref(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ pub(crate) trait BottomPaneView<'a> {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Height required to render the view.
|
|
||||||
fn calculate_required_height(&self, area: &Rect) -> u16;
|
|
||||||
|
|
||||||
/// Render the view: this will be displayed in place of the composer.
|
/// Render the view: this will be displayed in place of the composer.
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ use crate::app_event::AppEvent;
|
|||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
|
|
||||||
/// Minimum number of visible text rows inside the textarea.
|
|
||||||
const MIN_TEXTAREA_ROWS: usize = 1;
|
|
||||||
/// Rows consumed by the border.
|
|
||||||
const BORDER_LINES: u16 = 2;
|
|
||||||
|
|
||||||
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
|
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
|
||||||
/// If the pasted content exceeds this number of characters, replace it with a
|
/// If the pasted content exceeds this number of characters, replace it with a
|
||||||
/// placeholder in the UI.
|
/// placeholder in the UI.
|
||||||
@@ -609,17 +604,6 @@ impl ChatComposer<'_> {
|
|||||||
self.dismissed_file_popup_token = None;
|
self.dismissed_file_popup_token = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calculate_required_height(&self, area: &Rect) -> u16 {
|
|
||||||
let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS);
|
|
||||||
let num_popup_rows = match &self.active_popup {
|
|
||||||
ActivePopup::Command(popup) => popup.calculate_required_height(area),
|
|
||||||
ActivePopup::File(popup) => popup.calculate_required_height(area),
|
|
||||||
ActivePopup::None => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
rows as u16 + BORDER_LINES + num_popup_rows
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_border(&mut self, has_focus: bool) {
|
fn update_border(&mut self, has_focus: bool) {
|
||||||
struct BlockState {
|
struct BlockState {
|
||||||
right_title: Line<'static>,
|
right_title: Line<'static>,
|
||||||
|
|||||||
@@ -65,10 +65,8 @@ impl BottomPane<'_> {
|
|||||||
if !view.is_complete() {
|
if !view.is_complete() {
|
||||||
self.active_view = Some(view);
|
self.active_view = Some(view);
|
||||||
} else if self.is_task_running {
|
} else if self.is_task_running {
|
||||||
let height = self.composer.calculate_required_height(&Rect::default());
|
|
||||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||||
self.app_event_tx.clone(),
|
self.app_event_tx.clone(),
|
||||||
height,
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
@@ -138,10 +136,8 @@ impl BottomPane<'_> {
|
|||||||
match (running, self.active_view.is_some()) {
|
match (running, self.active_view.is_some()) {
|
||||||
(true, false) => {
|
(true, false) => {
|
||||||
// Show status indicator overlay.
|
// Show status indicator overlay.
|
||||||
let height = self.composer.calculate_required_height(&Rect::default());
|
|
||||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||||
self.app_event_tx.clone(),
|
self.app_event_tx.clone(),
|
||||||
height,
|
|
||||||
)));
|
)));
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -203,14 +199,6 @@ impl BottomPane<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Height (terminal rows) required by the current bottom pane.
|
/// Height (terminal rows) required by the current bottom pane.
|
||||||
pub fn calculate_required_height(&self, area: &Rect) -> u16 {
|
|
||||||
if let Some(view) = &self.active_view {
|
|
||||||
view.calculate_required_height(area)
|
|
||||||
} else {
|
|
||||||
self.composer.calculate_required_height(area)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn request_redraw(&self) {
|
pub(crate) fn request_redraw(&self) {
|
||||||
self.app_event_tx.send(AppEvent::RequestRedraw)
|
self.app_event_tx.send(AppEvent::RequestRedraw)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
@@ -13,9 +12,9 @@ pub(crate) struct StatusIndicatorView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StatusIndicatorView {
|
impl StatusIndicatorView {
|
||||||
pub fn new(app_event_tx: AppEventSender, height: u16) -> Self {
|
pub fn new(app_event_tx: AppEventSender) -> Self {
|
||||||
Self {
|
Self {
|
||||||
view: StatusIndicatorWidget::new(app_event_tx, height),
|
view: StatusIndicatorWidget::new(app_event_tx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +33,7 @@ impl BottomPaneView<'_> for StatusIndicatorView {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
|
||||||
self.view.get_height()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
||||||
self.view.render_ref(area, buf);
|
self.view.render_ref(area, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ use codex_core::protocol::TaskCompleteEvent;
|
|||||||
use codex_core::protocol::TokenUsage;
|
use codex_core::protocol::TokenUsage;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Constraint;
|
|
||||||
use ratatui::layout::Direction;
|
|
||||||
use ratatui::layout::Layout;
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
@@ -52,6 +49,9 @@ pub(crate) struct ChatWidget<'a> {
|
|||||||
initial_user_message: Option<UserMessage>,
|
initial_user_message: Option<UserMessage>,
|
||||||
token_usage: TokenUsage,
|
token_usage: TokenUsage,
|
||||||
reasoning_buffer: String,
|
reasoning_buffer: String,
|
||||||
|
// Buffer for streaming assistant answer text; we do not surface partial
|
||||||
|
// We wait for the final AgentMessage event and then emit the full text
|
||||||
|
// at once into scrollback so the history contains a single message.
|
||||||
answer_buffer: String,
|
answer_buffer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +187,13 @@ impl ChatWidget<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits the last entry's plain lines from conversation_history, if any.
|
||||||
|
fn emit_last_history_entry(&mut self) {
|
||||||
|
if let Some(lines) = self.conversation_history.last_entry_plain_lines() {
|
||||||
|
self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||||
let UserMessage { text, image_paths } = user_message;
|
let UserMessage { text, image_paths } = user_message;
|
||||||
let mut items: Vec<InputItem> = Vec::new();
|
let mut items: Vec<InputItem> = Vec::new();
|
||||||
@@ -220,7 +227,8 @@ impl ChatWidget<'_> {
|
|||||||
|
|
||||||
// Only show text portion in conversation history for now.
|
// Only show text portion in conversation history for now.
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
self.conversation_history.add_user_message(text);
|
self.conversation_history.add_user_message(text.clone());
|
||||||
|
self.emit_last_history_entry();
|
||||||
}
|
}
|
||||||
self.conversation_history.scroll_to_bottom();
|
self.conversation_history.scroll_to_bottom();
|
||||||
}
|
}
|
||||||
@@ -232,6 +240,10 @@ impl ChatWidget<'_> {
|
|||||||
// Record session information at the top of the conversation.
|
// Record session information at the top of the conversation.
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.add_session_info(&self.config, event.clone());
|
.add_session_info(&self.config, event.clone());
|
||||||
|
// Immediately surface the session banner / settings summary in
|
||||||
|
// scrollback so the user can review configuration (model,
|
||||||
|
// sandbox, approvals, etc.) before interacting.
|
||||||
|
self.emit_last_history_entry();
|
||||||
|
|
||||||
// Forward history metadata to the bottom pane so the chat
|
// Forward history metadata to the bottom pane so the chat
|
||||||
// composer can navigate through past messages.
|
// composer can navigate through past messages.
|
||||||
@@ -247,50 +259,50 @@ impl ChatWidget<'_> {
|
|||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||||
// if the answer buffer is empty, this means we haven't received any
|
// Final assistant answer. Prefer the fully provided message
|
||||||
// delta. Thus, we need to print the message as a new answer.
|
// from the event; if it is empty fall back to any accumulated
|
||||||
if self.answer_buffer.is_empty() {
|
// delta buffer (some providers may only stream deltas and send
|
||||||
self.conversation_history
|
// an empty final message).
|
||||||
.add_agent_message(&self.config, message);
|
let full = if message.is_empty() {
|
||||||
|
std::mem::take(&mut self.answer_buffer)
|
||||||
} else {
|
} else {
|
||||||
|
self.answer_buffer.clear();
|
||||||
|
message
|
||||||
|
};
|
||||||
|
if !full.is_empty() {
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.replace_prev_agent_message(&self.config, message);
|
.add_agent_message(&self.config, full);
|
||||||
|
self.emit_last_history_entry();
|
||||||
}
|
}
|
||||||
self.answer_buffer.clear();
|
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||||
if self.answer_buffer.is_empty() {
|
// Buffer only – do not emit partial lines. This avoids cases
|
||||||
self.conversation_history
|
// where long responses appear truncated if the terminal
|
||||||
.add_agent_message(&self.config, "".to_string());
|
// wrapped early. The full message is emitted on
|
||||||
}
|
// AgentMessage.
|
||||||
self.answer_buffer.push_str(&delta.clone());
|
self.answer_buffer.push_str(&delta);
|
||||||
self.conversation_history
|
|
||||||
.replace_prev_agent_message(&self.config, self.answer_buffer.clone());
|
|
||||||
self.request_redraw();
|
|
||||||
}
|
}
|
||||||
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||||||
if self.reasoning_buffer.is_empty() {
|
// Buffer only – disable incremental reasoning streaming so we
|
||||||
self.conversation_history
|
// avoid truncated intermediate lines. Full text emitted on
|
||||||
.add_agent_reasoning(&self.config, "".to_string());
|
// AgentReasoning.
|
||||||
}
|
self.reasoning_buffer.push_str(&delta);
|
||||||
self.reasoning_buffer.push_str(&delta.clone());
|
|
||||||
self.conversation_history
|
|
||||||
.replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone());
|
|
||||||
self.request_redraw();
|
|
||||||
}
|
}
|
||||||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||||
// if the reasoning buffer is empty, this means we haven't received any
|
// Emit full reasoning text once. Some providers might send
|
||||||
// delta. Thus, we need to print the message as a new reasoning.
|
// final event with empty text if only deltas were used.
|
||||||
if self.reasoning_buffer.is_empty() {
|
let full = if text.is_empty() {
|
||||||
self.conversation_history
|
std::mem::take(&mut self.reasoning_buffer)
|
||||||
.add_agent_reasoning(&self.config, "".to_string());
|
|
||||||
} else {
|
} else {
|
||||||
// else, we rerender one last time.
|
self.reasoning_buffer.clear();
|
||||||
|
text
|
||||||
|
};
|
||||||
|
if !full.is_empty() {
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.replace_prev_agent_reasoning(&self.config, text);
|
.add_agent_reasoning(&self.config, full);
|
||||||
|
self.emit_last_history_entry();
|
||||||
}
|
}
|
||||||
self.reasoning_buffer.clear();
|
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
EventMsg::TaskStarted => {
|
EventMsg::TaskStarted => {
|
||||||
@@ -310,7 +322,8 @@ impl ChatWidget<'_> {
|
|||||||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||||||
}
|
}
|
||||||
EventMsg::Error(ErrorEvent { message }) => {
|
EventMsg::Error(ErrorEvent { message }) => {
|
||||||
self.conversation_history.add_error(message);
|
self.conversation_history.add_error(message.clone());
|
||||||
|
self.emit_last_history_entry();
|
||||||
self.bottom_pane.set_task_running(false);
|
self.bottom_pane.set_task_running(false);
|
||||||
}
|
}
|
||||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||||
@@ -346,6 +359,7 @@ impl ChatWidget<'_> {
|
|||||||
|
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.add_patch_event(PatchEventType::ApprovalRequest, changes);
|
.add_patch_event(PatchEventType::ApprovalRequest, changes);
|
||||||
|
self.emit_last_history_entry();
|
||||||
|
|
||||||
self.conversation_history.scroll_to_bottom();
|
self.conversation_history.scroll_to_bottom();
|
||||||
|
|
||||||
@@ -364,7 +378,8 @@ impl ChatWidget<'_> {
|
|||||||
cwd: _,
|
cwd: _,
|
||||||
}) => {
|
}) => {
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.reset_or_add_active_exec_command(call_id, command);
|
.add_active_exec_command(call_id, command);
|
||||||
|
self.emit_last_history_entry();
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||||
@@ -376,6 +391,7 @@ impl ChatWidget<'_> {
|
|||||||
// summary so the user can follow along.
|
// summary so the user can follow along.
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes);
|
.add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes);
|
||||||
|
self.emit_last_history_entry();
|
||||||
if !auto_approved {
|
if !auto_approved {
|
||||||
self.conversation_history.scroll_to_bottom();
|
self.conversation_history.scroll_to_bottom();
|
||||||
}
|
}
|
||||||
@@ -399,6 +415,7 @@ impl ChatWidget<'_> {
|
|||||||
}) => {
|
}) => {
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.add_active_mcp_tool_call(call_id, server, tool, arguments);
|
.add_active_mcp_tool_call(call_id, server, tool, arguments);
|
||||||
|
self.emit_last_history_entry();
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => {
|
EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => {
|
||||||
@@ -425,6 +442,7 @@ impl ChatWidget<'_> {
|
|||||||
event => {
|
event => {
|
||||||
self.conversation_history
|
self.conversation_history
|
||||||
.add_background_event(format!("{event:?}"));
|
.add_background_event(format!("{event:?}"));
|
||||||
|
self.emit_last_history_entry();
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,7 +459,9 @@ impl ChatWidget<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||||||
self.conversation_history.add_diff_output(diff_output);
|
self.conversation_history
|
||||||
|
.add_diff_output(diff_output.clone());
|
||||||
|
self.emit_last_history_entry();
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,19 +512,18 @@ impl ChatWidget<'_> {
|
|||||||
tracing::error!("failed to submit op: {e}");
|
tracing::error!("failed to submit op: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn token_usage(&self) -> &TokenUsage {
|
||||||
|
&self.token_usage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WidgetRef for &ChatWidget<'_> {
|
impl WidgetRef for &ChatWidget<'_> {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let bottom_height = self.bottom_pane.calculate_required_height(&area);
|
// In the hybrid inline viewport mode we only draw the interactive
|
||||||
|
// bottom pane; history entries are injected directly into scrollback
|
||||||
let chunks = Layout::default()
|
// via `Terminal::insert_before`.
|
||||||
.direction(Direction::Vertical)
|
(&self.bottom_pane).render(area, buf);
|
||||||
.constraints([Constraint::Min(0), Constraint::Length(bottom_height)])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
self.conversation_history.render(chunks[0], buf);
|
|
||||||
(&self.bottom_pane).render(chunks[1], buf);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,14 +202,6 @@ impl ConversationHistoryWidget {
|
|||||||
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
|
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_prev_agent_reasoning(&mut self, config: &Config, text: String) {
|
|
||||||
self.replace_last_agent_reasoning(config, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn replace_prev_agent_message(&mut self, config: &Config, text: String) {
|
|
||||||
self.replace_last_agent_message(config, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_background_event(&mut self, message: String) {
|
pub fn add_background_event(&mut self, message: String) {
|
||||||
self.add_to_history(HistoryCell::new_background_event(message));
|
self.add_to_history(HistoryCell::new_background_event(message));
|
||||||
}
|
}
|
||||||
@@ -235,30 +227,6 @@ impl ConversationHistoryWidget {
|
|||||||
self.add_to_history(HistoryCell::new_active_exec_command(call_id, command));
|
self.add_to_history(HistoryCell::new_active_exec_command(call_id, command));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If an ActiveExecCommand with the same call_id already exists, replace
|
|
||||||
/// it with a fresh one (resetting start time and view). Otherwise, add a new entry.
|
|
||||||
pub fn reset_or_add_active_exec_command(&mut self, call_id: String, command: Vec<String>) {
|
|
||||||
// Find the most recent matching ActiveExecCommand.
|
|
||||||
let maybe_idx = self.entries.iter().rposition(|entry| {
|
|
||||||
if let HistoryCell::ActiveExecCommand { call_id: id, .. } = &entry.cell {
|
|
||||||
id == &call_id
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(idx) = maybe_idx {
|
|
||||||
let width = self.cached_width.get();
|
|
||||||
self.entries[idx].cell = HistoryCell::new_active_exec_command(call_id.clone(), command);
|
|
||||||
if width > 0 {
|
|
||||||
let height = self.entries[idx].cell.height(width);
|
|
||||||
self.entries[idx].line_count.set(height);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.add_active_exec_command(call_id, command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_active_mcp_tool_call(
|
pub fn add_active_mcp_tool_call(
|
||||||
&mut self,
|
&mut self,
|
||||||
call_id: String,
|
call_id: String,
|
||||||
@@ -281,40 +249,10 @@ impl ConversationHistoryWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_last_agent_reasoning(&mut self, config: &Config, text: String) {
|
/// Return the lines for the most recently appended entry (if any) so the
|
||||||
if let Some(idx) = self
|
/// parent widget can surface them via the new scrollback insertion path.
|
||||||
.entries
|
pub(crate) fn last_entry_plain_lines(&self) -> Option<Vec<Line<'static>>> {
|
||||||
.iter()
|
self.entries.last().map(|e| e.cell.plain_lines())
|
||||||
.rposition(|entry| matches!(entry.cell, HistoryCell::AgentReasoning { .. }))
|
|
||||||
{
|
|
||||||
let width = self.cached_width.get();
|
|
||||||
let entry = &mut self.entries[idx];
|
|
||||||
entry.cell = HistoryCell::new_agent_reasoning(config, text);
|
|
||||||
let height = if width > 0 {
|
|
||||||
entry.cell.height(width)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
entry.line_count.set(height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn replace_last_agent_message(&mut self, config: &Config, text: String) {
|
|
||||||
if let Some(idx) = self
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.rposition(|entry| matches!(entry.cell, HistoryCell::AgentMessage { .. }))
|
|
||||||
{
|
|
||||||
let width = self.cached_width.get();
|
|
||||||
let entry = &mut self.entries[idx];
|
|
||||||
entry.cell = HistoryCell::new_agent_message(config, text);
|
|
||||||
let height = if width > 0 {
|
|
||||||
entry.cell.height(width)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
entry.line_count.set(height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_completed_exec_command(
|
pub fn record_completed_exec_command(
|
||||||
|
|||||||
@@ -123,6 +123,30 @@ pub(crate) enum HistoryCell {
|
|||||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
const TOOL_CALL_MAX_LINES: usize = 5;
|
||||||
|
|
||||||
impl HistoryCell {
|
impl HistoryCell {
|
||||||
|
/// Return a cloned, plain representation of the cell's lines suitable for
|
||||||
|
/// one‑shot insertion into the terminal scrollback. Image cells are
|
||||||
|
/// represented with a simple placeholder for now.
|
||||||
|
pub(crate) fn plain_lines(&self) -> Vec<Line<'static>> {
|
||||||
|
match self {
|
||||||
|
HistoryCell::WelcomeMessage { view }
|
||||||
|
| HistoryCell::UserPrompt { view }
|
||||||
|
| HistoryCell::AgentMessage { view }
|
||||||
|
| HistoryCell::AgentReasoning { view }
|
||||||
|
| HistoryCell::BackgroundEvent { view }
|
||||||
|
| HistoryCell::GitDiffOutput { view }
|
||||||
|
| HistoryCell::ErrorEvent { view }
|
||||||
|
| HistoryCell::SessionInfo { view }
|
||||||
|
| HistoryCell::CompletedExecCommand { view }
|
||||||
|
| HistoryCell::CompletedMcpToolCall { view }
|
||||||
|
| HistoryCell::PendingPatch { view }
|
||||||
|
| HistoryCell::ActiveExecCommand { view, .. }
|
||||||
|
| HistoryCell::ActiveMcpToolCall { view, .. } => view.lines.clone(),
|
||||||
|
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
|
||||||
|
Line::from("tool result (image output omitted)"),
|
||||||
|
Line::from(""),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(crate) fn new_session_info(
|
pub(crate) fn new_session_info(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
event: SessionConfiguredEvent,
|
event: SessionConfiguredEvent,
|
||||||
|
|||||||
178
codex-rs/tui/src/insert_history.rs
Normal file
178
codex-rs/tui/src/insert_history.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
use crate::tui;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::text::Span;
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::widgets::Widget;
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
/// Insert a batch of history lines into the terminal scrollback above the
|
||||||
|
/// inline viewport.
|
||||||
|
///
|
||||||
|
/// The incoming `lines` are the logical lines supplied by the
|
||||||
|
/// `ConversationHistory`. They may contain embedded newlines and arbitrary
|
||||||
|
/// runs of whitespace inside individual [`Span`]s. All of that must be
|
||||||
|
/// normalised before writing to the backing terminal buffer because the
|
||||||
|
/// ratatui [`Paragraph`] widget does not perform soft‑wrapping when used in
|
||||||
|
/// conjunction with [`Terminal::insert_before`].
|
||||||
|
///
|
||||||
|
/// This function performs a minimal wrapping / normalisation pass:
|
||||||
|
///
|
||||||
|
/// * A terminal width is determined via `Terminal::size()` (falling back to
|
||||||
|
/// 80 columns if the size probe fails).
|
||||||
|
/// * Each logical line is broken into words and whitespace. Consecutive
|
||||||
|
/// whitespace is collapsed to a single space; leading whitespace is
|
||||||
|
/// discarded.
|
||||||
|
/// * Words that do not fit on the current line cause a soft wrap. Extremely
|
||||||
|
/// long words (longer than the terminal width) are split character by
|
||||||
|
/// character so they still populate the display instead of overflowing the
|
||||||
|
/// line.
|
||||||
|
/// * Explicit `\n` characters inside a span force a hard line break.
|
||||||
|
/// * Empty lines (including a trailing newline at the end of the batch) are
|
||||||
|
/// preserved so vertical spacing remains faithful to the logical history.
|
||||||
|
///
|
||||||
|
/// Finally the physical lines are rendered directly into the terminal's
|
||||||
|
/// scrollback region using [`Terminal::insert_before`]. Any backend error is
|
||||||
|
/// ignored: failing to insert history is non‑fatal and a subsequent redraw
|
||||||
|
/// will eventually repaint a consistent view.
|
||||||
|
fn display_width(s: &str) -> usize {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LineBuilder {
|
||||||
|
term_width: usize,
|
||||||
|
spans: Vec<Span<'static>>,
|
||||||
|
width: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LineBuilder {
|
||||||
|
fn new(term_width: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
term_width,
|
||||||
|
spans: Vec::new(),
|
||||||
|
width: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_line(&mut self, out: &mut Vec<Line<'static>>) {
|
||||||
|
out.push(Line::from(std::mem::take(&mut self.spans)));
|
||||||
|
self.width = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_segment(&mut self, text: String, style: Style) {
|
||||||
|
self.width += display_width(&text);
|
||||||
|
self.spans.push(Span::styled(text, style));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
|
||||||
|
if word.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let w_len = display_width(word);
|
||||||
|
if self.width > 0 && self.width + w_len > self.term_width {
|
||||||
|
self.flush_line(out);
|
||||||
|
}
|
||||||
|
if w_len > self.term_width && self.width == 0 {
|
||||||
|
// Split an overlong word across multiple lines.
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut cur_w = 0;
|
||||||
|
for ch in word.chars() {
|
||||||
|
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||||
|
if cur_w + ch_w > self.term_width && cur_w > 0 {
|
||||||
|
self.push_segment(cur.clone(), style);
|
||||||
|
self.flush_line(out);
|
||||||
|
cur.clear();
|
||||||
|
cur_w = 0;
|
||||||
|
}
|
||||||
|
cur.push(ch);
|
||||||
|
cur_w += ch_w;
|
||||||
|
}
|
||||||
|
if !cur.is_empty() {
|
||||||
|
self.push_segment(cur, style);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.push_segment(word.clone(), style);
|
||||||
|
}
|
||||||
|
word.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
|
||||||
|
if ws.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let space_w = display_width(ws);
|
||||||
|
if self.width > 0 && self.width + space_w > self.term_width {
|
||||||
|
self.flush_line(out);
|
||||||
|
}
|
||||||
|
if self.width > 0 {
|
||||||
|
self.push_segment(" ".to_string(), style);
|
||||||
|
}
|
||||||
|
ws.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
|
||||||
|
let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
|
||||||
|
let mut physical: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
for logical in lines.into_iter() {
|
||||||
|
if logical.spans.is_empty() {
|
||||||
|
physical.push(logical);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = LineBuilder::new(term_width);
|
||||||
|
let mut buf_space = String::new();
|
||||||
|
|
||||||
|
for span in logical.spans.into_iter() {
|
||||||
|
let style = span.style;
|
||||||
|
let mut buf_word = String::new();
|
||||||
|
|
||||||
|
for ch in span.content.chars() {
|
||||||
|
if ch == '\n' {
|
||||||
|
builder.push_word(&mut buf_word, style, &mut physical);
|
||||||
|
buf_space.clear();
|
||||||
|
builder.flush_line(&mut physical);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
builder.push_word(&mut buf_word, style, &mut physical);
|
||||||
|
buf_space.push(ch);
|
||||||
|
} else {
|
||||||
|
builder.consume_whitespace(&mut buf_space, style, &mut physical);
|
||||||
|
buf_word.push(ch);
|
||||||
|
}
|
||||||
|
if builder.width >= term_width {
|
||||||
|
builder.flush_line(&mut physical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.push_word(&mut buf_word, style, &mut physical);
|
||||||
|
// whitespace intentionally left to allow collapsing across spans
|
||||||
|
}
|
||||||
|
if !builder.spans.is_empty() {
|
||||||
|
physical.push(Line::from(std::mem::take(&mut builder.spans)));
|
||||||
|
} else {
|
||||||
|
// Preserve explicit blank line (e.g. due to a trailing newline).
|
||||||
|
physical.push(Line::from(Vec::<Span<'static>>::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = physical.len() as u16;
|
||||||
|
terminal
|
||||||
|
.insert_before(total, |buf| {
|
||||||
|
let width = buf.area.width;
|
||||||
|
for (i, line) in physical.into_iter().enumerate() {
|
||||||
|
let area = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: i as u16,
|
||||||
|
width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
Paragraph::new(line).render(area, buf);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
@@ -33,10 +33,10 @@ mod file_search;
|
|||||||
mod get_git_diff;
|
mod get_git_diff;
|
||||||
mod git_warning_screen;
|
mod git_warning_screen;
|
||||||
mod history_cell;
|
mod history_cell;
|
||||||
|
mod insert_history;
|
||||||
mod log_layer;
|
mod log_layer;
|
||||||
mod login_screen;
|
mod login_screen;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
mod mouse_capture;
|
|
||||||
mod scroll_event_helper;
|
mod scroll_event_helper;
|
||||||
mod slash_command;
|
mod slash_command;
|
||||||
mod status_indicator_widget;
|
mod status_indicator_widget;
|
||||||
@@ -47,7 +47,10 @@ mod user_approval_widget;
|
|||||||
|
|
||||||
pub use cli::Cli;
|
pub use cli::Cli;
|
||||||
|
|
||||||
pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::Result<()> {
|
pub fn run_main(
|
||||||
|
cli: Cli,
|
||||||
|
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||||
|
) -> std::io::Result<codex_core::protocol::TokenUsage> {
|
||||||
let (sandbox_mode, approval_policy) = if cli.full_auto {
|
let (sandbox_mode, approval_policy) = if cli.full_auto {
|
||||||
(
|
(
|
||||||
Some(SandboxMode::WorkspaceWrite),
|
Some(SandboxMode::WorkspaceWrite),
|
||||||
@@ -147,24 +150,8 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::
|
|||||||
// `--allow-no-git-exec` flag.
|
// `--allow-no-git-exec` flag.
|
||||||
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
|
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
|
||||||
|
|
||||||
try_run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx);
|
run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
|
||||||
Ok(())
|
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::print_stderr,
|
|
||||||
reason = "Resort to stderr in exceptional situations."
|
|
||||||
)]
|
|
||||||
fn try_run_ratatui_app(
|
|
||||||
cli: Cli,
|
|
||||||
config: Config,
|
|
||||||
show_login_screen: bool,
|
|
||||||
show_git_warning: bool,
|
|
||||||
log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
|
||||||
) {
|
|
||||||
if let Err(report) = run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) {
|
|
||||||
eprintln!("Error: {report:?}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_ratatui_app(
|
fn run_ratatui_app(
|
||||||
@@ -173,16 +160,15 @@ fn run_ratatui_app(
|
|||||||
show_login_screen: bool,
|
show_login_screen: bool,
|
||||||
show_git_warning: bool,
|
show_git_warning: bool,
|
||||||
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||||
) -> color_eyre::Result<()> {
|
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
// Forward panic reports through the tracing stack so that they appear in
|
// Forward panic reports through tracing so they appear in the UI status
|
||||||
// the status indicator instead of breaking the alternate screen – the
|
// line instead of interleaving raw panic output with the interface.
|
||||||
// normal colour‑eyre hook writes to stderr which would corrupt the UI.
|
|
||||||
std::panic::set_hook(Box::new(|info| {
|
std::panic::set_hook(Box::new(|info| {
|
||||||
tracing::error!("panic: {info}");
|
tracing::error!("panic: {info}");
|
||||||
}));
|
}));
|
||||||
let (mut terminal, mut mouse_capture) = tui::init(&config)?;
|
let mut terminal = tui::init(&config)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
|
|
||||||
let Cli { prompt, images, .. } = cli;
|
let Cli { prompt, images, .. } = cli;
|
||||||
@@ -204,10 +190,12 @@ fn run_ratatui_app(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let app_result = app.run(&mut terminal, &mut mouse_capture);
|
let app_result = app.run(&mut terminal);
|
||||||
|
let usage = app.token_usage();
|
||||||
|
|
||||||
restore();
|
restore();
|
||||||
app_result
|
// ignore error when collecting usage – report underlying error instead
|
||||||
|
app_result.map(|_| usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
#[expect(
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ fn main() -> anyhow::Result<()> {
|
|||||||
.config_overrides
|
.config_overrides
|
||||||
.raw_overrides
|
.raw_overrides
|
||||||
.splice(0..0, top_cli.config_overrides.raw_overrides);
|
.splice(0..0, top_cli.config_overrides.raw_overrides);
|
||||||
run_main(inner, codex_linux_sandbox_exe)?;
|
let usage = run_main(inner, codex_linux_sandbox_exe)?;
|
||||||
|
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
use crossterm::event::DisableMouseCapture;
|
|
||||||
use crossterm::event::EnableMouseCapture;
|
|
||||||
use ratatui::crossterm::execute;
|
|
||||||
use std::io::Result;
|
|
||||||
use std::io::stdout;
|
|
||||||
|
|
||||||
pub(crate) struct MouseCapture {
|
|
||||||
mouse_capture_is_active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MouseCapture {
|
|
||||||
pub(crate) fn new_with_capture(mouse_capture_is_active: bool) -> Result<Self> {
|
|
||||||
if mouse_capture_is_active {
|
|
||||||
enable_capture()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
mouse_capture_is_active,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MouseCapture {
|
|
||||||
/// Idempotent method to set the mouse capture state.
|
|
||||||
pub fn set_active(&mut self, is_active: bool) -> Result<()> {
|
|
||||||
match (self.mouse_capture_is_active, is_active) {
|
|
||||||
(true, true) => {}
|
|
||||||
(false, false) => {}
|
|
||||||
(true, false) => {
|
|
||||||
disable_capture()?;
|
|
||||||
self.mouse_capture_is_active = false;
|
|
||||||
}
|
|
||||||
(false, true) => {
|
|
||||||
enable_capture()?;
|
|
||||||
self.mouse_capture_is_active = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn toggle(&mut self) -> Result<()> {
|
|
||||||
self.set_active(!self.mouse_capture_is_active)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn disable(&mut self) -> Result<()> {
|
|
||||||
if self.mouse_capture_is_active {
|
|
||||||
disable_capture()?;
|
|
||||||
self.mouse_capture_is_active = false;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for MouseCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if self.disable().is_err() {
|
|
||||||
// The user is likely shutting down, so ignore any errors so the
|
|
||||||
// shutdown process can complete.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enable_capture() -> Result<()> {
|
|
||||||
execute!(stdout(), EnableMouseCapture)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disable_capture() -> Result<()> {
|
|
||||||
execute!(stdout(), DisableMouseCapture)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ pub enum SlashCommand {
|
|||||||
New,
|
New,
|
||||||
Diff,
|
Diff,
|
||||||
Quit,
|
Quit,
|
||||||
ToggleMouseMode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SlashCommand {
|
impl SlashCommand {
|
||||||
@@ -23,9 +22,6 @@ impl SlashCommand {
|
|||||||
pub fn description(self) -> &'static str {
|
pub fn description(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
SlashCommand::New => "Start a new chat.",
|
SlashCommand::New => "Start a new chat.",
|
||||||
SlashCommand::ToggleMouseMode => {
|
|
||||||
"Toggle mouse mode (enable for scrolling, disable for text selection)"
|
|
||||||
}
|
|
||||||
SlashCommand::Quit => "Exit the application.",
|
SlashCommand::Quit => "Exit the application.",
|
||||||
SlashCommand::Diff => {
|
SlashCommand::Diff => {
|
||||||
"Show git diff of the working directory (including untracked files)"
|
"Show git diff of the working directory (including untracked files)"
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ pub(crate) struct StatusIndicatorWidget {
|
|||||||
/// time).
|
/// time).
|
||||||
text: String,
|
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: Arc<AtomicUsize>,
|
frame_idx: Arc<AtomicUsize>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
// Keep one sender alive to prevent the channel from closing while the
|
// Keep one sender alive to prevent the channel from closing while the
|
||||||
@@ -50,7 +45,7 @@ pub(crate) struct StatusIndicatorWidget {
|
|||||||
|
|
||||||
impl StatusIndicatorWidget {
|
impl StatusIndicatorWidget {
|
||||||
/// Create a new status indicator and start the animation timer.
|
/// Create a new status indicator and start the animation timer.
|
||||||
pub(crate) fn new(app_event_tx: AppEventSender, height: u16) -> Self {
|
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
|
||||||
let frame_idx = Arc::new(AtomicUsize::new(0));
|
let frame_idx = Arc::new(AtomicUsize::new(0));
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
@@ -72,18 +67,12 @@ impl StatusIndicatorWidget {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
text: String::from("waiting for logs…"),
|
text: String::from("waiting for logs…"),
|
||||||
height: height.max(3),
|
|
||||||
frame_idx,
|
frame_idx,
|
||||||
running,
|
running,
|
||||||
_app_event_tx: app_event_tx,
|
_app_event_tx: app_event_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferred height in terminal rows.
|
|
||||||
pub(crate) fn get_height(&self) -> u16 {
|
|
||||||
self.height
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the line that is displayed in the widget.
|
/// Update the line that is displayed in the widget.
|
||||||
pub(crate) fn update_text(&mut self, text: String) {
|
pub(crate) fn update_text(&mut self, text: String) {
|
||||||
self.text = text.replace(['\n', '\r'], " ");
|
self.text = text.replace(['\n', '\r'], " ");
|
||||||
|
|||||||
@@ -4,31 +4,39 @@ use std::io::stdout;
|
|||||||
|
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use crossterm::event::DisableBracketedPaste;
|
use crossterm::event::DisableBracketedPaste;
|
||||||
use crossterm::event::DisableMouseCapture;
|
|
||||||
use crossterm::event::EnableBracketedPaste;
|
use crossterm::event::EnableBracketedPaste;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
use ratatui::TerminalOptions;
|
||||||
|
use ratatui::Viewport;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
use ratatui::crossterm::terminal::EnterAlternateScreen;
|
|
||||||
use ratatui::crossterm::terminal::LeaveAlternateScreen;
|
|
||||||
use ratatui::crossterm::terminal::disable_raw_mode;
|
use ratatui::crossterm::terminal::disable_raw_mode;
|
||||||
use ratatui::crossterm::terminal::enable_raw_mode;
|
use ratatui::crossterm::terminal::enable_raw_mode;
|
||||||
|
|
||||||
use crate::mouse_capture::MouseCapture;
|
|
||||||
|
|
||||||
/// A type alias for the terminal type used in this application
|
/// A type alias for the terminal type used in this application
|
||||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
||||||
/// Initialize the terminal
|
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||||
pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> {
|
pub fn init(_config: &Config) -> Result<Tui> {
|
||||||
execute!(stdout(), EnterAlternateScreen)?;
|
|
||||||
execute!(stdout(), EnableBracketedPaste)?;
|
execute!(stdout(), EnableBracketedPaste)?;
|
||||||
let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?;
|
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
set_panic_hook();
|
set_panic_hook();
|
||||||
let tui = Terminal::new(CrosstermBackend::new(stdout()))?;
|
|
||||||
Ok((tui, mouse_capture))
|
// Reserve a fixed number of lines for the interactive viewport (composer,
|
||||||
|
// status, popups). History is injected above using `insert_before`. This
|
||||||
|
// is an initial step of the refactor – later the height can become
|
||||||
|
// dynamic. For now a conservative default keeps enough room for the
|
||||||
|
// multi‑line composer while not occupying the whole screen.
|
||||||
|
const BOTTOM_VIEWPORT_HEIGHT: u16 = 8;
|
||||||
|
let backend = CrosstermBackend::new(stdout());
|
||||||
|
let tui = Terminal::with_options(
|
||||||
|
backend,
|
||||||
|
TerminalOptions {
|
||||||
|
viewport: Viewport::Inline(BOTTOM_VIEWPORT_HEIGHT),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(tui)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_panic_hook() {
|
fn set_panic_hook() {
|
||||||
@@ -41,14 +49,7 @@ fn set_panic_hook() {
|
|||||||
|
|
||||||
/// Restore the terminal to its original state
|
/// Restore the terminal to its original state
|
||||||
pub fn restore() -> Result<()> {
|
pub fn restore() -> Result<()> {
|
||||||
// We are shutting down, and we cannot reference the `MouseCapture`, so we
|
|
||||||
// categorically disable mouse capture just to be safe.
|
|
||||||
if execute!(stdout(), DisableMouseCapture).is_err() {
|
|
||||||
// It is possible that `DisableMouseCapture` is written more than once
|
|
||||||
// on shutdown, so ignore the error in this case.
|
|
||||||
}
|
|
||||||
execute!(stdout(), DisableBracketedPaste)?;
|
execute!(stdout(), DisableBracketedPaste)?;
|
||||||
execute!(stdout(), LeaveAlternateScreen)?;
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,10 +116,6 @@ pub(crate) struct UserApprovalWidget<'a> {
|
|||||||
done: bool,
|
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<'_> {
|
impl UserApprovalWidget<'_> {
|
||||||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||||
let input = Input::default();
|
let input = Input::default();
|
||||||
@@ -190,28 +186,6 @@ impl UserApprovalWidget<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
|
||||||
// Should cache this for last value of width.
|
// Should cache this for last value of width.
|
||||||
self.confirmation_prompt.line_count(width) as u16
|
self.confirmation_prompt.line_count(width) as u16
|
||||||
@@ -333,7 +307,32 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
|||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded);
|
.border_type(BorderType::Rounded);
|
||||||
let inner = outer.inner(area);
|
let inner = outer.inner(area);
|
||||||
let prompt_height = self.get_confirmation_prompt_height(inner.width);
|
|
||||||
|
// Determine how many rows we can allocate for the static confirmation
|
||||||
|
// prompt while *always* keeping enough space for the interactive
|
||||||
|
// response area (select list or input field). When the full prompt
|
||||||
|
// would exceed the available height we truncate it so the response
|
||||||
|
// options never get pushed out of view. This keeps the approval modal
|
||||||
|
// usable even when the overall bottom viewport is small.
|
||||||
|
|
||||||
|
// Full height of the prompt (may be larger than the available area).
|
||||||
|
let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
|
||||||
|
|
||||||
|
// Minimum rows that must remain for the interactive section.
|
||||||
|
let min_response_rows = match self.mode {
|
||||||
|
Mode::Select => SELECT_OPTIONS.len() as u16,
|
||||||
|
// In input mode we need exactly two rows: one for the guidance
|
||||||
|
// prompt and one for the single-line input field.
|
||||||
|
Mode::Input => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clamp prompt height so confirmation + response never exceed the
|
||||||
|
// available space. `saturating_sub` avoids underflow when the area is
|
||||||
|
// too small even for the minimal layout – in this unlikely case we
|
||||||
|
// fall back to zero-height prompt so at least the options are
|
||||||
|
// visible.
|
||||||
|
let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||||||
@@ -342,8 +341,7 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
|||||||
let response_chunk = chunks[1];
|
let response_chunk = chunks[1];
|
||||||
|
|
||||||
// Build the inner lines based on the mode. Collect them into a List of
|
// 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)
|
// non-wrapping lines rather than a Paragraph for predictable layout.
|
||||||
// depends on this behavior for its calculation.
|
|
||||||
let lines = match self.mode {
|
let lines = match self.mode {
|
||||||
Mode::Select => SELECT_OPTIONS
|
Mode::Select => SELECT_OPTIONS
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
Reference in New Issue
Block a user