use super::*;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum RunStatus {
Idle,
Working,
Aborting,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(super) enum ProviderFormStep {
#[default]
Id,
Api,
BaseUrl,
ApiKey,
ModelId,
ModelName,
}
#[derive(Clone, Debug, Default)]
pub(super) struct ProviderFormState {
pub(super) step: ProviderFormStep,
pub(super) id: String,
pub(super) api: String,
pub(super) base_url: String,
pub(super) api_key: String,
pub(super) model_id: String,
pub(super) model_name: String,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum SelectorKind {
Model,
Thinking,
Tools,
Images,
Session,
Theme,
Provider,
ScopedModels,
Settings,
Tree,
Extension,
}
#[derive(Clone)]
pub(super) enum SelectorView {
List(Arc<SelectList>),
Searchable(Arc<SearchableSelectList>),
Settings(Arc<SettingsList>),
}
impl SelectorView {
pub(super) 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)]
pub(super) struct AutoSendPending {
pub(super) deadline: std::time::Instant,
pub(super) text: String,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum AutoSendStep {
Idle,
Cancel,
Submit,
Wait(std::time::Duration),
}
pub(super) 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)
}
}
pub(super) struct TuiState {
pub(super) current_assistant: Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
pub(super) tool_components: Arc<std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
pub(super) bash_components: Arc<std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
pub(super) hide_thinking: Arc<std::sync::Mutex<bool>>,
pub(super) tool_outputs_expanded: Arc<std::sync::Mutex<bool>>,
pub(super) show_terminal_progress: bool,
pub(super) status: std::sync::Mutex<RunStatus>,
pub(super) ext_status: rpi_extensions::ExtensionStatusMailbox,
pub(super) ext_status_revision: std::sync::atomic::AtomicU64,
pub(super) editor_text: rpi_extensions::EditorTextMailbox,
pub(super) auto_send: std::sync::Mutex<Option<AutoSendPending>>,
pub(super) last_editor_text: std::sync::Mutex<String>,
pub(super) programmatic_editor_write: std::sync::atomic::AtomicBool,
pub(super) js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
pub(super) user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
pub(super) pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
pub(super) footer: Arc<FooterComponent>,
pub(super) status_container: Arc<Container>,
pub(super) chat_container: Arc<Container>,
pub(super) loader: Arc<Loader>,
pub(super) editor: Arc<Editor>,
pub(super) last_assistant_text: std::sync::Mutex<String>,
pub(super) active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
pub(super) provider_form: std::sync::Mutex<Option<ProviderFormState>>,
pub(super) active_provider_input: std::sync::Mutex<Option<Arc<Input>>>,
pub(super) active_provider_select: std::sync::Mutex<Option<Arc<SelectList>>>,
pub(super) active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
pub(super) active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
pub(super) active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
pub(super) autocomplete: AutocompleteManager,
pub(super) autocomplete_container: Arc<Container>,
pub(super) autocomplete_max_visible: usize,
pub(super) autocomplete_selection: std::sync::Mutex<usize>,
pub(super) pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
pub(super) theme_manager: Arc<ThemeManager>,
pub(super) tui: Option<Arc<TuiAltScreen>>,
pub(super) current_model_id: std::sync::Mutex<String>,
pub(super) show_images: std::sync::Mutex<bool>,
pub(super) cache_miss_notices: std::sync::Mutex<bool>,
pub(super) history: std::sync::Mutex<Vec<String>>,
pub(super) history_index: std::sync::Mutex<isize>,
pub(super) history_draft: std::sync::Mutex<Option<String>>,
pub(super) cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
pub(super) scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
pub(super) markdown_transformer: Arc<std::sync::Mutex<Option<MarkdownTransformer>>>,
pub(super) extension_session: crate::session::ExtensionSessionCell,
pub(super) extension_claimed_keys: Mutex<std::collections::HashSet<KeyCode>>,
pub(super) search: Arc<AltScreenSearch>,
pub(super) search_bar: Arc<SearchBar>,
pub(super) pending_container: Arc<Container>,
pub(super) dequeue_hint: String,
pub(super) pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
pub(super) selection_start: std::sync::Mutex<Option<(u16, u16)>>,
pub(super) selection_end: std::sync::Mutex<Option<(u16, u16)>>,
}
pub(super) const HISTORY_LIMIT: usize = 100;
pub(super) const AUTO_SEND_PREFIX: &str = "Auto-send in ";
pub(super) const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
pub(super) 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))
}
pub(crate) 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),
("tui.altScreen.bottom", rpi_tui::keybindings::keys::BOTTOM),
("tui.altScreen.top", rpi_tui::keybindings::keys::TOP),
("tui.altScreen.pageUp", rpi_tui::keybindings::keys::PAGE_UP),
(
"tui.altScreen.pageDown",
rpi_tui::keybindings::keys::PAGE_DOWN,
),
];
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)
}
pub(super) 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
}
}
pub(super) 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("/")
}
pub(super) 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)]
pub(super) enum CtrlCAction {
Clear,
Exit,
}
pub(super) 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
}
}
pub(super) const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
pub(super) const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
#[derive(Default)]
pub(super) struct WindowsPasteRun {
pub(super) text: String,
pub(super) last_at: Option<std::time::Instant>,
pub(super) burst: bool,
}
impl WindowsPasteRun {
pub(super) fn grace(&self) -> std::time::Duration {
std::time::Duration::from_millis(if self.burst { 120 } else { 15 })
}
pub(super) 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);
}
#[cfg(windows)]
pub(super) fn read_queued_text(&mut self) {
if self.burst && self.text.len() >= 64 {
if let Ok(text) = rpi_tui::windows_paste::read_text_batch() {
if !text.is_empty() {
self.text.push_str(&text);
self.last_at = Some(std::time::Instant::now());
}
}
}
}
pub(super) fn flush(&mut self, editor: &Editor) {
if !self.text.is_empty() {
editor.handle_paste(&self.text);
}
*self = Self::default();
}
}
pub(super) 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)]
pub(super) 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)
}
pub(super) 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)
}
#[cfg(test)]
pub(super) use crate::transcript_navigation::transcript_page_size;
pub(super) fn should_dispatch_key(kind: KeyEventKind) -> bool {
kind != KeyEventKind::Release
}
pub(super) 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)]
pub(super) fn handle_suspend(tui: &Arc<TuiAltScreen>) {
tui.suspend();
let _ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
tui.resume();
}
#[cfg(not(unix))]
pub(super) fn handle_suspend(_tui: &Arc<TuiAltScreen>) {}
pub(super) 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()
}
}
pub(super) 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;
}
pub(super) 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(),
state.take_pending_images(),
));
}
pub(super) 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
}
}
}
}
pub(super) 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 {
pub(super) 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
}
}
pub(super) fn user_bash_running(&self) -> bool {
self.user_bash_cancel.lock().unwrap().is_some()
}
pub(super) 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
}
pub(super) fn finish_user_bash(&self) {
self.user_bash_cancel.lock().unwrap().take();
}
pub(super) 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
}
}
pub(super) fn set_status(&self, status: RunStatus) {
*self.status.lock().unwrap() = status;
self.apply_status(status);
}
pub(super) fn pending_container(&self) -> &Arc<Container> {
&self.pending_container
}
pub(super) 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,
)));
}
pub(super) 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
}
pub(super) 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);
}
}
}
pub(super) 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);
let hidden = if pet::active(&self.ext_status) {
vec![pet::PET_KEY, "voice"]
} else {
vec![pet::PET_KEY]
};
self.footer
.set_extension_status(&self.ext_status.text_except(&hidden));
true
}
pub(super) 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
}
pub(super) fn clear_editor_programmatically(&self) {
self.programmatic_editor_write
.store(true, std::sync::atomic::Ordering::SeqCst);
self.editor.clear();
}
pub(super) 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("");
}
}
pub(super) fn cancel_auto_send_on_key(&self) -> bool {
let had = self.auto_send.lock().unwrap().is_some();
if had {
self.clear_auto_send();
}
had
}
pub(super) 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(),
);
}
pub(super) 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"
},
}))
}
pub(super) fn sync_working_loader_with_bash(&self) {
if *self.status.lock().unwrap() != RunStatus::Working {
return;
}
self.status_container.clear();
}
pub(super) 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),
)));
}
pub(super) fn selector_open(&self) -> bool {
self.active_selector.lock().unwrap().is_some()
}
pub(super) fn extension_editor_open(&self) -> bool {
self.active_extension_editor.lock().unwrap().is_some()
}
pub(super) fn extension_input_open(&self) -> bool {
self.active_extension_input.lock().unwrap().is_some()
}
pub(super) fn extension_dialog_open(&self) -> bool {
self.extension_editor_open() || self.extension_input_open()
}
pub(super) 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);
}
}
pub(super) fn hide_thinking(&self) -> bool {
*self.hide_thinking.lock().unwrap()
}
pub(super) fn toggle_thinking(&self) -> bool {
let next = !self.hide_thinking();
self.set_hide_thinking(next);
next
}
pub(super) fn toggle_tool_outputs(&self) -> bool {
let next = !*self.tool_outputs_expanded.lock().unwrap();
self.transcript_view().set_expanded(next);
next
}
pub(super) fn current_model_id(&self) -> String {
self.current_model_id.lock().unwrap().clone()
}
pub(super) fn set_current_model(&self, model: &rpi_ai::Model) {
*self.current_model_id.lock().unwrap() = model.id.clone();
self.footer.set_model(&format!(
"({}) {}",
model.provider,
short_model_name(&model.id)
));
self.footer.set_context_window(model.context_window as i64);
}
pub(super) fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
self.markdown_transformer.lock().unwrap().clone()
}
pub(super) 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(),
)
.with_show_images(self.images_visible())
}
pub(super) fn set_markdown_transformer_with_reinstall(
&self,
transformer: Option<MarkdownTransformer>,
) {
*self.markdown_transformer.lock().unwrap() = transformer.clone();
if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
comp.set_markdown_transformer(transformer);
}
}
pub(super) fn queue_image(&self, image: rpi_ai::types::ImageContent) {
self.pending_images.lock().unwrap().push(image);
}
pub(super) fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
std::mem::take(&mut *self.pending_images.lock().unwrap())
}
pub(super) fn restore_pending_images(&self, mut images: Vec<rpi_ai::types::ImageContent>) {
let mut pending = self.pending_images.lock().unwrap();
images.append(&mut *pending);
*pending = images;
}
pub(super) fn set_show_images(&self, show: bool) {
*self.show_images.lock().unwrap() = show;
for child in self.chat_container.get_children() {
if let Some(image) = child.as_any().downcast_ref::<Image>() {
image.set_inline_visible(show);
}
if let Some(assistant) = child.as_any().downcast_ref::<AssistantMessageComponent>() {
assistant.set_show_images(show);
}
}
}
pub(super) fn images_visible(&self) -> bool {
*self.show_images.lock().unwrap()
}
}