feat(tui/history): drive explore and rate limit cells via events

This commit is contained in:
James Peter
2025-09-27 09:23:29 +10:00
parent e6e72d36aa
commit 540949398c
7 changed files with 739 additions and 430 deletions
+3 -5
View File
@@ -150,16 +150,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:** 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, and image previews now insert exclusively via `history_insert_with_key_global_tagged` + `HistoryState::apply_event`. Next steps: migrate explore aggregations, 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 cells and keep `history_cell_ids` in sync; replacement/removal paths still mirror the legacy vector and need to be ported. Pending: wire explore/rate-limit mutations through dedicated `HistoryEvent` variants, propagate patch success/failure updates via `HistoryEvent::Replace`, and delete the remaining direct `history_cells` manipulations. **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:** 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.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)
- Explore aggregation cells still maintain bespoke `ExploreAggregationState`; convert into `ExploreRecord` instances so inserts/replays serialize cleanly.
- Rate-limit warnings and compact panels render outside the history pipeline; decide whether they should emit `HistoryRecord::RateLimits` snapshots or remain overlay-only.
- Patch apply success/failure currently mutates `PatchSummaryCell` in place; emit `HistoryEvent::Replace` once the replace arm is implemented so records stay authoritative.
- `HistoryState::apply_event` only drives inserts today; removal/replace helpers still operate directly on `history_cells` and must migrate before we can drop the legacy vector.
- 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.
## 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.
+217 -81
View File
@@ -222,6 +222,9 @@ use crate::history::state::{
HistoryRecord,
HistoryState,
MessageMetadata,
RateLimitLegendEntry,
RateLimitsRecord,
TextTone,
};
use crate::slash_command::SlashCommand;
use crate::live_wrap::RowBuilder;
@@ -2185,11 +2188,9 @@ impl ChatWidget<'_> {
/// Handle patch apply end immediately
fn handle_patch_apply_end_now(&mut self, ev: PatchApplyEndEvent) {
if ev.success {
// Update the most recent patch cell header from "Updating..." to "Updated"
// without creating a new history section.
if let Some(last) = self.history_cells.iter_mut().rev().find(|c| {
if let Some(idx) = self.history_cells.iter().rposition(|cell| {
matches!(
c.kind(),
cell.kind(),
crate::history_cell::HistoryCellType::Patch {
kind: crate::history_cell::PatchKind::ApplyBegin
} | crate::history_cell::HistoryCellType::Patch {
@@ -2197,59 +2198,86 @@ impl ChatWidget<'_> {
}
)
}) {
// Case 1: Patch summary cell – update title/kind in-place
if let Some(summary) = last
.as_any_mut()
.downcast_mut::<history_cell::PatchSummaryCell>()
if let Some(summary) = self.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::PatchSummaryCell>()
{
summary.title = "Updated".to_string();
summary.kind = history_cell::PatchKind::ApplySuccess;
summary.record_mut().patch_type = HistoryPatchEventType::ApplySuccess;
self.request_redraw();
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;
}
// Case 2: Plain history cell fallback – adjust first span and kind
if let Some(plain) = last
.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;
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();
}
first_line.spans[0].text = "Updated".to_string();
}
plain.set_kind(history_cell::HistoryCellType::Patch {
kind: history_cell::PatchKind::ApplySuccess,
});
plain.invalidate_layout_cache();
}
}
let record = self.history_record_from_cell(self.history_cells[idx].as_ref());
if let Some(record) = record {
if let HistoryMutation::Replaced { id, .. } = self
.history_state
.apply_event(HistoryEvent::Replace { index: idx, 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 idx < self.history_cell_ids.len() {
self.history_cell_ids[idx] = Some(id);
}
}
plain.set_kind(history_cell::HistoryCellType::Patch {
kind: history_cell::PatchKind::ApplySuccess,
});
plain.invalidate_layout_cache();
self.request_redraw();
return;
}
self.request_redraw();
return;
}
// Fallback: if no prior cell found, do nothing (avoid extra section)
// 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(
@@ -4377,11 +4405,14 @@ 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 record_index = self.record_index_for_position(pos);
match self
.history_state
.apply_event(HistoryEvent::Insert { index: pos, record })
.apply_event(HistoryEvent::Insert {
index: record_index,
record,
})
{
HistoryMutation::Inserted { id, .. } => {
self.assign_history_id(&mut cell, id);
@@ -4526,6 +4557,16 @@ impl ChatWidget<'_> {
.downcast_mut::<crate::history_cell::PatchSummaryCell>()
{
patch.record_mut().id = id;
} else if let Some(explore) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::ExploreAggregationCell>()
{
explore.record_mut().id = id;
} else if let Some(rate_limits) = cell
.as_any_mut()
.downcast_mut::<crate::history_cell::RateLimitsCell>()
{
rate_limits.record_mut().id = id;
}
}
@@ -4620,6 +4661,18 @@ impl ChatWidget<'_> {
{
return Some(HistoryRecord::Patch(patch.record().clone()));
}
if let Some(explore) = cell
.as_any()
.downcast_ref::<crate::history_cell::ExploreAggregationCell>()
{
return Some(HistoryRecord::Explore(explore.record().clone()));
}
if let Some(rate_limits) = cell
.as_any()
.downcast_ref::<crate::history_cell::RateLimitsCell>()
{
return Some(HistoryRecord::RateLimits(rate_limits.record().clone()));
}
None
}
@@ -4714,17 +4767,56 @@ impl ChatWidget<'_> {
let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "prompt", None);
}
fn history_replace_at(&mut self, idx: usize, cell: Box<dyn HistoryCell>) {
if idx < self.history_cells.len() {
self.history_cells[idx] = cell;
if idx < self.history_cell_ids.len() {
self.history_cell_ids[idx] = None;
}
self.invalidate_height_cache();
self.request_redraw();
self.refresh_explore_trailing_flags();
// Keep debug info for this cell index as-is.
fn history_replace_at(&mut self, idx: usize, mut cell: Box<dyn HistoryCell>) {
if idx >= self.history_cells.len() {
return;
}
let record = self.history_record_from_cell(cell.as_ref());
let mut maybe_id = None;
match (record, self.record_index_for_cell(idx)) {
(Some(record), Some(record_idx)) => {
if let HistoryMutation::Replaced { id, .. } = self
.history_state
.apply_event(HistoryEvent::Replace {
index: record_idx,
record,
})
{
self.assign_history_id(&mut cell, id);
maybe_id = Some(id);
}
}
(Some(record), None) => {
let record_idx = self.record_index_for_position(idx);
if let HistoryMutation::Inserted { id, .. } = self
.history_state
.apply_event(HistoryEvent::Insert {
index: record_idx,
record,
})
{
self.assign_history_id(&mut cell, id);
maybe_id = Some(id);
}
}
(None, Some(record_idx)) => {
let _ = self
.history_state
.apply_event(HistoryEvent::Remove { index: record_idx });
}
(None, None) => {}
}
self.history_cells[idx] = cell;
if idx < self.history_cell_ids.len() {
self.history_cell_ids[idx] = maybe_id;
}
self.invalidate_height_cache();
self.request_redraw();
self.refresh_explore_trailing_flags();
// Keep debug info for this cell index as-is.
}
fn resolve_running_tool_index(&self, entry: &RunningToolEntry) -> Option<usize> {
@@ -4742,21 +4834,29 @@ impl ChatWidget<'_> {
}
fn history_remove_at(&mut self, idx: usize) {
if idx < self.history_cells.len() {
self.history_cells.remove(idx);
if idx < self.history_cell_ids.len() {
self.history_cell_ids.remove(idx);
}
if idx < self.cell_order_seq.len() {
self.cell_order_seq.remove(idx);
}
if idx < self.cell_order_dbg.len() {
self.cell_order_dbg.remove(idx);
}
self.invalidate_height_cache();
self.request_redraw();
self.refresh_explore_trailing_flags();
if idx >= self.history_cells.len() {
return;
}
if let Some(record_idx) = self.record_index_for_cell(idx) {
let _ = self
.history_state
.apply_event(HistoryEvent::Remove { index: record_idx });
}
self.history_cells.remove(idx);
if idx < self.history_cell_ids.len() {
self.history_cell_ids.remove(idx);
}
if idx < self.cell_order_seq.len() {
self.cell_order_seq.remove(idx);
}
if idx < self.cell_order_dbg.len() {
self.cell_order_dbg.remove(idx);
}
self.invalidate_height_cache();
self.request_redraw();
self.refresh_explore_trailing_flags();
}
fn history_replace_and_maybe_merge(&mut self, idx: usize, cell: Box<dyn HistoryCell>) {
@@ -4830,6 +4930,20 @@ impl ChatWidget<'_> {
self.history_remove_at(idx);
}
fn record_index_for_position(&self, ui_index: usize) -> usize {
self.history_cell_ids
.iter()
.take(ui_index)
.filter(|entry| entry.is_some())
.count()
}
fn record_index_for_cell(&self, idx: usize) -> Option<usize> {
self.history_cell_ids
.get(idx)
.and_then(|entry| entry.map(|_| self.record_index_for_position(idx)))
}
/// Clean up faded-out animation cells
fn process_animation_cleanup(&mut self) {
// With trait-based cells, we can't easily detect and clean up specific cell types
@@ -6503,16 +6617,38 @@ impl ChatWidget<'_> {
let warnings = self
.rate_limit_warnings
.take_warnings(snapshot.secondary_used_percent, snapshot.primary_used_percent);
let mut displayed_warning = false;
let mut legend_entries: Vec<RateLimitLegendEntry> = Vec::new();
for warning in warnings {
if self.log_and_should_display_warning(&warning) {
self.history_push(history_cell::new_warning_event(
warning.message.clone(),
));
displayed_warning = true;
let label = match warning.scope {
RateLimitWarningScope::Primary => {
format!("Hourly usage ≥ {:.0}%", warning.threshold)
}
RateLimitWarningScope::Secondary => {
format!("Weekly usage ≥ {:.0}%", warning.threshold)
}
};
legend_entries.push(RateLimitLegendEntry {
label,
description: warning.message.clone(),
tone: TextTone::Warning,
});
}
}
if displayed_warning {
if !legend_entries.is_empty() {
let record = RateLimitsRecord {
id: HistoryId::ZERO,
snapshot: snapshot.clone(),
legend: legend_entries,
};
let cell = history_cell::RateLimitsCell::from_record(record.clone());
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global_tagged(
Box::new(cell),
key,
"rate-limits",
Some(HistoryRecord::RateLimits(record)),
);
self.request_redraw();
}
+82 -64
View File
@@ -88,14 +88,20 @@ pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
}
if let Some((agg_idx, entry_idx)) = explore_entry {
if *agg_idx < chat.history_cells.len() {
if let Some(agg) = chat.history_cells[*agg_idx]
.as_any_mut()
.downcast_mut::<history_cell::ExploreAggregationCell>()
if let Some(existing) = chat.history_cells[*agg_idx]
.as_any()
.downcast_ref::<history_cell::ExploreAggregationCell>()
{
agg.update_status(
let mut record = existing.record().clone();
record.update_status(
*entry_idx,
history_cell::ExploreEntryStatus::Error { exit_code: None },
);
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
cell.set_trailing(existing.is_trailing());
chat.history_replace_at(*agg_idx, Box::new(cell));
chat.autoscroll_if_near_bottom();
chat.request_redraw();
}
}
}
@@ -232,11 +238,17 @@ pub(super) fn finalize_all_running_due_to_answer(chat: &mut ChatWidget<'_>) {
if let Some((agg_idx, entry_idx)) = explore_entry {
if *agg_idx < chat.history_cells.len() {
if let Some(agg) = chat.history_cells[*agg_idx]
.as_any_mut()
.downcast_mut::<history_cell::ExploreAggregationCell>()
if let Some(existing) = chat.history_cells[*agg_idx]
.as_any()
.downcast_ref::<history_cell::ExploreAggregationCell>()
{
agg.update_status(*entry_idx, history_cell::ExploreEntryStatus::Success);
let mut record = existing.record().clone();
record.update_status(*entry_idx, history_cell::ExploreEntryStatus::Success);
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
cell.set_trailing(existing.is_trailing());
chat.history_replace_at(*agg_idx, Box::new(cell));
chat.autoscroll_if_near_bottom();
chat.request_redraw();
agg_was_updated = true;
}
}
@@ -492,47 +504,52 @@ pub(super) fn handle_exec_begin_now(
}
if let Some(idx) = agg_idx {
let entry_idx = chat.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::ExploreAggregationCell>()
.and_then(|agg| {
agg.push_from_parsed(
&parsed_command,
history_cell::ExploreEntryStatus::Running,
&ev.cwd,
&chat.config.cwd,
&ev.command,
)
});
if let Some(entry_idx) = entry_idx {
chat.exec.running_explore_agg_index = Some(idx);
chat.exec.running_commands.insert(
super::ExecCallId(ev.call_id.clone()),
super::RunningCommand {
command: ev.command.clone(),
parsed: parsed_command.clone(),
history_index: None,
explore_entry: Some((idx, entry_idx)),
stdout: String::new(),
stderr: String::new(),
wait_total: None,
wait_active: false,
wait_notes: Vec::new(),
},
);
chat.invalidate_height_cache();
if let Some((mut record, trailing)) = chat.history_cells.get(idx).and_then(|cell| {
cell.as_any()
.downcast_ref::<history_cell::ExploreAggregationCell>()
.map(|existing| (existing.record().clone(), existing.is_trailing()))
}) {
if let Some(entry_idx) = record.push_from_parsed(
&parsed_command,
history_cell::ExploreEntryStatus::Running,
&ev.cwd,
&chat.config.cwd,
&ev.command,
) {
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
cell.set_trailing(trailing);
chat.history_replace_at(idx, Box::new(cell));
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()),
super::RunningCommand {
command: ev.command.clone(),
parsed: parsed_command.clone(),
history_index: None,
explore_entry: Some((idx, entry_idx)),
stdout: String::new(),
stderr: String::new(),
wait_total: None,
wait_active: false,
wait_notes: Vec::new(),
},
);
chat.bottom_pane.set_has_chat_history(true);
let status_text = match action {
ExecAction::Read => "reading files…",
_ => "exploring…",
};
chat.bottom_pane.update_status_text(status_text.to_string());
return;
}
}
if created_new {
chat.history_remove_at(idx);
chat.autoscroll_if_near_bottom();
chat.request_redraw();
chat.bottom_pane.set_has_chat_history(true);
let status_text = match action {
ExecAction::Read => "reading files…",
_ => "exploring…",
};
chat.bottom_pane.update_status_text(status_text.to_string());
return;
} else if created_new {
chat.history_remove_at(idx);
chat.invalidate_height_cache();
}
}
}
@@ -626,22 +643,25 @@ pub(super) fn handle_exec_end_now(
if let Some((agg_idx, entry_idx)) = explore_entry {
let action = history_cell::action_enum_from_parsed(&parsed);
let status = match (exit_code, action) {
(0, _) => history_cell::ExploreEntryStatus::Success,
(1, ExecAction::Search) => history_cell::ExploreEntryStatus::NotFound,
(1, ExecAction::List) => history_cell::ExploreEntryStatus::NotFound,
// Anything else is an error; preserve exit code
_ => history_cell::ExploreEntryStatus::Error {
exit_code: Some(exit_code),
},
let status = match (exit_code, action) {
(0, _) => history_cell::ExploreEntryStatus::Success,
(1, ExecAction::Search) => history_cell::ExploreEntryStatus::NotFound,
(1, ExecAction::List) => history_cell::ExploreEntryStatus::NotFound,
_ => history_cell::ExploreEntryStatus::Error {
exit_code: Some(exit_code),
},
};
if agg_idx < chat.history_cells.len() {
if let Some(agg) = chat.history_cells[agg_idx]
.as_any_mut()
.downcast_mut::<history_cell::ExploreAggregationCell>()
{
agg.update_status(entry_idx, status.clone());
}
if let Some((mut record, trailing)) = chat.history_cells.get(agg_idx).and_then(|cell| {
cell.as_any()
.downcast_ref::<history_cell::ExploreAggregationCell>()
.map(|existing| (existing.record().clone(), existing.is_trailing()))
}) {
record.update_status(entry_idx, status.clone());
let mut cell = history_cell::ExploreAggregationCell::from_record(record);
cell.set_trailing(trailing);
chat.history_replace_at(agg_idx, Box::new(cell));
chat.autoscroll_if_near_bottom();
chat.request_redraw();
}
if !chat
.exec
@@ -651,8 +671,6 @@ pub(super) fn handle_exec_end_now(
{
chat.exec.running_explore_agg_index = None;
}
chat.invalidate_height_cache();
chat.request_redraw();
let status_text = match status {
history_cell::ExploreEntryStatus::Success => match action {
ExecAction::Read => "files read".to_string(),
+27 -4
View File
@@ -393,22 +393,45 @@ pub struct ImageRecord {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExploreRecord {
pub id: HistoryId,
pub title: String,
pub entries: Vec<ExploreEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExploreEntry {
pub label: String,
pub action: ExecAction,
pub summary: ExploreSummary,
pub status: ExploreEntryStatus,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExploreSummary {
Search {
query: Option<String>,
path: Option<String>,
},
List {
path: Option<String>,
},
Read {
display_path: String,
annotation: Option<String>,
range: Option<(u32, u32)>,
},
Command {
display: String,
annotation: Option<String>,
},
Fallback {
text: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExploreEntryStatus {
Pending,
Running,
Success,
Failed,
NotFound,
Error { exit_code: Option<i32> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+259 -274
View File
@@ -1,177 +1,182 @@
use super::*;
use crate::history::state::ExecAction;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ExploreEntryStatus {
Running,
Success,
NotFound,
Error { exit_code: Option<i32> },
}
#[derive(Clone, Debug, PartialEq)]
struct CommandSummary {
display: String,
annotation: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
enum ExploreSummary {
Search {
query: Option<String>,
path: Option<String>,
},
List {
path: Option<String>,
},
Read {
display_path: String,
annotation: Option<String>,
range: Option<(u32, u32)>,
},
Command {
command: CommandSummary,
},
Fallback {
text: String,
},
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ExploreEntry {
action: ExecAction,
summary: ExploreSummary,
status: ExploreEntryStatus,
}
impl ExploreEntry {
fn label(&self) -> &'static str {
if matches!(self.summary, ExploreSummary::Command { .. }) {
return "Ran";
}
match self.action {
ExecAction::Read => "Read",
ExecAction::Search => "Search",
ExecAction::List => "List",
ExecAction::Run => "Run",
}
}
fn summary_spans(&self) -> Vec<Span<'static>> {
match &self.summary {
ExploreSummary::Search { query, path } => {
let mut spans = Vec::new();
if let Some(q) = query {
if !q.is_empty() {
spans.push(Span::styled(
q.clone(),
Style::default().fg(crate::colors::text()),
));
}
}
if let Some(p) = path {
spans.push(Span::styled(
format!(" in {}", p),
Style::default().fg(crate::colors::text_dim()),
));
}
if spans.is_empty() {
spans.push(Span::styled(
"search".to_string(),
Style::default().fg(crate::colors::text()),
));
}
spans
}
ExploreSummary::List { path } => {
let target = path.as_ref().cloned().unwrap_or_else(|| "./".to_string());
vec![Span::styled(
target,
Style::default().fg(crate::colors::text_dim()),
)]
}
ExploreSummary::Read {
display_path,
annotation,
..
} => {
let mut spans = vec![Span::styled(
display_path.clone(),
Style::default().fg(crate::colors::text()),
)];
if let Some(ann) = annotation {
spans.push(Span::styled(
format!(" {}", ann),
Style::default().fg(crate::colors::text_dim()),
));
}
spans
}
ExploreSummary::Command { command } => {
let mut spans = highlight_command_summary(&command.display);
if let Some(annotation) = &command.annotation {
spans.push(Span::styled(
format!(" {}", annotation),
Style::default().fg(crate::colors::text_dim()),
));
}
spans
}
ExploreSummary::Fallback { text } => vec![Span::styled(
text.clone(),
Style::default().fg(crate::colors::text()),
)],
}
}
fn is_running(&self) -> bool {
matches!(self.status, ExploreEntryStatus::Running)
}
fn is_error(&self) -> bool {
matches!(self.status, ExploreEntryStatus::Error { .. })
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ExploreAggregationState {
pub entries: Vec<ExploreEntry>,
pub is_trailing: bool,
}
use crate::history::state::{
ExecAction,
ExploreEntry,
ExploreEntryStatus,
ExploreRecord,
ExploreSummary,
HistoryId,
};
use codex_core::parse_command::ParsedCommand;
use shlex::Shlex;
use std::path::{Component, Path};
pub(crate) struct ExploreAggregationCell {
state: ExploreAggregationState,
record: ExploreRecord,
is_trailing: bool,
}
impl ExploreAggregationCell {
pub(crate) fn new() -> Self {
Self {
state: ExploreAggregationState {
record: ExploreRecord {
id: HistoryId::ZERO,
entries: Vec::new(),
is_trailing: true,
},
is_trailing: true,
}
}
#[allow(dead_code)]
pub(crate) fn from_state(state: ExploreAggregationState) -> Self {
Self { state }
pub(crate) fn from_record(record: ExploreRecord) -> Self {
Self {
record,
is_trailing: true,
}
}
#[allow(dead_code)]
pub(crate) fn state(&self) -> ExploreAggregationState {
self.state.clone()
pub(crate) fn record(&self) -> &ExploreRecord {
&self.record
}
#[allow(dead_code)]
pub(crate) fn set_state(&mut self, state: ExploreAggregationState) {
self.state = state;
pub(crate) fn record_mut(&mut self) -> &mut ExploreRecord {
&mut self.record
}
pub(crate) fn set_trailing(&mut self, trailing: bool) {
self.state.is_trailing = trailing;
self.is_trailing = trailing;
}
pub(crate) fn is_trailing(&self) -> bool {
self.is_trailing
}
fn current_exec_status(&self) -> ExecStatus {
if self
.record
.entries
.iter()
.any(|entry| matches!(entry.status, ExploreEntryStatus::Running))
{
ExecStatus::Running
} else if self
.record
.entries
.iter()
.any(|entry| matches!(entry.status, ExploreEntryStatus::Error { .. }))
{
ExecStatus::Error
} else {
ExecStatus::Success
}
}
}
impl HistoryCell for ExploreAggregationCell {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn kind(&self) -> HistoryCellType {
HistoryCellType::Exec {
kind: ExecKind::Search,
status: self.current_exec_status(),
}
}
fn display_lines(&self) -> Vec<Line<'static>> {
let header = if self.is_trailing {
"Exploring..."
} else {
"Explored"
};
if self.record.entries.is_empty() {
return vec![Line::styled(
header,
Style::default().fg(crate::colors::text()),
)];
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::styled(
header,
Style::default().fg(crate::colors::text()),
));
let max_label_len = self
.record
.entries
.iter()
.map(entry_label_width)
.max()
.unwrap_or(0);
for (idx, entry) in self.record.entries.iter().enumerate() {
let prefix = if idx == 0 { "└ " } else { " " };
let mut spans: Vec<Span<'static>> = vec![Span::styled(
prefix,
Style::default().add_modifier(Modifier::DIM),
)];
let label = entry_label(entry);
let padding = max_label_len.saturating_sub(label.chars().count()) + 1;
let mut padded_label = String::with_capacity(label.len() + padding);
padded_label.push_str(label);
padded_label.extend(std::iter::repeat(' ').take(padding));
spans.push(Span::styled(
padded_label,
Style::default().fg(crate::colors::text_dim()),
));
spans.extend(entry_summary_spans(entry));
match entry.status {
ExploreEntryStatus::Running => spans.push(Span::styled(
"…",
Style::default().fg(crate::colors::text_dim()),
)),
ExploreEntryStatus::NotFound => spans.push(Span::styled(
" (not found)",
Style::default().fg(crate::colors::text_dim()),
)),
ExploreEntryStatus::Error { exit_code } => {
let msg = match (entry.action, exit_code) {
(ExecAction::Search, Some(2)) => " (invalid pattern)".to_string(),
(ExecAction::Search, _) => " (search error)".to_string(),
(ExecAction::List, _) => " (list error)".to_string(),
(ExecAction::Read, _) => " (read error)".to_string(),
_ => exit_code
.map(|code| format!(" (exit {})", code))
.unwrap_or_else(|| " (failed)".to_string()),
};
spans.push(Span::styled(
msg,
Style::default().fg(crate::colors::error()),
));
}
ExploreEntryStatus::Success => {}
}
lines.push(Line::from(spans));
}
lines
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines_trimmed()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
fn gutter_symbol(&self) -> Option<&'static str> {
None
}
}
impl ExploreRecord {
pub(crate) fn push_from_parsed(
&mut self,
parsed: &[ParsedCommand],
@@ -222,9 +227,13 @@ impl ExploreAggregationCell {
_ => None,
}),
ExecAction::Run => parsed.iter().find_map(|p| match p {
ParsedCommand::ReadCommand { cmd } => Some(ExploreSummary::Command {
command: build_command_summary(cmd, original_command),
}),
ParsedCommand::ReadCommand { cmd } => {
let summary = build_command_summary(cmd, original_command);
Some(ExploreSummary::Command {
display: summary.display,
annotation: summary.annotation,
})
}
_ => None,
}),
};
@@ -239,9 +248,7 @@ impl ExploreAggregationCell {
.find(|s| !s.is_empty())
.unwrap_or_else(|| "exec".to_string());
Some(ExploreSummary::Fallback { text })
});
let summary = summary?;
})?;
if let ExploreSummary::Read {
display_path,
@@ -252,12 +259,12 @@ impl ExploreAggregationCell {
let path_key = display_path.clone();
let annot = annotation.clone();
let range_val = *range;
for idx in (0..self.state.entries.len()).rev() {
for idx in (0..self.entries.len()).rev() {
if let ExploreSummary::Read {
display_path: existing_path,
annotation: existing_ann,
range: existing_range,
} = &mut self.state.entries[idx].summary
} = &mut self.entries[idx].summary
{
if *existing_path == path_key {
let reuse = match (*existing_range, range_val) {
@@ -302,7 +309,7 @@ impl ExploreAggregationCell {
};
if reuse {
self.state.entries[idx].status = status;
self.entries[idx].status = status;
return Some(idx);
}
}
@@ -310,149 +317,127 @@ impl ExploreAggregationCell {
}
}
if let ExploreSummary::Command { command: new_cmd } = &summary {
for idx in (0..self.state.entries.len()).rev() {
if let ExploreSummary::Command { command: existing } =
&mut self.state.entries[idx].summary
if let ExploreSummary::Command {
display,
annotation,
} = &summary
{
for idx in (0..self.entries.len()).rev() {
if let ExploreSummary::Command {
display: existing_display,
annotation: existing_annotation,
} = &self.entries[idx].summary
{
if existing.display == new_cmd.display
&& existing.annotation == new_cmd.annotation
{
self.state.entries[idx].status = status;
if existing_display == display && existing_annotation == annotation {
self.entries[idx].status = status;
return Some(idx);
}
}
}
}
self.state.entries.push(ExploreEntry {
self.entries.push(ExploreEntry {
action,
summary,
status,
});
Some(self.state.entries.len().saturating_sub(1))
Some(self.entries.len().saturating_sub(1))
}
pub(crate) fn update_status(&mut self, idx: usize, status: ExploreEntryStatus) {
if let Some(entry) = self.state.entries.get_mut(idx) {
if let Some(entry) = self.entries.get_mut(idx) {
entry.status = status;
}
}
fn current_exec_status(&self) -> ExecStatus {
if self.state.entries.iter().any(|e| e.is_running()) {
ExecStatus::Running
} else if self.state.entries.iter().any(|e| e.is_error()) {
ExecStatus::Error
} else {
ExecStatus::Success
}
}
}
impl HistoryCell for ExploreAggregationCell {
fn as_any(&self) -> &dyn std::any::Any {
self
fn entry_label(entry: &ExploreEntry) -> &'static str {
if matches!(entry.summary, ExploreSummary::Command { .. }) {
return "Ran";
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
match entry.action {
ExecAction::Read => "Read",
ExecAction::Search => "Search",
ExecAction::List => "List",
ExecAction::Run => "Run",
}
}
fn kind(&self) -> HistoryCellType {
HistoryCellType::Exec {
kind: ExecKind::Search,
status: self.current_exec_status(),
}
}
fn entry_label_width(entry: &ExploreEntry) -> usize {
entry_label(entry).chars().count()
}
fn display_lines(&self) -> Vec<Line<'static>> {
let header = if self.state.is_trailing {
"Exploring..."
} else {
"Explored"
};
if self.state.entries.is_empty() {
return vec![Line::styled(
header,
Style::default().fg(crate::colors::text()),
)];
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::styled(
header,
Style::default().fg(crate::colors::text()),
));
let max_label_len = self
.state
.entries
.iter()
.map(|e| e.label().chars().count())
.max()
.unwrap_or(0);
for (idx, entry) in self.state.entries.iter().enumerate() {
let prefix = if idx == 0 { "└ " } else { " " };
let mut spans: Vec<Span<'static>> = vec![Span::styled(
prefix,
Style::default().add_modifier(Modifier::DIM),
)];
let label = entry.label();
let label_len = label.chars().count();
let padding = max_label_len.saturating_sub(label_len) + 1;
let mut padded_label = String::with_capacity(label.len() + padding);
padded_label.push_str(label);
padded_label.extend(std::iter::repeat(' ').take(padding));
spans.push(Span::styled(
padded_label,
Style::default().fg(crate::colors::text_dim()),
));
spans.extend(entry.summary_spans());
match entry.status {
ExploreEntryStatus::Running => spans.push(Span::styled(
"…",
Style::default().fg(crate::colors::text_dim()),
)),
ExploreEntryStatus::NotFound => spans.push(Span::styled(
" (not found)",
Style::default().fg(crate::colors::text_dim()),
)),
ExploreEntryStatus::Error { exit_code } => {
let msg = match (entry.action, exit_code) {
(ExecAction::Search, Some(2)) => " (invalid pattern)".to_string(),
(ExecAction::Search, _) => " (search error)".to_string(),
(ExecAction::List, _) => " (list error)".to_string(),
(ExecAction::Read, _) => " (read error)".to_string(),
_ => exit_code
.map(|code| format!(" (exit {})", code))
.unwrap_or_else(|| " (failed)".to_string()),
};
fn entry_summary_spans(entry: &ExploreEntry) -> Vec<Span<'static>> {
match &entry.summary {
ExploreSummary::Search { query, path } => {
let mut spans = Vec::new();
if let Some(q) = query {
if !q.is_empty() {
spans.push(Span::styled(
msg,
Style::default().fg(crate::colors::error()),
q.clone(),
Style::default().fg(crate::colors::text()),
));
}
ExploreEntryStatus::Success => {}
}
lines.push(Line::from(spans));
if let Some(p) = path {
spans.push(Span::styled(
format!(" in {}", p),
Style::default().fg(crate::colors::text_dim()),
));
}
if spans.is_empty() {
spans.push(Span::styled(
"search".to_string(),
Style::default().fg(crate::colors::text()),
));
}
spans
}
lines
ExploreSummary::List { path } => {
let target = path.clone().unwrap_or_else(|| "./".to_string());
vec![Span::styled(
target,
Style::default().fg(crate::colors::text_dim()),
)]
}
ExploreSummary::Read {
display_path,
annotation,
..
} => {
let mut spans = vec![Span::styled(
display_path.clone(),
Style::default().fg(crate::colors::text()),
)];
if let Some(ann) = annotation {
spans.push(Span::styled(
format!(" {}", ann),
Style::default().fg(crate::colors::text_dim()),
));
}
spans
}
ExploreSummary::Command { display, annotation } => {
let mut spans = highlight_command_summary(display);
if let Some(annotation) = annotation {
spans.push(Span::styled(
format!(" {}", annotation),
Style::default().fg(crate::colors::text_dim()),
));
}
spans
}
ExploreSummary::Fallback { text } => vec![Span::styled(
text.clone(),
Style::default().fg(crate::colors::text()),
)],
}
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines_trimmed()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
fn gutter_symbol(&self) -> Option<&'static str> {
None
}
#[derive(Clone, Debug, PartialEq)]
struct CommandSummary {
display: String,
annotation: Option<String>,
}
fn build_command_summary(cmd: &str, original_command: &[String]) -> CommandSummary {
+4 -2
View File
@@ -58,7 +58,6 @@ use shlex::Shlex;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
@@ -79,6 +78,7 @@ mod reasoning;
mod tool;
mod wait_status;
mod plan_update;
mod rate_limits;
mod plain;
mod upgrade;
mod semantic;
@@ -95,7 +95,9 @@ pub(crate) use exec::{
ParsedExecMetadata,
};
pub(crate) use diff::{diff_record_from_string, new_diff_cell_from_string, DiffCell};
pub(crate) use explore::{ExploreAggregationCell, ExploreEntryStatus};
pub(crate) use explore::ExploreAggregationCell;
pub(crate) use rate_limits::RateLimitsCell;
pub(crate) use crate::history::state::ExploreEntryStatus;
pub(crate) use image::ImageOutputCell;
pub(crate) use loading::LoadingCell;
pub(crate) use reasoning::CollapsibleReasoningCell;
@@ -0,0 +1,147 @@
use super::*;
use crate::history::state::{RateLimitLegendEntry, RateLimitsRecord, TextTone};
use codex_common::elapsed::format_duration;
use codex_core::protocol::RateLimitSnapshotEvent;
use ratatui::style::Color;
pub(crate) struct RateLimitsCell {
record: RateLimitsRecord,
}
impl RateLimitsCell {
pub(crate) fn from_record(record: RateLimitsRecord) -> Self {
Self { record }
}
pub(crate) fn record(&self) -> &RateLimitsRecord {
&self.record
}
pub(crate) fn record_mut(&mut self) -> &mut RateLimitsRecord {
&mut self.record
}
}
impl HistoryCell for RateLimitsCell {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn kind(&self) -> HistoryCellType {
HistoryCellType::Notice
}
fn display_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(
Line::styled(
"Rate limits update",
Style::default()
.fg(crate::colors::warning())
.add_modifier(Modifier::BOLD),
),
);
let snapshot = &self.record.snapshot;
lines.extend(snapshot_summary_lines(snapshot));
if !self.record.legend.is_empty() {
lines.push(Line::default());
lines.push(Line::styled(
"Warnings",
Style::default()
.fg(crate::colors::warning())
.add_modifier(Modifier::BOLD),
));
for entry in &self.record.legend {
lines.extend(legend_lines(entry));
}
}
lines
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines_trimmed()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
}
fn snapshot_summary_lines(snapshot: &RateLimitSnapshotEvent) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let hourly_line = format!(
"└ Hourly usage: {:.0}% of {} min window",
snapshot.primary_used_percent,
snapshot.primary_window_minutes,
);
lines.push(Line::from(vec![Span::styled(
hourly_line,
Style::default().fg(crate::colors::text()),
)]));
if let Some(seconds) = snapshot.primary_reset_after_seconds {
let reset = format_duration(std::time::Duration::from_secs(seconds));
lines.push(Line::from(vec![Span::styled(
format!(" • resets in {reset}"),
Style::default().fg(crate::colors::text_dim()),
)]));
}
let weekly_line = format!(
"└ Weekly usage: {:.0}% of {} min window",
snapshot.secondary_used_percent,
snapshot.secondary_window_minutes,
);
lines.push(Line::from(vec![Span::styled(
weekly_line,
Style::default().fg(crate::colors::text()),
)]));
if let Some(seconds) = snapshot.secondary_reset_after_seconds {
let reset = format_duration(std::time::Duration::from_secs(seconds));
lines.push(Line::from(vec![Span::styled(
format!(" • resets in {reset}"),
Style::default().fg(crate::colors::text_dim()),
)]));
}
lines
}
fn legend_lines(entry: &RateLimitLegendEntry) -> Vec<Line<'static>> {
let tone_style = Style::default().fg(color_for_tone(entry.tone));
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(" • ", tone_style),
Span::styled(entry.label.clone(), tone_style.add_modifier(Modifier::BOLD)),
]));
if !entry.description.is_empty() {
lines.push(Line::from(vec![Span::styled(
format!(" {}", entry.description),
Style::default().fg(crate::colors::text()),
)]));
}
lines
}
fn color_for_tone(tone: TextTone) -> Color {
match tone {
TextTone::Default => crate::colors::text(),
TextTone::Dim => crate::colors::text_dim(),
TextTone::Primary => crate::colors::primary(),
TextTone::Success => crate::colors::success(),
TextTone::Warning => crate::colors::warning(),
TextTone::Error => crate::colors::error(),
TextTone::Info => crate::colors::info(),
}
}