mod extensions;
use extensions::*;
mod commands;
use commands::*;
mod settings;
use settings::*;
mod sessions;
use sessions::*;
mod run;
use run::*;
mod events;
use events::*;
mod selectors;
use selectors::*;
mod autocomplete;
use autocomplete::*;
mod rendering;
use rendering::*;
mod panels;
mod pet;
#[cfg(test)]
mod tests;
mod state;
use state::*;
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>>,
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 owned_release = key.kind == KeyEventKind::Release
&& state
.extension_claimed_keys
.lock()
.unwrap()
.remove(&key.code);
if !owned_release
&& (state.extension_dialog_open() || state.selector_open() || state.search.is_active())
{
return false;
}
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 !owned_release && !editor.get_text().is_empty() && !matches!(key.code, KeyCode::F(_)) {
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();
let claimed =
rpi_extensions::dispatch_data_event_claiming(&snapshot, EventTag::Input, &payload);
if claimed && key.kind == KeyEventKind::Press {
state
.extension_claimed_keys
.lock()
.unwrap()
.insert(key.code);
}
claimed
}
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,
}));
});
}
enum TuiMessage {
UserInput(String, Vec<rpi_ai::types::ImageContent>),
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(),
}
}
#[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 = lane.get_model().await.ok();
let lane_model_id = lane_model
.as_ref()
.map(|model| model.id.clone())
.unwrap_or_default();
let model_name = lane_model
.as_ref()
.map(|model| format!("({}) {}", model.provider, short_model_name(&model.id)))
.unwrap_or_else(|| 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 collapse_changelog =
preferred_project_setting(&project_settings, |settings| settings.collapse_changelog)
.or(saved_settings.collapse_changelog)
.unwrap_or(false);
maybe_add_startup_changelog(
&harness,
&chat_container,
&saved_settings,
collapse_changelog,
)
.await;
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()),
show_images,
)
.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 pet_document = Arc::new(pet::PetDocument::new(
document_container.clone(),
reload_context.ext_status.clone(),
));
let scroll_view = Arc::new(ScrollView::new(
pet_document.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);
if let Ok(level) = lane.get_thinking_level().await {
footer.set_thinking_level(Some(thinking_level_name(level)));
}
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/collapse tools | Ctrl+T: Show/hide thinking | /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),
provider_form: std::sync::Mutex::new(None),
active_provider_input: std::sync::Mutex::new(None),
active_provider_select: 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_claimed_keys: Mutex::new(Default::default()),
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 _compaction_watcher = harness.watch({
let state = Arc::clone(&state);
let tui = Arc::clone(&tui);
move |event| {
if let rpi_harness::events::HarnessEvent::CompactionProgress(event) = event {
if event.lane != "main" {
return;
}
if event.active {
if state.show_terminal_progress {
state.editor.set_working(Some(WorkingState {
frame: 0,
started_at: std::time::Instant::now(),
message: if event.manual {
"Compacting context… (Esc to cancel)"
} else {
"Auto-compacting… (Esc to cancel)"
}
.into(),
}));
}
} else {
let status = *state.status.lock().unwrap();
state.apply_status(status);
}
tui.request_render(false);
}
}
});
let model_catalog_arc = Arc::new(model_catalog.clone());
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 transcript_stack = Arc::new(rpi_tui::HStack::from_entries(vec![StackEntry::new(
scroll_view.clone(),
)
.basis(0)
.grow(1)
.shrink(1)
.min_size(1)]));
let root = VStack::from_children(vec![
StackChild::Entry(
StackEntry::new(transcript_stack.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(),
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() && ctx_for_cb.state.pending_images.lock().unwrap().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 images = ctx_for_cb.state.take_pending_images();
let message = user_message_with_images(text, images.clone());
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 {
state.restore_pending_images(images);
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(),
ctx_for_cb.state.take_pending_images(),
))
.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 pet_tick = pet_document.clone();
let mut plugin_panels = panels::Panels::with_sidebar(
transcript_stack,
scroll_view.clone(),
reload_context.ext_status.clone(),
);
plugin_panels.sync(tui.as_ref(), &reload_context.ext_status);
let scroll_tick = scroll_view.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;
let panels_changed = plugin_panels.sync(tui_tick.as_ref(), &state_tick.ext_status);
if state_tick.sync_extension_status() || panels_changed {
tui_tick.request_render_reusing_scroll_content();
}
if pet_tick.tick(scroll_tick.viewport_height()) {
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()
));
if state_for_key.provider_form.lock().unwrap().is_some() {
let active = state_for_key.active_provider_input.lock().unwrap().clone();
if let Some(input) = active {
input.handle_paste(&text);
tui_for_key.request_render_reusing_scroll_content();
continue;
}
}
if let Some(images) = images_from_pasted_paths(&text) {
for image in images {
state_for_key.queue_image(image);
}
add_note_message(
&state_for_key.chat_container,
"Dropped images 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;
};
let key = rpi_tui::terminal::normalize_key_event(key);
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.provider_form.lock().unwrap().is_some() {
let active_input = state_for_key.active_provider_input.lock().unwrap().clone();
let active_select = state_for_key.active_provider_select.lock().unwrap().clone();
if let Some(input) = active_input {
input.handle_key(key);
} else if let Some(select) = active_select {
select.handle_key(key);
}
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 key.code == KeyCode::Esc
&& key.modifiers == KeyModifiers::NONE
&& !state_for_key.selector_open()
&& pet::active(&state_for_key.ext_status)
{
let _ = invoke_extension_command(&state_for_key.extension_session, "pet", "quiet");
tui_for_key.request_render(false);
if *state_for_key.status.lock().unwrap() == RunStatus::Idle
&& editor_for_key.get_text().is_empty()
{
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();
state_for_key.take_pending_images();
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);
let scope = same_provider_catalog(&scope, ¤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);
let scope = same_provider_catalog(&scope, ¤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,
) {
if let Some(paths) = read_clipboard_file_paths() {
let text = paths.join("\n");
if let Some(images) = images_from_pasted_paths(&text) {
for image in images {
state_for_key.queue_image(image);
}
add_note_message(
&state_for_key.chat_container,
"Clipboard images attached to the next prompt.",
);
} else {
editor_for_key.handle_paste(&text);
}
tui_for_key.request_render(false);
continue;
}
match read_clipboard_image() {
Ok(Some(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) || std::env::var_os("SSH_CONNECTION").is_none() {
if let Some(text) = read_clipboard_text() {
if !text.is_empty() {
if !attach_pasted_images(&state_for_key, &text) {
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() && state_for_key.pending_images.lock().unwrap().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,
state_for_key.take_pending_images(),
));
}
} else {
let images = state_for_key.take_pending_images();
let message = user_message_with_images(&prompt, images.clone());
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 {
state.restore_pending_images(images);
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);
add_image_previews(&chat_container, &images, show_images);
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, prompt_images)) => {
state.clear_editor_programmatically();
add_image_previews(&chat_container, &prompt_images, state.images_visible());
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()),
state.images_visible(),
)
.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,
&reload_context.tool_context,
)
.await;
tui.request_render(false);
}
Some(TuiMessage::ImportSession(path)) => {
import_session(
&harness,
&lane,
&path,
&cwd,
&chat_container,
&state,
&reload_context.tool_context,
)
.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,
&reload_context.tool_context,
)
.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
}
pub fn is_tui_supported() -> bool {
std::io::stdout().is_terminal()
}
#[allow(unused_imports)]
use rpi_tui::Color as _Color;