use std::path::PathBuf;
use serde_json::Value;
use vw_shared::session::ui_types::{
self as session_ui, ChatRole, ChatSession, ChatSessionMeta, ChatSessionStep,
};
use super::model::{
OverlayState, PromptState, UiAssistantMessage, UiMemoryEntry, UiMessage, UiMessageBase,
UiMessageId, UiQuestionOverlay, UiStep, UiStepState, UiSystemMessage, UiSystemMessageLevel,
UiThinkingTiming, UiTodoOverlay, UiTokenUsage, UiToolResult, UiTurnTerminal, UiUserMessage,
};
use crate::cli::session::GitWorkspaceStatus;
pub(crate) mod reducer;
#[cfg(test)]
#[path = "reducer_tests.rs"]
mod reducer_tests;
mod runtime_pipeline;
pub(crate) mod selectors;
use self::selectors::{TuiSearchTextCache, TuiTranscriptLayoutCache};
use self::selectors::{TuiTranscriptProjectionCache, derive_transcript_projection_cache};
pub(crate) use reducer::{
TuiAction, TuiTerminalUpdate, TuiToolCallUpdate, TuiToolResultUpdate, reduce_tui_state,
};
pub(crate) use runtime_pipeline::apply_runtime_event;
pub(crate) use selectors::{
TuiAssistantTurnEntry, TuiStatusSummary, TuiStickyPromptSummary, TuiTranscriptItem,
TuiUnseenRangeSummary, TuiViewportSummary, TuiVisibleTranscriptWindow, TuiWindowSummary,
select_status_summary, select_transcript_message_anchors,
select_visible_grouped_transcript_window,
};
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiScrollState {
pub(crate) top_message: usize,
pub(crate) viewport_messages: usize,
pub(crate) viewport_height: u16,
pub(crate) viewport_width: u16,
pub(crate) overscan: usize,
pub(crate) follow_tail: bool,
pub(crate) sticky_message: Option<usize>,
pub(crate) last_seen_message: Option<usize>,
}
impl Default for TuiScrollState {
fn default() -> Self {
Self {
top_message: 0,
viewport_messages: 0,
viewport_height: 0,
viewport_width: 0,
overscan: 2,
follow_tail: true,
sticky_message: None,
last_seen_message: None,
}
}
}
impl TuiScrollState {
pub(crate) fn clamp(&mut self, message_count: usize) {
self.top_message = self.top_message.min(message_count.saturating_sub(1));
self.refresh_sticky(message_count);
}
pub(crate) fn snap_to_tail(&mut self, message_count: usize) {
self.top_message = message_count.saturating_sub(1);
self.refresh_sticky(message_count);
}
pub(crate) fn sync_viewport(&mut self, viewport_height: u16, viewport_width: u16) {
self.viewport_messages = viewport_height as usize;
self.viewport_height = viewport_height;
self.viewport_width = viewport_width;
}
pub(crate) fn clamp_to_anchors(&mut self, anchors: &[usize]) {
if anchors.is_empty() {
self.top_message = 0;
self.sticky_message = None;
return;
}
let anchor_index = anchor_index_for_message(anchors, self.top_message);
self.top_message = anchors[anchor_index];
self.refresh_sticky_from_anchors(anchors, anchor_index);
}
pub(crate) fn snap_to_tail_anchors(&mut self, anchors: &[usize]) {
if let Some(anchor) = anchors.last().copied() {
self.top_message = anchor;
self.refresh_sticky_from_anchors(anchors, anchors.len().saturating_sub(1));
} else {
self.top_message = 0;
self.sticky_message = None;
}
}
fn refresh_sticky(&mut self, message_count: usize) {
self.sticky_message = if self.follow_tail || message_count == 0 || self.top_message == 0 {
None
} else {
Some(self.top_message.saturating_sub(1))
};
}
fn refresh_sticky_from_anchors(&mut self, anchors: &[usize], anchor_index: usize) {
self.sticky_message = if self.follow_tail || anchors.is_empty() || anchor_index == 0 {
None
} else {
Some(anchors[anchor_index.saturating_sub(1)])
};
}
fn refresh_seen_tail(&mut self, message_count: usize) {
if message_count == 0 {
self.last_seen_message = None;
return;
}
let tail_message = message_count.saturating_sub(1);
self.last_seen_message = if self.follow_tail {
Some(tail_message)
} else {
Some(self.last_seen_message.unwrap_or(tail_message).min(tail_message))
};
}
}
fn anchor_index_for_message(anchors: &[usize], message_index: usize) -> usize {
anchors.iter().rposition(|anchor| *anchor <= message_index).unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiStatusState {
pub(crate) session_title: String,
pub(crate) provider_name: Option<String>,
pub(crate) model_name: Option<String>,
pub(crate) turn_terminal: UiTurnTerminal,
pub(crate) last_error: Option<String>,
}
impl Default for TuiStatusState {
fn default() -> Self {
Self {
session_title: String::new(),
provider_name: None,
model_name: None,
turn_terminal: UiTurnTerminal::Pending,
last_error: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct TuiTaskState {
pub(crate) pending_questions: Vec<UiQuestionOverlay>,
pub(crate) todo_overlay: Option<UiTodoOverlay>,
pub(crate) sync_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct TuiPersistedMessage {
pub(crate) raw_message_id: Option<String>,
pub(crate) think_timing: Vec<UiThinkingTiming>,
pub(crate) tool_payload: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedToolMessage {
tool_name: String,
text: String,
is_error: bool,
}
fn parse_tool_message_payload(raw: &str) -> ParsedToolMessage {
let trimmed = raw.trim();
let (tool_name, payload_text) = match trimmed.split_once('\n') {
Some((header, payload)) => {
let parsed_name = header
.trim()
.strip_prefix("tool ")
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or("tool");
(parsed_name.to_string(), payload.trim().to_string())
}
None => ("tool".to_string(), trimmed.to_string()),
};
if let Ok(value) = serde_json::from_str::<Value>(&payload_text) {
let status = value.get("status").and_then(Value::as_str).unwrap_or("completed");
let is_error = matches!(status, "error" | "denied");
let text = if is_error {
value
.get("error")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.or_else(|| {
value
.get("output")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
})
.unwrap_or(payload_text.as_str())
} else {
value
.get("output")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.unwrap_or(payload_text.as_str())
};
return ParsedToolMessage { tool_name, text: text.to_string(), is_error };
}
ParsedToolMessage { tool_name, text: payload_text, is_error: false }
}
fn serialize_tool_result_message(message: &UiToolResult) -> String {
let payload = if message.is_error {
serde_json::json!({
"status": "error",
"error": message.content,
})
} else {
serde_json::json!({
"status": "completed",
"output": message.content,
})
};
format!("tool {}\n{}\n", message.tool_name, payload)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiSessionPreview {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) updated_ms: u64,
pub(crate) message_count: usize,
pub(crate) call_count: usize,
pub(crate) last_content: Option<String>,
}
impl From<ChatSessionMeta> for TuiSessionPreview {
fn from(value: ChatSessionMeta) -> Self {
Self {
id: value.id,
title: value.title,
updated_ms: value.updated_ms,
message_count: value.message_count,
call_count: value.call_count,
last_content: value.last_content,
}
}
}
impl From<&ChatSessionMeta> for TuiSessionPreview {
fn from(value: &ChatSessionMeta) -> Self {
Self {
id: value.id.clone(),
title: value.title.clone(),
updated_ms: value.updated_ms,
message_count: value.message_count,
call_count: value.call_count,
last_content: value.last_content.clone(),
}
}
}
impl TuiSessionPreview {
pub(crate) fn from_chat_session(session: &ChatSession) -> Self {
Self {
id: session.id.clone(),
title: session.title.clone(),
updated_ms: session.updated_ms,
message_count: session.messages.len(),
call_count: session.calls.len(),
last_content: session.messages.last().map(|message| message.content.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct TuiSessionState {
pub(crate) session_id: Option<String>,
pub(crate) title: String,
pub(crate) created_ms: u64,
pub(crate) updated_ms: u64,
pub(crate) scope: Option<String>,
pub(crate) path: Option<PathBuf>,
pub(crate) preview: Option<TuiSessionPreview>,
pub(crate) persisted_calls: Vec<Value>,
pub(crate) persisted_messages: Vec<TuiPersistedMessage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct TuiProjectContextState {
pub(crate) workspace_root: Option<PathBuf>,
pub(crate) info: String,
pub(crate) git_status: GitWorkspaceStatus,
pub(crate) memory_evidence: Option<UiMemoryEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiModelCatalogEntry {
pub(crate) provider_id: String,
pub(crate) provider_name: String,
pub(crate) model_id: String,
pub(crate) model_name: String,
}
impl TuiModelCatalogEntry {
pub(crate) fn qualified_id(&self) -> String {
format!("{}/{}", self.provider_id, self.model_id)
}
pub(crate) fn suggestion_detail(&self) -> String {
if self.model_name.trim().is_empty() || self.model_name == self.model_id {
format!("供应商: {}", self.provider_name)
} else {
format!("{} · {}", self.provider_name, self.model_name)
}
}
pub(crate) fn matches_query(&self, query: &str) -> bool {
let query = query.trim();
if query.is_empty() {
return true;
}
let query = query.to_ascii_lowercase();
self.provider_id.to_ascii_lowercase().contains(&query)
|| self.provider_name.to_ascii_lowercase().contains(&query)
|| self.model_id.to_ascii_lowercase().contains(&query)
|| self.model_name.to_ascii_lowercase().contains(&query)
|| self.qualified_id().to_ascii_lowercase().contains(&query)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct TuiRuntimeState {
pub(crate) thinking_open: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct TuiState {
pub(crate) messages: Vec<UiMessage>,
pub(crate) search_index: TuiSearchTextCache,
pub(crate) transcript: TuiTranscriptProjectionCache,
pub(crate) transcript_layout: TuiTranscriptLayoutCache,
pub(crate) scroll: TuiScrollState,
pub(crate) status: TuiStatusState,
pub(crate) tasks: TuiTaskState,
pub(crate) prompt: PromptState,
pub(crate) overlays: OverlayState,
pub(crate) project: TuiProjectContextState,
pub(crate) session: TuiSessionState,
pub(crate) runtime: TuiRuntimeState,
pub(crate) model_catalog: Vec<TuiModelCatalogEntry>,
}
impl TuiState {
pub(crate) fn from_chat_session(session: &ChatSession) -> Self {
let mut state = Self::default();
let active_terminal = terminal_from_snapshot(session);
let last_assistant_index = session
.messages
.iter()
.enumerate()
.filter_map(|(index, message)| (message.role == ChatRole::Assistant).then_some(index))
.next_back();
state.session.session_id = Some(session.id.clone());
state.session.title = session.title.clone();
state.session.created_ms = session.created_ms;
state.session.updated_ms = session.updated_ms;
state.session.preview = Some(TuiSessionPreview::from_chat_session(session));
state.session.persisted_calls = session.calls.clone();
state.status.session_title = session.title.clone();
state.status.turn_terminal = active_terminal.clone();
state.status.model_name = session.steps.last().and_then(|step| step.model.clone());
for (index, message) in session.messages.iter().enumerate() {
let raw_message_id = session.message_ids.get(index).cloned().flatten();
let base = snapshot_message_base(session.id.as_str(), index, raw_message_id.clone());
let ui_message = match message.role {
ChatRole::User => {
UiMessage::User(UiUserMessage { base, text: message.content.clone() })
}
ChatRole::Assistant => UiMessage::Assistant(UiAssistantMessage {
base,
text: message.content.clone(),
usage: UiTokenUsage::default(),
step_count: if Some(index) == last_assistant_index {
session.steps.len()
} else {
0
},
terminal: if Some(index) == last_assistant_index {
active_terminal.clone()
} else {
UiTurnTerminal::Done { finish_reason: None }
},
model: if Some(index) == last_assistant_index {
state.status.model_name.clone()
} else {
None
},
}),
ChatRole::Tool => {
let parsed = parse_tool_message_payload(&message.content);
UiMessage::ToolResult(UiToolResult {
base,
call_id: None,
tool_name: parsed.tool_name,
content: parsed.text,
is_error: parsed.is_error,
})
}
ChatRole::System => UiMessage::System(UiSystemMessage {
base,
text: message.content.clone(),
level: UiSystemMessageLevel::Info,
}),
};
state.messages.push(ui_message);
state.session.persisted_messages.push(TuiPersistedMessage {
raw_message_id,
think_timing: message.think_timing.iter().map(UiThinkingTiming::from).collect(),
tool_payload: (message.role == ChatRole::Tool).then(|| message.content.clone()),
});
}
for step in &session.steps {
state
.messages
.push(UiMessage::Step(ui_step_from_snapshot_step(session.id.as_str(), step)));
}
state.refresh_search_index();
state.refresh_session_preview();
state.refresh_transcript_projection();
state.clamp_scroll();
state
}
pub(crate) fn to_chat_session(&self) -> ChatSession {
let mut messages = Vec::new();
let mut message_ids = Vec::new();
let mut steps = Vec::new();
let mut persisted_index = 0usize;
for message in &self.messages {
if let Some(chat_message) = snapshot_chat_message_from_ui_message(
message,
self.session.persisted_messages.get(persisted_index),
) {
message_ids.push(
self.session
.persisted_messages
.get(persisted_index)
.and_then(|metadata| metadata.raw_message_id.clone())
.or_else(|| raw_message_id_from_ui_message(message)),
);
messages.push(chat_message);
persisted_index = persisted_index.saturating_add(1);
continue;
}
if let UiMessage::Step(step) = message {
let mut snapshot_step = snapshot_step_from_ui_step(step);
snapshot_step.index =
u32::try_from(steps.len().saturating_add(1)).unwrap_or(u32::MAX);
steps.push(snapshot_step);
}
}
ChatSession {
id: self.session.session_id.clone().unwrap_or_default(),
title: self.session.title.clone(),
messages,
message_ids,
calls: self.session.persisted_calls.clone(),
steps,
created_ms: self.session.created_ms,
updated_ms: self.session.updated_ms,
}
}
pub(crate) fn append_message(&mut self, mut message: UiMessage) {
attach_session_id(self.session.session_id.as_deref(), &mut message);
if let Some(metadata) = persisted_message_from_ui_message(&message) {
self.session.persisted_messages.push(metadata);
}
self.messages.push(message);
let message_index = self.messages.len().saturating_sub(1);
self.refresh_search_index_for_message(message_index);
self.refresh_transcript_projection();
self.refresh_session_preview();
self.clamp_scroll();
}
pub(crate) fn refresh_search_index(&mut self) {
self.search_index.rebuild(&self.messages);
}
pub(crate) fn refresh_search_index_for_message(&mut self, message_index: usize) {
self.search_index.refresh_message(&self.messages, message_index);
}
pub(crate) fn refresh_transcript_projection(&mut self) {
self.transcript = derive_transcript_projection_cache(&self.messages);
self.transcript_layout.clear();
self.refresh_transcript_layout_for_current_width();
}
pub(crate) fn refresh_transcript_layout_for_current_width(&mut self) {
let content_width = self.scroll.viewport_width;
self.transcript_layout.rebuild_width(&self.messages, &self.transcript, content_width);
}
pub(crate) fn refresh_transcript_layout_for_message(&mut self, message_index: usize) {
self.transcript_layout.refresh_message(&self.messages, &self.transcript, message_index);
}
pub(crate) fn refresh_session_preview(&mut self) {
let Some(session_id) = self.session.session_id.as_ref() else {
self.session.preview = None;
self.status.session_title = self.session.title.clone();
return;
};
self.status.session_title = self.session.title.clone();
self.session.preview = Some(TuiSessionPreview {
id: session_id.clone(),
title: self.session.title.clone(),
updated_ms: self.session.updated_ms,
message_count: self.session.persisted_messages.len(),
call_count: self.session.persisted_calls.len(),
last_content: self.messages.iter().rev().find_map(last_content_from_ui_message),
});
}
pub(crate) fn clear_messages(&mut self) {
self.messages.clear();
self.search_index = TuiSearchTextCache::default();
self.transcript = TuiTranscriptProjectionCache::default();
self.transcript_layout.clear();
self.scroll.top_message = 0;
self.scroll.follow_tail = true;
self.scroll.sticky_message = None;
self.scroll.last_seen_message = None;
self.status.turn_terminal = UiTurnTerminal::Pending;
self.status.last_error = None;
self.tasks.pending_questions.clear();
self.tasks.todo_overlay = None;
self.tasks.sync_error = None;
self.overlays.clear();
self.runtime = TuiRuntimeState::default();
self.session.persisted_messages.clear();
self.session.persisted_calls.clear();
self.refresh_session_preview();
}
pub(crate) fn clamp_scroll(&mut self) {
let anchors = self.transcript.anchors();
if self.scroll.follow_tail {
self.scroll.snap_to_tail_anchors(anchors);
} else {
self.scroll.clamp_to_anchors(anchors);
}
self.scroll.refresh_seen_tail(self.messages.len());
}
}
pub(super) fn message_base_mut(message: &mut UiMessage) -> &mut UiMessageBase {
match message {
UiMessage::User(message) => &mut message.base,
UiMessage::Assistant(message) => &mut message.base,
UiMessage::ToolCall(message) => &mut message.base,
UiMessage::ToolResult(message) => &mut message.base,
UiMessage::Thinking(message) => &mut message.base,
UiMessage::Step(message) => &mut message.base,
UiMessage::System(message) => &mut message.base,
UiMessage::Error(message) => &mut message.base,
}
}
pub(super) fn is_persistable_chat_message(message: &UiMessage) -> bool {
match message {
UiMessage::User(_) | UiMessage::Assistant(_) | UiMessage::ToolResult(_) => true,
UiMessage::System(message) => !is_ui_local_message_id(message.base.id.as_str()),
_ => false,
}
}
pub(super) fn persisted_slot_index_for_message_index(
messages: &[UiMessage],
message_index: usize,
) -> Option<usize> {
if message_index >= messages.len() || !is_persistable_chat_message(&messages[message_index]) {
return None;
}
let mut persisted_index = 0usize;
for (index, message) in messages.iter().enumerate() {
if !is_persistable_chat_message(message) {
continue;
}
if index == message_index {
return Some(persisted_index);
}
persisted_index = persisted_index.saturating_add(1);
}
None
}
pub(super) fn raw_message_id_from_ui_message(message: &UiMessage) -> Option<String> {
message.id().as_str().strip_prefix("gateway:").map(ToOwned::to_owned)
}
fn attach_session_id(session_id: Option<&str>, message: &mut UiMessage) {
let Some(session_id) = session_id else {
return;
};
let base = message_base_mut(message);
if base.session_id.is_none() {
base.session_id = Some(session_id.to_string());
}
}
fn snapshot_message_base(
session_id: &str,
index: usize,
raw_message_id: Option<String>,
) -> UiMessageBase {
let base = match raw_message_id {
Some(message_id) => UiMessageBase::new(UiMessageId::gateway(message_id)),
None => UiMessageBase::new(UiMessageId::local(format!("snapshot-{index}"))),
};
base.with_session_id(session_id)
}
fn ui_step_from_snapshot_step(session_id: &str, step: &ChatSessionStep) -> UiStep {
let mut base = UiMessageBase::new(UiMessageId::local(format!("snapshot-step-{}", step.index)))
.with_session_id(session_id)
.with_created_ms(step.started_ms);
if let Some(path) = step.start_snapshot_path.as_deref() {
base.parent_id = Some(UiMessageId::local(path.to_string()));
}
UiStep {
base,
step_index: step.index,
started_ms: step.started_ms,
finished_ms: step.finished_ms,
usage: UiTokenUsage::from(&step.usage),
finish_reason: step.finish_reason.clone(),
model: step.model.clone(),
state: step_state_from_snapshot(step),
}
}
fn step_state_from_snapshot(step: &ChatSessionStep) -> UiStepState {
if step.finished_ms.is_none() {
return UiStepState::Running;
}
let finish_reason = normalize_optional_string(step.finish_reason.clone());
if contains_marker(finish_reason.as_deref(), &["cancelled", "canceled", "aborted"]) {
return UiStepState::Cancelled;
}
if contains_marker(finish_reason.as_deref(), &["error", "failed", "failure"]) {
return UiStepState::Failed;
}
UiStepState::Complete
}
fn terminal_from_snapshot(session: &ChatSession) -> UiTurnTerminal {
if let Some(step) = session.steps.last() {
if step.finished_ms.is_none() {
return UiTurnTerminal::Streaming;
}
let finish_reason = normalize_optional_string(step.finish_reason.clone());
if contains_marker(finish_reason.as_deref(), &["timeout", "timed out", "deadline exceeded"])
{
return UiTurnTerminal::TimedOut {
message: finish_reason.unwrap_or_else(|| "session timed out".to_string()),
};
}
if contains_marker(
finish_reason.as_deref(),
&["cancelled", "canceled", "interrupted", "aborted"],
) {
return UiTurnTerminal::Cancelled { reason: finish_reason };
}
if contains_marker(finish_reason.as_deref(), &["error", "failed", "failure"]) {
return UiTurnTerminal::Error {
message: finish_reason.unwrap_or_else(|| "session failed".to_string()),
};
}
return UiTurnTerminal::Done { finish_reason };
}
match session.messages.last().map(|message| message.role) {
Some(ChatRole::Assistant | ChatRole::System | ChatRole::Tool) => {
UiTurnTerminal::Done { finish_reason: None }
}
Some(ChatRole::User) | None => UiTurnTerminal::Pending,
}
}
fn persisted_message_from_ui_message(message: &UiMessage) -> Option<TuiPersistedMessage> {
is_persistable_chat_message(message).then(|| TuiPersistedMessage {
raw_message_id: raw_message_id_from_ui_message(message),
think_timing: Vec::new(),
tool_payload: match message {
UiMessage::ToolResult(message) => Some(serialize_tool_result_message(message)),
_ => None,
},
})
}
fn snapshot_chat_message_from_ui_message(
message: &UiMessage,
metadata: Option<&TuiPersistedMessage>,
) -> Option<session_ui::ChatMessage> {
if !is_persistable_chat_message(message) {
return None;
}
match message {
UiMessage::User(message) => Some(session_ui::ChatMessage {
role: ChatRole::User,
content: message.text.clone(),
think_timing: Vec::new(),
}),
UiMessage::Assistant(message) => Some(session_ui::ChatMessage {
role: ChatRole::Assistant,
content: message.text.clone(),
think_timing: metadata
.map(|metadata| {
metadata.think_timing.iter().map(snapshot_think_timing).collect::<Vec<_>>()
})
.unwrap_or_default(),
}),
UiMessage::ToolResult(message) => Some(session_ui::ChatMessage {
role: ChatRole::Tool,
content: metadata
.and_then(|metadata| metadata.tool_payload.clone())
.unwrap_or_else(|| serialize_tool_result_message(message)),
think_timing: Vec::new(),
}),
UiMessage::System(message) => Some(session_ui::ChatMessage {
role: ChatRole::System,
content: message.text.clone(),
think_timing: Vec::new(),
}),
_ => None,
}
}
fn snapshot_step_from_ui_step(step: &UiStep) -> ChatSessionStep {
ChatSessionStep {
index: step.step_index,
started_ms: step.started_ms,
finished_ms: step.finished_ms,
start_snapshot_path: step.base.parent_id.as_ref().map(|parent| parent.as_str().to_string()),
finish_snapshot_path: None,
usage: snapshot_token_usage(&step.usage),
cost_usd: None,
finish_reason: step.finish_reason.clone(),
model: step.model.clone(),
}
}
fn snapshot_token_usage(usage: &UiTokenUsage) -> session_ui::TokenUsage {
session_ui::TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
}
}
fn snapshot_think_timing(timing: &UiThinkingTiming) -> session_ui::ThinkTiming {
session_ui::ThinkTiming {
start_ms: timing.start_ms,
end_ms: timing.end_ms,
last_update_ms: timing.last_update_ms,
}
}
fn last_content_from_ui_message(message: &UiMessage) -> Option<String> {
match message {
UiMessage::User(message) => Some(message.text.clone()),
UiMessage::Assistant(message) => Some(message.text.clone()),
UiMessage::ToolResult(message) => Some(if message.content.trim().is_empty() {
message.tool_name.clone()
} else {
format!("{}: {}", message.tool_name, message.content)
}),
UiMessage::System(message) if !is_ui_local_message_id(message.base.id.as_str()) => {
Some(message.text.clone())
}
_ => None,
}
}
fn is_ui_local_message_id(message_id: &str) -> bool {
message_id.starts_with("local:")
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
}
fn contains_marker(value: Option<&str>, markers: &[&str]) -> bool {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return false;
};
let normalized = value.to_ascii_lowercase();
markers.iter().any(|marker| normalized.contains(marker))
}