mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Merge branch 'code-branch-20250926-063412' into main
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::history::state::ExecAction;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ExploreEntryStatus {
|
||||
|
||||
+186
-1124
File diff suppressed because it is too large
Load Diff
@@ -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("e_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) = §ion.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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user