use std::collections::HashMap;
use std::io::IsTerminal;
use std::sync::Arc;
use std::sync::mpsc::channel;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use tokio::sync::broadcast;
use rpi_agent::{AgentEvent, AgentMessage};
use rpi_ai::types::{AssistantMessage, Content};
use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
use rpi_tui::{
AutocompleteManager, CombinedAutocompleteProvider, Container, Editor, EditorOptions,
EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, Loader, ProcessTerminal,
ScrollView, ScrollViewOptions, SlashCommand, SlashCommandAutocompleteProvider, Spacer,
StackChild, StackEntry, Text, TuiAltScreen, TUI, VStack, AssistantBlock,
AssistantMessageComponent, AssistantMessageOptions, AutocompleteSuggestions,
FooterComponent, SelectList, SelectItem, ThemeManager, ThemePreset,
ToolExecutionComponent, render_diff,
BashExecutionComponent, BashTruncation, UserMessageComponent,
};
#[allow(unused_imports)]
use rpi_tui::BashStatus;
use crate::args::Args;
enum SlashCommandResult {
Exit,
ClearChat,
Unknown,
SendMessage(String),
Help,
Version,
Hotkeys,
SelectModel,
SelectThinking,
SelectTools,
SelectImages,
Armin,
Earendil,
SelectSession,
SelectTheme,
Compact,
Copy,
Unsupported(String),
}
fn handle_slash_command(text: &str) -> SlashCommandResult {
let parts: Vec<&str> = text.split_whitespace().collect();
if parts.is_empty() {
return SlashCommandResult::SendMessage(text.to_string());
}
let command = parts[0];
match command {
"/help" | "/?" => SlashCommandResult::Help,
"/clear" | "/new" => SlashCommandResult::ClearChat,
"/exit" | "/quit" | "/q" => SlashCommandResult::Exit,
"/version" | "/v" => SlashCommandResult::Version,
"/model" | "/m" => SlashCommandResult::SelectModel,
"/thinking" | "/think" => SlashCommandResult::SelectThinking,
"/tools" => SlashCommandResult::SelectTools,
"/images" => SlashCommandResult::SelectImages,
"/armin" => SlashCommandResult::Armin,
"/earendil" => SlashCommandResult::Earendil,
"/hotkeys" => SlashCommandResult::Hotkeys,
"/session" | "/resume" => SlashCommandResult::SelectSession,
"/theme" => SlashCommandResult::SelectTheme,
"/compact" => SlashCommandResult::Compact,
"/copy" => SlashCommandResult::Copy,
"/name" => SlashCommandResult::Unsupported("/name".to_string()),
"/settings"
| "/scoped-models"
| "/export"
| "/import"
| "/share"
| "/fork"
| "/clone"
| "/tree"
| "/trust"
| "/login"
| "/logout"
| "/reload" => SlashCommandResult::Unsupported(command.to_string()),
_ => SlashCommandResult::Unknown,
}
}
fn v1_slash_commands() -> Vec<SlashCommand> {
vec![
SlashCommand { name: "/help".into(), description: "Show available commands".into() },
SlashCommand { name: "/clear".into(), description: "Clear the conversation".into() },
SlashCommand { name: "/new".into(), description: "Clear the conversation".into() },
SlashCommand { name: "/exit".into(), description: "Exit the application".into() },
SlashCommand { name: "/quit".into(), description: "Exit the application".into() },
SlashCommand { name: "/version".into(), description: "Show version information".into() },
SlashCommand { name: "/model".into(), description: "Choose a model (selector)".into() },
SlashCommand { name: "/thinking".into(), description: "Set thinking level (selector)".into() },
SlashCommand { name: "/tools".into(), description: "Toggle tools on/off".into() },
SlashCommand { name: "/images".into(), description: "Toggle inline images".into() },
SlashCommand { name: "/session".into(), description: "List saved sessions".into() },
SlashCommand { name: "/theme".into(), description: "Choose a theme (selector)".into() },
SlashCommand { name: "/compact".into(), description: "Compact the conversation".into() },
SlashCommand { name: "/copy".into(), description: "Copy last reply to clipboard".into() },
SlashCommand { name: "/hotkeys".into(), description: "Show keyboard shortcuts".into() },
SlashCommand { name: "/armin".into(), description: "??? (easter egg)".into() },
SlashCommand { name: "/earendil".into(), description: "Announcement".into() },
]
}
enum TuiMessage {
UserInput(String),
Exit,
ClearChat,
Compact,
Copy,
}
fn assistant_text(msg: &AssistantMessage) -> String {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect()
}
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())),
_ => None,
})
.collect()
}
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,
}
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>>>,
last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
status: std::sync::Mutex<RunStatus>,
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)>>,
autocomplete: AutocompleteManager,
autocomplete_container: Arc<Container>,
theme_manager: Arc<ThemeManager>,
tui: Option<Arc<TuiAltScreen>>,
current_model_id: std::sync::Mutex<String>,
show_images: std::sync::Mutex<bool>,
}
impl TuiState {
fn set_status(&self, status: RunStatus) {
*self.status.lock().unwrap() = status;
match status {
RunStatus::Working => {
self.footer.set_status("Working…");
if let Some(tui) = &self.tui {
tui.set_title("rpi — working");
}
self.status_container.clear();
self.loader.start();
self.status_container.add_child(self.loader.clone());
}
RunStatus::Aborting => {
self.footer.set_status("Aborting…");
}
RunStatus::Idle => {
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title("rpi");
}
self.loader.stop();
self.status_container.clear();
}
}
}
fn selector_open(&self) -> bool {
self.active_selector.lock().unwrap().is_some()
}
fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
*self.last_tool_comp.lock().unwrap() = Some(comp);
}
fn toggle_expand_last_tool(&self) -> bool {
if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
let cur = comp.is_expanded();
comp.set_expanded(!cur);
true
} else {
false
}
}
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));
}
}
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],
theme: Option<&str>,
) -> i32 {
let lane: Arc<dyn AgentLane> = harness.lane("main");
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 cwd = std::env::current_dir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| std::path::PathBuf::from("."));
let (tx, rx) = channel::<TuiMessage>();
let terminal = Box::new(ProcessTerminal::new());
let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
let chat_container = Arc::new(Container::new());
add_welcome_message(&chat_container);
crate::extras::maybe_first_time_setup(&chat_container);
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,
..Default::default()
},
));
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
..Default::default()
},
EditorStyle::default(),
Arc::new(rpi_tui::Keybindings::new()),
));
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 autocomplete = AutocompleteManager::new();
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
v1_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()),
last_tool_comp: std::sync::Mutex::new(None),
status: std::sync::Mutex::new(RunStatus::Idle),
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),
autocomplete,
autocomplete_container: autocomplete_container.clone(),
theme_manager: Arc::new(ThemeManager::new()),
tui: Some(tui.clone()),
current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
show_images: std::sync::Mutex::new(true),
});
if let Some(theme_name) = theme {
let preset = match theme_name {
"light" => Some(ThemePreset::Light),
"monochrome" => Some(ThemePreset::Monochrome),
"dark" => Some(ThemePreset::Dark),
_ => None,
};
if let Some(preset) = preset {
state.theme_manager.apply_preset(preset);
}
}
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 chat_for_cb = chat_container.clone();
let tui_for_cb = tui.clone();
let tx_for_cb = tx.clone();
let state_for_cb = state.clone();
let editor_for_cb = editor.clone();
let lane_for_cb = lane.clone();
let editor_container_for_cb = editor_container.clone();
let model_catalog_for_cb = model_catalog_arc.clone();
let lane_model_id_for_cb = lane_model_id.clone();
let cwd_for_cb = cwd.clone();
editor.on_submit(Arc::new(move |text: &str| {
let text = text.trim();
if text.is_empty() {
return;
}
if text.starts_with('/') {
match handle_slash_command(text) {
SlashCommandResult::Exit => {
let _ = tx_for_cb.send(TuiMessage::Exit);
}
SlashCommandResult::ClearChat => {
let _ = tx_for_cb.send(TuiMessage::ClearChat);
}
SlashCommandResult::Help => {
add_help_message(&chat_for_cb);
tui_for_cb.request_render(false);
}
SlashCommandResult::Version => {
add_version_message(&chat_for_cb);
tui_for_cb.request_render(false);
}
SlashCommandResult::Hotkeys => {
add_hotkeys_message(&chat_for_cb);
tui_for_cb.request_render(false);
}
SlashCommandResult::SelectModel => {
open_model_selector(
&state_for_cb,
&editor_container_for_cb,
&editor_for_cb,
&tui_for_cb,
&model_catalog_for_cb,
&lane_for_cb,
&lane_model_id_for_cb,
&chat_for_cb,
);
}
SlashCommandResult::SelectThinking => {
open_thinking_selector(
&state_for_cb,
&editor_container_for_cb,
&editor_for_cb,
&tui_for_cb,
&lane_for_cb,
&model_catalog_for_cb,
&lane_model_id_for_cb,
&chat_for_cb,
);
}
SlashCommandResult::SelectTools => {
open_tools_selector(
&state_for_cb,
&editor_container_for_cb,
&editor_for_cb,
&tui_for_cb,
&lane_for_cb,
&chat_for_cb,
);
}
SlashCommandResult::SelectImages => {
open_images_selector(
&state_for_cb,
&editor_container_for_cb,
&editor_for_cb,
&tui_for_cb,
&chat_for_cb,
);
}
SlashCommandResult::Armin => {
crate::extras::add_armin(&chat_for_cb);
tui_for_cb.request_render(false);
}
SlashCommandResult::Earendil => {
crate::extras::add_earendil(&chat_for_cb);
tui_for_cb.request_render(false);
}
SlashCommandResult::SelectSession => {
open_session_selector(
&state_for_cb,
&editor_container_for_cb,
&editor_for_cb,
&tui_for_cb,
&cwd_for_cb,
);
}
SlashCommandResult::SelectTheme => {
open_theme_selector(
&state_for_cb,
&editor_container_for_cb,
&editor_for_cb,
&tui_for_cb,
);
}
SlashCommandResult::Compact => {
let _ = tx_for_cb.send(TuiMessage::Compact);
}
SlashCommandResult::Copy => {
let _ = tx_for_cb.send(TuiMessage::Copy);
}
SlashCommandResult::Unsupported(cmd) => {
add_note_message(
&chat_for_cb,
&format!("{cmd} is not supported in v1."),
);
tui_for_cb.request_render(false);
}
SlashCommandResult::Unknown => {
add_error_message(
&chat_for_cb,
&format!("Unknown command: {text}. Type /help for available commands."),
);
tui_for_cb.request_render(false);
}
SlashCommandResult::SendMessage(msg) => {
add_user_message(&chat_for_cb, &msg);
tui_for_cb.request_render(false);
let _ = tx_for_cb.send(TuiMessage::UserInput(msg));
}
}
return;
}
add_user_message(&chat_for_cb, text);
tui_for_cb.request_render(false);
let _ = tx_for_cb.send(TuiMessage::UserInput(text.to_string()));
}));
tui.start_readerless();
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 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(120));
interval.tick().await; loop {
interval.tick().await;
let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
if working {
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 editor_container_for_key = editor_container.clone();
let scroll_for_key = scroll_view.clone();
let lane_for_key = lane.clone();
let state_for_key = state.clone();
let catalog_for_key = model_catalog_arc.clone();
let lane_model_id_for_key = lane_model_id.clone();
let chat_for_key = chat_container.clone();
tokio::task::spawn_blocking(move || {
loop {
if !*running_key.lock().unwrap() {
break;
}
let Ok(ev) = crossterm::event::read() else {
continue;
};
if let Event::Resize(_cols, _rows) = ev {
tui_for_key.refresh_size();
continue;
}
let Event::Key(key) = ev else { continue; };
if key.kind != KeyEventKind::Press {
continue;
}
if state_for_key.selector_open() {
if key.code == KeyCode::Esc {
close_selector(
&state_for_key,
&editor_container_for_key,
&editor_for_key,
&tui_for_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(false);
continue;
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
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;
});
} else {
let _ = tx_for_key.send(TuiMessage::Exit);
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
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 key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
state_for_key.toggle_expand_last_tool();
tui_for_key.request_render(false);
continue;
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
if let Some(next) = cycle_next_model(&catalog_for_key, &state_for_key.current_model_id()) {
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(false);
}
continue;
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
open_model_selector(
&state_for_key,
&editor_container_for_key,
&editor_for_key,
&tui_for_key,
&catalog_for_key,
&lane_for_key,
&lane_model_id_for_key,
&chat_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(false);
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
scroll_for_key.scroll_by(-10);
tui_for_key.request_render(false);
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
scroll_for_key.scroll_by(10);
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(false);
}
});
let mut prompts: Vec<String> = Vec::new();
if let Some(init) = initial {
prompts.push(init);
}
for m in extra_messages {
prompts.push(m.clone());
}
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()).await;
}
loop {
if !*running.lock().unwrap() {
break;
}
match rx.try_recv() {
Ok(TuiMessage::UserInput(prompt)) => {
run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
}
Ok(TuiMessage::ClearChat) => {
chat_container.clear();
add_welcome_message(&chat_container);
tui.request_render(false);
}
Ok(TuiMessage::Compact) => {
run_compact(&lane, &tui, &state).await;
}
Ok(TuiMessage::Copy) => {
copy_last_assistant(&state, &chat_container);
tui.request_render(false);
}
Ok(TuiMessage::Exit) => {
*running.lock().unwrap() = false;
break;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
}
tick_handle.abort();
if let Some(handle) = drain_handle {
handle.abort();
}
tui.stop(Default::default());
println!("\nGoodbye!");
let _ = args;
0
}
async fn run_prompt_streaming(
lane: &Arc<dyn AgentLane>,
prompt: &str,
tui: &Arc<TuiAltScreen>,
state: &Arc<TuiState>,
streaming: bool,
) {
state.set_status(RunStatus::Working);
tui.request_render(false);
let outcome = lane.prompt_text(prompt, Vec::new()).await;
{
let mut cur = state.current_assistant.lock().unwrap();
if let Some(comp) = cur.take() {
comp.set_streaming(false);
}
}
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.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
}
fn add_assistant_message_blocking(container: &Arc<Container>, text: &str) {
if text.is_empty() {
return;
}
let msg = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
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::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(),
));
comp.set_streaming(true);
comp.update_blocks(&assistant_blocks(&a));
chat.add_child(comp.clone());
chat.add_child(Arc::new(Spacer::new(0)));
*state.current_assistant.lock().unwrap() = Some(comp);
tui.request_render(false);
}
_ => {}
},
AgentEvent::MessageUpdate { message, assistant_message_event } => {
if let AgentMessage::Assistant(a) = &message {
let text = assistant_text(a);
for c in &a.content {
if let Content::ToolCall(tc) = c {
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_running();
chat.add_child(comp.clone());
tools.insert(tc.id.clone(), comp);
}
}
}
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;
}
}
tui.request_render(false);
}
AgentEvent::ToolExecutionStart { tool_call_id, tool_name, args } => {
if tool_name == "bash" {
let command = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let comp = Arc::new(BashExecutionComponent::new(command));
chat.add_child(comp.clone());
state
.bash_components
.lock()
.unwrap()
.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_running();
chat.add_child(comp.clone());
tools.insert(tool_call_id.clone(), comp.clone());
comp
}
};
state.remember_tool(comp);
}
tui.request_render(false);
}
AgentEvent::ToolExecutionUpdate { tool_call_id, tool_name, partial_result, .. } => {
if tool_name == "bash" {
let chunk = summarize_tool_result(&partial_result);
if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
bash.append_output(&chunk);
} else {
let comp = Arc::new(BashExecutionComponent::new(""));
comp.append_output(&chunk);
chat.add_child(comp.clone());
state
.bash_components
.lock()
.unwrap()
.insert(tool_call_id.clone(), comp);
}
} else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
let summary = summarize_tool_result(&partial_result);
comp.set_result(&summary, false);
apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
state.remember_tool(comp.clone());
} else {
let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
comp.set_running();
comp.set_result(&summarize_tool_result(&partial_result), 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.remember_tool(comp);
}
tui.request_render(false);
}
AgentEvent::ToolExecutionEnd { tool_call_id, tool_name, result, is_error } => {
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();
let comp = Arc::new(BashExecutionComponent::new(command));
comp.append_output(&summarize_tool_result(&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(&summarize_tool_result(&result), is_error);
apply_edit_diff(&comp, &tool_name, &result.details, &tui);
} else {
let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
comp.set_result(&summarize_tool_result(&result), is_error);
apply_edit_diff(&comp, &tool_name, &result.details, &tui);
chat.add_child(comp.clone());
state.remember_tool(comp);
}
}
tui.request_render(false);
}
}
}
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 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 open_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
list: Arc<SelectList>,
kind: SelectorKind,
) {
editor.set_focused(false);
editor_container.clear();
editor_container.add_child(list.clone());
*state.active_selector.lock().unwrap() = Some((list, kind));
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;
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 mut items: Vec<SelectItem> = Vec::new();
for m in catalog {
let label = if m.name.is_empty() { short_model_name(&m.id) } else { m.name.clone() };
let marker = if m.id.eq_ignore_ascii_case(lane_model_id) { " (current)" } else { "" };
items.push(
SelectItem::new(&m.id, &label)
.with_description(&format!("{id}{marker}", id = m.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,
) {
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 chat_sel = state.chat_container.clone();
list.on_select(Arc::new(move |item| {
add_note_message(
&chat_sel,
&format!("Session {} — restore is not implemented in v1.", 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::Session);
}
fn open_theme_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
) {
let 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"),
];
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();
list.on_select(Arc::new(move |item| {
let preset = match item.value.as_str() {
"light" => ThemePreset::Light,
"monochrome" => ThemePreset::Monochrome,
_ => ThemePreset::Dark,
};
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 active = match tokio::runtime::Handle::try_current() {
Ok(h) => h.block_on(async { lane.get_active_tools().await }).unwrap_or_default(),
Err(_) => Vec::new(),
};
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(5).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);
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
},
);
let _ = end;
editor.set_text(&replaced);
editor.set_cursor(0, new_cursor);
state.autocomplete_container.clear();
true
}
fn add_welcome_message(container: &Arc<Container>) {
container.add_child(Arc::new(Text::new("rpi interactive TUI", 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
"Type your message and press Enter to send.",
1, 0,
)));
container.add_child(Arc::new(Text::new(
"Ctrl+C: Abort/Exit | Esc: Abort | Enter: Send | Shift+Enter: New line | Tab: Complete | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help",
1, 0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_help_message(container: &Arc<Container>) {
container.add_child(Arc::new(Text::new("📚 Available Commands:", 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(" /help, /? — Show this help message", 1, 0)));
container.add_child(Arc::new(Text::new(" /clear, /new — Clear the conversation", 1, 0)));
container.add_child(Arc::new(Text::new(" /exit, /quit, /q — Exit the application", 1, 0)));
container.add_child(Arc::new(Text::new(" /version, /v — Show version information", 1, 0)));
container.add_child(Arc::new(Text::new(" /model, /m — Choose a model (live switch)", 1, 0)));
container.add_child(Arc::new(Text::new(" /thinking, /think — Set reasoning depth (selector)", 1, 0)));
container.add_child(Arc::new(Text::new(" /tools — Toggle built-in tools on/off", 1, 0)));
container.add_child(Arc::new(Text::new(" /images — Toggle inline image rendering", 1, 0)));
container.add_child(Arc::new(Text::new(" /session — List saved sessions", 1, 0)));
container.add_child(Arc::new(Text::new(" /theme — Choose a theme (selector)", 1, 0)));
container.add_child(Arc::new(Text::new(" /compact — Compact the conversation", 1, 0)));
container.add_child(Arc::new(Text::new(" /copy — Copy last reply to clipboard", 1, 0)));
container.add_child(Arc::new(Text::new(" /hotkeys — Show keyboard shortcuts", 1, 0)));
container.add_child(Arc::new(Text::new(" /armin — 🐾 Easter egg", 1, 0)));
container.add_child(Arc::new(Text::new(" /earendil — Earendil announcement", 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_version_message(container: &Arc<Container>) {
container.add_child(Arc::new(Text::new("📦 Version Information:", 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(" rpi-cli v0.1.2", 1, 0)));
container.add_child(Arc::new(Text::new(
" Rust implementation of pi coding agent TUI",
1, 0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_hotkeys_message(container: &Arc<Container>) {
container.add_child(Arc::new(Text::new("⌨️ Keyboard Shortcuts:", 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(" Enter — Send message", 1, 0)));
container.add_child(Arc::new(Text::new(" Shift+Enter — New line", 1, 0)));
container.add_child(Arc::new(Text::new(" Tab — Accept autocomplete suggestion", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+A / Ctrl+E — Line start / end", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+K / Ctrl+U — Delete to end / start of line", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+C — Abort a run, or exit when idle", 1, 0)));
container.add_child(Arc::new(Text::new(" Esc — Abort a running prompt", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+L — Open model selector", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+M — Cycle to the next model (live)", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+T — Expand/collapse last tool result", 1, 0)));
container.add_child(Arc::new(Text::new(" PageUp/Down — Scroll transcript", 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(0)));
}
fn add_error_message(container: &Arc<Container>, text: &str) {
container.add_child(Arc::new(Text::new(format!("❌ {text}"), 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_note_message(container: &Arc<Container>, text: &str) {
container.add_child(Arc::new(Text::new(format!("ℹ️ {text}"), 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 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 interactive"), "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(&chat);
let lines = chat.render(80);
let all: String = lines.join("\n");
assert!(all.contains("rpi interactive"), "Welcome message not in chat container: {:?}", lines);
}
#[test]
fn test_slash_command_dispatch() {
assert!(matches!(handle_slash_command("/help"), SlashCommandResult::Help));
assert!(matches!(handle_slash_command("/clear"), SlashCommandResult::ClearChat));
assert!(matches!(handle_slash_command("/q"), SlashCommandResult::Exit));
assert!(matches!(handle_slash_command("/hotkeys"), SlashCommandResult::Hotkeys));
assert!(matches!(
handle_slash_command("/model"),
SlashCommandResult::SelectModel
));
assert!(matches!(
handle_slash_command("/theme"),
SlashCommandResult::SelectTheme
));
assert!(matches!(handle_slash_command("/session"), SlashCommandResult::SelectSession));
assert!(matches!(handle_slash_command("/compact"), SlashCommandResult::Compact));
assert!(matches!(handle_slash_command("/copy"), SlashCommandResult::Copy));
assert!(matches!(
handle_slash_command("/thinking"),
SlashCommandResult::SelectThinking
));
assert!(matches!(
handle_slash_command("/think"),
SlashCommandResult::SelectThinking
));
assert!(matches!(
handle_slash_command("/tools"),
SlashCommandResult::SelectTools
));
assert!(matches!(
handle_slash_command("/images"),
SlashCommandResult::SelectImages
));
assert!(matches!(handle_slash_command("/armin"), SlashCommandResult::Armin));
assert!(matches!(handle_slash_command("/earendil"), SlashCommandResult::Earendil));
assert!(matches!(
handle_slash_command("/settings"),
SlashCommandResult::Unsupported(_)
));
assert!(matches!(handle_slash_command("/nope"), SlashCommandResult::Unknown));
assert!(matches!(
handle_slash_command(""),
SlashCommandResult::SendMessage(_)
));
}
#[test]
fn test_v1_slash_commands_cover_dispatcher() {
let cmds = v1_slash_commands();
let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
for recognized in ["/help", "/clear", "/new", "/exit", "/quit", "/version",
"/model", "/session", "/theme", "/compact", "/copy", "/hotkeys"]
{
assert!(names.contains(&recognized), "{recognized} missing from autocomplete list");
}
}
#[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()),
last_tool_comp: std::sync::Mutex::new(None),
status: std::sync::Mutex::new(RunStatus::Idle),
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),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::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),
});
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());
}
#[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 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()),
last_tool_comp: std::sync::Mutex::new(None),
status: std::sync::Mutex::new(RunStatus::Idle),
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),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::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),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
v1_slash_commands(),
)));
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()),
last_tool_comp: std::sync::Mutex::new(None),
status: std::sync::Mutex::new(RunStatus::Idle),
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),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::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),
});
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_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()),
last_tool_comp: std::sync::Mutex::new(None),
status: std::sync::Mutex::new(RunStatus::Idle),
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),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::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),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
v1_slash_commands(),
)));
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}"
);
}
}