use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use base64::Engine;
use rpi_tui::{
render_diff, AssistantBlock, AssistantMessageComponent, AssistantMessageOptions,
BashExecutionComponent, BashTruncation, Container, Spacer, ToolExecutionComponent, ToolStatus,
UserMessageComponent,
};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct UiToolCall {
pub id: String,
pub name: String,
pub args: Value,
}
#[derive(Debug, Clone)]
pub enum UiEvent {
AgentStart,
AgentEnd,
Retry {
attempt: u32,
max_retries: u32,
delay_ms: u64,
error: String,
},
AssistantStart { blocks: Vec<AssistantBlock> },
AssistantUpdate {
blocks: Vec<AssistantBlock>,
tool_calls: Vec<UiToolCall>,
},
AssistantEnd { blocks: Vec<AssistantBlock> },
ToolStart {
id: String,
name: String,
args: Value,
},
ToolUpdate {
id: String,
name: String,
args: Value,
result: Value,
},
ToolEnd {
id: String,
name: String,
result: Value,
is_error: bool,
},
}
pub struct TranscriptView {
show_images: bool,
chat: Arc<Container>,
current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
hide_thinking: Arc<Mutex<bool>>,
expanded: Arc<Mutex<bool>>,
markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
}
pub type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
impl TranscriptView {
pub fn new(chat: Arc<Container>) -> Self {
Self::with_maps(
chat,
Arc::new(Mutex::new(None)),
Arc::new(Mutex::new(HashMap::new())),
Arc::new(Mutex::new(HashMap::new())),
Arc::new(Mutex::new(false)),
Arc::new(Mutex::new(false)),
Arc::new(Mutex::new(None)),
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_maps(
chat: Arc<Container>,
current_assistant: Arc<Mutex<Option<Arc<AssistantMessageComponent>>>>,
tool_components: Arc<Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
bash_components: Arc<Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
hide_thinking: Arc<Mutex<bool>>,
expanded: Arc<Mutex<bool>>,
markdown_transformer: Arc<Mutex<Option<MarkdownTransformer>>>,
) -> Self {
Self {
show_images: true,
chat,
current_assistant,
tool_components,
bash_components,
hide_thinking,
expanded,
markdown_transformer,
}
}
pub fn with_show_images(mut self, show: bool) -> Self {
self.show_images = show;
self
}
pub fn add_user(&self, text: &str) {
self.chat
.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
self.chat.add_child(Arc::new(Spacer::new(1)));
}
pub fn add_notice(&self, text: &str) {
self.chat
.add_child(Arc::new(rpi_tui::Text::new(format!("· {text}"), 1, 0)));
}
pub fn apply(&self, event: &UiEvent, width: usize) {
match event {
UiEvent::AgentStart => {}
UiEvent::AgentEnd => {
if let Some(comp) = self.current_assistant.lock().unwrap().take() {
comp.set_streaming(false);
}
}
UiEvent::Retry {
attempt,
max_retries,
delay_ms,
error,
} => {
self.add_notice(&format!(
"retry {attempt}/{max_retries} in {delay_ms}ms: {error}"
));
}
UiEvent::AssistantStart { blocks } => {
self.start_assistant(blocks);
}
UiEvent::AssistantUpdate { blocks, tool_calls } => {
self.ensure_assistant();
self.sync_tool_calls(tool_calls);
if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
comp.update_blocks(blocks);
}
}
UiEvent::AssistantEnd { blocks } => {
if let Some(comp) = self.current_assistant.lock().unwrap().take() {
comp.update_blocks(blocks);
comp.set_streaming(false);
}
}
UiEvent::ToolStart { id, name, args } => self.tool_start(id, name, args, width),
UiEvent::ToolUpdate {
id,
name,
args,
result,
} => self.tool_update(id, name, args, result, width),
UiEvent::ToolEnd {
id,
name,
result,
is_error,
} => self.tool_end(id, name, result, *is_error, width),
}
}
pub fn set_expanded(&self, expanded: bool) {
*self.expanded.lock().unwrap() = expanded;
for child in self.chat.get_children() {
if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
component.set_expanded(expanded);
}
}
for comp in self.tool_components.lock().unwrap().values() {
comp.set_expanded(expanded);
}
for comp in self.bash_components.lock().unwrap().values() {
comp.set_expanded(expanded);
}
}
pub fn toggle_expanded(&self) -> bool {
let next = !*self.expanded.lock().unwrap();
self.set_expanded(next);
next
}
pub fn expanded(&self) -> bool {
*self.expanded.lock().unwrap()
}
pub fn set_hide_thinking(&self, hide: bool) {
*self.hide_thinking.lock().unwrap() = hide;
if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
comp.set_hide_thinking(hide);
}
}
pub fn toggle_hide_thinking(&self) -> bool {
let next = !*self.hide_thinking.lock().unwrap();
self.set_hide_thinking(next);
next
}
pub fn hide_thinking(&self) -> bool {
*self.hide_thinking.lock().unwrap()
}
pub fn set_markdown_transformer(&self, transformer: Option<MarkdownTransformer>) {
*self.markdown_transformer.lock().unwrap() = transformer.clone();
if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
comp.set_markdown_transformer(transformer);
}
}
pub fn finalize_assistant(&self) {
if let Some(comp) = self.current_assistant.lock().unwrap().take() {
comp.set_streaming(false);
}
}
pub fn finalize_assistant_blocks(&self, blocks: &[AssistantBlock]) {
if let Some(comp) = self.current_assistant.lock().unwrap().take() {
comp.update_blocks(blocks);
comp.set_streaming(false);
}
}
pub fn has_running_tool(&self) -> bool {
self.tool_components
.lock()
.unwrap()
.values()
.any(|component| component.status() == ToolStatus::Running)
}
pub fn has_active_bash(&self) -> bool {
!self.bash_components.lock().unwrap().is_empty()
}
fn start_assistant(&self, blocks: &[AssistantBlock]) {
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
comp.set_show_images(self.show_images);
comp.set_trailing_spacing(1);
if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
comp.set_markdown_transformer(Some(transformer));
}
comp.set_streaming(true);
comp.update_blocks(blocks);
self.chat.add_child(comp.clone());
*self.current_assistant.lock().unwrap() = Some(comp);
}
fn ensure_assistant(&self) {
let mut current = self.current_assistant.lock().unwrap();
if current.is_none() {
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
comp.set_hide_thinking(*self.hide_thinking.lock().unwrap());
comp.set_show_images(self.show_images);
comp.set_trailing_spacing(1);
if let Some(transformer) = self.markdown_transformer.lock().unwrap().clone() {
comp.set_markdown_transformer(Some(transformer));
}
comp.set_streaming(true);
self.chat.add_child(comp.clone());
*current = Some(comp);
}
}
fn sync_tool_calls(&self, calls: &[UiToolCall]) {
for call in calls {
if matches!(
call.name.as_str(),
"todo" | "plan_mode_start" | "plan_mode_complete"
) {
continue;
}
if call.name == "bash" {
let command = call
.args
.get("command")
.and_then(Value::as_str)
.unwrap_or("");
if command.trim().is_empty() {
continue;
}
let mut bash = self.bash_components.lock().unwrap();
if let Some(existing) = bash.get(&call.id) {
existing.set_command(command);
} else {
let comp = Arc::new(BashExecutionComponent::new(command));
comp.set_expanded(self.expanded());
self.chat.add_child(comp.clone());
bash.insert(call.id.clone(), comp);
}
continue;
}
let ask_user = is_ask_user_tool(&call.name);
let display = if ask_user {
ask_user_args_display(&call.args)
} else {
call.args.to_string()
};
let mut tools = self.tool_components.lock().unwrap();
if let Some(existing) = tools.get(&call.id) {
if !display.trim().is_empty() && display.trim() != "{}" {
existing.set_args(&display);
}
} else {
let comp = Arc::new(ToolExecutionComponent::new(&call.name, &display));
if ask_user {
comp.set_display_title("ASK USER");
}
if let Some(skill) = skill_tool_name(&call.name, &call.args) {
comp.set_skill_name(skill);
}
comp.set_expanded(self.expanded());
comp.set_running();
self.chat.add_child(comp.clone());
tools.insert(call.id.clone(), comp);
}
}
}
fn tool_start(&self, id: &str, name: &str, args: &Value, _width: usize) {
if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
return;
}
if name.trim().is_empty() {
return;
}
if name == "bash" {
let command = args.get("command").and_then(Value::as_str).unwrap_or("");
if command.trim().is_empty() {
return;
}
let mut bash = self.bash_components.lock().unwrap();
if let Some(existing) = bash.get(id) {
existing.set_command(command);
} else {
let comp = Arc::new(BashExecutionComponent::new(command));
comp.set_expanded(self.expanded());
self.chat.add_child(comp.clone());
bash.insert(id.to_string(), comp);
}
return;
}
let ask_user = is_ask_user_tool(name);
let display = if ask_user {
ask_user_args_display(args)
} else {
args.to_string()
};
let mut tools = self.tool_components.lock().unwrap();
if let Some(existing) = tools.get(id) {
if ask_user {
existing.set_display_title("ASK USER");
}
existing.set_args(&display);
} else {
let comp = Arc::new(ToolExecutionComponent::new(name, &display));
if ask_user {
comp.set_display_title("ASK USER");
}
if let Some(skill) = skill_tool_name(name, args) {
comp.set_skill_name(skill);
}
comp.set_expanded(self.expanded());
comp.set_running();
self.chat.add_child(comp.clone());
tools.insert(id.to_string(), comp);
}
}
fn tool_update(&self, id: &str, name: &str, args: &Value, result: &Value, width: usize) {
if matches!(name, "todo" | "plan_mode_start" | "plan_mode_complete") {
return;
}
if name.trim().is_empty() {
return;
}
let ask_user = is_ask_user_tool(name);
let text = if ask_user {
ask_user_progress_text(args, result)
} else {
tool_result_text(result)
};
if name == "bash" {
if let Some(bash) = self.bash_components.lock().unwrap().get(id) {
if let Some(command) = args.get("command").and_then(Value::as_str) {
bash.set_command(command);
}
if !text.is_empty() {
bash.append_output(&text);
}
}
return;
}
let existing = self.tool_components.lock().unwrap().get(id).cloned();
match existing {
Some(comp) => {
if ask_user {
comp.set_display_title("ASK USER");
comp.set_args(&ask_user_args_display(args));
} else if !args.is_null() && args != &serde_json::json!({}) {
comp.set_args(&args.to_string());
}
if tool_update_has_payload(&text, result) {
comp.set_result(&text, false);
if !ask_user {
apply_edit_diff(&comp, name, result.get("details"), width);
}
}
}
None => {
if tool_update_has_payload(&text, result) {
let display = if ask_user {
ask_user_args_display(args)
} else {
args.to_string()
};
let comp = Arc::new(ToolExecutionComponent::new(name, &display));
if ask_user {
comp.set_display_title("ASK USER");
}
if let Some(skill) = skill_tool_name(name, args) {
comp.set_skill_name(skill);
}
comp.set_expanded(self.expanded());
comp.set_running();
comp.set_result(&text, false);
if !ask_user {
apply_edit_diff(&comp, name, result.get("details"), width);
}
self.chat.add_child(comp.clone());
self.tool_components
.lock()
.unwrap()
.insert(id.to_string(), comp);
}
}
}
}
fn tool_end(&self, id: &str, name: &str, result: &Value, is_error: bool, width: usize) {
if matches!(name, "plan_mode_start" | "plan_mode_complete")
&& !is_error
&& update_plan_panel(&self.chat, result)
{
return;
}
if name == "todo" && !is_error && update_todo_list(&self.chat, result) {
return;
}
if name.trim().is_empty() {
return;
}
if name == "bash" {
let bash = self.bash_components.lock().unwrap().remove(id);
match bash {
Some(comp) => finalize_bash(&comp, result, is_error),
None => {
let command = result
.get("details")
.and_then(|details| details.get("command"))
.and_then(Value::as_str)
.unwrap_or("");
let text = tool_result_text(result);
if command.trim().is_empty() && text.trim().is_empty() {
return;
}
let comp = Arc::new(BashExecutionComponent::new(command));
comp.set_expanded(self.expanded());
if !text.is_empty() {
comp.append_output(&text);
}
finalize_bash(&comp, result, is_error);
self.chat.add_child(comp);
}
}
return;
}
let ask_user = is_ask_user_tool(name);
let text = if ask_user {
ask_user_result_text(result)
} else {
tool_result_text(result)
};
let details = result.get("details");
let existing = self.tool_components.lock().unwrap().remove(id);
let comp = match existing {
Some(comp) => comp,
None => {
let comp = Arc::new(ToolExecutionComponent::new(name, ""));
if ask_user {
comp.set_display_title("ASK USER");
}
comp.set_expanded(self.expanded());
self.chat.add_child(comp.clone());
comp
}
};
comp.set_result(&text, is_error);
if !ask_user {
if tool_result_requests_markdown(details) {
comp.set_result_markdown(true);
}
apply_edit_diff(&comp, name, details, width);
}
}
}
pub(crate) fn update_plan_panel(chat: &Arc<Container>, result: &Value) -> bool {
let Some(details) = result.get("details") else {
return false;
};
if details.get("kind").and_then(Value::as_str) != Some("plan") || details.get("error").is_some()
{
return false;
}
let Some(active) = details.get("active").and_then(Value::as_bool) else {
return false;
};
let plan = match details.get("plan") {
None | Some(Value::Null) => String::new(),
Some(Value::String(plan)) => plan.clone(),
_ => return false,
};
for child in chat.get_children().into_iter().rev() {
if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
if component.is_plan_panel() {
component.set_plan_state(plan, active);
component.set_result("", false);
if let Some(expanded) = details.get("expanded").and_then(Value::as_bool) {
component.set_expanded(expanded);
}
chat.remove_child(&child);
chat.add_child(child);
return true;
}
}
}
let component = Arc::new(ToolExecutionComponent::new("plan_mode_complete", ""));
component.set_plan_state(plan, active);
component.set_result("", false);
component.set_expanded(
details
.get("expanded")
.and_then(Value::as_bool)
.unwrap_or(false),
);
chat.add_child(component);
true
}
pub(crate) fn update_todo_list(chat: &Arc<Container>, result: &Value) -> bool {
let Some(details) = result.get("details") else {
return false;
};
if details.get("error").is_some() {
return false;
}
let Some(items) = details.get("todos").and_then(Value::as_array) else {
return false;
};
let rows: Option<Vec<_>> = items
.iter()
.map(|item| {
let text = item.get("text")?.as_str()?.to_owned();
let status = if item.get("done").and_then(Value::as_bool) == Some(true) {
"completed"
} else {
item.get("status")
.and_then(Value::as_str)
.unwrap_or("pending")
};
Some((text, status.to_owned()))
})
.collect();
let Some(rows) = rows else {
return false;
};
let text = tool_result_text(result);
for child in chat.get_children().into_iter().rev() {
if let Some(component) = child.as_any().downcast_ref::<ToolExecutionComponent>() {
if component.is_todo_list() {
component.set_todo_state(rows);
component.set_result(&text, false);
chat.remove_child(&child);
chat.add_child(child);
return true;
}
}
}
let candidate = Arc::new(ToolExecutionComponent::new("todo", ""));
candidate.set_todo_state(rows);
candidate.set_result(&text, false);
chat.add_child(candidate);
true
}
pub fn assistant_blocks_from_value(message: &Value) -> Vec<AssistantBlock> {
let mut blocks = Vec::new();
let Some(content) = message.get("content").and_then(Value::as_array) else {
return blocks;
};
for block in content {
match block.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(text) = block.get("text").and_then(Value::as_str) {
blocks.push(AssistantBlock::Text(text.to_string()));
}
}
Some("thinking") => {
if let Some(text) = block.get("thinking").and_then(Value::as_str) {
blocks.push(AssistantBlock::Thinking(text.to_string()));
}
}
Some("image") => {
if let Some(data) = block.get("data").and_then(Value::as_str) {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
if !bytes.is_empty() {
let png = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some(bytes)
} else {
rpi_tools::image_processing::process_image(
&bytes,
Default::default(),
)
.ok()
};
if let Some(png) = png {
blocks.push(AssistantBlock::Image(png));
}
}
}
}
}
_ => {}
}
}
blocks
}
pub fn tool_calls_from_value(message: &Value) -> Vec<UiToolCall> {
let mut calls = Vec::new();
let Some(content) = message.get("content").and_then(Value::as_array) else {
return calls;
};
for block in content {
if block.get("type").and_then(Value::as_str) != Some("toolCall") {
continue;
}
let id = block.get("id").and_then(Value::as_str).unwrap_or("");
let name = block.get("name").and_then(Value::as_str).unwrap_or("");
if id.is_empty() || name.trim().is_empty() {
continue;
}
calls.push(UiToolCall {
id: id.to_string(),
name: name.to_string(),
args: block.get("arguments").cloned().unwrap_or(Value::Null),
});
}
calls
}
pub fn tool_result_text(result: &Value) -> String {
let mut out = String::new();
if let Some(blocks) = result.get("content").and_then(Value::as_array) {
for block in blocks {
if let Some(text) = block.get("text").and_then(Value::as_str) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
}
}
out
}
pub fn tool_result_requests_markdown(details: Option<&Value>) -> bool {
details
.and_then(|details| details.get("markdown"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub fn skill_tool_name(tool_name: &str, args: &Value) -> Option<String> {
if tool_name != "read" {
return None;
}
let path = args.get("path").and_then(Value::as_str)?;
let normalized = path.replace('\\', "/");
let file_name = normalized.rsplit('/').next()?;
if !file_name.eq_ignore_ascii_case("SKILL.md") {
return None;
}
normalized
.trim_end_matches('/')
.rsplit('/')
.nth(1)
.filter(|name| !name.is_empty())
.map(str::to_string)
}
fn is_ask_user_tool(tool_name: &str) -> bool {
matches!(tool_name, "ask_user" | "ask_user_question")
}
fn ask_user_args_display(args: &Value) -> String {
if args.is_null() {
return String::new();
}
if let Some(summary) = args
.get("summary")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
{
return format!("Confirmation requested: {summary}");
}
let mut lines: Vec<String> = Vec::new();
let questions: Vec<&Value> = args
.get("questions")
.and_then(Value::as_array)
.filter(|items| !items.is_empty())
.map(|items| items.iter().collect())
.unwrap_or_else(|| vec![args]);
for question in questions {
let prompt = question
.get("question")
.and_then(Value::as_str)
.or_else(|| args.get("question").and_then(Value::as_str))
.map(str::trim)
.unwrap_or_default();
if prompt.is_empty() {
continue;
}
if let Some(header) = question
.get("header")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
{
lines.push(format!("{header}: {prompt}"));
} else {
lines.push(prompt.to_string());
}
let context = question
.get("context")
.and_then(Value::as_str)
.or_else(|| args.get("context").and_then(Value::as_str))
.map(str::trim)
.filter(|text| !text.is_empty());
if let Some(context) = context {
lines.push(format!(" {context}"));
}
let options = question
.get("options")
.and_then(Value::as_array)
.or_else(|| args.get("options").and_then(Value::as_array));
if let Some(options) = options {
let labels: Vec<String> = options
.iter()
.filter_map(|option| {
option
.get("title")
.and_then(Value::as_str)
.or_else(|| option.as_str())
.map(str::trim)
.filter(|label| !label.is_empty())
.map(str::to_string)
})
.collect();
if !labels.is_empty() {
lines.push(format!("Choices: {}", labels.join(", ")));
}
}
if let Some(suggest) = question
.get("suggest")
.and_then(Value::as_str)
.or_else(|| args.get("suggest").and_then(Value::as_str))
.map(str::trim)
.filter(|text| !text.is_empty())
{
lines.push(format!("Suggestions: {suggest}"));
}
}
lines.join("\n")
}
fn ask_user_progress_text(args: &Value, result: &Value) -> String {
let text = tool_result_text(result);
if !text.trim().is_empty() {
return text;
}
let args_text = ask_user_args_display(args);
if args_text.trim().is_empty() {
"Waiting for your answer…".to_string()
} else {
args_text
}
}
fn ask_user_result_text(result: &Value) -> String {
let text = tool_result_text(result);
if text.trim().is_empty() {
"Answer recorded.".to_string()
} else {
text
}
}
fn tool_update_has_payload(text: &str, result: &Value) -> bool {
!text.trim().is_empty()
|| result
.get("details")
.map(|details| !details.is_null())
.unwrap_or(false)
}
fn apply_edit_diff(
comp: &Arc<ToolExecutionComponent>,
tool_name: &str,
details: Option<&Value>,
width: usize,
) {
if tool_name != "edit" {
return;
}
let Some(diff_text) = details
.and_then(|details| details.get("diff"))
.and_then(Value::as_str)
else {
return;
};
if diff_text.is_empty() {
return;
}
comp.set_diff(render_diff(diff_text, width));
}
fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &Value, is_error: bool) {
let details = result.get("details");
let exit_code = Some(if is_error { 1 } else { 0 });
let truncated = details
.and_then(|details| details.get("truncation"))
.and_then(|truncation| truncation.get("truncated"))
.and_then(Value::as_bool)
.unwrap_or(false);
let full_output_path = details
.and_then(|details| details.get("full_output_path"))
.and_then(Value::as_str)
.map(str::to_string);
if let Some(command) = details
.and_then(|details| details.get("command"))
.and_then(Value::as_str)
{
comp.set_command(command);
}
comp.set_complete(
exit_code,
false,
BashTruncation {
truncated,
full_output_path,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn view() -> TranscriptView {
TranscriptView::new(Arc::new(Container::new()))
}
#[test]
fn empty_assistant_start_and_whitespace_deltas_do_not_move_the_transcript() {
use rpi_tui::Component;
for starts_with_event in [true, false] {
let view = view();
view.add_user("hi");
let before = view.chat.render(80);
if starts_with_event {
view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
assert_eq!(view.chat.render(80), before);
}
for blocks in [
vec![],
vec![AssistantBlock::Text(" ".into())],
vec![AssistantBlock::Thinking("\n".into())],
] {
view.apply(
&UiEvent::AssistantUpdate {
blocks,
tool_calls: vec![],
},
80,
);
assert_eq!(view.chat.render(80), before);
}
view.apply(
&UiEvent::AssistantUpdate {
blocks: vec![AssistantBlock::Text("hello".into())],
tool_calls: vec![],
},
80,
);
let visible = view.chat.render(80);
assert!(visible.len() > before.len());
assert!(visible.iter().any(|line| line.contains("hello")));
assert_eq!(visible.last(), Some(&String::new()));
view.apply(&UiEvent::AssistantEnd { blocks: vec![] }, 80);
assert_eq!(view.chat.render(80), before);
view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
assert_eq!(view.chat.render(80), before);
}
}
#[test]
fn assistant_blocks_follow_content_order() {
let message = json!({
"content": [
{"type": "thinking", "thinking": "why"},
{"type": "text", "text": "hello"},
{"type": "toolCall", "id": "c1", "name": "read", "arguments": {}},
]
});
assert_eq!(
assistant_blocks_from_value(&message),
vec![
AssistantBlock::Thinking("why".into()),
AssistantBlock::Text("hello".into()),
]
);
}
#[test]
fn assistant_blocks_decode_images() {
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
encoder.set_color(png::ColorType::Rgb);
encoder
.write_header()
.unwrap()
.write_image_data(&[255, 0, 0])
.unwrap();
}
let message = json!({
"content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "mimeType": "image/png"}]
});
assert_eq!(
assistant_blocks_from_value(&message),
vec![AssistantBlock::Image(bytes.to_vec())]
);
let jpeg = rpi_tools::image_processing::process_image(
&bytes,
rpi_tools::image_processing::ImageProcessingOptions {
format: Some(rpi_tools::image_processing::OutputFormat::Jpeg { quality: 85 }),
..Default::default()
},
)
.unwrap();
let blocks = assistant_blocks_from_value(&json!({
"content": [{"type": "image", "data": base64::engine::general_purpose::STANDARD.encode(jpeg), "mimeType": "image/jpeg"}]
}));
assert!(
matches!(&blocks[0], AssistantBlock::Image(data) if data.starts_with(b"\x89PNG\r\n\x1a\n"))
);
}
#[test]
fn tool_calls_extract_ids_names_and_args() {
let message = json!({
"content": [
{"type": "text", "text": "x"},
{"type": "toolCall", "id": "b1", "name": "bash", "arguments": {"command": "ls"}},
{"type": "toolCall", "id": "", "name": "read", "arguments": {}},
]
});
assert_eq!(
tool_calls_from_value(&message),
vec![UiToolCall {
id: "b1".into(),
name: "bash".into(),
args: json!({"command": "ls"}),
}]
);
}
#[test]
fn markdown_flag_is_opt_in() {
assert!(tool_result_requests_markdown(Some(
&json!({"markdown": true})
)));
assert!(!tool_result_requests_markdown(Some(
&json!({"markdown": false})
)));
assert!(!tool_result_requests_markdown(Some(&json!({}))));
assert!(!tool_result_requests_markdown(None));
}
#[test]
fn skill_tool_name_only_matches_skill_reads() {
assert_eq!(
skill_tool_name("read", &json!({"path": "skills/foo/SKILL.md"})),
Some("foo".to_string())
);
assert_eq!(skill_tool_name("read", &json!({"path": "README.md"})), None);
assert_eq!(
skill_tool_name("write", &json!({"path": "skills/foo/SKILL.md"})),
None
);
}
#[test]
fn ask_user_args_display_never_leaks_transport_json() {
let text = ask_user_args_display(&json!({
"question": "你的代理端口是多少?",
"context": "本机",
"options": ["7890", {"title": "7897", "description": "clash"}],
"suggest": "1080"
}));
assert!(text.contains("你的代理端口是多少?"));
assert!(text.contains("本机"));
assert!(text.contains("Choices: 7890, 7897"));
assert!(text.contains("Suggestions: 1080"));
assert!(!text.trim_start().starts_with('{'));
assert_eq!(
ask_user_args_display(&json!({"type": "confirm", "summary": "Deploy?"})),
"Confirmation requested: Deploy?"
);
}
#[test]
fn tool_start_routes_bash_to_its_own_map() {
let view = view();
view.apply(
&UiEvent::ToolStart {
id: "b1".into(),
name: "bash".into(),
args: json!({"command": "ls -la"}),
},
80,
);
view.apply(
&UiEvent::ToolStart {
id: "r1".into(),
name: "read".into(),
args: json!({"path": "a.txt"}),
},
80,
);
assert!(view.bash_components.lock().unwrap().contains_key("b1"));
assert!(view.tool_components.lock().unwrap().contains_key("r1"));
assert!(view.tool_components.lock().unwrap().get("b1").is_none());
}
#[test]
fn bash_start_without_a_command_is_deferred() {
let view = view();
view.apply(
&UiEvent::ToolStart {
id: "b1".into(),
name: "bash".into(),
args: json!({}),
},
80,
);
assert!(view.bash_components.lock().unwrap().is_empty());
assert!(view.tool_components.lock().unwrap().is_empty());
}
#[test]
fn assistant_stream_reuses_one_component() {
let view = view();
view.apply(&UiEvent::AssistantStart { blocks: vec![] }, 80);
view.apply(
&UiEvent::AssistantUpdate {
blocks: vec![AssistantBlock::Text("hi".into())],
tool_calls: vec![],
},
80,
);
assert!(view.current_assistant.lock().unwrap().is_some());
}
#[test]
fn toggle_expanded_round_trips() {
let view = view();
assert!(!view.expanded());
assert!(view.toggle_expanded());
assert!(view.expanded());
}
#[test]
fn todo_updates_share_one_panel_and_preserve_errors() {
let view = view();
for (id, done) in [("a", false), ("b", true)] {
view.sync_tool_calls(&[UiToolCall {
id: id.into(),
name: "todo".into(),
args: json!({"action":"toggle"}),
}]);
view.tool_start(id, "todo", &json!({}), 80);
view.tool_end(id, "todo", &json!({"content":[],"details":{"todos":[{"id":1,"text":"Run tests","done":done}]}}), false, 80);
}
assert_eq!(view.chat.child_count(), 1);
assert!(view.tool_components.lock().unwrap().is_empty());
let children = view.chat.get_children();
let panel = children[0]
.as_any()
.downcast_ref::<ToolExecutionComponent>()
.unwrap();
use rpi_tui::Component;
let rendered = rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n"));
assert!(rendered.contains("1/1 completed"));
assert!(rendered.contains("[✓] Run tests"));
view.tool_end(
"error",
"todo",
&json!({"content":[{"type":"text","text":"invalid task"}]}),
true,
80,
);
assert_eq!(view.chat.child_count(), 2);
view.tool_end("soft-error", "todo", &json!({"content":[{"type":"text","text":"todo not found"}],"details":{"todos":[],"error":"todo not found"}}), false, 80);
assert_eq!(view.chat.child_count(), 3);
assert!(update_todo_list(
&view.chat,
&json!({"details":{"todos":[]}})
));
assert_eq!(view.chat.child_count(), 3);
assert!(!update_todo_list(
&view.chat,
&json!({"details":{"todos":[{}]}})
));
assert_eq!(view.chat.child_count(), 3);
}
#[test]
fn plan_tools_and_show_update_one_expandable_panel() {
let view = view();
view.sync_tool_calls(&[UiToolCall {
id: "start".into(),
name: "plan_mode_start".into(),
args: json!({}),
}]);
view.tool_start("start", "plan_mode_start", &json!({}), 80);
view.tool_end(
"start",
"plan_mode_start",
&json!({"details":{"kind":"plan","active":true}}),
false,
80,
);
let body = (1..=30)
.map(|i| format!("{i}. Step {i}"))
.collect::<Vec<_>>()
.join("\n");
view.tool_end(
"complete",
"plan_mode_complete",
&json!({"details":{"kind":"plan","active":false,"plan":body}}),
false,
80,
);
assert_eq!(view.chat.child_count(), 1);
assert!(view.tool_components.lock().unwrap().is_empty());
view.set_expanded(true);
let children = view.chat.get_children();
let panel = children[0]
.as_any()
.downcast_ref::<ToolExecutionComponent>()
.unwrap();
use rpi_tui::Component;
assert!(rpi_tui::ansi::strip_ansi(&panel.render(80).join("\n")).contains("Step 30"));
assert!(update_plan_panel(
&view.chat,
&json!({"kind":"message","details":{"kind":"plan","active":false,"plan":body,"expanded":true}})
));
assert_eq!(view.chat.child_count(), 1);
view.tool_end(
"error",
"plan_mode_complete",
&json!({"content":[{"type":"text","text":"not active"}]}),
true,
80,
);
assert_eq!(view.chat.child_count(), 2);
}
}