use super::*;
pub(super) async fn maybe_add_startup_changelog(
harness: &AgentHarness,
chat: &Arc<Container>,
settings: &crate::settings::Settings,
collapse: bool,
) {
let has_messages = harness
.session()
.find_entry_on_branch(
&EntryQuery {
entry_type: Some("message"),
..Default::default()
},
&Default::default(),
)
.await
.map(|entry| entry.is_some())
.unwrap_or(true);
let notice = crate::changelog::startup_changelog(
&crate::changelog::bundled_entries(),
settings.last_changelog_version.as_deref(),
crate::VERSION,
has_messages,
);
if let Some(markdown) = ¬ice.markdown {
add_startup_changelog_message(chat, markdown, crate::VERSION, collapse);
}
if let Some(version) = notice.version_to_record {
let saved = crate::settings::load_settings()
.map_err(|error| error.to_string())
.and_then(|mut current| {
current.last_changelog_version = Some(version);
crate::settings::save_settings(¤t)
});
if let Err(error) = saved {
tracing::warn!("Could not record changelog version: {error}");
}
}
}
pub(super) fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
std::thread::spawn(move || {
let file = match tempfile::Builder::new()
.prefix("rpi-draft-")
.suffix(".md")
.tempfile()
{
Ok(file) => file,
Err(error) => {
let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
"Could not create editor file: {error}"
))));
return;
}
};
if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
"Could not write editor file: {error}"
))));
return;
}
let editor = std::env::var("RPI_EXTERNAL_EDITOR")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| std::env::var("VISUAL").ok())
.or_else(|| std::env::var("EDITOR").ok())
.unwrap_or_else(|| {
if cfg!(windows) {
"notepad".to_string()
} else {
"nano".to_string()
}
});
let status = std::process::Command::new(&editor)
.arg(file.path())
.status();
let result = match status {
Ok(status) if status.success() => std::fs::read_to_string(file.path())
.map_err(|error| format!("Could not read editor file: {error}")),
Ok(status) => Err(format!("External editor exited with {status}")),
Err(error) => Err(format!(
"Could not launch external editor `{editor}`: {error}"
)),
};
let _ = tx.send(TuiMessage::ExternalEditorResult(result));
});
}
pub(super) fn reconcile_streamed_assistant_completion(
current_assistant: &Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
last_assistant_text: &Mutex<String>,
final_message: Option<&AssistantMessage>,
) {
let final_blocks = final_message.map(assistant_blocks);
let final_text = final_message.map(assistant_text);
let component = current_assistant.lock().unwrap().take();
if let Some(component) = component {
if let Some(blocks) = final_blocks.as_deref() {
component.update_blocks(blocks);
}
component.set_streaming(false);
}
if let Some(text) = final_text.filter(|text| !text.is_empty()) {
*last_assistant_text.lock().unwrap() = text;
}
}
pub(super) async fn run_prompt_streaming(
lane: &Arc<dyn AgentLane>,
prompt: &str,
resume: bool,
tui: &Arc<TuiAltScreen>,
state: &Arc<TuiState>,
streaming: bool,
images: Vec<rpi_ai::types::ImageContent>,
) {
state.set_status(RunStatus::Working);
tui.request_render(false);
let outcome = if resume {
match lane.resume_pending().await {
Ok(Some(result)) => Ok(result),
Ok(None) => {
state.set_status(RunStatus::Idle);
tui.request_render(false);
return;
}
Err(error) => Err(error),
}
} else {
lane.prompt_text(prompt, images).await
};
if streaming {
let final_message = match &outcome {
Ok(result) => match &result.outcome {
HarnessRunOutcome::Completed { final_message, .. }
| HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
HarnessRunOutcome::Suspended { .. } => None,
},
Err(_) => None,
};
reconcile_streamed_assistant_completion(
&state.current_assistant,
&state.last_assistant_text,
final_message,
);
}
state.set_status(RunStatus::Idle);
refresh_pending_messages(state, lane).await;
if let Ok(result) = &outcome {
let final_message: Option<&AssistantMessage> = match &result.outcome {
HarnessRunOutcome::Completed { final_message, .. }
| HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
HarnessRunOutcome::Suspended { .. } => None,
};
if let Some(a) = final_message {
record_context_usage(&state.footer, a);
}
}
match outcome {
Ok(result) => match &result.outcome {
HarnessRunOutcome::Failed {
error,
final_message,
..
} => {
let already_rendered = streaming && final_message.is_some();
if !already_rendered {
let msg = final_message
.as_ref()
.and_then(|m| m.error_message.clone())
.unwrap_or_else(|| format!("{error:?}"));
add_error_message(&state.chat_container, &msg);
}
}
HarnessRunOutcome::Suspended { .. } => {
add_error_message(
&state.chat_container,
"Run suspended (deferred) — resume is not supported in v1.",
);
}
HarnessRunOutcome::Aborted { final_message, .. } => {
if !streaming {
add_error_message(&state.chat_container, "Request aborted.");
let _ = final_message; }
}
HarnessRunOutcome::Completed { final_message, .. } => {
if !streaming {
let text = assistant_text(final_message);
if !text.is_empty() {
add_assistant_message_blocking(
&state.chat_container,
&text,
state.markdown_transformer(),
);
*state.last_assistant_text.lock().unwrap() = text;
}
}
}
},
Err(e) => {
add_error_message(&state.chat_container, &e.to_string());
}
}
tui.request_render(false);
}
pub(super) async fn run_compact(
lane: &Arc<dyn AgentLane>,
tui: &Arc<TuiAltScreen>,
state: &Arc<TuiState>,
) {
state.set_status(RunStatus::Working);
tui.request_render(false);
match lane.compact(None).await {
Ok(_) => {
add_note_message(&state.chat_container, "Conversation compacted.");
state.footer.set_context_tokens(None);
}
Err(e) => {
add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
}
}
state.set_status(RunStatus::Idle);
tui.request_render(false);
}
pub(super) fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
let text = state.last_assistant_text.lock().unwrap().clone();
if text.is_empty() {
add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
return;
}
if copy_to_clipboard(&text) {
add_note_message(chat, "Copied last reply to the clipboard.");
} else {
let preview: String = text.chars().take(200).collect();
add_note_message(
chat,
&format!(
"Clipboard unavailable. Last reply: {preview}{}",
if text.chars().count() > 200 {
"…"
} else {
""
}
),
);
}
}
pub(super) fn extract_selected_text(
chat_container: &Arc<Container>,
start: (u16, u16),
end: (u16, u16),
) -> Option<String> {
use rpi_tui::Component;
let lines = chat_container.render(80); if lines.is_empty() {
return None;
}
let (start_row, end_row) = if start.1 <= end.1 {
(start.1 as usize, end.1 as usize)
} else {
(end.1 as usize, start.1 as usize)
};
let start_row = start_row.min(lines.len().saturating_sub(1));
let end_row = end_row.min(lines.len().saturating_sub(1));
if start_row > end_row {
return None;
}
let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
if selected_lines.is_empty() {
return None;
}
Some(selected_lines.join("\n"))
}
#[cfg(all(feature = "clipboard", not(target_os = "android")))]
pub(super) fn copy_to_clipboard(text: &str) -> bool {
match arboard::Clipboard::new() {
Ok(mut cb) => cb.set_text(text).is_ok(),
Err(_) => false,
}
}
#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
pub(super) fn copy_to_clipboard(_text: &str) -> bool {
false
}
#[cfg(all(feature = "clipboard", not(target_os = "android")))]
pub(super) fn read_clipboard_text() -> Option<String> {
let mut clipboard = arboard::Clipboard::new().ok()?;
clipboard.get_text().ok()
}
#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
pub(super) fn read_clipboard_text() -> Option<String> {
None
}
#[cfg(all(feature = "clipboard", not(target_os = "android")))]
pub(super) fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
let image = match clipboard.get_image() {
Ok(image) => image,
Err(_) => return Ok(None),
};
let width =
u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
let height =
u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
return Err("clipboard image dimensions are outside the supported range".into());
}
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
writer
.write_image_data(&image.bytes)
.map_err(|e| e.to_string())?;
}
Ok(Some(rpi_ai::types::ImageContent {
kind: rpi_ai::types::ImageContentType,
data: base64::engine::general_purpose::STANDARD.encode(bytes),
mime_type: "image/png".into(),
}))
}
#[cfg(all(target_os = "macos", feature = "clipboard"))]
pub(super) fn read_clipboard_file_paths() -> Option<Vec<String>> {
let script = "try\nset items to the clipboard as alias list\nset paths to {}\nrepeat with itemPath in items\nset end of paths to POSIX path of itemPath\nend repeat\nset AppleScript's text item delimiters to linefeed\nreturn paths as text\non error\nreturn \"\"\nend try";
let output = std::process::Command::new("osascript")
.args(["-e", script])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
let paths: Vec<String> = text
.lines()
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect();
if paths.is_empty() {
None
} else {
Some(paths)
}
}
#[cfg(all(windows, feature = "clipboard"))]
pub(super) fn read_clipboard_file_paths() -> Option<Vec<String>> {
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, GetClipboardData, OpenClipboard,
};
use windows_sys::Win32::UI::Shell::DragQueryFileW;
const CF_HDROP: u32 = 15;
struct ClipboardRead;
impl Drop for ClipboardRead {
fn drop(&mut self) {
unsafe {
CloseClipboard();
}
}
}
if unsafe { OpenClipboard(std::ptr::null_mut()) } == 0 {
return None;
}
let _guard = ClipboardRead;
let drop = unsafe { GetClipboardData(CF_HDROP) };
if drop.is_null() {
return None;
}
let count = unsafe { DragQueryFileW(drop, u32::MAX, std::ptr::null_mut(), 0) };
let mut paths = Vec::new();
for index in 0..count {
let length = unsafe { DragQueryFileW(drop, index, std::ptr::null_mut(), 0) };
if length == 0 {
continue;
}
let mut buffer = vec![0u16; length as usize + 1];
let copied =
unsafe { DragQueryFileW(drop, index, buffer.as_mut_ptr(), buffer.len() as u32) };
if copied == 0 {
continue;
}
let path = String::from_utf16(&buffer[..copied as usize]).ok()?;
paths.push(path);
}
if paths.is_empty() {
None
} else {
Some(paths)
}
}
#[cfg(not(all(any(target_os = "macos", windows), feature = "clipboard")))]
pub(super) fn read_clipboard_file_paths() -> Option<Vec<String>> {
None
}
pub(super) fn attach_pasted_images(state: &Arc<TuiState>, text: &str) -> bool {
let Some(images) = images_from_pasted_paths(text) else {
return false;
};
let count = images.len();
for image in images {
state.queue_image(image);
}
add_note_message(
&state.chat_container,
&format!("Attached {count} image(s) to the next prompt."),
);
true
}
pub(super) fn images_from_pasted_paths(text: &str) -> Option<Vec<rpi_ai::types::ImageContent>> {
let candidate = text.trim().trim_matches(['\"', '\'']);
let single = std::path::Path::new(candidate);
if single.is_file() {
return crate::app::image_content_from_path(single)
.ok()
.flatten()
.map(|image| vec![image]);
}
let mut paths = Vec::new();
if text.contains('\n') {
paths.extend(
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().trim_matches(['\"', '\'']).to_owned()),
);
} else {
let mut path = String::new();
let mut quote = None;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\'' | '"' if quote == Some(ch) => quote = None,
'\'' | '"' if quote.is_none() => quote = Some(ch),
'\\' if quote != Some('\'')
&& chars.peek().is_some_and(|ch| {
ch.is_whitespace() || matches!(ch, '\'' | '"' | '\\')
}) =>
{
path.push(chars.next().unwrap());
}
ch if ch.is_whitespace() && quote.is_none() => {
if !path.is_empty() {
paths.push(std::mem::take(&mut path));
}
}
_ => path.push(ch),
}
}
if quote.is_some() {
return None;
}
if !path.is_empty() {
paths.push(path);
}
}
if paths.is_empty() {
return None;
}
paths
.iter()
.map(|path| {
crate::app::image_content_from_path(std::path::Path::new(path))
.ok()
.flatten()
})
.collect()
}
pub(super) fn user_message_with_images(
text: &str,
images: Vec<rpi_ai::types::ImageContent>,
) -> AgentMessage {
let content = if images.is_empty() {
rpi_ai::types::UserContent::Text(text.into())
} else {
let mut blocks = Vec::with_capacity(images.len() + 1);
if !text.is_empty() {
blocks.push(rpi_ai::types::Content::text(text));
}
blocks.extend(images.into_iter().map(rpi_ai::types::Content::Image));
rpi_ai::types::UserContent::Blocks(blocks)
};
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default();
AgentMessage::User(UserMessage::new(content, timestamp))
}
pub(super) fn add_image_previews(
chat: &Arc<Container>,
images: &[rpi_ai::types::ImageContent],
show: bool,
) {
for image in images {
add_image_preview(chat, image, show);
}
}
pub(super) fn add_user_images(
chat: &Arc<Container>,
user: &rpi_ai::types::UserMessage,
show: bool,
) {
if let rpi_ai::types::UserContent::Blocks(blocks) = &user.content {
for block in blocks {
if let rpi_ai::types::Content::Image(image) = block {
add_image_preview(chat, image, show);
}
}
}
}
pub(super) fn add_tool_images(
chat: &Arc<Container>,
content: &[rpi_agent::types::TextContentOrImage],
show: bool,
) {
for block in content {
if let rpi_agent::types::TextContentOrImage::Image(image) = block {
add_image_preview(
chat,
&rpi_ai::types::ImageContent {
kind: rpi_ai::types::ImageContentType,
data: image.data.clone(),
mime_type: image.mime_type.clone(),
},
show,
);
}
}
}
pub(super) fn add_image_preview(
chat: &Arc<Container>,
image: &rpi_ai::types::ImageContent,
show: bool,
) {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
let bytes = if image.mime_type == "image/png" {
bytes
} else {
match rpi_tools::image_processing::process_image(&bytes, Default::default()) {
Ok(png) => png,
Err(_) => {
add_note_message(chat, "[Image preview unavailable]");
return;
}
}
};
let mut options = ImageOptions::default();
options.width = Some(60);
options.alt_text = Some("[Attached image]".into());
let preview = Arc::new(Image::from_data(bytes, options));
preview.set_inline_visible(show);
chat.add_child(preview);
chat.add_child(Arc::new(Spacer::new(1)));
}
}
#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
pub(super) fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
Ok(None)
}
pub(super) fn add_assistant_message_blocking(
container: &Arc<Container>,
text: &str,
transformer: Option<MarkdownTransformer>,
) {
if text.is_empty() {
return;
}
let msg = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(t) = &transformer {
msg.set_markdown_transformer(Some(t.clone()));
}
msg.update_text(text);
container.add_child(msg);
container.add_child(Arc::new(Spacer::new(1)));
}