feat(tui/history): route diff and explore through domain events

This commit is contained in:
James Peter
2025-09-27 21:17:40 +10:00
parent f3badfd9fa
commit 0d5d57ee21
11 changed files with 916 additions and 348 deletions
+5 -5
View File
@@ -159,14 +159,14 @@ Status legend: ✅ complete (semantic deterministic state ready), ⏳ still need
## Step 3 – HistoryState Manager *(Pending)*
3.1 **HistoryRecord enum** – ✅ complete: `history/state.rs` defines `HistoryRecord`, per-cell state structs, and `HistoryState` scaffolding with ID management helpers.
3.2 **ChatWidget incremental adoption** – Introduce `HistoryState`/`HistoryRenderState` alongside the legacy `history_cells` vector, migrate low-risk cell types (plain, loading, wait status) to the new state, then remove the legacy vector once the path is stable. **Status:** [~] `history_insert_with_key_global_tagged` now mirrors inserts into `HistoryState` to assign `HistoryId`s, but the `history_cells: Vec<Box<dyn HistoryCell>>` is still the primary source of truth and several helpers reconstruct `HistoryRecord` values from existing cells. Next steps: migrate the remaining rate-limit/system notice paths, then flip the ownership so `HistoryState` drives rendering and cells become a derived cache only.
3.3 **Apply-event pipeline** – Implement `HistoryState::apply_event(&mut self, event: &EventMsg)` covering all core/TUI event types (exec lifecycle, tool updates, background notices, resume snapshots, undo) and route migrated cells through it. **Status:** [~] `HistoryState::apply_domain_event` now converts `HistoryDomainRecord` values into `HistoryRecord`s so background/system notices insert via domain events; remaining flows still emit bespoke mutations (e.g., patch success). Follow-up: add typed handlers per event family, eliminate `history_record_from_cell`, and collapse direct cell mutation once Step 3.5 is ready.
3.2 **ChatWidget incremental adoption** – Introduce `HistoryState`/`HistoryRenderState` alongside the legacy `history_cells` vector, migrate low-risk cell types (plain, loading, wait status) to the new state, then remove the legacy vector once the path is stable. **Status:** ✅ Wait tooling, diff inserts, and explore aggregations now flow through domain events; `history_cells` remains solely as the interim render cache until Step 3.5 removes the trait bridge.
3.3 **Apply-event pipeline** – Implement `HistoryState::apply_event(&mut self, event: &EventMsg)` covering all core/TUI event types (exec lifecycle, tool updates, background notices, resume snapshots, undo) and route migrated cells through it. **Status:** ✅ Exec wait notes, diff pushes, and explore status updates now call into `HistoryState::apply_domain_event`; next wave is wiring streaming assistant/diff updates before the Step 4 renderer consolidation.
3.4 **Undo/resume hooks** – Expose `snapshot`, `restore`, and `truncate_after(id)` to support /undo and resume flows.
3.5 **ChatWidget full integration** – Delete `history_cells: Vec<Box<dyn HistoryCell>>`, wire all helper methods (`history_push`, `history_replace`, etc.) into `HistoryState`, and treat Step 4 as the follow-up for centralized rendering.
### Outstanding Gaps (2025-09-26)
- Patch apply success/failure currently mutates `PatchSummaryCell` in place; emit `HistoryEvent::Replace` once the replace arm is implemented so records stay authoritative.
- Several helpers still rebuild cells inline (e.g., patch success) instead of emitting domain-specific events; consolidate around `HistoryState::apply_event` so the legacy `history_cells` vector can be retired.
### Outstanding Gaps (2025-09-27)
- Apply-patch failures still surface a minimal `PatchRecord` without stderr context; consider storing structured failure metadata (message, stdout/stderr digest) so the renderer can show richer feedback while remaining state-driven.
- Several helpers still rebuild cells inline (e.g., merged exec summaries) instead of emitting domain-specific events; consolidate around `HistoryState::apply_event` so the legacy `history_cells` vector can be retired.
- Track exec/assistant/diff layout caches as an intentional bridge; remove them only after the shared renderer cache in Step 6 ships to avoid frame-time regressions.
### Event Pipeline Consolidation Plan
+407 -231
View File
@@ -212,6 +212,7 @@ use crate::history::state::PatchEventType as HistoryPatchEventType;
use crate::history::state::{
HistoryDomainEvent,
HistoryDomainRecord,
ExecWaitNote,
AssistantMessageState,
AssistantStreamDelta,
AssistantStreamState,
@@ -2257,101 +2258,38 @@ impl ChatWidget<'_> {
}
)
}) {
if let Some(summary) = self.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::PatchSummaryCell>()
if let Some(record) = self
.history_cells
.get(idx)
.and_then(|existing| self.history_record_from_cell(existing.as_ref()))
{
let mut updated = history_cell::new_patch_event(
PatchEventType::ApplySuccess,
summary.record().changes.clone(),
);
updated.title = "Updated".to_string();
updated.kind = history_cell::PatchKind::ApplySuccess;
updated.record_mut().patch_type = HistoryPatchEventType::ApplySuccess;
self.history_replace_at(idx, Box::new(updated));
return;
}
if let Some(cell) = self.history_cells.get_mut(idx) {
if let Some(plain) = cell
.as_any_mut()
.downcast_mut::<history_cell::PlainHistoryCell>()
{
let state = plain.state_mut();
if let Some(header) = state.header.as_mut() {
header.label = "Updated".to_string();
}
if let Some(first_line) = state.lines.first_mut() {
if first_line.spans.is_empty() {
first_line.kind = crate::history::MessageLineKind::Paragraph;
first_line.spans.push(crate::history::InlineSpan {
text: "Updated".to_string(),
tone: crate::history::TextTone::Success,
emphasis: crate::history::TextEmphasis {
bold: true,
italic: false,
dim: false,
strike: false,
underline: false,
},
entity: None,
});
} else {
for span in &mut first_line.spans {
span.tone = crate::history::TextTone::Success;
span.emphasis.bold = true;
span.emphasis.dim = false;
}
first_line.spans[0].text = "Updated".to_string();
}
}
plain.set_kind(history_cell::HistoryCellType::Patch {
kind: history_cell::PatchKind::ApplySuccess,
if let HistoryRecord::Patch(mut patch_record) = record {
patch_record.patch_type = HistoryPatchEventType::ApplySuccess;
let record_index = self
.record_index_for_cell(idx)
.unwrap_or_else(|| self.record_index_for_position(idx));
let mutation = self.history_state.apply_event(HistoryEvent::Replace {
index: record_index,
record: HistoryRecord::Patch(patch_record.clone()),
});
plain.invalidate_layout_cache();
}
}
let record = self.history_record_from_cell(self.history_cells[idx].as_ref());
if let Some(record) = record {
if let Some(record_index) = self.record_index_for_cell(idx) {
if let HistoryMutation::Replaced { id, .. } = self
.history_state
.apply_event(HistoryEvent::Replace { index: record_index, record })
{
if let Some(cell) = self.history_cells.get_mut(idx) {
if let Some(plain_cell) = cell
.as_any_mut()
.downcast_mut::<history_cell::PlainHistoryCell>()
{
let state = plain_cell.state_mut();
state.id = id;
}
}
if let Some(id) = self.apply_mutation_to_cell_index(idx, mutation) {
if idx < self.history_cell_ids.len() {
self.history_cell_ids[idx] = Some(id);
}
self.maybe_hide_spinner();
return;
}
} else {
tracing::warn!(
"history-state mismatch: no persisted index for patch cell {}",
idx
);
}
}
self.request_redraw();
return;
}
// If nothing to update, fall through without inserting a duplicate cell.
} else {
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global(
Box::new(history_cell::new_patch_apply_failure(ev.stderr)),
key,
);
self.maybe_hide_spinner();
return;
}
// After patch application completes, re-evaluate idle state
// failure path
let failure_cell = history_cell::new_patch_apply_failure(ev.stderr);
let key = self.next_internal_key();
let _ = self.history_insert_plain_cell_with_key(failure_cell, key, "patch-failure");
self.maybe_hide_spinner();
}
@@ -4406,41 +4344,35 @@ impl ChatWidget<'_> {
let mut cell = cell;
let maybe_id = if let Some(domain_record) = record {
let mutation = if let Some(domain_record) = record {
let record_index = self.record_index_for_position(pos);
match self.history_state.apply_domain_event(HistoryDomainEvent::Insert {
index: record_index,
record: domain_record,
}) {
HistoryMutation::Inserted { id, record, .. } => {
if !self.hydrate_cell_from_record(&mut cell, &record) {
self.assign_history_id(&mut cell, id);
}
Some(id)
}
_ => None,
}
Some(
self.history_state
.apply_domain_event(HistoryDomainEvent::Insert {
index: record_index,
record: domain_record,
}),
)
} else if let Some(record) = self.history_record_from_cell(cell.as_ref()) {
let record_index = self.record_index_for_position(pos);
match self
.history_state
.apply_event(HistoryEvent::Insert {
index: record_index,
record,
})
{
HistoryMutation::Inserted { id, record, .. } => {
if !self.hydrate_cell_from_record(&mut cell, &record) {
self.assign_history_id(&mut cell, id);
}
Some(id)
}
_ => None,
}
Some(
self.history_state
.apply_event(HistoryEvent::Insert {
index: record_index,
record,
}),
)
} else {
None
};
let mut maybe_id = None;
if let Some(mutation) = mutation {
if let Some(id) = self.apply_mutation_to_cell(&mut cell, mutation) {
maybe_id = Some(id);
}
}
self.history_cells.insert(pos, cell);
self.history_cell_ids.insert(pos, maybe_id);
// In terminal mode, App mirrors history lines into the native buffer.
@@ -4498,10 +4430,96 @@ impl ChatWidget<'_> {
pos
}
fn append_wait_pairs(target: &mut Vec<(String, bool)>, additions: &[(String, bool)]) {
for (text, is_error) in additions {
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
if target
.last()
.map(|(existing, existing_err)| existing == trimmed && *existing_err == *is_error)
.unwrap_or(false)
{
continue;
}
target.push((trimmed.to_string(), *is_error));
}
}
fn wait_pairs_from_exec_notes(notes: &[ExecWaitNote]) -> Vec<(String, bool)> {
notes
.iter()
.map(|note| {
(
note.message.clone(),
matches!(note.tone, TextTone::Error),
)
})
.collect()
}
fn update_exec_wait_state_with_pairs(
&mut self,
history_idx: usize,
total_wait: Option<Duration>,
wait_active: bool,
notes: &[(String, bool)],
) -> bool {
if history_idx >= self.history_cells.len() {
return false;
}
let record_idx = self
.record_index_for_cell(history_idx)
.unwrap_or_else(|| self.record_index_for_position(history_idx));
let note_records: Vec<ExecWaitNote> = notes
.iter()
.filter_map(|(text, is_error)| {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(ExecWaitNote {
message: trimmed.to_string(),
tone: if *is_error {
TextTone::Error
} else {
TextTone::Info
},
timestamp: SystemTime::now(),
})
}
})
.collect();
let mutation = self.history_state.apply_domain_event(HistoryDomainEvent::UpdateExecWait {
index: record_idx,
total_wait,
wait_active,
notes: note_records,
});
if let Some(id) = self.apply_mutation_to_cell_index(history_idx, mutation) {
if history_idx < self.history_cell_ids.len() {
self.history_cell_ids[history_idx] = Some(id);
}
self.invalidate_height_cache();
self.request_redraw();
return true;
}
false
}
fn hydrate_cell_from_record(
&self,
cell: &mut Box<dyn HistoryCell>,
record: &HistoryRecord,
) -> bool {
Self::hydrate_cell_from_record_inner(cell, record, &self.config)
}
fn hydrate_cell_from_record_inner(
cell: &mut Box<dyn HistoryCell>,
record: &HistoryRecord,
config: &Config,
) -> bool {
match record {
HistoryRecord::PlainMessage(state) => {
@@ -4554,7 +4572,7 @@ impl ChatWidget<'_> {
.as_any_mut()
.downcast_mut::<crate::history_cell::StreamingContentCell>()
{
stream.update_from_state(state.clone(), &self.config);
stream.update_from_state(state.clone(), config);
return true;
}
}
@@ -4567,12 +4585,180 @@ impl ChatWidget<'_> {
return true;
}
}
HistoryRecord::Patch(state) => {
if let Some(patch) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::PatchSummaryCell>()
{
*patch.record_mut() = state.clone();
patch.invalidate_cache();
return true;
}
}
HistoryRecord::Image(state) => {
if let Some(image) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::ImageOutputCell>()
{
*image.record_mut() = state.clone();
return true;
}
}
_ => {}
}
false
}
fn build_cell_from_record(&self, record: &HistoryRecord) -> Option<Box<dyn HistoryCell>> {
use crate::history_cell;
match record {
HistoryRecord::PlainMessage(state) => Some(Box::new(
history_cell::PlainHistoryCell::from_state(state.clone()),
)),
HistoryRecord::WaitStatus(state) => {
Some(Box::new(history_cell::WaitStatusCell::from_state(state.clone())))
}
HistoryRecord::Loading(state) => {
Some(Box::new(history_cell::LoadingCell::from_state(state.clone())))
}
HistoryRecord::RunningTool(state) => Some(Box::new(
history_cell::RunningToolCallCell::from_state(state.clone()),
)),
HistoryRecord::ToolCall(state) => Some(Box::new(
history_cell::ToolCallCell::from_state(state.clone()),
)),
HistoryRecord::PlanUpdate(state) => Some(Box::new(
history_cell::PlanUpdateCell::from_state(state.clone()),
)),
HistoryRecord::UpgradeNotice(state) => Some(Box::new(
history_cell::UpgradeNoticeCell::from_state(state.clone()),
)),
HistoryRecord::Reasoning(state) => Some(Box::new(
history_cell::CollapsibleReasoningCell::from_state(state.clone()),
)),
HistoryRecord::Exec(state) => {
Some(Box::new(history_cell::ExecCell::from_record(state.clone())))
}
HistoryRecord::AssistantStream(state) => Some(Box::new(
history_cell::StreamingContentCell::from_state(state.clone(), &self.config),
)),
HistoryRecord::AssistantMessage(state) => Some(Box::new(
history_cell::AssistantMarkdownCell::from_state(state.clone(), &self.config),
)),
HistoryRecord::Diff(state) => {
Some(Box::new(history_cell::DiffCell::from_record(state.clone())))
}
HistoryRecord::Patch(state) => {
Some(Box::new(history_cell::PatchSummaryCell::from_record(state.clone())))
}
HistoryRecord::Explore(state) => Some(Box::new(
history_cell::ExploreAggregationCell::from_record(state.clone()),
)),
HistoryRecord::RateLimits(state) => Some(Box::new(
history_cell::RateLimitsCell::from_record(state.clone()),
)),
HistoryRecord::BackgroundEvent(state) => {
Some(Box::new(history_cell::BackgroundEventCell::new(state.clone())))
}
HistoryRecord::Image(state) => Some(Box::new(
history_cell::ImageOutputCell::from_record(state.clone()),
)),
HistoryRecord::Notice(state) => Some(Box::new(
history_cell::PlainHistoryCell::from_notice_record(state.clone()),
)),
}
}
fn apply_mutation_to_cell(
&self,
cell: &mut Box<dyn HistoryCell>,
mutation: HistoryMutation,
) -> Option<HistoryId> {
match mutation {
HistoryMutation::Inserted { id, record, .. }
| HistoryMutation::Replaced { id, record, .. } => {
if let Some(mut new_cell) = self.build_cell_from_record(&record) {
self.assign_history_id(&mut new_cell, id);
*cell = new_cell;
} else if !self.hydrate_cell_from_record(cell, &record) {
self.assign_history_id(cell, id);
}
Some(id)
}
_ => None,
}
}
fn apply_mutation_to_cell_index(
&mut self,
idx: usize,
mutation: HistoryMutation,
) -> Option<HistoryId> {
if idx >= self.history_cells.len() {
return None;
}
match mutation {
HistoryMutation::Inserted { id, record, .. }
| HistoryMutation::Replaced { id, record, .. } => {
self.update_cell_from_record(id, record);
Some(id)
}
_ => None,
}
}
fn cell_index_for_history_id(&self, id: HistoryId) -> Option<usize> {
if let Some(idx) = self
.history_cell_ids
.iter()
.position(|maybe| maybe.map(|stored| stored == id).unwrap_or(false))
{
return Some(idx);
}
self.history_cells.iter().enumerate().find_map(|(idx, cell)| {
self.history_record_from_cell(cell.as_ref())
.map(|record| record.id() == id)
.filter(|matched| *matched)
.map(|_| idx)
})
}
fn update_cell_from_record(&mut self, id: HistoryId, record: HistoryRecord) {
if id == HistoryId::ZERO {
tracing::debug!("skip update_cell_from_record: zero id");
return;
}
if let Some(idx) = self.cell_index_for_history_id(id) {
if let Some(mut rebuilt) = self.build_cell_from_record(&record) {
Self::assign_history_id_inner(&mut rebuilt, id);
self.history_cells[idx] = rebuilt;
} else if let Some(cell_slot) = self.history_cells.get_mut(idx) {
if !Self::hydrate_cell_from_record_inner(cell_slot, &record, &self.config) {
Self::assign_history_id_inner(cell_slot, id);
}
}
if idx < self.history_cell_ids.len() {
self.history_cell_ids[idx] = Some(id);
}
self.invalidate_height_cache();
self.request_redraw();
} else {
tracing::warn!(
"history-state mismatch: unable to locate cell for id {:?}",
id
);
}
}
fn assign_history_id(&self, cell: &mut Box<dyn HistoryCell>, id: HistoryId) {
Self::assign_history_id_inner(cell, id);
}
fn assign_history_id_inner(cell: &mut Box<dyn HistoryCell>, id: HistoryId) {
if let Some(tool_call) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::ToolCallCell>()
@@ -4638,6 +4824,26 @@ impl ChatWidget<'_> {
.downcast_mut::<crate::history_cell::RateLimitsCell>()
{
rate_limits.record_mut().id = id;
} else if let Some(plain) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::PlainHistoryCell>()
{
plain.state_mut().id = id;
} else if let Some(wait) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::WaitStatusCell>()
{
wait.state_mut().id = id;
} else if let Some(loading) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::LoadingCell>()
{
loading.state_mut().id = id;
} else if let Some(background) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::BackgroundEventCell>()
{
background.state_mut().id = id;
}
}
@@ -4780,8 +4986,13 @@ impl ChatWidget<'_> {
title.unwrap_or_default(),
&diff_output,
);
let cell = history_cell::DiffCell::from_record(record);
self.history_push(cell);
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global_tagged(
Box::new(history_cell::DiffCell::from_record(record.clone())),
key,
"diff",
Some(HistoryDomainRecord::Diff(record)),
);
}
/// Insert a background event near the top of the current request so it appears
/// before imminent provider output (e.g. Exec begin).
@@ -4860,21 +5071,7 @@ impl ChatWidget<'_> {
record,
});
if let Some(id) = match mutation {
HistoryMutation::Replaced { id, record, .. } => {
if !self.hydrate_cell_from_record(&mut cell, &record) {
self.assign_history_id(&mut cell, id);
}
Some(id)
}
HistoryMutation::Inserted { id, record, .. } => {
if !self.hydrate_cell_from_record(&mut cell, &record) {
self.assign_history_id(&mut cell, id);
}
Some(id)
}
_ => None,
} {
if let Some(id) = self.apply_mutation_to_cell(&mut cell, mutation) {
if idx < self.history_cell_ids.len() {
self.history_cell_ids[idx] = Some(id);
}
@@ -4896,38 +5093,32 @@ impl ChatWidget<'_> {
match (record, self.record_index_for_cell(idx)) {
(Some(record), Some(record_idx)) => {
if let HistoryMutation::Replaced { id, record, .. } = self
let mutation = self
.history_state
.apply_event(HistoryEvent::Replace {
index: record_idx,
record,
})
{
if !self.hydrate_cell_from_record(&mut cell, &record) {
self.assign_history_id(&mut cell, id);
}
});
if let Some(id) = self.apply_mutation_to_cell(&mut cell, mutation) {
maybe_id = Some(id);
}
}
(Some(record), None) => {
let record_idx = self.record_index_for_position(idx);
if let HistoryMutation::Inserted { id, record, .. } = self
let mutation = self
.history_state
.apply_event(HistoryEvent::Insert {
index: record_idx,
record,
})
{
if !self.hydrate_cell_from_record(&mut cell, &record) {
self.assign_history_id(&mut cell, id);
}
});
if let Some(id) = self.apply_mutation_to_cell(&mut cell, mutation) {
maybe_id = Some(id);
}
}
(None, Some(record_idx)) => {
let _ = self
.history_state
.apply_event(HistoryEvent::Remove { index: record_idx });
.apply_domain_event(HistoryDomainEvent::Remove { index: record_idx });
}
(None, None) => {}
}
@@ -4964,7 +5155,7 @@ impl ChatWidget<'_> {
if let Some(record_idx) = self.record_index_for_cell(idx) {
let _ = self
.history_state
.apply_event(HistoryEvent::Remove { index: record_idx });
.apply_domain_event(HistoryDomainEvent::Remove { index: record_idx });
}
self.history_cells.remove(idx);
@@ -7011,18 +7202,12 @@ impl ChatWidget<'_> {
},
);
if let HistoryMutation::Replaced {
id,
record: HistoryRecord::Exec(exec_record),
..
} = mutation
{
if idx < self.history_cells.len() {
if let Some(exec) = self.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::ExecCell>()
{
exec.sync_from_record(&exec_record);
}
}
self.update_cell_from_record(id, HistoryRecord::Exec(exec_record));
}
}
self.invalidate_height_cache();
@@ -7208,22 +7393,17 @@ impl ChatWidget<'_> {
.running_wait_tools
.insert(ToolCallId(call_id.clone()), exec_call_id.clone());
let mut wait_update: Option<(usize, Option<Duration>, Vec<(String, bool)>)> = None;
if let Some(running) = self.exec.running_commands.get_mut(&exec_call_id) {
running.wait_active = true;
running.wait_notes.clear();
let history_index = running.history_index;
if let Some(idx) = history_index {
if idx < self.history_cells.len() {
if let Some(exec_cell) = self.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::ExecCell>()
{
exec_cell.set_waiting(true);
exec_cell.clear_wait_notes();
}
}
if let Some(idx) = running.history_index {
wait_update = Some((idx, running.wait_total, running.wait_notes.clone()));
}
}
if let Some((idx, total, notes)) = wait_update {
let _ = self.update_exec_wait_state_with_pairs(idx, total, true, &notes);
}
self.bottom_pane
.update_status_text("waiting for command".to_string());
self.invalidate_height_cache();
@@ -7345,74 +7525,65 @@ impl ChatWidget<'_> {
note_lines.push((note_text.to_string(), is_error_note));
}
}
let mut history_index: Option<usize> = None;
let mut history_idx: Option<usize> = None;
let mut wait_total: Option<Duration> = None;
let mut wait_notes_snapshot: Vec<(String, bool)> = Vec::new();
if let Some(running) = self.exec.running_commands.get_mut(&exec_call_id) {
let base = running.wait_total.unwrap_or_default();
let total = base.saturating_add(duration);
running.wait_total = Some(total);
history_index = running.history_index;
running.wait_active = wait_still_pending;
for (text, is_error_note) in &note_lines {
if running
.wait_notes
.last()
.map(|(existing, existing_err)| existing == text && existing_err == is_error_note)
.unwrap_or(false)
{
continue;
Self::append_wait_pairs(&mut running.wait_notes, &note_lines);
wait_notes_snapshot = running.wait_notes.clone();
wait_total = running.wait_total;
history_idx = running.history_index;
} else {
Self::append_wait_pairs(&mut wait_notes_snapshot, &note_lines);
}
if history_idx.is_none() {
if let Some((idx, _)) = self.history_cells.iter().enumerate().rev().find(|(_, cell)| {
cell.as_any()
.downcast_ref::<history_cell::ExecCell>()
.is_some()
}) {
history_idx = Some(idx);
if let Some(running) = self.exec.running_commands.get_mut(&exec_call_id) {
running.history_index = Some(idx);
}
running.wait_notes.push((text.clone(), *is_error_note));
}
}
let mut updated = false;
if let Some(idx) = history_index {
if idx < self.history_cells.len() {
if let Some(exec_cell) = self.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::ExecCell>()
{
let total = exec_cell
.wait_total()
.unwrap_or_default()
.saturating_add(duration);
exec_cell.set_wait_total(Some(total));
if wait_still_pending {
exec_cell.set_waiting(true);
} else {
exec_cell.set_waiting(false);
}
for (text, is_error_note) in &note_lines {
exec_cell.push_wait_note(text, *is_error_note);
}
updated = true;
}
}
}
if !updated {
if let Some(exec_cell) = self
.history_cells
.iter_mut()
.rev()
.find_map(|cell| {
cell.as_any_mut()
.downcast_mut::<history_cell::ExecCell>()
})
if let Some(idx) = history_idx {
let record_idx = self
.record_index_for_cell(idx)
.unwrap_or_else(|| self.record_index_for_position(idx));
if let Some(HistoryRecord::Exec(exec_record)) =
self.history_state.get(record_idx).cloned()
{
let total = exec_cell
.wait_total()
.unwrap_or_default()
.saturating_add(duration);
exec_cell.set_wait_total(Some(total));
if wait_still_pending {
exec_cell.set_waiting(true);
} else {
exec_cell.set_waiting(false);
if wait_total.is_none() {
let base = exec_record.wait_total.unwrap_or_default();
wait_total = Some(base.saturating_add(duration));
}
for (text, is_error_note) in &note_lines {
exec_cell.push_wait_note(text, *is_error_note);
if wait_notes_snapshot.is_empty() {
wait_notes_snapshot =
Self::wait_pairs_from_exec_notes(&exec_record.wait_notes);
Self::append_wait_pairs(&mut wait_notes_snapshot, &note_lines);
}
} else {
if wait_total.is_none() {
wait_total = Some(duration);
}
if wait_notes_snapshot.is_empty() {
Self::append_wait_pairs(&mut wait_notes_snapshot, &note_lines);
}
}
let _ = self.update_exec_wait_state_with_pairs(
idx,
wait_total,
wait_still_pending,
&wait_notes_snapshot,
);
}
if success {
@@ -11772,6 +11943,14 @@ impl ChatWidget<'_> {
stream_id: &str,
state: AssistantStreamState,
) {
if state.id != HistoryId::ZERO {
self.update_cell_from_record(
state.id,
HistoryRecord::AssistantStream(state.clone()),
);
return;
}
if let Some(idx) = self.history_cells.iter().rposition(|c| {
c.as_any()
.downcast_ref::<history_cell::StreamingContentCell>()
@@ -11838,21 +12017,18 @@ impl ChatWidget<'_> {
},
);
if let HistoryMutation::Inserted { index, record, .. }
| HistoryMutation::Replaced { index, record, .. } = mutation
{
if let HistoryRecord::AssistantStream(state) = record {
if index < self.history_cells.len() {
if let Some(stream_cell) = self.history_cells[index]
.as_any_mut()
.downcast_mut::<history_cell::StreamingContentCell>()
{
stream_cell.update_from_state(state.clone(), &self.config);
}
} else {
match mutation {
HistoryMutation::Inserted { record, .. } => {
if let HistoryRecord::AssistantStream(state) = record {
self.refresh_streaming_cell_for_stream_id(stream_id, state);
}
}
HistoryMutation::Replaced { id, record, .. } => {
if matches!(record, HistoryRecord::AssistantStream(_)) {
self.update_cell_from_record(id, record);
}
}
_ => {}
}
}
+37 -16
View File
@@ -4,7 +4,7 @@ use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::height_manager::HeightEvent;
use crate::history::state::{
ExecAction, ExecRecord, ExecStatus, HistoryDomainRecord,
ExecAction, ExecRecord, ExecStatus, ExploreRecord, HistoryDomainRecord, HistoryId,
};
use crate::history_cell::CommandOutput;
use crate::history_cell::{self, HistoryCell};
@@ -51,6 +51,8 @@ fn exec_record_from_begin(ev: &ExecCommandBeginEvent) -> ExecRecord {
stdout_chunks: Vec::new(),
stderr_chunks: Vec::new(),
exit_code: None,
wait_total: None,
wait_active: false,
wait_notes: Vec::new(),
started_at: std::time::SystemTime::now(),
completed_at: None,
@@ -95,6 +97,7 @@ pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
.iter()
.map(|(k, v)| (k.clone(), v.history_index, v.explore_entry))
.collect();
let mut agg_was_updated = false;
for (call_id, maybe_idx, explore_entry) in &running {
if let Some(idx) = maybe_idx {
finalize_exec_cell_at(
@@ -116,17 +119,20 @@ pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
*entry_idx,
history_cell::ExploreEntryStatus::Error { exit_code: None },
);
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
let mut cell = history_cell::ExploreAggregationCell::from_record(record.clone());
cell.set_trailing(existing.is_trailing());
chat.history_replace_at(*agg_idx, Box::new(cell));
chat.history_replace_with_record(
*agg_idx,
Box::new(cell),
HistoryDomainRecord::Explore(record),
);
chat.autoscroll_if_near_bottom();
chat.request_redraw();
agg_was_updated = true;
}
}
}
chat.canceled_exec_call_ids.insert(call_id.clone());
}
let agg_was_updated = running.iter().any(|(_, _, entry)| entry.is_some());
chat.exec.running_commands.clear();
if agg_was_updated {
chat.exec.running_explore_agg_index = None;
@@ -263,11 +269,14 @@ pub(super) fn finalize_all_running_due_to_answer(chat: &mut ChatWidget<'_>) {
{
let mut record = existing.record().clone();
record.update_status(*entry_idx, history_cell::ExploreEntryStatus::Success);
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
let mut cell = history_cell::ExploreAggregationCell::from_record(record.clone());
cell.set_trailing(existing.is_trailing());
chat.history_replace_at(*agg_idx, Box::new(cell));
chat.history_replace_with_record(
*agg_idx,
Box::new(cell),
HistoryDomainRecord::Explore(record),
);
chat.autoscroll_if_near_bottom();
chat.request_redraw();
agg_was_updated = true;
}
}
@@ -514,9 +523,15 @@ pub(super) fn handle_exec_begin_now(
if agg_idx.is_none() {
let key = ChatWidget::order_key_from_order_meta(order);
let idx = chat.history_insert_with_key_global(
Box::new(history_cell::ExploreAggregationCell::new()),
let record = ExploreRecord {
id: HistoryId::ZERO,
entries: Vec::new(),
};
let idx = chat.history_insert_with_key_global_tagged(
Box::new(history_cell::ExploreAggregationCell::from_record(record.clone())),
key,
"explore",
Some(HistoryDomainRecord::Explore(record)),
);
created_new = true;
agg_idx = Some(idx);
@@ -535,11 +550,14 @@ pub(super) fn handle_exec_begin_now(
&chat.config.cwd,
&ev.command,
) {
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
let mut cell = history_cell::ExploreAggregationCell::from_record(record.clone());
cell.set_trailing(trailing);
chat.history_replace_at(idx, Box::new(cell));
chat.history_replace_with_record(
idx,
Box::new(cell),
HistoryDomainRecord::Explore(record),
);
chat.autoscroll_if_near_bottom();
chat.request_redraw();
chat.exec.running_explore_agg_index = Some(idx);
chat.exec.running_commands.insert(
super::ExecCallId(ev.call_id.clone()),
@@ -682,11 +700,14 @@ pub(super) fn handle_exec_end_now(
.map(|existing| (existing.record().clone(), existing.is_trailing()))
}) {
record.update_status(entry_idx, status.clone());
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
let mut cell = history_cell::ExploreAggregationCell::from_record(record.clone());
cell.set_trailing(trailing);
chat.history_replace_at(agg_idx, Box::new(cell));
chat.history_replace_with_record(
agg_idx,
Box::new(cell),
HistoryDomainRecord::Explore(record),
);
chat.autoscroll_if_near_bottom();
chat.request_redraw();
}
if !chat
.exec
+200 -1
View File
@@ -3,7 +3,7 @@
use super::*;
use crate::app_event::{AppEvent, BackgroundPlacement};
use crate::app_event_sender::AppEventSender;
use crate::history::state::{ExecStatus, HistoryRecord, HistoryState};
use crate::history::state::{ExecStatus, ExploreEntryStatus, HistoryId, HistoryRecord, HistoryState};
use crate::slash_command::SlashCommand;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
@@ -11,6 +11,7 @@ use codex_core::config::ConfigToml;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::parse_command::ParsedCommand;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentStatusUpdateEvent;
@@ -20,6 +21,8 @@ use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::CustomToolCallBeginEvent;
use codex_core::protocol::CustomToolCallEndEvent;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
@@ -29,6 +32,7 @@ use codex_core::protocol::FileChange;
use codex_core::protocol::RateLimitSnapshotEvent;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::OrderMeta;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use crossterm::event::KeyCode;
@@ -43,8 +47,10 @@ use std::io::BufReader;
use std::io::Read;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::time::Duration;
use tokio::sync::mpsc::unbounded_channel;
use strip_ansi_escapes::strip as strip_ansi_bytes;
use serde_json::json;
fn test_config() -> Config {
// Use base defaults to avoid depending on host state.
@@ -216,6 +222,191 @@ fn background_event_before_next_output_precedes_later_cells() {
]);
}
#[test]
fn wait_tool_updates_exec_record_via_history_state() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
let cwd = chat.config.cwd.clone();
chat.handle_codex_event(Event {
id: "req-1".into(),
event_seq: 1,
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-1".into(),
command: vec!["sleep".into(), "1".into()],
cwd: cwd.clone(),
parsed_cmd: Vec::new(),
}),
order: Some(order_meta(0)),
});
pump_app_events(&mut chat, &rx);
let _ = drain_insert_history(&rx);
let wait_params = json!({
"call_id": "call-1",
"for": "sleep 1",
});
chat.handle_codex_event(Event {
id: "req-1".into(),
event_seq: 2,
msg: EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "wait-1".into(),
tool_name: "wait".into(),
parameters: Some(wait_params.clone()),
}),
order: Some(order_meta(1)),
});
pump_app_events(&mut chat, &rx);
let _ = drain_insert_history(&rx);
chat.handle_codex_event(Event {
id: "req-1".into(),
event_seq: 3,
msg: EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "wait-1".into(),
tool_name: "wait".into(),
parameters: Some(wait_params),
duration: Duration::from_secs(2),
result: Ok("Scaling services".to_string()),
}),
order: Some(order_meta(2)),
});
pump_app_events(&mut chat, &rx);
let _ = drain_insert_history(&rx);
let exec_record = chat
.history_state()
.records
.iter()
.find_map(|rec| match rec {
HistoryRecord::Exec(record) => Some(record.clone()),
_ => None,
})
.expect("exec record present");
assert_eq!(exec_record.wait_total, Some(Duration::from_secs(2)));
assert!(!exec_record.wait_active);
assert!(exec_record
.wait_notes
.iter()
.any(|note| note.message.contains("Scaling services")));
let exec_cell = chat
.history_cells
.iter()
.find_map(|cell| cell.as_any().downcast_ref::<history_cell::ExecCell>())
.expect("exec cell present");
assert_eq!(exec_cell.record.wait_total, exec_record.wait_total);
assert_eq!(exec_cell.record.wait_notes.len(), exec_record.wait_notes.len());
}
#[test]
fn explore_updates_history_state_via_domain_events() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
let cwd = chat.config.cwd.clone();
let search_cmd = vec!["rg".into(), "foo".into()];
let parsed = vec![ParsedCommand::Search {
cmd: "rg foo".to_string(),
query: Some("foo".to_string()),
path: None,
}];
chat.handle_codex_event(Event {
id: "req-1".into(),
event_seq: 1,
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-1".into(),
command: search_cmd.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed.clone(),
}),
order: Some(order_meta(0)),
});
pump_app_events(&mut chat, &rx);
let _ = drain_insert_history(&rx);
let explore_id = chat
.history_state()
.records
.iter()
.find_map(|rec| match rec {
HistoryRecord::Explore(record) => Some(record.id),
_ => None,
})
.expect("explore record inserted");
assert_ne!(explore_id, HistoryId::ZERO);
let explore_state = chat
.history_state()
.records
.iter()
.find_map(|rec| match rec {
HistoryRecord::Explore(record) if record.id == explore_id => Some(record.clone()),
_ => None,
})
.unwrap();
assert_eq!(explore_state.entries.len(), 1);
assert!(matches!(explore_state.entries[0].status, ExploreEntryStatus::Running));
chat.handle_codex_event(Event {
id: "req-1".into(),
event_seq: 2,
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-1".into(),
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
duration: Duration::from_millis(250),
}),
order: Some(order_meta(1)),
});
pump_app_events(&mut chat, &rx);
let _ = drain_insert_history(&rx);
let explore_state = chat
.history_state()
.records
.iter()
.find_map(|rec| match rec {
HistoryRecord::Explore(record) if record.id == explore_id => Some(record.clone()),
_ => None,
})
.unwrap();
assert!(matches!(explore_state.entries[0].status, ExploreEntryStatus::Success));
let explore_cell = chat
.history_cells
.iter()
.find_map(|cell| cell.as_any().downcast_ref::<history_cell::ExploreAggregationCell>())
.expect("explore cell present");
assert_eq!(explore_cell.record().id, explore_id);
}
#[test]
fn diff_inserts_history_record_through_domain_event() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.history_push_diff(Some("README".to_string()), "@@ -0,0 +1 @@\n+hello world\n".to_string());
let diff_record = chat
.history_state()
.records
.iter()
.find_map(|rec| match rec {
HistoryRecord::Diff(record) => Some(record.clone()),
_ => None,
})
.expect("diff record present");
assert_ne!(diff_record.id, HistoryId::ZERO);
let diff_cell = chat
.history_cells
.iter()
.find_map(|cell| cell.as_any().downcast_ref::<history_cell::DiffCell>())
.expect("diff cell present");
assert_eq!(diff_cell.record().id, diff_record.id);
}
#[test]
fn limits_overlay_loading_when_snapshot_missing() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
@@ -468,6 +659,14 @@ fn drain_insert_history(
out
}
fn order_meta(seq: u64) -> OrderMeta {
OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(seq),
}
}
fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
let mut s = String::new();
for line in lines {
+102
View File
@@ -38,6 +38,9 @@ pub enum HistoryDomainEvent {
index: usize,
record: HistoryDomainRecord,
},
Remove {
index: usize,
},
UpdateExecStream {
index: usize,
stdout_chunk: Option<ExecStreamChunk>,
@@ -49,6 +52,12 @@ pub enum HistoryDomainEvent {
delta: Option<AssistantStreamDelta>,
metadata: Option<MessageMetadata>,
},
UpdateExecWait {
index: usize,
total_wait: Option<Duration>,
wait_active: bool,
notes: Vec<ExecWaitNote>,
},
}
#[derive(Clone, Debug, PartialEq)]
@@ -60,6 +69,11 @@ pub enum HistoryDomainRecord {
RateLimits(RateLimitsRecord),
Exec(ExecRecord),
AssistantStream(AssistantStreamState),
Patch(PatchRecord),
Image(ImageRecord),
Diff(DiffRecord),
Explore(ExploreRecord),
Notice(NoticeRecord),
}
impl From<PlainMessageState> for HistoryDomainRecord {
@@ -104,6 +118,36 @@ impl From<AssistantStreamState> for HistoryDomainRecord {
}
}
impl From<PatchRecord> for HistoryDomainRecord {
fn from(state: PatchRecord) -> Self {
HistoryDomainRecord::Patch(state)
}
}
impl From<ImageRecord> for HistoryDomainRecord {
fn from(state: ImageRecord) -> Self {
HistoryDomainRecord::Image(state)
}
}
impl From<DiffRecord> for HistoryDomainRecord {
fn from(state: DiffRecord) -> Self {
HistoryDomainRecord::Diff(state)
}
}
impl From<ExploreRecord> for HistoryDomainRecord {
fn from(state: ExploreRecord) -> Self {
HistoryDomainRecord::Explore(state)
}
}
impl From<NoticeRecord> for HistoryDomainRecord {
fn from(state: NoticeRecord) -> Self {
HistoryDomainRecord::Notice(state)
}
}
impl HistoryDomainRecord {
fn into_history_record(self) -> HistoryRecord {
match self {
@@ -135,6 +179,26 @@ impl HistoryDomainRecord {
state.id = HistoryId::ZERO;
HistoryRecord::AssistantStream(state)
}
HistoryDomainRecord::Patch(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::Patch(state)
}
HistoryDomainRecord::Image(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::Image(state)
}
HistoryDomainRecord::Diff(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::Diff(state)
}
HistoryDomainRecord::Explore(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::Explore(state)
}
HistoryDomainRecord::Notice(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::Notice(state)
}
}
}
}
@@ -143,11 +207,23 @@ impl HistoryDomainRecord {
pub struct PlainMessageState {
pub id: HistoryId,
pub role: PlainMessageRole,
pub kind: PlainMessageKind,
pub header: Option<MessageHeader>,
pub lines: Vec<MessageLine>,
pub metadata: Option<MessageMetadata>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlainMessageKind {
Plain,
User,
Assistant,
Tool,
Error,
Background,
Notice,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlainMessageRole {
System,
@@ -403,6 +479,10 @@ pub struct ExecRecord {
pub stdout_chunks: Vec<ExecStreamChunk>,
pub stderr_chunks: Vec<ExecStreamChunk>,
pub exit_code: Option<i32>,
#[serde(default)]
pub wait_total: Option<Duration>,
#[serde(default)]
pub wait_active: bool,
pub wait_notes: Vec<ExecWaitNote>,
pub started_at: SystemTime,
pub completed_at: Option<SystemTime>,
@@ -769,6 +849,9 @@ impl HistoryState {
let record = record.into_history_record();
self.apply_event(HistoryEvent::Replace { index, record })
}
HistoryDomainEvent::Remove { index } => {
self.apply_event(HistoryEvent::Remove { index })
}
HistoryDomainEvent::UpdateExecStream {
index,
stdout_chunk,
@@ -846,6 +929,25 @@ impl HistoryState {
record,
})
}
HistoryDomainEvent::UpdateExecWait {
index,
total_wait,
wait_active,
notes,
} => {
if let Some(HistoryRecord::Exec(existing)) = self.records.get(index).cloned() {
let mut updated = existing;
updated.wait_total = total_wait;
updated.wait_active = wait_active;
updated.wait_notes = notes;
self.apply_event(HistoryEvent::Replace {
index,
record: HistoryRecord::Exec(updated),
})
} else {
HistoryMutation::Noop
}
}
}
}
}
+39 -58
View File
@@ -366,9 +366,9 @@ impl ExecCell {
.completed_at
.and_then(|done| done.duration_since(record.started_at).ok());
let wait_state = ExecWaitState {
total_wait: None,
total_wait: record.wait_total,
run_duration,
waiting: matches!(record.status, ExecStatus::Running),
waiting: record.wait_active,
notes: wait_notes_from_record(&record.wait_notes),
};
let start_time = if matches!(record.status, ExecStatus::Running) {
@@ -413,20 +413,32 @@ impl ExecCell {
}
pub(crate) fn set_waiting(&self, waiting: bool) {
let mut state = self.wait_state.borrow_mut();
if state.waiting != waiting {
state.waiting = waiting;
drop(state);
pub(crate) fn set_waiting(&mut self, waiting: bool) {
let mut changed = false;
{
let mut state = self.wait_state.borrow_mut();
if state.waiting != waiting {
state.waiting = waiting;
changed = true;
}
}
if changed {
self.record.wait_active = waiting;
self.invalidate_render_caches();
}
}
pub(crate) fn set_wait_total(&self, total: Option<Duration>) {
let mut state = self.wait_state.borrow_mut();
if state.total_wait != total {
state.total_wait = total;
drop(state);
pub(crate) fn set_wait_total(&mut self, total: Option<Duration>) {
let mut changed = false;
{
let mut state = self.wait_state.borrow_mut();
if state.total_wait != total {
state.total_wait = total;
changed = true;
}
}
if changed {
self.record.wait_total = total;
self.invalidate_render_caches();
}
}
@@ -440,52 +452,6 @@ impl ExecCell {
}
}
pub(crate) fn wait_total(&self) -> Option<Duration> {
self.wait_state.borrow().total_wait
}
pub(crate) fn clear_wait_notes(&mut self) {
let mut state = self.wait_state.borrow_mut();
if state.notes.is_empty() {
return;
}
state.notes.clear();
drop(state);
self.record.wait_notes.clear();
self.invalidate_render_caches();
}
pub(crate) fn push_wait_note(&mut self, text: &str, is_error: bool) {
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
let mut state = self.wait_state.borrow_mut();
if state
.notes
.last()
.map(|note| note.text == trimmed && note.is_error == is_error)
.unwrap_or(false)
{
return;
}
state.notes.push(ExecWaitNote {
text: trimmed.to_string(),
is_error,
});
drop(state);
self.record.wait_notes.push(RecordExecWaitNote {
message: trimmed.to_string(),
tone: if is_error {
TextTone::Error
} else {
TextTone::Info
},
timestamp: SystemTime::now(),
});
self.invalidate_render_caches();
}
pub(crate) fn set_wait_notes(&mut self, notes: &[(String, bool)]) {
let mut state = self.wait_state.borrow_mut();
let mut changed = state.notes.len() != notes.len();
@@ -796,6 +762,17 @@ impl ExecCell {
self.parsed = record.parsed.clone();
self.has_bold_command = command_has_bold_token(&self.command);
let run_duration = record
.completed_at
.and_then(|done| done.duration_since(record.started_at).ok());
{
let mut wait_state = self.wait_state.borrow_mut();
wait_state.total_wait = record.wait_total;
wait_state.waiting = record.wait_active;
wait_state.run_duration = run_duration;
wait_state.notes = wait_notes_from_record(&record.wait_notes);
}
if matches!(record.status, ExecStatus::Running) {
let stdout = chunks_to_string(&record.stdout_chunks);
let stderr = chunks_to_string(&record.stderr_chunks);
@@ -981,6 +958,8 @@ fn build_exec_record(
stdout_chunks: chunk_from_text(&output.stdout),
stderr_chunks: chunk_from_text(&output.stderr),
exit_code: Some(exit_code),
wait_total: None,
wait_active: false,
wait_notes: Vec::new(),
started_at,
completed_at: Some(SystemTime::now()),
@@ -995,6 +974,8 @@ fn build_exec_record(
stdout_chunks: Vec::new(),
stderr_chunks: Vec::new(),
exit_code: None,
wait_total: None,
wait_active: false,
wait_notes: Vec::new(),
started_at,
completed_at: None,
-11
View File
@@ -5,7 +5,6 @@ use crate::history::state::{
ExploreEntryStatus,
ExploreRecord,
ExploreSummary,
HistoryId,
};
use codex_core::parse_command::ParsedCommand;
use shlex::Shlex;
@@ -17,16 +16,6 @@ pub(crate) struct ExploreAggregationCell {
}
impl ExploreAggregationCell {
pub(crate) fn new() -> Self {
Self {
record: ExploreRecord {
id: HistoryId::ZERO,
entries: Vec::new(),
},
is_trailing: true,
}
}
pub(crate) fn from_record(record: ExploreRecord) -> Self {
Self {
record,
+9
View File
@@ -41,6 +41,15 @@ impl ImageOutputCell {
Self { state, record }
}
pub(crate) fn from_record(record: ImageRecord) -> Self {
let state = ImageCellState {
width: record.width as u32,
height: record.height as u32,
rgba: Vec::new(),
};
Self { state, record }
}
pub(crate) fn record(&self) -> &ImageRecord {
&self.record
}
+26 -18
View File
@@ -5842,18 +5842,6 @@ pub(crate) fn new_patch_event(
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>,
) -> PatchSummaryCell {
let title = match event_type {
PatchEventType::ApprovalRequest => "proposed patch".to_string(),
PatchEventType::ApplyBegin { .. } => "Updated".to_string(),
PatchEventType::ApplySuccess => "Patch applied".to_string(),
PatchEventType::ApplyFailure => "Patch failed".to_string(),
};
let kind = match event_type {
PatchEventType::ApprovalRequest => PatchKind::Proposed,
PatchEventType::ApplyBegin { .. } => PatchKind::ApplyBegin,
PatchEventType::ApplySuccess => PatchKind::ApplySuccess,
PatchEventType::ApplyFailure => PatchKind::ApplyFailure,
};
let record = PatchRecord {
id: HistoryId::ZERO,
patch_type: match event_type {
@@ -5866,12 +5854,7 @@ pub(crate) fn new_patch_event(
},
changes,
};
PatchSummaryCell {
title,
kind,
record,
cached: std::cell::RefCell::new(None),
}
PatchSummaryCell::from_record(record)
}
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
@@ -5927,6 +5910,31 @@ struct PatchLayoutCache {
}
impl PatchSummaryCell {
pub(crate) fn from_record(record: PatchRecord) -> Self {
let kind = match record.patch_type {
HistoryPatchEventType::ApprovalRequest => PatchKind::Proposed,
HistoryPatchEventType::ApplyBegin { .. } => PatchKind::ApplyBegin,
HistoryPatchEventType::ApplySuccess => PatchKind::ApplySuccess,
HistoryPatchEventType::ApplyFailure => PatchKind::ApplyFailure,
};
let title = match record.patch_type {
HistoryPatchEventType::ApprovalRequest => "proposed patch".to_string(),
HistoryPatchEventType::ApplyBegin { .. } => "Updated".to_string(),
HistoryPatchEventType::ApplySuccess => "Patch applied".to_string(),
HistoryPatchEventType::ApplyFailure => "Patch failed".to_string(),
};
Self {
title,
kind,
record,
cached: std::cell::RefCell::new(None),
}
}
pub(crate) fn invalidate_cache(&self) {
self.cached.borrow_mut().take();
}
fn ui_event_type(&self) -> PatchEventType {
match self.record.patch_type {
HistoryPatchEventType::ApprovalRequest => PatchEventType::ApprovalRequest,
+58 -8
View File
@@ -4,6 +4,8 @@ use crate::history::state::{
HistoryId,
MessageHeader,
MessageLine,
NoticeRecord,
PlainMessageKind,
PlainMessageRole,
PlainMessageState,
};
@@ -29,7 +31,8 @@ pub(crate) struct PlainCellState {
impl PlainCellState {
fn new(lines: Vec<Line<'static>>, kind: HistoryCellType) -> Self {
let message = PlainMessageStateBuilder::from_lines(lines, kind);
let mut message = PlainMessageStateBuilder::from_lines(lines, kind);
message.kind = plain_message_kind_from_cell_kind(kind);
Self { message, kind }
}
@@ -59,6 +62,33 @@ impl PlainHistoryCell {
}
}
pub(crate) fn from_state(state: PlainMessageState) -> Self {
let kind = history_cell_kind_from_plain(state.kind);
Self {
state: PlainCellState {
message: state,
kind,
},
cached_layout: std::cell::RefCell::new(None),
}
}
pub(crate) fn from_notice_record(record: NoticeRecord) -> Self {
let header = record
.title
.filter(|title| !title.trim().is_empty())
.map(|label| MessageHeader { label, badge: None });
let state = PlainMessageState {
id: record.id,
role: PlainMessageRole::System,
kind: PlainMessageKind::Notice,
header,
lines: record.body,
metadata: None,
};
Self::from_state(state)
}
pub(crate) fn state(&self) -> &PlainMessageState {
&self.state.message
}
@@ -68,13 +98,6 @@ impl PlainHistoryCell {
&mut self.state.message
}
pub(crate) fn set_kind(&mut self, kind: HistoryCellType) {
if self.state.kind != kind {
self.state.kind = kind;
self.invalidate_layout_cache();
}
}
pub(crate) fn invalidate_layout_cache(&self) {
self.cached_layout.borrow_mut().take();
}
@@ -307,6 +330,7 @@ impl PlainMessageStateBuilder {
PlainMessageState {
id: HistoryId::ZERO,
role,
kind: plain_message_kind_from_cell_kind(kind),
header,
lines: message_lines,
metadata: None,
@@ -326,6 +350,32 @@ fn plain_role_from_kind(kind: HistoryCellType) -> PlainMessageRole {
}
}
fn plain_message_kind_from_cell_kind(kind: HistoryCellType) -> PlainMessageKind {
match kind {
HistoryCellType::User => PlainMessageKind::User,
HistoryCellType::Assistant => PlainMessageKind::Assistant,
HistoryCellType::Tool { .. } => PlainMessageKind::Tool,
HistoryCellType::Error => PlainMessageKind::Error,
HistoryCellType::BackgroundEvent => PlainMessageKind::Background,
HistoryCellType::Notice => PlainMessageKind::Notice,
_ => PlainMessageKind::Plain,
}
}
fn history_cell_kind_from_plain(kind: PlainMessageKind) -> HistoryCellType {
match kind {
PlainMessageKind::User => HistoryCellType::User,
PlainMessageKind::Assistant => HistoryCellType::Assistant,
PlainMessageKind::Tool => HistoryCellType::Tool {
status: super::ToolCellStatus::Success,
},
PlainMessageKind::Error => HistoryCellType::Error,
PlainMessageKind::Background => HistoryCellType::BackgroundEvent,
PlainMessageKind::Notice => HistoryCellType::Notice,
PlainMessageKind::Plain => HistoryCellType::Plain,
}
}
fn should_hide_header(kind: HistoryCellType) -> bool {
matches!(
kind,
@@ -69,6 +69,39 @@ impl CollapsibleReasoningCell {
}
}
pub(crate) fn from_state(state: ReasoningState) -> Self {
let ReasoningState {
id,
sections,
effort: _,
in_progress,
} = state;
let theme = crate::theme::current_theme();
let rendered_lines = sections_to_ratatui_lines(&sections, &theme);
let entries = rendered_lines
.into_iter()
.map(|line| ReasoningLineEntry {
semantic: SemanticLine::from_line(line.clone()),
raw: line,
})
.collect::<Vec<_>>();
let cell_state = CollapsibleReasoningState {
lines: entries,
sections,
in_progress,
hide_when_collapsed: false,
id: None,
history_id: id,
};
Self {
state: RefCell::new(cell_state),
collapsed: Cell::new(true),
}
}
pub(crate) fn matches_id(&self, candidate: &str) -> bool {
self.state
.borrow()