mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
refactor(tui/chatwidget): finish history helper adoption across TUI
- Replace direct history mutations with shim: - add_to_history -> history_push - history_cells[idx] = … -> history_replace_at - history_cells.remove(i) -> history_remove_at (where safe) - Sweep chatwidget.rs, chatwidget/tools.rs, chatwidget/exec_tools.rs, chatwidget/interrupts.rs - Preserve move-before-assistant helpers where removal return value is required - Keep behavior identical; centralize invalidation/redraw in helpers Validated with ./build-fast.sh (no warnings).
This commit is contained in:
+321
-1495
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
//! Diff overlay key handling extracted from ChatWidget::handle_key_event.
|
||||
|
||||
use super::ChatWidget;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
// Returns true if the key was handled by the diff overlay.
|
||||
pub(super) fn handle_diff_key(chat: &mut ChatWidget<'_>, key_event: KeyEvent) -> bool {
|
||||
// Only operate when an overlay is present
|
||||
let Some(ref mut overlay) = chat.diffs.overlay else { return false };
|
||||
|
||||
// If a confirmation banner is active, only Enter/Esc apply to it.
|
||||
if let Some(confirm) = chat.diffs.confirm.take() {
|
||||
match key_event.code {
|
||||
KeyCode::Enter => {
|
||||
chat.submit_user_message(confirm.text_to_submit.into());
|
||||
chat.request_redraw();
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
chat.diffs.confirm = None;
|
||||
chat.request_redraw();
|
||||
return true;
|
||||
}
|
||||
_ => {
|
||||
// Put it back for other keys
|
||||
chat.diffs.confirm = Some(confirm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Left => {
|
||||
if overlay.selected > 0 { overlay.selected -= 1; }
|
||||
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) { *off = 0; }
|
||||
chat.request_redraw();
|
||||
true
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if overlay.selected + 1 < overlay.tabs.len() { overlay.selected += 1; }
|
||||
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) { *off = 0; }
|
||||
chat.request_redraw();
|
||||
true
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) {
|
||||
let visible_rows = chat.diffs.body_visible_rows.get() as usize;
|
||||
let total_lines: usize = overlay
|
||||
.tabs
|
||||
.get(overlay.selected)
|
||||
.map(|(_, blocks)| blocks.iter().map(|b| b.lines.len()).sum())
|
||||
.unwrap_or(0);
|
||||
let max_off = total_lines.saturating_sub(visible_rows.max(1));
|
||||
let cur = (*off).min(max_off as u16);
|
||||
*off = cur.saturating_sub(1);
|
||||
}
|
||||
chat.request_redraw();
|
||||
true
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) {
|
||||
let visible_rows = chat.diffs.body_visible_rows.get() as usize;
|
||||
let total_lines: usize = overlay
|
||||
.tabs
|
||||
.get(overlay.selected)
|
||||
.map(|(_, blocks)| blocks.iter().map(|b| b.lines.len()).sum())
|
||||
.unwrap_or(0);
|
||||
let max_off = total_lines.saturating_sub(visible_rows.max(1));
|
||||
let next = (*off as usize).saturating_add(1).min(max_off);
|
||||
*off = next as u16;
|
||||
}
|
||||
chat.request_redraw();
|
||||
true
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if let Some((_, blocks)) = overlay.tabs.get(overlay.selected) {
|
||||
let visible_rows = chat.diffs.body_visible_rows.get() as usize;
|
||||
let total_lines: usize = blocks.iter().map(|b| b.lines.len()).sum();
|
||||
let max_off = total_lines.saturating_sub(visible_rows.max(1));
|
||||
let skip_raw = overlay.scroll_offsets.get(overlay.selected).copied().unwrap_or(0) as usize;
|
||||
let skip = skip_raw.min(max_off);
|
||||
let mut start = 0usize;
|
||||
let mut chosen: Option<&super::diff_ui::DiffBlock> = None;
|
||||
for b in blocks {
|
||||
let len = b.lines.len();
|
||||
if start <= skip && skip < start + len { chosen = Some(b); }
|
||||
start += len;
|
||||
}
|
||||
if let Some(block) = chosen {
|
||||
let mut diff_text = String::new();
|
||||
for l in &block.lines {
|
||||
let s: String = l.spans.iter().map(|sp| sp.content.clone()).collect();
|
||||
diff_text.push_str(&s);
|
||||
diff_text.push('\n');
|
||||
}
|
||||
let submit_text = format!("Please undo this:\n{}", diff_text);
|
||||
chat.diffs.confirm = Some(super::diff_ui::DiffConfirm { text_to_submit: submit_text });
|
||||
chat.request_redraw();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some((_, blocks)) = overlay.tabs.get(overlay.selected) {
|
||||
let visible_rows = chat.diffs.body_visible_rows.get() as usize;
|
||||
let total_lines: usize = blocks.iter().map(|b| b.lines.len()).sum();
|
||||
let max_off = total_lines.saturating_sub(visible_rows.max(1));
|
||||
let skip_raw = overlay.scroll_offsets.get(overlay.selected).copied().unwrap_or(0) as usize;
|
||||
let skip = skip_raw.min(max_off);
|
||||
let mut start = 0usize;
|
||||
let mut chosen: Option<&super::diff_ui::DiffBlock> = None;
|
||||
for b in blocks {
|
||||
let len = b.lines.len();
|
||||
if start <= skip && skip < start + len { chosen = Some(b); }
|
||||
start += len;
|
||||
}
|
||||
if let Some(block) = chosen {
|
||||
let mut diff_text = String::new();
|
||||
for l in &block.lines {
|
||||
let s: String = l.spans.iter().map(|sp| sp.content.clone()).collect();
|
||||
diff_text.push_str(&s);
|
||||
diff_text.push('\n');
|
||||
}
|
||||
let prompt = format!(
|
||||
"Can you please explain what this diff does and the reason behind it?\n\n{}",
|
||||
diff_text
|
||||
);
|
||||
chat.submit_user_message(prompt.into());
|
||||
chat.request_redraw();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
chat.diffs.overlay = None;
|
||||
chat.diffs.confirm = None;
|
||||
chat.request_redraw();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Diff overlay types used by the chat widget.
|
||||
//!
|
||||
//! Separated to keep `chatwidget.rs` smaller and focused on behavior.
|
||||
|
||||
use ratatui::text::Line;
|
||||
|
||||
pub struct DiffOverlay {
|
||||
pub tabs: Vec<(String, Vec<DiffBlock>)>,
|
||||
pub selected: usize,
|
||||
pub scroll_offsets: Vec<u16>,
|
||||
}
|
||||
|
||||
impl DiffOverlay {
|
||||
pub fn new(tabs: Vec<(String, Vec<DiffBlock>)>) -> Self {
|
||||
let n = tabs.len();
|
||||
Self { tabs, selected: 0, scroll_offsets: vec![0; n] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DiffBlock {
|
||||
pub lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
pub struct DiffConfirm {
|
||||
pub text_to_submit: String,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Exec and tool call lifecycle helpers for `ChatWidget`.
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::height_manager::HeightEvent;
|
||||
use crate::history_cell::{self, HistoryCell};
|
||||
use crate::history_cell::CommandOutput;
|
||||
use codex_core::protocol::{ExecCommandBeginEvent, ExecCommandEndEvent};
|
||||
|
||||
pub(super) fn finalize_exec_cell_at(
|
||||
chat: &mut ChatWidget<'_>,
|
||||
idx: usize,
|
||||
exit_code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
) {
|
||||
if idx >= chat.history_cells.len() { return; }
|
||||
if let Some(exec) = chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ExecCell>()
|
||||
{
|
||||
if exec.output.is_none() {
|
||||
let completed = history_cell::new_completed_exec_command(
|
||||
exec.command.clone(),
|
||||
exec.parsed.clone(),
|
||||
CommandOutput { exit_code, stdout, stderr },
|
||||
);
|
||||
chat.history_replace_at(idx, Box::new(completed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
|
||||
let interrupted_msg = "Interrupted by user".to_string();
|
||||
let stdout_empty = String::new();
|
||||
let running: Vec<(String, Option<usize>)> = chat
|
||||
.exec
|
||||
.running_commands
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.history_index))
|
||||
.collect();
|
||||
for (_call_id, maybe_idx) in running {
|
||||
if let Some(idx) = maybe_idx {
|
||||
finalize_exec_cell_at(chat, idx, 130, stdout_empty.clone(), interrupted_msg.clone());
|
||||
}
|
||||
}
|
||||
chat.exec.running_commands.clear();
|
||||
|
||||
if !chat.tools_state.running_custom_tools.is_empty() {
|
||||
let entries: Vec<(String, usize)> = chat
|
||||
.tools_state
|
||||
.running_custom_tools
|
||||
.iter()
|
||||
.map(|(k, i)| (k.clone(), *i))
|
||||
.collect();
|
||||
for (_k, idx) in entries {
|
||||
if idx < chat.history_cells.len() {
|
||||
let completed = history_cell::new_completed_custom_tool_call(
|
||||
"custom".to_string(),
|
||||
None,
|
||||
std::time::Duration::from_millis(0),
|
||||
false,
|
||||
"Interrupted by user".to_string(),
|
||||
);
|
||||
chat.history_replace_at(idx, Box::new(completed));
|
||||
}
|
||||
}
|
||||
chat.tools_state.running_custom_tools.clear();
|
||||
chat.invalidate_height_cache();
|
||||
chat.request_redraw();
|
||||
}
|
||||
|
||||
if !chat.tools_state.running_web_search.is_empty() {
|
||||
let entries: Vec<(String, (usize, Option<String>))> = chat
|
||||
.tools_state
|
||||
.running_web_search
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
for (call_id, (idx, query_opt)) in entries {
|
||||
let mut target_idx = None;
|
||||
if idx < chat.history_cells.len() {
|
||||
let is_ws = chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
.is_some_and(|rt| rt.has_title("Web Search..."));
|
||||
if is_ws { target_idx = Some(idx); }
|
||||
}
|
||||
if target_idx.is_none() {
|
||||
for i in (0..chat.history_cells.len()).rev() {
|
||||
if let Some(rt) = chat.history_cells[i]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
{
|
||||
if rt.has_title("Web Search...") { target_idx = Some(i); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = target_idx {
|
||||
if let Some(rt) = chat.history_cells[i]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
{
|
||||
let completed = rt.finalize_web_search(false, query_opt);
|
||||
chat.history_replace_at(i, Box::new(completed));
|
||||
}
|
||||
}
|
||||
chat.tools_state.running_web_search.remove(&call_id);
|
||||
}
|
||||
}
|
||||
|
||||
chat.bottom_pane.update_status_text("cancelled".to_string());
|
||||
let any_tasks_active = !chat.active_task_ids.is_empty();
|
||||
if !any_tasks_active {
|
||||
chat.bottom_pane.set_task_running(false);
|
||||
}
|
||||
if let Some(idx) = chat.exec.running_read_agg_index.take() {
|
||||
if idx < chat.history_cells.len() {
|
||||
if let Some(agg) = chat.history_cells[idx]
|
||||
.as_any_mut()
|
||||
.downcast_mut::<history_cell::ReadAggregationCell>()
|
||||
{
|
||||
agg.finalize();
|
||||
chat.invalidate_height_cache();
|
||||
chat.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
chat.maybe_hide_spinner();
|
||||
}
|
||||
|
||||
pub(super) fn finalize_all_running_due_to_answer(chat: &mut ChatWidget<'_>) {
|
||||
let note = "Completed (final answer received)".to_string();
|
||||
let stdout_empty = String::new();
|
||||
let running: Vec<(String, Option<usize>)> = chat
|
||||
.exec
|
||||
.running_commands
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.history_index))
|
||||
.collect();
|
||||
for (_call_id, maybe_idx) in running {
|
||||
if let Some(idx) = maybe_idx {
|
||||
finalize_exec_cell_at(chat, idx, 0, stdout_empty.clone(), note.clone());
|
||||
}
|
||||
}
|
||||
chat.exec.running_commands.clear();
|
||||
|
||||
if !chat.tools_state.running_custom_tools.is_empty() {
|
||||
let entries: Vec<(String, usize)> = chat
|
||||
.tools_state
|
||||
.running_custom_tools
|
||||
.iter()
|
||||
.map(|(k, i)| (k.clone(), *i))
|
||||
.collect();
|
||||
for (_k, idx) in entries {
|
||||
if idx < chat.history_cells.len() {
|
||||
let completed = history_cell::new_completed_custom_tool_call(
|
||||
"custom".to_string(),
|
||||
None,
|
||||
std::time::Duration::from_millis(0),
|
||||
true,
|
||||
"Final answer received".to_string(),
|
||||
);
|
||||
chat.history_replace_at(idx, Box::new(completed));
|
||||
}
|
||||
}
|
||||
chat.tools_state.running_custom_tools.clear();
|
||||
chat.invalidate_height_cache();
|
||||
chat.request_redraw();
|
||||
}
|
||||
|
||||
if !chat.tools_state.running_web_search.is_empty() {
|
||||
let entries: Vec<(String, (usize, Option<String>))> = chat
|
||||
.tools_state
|
||||
.running_web_search
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
for (call_id, (idx, query_opt)) in entries {
|
||||
let mut target_idx = None;
|
||||
if idx < chat.history_cells.len() {
|
||||
let is_ws = chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
.is_some_and(|rt| rt.has_title("Web Search..."));
|
||||
if is_ws { target_idx = Some(idx); }
|
||||
}
|
||||
if target_idx.is_none() {
|
||||
for i in (0..chat.history_cells.len()).rev() {
|
||||
if let Some(rt) = chat.history_cells[i]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
{
|
||||
if rt.has_title("Web Search...") { target_idx = Some(i); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = target_idx {
|
||||
if let Some(rt) = chat.history_cells[i]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
{
|
||||
let completed = rt.finalize_web_search(true, query_opt);
|
||||
chat.history_replace_at(i, Box::new(completed));
|
||||
}
|
||||
}
|
||||
chat.tools_state.running_web_search.remove(&call_id);
|
||||
}
|
||||
}
|
||||
if let Some(idx) = chat.exec.running_read_agg_index.take() {
|
||||
if idx < chat.history_cells.len() {
|
||||
if let Some(agg) = chat.history_cells[idx]
|
||||
.as_any_mut()
|
||||
.downcast_mut::<history_cell::ReadAggregationCell>()
|
||||
{
|
||||
agg.finalize();
|
||||
chat.invalidate_height_cache();
|
||||
chat.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn try_merge_completed_exec_at(chat: &mut ChatWidget<'_>, idx: usize) {
|
||||
if idx == 0 || idx >= chat.history_cells.len() {
|
||||
return;
|
||||
}
|
||||
let to_kind = |e: &history_cell::ExecCell| -> history_cell::ExecKind {
|
||||
match history_cell::action_from_parsed(&e.parsed) {
|
||||
"read" => history_cell::ExecKind::Read,
|
||||
"search" => history_cell::ExecKind::Search,
|
||||
"list" => history_cell::ExecKind::List,
|
||||
_ => history_cell::ExecKind::Run,
|
||||
}
|
||||
};
|
||||
|
||||
let new_exec = match chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ExecCell>()
|
||||
{
|
||||
Some(e) if e.output.is_some() => e,
|
||||
_ => return,
|
||||
};
|
||||
let new_kind = to_kind(new_exec);
|
||||
if matches!(new_kind, history_cell::ExecKind::Run) { return; }
|
||||
|
||||
if let Some(prev_exec) = chat.history_cells[idx - 1]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ExecCell>()
|
||||
{
|
||||
if prev_exec.output.is_some() {
|
||||
if to_kind(prev_exec) == new_kind {
|
||||
let mut merged = history_cell::MergedExecCell::from_exec(prev_exec);
|
||||
if let Some(current_exec) = chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ExecCell>()
|
||||
{
|
||||
merged.push_exec(current_exec);
|
||||
}
|
||||
chat.history_replace_at(idx - 1, Box::new(merged));
|
||||
chat.history_remove_at(idx);
|
||||
chat.invalidate_height_cache();
|
||||
chat.autoscroll_if_near_bottom();
|
||||
chat.bottom_pane.set_has_chat_history(true);
|
||||
chat.process_animation_cleanup();
|
||||
chat.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut did_merge_into_prev = false;
|
||||
if idx < chat.history_cells.len() {
|
||||
let (left, right) = chat.history_cells.split_at_mut(idx);
|
||||
if let Some(prev_merged) = left[idx - 1]
|
||||
.as_any_mut()
|
||||
.downcast_mut::<history_cell::MergedExecCell>()
|
||||
{
|
||||
if prev_merged.exec_kind() == new_kind {
|
||||
if let Some(current_exec) = right[0]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ExecCell>()
|
||||
{
|
||||
prev_merged.push_exec(current_exec);
|
||||
did_merge_into_prev = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if did_merge_into_prev {
|
||||
chat.history_remove_at(idx);
|
||||
chat.invalidate_height_cache();
|
||||
chat.autoscroll_if_near_bottom();
|
||||
chat.bottom_pane.set_has_chat_history(true);
|
||||
chat.process_animation_cleanup();
|
||||
chat.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_exec_begin_now(chat: &mut ChatWidget<'_>, ev: ExecCommandBeginEvent) {
|
||||
if chat.ended_call_ids.contains(&ev.call_id) { return; }
|
||||
for cell in &chat.history_cells { cell.trigger_fade(); }
|
||||
let parsed_command = ev.parsed_cmd.clone();
|
||||
let action = history_cell::action_from_parsed(&parsed_command);
|
||||
chat.height_manager.borrow_mut().record_event(HeightEvent::RunBegin);
|
||||
|
||||
if action == "read" {
|
||||
chat.exec.running_commands.insert(
|
||||
ev.call_id.clone(),
|
||||
super::RunningCommand { command: ev.command.clone(), parsed: parsed_command.clone(), history_index: None },
|
||||
);
|
||||
let agg_index = match chat.exec.running_read_agg_index {
|
||||
Some(idx) if idx < chat.history_cells.len()
|
||||
&& chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ReadAggregationCell>()
|
||||
.is_some() => Some(idx),
|
||||
_ => None,
|
||||
};
|
||||
let idx = if let Some(i) = agg_index {
|
||||
i
|
||||
} else {
|
||||
let before_len = chat.history_cells.len();
|
||||
chat.history_push(history_cell::ReadAggregationCell::new());
|
||||
let i = if chat.history_cells.len() > 0 { chat.history_cells.len() - 1 } else { before_len };
|
||||
chat.exec.running_read_agg_index = Some(i);
|
||||
i
|
||||
};
|
||||
let tmp = history_cell::new_active_exec_command(ev.command.clone(), parsed_command.clone());
|
||||
let mut lines = tmp.display_lines();
|
||||
if !lines.is_empty() { lines.remove(0); }
|
||||
if let Some(agg) = chat.history_cells[idx]
|
||||
.as_any_mut()
|
||||
.downcast_mut::<history_cell::ReadAggregationCell>()
|
||||
{
|
||||
agg.push_lines(lines);
|
||||
chat.invalidate_height_cache();
|
||||
chat.request_redraw();
|
||||
}
|
||||
chat.bottom_pane.update_status_text("reading files…".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let cell = history_cell::new_active_exec_command(ev.command.clone(), parsed_command.clone());
|
||||
let before_len = chat.history_cells.len();
|
||||
chat.history_push(cell);
|
||||
let idx = if chat.history_cells.len() > 0 { chat.history_cells.len() - 1 } else { before_len };
|
||||
chat.exec.running_commands.insert(
|
||||
ev.call_id.clone(),
|
||||
super::RunningCommand { command: ev.command.clone(), parsed: parsed_command, history_index: Some(idx) },
|
||||
);
|
||||
if !chat.tools_state.running_web_search.is_empty() {
|
||||
chat.bottom_pane.update_status_text("Searched".to_string());
|
||||
} else {
|
||||
let preview = chat
|
||||
.exec
|
||||
.running_commands
|
||||
.get(&ev.call_id)
|
||||
.map(|rc| rc.command.join(" "))
|
||||
.unwrap_or_else(|| "command".to_string());
|
||||
let preview_short = if preview.chars().count() > 40 {
|
||||
let mut truncated: String = preview.chars().take(40).collect();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
} else {
|
||||
preview
|
||||
};
|
||||
chat.bottom_pane
|
||||
.update_status_text(format!("running command: {}", preview_short));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_exec_end_now(chat: &mut ChatWidget<'_>, ev: ExecCommandEndEvent) {
|
||||
chat.ended_call_ids.insert(ev.call_id.clone());
|
||||
let ExecCommandEndEvent { call_id, exit_code, duration: _, stdout, stderr } = ev;
|
||||
let cmd = chat.exec.running_commands.remove(&call_id);
|
||||
chat.height_manager.borrow_mut().record_event(HeightEvent::RunEnd);
|
||||
let (command, parsed, history_index) = cmd
|
||||
.map(|cmd| (cmd.command, cmd.parsed, cmd.history_index))
|
||||
.unwrap_or_else(|| (vec![call_id.clone()], vec![], None));
|
||||
|
||||
let action = history_cell::action_from_parsed(&parsed);
|
||||
if action == "read" {
|
||||
let any_read_running = chat
|
||||
.exec
|
||||
.running_commands
|
||||
.values()
|
||||
.any(|rc| history_cell::action_from_parsed(&rc.parsed) == "read");
|
||||
if !any_read_running {
|
||||
if let Some(idx) = chat.exec.running_read_agg_index.take() {
|
||||
if idx < chat.history_cells.len() {
|
||||
if let Some(agg) = chat.history_cells[idx]
|
||||
.as_any_mut()
|
||||
.downcast_mut::<history_cell::ReadAggregationCell>()
|
||||
{
|
||||
agg.finalize();
|
||||
chat.invalidate_height_cache();
|
||||
chat.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if exit_code == 0 {
|
||||
chat.bottom_pane.update_status_text("files read".to_string());
|
||||
} else {
|
||||
chat.bottom_pane.update_status_text(format!("read failed (exit {})", exit_code));
|
||||
}
|
||||
chat.maybe_hide_spinner();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut completed_opt = Some(history_cell::new_completed_exec_command(
|
||||
command,
|
||||
parsed,
|
||||
CommandOutput { exit_code, stdout, stderr },
|
||||
));
|
||||
|
||||
let mut replaced = false;
|
||||
if let Some(idx) = history_index {
|
||||
if idx < chat.history_cells.len() {
|
||||
let is_match = chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ExecCell>()
|
||||
.map(|e| {
|
||||
if let Some(ref c) = completed_opt {
|
||||
e.output.is_none() && e.command == c.command
|
||||
} else { false }
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if is_match {
|
||||
if let Some(c) = completed_opt.take() { chat.history_replace_at(idx, Box::new(c)); }
|
||||
let pre_len = chat.history_cells.len();
|
||||
replaced = true;
|
||||
try_merge_completed_exec_at(chat, idx);
|
||||
if chat.history_cells.len() == pre_len {
|
||||
chat.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
let mut found: Option<usize> = None;
|
||||
for i in (0..chat.history_cells.len()).rev() {
|
||||
if let Some(exec) = chat.history_cells[i].as_any().downcast_ref::<history_cell::ExecCell>() {
|
||||
let is_same = if let Some(ref c) = completed_opt { exec.command == c.command } else { false };
|
||||
if exec.output.is_none() && is_same { found = Some(i); break; }
|
||||
}
|
||||
}
|
||||
if let Some(i) = found {
|
||||
if let Some(c) = completed_opt.take() { chat.history_replace_at(i, Box::new(c)); }
|
||||
let pre_len = chat.history_cells.len();
|
||||
replaced = true;
|
||||
try_merge_completed_exec_at(chat, i);
|
||||
if chat.history_cells.len() == pre_len { chat.request_redraw(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
if let Some(c) = completed_opt.take() { chat.history_push(c); }
|
||||
}
|
||||
|
||||
if exit_code == 0 {
|
||||
chat.bottom_pane.update_status_text("command completed".to_string());
|
||||
} else {
|
||||
chat.bottom_pane.update_status_text(format!("command failed (exit {})", exit_code));
|
||||
}
|
||||
chat.maybe_hide_spinner();
|
||||
}
|
||||
|
||||
pub(super) fn maybe_move_last_before_final_assistant_exec(chat: &mut ChatWidget<'_>, call_id: &str) {
|
||||
let assistant_idx = match chat.index_of_final_assistant() { Some(i) => i, None => return };
|
||||
if chat.history_cells.is_empty() { return; }
|
||||
let last_idx = chat.history_cells.len() - 1; if last_idx <= assistant_idx { return; }
|
||||
let cell = chat.history_cells.remove(last_idx); chat.history_cells.insert(assistant_idx, cell);
|
||||
if let Some(rc) = chat.exec.running_commands.get_mut(call_id) { rc.history_index = Some(assistant_idx); }
|
||||
chat.invalidate_height_cache(); chat.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn maybe_move_last_before_final_assistant_tool(chat: &mut ChatWidget<'_>, call_id: &str) {
|
||||
let assistant_idx = match chat.index_of_final_assistant() { Some(i) => i, None => return };
|
||||
if chat.history_cells.is_empty() { return; }
|
||||
let last_idx = chat.history_cells.len() - 1; if last_idx <= assistant_idx { return; }
|
||||
let cell = chat.history_cells.remove(last_idx); chat.history_cells.insert(assistant_idx, cell);
|
||||
if let Some(idx) = chat.tools_state.running_custom_tools.get_mut(call_id) { *idx = assistant_idx; }
|
||||
chat.invalidate_height_cache(); chat.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn maybe_move_last_before_final_assistant(chat: &mut ChatWidget<'_>, seq: u64) {
|
||||
let ans_seq = match chat.stream_state.seq_answer_final { Some(s) => s, None => return };
|
||||
if seq >= ans_seq { return; }
|
||||
let assistant_idx = match chat.index_of_final_assistant() { Some(i) => i, None => return };
|
||||
if chat.history_cells.is_empty() { return; }
|
||||
let last_idx = chat.history_cells.len() - 1; if last_idx <= assistant_idx { return; }
|
||||
let cell = chat.history_cells.remove(last_idx); chat.history_cells.insert(assistant_idx, cell);
|
||||
chat.invalidate_height_cache(); chat.request_redraw();
|
||||
}
|
||||
|
||||
// `handle_exec_approval_now` remains on ChatWidget in chatwidget.rs because
|
||||
// it is referenced directly from interrupt handling and is trivial.
|
||||
@@ -10,6 +10,7 @@ use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
|
||||
use super::ChatWidget;
|
||||
use super::tools;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum QueuedInterrupt {
|
||||
@@ -91,16 +92,16 @@ impl InterruptManager {
|
||||
QueuedInterrupt::ExecEnd { ev, .. } => chat.handle_exec_end_now(ev),
|
||||
QueuedInterrupt::McpBegin { ev, .. } => {
|
||||
let call_id = ev.call_id.clone();
|
||||
chat.handle_mcp_begin_now(ev);
|
||||
tools::mcp_begin(chat, ev);
|
||||
chat.maybe_move_last_before_final_assistant_tool(&call_id);
|
||||
}
|
||||
QueuedInterrupt::McpEnd { ev, .. } => chat.handle_mcp_end_now(ev),
|
||||
QueuedInterrupt::McpEnd { ev, .. } => tools::mcp_end(chat, ev),
|
||||
QueuedInterrupt::PatchEnd { seq, ev, .. } => {
|
||||
chat.handle_patch_apply_end_now(ev);
|
||||
chat.maybe_move_last_before_final_assistant(seq);
|
||||
}
|
||||
QueuedInterrupt::PlanUpdate { seq, ev, .. } => {
|
||||
chat.add_to_history(crate::history_cell::new_plan_update(ev));
|
||||
chat.history_push(crate::history_cell::new_plan_update(ev));
|
||||
chat.maybe_move_last_before_final_assistant(seq);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Layout computation and scrolling/HUD helpers for ChatWidget.
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::height_manager::HeightEvent;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
pub(super) fn autoscroll_if_near_bottom(chat: &mut ChatWidget<'_>) {
|
||||
if chat.layout.scroll_offset <= 3 {
|
||||
chat.layout.scroll_offset = 0;
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
chat.height_manager
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::ComposerModeChange);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn page_up(chat: &mut ChatWidget<'_>) {
|
||||
let step = chat.layout.last_history_viewport_height.get().max(1);
|
||||
let new_offset = chat
|
||||
.layout.scroll_offset
|
||||
.saturating_add(step)
|
||||
.min(chat.layout.last_max_scroll.get());
|
||||
chat.layout.scroll_offset = new_offset;
|
||||
chat.bottom_pane.set_compact_compose(true);
|
||||
flash_scrollbar(chat);
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
chat.height_manager.borrow_mut().record_event(crate::height_manager::HeightEvent::UserScroll);
|
||||
}
|
||||
|
||||
pub(super) fn page_down(chat: &mut ChatWidget<'_>) {
|
||||
let step = chat.layout.last_history_viewport_height.get().max(1);
|
||||
if chat.layout.scroll_offset > step {
|
||||
chat.layout.scroll_offset = chat.layout.scroll_offset.saturating_sub(step);
|
||||
} else {
|
||||
chat.layout.scroll_offset = 0;
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
}
|
||||
flash_scrollbar(chat);
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
chat.height_manager.borrow_mut().record_event(crate::height_manager::HeightEvent::UserScroll);
|
||||
}
|
||||
|
||||
pub(super) fn mouse_scroll(chat: &mut ChatWidget<'_>, up: bool) {
|
||||
if up {
|
||||
let new_offset = chat
|
||||
.layout.scroll_offset
|
||||
.saturating_add(3)
|
||||
.min(chat.layout.last_max_scroll.get());
|
||||
chat.layout.scroll_offset = new_offset;
|
||||
flash_scrollbar(chat);
|
||||
if chat.layout.scroll_offset > 0 {
|
||||
chat.bottom_pane.set_compact_compose(true);
|
||||
}
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
} else {
|
||||
if chat.layout.scroll_offset >= 3 {
|
||||
chat.layout.scroll_offset = chat.layout.scroll_offset.saturating_sub(3);
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
} else if chat.layout.scroll_offset > 0 {
|
||||
chat.layout.scroll_offset = 0;
|
||||
chat.app_event_tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
}
|
||||
flash_scrollbar(chat);
|
||||
if chat.layout.scroll_offset == 0 {
|
||||
chat.bottom_pane.set_compact_compose(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn flash_scrollbar(chat: &ChatWidget<'_>) {
|
||||
use std::time::{Duration, Instant};
|
||||
let until = Instant::now() + Duration::from_millis(1200);
|
||||
chat.layout.scrollbar_visible_until.set(Some(until));
|
||||
let tx = chat.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
tx.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn toggle_browser_hud(chat: &mut ChatWidget<'_>) {
|
||||
let new_state = !chat.layout.browser_hud_expanded;
|
||||
chat.layout.browser_hud_expanded = new_state;
|
||||
if new_state { chat.layout.agents_hud_expanded = false; }
|
||||
chat.height_manager.borrow_mut().record_event(HeightEvent::HudToggle(true));
|
||||
chat.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn toggle_agents_hud(chat: &mut ChatWidget<'_>) {
|
||||
let new_state = !chat.layout.agents_hud_expanded;
|
||||
chat.layout.agents_hud_expanded = new_state;
|
||||
if new_state { chat.layout.browser_hud_expanded = false; }
|
||||
chat.height_manager.borrow_mut().record_event(HeightEvent::HudToggle(true));
|
||||
chat.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
|
||||
let has_browser_screenshot = chat
|
||||
.latest_browser_screenshot
|
||||
.lock()
|
||||
.map(|lock| lock.is_some())
|
||||
.unwrap_or(false);
|
||||
let has_active_agents = !chat.active_agents.is_empty() || chat.agents_ready_to_start;
|
||||
let hud_present = has_browser_screenshot || has_active_agents;
|
||||
|
||||
let bottom_desired = chat.bottom_pane.desired_height(area.width);
|
||||
let font_cell = chat.measured_font_size();
|
||||
let mut hm = chat.height_manager.borrow_mut();
|
||||
|
||||
let last = chat.layout.last_hud_present.get();
|
||||
if last != hud_present {
|
||||
hm.record_event(HeightEvent::HudToggle(hud_present));
|
||||
chat.layout.last_hud_present.set(hud_present);
|
||||
}
|
||||
|
||||
let collapsed_unit: u16 = 3;
|
||||
let present_count: u16 = (has_active_agents as u16) + (has_browser_screenshot as u16);
|
||||
let hud_target: Option<u16> = if !hud_present || present_count == 0 {
|
||||
None
|
||||
} else {
|
||||
let base_collapsed = collapsed_unit * present_count.max(1);
|
||||
let term_h = chat.layout.last_frame_height.get().max(1);
|
||||
let thirty = ((term_h as u32) * 30 / 100) as u16;
|
||||
let sixty = ((term_h as u32) * 60 / 100) as u16;
|
||||
let mut expanded = if thirty < 25 { 25.min(sixty) } else { thirty };
|
||||
expanded = expanded.max(collapsed_unit.saturating_add(2));
|
||||
let any_expanded = chat.layout.browser_hud_expanded || chat.layout.agents_hud_expanded;
|
||||
let target = if any_expanded { base_collapsed.saturating_add(expanded) } else { base_collapsed };
|
||||
Some(target)
|
||||
};
|
||||
|
||||
hm.begin_frame(area, hud_present, bottom_desired, font_cell, hud_target)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Message composition helpers and types for the chat widget.
|
||||
|
||||
use codex_core::protocol::InputItem;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserMessage {
|
||||
/// What to show in the chat history (keeps placeholders like "[image: name.png]")
|
||||
pub display_text: String,
|
||||
/// Items to send to the core/model in the correct order, with inline
|
||||
/// markers preceding images so the LLM knows placement.
|
||||
pub ordered_items: Vec<InputItem>,
|
||||
}
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
let mut ordered = Vec::new();
|
||||
if !text.trim().is_empty() {
|
||||
ordered.push(InputItem::Text { text: text.clone() });
|
||||
}
|
||||
Self { display_text: text, ordered_items: ordered }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Option<UserMessage> {
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut ordered: Vec<InputItem> = Vec::new();
|
||||
if !text.trim().is_empty() {
|
||||
ordered.push(InputItem::Text { text: text.clone() });
|
||||
}
|
||||
for path in image_paths {
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
ordered.push(InputItem::Text { text: format!("[image: {}]", filename) });
|
||||
ordered.push(InputItem::LocalImage { path });
|
||||
}
|
||||
Some(UserMessage { display_text: text, ordered_items: ordered })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Performance statistics support for ChatWidget.
|
||||
//!
|
||||
//! Kept as a separate module to keep `chatwidget.rs` lean. Pure data + helpers
|
||||
//! with no UI dependencies so it is easy to unit‑test in isolation.
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct PerfStats {
|
||||
pub frames: u64,
|
||||
pub prefix_rebuilds: u64,
|
||||
pub height_hits_total: u64,
|
||||
pub height_misses_total: u64,
|
||||
pub height_hits_render: u64,
|
||||
pub height_misses_render: u64,
|
||||
pub ns_total_height: u128,
|
||||
pub ns_render_loop: u128,
|
||||
// Hotspots: time spent computing heights on cache misses
|
||||
pub hot_total: std::collections::HashMap<(usize, u16), ItemStat>,
|
||||
pub hot_render: std::collections::HashMap<(usize, u16), ItemStat>,
|
||||
// Aggregation by cell kind/label
|
||||
pub per_kind_total: std::collections::HashMap<String, ItemStat>,
|
||||
pub per_kind_render: std::collections::HashMap<String, ItemStat>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct ItemStat {
|
||||
pub calls: u64,
|
||||
pub ns: u128,
|
||||
}
|
||||
|
||||
impl PerfStats {
|
||||
pub fn reset(&mut self) { *self = PerfStats::default(); }
|
||||
|
||||
pub fn summary(&self) -> String {
|
||||
let ms_total_height = (self.ns_total_height as f64) / 1_000_000.0;
|
||||
let ms_render = (self.ns_render_loop as f64) / 1_000_000.0;
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"perf: frames={}\n prefix_rebuilds={}\n height_cache: total hits={} misses={}\n height_cache (render): hits={} misses={}\n time: total_height={:.2}ms render_visible={:.2}ms",
|
||||
self.frames,
|
||||
self.prefix_rebuilds,
|
||||
self.height_hits_total,
|
||||
self.height_misses_total,
|
||||
self.height_hits_render,
|
||||
self.height_misses_render,
|
||||
ms_total_height,
|
||||
ms_render,
|
||||
));
|
||||
|
||||
// Top hotspots by (index,width)
|
||||
let mut top_total: Vec<(&(usize, u16), &ItemStat)> = self.hot_total.iter().collect();
|
||||
top_total.sort_by_key(|(_, s)| std::cmp::Reverse(s.ns));
|
||||
let mut top_render: Vec<(&(usize, u16), &ItemStat)> = self.hot_render.iter().collect();
|
||||
top_render.sort_by_key(|(_, s)| std::cmp::Reverse(s.ns));
|
||||
|
||||
if !top_total.is_empty() {
|
||||
out.push_str("\n\n hot items (total height, cache misses):\n");
|
||||
for ((idx, w), stat) in top_total.into_iter().take(5) {
|
||||
out.push_str(&format!(
|
||||
" (idx={}, width={}) calls={} time={:.2}ms\n",
|
||||
idx,
|
||||
w,
|
||||
stat.calls,
|
||||
(stat.ns as f64) / 1_000_000.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !top_render.is_empty() {
|
||||
out.push_str("\n hot items (render visible, cache misses):\n");
|
||||
for ((idx, w), stat) in top_render.into_iter().take(5) {
|
||||
out.push_str(&format!(
|
||||
" (idx={}, width={}) calls={} time={:.2}ms\n",
|
||||
idx,
|
||||
w,
|
||||
stat.calls,
|
||||
(stat.ns as f64) / 1_000_000.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-kind aggregation
|
||||
if !self.per_kind_total.is_empty() {
|
||||
let mut v: Vec<(&String, &ItemStat)> = self.per_kind_total.iter().collect();
|
||||
v.sort_by_key(|(_, s)| std::cmp::Reverse(s.ns));
|
||||
out.push_str("\n by kind (total height):\n");
|
||||
for (k, s) in v.into_iter().take(5) {
|
||||
out.push_str(&format!(
|
||||
" {} calls={} time={:.2}ms\n",
|
||||
k,
|
||||
s.calls,
|
||||
(s.ns as f64) / 1_000_000.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.per_kind_render.is_empty() {
|
||||
let mut v: Vec<(&String, &ItemStat)> = self.per_kind_render.iter().collect();
|
||||
v.sort_by_key(|(_, s)| std::cmp::Reverse(s.ns));
|
||||
out.push_str("\n by kind (render visible):\n");
|
||||
for (k, s) in v.into_iter().take(5) {
|
||||
out.push_str(&format!(
|
||||
" {} calls={} time={:.2}ms\n",
|
||||
k,
|
||||
s.calls,
|
||||
(s.ns as f64) / 1_000_000.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn record_total(&mut self, key: (usize, u16), kind: &str, ns: u128) {
|
||||
let e = self.hot_total.entry(key).or_insert_with(ItemStat::default);
|
||||
e.calls = e.calls.saturating_add(1);
|
||||
e.ns = e.ns.saturating_add(ns);
|
||||
let ek = self
|
||||
.per_kind_total
|
||||
.entry(kind.to_string())
|
||||
.or_insert_with(ItemStat::default);
|
||||
ek.calls = ek.calls.saturating_add(1);
|
||||
ek.ns = ek.ns.saturating_add(ns);
|
||||
}
|
||||
|
||||
pub fn record_render(&mut self, key: (usize, u16), kind: &str, ns: u128) {
|
||||
let e = self.hot_render.entry(key).or_insert_with(ItemStat::default);
|
||||
e.calls = e.calls.saturating_add(1);
|
||||
e.ns = e.ns.saturating_add(ns);
|
||||
let ek = self
|
||||
.per_kind_render
|
||||
.entry(kind.to_string())
|
||||
.or_insert_with(ItemStat::default);
|
||||
ek.calls = ek.calls.saturating_add(1);
|
||||
ek.ns = ek.ns.saturating_add(ns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Streaming-related helpers for `ChatWidget`.
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::height_manager::HeightEvent;
|
||||
use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::StreamKind;
|
||||
|
||||
pub(super) fn on_commit_tick(chat: &mut ChatWidget<'_>) {
|
||||
let sink = AppEventHistorySink(chat.app_event_tx.clone());
|
||||
let _finished = chat.stream.on_commit_tick(&sink);
|
||||
}
|
||||
|
||||
pub(super) fn is_write_cycle_active(chat: &ChatWidget<'_>) -> bool {
|
||||
chat.stream.is_write_cycle_active()
|
||||
}
|
||||
|
||||
pub(super) fn handle_streaming_delta(
|
||||
chat: &mut ChatWidget<'_>,
|
||||
kind: StreamKind,
|
||||
id: String,
|
||||
delta: String,
|
||||
) {
|
||||
if chat.stream_state.drop_streaming {
|
||||
tracing::debug!(
|
||||
"dropping streaming delta after cancel (kind={:?}, id={})",
|
||||
kind, id
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::debug!("handle_streaming_delta kind={:?}, delta={:?}", kind, delta);
|
||||
chat.stream_state.current_kind = Some(kind);
|
||||
let sink = AppEventHistorySink(chat.app_event_tx.clone());
|
||||
chat.stream.begin_with_id(kind, Some(id), &sink);
|
||||
chat.stream.push_and_maybe_commit(&delta, &sink);
|
||||
chat.mark_needs_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn finalize_active_stream(chat: &mut ChatWidget<'_>) {
|
||||
let sink = AppEventHistorySink(chat.app_event_tx.clone());
|
||||
if chat.stream.is_write_cycle_active() {
|
||||
chat.stream_state.current_kind = Some(StreamKind::Reasoning);
|
||||
chat.stream.finalize(StreamKind::Reasoning, true, &sink);
|
||||
chat.stream_state.current_kind = Some(StreamKind::Answer);
|
||||
chat.stream.finalize(StreamKind::Answer, true, &sink);
|
||||
chat.height_manager
|
||||
.borrow_mut()
|
||||
.record_event(HeightEvent::HistoryFinalize);
|
||||
}
|
||||
chat.stream_state.current_kind = None;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Tool (Web Search, MCP) lifecycle handlers extracted from ChatWidget.
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::history_cell;
|
||||
use codex_core::protocol::{McpToolCallBeginEvent, McpToolCallEndEvent};
|
||||
|
||||
pub(super) fn web_search_begin(chat: &mut ChatWidget<'_>, call_id: String, query: Option<String>) {
|
||||
for cell in &chat.history_cells { cell.trigger_fade(); }
|
||||
chat.finalize_active_stream();
|
||||
chat.flush_interrupt_queue();
|
||||
|
||||
let cell = history_cell::new_running_web_search(query.clone());
|
||||
chat.history_push(cell);
|
||||
if let Some(last_idx) = chat.history_cells.len().checked_sub(1) {
|
||||
chat.tools_state.running_web_search.insert(call_id, (last_idx, query));
|
||||
}
|
||||
chat.bottom_pane.update_status_text("Searched".to_string());
|
||||
chat.mark_needs_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn web_search_complete(chat: &mut ChatWidget<'_>, call_id: String, query: Option<String>) {
|
||||
if let Some((idx, maybe_query)) = chat.tools_state.running_web_search.remove(&call_id) {
|
||||
let mut target_idx = None;
|
||||
if idx < chat.history_cells.len() {
|
||||
let is_ws = chat.history_cells[idx]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
.is_some_and(|rt| rt.has_title("Web Search..."));
|
||||
if is_ws { target_idx = Some(idx); }
|
||||
}
|
||||
if target_idx.is_none() {
|
||||
for i in (0..chat.history_cells.len()).rev() {
|
||||
if let Some(rt) = chat.history_cells[i]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
{
|
||||
if rt.has_title("Web Search...") {
|
||||
target_idx = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = target_idx {
|
||||
if let Some(rt) = chat.history_cells[i]
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::RunningToolCallCell>()
|
||||
{
|
||||
let final_query = query.or(maybe_query);
|
||||
let completed = rt.finalize_web_search(true, final_query);
|
||||
chat.history_replace_at(i, Box::new(completed));
|
||||
|
||||
// Merge adjacent Web Search blocks with same header
|
||||
if i > 0 {
|
||||
let new_lines = chat.history_cells[i].display_lines();
|
||||
let new_header = new_lines.first().and_then(|l| l.spans.get(0)).map(|s| s.content.clone().to_string()).unwrap_or_default();
|
||||
let prev_lines = chat.history_cells[i - 1].display_lines();
|
||||
let prev_header = prev_lines.first().and_then(|l| l.spans.get(0)).map(|s| s.content.clone().to_string()).unwrap_or_default();
|
||||
if !new_header.is_empty() && new_header == prev_header {
|
||||
let mut combined = prev_lines.clone();
|
||||
while combined.last().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) { combined.pop(); }
|
||||
let mut body: Vec<ratatui::text::Line<'static>> = new_lines.into_iter().skip(1).collect();
|
||||
while body.first().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) { body.remove(0); }
|
||||
while body.last().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) { body.pop(); }
|
||||
if let Some(first_line) = body.first_mut() {
|
||||
if let Some(first_span) = first_line.spans.get_mut(0) {
|
||||
if first_span.content == " └ " || first_span.content == "└ " {
|
||||
first_span.content = " ".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
combined.extend(body);
|
||||
chat.history_replace_at(i - 1, Box::new(history_cell::PlainHistoryCell { lines: combined, kind: history_cell::HistoryCellType::Plain }));
|
||||
chat.history_remove_at(i);
|
||||
}
|
||||
}
|
||||
// history_replace_at already invalidates and redraws
|
||||
}
|
||||
}
|
||||
}
|
||||
chat.bottom_pane.update_status_text("responding".to_string());
|
||||
chat.maybe_hide_spinner();
|
||||
}
|
||||
|
||||
pub(super) fn mcp_begin(chat: &mut ChatWidget<'_>, ev: McpToolCallBeginEvent) {
|
||||
for cell in &chat.history_cells { cell.trigger_fade(); }
|
||||
let McpToolCallBeginEvent { call_id, invocation } = ev;
|
||||
let cell = history_cell::new_running_mcp_tool_call(invocation);
|
||||
chat.history_push(cell);
|
||||
if let Some(last_idx) = chat.history_cells.len().checked_sub(1) {
|
||||
chat.tools_state.running_custom_tools.insert(call_id, last_idx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mcp_end(chat: &mut ChatWidget<'_>, ev: McpToolCallEndEvent) {
|
||||
let McpToolCallEndEvent { call_id, duration, invocation, result } = ev;
|
||||
let success = !result.as_ref().map(|r| r.is_error.unwrap_or(false)).unwrap_or(false);
|
||||
let completed = history_cell::new_completed_mcp_tool_call(80, invocation, duration, success, result);
|
||||
if let Some(idx) = chat.tools_state.running_custom_tools.remove(&call_id) {
|
||||
if idx < chat.history_cells.len() {
|
||||
chat.history_replace_at(idx, completed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
chat.history_push(completed);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
|
||||
# TUI ChatWidget Refactor Plan
|
||||
|
||||
This document tracks the refactor of `codex-rs/tui/src/chatwidget.rs` into smaller, maintainable modules and state bundles. It also captures what has been completed, and what remains so another engineer can continue seamlessly.
|
||||
|
||||
## Goals
|
||||
|
||||
- Break the 389kB `chatwidget.rs` into cohesive modules by responsibility.
|
||||
- Group related state into small structs to reduce field sprawl and accidental misuse.
|
||||
- Centralize common history operations (push/replace/remove) behind a tiny API.
|
||||
- Keep behavior identical and builds green at every step (`./build-fast.sh`).
|
||||
- Maintain zero warnings policy.
|
||||
|
||||
---
|
||||
|
||||
## Status Summary (as of this commit)
|
||||
|
||||
Completed extractions (modules):
|
||||
- `chatwidget/perf.rs`: `PerfStats` (data + helpers)
|
||||
- `chatwidget/diff_ui.rs`: `DiffOverlay`, `DiffBlock`, `DiffConfirm`
|
||||
- `chatwidget/message.rs`: `UserMessage`, `create_initial_user_message`
|
||||
- `chatwidget/streaming.rs`: streaming deltas and finalize helpers (`on_commit_tick`, `is_write_cycle_active`, `handle_streaming_delta`, `finalize_active_stream`)
|
||||
- `chatwidget/exec_tools.rs`: exec lifecycle, tool merge helpers; move-before-assistant helpers
|
||||
- `chatwidget/tools.rs`: Web Search and MCP tool lifecycle
|
||||
- `chatwidget/layout_scroll.rs`: HUD toggles, layout_areas, PageUp/PageDown, mouse wheel scroll
|
||||
- `chatwidget/diff_handlers.rs`: all Diff overlay key handling
|
||||
|
||||
State groupings:
|
||||
- `StreamState` on `ChatWidget`
|
||||
- `current_kind`, `closed_answer_ids`, `closed_reasoning_ids`, `next_seq`, `seq_answer_final`, `drop_streaming`
|
||||
- `LayoutState` on `ChatWidget`
|
||||
- `scroll_offset`, `last_max_scroll`, `last_history_viewport_height`, `vertical_scrollbar_state`, `scrollbar_visible_until`, `last_hud_present`, `browser_hud_expanded`, `agents_hud_expanded`, `last_frame_height`
|
||||
- `DiffsState` on `ChatWidget`
|
||||
- `session_patch_sets`, `baseline_file_contents`, `overlay`, `confirm`, `body_visible_rows`
|
||||
- `PerfState` on `ChatWidget`
|
||||
- `enabled`, `stats: RefCell<PerfStats>` replacing `perf_enabled` and `perf`
|
||||
|
||||
History helper shim:
|
||||
- Added to `ChatWidget`:
|
||||
- `history_push(cell)`: wrapper for `add_to_history`
|
||||
- `history_replace_at(idx, cell)`
|
||||
- `history_remove_at(idx)`
|
||||
- Migrated usages in `exec_tools.rs` and `tools.rs` (some hotspots) to use the shim.
|
||||
|
||||
Build + policy:
|
||||
- All steps validated with `./build-fast.sh`.
|
||||
- Zero compiler warnings kept intact.
|
||||
|
||||
---
|
||||
|
||||
## New File/Module Map
|
||||
|
||||
- `tui/src/chatwidget.rs` (shrinking coordinator)
|
||||
- owns `ChatWidget` and wires events → submodules
|
||||
- houses grouped states: `StreamState`, `LayoutState`, `DiffsState`, `PerfState`
|
||||
- history helper shim (temporary; see Future consolidation)
|
||||
- `tui/src/chatwidget/perf.rs` → performance structs
|
||||
- `tui/src/chatwidget/diff_ui.rs` → diff overlay data types
|
||||
- `tui/src/chatwidget/message.rs` → user message building
|
||||
- `tui/src/chatwidget/streaming.rs` → unified streaming operations
|
||||
- `tui/src/chatwidget/exec_tools.rs` → exec lifecycle + merges
|
||||
- `tui/src/chatwidget/tools.rs` → Web Search + MCP lifecycle
|
||||
- `tui/src/chatwidget/layout_scroll.rs` → layout + scrolling + HUD controls
|
||||
- `tui/src/chatwidget/diff_handlers.rs` → diff overlay key handling
|
||||
|
||||
---
|
||||
|
||||
## Behavior-Preserving Changes Made
|
||||
|
||||
- No intentional UX/logic changes. Extracted code calls the same operations as before.
|
||||
- Replaced direct field access with grouped state access (e.g., `self.stream_state.current_kind`).
|
||||
- Centralized common history mutations and used them in a few hotspots to reduce duplication.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Recommended Order)
|
||||
|
||||
1) Finish History helper adoption
|
||||
- Replace remaining ad-hoc `history_cells[idx] = …`, `remove(i)`, and `add_to_history` sites with `history_replace_at`, `history_remove_at`, `history_push`.
|
||||
- Candidates to sweep: `chatwidget.rs` (render/patch flows), `tools.rs` (MCP end), `exec_tools.rs` (final merges), streaming paths if any remaining.
|
||||
|
||||
2) History merge API consolidation
|
||||
- Introduce a small `History` helper (or keep on `ChatWidget`) that encapsulates the “Exec/Search/List merges” currently inside `add_to_history`.
|
||||
- Public methods (draft):
|
||||
- `push_and_maybe_merge(cell)`
|
||||
- `replace_and_maybe_merge(idx, cell)`
|
||||
- Move the complex “merged exec cell” logic behind this boundary.
|
||||
|
||||
3) Enum-ize exec actions and newtype IDs
|
||||
- Replace string action tags from `action_from_parsed(&parsed)` with `ExecAction` enum { Read, Search, List, Run }.
|
||||
- Introduce newtypes for IDs: `ExecCallId(String)`, `ToolCallId(String)`, `StreamId(String)` to clarify map/set roles across modules.
|
||||
- Migrate `HashMap`/`HashSet` keys and helper signatures.
|
||||
|
||||
4) Streaming API cleanup
|
||||
- In `streaming.rs`, expose: `begin(kind,id)`, `delta(text)`, `finalize(kind, follow_bottom: bool)`.
|
||||
- Remove remaining external touches of `stream_state.current_kind` (already mostly contained) and push that responsibility into the streaming module.
|
||||
|
||||
5) Tests (valuable quick wins)
|
||||
- Unit tests
|
||||
- Exec merges: combinations for Read/Search/List/Run and exit statuses.
|
||||
- Parse/paste: `[image: …]`, `[file: …]`, direct path handling, size thresholds, non‑UTF8 case.
|
||||
- Diff selection math: selecting block by offset and generating undo/explain prompts.
|
||||
- Replay tests (vt100)
|
||||
- Cancel mid-stream (drop deltas immediately, then finalize when backend completes).
|
||||
- “Final answer closes lingering tools/execs” (already coded; test it).
|
||||
|
||||
6) Optional: rename for clarity
|
||||
- Consider `ChatWidget` → `ChatView` (render/controller) once responsibilities are clearer.
|
||||
|
||||
---
|
||||
|
||||
## Tips for the Next Engineer
|
||||
|
||||
- Always validate with `./build-fast.sh`; warnings are treated as failures.
|
||||
- Avoid touching any code related to `CODEX_SANDBOX_*` env vars.
|
||||
- Keep steps small and behavior‑preserving; prefer many tiny commits over one big one.
|
||||
- When moving code, first copy the function into a submodule, compile, then replace the original with a delegate call. Only then remove the original body.
|
||||
- For scrolling math: all offsets are measured from the bottom; `LayoutState` centralizes the values you need.
|
||||
- For diffs popup: `DiffsState` now owns overlay/confirm and the session patch context.
|
||||
- For perf: read/write via `self.perf_state.enabled` and `self.perf_state.stats.borrow_mut()`.
|
||||
|
||||
---
|
||||
|
||||
## Quick Pointers
|
||||
|
||||
- Stream state: `StreamState` on `ChatWidget` (search for `stream_state`)
|
||||
- Layout state: `LayoutState` on `ChatWidget` (search for `layout.`)
|
||||
- Diff state: `DiffsState` on `ChatWidget` (search for `diffs.`)
|
||||
- History helpers: `history_push`, `history_replace_at`, `history_remove_at` in `chatwidget.rs`
|
||||
- Web search lifecycle: `chatwidget/tools.rs`
|
||||
- Exec lifecycle + merges: `chatwidget/exec_tools.rs`
|
||||
- Streaming functions: `chatwidget/streaming.rs`
|
||||
- Diff overlay keys: `chatwidget/diff_handlers.rs`
|
||||
- HUD/layout: `chatwidget/layout_scroll.rs`
|
||||
|
||||
---
|
||||
|
||||
## Done Checklist
|
||||
|
||||
- [x] Extract perf/diff/message data types
|
||||
- [x] Extract streaming and exec/tool handlers
|
||||
- [x] Extract layout/scroll + diff key handling
|
||||
- [x] Group StreamState, LayoutState, DiffsState, PerfState
|
||||
- [x] Add history helper shim and adopt in hotspots
|
||||
- [x] Keep builds green with zero warnings at each step
|
||||
|
||||
## Todo Checklist
|
||||
|
||||
- [ ] Sweep remaining history mutations to use the shim
|
||||
- [ ] Consolidate history merge API
|
||||
- [ ] Replace action strings with `ExecAction` enum
|
||||
- [ ] Add ID newtypes (Exec/Tool/Stream)
|
||||
- [ ] Tighten streaming API (begin/delta/finalize facade)
|
||||
- [ ] Add unit + vt100 tests as listed above
|
||||
|
||||
---
|
||||
|
||||
## How to Validate Locally
|
||||
|
||||
```bash
|
||||
./build-fast.sh
|
||||
```
|
||||
|
||||
Expect: success with zero warnings. The script builds the Rust workspace in a fast profile and maintains symlinks required by the CLI wrapper.
|
||||
|
||||
---
|
||||
|
||||
If anything is unclear or you hit conflicts, start by grepping for the state bundle or module name and follow the delegate calls from `ChatWidget` into the submodule.
|
||||
Reference in New Issue
Block a user