use std::collections::{HashMap, HashSet, VecDeque};
use std::io::IsTerminal;
use std::sync::{mpsc as std_mpsc, Arc, Mutex};
use base64::Engine;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use tokio::sync::{broadcast, mpsc};
use tokio_util::sync::CancellationToken;
use rpi_agent::{AgentEvent, AgentMessage};
use rpi_ai::types::{AssistantMessage, Content, UserMessage};
use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
#[cfg(test)]
use rpi_tui::strip_ansi;
use rpi_tui::{
apply_theme_preset, render_diff, AssistantBlock, AssistantMessageComponent,
AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
FooterComponent, Image, ImageOptions, Input, Loader, ProcessTerminal, ScrollView,
ScrollViewOptions, SelectItem, SelectList, SlashCommand as SlashCommandEntry,
SlashCommandAutocompleteProvider, Spacer, StackChild, StackEntry, StatusIndicator, Text,
ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack,
TUI,
};
use rpi_tui::{bold as tui_bold, theme as current_theme};
#[allow(unused_imports)]
use rpi_tui::BashStatus;
use crate::args::Args;
type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
#[derive(Clone, Default)]
struct JsDialogBridge {
pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
visible: Arc<Mutex<Option<String>>>,
cancelled_before_open: Arc<Mutex<HashSet<String>>>,
closed: Arc<Mutex<bool>>,
}
struct JsDialogPending {
request: JsDialogRequest,
result: std_mpsc::Sender<serde_json::Value>,
}
struct JsDialogActive {
result: std_mpsc::Sender<serde_json::Value>,
cancel_requested: bool,
}
#[derive(Clone, Debug)]
struct JsDialogRequest {
id: String,
method: String,
title: String,
message: String,
options: Vec<String>,
placeholder: Option<String>,
prefill: Option<String>,
}
impl JsDialogRequest {
fn parse(args: &serde_json::Value) -> Result<Self, String> {
let id = args
.get("dialogId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.ok_or("ui.dialog missing dialogId")?
.to_string();
let method = args
.get("method")
.and_then(serde_json::Value::as_str)
.ok_or("ui.dialog missing method")?
.to_string();
if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
return Err(format!("unsupported UI dialog method: {method}"));
}
let options = args
.get("options")
.and_then(serde_json::Value::as_array)
.map(|values| {
values
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default();
Ok(Self {
id,
method,
title: args
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
message: args
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
options,
placeholder: args
.get("placeholder")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned),
prefill: args
.get("prefill")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned),
})
}
}
impl JsDialogBridge {
fn handle_runtime_request(
&self,
action: &str,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
match action {
"ui.dialog" => self.wait_for_dialog(args),
"ui.dialog.cancel" => {
let id = args
.get("dialogId")
.and_then(serde_json::Value::as_str)
.ok_or("ui.dialog.cancel missing dialogId")?;
self.cancel(id);
Ok(serde_json::json!(true))
}
_ => Err(format!("unsupported capability: {action}")),
}
}
fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let request = JsDialogRequest::parse(&args)?;
let (sender, receiver) = std_mpsc::channel();
let _closed = self
.closed
.lock()
.map_err(|_| "JS dialog bridge poisoned")?;
if *_closed {
return Ok(serde_json::json!({ "cancelled": true }));
}
let cancelled_before_open = self
.cancelled_before_open
.lock()
.map_err(|_| "JS dialog cancellation state poisoned")?
.remove(&request.id);
if cancelled_before_open {
return Ok(serde_json::json!({ "cancelled": true }));
}
self.pending
.lock()
.map_err(|_| "JS dialog pending state poisoned")?
.push_back(JsDialogPending {
request,
result: sender,
});
drop(_closed);
receiver
.recv()
.map_err(|_| "JS dialog closed before it received an answer".to_string())
}
fn take_pending(&self) -> Option<JsDialogRequest> {
loop {
let pending = self.pending.lock().ok()?.pop_front()?;
let mut active = self.active.lock().ok()?;
if self
.cancelled_before_open
.lock()
.ok()?
.remove(&pending.request.id)
{
drop(active);
let _ = pending
.result
.send(serde_json::json!({ "cancelled": true }));
continue;
}
active.insert(
pending.request.id.clone(),
JsDialogActive {
result: pending.result,
cancel_requested: false,
},
);
if let Ok(mut visible) = self.visible.lock() {
*visible = Some(pending.request.id.clone());
}
return Some(pending.request);
}
}
fn respond(&self, id: &str, result: serde_json::Value) {
if let Ok(mut active) = self.active.lock() {
if let Some(entry) = active.remove(id) {
let _ = entry.result.send(result);
}
}
if let Ok(mut visible) = self.visible.lock() {
if visible.as_deref() == Some(id) {
*visible = None;
}
}
}
fn cancel(&self, id: &str) {
if let Ok(mut pending) = self.pending.lock() {
if let Some(index) = pending.iter().position(|item| item.request.id == id) {
if let Some(item) = pending.remove(index) {
let _ = item.result.send(serde_json::json!({ "cancelled": true }));
return;
}
}
}
if let Ok(mut active) = self.active.lock() {
if let Some(entry) = active.get_mut(id) {
if !entry.cancel_requested {
entry.cancel_requested = true;
let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
}
return;
}
}
if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
cancelled.insert(id.to_string());
}
}
fn cancelled_active_ids(&self) -> Vec<String> {
self.active
.lock()
.map(|active| {
active
.iter()
.filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
.collect()
})
.unwrap_or_default()
}
fn is_visible(&self, id: &str) -> bool {
self.visible
.lock()
.map(|visible| visible.as_deref() == Some(id))
.unwrap_or(false)
}
fn finish(&self, id: &str) {
if let Ok(mut active) = self.active.lock() {
active.remove(id);
}
if let Ok(mut visible) = self.visible.lock() {
if visible.as_deref() == Some(id) {
*visible = None;
}
}
}
fn cancel_all(&self) {
let Ok(mut closed) = self.closed.lock() else {
return;
};
*closed = true;
if let Ok(mut pending) = self.pending.lock() {
for item in pending.drain(..) {
let _ = item.result.send(serde_json::json!({ "cancelled": true }));
}
}
if let Ok(mut active) = self.active.lock() {
for (_, entry) in active.drain() {
let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
}
}
if let Ok(mut visible) = self.visible.lock() {
*visible = None;
}
drop(closed);
}
fn cancel_open_requests(&self) {
let Ok(mut closed) = self.closed.lock() else {
return;
};
*closed = true;
if let Ok(mut pending) = self.pending.lock() {
for item in pending.drain(..) {
let _ = item.result.send(serde_json::json!({ "cancelled": true }));
}
}
if let Ok(mut active) = self.active.lock() {
for (_, entry) in active.drain() {
let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
}
}
if let Ok(mut visible) = self.visible.lock() {
*visible = None;
}
if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
cancelled.clear();
}
drop(closed);
}
fn reopen(&self) {
if let Ok(mut closed) = self.closed.lock() {
*closed = false;
}
}
}
fn build_markdown_transformer(
snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
) -> Option<MarkdownTransformer> {
let snapshot = snapshot?;
let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
if renderers.is_empty() {
return None;
}
Some(Arc::new(move |raw: &str| -> String {
transform_markdown_chain(&snapshot, &renderers, raw)
}))
}
fn transform_markdown_chain(
snapshot: &rpi_extensions::RegistrySnapshot,
renderers: &[rpi_extensions::RegisteredRenderer],
raw: &str,
) -> String {
if !snapshot.is_active() {
return raw.to_string();
}
let mut current = raw.to_string();
for renderer in renderers {
let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
Ok(s) => s,
Err(_) => return current, };
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut out = rpi_plugin_sdk::StbString::empty();
let rc = (renderer.render_fn)(
rpi_plugin_sdk::StbStringRef::from_str(&input),
&mut out as *mut rpi_plugin_sdk::StbString,
renderer.user_data,
);
let text = if rc == 0 {
let s = out.to_string_lossy();
Some(s)
} else {
None
};
out.free_with(Some(renderer.plugin_free_string));
text
}));
let out_text = match outcome {
Ok(Some(s)) => s,
Ok(None) => return current, Err(_) => return current, };
let next = serde_json::from_str::<serde_json::Value>(&out_text)
.ok()
.and_then(|v| {
v.get("markdown")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
.unwrap_or(current);
current = next;
}
current
}
#[derive(Clone)]
struct CommandContext {
chat: Arc<Container>,
tui: Arc<TuiAltScreen>,
tx: mpsc::UnboundedSender<TuiMessage>,
state: Arc<TuiState>,
editor: Arc<Editor>,
editor_container: Arc<Container>,
lane: Arc<dyn AgentLane>,
model_catalog: Arc<Vec<rpi_ai::Model>>,
lane_model_id: String,
cwd: std::path::PathBuf,
package_resources: Arc<crate::packages::PackageResources>,
resources: Arc<rpi_harness::types::AgentHarnessResources>,
reload_context: Arc<crate::session::ReloadContext>,
}
trait SlashCommand: Send + Sync {
fn name(&self) -> &str;
fn aliases(&self) -> &'static [&'static str] {
&[]
}
fn visible(&self) -> bool {
true
}
fn alias_visible(&self) -> &'static [&'static str] {
&[]
}
fn description(&self) -> &'static str {
""
}
fn description_owned(&self) -> String {
self.description().to_string()
}
fn execute(&self, ctx: &CommandContext, args: &str);
}
struct CommandRegistry {
commands: Vec<Arc<dyn SlashCommand>>,
}
impl CommandRegistry {
fn new() -> Self {
Self {
commands: Vec::new(),
}
}
fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
self.commands.push(cmd);
}
fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
self.commands
.iter()
.find(|c| c.name() == token || c.aliases().contains(&token))
}
fn visible_entries(&self) -> Vec<SlashCommandEntry> {
let mut out: Vec<SlashCommandEntry> = Vec::new();
for c in &self.commands {
let description = c.description_owned();
if c.visible() && !description.is_empty() {
out.push(SlashCommandEntry {
name: c.name().into(),
description: description.clone(),
});
}
for alias in c.alias_visible() {
out.push(SlashCommandEntry {
name: (*alias).into(),
description: description.clone(),
});
}
}
out
}
}
fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
let mut parts = text.split_whitespace();
let token = parts.next().unwrap_or("");
let args = parts.collect::<Vec<_>>().join(" ");
match registry.find(token) {
Some(cmd) => cmd.execute(ctx, &args),
None => {
add_error_message(
&ctx.chat,
&format!("Unknown command: {text}. Type /help for available commands."),
);
ctx.tui.request_render(false);
}
}
}
fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
use crossterm::event::{KeyCode, KeyModifiers};
let modifiers = key.modifiers;
let ctrl = modifiers.contains(KeyModifiers::CONTROL);
let shift = modifiers.contains(KeyModifiers::SHIFT);
let alt = modifiers.contains(KeyModifiers::ALT);
let super_key = modifiers.contains(KeyModifiers::SUPER);
if modifiers == KeyModifiers::NONE {
return match key.code {
KeyCode::Char(ch) => ch.to_string(),
KeyCode::Enter => "\r".into(),
KeyCode::Esc => "\x1b".into(),
KeyCode::Backspace => "\x7f".into(),
KeyCode::Tab => "\t".into(),
KeyCode::BackTab => "\x1b[Z".into(),
KeyCode::Up => "\x1b[A".into(),
KeyCode::Down => "\x1b[B".into(),
KeyCode::Right => "\x1b[C".into(),
KeyCode::Left => "\x1b[D".into(),
KeyCode::Home => "\x1b[H".into(),
KeyCode::End => "\x1b[F".into(),
KeyCode::PageUp => "\x1b[5~".into(),
KeyCode::PageDown => "\x1b[6~".into(),
KeyCode::Delete => "\x1b[3~".into(),
KeyCode::Insert => "\x1b[2~".into(),
KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
_ => String::new(),
};
}
if ctrl && !shift && !alt && !super_key {
if let KeyCode::Char(ch) = key.code {
if let Some(code) = control_code(ch) {
return char::from(code).to_string();
}
}
}
if alt && !ctrl && !shift && !super_key {
if let KeyCode::Char(ch) = key.code {
return format!("\x1b{ch}");
}
}
if shift && !ctrl && !alt && !super_key {
if let KeyCode::Char(ch) = key.code {
return ch.to_string();
}
}
if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
return sequence;
}
let Some(codepoint) = key_codepoint(key.code, ctrl) else {
return String::new();
};
kitty_key_sequence(codepoint, modifiers)
}
fn control_code(ch: char) -> Option<u8> {
let ch = ch.to_ascii_lowercase();
Some(match ch {
'@' | ' ' => 0,
'a'..='z' => (ch as u8) & 0x1f,
'[' => 0x1b,
'\\' => 0x1c,
']' => 0x1d,
'^' => 0x1e,
'_' | '-' => 0x1f,
_ => return None,
})
}
fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
use crossterm::event::KeyCode;
Some(match code {
KeyCode::Char(ch) => {
if ctrl {
ch.to_ascii_lowercase() as u32
} else {
ch as u32
}
}
KeyCode::Enter => 13,
KeyCode::Esc => 27,
KeyCode::Backspace => 127,
KeyCode::Tab => 9,
KeyCode::BackTab => 9,
_ => return None,
})
}
fn modified_functional_sequence(
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<String> {
use crossterm::event::{KeyCode, KeyModifiers};
if code == KeyCode::BackTab
&& !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
return Some("\x1b[Z".into());
}
let modifier = kitty_modifier(modifiers);
let sequence = match code {
KeyCode::Up => format!("\x1b[1;{modifier}A"),
KeyCode::Down => format!("\x1b[1;{modifier}B"),
KeyCode::Right => format!("\x1b[1;{modifier}C"),
KeyCode::Left => format!("\x1b[1;{modifier}D"),
KeyCode::Home => format!("\x1b[1;{modifier}H"),
KeyCode::End => format!("\x1b[1;{modifier}F"),
KeyCode::Insert => format!("\x1b[2;{modifier}~"),
KeyCode::Delete => format!("\x1b[3;{modifier}~"),
KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
_ => return None,
};
Some(sequence)
}
fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
use crossterm::event::KeyModifiers;
let mut modifier = 1u8;
if modifiers.contains(KeyModifiers::SHIFT) {
modifier += 1;
}
if modifiers.contains(KeyModifiers::ALT) {
modifier += 2;
}
if modifiers.contains(KeyModifiers::CONTROL) {
modifier += 4;
}
if modifiers.contains(KeyModifiers::SUPER) {
modifier += 8;
}
modifier
}
fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
let modifier = kitty_modifier(modifiers);
format!("\x1b[{codepoint};{modifier}u")
}
struct ExtensionCommand {
name: String,
description: String,
session: crate::session::ExtensionSessionCell,
}
struct JsExtensionCommand {
name: String,
session: crate::js_extensions::JsExtensionSession,
}
impl SlashCommand for JsExtensionCommand {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &'static str {
"JS extension command"
}
fn description_owned(&self) -> String {
"JS extension command".to_string()
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let session = self.session.clone();
let command = self.name.trim_start_matches('/').to_string();
let args = args.to_string();
let ctx = ctx.clone();
let initial_editor_text = ctx.editor.get_text();
tokio::task::spawn_blocking(move || {
match session.invoke_command_with_context(
&command,
&args,
serde_json::json!({"editorText": initial_editor_text}),
) {
Ok(value) => {
if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
if ctx.editor.get_text() == initial_editor_text
&& editor_text != initial_editor_text
{
let cursor = editor_text.chars().count();
ctx.editor.set_text(editor_text);
ctx.editor.set_cursor(0, cursor);
}
}
if let Some(notifications) =
value.get("notifications").and_then(|v| v.as_array())
{
for notification in notifications {
let message = notification
.get("message")
.and_then(|v| v.as_str())
.unwrap_or_default();
if message.is_empty() {
continue;
}
match notification.get("level").and_then(|v| v.as_str()) {
Some("error") => add_error_message(&ctx.chat, message),
_ => add_note_message(&ctx.chat, message),
}
}
}
let result = value.get("result").unwrap_or(&value);
let text = result
.get("text")
.and_then(|item| item.as_str())
.map(str::to_string)
.or_else(|| result.as_str().map(str::to_string))
.filter(|text| !text.is_empty() && text != "null");
if let Some(text) = text {
add_note_message(&ctx.chat, &text);
}
}
Err(error) => {
add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
}
}
ctx.tui.request_render(false);
});
}
}
impl SlashCommand for ExtensionCommand {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &'static str {
"extension command"
}
fn description_owned(&self) -> String {
self.description.clone()
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let result = invoke_extension_command(&self.session, &self.name, args);
handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
}
}
fn invoke_extension_command(
session: &crate::session::ExtensionSessionCell,
name: &str,
args: &str,
) -> Option<serde_json::Value> {
let command = session
.lock()
.ok()
.and_then(|s| s.snapshot_arc())
.and_then(|snap| {
snap.commands()
.iter()
.find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
.cloned()
})?;
let input = serde_json::json!({ "args": args, "command": name });
let input = serde_json::to_string(&input).ok()?;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut out = rpi_plugin_sdk::StbString::empty();
let rc = (command.handler)(
rpi_plugin_sdk::StbStringRef::from_str(&input),
&mut out as *mut rpi_plugin_sdk::StbString,
command.user_data,
);
let text = if rc == 0 {
Some(out.to_string_lossy())
} else {
None
};
rpi_extensions::host_free_string(out);
text
}))
.ok()
.flatten()?;
serde_json::from_str(&outcome).ok()
}
fn handle_extension_ui_result(
result: Option<serde_json::Value>,
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
) {
let Some(value) = result else {
add_error_message(&ctx.chat, "Extension command failed.");
ctx.tui.request_render(false);
return;
};
if value.is_null() {
ctx.tui.request_render(false);
return;
}
match value.get("kind").and_then(|v| v.as_str()) {
Some("message") | None => {
let fallback = value.to_string();
let text = value
.get("text")
.and_then(|v| v.as_str())
.unwrap_or(&fallback)
.to_string();
if !text.is_empty() {
add_note_message(&ctx.chat, &text);
}
ctx.tui.request_render(false);
}
Some("selector") => open_extension_selector(ctx, session, command_name, value),
Some("editor") => open_extension_editor(ctx, session, command_name, value),
Some("input") => open_extension_input(ctx, session, command_name, value),
Some(other) => {
add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
ctx.tui.request_render(false);
}
}
}
fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
let colors = current_theme().colors;
let text = if bold {
tui_bold(title)
} else {
title.to_string()
};
Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
}
fn extension_dialog_hint(label: &str) -> Arc<Text> {
Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
}
fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
let callback = state.active_extension_cancel.lock().unwrap().take();
if let Some(callback) = callback {
callback();
true
} else {
false
}
}
fn open_extension_input(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let title = value
.get("title")
.and_then(|v| v.as_str())
.filter(|title| !title.is_empty())
.unwrap_or("Input");
let input = value
.get("placeholder")
.and_then(|v| v.as_str())
.map(Input::with_placeholder)
.unwrap_or_default();
let input = Arc::new(input);
if let Some(initial) = value
.get("initialText")
.or_else(|| value.get("text"))
.and_then(|v| v.as_str())
{
input.set_value(initial);
}
input.set_focused(true);
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(title, false));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(input.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
*ctx.state.active_extension_editor.lock().unwrap() = None;
*ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
ctx.editor_container.clear();
ctx.editor_container.add_child(frame);
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let original = ctx.editor.clone();
let tui = ctx.tui.clone();
let session_submit = session.clone();
let command_submit = command_name.clone();
let ctx_submit = ctx.clone();
input.on_submit(Arc::new(move |text| {
let args = serde_json::json!({ "action": "input", "value": text, "text": text });
let result = invoke_extension_command(
&session_submit,
&command_submit,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state, &ec, &original, &tui);
handle_extension_ui_result(
result,
&ctx_submit,
session_submit.clone(),
command_submit.clone(),
);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let original_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
let session_cancel = session.clone();
let command_cancel = command_name.clone();
let ctx_cancel = ctx.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
let args = serde_json::json!({ "action": "cancel" });
let result = invoke_extension_command(
&session_cancel,
&command_cancel,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
handle_extension_ui_result(
result,
&ctx_cancel,
session_cancel.clone(),
command_cancel.clone(),
);
}));
ctx.tui.set_focus(Some(input));
ctx.tui.request_render(false);
}
fn open_extension_selector(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let items = value
.get("items")
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
let value = if let Some(value) = item.as_str() {
value
} else {
item.get("value")?.as_str()?
};
let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
let mut out = SelectItem::new(value, label);
if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
out = out.with_description(desc);
}
Some(out)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if items.is_empty() {
add_error_message(&ctx.chat, "Extension selector has no items.");
ctx.tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let title = value
.get("title")
.and_then(|v| v.as_str())
.filter(|title| !title.is_empty())
.unwrap_or("Select");
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(title, true));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(list.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint(
"↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let editor = ctx.editor.clone();
let tui = ctx.tui.clone();
let session_select = session.clone();
let command_select = command_name.clone();
let ctx_select = ctx.clone();
list.on_select(Arc::new(move |item| {
let args = serde_json::json!({ "action": "select", "value": item.value });
let result = invoke_extension_command(
&session_select,
&command_select,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_selector(&state, &ec, &editor, &tui);
handle_extension_ui_result(
result,
&ctx_select,
session_select.clone(),
command_select.clone(),
);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
list.on_cancel(Arc::new(move || {
if !run_extension_cancel(&state_cancel) {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
let session_cancel = session.clone();
let command_cancel = command_name.clone();
let ctx_cancel = ctx.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
let args = serde_json::json!({ "action": "cancel" });
let result = invoke_extension_command(
&session_cancel,
&command_cancel,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
handle_extension_ui_result(
result,
&ctx_cancel,
session_cancel.clone(),
command_cancel.clone(),
);
}));
open_selector_with_view(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
list,
frame,
SelectorKind::Extension,
);
}
fn open_extension_editor(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let initial = value
.get("initialText")
.or_else(|| value.get("text"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let title = value
.get("title")
.and_then(|v| v.as_str())
.filter(|title| !title.is_empty())
.unwrap_or("Editor");
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
autocomplete_max_visible: 0,
placeholder: value
.get("placeholder")
.and_then(|v| v.as_str())
.map(str::to_string),
initial_text: Some(initial),
},
EditorStyle {
prompt: "> ".to_string(),
placeholder: String::new(),
},
Arc::new(rpi_tui::Keybindings::new()),
));
editor.set_focused(true);
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(title, false));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(editor.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint(
"Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
*ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
*ctx.state.active_extension_input.lock().unwrap() = None;
ctx.editor_container.clear();
ctx.editor_container.add_child(frame);
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let original = ctx.editor.clone();
let tui = ctx.tui.clone();
let session_submit = session.clone();
let command_submit = command_name.clone();
let ctx_submit = ctx.clone();
editor.on_submit(Arc::new(move |text| {
let args = serde_json::json!({ "action": "edit", "text": text });
let result = invoke_extension_command(
&session_submit,
&command_submit,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state, &ec, &original, &tui);
handle_extension_ui_result(
result,
&ctx_submit,
session_submit.clone(),
command_submit.clone(),
);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let original_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
let session_cancel = session.clone();
let command_cancel = command_name.clone();
let ctx_cancel = ctx.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
let args = serde_json::json!({ "action": "cancel" });
let result = invoke_extension_command(
&session_cancel,
&command_cancel,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
handle_extension_ui_result(
result,
&ctx_cancel,
session_cancel.clone(),
command_cancel.clone(),
);
}));
ctx.tui.set_focus(Some(editor));
ctx.tui.request_render(false);
}
fn close_extension_editor(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
) {
editor_container.clear();
editor_container.add_child(editor.clone());
*state.active_extension_editor.lock().unwrap() = None;
*state.active_extension_input.lock().unwrap() = None;
*state.active_extension_cancel.lock().unwrap() = None;
editor.set_focused(true);
tui.set_focus(Some(editor.clone()));
tui.request_render(false);
}
fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
match request.method.as_str() {
"select" => open_js_selector(ctx, bridge, request, false),
"confirm" => open_js_selector(ctx, bridge, request, true),
"input" => open_js_input(ctx, bridge, request),
"editor" => open_js_editor(ctx, bridge, request),
_ => {
bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
}
}
}
fn open_js_selector(
ctx: &CommandContext,
bridge: Arc<JsDialogBridge>,
request: JsDialogRequest,
confirm: bool,
) {
let values = if confirm {
vec!["Yes".to_string(), "No".to_string()]
} else {
request.options.clone()
};
if values.is_empty() {
bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
return;
}
let items = values
.iter()
.map(|value| SelectItem::new(value, value))
.collect::<Vec<_>>();
let list = Arc::new(SelectList::new(items, 10));
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(
if request.title.is_empty() {
if confirm {
"Confirm"
} else {
"Select"
}
} else {
request.title.as_str()
},
true,
));
if !request.message.is_empty() {
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
}
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(list.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint(
"↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
let id = request.id.clone();
let bridge_select = bridge.clone();
let state_select = ctx.state.clone();
let ec_select = ctx.editor_container.clone();
let editor_select = ctx.editor.clone();
let tui_select = ctx.tui.clone();
list.on_select(Arc::new(move |item| {
let result = if confirm {
serde_json::json!({ "confirmed": item.value == "Yes" })
} else {
serde_json::json!({ "value": item.value })
};
bridge_select.respond(&id, result);
close_selector(&state_select, &ec_select, &editor_select, &tui_select);
}));
let id_cancel = request.id.clone();
let bridge_cancel = bridge.clone();
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
list.on_cancel(Arc::new(move || {
bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
let id_abort = request.id.clone();
let bridge_abort = bridge.clone();
let state_abort = ctx.state.clone();
let ec_abort = ctx.editor_container.clone();
let editor_abort = ctx.editor.clone();
let tui_abort = ctx.tui.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
}));
open_selector_with_view(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
list,
frame,
SelectorKind::Extension,
);
}
fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
let input = request
.placeholder
.as_deref()
.map(Input::with_placeholder)
.unwrap_or_default();
let input = Arc::new(input);
input.set_focused(true);
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(
if request.title.is_empty() {
"Input"
} else {
&request.title
},
false,
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(input.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
*ctx.state.active_extension_editor.lock().unwrap() = None;
*ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
ctx.editor_container.clear();
ctx.editor_container.add_child(frame);
let id = request.id.clone();
let bridge_submit = bridge.clone();
let state_submit = ctx.state.clone();
let ec_submit = ctx.editor_container.clone();
let editor_submit = ctx.editor.clone();
let tui_submit = ctx.tui.clone();
input.on_submit(Arc::new(move |value| {
bridge_submit.respond(&id, serde_json::json!({ "value": value }));
close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
}));
let id_cancel = request.id.clone();
let bridge_cancel = bridge.clone();
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
ctx.tui.set_focus(Some(input));
ctx.tui.request_render(false);
}
fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
autocomplete_max_visible: 0,
initial_text: request.prefill.clone(),
..Default::default()
},
EditorStyle {
prompt: "> ".to_string(),
placeholder: String::new(),
},
Arc::new(rpi_tui::Keybindings::new()),
));
editor.set_focused(true);
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(
if request.title.is_empty() {
"Editor"
} else {
&request.title
},
false,
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(editor.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint(
"Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
*ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
*ctx.state.active_extension_input.lock().unwrap() = None;
ctx.editor_container.clear();
ctx.editor_container.add_child(frame);
let id = request.id.clone();
let bridge_submit = bridge.clone();
let state_submit = ctx.state.clone();
let ec_submit = ctx.editor_container.clone();
let editor_submit = ctx.editor.clone();
let tui_submit = ctx.tui.clone();
editor.on_submit(Arc::new(move |value| {
bridge_submit.respond(&id, serde_json::json!({ "value": value }));
close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
}));
let id_cancel = request.id.clone();
let bridge_cancel = bridge.clone();
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
ctx.tui.set_focus(Some(editor));
ctx.tui.request_render(false);
}
fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
for id in bridge.cancelled_active_ids() {
if !bridge.is_visible(&id) {
bridge.finish(&id);
continue;
}
if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
} else if ctx.state.extension_dialog_open() {
if !run_extension_cancel(&ctx.state) {
close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
}
}
bridge.finish(&id);
}
}
struct HelpCommand;
impl SlashCommand for HelpCommand {
fn name(&self) -> &'static str {
"/help"
}
fn aliases(&self) -> &'static [&'static str] {
&["/?"]
}
fn description(&self) -> &'static str {
"Show available commands"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_help_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct ClearChatCommand;
impl SlashCommand for ClearChatCommand {
fn name(&self) -> &'static str {
"/clear"
}
fn aliases(&self) -> &'static [&'static str] {
&["/new"]
}
fn alias_visible(&self) -> &'static [&'static str] {
&["/new"]
}
fn description(&self) -> &'static str {
"Clear the conversation"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ClearChat);
}
}
struct ExitCommand;
impl SlashCommand for ExitCommand {
fn name(&self) -> &'static str {
"/exit"
}
fn aliases(&self) -> &'static [&'static str] {
&["/quit", "/q"]
}
fn alias_visible(&self) -> &'static [&'static str] {
&["/quit"]
}
fn description(&self) -> &'static str {
"Exit the application"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
ctx.state.cancel_js_preparation();
let _ = ctx.tx.send(TuiMessage::Exit);
}
}
struct VersionCommand;
impl SlashCommand for VersionCommand {
fn name(&self) -> &'static str {
"/version"
}
fn aliases(&self) -> &'static [&'static str] {
&["/v"]
}
fn description(&self) -> &'static str {
"Show version information"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_version_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct ChangelogCommand;
impl SlashCommand for ChangelogCommand {
fn name(&self) -> &'static str {
"/changelog"
}
fn description(&self) -> &'static str {
"Show recent release changes"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_changelog_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct HotkeysCommand;
impl SlashCommand for HotkeysCommand {
fn name(&self) -> &'static str {
"/hotkeys"
}
fn description(&self) -> &'static str {
"Show keyboard shortcuts"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_hotkeys_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct ModelCommand;
impl SlashCommand for ModelCommand {
fn name(&self) -> &'static str {
"/model"
}
fn aliases(&self) -> &'static [&'static str] {
&["/m"]
}
fn description(&self) -> &'static str {
"Choose a model (selector)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let term = args.trim();
if !term.is_empty() {
let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
add_error_message(
&ctx.chat,
&format!("No model matches \"{term}\". Try /model for the list."),
);
ctx.tui.request_render(false);
return;
};
let model_id = model.id.clone();
ctx.state.set_current_model(&model);
let lane = ctx.lane.clone();
tokio::spawn(async move {
let _ = lane.set_model(model).await;
});
add_note_message(
&ctx.chat,
&format!(
"Model set to {} — applies to the next message.",
short_model_name(&model_id)
),
);
ctx.tui.request_render(false);
return;
}
open_model_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.model_catalog,
&ctx.lane,
&ctx.lane_model_id,
&ctx.chat,
);
}
}
struct ThinkingCommand;
impl SlashCommand for ThinkingCommand {
fn name(&self) -> &'static str {
"/thinking"
}
fn aliases(&self) -> &'static [&'static str] {
&["/think"]
}
fn description(&self) -> &'static str {
"Set thinking level (selector)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let level_name = args.trim();
if !level_name.is_empty() {
let Some(level) = thinking_level_from_name(level_name) else {
add_error_message(
&ctx.chat,
&format!(
"Unknown thinking level \"{level_name}\". Valid: {}",
crate::args::VALID_THINKING_LEVELS.join(", ")
),
);
ctx.tui.request_render(false);
return;
};
let lane = ctx.lane.clone();
let footer = ctx.state.footer.clone();
tokio::spawn(async move {
let _ = lane.set_thinking_level(level).await;
});
footer.set_thinking_level(Some(thinking_level_name(level)));
add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
ctx.tui.request_render(false);
return;
}
open_thinking_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.lane,
&ctx.model_catalog,
&ctx.lane_model_id,
&ctx.chat,
);
}
}
struct ToolsCommand;
impl SlashCommand for ToolsCommand {
fn name(&self) -> &'static str {
"/tools"
}
fn description(&self) -> &'static str {
"Toggle tools on/off"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_tools_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.lane,
&ctx.chat,
);
}
}
struct ImagesCommand;
impl SlashCommand for ImagesCommand {
fn name(&self) -> &'static str {
"/images"
}
fn description(&self) -> &'static str {
"Toggle inline images"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_images_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.chat,
);
}
}
struct SessionCommand;
impl SlashCommand for SessionCommand {
fn name(&self) -> &'static str {
"/session"
}
fn aliases(&self) -> &'static [&'static str] {
&["/resume"]
}
fn description(&self) -> &'static str {
"List saved sessions"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_session_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.cwd,
&ctx.tx,
);
}
}
struct ThemeCommand;
impl SlashCommand for ThemeCommand {
fn name(&self) -> &'static str {
"/theme"
}
fn description(&self) -> &'static str {
"Choose a theme (selector)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let name = args.trim().to_ascii_lowercase();
if !name.is_empty() {
let preset = match name.as_str() {
"light" => ThemePreset::Light,
"monochrome" => ThemePreset::Monochrome,
"dark" => ThemePreset::Dark,
_ => {
add_error_message(
&ctx.chat,
&format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
);
ctx.tui.request_render(false);
return;
}
};
apply_theme_preset(preset);
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.theme = Some(name.clone());
let _ = crate::settings::save_settings(&settings);
add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
ctx.tui.request_render(false);
ctx.tui.render_now(true);
return;
}
open_theme_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.cwd,
&ctx.package_resources,
);
}
}
struct CompactCommand;
impl SlashCommand for CompactCommand {
fn name(&self) -> &'static str {
"/compact"
}
fn description(&self) -> &'static str {
"Compact the conversation"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::Compact);
}
}
struct CopyCommand;
impl SlashCommand for CopyCommand {
fn name(&self) -> &'static str {
"/copy"
}
fn description(&self) -> &'static str {
"Copy last reply to clipboard"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::Copy);
}
}
struct ExportCommand;
impl SlashCommand for ExportCommand {
fn name(&self) -> &'static str {
"/export"
}
fn description(&self) -> &'static str {
"Export session to a markdown file"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ExportSession);
}
}
struct ForkCommand;
impl SlashCommand for ForkCommand {
fn name(&self) -> &'static str {
"/fork"
}
fn description(&self) -> &'static str {
"Fork the session into a new one"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ForkSession);
}
}
struct CloneCommand;
impl SlashCommand for CloneCommand {
fn name(&self) -> &'static str {
"/clone"
}
fn description(&self) -> &'static str {
"Duplicate the current session"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ForkSession);
}
}
struct TreeCommand;
impl SlashCommand for TreeCommand {
fn name(&self) -> &'static str {
"/tree"
}
fn description(&self) -> &'static str {
"Navigate the current session tree"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::OpenTree);
}
}
struct LoginCommand;
impl SlashCommand for LoginCommand {
fn name(&self) -> &'static str {
"/login"
}
fn description(&self) -> &'static str {
"Save an Anthropic API key"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let key = args.trim();
if key.is_empty() {
add_note_message(&ctx.chat, "Usage: /login <api-key>");
} else {
let result = crate::config::upsert_credential(
"anthropic",
crate::config::Credential::ApiKey {
key: Some(key.to_string()),
env: None,
},
);
match result {
Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
Err(error) => {
add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
}
}
}
ctx.tui.request_render(false);
}
}
struct LogoutCommand;
impl SlashCommand for LogoutCommand {
fn name(&self) -> &'static str {
"/logout"
}
fn description(&self) -> &'static str {
"Remove saved Anthropic credentials"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
match crate::config::delete_credential("anthropic") {
Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
Err(error) => {
add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
}
}
ctx.tui.request_render(false);
}
}
fn set_project_trust_for_command(
cwd: &std::path::Path,
value: Option<bool>,
) -> Result<(), crate::config::ConfigError> {
crate::config::set_project_trust(cwd, value)
}
struct TrustCommand;
impl SlashCommand for TrustCommand {
fn name(&self) -> &'static str {
"/trust"
}
fn description(&self) -> &'static str {
"Trust the current project"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let value = match args.trim().to_ascii_lowercase().as_str() {
"" | "yes" | "y" | "true" => Some(true),
"no" | "n" | "false" => Some(false),
"clear" | "reset" | "none" => None,
_ => {
add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
ctx.tui.request_render(false);
return;
}
};
match set_project_trust_for_command(&ctx.cwd, value) {
Ok(()) => {
let label = match value {
Some(true) => "trusted",
Some(false) => "untrusted",
None => "trust decision cleared",
};
add_note_message(&ctx.chat, &format!("Current project marked {label}."));
}
Err(error) => add_error_message(
&ctx.chat,
&format!("Could not save trust decision: {error}"),
),
}
ctx.tui.request_render(false);
}
}
struct NameCommand;
impl SlashCommand for NameCommand {
fn name(&self) -> &'static str {
"/name"
}
fn description(&self) -> &'static str {
"Set session display name"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let name = args.trim();
if name.is_empty() {
add_note_message(
&ctx.chat,
"Usage: /name <display name> — sets the current session's name.",
);
ctx.tui.request_render(false);
return;
}
let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
}
}
struct ImportCommand;
impl SlashCommand for ImportCommand {
fn name(&self) -> &'static str {
"/import"
}
fn description(&self) -> &'static str {
"Import a session file (path)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let path = args.trim();
if path.is_empty() {
add_note_message(
&ctx.chat,
"Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
);
ctx.tui.request_render(false);
return;
}
let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
}
}
struct SettingsCommand;
impl SlashCommand for SettingsCommand {
fn name(&self) -> &'static str {
"/settings"
}
fn description(&self) -> &'static str {
"Open settings menu"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_settings_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.lane,
&ctx.model_catalog,
&ctx.lane_model_id,
&ctx.chat,
&ctx.cwd,
&ctx.package_resources,
);
}
}
struct ScopedModelsCommand;
impl SlashCommand for ScopedModelsCommand {
fn name(&self) -> &'static str {
"/scoped-models"
}
fn description(&self) -> &'static str {
"Choose models for Ctrl+M cycling"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_scoped_models_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.model_catalog,
&ctx.chat,
);
}
}
struct ShareCommand;
impl SlashCommand for ShareCommand {
fn name(&self) -> &'static str {
"/share"
}
fn description(&self) -> &'static str {
"Share session (gist via gh, or clipboard)"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ShareSession);
}
}
struct ArminCommand;
impl SlashCommand for ArminCommand {
fn name(&self) -> &'static str {
"/armin"
}
fn description(&self) -> &'static str {
"??? (easter egg)"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
crate::extras::add_armin(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct EarendilCommand;
impl SlashCommand for EarendilCommand {
fn name(&self) -> &'static str {
"/earendil"
}
fn description(&self) -> &'static str {
"Announcement"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
crate::extras::add_earendil(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct ContextCommand;
impl SlashCommand for ContextCommand {
fn name(&self) -> &'static str {
"/context"
}
fn visible(&self) -> bool {
false
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
show_context_panel(&ctx.chat, &ctx.resources);
ctx.tui.request_render(false);
}
}
struct ReloadCommand;
impl SlashCommand for ReloadCommand {
fn name(&self) -> &'static str {
"/reload"
}
fn description(&self) -> &'static str {
"Reload extensions, skills, prompts"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_note_message(&ctx.chat, "Reloading extensions + resources…");
ctx.tui.request_render(false);
let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
}
}
fn build_builtin_registry() -> CommandRegistry {
let mut r = CommandRegistry::new();
r.register(Arc::new(HelpCommand));
r.register(Arc::new(ClearChatCommand));
r.register(Arc::new(ExitCommand));
r.register(Arc::new(VersionCommand));
r.register(Arc::new(ChangelogCommand));
r.register(Arc::new(ModelCommand));
r.register(Arc::new(ThinkingCommand));
r.register(Arc::new(ToolsCommand));
r.register(Arc::new(ImagesCommand));
r.register(Arc::new(SessionCommand));
r.register(Arc::new(ThemeCommand));
r.register(Arc::new(CompactCommand));
r.register(Arc::new(CopyCommand));
r.register(Arc::new(HotkeysCommand));
r.register(Arc::new(ArminCommand));
r.register(Arc::new(EarendilCommand));
r.register(Arc::new(ContextCommand));
r.register(Arc::new(NameCommand));
r.register(Arc::new(SettingsCommand));
r.register(Arc::new(ScopedModelsCommand));
r.register(Arc::new(ExportCommand));
r.register(Arc::new(ImportCommand));
r.register(Arc::new(ShareCommand));
r.register(Arc::new(ForkCommand));
r.register(Arc::new(CloneCommand));
r.register(Arc::new(TreeCommand));
r.register(Arc::new(TrustCommand));
r.register(Arc::new(LoginCommand));
r.register(Arc::new(LogoutCommand));
r.register(Arc::new(ReloadCommand));
r
}
fn register_extension_commands(
registry: &mut CommandRegistry,
session: crate::session::ExtensionSessionCell,
) {
let commands = session
.lock()
.ok()
.and_then(|s| s.snapshot_arc())
.map(|snap| snap.commands().to_vec())
.unwrap_or_default();
for command in commands {
let name = if command.name.starts_with('/') {
command.name.clone()
} else {
format!("/{}", command.name)
};
if registry.find(&name).is_some() {
continue;
}
registry.register(Arc::new(ExtensionCommand {
name,
description: command.description,
session: session.clone(),
}));
}
}
fn register_js_extension_commands(
registry: &mut CommandRegistry,
session: Option<crate::js_extensions::JsExtensionSession>,
) {
let Some(session) = session else {
return;
};
for command in &session.commands {
let name = if command.starts_with('/') {
command.clone()
} else {
format!("/{command}")
};
if registry.find(&name).is_none() {
registry.register(Arc::new(JsExtensionCommand {
name,
session: session.clone(),
}));
}
}
}
enum TuiMessage {
UserInput(String),
OpenTree,
NavigateTree(String),
Exit,
ClearChat,
Compact,
Copy,
SwitchSession(String),
ExportSession,
ForkSession,
SetSessionName(String),
ImportSession(String),
ShareSession,
ReloadExtensions,
ExternalEditorResult(Result<String, String>),
}
fn assistant_text(msg: &AssistantMessage) -> String {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect()
}
fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
match &msg.content {
rpi_ai::types::UserContent::Text(s) => s.clone(),
rpi_ai::types::UserContent::Blocks(blocks) => blocks
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect(),
}
}
fn show_settings_panel(chat: &Arc<Container>) {
let s = crate::settings::load_settings().unwrap_or_default();
let mut lines: Vec<String> = Vec::new();
lines.push("⚙️ Saved settings:".into());
lines.push(format!(
" Theme: {} (edit with /theme)",
s.theme.as_deref().unwrap_or("(default)")
));
lines.push(format!(
" Default model: {} (set at launch with --model)",
s.default_model.as_deref().unwrap_or("(none)")
));
lines.push(format!(
" Default thinking: {} (set at launch with --thinking)",
s.default_thinking_level.as_deref().unwrap_or("(default)")
));
match &s.scoped_models {
Some(list) if !list.is_empty() => lines.push(format!(
" Ctrl+M cycle scope: {} (edit with /scoped-models)",
list.join(", ")
)),
_ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
}
let body = lines.join("\n");
container_note_block(chat, &body);
}
fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
let scoped = crate::settings::load_settings()
.ok()
.and_then(|s| s.scoped_models)
.unwrap_or_default();
if scoped.is_empty() {
return catalog.to_vec();
}
let mut out: Vec<rpi_ai::Model> = catalog
.iter()
.filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
.cloned()
.collect();
if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
if let Some(cur) = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(current_id))
{
out.push(cur.clone());
}
}
out
}
fn open_settings_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
lane_model_id: &str,
chat: &Arc<Container>,
cwd: &std::path::Path,
package_resources: &Arc<crate::packages::PackageResources>,
) {
let settings = crate::settings::load_settings().unwrap_or_default();
let mut items: Vec<SelectItem> = Vec::new();
items.push(
SelectItem::new("theme", "Theme")
.with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
);
items.push(
SelectItem::new("model", "Default model").with_description(
&settings
.default_model
.clone()
.unwrap_or_else(|| "(none)".into()),
),
);
items.push(
SelectItem::new("thinking", "Default thinking").with_description(
&settings
.default_thinking_level
.clone()
.unwrap_or_else(|| "(default)".into()),
),
);
let scope_desc = match &settings.scoped_models {
Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
_ => "all models".to_string(),
};
items
.push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let lane_sel = lane.clone();
let chat_sel = chat.clone();
let catalog_sel = catalog.to_vec();
let lane_model_sel = lane_model_id.to_string();
let cwd_sel = cwd.to_path_buf();
let package_resources_sel = package_resources.clone();
list.on_select(Arc::new(move |item| {
match item.value.as_str() {
"theme" => open_settings_theme_selector(
&state_sel,
&ec_sel,
&editor_sel,
&tui_sel,
&chat_sel,
&cwd_sel,
&package_resources_sel,
),
"model" => open_settings_model_selector(
&state_sel,
&ec_sel,
&editor_sel,
&tui_sel,
&lane_sel,
&catalog_sel,
&lane_model_sel,
&chat_sel,
),
"thinking" => open_settings_thinking_selector(
&state_sel,
&ec_sel,
&editor_sel,
&tui_sel,
&lane_sel,
&catalog_sel,
&lane_model_sel,
&chat_sel,
),
"scoped-models" => open_scoped_models_selector(
&state_sel,
&ec_sel,
&editor_sel,
&tui_sel,
&catalog_sel,
&chat_sel,
),
_ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
}
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Settings,
);
}
fn open_settings_theme_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
chat: &Arc<Container>,
cwd: &std::path::Path,
package_resources: &Arc<crate::packages::PackageResources>,
) {
let mut items = vec![
SelectItem::new("dark", "Dark").with_description("Default dark theme"),
SelectItem::new("light", "Light").with_description("Light background"),
SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
];
if state.themes_enabled {
for path in package_resources.theme_files() {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
items.push(SelectItem::new(name, name).with_description("Package theme"));
}
}
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let cwd_sel = cwd.to_path_buf();
let package_resources_sel = package_resources.clone();
list.on_select(Arc::new(move |item| {
let preset = match item.value.as_str() {
"light" => Some(ThemePreset::Light),
"monochrome" => Some(ThemePreset::Monochrome),
"dark" => Some(ThemePreset::Dark),
name => {
if state_sel.themes_enabled {
if let Ok(custom) = crate::packages::load_theme_with_resources(
&cwd_sel,
name,
&package_resources_sel,
) {
rpi_tui::global_theme_manager().set(custom.clone());
state_sel.theme_manager.set(custom);
}
}
add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
return;
}
};
let Some(preset) = preset else { return };
apply_theme_preset(preset);
state_sel.theme_manager.apply_preset(preset);
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.theme = Some(item.value.clone());
let saved = crate::settings::save_settings(&settings);
add_note_message(
&chat_sel,
&format!(
"Theme set to {} (saved{})",
item.label,
if saved.is_ok() { "" } else { ", not saved" },
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Settings,
);
}
fn open_settings_model_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
lane_model_id: &str,
chat: &Arc<Container>,
) {
let items = model_selector_items(catalog, lane_model_id);
if items.is_empty() {
add_note_message(chat, "No models in the catalog.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let catalog_arc = catalog.to_vec();
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
return;
};
state_sel.set_current_model(&model);
let lane = lane_sel.clone();
tokio::spawn(async move {
let _ = lane.set_model(model).await;
});
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.default_model = Some(item.value.clone());
let saved = crate::settings::save_settings(&settings);
add_note_message(
&chat_sel,
&format!(
"Default model set to {} (saved{}",
short_model_name(&item.value),
if saved.is_ok() { ")" } else { ", not saved)" },
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Settings,
);
}
fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
let mut seen = std::collections::HashSet::new();
catalog
.iter()
.filter(|m| {
seen.insert((
m.api.clone(),
m.provider.to_ascii_lowercase(),
m.id.to_ascii_lowercase(),
))
})
.map(|m| {
let label = if m.name.is_empty() {
short_model_name(&m.id)
} else {
m.name.clone()
};
let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
&& m.provider.eq_ignore_ascii_case("anthropic")
{
m.id.clone()
} else {
format!("{}/{}", m.provider, m.id)
};
let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
" (current)"
} else {
""
};
SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
})
.collect()
}
fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
let (provider, id) = input
.split_once('/')
.filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
.map_or((None, input), |(provider, id)| (Some(provider), id));
catalog
.iter()
.find(|model| {
model.id.eq_ignore_ascii_case(id)
&& provider.map_or(true, |provider| {
model.provider.eq_ignore_ascii_case(provider)
|| (provider.eq_ignore_ascii_case("anthropic")
&& matches!(model.api, rpi_ai::Api::AnthropicMessages))
})
})
.cloned()
}
fn open_settings_thinking_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
lane_model_id: &str,
chat: &Arc<Container>,
) {
let model = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
let levels: Vec<rpi_ai::types::ThinkingLevel> = model
.map(|m| m.supported_thinking_levels())
.unwrap_or_else(|| {
use rpi_ai::types::ThinkingLevel::*;
vec![Off, Minimal, Low, Medium, High]
});
let mut items: Vec<SelectItem> = Vec::new();
for lvl in &levels {
let name = thinking_level_name(*lvl);
items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
}
if items.is_empty() {
add_note_message(chat, "This model has no supported thinking levels.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let Some(level) = thinking_level_from_name(&item.value) else {
add_note_message(
&chat_sel,
&format!("Unknown thinking level: {}.", item.label),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
return;
};
let lane = lane_sel.clone();
let footer_sel = state_sel.footer.clone();
tokio::spawn(async move {
let _ = lane.set_thinking_level(level).await;
});
footer_sel.set_thinking_level(Some(thinking_level_name(level)));
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.default_thinking_level = Some(item.value.clone());
let saved = crate::settings::save_settings(&settings);
add_note_message(
&chat_sel,
&format!(
"Default thinking set to {} (saved{}",
item.label,
if saved.is_ok() { ")" } else { ", not saved)" },
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Settings,
);
}
fn open_scoped_models_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
catalog: &[rpi_ai::Model],
chat: &Arc<Container>,
) {
if catalog.is_empty() {
add_note_message(chat, "No models in the catalog.");
tui.request_render(false);
return;
}
let seed: Vec<String> = crate::settings::load_settings()
.ok()
.and_then(|s| s.scoped_models)
.unwrap_or_default();
*state.scoped_edit.lock().unwrap() = Some(seed);
let mut items: Vec<SelectItem> = Vec::new();
for m in catalog {
items.push(SelectItem::new(&m.id, &m.id));
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let chat_sel = chat.clone();
let tui_sel = tui.clone();
list.on_select(Arc::new(move |item| {
let mut set = state_sel.scoped_edit.lock().unwrap();
let set = set.get_or_insert_with(Vec::new);
if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
set.remove(pos);
add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
} else {
set.push(item.value.clone());
add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
}
tui_sel.request_render(false);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
let chat_cancel = chat.clone();
list.on_cancel(Arc::new(move || {
let set = state_cancel
.scoped_edit
.lock()
.unwrap()
.take()
.unwrap_or_default();
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.scoped_models = if set.is_empty() {
None
} else {
Some(set.clone())
};
match crate::settings::save_settings(&settings) {
Ok(()) => {
if set.is_empty() {
add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
} else {
add_note_message(
&chat_cancel,
&format!("Ctrl+M cycle scope: {}", set.join(", ")),
);
}
}
Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
}
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::ScopedModels,
);
}
async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
use std::process::Stdio;
let tree = harness.session().view("main");
let entries = match tree
.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: Some(EntryOrder::OldestFirst),
limit: None,
cursor: None,
})
.await
{
Ok(e) => e,
Err(e) => {
add_error_message(chat, &format!("Could not read session: {e}"));
return;
}
};
let mut md = String::from("# Session\n\n");
for e in entries {
let Entry::Message(me) = e else { continue };
match &me.message {
AgentMessage::User(u) => {
md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
}
AgentMessage::Assistant(a) => {
let text = assistant_text(a);
if !text.is_empty() {
md.push_str(&format!("## Assistant\n\n{}\n\n", text));
}
}
_ => {}
}
}
let gh = std::process::Command::new("gh")
.arg("gist")
.arg("create")
.arg("--filename")
.arg("session.md")
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = gh {
use std::io::Write;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(md.as_bytes());
let _ = stdin.flush();
}
let out = child.wait_with_output().ok();
if let Some(out) = out {
if out.status.success() {
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
add_note_message(chat, &format!("Shared session: {url}"));
return;
}
}
add_note_message(chat, "gh gist failed — falling back to the clipboard.");
} else {
add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
}
if copy_to_clipboard(&md) {
add_note_message(chat, "Session transcript copied to the clipboard.");
} else {
add_note_message(
chat,
"Clipboard unavailable — use /export to write the transcript to a file.",
);
}
}
async fn export_session(harness: &AgentHarness, chat: &Arc<Container>, cwd: &std::path::Path) {
let tree = harness.session().view("main");
let entries = match tree
.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: Some(EntryOrder::OldestFirst),
limit: None,
cursor: None,
})
.await
{
Ok(e) => e,
Err(e) => {
add_error_message(chat, &format!("Could not read session: {e}"));
return;
}
};
let name = tree.get_name().await.ok().flatten().unwrap_or_default();
let id = tree
.get_leaf_id()
.await
.ok()
.flatten()
.unwrap_or_else(|| "session".to_string());
let mut md = String::from("# Session\n\n");
for e in entries {
let Entry::Message(me) = e else { continue };
match &me.message {
AgentMessage::User(u) => {
md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
}
AgentMessage::Assistant(a) => {
let text = assistant_text(a);
if !text.is_empty() {
md.push_str(&format!("## Assistant\n\n{}\n\n", text));
}
}
_ => {}
}
}
let file_name = if name.is_empty() {
format!("{id}.md")
} else {
format!("{name}.md")
};
let path = cwd.join(&file_name);
match std::fs::write(&path, md) {
Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
}
}
async fn switch_to_session(
harness: &AgentHarness,
lane: &Arc<dyn AgentLane>,
id: &str,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) -> bool {
if *state.status.lock().unwrap() == RunStatus::Working {
state.set_status(RunStatus::Aborting);
let _ = lane.abort().await;
}
let cwd_str = cwd.to_string_lossy().to_string();
match crate::session::open_session_by_id(id, &cwd_str).await {
Ok(new_session) => {
let _ = harness.set_session(new_session).await;
chat.clear();
add_welcome_message(chat);
render_session_history(
harness,
chat,
state.markdown_transformer(),
Some(state.extension_session.clone()),
)
.await;
state.set_status(RunStatus::Idle);
add_note_message(chat, &format!("Switched to session {id}."));
true
}
Err(e) => {
state.set_status(RunStatus::Idle);
add_error_message(chat, &format!("Could not open session {id}: {e}"));
false
}
}
}
async fn import_session(
harness: &AgentHarness,
lane: &Arc<dyn AgentLane>,
path: &str,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) {
use std::path::Path as FsPath;
let src = FsPath::new(path);
if !src.is_file() {
add_error_message(chat, &format!("Import source not found: {path}"));
return;
}
let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
add_error_message(chat, "Import source has no file name.");
return;
};
if !fname.ends_with(".jsonl") {
add_error_message(chat, "Import source must be a .jsonl session file.");
return;
}
let dir = crate::session::default_session_dir(cwd);
if let Err(e) = std::fs::create_dir_all(&dir) {
add_error_message(chat, &format!("Could not create session dir: {e}"));
return;
}
let dest = dir.join(fname);
match std::fs::copy(src, &dest) {
Ok(_) => {
let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
if switch_to_session(harness, lane, &id, cwd, chat, state).await {
add_note_message(chat, &format!("Imported session from {path}"));
}
}
Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
}
}
async fn fork_session(
harness: &AgentHarness,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) {
use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
use rpi_tools::FileSystem;
let cwd_str = cwd.to_string_lossy().to_string();
let dir = crate::session::default_session_dir(cwd);
let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
let fs: Arc<dyn FileSystem> = env.clone();
let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
fs,
sessions_root: dir.to_string_lossy().into_owned(),
clock: Arc::new(rpi_harness::session::memory::SystemClock),
ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
});
let id = harness.session().storage().metadata().id.clone();
let metas = match crate::session::list_session_metadata(&cwd_str).await {
Ok(m) => m,
Err(e) => {
add_error_message(chat, &format!("Could not list sessions: {e}"));
return;
}
};
let Some(source) = metas.iter().find(|m| m.id == id) else {
add_error_message(chat, &format!("Current session {id} not found on disk."));
return;
};
let fork_storage = match repo
.fork_typed(
source,
&rpi_harness::session::jsonl::JsonlSessionCreateOptions {
id: None,
parent_session_id: Some(source.id.clone()),
cwd: cwd_str.clone(),
metadata: None,
},
&rpi_harness::session::types::ForkOptions::default(),
)
.await
{
Ok(s) => s,
Err(e) => {
add_error_message(chat, &format!("Could not fork session: {e}"));
return;
}
};
let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
let _ = harness.set_session(new_session).await;
chat.clear();
add_welcome_message(chat);
render_session_history(
harness,
chat,
state.markdown_transformer(),
Some(state.extension_session.clone()),
)
.await;
state.set_status(RunStatus::Idle);
add_note_message(chat, "Forked into a new session.");
}
async fn render_session_history(
harness: &AgentHarness,
chat: &Arc<Container>,
transformer: Option<MarkdownTransformer>,
extension_session: Option<crate::session::ExtensionSessionCell>,
) {
let tree = harness.session().view("main");
let entries = match tree
.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: Some(EntryOrder::OldestFirst),
limit: None,
cursor: None,
})
.await
{
Ok(e) => e,
Err(_) => return,
};
let mut rendered_any = false;
for e in entries {
match e {
Entry::Message(me) => match &me.message {
AgentMessage::User(u) => {
add_user_message(chat, &user_message_text(u));
rendered_any = true;
}
AgentMessage::Assistant(a) => {
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(t) = &transformer {
comp.set_markdown_transformer(Some(t.clone()));
}
comp.update_blocks(&assistant_blocks(a));
chat.add_child(comp);
chat.add_child(Arc::new(Spacer::new(1)));
if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
add_note_message(chat, &text);
}
rendered_any = true;
}
AgentMessage::Custom(custom) => {
if let Some(session) = &extension_session {
if let Some(component) = extension_message_component(
session,
&custom.role,
&serde_json::json!({
"customType": custom.role,
"content": custom.content,
"details": custom.data,
}),
transformer.clone(),
) {
chat.add_child(component);
chat.add_child(Arc::new(Spacer::new(1)));
rendered_any = true;
continue;
}
}
add_note_message(chat, &custom_message_fallback(&custom));
rendered_any = true;
}
_ => {}
},
Entry::Compaction(compaction) => {
add_note_message(
chat,
&format!(
"Compacted {} tokens: {}",
compaction.tokens_before, compaction.summary
),
);
rendered_any = true;
}
Entry::BranchSummary(summary) => {
add_note_message(chat, &format!("Branch summary: {}", summary.summary));
rendered_any = true;
}
Entry::Custom(custom) => {
let rendered = extension_session.as_ref().and_then(|session| {
extension_entry_component(session, &custom.custom_type, custom.data.clone())
});
if let Some(component) = rendered {
chat.add_child(component);
chat.add_child(Arc::new(Spacer::new(1)));
rendered_any = true;
} else if let Some(text) =
custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
{
add_note_message(chat, &text);
rendered_any = true;
}
}
Entry::ModelChange(change) => {
add_note_message(
chat,
&format!("Model changed to {}:{}", change.provider, change.model_id),
);
rendered_any = true;
}
Entry::ThinkingLevel(change) => {
add_note_message(
chat,
&format!("Thinking level: {:?}", change.thinking_level),
);
rendered_any = true;
}
Entry::ActiveTools(change) => {
add_note_message(
chat,
&format!("Active tools: {}", change.active_tool_names.join(", ")),
);
rendered_any = true;
}
}
}
if rendered_any {
}
}
fn invoke_extension_renderer(
session: &crate::session::ExtensionSessionCell,
kind: rpi_extensions::RegisteredRendererKind,
payload: &serde_json::Value,
) -> Option<serde_json::Value> {
let snapshot = session.lock().ok()?.snapshot_arc()?;
let input = serde_json::to_string(payload).ok()?;
for renderer in snapshot.renderers_of(kind) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut out = rpi_plugin_sdk::StbString::empty();
let rc = (renderer.render_fn)(
rpi_plugin_sdk::StbStringRef::from_str(&input),
&mut out as *mut rpi_plugin_sdk::StbString,
renderer.user_data,
);
let text = if rc == 0 {
Some(out.to_string_lossy())
} else {
None
};
out.free_with(Some(renderer.plugin_free_string));
text
}))
.ok()
.flatten();
let Some(text) = outcome else { continue };
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
return Some(value);
}
}
None
}
fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
let text = lines
.iter()
.filter_map(|line| line.as_str())
.collect::<Vec<_>>()
.join("\n");
return Some(Arc::new(Text::new(text, 0, 0)));
}
let text = value.get("text").and_then(|v| v.as_str())?;
if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
let component = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
Some(component)
} else {
Some(Arc::new(Text::new(text, 0, 0)))
}
}
fn extension_message_component(
session: &crate::session::ExtensionSessionCell,
custom_type: &str,
payload: &serde_json::Value,
transformer: Option<MarkdownTransformer>,
) -> Option<Arc<dyn rpi_tui::Component>> {
let value = invoke_extension_renderer(
session,
rpi_extensions::RegisteredRendererKind::Message,
payload,
)?;
if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
let text = value.get("text").and_then(|v| v.as_str())?;
let component = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(transformer) = transformer {
component.set_markdown_transformer(Some(transformer));
}
component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
return Some(component);
}
extension_text_component(&value)
.or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
}
fn extension_usage_text(
session: Option<&crate::session::ExtensionSessionCell>,
usage: &rpi_ai::types::Usage,
) -> Option<String> {
let session = session?;
let payload = serde_json::json!({
"customType": "token-usage",
"usage": usage,
});
let value = invoke_extension_renderer(
session,
rpi_extensions::RegisteredRendererKind::Message,
&payload,
)?;
value
.get("text")
.and_then(|value| value.as_str())
.filter(|text| !text.trim().is_empty())
.map(ToOwned::to_owned)
}
fn extension_entry_component(
session: &crate::session::ExtensionSessionCell,
custom_type: &str,
data: Option<serde_json::Value>,
) -> Option<Arc<dyn rpi_tui::Component>> {
let payload = serde_json::json!({
"customType": custom_type,
"data": data,
});
let value = invoke_extension_renderer(
session,
rpi_extensions::RegisteredRendererKind::Entry,
&payload,
)?;
extension_text_component(&value)
}
fn launch_restores_history(args: &Args) -> bool {
args.continue_session
|| args.resume
|| args.session.is_some()
|| args.session_id.is_some()
|| args.fork.is_some()
}
fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
Content::Image(image) => base64::engine::general_purpose::STANDARD
.decode(&image.data)
.ok()
.filter(|data| !data.is_empty())
.map(AssistantBlock::Image),
_ => None,
})
.collect()
}
fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
let content = custom
.content
.iter()
.filter_map(|item| match item {
Content::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if content.is_empty() {
format!("{}: {}", custom.role, custom.data)
} else {
format!("{}: {}", custom.role, content)
}
}
fn short_model_name(id: &str) -> String {
id.rsplit([':', '/'])
.next()
.filter(|s| !s.is_empty())
.unwrap_or(id)
.to_string()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RunStatus {
Idle,
Working,
Aborting,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SelectorKind {
Model,
Thinking,
Tools,
Images,
Session,
Theme,
ScopedModels,
Settings,
Tree,
Extension,
}
struct TuiState {
current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
themes_enabled: bool,
hide_thinking: std::sync::Mutex<bool>,
tool_outputs_expanded: std::sync::Mutex<bool>,
show_terminal_progress: bool,
status: std::sync::Mutex<RunStatus>,
js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
footer: Arc<FooterComponent>,
status_container: Arc<Container>,
chat_container: Arc<Container>,
loader: Arc<Loader>,
last_assistant_text: std::sync::Mutex<String>,
active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
autocomplete: AutocompleteManager,
autocomplete_container: Arc<Container>,
autocomplete_max_visible: usize,
pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
theme_manager: Arc<ThemeManager>,
tui: Option<Arc<TuiAltScreen>>,
current_model_id: std::sync::Mutex<String>,
show_images: std::sync::Mutex<bool>,
history: std::sync::Mutex<Vec<String>>,
history_index: std::sync::Mutex<isize>,
history_draft: std::sync::Mutex<Option<String>>,
last_input_tokens: std::sync::Mutex<i64>,
scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
extension_session: crate::session::ExtensionSessionCell,
}
const HISTORY_LIMIT: usize = 100;
const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
const PAGE_SCROLL_OVERLAP: usize = 4;
const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
let mut modifiers = KeyModifiers::NONE;
let mut key = None;
for part in value.trim().to_ascii_lowercase().split('+') {
match part {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"shift" => modifiers |= KeyModifiers::SHIFT,
"alt" | "option" => modifiers |= KeyModifiers::ALT,
"super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
part if !part.is_empty() => key = Some(part.to_string()),
_ => {}
}
}
let key = key?;
let code = match key.as_str() {
"esc" | "escape" => KeyCode::Esc,
"enter" | "return" => KeyCode::Enter,
"tab" => {
if modifiers.contains(KeyModifiers::SHIFT) {
return Some(rpi_tui::KeyCombo::new(
KeyCode::BackTab,
modifiers & !KeyModifiers::SHIFT,
));
}
KeyCode::Tab
}
"backspace" | "back" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"up" | "arrowup" => KeyCode::Up,
"down" | "arrowdown" => KeyCode::Down,
"left" | "arrowleft" => KeyCode::Left,
"right" | "arrowright" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "page-up" => KeyCode::PageUp,
"pagedown" | "page-down" => KeyCode::PageDown,
"space" => KeyCode::Char(' '),
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
_ => return None,
};
Some(rpi_tui::KeyCombo::new(code, modifiers))
}
fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
let mut bindings = rpi_tui::Keybindings::new();
let settings = crate::settings::load_settings().unwrap_or_default();
let Some(overrides) = settings.keybindings else {
rpi_tui::set_keybindings(bindings.clone());
return Arc::new(bindings);
};
let known: &[(&str, rpi_tui::KeybindingId)] = &[
("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
("app.clear", rpi_tui::keybindings::keys::CLEAR),
("app.exit", rpi_tui::keybindings::keys::EXIT),
("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
(
"app.model.cycleForward",
rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
),
("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
(
"app.thinking.toggle",
rpi_tui::keybindings::keys::THINKING_TOGGLE,
),
(
"app.editor.external",
rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
),
(
"app.thinking.cycle",
rpi_tui::keybindings::keys::THINKING_CYCLE,
),
(
"app.clipboard.pasteImage",
rpi_tui::keybindings::keys::PASTE_IMAGE,
),
];
for (name, id) in known {
let Some(value) = overrides.get(*name) else {
continue;
};
let values: Vec<String> = match value {
serde_json::Value::String(value) => vec![value.clone()],
serde_json::Value::Array(values) => values
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
serde_json::Value::Null => Vec::new(),
_ => continue,
};
let combos: Vec<_> = values
.iter()
.filter_map(|value| parse_configured_key(value))
.collect();
if values.is_empty() || !combos.is_empty() {
bindings.set(id, combos);
}
}
rpi_tui::set_keybindings(bindings.clone());
Arc::new(bindings)
}
fn keybinding_matches(
bindings: &rpi_tui::Keybindings,
event: &crossterm::event::KeyEvent,
id: rpi_tui::KeybindingId,
) -> bool {
if bindings.matches(event, id) {
return true;
}
if event.code == KeyCode::BackTab {
let normalized =
crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
bindings.matches(&normalized, id)
} else {
false
}
}
fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
last.is_some_and(|previous| {
now.duration_since(previous) <= std::time::Duration::from_millis(500)
})
}
fn transcript_page_size(viewport_height: usize) -> i32 {
viewport_height
.saturating_sub(PAGE_SCROLL_OVERLAP)
.max(1)
.min(i32::MAX as usize) as i32
}
fn should_dispatch_key(kind: KeyEventKind) -> bool {
kind != KeyEventKind::Release
}
fn format_tokens(n: i64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn push_history(state: &Arc<TuiState>, text: &str) {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return;
}
let mut history = state.history.lock().unwrap();
if history.first() == Some(&trimmed) {
return;
}
history.insert(0, trimmed);
history.truncate(HISTORY_LIMIT);
*state.history_index.lock().unwrap() = -1;
*state.history_draft.lock().unwrap() = None;
}
fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
let history = state.history.lock().unwrap();
if history.is_empty() {
return;
}
let mut index = state.history_index.lock().unwrap();
let new_index = *index - direction as isize;
if new_index < -1 || new_index >= history.len() as isize {
return;
}
if *index == -1 && new_index >= 0 {
*state.history_draft.lock().unwrap() = Some(editor.get_text());
}
*index = new_index;
if new_index == -1 {
let draft = state.history_draft.lock().unwrap().take();
match draft {
Some(d) => {
let len = d.len();
editor.set_text(&d);
editor.set_cursor(0, len);
}
None => editor.set_text(""),
}
} else {
let text = history[new_index as usize].clone();
let len = text.len();
editor.set_text(&text);
editor.set_cursor(0, len);
}
}
impl TuiState {
fn begin_js_preparation(&self) -> CancellationToken {
let cancellation = CancellationToken::new();
if let Some(previous) = self
.js_preparation_cancel
.lock()
.unwrap()
.replace(cancellation.clone())
{
previous.cancel();
}
cancellation
}
fn finish_js_preparation(&self) {
self.js_preparation_cancel.lock().unwrap().take();
}
fn cancel_js_preparation(&self) -> bool {
let cancellation = self.js_preparation_cancel.lock().unwrap().take();
if let Some(cancellation) = cancellation {
cancellation.cancel();
true
} else {
false
}
}
fn set_status(&self, status: RunStatus) {
*self.status.lock().unwrap() = status;
self.apply_status(status);
}
fn try_start_working(&self) -> bool {
let mut status = self.status.lock().unwrap();
if *status != RunStatus::Idle {
return false;
}
*status = RunStatus::Working;
drop(status);
self.apply_status(RunStatus::Working);
true
}
fn apply_status(&self, status: RunStatus) {
match status {
RunStatus::Working => {
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title("rpi — working");
}
self.status_container.clear();
if self.show_terminal_progress {
self.loader.start();
self.status_container.add_child(self.loader.clone());
}
}
RunStatus::Aborting => {
self.footer.set_status("Aborting…");
self.loader.stop();
self.status_container.clear();
}
RunStatus::Idle => {
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title("rpi");
}
self.loader.stop();
self.status_container.clear();
}
}
}
fn sync_working_loader_with_bash(&self) {
if *self.status.lock().unwrap() != RunStatus::Working {
return;
}
self.status_container.clear();
if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
self.status_container.add_child(self.loader.clone());
}
}
fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
*self.status.lock().unwrap() = RunStatus::Working;
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title("rpi — retrying");
}
self.loader.stop();
self.status_container.clear();
self.status_container
.add_child(Arc::new(StatusIndicator::retry(
attempt,
max_retries,
std::time::Duration::from_millis(delay_ms),
)));
}
fn selector_open(&self) -> bool {
self.active_selector.lock().unwrap().is_some()
}
fn extension_editor_open(&self) -> bool {
self.active_extension_editor.lock().unwrap().is_some()
}
fn extension_input_open(&self) -> bool {
self.active_extension_input.lock().unwrap().is_some()
}
fn extension_dialog_open(&self) -> bool {
self.extension_editor_open() || self.extension_input_open()
}
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);
}
}
fn hide_thinking(&self) -> bool {
*self.hide_thinking.lock().unwrap()
}
fn toggle_thinking(&self) -> bool {
let next = !self.hide_thinking();
self.set_hide_thinking(next);
next
}
fn toggle_tool_outputs(&self) -> bool {
let next = !*self.tool_outputs_expanded.lock().unwrap();
*self.tool_outputs_expanded.lock().unwrap() = next;
for comp in self.tool_components.lock().unwrap().values() {
comp.set_expanded(next);
}
for comp in self.bash_components.lock().unwrap().values() {
comp.set_expanded(next);
}
next
}
fn current_model_id(&self) -> String {
self.current_model_id.lock().unwrap().clone()
}
fn set_current_model(&self, model: &rpi_ai::Model) {
*self.current_model_id.lock().unwrap() = model.id.clone();
self.footer.set_model(&short_model_name(&model.id));
}
fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
self.markdown_transformer.lock().unwrap().clone()
}
fn set_markdown_transformer_with_reinstall(&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);
}
}
fn queue_image(&self, image: rpi_ai::types::ImageContent) {
self.pending_images.lock().unwrap().push(image);
}
fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
std::mem::take(&mut *self.pending_images.lock().unwrap())
}
}
#[derive(Debug, PartialEq)]
struct TuiStartupSettings {
editor_padding_x: usize,
autocomplete_max_visible: usize,
hide_thinking: bool,
quiet_startup: bool,
show_terminal_progress: bool,
}
fn preferred_project_setting<T>(
project_settings: &[crate::settings::Settings],
field: impl Fn(&crate::settings::Settings) -> Option<T>,
) -> Option<T> {
project_settings.iter().find_map(field)
}
fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
verbose || !quiet_startup
}
fn git_only_update_resources(
mut resources: crate::packages::PackageResources,
) -> crate::packages::PackageResources {
resources
.packages
.retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
resources
}
fn resolve_tui_startup_settings(
global: &crate::settings::Settings,
project_settings: &[crate::settings::Settings],
project_trusted: bool,
) -> TuiStartupSettings {
let project_settings = if project_trusted {
project_settings
} else {
&[]
};
let editor_padding_x =
preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
.or(global.editor_padding_x)
.unwrap_or(1)
.min(16);
let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
settings.autocomplete_max_visible
})
.or(global.autocomplete_max_visible)
.unwrap_or(5)
.clamp(1, 20);
let hide_thinking =
preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
.or(global.hide_thinking_block)
.unwrap_or(false);
let quiet_startup =
preferred_project_setting(project_settings, |settings| settings.quiet_startup)
.or(global.quiet_startup)
.unwrap_or(false);
let show_terminal_progress =
preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
.or(global.show_terminal_progress)
.unwrap_or(true);
TuiStartupSettings {
editor_padding_x,
autocomplete_max_visible,
hide_thinking,
quiet_startup,
show_terminal_progress,
}
}
pub async fn interactive_tui(
harness: &AgentHarness,
event_rx: Option<broadcast::Receiver<AgentEvent>>,
args: &Args,
model_catalog: Vec<rpi_ai::Model>,
initial: Option<String>,
extra_messages: &[String],
initial_images: Vec<rpi_ai::types::ImageContent>,
theme: Option<&str>,
no_themes: bool,
reload_context: &crate::session::ReloadContext,
) -> i32 {
let lane: Arc<dyn AgentLane> = harness.lane("main");
let cwd = reload_context.cwd.clone();
let saved_settings = crate::settings::load_settings().unwrap_or_default();
let project_trusted = reload_context.project_trusted;
let project_settings = if project_trusted {
crate::settings::load_project_settings(&cwd)
} else {
Vec::new()
};
let tui_settings =
resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
let editor_padding_x = tui_settings.editor_padding_x;
let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
let hide_thinking = tui_settings.hide_thinking;
let show_terminal_progress = tui_settings.show_terminal_progress;
let quiet_startup = tui_settings.quiet_startup;
let update_checks_enabled =
!args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
let model_name = short_model_name(&lane_model_id);
let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
if active_tool_names.is_empty() {
active_tool_names = harness
.get_tools()
.await
.unwrap_or_default()
.into_iter()
.map(|tool| tool.tool.schema().name.clone())
.collect();
}
let resources_snapshot = harness.get_resources().await.unwrap_or_default();
let skill_names: Vec<String> = resources_snapshot
.skills
.as_deref()
.unwrap_or(&[])
.iter()
.map(|skill| skill.name.clone())
.collect();
let package_resources = reload_context.package_resources.clone();
let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
let theme_manager = Arc::new(ThemeManager::new());
if let Some(preset) = match theme {
Some("light") => Some(ThemePreset::Light),
Some("monochrome") => Some(ThemePreset::Monochrome),
Some("dark") => Some(ThemePreset::Dark),
_ => None,
} {
apply_theme_preset(preset);
theme_manager.apply_preset(preset);
} else if !no_themes {
if let Some(name) = theme {
match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
Ok(custom) => {
rpi_tui::global_theme_manager().set(custom.clone());
theme_manager.set(custom);
}
Err(error) => {
eprintln!("warning: could not load package theme `{name}`: {error}");
}
}
}
}
let terminal = Box::new(ProcessTerminal::new());
let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
let js_dialog_bridge = Arc::new(JsDialogBridge::default());
tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
if let Some(js) = &reload_context.js_extension_session {
if let Err(error) = js.install_ui_runtime(tui.clone()) {
eprintln!("warning: could not enable JS custom UI bridge: {error}");
} else if let Some(js_active) = js.active_tools() {
let js_names = js.tool_names();
let mut active = lane.get_active_tools().await.unwrap_or_default();
active.retain(|name| {
crate::session::tool_name_allowed(name, args)
&& !js_names.iter().any(|js_name| js_name == name)
});
active.extend(js_active.into_iter().filter(|name| {
js_names.iter().any(|js_name| js_name == name)
&& crate::session::tool_name_allowed(name, args)
}));
active = crate::session::filter_active_tool_names(active, args);
let _ = lane.set_active_tools(active).await;
active_tool_names = lane.get_active_tools().await.unwrap_or_default();
}
}
let chat_container = Arc::new(Container::new());
if let Some(js) = &reload_context.js_extension_session {
let chat_notify = chat_container.clone();
let tui_notify = tui.clone();
if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
if action != "ui.notify" {
return Err(format!("unsupported capability: {action}"));
}
let message = args
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !message.is_empty() {
if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
add_error_message(&chat_notify, message);
} else {
add_note_message(&chat_notify, message);
}
tui_notify.request_render(false);
}
Ok(serde_json::json!(true))
})) {
if args.verbose {
eprintln!("warning: could not enable JS notification bridge: {error}");
}
}
}
if should_show_startup_listing(args.verbose, quiet_startup) {
add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
}
crate::extras::maybe_first_time_setup(&chat_container);
let initial_transformer = build_markdown_transformer(
reload_context
.extension_session
.lock()
.unwrap()
.snapshot_arc(),
);
if launch_restores_history(args) {
render_session_history(
&harness,
&chat_container,
initial_transformer.clone(),
Some(reload_context.extension_session.clone()),
)
.await;
}
let document_container = Arc::new(Container::new());
document_container.add_child(chat_container.clone());
let scroll_view = Arc::new(ScrollView::new(
document_container.clone(),
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
overscroll: OverscrollMode::Chain,
scrollbar: ScrollbarMode::Hidden,
..Default::default()
},
));
let keybindings = configured_keybindings();
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: editor_padding_x,
autocomplete_max_visible,
..Default::default()
},
EditorStyle::default(),
keybindings.clone(),
));
let footer = Arc::new(FooterComponent::new());
footer.set_model(&model_name);
footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Abort/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help");
let status_container = Arc::new(Container::new());
let loader = Arc::new(Loader::with_text("Working…"));
let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
.prompt_templates
.clone()
.unwrap_or_default()
.iter()
.map(|t| SlashCommandEntry {
name: format!("/{}", t.name),
description: t
.description
.clone()
.unwrap_or_else(|| "Expand prompt template".to_string()),
})
.collect();
let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
Arc::new(resources_snapshot);
let mut command_registry = build_builtin_registry();
register_extension_commands(
&mut command_registry,
reload_context.extension_session.clone(),
);
register_js_extension_commands(
&mut command_registry,
reload_context.js_extension_session.clone(),
);
let registry = Arc::new(command_registry);
let mut all_slash_commands = registry.visible_entries();
all_slash_commands.extend(template_slash_commands);
let autocomplete = AutocompleteManager::new();
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
all_slash_commands,
)));
combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
cwd.clone(),
)));
autocomplete.set_provider(Arc::new(combined));
}
let autocomplete_container = Arc::new(Container::new());
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
themes_enabled: !no_themes,
hide_thinking: std::sync::Mutex::new(hide_thinking),
tool_outputs_expanded: std::sync::Mutex::new(false),
show_terminal_progress,
status: std::sync::Mutex::new(RunStatus::Idle),
js_preparation_cancel: std::sync::Mutex::new(None),
footer: footer.clone(),
status_container: status_container.clone(),
chat_container: chat_container.clone(),
loader: loader.clone(),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete,
autocomplete_container: autocomplete_container.clone(),
autocomplete_max_visible,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager,
tui: Some(tui.clone()),
current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
show_images: std::sync::Mutex::new(true),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
last_input_tokens: std::sync::Mutex::new(0),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: std::sync::Mutex::new(initial_transformer),
extension_session: reload_context.extension_session.clone(),
});
let model_catalog_arc = Arc::new(model_catalog.clone());
let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
let editor_container = Arc::new(Container::new());
editor_container.add_child(editor.clone());
let dock = Arc::new(VStack::from_children(vec![
StackChild::Entry(StackEntry::new(status_container.clone())),
StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
StackChild::Entry(
StackEntry::new(editor_container.clone())
.shrink(0)
.min_size(3),
),
StackChild::Entry(StackEntry::new(footer.clone())),
]));
let root = VStack::from_children(vec![
StackChild::Entry(
StackEntry::new(scroll_view.clone())
.basis(0)
.grow(1)
.shrink(1)
.min_size(1),
),
StackChild::Entry(StackEntry::new(dock).shrink(1)),
]);
tui.set_layout_root(Some(Arc::new(root)));
tui.set_focus(Some(editor.clone()));
editor.set_focused(true);
let ctx = CommandContext {
chat: chat_container.clone(),
tui: tui.clone(),
tx: tx.clone(),
state: state.clone(),
editor: editor.clone(),
editor_container: editor_container.clone(),
lane: lane.clone(),
model_catalog: model_catalog_arc.clone(),
lane_model_id: lane_model_id.clone(),
cwd: cwd.clone(),
package_resources: package_resources.clone(),
resources: resources_arc.clone(),
reload_context: Arc::new(reload_context.clone()),
};
if let Some(js) = &reload_context.js_extension_session {
let bridge = js_dialog_bridge.clone();
if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
bridge.handle_runtime_request(action, args)
})) {
eprintln!("warning: could not enable JS dialog UI bridge: {error}");
}
}
let ctx_for_cb = ctx.clone();
let registry_for_cb = registry.clone();
editor.on_submit(Arc::new(move |text: &str| {
let text = text.trim();
if text.is_empty() {
return;
}
if text.starts_with('/') {
dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
return;
}
let run_status = *ctx_for_cb.state.status.lock().unwrap();
if run_status != RunStatus::Idle {
let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
let aborting = run_status == RunStatus::Aborting;
let lane = ctx_for_cb.lane.clone();
let chat = ctx_for_cb.chat.clone();
let tui = ctx_for_cb.tui.clone();
tokio::spawn(async move {
let result = if aborting {
lane.next_run(message).await
} else {
lane.steer(message).await
};
if let Err(error) = result {
add_error_message(&chat, &format!("Could not queue message: {error}"));
tui.request_render(false);
}
});
add_note_message(
&ctx_for_cb.chat,
&format!("Queued steering message: {text}"),
);
ctx_for_cb.tui.request_render(false);
return;
}
if !ctx_for_cb.state.try_start_working() {
return;
}
add_user_message(&ctx_for_cb.chat, text);
if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
scroll.scroll_to_end();
}
ctx_for_cb.tui.request_render(false);
push_history(&ctx_for_cb.state, text);
if ctx_for_cb
.tx
.send(TuiMessage::UserInput(text.to_string()))
.is_err()
{
ctx_for_cb.state.set_status(RunStatus::Idle);
}
}));
tui.start_readerless();
let mut update_check_handles = Vec::new();
let rpi_chat = chat_container.clone();
let rpi_tui = tui.clone();
update_check_handles.push(tokio::spawn(async move {
let report = crate::updates::check_rpi_startup().await;
if !report.is_empty() {
add_update_notices(&rpi_chat, &report);
}
rpi_tui.request_render(false);
}));
if update_checks_enabled {
let update_args = args.clone();
let update_cwd = cwd.clone();
let update_project_trusted = project_trusted;
let chat = chat_container.clone();
let tui = tui.clone();
update_check_handles.push(tokio::spawn(async move {
let resources = crate::session::package_resources_for_update_check(
&update_args,
&update_cwd,
update_project_trusted,
);
let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
{
Ok(npm_command) => {
crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
Some(&resources),
&npm_command,
update_project_trusted.then_some(update_cwd.as_path()),
)
.await
}
Err(error) => {
add_error_message(
&chat,
&format!("npm package update checks disabled: {error}"),
);
let git_resources = git_only_update_resources(resources);
crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
}
};
if !report.is_empty() {
add_update_notices(&chat, &report);
}
tui.request_render(false);
}));
}
let drain_handle = if let Some(rx) = event_rx {
let tui_drain = tui.clone();
let state_drain = state.clone();
let chat_drain = chat_container.clone();
Some(tokio::spawn(async move {
drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
}))
} else {
None
};
let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
reload_context.mailbox.install(reload_sig_tx);
let reload_tx = tx.clone();
let reload_bridge_handle = tokio::spawn(async move {
while reload_sig_rx.recv().await.is_some() {
if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
break; }
}
});
let tui_tick = tui.clone();
let state_tick = state.clone();
let tick_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
interval.tick().await; loop {
interval.tick().await;
let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
if working {
if state_tick.bash_components.lock().unwrap().is_empty() {
tui_tick.request_render_reusing_scroll_content();
} else {
tui_tick.request_render(false);
}
}
}
});
let running = Arc::new(std::sync::Mutex::new(true));
let running_key = running.clone();
let tx_for_key = tx.clone();
let tui_for_key = tui.clone();
let editor_for_key = editor.clone();
let scroll_for_key = scroll_view.clone();
let lane_for_key = lane.clone();
let state_for_key = state.clone();
let model_catalog_for_key = model_catalog_arc.clone();
let js_for_key = reload_context.js_extension_session.clone();
let js_dialog_for_key = js_dialog_bridge.clone();
let ctx_for_key = ctx.clone();
let registry_for_key = registry.clone();
let keybindings_for_key = keybindings.clone();
let double_escape_action = crate::settings::load_settings()
.ok()
.and_then(|settings| settings.double_escape_action)
.unwrap_or_else(|| "tree".to_string())
.to_ascii_lowercase();
let key_handle = tokio::task::spawn_blocking(move || {
let mut last_escape_time = None;
loop {
if !*running_key.lock().unwrap() {
break;
}
if !state_for_key.selector_open()
&& !state_for_key.extension_dialog_open()
&& js_for_key.as_ref().map_or(true, |js| !js.custom_active())
{
if let Some(request) = js_dialog_for_key.take_pending() {
open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
}
}
cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
match crossterm::event::poll(std::time::Duration::from_millis(50)) {
Ok(true) => {}
Ok(false) => continue,
Err(_) => {
state_for_key.cancel_js_preparation();
let _ = tx_for_key.send(TuiMessage::Exit);
break;
}
}
let Ok(ev) = crossterm::event::read() else {
state_for_key.cancel_js_preparation();
let _ = tx_for_key.send(TuiMessage::Exit);
break;
};
if let Event::Resize(_cols, _rows) = ev {
tui_for_key.refresh_size();
if let Some(js) = &js_for_key {
if js.custom_active() {
let _ = js.send_custom_resize(_cols as usize, _rows as usize);
tui_for_key.request_render(false);
}
}
continue;
}
if let Event::Mouse(m) = ev {
use crossterm::event::MouseEventKind;
match m.kind {
MouseEventKind::ScrollUp => {
let delta = -MOUSE_WHEEL_SCROLL_LINES;
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
}
MouseEventKind::ScrollDown => {
let delta = MOUSE_WHEEL_SCROLL_LINES;
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
}
_ => {}
}
continue;
}
let Event::Key(key) = ev else {
if let Event::Paste(text) = ev {
let candidate = text.trim().trim_matches(['\"', '\'']);
let path = std::path::PathBuf::from(candidate);
if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
add_image_preview(&state_for_key.chat_container, &image);
state_for_key.queue_image(image);
add_note_message(
&state_for_key.chat_container,
"Dropped image attached to the next prompt.",
);
tui_for_key.request_render(false);
continue;
}
}
editor_for_key.insert(&text);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
}
continue;
};
if !should_dispatch_key(key.kind) {
continue;
}
let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
|| (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
if prompt_abort && state_for_key.cancel_js_preparation() {
state_for_key.set_status(RunStatus::Aborting);
if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
close_extension_editor(
&state_for_key,
&ctx_for_key.editor_container,
&editor_for_key,
&tui_for_key,
);
}
js_dialog_for_key.cancel_open_requests();
tui_for_key.set_render_suspended(false);
tui_for_key.request_render(false);
continue;
}
if let Some(js) = &js_for_key {
if js.custom_active() {
let visible = js.custom_accepts_input();
let data = key_event_to_input(key);
if !data.is_empty() {
if visible {
let _ = js.send_custom_input(&data);
continue;
}
let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
if consumed {
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
}
}
if key.modifiers == KeyModifiers::CONTROL
&& key.code == KeyCode::Char('c')
&& state_for_key.selector_open()
{
let selector = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some")
.0;
selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if state_for_key.extension_dialog_open() {
let cancel = key.code == KeyCode::Esc
|| (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
if cancel {
if !run_extension_cancel(&state_for_key) {
close_extension_editor(
&state_for_key,
&ctx_for_key.editor_container,
&editor_for_key,
&tui_for_key,
);
}
} else if let Some(extension_editor) = state_for_key
.active_extension_editor
.lock()
.unwrap()
.clone()
{
extension_editor.handle_key(key);
} else if let Some(extension_input) =
state_for_key.active_extension_input.lock().unwrap().clone()
{
extension_input.handle_key(key);
}
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::CLEAR,
) {
if !state_for_key.selector_open() && editor_for_key.has_selection() {
editor_for_key.copy_selection();
continue;
}
let status = *state_for_key.status.lock().unwrap();
match status {
RunStatus::Working => {
state_for_key.set_status(RunStatus::Aborting);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.abort().await;
});
}
RunStatus::Aborting => {}
RunStatus::Idle => {
let _ = tx_for_key.send(TuiMessage::Exit);
}
}
continue;
}
if state_for_key.selector_open() {
if key.code == KeyCode::Esc {
let (selector, _kind) = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some");
selector.handle_key(key);
continue;
}
let (selector, _kind) = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some");
selector.handle_key(key);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
let status = *state_for_key.status.lock().unwrap();
match status {
RunStatus::Working => {
state_for_key.set_status(RunStatus::Aborting);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.abort().await;
});
continue;
}
RunStatus::Aborting => continue,
RunStatus::Idle => {}
}
if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
editor_for_key.handle_key(key);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
let _ = tx_for_key.send(TuiMessage::Exit);
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::INTERRUPT,
) {
let status = *state_for_key.status.lock().unwrap();
if status == RunStatus::Working {
state_for_key.set_status(RunStatus::Aborting);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.abort().await;
});
continue;
}
if status == RunStatus::Idle
&& editor_for_key.get_text().trim().is_empty()
&& double_escape_action != "none"
{
let now = std::time::Instant::now();
if double_escape_trigger(last_escape_time, now) {
last_escape_time = None;
match double_escape_action.as_str() {
"tree" => {
let _ = tx_for_key.send(TuiMessage::OpenTree);
}
"fork" => {
let _ = tx_for_key.send(TuiMessage::ForkSession);
}
_ => {}
}
} else {
last_escape_time = Some(now);
}
}
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
) {
launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::TOOLS_EXPAND,
) {
state_for_key.toggle_tool_outputs();
tui_for_key.request_render(false);
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::THINKING_TOGGLE,
) {
state_for_key.toggle_thinking();
tui_for_key.request_render(false);
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
) {
let current = state_for_key.current_model_id();
let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
if let Some(next) = cycle_next_model(&scope, ¤t) {
state_for_key.set_current_model(&next);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.set_model(next).await;
});
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::THINKING_CYCLE,
) {
let lane = lane_for_key.clone();
let catalog = model_catalog_for_key.clone();
let state = state_for_key.clone();
tokio::spawn(async move {
let Ok(current_model) = lane.get_model().await else {
return;
};
let levels = catalog
.iter()
.find(|model| {
model.provider == current_model.provider && model.id == current_model.id
})
.map(|model| model.supported_thinking_levels())
.unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
if levels.is_empty() {
return;
}
let current = lane
.get_thinking_level()
.await
.unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
let next = levels
.iter()
.position(|level| *level == current)
.map(|index| levels[(index + 1) % levels.len()])
.unwrap_or(levels[0]);
if lane.set_thinking_level(next).await.is_ok() {
state
.footer
.set_thinking_level(Some(thinking_level_name(next)));
state.tui.as_ref().map(|tui| tui.request_render(false));
}
});
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::PASTE_IMAGE,
) {
match read_clipboard_image() {
Ok(Some(image)) => {
add_image_preview(&state_for_key.chat_container, &image);
state_for_key.queue_image(image);
add_note_message(
&state_for_key.chat_container,
"Clipboard image attached to the next prompt.",
);
tui_for_key.request_render(false);
continue;
}
Ok(None) | Err(_) => {}
}
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::MODEL_SELECT,
) {
if let Some(cmd) = registry_for_key.find("/model") {
cmd.execute(&ctx_for_key, "");
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
if accept_top_suggestion(&state_for_key, &editor_for_key) {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
let delta = -transcript_page_size(scroll_for_key.viewport_height());
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
let delta = transcript_page_size(scroll_for_key.viewport_height());
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
scroll_for_key.scroll_to_start();
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
scroll_for_key.scroll_to_end();
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
let browsing = *state_for_key.history_index.lock().unwrap() != -1;
if editor_for_key.get_text().is_empty() || browsing {
navigate_history(&state_for_key, &editor_for_key, -1);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
let browsing = *state_for_key.history_index.lock().unwrap() != -1;
if editor_for_key.get_text().is_empty() || browsing {
navigate_history(&state_for_key, &editor_for_key, 1);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
let prompt = editor_for_key.get_text().trim().to_string();
if prompt.is_empty() {
continue;
}
editor_for_key.clear();
let status = *state_for_key.status.lock().unwrap();
if status == RunStatus::Idle {
if state_for_key.try_start_working() {
add_user_message(&state_for_key.chat_container, &prompt);
push_history(&state_for_key, &prompt);
let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
}
} else {
add_note_message(
&state_for_key.chat_container,
&format!("Queued follow-up message: {prompt}"),
);
let message = AgentMessage::User(UserMessage::new(prompt, 0));
let lane = lane_for_key.clone();
let chat = state_for_key.chat_container.clone();
let tui = tui_for_key.clone();
tokio::spawn(async move {
if let Err(error) = lane.follow_up(message).await {
add_error_message(&chat, &format!("Could not queue message: {error}"));
tui.request_render(false);
}
});
}
tui_for_key.request_render(false);
continue;
}
editor_for_key.handle_key(key);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
}
});
let mut prompts: Vec<String> = Vec::new();
if let Some(init) = initial {
prompts.push(init);
}
for m in extra_messages {
prompts.push(m.clone());
}
let mut images = initial_images;
for prompt in prompts {
if !*running.lock().unwrap() {
break;
}
add_user_message(&chat_container, &prompt);
tui.request_render(false);
run_prompt_streaming(
&lane,
&prompt,
&tui,
&state,
drain_handle.is_some(),
reload_context.js_extension_session.as_ref(),
&js_dialog_bridge,
args,
std::mem::take(&mut images),
)
.await;
}
loop {
if !*running.lock().unwrap() {
break;
}
match rx.recv().await {
Some(TuiMessage::UserInput(prompt)) => {
editor.clear();
let prompt_images = state.take_pending_images();
if !prompt_images.is_empty() {
add_note_message(
&chat_container,
&format!("Attached {} image(s) to this prompt.", prompt_images.len()),
);
}
run_prompt_streaming(
&lane,
&prompt,
&tui,
&state,
drain_handle.is_some(),
reload_context.js_extension_session.as_ref(),
&js_dialog_bridge,
args,
prompt_images,
)
.await;
}
Some(TuiMessage::ExternalEditorResult(result)) => {
match result {
Ok(text) => {
let cursor = text.chars().count();
editor.set_text(&text);
editor.set_cursor(0, cursor);
add_note_message(&chat_container, "Draft updated from external editor.");
}
Err(error) => add_error_message(&chat_container, &error),
}
tui.request_render(false);
}
Some(TuiMessage::OpenTree) => {
if *state.status.lock().unwrap() != RunStatus::Idle {
add_note_message(
&chat_container,
"Wait for the current run to finish before opening the tree.",
);
tui.request_render(false);
} else {
open_tree_selector(
&harness,
&state,
&editor_container,
&editor,
&tui,
&chat_container,
&tx,
)
.await;
}
}
Some(TuiMessage::NavigateTree(entry_id)) => {
match lane.navigate_tree(Some(&entry_id), false, None, None).await {
Ok(result) => match result.outcome {
rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
chat_container.clear();
add_welcome_message(&chat_container);
render_session_history(
&harness,
&chat_container,
state.markdown_transformer(),
Some(state.extension_session.clone()),
)
.await;
add_note_message(
&chat_container,
"Moved to the selected session entry.",
);
}
rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
add_error_message(&chat_container, &error.message);
}
_ => add_note_message(
&chat_container,
"The selected entry could not be opened.",
),
},
Err(error) => add_error_message(
&chat_container,
&format!("Could not navigate session tree: {error}"),
),
}
tui.request_render(false);
}
Some(TuiMessage::ClearChat) => {
chat_container.clear();
add_welcome_message(&chat_container);
tui.request_render(false);
}
Some(TuiMessage::Compact) => {
run_compact(&lane, &tui, &state).await;
}
Some(TuiMessage::Copy) => {
copy_last_assistant(&state, &chat_container);
tui.request_render(false);
}
Some(TuiMessage::Exit) => {
*running.lock().unwrap() = false;
break;
}
Some(TuiMessage::SwitchSession(id)) => {
switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Some(TuiMessage::ImportSession(path)) => {
import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Some(TuiMessage::ShareSession) => {
share_session(&harness, &chat_container).await;
tui.request_render(false);
}
Some(TuiMessage::SetSessionName(name)) => {
let outcome = harness.session().set_name(Some(&name)).await;
match outcome {
Ok(_) => add_note_message(
&chat_container,
&format!("Session renamed to \"{name}\"."),
),
Err(e) => add_error_message(
&chat_container,
&format!("Could not rename session: {e}"),
),
}
tui.request_render(false);
}
Some(TuiMessage::ExportSession) => {
export_session(&harness, &chat_container, &cwd).await;
tui.request_render(false);
}
Some(TuiMessage::ForkSession) => {
fork_session(&harness, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Some(TuiMessage::ReloadExtensions) => {
let reload_ctx = ctx.reload_context.clone();
add_note_message(&chat_container, "Reloading extensions + resources…");
tui.request_render(false);
let outcome =
crate::session::reload_extension_resources(&harness, &reload_ctx).await;
let fresh_transformer = build_markdown_transformer(
reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
);
state.set_markdown_transformer_with_reinstall(fresh_transformer);
if outcome.had_warnings {
add_error_message(
&chat_container,
&format!(
"{} (with warnings — see stderr for details).",
outcome.summary
),
);
} else {
add_note_message(&chat_container, &outcome.summary);
}
tui.request_render(false);
}
None => break,
}
}
js_dialog_bridge.cancel_all();
*running.lock().unwrap() = false;
for handle in update_check_handles {
handle.abort();
let _ = handle.await;
}
if let Some(js) = &reload_context.js_extension_session {
js.shutdown();
}
let _ = key_handle.await;
tick_handle.abort();
if let Some(handle) = drain_handle {
handle.abort();
}
reload_context.mailbox.clear();
reload_bridge_handle.abort();
tui.stop(Default::default());
println!("\nGoodbye!");
let _ = args;
0
}
async fn ensure_js_runtime_before_prompt(
js: Option<&crate::js_extensions::JsExtensionSession>,
lane: &Arc<dyn AgentLane>,
state: &Arc<TuiState>,
dialog_bridge: &JsDialogBridge,
args: &Args,
) -> bool {
let Some(js) = js else {
return true;
};
let cancellation = state.begin_js_preparation();
let worker_cancellation = cancellation.clone();
let js_for_start = js.clone();
let mut worker = tokio::task::spawn_blocking(move || {
js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
});
let result = tokio::select! {
result = &mut worker => result,
_ = cancellation.cancelled() => {
dialog_bridge.cancel_open_requests();
let js_for_cancel = js.clone();
let _ = tokio::task::spawn_blocking(move || {
js_for_cancel.cancel_prompt_preparation();
}).await;
worker.await
}
};
let was_cancelled = cancellation.is_cancelled();
if was_cancelled {
dialog_bridge.cancel_open_requests();
let js_for_cancel = js.clone();
let _ = tokio::task::spawn_blocking(move || {
js_for_cancel.cancel_prompt_preparation();
})
.await;
state.finish_js_preparation();
dialog_bridge.reopen();
return false;
}
state.finish_js_preparation();
match result {
Ok(Ok(())) => {
if let Some(js_active) = js.active_tools() {
let js_names = js.tool_names();
let mut active = lane.get_active_tools().await.unwrap_or_default();
active.retain(|name| {
crate::session::tool_name_allowed(name, args)
&& !js_names.iter().any(|js_name| js_name == name)
});
active.extend(js_active.into_iter().filter(|name| {
js_names.iter().any(|js_name| js_name == name)
&& crate::session::tool_name_allowed(name, args)
}));
active = crate::session::filter_active_tool_names(active, args);
let _ = lane.set_active_tools(active).await;
}
}
Ok(Err(error)) => {
if args.verbose {
eprintln!("warning: could not start JS extension runtime: {error}");
}
}
Err(error) => {
if args.verbose {
eprintln!("warning: JS extension runtime worker failed: {error}");
}
}
}
true
}
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));
});
}
fn reconcile_streamed_assistant_completion(
current_assistant: &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;
}
}
async fn run_prompt_streaming(
lane: &Arc<dyn AgentLane>,
prompt: &str,
tui: &Arc<TuiAltScreen>,
state: &Arc<TuiState>,
streaming: bool,
js: Option<&crate::js_extensions::JsExtensionSession>,
dialog_bridge: &JsDialogBridge,
args: &Args,
images: Vec<rpi_ai::types::ImageContent>,
) {
state.set_status(RunStatus::Working);
tui.request_render(false);
if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
state.set_status(RunStatus::Idle);
tui.request_render(false);
return;
}
let outcome = 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);
match outcome {
Ok(result) => match &result.outcome {
HarnessRunOutcome::Failed {
error,
final_message,
..
} => {
let already_rendered = 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);
}
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.");
}
Err(e) => {
add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
}
}
state.set_status(RunStatus::Idle);
tui.request_render(false);
}
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 {
""
}
),
);
}
}
#[cfg(feature = "clipboard")]
fn copy_to_clipboard(text: &str) -> bool {
match arboard::Clipboard::new() {
Ok(mut cb) => cb.set_text(text).is_ok(),
Err(_) => false,
}
}
#[cfg(not(feature = "clipboard"))]
fn copy_to_clipboard(_text: &str) -> bool {
false
}
#[cfg(feature = "clipboard")]
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(),
}))
}
fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
let mut options = ImageOptions::default();
options.width = Some(48);
options.alt_text = Some("Attached image".into());
chat.add_child(Arc::new(Image::from_data(bytes, options)));
chat.add_child(Arc::new(Spacer::new(1)));
}
}
#[cfg(not(feature = "clipboard"))]
fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
Ok(None)
}
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)));
}
async fn drain_agent_events(
mut rx: broadcast::Receiver<AgentEvent>,
tui: Arc<TuiAltScreen>,
state: Arc<TuiState>,
chat: Arc<Container>,
) {
loop {
match rx.recv().await {
Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
Err(broadcast::error::RecvError::Lagged(_)) => {
continue;
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
async fn handle_agent_event(
event: AgentEvent,
tui: &Arc<TuiAltScreen>,
state: &Arc<TuiState>,
chat: &Arc<Container>,
) {
match event {
AgentEvent::AgentStart => {
state.set_status(RunStatus::Working);
tui.request_render(false);
}
AgentEvent::AgentEnd { .. } => {
if let Some(comp) = state.current_assistant.lock().unwrap().take() {
comp.set_streaming(false);
}
state.set_status(RunStatus::Idle);
tui.request_render(false);
}
AgentEvent::RetryScheduled {
attempt,
max_retries,
delay_ms,
..
} => {
state.show_retry(attempt, max_retries, delay_ms);
tui.request_render(false);
}
AgentEvent::TurnStart => {
if let Some(comp) = state.current_assistant.lock().unwrap().take() {
comp.set_streaming(false);
}
}
AgentEvent::TurnEnd {
message,
tool_results,
} => {
if let Some(comp) = state.current_assistant.lock().unwrap().take() {
if let AgentMessage::Assistant(a) = &message {
comp.update_blocks(&assistant_blocks(a));
}
comp.set_streaming(false);
}
let tools = state.tool_components.lock().unwrap();
for tr in &tool_results {
if tools.contains_key(&tr.tool_call_id) {
let _ = tr;
}
}
drop(tools);
tui.request_render(false);
}
AgentEvent::MessageStart { message } => match message {
AgentMessage::Assistant(a) => {
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(t) = state.markdown_transformer() {
comp.set_markdown_transformer(Some(t));
}
comp.set_hide_thinking(state.hide_thinking());
comp.set_streaming(true);
comp.update_blocks(&assistant_blocks(&a));
chat.add_child(comp.clone());
chat.add_child(Arc::new(Spacer::new(1)));
*state.current_assistant.lock().unwrap() = Some(comp);
tui.request_render(false);
}
AgentMessage::Custom(custom) => {
let payload = serde_json::json!({
"customType": custom.role,
"content": custom.content,
"details": custom.data,
"expanded": false,
"outputPad": 1,
});
if let Some(component) = extension_message_component(
&state.extension_session,
&custom.role,
&payload,
state.markdown_transformer(),
) {
chat.add_child(component);
chat.add_child(Arc::new(Spacer::new(1)));
tui.request_render(false);
} else {
add_note_message(chat, &custom_message_fallback(&custom));
tui.request_render(false);
}
}
_ => {}
},
AgentEvent::MessageUpdate {
message,
assistant_message_event,
} => {
if let AgentMessage::Assistant(a) = &message {
let text = assistant_text(a);
let mut saw_bash_tool_call = false;
for c in &a.content {
if let Content::ToolCall(tc) = c {
if tc.name.trim().is_empty() {
continue;
}
if tc.name == "bash" {
let command = tc
.arguments
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
if command.trim().is_empty() {
continue;
}
saw_bash_tool_call = true;
let mut bash = state.bash_components.lock().unwrap();
if !bash.contains_key(&tc.id) {
let comp = Arc::new(BashExecutionComponent::new(command));
comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
chat.add_child(comp.clone());
bash.insert(tc.id.clone(), comp);
}
} else {
let mut tools = state.tool_components.lock().unwrap();
if !tools.contains_key(&tc.id) {
let comp = Arc::new(ToolExecutionComponent::new(
&tc.name,
&tc.arguments.to_string(),
));
comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
comp.set_running();
chat.add_child(comp.clone());
tools.insert(tc.id.clone(), comp);
}
}
}
}
if saw_bash_tool_call {
state.sync_working_loader_with_bash();
}
let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
comp.update_blocks(&assistant_blocks(a));
}
*state.last_assistant_text.lock().unwrap() = text;
tui.request_render(false);
}
}
AgentEvent::MessageEnd { message } => {
if let AgentMessage::Assistant(a) = &message {
let text = assistant_text(a);
if let Some(comp) = state.current_assistant.lock().unwrap().take() {
comp.update_blocks(&assistant_blocks(a));
comp.set_streaming(false);
}
if !text.is_empty() {
*state.last_assistant_text.lock().unwrap() = text;
}
let usage = &a.usage;
let prev_input = *state.last_input_tokens.lock().unwrap();
if prev_input > 0
&& usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
&& usage.cache_read == 0
{
add_note_message(
&state.chat_container,
&format!(
"Cache miss: {} tokens re-billed",
format_tokens(usage.input)
),
);
}
if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
add_note_message(chat, &text);
}
if let Some(error) = assistant_error_text(a) {
add_error_message(chat, &error);
}
*state.last_input_tokens.lock().unwrap() = usage.input;
}
tui.request_render(false);
}
AgentEvent::ToolExecutionStart {
tool_call_id,
tool_name,
args,
} => {
if tool_name.trim().is_empty() {
return;
}
if tool_name == "bash" {
let command = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if command.trim().is_empty() {
return;
}
let mut bash_map = state.bash_components.lock().unwrap();
if let Some(existing) = bash_map.get(&tool_call_id) {
existing.set_command(&command);
} else {
let comp = Arc::new(BashExecutionComponent::new(command));
comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
chat.add_child(comp.clone());
bash_map.insert(tool_call_id.clone(), comp);
}
} else {
let _comp = {
let mut tools = state.tool_components.lock().unwrap();
if let Some(existing) = tools.get(&tool_call_id) {
existing.set_args(&args.to_string());
existing.clone()
} else {
let comp =
Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
if let Some(skill) = skill_tool_name(&tool_name, &args) {
comp.set_skill_name(skill);
}
comp.set_running();
chat.add_child(comp.clone());
tools.insert(tool_call_id.clone(), comp.clone());
comp
}
};
}
state.sync_working_loader_with_bash();
tui.request_render(false);
}
AgentEvent::ToolExecutionUpdate {
tool_call_id,
tool_name,
args,
partial_result,
} => {
if tool_name.trim().is_empty() {
return;
}
let partial_text = tool_result_text(&partial_result);
let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
if tool_name == "bash" {
let chunk = partial_text;
if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
if has_partial_payload {
bash.append_output(&chunk);
}
} else if has_partial_payload {
}
} else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
if let Some(skill) = skill_tool_name(&tool_name, &args) {
comp.set_skill_name(skill);
}
if has_partial_payload {
comp.set_result(&partial_text, false);
apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
}
} else if has_partial_payload {
let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
if let Some(skill) = skill_tool_name(&tool_name, &args) {
comp.set_skill_name(skill);
}
comp.set_running();
comp.set_result(&partial_text, false);
apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
chat.add_child(comp.clone());
state
.tool_components
.lock()
.unwrap()
.insert(tool_call_id.clone(), comp.clone());
}
state.sync_working_loader_with_bash();
tui.request_render(false);
}
AgentEvent::ToolExecutionEnd {
tool_call_id,
tool_name,
result,
is_error,
} => {
if tool_name.trim().is_empty() {
return;
}
if tool_name == "bash" {
let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
if let Some(bash) = bash {
finalize_bash(&bash, &result, is_error);
} else {
let command = result
.details
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
state.sync_working_loader_with_bash();
tui.request_render(false);
return;
}
let comp = Arc::new(BashExecutionComponent::new(command));
comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
comp.append_output(&tool_result_text(&result));
finalize_bash(&comp, &result, is_error);
chat.add_child(comp);
}
} else {
let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
if let Some(comp) = comp {
comp.set_result(&tool_result_text(&result), is_error);
apply_edit_diff(&comp, &tool_name, &result.details, &tui);
} else {
let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
comp.set_result(&tool_result_text(&result), is_error);
apply_edit_diff(&comp, &tool_name, &result.details, &tui);
chat.add_child(comp.clone());
}
}
state.sync_working_loader_with_bash();
tui.request_render(false);
}
}
}
fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
let fallback = match message.stop_reason {
rpi_ai::StopReason::Error => "Provider request failed.",
rpi_ai::StopReason::Aborted => "Request aborted.",
_ => return None,
};
Some(
message
.error_message
.as_deref()
.filter(|text| !text.trim().is_empty())
.unwrap_or(fallback)
.to_string(),
)
}
fn finalize_bash(
comp: &Arc<BashExecutionComponent>,
result: &rpi_agent::AgentToolResult,
is_error: bool,
) {
let exit_code = if is_error { Some(1) } else { Some(0) };
let truncated = result
.details
.get("truncation")
.and_then(|t| t.get("truncated"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let full_output_path = result
.details
.get("full_output_path")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let truncation = BashTruncation {
truncated,
full_output_path,
};
let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
}
fn apply_edit_diff(
comp: &Arc<ToolExecutionComponent>,
tool_name: &str,
details: &serde_json::Value,
tui: &Arc<TuiAltScreen>,
) {
if tool_name != "edit" {
return;
}
let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
return;
};
if diff_text.is_empty() {
return;
}
let width = tui.width();
let lines = render_diff(diff_text, width);
comp.set_diff(lines);
}
fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
if tool_name != "read" {
return None;
}
let path = args.get("path").and_then(|value| 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 summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
use rpi_agent::TextContentOrImage;
let mut parts: Vec<String> = Vec::new();
for c in &result.content {
if let TextContentOrImage::Text(t) = c {
parts.push(t.text.clone());
}
}
let joined = parts.join("\n");
let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
if one_line.chars().count() > 200 {
let truncated: String = one_line.chars().take(200).collect();
format!("{truncated}…")
} else {
one_line
}
}
fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
use rpi_agent::TextContentOrImage;
let mut parts: Vec<String> = Vec::new();
for c in &result.content {
if let TextContentOrImage::Text(t) = c {
parts.push(t.text.clone());
}
}
parts.join("\n")
}
fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
!text.trim().is_empty() || !result.details.is_null()
}
fn open_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
list: Arc<SelectList>,
kind: SelectorKind,
) {
open_selector_with_view(
state,
editor_container,
editor,
tui,
list.clone(),
list,
kind,
);
}
fn open_selector_with_view<C: Component + 'static>(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
list: Arc<SelectList>,
view: Arc<C>,
kind: SelectorKind,
) {
editor.set_focused(false);
editor_container.clear();
editor_container.add_child(view.clone());
*state.active_selector.lock().unwrap() = Some((list, kind));
let focused: Arc<dyn Component> = view;
tui.set_focus(Some(focused));
tui.request_render(false);
}
fn close_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
) {
editor_container.clear();
editor_container.add_child(editor.clone());
editor.set_focused(true);
*state.active_selector.lock().unwrap() = None;
*state.active_extension_cancel.lock().unwrap() = None;
tui.set_focus(Some(editor.clone()));
tui.request_render(false);
}
fn open_model_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
catalog: &[rpi_ai::Model],
lane: &Arc<dyn AgentLane>,
lane_model_id: &str,
chat: &Arc<Container>,
) {
let items = model_selector_items(catalog, lane_model_id);
if items.is_empty() {
add_note_message(
chat,
"No models in the catalog. Use --model at startup to select one.",
);
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let catalog_arc = catalog.to_vec();
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
add_note_message(
&chat_sel,
&format!("Model {} not found in catalog.", item.label),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
return;
};
state_sel.set_current_model(&model);
let lane = lane_sel.clone();
tokio::spawn(async move {
let _ = lane.set_model(model).await;
});
add_note_message(
&chat_sel,
&format!(
"Model set to {} — applies to the next message.",
short_model_name(&item.value)
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Model,
);
}
fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
if catalog.is_empty() {
return None;
}
let idx = catalog
.iter()
.position(|m| m.id.eq_ignore_ascii_case(current_id));
match idx {
Some(i) => {
let next = (i + 1) % catalog.len();
Some(catalog[next].clone())
}
None => Some(catalog[0].clone()),
}
}
fn open_session_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
cwd: &std::path::Path,
tx: &mpsc::UnboundedSender<TuiMessage>,
) {
let dir = crate::session::default_session_dir(cwd);
let mut items: Vec<SelectItem> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("(unnamed)")
.to_string();
let display = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&stem)
.to_string();
items.push(SelectItem::new(&stem, &display));
}
}
if items.is_empty() {
add_note_message(
&state.chat_container,
"No saved sessions found. Sessions are created automatically in interactive mode.",
);
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let tx_sel = tx.clone();
list.on_select(Arc::new(move |item| {
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Session,
);
}
fn custom_entry_display_text(
custom_type: &str,
data: Option<&serde_json::Value>,
) -> Option<String> {
let data = data?;
let text = data
.get("summary")
.or_else(|| data.get("text"))
.or_else(|| data.get("output"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())?;
let label = match custom_type {
"compactionSummary" => "Compaction summary",
"branchSummary" => "Branch summary",
"bashExecution" => "Command output",
other => other,
};
Some(format!("{label}: {text}"))
}
async fn open_tree_selector(
harness: &AgentHarness,
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
chat: &Arc<Container>,
tx: &mpsc::UnboundedSender<TuiMessage>,
) {
let entries = match harness
.session()
.view("main")
.find_entries(&EntryQuery {
order: Some(EntryOrder::OldestFirst),
..Default::default()
})
.await
{
Ok(entries) => entries,
Err(error) => {
add_error_message(chat, &format!("Could not read session tree: {error}"));
tui.request_render(false);
return;
}
};
let current = harness.session().get_leaf_id().await.ok().flatten();
let items: Vec<SelectItem> = entries
.iter()
.map(|entry| {
let marker = if current.as_deref() == Some(entry.id()) {
" (current)"
} else {
""
};
SelectItem::new(
entry.id(),
&format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
)
.with_description(&entry.id()[..entry.id().len().min(12)])
})
.collect();
if items.is_empty() {
add_note_message(chat, "The current session has no entries to navigate.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 12));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let tx_sel = tx.clone();
list.on_select(Arc::new(move |item| {
let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Tree,
);
}
fn open_theme_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
cwd: &std::path::Path,
package_resources: &Arc<crate::packages::PackageResources>,
) {
let mut items = vec![
SelectItem::new("dark", "Dark").with_description("Default dark theme"),
SelectItem::new("light", "Light").with_description("Light background"),
SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
];
if state.themes_enabled {
for path in package_resources.theme_files() {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
items.push(SelectItem::new(name, name).with_description("Package theme"));
}
}
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = state.chat_container.clone();
let cwd_sel = cwd.to_path_buf();
let package_resources_sel = package_resources.clone();
list.on_select(Arc::new(move |item| {
let preset = match item.value.as_str() {
"light" => Some(ThemePreset::Light),
"monochrome" => Some(ThemePreset::Monochrome),
"dark" => Some(ThemePreset::Dark),
name => {
if state_sel.themes_enabled {
if let Ok(custom) = crate::packages::load_theme_with_resources(
&cwd_sel,
name,
&package_resources_sel,
) {
rpi_tui::global_theme_manager().set(custom.clone());
state_sel.theme_manager.set(custom);
}
}
add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
return;
}
};
let Some(preset) = preset else { return };
apply_theme_preset(preset);
state_sel.theme_manager.apply_preset(preset);
add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Theme,
);
}
fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
use rpi_ai::types::ThinkingLevel::*;
match level {
Off => "Off — No reasoning",
Minimal => "Minimal — Brief reasoning (~1k tokens)",
Low => "Low — Light reasoning (~1k tokens)",
Medium => "Medium — Moderate reasoning (~80% of max)",
High => "High — Extensive reasoning (~95% of max)",
Xhigh => "Xhigh — Near-maximal reasoning",
Max => "Max — Maximum reasoning",
}
}
fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
use rpi_ai::types::ThinkingLevel::*;
match level {
Off => "off",
Minimal => "minimal",
Low => "low",
Medium => "medium",
High => "high",
Xhigh => "xhigh",
Max => "max",
}
}
fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
use rpi_ai::types::ThinkingLevel::*;
match name.to_ascii_lowercase().as_str() {
"off" => Some(Off),
"minimal" => Some(Minimal),
"low" => Some(Low),
"medium" => Some(Medium),
"high" => Some(High),
"xhigh" => Some(Xhigh),
"max" => Some(Max),
_ => None,
}
}
fn open_thinking_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
lane_model_id: &str,
chat: &Arc<Container>,
) {
let model = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
let levels: Vec<rpi_ai::types::ThinkingLevel> = model
.map(|m| m.supported_thinking_levels())
.unwrap_or_else(|| {
use rpi_ai::types::ThinkingLevel::*;
vec![Off, Minimal, Low, Medium, High]
});
let mut items: Vec<SelectItem> = Vec::new();
for lvl in &levels {
let name = thinking_level_name(*lvl);
items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
}
if items.is_empty() {
add_note_message(chat, "This model has no supported thinking levels.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let Some(level) = thinking_level_from_name(&item.value) else {
add_note_message(
&chat_sel,
&format!("Unknown thinking level: {}.", item.label),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
return;
};
let lane = lane_sel.clone();
let footer_sel = state_sel.footer.clone();
tokio::spawn(async move {
let _ = lane.set_thinking_level(level).await;
});
footer_sel.set_thinking_level(Some(thinking_level_name(level)));
add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Thinking,
);
}
fn open_tools_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
chat: &Arc<Container>,
) {
let mut active = match tokio::runtime::Handle::try_current() {
Ok(h) => h
.block_on(async { lane.get_active_tools().await })
.unwrap_or_default(),
Err(_) => Vec::new(),
};
if active.is_empty() {
active = crate::session::BUILTIN_TOOL_NAMES
.iter()
.map(|name| (*name).to_string())
.collect();
}
let mut items: Vec<SelectItem> = Vec::new();
for name in crate::session::BUILTIN_TOOL_NAMES {
let on = active.iter().any(|a| a == name);
let label = if on {
format!("{name} (on)")
} else {
(*name).to_string()
};
items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
}
let list = Arc::new(SelectList::new(items, 10));
let active_captured = active.clone();
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let mut next = active_captured.clone();
if let Some(pos) = next.iter().position(|a| a == &item.value) {
next.remove(pos);
} else {
next.push(item.value.clone());
}
let on = next.iter().any(|a| a == &item.value);
let lane = lane_sel.clone();
let next_clone = next.clone();
tokio::spawn(async move {
let _ = lane.set_active_tools(next_clone).await;
});
let list_str = if next.is_empty() {
"(none)".to_string()
} else {
next.join(", ")
};
add_note_message(
&chat_sel,
&format!(
"{} {} — active tools: {}",
item.value,
if on { "enabled" } else { "disabled" },
list_str
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Tools,
);
}
fn open_images_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
chat: &Arc<Container>,
) {
let current = *state.show_images.lock().unwrap();
let items = vec![
SelectItem::new("yes", "Yes").with_description(if current {
"Inline images (current)"
} else {
"Inline images"
}),
SelectItem::new("no", "No").with_description(if current {
"Placeholder only"
} else {
"Placeholder only (current)"
}),
];
let list = Arc::new(SelectList::new(items, 5));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
list.on_select(Arc::new(move |item| {
let on = item.value == "yes";
*state_sel.show_images.lock().unwrap() = on;
add_note_message(
&chat_sel,
&format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Images,
);
}
fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
let text = editor.get_text();
let (_row, col) = editor.cursor_position();
let cursor = col.min(text.len());
let suggestions = state.autocomplete.get_suggestions(&text, cursor);
render_autocomplete(state, suggestions);
}
fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
state.autocomplete_container.clear();
let Some(sugg) = suggestions else {
return;
};
if sugg.items.is_empty() {
return;
}
let accent = state.theme_manager.get().colors.accent;
let muted = state.theme_manager.get().colors.muted;
for (i, item) in sugg
.items
.iter()
.take(state.autocomplete_max_visible)
.enumerate()
{
let prefix = if i == 0 { "→ " } else { " " };
let label = item.display_text();
let line = if i == 0 {
format!(
"{prefix}{} {}",
accent.fg(label),
muted.fg(item.description.as_deref().unwrap_or(""))
)
} else {
format!(
"{prefix}{} {}",
muted.fg(label),
muted.fg(item.description.as_deref().unwrap_or(""))
)
};
state
.autocomplete_container
.add_child(Arc::new(Text::new(line, 1, 0)));
}
}
fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
let text = editor.get_text();
let (_row, col) = editor.cursor_position();
let cursor = col.min(text.len());
let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
return false;
};
let Some(top) = sugg.items.first() else {
return false;
};
let start = sugg.start.min(text.len());
let end = sugg.end.min(text.len());
let mut replaced = String::with_capacity(text.len() + top.text.len());
replaced.push_str(&text[..start]);
replaced.push_str(&top.text);
replaced.push_str(&text[end..]);
if top.insert_space && !replaced.ends_with('/') {
replaced.push(' ');
}
let new_cursor = replaced.len().min(
start
+ top.text.len()
+ if top.insert_space && !top.text.ends_with('/') {
1
} else {
0
},
);
editor.set_text(&replaced);
editor.set_cursor(0, new_cursor);
state.autocomplete_container.clear();
true
}
fn add_welcome_message(container: &Arc<Container>) {
add_welcome_message_with_capabilities(container, &[], &[]);
}
fn add_welcome_message_with_capabilities(
container: &Arc<Container>,
active_tools: &[String],
skills: &[String],
) {
let c = current_theme().colors;
let title = format!(
"{} {}",
c.accent.fg(&tui_bold("rpi")),
c.muted.fg("interactive TUI")
);
container.add_child(Arc::new(Text::new(title, 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
c.dim.fg("Type your message and press Enter to send."),
1,
0,
)));
let hint = c
.dim
.fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
container.add_child(Arc::new(Text::new(hint, 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
welcome_capability_line("Tools", active_tools),
1,
0,
)));
container.add_child(Arc::new(Text::new(
welcome_capability_line("Skills", skills),
1,
0,
)));
container.add_child(Arc::new(DynamicBorder::new()));
}
fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
let colors = current_theme().colors;
let group = Arc::new(Container::new());
for warning in &report.warnings {
let body = format!(
"{}\n{} {}{}",
colors.error.fg(&warning.message),
colors.muted.fg("Run"),
colors.accent.fg(&warning.command),
colors.muted.fg(" to retry.")
);
add_update_panel(&group, "Update Failed", &body, colors.error);
}
if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
let body = format!(
"{} {}{}",
colors
.muted
.fg(&format!("New version {} is available. Run", notice.latest)),
colors.accent.fg(¬ice.command),
colors.muted.fg(".")
);
add_update_panel(&group, "Update Available", &body, colors.warning);
}
let package_notices = report
.notices
.iter()
.filter(|notice| notice.name != "rpi")
.collect::<Vec<_>>();
if !package_notices.is_empty() {
let command = package_notices[0].command.as_str();
let mut lines = vec![format!(
"{} {}{}",
colors.muted.fg("Package updates are available. Run"),
colors.accent.fg(command),
colors.muted.fg(".")
)];
lines.push(colors.muted.fg("Packages:"));
lines.extend(
package_notices
.into_iter()
.map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
);
add_update_panel(
&group,
"Package Updates Available",
&lines.join("\n"),
colors.warning,
);
}
if group.child_count() > 0 {
container.add_child(group);
}
}
fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(DynamicBorder::with_color(color)));
container.add_child(Arc::new(Text::new(
format!("{}\n{body}", color.fg(&tui_bold(title))),
1,
0,
)));
container.add_child(Arc::new(DynamicBorder::with_color(color)));
}
fn welcome_capability_line(label: &str, names: &[String]) -> String {
let c = current_theme().colors;
let value = if names.is_empty() {
"none".to_string()
} else {
names.join(" · ")
};
format!(
"{} {}",
c.accent.fg(&format!("{label} ({})", names.len())),
c.muted.fg(&value)
)
}
fn add_help_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("📚 Available Commands")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let cmds: &[(&str, &str)] = &[
("/help, /?", "Show this help message"),
("/clear, /new", "Clear the conversation"),
("/exit, /quit, /q", "Exit the application"),
("/version, /v", "Show version information"),
("/changelog", "Show recent release changes"),
("/model, /m", "Choose a model (live switch)"),
("/thinking, /think", "Set reasoning depth (selector)"),
("/tools", "Toggle built-in tools on/off"),
("/images", "Toggle inline image rendering"),
("/session", "List saved sessions"),
("/theme", "Choose a theme (selector)"),
("/compact", "Compact the conversation"),
("/copy", "Copy last reply to clipboard"),
("/hotkeys", "Show keyboard shortcuts"),
("/armin", "🐾 Easter egg"),
("/earendil", "Earendil announcement"),
];
let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (cmd, desc) in cmds {
let row = format!(
" {:<cmd_w$} {} {}",
c.accent.fg(cmd),
c.dim.fg("—"),
c.muted.fg(desc)
);
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_version_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("📦 Version Information")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
format!(
" {} {}",
c.muted.fg("rpi-cli"),
c.text.fg(&format!("v{}", crate::VERSION))
),
1,
0,
)));
container.add_child(Arc::new(Text::new(
format!(
" {}",
c.dim.fg("Rust implementation of pi coding agent TUI")
),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_changelog_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("Recent Changes")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let entries = [
(
"Native parity phase 1",
"models, images, trust, export, and JSON events",
),
(
"TUI controls",
"external editor, thinking levels, and tool output toggles",
),
(
"Provider auth",
"OpenAI-compatible API key aliases and gateway headers",
),
];
for (release, summary) in entries {
let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Text::new(
format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_hotkeys_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let keys: &[(&str, &str)] = &[
("Enter", "Send message"),
("Shift+Enter", "New line"),
("Tab", "Accept autocomplete suggestion"),
("Ctrl+A / Ctrl+E", "Line start / end"),
(
"Ctrl+K / Ctrl+U",
"Kill to end / start of line (Ctrl+Y yanks)",
),
("Ctrl+- / Ctrl+R", "Undo / redo"),
("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
("Alt+Backspace", "Kill previous word"),
("Ctrl+C", "Abort a run, or exit when idle"),
("Esc", "Abort a running prompt"),
("Ctrl+L", "Open model selector"),
("Ctrl+M", "Cycle to the next model (live)"),
("Ctrl+O", "Expand/collapse all tool output"),
("Ctrl+T", "Show/hide reasoning blocks"),
("PageUp/Down", "Scroll transcript by one page"),
("Home / End", "Jump to transcript start / latest output"),
];
let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, desc) in keys {
let row = format!(
" {:<key_w$} {} {}",
c.accent.fg(key),
c.dim.fg("—"),
c.muted.fg(desc)
);
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_user_message(container: &Arc<Container>, text: &str) {
container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_error_message(container: &Arc<Container>, text: &str) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
format!(" {} {}", c.error.fg("✗"), c.error.fg(text)),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_note_message(container: &Arc<Container>, text: &str) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn show_context_panel(
chat: &Arc<Container>,
resources: &Arc<rpi_harness::types::AgentHarnessResources>,
) {
let skills = resources.skills.as_deref().unwrap_or(&[]);
let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
let mut lines: Vec<String> = Vec::new();
lines.push("📂 Discovered resources for this session:".into());
if skills.is_empty() {
lines.push(
" Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
);
} else {
lines.push(format!(" Skills ({}):", skills.len()));
for s in skills {
let marker = if s.disable_model_invocation == Some(true) {
" [hidden]"
} else {
""
};
let desc: String = s.description.chars().take(72).collect();
lines.push(format!(" • {}{marker} — {desc}", s.name));
}
}
if templates.is_empty() {
lines.push(
" Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
);
} else {
lines.push(format!(" Prompt templates ({}):", templates.len()));
for t in templates {
let desc = t
.description
.as_deref()
.unwrap_or("(no description)")
.chars()
.take(72)
.collect::<String>();
lines.push(format!(" • /{} — {desc}", t.name));
}
}
lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
lines.push(
" Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
.into(),
);
let body = lines.join("\n");
container_note_block(chat, &body);
}
fn container_note_block(container: &Arc<Container>, body: &str) {
for line in body.lines() {
container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
}
container.add_child(Arc::new(Spacer::new(1)));
}
pub fn is_tui_supported() -> bool {
std::io::stdout().is_terminal()
}
#[allow(unused_imports)]
use rpi_tui::Color as _Color;
#[cfg(test)]
mod tests {
use super::*;
use rpi_tui::Component;
#[test]
fn verbose_overrides_quiet_startup_listing() {
assert!(should_show_startup_listing(false, false));
assert!(should_show_startup_listing(true, false));
assert!(should_show_startup_listing(true, true));
assert!(!should_show_startup_listing(false, true));
}
#[test]
fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
let tmp = tempfile::tempdir().unwrap();
let git_root = tmp.path().join(".pi/git/github.com/example/repo");
let local_root = tmp.path().join("local-package");
std::fs::create_dir_all(git_root.join(".git")).unwrap();
std::fs::create_dir_all(&local_root).unwrap();
std::fs::write(
git_root.join("package.json"),
r#"{"name":"git-demo","version":"1.0.0"}"#,
)
.unwrap();
std::fs::write(
local_root.join("package.json"),
r#"{"name":"local-demo","version":"1.0.0"}"#,
)
.unwrap();
let resources = crate::packages::discover(
tmp.path(),
&[
"git:github.com/example/repo".to_string(),
local_root.to_string_lossy().into_owned(),
],
);
assert_eq!(resources.packages.len(), 2);
let filtered = git_only_update_resources(resources);
assert_eq!(filtered.packages.len(), 1);
assert!(matches!(
filtered.packages[0].source,
crate::packages::PackageSource::Git
));
}
#[test]
fn trust_command_uses_the_session_cwd_after_process_chdir() {
const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
const TEST_NAME: &str =
"interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
if std::env::var_os(CHILD_ENV).is_some() {
let session_cwd = std::path::PathBuf::from(
std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
);
let changed_cwd = std::env::current_dir().unwrap();
set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
assert_eq!(
crate::config::project_trust_decision(&session_cwd).unwrap(),
Some(true)
);
assert_eq!(
crate::config::project_trust_decision(&changed_cwd).unwrap(),
None
);
return;
}
let tmp = tempfile::tempdir().unwrap();
let session_cwd = tmp.path().join("session-project");
let changed_cwd = tmp.path().join("extension-cwd");
let agent_dir = tmp.path().join("agent");
std::fs::create_dir_all(&session_cwd).unwrap();
std::fs::create_dir_all(&changed_cwd).unwrap();
std::fs::create_dir_all(&agent_dir).unwrap();
let status = std::process::Command::new(std::env::current_exe().unwrap())
.arg("--exact")
.arg(TEST_NAME)
.arg("--nocapture")
.env(CHILD_ENV, "1")
.env(SESSION_CWD_ENV, &session_cwd)
.env(crate::config::CONFIG_DIR_ENV, &agent_dir)
.current_dir(&changed_cwd)
.status()
.unwrap();
assert!(status.success(), "child test process failed: {status}");
}
#[test]
fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
let global = crate::settings::Settings {
editor_padding_x: Some(3),
autocomplete_max_visible: Some(6),
hide_thinking_block: Some(false),
quiet_startup: Some(true),
show_terminal_progress: Some(true),
..Default::default()
};
let rpi_project = crate::settings::Settings {
editor_padding_x: Some(0),
hide_thinking_block: Some(true),
quiet_startup: Some(false),
show_terminal_progress: Some(false),
..Default::default()
};
let pi_project = crate::settings::Settings {
editor_padding_x: Some(9),
autocomplete_max_visible: Some(12),
quiet_startup: Some(true),
..Default::default()
};
assert_eq!(
resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
TuiStartupSettings {
editor_padding_x: 0,
autocomplete_max_visible: 12,
hide_thinking: true,
quiet_startup: false,
show_terminal_progress: false,
}
);
}
#[test]
fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
let global = crate::settings::Settings {
quiet_startup: Some(false),
..Default::default()
};
let rpi_project = crate::settings::Settings::default();
let pi_project = crate::settings::Settings {
quiet_startup: Some(true),
..Default::default()
};
let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
assert!(resolved.quiet_startup);
}
#[test]
fn tui_startup_settings_use_global_values_without_project_fields() {
let global = crate::settings::Settings {
editor_padding_x: Some(7),
autocomplete_max_visible: Some(8),
hide_thinking_block: Some(true),
quiet_startup: Some(true),
show_terminal_progress: Some(false),
..Default::default()
};
assert_eq!(
resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
TuiStartupSettings {
editor_padding_x: 7,
autocomplete_max_visible: 8,
hide_thinking: true,
quiet_startup: true,
show_terminal_progress: false,
}
);
}
#[test]
fn tui_startup_settings_ignore_untrusted_project_values() {
let global = crate::settings::Settings {
quiet_startup: Some(false),
show_terminal_progress: Some(true),
..Default::default()
};
let project = crate::settings::Settings {
quiet_startup: Some(true),
show_terminal_progress: Some(false),
..Default::default()
};
let resolved = resolve_tui_startup_settings(&global, &[project], false);
assert!(!resolved.quiet_startup);
assert!(resolved.show_terminal_progress);
}
#[test]
fn transcript_page_uses_viewport_with_overlap() {
assert_eq!(transcript_page_size(24), 20);
assert_eq!(transcript_page_size(4), 1);
assert_eq!(transcript_page_size(0), 1);
}
#[test]
fn key_repeat_is_dispatched_but_release_is_not() {
assert!(should_dispatch_key(KeyEventKind::Press));
assert!(should_dispatch_key(KeyEventKind::Repeat));
assert!(!should_dispatch_key(KeyEventKind::Release));
}
#[test]
fn key_event_encoding_matches_pi_keybinding_protocol() {
let key = |code, modifiers| KeyEvent::new(code, modifiers);
assert_eq!(
key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
"\r"
);
assert_eq!(
key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
"\x1b[13;2u"
);
assert_eq!(
key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
"\x1b[9;2u"
);
assert_eq!(
key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
"\x1b[Z"
);
assert_eq!(
key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
"\x1b[Z"
);
assert_eq!(
key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
"\x03"
);
assert_eq!(
key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
"\x0f"
);
assert_eq!(
key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
"!"
);
assert_eq!(
key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
"1"
);
}
#[test]
fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
let bridge = JsDialogBridge::default();
let (sender, receiver) = std_mpsc::channel();
bridge.pending.lock().unwrap().push_back(JsDialogPending {
request: JsDialogRequest {
id: "dialog-1".into(),
method: "input".into(),
title: String::new(),
message: String::new(),
options: Vec::new(),
placeholder: None,
prefill: None,
},
result: sender,
});
bridge.cancel("dialog-1");
assert!(bridge.take_pending().is_none());
assert_eq!(
receiver.recv().unwrap(),
serde_json::json!({ "cancelled": true })
);
assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
}
#[test]
fn test_layout_renders_welcome_message() {
let chat = Arc::new(Container::new());
add_welcome_message(&chat);
let scroll = Arc::new(ScrollView::new(
chat.clone(),
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
..Default::default()
},
));
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
..Default::default()
},
EditorStyle::default(),
Arc::new(rpi_tui::Keybindings::new()),
));
let dock = Arc::new(Container::new());
dock.add_child(editor);
let footer = Arc::new(FooterComponent::new());
let root = VStack::from_children(vec![
StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
StackChild::Entry(StackEntry::new(dock)),
StackChild::Entry(StackEntry::new(footer)),
]);
let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
let all: String = frame.lines.join("\n");
assert!(
all.contains("rpi"),
"Welcome message not found. Rendered: {}",
all
);
assert!(
all.contains("Type your message"),
"Help text not found. Rendered: {}",
all
);
}
#[test]
fn test_chat_container_has_welcome_content() {
let chat = Arc::new(Container::new());
add_welcome_message_with_capabilities(
&chat,
&["read".into(), "bash".into(), "web_fetch".into()],
&["rust-review".into(), "release".into()],
);
let lines = chat.render(80);
let all: String = lines.join("\n");
let plain = strip_ansi(&all);
assert!(
plain.contains("rpi"),
"Welcome message not in chat container: {:?}",
lines
);
assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
assert!(
plain.contains("read · bash · web_fetch"),
"Tool names missing: {plain}"
);
assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
assert!(
plain.contains("rust-review · release"),
"Skill names missing: {plain}"
);
}
#[test]
fn update_notices_render_inside_the_transcript() {
let chat = Arc::new(Container::new());
let report = crate::updates::UpdateReport {
notices: vec![
crate::updates::UpdateNotice {
name: "rpi".into(),
current: "0.1.10".into(),
latest: "0.1.11".into(),
command: "rpi self-update".into(),
},
crate::updates::UpdateNotice {
name: "rpi-search".into(),
current: "0.1.0".into(),
latest: "0.1.1".into(),
command: "rpi pi-update".into(),
},
],
warnings: vec![crate::updates::UpdateWarning {
message: "The previously scheduled rpi self-update failed: access denied".into(),
command: "rpi self-update".into(),
}],
};
add_update_notices(&chat, &report);
assert_eq!(chat.child_count(), 1);
let plain = strip_ansi(&chat.render(80).join("\n"));
assert!(plain.contains("Update Failed"), "{plain}");
assert!(
plain.contains("self-update failed: access denied"),
"{plain}"
);
assert!(plain.contains("Update Available"), "{plain}");
assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
assert!(plain.contains("rpi self-update"), "{plain}");
assert!(plain.contains("Package Updates Available"), "{plain}");
assert!(plain.contains("rpi pi-update"), "{plain}");
assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
}
#[test]
fn empty_update_report_does_not_add_transcript_content() {
let chat = Arc::new(Container::new());
add_update_notices(&chat, &crate::updates::UpdateReport::default());
assert_eq!(chat.child_count(), 0);
}
#[test]
fn skill_reads_are_detected_by_path() {
let name = skill_tool_name(
"read",
&serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
);
assert_eq!(name.as_deref(), Some("release"));
let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
assert!(name.is_none());
assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
}
#[test]
fn welcome_capabilities_show_empty_state() {
let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
assert_eq!(plain, "Skills (0) none");
}
#[test]
fn empty_tool_progress_is_deferred_until_start() {
let empty = rpi_agent::AgentToolResult::default();
assert!(!tool_update_has_payload("", &empty));
let text = rpi_agent::AgentToolResult::text("partial output");
assert!(tool_update_has_payload("partial output", &text));
let details = rpi_agent::AgentToolResult {
details: serde_json::json!({"path": "src/lib.rs"}),
..Default::default()
};
assert!(tool_update_has_payload("", &details));
}
#[test]
fn completed_stream_reconciles_the_final_tail_before_detaching() {
let component = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
component.set_streaming(true);
component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
let current = Mutex::new(Some(component.clone()));
let cached = Mutex::new("partial response".to_string());
let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
final_message.content = vec![Content::text(
"partial response with the previously missing final tail",
)];
final_message.stop_reason = rpi_ai::types::StopReason::Stop;
reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
assert!(current.lock().unwrap().is_none());
assert_eq!(
cached.lock().unwrap().as_str(),
"partial response with the previously missing final tail"
);
let rendered = strip_ansi(&component.render(100).join("\n"));
assert!(
rendered.contains("previously missing final tail"),
"{rendered}"
);
}
#[test]
fn tab_accept_suggestion_reflects_in_next_render() {
use rpi_tui::render_layout_frame;
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
..Default::default()
},
EditorStyle::default(),
Arc::new(rpi_tui::Keybindings::new()),
));
editor.set_focused(true);
let editor_container = Arc::new(Container::new());
editor_container.add_child(editor.clone());
let autocomplete_container = Arc::new(Container::new());
let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
let dock = Arc::new(VStack::from_children(vec![
StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
StackChild::Entry(
StackEntry::new(editor_container.clone())
.shrink(0)
.min_size(3),
),
StackChild::Entry(StackEntry::new(footer)),
]));
let mut manager = AutocompleteManager::new();
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(
SlashCommandAutocompleteProvider::with_default_commands(),
));
combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
manager.set_provider(Arc::new(combined));
editor.insert("/mo");
assert_eq!(editor.cursor_position(), (0, 3));
let frame_before = render_layout_frame(dock.clone(), 80, 10);
assert!(
frame_before.lines.iter().any(|l| l.contains("/mo")),
"precondition: editor shows the typed prefix. Frame rows:\n{}",
frame_before
.lines
.iter()
.map(|l| format!(" [{l}]"))
.collect::<Vec<_>>()
.join("\n")
);
let text = editor.get_text();
let (_row, col) = editor.cursor_position();
let cursor = col.min(text.len());
let sugg = manager
.get_suggestions(&text, cursor)
.expect("slash suggestions for /mo");
let top = sugg.items.first().expect("at least one suggestion");
let start = sugg.start.min(text.len());
let end = sugg.end.min(text.len());
let mut replaced = String::new();
replaced.push_str(&text[..start]);
replaced.push_str(&top.text);
replaced.push_str(&text[end..]);
if top.insert_space && !replaced.ends_with('/') {
replaced.push(' ');
}
editor.set_text(&replaced);
editor.set_cursor(0, replaced.len().min(start + top.text.len()));
autocomplete_container.clear();
assert_eq!(editor.get_text(), "/model");
let frame_after = render_layout_frame(dock, 80, 10);
let all: String = frame_after.lines.join("\n");
assert!(
all.contains("/model"),
"completed text missing from next render. Got:\n{all}"
);
let editor_line = frame_after
.lines
.iter()
.find(|l| l.contains("/model"))
.expect("editor row with completed text");
assert!(
editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
"caret must follow the full completed text. Got: {editor_line:?}"
);
}
#[test]
fn test_slash_command_dispatch() {
let registry = build_builtin_registry();
let resolves_to = |token: &str, canonical: &str| {
let found = registry.find(token).expect("{token} should resolve");
assert_eq!(
found.name(),
canonical,
"{token} resolved to {} (expected {canonical})",
found.name()
);
};
resolves_to("/help", "/help");
resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
resolves_to("/hotkeys", "/hotkeys");
resolves_to("/model", "/model");
resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
resolves_to("/session", "/session");
resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
resolves_to("/copy", "/copy");
resolves_to("/thinking", "/thinking");
resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
resolves_to("/images", "/images");
resolves_to("/armin", "/armin");
resolves_to("/earendil", "/earendil");
resolves_to("/context", "/context");
resolves_to("/settings", "/settings");
resolves_to("/name", "/name");
resolves_to("/export", "/export");
assert!(registry.find("/nope").is_none(), "/nope should be unknown");
}
#[test]
fn test_registry_visible_entries_cover_dispatch() {
let registry = build_builtin_registry();
let names: Vec<String> = registry
.visible_entries()
.iter()
.map(|c| c.name.clone())
.collect();
for recognized in [
"/help",
"/clear",
"/new",
"/exit",
"/quit",
"/version",
"/changelog",
"/model",
"/session",
"/theme",
"/compact",
"/copy",
"/hotkeys",
"/tools",
"/images",
"/thinking",
"/armin",
"/earendil",
] {
assert!(
names.contains(&recognized.to_string()),
"{recognized} missing from autocomplete list"
);
}
for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
assert!(
!names.contains(&hidden.to_string()),
"{hidden} should be hidden from autocomplete"
);
}
}
#[test]
fn test_agent_event_mapping_creates_assistant_and_tool() {
use rpi_ai::types::{
StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
ToolCall, ToolCallType, Usage,
};
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
themes_enabled: true,
hide_thinking: std::sync::Mutex::new(false),
tool_outputs_expanded: std::sync::Mutex::new(false),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
js_preparation_cancel: std::sync::Mutex::new(None),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
last_input_tokens: std::sync::Mutex::new(0),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: std::sync::Mutex::new(None),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
let assistant = AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![
Content::Thinking(ThinkingContent {
kind: ThinkingContentType,
thinking: "Reasoning about the reply.".into(),
thinking_signature: None,
redacted: false,
}),
Content::Text(TextContent {
kind: TextContentType,
text: "Hello.".into(),
text_signature: None,
}),
Content::ToolCall(ToolCall {
kind: ToolCallType,
id: "tc1".into(),
name: "bash".into(),
arguments: serde_json::json!({"command": "echo hi"}),
thought_signature: None,
namespace: None,
}),
],
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
};
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
comp.set_streaming(true);
comp.update_blocks(&assistant_blocks(&assistant));
let chat = Arc::new(Container::new());
chat.add_child(comp.clone());
*state.current_assistant.lock().unwrap() = Some(comp);
for c in &assistant.content {
if let Content::ToolCall(tc) = c {
let mut tools = state.tool_components.lock().unwrap();
if !tools.contains_key(&tc.id) {
let tc_comp = Arc::new(ToolExecutionComponent::new(
&tc.name,
&tc.arguments.to_string(),
));
tc_comp.set_running();
chat.add_child(tc_comp.clone());
tools.insert(tc.id.clone(), tc_comp);
}
}
}
let rendered = chat.render(80);
let joined: String = rendered.join("\n");
assert!(
joined.contains("Hello."),
"assistant text not rendered: {joined}"
);
assert!(
joined.contains("Reasoning about the reply."),
"thinking block not rendered: {joined}"
);
assert_eq!(state.tool_components.lock().unwrap().len(), 1);
assert!(state.current_assistant.lock().unwrap().is_some());
let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
ended.set_result("hi", false);
assert!(state.tool_components.lock().unwrap().is_empty());
assert!(state.try_start_working());
assert!(
!state.try_start_working(),
"a second submit must be rejected"
);
state.set_status(RunStatus::Idle);
state.set_status(RunStatus::Working);
assert_eq!(state.status_container.child_count(), 1);
assert!(state.footer.get_status().is_empty());
state.show_retry(3, 10, 8_000);
let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
assert!(retry_status.contains("Retrying (3/10)"));
state.set_status(RunStatus::Working);
{
let mut bash = state.bash_components.lock().unwrap();
bash.insert(
"bash-1".into(),
Arc::new(BashExecutionComponent::new("one")),
);
bash.insert(
"bash-2".into(),
Arc::new(BashExecutionComponent::new("two")),
);
}
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-1");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-2");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 1);
state.set_status(RunStatus::Aborting);
assert_eq!(state.status_container.child_count(), 0);
assert!(!state.loader.is_running());
}
#[test]
fn fresh_launch_does_not_restore_old_history() {
let fresh = Args::default();
assert!(!launch_restores_history(&fresh));
let continued = Args {
continue_session: true,
..Args::default()
};
assert!(launch_restores_history(&continued));
let selected = Args {
session: Some("session-id".into()),
..Args::default()
};
assert!(launch_restores_history(&selected));
}
#[test]
fn test_short_model_name() {
assert_eq!(
short_model_name("anthropic:claude-sonnet-5"),
"claude-sonnet-5"
);
assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
}
#[test]
fn model_selector_items_are_deduplicated_and_provider_qualified() {
use rpi_ai::{Api, Model};
let mut gateway = Model::new(
"gpt-5.6-sol",
"GPT 5.6 Sol",
Api::OpenaiCompletions,
"routeryo-copy",
"https://gateway.example.com",
);
let duplicate = gateway.clone();
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
gateway.headers = Some(std::collections::BTreeMap::from([(
"authorization".into(),
"Bearer test".into(),
)]));
let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
assert_eq!(items.len(), 2);
assert_eq!(items[0].value, "gpt-5.6-sol");
assert_eq!(items[0].label, "GPT 5.6 Sol");
assert_eq!(
items[0].description.as_deref(),
Some("routeryo-copy/gpt-5.6-sol (current)")
);
assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
}
#[test]
fn model_selector_match_accepts_bare_and_qualified_ids() {
use rpi_ai::{Api, Model};
let gateway = Model::new(
"gpt-5.6-sol",
"GPT 5.6 Sol",
Api::OpenaiCompletions,
"routeryo-copy",
"https://gateway.example.com",
);
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let catalog = [gateway, anthropic];
assert_eq!(
find_model_selector_match(&catalog, "gpt-5.6-sol")
.unwrap()
.provider,
"routeryo-copy"
);
assert_eq!(
find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
.unwrap()
.id,
"gpt-5.6-sol"
);
assert_eq!(
find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
.unwrap()
.id,
"claude-sonnet-5"
);
assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
}
#[test]
fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
let failed = AssistantMessage {
role: AssistantRole,
content: Vec::new(),
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: StopReason::Error,
deferred: None,
error_message: Some("upstream returned 401".into()),
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
};
assert_eq!(
assistant_error_text(&failed).as_deref(),
Some("upstream returned 401")
);
let mut no_detail = failed;
no_detail.error_message = Some(" ".into());
assert_eq!(
assistant_error_text(&no_detail).as_deref(),
Some("Provider request failed.")
);
let mut aborted = no_detail;
aborted.stop_reason = StopReason::Aborted;
aborted.error_message = Some("abort error: Request aborted".into());
assert_eq!(
assistant_error_text(&aborted).as_deref(),
Some("abort error: Request aborted")
);
aborted.error_message = None;
assert_eq!(
assistant_error_text(&aborted).as_deref(),
Some("Request aborted.")
);
}
#[test]
fn test_cycle_next_model_wraps_around() {
use rpi_ai::{Api, Model};
let mk = |id: &str| {
Model::new(
id,
id,
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
)
};
let catalog = [mk("a"), mk("b"), mk("c")];
assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
let empty: Vec<Model> = vec![];
assert!(cycle_next_model(&empty, "a").is_none());
}
#[test]
fn test_autocomplete_slash_suggestions_render() {
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
themes_enabled: true,
hide_thinking: std::sync::Mutex::new(false),
tool_outputs_expanded: std::sync::Mutex::new(false),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
js_preparation_cancel: std::sync::Mutex::new(None),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
last_input_tokens: std::sync::Mutex::new(0),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: std::sync::Mutex::new(None),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let lines = state.autocomplete_container.render(80);
let joined: String = lines.join("\n");
assert!(
joined.contains("/help"),
"slash suggestions not rendered: {joined}"
);
editor.set_text("hello");
editor.set_cursor(0, 5);
refresh_autocomplete(&state, &editor);
assert!(state.autocomplete_container.render(80).is_empty());
}
#[test]
fn test_select_list_swap_restores_editor() {
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
themes_enabled: true,
hide_thinking: std::sync::Mutex::new(false),
tool_outputs_expanded: std::sync::Mutex::new(false),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
js_preparation_cancel: std::sync::Mutex::new(None),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
last_input_tokens: std::sync::Mutex::new(0),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: std::sync::Mutex::new(None),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
let editor_container = Arc::new(Container::new());
let editor = Arc::new(Editor::simple());
editor_container.add_child(editor.clone());
assert!(!state.selector_open());
let tui_terminal = Box::new(ProcessTerminal::new());
let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
let list = Arc::new(SelectList::new(
vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
5,
));
open_selector(
&state,
&editor_container,
&editor,
&tui,
list,
SelectorKind::Theme,
);
assert!(state.selector_open());
assert_eq!(editor_container.child_count(), 1);
close_selector(&state, &editor_container, &editor, &tui);
assert!(!state.selector_open());
assert_eq!(editor_container.child_count(), 1);
}
#[test]
fn test_message_history_browse_restores_draft() {
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
themes_enabled: true,
hide_thinking: std::sync::Mutex::new(false),
tool_outputs_expanded: std::sync::Mutex::new(false),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
js_preparation_cancel: std::sync::Mutex::new(None),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
last_input_tokens: std::sync::Mutex::new(0),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: std::sync::Mutex::new(None),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
let editor = Arc::new(Editor::simple());
push_history(&state, "first message");
push_history(&state, "second message");
push_history(&state, "second message");
push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
assert_eq!(state.history.lock().unwrap()[0], "second message");
editor.set_text("half-typed");
editor.set_cursor(0, 11);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "second message");
assert_eq!(*state.history_index.lock().unwrap(), 0);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
assert_eq!(*state.history_index.lock().unwrap(), 1);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "second message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "half-typed");
assert_eq!(*state.history_index.lock().unwrap(), -1);
}
#[test]
fn test_accept_top_suggestion_replaces_prefix() {
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
themes_enabled: true,
hide_thinking: std::sync::Mutex::new(false),
tool_outputs_expanded: std::sync::Mutex::new(false),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
js_preparation_cancel: std::sync::Mutex::new(None),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
last_input_tokens: std::sync::Mutex::new(0),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: std::sync::Mutex::new(None),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let accepted = accept_top_suggestion(&state, &editor);
assert!(accepted, "should accept the top suggestion");
let text = editor.get_text();
assert!(
text.starts_with("/help"),
"editor text should start with /help, got {text}"
);
}
#[test]
fn configured_key_parser_supports_native_notation() {
let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
assert_eq!(combo.code, KeyCode::Char('g'));
assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
assert_eq!(combo.code, KeyCode::BackTab);
}
#[test]
fn double_escape_trigger_has_half_second_window() {
let now = std::time::Instant::now();
assert!(!double_escape_trigger(None, now));
assert!(double_escape_trigger(
Some(now - std::time::Duration::from_millis(500)),
now
));
assert!(!double_escape_trigger(
Some(now - std::time::Duration::from_millis(501)),
now
));
}
}