use std::collections::HashMap;
use std::io::IsTerminal;
use std::sync::{Arc, Mutex};
use base64::Engine;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use tokio::sync::{broadcast, mpsc};
use tokio_util::sync::CancellationToken;
use rpi_agent::{AgentEvent, AgentMessage};
use rpi_ai::types::{AssistantMessage, Content, UserMessage};
use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
#[cfg(test)]
use rpi_tui::strip_ansi;
use rpi_tui::{
apply_theme_preset, AltScreenSearch, AssistantBlock, AssistantMessageComponent,
AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
FooterComponent, Image, ImageOptions, Input, Loader, Markdown, ProcessTerminal, ScrollView,
ScrollViewOptions, SearchBar, SearchableSelectList, SelectItem, SelectList, SettingItem,
SettingsList, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
StackChild, StackEntry, StatusIndicator, Text, ThemeManager, ThemePreset,
ToolExecutionComponent, ToolStatus, TuiAltScreen, UserMessageComponent, VStack, WorkingState,
TUI,
};
use rpi_tui::{bold as tui_bold, theme as current_theme};
#[allow(unused_imports)]
use rpi_tui::BashStatus;
use crate::args::Args;
use crate::session_driver::{assistant_blocks, assistant_tool_calls};
use crate::transcript_view::UiEvent;
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>,
session: Arc<crate::agent_session::AgentSession>,
}
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_ui_prompt_event(state: &TuiState) {
use rpi_plugin_sdk::EventTag;
if let Ok(session) = state.extension_session.lock() {
if let Some(snapshot) = session.snapshot_arc() {
rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
}
}
}
fn dispatch_ui_prompt_event_end(state: &TuiState) {
use rpi_plugin_sdk::EventTag;
if let Ok(session) = state.extension_session.lock() {
if let Some(snapshot) = session.snapshot_arc() {
rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
}
}
}
fn key_shortcut_name(code: KeyCode) -> Option<&'static str> {
Some(match code {
KeyCode::Char(' ') => "space",
KeyCode::Enter => "enter",
KeyCode::Esc => "escape",
KeyCode::Tab => "tab",
KeyCode::BackTab => "backtab",
KeyCode::Backspace => "backspace",
KeyCode::Delete => "delete",
KeyCode::Up => "up",
KeyCode::Down => "down",
KeyCode::Left => "left",
KeyCode::Right => "right",
KeyCode::Home => "home",
KeyCode::End => "end",
KeyCode::PageUp => "pageup",
KeyCode::PageDown => "pagedown",
_ => return None,
})
}
fn char_shortcut_name(ch: char) -> Option<String> {
if ch.is_ascii_graphic() {
Some(ch.to_ascii_lowercase().to_string())
} else {
None
}
}
fn dispatch_key_event(state: &TuiState, key: &KeyEvent, editor: &Editor) -> bool {
use rpi_plugin_sdk::EventTag;
let name = key_shortcut_name(key.code)
.map(str::to_string)
.or_else(|| match key.code {
KeyCode::Char(ch) => char_shortcut_name(ch),
KeyCode::F(n) => Some(format!("f{n}")),
_ => None,
});
let Some(name) = name else {
return false;
};
let Ok(session) = state.extension_session.lock() else {
return false;
};
let Some(snapshot) = session.snapshot_arc() else {
return false;
};
if !snapshot.has_shortcut(&name) || snapshot.handlers_for(EventTag::Input).is_empty() {
return false;
}
if !editor.get_text().is_empty() {
return false;
}
let kind = match key.kind {
KeyEventKind::Press => "press",
KeyEventKind::Repeat => "repeat",
KeyEventKind::Release => "release",
};
let payload = serde_json::json!({
"type": "key",
"key": name,
"kind": kind,
"ctrl": key.modifiers.contains(KeyModifiers::CONTROL),
"alt": key.modifiers.contains(KeyModifiers::ALT),
"shift": key.modifiers.contains(KeyModifiers::SHIFT),
})
.to_string();
rpi_extensions::dispatch_data_event_claiming(&snapshot, EventTag::Input, &payload)
}
fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) -> bool {
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);
true
}
None => false,
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
let rest = text.strip_prefix('!')?;
let (command, exclude_from_context) = match rest.strip_prefix('!') {
Some(command) => (command, true),
None => (rest, false),
};
let command = command.trim();
if command.is_empty() {
return None;
}
Some((command, exclude_from_context))
}
fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
let comp = Arc::new(BashExecutionComponent::new_with_context(
command,
exclude_from_context,
));
comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
ctx.chat.add_child(comp.clone());
let cancel = ctx.state.begin_user_bash();
ctx.tui.request_render(false);
let env: Arc<dyn rpi_tools::ExecutionEnv> =
Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
let tui = ctx.tui.clone();
let tx = ctx.tx.clone();
let cwd = ctx.cwd.clone();
let command_owned = command.to_string();
tokio::spawn(async move {
let comp_chunk = comp.clone();
let tui_chunk = tui.clone();
let on_chunk: Box<
dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
> = Box::new(move |_chunk, get_progress| {
comp_chunk.append_output(&get_progress().output);
tui_chunk.request_render(false);
});
let options = rpi_tools::shell_output::ShellCaptureOptions {
cwd: Some(cwd),
cancel: Some(&cancel),
on_chunk: Some(on_chunk),
return_execution_errors: true,
..Default::default()
};
let result =
rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
.await;
let (exit_code, cancelled, output, truncated, full_output_path) = match result {
Ok(capture) => (
capture.exit_code,
capture.cancelled,
capture.output.clone(),
capture.truncated,
capture
.full_output_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned()),
),
Err(error) => (
None,
false,
format!("Bash command failed: {error}"),
false,
None,
),
};
comp.append_output(&output);
comp.set_complete(
exit_code,
cancelled,
BashTruncation {
truncated,
full_output_path: full_output_path.clone(),
},
);
tui.render_now(false);
let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
command: command_owned,
output,
exit_code,
cancelled,
truncated,
full_output_path,
exclude_from_context,
}));
});
}
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;
};
if value.is_null() {
ctx.tui.request_render(false);
return;
}
match value.get("kind").and_then(|v| v.as_str()) {
Some("message") | None => {
let fallback = value.to_string();
let text = value
.get("text")
.and_then(|v| v.as_str())
.unwrap_or(&fallback)
.to_string();
if !text.is_empty() {
add_note_message(&ctx.chat, &text);
}
ctx.tui.request_render(false);
}
Some("selector") => open_extension_selector(ctx, session, command_name, value),
Some("editor") => open_extension_editor(ctx, session, command_name, value),
Some("input") => open_extension_input(ctx, session, command_name, value),
Some(other) => {
add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
ctx.tui.request_render(false);
}
}
}
fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
let colors = current_theme().colors;
let text = if bold {
tui_bold(title)
} else {
title.to_string()
};
Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
}
fn extension_dialog_hint(label: &str) -> Arc<Text> {
Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
}
fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
let text = if bold {
format!("**{}**", title)
} else {
title.to_string()
};
Arc::new(Markdown::new(text, 1, 0))
}
fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
Arc::new(Markdown::new(label, 1, 0))
}
fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
let callback = state.active_extension_cancel.lock().unwrap().take();
if let Some(callback) = callback {
callback();
true
} else {
false
}
}
fn open_extension_input(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let title = value
.get("title")
.and_then(|v| v.as_str())
.filter(|title| !title.is_empty())
.unwrap_or("Input");
let input = value
.get("placeholder")
.and_then(|v| v.as_str())
.map(Input::with_placeholder)
.unwrap_or_default();
let input = Arc::new(input);
if let Some(initial) = value
.get("initialText")
.or_else(|| value.get("text"))
.and_then(|v| v.as_str())
{
input.set_value(initial);
}
input.set_focused(true);
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(title, false));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(input.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
*ctx.state.active_extension_editor.lock().unwrap() = None;
*ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
ctx.editor_container.clear();
ctx.editor_container.add_child(frame);
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let original = ctx.editor.clone();
let tui = ctx.tui.clone();
let session_submit = session.clone();
let command_submit = command_name.clone();
let ctx_submit = ctx.clone();
input.on_submit(Arc::new(move |text| {
let args = serde_json::json!({ "action": "input", "value": text, "text": text });
let result = invoke_extension_command(
&session_submit,
&command_submit,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state, &ec, &original, &tui);
handle_extension_ui_result(
result,
&ctx_submit,
session_submit.clone(),
command_submit.clone(),
);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let original_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
let session_cancel = session.clone();
let command_cancel = command_name.clone();
let ctx_cancel = ctx.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
let args = serde_json::json!({ "action": "cancel" });
let result = invoke_extension_command(
&session_cancel,
&command_cancel,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
handle_extension_ui_result(
result,
&ctx_cancel,
session_cancel.clone(),
command_cancel.clone(),
);
}));
ctx.tui.set_focus(Some(input));
ctx.tui.request_render(false);
}
fn open_extension_selector(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let items = value
.get("items")
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
let value = if let Some(value) = item.as_str() {
value
} else {
item.get("value")?.as_str()?
};
let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
let mut out = SelectItem::new(value, label);
if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
out = out.with_description(desc);
}
Some(out)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if items.is_empty() {
add_error_message(&ctx.chat, "Extension selector has no items.");
ctx.tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let title = value
.get("title")
.and_then(|v| v.as_str())
.filter(|title| !title.is_empty())
.unwrap_or("Select");
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(title, true));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(list.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint(
"↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let editor = ctx.editor.clone();
let tui = ctx.tui.clone();
let session_select = session.clone();
let command_select = command_name.clone();
let ctx_select = ctx.clone();
list.on_select(Arc::new(move |item| {
let args = serde_json::json!({ "action": "select", "value": item.value });
let result = invoke_extension_command(
&session_select,
&command_select,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_selector(&state, &ec, &editor, &tui);
handle_extension_ui_result(
result,
&ctx_select,
session_select.clone(),
command_select.clone(),
);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
list.on_cancel(Arc::new(move || {
if !run_extension_cancel(&state_cancel) {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
let session_cancel = session.clone();
let command_cancel = command_name.clone();
let ctx_cancel = ctx.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
let args = serde_json::json!({ "action": "cancel" });
let result = invoke_extension_command(
&session_cancel,
&command_cancel,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
handle_extension_ui_result(
result,
&ctx_cancel,
session_cancel.clone(),
command_cancel.clone(),
);
}));
open_selector_with_view(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
SelectorView::List(list),
frame,
SelectorKind::Extension,
);
}
fn open_extension_editor(
ctx: &CommandContext,
session: crate::session::ExtensionSessionCell,
command_name: String,
value: serde_json::Value,
) {
let initial = value
.get("initialText")
.or_else(|| value.get("text"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let title = value
.get("title")
.and_then(|v| v.as_str())
.filter(|title| !title.is_empty())
.unwrap_or("Editor");
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
autocomplete_max_visible: 0,
placeholder: value
.get("placeholder")
.and_then(|v| v.as_str())
.map(str::to_string),
initial_text: Some(initial),
},
EditorStyle {
prompt: "> ".to_string(),
placeholder: String::new(),
},
Arc::new(rpi_tui::Keybindings::new()),
));
editor.set_focused(true);
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_title(title, false));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(editor.clone());
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint(
"Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
*ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
*ctx.state.active_extension_input.lock().unwrap() = None;
ctx.editor_container.clear();
ctx.editor_container.add_child(frame);
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let original = ctx.editor.clone();
let tui = ctx.tui.clone();
let session_submit = session.clone();
let command_submit = command_name.clone();
let ctx_submit = ctx.clone();
editor.on_submit(Arc::new(move |text| {
let args = serde_json::json!({ "action": "edit", "text": text });
let result = invoke_extension_command(
&session_submit,
&command_submit,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state, &ec, &original, &tui);
handle_extension_ui_result(
result,
&ctx_submit,
session_submit.clone(),
command_submit.clone(),
);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let original_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
let session_cancel = session.clone();
let command_cancel = command_name.clone();
let ctx_cancel = ctx.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
let args = serde_json::json!({ "action": "cancel" });
let result = invoke_extension_command(
&session_cancel,
&command_cancel,
&serde_json::to_string(&args).unwrap_or_default(),
);
close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
handle_extension_ui_result(
result,
&ctx_cancel,
session_cancel.clone(),
command_cancel.clone(),
);
}));
ctx.tui.set_focus(Some(editor));
ctx.tui.request_render(false);
}
fn close_extension_editor(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
) {
editor_container.clear();
editor_container.add_child(editor.clone());
state.autocomplete_container.clear();
*state.active_extension_editor.lock().unwrap() = None;
*state.active_extension_input.lock().unwrap() = None;
*state.active_extension_cancel.lock().unwrap() = None;
editor.set_focused(true);
tui.set_focus(Some(editor.clone()));
tui.request_render(false);
}
const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
#[derive(Clone)]
struct AskUserBridge {
mailbox: rpi_extensions::UiDialogMailbox,
visible: Arc<Mutex<Option<String>>>,
}
impl AskUserBridge {
fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
Self {
mailbox,
visible: Arc::new(Mutex::new(None)),
}
}
fn attach(&self) {
self.mailbox.attach();
}
fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
let mut visible = self.visible.lock().ok()?;
if visible.is_some() {
return None;
}
let request = self.mailbox.take_pending()?;
*visible = Some(request.request_id.clone());
Some(request)
}
fn clear_visible(&self, id: &str) {
if let Ok(mut visible) = self.visible.lock() {
if visible.as_deref() == Some(id) {
*visible = None;
}
}
}
fn respond(&self, id: &str, answer: serde_json::Value) {
let _ = self.mailbox.respond(id, answer);
self.clear_visible(id);
}
fn cancel(&self, id: &str) {
let _ = self.mailbox.cancel(id);
self.clear_visible(id);
}
fn cancel_all(&self) {
self.mailbox.cancel_all();
if let Ok(mut visible) = self.visible.lock() {
*visible = None;
}
}
fn shutdown(&self) {
self.mailbox.detach();
if let Ok(mut visible) = self.visible.lock() {
*visible = None;
}
}
}
#[derive(Clone)]
struct AskUserPrompt {
question_id: String,
question: String,
header: Option<String>,
context: Option<String>,
options: Vec<(String, Option<String>)>,
allow_multiple: bool,
allow_freeform: bool,
suggest: Option<String>,
kind: String,
}
fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
value
.get(*key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string)
})
}
fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
keys.iter()
.find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
}
fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
let mut options = Vec::new();
let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
return options;
};
for item in items {
if let Some(title) = item.as_str() {
let title = title.trim();
if !title.is_empty() {
options.push((title.to_string(), None));
}
continue;
}
let Some(object) = item.as_object() else {
continue;
};
let title = ["title", "label", "text", "value", "name"]
.iter()
.find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|title| !title.is_empty());
let Some(title) = title else { continue };
let description = object
.get("description")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
options.push((title.to_string(), description));
}
options
}
fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
let ui = &request.ui;
let kind = ui
.get("kind")
.and_then(serde_json::Value::as_str)
.unwrap_or("selector")
.to_string();
let nested = ui
.get("questions")
.and_then(serde_json::Value::as_array)
.and_then(|items| items.first());
let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
let question = ask_user_str(source, &["question", "summary", "title"])
.or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
.unwrap_or_default();
let question_id = ask_user_str(source, &["id", "questionId"])
.or_else(|| ask_user_str(ui, &["id", "questionId"]))
.unwrap_or_else(|| request.request_id.clone());
let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
let context = ask_user_str(source, &["context", "message"])
.or_else(|| ask_user_str(ui, &["context", "message"]));
let mut options = ask_user_options(source);
if options.is_empty() {
options = ask_user_options(ui);
}
if kind == "confirm" && options.is_empty() {
options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
}
let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
.or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
.unwrap_or(false);
let allow_freeform =
ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
.or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
.unwrap_or(options.is_empty());
let suggest = ask_user_str(source, &["suggest", "placeholder"])
.or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
AskUserPrompt {
question_id,
question,
header,
context,
options,
allow_multiple,
allow_freeform,
suggest,
kind,
}
}
fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
let frame = Arc::new(Container::new());
frame.add_child(Arc::new(DynamicBorder::new()));
frame.add_child(Arc::new(Spacer::new(1)));
if let Some(header) = prompt.header.as_deref() {
frame.add_child(extension_dialog_title_md(header, true));
}
if !prompt.question.trim().is_empty() {
frame.add_child(extension_dialog_title_md(
&prompt.question,
prompt.header.is_none(),
));
}
if let Some(context) = prompt.context.as_deref() {
frame.add_child(extension_dialog_hint_md(context));
}
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(body);
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(extension_dialog_hint(hint));
frame.add_child(Arc::new(Spacer::new(1)));
frame.add_child(Arc::new(DynamicBorder::new()));
frame
}
fn open_ask_user_dialog(
ctx: &CommandContext,
bridge: AskUserBridge,
request: rpi_extensions::UiDialogRequest,
) {
let prompt = parse_ask_user_prompt(&request);
if prompt.kind == "input" || prompt.options.is_empty() {
open_ask_user_input(ctx, bridge, request, prompt);
} else {
open_ask_user_selector(ctx, bridge, request, prompt);
}
}
fn open_ask_user_selector(
ctx: &CommandContext,
bridge: AskUserBridge,
request: rpi_extensions::UiDialogRequest,
prompt: AskUserPrompt,
) {
let multi = prompt.allow_multiple && prompt.kind != "confirm";
let mut items: Vec<SelectItem> = Vec::new();
for (title, description) in &prompt.options {
let item = SelectItem::new(title, title);
let item = match description.as_deref() {
Some(description) => item.with_description(description),
None => item,
};
items.push(item);
}
if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
}
let list = if multi {
Arc::new(SelectList::new_multi(items, 10))
} else {
Arc::new(SelectList::new(items, 10))
};
let request_id = request.request_id.clone();
let state = ctx.state.clone();
let editor_container = ctx.editor_container.clone();
let editor = ctx.editor.clone();
let tui = ctx.tui.clone();
let bridge_select = bridge.clone();
let request_for_freeform = request.clone();
let prompt_for_freeform = AskUserPrompt {
question_id: prompt.question_id.clone(),
question: prompt.question.clone(),
header: prompt.header.clone(),
context: prompt.context.clone(),
options: Vec::new(),
allow_multiple: false,
allow_freeform: true,
suggest: prompt.suggest.clone(),
kind: "input".to_string(),
};
let state_select = state.clone();
let ec_select = editor_container.clone();
let editor_select = editor.clone();
let tui_select = tui.clone();
let ctx_select = ctx.clone();
list.on_select(Arc::new(move |item: &SelectItem| {
if item.value == ASK_USER_FREEFORM {
close_selector(&state_select, &ec_select, &editor_select, &tui_select);
open_ask_user_input(
&ctx_select,
bridge_select.clone(),
request_for_freeform.clone(),
prompt_for_freeform.clone(),
);
return;
}
bridge_select.respond(
&request_id,
serde_json::json!({
"values": [item.display_value()],
"kind": "selector",
}),
);
close_selector(&state_select, &ec_select, &editor_select, &tui_select);
}));
if multi {
let bridge_multi = bridge.clone();
let request_id_multi = request.request_id.clone();
let state_multi = state.clone();
let ec_multi = editor_container.clone();
let editor_multi = editor.clone();
let tui_multi = tui.clone();
list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
let values: Vec<String> = selected
.iter()
.map(|item| item.display_value().to_string())
.collect();
bridge_multi.respond(
&request_id_multi,
serde_json::json!({"values": values, "kind": "selector"}),
);
close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
}));
}
let bridge_cancel = bridge.clone();
let request_id_cancel = request.request_id.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 || {
bridge_cancel.cancel(&request_id_cancel);
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
let hint = if multi {
"Space toggle · Enter submit · Esc cancel"
} else {
"Enter select · Esc cancel"
};
let frame = ask_user_frame(&prompt, list.clone(), hint);
open_selector_with_view(
&state,
&editor_container,
&editor,
&tui,
SelectorView::List(list),
frame,
SelectorKind::Extension,
);
}
fn open_ask_user_input(
ctx: &CommandContext,
bridge: AskUserBridge,
request: rpi_extensions::UiDialogRequest,
prompt: AskUserPrompt,
) {
let placeholder = prompt
.suggest
.clone()
.unwrap_or_else(|| "Type your answer…".to_string());
let input = Arc::new(Input::with_placeholder(&placeholder));
input.set_focused(true);
let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
*ctx.state.active_extension_editor.lock().unwrap() = None;
*ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
ctx.editor_container.clear();
ctx.editor_container.add_child(frame);
ctx.tui.set_focus(Some(input.clone()));
ctx.tui.request_render(false);
let state = ctx.state.clone();
let ec = ctx.editor_container.clone();
let editor = ctx.editor.clone();
let tui = ctx.tui.clone();
let bridge_submit = bridge.clone();
let request_id = request.request_id.clone();
input.on_submit(Arc::new(move |text: &str| {
bridge_submit.respond(
&request_id,
serde_json::json!({
"values": [text],
"value": text,
"text": text,
"kind": "input",
}),
);
close_extension_editor(&state, &ec, &editor, &tui);
}));
let state_cancel = ctx.state.clone();
let ec_cancel = ctx.editor_container.clone();
let editor_cancel = ctx.editor.clone();
let tui_cancel = ctx.tui.clone();
let bridge_cancel = bridge.clone();
let request_id_cancel = request.request_id.clone();
*ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
bridge_cancel.cancel(&request_id_cancel);
close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
}
struct HelpCommand;
impl SlashCommand for HelpCommand {
fn name(&self) -> &'static str {
"/help"
}
fn aliases(&self) -> &'static [&'static str] {
&["/?"]
}
fn description(&self) -> &'static str {
"Show available commands"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_help_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct ClearChatCommand;
impl SlashCommand for ClearChatCommand {
fn name(&self) -> &'static str {
"/clear"
}
fn aliases(&self) -> &'static [&'static str] {
&["/new"]
}
fn alias_visible(&self) -> &'static [&'static str] {
&["/new"]
}
fn description(&self) -> &'static str {
"Clear the conversation"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ClearChat);
}
}
struct ExitCommand;
impl SlashCommand for ExitCommand {
fn name(&self) -> &'static str {
"/exit"
}
fn aliases(&self) -> &'static [&'static str] {
&["/quit", "/q"]
}
fn alias_visible(&self) -> &'static [&'static str] {
&["/quit"]
}
fn description(&self) -> &'static str {
"Exit the application"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
ctx.state.cancel_js_preparation();
let _ = ctx.tx.send(TuiMessage::Exit);
}
}
struct VersionCommand;
impl SlashCommand for VersionCommand {
fn name(&self) -> &'static str {
"/version"
}
fn aliases(&self) -> &'static [&'static str] {
&["/v"]
}
fn description(&self) -> &'static str {
"Show version information"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_version_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct ChangelogCommand;
impl SlashCommand for ChangelogCommand {
fn name(&self) -> &'static str {
"/changelog"
}
fn description(&self) -> &'static str {
"Show recent release changes"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_changelog_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct HotkeysCommand;
impl SlashCommand for HotkeysCommand {
fn name(&self) -> &'static str {
"/hotkeys"
}
fn description(&self) -> &'static str {
"Show keyboard shortcuts"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_hotkeys_message(&ctx.chat);
ctx.tui.request_render(false);
}
}
struct ModelCommand;
impl SlashCommand for ModelCommand {
fn name(&self) -> &'static str {
"/model"
}
fn aliases(&self) -> &'static [&'static str] {
&["/m"]
}
fn description(&self) -> &'static str {
"Choose a model (selector)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let term = args.trim();
if !term.is_empty() {
if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
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,
(!term.is_empty()).then_some(term),
);
}
}
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 (supports: md, html, jsonl)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let format = args.trim().to_lowercase();
if format.is_empty() || format == "md" || format == "markdown" {
let _ = ctx.tx.send(TuiMessage::ExportSession);
} else if format == "html" {
let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
crate::export::ExportFormat::Html,
));
} else if format == "jsonl" {
let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
crate::export::ExportFormat::Jsonl,
));
} else {
add_error_message(
&ctx.chat,
&format!(
"Unknown export format: {}. Supported: md, html, jsonl",
args.trim()
),
);
ctx.tui.request_render(false);
}
}
}
struct ForkCommand;
impl SlashCommand for ForkCommand {
fn name(&self) -> &'static str {
"/fork"
}
fn description(&self) -> &'static str {
"Fork the session into a new one"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ForkSession);
}
}
struct CloneCommand;
impl SlashCommand for CloneCommand {
fn name(&self) -> &'static str {
"/clone"
}
fn description(&self) -> &'static str {
"Duplicate the current session"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ForkSession);
}
}
struct TreeCommand;
impl SlashCommand for TreeCommand {
fn name(&self) -> &'static str {
"/tree"
}
fn description(&self) -> &'static str {
"Navigate the current session tree"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::OpenTree);
}
}
struct LoginCommand;
impl SlashCommand for LoginCommand {
fn name(&self) -> &'static str {
"/login"
}
fn description(&self) -> &'static str {
"Save an Anthropic API key"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let key = args.trim();
if key.is_empty() {
add_note_message(&ctx.chat, "Usage: /login <api-key>");
} else {
let result = crate::config::upsert_credential(
"anthropic",
crate::config::Credential::ApiKey {
key: Some(key.to_string()),
env: None,
},
);
match result {
Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
Err(error) => {
add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
}
}
}
ctx.tui.request_render(false);
}
}
struct LogoutCommand;
impl SlashCommand for LogoutCommand {
fn name(&self) -> &'static str {
"/logout"
}
fn description(&self) -> &'static str {
"Remove saved Anthropic credentials"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
match crate::config::delete_credential("anthropic") {
Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
Err(error) => {
add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
}
}
ctx.tui.request_render(false);
}
}
fn set_project_trust_for_command(
cwd: &std::path::Path,
value: Option<bool>,
) -> Result<(), crate::config::ConfigError> {
crate::config::set_project_trust(cwd, value)
}
struct TrustCommand;
impl SlashCommand for TrustCommand {
fn name(&self) -> &'static str {
"/trust"
}
fn description(&self) -> &'static str {
"Trust the current project"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let value = match args.trim().to_ascii_lowercase().as_str() {
"" | "yes" | "y" | "true" => Some(true),
"no" | "n" | "false" => Some(false),
"clear" | "reset" | "none" => None,
_ => {
add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
ctx.tui.request_render(false);
return;
}
};
match set_project_trust_for_command(&ctx.cwd, value) {
Ok(()) => {
let label = match value {
Some(true) => "trusted",
Some(false) => "untrusted",
None => "trust decision cleared",
};
add_note_message(&ctx.chat, &format!("Current project marked {label}."));
}
Err(error) => add_error_message(
&ctx.chat,
&format!("Could not save trust decision: {error}"),
),
}
ctx.tui.request_render(false);
}
}
struct NameCommand;
impl SlashCommand for NameCommand {
fn name(&self) -> &'static str {
"/name"
}
fn description(&self) -> &'static str {
"Set session display name"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let name = args.trim();
if name.is_empty() {
add_note_message(
&ctx.chat,
"Usage: /name <display name> — sets the current session's name.",
);
ctx.tui.request_render(false);
return;
}
let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
}
}
struct ImportCommand;
impl SlashCommand for ImportCommand {
fn name(&self) -> &'static str {
"/import"
}
fn description(&self) -> &'static str {
"Import a session file (path)"
}
fn execute(&self, ctx: &CommandContext, args: &str) {
let path = args.trim();
if path.is_empty() {
add_note_message(
&ctx.chat,
"Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
);
ctx.tui.request_render(false);
return;
}
let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
}
}
struct SettingsCommand;
impl SlashCommand for SettingsCommand {
fn name(&self) -> &'static str {
"/settings"
}
fn description(&self) -> &'static str {
"Open settings menu"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
open_settings_selector(
&ctx.state,
&ctx.editor_container,
&ctx.editor,
&ctx.tui,
&ctx.lane,
&ctx.model_catalog,
&ctx.lane_model_id,
&ctx.chat,
);
}
}
struct ScopedModelsCommand;
impl SlashCommand for ScopedModelsCommand {
fn name(&self) -> &'static str {
"/scoped-models"
}
fn description(&self) -> &'static str {
"Choose models for Ctrl+P 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 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);
let _ = ctx.tx.send(TuiMessage::ShowUsage);
ctx.tui.request_render(false);
}
}
struct UsageCommand;
impl SlashCommand for UsageCommand {
fn name(&self) -> &'static str {
"/usage"
}
fn description(&self) -> &'static str {
"Show token, cost, and cache totals for this session"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
let _ = ctx.tx.send(TuiMessage::ShowUsage);
ctx.tui.request_render(false);
}
}
struct ReloadCommand;
impl SlashCommand for ReloadCommand {
fn name(&self) -> &'static str {
"/reload"
}
fn description(&self) -> &'static str {
"Reload extensions, skills, prompts"
}
fn execute(&self, ctx: &CommandContext, _args: &str) {
add_note_message(&ctx.chat, "Reloading extensions + resources…");
ctx.tui.request_render(false);
let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
}
}
fn build_builtin_registry() -> CommandRegistry {
let mut r = CommandRegistry::new();
r.register(Arc::new(HelpCommand));
r.register(Arc::new(ClearChatCommand));
r.register(Arc::new(ExitCommand));
r.register(Arc::new(VersionCommand));
r.register(Arc::new(ChangelogCommand));
r.register(Arc::new(ModelCommand));
r.register(Arc::new(ThinkingCommand));
r.register(Arc::new(ToolsCommand));
r.register(Arc::new(ImagesCommand));
r.register(Arc::new(SessionCommand));
r.register(Arc::new(ThemeCommand));
r.register(Arc::new(CompactCommand));
r.register(Arc::new(CopyCommand));
r.register(Arc::new(HotkeysCommand));
r.register(Arc::new(ContextCommand));
r.register(Arc::new(UsageCommand));
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),
OpenTree,
NavigateTree(String),
Exit,
ClearChat,
Compact,
Copy,
SwitchSession(String),
ExportSession,
ExportSessionWithFormat(crate::export::ExportFormat),
ShowUsage,
ForkSession,
SetSessionName(String),
ImportSession(String),
ShareSession,
ReloadExtensions,
ExternalEditorResult(Result<String, String>),
UserBashFinished(UserBashReport),
}
struct UserBashReport {
command: String,
output: String,
exit_code: Option<i32>,
cancelled: bool,
truncated: bool,
full_output_path: Option<String>,
exclude_from_context: bool,
}
fn assistant_text(msg: &AssistantMessage) -> String {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect()
}
fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
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 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 items = settings_menu_items(&settings, state, lane_model_id);
let list = Arc::new(SettingsList::new(items));
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 catalog_sel = catalog.to_vec();
let lane_model_sel = lane_model_id.to_string();
let chat_sel = chat.clone();
list.on_select(Arc::new(move |item| match item.key.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_change = state.clone();
let chat_change = chat.clone();
let tui_change = tui.clone();
list.on_change(Arc::new(move |key, value| {
let mut settings = crate::settings::load_settings().unwrap_or_default();
let applied = apply_setting_change(&state_change, key, value, &mut settings);
let saved = crate::settings::save_settings(&settings);
if let Some(note) = applied {
let suffix = if saved.is_ok() { "" } else { " (not saved)" };
add_note_message(&chat_change, &format!("{note}{suffix}"));
}
tui_change.request_render(false);
}));
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_with_view(
state,
editor_container,
editor,
tui,
SelectorView::Settings(list.clone()),
list,
SelectorKind::Settings,
);
}
fn settings_menu_items(
settings: &crate::settings::Settings,
state: &Arc<TuiState>,
lane_model_id: &str,
) -> Vec<SettingItem> {
let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
let current_model = settings
.default_model
.clone()
.unwrap_or_else(|| lane_model_id.to_string());
let current_thinking = settings
.default_thinking_level
.clone()
.unwrap_or_else(|| "(default)".to_string());
let scoped_desc = match &settings.scoped_models {
Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
_ => "all models".to_string(),
};
let hide_thinking = state.hide_thinking();
let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
let http_idle = settings
.http_idle_timeout_ms()
.map(|ms| {
if ms == 0 {
"disabled".to_string()
} else {
format!("{}s", ms / 1000)
}
})
.unwrap_or_else(|| "5m".to_string());
vec![
SettingItem::new("theme", "Theme", ¤t_theme)
.with_description("Color theme for the interface")
.with_submenu(),
SettingItem::new("model", "Default model", ¤t_model)
.with_description("Saved default model for new sessions")
.with_submenu(),
SettingItem::new("thinking", "Default thinking", ¤t_thinking)
.with_description("Saved default thinking level")
.with_submenu(),
SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
.with_description("Models enabled for Ctrl+P cycling")
.with_submenu(),
SettingItem::new(
"hide-thinking",
"Hide thinking",
bool_setting_value(hide_thinking),
)
.with_description("Hide thinking blocks in assistant responses")
.with_values(&["true", "false"]),
SettingItem::new(
"show-images",
"Show images",
bool_setting_value(show_images),
)
.with_description("Render images inline in the terminal")
.with_values(&["true", "false"]),
SettingItem::new(
"cache-miss-notices",
"Cache miss notices",
bool_setting_value(show_cache_miss),
)
.with_description("Transcript notices for prompt-cache costs")
.with_values(&["true", "false"]),
SettingItem::new(
"quiet-startup",
"Quiet startup",
bool_setting_value(settings.quiet_startup.unwrap_or(false)),
)
.with_description("Disable the verbose startup listing (applies on restart)")
.with_values(&["true", "false"]),
SettingItem::new(
"terminal-progress",
"Terminal progress",
bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
)
.with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
.with_values(&["true", "false"]),
SettingItem::new(
"fullscreen-copy-on-select",
"Fullscreen copy on select",
bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
)
.with_description("Copy selected text automatically in fullscreen mode")
.with_values(&["true", "false"]),
SettingItem::new(
"double-escape-action",
"Double-escape action",
settings
.double_escape_action
.clone()
.unwrap_or_else(|| "tree".to_string())
.as_str(),
)
.with_description("Esc Esc with an empty editor (applies on restart)")
.with_values(&["tree", "fork", "none"]),
SettingItem::new(
"editor-padding",
"Editor padding",
settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
)
.with_description("Horizontal editor padding, 0-3 (applies on restart)")
.with_values(&["0", "1", "2", "3"]),
SettingItem::new(
"autocomplete-max-items",
"Autocomplete max items",
settings
.autocomplete_max_visible
.unwrap_or(5)
.to_string()
.as_str(),
)
.with_description("Max autocomplete rows, 3-20 (applies on restart)")
.with_values(&["3", "5", "7", "10", "15", "20"]),
SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
.with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
.with_values(&["30s", "1m", "5m", "disabled"]),
]
}
fn bool_setting_value(value: bool) -> &'static str {
if value {
"true"
} else {
"false"
}
}
fn apply_setting_change(
state: &Arc<TuiState>,
key: &str,
value: &str,
settings: &mut crate::settings::Settings,
) -> Option<String> {
let as_bool = || value == "true";
match key {
"hide-thinking" => {
state.set_hide_thinking(as_bool());
settings.hide_thinking_block = Some(as_bool());
Some(format!(
"Thinking blocks {}.",
if as_bool() { "hidden" } else { "shown" }
))
}
"show-images" => {
if let Ok(mut guard) = state.show_images.lock() {
*guard = as_bool();
}
settings.show_images = Some(as_bool());
Some(format!(
"Inline images {}.",
if as_bool() { "enabled" } else { "disabled" }
))
}
"cache-miss-notices" => {
if let Ok(mut guard) = state.cache_miss_notices.lock() {
*guard = as_bool();
}
settings.show_cache_miss_notices = Some(as_bool());
Some(format!(
"Cache miss notices {}.",
if as_bool() { "enabled" } else { "disabled" }
))
}
"fullscreen-copy-on-select" => {
settings.fullscreen_copy_on_select = Some(as_bool());
Some(format!(
"Copy on select {}.",
if as_bool() { "enabled" } else { "disabled" }
))
}
"quiet-startup" => {
settings.quiet_startup = Some(as_bool());
None
}
"terminal-progress" => {
settings.show_terminal_progress = Some(as_bool());
None
}
"double-escape-action" => {
settings.double_escape_action = Some(value.to_string());
None
}
"editor-padding" => {
settings.editor_padding_x = value.parse().ok();
None
}
"autocomplete-max-items" => {
settings.autocomplete_max_visible = value.parse().ok();
None
}
"http-idle-timeout" => {
settings.http_idle_timeout = Some(match value {
"disabled" => serde_json::Value::String("disabled".to_string()),
"30s" => serde_json::json!(30_000),
"1m" => serde_json::json!(60_000),
_ => serde_json::json!(300_000),
});
None
}
_ => None,
}
}
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" => Some(ThemePreset::Light),
"monochrome" => Some(ThemePreset::Monochrome),
"dark" => Some(ThemePreset::Dark),
name => {
add_error_message(&chat_sel, &format!("Unknown theme `{name}`."));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
return;
}
};
let Some(preset) = preset else { return };
apply_theme_preset(preset);
state_sel.theme_manager.apply_preset(preset);
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.theme = Some(item.value.clone());
let saved = crate::settings::save_settings(&settings);
add_note_message(
&chat_sel,
&format!(
"Theme set to {} (saved{})",
item.label,
if saved.is_ok() { "" } else { ", not saved" },
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Settings,
);
}
fn open_settings_model_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
lane_model_id: &str,
chat: &Arc<Container>,
) {
let items = model_selector_items(catalog, lane_model_id);
if items.is_empty() {
add_note_message(chat, "No models in the catalog.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let catalog_arc = catalog.to_vec();
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
return;
};
state_sel.set_current_model(&model);
let lane = lane_sel.clone();
tokio::spawn(async move {
let _ = lane.set_model(model).await;
});
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.default_model = Some(item.value.clone());
let saved = crate::settings::save_settings(&settings);
add_note_message(
&chat_sel,
&format!(
"Default model set to {} (saved{}",
short_model_name(&item.value),
if saved.is_ok() { ")" } else { ", not saved)" },
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_searchable_selector(
state,
editor_container,
editor,
tui,
Some("Default model"),
Some("Type to filter by name, provider, or id"),
list,
SelectorKind::Settings,
);
}
fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
let mut seen = std::collections::HashSet::new();
catalog
.iter()
.filter(|m| {
seen.insert((
m.api.clone(),
m.provider.to_ascii_lowercase(),
m.id.to_ascii_lowercase(),
))
})
.map(|m| {
let label = if m.name.is_empty() {
short_model_name(&m.id)
} else {
m.name.clone()
};
let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
&& m.provider.eq_ignore_ascii_case("anthropic")
{
m.id.clone()
} else {
format!("{}/{}", m.provider, m.id)
};
let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
" (current)"
} else {
""
};
let name = if m.name.is_empty() {
String::new()
} else {
format!(" {}", m.name)
};
let search_text = format!(
"{} {}/{} {} {}{}",
m.provider, m.provider, m.id, m.provider, m.id, name
);
SelectItem::new(&m.id, &label)
.with_description(&format!("{identity}{marker}"))
.with_search_text(&search_text)
})
.collect()
}
fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
let (provider, id) = input
.split_once('/')
.filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
.map_or((None, input), |(provider, id)| (Some(provider), id));
catalog
.iter()
.find(|model| {
model.id.eq_ignore_ascii_case(id)
&& provider.map_or(true, |provider| {
model.provider.eq_ignore_ascii_case(provider)
|| (provider.eq_ignore_ascii_case("anthropic")
&& matches!(model.api, rpi_ai::Api::AnthropicMessages))
})
})
.cloned()
}
fn open_settings_thinking_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
lane_model_id: &str,
chat: &Arc<Container>,
) {
let model = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
let levels: Vec<rpi_ai::types::ThinkingLevel> = model
.map(|m| m.supported_thinking_levels())
.unwrap_or_else(|| {
use rpi_ai::types::ThinkingLevel::*;
vec![Off, Minimal, Low, Medium, High]
});
let mut items: Vec<SelectItem> = Vec::new();
for lvl in &levels {
let name = thinking_level_name(*lvl);
items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
}
if items.is_empty() {
add_note_message(chat, "This model has no supported thinking levels.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let Some(level) = thinking_level_from_name(&item.value) else {
add_note_message(
&chat_sel,
&format!("Unknown thinking level: {}.", item.label),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
return;
};
let lane = lane_sel.clone();
let footer_sel = state_sel.footer.clone();
tokio::spawn(async move {
let _ = lane.set_thinking_level(level).await;
});
footer_sel.set_thinking_level(Some(thinking_level_name(level)));
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.default_thinking_level = Some(item.value.clone());
let saved = crate::settings::save_settings(&settings);
add_note_message(
&chat_sel,
&format!(
"Default thinking set to {} (saved{}",
item.label,
if saved.is_ok() { ")" } else { ", not saved)" },
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Settings,
);
}
fn open_scoped_models_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
catalog: &[rpi_ai::Model],
chat: &Arc<Container>,
) {
if catalog.is_empty() {
add_note_message(chat, "No models in the catalog.");
tui.request_render(false);
return;
}
let seed: Vec<String> = crate::settings::load_settings()
.ok()
.and_then(|s| s.scoped_models)
.unwrap_or_default();
*state.scoped_edit.lock().unwrap() = Some(seed);
let mut items: Vec<SelectItem> = Vec::new();
for m in catalog {
items.push(SelectItem::new(&m.id, &m.id));
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let chat_sel = chat.clone();
let tui_sel = tui.clone();
list.on_select(Arc::new(move |item| {
let mut set = state_sel.scoped_edit.lock().unwrap();
let set = set.get_or_insert_with(Vec::new);
if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
set.remove(pos);
add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
} else {
set.push(item.value.clone());
add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
}
tui_sel.request_render(false);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
let chat_cancel = chat.clone();
list.on_cancel(Arc::new(move || {
let set = state_cancel
.scoped_edit
.lock()
.unwrap()
.take()
.unwrap_or_default();
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.scoped_models = if set.is_empty() {
None
} else {
Some(set.clone())
};
match crate::settings::save_settings(&settings) {
Ok(()) => {
if set.is_empty() {
add_note_message(&chat_cancel, "Ctrl+P cycles all models (scope cleared).");
} else {
add_note_message(
&chat_cancel,
&format!("Ctrl+P cycle scope: {}", set.join(", ")),
);
}
}
Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
}
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::ScopedModels,
);
}
async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
use std::process::Stdio;
let tree = harness.session().view("main");
let entries = match tree
.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: Some(EntryOrder::OldestFirst),
limit: None,
cursor: None,
})
.await
{
Ok(e) => e,
Err(e) => {
add_error_message(chat, &format!("Could not read session: {e}"));
return;
}
};
let mut md = String::from("# Session\n\n");
for e in entries {
let Entry::Message(me) = e else { continue };
match &me.message {
AgentMessage::User(u) => {
md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
}
AgentMessage::Assistant(a) => {
let text = assistant_text(a);
if !text.is_empty() {
md.push_str(&format!("## Assistant\n\n{}\n\n", text));
}
}
_ => {}
}
}
let gh = std::process::Command::new("gh")
.arg("gist")
.arg("create")
.arg("--filename")
.arg("session.md")
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = gh {
use std::io::Write;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(md.as_bytes());
let _ = stdin.flush();
}
let out = child.wait_with_output().ok();
if let Some(out) = out {
if out.status.success() {
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
add_note_message(chat, &format!("Shared session: {url}"));
return;
}
}
add_note_message(chat, "gh gist failed — falling back to the clipboard.");
} else {
add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
}
if copy_to_clipboard(&md) {
add_note_message(chat, "Session transcript copied to the clipboard.");
} else {
add_note_message(
chat,
"Clipboard unavailable — use /export to write the transcript to a file.",
);
}
}
async fn switch_to_session(
harness: &AgentHarness,
lane: &Arc<dyn AgentLane>,
id: &str,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) -> bool {
if *state.status.lock().unwrap() == RunStatus::Working {
state.set_status(RunStatus::Aborting);
let _ = lane.abort().await;
}
let cwd_str = cwd.to_string_lossy().to_string();
match crate::session::open_session_by_id(id, &cwd_str).await {
Ok(new_session) => {
let _ = harness.set_session(new_session).await;
chat.clear();
add_welcome_message(chat);
render_session_history(
harness,
chat,
state.markdown_transformer(),
Some(state.extension_session.clone()),
)
.await;
state.set_status(RunStatus::Idle);
add_note_message(chat, &format!("Switched to session {id}."));
true
}
Err(e) => {
state.set_status(RunStatus::Idle);
add_error_message(chat, &format!("Could not open session {id}: {e}"));
false
}
}
}
async fn import_session(
harness: &AgentHarness,
lane: &Arc<dyn AgentLane>,
path: &str,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) {
use std::path::Path as FsPath;
let src = FsPath::new(path);
if !src.is_file() {
add_error_message(chat, &format!("Import source not found: {path}"));
return;
}
let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
add_error_message(chat, "Import source has no file name.");
return;
};
if !fname.ends_with(".jsonl") {
add_error_message(chat, "Import source must be a .jsonl session file.");
return;
}
let dir = crate::session::default_session_dir(cwd);
if let Err(e) = std::fs::create_dir_all(&dir) {
add_error_message(chat, &format!("Could not create session dir: {e}"));
return;
}
let dest = dir.join(fname);
match std::fs::copy(src, &dest) {
Ok(_) => {
let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
if switch_to_session(harness, lane, &id, cwd, chat, state).await {
add_note_message(chat, &format!("Imported session from {path}"));
}
}
Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
}
}
async fn fork_session(
harness: &AgentHarness,
cwd: &std::path::Path,
chat: &Arc<Container>,
state: &Arc<TuiState>,
) {
use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
use rpi_tools::FileSystem;
let cwd_str = cwd.to_string_lossy().to_string();
let dir = crate::session::default_session_dir(cwd);
let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
let fs: Arc<dyn FileSystem> = env.clone();
let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
fs,
sessions_root: dir.to_string_lossy().into_owned(),
clock: Arc::new(rpi_harness::session::memory::SystemClock),
ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
});
let id = harness.session().storage().metadata().id.clone();
let metas = match crate::session::list_session_metadata(&cwd_str).await {
Ok(m) => m,
Err(e) => {
add_error_message(chat, &format!("Could not list sessions: {e}"));
return;
}
};
let Some(source) = metas.iter().find(|m| m.id == id) else {
add_error_message(chat, &format!("Current session {id} not found on disk."));
return;
};
let fork_storage = match repo
.fork_typed(
source,
&rpi_harness::session::jsonl::JsonlSessionCreateOptions {
id: None,
parent_session_id: Some(source.id.clone()),
cwd: cwd_str.clone(),
metadata: None,
},
&rpi_harness::session::types::ForkOptions::default(),
)
.await
{
Ok(s) => s,
Err(e) => {
add_error_message(chat, &format!("Could not fork session: {e}"));
return;
}
};
let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
let _ = harness.set_session(new_session).await;
chat.clear();
add_welcome_message(chat);
render_session_history(
harness,
chat,
state.markdown_transformer(),
Some(state.extension_session.clone()),
)
.await;
state.set_status(RunStatus::Idle);
add_note_message(chat, "Forked into a new session.");
}
async fn render_session_history(
harness: &AgentHarness,
chat: &Arc<Container>,
transformer: Option<MarkdownTransformer>,
extension_session: Option<crate::session::ExtensionSessionCell>,
) {
let tree = harness.session().view("main");
let entries = match tree
.find_entries(&EntryQuery {
entry_type: None,
custom_type: None,
order: Some(EntryOrder::OldestFirst),
limit: None,
cursor: None,
})
.await
{
Ok(e) => e,
Err(_) => return,
};
let mut rendered_any = false;
for e in entries {
match e {
Entry::Message(me) => match &me.message {
AgentMessage::User(u) => {
add_user_message(chat, &user_message_text(u));
rendered_any = true;
}
AgentMessage::Assistant(a) => {
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(t) = &transformer {
comp.set_markdown_transformer(Some(t.clone()));
}
comp.update_blocks(&assistant_blocks(a));
chat.add_child(comp);
chat.add_child(Arc::new(Spacer::new(1)));
if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
add_note_message(chat, &text);
}
if let Some(error) = assistant_error_text(a) {
add_error_message(chat, &error);
}
rendered_any = true;
}
AgentMessage::ToolResult(result) => {
let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
comp.set_result(&tool_result_message_text(result), result.is_error);
if tool_result_requests_markdown(result.details.as_ref()) {
comp.set_result_markdown(true);
}
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_usage_text(
session: Option<&crate::session::ExtensionSessionCell>,
usage: &rpi_ai::types::Usage,
) -> Option<String> {
let session = session?;
let payload = serde_json::json!({
"customType": "token-usage",
"usage": usage,
});
let value = invoke_extension_renderer(
session,
rpi_extensions::RegisteredRendererKind::Message,
&payload,
)?;
value
.get("text")
.and_then(|value| value.as_str())
.filter(|text| !text.trim().is_empty())
.map(ToOwned::to_owned)
}
fn extension_entry_component(
session: &crate::session::ExtensionSessionCell,
custom_type: &str,
data: Option<serde_json::Value>,
) -> Option<Arc<dyn rpi_tui::Component>> {
let payload = serde_json::json!({
"customType": custom_type,
"data": data,
});
let value = invoke_extension_renderer(
session,
rpi_extensions::RegisteredRendererKind::Entry,
&payload,
)?;
extension_text_component(&value)
}
fn launch_restores_history(args: &Args) -> bool {
args.continue_session
|| args.resume
|| args.session.is_some()
|| args.session_id.is_some()
|| args.fork.is_some()
}
fn 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 find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
let mut dir = Some(cwd);
while let Some(current) = dir {
let head = current.join(".git").join("HEAD");
if head.exists() {
return Some(head);
}
dir = current.parent();
}
None
}
fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
let mut dir = Some(cwd);
while let Some(current) = dir {
let head = current.join(".git").join("HEAD");
if let Ok(contents) = std::fs::read_to_string(&head) {
let trimmed = contents.trim();
if let Some(reference) = trimmed.strip_prefix("ref: ") {
let branch = reference
.rsplit('/')
.next()
.unwrap_or(reference)
.trim()
.to_string();
if !branch.is_empty() {
return Some(branch);
}
}
if !trimmed.is_empty() {
return Some(trimmed.chars().take(7).collect());
}
}
dir = current.parent();
}
None
}
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,
}
#[derive(Clone)]
enum SelectorView {
List(Arc<SelectList>),
Searchable(Arc<SearchableSelectList>),
Settings(Arc<SettingsList>),
}
impl SelectorView {
fn handle_key(&self, key: KeyEvent) {
match self {
Self::List(list) => list.handle_key(key),
Self::Searchable(searchable) => searchable.handle_key(key),
Self::Settings(settings) => settings.handle_key(key),
}
}
}
#[derive(Debug, Clone)]
struct AutoSendPending {
deadline: std::time::Instant,
text: String,
}
#[derive(Debug, PartialEq, Eq)]
enum AutoSendStep {
Idle,
Cancel,
Submit,
Wait(std::time::Duration),
}
fn auto_send_step(
pending: Option<&AutoSendPending>,
current_text: &str,
idle: bool,
now: std::time::Instant,
) -> AutoSendStep {
let Some(pending) = pending else {
return AutoSendStep::Idle;
};
if current_text != pending.text || current_text.trim().is_empty() {
return AutoSendStep::Cancel;
}
if !idle {
return AutoSendStep::Cancel;
}
let remaining = pending.deadline.saturating_duration_since(now);
if remaining.is_zero() {
AutoSendStep::Submit
} else {
AutoSendStep::Wait(remaining)
}
}
struct TuiState {
current_assistant: Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
tool_components: Arc<std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
bash_components: Arc<std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
hide_thinking: Arc<std::sync::Mutex<bool>>,
tool_outputs_expanded: Arc<std::sync::Mutex<bool>>,
show_terminal_progress: bool,
status: std::sync::Mutex<RunStatus>,
ext_status: rpi_extensions::ExtensionStatusMailbox,
ext_status_revision: std::sync::atomic::AtomicU64,
editor_text: rpi_extensions::EditorTextMailbox,
auto_send: std::sync::Mutex<Option<AutoSendPending>>,
last_editor_text: std::sync::Mutex<String>,
programmatic_editor_write: std::sync::atomic::AtomicBool,
js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
footer: Arc<FooterComponent>,
status_container: Arc<Container>,
chat_container: Arc<Container>,
loader: Arc<Loader>,
editor: Arc<Editor>,
last_assistant_text: std::sync::Mutex<String>,
active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
autocomplete: AutocompleteManager,
autocomplete_container: Arc<Container>,
autocomplete_max_visible: usize,
pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
theme_manager: Arc<ThemeManager>,
tui: Option<Arc<TuiAltScreen>>,
current_model_id: std::sync::Mutex<String>,
show_images: std::sync::Mutex<bool>,
cache_miss_notices: std::sync::Mutex<bool>,
history: std::sync::Mutex<Vec<String>>,
history_index: std::sync::Mutex<isize>,
history_draft: std::sync::Mutex<Option<String>>,
cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
markdown_transformer: Arc<std::sync::Mutex<Option<MarkdownTransformer>>>,
extension_session: crate::session::ExtensionSessionCell,
search: Arc<AltScreenSearch>,
search_bar: Arc<SearchBar>,
pending_container: Arc<Container>,
dequeue_hint: String,
pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
selection_start: std::sync::Mutex<Option<(u16, u16)>>,
selection_end: std::sync::Mutex<Option<(u16, u16)>>,
}
const HISTORY_LIMIT: usize = 100;
const AUTO_SEND_PREFIX: &str = "Auto-send in ";
const PAGE_SCROLL_OVERLAP: usize = 4;
const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
let mut modifiers = KeyModifiers::NONE;
let mut key = None;
for part in value.trim().to_ascii_lowercase().split('+') {
match part {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"shift" => modifiers |= KeyModifiers::SHIFT,
"alt" | "option" => modifiers |= KeyModifiers::ALT,
"super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
part if !part.is_empty() => key = Some(part.to_string()),
_ => {}
}
}
let key = key?;
let code = match key.as_str() {
"esc" | "escape" => KeyCode::Esc,
"enter" | "return" => KeyCode::Enter,
"tab" => {
if modifiers.contains(KeyModifiers::SHIFT) {
return Some(rpi_tui::KeyCombo::new(
KeyCode::BackTab,
modifiers & !KeyModifiers::SHIFT,
));
}
KeyCode::Tab
}
"backspace" | "back" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"up" | "arrowup" => KeyCode::Up,
"down" | "arrowdown" => KeyCode::Down,
"left" | "arrowleft" => KeyCode::Left,
"right" | "arrowright" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "page-up" => KeyCode::PageUp,
"pagedown" | "page-down" => KeyCode::PageDown,
"space" => KeyCode::Char(' '),
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
_ => return None,
};
Some(rpi_tui::KeyCombo::new(code, modifiers))
}
fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
let mut bindings = rpi_tui::Keybindings::new();
let settings = crate::settings::load_settings().unwrap_or_default();
let Some(overrides) = settings.keybindings else {
rpi_tui::set_keybindings(bindings.clone());
return Arc::new(bindings);
};
let known: &[(&str, rpi_tui::KeybindingId)] = &[
("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
("app.clear", rpi_tui::keybindings::keys::CLEAR),
("app.exit", rpi_tui::keybindings::keys::EXIT),
("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
(
"app.model.cycleForward",
rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
),
("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
(
"app.thinking.toggle",
rpi_tui::keybindings::keys::THINKING_TOGGLE,
),
(
"app.editor.external",
rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
),
(
"app.thinking.cycle",
rpi_tui::keybindings::keys::THINKING_CYCLE,
),
(
"app.clipboard.pasteImage",
rpi_tui::keybindings::keys::PASTE_IMAGE,
),
("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
];
for (name, id) in known {
let Some(value) = overrides.get(*name) else {
continue;
};
let values: Vec<String> = match value {
serde_json::Value::String(value) => vec![value.clone()],
serde_json::Value::Array(values) => values
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
serde_json::Value::Null => Vec::new(),
_ => continue,
};
let combos: Vec<_> = values
.iter()
.filter_map(|value| parse_configured_key(value))
.collect();
if values.is_empty() || !combos.is_empty() {
bindings.set(id, combos);
}
}
rpi_tui::set_keybindings(bindings.clone());
Arc::new(bindings)
}
fn keybinding_matches(
bindings: &rpi_tui::Keybindings,
event: &crossterm::event::KeyEvent,
id: rpi_tui::KeybindingId,
) -> bool {
if bindings.matches(event, id) {
return true;
}
if event.code == KeyCode::BackTab {
let normalized =
crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
bindings.matches(&normalized, id)
} else {
false
}
}
fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
if keys.is_empty() {
return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
}
keys.iter()
.map(|combo| combo.display())
.collect::<Vec<_>>()
.join("/")
}
fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
last.is_some_and(|previous| {
now.duration_since(previous) <= std::time::Duration::from_millis(500)
})
}
#[derive(Debug, PartialEq, Eq)]
enum CtrlCAction {
Clear,
Exit,
}
fn ctrl_c_action(last_sigint: Option<std::time::Instant>, now: std::time::Instant) -> CtrlCAction {
if double_escape_trigger(last_sigint, now) {
CtrlCAction::Exit
} else {
CtrlCAction::Clear
}
}
const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
#[derive(Default)]
struct WindowsPasteRun {
text: String,
last_at: Option<std::time::Instant>,
burst: bool,
}
impl WindowsPasteRun {
fn grace(&self) -> std::time::Duration {
std::time::Duration::from_millis(if self.burst { 120 } else { 15 })
}
fn feed(&mut self, ch: char, now: std::time::Instant) {
if self
.last_at
.is_some_and(|at| now.duration_since(at) <= PASTE_BURST_GAP)
{
self.burst = true;
}
self.text.push(ch);
self.last_at = Some(now);
}
fn flush(&mut self, editor: &Editor) {
if !self.text.is_empty() {
editor.handle_paste(&self.text);
}
*self = Self::default();
}
}
fn windows_enter_in_paste(
run: &WindowsPasteRun,
more_queued: bool,
clipboard: Option<&str>,
) -> bool {
if more_queued {
return true;
}
if run.text.is_empty() {
return false;
}
run.burst
&& clipboard.is_some_and(|text| {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let prefix = format!("{}\n", run.text);
normalized.starts_with(&prefix) && normalized.matches('\n').count() >= 10
})
}
#[cfg(test)]
fn enter_is_paste_burst(
last_text_key_at: Option<std::time::Instant>,
now: std::time::Instant,
more_queued: bool,
) -> bool {
if more_queued {
return true;
}
last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
}
fn probe_paste_input(
pending: Option<Event>,
mut next_event: impl FnMut() -> Option<Event>,
) -> (bool, Option<Event>) {
let mut pending = pending;
let mut more_queued = pending.is_some();
while !more_queued {
match next_event() {
Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
Some(ev) => {
pending = Some(ev);
more_queued = true;
}
None => break,
}
}
(more_queued, pending)
}
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 emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
tui.stop(Default::default());
eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
std::process::exit(130);
}
#[cfg(unix)]
fn handle_suspend(tui: &Arc<TuiAltScreen>) {
tui.suspend();
let _ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
tui.resume();
}
#[cfg(not(unix))]
fn handle_suspend(_tui: &Arc<TuiAltScreen>) {}
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 submit_draft(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>, text: &str) {
if text.trim().is_empty() || !state.try_start_working() {
return;
}
add_user_message(&state.chat_container, text);
if let Some(tui) = &state.tui {
if let Some(scroll) = tui.get_primary_scroll_view() {
scroll.scroll_to_end();
}
tui.request_render(false);
}
push_history(state, text);
let _ = tx.send(TuiMessage::UserInput(text.to_string()));
}
fn poll_auto_send(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>) -> bool {
let pending = state.auto_send.lock().unwrap().clone();
let step = auto_send_step(
pending.as_ref(),
&state.editor.get_text(),
*state.status.lock().unwrap() == RunStatus::Idle,
std::time::Instant::now(),
);
match step {
AutoSendStep::Idle => false,
AutoSendStep::Cancel => {
state.clear_auto_send();
true
}
AutoSendStep::Submit => {
let text = state.editor.get_text();
state.clear_auto_send();
state.clear_editor_programmatically();
submit_draft(state, tx, &text);
true
}
AutoSendStep::Wait(remaining) => {
let secs = remaining.as_millis() as f64 / 1000.0;
let label = format!("{AUTO_SEND_PREFIX}{secs:.1}s · any key to edit");
if state.footer.get_status() != label {
state.footer.set_status(&label);
true
} else {
false
}
}
}
}
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 cancel_js_preparation(&self) -> bool {
let cancellation = self.js_preparation_cancel.lock().unwrap().take();
if let Some(cancellation) = cancellation {
cancellation.cancel();
true
} else {
false
}
}
fn user_bash_running(&self) -> bool {
self.user_bash_cancel.lock().unwrap().is_some()
}
fn begin_user_bash(&self) -> CancellationToken {
let cancellation = CancellationToken::new();
if let Some(previous) = self
.user_bash_cancel
.lock()
.unwrap()
.replace(cancellation.clone())
{
previous.cancel();
}
cancellation
}
fn finish_user_bash(&self) {
self.user_bash_cancel.lock().unwrap().take();
}
fn cancel_user_bash(&self) -> bool {
let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
if let Some(cancellation) = cancellation {
cancellation.cancel();
true
} else {
false
}
}
fn set_status(&self, status: RunStatus) {
*self.status.lock().unwrap() = status;
self.apply_status(status);
}
fn pending_container(&self) -> &Arc<Container> {
&self.pending_container
}
fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
{
let mut current = self.pending_snapshot.lock().unwrap();
if *current == messages {
return;
}
*current = messages.clone();
}
let container = &self.pending_container;
container.clear();
if messages.is_empty() {
return;
}
let colors = current_theme().colors;
container.add_child(Arc::new(Spacer::new(1)));
for text in &messages.steering {
container.add_child(Arc::new(Text::new(
colors.muted.fg(&format!("Steering: {text}")),
1,
0,
)));
}
for text in &messages.follow_up {
container.add_child(Arc::new(Text::new(
colors.muted.fg(&format!("Follow-up: {text}")),
1,
0,
)));
}
container.add_child(Arc::new(Text::new(
colors.muted.fg(&format!(
"↳ {} to edit all queued messages",
self.dequeue_hint
)),
1,
0,
)));
}
fn try_start_working(&self) -> bool {
let mut status = self.status.lock().unwrap();
if *status != RunStatus::Idle {
return false;
}
*status = RunStatus::Working;
drop(status);
self.apply_status(RunStatus::Working);
true
}
fn apply_status(&self, status: RunStatus) {
match status {
RunStatus::Working => {
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title(&crate::brand::window_title("⟳"));
}
self.status_container.clear();
if self.show_terminal_progress {
self.editor.set_working(Some(WorkingState {
frame: 0,
started_at: std::time::Instant::now(),
message: "Working…".to_string(),
}));
} else {
self.editor.set_working(None);
}
}
RunStatus::Aborting => {
self.footer.set_status("Aborting…");
self.loader.stop();
self.status_container.clear();
self.editor.set_working(None);
}
RunStatus::Idle => {
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title(&crate::brand::window_title(""));
}
self.loader.stop();
self.status_container.clear();
self.editor.set_working(None);
}
}
}
fn sync_extension_status(&self) -> bool {
let revision = self.ext_status.revision();
if self
.ext_status_revision
.load(std::sync::atomic::Ordering::SeqCst)
== revision
{
return false;
}
self.ext_status_revision
.store(revision, std::sync::atomic::Ordering::SeqCst);
self.footer.set_extension_status(&self.ext_status.text());
true
}
fn drain_editor_text(&self) -> bool {
let mut applied = false;
while let Some(edit) = self.editor_text.take_pending() {
let current = self.editor.get_text();
let text = match edit.mode {
rpi_extensions::EditorTextMode::Replace => edit.text,
rpi_extensions::EditorTextMode::Append => {
if current.is_empty() {
edit.text
} else {
let mut joined = current.trim_end().to_string();
joined.push(' ');
joined.push_str(&edit.text);
joined
}
}
};
set_editor_text_caret_at_end(&self.editor, &text);
self.programmatic_editor_write
.store(true, std::sync::atomic::Ordering::SeqCst);
let pending = edit
.auto_send_ms
.filter(|ms| *ms > 0)
.map(|ms| AutoSendPending {
deadline: std::time::Instant::now() + std::time::Duration::from_millis(ms),
text: text.clone(),
});
*self.auto_send.lock().unwrap() = pending;
applied = true;
}
applied
}
fn clear_editor_programmatically(&self) {
self.programmatic_editor_write
.store(true, std::sync::atomic::Ordering::SeqCst);
self.editor.clear();
}
fn clear_auto_send(&self) {
if self.auto_send.lock().unwrap().take().is_some()
&& self.footer.get_status().starts_with(AUTO_SEND_PREFIX)
{
self.footer.set_status("");
}
}
fn cancel_auto_send_on_key(&self) -> bool {
let had = self.auto_send.lock().unwrap().is_some();
if had {
self.clear_auto_send();
}
had
}
fn sync_editor_change(&self) {
use rpi_plugin_sdk::EventTag;
let Some(payload) = self.take_editor_change() else {
return;
};
let Ok(session) = self.extension_session.lock() else {
return;
};
let Some(snapshot) = session.snapshot_arc() else {
return;
};
if snapshot.handlers_for(EventTag::EditorChange).is_empty() {
return;
}
rpi_extensions::dispatch_data_event(
&snapshot,
EventTag::EditorChange,
&payload.to_string(),
);
}
fn take_editor_change(&self) -> Option<serde_json::Value> {
let text = self.editor.get_text();
{
let mut last = self.last_editor_text.lock().unwrap();
if *last == text {
return None;
}
*last = text.clone();
}
let programmatic = self
.programmatic_editor_write
.swap(false, std::sync::atomic::Ordering::SeqCst);
Some(serde_json::json!({
"chars": text.chars().count(),
"empty": text.is_empty(),
"source": if programmatic {
"extension"
} else {
"user"
},
}))
}
fn sync_working_loader_with_bash(&self) {
if *self.status.lock().unwrap() != RunStatus::Working {
return;
}
self.status_container.clear();
}
fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
*self.status.lock().unwrap() = RunStatus::Working;
self.footer.set_status("");
if let Some(tui) = &self.tui {
tui.set_title(&crate::brand::window_title("↻"));
}
self.loader.stop();
self.status_container.clear();
self.status_container
.add_child(Arc::new(StatusIndicator::retry(
attempt,
max_retries,
std::time::Duration::from_millis(delay_ms),
)));
}
fn selector_open(&self) -> bool {
self.active_selector.lock().unwrap().is_some()
}
fn extension_editor_open(&self) -> bool {
self.active_extension_editor.lock().unwrap().is_some()
}
fn extension_input_open(&self) -> bool {
self.active_extension_input.lock().unwrap().is_some()
}
fn extension_dialog_open(&self) -> bool {
self.extension_editor_open() || self.extension_input_open()
}
fn set_hide_thinking(&self, hide: bool) {
*self.hide_thinking.lock().unwrap() = hide;
if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
comp.set_hide_thinking(hide);
}
}
fn hide_thinking(&self) -> bool {
*self.hide_thinking.lock().unwrap()
}
fn toggle_thinking(&self) -> bool {
let next = !self.hide_thinking();
self.set_hide_thinking(next);
next
}
fn toggle_tool_outputs(&self) -> bool {
let next = !*self.tool_outputs_expanded.lock().unwrap();
*self.tool_outputs_expanded.lock().unwrap() = next;
for comp in self.tool_components.lock().unwrap().values() {
comp.set_expanded(next);
}
for comp in self.bash_components.lock().unwrap().values() {
comp.set_expanded(next);
}
next
}
fn current_model_id(&self) -> String {
self.current_model_id.lock().unwrap().clone()
}
fn set_current_model(&self, model: &rpi_ai::Model) {
*self.current_model_id.lock().unwrap() = model.id.clone();
self.footer.set_model(&short_model_name(&model.id));
self.footer.set_context_window(model.context_window as i64);
}
fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
self.markdown_transformer.lock().unwrap().clone()
}
fn transcript_view(&self) -> crate::transcript_view::TranscriptView {
crate::transcript_view::TranscriptView::with_maps(
self.chat_container.clone(),
self.current_assistant.clone(),
self.tool_components.clone(),
self.bash_components.clone(),
self.hide_thinking.clone(),
self.tool_outputs_expanded.clone(),
self.markdown_transformer.clone(),
)
}
fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
*self.markdown_transformer.lock().unwrap() = transformer.clone();
if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
comp.set_markdown_transformer(transformer);
}
}
fn queue_image(&self, image: rpi_ai::types::ImageContent) {
self.pending_images.lock().unwrap().push(image);
}
fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
std::mem::take(&mut *self.pending_images.lock().unwrap())
}
}
#[derive(Debug, PartialEq)]
struct TuiStartupSettings {
editor_padding_x: usize,
autocomplete_max_visible: usize,
hide_thinking: bool,
quiet_startup: bool,
show_terminal_progress: bool,
show_images: bool,
cache_miss_notices: bool,
}
fn preferred_project_setting<T>(
project_settings: &[crate::settings::Settings],
field: impl Fn(&crate::settings::Settings) -> Option<T>,
) -> Option<T> {
project_settings.iter().find_map(field)
}
fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
verbose || !quiet_startup
}
fn resolve_tui_startup_settings(
global: &crate::settings::Settings,
project_settings: &[crate::settings::Settings],
project_trusted: bool,
) -> TuiStartupSettings {
let project_settings = if project_trusted {
project_settings
} else {
&[]
};
let editor_padding_x =
preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
.or(global.editor_padding_x)
.unwrap_or(1)
.min(16);
let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
settings.autocomplete_max_visible
})
.or(global.autocomplete_max_visible)
.unwrap_or(5)
.clamp(1, 20);
let hide_thinking =
preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
.or(global.hide_thinking_block)
.unwrap_or(false);
let quiet_startup =
preferred_project_setting(project_settings, |settings| settings.quiet_startup)
.or(global.quiet_startup)
.unwrap_or(false);
let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
settings.show_terminal_progress()
})
.or_else(|| global.show_terminal_progress())
.unwrap_or(true);
let show_images =
preferred_project_setting(project_settings, |settings| settings.show_images())
.or_else(|| global.show_images())
.unwrap_or(true);
let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
settings.show_cache_miss_notices
})
.or(global.show_cache_miss_notices)
.unwrap_or(false);
TuiStartupSettings {
editor_padding_x,
autocomplete_max_visible,
hide_thinking,
quiet_startup,
show_terminal_progress,
show_images,
cache_miss_notices,
}
}
pub async fn interactive_tui(
harness: &AgentHarness,
event_rx: Option<broadcast::Receiver<AgentEvent>>,
args: &Args,
model_catalog: Vec<rpi_ai::Model>,
initial: Option<String>,
extra_messages: &[String],
initial_images: Vec<rpi_ai::types::ImageContent>,
theme: Option<&str>,
_no_themes: bool,
reload_context: &crate::session::ReloadContext,
) -> i32 {
let lane: Arc<dyn AgentLane> = harness.lane("main");
let cwd = reload_context.cwd.clone();
let saved_settings = crate::settings::load_settings().unwrap_or_default();
let project_trusted = reload_context.project_trusted;
let project_settings = if project_trusted {
crate::settings::load_project_settings(&cwd)
} else {
Vec::new()
};
let tui_settings =
resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
let editor_padding_x = tui_settings.editor_padding_x;
let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
let mut hide_thinking = tui_settings.hide_thinking;
let show_terminal_progress = tui_settings.show_terminal_progress;
let show_images = tui_settings.show_images;
let cache_miss_notices = tui_settings.cache_miss_notices;
let quiet_startup = tui_settings.quiet_startup;
let session_values = rpi_harness::session::SessionValues::load(harness.session())
.await
.unwrap_or_default();
if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
hide_thinking = persisted;
}
let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
let model_name = short_model_name(&lane_model_id);
let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
if active_tool_names.is_empty() {
active_tool_names = harness
.get_tools()
.await
.unwrap_or_default()
.into_iter()
.map(|tool| tool.tool.schema().name.clone())
.collect();
}
let resources_snapshot = harness.get_resources().await.unwrap_or_default();
let skill_names: Vec<String> = resources_snapshot
.skills
.as_deref()
.unwrap_or(&[])
.iter()
.map(|skill| skill.name.clone())
.collect();
let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
let theme_manager = Arc::new(ThemeManager::new());
if let Some(preset) = match theme {
Some("light") => Some(ThemePreset::Light),
Some("monochrome") => Some(ThemePreset::Monochrome),
Some("dark") => Some(ThemePreset::Dark),
_ => None,
} {
apply_theme_preset(preset);
theme_manager.apply_preset(preset);
}
let terminal = Box::new(ProcessTerminal::new());
let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
ask_user_bridge.attach();
tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
let chat_container = Arc::new(Container::new());
if should_show_startup_listing(args.verbose, quiet_startup) {
add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
}
crate::extras::maybe_first_time_setup(&chat_container);
let initial_transformer = build_markdown_transformer(
reload_context
.extension_session
.lock()
.unwrap()
.snapshot_arc(),
);
if launch_restores_history(args) {
render_session_history(
&harness,
&chat_container,
initial_transformer.clone(),
Some(reload_context.extension_session.clone()),
)
.await;
}
let document_container = Arc::new(Container::new());
document_container.add_child(chat_container.clone());
document_container.add_child(Arc::new(Spacer::new(1)));
let scroll_view = Arc::new(ScrollView::new(
document_container.clone(),
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
overscroll: OverscrollMode::Chain,
scrollbar: ScrollbarMode::Hidden,
..Default::default()
},
));
let keybindings = configured_keybindings();
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: editor_padding_x,
autocomplete_max_visible,
..Default::default()
},
EditorStyle::default(),
keybindings.clone(),
));
{
let editor_for_change = editor.clone();
editor.on_change(Arc::new(move |text: &str| {
let colors = current_theme().colors;
let is_bash = text.trim_start().starts_with('!');
editor_for_change.set_border_color(if is_bash {
Some(colors.bash_mode)
} else {
None
});
}));
}
let footer = Arc::new(FooterComponent::new());
footer.set_model(&model_name);
footer.set_cwd(&cwd.to_string_lossy());
footer.set_git_branch(git_branch_for(&cwd).as_deref());
if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
footer.set_context_window(m.context_window as i64);
}
footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Clear/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+P: Cycle | Ctrl+O: Expand tool | /help");
let _branch_watcher = find_git_head_path(&cwd).map(|head| {
let footer_ref = footer.clone();
let cwd_ref = cwd.clone();
crate::fs_watch::watch_path(head, move || {
footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
})
});
let status_container = Arc::new(Container::new());
let loader = Arc::new(Loader::with_text("Working…"));
let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
.prompt_templates
.clone()
.unwrap_or_default()
.iter()
.map(|t| SlashCommandEntry {
name: format!("/{}", t.name),
description: t
.description
.clone()
.unwrap_or_else(|| "Expand prompt template".to_string()),
})
.collect();
let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
Arc::new(resources_snapshot);
let mut command_registry = build_builtin_registry();
register_extension_commands(
&mut command_registry,
reload_context.extension_session.clone(),
);
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 tool_outputs_expanded = session_values
.get_as::<bool>("display.tool_outputs_expanded")
.unwrap_or(false);
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(hide_thinking)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(tool_outputs_expanded)),
show_terminal_progress,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: reload_context.ext_status.clone(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: reload_context.editor_text.clone(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: footer.clone(),
status_container: status_container.clone(),
chat_container: chat_container.clone(),
loader: loader.clone(),
editor: editor.clone(),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete,
autocomplete_container: autocomplete_container.clone(),
autocomplete_max_visible,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager,
tui: Some(tui.clone()),
current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
show_images: std::sync::Mutex::new(show_images),
cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(initial_transformer)),
extension_session: reload_context.extension_session.clone(),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: pending_dequeue_hint(&keybindings),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
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(state.pending_container().clone())),
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(state.search_bar.clone())),
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 scoped_models = args
.model
.as_deref()
.map(crate::agent_session::ScopedModel::parse)
.into_iter()
.collect();
let agent_session = Arc::new(crate::agent_session::AgentSession::new(
harness.clone(),
model_catalog.clone(),
scoped_models,
cwd.clone(),
));
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()),
session: agent_session.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 let Some((command, exclude_from_context)) = parse_user_bash(text) {
if ctx_for_cb.state.user_bash_running() {
add_error_message(
&ctx_for_cb.chat,
"A bash command is already running. Press Esc to cancel it first.",
);
ctx_for_cb.tui.request_render(false);
return;
}
start_user_bash(&ctx_for_cb, command, exclude_from_context);
return;
}
let run_status = *ctx_for_cb.state.status.lock().unwrap();
if run_status != RunStatus::Idle {
let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
let lane = ctx_for_cb.lane.clone();
let chat = ctx_for_cb.chat.clone();
let tui = ctx_for_cb.tui.clone();
let state = ctx_for_cb.state.clone();
tokio::spawn(async move {
if let Err(error) = lane.steer(message).await {
add_error_message(&chat, &format!("Could not queue message: {error}"));
tui.render_now(false);
return;
}
refresh_pending_messages(&state, &lane).await;
tui.request_render(false);
});
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_render_scheduler();
tui.start_readerless();
let mut update_check_handles = Vec::new();
let rpi_chat = chat_container.clone();
let rpi_tui = tui.clone();
update_check_handles.push(tokio::spawn(async move {
let report = crate::updates::check_rpi_startup().await;
if !report.is_empty() {
add_update_notices(&rpi_chat, &report);
}
rpi_tui.request_render(false);
}));
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();
let lane_drain = lane.clone();
Some(tokio::spawn(async move {
drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_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 tx_tick = tx.clone();
let tick_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(
rpi_tui::loader::SPINNER_FRAME_MS,
));
interval.tick().await; loop {
interval.tick().await;
if state_tick.sync_extension_status() {
tui_tick.request_render(false);
}
if state_tick.drain_editor_text() {
tui_tick.request_render(false);
}
state_tick.sync_editor_change();
if poll_auto_send(&state_tick, &tx_tick) {
tui_tick.request_render(false);
}
let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
if working {
let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
let has_live_panel = {
let tools = state_tick.tool_components.lock().unwrap();
transcript_has_live_panel(bash_present, &tools)
};
if has_live_panel {
tui_tick.request_render(false);
} else {
tui_tick.request_render_reusing_scroll_content();
}
}
}
});
let running = Arc::new(std::sync::Mutex::new(true));
let running_key = running.clone();
let tx_for_key = tx.clone();
let tui_for_key = tui.clone();
let editor_for_key = editor.clone();
let scroll_for_key = scroll_view.clone();
let lane_for_key = lane.clone();
let state_for_key = state.clone();
let model_catalog_for_key = model_catalog_arc.clone();
let ask_user_for_key = ask_user_bridge.clone();
let ctx_for_key = ctx.clone();
let registry_for_key = registry.clone();
let keybindings_for_key = keybindings.clone();
let double_escape_action = crate::settings::load_settings()
.ok()
.and_then(|settings| settings.double_escape_action)
.unwrap_or_else(|| "tree".to_string())
.to_ascii_lowercase();
let key_handle = tokio::task::spawn_blocking(move || {
let mut last_escape_time = None;
let mut last_sigint_time: Option<std::time::Instant> = None;
let mut pending_event: Option<Event> = None;
let mut windows_paste_run = WindowsPasteRun::default();
if crate::key_trace::enabled() {
crate::key_trace::note(&format!(
"--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
std::process::id(),
std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
));
}
loop {
if !*running_key.lock().unwrap() {
break;
}
if !state_for_key.selector_open() && !state_for_key.extension_dialog_open() {
if let Some(request) = ask_user_for_key.take_pending() {
open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
}
}
let ev = if let Some(ev) = pending_event.take() {
ev
} else {
match crossterm::event::poll(
if cfg!(windows) && !windows_paste_run.text.is_empty() {
windows_paste_run.grace()
} else {
std::time::Duration::from_millis(50)
},
) {
Ok(true) => {}
Ok(false) => {
if cfg!(windows) && !windows_paste_run.text.is_empty() {
windows_paste_run.flush(&editor_for_key);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
Err(_) => {
state_for_key.cancel_js_preparation();
let _ = tx_for_key.send(TuiMessage::Exit);
break;
}
}
let Ok(ev) = crossterm::event::read() else {
state_for_key.cancel_js_preparation();
let _ = tx_for_key.send(TuiMessage::Exit);
break;
};
ev
};
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();
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) if cfg!(windows) => {
if let Some(text) = read_clipboard_text() {
if !text.is_empty() {
editor_for_key.handle_paste(&text);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
}
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
*state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
*state_for_key.selection_end.lock().unwrap() = None;
}
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
if state_for_key.selection_start.lock().unwrap().is_some() {
*state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
}
}
MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
let end = (m.column, m.row);
let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
*state_for_key.selection_end.lock().unwrap() = Some(end);
let auto_copy = crate::settings::load_settings()
.ok()
.and_then(|s| s.fullscreen_copy_on_select)
.unwrap_or(true);
if auto_copy && is_drag {
if let Some(selected_text) =
extract_selected_text(&state_for_key.chat_container, start, end)
{
if !selected_text.trim().is_empty() {
let _ = copy_to_clipboard(&selected_text);
}
}
}
}
*state_for_key.selection_start.lock().unwrap() = None;
*state_for_key.selection_end.lock().unwrap() = None;
}
_ => {}
}
continue;
}
let Event::Key(key) = ev else {
if let Event::Paste(text) = ev {
crate::key_trace::note(&format!(
"paste event ({} bytes, bracketed paste supported)",
text.len()
));
let candidate = text.trim().trim_matches(['\"', '\'']);
let path = std::path::PathBuf::from(candidate);
if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
add_image_preview(&state_for_key.chat_container, &image);
state_for_key.queue_image(image);
add_note_message(
&state_for_key.chat_container,
"Dropped image attached to the next prompt.",
);
tui_for_key.request_render(false);
continue;
}
}
editor_for_key.handle_paste(&text);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
}
continue;
};
crate::key_trace::key(&key, "");
if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
tui_for_key.request_render(false);
}
if dispatch_key_event(&state_for_key, &key, &editor_for_key) {
continue;
}
if !should_dispatch_key(key.kind) {
continue;
}
let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
|| (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
if prompt_abort && state_for_key.cancel_js_preparation() {
state_for_key.set_status(RunStatus::Aborting);
if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
close_extension_editor(
&state_for_key,
&ctx_for_key.editor_container,
&editor_for_key,
&tui_for_key,
);
}
tui_for_key.set_render_suspended(false);
tui_for_key.request_render(false);
continue;
}
if key.modifiers == KeyModifiers::CONTROL
&& key.code == KeyCode::Char('c')
&& state_for_key.selector_open()
{
let selector = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some")
.0;
selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if state_for_key.extension_dialog_open() {
let cancel = key.code == KeyCode::Esc
|| (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
if cancel {
if !run_extension_cancel(&state_for_key) {
close_extension_editor(
&state_for_key,
&ctx_for_key.editor_container,
&editor_for_key,
&tui_for_key,
);
}
} else if let Some(extension_editor) = state_for_key
.active_extension_editor
.lock()
.unwrap()
.clone()
{
extension_editor.handle_key(key);
} else if let Some(extension_input) =
state_for_key.active_extension_input.lock().unwrap().clone()
{
extension_input.handle_key(key);
}
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::CLEAR,
) {
if key.kind != KeyEventKind::Repeat {
let now = std::time::Instant::now();
match ctrl_c_action(last_sigint_time, now) {
CtrlCAction::Exit => {
last_sigint_time = None;
if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
let _ = tx_for_key.send(TuiMessage::Exit);
} else {
emergency_exit(&tui_for_key);
}
}
CtrlCAction::Clear => {
editor_for_key.clear();
refresh_autocomplete(&state_for_key, &editor_for_key);
last_sigint_time = Some(now);
tui_for_key.request_render(false);
}
}
}
continue;
}
if !state_for_key.selector_open()
&& keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::MESSAGE_COPY,
)
{
if editor_for_key.has_selection() && editor_for_key.copy_selection() {
add_note_message(&state_for_key.chat_container, "Copied!");
} else {
copy_last_assistant(&state_for_key, &state_for_key.chat_container);
}
tui_for_key.request_render(false);
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::SUSPEND,
) {
handle_suspend(&tui_for_key);
continue;
}
if state_for_key.selector_open() {
if key.code == KeyCode::Esc {
let (selector, _kind) = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some");
selector.handle_key(key);
continue;
}
let (selector, _kind) = state_for_key
.active_selector
.lock()
.unwrap()
.clone()
.expect("selector_open guaranteed Some");
selector.handle_key(key);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
if !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;
}
if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
let _ = tx_for_key.send(TuiMessage::Exit);
} else {
emergency_exit(&tui_for_key);
}
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::INTERRUPT,
) {
let status = *state_for_key.status.lock().unwrap();
if status == RunStatus::Working {
state_for_key.set_status(RunStatus::Aborting);
ask_user_for_key.cancel_all();
if !run_extension_cancel(&state_for_key)
&& state_for_key.extension_dialog_open()
{
close_extension_editor(
&state_for_key,
&ctx_for_key.editor_container,
&editor_for_key,
&tui_for_key,
);
}
abort_run_restoring_queue(
lane_for_key.clone(),
editor_for_key.clone(),
state_for_key.clone(),
tui_for_key.clone(),
);
continue;
}
if state_for_key.user_bash_running() {
state_for_key.cancel_user_bash();
tui_for_key.request_render(false);
continue;
}
if status == RunStatus::Idle
&& editor_for_key.get_text().trim().is_empty()
&& double_escape_action != "none"
{
let now = std::time::Instant::now();
if double_escape_trigger(last_escape_time, now) {
last_escape_time = None;
match double_escape_action.as_str() {
"tree" => {
let _ = tx_for_key.send(TuiMessage::OpenTree);
}
"fork" => {
let _ = tx_for_key.send(TuiMessage::ForkSession);
}
_ => {}
}
} else {
last_escape_time = Some(now);
}
}
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
) {
launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::TOOLS_EXPAND,
) {
let expanded = state_for_key.toggle_tool_outputs();
let session = ctx_for_key.session.clone();
tokio::spawn(async move {
let _ = session
.set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
.await;
});
tui_for_key.request_render(false);
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::THINKING_TOGGLE,
) {
let hide = state_for_key.toggle_thinking();
let session = ctx_for_key.session.clone();
tokio::spawn(async move {
let _ = session
.set_value("display.hide_thinking", serde_json::json!(hide))
.await;
});
tui_for_key.request_render(false);
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
) {
let current = state_for_key.current_model_id();
let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
if let Some(next) = cycle_next_model(&scope, ¤t) {
state_for_key.set_current_model(&next);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.set_model(next).await;
});
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
) {
let current = state_for_key.current_model_id();
let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
if let Some(prev) = cycle_prev_model(&scope, ¤t) {
state_for_key.set_current_model(&prev);
let lane = lane_for_key.clone();
tokio::spawn(async move {
let _ = lane.set_model(prev).await;
});
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::THINKING_CYCLE,
) {
let lane = lane_for_key.clone();
let catalog = model_catalog_for_key.clone();
let state = state_for_key.clone();
tokio::spawn(async move {
let Ok(current_model) = lane.get_model().await else {
return;
};
let levels = catalog
.iter()
.find(|model| {
model.provider == current_model.provider && model.id == current_model.id
})
.map(|model| model.supported_thinking_levels())
.unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
if levels.is_empty() {
return;
}
let current = lane
.get_thinking_level()
.await
.unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
let next = levels
.iter()
.position(|level| *level == current)
.map(|index| levels[(index + 1) % levels.len()])
.unwrap_or(levels[0]);
if lane.set_thinking_level(next).await.is_ok() {
state
.footer
.set_thinking_level(Some(thinking_level_name(next)));
state.tui.as_ref().map(|tui| tui.request_render(false));
}
});
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::PASTE_IMAGE,
) {
match read_clipboard_image() {
Ok(Some(image)) => {
add_image_preview(&state_for_key.chat_container, &image);
state_for_key.queue_image(image);
add_note_message(
&state_for_key.chat_container,
"Clipboard image attached to the next prompt.",
);
tui_for_key.request_render(false);
continue;
}
Ok(None) | Err(_) => {}
}
if cfg!(windows) {
if let Some(text) = read_clipboard_text() {
if !text.is_empty() {
editor_for_key.handle_paste(&text);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
}
}
if key
.modifiers
.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
&& key.code == KeyCode::Char('F')
{
state_for_key.search.activate();
state_for_key.search_bar.set_visible(true);
state_for_key.search_bar.set_query("");
state_for_key.search_bar.set_match_info(0, 0);
tui_for_key.request_render(false);
continue;
}
if state_for_key.search.is_active() {
let search_bar = state_for_key.search_bar.clone();
match key.code {
KeyCode::Esc => {
state_for_key.search.deactivate();
search_bar.set_visible(false);
tui_for_key.request_render(false);
continue;
}
KeyCode::Enter => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
state_for_key.search.previous_match();
} else {
state_for_key.search.next_match();
}
let match_index = state_for_key.search.get_match_index();
let match_count = state_for_key.search.get_match_count();
search_bar.set_match_info(match_index, match_count);
tui_for_key.request_render(false);
continue;
}
KeyCode::Backspace => {
state_for_key.search.backspace();
let lines = collect_transcript_lines(&state_for_key.chat_container);
state_for_key.search.find_matches(&lines);
let query = state_for_key.search.get_query();
let match_count = state_for_key.search.get_match_count();
search_bar.set_query(&query);
search_bar.set_match_info(0, match_count);
tui_for_key.request_render(false);
continue;
}
KeyCode::Char(c) => {
state_for_key.search.append_char(c);
let lines = collect_transcript_lines(&state_for_key.chat_container);
state_for_key.search.find_matches(&lines);
let query = state_for_key.search.get_query();
let match_count = state_for_key.search.get_match_count();
search_bar.set_query(&query);
search_bar.set_match_info(0, match_count);
tui_for_key.request_render(false);
continue;
}
_ => continue,
}
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::MODEL_SELECT,
) {
if let Some(cmd) = registry_for_key.find("/model") {
cmd.execute(&ctx_for_key, "");
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
if accept_top_suggestion(&state_for_key, &editor_for_key) {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
let delta = -transcript_page_size(scroll_for_key.viewport_height());
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
let delta = transcript_page_size(scroll_for_key.viewport_height());
if scroll_for_key.scroll_by(delta) != delta {
tui_for_key.request_render_reusing_scroll_content();
}
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
scroll_for_key.scroll_to_start();
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
scroll_for_key.scroll_to_end();
tui_for_key.request_render_reusing_scroll_content();
continue;
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
let browsing = *state_for_key.history_index.lock().unwrap() != -1;
if editor_for_key.get_text().is_empty() || browsing {
navigate_history(&state_for_key, &editor_for_key, -1);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
let browsing = *state_for_key.history_index.lock().unwrap() != -1;
if editor_for_key.get_text().is_empty() || browsing {
navigate_history(&state_for_key, &editor_for_key, 1);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::DEQUEUE,
) {
tokio::spawn(handle_dequeue(
lane_for_key.clone(),
editor_for_key.clone(),
state_for_key.clone(),
tui_for_key.clone(),
));
continue;
}
if keybinding_matches(
&keybindings_for_key,
&key,
rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
) {
let prompt = editor_for_key.get_expanded_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 {
let message = AgentMessage::User(UserMessage::new(prompt, 0));
let lane = lane_for_key.clone();
let chat = state_for_key.chat_container.clone();
let tui = tui_for_key.clone();
let state = state_for_key.clone();
tokio::spawn(async move {
if let Err(error) = lane.follow_up(message).await {
add_error_message(&chat, &format!("Could not queue message: {error}"));
tui.request_render(false);
return;
}
refresh_pending_messages(&state, &lane).await;
tui.request_render(false);
});
}
tui_for_key.request_render(false);
continue;
}
if cfg!(windows) {
let text_key = match key.code {
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
Some(c)
}
_ => None,
};
if let Some(ch) = text_key {
if ch == '\r' || ch == '\n' {
} else {
windows_paste_run.feed(ch, std::time::Instant::now());
continue;
}
}
if key.modifiers.is_empty()
&& matches!(
key.code,
KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
)
{
let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
crossterm::event::read().ok()
} else {
None
}
});
pending_event = stashed;
let clipboard = if !more_queued && windows_paste_run.burst {
read_clipboard_text()
} else {
None
};
if windows_enter_in_paste(&windows_paste_run, more_queued, clipboard.as_deref())
{
windows_paste_run.feed('\n', std::time::Instant::now());
crate::key_trace::note("windows paste run -> newline");
continue;
}
crate::key_trace::note("windows paste run -> flush + submit");
windows_paste_run.flush(&editor_for_key);
} else {
windows_paste_run.flush(&editor_for_key);
}
}
editor_for_key.handle_key(key);
refresh_autocomplete(&state_for_key, &editor_for_key);
tui_for_key.request_render_reusing_scroll_content();
}
});
if let Some(reason) = crate::session::dispatch_session_event_async(
reload_context,
rpi_plugin_sdk::EventTag::SessionStart,
)
.await
{
tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
}
let mut prompts: Vec<String> = Vec::new();
if let Some(init) = initial {
prompts.push(init);
}
for m in extra_messages {
prompts.push(m.clone());
}
if prompts.is_empty() && lane.has_pending_resume().await {
run_prompt_streaming(
&lane,
"",
true,
&tui,
&state,
drain_handle.is_some(),
Vec::new(),
)
.await;
}
let mut images = initial_images;
for prompt in prompts {
if !*running.lock().unwrap() {
break;
}
add_user_message(&chat_container, &prompt);
tui.request_render(false);
run_prompt_streaming(
&lane,
&prompt,
false,
&tui,
&state,
drain_handle.is_some(),
std::mem::take(&mut images),
)
.await;
}
loop {
if !*running.lock().unwrap() {
break;
}
match rx.recv().await {
Some(TuiMessage::UserInput(prompt)) => {
state.clear_editor_programmatically();
let prompt_images = state.take_pending_images();
if !prompt_images.is_empty() {
add_note_message(
&chat_container,
&format!("Attached {} image(s) to this prompt.", prompt_images.len()),
);
}
run_prompt_streaming(
&lane,
&prompt,
false,
&tui,
&state,
drain_handle.is_some(),
prompt_images,
)
.await;
}
Some(TuiMessage::ExternalEditorResult(result)) => {
match result {
Ok(text) => {
let cursor = text.chars().count();
editor.set_text(&text);
editor.set_cursor(0, cursor);
add_note_message(&chat_container, "Draft updated from external editor.");
}
Err(error) => add_error_message(&chat_container, &error),
}
tui.request_render(false);
}
Some(TuiMessage::UserBashFinished(report)) => {
state.finish_user_bash();
let message = rpi_harness::messages::bash_execution_message(
report.command,
report.output,
report.exit_code,
report.cancelled,
report.truncated,
report.full_output_path,
Some(report.exclude_from_context),
now_ms(),
);
if *state.status.lock().unwrap() == RunStatus::Idle {
if let Err(error) = lane.append_message(message).await {
add_error_message(
&chat_container,
&format!("Could not record bash result: {error}"),
);
tui.request_render(false);
}
} else {
state.pending_bash_messages.lock().unwrap().push(message);
}
}
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) => {
let file_name = agent_session
.default_export_file_name(crate::export::ExportFormat::Markdown)
.await;
let path = cwd.join(&file_name);
match agent_session.export_to_markdown(&path).await {
Ok(_) => add_note_message(
&chat_container,
&format!("Exported session to {}", path.display()),
),
Err(e) => {
add_error_message(&chat_container, &format!("Could not write export: {e}"))
}
}
tui.request_render(false);
}
Some(TuiMessage::ExportSessionWithFormat(format)) => {
let file_name = agent_session.default_export_file_name(format.clone()).await;
let path = cwd.join(&file_name);
match agent_session.export(format, &path).await {
Ok(_) => add_note_message(
&chat_container,
&format!("Exported session to {}", path.display()),
),
Err(e) => {
add_error_message(&chat_container, &format!("Could not write export: {e}"))
}
}
tui.request_render(false);
}
Some(TuiMessage::ShowUsage) => {
let stats = agent_session.usage_stats().await.ok();
let total = agent_session.total_usage().await.ok();
let snapshot = agent_session.snapshot().await.ok();
match stats {
Some(stats) => {
let mut lines = vec![
"## Usage".to_string(),
String::new(),
format!("- messages: {}", stats.message_count),
format!("- total tokens: {}", stats.total_tokens),
format!("- cached tokens: {}", stats.cached_tokens),
format!("- uncached tokens: {}", stats.uncached_tokens),
format!("- cost: ${:.4}", stats.cost_total),
];
match stats.cache_hit_ratio() {
Some(ratio) => {
lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
}
None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
}
if let Some(total) = total {
lines.push(String::new());
lines.push(format!(
"- last cumulative input/output: {} / {}",
total.input, total.output
));
}
if let Some(snapshot) = snapshot {
lines.push(String::new());
lines.push(format!("- lane: `{}`", snapshot.lane));
lines.push(format!(
"- leaf: `{}`",
snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
));
if let Some(active) = &snapshot.active {
lines.push(format!(
"- active operation: {} (`{}`)",
active.kind.as_str(),
active.run_id
));
}
}
add_note_message(&chat_container, &lines.join("\n"));
}
None => {
add_error_message(&chat_container, "Could not read session usage stats.")
}
}
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_errors {
add_error_message(&chat_container, &outcome.summary);
} else if outcome.had_warnings {
add_note_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,
}
}
ask_user_bridge.shutdown();
*running.lock().unwrap() = false;
for handle in update_check_handles {
handle.abort();
let _ = handle.await;
}
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());
let session_id = harness.session().storage().metadata().id;
if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
println!("\x1b[2mTo resume this session:\x1b[0m {command}");
}
println!("\nGoodbye!");
let _ = args;
0
}
fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
std::thread::spawn(move || {
let file = match tempfile::Builder::new()
.prefix("rpi-draft-")
.suffix(".md")
.tempfile()
{
Ok(file) => file,
Err(error) => {
let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
"Could not create editor file: {error}"
))));
return;
}
};
if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
"Could not write editor file: {error}"
))));
return;
}
let editor = std::env::var("RPI_EXTERNAL_EDITOR")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| std::env::var("VISUAL").ok())
.or_else(|| std::env::var("EDITOR").ok())
.unwrap_or_else(|| {
if cfg!(windows) {
"notepad".to_string()
} else {
"nano".to_string()
}
});
let status = std::process::Command::new(&editor)
.arg(file.path())
.status();
let result = match status {
Ok(status) if status.success() => std::fs::read_to_string(file.path())
.map_err(|error| format!("Could not read editor file: {error}")),
Ok(status) => Err(format!("External editor exited with {status}")),
Err(error) => Err(format!(
"Could not launch external editor `{editor}`: {error}"
)),
};
let _ = tx.send(TuiMessage::ExternalEditorResult(result));
});
}
fn reconcile_streamed_assistant_completion(
current_assistant: &Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
last_assistant_text: &Mutex<String>,
final_message: Option<&AssistantMessage>,
) {
let final_blocks = final_message.map(assistant_blocks);
let final_text = final_message.map(assistant_text);
let component = current_assistant.lock().unwrap().take();
if let Some(component) = component {
if let Some(blocks) = final_blocks.as_deref() {
component.update_blocks(blocks);
}
component.set_streaming(false);
}
if let Some(text) = final_text.filter(|text| !text.is_empty()) {
*last_assistant_text.lock().unwrap() = text;
}
}
async fn run_prompt_streaming(
lane: &Arc<dyn AgentLane>,
prompt: &str,
resume: bool,
tui: &Arc<TuiAltScreen>,
state: &Arc<TuiState>,
streaming: bool,
images: Vec<rpi_ai::types::ImageContent>,
) {
state.set_status(RunStatus::Working);
tui.request_render(false);
let outcome = if resume {
match lane.resume_pending().await {
Ok(Some(result)) => Ok(result),
Ok(None) => {
state.set_status(RunStatus::Idle);
tui.request_render(false);
return;
}
Err(error) => Err(error),
}
} else {
lane.prompt_text(prompt, images).await
};
if streaming {
let final_message = match &outcome {
Ok(result) => match &result.outcome {
HarnessRunOutcome::Completed { final_message, .. }
| HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
HarnessRunOutcome::Suspended { .. } => None,
},
Err(_) => None,
};
reconcile_streamed_assistant_completion(
&state.current_assistant,
&state.last_assistant_text,
final_message,
);
}
state.set_status(RunStatus::Idle);
refresh_pending_messages(state, lane).await;
if let Ok(result) = &outcome {
let final_message: Option<&AssistantMessage> = match &result.outcome {
HarnessRunOutcome::Completed { final_message, .. }
| HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
HarnessRunOutcome::Suspended { .. } => None,
};
if let Some(a) = final_message {
record_context_usage(&state.footer, a);
}
}
match outcome {
Ok(result) => match &result.outcome {
HarnessRunOutcome::Failed {
error,
final_message,
..
} => {
let already_rendered = streaming && final_message.is_some();
if !already_rendered {
let msg = final_message
.as_ref()
.and_then(|m| m.error_message.clone())
.unwrap_or_else(|| format!("{error:?}"));
add_error_message(&state.chat_container, &msg);
}
}
HarnessRunOutcome::Suspended { .. } => {
add_error_message(
&state.chat_container,
"Run suspended (deferred) — resume is not supported in v1.",
);
}
HarnessRunOutcome::Aborted { final_message, .. } => {
if !streaming {
add_error_message(&state.chat_container, "Request aborted.");
let _ = final_message; }
}
HarnessRunOutcome::Completed { final_message, .. } => {
if !streaming {
let text = assistant_text(final_message);
if !text.is_empty() {
add_assistant_message_blocking(
&state.chat_container,
&text,
state.markdown_transformer(),
);
*state.last_assistant_text.lock().unwrap() = text;
}
}
}
},
Err(e) => {
add_error_message(&state.chat_container, &e.to_string());
}
}
tui.request_render(false);
}
async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
state.set_status(RunStatus::Working);
tui.request_render(false);
match lane.compact(None).await {
Ok(_) => {
add_note_message(&state.chat_container, "Conversation compacted.");
state.footer.set_context_tokens(None);
}
Err(e) => {
add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
}
}
state.set_status(RunStatus::Idle);
tui.request_render(false);
}
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 {
""
}
),
);
}
}
fn extract_selected_text(
chat_container: &Arc<Container>,
start: (u16, u16),
end: (u16, u16),
) -> Option<String> {
use rpi_tui::Component;
let lines = chat_container.render(80); if lines.is_empty() {
return None;
}
let (start_row, end_row) = if start.1 <= end.1 {
(start.1 as usize, end.1 as usize)
} else {
(end.1 as usize, start.1 as usize)
};
let start_row = start_row.min(lines.len().saturating_sub(1));
let end_row = end_row.min(lines.len().saturating_sub(1));
if start_row > end_row {
return None;
}
let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
if selected_lines.is_empty() {
return None;
}
Some(selected_lines.join("\n"))
}
#[cfg(all(feature = "clipboard", not(target_os = "android")))]
fn copy_to_clipboard(text: &str) -> bool {
match arboard::Clipboard::new() {
Ok(mut cb) => cb.set_text(text).is_ok(),
Err(_) => false,
}
}
#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
fn copy_to_clipboard(_text: &str) -> bool {
false
}
#[cfg(all(feature = "clipboard", not(target_os = "android")))]
fn read_clipboard_text() -> Option<String> {
let mut clipboard = arboard::Clipboard::new().ok()?;
clipboard.get_text().ok()
}
#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
fn read_clipboard_text() -> Option<String> {
None
}
#[cfg(all(feature = "clipboard", not(target_os = "android")))]
fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
let image = match clipboard.get_image() {
Ok(image) => image,
Err(_) => return Ok(None),
};
let width =
u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
let height =
u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
return Err("clipboard image dimensions are outside the supported range".into());
}
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
writer
.write_image_data(&image.bytes)
.map_err(|e| e.to_string())?;
}
Ok(Some(rpi_ai::types::ImageContent {
kind: rpi_ai::types::ImageContentType,
data: base64::engine::general_purpose::STANDARD.encode(bytes),
mime_type: "image/png".into(),
}))
}
fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
let mut options = ImageOptions::default();
options.width = Some(48);
options.alt_text = Some("Attached image".into());
chat.add_child(Arc::new(Image::from_data(bytes, options)));
chat.add_child(Arc::new(Spacer::new(1)));
}
}
#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
Ok(None)
}
fn add_assistant_message_blocking(
container: &Arc<Container>,
text: &str,
transformer: Option<MarkdownTransformer>,
) {
if text.is_empty() {
return;
}
let msg = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
if let Some(t) = &transformer {
msg.set_markdown_transformer(Some(t.clone()));
}
msg.update_text(text);
container.add_child(msg);
container.add_child(Arc::new(Spacer::new(1)));
}
async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
if let Ok(queued) = lane.queued_messages().await {
state.set_pending_queue(queued);
}
}
fn transcript_has_live_panel(
bash_components_present: bool,
tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
) -> bool {
bash_components_present
|| tool_components
.values()
.any(|component| component.status() == ToolStatus::Running)
}
fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
if current.trim().is_empty() {
queued_text.to_string()
} else {
format!("{queued_text}\n\n{current}")
}
}
fn set_editor_text_caret_at_end(editor: &Arc<Editor>, text: &str) {
editor.set_text(text);
let last_row = text.split('\n').count().saturating_sub(1);
let last_len = text.split('\n').next_back().map(str::len).unwrap_or(0);
editor.set_cursor(last_row, last_len);
}
async fn restore_queued_messages_to_editor(
lane: Arc<dyn AgentLane>,
editor: Arc<Editor>,
state: Arc<TuiState>,
tui: Arc<TuiAltScreen>,
) -> usize {
match lane.clear_queue().await {
Ok(queued) => {
let all: Vec<String> = queued
.steering
.iter()
.chain(queued.follow_up.iter())
.cloned()
.collect();
if !all.is_empty() {
let queued_text = all.join("\n\n");
let current = editor.get_expanded_text();
let combined = merge_queued_into_draft(&queued_text, ¤t);
set_editor_text_caret_at_end(&editor, &combined);
}
refresh_pending_messages(&state, &lane).await;
tui.request_render(false);
all.len()
}
Err(error) => {
add_error_message(
&state.chat_container,
&format!("Could not restore queued messages: {error}"),
);
tui.request_render(false);
0
}
}
}
async fn handle_dequeue(
lane: Arc<dyn AgentLane>,
editor: Arc<Editor>,
state: Arc<TuiState>,
tui: Arc<TuiAltScreen>,
) {
let restored =
restore_queued_messages_to_editor(lane, editor, state.clone(), tui.clone()).await;
if restored == 0 {
add_note_message(&state.chat_container, "No queued messages to restore");
} else {
let plural = if restored > 1 { "s" } else { "" };
add_note_message(
&state.chat_container,
&format!("Restored {restored} queued message{plural} to editor"),
);
}
tui.request_render(false);
}
fn abort_run_restoring_queue(
lane: Arc<dyn AgentLane>,
editor: Arc<Editor>,
state: Arc<TuiState>,
tui: Arc<TuiAltScreen>,
) {
tokio::spawn(async move {
let _ = restore_queued_messages_to_editor(lane.clone(), editor, state, tui.clone()).await;
let _ = lane.abort().await;
tui.request_render(false);
});
}
async fn drain_agent_events(
mut rx: broadcast::Receiver<AgentEvent>,
tui: Arc<TuiAltScreen>,
state: Arc<TuiState>,
chat: Arc<Container>,
lane: Arc<dyn AgentLane>,
) {
loop {
match rx.recv().await {
Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).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>,
lane: &Arc<dyn AgentLane>,
) {
match event {
AgentEvent::AgentStart => {
state.set_status(RunStatus::Working);
tui.request_render(false);
}
AgentEvent::AgentEnd { .. } => {
state.transcript_view().finalize_assistant();
state.set_status(RunStatus::Idle);
let pending: Vec<AgentMessage> =
std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
for message in pending {
if let Err(error) = lane.append_message(message).await {
add_error_message(chat, &format!("Could not record bash result: {error}"));
}
}
refresh_pending_messages(state, lane).await;
tui.request_render(false);
}
AgentEvent::RetryScheduled {
attempt,
max_retries,
delay_ms,
..
} => {
state.show_retry(attempt, max_retries, delay_ms);
tui.request_render(false);
}
AgentEvent::TurnStart => {
state.transcript_view().finalize_assistant();
}
AgentEvent::TurnEnd {
message,
tool_results,
} => {
let view = state.transcript_view();
if let AgentMessage::Assistant(a) = &message {
view.finalize_assistant_blocks(&assistant_blocks(a));
} else {
view.finalize_assistant();
}
let _ = tool_results;
tui.request_render(false);
}
AgentEvent::MessageStart { message } => match message {
AgentMessage::Assistant(a) => {
state.transcript_view().apply(
&UiEvent::AssistantStart {
blocks: assistant_blocks(&a),
},
tui.width(),
);
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);
}
}
AgentMessage::User(user) => {
state.transcript_view().add_user(&user_message_text(&user));
refresh_pending_messages(state, lane).await;
tui.request_render(false);
}
_ => {}
},
AgentEvent::MessageUpdate {
message,
assistant_message_event,
} => {
let _ = assistant_message_event; let a: &rpi_ai::types::AssistantMessage = &message;
let text = assistant_text(a);
state.transcript_view().apply(
&UiEvent::AssistantUpdate {
blocks: assistant_blocks(a),
tool_calls: assistant_tool_calls(a),
},
tui.width(),
);
state.sync_working_loader_with_bash();
*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);
state
.transcript_view()
.finalize_assistant_blocks(&assistant_blocks(a));
if !text.is_empty() {
*state.last_assistant_text.lock().unwrap() = text;
}
let usage = &a.usage;
state.footer.add_usage(
usage.input,
usage.output,
usage.cache_read,
usage.cache_write,
usage.cost.total,
);
let denom = usage.input + usage.cache_read + usage.cache_write;
if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
state
.footer
.set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
}
record_context_usage(&state.footer, a);
let miss = state
.cache_tracker
.lock()
.unwrap()
.observe(a, &rpi_harness::cache_stats::NoPrices);
let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
if let Some(miss) = miss.filter(|_| cache_miss_notices) {
let cost = if miss.missed_cost > 0.0 {
format!(" (~${:.4})", miss.missed_cost)
} else {
String::new()
};
add_note_message(
&state.chat_container,
&format!(
"Cache miss: {} tokens re-billed{}",
format_tokens(miss.missed_tokens),
cost
),
);
}
if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
add_note_message(chat, &text);
}
if let Some(error) = assistant_error_text(a) {
add_error_message(chat, &error);
}
}
tui.request_render(false);
}
AgentEvent::ToolExecutionStart {
tool_call_id,
tool_name,
args,
} => {
state.transcript_view().apply(
&UiEvent::ToolStart {
id: tool_call_id,
name: tool_name,
args,
},
tui.width(),
);
state.sync_working_loader_with_bash();
tui.request_render(false);
}
AgentEvent::ToolExecutionUpdate {
tool_call_id,
tool_name,
args,
partial_result,
} => {
state.transcript_view().apply(
&UiEvent::ToolUpdate {
id: tool_call_id,
name: tool_name,
args,
result: crate::remote::protocol::tool_result_json(&partial_result),
},
tui.width(),
);
state.sync_working_loader_with_bash();
tui.request_render(false);
}
AgentEvent::ToolExecutionEnd {
tool_call_id,
tool_name,
result,
is_error,
} => {
state.transcript_view().apply(
&UiEvent::ToolEnd {
id: tool_call_id,
name: tool_name,
result: crate::remote::protocol::tool_result_json(&result),
is_error,
},
tui.width(),
);
state.sync_working_loader_with_bash();
tui.render_now(false);
}
}
}
fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
if message.stop_reason == rpi_ai::StopReason::Aborted
|| message.stop_reason == rpi_ai::StopReason::Error
{
return;
}
let tokens = message.usage.context_tokens();
if tokens > 0 {
footer.set_context_tokens(Some(tokens));
}
}
fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
let fallback = match message.stop_reason {
rpi_ai::StopReason::Error => "Provider request failed.",
rpi_ai::StopReason::Aborted => "Request aborted.",
_ => return None,
};
Some(
message
.error_message
.as_deref()
.filter(|text| !text.trim().is_empty())
.unwrap_or(fallback)
.to_string(),
)
}
fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
details
.and_then(|details| details.get("markdown"))
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn open_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
list: Arc<SelectList>,
kind: SelectorKind,
) {
open_selector_with_view(
state,
editor_container,
editor,
tui,
SelectorView::List(list.clone()),
list,
kind,
);
}
fn open_searchable_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
title: Option<&str>,
description: Option<&str>,
list: Arc<SelectList>,
kind: SelectorKind,
) -> Arc<SearchableSelectList> {
let searchable = Arc::new(SearchableSelectList::new(title, description, list));
open_selector_with_view(
state,
editor_container,
editor,
tui,
SelectorView::Searchable(searchable.clone()),
searchable.clone(),
kind,
);
searchable.set_focused(true);
searchable
}
fn open_selector_with_view<V: Component + 'static>(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
view_router: SelectorView,
view: Arc<V>,
kind: SelectorKind,
) {
dispatch_ui_prompt_event(state);
editor.set_focused(false);
state.autocomplete_container.clear();
editor_container.clear();
editor_container.add_child(view.clone());
*state.active_selector.lock().unwrap() = Some((view_router, kind));
let focused: Arc<dyn Component> = view;
tui.set_focus(Some(focused));
tui.request_render(false);
}
fn close_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
) {
dispatch_ui_prompt_event_end(state);
editor_container.clear();
editor_container.add_child(editor.clone());
state.autocomplete_container.clear();
editor.set_focused(true);
*state.active_selector.lock().unwrap() = None;
*state.active_extension_cancel.lock().unwrap() = None;
tui.set_focus(Some(editor.clone()));
tui.request_render(false);
}
fn open_model_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
catalog: &[rpi_ai::Model],
lane: &Arc<dyn AgentLane>,
lane_model_id: &str,
chat: &Arc<Container>,
initial_search: Option<&str>,
) -> Option<Arc<SearchableSelectList>> {
let items = model_selector_items(catalog, lane_model_id);
if items.is_empty() {
add_note_message(
chat,
"No models in the catalog. Use --model at startup to select one.",
);
tui.request_render(false);
return None;
}
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);
}));
let searchable = open_searchable_selector(
state,
editor_container,
editor,
tui,
Some("Select model"),
Some("Type to filter by name, provider, or id"),
list,
SelectorKind::Model,
);
if let Some(term) = initial_search {
searchable.set_search_text(term);
}
Some(searchable)
}
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 cycle_prev_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 prev = (i + catalog.len() - 1) % catalog.len();
Some(catalog[prev].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();
for path in crate::session::list_session_files_sync(&dir) {
let summary = crate::session::summarize_session_file(&path);
if summary.empty {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("(unnamed)")
.to_string();
let label = crate::session::session_display_label(&summary);
let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
let short_id = crate::session::short_session_id(id_tail);
let description = format!(
"{} · {}",
crate::session::format_session_bytes(summary.bytes),
short_id,
);
items.push(
SelectItem::new(&stem, &label)
.with_description(&description)
.with_search_text(&format!("{label} {short_id} {stem}")),
);
}
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 mut list = SelectList::new(items, 10);
list.set_layout(rpi_tui::SelectListLayoutOptions {
min_primary_column_width: Some(56),
max_primary_column_width: Some(72),
truncate_primary: None,
});
let list = Arc::new(list);
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_searchable_selector(
state,
editor_container,
editor,
tui,
Some("Resume session"),
Some("Type to filter sessions"),
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 label = if current.as_deref() == Some(entry.id()) {
format!("{} #{} (current)", entry.entry_type(), entry.seq())
} else {
format!("{} #{}", entry.entry_type(), entry.seq())
};
let short_id = &entry.id()[..entry.id().len().min(12)];
let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
SelectItem::new(entry.id(), &label)
.with_description(short_id)
.with_search_text(&search_text)
})
.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_searchable_selector(
state,
editor_container,
editor,
tui,
Some("Session tree"),
Some("Type to filter by entry type, sequence, or id"),
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" => Some(ThemePreset::Light),
"monochrome" => Some(ThemePreset::Monochrome),
"dark" => Some(ThemePreset::Dark),
name => {
add_error_message(&chat_sel, &format!("Unknown theme `{name}`."));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
return;
}
};
let Some(preset) = preset else { return };
apply_theme_preset(preset);
state_sel.theme_manager.apply_preset(preset);
add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
tui_sel.render_now(true);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Theme,
);
}
fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
use rpi_ai::types::ThinkingLevel::*;
match level {
Off => "Off — No reasoning",
Minimal => "Minimal — Brief reasoning (~1k tokens)",
Low => "Low — Light reasoning (~1k tokens)",
Medium => "Medium — Moderate reasoning (~80% of max)",
High => "High — Extensive reasoning (~95% of max)",
Xhigh => "Xhigh — Near-maximal reasoning",
Max => "Max — Maximum reasoning",
}
}
fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
use rpi_ai::types::ThinkingLevel::*;
match level {
Off => "off",
Minimal => "minimal",
Low => "low",
Medium => "medium",
High => "high",
Xhigh => "xhigh",
Max => "max",
}
}
fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
use rpi_ai::types::ThinkingLevel::*;
match name.to_ascii_lowercase().as_str() {
"off" => Some(Off),
"minimal" => Some(Minimal),
"low" => Some(Low),
"medium" => Some(Medium),
"high" => Some(High),
"xhigh" => Some(Xhigh),
"max" => Some(Max),
_ => None,
}
}
fn open_thinking_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
lane_model_id: &str,
chat: &Arc<Container>,
) {
let model = catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
let levels: Vec<rpi_ai::types::ThinkingLevel> = model
.map(|m| m.supported_thinking_levels())
.unwrap_or_else(|| {
use rpi_ai::types::ThinkingLevel::*;
vec![Off, Minimal, Low, Medium, High]
});
let mut items: Vec<SelectItem> = Vec::new();
for lvl in &levels {
let name = thinking_level_name(*lvl);
items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
}
if items.is_empty() {
add_note_message(chat, "This model has no supported thinking levels.");
tui.request_render(false);
return;
}
let list = Arc::new(SelectList::new(items, 10));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let Some(level) = thinking_level_from_name(&item.value) else {
add_note_message(
&chat_sel,
&format!("Unknown thinking level: {}.", item.label),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
return;
};
let lane = lane_sel.clone();
let footer_sel = state_sel.footer.clone();
tokio::spawn(async move {
let _ = lane.set_thinking_level(level).await;
});
footer_sel.set_thinking_level(Some(thinking_level_name(level)));
add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Thinking,
);
}
fn open_tools_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
lane: &Arc<dyn AgentLane>,
chat: &Arc<Container>,
) {
let mut active = match tokio::runtime::Handle::try_current() {
Ok(h) => h
.block_on(async { lane.get_active_tools().await })
.unwrap_or_default(),
Err(_) => Vec::new(),
};
if active.is_empty() {
active = crate::session::BUILTIN_TOOL_NAMES
.iter()
.map(|name| (*name).to_string())
.collect();
}
let mut items: Vec<SelectItem> = Vec::new();
for name in crate::session::BUILTIN_TOOL_NAMES {
let on = active.iter().any(|a| a == name);
let label = if on {
format!("{name} (on)")
} else {
(*name).to_string()
};
items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
}
let list = Arc::new(SelectList::new(items, 10));
let active_captured = active.clone();
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
let lane_sel = lane.clone();
list.on_select(Arc::new(move |item| {
let mut next = active_captured.clone();
if let Some(pos) = next.iter().position(|a| a == &item.value) {
next.remove(pos);
} else {
next.push(item.value.clone());
}
let on = next.iter().any(|a| a == &item.value);
let lane = lane_sel.clone();
let next_clone = next.clone();
tokio::spawn(async move {
let _ = lane.set_active_tools(next_clone).await;
});
let list_str = if next.is_empty() {
"(none)".to_string()
} else {
next.join(", ")
};
add_note_message(
&chat_sel,
&format!(
"{} {} — active tools: {}",
item.value,
if on { "enabled" } else { "disabled" },
list_str
),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Tools,
);
}
fn open_images_selector(
state: &Arc<TuiState>,
editor_container: &Arc<Container>,
editor: &Arc<Editor>,
tui: &Arc<TuiAltScreen>,
chat: &Arc<Container>,
) {
let current = *state.show_images.lock().unwrap();
let items = vec![
SelectItem::new("yes", "Yes").with_description(if current {
"Inline images (current)"
} else {
"Inline images"
}),
SelectItem::new("no", "No").with_description(if current {
"Placeholder only"
} else {
"Placeholder only (current)"
}),
];
let list = Arc::new(SelectList::new(items, 5));
let state_sel = state.clone();
let ec_sel = editor_container.clone();
let editor_sel = editor.clone();
let tui_sel = tui.clone();
let chat_sel = chat.clone();
list.on_select(Arc::new(move |item| {
let on = item.value == "yes";
*state_sel.show_images.lock().unwrap() = on;
add_note_message(
&chat_sel,
&format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
);
close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
}));
let state_cancel = state.clone();
let ec_cancel = editor_container.clone();
let editor_cancel = editor.clone();
let tui_cancel = tui.clone();
list.on_cancel(Arc::new(move || {
close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
}));
open_selector(
state,
editor_container,
editor,
tui,
list,
SelectorKind::Images,
);
}
fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
let text = editor.get_text();
let cursor = editor_cursor_offset(editor, &text);
let suggestions = state.autocomplete.get_suggestions(&text, cursor);
render_autocomplete(state, suggestions);
}
fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
let (row, col) = editor.cursor_position();
let mut offset = 0;
for (index, line) in text.split('\n').enumerate() {
if index == row {
return (offset + col.min(line.len())).min(text.len());
}
offset = offset.saturating_add(line.len() + 1);
}
text.len()
}
fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
let offset = offset.min(text.len());
let mut consumed = 0;
for (row, line) in text.split('\n').enumerate() {
let end = consumed + line.len();
if offset <= end {
editor.set_cursor(row, offset - consumed);
return;
}
consumed = end + 1;
}
let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
editor.set_cursor(
last_row,
text.rsplit('\n').next().map(str::len).unwrap_or(0),
);
}
fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
state.autocomplete_container.clear();
let Some(sugg) = suggestions else {
return;
};
if sugg.items.is_empty() {
return;
}
let accent = state.theme_manager.get().colors.accent;
let muted = state.theme_manager.get().colors.muted;
for (i, item) in sugg
.items
.iter()
.take(state.autocomplete_max_visible)
.enumerate()
{
let prefix = if i == 0 { "→ " } else { " " };
let label = item.display_text();
let line = if i == 0 {
format!(
"{prefix}{} {}",
accent.fg(label),
muted.fg(item.description.as_deref().unwrap_or(""))
)
} else {
format!(
"{prefix}{} {}",
muted.fg(label),
muted.fg(item.description.as_deref().unwrap_or(""))
)
};
state
.autocomplete_container
.add_child(Arc::new(Text::new(line, 1, 0)));
}
}
fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
let text = editor.get_text();
let cursor = editor_cursor_offset(editor, &text);
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);
set_editor_cursor_offset(editor, &replaced, new_cursor);
state.autocomplete_container.clear();
true
}
fn add_welcome_message(container: &Arc<Container>) {
add_welcome_message_with_capabilities(container, &[], &[]);
}
fn add_welcome_message_with_capabilities(
container: &Arc<Container>,
active_tools: &[String],
skills: &[String],
) {
let c = current_theme().colors;
container.add_child(Arc::new(crate::brand::BrandLockup::new()));
container.add_child(Arc::new(Spacer::new(1)));
let title = c.muted.fg("interactive TUI · library-first agent runtime");
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 clear · Esc abort · /help");
container.add_child(Arc::new(Text::new(hint, 1, 0)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
welcome_capability_line("Tools", active_tools),
1,
0,
)));
container.add_child(Arc::new(Text::new(
welcome_capability_line("Skills", skills),
1,
0,
)));
container.add_child(Arc::new(DynamicBorder::new()));
}
fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
let colors = current_theme().colors;
let group = Arc::new(Container::new());
for warning in &report.warnings {
let body = format!(
"{}\n{} {}{}",
colors.error.fg(&warning.message),
colors.muted.fg("Run"),
colors.accent.fg(&warning.command),
colors.muted.fg(" to retry.")
);
add_update_panel(&group, "Update Failed", &body, colors.error);
}
if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
let body = format!(
"{} {}{}",
colors
.muted
.fg(&format!("New version {} is available. Run", notice.latest)),
colors.accent.fg(¬ice.command),
colors.muted.fg(".")
);
add_update_panel(&group, "Update Available", &body, colors.warning);
}
if group.child_count() > 0 {
container.add_child(group);
}
}
fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(DynamicBorder::with_color(color)));
container.add_child(Arc::new(Text::new(
format!("{}\n{body}", color.fg(&tui_bold(title))),
1,
0,
)));
container.add_child(Arc::new(DynamicBorder::with_color(color)));
}
fn welcome_capability_line(label: &str, names: &[String]) -> String {
let c = current_theme().colors;
let value = if names.is_empty() {
"none".to_string()
} else {
names.join(" · ")
};
format!(
"{} {}",
c.accent.fg(&format!("{label} ({})", names.len())),
c.muted.fg(&value)
)
}
fn add_help_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("📚 Available Commands")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let cmds: &[(&str, &str)] = &[
("/help, /?", "Show this help message"),
("/clear, /new", "Clear the conversation"),
("/exit, /quit, /q", "Exit the application"),
("/version, /v", "Show version information"),
("/changelog", "Show recent release changes"),
("/model, /m", "Choose a model (live switch)"),
("/thinking, /think", "Set reasoning depth (selector)"),
("/tools", "Toggle built-in tools on/off"),
("/images", "Toggle inline image rendering"),
("/session", "List saved sessions"),
("/theme", "Choose a theme (selector)"),
("/compact", "Compact the conversation"),
("/copy", "Copy last reply to clipboard"),
("/hotkeys", "Show keyboard shortcuts"),
];
let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (cmd, desc) in cmds {
let row = format!(
" {:<cmd_w$} {} {}",
c.accent.fg(cmd),
c.dim.fg("—"),
c.muted.fg(desc)
);
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_version_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("📦 Version Information")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
container.add_child(Arc::new(Text::new(
format!(
" {} {}",
c.muted.fg("rpi-cli"),
c.text.fg(&format!("v{}", crate::VERSION))
),
1,
0,
)));
container.add_child(Arc::new(Text::new(
format!(
" {}",
c.dim.fg("Rust implementation of pi coding agent TUI")
),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_changelog_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("Recent Changes")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let entries = [
(
"Native parity phase 1",
"models, images, trust, export, and JSON events",
),
(
"TUI controls",
"external editor, thinking levels, and tool output toggles",
),
(
"Provider auth",
"OpenAI-compatible API key aliases and gateway headers",
),
];
for (release, summary) in entries {
let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Text::new(
format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_hotkeys_message(container: &Arc<Container>) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
let keys: &[(&str, &str)] = &[
("Enter", "Send message"),
("Shift+Enter", "New line"),
("Tab", "Accept autocomplete suggestion"),
("Ctrl+A / Ctrl+E", "Line start / end"),
(
"Ctrl+K / Ctrl+U",
"Kill to end / start of line (Ctrl+Y yanks)",
),
("Ctrl+- / Ctrl+R", "Undo / redo"),
("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
("Alt+Backspace", "Kill previous word"),
("Ctrl+C", "Clear the editor (press twice quickly to exit)"),
("Ctrl+X", "Copy the editor selection or the last reply"),
("Esc", "Abort a running prompt"),
("Ctrl+L", "Open model selector"),
("Ctrl+P", "Cycle to the next model (live)"),
("Ctrl+O", "Expand/collapse all tool output"),
("Ctrl+T", "Show/hide reasoning blocks"),
("PageUp/Down", "Scroll transcript by one page"),
("Home / End", "Jump to transcript start / latest output"),
];
let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, desc) in keys {
let row = format!(
" {:<key_w$} {} {}",
c.accent.fg(key),
c.dim.fg("—"),
c.muted.fg(desc)
);
container.add_child(Arc::new(Text::new(row, 1, 0)));
}
container.add_child(Arc::new(Spacer::new(1)));
}
fn add_user_message(container: &Arc<Container>, text: &str) {
container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
container.add_child(Arc::new(Spacer::new(1)));
}
fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
let width = 80; chat_container.render(width)
}
fn format_resume_command(
session_id: &str,
cwd: &std::path::Path,
session_dir: Option<&std::path::Path>,
) -> Option<String> {
if session_id.is_empty() {
return None;
}
let mut args = vec![crate::APP_NAME.to_string()];
if let Some(dir) = session_dir {
let default = crate::session::default_session_dir(cwd);
if dir != default.as_path() {
args.push("--session-dir".to_string());
args.push(quote_shell_arg(&dir.to_string_lossy()));
}
}
args.push("--session".to_string());
args.push(session_id.to_string());
Some(args.join(" "))
}
fn quote_shell_arg(value: &str) -> String {
let needs_quoting = value.is_empty()
|| value
.chars()
.any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
if needs_quoting {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
} else {
value.to_string()
}
}
fn add_error_message(container: &Arc<Container>, text: &str) {
let c = current_theme().colors;
let text = sanitize_error_message(text);
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 sanitize_error_message(text: &str) -> String {
const MAX_ERROR_CHARS: usize = 16 * 1024;
let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
let mut count = 0;
let mut escape_mode = 0u8;
for ch in text.chars() {
if escape_mode != 0 {
match escape_mode {
1 if ch == '[' => escape_mode = 2,
1 if ch == ']' => escape_mode = 3,
1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
2 if ('@'..='~').contains(&ch) => escape_mode = 0,
3 if ch == '\x07' => escape_mode = 0,
_ => {}
}
continue;
}
if ch == '\x1b' {
escape_mode = 1;
continue;
}
if count >= MAX_ERROR_CHARS {
result.push_str("…");
break;
}
match ch {
'\n' | '\t' => {
result.push(ch);
count += 1;
}
'\r' => {}
c if c.is_control() => {}
c => {
result.push(c);
count += 1;
}
}
}
if result.trim().is_empty() {
"Provider request failed.".to_string()
} else {
result
}
}
fn add_note_message(container: &Arc<Container>, text: &str) {
let c = current_theme().colors;
container.add_child(Arc::new(Text::new(
format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
1,
0,
)));
container.add_child(Arc::new(Spacer::new(1)));
}
fn show_context_panel(
chat: &Arc<Container>,
resources: &Arc<rpi_harness::types::AgentHarnessResources>,
) {
let skills = resources.skills.as_deref().unwrap_or(&[]);
let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
let mut lines: Vec<String> = Vec::new();
lines.push("📂 Discovered resources for this session:".into());
if skills.is_empty() {
lines.push(
" Skills: (none discovered — create .rpi/skills/ or ~/.rpi/agent/skills/)".into(),
);
} else {
lines.push(format!(" Skills ({}):", skills.len()));
for s in skills {
let marker = if s.disable_model_invocation == Some(true) {
" [hidden]"
} else {
""
};
let desc: String = s.description.chars().take(72).collect();
lines.push(format!(" • {}{marker} — {desc}", s.name));
}
}
if templates.is_empty() {
lines.push(
" Prompt templates: (none — create .rpi/prompts/ 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 auto_send_cancels_when_the_user_edits_the_draft() {
let now = std::time::Instant::now();
let pending = AutoSendPending {
deadline: now + std::time::Duration::from_secs(2),
text: "hello world".to_string(),
};
assert!(matches!(
auto_send_step(Some(&pending), "hello world", true, now),
AutoSendStep::Wait(_)
));
assert_eq!(
auto_send_step(Some(&pending), "hello worlds", true, now),
AutoSendStep::Cancel
);
assert_eq!(
auto_send_step(Some(&pending), "", true, now),
AutoSendStep::Cancel
);
assert_eq!(
auto_send_step(Some(&pending), " ", true, now),
AutoSendStep::Cancel
);
}
#[test]
fn auto_send_submits_at_the_deadline_only_while_idle() {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2000);
let pending = AutoSendPending {
deadline,
text: "go".to_string(),
};
assert!(matches!(
auto_send_step(
Some(&pending),
"go",
true,
deadline - std::time::Duration::from_millis(1)
),
AutoSendStep::Wait(_)
));
assert_eq!(
auto_send_step(Some(&pending), "go", true, deadline),
AutoSendStep::Submit
);
assert_eq!(
auto_send_step(Some(&pending), "go", false, deadline),
AutoSendStep::Cancel
);
assert_eq!(
auto_send_step(None, "go", true, deadline),
AutoSendStep::Idle
);
}
#[test]
fn auto_send_footer_label_is_recognisable() {
let label = format!("{AUTO_SEND_PREFIX}2.0s · any key to edit");
assert!(label.starts_with(AUTO_SEND_PREFIX));
assert!(!"Working…".starts_with(AUTO_SEND_PREFIX));
}
#[test]
fn markdown_flag_only_comes_from_an_explicit_true() {
assert!(tool_result_requests_markdown(Some(
&serde_json::json!({"kind": "todo", "markdown": true})
)));
assert!(!tool_result_requests_markdown(None));
assert!(!tool_result_requests_markdown(Some(
&serde_json::json!({"kind": "todo"})
)));
assert!(!tool_result_requests_markdown(Some(
&serde_json::json!({"markdown": false})
)));
assert!(!tool_result_requests_markdown(Some(
&serde_json::json!({"markdown": "true"})
)));
}
fn test_tui_state() -> Arc<TuiState> {
Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
})
}
#[test]
fn editor_change_source_separates_tui_writes_from_typing() {
use std::sync::atomic::Ordering;
let state = test_tui_state();
assert!(state.take_editor_change().is_none());
state.editor_text.push(rpi_extensions::EditorTextEdit {
text: "你好".to_string(),
mode: rpi_extensions::EditorTextMode::Replace,
auto_send_ms: Some(2000),
});
assert!(state.drain_editor_text(), "the queued draft must land");
assert!(
state.programmatic_editor_write.load(Ordering::SeqCst),
"a mailbox-applied edit must be marked programmatic"
);
let payload = state
.take_editor_change()
.expect("draft applied ⇒ a change");
assert_eq!(payload["source"], serde_json::json!("extension"));
assert_eq!(payload["chars"], serde_json::json!(2));
assert_eq!(payload["empty"], serde_json::json!(false));
assert!(state.take_editor_change().is_none());
state.editor.set_text("你好啊");
let payload = state.take_editor_change().expect("typing ⇒ a change");
assert_eq!(payload["source"], serde_json::json!("user"));
assert_eq!(payload["chars"], serde_json::json!(3));
}
#[test]
fn programmatic_mark_is_consumed_even_with_no_subscriber() {
use std::sync::atomic::Ordering;
let state = test_tui_state();
assert!(
state
.extension_session
.lock()
.unwrap()
.snapshot_arc()
.is_none(),
"this test relies on nobody subscribing"
);
state.editor_text.push(rpi_extensions::EditorTextEdit {
text: "draft".to_string(),
mode: rpi_extensions::EditorTextMode::Replace,
auto_send_ms: None,
});
state.drain_editor_text();
state.sync_editor_change();
assert!(!state.programmatic_editor_write.load(Ordering::SeqCst));
state.editor.set_text("draft +");
let payload = state.take_editor_change().expect("typing ⇒ a change");
assert_eq!(payload["source"], serde_json::json!("user"));
}
#[test]
fn submit_clear_is_attributed_to_the_host_not_the_user() {
let state = test_tui_state();
state.editor.set_text("/voice auto");
assert_eq!(
state.take_editor_change().unwrap()["source"],
serde_json::json!("user")
);
state.clear_editor_programmatically();
let payload = state.take_editor_change().expect("clearing is a change");
assert_eq!(payload["empty"], serde_json::json!(true));
assert_eq!(
payload["source"],
serde_json::json!("extension"),
"a submit-clear must not be reported as the user typing"
);
}
#[test]
fn user_initiated_clear_stays_a_user_change() {
let state = test_tui_state();
state.editor.set_text("draft");
assert_eq!(
state.take_editor_change().unwrap()["source"],
serde_json::json!("user")
);
state.editor.clear();
let payload = state.take_editor_change().expect("clearing is a change");
assert_eq!(payload["source"], serde_json::json!("user"));
}
#[test]
fn live_panel_repaints_while_a_tool_or_bash_is_running() {
let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
assert!(!transcript_has_live_panel(false, &empty));
let mut tools = HashMap::new();
let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
tool.set_running();
tools.insert("tc1".to_string(), tool);
assert!(transcript_has_live_panel(false, &tools));
tools.get("tc1").unwrap().set_result("done", false);
assert!(!transcript_has_live_panel(false, &tools));
assert!(transcript_has_live_panel(true, &tools));
}
#[test]
fn dequeue_merge_preserves_a_typed_draft() {
assert_eq!(merge_queued_into_draft("queued", ""), "queued");
assert_eq!(merge_queued_into_draft("queued", " "), "queued");
assert_eq!(
merge_queued_into_draft("queued", "typed"),
"queued\n\ntyped"
);
}
#[test]
fn dequeue_places_the_caret_at_the_end_of_the_last_line_in_bytes() {
let editor = Arc::new(Editor::simple());
let text = "排队\n第二行";
set_editor_text_caret_at_end(&editor, text);
assert_eq!(editor.get_text(), text);
assert_eq!(
editor.cursor_position(),
(1, "第二行".len()),
"caret must sit at the end of the last line, in bytes"
);
}
#[test]
fn settings_menu_covers_the_wired_settings_surface() {
let state = test_tui_state();
let settings = crate::settings::Settings::default();
let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
assert_eq!(
keys,
[
"theme",
"model",
"thinking",
"scoped-models",
"hide-thinking",
"show-images",
"cache-miss-notices",
"quiet-startup",
"terminal-progress",
"fullscreen-copy-on-select",
"double-escape-action",
"editor-padding",
"autocomplete-max-items",
"http-idle-timeout",
]
);
for item in items.iter().filter(|item| item.has_submenu) {
assert!(
item.values.is_empty(),
"{} should be submenu-only",
item.key
);
}
for item in items.iter().filter(|item| !item.has_submenu) {
assert!(!item.values.is_empty(), "{} has no values", item.key);
assert!(
item.values.iter().any(|value| value == &item.value),
"{} current value {} not in {:?}",
item.key,
item.value,
item.values
);
}
assert_eq!(items[1].value, "claude-sonnet-5");
assert_eq!(items[6].value, "false");
assert_eq!(items[8].value, "true");
}
#[test]
fn apply_setting_change_updates_settings_and_live_state() {
let state = test_tui_state();
let mut settings = crate::settings::Settings::default();
let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
assert!(state.hide_thinking());
assert_eq!(settings.hide_thinking_block, Some(true));
assert!(note.expect("user-visible note").contains("hidden"));
apply_setting_change(&state, "show-images", "false", &mut settings);
assert!(!*state.show_images.lock().unwrap());
assert_eq!(settings.show_images, Some(false));
apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
assert!(*state.cache_miss_notices.lock().unwrap());
assert_eq!(settings.show_cache_miss_notices, Some(true));
assert_eq!(
apply_setting_change(&state, "editor-padding", "3", &mut settings),
None
);
assert_eq!(settings.editor_padding_x, Some(3));
apply_setting_change(&state, "double-escape-action", "none", &mut settings);
assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
assert_eq!(settings.autocomplete_max_visible, Some(20));
apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
assert_eq!(settings.http_idle_timeout_ms(), Some(0));
apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
assert_eq!(
apply_setting_change(&state, "nope", "x", &mut settings),
None
);
}
#[test]
fn add_user_message_renders_a_visible_user_bubble() {
let chat = Arc::new(Container::new());
add_user_message(&chat, "hello from the user");
let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
let plain = strip_ansi(&rendered.join("\n"));
assert!(
plain.contains("hello from the user"),
"user bubble must show the prompt text; got: {plain:?}"
);
}
#[test]
fn verbose_overrides_quiet_startup_listing() {
assert!(should_show_startup_listing(false, false));
assert!(should_show_startup_listing(true, false));
assert!(should_show_startup_listing(true, true));
assert!(!should_show_startup_listing(false, true));
}
#[test]
fn resume_command_omits_the_default_session_dir() {
let cwd = std::path::Path::new("/proj/demo");
assert_eq!(format_resume_command("", cwd, None), None);
assert_eq!(
format_resume_command("abc-123", cwd, None).as_deref(),
Some("rpi --session abc-123")
);
let default = crate::session::default_session_dir(cwd);
assert_eq!(
format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
Some("rpi --session abc-123")
);
let custom = std::path::Path::new("/tmp/my sessions");
assert_eq!(
format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
);
}
#[test]
fn quote_shell_arg_quotes_only_when_needed() {
assert_eq!(quote_shell_arg("plain"), "plain");
assert_eq!(quote_shell_arg("with space"), "\"with space\"");
assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
}
#[test]
fn trust_command_uses_the_session_cwd_after_process_chdir() {
const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
const TEST_NAME: &str =
"interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
if std::env::var_os(CHILD_ENV).is_some() {
let session_cwd = std::path::PathBuf::from(
std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
);
let changed_cwd = std::env::current_dir().unwrap();
set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
assert_eq!(
crate::config::project_trust_decision(&session_cwd).unwrap(),
Some(true)
);
assert_eq!(
crate::config::project_trust_decision(&changed_cwd).unwrap(),
None
);
return;
}
let tmp = tempfile::tempdir().unwrap();
let session_cwd = tmp.path().join("session-project");
let changed_cwd = tmp.path().join("extension-cwd");
let agent_dir = tmp.path().join("agent");
std::fs::create_dir_all(&session_cwd).unwrap();
std::fs::create_dir_all(&changed_cwd).unwrap();
std::fs::create_dir_all(&agent_dir).unwrap();
let status = std::process::Command::new(std::env::current_exe().unwrap())
.arg("--exact")
.arg(TEST_NAME)
.arg("--nocapture")
.env(CHILD_ENV, "1")
.env(SESSION_CWD_ENV, &session_cwd)
.env(crate::config::CONFIG_DIR_ENV, &agent_dir)
.current_dir(&changed_cwd)
.status()
.unwrap();
assert!(status.success(), "child test process failed: {status}");
}
#[test]
fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
let global = crate::settings::Settings {
editor_padding_x: Some(3),
autocomplete_max_visible: Some(6),
hide_thinking_block: Some(false),
quiet_startup: Some(true),
show_terminal_progress: Some(true),
..Default::default()
};
let rpi_project = crate::settings::Settings {
editor_padding_x: Some(0),
hide_thinking_block: Some(true),
quiet_startup: Some(false),
show_terminal_progress: Some(false),
..Default::default()
};
let pi_project = crate::settings::Settings {
editor_padding_x: Some(9),
autocomplete_max_visible: Some(12),
quiet_startup: Some(true),
..Default::default()
};
assert_eq!(
resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
TuiStartupSettings {
editor_padding_x: 0,
autocomplete_max_visible: 12,
hide_thinking: true,
quiet_startup: false,
show_terminal_progress: false,
show_images: true,
cache_miss_notices: false,
}
);
}
#[test]
fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
let global = crate::settings::Settings {
quiet_startup: Some(false),
..Default::default()
};
let rpi_project = crate::settings::Settings::default();
let pi_project = crate::settings::Settings {
quiet_startup: Some(true),
..Default::default()
};
let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
assert!(resolved.quiet_startup);
}
#[test]
fn tui_startup_settings_use_global_values_without_project_fields() {
let global = crate::settings::Settings {
editor_padding_x: Some(7),
autocomplete_max_visible: Some(8),
hide_thinking_block: Some(true),
quiet_startup: Some(true),
show_terminal_progress: Some(false),
show_images: Some(false),
show_cache_miss_notices: Some(true),
..Default::default()
};
assert_eq!(
resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
TuiStartupSettings {
editor_padding_x: 7,
autocomplete_max_visible: 8,
hide_thinking: true,
quiet_startup: true,
show_terminal_progress: false,
show_images: false,
cache_miss_notices: true,
}
);
}
#[test]
fn tui_startup_settings_read_native_nested_terminal_block() {
let global = crate::settings::Settings {
terminal: Some(crate::settings::TerminalSettings {
show_images: Some(false),
show_terminal_progress: Some(false),
clear_on_shrink: None,
}),
..Default::default()
};
let resolved =
resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
assert!(!resolved.show_images);
assert!(!resolved.show_terminal_progress);
}
#[test]
fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
let global = crate::settings::Settings {
show_images: Some(true),
terminal: Some(crate::settings::TerminalSettings {
show_images: Some(false),
show_terminal_progress: None,
clear_on_shrink: None,
}),
..Default::default()
};
let resolved =
resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
assert!(resolved.show_images);
}
#[test]
fn tui_startup_settings_ignore_untrusted_project_values() {
let global = crate::settings::Settings {
quiet_startup: Some(false),
show_terminal_progress: Some(true),
..Default::default()
};
let project = crate::settings::Settings {
quiet_startup: Some(true),
show_terminal_progress: Some(false),
..Default::default()
};
let resolved = resolve_tui_startup_settings(&global, &[project], false);
assert!(!resolved.quiet_startup);
assert!(resolved.show_terminal_progress);
}
#[test]
fn transcript_page_uses_viewport_with_overlap() {
assert_eq!(transcript_page_size(24), 20);
assert_eq!(transcript_page_size(4), 1);
assert_eq!(transcript_page_size(0), 1);
}
#[test]
fn parse_user_bash_distinguishes_single_and_double_bang() {
assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
}
#[test]
fn parse_user_bash_ignores_bare_bang_and_plain_text() {
assert_eq!(parse_user_bash("!"), None);
assert_eq!(parse_user_bash("!!"), None);
assert_eq!(parse_user_bash("! "), None);
assert_eq!(parse_user_bash("hello"), None);
assert_eq!(parse_user_bash(""), None);
assert_eq!(parse_user_bash("/model"), None);
}
#[test]
fn user_bash_run_slot_guards_and_cancels() {
let state = test_tui_state();
assert!(!state.user_bash_running());
let cancel = state.begin_user_bash();
assert!(state.user_bash_running());
assert!(state.cancel_user_bash());
assert!(state.user_bash_running());
assert!(cancel.is_cancelled());
state.finish_user_bash();
assert!(!state.user_bash_running());
assert!(!state.cancel_user_bash());
}
#[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_with_capabilities(
&chat,
&["read".into(), "bash".into(), "web_fetch".into()],
&["rust-review".into(), "release".into()],
);
let lines = chat.render(80);
let all: String = lines.join("\n");
let plain = strip_ansi(&all);
assert!(
plain.contains("rpi"),
"Welcome message not in chat container: {:?}",
lines
);
assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
assert!(
plain.contains("read · bash · web_fetch"),
"Tool names missing: {plain}"
);
assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
assert!(
plain.contains("rust-review · release"),
"Skill names missing: {plain}"
);
}
#[test]
fn welcome_header_renders_the_brand_mark() {
let chat = Arc::new(Container::new());
add_welcome_message(&chat);
let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
let all = lines.join("\n");
assert!(
lines.iter().any(|l| l.contains("██ ██ ██")),
"brand mark missing: {all}"
);
assert!(
all.contains("rpi"),
"the mark must carry the product name: {all}"
);
assert!(
!all.contains('π'),
"the header must not use east-asian-ambiguous glyphs: {all}"
);
}
#[test]
fn update_notices_render_inside_the_transcript() {
let chat = Arc::new(Container::new());
let report = crate::updates::UpdateReport {
notices: vec![crate::updates::UpdateNotice {
name: "rpi".into(),
current: "0.1.10".into(),
latest: "0.1.11".into(),
command: "rpi update".into(),
}],
warnings: vec![crate::updates::UpdateWarning {
message: "The previously scheduled rpi update failed: access denied".into(),
command: "rpi update".into(),
}],
};
add_update_notices(&chat, &report);
assert_eq!(chat.child_count(), 1);
let plain = strip_ansi(&chat.render(80).join("\n"));
assert!(plain.contains("Update Failed"), "{plain}");
assert!(
plain.contains("rpi update failed: access denied"),
"{plain}"
);
assert!(plain.contains("Update Available"), "{plain}");
assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
assert!(plain.contains("rpi update"), "{plain}");
}
#[test]
fn empty_update_report_does_not_add_transcript_content() {
let chat = Arc::new(Container::new());
add_update_notices(&chat, &crate::updates::UpdateReport::default());
assert_eq!(chat.child_count(), 0);
}
#[test]
fn welcome_capabilities_show_empty_state() {
let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
assert_eq!(plain, "Skills (0) none");
}
#[test]
fn completed_stream_reconciles_the_final_tail_before_detaching() {
let component = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
component.set_streaming(true);
component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
let current = Arc::new(Mutex::new(Some(component.clone())));
let cached = Mutex::new("partial response".to_string());
let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
final_message.content = vec![Content::text(
"partial response with the previously missing final tail",
)];
final_message.stop_reason = rpi_ai::types::StopReason::Stop;
reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
assert!(current.lock().unwrap().is_none());
assert_eq!(
cached.lock().unwrap().as_str(),
"partial response with the previously missing final tail"
);
let rendered = strip_ansi(&component.render(100).join("\n"));
assert!(
rendered.contains("previously missing final tail"),
"{rendered}"
);
}
#[test]
fn tab_accept_suggestion_reflects_in_next_render() {
use rpi_tui::render_layout_frame;
let editor = Arc::new(Editor::new(
EditorOptions {
padding_x: 1,
..Default::default()
},
EditorStyle::default(),
Arc::new(rpi_tui::Keybindings::new()),
));
editor.set_focused(true);
let editor_container = Arc::new(Container::new());
editor_container.add_child(editor.clone());
let autocomplete_container = Arc::new(Container::new());
let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
let dock = Arc::new(VStack::from_children(vec![
StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
StackChild::Entry(
StackEntry::new(editor_container.clone())
.shrink(0)
.min_size(3),
),
StackChild::Entry(StackEntry::new(footer)),
]));
let 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 cursor = editor_cursor_offset(&editor, &text);
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(' ');
}
let new_cursor = start + top.text.len();
editor.set_text(&replaced);
set_editor_cursor_offset(&editor, &replaced, new_cursor);
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 multiline_autocomplete_preserves_row_and_column() {
let editor = Arc::new(Editor::simple());
editor.set_text("first\n/mo");
editor.set_cursor(1, 3);
let text = editor.get_text();
assert_eq!(editor_cursor_offset(&editor, &text), 9);
let replaced = "first\n/model";
editor.set_text(replaced);
set_editor_cursor_offset(&editor, replaced, 12);
assert_eq!(editor.cursor_position(), (1, 6));
}
#[test]
fn test_slash_command_dispatch() {
let registry = build_builtin_registry();
let resolves_to = |token: &str, canonical: &str| {
let found = registry.find(token).expect("{token} should resolve");
assert_eq!(
found.name(),
canonical,
"{token} resolved to {} (expected {canonical})",
found.name()
);
};
resolves_to("/help", "/help");
resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
resolves_to("/hotkeys", "/hotkeys");
resolves_to("/model", "/model");
resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
resolves_to("/session", "/session");
resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
resolves_to("/copy", "/copy");
resolves_to("/thinking", "/thinking");
resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
resolves_to("/images", "/images");
resolves_to("/context", "/context");
resolves_to("/settings", "/settings");
resolves_to("/name", "/name");
resolves_to("/export", "/export");
assert!(registry.find("/nope").is_none(), "/nope should be unknown");
}
#[test]
fn test_registry_visible_entries_cover_dispatch() {
let registry = build_builtin_registry();
let names: Vec<String> = registry
.visible_entries()
.iter()
.map(|c| c.name.clone())
.collect();
for recognized in [
"/help",
"/clear",
"/new",
"/exit",
"/quit",
"/version",
"/changelog",
"/model",
"/session",
"/theme",
"/compact",
"/copy",
"/hotkeys",
"/tools",
"/images",
"/thinking",
"/usage",
] {
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: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
let assistant = AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![
Content::Thinking(ThinkingContent {
kind: ThinkingContentType,
thinking: "Reasoning about the reply.".into(),
thinking_signature: None,
redacted: false,
}),
Content::Text(TextContent {
kind: TextContentType,
text: "Hello.".into(),
text_signature: None,
}),
Content::ToolCall(ToolCall {
kind: ToolCallType,
id: "tc1".into(),
name: "bash".into(),
arguments: serde_json::json!({"command": "echo hi"}),
thought_signature: None,
namespace: None,
}),
],
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
};
let comp = Arc::new(AssistantMessageComponent::new(
AssistantMessageOptions::default(),
));
comp.set_streaming(true);
comp.update_blocks(&assistant_blocks(&assistant));
let chat = Arc::new(Container::new());
chat.add_child(comp.clone());
*state.current_assistant.lock().unwrap() = Some(comp);
for c in &assistant.content {
if let Content::ToolCall(tc) = c {
let mut tools = state.tool_components.lock().unwrap();
if !tools.contains_key(&tc.id) {
let tc_comp = Arc::new(ToolExecutionComponent::new(
&tc.name,
&tc.arguments.to_string(),
));
tc_comp.set_running();
chat.add_child(tc_comp.clone());
tools.insert(tc.id.clone(), tc_comp);
}
}
}
let rendered = chat.render(80);
let joined: String = rendered.join("\n");
assert!(
joined.contains("Hello."),
"assistant text not rendered: {joined}"
);
assert!(
joined.contains("Reasoning about the reply."),
"thinking block not rendered: {joined}"
);
assert_eq!(state.tool_components.lock().unwrap().len(), 1);
assert!(state.current_assistant.lock().unwrap().is_some());
let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
ended.set_result("hi", false);
assert!(state.tool_components.lock().unwrap().is_empty());
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!(state.editor.working().is_some());
assert_eq!(state.status_container.child_count(), 0);
assert!(state.footer.get_status().is_empty());
state.show_retry(3, 10, 8_000);
let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
assert!(retry_status.contains("Retrying (3/10)"));
state.set_status(RunStatus::Working);
{
let mut bash = state.bash_components.lock().unwrap();
bash.insert(
"bash-1".into(),
Arc::new(BashExecutionComponent::new("one")),
);
bash.insert(
"bash-2".into(),
Arc::new(BashExecutionComponent::new("two")),
);
}
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-1");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
state.bash_components.lock().unwrap().remove("bash-2");
state.sync_working_loader_with_bash();
assert_eq!(state.status_container.child_count(), 0);
assert!(state.editor.working().is_some());
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 context_badge_ignores_aborted_and_error_turns() {
use rpi_ai::types::{Api, StopReason, Usage};
let footer = Arc::new(FooterComponent::new());
footer.set_context_window(100_000);
let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
ok.stop_reason = StopReason::Stop;
ok.usage = Usage {
input: 25_000,
..Usage::zero()
};
record_context_usage(&footer, &ok);
let stats = strip_ansi(&footer.render(120).join("\n"));
assert!(stats.contains("25.0%/100k"), "stats: {stats}");
let mut aborted =
rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
aborted.stop_reason = StopReason::Aborted;
aborted.usage = Usage {
input: 90_000,
..Usage::zero()
};
record_context_usage(&footer, &aborted);
let stats = strip_ansi(&footer.render(120).join("\n"));
assert!(stats.contains("25.0%/100k"), "stats: {stats}");
let mut errored =
rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
errored.stop_reason = StopReason::Error;
errored.usage = Usage {
input: 90_000,
..Usage::zero()
};
record_context_usage(&footer, &errored);
let stats = strip_ansi(&footer.render(120).join("\n"));
assert!(stats.contains("25.0%/100k"), "stats: {stats}");
}
#[test]
fn test_short_model_name() {
assert_eq!(
short_model_name("anthropic:claude-sonnet-5"),
"claude-sonnet-5"
);
assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
}
#[test]
fn model_selector_items_are_deduplicated_and_provider_qualified() {
use rpi_ai::{Api, Model};
let mut gateway = Model::new(
"gpt-5.6-sol",
"GPT 5.6 Sol",
Api::OpenaiCompletions,
"routeryo-copy",
"https://gateway.example.com",
);
let duplicate = gateway.clone();
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
gateway.headers = Some(std::collections::BTreeMap::from([(
"authorization".into(),
"Bearer test".into(),
)]));
let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
assert_eq!(items.len(), 2);
assert_eq!(items[0].value, "gpt-5.6-sol");
assert_eq!(items[0].label, "GPT 5.6 Sol");
assert_eq!(
items[0].description.as_deref(),
Some("routeryo-copy/gpt-5.6-sol (current)")
);
assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
}
#[test]
fn model_selector_search_text_covers_provider_name_and_qualified_id() {
use rpi_ai::{Api, Model};
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let items = model_selector_items(&[anthropic], "");
let search = items[0].search_key();
assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
assert!(search.contains("Claude Sonnet 5"), "{search}");
use rpi_tui::fuzzy_filter;
for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
let matched = fuzzy_filter(&items, query, |item| item.search_key());
assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
}
}
#[test]
fn model_selector_match_accepts_bare_and_qualified_ids() {
use rpi_ai::{Api, Model};
let gateway = Model::new(
"gpt-5.6-sol",
"GPT 5.6 Sol",
Api::OpenaiCompletions,
"routeryo-copy",
"https://gateway.example.com",
);
let anthropic = Model::new(
"claude-sonnet-5",
"Claude Sonnet 5",
Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let catalog = [gateway, anthropic];
assert_eq!(
find_model_selector_match(&catalog, "gpt-5.6-sol")
.unwrap()
.provider,
"routeryo-copy"
);
assert_eq!(
find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
.unwrap()
.id,
"gpt-5.6-sol"
);
assert_eq!(
find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
.unwrap()
.id,
"claude-sonnet-5"
);
assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
}
#[test]
fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
let failed = AssistantMessage {
role: AssistantRole,
content: Vec::new(),
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: StopReason::Error,
deferred: None,
error_message: Some("upstream returned 401".into()),
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
};
assert_eq!(
assistant_error_text(&failed).as_deref(),
Some("upstream returned 401")
);
let mut no_detail = failed;
no_detail.error_message = Some(" ".into());
assert_eq!(
assistant_error_text(&no_detail).as_deref(),
Some("Provider request failed.")
);
let mut aborted = no_detail;
aborted.stop_reason = StopReason::Aborted;
aborted.error_message = Some("abort error: Request aborted".into());
assert_eq!(
assistant_error_text(&aborted).as_deref(),
Some("abort error: Request aborted")
);
aborted.error_message = None;
assert_eq!(
assistant_error_text(&aborted).as_deref(),
Some("Request aborted.")
);
}
#[test]
fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
assert_eq!(
sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
"405\nMethod Not Allowed"
);
assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
assert_eq!(sanitize_error_message(" "), "Provider request failed.");
let long = "x".repeat(20_000);
let cleaned = sanitize_error_message(&long);
assert!(cleaned.chars().count() <= 16 * 1024 + 1);
assert!(cleaned.ends_with('…'));
}
#[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_cycle_prev_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_prev_model(&catalog, "b").unwrap().id, "a");
assert_eq!(cycle_prev_model(&catalog, "a").unwrap().id, "c");
assert_eq!(cycle_prev_model(&catalog, "zzz").unwrap().id, "a");
let empty: Vec<Model> = vec![];
assert!(cycle_prev_model(&empty, "a").is_none());
}
#[test]
fn test_autocomplete_slash_suggestions_render() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let lines = state.autocomplete_container.render(80);
let joined: String = lines.join("\n");
assert!(
joined.contains("/help"),
"slash suggestions not rendered: {joined}"
);
editor.set_text("hello");
editor.set_cursor(0, 5);
refresh_autocomplete(&state, &editor);
assert!(state.autocomplete_container.render(80).is_empty());
}
#[test]
fn test_select_list_swap_restores_editor() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
let editor_container = Arc::new(Container::new());
let editor = Arc::new(Editor::simple());
editor_container.add_child(editor.clone());
assert!(!state.selector_open());
let tui_terminal = Box::new(ProcessTerminal::new());
let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
let list = Arc::new(SelectList::new(
vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
5,
));
open_selector(
&state,
&editor_container,
&editor,
&tui,
list,
SelectorKind::Theme,
);
assert!(state.selector_open());
assert_eq!(editor_container.child_count(), 1);
close_selector(&state, &editor_container, &editor, &tui);
assert!(!state.selector_open());
assert_eq!(editor_container.child_count(), 1);
}
#[test]
fn test_message_history_browse_restores_draft() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
let editor = Arc::new(Editor::simple());
push_history(&state, "first message");
push_history(&state, "second message");
push_history(&state, "second message");
push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
assert_eq!(state.history.lock().unwrap()[0], "second message");
editor.set_text("half-typed");
editor.set_cursor(0, 11);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "second message");
assert_eq!(*state.history_index.lock().unwrap(), 0);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
assert_eq!(*state.history_index.lock().unwrap(), 1);
navigate_history(&state, &editor, -1);
assert_eq!(editor.get_text(), "first message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "second message");
navigate_history(&state, &editor, 1);
assert_eq!(editor.get_text(), "half-typed");
assert_eq!(*state.history_index.lock().unwrap(), -1);
}
#[test]
fn test_accept_top_suggestion_replaces_prefix() {
let state = Arc::new(TuiState {
current_assistant: Arc::new(std::sync::Mutex::new(None)),
tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
hide_thinking: Arc::new(std::sync::Mutex::new(false)),
tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
show_terminal_progress: true,
status: std::sync::Mutex::new(RunStatus::Idle),
ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
editor_text: rpi_extensions::EditorTextMailbox::new(),
auto_send: std::sync::Mutex::new(None),
last_editor_text: std::sync::Mutex::new(String::new()),
programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
js_preparation_cancel: std::sync::Mutex::new(None),
user_bash_cancel: std::sync::Mutex::new(None),
pending_bash_messages: std::sync::Mutex::new(Vec::new()),
footer: Arc::new(FooterComponent::new()),
status_container: Arc::new(Container::new()),
chat_container: Arc::new(Container::new()),
loader: Arc::new(Loader::new()),
editor: Arc::new(Editor::simple()),
last_assistant_text: std::sync::Mutex::new(String::new()),
active_selector: std::sync::Mutex::new(None),
active_extension_editor: std::sync::Mutex::new(None),
active_extension_input: std::sync::Mutex::new(None),
active_extension_cancel: std::sync::Mutex::new(None),
autocomplete: AutocompleteManager::new(),
autocomplete_container: Arc::new(Container::new()),
autocomplete_max_visible: 5,
pending_images: std::sync::Mutex::new(Vec::new()),
theme_manager: Arc::new(ThemeManager::new()),
tui: None,
current_model_id: std::sync::Mutex::new(String::new()),
show_images: std::sync::Mutex::new(true),
cache_miss_notices: std::sync::Mutex::new(false),
history: std::sync::Mutex::new(Vec::new()),
history_index: std::sync::Mutex::new(-1),
history_draft: std::sync::Mutex::new(None),
cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
scoped_edit: std::sync::Mutex::new(None),
markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
extension_session: Arc::new(std::sync::Mutex::new(
rpi_extensions::ExtensionSession::none(),
)),
search: Arc::new(AltScreenSearch::new()),
search_bar: Arc::new(SearchBar::new()),
pending_container: Arc::new(Container::new()),
dequeue_hint: String::new(),
pending_snapshot: std::sync::Mutex::new(
rpi_harness::agent_harness::QueuedMessages::default(),
),
selection_start: std::sync::Mutex::new(None),
selection_end: std::sync::Mutex::new(None),
});
{
let mut combined = CombinedAutocompleteProvider::new();
combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
build_builtin_registry().visible_entries(),
)));
state.autocomplete.set_provider(Arc::new(combined));
}
let editor = Arc::new(Editor::simple());
editor.set_text("/he");
editor.set_cursor(0, 3);
refresh_autocomplete(&state, &editor);
let accepted = accept_top_suggestion(&state, &editor);
assert!(accepted, "should accept the top suggestion");
let text = editor.get_text();
assert!(
text.starts_with("/help"),
"editor text should start with /help, got {text}"
);
}
#[test]
fn configured_key_parser_supports_native_notation() {
let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
assert_eq!(combo.code, KeyCode::Char('g'));
assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
assert_eq!(combo.code, KeyCode::BackTab);
}
#[test]
fn double_escape_trigger_has_half_second_window() {
let now = std::time::Instant::now();
assert!(!double_escape_trigger(None, now));
assert!(double_escape_trigger(
Some(now - std::time::Duration::from_millis(500)),
now
));
assert!(!double_escape_trigger(
Some(now - std::time::Duration::from_millis(501)),
now
));
}
#[test]
fn ctrl_c_clears_first_and_only_exits_inside_the_window() {
let now = std::time::Instant::now();
assert_eq!(ctrl_c_action(None, now), CtrlCAction::Clear);
assert_eq!(
ctrl_c_action(Some(now - std::time::Duration::from_millis(500)), now),
CtrlCAction::Exit
);
assert_eq!(
ctrl_c_action(Some(now - std::time::Duration::from_millis(501)), now),
CtrlCAction::Clear
);
}
#[test]
fn pasted_multi_line_block_becomes_one_prompt_not_many() {
use std::sync::atomic::{AtomicUsize, Ordering};
let editor = rpi_tui::Editor::simple();
let submits = Arc::new(AtomicUsize::new(0));
let submits_for_cb = submits.clone();
editor.on_submit(Arc::new(move |_text: &str| {
submits_for_cb.fetch_add(1, Ordering::SeqCst);
}));
let now = std::time::Instant::now();
let mut last_text_key_at: Option<std::time::Instant> = None;
let block = ["l1", "l2", "l3"];
for (index, line) in block.iter().enumerate() {
for ch in line.chars() {
editor.insert(&ch.to_string());
last_text_key_at = Some(now);
}
if index + 1 < block.len() {
let more_queued = true;
assert!(
enter_is_paste_burst(last_text_key_at, now, more_queued),
"pasted newline {index} must not submit"
);
editor.insert("\n");
last_text_key_at = Some(now);
}
}
assert_eq!(editor.get_text(), "l1\nl2\nl3");
assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
editor.handle_key(crossterm::event::KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
));
assert_eq!(
submits.load(Ordering::SeqCst),
1,
"typed Enter submits once"
);
}
#[test]
fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
let editor = rpi_tui::Editor::simple();
editor.insert("alpha\r\nbeta\r\n");
assert_eq!(editor.get_text(), "alpha\nbeta\n");
assert!(!editor.get_text().contains('\r'));
}
#[test]
fn pasted_enter_is_not_a_submit() {
let now = std::time::Instant::now();
assert!(enter_is_paste_burst(None, now, true));
assert!(enter_is_paste_burst(
Some(now - std::time::Duration::from_millis(1)),
now,
false
));
assert!(enter_is_paste_burst(
Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
now,
false
));
assert!(!enter_is_paste_burst(None, now, false));
assert!(!enter_is_paste_burst(
Some(now - PASTE_BURST_GAP),
now,
false
));
assert!(!enter_is_paste_burst(
Some(now - std::time::Duration::from_millis(120)),
now,
false
));
}
#[test]
fn paste_probe_ignores_the_enter_keys_own_release() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
let release =
KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
let mut queued = vec![Event::Key(release)].into_iter();
let (more, stashed) = probe_paste_input(None, || queued.next());
assert!(!more, "a key release alone is not queued paste input");
assert!(stashed.is_none());
let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
let (more, stashed) = probe_paste_input(None, || queued.next());
assert!(more, "a queued key press is paste continuation");
assert!(matches!(stashed, Some(Event::Key(_))));
let (more, stashed) = probe_paste_input(None, || None);
assert!(!more);
assert!(stashed.is_none());
let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
panic!("must not read when an event is already stashed")
});
assert!(more);
assert!(matches!(stashed, Some(Event::Key(_))));
}
#[test]
fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
let flat = rpi_extensions::UiDialogRequest {
request_id: "r1".into(),
tool_call_id: Some("c1".into()),
ui: serde_json::json!({
"kind": "selector",
"id": "proxy_port",
"header": "Proxy",
"question": "端口?",
"options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
"allowMultiple": true,
"allowFreeform": false,
"suggest": "1080"
}),
raw: serde_json::json!({}),
};
let prompt = parse_ask_user_prompt(&flat);
assert_eq!(prompt.question_id, "proxy_port");
assert_eq!(prompt.header.as_deref(), Some("Proxy"));
assert_eq!(prompt.options.len(), 2);
assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
assert!(prompt.allow_multiple);
assert!(!prompt.allow_freeform);
assert_eq!(prompt.suggest.as_deref(), Some("1080"));
let alias = rpi_extensions::UiDialogRequest {
ui: serde_json::json!({
"questions": [{"id": "q", "question": "Q?", "options": []}]
}),
..flat.clone()
};
let prompt = parse_ask_user_prompt(&alias);
assert_eq!(prompt.question_id, "q");
assert!(prompt.allow_freeform, "no options defaults to freeform");
let confirm = rpi_extensions::UiDialogRequest {
ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
..flat.clone()
};
let prompt = parse_ask_user_prompt(&confirm);
assert_eq!(prompt.kind, "confirm");
assert_eq!(prompt.options.len(), 2);
assert_eq!(prompt.options[0].0, "Yes");
}
#[test]
fn ask_user_bridge_answers_and_cancels_by_request_id() {
let mailbox = rpi_extensions::UiDialogMailbox::new();
mailbox.attach();
let bridge = AskUserBridge::new(mailbox.clone());
mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r1", "toolCallId": "call-r1",
"ui": {"kind": "input", "question": "Port?"}
}))
.unwrap();
mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r2", "toolCallId": "call-r2",
"ui": {"kind": "input", "question": "Level?"}
}))
.unwrap();
let first = bridge.take_pending().expect("r1 visible");
assert_eq!(first.request_id, "r1");
assert!(
bridge.take_pending().is_none(),
"only one request occupies the input slot"
);
bridge.respond("r1", serde_json::json!({"text": "7897"}));
assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
let second = bridge.take_pending().expect("r2 visible");
assert_eq!(second.request_id, "r2");
bridge.cancel("r2");
assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r3",
"ui": {"kind": "input", "question": "X?"}
}))
.unwrap();
bridge.cancel_all();
assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
bridge.shutdown();
assert!(mailbox
.handle(serde_json::json!({
"op": "open", "requestId": "r4", "ui": {"kind": "input"}
}))
.is_err());
}
}