22 KiB
History Cell Refactor Plan
Background
We're seeing a lot of issues with the conversation history in the TUI. It's slow to scroll (not easily cached), has inconsistent ordering, does not reconstruct correctly when using resume or undo. We also want to be able to import conversations from sub-agents, so need a portable format. We're also seeing some really large files (such as chatwidget.rs) which we want to reduce to improve maintainability.
To do that we're working on refactoring the history cells so that state is separated from rendering. Each cell should be able to be rendered from a state object along with the system settings. This state should be React-style so determine how the cell works, but the actual rendering logic should be entirely in the cell. The state should be easily serializable in JSON and will be built entirely from events in the new HistoryState. So, for example, the state should not have presentation logic like color etc.. that would be handled by the cell during rendering.
Primary Goals (Plan & Status)
- Extract per-cell modules – core message/tool/plan/upgrade/reasoning/image/loading/animated/assistant/stream files live under
history_cell/; exec remains inline and pending extraction. - Finish semantic state refactor – removed the last
SemanticLinebridges; message/reasoning cells now store typed spans and convert independently of presentation styles (2025-09-30). - Introduce single
HistoryStatevector – foundational types (HistoryRecord,HistoryState,HistoryId) now live inhistory/state.rs; UI now resolves render/cache flows viaHistoryId, andChatWidgetbuilds frames directly from the unified vector (2025-09-30). - Centralize event → state mapping – history mutations now derive records via
history_cell::record_from_cell, ensuring every insert/remove funnels throughHistoryState::apply_eventbefore UI caches update (2025-09-30). - Unlock serialization & perf goals – history snapshots now carry ordering metadata, session logs emit record/order counts, restore paths rebuild ID/look-up caches, and renderer caches remain keyed by
(HistoryId, width, theme_epoch, reasoning)(2025-09-30). - 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. See
docs/history_render_cache_bridge.mdfor the current bridge design and removal plan (2025-09-30). - Land HistoryDomainEvent layer – introduce a dedicated domain-event enum + helpers so every TUI mutation flows through
HistoryState::apply_domain_eventwithout reconstructing records from cells. HistoryDomainRecord now covers every record variant, chatwidget mutation paths callapply_domain_event, and regression tests exercise the conversions (2025-09-30).
Shared Rendering Inputs
- History viewport width (drives wrapping and layout caches).
- Theme palette (applied when converting stored styles to runtime
Style). - Reasoning visibility toggle (
Ctrl+R, affects collapsed vs expanded reasoning blocks). - Animation timers for ephemeral UI elements (kept outside the serialized state vector).
Cell Inventory
Status legend: ✅ complete (semantic deterministic state ready), ⏳ still needs semantic refactor.
✅ Plain Messages – PlainHistoryCell
- Desired state:
struct PlainMessageState { role: HistoryCellRole, body: Vec<MessageSegment>, // text segments tagged with semantics (code, bullet, emphasis, etc.) metadata: Option<MessageMetadata>, } - Current status: ✅ state now stored as
PlainMessageState(header +Vec<MessageLine>); follow-up: richerMessageSegmenttypes for code/bullets plus metadata serialization.
✅ Wait Status – WaitStatusCell
- Desired state:
WaitStatusState { header: WaitHeader, bullet_points: Vec<WaitDetail> } - Status: ✅ migrated to
WaitStatusStatewith explicit header/detail tones; renderer rebuilds lines from structured data.
✅ Loading Spinner – LoadingCell
- State:
LoadingCellState { message: String } - External settings: theme.
✅ Tool Calls – ToolCallCell
- Desired state:
struct ToolCallState { status: ToolCallStatus, title: ToolTitle, arguments: Vec<ToolArgument>, result_preview: Option<ToolResultPreview>, } - Status: ✅ constructors now populate
ToolArgument/ToolResultPreview; follow-up: polish preview truncation + metadata serialization. - External settings: theme, width.
✅ Running Tool Calls – RunningToolCallCell
- Desired state: same struct but
arguments: Vec<ToolArgument>instead of rendered lines. - Status: ✅ running tool state stores
Vec<ToolArgument>with wait caps/call ids tracked separately. - External settings: theme, width, current time (for elapsed label).
✅ Plan Updates – PlanUpdateCell
- Desired state:
PlanUpdateState { icon: PlanIcon, items: Vec<PlanLine>, completion: PlanCompletion } - Status: ✅ stores
PlanUpdateStatewithPlanProgress/PlanStep; follow-up: richer icons + metadata serialization. - External settings: theme, width.
✅ Upgrade Notices – UpgradeNoticeCell
- Desired state:
UpgradeNoticeState { current_version: String, latest_version: String, message: UpgradeMessage } - Status: ✅ upgrade notices now keep
{ current_version, latest_version, message }; follow-up: wire optional CTA metadata. - External settings: theme, width.
✅ Background Events – BackgroundEventCell
- Status: ✅ already semantic (metadata only).
- External settings: theme; renderer currently emits metadata text.
✅ Animated Welcome – AnimatedWelcomeCell
- Persistence: not stored in the state vector (UI-only animation).
- Runtime state: start time, fade progress, cached height.
✅ Reasoning – CollapsibleReasoningCell
- Desired state:
ReasoningState { id, sections: Vec<ReasoningSection>, in_progress, hide_when_collapsed }with sections broken into semantic blocks. - Status: ✅ state maintains
ReasoningSectionblocks with persisted summaries and typed bullet markers (2025-09-26 follow-up complete). - External settings:
Ctrl+R(collapsed vs expanded), theme, width. - Widget state: collapse flag held outside the serialized state (driven by the global toggle).
✅ Explore Aggregations – ExploreAggregationCell
- Status: ✅ already semantic (summary enums + statuses).
- External settings: theme, width.
✅ Image Outputs – ImageOutputCell
- Status: ✅ image records now store width, height, SHA, MIME type, and byte size in
HistoryState; the UI renders fromImageRecordwithout private RGBA caches (2025-09-30).
✅ Patch Summaries – PatchSummaryCell
- Status: ✅ patch events now emit and hydrate
PatchRecord; the cell renders directly from recorded metadata without internal width caches (2025-09-30).
✅ Exec Commands – ExecCell
-
Status: ✅
ExecRecord/HistoryDomainEventnow drive exec lifecycle end-to-end; renderer pulls lines viadisplay_lines_from_recordthrough the shared cache. -
Current: command metadata, streaming tails, and wait states persist in
HistoryState; UI helpers resolve purely byHistoryIdwith stable call_id mapping. -
Decision: per-width caches deleted; centralized renderer cache handles memoization without regressing redraw performance.
-
Notes: Jump-back/undo flows snapshot exec records and merged summaries rebuild from state; regression tests cover streaming reorder + finish tails.
-
External settings: theme, width, monotonic time for “running” durations.
-
✅ Assistant Streaming –
StreamingContentCell -
Completed the streaming renderer tests (
streaming_updates_replace_record_in_place,streaming_flow_handles_deltas_and_finalize) and documentation: streaming lines now come solely fromHistoryState, renderer cache keys are documented, and no per-cell caches remain. -
Status: [~] streaming records capture IDs, markdown deltas, citations, and metadata in
AssistantStreamState; the UI now routes all begin/delta events throughHistoryDomainEvent::UpsertAssistantStream, and the cell holds only lightweight context (file opener + cwd) instead of cloning the fullConfig. -
Current: renderer consumes
RenderRequestKind::Streaming, and aHistoryRenderStatetest (streaming_updates_replace_record_in_place) verifies that preview lines are sourced fromHistoryStateacross replacements. -
Decision: finish documenting the state-driven flow and mop up any lingering fallback usage before flipping status to ✅.
-
Needed: pipe structured citations/token usage through serialization and audit for any remaining UI-owned caches once final test/docs land.
✅ Assistant Answers – AssistantMarkdownCell
- Completed state-driven rendering for finalized assistant messages:
AssistantMarkdownCellnow stores onlyAssistantMessageStateplus minimal context, renderer tests (assistant_render_from_state,assistant_render_remains_stable_after_insertions) confirmHistoryRenderState::visible_cells()pulls lines fromHistoryState, and plan doc updated accordingly.
✅ Merged Exec Summary – MergedExecCell
- Status: ✅ merged summaries now hydrate from
MergedExecRecordentries inHistoryStaterather than cachedExecCelloutput. - Current: exec finish handlers merge adjacent state records into a single
HistoryRecord::MergedExec; the renderer consumes them viaRenderRequestKind::MergedExecand the shared cache. - Decision: legacy per-width caches were removed; merged cells carry their
HistoryIdand serialize all segments, so session logs and snapshots stay consistent. - Follow-up: consider deduplicating read preamble heuristics inside the renderer once diff caching lands, but no functional gaps remain.
✅ Diffs – DiffCell
- Status: ✅ diff cells now render directly from
DiffRecordvia the shared renderer cache; the per-width RefCell cache was deleted. - Current:
RenderRequestKind::Diffsources lines fromHistoryState, so theme/width updates invalidate throughHistoryRenderStateautomatically. - Decision:
DiffCellstores only semantic state; chatwidget builds diff render requests without touching cell caches, and snapshots/logs serialize the structured diff hunks. - Follow-up: consider richer diff metadata (e.g., file icons) in a future pass once renderer pipelines stabilize.
✅ Explore Fetch / HTTP – ExploreRecord
- Status: ✅ explore aggregations now render from
ExploreRecordstate via the shared renderer cache; the legacy trailing flag and UI-owned line cache were removed. - Current:
RenderRequestKind::Explorefetches lines fromHistoryState; explore lifecycle events updateExploreRecordentries throughHistoryDomainEvent::Replace. - Follow-up: consider richer per-entry metadata (e.g., elapsed time, HTTP previews) once fetch tooling expands.
✅ Legacy Plain Producers
- Status: ✅ plain system/user notices now build
PlainMessageStateand flow throughHistoryState;PlainHistoryCell::new/prompt helpers no longer leak UI caches. - Change: chatwidget/app flows now call
history_insert_plain_state_with_key/history_push_plain_state, queued prompt previews and plan docs/tests updated.
Step 1 – Finish Semantic State Refactors (Complete)
2025-09-30: Removed the transitional SemanticLine adapter; plain and reasoning cells now persist InlineSpan data directly so themes no longer drive semantic parsing.
1.1 Plain messages – ✅ replaced SemanticLine caches with PlainMessageState (plain.rs now stores MessageHeader + MessageLine via shared conversion helpers). Follow-up: enrich headers with structured badges and surface metadata once available.
1.2 Tool calls (running & completed) – ✅ constructors now emit ToolArgument/ToolResultPreview; remaining work: tighten JSON summaries + result truncation heuristics.
1.3 Plan updates – ✅ PlanUpdateCell now renders from PlanProgress/PlanStep + PlanIcon; follow-up: improved summary metadata.
1.4 Upgrade notice – ✅ cell consumes UpgradeNoticeState (versions + message), custom render derived at draw time.
1.5 Reasoning – ✅ sections/blocks stored; block metadata now includes typed bullets and per-section summaries for collapse previews (2025-09-26).
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 Bridge (In Progress)
2.1 Exec state extraction & module split – ✅ exec records now render exclusively via RenderRequestKind::Exec; tests (exec_render_from_state, exec_render_remains_stable_after_insertions) confirm history restores are state-driven with no per-cell caches.
2.2 Streaming assistant module – ✅ streaming records hydrate from HistoryState; see streaming_flow_handles_deltas_and_finalize for start → delta → finish coverage.
2.3 Finalized assistant markdown module – ✅ finalized answers render from AssistantMessageState; tests (assistant_render_from_state, assistant_render_remains_stable_after_insertions) verify state-driven output and stability.
2.4 Diff module breakout – ✅ diff records render from state via RenderRequestKind::Diff; regression coverage tracks reorder stability.
2.5 Merged exec views – ✅ merged exec summaries rebuild from HistoryState and share the renderer cache; no per-cell layout caches remain.
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: ✅ 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. Status: ✅ history snapshots + truncation API landed in HistoryState, ghosts now capture them (2025-09-30).
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-27)
- Apply-patch failures still surface a minimal
PatchRecordwithout stderr context; consider storing structured failure metadata (message, stdout/stderr digest) so the renderer can show richer feedback while remaining state-driven. Status (2025-09-30): ✅ patch failure events now capture sanitized stdout/stderr excerpts inPatchRecord::failure, and the summary cell renders them inline. - Several helpers still rebuild cells inline (e.g., merged exec summaries) instead of emitting domain-specific events; consolidate around
HistoryState::apply_eventso the legacyhistory_cellsvector 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 targetHistoryEventvariant. Track results inchatwidget/events_audit.md(new doc) so progress is visible. - Phase B – API surface: extend
HistoryState::apply_eventto accept domain enums (HistoryDomainEvent) which wrap coreEventMsgpayloads; expose helpers likeapply_exec_eventto keep match arms local to each module. - Phase C – Migration waves:
- Plain/background/loading/wait/tool/plan/upgrade (already idempotent) – replace inline constructors with
HistoryDomainEvent::InsertPlainetc. - Exec/stream/assistant – route lifecycle through domain events so streaming merges/final answers run purely on
HistoryStatedata. - Diff/patch/image/explore/rate-limit – remove bespoke mutation helpers and rely on event emissions from their source modules.
- Plain/background/loading/wait/tool/plan/upgrade (already idempotent) – replace inline constructors with
- Phase D – Flip ownership: once all mutations go through domain events, replace
history_cellswith a derived cache built fromHistoryRecordsnapshots + the renderer cache. Provide a debug flag to assert no code path mutateshistory_cellsdirectly.
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. Status: ✅ patch approval / apply flows now issue HistoryDomainEvent updates (no direct cell mutations), and jump-back/ghost restore rehydrate via snapshots (2025-09-30).
4.2 Stable IDs for streaming – Store HistoryId + domain IDs (exec id, tool call id, stream id) to ensure in-place updates and dedupe. Status: ✅ history state now tracks exec/tool call mappings, handlers resolve deltas via HistoryId, and regression coverage checks reordered exec output (2025-09-30).
4.3 HistoryRenderState – Provide adapter that consumes HistoryRecord + current settings (theme, width, reasoning collapsed) and produces cached RenderedCell structures. Cache keyed by (HistoryId, width, theme_epoch). Status: ✅ history render adapter now renders via RenderSettings keyed by HistoryId, clears caches on theme/width changes, and new tests cover caching + invalidation (2025-09-30).
4.4 Renderer migration – Update TUI drawing code to iterate HistoryRenderState::visible_cells(); drop direct use of HistoryCell trait. Status: ✅ render loop now consumes RenderRequest/visible_cells, caches assistant plans, and performs layout/spacing without direct trait traversal (2025-09-30).
Step 5 – Serialization & Persistence
- Define canonical JSON schema for each record variant (enum tags, tone names instead of colors). Status: ✅ documented in
docs/history_state_schema.md(2025-09-30). - Add round-trip tests for snapshot/resume. Status: ✅ JSON snapshot tests cover serialize/restore (
history/state.rs) (2025-09-30). - Persist history vector in session logs; implement
/undoby restoring prior snapshot. Status: ✅ session logs now emithistory_snapshotentries and/undorestoresHistorySnapshotfor the forked conversation (2025-09-30).
Step 6 – Performance Improvements (Blocked on Step 4)
- 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. Status: ✅ history_render now caches by(HistoryId, width, theme_epoch, reasoning)andChatWidgetconsumes the shared API (2025-09-30). - Add instrumentation to measure render latency and scrolling cost pre/post refactor. Status: ✅ perf stats now track scroll events/rows and render time;
/perf showsurfaces the new metrics (2025-09-30). - Benchmark resume/undo operations using new snapshots. Status: ✅ perf stats now record undo/restore timings surfaced via
/perf show(2025-09-30).
Renderer Cache Design Sketch
- Responsibility: centralize layout caching in
HistoryRenderStateso individual cells own only semantic state. Cache entries map(HistoryId, width, theme_epoch, reason_visibility)→RenderedCell(lines + per-row buffers + meta). - Structure:
RenderedCellwraps immutableVec<Line<'static>>plus precomposedVec<Box<[BufferCell]>>for fast draw.HistoryRenderStatekeeps anIndexMap<CacheKey, Arc<RenderedCell>>with LRU eviction (target: 512 entries, tunable via config).CacheKeyincludestheme_epoch(incremented on palette change) and any feature flags (e.g., reasoning collapsed) so stale entries drop naturally.
- Invalidation hooks:
HistoryState::apply_eventemitsHistoryRenderInvalidatemessages 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 aReasoningVisibilityenum baked into the key.
- Warm path: when rendering, look up
(id, width, theme_epoch, visibility). If present, reuse. If missing, invoke cell-specificrender_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
/perfto confirm the shared cache covers ≥90 % of paint operations before removing per-cell caches.
Completion Notes (2025-09-30)
All primary goals and follow-on cleanups listed in this plan are now complete. The TUI history stack routes every mutation through HistoryState + HistoryDomainEvent, rendering flows via the shared HistoryRenderState cache, and persistence/undo paths serialize structured records. Future work should land as incremental enhancements on top of this baseline (e.g., richer metadata or new cell types) and append new sections below this completion note as needed.