chore(tui/history): introduce domain events and task briefs

This commit is contained in:
James Peter
2025-09-27 10:56:36 +10:00
parent 0c36744a4c
commit d7e9ee7deb
7 changed files with 397 additions and 24 deletions
+41 -10
View File
@@ -13,6 +13,8 @@ To do that we're working on refactoring the history cells so that state is separ
- [~] **Introduce single `HistoryState` vector** – foundational types (`HistoryRecord`, `HistoryState`, `HistoryId`) now live in `history/state.rs`; still need to adopt them in `chatwidget.rs` and event handling.
- [ ] **Centralize event → state mapping** – funnel all `handle_*` mutations through `HistoryState::apply_event` and a dedicated render adapter.
- [ ] **Unlock serialization & perf goals** – once the state vector exists, wire serialization, resume snapshots, undo rewind, and cached layout per `(record_id, width)`.
- [ ] **Document interim cache bridge** – keep legacy per-cell layout caches (exec/assistant/diff) in place until the Step 6 renderer cache is implemented, and track their removal behind the new caching system.
- [ ] **Land HistoryDomainEvent layer** – introduce a dedicated domain-event enum + helpers so every TUI mutation flows through `HistoryState::apply_event` without reconstructing records from cells.
## Shared Rendering Inputs
- History viewport width (drives wrapping and layout caches).
@@ -99,27 +101,32 @@ Status legend: ✅ complete (semantic deterministic state ready), ⏳ still need
### ⏳ Exec Commands – `ExecCell`
- **Status:** [~] `ExecRecord` lives in `HistoryState`, but rendering still depends on the legacy `ExecCell` caches.
- **Current:** command metadata, layout caches, and wait state remain coupled to the UI struct instead of the serialized record.
- **Needed:** read/write `ExecRecord` via a dedicated `history_cell/exec.rs`, and move per-width layout caches into the renderer layer.
- **Decision:** keep those caches energized until the Step 6 renderer cache is available; otherwise exec redraws regressed by >2× during testing.
- **Needed:** read/write `ExecRecord` via a dedicated `history_cell/exec.rs`, and move per-width layout caches into the renderer layer once the shared cache exists.
- **External settings:** theme, width, monotonic time for “running” durations.
### ⏳ Assistant Streaming – `StreamingContentCell`
- **Status:** [~] streaming records capture IDs, markdown deltas, citations, and metadata in `AssistantStreamState`; the cell still owns a redundant `Vec<Line>` cache.
- **Current:** renderer ignores the stored deltas and instead maintains wrapped lines per width.
- **Decision:** hold this cache until the centralized renderer cache exists so streaming updates stay smooth.
- **Needed:** rebuild previews from `AssistantStreamState`, drop the duplicated line cache, and finish piping citations/token usage through serialization.
### ⏳ Assistant Answers – `AssistantMarkdownCell`
- **Status:** [~] finalized messages persist markdown/citations/token usage via `AssistantMessageState`, while the cell caches a wrapped copy for legacy rendering.
- **Current:** raw markdown and per-width layout live in the UI layer, so `HistoryState` snapshots cannot rehydrate the answer alone.
- **Decision:** keep the wrapped-line cache until Step 6 introduces the shared renderer cache to avoid flicker on theme/width changes.
- **Needed:** render directly from `AssistantMessageState`, move layout caching to the renderer, and delete the redundant `lines` buffer.
### ⏳ Merged Exec Summary – `MergedExecCell`
- **Status:** ⏳ still reuses rendered `ExecCell` line pairs instead of the structured `ExecRecord` output chunks.
- **Current:** keeps preformatted preamble/output text, preventing reuse of serialized exec data.
- **Decision:** rely on the existing `ExecCell` cache bridge until the new renderer cache is ready so merged exec performance stays stable.
- **Needed:** rebuild merged summaries from `Vec<ExecRecord>` snapshots once the exec module is refactored.
### ⏳ Diffs – `DiffCell`
- **Status:** [~] `DiffRecord` already stores hunks and `DiffLineKind`; the cell precomputes styled lines for legacy rendering.
- **Current:** cached `Vec<Line<'static>>` duplicates the structured data and bypasses `HistoryState` for updates.
- **Decision:** retain the RefCell layout cache until the shared renderer cache is available; otherwise large diffs stutter on scroll.
- **Needed:** route diff events through `HistoryState`, render directly from `DiffRecord`, then remove the cached line buffer.
### ⏳ Explore Fetch / HTTP – `ExploreRecord`
@@ -141,23 +148,33 @@ Status legend: ✅ complete (semantic deterministic state ready), ⏳ still need
1.6 **Wait status & background notices** – ✅ wait tools use `WaitStatusState`; background notices render via `BackgroundEventRecord`.
1.7 **Documentation** – ✅ inline docs now cover reasoning summaries/bullets; audit of constructors confirmed strongly typed state (2025-09-26).
## Step 2 – Exec / Streaming / Diff Family *(Pending)*
2.1 **Exec state extraction & module split** – ✅ `ExecCell` now lives in `history_cell/exec.rs`, builds from `ExecRecord` (including wait notes/stream chunks), and keeps layout caches strictly in the renderer layer (2025-09-26).
2.2 **Streaming assistant module** – Capture raw markdown deltas + metadata in `AssistantStreamState`, relocate the streaming renderer to `history_cell/stream.rs`, and support merge/upsert by stream id. **Status:** ✅ streaming renderer now rebuilds cells directly from `AssistantStreamState`, token usage metadata is upserted alongside deltas, and redundant layout caches were removed (2025-09-26).
2.3 **Finalized assistant markdown module** – Use `AssistantMessageState` storing markdown + citations + token metadata. **Status:** ✅ assistant markdown cells now rebuild directly from `AssistantMessageState`, `InsertFinalAnswer` carries stream metadata (citations/token usage) into history state, and redundant raw caching has been removed in favor of state-driven rebuilds (2025-09-26).
2.4 **Diff module breakout** – ✅ diff cells now rebuild from `DiffRecord` state with per-width layouts, ChatWidget persists diff snapshots in `HistoryState`, and the renderer applies line-kind styling with dedicated marker columns (2025-09-26).
2.5 **Merged exec views** – ✅ merged exec cells now clone `ExecRecord` snapshots for each segment, rebuild layouts on demand (theme-aware caches), and ChatWidget merges/retints using the recorded exec state instead of legacy line buffers (2025-09-26).
## Step 2 – Exec / Streaming / Diff Bridge *(In Progress)*
2.1 **Exec state extraction & module split** – [~] `ExecCell` now lives in `history_cell/exec.rs` and consumes `ExecRecord`, but we are deliberately keeping the legacy per-width layout caches and wait-state buffers until the new renderer cache (Step 6) lands to avoid regressions.
2.2 **Streaming assistant module** – [~] `AssistantStreamState` drives the streaming cell and carries token/citation metadata; `StreamingContentCell` still wraps `AssistantMarkdownCell`, so the internal markdown+layout caches remain as an intentional bridge until the shared renderer cache is ready.
2.3 **Finalized assistant markdown module** – [~] `AssistantMessageState` is the source of truth, but `AssistantMarkdownCell` continues to own per-width layout caches for performance; plan to migrate them once Step 6 introduces the centralized cache.
2.4 **Diff module breakout** – [~] diff cells read `DiffRecord` hunks, yet the `DiffCell` keeps a per-width layout cache; removal is blocked on the new caching layer.
2.5 **Merged exec views** – [~] merged exec cells construct segments from `ExecRecord`, but each segment spins up an `ExecCell` to reuse the old caches; this is acceptable until the shared renderer cache replaces both layers.
## 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:** assistant streaming/final answer flows now write into `HistoryState`; plain/background/wait/loading cells, running/completed tool calls, plan updates, upgrade notices, reasoning summaries, exec cells, diff summaries, patch summaries, image previews, **and explore aggregations** now insert exclusively via `history_insert_with_key_global_tagged` + `HistoryState::apply_event`. Next steps: migrate rate-limit notices and remaining niche cells before deleting the legacy vector. **Update (2025-09-26):** migration will now happen only via the new `HistoryState::apply_event` entrypoint so every cell’s lifecycle is driven by events rather than bespoke insert/replace helpers.
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:** Insert events now assign `HistoryId`s for plain/background/loading/wait/tool/plan/upgrade/reasoning/exec/diff/assistant/patch/image/explore/rate-limit cells and keep `history_cell_ids` in sync. Replacement and removal helpers also call `HistoryState::apply_event`; remaining follow-up: emit domain-specific `HistoryEvent::Replace` updates for patch/result metadata and delete the few direct `history_cells` mutations that still rebuild cells inline. **Update (2025-09-26):** build `apply_event` first, then iterate cell-by-cell: for each event family (plain messages, loading indicators, wait status, etc.) add a handler in `apply_event`, update `ChatWidget` to publish only events, and adapt rendering to rebuild from the resulting `HistoryRecord`s. The legacy `history_cells` vector will temporarily mirror `HistoryState` via an ID→cell cache until Step 3.5 removes it entirely.
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.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.
- 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
- **Phase A – Inventory:** enumerate every direct mutation (`history_cells[idx] = ...`, `history_cells.remove`, `history_record_from_cell`) and map them to a target `HistoryEvent` variant. Track results in `chatwidget/events_audit.md` (new doc) so progress is visible.
- **Phase B – API surface:** extend `HistoryState::apply_event` to accept domain enums (`HistoryDomainEvent`) which wrap core `EventMsg` payloads; expose helpers like `apply_exec_event` to keep match arms local to each module.
- **Phase C – Migration waves:**
1. Plain/background/loading/wait/tool/plan/upgrade (already idempotent) – replace inline constructors with `HistoryDomainEvent::InsertPlain` etc.
2. Exec/stream/assistant – route lifecycle through domain events so streaming merges/final answers run purely on `HistoryState` data.
3. Diff/patch/image/explore/rate-limit – remove bespoke mutation helpers and rely on event emissions from their source modules.
- **Phase D – Flip ownership:** once all mutations go through domain events, replace `history_cells` with a derived cache built from `HistoryRecord` snapshots + the renderer cache. Provide a debug flag to assert no code path mutates `history_cells` directly.
## Step 4 – Event Mapping & Rendering *(Pending)*
4.1 **Centralize handlers** – Route every mutation in `handle_*` (exec events, tool deltas, diff updates, background notices) through `HistoryState` so ordering/id management lives in one place.
@@ -171,6 +188,20 @@ Status legend: ✅ complete (semantic deterministic state ready), ⏳ still need
- Persist history vector in session logs; implement `/undo` by restoring prior snapshot.
## Step 6 – Performance Improvements *(Blocked on Step 4)*
- Memoize per-cell layouts keyed by `(HistoryId, width, theme_epoch)`.
- Ship the shared renderer cache (memoized per-cell layouts keyed by `(HistoryId, width, theme_epoch)`) so the interim exec/assistant/diff caches can be removed.
- Add instrumentation to measure render latency and scrolling cost pre/post refactor.
- Benchmark resume/undo operations using new snapshots.
### Renderer Cache Design Sketch
- **Responsibility:** centralize layout caching in `HistoryRenderState` so individual cells own only semantic state. Cache entries map `(HistoryId, width, theme_epoch, reason_visibility)` → `RenderedCell` (lines + per-row buffers + meta).
- **Structure:**
- `RenderedCell` wraps immutable `Vec<Line<'static>>` plus precomposed `Vec<Box<[BufferCell]>>` for fast draw.
- `HistoryRenderState` keeps an `IndexMap<CacheKey, Arc<RenderedCell>>` with LRU eviction (target: 512 entries, tunable via config).
- `CacheKey` includes `theme_epoch` (incremented on palette change) and any feature flags (e.g., reasoning collapsed) so stale entries drop naturally.
- **Invalidation hooks:**
- `HistoryState::apply_event` emits `HistoryRenderInvalidate` messages when the underlying record changes.
- Width changes call `HistoryRenderState::handle_width_change(new_width)` to flush mismatched entries.
- Theme swaps bump `theme_epoch`; toggling reasoning detail flips a `ReasoningVisibility` enum baked into the key.
- **Warm path:** when rendering, look up `(id, width, theme_epoch, visibility)`. If present, reuse. If missing, invoke cell-specific `render_to_lines(record, settings)` helpers to produce lines, then store.
- **Cold path optimizations:** reuse arena-allocated span buffers to reduce allocations; consider hashing markdown bodies to share across identical answers when history rewinds.
- **Telemetry:** wrap cache hits/misses with counters exported via `/perf` to confirm the shared cache covers ≥90 % of paint operations before removing per-cell caches.
@@ -0,0 +1,36 @@
# Plain/Background/Loading/Wait Migration
## Summary
- Replace all plain/background/loading/wait cell insertions and replacements in
`chatwidget.rs` with `HistoryDomainEvent` calls so `HistoryState` becomes the
source of truth for these stable cell types.
- Remove any remaining uses of `history_record_from_cell` for these families
and ensure IDs are assigned via the new domain-event helpers.
## Prerequisites
- `HistoryDomainEvent` infrastructure landed and exported from
`history/state.rs`.
- Background system notices already routed through
`history_replace_with_record` (completed 2025-09-27).
## Scope
- `history_insert_plain_cell_with_key`, `history_push_plain_cell`, wait tool
completions, loading spinners, and generic system helpers (`push_system_cell`).
- Update `assign_history_id` branches that become redundant once the domain
events populate IDs.
- Extend `events_audit.md` with status notes as paths are migrated.
## Deliverables
- New domain-event constructors for `PlainMessageState`, `WaitStatusState`,
`LoadingState`, and `BackgroundEventRecord`.
- `chatwidget.rs` updated to call `history_state.apply_domain_event(...)`
(exact API name per infrastructure work).
- Unit or integration coverage that emits events and confirms `HistoryState`
stores the expected records without referencing `history_cells` directly.
- Documentation update in `events_audit.md` marking these categories as
migrated.
## References
- `codex-rs/tui/HISTORY_CELLS_PLAN.md` – Step 3, Event Pipeline Consolidation
Plan (Phase C – wave 1).
- `codex-rs/tui/src/chatwidget/events_audit.md` – mutation inventory.
@@ -0,0 +1,35 @@
# History Renderer Cache Implementation
## Summary
- Implement the shared renderer cache described in Step 6 so per-cell layout
caches (exec/assistant/diff) can be retired once all migrations are complete.
- Provide APIs for cache lookup, invalidation, and telemetry that integrate with
the `HistoryDomainEvent` pipeline.
## Prerequisites
- Domain-event migrations for waves 1 & 2 complete (cells reconstruct from
`HistoryState` without mutating `history_cells`).
- `HistoryRenderState` ready to accept cache invalidation signals.
## Scope
- Introduce `RenderedCell` structs, cache key types, and LRU storage inside
`history_render.rs` (or a new module) based on the design sketch in
`HISTORY_CELLS_PLAN.md`.
- Expose invalidation hooks that respond to History mutations (insert/replace/
remove) and viewport/theme changes.
- Wire ChatWidget rendering paths to consult the shared cache before invoking
cell-specific rendering logic.
- Define instrumentation (counters or tracing) to measure hit/miss rates.
- Plan removal of legacy per-cell caches once cache hit rates are validated.
## Deliverables
- New caching module with unit tests covering key eviction, width/theme
invalidation, and reasoning-visibility variants.
- ChatWidget rendering updated to consume cached buffers for all migrated cell
types.
- Documentation update (HISTORY_CELLS_PLAN.md Step 6) noting completion and
instructions for removing cell-local caches.
## References
- `codex-rs/tui/HISTORY_CELLS_PLAN.md` – Step 6 renderer cache design sketch.
- `codex-rs/tui/src/chatwidget/history_render.rs` – existing memoization hooks.
@@ -0,0 +1,37 @@
# Streaming / Exec / Assistant Migration
## Summary
- Move streaming deltas, finalized assistant messages, exec lifecycle updates,
and merged exec summaries onto the `HistoryDomainEvent` pipeline.
- Eliminate cell-side state mutations and redundant cached line buffers where
practical, while preserving the interim cache bridge called out in Step 2.
## Prerequisites
- Infrastructure: `HistoryDomainEvent` enums + helpers merged.
- Wave 1 (plain/background/loading/wait) migrated so supporting utilities are
battle-tested.
## Scope
- Streaming handlers: `ensure_answer_stream_state`, tail updates, review-flow
paths, and final message insertions (`chatwidget.rs:11380-11890`).
- Exec lifecycle: active exec state, merged exec summaries, wait notes, and
tool completion merges (`chatwidget.rs:6515-7330`).
- Ensure `history_cell::ExecCell`, `StreamingContentCell`, and
`AssistantMarkdownCell` rebuild purely from `HistoryState` data after events.
- Update `assign_history_id` only where still required; prefer storing IDs via
the domain events.
## Deliverables
- Domain-event variants covering exec begin/end/stream chunks, assistant stream
deltas, assistant final messages, and merged exec summaries.
- ChatWidget handlers updated to emit domain events instead of mutating
`history_cells` directly.
- Regression tests (prefer snapshot-based tests around
`chatwidget::tests::run_script`) validating that streaming/exec flows produce
equivalent UI output after the migration.
- Notes added to `events_audit.md` marking the executive/assistant entries as
completed or updated with any residual technical debt.
## References
- `codex-rs/tui/HISTORY_CELLS_PLAN.md` – Step 2 bridge & Step 3 Phase C wave 2.
- `codex-rs/tui/src/chatwidget/events_audit.md` – exec/stream mutation list.
+84 -14
View File
@@ -209,6 +209,8 @@ use crate::history_cell::PatchEventType;
use crate::history_cell::PlainHistoryCell;
use crate::history::state::PatchEventType as HistoryPatchEventType;
use crate::history::state::{
HistoryDomainEvent,
HistoryDomainRecord,
AssistantMessageState,
AssistantStreamDelta,
AssistantStreamState,
@@ -1255,20 +1257,25 @@ impl ChatWidget<'_> {
/// If `id_for_replace` is provided and we have a prior index for it, replace in place.
fn push_system_cell(
&mut self,
cell: impl HistoryCell + 'static,
cell: Box<dyn HistoryCell>,
placement: SystemPlacement,
id_for_replace: Option<String>,
order: Option<&codex_core::protocol::OrderMeta>,
tag: &'static str,
record: Option<HistoryDomainRecord>,
) {
if let Some(id) = id_for_replace.as_ref() {
if let Some(&idx) = self.system_cell_by_id.get(id) {
self.history_replace_at(idx, Box::new(cell));
if let Some(record) = record.clone() {
self.history_replace_with_record(idx, cell, record);
} else {
self.history_replace_at(idx, cell);
}
return;
}
}
let key = self.system_order_key(placement, order);
let pos = self.history_insert_with_key_global_tagged(Box::new(cell), key, tag, None);
let pos = self.history_insert_with_key_global_tagged(cell, key, tag, record);
if let Some(id) = id_for_replace {
self.system_cell_by_id.insert(id, pos);
}
@@ -4239,7 +4246,7 @@ impl ChatWidget<'_> {
cell: Box<dyn HistoryCell>,
key: OrderKey,
tag: &'static str,
record: Option<HistoryRecord>,
record: Option<HistoryDomainRecord>,
) -> usize {
#[cfg(debug_assertions)]
{
@@ -4349,8 +4356,19 @@ impl ChatWidget<'_> {
let mut cell = cell;
let record = record.or_else(|| self.history_record_from_cell(cell.as_ref()));
let maybe_id = if let Some(record) = record {
let maybe_id = 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, .. } => {
self.assign_history_id(&mut cell, id);
Some(id)
}
_ => None,
}
} 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
@@ -4640,7 +4658,7 @@ impl ChatWidget<'_> {
key: OrderKey,
tag: &'static str,
) -> usize {
let record = HistoryRecord::PlainMessage(cell.state().clone());
let record = HistoryDomainRecord::Plain(cell.state().clone());
self.history_insert_with_key_global_tagged(Box::new(cell), key, tag, Some(record))
}
@@ -4681,12 +4699,15 @@ impl ChatWidget<'_> {
}
}
};
let cell = history_cell::new_background_event(message);
let record = HistoryDomainRecord::BackgroundEvent(cell.state().clone());
self.push_system_cell(
history_cell::new_background_event(message),
Box::new(cell),
system_placement,
None,
None,
"background",
Some(record),
);
}
@@ -4712,6 +4733,42 @@ impl ChatWidget<'_> {
let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "prompt", None);
}
fn history_replace_with_record(
&mut self,
idx: usize,
mut cell: Box<dyn HistoryCell>,
record: HistoryDomainRecord,
) {
if idx >= self.history_cells.len() {
return;
}
let record_idx = self
.record_index_for_cell(idx)
.unwrap_or_else(|| self.record_index_for_position(idx));
let mutation = self.history_state.apply_domain_event(HistoryDomainEvent::Replace {
index: record_idx,
record,
});
if let Some(id) = match mutation {
HistoryMutation::Replaced { id, .. } => Some(id),
HistoryMutation::Inserted { id, .. } => Some(id),
_ => None,
} {
self.assign_history_id(&mut cell, id);
if idx < self.history_cell_ids.len() {
self.history_cell_ids[idx] = Some(id);
}
}
self.history_cells[idx] = cell;
self.invalidate_height_cache();
self.request_redraw();
self.refresh_explore_trailing_flags();
}
fn history_replace_at(&mut self, idx: usize, mut cell: Box<dyn HistoryCell>) {
if idx >= self.history_cells.len() {
return;
@@ -6671,7 +6728,7 @@ impl ChatWidget<'_> {
Box::new(cell),
key,
"rate-limits",
Some(HistoryRecord::RateLimits(record)),
Some(HistoryDomainRecord::RateLimits(record)),
);
self.request_redraw();
}
@@ -7382,12 +7439,15 @@ impl ChatWidget<'_> {
};
let id_for_replace = Some(id.clone());
let message_clone = message.clone();
let cell = history_cell::new_background_event(message_clone);
let record = HistoryDomainRecord::BackgroundEvent(cell.state().clone());
self.push_system_cell(
history_cell::new_background_event(message_clone),
Box::new(cell),
placement,
id_for_replace,
event.order.as_ref(),
"background",
Some(record),
);
// If we inserted during streaming, keep the reasoning ellipsis visible.
self.restore_reasoning_in_progress_if_streaming();
@@ -10321,12 +10381,14 @@ impl ChatWidget<'_> {
}
let placement = self.ui_placement_for_now();
let cell = history_cell::new_model_output(&self.config.model, self.config.model_reasoning_effort);
self.push_system_cell(
history_cell::new_model_output(&self.config.model, self.config.model_reasoning_effort),
Box::new(cell),
placement,
Some("ui:model".to_string()),
None,
"system",
None,
);
self.request_redraw();
@@ -10561,12 +10623,14 @@ impl ChatWidget<'_> {
// Add status message to history (replaceable system notice)
let placement = self.ui_placement_for_now();
let cell = history_cell::new_reasoning_output(&new_effort);
self.push_system_cell(
history_cell::new_reasoning_output(&new_effort),
Box::new(cell),
placement,
Some("ui:reasoning".to_string()),
None,
"system",
None,
);
}
@@ -10742,12 +10806,15 @@ impl ChatWidget<'_> {
};
let message = format!("Theme changed to {}", theme_name);
let placement = self.ui_placement_for_now();
let cell = history_cell::new_background_event(message);
let record = HistoryDomainRecord::BackgroundEvent(cell.state().clone());
self.push_system_cell(
history_cell::new_background_event(message),
Box::new(cell),
placement,
Some("ui:theme".to_string()),
None,
"background",
Some(record),
);
}
@@ -10768,12 +10835,15 @@ impl ChatWidget<'_> {
// Confirmation message (replaceable system notice)
let message = format!("Spinner changed to {}", spinner_name);
let placement = self.ui_placement_for_now();
let cell = history_cell::new_background_event(message);
let record = HistoryDomainRecord::BackgroundEvent(cell.state().clone());
self.push_system_cell(
history_cell::new_background_event(message),
Box::new(cell),
placement,
Some("ui:spinner".to_string()),
None,
"background",
Some(record),
);
}
@@ -0,0 +1,73 @@
# ChatWidget History Event Audit (2025-09-27)
This note summarizes the places where `ChatWidget` still mutates `history_cells`
directly so we can migrate them onto the `HistoryState::apply_event` pipeline.
Line numbers reference `codex-rs/tui/src/chatwidget.rs` as of 2025-09-27.
## Structural mutations
- **Global insert path** – `history_insert_with_key_global_tagged`
(`chatwidget.rs:4638`) now accepts optional `HistoryDomainRecord` inputs. When
present (e.g., background/system notices as of 2025-09-27) it routes through
`HistoryState::apply_domain_event(Insert)` and assigns IDs before the cell is
stored. Other callers still fall back to `history_record_from_cell`.
- **Indexed replacement** – `history_replace_at` (`chatwidget.rs:4715`) rebuilds a
record from the incoming cell, applies `HistoryEvent::Replace`, then writes
`history_cells[idx] = cell`. Several call sites mutate the cell before it flows
here (e.g., plan/patch updates).
- **Removal path** – `history_remove_at` (`chatwidget.rs:4781`) mirrors
`HistoryEvent::Remove` and then calls `history_cells.remove(idx)` alongside the
auxiliary ID/order vectors.
- **Patch apply success** – `handle_patch_apply_end_now`
(`chatwidget.rs:2240-2338`) finds a prior patch cell, mutates its state in
place (including manual text span edits), and calls `history_record_from_cell`
+ `HistoryEvent::Replace`. This is a prime candidate for a dedicated
`HistoryDomainEvent::PatchOutcome` handler.
- **Streaming updates** – the streaming lifecycle functions repeatedly locate
cells via `rposition` and mutate them directly:
- tail update (`chatwidget.rs:11390-11436`)
- indexed update (`chatwidget.rs:11417-11446`)
- final message hydration (`chatwidget.rs:11782-11860`)
- review-flow variants (`chatwidget.rs:11660-11747`)
These still rely on cell-owned caches and in-place mutation.
- **Exec lifecycle** – exec merge/update logic holds an active `ExecCell` in
`active_exec_cell` and downcasts cells when updates arrive
(`chatwidget.rs:7159-7184`, `7233-7242`).
- **Background/system notices** – `push_system_cell` (`chatwidget.rs:1254`) now
uses `HistoryDomainRecord::BackgroundEvent` for inserts/replacements, so these
paths no longer reconstruct records from existing cells (migrated 2025-09-27).
- **Reasoning merge** – reasoning stream handling mutates the stored
`CollapsibleReasoningCell` state (`chatwidget.rs:1440-2058`,
`11288-11336`).
## Non-structural traversal (stateful toggles)
The following loops walk `history_cells` to update cached state or trigger
animations; they should eventually derive from `HistoryRecord` once the renderer
cache lands, but they are less urgent than the structural mutations above.
- `clear_reasoning_in_progress` / `set_show_reasoning`
(`chatwidget.rs:1991-2034`).
- `on_composer_expanded`, `toggle_reasoning_visibility`, export helpers, etc.
- Animation checks (`chatwidget.rs:3174`, `18153`, `18309`).
## Migration plan mapping
- **Phase A (inventory complete)** – this document captures all direct mutation
sites that need event coverage.
- **Phase B (API surface)** – introduce `HistoryDomainEvent` enums per category:
`Plain`, `Tool`, `Exec`, `Streaming`, `Patch`, `Diff`, etc. Each enum variant
carries the payload currently embedded in the cell mutation.
- **Phase C (migration waves)** – replace call-site logic:
1. Plain/background/loading/wait/tool/plan/upgrade – already flow through
insert/replace; only need to move pre-insert mutation into domain event
constructors.
2. Exec/stream/assistant – emit domain events so state changes happen inside
`HistoryState`, then render from the resulting record.
3. Diff/patch/image/explore/rate-limit – replace bespoke `history_cells`
mutations with domain events and remove the remaining uses of
`history_record_from_cell`.
- **Phase D (ownership flip)** – once all events route through
`HistoryState::apply_event`, collapse `history_cells` into a derived cache
(fed by the Step 6 renderer cache) and guard with debug asserts to prevent
new direct mutations.
+91
View File
@@ -28,6 +28,84 @@ pub enum HistoryRecord {
Notice(NoticeRecord),
}
#[derive(Clone, Debug, PartialEq)]
pub enum HistoryDomainEvent {
Insert {
index: usize,
record: HistoryDomainRecord,
},
Replace {
index: usize,
record: HistoryDomainRecord,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum HistoryDomainRecord {
Plain(PlainMessageState),
WaitStatus(WaitStatusState),
Loading(LoadingState),
BackgroundEvent(BackgroundEventRecord),
RateLimits(RateLimitsRecord),
}
impl From<PlainMessageState> for HistoryDomainRecord {
fn from(state: PlainMessageState) -> Self {
HistoryDomainRecord::Plain(state)
}
}
impl From<WaitStatusState> for HistoryDomainRecord {
fn from(state: WaitStatusState) -> Self {
HistoryDomainRecord::WaitStatus(state)
}
}
impl From<LoadingState> for HistoryDomainRecord {
fn from(state: LoadingState) -> Self {
HistoryDomainRecord::Loading(state)
}
}
impl From<BackgroundEventRecord> for HistoryDomainRecord {
fn from(state: BackgroundEventRecord) -> Self {
HistoryDomainRecord::BackgroundEvent(state)
}
}
impl From<RateLimitsRecord> for HistoryDomainRecord {
fn from(state: RateLimitsRecord) -> Self {
HistoryDomainRecord::RateLimits(state)
}
}
impl HistoryDomainRecord {
fn into_history_record(self) -> HistoryRecord {
match self {
HistoryDomainRecord::Plain(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::PlainMessage(state)
}
HistoryDomainRecord::WaitStatus(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::WaitStatus(state)
}
HistoryDomainRecord::Loading(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::Loading(state)
}
HistoryDomainRecord::BackgroundEvent(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::BackgroundEvent(state)
}
HistoryDomainRecord::RateLimits(mut state) => {
state.id = HistoryId::ZERO;
HistoryRecord::RateLimits(state)
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlainMessageState {
pub id: HistoryId,
@@ -677,6 +755,19 @@ impl HistoryState {
}
}
}
pub fn apply_domain_event(&mut self, event: HistoryDomainEvent) -> HistoryMutation {
match event {
HistoryDomainEvent::Insert { index, record } => {
let record = record.into_history_record();
self.apply_event(HistoryEvent::Insert { index, record })
}
HistoryDomainEvent::Replace { index, record } => {
let record = record.into_history_record();
self.apply_event(HistoryEvent::Replace { index, record })
}
}
}
}
#[allow(dead_code)]