Merge branch 'code-branch-20250926-063412' into main

This commit is contained in:
James Peter
2025-09-26 22:39:14 +10:00
11 changed files with 2296 additions and 1622 deletions
+8 -8
View File
@@ -80,7 +80,7 @@ Status legend: ✅ complete (semantic deterministic state ready), ⏳ still need
### ✅ Reasoning – `CollapsibleReasoningCell`
- **Desired state:** `ReasoningState { id, sections: Vec<ReasoningSection>, in_progress, hide_when_collapsed }` with sections broken into semantic blocks.
- **Status:** ✅ state maintains `ReasoningSection` blocks; follow-up: richer block typing + metadata.
- **Status:** ✅ state maintains `ReasoningSection` blocks 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).
@@ -137,16 +137,16 @@ Status legend: ✅ complete (semantic deterministic state ready), ⏳ still need
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; next: enrich block types (code/bullets) and attach metadata for collapse summaries.
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** – Update module docs/tests to reflect new structs; ensure all constructors return strongly typed states.
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** – `ExecRecord` already lives in `history/state.rs`; migrate the inline `ExecCell` into `history_cell/exec.rs`, adapt it to consume the record, and relocate per-width layout caches out of the state layer.
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:** renderer now lives in `history_cell/stream.rs`; `HistoryState` records per-stream deltas/preview snapshots; **follow-up:** flow citations/token usage from streaming events, persist provenance metadata, and remove redundant layout caches.
2.3 **Finalized assistant markdown module** – Use `AssistantMessageState` storing markdown + citations + token metadata. **Status:** rendering has been split into `history_cell/assistant.rs`; finalized answers now land in `HistoryState`; **follow-up:** carry citation/token metadata through `InsertFinalAnswer`, expose state to resume snapshots, and drop raw `AssistantMarkdownCell` caching once render adapters exist.
2.4 **Diff module breakout** – Persist diff hunks (`DiffHunk`, `DiffLine`) and patch metadata, move diff rendering into `history_cell/diff.rs`, and have the renderer apply styling based on line kind.
2.5 **Merged exec views** – Rebuild aggregated exec cells from `Vec<ExecRecord>` snapshots rather than cached text blocks once the exec module exists.
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 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.
+249 -163
View File
@@ -211,7 +211,15 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryCellType;
use crate::history_cell::PatchEventType;
use crate::history_cell::PlainHistoryCell;
use crate::history::state::{AssistantStreamDelta, HistoryState};
use crate::history::state::{
AssistantMessageState,
AssistantStreamDelta,
AssistantStreamState,
HistoryId,
HistoryRecord,
HistoryState,
MessageMetadata,
};
use crate::slash_command::SlashCommand;
use crate::live_wrap::RowBuilder;
use crate::streaming::StreamKind;
@@ -1556,7 +1564,7 @@ impl ChatWidget<'_> {
}
fn perf_label_for_item(&self, item: &dyn HistoryCell) -> String {
use crate::history_cell::ExecKind;
use crate::history_cell::ExecStatus;
use crate::history::state::ExecStatus;
use crate::history_cell::HistoryCellType;
use crate::history_cell::PatchKind;
use crate::history_cell::ToolStatus;
@@ -4400,6 +4408,19 @@ impl ChatWidget<'_> {
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "epilogue");
}
fn history_push_diff(&mut self, title: Option<String>, diff_output: String) {
let mut record = history_cell::diff_record_from_string(
title.unwrap_or_default(),
&diff_output,
);
let id = self
.history_state
.push(HistoryRecord::Diff(record.clone()));
record.id = id;
let cell = history_cell::DiffCell::from_record(record);
self.history_push(cell);
}
/// Insert a background event near the top of the current request so it appears
/// before imminent provider output (e.g. Exec begin).
pub(crate) fn insert_background_event_early(&mut self, message: String) {
@@ -6264,6 +6285,7 @@ impl ChatWidget<'_> {
self.last_token_usage.clone(),
self.config.model_context_window,
);
self.update_stream_token_usage_metadata();
}
EventMsg::Error(ErrorEvent { message }) => {
self.on_error(message);
@@ -7393,16 +7415,25 @@ impl ChatWidget<'_> {
reasoning_cell.set_in_progress(false);
self.history_push(reasoning_cell);
let streaming_preview = history_cell::new_streaming_content(
stream_lines
.iter()
.map(|line| Line::from((*line).to_string()))
.collect(),
);
let preview_lines: Vec<ratatui::text::Line<'static>> = stream_lines
.iter()
.map(|line| Line::from((*line).to_string()))
.collect();
let state = self.synthesize_stream_state_from_lines(None, &preview_lines, false);
let streaming_preview = history_cell::new_streaming_content(state, &self.config);
self.history_push(streaming_preview);
let assistant_state = AssistantMessageState {
id: HistoryId::ZERO,
stream_id: None,
markdown: (*assistant_body).to_string(),
citations: Vec::new(),
metadata: None,
token_usage: None,
created_at: SystemTime::now(),
};
let assistant_cell =
history_cell::AssistantMarkdownCell::new((*assistant_body).to_string(), &self.config);
history_cell::AssistantMarkdownCell::from_state(assistant_state, &self.config);
self.history_push(assistant_cell);
for (command_tokens, stdout) in execs {
@@ -7427,7 +7458,7 @@ impl ChatWidget<'_> {
}
if let Some(diff) = diff_snippet {
self.history_push(history_cell::new_diff_output(diff.to_string()));
self.history_push_diff(None, diff.to_string());
}
if let Some(patch) = patch_change {
@@ -7493,10 +7524,13 @@ impl ChatWidget<'_> {
self.history_push(history_cell::new_prompts_output());
}
let final_stream = history_cell::new_streaming_content(vec![
let final_preview_lines = vec![
Line::from("streaming preview: final tokens rendered."),
Line::from("streaming preview: viewport ready for scroll testing."),
]);
];
let final_state =
self.synthesize_stream_state_from_lines(None, &final_preview_lines, false);
let final_stream = history_cell::new_streaming_content(final_state, &self.config);
self.history_push(final_stream);
self.push_background_tail("demo: finished populating sample history.");
@@ -7516,7 +7550,7 @@ impl ChatWidget<'_> {
}
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
self.history_push(history_cell::new_diff_output(diff_output.clone()));
self.history_push_diff(None, diff_output);
}
pub(crate) fn add_status_output(&mut self) {
@@ -10439,7 +10473,7 @@ impl ChatWidget<'_> {
.as_any_mut()
.downcast_mut::<history_cell::StreamingContentCell>()
{
stream.retint(&old, &new);
stream.rebuild_with_config(&self.config);
} else if let Some(wait) = cell
.as_any_mut()
.downcast_mut::<history_cell::WaitStatusCell>()
@@ -10450,7 +10484,18 @@ impl ChatWidget<'_> {
.downcast_mut::<history_cell::AssistantMarkdownCell>()
{
// Fully rebuild from raw to apply new theme + syntax highlight
assist.rebuild(&self.config);
let current = assist.state().clone();
assist.update_state(current, &self.config);
} else if let Some(merged) = cell
.as_any_mut()
.downcast_mut::<history_cell::MergedExecCell>()
{
merged.rebuild_with_theme();
} else if let Some(diff) = cell
.as_any_mut()
.downcast_mut::<history_cell::DiffCell>()
{
diff.rebuild_with_theme();
}
}
@@ -10723,7 +10768,7 @@ impl ChatWidget<'_> {
&mut self,
kind: StreamKind,
id: Option<String>,
mut lines: Vec<ratatui::text::Line<'static>>,
lines: Vec<ratatui::text::Line<'static>>,
) {
// No debug logging: we rely on preserving span modifiers end-to-end.
// Insert all lines as a single streaming content cell to preserve spacing
@@ -10844,76 +10889,35 @@ impl ChatWidget<'_> {
id,
lines.len()
);
// Any incoming Answer means reasoning is no longer bottom-most
self.clear_reasoning_in_progress();
if let Some(ref want) = id {
self.ensure_answer_stream_state(want);
}
// Keep a single StreamingContentCell and append to it
let state_lookup = id
.as_ref()
.and_then(|want| self.history_state.assistant_stream_state(want).cloned());
let fallback_state =
self.synthesize_stream_state_from_lines(id.as_ref(), &lines, true);
if let Some(last) = self.history_cells.last_mut() {
if let Some(stream_cell) = last
.as_any_mut()
.downcast_mut::<history_cell::StreamingContentCell>()
{
// If id is specified, only append when ids match
if let Some(ref want) = id {
if stream_cell.id.as_ref() != Some(want) {
// fall through to create/find matching cell below
} else {
tracing::debug!(
"history.append -> last StreamingContentCell (id match) lines+={}",
lines.len()
);
// Guard against stray header sneaking into a later chunk
if lines
.first()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim()
.eq_ignore_ascii_case("codex")
})
.unwrap_or(false)
{
if lines.len() == 1 {
return;
} else {
lines.remove(0);
}
}
stream_cell.extend_lines(lines);
self.invalidate_height_cache();
self.autoscroll_if_near_bottom();
self.request_redraw();
return;
}
} else {
// No id — legacy: append to last
let ids_match = match id.as_ref() {
Some(want) => stream_cell.matches_id(want),
None => true,
};
if ids_match {
let state = state_lookup
.clone()
.unwrap_or_else(|| fallback_state.clone());
tracing::debug!(
"history.append -> last StreamingContentCell (no id provided) lines+={}",
lines.len()
"history.update -> tail StreamingContentCell id={:?}",
id
);
if lines
.first()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim()
.eq_ignore_ascii_case("codex")
})
.unwrap_or(false)
{
if lines.len() == 1 {
return;
} else {
lines.remove(0);
}
}
stream_cell.extend_lines(lines);
stream_cell.update_from_state(state, &self.config);
self.invalidate_height_cache();
self.autoscroll_if_near_bottom();
self.request_redraw();
@@ -10922,42 +10926,26 @@ impl ChatWidget<'_> {
}
}
// If id is specified, try to locate an existing streaming cell with that id
if let Some(ref want) = id {
if let Some(idx) = self.history_cells.iter().rposition(|c| {
c.as_any()
.downcast_ref::<history_cell::StreamingContentCell>()
.map(|sc| sc.id.as_ref() == Some(want))
.map(|sc| sc.matches_id(want))
.unwrap_or(false)
}) {
if let Some(stream_cell) = self.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::StreamingContentCell>(
) {
.downcast_mut::<history_cell::StreamingContentCell>()
{
let state = state_lookup
.clone()
.unwrap_or_else(|| fallback_state.clone());
tracing::debug!(
"history.append -> StreamingContentCell by id at idx={} lines+={}",
"history.update -> StreamingContentCell at idx={} id={}",
idx,
lines.len()
want
);
if lines
.first()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim()
.eq_ignore_ascii_case("codex")
})
.unwrap_or(false)
{
if lines.len() == 1 {
return;
} else {
lines.remove(0);
}
}
stream_cell.extend_lines(lines);
stream_cell.update_from_state(state, &self.config);
self.invalidate_height_cache();
self.autoscroll_if_near_bottom();
self.request_redraw();
@@ -10966,26 +10954,8 @@ impl ChatWidget<'_> {
}
}
// Ensure a hidden 'codex' header is present
let has_header = lines
.first()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim()
.eq_ignore_ascii_case("codex")
})
.unwrap_or(false);
if !has_header {
let mut with_header: Vec<ratatui::text::Line<'static>> =
Vec::with_capacity(lines.len() + 1);
with_header.push(ratatui::text::Line::from("codex"));
with_header.extend(lines);
lines = with_header;
}
// Use pre-seeded key for this stream id when present; otherwise synthesize.
let state = state_lookup.unwrap_or(fallback_state);
let key = match id.as_deref() {
Some(rid) => self.try_stream_order_key(kind, rid).unwrap_or_else(|| {
tracing::warn!(
@@ -11005,10 +10975,7 @@ impl ChatWidget<'_> {
Self::debug_fmt_order_key(key)
);
let new_idx = self.history_insert_with_key_global(
Box::new(history_cell::new_streaming_content_with_id(
id.clone(),
lines,
)),
Box::new(history_cell::new_streaming_content(state, &self.config)),
key,
);
tracing::debug!(
@@ -11033,6 +11000,96 @@ impl ChatWidget<'_> {
.upsert_assistant_stream_state(stream_id, preview, None, None);
}
fn synthesize_stream_state_from_lines(
&self,
stream_id: Option<&String>,
lines: &[ratatui::text::Line<'static>],
in_progress: bool,
) -> AssistantStreamState {
let mut preview = String::new();
for (idx, line) in lines.iter().enumerate() {
let flat: String = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
if idx == 0 && flat.trim().eq_ignore_ascii_case("codex") {
continue;
}
if !preview.is_empty() {
preview.push('\n');
}
preview.push_str(&flat);
}
if !preview.is_empty() && !preview.ends_with('\n') {
preview.push('\n');
}
let mut stream_id_string = stream_id
.cloned()
.unwrap_or_else(|| "stream-preview".to_string());
if stream_id_string.is_empty() {
stream_id_string = "stream-preview".to_string();
}
AssistantStreamState {
id: HistoryId::ZERO,
stream_id: stream_id_string,
preview_markdown: preview,
deltas: Vec::new(),
citations: Vec::new(),
metadata: None,
in_progress,
last_updated_at: SystemTime::now(),
}
}
fn refresh_streaming_cell_for_stream_id(
&mut self,
stream_id: &str,
state: AssistantStreamState,
) {
if let Some(idx) = self.history_cells.iter().rposition(|c| {
c.as_any()
.downcast_ref::<history_cell::StreamingContentCell>()
.map(|sc| sc.matches_id(stream_id))
.unwrap_or(false)
}) {
if let Some(stream_cell) = self.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::StreamingContentCell>()
{
stream_cell.update_from_state(state, &self.config);
self.invalidate_height_cache();
self.request_redraw();
}
}
}
fn update_stream_token_usage_metadata(&mut self) {
let Some(stream_id) = self.stream.current_stream_id().cloned() else {
return;
};
let Some(preview) = self
.stream
.preview_source_for_kind(StreamKind::Answer)
else {
return;
};
let metadata = MessageMetadata {
citations: Vec::new(),
token_usage: Some(self.last_token_usage.clone()),
};
self
.history_state
.upsert_assistant_stream_state(&stream_id, preview, None, Some(&metadata));
if let Some(state) = self
.history_state
.assistant_stream_state(&stream_id)
.cloned()
{
self.refresh_streaming_cell_for_stream_id(&stream_id, state);
}
}
fn track_answer_stream_delta(&mut self, stream_id: &str, delta: &str, seq: Option<u64>) {
let preview = self
.stream
@@ -11052,13 +11109,44 @@ impl ChatWidget<'_> {
.upsert_assistant_stream_state(stream_id, preview, delta, None);
}
fn finalize_answer_stream_state(&mut self, stream_id: Option<&str>, source: &str) {
self.history_state.finalize_assistant_stream_state(
fn finalize_answer_stream_state(
&mut self,
stream_id: Option<&str>,
source: &str,
) -> AssistantMessageState {
let mut metadata = stream_id.and_then(|sid| {
self.history_state
.assistant_stream_state(sid)
.and_then(|state| state.metadata.clone())
});
let should_attach_token_usage = self.last_token_usage.total_tokens > 0;
if should_attach_token_usage {
if let Some(meta) = metadata.as_mut() {
if meta.token_usage.is_none() {
meta.token_usage = Some(self.last_token_usage.clone());
}
} else {
metadata = Some(MessageMetadata {
citations: Vec::new(),
token_usage: Some(self.last_token_usage.clone()),
});
}
}
let token_usage = if should_attach_token_usage {
Some(self.last_token_usage.clone())
} else {
None
};
let state = self.history_state.finalize_assistant_stream_state(
stream_id,
source.to_string(),
None,
None,
metadata.as_ref(),
token_usage.as_ref(),
);
state
}
#[cfg(test)]
@@ -11104,7 +11192,7 @@ impl ChatWidget<'_> {
self.history_remove_at(idx);
}
self.last_assistant_message = Some(final_source.clone());
self.finalize_answer_stream_state(id.as_deref(), &final_source);
let _ = self.finalize_answer_stream_state(id.as_deref(), &final_source);
return;
}
// Debug: list last few history cell kinds so we can see what's present
@@ -11164,14 +11252,14 @@ impl ChatWidget<'_> {
if let Some(existing_idx) = self.history_cells.iter().rposition(|c| {
c.as_any()
.downcast_ref::<history_cell::AssistantMarkdownCell>()
.map(|amc| amc.id.as_ref() == Some(want))
.map(|amc| amc.stream_id() == Some(want.as_str()))
.unwrap_or(false)
}) {
if let Some(amc) = self.history_cells[existing_idx]
.as_any()
.downcast_ref::<history_cell::AssistantMarkdownCell>()
{
let prev = Self::normalize_text(&amc.raw);
let prev = Self::normalize_text(amc.markdown());
let newn = Self::normalize_text(&source);
if prev == newn {
tracing::debug!(
@@ -11224,18 +11312,14 @@ impl ChatWidget<'_> {
"final-answer: replacing StreamingContentCell at idx={} by id match",
idx
);
// Replace the matching streaming cell in-place, preserving the id
let cell =
history_cell::AssistantMarkdownCell::new_with_id(source, id.clone(), &self.config);
let state = self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
self.history_replace_at(idx, Box::new(cell));
// Mark this Answer stream id as closed for the rest of the turn so
// any late AgentMessageDelta for the same id is ignored.
if let Some(ref want) = id {
self.stream_state
.closed_answer_ids
.insert(StreamId(want.clone()));
}
self.finalize_answer_stream_state(id.as_deref(), &final_source);
self.autoscroll_if_near_bottom();
return;
}
@@ -11249,7 +11333,7 @@ impl ChatWidget<'_> {
.as_any()
.downcast_ref::<history_cell::AssistantMarkdownCell>()
{
amc.id.as_ref() == Some(want)
amc.stream_id() == Some(want.as_str())
} else {
false
}
@@ -11258,18 +11342,13 @@ impl ChatWidget<'_> {
"final-answer: replacing existing AssistantMarkdownCell at idx={} by id match",
idx
);
let cell = history_cell::AssistantMarkdownCell::new_with_id(
source,
id.clone(),
&self.config,
);
let state =
self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
self.history_replace_at(idx, Box::new(cell));
if let Some(ref want) = id {
self.stream_state
.closed_answer_ids
.insert(StreamId(want.clone()));
}
self.finalize_answer_stream_state(id.as_deref(), &final_source);
self.stream_state
.closed_answer_ids
.insert(StreamId(want.clone()));
self.autoscroll_if_near_bottom();
return;
}
@@ -11292,7 +11371,7 @@ impl ChatWidget<'_> {
.as_any()
.downcast_ref::<history_cell::AssistantMarkdownCell>()
.map(|amc| {
let prev = Self::normalize_text(&amc.raw);
let prev = Self::normalize_text(amc.markdown());
let newn = Self::normalize_text(&source);
let identical = prev == newn;
let is_superset = !identical && newn.contains(&prev);
@@ -11310,13 +11389,10 @@ impl ChatWidget<'_> {
tracing::debug!(
"final-answer: replacing tail AssistantMarkdownCell via heuristic identical/superset"
);
let cell = history_cell::AssistantMarkdownCell::new_with_id(
source,
id.clone(),
&self.config,
);
let state =
self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
self.history_replace_at(idx, Box::new(cell));
self.finalize_answer_stream_state(id.as_deref(), &final_source);
self.autoscroll_if_near_bottom();
return;
}
@@ -11347,15 +11423,14 @@ impl ChatWidget<'_> {
id,
Self::debug_fmt_order_key(key)
);
let cell =
history_cell::AssistantMarkdownCell::new_with_id(source, id.clone(), &self.config);
let state = self.finalize_answer_stream_state(id.as_deref(), &final_source);
let cell = history_cell::AssistantMarkdownCell::from_state(state, &self.config);
let _ = self.history_insert_with_key_global(Box::new(cell), key);
if let Some(ref want) = id {
self.stream_state
.closed_answer_ids
.insert(StreamId(want.clone()));
}
self.finalize_answer_stream_state(id.as_deref(), &final_source);
}
// Assign or fetch a stable sequence for a stream kind+id within its originating turn
@@ -14706,7 +14781,16 @@ impl ChatWidget<'_> {
.collect::<Vec<_>>()
.join("\n\n");
history_cell::AssistantMarkdownCell::new(markdown, &self.config)
let state = AssistantMessageState {
id: HistoryId::ZERO,
stream_id: None,
markdown,
citations: Vec::new(),
metadata: None,
token_usage: None,
created_at: SystemTime::now(),
};
history_cell::AssistantMarkdownCell::from_state(state, &self.config)
}
/// Handle `/branch [task]` command. Creates a worktree under `.code/branches`,
@@ -17824,7 +17908,9 @@ impl WidgetRef for &ChatWidget<'_> {
.collect::<Vec<_>>();
let streaming_cell = if !streaming_lines.is_empty() {
Some(history_cell::new_streaming_content(streaming_lines))
let state =
self.synthesize_stream_state_from_lines(None, &streaming_lines, true);
Some(history_cell::new_streaming_content(state, &self.config))
} else {
None
};
@@ -18377,11 +18463,11 @@ impl WidgetRef for &ChatWidget<'_> {
match item.kind() {
crate::history_cell::HistoryCellType::Exec {
kind: crate::history_cell::ExecKind::Run,
status: crate::history_cell::ExecStatus::Success,
status: crate::history::state::ExecStatus::Success,
} => crate::colors::text(),
crate::history_cell::HistoryCellType::Exec {
kind: crate::history_cell::ExecKind::Run,
status: crate::history_cell::ExecStatus::Error,
status: crate::history::state::ExecStatus::Error,
} => crate::colors::error(),
crate::history_cell::HistoryCellType::Exec { .. } => {
crate::colors::text()
+21 -24
View File
@@ -3,6 +3,7 @@
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::height_manager::HeightEvent;
use crate::history::state::ExecAction;
use crate::history_cell::CommandOutput;
use crate::history_cell::{self, HistoryCell};
use codex_core::parse_command::ParsedCommand;
@@ -331,10 +332,10 @@ pub(super) fn try_merge_completed_exec_at(chat: &mut ChatWidget<'_>, idx: usize)
}
let to_kind = |e: &history_cell::ExecCell| -> history_cell::ExecKind {
match history_cell::action_enum_from_parsed(&e.parsed) {
history_cell::ExecAction::Read => history_cell::ExecKind::Read,
history_cell::ExecAction::Search => history_cell::ExecKind::Search,
history_cell::ExecAction::List => history_cell::ExecKind::List,
history_cell::ExecAction::Run => history_cell::ExecKind::Run,
ExecAction::Read => history_cell::ExecKind::Read,
ExecAction::Search => history_cell::ExecKind::Search,
ExecAction::List => history_cell::ExecKind::List,
ExecAction::Run => history_cell::ExecKind::Run,
}
};
@@ -459,9 +460,7 @@ pub(super) fn handle_exec_begin_now(
if matches!(
action,
history_cell::ExecAction::Read
| history_cell::ExecAction::Search
| history_cell::ExecAction::List
ExecAction::Read | ExecAction::Search | ExecAction::List
) || has_read_command
{
let mut created_new = false;
@@ -526,7 +525,7 @@ pub(super) fn handle_exec_begin_now(
chat.request_redraw();
chat.bottom_pane.set_has_chat_history(true);
let status_text = match action {
history_cell::ExecAction::Read => "reading files…",
ExecAction::Read => "reading files…",
_ => "exploring…",
};
chat.bottom_pane.update_status_text(status_text.to_string());
@@ -627,16 +626,14 @@ 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,
// No matches for searches
(1, history_cell::ExecAction::Search) => history_cell::ExploreEntryStatus::NotFound,
// Missing file/dir for list operations (e.g., ls path)
(1, history_cell::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,
// Anything else is an error; preserve exit code
_ => 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]
@@ -658,19 +655,19 @@ pub(super) fn handle_exec_end_now(
chat.request_redraw();
let status_text = match status {
history_cell::ExploreEntryStatus::Success => match action {
history_cell::ExecAction::Read => "files read".to_string(),
ExecAction::Read => "files read".to_string(),
_ => "exploration updated".to_string(),
},
history_cell::ExploreEntryStatus::NotFound => match action {
history_cell::ExecAction::List => "path not found".to_string(),
ExecAction::List => "path not found".to_string(),
_ => "no matches found".to_string(),
},
history_cell::ExploreEntryStatus::Error { .. } => match action {
history_cell::ExecAction::Read => format!("read failed (exit {exit_code})"),
history_cell::ExecAction::Search => {
ExecAction::Read => format!("read failed (exit {exit_code})"),
ExecAction::Search => {
if exit_code == 2 { "invalid pattern".to_string() } else { format!("search failed (exit {exit_code})") }
}
history_cell::ExecAction::List => format!("list failed (exit {exit_code})"),
ExecAction::List => format!("list failed (exit {exit_code})"),
_ => format!("exploration failed (exit {exit_code})"),
},
history_cell::ExploreEntryStatus::Running => "exploring…".to_string(),
@@ -690,7 +687,7 @@ pub(super) fn handle_exec_end_now(
stderr,
},
));
if let Some(ref cell) = completed_opt {
if let Some(cell) = completed_opt.as_mut() {
cell.set_wait_total(wait_total);
cell.set_wait_notes(&wait_notes);
cell.set_waiting(false);
+29 -7
View File
@@ -260,14 +260,23 @@ pub enum ReasoningEffortLevel {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReasoningSection {
/// Optional heading rendered in bold at the top of the section.
pub heading: Option<String>,
/// Single-line preview used for collapsed summaries; derived from the first
/// meaningful block (heading, bullet, paragraph, etc.).
pub summary: Option<Vec<InlineSpan>>,
/// Rich collection of blocks that fully describe the reasoning content.
pub blocks: Vec<ReasoningBlock>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReasoningBlock {
Paragraph(Vec<InlineSpan>),
Bullet { indent: u8, spans: Vec<InlineSpan> },
Bullet {
indent: u8,
marker: BulletMarker,
spans: Vec<InlineSpan>,
},
Code { language: Option<String>, content: String },
Quote(Vec<InlineSpan>),
Separator,
@@ -287,7 +296,6 @@ pub struct ExecRecord {
pub started_at: SystemTime,
pub completed_at: Option<SystemTime>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExecAction {
Read,
@@ -530,7 +538,7 @@ impl HistoryState {
markdown: String,
metadata: Option<&MessageMetadata>,
token_usage: Option<&TokenUsage>,
) -> HistoryId {
) -> AssistantMessageState {
let mut carried_citations: Vec<String> = Vec::new();
let mut carried_metadata: Option<MessageMetadata> = None;
if let Some(stream_id) = stream_id {
@@ -553,17 +561,31 @@ impl HistoryState {
.map(|meta| meta.citations.clone())
.unwrap_or(carried_citations);
let metadata = metadata.cloned().or(carried_metadata);
let token_usage = token_usage
.cloned()
.or_else(|| metadata.as_ref().and_then(|meta| meta.token_usage.clone()));
let state = AssistantMessageState {
id: HistoryId(0),
let mut state = AssistantMessageState {
id: HistoryId::ZERO,
stream_id: stream_id.map(|s| s.to_string()),
markdown,
citations,
metadata,
token_usage: token_usage.cloned(),
token_usage,
created_at: SystemTime::now(),
};
self.push(HistoryRecord::AssistantMessage(state))
let id = self.next_history_id();
state.id = id;
self.records
.push(HistoryRecord::AssistantMessage(state.clone()));
state
}
pub fn assistant_stream_state(&self, stream_id: &str) -> Option<&AssistantStreamState> {
self.records.iter().find_map(|record| match record {
HistoryRecord::AssistantStream(state) if state.stream_id == stream_id => Some(state),
_ => None,
})
}
pub fn insert(&mut self, index: usize, record: HistoryRecord) -> HistoryId {
+50 -19
View File
@@ -1,33 +1,24 @@
use super::*;
use crate::history::state::{AssistantMessageState, AssistantStreamState};
// ==================== AssistantMarkdownCell ====================
// Stores raw assistant markdown and rebuilds on demand (e.g., theme/syntax changes)
// Stores assistant markdown state and rebuilds on demand (e.g., theme/syntax changes)
pub(crate) struct AssistantMarkdownCell {
// Raw markdown used to rebuild when theme/syntax changes
pub(crate) raw: String,
// Optional stream/item id that produced this finalized cell
pub(crate) id: Option<String>,
state: AssistantMessageState,
// Pre-rendered lines (first line is a hidden "codex" header)
pub(crate) lines: Vec<Line<'static>>, // includes hidden header "codex"
lines: Vec<Line<'static>>, // includes hidden header "codex"
// Cached per-width wrap plan to avoid re-segmentation and re-measure
pub(crate) cached_layout: std::cell::RefCell<Option<AssistantLayoutCache>>,
cached_layout: std::cell::RefCell<Option<AssistantLayoutCache>>,
}
impl AssistantMarkdownCell {
#[allow(dead_code)]
pub(crate) fn new(raw: String, cfg: &codex_core::config::Config) -> Self {
Self::new_with_id(raw, None, cfg)
}
pub(crate) fn new_with_id(
raw: String,
id: Option<String>,
pub(crate) fn from_state(
state: AssistantMessageState,
cfg: &codex_core::config::Config,
) -> Self {
let mut me = Self {
raw,
id,
state,
lines: Vec::new(),
cached_layout: std::cell::RefCell::new(None),
};
@@ -35,10 +26,50 @@ impl AssistantMarkdownCell {
me
}
pub(crate) fn rebuild(&mut self, cfg: &codex_core::config::Config) {
pub(crate) fn from_stream_state(
state: &AssistantStreamState,
cfg: &codex_core::config::Config,
) -> Self {
let message_state = AssistantMessageState {
id: state.id,
stream_id: Some(state.stream_id.clone()),
markdown: state.preview_markdown.clone(),
citations: state.citations.clone(),
metadata: state.metadata.clone(),
token_usage: state
.metadata
.as_ref()
.and_then(|meta| meta.token_usage.clone()),
created_at: state.last_updated_at,
};
Self::from_state(message_state, cfg)
}
pub(crate) fn update_state(
&mut self,
state: AssistantMessageState,
cfg: &codex_core::config::Config,
) {
self.state = state;
self.rebuild(cfg);
}
pub(crate) fn stream_id(&self) -> Option<&str> {
self.state.stream_id.as_deref()
}
pub(crate) fn markdown(&self) -> &str {
&self.state.markdown
}
pub(crate) fn state(&self) -> &AssistantMessageState {
&self.state
}
fn rebuild(&mut self, cfg: &codex_core::config::Config) {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from("codex"));
crate::markdown::append_markdown_with_bold_first(&self.raw, &mut out, cfg);
crate::markdown::append_markdown_with_bold_first(&self.state.markdown, &mut out, cfg);
// Apply bright text to body like streaming finalize
let bright = crate::colors::text_bright();
for line in out.iter_mut().skip(1) {
+198 -115
View File
@@ -1,15 +1,68 @@
use super::*;
use crate::history::state::{DiffHunk, DiffLine, DiffLineKind, DiffRecord, HistoryId};
use crate::insert_history::word_wrap_lines;
pub(crate) struct DiffCell {
_record: DiffRecord,
lines: Vec<Line<'static>>,
record: DiffRecord,
layout: std::cell::RefCell<Option<DiffLayoutCache>>,
}
impl DiffCell {
pub(crate) fn new(record: DiffRecord) -> Self {
let lines = build_lines(&record);
Self { _record: record, lines }
pub(crate) fn from_record(record: DiffRecord) -> Self {
Self {
record,
layout: std::cell::RefCell::new(None),
}
}
pub(crate) fn rebuild_with_theme(&self) {
self.layout.borrow_mut().take();
}
fn ensure_layout(&self, width: u16) -> DiffLayoutCache {
if let Some(cache) = self.layout.borrow().as_ref() {
if cache.width == width {
return cache.clone();
}
}
let cache = DiffLayoutCache::build(&self.record, width);
*self.layout.borrow_mut() = Some(cache.clone());
cache
}
fn display_lines_from_record(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if !self.record.title.is_empty() {
lines.push(Line::from(self.record.title.clone()).fg(crate::colors::primary()));
}
for hunk in &self.record.hunks {
if !hunk.header.is_empty() {
lines.push(Line::from(hunk.header.clone()).fg(crate::colors::primary()));
}
for diff_line in &hunk.lines {
let prefix = match diff_line.kind {
DiffLineKind::Addition => '+',
DiffLineKind::Removal => '-',
DiffLineKind::Context => ' ',
};
let content = format!("{}{}", prefix, diff_line.content);
let styled = match diff_line.kind {
DiffLineKind::Addition => {
Line::from(content).fg(crate::colors::success())
}
DiffLineKind::Removal => {
Line::from(content).fg(crate::colors::error())
}
DiffLineKind::Context => Line::from(content),
};
lines.push(styled);
}
}
lines
}
}
@@ -27,7 +80,7 @@ impl HistoryCell for DiffCell {
}
fn display_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
self.display_lines_from_record()
}
fn has_custom_render(&self) -> bool {
@@ -38,130 +91,78 @@ impl HistoryCell for DiffCell {
let bg = Style::default().bg(crate::colors::background());
fill_rect(buf, area, Some(' '), bg);
let marker_col_x = area.x.saturating_add(2);
let content_x = area.x.saturating_add(4);
let content_w = area.width.saturating_sub(4);
if area.width == 0 || area.height == 0 {
return;
}
let plan = self.ensure_layout(area.width);
let marker_col_x = area.x.saturating_add(plan.marker_offset);
let content_x = area.x.saturating_add(plan.content_offset);
let content_w = plan.content_width;
let mut cur_y = area.y;
let end_y = area.y.saturating_add(area.height);
let classify = |line: &Line<'static>| -> (Option<char>, Line<'static>) {
if line.spans.is_empty() {
return (None, line.clone());
}
let mut iter = line
.spans
.iter()
.flat_map(|s| s.content.chars())
.peekable();
match iter.peek().copied() {
Some('+') => {
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
let content = text.chars().skip(1).collect::<String>();
let styled = Line::from(content).style(Style::default().fg(crate::colors::success()));
(Some('+'), styled)
}
Some('-') => {
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
let content = text.chars().skip(1).collect::<String>();
let styled = Line::from(content).style(Style::default().fg(crate::colors::error()));
(Some('-'), styled)
}
_ => (None, line.clone()),
}
};
'outer: for line in &self.lines {
let (marker, content_line) = classify(line);
let text = Text::from(vec![content_line.clone()]);
let rows: u16 = Paragraph::new(text.clone())
.wrap(Wrap { trim: false })
.line_count(content_w)
.try_into()
.unwrap_or(0);
let mut local_skip = 0u16;
if skip_rows > 0 {
if skip_rows >= rows {
skip_rows -= rows;
continue 'outer;
}
local_skip = skip_rows;
skip_rows = 0;
}
if cur_y >= end_y {
break;
}
let avail = end_y.saturating_sub(cur_y);
let draw_h = rows.saturating_sub(local_skip).min(avail);
if draw_h == 0 {
for (segment, rows) in plan.segments.iter().zip(plan.segment_heights.iter()) {
if skip_rows >= *rows {
skip_rows -= *rows;
continue;
}
let content_area = Rect {
x: content_x,
y: cur_y,
width: content_w,
height: draw_h,
};
Paragraph::new(text)
.block(Block::default().style(bg))
.wrap(Wrap { trim: false })
.scroll((local_skip, 0))
.style(bg)
.render(content_area, buf);
let local_skip = skip_rows;
skip_rows = 0;
if let Some(m) = marker {
if local_skip == 0 && area.width > 0 {
let style = Style::default().fg(if m == '+' {
crate::colors::success()
} else {
crate::colors::error()
});
buf.set_string(marker_col_x, cur_y, m.to_string(), style);
}
}
cur_y = cur_y.saturating_add(draw_h);
if cur_y >= end_y {
break;
}
}
}
}
fn build_lines(record: &DiffRecord) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let avail = end_y.saturating_sub(cur_y);
if avail == 0 {
break;
}
if !record.title.is_empty() {
lines.push(Line::from(record.title.clone()).fg(crate::colors::primary()));
}
let first_visible = usize::from(local_skip);
let remaining = (*rows).saturating_sub(local_skip);
let draw_rows = remaining.min(avail);
if draw_rows == 0 {
continue;
}
for hunk in &record.hunks {
lines.push(Line::from(hunk.header.clone()).fg(crate::colors::primary()));
for line in &hunk.lines {
lines.push(line_to_history(line));
for (idx, line) in segment
.lines
.iter()
.enumerate()
.skip(first_visible)
.take(draw_rows as usize)
{
if cur_y >= end_y {
break;
}
write_line(buf, content_x, cur_y, content_w, line, bg);
if idx == first_visible {
if let Some(marker) = segment.marker {
let marker_style = match marker {
'+' => Style::default().fg(crate::colors::success()),
'-' => Style::default().fg(crate::colors::error()),
_ => Style::default().fg(crate::colors::text_dim()),
};
if area.width > 0 {
buf.set_string(marker_col_x, cur_y, marker.to_string(), marker_style);
}
}
}
cur_y = cur_y.saturating_add(1);
if cur_y >= end_y {
break;
}
}
}
}
lines
}
fn line_to_history(line: &DiffLine) -> Line<'static> {
match line.kind {
DiffLineKind::Addition => Line::from(format!("+{}", line.content))
.fg(crate::colors::success()),
DiffLineKind::Removal => Line::from(format!("-{}", line.content))
.fg(crate::colors::error()),
DiffLineKind::Context => Line::from(line.content.clone()),
fn desired_height(&self, width: u16) -> u16 {
self.ensure_layout(width).total_rows
}
}
@@ -210,7 +211,89 @@ pub(crate) fn diff_record_from_string(title: String, diff: &str) -> DiffRecord {
}
}
#[allow(dead_code)]
pub(crate) fn new_diff_cell_from_string(diff_output: String) -> DiffCell {
let record = diff_record_from_string(String::new(), &diff_output);
DiffCell::new(record)
DiffCell::from_record(record)
}
#[derive(Clone)]
struct DiffRenderedSegment {
marker: Option<char>,
lines: Vec<Line<'static>>,
}
#[derive(Clone)]
struct DiffLayoutCache {
width: u16,
marker_offset: u16,
content_offset: u16,
content_width: u16,
segments: Vec<DiffRenderedSegment>,
segment_heights: Vec<u16>,
total_rows: u16,
}
impl DiffLayoutCache {
fn build(record: &DiffRecord, width: u16) -> Self {
let marker_offset = if width >= 3 { 2 } else { width.saturating_sub(1) };
let content_offset = marker_offset.saturating_add(2);
let content_width = width.saturating_sub(content_offset).max(1);
let mut segments: Vec<DiffRenderedSegment> = Vec::new();
if !record.title.is_empty() {
let line = Line::from(record.title.clone()).fg(crate::colors::primary());
let wrapped = word_wrap_lines(&[line], content_width);
segments.push(DiffRenderedSegment {
marker: None,
lines: wrapped,
});
}
for hunk in &record.hunks {
if !hunk.header.is_empty() {
let header = Line::from(hunk.header.clone()).fg(crate::colors::primary());
let wrapped = word_wrap_lines(&[header], content_width);
segments.push(DiffRenderedSegment {
marker: None,
lines: wrapped,
});
}
for diff_line in &hunk.lines {
let (marker, styled_line) = match diff_line.kind {
DiffLineKind::Addition => (
Some('+'),
Line::from(diff_line.content.clone()).fg(crate::colors::success()),
),
DiffLineKind::Removal => (
Some('-'),
Line::from(diff_line.content.clone()).fg(crate::colors::error()),
),
DiffLineKind::Context => (None, Line::from(diff_line.content.clone())),
};
let wrapped = word_wrap_lines(&[styled_line], content_width);
segments.push(DiffRenderedSegment { marker, lines: wrapped });
}
}
let mut segment_heights: Vec<u16> = Vec::with_capacity(segments.len());
let mut total_rows: u16 = 0;
for segment in &segments {
let rows = segment.lines.len() as u16;
segment_heights.push(rows);
total_rows = total_rows.saturating_add(rows);
}
Self {
width,
marker_offset,
content_offset,
content_width,
segments,
segment_heights,
total_rows,
}
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::history::state::ExecAction;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ExploreEntryStatus {
File diff suppressed because it is too large Load Diff
+460 -62
View File
@@ -1,28 +1,44 @@
//! Collapsible reasoning cells backed by structured reasoning sections.
use super::semantic::{lines_from_ratatui, SemanticLine};
use super::semantic;
use super::semantic::SemanticLine;
use super::text;
use super::*;
use crate::history::state::{ReasoningBlock, ReasoningSection};
use crate::history::state::{
BulletMarker, InlineSpan, ReasoningBlock, ReasoningSection, TextEmphasis, TextTone,
};
use crate::history_cell::assistant::detect_bullet_prefix;
use crate::render::line_utils;
use ratatui::text::Line;
use std::cell::{Cell, RefCell};
use unicode_width::UnicodeWidthStr as _;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CollapsibleReasoningState {
pub lines: Vec<SemanticLine>,
lines: Vec<ReasoningLineEntry>,
pub sections: Vec<ReasoningSection>,
pub in_progress: bool,
pub hide_when_collapsed: bool,
pub id: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
struct ReasoningLineEntry {
raw: Line<'static>,
semantic: SemanticLine,
}
impl CollapsibleReasoningState {
pub(crate) fn new(lines: Vec<Line<'static>>, id: Option<String>) -> Self {
let semantic_lines = lines_from_ratatui(lines);
let sections = sections_from_semantic_lines(&semantic_lines);
let entries = lines
.into_iter()
.map(|line| ReasoningLineEntry {
semantic: SemanticLine::from_line(line.clone()),
raw: line,
})
.collect::<Vec<_>>();
let sections = sections_from_entries(&entries);
Self {
lines: semantic_lines,
lines: entries,
sections,
in_progress: false,
hide_when_collapsed: false,
@@ -79,10 +95,16 @@ impl CollapsibleReasoningCell {
return;
}
let mut state = self.state.borrow_mut();
let mut incoming = semantic::lines_from_ratatui(new_lines);
dedup_append_semantic(&mut state.lines, &mut incoming);
let mut incoming = new_lines
.into_iter()
.map(|line| ReasoningLineEntry {
semantic: SemanticLine::from_line(line.clone()),
raw: line,
})
.collect::<Vec<_>>();
dedup_append_entries(&mut state.lines, &mut incoming);
state.lines.extend(incoming);
state.sections = sections_from_semantic_lines(&state.lines);
state.sections = sections_from_entries(&state.lines);
}
pub(crate) fn retint(&self, _old: &crate::theme::Theme, _new: &crate::theme::Theme) {}
@@ -121,7 +143,7 @@ impl HistoryCell for CollapsibleReasoningCell {
if state.hide_when_collapsed {
return Vec::new();
}
let mut titles = extract_section_titles_locked(&state, &normalized);
let mut titles = extract_section_titles_locked(&state, &normalized, &theme);
if state.in_progress {
if let Some(mut last) = titles.pop() {
last.spans.push(Span::styled(
@@ -213,21 +235,23 @@ impl HistoryCell for CollapsibleReasoningCell {
}
}
fn dedup_append_semantic(
existing: &mut Vec<semantic::SemanticLine>,
incoming: &mut Vec<semantic::SemanticLine>,
fn dedup_append_entries(
existing: &mut Vec<ReasoningLineEntry>,
incoming: &mut Vec<ReasoningLineEntry>,
) {
if incoming.is_empty() {
return;
}
let to_plain = |line: &semantic::SemanticLine| -> String {
line.spans
let to_plain = |line: &ReasoningLineEntry| -> String {
line
.semantic
.spans
.iter()
.map(|span| span.text.as_str())
.collect::<String>()
};
let is_marker = |line: &semantic::SemanticLine| -> bool {
let is_marker = |line: &ReasoningLineEntry| -> bool {
let t = to_plain(line).trim().to_string();
t.starts_with('[') && t.ends_with(']')
};
@@ -258,7 +282,7 @@ fn dedup_append_semantic(
if overlap > 0 {
let mut to_drop = overlap;
let mut trimmed: Vec<semantic::SemanticLine> = Vec::with_capacity(incoming.len());
let mut trimmed: Vec<ReasoningLineEntry> = Vec::with_capacity(incoming.len());
for l in incoming.drain(..) {
if to_drop > 0 && !is_marker(&l) {
to_drop -= 1;
@@ -280,30 +304,384 @@ fn dedup_append_semantic(
}
}
fn sections_from_semantic_lines(lines: &[SemanticLine]) -> Vec<ReasoningSection> {
fn sections_from_entries(lines: &[ReasoningLineEntry]) -> Vec<ReasoningSection> {
if lines.is_empty() {
return Vec::new();
}
let mut blocks: Vec<ReasoningBlock> = Vec::with_capacity(lines.len());
for line in lines {
let spans = line
.spans
.iter()
.cloned()
.map(text::inline_span_from_semantic)
.collect::<Vec<_>>();
let is_blank = spans.iter().all(|span| span.text.trim().is_empty());
if is_blank {
blocks.push(ReasoningBlock::Separator);
} else {
blocks.push(ReasoningBlock::Paragraph(spans));
let mut sections: Vec<ReasoningSection> = Vec::new();
let mut current = new_empty_section();
let mut summary_set = false;
let mut idx = 0usize;
while idx < lines.len() {
let entry = &lines[idx];
let plain = plain_text(entry);
if plain.trim().is_empty() {
if !current.blocks.is_empty()
&& !matches!(current.blocks.last(), Some(ReasoningBlock::Separator))
{
current.blocks.push(ReasoningBlock::Separator);
}
idx += 1;
continue;
}
if is_marker_text(&plain) {
idx += 1;
continue;
}
if line_utils::is_code_block_painted(&entry.raw) {
let (block_opt, next_idx) = extract_code_block(lines, idx);
idx = next_idx;
if let Some(block) = block_opt {
if !summary_set {
if let ReasoningBlock::Code { content, .. } = &block {
if let Some(first_line) = content.lines().find(|l| !l.trim().is_empty()) {
current.summary = Some(vec![InlineSpan {
text: first_line.trim().to_string(),
tone: TextTone::Dim,
emphasis: TextEmphasis::default(),
entity: None,
}]);
summary_set = true;
}
}
}
current.blocks.push(block);
}
continue;
}
if is_heading_entry(entry) {
trim_section(&mut current);
if current.heading.is_some() || !current.blocks.is_empty() || current.summary.is_some() {
sections.push(current);
current = new_empty_section();
}
let spans = spans_from_entry(entry);
if !spans_are_blank(&spans) {
current.summary = Some(spans.clone());
summary_set = true;
} else {
summary_set = false;
}
current.heading = Some(plain.trim().to_string());
idx += 1;
continue;
}
if let Some((indent, marker, mut spans)) = detect_bullet_block(entry) {
if !summary_set && !spans_are_blank(&spans) {
current.summary = Some(spans.clone());
summary_set = true;
}
trim_leading_whitespace(&mut spans);
current.blocks.push(ReasoningBlock::Bullet {
indent,
marker,
spans,
});
idx += 1;
continue;
}
if let Some(mut quote_spans) = extract_quote_spans(entry) {
if !summary_set && !spans_are_blank(&quote_spans) {
current.summary = Some(quote_spans.clone());
summary_set = true;
}
trim_leading_whitespace(&mut quote_spans);
current.blocks.push(ReasoningBlock::Quote(quote_spans));
idx += 1;
continue;
}
let spans = spans_from_entry(entry);
if !spans_are_blank(&spans) {
if !summary_set {
current.summary = Some(spans.clone());
summary_set = true;
}
current.blocks.push(ReasoningBlock::Paragraph(spans));
}
idx += 1;
}
vec![ReasoningSection {
trim_section(&mut current);
if current.heading.is_some() || !current.blocks.is_empty() || current.summary.is_some() {
sections.push(current);
}
sections
}
fn new_empty_section() -> ReasoningSection {
ReasoningSection {
heading: None,
blocks,
}]
summary: None,
blocks: Vec::new(),
}
}
fn trim_section(section: &mut ReasoningSection) {
while section
.blocks
.last()
.is_some_and(|b| matches!(b, ReasoningBlock::Separator))
{
let _ = section.blocks.pop();
}
}
fn plain_text(entry: &ReasoningLineEntry) -> String {
entry
.semantic
.spans
.iter()
.map(|span| span.text.as_str())
.collect::<String>()
}
fn spans_from_entry(entry: &ReasoningLineEntry) -> Vec<InlineSpan> {
entry
.semantic
.spans
.iter()
.cloned()
.map(text::inline_span_from_semantic)
.collect()
}
fn spans_are_blank(spans: &[InlineSpan]) -> bool {
spans.iter().all(|span| span.text.trim().is_empty())
}
fn is_marker_text(text: &str) -> bool {
let trimmed = text.trim();
trimmed.starts_with('[') && trimmed.ends_with(']')
}
fn is_heading_entry(entry: &ReasoningLineEntry) -> bool {
let mut has_content = false;
for span in &entry.semantic.spans {
if span.text.trim().is_empty() {
continue;
}
has_content = true;
if !span.emphasis.bold {
return false;
}
}
has_content
}
fn detect_bullet_block(entry: &ReasoningLineEntry) -> Option<(u8, BulletMarker, Vec<InlineSpan>)> {
let (indent_spaces, bullet) = detect_bullet_prefix(&entry.raw)?;
let plain = plain_text(entry);
let prefix_len = compute_bullet_prefix_len(&plain, indent_spaces, &bullet);
let spans = strip_prefix_from_inline_spans(spans_from_entry(entry), prefix_len);
if spans_are_blank(&spans) {
return None;
}
let indent_level = (indent_spaces / 2).min(u8::MAX as usize) as u8;
let marker = parse_bullet_marker(&bullet);
Some((indent_level, marker, spans))
}
fn compute_bullet_prefix_len(plain: &str, indent_spaces: usize, bullet: &str) -> usize {
let mut consumed = 0usize;
let mut chars = plain.chars();
for _ in 0..indent_spaces {
if chars.next().is_some() {
consumed += 1;
}
}
for ch in bullet.chars() {
match chars.next() {
Some(c) if c == ch => consumed += 1,
Some(_) => {
consumed += 1;
}
None => break,
}
}
if matches!(chars.clone().next(), Some(c) if c.is_whitespace()) {
chars.next();
consumed += 1;
}
consumed
}
fn parse_bullet_marker(bullet: &str) -> BulletMarker {
if let Some(num) = bullet
.strip_suffix('.')
.and_then(|s| s.parse::<u32>().ok())
{
return BulletMarker::Numbered(num);
}
if let Some(num) = bullet
.strip_suffix(')')
.and_then(|s| s.parse::<u32>().ok())
{
return BulletMarker::Numbered(num);
}
if matches!(bullet, "-" | "*") {
BulletMarker::Dash
} else {
BulletMarker::Custom(bullet.to_string())
}
}
fn strip_prefix_from_inline_spans(spans: Vec<InlineSpan>, mut chars_to_strip: usize) -> Vec<InlineSpan> {
let mut out: Vec<InlineSpan> = Vec::new();
for mut span in spans.into_iter() {
if chars_to_strip == 0 {
if !span.text.is_empty() {
out.push(span);
}
continue;
}
let len = span.text.chars().count();
if chars_to_strip >= len {
chars_to_strip -= len;
continue;
}
let trimmed: String = span.text.chars().skip(chars_to_strip).collect();
span.text = trimmed;
chars_to_strip = 0;
if !span.text.is_empty() {
out.push(span);
}
}
out
}
fn trim_leading_whitespace(spans: &mut Vec<InlineSpan>) {
while let Some(first) = spans.first_mut() {
let original = first.text.clone();
let trimmed = original.trim_start().to_string();
if trimmed.is_empty() {
spans.remove(0);
continue;
}
if trimmed.len() != original.len() {
first.text = trimmed;
}
break;
}
}
fn extract_quote_spans(entry: &ReasoningLineEntry) -> Option<Vec<InlineSpan>> {
let plain = plain_text(entry);
let mut chars = plain.chars().peekable();
let mut prefix = 0usize;
let mut saw_marker = false;
while let Some(&c) = chars.peek() {
if c.is_whitespace() && !saw_marker {
chars.next();
prefix += 1;
continue;
}
if c == '>' {
chars.next();
prefix += 1;
saw_marker = true;
if matches!(chars.peek(), Some(' ')) {
chars.next();
prefix += 1;
}
break;
}
break;
}
if !saw_marker {
return None;
}
let spans = strip_prefix_from_inline_spans(spans_from_entry(entry), prefix);
if spans_are_blank(&spans) {
return None;
}
Some(spans)
}
fn extract_code_block(
lines: &[ReasoningLineEntry],
start_idx: usize,
) -> (Option<ReasoningBlock>, usize) {
let mut idx = start_idx;
let mut chunk: Vec<Line<'static>> = Vec::new();
while idx < lines.len() && line_utils::is_code_block_painted(&lines[idx].raw) {
chunk.push(lines[idx].raw.clone());
idx += 1;
}
if chunk.is_empty() {
return (None, idx);
}
let mut lang_label: Option<String> = None;
let mut content_lines: Vec<Line<'static>> = Vec::new();
for (line_idx, line) in chunk.into_iter().enumerate() {
let flat = flatten_line(&line);
if line_idx == 0 {
if let Some(label) = extract_lang_label(&flat) {
lang_label = Some(label);
continue;
}
}
if flat.contains("⟦LANG:") {
continue;
}
content_lines.push(line);
}
while content_lines
.first()
.is_some_and(|l| line_utils::is_blank_line_spaces_only(l))
{
let _ = content_lines.remove(0);
}
while content_lines
.last()
.is_some_and(|l| line_utils::is_blank_line_spaces_only(l))
{
let _ = content_lines.pop();
}
if content_lines.is_empty() {
return (None, idx);
}
let mut content = String::new();
for (i, line) in content_lines.iter().enumerate() {
if i > 0 {
content.push('\n');
}
content.push_str(flatten_line(line).trim_end_matches('\n'));
}
(
Some(ReasoningBlock::Code {
language: lang_label,
content,
}),
idx,
)
}
fn flatten_line(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
}
fn extract_lang_label(flat: &str) -> Option<String> {
let tail = flat.strip_prefix("⟦LANG:")?;
let end = tail.find('⟧')?;
Some(tail[..end].to_string())
}
fn sections_to_ratatui_lines(
@@ -329,10 +707,23 @@ fn sections_to_ratatui_lines(
.collect();
out.push(Line::from(spans));
}
ReasoningBlock::Bullet { indent, spans } => {
let mut line_spans = Vec::new();
ReasoningBlock::Bullet {
indent,
marker,
spans,
} => {
let indent_spaces = (*indent as usize).saturating_mul(2);
line_spans.push(Span::raw(format!("{}• ", " ".repeat(indent_spaces))));
let marker_text = match marker {
BulletMarker::Dash => "•".to_string(),
BulletMarker::Numbered(n) => format!("{}.", n),
BulletMarker::Custom(s) => s.clone(),
};
let mut line_spans = Vec::new();
line_spans.push(Span::raw(format!(
"{}{} ",
" ".repeat(indent_spaces),
marker_text
)));
for span in spans {
line_spans.push(text::inline_span_to_span(span, theme));
}
@@ -362,6 +753,30 @@ fn sections_to_ratatui_lines(
out
}
fn collect_section_summaries(
sections: &[ReasoningSection],
theme: &crate::theme::Theme,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
for section in sections {
if let Some(summary) = section.summary.as_ref().filter(|spans| !spans_are_blank(spans)) {
let spans = summary
.iter()
.map(|span| text::inline_span_to_span(span, theme))
.collect::<Vec<_>>();
out.push(Line::from(spans));
} else if let Some(heading) = &section.heading {
out.push(Line::from(vec![Span::styled(
heading.clone(),
Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD),
)]));
}
}
out
}
fn normalized_lines(lines: &[Line<'static>]) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
for line in lines {
@@ -412,32 +827,15 @@ fn normalized_lines(lines: &[Line<'static>]) -> Vec<Line<'static>> {
fn extract_section_titles_locked(
state: &CollapsibleReasoningState,
normalized: &[Line<'static>],
theme: &crate::theme::Theme,
) -> Vec<Line<'static>> {
let mut titles: Vec<Line<'static>> = Vec::new();
for l in normalized {
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
if text.trim().is_empty() {
continue;
}
let all_bold = !l.spans.is_empty()
&& l
.spans
.iter()
.all(|s| s.style.add_modifier.contains(Modifier::BOLD) || s.content.trim().is_empty());
if all_bold {
titles.push(l.clone());
}
}
let mut titles = collect_section_summaries(&state.sections, theme);
if titles.is_empty() && !state.in_progress {
for l in normalized {
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
if text.trim().is_empty() {
continue;
}
titles.push(l.clone());
break;
}
titles = normalized
.iter()
.filter(|line| line.spans.iter().any(|s| !s.content.trim().is_empty()))
.cloned()
.collect();
}
let color = crate::colors::text_dim();
+57 -100
View File
@@ -1,16 +1,16 @@
use super::*;
use super::assistant::{AssistantLayoutCache, AssistantMarkdownCell, AssistantSeg};
use crate::history::state::AssistantStreamState;
use codex_core::config::Config;
// ==================== StreamingContentCell ====================
// For live streaming content that's being actively rendered
// Renders in-progress assistant answers backed by `AssistantStreamState`.
pub(crate) struct StreamingContentCell {
pub(crate) id: Option<String>,
lines: Vec<Line<'static>>,
// Show an ellipsis on a new line while streaming is in progress
state: AssistantStreamState,
assistant: AssistantMarkdownCell,
pub(crate) show_ellipsis: bool,
// Cached per-width wrap plan to avoid re-segmentation; invalidated on extend
cached_layout: std::cell::RefCell<Option<AssistantLayoutCache>>, // reuse same struct
}
impl HistoryCell for StreamingContentCell {
@@ -44,8 +44,12 @@ impl HistoryCell for StreamingContentCell {
let bg_style = Style::default().bg(cell_bg);
fill_rect(buf, area, Some(' '), bg_style);
let plan = self.ensure_stream_layout(area.width);
if area.width == 0 || area.height == 0 {
return;
}
let text_wrap_width = area.width;
let plan = self.ensure_stream_layout(text_wrap_width);
let mut segs = plan.segs.clone();
let mut seg_rows = plan.seg_rows.clone();
@@ -70,22 +74,23 @@ impl HistoryCell for StreamingContentCell {
let mut remaining_skip = skip_rows;
let mut cur_y = area.y;
let end_y = area.y.saturating_add(area.height);
if remaining_skip == 0 && cur_y < end_y {
cur_y = cur_y.saturating_add(1);
}
remaining_skip = remaining_skip.saturating_sub(1);
#[derive(Debug, Clone)]
#[derive(Clone)]
enum Seg {
Text(Vec<Line<'static>>),
Bullet(Vec<Line<'static>>),
Code(Vec<Line<'static>>),
Code {
lines: Vec<Line<'static>>,
lang_label: Option<String>,
max_line_width: u16,
},
}
use unicode_width::UnicodeWidthStr as UW;
let measure_line =
|l: &Line<'_>| -> usize { l.spans.iter().map(|s| UW::width(s.content.as_ref())).sum() };
let mut draw_segment = |seg: &Seg, y: &mut u16, skip: &mut u16| {
if *y >= end_y {
return;
@@ -148,28 +153,17 @@ impl HistoryCell for StreamingContentCell {
*y = y.saturating_add(draw_h);
*skip = 0;
}
Seg::Code(lines_in) => {
if lines_in.is_empty() {
return;
}
let mut lang_label: Option<String> = None;
let mut lines = lines_in.clone();
if let Some(first) = lines.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if let Some(s) = flat.strip_prefix("⟦LANG:") {
if let Some(end) = s.find('⟧') {
lang_label = Some(s[..end].to_string());
lines.remove(0);
}
}
}
Seg::Code {
lines,
lang_label,
max_line_width,
} => {
if lines.is_empty() {
return;
}
let max_w = lines.iter().map(|l| measure_line(l)).max().unwrap_or(0) as u16;
let inner_w = max_w.max(1);
let inner_w = (*max_line_width).max(1);
let card_w = inner_w.saturating_add(6).min(area.width.max(6));
let total = lines.len() as u16 + 2;
let total = lines.len() as u16 + 2; // borders
if *skip >= total {
*skip -= total;
return;
@@ -214,7 +208,7 @@ impl HistoryCell for StreamingContentCell {
top: 0,
bottom: 0,
});
if let Some(lang) = &lang_label {
if let Some(lang) = lang_label {
blk = blk.title(Span::styled(
format!(" {} ", lang),
Style::default().fg(crate::colors::text_dim()),
@@ -238,11 +232,11 @@ impl HistoryCell for StreamingContentCell {
}
};
for (i, seg) in segs.iter().enumerate() {
for (idx, seg) in segs.iter().enumerate() {
if cur_y >= end_y {
break;
}
let rows = seg_rows.get(i).copied().unwrap_or(0);
let rows = seg_rows.get(idx).copied().unwrap_or(0);
if remaining_skip >= rows {
remaining_skip -= rows;
continue;
@@ -250,7 +244,15 @@ impl HistoryCell for StreamingContentCell {
let seg_draw = match seg {
AssistantSeg::Text(lines) => Seg::Text(lines.clone()),
AssistantSeg::Bullet(lines) => Seg::Bullet(lines.clone()),
AssistantSeg::Code { lines, .. } => Seg::Code(lines.clone()),
AssistantSeg::Code {
lines,
lang_label,
max_line_width,
} => Seg::Code {
lines: lines.clone(),
lang_label: lang_label.clone(),
max_line_width: *max_line_width,
},
};
draw_segment(&seg_draw, &mut cur_y, &mut remaining_skip);
}
@@ -264,84 +266,39 @@ impl HistoryCell for StreamingContentCell {
}
fn display_lines(&self) -> Vec<Line<'static>> {
let has_leading_header = self
.lines
.first()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim()
.eq_ignore_ascii_case("codex")
})
.unwrap_or(false);
if has_leading_header {
if self.lines.len() == 1 {
Vec::new()
} else {
self.lines[1..].to_vec()
}
} else {
self.lines.clone()
}
self.assistant.display_lines()
}
}
impl StreamingContentCell {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self::new_with_id(None, lines)
}
pub(crate) fn new_with_id(id: Option<String>, lines: Vec<Line<'static>>) -> Self {
pub(crate) fn from_state(state: AssistantStreamState, cfg: &Config) -> Self {
let assistant = AssistantMarkdownCell::from_stream_state(&state, cfg);
let show_ellipsis = state.in_progress;
Self {
id,
lines,
show_ellipsis: true,
cached_layout: std::cell::RefCell::new(None),
id: Some(state.stream_id.clone()),
state,
assistant,
show_ellipsis,
}
}
pub(crate) fn extend_lines(&mut self, mut new_lines: Vec<Line<'static>>) {
if new_lines.is_empty() {
return;
}
self.lines.append(&mut new_lines);
*self.cached_layout.borrow_mut() = None;
pub(crate) fn update_from_state(&mut self, state: AssistantStreamState, cfg: &Config) {
self.state = state;
self.id = Some(self.state.stream_id.clone());
self.assistant = AssistantMarkdownCell::from_stream_state(&self.state, cfg);
self.show_ellipsis = self.state.in_progress;
}
pub(crate) fn retint(&mut self, old: &crate::theme::Theme, new: &crate::theme::Theme) {
retint_lines_in_place(&mut self.lines, old, new);
*self.cached_layout.borrow_mut() = None;
pub(crate) fn rebuild_with_config(&mut self, cfg: &Config) {
let current = self.assistant.state().clone();
self.assistant.update_state(current, cfg);
}
pub(crate) fn matches_id(&self, want: &str) -> bool {
self.id.as_deref() == Some(want) || self.state.stream_id == want
}
fn ensure_stream_layout(&self, width: u16) -> AssistantLayoutCache {
if let Some(cache) = self.cached_layout.borrow().as_ref() {
if cache.width == width {
return cache.clone();
}
}
let mut body_lines = self.lines.clone();
let mut had_header = false;
if let Some(first) = body_lines.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if flat.trim().eq_ignore_ascii_case("codex") {
had_header = true;
}
}
if !had_header {
body_lines.insert(0, ratatui::text::Line::from("codex"));
}
let tmp = AssistantMarkdownCell {
raw: String::new(),
id: None,
lines: body_lines,
cached_layout: std::cell::RefCell::new(None),
};
let cache = tmp.ensure_layout(width);
*self.cached_layout.borrow_mut() = Some(cache.clone());
cache
self.assistant.ensure_layout(width)
}
}