This replaces tui-textarea with a custom textarea component. Key differences: 1. wrapped lines 2. better unicode handling 3. uses the native terminal cursor This should perhaps be spun out into its own separate crate at some point, but for now it's convenient to have it in-tree.
554 lines
20 KiB
Rust
554 lines
20 KiB
Rust
use std::collections::HashMap;
|
||
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;
|
||
use codex_core::config::Config;
|
||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||
use codex_core::protocol::AgentMessageEvent;
|
||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||
use codex_core::protocol::AgentReasoningEvent;
|
||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||
use codex_core::protocol::ErrorEvent;
|
||
use codex_core::protocol::Event;
|
||
use codex_core::protocol::EventMsg;
|
||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||
use codex_core::protocol::ExecCommandBeginEvent;
|
||
use codex_core::protocol::ExecCommandEndEvent;
|
||
use codex_core::protocol::InputItem;
|
||
use codex_core::protocol::McpToolCallBeginEvent;
|
||
use codex_core::protocol::McpToolCallEndEvent;
|
||
use codex_core::protocol::Op;
|
||
use codex_core::protocol::PatchApplyBeginEvent;
|
||
use codex_core::protocol::TaskCompleteEvent;
|
||
use codex_core::protocol::TokenUsage;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::widgets::Widget;
|
||
use ratatui::widgets::WidgetRef;
|
||
use tokio::sync::mpsc::UnboundedSender;
|
||
use tokio::sync::mpsc::unbounded_channel;
|
||
|
||
use crate::app_event::AppEvent;
|
||
use crate::app_event_sender::AppEventSender;
|
||
use crate::bottom_pane::BottomPane;
|
||
use crate::bottom_pane::BottomPaneParams;
|
||
use crate::bottom_pane::CancellationEvent;
|
||
use crate::bottom_pane::InputResult;
|
||
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;
|
||
|
||
struct RunningCommand {
|
||
command: Vec<String>,
|
||
#[allow(dead_code)]
|
||
cwd: PathBuf,
|
||
}
|
||
|
||
pub(crate) struct ChatWidget<'a> {
|
||
app_event_tx: AppEventSender,
|
||
codex_op_tx: UnboundedSender<Op>,
|
||
bottom_pane: BottomPane<'a>,
|
||
config: Config,
|
||
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,
|
||
running_commands: HashMap<String, RunningCommand>,
|
||
}
|
||
|
||
struct UserMessage {
|
||
text: String,
|
||
image_paths: Vec<PathBuf>,
|
||
}
|
||
|
||
impl From<String> for UserMessage {
|
||
fn from(text: String) -> Self {
|
||
Self {
|
||
text,
|
||
image_paths: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Option<UserMessage> {
|
||
if text.is_empty() && image_paths.is_empty() {
|
||
None
|
||
} else {
|
||
Some(UserMessage { text, image_paths })
|
||
}
|
||
}
|
||
|
||
impl ChatWidget<'_> {
|
||
pub(crate) fn new(
|
||
config: Config,
|
||
app_event_tx: AppEventSender,
|
||
initial_prompt: Option<String>,
|
||
initial_images: Vec<PathBuf>,
|
||
enhanced_keys_supported: bool,
|
||
) -> Self {
|
||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||
|
||
let app_event_tx_clone = app_event_tx.clone();
|
||
// Create the Codex asynchronously so the UI loads as quickly as possible.
|
||
let config_for_agent_loop = config.clone();
|
||
tokio::spawn(async move {
|
||
let CodexConversation {
|
||
codex,
|
||
session_configured,
|
||
..
|
||
} = match init_codex(config_for_agent_loop).await {
|
||
Ok(vals) => vals,
|
||
Err(e) => {
|
||
// TODO: surface this error to the user.
|
||
tracing::error!("failed to initialize codex: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Forward the captured `SessionInitialized` event that was consumed
|
||
// inside `init_codex()` so it can be rendered in the UI.
|
||
app_event_tx_clone.send(AppEvent::CodexEvent(session_configured.clone()));
|
||
let codex = Arc::new(codex);
|
||
let codex_clone = codex.clone();
|
||
tokio::spawn(async move {
|
||
while let Some(op) = codex_op_rx.recv().await {
|
||
let id = codex_clone.submit(op).await;
|
||
if let Err(e) = id {
|
||
tracing::error!("failed to submit op: {e}");
|
||
}
|
||
}
|
||
});
|
||
|
||
while let Ok(event) = codex.next_event().await {
|
||
app_event_tx_clone.send(AppEvent::CodexEvent(event));
|
||
}
|
||
});
|
||
|
||
Self {
|
||
app_event_tx: app_event_tx.clone(),
|
||
codex_op_tx,
|
||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||
app_event_tx,
|
||
has_input_focus: true,
|
||
enhanced_keys_supported,
|
||
}),
|
||
config,
|
||
initial_user_message: create_initial_user_message(
|
||
initial_prompt.unwrap_or_default(),
|
||
initial_images,
|
||
),
|
||
token_usage: TokenUsage::default(),
|
||
reasoning_buffer: String::new(),
|
||
answer_buffer: String::new(),
|
||
running_commands: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
pub fn desired_height(&self, width: u16) -> u16 {
|
||
self.bottom_pane.desired_height(width)
|
||
}
|
||
|
||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||
if key_event.kind == KeyEventKind::Press {
|
||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||
}
|
||
|
||
match self.bottom_pane.handle_key_event(key_event) {
|
||
InputResult::Submitted(text) => {
|
||
self.submit_user_message(text.into());
|
||
}
|
||
InputResult::None => {}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||
self.bottom_pane.handle_paste(text);
|
||
}
|
||
|
||
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) {
|
||
let UserMessage { text, image_paths } = user_message;
|
||
let mut items: Vec<InputItem> = Vec::new();
|
||
|
||
if !text.is_empty() {
|
||
items.push(InputItem::Text { text: text.clone() });
|
||
}
|
||
|
||
for path in image_paths {
|
||
items.push(InputItem::LocalImage { path });
|
||
}
|
||
|
||
if items.is_empty() {
|
||
return;
|
||
}
|
||
|
||
self.codex_op_tx
|
||
.send(Op::UserInput { items })
|
||
.unwrap_or_else(|e| {
|
||
tracing::error!("failed to send message: {e}");
|
||
});
|
||
|
||
// Persist the text to cross-session message history.
|
||
if !text.is_empty() {
|
||
self.codex_op_tx
|
||
.send(Op::AddToHistory { text: text.clone() })
|
||
.unwrap_or_else(|e| {
|
||
tracing::error!("failed to send AddHistory op: {e}");
|
||
});
|
||
}
|
||
|
||
// Only show text portion in conversation history for now.
|
||
if !text.is_empty() {
|
||
self.add_to_history(HistoryCell::new_user_prompt(text.clone()));
|
||
}
|
||
}
|
||
|
||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||
let Event { id, msg } = event;
|
||
match msg {
|
||
EventMsg::SessionConfigured(event) => {
|
||
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
|
||
// conversation history.
|
||
self.submit_user_message(user_message);
|
||
}
|
||
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::AgentMessage(AgentMessageEvent { 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.add_to_history(HistoryCell::new_agent_message(&self.config, full));
|
||
}
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||
// 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 }) => {
|
||
// 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 }) => {
|
||
// 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 {
|
||
self.reasoning_buffer.clear();
|
||
text
|
||
};
|
||
if !full.is_empty() {
|
||
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full));
|
||
}
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::TaskStarted => {
|
||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||
self.bottom_pane.set_task_running(true);
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::TaskComplete(TaskCompleteEvent {
|
||
last_agent_message: _,
|
||
}) => {
|
||
self.bottom_pane.set_task_running(false);
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::TokenCount(token_usage) => {
|
||
self.token_usage = add_token_usage(&self.token_usage, &token_usage);
|
||
self.bottom_pane
|
||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||
}
|
||
EventMsg::Error(ErrorEvent { message }) => {
|
||
self.add_to_history(HistoryCell::new_error_event(message.clone()));
|
||
self.bottom_pane.set_task_running(false);
|
||
}
|
||
EventMsg::PlanUpdate(update) => {
|
||
self.add_to_history(HistoryCell::new_plan_update(update));
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||
call_id: _,
|
||
command,
|
||
cwd,
|
||
reason,
|
||
}) => {
|
||
// Print the command to the history so it is visible in the
|
||
// transcript *before* the modal asks for approval.
|
||
let cmdline = strip_bash_lc_and_escape(&command);
|
||
let text = format!(
|
||
"command requires approval:\n$ {cmdline}{reason}",
|
||
reason = reason
|
||
.as_ref()
|
||
.map(|r| format!("\n{r}"))
|
||
.unwrap_or_default()
|
||
);
|
||
self.add_to_history(HistoryCell::new_background_event(text));
|
||
|
||
let request = ApprovalRequest::Exec {
|
||
id,
|
||
command,
|
||
cwd,
|
||
reason,
|
||
};
|
||
self.bottom_pane.push_approval_request(request);
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||
call_id: _,
|
||
changes,
|
||
reason,
|
||
grant_root,
|
||
}) => {
|
||
// ------------------------------------------------------------------
|
||
// Before we even prompt the user for approval we surface the patch
|
||
// summary in the main conversation so that the dialog appears in a
|
||
// sensible chronological order:
|
||
// (1) codex → proposes patch (HistoryCell::PendingPatch)
|
||
// (2) UI → asks for approval (BottomPane)
|
||
// This mirrors how command execution is shown (command begins →
|
||
// approval dialog) and avoids surprising the user with a modal
|
||
// prompt before they have seen *what* is being requested.
|
||
// ------------------------------------------------------------------
|
||
|
||
self.add_to_history(HistoryCell::new_patch_event(
|
||
PatchEventType::ApprovalRequest,
|
||
changes,
|
||
));
|
||
|
||
// Now surface the approval request in the BottomPane as before.
|
||
let request = ApprovalRequest::ApplyPatch {
|
||
id,
|
||
reason,
|
||
grant_root,
|
||
};
|
||
self.bottom_pane.push_approval_request(request);
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||
call_id,
|
||
command,
|
||
cwd,
|
||
}) => {
|
||
self.running_commands.insert(
|
||
call_id,
|
||
RunningCommand {
|
||
command: command.clone(),
|
||
cwd: cwd.clone(),
|
||
},
|
||
);
|
||
self.add_to_history(HistoryCell::new_active_exec_command(command));
|
||
}
|
||
EventMsg::ExecCommandOutputDelta(_) => {}
|
||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||
call_id: _,
|
||
auto_approved,
|
||
changes,
|
||
}) => {
|
||
// Even when a patch is auto‑approved we still display the
|
||
// summary so the user can follow along.
|
||
self.add_to_history(HistoryCell::new_patch_event(
|
||
PatchEventType::ApplyBegin { auto_approved },
|
||
changes,
|
||
));
|
||
}
|
||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||
call_id,
|
||
exit_code,
|
||
stdout,
|
||
stderr,
|
||
}) => {
|
||
let cmd = self.running_commands.remove(&call_id);
|
||
self.add_to_history(HistoryCell::new_completed_exec_command(
|
||
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
|
||
CommandOutput {
|
||
exit_code,
|
||
stdout,
|
||
stderr,
|
||
duration: Duration::from_secs(0),
|
||
},
|
||
));
|
||
}
|
||
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||
call_id: _,
|
||
invocation,
|
||
}) => {
|
||
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
|
||
}
|
||
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 {
|
||
offset,
|
||
log_id,
|
||
entry,
|
||
} = event;
|
||
|
||
// Inform bottom pane / composer.
|
||
self.bottom_pane
|
||
.on_history_entry_response(log_id, offset, entry.map(|e| e.text));
|
||
}
|
||
EventMsg::ShutdownComplete => {
|
||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||
}
|
||
event => {
|
||
self.add_to_history(HistoryCell::new_background_event(format!("{event:?}")));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Update the live log preview while a task is running.
|
||
pub(crate) fn update_latest_log(&mut self, line: String) {
|
||
// Forward only if we are currently showing the status indicator.
|
||
self.bottom_pane.update_status_text(line);
|
||
}
|
||
|
||
fn request_redraw(&mut self) {
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
|
||
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone()));
|
||
}
|
||
|
||
/// Forward file-search results to the bottom pane.
|
||
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||
self.bottom_pane.on_file_search_result(query, matches);
|
||
}
|
||
|
||
/// Handle Ctrl-C key press.
|
||
/// Returns CancellationEvent::Handled if the event was consumed by the UI, or
|
||
/// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
|
||
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||
match self.bottom_pane.on_ctrl_c() {
|
||
CancellationEvent::Handled => return CancellationEvent::Handled,
|
||
CancellationEvent::Ignored => {}
|
||
}
|
||
if self.bottom_pane.is_task_running() {
|
||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||
self.submit_op(Op::Interrupt);
|
||
self.answer_buffer.clear();
|
||
self.reasoning_buffer.clear();
|
||
CancellationEvent::Ignored
|
||
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
|
||
self.submit_op(Op::Shutdown);
|
||
CancellationEvent::Handled
|
||
} else {
|
||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||
CancellationEvent::Ignored
|
||
}
|
||
}
|
||
|
||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||
self.bottom_pane.composer_is_empty()
|
||
}
|
||
|
||
/// Forward an `Op` directly to codex.
|
||
pub(crate) fn submit_op(&self, op: Op) {
|
||
if let Err(e) = self.codex_op_tx.send(op) {
|
||
tracing::error!("failed to submit op: {e}");
|
||
}
|
||
}
|
||
|
||
pub(crate) fn token_usage(&self) -> &TokenUsage {
|
||
&self.token_usage
|
||
}
|
||
|
||
pub(crate) fn clear_token_usage(&mut self) {
|
||
self.token_usage = TokenUsage::default();
|
||
self.bottom_pane
|
||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||
}
|
||
|
||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||
self.bottom_pane.cursor_pos(area)
|
||
}
|
||
}
|
||
|
||
impl WidgetRef for &ChatWidget<'_> {
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenUsage {
|
||
let cached_input_tokens = match (
|
||
current_usage.cached_input_tokens,
|
||
new_usage.cached_input_tokens,
|
||
) {
|
||
(Some(current), Some(new)) => Some(current + new),
|
||
(Some(current), None) => Some(current),
|
||
(None, Some(new)) => Some(new),
|
||
(None, None) => None,
|
||
};
|
||
let reasoning_output_tokens = match (
|
||
current_usage.reasoning_output_tokens,
|
||
new_usage.reasoning_output_tokens,
|
||
) {
|
||
(Some(current), Some(new)) => Some(current + new),
|
||
(Some(current), None) => Some(current),
|
||
(None, Some(new)) => Some(new),
|
||
(None, None) => None,
|
||
};
|
||
TokenUsage {
|
||
input_tokens: current_usage.input_tokens + new_usage.input_tokens,
|
||
cached_input_tokens,
|
||
output_tokens: current_usage.output_tokens + new_usage.output_tokens,
|
||
reasoning_output_tokens,
|
||
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
|
||
}
|
||
}
|