Files
1jehuang--jcode/crates/jcode-desktop/src/single_session/transcript.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

997 lines
39 KiB
Rust

use super::*;
impl SingleSessionApp {
pub(crate) fn body_lines(&self) -> Vec<String> {
self.body_styled_lines()
.into_iter()
.map(|line| line.text)
.collect()
}
pub(crate) fn body_styled_lines(&self) -> Vec<SingleSessionStyledLine> {
if let Some(stdin_response) = &self.stdin_response {
return stdin_response_styled_lines(stdin_response);
}
self.body_styled_lines_without_inline_widgets()
}
pub(crate) fn body_styled_lines_without_inline_widgets(&self) -> Vec<SingleSessionStyledLine> {
if !self.messages.is_empty() || !self.streaming_response.is_empty() || self.error.is_some()
{
return self.transcript_styled_lines(true);
}
if self.is_welcome_timeline_visible() {
if let Some(status) = &self.status
&& self.session.is_none()
&& !self.model_picker.open
&& !self.show_session_info
{
return vec![styled_line(status.clone(), SingleSessionLineStyle::Status)];
}
if self.welcome.recovery_session_count > 0 {
return welcome_recovery_styled_lines(self.welcome.recovery_session_count);
}
return Vec::new();
}
if let Some(status) = &self.status
&& self.session.is_none()
&& !self.model_picker.open
&& !self.show_session_info
{
return vec![styled_line(status.clone(), SingleSessionLineStyle::Status)];
}
single_session_styled_lines(self.session.as_ref())
}
pub(crate) fn body_styled_lines_for_tick(&self, _tick: u64) -> Vec<SingleSessionStyledLine> {
self.body_styled_lines()
}
pub(crate) fn body_styled_lines_without_streaming_response(
&self,
) -> Option<Vec<SingleSessionStyledLine>> {
if self.stdin_response.is_some()
|| self.session_switcher.open
|| self.model_picker.open
|| self.show_help
|| self.error.is_some()
{
return None;
}
if self.messages.is_empty() && self.streaming_response.is_empty() {
return None;
}
Some(self.transcript_styled_lines(false))
}
pub(crate) fn streaming_response_styled_lines(&self) -> Vec<SingleSessionStyledLine> {
self.streaming_response_revealed_styled_lines(self.streaming_response.len())
}
/// Styled lines for the first `revealed_bytes` of the streaming response.
/// Drives the adaptive streaming reveal: the renderer grows the visible
/// prefix smoothly instead of popping whole provider chunks in at once.
pub(crate) fn streaming_response_revealed_styled_lines(
&self,
revealed_bytes: usize,
) -> Vec<SingleSessionStyledLine> {
let mut end = revealed_bytes.min(self.streaming_response.len());
while end > 0 && !self.streaming_response.is_char_boundary(end) {
end -= 1;
}
let revealed = self.streaming_response[..end].trim_end();
let mut lines = Vec::new();
if !revealed.is_empty() {
append_streaming_assistant_lines(&mut lines, revealed);
}
lines
}
pub(crate) fn transcript_styled_lines(
&self,
include_streaming_response: bool,
) -> Vec<SingleSessionStyledLine> {
let mut lines = Vec::new();
let mut user_turn = 1;
let mut message_index = 0;
while message_index < self.messages.len() {
if !lines.is_empty() {
lines.push(blank_styled_line());
}
let message = &self.messages[message_index];
if message.role() == SingleSessionRole::Tool {
let group_start = message_index;
while message_index < self.messages.len()
&& self.messages[message_index].role() == SingleSessionRole::Tool
{
message_index += 1;
}
let tool_messages = &self.messages[group_start..message_index];
let group_contains_active_tool = self
.tool
.active_message_index
.is_some_and(|index| (group_start..message_index).contains(&index));
if tool_messages.len() > 1 && !group_contains_active_tool {
append_tool_group_summary(&mut lines, tool_messages);
} else {
for (offset, tool_message) in tool_messages.iter().enumerate() {
let is_active_tool = self.tool.active_message_index
== Some(group_start.saturating_add(offset));
let tool_run =
self.tool_run_for_message_index(group_start.saturating_add(offset));
append_chat_message_lines(
&mut lines,
tool_message,
&mut user_turn,
is_active_tool,
if is_active_tool {
Some(self.tool.input_buffer.as_str())
} else {
None
},
tool_run,
);
}
}
continue;
}
append_chat_message_lines(&mut lines, message, &mut user_turn, false, None, None);
message_index += 1;
}
if include_streaming_response && !self.streaming_response.is_empty() {
if !lines.is_empty() {
lines.push(blank_styled_line());
}
append_streaming_assistant_lines(&mut lines, self.streaming_response.trim_end());
}
if let Some(error) = &self.error {
if !lines.is_empty() {
lines.push(blank_styled_line());
}
lines.push(styled_line(
format!("error: {error}"),
SingleSessionLineStyle::Error,
));
}
lines
}
pub(crate) fn rendered_body_cache_key(&self, size: (u32, u32)) -> u64 {
let mut hasher = DefaultHasher::new();
size.hash(&mut hasher);
self.session
.as_ref()
.map(|session| {
(
session.session_id.as_str(),
session.title.as_str(),
session.subtitle.as_str(),
session.detail.as_str(),
session.preview_lines.as_slice(),
session.detail_lines.as_slice(),
)
})
.hash(&mut hasher);
hash_messages_cache_fingerprint(&self.messages, &mut hasher);
hash_text_cache_fingerprint(&self.streaming_response, &mut hasher);
hash_tool_cache_fingerprint(&self.tool, &mut hasher);
self.status.hash(&mut hasher);
self.error.hash(&mut hasher);
self.show_help.hash(&mut hasher);
self.show_session_info.hash(&mut hasher);
self.model_picker.open.hash(&mut hasher);
self.model_picker.filter.hash(&mut hasher);
self.model_picker.selected.hash(&mut hasher);
hash_session_switcher_cache_state(&self.session_switcher, &mut hasher);
self.stdin_response.hash(&mut hasher);
self.welcome.name.hash(&mut hasher);
self.welcome.recovery_session_count.hash(&mut hasher);
self.welcome.continuation_suggestion.hash(&mut hasher);
self.welcome.timeline.hash(&mut hasher);
self.welcome.hero_phrase_index.hash(&mut hasher);
self.view.text_scale.to_bits().hash(&mut hasher);
hasher.finish()
}
pub(crate) fn rendered_body_static_cache_key(&self, size: (u32, u32)) -> u64 {
let mut hasher = DefaultHasher::new();
size.hash(&mut hasher);
self.session
.as_ref()
.map(|session| {
(
session.session_id.as_str(),
session.title.as_str(),
session.subtitle.as_str(),
session.detail.as_str(),
session.preview_lines.as_slice(),
session.detail_lines.as_slice(),
)
})
.hash(&mut hasher);
hash_messages_cache_fingerprint(&self.messages, &mut hasher);
hash_tool_cache_fingerprint(&self.tool, &mut hasher);
self.status.hash(&mut hasher);
self.error.hash(&mut hasher);
self.show_help.hash(&mut hasher);
self.show_session_info.hash(&mut hasher);
self.model_picker.open.hash(&mut hasher);
self.model_picker.filter.hash(&mut hasher);
self.model_picker.selected.hash(&mut hasher);
hash_session_switcher_cache_state(&self.session_switcher, &mut hasher);
self.stdin_response.hash(&mut hasher);
self.welcome.name.hash(&mut hasher);
self.welcome.recovery_session_count.hash(&mut hasher);
self.welcome.timeline.hash(&mut hasher);
self.welcome.hero_phrase_index.hash(&mut hasher);
self.view.text_scale.to_bits().hash(&mut hasher);
hasher.finish()
}
pub(crate) fn apply_session_event(&mut self, event: DesktopSessionEvent) {
match event {
DesktopSessionEvent::Status(status) => self.set_backend_status(status),
DesktopSessionEvent::Reloading { .. } => {
self.set_status(SingleSessionStatus::ServerReloading);
self.is_processing = true;
self.runtime.reload_phase = ReloadPhase::AwaitingReconnect;
}
DesktopSessionEvent::ReloadProgress {
step,
message,
success,
output,
} => {
let marker = match success {
Some(true) => "✓ ",
Some(false) => "✗ ",
None => "",
};
let mut line = format!("reload {step}: {marker}{message}");
if let Some(output) = output.as_deref().filter(|output| !output.trim().is_empty()) {
line.push_str(" — ");
line.push_str(output.trim());
}
self.messages.push(SingleSessionMessage::meta(line));
self.set_status(SingleSessionStatus::Info(format!("reload: {message}")));
}
DesktopSessionEvent::RuntimeMetadata {
connection_type,
status_detail,
upstream_provider,
} => {
if let Some(connection_type) = connection_type {
self.runtime_settings.connection_type = Some(connection_type);
}
if let Some(upstream_provider) = upstream_provider {
self.runtime_settings.upstream_provider = Some(upstream_provider);
}
if let Some(status_detail) = status_detail {
self.runtime_settings.status_detail = Some(status_detail.clone());
self.set_status(SingleSessionStatus::Info(status_detail));
}
}
DesktopSessionEvent::TokenUsage {
input,
output,
cache_read_input,
cache_creation_input,
} => {
self.runtime_settings.token_usage = Some(SingleSessionTokenUsage {
input,
output,
cache_read_input,
cache_creation_input,
});
}
DesktopSessionEvent::SystemNotice { title, message } => {
let line = message
.as_deref()
.filter(|message| !message.trim().is_empty())
.map(|message| format!("{title}: {}", message.trim()))
.unwrap_or(title.clone());
self.messages.push(SingleSessionMessage::meta(line));
self.set_status(SingleSessionStatus::Info(title));
}
DesktopSessionEvent::SessionCloseRequested { reason } => {
self.finish_streaming_response();
self.is_processing = false;
self.stdin_response = None;
self.runtime.session_handle = None;
self.set_status(SingleSessionStatus::Info(
"session close requested".to_string(),
));
self.messages.push(SingleSessionMessage::meta(format!(
"session close requested by server: {reason}"
)));
}
DesktopSessionEvent::Reloaded { session_id } => {
self.live_session_id = Some(session_id);
self.set_status(SingleSessionStatus::ServerReconnected);
self.is_processing = true;
self.runtime.reload_phase = ReloadPhase::Stable;
}
DesktopSessionEvent::SessionStarted { session_id } => {
self.live_session_id = Some(session_id);
self.set_status(SingleSessionStatus::Connected);
}
DesktopSessionEvent::SessionRenamed {
title,
display_title,
} => {
if let Some(session) = &mut self.session {
session.title = display_title.clone();
}
let message = if title.is_some() {
format!("renamed session to {display_title}")
} else {
format!("cleared session name; title is now {display_title}")
};
self.messages.push(SingleSessionMessage::meta(message));
self.set_status(SingleSessionStatus::Info(if title.is_some() {
"session renamed".to_string()
} else {
"session name cleared".to_string()
}));
}
DesktopSessionEvent::TextDelta(text) => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.streaming_response.push_str(&text);
self.set_status(SingleSessionStatus::Receiving);
}
DesktopSessionEvent::TextReplace(text) => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.streaming_response = text;
self.set_status(SingleSessionStatus::Receiving);
}
DesktopSessionEvent::ToolStarted { id, name } => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.finish_streaming_response();
self.collapse_active_tool_message();
self.tool.input_buffer.clear();
self.set_status(SingleSessionStatus::ToolPreparing(name.clone()));
self.messages
.push(SingleSessionMessage::tool(format!("▾ {name} preparing")));
self.tool.active_message_index = Some(self.messages.len().saturating_sub(1));
let message_index = self.messages.len().saturating_sub(1);
self.start_tool_run(id, &name, message_index);
}
DesktopSessionEvent::ToolExecuting { id, name } => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.finish_streaming_response();
self.set_status(SingleSessionStatus::ToolUsing(name.clone()));
self.update_tool_run_state(id, &name, SingleSessionToolVisualState::Running, None);
self.replace_active_tool_header(&format!("▾ {name} running"));
}
DesktopSessionEvent::ToolInput { id, delta } => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.finish_streaming_response();
self.append_tool_run_input(id, &delta);
self.append_active_tool_input(&delta);
}
DesktopSessionEvent::ToolFinished {
id,
name,
summary,
is_error,
} => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.finish_streaming_response();
self.set_status(SingleSessionStatus::ToolFinished {
name: name.clone(),
is_error,
});
let marker = if is_error { "failed" } else { "done" };
let line = format!("▾ {name} {marker}: {summary}");
let finished_call_id = self.update_tool_run_state(
id,
&name,
if is_error {
SingleSessionToolVisualState::Failed
} else {
SingleSessionToolVisualState::Succeeded
},
Some(summary.clone()),
);
self.flush_active_tool_input_to_message();
if let Some(index) = self.tool.active_message_index
&& let Some(message) = self.messages.get_mut(index)
&& message.role() == SingleSessionRole::Tool
{
let replacement =
merge_tool_finish_with_existing_context(message.content(), &line);
message.set_content(replacement);
} else {
self.messages.push(SingleSessionMessage::tool(line));
let message_index = self.messages.len().saturating_sub(1);
self.tool.active_message_index = Some(message_index);
if let Some(run) = self
.tool
.runs
.iter_mut()
.find(|run| run.call_id == finished_call_id)
{
run.message_index = message_index;
}
}
}
DesktopSessionEvent::ModelChanged {
model,
provider_name,
error,
} => {
if let Some(error) = error {
self.set_status(SingleSessionStatus::ModelSwitchFailed);
self.model_picker.apply_error(error.clone());
self.messages.push(SingleSessionMessage::meta(format!(
"model switch failed: {error}"
)));
return;
}
let label = provider_name
.as_deref()
.filter(|provider| !provider.is_empty())
.map(|provider| format!("{provider} · {model}"))
.unwrap_or_else(|| model.clone());
self.model_picker
.apply_model_change(model.clone(), provider_name.clone());
self.set_status(SingleSessionStatus::ModelSelected(label.clone()));
self.messages.push(SingleSessionMessage::meta(format!(
"model switched to {label}"
)));
}
DesktopSessionEvent::ModelCatalog {
current_model,
provider_name,
models,
reasoning_effort,
service_tier,
compaction_mode,
} => {
if let Some(reasoning_effort) = reasoning_effort {
self.runtime_settings.reasoning_effort = Some(reasoning_effort);
}
if let Some(service_tier) = service_tier {
self.runtime_settings.service_tier = Some(service_tier);
}
if let Some(compaction_mode) = compaction_mode {
self.runtime_settings.compaction_mode = Some(compaction_mode);
}
self.model_picker
.apply_catalog(current_model, provider_name, models);
self.set_status(SingleSessionStatus::ModelsLoaded);
}
DesktopSessionEvent::ModelCatalogError { error } => {
self.model_picker.apply_error(error.clone());
self.set_status(SingleSessionStatus::ModelPickerError);
}
DesktopSessionEvent::StdinRequest {
request_id,
prompt,
is_password,
tool_call_id,
} => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.set_status(SingleSessionStatus::InteractiveInputRequested);
self.close_inline_widgets();
let raw_prompt = prompt.trim();
let display_prompt = if raw_prompt.is_empty() {
"interactive input requested"
} else {
raw_prompt
};
self.stdin_response = Some(StdinResponseState {
request_id: request_id.clone(),
prompt: display_prompt.to_string(),
is_password,
tool_call_id: tool_call_id.clone(),
input: String::new(),
});
self.mark_tool_stdin_prompt(&tool_call_id, display_prompt);
let sensitive = if is_password { " password" } else { "" };
self.messages.push(SingleSessionMessage::meta(format!(
"interactive{sensitive} input requested by {tool_call_id} ({request_id}): {display_prompt}"
)));
}
DesktopSessionEvent::Done => {
if self.runtime.reload_phase == ReloadPhase::AwaitingReconnect {
self.set_status(SingleSessionStatus::ServerReloading);
self.is_processing = true;
return;
}
self.finish_streaming_response();
self.is_processing = false;
self.stdin_response = None;
self.runtime.session_handle = None;
self.tool.active_message_index = None;
self.tool.active_call_id = None;
self.tool.input_buffer.clear();
self.clear_tool_stdin_prompts();
self.set_status(SingleSessionStatus::Ready);
}
DesktopSessionEvent::Error(error) => {
self.runtime.reload_phase = ReloadPhase::Stable;
self.finish_streaming_response();
self.is_processing = false;
self.stdin_response = None;
self.runtime.session_handle = None;
self.tool.active_message_index = None;
self.tool.active_call_id = None;
self.tool.input_buffer.clear();
self.clear_tool_stdin_prompts();
self.set_status(SingleSessionStatus::Error);
self.error = Some(error);
}
}
}
pub(crate) fn set_session_handle(&mut self, handle: DesktopSessionHandle) {
self.runtime.session_handle = Some(handle);
}
pub(crate) fn cancel_generation(&mut self) -> bool {
let Some(handle) = &self.runtime.session_handle else {
return false;
};
match handle.cancel() {
Ok(()) => {
self.stdin_response = None;
self.clear_tool_stdin_prompts();
self.set_status(SingleSessionStatus::Cancelling);
true
}
Err(error) => {
self.error = Some(format!("{error:#}"));
self.is_processing = false;
self.stdin_response = None;
self.clear_tool_stdin_prompts();
self.runtime.session_handle = None;
true
}
}
}
pub(crate) fn scroll_body_lines(&mut self, lines: impl Into<f64>) {
let lines = lines.into() as f32;
if !lines.is_finite() || lines.abs() < f32::EPSILON {
return;
}
self.body_scroll_lines = (self.body_scroll_lines + lines).max(0.0);
}
pub(crate) fn scroll_body_to_top(&mut self) {
self.body_scroll_lines = self
.body_styled_lines_without_inline_widgets()
.len()
.saturating_sub(1) as f32;
}
pub(crate) fn scroll_body_to_bottom(&mut self) {
self.body_scroll_lines = 0.0;
}
pub(crate) fn copy_latest_code_block(&mut self) -> KeyOutcome {
if let Some(text) = self
.latest_rich_code_block_text()
.filter(|text| !text.trim().is_empty())
{
return KeyOutcome::CopyText {
text,
success_notice: "copied latest code block",
};
}
self.set_status(SingleSessionStatus::Info(
"no code block to copy".to_string(),
));
KeyOutcome::Redraw
}
pub(crate) fn copy_transcript(&mut self) -> KeyOutcome {
if let Some(text) = self
.copy_rich_transcript_text(desktop_rich_text::TranscriptCopyMode::TranscriptPlainText)
.filter(|text| !text.trim().is_empty())
{
return KeyOutcome::CopyText {
text,
success_notice: "copied transcript",
};
}
self.set_status(SingleSessionStatus::Info(
"no transcript to copy".to_string(),
));
KeyOutcome::Redraw
}
pub(crate) fn latest_assistant_response(&self) -> Option<String> {
if !self.streaming_response.trim().is_empty() {
return Some(self.streaming_response.trim().to_string());
}
self.messages
.iter()
.rev()
.find(|message| message.role() == SingleSessionRole::Assistant)
.map(|message| message.content().trim().to_string())
.filter(|message| !message.is_empty())
}
pub(crate) fn rich_transcript_document(&self) -> desktop_rich_text::RichTranscriptDocument {
desktop_rich_text::build_rich_transcript(
&self.rich_transcript_messages(true),
&desktop_rich_text::RichTranscriptBuildOptions::default(),
)
}
pub(crate) fn search_rich_transcript(
&self,
query: &str,
) -> Vec<desktop_rich_text::TranscriptSearchMatch> {
let document = self.rich_transcript_document();
desktop_rich_text::search_transcript(&document, query, false)
}
pub(crate) fn copy_rich_transcript_text(
&self,
mode: desktop_rich_text::TranscriptCopyMode,
) -> Option<String> {
let document = self.rich_transcript_document();
desktop_rich_text::copy_transcript_text(&document, mode)
}
pub(crate) fn latest_rich_code_block_text(&self) -> Option<String> {
let document = self.rich_transcript_document();
document.blocks.iter().rev().find_map(|block| {
matches!(
block.kind,
desktop_rich_text::TranscriptBlockKind::CodeBlock { .. }
)
.then(|| block.copy_text.clone())
})
}
#[allow(dead_code)]
pub(crate) fn rich_transcript_jump_targets(
&self,
) -> Vec<desktop_rich_text::TranscriptJumpTarget> {
self.rich_transcript_document().jumps
}
pub(crate) fn rich_transcript_messages(
&self,
include_streaming_response: bool,
) -> Vec<desktop_rich_text::RichTranscriptMessage> {
let mut messages = self
.messages
.iter()
.enumerate()
.map(|(index, message)| {
let mut rich = desktop_rich_text::RichTranscriptMessage::new(
format!("message-{index}"),
rich_role_from_single_session_role(message.role()),
message.content().to_string(),
);
rich.attachments = message.rich_attachments().to_vec();
rich
})
.collect::<Vec<_>>();
if include_streaming_response && !self.streaming_response.trim().is_empty() {
messages.push(desktop_rich_text::RichTranscriptMessage::new(
"streaming-assistant",
desktop_rich_text::TranscriptRole::Assistant,
self.streaming_response.trim().to_string(),
));
}
if let Some(error) = &self.error {
messages.push(desktop_rich_text::RichTranscriptMessage::new(
"desktop-error",
desktop_rich_text::TranscriptRole::System,
format!("error: {error}"),
));
}
messages
}
pub(crate) fn jump_prompt(&mut self, direction: i32) {
let lines = self.body_lines();
let prompt_indices = lines
.iter()
.enumerate()
.filter_map(|(index, line)| is_user_prompt_line(line).then_some(index))
.collect::<Vec<_>>();
if prompt_indices.is_empty() {
return;
}
let current_line = lines
.len()
.saturating_sub(self.body_scroll_lines.floor().max(0.0) as usize)
.saturating_sub(1);
let target = if direction < 0 {
prompt_indices
.iter()
.rev()
.copied()
.find(|index| *index < current_line)
.or_else(|| prompt_indices.first().copied())
} else {
let next = prompt_indices
.iter()
.copied()
.find(|index| *index > current_line);
if next.is_none() {
self.scroll_body_to_bottom();
return;
}
next
};
if let Some(target) = target {
self.body_scroll_lines = lines.len().saturating_sub(target + 1) as f32;
}
}
pub(crate) fn record_user_submit(&mut self, message: &str, images: &[(String, String)]) {
let attachments = images
.iter()
.enumerate()
.map(|(index, (media_type, base64_data))| {
desktop_rich_text::RichAttachment::image(
format!("user-{}-image-{index}", self.messages.len() + 1),
media_type.clone(),
format!("attached image {}", index + 1),
base64_data.len(),
)
})
.collect::<Vec<_>>();
self.messages
.push(SingleSessionMessage::user(message).with_rich_attachments(attachments));
self.draft.clear();
self.draft_cursor = 0;
self.composer.input_undo_stack.clear();
self.streaming_response.clear();
self.scroll_body_to_bottom();
self.set_status(SingleSessionStatus::Sending);
self.error = None;
self.is_processing = true;
}
pub(crate) fn finish_streaming_response(&mut self) {
let response = self.streaming_response.trim().to_string();
if !response.is_empty() {
self.messages
.push(SingleSessionMessage::assistant(response));
}
self.streaming_response.clear();
}
pub(crate) fn next_tool_event_sequence(&mut self) -> u64 {
self.tool.event_sequence = self.tool.event_sequence.saturating_add(1);
self.tool.event_sequence
}
pub(crate) fn start_tool_run(
&mut self,
id: Option<String>,
name: &str,
message_index: usize,
) -> String {
let sequence = self.next_tool_event_sequence();
let call_id =
normalized_tool_call_id(id).unwrap_or_else(|| format!("desktop-tool-{sequence}"));
if let Some(run) = self.tool.runs.iter_mut().find(|run| run.call_id == call_id) {
run.message_index = message_index;
run.name = name.to_string();
run.state = SingleSessionToolVisualState::Preparing;
run.summary = None;
run.input_raw.clear();
run.input_preview = None;
run.stdin_prompt = None;
run.updated_sequence = sequence;
run.completed_sequence = None;
} else {
self.tool.runs.push(SingleSessionToolRun {
call_id: call_id.clone(),
message_index,
name: name.to_string(),
state: SingleSessionToolVisualState::Preparing,
summary: None,
input_raw: String::new(),
input_preview: None,
stdin_prompt: None,
started_sequence: sequence,
updated_sequence: sequence,
completed_sequence: None,
});
}
self.tool.active_call_id = Some(call_id.clone());
call_id
}
pub(crate) fn update_tool_run_state(
&mut self,
id: Option<String>,
name: &str,
state: SingleSessionToolVisualState,
summary: Option<String>,
) -> String {
let sequence = self.next_tool_event_sequence();
let call_id = normalized_tool_call_id(id)
.or_else(|| self.tool.active_call_id.clone())
.or_else(|| {
self.tool
.runs
.iter()
.rev()
.find(|run| run.name == name && run.state.is_active())
.map(|run| run.call_id.clone())
})
.unwrap_or_else(|| format!("desktop-tool-{sequence}"));
let message_index = self
.tool
.active_message_index
.unwrap_or_else(|| self.messages.len().saturating_sub(1));
let summary = summary.filter(|summary| !summary.trim().is_empty());
if let Some(run) = self.tool.runs.iter_mut().find(|run| run.call_id == call_id) {
run.message_index = message_index;
run.name = name.to_string();
run.state = state;
run.summary = summary.clone();
run.updated_sequence = sequence;
run.completed_sequence = matches!(
state,
SingleSessionToolVisualState::Succeeded | SingleSessionToolVisualState::Failed
)
.then_some(sequence);
} else {
self.tool.runs.push(SingleSessionToolRun {
call_id: call_id.clone(),
message_index,
name: name.to_string(),
state,
summary,
input_raw: String::new(),
input_preview: None,
stdin_prompt: None,
started_sequence: sequence,
updated_sequence: sequence,
completed_sequence: matches!(
state,
SingleSessionToolVisualState::Succeeded | SingleSessionToolVisualState::Failed
)
.then_some(sequence),
});
}
self.tool.active_call_id = Some(call_id.clone());
call_id
}
pub(crate) fn append_tool_run_input(&mut self, id: Option<String>, delta: &str) {
if delta.is_empty() {
return;
}
let sequence = self.next_tool_event_sequence();
let call_id = normalized_tool_call_id(id)
.or_else(|| self.tool.active_call_id.clone())
.or_else(|| self.tool.runs.last().map(|run| run.call_id.clone()));
let Some(call_id) = call_id else {
return;
};
if let Some(run) = self.tool.runs.iter_mut().find(|run| run.call_id == call_id) {
run.input_raw.push_str(delta);
run.input_preview =
compact_tool_metadata(&formatted_tool_input_lines(&run.name, &run.input_raw));
run.updated_sequence = sequence;
}
}
pub(crate) fn mark_tool_stdin_prompt(&mut self, tool_call_id: &str, prompt: &str) {
let sequence = self.next_tool_event_sequence();
if let Some(run) = self
.tool
.runs
.iter_mut()
.rev()
.find(|run| run.call_id == tool_call_id)
{
run.stdin_prompt = Some(prompt.to_string());
run.updated_sequence = sequence;
return;
}
if let Some(run) = self
.tool
.runs
.iter_mut()
.rev()
.find(|run| run.state.is_active())
{
run.stdin_prompt = Some(prompt.to_string());
run.updated_sequence = sequence;
}
}
pub(crate) fn clear_tool_stdin_prompts(&mut self) {
let sequence = self.next_tool_event_sequence();
for run in &mut self.tool.runs {
if run.stdin_prompt.is_some() {
run.stdin_prompt = None;
run.updated_sequence = sequence;
}
}
}
pub(crate) fn tool_run_for_message_index(
&self,
message_index: usize,
) -> Option<&SingleSessionToolRun> {
self.tool
.runs
.iter()
.find(|run| run.message_index == message_index)
}
pub(crate) fn collapse_active_tool_message(&mut self) {
let Some(index) = self.tool.active_message_index.take() else {
return;
};
self.tool.active_call_id = None;
let Some(message) = self.messages.get_mut(index) else {
return;
};
if message.role() != SingleSessionRole::Tool {
return;
}
if let Some(first_line) = message.content().lines().next() {
message.set_content(first_line.replacen('▾', "▸", 1));
}
}
pub(crate) fn append_active_tool_input(&mut self, delta: &str) {
if delta.is_empty() {
return;
}
self.tool.input_buffer.push_str(delta);
}
pub(crate) fn flush_active_tool_input_to_message(&mut self) {
if self.tool.input_buffer.is_empty() {
return;
}
let Some(index) = self.tool.active_message_index else {
return;
};
let Some(message) = self.messages.get_mut(index) else {
return;
};
if message.role() != SingleSessionRole::Tool {
return;
}
if !message.content().contains("\n input: ") {
message.content_mut().push_str("\n input: ");
}
message.content_mut().push_str(&self.tool.input_buffer);
self.tool.input_buffer.clear();
}
pub(crate) fn replace_active_tool_header(&mut self, header: &str) {
let Some(index) = self.tool.active_message_index else {
self.messages
.push(SingleSessionMessage::tool(header.to_string()));
self.tool.active_message_index = Some(self.messages.len().saturating_sub(1));
return;
};
let Some(message) = self.messages.get_mut(index) else {
self.messages
.push(SingleSessionMessage::tool(header.to_string()));
self.tool.active_message_index = Some(self.messages.len().saturating_sub(1));
return;
};
if message.role() == SingleSessionRole::Tool {
let replacement = merge_tool_finish_with_existing_context(message.content(), header);
if message.content() != replacement {
message.set_content(replacement);
}
}
}
}