use std::collections::HashMap;
use std::io::IsTerminal;
use std::sync::Arc;
use base64::Engine;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use tokio::sync::{broadcast, mpsc};
use rpi_agent::{AgentEvent, AgentMessage};
use rpi_ai::types::{AssistantMessage, Content, UserMessage};
use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
#[cfg(test)]
use rpi_tui::strip_ansi;
use rpi_tui::{
apply_theme_preset, render_diff, AssistantBlock, AssistantMessageComponent,
AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
BashTruncation, CombinedAutocompleteProvider, Container, DynamicBorder, Editor, EditorOptions,
EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, FooterComponent, Loader,
ProcessTerminal, ScrollView, ScrollViewOptions, SelectItem, SelectList,
SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer, StackChild,
StackEntry, Text, ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen,
UserMessageComponent, VStack, TUI,
};
use rpi_tui::{bold as tui_bold, theme as current_theme};
#[allow(unused_imports)]
use rpi_tui::BashStatus;
use crate::args::Args;
type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
fn build_markdown_transformer(
snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
) -> Option<MarkdownTransformer> {
let snapshot = snapshot?;
let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
if renderers.is_empty() {
return None;
}
Some(Arc::new(move |raw: &str| -> String {
transform_markdown_chain(&snapshot, &renderers, raw)
}))
}
fn transform_markdown_chain(
snapshot: &rpi_extensions::RegistrySnapshot,
renderers: &[rpi_extensions::RegisteredRenderer],
raw: &str,
) -> String {
if !snapshot.is_active() {
return raw.to_string();
}
let mut current = raw.to_string();
for renderer in renderers {
let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
Ok(s) => s,
Err(_) => return current, };
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut out = rpi_plugin_sdk::StbString::empty();
let rc = (renderer.render_fn)(
rpi_plugin_sdk::StbStringRef::from_str(&input),
&mut out as *mut rpi_plugin_sdk::StbString,
renderer.user_data,
);
let text = if rc == 0 {
let s = out.to_string_lossy();
Some(s)
} else {
None
};
out.free_with(Some(renderer.plugin_free_string));
text
}));
let out_text = match outcome {
Ok(Some(s)) => s,
Ok(None) => return current, Err(_) => return current, };
let next = serde_json::from_str::<serde_json::Value>(&out_text)
.ok()
.and_then(|v| {
v.get("markdown")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
.unwrap_or(current);
current = next;
}
current
}
#[derive(Clone)]
struct CommandContext {
chat: Arc<Container>,
tui: Arc<TuiAltScreen>,
tx: mpsc::UnboundedSender<TuiMessage>,
state: Arc<TuiState>,
editor: Arc<Editor>,
editor_container: Arc<Container>,
lane: Arc<dyn AgentLane>,
model_catalog: Arc<Vec<rpi_ai::Model>>,
lane_model_id: String,
cwd: std::path::PathBuf,
resources: Arc<rpi_harness::types::AgentHarnessResources>,
reload_context: Arc<crate::session::ReloadContext>,
}
trait SlashCommand: Send + Sync {
fn name(&self) -> &str;
fn aliases(&self) -> &'static [&'static str] {
&[]
}
fn visible(&self) -> bool {
true
}
fn alias_visible(&self) -> &'static [&'static str] {
&[]
}
fn description(&self) -> &'static str {
""
}
fn description_owned(&self) -> String {
self.description().to_string()
}
fn execute(&self, ctx: &CommandContext, args: &str);
}
struct CommandRegistry {
commands: Vec<Arc<dyn SlashCommand>>,
}
impl CommandRegistry {
fn new() -> Self {
Self {
commands: Vec::new(),
}
}
fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
self.commands.push(cmd);
}
fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
self.commands
.iter()
.find(|c| c.name() == token || c.aliases().contains(&token))
}
fn visible_entries(&self) -> Vec<SlashCommandEntry> {
let mut out: Vec<SlashCommandEntry> = Vec::new();
for c in &self.commands {
let description = c.description_owned();
if c.visible() && !description.is_empty() {
out.push(SlashCommandEntry {
name: c.name().into(),
description: description.clone(),
});
}
for alias in c.alias_visible() {
out.push(SlashCommandEntry {
name: (*alias).into(),
description: description.clone(),
});
}
}
out
}
}
fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
let mut parts = text.split_whitespace();
let token = parts.next().unwrap_or("");
let args = parts.collect::<Vec<_>>().join(" ");
match registry.find(token) {
Some(cmd) => cmd.execute(ctx, &args),
None => {
add_error_message(
&ctx.chat,
&format!("Unknown command: {text}. Type /help for available commands."),
);
ctx.tui.request_render(false);
}
}
}
struct ExtensionCommand {
name: String,
description: String,
session: crate::session::ExtensionSessionCell,
}
impl SlashCommand for ExtensionCommand {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &'static str {
"extension command"
}
fn description_owned(&self) -> String {
self.description.clone()
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let result = invoke_extension_command(&self.session, &self.name, args);
handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
}
}
fn invoke_extension_command(
session: &crate::session::ExtensionSessionCell,
name: &str,
args: &str,
) -> Option<serde_json::Value> {
let command = session
.lock()
.ok()
.and_then(|s| s.snapshot_arc())
.and_then(|snap| {
snap.commands()
.iter()
.find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
.cloned()
})?;
let input = serde_json::json!({ "args": args, "command": name });
let input = serde_json::to_string(&input).ok()?;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut out = rpi_plugin_sdk::StbString::empty();
let rc = (command.handler)(
rpi_plugin_sdk::StbStringRef::from_str(&input),
&mut out as *mut rpi_plugin_sdk::StbString,
command.user_data,
);
let text = if rc == 0 {
Some(out.to_string_lossy())
} else {
None
};
rpi_extensions::host_free_string(out);
text
}))
.ok()
.flatten()?;
serde_json::from_str(&outcome).ok()
}
fn handle_extension_ui_result(
result: Option<serde_json::Value>,
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
) {
let Some(value) = result else {
add_error_message(&ctx.chat, "Extension command failed.");
ctx.tui.request_render(false);
return;
};
match value.get("kind").and_then(|v| v.as_str()) {
Some("message") | None => {
let fallback = value.to_string();
let text = value
.get("text")
.and_then(|v| v.as_str())
.unwrap_or(&fallback)
.to_string();
if !text.is_empty() {
add_note_message(&ctx.chat, &text);
}
ctx.tui.request_render(false);
}
Some("selector") => open_extension_selector(ctx, session, command_name, value),
Some("editor") => open_extension_editor(ctx, session, command_name, value),
Some(other) => {
add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
ctx.tui.request_render(false);
}
}
}
fn open_extension_selector(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let items = value
.get("items")
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
let value = item.get("value")?.as_str()?;
let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
let mut out = SelectItem::new(value, label);
if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
out = out.with_description(desc);
}
Some(out)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if items.is_empty() {
add_error_message(&ctx.chat, "Extension selector has no items.");
ctx.tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let editor = ctx.editor.clone();
let tui = ctx.tui.clone();
let session_select = session.clone();
let command_select = command_name.clone();
let ctx_select = ctx.clone();
list.on_select(Arc::new(move |item| {
let args = serde_json::json!({ "action": "select", "value": item.value });
let result = invoke_extension_command(
&session_select,
&command_select,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_selector(&state, &ec, &editor, &tui);
handle_extension_ui_result(
result,
&ctx_select,
session_select.clone(),
command_select.clone(),
);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
list,
SelectorKind::Extension,
);
}
fn open_extension_editor(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let initial = value
.get("initialText")
.or_else(|| value.get("text"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
autocomplete_max_visible: 0,
placeholder: value
.get("placeholder")
.and_then(|v| v.as_str())
.map(str::to_string),
initial_text: Some(initial),
},
EditorStyle {
prompt: "> ".to_string(),
placeholder: String::new(),
},
Arc::new(rpi_tui::Keybindings::new()),
));
editor.set_focused(true);
*ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
ctx.editor_container.clear();
ctx.editor_container.add_child(editor.clone());
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let original = ctx.editor.clone();
let session_submit = session.clone();
let command_submit = command_name.clone();
let ctx_submit = ctx.clone();
editor.on_submit(Arc::new(move |text| {
let args = serde_json::json!({ "action": "edit", "text": text });
let result = invoke_extension_command(
&session_submit,
&command_submit,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state, &ec, &original);
handle_extension_ui_result(
result,
&ctx_submit,
session_submit.clone(),
command_submit.clone(),
);
}));
ctx.tui.set_focus(Some(editor));
ctx.tui.request_render(false);
}
fn close_extension_editor(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
) {
editor_container.clear();
editor_container.add_child(editor.clone());
*state.active_extension_editor.lock().unwrap() = None;
editor.set_focused(true);
}
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) {
let term = args.trim();
if !term.is_empty() {
let Some(model) = ctx
.model_catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(term))
.cloned()
else {
add_error_message(
&ctx.chat,
&format!("No model matches \"{term}\". Try /model for the list."),
);
ctx.tui.request_render(false);
return;
};
let model_id = model.id.clone();
ctx.state.set_current_model(&model);
let lane = ctx.lane.clone();
tokio::spawn(async move {
let _ = lane.set_model(model).await;
});
add_note_message(
&ctx.chat,
&format!(
"Model set to {} — applies to the next message.",
short_model_name(&model_id)
),
);
ctx.tui.request_render(false);
return;
}
open_model_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.model_catalog,
&ctx.lane,
&ctx.lane_model_id,
&ctx.chat,
);
}
}
struct ThinkingCommand;
impl SlashCommand for ThinkingCommand {
fn name(&self) -> &'static str {
"/thinking"
}
fn aliases(&self) -> &'static [&'static str] {
&["/think"]
}
fn description(&self) -> &'static str {
"Set thinking level (selector)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let level_name = args.trim();
if !level_name.is_empty() {
let Some(level) = thinking_level_from_name(level_name) else {
add_error_message(
&ctx.chat,
&format!(
"Unknown thinking level \"{level_name}\". Valid: {}",
crate::args::VALID_THINKING_LEVELS.join(", ")
),
);
ctx.tui.request_render(false);
return;
};
let lane = ctx.lane.clone();
let footer = ctx.state.footer.clone();
tokio::spawn(async move {
let _ = lane.set_thinking_level(level).await;
});
footer.set_thinking_level(Some(thinking_level_name(level)));
add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
ctx.tui.request_render(false);
return;
}
open_thinking_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.lane,
&ctx.model_catalog,
&ctx.lane_model_id,
&ctx.chat,
);
}
}
struct ToolsCommand;
impl SlashCommand for ToolsCommand {
fn name(&self) -> &'static str {
"/tools"
}
fn description(&self) -> &'static str {
"Toggle tools on/off"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_tools_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.lane,
&ctx.chat,
);
}
}
struct ImagesCommand;
impl SlashCommand for ImagesCommand {
fn name(&self) -> &'static str {
"/images"
}
fn description(&self) -> &'static str {
"Toggle inline images"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_images_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.chat,
);
}
}
struct SessionCommand;
impl SlashCommand for SessionCommand {
fn name(&self) -> &'static str {
"/session"
}
fn aliases(&self) -> &'static [&'static str] {
&["/resume"]
}
fn description(&self) -> &'static str {
"List saved sessions"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_session_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.cwd,
&ctx.tx,
);
}
}
struct ThemeCommand;
impl SlashCommand for ThemeCommand {
fn name(&self) -> &'static str {
"/theme"
}
fn description(&self) -> &'static str {
"Choose a theme (selector)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let name = args.trim().to_ascii_lowercase();
if !name.is_empty() {
let preset = match name.as_str() {
"light" => ThemePreset::Light,
"monochrome" => ThemePreset::Monochrome,
"dark" => ThemePreset::Dark,
_ => {
add_error_message(
&ctx.chat,
&format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
);
ctx.tui.request_render(false);
return;
}
};
apply_theme_preset(preset);
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.theme = Some(name.clone());
let _ = crate::settings::save_settings(&settings);
add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
ctx.tui.request_render(false);
ctx.tui.render_now(true);
return;
}
open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
}
}
struct CompactCommand;
impl SlashCommand for CompactCommand {
fn name(&self) -> &'static str {
"/compact"
}
fn description(&self) -> &'static str {
"Compact the conversation"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::Compact);
}
}
struct CopyCommand;
impl SlashCommand for CopyCommand {
fn name(&self) -> &'static str {
"/copy"
}
fn description(&self) -> &'static str {
"Copy last reply to clipboard"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::Copy);
}
}
struct ExportCommand;
impl SlashCommand for ExportCommand {
fn name(&self) -> &'static str {
"/export"
}
fn description(&self) -> &'static str {
"Export session to a markdown file"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ExportSession);
}
}
struct ForkCommand;
impl SlashCommand for ForkCommand {
fn name(&self) -> &'static str {
"/fork"
}
fn description(&self) -> &'static str {
"Fork the session into a new one"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ForkSession);
}
}
struct CloneCommand;
impl SlashCommand for CloneCommand {
fn name(&self) -> &'static str {
"/clone"
}
fn description(&self) -> &'static str {
"Duplicate the current session"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ForkSession);
}
}
struct TreeCommand;
impl SlashCommand for TreeCommand {
fn name(&self) -> &'static str {
"/tree"
}
fn description(&self) -> &'static str {
"Navigate the current session tree"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::OpenTree);
}
}
struct LoginCommand;
impl SlashCommand for LoginCommand {
fn name(&self) -> &'static str {
"/login"
}
fn description(&self) -> &'static str {
"Save an Anthropic API key"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let key = args.trim();
if key.is_empty() {
add_note_message(&ctx.chat, "Usage: /login <api-key>");
} else {
let result = crate::config::upsert_credential(
"anthropic",
crate::config::Credential::ApiKey {
key: Some(key.to_string()),
env: None,
},
);
match result {
Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
Err(error) => {
add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
}
}
}
ctx.tui.request_render(false);
}
}
struct LogoutCommand;
impl SlashCommand for LogoutCommand {
fn name(&self) -> &'static str {
"/logout"
}
fn description(&self) -> &'static str {
"Remove saved Anthropic credentials"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
match crate::config::delete_credential("anthropic") {
Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
Err(error) => {
add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
}
}
ctx.tui.request_render(false);
}
}
struct TrustCommand;
impl SlashCommand for TrustCommand {
fn name(&self) -> &'static str {
"/trust"
}
fn description(&self) -> &'static str {
"Trust the current project"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let value = match args.trim().to_ascii_lowercase().as_str() {
"" | "yes" | "y" | "true" => Some(true),
"no" | "n" | "false" => Some(false),
"clear" | "reset" | "none" => None,
_ => {
add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
ctx.tui.request_render(false);
return;
}
};
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
match crate::config::set_project_trust(&cwd, value) {
Ok(()) => {
let label = match value {
Some(true) => "trusted",
Some(false) => "untrusted",
None => "trust decision cleared",
};
add_note_message(&ctx.chat, &format!("Current project marked {label}."));
}
Err(error) => add_error_message(
&ctx.chat,
&format!("Could not save trust decision: {error}"),
),
}
ctx.tui.request_render(false);
}
}
struct NameCommand;
impl SlashCommand for NameCommand {
fn name(&self) -> &'static str {
"/name"
}
fn description(&self) -> &'static str {
"Set session display name"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let name = args.trim();
if name.is_empty() {
add_note_message(
&ctx.chat,
"Usage: /name <display name> — sets the current session's name.",
);
ctx.tui.request_render(false);
return;
}
let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
}
}
struct ImportCommand;
impl SlashCommand for ImportCommand {
fn name(&self) -> &'static str {
"/import"
}
fn description(&self) -> &'static str {
"Import a session file (path)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let path = args.trim();
if path.is_empty() {
add_note_message(
&ctx.chat,
"Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
);
ctx.tui.request_render(false);
return;
}
let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
}
}
struct SettingsCommand;
impl SlashCommand for SettingsCommand {
fn name(&self) -> &'static str {
"/settings"
}
fn description(&self) -> &'static str {
"Open settings menu"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_settings_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.lane,
&ctx.model_catalog,
&ctx.lane_model_id,
&ctx.chat,
);
}
}
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(CloneCommand));
r.register(Arc::new(TreeCommand));
r.register(Arc::new(TrustCommand));
r.register(Arc::new(LoginCommand));
r.register(Arc::new(LogoutCommand));
r.register(Arc::new(ReloadCommand));
r
}
fn register_extension_commands(
registry: &mut CommandRegistry,
session: crate::session::ExtensionSessionCell,
) {
let commands = session
.lock()
.ok()
.and_then(|s| s.snapshot_arc())
.map(|snap| snap.commands().to_vec())
.unwrap_or_default();
for command in commands {
let name = if command.name.starts_with('/') {
command.name.clone()
} else {
format!("/{}", command.name)
};
if registry.find(&name).is_some() {
continue;
}
registry.register(Arc::new(ExtensionCommand {
name,
description: command.description,
session: session.clone(),
}));
}
}
enum TuiMessage {
UserInput(String),
QueueInput {
prompt: String,
follow_up: bool,
},
OpenTree,
NavigateTree(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,
};
apply_theme_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(),
Some(state.extension_session.clone()),
)
.await;
state.set_status(RunStatus::Idle);
add_note_message(chat, &format!("Switched to session {id}."));
true
}
Err(e) => {
state.set_status(RunStatus::Idle);
add_error_message(chat, &format!("Could not open session {id}: {e}"));
false
}
}
}
async fn import_session(
harness: &AgentHarness,
lane: &Arc<dyn AgentLane>,
path: &str,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) {
use std::path::Path as FsPath;
let src = FsPath::new(path);
if !src.is_file() {
add_error_message(chat, &format!("Import source not found: {path}"));
return;
}
let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
add_error_message(chat, "Import source has no file name.");
return;
};
if !fname.ends_with(".jsonl") {
add_error_message(chat, "Import source must be a .jsonl session file.");
return;
}
let dir = crate::session::default_session_dir(cwd);
if let Err(e) = std::fs::create_dir_all(&dir) {
add_error_message(chat, &format!("Could not create session dir: {e}"));
return;
}
let dest = dir.join(fname);
match std::fs::copy(src, &dest) {
Ok(_) => {
let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
if switch_to_session(harness, lane, &id, cwd, chat, state).await {
add_note_message(chat, &format!("Imported session from {path}"));
}
}
Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
}
}
async fn fork_session(
harness: &AgentHarness,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) {
use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
use rpi_tools::FileSystem;
let cwd_str = cwd.to_string_lossy().to_string();
let dir = crate::session::default_session_dir(cwd);
let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
let fs: Arc<dyn FileSystem> = env.clone();
let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
fs,
sessions_root: dir.to_string_lossy().into_owned(),
clock: Arc::new(rpi_harness::session::memory::SystemClock),
ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
});
let id = harness.session().storage().metadata().id.clone();
let metas = match crate::session::list_session_metadata(&cwd_str).await {
Ok(m) => m,
Err(e) => {
add_error_message(chat, &format!("Could not list sessions: {e}"));
return;
}
};
let Some(source) = metas.iter().find(|m| m.id == id) else {
add_error_message(chat, &format!("Current session {id} not found on disk."));
return;
};
let fork_storage = match repo
.fork_typed(
source,
&rpi_harness::session::jsonl::JsonlSessionCreateOptions {
id: None,
parent_session_id: Some(source.id.clone()),
cwd: cwd_str.clone(),
metadata: None,
},
&rpi_harness::session::types::ForkOptions::default(),
)
.await
{
Ok(s) => s,
Err(e) => {
add_error_message(chat, &format!("Could not fork session: {e}"));
return;
}
};
let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
let _ = harness.set_session(new_session).await;
chat.clear();
add_welcome_message(chat);
render_session_history(
harness,
chat,
state.markdown_transformer(),
Some(state.extension_session.clone()),
)
.await;
state.set_status(RunStatus::Idle);
add_note_message(chat, "Forked into a new session.");
}
async fn render_session_history(
harness: &AgentHarness,
chat: &Arc<Container>,
transformer: Option<MarkdownTransformer>,
extension_session: Option<crate::session::ExtensionSessionCell>,
) {
let tree = harness.session().view("main");
let entries = match tree
.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: None,
limit: None,
cursor: None,
})
.await
{
Ok(e) => e,
Err(_) => return,
};
let mut rendered_any = false;
for e in entries {
match e {
Entry::Message(me) => match &me.message {
AgentMessage::User(u) => {
add_user_message(chat, &user_message_text(u));
rendered_any = true;
}
AgentMessage::Assistant(a) => {
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(t) = &transformer {
comp.set_markdown_transformer(Some(t.clone()));
}
comp.update_blocks(&assistant_blocks(a));
chat.add_child(comp);
chat.add_child(Arc::new(Spacer::new(1)));
rendered_any = true;
}
AgentMessage::Custom(custom) => {
if let Some(session) = &extension_session {
if let Some(component) = extension_message_component(
session,
&custom.role,
&serde_json::json!({
"customType": custom.role,
"content": custom.content,
"details": custom.data,
}),
transformer.clone(),
) {
chat.add_child(component);
chat.add_child(Arc::new(Spacer::new(1)));
rendered_any = true;
continue;
}
}
add_note_message(chat, &custom_message_fallback(&custom));
rendered_any = true;
}
_ => {}
},
Entry::Compaction(compaction) => {
add_note_message(
chat,
&format!(
"Compacted {} tokens: {}",
compaction.tokens_before, compaction.summary
),
);
rendered_any = true;
}
Entry::BranchSummary(summary) => {
add_note_message(chat, &format!("Branch summary: {}", summary.summary));
rendered_any = true;
}
Entry::Custom(custom) => {
let rendered = extension_session.as_ref().and_then(|session| {
extension_entry_component(session, &custom.custom_type, custom.data.clone())
});
if let Some(component) = rendered {
chat.add_child(component);
chat.add_child(Arc::new(Spacer::new(1)));
rendered_any = true;
} else if let Some(text) =
custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
{
add_note_message(chat, &text);
rendered_any = true;
}
}
Entry::ModelChange(change) => {
add_note_message(
chat,
&format!("Model changed to {}:{}", change.provider, change.model_id),
);
rendered_any = true;
}
Entry::ThinkingLevel(change) => {
add_note_message(
chat,
&format!("Thinking level: {:?}", change.thinking_level),
);
rendered_any = true;
}
Entry::ActiveTools(change) => {
add_note_message(
chat,
&format!("Active tools: {}", change.active_tool_names.join(", ")),
);
rendered_any = true;
}
}
}
if rendered_any {
}
}
fn invoke_extension_renderer(
session: &crate::session::ExtensionSessionCell,
kind: rpi_extensions::RegisteredRendererKind,
payload: &serde_json::Value,
) -> Option<serde_json::Value> {
let snapshot = session.lock().ok()?.snapshot_arc()?;
let input = serde_json::to_string(payload).ok()?;
for renderer in snapshot.renderers_of(kind) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut out = rpi_plugin_sdk::StbString::empty();
let rc = (renderer.render_fn)(
rpi_plugin_sdk::StbStringRef::from_str(&input),
&mut out as *mut rpi_plugin_sdk::StbString,
renderer.user_data,
);
let text = if rc == 0 {
Some(out.to_string_lossy())
} else {
None
};
out.free_with(Some(renderer.plugin_free_string));
text
}))
.ok()
.flatten();
let Some(text) = outcome else { continue };
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
return Some(value);
}
}
None
}
fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
let text = lines
.iter()
.filter_map(|line| line.as_str())
.collect::<Vec<_>>()
.join("\n");
return Some(Arc::new(Text::new(text, 0, 0)));
}
let text = value.get("text").and_then(|v| v.as_str())?;
if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
let component = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
Some(component)
} else {
Some(Arc::new(Text::new(text, 0, 0)))
}
}
fn extension_message_component(
session: &crate::session::ExtensionSessionCell,
custom_type: &str,
payload: &serde_json::Value,
transformer: Option<MarkdownTransformer>,
) -> Option<Arc<dyn rpi_tui::Component>> {
let value = invoke_extension_renderer(
session,
rpi_extensions::RegisteredRendererKind::Message,
payload,
)?;
if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
let text = value.get("text").and_then(|v| v.as_str())?;
let component = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(transformer) = transformer {
component.set_markdown_transformer(Some(transformer));
}
component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
return Some(component);
}
extension_text_component(&value)
.or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
}
fn extension_entry_component(
session: &crate::session::ExtensionSessionCell,
custom_type: &str,
data: Option<serde_json::Value>,
) -> Option<Arc<dyn rpi_tui::Component>> {
let payload = serde_json::json!({
"customType": custom_type,
"data": data,
});
let value = invoke_extension_renderer(
session,
rpi_extensions::RegisteredRendererKind::Entry,
&payload,
)?;
extension_text_component(&value)
}
fn launch_restores_history(args: &Args) -> bool {
args.continue_session
|| args.resume
|| args.session.is_some()
|| args.session_id.is_some()
|| args.fork.is_some()
}
fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
Content::Image(image) => base64::engine::general_purpose::STANDARD
.decode(&image.data)
.ok()
.filter(|data| !data.is_empty())
.map(AssistantBlock::Image),
_ => None,
})
.collect()
}
fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
let content = custom
.content
.iter()
.filter_map(|item| match item {
Content::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if content.is_empty() {
format!("{}: {}", custom.role, custom.data)
} else {
format!("{}: {}", custom.role, content)
}
}
fn short_model_name(id: &str) -> String {
id.rsplit([':', '/'])
.next()
.filter(|s| !s.is_empty())
.unwrap_or(id)
.to_string()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RunStatus {
Idle,
Working,
Aborting,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SelectorKind {
Model,
Thinking,
Tools,
Images,
Session,
Theme,
ScopedModels,
Settings,
Tree,
Extension,
}
struct TuiState {
current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
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)>>,
active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
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>>,
extension_session: crate::session::ExtensionSessionCell,
}
const HISTORY_LIMIT: usize = 100;
const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
const PAGE_SCROLL_OVERLAP: usize = 4;
const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
fn transcript_page_size(viewport_height: usize) -> i32 {
viewport_height
.saturating_sub(PAGE_SCROLL_OVERLAP)
.max(1)
.min(i32::MAX as usize) as i32
}
fn should_dispatch_key(kind: KeyEventKind) -> bool {
kind != KeyEventKind::Release
}
fn format_tokens(n: i64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn push_history(state: &Arc<TuiState>, text: &str) {
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
return;
}
let mut history = state.history.lock().unwrap();
if history.first() == Some(&trimmed) {
return;
}
history.insert(0, trimmed);
history.truncate(HISTORY_LIMIT);
*state.history_index.lock().unwrap() = -1;
*state.history_draft.lock().unwrap() = None;
}
fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
let history = state.history.lock().unwrap();
if history.is_empty() {
return;
}
let mut index = state.history_index.lock().unwrap();
let new_index = *index - direction as isize;
if new_index < -1 || new_index >= history.len() as isize {
return;
}
if *index == -1 && new_index >= 0 {
*state.history_draft.lock().unwrap() = Some(editor.get_text());
}
*index = new_index;
if new_index == -1 {
let draft = state.history_draft.lock().unwrap().take();
match draft {
Some(d) => {
let len = d.len();
editor.set_text(&d);
editor.set_cursor(0, len);
}
None => editor.set_text(""),
}
} else {
let text = history[new_index as usize].clone();
let len = text.len();
editor.set_text(&text);
editor.set_cursor(0, len);
}
}
impl TuiState {
fn set_status(&self, status: RunStatus) {
*self.status.lock().unwrap() = status;
self.apply_status(status);
}
fn try_start_working(&self) -> bool {
let mut status = self.status.lock().unwrap();
if *status != RunStatus::Idle {
return false;
}
*status = RunStatus::Working;
drop(status);
self.apply_status(RunStatus::Working);
true
}
fn apply_status(&self, status: RunStatus) {
match status {
RunStatus::Working => {
self.footer.set_status("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…");
self.loader.stop();
self.status_container.clear();
}
RunStatus::Idle => {
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title("rpi");
}
self.loader.stop();
self.status_container.clear();
}
}
}
fn sync_working_loader_with_bash(&self) {
if *self.status.lock().unwrap() != RunStatus::Working {
return;
}
self.status_container.clear();
if self.bash_components.lock().unwrap().is_empty() {
self.status_container.add_child(self.loader.clone());
}
}
fn selector_open(&self) -> bool {
self.active_selector.lock().unwrap().is_some()
}
fn extension_editor_open(&self) -> bool {
self.active_extension_editor.lock().unwrap().is_some()
}
fn 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, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
if let Some(preset) = match theme {
Some("light") => Some(ThemePreset::Light),
Some("monochrome") => Some(ThemePreset::Monochrome),
Some("dark") => Some(ThemePreset::Dark),
_ => None,
} {
apply_theme_preset(preset);
}
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(),
);
if launch_restores_history(args) {
render_session_history(
&harness,
&chat_container,
initial_transformer.clone(),
Some(reload_context.extension_session.clone()),
)
.await;
}
let document_container = Arc::new(Container::new());
document_container.add_child(chat_container.clone());
let scroll_view = Arc::new(ScrollView::new(
document_container.clone(),
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
overscroll: OverscrollMode::Chain,
scrollbar: ScrollbarMode::Hidden,
..Default::default()
},
));
let 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 mut command_registry = build_builtin_registry();
register_extension_commands(
&mut command_registry,
reload_context.extension_session.clone(),
);
let registry = Arc::new(command_registry);
let mut all_slash_commands = registry.visible_entries();
all_slash_commands.extend(template_slash_commands);
let autocomplete = AutocompleteManager::new();
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
all_slash_commands,
)));
combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
cwd.clone(),
)));
autocomplete.set_provider(Arc::new(combined));
}
let autocomplete_container = Arc::new(Container::new());
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
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),
active_extension_editor: 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),
extension_session: reload_context.extension_session.clone(),
});
let model_catalog_arc = Arc::new(model_catalog.clone());
let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
let editor_container = Arc::new(Container::new());
editor_container.add_child(editor.clone());
let dock = Arc::new(VStack::from_children(vec![
StackChild::Entry(StackEntry::new(status_container.clone())),
StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
StackChild::Entry(
StackEntry::new(editor_container.clone())
.shrink(0)
.min_size(3),
),
StackChild::Entry(StackEntry::new(footer.clone())),
]));
let root = VStack::from_children(vec![
StackChild::Entry(
StackEntry::new(scroll_view.clone())
.basis(0)
.grow(1)
.shrink(1)
.min_size(1),
),
StackChild::Entry(StackEntry::new(dock).shrink(1)),
]);
tui.set_layout_root(Some(Arc::new(root)));
tui.set_focus(Some(editor.clone()));
editor.set_focused(true);
let ctx = CommandContext {
chat: chat_container.clone(),
tui: tui.clone(),
tx: tx.clone(),
state: state.clone(),
editor: editor.clone(),
editor_container: editor_container.clone(),
lane: lane.clone(),
model_catalog: model_catalog_arc.clone(),
lane_model_id: lane_model_id.clone(),
cwd: cwd.clone(),
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;
}
if *ctx_for_cb.state.status.lock().unwrap() != RunStatus::Idle {
let _ = ctx_for_cb.tx.send(TuiMessage::QueueInput {
prompt: text.to_string(),
follow_up: false,
});
add_note_message(
&ctx_for_cb.chat,
&format!("Queued steering message: {text}"),
);
ctx_for_cb.tui.request_render(false);
return;
}
if !ctx_for_cb.state.try_start_working() {
return;
}
add_user_message(&ctx_for_cb.chat, text);
if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
scroll.scroll_to_end();
}
ctx_for_cb.tui.request_render(false);
push_history(&ctx_for_cb.state, text);
if ctx_for_cb
.tx
.send(TuiMessage::UserInput(text.to_string()))
.is_err()
{
ctx_for_cb.state.set_status(RunStatus::Idle);
}
}));
tui.start_readerless();
let drain_handle = if let Some(rx) = event_rx {
let tui_drain = tui.clone();
let state_drain = state.clone();
let chat_drain = chat_container.clone();
Some(tokio::spawn(async move {
drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
}))
} else {
None
};
let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
reload_context.mailbox.install(reload_sig_tx);
let reload_tx = tx.clone();
let reload_bridge_handle = tokio::spawn(async move {
while reload_sig_rx.recv().await.is_some() {
if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
break; }
}
});
let tui_tick = tui.clone();
let state_tick = state.clone();
let tick_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
interval.tick().await; loop {
interval.tick().await;
let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
if working {
if state_tick.bash_components.lock().unwrap().is_empty() {
tui_tick.request_render_reusing_scroll_content();
} else {
tui_tick.request_render(false);
}
}
}
});
let running = Arc::new(std::sync::Mutex::new(true));
let running_key = running.clone();
let tx_for_key = tx.clone();
let tui_for_key = tui.clone();
let editor_for_key = editor.clone();
let scroll_for_key = scroll_view.clone();
let lane_for_key = lane.clone();
let state_for_key = state.clone();
let ctx_for_key = ctx.clone();
let registry_for_key = registry.clone();
let key_handle = tokio::task::spawn_blocking(move || {
loop {
if !*running_key.lock().unwrap() {
break;
}
match crossterm::event::poll(std::time::Duration::from_millis(50)) {
Ok(true) => {}
Ok(false) => continue,
Err(_) => {
let _ = tx_for_key.send(TuiMessage::Exit);
break;
}
}
let Ok(ev) = crossterm::event::read() else {
let _ = tx_for_key.send(TuiMessage::Exit);
break;
};
if let Event::Resize(_cols, _rows) = ev {
tui_for_key.refresh_size();
continue;
}
if let Event::Mouse(m) = ev {
use crossterm::event::MouseEventKind;
match m.kind {
MouseEventKind::ScrollUp => {
let delta = -MOUSE_WHEEL_SCROLL_LINES;
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
}
MouseEventKind::ScrollDown => {
let delta = MOUSE_WHEEL_SCROLL_LINES;
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
}
_ => {}
}
continue;
}
let Event::Key(key) = ev else {
continue;
};
if !should_dispatch_key(key.kind) {
continue;
}
if state_for_key.extension_editor_open()
&& key.modifiers == KeyModifiers::CONTROL
&& key.code == KeyCode::Char('c')
{
close_extension_editor(
&state_for_key,
&ctx_for_key.editor_container,
&editor_for_key,
);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
if !state_for_key.selector_open() && editor_for_key.has_selection() {
editor_for_key.copy_selection();
continue;
}
let status = *state_for_key.status.lock().unwrap();
match status {
RunStatus::Working => {
state_for_key.set_status(RunStatus::Aborting);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.abort().await;
});
}
RunStatus::Aborting => {}
RunStatus::Idle => {
let _ = tx_for_key.send(TuiMessage::Exit);
}
}
continue;
}
if state_for_key.selector_open() {
if key.code == KeyCode::Esc {
let (selector, _kind) = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some");
selector.handle_key(key);
continue;
}
let (selector, _kind) = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some");
selector.handle_key(key);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if state_for_key.extension_editor_open() {
let extension_editor = state_for_key
.active_extension_editor
.lock()
.unwrap()
.clone()
.expect("extension_editor_open guaranteed Some");
if key.code == KeyCode::Esc {
close_extension_editor(
&state_for_key,
&ctx_for_key.editor_container,
&editor_for_key,
);
} else {
extension_editor.handle_key(key);
}
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
let status = *state_for_key.status.lock().unwrap();
match status {
RunStatus::Working => {
state_for_key.set_status(RunStatus::Aborting);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.abort().await;
});
continue;
}
RunStatus::Aborting => continue,
RunStatus::Idle => {}
}
if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
editor_for_key.handle_key(key);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
let _ = tx_for_key.send(TuiMessage::Exit);
continue;
}
if 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_reusing_scroll_content();
}
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_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
let delta = -transcript_page_size(scroll_for_key.viewport_height());
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
let delta = transcript_page_size(scroll_for_key.viewport_height());
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
scroll_for_key.scroll_to_start();
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
scroll_for_key.scroll_to_end();
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
let browsing = *state_for_key.history_index.lock().unwrap() != -1;
if editor_for_key.get_text().is_empty() || browsing {
navigate_history(&state_for_key, &editor_for_key, -1);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
let browsing = *state_for_key.history_index.lock().unwrap() != -1;
if editor_for_key.get_text().is_empty() || browsing {
navigate_history(&state_for_key, &editor_for_key, 1);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
let prompt = editor_for_key.get_text().trim().to_string();
if prompt.is_empty() {
continue;
}
editor_for_key.clear();
let status = *state_for_key.status.lock().unwrap();
if status == RunStatus::Idle {
if state_for_key.try_start_working() {
add_user_message(&state_for_key.chat_container, &prompt);
push_history(&state_for_key, &prompt);
let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
}
} else {
add_note_message(
&state_for_key.chat_container,
&format!("Queued follow-up message: {prompt}"),
);
let _ = tx_for_key.send(TuiMessage::QueueInput {
prompt,
follow_up: true,
});
}
tui_for_key.request_render(false);
continue;
}
editor_for_key.handle_key(key);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
}
});
let mut prompts: Vec<String> = Vec::new();
if let Some(init) = initial {
prompts.push(init);
}
for m in extra_messages {
prompts.push(m.clone());
}
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.recv().await {
Some(TuiMessage::UserInput(prompt)) => {
editor.clear();
run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
}
Some(TuiMessage::QueueInput { prompt, follow_up }) => {
let message = AgentMessage::User(UserMessage::new(prompt.clone(), 0));
let aborting = *state.status.lock().unwrap() == RunStatus::Aborting;
let result = if aborting {
lane.next_run(message).await
} else if follow_up {
lane.follow_up(message).await
} else {
lane.steer(message).await
};
if let Err(error) = result {
add_error_message(
&chat_container,
&format!("Could not queue message: {error}"),
);
tui.request_render(false);
}
}
Some(TuiMessage::OpenTree) => {
if *state.status.lock().unwrap() != RunStatus::Idle {
add_note_message(
&chat_container,
"Wait for the current run to finish before opening the tree.",
);
tui.request_render(false);
} else {
open_tree_selector(
&harness,
&state,
&editor_container,
&editor,
&tui,
&chat_container,
&tx,
)
.await;
}
}
Some(TuiMessage::NavigateTree(entry_id)) => {
match lane.navigate_tree(Some(&entry_id), false, None, None).await {
Ok(result) => match result.outcome {
rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
chat_container.clear();
add_welcome_message(&chat_container);
render_session_history(
&harness,
&chat_container,
state.markdown_transformer(),
Some(state.extension_session.clone()),
)
.await;
add_note_message(
&chat_container,
"Moved to the selected session entry.",
);
}
rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
add_error_message(&chat_container, &error.message);
}
_ => add_note_message(
&chat_container,
"The selected entry could not be opened.",
),
},
Err(error) => add_error_message(
&chat_container,
&format!("Could not navigate session tree: {error}"),
),
}
tui.request_render(false);
}
Some(TuiMessage::ClearChat) => {
chat_container.clear();
add_welcome_message(&chat_container);
tui.request_render(false);
}
Some(TuiMessage::Compact) => {
run_compact(&lane, &tui, &state).await;
}
Some(TuiMessage::Copy) => {
copy_last_assistant(&state, &chat_container);
tui.request_render(false);
}
Some(TuiMessage::Exit) => {
*running.lock().unwrap() = false;
break;
}
Some(TuiMessage::SwitchSession(id)) => {
switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Some(TuiMessage::ImportSession(path)) => {
import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Some(TuiMessage::ShareSession) => {
share_session(&harness, &chat_container).await;
tui.request_render(false);
}
Some(TuiMessage::SetSessionName(name)) => {
let outcome = harness.session().set_name(Some(&name)).await;
match outcome {
Ok(_) => add_note_message(
&chat_container,
&format!("Session renamed to \"{name}\"."),
),
Err(e) => add_error_message(
&chat_container,
&format!("Could not rename session: {e}"),
),
}
tui.request_render(false);
}
Some(TuiMessage::ExportSession) => {
export_session(&harness, &chat_container).await;
tui.request_render(false);
}
Some(TuiMessage::ForkSession) => {
fork_session(&harness, &cwd, &chat_container, &state).await;
tui.request_render(false);
}
Some(TuiMessage::ReloadExtensions) => {
let reload_ctx = ctx.reload_context.clone();
add_note_message(&chat_container, "Reloading extensions + resources…");
tui.request_render(false);
let outcome =
crate::session::reload_extension_resources(&harness, &reload_ctx).await;
let fresh_transformer = build_markdown_transformer(
reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
);
state.set_markdown_transformer_with_reinstall(fresh_transformer);
if outcome.had_warnings {
add_error_message(
&chat_container,
&format!(
"{} (with warnings — see stderr for details).",
outcome.summary
),
);
} else {
add_note_message(&chat_container, &outcome.summary);
}
tui.request_render(false);
}
None => break,
}
}
*running.lock().unwrap() = false;
let _ = key_handle.await;
tick_handle.abort();
if let Some(handle) = drain_handle {
handle.abort();
}
reload_context.mailbox.clear();
reload_bridge_handle.abort();
tui.stop(Default::default());
println!("\nGoodbye!");
let _ = args;
0
}
async fn 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(1)));
*state.current_assistant.lock().unwrap() = Some(comp);
tui.request_render(false);
}
AgentMessage::Custom(custom) => {
let payload = serde_json::json!({
"customType": custom.role,
"content": custom.content,
"details": custom.data,
"expanded": false,
"outputPad": 1,
});
if let Some(component) = extension_message_component(
&state.extension_session,
&custom.role,
&payload,
state.markdown_transformer(),
) {
chat.add_child(component);
chat.add_child(Arc::new(Spacer::new(1)));
tui.request_render(false);
} else {
add_note_message(chat, &custom_message_fallback(&custom));
tui.request_render(false);
}
}
_ => {}
},
AgentEvent::MessageUpdate {
message,
assistant_message_event,
} => {
if let AgentMessage::Assistant(a) = &message {
let text = assistant_text(a);
for c in &a.content {
if let Content::ToolCall(tc) = c {
if tc.name == "bash" {
let command = tc
.arguments
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut bash = state.bash_components.lock().unwrap();
if !bash.contains_key(&tc.id) {
let comp = Arc::new(BashExecutionComponent::new(command));
chat.add_child(comp.clone());
bash.insert(tc.id.clone(), comp);
}
} else {
let mut tools = state.tool_components.lock().unwrap();
if !tools.contains_key(&tc.id) {
let comp = Arc::new(ToolExecutionComponent::new(
&tc.name,
&tc.arguments.to_string(),
));
comp.set_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 mut bash_map = state.bash_components.lock().unwrap();
if let Some(existing) = bash_map.get(&tool_call_id) {
existing.set_command(&command);
} else {
let comp = Arc::new(BashExecutionComponent::new(command));
chat.add_child(comp.clone());
bash_map.insert(tool_call_id.clone(), comp);
}
} else {
let comp = {
let mut tools = state.tool_components.lock().unwrap();
if let Some(existing) = tools.get(&tool_call_id) {
existing.set_args(&args.to_string());
existing.clone()
} else {
let comp =
Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
comp.set_running();
chat.add_child(comp.clone());
tools.insert(tool_call_id.clone(), comp.clone());
comp
}
};
state.remember_tool(comp);
}
state.sync_working_loader_with_bash();
tui.request_render(false);
}
AgentEvent::ToolExecutionUpdate {
tool_call_id,
tool_name,
partial_result,
..
} => {
if tool_name == "bash" {
let chunk = tool_result_text(&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) {
comp.set_result(&tool_result_text(&partial_result), 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(&tool_result_text(&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);
}
state.sync_working_loader_with_bash();
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(&tool_result_text(&result));
finalize_bash(&comp, &result, is_error);
chat.add_child(comp);
}
} else {
let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
if let Some(comp) = comp {
comp.set_result(&tool_result_text(&result), is_error);
apply_edit_diff(&comp, &tool_name, &result.details, &tui);
} else {
let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
comp.set_result(&tool_result_text(&result), is_error);
apply_edit_diff(&comp, &tool_name, &result.details, &tui);
chat.add_child(comp.clone());
state.remember_tool(comp);
}
}
state.sync_working_loader_with_bash();
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 tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
use rpi_agent::TextContentOrImage;
let mut parts: Vec<String> = Vec::new();
for c in &result.content {
if let TextContentOrImage::Text(t) = c {
parts.push(t.text.clone());
}
}
parts.join("\n")
}
fn 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::UnboundedSender<TuiMessage>,
) {
let dir = crate::session::default_session_dir(cwd);
let mut items: Vec<SelectItem> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("(unnamed)")
.to_string();
let display = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&stem)
.to_string();
items.push(SelectItem::new(&stem, &display));
}
}
if items.is_empty() {
add_note_message(
&state.chat_container,
"No saved sessions found. Sessions are created automatically in interactive mode.",
);
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let tx_sel = tx.clone();
list.on_select(Arc::new(move |item| {
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Session,
);
}
fn custom_entry_display_text(
custom_type: &str,
data: Option<&serde_json::Value>,
) -> Option<String> {
let data = data?;
let text = data
.get("summary")
.or_else(|| data.get("text"))
.or_else(|| data.get("output"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())?;
let label = match custom_type {
"compactionSummary" => "Compaction summary",
"branchSummary" => "Branch summary",
"bashExecution" => "Command output",
other => other,
};
Some(format!("{label}: {text}"))
}
async fn open_tree_selector(
harness: &AgentHarness,
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
chat: &Arc<Container>,
tx: &mpsc::UnboundedSender<TuiMessage>,
) {
let entries = match harness
.session()
.view("main")
.find_entries(&EntryQuery {
order: Some(EntryOrder::OldestFirst),
..Default::default()
})
.await
{
Ok(entries) => entries,
Err(error) => {
add_error_message(chat, &format!("Could not read session tree: {error}"));
tui.request_render(false);
return;
}
};
let current = harness.session().get_leaf_id().await.ok().flatten();
let items: Vec<SelectItem> = entries
.iter()
.map(|entry| {
let marker = if current.as_deref() == Some(entry.id()) {
" (current)"
} else {
""
};
SelectItem::new(
entry.id(),
&format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
)
.with_description(&entry.id()[..entry.id().len().min(12)])
})
.collect();
if items.is_empty() {
add_note_message(chat, "The current session has no entries to navigate.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 12));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let tx_sel = tx.clone();
list.on_select(Arc::new(move |item| {
let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Tree,
);
}
fn open_theme_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
) {
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,
};
apply_theme_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>) {
let c = current_theme().colors;
let title = format!(
"{} {}",
c.accent.fg(&tui_bold("rpi")),
c.muted.fg("interactive TUI")
);
container.add_child(Arc::new(Text::new(title, 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
c.dim.fg("Type your message and press Enter to send."),
1,
0,
)));
let hint = c
.dim
.fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
container.add_child(Arc::new(Text::new(hint, 1, 0)));
container.add_child(Arc::new(DynamicBorder::new()));
}
fn add_help_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("📚 Available Commands")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let cmds: &[(&str, &str)] = &[
("/help, /?", "Show this help message"),
("/clear, /new", "Clear the conversation"),
("/exit, /quit, /q", "Exit the application"),
("/version, /v", "Show version information"),
("/model, /m", "Choose a model (live switch)"),
("/thinking, /think", "Set reasoning depth (selector)"),
("/tools", "Toggle built-in tools on/off"),
("/images", "Toggle inline image rendering"),
("/session", "List saved sessions"),
("/theme", "Choose a theme (selector)"),
("/compact", "Compact the conversation"),
("/copy", "Copy last reply to clipboard"),
("/hotkeys", "Show keyboard shortcuts"),
("/armin", "🐾 Easter egg"),
("/earendil", "Earendil announcement"),
];
let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (cmd, desc) in cmds {
let row = format!(
" {:<cmd_w$} {} {}",
c.accent.fg(cmd),
c.dim.fg("—"),
c.muted.fg(desc)
);
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_version_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("📦 Version Information")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
format!(
" {} {}",
c.muted.fg("rpi-cli"),
c.text.fg(&format!("v{}", crate::VERSION))
),
1,
0,
)));
container.add_child(Arc::new(Text::new(
format!(
" {}",
c.dim.fg("Rust implementation of pi coding agent TUI")
),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_hotkeys_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let keys: &[(&str, &str)] = &[
("Enter", "Send message"),
("Shift+Enter", "New line"),
("Tab", "Accept autocomplete suggestion"),
("Ctrl+A / Ctrl+E", "Line start / end"),
(
"Ctrl+K / Ctrl+U",
"Kill to end / start of line (Ctrl+Y yanks)",
),
("Ctrl+- / Ctrl+R", "Undo / redo"),
("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
("Alt+Backspace", "Kill previous word"),
("Ctrl+C", "Abort a run, or exit when idle"),
("Esc", "Abort a running prompt"),
("Ctrl+L", "Open model selector"),
("Ctrl+M", "Cycle to the next model (live)"),
("Ctrl+T", "Expand/collapse last tool result"),
("PageUp/Down", "Scroll transcript by one page"),
("Home / End", "Jump to transcript start / latest output"),
];
let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, desc) in keys {
let row = format!(
" {:<key_w$} {} {}",
c.accent.fg(key),
c.dim.fg("—"),
c.muted.fg(desc)
);
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_user_message(container: &Arc<Container>, text: &str) {
container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_error_message(container: &Arc<Container>, text: &str) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
format!(" {} {}", c.error.fg("✗"), c.error.fg(text)),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_note_message(container: &Arc<Container>, text: &str) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn show_context_panel(
chat: &Arc<Container>,
resources: &Arc<rpi_harness::types::AgentHarnessResources>,
) {
let skills = resources.skills.as_deref().unwrap_or(&[]);
let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
let mut lines: Vec<String> = Vec::new();
lines.push("📂 Discovered resources for this session:".into());
if skills.is_empty() {
lines.push(
" Skills: (none discovered — create .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 transcript_page_uses_viewport_with_overlap() {
assert_eq!(transcript_page_size(24), 20);
assert_eq!(transcript_page_size(4), 1);
assert_eq!(transcript_page_size(0), 1);
}
#[test]
fn key_repeat_is_dispatched_but_release_is_not() {
assert!(should_dispatch_key(KeyEventKind::Press));
assert!(should_dispatch_key(KeyEventKind::Repeat));
assert!(!should_dispatch_key(KeyEventKind::Release));
}
#[test]
fn test_layout_renders_welcome_message() {
let chat = Arc::new(Container::new());
add_welcome_message(&chat);
let scroll = Arc::new(ScrollView::new(
chat.clone(),
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
..Default::default()
},
));
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
..Default::default()
},
EditorStyle::default(),
Arc::new(rpi_tui::Keybindings::new()),
));
let dock = Arc::new(Container::new());
dock.add_child(editor);
let footer = Arc::new(FooterComponent::new());
let root = VStack::from_children(vec![
StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
StackChild::Entry(StackEntry::new(dock)),
StackChild::Entry(StackEntry::new(footer)),
]);
let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
let all: String = frame.lines.join("\n");
assert!(
all.contains("rpi"),
"Welcome message not found. Rendered: {}",
all
);
assert!(
all.contains("Type your message"),
"Help text not found. Rendered: {}",
all
);
}
#[test]
fn test_chat_container_has_welcome_content() {
let chat = Arc::new(Container::new());
add_welcome_message(&chat);
let lines = chat.render(80);
let all: String = lines.join("\n");
let plain = strip_ansi(&all);
assert!(
plain.contains("rpi"),
"Welcome message not in chat container: {:?}",
lines
);
}
#[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),
active_extension_editor: 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),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
let assistant = AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![
Content::Thinking(ThinkingContent {
kind: ThinkingContentType,
thinking: "Reasoning about the reply.".into(),
thinking_signature: None,
redacted: false,
}),
Content::Text(TextContent {
kind: TextContentType,
text: "Hello.".into(),
text_signature: None,
}),
Content::ToolCall(ToolCall {
kind: ToolCallType,
id: "tc1".into(),
name: "bash".into(),
arguments: serde_json::json!({"command": "echo hi"}),
thought_signature: None,
namespace: None,
}),
],
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
};
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
comp.set_streaming(true);
comp.update_blocks(&assistant_blocks(&assistant));
let chat = Arc::new(Container::new());
chat.add_child(comp.clone());
*state.current_assistant.lock().unwrap() = Some(comp);
for c in &assistant.content {
if let Content::ToolCall(tc) = c {
let mut tools = state.tool_components.lock().unwrap();
if !tools.contains_key(&tc.id) {
let tc_comp = Arc::new(ToolExecutionComponent::new(
&tc.name,
&tc.arguments.to_string(),
));
tc_comp.set_running();
chat.add_child(tc_comp.clone());
tools.insert(tc.id.clone(), tc_comp);
}
}
}
let rendered = chat.render(80);
let joined: String = rendered.join("\n");
assert!(
joined.contains("Hello."),
"assistant text not rendered: {joined}"
);
assert!(
joined.contains("Reasoning about the reply."),
"thinking block not rendered: {joined}"
);
assert_eq!(state.tool_components.lock().unwrap().len(), 1);
assert!(state.current_assistant.lock().unwrap().is_some());
let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
ended.set_result("hi", false);
assert!(state.tool_components.lock().unwrap().is_empty());
assert!(state.try_start_working());
assert!(
!state.try_start_working(),
"a second submit must be rejected"
);
state.set_status(RunStatus::Idle);
state.set_status(RunStatus::Working);
assert_eq!(state.status_container.child_count(), 1);
{
let mut bash = state.bash_components.lock().unwrap();
bash.insert(
"bash-1".into(),
Arc::new(BashExecutionComponent::new("one")),
);
bash.insert(
"bash-2".into(),
Arc::new(BashExecutionComponent::new("two")),
);
}
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-1");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-2");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 1);
state.set_status(RunStatus::Aborting);
assert_eq!(state.status_container.child_count(), 0);
assert!(!state.loader.is_running());
}
#[test]
fn fresh_launch_does_not_restore_old_history() {
let fresh = Args::default();
assert!(!launch_restores_history(&fresh));
let continued = Args {
continue_session: true,
..Args::default()
};
assert!(launch_restores_history(&continued));
let selected = Args {
session: Some("session-id".into()),
..Args::default()
};
assert!(launch_restores_history(&selected));
}
#[test]
fn test_short_model_name() {
assert_eq!(
short_model_name("anthropic:claude-sonnet-5"),
"claude-sonnet-5"
);
assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
}
#[test]
fn 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),
active_extension_editor: 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),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let lines = state.autocomplete_container.render(80);
let joined: String = lines.join("\n");
assert!(
joined.contains("/help"),
"slash suggestions not rendered: {joined}"
);
editor.set_text("hello");
editor.set_cursor(0, 5);
refresh_autocomplete(&state, &editor);
assert!(state.autocomplete_container.render(80).is_empty());
}
#[test]
fn test_select_list_swap_restores_editor() {
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
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),
active_extension_editor: 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),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
let editor_container = Arc::new(Container::new());
let editor = Arc::new(Editor::simple());
editor_container.add_child(editor.clone());
assert!(!state.selector_open());
let tui_terminal = Box::new(ProcessTerminal::new());
let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
let list = Arc::new(SelectList::new(
vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
5,
));
open_selector(
&state,
&editor_container,
&editor,
&tui,
list,
SelectorKind::Theme,
);
assert!(state.selector_open());
assert_eq!(editor_container.child_count(), 1);
close_selector(&state, &editor_container, &editor, &tui);
assert!(!state.selector_open());
assert_eq!(editor_container.child_count(), 1);
}
#[test]
fn test_message_history_browse_restores_draft() {
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
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),
active_extension_editor: 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),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
let editor = Arc::new(Editor::simple());
push_history(&state, "first message");
push_history(&state, "second message");
push_history(&state, "second message");
push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
assert_eq!(state.history.lock().unwrap()[0], "second message");
editor.set_text("half-typed");
editor.set_cursor(0, 11);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "second message");
assert_eq!(*state.history_index.lock().unwrap(), 0);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
assert_eq!(*state.history_index.lock().unwrap(), 1);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "second message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "half-typed");
assert_eq!(*state.history_index.lock().unwrap(), -1);
}
#[test]
fn test_accept_top_suggestion_replaces_prefix() {
let state = Arc::new(TuiState {
current_assistant: std::sync::Mutex::new(None),
tool_components: std::sync::Mutex::new(HashMap::new()),
bash_components: std::sync::Mutex::new(HashMap::new()),
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),
active_extension_editor: 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),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let accepted = accept_top_suggestion(&state, &editor);
assert!(accepted, "should accept the top suggestion");
let text = editor.get_text();
assert!(
text.starts_with("/help"),
"editor text should start with /help, got {text}"
);
}
}