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, 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, SelectItem, SelectList, SlashCommand as SlashCommandEntry,
49 SlashCommandAutocompleteProvider, Spacer, StackChild, StackEntry, StatusIndicator, Text,
50 ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack,
51 TUI, AltScreenSearch,
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 key_event_to_input(key: crossterm::event::KeyEvent) -> String {
687 use crossterm::event::{KeyCode, KeyModifiers};
688
689 let modifiers = key.modifiers;
690 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
691 let shift = modifiers.contains(KeyModifiers::SHIFT);
692 let alt = modifiers.contains(KeyModifiers::ALT);
693 let super_key = modifiers.contains(KeyModifiers::SUPER);
694
695 if modifiers == KeyModifiers::NONE {
696 return match key.code {
697 KeyCode::Char(ch) => ch.to_string(),
698 KeyCode::Enter => "\r".into(),
699 KeyCode::Esc => "\x1b".into(),
700 KeyCode::Backspace => "\x7f".into(),
701 KeyCode::Tab => "\t".into(),
702 KeyCode::BackTab => "\x1b[Z".into(),
706 KeyCode::Up => "\x1b[A".into(),
707 KeyCode::Down => "\x1b[B".into(),
708 KeyCode::Right => "\x1b[C".into(),
709 KeyCode::Left => "\x1b[D".into(),
710 KeyCode::Home => "\x1b[H".into(),
711 KeyCode::End => "\x1b[F".into(),
712 KeyCode::PageUp => "\x1b[5~".into(),
713 KeyCode::PageDown => "\x1b[6~".into(),
714 KeyCode::Delete => "\x1b[3~".into(),
715 KeyCode::Insert => "\x1b[2~".into(),
716 KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
717 _ => String::new(),
718 };
719 }
720
721 if ctrl && !shift && !alt && !super_key {
724 if let KeyCode::Char(ch) = key.code {
725 if let Some(code) = control_code(ch) {
726 return char::from(code).to_string();
727 }
728 }
729 }
730
731 if alt && !ctrl && !shift && !super_key {
734 if let KeyCode::Char(ch) = key.code {
735 return format!("\x1b{ch}");
736 }
737 }
738
739 if shift && !ctrl && !alt && !super_key {
745 if let KeyCode::Char(ch) = key.code {
746 return ch.to_string();
747 }
748 }
749
750 if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
751 return sequence;
752 }
753 let Some(codepoint) = key_codepoint(key.code, ctrl) else {
754 return String::new();
755 };
756 kitty_key_sequence(codepoint, modifiers)
757}
758
759fn control_code(ch: char) -> Option<u8> {
760 let ch = ch.to_ascii_lowercase();
761 Some(match ch {
762 '@' | ' ' => 0,
763 'a'..='z' => (ch as u8) & 0x1f,
764 '[' => 0x1b,
765 '\\' => 0x1c,
766 ']' => 0x1d,
767 '^' => 0x1e,
768 '_' | '-' => 0x1f,
769 _ => return None,
770 })
771}
772
773fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
774 use crossterm::event::KeyCode;
775 Some(match code {
776 KeyCode::Char(ch) => {
777 if ctrl {
778 ch.to_ascii_lowercase() as u32
779 } else {
780 ch as u32
781 }
782 }
783 KeyCode::Enter => 13,
784 KeyCode::Esc => 27,
785 KeyCode::Backspace => 127,
786 KeyCode::Tab => 9,
787 KeyCode::BackTab => 9,
790 _ => return None,
791 })
792}
793
794fn modified_functional_sequence(
795 code: crossterm::event::KeyCode,
796 modifiers: crossterm::event::KeyModifiers,
797) -> Option<String> {
798 use crossterm::event::{KeyCode, KeyModifiers};
799 if code == KeyCode::BackTab
803 && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
804 {
805 return Some("\x1b[Z".into());
806 }
807 let modifier = kitty_modifier(modifiers);
808 let sequence = match code {
809 KeyCode::Up => format!("\x1b[1;{modifier}A"),
810 KeyCode::Down => format!("\x1b[1;{modifier}B"),
811 KeyCode::Right => format!("\x1b[1;{modifier}C"),
812 KeyCode::Left => format!("\x1b[1;{modifier}D"),
813 KeyCode::Home => format!("\x1b[1;{modifier}H"),
814 KeyCode::End => format!("\x1b[1;{modifier}F"),
815 KeyCode::Insert => format!("\x1b[2;{modifier}~"),
816 KeyCode::Delete => format!("\x1b[3;{modifier}~"),
817 KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
818 KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
819 _ => return None,
820 };
821 Some(sequence)
822}
823
824fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
825 use crossterm::event::KeyModifiers;
826 let mut modifier = 1u8;
827 if modifiers.contains(KeyModifiers::SHIFT) {
828 modifier += 1;
829 }
830 if modifiers.contains(KeyModifiers::ALT) {
831 modifier += 2;
832 }
833 if modifiers.contains(KeyModifiers::CONTROL) {
834 modifier += 4;
835 }
836 if modifiers.contains(KeyModifiers::SUPER) {
837 modifier += 8;
838 }
839 modifier
840}
841
842fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
843 let modifier = kitty_modifier(modifiers);
844 format!("\x1b[{codepoint};{modifier}u")
845}
846
847struct ExtensionCommand {
852 name: String,
853 description: String,
854 session: crate::session::ExtensionSessionCell,
855}
856
857struct JsExtensionCommand {
858 name: String,
859 session: crate::js_extensions::JsExtensionSession,
860}
861
862impl SlashCommand for JsExtensionCommand {
863 fn name(&self) -> &str {
864 &self.name
865 }
866 fn description(&self) -> &'static str {
867 "JS extension command"
868 }
869 fn description_owned(&self) -> String {
870 "JS extension command".to_string()
871 }
872 fn execute(&self, ctx: &CommandContext, args: &str) {
873 let session = self.session.clone();
878 let command = self.name.trim_start_matches('/').to_string();
879 let args = args.to_string();
880 let ctx = ctx.clone();
881 let initial_editor_text = ctx.editor.get_text();
885 tokio::task::spawn_blocking(move || {
886 match session.invoke_command_with_context(
887 &command,
888 &args,
889 serde_json::json!({"editorText": initial_editor_text}),
890 ) {
891 Ok(value) => {
892 if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
893 if ctx.editor.get_text() == initial_editor_text
894 && editor_text != initial_editor_text
895 {
896 let cursor = editor_text.chars().count();
897 ctx.editor.set_text(editor_text);
898 ctx.editor.set_cursor(0, cursor);
899 }
900 }
901 if let Some(notifications) =
902 value.get("notifications").and_then(|v| v.as_array())
903 {
904 for notification in notifications {
905 let message = notification
906 .get("message")
907 .and_then(|v| v.as_str())
908 .unwrap_or_default();
909 if message.is_empty() {
910 continue;
911 }
912 match notification.get("level").and_then(|v| v.as_str()) {
913 Some("error") => add_error_message(&ctx.chat, message),
914 _ => add_note_message(&ctx.chat, message),
915 }
916 }
917 }
918 let result = value.get("result").unwrap_or(&value);
919 let text = result
920 .get("text")
921 .and_then(|item| item.as_str())
922 .map(str::to_string)
923 .or_else(|| result.as_str().map(str::to_string))
924 .filter(|text| !text.is_empty() && text != "null");
925 if let Some(text) = text {
926 add_note_message(&ctx.chat, &text);
927 }
928 }
929 Err(error) => {
930 add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
931 }
932 }
933 ctx.tui.request_render(false);
934 });
935 }
936}
937
938impl SlashCommand for ExtensionCommand {
939 fn name(&self) -> &str {
940 &self.name
941 }
942
943 fn description(&self) -> &'static str {
944 "extension command"
945 }
946
947 fn description_owned(&self) -> String {
948 self.description.clone()
949 }
950
951 fn execute(&self, ctx: &CommandContext, args: &str) {
952 let result = invoke_extension_command(&self.session, &self.name, args);
953 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
954 }
955}
956
957fn invoke_extension_command(
958 session: &crate::session::ExtensionSessionCell,
959 name: &str,
960 args: &str,
961) -> Option<serde_json::Value> {
962 let command = session
963 .lock()
964 .ok()
965 .and_then(|s| s.snapshot_arc())
966 .and_then(|snap| {
967 snap.commands()
968 .iter()
969 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
970 .cloned()
971 })?;
972 let input = serde_json::json!({ "args": args, "command": name });
973 let input = serde_json::to_string(&input).ok()?;
974 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
975 let mut out = rpi_plugin_sdk::StbString::empty();
976 let rc = (command.handler)(
977 rpi_plugin_sdk::StbStringRef::from_str(&input),
978 &mut out as *mut rpi_plugin_sdk::StbString,
979 command.user_data,
980 );
981 let text = if rc == 0 {
982 Some(out.to_string_lossy())
983 } else {
984 None
985 };
986 rpi_extensions::host_free_string(out);
987 text
988 }))
989 .ok()
990 .flatten()?;
991 serde_json::from_str(&outcome).ok()
992}
993
994fn handle_extension_ui_result(
995 result: Option<serde_json::Value>,
996 ctx: &CommandContext,
997 session: crate::session::ExtensionSessionCell,
998 command_name: String,
999) {
1000 let Some(value) = result else {
1001 add_error_message(&ctx.chat, "Extension command failed.");
1002 ctx.tui.request_render(false);
1003 return;
1004 };
1005 if value.is_null() {
1009 ctx.tui.request_render(false);
1010 return;
1011 }
1012 match value.get("kind").and_then(|v| v.as_str()) {
1013 Some("message") | None => {
1014 let fallback = value.to_string();
1015 let text = value
1016 .get("text")
1017 .and_then(|v| v.as_str())
1018 .unwrap_or(&fallback)
1019 .to_string();
1020 if !text.is_empty() {
1021 add_note_message(&ctx.chat, &text);
1022 }
1023 ctx.tui.request_render(false);
1024 }
1025 Some("selector") => open_extension_selector(ctx, session, command_name, value),
1026 Some("editor") => open_extension_editor(ctx, session, command_name, value),
1027 Some("input") => open_extension_input(ctx, session, command_name, value),
1031 Some(other) => {
1032 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1033 ctx.tui.request_render(false);
1034 }
1035 }
1036}
1037
1038fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1042 let colors = current_theme().colors;
1043 let text = if bold {
1044 tui_bold(title)
1045 } else {
1046 title.to_string()
1047 };
1048 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1049}
1050
1051fn extension_dialog_hint(label: &str) -> Arc<Text> {
1052 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1053}
1054
1055fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1058 let text = if bold {
1059 format!("**{}**", title)
1060 } else {
1061 title.to_string()
1062 };
1063 Arc::new(Markdown::new(text, 1, 0))
1064}
1065
1066fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1069 Arc::new(Markdown::new(label, 1, 0))
1070}
1071
1072fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1076 let callback = state.active_extension_cancel.lock().unwrap().take();
1077 if let Some(callback) = callback {
1078 callback();
1079 true
1080 } else {
1081 false
1082 }
1083}
1084
1085fn open_extension_input(
1086 ctx: &CommandContext,
1087 session: crate::session::ExtensionSessionCell,
1088 command_name: String,
1089 value: serde_json::Value,
1090) {
1091 let title = value
1092 .get("title")
1093 .and_then(|v| v.as_str())
1094 .filter(|title| !title.is_empty())
1095 .unwrap_or("Input");
1096 let input = value
1097 .get("placeholder")
1098 .and_then(|v| v.as_str())
1099 .map(Input::with_placeholder)
1100 .unwrap_or_default();
1101 let input = Arc::new(input);
1102 if let Some(initial) = value
1103 .get("initialText")
1104 .or_else(|| value.get("text"))
1105 .and_then(|v| v.as_str())
1106 {
1107 input.set_value(initial);
1108 }
1109 input.set_focused(true);
1110
1111 let frame = Arc::new(Container::new());
1112 frame.add_child(Arc::new(DynamicBorder::new()));
1113 frame.add_child(Arc::new(Spacer::new(1)));
1114 frame.add_child(extension_dialog_title(title, false));
1115 frame.add_child(Arc::new(Spacer::new(1)));
1116 frame.add_child(input.clone());
1117 frame.add_child(Arc::new(Spacer::new(1)));
1118 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1119 frame.add_child(Arc::new(Spacer::new(1)));
1120 frame.add_child(Arc::new(DynamicBorder::new()));
1121
1122 *ctx.state.active_extension_editor.lock().unwrap() = None;
1123 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1124 ctx.editor_container.clear();
1125 ctx.editor_container.add_child(frame);
1126
1127 let state = ctx.state.clone();
1128 let ec = ctx.editor_container.clone();
1129 let original = ctx.editor.clone();
1130 let tui = ctx.tui.clone();
1131 let session_submit = session.clone();
1132 let command_submit = command_name.clone();
1133 let ctx_submit = ctx.clone();
1134 input.on_submit(Arc::new(move |text| {
1135 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1136 let result = invoke_extension_command(
1137 &session_submit,
1138 &command_submit,
1139 &serde_json::to_string(&args).unwrap_or_default(),
1140 );
1141 close_extension_editor(&state, &ec, &original, &tui);
1142 handle_extension_ui_result(
1143 result,
1144 &ctx_submit,
1145 session_submit.clone(),
1146 command_submit.clone(),
1147 );
1148 }));
1149
1150 let state_cancel = ctx.state.clone();
1151 let ec_cancel = ctx.editor_container.clone();
1152 let original_cancel = ctx.editor.clone();
1153 let tui_cancel = ctx.tui.clone();
1154 let session_cancel = session.clone();
1155 let command_cancel = command_name.clone();
1156 let ctx_cancel = ctx.clone();
1157 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1158 let args = serde_json::json!({ "action": "cancel" });
1159 let result = invoke_extension_command(
1160 &session_cancel,
1161 &command_cancel,
1162 &serde_json::to_string(&args).unwrap_or_default(),
1163 );
1164 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1165 handle_extension_ui_result(
1166 result,
1167 &ctx_cancel,
1168 session_cancel.clone(),
1169 command_cancel.clone(),
1170 );
1171 }));
1172
1173 ctx.tui.set_focus(Some(input));
1174 ctx.tui.request_render(false);
1175}
1176
1177fn open_extension_selector(
1178 ctx: &CommandContext,
1179 session: crate::session::ExtensionSessionCell,
1180 command_name: String,
1181 value: serde_json::Value,
1182) {
1183 let items = value
1184 .get("items")
1185 .and_then(|v| v.as_array())
1186 .map(|items| {
1187 items
1188 .iter()
1189 .filter_map(|item| {
1190 let value = if let Some(value) = item.as_str() {
1193 value
1194 } else {
1195 item.get("value")?.as_str()?
1196 };
1197 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1198 let mut out = SelectItem::new(value, label);
1199 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1200 out = out.with_description(desc);
1201 }
1202 Some(out)
1203 })
1204 .collect::<Vec<_>>()
1205 })
1206 .unwrap_or_default();
1207 if items.is_empty() {
1208 add_error_message(&ctx.chat, "Extension selector has no items.");
1209 ctx.tui.request_render(false);
1210 return;
1211 }
1212 let list = Arc::new(SelectList::new(items, 10));
1213 let title = value
1214 .get("title")
1215 .and_then(|v| v.as_str())
1216 .filter(|title| !title.is_empty())
1217 .unwrap_or("Select");
1218 let frame = Arc::new(Container::new());
1219 frame.add_child(Arc::new(DynamicBorder::new()));
1220 frame.add_child(Arc::new(Spacer::new(1)));
1221 frame.add_child(extension_dialog_title(title, true));
1222 frame.add_child(Arc::new(Spacer::new(1)));
1223 frame.add_child(list.clone());
1224 frame.add_child(Arc::new(Spacer::new(1)));
1225 frame.add_child(extension_dialog_hint(
1226 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1227 ));
1228 frame.add_child(Arc::new(Spacer::new(1)));
1229 frame.add_child(Arc::new(DynamicBorder::new()));
1230
1231 let state = ctx.state.clone();
1232 let ec = ctx.editor_container.clone();
1233 let editor = ctx.editor.clone();
1234 let tui = ctx.tui.clone();
1235 let session_select = session.clone();
1236 let command_select = command_name.clone();
1237 let ctx_select = ctx.clone();
1238 list.on_select(Arc::new(move |item| {
1239 let args = serde_json::json!({ "action": "select", "value": item.value });
1240 let result = invoke_extension_command(
1241 &session_select,
1242 &command_select,
1243 &serde_json::to_string(&args).unwrap_or_default(),
1244 );
1245 close_selector(&state, &ec, &editor, &tui);
1246 handle_extension_ui_result(
1247 result,
1248 &ctx_select,
1249 session_select.clone(),
1250 command_select.clone(),
1251 );
1252 }));
1253 let state_cancel = ctx.state.clone();
1254 let ec_cancel = ctx.editor_container.clone();
1255 let editor_cancel = ctx.editor.clone();
1256 let tui_cancel = ctx.tui.clone();
1257 list.on_cancel(Arc::new(move || {
1258 if !run_extension_cancel(&state_cancel) {
1259 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1260 }
1261 }));
1262 let state_cancel = ctx.state.clone();
1263 let ec_cancel = ctx.editor_container.clone();
1264 let editor_cancel = ctx.editor.clone();
1265 let tui_cancel = ctx.tui.clone();
1266 let session_cancel = session.clone();
1267 let command_cancel = command_name.clone();
1268 let ctx_cancel = ctx.clone();
1269 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1270 let args = serde_json::json!({ "action": "cancel" });
1271 let result = invoke_extension_command(
1272 &session_cancel,
1273 &command_cancel,
1274 &serde_json::to_string(&args).unwrap_or_default(),
1275 );
1276 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1277 handle_extension_ui_result(
1278 result,
1279 &ctx_cancel,
1280 session_cancel.clone(),
1281 command_cancel.clone(),
1282 );
1283 }));
1284 open_selector_with_view(
1285 &ctx.state,
1286 &ctx.editor_container,
1287 &ctx.editor,
1288 &ctx.tui,
1289 list,
1290 frame,
1291 SelectorKind::Extension,
1292 );
1293}
1294
1295fn open_extension_editor(
1296 ctx: &CommandContext,
1297 session: crate::session::ExtensionSessionCell,
1298 command_name: String,
1299 value: serde_json::Value,
1300) {
1301 let initial = value
1302 .get("initialText")
1303 .or_else(|| value.get("text"))
1304 .and_then(|v| v.as_str())
1305 .unwrap_or_default()
1306 .to_string();
1307 let title = value
1308 .get("title")
1309 .and_then(|v| v.as_str())
1310 .filter(|title| !title.is_empty())
1311 .unwrap_or("Editor");
1312 let editor = Arc::new(Editor::new(
1313 EditorOptions {
1314 padding_x: 1,
1315 autocomplete_max_visible: 0,
1316 placeholder: value
1317 .get("placeholder")
1318 .and_then(|v| v.as_str())
1319 .map(str::to_string),
1320 initial_text: Some(initial),
1321 },
1322 EditorStyle {
1323 prompt: "> ".to_string(),
1324 placeholder: String::new(),
1325 },
1326 Arc::new(rpi_tui::Keybindings::new()),
1327 ));
1328 editor.set_focused(true);
1329 let frame = Arc::new(Container::new());
1330 frame.add_child(Arc::new(DynamicBorder::new()));
1331 frame.add_child(Arc::new(Spacer::new(1)));
1332 frame.add_child(extension_dialog_title(title, false));
1333 frame.add_child(Arc::new(Spacer::new(1)));
1334 frame.add_child(editor.clone());
1335 frame.add_child(Arc::new(Spacer::new(1)));
1336 frame.add_child(extension_dialog_hint(
1337 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1338 ));
1339 frame.add_child(Arc::new(Spacer::new(1)));
1340 frame.add_child(Arc::new(DynamicBorder::new()));
1341
1342 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1343 *ctx.state.active_extension_input.lock().unwrap() = None;
1344 ctx.editor_container.clear();
1345 ctx.editor_container.add_child(frame);
1346
1347 let state = ctx.state.clone();
1348 let ec = ctx.editor_container.clone();
1349 let original = ctx.editor.clone();
1350 let tui = ctx.tui.clone();
1351 let session_submit = session.clone();
1352 let command_submit = command_name.clone();
1353 let ctx_submit = ctx.clone();
1354 editor.on_submit(Arc::new(move |text| {
1355 let args = serde_json::json!({ "action": "edit", "text": text });
1356 let result = invoke_extension_command(
1357 &session_submit,
1358 &command_submit,
1359 &serde_json::to_string(&args).unwrap_or_default(),
1360 );
1361 close_extension_editor(&state, &ec, &original, &tui);
1362 handle_extension_ui_result(
1363 result,
1364 &ctx_submit,
1365 session_submit.clone(),
1366 command_submit.clone(),
1367 );
1368 }));
1369
1370 let state_cancel = ctx.state.clone();
1371 let ec_cancel = ctx.editor_container.clone();
1372 let original_cancel = ctx.editor.clone();
1373 let tui_cancel = ctx.tui.clone();
1374 let session_cancel = session.clone();
1375 let command_cancel = command_name.clone();
1376 let ctx_cancel = ctx.clone();
1377 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1378 let args = serde_json::json!({ "action": "cancel" });
1379 let result = invoke_extension_command(
1380 &session_cancel,
1381 &command_cancel,
1382 &serde_json::to_string(&args).unwrap_or_default(),
1383 );
1384 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1385 handle_extension_ui_result(
1386 result,
1387 &ctx_cancel,
1388 session_cancel.clone(),
1389 command_cancel.clone(),
1390 );
1391 }));
1392
1393 ctx.tui.set_focus(Some(editor));
1394 ctx.tui.request_render(false);
1395}
1396
1397fn close_extension_editor(
1398 state: &Arc<TuiState>,
1399 editor_container: &Arc<Container>,
1400 editor: &Arc<Editor>,
1401 tui: &Arc<TuiAltScreen>,
1402) {
1403 editor_container.clear();
1404 editor_container.add_child(editor.clone());
1405 state.autocomplete_container.clear();
1406 *state.active_extension_editor.lock().unwrap() = None;
1407 *state.active_extension_input.lock().unwrap() = None;
1408 *state.active_extension_cancel.lock().unwrap() = None;
1409 editor.set_focused(true);
1410 tui.set_focus(Some(editor.clone()));
1411 tui.request_render(false);
1412}
1413
1414fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1418 match request.method.as_str() {
1419 "select" => open_js_selector(ctx, bridge, request, false),
1420 "confirm" => open_js_selector(ctx, bridge, request, true),
1421 "input" => open_js_input(ctx, bridge, request),
1422 "editor" => open_js_editor(ctx, bridge, request),
1423 _ => {
1424 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1425 }
1426 }
1427}
1428
1429fn open_js_selector(
1430 ctx: &CommandContext,
1431 bridge: Arc<JsDialogBridge>,
1432 request: JsDialogRequest,
1433 confirm: bool,
1434) {
1435 let values = if confirm {
1436 vec!["Yes".to_string(), "No".to_string()]
1437 } else {
1438 request.options.clone()
1439 };
1440 if values.is_empty() {
1441 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1442 return;
1443 }
1444 let items = values
1445 .iter()
1446 .map(|value| SelectItem::new(value, value))
1447 .collect::<Vec<_>>();
1448 let list = Arc::new(SelectList::new(items, 10));
1449 let frame = Arc::new(Container::new());
1450 frame.add_child(Arc::new(DynamicBorder::new()));
1451 frame.add_child(Arc::new(Spacer::new(1)));
1452 frame.add_child(extension_dialog_title(
1453 if request.title.is_empty() {
1454 if confirm {
1455 "Confirm"
1456 } else {
1457 "Select"
1458 }
1459 } else {
1460 request.title.as_str()
1461 },
1462 true,
1463 ));
1464 if !request.message.is_empty() {
1465 frame.add_child(Arc::new(Spacer::new(1)));
1466 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1467 }
1468 frame.add_child(Arc::new(Spacer::new(1)));
1469 frame.add_child(list.clone());
1470 frame.add_child(Arc::new(Spacer::new(1)));
1471 frame.add_child(extension_dialog_hint(
1472 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1473 ));
1474 frame.add_child(Arc::new(Spacer::new(1)));
1475 frame.add_child(Arc::new(DynamicBorder::new()));
1476
1477 let id = request.id.clone();
1478 let bridge_select = bridge.clone();
1479 let state_select = ctx.state.clone();
1480 let ec_select = ctx.editor_container.clone();
1481 let editor_select = ctx.editor.clone();
1482 let tui_select = ctx.tui.clone();
1483 list.on_select(Arc::new(move |item| {
1484 let result = if confirm {
1485 serde_json::json!({ "confirmed": item.value == "Yes" })
1486 } else {
1487 serde_json::json!({ "value": item.value })
1488 };
1489 bridge_select.respond(&id, result);
1490 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1491 }));
1492
1493 let id_cancel = request.id.clone();
1494 let bridge_cancel = bridge.clone();
1495 let state_cancel = ctx.state.clone();
1496 let ec_cancel = ctx.editor_container.clone();
1497 let editor_cancel = ctx.editor.clone();
1498 let tui_cancel = ctx.tui.clone();
1499 list.on_cancel(Arc::new(move || {
1500 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1501 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1502 }));
1503
1504 let id_abort = request.id.clone();
1505 let bridge_abort = bridge.clone();
1506 let state_abort = ctx.state.clone();
1507 let ec_abort = ctx.editor_container.clone();
1508 let editor_abort = ctx.editor.clone();
1509 let tui_abort = ctx.tui.clone();
1510 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1511 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1512 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1513 }));
1514
1515 open_selector_with_view(
1516 &ctx.state,
1517 &ctx.editor_container,
1518 &ctx.editor,
1519 &ctx.tui,
1520 list,
1521 frame,
1522 SelectorKind::Extension,
1523 );
1524}
1525
1526fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1527 let input = request
1528 .placeholder
1529 .as_deref()
1530 .map(Input::with_placeholder)
1531 .unwrap_or_default();
1532 let input = Arc::new(input);
1533 input.set_focused(true);
1534
1535 let frame = Arc::new(Container::new());
1536 frame.add_child(Arc::new(DynamicBorder::new()));
1537 frame.add_child(Arc::new(Spacer::new(1)));
1538 frame.add_child(extension_dialog_title(
1539 if request.title.is_empty() {
1540 "Input"
1541 } else {
1542 &request.title
1543 },
1544 false,
1545 ));
1546 frame.add_child(Arc::new(Spacer::new(1)));
1547 frame.add_child(input.clone());
1548 frame.add_child(Arc::new(Spacer::new(1)));
1549 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1550 frame.add_child(Arc::new(Spacer::new(1)));
1551 frame.add_child(Arc::new(DynamicBorder::new()));
1552
1553 *ctx.state.active_extension_editor.lock().unwrap() = None;
1554 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1555 ctx.editor_container.clear();
1556 ctx.editor_container.add_child(frame);
1557
1558 let id = request.id.clone();
1559 let bridge_submit = bridge.clone();
1560 let state_submit = ctx.state.clone();
1561 let ec_submit = ctx.editor_container.clone();
1562 let editor_submit = ctx.editor.clone();
1563 let tui_submit = ctx.tui.clone();
1564 input.on_submit(Arc::new(move |value| {
1565 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1566 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1567 }));
1568
1569 let id_cancel = request.id.clone();
1570 let bridge_cancel = bridge.clone();
1571 let state_cancel = ctx.state.clone();
1572 let ec_cancel = ctx.editor_container.clone();
1573 let editor_cancel = ctx.editor.clone();
1574 let tui_cancel = ctx.tui.clone();
1575 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1576 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1577 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1578 }));
1579 ctx.tui.set_focus(Some(input));
1580 ctx.tui.request_render(false);
1581}
1582
1583fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1584 let editor = Arc::new(Editor::new(
1585 EditorOptions {
1586 padding_x: 1,
1587 autocomplete_max_visible: 0,
1588 initial_text: request.prefill.clone(),
1589 ..Default::default()
1590 },
1591 EditorStyle {
1592 prompt: "> ".to_string(),
1593 placeholder: String::new(),
1594 },
1595 Arc::new(rpi_tui::Keybindings::new()),
1596 ));
1597 editor.set_focused(true);
1598
1599 let frame = Arc::new(Container::new());
1600 frame.add_child(Arc::new(DynamicBorder::new()));
1601 frame.add_child(Arc::new(Spacer::new(1)));
1602 frame.add_child(extension_dialog_title(
1603 if request.title.is_empty() {
1604 "Editor"
1605 } else {
1606 &request.title
1607 },
1608 false,
1609 ));
1610 frame.add_child(Arc::new(Spacer::new(1)));
1611 frame.add_child(editor.clone());
1612 frame.add_child(Arc::new(Spacer::new(1)));
1613 frame.add_child(extension_dialog_hint(
1614 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1615 ));
1616 frame.add_child(Arc::new(Spacer::new(1)));
1617 frame.add_child(Arc::new(DynamicBorder::new()));
1618
1619 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1620 *ctx.state.active_extension_input.lock().unwrap() = None;
1621 ctx.editor_container.clear();
1622 ctx.editor_container.add_child(frame);
1623
1624 let id = request.id.clone();
1625 let bridge_submit = bridge.clone();
1626 let state_submit = ctx.state.clone();
1627 let ec_submit = ctx.editor_container.clone();
1628 let editor_submit = ctx.editor.clone();
1629 let tui_submit = ctx.tui.clone();
1630 editor.on_submit(Arc::new(move |value| {
1631 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1632 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1633 }));
1634
1635 let id_cancel = request.id.clone();
1636 let bridge_cancel = bridge.clone();
1637 let state_cancel = ctx.state.clone();
1638 let ec_cancel = ctx.editor_container.clone();
1639 let editor_cancel = ctx.editor.clone();
1640 let tui_cancel = ctx.tui.clone();
1641 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1642 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1643 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1644 }));
1645 ctx.tui.set_focus(Some(editor));
1646 ctx.tui.request_render(false);
1647}
1648
1649fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1650 for id in bridge.cancelled_active_ids() {
1651 if !bridge.is_visible(&id) {
1655 bridge.finish(&id);
1656 continue;
1657 }
1658 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1659 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1660 } else if ctx.state.extension_dialog_open() {
1661 if !run_extension_cancel(&ctx.state) {
1662 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1663 }
1664 }
1665 bridge.finish(&id);
1669 }
1670}
1671
1672const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1679
1680#[derive(Clone)]
1685struct AskUserBridge {
1686 mailbox: rpi_extensions::UiDialogMailbox,
1687 visible: Arc<Mutex<Option<String>>>,
1688}
1689
1690impl AskUserBridge {
1691 fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1692 Self {
1693 mailbox,
1694 visible: Arc::new(Mutex::new(None)),
1695 }
1696 }
1697
1698 fn attach(&self) {
1700 self.mailbox.attach();
1701 }
1702
1703 fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1707 let mut visible = self.visible.lock().ok()?;
1708 if visible.is_some() {
1709 return None;
1710 }
1711 let request = self.mailbox.take_pending()?;
1712 *visible = Some(request.request_id.clone());
1713 Some(request)
1714 }
1715
1716 fn clear_visible(&self, id: &str) {
1717 if let Ok(mut visible) = self.visible.lock() {
1718 if visible.as_deref() == Some(id) {
1719 *visible = None;
1720 }
1721 }
1722 }
1723
1724 fn respond(&self, id: &str, answer: serde_json::Value) {
1726 let _ = self.mailbox.respond(id, answer);
1727 self.clear_visible(id);
1728 }
1729
1730 fn cancel(&self, id: &str) {
1732 let _ = self.mailbox.cancel(id);
1733 self.clear_visible(id);
1734 }
1735
1736 fn cancel_all(&self) {
1739 self.mailbox.cancel_all();
1740 if let Ok(mut visible) = self.visible.lock() {
1741 *visible = None;
1742 }
1743 }
1744
1745 fn shutdown(&self) {
1748 self.mailbox.detach();
1749 if let Ok(mut visible) = self.visible.lock() {
1750 *visible = None;
1751 }
1752 }
1753}
1754
1755#[derive(Clone)]
1759struct AskUserPrompt {
1760 question_id: String,
1761 question: String,
1762 header: Option<String>,
1763 context: Option<String>,
1764 options: Vec<(String, Option<String>)>,
1765 allow_multiple: bool,
1766 allow_freeform: bool,
1767 suggest: Option<String>,
1768 kind: String,
1769}
1770
1771fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1772 keys.iter().find_map(|key| {
1773 value
1774 .get(*key)
1775 .and_then(serde_json::Value::as_str)
1776 .map(str::trim)
1777 .filter(|text| !text.is_empty())
1778 .map(str::to_string)
1779 })
1780}
1781
1782fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1783 keys.iter()
1784 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1785}
1786
1787fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1788 let mut options = Vec::new();
1789 let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1790 return options;
1791 };
1792 for item in items {
1793 if let Some(title) = item.as_str() {
1794 let title = title.trim();
1795 if !title.is_empty() {
1796 options.push((title.to_string(), None));
1797 }
1798 continue;
1799 }
1800 let Some(object) = item.as_object() else { continue };
1801 let title = ["title", "label", "text", "value", "name"]
1802 .iter()
1803 .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1804 .map(str::trim)
1805 .filter(|title| !title.is_empty());
1806 let Some(title) = title else { continue };
1807 let description = object
1808 .get("description")
1809 .and_then(serde_json::Value::as_str)
1810 .map(str::trim)
1811 .filter(|text| !text.is_empty())
1812 .map(str::to_string);
1813 options.push((title.to_string(), description));
1814 }
1815 options
1816}
1817
1818fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1819 let ui = &request.ui;
1820 let kind = ui
1821 .get("kind")
1822 .and_then(serde_json::Value::as_str)
1823 .unwrap_or("selector")
1824 .to_string();
1825 let nested = ui
1829 .get("questions")
1830 .and_then(serde_json::Value::as_array)
1831 .and_then(|items| items.first());
1832 let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1833
1834 let question = ask_user_str(source, &["question", "summary", "title"])
1835 .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1836 .unwrap_or_default();
1837 let question_id = ask_user_str(source, &["id", "questionId"])
1838 .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1839 .unwrap_or_else(|| request.request_id.clone());
1840 let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1841 let context = ask_user_str(source, &["context", "message"])
1842 .or_else(|| ask_user_str(ui, &["context", "message"]));
1843 let mut options = ask_user_options(source);
1844 if options.is_empty() {
1845 options = ask_user_options(ui);
1846 }
1847 if kind == "confirm" && options.is_empty() {
1848 options = vec![
1849 ("Yes".to_string(), None),
1850 ("No".to_string(), None),
1851 ];
1852 }
1853 let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1854 .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1855 .unwrap_or(false);
1856 let allow_freeform = ask_user_bool(
1859 source,
1860 &["allowFreeform", "allow_freeform", "allow_custom"],
1861 )
1862 .or_else(|| {
1863 ask_user_bool(
1864 ui,
1865 &["allowFreeform", "allow_freeform", "allow_custom"],
1866 )
1867 })
1868 .unwrap_or(options.is_empty());
1869 let suggest = ask_user_str(source, &["suggest", "placeholder"])
1870 .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1871
1872 AskUserPrompt {
1873 question_id,
1874 question,
1875 header,
1876 context,
1877 options,
1878 allow_multiple,
1879 allow_freeform,
1880 suggest,
1881 kind,
1882 }
1883}
1884
1885fn ask_user_frame(
1888 prompt: &AskUserPrompt,
1889 body: Arc<dyn Component>,
1890 hint: &str,
1891) -> Arc<Container> {
1892 let frame = Arc::new(Container::new());
1893 frame.add_child(Arc::new(DynamicBorder::new()));
1894 frame.add_child(Arc::new(Spacer::new(1)));
1895 if let Some(header) = prompt.header.as_deref() {
1896 frame.add_child(extension_dialog_title_md(header, true));
1897 }
1898 if !prompt.question.trim().is_empty() {
1899 frame.add_child(extension_dialog_title_md(
1900 &prompt.question,
1901 prompt.header.is_none(),
1902 ));
1903 }
1904 if let Some(context) = prompt.context.as_deref() {
1905 frame.add_child(extension_dialog_hint_md(context));
1906 }
1907 frame.add_child(Arc::new(Spacer::new(1)));
1908 frame.add_child(body);
1909 frame.add_child(Arc::new(Spacer::new(1)));
1910 frame.add_child(extension_dialog_hint(hint));
1911 frame.add_child(Arc::new(Spacer::new(1)));
1912 frame.add_child(Arc::new(DynamicBorder::new()));
1913 frame
1914}
1915
1916fn open_ask_user_dialog(
1918 ctx: &CommandContext,
1919 bridge: AskUserBridge,
1920 request: rpi_extensions::UiDialogRequest,
1921) {
1922 let prompt = parse_ask_user_prompt(&request);
1923 if prompt.kind == "input" || prompt.options.is_empty() {
1924 open_ask_user_input(ctx, bridge, request, prompt);
1925 } else {
1926 open_ask_user_selector(ctx, bridge, request, prompt);
1927 }
1928}
1929
1930fn open_ask_user_selector(
1931 ctx: &CommandContext,
1932 bridge: AskUserBridge,
1933 request: rpi_extensions::UiDialogRequest,
1934 prompt: AskUserPrompt,
1935) {
1936 let multi = prompt.allow_multiple && prompt.kind != "confirm";
1937 let mut items: Vec<SelectItem> = Vec::new();
1938 for (title, description) in &prompt.options {
1939 let item = SelectItem::new(title, title);
1940 let item = match description.as_deref() {
1941 Some(description) => item.with_description(description),
1942 None => item,
1943 };
1944 items.push(item);
1945 }
1946 if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1949 items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1950 }
1951 let list = if multi {
1952 Arc::new(SelectList::new_multi(items, 10))
1953 } else {
1954 Arc::new(SelectList::new(items, 10))
1955 };
1956
1957 let request_id = request.request_id.clone();
1958 let state = ctx.state.clone();
1959 let editor_container = ctx.editor_container.clone();
1960 let editor = ctx.editor.clone();
1961 let tui = ctx.tui.clone();
1962
1963 let bridge_select = bridge.clone();
1966 let request_for_freeform = request.clone();
1967 let prompt_for_freeform = AskUserPrompt {
1968 question_id: prompt.question_id.clone(),
1969 question: prompt.question.clone(),
1970 header: prompt.header.clone(),
1971 context: prompt.context.clone(),
1972 options: Vec::new(),
1973 allow_multiple: false,
1974 allow_freeform: true,
1975 suggest: prompt.suggest.clone(),
1976 kind: "input".to_string(),
1977 };
1978 let state_select = state.clone();
1979 let ec_select = editor_container.clone();
1980 let editor_select = editor.clone();
1981 let tui_select = tui.clone();
1982 let ctx_select = ctx.clone();
1983 list.on_select(Arc::new(move |item: &SelectItem| {
1984 if item.value == ASK_USER_FREEFORM {
1985 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1986 open_ask_user_input(
1987 &ctx_select,
1988 bridge_select.clone(),
1989 request_for_freeform.clone(),
1990 prompt_for_freeform.clone(),
1991 );
1992 return;
1993 }
1994 bridge_select.respond(
1995 &request_id,
1996 serde_json::json!({
1997 "values": [item.display_value()],
1998 "kind": "selector",
1999 }),
2000 );
2001 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2002 }));
2003
2004 if multi {
2005 let bridge_multi = bridge.clone();
2006 let request_id_multi = request.request_id.clone();
2007 let state_multi = state.clone();
2008 let ec_multi = editor_container.clone();
2009 let editor_multi = editor.clone();
2010 let tui_multi = tui.clone();
2011 list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2012 let values: Vec<String> = selected
2013 .iter()
2014 .map(|item| item.display_value().to_string())
2015 .collect();
2016 bridge_multi.respond(
2017 &request_id_multi,
2018 serde_json::json!({"values": values, "kind": "selector"}),
2019 );
2020 close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2021 }));
2022 }
2023
2024 let bridge_cancel = bridge.clone();
2025 let request_id_cancel = request.request_id.clone();
2026 let state_cancel = state.clone();
2027 let ec_cancel = editor_container.clone();
2028 let editor_cancel = editor.clone();
2029 let tui_cancel = tui.clone();
2030 list.on_cancel(Arc::new(move || {
2031 bridge_cancel.cancel(&request_id_cancel);
2032 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2033 }));
2034
2035 let hint = if multi {
2036 "Space toggle · Enter submit · Esc cancel"
2037 } else {
2038 "Enter select · Esc cancel"
2039 };
2040 let frame = ask_user_frame(&prompt, list.clone(), hint);
2041 open_selector_with_view(
2042 &state,
2043 &editor_container,
2044 &editor,
2045 &tui,
2046 list,
2047 frame,
2048 SelectorKind::Extension,
2049 );
2050}
2051
2052fn open_ask_user_input(
2053 ctx: &CommandContext,
2054 bridge: AskUserBridge,
2055 request: rpi_extensions::UiDialogRequest,
2056 prompt: AskUserPrompt,
2057) {
2058 let placeholder = prompt
2059 .suggest
2060 .clone()
2061 .unwrap_or_else(|| "Type your answer…".to_string());
2062 let input = Arc::new(Input::with_placeholder(&placeholder));
2063 input.set_focused(true);
2064
2065 let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2066 *ctx.state.active_extension_editor.lock().unwrap() = None;
2067 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2068 ctx.editor_container.clear();
2069 ctx.editor_container.add_child(frame);
2070 ctx.tui.set_focus(Some(input.clone()));
2071 ctx.tui.request_render(false);
2072
2073 let state = ctx.state.clone();
2074 let ec = ctx.editor_container.clone();
2075 let editor = ctx.editor.clone();
2076 let tui = ctx.tui.clone();
2077 let bridge_submit = bridge.clone();
2078 let request_id = request.request_id.clone();
2079 input.on_submit(Arc::new(move |text: &str| {
2080 bridge_submit.respond(
2081 &request_id,
2082 serde_json::json!({
2083 "values": [text],
2084 "value": text,
2085 "text": text,
2086 "kind": "input",
2087 }),
2088 );
2089 close_extension_editor(&state, &ec, &editor, &tui);
2090 }));
2091
2092 let state_cancel = ctx.state.clone();
2093 let ec_cancel = ctx.editor_container.clone();
2094 let editor_cancel = ctx.editor.clone();
2095 let tui_cancel = ctx.tui.clone();
2096 let bridge_cancel = bridge.clone();
2097 let request_id_cancel = request.request_id.clone();
2098 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2099 bridge_cancel.cancel(&request_id_cancel);
2100 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2101 }));
2102}
2103
2104struct HelpCommand;
2107impl SlashCommand for HelpCommand {
2108 fn name(&self) -> &'static str {
2109 "/help"
2110 }
2111 fn aliases(&self) -> &'static [&'static str] {
2112 &["/?"]
2113 }
2114 fn description(&self) -> &'static str {
2115 "Show available commands"
2116 }
2117 fn execute(&self, ctx: &CommandContext, _args: &str) {
2118 add_help_message(&ctx.chat);
2119 ctx.tui.request_render(false);
2120 }
2121}
2122
2123struct ClearChatCommand;
2124impl SlashCommand for ClearChatCommand {
2125 fn name(&self) -> &'static str {
2126 "/clear"
2127 }
2128 fn aliases(&self) -> &'static [&'static str] {
2129 &["/new"]
2130 }
2131 fn alias_visible(&self) -> &'static [&'static str] {
2133 &["/new"]
2134 }
2135 fn description(&self) -> &'static str {
2136 "Clear the conversation"
2137 }
2138 fn execute(&self, ctx: &CommandContext, _args: &str) {
2139 let _ = ctx.tx.send(TuiMessage::ClearChat);
2140 }
2141}
2142
2143struct ExitCommand;
2144impl SlashCommand for ExitCommand {
2145 fn name(&self) -> &'static str {
2146 "/exit"
2147 }
2148 fn aliases(&self) -> &'static [&'static str] {
2149 &["/quit", "/q"]
2150 }
2151 fn alias_visible(&self) -> &'static [&'static str] {
2153 &["/quit"]
2154 }
2155 fn description(&self) -> &'static str {
2156 "Exit the application"
2157 }
2158 fn execute(&self, ctx: &CommandContext, _args: &str) {
2159 ctx.state.cancel_js_preparation();
2160 let _ = ctx.tx.send(TuiMessage::Exit);
2161 }
2162}
2163
2164struct VersionCommand;
2165impl SlashCommand for VersionCommand {
2166 fn name(&self) -> &'static str {
2167 "/version"
2168 }
2169 fn aliases(&self) -> &'static [&'static str] {
2170 &["/v"]
2171 }
2172 fn description(&self) -> &'static str {
2173 "Show version information"
2174 }
2175 fn execute(&self, ctx: &CommandContext, _args: &str) {
2176 add_version_message(&ctx.chat);
2177 ctx.tui.request_render(false);
2178 }
2179}
2180
2181struct ChangelogCommand;
2182impl SlashCommand for ChangelogCommand {
2183 fn name(&self) -> &'static str {
2184 "/changelog"
2185 }
2186 fn description(&self) -> &'static str {
2187 "Show recent release changes"
2188 }
2189 fn execute(&self, ctx: &CommandContext, _args: &str) {
2190 add_changelog_message(&ctx.chat);
2191 ctx.tui.request_render(false);
2192 }
2193}
2194
2195struct HotkeysCommand;
2196impl SlashCommand for HotkeysCommand {
2197 fn name(&self) -> &'static str {
2198 "/hotkeys"
2199 }
2200 fn description(&self) -> &'static str {
2201 "Show keyboard shortcuts"
2202 }
2203 fn execute(&self, ctx: &CommandContext, _args: &str) {
2204 add_hotkeys_message(&ctx.chat);
2205 ctx.tui.request_render(false);
2206 }
2207}
2208
2209struct ModelCommand;
2210impl SlashCommand for ModelCommand {
2211 fn name(&self) -> &'static str {
2212 "/model"
2213 }
2214 fn aliases(&self) -> &'static [&'static str] {
2215 &["/m"]
2216 }
2217 fn description(&self) -> &'static str {
2218 "Choose a model (selector)"
2219 }
2220 fn execute(&self, ctx: &CommandContext, args: &str) {
2221 let term = args.trim();
2222 if !term.is_empty() {
2223 let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
2225 add_error_message(
2226 &ctx.chat,
2227 &format!("No model matches \"{term}\". Try /model for the list."),
2228 );
2229 ctx.tui.request_render(false);
2230 return;
2231 };
2232 let model_id = model.id.clone();
2233 ctx.state.set_current_model(&model);
2234 let lane = ctx.lane.clone();
2235 tokio::spawn(async move {
2236 let _ = lane.set_model(model).await;
2237 });
2238 add_note_message(
2239 &ctx.chat,
2240 &format!(
2241 "Model set to {} — applies to the next message.",
2242 short_model_name(&model_id)
2243 ),
2244 );
2245 ctx.tui.request_render(false);
2246 return;
2247 }
2248 open_model_selector(
2249 &ctx.state,
2250 &ctx.editor_container,
2251 &ctx.editor,
2252 &ctx.tui,
2253 &ctx.model_catalog,
2254 &ctx.lane,
2255 &ctx.lane_model_id,
2256 &ctx.chat,
2257 );
2258 }
2259}
2260
2261struct ThinkingCommand;
2262impl SlashCommand for ThinkingCommand {
2263 fn name(&self) -> &'static str {
2264 "/thinking"
2265 }
2266 fn aliases(&self) -> &'static [&'static str] {
2267 &["/think"]
2268 }
2269 fn description(&self) -> &'static str {
2270 "Set thinking level (selector)"
2271 }
2272 fn execute(&self, ctx: &CommandContext, args: &str) {
2273 let level_name = args.trim();
2274 if !level_name.is_empty() {
2275 let Some(level) = thinking_level_from_name(level_name) else {
2277 add_error_message(
2278 &ctx.chat,
2279 &format!(
2280 "Unknown thinking level \"{level_name}\". Valid: {}",
2281 crate::args::VALID_THINKING_LEVELS.join(", ")
2282 ),
2283 );
2284 ctx.tui.request_render(false);
2285 return;
2286 };
2287 let lane = ctx.lane.clone();
2288 let footer = ctx.state.footer.clone();
2289 tokio::spawn(async move {
2290 let _ = lane.set_thinking_level(level).await;
2291 });
2292 footer.set_thinking_level(Some(thinking_level_name(level)));
2293 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2294 ctx.tui.request_render(false);
2295 return;
2296 }
2297 open_thinking_selector(
2298 &ctx.state,
2299 &ctx.editor_container,
2300 &ctx.editor,
2301 &ctx.tui,
2302 &ctx.lane,
2303 &ctx.model_catalog,
2304 &ctx.lane_model_id,
2305 &ctx.chat,
2306 );
2307 }
2308}
2309
2310struct ToolsCommand;
2311impl SlashCommand for ToolsCommand {
2312 fn name(&self) -> &'static str {
2313 "/tools"
2314 }
2315 fn description(&self) -> &'static str {
2316 "Toggle tools on/off"
2317 }
2318 fn execute(&self, ctx: &CommandContext, _args: &str) {
2319 open_tools_selector(
2320 &ctx.state,
2321 &ctx.editor_container,
2322 &ctx.editor,
2323 &ctx.tui,
2324 &ctx.lane,
2325 &ctx.chat,
2326 );
2327 }
2328}
2329
2330struct ImagesCommand;
2331impl SlashCommand for ImagesCommand {
2332 fn name(&self) -> &'static str {
2333 "/images"
2334 }
2335 fn description(&self) -> &'static str {
2336 "Toggle inline images"
2337 }
2338 fn execute(&self, ctx: &CommandContext, _args: &str) {
2339 open_images_selector(
2340 &ctx.state,
2341 &ctx.editor_container,
2342 &ctx.editor,
2343 &ctx.tui,
2344 &ctx.chat,
2345 );
2346 }
2347}
2348
2349struct SessionCommand;
2350impl SlashCommand for SessionCommand {
2351 fn name(&self) -> &'static str {
2352 "/session"
2353 }
2354 fn aliases(&self) -> &'static [&'static str] {
2355 &["/resume"]
2356 }
2357 fn description(&self) -> &'static str {
2358 "List saved sessions"
2359 }
2360 fn execute(&self, ctx: &CommandContext, _args: &str) {
2361 open_session_selector(
2362 &ctx.state,
2363 &ctx.editor_container,
2364 &ctx.editor,
2365 &ctx.tui,
2366 &ctx.cwd,
2367 &ctx.tx,
2368 );
2369 }
2370}
2371
2372struct ThemeCommand;
2373impl SlashCommand for ThemeCommand {
2374 fn name(&self) -> &'static str {
2375 "/theme"
2376 }
2377 fn description(&self) -> &'static str {
2378 "Choose a theme (selector)"
2379 }
2380 fn execute(&self, ctx: &CommandContext, args: &str) {
2381 let name = args.trim().to_ascii_lowercase();
2382 if !name.is_empty() {
2383 let preset = match name.as_str() {
2385 "light" => ThemePreset::Light,
2386 "monochrome" => ThemePreset::Monochrome,
2387 "dark" => ThemePreset::Dark,
2388 _ => {
2389 add_error_message(
2390 &ctx.chat,
2391 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2392 );
2393 ctx.tui.request_render(false);
2394 return;
2395 }
2396 };
2397 apply_theme_preset(preset);
2398 let mut settings = crate::settings::load_settings().unwrap_or_default();
2399 settings.theme = Some(name.clone());
2400 let _ = crate::settings::save_settings(&settings);
2401 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2402 ctx.tui.request_render(false);
2403 ctx.tui.render_now(true);
2404 return;
2405 }
2406 open_theme_selector(
2407 &ctx.state,
2408 &ctx.editor_container,
2409 &ctx.editor,
2410 &ctx.tui,
2411 &ctx.cwd,
2412 &ctx.package_resources,
2413 );
2414 }
2415}
2416
2417struct CompactCommand;
2418impl SlashCommand for CompactCommand {
2419 fn name(&self) -> &'static str {
2420 "/compact"
2421 }
2422 fn description(&self) -> &'static str {
2423 "Compact the conversation"
2424 }
2425 fn execute(&self, ctx: &CommandContext, _args: &str) {
2426 let _ = ctx.tx.send(TuiMessage::Compact);
2427 }
2428}
2429
2430struct CopyCommand;
2431impl SlashCommand for CopyCommand {
2432 fn name(&self) -> &'static str {
2433 "/copy"
2434 }
2435 fn description(&self) -> &'static str {
2436 "Copy last reply to clipboard"
2437 }
2438 fn execute(&self, ctx: &CommandContext, _args: &str) {
2439 let _ = ctx.tx.send(TuiMessage::Copy);
2440 }
2441}
2442
2443struct ExportCommand;
2444impl SlashCommand for ExportCommand {
2445 fn name(&self) -> &'static str {
2446 "/export"
2447 }
2448 fn description(&self) -> &'static str {
2449 "Export session (supports: md, html, jsonl)"
2450 }
2451 fn execute(&self, ctx: &CommandContext, args: &str) {
2452 let format = args.trim().to_lowercase();
2453 if format.is_empty() || format == "md" || format == "markdown" {
2454 let _ = ctx.tx.send(TuiMessage::ExportSession);
2455 } else if format == "html" {
2456 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(crate::export::ExportFormat::Html));
2457 } else if format == "jsonl" {
2458 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(crate::export::ExportFormat::Jsonl));
2459 } else {
2460 add_error_message(&ctx.chat, &format!("Unknown export format: {}. Supported: md, html, jsonl", args.trim()));
2462 ctx.tui.request_render(false);
2463 }
2464 }
2465}
2466
2467struct ForkCommand;
2468impl SlashCommand for ForkCommand {
2469 fn name(&self) -> &'static str {
2470 "/fork"
2471 }
2472 fn description(&self) -> &'static str {
2473 "Fork the session into a new one"
2474 }
2475 fn execute(&self, ctx: &CommandContext, _args: &str) {
2476 let _ = ctx.tx.send(TuiMessage::ForkSession);
2477 }
2478}
2479
2480struct CloneCommand;
2484impl SlashCommand for CloneCommand {
2485 fn name(&self) -> &'static str {
2486 "/clone"
2487 }
2488 fn description(&self) -> &'static str {
2489 "Duplicate the current session"
2490 }
2491 fn execute(&self, ctx: &CommandContext, _args: &str) {
2492 let _ = ctx.tx.send(TuiMessage::ForkSession);
2493 }
2494}
2495
2496struct TreeCommand;
2497impl SlashCommand for TreeCommand {
2498 fn name(&self) -> &'static str {
2499 "/tree"
2500 }
2501 fn description(&self) -> &'static str {
2502 "Navigate the current session tree"
2503 }
2504 fn execute(&self, ctx: &CommandContext, _args: &str) {
2505 let _ = ctx.tx.send(TuiMessage::OpenTree);
2506 }
2507}
2508
2509struct LoginCommand;
2510impl SlashCommand for LoginCommand {
2511 fn name(&self) -> &'static str {
2512 "/login"
2513 }
2514 fn description(&self) -> &'static str {
2515 "Save an Anthropic API key"
2516 }
2517 fn execute(&self, ctx: &CommandContext, args: &str) {
2518 let key = args.trim();
2519 if key.is_empty() {
2520 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2521 } else {
2522 let result = crate::config::upsert_credential(
2523 "anthropic",
2524 crate::config::Credential::ApiKey {
2525 key: Some(key.to_string()),
2526 env: None,
2527 },
2528 );
2529 match result {
2530 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2531 Err(error) => {
2532 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2533 }
2534 }
2535 }
2536 ctx.tui.request_render(false);
2537 }
2538}
2539
2540struct LogoutCommand;
2541impl SlashCommand for LogoutCommand {
2542 fn name(&self) -> &'static str {
2543 "/logout"
2544 }
2545 fn description(&self) -> &'static str {
2546 "Remove saved Anthropic credentials"
2547 }
2548 fn execute(&self, ctx: &CommandContext, _args: &str) {
2549 match crate::config::delete_credential("anthropic") {
2550 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2551 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2552 Err(error) => {
2553 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2554 }
2555 }
2556 ctx.tui.request_render(false);
2557 }
2558}
2559
2560fn set_project_trust_for_command(
2561 cwd: &std::path::Path,
2562 value: Option<bool>,
2563) -> Result<(), crate::config::ConfigError> {
2564 crate::config::set_project_trust(cwd, value)
2565}
2566
2567struct TrustCommand;
2568impl SlashCommand for TrustCommand {
2569 fn name(&self) -> &'static str {
2570 "/trust"
2571 }
2572 fn description(&self) -> &'static str {
2573 "Trust the current project"
2574 }
2575 fn execute(&self, ctx: &CommandContext, args: &str) {
2576 let value = match args.trim().to_ascii_lowercase().as_str() {
2577 "" | "yes" | "y" | "true" => Some(true),
2578 "no" | "n" | "false" => Some(false),
2579 "clear" | "reset" | "none" => None,
2580 _ => {
2581 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2582 ctx.tui.request_render(false);
2583 return;
2584 }
2585 };
2586 match set_project_trust_for_command(&ctx.cwd, value) {
2587 Ok(()) => {
2588 let label = match value {
2589 Some(true) => "trusted",
2590 Some(false) => "untrusted",
2591 None => "trust decision cleared",
2592 };
2593 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2594 }
2595 Err(error) => add_error_message(
2596 &ctx.chat,
2597 &format!("Could not save trust decision: {error}"),
2598 ),
2599 }
2600 ctx.tui.request_render(false);
2601 }
2602}
2603
2604struct NameCommand;
2605impl SlashCommand for NameCommand {
2606 fn name(&self) -> &'static str {
2607 "/name"
2608 }
2609 fn description(&self) -> &'static str {
2610 "Set session display name"
2611 }
2612 fn execute(&self, ctx: &CommandContext, args: &str) {
2613 let name = args.trim();
2614 if name.is_empty() {
2615 add_note_message(
2616 &ctx.chat,
2617 "Usage: /name <display name> — sets the current session's name.",
2618 );
2619 ctx.tui.request_render(false);
2620 return;
2621 }
2622 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2623 }
2624}
2625
2626struct ImportCommand;
2627impl SlashCommand for ImportCommand {
2628 fn name(&self) -> &'static str {
2629 "/import"
2630 }
2631 fn description(&self) -> &'static str {
2632 "Import a session file (path)"
2633 }
2634 fn execute(&self, ctx: &CommandContext, args: &str) {
2635 let path = args.trim();
2636 if path.is_empty() {
2637 add_note_message(
2638 &ctx.chat,
2639 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2640 );
2641 ctx.tui.request_render(false);
2642 return;
2643 }
2644 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2645 }
2646}
2647
2648struct SettingsCommand;
2649impl SlashCommand for SettingsCommand {
2650 fn name(&self) -> &'static str {
2651 "/settings"
2652 }
2653 fn description(&self) -> &'static str {
2654 "Open settings menu"
2655 }
2656 fn execute(&self, ctx: &CommandContext, _args: &str) {
2657 open_settings_selector(
2658 &ctx.state,
2659 &ctx.editor_container,
2660 &ctx.editor,
2661 &ctx.tui,
2662 &ctx.lane,
2663 &ctx.model_catalog,
2664 &ctx.lane_model_id,
2665 &ctx.chat,
2666 &ctx.cwd,
2667 &ctx.package_resources,
2668 );
2669 }
2670}
2671
2672struct ScopedModelsCommand;
2673impl SlashCommand for ScopedModelsCommand {
2674 fn name(&self) -> &'static str {
2675 "/scoped-models"
2676 }
2677 fn description(&self) -> &'static str {
2678 "Choose models for Ctrl+M cycling"
2679 }
2680 fn execute(&self, ctx: &CommandContext, _args: &str) {
2681 open_scoped_models_selector(
2682 &ctx.state,
2683 &ctx.editor_container,
2684 &ctx.editor,
2685 &ctx.tui,
2686 &ctx.model_catalog,
2687 &ctx.chat,
2688 );
2689 }
2690}
2691
2692struct ShareCommand;
2693impl SlashCommand for ShareCommand {
2694 fn name(&self) -> &'static str {
2695 "/share"
2696 }
2697 fn description(&self) -> &'static str {
2698 "Share session (gist via gh, or clipboard)"
2699 }
2700 fn execute(&self, ctx: &CommandContext, _args: &str) {
2701 let _ = ctx.tx.send(TuiMessage::ShareSession);
2702 }
2703}
2704
2705struct ArminCommand;
2706impl SlashCommand for ArminCommand {
2707 fn name(&self) -> &'static str {
2708 "/armin"
2709 }
2710 fn description(&self) -> &'static str {
2711 "??? (easter egg)"
2712 }
2713 fn execute(&self, ctx: &CommandContext, _args: &str) {
2714 crate::extras::add_armin(&ctx.chat);
2715 ctx.tui.request_render(false);
2716 }
2717}
2718
2719struct EarendilCommand;
2720impl SlashCommand for EarendilCommand {
2721 fn name(&self) -> &'static str {
2722 "/earendil"
2723 }
2724 fn description(&self) -> &'static str {
2725 "Announcement"
2726 }
2727 fn execute(&self, ctx: &CommandContext, _args: &str) {
2728 crate::extras::add_earendil(&ctx.chat);
2729 ctx.tui.request_render(false);
2730 }
2731}
2732
2733struct ContextCommand;
2737impl SlashCommand for ContextCommand {
2738 fn name(&self) -> &'static str {
2739 "/context"
2740 }
2741 fn visible(&self) -> bool {
2742 false
2743 }
2744 fn execute(&self, ctx: &CommandContext, _args: &str) {
2745 show_context_panel(&ctx.chat, &ctx.resources);
2746 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2748 ctx.tui.request_render(false);
2749 }
2750}
2751
2752struct UsageCommand;
2756impl SlashCommand for UsageCommand {
2757 fn name(&self) -> &'static str {
2758 "/usage"
2759 }
2760 fn description(&self) -> &'static str {
2761 "Show token, cost, and cache totals for this session"
2762 }
2763 fn execute(&self, ctx: &CommandContext, _args: &str) {
2764 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2765 ctx.tui.request_render(false);
2766 }
2767}
2768
2769struct ReloadCommand;
2780impl SlashCommand for ReloadCommand {
2781 fn name(&self) -> &'static str {
2782 "/reload"
2783 }
2784 fn description(&self) -> &'static str {
2785 "Reload extensions, skills, prompts"
2786 }
2787 fn execute(&self, ctx: &CommandContext, _args: &str) {
2788 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2792 ctx.tui.request_render(false);
2793 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2794 }
2795}
2796
2797fn build_builtin_registry() -> CommandRegistry {
2802 let mut r = CommandRegistry::new();
2803 r.register(Arc::new(HelpCommand));
2804 r.register(Arc::new(ClearChatCommand));
2805 r.register(Arc::new(ExitCommand));
2806 r.register(Arc::new(VersionCommand));
2807 r.register(Arc::new(ChangelogCommand));
2808 r.register(Arc::new(ModelCommand));
2809 r.register(Arc::new(ThinkingCommand));
2810 r.register(Arc::new(ToolsCommand));
2811 r.register(Arc::new(ImagesCommand));
2812 r.register(Arc::new(SessionCommand));
2813 r.register(Arc::new(ThemeCommand));
2814 r.register(Arc::new(CompactCommand));
2815 r.register(Arc::new(CopyCommand));
2816 r.register(Arc::new(HotkeysCommand));
2817 r.register(Arc::new(ArminCommand));
2818 r.register(Arc::new(EarendilCommand));
2819 r.register(Arc::new(ContextCommand));
2820 r.register(Arc::new(UsageCommand));
2821 r.register(Arc::new(NameCommand));
2825 r.register(Arc::new(SettingsCommand));
2826 r.register(Arc::new(ScopedModelsCommand));
2827 r.register(Arc::new(ExportCommand));
2828 r.register(Arc::new(ImportCommand));
2829 r.register(Arc::new(ShareCommand));
2830 r.register(Arc::new(ForkCommand));
2831 r.register(Arc::new(CloneCommand));
2832 r.register(Arc::new(TreeCommand));
2833 r.register(Arc::new(TrustCommand));
2834 r.register(Arc::new(LoginCommand));
2835 r.register(Arc::new(LogoutCommand));
2836 r.register(Arc::new(ReloadCommand));
2837 r
2838}
2839
2840fn register_extension_commands(
2841 registry: &mut CommandRegistry,
2842 session: crate::session::ExtensionSessionCell,
2843) {
2844 let commands = session
2845 .lock()
2846 .ok()
2847 .and_then(|s| s.snapshot_arc())
2848 .map(|snap| snap.commands().to_vec())
2849 .unwrap_or_default();
2850 for command in commands {
2851 let name = if command.name.starts_with('/') {
2852 command.name.clone()
2853 } else {
2854 format!("/{}", command.name)
2855 };
2856 if registry.find(&name).is_some() {
2857 continue;
2858 }
2859 registry.register(Arc::new(ExtensionCommand {
2860 name,
2861 description: command.description,
2862 session: session.clone(),
2863 }));
2864 }
2865}
2866
2867fn register_js_extension_commands(
2868 registry: &mut CommandRegistry,
2869 session: Option<crate::js_extensions::JsExtensionSession>,
2870) {
2871 let Some(session) = session else {
2872 return;
2873 };
2874 for command in &session.commands {
2875 let name = if command.starts_with('/') {
2876 command.clone()
2877 } else {
2878 format!("/{command}")
2879 };
2880 if registry.find(&name).is_none() {
2881 registry.register(Arc::new(JsExtensionCommand {
2882 name,
2883 session: session.clone(),
2884 }));
2885 }
2886 }
2887}
2888
2889enum TuiMessage {
2896 UserInput(String),
2897 OpenTree,
2898 NavigateTree(String),
2899 Exit,
2900 ClearChat,
2902 Compact,
2904 Copy,
2906 SwitchSession(String),
2909 ExportSession,
2911 ExportSessionWithFormat(crate::export::ExportFormat),
2913 ShowUsage,
2915 ForkSession,
2917 SetSessionName(String),
2919 ImportSession(String),
2922 ShareSession,
2925 ReloadExtensions,
2930 ExternalEditorResult(Result<String, String>),
2934}
2935
2936fn assistant_text(msg: &AssistantMessage) -> String {
2939 msg.content
2940 .iter()
2941 .filter_map(|c| match c {
2942 Content::Text(t) => Some(t.text.clone()),
2943 _ => None,
2944 })
2945 .collect()
2946}
2947
2948fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2951 msg.content
2952 .iter()
2953 .filter_map(|c| match c {
2954 Content::Text(t) => Some(t.text.clone()),
2955 _ => None,
2956 })
2957 .collect::<Vec<_>>()
2958 .join("\n")
2959}
2960
2961fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2962 match &msg.content {
2963 rpi_ai::types::UserContent::Text(s) => s.clone(),
2964 rpi_ai::types::UserContent::Blocks(blocks) => blocks
2965 .iter()
2966 .filter_map(|c| match c {
2967 Content::Text(t) => Some(t.text.clone()),
2968 _ => None,
2969 })
2970 .collect(),
2971 }
2972}
2973
2974fn show_settings_panel(chat: &Arc<Container>) {
2979 let s = crate::settings::load_settings().unwrap_or_default();
2980 let mut lines: Vec<String> = Vec::new();
2981 lines.push("⚙️ Saved settings:".into());
2982 lines.push(format!(
2983 " Theme: {} (edit with /theme)",
2984 s.theme.as_deref().unwrap_or("(default)")
2985 ));
2986 lines.push(format!(
2987 " Default model: {} (set at launch with --model)",
2988 s.default_model.as_deref().unwrap_or("(none)")
2989 ));
2990 lines.push(format!(
2991 " Default thinking: {} (set at launch with --thinking)",
2992 s.default_thinking_level.as_deref().unwrap_or("(default)")
2993 ));
2994 match &s.scoped_models {
2995 Some(list) if !list.is_empty() => lines.push(format!(
2996 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
2997 list.join(", ")
2998 )),
2999 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
3000 }
3001 let body = lines.join("\n");
3002 container_note_block(chat, &body);
3003}
3004
3005fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3009 let scoped = crate::settings::load_settings()
3010 .ok()
3011 .and_then(|s| s.scoped_models)
3012 .unwrap_or_default();
3013 if scoped.is_empty() {
3014 return catalog.to_vec();
3015 }
3016 let mut out: Vec<rpi_ai::Model> = catalog
3017 .iter()
3018 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3019 .cloned()
3020 .collect();
3021 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3023 if let Some(cur) = catalog
3024 .iter()
3025 .find(|m| m.id.eq_ignore_ascii_case(current_id))
3026 {
3027 out.push(cur.clone());
3028 }
3029 }
3030 out
3031}
3032
3033fn open_settings_selector(
3040 state: &Arc<TuiState>,
3041 editor_container: &Arc<Container>,
3042 editor: &Arc<Editor>,
3043 tui: &Arc<TuiAltScreen>,
3044 lane: &Arc<dyn AgentLane>,
3045 catalog: &[rpi_ai::Model],
3046 lane_model_id: &str,
3047 chat: &Arc<Container>,
3048 cwd: &std::path::Path,
3049 package_resources: &Arc<crate::packages::PackageResources>,
3050) {
3051 let settings = crate::settings::load_settings().unwrap_or_default();
3052 let mut items: Vec<SelectItem> = Vec::new();
3053 items.push(
3054 SelectItem::new("theme", "Theme")
3055 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
3056 );
3057 items.push(
3058 SelectItem::new("model", "Default model").with_description(
3059 &settings
3060 .default_model
3061 .clone()
3062 .unwrap_or_else(|| "(none)".into()),
3063 ),
3064 );
3065 items.push(
3066 SelectItem::new("thinking", "Default thinking").with_description(
3067 &settings
3068 .default_thinking_level
3069 .clone()
3070 .unwrap_or_else(|| "(default)".into()),
3071 ),
3072 );
3073 let scope_desc = match &settings.scoped_models {
3074 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
3075 _ => "all models".to_string(),
3076 };
3077 items
3078 .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
3079 let list = Arc::new(SelectList::new(items, 10));
3080
3081 let state_sel = state.clone();
3082 let ec_sel = editor_container.clone();
3083 let editor_sel = editor.clone();
3084 let tui_sel = tui.clone();
3085 let lane_sel = lane.clone();
3086 let chat_sel = chat.clone();
3087 let catalog_sel = catalog.to_vec();
3088 let lane_model_sel = lane_model_id.to_string();
3089 let cwd_sel = cwd.to_path_buf();
3090 let package_resources_sel = package_resources.clone();
3091 list.on_select(Arc::new(move |item| {
3092 match item.value.as_str() {
3095 "theme" => open_settings_theme_selector(
3096 &state_sel,
3097 &ec_sel,
3098 &editor_sel,
3099 &tui_sel,
3100 &chat_sel,
3101 &cwd_sel,
3102 &package_resources_sel,
3103 ),
3104 "model" => open_settings_model_selector(
3105 &state_sel,
3106 &ec_sel,
3107 &editor_sel,
3108 &tui_sel,
3109 &lane_sel,
3110 &catalog_sel,
3111 &lane_model_sel,
3112 &chat_sel,
3113 ),
3114 "thinking" => open_settings_thinking_selector(
3115 &state_sel,
3116 &ec_sel,
3117 &editor_sel,
3118 &tui_sel,
3119 &lane_sel,
3120 &catalog_sel,
3121 &lane_model_sel,
3122 &chat_sel,
3123 ),
3124 "scoped-models" => open_scoped_models_selector(
3125 &state_sel,
3126 &ec_sel,
3127 &editor_sel,
3128 &tui_sel,
3129 &catalog_sel,
3130 &chat_sel,
3131 ),
3132 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3133 }
3134 }));
3135 let state_cancel = state.clone();
3136 let ec_cancel = editor_container.clone();
3137 let editor_cancel = editor.clone();
3138 let tui_cancel = tui.clone();
3139 list.on_cancel(Arc::new(move || {
3140 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3141 }));
3142
3143 open_selector(
3144 state,
3145 editor_container,
3146 editor,
3147 tui,
3148 list,
3149 SelectorKind::Settings,
3150 );
3151}
3152
3153fn open_settings_theme_selector(
3155 state: &Arc<TuiState>,
3156 editor_container: &Arc<Container>,
3157 editor: &Arc<Editor>,
3158 tui: &Arc<TuiAltScreen>,
3159 chat: &Arc<Container>,
3160 cwd: &std::path::Path,
3161 package_resources: &Arc<crate::packages::PackageResources>,
3162) {
3163 let mut items = vec![
3164 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3165 SelectItem::new("light", "Light").with_description("Light background"),
3166 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3167 ];
3168 if state.themes_enabled {
3169 for path in package_resources.theme_files() {
3170 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3171 items.push(SelectItem::new(name, name).with_description("Package theme"));
3172 }
3173 }
3174 }
3175 let list = Arc::new(SelectList::new(items, 10));
3176
3177 let state_sel = state.clone();
3178 let ec_sel = editor_container.clone();
3179 let editor_sel = editor.clone();
3180 let tui_sel = tui.clone();
3181 let chat_sel = chat.clone();
3182 let cwd_sel = cwd.to_path_buf();
3183 let package_resources_sel = package_resources.clone();
3184 list.on_select(Arc::new(move |item| {
3185 let preset = match item.value.as_str() {
3186 "light" => Some(ThemePreset::Light),
3187 "monochrome" => Some(ThemePreset::Monochrome),
3188 "dark" => Some(ThemePreset::Dark),
3189 name => {
3190 if state_sel.themes_enabled {
3191 if let Ok(custom) = crate::packages::load_theme_with_resources(
3192 &cwd_sel,
3193 name,
3194 &package_resources_sel,
3195 ) {
3196 rpi_tui::global_theme_manager().set(custom.clone());
3197 state_sel.theme_manager.set(custom);
3198 }
3199 }
3200 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3201 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3202 tui_sel.render_now(true);
3203 return;
3204 }
3205 };
3206 let Some(preset) = preset else { return };
3207 apply_theme_preset(preset);
3208 state_sel.theme_manager.apply_preset(preset);
3209 let mut settings = crate::settings::load_settings().unwrap_or_default();
3210 settings.theme = Some(item.value.clone());
3211 let saved = crate::settings::save_settings(&settings);
3212 add_note_message(
3213 &chat_sel,
3214 &format!(
3215 "Theme set to {} (saved{})",
3216 item.label,
3217 if saved.is_ok() { "" } else { ", not saved" },
3218 ),
3219 );
3220 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3221 tui_sel.render_now(true);
3222 }));
3223 let state_cancel = state.clone();
3224 let ec_cancel = editor_container.clone();
3225 let editor_cancel = editor.clone();
3226 let tui_cancel = tui.clone();
3227 list.on_cancel(Arc::new(move || {
3228 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3229 }));
3230
3231 open_selector(
3232 state,
3233 editor_container,
3234 editor,
3235 tui,
3236 list,
3237 SelectorKind::Settings,
3238 );
3239}
3240
3241fn open_settings_model_selector(
3245 state: &Arc<TuiState>,
3246 editor_container: &Arc<Container>,
3247 editor: &Arc<Editor>,
3248 tui: &Arc<TuiAltScreen>,
3249 lane: &Arc<dyn AgentLane>,
3250 catalog: &[rpi_ai::Model],
3251 lane_model_id: &str,
3252 chat: &Arc<Container>,
3253) {
3254 let items = model_selector_items(catalog, lane_model_id);
3255 if items.is_empty() {
3256 add_note_message(chat, "No models in the catalog.");
3257 tui.request_render(false);
3258 return;
3259 }
3260 let list = Arc::new(SelectList::new(items, 10));
3261
3262 let catalog_arc = catalog.to_vec();
3263 let state_sel = state.clone();
3264 let ec_sel = editor_container.clone();
3265 let editor_sel = editor.clone();
3266 let tui_sel = tui.clone();
3267 let chat_sel = chat.clone();
3268 let lane_sel = lane.clone();
3269 list.on_select(Arc::new(move |item| {
3270 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3271 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3272 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3273 return;
3274 };
3275 state_sel.set_current_model(&model);
3276 let lane = lane_sel.clone();
3277 tokio::spawn(async move {
3278 let _ = lane.set_model(model).await;
3279 });
3280 let mut settings = crate::settings::load_settings().unwrap_or_default();
3281 settings.default_model = Some(item.value.clone());
3282 let saved = crate::settings::save_settings(&settings);
3283 add_note_message(
3284 &chat_sel,
3285 &format!(
3286 "Default model set to {} (saved{}",
3287 short_model_name(&item.value),
3288 if saved.is_ok() { ")" } else { ", not saved)" },
3289 ),
3290 );
3291 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3292 }));
3293 let state_cancel = state.clone();
3294 let ec_cancel = editor_container.clone();
3295 let editor_cancel = editor.clone();
3296 let tui_cancel = tui.clone();
3297 list.on_cancel(Arc::new(move || {
3298 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3299 }));
3300
3301 open_selector(
3302 state,
3303 editor_container,
3304 editor,
3305 tui,
3306 list,
3307 SelectorKind::Settings,
3308 );
3309}
3310
3311fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3316 let mut seen = std::collections::HashSet::new();
3317 catalog
3318 .iter()
3319 .filter(|m| {
3320 seen.insert((
3321 m.api.clone(),
3322 m.provider.to_ascii_lowercase(),
3323 m.id.to_ascii_lowercase(),
3324 ))
3325 })
3326 .map(|m| {
3327 let label = if m.name.is_empty() {
3328 short_model_name(&m.id)
3329 } else {
3330 m.name.clone()
3331 };
3332 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3333 && m.provider.eq_ignore_ascii_case("anthropic")
3334 {
3335 m.id.clone()
3336 } else {
3337 format!("{}/{}", m.provider, m.id)
3338 };
3339 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3340 " (current)"
3341 } else {
3342 ""
3343 };
3344 SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
3345 })
3346 .collect()
3347}
3348
3349fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3353 let (provider, id) = input
3354 .split_once('/')
3355 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3356 .map_or((None, input), |(provider, id)| (Some(provider), id));
3357 catalog
3358 .iter()
3359 .find(|model| {
3360 model.id.eq_ignore_ascii_case(id)
3361 && provider.map_or(true, |provider| {
3362 model.provider.eq_ignore_ascii_case(provider)
3363 || (provider.eq_ignore_ascii_case("anthropic")
3364 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3365 })
3366 })
3367 .cloned()
3368}
3369
3370fn open_settings_thinking_selector(
3374 state: &Arc<TuiState>,
3375 editor_container: &Arc<Container>,
3376 editor: &Arc<Editor>,
3377 tui: &Arc<TuiAltScreen>,
3378 lane: &Arc<dyn AgentLane>,
3379 catalog: &[rpi_ai::Model],
3380 lane_model_id: &str,
3381 chat: &Arc<Container>,
3382) {
3383 let model = catalog
3384 .iter()
3385 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3386 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3387 .map(|m| m.supported_thinking_levels())
3388 .unwrap_or_else(|| {
3389 use rpi_ai::types::ThinkingLevel::*;
3390 vec![Off, Minimal, Low, Medium, High]
3391 });
3392 let mut items: Vec<SelectItem> = Vec::new();
3393 for lvl in &levels {
3394 let name = thinking_level_name(*lvl);
3395 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3396 }
3397 if items.is_empty() {
3398 add_note_message(chat, "This model has no supported thinking levels.");
3399 tui.request_render(false);
3400 return;
3401 }
3402 let list = Arc::new(SelectList::new(items, 10));
3403
3404 let state_sel = state.clone();
3405 let ec_sel = editor_container.clone();
3406 let editor_sel = editor.clone();
3407 let tui_sel = tui.clone();
3408 let chat_sel = chat.clone();
3409 let lane_sel = lane.clone();
3410 list.on_select(Arc::new(move |item| {
3411 let Some(level) = thinking_level_from_name(&item.value) else {
3412 add_note_message(
3413 &chat_sel,
3414 &format!("Unknown thinking level: {}.", item.label),
3415 );
3416 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3417 return;
3418 };
3419 let lane = lane_sel.clone();
3420 let footer_sel = state_sel.footer.clone();
3421 tokio::spawn(async move {
3422 let _ = lane.set_thinking_level(level).await;
3423 });
3424 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3425 let mut settings = crate::settings::load_settings().unwrap_or_default();
3426 settings.default_thinking_level = Some(item.value.clone());
3427 let saved = crate::settings::save_settings(&settings);
3428 add_note_message(
3429 &chat_sel,
3430 &format!(
3431 "Default thinking set to {} (saved{}",
3432 item.label,
3433 if saved.is_ok() { ")" } else { ", not saved)" },
3434 ),
3435 );
3436 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3437 }));
3438 let state_cancel = state.clone();
3439 let ec_cancel = editor_container.clone();
3440 let editor_cancel = editor.clone();
3441 let tui_cancel = tui.clone();
3442 list.on_cancel(Arc::new(move || {
3443 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3444 }));
3445
3446 open_selector(
3447 state,
3448 editor_container,
3449 editor,
3450 tui,
3451 list,
3452 SelectorKind::Settings,
3453 );
3454}
3455
3456fn open_scoped_models_selector(
3461 state: &Arc<TuiState>,
3462 editor_container: &Arc<Container>,
3463 editor: &Arc<Editor>,
3464 tui: &Arc<TuiAltScreen>,
3465 catalog: &[rpi_ai::Model],
3466 chat: &Arc<Container>,
3467) {
3468 if catalog.is_empty() {
3469 add_note_message(chat, "No models in the catalog.");
3470 tui.request_render(false);
3471 return;
3472 }
3473 let seed: Vec<String> = crate::settings::load_settings()
3475 .ok()
3476 .and_then(|s| s.scoped_models)
3477 .unwrap_or_default();
3478 *state.scoped_edit.lock().unwrap() = Some(seed);
3479
3480 let mut items: Vec<SelectItem> = Vec::new();
3481 for m in catalog {
3482 items.push(SelectItem::new(&m.id, &m.id));
3483 }
3484 let list = Arc::new(SelectList::new(items, 10));
3485
3486 let state_sel = state.clone();
3487 let chat_sel = chat.clone();
3488 let tui_sel = tui.clone();
3489 list.on_select(Arc::new(move |item| {
3490 let mut set = state_sel.scoped_edit.lock().unwrap();
3492 let set = set.get_or_insert_with(Vec::new);
3493 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3494 set.remove(pos);
3495 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3496 } else {
3497 set.push(item.value.clone());
3498 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3499 }
3500 tui_sel.request_render(false);
3501 }));
3502 let state_cancel = state.clone();
3503 let ec_cancel = editor_container.clone();
3504 let editor_cancel = editor.clone();
3505 let tui_cancel = tui.clone();
3506 let chat_cancel = chat.clone();
3507 list.on_cancel(Arc::new(move || {
3508 let set = state_cancel
3510 .scoped_edit
3511 .lock()
3512 .unwrap()
3513 .take()
3514 .unwrap_or_default();
3515 let mut settings = crate::settings::load_settings().unwrap_or_default();
3516 settings.scoped_models = if set.is_empty() {
3517 None
3518 } else {
3519 Some(set.clone())
3520 };
3521 match crate::settings::save_settings(&settings) {
3522 Ok(()) => {
3523 if set.is_empty() {
3524 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3525 } else {
3526 add_note_message(
3527 &chat_cancel,
3528 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3529 );
3530 }
3531 }
3532 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3533 }
3534 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3535 }));
3536
3537 open_selector(
3538 state,
3539 editor_container,
3540 editor,
3541 tui,
3542 list,
3543 SelectorKind::ScopedModels,
3544 );
3545}
3546
3547async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3551 use std::process::Stdio;
3552
3553 let tree = harness.session().view("main");
3555 let entries = match tree
3556 .find_entries(&EntryQuery {
3557 entry_type: None,
3558 custom_type: None,
3559 order: Some(EntryOrder::OldestFirst),
3562 limit: None,
3563 cursor: None,
3564 })
3565 .await
3566 {
3567 Ok(e) => e,
3568 Err(e) => {
3569 add_error_message(chat, &format!("Could not read session: {e}"));
3570 return;
3571 }
3572 };
3573 let mut md = String::from("# Session\n\n");
3574 for e in entries {
3575 let Entry::Message(me) = e else { continue };
3576 match &me.message {
3577 AgentMessage::User(u) => {
3578 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3579 }
3580 AgentMessage::Assistant(a) => {
3581 let text = assistant_text(a);
3582 if !text.is_empty() {
3583 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3584 }
3585 }
3586 _ => {}
3587 }
3588 }
3589
3590 let gh = std::process::Command::new("gh")
3592 .arg("gist")
3593 .arg("create")
3594 .arg("--filename")
3595 .arg("session.md")
3596 .arg("-")
3597 .stdin(Stdio::piped())
3598 .stdout(Stdio::piped())
3599 .stderr(Stdio::null())
3600 .spawn();
3601 if let Ok(mut child) = gh {
3602 use std::io::Write;
3603 if let Some(mut stdin) = child.stdin.take() {
3604 let _ = stdin.write_all(md.as_bytes());
3605 let _ = stdin.flush();
3606 }
3607 let out = child.wait_with_output().ok();
3608 if let Some(out) = out {
3609 if out.status.success() {
3610 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3611 add_note_message(chat, &format!("Shared session: {url}"));
3612 return;
3613 }
3614 }
3615 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3616 } else {
3617 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3618 }
3619 if copy_to_clipboard(&md) {
3621 add_note_message(chat, "Session transcript copied to the clipboard.");
3622 } else {
3623 add_note_message(
3624 chat,
3625 "Clipboard unavailable — use /export to write the transcript to a file.",
3626 );
3627 }
3628}
3629
3630async fn switch_to_session(
3645 harness: &AgentHarness,
3646 lane: &Arc<dyn AgentLane>,
3647 id: &str,
3648 cwd: &std::path::Path,
3649 chat: &Arc<Container>,
3650 state: &Arc<TuiState>,
3651) -> bool {
3652 if *state.status.lock().unwrap() == RunStatus::Working {
3653 state.set_status(RunStatus::Aborting);
3654 let _ = lane.abort().await;
3655 }
3656 let cwd_str = cwd.to_string_lossy().to_string();
3657 match crate::session::open_session_by_id(id, &cwd_str).await {
3658 Ok(new_session) => {
3659 let _ = harness.set_session(new_session).await;
3660 chat.clear();
3661 add_welcome_message(chat);
3662 render_session_history(
3663 harness,
3664 chat,
3665 state.markdown_transformer(),
3666 Some(state.extension_session.clone()),
3667 )
3668 .await;
3669 state.set_status(RunStatus::Idle);
3670 add_note_message(chat, &format!("Switched to session {id}."));
3671 true
3672 }
3673 Err(e) => {
3674 state.set_status(RunStatus::Idle);
3675 add_error_message(chat, &format!("Could not open session {id}: {e}"));
3676 false
3677 }
3678 }
3679}
3680
3681async fn import_session(
3685 harness: &AgentHarness,
3686 lane: &Arc<dyn AgentLane>,
3687 path: &str,
3688 cwd: &std::path::Path,
3689 chat: &Arc<Container>,
3690 state: &Arc<TuiState>,
3691) {
3692 use std::path::Path as FsPath;
3693
3694 let src = FsPath::new(path);
3695 if !src.is_file() {
3696 add_error_message(chat, &format!("Import source not found: {path}"));
3697 return;
3698 }
3699 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3700 add_error_message(chat, "Import source has no file name.");
3701 return;
3702 };
3703 if !fname.ends_with(".jsonl") {
3704 add_error_message(chat, "Import source must be a .jsonl session file.");
3705 return;
3706 }
3707 let dir = crate::session::default_session_dir(cwd);
3708 if let Err(e) = std::fs::create_dir_all(&dir) {
3709 add_error_message(chat, &format!("Could not create session dir: {e}"));
3710 return;
3711 }
3712 let dest = dir.join(fname);
3713 match std::fs::copy(src, &dest) {
3714 Ok(_) => {
3715 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3716 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3717 add_note_message(chat, &format!("Imported session from {path}"));
3718 }
3719 }
3720 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3721 }
3722}
3723
3724async fn fork_session(
3725 harness: &AgentHarness,
3726 cwd: &std::path::Path,
3727 chat: &Arc<Container>,
3728 state: &Arc<TuiState>,
3729) {
3730 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3731 use rpi_tools::FileSystem;
3732
3733 let cwd_str = cwd.to_string_lossy().to_string();
3734 let dir = crate::session::default_session_dir(cwd);
3735 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3736 let fs: Arc<dyn FileSystem> = env.clone();
3737 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3738 fs,
3739 sessions_root: dir.to_string_lossy().into_owned(),
3740 clock: Arc::new(rpi_harness::session::memory::SystemClock),
3741 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3742 });
3743 let id = harness.session().storage().metadata().id.clone();
3746 let metas = match crate::session::list_session_metadata(&cwd_str).await {
3747 Ok(m) => m,
3748 Err(e) => {
3749 add_error_message(chat, &format!("Could not list sessions: {e}"));
3750 return;
3751 }
3752 };
3753 let Some(source) = metas.iter().find(|m| m.id == id) else {
3754 add_error_message(chat, &format!("Current session {id} not found on disk."));
3755 return;
3756 };
3757 let fork_storage = match repo
3758 .fork_typed(
3759 source,
3760 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3761 id: None,
3762 parent_session_id: Some(source.id.clone()),
3763 cwd: cwd_str.clone(),
3764 metadata: None,
3765 },
3766 &rpi_harness::session::types::ForkOptions::default(),
3767 )
3768 .await
3769 {
3770 Ok(s) => s,
3771 Err(e) => {
3772 add_error_message(chat, &format!("Could not fork session: {e}"));
3773 return;
3774 }
3775 };
3776 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3777 let _ = harness.set_session(new_session).await;
3778 chat.clear();
3779 add_welcome_message(chat);
3780 render_session_history(
3781 harness,
3782 chat,
3783 state.markdown_transformer(),
3784 Some(state.extension_session.clone()),
3785 )
3786 .await;
3787 state.set_status(RunStatus::Idle);
3788 add_note_message(chat, "Forked into a new session.");
3789}
3790
3791async fn render_session_history(
3801 harness: &AgentHarness,
3802 chat: &Arc<Container>,
3803 transformer: Option<MarkdownTransformer>,
3804 extension_session: Option<crate::session::ExtensionSessionCell>,
3805) {
3806 let tree = harness.session().view("main");
3807 let entries = match tree
3808 .find_entries(&EntryQuery {
3809 entry_type: None,
3810 custom_type: None,
3811 order: Some(EntryOrder::OldestFirst),
3815 limit: None,
3816 cursor: None,
3817 })
3818 .await
3819 {
3820 Ok(e) => e,
3821 Err(_) => return,
3822 };
3823 let mut rendered_any = false;
3824 for e in entries {
3825 match e {
3826 Entry::Message(me) => match &me.message {
3827 AgentMessage::User(u) => {
3828 add_user_message(chat, &user_message_text(u));
3829 rendered_any = true;
3830 }
3831 AgentMessage::Assistant(a) => {
3832 let comp = Arc::new(AssistantMessageComponent::new(
3833 AssistantMessageOptions::default(),
3834 ));
3835 if let Some(t) = &transformer {
3836 comp.set_markdown_transformer(Some(t.clone()));
3837 }
3838 comp.update_blocks(&assistant_blocks(a));
3839 chat.add_child(comp);
3840 chat.add_child(Arc::new(Spacer::new(1)));
3843 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3844 add_note_message(chat, &text);
3845 }
3846 if let Some(error) = assistant_error_text(a) {
3847 add_error_message(chat, &error);
3848 }
3849 rendered_any = true;
3850 }
3851 AgentMessage::ToolResult(result) => {
3852 let comp = Arc::new(ToolExecutionComponent::new(
3857 &result.tool_name,
3858 "",
3859 ));
3860 comp.set_result(&tool_result_message_text(result), result.is_error);
3861 chat.add_child(comp);
3862 chat.add_child(Arc::new(Spacer::new(1)));
3863 rendered_any = true;
3864 }
3865 AgentMessage::Custom(custom) => {
3866 if let Some(session) = &extension_session {
3867 if let Some(component) = extension_message_component(
3868 session,
3869 &custom.role,
3870 &serde_json::json!({
3871 "customType": custom.role,
3872 "content": custom.content,
3873 "details": custom.data,
3874 }),
3875 transformer.clone(),
3876 ) {
3877 chat.add_child(component);
3878 chat.add_child(Arc::new(Spacer::new(1)));
3879 rendered_any = true;
3880 continue;
3881 }
3882 }
3883 add_note_message(chat, &custom_message_fallback(&custom));
3884 rendered_any = true;
3885 }
3886 },
3887 Entry::Compaction(compaction) => {
3888 add_note_message(
3889 chat,
3890 &format!(
3891 "Compacted {} tokens: {}",
3892 compaction.tokens_before, compaction.summary
3893 ),
3894 );
3895 rendered_any = true;
3896 }
3897 Entry::BranchSummary(summary) => {
3898 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3899 rendered_any = true;
3900 }
3901 Entry::Custom(custom) => {
3902 let rendered = extension_session.as_ref().and_then(|session| {
3903 extension_entry_component(session, &custom.custom_type, custom.data.clone())
3904 });
3905 if let Some(component) = rendered {
3906 chat.add_child(component);
3907 chat.add_child(Arc::new(Spacer::new(1)));
3908 rendered_any = true;
3909 } else if let Some(text) =
3910 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3911 {
3912 add_note_message(chat, &text);
3913 rendered_any = true;
3914 }
3915 }
3916 Entry::ModelChange(change) => {
3917 add_note_message(
3918 chat,
3919 &format!("Model changed to {}:{}", change.provider, change.model_id),
3920 );
3921 rendered_any = true;
3922 }
3923 Entry::ThinkingLevel(change) => {
3924 add_note_message(
3925 chat,
3926 &format!("Thinking level: {:?}", change.thinking_level),
3927 );
3928 rendered_any = true;
3929 }
3930 Entry::ActiveTools(change) => {
3931 add_note_message(
3932 chat,
3933 &format!("Active tools: {}", change.active_tool_names.join(", ")),
3934 );
3935 rendered_any = true;
3936 }
3937 }
3938 }
3939 if rendered_any {
3940 }
3943}
3944
3945fn invoke_extension_renderer(
3946 session: &crate::session::ExtensionSessionCell,
3947 kind: rpi_extensions::RegisteredRendererKind,
3948 payload: &serde_json::Value,
3949) -> Option<serde_json::Value> {
3950 let snapshot = session.lock().ok()?.snapshot_arc()?;
3951 let input = serde_json::to_string(payload).ok()?;
3952 for renderer in snapshot.renderers_of(kind) {
3953 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3954 let mut out = rpi_plugin_sdk::StbString::empty();
3955 let rc = (renderer.render_fn)(
3956 rpi_plugin_sdk::StbStringRef::from_str(&input),
3957 &mut out as *mut rpi_plugin_sdk::StbString,
3958 renderer.user_data,
3959 );
3960 let text = if rc == 0 {
3961 Some(out.to_string_lossy())
3962 } else {
3963 None
3964 };
3965 out.free_with(Some(renderer.plugin_free_string));
3966 text
3967 }))
3968 .ok()
3969 .flatten();
3970 let Some(text) = outcome else { continue };
3971 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3972 return Some(value);
3973 }
3974 }
3975 None
3976}
3977
3978fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3979 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3980 let text = lines
3981 .iter()
3982 .filter_map(|line| line.as_str())
3983 .collect::<Vec<_>>()
3984 .join("\n");
3985 return Some(Arc::new(Text::new(text, 0, 0)));
3986 }
3987 let text = value.get("text").and_then(|v| v.as_str())?;
3988 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3989 let component = Arc::new(AssistantMessageComponent::new(
3990 AssistantMessageOptions::default(),
3991 ));
3992 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3993 Some(component)
3994 } else {
3995 Some(Arc::new(Text::new(text, 0, 0)))
3996 }
3997}
3998
3999fn extension_message_component(
4000 session: &crate::session::ExtensionSessionCell,
4001 custom_type: &str,
4002 payload: &serde_json::Value,
4003 transformer: Option<MarkdownTransformer>,
4004) -> Option<Arc<dyn rpi_tui::Component>> {
4005 let value = invoke_extension_renderer(
4006 session,
4007 rpi_extensions::RegisteredRendererKind::Message,
4008 payload,
4009 )?;
4010 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4011 let text = value.get("text").and_then(|v| v.as_str())?;
4012 let component = Arc::new(AssistantMessageComponent::new(
4013 AssistantMessageOptions::default(),
4014 ));
4015 if let Some(transformer) = transformer {
4016 component.set_markdown_transformer(Some(transformer));
4017 }
4018 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4019 return Some(component);
4020 }
4021 extension_text_component(&value)
4022 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4023}
4024
4025fn extension_usage_text(
4028 session: Option<&crate::session::ExtensionSessionCell>,
4029 usage: &rpi_ai::types::Usage,
4030) -> Option<String> {
4031 let session = session?;
4032 let payload = serde_json::json!({
4033 "customType": "token-usage",
4034 "usage": usage,
4035 });
4036 let value = invoke_extension_renderer(
4037 session,
4038 rpi_extensions::RegisteredRendererKind::Message,
4039 &payload,
4040 )?;
4041 value
4042 .get("text")
4043 .and_then(|value| value.as_str())
4044 .filter(|text| !text.trim().is_empty())
4045 .map(ToOwned::to_owned)
4046}
4047
4048fn extension_entry_component(
4049 session: &crate::session::ExtensionSessionCell,
4050 custom_type: &str,
4051 data: Option<serde_json::Value>,
4052) -> Option<Arc<dyn rpi_tui::Component>> {
4053 let payload = serde_json::json!({
4054 "customType": custom_type,
4055 "data": data,
4056 });
4057 let value = invoke_extension_renderer(
4058 session,
4059 rpi_extensions::RegisteredRendererKind::Entry,
4060 &payload,
4061 )?;
4062 extension_text_component(&value)
4063}
4064
4065fn launch_restores_history(args: &Args) -> bool {
4071 args.continue_session
4072 || args.resume
4073 || args.session.is_some()
4074 || args.session_id.is_some()
4075 || args.fork.is_some()
4076}
4077
4078fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4079 msg.content
4080 .iter()
4081 .filter_map(|c| match c {
4082 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4083 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4084 Content::Image(image) => base64::engine::general_purpose::STANDARD
4085 .decode(&image.data)
4086 .ok()
4087 .filter(|data| !data.is_empty())
4088 .map(AssistantBlock::Image),
4089 _ => None,
4090 })
4091 .collect()
4092}
4093
4094fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4095 let content = custom
4096 .content
4097 .iter()
4098 .filter_map(|item| match item {
4099 Content::Text(text) => Some(text.text.as_str()),
4100 _ => None,
4101 })
4102 .collect::<Vec<_>>()
4103 .join("\n");
4104 if content.is_empty() {
4105 format!("{}: {}", custom.role, custom.data)
4106 } else {
4107 format!("{}: {}", custom.role, content)
4108 }
4109}
4110
4111fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4113 let mut dir = Some(cwd);
4114 while let Some(current) = dir {
4115 let head = current.join(".git").join("HEAD");
4116 if head.exists() {
4117 return Some(head);
4118 }
4119 dir = current.parent();
4120 }
4121 None
4122}
4123
4124fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4128 let mut dir = Some(cwd);
4129 while let Some(current) = dir {
4130 let head = current.join(".git").join("HEAD");
4131 if let Ok(contents) = std::fs::read_to_string(&head) {
4132 let trimmed = contents.trim();
4133 if let Some(reference) = trimmed.strip_prefix("ref: ") {
4134 let branch = reference
4135 .rsplit('/')
4136 .next()
4137 .unwrap_or(reference)
4138 .trim()
4139 .to_string();
4140 if !branch.is_empty() {
4141 return Some(branch);
4142 }
4143 }
4144 if !trimmed.is_empty() {
4145 return Some(trimmed.chars().take(7).collect());
4146 }
4147 }
4148 dir = current.parent();
4149 }
4150 None
4151}
4152
4153fn short_model_name(id: &str) -> String {
4156 id.rsplit([':', '/'])
4157 .next()
4158 .filter(|s| !s.is_empty())
4159 .unwrap_or(id)
4160 .to_string()
4161}
4162
4163#[derive(Clone, Copy, PartialEq, Eq)]
4169enum RunStatus {
4170 Idle,
4171 Working,
4172 Aborting,
4173}
4174
4175#[derive(Clone, Copy, PartialEq, Eq)]
4177enum SelectorKind {
4178 Model,
4180 Thinking,
4182 Tools,
4184 Images,
4186 Session,
4188 Theme,
4190 ScopedModels,
4192 Settings,
4194 Tree,
4196 Extension,
4198}
4199
4200struct TuiState {
4203 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4205 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4207 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4211 themes_enabled: bool,
4213 hide_thinking: std::sync::Mutex<bool>,
4215 tool_outputs_expanded: std::sync::Mutex<bool>,
4217 show_terminal_progress: bool,
4219 status: std::sync::Mutex<RunStatus>,
4221 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4225 footer: Arc<FooterComponent>,
4227 status_container: Arc<Container>,
4230 chat_container: Arc<Container>,
4232 loader: Arc<Loader>,
4234 last_assistant_text: std::sync::Mutex<String>,
4237 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
4241 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4243 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4245 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4247 autocomplete: AutocompleteManager,
4250 autocomplete_container: Arc<Container>,
4253 autocomplete_max_visible: usize,
4255 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4257 theme_manager: Arc<ThemeManager>,
4261 tui: Option<Arc<TuiAltScreen>>,
4265 current_model_id: std::sync::Mutex<String>,
4269 show_images: std::sync::Mutex<bool>,
4273 history: std::sync::Mutex<Vec<String>>,
4276 history_index: std::sync::Mutex<isize>,
4279 history_draft: std::sync::Mutex<Option<String>>,
4282 cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4286 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4289 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4299 extension_session: crate::session::ExtensionSessionCell,
4301 search: Arc<AltScreenSearch>,
4303 search_bar: Arc<SearchBar>,
4305 selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4307 selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4308}
4309
4310const HISTORY_LIMIT: usize = 100;
4313
4314const PAGE_SCROLL_OVERLAP: usize = 4;
4317
4318const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4322
4323fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4326 let mut modifiers = KeyModifiers::NONE;
4327 let mut key = None;
4328 for part in value.trim().to_ascii_lowercase().split('+') {
4329 match part {
4330 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4331 "shift" => modifiers |= KeyModifiers::SHIFT,
4332 "alt" | "option" => modifiers |= KeyModifiers::ALT,
4333 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4334 part if !part.is_empty() => key = Some(part.to_string()),
4335 _ => {}
4336 }
4337 }
4338 let key = key?;
4339 let code = match key.as_str() {
4340 "esc" | "escape" => KeyCode::Esc,
4341 "enter" | "return" => KeyCode::Enter,
4342 "tab" => {
4343 if modifiers.contains(KeyModifiers::SHIFT) {
4344 return Some(rpi_tui::KeyCombo::new(
4345 KeyCode::BackTab,
4346 modifiers & !KeyModifiers::SHIFT,
4347 ));
4348 }
4349 KeyCode::Tab
4350 }
4351 "backspace" | "back" => KeyCode::Backspace,
4352 "delete" | "del" => KeyCode::Delete,
4353 "up" | "arrowup" => KeyCode::Up,
4354 "down" | "arrowdown" => KeyCode::Down,
4355 "left" | "arrowleft" => KeyCode::Left,
4356 "right" | "arrowright" => KeyCode::Right,
4357 "home" => KeyCode::Home,
4358 "end" => KeyCode::End,
4359 "pageup" | "page-up" => KeyCode::PageUp,
4360 "pagedown" | "page-down" => KeyCode::PageDown,
4361 "space" => KeyCode::Char(' '),
4362 "f1" => KeyCode::F(1),
4363 "f2" => KeyCode::F(2),
4364 "f3" => KeyCode::F(3),
4365 "f4" => KeyCode::F(4),
4366 "f5" => KeyCode::F(5),
4367 "f6" => KeyCode::F(6),
4368 "f7" => KeyCode::F(7),
4369 "f8" => KeyCode::F(8),
4370 "f9" => KeyCode::F(9),
4371 "f10" => KeyCode::F(10),
4372 "f11" => KeyCode::F(11),
4373 "f12" => KeyCode::F(12),
4374 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4375 _ => return None,
4376 };
4377 Some(rpi_tui::KeyCombo::new(code, modifiers))
4378}
4379
4380fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4381 let mut bindings = rpi_tui::Keybindings::new();
4382 let settings = crate::settings::load_settings().unwrap_or_default();
4383 let Some(overrides) = settings.keybindings else {
4384 rpi_tui::set_keybindings(bindings.clone());
4385 return Arc::new(bindings);
4386 };
4387 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4388 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4389 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4390 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4391 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4392 (
4393 "app.model.cycleForward",
4394 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4395 ),
4396 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4397 (
4398 "app.thinking.toggle",
4399 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4400 ),
4401 (
4402 "app.editor.external",
4403 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4404 ),
4405 (
4406 "app.thinking.cycle",
4407 rpi_tui::keybindings::keys::THINKING_CYCLE,
4408 ),
4409 (
4410 "app.clipboard.pasteImage",
4411 rpi_tui::keybindings::keys::PASTE_IMAGE,
4412 ),
4413 ];
4414 for (name, id) in known {
4415 let Some(value) = overrides.get(*name) else {
4416 continue;
4417 };
4418 let values: Vec<String> = match value {
4419 serde_json::Value::String(value) => vec![value.clone()],
4420 serde_json::Value::Array(values) => values
4421 .iter()
4422 .filter_map(|v| v.as_str().map(str::to_string))
4423 .collect(),
4424 serde_json::Value::Null => Vec::new(),
4425 _ => continue,
4426 };
4427 let combos: Vec<_> = values
4428 .iter()
4429 .filter_map(|value| parse_configured_key(value))
4430 .collect();
4431 if values.is_empty() || !combos.is_empty() {
4432 bindings.set(id, combos);
4433 }
4434 }
4435 rpi_tui::set_keybindings(bindings.clone());
4436 Arc::new(bindings)
4437}
4438
4439fn keybinding_matches(
4440 bindings: &rpi_tui::Keybindings,
4441 event: &crossterm::event::KeyEvent,
4442 id: rpi_tui::KeybindingId,
4443) -> bool {
4444 if bindings.matches(event, id) {
4445 return true;
4446 }
4447 if event.code == KeyCode::BackTab {
4450 let normalized =
4451 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4452 bindings.matches(&normalized, id)
4453 } else {
4454 false
4455 }
4456}
4457
4458fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4459 last.is_some_and(|previous| {
4460 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4461 })
4462}
4463
4464const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4468
4469const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4472
4473fn enter_is_paste_burst(
4487 last_text_key_at: Option<std::time::Instant>,
4488 now: std::time::Instant,
4489 more_queued: bool,
4490) -> bool {
4491 if more_queued {
4492 return true;
4493 }
4494 last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4495}
4496
4497fn transcript_page_size(viewport_height: usize) -> i32 {
4498 viewport_height
4499 .saturating_sub(PAGE_SCROLL_OVERLAP)
4500 .max(1)
4501 .min(i32::MAX as usize) as i32
4502}
4503
4504fn should_dispatch_key(kind: KeyEventKind) -> bool {
4505 kind != KeyEventKind::Release
4506}
4507
4508fn format_tokens(n: i64) -> String {
4510 if n >= 1_000_000 {
4511 format!("{:.1}M", n as f64 / 1_000_000.0)
4512 } else if n >= 1_000 {
4513 format!("{:.1}K", n as f64 / 1_000.0)
4514 } else {
4515 n.to_string()
4516 }
4517}
4518
4519fn push_history(state: &Arc<TuiState>, text: &str) {
4523 let trimmed = text.trim().to_string();
4524 if trimmed.is_empty() {
4525 return;
4526 }
4527 let mut history = state.history.lock().unwrap();
4528 if history.first() == Some(&trimmed) {
4529 return;
4530 }
4531 history.insert(0, trimmed);
4532 history.truncate(HISTORY_LIMIT);
4533 *state.history_index.lock().unwrap() = -1;
4534 *state.history_draft.lock().unwrap() = None;
4535}
4536
4537fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4542 let history = state.history.lock().unwrap();
4543 if history.is_empty() {
4544 return;
4545 }
4546 let mut index = state.history_index.lock().unwrap();
4547 let new_index = *index - direction as isize;
4548 if new_index < -1 || new_index >= history.len() as isize {
4549 return;
4550 }
4551 if *index == -1 && new_index >= 0 {
4552 *state.history_draft.lock().unwrap() = Some(editor.get_text());
4554 }
4555 *index = new_index;
4556 if new_index == -1 {
4557 let draft = state.history_draft.lock().unwrap().take();
4559 match draft {
4560 Some(d) => {
4561 let len = d.len();
4562 editor.set_text(&d);
4563 editor.set_cursor(0, len);
4564 }
4565 None => editor.set_text(""),
4566 }
4567 } else {
4568 let text = history[new_index as usize].clone();
4569 let len = text.len();
4570 editor.set_text(&text);
4571 editor.set_cursor(0, len);
4572 }
4573}
4574
4575impl TuiState {
4576 fn begin_js_preparation(&self) -> CancellationToken {
4577 let cancellation = CancellationToken::new();
4578 if let Some(previous) = self
4579 .js_preparation_cancel
4580 .lock()
4581 .unwrap()
4582 .replace(cancellation.clone())
4583 {
4584 previous.cancel();
4585 }
4586 cancellation
4587 }
4588
4589 fn finish_js_preparation(&self) {
4590 self.js_preparation_cancel.lock().unwrap().take();
4591 }
4592
4593 fn cancel_js_preparation(&self) -> bool {
4594 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4595 if let Some(cancellation) = cancellation {
4596 cancellation.cancel();
4597 true
4598 } else {
4599 false
4600 }
4601 }
4602
4603 fn set_status(&self, status: RunStatus) {
4604 *self.status.lock().unwrap() = status;
4605 self.apply_status(status);
4606 }
4607
4608 fn try_start_working(&self) -> bool {
4613 let mut status = self.status.lock().unwrap();
4614 if *status != RunStatus::Idle {
4615 return false;
4616 }
4617 *status = RunStatus::Working;
4618 drop(status);
4619 self.apply_status(RunStatus::Working);
4620 true
4621 }
4622
4623 fn apply_status(&self, status: RunStatus) {
4624 match status {
4625 RunStatus::Working => {
4626 self.footer.set_status("");
4630 if let Some(tui) = &self.tui {
4633 tui.set_title("rpi — working");
4634 }
4635 self.status_container.clear();
4636 if self.show_terminal_progress {
4637 self.loader.start();
4638 self.status_container.add_child(self.loader.clone());
4639 }
4640 }
4641 RunStatus::Aborting => {
4642 self.footer.set_status("Aborting…");
4643 self.loader.stop();
4646 self.status_container.clear();
4647 }
4648 RunStatus::Idle => {
4649 self.footer.set_status("");
4650 if let Some(tui) = &self.tui {
4651 tui.set_title("rpi");
4652 }
4653 self.loader.stop();
4654 self.status_container.clear();
4655 }
4656 }
4657 }
4658
4659 fn sync_working_loader_with_bash(&self) {
4663 if *self.status.lock().unwrap() != RunStatus::Working {
4664 return;
4665 }
4666
4667 self.status_container.clear();
4668 if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4669 self.status_container.add_child(self.loader.clone());
4670 }
4671 }
4672
4673 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4674 *self.status.lock().unwrap() = RunStatus::Working;
4675 self.footer.set_status("");
4676 if let Some(tui) = &self.tui {
4677 tui.set_title("rpi — retrying");
4678 }
4679 self.loader.stop();
4680 self.status_container.clear();
4681 self.status_container
4682 .add_child(Arc::new(StatusIndicator::retry(
4683 attempt,
4684 max_retries,
4685 std::time::Duration::from_millis(delay_ms),
4686 )));
4687 }
4688
4689 fn selector_open(&self) -> bool {
4691 self.active_selector.lock().unwrap().is_some()
4692 }
4693
4694 fn extension_editor_open(&self) -> bool {
4695 self.active_extension_editor.lock().unwrap().is_some()
4696 }
4697
4698 fn extension_input_open(&self) -> bool {
4699 self.active_extension_input.lock().unwrap().is_some()
4700 }
4701
4702 fn extension_dialog_open(&self) -> bool {
4703 self.extension_editor_open() || self.extension_input_open()
4704 }
4705
4706 fn set_hide_thinking(&self, hide: bool) {
4707 *self.hide_thinking.lock().unwrap() = hide;
4708 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4709 comp.set_hide_thinking(hide);
4710 }
4711 }
4712
4713 fn hide_thinking(&self) -> bool {
4714 *self.hide_thinking.lock().unwrap()
4715 }
4716
4717 fn toggle_thinking(&self) -> bool {
4718 let next = !self.hide_thinking();
4719 self.set_hide_thinking(next);
4720 next
4721 }
4722
4723 fn toggle_tool_outputs(&self) -> bool {
4724 let next = !*self.tool_outputs_expanded.lock().unwrap();
4725 *self.tool_outputs_expanded.lock().unwrap() = next;
4726 for comp in self.tool_components.lock().unwrap().values() {
4727 comp.set_expanded(next);
4728 }
4729 for comp in self.bash_components.lock().unwrap().values() {
4730 comp.set_expanded(next);
4731 }
4732 next
4733 }
4734
4735 fn current_model_id(&self) -> String {
4737 self.current_model_id.lock().unwrap().clone()
4738 }
4739
4740 fn set_current_model(&self, model: &rpi_ai::Model) {
4743 *self.current_model_id.lock().unwrap() = model.id.clone();
4744 self.footer.set_model(&short_model_name(&model.id));
4745 }
4746
4747 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4751 self.markdown_transformer.lock().unwrap().clone()
4752 }
4753
4754 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4763 *self.markdown_transformer.lock().unwrap() = transformer.clone();
4764 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4765 comp.set_markdown_transformer(transformer);
4766 }
4767 }
4768
4769 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4770 self.pending_images.lock().unwrap().push(image);
4771 }
4772
4773 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4774 std::mem::take(&mut *self.pending_images.lock().unwrap())
4775 }
4776}
4777
4778#[derive(Debug, PartialEq)]
4783struct TuiStartupSettings {
4784 editor_padding_x: usize,
4785 autocomplete_max_visible: usize,
4786 hide_thinking: bool,
4787 quiet_startup: bool,
4788 show_terminal_progress: bool,
4789}
4790
4791fn preferred_project_setting<T>(
4792 project_settings: &[crate::settings::Settings],
4793 field: impl Fn(&crate::settings::Settings) -> Option<T>,
4794) -> Option<T> {
4795 project_settings.iter().find_map(field)
4796}
4797
4798fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4799 verbose || !quiet_startup
4800}
4801
4802fn git_only_update_resources(
4803 mut resources: crate::packages::PackageResources,
4804) -> crate::packages::PackageResources {
4805 resources
4806 .packages
4807 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
4808 resources
4809}
4810
4811fn resolve_tui_startup_settings(
4815 global: &crate::settings::Settings,
4816 project_settings: &[crate::settings::Settings],
4817 project_trusted: bool,
4818) -> TuiStartupSettings {
4819 let project_settings = if project_trusted {
4820 project_settings
4821 } else {
4822 &[]
4823 };
4824 let editor_padding_x =
4825 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4826 .or(global.editor_padding_x)
4827 .unwrap_or(1)
4828 .min(16);
4829 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4830 settings.autocomplete_max_visible
4831 })
4832 .or(global.autocomplete_max_visible)
4833 .unwrap_or(5)
4834 .clamp(1, 20);
4835 let hide_thinking =
4836 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4837 .or(global.hide_thinking_block)
4838 .unwrap_or(false);
4839 let quiet_startup =
4840 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4841 .or(global.quiet_startup)
4842 .unwrap_or(false);
4843 let show_terminal_progress =
4844 preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
4845 .or(global.show_terminal_progress)
4846 .unwrap_or(true);
4847
4848 TuiStartupSettings {
4849 editor_padding_x,
4850 autocomplete_max_visible,
4851 hide_thinking,
4852 quiet_startup,
4853 show_terminal_progress,
4854 }
4855}
4856
4857pub async fn interactive_tui(
4871 harness: &AgentHarness,
4872 event_rx: Option<broadcast::Receiver<AgentEvent>>,
4873 args: &Args,
4874 model_catalog: Vec<rpi_ai::Model>,
4875 initial: Option<String>,
4876 extra_messages: &[String],
4877 initial_images: Vec<rpi_ai::types::ImageContent>,
4878 theme: Option<&str>,
4879 no_themes: bool,
4880 reload_context: &crate::session::ReloadContext,
4881) -> i32 {
4882 let lane: Arc<dyn AgentLane> = harness.lane("main");
4883 let cwd = reload_context.cwd.clone();
4887 let saved_settings = crate::settings::load_settings().unwrap_or_default();
4888 let project_trusted = reload_context.project_trusted;
4889 let project_settings = if project_trusted {
4890 crate::settings::load_project_settings(&cwd)
4891 } else {
4892 Vec::new()
4893 };
4894 let tui_settings =
4895 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
4896 let editor_padding_x = tui_settings.editor_padding_x;
4897 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
4898 let mut hide_thinking = tui_settings.hide_thinking;
4899 let show_terminal_progress = tui_settings.show_terminal_progress;
4900 let quiet_startup = tui_settings.quiet_startup;
4901 let update_checks_enabled =
4902 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
4903
4904 let session_values = rpi_harness::session::SessionValues::load(harness.session())
4911 .await
4912 .unwrap_or_default();
4913 if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
4914 hide_thinking = persisted;
4915 }
4916
4917 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4921 let model_name = short_model_name(&lane_model_id);
4922
4923 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4927 if active_tool_names.is_empty() {
4931 active_tool_names = harness
4932 .get_tools()
4933 .await
4934 .unwrap_or_default()
4935 .into_iter()
4936 .map(|tool| tool.tool.schema().name.clone())
4937 .collect();
4938 }
4939 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4940 let skill_names: Vec<String> = resources_snapshot
4941 .skills
4942 .as_deref()
4943 .unwrap_or(&[])
4944 .iter()
4945 .map(|skill| skill.name.clone())
4946 .collect();
4947
4948 let package_resources = reload_context.package_resources.clone();
4950
4951 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4953
4954 let theme_manager = Arc::new(ThemeManager::new());
4958 if let Some(preset) = match theme {
4959 Some("light") => Some(ThemePreset::Light),
4960 Some("monochrome") => Some(ThemePreset::Monochrome),
4961 Some("dark") => Some(ThemePreset::Dark),
4962 _ => None,
4963 } {
4964 apply_theme_preset(preset);
4965 theme_manager.apply_preset(preset);
4966 } else if !no_themes {
4967 if let Some(name) = theme {
4968 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4969 Ok(custom) => {
4970 rpi_tui::global_theme_manager().set(custom.clone());
4971 theme_manager.set(custom);
4972 }
4973 Err(error) => {
4974 eprintln!("warning: could not load package theme `{name}`: {error}");
4975 }
4976 }
4977 }
4978 }
4979
4980 let terminal = Box::new(ProcessTerminal::new());
4982 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4983 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4984 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
4989 ask_user_bridge.attach();
4990 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4991
4992 if let Some(js) = &reload_context.js_extension_session {
4993 if let Err(error) = js.install_ui_runtime(tui.clone()) {
4994 eprintln!("warning: could not enable JS custom UI bridge: {error}");
4995 } else if let Some(js_active) = js.active_tools() {
4996 let js_names = js.tool_names();
5000 let mut active = lane.get_active_tools().await.unwrap_or_default();
5001 active.retain(|name| {
5002 crate::session::tool_name_allowed(name, args)
5003 && !js_names.iter().any(|js_name| js_name == name)
5004 });
5005 active.extend(js_active.into_iter().filter(|name| {
5006 js_names.iter().any(|js_name| js_name == name)
5007 && crate::session::tool_name_allowed(name, args)
5008 }));
5009 active = crate::session::filter_active_tool_names(active, args);
5010 let _ = lane.set_active_tools(active).await;
5011 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5012 }
5013 }
5014
5015 let chat_container = Arc::new(Container::new());
5016 if let Some(js) = &reload_context.js_extension_session {
5017 let chat_notify = chat_container.clone();
5021 let tui_notify = tui.clone();
5022 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
5023 if action != "ui.notify" {
5024 return Err(format!("unsupported capability: {action}"));
5025 }
5026 let message = args
5027 .get("message")
5028 .and_then(serde_json::Value::as_str)
5029 .unwrap_or_default();
5030 if !message.is_empty() {
5031 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
5032 add_error_message(&chat_notify, message);
5033 } else {
5034 add_note_message(&chat_notify, message);
5035 }
5036 tui_notify.request_render(false);
5037 }
5038 Ok(serde_json::json!(true))
5039 })) {
5040 if args.verbose {
5041 eprintln!("warning: could not enable JS notification bridge: {error}");
5042 }
5043 }
5044 }
5045 if should_show_startup_listing(args.verbose, quiet_startup) {
5046 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5047 }
5048
5049 crate::extras::maybe_first_time_setup(&chat_container);
5055
5056 let initial_transformer = build_markdown_transformer(
5061 reload_context
5062 .extension_session
5063 .lock()
5064 .unwrap()
5065 .snapshot_arc(),
5066 );
5067 if launch_restores_history(args) {
5071 render_session_history(
5072 &harness,
5073 &chat_container,
5074 initial_transformer.clone(),
5075 Some(reload_context.extension_session.clone()),
5076 )
5077 .await;
5078 }
5079
5080 let document_container = Arc::new(Container::new());
5083 document_container.add_child(chat_container.clone());
5084
5085 let scroll_view = Arc::new(ScrollView::new(
5086 document_container.clone(),
5087 ScrollViewOptions {
5088 follow: FollowMode::End,
5089 primary: true,
5090 overscroll: OverscrollMode::Chain,
5091 scrollbar: ScrollbarMode::Hidden,
5095 ..Default::default()
5096 },
5097 ));
5098
5099 let keybindings = configured_keybindings();
5104 let editor = Arc::new(Editor::new(
5105 EditorOptions {
5106 padding_x: editor_padding_x,
5107 autocomplete_max_visible,
5108 ..Default::default()
5109 },
5110 EditorStyle::default(),
5111 keybindings.clone(),
5112 ));
5113
5114 let footer = Arc::new(FooterComponent::new());
5116 footer.set_model(&model_name);
5117 footer.set_cwd(&cwd.to_string_lossy());
5118 footer.set_git_branch(git_branch_for(&cwd).as_deref());
5119 if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5120 footer.set_context_usage(None, m.context_window as i64);
5121 }
5122 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");
5123
5124 let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5127 let footer_ref = footer.clone();
5128 let cwd_ref = cwd.clone();
5129 crate::fs_watch::watch_path(head, move || {
5130 footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5131 })
5132 });
5133
5134 let status_container = Arc::new(Container::new());
5135 let loader = Arc::new(Loader::with_text("Working…"));
5136
5137 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5151 .prompt_templates
5152 .clone()
5153 .unwrap_or_default()
5154 .iter()
5155 .map(|t| SlashCommandEntry {
5156 name: format!("/{}", t.name),
5157 description: t
5158 .description
5159 .clone()
5160 .unwrap_or_else(|| "Expand prompt template".to_string()),
5161 })
5162 .collect();
5163 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5164 Arc::new(resources_snapshot);
5165 let mut command_registry = build_builtin_registry();
5171 register_extension_commands(
5172 &mut command_registry,
5173 reload_context.extension_session.clone(),
5174 );
5175 register_js_extension_commands(
5176 &mut command_registry,
5177 reload_context.js_extension_session.clone(),
5178 );
5179 let registry = Arc::new(command_registry);
5180 let mut all_slash_commands = registry.visible_entries();
5181 all_slash_commands.extend(template_slash_commands);
5182 let autocomplete = AutocompleteManager::new();
5183 {
5184 let mut combined = CombinedAutocompleteProvider::new();
5185 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5186 all_slash_commands,
5187 )));
5188 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5189 cwd.clone(),
5190 )));
5191 autocomplete.set_provider(Arc::new(combined));
5192 }
5193 let autocomplete_container = Arc::new(Container::new());
5194
5195 let tool_outputs_expanded = session_values
5196 .get_as::<bool>("display.tool_outputs_expanded")
5197 .unwrap_or(false);
5198 let state = Arc::new(TuiState {
5199 current_assistant: std::sync::Mutex::new(None),
5200 tool_components: std::sync::Mutex::new(HashMap::new()),
5201 bash_components: std::sync::Mutex::new(HashMap::new()),
5202 themes_enabled: !no_themes,
5203 hide_thinking: std::sync::Mutex::new(hide_thinking),
5204 tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5205 show_terminal_progress,
5206 status: std::sync::Mutex::new(RunStatus::Idle),
5207 js_preparation_cancel: std::sync::Mutex::new(None),
5208 footer: footer.clone(),
5209 status_container: status_container.clone(),
5210 chat_container: chat_container.clone(),
5211 loader: loader.clone(),
5212 last_assistant_text: std::sync::Mutex::new(String::new()),
5213 active_selector: std::sync::Mutex::new(None),
5214 active_extension_editor: std::sync::Mutex::new(None),
5215 active_extension_input: std::sync::Mutex::new(None),
5216 active_extension_cancel: std::sync::Mutex::new(None),
5217 autocomplete,
5218 autocomplete_container: autocomplete_container.clone(),
5219 autocomplete_max_visible,
5220 pending_images: std::sync::Mutex::new(Vec::new()),
5221 theme_manager,
5222 tui: Some(tui.clone()),
5223 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5224 show_images: std::sync::Mutex::new(true),
5225 history: std::sync::Mutex::new(Vec::new()),
5226 history_index: std::sync::Mutex::new(-1),
5227 history_draft: std::sync::Mutex::new(None),
5228 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5229 scoped_edit: std::sync::Mutex::new(None),
5230 markdown_transformer: std::sync::Mutex::new(initial_transformer),
5231 extension_session: reload_context.extension_session.clone(),
5232 search: Arc::new(AltScreenSearch::new()),
5233 search_bar: Arc::new(SearchBar::new()),
5234 selection_start: std::sync::Mutex::new(None),
5235 selection_end: std::sync::Mutex::new(None),
5236 });
5237
5238 let model_catalog_arc = Arc::new(model_catalog.clone());
5241 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5242
5243 let editor_container = Arc::new(Container::new());
5254 editor_container.add_child(editor.clone());
5255
5256 let dock = Arc::new(VStack::from_children(vec![
5257 StackChild::Entry(StackEntry::new(status_container.clone())),
5258 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5259 StackChild::Entry(
5260 StackEntry::new(editor_container.clone())
5261 .shrink(0)
5262 .min_size(3),
5263 ),
5264 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5265 StackChild::Entry(StackEntry::new(footer.clone())),
5266 ]));
5267
5268 let root = VStack::from_children(vec![
5269 StackChild::Entry(
5270 StackEntry::new(scroll_view.clone())
5271 .basis(0)
5272 .grow(1)
5273 .shrink(1)
5274 .min_size(1),
5275 ),
5276 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5277 ]);
5278
5279 tui.set_layout_root(Some(Arc::new(root)));
5280 tui.set_focus(Some(editor.clone()));
5281 editor.set_focused(true);
5282
5283 let scoped_models = args
5299 .model
5300 .as_deref()
5301 .map(crate::agent_session::ScopedModel::parse)
5302 .into_iter()
5303 .collect();
5304 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5305 harness.clone(),
5306 model_catalog.clone(),
5307 scoped_models,
5308 cwd.clone(),
5309 ));
5310 let ctx = CommandContext {
5311 chat: chat_container.clone(),
5312 tui: tui.clone(),
5313 tx: tx.clone(),
5314 state: state.clone(),
5315 editor: editor.clone(),
5316 editor_container: editor_container.clone(),
5317 lane: lane.clone(),
5318 model_catalog: model_catalog_arc.clone(),
5319 lane_model_id: lane_model_id.clone(),
5320 cwd: cwd.clone(),
5321 package_resources: package_resources.clone(),
5322 resources: resources_arc.clone(),
5323 reload_context: Arc::new(reload_context.clone()),
5324 session: agent_session.clone(),
5325 };
5326
5327 if let Some(js) = &reload_context.js_extension_session {
5328 let bridge = js_dialog_bridge.clone();
5329 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5330 bridge.handle_runtime_request(action, args)
5331 })) {
5332 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5333 }
5334 }
5335
5336 let ctx_for_cb = ctx.clone();
5337 let registry_for_cb = registry.clone();
5338 editor.on_submit(Arc::new(move |text: &str| {
5339 let text = text.trim();
5340 if text.is_empty() {
5341 return;
5342 }
5343
5344 if text.starts_with('/') {
5345 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
5346 return;
5347 }
5348
5349 let run_status = *ctx_for_cb.state.status.lock().unwrap();
5350 if run_status != RunStatus::Idle {
5351 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5352 let lane = ctx_for_cb.lane.clone();
5353 let chat = ctx_for_cb.chat.clone();
5354 let tui = ctx_for_cb.tui.clone();
5355 tokio::spawn(async move {
5356 if let Err(error) = lane.steer(message).await {
5366 add_error_message(&chat, &format!("Could not queue message: {error}"));
5367 tui.render_now(false);
5368 }
5369 });
5370 add_user_message(&ctx_for_cb.chat, text);
5375 ctx_for_cb.tui.request_render(false);
5376 return;
5377 }
5378
5379 if !ctx_for_cb.state.try_start_working() {
5380 return;
5381 }
5382
5383 add_user_message(&ctx_for_cb.chat, text);
5384 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5387 scroll.scroll_to_end();
5388 }
5389 ctx_for_cb.tui.request_render(false);
5390 push_history(&ctx_for_cb.state, text);
5393 if ctx_for_cb
5394 .tx
5395 .send(TuiMessage::UserInput(text.to_string()))
5396 .is_err()
5397 {
5398 ctx_for_cb.state.set_status(RunStatus::Idle);
5399 }
5400 }));
5401
5402 tui.start_render_scheduler();
5410 tui.start_readerless();
5411
5412 let mut update_check_handles = Vec::new();
5416 let rpi_chat = chat_container.clone();
5417 let rpi_tui = tui.clone();
5418 update_check_handles.push(tokio::spawn(async move {
5419 let report = crate::updates::check_rpi_startup().await;
5420 if !report.is_empty() {
5421 add_update_notices(&rpi_chat, &report);
5422 }
5423 rpi_tui.request_render(false);
5424 }));
5425
5426 if update_checks_enabled {
5427 let update_args = args.clone();
5428 let update_cwd = cwd.clone();
5429 let update_project_trusted = project_trusted;
5430 let chat = chat_container.clone();
5431 let tui = tui.clone();
5432 update_check_handles.push(tokio::spawn(async move {
5433 let resources = crate::session::package_resources_for_update_check(
5434 &update_args,
5435 &update_cwd,
5436 update_project_trusted,
5437 );
5438 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
5439 {
5440 Ok(npm_command) => {
5441 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
5442 Some(&resources),
5443 &npm_command,
5444 update_project_trusted.then_some(update_cwd.as_path()),
5445 )
5446 .await
5447 }
5448 Err(error) => {
5449 add_error_message(
5450 &chat,
5451 &format!("npm package update checks disabled: {error}"),
5452 );
5453 let git_resources = git_only_update_resources(resources);
5454 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
5455 }
5456 };
5457 if !report.is_empty() {
5458 add_update_notices(&chat, &report);
5459 }
5460 tui.request_render(false);
5461 }));
5462 }
5463
5464 let drain_handle = if let Some(rx) = event_rx {
5466 let tui_drain = tui.clone();
5467 let state_drain = state.clone();
5468 let chat_drain = chat_container.clone();
5469 Some(tokio::spawn(async move {
5470 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
5471 }))
5472 } else {
5473 None
5474 };
5475
5476 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5486 reload_context.mailbox.install(reload_sig_tx);
5487 let reload_tx = tx.clone();
5488 let reload_bridge_handle = tokio::spawn(async move {
5489 while reload_sig_rx.recv().await.is_some() {
5490 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5491 break; }
5493 }
5494 });
5495
5496 let tui_tick = tui.clone();
5501 let state_tick = state.clone();
5502 let tick_handle = tokio::spawn(async move {
5503 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
5506 interval.tick().await; loop {
5508 interval.tick().await;
5509 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5510 if working {
5511 if state_tick.bash_components.lock().unwrap().is_empty() {
5512 tui_tick.request_render_reusing_scroll_content();
5516 } else {
5517 tui_tick.request_render(false);
5519 }
5520 }
5521 }
5522 });
5523
5524 let running = Arc::new(std::sync::Mutex::new(true));
5526 let running_key = running.clone();
5527 let tx_for_key = tx.clone();
5528 let tui_for_key = tui.clone();
5529 let editor_for_key = editor.clone();
5530 let scroll_for_key = scroll_view.clone();
5531 let lane_for_key = lane.clone();
5532 let state_for_key = state.clone();
5533 let model_catalog_for_key = model_catalog_arc.clone();
5534 let js_for_key = reload_context.js_extension_session.clone();
5535 let js_dialog_for_key = js_dialog_bridge.clone();
5536 let ask_user_for_key = ask_user_bridge.clone();
5537 let ctx_for_key = ctx.clone();
5541 let registry_for_key = registry.clone();
5542 let keybindings_for_key = keybindings.clone();
5543 let double_escape_action = crate::settings::load_settings()
5544 .ok()
5545 .and_then(|settings| settings.double_escape_action)
5546 .unwrap_or_else(|| "tree".to_string())
5547 .to_ascii_lowercase();
5548
5549 let key_handle = tokio::task::spawn_blocking(move || {
5550 let mut last_escape_time = None;
5551 let mut last_text_key_at: Option<std::time::Instant> = None;
5554 loop {
5555 if !*running_key.lock().unwrap() {
5556 break;
5557 }
5558 if !state_for_key.selector_open()
5559 && !state_for_key.extension_dialog_open()
5560 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
5561 {
5562 if let Some(request) = js_dialog_for_key.take_pending() {
5563 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
5564 } else if let Some(request) = ask_user_for_key.take_pending() {
5565 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5566 }
5567 }
5568 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
5569 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
5572 Ok(true) => {}
5573 Ok(false) => continue,
5574 Err(_) => {
5575 state_for_key.cancel_js_preparation();
5576 let _ = tx_for_key.send(TuiMessage::Exit);
5577 break;
5578 }
5579 }
5580 let Ok(ev) = crossterm::event::read() else {
5581 state_for_key.cancel_js_preparation();
5582 let _ = tx_for_key.send(TuiMessage::Exit);
5583 break;
5584 };
5585 if let Event::Resize(_cols, _rows) = ev {
5590 tui_for_key.refresh_size();
5591 if let Some(js) = &js_for_key {
5592 if js.custom_active() {
5593 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
5594 tui_for_key.request_render(false);
5595 }
5596 }
5597 continue;
5598 }
5599 if let Event::Mouse(m) = ev {
5603 use crossterm::event::MouseEventKind;
5604 match m.kind {
5605 MouseEventKind::ScrollUp => {
5606 let delta = -MOUSE_WHEEL_SCROLL_LINES;
5607 if scroll_for_key.scroll_by(delta) != delta {
5608 tui_for_key.request_render_reusing_scroll_content();
5609 }
5610 }
5611 MouseEventKind::ScrollDown => {
5612 let delta = MOUSE_WHEEL_SCROLL_LINES;
5613 if scroll_for_key.scroll_by(delta) != delta {
5614 tui_for_key.request_render_reusing_scroll_content();
5615 }
5616 }
5617 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
5618 *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
5620 *state_for_key.selection_end.lock().unwrap() = None;
5621 }
5622 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
5623 if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
5625 *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
5626
5627 let auto_copy = crate::settings::load_settings()
5629 .ok()
5630 .and_then(|s| s.fullscreen_copy_on_select)
5631 .unwrap_or(true);
5632
5633 if auto_copy {
5634 if let Some(selected_text) = extract_selected_text(
5636 &state_for_key.chat_container,
5637 start,
5638 (m.column, m.row),
5639 ) {
5640 if !selected_text.trim().is_empty() {
5641 let _ = copy_to_clipboard(&selected_text);
5642 }
5643 }
5644 }
5645 }
5646 *state_for_key.selection_start.lock().unwrap() = None;
5647 *state_for_key.selection_end.lock().unwrap() = None;
5648 }
5649 _ => {}
5650 }
5651 continue;
5652 }
5653 let Event::Key(key) = ev else {
5654 if let Event::Paste(text) = ev {
5655 let candidate = text.trim().trim_matches(['\"', '\'']);
5656 let path = std::path::PathBuf::from(candidate);
5657 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5658 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5659 add_image_preview(&state_for_key.chat_container, &image);
5660 state_for_key.queue_image(image);
5661 add_note_message(
5662 &state_for_key.chat_container,
5663 "Dropped image attached to the next prompt.",
5664 );
5665 tui_for_key.request_render(false);
5666 continue;
5667 }
5668 }
5669 editor_for_key.insert(&text);
5670 refresh_autocomplete(&state_for_key, &editor_for_key);
5671 tui_for_key.request_render_reusing_scroll_content();
5672 }
5673 continue;
5674 };
5675 if !should_dispatch_key(key.kind) {
5680 continue;
5681 }
5682
5683 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5689 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5690 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5691 if prompt_abort && state_for_key.cancel_js_preparation() {
5692 state_for_key.set_status(RunStatus::Aborting);
5693 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5694 close_extension_editor(
5695 &state_for_key,
5696 &ctx_for_key.editor_container,
5697 &editor_for_key,
5698 &tui_for_key,
5699 );
5700 }
5701 js_dialog_for_key.cancel_open_requests();
5702 tui_for_key.set_render_suspended(false);
5703 tui_for_key.request_render(false);
5704 continue;
5705 }
5706
5707 if let Some(js) = &js_for_key {
5708 if js.custom_active() {
5709 let visible = js.custom_accepts_input();
5710 let data = key_event_to_input(key);
5711 if !data.is_empty() {
5712 if visible {
5718 let _ = js.send_custom_input(&data);
5719 continue;
5720 }
5721 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5722 if consumed {
5726 tui_for_key.request_render_reusing_scroll_content();
5727 continue;
5728 }
5729 }
5730 }
5731 }
5732
5733 if key.modifiers == KeyModifiers::CONTROL
5736 && key.code == KeyCode::Char('c')
5737 && state_for_key.selector_open()
5738 {
5739 let selector = state_for_key
5740 .active_selector
5741 .lock()
5742 .unwrap()
5743 .clone()
5744 .expect("selector_open guaranteed Some")
5745 .0;
5746 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5747 tui_for_key.request_render_reusing_scroll_content();
5748 continue;
5749 }
5750
5751 if state_for_key.extension_dialog_open() {
5755 let cancel = key.code == KeyCode::Esc
5756 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5757 if cancel {
5758 if !run_extension_cancel(&state_for_key) {
5759 close_extension_editor(
5760 &state_for_key,
5761 &ctx_for_key.editor_container,
5762 &editor_for_key,
5763 &tui_for_key,
5764 );
5765 }
5766 } else if let Some(extension_editor) = state_for_key
5767 .active_extension_editor
5768 .lock()
5769 .unwrap()
5770 .clone()
5771 {
5772 extension_editor.handle_key(key);
5773 } else if let Some(extension_input) =
5774 state_for_key.active_extension_input.lock().unwrap().clone()
5775 {
5776 extension_input.handle_key(key);
5777 }
5778 tui_for_key.request_render_reusing_scroll_content();
5779 continue;
5780 }
5781
5782 if keybinding_matches(
5787 &keybindings_for_key,
5788 &key,
5789 rpi_tui::keybindings::keys::CLEAR,
5790 ) {
5791 if !state_for_key.selector_open() && editor_for_key.has_selection() {
5792 editor_for_key.copy_selection();
5793 continue;
5794 }
5795 let status = *state_for_key.status.lock().unwrap();
5796 match status {
5797 RunStatus::Working => {
5798 state_for_key.set_status(RunStatus::Aborting);
5799 ask_user_for_key.cancel_all();
5801 if !run_extension_cancel(&state_for_key)
5803 && state_for_key.extension_dialog_open()
5804 {
5805 close_extension_editor(
5806 &state_for_key,
5807 &ctx_for_key.editor_container,
5808 &editor_for_key,
5809 &tui_for_key,
5810 );
5811 }
5812 let lane = lane_for_key.clone();
5813 tokio::spawn(async move {
5814 let _ = lane.abort().await;
5815 });
5816 }
5817 RunStatus::Aborting => {}
5821 RunStatus::Idle => {
5822 let _ = tx_for_key.send(TuiMessage::Exit);
5823 }
5824 }
5825 continue;
5826 }
5827
5828 if state_for_key.selector_open() {
5833 if key.code == KeyCode::Esc {
5839 let (selector, _kind) = state_for_key
5840 .active_selector
5841 .lock()
5842 .unwrap()
5843 .clone()
5844 .expect("selector_open guaranteed Some");
5845 selector.handle_key(key);
5846 continue;
5847 }
5848 let (selector, _kind) = state_for_key
5849 .active_selector
5850 .lock()
5851 .unwrap()
5852 .clone()
5853 .expect("selector_open guaranteed Some");
5854 selector.handle_key(key);
5855 tui_for_key.request_render_reusing_scroll_content();
5856 continue;
5857 }
5858
5859 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5864 let status = *state_for_key.status.lock().unwrap();
5865 match status {
5866 RunStatus::Working => {
5867 state_for_key.set_status(RunStatus::Aborting);
5868 ask_user_for_key.cancel_all();
5869 if !run_extension_cancel(&state_for_key)
5870 && state_for_key.extension_dialog_open()
5871 {
5872 close_extension_editor(
5873 &state_for_key,
5874 &ctx_for_key.editor_container,
5875 &editor_for_key,
5876 &tui_for_key,
5877 );
5878 }
5879 let lane = lane_for_key.clone();
5880 tokio::spawn(async move {
5881 let _ = lane.abort().await;
5882 });
5883 continue;
5884 }
5885 RunStatus::Aborting => continue,
5886 RunStatus::Idle => {}
5887 }
5888 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5889 editor_for_key.handle_key(key);
5891 refresh_autocomplete(&state_for_key, &editor_for_key);
5892 tui_for_key.request_render_reusing_scroll_content();
5893 continue;
5894 }
5895 let _ = tx_for_key.send(TuiMessage::Exit);
5896 continue;
5897 }
5898
5899 if keybinding_matches(
5904 &keybindings_for_key,
5905 &key,
5906 rpi_tui::keybindings::keys::INTERRUPT,
5907 ) {
5908 let status = *state_for_key.status.lock().unwrap();
5909 if status == RunStatus::Working {
5910 state_for_key.set_status(RunStatus::Aborting);
5911 ask_user_for_key.cancel_all();
5912 if !run_extension_cancel(&state_for_key)
5913 && state_for_key.extension_dialog_open()
5914 {
5915 close_extension_editor(
5916 &state_for_key,
5917 &ctx_for_key.editor_container,
5918 &editor_for_key,
5919 &tui_for_key,
5920 );
5921 }
5922 let lane = lane_for_key.clone();
5923 tokio::spawn(async move {
5924 let _ = lane.abort().await;
5925 });
5926 continue;
5927 }
5928 if status == RunStatus::Idle
5929 && editor_for_key.get_text().trim().is_empty()
5930 && double_escape_action != "none"
5931 {
5932 let now = std::time::Instant::now();
5933 if double_escape_trigger(last_escape_time, now) {
5934 last_escape_time = None;
5935 match double_escape_action.as_str() {
5936 "tree" => {
5937 let _ = tx_for_key.send(TuiMessage::OpenTree);
5938 }
5939 "fork" => {
5940 let _ = tx_for_key.send(TuiMessage::ForkSession);
5941 }
5942 _ => {}
5943 }
5944 } else {
5945 last_escape_time = Some(now);
5946 }
5947 }
5948 continue;
5949 }
5950
5951 if keybinding_matches(
5954 &keybindings_for_key,
5955 &key,
5956 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5957 ) {
5958 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5959 continue;
5960 }
5961
5962 if keybinding_matches(
5965 &keybindings_for_key,
5966 &key,
5967 rpi_tui::keybindings::keys::TOOLS_EXPAND,
5968 ) {
5969 let expanded = state_for_key.toggle_tool_outputs();
5970 let session = ctx_for_key.session.clone();
5972 tokio::spawn(async move {
5973 let _ = session
5974 .set_value(
5975 "display.tool_outputs_expanded",
5976 serde_json::json!(expanded),
5977 )
5978 .await;
5979 });
5980 tui_for_key.request_render(false);
5981 continue;
5982 }
5983
5984 if keybinding_matches(
5986 &keybindings_for_key,
5987 &key,
5988 rpi_tui::keybindings::keys::THINKING_TOGGLE,
5989 ) {
5990 let hide = state_for_key.toggle_thinking();
5991 let session = ctx_for_key.session.clone();
5993 tokio::spawn(async move {
5994 let _ = session
5995 .set_value("display.hide_thinking", serde_json::json!(hide))
5996 .await;
5997 });
5998 tui_for_key.request_render(false);
5999 continue;
6000 }
6001
6002 if keybinding_matches(
6008 &keybindings_for_key,
6009 &key,
6010 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6011 ) {
6012 let current = state_for_key.current_model_id();
6013 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6016 if let Some(next) = cycle_next_model(&scope, ¤t) {
6017 state_for_key.set_current_model(&next);
6018 let lane = lane_for_key.clone();
6019 tokio::spawn(async move {
6020 let _ = lane.set_model(next).await;
6021 });
6022 tui_for_key.request_render_reusing_scroll_content();
6023 }
6024 continue;
6025 }
6026
6027 if keybinding_matches(
6030 &keybindings_for_key,
6031 &key,
6032 rpi_tui::keybindings::keys::THINKING_CYCLE,
6033 ) {
6034 let lane = lane_for_key.clone();
6035 let catalog = model_catalog_for_key.clone();
6036 let state = state_for_key.clone();
6037 tokio::spawn(async move {
6038 let Ok(current_model) = lane.get_model().await else {
6039 return;
6040 };
6041 let levels = catalog
6042 .iter()
6043 .find(|model| {
6044 model.provider == current_model.provider && model.id == current_model.id
6045 })
6046 .map(|model| model.supported_thinking_levels())
6047 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6048 if levels.is_empty() {
6049 return;
6050 }
6051 let current = lane
6052 .get_thinking_level()
6053 .await
6054 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6055 let next = levels
6056 .iter()
6057 .position(|level| *level == current)
6058 .map(|index| levels[(index + 1) % levels.len()])
6059 .unwrap_or(levels[0]);
6060 if lane.set_thinking_level(next).await.is_ok() {
6061 state
6062 .footer
6063 .set_thinking_level(Some(thinking_level_name(next)));
6064 state.tui.as_ref().map(|tui| tui.request_render(false));
6065 }
6066 });
6067 continue;
6068 }
6069
6070 if keybinding_matches(
6074 &keybindings_for_key,
6075 &key,
6076 rpi_tui::keybindings::keys::PASTE_IMAGE,
6077 ) {
6078 match read_clipboard_image() {
6079 Ok(Some(image)) => {
6080 add_image_preview(&state_for_key.chat_container, &image);
6081 state_for_key.queue_image(image);
6082 add_note_message(
6083 &state_for_key.chat_container,
6084 "Clipboard image attached to the next prompt.",
6085 );
6086 tui_for_key.request_render(false);
6087 continue;
6088 }
6089 Ok(None) | Err(_) => {}
6090 }
6091 }
6092
6093 if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6095 && key.code == KeyCode::Char('F')
6096 {
6097 state_for_key.search.activate();
6098 state_for_key.search_bar.set_visible(true);
6099 state_for_key.search_bar.set_query("");
6100 state_for_key.search_bar.set_match_info(0, 0);
6101 tui_for_key.request_render(false);
6102 continue;
6103 }
6104
6105 if state_for_key.search.is_active() {
6107 let search_bar = state_for_key.search_bar.clone();
6108 match key.code {
6109 KeyCode::Esc => {
6110 state_for_key.search.deactivate();
6111 search_bar.set_visible(false);
6112 tui_for_key.request_render(false);
6113 continue;
6114 }
6115 KeyCode::Enter => {
6116 if key.modifiers.contains(KeyModifiers::SHIFT) {
6117 state_for_key.search.previous_match();
6118 } else {
6119 state_for_key.search.next_match();
6120 }
6121 let match_index = state_for_key.search.get_match_index();
6123 let match_count = state_for_key.search.get_match_count();
6124 search_bar.set_match_info(match_index, match_count);
6125 tui_for_key.request_render(false);
6126 continue;
6127 }
6128 KeyCode::Backspace => {
6129 state_for_key.search.backspace();
6130 let lines = collect_transcript_lines(&state_for_key.chat_container);
6132 state_for_key.search.find_matches(&lines);
6133 let query = state_for_key.search.get_query();
6135 let match_count = state_for_key.search.get_match_count();
6136 search_bar.set_query(&query);
6137 search_bar.set_match_info(0, match_count);
6138 tui_for_key.request_render(false);
6139 continue;
6140 }
6141 KeyCode::Char(c) => {
6142 state_for_key.search.append_char(c);
6143 let lines = collect_transcript_lines(&state_for_key.chat_container);
6145 state_for_key.search.find_matches(&lines);
6146 let query = state_for_key.search.get_query();
6148 let match_count = state_for_key.search.get_match_count();
6149 search_bar.set_query(&query);
6150 search_bar.set_match_info(0, match_count);
6151 tui_for_key.request_render(false);
6152 continue;
6153 }
6154 _ => continue,
6155 }
6156 }
6157
6158 if keybinding_matches(
6162 &keybindings_for_key,
6163 &key,
6164 rpi_tui::keybindings::keys::MODEL_SELECT,
6165 ) {
6166 if let Some(cmd) = registry_for_key.find("/model") {
6167 cmd.execute(&ctx_for_key, "");
6168 }
6169 continue;
6170 }
6171
6172 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6174 if accept_top_suggestion(&state_for_key, &editor_for_key) {
6175 tui_for_key.request_render_reusing_scroll_content();
6176 }
6177 continue;
6178 }
6179
6180 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6184 let delta = -transcript_page_size(scroll_for_key.viewport_height());
6185 if scroll_for_key.scroll_by(delta) != delta {
6186 tui_for_key.request_render_reusing_scroll_content();
6187 }
6188 continue;
6189 }
6190 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6191 let delta = transcript_page_size(scroll_for_key.viewport_height());
6192 if scroll_for_key.scroll_by(delta) != delta {
6193 tui_for_key.request_render_reusing_scroll_content();
6194 }
6195 continue;
6196 }
6197 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6198 scroll_for_key.scroll_to_start();
6199 tui_for_key.request_render_reusing_scroll_content();
6200 continue;
6201 }
6202 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
6203 scroll_for_key.scroll_to_end();
6204 tui_for_key.request_render_reusing_scroll_content();
6205 continue;
6206 }
6207
6208 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
6217 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6218 if editor_for_key.get_text().is_empty() || browsing {
6219 navigate_history(&state_for_key, &editor_for_key, -1);
6220 tui_for_key.request_render_reusing_scroll_content();
6221 continue;
6222 }
6223 }
6224 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
6225 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6226 if editor_for_key.get_text().is_empty() || browsing {
6227 navigate_history(&state_for_key, &editor_for_key, 1);
6228 tui_for_key.request_render_reusing_scroll_content();
6229 continue;
6230 }
6231 }
6232
6233 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
6237 let prompt = editor_for_key.get_text().trim().to_string();
6238 if prompt.is_empty() {
6239 continue;
6240 }
6241 editor_for_key.clear();
6242 let status = *state_for_key.status.lock().unwrap();
6243 if status == RunStatus::Idle {
6244 if state_for_key.try_start_working() {
6245 add_user_message(&state_for_key.chat_container, &prompt);
6246 push_history(&state_for_key, &prompt);
6247 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
6248 }
6249 } else {
6250 add_user_message(&state_for_key.chat_container, &prompt);
6253 let message = AgentMessage::User(UserMessage::new(prompt, 0));
6254 let lane = lane_for_key.clone();
6255 let chat = state_for_key.chat_container.clone();
6256 let tui = tui_for_key.clone();
6257 tokio::spawn(async move {
6258 if let Err(error) = lane.follow_up(message).await {
6259 add_error_message(&chat, &format!("Could not queue message: {error}"));
6260 tui.request_render(false);
6261 }
6262 });
6263 }
6264 tui_for_key.request_render(false);
6265 continue;
6266 }
6267
6268 if key.code == KeyCode::Enter && key.modifiers.is_empty() {
6276 let more_queued = crossterm::event::poll(PASTE_PROBE).unwrap_or(false);
6277 if enter_is_paste_burst(
6278 last_text_key_at,
6279 std::time::Instant::now(),
6280 more_queued,
6281 ) {
6282 editor_for_key.insert("\n");
6285 last_text_key_at = Some(std::time::Instant::now());
6286 refresh_autocomplete(&state_for_key, &editor_for_key);
6287 tui_for_key.request_render_reusing_scroll_content();
6288 continue;
6289 }
6290 last_text_key_at = None;
6293 } else if matches!(
6294 key.code,
6295 KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
6296 ) && !key
6297 .modifiers
6298 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
6299 {
6300 last_text_key_at = Some(std::time::Instant::now());
6303 } else {
6304 last_text_key_at = None;
6307 }
6308
6309 editor_for_key.handle_key(key);
6311 refresh_autocomplete(&state_for_key, &editor_for_key);
6312 tui_for_key.request_render_reusing_scroll_content();
6313 }
6314 });
6315
6316 if let Some(reason) = crate::session::dispatch_session_event_async(
6322 reload_context,
6323 rpi_plugin_sdk::EventTag::SessionStart,
6324 )
6325 .await
6326 {
6327 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
6328 }
6329
6330 let mut prompts: Vec<String> = Vec::new();
6332 if let Some(init) = initial {
6333 prompts.push(init);
6334 }
6335 for m in extra_messages {
6336 prompts.push(m.clone());
6337 }
6338 let mut images = initial_images;
6339 for prompt in prompts {
6340 if !*running.lock().unwrap() {
6341 break;
6342 }
6343 add_user_message(&chat_container, &prompt);
6344 tui.request_render(false);
6345 run_prompt_streaming(
6346 &lane,
6347 &prompt,
6348 &tui,
6349 &state,
6350 drain_handle.is_some(),
6351 reload_context.js_extension_session.as_ref(),
6352 &js_dialog_bridge,
6353 args,
6354 std::mem::take(&mut images),
6355 )
6356 .await;
6357 }
6358
6359 loop {
6361 if !*running.lock().unwrap() {
6362 break;
6363 }
6364 match rx.recv().await {
6365 Some(TuiMessage::UserInput(prompt)) => {
6366 editor.clear();
6371 let prompt_images = state.take_pending_images();
6372 if !prompt_images.is_empty() {
6373 add_note_message(
6374 &chat_container,
6375 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6376 );
6377 }
6378 run_prompt_streaming(
6379 &lane,
6380 &prompt,
6381 &tui,
6382 &state,
6383 drain_handle.is_some(),
6384 reload_context.js_extension_session.as_ref(),
6385 &js_dialog_bridge,
6386 args,
6387 prompt_images,
6388 )
6389 .await;
6390 }
6391 Some(TuiMessage::ExternalEditorResult(result)) => {
6392 match result {
6393 Ok(text) => {
6394 let cursor = text.chars().count();
6395 editor.set_text(&text);
6396 editor.set_cursor(0, cursor);
6397 add_note_message(&chat_container, "Draft updated from external editor.");
6398 }
6399 Err(error) => add_error_message(&chat_container, &error),
6400 }
6401 tui.request_render(false);
6402 }
6403 Some(TuiMessage::OpenTree) => {
6404 if *state.status.lock().unwrap() != RunStatus::Idle {
6405 add_note_message(
6406 &chat_container,
6407 "Wait for the current run to finish before opening the tree.",
6408 );
6409 tui.request_render(false);
6410 } else {
6411 open_tree_selector(
6412 &harness,
6413 &state,
6414 &editor_container,
6415 &editor,
6416 &tui,
6417 &chat_container,
6418 &tx,
6419 )
6420 .await;
6421 }
6422 }
6423 Some(TuiMessage::NavigateTree(entry_id)) => {
6424 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6425 Ok(result) => match result.outcome {
6426 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6427 chat_container.clear();
6428 add_welcome_message(&chat_container);
6429 render_session_history(
6430 &harness,
6431 &chat_container,
6432 state.markdown_transformer(),
6433 Some(state.extension_session.clone()),
6434 )
6435 .await;
6436 add_note_message(
6437 &chat_container,
6438 "Moved to the selected session entry.",
6439 );
6440 }
6441 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6442 add_error_message(&chat_container, &error.message);
6443 }
6444 _ => add_note_message(
6445 &chat_container,
6446 "The selected entry could not be opened.",
6447 ),
6448 },
6449 Err(error) => add_error_message(
6450 &chat_container,
6451 &format!("Could not navigate session tree: {error}"),
6452 ),
6453 }
6454 tui.request_render(false);
6455 }
6456 Some(TuiMessage::ClearChat) => {
6457 chat_container.clear();
6458 add_welcome_message(&chat_container);
6459 tui.request_render(false);
6460 }
6461 Some(TuiMessage::Compact) => {
6462 run_compact(&lane, &tui, &state).await;
6463 }
6464 Some(TuiMessage::Copy) => {
6465 copy_last_assistant(&state, &chat_container);
6466 tui.request_render(false);
6467 }
6468 Some(TuiMessage::Exit) => {
6469 *running.lock().unwrap() = false;
6470 break;
6471 }
6472 Some(TuiMessage::SwitchSession(id)) => {
6473 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6474 tui.request_render(false);
6475 }
6476 Some(TuiMessage::ImportSession(path)) => {
6477 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6478 tui.request_render(false);
6479 }
6480 Some(TuiMessage::ShareSession) => {
6481 share_session(&harness, &chat_container).await;
6482 tui.request_render(false);
6483 }
6484 Some(TuiMessage::SetSessionName(name)) => {
6485 let outcome = harness.session().set_name(Some(&name)).await;
6486 match outcome {
6487 Ok(_) => add_note_message(
6488 &chat_container,
6489 &format!("Session renamed to \"{name}\"."),
6490 ),
6491 Err(e) => add_error_message(
6492 &chat_container,
6493 &format!("Could not rename session: {e}"),
6494 ),
6495 }
6496 tui.request_render(false);
6497 }
6498 Some(TuiMessage::ExportSession) => {
6499 let file_name = agent_session
6500 .default_export_file_name(crate::export::ExportFormat::Markdown)
6501 .await;
6502 let path = cwd.join(&file_name);
6503 match agent_session.export_to_markdown(&path).await {
6504 Ok(_) => add_note_message(
6505 &chat_container,
6506 &format!("Exported session to {}", path.display()),
6507 ),
6508 Err(e) => add_error_message(
6509 &chat_container,
6510 &format!("Could not write export: {e}"),
6511 ),
6512 }
6513 tui.request_render(false);
6514 }
6515 Some(TuiMessage::ExportSessionWithFormat(format)) => {
6516 let file_name = agent_session.default_export_file_name(format.clone()).await;
6517 let path = cwd.join(&file_name);
6518 match agent_session.export(format, &path).await {
6519 Ok(_) => add_note_message(
6520 &chat_container,
6521 &format!("Exported session to {}", path.display()),
6522 ),
6523 Err(e) => add_error_message(
6524 &chat_container,
6525 &format!("Could not write export: {e}"),
6526 ),
6527 }
6528 tui.request_render(false);
6529 }
6530 Some(TuiMessage::ShowUsage) => {
6531 let stats = agent_session.usage_stats().await.ok();
6532 let total = agent_session.total_usage().await.ok();
6533 let snapshot = agent_session.snapshot().await.ok();
6534 match stats {
6535 Some(stats) => {
6536 let mut lines = vec![
6537 "## Usage".to_string(),
6538 String::new(),
6539 format!("- messages: {}", stats.message_count),
6540 format!("- total tokens: {}", stats.total_tokens),
6541 format!("- cached tokens: {}", stats.cached_tokens),
6542 format!("- uncached tokens: {}", stats.uncached_tokens),
6543 format!("- cost: ${:.4}", stats.cost_total),
6544 ];
6545 match stats.cache_hit_ratio() {
6546 Some(ratio) => lines
6547 .push(format!("- cache hit ratio: {:.1}%", ratio * 100.0)),
6548 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
6549 }
6550 if let Some(total) = total {
6551 lines.push(String::new());
6552 lines.push(format!(
6553 "- last cumulative input/output: {} / {}",
6554 total.input, total.output
6555 ));
6556 }
6557 if let Some(snapshot) = snapshot {
6558 lines.push(String::new());
6559 lines.push(format!("- lane: `{}`", snapshot.lane));
6560 lines.push(format!(
6561 "- leaf: `{}`",
6562 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
6563 ));
6564 if let Some(active) = &snapshot.active {
6565 lines.push(format!(
6566 "- active operation: {} (`{}`)",
6567 active.kind.as_str(),
6568 active.run_id
6569 ));
6570 }
6571 }
6572 add_note_message(&chat_container, &lines.join("\n"));
6573 }
6574 None => add_error_message(
6575 &chat_container,
6576 "Could not read session usage stats.",
6577 ),
6578 }
6579 tui.request_render(false);
6580 }
6581 Some(TuiMessage::ForkSession) => {
6582 fork_session(&harness, &cwd, &chat_container, &state).await;
6583 tui.request_render(false);
6584 }
6585 Some(TuiMessage::ReloadExtensions) => {
6586 let reload_ctx = ctx.reload_context.clone();
6593 add_note_message(&chat_container, "Reloading extensions + resources…");
6594 tui.request_render(false);
6595 let outcome =
6596 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6597 let fresh_transformer = build_markdown_transformer(
6606 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6607 );
6608 state.set_markdown_transformer_with_reinstall(fresh_transformer);
6609 if outcome.had_errors {
6610 add_error_message(&chat_container, &outcome.summary);
6614 } else if outcome.had_warnings {
6615 add_note_message(
6620 &chat_container,
6621 &format!(
6622 "{} (with warnings — see stderr for details).",
6623 outcome.summary
6624 ),
6625 );
6626 } else {
6627 add_note_message(&chat_container, &outcome.summary);
6628 }
6629 tui.request_render(false);
6630 }
6631 None => break,
6632 }
6633 }
6634
6635 js_dialog_bridge.cancel_all();
6639 ask_user_bridge.shutdown();
6640 *running.lock().unwrap() = false;
6641 for handle in update_check_handles {
6642 handle.abort();
6643 let _ = handle.await;
6644 }
6645 if let Some(js) = &reload_context.js_extension_session {
6649 js.shutdown();
6650 }
6651 let _ = key_handle.await;
6654 tick_handle.abort();
6655 if let Some(handle) = drain_handle {
6656 handle.abort();
6657 }
6658 reload_context.mailbox.clear();
6662 reload_bridge_handle.abort();
6663 tui.stop(Default::default());
6664 println!("\nGoodbye!");
6665 let _ = args;
6666
6667 0
6668}
6669
6670async fn ensure_js_runtime_before_prompt(
6680 js: Option<&crate::js_extensions::JsExtensionSession>,
6681 lane: &Arc<dyn AgentLane>,
6682 state: &Arc<TuiState>,
6683 dialog_bridge: &JsDialogBridge,
6684 args: &Args,
6685) -> bool {
6686 let Some(js) = js else {
6687 return true;
6688 };
6689 let cancellation = state.begin_js_preparation();
6690 let worker_cancellation = cancellation.clone();
6691 let js_for_start = js.clone();
6692 let mut worker = tokio::task::spawn_blocking(move || {
6693 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6694 });
6695 let result = tokio::select! {
6696 result = &mut worker => result,
6697 _ = cancellation.cancelled() => {
6698 dialog_bridge.cancel_open_requests();
6699 let js_for_cancel = js.clone();
6700 let _ = tokio::task::spawn_blocking(move || {
6701 js_for_cancel.cancel_prompt_preparation();
6702 }).await;
6703 worker.await
6704 }
6705 };
6706 let was_cancelled = cancellation.is_cancelled();
6707 if was_cancelled {
6708 dialog_bridge.cancel_open_requests();
6709 let js_for_cancel = js.clone();
6710 let _ = tokio::task::spawn_blocking(move || {
6711 js_for_cancel.cancel_prompt_preparation();
6712 })
6713 .await;
6714 state.finish_js_preparation();
6715 dialog_bridge.reopen();
6716 return false;
6717 }
6718 state.finish_js_preparation();
6719 match result {
6720 Ok(Ok(())) => {
6721 if let Some(js_active) = js.active_tools() {
6725 let js_names = js.tool_names();
6726 let mut active = lane.get_active_tools().await.unwrap_or_default();
6727 active.retain(|name| {
6728 crate::session::tool_name_allowed(name, args)
6729 && !js_names.iter().any(|js_name| js_name == name)
6730 });
6731 active.extend(js_active.into_iter().filter(|name| {
6732 js_names.iter().any(|js_name| js_name == name)
6733 && crate::session::tool_name_allowed(name, args)
6734 }));
6735 active = crate::session::filter_active_tool_names(active, args);
6736 let _ = lane.set_active_tools(active).await;
6737 }
6738 }
6739 Ok(Err(error)) => {
6740 if args.verbose {
6741 eprintln!("warning: could not start JS extension runtime: {error}");
6742 }
6743 }
6744 Err(error) => {
6745 if args.verbose {
6746 eprintln!("warning: JS extension runtime worker failed: {error}");
6747 }
6748 }
6749 }
6750 true
6751}
6752
6753fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6754 std::thread::spawn(move || {
6755 let file = match tempfile::Builder::new()
6756 .prefix("rpi-draft-")
6757 .suffix(".md")
6758 .tempfile()
6759 {
6760 Ok(file) => file,
6761 Err(error) => {
6762 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6763 "Could not create editor file: {error}"
6764 ))));
6765 return;
6766 }
6767 };
6768 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6769 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6770 "Could not write editor file: {error}"
6771 ))));
6772 return;
6773 }
6774 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6775 .ok()
6776 .filter(|value| !value.trim().is_empty())
6777 .or_else(|| std::env::var("VISUAL").ok())
6778 .or_else(|| std::env::var("EDITOR").ok())
6779 .unwrap_or_else(|| {
6780 if cfg!(windows) {
6781 "notepad".to_string()
6782 } else {
6783 "nano".to_string()
6784 }
6785 });
6786 let status = std::process::Command::new(&editor)
6787 .arg(file.path())
6788 .status();
6789 let result = match status {
6790 Ok(status) if status.success() => std::fs::read_to_string(file.path())
6791 .map_err(|error| format!("Could not read editor file: {error}")),
6792 Ok(status) => Err(format!("External editor exited with {status}")),
6793 Err(error) => Err(format!(
6794 "Could not launch external editor `{editor}`: {error}"
6795 )),
6796 };
6797 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6798 });
6799}
6800
6801fn reconcile_streamed_assistant_completion(
6804 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6805 last_assistant_text: &Mutex<String>,
6806 final_message: Option<&AssistantMessage>,
6807) {
6808 let final_blocks = final_message.map(assistant_blocks);
6809 let final_text = final_message.map(assistant_text);
6810 let component = current_assistant.lock().unwrap().take();
6811
6812 if let Some(component) = component {
6813 if let Some(blocks) = final_blocks.as_deref() {
6814 component.update_blocks(blocks);
6815 }
6816 component.set_streaming(false);
6817 }
6818
6819 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6820 *last_assistant_text.lock().unwrap() = text;
6821 }
6822}
6823
6824async fn run_prompt_streaming(
6829 lane: &Arc<dyn AgentLane>,
6830 prompt: &str,
6831 tui: &Arc<TuiAltScreen>,
6832 state: &Arc<TuiState>,
6833 streaming: bool,
6834 js: Option<&crate::js_extensions::JsExtensionSession>,
6835 dialog_bridge: &JsDialogBridge,
6836 args: &Args,
6837 images: Vec<rpi_ai::types::ImageContent>,
6838) {
6839 state.set_status(RunStatus::Working);
6848 tui.request_render(false);
6849 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6850 state.set_status(RunStatus::Idle);
6851 tui.request_render(false);
6852 return;
6853 }
6854
6855 let outcome = lane.prompt_text(prompt, images).await;
6856
6857 if streaming {
6858 let final_message = match &outcome {
6863 Ok(result) => match &result.outcome {
6864 HarnessRunOutcome::Completed { final_message, .. }
6865 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6866 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6867 HarnessRunOutcome::Suspended { .. } => None,
6868 },
6869 Err(_) => None,
6870 };
6871 reconcile_streamed_assistant_completion(
6872 &state.current_assistant,
6873 &state.last_assistant_text,
6874 final_message,
6875 );
6876 }
6877
6878 state.set_status(RunStatus::Idle);
6879
6880 match outcome {
6881 Ok(result) => match &result.outcome {
6882 HarnessRunOutcome::Failed {
6883 error,
6884 final_message,
6885 ..
6886 } => {
6887 let already_rendered = streaming && final_message.is_some();
6895 if !already_rendered {
6896 let msg = final_message
6897 .as_ref()
6898 .and_then(|m| m.error_message.clone())
6899 .unwrap_or_else(|| format!("{error:?}"));
6900 add_error_message(&state.chat_container, &msg);
6901 }
6902 }
6903 HarnessRunOutcome::Suspended { .. } => {
6904 add_error_message(
6905 &state.chat_container,
6906 "Run suspended (deferred) — resume is not supported in v1.",
6907 );
6908 }
6909 HarnessRunOutcome::Aborted { final_message, .. } => {
6910 if !streaming {
6913 add_error_message(&state.chat_container, "Request aborted.");
6914 let _ = final_message; }
6916 }
6917 HarnessRunOutcome::Completed { final_message, .. } => {
6918 if !streaming {
6919 let text = assistant_text(final_message);
6920 if !text.is_empty() {
6921 add_assistant_message_blocking(
6922 &state.chat_container,
6923 &text,
6924 state.markdown_transformer(),
6925 );
6926 *state.last_assistant_text.lock().unwrap() = text;
6927 }
6928 }
6929 }
6930 },
6931 Err(e) => {
6932 add_error_message(&state.chat_container, &e.to_string());
6933 }
6934 }
6935
6936 tui.request_render(false);
6937}
6938
6939async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6944 state.set_status(RunStatus::Working);
6945 tui.request_render(false);
6946 match lane.compact(None).await {
6947 Ok(_) => {
6948 add_note_message(&state.chat_container, "Conversation compacted.");
6949 }
6950 Err(e) => {
6951 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6952 }
6953 }
6954 state.set_status(RunStatus::Idle);
6955 tui.request_render(false);
6956}
6957
6958fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6962 let text = state.last_assistant_text.lock().unwrap().clone();
6963 if text.is_empty() {
6964 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6965 return;
6966 }
6967 if copy_to_clipboard(&text) {
6968 add_note_message(chat, "Copied last reply to the clipboard.");
6969 } else {
6970 let preview: String = text.chars().take(200).collect();
6973 add_note_message(
6974 chat,
6975 &format!(
6976 "Clipboard unavailable. Last reply: {preview}{}",
6977 if text.chars().count() > 200 {
6978 "…"
6979 } else {
6980 ""
6981 }
6982 ),
6983 );
6984 }
6985}
6986
6987fn extract_selected_text(
6990 chat_container: &Arc<Container>,
6991 start: (u16, u16),
6992 end: (u16, u16),
6993) -> Option<String> {
6994 use rpi_tui::Component;
6995
6996 let lines = chat_container.render(80); if lines.is_empty() {
6999 return None;
7000 }
7001
7002 let (start_row, end_row) = if start.1 <= end.1 {
7004 (start.1 as usize, end.1 as usize)
7005 } else {
7006 (end.1 as usize, start.1 as usize)
7007 };
7008
7009 let start_row = start_row.min(lines.len().saturating_sub(1));
7011 let end_row = end_row.min(lines.len().saturating_sub(1));
7012
7013 if start_row > end_row {
7014 return None;
7015 }
7016
7017 let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7019
7020 if selected_lines.is_empty() {
7021 return None;
7022 }
7023
7024 Some(selected_lines.join("\n"))
7025}
7026
7027#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7031fn copy_to_clipboard(text: &str) -> bool {
7032 match arboard::Clipboard::new() {
7033 Ok(mut cb) => cb.set_text(text).is_ok(),
7034 Err(_) => false,
7035 }
7036}
7037
7038#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7039fn copy_to_clipboard(_text: &str) -> bool {
7040 false
7041}
7042
7043#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7047fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7048 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
7049 let image = match clipboard.get_image() {
7050 Ok(image) => image,
7051 Err(_) => return Ok(None),
7052 };
7053 let width =
7054 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
7055 let height =
7056 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
7057 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
7058 return Err("clipboard image dimensions are outside the supported range".into());
7059 }
7060 let mut bytes = Vec::new();
7061 {
7062 let mut encoder = png::Encoder::new(&mut bytes, width, height);
7063 encoder.set_color(png::ColorType::Rgba);
7064 encoder.set_depth(png::BitDepth::Eight);
7065 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
7066 writer
7067 .write_image_data(&image.bytes)
7068 .map_err(|e| e.to_string())?;
7069 }
7070 Ok(Some(rpi_ai::types::ImageContent {
7071 kind: rpi_ai::types::ImageContentType,
7072 data: base64::engine::general_purpose::STANDARD.encode(bytes),
7073 mime_type: "image/png".into(),
7074 }))
7075}
7076
7077fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
7078 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
7079 let mut options = ImageOptions::default();
7080 options.width = Some(48);
7081 options.alt_text = Some("Attached image".into());
7082 chat.add_child(Arc::new(Image::from_data(bytes, options)));
7083 chat.add_child(Arc::new(Spacer::new(1)));
7084 }
7085}
7086
7087#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7088fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7089 Ok(None)
7090}
7091
7092fn add_assistant_message_blocking(
7099 container: &Arc<Container>,
7100 text: &str,
7101 transformer: Option<MarkdownTransformer>,
7102) {
7103 if text.is_empty() {
7104 return;
7105 }
7106 let msg = Arc::new(AssistantMessageComponent::new(
7107 AssistantMessageOptions::default(),
7108 ));
7109 if let Some(t) = &transformer {
7110 msg.set_markdown_transformer(Some(t.clone()));
7111 }
7112 msg.update_text(text);
7113 container.add_child(msg);
7114 container.add_child(Arc::new(Spacer::new(1)));
7115}
7116
7117async fn drain_agent_events(
7125 mut rx: broadcast::Receiver<AgentEvent>,
7126 tui: Arc<TuiAltScreen>,
7127 state: Arc<TuiState>,
7128 chat: Arc<Container>,
7129) {
7130 loop {
7131 match rx.recv().await {
7132 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
7133 Err(broadcast::error::RecvError::Lagged(_)) => {
7134 continue;
7137 }
7138 Err(broadcast::error::RecvError::Closed) => break,
7139 }
7140 }
7141}
7142
7143async fn handle_agent_event(
7146 event: AgentEvent,
7147 tui: &Arc<TuiAltScreen>,
7148 state: &Arc<TuiState>,
7149 chat: &Arc<Container>,
7150) {
7151 match event {
7152 AgentEvent::AgentStart => {
7153 state.set_status(RunStatus::Working);
7154 tui.request_render(false);
7155 }
7156
7157 AgentEvent::AgentEnd { .. } => {
7158 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7160 comp.set_streaming(false);
7161 }
7162 state.set_status(RunStatus::Idle);
7163 tui.request_render(false);
7164 }
7165
7166 AgentEvent::RetryScheduled {
7167 attempt,
7168 max_retries,
7169 delay_ms,
7170 ..
7171 } => {
7172 state.show_retry(attempt, max_retries, delay_ms);
7173 tui.request_render(false);
7174 }
7175
7176 AgentEvent::TurnStart => {
7177 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7180 comp.set_streaming(false);
7181 }
7182 }
7183
7184 AgentEvent::TurnEnd {
7185 message,
7186 tool_results,
7187 } => {
7188 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7190 if let AgentMessage::Assistant(a) = &message {
7191 comp.update_blocks(&assistant_blocks(a));
7192 }
7193 comp.set_streaming(false);
7194 }
7195 let tools = state.tool_components.lock().unwrap();
7200 for tr in &tool_results {
7201 if tools.contains_key(&tr.tool_call_id) {
7202 let _ = tr;
7205 }
7206 }
7207 drop(tools);
7208 tui.request_render(false);
7209 }
7210
7211 AgentEvent::MessageStart { message } => match message {
7212 AgentMessage::Assistant(a) => {
7213 let comp = Arc::new(AssistantMessageComponent::new(
7214 AssistantMessageOptions::default(),
7215 ));
7216 if let Some(t) = state.markdown_transformer() {
7221 comp.set_markdown_transformer(Some(t));
7222 }
7223 comp.set_hide_thinking(state.hide_thinking());
7224 comp.set_streaming(true);
7225 comp.update_blocks(&assistant_blocks(&a));
7228 chat.add_child(comp.clone());
7229 chat.add_child(Arc::new(Spacer::new(1)));
7232 *state.current_assistant.lock().unwrap() = Some(comp);
7233 tui.request_render(false);
7234 }
7235 AgentMessage::Custom(custom) => {
7236 let payload = serde_json::json!({
7237 "customType": custom.role,
7238 "content": custom.content,
7239 "details": custom.data,
7240 "expanded": false,
7241 "outputPad": 1,
7242 });
7243 if let Some(component) = extension_message_component(
7244 &state.extension_session,
7245 &custom.role,
7246 &payload,
7247 state.markdown_transformer(),
7248 ) {
7249 chat.add_child(component);
7250 chat.add_child(Arc::new(Spacer::new(1)));
7251 tui.request_render(false);
7252 } else {
7253 add_note_message(chat, &custom_message_fallback(&custom));
7254 tui.request_render(false);
7255 }
7256 }
7257 _ => {}
7260 },
7261
7262 AgentEvent::MessageUpdate {
7263 message,
7264 assistant_message_event,
7265 } => {
7266 if let AgentMessage::Assistant(a) = &message {
7267 let text = assistant_text(a);
7268 let mut saw_bash_tool_call = false;
7269 for c in &a.content {
7274 if let Content::ToolCall(tc) = c {
7275 if tc.name.trim().is_empty() {
7279 continue;
7280 }
7281 if tc.name == "bash" {
7282 let command = tc
7288 .arguments
7289 .get("command")
7290 .and_then(|v| v.as_str())
7291 .unwrap_or("");
7292 if command.trim().is_empty() {
7299 continue;
7300 }
7301 saw_bash_tool_call = true;
7302 let mut bash = state.bash_components.lock().unwrap();
7303 if let Some(existing) = bash.get(&tc.id) {
7304 existing.set_command(command);
7309 } else {
7310 let comp = Arc::new(BashExecutionComponent::new(command));
7311 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7312 chat.add_child(comp.clone());
7313 bash.insert(tc.id.clone(), comp);
7314 }
7315 } else {
7316 let mut tools = state.tool_components.lock().unwrap();
7317 let display_args = if is_ask_user_tool(&tc.name) {
7318 ask_user_args_display(&tc.arguments)
7319 } else {
7320 tc.arguments.to_string()
7321 };
7322 if let Some(existing) = tools.get(&tc.id) {
7323 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
7327 existing.set_args(&display_args);
7328 }
7329 } else {
7330 let comp = Arc::new(ToolExecutionComponent::new(
7331 &tc.name,
7332 &display_args,
7333 ));
7334 if is_ask_user_tool(&tc.name) {
7335 comp.set_display_title("ASK USER");
7336 }
7337 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7338 comp.set_running();
7339 chat.add_child(comp.clone());
7340 tools.insert(tc.id.clone(), comp);
7341 }
7342 }
7343 }
7344 }
7345 if saw_bash_tool_call {
7350 state.sync_working_loader_with_bash();
7351 }
7352 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
7354 comp.update_blocks(&assistant_blocks(a));
7357 }
7358 *state.last_assistant_text.lock().unwrap() = text;
7359 tui.request_render(false);
7360 }
7361 }
7362
7363 AgentEvent::MessageEnd { message } => {
7364 if let AgentMessage::Assistant(a) = &message {
7365 let text = assistant_text(a);
7366 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7367 comp.update_blocks(&assistant_blocks(a));
7368 comp.set_streaming(false);
7369 }
7370 if !text.is_empty() {
7372 *state.last_assistant_text.lock().unwrap() = text;
7373 }
7374 let usage = &a.usage;
7380 state.footer.add_usage(
7382 usage.input,
7383 usage.output,
7384 usage.cache_read,
7385 usage.cache_write,
7386 usage.cost.total,
7387 );
7388 let denom = usage.input + usage.cache_read + usage.cache_write;
7389 if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
7390 state
7391 .footer
7392 .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
7393 }
7394 let miss = state
7395 .cache_tracker
7396 .lock()
7397 .unwrap()
7398 .observe(a, &rpi_harness::cache_stats::NoPrices);
7399 if let Some(miss) = miss {
7400 let cost = if miss.missed_cost > 0.0 {
7401 format!(" (~${:.4})", miss.missed_cost)
7402 } else {
7403 String::new()
7404 };
7405 add_note_message(
7406 &state.chat_container,
7407 &format!(
7408 "Cache miss: {} tokens re-billed{}",
7409 format_tokens(miss.missed_tokens),
7410 cost
7411 ),
7412 );
7413 }
7414 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
7415 add_note_message(chat, &text);
7416 }
7417 if let Some(error) = assistant_error_text(a) {
7422 add_error_message(chat, &error);
7423 }
7424 }
7425 tui.request_render(false);
7426 }
7427
7428 AgentEvent::ToolExecutionStart {
7429 tool_call_id,
7430 tool_name,
7431 args,
7432 } => {
7433 if tool_name.trim().is_empty() {
7436 return;
7437 }
7438 if tool_name == "bash" {
7439 let command = args
7444 .get("command")
7445 .and_then(|v| v.as_str())
7446 .unwrap_or("")
7447 .to_string();
7448 if command.trim().is_empty() {
7452 return;
7453 }
7454 let mut bash_map = state.bash_components.lock().unwrap();
7455 if let Some(existing) = bash_map.get(&tool_call_id) {
7456 existing.set_command(&command);
7461 } else {
7462 let comp = Arc::new(BashExecutionComponent::new(command));
7463 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7464 chat.add_child(comp.clone());
7465 bash_map.insert(tool_call_id.clone(), comp);
7466 }
7467 } else {
7468 let _comp = {
7469 let mut tools = state.tool_components.lock().unwrap();
7470 if let Some(existing) = tools.get(&tool_call_id) {
7471 if is_ask_user_tool(&tool_name) {
7472 existing.set_display_title("ASK USER");
7473 existing.set_args(&ask_user_args_display(&args));
7474 } else {
7475 existing.set_args(&args.to_string());
7476 }
7477 existing.clone()
7478 } else {
7479 let display_args = if is_ask_user_tool(&tool_name) {
7480 ask_user_args_display(&args)
7481 } else {
7482 args.to_string()
7483 };
7484 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7485 if is_ask_user_tool(&tool_name) {
7486 comp.set_display_title("ASK USER");
7487 }
7488 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7489 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7494 comp.set_skill_name(skill);
7495 }
7496 comp.set_running();
7497 chat.add_child(comp.clone());
7498 tools.insert(tool_call_id.clone(), comp.clone());
7499 comp
7500 }
7501 };
7502 }
7503 state.sync_working_loader_with_bash();
7504 tui.request_render(false);
7505 }
7506
7507 AgentEvent::ToolExecutionUpdate {
7508 tool_call_id,
7509 tool_name,
7510 args,
7511 partial_result,
7512 } => {
7513 if tool_name.trim().is_empty() {
7514 return;
7515 }
7516 let partial_text = if is_ask_user_tool(&tool_name) {
7517 ask_user_progress_text(&args, &partial_result)
7518 } else {
7519 tool_result_text(&partial_result)
7520 };
7521 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7522 if tool_name == "bash" {
7523 let chunk = partial_text;
7528 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7529 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7532 bash.set_command(command);
7533 }
7534 if has_partial_payload {
7535 bash.append_output(&chunk);
7536 }
7537 } else if has_partial_payload {
7538 }
7544 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7545 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7546 comp.set_skill_name(skill);
7547 }
7548 if is_ask_user_tool(&tool_name) {
7549 comp.set_display_title("ASK USER");
7550 comp.set_args(&ask_user_args_display(&args));
7551 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7552 comp.set_args(&args.to_string());
7553 }
7554 if has_partial_payload {
7557 comp.set_result(&partial_text, false);
7558 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7559 }
7560 } else if has_partial_payload {
7561 let display_args = if is_ask_user_tool(&tool_name) {
7566 ask_user_args_display(&args)
7567 } else {
7568 args.to_string()
7569 };
7570 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7571 if is_ask_user_tool(&tool_name) {
7572 comp.set_display_title("ASK USER");
7573 }
7574 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7575 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7576 comp.set_skill_name(skill);
7577 }
7578 comp.set_running();
7579 comp.set_result(&partial_text, false);
7580 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7581 chat.add_child(comp.clone());
7582 state
7583 .tool_components
7584 .lock()
7585 .unwrap()
7586 .insert(tool_call_id.clone(), comp.clone());
7587 }
7588 state.sync_working_loader_with_bash();
7589 tui.request_render(false);
7590 }
7591
7592 AgentEvent::ToolExecutionEnd {
7593 tool_call_id,
7594 tool_name,
7595 result,
7596 is_error,
7597 } => {
7598 if tool_name.trim().is_empty() {
7599 return;
7600 }
7601 if tool_name == "bash" {
7602 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7603 if let Some(bash) = bash {
7604 finalize_bash(&bash, &result, is_error);
7605 } else {
7606 let command = result
7609 .details
7610 .get("command")
7611 .and_then(|v| v.as_str())
7612 .unwrap_or("")
7613 .to_string();
7614 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7615 state.sync_working_loader_with_bash();
7616 tui.render_now(false);
7619 return;
7620 }
7621 let comp = Arc::new(BashExecutionComponent::new(command));
7622 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7623 comp.append_output(&tool_result_text(&result));
7624 finalize_bash(&comp, &result, is_error);
7625 chat.add_child(comp);
7626 }
7627 } else {
7628 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7629 if let Some(comp) = comp {
7630 let result_text = if is_ask_user_tool(&tool_name) {
7631 ask_user_result_text(&result)
7632 } else {
7633 tool_result_text(&result)
7634 };
7635 comp.set_result(&result_text, is_error);
7636 if !is_ask_user_tool(&tool_name) {
7637 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7638 }
7639 } else {
7640 let comp = Arc::new(ToolExecutionComponent::new(
7643 &tool_name,
7644 &if is_ask_user_tool(&tool_name) {
7645 ask_user_args_display(&serde_json::Value::Null)
7646 } else {
7647 "".to_string()
7648 },
7649 ));
7650 if is_ask_user_tool(&tool_name) {
7651 comp.set_display_title("ASK USER");
7652 }
7653 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7654 let result_text = if is_ask_user_tool(&tool_name) {
7655 ask_user_result_text(&result)
7656 } else {
7657 tool_result_text(&result)
7658 };
7659 comp.set_result(&result_text, is_error);
7660 if !is_ask_user_tool(&tool_name) {
7661 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7662 }
7663 chat.add_child(comp.clone());
7664 }
7665 }
7666 state.sync_working_loader_with_bash();
7667 tui.render_now(false);
7673 }
7674 }
7675}
7676
7677fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7681 let fallback = match message.stop_reason {
7682 rpi_ai::StopReason::Error => "Provider request failed.",
7683 rpi_ai::StopReason::Aborted => "Request aborted.",
7684 _ => return None,
7685 };
7686 Some(
7687 message
7688 .error_message
7689 .as_deref()
7690 .filter(|text| !text.trim().is_empty())
7691 .unwrap_or(fallback)
7692 .to_string(),
7693 )
7694}
7695
7696fn finalize_bash(
7700 comp: &Arc<BashExecutionComponent>,
7701 result: &rpi_agent::AgentToolResult,
7702 is_error: bool,
7703) {
7704 let exit_code = if is_error { Some(1) } else { Some(0) };
7707 let truncated = result
7708 .details
7709 .get("truncation")
7710 .and_then(|t| t.get("truncated"))
7711 .and_then(|v| v.as_bool())
7712 .unwrap_or(false);
7713 let full_output_path = result
7714 .details
7715 .get("full_output_path")
7716 .and_then(|v| v.as_str())
7717 .map(|s| s.to_string());
7718 let truncation = BashTruncation {
7719 truncated,
7720 full_output_path,
7721 };
7722 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7726 comp.set_command(command);
7727 }
7728 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
7730}
7731
7732fn apply_edit_diff(
7737 comp: &Arc<ToolExecutionComponent>,
7738 tool_name: &str,
7739 details: &serde_json::Value,
7740 tui: &Arc<TuiAltScreen>,
7741) {
7742 if tool_name != "edit" {
7743 return;
7744 }
7745 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7746 return;
7747 };
7748 if diff_text.is_empty() {
7749 return;
7750 }
7751 let width = tui.width();
7752 let lines = render_diff(diff_text, width);
7753 comp.set_diff(lines);
7754}
7755
7756fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7761 if tool_name != "read" {
7762 return None;
7763 }
7764 let path = args.get("path").and_then(|value| value.as_str())?;
7765 let normalized = path.replace('\\', "/");
7766 let file_name = normalized.rsplit('/').next()?;
7767 if !file_name.eq_ignore_ascii_case("SKILL.md") {
7768 return None;
7769 }
7770 normalized
7771 .trim_end_matches('/')
7772 .rsplit('/')
7773 .nth(1)
7774 .filter(|name| !name.is_empty())
7775 .map(str::to_string)
7776}
7777
7778fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7781 use rpi_agent::TextContentOrImage;
7782 let mut parts: Vec<String> = Vec::new();
7783 for c in &result.content {
7784 if let TextContentOrImage::Text(t) = c {
7785 parts.push(t.text.clone());
7786 }
7787 }
7788 let joined = parts.join("\n");
7789 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7791 if one_line.chars().count() > 200 {
7792 let truncated: String = one_line.chars().take(200).collect();
7793 format!("{truncated}…")
7794 } else {
7795 one_line
7796 }
7797}
7798
7799fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7804 use rpi_agent::TextContentOrImage;
7805 let mut parts: Vec<String> = Vec::new();
7806 for c in &result.content {
7807 if let TextContentOrImage::Text(t) = c {
7808 parts.push(t.text.clone());
7809 }
7810 }
7811 parts.join("\n")
7812}
7813
7814fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7818 !text.trim().is_empty() || !result.details.is_null()
7819}
7820
7821fn is_ask_user_tool(tool_name: &str) -> bool {
7824 matches!(tool_name, "ask_user" | "ask_user_question")
7825}
7826
7827fn ask_user_args_display(args: &serde_json::Value) -> String {
7830 if args.is_null() {
7831 return String::new();
7832 }
7833 if let Some(summary) = args
7834 .get("summary")
7835 .and_then(serde_json::Value::as_str)
7836 .map(str::trim)
7837 .filter(|text| !text.is_empty())
7838 {
7839 return format!("Confirmation requested: {summary}");
7840 }
7841
7842 let mut lines: Vec<String> = Vec::new();
7843 let questions: Vec<&serde_json::Value> = args
7844 .get("questions")
7845 .and_then(serde_json::Value::as_array)
7846 .filter(|items| !items.is_empty())
7847 .map(|items| items.iter().collect())
7848 .unwrap_or_else(|| vec![args]);
7849
7850 for question in questions {
7851 let prompt = question
7852 .get("question")
7853 .and_then(serde_json::Value::as_str)
7854 .or_else(|| {
7855 args.get("question")
7856 .and_then(serde_json::Value::as_str)
7857 })
7858 .map(str::trim)
7859 .unwrap_or_default();
7860 if prompt.is_empty() {
7861 continue;
7862 }
7863 if let Some(header) = question
7864 .get("header")
7865 .and_then(serde_json::Value::as_str)
7866 .map(str::trim)
7867 .filter(|text| !text.is_empty())
7868 {
7869 lines.push(format!("{header}: {prompt}"));
7870 } else {
7871 lines.push(prompt.to_string());
7872 }
7873 let context = question
7874 .get("context")
7875 .and_then(serde_json::Value::as_str)
7876 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7877 .map(str::trim)
7878 .filter(|text| !text.is_empty());
7879 if let Some(context) = context {
7880 lines.push(format!(" {context}"));
7881 }
7882 let options = question
7883 .get("options")
7884 .and_then(serde_json::Value::as_array)
7885 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7886 if let Some(options) = options {
7887 let labels: Vec<String> = options
7888 .iter()
7889 .filter_map(|option| {
7890 option
7891 .get("title")
7892 .and_then(serde_json::Value::as_str)
7893 .or_else(|| option.as_str())
7894 .map(str::trim)
7895 .filter(|label| !label.is_empty())
7896 .map(str::to_string)
7897 })
7898 .collect();
7899 if !labels.is_empty() {
7900 lines.push(format!("Choices: {}", labels.join(", ")));
7901 }
7902 }
7903 if let Some(suggest) = question
7904 .get("suggest")
7905 .and_then(serde_json::Value::as_str)
7906 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7907 .map(str::trim)
7908 .filter(|text| !text.is_empty())
7909 {
7910 lines.push(format!("Suggestions: {suggest}"));
7911 }
7912 }
7913
7914 lines.join("\n")
7915}
7916
7917fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7920 let text = tool_result_text(result);
7921 if !text.trim().is_empty() {
7922 return text;
7923 }
7924 let args_text = ask_user_args_display(args);
7925 if args_text.trim().is_empty() {
7926 "Waiting for your answer…".to_string()
7927 } else {
7928 args_text
7929 }
7930}
7931
7932fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7935 let text = tool_result_text(result);
7936 if text.trim().is_empty() {
7937 "Answer recorded.".to_string()
7938 } else {
7939 text
7940 }
7941}
7942
7943fn open_selector(
7951 state: &Arc<TuiState>,
7952 editor_container: &Arc<Container>,
7953 editor: &Arc<Editor>,
7954 tui: &Arc<TuiAltScreen>,
7955 list: Arc<SelectList>,
7956 kind: SelectorKind,
7957) {
7958 open_selector_with_view(
7959 state,
7960 editor_container,
7961 editor,
7962 tui,
7963 list.clone(),
7964 list,
7965 kind,
7966 );
7967}
7968
7969fn open_selector_with_view<C: Component + 'static>(
7973 state: &Arc<TuiState>,
7974 editor_container: &Arc<Container>,
7975 editor: &Arc<Editor>,
7976 tui: &Arc<TuiAltScreen>,
7977 list: Arc<SelectList>,
7978 view: Arc<C>,
7979 kind: SelectorKind,
7980) {
7981 dispatch_ui_prompt_event(state);
7983
7984 editor.set_focused(false);
7986 state.autocomplete_container.clear();
7989 editor_container.clear();
7991 editor_container.add_child(view.clone());
7992 *state.active_selector.lock().unwrap() = Some((list, kind));
7993 let focused: Arc<dyn Component> = view;
7994 tui.set_focus(Some(focused));
7995 tui.request_render(false);
7996}
7997
7998fn close_selector(
8001 state: &Arc<TuiState>,
8002 editor_container: &Arc<Container>,
8003 editor: &Arc<Editor>,
8004 tui: &Arc<TuiAltScreen>,
8005) {
8006 dispatch_ui_prompt_event_end(state);
8008
8009 editor_container.clear();
8010 editor_container.add_child(editor.clone());
8011 state.autocomplete_container.clear();
8012 editor.set_focused(true);
8013 *state.active_selector.lock().unwrap() = None;
8014 *state.active_extension_cancel.lock().unwrap() = None;
8015 tui.set_focus(Some(editor.clone()));
8016 tui.request_render(false);
8017}
8018
8019fn open_model_selector(
8025 state: &Arc<TuiState>,
8026 editor_container: &Arc<Container>,
8027 editor: &Arc<Editor>,
8028 tui: &Arc<TuiAltScreen>,
8029 catalog: &[rpi_ai::Model],
8030 lane: &Arc<dyn AgentLane>,
8031 lane_model_id: &str,
8032 chat: &Arc<Container>,
8033) {
8034 let items = model_selector_items(catalog, lane_model_id);
8035 if items.is_empty() {
8036 add_note_message(
8037 chat,
8038 "No models in the catalog. Use --model at startup to select one.",
8039 );
8040 tui.request_render(false);
8041 return;
8042 }
8043 let list = Arc::new(SelectList::new(items, 10));
8044
8045 let catalog_arc = catalog.to_vec();
8049 let state_sel = state.clone();
8050 let ec_sel = editor_container.clone();
8051 let editor_sel = editor.clone();
8052 let tui_sel = tui.clone();
8053 let chat_sel = chat.clone();
8054 let lane_sel = lane.clone();
8055 list.on_select(Arc::new(move |item| {
8056 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
8057 add_note_message(
8058 &chat_sel,
8059 &format!("Model {} not found in catalog.", item.label),
8060 );
8061 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8062 return;
8063 };
8064 state_sel.set_current_model(&model);
8065 let lane = lane_sel.clone();
8066 tokio::spawn(async move {
8067 let _ = lane.set_model(model).await;
8068 });
8069 add_note_message(
8070 &chat_sel,
8071 &format!(
8072 "Model set to {} — applies to the next message.",
8073 short_model_name(&item.value)
8074 ),
8075 );
8076 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8077 }));
8078 let state_cancel = state.clone();
8079 let ec_cancel = editor_container.clone();
8080 let editor_cancel = editor.clone();
8081 let tui_cancel = tui.clone();
8082 list.on_cancel(Arc::new(move || {
8083 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8084 }));
8085
8086 open_selector(
8087 state,
8088 editor_container,
8089 editor,
8090 tui,
8091 list,
8092 SelectorKind::Model,
8093 );
8094}
8095
8096fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8101 if catalog.is_empty() {
8102 return None;
8103 }
8104 let idx = catalog
8105 .iter()
8106 .position(|m| m.id.eq_ignore_ascii_case(current_id));
8107 match idx {
8108 Some(i) => {
8109 let next = (i + 1) % catalog.len();
8110 Some(catalog[next].clone())
8111 }
8112 None => Some(catalog[0].clone()),
8113 }
8114}
8115
8116fn open_session_selector(
8122 state: &Arc<TuiState>,
8123 editor_container: &Arc<Container>,
8124 editor: &Arc<Editor>,
8125 tui: &Arc<TuiAltScreen>,
8126 cwd: &std::path::Path,
8127 tx: &mpsc::UnboundedSender<TuiMessage>,
8128) {
8129 let dir = crate::session::default_session_dir(cwd);
8130 let mut items: Vec<SelectItem> = Vec::new();
8131 if let Ok(entries) = std::fs::read_dir(&dir) {
8132 for entry in entries.flatten() {
8133 let path = entry.path();
8134 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
8135 continue;
8136 }
8137 let stem = path
8138 .file_stem()
8139 .and_then(|s| s.to_str())
8140 .unwrap_or("(unnamed)")
8141 .to_string();
8142 let display = path
8143 .file_name()
8144 .and_then(|s| s.to_str())
8145 .unwrap_or(&stem)
8146 .to_string();
8147 items.push(SelectItem::new(&stem, &display));
8148 }
8149 }
8150 if items.is_empty() {
8151 add_note_message(
8152 &state.chat_container,
8153 "No saved sessions found. Sessions are created automatically in interactive mode.",
8154 );
8155 tui.request_render(false);
8156 return;
8157 }
8158 let list = Arc::new(SelectList::new(items, 10));
8159
8160 let state_sel = state.clone();
8161 let ec_sel = editor_container.clone();
8162 let editor_sel = editor.clone();
8163 let tui_sel = tui.clone();
8164 let tx_sel = tx.clone();
8165 list.on_select(Arc::new(move |item| {
8166 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8170 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
8171 }));
8172 let state_cancel = state.clone();
8173 let ec_cancel = editor_container.clone();
8174 let editor_cancel = editor.clone();
8175 let tui_cancel = tui.clone();
8176 list.on_cancel(Arc::new(move || {
8177 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8178 }));
8179
8180 open_selector(
8181 state,
8182 editor_container,
8183 editor,
8184 tui,
8185 list,
8186 SelectorKind::Session,
8187 );
8188}
8189
8190fn custom_entry_display_text(
8191 custom_type: &str,
8192 data: Option<&serde_json::Value>,
8193) -> Option<String> {
8194 let data = data?;
8195 let text = data
8196 .get("summary")
8197 .or_else(|| data.get("text"))
8198 .or_else(|| data.get("output"))
8199 .and_then(|value| value.as_str())
8200 .filter(|value| !value.trim().is_empty())?;
8201 let label = match custom_type {
8202 "compactionSummary" => "Compaction summary",
8203 "branchSummary" => "Branch summary",
8204 "bashExecution" => "Command output",
8205 other => other,
8206 };
8207 Some(format!("{label}: {text}"))
8208}
8209
8210async fn open_tree_selector(
8214 harness: &AgentHarness,
8215 state: &Arc<TuiState>,
8216 editor_container: &Arc<Container>,
8217 editor: &Arc<Editor>,
8218 tui: &Arc<TuiAltScreen>,
8219 chat: &Arc<Container>,
8220 tx: &mpsc::UnboundedSender<TuiMessage>,
8221) {
8222 let entries = match harness
8223 .session()
8224 .view("main")
8225 .find_entries(&EntryQuery {
8226 order: Some(EntryOrder::OldestFirst),
8227 ..Default::default()
8228 })
8229 .await
8230 {
8231 Ok(entries) => entries,
8232 Err(error) => {
8233 add_error_message(chat, &format!("Could not read session tree: {error}"));
8234 tui.request_render(false);
8235 return;
8236 }
8237 };
8238 let current = harness.session().get_leaf_id().await.ok().flatten();
8239 let items: Vec<SelectItem> = entries
8240 .iter()
8241 .map(|entry| {
8242 let marker = if current.as_deref() == Some(entry.id()) {
8243 " (current)"
8244 } else {
8245 ""
8246 };
8247 SelectItem::new(
8248 entry.id(),
8249 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
8250 )
8251 .with_description(&entry.id()[..entry.id().len().min(12)])
8252 })
8253 .collect();
8254 if items.is_empty() {
8255 add_note_message(chat, "The current session has no entries to navigate.");
8256 tui.request_render(false);
8257 return;
8258 }
8259 let list = Arc::new(SelectList::new(items, 12));
8260 let state_sel = state.clone();
8261 let ec_sel = editor_container.clone();
8262 let editor_sel = editor.clone();
8263 let tui_sel = tui.clone();
8264 let tx_sel = tx.clone();
8265 list.on_select(Arc::new(move |item| {
8266 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
8267 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8268 }));
8269 let state_cancel = state.clone();
8270 let ec_cancel = editor_container.clone();
8271 let editor_cancel = editor.clone();
8272 let tui_cancel = tui.clone();
8273 list.on_cancel(Arc::new(move || {
8274 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8275 }));
8276 open_selector(
8277 state,
8278 editor_container,
8279 editor,
8280 tui,
8281 list,
8282 SelectorKind::Tree,
8283 );
8284}
8285
8286fn open_theme_selector(
8289 state: &Arc<TuiState>,
8290 editor_container: &Arc<Container>,
8291 editor: &Arc<Editor>,
8292 tui: &Arc<TuiAltScreen>,
8293 cwd: &std::path::Path,
8294 package_resources: &Arc<crate::packages::PackageResources>,
8295) {
8296 let mut items = vec![
8297 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
8298 SelectItem::new("light", "Light").with_description("Light background"),
8299 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
8300 ];
8301 if state.themes_enabled {
8302 for path in package_resources.theme_files() {
8303 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
8304 items.push(SelectItem::new(name, name).with_description("Package theme"));
8305 }
8306 }
8307 }
8308 let list = Arc::new(SelectList::new(items, 10));
8309
8310 let state_sel = state.clone();
8311 let ec_sel = editor_container.clone();
8312 let editor_sel = editor.clone();
8313 let tui_sel = tui.clone();
8314 let chat_sel = state.chat_container.clone();
8315 let cwd_sel = cwd.to_path_buf();
8316 let package_resources_sel = package_resources.clone();
8317 list.on_select(Arc::new(move |item| {
8318 let preset = match item.value.as_str() {
8319 "light" => Some(ThemePreset::Light),
8320 "monochrome" => Some(ThemePreset::Monochrome),
8321 "dark" => Some(ThemePreset::Dark),
8322 name => {
8323 if state_sel.themes_enabled {
8324 if let Ok(custom) = crate::packages::load_theme_with_resources(
8325 &cwd_sel,
8326 name,
8327 &package_resources_sel,
8328 ) {
8329 rpi_tui::global_theme_manager().set(custom.clone());
8330 state_sel.theme_manager.set(custom);
8331 }
8332 }
8333 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8334 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8335 tui_sel.render_now(true);
8336 return;
8337 }
8338 };
8339 let Some(preset) = preset else { return };
8340 apply_theme_preset(preset);
8341 state_sel.theme_manager.apply_preset(preset);
8342 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8345 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8346 tui_sel.render_now(true);
8347 }));
8348 let state_cancel = state.clone();
8349 let ec_cancel = editor_container.clone();
8350 let editor_cancel = editor.clone();
8351 let tui_cancel = tui.clone();
8352 list.on_cancel(Arc::new(move || {
8353 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8354 }));
8355
8356 open_selector(
8357 state,
8358 editor_container,
8359 editor,
8360 tui,
8361 list,
8362 SelectorKind::Theme,
8363 );
8364}
8365
8366fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8373 use rpi_ai::types::ThinkingLevel::*;
8374 match level {
8375 Off => "Off — No reasoning",
8376 Minimal => "Minimal — Brief reasoning (~1k tokens)",
8377 Low => "Low — Light reasoning (~1k tokens)",
8378 Medium => "Medium — Moderate reasoning (~80% of max)",
8379 High => "High — Extensive reasoning (~95% of max)",
8380 Xhigh => "Xhigh — Near-maximal reasoning",
8381 Max => "Max — Maximum reasoning",
8382 }
8383}
8384
8385fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8388 use rpi_ai::types::ThinkingLevel::*;
8389 match level {
8390 Off => "off",
8391 Minimal => "minimal",
8392 Low => "low",
8393 Medium => "medium",
8394 High => "high",
8395 Xhigh => "xhigh",
8396 Max => "max",
8397 }
8398}
8399
8400fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
8403 use rpi_ai::types::ThinkingLevel::*;
8404 match name.to_ascii_lowercase().as_str() {
8405 "off" => Some(Off),
8406 "minimal" => Some(Minimal),
8407 "low" => Some(Low),
8408 "medium" => Some(Medium),
8409 "high" => Some(High),
8410 "xhigh" => Some(Xhigh),
8411 "max" => Some(Max),
8412 _ => None,
8413 }
8414}
8415
8416fn open_thinking_selector(
8426 state: &Arc<TuiState>,
8427 editor_container: &Arc<Container>,
8428 editor: &Arc<Editor>,
8429 tui: &Arc<TuiAltScreen>,
8430 lane: &Arc<dyn AgentLane>,
8431 catalog: &[rpi_ai::Model],
8432 lane_model_id: &str,
8433 chat: &Arc<Container>,
8434) {
8435 let model = catalog
8438 .iter()
8439 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8440 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8441 .map(|m| m.supported_thinking_levels())
8442 .unwrap_or_else(|| {
8443 use rpi_ai::types::ThinkingLevel::*;
8444 vec![Off, Minimal, Low, Medium, High]
8445 });
8446 let mut items: Vec<SelectItem> = Vec::new();
8447 for lvl in &levels {
8448 let name = thinking_level_name(*lvl);
8449 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8450 }
8451 if items.is_empty() {
8452 add_note_message(chat, "This model has no supported thinking levels.");
8453 tui.request_render(false);
8454 return;
8455 }
8456 let list = Arc::new(SelectList::new(items, 10));
8457
8458 let state_sel = state.clone();
8459 let ec_sel = editor_container.clone();
8460 let editor_sel = editor.clone();
8461 let tui_sel = tui.clone();
8462 let chat_sel = chat.clone();
8463 let lane_sel = lane.clone();
8464 list.on_select(Arc::new(move |item| {
8465 let Some(level) = thinking_level_from_name(&item.value) else {
8466 add_note_message(
8467 &chat_sel,
8468 &format!("Unknown thinking level: {}.", item.label),
8469 );
8470 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8471 return;
8472 };
8473 let lane = lane_sel.clone();
8474 let footer_sel = state_sel.footer.clone();
8475 tokio::spawn(async move {
8476 let _ = lane.set_thinking_level(level).await;
8477 });
8478 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8482 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8483 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8484 }));
8485 let state_cancel = state.clone();
8486 let ec_cancel = editor_container.clone();
8487 let editor_cancel = editor.clone();
8488 let tui_cancel = tui.clone();
8489 list.on_cancel(Arc::new(move || {
8490 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8491 }));
8492
8493 open_selector(
8494 state,
8495 editor_container,
8496 editor,
8497 tui,
8498 list,
8499 SelectorKind::Thinking,
8500 );
8501}
8502
8503fn open_tools_selector(
8509 state: &Arc<TuiState>,
8510 editor_container: &Arc<Container>,
8511 editor: &Arc<Editor>,
8512 tui: &Arc<TuiAltScreen>,
8513 lane: &Arc<dyn AgentLane>,
8514 chat: &Arc<Container>,
8515) {
8516 let mut active = match tokio::runtime::Handle::try_current() {
8521 Ok(h) => h
8522 .block_on(async { lane.get_active_tools().await })
8523 .unwrap_or_default(),
8524 Err(_) => Vec::new(),
8525 };
8526 if active.is_empty() {
8531 active = crate::session::BUILTIN_TOOL_NAMES
8532 .iter()
8533 .map(|name| (*name).to_string())
8534 .collect();
8535 }
8536 let mut items: Vec<SelectItem> = Vec::new();
8537 for name in crate::session::BUILTIN_TOOL_NAMES {
8538 let on = active.iter().any(|a| a == name);
8539 let label = if on {
8540 format!("{name} (on)")
8541 } else {
8542 (*name).to_string()
8543 };
8544 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8545 }
8546 let list = Arc::new(SelectList::new(items, 10));
8547
8548 let active_captured = active.clone();
8550 let state_sel = state.clone();
8551 let ec_sel = editor_container.clone();
8552 let editor_sel = editor.clone();
8553 let tui_sel = tui.clone();
8554 let chat_sel = chat.clone();
8555 let lane_sel = lane.clone();
8556 list.on_select(Arc::new(move |item| {
8557 let mut next = active_captured.clone();
8558 if let Some(pos) = next.iter().position(|a| a == &item.value) {
8559 next.remove(pos);
8560 } else {
8561 next.push(item.value.clone());
8562 }
8563 let on = next.iter().any(|a| a == &item.value);
8564 let lane = lane_sel.clone();
8565 let next_clone = next.clone();
8566 tokio::spawn(async move {
8567 let _ = lane.set_active_tools(next_clone).await;
8568 });
8569 let list_str = if next.is_empty() {
8570 "(none)".to_string()
8571 } else {
8572 next.join(", ")
8573 };
8574 add_note_message(
8575 &chat_sel,
8576 &format!(
8577 "{} {} — active tools: {}",
8578 item.value,
8579 if on { "enabled" } else { "disabled" },
8580 list_str
8581 ),
8582 );
8583 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8584 }));
8585 let state_cancel = state.clone();
8586 let ec_cancel = editor_container.clone();
8587 let editor_cancel = editor.clone();
8588 let tui_cancel = tui.clone();
8589 list.on_cancel(Arc::new(move || {
8590 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8591 }));
8592
8593 open_selector(
8594 state,
8595 editor_container,
8596 editor,
8597 tui,
8598 list,
8599 SelectorKind::Tools,
8600 );
8601}
8602
8603fn open_images_selector(
8607 state: &Arc<TuiState>,
8608 editor_container: &Arc<Container>,
8609 editor: &Arc<Editor>,
8610 tui: &Arc<TuiAltScreen>,
8611 chat: &Arc<Container>,
8612) {
8613 let current = *state.show_images.lock().unwrap();
8614 let items = vec![
8615 SelectItem::new("yes", "Yes").with_description(if current {
8616 "Inline images (current)"
8617 } else {
8618 "Inline images"
8619 }),
8620 SelectItem::new("no", "No").with_description(if current {
8621 "Placeholder only"
8622 } else {
8623 "Placeholder only (current)"
8624 }),
8625 ];
8626 let list = Arc::new(SelectList::new(items, 5));
8627
8628 let state_sel = state.clone();
8629 let ec_sel = editor_container.clone();
8630 let editor_sel = editor.clone();
8631 let tui_sel = tui.clone();
8632 let chat_sel = chat.clone();
8633 list.on_select(Arc::new(move |item| {
8634 let on = item.value == "yes";
8635 *state_sel.show_images.lock().unwrap() = on;
8636 add_note_message(
8637 &chat_sel,
8638 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8639 );
8640 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8641 }));
8642 let state_cancel = state.clone();
8643 let ec_cancel = editor_container.clone();
8644 let editor_cancel = editor.clone();
8645 let tui_cancel = tui.clone();
8646 list.on_cancel(Arc::new(move || {
8647 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8648 }));
8649
8650 open_selector(
8651 state,
8652 editor_container,
8653 editor,
8654 tui,
8655 list,
8656 SelectorKind::Images,
8657 );
8658}
8659
8660fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8668 let text = editor.get_text();
8669 let cursor = editor_cursor_offset(editor, &text);
8670 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8671 render_autocomplete(state, suggestions);
8672}
8673
8674fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8677 let (row, col) = editor.cursor_position();
8678 let mut offset = 0;
8679 for (index, line) in text.split('\n').enumerate() {
8680 if index == row {
8681 return (offset + col.min(line.len())).min(text.len());
8682 }
8683 offset = offset.saturating_add(line.len() + 1);
8684 }
8685 text.len()
8686}
8687
8688fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8691 let offset = offset.min(text.len());
8692 let mut consumed = 0;
8693 for (row, line) in text.split('\n').enumerate() {
8694 let end = consumed + line.len();
8695 if offset <= end {
8696 editor.set_cursor(row, offset - consumed);
8697 return;
8698 }
8699 consumed = end + 1;
8700 }
8701 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8702 editor.set_cursor(
8703 last_row,
8704 text.rsplit('\n').next().map(str::len).unwrap_or(0),
8705 );
8706}
8707
8708fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8710 state.autocomplete_container.clear();
8711 let Some(sugg) = suggestions else {
8712 return;
8713 };
8714 if sugg.items.is_empty() {
8715 return;
8716 }
8717 let accent = state.theme_manager.get().colors.accent;
8720 let muted = state.theme_manager.get().colors.muted;
8721 for (i, item) in sugg
8722 .items
8723 .iter()
8724 .take(state.autocomplete_max_visible)
8725 .enumerate()
8726 {
8727 let prefix = if i == 0 { "→ " } else { " " };
8728 let label = item.display_text();
8729 let line = if i == 0 {
8730 format!(
8731 "{prefix}{} {}",
8732 accent.fg(label),
8733 muted.fg(item.description.as_deref().unwrap_or(""))
8734 )
8735 } else {
8736 format!(
8737 "{prefix}{} {}",
8738 muted.fg(label),
8739 muted.fg(item.description.as_deref().unwrap_or(""))
8740 )
8741 };
8742 state
8743 .autocomplete_container
8744 .add_child(Arc::new(Text::new(line, 1, 0)));
8745 }
8746}
8747
8748fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8752 let text = editor.get_text();
8753 let cursor = editor_cursor_offset(editor, &text);
8754 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8755 return false;
8756 };
8757 let Some(top) = sugg.items.first() else {
8758 return false;
8759 };
8760 let start = sugg.start.min(text.len());
8764 let end = sugg.end.min(text.len());
8765 let mut replaced = String::with_capacity(text.len() + top.text.len());
8766 replaced.push_str(&text[..start]);
8767 replaced.push_str(&top.text);
8768 replaced.push_str(&text[end..]);
8771 if top.insert_space && !replaced.ends_with('/') {
8772 replaced.push(' ');
8773 }
8774 let new_cursor = replaced.len().min(
8777 start
8778 + top.text.len()
8779 + if top.insert_space && !top.text.ends_with('/') {
8780 1
8781 } else {
8782 0
8783 },
8784 );
8785 editor.set_text(&replaced);
8786 set_editor_cursor_offset(editor, &replaced, new_cursor);
8787 state.autocomplete_container.clear();
8788 true
8789}
8790
8791fn add_welcome_message(container: &Arc<Container>) {
8797 add_welcome_message_with_capabilities(container, &[], &[]);
8798}
8799
8800fn add_welcome_message_with_capabilities(
8804 container: &Arc<Container>,
8805 active_tools: &[String],
8806 skills: &[String],
8807) {
8808 let c = current_theme().colors;
8809 let title = format!(
8813 "{} {}",
8814 c.accent.fg(&tui_bold("rpi")),
8815 c.muted.fg("interactive TUI")
8816 );
8817 container.add_child(Arc::new(Text::new(title, 1, 0)));
8818 container.add_child(Arc::new(Spacer::new(1)));
8819 container.add_child(Arc::new(Text::new(
8820 c.dim.fg("Type your message and press Enter to send."),
8821 1,
8822 0,
8823 )));
8824 let hint = c
8825 .dim
8826 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8827 container.add_child(Arc::new(Text::new(hint, 1, 0)));
8828 container.add_child(Arc::new(Spacer::new(1)));
8829 container.add_child(Arc::new(Text::new(
8830 welcome_capability_line("Tools", active_tools),
8831 1,
8832 0,
8833 )));
8834 container.add_child(Arc::new(Text::new(
8835 welcome_capability_line("Skills", skills),
8836 1,
8837 0,
8838 )));
8839 container.add_child(Arc::new(DynamicBorder::new()));
8840}
8841
8842fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8845 let colors = current_theme().colors;
8846 let group = Arc::new(Container::new());
8847
8848 for warning in &report.warnings {
8849 let body = format!(
8850 "{}\n{} {}{}",
8851 colors.error.fg(&warning.message),
8852 colors.muted.fg("Run"),
8853 colors.accent.fg(&warning.command),
8854 colors.muted.fg(" to retry.")
8855 );
8856 add_update_panel(&group, "Update Failed", &body, colors.error);
8857 }
8858
8859 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8860 let body = format!(
8861 "{} {}{}",
8862 colors
8863 .muted
8864 .fg(&format!("New version {} is available. Run", notice.latest)),
8865 colors.accent.fg(¬ice.command),
8866 colors.muted.fg(".")
8867 );
8868 add_update_panel(&group, "Update Available", &body, colors.warning);
8869 }
8870
8871 let package_notices = report
8872 .notices
8873 .iter()
8874 .filter(|notice| notice.name != "rpi")
8875 .collect::<Vec<_>>();
8876 if !package_notices.is_empty() {
8877 let command = package_notices[0].command.as_str();
8878 let mut lines = vec![format!(
8879 "{} {}{}",
8880 colors.muted.fg("Package updates are available. Run"),
8881 colors.accent.fg(command),
8882 colors.muted.fg(".")
8883 )];
8884 lines.push(colors.muted.fg("Packages:"));
8885 lines.extend(
8886 package_notices
8887 .into_iter()
8888 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8889 );
8890 add_update_panel(
8891 &group,
8892 "Package Updates Available",
8893 &lines.join("\n"),
8894 colors.warning,
8895 );
8896 }
8897
8898 if group.child_count() > 0 {
8902 container.add_child(group);
8903 }
8904}
8905
8906fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8907 container.add_child(Arc::new(Spacer::new(1)));
8908 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8909 container.add_child(Arc::new(Text::new(
8910 format!("{}\n{body}", color.fg(&tui_bold(title))),
8911 1,
8912 0,
8913 )));
8914 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8915}
8916
8917fn welcome_capability_line(label: &str, names: &[String]) -> String {
8918 let c = current_theme().colors;
8919 let value = if names.is_empty() {
8920 "none".to_string()
8921 } else {
8922 names.join(" · ")
8923 };
8924 format!(
8925 "{} {}",
8926 c.accent.fg(&format!("{label} ({})", names.len())),
8927 c.muted.fg(&value)
8928 )
8929}
8930
8931fn add_help_message(container: &Arc<Container>) {
8933 let c = current_theme().colors;
8934 container.add_child(Arc::new(Text::new(
8938 c.md_heading.fg(&tui_bold("📚 Available Commands")),
8939 1,
8940 0,
8941 )));
8942 container.add_child(Arc::new(Spacer::new(1)));
8943
8944 let cmds: &[(&str, &str)] = &[
8945 ("/help, /?", "Show this help message"),
8946 ("/clear, /new", "Clear the conversation"),
8947 ("/exit, /quit, /q", "Exit the application"),
8948 ("/version, /v", "Show version information"),
8949 ("/changelog", "Show recent release changes"),
8950 ("/model, /m", "Choose a model (live switch)"),
8951 ("/thinking, /think", "Set reasoning depth (selector)"),
8952 ("/tools", "Toggle built-in tools on/off"),
8953 ("/images", "Toggle inline image rendering"),
8954 ("/session", "List saved sessions"),
8955 ("/theme", "Choose a theme (selector)"),
8956 ("/compact", "Compact the conversation"),
8957 ("/copy", "Copy last reply to clipboard"),
8958 ("/hotkeys", "Show keyboard shortcuts"),
8959 ("/armin", "🐾 Easter egg"),
8960 ("/earendil", "Earendil announcement"),
8961 ];
8962 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8963 for (cmd, desc) in cmds {
8964 let row = format!(
8965 " {:<cmd_w$} {} {}",
8966 c.accent.fg(cmd),
8967 c.dim.fg("—"),
8968 c.muted.fg(desc)
8969 );
8970 container.add_child(Arc::new(Text::new(row, 1, 0)));
8971 }
8972 container.add_child(Arc::new(Spacer::new(1)));
8973}
8974
8975fn add_version_message(container: &Arc<Container>) {
8977 let c = current_theme().colors;
8978 container.add_child(Arc::new(Text::new(
8979 c.md_heading.fg(&tui_bold("📦 Version Information")),
8980 1,
8981 0,
8982 )));
8983 container.add_child(Arc::new(Spacer::new(1)));
8984 container.add_child(Arc::new(Text::new(
8987 format!(
8988 " {} {}",
8989 c.muted.fg("rpi-cli"),
8990 c.text.fg(&format!("v{}", crate::VERSION))
8991 ),
8992 1,
8993 0,
8994 )));
8995 container.add_child(Arc::new(Text::new(
8996 format!(
8997 " {}",
8998 c.dim.fg("Rust implementation of pi coding agent TUI")
8999 ),
9000 1,
9001 0,
9002 )));
9003 container.add_child(Arc::new(Spacer::new(1)));
9004}
9005
9006fn add_changelog_message(container: &Arc<Container>) {
9010 let c = current_theme().colors;
9011 container.add_child(Arc::new(Text::new(
9012 c.md_heading.fg(&tui_bold("Recent Changes")),
9013 1,
9014 0,
9015 )));
9016 container.add_child(Arc::new(Spacer::new(1)));
9017 let entries = [
9018 (
9019 "Native parity phase 1",
9020 "models, images, trust, export, and JSON events",
9021 ),
9022 (
9023 "TUI controls",
9024 "external editor, thinking levels, and tool output toggles",
9025 ),
9026 (
9027 "Provider auth",
9028 "OpenAI-compatible API key aliases and gateway headers",
9029 ),
9030 ];
9031 for (release, summary) in entries {
9032 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
9033 container.add_child(Arc::new(Text::new(row, 1, 0)));
9034 }
9035 container.add_child(Arc::new(Text::new(
9036 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
9037 1,
9038 0,
9039 )));
9040 container.add_child(Arc::new(Spacer::new(1)));
9041}
9042
9043fn add_hotkeys_message(container: &Arc<Container>) {
9045 let c = current_theme().colors;
9046 container.add_child(Arc::new(Text::new(
9047 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
9048 1,
9049 0,
9050 )));
9051 container.add_child(Arc::new(Spacer::new(1)));
9052 let keys: &[(&str, &str)] = &[
9053 ("Enter", "Send message"),
9054 ("Shift+Enter", "New line"),
9055 ("Tab", "Accept autocomplete suggestion"),
9056 ("Ctrl+A / Ctrl+E", "Line start / end"),
9057 (
9058 "Ctrl+K / Ctrl+U",
9059 "Kill to end / start of line (Ctrl+Y yanks)",
9060 ),
9061 ("Ctrl+- / Ctrl+R", "Undo / redo"),
9062 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
9063 ("Alt+Backspace", "Kill previous word"),
9064 ("Ctrl+C", "Abort a run, or exit when idle"),
9065 ("Esc", "Abort a running prompt"),
9066 ("Ctrl+L", "Open model selector"),
9067 ("Ctrl+M", "Cycle to the next model (live)"),
9068 ("Ctrl+O", "Expand/collapse all tool output"),
9069 ("Ctrl+T", "Show/hide reasoning blocks"),
9070 ("PageUp/Down", "Scroll transcript by one page"),
9071 ("Home / End", "Jump to transcript start / latest output"),
9072 ];
9073 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9074 for (key, desc) in keys {
9075 let row = format!(
9076 " {:<key_w$} {} {}",
9077 c.accent.fg(key),
9078 c.dim.fg("—"),
9079 c.muted.fg(desc)
9080 );
9081 container.add_child(Arc::new(Text::new(row, 1, 0)));
9082 }
9083 container.add_child(Arc::new(Spacer::new(1)));
9084}
9085
9086fn add_user_message(container: &Arc<Container>, text: &str) {
9092 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
9093 container.add_child(Arc::new(Spacer::new(1)));
9094}
9095
9096fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
9099 let width = 80; chat_container.render(width)
9101}
9102
9103fn add_error_message(container: &Arc<Container>, text: &str) {
9104 let c = current_theme().colors;
9105 let text = sanitize_error_message(text);
9106 container.add_child(Arc::new(Text::new(
9107 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
9108 1,
9109 0,
9110 )));
9111 container.add_child(Arc::new(Spacer::new(1)));
9112}
9113
9114fn sanitize_error_message(text: &str) -> String {
9119 const MAX_ERROR_CHARS: usize = 16 * 1024;
9120 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
9121 let mut count = 0;
9122 let mut escape_mode = 0u8;
9124 for ch in text.chars() {
9125 if escape_mode != 0 {
9126 match escape_mode {
9127 1 if ch == '[' => escape_mode = 2,
9128 1 if ch == ']' => escape_mode = 3,
9129 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
9130 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
9131 3 if ch == '\x07' => escape_mode = 0,
9132 _ => {}
9133 }
9134 continue;
9135 }
9136 if ch == '\x1b' {
9137 escape_mode = 1;
9138 continue;
9139 }
9140 if count >= MAX_ERROR_CHARS {
9141 result.push_str("…");
9142 break;
9143 }
9144 match ch {
9145 '\n' | '\t' => {
9146 result.push(ch);
9147 count += 1;
9148 }
9149 '\r' => {}
9150 c if c.is_control() => {}
9151 c => {
9152 result.push(c);
9153 count += 1;
9154 }
9155 }
9156 }
9157 if result.trim().is_empty() {
9158 "Provider request failed.".to_string()
9159 } else {
9160 result
9161 }
9162}
9163
9164fn add_note_message(container: &Arc<Container>, text: &str) {
9166 let c = current_theme().colors;
9167 container.add_child(Arc::new(Text::new(
9168 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
9169 1,
9170 0,
9171 )));
9172 container.add_child(Arc::new(Spacer::new(1)));
9173}
9174
9175fn show_context_panel(
9184 chat: &Arc<Container>,
9185 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
9186) {
9187 let skills = resources.skills.as_deref().unwrap_or(&[]);
9188 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
9189 let mut lines: Vec<String> = Vec::new();
9190 lines.push("📂 Discovered resources for this session:".into());
9191
9192 if skills.is_empty() {
9193 lines.push(
9194 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
9195 );
9196 } else {
9197 lines.push(format!(" Skills ({}):", skills.len()));
9198 for s in skills {
9199 let marker = if s.disable_model_invocation == Some(true) {
9200 " [hidden]"
9201 } else {
9202 ""
9203 };
9204 let desc: String = s.description.chars().take(72).collect();
9205 lines.push(format!(" • {}{marker} — {desc}", s.name));
9206 }
9207 }
9208
9209 if templates.is_empty() {
9210 lines.push(
9211 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
9212 );
9213 } else {
9214 lines.push(format!(" Prompt templates ({}):", templates.len()));
9215 for t in templates {
9216 let desc = t
9217 .description
9218 .as_deref()
9219 .unwrap_or("(no description)")
9220 .chars()
9221 .take(72)
9222 .collect::<String>();
9223 lines.push(format!(" • /{} — {desc}", t.name));
9224 }
9225 }
9226 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
9227 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
9228 lines.push(
9229 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
9230 .into(),
9231 );
9232 let body = lines.join("\n");
9233 container_note_block(chat, &body);
9234}
9235
9236fn container_note_block(container: &Arc<Container>, body: &str) {
9238 for line in body.lines() {
9239 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
9240 }
9241 container.add_child(Arc::new(Spacer::new(1)));
9242}
9243
9244pub fn is_tui_supported() -> bool {
9250 std::io::stdout().is_terminal()
9251}
9252
9253#[allow(unused_imports)]
9255use rpi_tui::Color as _Color;
9256
9257#[cfg(test)]
9258mod tests {
9259 use super::*;
9260 use rpi_tui::Component;
9261
9262 #[test]
9263 fn verbose_overrides_quiet_startup_listing() {
9264 assert!(should_show_startup_listing(false, false));
9265 assert!(should_show_startup_listing(true, false));
9266 assert!(should_show_startup_listing(true, true));
9267 assert!(!should_show_startup_listing(false, true));
9268 }
9269
9270 #[test]
9271 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
9272 let tmp = tempfile::tempdir().unwrap();
9273 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
9274 let local_root = tmp.path().join("local-package");
9275 std::fs::create_dir_all(git_root.join(".git")).unwrap();
9276 std::fs::create_dir_all(&local_root).unwrap();
9277 std::fs::write(
9278 git_root.join("package.json"),
9279 r#"{"name":"git-demo","version":"1.0.0"}"#,
9280 )
9281 .unwrap();
9282 std::fs::write(
9283 local_root.join("package.json"),
9284 r#"{"name":"local-demo","version":"1.0.0"}"#,
9285 )
9286 .unwrap();
9287 let resources = crate::packages::discover(
9288 tmp.path(),
9289 &[
9290 "git:github.com/example/repo".to_string(),
9291 local_root.to_string_lossy().into_owned(),
9292 ],
9293 );
9294 assert_eq!(resources.packages.len(), 2);
9295
9296 let filtered = git_only_update_resources(resources);
9297
9298 assert_eq!(filtered.packages.len(), 1);
9299 assert!(matches!(
9300 filtered.packages[0].source,
9301 crate::packages::PackageSource::Git
9302 ));
9303 }
9304
9305 #[test]
9306 fn trust_command_uses_the_session_cwd_after_process_chdir() {
9307 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
9308 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
9309 const TEST_NAME: &str =
9310 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
9311
9312 if std::env::var_os(CHILD_ENV).is_some() {
9313 let session_cwd = std::path::PathBuf::from(
9314 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
9315 );
9316 let changed_cwd = std::env::current_dir().unwrap();
9317
9318 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
9319
9320 assert_eq!(
9321 crate::config::project_trust_decision(&session_cwd).unwrap(),
9322 Some(true)
9323 );
9324 assert_eq!(
9325 crate::config::project_trust_decision(&changed_cwd).unwrap(),
9326 None
9327 );
9328 return;
9329 }
9330
9331 let tmp = tempfile::tempdir().unwrap();
9332 let session_cwd = tmp.path().join("session-project");
9333 let changed_cwd = tmp.path().join("extension-cwd");
9334 let agent_dir = tmp.path().join("agent");
9335 std::fs::create_dir_all(&session_cwd).unwrap();
9336 std::fs::create_dir_all(&changed_cwd).unwrap();
9337 std::fs::create_dir_all(&agent_dir).unwrap();
9338
9339 let status = std::process::Command::new(std::env::current_exe().unwrap())
9340 .arg("--exact")
9341 .arg(TEST_NAME)
9342 .arg("--nocapture")
9343 .env(CHILD_ENV, "1")
9344 .env(SESSION_CWD_ENV, &session_cwd)
9345 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
9346 .current_dir(&changed_cwd)
9347 .status()
9348 .unwrap();
9349
9350 assert!(status.success(), "child test process failed: {status}");
9351 }
9352
9353 #[test]
9354 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
9355 let global = crate::settings::Settings {
9356 editor_padding_x: Some(3),
9357 autocomplete_max_visible: Some(6),
9358 hide_thinking_block: Some(false),
9359 quiet_startup: Some(true),
9360 show_terminal_progress: Some(true),
9361 ..Default::default()
9362 };
9363 let rpi_project = crate::settings::Settings {
9364 editor_padding_x: Some(0),
9365 hide_thinking_block: Some(true),
9366 quiet_startup: Some(false),
9367 show_terminal_progress: Some(false),
9368 ..Default::default()
9369 };
9370 let pi_project = crate::settings::Settings {
9371 editor_padding_x: Some(9),
9372 autocomplete_max_visible: Some(12),
9373 quiet_startup: Some(true),
9374 ..Default::default()
9375 };
9376
9377 assert_eq!(
9378 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
9379 TuiStartupSettings {
9380 editor_padding_x: 0,
9381 autocomplete_max_visible: 12,
9382 hide_thinking: true,
9383 quiet_startup: false,
9384 show_terminal_progress: false,
9385 }
9386 );
9387 }
9388
9389 #[test]
9390 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
9391 let global = crate::settings::Settings {
9392 quiet_startup: Some(false),
9393 ..Default::default()
9394 };
9395 let rpi_project = crate::settings::Settings::default();
9396 let pi_project = crate::settings::Settings {
9397 quiet_startup: Some(true),
9398 ..Default::default()
9399 };
9400
9401 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
9402
9403 assert!(resolved.quiet_startup);
9404 }
9405
9406 #[test]
9407 fn tui_startup_settings_use_global_values_without_project_fields() {
9408 let global = crate::settings::Settings {
9409 editor_padding_x: Some(7),
9410 autocomplete_max_visible: Some(8),
9411 hide_thinking_block: Some(true),
9412 quiet_startup: Some(true),
9413 show_terminal_progress: Some(false),
9414 ..Default::default()
9415 };
9416
9417 assert_eq!(
9418 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
9419 TuiStartupSettings {
9420 editor_padding_x: 7,
9421 autocomplete_max_visible: 8,
9422 hide_thinking: true,
9423 quiet_startup: true,
9424 show_terminal_progress: false,
9425 }
9426 );
9427 }
9428
9429 #[test]
9430 fn tui_startup_settings_ignore_untrusted_project_values() {
9431 let global = crate::settings::Settings {
9432 quiet_startup: Some(false),
9433 show_terminal_progress: Some(true),
9434 ..Default::default()
9435 };
9436 let project = crate::settings::Settings {
9437 quiet_startup: Some(true),
9438 show_terminal_progress: Some(false),
9439 ..Default::default()
9440 };
9441
9442 let resolved = resolve_tui_startup_settings(&global, &[project], false);
9443
9444 assert!(!resolved.quiet_startup);
9445 assert!(resolved.show_terminal_progress);
9446 }
9447
9448 #[test]
9449 fn transcript_page_uses_viewport_with_overlap() {
9450 assert_eq!(transcript_page_size(24), 20);
9451 assert_eq!(transcript_page_size(4), 1);
9452 assert_eq!(transcript_page_size(0), 1);
9453 }
9454
9455 #[test]
9456 fn key_repeat_is_dispatched_but_release_is_not() {
9457 assert!(should_dispatch_key(KeyEventKind::Press));
9458 assert!(should_dispatch_key(KeyEventKind::Repeat));
9459 assert!(!should_dispatch_key(KeyEventKind::Release));
9460 }
9461
9462 #[test]
9463 fn key_event_encoding_matches_pi_keybinding_protocol() {
9464 let key = |code, modifiers| KeyEvent::new(code, modifiers);
9465 assert_eq!(
9466 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9467 "\r"
9468 );
9469 assert_eq!(
9470 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9471 "\x1b[13;2u"
9472 );
9473 assert_eq!(
9474 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9475 "\x1b[9;2u"
9476 );
9477 assert_eq!(
9478 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9479 "\x1b[Z"
9480 );
9481 assert_eq!(
9482 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9483 "\x1b[Z"
9484 );
9485 assert_eq!(
9486 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9487 "\x03"
9488 );
9489 assert_eq!(
9490 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9491 "\x0f"
9492 );
9493 assert_eq!(
9494 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9495 "!"
9496 );
9497 assert_eq!(
9498 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9499 "1"
9500 );
9501 }
9502
9503 #[test]
9504 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9505 let bridge = JsDialogBridge::default();
9506 let (sender, receiver) = std_mpsc::channel();
9507 bridge.pending.lock().unwrap().push_back(JsDialogPending {
9508 request: JsDialogRequest {
9509 id: "dialog-1".into(),
9510 method: "input".into(),
9511 title: String::new(),
9512 message: String::new(),
9513 options: Vec::new(),
9514 placeholder: None,
9515 prefill: None,
9516 },
9517 result: sender,
9518 });
9519
9520 bridge.cancel("dialog-1");
9523 assert!(bridge.take_pending().is_none());
9524 assert_eq!(
9525 receiver.recv().unwrap(),
9526 serde_json::json!({ "cancelled": true })
9527 );
9528 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9529 }
9530
9531 #[test]
9532 fn test_layout_renders_welcome_message() {
9533 let chat = Arc::new(Container::new());
9534 add_welcome_message(&chat);
9535
9536 let scroll = Arc::new(ScrollView::new(
9537 chat.clone(),
9538 ScrollViewOptions {
9539 follow: FollowMode::End,
9540 primary: true,
9541 ..Default::default()
9542 },
9543 ));
9544
9545 let editor = Arc::new(Editor::new(
9546 EditorOptions {
9547 padding_x: 1,
9548 ..Default::default()
9549 },
9550 EditorStyle::default(),
9551 Arc::new(rpi_tui::Keybindings::new()),
9552 ));
9553 let dock = Arc::new(Container::new());
9554 dock.add_child(editor);
9555
9556 let footer = Arc::new(FooterComponent::new());
9557
9558 let root = VStack::from_children(vec![
9559 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9560 StackChild::Entry(StackEntry::new(dock)),
9561 StackChild::Entry(StackEntry::new(footer)),
9562 ]);
9563
9564 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9565
9566 let all: String = frame.lines.join("\n");
9567 assert!(
9568 all.contains("rpi"),
9569 "Welcome message not found. Rendered: {}",
9570 all
9571 );
9572 assert!(
9573 all.contains("Type your message"),
9574 "Help text not found. Rendered: {}",
9575 all
9576 );
9577 }
9578
9579 #[test]
9580 fn test_chat_container_has_welcome_content() {
9581 let chat = Arc::new(Container::new());
9582 add_welcome_message_with_capabilities(
9583 &chat,
9584 &["read".into(), "bash".into(), "web_fetch".into()],
9585 &["rust-review".into(), "release".into()],
9586 );
9587
9588 let lines = chat.render(80);
9589 let all: String = lines.join("\n");
9590 let plain = strip_ansi(&all);
9593 assert!(
9594 plain.contains("rpi"),
9595 "Welcome message not in chat container: {:?}",
9596 lines
9597 );
9598 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9599 assert!(
9600 plain.contains("read · bash · web_fetch"),
9601 "Tool names missing: {plain}"
9602 );
9603 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9604 assert!(
9605 plain.contains("rust-review · release"),
9606 "Skill names missing: {plain}"
9607 );
9608 }
9609
9610 #[test]
9611 fn update_notices_render_inside_the_transcript() {
9612 let chat = Arc::new(Container::new());
9613 let report = crate::updates::UpdateReport {
9614 notices: vec![
9615 crate::updates::UpdateNotice {
9616 name: "rpi".into(),
9617 current: "0.1.10".into(),
9618 latest: "0.1.11".into(),
9619 command: "rpi update".into(),
9620 },
9621 crate::updates::UpdateNotice {
9622 name: "rpi-search".into(),
9623 current: "0.1.0".into(),
9624 latest: "0.1.1".into(),
9625 command: "rpi pi-package update".into(),
9626 },
9627 ],
9628 warnings: vec![crate::updates::UpdateWarning {
9629 message: "The previously scheduled rpi update failed: access denied".into(),
9630 command: "rpi update".into(),
9631 }],
9632 };
9633
9634 add_update_notices(&chat, &report);
9635
9636 assert_eq!(chat.child_count(), 1);
9637 let plain = strip_ansi(&chat.render(80).join("\n"));
9638 assert!(plain.contains("Update Failed"), "{plain}");
9639 assert!(
9640 plain.contains("rpi update failed: access denied"),
9641 "{plain}"
9642 );
9643 assert!(plain.contains("Update Available"), "{plain}");
9644 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9645 assert!(plain.contains("rpi update"), "{plain}");
9646 assert!(plain.contains("Package Updates Available"), "{plain}");
9647 assert!(plain.contains("rpi pi-package update"), "{plain}");
9648 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9649 }
9650
9651 #[test]
9652 fn empty_update_report_does_not_add_transcript_content() {
9653 let chat = Arc::new(Container::new());
9654
9655 add_update_notices(&chat, &crate::updates::UpdateReport::default());
9656
9657 assert_eq!(chat.child_count(), 0);
9658 }
9659
9660 #[test]
9661 fn skill_reads_are_detected_by_path() {
9662 let name = skill_tool_name(
9663 "read",
9664 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9665 );
9666 assert_eq!(name.as_deref(), Some("release"));
9667
9668 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9669 assert!(name.is_none());
9670
9671 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9673 }
9674
9675 #[test]
9676 fn welcome_capabilities_show_empty_state() {
9677 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9678 assert_eq!(plain, "Skills (0) none");
9679 }
9680
9681 #[test]
9682 fn empty_tool_progress_is_deferred_until_start() {
9683 let empty = rpi_agent::AgentToolResult::default();
9684 assert!(!tool_update_has_payload("", &empty));
9685
9686 let text = rpi_agent::AgentToolResult::text("partial output");
9687 assert!(tool_update_has_payload("partial output", &text));
9688
9689 let details = rpi_agent::AgentToolResult {
9690 details: serde_json::json!({"path": "src/lib.rs"}),
9691 ..Default::default()
9692 };
9693 assert!(tool_update_has_payload("", &details));
9694 }
9695
9696 #[test]
9697 fn completed_stream_reconciles_the_final_tail_before_detaching() {
9698 let component = Arc::new(AssistantMessageComponent::new(
9699 AssistantMessageOptions::default(),
9700 ));
9701 component.set_streaming(true);
9702 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9703
9704 let current = Mutex::new(Some(component.clone()));
9705 let cached = Mutex::new("partial response".to_string());
9706 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9707 final_message.content = vec![Content::text(
9708 "partial response with the previously missing final tail",
9709 )];
9710 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9711
9712 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
9713
9714 assert!(current.lock().unwrap().is_none());
9715 assert_eq!(
9716 cached.lock().unwrap().as_str(),
9717 "partial response with the previously missing final tail"
9718 );
9719 let rendered = strip_ansi(&component.render(100).join("\n"));
9720 assert!(
9721 rendered.contains("previously missing final tail"),
9722 "{rendered}"
9723 );
9724 }
9725
9726 #[test]
9732 fn tab_accept_suggestion_reflects_in_next_render() {
9733 use rpi_tui::render_layout_frame;
9734
9735 let editor = Arc::new(Editor::new(
9736 EditorOptions {
9737 padding_x: 1,
9738 ..Default::default()
9739 },
9740 EditorStyle::default(),
9741 Arc::new(rpi_tui::Keybindings::new()),
9742 ));
9743 editor.set_focused(true);
9744 let editor_container = Arc::new(Container::new());
9745 editor_container.add_child(editor.clone());
9746 let autocomplete_container = Arc::new(Container::new());
9747 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9748 let dock = Arc::new(VStack::from_children(vec![
9749 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9750 StackChild::Entry(
9751 StackEntry::new(editor_container.clone())
9752 .shrink(0)
9753 .min_size(3),
9754 ),
9755 StackChild::Entry(StackEntry::new(footer)),
9756 ]));
9757
9758 let mut manager = AutocompleteManager::new();
9760 let mut combined = CombinedAutocompleteProvider::new();
9761 combined.add_provider(Arc::new(
9762 SlashCommandAutocompleteProvider::with_default_commands(),
9763 ));
9764 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9765 manager.set_provider(Arc::new(combined));
9766 editor.insert("/mo");
9769 assert_eq!(editor.cursor_position(), (0, 3));
9770
9771 let frame_before = render_layout_frame(dock.clone(), 80, 10);
9772 assert!(
9773 frame_before.lines.iter().any(|l| l.contains("/mo")),
9774 "precondition: editor shows the typed prefix. Frame rows:\n{}",
9775 frame_before
9776 .lines
9777 .iter()
9778 .map(|l| format!(" [{l}]"))
9779 .collect::<Vec<_>>()
9780 .join("\n")
9781 );
9782
9783 let text = editor.get_text();
9785 let cursor = editor_cursor_offset(&editor, &text);
9786 let sugg = manager
9787 .get_suggestions(&text, cursor)
9788 .expect("slash suggestions for /mo");
9789 let top = sugg.items.first().expect("at least one suggestion");
9790 let start = sugg.start.min(text.len());
9791 let end = sugg.end.min(text.len());
9792 let mut replaced = String::new();
9793 replaced.push_str(&text[..start]);
9794 replaced.push_str(&top.text);
9795 replaced.push_str(&text[end..]);
9796 if top.insert_space && !replaced.ends_with('/') {
9797 replaced.push(' ');
9798 }
9799 let new_cursor = start + top.text.len();
9800 editor.set_text(&replaced);
9801 set_editor_cursor_offset(&editor, &replaced, new_cursor);
9802 autocomplete_container.clear();
9803 assert_eq!(editor.get_text(), "/model");
9804
9805 let frame_after = render_layout_frame(dock, 80, 10);
9807 let all: String = frame_after.lines.join("\n");
9808 assert!(
9809 all.contains("/model"),
9810 "completed text missing from next render. Got:\n{all}"
9811 );
9812 let editor_line = frame_after
9816 .lines
9817 .iter()
9818 .find(|l| l.contains("/model"))
9819 .expect("editor row with completed text");
9820 assert!(
9821 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9822 "caret must follow the full completed text. Got: {editor_line:?}"
9823 );
9824 }
9825
9826 #[test]
9827 fn multiline_autocomplete_preserves_row_and_column() {
9828 let editor = Arc::new(Editor::simple());
9829 editor.set_text("first\n/mo");
9830 editor.set_cursor(1, 3);
9831
9832 let text = editor.get_text();
9833 assert_eq!(editor_cursor_offset(&editor, &text), 9);
9834
9835 let replaced = "first\n/model";
9836 editor.set_text(replaced);
9837 set_editor_cursor_offset(&editor, replaced, 12);
9838 assert_eq!(editor.cursor_position(), (1, 6));
9839 }
9840
9841 #[test]
9842 fn test_slash_command_dispatch() {
9843 let registry = build_builtin_registry();
9848
9849 let resolves_to = |token: &str, canonical: &str| {
9851 let found = registry.find(token).expect("{token} should resolve");
9852 assert_eq!(
9853 found.name(),
9854 canonical,
9855 "{token} resolved to {} (expected {canonical})",
9856 found.name()
9857 );
9858 };
9859
9860 resolves_to("/help", "/help");
9861 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
9863 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
9867 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
9869 resolves_to("/hotkeys", "/hotkeys");
9870 resolves_to("/model", "/model");
9871 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
9873 resolves_to("/session", "/session");
9874 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
9876 resolves_to("/copy", "/copy");
9877 resolves_to("/thinking", "/thinking");
9878 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
9880 resolves_to("/images", "/images");
9881 resolves_to("/armin", "/armin");
9882 resolves_to("/earendil", "/earendil");
9883 resolves_to("/context", "/context");
9884 resolves_to("/settings", "/settings");
9886 resolves_to("/name", "/name");
9887 resolves_to("/export", "/export");
9888
9889 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9891 }
9892
9893 #[test]
9894
9895 fn test_registry_visible_entries_cover_dispatch() {
9896 let registry = build_builtin_registry();
9901 let names: Vec<String> = registry
9902 .visible_entries()
9903 .iter()
9904 .map(|c| c.name.clone())
9905 .collect();
9906 for recognized in [
9907 "/help",
9908 "/clear",
9909 "/new",
9910 "/exit",
9911 "/quit",
9912 "/version",
9913 "/changelog",
9914 "/model",
9915 "/session",
9916 "/theme",
9917 "/compact",
9918 "/copy",
9919 "/hotkeys",
9920 "/tools",
9921 "/images",
9922 "/thinking",
9923 "/usage",
9924 "/armin",
9925 "/earendil",
9926 ] {
9927 assert!(
9928 names.contains(&recognized.to_string()),
9929 "{recognized} missing from autocomplete list"
9930 );
9931 }
9932 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9934 assert!(
9935 !names.contains(&hidden.to_string()),
9936 "{hidden} should be hidden from autocomplete"
9937 );
9938 }
9939 }
9940
9941 #[test]
9942 fn test_agent_event_mapping_creates_assistant_and_tool() {
9943 use rpi_ai::types::{
9946 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9947 ToolCall, ToolCallType, Usage,
9948 };
9949
9950 let state = Arc::new(TuiState {
9951 current_assistant: std::sync::Mutex::new(None),
9952 tool_components: std::sync::Mutex::new(HashMap::new()),
9953 bash_components: std::sync::Mutex::new(HashMap::new()),
9954 themes_enabled: true,
9955 hide_thinking: std::sync::Mutex::new(false),
9956 tool_outputs_expanded: std::sync::Mutex::new(false),
9957 show_terminal_progress: true,
9958 status: std::sync::Mutex::new(RunStatus::Idle),
9959 js_preparation_cancel: std::sync::Mutex::new(None),
9960 footer: Arc::new(FooterComponent::new()),
9961 status_container: Arc::new(Container::new()),
9962 chat_container: Arc::new(Container::new()),
9963 loader: Arc::new(Loader::new()),
9964 last_assistant_text: std::sync::Mutex::new(String::new()),
9965 active_selector: std::sync::Mutex::new(None),
9966 active_extension_editor: std::sync::Mutex::new(None),
9967 active_extension_input: std::sync::Mutex::new(None),
9968 active_extension_cancel: std::sync::Mutex::new(None),
9969 autocomplete: AutocompleteManager::new(),
9970 autocomplete_container: Arc::new(Container::new()),
9971 autocomplete_max_visible: 5,
9972 pending_images: std::sync::Mutex::new(Vec::new()),
9973 theme_manager: Arc::new(ThemeManager::new()),
9974 tui: None,
9975 current_model_id: std::sync::Mutex::new(String::new()),
9976 show_images: std::sync::Mutex::new(true),
9977 history: std::sync::Mutex::new(Vec::new()),
9978 history_index: std::sync::Mutex::new(-1),
9979 history_draft: std::sync::Mutex::new(None),
9980 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
9981 scoped_edit: std::sync::Mutex::new(None),
9982 markdown_transformer: std::sync::Mutex::new(None),
9983 extension_session: Arc::new(std::sync::Mutex::new(
9984 rpi_extensions::ExtensionSession::none(),
9985 )),
9986 search: Arc::new(AltScreenSearch::new()),
9987 search_bar: Arc::new(SearchBar::new()),
9988 selection_start: std::sync::Mutex::new(None),
9989 selection_end: std::sync::Mutex::new(None),
9990 });
9991
9992 let assistant = AssistantMessage {
9996 role: rpi_ai::types::AssistantRole,
9997 content: vec![
9998 Content::Thinking(ThinkingContent {
9999 kind: ThinkingContentType,
10000 thinking: "Reasoning about the reply.".into(),
10001 thinking_signature: None,
10002 redacted: false,
10003 }),
10004 Content::Text(TextContent {
10005 kind: TextContentType,
10006 text: "Hello.".into(),
10007 text_signature: None,
10008 }),
10009 Content::ToolCall(ToolCall {
10010 kind: ToolCallType,
10011 id: "tc1".into(),
10012 name: "bash".into(),
10013 arguments: serde_json::json!({"command": "echo hi"}),
10014 thought_signature: None,
10015 namespace: None,
10016 }),
10017 ],
10018 api: rpi_ai::Api::AnthropicMessages,
10019 provider: "anthropic".into(),
10020 model: "claude-sonnet-5".into(),
10021 response_model: None,
10022 response_id: None,
10023 usage: Usage::zero(),
10024 stop_reason: StopReason::Stop,
10025 deferred: None,
10026 error_message: None,
10027 raw_stop_reason: None,
10028 end_turn: None,
10029 timestamp: 0,
10030 };
10031
10032 let comp = Arc::new(AssistantMessageComponent::new(
10035 AssistantMessageOptions::default(),
10036 ));
10037 comp.set_streaming(true);
10038 comp.update_blocks(&assistant_blocks(&assistant));
10039 let chat = Arc::new(Container::new());
10040 chat.add_child(comp.clone());
10041 *state.current_assistant.lock().unwrap() = Some(comp);
10042
10043 for c in &assistant.content {
10045 if let Content::ToolCall(tc) = c {
10046 let mut tools = state.tool_components.lock().unwrap();
10047 if !tools.contains_key(&tc.id) {
10048 let tc_comp = Arc::new(ToolExecutionComponent::new(
10049 &tc.name,
10050 &tc.arguments.to_string(),
10051 ));
10052 tc_comp.set_running();
10053 chat.add_child(tc_comp.clone());
10054 tools.insert(tc.id.clone(), tc_comp);
10055 }
10056 }
10057 }
10058
10059 let rendered = chat.render(80);
10063 let joined: String = rendered.join("\n");
10064 assert!(
10065 joined.contains("Hello."),
10066 "assistant text not rendered: {joined}"
10067 );
10068 assert!(
10069 joined.contains("Reasoning about the reply."),
10070 "thinking block not rendered: {joined}"
10071 );
10072 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
10073 assert!(state.current_assistant.lock().unwrap().is_some());
10074
10075 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
10077 ended.set_result("hi", false);
10078 assert!(state.tool_components.lock().unwrap().is_empty());
10079
10080 assert!(state.try_start_working());
10084 assert!(
10085 !state.try_start_working(),
10086 "a second submit must be rejected"
10087 );
10088 state.set_status(RunStatus::Idle);
10089 state.set_status(RunStatus::Working);
10090 assert_eq!(state.status_container.child_count(), 1);
10091 assert!(state.footer.get_status().is_empty());
10092 state.show_retry(3, 10, 8_000);
10093 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
10094 assert!(retry_status.contains("Retrying (3/10)"));
10095 state.set_status(RunStatus::Working);
10096 {
10097 let mut bash = state.bash_components.lock().unwrap();
10098 bash.insert(
10099 "bash-1".into(),
10100 Arc::new(BashExecutionComponent::new("one")),
10101 );
10102 bash.insert(
10103 "bash-2".into(),
10104 Arc::new(BashExecutionComponent::new("two")),
10105 );
10106 }
10107 state.sync_working_loader_with_bash();
10108 assert_eq!(state.status_container.child_count(), 0);
10109 state.bash_components.lock().unwrap().remove("bash-1");
10110 state.sync_working_loader_with_bash();
10111 assert_eq!(state.status_container.child_count(), 0);
10112 state.bash_components.lock().unwrap().remove("bash-2");
10113 state.sync_working_loader_with_bash();
10114 assert_eq!(state.status_container.child_count(), 1);
10115
10116 state.set_status(RunStatus::Aborting);
10117 assert_eq!(state.status_container.child_count(), 0);
10118 assert!(!state.loader.is_running());
10119 }
10120
10121 #[test]
10122 fn fresh_launch_does_not_restore_old_history() {
10123 let fresh = Args::default();
10124 assert!(!launch_restores_history(&fresh));
10125
10126 let continued = Args {
10127 continue_session: true,
10128 ..Args::default()
10129 };
10130 assert!(launch_restores_history(&continued));
10131
10132 let selected = Args {
10133 session: Some("session-id".into()),
10134 ..Args::default()
10135 };
10136 assert!(launch_restores_history(&selected));
10137 }
10138
10139 #[test]
10140 fn test_short_model_name() {
10141 assert_eq!(
10142 short_model_name("anthropic:claude-sonnet-5"),
10143 "claude-sonnet-5"
10144 );
10145 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
10146 }
10147
10148 #[test]
10149 fn model_selector_items_are_deduplicated_and_provider_qualified() {
10150 use rpi_ai::{Api, Model};
10151
10152 let mut gateway = Model::new(
10153 "gpt-5.6-sol",
10154 "GPT 5.6 Sol",
10155 Api::OpenaiCompletions,
10156 "routeryo-copy",
10157 "https://gateway.example.com",
10158 );
10159 let duplicate = gateway.clone();
10160 let anthropic = Model::new(
10161 "claude-sonnet-5",
10162 "Claude Sonnet 5",
10163 Api::AnthropicMessages,
10164 "anthropic",
10165 "https://api.anthropic.com",
10166 );
10167 gateway.headers = Some(std::collections::BTreeMap::from([(
10168 "authorization".into(),
10169 "Bearer test".into(),
10170 )]));
10171
10172 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
10173 assert_eq!(items.len(), 2);
10174 assert_eq!(items[0].value, "gpt-5.6-sol");
10175 assert_eq!(items[0].label, "GPT 5.6 Sol");
10176 assert_eq!(
10177 items[0].description.as_deref(),
10178 Some("routeryo-copy/gpt-5.6-sol (current)")
10179 );
10180 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
10181 }
10182
10183 #[test]
10184 fn model_selector_match_accepts_bare_and_qualified_ids() {
10185 use rpi_ai::{Api, Model};
10186
10187 let gateway = Model::new(
10188 "gpt-5.6-sol",
10189 "GPT 5.6 Sol",
10190 Api::OpenaiCompletions,
10191 "routeryo-copy",
10192 "https://gateway.example.com",
10193 );
10194 let anthropic = Model::new(
10195 "claude-sonnet-5",
10196 "Claude Sonnet 5",
10197 Api::AnthropicMessages,
10198 "anthropic",
10199 "https://api.anthropic.com",
10200 );
10201 let catalog = [gateway, anthropic];
10202 assert_eq!(
10203 find_model_selector_match(&catalog, "gpt-5.6-sol")
10204 .unwrap()
10205 .provider,
10206 "routeryo-copy"
10207 );
10208 assert_eq!(
10209 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
10210 .unwrap()
10211 .id,
10212 "gpt-5.6-sol"
10213 );
10214 assert_eq!(
10215 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
10216 .unwrap()
10217 .id,
10218 "claude-sonnet-5"
10219 );
10220 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
10221 }
10222
10223 #[test]
10224 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
10225 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
10226
10227 let failed = AssistantMessage {
10228 role: AssistantRole,
10229 content: Vec::new(),
10230 api: rpi_ai::Api::AnthropicMessages,
10231 provider: "anthropic".into(),
10232 model: "claude-sonnet-5".into(),
10233 response_model: None,
10234 response_id: None,
10235 usage: Usage::zero(),
10236 stop_reason: StopReason::Error,
10237 deferred: None,
10238 error_message: Some("upstream returned 401".into()),
10239 raw_stop_reason: None,
10240 end_turn: None,
10241 timestamp: 0,
10242 };
10243 assert_eq!(
10244 assistant_error_text(&failed).as_deref(),
10245 Some("upstream returned 401")
10246 );
10247
10248 let mut no_detail = failed;
10249 no_detail.error_message = Some(" ".into());
10250 assert_eq!(
10251 assistant_error_text(&no_detail).as_deref(),
10252 Some("Provider request failed.")
10253 );
10254
10255 let mut aborted = no_detail;
10256 aborted.stop_reason = StopReason::Aborted;
10257 aborted.error_message = Some("abort error: Request aborted".into());
10258 assert_eq!(
10259 assistant_error_text(&aborted).as_deref(),
10260 Some("abort error: Request aborted")
10261 );
10262
10263 aborted.error_message = None;
10264 assert_eq!(
10265 assistant_error_text(&aborted).as_deref(),
10266 Some("Request aborted.")
10267 );
10268 }
10269
10270 #[test]
10271 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
10272 assert_eq!(
10273 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
10274 "405\nMethod Not Allowed"
10275 );
10276 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
10277 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
10278 let long = "x".repeat(20_000);
10279 let cleaned = sanitize_error_message(&long);
10280 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
10281 assert!(cleaned.ends_with('…'));
10282 }
10283
10284 #[test]
10285 fn test_cycle_next_model_wraps_around() {
10286 use rpi_ai::{Api, Model};
10287 let mk = |id: &str| {
10288 Model::new(
10289 id,
10290 id,
10291 Api::AnthropicMessages,
10292 "anthropic",
10293 "https://api.anthropic.com",
10294 )
10295 };
10296 let catalog = [mk("a"), mk("b"), mk("c")];
10297 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
10299 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
10300 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
10302 let empty: Vec<Model> = vec![];
10304 assert!(cycle_next_model(&empty, "a").is_none());
10305 }
10306
10307 #[test]
10308 fn test_autocomplete_slash_suggestions_render() {
10309 let state = Arc::new(TuiState {
10312 current_assistant: std::sync::Mutex::new(None),
10313 tool_components: std::sync::Mutex::new(HashMap::new()),
10314 bash_components: std::sync::Mutex::new(HashMap::new()),
10315 themes_enabled: true,
10316 hide_thinking: std::sync::Mutex::new(false),
10317 tool_outputs_expanded: std::sync::Mutex::new(false),
10318 show_terminal_progress: true,
10319 status: std::sync::Mutex::new(RunStatus::Idle),
10320 js_preparation_cancel: std::sync::Mutex::new(None),
10321 footer: Arc::new(FooterComponent::new()),
10322 status_container: Arc::new(Container::new()),
10323 chat_container: Arc::new(Container::new()),
10324 loader: Arc::new(Loader::new()),
10325 last_assistant_text: std::sync::Mutex::new(String::new()),
10326 active_selector: std::sync::Mutex::new(None),
10327 active_extension_editor: std::sync::Mutex::new(None),
10328 active_extension_input: std::sync::Mutex::new(None),
10329 active_extension_cancel: std::sync::Mutex::new(None),
10330 autocomplete: AutocompleteManager::new(),
10331 autocomplete_container: Arc::new(Container::new()),
10332 autocomplete_max_visible: 5,
10333 pending_images: std::sync::Mutex::new(Vec::new()),
10334 theme_manager: Arc::new(ThemeManager::new()),
10335 tui: None,
10336 current_model_id: std::sync::Mutex::new(String::new()),
10337 show_images: std::sync::Mutex::new(true),
10338 history: std::sync::Mutex::new(Vec::new()),
10339 history_index: std::sync::Mutex::new(-1),
10340 history_draft: std::sync::Mutex::new(None),
10341 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10342 scoped_edit: std::sync::Mutex::new(None),
10343 markdown_transformer: std::sync::Mutex::new(None),
10344 extension_session: Arc::new(std::sync::Mutex::new(
10345 rpi_extensions::ExtensionSession::none(),
10346 )),
10347 search: Arc::new(AltScreenSearch::new()),
10348 search_bar: Arc::new(SearchBar::new()),
10349 selection_start: std::sync::Mutex::new(None),
10350 selection_end: std::sync::Mutex::new(None),
10351 });
10352 {
10353 let mut combined = CombinedAutocompleteProvider::new();
10354 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10355 build_builtin_registry().visible_entries(),
10356 )));
10357 state.autocomplete.set_provider(Arc::new(combined));
10358 }
10359
10360 let editor = Arc::new(Editor::simple());
10361 editor.set_text("/he");
10362 editor.set_cursor(0, 3);
10363 refresh_autocomplete(&state, &editor);
10364 let lines = state.autocomplete_container.render(80);
10365 let joined: String = lines.join("\n");
10366 assert!(
10367 joined.contains("/help"),
10368 "slash suggestions not rendered: {joined}"
10369 );
10370
10371 editor.set_text("hello");
10373 editor.set_cursor(0, 5);
10374 refresh_autocomplete(&state, &editor);
10375 assert!(state.autocomplete_container.render(80).is_empty());
10376 }
10377
10378 #[test]
10379 fn test_select_list_swap_restores_editor() {
10380 let state = Arc::new(TuiState {
10384 current_assistant: std::sync::Mutex::new(None),
10385 tool_components: std::sync::Mutex::new(HashMap::new()),
10386 bash_components: std::sync::Mutex::new(HashMap::new()),
10387 themes_enabled: true,
10388 hide_thinking: std::sync::Mutex::new(false),
10389 tool_outputs_expanded: std::sync::Mutex::new(false),
10390 show_terminal_progress: true,
10391 status: std::sync::Mutex::new(RunStatus::Idle),
10392 js_preparation_cancel: std::sync::Mutex::new(None),
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 last_assistant_text: std::sync::Mutex::new(String::new()),
10398 active_selector: std::sync::Mutex::new(None),
10399 active_extension_editor: std::sync::Mutex::new(None),
10400 active_extension_input: std::sync::Mutex::new(None),
10401 active_extension_cancel: std::sync::Mutex::new(None),
10402 autocomplete: AutocompleteManager::new(),
10403 autocomplete_container: Arc::new(Container::new()),
10404 autocomplete_max_visible: 5,
10405 pending_images: std::sync::Mutex::new(Vec::new()),
10406 theme_manager: Arc::new(ThemeManager::new()),
10407 tui: None,
10408 current_model_id: std::sync::Mutex::new(String::new()),
10409 show_images: std::sync::Mutex::new(true),
10410 history: std::sync::Mutex::new(Vec::new()),
10411 history_index: std::sync::Mutex::new(-1),
10412 history_draft: std::sync::Mutex::new(None),
10413 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10414 scoped_edit: std::sync::Mutex::new(None),
10415 markdown_transformer: std::sync::Mutex::new(None),
10416 extension_session: Arc::new(std::sync::Mutex::new(
10417 rpi_extensions::ExtensionSession::none(),
10418 )),
10419 search: Arc::new(AltScreenSearch::new()),
10420 search_bar: Arc::new(SearchBar::new()),
10421 selection_start: std::sync::Mutex::new(None),
10422 selection_end: std::sync::Mutex::new(None),
10423 });
10424 let editor_container = Arc::new(Container::new());
10425 let editor = Arc::new(Editor::simple());
10426 editor_container.add_child(editor.clone());
10427 assert!(!state.selector_open());
10428
10429 let tui_terminal = Box::new(ProcessTerminal::new());
10430 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
10431 let list = Arc::new(SelectList::new(
10432 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
10433 5,
10434 ));
10435 open_selector(
10436 &state,
10437 &editor_container,
10438 &editor,
10439 &tui,
10440 list,
10441 SelectorKind::Theme,
10442 );
10443 assert!(state.selector_open());
10444 assert_eq!(editor_container.child_count(), 1);
10446
10447 close_selector(&state, &editor_container, &editor, &tui);
10448 assert!(!state.selector_open());
10449 assert_eq!(editor_container.child_count(), 1);
10451 }
10452
10453 #[test]
10454 fn test_message_history_browse_restores_draft() {
10455 let state = Arc::new(TuiState {
10459 current_assistant: std::sync::Mutex::new(None),
10460 tool_components: std::sync::Mutex::new(HashMap::new()),
10461 bash_components: std::sync::Mutex::new(HashMap::new()),
10462 themes_enabled: true,
10463 hide_thinking: std::sync::Mutex::new(false),
10464 tool_outputs_expanded: std::sync::Mutex::new(false),
10465 show_terminal_progress: true,
10466 status: std::sync::Mutex::new(RunStatus::Idle),
10467 js_preparation_cancel: std::sync::Mutex::new(None),
10468 footer: Arc::new(FooterComponent::new()),
10469 status_container: Arc::new(Container::new()),
10470 chat_container: Arc::new(Container::new()),
10471 loader: Arc::new(Loader::new()),
10472 last_assistant_text: std::sync::Mutex::new(String::new()),
10473 active_selector: std::sync::Mutex::new(None),
10474 active_extension_editor: std::sync::Mutex::new(None),
10475 active_extension_input: std::sync::Mutex::new(None),
10476 active_extension_cancel: std::sync::Mutex::new(None),
10477 autocomplete: AutocompleteManager::new(),
10478 autocomplete_container: Arc::new(Container::new()),
10479 autocomplete_max_visible: 5,
10480 pending_images: std::sync::Mutex::new(Vec::new()),
10481 theme_manager: Arc::new(ThemeManager::new()),
10482 tui: None,
10483 current_model_id: std::sync::Mutex::new(String::new()),
10484 show_images: std::sync::Mutex::new(true),
10485 history: std::sync::Mutex::new(Vec::new()),
10486 history_index: std::sync::Mutex::new(-1),
10487 history_draft: std::sync::Mutex::new(None),
10488 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10489 scoped_edit: std::sync::Mutex::new(None),
10490 markdown_transformer: std::sync::Mutex::new(None),
10491 extension_session: Arc::new(std::sync::Mutex::new(
10492 rpi_extensions::ExtensionSession::none(),
10493 )),
10494 search: Arc::new(AltScreenSearch::new()),
10495 search_bar: Arc::new(SearchBar::new()),
10496 selection_start: std::sync::Mutex::new(None),
10497 selection_end: std::sync::Mutex::new(None),
10498 });
10499 let editor = Arc::new(Editor::simple());
10500
10501 push_history(&state, "first message");
10502 push_history(&state, "second message");
10503 push_history(&state, "second message");
10505 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
10507 assert_eq!(state.history.lock().unwrap()[0], "second message");
10508
10509 editor.set_text("half-typed");
10511 editor.set_cursor(0, 11);
10512
10513 navigate_history(&state, &editor, -1);
10515 assert_eq!(editor.get_text(), "second message");
10516 assert_eq!(*state.history_index.lock().unwrap(), 0);
10517 navigate_history(&state, &editor, -1);
10519 assert_eq!(editor.get_text(), "first message");
10520 assert_eq!(*state.history_index.lock().unwrap(), 1);
10521 navigate_history(&state, &editor, -1);
10523 assert_eq!(editor.get_text(), "first message");
10524 navigate_history(&state, &editor, 1);
10526 assert_eq!(editor.get_text(), "second message");
10527 navigate_history(&state, &editor, 1);
10529 assert_eq!(editor.get_text(), "half-typed");
10530 assert_eq!(*state.history_index.lock().unwrap(), -1);
10531 }
10532
10533 #[test]
10534 fn test_accept_top_suggestion_replaces_prefix() {
10535 let state = Arc::new(TuiState {
10537 current_assistant: std::sync::Mutex::new(None),
10538 tool_components: std::sync::Mutex::new(HashMap::new()),
10539 bash_components: std::sync::Mutex::new(HashMap::new()),
10540 themes_enabled: true,
10541 hide_thinking: std::sync::Mutex::new(false),
10542 tool_outputs_expanded: std::sync::Mutex::new(false),
10543 show_terminal_progress: true,
10544 status: std::sync::Mutex::new(RunStatus::Idle),
10545 js_preparation_cancel: std::sync::Mutex::new(None),
10546 footer: Arc::new(FooterComponent::new()),
10547 status_container: Arc::new(Container::new()),
10548 chat_container: Arc::new(Container::new()),
10549 loader: Arc::new(Loader::new()),
10550 last_assistant_text: std::sync::Mutex::new(String::new()),
10551 active_selector: std::sync::Mutex::new(None),
10552 active_extension_editor: std::sync::Mutex::new(None),
10553 active_extension_input: std::sync::Mutex::new(None),
10554 active_extension_cancel: std::sync::Mutex::new(None),
10555 autocomplete: AutocompleteManager::new(),
10556 autocomplete_container: Arc::new(Container::new()),
10557 autocomplete_max_visible: 5,
10558 pending_images: std::sync::Mutex::new(Vec::new()),
10559 theme_manager: Arc::new(ThemeManager::new()),
10560 tui: None,
10561 current_model_id: std::sync::Mutex::new(String::new()),
10562 show_images: std::sync::Mutex::new(true),
10563 history: std::sync::Mutex::new(Vec::new()),
10564 history_index: std::sync::Mutex::new(-1),
10565 history_draft: std::sync::Mutex::new(None),
10566 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10567 scoped_edit: std::sync::Mutex::new(None),
10568 markdown_transformer: std::sync::Mutex::new(None),
10569 extension_session: Arc::new(std::sync::Mutex::new(
10570 rpi_extensions::ExtensionSession::none(),
10571 )),
10572 search: Arc::new(AltScreenSearch::new()),
10573 search_bar: Arc::new(SearchBar::new()),
10574 selection_start: std::sync::Mutex::new(None),
10575 selection_end: std::sync::Mutex::new(None),
10576 });
10577 {
10578 let mut combined = CombinedAutocompleteProvider::new();
10579 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10580 build_builtin_registry().visible_entries(),
10581 )));
10582 state.autocomplete.set_provider(Arc::new(combined));
10583 }
10584 let editor = Arc::new(Editor::simple());
10585 editor.set_text("/he");
10586 editor.set_cursor(0, 3);
10587 refresh_autocomplete(&state, &editor);
10588 let accepted = accept_top_suggestion(&state, &editor);
10589 assert!(accepted, "should accept the top suggestion");
10590 let text = editor.get_text();
10591 assert!(
10592 text.starts_with("/help"),
10593 "editor text should start with /help, got {text}"
10594 );
10595 }
10596
10597 #[test]
10598 fn configured_key_parser_supports_native_notation() {
10599 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10600 assert_eq!(combo.code, KeyCode::Char('g'));
10601 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10602 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10603 assert_eq!(combo.code, KeyCode::BackTab);
10604 }
10605
10606 #[test]
10607 fn double_escape_trigger_has_half_second_window() {
10608 let now = std::time::Instant::now();
10609 assert!(!double_escape_trigger(None, now));
10610 assert!(double_escape_trigger(
10611 Some(now - std::time::Duration::from_millis(500)),
10612 now
10613 ));
10614 assert!(!double_escape_trigger(
10615 Some(now - std::time::Duration::from_millis(501)),
10616 now
10617 ));
10618 }
10619
10620 #[test]
10621 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
10622 use std::sync::atomic::{AtomicUsize, Ordering};
10628
10629 let editor = rpi_tui::Editor::simple();
10630 let submits = Arc::new(AtomicUsize::new(0));
10631 let submits_for_cb = submits.clone();
10632 editor.on_submit(Arc::new(move |_text: &str| {
10633 submits_for_cb.fetch_add(1, Ordering::SeqCst);
10634 }));
10635
10636 let now = std::time::Instant::now();
10639 let mut last_text_key_at: Option<std::time::Instant> = None;
10640 let block = ["l1", "l2", "l3"];
10641 for (index, line) in block.iter().enumerate() {
10642 for ch in line.chars() {
10643 editor.insert(&ch.to_string());
10644 last_text_key_at = Some(now);
10645 }
10646 if index + 1 < block.len() {
10647 let more_queued = true;
10649 assert!(
10650 enter_is_paste_burst(last_text_key_at, now, more_queued),
10651 "pasted newline {index} must not submit"
10652 );
10653 editor.insert("\n");
10654 last_text_key_at = Some(now);
10655 }
10656 }
10657 assert_eq!(editor.get_text(), "l1\nl2\nl3");
10658 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
10659
10660 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
10662 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
10663 editor.handle_key(crossterm::event::KeyEvent::new(
10664 KeyCode::Enter,
10665 KeyModifiers::NONE,
10666 ));
10667 assert_eq!(submits.load(Ordering::SeqCst), 1, "typed Enter submits once");
10668 }
10669
10670 #[test]
10671 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
10672 let editor = rpi_tui::Editor::simple();
10675 editor.insert("alpha\r\nbeta\r\n");
10676 assert_eq!(editor.get_text(), "alpha\nbeta\n");
10677 assert!(!editor.get_text().contains('\r'));
10678 }
10679
10680 #[test]
10681 fn pasted_enter_is_not_a_submit() {
10682 let now = std::time::Instant::now();
10683
10684 assert!(enter_is_paste_burst(None, now, true));
10686
10687 assert!(enter_is_paste_burst(
10690 Some(now - std::time::Duration::from_millis(1)),
10691 now,
10692 false
10693 ));
10694 assert!(enter_is_paste_burst(
10695 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
10696 now,
10697 false
10698 ));
10699
10700 assert!(!enter_is_paste_burst(None, now, false));
10702 assert!(!enter_is_paste_burst(
10703 Some(now - PASTE_BURST_GAP),
10704 now,
10705 false
10706 ));
10707 assert!(!enter_is_paste_burst(
10708 Some(now - std::time::Duration::from_millis(120)),
10709 now,
10710 false
10711 ));
10712 }
10713
10714 #[test]
10717 fn ask_user_tool_names_are_recognized() {
10718 assert!(is_ask_user_tool("ask_user"));
10719 assert!(is_ask_user_tool("ask_user_question"));
10720 assert!(!is_ask_user_tool("bash"));
10721 }
10722
10723 #[test]
10724 fn ask_user_args_display_never_leaks_transport_json() {
10725 let text = ask_user_args_display(&serde_json::json!({
10726 "question": "你的代理端口是多少?",
10727 "context": "本机",
10728 "options": ["7890", {"title": "7897", "description": "clash"}],
10729 "suggest": "1080"
10730 }));
10731 assert!(text.contains("你的代理端口是多少?"));
10732 assert!(text.contains("本机"));
10733 assert!(text.contains("Choices: 7890, 7897"));
10734 assert!(text.contains("Suggestions: 1080"));
10735 assert!(!text.trim_start().starts_with('{'));
10736
10737 let multi = ask_user_args_display(&serde_json::json!({
10738 "questions": [
10739 {"id": "a", "question": "Q1"},
10740 {"id": "b", "header": "H", "question": "Q2"}
10741 ]
10742 }));
10743 assert!(multi.contains("Q1"));
10744 assert!(multi.contains("H: Q2"));
10745
10746 assert_eq!(
10747 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10748 "Confirmation requested: Deploy?"
10749 );
10750 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10751 }
10752
10753 #[test]
10754 fn ask_user_result_and_progress_prefer_readable_content() {
10755 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10756 assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10757 .contains("端口?"));
10758 let empty = rpi_agent::AgentToolResult::default();
10759 assert!(
10760 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10761 .contains("端口?")
10762 );
10763 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10764 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10765 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10766 }
10767
10768 #[test]
10769 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10770 let flat = rpi_extensions::UiDialogRequest {
10771 request_id: "r1".into(),
10772 tool_call_id: Some("c1".into()),
10773 ui: serde_json::json!({
10774 "kind": "selector",
10775 "id": "proxy_port",
10776 "header": "Proxy",
10777 "question": "端口?",
10778 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10779 "allowMultiple": true,
10780 "allowFreeform": false,
10781 "suggest": "1080"
10782 }),
10783 raw: serde_json::json!({}),
10784 };
10785 let prompt = parse_ask_user_prompt(&flat);
10786 assert_eq!(prompt.question_id, "proxy_port");
10787 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10788 assert_eq!(prompt.options.len(), 2);
10789 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10790 assert!(prompt.allow_multiple);
10791 assert!(!prompt.allow_freeform);
10792 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10793
10794 let alias = rpi_extensions::UiDialogRequest {
10795 ui: serde_json::json!({
10796 "questions": [{"id": "q", "question": "Q?", "options": []}]
10797 }),
10798 ..flat.clone()
10799 };
10800 let prompt = parse_ask_user_prompt(&alias);
10801 assert_eq!(prompt.question_id, "q");
10802 assert!(prompt.allow_freeform, "no options defaults to freeform");
10803
10804 let confirm = rpi_extensions::UiDialogRequest {
10805 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10806 ..flat.clone()
10807 };
10808 let prompt = parse_ask_user_prompt(&confirm);
10809 assert_eq!(prompt.kind, "confirm");
10810 assert_eq!(prompt.options.len(), 2);
10811 assert_eq!(prompt.options[0].0, "Yes");
10812 }
10813
10814 #[test]
10815 fn ask_user_bridge_answers_and_cancels_by_request_id() {
10816 let mailbox = rpi_extensions::UiDialogMailbox::new();
10817 mailbox.attach();
10818 let bridge = AskUserBridge::new(mailbox.clone());
10819
10820 mailbox
10822 .handle(serde_json::json!({
10823 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10824 "ui": {"kind": "input", "question": "Port?"}
10825 }))
10826 .unwrap();
10827 mailbox
10828 .handle(serde_json::json!({
10829 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10830 "ui": {"kind": "input", "question": "Level?"}
10831 }))
10832 .unwrap();
10833
10834 let first = bridge.take_pending().expect("r1 visible");
10835 assert_eq!(first.request_id, "r1");
10836 assert!(
10837 bridge.take_pending().is_none(),
10838 "only one request occupies the input slot"
10839 );
10840 bridge.respond("r1", serde_json::json!({"text": "7897"}));
10841 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10842
10843 let second = bridge.take_pending().expect("r2 visible");
10844 assert_eq!(second.request_id, "r2");
10845 bridge.cancel("r2");
10846 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10847
10848 mailbox
10850 .handle(serde_json::json!({
10851 "op": "open", "requestId": "r3",
10852 "ui": {"kind": "input", "question": "X?"}
10853 }))
10854 .unwrap();
10855 bridge.cancel_all();
10856 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10857
10858 bridge.shutdown();
10860 assert!(mailbox
10861 .handle(serde_json::json!({
10862 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10863 }))
10864 .is_err());
10865 }
10866}