1use std::collections::{HashMap, HashSet, VecDeque};
27use std::io::IsTerminal;
28use std::sync::{mpsc as std_mpsc, Arc, Mutex};
29
30use base64::Engine;
31use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
32use tokio::sync::{broadcast, mpsc};
33use tokio_util::sync::CancellationToken;
34
35use rpi_agent::{AgentEvent, AgentMessage};
36use rpi_ai::types::{AssistantMessage, Content, UserMessage};
37use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
38use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
39use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
40#[cfg(test)]
41use rpi_tui::strip_ansi;
42use rpi_tui::{
43 apply_theme_preset, render_diff, AltScreenSearch, AssistantBlock, AssistantMessageComponent,
44 AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
45 BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
46 EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
47 FooterComponent, Image, ImageOptions, Input, Loader, Markdown, ProcessTerminal, ScrollView,
48 ScrollViewOptions, SearchBar, SearchableSelectList, SelectItem, SelectList, SettingItem,
49 SettingsList, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
50 StackChild, StackEntry, StatusIndicator, Text, ThemeManager, ThemePreset,
51 ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack, WorkingState, TUI,
52};
53use rpi_tui::{bold as tui_bold, theme as current_theme};
54
55#[allow(unused_imports)]
56use rpi_tui::BashStatus;
57
58use crate::args::Args;
59
60type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
67
68#[derive(Clone, Default)]
74struct JsDialogBridge {
75 pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
76 active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
77 visible: Arc<Mutex<Option<String>>>,
81 cancelled_before_open: Arc<Mutex<HashSet<String>>>,
82 closed: Arc<Mutex<bool>>,
83}
84
85struct JsDialogPending {
86 request: JsDialogRequest,
87 result: std_mpsc::Sender<serde_json::Value>,
88}
89
90struct JsDialogActive {
91 result: std_mpsc::Sender<serde_json::Value>,
92 cancel_requested: bool,
93}
94
95#[derive(Clone, Debug)]
96struct JsDialogRequest {
97 id: String,
98 method: String,
99 title: String,
100 message: String,
101 options: Vec<String>,
102 placeholder: Option<String>,
103 prefill: Option<String>,
104}
105
106impl JsDialogRequest {
107 fn parse(args: &serde_json::Value) -> Result<Self, String> {
108 let id = args
109 .get("dialogId")
110 .and_then(serde_json::Value::as_str)
111 .filter(|value| !value.is_empty())
112 .ok_or("ui.dialog missing dialogId")?
113 .to_string();
114 let method = args
115 .get("method")
116 .and_then(serde_json::Value::as_str)
117 .ok_or("ui.dialog missing method")?
118 .to_string();
119 if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
120 return Err(format!("unsupported UI dialog method: {method}"));
121 }
122 let options = args
123 .get("options")
124 .and_then(serde_json::Value::as_array)
125 .map(|values| {
126 values
127 .iter()
128 .filter_map(serde_json::Value::as_str)
129 .map(ToOwned::to_owned)
130 .collect()
131 })
132 .unwrap_or_default();
133 Ok(Self {
134 id,
135 method,
136 title: args
137 .get("title")
138 .and_then(serde_json::Value::as_str)
139 .unwrap_or_default()
140 .to_string(),
141 message: args
142 .get("message")
143 .and_then(serde_json::Value::as_str)
144 .unwrap_or_default()
145 .to_string(),
146 options,
147 placeholder: args
148 .get("placeholder")
149 .and_then(serde_json::Value::as_str)
150 .map(ToOwned::to_owned),
151 prefill: args
152 .get("prefill")
153 .and_then(serde_json::Value::as_str)
154 .map(ToOwned::to_owned),
155 })
156 }
157}
158
159impl JsDialogBridge {
160 fn handle_runtime_request(
161 &self,
162 action: &str,
163 args: serde_json::Value,
164 ) -> Result<serde_json::Value, String> {
165 match action {
166 "ui.dialog" => self.wait_for_dialog(args),
167 "ui.dialog.cancel" => {
168 let id = args
169 .get("dialogId")
170 .and_then(serde_json::Value::as_str)
171 .ok_or("ui.dialog.cancel missing dialogId")?;
172 self.cancel(id);
173 Ok(serde_json::json!(true))
174 }
175 _ => Err(format!("unsupported capability: {action}")),
176 }
177 }
178
179 fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
180 let request = JsDialogRequest::parse(&args)?;
181 let (sender, receiver) = std_mpsc::channel();
182 let _closed = self
186 .closed
187 .lock()
188 .map_err(|_| "JS dialog bridge poisoned")?;
189 if *_closed {
190 return Ok(serde_json::json!({ "cancelled": true }));
191 }
192 let cancelled_before_open = self
193 .cancelled_before_open
194 .lock()
195 .map_err(|_| "JS dialog cancellation state poisoned")?
196 .remove(&request.id);
197 if cancelled_before_open {
198 return Ok(serde_json::json!({ "cancelled": true }));
199 }
200 self.pending
203 .lock()
204 .map_err(|_| "JS dialog pending state poisoned")?
205 .push_back(JsDialogPending {
206 request,
207 result: sender,
208 });
209 drop(_closed);
210 receiver
211 .recv()
212 .map_err(|_| "JS dialog closed before it received an answer".to_string())
213 }
214
215 fn take_pending(&self) -> Option<JsDialogRequest> {
218 loop {
219 let pending = self.pending.lock().ok()?.pop_front()?;
220 let mut active = self.active.lock().ok()?;
221 if self
228 .cancelled_before_open
229 .lock()
230 .ok()?
231 .remove(&pending.request.id)
232 {
233 drop(active);
234 let _ = pending
235 .result
236 .send(serde_json::json!({ "cancelled": true }));
237 continue;
238 }
239 active.insert(
240 pending.request.id.clone(),
241 JsDialogActive {
242 result: pending.result,
243 cancel_requested: false,
244 },
245 );
246 if let Ok(mut visible) = self.visible.lock() {
247 *visible = Some(pending.request.id.clone());
248 }
249 return Some(pending.request);
250 }
251 }
252
253 fn respond(&self, id: &str, result: serde_json::Value) {
254 if let Ok(mut active) = self.active.lock() {
255 if let Some(entry) = active.remove(id) {
256 let _ = entry.result.send(result);
257 }
258 }
259 if let Ok(mut visible) = self.visible.lock() {
260 if visible.as_deref() == Some(id) {
261 *visible = None;
262 }
263 }
264 }
265
266 fn cancel(&self, id: &str) {
267 if let Ok(mut pending) = self.pending.lock() {
268 if let Some(index) = pending.iter().position(|item| item.request.id == id) {
269 if let Some(item) = pending.remove(index) {
270 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
271 return;
272 }
273 }
274 }
275 if let Ok(mut active) = self.active.lock() {
276 if let Some(entry) = active.get_mut(id) {
277 if !entry.cancel_requested {
278 entry.cancel_requested = true;
279 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
280 }
281 return;
282 }
283 }
284 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
285 cancelled.insert(id.to_string());
286 }
287 }
288
289 fn cancelled_active_ids(&self) -> Vec<String> {
290 self.active
291 .lock()
292 .map(|active| {
293 active
294 .iter()
295 .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
296 .collect()
297 })
298 .unwrap_or_default()
299 }
300
301 fn is_visible(&self, id: &str) -> bool {
302 self.visible
303 .lock()
304 .map(|visible| visible.as_deref() == Some(id))
305 .unwrap_or(false)
306 }
307
308 fn finish(&self, id: &str) {
309 if let Ok(mut active) = self.active.lock() {
310 active.remove(id);
311 }
312 if let Ok(mut visible) = self.visible.lock() {
313 if visible.as_deref() == Some(id) {
314 *visible = None;
315 }
316 }
317 }
318
319 fn cancel_all(&self) {
320 let Ok(mut closed) = self.closed.lock() else {
324 return;
325 };
326 *closed = true;
327 if let Ok(mut pending) = self.pending.lock() {
328 for item in pending.drain(..) {
329 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
330 }
331 }
332 if let Ok(mut active) = self.active.lock() {
333 for (_, entry) in active.drain() {
334 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
335 }
336 }
337 if let Ok(mut visible) = self.visible.lock() {
338 *visible = None;
339 }
340 drop(closed);
341 }
342
343 fn cancel_open_requests(&self) {
346 let Ok(mut closed) = self.closed.lock() else {
350 return;
351 };
352 *closed = true;
353 if let Ok(mut pending) = self.pending.lock() {
354 for item in pending.drain(..) {
355 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
356 }
357 }
358 if let Ok(mut active) = self.active.lock() {
359 for (_, entry) in active.drain() {
360 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
361 }
362 }
363 if let Ok(mut visible) = self.visible.lock() {
364 *visible = None;
365 }
366 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
367 cancelled.clear();
368 }
369 drop(closed);
370 }
371
372 fn reopen(&self) {
373 if let Ok(mut closed) = self.closed.lock() {
374 *closed = false;
375 }
376 }
377}
378
379fn build_markdown_transformer(
401 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
402) -> Option<MarkdownTransformer> {
403 let snapshot = snapshot?;
404 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
409 if renderers.is_empty() {
410 return None;
411 }
412 Some(Arc::new(move |raw: &str| -> String {
413 transform_markdown_chain(&snapshot, &renderers, raw)
414 }))
415}
416
417fn transform_markdown_chain(
425 snapshot: &rpi_extensions::RegistrySnapshot,
426 renderers: &[rpi_extensions::RegisteredRenderer],
427 raw: &str,
428) -> String {
429 if !snapshot.is_active() {
434 return raw.to_string();
435 }
436
437 let mut current = raw.to_string();
438 for renderer in renderers {
439 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
440 Ok(s) => s,
441 Err(_) => return current, };
443 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
453 let mut out = rpi_plugin_sdk::StbString::empty();
454 let rc = (renderer.render_fn)(
455 rpi_plugin_sdk::StbStringRef::from_str(&input),
456 &mut out as *mut rpi_plugin_sdk::StbString,
457 renderer.user_data,
458 );
459 let text = if rc == 0 {
460 let s = out.to_string_lossy();
461 Some(s)
462 } else {
463 None
464 };
465 out.free_with(Some(renderer.plugin_free_string));
469 text
470 }));
471 let out_text = match outcome {
472 Ok(Some(s)) => s,
473 Ok(None) => return current, Err(_) => return current, };
479 let next = serde_json::from_str::<serde_json::Value>(&out_text)
482 .ok()
483 .and_then(|v| {
484 v.get("markdown")
485 .and_then(|m| m.as_str())
486 .map(|s| s.to_string())
487 })
488 .unwrap_or(current);
489 current = next;
490 }
491 current
492}
493
494#[derive(Clone)]
518struct CommandContext {
519 chat: Arc<Container>,
520 tui: Arc<TuiAltScreen>,
521 tx: mpsc::UnboundedSender<TuiMessage>,
522 state: Arc<TuiState>,
523 editor: Arc<Editor>,
524 editor_container: Arc<Container>,
525 lane: Arc<dyn AgentLane>,
526 model_catalog: Arc<Vec<rpi_ai::Model>>,
527 lane_model_id: String,
531 cwd: std::path::PathBuf,
532 package_resources: Arc<crate::packages::PackageResources>,
536 resources: Arc<rpi_harness::types::AgentHarnessResources>,
540 reload_context: Arc<crate::session::ReloadContext>,
546 session: Arc<crate::agent_session::AgentSession>,
550}
551
552trait SlashCommand: Send + Sync {
554 fn name(&self) -> &str;
556 fn aliases(&self) -> &'static [&'static str] {
560 &[]
561 }
562 fn visible(&self) -> bool {
565 true
566 }
567 fn alias_visible(&self) -> &'static [&'static str] {
571 &[]
572 }
573 fn description(&self) -> &'static str {
576 ""
577 }
578 fn description_owned(&self) -> String {
579 self.description().to_string()
580 }
581 fn execute(&self, ctx: &CommandContext, args: &str);
587}
588
589struct CommandRegistry {
592 commands: Vec<Arc<dyn SlashCommand>>,
593}
594
595impl CommandRegistry {
596 fn new() -> Self {
597 Self {
598 commands: Vec::new(),
599 }
600 }
601
602 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
603 self.commands.push(cmd);
604 }
605
606 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
609 self.commands
610 .iter()
611 .find(|c| c.name() == token || c.aliases().contains(&token))
612 }
613
614 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
620 let mut out: Vec<SlashCommandEntry> = Vec::new();
621 for c in &self.commands {
622 let description = c.description_owned();
623 if c.visible() && !description.is_empty() {
624 out.push(SlashCommandEntry {
625 name: c.name().into(),
626 description: description.clone(),
627 });
628 }
629 for alias in c.alias_visible() {
631 out.push(SlashCommandEntry {
632 name: (*alias).into(),
633 description: description.clone(),
634 });
635 }
636 }
637 out
638 }
639}
640
641fn dispatch_ui_prompt_event(state: &TuiState) {
643 use rpi_plugin_sdk::EventTag;
644
645 if let Ok(session) = state.extension_session.lock() {
646 if let Some(snapshot) = session.snapshot_arc() {
647 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
648 }
649 }
650}
651
652fn dispatch_ui_prompt_event_end(state: &TuiState) {
654 use rpi_plugin_sdk::EventTag;
655
656 if let Ok(session) = state.extension_session.lock() {
657 if let Some(snapshot) = session.snapshot_arc() {
658 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
659 }
660 }
661}
662
663fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
667 let mut parts = text.split_whitespace();
668 let token = parts.next().unwrap_or("");
669 let args = parts.collect::<Vec<_>>().join(" ");
670 match registry.find(token) {
671 Some(cmd) => cmd.execute(ctx, &args),
672 None => {
673 add_error_message(
674 &ctx.chat,
675 &format!("Unknown command: {text}. Type /help for available commands."),
676 );
677 ctx.tui.request_render(false);
678 }
679 }
680}
681
682fn now_ms() -> i64 {
686 std::time::SystemTime::now()
687 .duration_since(std::time::UNIX_EPOCH)
688 .map(|d| d.as_millis() as i64)
689 .unwrap_or(0)
690}
691
692fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
699 let rest = text.strip_prefix('!')?;
700 let (command, exclude_from_context) = match rest.strip_prefix('!') {
701 Some(command) => (command, true),
702 None => (rest, false),
703 };
704 let command = command.trim();
705 if command.is_empty() {
706 return None;
707 }
708 Some((command, exclude_from_context))
709}
710
711fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
719 let comp = Arc::new(BashExecutionComponent::new_with_context(
720 command,
721 exclude_from_context,
722 ));
723 comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
724 ctx.chat.add_child(comp.clone());
725
726 let cancel = ctx.state.begin_user_bash();
729 ctx.tui.request_render(false);
730
731 let env: Arc<dyn rpi_tools::ExecutionEnv> =
732 Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
733 let tui = ctx.tui.clone();
734 let tx = ctx.tx.clone();
735 let cwd = ctx.cwd.clone();
736 let command_owned = command.to_string();
737
738 tokio::spawn(async move {
739 let comp_chunk = comp.clone();
740 let tui_chunk = tui.clone();
741 let on_chunk: Box<
742 dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
743 > = Box::new(move |_chunk, get_progress| {
744 comp_chunk.append_output(&get_progress().output);
747 tui_chunk.request_render(false);
748 });
749 let options = rpi_tools::shell_output::ShellCaptureOptions {
750 cwd: Some(cwd),
751 cancel: Some(&cancel),
752 on_chunk: Some(on_chunk),
753 return_execution_errors: true,
754 ..Default::default()
755 };
756 let result =
757 rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
758 .await;
759
760 let (exit_code, cancelled, output, truncated, full_output_path) = match result {
763 Ok(capture) => (
764 capture.exit_code,
765 capture.cancelled,
766 capture.output.clone(),
767 capture.truncated,
768 capture
769 .full_output_path
770 .as_ref()
771 .map(|path| path.to_string_lossy().into_owned()),
772 ),
773 Err(error) => (
774 None,
775 false,
776 format!("Bash command failed: {error}"),
777 false,
778 None,
779 ),
780 };
781 comp.append_output(&output);
782 comp.set_complete(
783 exit_code,
784 cancelled,
785 BashTruncation {
786 truncated,
787 full_output_path: full_output_path.clone(),
788 },
789 );
790 tui.render_now(false);
791
792 let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
793 command: command_owned,
794 output,
795 exit_code,
796 cancelled,
797 truncated,
798 full_output_path,
799 exclude_from_context,
800 }));
801 });
802}
803
804fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
809 use crossterm::event::{KeyCode, KeyModifiers};
810
811 let modifiers = key.modifiers;
812 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
813 let shift = modifiers.contains(KeyModifiers::SHIFT);
814 let alt = modifiers.contains(KeyModifiers::ALT);
815 let super_key = modifiers.contains(KeyModifiers::SUPER);
816
817 if modifiers == KeyModifiers::NONE {
818 return match key.code {
819 KeyCode::Char(ch) => ch.to_string(),
820 KeyCode::Enter => "\r".into(),
821 KeyCode::Esc => "\x1b".into(),
822 KeyCode::Backspace => "\x7f".into(),
823 KeyCode::Tab => "\t".into(),
824 KeyCode::BackTab => "\x1b[Z".into(),
828 KeyCode::Up => "\x1b[A".into(),
829 KeyCode::Down => "\x1b[B".into(),
830 KeyCode::Right => "\x1b[C".into(),
831 KeyCode::Left => "\x1b[D".into(),
832 KeyCode::Home => "\x1b[H".into(),
833 KeyCode::End => "\x1b[F".into(),
834 KeyCode::PageUp => "\x1b[5~".into(),
835 KeyCode::PageDown => "\x1b[6~".into(),
836 KeyCode::Delete => "\x1b[3~".into(),
837 KeyCode::Insert => "\x1b[2~".into(),
838 KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
839 _ => String::new(),
840 };
841 }
842
843 if ctrl && !shift && !alt && !super_key {
846 if let KeyCode::Char(ch) = key.code {
847 if let Some(code) = control_code(ch) {
848 return char::from(code).to_string();
849 }
850 }
851 }
852
853 if alt && !ctrl && !shift && !super_key {
856 if let KeyCode::Char(ch) = key.code {
857 return format!("\x1b{ch}");
858 }
859 }
860
861 if shift && !ctrl && !alt && !super_key {
867 if let KeyCode::Char(ch) = key.code {
868 return ch.to_string();
869 }
870 }
871
872 if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
873 return sequence;
874 }
875 let Some(codepoint) = key_codepoint(key.code, ctrl) else {
876 return String::new();
877 };
878 kitty_key_sequence(codepoint, modifiers)
879}
880
881fn control_code(ch: char) -> Option<u8> {
882 let ch = ch.to_ascii_lowercase();
883 Some(match ch {
884 '@' | ' ' => 0,
885 'a'..='z' => (ch as u8) & 0x1f,
886 '[' => 0x1b,
887 '\\' => 0x1c,
888 ']' => 0x1d,
889 '^' => 0x1e,
890 '_' | '-' => 0x1f,
891 _ => return None,
892 })
893}
894
895fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
896 use crossterm::event::KeyCode;
897 Some(match code {
898 KeyCode::Char(ch) => {
899 if ctrl {
900 ch.to_ascii_lowercase() as u32
901 } else {
902 ch as u32
903 }
904 }
905 KeyCode::Enter => 13,
906 KeyCode::Esc => 27,
907 KeyCode::Backspace => 127,
908 KeyCode::Tab => 9,
909 KeyCode::BackTab => 9,
912 _ => return None,
913 })
914}
915
916fn modified_functional_sequence(
917 code: crossterm::event::KeyCode,
918 modifiers: crossterm::event::KeyModifiers,
919) -> Option<String> {
920 use crossterm::event::{KeyCode, KeyModifiers};
921 if code == KeyCode::BackTab
925 && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
926 {
927 return Some("\x1b[Z".into());
928 }
929 let modifier = kitty_modifier(modifiers);
930 let sequence = match code {
931 KeyCode::Up => format!("\x1b[1;{modifier}A"),
932 KeyCode::Down => format!("\x1b[1;{modifier}B"),
933 KeyCode::Right => format!("\x1b[1;{modifier}C"),
934 KeyCode::Left => format!("\x1b[1;{modifier}D"),
935 KeyCode::Home => format!("\x1b[1;{modifier}H"),
936 KeyCode::End => format!("\x1b[1;{modifier}F"),
937 KeyCode::Insert => format!("\x1b[2;{modifier}~"),
938 KeyCode::Delete => format!("\x1b[3;{modifier}~"),
939 KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
940 KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
941 _ => return None,
942 };
943 Some(sequence)
944}
945
946fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
947 use crossterm::event::KeyModifiers;
948 let mut modifier = 1u8;
949 if modifiers.contains(KeyModifiers::SHIFT) {
950 modifier += 1;
951 }
952 if modifiers.contains(KeyModifiers::ALT) {
953 modifier += 2;
954 }
955 if modifiers.contains(KeyModifiers::CONTROL) {
956 modifier += 4;
957 }
958 if modifiers.contains(KeyModifiers::SUPER) {
959 modifier += 8;
960 }
961 modifier
962}
963
964fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
965 let modifier = kitty_modifier(modifiers);
966 format!("\x1b[{codepoint};{modifier}u")
967}
968
969struct ExtensionCommand {
974 name: String,
975 description: String,
976 session: crate::session::ExtensionSessionCell,
977}
978
979struct JsExtensionCommand {
980 name: String,
981 session: crate::js_extensions::JsExtensionSession,
982}
983
984impl SlashCommand for JsExtensionCommand {
985 fn name(&self) -> &str {
986 &self.name
987 }
988 fn description(&self) -> &'static str {
989 "JS extension command"
990 }
991 fn description_owned(&self) -> String {
992 "JS extension command".to_string()
993 }
994 fn execute(&self, ctx: &CommandContext, args: &str) {
995 let session = self.session.clone();
1000 let command = self.name.trim_start_matches('/').to_string();
1001 let args = args.to_string();
1002 let ctx = ctx.clone();
1003 let initial_editor_text = ctx.editor.get_text();
1007 tokio::task::spawn_blocking(move || {
1008 match session.invoke_command_with_context(
1009 &command,
1010 &args,
1011 serde_json::json!({"editorText": initial_editor_text}),
1012 ) {
1013 Ok(value) => {
1014 if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
1015 if ctx.editor.get_text() == initial_editor_text
1016 && editor_text != initial_editor_text
1017 {
1018 let cursor = editor_text.chars().count();
1019 ctx.editor.set_text(editor_text);
1020 ctx.editor.set_cursor(0, cursor);
1021 }
1022 }
1023 if let Some(notifications) =
1024 value.get("notifications").and_then(|v| v.as_array())
1025 {
1026 for notification in notifications {
1027 let message = notification
1028 .get("message")
1029 .and_then(|v| v.as_str())
1030 .unwrap_or_default();
1031 if message.is_empty() {
1032 continue;
1033 }
1034 match notification.get("level").and_then(|v| v.as_str()) {
1035 Some("error") => add_error_message(&ctx.chat, message),
1036 _ => add_note_message(&ctx.chat, message),
1037 }
1038 }
1039 }
1040 let result = value.get("result").unwrap_or(&value);
1041 let text = result
1042 .get("text")
1043 .and_then(|item| item.as_str())
1044 .map(str::to_string)
1045 .or_else(|| result.as_str().map(str::to_string))
1046 .filter(|text| !text.is_empty() && text != "null");
1047 if let Some(text) = text {
1048 add_note_message(&ctx.chat, &text);
1049 }
1050 }
1051 Err(error) => {
1052 add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
1053 }
1054 }
1055 ctx.tui.request_render(false);
1056 });
1057 }
1058}
1059
1060impl SlashCommand for ExtensionCommand {
1061 fn name(&self) -> &str {
1062 &self.name
1063 }
1064
1065 fn description(&self) -> &'static str {
1066 "extension command"
1067 }
1068
1069 fn description_owned(&self) -> String {
1070 self.description.clone()
1071 }
1072
1073 fn execute(&self, ctx: &CommandContext, args: &str) {
1074 let result = invoke_extension_command(&self.session, &self.name, args);
1075 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
1076 }
1077}
1078
1079fn invoke_extension_command(
1080 session: &crate::session::ExtensionSessionCell,
1081 name: &str,
1082 args: &str,
1083) -> Option<serde_json::Value> {
1084 let command = session
1085 .lock()
1086 .ok()
1087 .and_then(|s| s.snapshot_arc())
1088 .and_then(|snap| {
1089 snap.commands()
1090 .iter()
1091 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1092 .cloned()
1093 })?;
1094 let input = serde_json::json!({ "args": args, "command": name });
1095 let input = serde_json::to_string(&input).ok()?;
1096 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1097 let mut out = rpi_plugin_sdk::StbString::empty();
1098 let rc = (command.handler)(
1099 rpi_plugin_sdk::StbStringRef::from_str(&input),
1100 &mut out as *mut rpi_plugin_sdk::StbString,
1101 command.user_data,
1102 );
1103 let text = if rc == 0 {
1104 Some(out.to_string_lossy())
1105 } else {
1106 None
1107 };
1108 rpi_extensions::host_free_string(out);
1109 text
1110 }))
1111 .ok()
1112 .flatten()?;
1113 serde_json::from_str(&outcome).ok()
1114}
1115
1116fn handle_extension_ui_result(
1117 result: Option<serde_json::Value>,
1118 ctx: &CommandContext,
1119 session: crate::session::ExtensionSessionCell,
1120 command_name: String,
1121) {
1122 let Some(value) = result else {
1123 add_error_message(&ctx.chat, "Extension command failed.");
1124 ctx.tui.request_render(false);
1125 return;
1126 };
1127 if value.is_null() {
1131 ctx.tui.request_render(false);
1132 return;
1133 }
1134 match value.get("kind").and_then(|v| v.as_str()) {
1135 Some("message") | None => {
1136 let fallback = value.to_string();
1137 let text = value
1138 .get("text")
1139 .and_then(|v| v.as_str())
1140 .unwrap_or(&fallback)
1141 .to_string();
1142 if !text.is_empty() {
1143 add_note_message(&ctx.chat, &text);
1144 }
1145 ctx.tui.request_render(false);
1146 }
1147 Some("selector") => open_extension_selector(ctx, session, command_name, value),
1148 Some("editor") => open_extension_editor(ctx, session, command_name, value),
1149 Some("input") => open_extension_input(ctx, session, command_name, value),
1153 Some(other) => {
1154 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1155 ctx.tui.request_render(false);
1156 }
1157 }
1158}
1159
1160fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1164 let colors = current_theme().colors;
1165 let text = if bold {
1166 tui_bold(title)
1167 } else {
1168 title.to_string()
1169 };
1170 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1171}
1172
1173fn extension_dialog_hint(label: &str) -> Arc<Text> {
1174 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1175}
1176
1177fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1180 let text = if bold {
1181 format!("**{}**", title)
1182 } else {
1183 title.to_string()
1184 };
1185 Arc::new(Markdown::new(text, 1, 0))
1186}
1187
1188fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1191 Arc::new(Markdown::new(label, 1, 0))
1192}
1193
1194fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1198 let callback = state.active_extension_cancel.lock().unwrap().take();
1199 if let Some(callback) = callback {
1200 callback();
1201 true
1202 } else {
1203 false
1204 }
1205}
1206
1207fn open_extension_input(
1208 ctx: &CommandContext,
1209 session: crate::session::ExtensionSessionCell,
1210 command_name: String,
1211 value: serde_json::Value,
1212) {
1213 let title = value
1214 .get("title")
1215 .and_then(|v| v.as_str())
1216 .filter(|title| !title.is_empty())
1217 .unwrap_or("Input");
1218 let input = value
1219 .get("placeholder")
1220 .and_then(|v| v.as_str())
1221 .map(Input::with_placeholder)
1222 .unwrap_or_default();
1223 let input = Arc::new(input);
1224 if let Some(initial) = value
1225 .get("initialText")
1226 .or_else(|| value.get("text"))
1227 .and_then(|v| v.as_str())
1228 {
1229 input.set_value(initial);
1230 }
1231 input.set_focused(true);
1232
1233 let frame = Arc::new(Container::new());
1234 frame.add_child(Arc::new(DynamicBorder::new()));
1235 frame.add_child(Arc::new(Spacer::new(1)));
1236 frame.add_child(extension_dialog_title(title, false));
1237 frame.add_child(Arc::new(Spacer::new(1)));
1238 frame.add_child(input.clone());
1239 frame.add_child(Arc::new(Spacer::new(1)));
1240 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1241 frame.add_child(Arc::new(Spacer::new(1)));
1242 frame.add_child(Arc::new(DynamicBorder::new()));
1243
1244 *ctx.state.active_extension_editor.lock().unwrap() = None;
1245 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1246 ctx.editor_container.clear();
1247 ctx.editor_container.add_child(frame);
1248
1249 let state = ctx.state.clone();
1250 let ec = ctx.editor_container.clone();
1251 let original = ctx.editor.clone();
1252 let tui = ctx.tui.clone();
1253 let session_submit = session.clone();
1254 let command_submit = command_name.clone();
1255 let ctx_submit = ctx.clone();
1256 input.on_submit(Arc::new(move |text| {
1257 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1258 let result = invoke_extension_command(
1259 &session_submit,
1260 &command_submit,
1261 &serde_json::to_string(&args).unwrap_or_default(),
1262 );
1263 close_extension_editor(&state, &ec, &original, &tui);
1264 handle_extension_ui_result(
1265 result,
1266 &ctx_submit,
1267 session_submit.clone(),
1268 command_submit.clone(),
1269 );
1270 }));
1271
1272 let state_cancel = ctx.state.clone();
1273 let ec_cancel = ctx.editor_container.clone();
1274 let original_cancel = ctx.editor.clone();
1275 let tui_cancel = ctx.tui.clone();
1276 let session_cancel = session.clone();
1277 let command_cancel = command_name.clone();
1278 let ctx_cancel = ctx.clone();
1279 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1280 let args = serde_json::json!({ "action": "cancel" });
1281 let result = invoke_extension_command(
1282 &session_cancel,
1283 &command_cancel,
1284 &serde_json::to_string(&args).unwrap_or_default(),
1285 );
1286 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1287 handle_extension_ui_result(
1288 result,
1289 &ctx_cancel,
1290 session_cancel.clone(),
1291 command_cancel.clone(),
1292 );
1293 }));
1294
1295 ctx.tui.set_focus(Some(input));
1296 ctx.tui.request_render(false);
1297}
1298
1299fn open_extension_selector(
1300 ctx: &CommandContext,
1301 session: crate::session::ExtensionSessionCell,
1302 command_name: String,
1303 value: serde_json::Value,
1304) {
1305 let items = value
1306 .get("items")
1307 .and_then(|v| v.as_array())
1308 .map(|items| {
1309 items
1310 .iter()
1311 .filter_map(|item| {
1312 let value = if let Some(value) = item.as_str() {
1315 value
1316 } else {
1317 item.get("value")?.as_str()?
1318 };
1319 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1320 let mut out = SelectItem::new(value, label);
1321 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1322 out = out.with_description(desc);
1323 }
1324 Some(out)
1325 })
1326 .collect::<Vec<_>>()
1327 })
1328 .unwrap_or_default();
1329 if items.is_empty() {
1330 add_error_message(&ctx.chat, "Extension selector has no items.");
1331 ctx.tui.request_render(false);
1332 return;
1333 }
1334 let list = Arc::new(SelectList::new(items, 10));
1335 let title = value
1336 .get("title")
1337 .and_then(|v| v.as_str())
1338 .filter(|title| !title.is_empty())
1339 .unwrap_or("Select");
1340 let frame = Arc::new(Container::new());
1341 frame.add_child(Arc::new(DynamicBorder::new()));
1342 frame.add_child(Arc::new(Spacer::new(1)));
1343 frame.add_child(extension_dialog_title(title, true));
1344 frame.add_child(Arc::new(Spacer::new(1)));
1345 frame.add_child(list.clone());
1346 frame.add_child(Arc::new(Spacer::new(1)));
1347 frame.add_child(extension_dialog_hint(
1348 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1349 ));
1350 frame.add_child(Arc::new(Spacer::new(1)));
1351 frame.add_child(Arc::new(DynamicBorder::new()));
1352
1353 let state = ctx.state.clone();
1354 let ec = ctx.editor_container.clone();
1355 let editor = ctx.editor.clone();
1356 let tui = ctx.tui.clone();
1357 let session_select = session.clone();
1358 let command_select = command_name.clone();
1359 let ctx_select = ctx.clone();
1360 list.on_select(Arc::new(move |item| {
1361 let args = serde_json::json!({ "action": "select", "value": item.value });
1362 let result = invoke_extension_command(
1363 &session_select,
1364 &command_select,
1365 &serde_json::to_string(&args).unwrap_or_default(),
1366 );
1367 close_selector(&state, &ec, &editor, &tui);
1368 handle_extension_ui_result(
1369 result,
1370 &ctx_select,
1371 session_select.clone(),
1372 command_select.clone(),
1373 );
1374 }));
1375 let state_cancel = ctx.state.clone();
1376 let ec_cancel = ctx.editor_container.clone();
1377 let editor_cancel = ctx.editor.clone();
1378 let tui_cancel = ctx.tui.clone();
1379 list.on_cancel(Arc::new(move || {
1380 if !run_extension_cancel(&state_cancel) {
1381 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1382 }
1383 }));
1384 let state_cancel = ctx.state.clone();
1385 let ec_cancel = ctx.editor_container.clone();
1386 let editor_cancel = ctx.editor.clone();
1387 let tui_cancel = ctx.tui.clone();
1388 let session_cancel = session.clone();
1389 let command_cancel = command_name.clone();
1390 let ctx_cancel = ctx.clone();
1391 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1392 let args = serde_json::json!({ "action": "cancel" });
1393 let result = invoke_extension_command(
1394 &session_cancel,
1395 &command_cancel,
1396 &serde_json::to_string(&args).unwrap_or_default(),
1397 );
1398 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1399 handle_extension_ui_result(
1400 result,
1401 &ctx_cancel,
1402 session_cancel.clone(),
1403 command_cancel.clone(),
1404 );
1405 }));
1406 open_selector_with_view(
1407 &ctx.state,
1408 &ctx.editor_container,
1409 &ctx.editor,
1410 &ctx.tui,
1411 SelectorView::List(list),
1412 frame,
1413 SelectorKind::Extension,
1414 );
1415}
1416
1417fn open_extension_editor(
1418 ctx: &CommandContext,
1419 session: crate::session::ExtensionSessionCell,
1420 command_name: String,
1421 value: serde_json::Value,
1422) {
1423 let initial = value
1424 .get("initialText")
1425 .or_else(|| value.get("text"))
1426 .and_then(|v| v.as_str())
1427 .unwrap_or_default()
1428 .to_string();
1429 let title = value
1430 .get("title")
1431 .and_then(|v| v.as_str())
1432 .filter(|title| !title.is_empty())
1433 .unwrap_or("Editor");
1434 let editor = Arc::new(Editor::new(
1435 EditorOptions {
1436 padding_x: 1,
1437 autocomplete_max_visible: 0,
1438 placeholder: value
1439 .get("placeholder")
1440 .and_then(|v| v.as_str())
1441 .map(str::to_string),
1442 initial_text: Some(initial),
1443 },
1444 EditorStyle {
1445 prompt: "> ".to_string(),
1446 placeholder: String::new(),
1447 },
1448 Arc::new(rpi_tui::Keybindings::new()),
1449 ));
1450 editor.set_focused(true);
1451 let frame = Arc::new(Container::new());
1452 frame.add_child(Arc::new(DynamicBorder::new()));
1453 frame.add_child(Arc::new(Spacer::new(1)));
1454 frame.add_child(extension_dialog_title(title, false));
1455 frame.add_child(Arc::new(Spacer::new(1)));
1456 frame.add_child(editor.clone());
1457 frame.add_child(Arc::new(Spacer::new(1)));
1458 frame.add_child(extension_dialog_hint(
1459 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1460 ));
1461 frame.add_child(Arc::new(Spacer::new(1)));
1462 frame.add_child(Arc::new(DynamicBorder::new()));
1463
1464 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1465 *ctx.state.active_extension_input.lock().unwrap() = None;
1466 ctx.editor_container.clear();
1467 ctx.editor_container.add_child(frame);
1468
1469 let state = ctx.state.clone();
1470 let ec = ctx.editor_container.clone();
1471 let original = ctx.editor.clone();
1472 let tui = ctx.tui.clone();
1473 let session_submit = session.clone();
1474 let command_submit = command_name.clone();
1475 let ctx_submit = ctx.clone();
1476 editor.on_submit(Arc::new(move |text| {
1477 let args = serde_json::json!({ "action": "edit", "text": text });
1478 let result = invoke_extension_command(
1479 &session_submit,
1480 &command_submit,
1481 &serde_json::to_string(&args).unwrap_or_default(),
1482 );
1483 close_extension_editor(&state, &ec, &original, &tui);
1484 handle_extension_ui_result(
1485 result,
1486 &ctx_submit,
1487 session_submit.clone(),
1488 command_submit.clone(),
1489 );
1490 }));
1491
1492 let state_cancel = ctx.state.clone();
1493 let ec_cancel = ctx.editor_container.clone();
1494 let original_cancel = ctx.editor.clone();
1495 let tui_cancel = ctx.tui.clone();
1496 let session_cancel = session.clone();
1497 let command_cancel = command_name.clone();
1498 let ctx_cancel = ctx.clone();
1499 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1500 let args = serde_json::json!({ "action": "cancel" });
1501 let result = invoke_extension_command(
1502 &session_cancel,
1503 &command_cancel,
1504 &serde_json::to_string(&args).unwrap_or_default(),
1505 );
1506 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1507 handle_extension_ui_result(
1508 result,
1509 &ctx_cancel,
1510 session_cancel.clone(),
1511 command_cancel.clone(),
1512 );
1513 }));
1514
1515 ctx.tui.set_focus(Some(editor));
1516 ctx.tui.request_render(false);
1517}
1518
1519fn close_extension_editor(
1520 state: &Arc<TuiState>,
1521 editor_container: &Arc<Container>,
1522 editor: &Arc<Editor>,
1523 tui: &Arc<TuiAltScreen>,
1524) {
1525 editor_container.clear();
1526 editor_container.add_child(editor.clone());
1527 state.autocomplete_container.clear();
1528 *state.active_extension_editor.lock().unwrap() = None;
1529 *state.active_extension_input.lock().unwrap() = None;
1530 *state.active_extension_cancel.lock().unwrap() = None;
1531 editor.set_focused(true);
1532 tui.set_focus(Some(editor.clone()));
1533 tui.request_render(false);
1534}
1535
1536fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1540 match request.method.as_str() {
1541 "select" => open_js_selector(ctx, bridge, request, false),
1542 "confirm" => open_js_selector(ctx, bridge, request, true),
1543 "input" => open_js_input(ctx, bridge, request),
1544 "editor" => open_js_editor(ctx, bridge, request),
1545 _ => {
1546 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1547 }
1548 }
1549}
1550
1551fn open_js_selector(
1552 ctx: &CommandContext,
1553 bridge: Arc<JsDialogBridge>,
1554 request: JsDialogRequest,
1555 confirm: bool,
1556) {
1557 let values = if confirm {
1558 vec!["Yes".to_string(), "No".to_string()]
1559 } else {
1560 request.options.clone()
1561 };
1562 if values.is_empty() {
1563 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1564 return;
1565 }
1566 let items = values
1567 .iter()
1568 .map(|value| SelectItem::new(value, value))
1569 .collect::<Vec<_>>();
1570 let list = Arc::new(SelectList::new(items, 10));
1571 let frame = Arc::new(Container::new());
1572 frame.add_child(Arc::new(DynamicBorder::new()));
1573 frame.add_child(Arc::new(Spacer::new(1)));
1574 frame.add_child(extension_dialog_title(
1575 if request.title.is_empty() {
1576 if confirm {
1577 "Confirm"
1578 } else {
1579 "Select"
1580 }
1581 } else {
1582 request.title.as_str()
1583 },
1584 true,
1585 ));
1586 if !request.message.is_empty() {
1587 frame.add_child(Arc::new(Spacer::new(1)));
1588 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1589 }
1590 frame.add_child(Arc::new(Spacer::new(1)));
1591 frame.add_child(list.clone());
1592 frame.add_child(Arc::new(Spacer::new(1)));
1593 frame.add_child(extension_dialog_hint(
1594 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1595 ));
1596 frame.add_child(Arc::new(Spacer::new(1)));
1597 frame.add_child(Arc::new(DynamicBorder::new()));
1598
1599 let id = request.id.clone();
1600 let bridge_select = bridge.clone();
1601 let state_select = ctx.state.clone();
1602 let ec_select = ctx.editor_container.clone();
1603 let editor_select = ctx.editor.clone();
1604 let tui_select = ctx.tui.clone();
1605 list.on_select(Arc::new(move |item| {
1606 let result = if confirm {
1607 serde_json::json!({ "confirmed": item.value == "Yes" })
1608 } else {
1609 serde_json::json!({ "value": item.value })
1610 };
1611 bridge_select.respond(&id, result);
1612 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1613 }));
1614
1615 let id_cancel = request.id.clone();
1616 let bridge_cancel = bridge.clone();
1617 let state_cancel = ctx.state.clone();
1618 let ec_cancel = ctx.editor_container.clone();
1619 let editor_cancel = ctx.editor.clone();
1620 let tui_cancel = ctx.tui.clone();
1621 list.on_cancel(Arc::new(move || {
1622 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1623 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1624 }));
1625
1626 let id_abort = request.id.clone();
1627 let bridge_abort = bridge.clone();
1628 let state_abort = ctx.state.clone();
1629 let ec_abort = ctx.editor_container.clone();
1630 let editor_abort = ctx.editor.clone();
1631 let tui_abort = ctx.tui.clone();
1632 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1633 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1634 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1635 }));
1636
1637 open_selector_with_view(
1638 &ctx.state,
1639 &ctx.editor_container,
1640 &ctx.editor,
1641 &ctx.tui,
1642 SelectorView::List(list),
1643 frame,
1644 SelectorKind::Extension,
1645 );
1646}
1647
1648fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1649 let input = request
1650 .placeholder
1651 .as_deref()
1652 .map(Input::with_placeholder)
1653 .unwrap_or_default();
1654 let input = Arc::new(input);
1655 input.set_focused(true);
1656
1657 let frame = Arc::new(Container::new());
1658 frame.add_child(Arc::new(DynamicBorder::new()));
1659 frame.add_child(Arc::new(Spacer::new(1)));
1660 frame.add_child(extension_dialog_title(
1661 if request.title.is_empty() {
1662 "Input"
1663 } else {
1664 &request.title
1665 },
1666 false,
1667 ));
1668 frame.add_child(Arc::new(Spacer::new(1)));
1669 frame.add_child(input.clone());
1670 frame.add_child(Arc::new(Spacer::new(1)));
1671 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1672 frame.add_child(Arc::new(Spacer::new(1)));
1673 frame.add_child(Arc::new(DynamicBorder::new()));
1674
1675 *ctx.state.active_extension_editor.lock().unwrap() = None;
1676 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1677 ctx.editor_container.clear();
1678 ctx.editor_container.add_child(frame);
1679
1680 let id = request.id.clone();
1681 let bridge_submit = bridge.clone();
1682 let state_submit = ctx.state.clone();
1683 let ec_submit = ctx.editor_container.clone();
1684 let editor_submit = ctx.editor.clone();
1685 let tui_submit = ctx.tui.clone();
1686 input.on_submit(Arc::new(move |value| {
1687 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1688 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1689 }));
1690
1691 let id_cancel = request.id.clone();
1692 let bridge_cancel = bridge.clone();
1693 let state_cancel = ctx.state.clone();
1694 let ec_cancel = ctx.editor_container.clone();
1695 let editor_cancel = ctx.editor.clone();
1696 let tui_cancel = ctx.tui.clone();
1697 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1698 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1699 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1700 }));
1701 ctx.tui.set_focus(Some(input));
1702 ctx.tui.request_render(false);
1703}
1704
1705fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1706 let editor = Arc::new(Editor::new(
1707 EditorOptions {
1708 padding_x: 1,
1709 autocomplete_max_visible: 0,
1710 initial_text: request.prefill.clone(),
1711 ..Default::default()
1712 },
1713 EditorStyle {
1714 prompt: "> ".to_string(),
1715 placeholder: String::new(),
1716 },
1717 Arc::new(rpi_tui::Keybindings::new()),
1718 ));
1719 editor.set_focused(true);
1720
1721 let frame = Arc::new(Container::new());
1722 frame.add_child(Arc::new(DynamicBorder::new()));
1723 frame.add_child(Arc::new(Spacer::new(1)));
1724 frame.add_child(extension_dialog_title(
1725 if request.title.is_empty() {
1726 "Editor"
1727 } else {
1728 &request.title
1729 },
1730 false,
1731 ));
1732 frame.add_child(Arc::new(Spacer::new(1)));
1733 frame.add_child(editor.clone());
1734 frame.add_child(Arc::new(Spacer::new(1)));
1735 frame.add_child(extension_dialog_hint(
1736 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1737 ));
1738 frame.add_child(Arc::new(Spacer::new(1)));
1739 frame.add_child(Arc::new(DynamicBorder::new()));
1740
1741 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1742 *ctx.state.active_extension_input.lock().unwrap() = None;
1743 ctx.editor_container.clear();
1744 ctx.editor_container.add_child(frame);
1745
1746 let id = request.id.clone();
1747 let bridge_submit = bridge.clone();
1748 let state_submit = ctx.state.clone();
1749 let ec_submit = ctx.editor_container.clone();
1750 let editor_submit = ctx.editor.clone();
1751 let tui_submit = ctx.tui.clone();
1752 editor.on_submit(Arc::new(move |value| {
1753 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1754 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1755 }));
1756
1757 let id_cancel = request.id.clone();
1758 let bridge_cancel = bridge.clone();
1759 let state_cancel = ctx.state.clone();
1760 let ec_cancel = ctx.editor_container.clone();
1761 let editor_cancel = ctx.editor.clone();
1762 let tui_cancel = ctx.tui.clone();
1763 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1764 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1765 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1766 }));
1767 ctx.tui.set_focus(Some(editor));
1768 ctx.tui.request_render(false);
1769}
1770
1771fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1772 for id in bridge.cancelled_active_ids() {
1773 if !bridge.is_visible(&id) {
1777 bridge.finish(&id);
1778 continue;
1779 }
1780 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1781 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1782 } else if ctx.state.extension_dialog_open() {
1783 if !run_extension_cancel(&ctx.state) {
1784 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1785 }
1786 }
1787 bridge.finish(&id);
1791 }
1792}
1793
1794const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1801
1802#[derive(Clone)]
1807struct AskUserBridge {
1808 mailbox: rpi_extensions::UiDialogMailbox,
1809 visible: Arc<Mutex<Option<String>>>,
1810}
1811
1812impl AskUserBridge {
1813 fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1814 Self {
1815 mailbox,
1816 visible: Arc::new(Mutex::new(None)),
1817 }
1818 }
1819
1820 fn attach(&self) {
1822 self.mailbox.attach();
1823 }
1824
1825 fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1829 let mut visible = self.visible.lock().ok()?;
1830 if visible.is_some() {
1831 return None;
1832 }
1833 let request = self.mailbox.take_pending()?;
1834 *visible = Some(request.request_id.clone());
1835 Some(request)
1836 }
1837
1838 fn clear_visible(&self, id: &str) {
1839 if let Ok(mut visible) = self.visible.lock() {
1840 if visible.as_deref() == Some(id) {
1841 *visible = None;
1842 }
1843 }
1844 }
1845
1846 fn respond(&self, id: &str, answer: serde_json::Value) {
1848 let _ = self.mailbox.respond(id, answer);
1849 self.clear_visible(id);
1850 }
1851
1852 fn cancel(&self, id: &str) {
1854 let _ = self.mailbox.cancel(id);
1855 self.clear_visible(id);
1856 }
1857
1858 fn cancel_all(&self) {
1861 self.mailbox.cancel_all();
1862 if let Ok(mut visible) = self.visible.lock() {
1863 *visible = None;
1864 }
1865 }
1866
1867 fn shutdown(&self) {
1870 self.mailbox.detach();
1871 if let Ok(mut visible) = self.visible.lock() {
1872 *visible = None;
1873 }
1874 }
1875}
1876
1877#[derive(Clone)]
1881struct AskUserPrompt {
1882 question_id: String,
1883 question: String,
1884 header: Option<String>,
1885 context: Option<String>,
1886 options: Vec<(String, Option<String>)>,
1887 allow_multiple: bool,
1888 allow_freeform: bool,
1889 suggest: Option<String>,
1890 kind: String,
1891}
1892
1893fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1894 keys.iter().find_map(|key| {
1895 value
1896 .get(*key)
1897 .and_then(serde_json::Value::as_str)
1898 .map(str::trim)
1899 .filter(|text| !text.is_empty())
1900 .map(str::to_string)
1901 })
1902}
1903
1904fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1905 keys.iter()
1906 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1907}
1908
1909fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1910 let mut options = Vec::new();
1911 let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1912 return options;
1913 };
1914 for item in items {
1915 if let Some(title) = item.as_str() {
1916 let title = title.trim();
1917 if !title.is_empty() {
1918 options.push((title.to_string(), None));
1919 }
1920 continue;
1921 }
1922 let Some(object) = item.as_object() else {
1923 continue;
1924 };
1925 let title = ["title", "label", "text", "value", "name"]
1926 .iter()
1927 .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1928 .map(str::trim)
1929 .filter(|title| !title.is_empty());
1930 let Some(title) = title else { continue };
1931 let description = object
1932 .get("description")
1933 .and_then(serde_json::Value::as_str)
1934 .map(str::trim)
1935 .filter(|text| !text.is_empty())
1936 .map(str::to_string);
1937 options.push((title.to_string(), description));
1938 }
1939 options
1940}
1941
1942fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1943 let ui = &request.ui;
1944 let kind = ui
1945 .get("kind")
1946 .and_then(serde_json::Value::as_str)
1947 .unwrap_or("selector")
1948 .to_string();
1949 let nested = ui
1953 .get("questions")
1954 .and_then(serde_json::Value::as_array)
1955 .and_then(|items| items.first());
1956 let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1957
1958 let question = ask_user_str(source, &["question", "summary", "title"])
1959 .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1960 .unwrap_or_default();
1961 let question_id = ask_user_str(source, &["id", "questionId"])
1962 .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1963 .unwrap_or_else(|| request.request_id.clone());
1964 let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1965 let context = ask_user_str(source, &["context", "message"])
1966 .or_else(|| ask_user_str(ui, &["context", "message"]));
1967 let mut options = ask_user_options(source);
1968 if options.is_empty() {
1969 options = ask_user_options(ui);
1970 }
1971 if kind == "confirm" && options.is_empty() {
1972 options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
1973 }
1974 let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1975 .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1976 .unwrap_or(false);
1977 let allow_freeform =
1980 ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
1981 .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
1982 .unwrap_or(options.is_empty());
1983 let suggest = ask_user_str(source, &["suggest", "placeholder"])
1984 .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1985
1986 AskUserPrompt {
1987 question_id,
1988 question,
1989 header,
1990 context,
1991 options,
1992 allow_multiple,
1993 allow_freeform,
1994 suggest,
1995 kind,
1996 }
1997}
1998
1999fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
2002 let frame = Arc::new(Container::new());
2003 frame.add_child(Arc::new(DynamicBorder::new()));
2004 frame.add_child(Arc::new(Spacer::new(1)));
2005 if let Some(header) = prompt.header.as_deref() {
2006 frame.add_child(extension_dialog_title_md(header, true));
2007 }
2008 if !prompt.question.trim().is_empty() {
2009 frame.add_child(extension_dialog_title_md(
2010 &prompt.question,
2011 prompt.header.is_none(),
2012 ));
2013 }
2014 if let Some(context) = prompt.context.as_deref() {
2015 frame.add_child(extension_dialog_hint_md(context));
2016 }
2017 frame.add_child(Arc::new(Spacer::new(1)));
2018 frame.add_child(body);
2019 frame.add_child(Arc::new(Spacer::new(1)));
2020 frame.add_child(extension_dialog_hint(hint));
2021 frame.add_child(Arc::new(Spacer::new(1)));
2022 frame.add_child(Arc::new(DynamicBorder::new()));
2023 frame
2024}
2025
2026fn open_ask_user_dialog(
2028 ctx: &CommandContext,
2029 bridge: AskUserBridge,
2030 request: rpi_extensions::UiDialogRequest,
2031) {
2032 let prompt = parse_ask_user_prompt(&request);
2033 if prompt.kind == "input" || prompt.options.is_empty() {
2034 open_ask_user_input(ctx, bridge, request, prompt);
2035 } else {
2036 open_ask_user_selector(ctx, bridge, request, prompt);
2037 }
2038}
2039
2040fn open_ask_user_selector(
2041 ctx: &CommandContext,
2042 bridge: AskUserBridge,
2043 request: rpi_extensions::UiDialogRequest,
2044 prompt: AskUserPrompt,
2045) {
2046 let multi = prompt.allow_multiple && prompt.kind != "confirm";
2047 let mut items: Vec<SelectItem> = Vec::new();
2048 for (title, description) in &prompt.options {
2049 let item = SelectItem::new(title, title);
2050 let item = match description.as_deref() {
2051 Some(description) => item.with_description(description),
2052 None => item,
2053 };
2054 items.push(item);
2055 }
2056 if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
2059 items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
2060 }
2061 let list = if multi {
2062 Arc::new(SelectList::new_multi(items, 10))
2063 } else {
2064 Arc::new(SelectList::new(items, 10))
2065 };
2066
2067 let request_id = request.request_id.clone();
2068 let state = ctx.state.clone();
2069 let editor_container = ctx.editor_container.clone();
2070 let editor = ctx.editor.clone();
2071 let tui = ctx.tui.clone();
2072
2073 let bridge_select = bridge.clone();
2076 let request_for_freeform = request.clone();
2077 let prompt_for_freeform = AskUserPrompt {
2078 question_id: prompt.question_id.clone(),
2079 question: prompt.question.clone(),
2080 header: prompt.header.clone(),
2081 context: prompt.context.clone(),
2082 options: Vec::new(),
2083 allow_multiple: false,
2084 allow_freeform: true,
2085 suggest: prompt.suggest.clone(),
2086 kind: "input".to_string(),
2087 };
2088 let state_select = state.clone();
2089 let ec_select = editor_container.clone();
2090 let editor_select = editor.clone();
2091 let tui_select = tui.clone();
2092 let ctx_select = ctx.clone();
2093 list.on_select(Arc::new(move |item: &SelectItem| {
2094 if item.value == ASK_USER_FREEFORM {
2095 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2096 open_ask_user_input(
2097 &ctx_select,
2098 bridge_select.clone(),
2099 request_for_freeform.clone(),
2100 prompt_for_freeform.clone(),
2101 );
2102 return;
2103 }
2104 bridge_select.respond(
2105 &request_id,
2106 serde_json::json!({
2107 "values": [item.display_value()],
2108 "kind": "selector",
2109 }),
2110 );
2111 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2112 }));
2113
2114 if multi {
2115 let bridge_multi = bridge.clone();
2116 let request_id_multi = request.request_id.clone();
2117 let state_multi = state.clone();
2118 let ec_multi = editor_container.clone();
2119 let editor_multi = editor.clone();
2120 let tui_multi = tui.clone();
2121 list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2122 let values: Vec<String> = selected
2123 .iter()
2124 .map(|item| item.display_value().to_string())
2125 .collect();
2126 bridge_multi.respond(
2127 &request_id_multi,
2128 serde_json::json!({"values": values, "kind": "selector"}),
2129 );
2130 close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2131 }));
2132 }
2133
2134 let bridge_cancel = bridge.clone();
2135 let request_id_cancel = request.request_id.clone();
2136 let state_cancel = state.clone();
2137 let ec_cancel = editor_container.clone();
2138 let editor_cancel = editor.clone();
2139 let tui_cancel = tui.clone();
2140 list.on_cancel(Arc::new(move || {
2141 bridge_cancel.cancel(&request_id_cancel);
2142 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2143 }));
2144
2145 let hint = if multi {
2146 "Space toggle · Enter submit · Esc cancel"
2147 } else {
2148 "Enter select · Esc cancel"
2149 };
2150 let frame = ask_user_frame(&prompt, list.clone(), hint);
2151 open_selector_with_view(
2152 &state,
2153 &editor_container,
2154 &editor,
2155 &tui,
2156 SelectorView::List(list),
2157 frame,
2158 SelectorKind::Extension,
2159 );
2160}
2161
2162fn open_ask_user_input(
2163 ctx: &CommandContext,
2164 bridge: AskUserBridge,
2165 request: rpi_extensions::UiDialogRequest,
2166 prompt: AskUserPrompt,
2167) {
2168 let placeholder = prompt
2169 .suggest
2170 .clone()
2171 .unwrap_or_else(|| "Type your answer…".to_string());
2172 let input = Arc::new(Input::with_placeholder(&placeholder));
2173 input.set_focused(true);
2174
2175 let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2176 *ctx.state.active_extension_editor.lock().unwrap() = None;
2177 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2178 ctx.editor_container.clear();
2179 ctx.editor_container.add_child(frame);
2180 ctx.tui.set_focus(Some(input.clone()));
2181 ctx.tui.request_render(false);
2182
2183 let state = ctx.state.clone();
2184 let ec = ctx.editor_container.clone();
2185 let editor = ctx.editor.clone();
2186 let tui = ctx.tui.clone();
2187 let bridge_submit = bridge.clone();
2188 let request_id = request.request_id.clone();
2189 input.on_submit(Arc::new(move |text: &str| {
2190 bridge_submit.respond(
2191 &request_id,
2192 serde_json::json!({
2193 "values": [text],
2194 "value": text,
2195 "text": text,
2196 "kind": "input",
2197 }),
2198 );
2199 close_extension_editor(&state, &ec, &editor, &tui);
2200 }));
2201
2202 let state_cancel = ctx.state.clone();
2203 let ec_cancel = ctx.editor_container.clone();
2204 let editor_cancel = ctx.editor.clone();
2205 let tui_cancel = ctx.tui.clone();
2206 let bridge_cancel = bridge.clone();
2207 let request_id_cancel = request.request_id.clone();
2208 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2209 bridge_cancel.cancel(&request_id_cancel);
2210 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2211 }));
2212}
2213
2214struct HelpCommand;
2217impl SlashCommand for HelpCommand {
2218 fn name(&self) -> &'static str {
2219 "/help"
2220 }
2221 fn aliases(&self) -> &'static [&'static str] {
2222 &["/?"]
2223 }
2224 fn description(&self) -> &'static str {
2225 "Show available commands"
2226 }
2227 fn execute(&self, ctx: &CommandContext, _args: &str) {
2228 add_help_message(&ctx.chat);
2229 ctx.tui.request_render(false);
2230 }
2231}
2232
2233struct ClearChatCommand;
2234impl SlashCommand for ClearChatCommand {
2235 fn name(&self) -> &'static str {
2236 "/clear"
2237 }
2238 fn aliases(&self) -> &'static [&'static str] {
2239 &["/new"]
2240 }
2241 fn alias_visible(&self) -> &'static [&'static str] {
2243 &["/new"]
2244 }
2245 fn description(&self) -> &'static str {
2246 "Clear the conversation"
2247 }
2248 fn execute(&self, ctx: &CommandContext, _args: &str) {
2249 let _ = ctx.tx.send(TuiMessage::ClearChat);
2250 }
2251}
2252
2253struct ExitCommand;
2254impl SlashCommand for ExitCommand {
2255 fn name(&self) -> &'static str {
2256 "/exit"
2257 }
2258 fn aliases(&self) -> &'static [&'static str] {
2259 &["/quit", "/q"]
2260 }
2261 fn alias_visible(&self) -> &'static [&'static str] {
2263 &["/quit"]
2264 }
2265 fn description(&self) -> &'static str {
2266 "Exit the application"
2267 }
2268 fn execute(&self, ctx: &CommandContext, _args: &str) {
2269 ctx.state.cancel_js_preparation();
2270 let _ = ctx.tx.send(TuiMessage::Exit);
2271 }
2272}
2273
2274struct VersionCommand;
2275impl SlashCommand for VersionCommand {
2276 fn name(&self) -> &'static str {
2277 "/version"
2278 }
2279 fn aliases(&self) -> &'static [&'static str] {
2280 &["/v"]
2281 }
2282 fn description(&self) -> &'static str {
2283 "Show version information"
2284 }
2285 fn execute(&self, ctx: &CommandContext, _args: &str) {
2286 add_version_message(&ctx.chat);
2287 ctx.tui.request_render(false);
2288 }
2289}
2290
2291struct ChangelogCommand;
2292impl SlashCommand for ChangelogCommand {
2293 fn name(&self) -> &'static str {
2294 "/changelog"
2295 }
2296 fn description(&self) -> &'static str {
2297 "Show recent release changes"
2298 }
2299 fn execute(&self, ctx: &CommandContext, _args: &str) {
2300 add_changelog_message(&ctx.chat);
2301 ctx.tui.request_render(false);
2302 }
2303}
2304
2305struct HotkeysCommand;
2306impl SlashCommand for HotkeysCommand {
2307 fn name(&self) -> &'static str {
2308 "/hotkeys"
2309 }
2310 fn description(&self) -> &'static str {
2311 "Show keyboard shortcuts"
2312 }
2313 fn execute(&self, ctx: &CommandContext, _args: &str) {
2314 add_hotkeys_message(&ctx.chat);
2315 ctx.tui.request_render(false);
2316 }
2317}
2318
2319struct ModelCommand;
2320impl SlashCommand for ModelCommand {
2321 fn name(&self) -> &'static str {
2322 "/model"
2323 }
2324 fn aliases(&self) -> &'static [&'static str] {
2325 &["/m"]
2326 }
2327 fn description(&self) -> &'static str {
2328 "Choose a model (selector)"
2329 }
2330 fn execute(&self, ctx: &CommandContext, args: &str) {
2331 let term = args.trim();
2332 if !term.is_empty() {
2333 if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
2339 let model_id = model.id.clone();
2340 ctx.state.set_current_model(&model);
2341 let lane = ctx.lane.clone();
2342 tokio::spawn(async move {
2343 let _ = lane.set_model(model).await;
2344 });
2345 add_note_message(
2346 &ctx.chat,
2347 &format!(
2348 "Model set to {} — applies to the next message.",
2349 short_model_name(&model_id)
2350 ),
2351 );
2352 ctx.tui.request_render(false);
2353 return;
2354 }
2355 }
2356 open_model_selector(
2357 &ctx.state,
2358 &ctx.editor_container,
2359 &ctx.editor,
2360 &ctx.tui,
2361 &ctx.model_catalog,
2362 &ctx.lane,
2363 &ctx.lane_model_id,
2364 &ctx.chat,
2365 (!term.is_empty()).then_some(term),
2366 );
2367 }
2368}
2369
2370struct ThinkingCommand;
2371impl SlashCommand for ThinkingCommand {
2372 fn name(&self) -> &'static str {
2373 "/thinking"
2374 }
2375 fn aliases(&self) -> &'static [&'static str] {
2376 &["/think"]
2377 }
2378 fn description(&self) -> &'static str {
2379 "Set thinking level (selector)"
2380 }
2381 fn execute(&self, ctx: &CommandContext, args: &str) {
2382 let level_name = args.trim();
2383 if !level_name.is_empty() {
2384 let Some(level) = thinking_level_from_name(level_name) else {
2386 add_error_message(
2387 &ctx.chat,
2388 &format!(
2389 "Unknown thinking level \"{level_name}\". Valid: {}",
2390 crate::args::VALID_THINKING_LEVELS.join(", ")
2391 ),
2392 );
2393 ctx.tui.request_render(false);
2394 return;
2395 };
2396 let lane = ctx.lane.clone();
2397 let footer = ctx.state.footer.clone();
2398 tokio::spawn(async move {
2399 let _ = lane.set_thinking_level(level).await;
2400 });
2401 footer.set_thinking_level(Some(thinking_level_name(level)));
2402 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2403 ctx.tui.request_render(false);
2404 return;
2405 }
2406 open_thinking_selector(
2407 &ctx.state,
2408 &ctx.editor_container,
2409 &ctx.editor,
2410 &ctx.tui,
2411 &ctx.lane,
2412 &ctx.model_catalog,
2413 &ctx.lane_model_id,
2414 &ctx.chat,
2415 );
2416 }
2417}
2418
2419struct ToolsCommand;
2420impl SlashCommand for ToolsCommand {
2421 fn name(&self) -> &'static str {
2422 "/tools"
2423 }
2424 fn description(&self) -> &'static str {
2425 "Toggle tools on/off"
2426 }
2427 fn execute(&self, ctx: &CommandContext, _args: &str) {
2428 open_tools_selector(
2429 &ctx.state,
2430 &ctx.editor_container,
2431 &ctx.editor,
2432 &ctx.tui,
2433 &ctx.lane,
2434 &ctx.chat,
2435 );
2436 }
2437}
2438
2439struct ImagesCommand;
2440impl SlashCommand for ImagesCommand {
2441 fn name(&self) -> &'static str {
2442 "/images"
2443 }
2444 fn description(&self) -> &'static str {
2445 "Toggle inline images"
2446 }
2447 fn execute(&self, ctx: &CommandContext, _args: &str) {
2448 open_images_selector(
2449 &ctx.state,
2450 &ctx.editor_container,
2451 &ctx.editor,
2452 &ctx.tui,
2453 &ctx.chat,
2454 );
2455 }
2456}
2457
2458struct SessionCommand;
2459impl SlashCommand for SessionCommand {
2460 fn name(&self) -> &'static str {
2461 "/session"
2462 }
2463 fn aliases(&self) -> &'static [&'static str] {
2464 &["/resume"]
2465 }
2466 fn description(&self) -> &'static str {
2467 "List saved sessions"
2468 }
2469 fn execute(&self, ctx: &CommandContext, _args: &str) {
2470 open_session_selector(
2471 &ctx.state,
2472 &ctx.editor_container,
2473 &ctx.editor,
2474 &ctx.tui,
2475 &ctx.cwd,
2476 &ctx.tx,
2477 );
2478 }
2479}
2480
2481struct ThemeCommand;
2482impl SlashCommand for ThemeCommand {
2483 fn name(&self) -> &'static str {
2484 "/theme"
2485 }
2486 fn description(&self) -> &'static str {
2487 "Choose a theme (selector)"
2488 }
2489 fn execute(&self, ctx: &CommandContext, args: &str) {
2490 let name = args.trim().to_ascii_lowercase();
2491 if !name.is_empty() {
2492 let preset = match name.as_str() {
2494 "light" => ThemePreset::Light,
2495 "monochrome" => ThemePreset::Monochrome,
2496 "dark" => ThemePreset::Dark,
2497 _ => {
2498 add_error_message(
2499 &ctx.chat,
2500 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2501 );
2502 ctx.tui.request_render(false);
2503 return;
2504 }
2505 };
2506 apply_theme_preset(preset);
2507 let mut settings = crate::settings::load_settings().unwrap_or_default();
2508 settings.theme = Some(name.clone());
2509 let _ = crate::settings::save_settings(&settings);
2510 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2511 ctx.tui.request_render(false);
2512 ctx.tui.render_now(true);
2513 return;
2514 }
2515 open_theme_selector(
2516 &ctx.state,
2517 &ctx.editor_container,
2518 &ctx.editor,
2519 &ctx.tui,
2520 &ctx.cwd,
2521 &ctx.package_resources,
2522 );
2523 }
2524}
2525
2526struct CompactCommand;
2527impl SlashCommand for CompactCommand {
2528 fn name(&self) -> &'static str {
2529 "/compact"
2530 }
2531 fn description(&self) -> &'static str {
2532 "Compact the conversation"
2533 }
2534 fn execute(&self, ctx: &CommandContext, _args: &str) {
2535 let _ = ctx.tx.send(TuiMessage::Compact);
2536 }
2537}
2538
2539struct CopyCommand;
2540impl SlashCommand for CopyCommand {
2541 fn name(&self) -> &'static str {
2542 "/copy"
2543 }
2544 fn description(&self) -> &'static str {
2545 "Copy last reply to clipboard"
2546 }
2547 fn execute(&self, ctx: &CommandContext, _args: &str) {
2548 let _ = ctx.tx.send(TuiMessage::Copy);
2549 }
2550}
2551
2552struct ExportCommand;
2553impl SlashCommand for ExportCommand {
2554 fn name(&self) -> &'static str {
2555 "/export"
2556 }
2557 fn description(&self) -> &'static str {
2558 "Export session (supports: md, html, jsonl)"
2559 }
2560 fn execute(&self, ctx: &CommandContext, args: &str) {
2561 let format = args.trim().to_lowercase();
2562 if format.is_empty() || format == "md" || format == "markdown" {
2563 let _ = ctx.tx.send(TuiMessage::ExportSession);
2564 } else if format == "html" {
2565 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2566 crate::export::ExportFormat::Html,
2567 ));
2568 } else if format == "jsonl" {
2569 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2570 crate::export::ExportFormat::Jsonl,
2571 ));
2572 } else {
2573 add_error_message(
2575 &ctx.chat,
2576 &format!(
2577 "Unknown export format: {}. Supported: md, html, jsonl",
2578 args.trim()
2579 ),
2580 );
2581 ctx.tui.request_render(false);
2582 }
2583 }
2584}
2585
2586struct ForkCommand;
2587impl SlashCommand for ForkCommand {
2588 fn name(&self) -> &'static str {
2589 "/fork"
2590 }
2591 fn description(&self) -> &'static str {
2592 "Fork the session into a new one"
2593 }
2594 fn execute(&self, ctx: &CommandContext, _args: &str) {
2595 let _ = ctx.tx.send(TuiMessage::ForkSession);
2596 }
2597}
2598
2599struct CloneCommand;
2603impl SlashCommand for CloneCommand {
2604 fn name(&self) -> &'static str {
2605 "/clone"
2606 }
2607 fn description(&self) -> &'static str {
2608 "Duplicate the current session"
2609 }
2610 fn execute(&self, ctx: &CommandContext, _args: &str) {
2611 let _ = ctx.tx.send(TuiMessage::ForkSession);
2612 }
2613}
2614
2615struct TreeCommand;
2616impl SlashCommand for TreeCommand {
2617 fn name(&self) -> &'static str {
2618 "/tree"
2619 }
2620 fn description(&self) -> &'static str {
2621 "Navigate the current session tree"
2622 }
2623 fn execute(&self, ctx: &CommandContext, _args: &str) {
2624 let _ = ctx.tx.send(TuiMessage::OpenTree);
2625 }
2626}
2627
2628struct LoginCommand;
2629impl SlashCommand for LoginCommand {
2630 fn name(&self) -> &'static str {
2631 "/login"
2632 }
2633 fn description(&self) -> &'static str {
2634 "Save an Anthropic API key"
2635 }
2636 fn execute(&self, ctx: &CommandContext, args: &str) {
2637 let key = args.trim();
2638 if key.is_empty() {
2639 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2640 } else {
2641 let result = crate::config::upsert_credential(
2642 "anthropic",
2643 crate::config::Credential::ApiKey {
2644 key: Some(key.to_string()),
2645 env: None,
2646 },
2647 );
2648 match result {
2649 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2650 Err(error) => {
2651 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2652 }
2653 }
2654 }
2655 ctx.tui.request_render(false);
2656 }
2657}
2658
2659struct LogoutCommand;
2660impl SlashCommand for LogoutCommand {
2661 fn name(&self) -> &'static str {
2662 "/logout"
2663 }
2664 fn description(&self) -> &'static str {
2665 "Remove saved Anthropic credentials"
2666 }
2667 fn execute(&self, ctx: &CommandContext, _args: &str) {
2668 match crate::config::delete_credential("anthropic") {
2669 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2670 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2671 Err(error) => {
2672 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2673 }
2674 }
2675 ctx.tui.request_render(false);
2676 }
2677}
2678
2679fn set_project_trust_for_command(
2680 cwd: &std::path::Path,
2681 value: Option<bool>,
2682) -> Result<(), crate::config::ConfigError> {
2683 crate::config::set_project_trust(cwd, value)
2684}
2685
2686struct TrustCommand;
2687impl SlashCommand for TrustCommand {
2688 fn name(&self) -> &'static str {
2689 "/trust"
2690 }
2691 fn description(&self) -> &'static str {
2692 "Trust the current project"
2693 }
2694 fn execute(&self, ctx: &CommandContext, args: &str) {
2695 let value = match args.trim().to_ascii_lowercase().as_str() {
2696 "" | "yes" | "y" | "true" => Some(true),
2697 "no" | "n" | "false" => Some(false),
2698 "clear" | "reset" | "none" => None,
2699 _ => {
2700 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2701 ctx.tui.request_render(false);
2702 return;
2703 }
2704 };
2705 match set_project_trust_for_command(&ctx.cwd, value) {
2706 Ok(()) => {
2707 let label = match value {
2708 Some(true) => "trusted",
2709 Some(false) => "untrusted",
2710 None => "trust decision cleared",
2711 };
2712 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2713 }
2714 Err(error) => add_error_message(
2715 &ctx.chat,
2716 &format!("Could not save trust decision: {error}"),
2717 ),
2718 }
2719 ctx.tui.request_render(false);
2720 }
2721}
2722
2723struct NameCommand;
2724impl SlashCommand for NameCommand {
2725 fn name(&self) -> &'static str {
2726 "/name"
2727 }
2728 fn description(&self) -> &'static str {
2729 "Set session display name"
2730 }
2731 fn execute(&self, ctx: &CommandContext, args: &str) {
2732 let name = args.trim();
2733 if name.is_empty() {
2734 add_note_message(
2735 &ctx.chat,
2736 "Usage: /name <display name> — sets the current session's name.",
2737 );
2738 ctx.tui.request_render(false);
2739 return;
2740 }
2741 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2742 }
2743}
2744
2745struct ImportCommand;
2746impl SlashCommand for ImportCommand {
2747 fn name(&self) -> &'static str {
2748 "/import"
2749 }
2750 fn description(&self) -> &'static str {
2751 "Import a session file (path)"
2752 }
2753 fn execute(&self, ctx: &CommandContext, args: &str) {
2754 let path = args.trim();
2755 if path.is_empty() {
2756 add_note_message(
2757 &ctx.chat,
2758 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2759 );
2760 ctx.tui.request_render(false);
2761 return;
2762 }
2763 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2764 }
2765}
2766
2767struct SettingsCommand;
2768impl SlashCommand for SettingsCommand {
2769 fn name(&self) -> &'static str {
2770 "/settings"
2771 }
2772 fn description(&self) -> &'static str {
2773 "Open settings menu"
2774 }
2775 fn execute(&self, ctx: &CommandContext, _args: &str) {
2776 open_settings_selector(
2777 &ctx.state,
2778 &ctx.editor_container,
2779 &ctx.editor,
2780 &ctx.tui,
2781 &ctx.lane,
2782 &ctx.model_catalog,
2783 &ctx.lane_model_id,
2784 &ctx.chat,
2785 &ctx.cwd,
2786 &ctx.package_resources,
2787 );
2788 }
2789}
2790
2791struct ScopedModelsCommand;
2792impl SlashCommand for ScopedModelsCommand {
2793 fn name(&self) -> &'static str {
2794 "/scoped-models"
2795 }
2796 fn description(&self) -> &'static str {
2797 "Choose models for Ctrl+M cycling"
2798 }
2799 fn execute(&self, ctx: &CommandContext, _args: &str) {
2800 open_scoped_models_selector(
2801 &ctx.state,
2802 &ctx.editor_container,
2803 &ctx.editor,
2804 &ctx.tui,
2805 &ctx.model_catalog,
2806 &ctx.chat,
2807 );
2808 }
2809}
2810
2811struct ShareCommand;
2812impl SlashCommand for ShareCommand {
2813 fn name(&self) -> &'static str {
2814 "/share"
2815 }
2816 fn description(&self) -> &'static str {
2817 "Share session (gist via gh, or clipboard)"
2818 }
2819 fn execute(&self, ctx: &CommandContext, _args: &str) {
2820 let _ = ctx.tx.send(TuiMessage::ShareSession);
2821 }
2822}
2823
2824struct ArminCommand;
2825impl SlashCommand for ArminCommand {
2826 fn name(&self) -> &'static str {
2827 "/armin"
2828 }
2829 fn description(&self) -> &'static str {
2830 "??? (easter egg)"
2831 }
2832 fn execute(&self, ctx: &CommandContext, _args: &str) {
2833 crate::extras::add_armin(&ctx.chat);
2834 ctx.tui.request_render(false);
2835 }
2836}
2837
2838struct EarendilCommand;
2839impl SlashCommand for EarendilCommand {
2840 fn name(&self) -> &'static str {
2841 "/earendil"
2842 }
2843 fn description(&self) -> &'static str {
2844 "Announcement"
2845 }
2846 fn execute(&self, ctx: &CommandContext, _args: &str) {
2847 crate::extras::add_earendil(&ctx.chat);
2848 ctx.tui.request_render(false);
2849 }
2850}
2851
2852struct ContextCommand;
2856impl SlashCommand for ContextCommand {
2857 fn name(&self) -> &'static str {
2858 "/context"
2859 }
2860 fn visible(&self) -> bool {
2861 false
2862 }
2863 fn execute(&self, ctx: &CommandContext, _args: &str) {
2864 show_context_panel(&ctx.chat, &ctx.resources);
2865 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2867 ctx.tui.request_render(false);
2868 }
2869}
2870
2871struct UsageCommand;
2875impl SlashCommand for UsageCommand {
2876 fn name(&self) -> &'static str {
2877 "/usage"
2878 }
2879 fn description(&self) -> &'static str {
2880 "Show token, cost, and cache totals for this session"
2881 }
2882 fn execute(&self, ctx: &CommandContext, _args: &str) {
2883 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2884 ctx.tui.request_render(false);
2885 }
2886}
2887
2888struct ReloadCommand;
2899impl SlashCommand for ReloadCommand {
2900 fn name(&self) -> &'static str {
2901 "/reload"
2902 }
2903 fn description(&self) -> &'static str {
2904 "Reload extensions, skills, prompts"
2905 }
2906 fn execute(&self, ctx: &CommandContext, _args: &str) {
2907 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2911 ctx.tui.request_render(false);
2912 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2913 }
2914}
2915
2916fn build_builtin_registry() -> CommandRegistry {
2921 let mut r = CommandRegistry::new();
2922 r.register(Arc::new(HelpCommand));
2923 r.register(Arc::new(ClearChatCommand));
2924 r.register(Arc::new(ExitCommand));
2925 r.register(Arc::new(VersionCommand));
2926 r.register(Arc::new(ChangelogCommand));
2927 r.register(Arc::new(ModelCommand));
2928 r.register(Arc::new(ThinkingCommand));
2929 r.register(Arc::new(ToolsCommand));
2930 r.register(Arc::new(ImagesCommand));
2931 r.register(Arc::new(SessionCommand));
2932 r.register(Arc::new(ThemeCommand));
2933 r.register(Arc::new(CompactCommand));
2934 r.register(Arc::new(CopyCommand));
2935 r.register(Arc::new(HotkeysCommand));
2936 r.register(Arc::new(ArminCommand));
2937 r.register(Arc::new(EarendilCommand));
2938 r.register(Arc::new(ContextCommand));
2939 r.register(Arc::new(UsageCommand));
2940 r.register(Arc::new(NameCommand));
2944 r.register(Arc::new(SettingsCommand));
2945 r.register(Arc::new(ScopedModelsCommand));
2946 r.register(Arc::new(ExportCommand));
2947 r.register(Arc::new(ImportCommand));
2948 r.register(Arc::new(ShareCommand));
2949 r.register(Arc::new(ForkCommand));
2950 r.register(Arc::new(CloneCommand));
2951 r.register(Arc::new(TreeCommand));
2952 r.register(Arc::new(TrustCommand));
2953 r.register(Arc::new(LoginCommand));
2954 r.register(Arc::new(LogoutCommand));
2955 r.register(Arc::new(ReloadCommand));
2956 r
2957}
2958
2959fn register_extension_commands(
2960 registry: &mut CommandRegistry,
2961 session: crate::session::ExtensionSessionCell,
2962) {
2963 let commands = session
2964 .lock()
2965 .ok()
2966 .and_then(|s| s.snapshot_arc())
2967 .map(|snap| snap.commands().to_vec())
2968 .unwrap_or_default();
2969 for command in commands {
2970 let name = if command.name.starts_with('/') {
2971 command.name.clone()
2972 } else {
2973 format!("/{}", command.name)
2974 };
2975 if registry.find(&name).is_some() {
2976 continue;
2977 }
2978 registry.register(Arc::new(ExtensionCommand {
2979 name,
2980 description: command.description,
2981 session: session.clone(),
2982 }));
2983 }
2984}
2985
2986fn register_js_extension_commands(
2987 registry: &mut CommandRegistry,
2988 session: Option<crate::js_extensions::JsExtensionSession>,
2989) {
2990 let Some(session) = session else {
2991 return;
2992 };
2993 for command in &session.commands {
2994 let name = if command.starts_with('/') {
2995 command.clone()
2996 } else {
2997 format!("/{command}")
2998 };
2999 if registry.find(&name).is_none() {
3000 registry.register(Arc::new(JsExtensionCommand {
3001 name,
3002 session: session.clone(),
3003 }));
3004 }
3005 }
3006}
3007
3008enum TuiMessage {
3015 UserInput(String),
3016 OpenTree,
3017 NavigateTree(String),
3018 Exit,
3019 ClearChat,
3021 Compact,
3023 Copy,
3025 SwitchSession(String),
3028 ExportSession,
3030 ExportSessionWithFormat(crate::export::ExportFormat),
3032 ShowUsage,
3034 ForkSession,
3036 SetSessionName(String),
3038 ImportSession(String),
3041 ShareSession,
3044 ReloadExtensions,
3049 ExternalEditorResult(Result<String, String>),
3053 Dequeue,
3056 UserBashFinished(UserBashReport),
3061}
3062
3063struct UserBashReport {
3068 command: String,
3069 output: String,
3070 exit_code: Option<i32>,
3071 cancelled: bool,
3072 truncated: bool,
3073 full_output_path: Option<String>,
3074 exclude_from_context: bool,
3075}
3076
3077fn assistant_text(msg: &AssistantMessage) -> String {
3080 msg.content
3081 .iter()
3082 .filter_map(|c| match c {
3083 Content::Text(t) => Some(t.text.clone()),
3084 _ => None,
3085 })
3086 .collect()
3087}
3088
3089fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
3092 msg.content
3093 .iter()
3094 .filter_map(|c| match c {
3095 Content::Text(t) => Some(t.text.clone()),
3096 _ => None,
3097 })
3098 .collect::<Vec<_>>()
3099 .join("\n")
3100}
3101
3102fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
3103 match &msg.content {
3104 rpi_ai::types::UserContent::Text(s) => s.clone(),
3105 rpi_ai::types::UserContent::Blocks(blocks) => blocks
3106 .iter()
3107 .filter_map(|c| match c {
3108 Content::Text(t) => Some(t.text.clone()),
3109 _ => None,
3110 })
3111 .collect(),
3112 }
3113}
3114
3115fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3119 let scoped = crate::settings::load_settings()
3120 .ok()
3121 .and_then(|s| s.scoped_models)
3122 .unwrap_or_default();
3123 if scoped.is_empty() {
3124 return catalog.to_vec();
3125 }
3126 let mut out: Vec<rpi_ai::Model> = catalog
3127 .iter()
3128 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3129 .cloned()
3130 .collect();
3131 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3133 if let Some(cur) = catalog
3134 .iter()
3135 .find(|m| m.id.eq_ignore_ascii_case(current_id))
3136 {
3137 out.push(cur.clone());
3138 }
3139 }
3140 out
3141}
3142
3143fn open_settings_selector(
3152 state: &Arc<TuiState>,
3153 editor_container: &Arc<Container>,
3154 editor: &Arc<Editor>,
3155 tui: &Arc<TuiAltScreen>,
3156 lane: &Arc<dyn AgentLane>,
3157 catalog: &[rpi_ai::Model],
3158 lane_model_id: &str,
3159 chat: &Arc<Container>,
3160 cwd: &std::path::Path,
3161 package_resources: &Arc<crate::packages::PackageResources>,
3162) {
3163 let settings = crate::settings::load_settings().unwrap_or_default();
3164 let items = settings_menu_items(&settings, state, lane_model_id);
3165 let list = Arc::new(SettingsList::new(items));
3166
3167 let state_sel = state.clone();
3172 let ec_sel = editor_container.clone();
3173 let editor_sel = editor.clone();
3174 let tui_sel = tui.clone();
3175 let lane_sel = lane.clone();
3176 let catalog_sel = catalog.to_vec();
3177 let lane_model_sel = lane_model_id.to_string();
3178 let chat_sel = chat.clone();
3179 let cwd_sel = cwd.to_path_buf();
3180 let package_resources_sel = package_resources.clone();
3181 list.on_select(Arc::new(move |item| match item.key.as_str() {
3182 "theme" => open_settings_theme_selector(
3183 &state_sel,
3184 &ec_sel,
3185 &editor_sel,
3186 &tui_sel,
3187 &chat_sel,
3188 &cwd_sel,
3189 &package_resources_sel,
3190 ),
3191 "model" => open_settings_model_selector(
3192 &state_sel,
3193 &ec_sel,
3194 &editor_sel,
3195 &tui_sel,
3196 &lane_sel,
3197 &catalog_sel,
3198 &lane_model_sel,
3199 &chat_sel,
3200 ),
3201 "thinking" => open_settings_thinking_selector(
3202 &state_sel,
3203 &ec_sel,
3204 &editor_sel,
3205 &tui_sel,
3206 &lane_sel,
3207 &catalog_sel,
3208 &lane_model_sel,
3209 &chat_sel,
3210 ),
3211 "scoped-models" => open_scoped_models_selector(
3212 &state_sel,
3213 &ec_sel,
3214 &editor_sel,
3215 &tui_sel,
3216 &catalog_sel,
3217 &chat_sel,
3218 ),
3219 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3220 }));
3221
3222 let state_change = state.clone();
3225 let chat_change = chat.clone();
3226 let tui_change = tui.clone();
3227 list.on_change(Arc::new(move |key, value| {
3228 let mut settings = crate::settings::load_settings().unwrap_or_default();
3229 let applied = apply_setting_change(&state_change, key, value, &mut settings);
3230 let saved = crate::settings::save_settings(&settings);
3231 if let Some(note) = applied {
3232 let suffix = if saved.is_ok() { "" } else { " (not saved)" };
3233 add_note_message(&chat_change, &format!("{note}{suffix}"));
3234 }
3235 tui_change.request_render(false);
3236 }));
3237
3238 let state_cancel = state.clone();
3239 let ec_cancel = editor_container.clone();
3240 let editor_cancel = editor.clone();
3241 let tui_cancel = tui.clone();
3242 list.on_cancel(Arc::new(move || {
3243 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3244 }));
3245
3246 open_selector_with_view(
3247 state,
3248 editor_container,
3249 editor,
3250 tui,
3251 SelectorView::Settings(list.clone()),
3252 list,
3253 SelectorKind::Settings,
3254 );
3255}
3256
3257fn settings_menu_items(
3263 settings: &crate::settings::Settings,
3264 state: &Arc<TuiState>,
3265 lane_model_id: &str,
3266) -> Vec<SettingItem> {
3267 let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
3268 let current_model = settings
3269 .default_model
3270 .clone()
3271 .unwrap_or_else(|| lane_model_id.to_string());
3272 let current_thinking = settings
3273 .default_thinking_level
3274 .clone()
3275 .unwrap_or_else(|| "(default)".to_string());
3276 let scoped_desc = match &settings.scoped_models {
3277 Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
3278 _ => "all models".to_string(),
3279 };
3280 let hide_thinking = state.hide_thinking();
3281 let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
3282 let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
3283 let http_idle = settings
3284 .http_idle_timeout_ms()
3285 .map(|ms| {
3286 if ms == 0 {
3287 "disabled".to_string()
3288 } else {
3289 format!("{}s", ms / 1000)
3290 }
3291 })
3292 .unwrap_or_else(|| "5m".to_string());
3293
3294 vec![
3295 SettingItem::new("theme", "Theme", ¤t_theme)
3296 .with_description("Color theme for the interface")
3297 .with_submenu(),
3298 SettingItem::new("model", "Default model", ¤t_model)
3299 .with_description("Saved default model for new sessions")
3300 .with_submenu(),
3301 SettingItem::new("thinking", "Default thinking", ¤t_thinking)
3302 .with_description("Saved default thinking level")
3303 .with_submenu(),
3304 SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
3305 .with_description("Models enabled for Ctrl+M cycling")
3306 .with_submenu(),
3307 SettingItem::new(
3308 "hide-thinking",
3309 "Hide thinking",
3310 bool_setting_value(hide_thinking),
3311 )
3312 .with_description("Hide thinking blocks in assistant responses")
3313 .with_values(&["true", "false"]),
3314 SettingItem::new(
3315 "show-images",
3316 "Show images",
3317 bool_setting_value(show_images),
3318 )
3319 .with_description("Render images inline in the terminal")
3320 .with_values(&["true", "false"]),
3321 SettingItem::new(
3322 "cache-miss-notices",
3323 "Cache miss notices",
3324 bool_setting_value(show_cache_miss),
3325 )
3326 .with_description("Transcript notices for prompt-cache costs")
3327 .with_values(&["true", "false"]),
3328 SettingItem::new(
3329 "quiet-startup",
3330 "Quiet startup",
3331 bool_setting_value(settings.quiet_startup.unwrap_or(false)),
3332 )
3333 .with_description("Disable the verbose startup listing (applies on restart)")
3334 .with_values(&["true", "false"]),
3335 SettingItem::new(
3336 "terminal-progress",
3337 "Terminal progress",
3338 bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
3339 )
3340 .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
3341 .with_values(&["true", "false"]),
3342 SettingItem::new(
3343 "fullscreen-copy-on-select",
3344 "Fullscreen copy on select",
3345 bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
3346 )
3347 .with_description("Copy selected text automatically in fullscreen mode")
3348 .with_values(&["true", "false"]),
3349 SettingItem::new(
3350 "double-escape-action",
3351 "Double-escape action",
3352 settings
3353 .double_escape_action
3354 .clone()
3355 .unwrap_or_else(|| "tree".to_string())
3356 .as_str(),
3357 )
3358 .with_description("Esc Esc with an empty editor (applies on restart)")
3359 .with_values(&["tree", "fork", "none"]),
3360 SettingItem::new(
3361 "editor-padding",
3362 "Editor padding",
3363 settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
3364 )
3365 .with_description("Horizontal editor padding, 0-3 (applies on restart)")
3366 .with_values(&["0", "1", "2", "3"]),
3367 SettingItem::new(
3368 "autocomplete-max-items",
3369 "Autocomplete max items",
3370 settings
3371 .autocomplete_max_visible
3372 .unwrap_or(5)
3373 .to_string()
3374 .as_str(),
3375 )
3376 .with_description("Max autocomplete rows, 3-20 (applies on restart)")
3377 .with_values(&["3", "5", "7", "10", "15", "20"]),
3378 SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
3379 .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
3380 .with_values(&["30s", "1m", "5m", "disabled"]),
3381 ]
3382}
3383
3384fn bool_setting_value(value: bool) -> &'static str {
3386 if value {
3387 "true"
3388 } else {
3389 "false"
3390 }
3391}
3392
3393fn apply_setting_change(
3398 state: &Arc<TuiState>,
3399 key: &str,
3400 value: &str,
3401 settings: &mut crate::settings::Settings,
3402) -> Option<String> {
3403 let as_bool = || value == "true";
3404 match key {
3405 "hide-thinking" => {
3407 state.set_hide_thinking(as_bool());
3408 settings.hide_thinking_block = Some(as_bool());
3409 Some(format!(
3410 "Thinking blocks {}.",
3411 if as_bool() { "hidden" } else { "shown" }
3412 ))
3413 }
3414 "show-images" => {
3416 if let Ok(mut guard) = state.show_images.lock() {
3417 *guard = as_bool();
3418 }
3419 settings.show_images = Some(as_bool());
3420 Some(format!(
3421 "Inline images {}.",
3422 if as_bool() { "enabled" } else { "disabled" }
3423 ))
3424 }
3425 "cache-miss-notices" => {
3427 if let Ok(mut guard) = state.cache_miss_notices.lock() {
3428 *guard = as_bool();
3429 }
3430 settings.show_cache_miss_notices = Some(as_bool());
3431 Some(format!(
3432 "Cache miss notices {}.",
3433 if as_bool() { "enabled" } else { "disabled" }
3434 ))
3435 }
3436 "fullscreen-copy-on-select" => {
3438 settings.fullscreen_copy_on_select = Some(as_bool());
3439 Some(format!(
3440 "Copy on select {}.",
3441 if as_bool() { "enabled" } else { "disabled" }
3442 ))
3443 }
3444 "quiet-startup" => {
3446 settings.quiet_startup = Some(as_bool());
3447 None
3448 }
3449 "terminal-progress" => {
3450 settings.show_terminal_progress = Some(as_bool());
3451 None
3452 }
3453 "double-escape-action" => {
3454 settings.double_escape_action = Some(value.to_string());
3455 None
3456 }
3457 "editor-padding" => {
3458 settings.editor_padding_x = value.parse().ok();
3459 None
3460 }
3461 "autocomplete-max-items" => {
3462 settings.autocomplete_max_visible = value.parse().ok();
3463 None
3464 }
3465 "http-idle-timeout" => {
3466 settings.http_idle_timeout = Some(match value {
3467 "disabled" => serde_json::Value::String("disabled".to_string()),
3468 "30s" => serde_json::json!(30_000),
3469 "1m" => serde_json::json!(60_000),
3470 _ => serde_json::json!(300_000),
3471 });
3472 None
3473 }
3474 _ => None,
3475 }
3476}
3477
3478fn open_settings_theme_selector(
3480 state: &Arc<TuiState>,
3481 editor_container: &Arc<Container>,
3482 editor: &Arc<Editor>,
3483 tui: &Arc<TuiAltScreen>,
3484 chat: &Arc<Container>,
3485 cwd: &std::path::Path,
3486 package_resources: &Arc<crate::packages::PackageResources>,
3487) {
3488 let mut items = vec![
3489 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3490 SelectItem::new("light", "Light").with_description("Light background"),
3491 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3492 ];
3493 if state.themes_enabled {
3494 for path in package_resources.theme_files() {
3495 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3496 items.push(SelectItem::new(name, name).with_description("Package theme"));
3497 }
3498 }
3499 }
3500 let list = Arc::new(SelectList::new(items, 10));
3501
3502 let state_sel = state.clone();
3503 let ec_sel = editor_container.clone();
3504 let editor_sel = editor.clone();
3505 let tui_sel = tui.clone();
3506 let chat_sel = chat.clone();
3507 let cwd_sel = cwd.to_path_buf();
3508 let package_resources_sel = package_resources.clone();
3509 list.on_select(Arc::new(move |item| {
3510 let preset = match item.value.as_str() {
3511 "light" => Some(ThemePreset::Light),
3512 "monochrome" => Some(ThemePreset::Monochrome),
3513 "dark" => Some(ThemePreset::Dark),
3514 name => {
3515 if state_sel.themes_enabled {
3516 if let Ok(custom) = crate::packages::load_theme_with_resources(
3517 &cwd_sel,
3518 name,
3519 &package_resources_sel,
3520 ) {
3521 rpi_tui::global_theme_manager().set(custom.clone());
3522 state_sel.theme_manager.set(custom);
3523 }
3524 }
3525 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3526 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3527 tui_sel.render_now(true);
3528 return;
3529 }
3530 };
3531 let Some(preset) = preset else { return };
3532 apply_theme_preset(preset);
3533 state_sel.theme_manager.apply_preset(preset);
3534 let mut settings = crate::settings::load_settings().unwrap_or_default();
3535 settings.theme = Some(item.value.clone());
3536 let saved = crate::settings::save_settings(&settings);
3537 add_note_message(
3538 &chat_sel,
3539 &format!(
3540 "Theme set to {} (saved{})",
3541 item.label,
3542 if saved.is_ok() { "" } else { ", not saved" },
3543 ),
3544 );
3545 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3546 tui_sel.render_now(true);
3547 }));
3548 let state_cancel = state.clone();
3549 let ec_cancel = editor_container.clone();
3550 let editor_cancel = editor.clone();
3551 let tui_cancel = tui.clone();
3552 list.on_cancel(Arc::new(move || {
3553 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3554 }));
3555
3556 open_selector(
3557 state,
3558 editor_container,
3559 editor,
3560 tui,
3561 list,
3562 SelectorKind::Settings,
3563 );
3564}
3565
3566fn open_settings_model_selector(
3570 state: &Arc<TuiState>,
3571 editor_container: &Arc<Container>,
3572 editor: &Arc<Editor>,
3573 tui: &Arc<TuiAltScreen>,
3574 lane: &Arc<dyn AgentLane>,
3575 catalog: &[rpi_ai::Model],
3576 lane_model_id: &str,
3577 chat: &Arc<Container>,
3578) {
3579 let items = model_selector_items(catalog, lane_model_id);
3580 if items.is_empty() {
3581 add_note_message(chat, "No models in the catalog.");
3582 tui.request_render(false);
3583 return;
3584 }
3585 let list = Arc::new(SelectList::new(items, 10));
3586
3587 let catalog_arc = catalog.to_vec();
3588 let state_sel = state.clone();
3589 let ec_sel = editor_container.clone();
3590 let editor_sel = editor.clone();
3591 let tui_sel = tui.clone();
3592 let chat_sel = chat.clone();
3593 let lane_sel = lane.clone();
3594 list.on_select(Arc::new(move |item| {
3595 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3596 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3597 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3598 return;
3599 };
3600 state_sel.set_current_model(&model);
3601 let lane = lane_sel.clone();
3602 tokio::spawn(async move {
3603 let _ = lane.set_model(model).await;
3604 });
3605 let mut settings = crate::settings::load_settings().unwrap_or_default();
3606 settings.default_model = Some(item.value.clone());
3607 let saved = crate::settings::save_settings(&settings);
3608 add_note_message(
3609 &chat_sel,
3610 &format!(
3611 "Default model set to {} (saved{}",
3612 short_model_name(&item.value),
3613 if saved.is_ok() { ")" } else { ", not saved)" },
3614 ),
3615 );
3616 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3617 }));
3618 let state_cancel = state.clone();
3619 let ec_cancel = editor_container.clone();
3620 let editor_cancel = editor.clone();
3621 let tui_cancel = tui.clone();
3622 list.on_cancel(Arc::new(move || {
3623 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3624 }));
3625
3626 open_searchable_selector(
3627 state,
3628 editor_container,
3629 editor,
3630 tui,
3631 Some("Default model"),
3632 Some("Type to filter by name, provider, or id"),
3633 list,
3634 SelectorKind::Settings,
3635 );
3636}
3637
3638fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3643 let mut seen = std::collections::HashSet::new();
3644 catalog
3645 .iter()
3646 .filter(|m| {
3647 seen.insert((
3648 m.api.clone(),
3649 m.provider.to_ascii_lowercase(),
3650 m.id.to_ascii_lowercase(),
3651 ))
3652 })
3653 .map(|m| {
3654 let label = if m.name.is_empty() {
3655 short_model_name(&m.id)
3656 } else {
3657 m.name.clone()
3658 };
3659 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3660 && m.provider.eq_ignore_ascii_case("anthropic")
3661 {
3662 m.id.clone()
3663 } else {
3664 format!("{}/{}", m.provider, m.id)
3665 };
3666 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3667 " (current)"
3668 } else {
3669 ""
3670 };
3671 let name = if m.name.is_empty() {
3674 String::new()
3675 } else {
3676 format!(" {}", m.name)
3677 };
3678 let search_text = format!(
3679 "{} {}/{} {} {}{}",
3680 m.provider, m.provider, m.id, m.provider, m.id, name
3681 );
3682 SelectItem::new(&m.id, &label)
3683 .with_description(&format!("{identity}{marker}"))
3684 .with_search_text(&search_text)
3685 })
3686 .collect()
3687}
3688
3689fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3693 let (provider, id) = input
3694 .split_once('/')
3695 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3696 .map_or((None, input), |(provider, id)| (Some(provider), id));
3697 catalog
3698 .iter()
3699 .find(|model| {
3700 model.id.eq_ignore_ascii_case(id)
3701 && provider.map_or(true, |provider| {
3702 model.provider.eq_ignore_ascii_case(provider)
3703 || (provider.eq_ignore_ascii_case("anthropic")
3704 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3705 })
3706 })
3707 .cloned()
3708}
3709
3710fn open_settings_thinking_selector(
3714 state: &Arc<TuiState>,
3715 editor_container: &Arc<Container>,
3716 editor: &Arc<Editor>,
3717 tui: &Arc<TuiAltScreen>,
3718 lane: &Arc<dyn AgentLane>,
3719 catalog: &[rpi_ai::Model],
3720 lane_model_id: &str,
3721 chat: &Arc<Container>,
3722) {
3723 let model = catalog
3724 .iter()
3725 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3726 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3727 .map(|m| m.supported_thinking_levels())
3728 .unwrap_or_else(|| {
3729 use rpi_ai::types::ThinkingLevel::*;
3730 vec![Off, Minimal, Low, Medium, High]
3731 });
3732 let mut items: Vec<SelectItem> = Vec::new();
3733 for lvl in &levels {
3734 let name = thinking_level_name(*lvl);
3735 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3736 }
3737 if items.is_empty() {
3738 add_note_message(chat, "This model has no supported thinking levels.");
3739 tui.request_render(false);
3740 return;
3741 }
3742 let list = Arc::new(SelectList::new(items, 10));
3743
3744 let state_sel = state.clone();
3745 let ec_sel = editor_container.clone();
3746 let editor_sel = editor.clone();
3747 let tui_sel = tui.clone();
3748 let chat_sel = chat.clone();
3749 let lane_sel = lane.clone();
3750 list.on_select(Arc::new(move |item| {
3751 let Some(level) = thinking_level_from_name(&item.value) else {
3752 add_note_message(
3753 &chat_sel,
3754 &format!("Unknown thinking level: {}.", item.label),
3755 );
3756 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3757 return;
3758 };
3759 let lane = lane_sel.clone();
3760 let footer_sel = state_sel.footer.clone();
3761 tokio::spawn(async move {
3762 let _ = lane.set_thinking_level(level).await;
3763 });
3764 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3765 let mut settings = crate::settings::load_settings().unwrap_or_default();
3766 settings.default_thinking_level = Some(item.value.clone());
3767 let saved = crate::settings::save_settings(&settings);
3768 add_note_message(
3769 &chat_sel,
3770 &format!(
3771 "Default thinking set to {} (saved{}",
3772 item.label,
3773 if saved.is_ok() { ")" } else { ", not saved)" },
3774 ),
3775 );
3776 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3777 }));
3778 let state_cancel = state.clone();
3779 let ec_cancel = editor_container.clone();
3780 let editor_cancel = editor.clone();
3781 let tui_cancel = tui.clone();
3782 list.on_cancel(Arc::new(move || {
3783 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3784 }));
3785
3786 open_selector(
3787 state,
3788 editor_container,
3789 editor,
3790 tui,
3791 list,
3792 SelectorKind::Settings,
3793 );
3794}
3795
3796fn open_scoped_models_selector(
3801 state: &Arc<TuiState>,
3802 editor_container: &Arc<Container>,
3803 editor: &Arc<Editor>,
3804 tui: &Arc<TuiAltScreen>,
3805 catalog: &[rpi_ai::Model],
3806 chat: &Arc<Container>,
3807) {
3808 if catalog.is_empty() {
3809 add_note_message(chat, "No models in the catalog.");
3810 tui.request_render(false);
3811 return;
3812 }
3813 let seed: Vec<String> = crate::settings::load_settings()
3815 .ok()
3816 .and_then(|s| s.scoped_models)
3817 .unwrap_or_default();
3818 *state.scoped_edit.lock().unwrap() = Some(seed);
3819
3820 let mut items: Vec<SelectItem> = Vec::new();
3821 for m in catalog {
3822 items.push(SelectItem::new(&m.id, &m.id));
3823 }
3824 let list = Arc::new(SelectList::new(items, 10));
3825
3826 let state_sel = state.clone();
3827 let chat_sel = chat.clone();
3828 let tui_sel = tui.clone();
3829 list.on_select(Arc::new(move |item| {
3830 let mut set = state_sel.scoped_edit.lock().unwrap();
3832 let set = set.get_or_insert_with(Vec::new);
3833 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3834 set.remove(pos);
3835 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3836 } else {
3837 set.push(item.value.clone());
3838 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3839 }
3840 tui_sel.request_render(false);
3841 }));
3842 let state_cancel = state.clone();
3843 let ec_cancel = editor_container.clone();
3844 let editor_cancel = editor.clone();
3845 let tui_cancel = tui.clone();
3846 let chat_cancel = chat.clone();
3847 list.on_cancel(Arc::new(move || {
3848 let set = state_cancel
3850 .scoped_edit
3851 .lock()
3852 .unwrap()
3853 .take()
3854 .unwrap_or_default();
3855 let mut settings = crate::settings::load_settings().unwrap_or_default();
3856 settings.scoped_models = if set.is_empty() {
3857 None
3858 } else {
3859 Some(set.clone())
3860 };
3861 match crate::settings::save_settings(&settings) {
3862 Ok(()) => {
3863 if set.is_empty() {
3864 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3865 } else {
3866 add_note_message(
3867 &chat_cancel,
3868 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3869 );
3870 }
3871 }
3872 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3873 }
3874 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3875 }));
3876
3877 open_selector(
3878 state,
3879 editor_container,
3880 editor,
3881 tui,
3882 list,
3883 SelectorKind::ScopedModels,
3884 );
3885}
3886
3887async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3891 use std::process::Stdio;
3892
3893 let tree = harness.session().view("main");
3895 let entries = match tree
3896 .find_entries(&EntryQuery {
3897 entry_type: None,
3898 custom_type: None,
3899 order: Some(EntryOrder::OldestFirst),
3902 limit: None,
3903 cursor: None,
3904 })
3905 .await
3906 {
3907 Ok(e) => e,
3908 Err(e) => {
3909 add_error_message(chat, &format!("Could not read session: {e}"));
3910 return;
3911 }
3912 };
3913 let mut md = String::from("# Session\n\n");
3914 for e in entries {
3915 let Entry::Message(me) = e else { continue };
3916 match &me.message {
3917 AgentMessage::User(u) => {
3918 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3919 }
3920 AgentMessage::Assistant(a) => {
3921 let text = assistant_text(a);
3922 if !text.is_empty() {
3923 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3924 }
3925 }
3926 _ => {}
3927 }
3928 }
3929
3930 let gh = std::process::Command::new("gh")
3932 .arg("gist")
3933 .arg("create")
3934 .arg("--filename")
3935 .arg("session.md")
3936 .arg("-")
3937 .stdin(Stdio::piped())
3938 .stdout(Stdio::piped())
3939 .stderr(Stdio::null())
3940 .spawn();
3941 if let Ok(mut child) = gh {
3942 use std::io::Write;
3943 if let Some(mut stdin) = child.stdin.take() {
3944 let _ = stdin.write_all(md.as_bytes());
3945 let _ = stdin.flush();
3946 }
3947 let out = child.wait_with_output().ok();
3948 if let Some(out) = out {
3949 if out.status.success() {
3950 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3951 add_note_message(chat, &format!("Shared session: {url}"));
3952 return;
3953 }
3954 }
3955 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3956 } else {
3957 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3958 }
3959 if copy_to_clipboard(&md) {
3961 add_note_message(chat, "Session transcript copied to the clipboard.");
3962 } else {
3963 add_note_message(
3964 chat,
3965 "Clipboard unavailable — use /export to write the transcript to a file.",
3966 );
3967 }
3968}
3969
3970async fn switch_to_session(
3985 harness: &AgentHarness,
3986 lane: &Arc<dyn AgentLane>,
3987 id: &str,
3988 cwd: &std::path::Path,
3989 chat: &Arc<Container>,
3990 state: &Arc<TuiState>,
3991) -> bool {
3992 if *state.status.lock().unwrap() == RunStatus::Working {
3993 state.set_status(RunStatus::Aborting);
3994 let _ = lane.abort().await;
3995 }
3996 let cwd_str = cwd.to_string_lossy().to_string();
3997 match crate::session::open_session_by_id(id, &cwd_str).await {
3998 Ok(new_session) => {
3999 let _ = harness.set_session(new_session).await;
4000 chat.clear();
4001 add_welcome_message(chat);
4002 render_session_history(
4003 harness,
4004 chat,
4005 state.markdown_transformer(),
4006 Some(state.extension_session.clone()),
4007 )
4008 .await;
4009 state.set_status(RunStatus::Idle);
4010 add_note_message(chat, &format!("Switched to session {id}."));
4011 true
4012 }
4013 Err(e) => {
4014 state.set_status(RunStatus::Idle);
4015 add_error_message(chat, &format!("Could not open session {id}: {e}"));
4016 false
4017 }
4018 }
4019}
4020
4021async fn import_session(
4025 harness: &AgentHarness,
4026 lane: &Arc<dyn AgentLane>,
4027 path: &str,
4028 cwd: &std::path::Path,
4029 chat: &Arc<Container>,
4030 state: &Arc<TuiState>,
4031) {
4032 use std::path::Path as FsPath;
4033
4034 let src = FsPath::new(path);
4035 if !src.is_file() {
4036 add_error_message(chat, &format!("Import source not found: {path}"));
4037 return;
4038 }
4039 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
4040 add_error_message(chat, "Import source has no file name.");
4041 return;
4042 };
4043 if !fname.ends_with(".jsonl") {
4044 add_error_message(chat, "Import source must be a .jsonl session file.");
4045 return;
4046 }
4047 let dir = crate::session::default_session_dir(cwd);
4048 if let Err(e) = std::fs::create_dir_all(&dir) {
4049 add_error_message(chat, &format!("Could not create session dir: {e}"));
4050 return;
4051 }
4052 let dest = dir.join(fname);
4053 match std::fs::copy(src, &dest) {
4054 Ok(_) => {
4055 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
4056 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
4057 add_note_message(chat, &format!("Imported session from {path}"));
4058 }
4059 }
4060 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
4061 }
4062}
4063
4064async fn fork_session(
4065 harness: &AgentHarness,
4066 cwd: &std::path::Path,
4067 chat: &Arc<Container>,
4068 state: &Arc<TuiState>,
4069) {
4070 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
4071 use rpi_tools::FileSystem;
4072
4073 let cwd_str = cwd.to_string_lossy().to_string();
4074 let dir = crate::session::default_session_dir(cwd);
4075 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
4076 let fs: Arc<dyn FileSystem> = env.clone();
4077 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4078 fs,
4079 sessions_root: dir.to_string_lossy().into_owned(),
4080 clock: Arc::new(rpi_harness::session::memory::SystemClock),
4081 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
4082 });
4083 let id = harness.session().storage().metadata().id.clone();
4086 let metas = match crate::session::list_session_metadata(&cwd_str).await {
4087 Ok(m) => m,
4088 Err(e) => {
4089 add_error_message(chat, &format!("Could not list sessions: {e}"));
4090 return;
4091 }
4092 };
4093 let Some(source) = metas.iter().find(|m| m.id == id) else {
4094 add_error_message(chat, &format!("Current session {id} not found on disk."));
4095 return;
4096 };
4097 let fork_storage = match repo
4098 .fork_typed(
4099 source,
4100 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
4101 id: None,
4102 parent_session_id: Some(source.id.clone()),
4103 cwd: cwd_str.clone(),
4104 metadata: None,
4105 },
4106 &rpi_harness::session::types::ForkOptions::default(),
4107 )
4108 .await
4109 {
4110 Ok(s) => s,
4111 Err(e) => {
4112 add_error_message(chat, &format!("Could not fork session: {e}"));
4113 return;
4114 }
4115 };
4116 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
4117 let _ = harness.set_session(new_session).await;
4118 chat.clear();
4119 add_welcome_message(chat);
4120 render_session_history(
4121 harness,
4122 chat,
4123 state.markdown_transformer(),
4124 Some(state.extension_session.clone()),
4125 )
4126 .await;
4127 state.set_status(RunStatus::Idle);
4128 add_note_message(chat, "Forked into a new session.");
4129}
4130
4131async fn render_session_history(
4141 harness: &AgentHarness,
4142 chat: &Arc<Container>,
4143 transformer: Option<MarkdownTransformer>,
4144 extension_session: Option<crate::session::ExtensionSessionCell>,
4145) {
4146 let tree = harness.session().view("main");
4147 let entries = match tree
4148 .find_entries(&EntryQuery {
4149 entry_type: None,
4150 custom_type: None,
4151 order: Some(EntryOrder::OldestFirst),
4155 limit: None,
4156 cursor: None,
4157 })
4158 .await
4159 {
4160 Ok(e) => e,
4161 Err(_) => return,
4162 };
4163 let mut rendered_any = false;
4164 for e in entries {
4165 match e {
4166 Entry::Message(me) => match &me.message {
4167 AgentMessage::User(u) => {
4168 add_user_message(chat, &user_message_text(u));
4169 rendered_any = true;
4170 }
4171 AgentMessage::Assistant(a) => {
4172 let comp = Arc::new(AssistantMessageComponent::new(
4173 AssistantMessageOptions::default(),
4174 ));
4175 if let Some(t) = &transformer {
4176 comp.set_markdown_transformer(Some(t.clone()));
4177 }
4178 comp.update_blocks(&assistant_blocks(a));
4179 chat.add_child(comp);
4180 chat.add_child(Arc::new(Spacer::new(1)));
4183 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
4184 add_note_message(chat, &text);
4185 }
4186 if let Some(error) = assistant_error_text(a) {
4187 add_error_message(chat, &error);
4188 }
4189 rendered_any = true;
4190 }
4191 AgentMessage::ToolResult(result) => {
4192 let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
4197 comp.set_result(&tool_result_message_text(result), result.is_error);
4198 chat.add_child(comp);
4199 chat.add_child(Arc::new(Spacer::new(1)));
4200 rendered_any = true;
4201 }
4202 AgentMessage::Custom(custom) => {
4203 if let Some(session) = &extension_session {
4204 if let Some(component) = extension_message_component(
4205 session,
4206 &custom.role,
4207 &serde_json::json!({
4208 "customType": custom.role,
4209 "content": custom.content,
4210 "details": custom.data,
4211 }),
4212 transformer.clone(),
4213 ) {
4214 chat.add_child(component);
4215 chat.add_child(Arc::new(Spacer::new(1)));
4216 rendered_any = true;
4217 continue;
4218 }
4219 }
4220 add_note_message(chat, &custom_message_fallback(&custom));
4221 rendered_any = true;
4222 }
4223 },
4224 Entry::Compaction(compaction) => {
4225 add_note_message(
4226 chat,
4227 &format!(
4228 "Compacted {} tokens: {}",
4229 compaction.tokens_before, compaction.summary
4230 ),
4231 );
4232 rendered_any = true;
4233 }
4234 Entry::BranchSummary(summary) => {
4235 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4236 rendered_any = true;
4237 }
4238 Entry::Custom(custom) => {
4239 let rendered = extension_session.as_ref().and_then(|session| {
4240 extension_entry_component(session, &custom.custom_type, custom.data.clone())
4241 });
4242 if let Some(component) = rendered {
4243 chat.add_child(component);
4244 chat.add_child(Arc::new(Spacer::new(1)));
4245 rendered_any = true;
4246 } else if let Some(text) =
4247 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4248 {
4249 add_note_message(chat, &text);
4250 rendered_any = true;
4251 }
4252 }
4253 Entry::ModelChange(change) => {
4254 add_note_message(
4255 chat,
4256 &format!("Model changed to {}:{}", change.provider, change.model_id),
4257 );
4258 rendered_any = true;
4259 }
4260 Entry::ThinkingLevel(change) => {
4261 add_note_message(
4262 chat,
4263 &format!("Thinking level: {:?}", change.thinking_level),
4264 );
4265 rendered_any = true;
4266 }
4267 Entry::ActiveTools(change) => {
4268 add_note_message(
4269 chat,
4270 &format!("Active tools: {}", change.active_tool_names.join(", ")),
4271 );
4272 rendered_any = true;
4273 }
4274 }
4275 }
4276 if rendered_any {
4277 }
4280}
4281
4282fn invoke_extension_renderer(
4283 session: &crate::session::ExtensionSessionCell,
4284 kind: rpi_extensions::RegisteredRendererKind,
4285 payload: &serde_json::Value,
4286) -> Option<serde_json::Value> {
4287 let snapshot = session.lock().ok()?.snapshot_arc()?;
4288 let input = serde_json::to_string(payload).ok()?;
4289 for renderer in snapshot.renderers_of(kind) {
4290 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4291 let mut out = rpi_plugin_sdk::StbString::empty();
4292 let rc = (renderer.render_fn)(
4293 rpi_plugin_sdk::StbStringRef::from_str(&input),
4294 &mut out as *mut rpi_plugin_sdk::StbString,
4295 renderer.user_data,
4296 );
4297 let text = if rc == 0 {
4298 Some(out.to_string_lossy())
4299 } else {
4300 None
4301 };
4302 out.free_with(Some(renderer.plugin_free_string));
4303 text
4304 }))
4305 .ok()
4306 .flatten();
4307 let Some(text) = outcome else { continue };
4308 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4309 return Some(value);
4310 }
4311 }
4312 None
4313}
4314
4315fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4316 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4317 let text = lines
4318 .iter()
4319 .filter_map(|line| line.as_str())
4320 .collect::<Vec<_>>()
4321 .join("\n");
4322 return Some(Arc::new(Text::new(text, 0, 0)));
4323 }
4324 let text = value.get("text").and_then(|v| v.as_str())?;
4325 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4326 let component = Arc::new(AssistantMessageComponent::new(
4327 AssistantMessageOptions::default(),
4328 ));
4329 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4330 Some(component)
4331 } else {
4332 Some(Arc::new(Text::new(text, 0, 0)))
4333 }
4334}
4335
4336fn extension_message_component(
4337 session: &crate::session::ExtensionSessionCell,
4338 custom_type: &str,
4339 payload: &serde_json::Value,
4340 transformer: Option<MarkdownTransformer>,
4341) -> Option<Arc<dyn rpi_tui::Component>> {
4342 let value = invoke_extension_renderer(
4343 session,
4344 rpi_extensions::RegisteredRendererKind::Message,
4345 payload,
4346 )?;
4347 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4348 let text = value.get("text").and_then(|v| v.as_str())?;
4349 let component = Arc::new(AssistantMessageComponent::new(
4350 AssistantMessageOptions::default(),
4351 ));
4352 if let Some(transformer) = transformer {
4353 component.set_markdown_transformer(Some(transformer));
4354 }
4355 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4356 return Some(component);
4357 }
4358 extension_text_component(&value)
4359 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4360}
4361
4362fn extension_usage_text(
4365 session: Option<&crate::session::ExtensionSessionCell>,
4366 usage: &rpi_ai::types::Usage,
4367) -> Option<String> {
4368 let session = session?;
4369 let payload = serde_json::json!({
4370 "customType": "token-usage",
4371 "usage": usage,
4372 });
4373 let value = invoke_extension_renderer(
4374 session,
4375 rpi_extensions::RegisteredRendererKind::Message,
4376 &payload,
4377 )?;
4378 value
4379 .get("text")
4380 .and_then(|value| value.as_str())
4381 .filter(|text| !text.trim().is_empty())
4382 .map(ToOwned::to_owned)
4383}
4384
4385fn extension_entry_component(
4386 session: &crate::session::ExtensionSessionCell,
4387 custom_type: &str,
4388 data: Option<serde_json::Value>,
4389) -> Option<Arc<dyn rpi_tui::Component>> {
4390 let payload = serde_json::json!({
4391 "customType": custom_type,
4392 "data": data,
4393 });
4394 let value = invoke_extension_renderer(
4395 session,
4396 rpi_extensions::RegisteredRendererKind::Entry,
4397 &payload,
4398 )?;
4399 extension_text_component(&value)
4400}
4401
4402fn launch_restores_history(args: &Args) -> bool {
4408 args.continue_session
4409 || args.resume
4410 || args.session.is_some()
4411 || args.session_id.is_some()
4412 || args.fork.is_some()
4413}
4414
4415fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4416 msg.content
4417 .iter()
4418 .filter_map(|c| match c {
4419 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4420 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4421 Content::Image(image) => base64::engine::general_purpose::STANDARD
4422 .decode(&image.data)
4423 .ok()
4424 .filter(|data| !data.is_empty())
4425 .map(AssistantBlock::Image),
4426 _ => None,
4427 })
4428 .collect()
4429}
4430
4431fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4432 let content = custom
4433 .content
4434 .iter()
4435 .filter_map(|item| match item {
4436 Content::Text(text) => Some(text.text.as_str()),
4437 _ => None,
4438 })
4439 .collect::<Vec<_>>()
4440 .join("\n");
4441 if content.is_empty() {
4442 format!("{}: {}", custom.role, custom.data)
4443 } else {
4444 format!("{}: {}", custom.role, content)
4445 }
4446}
4447
4448fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4450 let mut dir = Some(cwd);
4451 while let Some(current) = dir {
4452 let head = current.join(".git").join("HEAD");
4453 if head.exists() {
4454 return Some(head);
4455 }
4456 dir = current.parent();
4457 }
4458 None
4459}
4460
4461fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4465 let mut dir = Some(cwd);
4466 while let Some(current) = dir {
4467 let head = current.join(".git").join("HEAD");
4468 if let Ok(contents) = std::fs::read_to_string(&head) {
4469 let trimmed = contents.trim();
4470 if let Some(reference) = trimmed.strip_prefix("ref: ") {
4471 let branch = reference
4472 .rsplit('/')
4473 .next()
4474 .unwrap_or(reference)
4475 .trim()
4476 .to_string();
4477 if !branch.is_empty() {
4478 return Some(branch);
4479 }
4480 }
4481 if !trimmed.is_empty() {
4482 return Some(trimmed.chars().take(7).collect());
4483 }
4484 }
4485 dir = current.parent();
4486 }
4487 None
4488}
4489
4490fn short_model_name(id: &str) -> String {
4493 id.rsplit([':', '/'])
4494 .next()
4495 .filter(|s| !s.is_empty())
4496 .unwrap_or(id)
4497 .to_string()
4498}
4499
4500#[derive(Clone, Copy, PartialEq, Eq)]
4506enum RunStatus {
4507 Idle,
4508 Working,
4509 Aborting,
4510}
4511
4512#[derive(Clone, Copy, PartialEq, Eq)]
4514enum SelectorKind {
4515 Model,
4517 Thinking,
4519 Tools,
4521 Images,
4523 Session,
4525 Theme,
4527 ScopedModels,
4529 Settings,
4531 Tree,
4533 Extension,
4535}
4536
4537#[derive(Clone)]
4544enum SelectorView {
4545 List(Arc<SelectList>),
4547 Searchable(Arc<SearchableSelectList>),
4549 Settings(Arc<SettingsList>),
4552}
4553
4554impl SelectorView {
4555 fn handle_key(&self, key: KeyEvent) {
4557 match self {
4558 Self::List(list) => list.handle_key(key),
4559 Self::Searchable(searchable) => searchable.handle_key(key),
4560 Self::Settings(settings) => settings.handle_key(key),
4561 }
4562 }
4563}
4564
4565struct TuiState {
4568 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4570 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4572 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4576 themes_enabled: bool,
4578 hide_thinking: std::sync::Mutex<bool>,
4580 tool_outputs_expanded: std::sync::Mutex<bool>,
4582 show_terminal_progress: bool,
4584 status: std::sync::Mutex<RunStatus>,
4586 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4590 user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4594 pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4598 footer: Arc<FooterComponent>,
4600 status_container: Arc<Container>,
4603 chat_container: Arc<Container>,
4605 loader: Arc<Loader>,
4607 editor: Arc<Editor>,
4610 last_assistant_text: std::sync::Mutex<String>,
4613 active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4617 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4619 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4621 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4623 autocomplete: AutocompleteManager,
4626 autocomplete_container: Arc<Container>,
4629 autocomplete_max_visible: usize,
4631 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4633 theme_manager: Arc<ThemeManager>,
4637 tui: Option<Arc<TuiAltScreen>>,
4641 current_model_id: std::sync::Mutex<String>,
4645 show_images: std::sync::Mutex<bool>,
4649 cache_miss_notices: std::sync::Mutex<bool>,
4652 history: std::sync::Mutex<Vec<String>>,
4655 history_index: std::sync::Mutex<isize>,
4658 history_draft: std::sync::Mutex<Option<String>>,
4661 cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4665 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4668 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4678 extension_session: crate::session::ExtensionSessionCell,
4680 search: Arc<AltScreenSearch>,
4682 search_bar: Arc<SearchBar>,
4684 pending_container: Arc<Container>,
4688 dequeue_hint: String,
4691 pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4695 selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4697 selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4698}
4699
4700const HISTORY_LIMIT: usize = 100;
4703
4704const PAGE_SCROLL_OVERLAP: usize = 4;
4707
4708const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4712
4713fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4716 let mut modifiers = KeyModifiers::NONE;
4717 let mut key = None;
4718 for part in value.trim().to_ascii_lowercase().split('+') {
4719 match part {
4720 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4721 "shift" => modifiers |= KeyModifiers::SHIFT,
4722 "alt" | "option" => modifiers |= KeyModifiers::ALT,
4723 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4724 part if !part.is_empty() => key = Some(part.to_string()),
4725 _ => {}
4726 }
4727 }
4728 let key = key?;
4729 let code = match key.as_str() {
4730 "esc" | "escape" => KeyCode::Esc,
4731 "enter" | "return" => KeyCode::Enter,
4732 "tab" => {
4733 if modifiers.contains(KeyModifiers::SHIFT) {
4734 return Some(rpi_tui::KeyCombo::new(
4735 KeyCode::BackTab,
4736 modifiers & !KeyModifiers::SHIFT,
4737 ));
4738 }
4739 KeyCode::Tab
4740 }
4741 "backspace" | "back" => KeyCode::Backspace,
4742 "delete" | "del" => KeyCode::Delete,
4743 "up" | "arrowup" => KeyCode::Up,
4744 "down" | "arrowdown" => KeyCode::Down,
4745 "left" | "arrowleft" => KeyCode::Left,
4746 "right" | "arrowright" => KeyCode::Right,
4747 "home" => KeyCode::Home,
4748 "end" => KeyCode::End,
4749 "pageup" | "page-up" => KeyCode::PageUp,
4750 "pagedown" | "page-down" => KeyCode::PageDown,
4751 "space" => KeyCode::Char(' '),
4752 "f1" => KeyCode::F(1),
4753 "f2" => KeyCode::F(2),
4754 "f3" => KeyCode::F(3),
4755 "f4" => KeyCode::F(4),
4756 "f5" => KeyCode::F(5),
4757 "f6" => KeyCode::F(6),
4758 "f7" => KeyCode::F(7),
4759 "f8" => KeyCode::F(8),
4760 "f9" => KeyCode::F(9),
4761 "f10" => KeyCode::F(10),
4762 "f11" => KeyCode::F(11),
4763 "f12" => KeyCode::F(12),
4764 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4765 _ => return None,
4766 };
4767 Some(rpi_tui::KeyCombo::new(code, modifiers))
4768}
4769
4770fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4771 let mut bindings = rpi_tui::Keybindings::new();
4772 let settings = crate::settings::load_settings().unwrap_or_default();
4773 let Some(overrides) = settings.keybindings else {
4774 rpi_tui::set_keybindings(bindings.clone());
4775 return Arc::new(bindings);
4776 };
4777 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4778 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4779 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4780 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4781 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4782 (
4783 "app.model.cycleForward",
4784 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4785 ),
4786 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4787 (
4788 "app.thinking.toggle",
4789 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4790 ),
4791 (
4792 "app.editor.external",
4793 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4794 ),
4795 (
4796 "app.thinking.cycle",
4797 rpi_tui::keybindings::keys::THINKING_CYCLE,
4798 ),
4799 (
4800 "app.clipboard.pasteImage",
4801 rpi_tui::keybindings::keys::PASTE_IMAGE,
4802 ),
4803 ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4804 ];
4805 for (name, id) in known {
4806 let Some(value) = overrides.get(*name) else {
4807 continue;
4808 };
4809 let values: Vec<String> = match value {
4810 serde_json::Value::String(value) => vec![value.clone()],
4811 serde_json::Value::Array(values) => values
4812 .iter()
4813 .filter_map(|v| v.as_str().map(str::to_string))
4814 .collect(),
4815 serde_json::Value::Null => Vec::new(),
4816 _ => continue,
4817 };
4818 let combos: Vec<_> = values
4819 .iter()
4820 .filter_map(|value| parse_configured_key(value))
4821 .collect();
4822 if values.is_empty() || !combos.is_empty() {
4823 bindings.set(id, combos);
4824 }
4825 }
4826 rpi_tui::set_keybindings(bindings.clone());
4827 Arc::new(bindings)
4828}
4829
4830fn keybinding_matches(
4831 bindings: &rpi_tui::Keybindings,
4832 event: &crossterm::event::KeyEvent,
4833 id: rpi_tui::KeybindingId,
4834) -> bool {
4835 if bindings.matches(event, id) {
4836 return true;
4837 }
4838 if event.code == KeyCode::BackTab {
4841 let normalized =
4842 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4843 bindings.matches(&normalized, id)
4844 } else {
4845 false
4846 }
4847}
4848
4849fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4853 let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4854 if keys.is_empty() {
4855 return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4856 }
4857 keys.iter()
4858 .map(|combo| combo.display())
4859 .collect::<Vec<_>>()
4860 .join("/")
4861}
4862
4863fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4864 last.is_some_and(|previous| {
4865 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4866 })
4867}
4868
4869const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4873
4874const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4877
4878fn enter_is_paste_burst(
4899 last_text_key_at: Option<std::time::Instant>,
4900 now: std::time::Instant,
4901 more_queued: bool,
4902) -> bool {
4903 if more_queued {
4904 return true;
4905 }
4906 last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4907}
4908
4909fn probe_paste_input(
4920 pending: Option<Event>,
4921 mut next_event: impl FnMut() -> Option<Event>,
4922) -> (bool, Option<Event>) {
4923 let mut pending = pending;
4924 let mut more_queued = pending.is_some();
4925 while !more_queued {
4926 match next_event() {
4927 Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4928 Some(ev) => {
4929 pending = Some(ev);
4930 more_queued = true;
4931 }
4932 None => break,
4933 }
4934 }
4935 (more_queued, pending)
4936}
4937
4938fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
4941 match last_text_key_at {
4942 Some(previous) => std::time::Instant::now()
4943 .duration_since(previous)
4944 .as_millis()
4945 .to_string(),
4946 None => "-".to_string(),
4947 }
4948}
4949
4950fn transcript_page_size(viewport_height: usize) -> i32 {
4951 viewport_height
4952 .saturating_sub(PAGE_SCROLL_OVERLAP)
4953 .max(1)
4954 .min(i32::MAX as usize) as i32
4955}
4956
4957fn should_dispatch_key(kind: KeyEventKind) -> bool {
4958 kind != KeyEventKind::Release
4959}
4960
4961fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4971 tui.stop(Default::default());
4975 eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4976 std::process::exit(130);
4977}
4978
4979fn format_tokens(n: i64) -> String {
4981 if n >= 1_000_000 {
4982 format!("{:.1}M", n as f64 / 1_000_000.0)
4983 } else if n >= 1_000 {
4984 format!("{:.1}K", n as f64 / 1_000.0)
4985 } else {
4986 n.to_string()
4987 }
4988}
4989
4990fn push_history(state: &Arc<TuiState>, text: &str) {
4994 let trimmed = text.trim().to_string();
4995 if trimmed.is_empty() {
4996 return;
4997 }
4998 let mut history = state.history.lock().unwrap();
4999 if history.first() == Some(&trimmed) {
5000 return;
5001 }
5002 history.insert(0, trimmed);
5003 history.truncate(HISTORY_LIMIT);
5004 *state.history_index.lock().unwrap() = -1;
5005 *state.history_draft.lock().unwrap() = None;
5006}
5007
5008fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5013 let history = state.history.lock().unwrap();
5014 if history.is_empty() {
5015 return;
5016 }
5017 let mut index = state.history_index.lock().unwrap();
5018 let new_index = *index - direction as isize;
5019 if new_index < -1 || new_index >= history.len() as isize {
5020 return;
5021 }
5022 if *index == -1 && new_index >= 0 {
5023 *state.history_draft.lock().unwrap() = Some(editor.get_text());
5025 }
5026 *index = new_index;
5027 if new_index == -1 {
5028 let draft = state.history_draft.lock().unwrap().take();
5030 match draft {
5031 Some(d) => {
5032 let len = d.len();
5033 editor.set_text(&d);
5034 editor.set_cursor(0, len);
5035 }
5036 None => editor.set_text(""),
5037 }
5038 } else {
5039 let text = history[new_index as usize].clone();
5040 let len = text.len();
5041 editor.set_text(&text);
5042 editor.set_cursor(0, len);
5043 }
5044}
5045
5046impl TuiState {
5047 fn begin_js_preparation(&self) -> CancellationToken {
5048 let cancellation = CancellationToken::new();
5049 if let Some(previous) = self
5050 .js_preparation_cancel
5051 .lock()
5052 .unwrap()
5053 .replace(cancellation.clone())
5054 {
5055 previous.cancel();
5056 }
5057 cancellation
5058 }
5059
5060 fn finish_js_preparation(&self) {
5061 self.js_preparation_cancel.lock().unwrap().take();
5062 }
5063
5064 fn cancel_js_preparation(&self) -> bool {
5065 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5066 if let Some(cancellation) = cancellation {
5067 cancellation.cancel();
5068 true
5069 } else {
5070 false
5071 }
5072 }
5073
5074 fn user_bash_running(&self) -> bool {
5076 self.user_bash_cancel.lock().unwrap().is_some()
5077 }
5078
5079 fn begin_user_bash(&self) -> CancellationToken {
5082 let cancellation = CancellationToken::new();
5083 if let Some(previous) = self
5084 .user_bash_cancel
5085 .lock()
5086 .unwrap()
5087 .replace(cancellation.clone())
5088 {
5089 previous.cancel();
5090 }
5091 cancellation
5092 }
5093
5094 fn finish_user_bash(&self) {
5097 self.user_bash_cancel.lock().unwrap().take();
5098 }
5099
5100 fn cancel_user_bash(&self) -> bool {
5104 let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5105 if let Some(cancellation) = cancellation {
5106 cancellation.cancel();
5107 true
5108 } else {
5109 false
5110 }
5111 }
5112
5113 fn set_status(&self, status: RunStatus) {
5114 *self.status.lock().unwrap() = status;
5115 self.apply_status(status);
5116 }
5117
5118 fn pending_container(&self) -> &Arc<Container> {
5121 &self.pending_container
5122 }
5123
5124 fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5130 {
5131 let mut current = self.pending_snapshot.lock().unwrap();
5132 if *current == messages {
5133 return;
5134 }
5135 *current = messages.clone();
5136 }
5137 let container = &self.pending_container;
5138 container.clear();
5139 if messages.is_empty() {
5140 return;
5141 }
5142 let colors = current_theme().colors;
5143 container.add_child(Arc::new(Spacer::new(1)));
5144 for text in &messages.steering {
5145 container.add_child(Arc::new(Text::new(
5146 colors.muted.fg(&format!("Steering: {text}")),
5147 1,
5148 0,
5149 )));
5150 }
5151 for text in &messages.follow_up {
5152 container.add_child(Arc::new(Text::new(
5153 colors.muted.fg(&format!("Follow-up: {text}")),
5154 1,
5155 0,
5156 )));
5157 }
5158 container.add_child(Arc::new(Text::new(
5159 colors.muted.fg(&format!(
5160 "↳ {} to edit all queued messages",
5161 self.dequeue_hint
5162 )),
5163 1,
5164 0,
5165 )));
5166 }
5167
5168 fn try_start_working(&self) -> bool {
5173 let mut status = self.status.lock().unwrap();
5174 if *status != RunStatus::Idle {
5175 return false;
5176 }
5177 *status = RunStatus::Working;
5178 drop(status);
5179 self.apply_status(RunStatus::Working);
5180 true
5181 }
5182
5183 fn apply_status(&self, status: RunStatus) {
5184 match status {
5185 RunStatus::Working => {
5186 self.footer.set_status("");
5190 if let Some(tui) = &self.tui {
5193 tui.set_title("🦀π rpi ⟳");
5194 }
5195 self.status_container.clear();
5196 if self.show_terminal_progress {
5199 self.editor.set_working(Some(WorkingState {
5200 frame: 0,
5201 started_at: std::time::Instant::now(),
5202 message: "Working…".to_string(),
5203 }));
5204 } else {
5205 self.editor.set_working(None);
5206 }
5207 }
5208 RunStatus::Aborting => {
5209 self.footer.set_status("Aborting…");
5210 self.loader.stop();
5213 self.status_container.clear();
5214 self.editor.set_working(None);
5215 }
5216 RunStatus::Idle => {
5217 self.footer.set_status("");
5218 if let Some(tui) = &self.tui {
5219 tui.set_title("🦀π rpi");
5220 }
5221 self.loader.stop();
5222 self.status_container.clear();
5223 self.editor.set_working(None);
5224 }
5225 }
5226 }
5227
5228 fn sync_working_loader_with_bash(&self) {
5232 if *self.status.lock().unwrap() != RunStatus::Working {
5233 return;
5234 }
5235
5236 self.status_container.clear();
5241 }
5242
5243 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5244 *self.status.lock().unwrap() = RunStatus::Working;
5245 self.footer.set_status("");
5246 if let Some(tui) = &self.tui {
5247 tui.set_title("🦀π rpi ↻");
5248 }
5249 self.loader.stop();
5250 self.status_container.clear();
5251 self.status_container
5252 .add_child(Arc::new(StatusIndicator::retry(
5253 attempt,
5254 max_retries,
5255 std::time::Duration::from_millis(delay_ms),
5256 )));
5257 }
5258
5259 fn selector_open(&self) -> bool {
5261 self.active_selector.lock().unwrap().is_some()
5262 }
5263
5264 fn extension_editor_open(&self) -> bool {
5265 self.active_extension_editor.lock().unwrap().is_some()
5266 }
5267
5268 fn extension_input_open(&self) -> bool {
5269 self.active_extension_input.lock().unwrap().is_some()
5270 }
5271
5272 fn extension_dialog_open(&self) -> bool {
5273 self.extension_editor_open() || self.extension_input_open()
5274 }
5275
5276 fn set_hide_thinking(&self, hide: bool) {
5277 *self.hide_thinking.lock().unwrap() = hide;
5278 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5279 comp.set_hide_thinking(hide);
5280 }
5281 }
5282
5283 fn hide_thinking(&self) -> bool {
5284 *self.hide_thinking.lock().unwrap()
5285 }
5286
5287 fn toggle_thinking(&self) -> bool {
5288 let next = !self.hide_thinking();
5289 self.set_hide_thinking(next);
5290 next
5291 }
5292
5293 fn toggle_tool_outputs(&self) -> bool {
5294 let next = !*self.tool_outputs_expanded.lock().unwrap();
5295 *self.tool_outputs_expanded.lock().unwrap() = next;
5296 for comp in self.tool_components.lock().unwrap().values() {
5297 comp.set_expanded(next);
5298 }
5299 for comp in self.bash_components.lock().unwrap().values() {
5300 comp.set_expanded(next);
5301 }
5302 next
5303 }
5304
5305 fn current_model_id(&self) -> String {
5307 self.current_model_id.lock().unwrap().clone()
5308 }
5309
5310 fn set_current_model(&self, model: &rpi_ai::Model) {
5313 *self.current_model_id.lock().unwrap() = model.id.clone();
5314 self.footer.set_model(&short_model_name(&model.id));
5315 self.footer.set_context_window(model.context_window as i64);
5318 }
5319
5320 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5324 self.markdown_transformer.lock().unwrap().clone()
5325 }
5326
5327 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5336 *self.markdown_transformer.lock().unwrap() = transformer.clone();
5337 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5338 comp.set_markdown_transformer(transformer);
5339 }
5340 }
5341
5342 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5343 self.pending_images.lock().unwrap().push(image);
5344 }
5345
5346 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5347 std::mem::take(&mut *self.pending_images.lock().unwrap())
5348 }
5349}
5350
5351#[derive(Debug, PartialEq)]
5356struct TuiStartupSettings {
5357 editor_padding_x: usize,
5358 autocomplete_max_visible: usize,
5359 hide_thinking: bool,
5360 quiet_startup: bool,
5361 show_terminal_progress: bool,
5362 show_images: bool,
5364 cache_miss_notices: bool,
5366}
5367
5368fn preferred_project_setting<T>(
5369 project_settings: &[crate::settings::Settings],
5370 field: impl Fn(&crate::settings::Settings) -> Option<T>,
5371) -> Option<T> {
5372 project_settings.iter().find_map(field)
5373}
5374
5375fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5376 verbose || !quiet_startup
5377}
5378
5379fn git_only_update_resources(
5380 mut resources: crate::packages::PackageResources,
5381) -> crate::packages::PackageResources {
5382 resources
5383 .packages
5384 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5385 resources
5386}
5387
5388fn resolve_tui_startup_settings(
5392 global: &crate::settings::Settings,
5393 project_settings: &[crate::settings::Settings],
5394 project_trusted: bool,
5395) -> TuiStartupSettings {
5396 let project_settings = if project_trusted {
5397 project_settings
5398 } else {
5399 &[]
5400 };
5401 let editor_padding_x =
5402 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5403 .or(global.editor_padding_x)
5404 .unwrap_or(1)
5405 .min(16);
5406 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5407 settings.autocomplete_max_visible
5408 })
5409 .or(global.autocomplete_max_visible)
5410 .unwrap_or(5)
5411 .clamp(1, 20);
5412 let hide_thinking =
5413 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5414 .or(global.hide_thinking_block)
5415 .unwrap_or(false);
5416 let quiet_startup =
5417 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5418 .or(global.quiet_startup)
5419 .unwrap_or(false);
5420 let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5421 settings.show_terminal_progress()
5422 })
5423 .or_else(|| global.show_terminal_progress())
5424 .unwrap_or(true);
5425 let show_images =
5426 preferred_project_setting(project_settings, |settings| settings.show_images())
5427 .or_else(|| global.show_images())
5428 .unwrap_or(true);
5429 let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5430 settings.show_cache_miss_notices
5431 })
5432 .or(global.show_cache_miss_notices)
5433 .unwrap_or(false);
5434
5435 TuiStartupSettings {
5436 editor_padding_x,
5437 autocomplete_max_visible,
5438 hide_thinking,
5439 quiet_startup,
5440 show_terminal_progress,
5441 show_images,
5442 cache_miss_notices,
5443 }
5444}
5445
5446pub async fn interactive_tui(
5460 harness: &AgentHarness,
5461 event_rx: Option<broadcast::Receiver<AgentEvent>>,
5462 args: &Args,
5463 model_catalog: Vec<rpi_ai::Model>,
5464 initial: Option<String>,
5465 extra_messages: &[String],
5466 initial_images: Vec<rpi_ai::types::ImageContent>,
5467 theme: Option<&str>,
5468 no_themes: bool,
5469 reload_context: &crate::session::ReloadContext,
5470) -> i32 {
5471 let lane: Arc<dyn AgentLane> = harness.lane("main");
5472 let cwd = reload_context.cwd.clone();
5476 let saved_settings = crate::settings::load_settings().unwrap_or_default();
5477 let project_trusted = reload_context.project_trusted;
5478 let project_settings = if project_trusted {
5479 crate::settings::load_project_settings(&cwd)
5480 } else {
5481 Vec::new()
5482 };
5483 let tui_settings =
5484 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5485 let editor_padding_x = tui_settings.editor_padding_x;
5486 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5487 let mut hide_thinking = tui_settings.hide_thinking;
5488 let show_terminal_progress = tui_settings.show_terminal_progress;
5489 let show_images = tui_settings.show_images;
5490 let cache_miss_notices = tui_settings.cache_miss_notices;
5491 let quiet_startup = tui_settings.quiet_startup;
5492 let update_checks_enabled =
5493 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5494
5495 let session_values = rpi_harness::session::SessionValues::load(harness.session())
5502 .await
5503 .unwrap_or_default();
5504 if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5505 hide_thinking = persisted;
5506 }
5507
5508 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5512 let model_name = short_model_name(&lane_model_id);
5513
5514 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5518 if active_tool_names.is_empty() {
5522 active_tool_names = harness
5523 .get_tools()
5524 .await
5525 .unwrap_or_default()
5526 .into_iter()
5527 .map(|tool| tool.tool.schema().name.clone())
5528 .collect();
5529 }
5530 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5531 let skill_names: Vec<String> = resources_snapshot
5532 .skills
5533 .as_deref()
5534 .unwrap_or(&[])
5535 .iter()
5536 .map(|skill| skill.name.clone())
5537 .collect();
5538
5539 let package_resources = reload_context.package_resources.clone();
5541
5542 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5544
5545 let theme_manager = Arc::new(ThemeManager::new());
5549 if let Some(preset) = match theme {
5550 Some("light") => Some(ThemePreset::Light),
5551 Some("monochrome") => Some(ThemePreset::Monochrome),
5552 Some("dark") => Some(ThemePreset::Dark),
5553 _ => None,
5554 } {
5555 apply_theme_preset(preset);
5556 theme_manager.apply_preset(preset);
5557 } else if !no_themes {
5558 if let Some(name) = theme {
5559 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
5560 Ok(custom) => {
5561 rpi_tui::global_theme_manager().set(custom.clone());
5562 theme_manager.set(custom);
5563 }
5564 Err(error) => {
5565 eprintln!("warning: could not load package theme `{name}`: {error}");
5566 }
5567 }
5568 }
5569 }
5570
5571 let terminal = Box::new(ProcessTerminal::new());
5573 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5574 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
5575 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5580 ask_user_bridge.attach();
5581 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5582
5583 if let Some(js) = &reload_context.js_extension_session {
5584 if let Err(error) = js.install_ui_runtime(tui.clone()) {
5585 eprintln!("warning: could not enable JS custom UI bridge: {error}");
5586 } else if let Some(js_active) = js.active_tools() {
5587 let js_names = js.tool_names();
5591 let mut active = lane.get_active_tools().await.unwrap_or_default();
5592 active.retain(|name| {
5593 crate::session::tool_name_allowed(name, args)
5594 && !js_names.iter().any(|js_name| js_name == name)
5595 });
5596 active.extend(js_active.into_iter().filter(|name| {
5597 js_names.iter().any(|js_name| js_name == name)
5598 && crate::session::tool_name_allowed(name, args)
5599 }));
5600 active = crate::session::filter_active_tool_names(active, args);
5601 let _ = lane.set_active_tools(active).await;
5602 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5603 }
5604 }
5605
5606 let chat_container = Arc::new(Container::new());
5607 if let Some(js) = &reload_context.js_extension_session {
5608 let chat_notify = chat_container.clone();
5612 let tui_notify = tui.clone();
5613 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
5614 if action != "ui.notify" {
5615 return Err(format!("unsupported capability: {action}"));
5616 }
5617 let message = args
5618 .get("message")
5619 .and_then(serde_json::Value::as_str)
5620 .unwrap_or_default();
5621 if !message.is_empty() {
5622 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
5623 add_error_message(&chat_notify, message);
5624 } else {
5625 add_note_message(&chat_notify, message);
5626 }
5627 tui_notify.request_render(false);
5628 }
5629 Ok(serde_json::json!(true))
5630 })) {
5631 if args.verbose {
5632 eprintln!("warning: could not enable JS notification bridge: {error}");
5633 }
5634 }
5635 }
5636 if should_show_startup_listing(args.verbose, quiet_startup) {
5637 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5638 }
5639
5640 crate::extras::maybe_first_time_setup(&chat_container);
5646
5647 let initial_transformer = build_markdown_transformer(
5652 reload_context
5653 .extension_session
5654 .lock()
5655 .unwrap()
5656 .snapshot_arc(),
5657 );
5658 if launch_restores_history(args) {
5662 render_session_history(
5663 &harness,
5664 &chat_container,
5665 initial_transformer.clone(),
5666 Some(reload_context.extension_session.clone()),
5667 )
5668 .await;
5669 }
5670
5671 let document_container = Arc::new(Container::new());
5674 document_container.add_child(chat_container.clone());
5675 document_container.add_child(Arc::new(Spacer::new(1)));
5677
5678 let scroll_view = Arc::new(ScrollView::new(
5679 document_container.clone(),
5680 ScrollViewOptions {
5681 follow: FollowMode::End,
5682 primary: true,
5683 overscroll: OverscrollMode::Chain,
5684 scrollbar: ScrollbarMode::Hidden,
5688 ..Default::default()
5689 },
5690 ));
5691
5692 let keybindings = configured_keybindings();
5697 let editor = Arc::new(Editor::new(
5698 EditorOptions {
5699 padding_x: editor_padding_x,
5700 autocomplete_max_visible,
5701 ..Default::default()
5702 },
5703 EditorStyle::default(),
5704 keybindings.clone(),
5705 ));
5706
5707 {
5712 let editor_for_change = editor.clone();
5713 editor.on_change(Arc::new(move |text: &str| {
5714 let colors = current_theme().colors;
5715 let is_bash = text.trim_start().starts_with('!');
5716 editor_for_change.set_border_color(if is_bash {
5717 Some(colors.bash_mode)
5718 } else {
5719 None
5720 });
5721 }));
5722 }
5723
5724 let footer = Arc::new(FooterComponent::new());
5726 footer.set_model(&model_name);
5727 footer.set_cwd(&cwd.to_string_lossy());
5728 footer.set_git_branch(git_branch_for(&cwd).as_deref());
5729 if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5730 footer.set_context_window(m.context_window as i64);
5731 }
5732 footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Abort/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help");
5733
5734 let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5737 let footer_ref = footer.clone();
5738 let cwd_ref = cwd.clone();
5739 crate::fs_watch::watch_path(head, move || {
5740 footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5741 })
5742 });
5743
5744 let status_container = Arc::new(Container::new());
5745 let loader = Arc::new(Loader::with_text("Working…"));
5746
5747 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5761 .prompt_templates
5762 .clone()
5763 .unwrap_or_default()
5764 .iter()
5765 .map(|t| SlashCommandEntry {
5766 name: format!("/{}", t.name),
5767 description: t
5768 .description
5769 .clone()
5770 .unwrap_or_else(|| "Expand prompt template".to_string()),
5771 })
5772 .collect();
5773 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5774 Arc::new(resources_snapshot);
5775 let mut command_registry = build_builtin_registry();
5781 register_extension_commands(
5782 &mut command_registry,
5783 reload_context.extension_session.clone(),
5784 );
5785 register_js_extension_commands(
5786 &mut command_registry,
5787 reload_context.js_extension_session.clone(),
5788 );
5789 let registry = Arc::new(command_registry);
5790 let mut all_slash_commands = registry.visible_entries();
5791 all_slash_commands.extend(template_slash_commands);
5792 let autocomplete = AutocompleteManager::new();
5793 {
5794 let mut combined = CombinedAutocompleteProvider::new();
5795 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5796 all_slash_commands,
5797 )));
5798 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5799 cwd.clone(),
5800 )));
5801 autocomplete.set_provider(Arc::new(combined));
5802 }
5803 let autocomplete_container = Arc::new(Container::new());
5804
5805 let tool_outputs_expanded = session_values
5806 .get_as::<bool>("display.tool_outputs_expanded")
5807 .unwrap_or(false);
5808 let state = Arc::new(TuiState {
5809 current_assistant: std::sync::Mutex::new(None),
5810 tool_components: std::sync::Mutex::new(HashMap::new()),
5811 bash_components: std::sync::Mutex::new(HashMap::new()),
5812 themes_enabled: !no_themes,
5813 hide_thinking: std::sync::Mutex::new(hide_thinking),
5814 tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5815 show_terminal_progress,
5816 status: std::sync::Mutex::new(RunStatus::Idle),
5817 js_preparation_cancel: std::sync::Mutex::new(None),
5818 user_bash_cancel: std::sync::Mutex::new(None),
5819 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5820 footer: footer.clone(),
5821 status_container: status_container.clone(),
5822 chat_container: chat_container.clone(),
5823 loader: loader.clone(),
5824 editor: editor.clone(),
5825 last_assistant_text: std::sync::Mutex::new(String::new()),
5826 active_selector: std::sync::Mutex::new(None),
5827 active_extension_editor: std::sync::Mutex::new(None),
5828 active_extension_input: std::sync::Mutex::new(None),
5829 active_extension_cancel: std::sync::Mutex::new(None),
5830 autocomplete,
5831 autocomplete_container: autocomplete_container.clone(),
5832 autocomplete_max_visible,
5833 pending_images: std::sync::Mutex::new(Vec::new()),
5834 theme_manager,
5835 tui: Some(tui.clone()),
5836 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5837 show_images: std::sync::Mutex::new(show_images),
5838 cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5839 history: std::sync::Mutex::new(Vec::new()),
5840 history_index: std::sync::Mutex::new(-1),
5841 history_draft: std::sync::Mutex::new(None),
5842 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5843 scoped_edit: std::sync::Mutex::new(None),
5844 markdown_transformer: std::sync::Mutex::new(initial_transformer),
5845 extension_session: reload_context.extension_session.clone(),
5846 search: Arc::new(AltScreenSearch::new()),
5847 search_bar: Arc::new(SearchBar::new()),
5848 pending_container: Arc::new(Container::new()),
5849 dequeue_hint: pending_dequeue_hint(&keybindings),
5850 pending_snapshot: std::sync::Mutex::new(
5851 rpi_harness::agent_harness::QueuedMessages::default(),
5852 ),
5853 selection_start: std::sync::Mutex::new(None),
5854 selection_end: std::sync::Mutex::new(None),
5855 });
5856
5857 let model_catalog_arc = Arc::new(model_catalog.clone());
5860 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5861
5862 let editor_container = Arc::new(Container::new());
5873 editor_container.add_child(editor.clone());
5874
5875 let dock = Arc::new(VStack::from_children(vec![
5876 StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5877 StackChild::Entry(StackEntry::new(status_container.clone())),
5878 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5879 StackChild::Entry(StackEntry::new(Arc::new(Spacer::new(1)))),
5881 StackChild::Entry(
5882 StackEntry::new(editor_container.clone())
5883 .shrink(0)
5884 .min_size(3),
5885 ),
5886 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5887 StackChild::Entry(StackEntry::new(footer.clone())),
5888 ]));
5889
5890 let root = VStack::from_children(vec![
5891 StackChild::Entry(
5892 StackEntry::new(scroll_view.clone())
5893 .basis(0)
5894 .grow(1)
5895 .shrink(1)
5896 .min_size(1),
5897 ),
5898 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5899 ]);
5900
5901 tui.set_layout_root(Some(Arc::new(root)));
5902 tui.set_focus(Some(editor.clone()));
5903 editor.set_focused(true);
5904
5905 let scoped_models = args
5921 .model
5922 .as_deref()
5923 .map(crate::agent_session::ScopedModel::parse)
5924 .into_iter()
5925 .collect();
5926 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5927 harness.clone(),
5928 model_catalog.clone(),
5929 scoped_models,
5930 cwd.clone(),
5931 ));
5932 let ctx = CommandContext {
5933 chat: chat_container.clone(),
5934 tui: tui.clone(),
5935 tx: tx.clone(),
5936 state: state.clone(),
5937 editor: editor.clone(),
5938 editor_container: editor_container.clone(),
5939 lane: lane.clone(),
5940 model_catalog: model_catalog_arc.clone(),
5941 lane_model_id: lane_model_id.clone(),
5942 cwd: cwd.clone(),
5943 package_resources: package_resources.clone(),
5944 resources: resources_arc.clone(),
5945 reload_context: Arc::new(reload_context.clone()),
5946 session: agent_session.clone(),
5947 };
5948
5949 if let Some(js) = &reload_context.js_extension_session {
5950 let bridge = js_dialog_bridge.clone();
5951 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5952 bridge.handle_runtime_request(action, args)
5953 })) {
5954 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5955 }
5956 }
5957
5958 let ctx_for_cb = ctx.clone();
5959 let registry_for_cb = registry.clone();
5960 editor.on_submit(Arc::new(move |text: &str| {
5961 let text = text.trim();
5962 if text.is_empty() {
5963 return;
5964 }
5965
5966 if text.starts_with('/') {
5967 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
5968 return;
5969 }
5970
5971 if let Some((command, exclude_from_context)) = parse_user_bash(text) {
5976 if ctx_for_cb.state.user_bash_running() {
5977 add_error_message(
5978 &ctx_for_cb.chat,
5979 "A bash command is already running. Press Esc to cancel it first.",
5980 );
5981 ctx_for_cb.tui.request_render(false);
5982 return;
5983 }
5984 start_user_bash(&ctx_for_cb, command, exclude_from_context);
5985 return;
5986 }
5987
5988 let run_status = *ctx_for_cb.state.status.lock().unwrap();
5989 if run_status != RunStatus::Idle {
5990 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5991 let lane = ctx_for_cb.lane.clone();
5992 let chat = ctx_for_cb.chat.clone();
5993 let tui = ctx_for_cb.tui.clone();
5994 let state = ctx_for_cb.state.clone();
5995 tokio::spawn(async move {
5996 if let Err(error) = lane.steer(message).await {
6006 add_error_message(&chat, &format!("Could not queue message: {error}"));
6007 tui.render_now(false);
6008 return;
6009 }
6010 refresh_pending_messages(&state, &lane).await;
6013 tui.request_render(false);
6014 });
6015 ctx_for_cb.tui.request_render(false);
6021 return;
6022 }
6023
6024 if !ctx_for_cb.state.try_start_working() {
6025 return;
6026 }
6027
6028 add_user_message(&ctx_for_cb.chat, text);
6039 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6042 scroll.scroll_to_end();
6043 }
6044 ctx_for_cb.tui.request_render(false);
6045 push_history(&ctx_for_cb.state, text);
6048 if ctx_for_cb
6049 .tx
6050 .send(TuiMessage::UserInput(text.to_string()))
6051 .is_err()
6052 {
6053 ctx_for_cb.state.set_status(RunStatus::Idle);
6054 }
6055 }));
6056
6057 tui.start_render_scheduler();
6065 tui.start_readerless();
6066
6067 let mut update_check_handles = Vec::new();
6071 let rpi_chat = chat_container.clone();
6072 let rpi_tui = tui.clone();
6073 update_check_handles.push(tokio::spawn(async move {
6074 let report = crate::updates::check_rpi_startup().await;
6075 if !report.is_empty() {
6076 add_update_notices(&rpi_chat, &report);
6077 }
6078 rpi_tui.request_render(false);
6079 }));
6080
6081 if update_checks_enabled {
6082 let update_args = args.clone();
6083 let update_cwd = cwd.clone();
6084 let update_project_trusted = project_trusted;
6085 let chat = chat_container.clone();
6086 let tui = tui.clone();
6087 update_check_handles.push(tokio::spawn(async move {
6088 let resources = crate::session::package_resources_for_update_check(
6089 &update_args,
6090 &update_cwd,
6091 update_project_trusted,
6092 );
6093 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6094 {
6095 Ok(npm_command) => {
6096 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6097 Some(&resources),
6098 &npm_command,
6099 update_project_trusted.then_some(update_cwd.as_path()),
6100 )
6101 .await
6102 }
6103 Err(error) => {
6104 add_error_message(
6105 &chat,
6106 &format!("npm package update checks disabled: {error}"),
6107 );
6108 let git_resources = git_only_update_resources(resources);
6109 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6110 }
6111 };
6112 if !report.is_empty() {
6113 add_update_notices(&chat, &report);
6114 }
6115 tui.request_render(false);
6116 }));
6117 }
6118
6119 let drain_handle = if let Some(rx) = event_rx {
6121 let tui_drain = tui.clone();
6122 let state_drain = state.clone();
6123 let chat_drain = chat_container.clone();
6124 let lane_drain = lane.clone();
6125 Some(tokio::spawn(async move {
6126 drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6127 }))
6128 } else {
6129 None
6130 };
6131
6132 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6142 reload_context.mailbox.install(reload_sig_tx);
6143 let reload_tx = tx.clone();
6144 let reload_bridge_handle = tokio::spawn(async move {
6145 while reload_sig_rx.recv().await.is_some() {
6146 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6147 break; }
6149 }
6150 });
6151
6152 let tui_tick = tui.clone();
6157 let state_tick = state.clone();
6158 let tick_handle = tokio::spawn(async move {
6159 let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6168 rpi_tui::loader::SPINNER_FRAME_MS,
6169 ));
6170 interval.tick().await; loop {
6172 interval.tick().await;
6173 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6174 if working {
6175 if state_tick.bash_components.lock().unwrap().is_empty() {
6176 tui_tick.request_render_reusing_scroll_content();
6180 } else {
6181 tui_tick.request_render(false);
6183 }
6184 }
6185 }
6186 });
6187
6188 let running = Arc::new(std::sync::Mutex::new(true));
6190 let running_key = running.clone();
6191 let tx_for_key = tx.clone();
6192 let tui_for_key = tui.clone();
6193 let editor_for_key = editor.clone();
6194 let scroll_for_key = scroll_view.clone();
6195 let lane_for_key = lane.clone();
6196 let state_for_key = state.clone();
6197 let model_catalog_for_key = model_catalog_arc.clone();
6198 let js_for_key = reload_context.js_extension_session.clone();
6199 let js_dialog_for_key = js_dialog_bridge.clone();
6200 let ask_user_for_key = ask_user_bridge.clone();
6201 let ctx_for_key = ctx.clone();
6205 let registry_for_key = registry.clone();
6206 let keybindings_for_key = keybindings.clone();
6207 let double_escape_action = crate::settings::load_settings()
6208 .ok()
6209 .and_then(|settings| settings.double_escape_action)
6210 .unwrap_or_else(|| "tree".to_string())
6211 .to_ascii_lowercase();
6212
6213 let key_handle = tokio::task::spawn_blocking(move || {
6214 let mut last_escape_time = None;
6215 let mut last_text_key_at: Option<std::time::Instant> = None;
6218 let mut pending_event: Option<Event> = None;
6222 if crate::key_trace::enabled() {
6223 crate::key_trace::note(&format!(
6224 "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6225 std::process::id(),
6226 std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6227 crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6228 ));
6229 }
6230 loop {
6231 if !*running_key.lock().unwrap() {
6232 break;
6233 }
6234 if !state_for_key.selector_open()
6235 && !state_for_key.extension_dialog_open()
6236 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6237 {
6238 if let Some(request) = js_dialog_for_key.take_pending() {
6239 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6240 } else if let Some(request) = ask_user_for_key.take_pending() {
6241 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6242 }
6243 }
6244 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6245 let ev = if let Some(ev) = pending_event.take() {
6246 ev
6247 } else {
6248 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6251 Ok(true) => {}
6252 Ok(false) => continue,
6253 Err(_) => {
6254 state_for_key.cancel_js_preparation();
6255 let _ = tx_for_key.send(TuiMessage::Exit);
6256 break;
6257 }
6258 }
6259 let Ok(ev) = crossterm::event::read() else {
6260 state_for_key.cancel_js_preparation();
6261 let _ = tx_for_key.send(TuiMessage::Exit);
6262 break;
6263 };
6264 ev
6265 };
6266 if let Event::Resize(_cols, _rows) = ev {
6271 tui_for_key.refresh_size();
6272 if let Some(js) = &js_for_key {
6273 if js.custom_active() {
6274 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6275 tui_for_key.request_render(false);
6276 }
6277 }
6278 continue;
6279 }
6280 if let Event::Mouse(m) = ev {
6284 use crossterm::event::MouseEventKind;
6285 match m.kind {
6286 MouseEventKind::ScrollUp => {
6287 let delta = -MOUSE_WHEEL_SCROLL_LINES;
6288 if scroll_for_key.scroll_by(delta) != delta {
6289 tui_for_key.request_render_reusing_scroll_content();
6290 }
6291 }
6292 MouseEventKind::ScrollDown => {
6293 let delta = MOUSE_WHEEL_SCROLL_LINES;
6294 if scroll_for_key.scroll_by(delta) != delta {
6295 tui_for_key.request_render_reusing_scroll_content();
6296 }
6297 }
6298 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6299 *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6303 *state_for_key.selection_end.lock().unwrap() = None;
6304 }
6305 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6306 if state_for_key.selection_start.lock().unwrap().is_some() {
6310 *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6311 }
6312 }
6313 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6314 if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6316 let end = (m.column, m.row);
6317
6318 let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6327 *state_for_key.selection_end.lock().unwrap() = Some(end);
6328
6329 let auto_copy = crate::settings::load_settings()
6331 .ok()
6332 .and_then(|s| s.fullscreen_copy_on_select)
6333 .unwrap_or(true);
6334
6335 if auto_copy && is_drag {
6336 if let Some(selected_text) =
6338 extract_selected_text(&state_for_key.chat_container, start, end)
6339 {
6340 if !selected_text.trim().is_empty() {
6341 let _ = copy_to_clipboard(&selected_text);
6342 }
6343 }
6344 }
6345 }
6346 *state_for_key.selection_start.lock().unwrap() = None;
6347 *state_for_key.selection_end.lock().unwrap() = None;
6348 }
6349 _ => {}
6350 }
6351 continue;
6352 }
6353 let Event::Key(key) = ev else {
6354 if let Event::Paste(text) = ev {
6355 crate::key_trace::note(&format!(
6356 "paste event ({} bytes, bracketed paste supported)",
6357 text.len()
6358 ));
6359 let candidate = text.trim().trim_matches(['\"', '\'']);
6360 let path = std::path::PathBuf::from(candidate);
6361 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6362 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6363 add_image_preview(&state_for_key.chat_container, &image);
6364 state_for_key.queue_image(image);
6365 add_note_message(
6366 &state_for_key.chat_container,
6367 "Dropped image attached to the next prompt.",
6368 );
6369 tui_for_key.request_render(false);
6370 continue;
6371 }
6372 }
6373 editor_for_key.insert(&text);
6374 refresh_autocomplete(&state_for_key, &editor_for_key);
6375 tui_for_key.request_render_reusing_scroll_content();
6376 }
6377 continue;
6378 };
6379 if !should_dispatch_key(key.kind) {
6384 continue;
6385 }
6386 crate::key_trace::key(&key, "");
6390
6391 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6397 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6398 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6399 if prompt_abort && state_for_key.cancel_js_preparation() {
6400 state_for_key.set_status(RunStatus::Aborting);
6401 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6402 close_extension_editor(
6403 &state_for_key,
6404 &ctx_for_key.editor_container,
6405 &editor_for_key,
6406 &tui_for_key,
6407 );
6408 }
6409 js_dialog_for_key.cancel_open_requests();
6410 tui_for_key.set_render_suspended(false);
6411 tui_for_key.request_render(false);
6412 continue;
6413 }
6414
6415 if let Some(js) = &js_for_key {
6416 if js.custom_active() {
6417 let visible = js.custom_accepts_input();
6418 let data = key_event_to_input(key);
6419 if !data.is_empty() {
6420 if visible {
6426 let _ = js.send_custom_input(&data);
6427 continue;
6428 }
6429 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6430 if consumed {
6434 tui_for_key.request_render_reusing_scroll_content();
6435 continue;
6436 }
6437 }
6438 }
6439 }
6440
6441 if key.modifiers == KeyModifiers::CONTROL
6444 && key.code == KeyCode::Char('c')
6445 && state_for_key.selector_open()
6446 {
6447 let selector = state_for_key
6448 .active_selector
6449 .lock()
6450 .unwrap()
6451 .clone()
6452 .expect("selector_open guaranteed Some")
6453 .0;
6454 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6455 tui_for_key.request_render_reusing_scroll_content();
6456 continue;
6457 }
6458
6459 if state_for_key.extension_dialog_open() {
6463 let cancel = key.code == KeyCode::Esc
6464 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6465 if cancel {
6466 if !run_extension_cancel(&state_for_key) {
6467 close_extension_editor(
6468 &state_for_key,
6469 &ctx_for_key.editor_container,
6470 &editor_for_key,
6471 &tui_for_key,
6472 );
6473 }
6474 } else if let Some(extension_editor) = state_for_key
6475 .active_extension_editor
6476 .lock()
6477 .unwrap()
6478 .clone()
6479 {
6480 extension_editor.handle_key(key);
6481 } else if let Some(extension_input) =
6482 state_for_key.active_extension_input.lock().unwrap().clone()
6483 {
6484 extension_input.handle_key(key);
6485 }
6486 tui_for_key.request_render_reusing_scroll_content();
6487 continue;
6488 }
6489
6490 if keybinding_matches(
6495 &keybindings_for_key,
6496 &key,
6497 rpi_tui::keybindings::keys::CLEAR,
6498 ) {
6499 if !state_for_key.selector_open() && editor_for_key.has_selection() {
6500 editor_for_key.copy_selection();
6501 continue;
6502 }
6503 let status = *state_for_key.status.lock().unwrap();
6504 match status {
6505 RunStatus::Working => {
6506 state_for_key.set_status(RunStatus::Aborting);
6507 ask_user_for_key.cancel_all();
6509 if !run_extension_cancel(&state_for_key)
6511 && state_for_key.extension_dialog_open()
6512 {
6513 close_extension_editor(
6514 &state_for_key,
6515 &ctx_for_key.editor_container,
6516 &editor_for_key,
6517 &tui_for_key,
6518 );
6519 }
6520 let lane = lane_for_key.clone();
6521 tokio::spawn(async move {
6522 let _ = lane.abort().await;
6523 });
6524 }
6525 RunStatus::Aborting => {
6529 if key.kind != KeyEventKind::Repeat {
6542 emergency_exit(&tui_for_key);
6543 }
6544 }
6545 RunStatus::Idle => {
6546 let _ = tx_for_key.send(TuiMessage::Exit);
6547 }
6548 }
6549 continue;
6550 }
6551
6552 if state_for_key.selector_open() {
6557 if key.code == KeyCode::Esc {
6563 let (selector, _kind) = state_for_key
6564 .active_selector
6565 .lock()
6566 .unwrap()
6567 .clone()
6568 .expect("selector_open guaranteed Some");
6569 selector.handle_key(key);
6570 continue;
6571 }
6572 let (selector, _kind) = state_for_key
6573 .active_selector
6574 .lock()
6575 .unwrap()
6576 .clone()
6577 .expect("selector_open guaranteed Some");
6578 selector.handle_key(key);
6579 tui_for_key.request_render_reusing_scroll_content();
6580 continue;
6581 }
6582
6583 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6588 let status = *state_for_key.status.lock().unwrap();
6589 match status {
6590 RunStatus::Working => {
6591 state_for_key.set_status(RunStatus::Aborting);
6592 ask_user_for_key.cancel_all();
6593 if !run_extension_cancel(&state_for_key)
6594 && state_for_key.extension_dialog_open()
6595 {
6596 close_extension_editor(
6597 &state_for_key,
6598 &ctx_for_key.editor_container,
6599 &editor_for_key,
6600 &tui_for_key,
6601 );
6602 }
6603 let lane = lane_for_key.clone();
6604 tokio::spawn(async move {
6605 let _ = lane.abort().await;
6606 });
6607 continue;
6608 }
6609 RunStatus::Aborting => continue,
6610 RunStatus::Idle => {}
6611 }
6612 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
6613 editor_for_key.handle_key(key);
6615 refresh_autocomplete(&state_for_key, &editor_for_key);
6616 tui_for_key.request_render_reusing_scroll_content();
6617 continue;
6618 }
6619 let _ = tx_for_key.send(TuiMessage::Exit);
6620 continue;
6621 }
6622
6623 if keybinding_matches(
6628 &keybindings_for_key,
6629 &key,
6630 rpi_tui::keybindings::keys::INTERRUPT,
6631 ) {
6632 let status = *state_for_key.status.lock().unwrap();
6633 if status == RunStatus::Working {
6634 state_for_key.set_status(RunStatus::Aborting);
6635 ask_user_for_key.cancel_all();
6636 if !run_extension_cancel(&state_for_key)
6637 && state_for_key.extension_dialog_open()
6638 {
6639 close_extension_editor(
6640 &state_for_key,
6641 &ctx_for_key.editor_container,
6642 &editor_for_key,
6643 &tui_for_key,
6644 );
6645 }
6646 let lane = lane_for_key.clone();
6647 tokio::spawn(async move {
6648 let _ = lane.abort().await;
6649 });
6650 continue;
6651 }
6652 if state_for_key.user_bash_running() {
6658 state_for_key.cancel_user_bash();
6659 tui_for_key.request_render(false);
6660 continue;
6661 }
6662 if status == RunStatus::Idle
6663 && editor_for_key.get_text().trim().is_empty()
6664 && double_escape_action != "none"
6665 {
6666 let now = std::time::Instant::now();
6667 if double_escape_trigger(last_escape_time, now) {
6668 last_escape_time = None;
6669 match double_escape_action.as_str() {
6670 "tree" => {
6671 let _ = tx_for_key.send(TuiMessage::OpenTree);
6672 }
6673 "fork" => {
6674 let _ = tx_for_key.send(TuiMessage::ForkSession);
6675 }
6676 _ => {}
6677 }
6678 } else {
6679 last_escape_time = Some(now);
6680 }
6681 }
6682 continue;
6683 }
6684
6685 if keybinding_matches(
6688 &keybindings_for_key,
6689 &key,
6690 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6691 ) {
6692 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
6693 continue;
6694 }
6695
6696 if keybinding_matches(
6699 &keybindings_for_key,
6700 &key,
6701 rpi_tui::keybindings::keys::TOOLS_EXPAND,
6702 ) {
6703 let expanded = state_for_key.toggle_tool_outputs();
6704 let session = ctx_for_key.session.clone();
6706 tokio::spawn(async move {
6707 let _ = session
6708 .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6709 .await;
6710 });
6711 tui_for_key.request_render(false);
6712 continue;
6713 }
6714
6715 if keybinding_matches(
6717 &keybindings_for_key,
6718 &key,
6719 rpi_tui::keybindings::keys::THINKING_TOGGLE,
6720 ) {
6721 let hide = state_for_key.toggle_thinking();
6722 let session = ctx_for_key.session.clone();
6724 tokio::spawn(async move {
6725 let _ = session
6726 .set_value("display.hide_thinking", serde_json::json!(hide))
6727 .await;
6728 });
6729 tui_for_key.request_render(false);
6730 continue;
6731 }
6732
6733 if keybinding_matches(
6739 &keybindings_for_key,
6740 &key,
6741 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6742 ) {
6743 let current = state_for_key.current_model_id();
6744 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6747 if let Some(next) = cycle_next_model(&scope, ¤t) {
6748 state_for_key.set_current_model(&next);
6749 let lane = lane_for_key.clone();
6750 tokio::spawn(async move {
6751 let _ = lane.set_model(next).await;
6752 });
6753 tui_for_key.request_render_reusing_scroll_content();
6754 }
6755 continue;
6756 }
6757
6758 if keybinding_matches(
6761 &keybindings_for_key,
6762 &key,
6763 rpi_tui::keybindings::keys::THINKING_CYCLE,
6764 ) {
6765 let lane = lane_for_key.clone();
6766 let catalog = model_catalog_for_key.clone();
6767 let state = state_for_key.clone();
6768 tokio::spawn(async move {
6769 let Ok(current_model) = lane.get_model().await else {
6770 return;
6771 };
6772 let levels = catalog
6773 .iter()
6774 .find(|model| {
6775 model.provider == current_model.provider && model.id == current_model.id
6776 })
6777 .map(|model| model.supported_thinking_levels())
6778 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6779 if levels.is_empty() {
6780 return;
6781 }
6782 let current = lane
6783 .get_thinking_level()
6784 .await
6785 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6786 let next = levels
6787 .iter()
6788 .position(|level| *level == current)
6789 .map(|index| levels[(index + 1) % levels.len()])
6790 .unwrap_or(levels[0]);
6791 if lane.set_thinking_level(next).await.is_ok() {
6792 state
6793 .footer
6794 .set_thinking_level(Some(thinking_level_name(next)));
6795 state.tui.as_ref().map(|tui| tui.request_render(false));
6796 }
6797 });
6798 continue;
6799 }
6800
6801 if keybinding_matches(
6811 &keybindings_for_key,
6812 &key,
6813 rpi_tui::keybindings::keys::PASTE_IMAGE,
6814 ) {
6815 match read_clipboard_image() {
6816 Ok(Some(image)) => {
6817 add_image_preview(&state_for_key.chat_container, &image);
6818 state_for_key.queue_image(image);
6819 add_note_message(
6820 &state_for_key.chat_container,
6821 "Clipboard image attached to the next prompt.",
6822 );
6823 tui_for_key.request_render(false);
6824 continue;
6825 }
6826 Ok(None) | Err(_) => {}
6827 }
6828 if cfg!(windows) {
6839 if let Some(text) = read_clipboard_text() {
6840 if !text.is_empty() {
6841 editor_for_key.insert(&text);
6842 refresh_autocomplete(&state_for_key, &editor_for_key);
6843 tui_for_key.request_render_reusing_scroll_content();
6844 continue;
6845 }
6846 }
6847 }
6848 }
6849
6850 if key
6852 .modifiers
6853 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6854 && key.code == KeyCode::Char('F')
6855 {
6856 state_for_key.search.activate();
6857 state_for_key.search_bar.set_visible(true);
6858 state_for_key.search_bar.set_query("");
6859 state_for_key.search_bar.set_match_info(0, 0);
6860 tui_for_key.request_render(false);
6861 continue;
6862 }
6863
6864 if state_for_key.search.is_active() {
6866 let search_bar = state_for_key.search_bar.clone();
6867 match key.code {
6868 KeyCode::Esc => {
6869 state_for_key.search.deactivate();
6870 search_bar.set_visible(false);
6871 tui_for_key.request_render(false);
6872 continue;
6873 }
6874 KeyCode::Enter => {
6875 if key.modifiers.contains(KeyModifiers::SHIFT) {
6876 state_for_key.search.previous_match();
6877 } else {
6878 state_for_key.search.next_match();
6879 }
6880 let match_index = state_for_key.search.get_match_index();
6882 let match_count = state_for_key.search.get_match_count();
6883 search_bar.set_match_info(match_index, match_count);
6884 tui_for_key.request_render(false);
6885 continue;
6886 }
6887 KeyCode::Backspace => {
6888 state_for_key.search.backspace();
6889 let lines = collect_transcript_lines(&state_for_key.chat_container);
6891 state_for_key.search.find_matches(&lines);
6892 let query = state_for_key.search.get_query();
6894 let match_count = state_for_key.search.get_match_count();
6895 search_bar.set_query(&query);
6896 search_bar.set_match_info(0, match_count);
6897 tui_for_key.request_render(false);
6898 continue;
6899 }
6900 KeyCode::Char(c) => {
6901 state_for_key.search.append_char(c);
6902 let lines = collect_transcript_lines(&state_for_key.chat_container);
6904 state_for_key.search.find_matches(&lines);
6905 let query = state_for_key.search.get_query();
6907 let match_count = state_for_key.search.get_match_count();
6908 search_bar.set_query(&query);
6909 search_bar.set_match_info(0, match_count);
6910 tui_for_key.request_render(false);
6911 continue;
6912 }
6913 _ => continue,
6914 }
6915 }
6916
6917 if keybinding_matches(
6921 &keybindings_for_key,
6922 &key,
6923 rpi_tui::keybindings::keys::MODEL_SELECT,
6924 ) {
6925 if let Some(cmd) = registry_for_key.find("/model") {
6926 cmd.execute(&ctx_for_key, "");
6927 }
6928 continue;
6929 }
6930
6931 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6933 if accept_top_suggestion(&state_for_key, &editor_for_key) {
6934 tui_for_key.request_render_reusing_scroll_content();
6935 }
6936 continue;
6937 }
6938
6939 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6943 let delta = -transcript_page_size(scroll_for_key.viewport_height());
6944 if scroll_for_key.scroll_by(delta) != delta {
6945 tui_for_key.request_render_reusing_scroll_content();
6946 }
6947 continue;
6948 }
6949 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6950 let delta = transcript_page_size(scroll_for_key.viewport_height());
6951 if scroll_for_key.scroll_by(delta) != delta {
6952 tui_for_key.request_render_reusing_scroll_content();
6953 }
6954 continue;
6955 }
6956 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6957 scroll_for_key.scroll_to_start();
6958 tui_for_key.request_render_reusing_scroll_content();
6959 continue;
6960 }
6961 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
6962 scroll_for_key.scroll_to_end();
6963 tui_for_key.request_render_reusing_scroll_content();
6964 continue;
6965 }
6966
6967 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
6976 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6977 if editor_for_key.get_text().is_empty() || browsing {
6978 navigate_history(&state_for_key, &editor_for_key, -1);
6979 tui_for_key.request_render_reusing_scroll_content();
6980 continue;
6981 }
6982 }
6983 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
6984 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6985 if editor_for_key.get_text().is_empty() || browsing {
6986 navigate_history(&state_for_key, &editor_for_key, 1);
6987 tui_for_key.request_render_reusing_scroll_content();
6988 continue;
6989 }
6990 }
6991
6992 if keybinding_matches(
6997 &keybindings_for_key,
6998 &key,
6999 rpi_tui::keybindings::keys::DEQUEUE,
7000 ) {
7001 let _ = tx_for_key.send(TuiMessage::Dequeue);
7004 continue;
7005 }
7006
7007 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
7011 let prompt = editor_for_key.get_text().trim().to_string();
7012 if prompt.is_empty() {
7013 continue;
7014 }
7015 editor_for_key.clear();
7016 let status = *state_for_key.status.lock().unwrap();
7017 if status == RunStatus::Idle {
7018 if state_for_key.try_start_working() {
7019 add_user_message(&state_for_key.chat_container, &prompt);
7023 push_history(&state_for_key, &prompt);
7024 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7025 }
7026 } else {
7027 let message = AgentMessage::User(UserMessage::new(prompt, 0));
7031 let lane = lane_for_key.clone();
7032 let chat = state_for_key.chat_container.clone();
7033 let tui = tui_for_key.clone();
7034 let state = state_for_key.clone();
7035 tokio::spawn(async move {
7036 if let Err(error) = lane.follow_up(message).await {
7037 add_error_message(&chat, &format!("Could not queue message: {error}"));
7038 tui.request_render(false);
7039 return;
7040 }
7041 refresh_pending_messages(&state, &lane).await;
7042 tui.request_render(false);
7043 });
7044 }
7045 tui_for_key.request_render(false);
7046 continue;
7047 }
7048
7049 if cfg!(windows)
7066 && key.modifiers.is_empty()
7067 && matches!(
7068 key.code,
7069 KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7070 )
7071 {
7072 let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7079 if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7080 crossterm::event::read().ok()
7081 } else {
7082 None
7083 }
7084 });
7085 pending_event = stashed;
7086 if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7087 crate::key_trace::note(&format!(
7088 "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7089 gap_ms(last_text_key_at),
7090 ));
7091 editor_for_key.insert("\n");
7094 last_text_key_at = Some(std::time::Instant::now());
7095 refresh_autocomplete(&state_for_key, &editor_for_key);
7096 tui_for_key.request_render_reusing_scroll_content();
7097 continue;
7098 }
7099 crate::key_trace::note(&format!(
7100 "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7101 gap_ms(last_text_key_at),
7102 ));
7103 last_text_key_at = None;
7106 } else if matches!(
7107 key.code,
7108 KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7109 ) && !key
7110 .modifiers
7111 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7112 {
7113 last_text_key_at = Some(std::time::Instant::now());
7116 } else {
7117 last_text_key_at = None;
7120 }
7121
7122 editor_for_key.handle_key(key);
7128 refresh_autocomplete(&state_for_key, &editor_for_key);
7129 tui_for_key.request_render_reusing_scroll_content();
7130 }
7131 });
7132
7133 if let Some(reason) = crate::session::dispatch_session_event_async(
7139 reload_context,
7140 rpi_plugin_sdk::EventTag::SessionStart,
7141 )
7142 .await
7143 {
7144 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7145 }
7146
7147 let mut prompts: Vec<String> = Vec::new();
7149 if let Some(init) = initial {
7150 prompts.push(init);
7151 }
7152 for m in extra_messages {
7153 prompts.push(m.clone());
7154 }
7155 if prompts.is_empty() && lane.has_pending_resume().await {
7162 run_prompt_streaming(
7163 &lane,
7164 "",
7165 true,
7166 &tui,
7167 &state,
7168 drain_handle.is_some(),
7169 reload_context.js_extension_session.as_ref(),
7170 &js_dialog_bridge,
7171 args,
7172 Vec::new(),
7173 )
7174 .await;
7175 }
7176
7177 let mut images = initial_images;
7178 for prompt in prompts {
7179 if !*running.lock().unwrap() {
7180 break;
7181 }
7182 add_user_message(&chat_container, &prompt);
7186 tui.request_render(false);
7187 run_prompt_streaming(
7188 &lane,
7189 &prompt,
7190 false,
7191 &tui,
7192 &state,
7193 drain_handle.is_some(),
7194 reload_context.js_extension_session.as_ref(),
7195 &js_dialog_bridge,
7196 args,
7197 std::mem::take(&mut images),
7198 )
7199 .await;
7200 }
7201
7202 loop {
7204 if !*running.lock().unwrap() {
7205 break;
7206 }
7207 match rx.recv().await {
7208 Some(TuiMessage::UserInput(prompt)) => {
7209 editor.clear();
7214 let prompt_images = state.take_pending_images();
7215 if !prompt_images.is_empty() {
7216 add_note_message(
7217 &chat_container,
7218 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7219 );
7220 }
7221 run_prompt_streaming(
7222 &lane,
7223 &prompt,
7224 false,
7225 &tui,
7226 &state,
7227 drain_handle.is_some(),
7228 reload_context.js_extension_session.as_ref(),
7229 &js_dialog_bridge,
7230 args,
7231 prompt_images,
7232 )
7233 .await;
7234 }
7235 Some(TuiMessage::ExternalEditorResult(result)) => {
7236 match result {
7237 Ok(text) => {
7238 let cursor = text.chars().count();
7239 editor.set_text(&text);
7240 editor.set_cursor(0, cursor);
7241 add_note_message(&chat_container, "Draft updated from external editor.");
7242 }
7243 Err(error) => add_error_message(&chat_container, &error),
7244 }
7245 tui.request_render(false);
7246 }
7247 Some(TuiMessage::Dequeue) => {
7248 match lane.clear_queue().await {
7254 Ok(queued) => {
7255 let all: Vec<String> = queued
7256 .steering
7257 .iter()
7258 .chain(queued.follow_up.iter())
7259 .cloned()
7260 .collect();
7261 if !all.is_empty() {
7262 let queued_text = all.join("\n\n");
7263 let current = editor.get_text();
7264 let combined = if current.trim().is_empty() {
7265 queued_text
7266 } else {
7267 format!("{queued_text}\n\n{current}")
7268 };
7269 let cursor = combined.chars().count();
7270 editor.set_text(&combined);
7271 editor.set_cursor(0, cursor);
7272 }
7273 state.set_pending_queue(
7274 rpi_harness::agent_harness::QueuedMessages::default(),
7275 );
7276 tui.request_render(false);
7277 }
7278 Err(error) => {
7279 add_error_message(
7280 &chat_container,
7281 &format!("Could not restore queued messages: {error}"),
7282 );
7283 tui.request_render(false);
7284 }
7285 }
7286 }
7287 Some(TuiMessage::UserBashFinished(report)) => {
7288 state.finish_user_bash();
7291 let message = rpi_harness::messages::bash_execution_message(
7292 report.command,
7293 report.output,
7294 report.exit_code,
7295 report.cancelled,
7296 report.truncated,
7297 report.full_output_path,
7298 Some(report.exclude_from_context),
7299 now_ms(),
7300 );
7301 if *state.status.lock().unwrap() == RunStatus::Idle {
7305 if let Err(error) = lane.append_message(message).await {
7306 add_error_message(
7307 &chat_container,
7308 &format!("Could not record bash result: {error}"),
7309 );
7310 tui.request_render(false);
7311 }
7312 } else {
7313 state.pending_bash_messages.lock().unwrap().push(message);
7314 }
7315 }
7316 Some(TuiMessage::OpenTree) => {
7317 if *state.status.lock().unwrap() != RunStatus::Idle {
7318 add_note_message(
7319 &chat_container,
7320 "Wait for the current run to finish before opening the tree.",
7321 );
7322 tui.request_render(false);
7323 } else {
7324 open_tree_selector(
7325 &harness,
7326 &state,
7327 &editor_container,
7328 &editor,
7329 &tui,
7330 &chat_container,
7331 &tx,
7332 )
7333 .await;
7334 }
7335 }
7336 Some(TuiMessage::NavigateTree(entry_id)) => {
7337 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7338 Ok(result) => match result.outcome {
7339 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7340 chat_container.clear();
7341 add_welcome_message(&chat_container);
7342 render_session_history(
7343 &harness,
7344 &chat_container,
7345 state.markdown_transformer(),
7346 Some(state.extension_session.clone()),
7347 )
7348 .await;
7349 add_note_message(
7350 &chat_container,
7351 "Moved to the selected session entry.",
7352 );
7353 }
7354 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7355 add_error_message(&chat_container, &error.message);
7356 }
7357 _ => add_note_message(
7358 &chat_container,
7359 "The selected entry could not be opened.",
7360 ),
7361 },
7362 Err(error) => add_error_message(
7363 &chat_container,
7364 &format!("Could not navigate session tree: {error}"),
7365 ),
7366 }
7367 tui.request_render(false);
7368 }
7369 Some(TuiMessage::ClearChat) => {
7370 chat_container.clear();
7371 add_welcome_message(&chat_container);
7372 tui.request_render(false);
7373 }
7374 Some(TuiMessage::Compact) => {
7375 run_compact(&lane, &tui, &state).await;
7376 }
7377 Some(TuiMessage::Copy) => {
7378 copy_last_assistant(&state, &chat_container);
7379 tui.request_render(false);
7380 }
7381 Some(TuiMessage::Exit) => {
7382 *running.lock().unwrap() = false;
7383 break;
7384 }
7385 Some(TuiMessage::SwitchSession(id)) => {
7386 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7387 tui.request_render(false);
7388 }
7389 Some(TuiMessage::ImportSession(path)) => {
7390 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7391 tui.request_render(false);
7392 }
7393 Some(TuiMessage::ShareSession) => {
7394 share_session(&harness, &chat_container).await;
7395 tui.request_render(false);
7396 }
7397 Some(TuiMessage::SetSessionName(name)) => {
7398 let outcome = harness.session().set_name(Some(&name)).await;
7399 match outcome {
7400 Ok(_) => add_note_message(
7401 &chat_container,
7402 &format!("Session renamed to \"{name}\"."),
7403 ),
7404 Err(e) => add_error_message(
7405 &chat_container,
7406 &format!("Could not rename session: {e}"),
7407 ),
7408 }
7409 tui.request_render(false);
7410 }
7411 Some(TuiMessage::ExportSession) => {
7412 let file_name = agent_session
7413 .default_export_file_name(crate::export::ExportFormat::Markdown)
7414 .await;
7415 let path = cwd.join(&file_name);
7416 match agent_session.export_to_markdown(&path).await {
7417 Ok(_) => add_note_message(
7418 &chat_container,
7419 &format!("Exported session to {}", path.display()),
7420 ),
7421 Err(e) => {
7422 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7423 }
7424 }
7425 tui.request_render(false);
7426 }
7427 Some(TuiMessage::ExportSessionWithFormat(format)) => {
7428 let file_name = agent_session.default_export_file_name(format.clone()).await;
7429 let path = cwd.join(&file_name);
7430 match agent_session.export(format, &path).await {
7431 Ok(_) => add_note_message(
7432 &chat_container,
7433 &format!("Exported session to {}", path.display()),
7434 ),
7435 Err(e) => {
7436 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7437 }
7438 }
7439 tui.request_render(false);
7440 }
7441 Some(TuiMessage::ShowUsage) => {
7442 let stats = agent_session.usage_stats().await.ok();
7443 let total = agent_session.total_usage().await.ok();
7444 let snapshot = agent_session.snapshot().await.ok();
7445 match stats {
7446 Some(stats) => {
7447 let mut lines = vec![
7448 "## Usage".to_string(),
7449 String::new(),
7450 format!("- messages: {}", stats.message_count),
7451 format!("- total tokens: {}", stats.total_tokens),
7452 format!("- cached tokens: {}", stats.cached_tokens),
7453 format!("- uncached tokens: {}", stats.uncached_tokens),
7454 format!("- cost: ${:.4}", stats.cost_total),
7455 ];
7456 match stats.cache_hit_ratio() {
7457 Some(ratio) => {
7458 lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7459 }
7460 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7461 }
7462 if let Some(total) = total {
7463 lines.push(String::new());
7464 lines.push(format!(
7465 "- last cumulative input/output: {} / {}",
7466 total.input, total.output
7467 ));
7468 }
7469 if let Some(snapshot) = snapshot {
7470 lines.push(String::new());
7471 lines.push(format!("- lane: `{}`", snapshot.lane));
7472 lines.push(format!(
7473 "- leaf: `{}`",
7474 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7475 ));
7476 if let Some(active) = &snapshot.active {
7477 lines.push(format!(
7478 "- active operation: {} (`{}`)",
7479 active.kind.as_str(),
7480 active.run_id
7481 ));
7482 }
7483 }
7484 add_note_message(&chat_container, &lines.join("\n"));
7485 }
7486 None => {
7487 add_error_message(&chat_container, "Could not read session usage stats.")
7488 }
7489 }
7490 tui.request_render(false);
7491 }
7492 Some(TuiMessage::ForkSession) => {
7493 fork_session(&harness, &cwd, &chat_container, &state).await;
7494 tui.request_render(false);
7495 }
7496 Some(TuiMessage::ReloadExtensions) => {
7497 let reload_ctx = ctx.reload_context.clone();
7504 add_note_message(&chat_container, "Reloading extensions + resources…");
7505 tui.request_render(false);
7506 let outcome =
7507 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
7508 let fresh_transformer = build_markdown_transformer(
7517 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
7518 );
7519 state.set_markdown_transformer_with_reinstall(fresh_transformer);
7520 if outcome.had_errors {
7521 add_error_message(&chat_container, &outcome.summary);
7525 } else if outcome.had_warnings {
7526 add_note_message(
7531 &chat_container,
7532 &format!(
7533 "{} (with warnings — see stderr for details).",
7534 outcome.summary
7535 ),
7536 );
7537 } else {
7538 add_note_message(&chat_container, &outcome.summary);
7539 }
7540 tui.request_render(false);
7541 }
7542 None => break,
7543 }
7544 }
7545
7546 js_dialog_bridge.cancel_all();
7550 ask_user_bridge.shutdown();
7551 *running.lock().unwrap() = false;
7552 for handle in update_check_handles {
7553 handle.abort();
7554 let _ = handle.await;
7555 }
7556 if let Some(js) = &reload_context.js_extension_session {
7560 js.shutdown();
7561 }
7562 let _ = key_handle.await;
7565 tick_handle.abort();
7566 if let Some(handle) = drain_handle {
7567 handle.abort();
7568 }
7569 reload_context.mailbox.clear();
7573 reload_bridge_handle.abort();
7574 tui.stop(Default::default());
7575
7576 let session_id = harness.session().storage().metadata().id;
7580 if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7581 println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7582 }
7583 println!("\nGoodbye!");
7584 let _ = args;
7585
7586 0
7587}
7588
7589async fn ensure_js_runtime_before_prompt(
7599 js: Option<&crate::js_extensions::JsExtensionSession>,
7600 lane: &Arc<dyn AgentLane>,
7601 state: &Arc<TuiState>,
7602 dialog_bridge: &JsDialogBridge,
7603 args: &Args,
7604) -> bool {
7605 let Some(js) = js else {
7606 return true;
7607 };
7608 let cancellation = state.begin_js_preparation();
7609 let worker_cancellation = cancellation.clone();
7610 let js_for_start = js.clone();
7611 let mut worker = tokio::task::spawn_blocking(move || {
7612 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
7613 });
7614 let result = tokio::select! {
7615 result = &mut worker => result,
7616 _ = cancellation.cancelled() => {
7617 dialog_bridge.cancel_open_requests();
7618 let js_for_cancel = js.clone();
7619 let _ = tokio::task::spawn_blocking(move || {
7620 js_for_cancel.cancel_prompt_preparation();
7621 }).await;
7622 worker.await
7623 }
7624 };
7625 let was_cancelled = cancellation.is_cancelled();
7626 if was_cancelled {
7627 dialog_bridge.cancel_open_requests();
7628 let js_for_cancel = js.clone();
7629 let _ = tokio::task::spawn_blocking(move || {
7630 js_for_cancel.cancel_prompt_preparation();
7631 })
7632 .await;
7633 state.finish_js_preparation();
7634 dialog_bridge.reopen();
7635 return false;
7636 }
7637 state.finish_js_preparation();
7638 match result {
7639 Ok(Ok(())) => {
7640 if let Some(js_active) = js.active_tools() {
7644 let js_names = js.tool_names();
7645 let mut active = lane.get_active_tools().await.unwrap_or_default();
7646 active.retain(|name| {
7647 crate::session::tool_name_allowed(name, args)
7648 && !js_names.iter().any(|js_name| js_name == name)
7649 });
7650 active.extend(js_active.into_iter().filter(|name| {
7651 js_names.iter().any(|js_name| js_name == name)
7652 && crate::session::tool_name_allowed(name, args)
7653 }));
7654 active = crate::session::filter_active_tool_names(active, args);
7655 let _ = lane.set_active_tools(active).await;
7656 }
7657 }
7658 Ok(Err(error)) => {
7659 if args.verbose {
7660 eprintln!("warning: could not start JS extension runtime: {error}");
7661 }
7662 }
7663 Err(error) => {
7664 if args.verbose {
7665 eprintln!("warning: JS extension runtime worker failed: {error}");
7666 }
7667 }
7668 }
7669 true
7670}
7671
7672fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7673 std::thread::spawn(move || {
7674 let file = match tempfile::Builder::new()
7675 .prefix("rpi-draft-")
7676 .suffix(".md")
7677 .tempfile()
7678 {
7679 Ok(file) => file,
7680 Err(error) => {
7681 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7682 "Could not create editor file: {error}"
7683 ))));
7684 return;
7685 }
7686 };
7687 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7688 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7689 "Could not write editor file: {error}"
7690 ))));
7691 return;
7692 }
7693 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7694 .ok()
7695 .filter(|value| !value.trim().is_empty())
7696 .or_else(|| std::env::var("VISUAL").ok())
7697 .or_else(|| std::env::var("EDITOR").ok())
7698 .unwrap_or_else(|| {
7699 if cfg!(windows) {
7700 "notepad".to_string()
7701 } else {
7702 "nano".to_string()
7703 }
7704 });
7705 let status = std::process::Command::new(&editor)
7706 .arg(file.path())
7707 .status();
7708 let result = match status {
7709 Ok(status) if status.success() => std::fs::read_to_string(file.path())
7710 .map_err(|error| format!("Could not read editor file: {error}")),
7711 Ok(status) => Err(format!("External editor exited with {status}")),
7712 Err(error) => Err(format!(
7713 "Could not launch external editor `{editor}`: {error}"
7714 )),
7715 };
7716 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7717 });
7718}
7719
7720fn reconcile_streamed_assistant_completion(
7723 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
7724 last_assistant_text: &Mutex<String>,
7725 final_message: Option<&AssistantMessage>,
7726) {
7727 let final_blocks = final_message.map(assistant_blocks);
7728 let final_text = final_message.map(assistant_text);
7729 let component = current_assistant.lock().unwrap().take();
7730
7731 if let Some(component) = component {
7732 if let Some(blocks) = final_blocks.as_deref() {
7733 component.update_blocks(blocks);
7734 }
7735 component.set_streaming(false);
7736 }
7737
7738 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7739 *last_assistant_text.lock().unwrap() = text;
7740 }
7741}
7742
7743async fn run_prompt_streaming(
7748 lane: &Arc<dyn AgentLane>,
7749 prompt: &str,
7750 resume: bool,
7751 tui: &Arc<TuiAltScreen>,
7752 state: &Arc<TuiState>,
7753 streaming: bool,
7754 js: Option<&crate::js_extensions::JsExtensionSession>,
7755 dialog_bridge: &JsDialogBridge,
7756 args: &Args,
7757 images: Vec<rpi_ai::types::ImageContent>,
7758) {
7759 state.set_status(RunStatus::Working);
7768 tui.request_render(false);
7769 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
7770 state.set_status(RunStatus::Idle);
7771 tui.request_render(false);
7772 return;
7773 }
7774
7775 let outcome = if resume {
7776 match lane.resume_pending().await {
7779 Ok(Some(result)) => Ok(result),
7780 Ok(None) => {
7783 state.set_status(RunStatus::Idle);
7784 tui.request_render(false);
7785 return;
7786 }
7787 Err(error) => Err(error),
7788 }
7789 } else {
7790 lane.prompt_text(prompt, images).await
7791 };
7792
7793 if streaming {
7794 let final_message = match &outcome {
7799 Ok(result) => match &result.outcome {
7800 HarnessRunOutcome::Completed { final_message, .. }
7801 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7802 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7803 HarnessRunOutcome::Suspended { .. } => None,
7804 },
7805 Err(_) => None,
7806 };
7807 reconcile_streamed_assistant_completion(
7808 &state.current_assistant,
7809 &state.last_assistant_text,
7810 final_message,
7811 );
7812 }
7813
7814 state.set_status(RunStatus::Idle);
7815 refresh_pending_messages(state, lane).await;
7819
7820 if let Ok(result) = &outcome {
7825 let final_message: Option<&AssistantMessage> = match &result.outcome {
7826 HarnessRunOutcome::Completed { final_message, .. }
7827 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7828 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7829 HarnessRunOutcome::Suspended { .. } => None,
7830 };
7831 if let Some(a) = final_message {
7832 record_context_usage(&state.footer, a);
7833 }
7834 }
7835
7836 match outcome {
7837 Ok(result) => match &result.outcome {
7838 HarnessRunOutcome::Failed {
7839 error,
7840 final_message,
7841 ..
7842 } => {
7843 let already_rendered = streaming && final_message.is_some();
7851 if !already_rendered {
7852 let msg = final_message
7853 .as_ref()
7854 .and_then(|m| m.error_message.clone())
7855 .unwrap_or_else(|| format!("{error:?}"));
7856 add_error_message(&state.chat_container, &msg);
7857 }
7858 }
7859 HarnessRunOutcome::Suspended { .. } => {
7860 add_error_message(
7861 &state.chat_container,
7862 "Run suspended (deferred) — resume is not supported in v1.",
7863 );
7864 }
7865 HarnessRunOutcome::Aborted { final_message, .. } => {
7866 if !streaming {
7869 add_error_message(&state.chat_container, "Request aborted.");
7870 let _ = final_message; }
7872 }
7873 HarnessRunOutcome::Completed { final_message, .. } => {
7874 if !streaming {
7875 let text = assistant_text(final_message);
7876 if !text.is_empty() {
7877 add_assistant_message_blocking(
7878 &state.chat_container,
7879 &text,
7880 state.markdown_transformer(),
7881 );
7882 *state.last_assistant_text.lock().unwrap() = text;
7883 }
7884 }
7885 }
7886 },
7887 Err(e) => {
7888 add_error_message(&state.chat_container, &e.to_string());
7889 }
7890 }
7891
7892 tui.request_render(false);
7893}
7894
7895async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7900 state.set_status(RunStatus::Working);
7901 tui.request_render(false);
7902 match lane.compact(None).await {
7903 Ok(_) => {
7904 add_note_message(&state.chat_container, "Conversation compacted.");
7905 state.footer.set_context_tokens(None);
7909 }
7910 Err(e) => {
7911 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7912 }
7913 }
7914 state.set_status(RunStatus::Idle);
7915 tui.request_render(false);
7916}
7917
7918fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7922 let text = state.last_assistant_text.lock().unwrap().clone();
7923 if text.is_empty() {
7924 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7925 return;
7926 }
7927 if copy_to_clipboard(&text) {
7928 add_note_message(chat, "Copied last reply to the clipboard.");
7929 } else {
7930 let preview: String = text.chars().take(200).collect();
7933 add_note_message(
7934 chat,
7935 &format!(
7936 "Clipboard unavailable. Last reply: {preview}{}",
7937 if text.chars().count() > 200 {
7938 "…"
7939 } else {
7940 ""
7941 }
7942 ),
7943 );
7944 }
7945}
7946
7947fn extract_selected_text(
7950 chat_container: &Arc<Container>,
7951 start: (u16, u16),
7952 end: (u16, u16),
7953) -> Option<String> {
7954 use rpi_tui::Component;
7955
7956 let lines = chat_container.render(80); if lines.is_empty() {
7959 return None;
7960 }
7961
7962 let (start_row, end_row) = if start.1 <= end.1 {
7964 (start.1 as usize, end.1 as usize)
7965 } else {
7966 (end.1 as usize, start.1 as usize)
7967 };
7968
7969 let start_row = start_row.min(lines.len().saturating_sub(1));
7971 let end_row = end_row.min(lines.len().saturating_sub(1));
7972
7973 if start_row > end_row {
7974 return None;
7975 }
7976
7977 let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7979
7980 if selected_lines.is_empty() {
7981 return None;
7982 }
7983
7984 Some(selected_lines.join("\n"))
7985}
7986
7987#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7991fn copy_to_clipboard(text: &str) -> bool {
7992 match arboard::Clipboard::new() {
7993 Ok(mut cb) => cb.set_text(text).is_ok(),
7994 Err(_) => false,
7995 }
7996}
7997
7998#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7999fn copy_to_clipboard(_text: &str) -> bool {
8000 false
8001}
8002
8003#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8007fn read_clipboard_text() -> Option<String> {
8008 let mut clipboard = arboard::Clipboard::new().ok()?;
8009 clipboard.get_text().ok()
8010}
8011
8012#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8013fn read_clipboard_text() -> Option<String> {
8014 None
8015}
8016
8017#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8021fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8022 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8023 let image = match clipboard.get_image() {
8024 Ok(image) => image,
8025 Err(_) => return Ok(None),
8026 };
8027 let width =
8028 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8029 let height =
8030 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8031 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8032 return Err("clipboard image dimensions are outside the supported range".into());
8033 }
8034 let mut bytes = Vec::new();
8035 {
8036 let mut encoder = png::Encoder::new(&mut bytes, width, height);
8037 encoder.set_color(png::ColorType::Rgba);
8038 encoder.set_depth(png::BitDepth::Eight);
8039 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8040 writer
8041 .write_image_data(&image.bytes)
8042 .map_err(|e| e.to_string())?;
8043 }
8044 Ok(Some(rpi_ai::types::ImageContent {
8045 kind: rpi_ai::types::ImageContentType,
8046 data: base64::engine::general_purpose::STANDARD.encode(bytes),
8047 mime_type: "image/png".into(),
8048 }))
8049}
8050
8051fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8052 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8053 let mut options = ImageOptions::default();
8054 options.width = Some(48);
8055 options.alt_text = Some("Attached image".into());
8056 chat.add_child(Arc::new(Image::from_data(bytes, options)));
8057 chat.add_child(Arc::new(Spacer::new(1)));
8058 }
8059}
8060
8061#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8062fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8063 Ok(None)
8064}
8065
8066fn add_assistant_message_blocking(
8073 container: &Arc<Container>,
8074 text: &str,
8075 transformer: Option<MarkdownTransformer>,
8076) {
8077 if text.is_empty() {
8078 return;
8079 }
8080 let msg = Arc::new(AssistantMessageComponent::new(
8081 AssistantMessageOptions::default(),
8082 ));
8083 if let Some(t) = &transformer {
8084 msg.set_markdown_transformer(Some(t.clone()));
8085 }
8086 msg.update_text(text);
8087 container.add_child(msg);
8088 container.add_child(Arc::new(Spacer::new(1)));
8089}
8090
8091async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8102 if let Ok(queued) = lane.queued_messages().await {
8103 state.set_pending_queue(queued);
8104 }
8105}
8106
8107async fn drain_agent_events(
8108 mut rx: broadcast::Receiver<AgentEvent>,
8109 tui: Arc<TuiAltScreen>,
8110 state: Arc<TuiState>,
8111 chat: Arc<Container>,
8112 lane: Arc<dyn AgentLane>,
8113) {
8114 loop {
8115 match rx.recv().await {
8116 Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8117 Err(broadcast::error::RecvError::Lagged(_)) => {
8118 continue;
8121 }
8122 Err(broadcast::error::RecvError::Closed) => break,
8123 }
8124 }
8125}
8126
8127async fn handle_agent_event(
8130 event: AgentEvent,
8131 tui: &Arc<TuiAltScreen>,
8132 state: &Arc<TuiState>,
8133 chat: &Arc<Container>,
8134 lane: &Arc<dyn AgentLane>,
8135) {
8136 match event {
8137 AgentEvent::AgentStart => {
8138 state.set_status(RunStatus::Working);
8139 tui.request_render(false);
8140 }
8141
8142 AgentEvent::AgentEnd { .. } => {
8143 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8145 comp.set_streaming(false);
8146 }
8147 state.set_status(RunStatus::Idle);
8148 let pending: Vec<AgentMessage> =
8152 std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8153 for message in pending {
8154 if let Err(error) = lane.append_message(message).await {
8155 add_error_message(chat, &format!("Could not record bash result: {error}"));
8156 }
8157 }
8158 refresh_pending_messages(state, lane).await;
8161 tui.request_render(false);
8162 }
8163
8164 AgentEvent::RetryScheduled {
8165 attempt,
8166 max_retries,
8167 delay_ms,
8168 ..
8169 } => {
8170 state.show_retry(attempt, max_retries, delay_ms);
8171 tui.request_render(false);
8172 }
8173
8174 AgentEvent::TurnStart => {
8175 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8178 comp.set_streaming(false);
8179 }
8180 }
8181
8182 AgentEvent::TurnEnd {
8183 message,
8184 tool_results,
8185 } => {
8186 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8188 if let AgentMessage::Assistant(a) = &message {
8189 comp.update_blocks(&assistant_blocks(a));
8190 }
8191 comp.set_streaming(false);
8192 }
8193 let tools = state.tool_components.lock().unwrap();
8198 for tr in &tool_results {
8199 if tools.contains_key(&tr.tool_call_id) {
8200 let _ = tr;
8203 }
8204 }
8205 drop(tools);
8206 tui.request_render(false);
8207 }
8208
8209 AgentEvent::MessageStart { message } => match message {
8210 AgentMessage::Assistant(a) => {
8211 let comp = Arc::new(AssistantMessageComponent::new(
8212 AssistantMessageOptions::default(),
8213 ));
8214 if let Some(t) = state.markdown_transformer() {
8219 comp.set_markdown_transformer(Some(t));
8220 }
8221 comp.set_hide_thinking(state.hide_thinking());
8222 comp.set_streaming(true);
8223 comp.update_blocks(&assistant_blocks(&a));
8226 chat.add_child(comp.clone());
8227 chat.add_child(Arc::new(Spacer::new(1)));
8230 *state.current_assistant.lock().unwrap() = Some(comp);
8231 tui.request_render(false);
8232 }
8233 AgentMessage::Custom(custom) => {
8234 let payload = serde_json::json!({
8235 "customType": custom.role,
8236 "content": custom.content,
8237 "details": custom.data,
8238 "expanded": false,
8239 "outputPad": 1,
8240 });
8241 if let Some(component) = extension_message_component(
8242 &state.extension_session,
8243 &custom.role,
8244 &payload,
8245 state.markdown_transformer(),
8246 ) {
8247 chat.add_child(component);
8248 chat.add_child(Arc::new(Spacer::new(1)));
8249 tui.request_render(false);
8250 } else {
8251 add_note_message(chat, &custom_message_fallback(&custom));
8252 tui.request_render(false);
8253 }
8254 }
8255 AgentMessage::User(user) => {
8265 add_user_message(chat, &user_message_text(&user));
8266 refresh_pending_messages(state, lane).await;
8268 tui.request_render(false);
8269 }
8270 _ => {}
8273 },
8274
8275 AgentEvent::MessageUpdate {
8276 message,
8277 assistant_message_event,
8278 } => {
8279 {
8280 let a: &rpi_ai::types::AssistantMessage = &message;
8285 let text = assistant_text(a);
8286 let mut saw_bash_tool_call = false;
8287 for c in &a.content {
8292 if let Content::ToolCall(tc) = c {
8293 if tc.name.trim().is_empty() {
8297 continue;
8298 }
8299 if tc.name == "bash" {
8300 let command = tc
8306 .arguments
8307 .get("command")
8308 .and_then(|v| v.as_str())
8309 .unwrap_or("");
8310 if command.trim().is_empty() {
8317 continue;
8318 }
8319 saw_bash_tool_call = true;
8320 let mut bash = state.bash_components.lock().unwrap();
8321 if let Some(existing) = bash.get(&tc.id) {
8322 existing.set_command(command);
8327 } else {
8328 let comp = Arc::new(BashExecutionComponent::new(command));
8329 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8330 chat.add_child(comp.clone());
8331 bash.insert(tc.id.clone(), comp);
8332 }
8333 } else {
8334 let mut tools = state.tool_components.lock().unwrap();
8335 let display_args = if is_ask_user_tool(&tc.name) {
8336 ask_user_args_display(&tc.arguments)
8337 } else {
8338 tc.arguments.to_string()
8339 };
8340 if let Some(existing) = tools.get(&tc.id) {
8341 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
8345 existing.set_args(&display_args);
8346 }
8347 } else {
8348 let comp =
8349 Arc::new(ToolExecutionComponent::new(&tc.name, &display_args));
8350 if is_ask_user_tool(&tc.name) {
8351 comp.set_display_title("ASK USER");
8352 }
8353 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8354 comp.set_running();
8355 chat.add_child(comp.clone());
8356 tools.insert(tc.id.clone(), comp);
8357 }
8358 }
8359 }
8360 }
8361 if saw_bash_tool_call {
8366 state.sync_working_loader_with_bash();
8367 }
8368 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
8370 comp.update_blocks(&assistant_blocks(a));
8373 }
8374 *state.last_assistant_text.lock().unwrap() = text;
8375 tui.request_render(false);
8376 }
8377 }
8378
8379 AgentEvent::MessageEnd { message } => {
8380 if let AgentMessage::Assistant(a) = &message {
8381 let text = assistant_text(a);
8382 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8383 comp.update_blocks(&assistant_blocks(a));
8384 comp.set_streaming(false);
8385 }
8386 if !text.is_empty() {
8388 *state.last_assistant_text.lock().unwrap() = text;
8389 }
8390 let usage = &a.usage;
8396 state.footer.add_usage(
8398 usage.input,
8399 usage.output,
8400 usage.cache_read,
8401 usage.cache_write,
8402 usage.cost.total,
8403 );
8404 let denom = usage.input + usage.cache_read + usage.cache_write;
8405 if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8406 state
8407 .footer
8408 .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8409 }
8410 record_context_usage(&state.footer, a);
8414 let miss = state
8415 .cache_tracker
8416 .lock()
8417 .unwrap()
8418 .observe(a, &rpi_harness::cache_stats::NoPrices);
8419 let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8423 if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8424 let cost = if miss.missed_cost > 0.0 {
8425 format!(" (~${:.4})", miss.missed_cost)
8426 } else {
8427 String::new()
8428 };
8429 add_note_message(
8430 &state.chat_container,
8431 &format!(
8432 "Cache miss: {} tokens re-billed{}",
8433 format_tokens(miss.missed_tokens),
8434 cost
8435 ),
8436 );
8437 }
8438 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8439 add_note_message(chat, &text);
8440 }
8441 if let Some(error) = assistant_error_text(a) {
8446 add_error_message(chat, &error);
8447 }
8448 }
8449 tui.request_render(false);
8450 }
8451
8452 AgentEvent::ToolExecutionStart {
8453 tool_call_id,
8454 tool_name,
8455 args,
8456 } => {
8457 if tool_name.trim().is_empty() {
8460 return;
8461 }
8462 if tool_name == "bash" {
8463 let command = args
8468 .get("command")
8469 .and_then(|v| v.as_str())
8470 .unwrap_or("")
8471 .to_string();
8472 if command.trim().is_empty() {
8476 return;
8477 }
8478 let mut bash_map = state.bash_components.lock().unwrap();
8479 if let Some(existing) = bash_map.get(&tool_call_id) {
8480 existing.set_command(&command);
8485 } else {
8486 let comp = Arc::new(BashExecutionComponent::new(command));
8487 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8488 chat.add_child(comp.clone());
8489 bash_map.insert(tool_call_id.clone(), comp);
8490 }
8491 } else {
8492 let _comp = {
8493 let mut tools = state.tool_components.lock().unwrap();
8494 if let Some(existing) = tools.get(&tool_call_id) {
8495 if is_ask_user_tool(&tool_name) {
8496 existing.set_display_title("ASK USER");
8497 existing.set_args(&ask_user_args_display(&args));
8498 } else {
8499 existing.set_args(&args.to_string());
8500 }
8501 existing.clone()
8502 } else {
8503 let display_args = if is_ask_user_tool(&tool_name) {
8504 ask_user_args_display(&args)
8505 } else {
8506 args.to_string()
8507 };
8508 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8509 if is_ask_user_tool(&tool_name) {
8510 comp.set_display_title("ASK USER");
8511 }
8512 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8513 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8518 comp.set_skill_name(skill);
8519 }
8520 comp.set_running();
8521 chat.add_child(comp.clone());
8522 tools.insert(tool_call_id.clone(), comp.clone());
8523 comp
8524 }
8525 };
8526 }
8527 state.sync_working_loader_with_bash();
8528 tui.request_render(false);
8529 }
8530
8531 AgentEvent::ToolExecutionUpdate {
8532 tool_call_id,
8533 tool_name,
8534 args,
8535 partial_result,
8536 } => {
8537 if tool_name.trim().is_empty() {
8538 return;
8539 }
8540 let partial_text = if is_ask_user_tool(&tool_name) {
8541 ask_user_progress_text(&args, &partial_result)
8542 } else {
8543 tool_result_text(&partial_result)
8544 };
8545 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
8546 if tool_name == "bash" {
8547 let chunk = partial_text;
8552 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
8553 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
8556 bash.set_command(command);
8557 }
8558 if has_partial_payload {
8559 bash.append_output(&chunk);
8560 }
8561 } else if has_partial_payload {
8562 }
8568 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
8569 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8570 comp.set_skill_name(skill);
8571 }
8572 if is_ask_user_tool(&tool_name) {
8573 comp.set_display_title("ASK USER");
8574 comp.set_args(&ask_user_args_display(&args));
8575 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
8576 comp.set_args(&args.to_string());
8577 }
8578 if has_partial_payload {
8581 comp.set_result(&partial_text, false);
8582 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
8583 }
8584 } else if has_partial_payload {
8585 let display_args = if is_ask_user_tool(&tool_name) {
8590 ask_user_args_display(&args)
8591 } else {
8592 args.to_string()
8593 };
8594 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8595 if is_ask_user_tool(&tool_name) {
8596 comp.set_display_title("ASK USER");
8597 }
8598 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8599 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8600 comp.set_skill_name(skill);
8601 }
8602 comp.set_running();
8603 comp.set_result(&partial_text, false);
8604 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
8605 chat.add_child(comp.clone());
8606 state
8607 .tool_components
8608 .lock()
8609 .unwrap()
8610 .insert(tool_call_id.clone(), comp.clone());
8611 }
8612 state.sync_working_loader_with_bash();
8613 tui.request_render(false);
8614 }
8615
8616 AgentEvent::ToolExecutionEnd {
8617 tool_call_id,
8618 tool_name,
8619 result,
8620 is_error,
8621 } => {
8622 if tool_name.trim().is_empty() {
8623 return;
8624 }
8625 if tool_name == "bash" {
8626 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
8627 if let Some(bash) = bash {
8628 finalize_bash(&bash, &result, is_error);
8629 } else {
8630 let command = result
8633 .details
8634 .get("command")
8635 .and_then(|v| v.as_str())
8636 .unwrap_or("")
8637 .to_string();
8638 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
8639 state.sync_working_loader_with_bash();
8640 tui.render_now(false);
8643 return;
8644 }
8645 let comp = Arc::new(BashExecutionComponent::new(command));
8646 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8647 comp.append_output(&tool_result_text(&result));
8648 finalize_bash(&comp, &result, is_error);
8649 chat.add_child(comp);
8650 }
8651 } else {
8652 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
8653 if let Some(comp) = comp {
8654 let result_text = if is_ask_user_tool(&tool_name) {
8655 ask_user_result_text(&result)
8656 } else {
8657 tool_result_text(&result)
8658 };
8659 comp.set_result(&result_text, is_error);
8660 if !is_ask_user_tool(&tool_name) {
8661 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8662 }
8663 } else {
8664 let comp = Arc::new(ToolExecutionComponent::new(
8667 &tool_name,
8668 &if is_ask_user_tool(&tool_name) {
8669 ask_user_args_display(&serde_json::Value::Null)
8670 } else {
8671 "".to_string()
8672 },
8673 ));
8674 if is_ask_user_tool(&tool_name) {
8675 comp.set_display_title("ASK USER");
8676 }
8677 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8678 let result_text = if is_ask_user_tool(&tool_name) {
8679 ask_user_result_text(&result)
8680 } else {
8681 tool_result_text(&result)
8682 };
8683 comp.set_result(&result_text, is_error);
8684 if !is_ask_user_tool(&tool_name) {
8685 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8686 }
8687 chat.add_child(comp.clone());
8688 }
8689 }
8690 state.sync_working_loader_with_bash();
8691 tui.render_now(false);
8697 }
8698 }
8699}
8700
8701fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
8709 if message.stop_reason == rpi_ai::StopReason::Aborted
8710 || message.stop_reason == rpi_ai::StopReason::Error
8711 {
8712 return;
8713 }
8714 let tokens = message.usage.context_tokens();
8715 if tokens > 0 {
8716 footer.set_context_tokens(Some(tokens));
8717 }
8718}
8719
8720fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
8724 let fallback = match message.stop_reason {
8725 rpi_ai::StopReason::Error => "Provider request failed.",
8726 rpi_ai::StopReason::Aborted => "Request aborted.",
8727 _ => return None,
8728 };
8729 Some(
8730 message
8731 .error_message
8732 .as_deref()
8733 .filter(|text| !text.trim().is_empty())
8734 .unwrap_or(fallback)
8735 .to_string(),
8736 )
8737}
8738
8739fn finalize_bash(
8743 comp: &Arc<BashExecutionComponent>,
8744 result: &rpi_agent::AgentToolResult,
8745 is_error: bool,
8746) {
8747 let exit_code = if is_error { Some(1) } else { Some(0) };
8750 let truncated = result
8751 .details
8752 .get("truncation")
8753 .and_then(|t| t.get("truncated"))
8754 .and_then(|v| v.as_bool())
8755 .unwrap_or(false);
8756 let full_output_path = result
8757 .details
8758 .get("full_output_path")
8759 .and_then(|v| v.as_str())
8760 .map(|s| s.to_string());
8761 let truncation = BashTruncation {
8762 truncated,
8763 full_output_path,
8764 };
8765 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
8769 comp.set_command(command);
8770 }
8771 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
8773}
8774
8775fn apply_edit_diff(
8780 comp: &Arc<ToolExecutionComponent>,
8781 tool_name: &str,
8782 details: &serde_json::Value,
8783 tui: &Arc<TuiAltScreen>,
8784) {
8785 if tool_name != "edit" {
8786 return;
8787 }
8788 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
8789 return;
8790 };
8791 if diff_text.is_empty() {
8792 return;
8793 }
8794 let width = tui.width();
8795 let lines = render_diff(diff_text, width);
8796 comp.set_diff(lines);
8797}
8798
8799fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
8804 if tool_name != "read" {
8805 return None;
8806 }
8807 let path = args.get("path").and_then(|value| value.as_str())?;
8808 let normalized = path.replace('\\', "/");
8809 let file_name = normalized.rsplit('/').next()?;
8810 if !file_name.eq_ignore_ascii_case("SKILL.md") {
8811 return None;
8812 }
8813 normalized
8814 .trim_end_matches('/')
8815 .rsplit('/')
8816 .nth(1)
8817 .filter(|name| !name.is_empty())
8818 .map(str::to_string)
8819}
8820
8821fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
8826 use rpi_agent::TextContentOrImage;
8827 let mut parts: Vec<String> = Vec::new();
8828 for c in &result.content {
8829 if let TextContentOrImage::Text(t) = c {
8830 parts.push(t.text.clone());
8831 }
8832 }
8833 parts.join("\n")
8834}
8835
8836fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
8840 !text.trim().is_empty() || !result.details.is_null()
8841}
8842
8843fn is_ask_user_tool(tool_name: &str) -> bool {
8846 matches!(tool_name, "ask_user" | "ask_user_question")
8847}
8848
8849fn ask_user_args_display(args: &serde_json::Value) -> String {
8852 if args.is_null() {
8853 return String::new();
8854 }
8855 if let Some(summary) = args
8856 .get("summary")
8857 .and_then(serde_json::Value::as_str)
8858 .map(str::trim)
8859 .filter(|text| !text.is_empty())
8860 {
8861 return format!("Confirmation requested: {summary}");
8862 }
8863
8864 let mut lines: Vec<String> = Vec::new();
8865 let questions: Vec<&serde_json::Value> = args
8866 .get("questions")
8867 .and_then(serde_json::Value::as_array)
8868 .filter(|items| !items.is_empty())
8869 .map(|items| items.iter().collect())
8870 .unwrap_or_else(|| vec![args]);
8871
8872 for question in questions {
8873 let prompt = question
8874 .get("question")
8875 .and_then(serde_json::Value::as_str)
8876 .or_else(|| args.get("question").and_then(serde_json::Value::as_str))
8877 .map(str::trim)
8878 .unwrap_or_default();
8879 if prompt.is_empty() {
8880 continue;
8881 }
8882 if let Some(header) = question
8883 .get("header")
8884 .and_then(serde_json::Value::as_str)
8885 .map(str::trim)
8886 .filter(|text| !text.is_empty())
8887 {
8888 lines.push(format!("{header}: {prompt}"));
8889 } else {
8890 lines.push(prompt.to_string());
8891 }
8892 let context = question
8893 .get("context")
8894 .and_then(serde_json::Value::as_str)
8895 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
8896 .map(str::trim)
8897 .filter(|text| !text.is_empty());
8898 if let Some(context) = context {
8899 lines.push(format!(" {context}"));
8900 }
8901 let options = question
8902 .get("options")
8903 .and_then(serde_json::Value::as_array)
8904 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
8905 if let Some(options) = options {
8906 let labels: Vec<String> = options
8907 .iter()
8908 .filter_map(|option| {
8909 option
8910 .get("title")
8911 .and_then(serde_json::Value::as_str)
8912 .or_else(|| option.as_str())
8913 .map(str::trim)
8914 .filter(|label| !label.is_empty())
8915 .map(str::to_string)
8916 })
8917 .collect();
8918 if !labels.is_empty() {
8919 lines.push(format!("Choices: {}", labels.join(", ")));
8920 }
8921 }
8922 if let Some(suggest) = question
8923 .get("suggest")
8924 .and_then(serde_json::Value::as_str)
8925 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
8926 .map(str::trim)
8927 .filter(|text| !text.is_empty())
8928 {
8929 lines.push(format!("Suggestions: {suggest}"));
8930 }
8931 }
8932
8933 lines.join("\n")
8934}
8935
8936fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
8939 let text = tool_result_text(result);
8940 if !text.trim().is_empty() {
8941 return text;
8942 }
8943 let args_text = ask_user_args_display(args);
8944 if args_text.trim().is_empty() {
8945 "Waiting for your answer…".to_string()
8946 } else {
8947 args_text
8948 }
8949}
8950
8951fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
8954 let text = tool_result_text(result);
8955 if text.trim().is_empty() {
8956 "Answer recorded.".to_string()
8957 } else {
8958 text
8959 }
8960}
8961
8962fn open_selector(
8970 state: &Arc<TuiState>,
8971 editor_container: &Arc<Container>,
8972 editor: &Arc<Editor>,
8973 tui: &Arc<TuiAltScreen>,
8974 list: Arc<SelectList>,
8975 kind: SelectorKind,
8976) {
8977 open_selector_with_view(
8978 state,
8979 editor_container,
8980 editor,
8981 tui,
8982 SelectorView::List(list.clone()),
8983 list,
8984 kind,
8985 );
8986}
8987
8988fn open_searchable_selector(
8993 state: &Arc<TuiState>,
8994 editor_container: &Arc<Container>,
8995 editor: &Arc<Editor>,
8996 tui: &Arc<TuiAltScreen>,
8997 title: Option<&str>,
8998 description: Option<&str>,
8999 list: Arc<SelectList>,
9000 kind: SelectorKind,
9001) -> Arc<SearchableSelectList> {
9002 let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9003 open_selector_with_view(
9004 state,
9005 editor_container,
9006 editor,
9007 tui,
9008 SelectorView::Searchable(searchable.clone()),
9009 searchable.clone(),
9010 kind,
9011 );
9012 searchable.set_focused(true);
9016 searchable
9017}
9018
9019fn open_selector_with_view<V: Component + 'static>(
9027 state: &Arc<TuiState>,
9028 editor_container: &Arc<Container>,
9029 editor: &Arc<Editor>,
9030 tui: &Arc<TuiAltScreen>,
9031 view_router: SelectorView,
9032 view: Arc<V>,
9033 kind: SelectorKind,
9034) {
9035 dispatch_ui_prompt_event(state);
9037
9038 editor.set_focused(false);
9040 state.autocomplete_container.clear();
9043 editor_container.clear();
9045 editor_container.add_child(view.clone());
9046 *state.active_selector.lock().unwrap() = Some((view_router, kind));
9047 let focused: Arc<dyn Component> = view;
9048 tui.set_focus(Some(focused));
9049 tui.request_render(false);
9050}
9051
9052fn close_selector(
9055 state: &Arc<TuiState>,
9056 editor_container: &Arc<Container>,
9057 editor: &Arc<Editor>,
9058 tui: &Arc<TuiAltScreen>,
9059) {
9060 dispatch_ui_prompt_event_end(state);
9062
9063 editor_container.clear();
9064 editor_container.add_child(editor.clone());
9065 state.autocomplete_container.clear();
9066 editor.set_focused(true);
9067 *state.active_selector.lock().unwrap() = None;
9068 *state.active_extension_cancel.lock().unwrap() = None;
9069 tui.set_focus(Some(editor.clone()));
9070 tui.request_render(false);
9071}
9072
9073fn open_model_selector(
9079 state: &Arc<TuiState>,
9080 editor_container: &Arc<Container>,
9081 editor: &Arc<Editor>,
9082 tui: &Arc<TuiAltScreen>,
9083 catalog: &[rpi_ai::Model],
9084 lane: &Arc<dyn AgentLane>,
9085 lane_model_id: &str,
9086 chat: &Arc<Container>,
9087 initial_search: Option<&str>,
9088) -> Option<Arc<SearchableSelectList>> {
9089 let items = model_selector_items(catalog, lane_model_id);
9090 if items.is_empty() {
9091 add_note_message(
9092 chat,
9093 "No models in the catalog. Use --model at startup to select one.",
9094 );
9095 tui.request_render(false);
9096 return None;
9097 }
9098 let list = Arc::new(SelectList::new(items, 10));
9099
9100 let catalog_arc = catalog.to_vec();
9104 let state_sel = state.clone();
9105 let ec_sel = editor_container.clone();
9106 let editor_sel = editor.clone();
9107 let tui_sel = tui.clone();
9108 let chat_sel = chat.clone();
9109 let lane_sel = lane.clone();
9110 list.on_select(Arc::new(move |item| {
9111 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9112 add_note_message(
9113 &chat_sel,
9114 &format!("Model {} not found in catalog.", item.label),
9115 );
9116 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9117 return;
9118 };
9119 state_sel.set_current_model(&model);
9120 let lane = lane_sel.clone();
9121 tokio::spawn(async move {
9122 let _ = lane.set_model(model).await;
9123 });
9124 add_note_message(
9125 &chat_sel,
9126 &format!(
9127 "Model set to {} — applies to the next message.",
9128 short_model_name(&item.value)
9129 ),
9130 );
9131 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9132 }));
9133 let state_cancel = state.clone();
9134 let ec_cancel = editor_container.clone();
9135 let editor_cancel = editor.clone();
9136 let tui_cancel = tui.clone();
9137 list.on_cancel(Arc::new(move || {
9138 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9139 }));
9140
9141 let searchable = open_searchable_selector(
9142 state,
9143 editor_container,
9144 editor,
9145 tui,
9146 Some("Select model"),
9147 Some("Type to filter by name, provider, or id"),
9148 list,
9149 SelectorKind::Model,
9150 );
9151 if let Some(term) = initial_search {
9152 searchable.set_search_text(term);
9153 }
9154 Some(searchable)
9155}
9156
9157fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9162 if catalog.is_empty() {
9163 return None;
9164 }
9165 let idx = catalog
9166 .iter()
9167 .position(|m| m.id.eq_ignore_ascii_case(current_id));
9168 match idx {
9169 Some(i) => {
9170 let next = (i + 1) % catalog.len();
9171 Some(catalog[next].clone())
9172 }
9173 None => Some(catalog[0].clone()),
9174 }
9175}
9176
9177fn open_session_selector(
9183 state: &Arc<TuiState>,
9184 editor_container: &Arc<Container>,
9185 editor: &Arc<Editor>,
9186 tui: &Arc<TuiAltScreen>,
9187 cwd: &std::path::Path,
9188 tx: &mpsc::UnboundedSender<TuiMessage>,
9189) {
9190 let dir = crate::session::default_session_dir(cwd);
9191 let mut items: Vec<SelectItem> = Vec::new();
9192 if let Ok(entries) = std::fs::read_dir(&dir) {
9193 for entry in entries.flatten() {
9194 let path = entry.path();
9195 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
9196 continue;
9197 }
9198 let stem = path
9199 .file_stem()
9200 .and_then(|s| s.to_str())
9201 .unwrap_or("(unnamed)")
9202 .to_string();
9203 let display = path
9204 .file_name()
9205 .and_then(|s| s.to_str())
9206 .unwrap_or(&stem)
9207 .to_string();
9208 items.push(SelectItem::new(&stem, &display));
9209 }
9210 }
9211 if items.is_empty() {
9212 add_note_message(
9213 &state.chat_container,
9214 "No saved sessions found. Sessions are created automatically in interactive mode.",
9215 );
9216 tui.request_render(false);
9217 return;
9218 }
9219 let list = Arc::new(SelectList::new(items, 10));
9220
9221 let state_sel = state.clone();
9222 let ec_sel = editor_container.clone();
9223 let editor_sel = editor.clone();
9224 let tui_sel = tui.clone();
9225 let tx_sel = tx.clone();
9226 list.on_select(Arc::new(move |item| {
9227 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9231 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9232 }));
9233 let state_cancel = state.clone();
9234 let ec_cancel = editor_container.clone();
9235 let editor_cancel = editor.clone();
9236 let tui_cancel = tui.clone();
9237 list.on_cancel(Arc::new(move || {
9238 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9239 }));
9240
9241 open_searchable_selector(
9242 state,
9243 editor_container,
9244 editor,
9245 tui,
9246 Some("Resume session"),
9247 Some("Type to filter by session id"),
9248 list,
9249 SelectorKind::Session,
9250 );
9251}
9252
9253fn custom_entry_display_text(
9254 custom_type: &str,
9255 data: Option<&serde_json::Value>,
9256) -> Option<String> {
9257 let data = data?;
9258 let text = data
9259 .get("summary")
9260 .or_else(|| data.get("text"))
9261 .or_else(|| data.get("output"))
9262 .and_then(|value| value.as_str())
9263 .filter(|value| !value.trim().is_empty())?;
9264 let label = match custom_type {
9265 "compactionSummary" => "Compaction summary",
9266 "branchSummary" => "Branch summary",
9267 "bashExecution" => "Command output",
9268 other => other,
9269 };
9270 Some(format!("{label}: {text}"))
9271}
9272
9273async fn open_tree_selector(
9277 harness: &AgentHarness,
9278 state: &Arc<TuiState>,
9279 editor_container: &Arc<Container>,
9280 editor: &Arc<Editor>,
9281 tui: &Arc<TuiAltScreen>,
9282 chat: &Arc<Container>,
9283 tx: &mpsc::UnboundedSender<TuiMessage>,
9284) {
9285 let entries = match harness
9286 .session()
9287 .view("main")
9288 .find_entries(&EntryQuery {
9289 order: Some(EntryOrder::OldestFirst),
9290 ..Default::default()
9291 })
9292 .await
9293 {
9294 Ok(entries) => entries,
9295 Err(error) => {
9296 add_error_message(chat, &format!("Could not read session tree: {error}"));
9297 tui.request_render(false);
9298 return;
9299 }
9300 };
9301 let current = harness.session().get_leaf_id().await.ok().flatten();
9302 let items: Vec<SelectItem> = entries
9303 .iter()
9304 .map(|entry| {
9305 let label = if current.as_deref() == Some(entry.id()) {
9306 format!("{} #{} (current)", entry.entry_type(), entry.seq())
9307 } else {
9308 format!("{} #{}", entry.entry_type(), entry.seq())
9309 };
9310 let short_id = &entry.id()[..entry.id().len().min(12)];
9311 let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9314 SelectItem::new(entry.id(), &label)
9315 .with_description(short_id)
9316 .with_search_text(&search_text)
9317 })
9318 .collect();
9319 if items.is_empty() {
9320 add_note_message(chat, "The current session has no entries to navigate.");
9321 tui.request_render(false);
9322 return;
9323 }
9324 let list = Arc::new(SelectList::new(items, 12));
9325 let state_sel = state.clone();
9326 let ec_sel = editor_container.clone();
9327 let editor_sel = editor.clone();
9328 let tui_sel = tui.clone();
9329 let tx_sel = tx.clone();
9330 list.on_select(Arc::new(move |item| {
9331 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9332 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9333 }));
9334 let state_cancel = state.clone();
9335 let ec_cancel = editor_container.clone();
9336 let editor_cancel = editor.clone();
9337 let tui_cancel = tui.clone();
9338 list.on_cancel(Arc::new(move || {
9339 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9340 }));
9341 open_searchable_selector(
9342 state,
9343 editor_container,
9344 editor,
9345 tui,
9346 Some("Session tree"),
9347 Some("Type to filter by entry type, sequence, or id"),
9348 list,
9349 SelectorKind::Tree,
9350 );
9351}
9352
9353fn open_theme_selector(
9356 state: &Arc<TuiState>,
9357 editor_container: &Arc<Container>,
9358 editor: &Arc<Editor>,
9359 tui: &Arc<TuiAltScreen>,
9360 cwd: &std::path::Path,
9361 package_resources: &Arc<crate::packages::PackageResources>,
9362) {
9363 let mut items = vec![
9364 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9365 SelectItem::new("light", "Light").with_description("Light background"),
9366 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9367 ];
9368 if state.themes_enabled {
9369 for path in package_resources.theme_files() {
9370 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9371 items.push(SelectItem::new(name, name).with_description("Package theme"));
9372 }
9373 }
9374 }
9375 let list = Arc::new(SelectList::new(items, 10));
9376
9377 let state_sel = state.clone();
9378 let ec_sel = editor_container.clone();
9379 let editor_sel = editor.clone();
9380 let tui_sel = tui.clone();
9381 let chat_sel = state.chat_container.clone();
9382 let cwd_sel = cwd.to_path_buf();
9383 let package_resources_sel = package_resources.clone();
9384 list.on_select(Arc::new(move |item| {
9385 let preset = match item.value.as_str() {
9386 "light" => Some(ThemePreset::Light),
9387 "monochrome" => Some(ThemePreset::Monochrome),
9388 "dark" => Some(ThemePreset::Dark),
9389 name => {
9390 if state_sel.themes_enabled {
9391 if let Ok(custom) = crate::packages::load_theme_with_resources(
9392 &cwd_sel,
9393 name,
9394 &package_resources_sel,
9395 ) {
9396 rpi_tui::global_theme_manager().set(custom.clone());
9397 state_sel.theme_manager.set(custom);
9398 }
9399 }
9400 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9401 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9402 tui_sel.render_now(true);
9403 return;
9404 }
9405 };
9406 let Some(preset) = preset else { return };
9407 apply_theme_preset(preset);
9408 state_sel.theme_manager.apply_preset(preset);
9409 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9412 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9413 tui_sel.render_now(true);
9414 }));
9415 let state_cancel = state.clone();
9416 let ec_cancel = editor_container.clone();
9417 let editor_cancel = editor.clone();
9418 let tui_cancel = tui.clone();
9419 list.on_cancel(Arc::new(move || {
9420 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9421 }));
9422
9423 open_selector(
9424 state,
9425 editor_container,
9426 editor,
9427 tui,
9428 list,
9429 SelectorKind::Theme,
9430 );
9431}
9432
9433fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9440 use rpi_ai::types::ThinkingLevel::*;
9441 match level {
9442 Off => "Off — No reasoning",
9443 Minimal => "Minimal — Brief reasoning (~1k tokens)",
9444 Low => "Low — Light reasoning (~1k tokens)",
9445 Medium => "Medium — Moderate reasoning (~80% of max)",
9446 High => "High — Extensive reasoning (~95% of max)",
9447 Xhigh => "Xhigh — Near-maximal reasoning",
9448 Max => "Max — Maximum reasoning",
9449 }
9450}
9451
9452fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9455 use rpi_ai::types::ThinkingLevel::*;
9456 match level {
9457 Off => "off",
9458 Minimal => "minimal",
9459 Low => "low",
9460 Medium => "medium",
9461 High => "high",
9462 Xhigh => "xhigh",
9463 Max => "max",
9464 }
9465}
9466
9467fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9470 use rpi_ai::types::ThinkingLevel::*;
9471 match name.to_ascii_lowercase().as_str() {
9472 "off" => Some(Off),
9473 "minimal" => Some(Minimal),
9474 "low" => Some(Low),
9475 "medium" => Some(Medium),
9476 "high" => Some(High),
9477 "xhigh" => Some(Xhigh),
9478 "max" => Some(Max),
9479 _ => None,
9480 }
9481}
9482
9483fn open_thinking_selector(
9493 state: &Arc<TuiState>,
9494 editor_container: &Arc<Container>,
9495 editor: &Arc<Editor>,
9496 tui: &Arc<TuiAltScreen>,
9497 lane: &Arc<dyn AgentLane>,
9498 catalog: &[rpi_ai::Model],
9499 lane_model_id: &str,
9500 chat: &Arc<Container>,
9501) {
9502 let model = catalog
9505 .iter()
9506 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9507 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9508 .map(|m| m.supported_thinking_levels())
9509 .unwrap_or_else(|| {
9510 use rpi_ai::types::ThinkingLevel::*;
9511 vec![Off, Minimal, Low, Medium, High]
9512 });
9513 let mut items: Vec<SelectItem> = Vec::new();
9514 for lvl in &levels {
9515 let name = thinking_level_name(*lvl);
9516 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9517 }
9518 if items.is_empty() {
9519 add_note_message(chat, "This model has no supported thinking levels.");
9520 tui.request_render(false);
9521 return;
9522 }
9523 let list = Arc::new(SelectList::new(items, 10));
9524
9525 let state_sel = state.clone();
9526 let ec_sel = editor_container.clone();
9527 let editor_sel = editor.clone();
9528 let tui_sel = tui.clone();
9529 let chat_sel = chat.clone();
9530 let lane_sel = lane.clone();
9531 list.on_select(Arc::new(move |item| {
9532 let Some(level) = thinking_level_from_name(&item.value) else {
9533 add_note_message(
9534 &chat_sel,
9535 &format!("Unknown thinking level: {}.", item.label),
9536 );
9537 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9538 return;
9539 };
9540 let lane = lane_sel.clone();
9541 let footer_sel = state_sel.footer.clone();
9542 tokio::spawn(async move {
9543 let _ = lane.set_thinking_level(level).await;
9544 });
9545 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9549 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9550 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9551 }));
9552 let state_cancel = state.clone();
9553 let ec_cancel = editor_container.clone();
9554 let editor_cancel = editor.clone();
9555 let tui_cancel = tui.clone();
9556 list.on_cancel(Arc::new(move || {
9557 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9558 }));
9559
9560 open_selector(
9561 state,
9562 editor_container,
9563 editor,
9564 tui,
9565 list,
9566 SelectorKind::Thinking,
9567 );
9568}
9569
9570fn open_tools_selector(
9576 state: &Arc<TuiState>,
9577 editor_container: &Arc<Container>,
9578 editor: &Arc<Editor>,
9579 tui: &Arc<TuiAltScreen>,
9580 lane: &Arc<dyn AgentLane>,
9581 chat: &Arc<Container>,
9582) {
9583 let mut active = match tokio::runtime::Handle::try_current() {
9588 Ok(h) => h
9589 .block_on(async { lane.get_active_tools().await })
9590 .unwrap_or_default(),
9591 Err(_) => Vec::new(),
9592 };
9593 if active.is_empty() {
9598 active = crate::session::BUILTIN_TOOL_NAMES
9599 .iter()
9600 .map(|name| (*name).to_string())
9601 .collect();
9602 }
9603 let mut items: Vec<SelectItem> = Vec::new();
9604 for name in crate::session::BUILTIN_TOOL_NAMES {
9605 let on = active.iter().any(|a| a == name);
9606 let label = if on {
9607 format!("{name} (on)")
9608 } else {
9609 (*name).to_string()
9610 };
9611 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9612 }
9613 let list = Arc::new(SelectList::new(items, 10));
9614
9615 let active_captured = active.clone();
9617 let state_sel = state.clone();
9618 let ec_sel = editor_container.clone();
9619 let editor_sel = editor.clone();
9620 let tui_sel = tui.clone();
9621 let chat_sel = chat.clone();
9622 let lane_sel = lane.clone();
9623 list.on_select(Arc::new(move |item| {
9624 let mut next = active_captured.clone();
9625 if let Some(pos) = next.iter().position(|a| a == &item.value) {
9626 next.remove(pos);
9627 } else {
9628 next.push(item.value.clone());
9629 }
9630 let on = next.iter().any(|a| a == &item.value);
9631 let lane = lane_sel.clone();
9632 let next_clone = next.clone();
9633 tokio::spawn(async move {
9634 let _ = lane.set_active_tools(next_clone).await;
9635 });
9636 let list_str = if next.is_empty() {
9637 "(none)".to_string()
9638 } else {
9639 next.join(", ")
9640 };
9641 add_note_message(
9642 &chat_sel,
9643 &format!(
9644 "{} {} — active tools: {}",
9645 item.value,
9646 if on { "enabled" } else { "disabled" },
9647 list_str
9648 ),
9649 );
9650 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9651 }));
9652 let state_cancel = state.clone();
9653 let ec_cancel = editor_container.clone();
9654 let editor_cancel = editor.clone();
9655 let tui_cancel = tui.clone();
9656 list.on_cancel(Arc::new(move || {
9657 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9658 }));
9659
9660 open_selector(
9661 state,
9662 editor_container,
9663 editor,
9664 tui,
9665 list,
9666 SelectorKind::Tools,
9667 );
9668}
9669
9670fn open_images_selector(
9674 state: &Arc<TuiState>,
9675 editor_container: &Arc<Container>,
9676 editor: &Arc<Editor>,
9677 tui: &Arc<TuiAltScreen>,
9678 chat: &Arc<Container>,
9679) {
9680 let current = *state.show_images.lock().unwrap();
9681 let items = vec![
9682 SelectItem::new("yes", "Yes").with_description(if current {
9683 "Inline images (current)"
9684 } else {
9685 "Inline images"
9686 }),
9687 SelectItem::new("no", "No").with_description(if current {
9688 "Placeholder only"
9689 } else {
9690 "Placeholder only (current)"
9691 }),
9692 ];
9693 let list = Arc::new(SelectList::new(items, 5));
9694
9695 let state_sel = state.clone();
9696 let ec_sel = editor_container.clone();
9697 let editor_sel = editor.clone();
9698 let tui_sel = tui.clone();
9699 let chat_sel = chat.clone();
9700 list.on_select(Arc::new(move |item| {
9701 let on = item.value == "yes";
9702 *state_sel.show_images.lock().unwrap() = on;
9703 add_note_message(
9704 &chat_sel,
9705 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9706 );
9707 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9708 }));
9709 let state_cancel = state.clone();
9710 let ec_cancel = editor_container.clone();
9711 let editor_cancel = editor.clone();
9712 let tui_cancel = tui.clone();
9713 list.on_cancel(Arc::new(move || {
9714 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9715 }));
9716
9717 open_selector(
9718 state,
9719 editor_container,
9720 editor,
9721 tui,
9722 list,
9723 SelectorKind::Images,
9724 );
9725}
9726
9727fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9735 let text = editor.get_text();
9736 let cursor = editor_cursor_offset(editor, &text);
9737 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9738 render_autocomplete(state, suggestions);
9739}
9740
9741fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9744 let (row, col) = editor.cursor_position();
9745 let mut offset = 0;
9746 for (index, line) in text.split('\n').enumerate() {
9747 if index == row {
9748 return (offset + col.min(line.len())).min(text.len());
9749 }
9750 offset = offset.saturating_add(line.len() + 1);
9751 }
9752 text.len()
9753}
9754
9755fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9758 let offset = offset.min(text.len());
9759 let mut consumed = 0;
9760 for (row, line) in text.split('\n').enumerate() {
9761 let end = consumed + line.len();
9762 if offset <= end {
9763 editor.set_cursor(row, offset - consumed);
9764 return;
9765 }
9766 consumed = end + 1;
9767 }
9768 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9769 editor.set_cursor(
9770 last_row,
9771 text.rsplit('\n').next().map(str::len).unwrap_or(0),
9772 );
9773}
9774
9775fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9777 state.autocomplete_container.clear();
9778 let Some(sugg) = suggestions else {
9779 return;
9780 };
9781 if sugg.items.is_empty() {
9782 return;
9783 }
9784 let accent = state.theme_manager.get().colors.accent;
9787 let muted = state.theme_manager.get().colors.muted;
9788 for (i, item) in sugg
9789 .items
9790 .iter()
9791 .take(state.autocomplete_max_visible)
9792 .enumerate()
9793 {
9794 let prefix = if i == 0 { "→ " } else { " " };
9795 let label = item.display_text();
9796 let line = if i == 0 {
9797 format!(
9798 "{prefix}{} {}",
9799 accent.fg(label),
9800 muted.fg(item.description.as_deref().unwrap_or(""))
9801 )
9802 } else {
9803 format!(
9804 "{prefix}{} {}",
9805 muted.fg(label),
9806 muted.fg(item.description.as_deref().unwrap_or(""))
9807 )
9808 };
9809 state
9810 .autocomplete_container
9811 .add_child(Arc::new(Text::new(line, 1, 0)));
9812 }
9813}
9814
9815fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9819 let text = editor.get_text();
9820 let cursor = editor_cursor_offset(editor, &text);
9821 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9822 return false;
9823 };
9824 let Some(top) = sugg.items.first() else {
9825 return false;
9826 };
9827 let start = sugg.start.min(text.len());
9831 let end = sugg.end.min(text.len());
9832 let mut replaced = String::with_capacity(text.len() + top.text.len());
9833 replaced.push_str(&text[..start]);
9834 replaced.push_str(&top.text);
9835 replaced.push_str(&text[end..]);
9838 if top.insert_space && !replaced.ends_with('/') {
9839 replaced.push(' ');
9840 }
9841 let new_cursor = replaced.len().min(
9844 start
9845 + top.text.len()
9846 + if top.insert_space && !top.text.ends_with('/') {
9847 1
9848 } else {
9849 0
9850 },
9851 );
9852 editor.set_text(&replaced);
9853 set_editor_cursor_offset(editor, &replaced, new_cursor);
9854 state.autocomplete_container.clear();
9855 true
9856}
9857
9858fn add_welcome_message(container: &Arc<Container>) {
9864 add_welcome_message_with_capabilities(container, &[], &[]);
9865}
9866
9867fn add_welcome_message_with_capabilities(
9871 container: &Arc<Container>,
9872 active_tools: &[String],
9873 skills: &[String],
9874) {
9875 let c = current_theme().colors;
9876 let logo = format!(
9878 "{}\n{}\n{}\n{}",
9879 c.accent.fg(" 🦀"),
9880 c.accent.fg(" ╭─π─╮"),
9881 c.accent.fg(" │") + &c.accent.fg(&tui_bold("rpi")) + &c.accent.fg("│"),
9882 c.accent.fg(" ╰───╯")
9883 );
9884 container.add_child(Arc::new(Text::new(logo, 1, 0)));
9885 container.add_child(Arc::new(Spacer::new(1)));
9886 let title = format!(
9890 "{} {}",
9891 c.accent.fg(&tui_bold("rpi")),
9892 c.muted.fg("interactive TUI")
9893 );
9894 container.add_child(Arc::new(Text::new(title, 1, 0)));
9895 container.add_child(Arc::new(Spacer::new(1)));
9896 container.add_child(Arc::new(Text::new(
9897 c.dim.fg("Type your message and press Enter to send."),
9898 1,
9899 0,
9900 )));
9901 let hint = c
9902 .dim
9903 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
9904 container.add_child(Arc::new(Text::new(hint, 1, 0)));
9905 container.add_child(Arc::new(Spacer::new(1)));
9906 container.add_child(Arc::new(Text::new(
9907 welcome_capability_line("Tools", active_tools),
9908 1,
9909 0,
9910 )));
9911 container.add_child(Arc::new(Text::new(
9912 welcome_capability_line("Skills", skills),
9913 1,
9914 0,
9915 )));
9916 container.add_child(Arc::new(DynamicBorder::new()));
9917}
9918
9919fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
9922 let colors = current_theme().colors;
9923 let group = Arc::new(Container::new());
9924
9925 for warning in &report.warnings {
9926 let body = format!(
9927 "{}\n{} {}{}",
9928 colors.error.fg(&warning.message),
9929 colors.muted.fg("Run"),
9930 colors.accent.fg(&warning.command),
9931 colors.muted.fg(" to retry.")
9932 );
9933 add_update_panel(&group, "Update Failed", &body, colors.error);
9934 }
9935
9936 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
9937 let body = format!(
9938 "{} {}{}",
9939 colors
9940 .muted
9941 .fg(&format!("New version {} is available. Run", notice.latest)),
9942 colors.accent.fg(¬ice.command),
9943 colors.muted.fg(".")
9944 );
9945 add_update_panel(&group, "Update Available", &body, colors.warning);
9946 }
9947
9948 let package_notices = report
9949 .notices
9950 .iter()
9951 .filter(|notice| notice.name != "rpi")
9952 .collect::<Vec<_>>();
9953 if !package_notices.is_empty() {
9954 let command = package_notices[0].command.as_str();
9955 let mut lines = vec![format!(
9956 "{} {}{}",
9957 colors.muted.fg("Package updates are available. Run"),
9958 colors.accent.fg(command),
9959 colors.muted.fg(".")
9960 )];
9961 lines.push(colors.muted.fg("Packages:"));
9962 lines.extend(
9963 package_notices
9964 .into_iter()
9965 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
9966 );
9967 add_update_panel(
9968 &group,
9969 "Package Updates Available",
9970 &lines.join("\n"),
9971 colors.warning,
9972 );
9973 }
9974
9975 if group.child_count() > 0 {
9979 container.add_child(group);
9980 }
9981}
9982
9983fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
9984 container.add_child(Arc::new(Spacer::new(1)));
9985 container.add_child(Arc::new(DynamicBorder::with_color(color)));
9986 container.add_child(Arc::new(Text::new(
9987 format!("{}\n{body}", color.fg(&tui_bold(title))),
9988 1,
9989 0,
9990 )));
9991 container.add_child(Arc::new(DynamicBorder::with_color(color)));
9992}
9993
9994fn welcome_capability_line(label: &str, names: &[String]) -> String {
9995 let c = current_theme().colors;
9996 let value = if names.is_empty() {
9997 "none".to_string()
9998 } else {
9999 names.join(" · ")
10000 };
10001 format!(
10002 "{} {}",
10003 c.accent.fg(&format!("{label} ({})", names.len())),
10004 c.muted.fg(&value)
10005 )
10006}
10007
10008fn add_help_message(container: &Arc<Container>) {
10010 let c = current_theme().colors;
10011 container.add_child(Arc::new(Text::new(
10015 c.md_heading.fg(&tui_bold("📚 Available Commands")),
10016 1,
10017 0,
10018 )));
10019 container.add_child(Arc::new(Spacer::new(1)));
10020
10021 let cmds: &[(&str, &str)] = &[
10022 ("/help, /?", "Show this help message"),
10023 ("/clear, /new", "Clear the conversation"),
10024 ("/exit, /quit, /q", "Exit the application"),
10025 ("/version, /v", "Show version information"),
10026 ("/changelog", "Show recent release changes"),
10027 ("/model, /m", "Choose a model (live switch)"),
10028 ("/thinking, /think", "Set reasoning depth (selector)"),
10029 ("/tools", "Toggle built-in tools on/off"),
10030 ("/images", "Toggle inline image rendering"),
10031 ("/session", "List saved sessions"),
10032 ("/theme", "Choose a theme (selector)"),
10033 ("/compact", "Compact the conversation"),
10034 ("/copy", "Copy last reply to clipboard"),
10035 ("/hotkeys", "Show keyboard shortcuts"),
10036 ("/armin", "🐾 Easter egg"),
10037 ("/earendil", "Earendil announcement"),
10038 ];
10039 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10040 for (cmd, desc) in cmds {
10041 let row = format!(
10042 " {:<cmd_w$} {} {}",
10043 c.accent.fg(cmd),
10044 c.dim.fg("—"),
10045 c.muted.fg(desc)
10046 );
10047 container.add_child(Arc::new(Text::new(row, 1, 0)));
10048 }
10049 container.add_child(Arc::new(Spacer::new(1)));
10050}
10051
10052fn add_version_message(container: &Arc<Container>) {
10054 let c = current_theme().colors;
10055 container.add_child(Arc::new(Text::new(
10056 c.md_heading.fg(&tui_bold("📦 Version Information")),
10057 1,
10058 0,
10059 )));
10060 container.add_child(Arc::new(Spacer::new(1)));
10061 container.add_child(Arc::new(Text::new(
10064 format!(
10065 " {} {}",
10066 c.muted.fg("rpi-cli"),
10067 c.text.fg(&format!("v{}", crate::VERSION))
10068 ),
10069 1,
10070 0,
10071 )));
10072 container.add_child(Arc::new(Text::new(
10073 format!(
10074 " {}",
10075 c.dim.fg("Rust implementation of pi coding agent TUI")
10076 ),
10077 1,
10078 0,
10079 )));
10080 container.add_child(Arc::new(Spacer::new(1)));
10081}
10082
10083fn add_changelog_message(container: &Arc<Container>) {
10087 let c = current_theme().colors;
10088 container.add_child(Arc::new(Text::new(
10089 c.md_heading.fg(&tui_bold("Recent Changes")),
10090 1,
10091 0,
10092 )));
10093 container.add_child(Arc::new(Spacer::new(1)));
10094 let entries = [
10095 (
10096 "Native parity phase 1",
10097 "models, images, trust, export, and JSON events",
10098 ),
10099 (
10100 "TUI controls",
10101 "external editor, thinking levels, and tool output toggles",
10102 ),
10103 (
10104 "Provider auth",
10105 "OpenAI-compatible API key aliases and gateway headers",
10106 ),
10107 ];
10108 for (release, summary) in entries {
10109 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
10110 container.add_child(Arc::new(Text::new(row, 1, 0)));
10111 }
10112 container.add_child(Arc::new(Text::new(
10113 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10114 1,
10115 0,
10116 )));
10117 container.add_child(Arc::new(Spacer::new(1)));
10118}
10119
10120fn add_hotkeys_message(container: &Arc<Container>) {
10122 let c = current_theme().colors;
10123 container.add_child(Arc::new(Text::new(
10124 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
10125 1,
10126 0,
10127 )));
10128 container.add_child(Arc::new(Spacer::new(1)));
10129 let keys: &[(&str, &str)] = &[
10130 ("Enter", "Send message"),
10131 ("Shift+Enter", "New line"),
10132 ("Tab", "Accept autocomplete suggestion"),
10133 ("Ctrl+A / Ctrl+E", "Line start / end"),
10134 (
10135 "Ctrl+K / Ctrl+U",
10136 "Kill to end / start of line (Ctrl+Y yanks)",
10137 ),
10138 ("Ctrl+- / Ctrl+R", "Undo / redo"),
10139 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10140 ("Alt+Backspace", "Kill previous word"),
10141 ("Ctrl+C", "Abort a run, or exit when idle"),
10142 ("Esc", "Abort a running prompt"),
10143 ("Ctrl+L", "Open model selector"),
10144 ("Ctrl+M", "Cycle to the next model (live)"),
10145 ("Ctrl+O", "Expand/collapse all tool output"),
10146 ("Ctrl+T", "Show/hide reasoning blocks"),
10147 ("PageUp/Down", "Scroll transcript by one page"),
10148 ("Home / End", "Jump to transcript start / latest output"),
10149 ];
10150 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10151 for (key, desc) in keys {
10152 let row = format!(
10153 " {:<key_w$} {} {}",
10154 c.accent.fg(key),
10155 c.dim.fg("—"),
10156 c.muted.fg(desc)
10157 );
10158 container.add_child(Arc::new(Text::new(row, 1, 0)));
10159 }
10160 container.add_child(Arc::new(Spacer::new(1)));
10161}
10162
10163fn add_user_message(container: &Arc<Container>, text: &str) {
10169 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10170 container.add_child(Arc::new(Spacer::new(1)));
10171}
10172
10173fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10176 let width = 80; chat_container.render(width)
10178}
10179
10180fn format_resume_command(
10185 session_id: &str,
10186 cwd: &std::path::Path,
10187 session_dir: Option<&std::path::Path>,
10188) -> Option<String> {
10189 if session_id.is_empty() {
10190 return None;
10191 }
10192 let mut args = vec![crate::APP_NAME.to_string()];
10193 if let Some(dir) = session_dir {
10194 let default = crate::session::default_session_dir(cwd);
10195 if dir != default.as_path() {
10196 args.push("--session-dir".to_string());
10197 args.push(quote_shell_arg(&dir.to_string_lossy()));
10198 }
10199 }
10200 args.push("--session".to_string());
10201 args.push(session_id.to_string());
10202 Some(args.join(" "))
10203}
10204
10205fn quote_shell_arg(value: &str) -> String {
10208 let needs_quoting = value.is_empty()
10209 || value
10210 .chars()
10211 .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10212 if needs_quoting {
10213 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10214 } else {
10215 value.to_string()
10216 }
10217}
10218
10219fn add_error_message(container: &Arc<Container>, text: &str) {
10220 let c = current_theme().colors;
10221 let text = sanitize_error_message(text);
10222 container.add_child(Arc::new(Text::new(
10223 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
10224 1,
10225 0,
10226 )));
10227 container.add_child(Arc::new(Spacer::new(1)));
10228}
10229
10230fn sanitize_error_message(text: &str) -> String {
10235 const MAX_ERROR_CHARS: usize = 16 * 1024;
10236 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10237 let mut count = 0;
10238 let mut escape_mode = 0u8;
10240 for ch in text.chars() {
10241 if escape_mode != 0 {
10242 match escape_mode {
10243 1 if ch == '[' => escape_mode = 2,
10244 1 if ch == ']' => escape_mode = 3,
10245 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10246 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10247 3 if ch == '\x07' => escape_mode = 0,
10248 _ => {}
10249 }
10250 continue;
10251 }
10252 if ch == '\x1b' {
10253 escape_mode = 1;
10254 continue;
10255 }
10256 if count >= MAX_ERROR_CHARS {
10257 result.push_str("…");
10258 break;
10259 }
10260 match ch {
10261 '\n' | '\t' => {
10262 result.push(ch);
10263 count += 1;
10264 }
10265 '\r' => {}
10266 c if c.is_control() => {}
10267 c => {
10268 result.push(c);
10269 count += 1;
10270 }
10271 }
10272 }
10273 if result.trim().is_empty() {
10274 "Provider request failed.".to_string()
10275 } else {
10276 result
10277 }
10278}
10279
10280fn add_note_message(container: &Arc<Container>, text: &str) {
10282 let c = current_theme().colors;
10283 container.add_child(Arc::new(Text::new(
10284 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10285 1,
10286 0,
10287 )));
10288 container.add_child(Arc::new(Spacer::new(1)));
10289}
10290
10291fn show_context_panel(
10300 chat: &Arc<Container>,
10301 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10302) {
10303 let skills = resources.skills.as_deref().unwrap_or(&[]);
10304 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10305 let mut lines: Vec<String> = Vec::new();
10306 lines.push("📂 Discovered resources for this session:".into());
10307
10308 if skills.is_empty() {
10309 lines.push(
10310 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10311 );
10312 } else {
10313 lines.push(format!(" Skills ({}):", skills.len()));
10314 for s in skills {
10315 let marker = if s.disable_model_invocation == Some(true) {
10316 " [hidden]"
10317 } else {
10318 ""
10319 };
10320 let desc: String = s.description.chars().take(72).collect();
10321 lines.push(format!(" • {}{marker} — {desc}", s.name));
10322 }
10323 }
10324
10325 if templates.is_empty() {
10326 lines.push(
10327 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10328 );
10329 } else {
10330 lines.push(format!(" Prompt templates ({}):", templates.len()));
10331 for t in templates {
10332 let desc = t
10333 .description
10334 .as_deref()
10335 .unwrap_or("(no description)")
10336 .chars()
10337 .take(72)
10338 .collect::<String>();
10339 lines.push(format!(" • /{} — {desc}", t.name));
10340 }
10341 }
10342 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10343 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10344 lines.push(
10345 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10346 .into(),
10347 );
10348 let body = lines.join("\n");
10349 container_note_block(chat, &body);
10350}
10351
10352fn container_note_block(container: &Arc<Container>, body: &str) {
10354 for line in body.lines() {
10355 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10356 }
10357 container.add_child(Arc::new(Spacer::new(1)));
10358}
10359
10360pub fn is_tui_supported() -> bool {
10366 std::io::stdout().is_terminal()
10367}
10368
10369#[allow(unused_imports)]
10371use rpi_tui::Color as _Color;
10372
10373#[cfg(test)]
10374mod tests {
10375 use super::*;
10376 use rpi_tui::Component;
10377
10378 fn test_tui_state() -> Arc<TuiState> {
10381 Arc::new(TuiState {
10382 current_assistant: std::sync::Mutex::new(None),
10383 tool_components: std::sync::Mutex::new(HashMap::new()),
10384 bash_components: std::sync::Mutex::new(HashMap::new()),
10385 themes_enabled: true,
10386 hide_thinking: std::sync::Mutex::new(false),
10387 tool_outputs_expanded: std::sync::Mutex::new(false),
10388 show_terminal_progress: true,
10389 status: std::sync::Mutex::new(RunStatus::Idle),
10390 js_preparation_cancel: std::sync::Mutex::new(None),
10391 user_bash_cancel: std::sync::Mutex::new(None),
10392 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10393 footer: Arc::new(FooterComponent::new()),
10394 status_container: Arc::new(Container::new()),
10395 chat_container: Arc::new(Container::new()),
10396 loader: Arc::new(Loader::new()),
10397 editor: Arc::new(Editor::simple()),
10398 last_assistant_text: std::sync::Mutex::new(String::new()),
10399 active_selector: std::sync::Mutex::new(None),
10400 active_extension_editor: std::sync::Mutex::new(None),
10401 active_extension_input: std::sync::Mutex::new(None),
10402 active_extension_cancel: std::sync::Mutex::new(None),
10403 autocomplete: AutocompleteManager::new(),
10404 autocomplete_container: Arc::new(Container::new()),
10405 autocomplete_max_visible: 5,
10406 pending_images: std::sync::Mutex::new(Vec::new()),
10407 theme_manager: Arc::new(ThemeManager::new()),
10408 tui: None,
10409 current_model_id: std::sync::Mutex::new(String::new()),
10410 show_images: std::sync::Mutex::new(true),
10411 cache_miss_notices: std::sync::Mutex::new(false),
10412 history: std::sync::Mutex::new(Vec::new()),
10413 history_index: std::sync::Mutex::new(-1),
10414 history_draft: std::sync::Mutex::new(None),
10415 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10416 scoped_edit: std::sync::Mutex::new(None),
10417 markdown_transformer: std::sync::Mutex::new(None),
10418 extension_session: Arc::new(std::sync::Mutex::new(
10419 rpi_extensions::ExtensionSession::none(),
10420 )),
10421 search: Arc::new(AltScreenSearch::new()),
10422 search_bar: Arc::new(SearchBar::new()),
10423 pending_container: Arc::new(Container::new()),
10424 dequeue_hint: String::new(),
10425 pending_snapshot: std::sync::Mutex::new(
10426 rpi_harness::agent_harness::QueuedMessages::default(),
10427 ),
10428 selection_start: std::sync::Mutex::new(None),
10429 selection_end: std::sync::Mutex::new(None),
10430 })
10431 }
10432
10433 #[test]
10434 fn settings_menu_covers_the_wired_settings_surface() {
10435 let state = test_tui_state();
10436 let settings = crate::settings::Settings::default();
10437 let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10438 let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10439 assert_eq!(
10440 keys,
10441 [
10442 "theme",
10443 "model",
10444 "thinking",
10445 "scoped-models",
10446 "hide-thinking",
10447 "show-images",
10448 "cache-miss-notices",
10449 "quiet-startup",
10450 "terminal-progress",
10451 "fullscreen-copy-on-select",
10452 "double-escape-action",
10453 "editor-padding",
10454 "autocomplete-max-items",
10455 "http-idle-timeout",
10456 ]
10457 );
10458 for item in items.iter().filter(|item| item.has_submenu) {
10460 assert!(
10461 item.values.is_empty(),
10462 "{} should be submenu-only",
10463 item.key
10464 );
10465 }
10466 for item in items.iter().filter(|item| !item.has_submenu) {
10468 assert!(!item.values.is_empty(), "{} has no values", item.key);
10469 assert!(
10470 item.values.iter().any(|value| value == &item.value),
10471 "{} current value {} not in {:?}",
10472 item.key,
10473 item.value,
10474 item.values
10475 );
10476 }
10477 assert_eq!(items[1].value, "claude-sonnet-5");
10479 assert_eq!(items[6].value, "false");
10481 assert_eq!(items[8].value, "true");
10482 }
10483
10484 #[test]
10485 fn apply_setting_change_updates_settings_and_live_state() {
10486 let state = test_tui_state();
10487 let mut settings = crate::settings::Settings::default();
10488
10489 let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10491 assert!(state.hide_thinking());
10492 assert_eq!(settings.hide_thinking_block, Some(true));
10493 assert!(note.expect("user-visible note").contains("hidden"));
10494
10495 apply_setting_change(&state, "show-images", "false", &mut settings);
10497 assert!(!*state.show_images.lock().unwrap());
10498 assert_eq!(settings.show_images, Some(false));
10499
10500 apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10502 assert!(*state.cache_miss_notices.lock().unwrap());
10503 assert_eq!(settings.show_cache_miss_notices, Some(true));
10504
10505 assert_eq!(
10507 apply_setting_change(&state, "editor-padding", "3", &mut settings),
10508 None
10509 );
10510 assert_eq!(settings.editor_padding_x, Some(3));
10511 apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10512 assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10513 apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10514 assert_eq!(settings.autocomplete_max_visible, Some(20));
10515
10516 apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10518 assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10519 apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10520 assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10521
10522 assert_eq!(
10524 apply_setting_change(&state, "nope", "x", &mut settings),
10525 None
10526 );
10527 }
10528
10529 #[test]
10530 fn add_user_message_renders_a_visible_user_bubble() {
10531 let chat = Arc::new(Container::new());
10535 add_user_message(&chat, "hello from the user");
10536 let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10537 let plain = strip_ansi(&rendered.join("\n"));
10538 assert!(
10539 plain.contains("hello from the user"),
10540 "user bubble must show the prompt text; got: {plain:?}"
10541 );
10542 }
10543
10544 #[test]
10545 fn verbose_overrides_quiet_startup_listing() {
10546 assert!(should_show_startup_listing(false, false));
10547 assert!(should_show_startup_listing(true, false));
10548 assert!(should_show_startup_listing(true, true));
10549 assert!(!should_show_startup_listing(false, true));
10550 }
10551
10552 #[test]
10553 fn resume_command_omits_the_default_session_dir() {
10554 let cwd = std::path::Path::new("/proj/demo");
10555 assert_eq!(format_resume_command("", cwd, None), None);
10556 assert_eq!(
10557 format_resume_command("abc-123", cwd, None).as_deref(),
10558 Some("rpi --session abc-123")
10559 );
10560 let default = crate::session::default_session_dir(cwd);
10562 assert_eq!(
10563 format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10564 Some("rpi --session abc-123")
10565 );
10566 let custom = std::path::Path::new("/tmp/my sessions");
10568 assert_eq!(
10569 format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
10570 Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
10571 );
10572 }
10573
10574 #[test]
10575 fn quote_shell_arg_quotes_only_when_needed() {
10576 assert_eq!(quote_shell_arg("plain"), "plain");
10577 assert_eq!(quote_shell_arg("with space"), "\"with space\"");
10578 assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
10579 }
10580
10581 #[test]
10582 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
10583 let tmp = tempfile::tempdir().unwrap();
10584 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
10585 let local_root = tmp.path().join("local-package");
10586 std::fs::create_dir_all(git_root.join(".git")).unwrap();
10587 std::fs::create_dir_all(&local_root).unwrap();
10588 std::fs::write(
10589 git_root.join("package.json"),
10590 r#"{"name":"git-demo","version":"1.0.0"}"#,
10591 )
10592 .unwrap();
10593 std::fs::write(
10594 local_root.join("package.json"),
10595 r#"{"name":"local-demo","version":"1.0.0"}"#,
10596 )
10597 .unwrap();
10598 let resources = crate::packages::discover(
10599 tmp.path(),
10600 &[
10601 "git:github.com/example/repo".to_string(),
10602 local_root.to_string_lossy().into_owned(),
10603 ],
10604 );
10605 assert_eq!(resources.packages.len(), 2);
10606
10607 let filtered = git_only_update_resources(resources);
10608
10609 assert_eq!(filtered.packages.len(), 1);
10610 assert!(matches!(
10611 filtered.packages[0].source,
10612 crate::packages::PackageSource::Git
10613 ));
10614 }
10615
10616 #[test]
10617 fn trust_command_uses_the_session_cwd_after_process_chdir() {
10618 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
10619 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
10620 const TEST_NAME: &str =
10621 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
10622
10623 if std::env::var_os(CHILD_ENV).is_some() {
10624 let session_cwd = std::path::PathBuf::from(
10625 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
10626 );
10627 let changed_cwd = std::env::current_dir().unwrap();
10628
10629 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
10630
10631 assert_eq!(
10632 crate::config::project_trust_decision(&session_cwd).unwrap(),
10633 Some(true)
10634 );
10635 assert_eq!(
10636 crate::config::project_trust_decision(&changed_cwd).unwrap(),
10637 None
10638 );
10639 return;
10640 }
10641
10642 let tmp = tempfile::tempdir().unwrap();
10643 let session_cwd = tmp.path().join("session-project");
10644 let changed_cwd = tmp.path().join("extension-cwd");
10645 let agent_dir = tmp.path().join("agent");
10646 std::fs::create_dir_all(&session_cwd).unwrap();
10647 std::fs::create_dir_all(&changed_cwd).unwrap();
10648 std::fs::create_dir_all(&agent_dir).unwrap();
10649
10650 let status = std::process::Command::new(std::env::current_exe().unwrap())
10651 .arg("--exact")
10652 .arg(TEST_NAME)
10653 .arg("--nocapture")
10654 .env(CHILD_ENV, "1")
10655 .env(SESSION_CWD_ENV, &session_cwd)
10656 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
10657 .current_dir(&changed_cwd)
10658 .status()
10659 .unwrap();
10660
10661 assert!(status.success(), "child test process failed: {status}");
10662 }
10663
10664 #[test]
10665 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
10666 let global = crate::settings::Settings {
10667 editor_padding_x: Some(3),
10668 autocomplete_max_visible: Some(6),
10669 hide_thinking_block: Some(false),
10670 quiet_startup: Some(true),
10671 show_terminal_progress: Some(true),
10672 ..Default::default()
10673 };
10674 let rpi_project = crate::settings::Settings {
10675 editor_padding_x: Some(0),
10676 hide_thinking_block: Some(true),
10677 quiet_startup: Some(false),
10678 show_terminal_progress: Some(false),
10679 ..Default::default()
10680 };
10681 let pi_project = crate::settings::Settings {
10682 editor_padding_x: Some(9),
10683 autocomplete_max_visible: Some(12),
10684 quiet_startup: Some(true),
10685 ..Default::default()
10686 };
10687
10688 assert_eq!(
10689 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
10690 TuiStartupSettings {
10691 editor_padding_x: 0,
10692 autocomplete_max_visible: 12,
10693 hide_thinking: true,
10694 quiet_startup: false,
10695 show_terminal_progress: false,
10696 show_images: true,
10697 cache_miss_notices: false,
10698 }
10699 );
10700 }
10701
10702 #[test]
10703 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
10704 let global = crate::settings::Settings {
10705 quiet_startup: Some(false),
10706 ..Default::default()
10707 };
10708 let rpi_project = crate::settings::Settings::default();
10709 let pi_project = crate::settings::Settings {
10710 quiet_startup: Some(true),
10711 ..Default::default()
10712 };
10713
10714 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
10715
10716 assert!(resolved.quiet_startup);
10717 }
10718
10719 #[test]
10720 fn tui_startup_settings_use_global_values_without_project_fields() {
10721 let global = crate::settings::Settings {
10722 editor_padding_x: Some(7),
10723 autocomplete_max_visible: Some(8),
10724 hide_thinking_block: Some(true),
10725 quiet_startup: Some(true),
10726 show_terminal_progress: Some(false),
10727 show_images: Some(false),
10728 show_cache_miss_notices: Some(true),
10729 ..Default::default()
10730 };
10731
10732 assert_eq!(
10733 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
10734 TuiStartupSettings {
10735 editor_padding_x: 7,
10736 autocomplete_max_visible: 8,
10737 hide_thinking: true,
10738 quiet_startup: true,
10739 show_terminal_progress: false,
10740 show_images: false,
10741 cache_miss_notices: true,
10742 }
10743 );
10744 }
10745
10746 #[test]
10747 fn tui_startup_settings_read_native_nested_terminal_block() {
10748 let global = crate::settings::Settings {
10751 terminal: Some(crate::settings::TerminalSettings {
10752 show_images: Some(false),
10753 show_terminal_progress: Some(false),
10754 clear_on_shrink: None,
10755 }),
10756 ..Default::default()
10757 };
10758 let resolved =
10759 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10760 assert!(!resolved.show_images);
10761 assert!(!resolved.show_terminal_progress);
10762 }
10763
10764 #[test]
10765 fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
10766 let global = crate::settings::Settings {
10767 show_images: Some(true),
10768 terminal: Some(crate::settings::TerminalSettings {
10769 show_images: Some(false),
10770 show_terminal_progress: None,
10771 clear_on_shrink: None,
10772 }),
10773 ..Default::default()
10774 };
10775 let resolved =
10776 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10777 assert!(resolved.show_images);
10778 }
10779
10780 #[test]
10781 fn tui_startup_settings_ignore_untrusted_project_values() {
10782 let global = crate::settings::Settings {
10783 quiet_startup: Some(false),
10784 show_terminal_progress: Some(true),
10785 ..Default::default()
10786 };
10787 let project = crate::settings::Settings {
10788 quiet_startup: Some(true),
10789 show_terminal_progress: Some(false),
10790 ..Default::default()
10791 };
10792
10793 let resolved = resolve_tui_startup_settings(&global, &[project], false);
10794
10795 assert!(!resolved.quiet_startup);
10796 assert!(resolved.show_terminal_progress);
10797 }
10798
10799 #[test]
10800 fn transcript_page_uses_viewport_with_overlap() {
10801 assert_eq!(transcript_page_size(24), 20);
10802 assert_eq!(transcript_page_size(4), 1);
10803 assert_eq!(transcript_page_size(0), 1);
10804 }
10805
10806 #[test]
10807 fn parse_user_bash_distinguishes_single_and_double_bang() {
10808 assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10810 assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10812 assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
10814 assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
10815 }
10816
10817 #[test]
10818 fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10819 assert_eq!(parse_user_bash("!"), None);
10822 assert_eq!(parse_user_bash("!!"), None);
10823 assert_eq!(parse_user_bash("! "), None);
10824 assert_eq!(parse_user_bash("hello"), None);
10825 assert_eq!(parse_user_bash(""), None);
10826 assert_eq!(parse_user_bash("/model"), None);
10828 }
10829
10830 #[test]
10831 fn user_bash_run_slot_guards_and_cancels() {
10832 let state = test_tui_state();
10833 assert!(!state.user_bash_running());
10834 let cancel = state.begin_user_bash();
10835 assert!(state.user_bash_running());
10836 assert!(state.cancel_user_bash());
10839 assert!(state.user_bash_running());
10840 assert!(cancel.is_cancelled());
10841 state.finish_user_bash();
10842 assert!(!state.user_bash_running());
10843 assert!(!state.cancel_user_bash());
10845 }
10846
10847 #[test]
10848 fn key_repeat_is_dispatched_but_release_is_not() {
10849 assert!(should_dispatch_key(KeyEventKind::Press));
10850 assert!(should_dispatch_key(KeyEventKind::Repeat));
10851 assert!(!should_dispatch_key(KeyEventKind::Release));
10852 }
10853
10854 #[test]
10855 fn key_event_encoding_matches_pi_keybinding_protocol() {
10856 let key = |code, modifiers| KeyEvent::new(code, modifiers);
10857 assert_eq!(
10858 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
10859 "\r"
10860 );
10861 assert_eq!(
10862 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
10863 "\x1b[13;2u"
10864 );
10865 assert_eq!(
10866 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
10867 "\x1b[9;2u"
10868 );
10869 assert_eq!(
10870 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
10871 "\x1b[Z"
10872 );
10873 assert_eq!(
10874 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
10875 "\x1b[Z"
10876 );
10877 assert_eq!(
10878 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
10879 "\x03"
10880 );
10881 assert_eq!(
10882 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
10883 "\x0f"
10884 );
10885 assert_eq!(
10886 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
10887 "!"
10888 );
10889 assert_eq!(
10890 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
10891 "1"
10892 );
10893 }
10894
10895 #[test]
10896 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
10897 let bridge = JsDialogBridge::default();
10898 let (sender, receiver) = std_mpsc::channel();
10899 bridge.pending.lock().unwrap().push_back(JsDialogPending {
10900 request: JsDialogRequest {
10901 id: "dialog-1".into(),
10902 method: "input".into(),
10903 title: String::new(),
10904 message: String::new(),
10905 options: Vec::new(),
10906 placeholder: None,
10907 prefill: None,
10908 },
10909 result: sender,
10910 });
10911
10912 bridge.cancel("dialog-1");
10915 assert!(bridge.take_pending().is_none());
10916 assert_eq!(
10917 receiver.recv().unwrap(),
10918 serde_json::json!({ "cancelled": true })
10919 );
10920 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
10921 }
10922
10923 #[test]
10924 fn test_layout_renders_welcome_message() {
10925 let chat = Arc::new(Container::new());
10926 add_welcome_message(&chat);
10927
10928 let scroll = Arc::new(ScrollView::new(
10929 chat.clone(),
10930 ScrollViewOptions {
10931 follow: FollowMode::End,
10932 primary: true,
10933 ..Default::default()
10934 },
10935 ));
10936
10937 let editor = Arc::new(Editor::new(
10938 EditorOptions {
10939 padding_x: 1,
10940 ..Default::default()
10941 },
10942 EditorStyle::default(),
10943 Arc::new(rpi_tui::Keybindings::new()),
10944 ));
10945 let dock = Arc::new(Container::new());
10946 dock.add_child(editor);
10947
10948 let footer = Arc::new(FooterComponent::new());
10949
10950 let root = VStack::from_children(vec![
10951 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
10952 StackChild::Entry(StackEntry::new(dock)),
10953 StackChild::Entry(StackEntry::new(footer)),
10954 ]);
10955
10956 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
10957
10958 let all: String = frame.lines.join("\n");
10959 assert!(
10960 all.contains("rpi"),
10961 "Welcome message not found. Rendered: {}",
10962 all
10963 );
10964 assert!(
10965 all.contains("Type your message"),
10966 "Help text not found. Rendered: {}",
10967 all
10968 );
10969 }
10970
10971 #[test]
10972 fn test_chat_container_has_welcome_content() {
10973 let chat = Arc::new(Container::new());
10974 add_welcome_message_with_capabilities(
10975 &chat,
10976 &["read".into(), "bash".into(), "web_fetch".into()],
10977 &["rust-review".into(), "release".into()],
10978 );
10979
10980 let lines = chat.render(80);
10981 let all: String = lines.join("\n");
10982 let plain = strip_ansi(&all);
10985 assert!(
10986 plain.contains("rpi"),
10987 "Welcome message not in chat container: {:?}",
10988 lines
10989 );
10990 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
10991 assert!(
10992 plain.contains("read · bash · web_fetch"),
10993 "Tool names missing: {plain}"
10994 );
10995 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
10996 assert!(
10997 plain.contains("rust-review · release"),
10998 "Skill names missing: {plain}"
10999 );
11000 }
11001
11002 #[test]
11003 fn update_notices_render_inside_the_transcript() {
11004 let chat = Arc::new(Container::new());
11005 let report = crate::updates::UpdateReport {
11006 notices: vec![
11007 crate::updates::UpdateNotice {
11008 name: "rpi".into(),
11009 current: "0.1.10".into(),
11010 latest: "0.1.11".into(),
11011 command: "rpi update".into(),
11012 },
11013 crate::updates::UpdateNotice {
11014 name: "rpi-search".into(),
11015 current: "0.1.0".into(),
11016 latest: "0.1.1".into(),
11017 command: "rpi pi-package update".into(),
11018 },
11019 ],
11020 warnings: vec![crate::updates::UpdateWarning {
11021 message: "The previously scheduled rpi update failed: access denied".into(),
11022 command: "rpi update".into(),
11023 }],
11024 };
11025
11026 add_update_notices(&chat, &report);
11027
11028 assert_eq!(chat.child_count(), 1);
11029 let plain = strip_ansi(&chat.render(80).join("\n"));
11030 assert!(plain.contains("Update Failed"), "{plain}");
11031 assert!(
11032 plain.contains("rpi update failed: access denied"),
11033 "{plain}"
11034 );
11035 assert!(plain.contains("Update Available"), "{plain}");
11036 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11037 assert!(plain.contains("rpi update"), "{plain}");
11038 assert!(plain.contains("Package Updates Available"), "{plain}");
11039 assert!(plain.contains("rpi pi-package update"), "{plain}");
11040 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11041 }
11042
11043 #[test]
11044 fn empty_update_report_does_not_add_transcript_content() {
11045 let chat = Arc::new(Container::new());
11046
11047 add_update_notices(&chat, &crate::updates::UpdateReport::default());
11048
11049 assert_eq!(chat.child_count(), 0);
11050 }
11051
11052 #[test]
11053 fn skill_reads_are_detected_by_path() {
11054 let name = skill_tool_name(
11055 "read",
11056 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
11057 );
11058 assert_eq!(name.as_deref(), Some("release"));
11059
11060 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
11061 assert!(name.is_none());
11062
11063 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
11065 }
11066
11067 #[test]
11068 fn welcome_capabilities_show_empty_state() {
11069 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11070 assert_eq!(plain, "Skills (0) none");
11071 }
11072
11073 #[test]
11074 fn empty_tool_progress_is_deferred_until_start() {
11075 let empty = rpi_agent::AgentToolResult::default();
11076 assert!(!tool_update_has_payload("", &empty));
11077
11078 let text = rpi_agent::AgentToolResult::text("partial output");
11079 assert!(tool_update_has_payload("partial output", &text));
11080
11081 let details = rpi_agent::AgentToolResult {
11082 details: serde_json::json!({"path": "src/lib.rs"}),
11083 ..Default::default()
11084 };
11085 assert!(tool_update_has_payload("", &details));
11086 }
11087
11088 #[test]
11089 fn completed_stream_reconciles_the_final_tail_before_detaching() {
11090 let component = Arc::new(AssistantMessageComponent::new(
11091 AssistantMessageOptions::default(),
11092 ));
11093 component.set_streaming(true);
11094 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11095
11096 let current = Mutex::new(Some(component.clone()));
11097 let cached = Mutex::new("partial response".to_string());
11098 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11099 final_message.content = vec![Content::text(
11100 "partial response with the previously missing final tail",
11101 )];
11102 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11103
11104 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
11105
11106 assert!(current.lock().unwrap().is_none());
11107 assert_eq!(
11108 cached.lock().unwrap().as_str(),
11109 "partial response with the previously missing final tail"
11110 );
11111 let rendered = strip_ansi(&component.render(100).join("\n"));
11112 assert!(
11113 rendered.contains("previously missing final tail"),
11114 "{rendered}"
11115 );
11116 }
11117
11118 #[test]
11124 fn tab_accept_suggestion_reflects_in_next_render() {
11125 use rpi_tui::render_layout_frame;
11126
11127 let editor = Arc::new(Editor::new(
11128 EditorOptions {
11129 padding_x: 1,
11130 ..Default::default()
11131 },
11132 EditorStyle::default(),
11133 Arc::new(rpi_tui::Keybindings::new()),
11134 ));
11135 editor.set_focused(true);
11136 let editor_container = Arc::new(Container::new());
11137 editor_container.add_child(editor.clone());
11138 let autocomplete_container = Arc::new(Container::new());
11139 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11140 let dock = Arc::new(VStack::from_children(vec![
11141 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11142 StackChild::Entry(
11143 StackEntry::new(editor_container.clone())
11144 .shrink(0)
11145 .min_size(3),
11146 ),
11147 StackChild::Entry(StackEntry::new(footer)),
11148 ]));
11149
11150 let manager = AutocompleteManager::new();
11152 let mut combined = CombinedAutocompleteProvider::new();
11153 combined.add_provider(Arc::new(
11154 SlashCommandAutocompleteProvider::with_default_commands(),
11155 ));
11156 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11157 manager.set_provider(Arc::new(combined));
11158 editor.insert("/mo");
11161 assert_eq!(editor.cursor_position(), (0, 3));
11162
11163 let frame_before = render_layout_frame(dock.clone(), 80, 10);
11164 assert!(
11165 frame_before.lines.iter().any(|l| l.contains("/mo")),
11166 "precondition: editor shows the typed prefix. Frame rows:\n{}",
11167 frame_before
11168 .lines
11169 .iter()
11170 .map(|l| format!(" [{l}]"))
11171 .collect::<Vec<_>>()
11172 .join("\n")
11173 );
11174
11175 let text = editor.get_text();
11177 let cursor = editor_cursor_offset(&editor, &text);
11178 let sugg = manager
11179 .get_suggestions(&text, cursor)
11180 .expect("slash suggestions for /mo");
11181 let top = sugg.items.first().expect("at least one suggestion");
11182 let start = sugg.start.min(text.len());
11183 let end = sugg.end.min(text.len());
11184 let mut replaced = String::new();
11185 replaced.push_str(&text[..start]);
11186 replaced.push_str(&top.text);
11187 replaced.push_str(&text[end..]);
11188 if top.insert_space && !replaced.ends_with('/') {
11189 replaced.push(' ');
11190 }
11191 let new_cursor = start + top.text.len();
11192 editor.set_text(&replaced);
11193 set_editor_cursor_offset(&editor, &replaced, new_cursor);
11194 autocomplete_container.clear();
11195 assert_eq!(editor.get_text(), "/model");
11196
11197 let frame_after = render_layout_frame(dock, 80, 10);
11199 let all: String = frame_after.lines.join("\n");
11200 assert!(
11201 all.contains("/model"),
11202 "completed text missing from next render. Got:\n{all}"
11203 );
11204 let editor_line = frame_after
11208 .lines
11209 .iter()
11210 .find(|l| l.contains("/model"))
11211 .expect("editor row with completed text");
11212 assert!(
11213 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11214 "caret must follow the full completed text. Got: {editor_line:?}"
11215 );
11216 }
11217
11218 #[test]
11219 fn multiline_autocomplete_preserves_row_and_column() {
11220 let editor = Arc::new(Editor::simple());
11221 editor.set_text("first\n/mo");
11222 editor.set_cursor(1, 3);
11223
11224 let text = editor.get_text();
11225 assert_eq!(editor_cursor_offset(&editor, &text), 9);
11226
11227 let replaced = "first\n/model";
11228 editor.set_text(replaced);
11229 set_editor_cursor_offset(&editor, replaced, 12);
11230 assert_eq!(editor.cursor_position(), (1, 6));
11231 }
11232
11233 #[test]
11234 fn test_slash_command_dispatch() {
11235 let registry = build_builtin_registry();
11240
11241 let resolves_to = |token: &str, canonical: &str| {
11243 let found = registry.find(token).expect("{token} should resolve");
11244 assert_eq!(
11245 found.name(),
11246 canonical,
11247 "{token} resolved to {} (expected {canonical})",
11248 found.name()
11249 );
11250 };
11251
11252 resolves_to("/help", "/help");
11253 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
11255 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
11259 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
11261 resolves_to("/hotkeys", "/hotkeys");
11262 resolves_to("/model", "/model");
11263 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
11265 resolves_to("/session", "/session");
11266 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
11268 resolves_to("/copy", "/copy");
11269 resolves_to("/thinking", "/thinking");
11270 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
11272 resolves_to("/images", "/images");
11273 resolves_to("/armin", "/armin");
11274 resolves_to("/earendil", "/earendil");
11275 resolves_to("/context", "/context");
11276 resolves_to("/settings", "/settings");
11278 resolves_to("/name", "/name");
11279 resolves_to("/export", "/export");
11280
11281 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11283 }
11284
11285 #[test]
11286
11287 fn test_registry_visible_entries_cover_dispatch() {
11288 let registry = build_builtin_registry();
11293 let names: Vec<String> = registry
11294 .visible_entries()
11295 .iter()
11296 .map(|c| c.name.clone())
11297 .collect();
11298 for recognized in [
11299 "/help",
11300 "/clear",
11301 "/new",
11302 "/exit",
11303 "/quit",
11304 "/version",
11305 "/changelog",
11306 "/model",
11307 "/session",
11308 "/theme",
11309 "/compact",
11310 "/copy",
11311 "/hotkeys",
11312 "/tools",
11313 "/images",
11314 "/thinking",
11315 "/usage",
11316 "/armin",
11317 "/earendil",
11318 ] {
11319 assert!(
11320 names.contains(&recognized.to_string()),
11321 "{recognized} missing from autocomplete list"
11322 );
11323 }
11324 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11326 assert!(
11327 !names.contains(&hidden.to_string()),
11328 "{hidden} should be hidden from autocomplete"
11329 );
11330 }
11331 }
11332
11333 #[test]
11334 fn test_agent_event_mapping_creates_assistant_and_tool() {
11335 use rpi_ai::types::{
11338 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11339 ToolCall, ToolCallType, Usage,
11340 };
11341
11342 let state = Arc::new(TuiState {
11343 current_assistant: std::sync::Mutex::new(None),
11344 tool_components: std::sync::Mutex::new(HashMap::new()),
11345 bash_components: std::sync::Mutex::new(HashMap::new()),
11346 themes_enabled: true,
11347 hide_thinking: std::sync::Mutex::new(false),
11348 tool_outputs_expanded: std::sync::Mutex::new(false),
11349 show_terminal_progress: true,
11350 status: std::sync::Mutex::new(RunStatus::Idle),
11351 js_preparation_cancel: std::sync::Mutex::new(None),
11352 user_bash_cancel: std::sync::Mutex::new(None),
11353 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11354 footer: Arc::new(FooterComponent::new()),
11355 status_container: Arc::new(Container::new()),
11356 chat_container: Arc::new(Container::new()),
11357 loader: Arc::new(Loader::new()),
11358 editor: Arc::new(Editor::simple()),
11359 last_assistant_text: std::sync::Mutex::new(String::new()),
11360 active_selector: std::sync::Mutex::new(None),
11361 active_extension_editor: std::sync::Mutex::new(None),
11362 active_extension_input: std::sync::Mutex::new(None),
11363 active_extension_cancel: std::sync::Mutex::new(None),
11364 autocomplete: AutocompleteManager::new(),
11365 autocomplete_container: Arc::new(Container::new()),
11366 autocomplete_max_visible: 5,
11367 pending_images: std::sync::Mutex::new(Vec::new()),
11368 theme_manager: Arc::new(ThemeManager::new()),
11369 tui: None,
11370 current_model_id: std::sync::Mutex::new(String::new()),
11371 show_images: std::sync::Mutex::new(true),
11372 cache_miss_notices: std::sync::Mutex::new(false),
11373 history: std::sync::Mutex::new(Vec::new()),
11374 history_index: std::sync::Mutex::new(-1),
11375 history_draft: std::sync::Mutex::new(None),
11376 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11377 scoped_edit: std::sync::Mutex::new(None),
11378 markdown_transformer: std::sync::Mutex::new(None),
11379 extension_session: Arc::new(std::sync::Mutex::new(
11380 rpi_extensions::ExtensionSession::none(),
11381 )),
11382 search: Arc::new(AltScreenSearch::new()),
11383 search_bar: Arc::new(SearchBar::new()),
11384 pending_container: Arc::new(Container::new()),
11385 dequeue_hint: String::new(),
11386 pending_snapshot: std::sync::Mutex::new(
11387 rpi_harness::agent_harness::QueuedMessages::default(),
11388 ),
11389 selection_start: std::sync::Mutex::new(None),
11390 selection_end: std::sync::Mutex::new(None),
11391 });
11392
11393 let assistant = AssistantMessage {
11397 role: rpi_ai::types::AssistantRole,
11398 content: vec![
11399 Content::Thinking(ThinkingContent {
11400 kind: ThinkingContentType,
11401 thinking: "Reasoning about the reply.".into(),
11402 thinking_signature: None,
11403 redacted: false,
11404 }),
11405 Content::Text(TextContent {
11406 kind: TextContentType,
11407 text: "Hello.".into(),
11408 text_signature: None,
11409 }),
11410 Content::ToolCall(ToolCall {
11411 kind: ToolCallType,
11412 id: "tc1".into(),
11413 name: "bash".into(),
11414 arguments: serde_json::json!({"command": "echo hi"}),
11415 thought_signature: None,
11416 namespace: None,
11417 }),
11418 ],
11419 api: rpi_ai::Api::AnthropicMessages,
11420 provider: "anthropic".into(),
11421 model: "claude-sonnet-5".into(),
11422 response_model: None,
11423 response_id: None,
11424 usage: Usage::zero(),
11425 stop_reason: StopReason::Stop,
11426 deferred: None,
11427 error_message: None,
11428 raw_stop_reason: None,
11429 end_turn: None,
11430 timestamp: 0,
11431 };
11432
11433 let comp = Arc::new(AssistantMessageComponent::new(
11436 AssistantMessageOptions::default(),
11437 ));
11438 comp.set_streaming(true);
11439 comp.update_blocks(&assistant_blocks(&assistant));
11440 let chat = Arc::new(Container::new());
11441 chat.add_child(comp.clone());
11442 *state.current_assistant.lock().unwrap() = Some(comp);
11443
11444 for c in &assistant.content {
11446 if let Content::ToolCall(tc) = c {
11447 let mut tools = state.tool_components.lock().unwrap();
11448 if !tools.contains_key(&tc.id) {
11449 let tc_comp = Arc::new(ToolExecutionComponent::new(
11450 &tc.name,
11451 &tc.arguments.to_string(),
11452 ));
11453 tc_comp.set_running();
11454 chat.add_child(tc_comp.clone());
11455 tools.insert(tc.id.clone(), tc_comp);
11456 }
11457 }
11458 }
11459
11460 let rendered = chat.render(80);
11464 let joined: String = rendered.join("\n");
11465 assert!(
11466 joined.contains("Hello."),
11467 "assistant text not rendered: {joined}"
11468 );
11469 assert!(
11470 joined.contains("Reasoning about the reply."),
11471 "thinking block not rendered: {joined}"
11472 );
11473 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11474 assert!(state.current_assistant.lock().unwrap().is_some());
11475
11476 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11478 ended.set_result("hi", false);
11479 assert!(state.tool_components.lock().unwrap().is_empty());
11480
11481 assert!(state.try_start_working());
11485 assert!(
11486 !state.try_start_working(),
11487 "a second submit must be rejected"
11488 );
11489 state.set_status(RunStatus::Idle);
11490 state.set_status(RunStatus::Working);
11491 assert!(state.editor.working().is_some());
11493 assert_eq!(state.status_container.child_count(), 0);
11494 assert!(state.footer.get_status().is_empty());
11495 state.show_retry(3, 10, 8_000);
11496 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11497 assert!(retry_status.contains("Retrying (3/10)"));
11498 state.set_status(RunStatus::Working);
11499 {
11500 let mut bash = state.bash_components.lock().unwrap();
11501 bash.insert(
11502 "bash-1".into(),
11503 Arc::new(BashExecutionComponent::new("one")),
11504 );
11505 bash.insert(
11506 "bash-2".into(),
11507 Arc::new(BashExecutionComponent::new("two")),
11508 );
11509 }
11510 state.sync_working_loader_with_bash();
11511 assert_eq!(state.status_container.child_count(), 0);
11512 state.bash_components.lock().unwrap().remove("bash-1");
11513 state.sync_working_loader_with_bash();
11514 assert_eq!(state.status_container.child_count(), 0);
11515 state.bash_components.lock().unwrap().remove("bash-2");
11516 state.sync_working_loader_with_bash();
11517 assert_eq!(state.status_container.child_count(), 0);
11520 assert!(state.editor.working().is_some());
11521
11522 state.set_status(RunStatus::Aborting);
11523 assert_eq!(state.status_container.child_count(), 0);
11524 assert!(!state.loader.is_running());
11525 }
11526
11527 #[test]
11528 fn fresh_launch_does_not_restore_old_history() {
11529 let fresh = Args::default();
11530 assert!(!launch_restores_history(&fresh));
11531
11532 let continued = Args {
11533 continue_session: true,
11534 ..Args::default()
11535 };
11536 assert!(launch_restores_history(&continued));
11537
11538 let selected = Args {
11539 session: Some("session-id".into()),
11540 ..Args::default()
11541 };
11542 assert!(launch_restores_history(&selected));
11543 }
11544
11545 #[test]
11546 fn context_badge_ignores_aborted_and_error_turns() {
11547 use rpi_ai::types::{Api, StopReason, Usage};
11548
11549 let footer = Arc::new(FooterComponent::new());
11550 footer.set_context_window(100_000);
11551
11552 let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11554 ok.stop_reason = StopReason::Stop;
11555 ok.usage = Usage {
11556 input: 25_000,
11557 ..Usage::zero()
11558 };
11559 record_context_usage(&footer, &ok);
11560 let stats = strip_ansi(&footer.render(120).join("\n"));
11561 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11562
11563 let mut aborted =
11565 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11566 aborted.stop_reason = StopReason::Aborted;
11567 aborted.usage = Usage {
11568 input: 90_000,
11569 ..Usage::zero()
11570 };
11571 record_context_usage(&footer, &aborted);
11572 let stats = strip_ansi(&footer.render(120).join("\n"));
11573 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11574
11575 let mut errored =
11577 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11578 errored.stop_reason = StopReason::Error;
11579 errored.usage = Usage {
11580 input: 90_000,
11581 ..Usage::zero()
11582 };
11583 record_context_usage(&footer, &errored);
11584 let stats = strip_ansi(&footer.render(120).join("\n"));
11585 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11586 }
11587
11588 #[test]
11589 fn test_short_model_name() {
11590 assert_eq!(
11591 short_model_name("anthropic:claude-sonnet-5"),
11592 "claude-sonnet-5"
11593 );
11594 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
11595 }
11596
11597 #[test]
11598 fn model_selector_items_are_deduplicated_and_provider_qualified() {
11599 use rpi_ai::{Api, Model};
11600
11601 let mut gateway = Model::new(
11602 "gpt-5.6-sol",
11603 "GPT 5.6 Sol",
11604 Api::OpenaiCompletions,
11605 "routeryo-copy",
11606 "https://gateway.example.com",
11607 );
11608 let duplicate = gateway.clone();
11609 let anthropic = Model::new(
11610 "claude-sonnet-5",
11611 "Claude Sonnet 5",
11612 Api::AnthropicMessages,
11613 "anthropic",
11614 "https://api.anthropic.com",
11615 );
11616 gateway.headers = Some(std::collections::BTreeMap::from([(
11617 "authorization".into(),
11618 "Bearer test".into(),
11619 )]));
11620
11621 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
11622 assert_eq!(items.len(), 2);
11623 assert_eq!(items[0].value, "gpt-5.6-sol");
11624 assert_eq!(items[0].label, "GPT 5.6 Sol");
11625 assert_eq!(
11626 items[0].description.as_deref(),
11627 Some("routeryo-copy/gpt-5.6-sol (current)")
11628 );
11629 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
11630 }
11631
11632 #[test]
11633 fn model_selector_search_text_covers_provider_name_and_qualified_id() {
11634 use rpi_ai::{Api, Model};
11635
11636 let anthropic = Model::new(
11637 "claude-sonnet-5",
11638 "Claude Sonnet 5",
11639 Api::AnthropicMessages,
11640 "anthropic",
11641 "https://api.anthropic.com",
11642 );
11643 let items = model_selector_items(&[anthropic], "");
11644 let search = items[0].search_key();
11645 assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
11648 assert!(search.contains("Claude Sonnet 5"), "{search}");
11649
11650 use rpi_tui::fuzzy_filter;
11653 for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
11654 let matched = fuzzy_filter(&items, query, |item| item.search_key());
11655 assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
11656 }
11657 }
11658
11659 #[test]
11660 fn model_selector_match_accepts_bare_and_qualified_ids() {
11661 use rpi_ai::{Api, Model};
11662
11663 let gateway = Model::new(
11664 "gpt-5.6-sol",
11665 "GPT 5.6 Sol",
11666 Api::OpenaiCompletions,
11667 "routeryo-copy",
11668 "https://gateway.example.com",
11669 );
11670 let anthropic = Model::new(
11671 "claude-sonnet-5",
11672 "Claude Sonnet 5",
11673 Api::AnthropicMessages,
11674 "anthropic",
11675 "https://api.anthropic.com",
11676 );
11677 let catalog = [gateway, anthropic];
11678 assert_eq!(
11679 find_model_selector_match(&catalog, "gpt-5.6-sol")
11680 .unwrap()
11681 .provider,
11682 "routeryo-copy"
11683 );
11684 assert_eq!(
11685 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
11686 .unwrap()
11687 .id,
11688 "gpt-5.6-sol"
11689 );
11690 assert_eq!(
11691 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
11692 .unwrap()
11693 .id,
11694 "claude-sonnet-5"
11695 );
11696 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
11697 }
11698
11699 #[test]
11700 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
11701 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
11702
11703 let failed = AssistantMessage {
11704 role: AssistantRole,
11705 content: Vec::new(),
11706 api: rpi_ai::Api::AnthropicMessages,
11707 provider: "anthropic".into(),
11708 model: "claude-sonnet-5".into(),
11709 response_model: None,
11710 response_id: None,
11711 usage: Usage::zero(),
11712 stop_reason: StopReason::Error,
11713 deferred: None,
11714 error_message: Some("upstream returned 401".into()),
11715 raw_stop_reason: None,
11716 end_turn: None,
11717 timestamp: 0,
11718 };
11719 assert_eq!(
11720 assistant_error_text(&failed).as_deref(),
11721 Some("upstream returned 401")
11722 );
11723
11724 let mut no_detail = failed;
11725 no_detail.error_message = Some(" ".into());
11726 assert_eq!(
11727 assistant_error_text(&no_detail).as_deref(),
11728 Some("Provider request failed.")
11729 );
11730
11731 let mut aborted = no_detail;
11732 aborted.stop_reason = StopReason::Aborted;
11733 aborted.error_message = Some("abort error: Request aborted".into());
11734 assert_eq!(
11735 assistant_error_text(&aborted).as_deref(),
11736 Some("abort error: Request aborted")
11737 );
11738
11739 aborted.error_message = None;
11740 assert_eq!(
11741 assistant_error_text(&aborted).as_deref(),
11742 Some("Request aborted.")
11743 );
11744 }
11745
11746 #[test]
11747 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
11748 assert_eq!(
11749 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
11750 "405\nMethod Not Allowed"
11751 );
11752 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
11753 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
11754 let long = "x".repeat(20_000);
11755 let cleaned = sanitize_error_message(&long);
11756 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
11757 assert!(cleaned.ends_with('…'));
11758 }
11759
11760 #[test]
11761 fn test_cycle_next_model_wraps_around() {
11762 use rpi_ai::{Api, Model};
11763 let mk = |id: &str| {
11764 Model::new(
11765 id,
11766 id,
11767 Api::AnthropicMessages,
11768 "anthropic",
11769 "https://api.anthropic.com",
11770 )
11771 };
11772 let catalog = [mk("a"), mk("b"), mk("c")];
11773 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
11775 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
11776 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
11778 let empty: Vec<Model> = vec![];
11780 assert!(cycle_next_model(&empty, "a").is_none());
11781 }
11782
11783 #[test]
11784 fn test_autocomplete_slash_suggestions_render() {
11785 let state = Arc::new(TuiState {
11788 current_assistant: std::sync::Mutex::new(None),
11789 tool_components: std::sync::Mutex::new(HashMap::new()),
11790 bash_components: std::sync::Mutex::new(HashMap::new()),
11791 themes_enabled: true,
11792 hide_thinking: std::sync::Mutex::new(false),
11793 tool_outputs_expanded: std::sync::Mutex::new(false),
11794 show_terminal_progress: true,
11795 status: std::sync::Mutex::new(RunStatus::Idle),
11796 js_preparation_cancel: std::sync::Mutex::new(None),
11797 user_bash_cancel: std::sync::Mutex::new(None),
11798 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11799 footer: Arc::new(FooterComponent::new()),
11800 status_container: Arc::new(Container::new()),
11801 chat_container: Arc::new(Container::new()),
11802 loader: Arc::new(Loader::new()),
11803 editor: Arc::new(Editor::simple()),
11804 last_assistant_text: std::sync::Mutex::new(String::new()),
11805 active_selector: std::sync::Mutex::new(None),
11806 active_extension_editor: std::sync::Mutex::new(None),
11807 active_extension_input: std::sync::Mutex::new(None),
11808 active_extension_cancel: std::sync::Mutex::new(None),
11809 autocomplete: AutocompleteManager::new(),
11810 autocomplete_container: Arc::new(Container::new()),
11811 autocomplete_max_visible: 5,
11812 pending_images: std::sync::Mutex::new(Vec::new()),
11813 theme_manager: Arc::new(ThemeManager::new()),
11814 tui: None,
11815 current_model_id: std::sync::Mutex::new(String::new()),
11816 show_images: std::sync::Mutex::new(true),
11817 cache_miss_notices: std::sync::Mutex::new(false),
11818 history: std::sync::Mutex::new(Vec::new()),
11819 history_index: std::sync::Mutex::new(-1),
11820 history_draft: std::sync::Mutex::new(None),
11821 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11822 scoped_edit: std::sync::Mutex::new(None),
11823 markdown_transformer: std::sync::Mutex::new(None),
11824 extension_session: Arc::new(std::sync::Mutex::new(
11825 rpi_extensions::ExtensionSession::none(),
11826 )),
11827 search: Arc::new(AltScreenSearch::new()),
11828 search_bar: Arc::new(SearchBar::new()),
11829 pending_container: Arc::new(Container::new()),
11830 dequeue_hint: String::new(),
11831 pending_snapshot: std::sync::Mutex::new(
11832 rpi_harness::agent_harness::QueuedMessages::default(),
11833 ),
11834 selection_start: std::sync::Mutex::new(None),
11835 selection_end: std::sync::Mutex::new(None),
11836 });
11837 {
11838 let mut combined = CombinedAutocompleteProvider::new();
11839 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11840 build_builtin_registry().visible_entries(),
11841 )));
11842 state.autocomplete.set_provider(Arc::new(combined));
11843 }
11844
11845 let editor = Arc::new(Editor::simple());
11846 editor.set_text("/he");
11847 editor.set_cursor(0, 3);
11848 refresh_autocomplete(&state, &editor);
11849 let lines = state.autocomplete_container.render(80);
11850 let joined: String = lines.join("\n");
11851 assert!(
11852 joined.contains("/help"),
11853 "slash suggestions not rendered: {joined}"
11854 );
11855
11856 editor.set_text("hello");
11858 editor.set_cursor(0, 5);
11859 refresh_autocomplete(&state, &editor);
11860 assert!(state.autocomplete_container.render(80).is_empty());
11861 }
11862
11863 #[test]
11864 fn test_select_list_swap_restores_editor() {
11865 let state = Arc::new(TuiState {
11869 current_assistant: std::sync::Mutex::new(None),
11870 tool_components: std::sync::Mutex::new(HashMap::new()),
11871 bash_components: std::sync::Mutex::new(HashMap::new()),
11872 themes_enabled: true,
11873 hide_thinking: std::sync::Mutex::new(false),
11874 tool_outputs_expanded: std::sync::Mutex::new(false),
11875 show_terminal_progress: true,
11876 status: std::sync::Mutex::new(RunStatus::Idle),
11877 js_preparation_cancel: std::sync::Mutex::new(None),
11878 user_bash_cancel: std::sync::Mutex::new(None),
11879 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11880 footer: Arc::new(FooterComponent::new()),
11881 status_container: Arc::new(Container::new()),
11882 chat_container: Arc::new(Container::new()),
11883 loader: Arc::new(Loader::new()),
11884 editor: Arc::new(Editor::simple()),
11885 last_assistant_text: std::sync::Mutex::new(String::new()),
11886 active_selector: std::sync::Mutex::new(None),
11887 active_extension_editor: std::sync::Mutex::new(None),
11888 active_extension_input: std::sync::Mutex::new(None),
11889 active_extension_cancel: std::sync::Mutex::new(None),
11890 autocomplete: AutocompleteManager::new(),
11891 autocomplete_container: Arc::new(Container::new()),
11892 autocomplete_max_visible: 5,
11893 pending_images: std::sync::Mutex::new(Vec::new()),
11894 theme_manager: Arc::new(ThemeManager::new()),
11895 tui: None,
11896 current_model_id: std::sync::Mutex::new(String::new()),
11897 show_images: std::sync::Mutex::new(true),
11898 cache_miss_notices: std::sync::Mutex::new(false),
11899 history: std::sync::Mutex::new(Vec::new()),
11900 history_index: std::sync::Mutex::new(-1),
11901 history_draft: std::sync::Mutex::new(None),
11902 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11903 scoped_edit: std::sync::Mutex::new(None),
11904 markdown_transformer: std::sync::Mutex::new(None),
11905 extension_session: Arc::new(std::sync::Mutex::new(
11906 rpi_extensions::ExtensionSession::none(),
11907 )),
11908 search: Arc::new(AltScreenSearch::new()),
11909 search_bar: Arc::new(SearchBar::new()),
11910 pending_container: Arc::new(Container::new()),
11911 dequeue_hint: String::new(),
11912 pending_snapshot: std::sync::Mutex::new(
11913 rpi_harness::agent_harness::QueuedMessages::default(),
11914 ),
11915 selection_start: std::sync::Mutex::new(None),
11916 selection_end: std::sync::Mutex::new(None),
11917 });
11918 let editor_container = Arc::new(Container::new());
11919 let editor = Arc::new(Editor::simple());
11920 editor_container.add_child(editor.clone());
11921 assert!(!state.selector_open());
11922
11923 let tui_terminal = Box::new(ProcessTerminal::new());
11924 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
11925 let list = Arc::new(SelectList::new(
11926 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
11927 5,
11928 ));
11929 open_selector(
11930 &state,
11931 &editor_container,
11932 &editor,
11933 &tui,
11934 list,
11935 SelectorKind::Theme,
11936 );
11937 assert!(state.selector_open());
11938 assert_eq!(editor_container.child_count(), 1);
11940
11941 close_selector(&state, &editor_container, &editor, &tui);
11942 assert!(!state.selector_open());
11943 assert_eq!(editor_container.child_count(), 1);
11945 }
11946
11947 #[test]
11948 fn test_message_history_browse_restores_draft() {
11949 let state = Arc::new(TuiState {
11953 current_assistant: std::sync::Mutex::new(None),
11954 tool_components: std::sync::Mutex::new(HashMap::new()),
11955 bash_components: std::sync::Mutex::new(HashMap::new()),
11956 themes_enabled: true,
11957 hide_thinking: std::sync::Mutex::new(false),
11958 tool_outputs_expanded: std::sync::Mutex::new(false),
11959 show_terminal_progress: true,
11960 status: std::sync::Mutex::new(RunStatus::Idle),
11961 js_preparation_cancel: std::sync::Mutex::new(None),
11962 user_bash_cancel: std::sync::Mutex::new(None),
11963 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11964 footer: Arc::new(FooterComponent::new()),
11965 status_container: Arc::new(Container::new()),
11966 chat_container: Arc::new(Container::new()),
11967 loader: Arc::new(Loader::new()),
11968 editor: Arc::new(Editor::simple()),
11969 last_assistant_text: std::sync::Mutex::new(String::new()),
11970 active_selector: std::sync::Mutex::new(None),
11971 active_extension_editor: std::sync::Mutex::new(None),
11972 active_extension_input: std::sync::Mutex::new(None),
11973 active_extension_cancel: std::sync::Mutex::new(None),
11974 autocomplete: AutocompleteManager::new(),
11975 autocomplete_container: Arc::new(Container::new()),
11976 autocomplete_max_visible: 5,
11977 pending_images: std::sync::Mutex::new(Vec::new()),
11978 theme_manager: Arc::new(ThemeManager::new()),
11979 tui: None,
11980 current_model_id: std::sync::Mutex::new(String::new()),
11981 show_images: std::sync::Mutex::new(true),
11982 cache_miss_notices: std::sync::Mutex::new(false),
11983 history: std::sync::Mutex::new(Vec::new()),
11984 history_index: std::sync::Mutex::new(-1),
11985 history_draft: std::sync::Mutex::new(None),
11986 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11987 scoped_edit: std::sync::Mutex::new(None),
11988 markdown_transformer: std::sync::Mutex::new(None),
11989 extension_session: Arc::new(std::sync::Mutex::new(
11990 rpi_extensions::ExtensionSession::none(),
11991 )),
11992 search: Arc::new(AltScreenSearch::new()),
11993 search_bar: Arc::new(SearchBar::new()),
11994 pending_container: Arc::new(Container::new()),
11995 dequeue_hint: String::new(),
11996 pending_snapshot: std::sync::Mutex::new(
11997 rpi_harness::agent_harness::QueuedMessages::default(),
11998 ),
11999 selection_start: std::sync::Mutex::new(None),
12000 selection_end: std::sync::Mutex::new(None),
12001 });
12002 let editor = Arc::new(Editor::simple());
12003
12004 push_history(&state, "first message");
12005 push_history(&state, "second message");
12006 push_history(&state, "second message");
12008 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
12010 assert_eq!(state.history.lock().unwrap()[0], "second message");
12011
12012 editor.set_text("half-typed");
12014 editor.set_cursor(0, 11);
12015
12016 navigate_history(&state, &editor, -1);
12018 assert_eq!(editor.get_text(), "second message");
12019 assert_eq!(*state.history_index.lock().unwrap(), 0);
12020 navigate_history(&state, &editor, -1);
12022 assert_eq!(editor.get_text(), "first message");
12023 assert_eq!(*state.history_index.lock().unwrap(), 1);
12024 navigate_history(&state, &editor, -1);
12026 assert_eq!(editor.get_text(), "first message");
12027 navigate_history(&state, &editor, 1);
12029 assert_eq!(editor.get_text(), "second message");
12030 navigate_history(&state, &editor, 1);
12032 assert_eq!(editor.get_text(), "half-typed");
12033 assert_eq!(*state.history_index.lock().unwrap(), -1);
12034 }
12035
12036 #[test]
12037 fn test_accept_top_suggestion_replaces_prefix() {
12038 let state = Arc::new(TuiState {
12040 current_assistant: std::sync::Mutex::new(None),
12041 tool_components: std::sync::Mutex::new(HashMap::new()),
12042 bash_components: std::sync::Mutex::new(HashMap::new()),
12043 themes_enabled: true,
12044 hide_thinking: std::sync::Mutex::new(false),
12045 tool_outputs_expanded: std::sync::Mutex::new(false),
12046 show_terminal_progress: true,
12047 status: std::sync::Mutex::new(RunStatus::Idle),
12048 js_preparation_cancel: std::sync::Mutex::new(None),
12049 user_bash_cancel: std::sync::Mutex::new(None),
12050 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12051 footer: Arc::new(FooterComponent::new()),
12052 status_container: Arc::new(Container::new()),
12053 chat_container: Arc::new(Container::new()),
12054 loader: Arc::new(Loader::new()),
12055 editor: Arc::new(Editor::simple()),
12056 last_assistant_text: std::sync::Mutex::new(String::new()),
12057 active_selector: std::sync::Mutex::new(None),
12058 active_extension_editor: std::sync::Mutex::new(None),
12059 active_extension_input: std::sync::Mutex::new(None),
12060 active_extension_cancel: std::sync::Mutex::new(None),
12061 autocomplete: AutocompleteManager::new(),
12062 autocomplete_container: Arc::new(Container::new()),
12063 autocomplete_max_visible: 5,
12064 pending_images: std::sync::Mutex::new(Vec::new()),
12065 theme_manager: Arc::new(ThemeManager::new()),
12066 tui: None,
12067 current_model_id: std::sync::Mutex::new(String::new()),
12068 show_images: std::sync::Mutex::new(true),
12069 cache_miss_notices: std::sync::Mutex::new(false),
12070 history: std::sync::Mutex::new(Vec::new()),
12071 history_index: std::sync::Mutex::new(-1),
12072 history_draft: std::sync::Mutex::new(None),
12073 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12074 scoped_edit: std::sync::Mutex::new(None),
12075 markdown_transformer: std::sync::Mutex::new(None),
12076 extension_session: Arc::new(std::sync::Mutex::new(
12077 rpi_extensions::ExtensionSession::none(),
12078 )),
12079 search: Arc::new(AltScreenSearch::new()),
12080 search_bar: Arc::new(SearchBar::new()),
12081 pending_container: Arc::new(Container::new()),
12082 dequeue_hint: String::new(),
12083 pending_snapshot: std::sync::Mutex::new(
12084 rpi_harness::agent_harness::QueuedMessages::default(),
12085 ),
12086 selection_start: std::sync::Mutex::new(None),
12087 selection_end: std::sync::Mutex::new(None),
12088 });
12089 {
12090 let mut combined = CombinedAutocompleteProvider::new();
12091 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12092 build_builtin_registry().visible_entries(),
12093 )));
12094 state.autocomplete.set_provider(Arc::new(combined));
12095 }
12096 let editor = Arc::new(Editor::simple());
12097 editor.set_text("/he");
12098 editor.set_cursor(0, 3);
12099 refresh_autocomplete(&state, &editor);
12100 let accepted = accept_top_suggestion(&state, &editor);
12101 assert!(accepted, "should accept the top suggestion");
12102 let text = editor.get_text();
12103 assert!(
12104 text.starts_with("/help"),
12105 "editor text should start with /help, got {text}"
12106 );
12107 }
12108
12109 #[test]
12110 fn configured_key_parser_supports_native_notation() {
12111 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12112 assert_eq!(combo.code, KeyCode::Char('g'));
12113 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12114 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12115 assert_eq!(combo.code, KeyCode::BackTab);
12116 }
12117
12118 #[test]
12119 fn double_escape_trigger_has_half_second_window() {
12120 let now = std::time::Instant::now();
12121 assert!(!double_escape_trigger(None, now));
12122 assert!(double_escape_trigger(
12123 Some(now - std::time::Duration::from_millis(500)),
12124 now
12125 ));
12126 assert!(!double_escape_trigger(
12127 Some(now - std::time::Duration::from_millis(501)),
12128 now
12129 ));
12130 }
12131
12132 #[test]
12133 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12134 use std::sync::atomic::{AtomicUsize, Ordering};
12140
12141 let editor = rpi_tui::Editor::simple();
12142 let submits = Arc::new(AtomicUsize::new(0));
12143 let submits_for_cb = submits.clone();
12144 editor.on_submit(Arc::new(move |_text: &str| {
12145 submits_for_cb.fetch_add(1, Ordering::SeqCst);
12146 }));
12147
12148 let now = std::time::Instant::now();
12151 let mut last_text_key_at: Option<std::time::Instant> = None;
12152 let block = ["l1", "l2", "l3"];
12153 for (index, line) in block.iter().enumerate() {
12154 for ch in line.chars() {
12155 editor.insert(&ch.to_string());
12156 last_text_key_at = Some(now);
12157 }
12158 if index + 1 < block.len() {
12159 let more_queued = true;
12161 assert!(
12162 enter_is_paste_burst(last_text_key_at, now, more_queued),
12163 "pasted newline {index} must not submit"
12164 );
12165 editor.insert("\n");
12166 last_text_key_at = Some(now);
12167 }
12168 }
12169 assert_eq!(editor.get_text(), "l1\nl2\nl3");
12170 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12171
12172 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12174 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12175 editor.handle_key(crossterm::event::KeyEvent::new(
12176 KeyCode::Enter,
12177 KeyModifiers::NONE,
12178 ));
12179 assert_eq!(
12180 submits.load(Ordering::SeqCst),
12181 1,
12182 "typed Enter submits once"
12183 );
12184 }
12185
12186 #[test]
12187 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12188 let editor = rpi_tui::Editor::simple();
12191 editor.insert("alpha\r\nbeta\r\n");
12192 assert_eq!(editor.get_text(), "alpha\nbeta\n");
12193 assert!(!editor.get_text().contains('\r'));
12194 }
12195
12196 #[test]
12197 fn pasted_enter_is_not_a_submit() {
12198 let now = std::time::Instant::now();
12199
12200 assert!(enter_is_paste_burst(None, now, true));
12202
12203 assert!(enter_is_paste_burst(
12206 Some(now - std::time::Duration::from_millis(1)),
12207 now,
12208 false
12209 ));
12210 assert!(enter_is_paste_burst(
12211 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12212 now,
12213 false
12214 ));
12215
12216 assert!(!enter_is_paste_burst(None, now, false));
12218 assert!(!enter_is_paste_burst(
12219 Some(now - PASTE_BURST_GAP),
12220 now,
12221 false
12222 ));
12223 assert!(!enter_is_paste_burst(
12224 Some(now - std::time::Duration::from_millis(120)),
12225 now,
12226 false
12227 ));
12228 }
12229
12230 #[test]
12231 fn paste_probe_ignores_the_enter_keys_own_release() {
12232 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12233 let release =
12234 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12235
12236 let mut queued = vec![Event::Key(release)].into_iter();
12239 let (more, stashed) = probe_paste_input(None, || queued.next());
12240 assert!(!more, "a key release alone is not queued paste input");
12241 assert!(stashed.is_none());
12242
12243 let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12246 let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12247 let (more, stashed) = probe_paste_input(None, || queued.next());
12248 assert!(more, "a queued key press is paste continuation");
12249 assert!(matches!(stashed, Some(Event::Key(_))));
12250
12251 let (more, stashed) = probe_paste_input(None, || None);
12253 assert!(!more);
12254 assert!(stashed.is_none());
12255
12256 let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12258 panic!("must not read when an event is already stashed")
12259 });
12260 assert!(more);
12261 assert!(matches!(stashed, Some(Event::Key(_))));
12262 }
12263
12264 #[test]
12267 fn ask_user_tool_names_are_recognized() {
12268 assert!(is_ask_user_tool("ask_user"));
12269 assert!(is_ask_user_tool("ask_user_question"));
12270 assert!(!is_ask_user_tool("bash"));
12271 }
12272
12273 #[test]
12274 fn ask_user_args_display_never_leaks_transport_json() {
12275 let text = ask_user_args_display(&serde_json::json!({
12276 "question": "你的代理端口是多少?",
12277 "context": "本机",
12278 "options": ["7890", {"title": "7897", "description": "clash"}],
12279 "suggest": "1080"
12280 }));
12281 assert!(text.contains("你的代理端口是多少?"));
12282 assert!(text.contains("本机"));
12283 assert!(text.contains("Choices: 7890, 7897"));
12284 assert!(text.contains("Suggestions: 1080"));
12285 assert!(!text.trim_start().starts_with('{'));
12286
12287 let multi = ask_user_args_display(&serde_json::json!({
12288 "questions": [
12289 {"id": "a", "question": "Q1"},
12290 {"id": "b", "header": "H", "question": "Q2"}
12291 ]
12292 }));
12293 assert!(multi.contains("Q1"));
12294 assert!(multi.contains("H: Q2"));
12295
12296 assert_eq!(
12297 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
12298 "Confirmation requested: Deploy?"
12299 );
12300 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
12301 }
12302
12303 #[test]
12304 fn ask_user_result_and_progress_prefer_readable_content() {
12305 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
12306 assert!(
12307 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
12308 .contains("端口?")
12309 );
12310 let empty = rpi_agent::AgentToolResult::default();
12311 assert!(
12312 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
12313 .contains("端口?")
12314 );
12315 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
12316 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
12317 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
12318 }
12319
12320 #[test]
12321 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12322 let flat = rpi_extensions::UiDialogRequest {
12323 request_id: "r1".into(),
12324 tool_call_id: Some("c1".into()),
12325 ui: serde_json::json!({
12326 "kind": "selector",
12327 "id": "proxy_port",
12328 "header": "Proxy",
12329 "question": "端口?",
12330 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12331 "allowMultiple": true,
12332 "allowFreeform": false,
12333 "suggest": "1080"
12334 }),
12335 raw: serde_json::json!({}),
12336 };
12337 let prompt = parse_ask_user_prompt(&flat);
12338 assert_eq!(prompt.question_id, "proxy_port");
12339 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12340 assert_eq!(prompt.options.len(), 2);
12341 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12342 assert!(prompt.allow_multiple);
12343 assert!(!prompt.allow_freeform);
12344 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12345
12346 let alias = rpi_extensions::UiDialogRequest {
12347 ui: serde_json::json!({
12348 "questions": [{"id": "q", "question": "Q?", "options": []}]
12349 }),
12350 ..flat.clone()
12351 };
12352 let prompt = parse_ask_user_prompt(&alias);
12353 assert_eq!(prompt.question_id, "q");
12354 assert!(prompt.allow_freeform, "no options defaults to freeform");
12355
12356 let confirm = rpi_extensions::UiDialogRequest {
12357 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12358 ..flat.clone()
12359 };
12360 let prompt = parse_ask_user_prompt(&confirm);
12361 assert_eq!(prompt.kind, "confirm");
12362 assert_eq!(prompt.options.len(), 2);
12363 assert_eq!(prompt.options[0].0, "Yes");
12364 }
12365
12366 #[test]
12367 fn ask_user_bridge_answers_and_cancels_by_request_id() {
12368 let mailbox = rpi_extensions::UiDialogMailbox::new();
12369 mailbox.attach();
12370 let bridge = AskUserBridge::new(mailbox.clone());
12371
12372 mailbox
12374 .handle(serde_json::json!({
12375 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12376 "ui": {"kind": "input", "question": "Port?"}
12377 }))
12378 .unwrap();
12379 mailbox
12380 .handle(serde_json::json!({
12381 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12382 "ui": {"kind": "input", "question": "Level?"}
12383 }))
12384 .unwrap();
12385
12386 let first = bridge.take_pending().expect("r1 visible");
12387 assert_eq!(first.request_id, "r1");
12388 assert!(
12389 bridge.take_pending().is_none(),
12390 "only one request occupies the input slot"
12391 );
12392 bridge.respond("r1", serde_json::json!({"text": "7897"}));
12393 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12394
12395 let second = bridge.take_pending().expect("r2 visible");
12396 assert_eq!(second.request_id, "r2");
12397 bridge.cancel("r2");
12398 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12399
12400 mailbox
12402 .handle(serde_json::json!({
12403 "op": "open", "requestId": "r3",
12404 "ui": {"kind": "input", "question": "X?"}
12405 }))
12406 .unwrap();
12407 bridge.cancel_all();
12408 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12409
12410 bridge.shutdown();
12412 assert!(mailbox
12413 .handle(serde_json::json!({
12414 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12415 }))
12416 .is_err());
12417 }
12418}