remove conversation history widget (#1727)
this widget is no longer used.
This commit is contained in:
@@ -5,7 +5,6 @@ use crate::file_search::FileSearchManager;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
use crate::scroll_event_helper::ScrollEventHelper;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
@@ -13,8 +12,6 @@ use codex_core::protocol::Event;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -77,7 +74,6 @@ impl App<'_> {
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
let pending_redraw = Arc::new(AtomicBool::new(false));
|
||||
let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
|
||||
|
||||
// Spawn a dedicated thread for reading the crossterm event loop and
|
||||
// re-publishing the events as AppEvents, as appropriate.
|
||||
@@ -100,18 +96,6 @@ impl App<'_> {
|
||||
crossterm::event::Event::Resize(_, _) => {
|
||||
app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
crossterm::event::Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::ScrollUp,
|
||||
..
|
||||
}) => {
|
||||
scroll_event_helper.scroll_up();
|
||||
}
|
||||
crossterm::event::Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::ScrollDown,
|
||||
..
|
||||
}) => {
|
||||
scroll_event_helper.scroll_down();
|
||||
}
|
||||
crossterm::event::Event::Paste(pasted) => {
|
||||
// Many terminals convert newlines to \r when
|
||||
// pasting, e.g. [iTerm2][]. But [tui-textarea
|
||||
@@ -259,9 +243,6 @@ impl App<'_> {
|
||||
}
|
||||
};
|
||||
}
|
||||
AppEvent::Scroll(scroll_delta) => {
|
||||
self.dispatch_scroll_event(scroll_delta);
|
||||
}
|
||||
AppEvent::Paste(text) => {
|
||||
self.dispatch_paste_event(text);
|
||||
}
|
||||
@@ -392,13 +373,6 @@ impl App<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta),
|
||||
AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||||
|
||||
@@ -20,10 +20,6 @@ pub(crate) enum AppEvent {
|
||||
/// Text pasted from the terminal clipboard.
|
||||
Paste(String),
|
||||
|
||||
/// Scroll event with a value representing the "scroll delta" as the net
|
||||
/// scroll up/down events within a short time window.
|
||||
Scroll(i32),
|
||||
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
use ratatui::prelude::*;
|
||||
|
||||
/// Trait implemented by every type that can live inside the conversation
|
||||
/// history list. It provides two primitives that the parent scroll-view
|
||||
/// needs: how *tall* the widget is at a given width and how to render an
|
||||
/// arbitrary contiguous *window* of that widget.
|
||||
///
|
||||
/// The `first_visible_line` argument to [`render_window`] allows partial
|
||||
/// rendering when the top of the widget is scrolled off-screen. The caller
|
||||
/// guarantees that `first_visible_line + area.height as usize` never exceeds
|
||||
/// the total height previously returned by [`height`].
|
||||
pub(crate) trait CellWidget {
|
||||
/// Total height measured in wrapped terminal lines when drawn with the
|
||||
/// given *content* width (no scrollbar column included).
|
||||
fn height(&self, width: u16) -> usize;
|
||||
|
||||
/// Render a *window* that starts `first_visible_line` lines below the top
|
||||
/// of the widget. The window’s size is given by `area`.
|
||||
fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::codex_wrapper::CodexConversation;
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
@@ -36,8 +37,9 @@ use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::conversation_history_widget::ConversationHistoryWidget;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -45,7 +47,6 @@ use codex_file_search::FileMatch;
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
conversation_history: ConversationHistoryWidget,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
@@ -127,7 +128,6 @@ impl ChatWidget<'_> {
|
||||
Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
conversation_history: ConversationHistoryWidget::new(),
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
@@ -158,11 +158,9 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
|
||||
/// 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 add_to_history(&mut self, cell: HistoryCell) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistory(cell.plain_lines()));
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
@@ -198,28 +196,18 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Only show text portion in conversation history for now.
|
||||
if !text.is_empty() {
|
||||
self.conversation_history.add_user_message(text.clone());
|
||||
self.emit_last_history_entry();
|
||||
self.add_to_history(HistoryCell::new_user_prompt(text.clone()));
|
||||
}
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
let Event { id, msg } = event;
|
||||
match msg {
|
||||
EventMsg::SessionConfigured(event) => {
|
||||
// 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.
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
// Record session information at the top of the conversation.
|
||||
self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
|
||||
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
// If the user provided an initial message, add it to the
|
||||
@@ -241,9 +229,7 @@ impl ChatWidget<'_> {
|
||||
message
|
||||
};
|
||||
if !full.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_message(&self.config, full);
|
||||
self.emit_last_history_entry();
|
||||
self.add_to_history(HistoryCell::new_agent_message(&self.config, full));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -270,9 +256,7 @@ impl ChatWidget<'_> {
|
||||
text
|
||||
};
|
||||
if !full.is_empty() {
|
||||
self.conversation_history
|
||||
.add_agent_reasoning(&self.config, full);
|
||||
self.emit_last_history_entry();
|
||||
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -293,8 +277,7 @@ impl ChatWidget<'_> {
|
||||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||||
}
|
||||
EventMsg::Error(ErrorEvent { message }) => {
|
||||
self.conversation_history.add_error(message.clone());
|
||||
self.emit_last_history_entry();
|
||||
self.add_to_history(HistoryCell::new_error_event(message.clone()));
|
||||
self.bottom_pane.set_task_running(false);
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
@@ -313,9 +296,7 @@ impl ChatWidget<'_> {
|
||||
.map(|r| format!("\n{r}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
self.conversation_history.add_background_event(text);
|
||||
self.emit_last_history_entry();
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
self.add_to_history(HistoryCell::new_background_event(text));
|
||||
|
||||
let request = ApprovalRequest::Exec {
|
||||
id,
|
||||
@@ -343,11 +324,10 @@ impl ChatWidget<'_> {
|
||||
// prompt before they have seen *what* is being requested.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
self.conversation_history
|
||||
.add_patch_event(PatchEventType::ApprovalRequest, changes);
|
||||
self.emit_last_history_entry();
|
||||
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
self.add_to_history(HistoryCell::new_patch_event(
|
||||
PatchEventType::ApprovalRequest,
|
||||
changes,
|
||||
));
|
||||
|
||||
// Now surface the approval request in the BottomPane as before.
|
||||
let request = ApprovalRequest::ApplyPatch {
|
||||
@@ -359,13 +339,11 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id,
|
||||
call_id: _,
|
||||
command,
|
||||
cwd: _,
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.add_active_exec_command(call_id, command);
|
||||
self.emit_last_history_entry();
|
||||
self.add_to_history(HistoryCell::new_active_exec_command(command));
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
@@ -375,12 +353,10 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
// Even when a patch is auto‑approved we still display the
|
||||
// summary so the user can follow along.
|
||||
self.conversation_history
|
||||
.add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes);
|
||||
self.emit_last_history_entry();
|
||||
if !auto_approved {
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
}
|
||||
self.add_to_history(HistoryCell::new_patch_event(
|
||||
PatchEventType::ApplyBegin { auto_approved },
|
||||
changes,
|
||||
));
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
@@ -389,27 +365,39 @@ impl ChatWidget<'_> {
|
||||
stdout,
|
||||
stderr,
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.record_completed_exec_command(call_id, stdout, stderr, exit_code);
|
||||
self.request_redraw();
|
||||
self.add_to_history(HistoryCell::new_completed_exec_command(
|
||||
call_id,
|
||||
CommandOutput {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
duration: Duration::from_secs(0),
|
||||
},
|
||||
));
|
||||
}
|
||||
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||
call_id,
|
||||
server,
|
||||
tool,
|
||||
arguments,
|
||||
call_id: _,
|
||||
invocation,
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.add_active_mcp_tool_call(call_id, server, tool, arguments);
|
||||
self.emit_last_history_entry();
|
||||
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => {
|
||||
let success = mcp_tool_call_end_event.is_success();
|
||||
let McpToolCallEndEvent { call_id, result } = mcp_tool_call_end_event;
|
||||
self.conversation_history
|
||||
.record_completed_mcp_tool_call(call_id, success, result);
|
||||
self.request_redraw();
|
||||
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: _,
|
||||
duration,
|
||||
invocation,
|
||||
result,
|
||||
}) => {
|
||||
self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
|
||||
80,
|
||||
invocation,
|
||||
duration,
|
||||
result
|
||||
.as_ref()
|
||||
.map(|r| r.is_error.unwrap_or(false))
|
||||
.unwrap_or(false),
|
||||
result,
|
||||
));
|
||||
}
|
||||
EventMsg::GetHistoryEntryResponse(event) => {
|
||||
let codex_core::protocol::GetHistoryEntryResponseEvent {
|
||||
@@ -426,9 +414,7 @@ impl ChatWidget<'_> {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
event => {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("{event:?}"));
|
||||
self.emit_last_history_entry();
|
||||
self.add_to_history(HistoryCell::new_background_event(format!("{event:?}")));
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -445,22 +431,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||||
self.conversation_history
|
||||
.add_diff_output(diff_output.clone());
|
||||
self.emit_last_history_entry();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) {
|
||||
// If the user is trying to scroll exactly one line, we let them, but
|
||||
// otherwise we assume they are trying to scroll in larger increments.
|
||||
let magnified_scroll_delta = if scroll_delta == 1 {
|
||||
1
|
||||
} else {
|
||||
// Play with this: perhaps it should be non-linear?
|
||||
scroll_delta * 2
|
||||
};
|
||||
self.conversation_history.scroll(magnified_scroll_delta);
|
||||
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone()));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
use crate::cell_widget::CellWidget;
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::*;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::cell::Cell as StdCell;
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A single history entry plus its cached wrapped-line count.
|
||||
struct Entry {
|
||||
cell: HistoryCell,
|
||||
line_count: Cell<usize>,
|
||||
}
|
||||
|
||||
pub struct ConversationHistoryWidget {
|
||||
entries: Vec<Entry>,
|
||||
/// The width (in terminal cells/columns) that [`Entry::line_count`] was
|
||||
/// computed for. When the available width changes we recompute counts.
|
||||
cached_width: StdCell<u16>,
|
||||
scroll_position: usize,
|
||||
/// Number of lines the last time render_ref() was called
|
||||
num_rendered_lines: StdCell<usize>,
|
||||
/// The height of the viewport last time render_ref() was called
|
||||
last_viewport_height: StdCell<usize>,
|
||||
has_input_focus: bool,
|
||||
}
|
||||
|
||||
impl ConversationHistoryWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
cached_width: StdCell::new(0),
|
||||
scroll_position: usize::MAX,
|
||||
num_rendered_lines: StdCell::new(0),
|
||||
last_viewport_height: StdCell::new(0),
|
||||
has_input_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Negative delta scrolls up; positive delta scrolls down.
|
||||
pub(crate) fn scroll(&mut self, delta: i32) {
|
||||
match delta.cmp(&0) {
|
||||
std::cmp::Ordering::Less => self.scroll_up(-delta as u32),
|
||||
std::cmp::Ordering::Greater => self.scroll_down(delta as u32),
|
||||
std::cmp::Ordering::Equal => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self, num_lines: u32) {
|
||||
// If a user is scrolling up from the "stick to bottom" mode, we need to
|
||||
// map this to a specific scroll position so we can calculate the delta.
|
||||
// This requires us to care about how tall the screen is.
|
||||
if self.scroll_position == usize::MAX {
|
||||
self.scroll_position = self
|
||||
.num_rendered_lines
|
||||
.get()
|
||||
.saturating_sub(self.last_viewport_height.get());
|
||||
}
|
||||
|
||||
self.scroll_position = self.scroll_position.saturating_sub(num_lines as usize);
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, num_lines: u32) {
|
||||
// If we're already pinned to the bottom there's nothing to do.
|
||||
if self.scroll_position == usize::MAX {
|
||||
return;
|
||||
}
|
||||
|
||||
let viewport_height = self.last_viewport_height.get().max(1);
|
||||
let num_rendered_lines = self.num_rendered_lines.get();
|
||||
|
||||
// Compute the maximum explicit scroll offset that still shows a full
|
||||
// viewport. This mirrors the calculation in `scroll_page_down()` and
|
||||
// in the render path.
|
||||
let max_scroll = num_rendered_lines.saturating_sub(viewport_height);
|
||||
|
||||
let new_pos = self.scroll_position.saturating_add(num_lines as usize);
|
||||
|
||||
if new_pos >= max_scroll {
|
||||
// Reached (or passed) the bottom – switch to stick‑to‑bottom mode
|
||||
// so that additional output keeps the view pinned automatically.
|
||||
self.scroll_position = usize::MAX;
|
||||
} else {
|
||||
self.scroll_position = new_pos;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_position = usize::MAX;
|
||||
}
|
||||
|
||||
/// Note `model` could differ from `config.model` if the agent decided to
|
||||
/// use a different model than the one requested by the user.
|
||||
pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) {
|
||||
// In practice, SessionConfiguredEvent should always be the first entry
|
||||
// in the history, but it is possible that an error could be sent
|
||||
// before the session info.
|
||||
let has_welcome_message = self
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry.cell, HistoryCell::WelcomeMessage { .. }));
|
||||
self.add_to_history(HistoryCell::new_session_info(
|
||||
config,
|
||||
event,
|
||||
!has_welcome_message,
|
||||
));
|
||||
}
|
||||
|
||||
pub fn add_user_message(&mut self, message: String) {
|
||||
self.add_to_history(HistoryCell::new_user_prompt(message));
|
||||
}
|
||||
|
||||
pub fn add_agent_message(&mut self, config: &Config, message: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_message(config, message));
|
||||
}
|
||||
|
||||
pub fn add_agent_reasoning(&mut self, config: &Config, text: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
|
||||
}
|
||||
|
||||
pub fn add_background_event(&mut self, message: String) {
|
||||
self.add_to_history(HistoryCell::new_background_event(message));
|
||||
}
|
||||
|
||||
pub fn add_diff_output(&mut self, diff_output: String) {
|
||||
self.add_to_history(HistoryCell::new_diff_output(diff_output));
|
||||
}
|
||||
|
||||
pub fn add_error(&mut self, message: String) {
|
||||
self.add_to_history(HistoryCell::new_error_event(message));
|
||||
}
|
||||
|
||||
/// Add a pending patch entry (before user approval).
|
||||
pub fn add_patch_event(
|
||||
&mut self,
|
||||
event_type: PatchEventType,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
) {
|
||||
self.add_to_history(HistoryCell::new_patch_event(event_type, changes));
|
||||
}
|
||||
|
||||
pub fn add_active_exec_command(&mut self, call_id: String, command: Vec<String>) {
|
||||
self.add_to_history(HistoryCell::new_active_exec_command(call_id, command));
|
||||
}
|
||||
|
||||
pub fn add_active_mcp_tool_call(
|
||||
&mut self,
|
||||
call_id: String,
|
||||
server: String,
|
||||
tool: String,
|
||||
arguments: Option<JsonValue>,
|
||||
) {
|
||||
self.add_to_history(HistoryCell::new_active_mcp_tool_call(
|
||||
call_id, server, tool, arguments,
|
||||
));
|
||||
}
|
||||
|
||||
fn add_to_history(&mut self, cell: HistoryCell) {
|
||||
let width = self.cached_width.get();
|
||||
let count = if width > 0 { cell.height(width) } else { 0 };
|
||||
|
||||
self.entries.push(Entry {
|
||||
cell,
|
||||
line_count: Cell::new(count),
|
||||
});
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&mut self,
|
||||
call_id: String,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
exit_code: i32,
|
||||
) {
|
||||
let width = self.cached_width.get();
|
||||
for entry in self.entries.iter_mut() {
|
||||
let cell = &mut entry.cell;
|
||||
if let HistoryCell::ActiveExecCommand {
|
||||
call_id: history_id,
|
||||
command,
|
||||
start,
|
||||
..
|
||||
} = cell
|
||||
{
|
||||
if &call_id == history_id {
|
||||
*cell = HistoryCell::new_completed_exec_command(
|
||||
command.clone(),
|
||||
CommandOutput {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
duration: start.elapsed(),
|
||||
},
|
||||
);
|
||||
|
||||
// Update cached line count.
|
||||
if width > 0 {
|
||||
entry.line_count.set(cell.height(width));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_completed_mcp_tool_call(
|
||||
&mut self,
|
||||
call_id: String,
|
||||
success: bool,
|
||||
result: Result<mcp_types::CallToolResult, String>,
|
||||
) {
|
||||
let width = self.cached_width.get();
|
||||
for entry in self.entries.iter_mut() {
|
||||
if let HistoryCell::ActiveMcpToolCall {
|
||||
call_id: history_id,
|
||||
invocation,
|
||||
start,
|
||||
..
|
||||
} = &entry.cell
|
||||
{
|
||||
if &call_id == history_id {
|
||||
let completed = HistoryCell::new_completed_mcp_tool_call(
|
||||
width,
|
||||
invocation.clone(),
|
||||
*start,
|
||||
success,
|
||||
result,
|
||||
);
|
||||
entry.cell = completed;
|
||||
|
||||
if width > 0 {
|
||||
entry.line_count.set(entry.cell.height(width));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for ConversationHistoryWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (title, border_style) = if self.has_input_focus {
|
||||
(
|
||||
"Messages (↑/↓ or j/k = line, b/space = page)",
|
||||
Style::default().fg(Color::LightYellow),
|
||||
)
|
||||
} else {
|
||||
("Messages (tab to focus)", Style::default().dim())
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style);
|
||||
|
||||
// Compute the inner area that will be available for the list after
|
||||
// the surrounding `Block` is drawn.
|
||||
let inner = block.inner(area);
|
||||
let viewport_height = inner.height as usize;
|
||||
|
||||
// Cache (and if necessary recalculate) the wrapped line counts for every
|
||||
// [`HistoryCell`] so that our scrolling math accounts for text
|
||||
// wrapping. We always reserve one column on the right-hand side for the
|
||||
// scrollbar so that the content never renders "under" the scrollbar.
|
||||
let effective_width = inner.width.saturating_sub(1);
|
||||
|
||||
if effective_width == 0 {
|
||||
return; // Nothing to draw – avoid division by zero.
|
||||
}
|
||||
|
||||
// Recompute cache if the effective width changed.
|
||||
let num_lines: usize = if self.cached_width.get() != effective_width {
|
||||
self.cached_width.set(effective_width);
|
||||
|
||||
let mut num_lines: usize = 0;
|
||||
for entry in &self.entries {
|
||||
let count = entry.cell.height(effective_width);
|
||||
num_lines += count;
|
||||
entry.line_count.set(count);
|
||||
}
|
||||
num_lines
|
||||
} else {
|
||||
self.entries.iter().map(|e| e.line_count.get()).sum()
|
||||
};
|
||||
|
||||
// Determine the scroll position. Note the existing value of
|
||||
// `self.scroll_position` could exceed the maximum scroll offset if the
|
||||
// user made the window wider since the last render.
|
||||
let max_scroll = num_lines.saturating_sub(viewport_height);
|
||||
let scroll_pos = if self.scroll_position == usize::MAX {
|
||||
max_scroll
|
||||
} else {
|
||||
self.scroll_position.min(max_scroll)
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Render order:
|
||||
// 1. Clear full widget area (avoid artifacts from prior frame).
|
||||
// 2. Draw the surrounding Block (border and title).
|
||||
// 3. Render *each* visible HistoryCell into its own sub-Rect while
|
||||
// respecting partial visibility at the top and bottom.
|
||||
// 4. Draw the scrollbar track / thumb in the reserved column.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Clear entire widget area first.
|
||||
Clear.render(area, buf);
|
||||
|
||||
// Draw border + title.
|
||||
block.render(area, buf);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Calculate which cells are visible for the current scroll position
|
||||
// and paint them one by one.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
let mut y_cursor = inner.y; // first line inside viewport
|
||||
let mut remaining_height = inner.height as usize;
|
||||
let mut lines_to_skip = scroll_pos; // number of wrapped lines to skip (above viewport)
|
||||
|
||||
for entry in &self.entries {
|
||||
let cell_height = entry.line_count.get();
|
||||
|
||||
// Completely above viewport? Skip whole cell.
|
||||
if lines_to_skip >= cell_height {
|
||||
lines_to_skip -= cell_height;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine how much of this cell is visible.
|
||||
let visible_height = (cell_height - lines_to_skip).min(remaining_height);
|
||||
|
||||
if visible_height == 0 {
|
||||
break; // no space left
|
||||
}
|
||||
|
||||
let cell_rect = Rect {
|
||||
x: inner.x,
|
||||
y: y_cursor,
|
||||
width: effective_width,
|
||||
height: visible_height as u16,
|
||||
};
|
||||
|
||||
entry.cell.render_window(lines_to_skip, cell_rect, buf);
|
||||
|
||||
// Advance cursor inside viewport.
|
||||
y_cursor += visible_height as u16;
|
||||
remaining_height -= visible_height;
|
||||
|
||||
// After the first (possibly partially skipped) cell, we no longer
|
||||
// need to skip lines at the top.
|
||||
lines_to_skip = 0;
|
||||
|
||||
if remaining_height == 0 {
|
||||
break; // viewport filled
|
||||
}
|
||||
}
|
||||
|
||||
// Always render a scrollbar *track* so the reserved column is filled.
|
||||
let overflow = num_lines.saturating_sub(viewport_height);
|
||||
|
||||
let mut scroll_state = ScrollbarState::default()
|
||||
// The Scrollbar widget expects the *content* height minus the
|
||||
// viewport height. When there is no overflow we still provide 0
|
||||
// so that the widget renders only the track without a thumb.
|
||||
.content_length(overflow)
|
||||
.position(scroll_pos);
|
||||
|
||||
{
|
||||
// Choose a thumb color that stands out only when this pane has focus so that the
|
||||
// user's attention is naturally drawn to the active viewport. When unfocused we show
|
||||
// a low-contrast thumb so the scrollbar fades into the background without becoming
|
||||
// invisible.
|
||||
let thumb_style = if self.has_input_focus {
|
||||
Style::reset().fg(Color::LightYellow)
|
||||
} else {
|
||||
Style::reset().fg(Color::Gray)
|
||||
};
|
||||
|
||||
// By default the Scrollbar widget inherits any style that was
|
||||
// present in the underlying buffer cells. That means if a colored
|
||||
// line happens to be underneath the scrollbar, the track (and
|
||||
// potentially the thumb) adopt that color. Explicitly setting the
|
||||
// track/thumb styles ensures we always draw the scrollbar with a
|
||||
// consistent palette regardless of what content is behind it.
|
||||
StatefulWidget::render(
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("↑"))
|
||||
.end_symbol(Some("↓"))
|
||||
.begin_style(Style::reset().fg(Color::DarkGray))
|
||||
.end_style(Style::reset().fg(Color::DarkGray))
|
||||
.thumb_symbol("█")
|
||||
.thumb_style(thumb_style)
|
||||
.track_symbol(Some("│"))
|
||||
.track_style(Style::reset().fg(Color::DarkGray)),
|
||||
inner,
|
||||
buf,
|
||||
&mut scroll_state,
|
||||
);
|
||||
}
|
||||
|
||||
// Update auxiliary stats that the scroll handlers rely on.
|
||||
self.num_rendered_lines.set(num_lines);
|
||||
self.last_viewport_height.set(viewport_height);
|
||||
}
|
||||
}
|
||||
|
||||
/// Common [`Wrap`] configuration used for both measurement and rendering so
|
||||
/// they stay in sync.
|
||||
#[inline]
|
||||
pub(crate) const fn wrap_cfg() -> ratatui::widgets::Wrap {
|
||||
ratatui::widgets::Wrap { trim: false }
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::cell_widget::CellWidget;
|
||||
use crate::exec_command::escape_command;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::text_block::TextBlock;
|
||||
@@ -11,11 +10,10 @@ use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::model_supports_reasoning_summaries;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use image::DynamicImage;
|
||||
use image::GenericImageView;
|
||||
use image::ImageReader;
|
||||
use lazy_static::lazy_static;
|
||||
use mcp_types::EmbeddedResourceResource;
|
||||
use mcp_types::ResourceLink;
|
||||
use ratatui::prelude::*;
|
||||
@@ -24,14 +22,10 @@ use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line as RtLine;
|
||||
use ratatui::text::Span as RtSpan;
|
||||
use ratatui_image::Image as TuiImage;
|
||||
use ratatui_image::Resize as ImgResize;
|
||||
use ratatui_image::picker::ProtocolType;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tracing::error;
|
||||
|
||||
pub(crate) struct CommandOutput {
|
||||
@@ -46,6 +40,21 @@ pub(crate) enum PatchEventType {
|
||||
ApplyBegin { auto_approved: bool },
|
||||
}
|
||||
|
||||
fn span_to_static(span: &Span) -> Span<'static> {
|
||||
Span {
|
||||
style: span.style,
|
||||
content: std::borrow::Cow::Owned(span.content.clone().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_to_static(line: &Line) -> Line<'static> {
|
||||
Line {
|
||||
style: line.style,
|
||||
alignment: line.alignment,
|
||||
spans: line.spans.iter().map(span_to_static).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an event to display in the conversation history. Returns its
|
||||
/// `Vec<Line<'static>>` representation to make it easier to display in a
|
||||
/// scrollable list.
|
||||
@@ -63,25 +72,13 @@ pub(crate) enum HistoryCell {
|
||||
AgentReasoning { view: TextBlock },
|
||||
|
||||
/// An exec tool call that has not finished yet.
|
||||
ActiveExecCommand {
|
||||
call_id: String,
|
||||
/// The shell command, escaped and formatted.
|
||||
command: String,
|
||||
start: Instant,
|
||||
view: TextBlock,
|
||||
},
|
||||
ActiveExecCommand { view: TextBlock },
|
||||
|
||||
/// Completed exec tool call.
|
||||
CompletedExecCommand { view: TextBlock },
|
||||
|
||||
/// An MCP tool call that has not finished yet.
|
||||
ActiveMcpToolCall {
|
||||
call_id: String,
|
||||
/// Formatted line that shows the command name and arguments
|
||||
invocation: Line<'static>,
|
||||
start: Instant,
|
||||
view: TextBlock,
|
||||
},
|
||||
ActiveMcpToolCall { view: TextBlock },
|
||||
|
||||
/// Completed MCP tool call where we show the result serialized as JSON.
|
||||
CompletedMcpToolCall { view: TextBlock },
|
||||
@@ -94,13 +91,7 @@ pub(crate) enum HistoryCell {
|
||||
// resized version avoids doing the potentially expensive rescale twice
|
||||
// because the scroll-view first calls `height()` for layouting and then
|
||||
// `render_window()` for painting.
|
||||
CompletedMcpToolCallWithImageOutput {
|
||||
image: DynamicImage,
|
||||
/// Cached data derived from the current terminal width. The cache is
|
||||
/// invalidated whenever the width changes (e.g. when the user
|
||||
/// resizes the window).
|
||||
render_cache: std::cell::RefCell<Option<ImageRenderCache>>,
|
||||
},
|
||||
CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
|
||||
|
||||
/// Background event.
|
||||
BackgroundEvent { view: TextBlock },
|
||||
@@ -140,7 +131,9 @@ impl HistoryCell {
|
||||
| HistoryCell::CompletedMcpToolCall { view }
|
||||
| HistoryCell::PendingPatch { view }
|
||||
| HistoryCell::ActiveExecCommand { view, .. }
|
||||
| HistoryCell::ActiveMcpToolCall { view, .. } => view.lines.clone(),
|
||||
| HistoryCell::ActiveMcpToolCall { view, .. } => {
|
||||
view.lines.iter().map(line_to_static).collect()
|
||||
}
|
||||
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
|
||||
Line::from("tool result (image output omitted)"),
|
||||
Line::from(""),
|
||||
@@ -252,9 +245,8 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_exec_command(call_id: String, command: Vec<String>) -> Self {
|
||||
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
|
||||
let command_escaped = escape_command(&command);
|
||||
let start = Instant::now();
|
||||
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from(vec!["command".magenta(), " running...".dim()]),
|
||||
@@ -263,9 +255,6 @@ impl HistoryCell {
|
||||
];
|
||||
|
||||
HistoryCell::ActiveExecCommand {
|
||||
call_id,
|
||||
command: command_escaped,
|
||||
start,
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
@@ -310,41 +299,15 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_mcp_tool_call(
|
||||
call_id: String,
|
||||
server: String,
|
||||
tool: String,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> Self {
|
||||
// Format the arguments as compact JSON so they roughly fit on one
|
||||
// line. If there are no arguments we keep it empty so the invocation
|
||||
// mirrors a function-style call.
|
||||
let args_str = arguments
|
||||
.as_ref()
|
||||
.map(|v| {
|
||||
// Use compact form to keep things short but readable.
|
||||
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let invocation_spans = vec![
|
||||
Span::styled(server, Style::default().fg(Color::Blue)),
|
||||
Span::raw("."),
|
||||
Span::styled(tool, Style::default().fg(Color::Blue)),
|
||||
Span::raw("("),
|
||||
Span::styled(args_str, Style::default().fg(Color::Gray)),
|
||||
Span::raw(")"),
|
||||
];
|
||||
let invocation = Line::from(invocation_spans);
|
||||
|
||||
let start = Instant::now();
|
||||
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self {
|
||||
let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]);
|
||||
let lines: Vec<Line<'static>> = vec![title_line, invocation.clone(), Line::from("")];
|
||||
let lines: Vec<Line> = vec![
|
||||
title_line,
|
||||
format_mcp_invocation(invocation.clone()),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
HistoryCell::ActiveMcpToolCall {
|
||||
call_id,
|
||||
invocation,
|
||||
start,
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
@@ -382,10 +345,7 @@ impl HistoryCell {
|
||||
}
|
||||
};
|
||||
|
||||
Some(HistoryCell::CompletedMcpToolCallWithImageOutput {
|
||||
image,
|
||||
render_cache: std::cell::RefCell::new(None),
|
||||
})
|
||||
Some(HistoryCell::CompletedMcpToolCallWithImageOutput { _image: image })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -396,8 +356,8 @@ impl HistoryCell {
|
||||
|
||||
pub(crate) fn new_completed_mcp_tool_call(
|
||||
num_cols: u16,
|
||||
invocation: Line<'static>,
|
||||
start: Instant,
|
||||
invocation: McpInvocation,
|
||||
duration: Duration,
|
||||
success: bool,
|
||||
result: Result<mcp_types::CallToolResult, String>,
|
||||
) -> Self {
|
||||
@@ -405,7 +365,7 @@ impl HistoryCell {
|
||||
return cell;
|
||||
}
|
||||
|
||||
let duration = format_duration(start.elapsed());
|
||||
let duration = format_duration(duration);
|
||||
let status_str = if success { "success" } else { "failed" };
|
||||
let title_line = Line::from(vec![
|
||||
"tool".magenta(),
|
||||
@@ -420,7 +380,7 @@ impl HistoryCell {
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(title_line);
|
||||
lines.push(invocation);
|
||||
lines.push(format_mcp_invocation(invocation));
|
||||
|
||||
match result {
|
||||
Ok(mcp_types::CallToolResult { content, .. }) => {
|
||||
@@ -581,85 +541,6 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `CellWidget` implementation – most variants delegate to their internal
|
||||
// `TextBlock`. Variants that need custom painting can add their own logic in
|
||||
// the match arms.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl CellWidget for HistoryCell {
|
||||
fn height(&self, width: u16) -> usize {
|
||||
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.height(width),
|
||||
HistoryCell::CompletedMcpToolCallWithImageOutput {
|
||||
image,
|
||||
render_cache,
|
||||
} => ensure_image_cache(image, width, render_cache),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) {
|
||||
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.render_window(first_visible_line, area, buf)
|
||||
}
|
||||
HistoryCell::CompletedMcpToolCallWithImageOutput {
|
||||
image,
|
||||
render_cache,
|
||||
} => {
|
||||
// Ensure we have a cached, resized copy that matches the current width.
|
||||
// `height()` should have prepared the cache, but if something invalidated it
|
||||
// (e.g. the first `render_window()` call happens *before* `height()` after a
|
||||
// resize) we rebuild it here.
|
||||
|
||||
let width_cells = area.width;
|
||||
|
||||
// Ensure the cache is up-to-date and extract the scaled image.
|
||||
let _ = ensure_image_cache(image, width_cells, render_cache);
|
||||
|
||||
let Some(resized) = render_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| c.scaled_image.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let picker = &*TERMINAL_PICKER;
|
||||
|
||||
if let Ok(protocol) = picker.new_protocol(resized, area, ImgResize::Fit(None)) {
|
||||
let img_widget = TuiImage::new(&protocol);
|
||||
img_widget.render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
|
||||
// Build a concise, human‑readable summary list similar to the
|
||||
// `git status` short format so the user can reason about the
|
||||
@@ -692,119 +573,23 @@ fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
|
||||
summaries
|
||||
}
|
||||
|
||||
// -------------------------------------
|
||||
// Helper types for image rendering
|
||||
// -------------------------------------
|
||||
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
let args_str = invocation
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|v| {
|
||||
// Use compact form to keep things short but readable.
|
||||
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
/// Cached information for rendering an image inside a conversation cell.
|
||||
///
|
||||
/// The cache ties the resized image to a *specific* content width (in
|
||||
/// terminal cells). Whenever the terminal is resized and the width changes
|
||||
/// we need to re-compute the scaled variant so that it still fits the
|
||||
/// available space. Keeping the resized copy around saves a costly rescale
|
||||
/// between the back-to-back `height()` and `render_window()` calls that the
|
||||
/// scroll-view performs while laying out the UI.
|
||||
pub(crate) struct ImageRenderCache {
|
||||
/// Width in *terminal cells* the cached image was generated for.
|
||||
width_cells: u16,
|
||||
/// Height in *terminal rows* that the conversation cell must occupy so
|
||||
/// the whole image becomes visible.
|
||||
height_rows: usize,
|
||||
/// The resized image that fits the given width / height constraints.
|
||||
scaled_image: DynamicImage,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref TERMINAL_PICKER: ratatui_image::picker::Picker = {
|
||||
use ratatui_image::picker::Picker;
|
||||
use ratatui_image::picker::cap_parser::QueryStdioOptions;
|
||||
|
||||
// Ask the terminal for capabilities and explicit font size. Request the
|
||||
// Kitty *text-sizing protocol* as a fallback mechanism for terminals
|
||||
// (like iTerm2) that do not reply to the standard CSI 16/18 queries.
|
||||
match Picker::from_query_stdio_with_options(QueryStdioOptions {
|
||||
text_sizing_protocol: true,
|
||||
}) {
|
||||
Ok(picker) => picker,
|
||||
Err(err) => {
|
||||
// Fall back to the conservative default that assumes ~8×16 px cells.
|
||||
// Still better than breaking the build in a headless test run.
|
||||
tracing::warn!("terminal capability query failed: {err:?}; using default font size");
|
||||
Picker::from_fontsize((8, 16))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Resize `image` to fit into `width_cells`×10-rows keeping the original aspect
|
||||
/// ratio. The function updates `render_cache` and returns the number of rows
|
||||
/// (<= 10) the picture will occupy.
|
||||
fn ensure_image_cache(
|
||||
image: &DynamicImage,
|
||||
width_cells: u16,
|
||||
render_cache: &std::cell::RefCell<Option<ImageRenderCache>>,
|
||||
) -> usize {
|
||||
if let Some(cache) = render_cache.borrow().as_ref() {
|
||||
if cache.width_cells == width_cells {
|
||||
return cache.height_rows;
|
||||
}
|
||||
}
|
||||
|
||||
let picker = &*TERMINAL_PICKER;
|
||||
let (char_w_px, char_h_px) = picker.font_size();
|
||||
|
||||
// Heuristic to compensate for Hi-DPI terminals (iTerm2 on Retina Mac) that
|
||||
// report logical pixels (≈ 8×16) while the iTerm2 graphics protocol
|
||||
// expects *device* pixels. Empirically the device-pixel-ratio is almost
|
||||
// always 2 on macOS Retina panels.
|
||||
let hidpi_scale = if picker.protocol_type() == ProtocolType::Iterm2 {
|
||||
2.0f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// The fallback Halfblocks protocol encodes two pixel rows per cell, so each
|
||||
// terminal *row* represents only half the (possibly scaled) font height.
|
||||
let effective_char_h_px: f64 = if picker.protocol_type() == ProtocolType::Halfblocks {
|
||||
(char_h_px as f64) * hidpi_scale / 2.0
|
||||
} else {
|
||||
(char_h_px as f64) * hidpi_scale
|
||||
};
|
||||
|
||||
let char_w_px_f64 = (char_w_px as f64) * hidpi_scale;
|
||||
|
||||
const MAX_ROWS: f64 = 10.0;
|
||||
let max_height_px: f64 = effective_char_h_px * MAX_ROWS;
|
||||
|
||||
let (orig_w_px, orig_h_px) = {
|
||||
let (w, h) = image.dimensions();
|
||||
(w as f64, h as f64)
|
||||
};
|
||||
|
||||
if orig_w_px == 0.0 || orig_h_px == 0.0 || width_cells == 0 {
|
||||
*render_cache.borrow_mut() = None;
|
||||
return 0;
|
||||
}
|
||||
|
||||
let max_w_px = char_w_px_f64 * width_cells as f64;
|
||||
let scale_w = max_w_px / orig_w_px;
|
||||
let scale_h = max_height_px / orig_h_px;
|
||||
let scale = scale_w.min(scale_h).min(1.0);
|
||||
|
||||
use image::imageops::FilterType;
|
||||
let scaled_w_px = (orig_w_px * scale).round().max(1.0) as u32;
|
||||
let scaled_h_px = (orig_h_px * scale).round().max(1.0) as u32;
|
||||
|
||||
let scaled_image = image.resize(scaled_w_px, scaled_h_px, FilterType::Lanczos3);
|
||||
|
||||
let height_rows = ((scaled_h_px as f64 / effective_char_h_px).ceil()) as usize;
|
||||
|
||||
let new_cache = ImageRenderCache {
|
||||
width_cells,
|
||||
height_rows,
|
||||
scaled_image,
|
||||
};
|
||||
*render_cache.borrow_mut() = Some(new_cache);
|
||||
|
||||
height_rows
|
||||
let invocation_spans = vec![
|
||||
Span::styled(invocation.server.clone(), Style::default().fg(Color::Blue)),
|
||||
Span::raw("."),
|
||||
Span::styled(invocation.tool.clone(), Style::default().fg(Color::Blue)),
|
||||
Span::raw("("),
|
||||
Span::styled(args_str, Style::default().fg(Color::Gray)),
|
||||
Span::raw(")"),
|
||||
];
|
||||
Line::from(invocation_spans)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Insert `lines` above the viewport.
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
|
||||
let mut area = terminal.get_frame().area();
|
||||
|
||||
@@ -24,11 +24,9 @@ mod app;
|
||||
mod app_event;
|
||||
mod app_event_sender;
|
||||
mod bottom_pane;
|
||||
mod cell_widget;
|
||||
mod chatwidget;
|
||||
mod citation_regex;
|
||||
mod cli;
|
||||
mod conversation_history_widget;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
mod get_git_diff;
|
||||
@@ -37,7 +35,6 @@ mod history_cell;
|
||||
mod insert_history;
|
||||
mod log_layer;
|
||||
mod markdown;
|
||||
mod scroll_event_helper;
|
||||
mod slash_command;
|
||||
mod status_indicator_widget;
|
||||
mod text_block;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicI32;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
pub(crate) struct ScrollEventHelper {
|
||||
app_event_tx: AppEventSender,
|
||||
scroll_delta: Arc<AtomicI32>,
|
||||
timer_scheduled: Arc<AtomicBool>,
|
||||
runtime: Handle,
|
||||
}
|
||||
|
||||
/// How long to wait after the first scroll event before sending the
|
||||
/// accumulated scroll delta to the main thread.
|
||||
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Utility to debounce scroll events so we can determine the **magnitude** of
|
||||
/// each scroll burst by accumulating individual wheel events over a short
|
||||
/// window. The debounce timer now runs on Tokio so we avoid spinning up a new
|
||||
/// operating-system thread for every burst.
|
||||
impl ScrollEventHelper {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
app_event_tx,
|
||||
scroll_delta: Arc::new(AtomicI32::new(0)),
|
||||
timer_scheduled: Arc::new(AtomicBool::new(false)),
|
||||
runtime: Handle::current(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_up(&self) {
|
||||
self.scroll_delta.fetch_sub(1, Ordering::Relaxed);
|
||||
self.schedule_notification();
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_down(&self) {
|
||||
self.scroll_delta.fetch_add(1, Ordering::Relaxed);
|
||||
self.schedule_notification();
|
||||
}
|
||||
|
||||
/// Starts a one-shot timer **only once** per burst of wheel events.
|
||||
fn schedule_notification(&self) {
|
||||
// If the timer is already scheduled, do nothing.
|
||||
if self
|
||||
.timer_scheduled
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, schedule a new timer.
|
||||
let tx = self.app_event_tx.clone();
|
||||
let delta = Arc::clone(&self.scroll_delta);
|
||||
let timer_flag = Arc::clone(&self.timer_scheduled);
|
||||
|
||||
// Use self.runtime instead of tokio::spawn() because the calling thread
|
||||
// in app.rs is not part of the Tokio runtime: it is a plain OS thread.
|
||||
self.runtime.spawn(async move {
|
||||
sleep(DEBOUNCE_WINDOW).await;
|
||||
|
||||
let accumulated = delta.swap(0, Ordering::SeqCst);
|
||||
if accumulated != 0 {
|
||||
tx.send(AppEvent::Scroll(accumulated));
|
||||
}
|
||||
|
||||
timer_flag.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::cell_widget::CellWidget;
|
||||
use ratatui::prelude::*;
|
||||
|
||||
/// A simple widget that just displays a list of `Line`s via a `Paragraph`.
|
||||
@@ -13,20 +12,3 @@ impl TextBlock {
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl CellWidget for TextBlock {
|
||||
fn height(&self, width: u16) -> usize {
|
||||
// Use the same wrapping configuration as ConversationHistoryWidget so
|
||||
// measurement stays in sync with rendering.
|
||||
ratatui::widgets::Paragraph::new(self.lines.clone())
|
||||
.wrap(crate::conversation_history_widget::wrap_cfg())
|
||||
.line_count(width)
|
||||
}
|
||||
|
||||
fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) {
|
||||
ratatui::widgets::Paragraph::new(self.lines.clone())
|
||||
.wrap(crate::conversation_history_widget::wrap_cfg())
|
||||
.scroll((first_visible_line as u16, 0))
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user