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:
James Peter
2025-09-02 17:41:46 +10:00
parent 6eac0512bd
commit 9fb2563cbb
11 changed files with 1629 additions and 1498 deletions
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,
}
}
+28
View File
@@ -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,
}
+499
View File
@@ -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.
+4 -3
View File
@@ -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)
}
+42
View File
@@ -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 })
}
}
+137
View File
@@ -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);
}
}
+50
View File
@@ -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;
}
+106
View File
@@ -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);
}
+168
View File
@@ -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.