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-textarea",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
None => {
|
||||
let mut tui_cli = cli.interactive;
|
||||
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)) => {
|
||||
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);
|
||||
|
||||
@@ -498,14 +498,5 @@ Options that are specific to the TUI.
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
# This will make it so that Codex does not try to process mouse events, which
|
||||
# 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`
|
||||
# More to come here
|
||||
```
|
||||
|
||||
@@ -76,20 +76,7 @@ pub enum HistoryPersistence {
|
||||
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
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,
|
||||
}
|
||||
pub struct Tui {}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
//! between user and agent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::str::FromStr; // Added for FinalOutput Display implementation
|
||||
|
||||
use mcp_types::CallToolResult;
|
||||
use serde::Deserialize;
|
||||
@@ -358,6 +359,36 @@ pub struct TokenUsage {
|
||||
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)]
|
||||
pub struct AgentMessageEvent {
|
||||
pub message: String,
|
||||
|
||||
@@ -58,6 +58,7 @@ tui-input = "0.14.0"
|
||||
tui-markdown = "0.3.3"
|
||||
tui-textarea = "0.7.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.1"
|
||||
uuid = "1"
|
||||
|
||||
[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::GitWarningScreen;
|
||||
use crate::login_screen::LoginScreen;
|
||||
use crate::mouse_capture::MouseCapture;
|
||||
use crate::scroll_event_helper::ScrollEventHelper;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
@@ -197,17 +196,17 @@ impl App<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn run(
|
||||
&mut self,
|
||||
terminal: &mut tui::Tui,
|
||||
mouse_capture: &mut MouseCapture,
|
||||
) -> Result<()> {
|
||||
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::RequestRedraw);
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
match event {
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
crate::insert_history::insert_history_lines(terminal, lines);
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
AppEvent::RequestRedraw => {
|
||||
self.schedule_redraw();
|
||||
}
|
||||
@@ -287,11 +286,6 @@ impl App<'_> {
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
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 => {
|
||||
break;
|
||||
}
|
||||
@@ -332,6 +326,15 @@ impl App<'_> {
|
||||
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<()> {
|
||||
// TODO: add a throttle to avoid redrawing too often
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
@@ -49,4 +50,6 @@ pub(crate) enum AppEvent {
|
||||
query: String,
|
||||
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()
|
||||
}
|
||||
|
||||
fn calculate_required_height(&self, area: &Rect) -> u16 {
|
||||
self.current.get_height(area)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
(&self.current).render_ref(area, buf);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ pub(crate) trait BottomPaneView<'a> {
|
||||
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.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
|
||||
|
||||
@@ -22,11 +22,6 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
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";
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
@@ -609,17 +604,6 @@ impl ChatComposer<'_> {
|
||||
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) {
|
||||
struct BlockState {
|
||||
right_title: Line<'static>,
|
||||
|
||||
@@ -65,10 +65,8 @@ impl BottomPane<'_> {
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
let height = self.composer.calculate_required_height(&Rect::default());
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
height,
|
||||
)));
|
||||
}
|
||||
self.request_redraw();
|
||||
@@ -138,10 +136,8 @@ impl BottomPane<'_> {
|
||||
match (running, self.active_view.is_some()) {
|
||||
(true, false) => {
|
||||
// Show status indicator overlay.
|
||||
let height = self.composer.calculate_required_height(&Rect::default());
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
height,
|
||||
)));
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -203,14 +199,6 @@ impl BottomPane<'_> {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -13,9 +12,9 @@ pub(crate) struct StatusIndicatorView {
|
||||
}
|
||||
|
||||
impl StatusIndicatorView {
|
||||
pub fn new(app_event_tx: AppEventSender, height: u16) -> Self {
|
||||
pub fn new(app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
view: StatusIndicatorWidget::new(app_event_tx, height),
|
||||
view: StatusIndicatorWidget::new(app_event_tx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +33,7 @@ impl BottomPaneView<'_> for StatusIndicatorView {
|
||||
true
|
||||
}
|
||||
|
||||
fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||||
self.view.get_height()
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
|
||||
self.view.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
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;
|
||||
@@ -52,6 +49,9 @@ pub(crate) struct ChatWidget<'a> {
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_usage: TokenUsage,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
@@ -220,7 +227,8 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Only show text portion in conversation history for now.
|
||||
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();
|
||||
}
|
||||
@@ -232,6 +240,10 @@ impl ChatWidget<'_> {
|
||||
// Record session information at the top of the conversation.
|
||||
self.conversation_history
|
||||
.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
|
||||
// composer can navigate through past messages.
|
||||
@@ -247,50 +259,50 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
// if the answer buffer is empty, this means we haven't received any
|
||||
// delta. Thus, we need to print the message as a new answer.
|
||||
if self.answer_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_message(&self.config, message);
|
||||
// Final assistant answer. Prefer the fully provided message
|
||||
// from the event; if it is empty fall back to any accumulated
|
||||
// delta buffer (some providers may only stream deltas and send
|
||||
// an empty final message).
|
||||
let full = if message.is_empty() {
|
||||
std::mem::take(&mut self.answer_buffer)
|
||||
} else {
|
||||
self.answer_buffer.clear();
|
||||
message
|
||||
};
|
||||
if !full.is_empty() {
|
||||
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();
|
||||
}
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
if self.answer_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_message(&self.config, "".to_string());
|
||||
}
|
||||
self.answer_buffer.push_str(&delta.clone());
|
||||
self.conversation_history
|
||||
.replace_prev_agent_message(&self.config, self.answer_buffer.clone());
|
||||
self.request_redraw();
|
||||
// Buffer only – do not emit partial lines. This avoids cases
|
||||
// where long responses appear truncated if the terminal
|
||||
// wrapped early. The full message is emitted on
|
||||
// AgentMessage.
|
||||
self.answer_buffer.push_str(&delta);
|
||||
}
|
||||
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||||
if self.reasoning_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_reasoning(&self.config, "".to_string());
|
||||
}
|
||||
self.reasoning_buffer.push_str(&delta.clone());
|
||||
self.conversation_history
|
||||
.replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone());
|
||||
self.request_redraw();
|
||||
// Buffer only – disable incremental reasoning streaming so we
|
||||
// avoid truncated intermediate lines. Full text emitted on
|
||||
// AgentReasoning.
|
||||
self.reasoning_buffer.push_str(&delta);
|
||||
}
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||
// if the reasoning buffer is empty, this means we haven't received any
|
||||
// delta. Thus, we need to print the message as a new reasoning.
|
||||
if self.reasoning_buffer.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_reasoning(&self.config, "".to_string());
|
||||
// Emit full reasoning text once. Some providers might send
|
||||
// final event with empty text if only deltas were used.
|
||||
let full = if text.is_empty() {
|
||||
std::mem::take(&mut self.reasoning_buffer)
|
||||
} else {
|
||||
// else, we rerender one last time.
|
||||
self.reasoning_buffer.clear();
|
||||
text
|
||||
};
|
||||
if !full.is_empty() {
|
||||
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();
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
@@ -310,7 +322,8 @@ impl ChatWidget<'_> {
|
||||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||||
}
|
||||
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);
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
@@ -346,6 +359,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
self.conversation_history
|
||||
.add_patch_event(PatchEventType::ApprovalRequest, changes);
|
||||
self.emit_last_history_entry();
|
||||
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
|
||||
@@ -364,7 +378,8 @@ impl ChatWidget<'_> {
|
||||
cwd: _,
|
||||
}) => {
|
||||
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();
|
||||
}
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
@@ -376,6 +391,7 @@ impl ChatWidget<'_> {
|
||||
// summary so the user can follow along.
|
||||
self.conversation_history
|
||||
.add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes);
|
||||
self.emit_last_history_entry();
|
||||
if !auto_approved {
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
}
|
||||
@@ -399,6 +415,7 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.add_active_mcp_tool_call(call_id, server, tool, arguments);
|
||||
self.emit_last_history_entry();
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => {
|
||||
@@ -425,6 +442,7 @@ impl ChatWidget<'_> {
|
||||
event => {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("{event:?}"));
|
||||
self.emit_last_history_entry();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -441,7 +459,9 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -492,19 +512,18 @@ impl ChatWidget<'_> {
|
||||
tracing::error!("failed to submit op: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> &TokenUsage {
|
||||
&self.token_usage
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let bottom_height = self.bottom_pane.calculate_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);
|
||||
// In the hybrid inline viewport mode we only draw the interactive
|
||||
// bottom pane; history entries are injected directly into scrollback
|
||||
// via `Terminal::insert_before`.
|
||||
(&self.bottom_pane).render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,14 +202,6 @@ impl ConversationHistoryWidget {
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&mut self,
|
||||
call_id: String,
|
||||
@@ -281,40 +249,10 @@ impl ConversationHistoryWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn replace_last_agent_reasoning(&mut self, config: &Config, text: String) {
|
||||
if let Some(idx) = self
|
||||
.entries
|
||||
.iter()
|
||||
.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);
|
||||
}
|
||||
/// Return the lines for the most recently appended entry (if any) so the
|
||||
/// parent widget can surface them via the new scrollback insertion path.
|
||||
pub(crate) fn last_entry_plain_lines(&self) -> Option<Vec<Line<'static>>> {
|
||||
self.entries.last().map(|e| e.cell.plain_lines())
|
||||
}
|
||||
|
||||
pub fn record_completed_exec_command(
|
||||
|
||||
@@ -123,6 +123,30 @@ pub(crate) enum HistoryCell {
|
||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
|
||||
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(
|
||||
config: &Config,
|
||||
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 git_warning_screen;
|
||||
mod history_cell;
|
||||
mod insert_history;
|
||||
mod log_layer;
|
||||
mod login_screen;
|
||||
mod markdown;
|
||||
mod mouse_capture;
|
||||
mod scroll_event_helper;
|
||||
mod slash_command;
|
||||
mod status_indicator_widget;
|
||||
@@ -47,7 +47,10 @@ mod user_approval_widget;
|
||||
|
||||
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 {
|
||||
(
|
||||
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.
|
||||
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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
fn run_ratatui_app(
|
||||
@@ -173,16 +160,15 @@ fn run_ratatui_app(
|
||||
show_login_screen: bool,
|
||||
show_git_warning: bool,
|
||||
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
) -> color_eyre::Result<()> {
|
||||
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
|
||||
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.
|
||||
// Forward panic reports through tracing so they appear in the UI status
|
||||
// line instead of interleaving raw panic output with the interface.
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
tracing::error!("panic: {info}");
|
||||
}));
|
||||
let (mut terminal, mut mouse_capture) = tui::init(&config)?;
|
||||
let mut terminal = tui::init(&config)?;
|
||||
terminal.clear()?;
|
||||
|
||||
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();
|
||||
app_result
|
||||
// ignore error when collecting usage – report underlying error instead
|
||||
app_result.map(|_| usage)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
|
||||
@@ -20,7 +20,8 @@ fn main() -> anyhow::Result<()> {
|
||||
.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(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
Diff,
|
||||
Quit,
|
||||
ToggleMouseMode,
|
||||
}
|
||||
|
||||
impl SlashCommand {
|
||||
@@ -23,9 +22,6 @@ impl SlashCommand {
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::New => "Start a new chat.",
|
||||
SlashCommand::ToggleMouseMode => {
|
||||
"Toggle mouse mode (enable for scrolling, disable for text selection)"
|
||||
}
|
||||
SlashCommand::Quit => "Exit the application.",
|
||||
SlashCommand::Diff => {
|
||||
"Show git diff of the working directory (including untracked files)"
|
||||
|
||||
@@ -34,11 +34,6 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
/// 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: Arc<AtomicUsize>,
|
||||
running: Arc<AtomicBool>,
|
||||
// Keep one sender alive to prevent the channel from closing while the
|
||||
@@ -50,7 +45,7 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
|
||||
impl StatusIndicatorWidget {
|
||||
/// 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 running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
@@ -72,18 +67,12 @@ impl StatusIndicatorWidget {
|
||||
|
||||
Self {
|
||||
text: String::from("waiting for logs…"),
|
||||
height: height.max(3),
|
||||
frame_idx,
|
||||
running,
|
||||
_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.
|
||||
pub(crate) fn update_text(&mut self, text: String) {
|
||||
self.text = text.replace(['\n', '\r'], " ");
|
||||
|
||||
@@ -4,31 +4,39 @@ use std::io::stdout;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::DisableMouseCapture;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::TerminalOptions;
|
||||
use ratatui::Viewport;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
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::enable_raw_mode;
|
||||
|
||||
use crate::mouse_capture::MouseCapture;
|
||||
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
/// Initialize the terminal
|
||||
pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> {
|
||||
execute!(stdout(), EnterAlternateScreen)?;
|
||||
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||
pub fn init(_config: &Config) -> Result<Tui> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
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() {
|
||||
@@ -41,14 +49,7 @@ fn set_panic_hook() {
|
||||
|
||||
/// Restore the terminal to its original state
|
||||
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(), LeaveAlternateScreen)?;
|
||||
disable_raw_mode()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -116,10 +116,6 @@ pub(crate) struct UserApprovalWidget<'a> {
|
||||
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: AppEventSender) -> Self {
|
||||
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 {
|
||||
// Should cache this for last value of width.
|
||||
self.confirmation_prompt.line_count(width) as u16
|
||||
@@ -333,7 +307,32 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded);
|
||||
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()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||||
@@ -342,8 +341,7 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
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.
|
||||
// non-wrapping lines rather than a Paragraph for predictable layout.
|
||||
let lines = match self.mode {
|
||||
Mode::Select => SELECT_OPTIONS
|
||||
.iter()
|
||||
|
||||
Reference in New Issue
Block a user