use std::collections::HashMap;
use std::io::IsTerminal;
use std::sync::Arc;
use std::sync::mpsc::{self, channel};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use tokio::sync::broadcast;
use rpi_agent::{AgentEvent, AgentMessage};
use rpi_harness::session::types::{Entry, EntryQuery};
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 as SlashCommandEntry, 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;
type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
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::Sender<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,
resources: Arc<rpi_harness::types::AgentHarnessResources>,
reload_context: Arc<crate::session::ReloadContext>,
}
trait SlashCommand: Send + Sync {
fn name(&self) -> &'static 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 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 {
if c.visible() && !c.description().is_empty() {
out.push(SlashCommandEntry {
name: c.name().into(),
description: c.description().into(),
});
}
for alias in c.alias_visible() {
out.push(SlashCommandEntry {
name: (*alias).into(),
description: c.description().into(),
});
}
}
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);
}
}
}
struct UnsupportedCommand {
name: &'static str,
desc: &'static str,
}
impl UnsupportedCommand {
fn new(name: &'static str, desc: &'static str) -> Self {
Self { name, desc }
}
}
impl SlashCommand for UnsupportedCommand {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
self.desc
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_note_message(&ctx.chat, &format!("{} is not supported in v1.", self.name));
ctx.tui.request_render(false);
}
}
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) {
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 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) {
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) {
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) {
open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
}
}
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 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,
);
}
}
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(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(UnsupportedCommand::new(
"/clone",
"Duplicate the current session",
)));
r.register(Arc::new(UnsupportedCommand::new(
"/tree",
"Navigate session tree",
)));
r.register(Arc::new(UnsupportedCommand::new(
"/trust",
"Save project trust decision",
)));
r.register(Arc::new(UnsupportedCommand::new(
"/login",
"Configure provider authentication",
)));
r.register(Arc::new(UnsupportedCommand::new(
"/logout",
"Remove provider authentication",
)));
r.register(Arc::new(ReloadCommand));
r
}
enum TuiMessage {
UserInput(String),
Exit,
ClearChat,
Compact,
Copy,
SwitchSession(String),
ExportSession,
ForkSession,
SetSessionName(String),
ImportSession(String),
ShareSession,
ReloadExtensions,
}
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>,
) {
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();
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,
),
"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>,
) {
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 = chat.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);
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 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.");
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 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: None,
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>) {
let tree = harness.session().view("main");
let entries = match tree.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: None,
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 = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.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()).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()).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>,
) {
let tree = harness.session().view("main");
let entries = match tree.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: None,
limit: None,
cursor: None,
}).await {
Ok(e) => e,
Err(_) => return,
};
let mut rendered_any = false;
for e in entries {
let Entry::Message(me) = e else { continue };
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)));
rendered_any = true;
}
_ => {}
}
}
if rendered_any {
chat.add_child(Arc::new(Spacer::new(1)));
}
}
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,
ScopedModels,
Settings,
}
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>,
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>>,
}
const HISTORY_LIMIT: usize = 100;
const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
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 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));
}
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);
}
}
}
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>,
reload_context: &crate::session::ReloadContext,
) -> 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 initial_transformer = build_markdown_transformer(
reload_context.extension_session.lock().unwrap().snapshot_arc(),
);
render_session_history(&harness, &chat_container, initial_transformer.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,
..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 resources_snapshot = harness.get_resources().await.unwrap_or_default();
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 registry = Arc::new(build_builtin_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()),
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),
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),
});
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 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(),
resources: resources_arc.clone(),
reload_context: Arc::new(reload_context.clone()),
};
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;
}
add_user_message(&ctx_for_cb.chat, text);
ctx_for_cb.tui.request_render(false);
push_history(&ctx_for_cb.state, text);
let _ = ctx_for_cb.tx.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 (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(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 ctx_for_key = ctx.clone();
let registry_for_key = registry.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 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 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(false);
continue;
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
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') {
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(false);
}
continue;
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
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(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;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
let (row, col) = editor_for_key.cursor_position();
if row == 0 && col == 0 {
navigate_history(&state_for_key, &editor_for_key, -1);
tui_for_key.request_render(false);
continue;
}
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
let text = editor_for_key.get_text();
let (row, col) = editor_for_key.cursor_position();
let last_row = text.lines().count().saturating_sub(1);
let last_len = text.lines().last().map(str::len).unwrap_or(0);
if row == last_row && col >= last_len {
navigate_history(&state_for_key, &editor_for_key, 1);
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)) => {
editor.clear();
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;
}
Ok(TuiMessage::SwitchSession(id)) => {
switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Ok(TuiMessage::ImportSession(path)) => {
import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Ok(TuiMessage::ShareSession) => {
share_session(&harness, &chat_container).await;
tui.request_render(false);
}
Ok(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);
}
Ok(TuiMessage::ExportSession) => {
export_session(&harness, &chat_container).await;
tui.request_render(false);
}
Ok(TuiMessage::ForkSession) => {
fork_session(&harness, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Ok(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);
}
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();
}
reload_context.mailbox.clear();
reload_bridge_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.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
}
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::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_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;
}
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)
),
);
}
*state.last_input_tokens.lock().unwrap() = usage.input;
}
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,
tx: &mpsc::Sender<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 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);
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>) {
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 — Kill to end / start of line (Ctrl+Y yanks)", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+- / Ctrl+R — Undo / redo", 1, 0)));
container.add_child(Arc::new(Text::new(" Ctrl+Y / Alt+Y — Yank / yank-pop", 1, 0)));
container.add_child(Arc::new(Text::new(" Alt+Backspace — Kill previous word", 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)));
}
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 .pi/skills/ 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 .pi/prompts/ 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 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 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("/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", "/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()),
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),
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),
});
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),
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),
});
{
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()),
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),
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),
});
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()),
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),
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),
});
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()),
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),
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),
});
{
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}"
);
}
}