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, SelectItem, SelectList, SlashCommand as SlashCommandEntry,
49 SlashCommandAutocompleteProvider, Spacer, StackChild, StackEntry, StatusIndicator, Text,
50 ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack,
51 TUI,
52};
53use rpi_tui::{bold as tui_bold, theme as current_theme};
54
55#[allow(unused_imports)]
56use rpi_tui::BashStatus;
57
58use crate::args::Args;
59
60type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
67
68#[derive(Clone, Default)]
74struct JsDialogBridge {
75 pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
76 active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
77 visible: Arc<Mutex<Option<String>>>,
81 cancelled_before_open: Arc<Mutex<HashSet<String>>>,
82 closed: Arc<Mutex<bool>>,
83}
84
85struct JsDialogPending {
86 request: JsDialogRequest,
87 result: std_mpsc::Sender<serde_json::Value>,
88}
89
90struct JsDialogActive {
91 result: std_mpsc::Sender<serde_json::Value>,
92 cancel_requested: bool,
93}
94
95#[derive(Clone, Debug)]
96struct JsDialogRequest {
97 id: String,
98 method: String,
99 title: String,
100 message: String,
101 options: Vec<String>,
102 placeholder: Option<String>,
103 prefill: Option<String>,
104}
105
106impl JsDialogRequest {
107 fn parse(args: &serde_json::Value) -> Result<Self, String> {
108 let id = args
109 .get("dialogId")
110 .and_then(serde_json::Value::as_str)
111 .filter(|value| !value.is_empty())
112 .ok_or("ui.dialog missing dialogId")?
113 .to_string();
114 let method = args
115 .get("method")
116 .and_then(serde_json::Value::as_str)
117 .ok_or("ui.dialog missing method")?
118 .to_string();
119 if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
120 return Err(format!("unsupported UI dialog method: {method}"));
121 }
122 let options = args
123 .get("options")
124 .and_then(serde_json::Value::as_array)
125 .map(|values| {
126 values
127 .iter()
128 .filter_map(serde_json::Value::as_str)
129 .map(ToOwned::to_owned)
130 .collect()
131 })
132 .unwrap_or_default();
133 Ok(Self {
134 id,
135 method,
136 title: args
137 .get("title")
138 .and_then(serde_json::Value::as_str)
139 .unwrap_or_default()
140 .to_string(),
141 message: args
142 .get("message")
143 .and_then(serde_json::Value::as_str)
144 .unwrap_or_default()
145 .to_string(),
146 options,
147 placeholder: args
148 .get("placeholder")
149 .and_then(serde_json::Value::as_str)
150 .map(ToOwned::to_owned),
151 prefill: args
152 .get("prefill")
153 .and_then(serde_json::Value::as_str)
154 .map(ToOwned::to_owned),
155 })
156 }
157}
158
159impl JsDialogBridge {
160 fn handle_runtime_request(
161 &self,
162 action: &str,
163 args: serde_json::Value,
164 ) -> Result<serde_json::Value, String> {
165 match action {
166 "ui.dialog" => self.wait_for_dialog(args),
167 "ui.dialog.cancel" => {
168 let id = args
169 .get("dialogId")
170 .and_then(serde_json::Value::as_str)
171 .ok_or("ui.dialog.cancel missing dialogId")?;
172 self.cancel(id);
173 Ok(serde_json::json!(true))
174 }
175 _ => Err(format!("unsupported capability: {action}")),
176 }
177 }
178
179 fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
180 let request = JsDialogRequest::parse(&args)?;
181 let (sender, receiver) = std_mpsc::channel();
182 let _closed = self
186 .closed
187 .lock()
188 .map_err(|_| "JS dialog bridge poisoned")?;
189 if *_closed {
190 return Ok(serde_json::json!({ "cancelled": true }));
191 }
192 let cancelled_before_open = self
193 .cancelled_before_open
194 .lock()
195 .map_err(|_| "JS dialog cancellation state poisoned")?
196 .remove(&request.id);
197 if cancelled_before_open {
198 return Ok(serde_json::json!({ "cancelled": true }));
199 }
200 self.pending
203 .lock()
204 .map_err(|_| "JS dialog pending state poisoned")?
205 .push_back(JsDialogPending {
206 request,
207 result: sender,
208 });
209 drop(_closed);
210 receiver
211 .recv()
212 .map_err(|_| "JS dialog closed before it received an answer".to_string())
213 }
214
215 fn take_pending(&self) -> Option<JsDialogRequest> {
218 loop {
219 let pending = self.pending.lock().ok()?.pop_front()?;
220 let mut active = self.active.lock().ok()?;
221 if self
228 .cancelled_before_open
229 .lock()
230 .ok()?
231 .remove(&pending.request.id)
232 {
233 drop(active);
234 let _ = pending
235 .result
236 .send(serde_json::json!({ "cancelled": true }));
237 continue;
238 }
239 active.insert(
240 pending.request.id.clone(),
241 JsDialogActive {
242 result: pending.result,
243 cancel_requested: false,
244 },
245 );
246 if let Ok(mut visible) = self.visible.lock() {
247 *visible = Some(pending.request.id.clone());
248 }
249 return Some(pending.request);
250 }
251 }
252
253 fn respond(&self, id: &str, result: serde_json::Value) {
254 if let Ok(mut active) = self.active.lock() {
255 if let Some(entry) = active.remove(id) {
256 let _ = entry.result.send(result);
257 }
258 }
259 if let Ok(mut visible) = self.visible.lock() {
260 if visible.as_deref() == Some(id) {
261 *visible = None;
262 }
263 }
264 }
265
266 fn cancel(&self, id: &str) {
267 if let Ok(mut pending) = self.pending.lock() {
268 if let Some(index) = pending.iter().position(|item| item.request.id == id) {
269 if let Some(item) = pending.remove(index) {
270 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
271 return;
272 }
273 }
274 }
275 if let Ok(mut active) = self.active.lock() {
276 if let Some(entry) = active.get_mut(id) {
277 if !entry.cancel_requested {
278 entry.cancel_requested = true;
279 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
280 }
281 return;
282 }
283 }
284 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
285 cancelled.insert(id.to_string());
286 }
287 }
288
289 fn cancelled_active_ids(&self) -> Vec<String> {
290 self.active
291 .lock()
292 .map(|active| {
293 active
294 .iter()
295 .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
296 .collect()
297 })
298 .unwrap_or_default()
299 }
300
301 fn is_visible(&self, id: &str) -> bool {
302 self.visible
303 .lock()
304 .map(|visible| visible.as_deref() == Some(id))
305 .unwrap_or(false)
306 }
307
308 fn finish(&self, id: &str) {
309 if let Ok(mut active) = self.active.lock() {
310 active.remove(id);
311 }
312 if let Ok(mut visible) = self.visible.lock() {
313 if visible.as_deref() == Some(id) {
314 *visible = None;
315 }
316 }
317 }
318
319 fn cancel_all(&self) {
320 let Ok(mut closed) = self.closed.lock() else {
324 return;
325 };
326 *closed = true;
327 if let Ok(mut pending) = self.pending.lock() {
328 for item in pending.drain(..) {
329 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
330 }
331 }
332 if let Ok(mut active) = self.active.lock() {
333 for (_, entry) in active.drain() {
334 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
335 }
336 }
337 if let Ok(mut visible) = self.visible.lock() {
338 *visible = None;
339 }
340 drop(closed);
341 }
342
343 fn cancel_open_requests(&self) {
346 let Ok(mut closed) = self.closed.lock() else {
350 return;
351 };
352 *closed = true;
353 if let Ok(mut pending) = self.pending.lock() {
354 for item in pending.drain(..) {
355 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
356 }
357 }
358 if let Ok(mut active) = self.active.lock() {
359 for (_, entry) in active.drain() {
360 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
361 }
362 }
363 if let Ok(mut visible) = self.visible.lock() {
364 *visible = None;
365 }
366 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
367 cancelled.clear();
368 }
369 drop(closed);
370 }
371
372 fn reopen(&self) {
373 if let Ok(mut closed) = self.closed.lock() {
374 *closed = false;
375 }
376 }
377}
378
379fn build_markdown_transformer(
401 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
402) -> Option<MarkdownTransformer> {
403 let snapshot = snapshot?;
404 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
409 if renderers.is_empty() {
410 return None;
411 }
412 Some(Arc::new(move |raw: &str| -> String {
413 transform_markdown_chain(&snapshot, &renderers, raw)
414 }))
415}
416
417fn transform_markdown_chain(
425 snapshot: &rpi_extensions::RegistrySnapshot,
426 renderers: &[rpi_extensions::RegisteredRenderer],
427 raw: &str,
428) -> String {
429 if !snapshot.is_active() {
434 return raw.to_string();
435 }
436
437 let mut current = raw.to_string();
438 for renderer in renderers {
439 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
440 Ok(s) => s,
441 Err(_) => return current, };
443 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
453 let mut out = rpi_plugin_sdk::StbString::empty();
454 let rc = (renderer.render_fn)(
455 rpi_plugin_sdk::StbStringRef::from_str(&input),
456 &mut out as *mut rpi_plugin_sdk::StbString,
457 renderer.user_data,
458 );
459 let text = if rc == 0 {
460 let s = out.to_string_lossy();
461 Some(s)
462 } else {
463 None
464 };
465 out.free_with(Some(renderer.plugin_free_string));
469 text
470 }));
471 let out_text = match outcome {
472 Ok(Some(s)) => s,
473 Ok(None) => return current, Err(_) => return current, };
479 let next = serde_json::from_str::<serde_json::Value>(&out_text)
482 .ok()
483 .and_then(|v| {
484 v.get("markdown")
485 .and_then(|m| m.as_str())
486 .map(|s| s.to_string())
487 })
488 .unwrap_or(current);
489 current = next;
490 }
491 current
492}
493
494#[derive(Clone)]
518struct CommandContext {
519 chat: Arc<Container>,
520 tui: Arc<TuiAltScreen>,
521 tx: mpsc::UnboundedSender<TuiMessage>,
522 state: Arc<TuiState>,
523 editor: Arc<Editor>,
524 editor_container: Arc<Container>,
525 lane: Arc<dyn AgentLane>,
526 model_catalog: Arc<Vec<rpi_ai::Model>>,
527 lane_model_id: String,
531 cwd: std::path::PathBuf,
532 package_resources: Arc<crate::packages::PackageResources>,
536 resources: Arc<rpi_harness::types::AgentHarnessResources>,
540 reload_context: Arc<crate::session::ReloadContext>,
546}
547
548trait SlashCommand: Send + Sync {
550 fn name(&self) -> &str;
552 fn aliases(&self) -> &'static [&'static str] {
556 &[]
557 }
558 fn visible(&self) -> bool {
561 true
562 }
563 fn alias_visible(&self) -> &'static [&'static str] {
567 &[]
568 }
569 fn description(&self) -> &'static str {
572 ""
573 }
574 fn description_owned(&self) -> String {
575 self.description().to_string()
576 }
577 fn execute(&self, ctx: &CommandContext, args: &str);
583}
584
585struct CommandRegistry {
588 commands: Vec<Arc<dyn SlashCommand>>,
589}
590
591impl CommandRegistry {
592 fn new() -> Self {
593 Self {
594 commands: Vec::new(),
595 }
596 }
597
598 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
599 self.commands.push(cmd);
600 }
601
602 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
605 self.commands
606 .iter()
607 .find(|c| c.name() == token || c.aliases().contains(&token))
608 }
609
610 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
616 let mut out: Vec<SlashCommandEntry> = Vec::new();
617 for c in &self.commands {
618 let description = c.description_owned();
619 if c.visible() && !description.is_empty() {
620 out.push(SlashCommandEntry {
621 name: c.name().into(),
622 description: description.clone(),
623 });
624 }
625 for alias in c.alias_visible() {
627 out.push(SlashCommandEntry {
628 name: (*alias).into(),
629 description: description.clone(),
630 });
631 }
632 }
633 out
634 }
635}
636
637fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
641 let mut parts = text.split_whitespace();
642 let token = parts.next().unwrap_or("");
643 let args = parts.collect::<Vec<_>>().join(" ");
644 match registry.find(token) {
645 Some(cmd) => cmd.execute(ctx, &args),
646 None => {
647 add_error_message(
648 &ctx.chat,
649 &format!("Unknown command: {text}. Type /help for available commands."),
650 );
651 ctx.tui.request_render(false);
652 }
653 }
654}
655
656fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
661 use crossterm::event::{KeyCode, KeyModifiers};
662
663 let modifiers = key.modifiers;
664 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
665 let shift = modifiers.contains(KeyModifiers::SHIFT);
666 let alt = modifiers.contains(KeyModifiers::ALT);
667 let super_key = modifiers.contains(KeyModifiers::SUPER);
668
669 if modifiers == KeyModifiers::NONE {
670 return match key.code {
671 KeyCode::Char(ch) => ch.to_string(),
672 KeyCode::Enter => "\r".into(),
673 KeyCode::Esc => "\x1b".into(),
674 KeyCode::Backspace => "\x7f".into(),
675 KeyCode::Tab => "\t".into(),
676 KeyCode::BackTab => "\x1b[Z".into(),
680 KeyCode::Up => "\x1b[A".into(),
681 KeyCode::Down => "\x1b[B".into(),
682 KeyCode::Right => "\x1b[C".into(),
683 KeyCode::Left => "\x1b[D".into(),
684 KeyCode::Home => "\x1b[H".into(),
685 KeyCode::End => "\x1b[F".into(),
686 KeyCode::PageUp => "\x1b[5~".into(),
687 KeyCode::PageDown => "\x1b[6~".into(),
688 KeyCode::Delete => "\x1b[3~".into(),
689 KeyCode::Insert => "\x1b[2~".into(),
690 KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
691 _ => String::new(),
692 };
693 }
694
695 if ctrl && !shift && !alt && !super_key {
698 if let KeyCode::Char(ch) = key.code {
699 if let Some(code) = control_code(ch) {
700 return char::from(code).to_string();
701 }
702 }
703 }
704
705 if alt && !ctrl && !shift && !super_key {
708 if let KeyCode::Char(ch) = key.code {
709 return format!("\x1b{ch}");
710 }
711 }
712
713 if shift && !ctrl && !alt && !super_key {
719 if let KeyCode::Char(ch) = key.code {
720 return ch.to_string();
721 }
722 }
723
724 if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
725 return sequence;
726 }
727 let Some(codepoint) = key_codepoint(key.code, ctrl) else {
728 return String::new();
729 };
730 kitty_key_sequence(codepoint, modifiers)
731}
732
733fn control_code(ch: char) -> Option<u8> {
734 let ch = ch.to_ascii_lowercase();
735 Some(match ch {
736 '@' | ' ' => 0,
737 'a'..='z' => (ch as u8) & 0x1f,
738 '[' => 0x1b,
739 '\\' => 0x1c,
740 ']' => 0x1d,
741 '^' => 0x1e,
742 '_' | '-' => 0x1f,
743 _ => return None,
744 })
745}
746
747fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
748 use crossterm::event::KeyCode;
749 Some(match code {
750 KeyCode::Char(ch) => {
751 if ctrl {
752 ch.to_ascii_lowercase() as u32
753 } else {
754 ch as u32
755 }
756 }
757 KeyCode::Enter => 13,
758 KeyCode::Esc => 27,
759 KeyCode::Backspace => 127,
760 KeyCode::Tab => 9,
761 KeyCode::BackTab => 9,
764 _ => return None,
765 })
766}
767
768fn modified_functional_sequence(
769 code: crossterm::event::KeyCode,
770 modifiers: crossterm::event::KeyModifiers,
771) -> Option<String> {
772 use crossterm::event::{KeyCode, KeyModifiers};
773 if code == KeyCode::BackTab
777 && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
778 {
779 return Some("\x1b[Z".into());
780 }
781 let modifier = kitty_modifier(modifiers);
782 let sequence = match code {
783 KeyCode::Up => format!("\x1b[1;{modifier}A"),
784 KeyCode::Down => format!("\x1b[1;{modifier}B"),
785 KeyCode::Right => format!("\x1b[1;{modifier}C"),
786 KeyCode::Left => format!("\x1b[1;{modifier}D"),
787 KeyCode::Home => format!("\x1b[1;{modifier}H"),
788 KeyCode::End => format!("\x1b[1;{modifier}F"),
789 KeyCode::Insert => format!("\x1b[2;{modifier}~"),
790 KeyCode::Delete => format!("\x1b[3;{modifier}~"),
791 KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
792 KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
793 _ => return None,
794 };
795 Some(sequence)
796}
797
798fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
799 use crossterm::event::KeyModifiers;
800 let mut modifier = 1u8;
801 if modifiers.contains(KeyModifiers::SHIFT) {
802 modifier += 1;
803 }
804 if modifiers.contains(KeyModifiers::ALT) {
805 modifier += 2;
806 }
807 if modifiers.contains(KeyModifiers::CONTROL) {
808 modifier += 4;
809 }
810 if modifiers.contains(KeyModifiers::SUPER) {
811 modifier += 8;
812 }
813 modifier
814}
815
816fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
817 let modifier = kitty_modifier(modifiers);
818 format!("\x1b[{codepoint};{modifier}u")
819}
820
821struct ExtensionCommand {
826 name: String,
827 description: String,
828 session: crate::session::ExtensionSessionCell,
829}
830
831struct JsExtensionCommand {
832 name: String,
833 session: crate::js_extensions::JsExtensionSession,
834}
835
836impl SlashCommand for JsExtensionCommand {
837 fn name(&self) -> &str {
838 &self.name
839 }
840 fn description(&self) -> &'static str {
841 "JS extension command"
842 }
843 fn description_owned(&self) -> String {
844 "JS extension command".to_string()
845 }
846 fn execute(&self, ctx: &CommandContext, args: &str) {
847 let session = self.session.clone();
852 let command = self.name.trim_start_matches('/').to_string();
853 let args = args.to_string();
854 let ctx = ctx.clone();
855 let initial_editor_text = ctx.editor.get_text();
859 tokio::task::spawn_blocking(move || {
860 match session.invoke_command_with_context(
861 &command,
862 &args,
863 serde_json::json!({"editorText": initial_editor_text}),
864 ) {
865 Ok(value) => {
866 if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
867 if ctx.editor.get_text() == initial_editor_text
868 && editor_text != initial_editor_text
869 {
870 let cursor = editor_text.chars().count();
871 ctx.editor.set_text(editor_text);
872 ctx.editor.set_cursor(0, cursor);
873 }
874 }
875 if let Some(notifications) =
876 value.get("notifications").and_then(|v| v.as_array())
877 {
878 for notification in notifications {
879 let message = notification
880 .get("message")
881 .and_then(|v| v.as_str())
882 .unwrap_or_default();
883 if message.is_empty() {
884 continue;
885 }
886 match notification.get("level").and_then(|v| v.as_str()) {
887 Some("error") => add_error_message(&ctx.chat, message),
888 _ => add_note_message(&ctx.chat, message),
889 }
890 }
891 }
892 let result = value.get("result").unwrap_or(&value);
893 let text = result
894 .get("text")
895 .and_then(|item| item.as_str())
896 .map(str::to_string)
897 .or_else(|| result.as_str().map(str::to_string))
898 .filter(|text| !text.is_empty() && text != "null");
899 if let Some(text) = text {
900 add_note_message(&ctx.chat, &text);
901 }
902 }
903 Err(error) => {
904 add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
905 }
906 }
907 ctx.tui.request_render(false);
908 });
909 }
910}
911
912impl SlashCommand for ExtensionCommand {
913 fn name(&self) -> &str {
914 &self.name
915 }
916
917 fn description(&self) -> &'static str {
918 "extension command"
919 }
920
921 fn description_owned(&self) -> String {
922 self.description.clone()
923 }
924
925 fn execute(&self, ctx: &CommandContext, args: &str) {
926 let result = invoke_extension_command(&self.session, &self.name, args);
927 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
928 }
929}
930
931fn invoke_extension_command(
932 session: &crate::session::ExtensionSessionCell,
933 name: &str,
934 args: &str,
935) -> Option<serde_json::Value> {
936 let command = session
937 .lock()
938 .ok()
939 .and_then(|s| s.snapshot_arc())
940 .and_then(|snap| {
941 snap.commands()
942 .iter()
943 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
944 .cloned()
945 })?;
946 let input = serde_json::json!({ "args": args, "command": name });
947 let input = serde_json::to_string(&input).ok()?;
948 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
949 let mut out = rpi_plugin_sdk::StbString::empty();
950 let rc = (command.handler)(
951 rpi_plugin_sdk::StbStringRef::from_str(&input),
952 &mut out as *mut rpi_plugin_sdk::StbString,
953 command.user_data,
954 );
955 let text = if rc == 0 {
956 Some(out.to_string_lossy())
957 } else {
958 None
959 };
960 rpi_extensions::host_free_string(out);
961 text
962 }))
963 .ok()
964 .flatten()?;
965 serde_json::from_str(&outcome).ok()
966}
967
968fn handle_extension_ui_result(
969 result: Option<serde_json::Value>,
970 ctx: &CommandContext,
971 session: crate::session::ExtensionSessionCell,
972 command_name: String,
973) {
974 let Some(value) = result else {
975 add_error_message(&ctx.chat, "Extension command failed.");
976 ctx.tui.request_render(false);
977 return;
978 };
979 if value.is_null() {
983 ctx.tui.request_render(false);
984 return;
985 }
986 match value.get("kind").and_then(|v| v.as_str()) {
987 Some("message") | None => {
988 let fallback = value.to_string();
989 let text = value
990 .get("text")
991 .and_then(|v| v.as_str())
992 .unwrap_or(&fallback)
993 .to_string();
994 if !text.is_empty() {
995 add_note_message(&ctx.chat, &text);
996 }
997 ctx.tui.request_render(false);
998 }
999 Some("selector") => open_extension_selector(ctx, session, command_name, value),
1000 Some("editor") => open_extension_editor(ctx, session, command_name, value),
1001 Some("input") => open_extension_input(ctx, session, command_name, value),
1005 Some(other) => {
1006 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1007 ctx.tui.request_render(false);
1008 }
1009 }
1010}
1011
1012fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1016 let colors = current_theme().colors;
1017 let text = if bold {
1018 tui_bold(title)
1019 } else {
1020 title.to_string()
1021 };
1022 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1023}
1024
1025fn extension_dialog_hint(label: &str) -> Arc<Text> {
1026 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1027}
1028
1029fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1032 let text = if bold {
1033 format!("**{}**", title)
1034 } else {
1035 title.to_string()
1036 };
1037 Arc::new(Markdown::new(text, 1, 0))
1038}
1039
1040fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1043 Arc::new(Markdown::new(label, 1, 0))
1044}
1045
1046fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1050 let callback = state.active_extension_cancel.lock().unwrap().take();
1051 if let Some(callback) = callback {
1052 callback();
1053 true
1054 } else {
1055 false
1056 }
1057}
1058
1059fn open_extension_input(
1060 ctx: &CommandContext,
1061 session: crate::session::ExtensionSessionCell,
1062 command_name: String,
1063 value: serde_json::Value,
1064) {
1065 let title = value
1066 .get("title")
1067 .and_then(|v| v.as_str())
1068 .filter(|title| !title.is_empty())
1069 .unwrap_or("Input");
1070 let input = value
1071 .get("placeholder")
1072 .and_then(|v| v.as_str())
1073 .map(Input::with_placeholder)
1074 .unwrap_or_default();
1075 let input = Arc::new(input);
1076 if let Some(initial) = value
1077 .get("initialText")
1078 .or_else(|| value.get("text"))
1079 .and_then(|v| v.as_str())
1080 {
1081 input.set_value(initial);
1082 }
1083 input.set_focused(true);
1084
1085 let frame = Arc::new(Container::new());
1086 frame.add_child(Arc::new(DynamicBorder::new()));
1087 frame.add_child(Arc::new(Spacer::new(1)));
1088 frame.add_child(extension_dialog_title(title, false));
1089 frame.add_child(Arc::new(Spacer::new(1)));
1090 frame.add_child(input.clone());
1091 frame.add_child(Arc::new(Spacer::new(1)));
1092 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1093 frame.add_child(Arc::new(Spacer::new(1)));
1094 frame.add_child(Arc::new(DynamicBorder::new()));
1095
1096 *ctx.state.active_extension_editor.lock().unwrap() = None;
1097 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1098 ctx.editor_container.clear();
1099 ctx.editor_container.add_child(frame);
1100
1101 let state = ctx.state.clone();
1102 let ec = ctx.editor_container.clone();
1103 let original = ctx.editor.clone();
1104 let tui = ctx.tui.clone();
1105 let session_submit = session.clone();
1106 let command_submit = command_name.clone();
1107 let ctx_submit = ctx.clone();
1108 input.on_submit(Arc::new(move |text| {
1109 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1110 let result = invoke_extension_command(
1111 &session_submit,
1112 &command_submit,
1113 &serde_json::to_string(&args).unwrap_or_default(),
1114 );
1115 close_extension_editor(&state, &ec, &original, &tui);
1116 handle_extension_ui_result(
1117 result,
1118 &ctx_submit,
1119 session_submit.clone(),
1120 command_submit.clone(),
1121 );
1122 }));
1123
1124 let state_cancel = ctx.state.clone();
1125 let ec_cancel = ctx.editor_container.clone();
1126 let original_cancel = ctx.editor.clone();
1127 let tui_cancel = ctx.tui.clone();
1128 let session_cancel = session.clone();
1129 let command_cancel = command_name.clone();
1130 let ctx_cancel = ctx.clone();
1131 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1132 let args = serde_json::json!({ "action": "cancel" });
1133 let result = invoke_extension_command(
1134 &session_cancel,
1135 &command_cancel,
1136 &serde_json::to_string(&args).unwrap_or_default(),
1137 );
1138 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1139 handle_extension_ui_result(
1140 result,
1141 &ctx_cancel,
1142 session_cancel.clone(),
1143 command_cancel.clone(),
1144 );
1145 }));
1146
1147 ctx.tui.set_focus(Some(input));
1148 ctx.tui.request_render(false);
1149}
1150
1151fn open_extension_selector(
1152 ctx: &CommandContext,
1153 session: crate::session::ExtensionSessionCell,
1154 command_name: String,
1155 value: serde_json::Value,
1156) {
1157 let items = value
1158 .get("items")
1159 .and_then(|v| v.as_array())
1160 .map(|items| {
1161 items
1162 .iter()
1163 .filter_map(|item| {
1164 let value = if let Some(value) = item.as_str() {
1167 value
1168 } else {
1169 item.get("value")?.as_str()?
1170 };
1171 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1172 let mut out = SelectItem::new(value, label);
1173 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1174 out = out.with_description(desc);
1175 }
1176 Some(out)
1177 })
1178 .collect::<Vec<_>>()
1179 })
1180 .unwrap_or_default();
1181 if items.is_empty() {
1182 add_error_message(&ctx.chat, "Extension selector has no items.");
1183 ctx.tui.request_render(false);
1184 return;
1185 }
1186 let list = Arc::new(SelectList::new(items, 10));
1187 let title = value
1188 .get("title")
1189 .and_then(|v| v.as_str())
1190 .filter(|title| !title.is_empty())
1191 .unwrap_or("Select");
1192 let frame = Arc::new(Container::new());
1193 frame.add_child(Arc::new(DynamicBorder::new()));
1194 frame.add_child(Arc::new(Spacer::new(1)));
1195 frame.add_child(extension_dialog_title(title, true));
1196 frame.add_child(Arc::new(Spacer::new(1)));
1197 frame.add_child(list.clone());
1198 frame.add_child(Arc::new(Spacer::new(1)));
1199 frame.add_child(extension_dialog_hint(
1200 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1201 ));
1202 frame.add_child(Arc::new(Spacer::new(1)));
1203 frame.add_child(Arc::new(DynamicBorder::new()));
1204
1205 let state = ctx.state.clone();
1206 let ec = ctx.editor_container.clone();
1207 let editor = ctx.editor.clone();
1208 let tui = ctx.tui.clone();
1209 let session_select = session.clone();
1210 let command_select = command_name.clone();
1211 let ctx_select = ctx.clone();
1212 list.on_select(Arc::new(move |item| {
1213 let args = serde_json::json!({ "action": "select", "value": item.value });
1214 let result = invoke_extension_command(
1215 &session_select,
1216 &command_select,
1217 &serde_json::to_string(&args).unwrap_or_default(),
1218 );
1219 close_selector(&state, &ec, &editor, &tui);
1220 handle_extension_ui_result(
1221 result,
1222 &ctx_select,
1223 session_select.clone(),
1224 command_select.clone(),
1225 );
1226 }));
1227 let state_cancel = ctx.state.clone();
1228 let ec_cancel = ctx.editor_container.clone();
1229 let editor_cancel = ctx.editor.clone();
1230 let tui_cancel = ctx.tui.clone();
1231 list.on_cancel(Arc::new(move || {
1232 if !run_extension_cancel(&state_cancel) {
1233 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1234 }
1235 }));
1236 let state_cancel = ctx.state.clone();
1237 let ec_cancel = ctx.editor_container.clone();
1238 let editor_cancel = ctx.editor.clone();
1239 let tui_cancel = ctx.tui.clone();
1240 let session_cancel = session.clone();
1241 let command_cancel = command_name.clone();
1242 let ctx_cancel = ctx.clone();
1243 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1244 let args = serde_json::json!({ "action": "cancel" });
1245 let result = invoke_extension_command(
1246 &session_cancel,
1247 &command_cancel,
1248 &serde_json::to_string(&args).unwrap_or_default(),
1249 );
1250 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1251 handle_extension_ui_result(
1252 result,
1253 &ctx_cancel,
1254 session_cancel.clone(),
1255 command_cancel.clone(),
1256 );
1257 }));
1258 open_selector_with_view(
1259 &ctx.state,
1260 &ctx.editor_container,
1261 &ctx.editor,
1262 &ctx.tui,
1263 list,
1264 frame,
1265 SelectorKind::Extension,
1266 );
1267}
1268
1269fn open_extension_editor(
1270 ctx: &CommandContext,
1271 session: crate::session::ExtensionSessionCell,
1272 command_name: String,
1273 value: serde_json::Value,
1274) {
1275 let initial = value
1276 .get("initialText")
1277 .or_else(|| value.get("text"))
1278 .and_then(|v| v.as_str())
1279 .unwrap_or_default()
1280 .to_string();
1281 let title = value
1282 .get("title")
1283 .and_then(|v| v.as_str())
1284 .filter(|title| !title.is_empty())
1285 .unwrap_or("Editor");
1286 let editor = Arc::new(Editor::new(
1287 EditorOptions {
1288 padding_x: 1,
1289 autocomplete_max_visible: 0,
1290 placeholder: value
1291 .get("placeholder")
1292 .and_then(|v| v.as_str())
1293 .map(str::to_string),
1294 initial_text: Some(initial),
1295 },
1296 EditorStyle {
1297 prompt: "> ".to_string(),
1298 placeholder: String::new(),
1299 },
1300 Arc::new(rpi_tui::Keybindings::new()),
1301 ));
1302 editor.set_focused(true);
1303 let frame = Arc::new(Container::new());
1304 frame.add_child(Arc::new(DynamicBorder::new()));
1305 frame.add_child(Arc::new(Spacer::new(1)));
1306 frame.add_child(extension_dialog_title(title, false));
1307 frame.add_child(Arc::new(Spacer::new(1)));
1308 frame.add_child(editor.clone());
1309 frame.add_child(Arc::new(Spacer::new(1)));
1310 frame.add_child(extension_dialog_hint(
1311 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1312 ));
1313 frame.add_child(Arc::new(Spacer::new(1)));
1314 frame.add_child(Arc::new(DynamicBorder::new()));
1315
1316 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1317 *ctx.state.active_extension_input.lock().unwrap() = None;
1318 ctx.editor_container.clear();
1319 ctx.editor_container.add_child(frame);
1320
1321 let state = ctx.state.clone();
1322 let ec = ctx.editor_container.clone();
1323 let original = ctx.editor.clone();
1324 let tui = ctx.tui.clone();
1325 let session_submit = session.clone();
1326 let command_submit = command_name.clone();
1327 let ctx_submit = ctx.clone();
1328 editor.on_submit(Arc::new(move |text| {
1329 let args = serde_json::json!({ "action": "edit", "text": text });
1330 let result = invoke_extension_command(
1331 &session_submit,
1332 &command_submit,
1333 &serde_json::to_string(&args).unwrap_or_default(),
1334 );
1335 close_extension_editor(&state, &ec, &original, &tui);
1336 handle_extension_ui_result(
1337 result,
1338 &ctx_submit,
1339 session_submit.clone(),
1340 command_submit.clone(),
1341 );
1342 }));
1343
1344 let state_cancel = ctx.state.clone();
1345 let ec_cancel = ctx.editor_container.clone();
1346 let original_cancel = ctx.editor.clone();
1347 let tui_cancel = ctx.tui.clone();
1348 let session_cancel = session.clone();
1349 let command_cancel = command_name.clone();
1350 let ctx_cancel = ctx.clone();
1351 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1352 let args = serde_json::json!({ "action": "cancel" });
1353 let result = invoke_extension_command(
1354 &session_cancel,
1355 &command_cancel,
1356 &serde_json::to_string(&args).unwrap_or_default(),
1357 );
1358 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1359 handle_extension_ui_result(
1360 result,
1361 &ctx_cancel,
1362 session_cancel.clone(),
1363 command_cancel.clone(),
1364 );
1365 }));
1366
1367 ctx.tui.set_focus(Some(editor));
1368 ctx.tui.request_render(false);
1369}
1370
1371fn close_extension_editor(
1372 state: &Arc<TuiState>,
1373 editor_container: &Arc<Container>,
1374 editor: &Arc<Editor>,
1375 tui: &Arc<TuiAltScreen>,
1376) {
1377 editor_container.clear();
1378 editor_container.add_child(editor.clone());
1379 state.autocomplete_container.clear();
1380 *state.active_extension_editor.lock().unwrap() = None;
1381 *state.active_extension_input.lock().unwrap() = None;
1382 *state.active_extension_cancel.lock().unwrap() = None;
1383 editor.set_focused(true);
1384 tui.set_focus(Some(editor.clone()));
1385 tui.request_render(false);
1386}
1387
1388fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1392 match request.method.as_str() {
1393 "select" => open_js_selector(ctx, bridge, request, false),
1394 "confirm" => open_js_selector(ctx, bridge, request, true),
1395 "input" => open_js_input(ctx, bridge, request),
1396 "editor" => open_js_editor(ctx, bridge, request),
1397 _ => {
1398 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1399 }
1400 }
1401}
1402
1403fn open_js_selector(
1404 ctx: &CommandContext,
1405 bridge: Arc<JsDialogBridge>,
1406 request: JsDialogRequest,
1407 confirm: bool,
1408) {
1409 let values = if confirm {
1410 vec!["Yes".to_string(), "No".to_string()]
1411 } else {
1412 request.options.clone()
1413 };
1414 if values.is_empty() {
1415 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1416 return;
1417 }
1418 let items = values
1419 .iter()
1420 .map(|value| SelectItem::new(value, value))
1421 .collect::<Vec<_>>();
1422 let list = Arc::new(SelectList::new(items, 10));
1423 let frame = Arc::new(Container::new());
1424 frame.add_child(Arc::new(DynamicBorder::new()));
1425 frame.add_child(Arc::new(Spacer::new(1)));
1426 frame.add_child(extension_dialog_title(
1427 if request.title.is_empty() {
1428 if confirm {
1429 "Confirm"
1430 } else {
1431 "Select"
1432 }
1433 } else {
1434 request.title.as_str()
1435 },
1436 true,
1437 ));
1438 if !request.message.is_empty() {
1439 frame.add_child(Arc::new(Spacer::new(1)));
1440 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1441 }
1442 frame.add_child(Arc::new(Spacer::new(1)));
1443 frame.add_child(list.clone());
1444 frame.add_child(Arc::new(Spacer::new(1)));
1445 frame.add_child(extension_dialog_hint(
1446 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1447 ));
1448 frame.add_child(Arc::new(Spacer::new(1)));
1449 frame.add_child(Arc::new(DynamicBorder::new()));
1450
1451 let id = request.id.clone();
1452 let bridge_select = bridge.clone();
1453 let state_select = ctx.state.clone();
1454 let ec_select = ctx.editor_container.clone();
1455 let editor_select = ctx.editor.clone();
1456 let tui_select = ctx.tui.clone();
1457 list.on_select(Arc::new(move |item| {
1458 let result = if confirm {
1459 serde_json::json!({ "confirmed": item.value == "Yes" })
1460 } else {
1461 serde_json::json!({ "value": item.value })
1462 };
1463 bridge_select.respond(&id, result);
1464 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1465 }));
1466
1467 let id_cancel = request.id.clone();
1468 let bridge_cancel = bridge.clone();
1469 let state_cancel = ctx.state.clone();
1470 let ec_cancel = ctx.editor_container.clone();
1471 let editor_cancel = ctx.editor.clone();
1472 let tui_cancel = ctx.tui.clone();
1473 list.on_cancel(Arc::new(move || {
1474 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1475 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1476 }));
1477
1478 let id_abort = request.id.clone();
1479 let bridge_abort = bridge.clone();
1480 let state_abort = ctx.state.clone();
1481 let ec_abort = ctx.editor_container.clone();
1482 let editor_abort = ctx.editor.clone();
1483 let tui_abort = ctx.tui.clone();
1484 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1485 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1486 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1487 }));
1488
1489 open_selector_with_view(
1490 &ctx.state,
1491 &ctx.editor_container,
1492 &ctx.editor,
1493 &ctx.tui,
1494 list,
1495 frame,
1496 SelectorKind::Extension,
1497 );
1498}
1499
1500fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1501 let input = request
1502 .placeholder
1503 .as_deref()
1504 .map(Input::with_placeholder)
1505 .unwrap_or_default();
1506 let input = Arc::new(input);
1507 input.set_focused(true);
1508
1509 let frame = Arc::new(Container::new());
1510 frame.add_child(Arc::new(DynamicBorder::new()));
1511 frame.add_child(Arc::new(Spacer::new(1)));
1512 frame.add_child(extension_dialog_title(
1513 if request.title.is_empty() {
1514 "Input"
1515 } else {
1516 &request.title
1517 },
1518 false,
1519 ));
1520 frame.add_child(Arc::new(Spacer::new(1)));
1521 frame.add_child(input.clone());
1522 frame.add_child(Arc::new(Spacer::new(1)));
1523 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1524 frame.add_child(Arc::new(Spacer::new(1)));
1525 frame.add_child(Arc::new(DynamicBorder::new()));
1526
1527 *ctx.state.active_extension_editor.lock().unwrap() = None;
1528 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1529 ctx.editor_container.clear();
1530 ctx.editor_container.add_child(frame);
1531
1532 let id = request.id.clone();
1533 let bridge_submit = bridge.clone();
1534 let state_submit = ctx.state.clone();
1535 let ec_submit = ctx.editor_container.clone();
1536 let editor_submit = ctx.editor.clone();
1537 let tui_submit = ctx.tui.clone();
1538 input.on_submit(Arc::new(move |value| {
1539 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1540 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1541 }));
1542
1543 let id_cancel = request.id.clone();
1544 let bridge_cancel = bridge.clone();
1545 let state_cancel = ctx.state.clone();
1546 let ec_cancel = ctx.editor_container.clone();
1547 let editor_cancel = ctx.editor.clone();
1548 let tui_cancel = ctx.tui.clone();
1549 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1550 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1551 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1552 }));
1553 ctx.tui.set_focus(Some(input));
1554 ctx.tui.request_render(false);
1555}
1556
1557fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1558 let editor = Arc::new(Editor::new(
1559 EditorOptions {
1560 padding_x: 1,
1561 autocomplete_max_visible: 0,
1562 initial_text: request.prefill.clone(),
1563 ..Default::default()
1564 },
1565 EditorStyle {
1566 prompt: "> ".to_string(),
1567 placeholder: String::new(),
1568 },
1569 Arc::new(rpi_tui::Keybindings::new()),
1570 ));
1571 editor.set_focused(true);
1572
1573 let frame = Arc::new(Container::new());
1574 frame.add_child(Arc::new(DynamicBorder::new()));
1575 frame.add_child(Arc::new(Spacer::new(1)));
1576 frame.add_child(extension_dialog_title(
1577 if request.title.is_empty() {
1578 "Editor"
1579 } else {
1580 &request.title
1581 },
1582 false,
1583 ));
1584 frame.add_child(Arc::new(Spacer::new(1)));
1585 frame.add_child(editor.clone());
1586 frame.add_child(Arc::new(Spacer::new(1)));
1587 frame.add_child(extension_dialog_hint(
1588 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1589 ));
1590 frame.add_child(Arc::new(Spacer::new(1)));
1591 frame.add_child(Arc::new(DynamicBorder::new()));
1592
1593 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1594 *ctx.state.active_extension_input.lock().unwrap() = None;
1595 ctx.editor_container.clear();
1596 ctx.editor_container.add_child(frame);
1597
1598 let id = request.id.clone();
1599 let bridge_submit = bridge.clone();
1600 let state_submit = ctx.state.clone();
1601 let ec_submit = ctx.editor_container.clone();
1602 let editor_submit = ctx.editor.clone();
1603 let tui_submit = ctx.tui.clone();
1604 editor.on_submit(Arc::new(move |value| {
1605 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1606 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1607 }));
1608
1609 let id_cancel = request.id.clone();
1610 let bridge_cancel = bridge.clone();
1611 let state_cancel = ctx.state.clone();
1612 let ec_cancel = ctx.editor_container.clone();
1613 let editor_cancel = ctx.editor.clone();
1614 let tui_cancel = ctx.tui.clone();
1615 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1616 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1617 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1618 }));
1619 ctx.tui.set_focus(Some(editor));
1620 ctx.tui.request_render(false);
1621}
1622
1623fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1624 for id in bridge.cancelled_active_ids() {
1625 if !bridge.is_visible(&id) {
1629 bridge.finish(&id);
1630 continue;
1631 }
1632 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1633 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1634 } else if ctx.state.extension_dialog_open() {
1635 if !run_extension_cancel(&ctx.state) {
1636 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1637 }
1638 }
1639 bridge.finish(&id);
1643 }
1644}
1645
1646const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1653
1654#[derive(Clone)]
1659struct AskUserBridge {
1660 mailbox: rpi_extensions::UiDialogMailbox,
1661 visible: Arc<Mutex<Option<String>>>,
1662}
1663
1664impl AskUserBridge {
1665 fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1666 Self {
1667 mailbox,
1668 visible: Arc::new(Mutex::new(None)),
1669 }
1670 }
1671
1672 fn attach(&self) {
1674 self.mailbox.attach();
1675 }
1676
1677 fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1681 let mut visible = self.visible.lock().ok()?;
1682 if visible.is_some() {
1683 return None;
1684 }
1685 let request = self.mailbox.take_pending()?;
1686 *visible = Some(request.request_id.clone());
1687 Some(request)
1688 }
1689
1690 fn clear_visible(&self, id: &str) {
1691 if let Ok(mut visible) = self.visible.lock() {
1692 if visible.as_deref() == Some(id) {
1693 *visible = None;
1694 }
1695 }
1696 }
1697
1698 fn respond(&self, id: &str, answer: serde_json::Value) {
1700 let _ = self.mailbox.respond(id, answer);
1701 self.clear_visible(id);
1702 }
1703
1704 fn cancel(&self, id: &str) {
1706 let _ = self.mailbox.cancel(id);
1707 self.clear_visible(id);
1708 }
1709
1710 fn cancel_all(&self) {
1713 self.mailbox.cancel_all();
1714 if let Ok(mut visible) = self.visible.lock() {
1715 *visible = None;
1716 }
1717 }
1718
1719 fn shutdown(&self) {
1722 self.mailbox.detach();
1723 if let Ok(mut visible) = self.visible.lock() {
1724 *visible = None;
1725 }
1726 }
1727}
1728
1729#[derive(Clone)]
1733struct AskUserPrompt {
1734 question_id: String,
1735 question: String,
1736 header: Option<String>,
1737 context: Option<String>,
1738 options: Vec<(String, Option<String>)>,
1739 allow_multiple: bool,
1740 allow_freeform: bool,
1741 suggest: Option<String>,
1742 kind: String,
1743}
1744
1745fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1746 keys.iter().find_map(|key| {
1747 value
1748 .get(*key)
1749 .and_then(serde_json::Value::as_str)
1750 .map(str::trim)
1751 .filter(|text| !text.is_empty())
1752 .map(str::to_string)
1753 })
1754}
1755
1756fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1757 keys.iter()
1758 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1759}
1760
1761fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1762 let mut options = Vec::new();
1763 let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1764 return options;
1765 };
1766 for item in items {
1767 if let Some(title) = item.as_str() {
1768 let title = title.trim();
1769 if !title.is_empty() {
1770 options.push((title.to_string(), None));
1771 }
1772 continue;
1773 }
1774 let Some(object) = item.as_object() else { continue };
1775 let title = ["title", "label", "text", "value", "name"]
1776 .iter()
1777 .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1778 .map(str::trim)
1779 .filter(|title| !title.is_empty());
1780 let Some(title) = title else { continue };
1781 let description = object
1782 .get("description")
1783 .and_then(serde_json::Value::as_str)
1784 .map(str::trim)
1785 .filter(|text| !text.is_empty())
1786 .map(str::to_string);
1787 options.push((title.to_string(), description));
1788 }
1789 options
1790}
1791
1792fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1793 let ui = &request.ui;
1794 let kind = ui
1795 .get("kind")
1796 .and_then(serde_json::Value::as_str)
1797 .unwrap_or("selector")
1798 .to_string();
1799 let nested = ui
1803 .get("questions")
1804 .and_then(serde_json::Value::as_array)
1805 .and_then(|items| items.first());
1806 let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1807
1808 let question = ask_user_str(source, &["question", "summary", "title"])
1809 .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1810 .unwrap_or_default();
1811 let question_id = ask_user_str(source, &["id", "questionId"])
1812 .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1813 .unwrap_or_else(|| request.request_id.clone());
1814 let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1815 let context = ask_user_str(source, &["context", "message"])
1816 .or_else(|| ask_user_str(ui, &["context", "message"]));
1817 let mut options = ask_user_options(source);
1818 if options.is_empty() {
1819 options = ask_user_options(ui);
1820 }
1821 if kind == "confirm" && options.is_empty() {
1822 options = vec![
1823 ("Yes".to_string(), None),
1824 ("No".to_string(), None),
1825 ];
1826 }
1827 let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1828 .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1829 .unwrap_or(false);
1830 let allow_freeform = ask_user_bool(
1833 source,
1834 &["allowFreeform", "allow_freeform", "allow_custom"],
1835 )
1836 .or_else(|| {
1837 ask_user_bool(
1838 ui,
1839 &["allowFreeform", "allow_freeform", "allow_custom"],
1840 )
1841 })
1842 .unwrap_or(options.is_empty());
1843 let suggest = ask_user_str(source, &["suggest", "placeholder"])
1844 .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1845
1846 AskUserPrompt {
1847 question_id,
1848 question,
1849 header,
1850 context,
1851 options,
1852 allow_multiple,
1853 allow_freeform,
1854 suggest,
1855 kind,
1856 }
1857}
1858
1859fn ask_user_frame(
1862 prompt: &AskUserPrompt,
1863 body: Arc<dyn Component>,
1864 hint: &str,
1865) -> Arc<Container> {
1866 let frame = Arc::new(Container::new());
1867 frame.add_child(Arc::new(DynamicBorder::new()));
1868 frame.add_child(Arc::new(Spacer::new(1)));
1869 if let Some(header) = prompt.header.as_deref() {
1870 frame.add_child(extension_dialog_title_md(header, true));
1871 }
1872 if !prompt.question.trim().is_empty() {
1873 frame.add_child(extension_dialog_title_md(
1874 &prompt.question,
1875 prompt.header.is_none(),
1876 ));
1877 }
1878 if let Some(context) = prompt.context.as_deref() {
1879 frame.add_child(extension_dialog_hint_md(context));
1880 }
1881 frame.add_child(Arc::new(Spacer::new(1)));
1882 frame.add_child(body);
1883 frame.add_child(Arc::new(Spacer::new(1)));
1884 frame.add_child(extension_dialog_hint(hint));
1885 frame.add_child(Arc::new(Spacer::new(1)));
1886 frame.add_child(Arc::new(DynamicBorder::new()));
1887 frame
1888}
1889
1890fn open_ask_user_dialog(
1892 ctx: &CommandContext,
1893 bridge: AskUserBridge,
1894 request: rpi_extensions::UiDialogRequest,
1895) {
1896 let prompt = parse_ask_user_prompt(&request);
1897 if prompt.kind == "input" || prompt.options.is_empty() {
1898 open_ask_user_input(ctx, bridge, request, prompt);
1899 } else {
1900 open_ask_user_selector(ctx, bridge, request, prompt);
1901 }
1902}
1903
1904fn open_ask_user_selector(
1905 ctx: &CommandContext,
1906 bridge: AskUserBridge,
1907 request: rpi_extensions::UiDialogRequest,
1908 prompt: AskUserPrompt,
1909) {
1910 let multi = prompt.allow_multiple && prompt.kind != "confirm";
1911 let mut items: Vec<SelectItem> = Vec::new();
1912 for (title, description) in &prompt.options {
1913 let item = SelectItem::new(title, title);
1914 let item = match description.as_deref() {
1915 Some(description) => item.with_description(description),
1916 None => item,
1917 };
1918 items.push(item);
1919 }
1920 if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1923 items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1924 }
1925 let list = if multi {
1926 Arc::new(SelectList::new_multi(items, 10))
1927 } else {
1928 Arc::new(SelectList::new(items, 10))
1929 };
1930
1931 let request_id = request.request_id.clone();
1932 let state = ctx.state.clone();
1933 let editor_container = ctx.editor_container.clone();
1934 let editor = ctx.editor.clone();
1935 let tui = ctx.tui.clone();
1936
1937 let bridge_select = bridge.clone();
1940 let request_for_freeform = request.clone();
1941 let prompt_for_freeform = AskUserPrompt {
1942 question_id: prompt.question_id.clone(),
1943 question: prompt.question.clone(),
1944 header: prompt.header.clone(),
1945 context: prompt.context.clone(),
1946 options: Vec::new(),
1947 allow_multiple: false,
1948 allow_freeform: true,
1949 suggest: prompt.suggest.clone(),
1950 kind: "input".to_string(),
1951 };
1952 let state_select = state.clone();
1953 let ec_select = editor_container.clone();
1954 let editor_select = editor.clone();
1955 let tui_select = tui.clone();
1956 let ctx_select = ctx.clone();
1957 list.on_select(Arc::new(move |item: &SelectItem| {
1958 if item.value == ASK_USER_FREEFORM {
1959 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1960 open_ask_user_input(
1961 &ctx_select,
1962 bridge_select.clone(),
1963 request_for_freeform.clone(),
1964 prompt_for_freeform.clone(),
1965 );
1966 return;
1967 }
1968 bridge_select.respond(
1969 &request_id,
1970 serde_json::json!({
1971 "values": [item.display_value()],
1972 "kind": "selector",
1973 }),
1974 );
1975 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1976 }));
1977
1978 if multi {
1979 let bridge_multi = bridge.clone();
1980 let request_id_multi = request.request_id.clone();
1981 let state_multi = state.clone();
1982 let ec_multi = editor_container.clone();
1983 let editor_multi = editor.clone();
1984 let tui_multi = tui.clone();
1985 list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
1986 let values: Vec<String> = selected
1987 .iter()
1988 .map(|item| item.display_value().to_string())
1989 .collect();
1990 bridge_multi.respond(
1991 &request_id_multi,
1992 serde_json::json!({"values": values, "kind": "selector"}),
1993 );
1994 close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
1995 }));
1996 }
1997
1998 let bridge_cancel = bridge.clone();
1999 let request_id_cancel = request.request_id.clone();
2000 let state_cancel = state.clone();
2001 let ec_cancel = editor_container.clone();
2002 let editor_cancel = editor.clone();
2003 let tui_cancel = tui.clone();
2004 list.on_cancel(Arc::new(move || {
2005 bridge_cancel.cancel(&request_id_cancel);
2006 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2007 }));
2008
2009 let hint = if multi {
2010 "Space toggle · Enter submit · Esc cancel"
2011 } else {
2012 "Enter select · Esc cancel"
2013 };
2014 let frame = ask_user_frame(&prompt, list.clone(), hint);
2015 open_selector_with_view(
2016 &state,
2017 &editor_container,
2018 &editor,
2019 &tui,
2020 list,
2021 frame,
2022 SelectorKind::Extension,
2023 );
2024}
2025
2026fn open_ask_user_input(
2027 ctx: &CommandContext,
2028 bridge: AskUserBridge,
2029 request: rpi_extensions::UiDialogRequest,
2030 prompt: AskUserPrompt,
2031) {
2032 let placeholder = prompt
2033 .suggest
2034 .clone()
2035 .unwrap_or_else(|| "Type your answer…".to_string());
2036 let input = Arc::new(Input::with_placeholder(&placeholder));
2037 input.set_focused(true);
2038
2039 let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2040 *ctx.state.active_extension_editor.lock().unwrap() = None;
2041 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2042 ctx.editor_container.clear();
2043 ctx.editor_container.add_child(frame);
2044 ctx.tui.set_focus(Some(input.clone()));
2045 ctx.tui.request_render(false);
2046
2047 let state = ctx.state.clone();
2048 let ec = ctx.editor_container.clone();
2049 let editor = ctx.editor.clone();
2050 let tui = ctx.tui.clone();
2051 let bridge_submit = bridge.clone();
2052 let request_id = request.request_id.clone();
2053 input.on_submit(Arc::new(move |text: &str| {
2054 bridge_submit.respond(
2055 &request_id,
2056 serde_json::json!({
2057 "values": [text],
2058 "value": text,
2059 "text": text,
2060 "kind": "input",
2061 }),
2062 );
2063 close_extension_editor(&state, &ec, &editor, &tui);
2064 }));
2065
2066 let state_cancel = ctx.state.clone();
2067 let ec_cancel = ctx.editor_container.clone();
2068 let editor_cancel = ctx.editor.clone();
2069 let tui_cancel = ctx.tui.clone();
2070 let bridge_cancel = bridge.clone();
2071 let request_id_cancel = request.request_id.clone();
2072 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2073 bridge_cancel.cancel(&request_id_cancel);
2074 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2075 }));
2076}
2077
2078struct HelpCommand;
2081impl SlashCommand for HelpCommand {
2082 fn name(&self) -> &'static str {
2083 "/help"
2084 }
2085 fn aliases(&self) -> &'static [&'static str] {
2086 &["/?"]
2087 }
2088 fn description(&self) -> &'static str {
2089 "Show available commands"
2090 }
2091 fn execute(&self, ctx: &CommandContext, _args: &str) {
2092 add_help_message(&ctx.chat);
2093 ctx.tui.request_render(false);
2094 }
2095}
2096
2097struct ClearChatCommand;
2098impl SlashCommand for ClearChatCommand {
2099 fn name(&self) -> &'static str {
2100 "/clear"
2101 }
2102 fn aliases(&self) -> &'static [&'static str] {
2103 &["/new"]
2104 }
2105 fn alias_visible(&self) -> &'static [&'static str] {
2107 &["/new"]
2108 }
2109 fn description(&self) -> &'static str {
2110 "Clear the conversation"
2111 }
2112 fn execute(&self, ctx: &CommandContext, _args: &str) {
2113 let _ = ctx.tx.send(TuiMessage::ClearChat);
2114 }
2115}
2116
2117struct ExitCommand;
2118impl SlashCommand for ExitCommand {
2119 fn name(&self) -> &'static str {
2120 "/exit"
2121 }
2122 fn aliases(&self) -> &'static [&'static str] {
2123 &["/quit", "/q"]
2124 }
2125 fn alias_visible(&self) -> &'static [&'static str] {
2127 &["/quit"]
2128 }
2129 fn description(&self) -> &'static str {
2130 "Exit the application"
2131 }
2132 fn execute(&self, ctx: &CommandContext, _args: &str) {
2133 ctx.state.cancel_js_preparation();
2134 let _ = ctx.tx.send(TuiMessage::Exit);
2135 }
2136}
2137
2138struct VersionCommand;
2139impl SlashCommand for VersionCommand {
2140 fn name(&self) -> &'static str {
2141 "/version"
2142 }
2143 fn aliases(&self) -> &'static [&'static str] {
2144 &["/v"]
2145 }
2146 fn description(&self) -> &'static str {
2147 "Show version information"
2148 }
2149 fn execute(&self, ctx: &CommandContext, _args: &str) {
2150 add_version_message(&ctx.chat);
2151 ctx.tui.request_render(false);
2152 }
2153}
2154
2155struct ChangelogCommand;
2156impl SlashCommand for ChangelogCommand {
2157 fn name(&self) -> &'static str {
2158 "/changelog"
2159 }
2160 fn description(&self) -> &'static str {
2161 "Show recent release changes"
2162 }
2163 fn execute(&self, ctx: &CommandContext, _args: &str) {
2164 add_changelog_message(&ctx.chat);
2165 ctx.tui.request_render(false);
2166 }
2167}
2168
2169struct HotkeysCommand;
2170impl SlashCommand for HotkeysCommand {
2171 fn name(&self) -> &'static str {
2172 "/hotkeys"
2173 }
2174 fn description(&self) -> &'static str {
2175 "Show keyboard shortcuts"
2176 }
2177 fn execute(&self, ctx: &CommandContext, _args: &str) {
2178 add_hotkeys_message(&ctx.chat);
2179 ctx.tui.request_render(false);
2180 }
2181}
2182
2183struct ModelCommand;
2184impl SlashCommand for ModelCommand {
2185 fn name(&self) -> &'static str {
2186 "/model"
2187 }
2188 fn aliases(&self) -> &'static [&'static str] {
2189 &["/m"]
2190 }
2191 fn description(&self) -> &'static str {
2192 "Choose a model (selector)"
2193 }
2194 fn execute(&self, ctx: &CommandContext, args: &str) {
2195 let term = args.trim();
2196 if !term.is_empty() {
2197 let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
2199 add_error_message(
2200 &ctx.chat,
2201 &format!("No model matches \"{term}\". Try /model for the list."),
2202 );
2203 ctx.tui.request_render(false);
2204 return;
2205 };
2206 let model_id = model.id.clone();
2207 ctx.state.set_current_model(&model);
2208 let lane = ctx.lane.clone();
2209 tokio::spawn(async move {
2210 let _ = lane.set_model(model).await;
2211 });
2212 add_note_message(
2213 &ctx.chat,
2214 &format!(
2215 "Model set to {} — applies to the next message.",
2216 short_model_name(&model_id)
2217 ),
2218 );
2219 ctx.tui.request_render(false);
2220 return;
2221 }
2222 open_model_selector(
2223 &ctx.state,
2224 &ctx.editor_container,
2225 &ctx.editor,
2226 &ctx.tui,
2227 &ctx.model_catalog,
2228 &ctx.lane,
2229 &ctx.lane_model_id,
2230 &ctx.chat,
2231 );
2232 }
2233}
2234
2235struct ThinkingCommand;
2236impl SlashCommand for ThinkingCommand {
2237 fn name(&self) -> &'static str {
2238 "/thinking"
2239 }
2240 fn aliases(&self) -> &'static [&'static str] {
2241 &["/think"]
2242 }
2243 fn description(&self) -> &'static str {
2244 "Set thinking level (selector)"
2245 }
2246 fn execute(&self, ctx: &CommandContext, args: &str) {
2247 let level_name = args.trim();
2248 if !level_name.is_empty() {
2249 let Some(level) = thinking_level_from_name(level_name) else {
2251 add_error_message(
2252 &ctx.chat,
2253 &format!(
2254 "Unknown thinking level \"{level_name}\". Valid: {}",
2255 crate::args::VALID_THINKING_LEVELS.join(", ")
2256 ),
2257 );
2258 ctx.tui.request_render(false);
2259 return;
2260 };
2261 let lane = ctx.lane.clone();
2262 let footer = ctx.state.footer.clone();
2263 tokio::spawn(async move {
2264 let _ = lane.set_thinking_level(level).await;
2265 });
2266 footer.set_thinking_level(Some(thinking_level_name(level)));
2267 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2268 ctx.tui.request_render(false);
2269 return;
2270 }
2271 open_thinking_selector(
2272 &ctx.state,
2273 &ctx.editor_container,
2274 &ctx.editor,
2275 &ctx.tui,
2276 &ctx.lane,
2277 &ctx.model_catalog,
2278 &ctx.lane_model_id,
2279 &ctx.chat,
2280 );
2281 }
2282}
2283
2284struct ToolsCommand;
2285impl SlashCommand for ToolsCommand {
2286 fn name(&self) -> &'static str {
2287 "/tools"
2288 }
2289 fn description(&self) -> &'static str {
2290 "Toggle tools on/off"
2291 }
2292 fn execute(&self, ctx: &CommandContext, _args: &str) {
2293 open_tools_selector(
2294 &ctx.state,
2295 &ctx.editor_container,
2296 &ctx.editor,
2297 &ctx.tui,
2298 &ctx.lane,
2299 &ctx.chat,
2300 );
2301 }
2302}
2303
2304struct ImagesCommand;
2305impl SlashCommand for ImagesCommand {
2306 fn name(&self) -> &'static str {
2307 "/images"
2308 }
2309 fn description(&self) -> &'static str {
2310 "Toggle inline images"
2311 }
2312 fn execute(&self, ctx: &CommandContext, _args: &str) {
2313 open_images_selector(
2314 &ctx.state,
2315 &ctx.editor_container,
2316 &ctx.editor,
2317 &ctx.tui,
2318 &ctx.chat,
2319 );
2320 }
2321}
2322
2323struct SessionCommand;
2324impl SlashCommand for SessionCommand {
2325 fn name(&self) -> &'static str {
2326 "/session"
2327 }
2328 fn aliases(&self) -> &'static [&'static str] {
2329 &["/resume"]
2330 }
2331 fn description(&self) -> &'static str {
2332 "List saved sessions"
2333 }
2334 fn execute(&self, ctx: &CommandContext, _args: &str) {
2335 open_session_selector(
2336 &ctx.state,
2337 &ctx.editor_container,
2338 &ctx.editor,
2339 &ctx.tui,
2340 &ctx.cwd,
2341 &ctx.tx,
2342 );
2343 }
2344}
2345
2346struct ThemeCommand;
2347impl SlashCommand for ThemeCommand {
2348 fn name(&self) -> &'static str {
2349 "/theme"
2350 }
2351 fn description(&self) -> &'static str {
2352 "Choose a theme (selector)"
2353 }
2354 fn execute(&self, ctx: &CommandContext, args: &str) {
2355 let name = args.trim().to_ascii_lowercase();
2356 if !name.is_empty() {
2357 let preset = match name.as_str() {
2359 "light" => ThemePreset::Light,
2360 "monochrome" => ThemePreset::Monochrome,
2361 "dark" => ThemePreset::Dark,
2362 _ => {
2363 add_error_message(
2364 &ctx.chat,
2365 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2366 );
2367 ctx.tui.request_render(false);
2368 return;
2369 }
2370 };
2371 apply_theme_preset(preset);
2372 let mut settings = crate::settings::load_settings().unwrap_or_default();
2373 settings.theme = Some(name.clone());
2374 let _ = crate::settings::save_settings(&settings);
2375 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2376 ctx.tui.request_render(false);
2377 ctx.tui.render_now(true);
2378 return;
2379 }
2380 open_theme_selector(
2381 &ctx.state,
2382 &ctx.editor_container,
2383 &ctx.editor,
2384 &ctx.tui,
2385 &ctx.cwd,
2386 &ctx.package_resources,
2387 );
2388 }
2389}
2390
2391struct CompactCommand;
2392impl SlashCommand for CompactCommand {
2393 fn name(&self) -> &'static str {
2394 "/compact"
2395 }
2396 fn description(&self) -> &'static str {
2397 "Compact the conversation"
2398 }
2399 fn execute(&self, ctx: &CommandContext, _args: &str) {
2400 let _ = ctx.tx.send(TuiMessage::Compact);
2401 }
2402}
2403
2404struct CopyCommand;
2405impl SlashCommand for CopyCommand {
2406 fn name(&self) -> &'static str {
2407 "/copy"
2408 }
2409 fn description(&self) -> &'static str {
2410 "Copy last reply to clipboard"
2411 }
2412 fn execute(&self, ctx: &CommandContext, _args: &str) {
2413 let _ = ctx.tx.send(TuiMessage::Copy);
2414 }
2415}
2416
2417struct ExportCommand;
2418impl SlashCommand for ExportCommand {
2419 fn name(&self) -> &'static str {
2420 "/export"
2421 }
2422 fn description(&self) -> &'static str {
2423 "Export session to a markdown file"
2424 }
2425 fn execute(&self, ctx: &CommandContext, _args: &str) {
2426 let _ = ctx.tx.send(TuiMessage::ExportSession);
2427 }
2428}
2429
2430struct ForkCommand;
2431impl SlashCommand for ForkCommand {
2432 fn name(&self) -> &'static str {
2433 "/fork"
2434 }
2435 fn description(&self) -> &'static str {
2436 "Fork the session into a new one"
2437 }
2438 fn execute(&self, ctx: &CommandContext, _args: &str) {
2439 let _ = ctx.tx.send(TuiMessage::ForkSession);
2440 }
2441}
2442
2443struct CloneCommand;
2447impl SlashCommand for CloneCommand {
2448 fn name(&self) -> &'static str {
2449 "/clone"
2450 }
2451 fn description(&self) -> &'static str {
2452 "Duplicate the current session"
2453 }
2454 fn execute(&self, ctx: &CommandContext, _args: &str) {
2455 let _ = ctx.tx.send(TuiMessage::ForkSession);
2456 }
2457}
2458
2459struct TreeCommand;
2460impl SlashCommand for TreeCommand {
2461 fn name(&self) -> &'static str {
2462 "/tree"
2463 }
2464 fn description(&self) -> &'static str {
2465 "Navigate the current session tree"
2466 }
2467 fn execute(&self, ctx: &CommandContext, _args: &str) {
2468 let _ = ctx.tx.send(TuiMessage::OpenTree);
2469 }
2470}
2471
2472struct LoginCommand;
2473impl SlashCommand for LoginCommand {
2474 fn name(&self) -> &'static str {
2475 "/login"
2476 }
2477 fn description(&self) -> &'static str {
2478 "Save an Anthropic API key"
2479 }
2480 fn execute(&self, ctx: &CommandContext, args: &str) {
2481 let key = args.trim();
2482 if key.is_empty() {
2483 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2484 } else {
2485 let result = crate::config::upsert_credential(
2486 "anthropic",
2487 crate::config::Credential::ApiKey {
2488 key: Some(key.to_string()),
2489 env: None,
2490 },
2491 );
2492 match result {
2493 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2494 Err(error) => {
2495 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2496 }
2497 }
2498 }
2499 ctx.tui.request_render(false);
2500 }
2501}
2502
2503struct LogoutCommand;
2504impl SlashCommand for LogoutCommand {
2505 fn name(&self) -> &'static str {
2506 "/logout"
2507 }
2508 fn description(&self) -> &'static str {
2509 "Remove saved Anthropic credentials"
2510 }
2511 fn execute(&self, ctx: &CommandContext, _args: &str) {
2512 match crate::config::delete_credential("anthropic") {
2513 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2514 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2515 Err(error) => {
2516 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2517 }
2518 }
2519 ctx.tui.request_render(false);
2520 }
2521}
2522
2523fn set_project_trust_for_command(
2524 cwd: &std::path::Path,
2525 value: Option<bool>,
2526) -> Result<(), crate::config::ConfigError> {
2527 crate::config::set_project_trust(cwd, value)
2528}
2529
2530struct TrustCommand;
2531impl SlashCommand for TrustCommand {
2532 fn name(&self) -> &'static str {
2533 "/trust"
2534 }
2535 fn description(&self) -> &'static str {
2536 "Trust the current project"
2537 }
2538 fn execute(&self, ctx: &CommandContext, args: &str) {
2539 let value = match args.trim().to_ascii_lowercase().as_str() {
2540 "" | "yes" | "y" | "true" => Some(true),
2541 "no" | "n" | "false" => Some(false),
2542 "clear" | "reset" | "none" => None,
2543 _ => {
2544 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2545 ctx.tui.request_render(false);
2546 return;
2547 }
2548 };
2549 match set_project_trust_for_command(&ctx.cwd, value) {
2550 Ok(()) => {
2551 let label = match value {
2552 Some(true) => "trusted",
2553 Some(false) => "untrusted",
2554 None => "trust decision cleared",
2555 };
2556 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2557 }
2558 Err(error) => add_error_message(
2559 &ctx.chat,
2560 &format!("Could not save trust decision: {error}"),
2561 ),
2562 }
2563 ctx.tui.request_render(false);
2564 }
2565}
2566
2567struct NameCommand;
2568impl SlashCommand for NameCommand {
2569 fn name(&self) -> &'static str {
2570 "/name"
2571 }
2572 fn description(&self) -> &'static str {
2573 "Set session display name"
2574 }
2575 fn execute(&self, ctx: &CommandContext, args: &str) {
2576 let name = args.trim();
2577 if name.is_empty() {
2578 add_note_message(
2579 &ctx.chat,
2580 "Usage: /name <display name> — sets the current session's name.",
2581 );
2582 ctx.tui.request_render(false);
2583 return;
2584 }
2585 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2586 }
2587}
2588
2589struct ImportCommand;
2590impl SlashCommand for ImportCommand {
2591 fn name(&self) -> &'static str {
2592 "/import"
2593 }
2594 fn description(&self) -> &'static str {
2595 "Import a session file (path)"
2596 }
2597 fn execute(&self, ctx: &CommandContext, args: &str) {
2598 let path = args.trim();
2599 if path.is_empty() {
2600 add_note_message(
2601 &ctx.chat,
2602 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2603 );
2604 ctx.tui.request_render(false);
2605 return;
2606 }
2607 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2608 }
2609}
2610
2611struct SettingsCommand;
2612impl SlashCommand for SettingsCommand {
2613 fn name(&self) -> &'static str {
2614 "/settings"
2615 }
2616 fn description(&self) -> &'static str {
2617 "Open settings menu"
2618 }
2619 fn execute(&self, ctx: &CommandContext, _args: &str) {
2620 open_settings_selector(
2621 &ctx.state,
2622 &ctx.editor_container,
2623 &ctx.editor,
2624 &ctx.tui,
2625 &ctx.lane,
2626 &ctx.model_catalog,
2627 &ctx.lane_model_id,
2628 &ctx.chat,
2629 &ctx.cwd,
2630 &ctx.package_resources,
2631 );
2632 }
2633}
2634
2635struct ScopedModelsCommand;
2636impl SlashCommand for ScopedModelsCommand {
2637 fn name(&self) -> &'static str {
2638 "/scoped-models"
2639 }
2640 fn description(&self) -> &'static str {
2641 "Choose models for Ctrl+M cycling"
2642 }
2643 fn execute(&self, ctx: &CommandContext, _args: &str) {
2644 open_scoped_models_selector(
2645 &ctx.state,
2646 &ctx.editor_container,
2647 &ctx.editor,
2648 &ctx.tui,
2649 &ctx.model_catalog,
2650 &ctx.chat,
2651 );
2652 }
2653}
2654
2655struct ShareCommand;
2656impl SlashCommand for ShareCommand {
2657 fn name(&self) -> &'static str {
2658 "/share"
2659 }
2660 fn description(&self) -> &'static str {
2661 "Share session (gist via gh, or clipboard)"
2662 }
2663 fn execute(&self, ctx: &CommandContext, _args: &str) {
2664 let _ = ctx.tx.send(TuiMessage::ShareSession);
2665 }
2666}
2667
2668struct ArminCommand;
2669impl SlashCommand for ArminCommand {
2670 fn name(&self) -> &'static str {
2671 "/armin"
2672 }
2673 fn description(&self) -> &'static str {
2674 "??? (easter egg)"
2675 }
2676 fn execute(&self, ctx: &CommandContext, _args: &str) {
2677 crate::extras::add_armin(&ctx.chat);
2678 ctx.tui.request_render(false);
2679 }
2680}
2681
2682struct EarendilCommand;
2683impl SlashCommand for EarendilCommand {
2684 fn name(&self) -> &'static str {
2685 "/earendil"
2686 }
2687 fn description(&self) -> &'static str {
2688 "Announcement"
2689 }
2690 fn execute(&self, ctx: &CommandContext, _args: &str) {
2691 crate::extras::add_earendil(&ctx.chat);
2692 ctx.tui.request_render(false);
2693 }
2694}
2695
2696struct ContextCommand;
2700impl SlashCommand for ContextCommand {
2701 fn name(&self) -> &'static str {
2702 "/context"
2703 }
2704 fn visible(&self) -> bool {
2705 false
2706 }
2707 fn execute(&self, ctx: &CommandContext, _args: &str) {
2708 show_context_panel(&ctx.chat, &ctx.resources);
2709 ctx.tui.request_render(false);
2710 }
2711}
2712
2713struct ReloadCommand;
2724impl SlashCommand for ReloadCommand {
2725 fn name(&self) -> &'static str {
2726 "/reload"
2727 }
2728 fn description(&self) -> &'static str {
2729 "Reload extensions, skills, prompts"
2730 }
2731 fn execute(&self, ctx: &CommandContext, _args: &str) {
2732 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2736 ctx.tui.request_render(false);
2737 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2738 }
2739}
2740
2741fn build_builtin_registry() -> CommandRegistry {
2746 let mut r = CommandRegistry::new();
2747 r.register(Arc::new(HelpCommand));
2748 r.register(Arc::new(ClearChatCommand));
2749 r.register(Arc::new(ExitCommand));
2750 r.register(Arc::new(VersionCommand));
2751 r.register(Arc::new(ChangelogCommand));
2752 r.register(Arc::new(ModelCommand));
2753 r.register(Arc::new(ThinkingCommand));
2754 r.register(Arc::new(ToolsCommand));
2755 r.register(Arc::new(ImagesCommand));
2756 r.register(Arc::new(SessionCommand));
2757 r.register(Arc::new(ThemeCommand));
2758 r.register(Arc::new(CompactCommand));
2759 r.register(Arc::new(CopyCommand));
2760 r.register(Arc::new(HotkeysCommand));
2761 r.register(Arc::new(ArminCommand));
2762 r.register(Arc::new(EarendilCommand));
2763 r.register(Arc::new(ContextCommand));
2764 r.register(Arc::new(NameCommand));
2768 r.register(Arc::new(SettingsCommand));
2769 r.register(Arc::new(ScopedModelsCommand));
2770 r.register(Arc::new(ExportCommand));
2771 r.register(Arc::new(ImportCommand));
2772 r.register(Arc::new(ShareCommand));
2773 r.register(Arc::new(ForkCommand));
2774 r.register(Arc::new(CloneCommand));
2775 r.register(Arc::new(TreeCommand));
2776 r.register(Arc::new(TrustCommand));
2777 r.register(Arc::new(LoginCommand));
2778 r.register(Arc::new(LogoutCommand));
2779 r.register(Arc::new(ReloadCommand));
2780 r
2781}
2782
2783fn register_extension_commands(
2784 registry: &mut CommandRegistry,
2785 session: crate::session::ExtensionSessionCell,
2786) {
2787 let commands = session
2788 .lock()
2789 .ok()
2790 .and_then(|s| s.snapshot_arc())
2791 .map(|snap| snap.commands().to_vec())
2792 .unwrap_or_default();
2793 for command in commands {
2794 let name = if command.name.starts_with('/') {
2795 command.name.clone()
2796 } else {
2797 format!("/{}", command.name)
2798 };
2799 if registry.find(&name).is_some() {
2800 continue;
2801 }
2802 registry.register(Arc::new(ExtensionCommand {
2803 name,
2804 description: command.description,
2805 session: session.clone(),
2806 }));
2807 }
2808}
2809
2810fn register_js_extension_commands(
2811 registry: &mut CommandRegistry,
2812 session: Option<crate::js_extensions::JsExtensionSession>,
2813) {
2814 let Some(session) = session else {
2815 return;
2816 };
2817 for command in &session.commands {
2818 let name = if command.starts_with('/') {
2819 command.clone()
2820 } else {
2821 format!("/{command}")
2822 };
2823 if registry.find(&name).is_none() {
2824 registry.register(Arc::new(JsExtensionCommand {
2825 name,
2826 session: session.clone(),
2827 }));
2828 }
2829 }
2830}
2831
2832enum TuiMessage {
2839 UserInput(String),
2840 OpenTree,
2841 NavigateTree(String),
2842 Exit,
2843 ClearChat,
2845 Compact,
2847 Copy,
2849 SwitchSession(String),
2852 ExportSession,
2854 ForkSession,
2856 SetSessionName(String),
2858 ImportSession(String),
2861 ShareSession,
2864 ReloadExtensions,
2869 ExternalEditorResult(Result<String, String>),
2873}
2874
2875fn assistant_text(msg: &AssistantMessage) -> String {
2878 msg.content
2879 .iter()
2880 .filter_map(|c| match c {
2881 Content::Text(t) => Some(t.text.clone()),
2882 _ => None,
2883 })
2884 .collect()
2885}
2886
2887fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2890 msg.content
2891 .iter()
2892 .filter_map(|c| match c {
2893 Content::Text(t) => Some(t.text.clone()),
2894 _ => None,
2895 })
2896 .collect::<Vec<_>>()
2897 .join("\n")
2898}
2899
2900fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2901 match &msg.content {
2902 rpi_ai::types::UserContent::Text(s) => s.clone(),
2903 rpi_ai::types::UserContent::Blocks(blocks) => blocks
2904 .iter()
2905 .filter_map(|c| match c {
2906 Content::Text(t) => Some(t.text.clone()),
2907 _ => None,
2908 })
2909 .collect(),
2910 }
2911}
2912
2913fn show_settings_panel(chat: &Arc<Container>) {
2918 let s = crate::settings::load_settings().unwrap_or_default();
2919 let mut lines: Vec<String> = Vec::new();
2920 lines.push("⚙️ Saved settings:".into());
2921 lines.push(format!(
2922 " Theme: {} (edit with /theme)",
2923 s.theme.as_deref().unwrap_or("(default)")
2924 ));
2925 lines.push(format!(
2926 " Default model: {} (set at launch with --model)",
2927 s.default_model.as_deref().unwrap_or("(none)")
2928 ));
2929 lines.push(format!(
2930 " Default thinking: {} (set at launch with --thinking)",
2931 s.default_thinking_level.as_deref().unwrap_or("(default)")
2932 ));
2933 match &s.scoped_models {
2934 Some(list) if !list.is_empty() => lines.push(format!(
2935 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
2936 list.join(", ")
2937 )),
2938 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
2939 }
2940 let body = lines.join("\n");
2941 container_note_block(chat, &body);
2942}
2943
2944fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2948 let scoped = crate::settings::load_settings()
2949 .ok()
2950 .and_then(|s| s.scoped_models)
2951 .unwrap_or_default();
2952 if scoped.is_empty() {
2953 return catalog.to_vec();
2954 }
2955 let mut out: Vec<rpi_ai::Model> = catalog
2956 .iter()
2957 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2958 .cloned()
2959 .collect();
2960 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2962 if let Some(cur) = catalog
2963 .iter()
2964 .find(|m| m.id.eq_ignore_ascii_case(current_id))
2965 {
2966 out.push(cur.clone());
2967 }
2968 }
2969 out
2970}
2971
2972fn open_settings_selector(
2979 state: &Arc<TuiState>,
2980 editor_container: &Arc<Container>,
2981 editor: &Arc<Editor>,
2982 tui: &Arc<TuiAltScreen>,
2983 lane: &Arc<dyn AgentLane>,
2984 catalog: &[rpi_ai::Model],
2985 lane_model_id: &str,
2986 chat: &Arc<Container>,
2987 cwd: &std::path::Path,
2988 package_resources: &Arc<crate::packages::PackageResources>,
2989) {
2990 let settings = crate::settings::load_settings().unwrap_or_default();
2991 let mut items: Vec<SelectItem> = Vec::new();
2992 items.push(
2993 SelectItem::new("theme", "Theme")
2994 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
2995 );
2996 items.push(
2997 SelectItem::new("model", "Default model").with_description(
2998 &settings
2999 .default_model
3000 .clone()
3001 .unwrap_or_else(|| "(none)".into()),
3002 ),
3003 );
3004 items.push(
3005 SelectItem::new("thinking", "Default thinking").with_description(
3006 &settings
3007 .default_thinking_level
3008 .clone()
3009 .unwrap_or_else(|| "(default)".into()),
3010 ),
3011 );
3012 let scope_desc = match &settings.scoped_models {
3013 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
3014 _ => "all models".to_string(),
3015 };
3016 items
3017 .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
3018 let list = Arc::new(SelectList::new(items, 10));
3019
3020 let state_sel = state.clone();
3021 let ec_sel = editor_container.clone();
3022 let editor_sel = editor.clone();
3023 let tui_sel = tui.clone();
3024 let lane_sel = lane.clone();
3025 let chat_sel = chat.clone();
3026 let catalog_sel = catalog.to_vec();
3027 let lane_model_sel = lane_model_id.to_string();
3028 let cwd_sel = cwd.to_path_buf();
3029 let package_resources_sel = package_resources.clone();
3030 list.on_select(Arc::new(move |item| {
3031 match item.value.as_str() {
3034 "theme" => open_settings_theme_selector(
3035 &state_sel,
3036 &ec_sel,
3037 &editor_sel,
3038 &tui_sel,
3039 &chat_sel,
3040 &cwd_sel,
3041 &package_resources_sel,
3042 ),
3043 "model" => open_settings_model_selector(
3044 &state_sel,
3045 &ec_sel,
3046 &editor_sel,
3047 &tui_sel,
3048 &lane_sel,
3049 &catalog_sel,
3050 &lane_model_sel,
3051 &chat_sel,
3052 ),
3053 "thinking" => open_settings_thinking_selector(
3054 &state_sel,
3055 &ec_sel,
3056 &editor_sel,
3057 &tui_sel,
3058 &lane_sel,
3059 &catalog_sel,
3060 &lane_model_sel,
3061 &chat_sel,
3062 ),
3063 "scoped-models" => open_scoped_models_selector(
3064 &state_sel,
3065 &ec_sel,
3066 &editor_sel,
3067 &tui_sel,
3068 &catalog_sel,
3069 &chat_sel,
3070 ),
3071 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3072 }
3073 }));
3074 let state_cancel = state.clone();
3075 let ec_cancel = editor_container.clone();
3076 let editor_cancel = editor.clone();
3077 let tui_cancel = tui.clone();
3078 list.on_cancel(Arc::new(move || {
3079 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3080 }));
3081
3082 open_selector(
3083 state,
3084 editor_container,
3085 editor,
3086 tui,
3087 list,
3088 SelectorKind::Settings,
3089 );
3090}
3091
3092fn open_settings_theme_selector(
3094 state: &Arc<TuiState>,
3095 editor_container: &Arc<Container>,
3096 editor: &Arc<Editor>,
3097 tui: &Arc<TuiAltScreen>,
3098 chat: &Arc<Container>,
3099 cwd: &std::path::Path,
3100 package_resources: &Arc<crate::packages::PackageResources>,
3101) {
3102 let mut items = vec![
3103 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3104 SelectItem::new("light", "Light").with_description("Light background"),
3105 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3106 ];
3107 if state.themes_enabled {
3108 for path in package_resources.theme_files() {
3109 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3110 items.push(SelectItem::new(name, name).with_description("Package theme"));
3111 }
3112 }
3113 }
3114 let list = Arc::new(SelectList::new(items, 10));
3115
3116 let state_sel = state.clone();
3117 let ec_sel = editor_container.clone();
3118 let editor_sel = editor.clone();
3119 let tui_sel = tui.clone();
3120 let chat_sel = chat.clone();
3121 let cwd_sel = cwd.to_path_buf();
3122 let package_resources_sel = package_resources.clone();
3123 list.on_select(Arc::new(move |item| {
3124 let preset = match item.value.as_str() {
3125 "light" => Some(ThemePreset::Light),
3126 "monochrome" => Some(ThemePreset::Monochrome),
3127 "dark" => Some(ThemePreset::Dark),
3128 name => {
3129 if state_sel.themes_enabled {
3130 if let Ok(custom) = crate::packages::load_theme_with_resources(
3131 &cwd_sel,
3132 name,
3133 &package_resources_sel,
3134 ) {
3135 rpi_tui::global_theme_manager().set(custom.clone());
3136 state_sel.theme_manager.set(custom);
3137 }
3138 }
3139 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3140 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3141 tui_sel.render_now(true);
3142 return;
3143 }
3144 };
3145 let Some(preset) = preset else { return };
3146 apply_theme_preset(preset);
3147 state_sel.theme_manager.apply_preset(preset);
3148 let mut settings = crate::settings::load_settings().unwrap_or_default();
3149 settings.theme = Some(item.value.clone());
3150 let saved = crate::settings::save_settings(&settings);
3151 add_note_message(
3152 &chat_sel,
3153 &format!(
3154 "Theme set to {} (saved{})",
3155 item.label,
3156 if saved.is_ok() { "" } else { ", not saved" },
3157 ),
3158 );
3159 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3160 tui_sel.render_now(true);
3161 }));
3162 let state_cancel = state.clone();
3163 let ec_cancel = editor_container.clone();
3164 let editor_cancel = editor.clone();
3165 let tui_cancel = tui.clone();
3166 list.on_cancel(Arc::new(move || {
3167 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3168 }));
3169
3170 open_selector(
3171 state,
3172 editor_container,
3173 editor,
3174 tui,
3175 list,
3176 SelectorKind::Settings,
3177 );
3178}
3179
3180fn open_settings_model_selector(
3184 state: &Arc<TuiState>,
3185 editor_container: &Arc<Container>,
3186 editor: &Arc<Editor>,
3187 tui: &Arc<TuiAltScreen>,
3188 lane: &Arc<dyn AgentLane>,
3189 catalog: &[rpi_ai::Model],
3190 lane_model_id: &str,
3191 chat: &Arc<Container>,
3192) {
3193 let items = model_selector_items(catalog, lane_model_id);
3194 if items.is_empty() {
3195 add_note_message(chat, "No models in the catalog.");
3196 tui.request_render(false);
3197 return;
3198 }
3199 let list = Arc::new(SelectList::new(items, 10));
3200
3201 let catalog_arc = catalog.to_vec();
3202 let state_sel = state.clone();
3203 let ec_sel = editor_container.clone();
3204 let editor_sel = editor.clone();
3205 let tui_sel = tui.clone();
3206 let chat_sel = chat.clone();
3207 let lane_sel = lane.clone();
3208 list.on_select(Arc::new(move |item| {
3209 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3210 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3211 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3212 return;
3213 };
3214 state_sel.set_current_model(&model);
3215 let lane = lane_sel.clone();
3216 tokio::spawn(async move {
3217 let _ = lane.set_model(model).await;
3218 });
3219 let mut settings = crate::settings::load_settings().unwrap_or_default();
3220 settings.default_model = Some(item.value.clone());
3221 let saved = crate::settings::save_settings(&settings);
3222 add_note_message(
3223 &chat_sel,
3224 &format!(
3225 "Default model set to {} (saved{}",
3226 short_model_name(&item.value),
3227 if saved.is_ok() { ")" } else { ", not saved)" },
3228 ),
3229 );
3230 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3231 }));
3232 let state_cancel = state.clone();
3233 let ec_cancel = editor_container.clone();
3234 let editor_cancel = editor.clone();
3235 let tui_cancel = tui.clone();
3236 list.on_cancel(Arc::new(move || {
3237 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3238 }));
3239
3240 open_selector(
3241 state,
3242 editor_container,
3243 editor,
3244 tui,
3245 list,
3246 SelectorKind::Settings,
3247 );
3248}
3249
3250fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3255 let mut seen = std::collections::HashSet::new();
3256 catalog
3257 .iter()
3258 .filter(|m| {
3259 seen.insert((
3260 m.api.clone(),
3261 m.provider.to_ascii_lowercase(),
3262 m.id.to_ascii_lowercase(),
3263 ))
3264 })
3265 .map(|m| {
3266 let label = if m.name.is_empty() {
3267 short_model_name(&m.id)
3268 } else {
3269 m.name.clone()
3270 };
3271 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3272 && m.provider.eq_ignore_ascii_case("anthropic")
3273 {
3274 m.id.clone()
3275 } else {
3276 format!("{}/{}", m.provider, m.id)
3277 };
3278 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3279 " (current)"
3280 } else {
3281 ""
3282 };
3283 SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
3284 })
3285 .collect()
3286}
3287
3288fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3292 let (provider, id) = input
3293 .split_once('/')
3294 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3295 .map_or((None, input), |(provider, id)| (Some(provider), id));
3296 catalog
3297 .iter()
3298 .find(|model| {
3299 model.id.eq_ignore_ascii_case(id)
3300 && provider.map_or(true, |provider| {
3301 model.provider.eq_ignore_ascii_case(provider)
3302 || (provider.eq_ignore_ascii_case("anthropic")
3303 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3304 })
3305 })
3306 .cloned()
3307}
3308
3309fn open_settings_thinking_selector(
3313 state: &Arc<TuiState>,
3314 editor_container: &Arc<Container>,
3315 editor: &Arc<Editor>,
3316 tui: &Arc<TuiAltScreen>,
3317 lane: &Arc<dyn AgentLane>,
3318 catalog: &[rpi_ai::Model],
3319 lane_model_id: &str,
3320 chat: &Arc<Container>,
3321) {
3322 let model = catalog
3323 .iter()
3324 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3325 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3326 .map(|m| m.supported_thinking_levels())
3327 .unwrap_or_else(|| {
3328 use rpi_ai::types::ThinkingLevel::*;
3329 vec![Off, Minimal, Low, Medium, High]
3330 });
3331 let mut items: Vec<SelectItem> = Vec::new();
3332 for lvl in &levels {
3333 let name = thinking_level_name(*lvl);
3334 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3335 }
3336 if items.is_empty() {
3337 add_note_message(chat, "This model has no supported thinking levels.");
3338 tui.request_render(false);
3339 return;
3340 }
3341 let list = Arc::new(SelectList::new(items, 10));
3342
3343 let state_sel = state.clone();
3344 let ec_sel = editor_container.clone();
3345 let editor_sel = editor.clone();
3346 let tui_sel = tui.clone();
3347 let chat_sel = chat.clone();
3348 let lane_sel = lane.clone();
3349 list.on_select(Arc::new(move |item| {
3350 let Some(level) = thinking_level_from_name(&item.value) else {
3351 add_note_message(
3352 &chat_sel,
3353 &format!("Unknown thinking level: {}.", item.label),
3354 );
3355 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3356 return;
3357 };
3358 let lane = lane_sel.clone();
3359 let footer_sel = state_sel.footer.clone();
3360 tokio::spawn(async move {
3361 let _ = lane.set_thinking_level(level).await;
3362 });
3363 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3364 let mut settings = crate::settings::load_settings().unwrap_or_default();
3365 settings.default_thinking_level = Some(item.value.clone());
3366 let saved = crate::settings::save_settings(&settings);
3367 add_note_message(
3368 &chat_sel,
3369 &format!(
3370 "Default thinking set to {} (saved{}",
3371 item.label,
3372 if saved.is_ok() { ")" } else { ", not saved)" },
3373 ),
3374 );
3375 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3376 }));
3377 let state_cancel = state.clone();
3378 let ec_cancel = editor_container.clone();
3379 let editor_cancel = editor.clone();
3380 let tui_cancel = tui.clone();
3381 list.on_cancel(Arc::new(move || {
3382 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3383 }));
3384
3385 open_selector(
3386 state,
3387 editor_container,
3388 editor,
3389 tui,
3390 list,
3391 SelectorKind::Settings,
3392 );
3393}
3394
3395fn open_scoped_models_selector(
3400 state: &Arc<TuiState>,
3401 editor_container: &Arc<Container>,
3402 editor: &Arc<Editor>,
3403 tui: &Arc<TuiAltScreen>,
3404 catalog: &[rpi_ai::Model],
3405 chat: &Arc<Container>,
3406) {
3407 if catalog.is_empty() {
3408 add_note_message(chat, "No models in the catalog.");
3409 tui.request_render(false);
3410 return;
3411 }
3412 let seed: Vec<String> = crate::settings::load_settings()
3414 .ok()
3415 .and_then(|s| s.scoped_models)
3416 .unwrap_or_default();
3417 *state.scoped_edit.lock().unwrap() = Some(seed);
3418
3419 let mut items: Vec<SelectItem> = Vec::new();
3420 for m in catalog {
3421 items.push(SelectItem::new(&m.id, &m.id));
3422 }
3423 let list = Arc::new(SelectList::new(items, 10));
3424
3425 let state_sel = state.clone();
3426 let chat_sel = chat.clone();
3427 let tui_sel = tui.clone();
3428 list.on_select(Arc::new(move |item| {
3429 let mut set = state_sel.scoped_edit.lock().unwrap();
3431 let set = set.get_or_insert_with(Vec::new);
3432 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3433 set.remove(pos);
3434 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3435 } else {
3436 set.push(item.value.clone());
3437 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3438 }
3439 tui_sel.request_render(false);
3440 }));
3441 let state_cancel = state.clone();
3442 let ec_cancel = editor_container.clone();
3443 let editor_cancel = editor.clone();
3444 let tui_cancel = tui.clone();
3445 let chat_cancel = chat.clone();
3446 list.on_cancel(Arc::new(move || {
3447 let set = state_cancel
3449 .scoped_edit
3450 .lock()
3451 .unwrap()
3452 .take()
3453 .unwrap_or_default();
3454 let mut settings = crate::settings::load_settings().unwrap_or_default();
3455 settings.scoped_models = if set.is_empty() {
3456 None
3457 } else {
3458 Some(set.clone())
3459 };
3460 match crate::settings::save_settings(&settings) {
3461 Ok(()) => {
3462 if set.is_empty() {
3463 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3464 } else {
3465 add_note_message(
3466 &chat_cancel,
3467 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3468 );
3469 }
3470 }
3471 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3472 }
3473 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3474 }));
3475
3476 open_selector(
3477 state,
3478 editor_container,
3479 editor,
3480 tui,
3481 list,
3482 SelectorKind::ScopedModels,
3483 );
3484}
3485
3486async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3490 use std::process::Stdio;
3491
3492 let tree = harness.session().view("main");
3494 let entries = match tree
3495 .find_entries(&EntryQuery {
3496 entry_type: None,
3497 custom_type: None,
3498 order: Some(EntryOrder::OldestFirst),
3501 limit: None,
3502 cursor: None,
3503 })
3504 .await
3505 {
3506 Ok(e) => e,
3507 Err(e) => {
3508 add_error_message(chat, &format!("Could not read session: {e}"));
3509 return;
3510 }
3511 };
3512 let mut md = String::from("# Session\n\n");
3513 for e in entries {
3514 let Entry::Message(me) = e else { continue };
3515 match &me.message {
3516 AgentMessage::User(u) => {
3517 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3518 }
3519 AgentMessage::Assistant(a) => {
3520 let text = assistant_text(a);
3521 if !text.is_empty() {
3522 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3523 }
3524 }
3525 _ => {}
3526 }
3527 }
3528
3529 let gh = std::process::Command::new("gh")
3531 .arg("gist")
3532 .arg("create")
3533 .arg("--filename")
3534 .arg("session.md")
3535 .arg("-")
3536 .stdin(Stdio::piped())
3537 .stdout(Stdio::piped())
3538 .stderr(Stdio::null())
3539 .spawn();
3540 if let Ok(mut child) = gh {
3541 use std::io::Write;
3542 if let Some(mut stdin) = child.stdin.take() {
3543 let _ = stdin.write_all(md.as_bytes());
3544 let _ = stdin.flush();
3545 }
3546 let out = child.wait_with_output().ok();
3547 if let Some(out) = out {
3548 if out.status.success() {
3549 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3550 add_note_message(chat, &format!("Shared session: {url}"));
3551 return;
3552 }
3553 }
3554 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3555 } else {
3556 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3557 }
3558 if copy_to_clipboard(&md) {
3560 add_note_message(chat, "Session transcript copied to the clipboard.");
3561 } else {
3562 add_note_message(
3563 chat,
3564 "Clipboard unavailable — use /export to write the transcript to a file.",
3565 );
3566 }
3567}
3568
3569async fn export_session(harness: &AgentHarness, chat: &Arc<Container>, cwd: &std::path::Path) {
3578 let tree = harness.session().view("main");
3579 let entries = match tree
3580 .find_entries(&EntryQuery {
3581 entry_type: None,
3582 custom_type: None,
3583 order: Some(EntryOrder::OldestFirst),
3586 limit: None,
3587 cursor: None,
3588 })
3589 .await
3590 {
3591 Ok(e) => e,
3592 Err(e) => {
3593 add_error_message(chat, &format!("Could not read session: {e}"));
3594 return;
3595 }
3596 };
3597 let name = tree.get_name().await.ok().flatten().unwrap_or_default();
3598 let id = tree
3599 .get_leaf_id()
3600 .await
3601 .ok()
3602 .flatten()
3603 .unwrap_or_else(|| "session".to_string());
3604 let mut md = String::from("# Session\n\n");
3605 for e in entries {
3606 let Entry::Message(me) = e else { continue };
3607 match &me.message {
3608 AgentMessage::User(u) => {
3609 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3610 }
3611 AgentMessage::Assistant(a) => {
3612 let text = assistant_text(a);
3613 if !text.is_empty() {
3614 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3615 }
3616 }
3617 _ => {}
3618 }
3619 }
3620 let file_name = if name.is_empty() {
3621 format!("{id}.md")
3622 } else {
3623 format!("{name}.md")
3624 };
3625 let path = cwd.join(&file_name);
3626 match std::fs::write(&path, md) {
3627 Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
3628 Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
3629 }
3630}
3631
3632async fn switch_to_session(
3642 harness: &AgentHarness,
3643 lane: &Arc<dyn AgentLane>,
3644 id: &str,
3645 cwd: &std::path::Path,
3646 chat: &Arc<Container>,
3647 state: &Arc<TuiState>,
3648) -> bool {
3649 if *state.status.lock().unwrap() == RunStatus::Working {
3650 state.set_status(RunStatus::Aborting);
3651 let _ = lane.abort().await;
3652 }
3653 let cwd_str = cwd.to_string_lossy().to_string();
3654 match crate::session::open_session_by_id(id, &cwd_str).await {
3655 Ok(new_session) => {
3656 let _ = harness.set_session(new_session).await;
3657 chat.clear();
3658 add_welcome_message(chat);
3659 render_session_history(
3660 harness,
3661 chat,
3662 state.markdown_transformer(),
3663 Some(state.extension_session.clone()),
3664 )
3665 .await;
3666 state.set_status(RunStatus::Idle);
3667 add_note_message(chat, &format!("Switched to session {id}."));
3668 true
3669 }
3670 Err(e) => {
3671 state.set_status(RunStatus::Idle);
3672 add_error_message(chat, &format!("Could not open session {id}: {e}"));
3673 false
3674 }
3675 }
3676}
3677
3678async fn import_session(
3682 harness: &AgentHarness,
3683 lane: &Arc<dyn AgentLane>,
3684 path: &str,
3685 cwd: &std::path::Path,
3686 chat: &Arc<Container>,
3687 state: &Arc<TuiState>,
3688) {
3689 use std::path::Path as FsPath;
3690
3691 let src = FsPath::new(path);
3692 if !src.is_file() {
3693 add_error_message(chat, &format!("Import source not found: {path}"));
3694 return;
3695 }
3696 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3697 add_error_message(chat, "Import source has no file name.");
3698 return;
3699 };
3700 if !fname.ends_with(".jsonl") {
3701 add_error_message(chat, "Import source must be a .jsonl session file.");
3702 return;
3703 }
3704 let dir = crate::session::default_session_dir(cwd);
3705 if let Err(e) = std::fs::create_dir_all(&dir) {
3706 add_error_message(chat, &format!("Could not create session dir: {e}"));
3707 return;
3708 }
3709 let dest = dir.join(fname);
3710 match std::fs::copy(src, &dest) {
3711 Ok(_) => {
3712 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3713 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3714 add_note_message(chat, &format!("Imported session from {path}"));
3715 }
3716 }
3717 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3718 }
3719}
3720
3721async fn fork_session(
3722 harness: &AgentHarness,
3723 cwd: &std::path::Path,
3724 chat: &Arc<Container>,
3725 state: &Arc<TuiState>,
3726) {
3727 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3728 use rpi_tools::FileSystem;
3729
3730 let cwd_str = cwd.to_string_lossy().to_string();
3731 let dir = crate::session::default_session_dir(cwd);
3732 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3733 let fs: Arc<dyn FileSystem> = env.clone();
3734 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3735 fs,
3736 sessions_root: dir.to_string_lossy().into_owned(),
3737 clock: Arc::new(rpi_harness::session::memory::SystemClock),
3738 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3739 });
3740 let id = harness.session().storage().metadata().id.clone();
3743 let metas = match crate::session::list_session_metadata(&cwd_str).await {
3744 Ok(m) => m,
3745 Err(e) => {
3746 add_error_message(chat, &format!("Could not list sessions: {e}"));
3747 return;
3748 }
3749 };
3750 let Some(source) = metas.iter().find(|m| m.id == id) else {
3751 add_error_message(chat, &format!("Current session {id} not found on disk."));
3752 return;
3753 };
3754 let fork_storage = match repo
3755 .fork_typed(
3756 source,
3757 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3758 id: None,
3759 parent_session_id: Some(source.id.clone()),
3760 cwd: cwd_str.clone(),
3761 metadata: None,
3762 },
3763 &rpi_harness::session::types::ForkOptions::default(),
3764 )
3765 .await
3766 {
3767 Ok(s) => s,
3768 Err(e) => {
3769 add_error_message(chat, &format!("Could not fork session: {e}"));
3770 return;
3771 }
3772 };
3773 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3774 let _ = harness.set_session(new_session).await;
3775 chat.clear();
3776 add_welcome_message(chat);
3777 render_session_history(
3778 harness,
3779 chat,
3780 state.markdown_transformer(),
3781 Some(state.extension_session.clone()),
3782 )
3783 .await;
3784 state.set_status(RunStatus::Idle);
3785 add_note_message(chat, "Forked into a new session.");
3786}
3787
3788async fn render_session_history(
3798 harness: &AgentHarness,
3799 chat: &Arc<Container>,
3800 transformer: Option<MarkdownTransformer>,
3801 extension_session: Option<crate::session::ExtensionSessionCell>,
3802) {
3803 let tree = harness.session().view("main");
3804 let entries = match tree
3805 .find_entries(&EntryQuery {
3806 entry_type: None,
3807 custom_type: None,
3808 order: Some(EntryOrder::OldestFirst),
3812 limit: None,
3813 cursor: None,
3814 })
3815 .await
3816 {
3817 Ok(e) => e,
3818 Err(_) => return,
3819 };
3820 let mut rendered_any = false;
3821 for e in entries {
3822 match e {
3823 Entry::Message(me) => match &me.message {
3824 AgentMessage::User(u) => {
3825 add_user_message(chat, &user_message_text(u));
3826 rendered_any = true;
3827 }
3828 AgentMessage::Assistant(a) => {
3829 let comp = Arc::new(AssistantMessageComponent::new(
3830 AssistantMessageOptions::default(),
3831 ));
3832 if let Some(t) = &transformer {
3833 comp.set_markdown_transformer(Some(t.clone()));
3834 }
3835 comp.update_blocks(&assistant_blocks(a));
3836 chat.add_child(comp);
3837 chat.add_child(Arc::new(Spacer::new(1)));
3840 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3841 add_note_message(chat, &text);
3842 }
3843 if let Some(error) = assistant_error_text(a) {
3844 add_error_message(chat, &error);
3845 }
3846 rendered_any = true;
3847 }
3848 AgentMessage::ToolResult(result) => {
3849 let comp = Arc::new(ToolExecutionComponent::new(
3854 &result.tool_name,
3855 "",
3856 ));
3857 comp.set_result(&tool_result_message_text(result), result.is_error);
3858 chat.add_child(comp);
3859 chat.add_child(Arc::new(Spacer::new(1)));
3860 rendered_any = true;
3861 }
3862 AgentMessage::Custom(custom) => {
3863 if let Some(session) = &extension_session {
3864 if let Some(component) = extension_message_component(
3865 session,
3866 &custom.role,
3867 &serde_json::json!({
3868 "customType": custom.role,
3869 "content": custom.content,
3870 "details": custom.data,
3871 }),
3872 transformer.clone(),
3873 ) {
3874 chat.add_child(component);
3875 chat.add_child(Arc::new(Spacer::new(1)));
3876 rendered_any = true;
3877 continue;
3878 }
3879 }
3880 add_note_message(chat, &custom_message_fallback(&custom));
3881 rendered_any = true;
3882 }
3883 },
3884 Entry::Compaction(compaction) => {
3885 add_note_message(
3886 chat,
3887 &format!(
3888 "Compacted {} tokens: {}",
3889 compaction.tokens_before, compaction.summary
3890 ),
3891 );
3892 rendered_any = true;
3893 }
3894 Entry::BranchSummary(summary) => {
3895 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3896 rendered_any = true;
3897 }
3898 Entry::Custom(custom) => {
3899 let rendered = extension_session.as_ref().and_then(|session| {
3900 extension_entry_component(session, &custom.custom_type, custom.data.clone())
3901 });
3902 if let Some(component) = rendered {
3903 chat.add_child(component);
3904 chat.add_child(Arc::new(Spacer::new(1)));
3905 rendered_any = true;
3906 } else if let Some(text) =
3907 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3908 {
3909 add_note_message(chat, &text);
3910 rendered_any = true;
3911 }
3912 }
3913 Entry::ModelChange(change) => {
3914 add_note_message(
3915 chat,
3916 &format!("Model changed to {}:{}", change.provider, change.model_id),
3917 );
3918 rendered_any = true;
3919 }
3920 Entry::ThinkingLevel(change) => {
3921 add_note_message(
3922 chat,
3923 &format!("Thinking level: {:?}", change.thinking_level),
3924 );
3925 rendered_any = true;
3926 }
3927 Entry::ActiveTools(change) => {
3928 add_note_message(
3929 chat,
3930 &format!("Active tools: {}", change.active_tool_names.join(", ")),
3931 );
3932 rendered_any = true;
3933 }
3934 }
3935 }
3936 if rendered_any {
3937 }
3940}
3941
3942fn invoke_extension_renderer(
3943 session: &crate::session::ExtensionSessionCell,
3944 kind: rpi_extensions::RegisteredRendererKind,
3945 payload: &serde_json::Value,
3946) -> Option<serde_json::Value> {
3947 let snapshot = session.lock().ok()?.snapshot_arc()?;
3948 let input = serde_json::to_string(payload).ok()?;
3949 for renderer in snapshot.renderers_of(kind) {
3950 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3951 let mut out = rpi_plugin_sdk::StbString::empty();
3952 let rc = (renderer.render_fn)(
3953 rpi_plugin_sdk::StbStringRef::from_str(&input),
3954 &mut out as *mut rpi_plugin_sdk::StbString,
3955 renderer.user_data,
3956 );
3957 let text = if rc == 0 {
3958 Some(out.to_string_lossy())
3959 } else {
3960 None
3961 };
3962 out.free_with(Some(renderer.plugin_free_string));
3963 text
3964 }))
3965 .ok()
3966 .flatten();
3967 let Some(text) = outcome else { continue };
3968 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3969 return Some(value);
3970 }
3971 }
3972 None
3973}
3974
3975fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3976 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3977 let text = lines
3978 .iter()
3979 .filter_map(|line| line.as_str())
3980 .collect::<Vec<_>>()
3981 .join("\n");
3982 return Some(Arc::new(Text::new(text, 0, 0)));
3983 }
3984 let text = value.get("text").and_then(|v| v.as_str())?;
3985 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3986 let component = Arc::new(AssistantMessageComponent::new(
3987 AssistantMessageOptions::default(),
3988 ));
3989 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3990 Some(component)
3991 } else {
3992 Some(Arc::new(Text::new(text, 0, 0)))
3993 }
3994}
3995
3996fn extension_message_component(
3997 session: &crate::session::ExtensionSessionCell,
3998 custom_type: &str,
3999 payload: &serde_json::Value,
4000 transformer: Option<MarkdownTransformer>,
4001) -> Option<Arc<dyn rpi_tui::Component>> {
4002 let value = invoke_extension_renderer(
4003 session,
4004 rpi_extensions::RegisteredRendererKind::Message,
4005 payload,
4006 )?;
4007 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4008 let text = value.get("text").and_then(|v| v.as_str())?;
4009 let component = Arc::new(AssistantMessageComponent::new(
4010 AssistantMessageOptions::default(),
4011 ));
4012 if let Some(transformer) = transformer {
4013 component.set_markdown_transformer(Some(transformer));
4014 }
4015 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4016 return Some(component);
4017 }
4018 extension_text_component(&value)
4019 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4020}
4021
4022fn extension_usage_text(
4025 session: Option<&crate::session::ExtensionSessionCell>,
4026 usage: &rpi_ai::types::Usage,
4027) -> Option<String> {
4028 let session = session?;
4029 let payload = serde_json::json!({
4030 "customType": "token-usage",
4031 "usage": usage,
4032 });
4033 let value = invoke_extension_renderer(
4034 session,
4035 rpi_extensions::RegisteredRendererKind::Message,
4036 &payload,
4037 )?;
4038 value
4039 .get("text")
4040 .and_then(|value| value.as_str())
4041 .filter(|text| !text.trim().is_empty())
4042 .map(ToOwned::to_owned)
4043}
4044
4045fn extension_entry_component(
4046 session: &crate::session::ExtensionSessionCell,
4047 custom_type: &str,
4048 data: Option<serde_json::Value>,
4049) -> Option<Arc<dyn rpi_tui::Component>> {
4050 let payload = serde_json::json!({
4051 "customType": custom_type,
4052 "data": data,
4053 });
4054 let value = invoke_extension_renderer(
4055 session,
4056 rpi_extensions::RegisteredRendererKind::Entry,
4057 &payload,
4058 )?;
4059 extension_text_component(&value)
4060}
4061
4062fn launch_restores_history(args: &Args) -> bool {
4068 args.continue_session
4069 || args.resume
4070 || args.session.is_some()
4071 || args.session_id.is_some()
4072 || args.fork.is_some()
4073}
4074
4075fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4076 msg.content
4077 .iter()
4078 .filter_map(|c| match c {
4079 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4080 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4081 Content::Image(image) => base64::engine::general_purpose::STANDARD
4082 .decode(&image.data)
4083 .ok()
4084 .filter(|data| !data.is_empty())
4085 .map(AssistantBlock::Image),
4086 _ => None,
4087 })
4088 .collect()
4089}
4090
4091fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4092 let content = custom
4093 .content
4094 .iter()
4095 .filter_map(|item| match item {
4096 Content::Text(text) => Some(text.text.as_str()),
4097 _ => None,
4098 })
4099 .collect::<Vec<_>>()
4100 .join("\n");
4101 if content.is_empty() {
4102 format!("{}: {}", custom.role, custom.data)
4103 } else {
4104 format!("{}: {}", custom.role, content)
4105 }
4106}
4107
4108fn short_model_name(id: &str) -> String {
4111 id.rsplit([':', '/'])
4112 .next()
4113 .filter(|s| !s.is_empty())
4114 .unwrap_or(id)
4115 .to_string()
4116}
4117
4118#[derive(Clone, Copy, PartialEq, Eq)]
4124enum RunStatus {
4125 Idle,
4126 Working,
4127 Aborting,
4128}
4129
4130#[derive(Clone, Copy, PartialEq, Eq)]
4132enum SelectorKind {
4133 Model,
4135 Thinking,
4137 Tools,
4139 Images,
4141 Session,
4143 Theme,
4145 ScopedModels,
4147 Settings,
4149 Tree,
4151 Extension,
4153}
4154
4155struct TuiState {
4158 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4160 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4162 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4166 themes_enabled: bool,
4168 hide_thinking: std::sync::Mutex<bool>,
4170 tool_outputs_expanded: std::sync::Mutex<bool>,
4172 show_terminal_progress: bool,
4174 status: std::sync::Mutex<RunStatus>,
4176 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4180 footer: Arc<FooterComponent>,
4182 status_container: Arc<Container>,
4185 chat_container: Arc<Container>,
4187 loader: Arc<Loader>,
4189 last_assistant_text: std::sync::Mutex<String>,
4192 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
4196 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4198 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4200 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4202 autocomplete: AutocompleteManager,
4205 autocomplete_container: Arc<Container>,
4208 autocomplete_max_visible: usize,
4210 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4212 theme_manager: Arc<ThemeManager>,
4216 tui: Option<Arc<TuiAltScreen>>,
4220 current_model_id: std::sync::Mutex<String>,
4224 show_images: std::sync::Mutex<bool>,
4228 history: std::sync::Mutex<Vec<String>>,
4231 history_index: std::sync::Mutex<isize>,
4234 history_draft: std::sync::Mutex<Option<String>>,
4237 last_input_tokens: std::sync::Mutex<i64>,
4241 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4244 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4254 extension_session: crate::session::ExtensionSessionCell,
4256}
4257
4258const HISTORY_LIMIT: usize = 100;
4261
4262const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
4265
4266const PAGE_SCROLL_OVERLAP: usize = 4;
4269
4270const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4274
4275fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4278 let mut modifiers = KeyModifiers::NONE;
4279 let mut key = None;
4280 for part in value.trim().to_ascii_lowercase().split('+') {
4281 match part {
4282 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4283 "shift" => modifiers |= KeyModifiers::SHIFT,
4284 "alt" | "option" => modifiers |= KeyModifiers::ALT,
4285 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4286 part if !part.is_empty() => key = Some(part.to_string()),
4287 _ => {}
4288 }
4289 }
4290 let key = key?;
4291 let code = match key.as_str() {
4292 "esc" | "escape" => KeyCode::Esc,
4293 "enter" | "return" => KeyCode::Enter,
4294 "tab" => {
4295 if modifiers.contains(KeyModifiers::SHIFT) {
4296 return Some(rpi_tui::KeyCombo::new(
4297 KeyCode::BackTab,
4298 modifiers & !KeyModifiers::SHIFT,
4299 ));
4300 }
4301 KeyCode::Tab
4302 }
4303 "backspace" | "back" => KeyCode::Backspace,
4304 "delete" | "del" => KeyCode::Delete,
4305 "up" | "arrowup" => KeyCode::Up,
4306 "down" | "arrowdown" => KeyCode::Down,
4307 "left" | "arrowleft" => KeyCode::Left,
4308 "right" | "arrowright" => KeyCode::Right,
4309 "home" => KeyCode::Home,
4310 "end" => KeyCode::End,
4311 "pageup" | "page-up" => KeyCode::PageUp,
4312 "pagedown" | "page-down" => KeyCode::PageDown,
4313 "space" => KeyCode::Char(' '),
4314 "f1" => KeyCode::F(1),
4315 "f2" => KeyCode::F(2),
4316 "f3" => KeyCode::F(3),
4317 "f4" => KeyCode::F(4),
4318 "f5" => KeyCode::F(5),
4319 "f6" => KeyCode::F(6),
4320 "f7" => KeyCode::F(7),
4321 "f8" => KeyCode::F(8),
4322 "f9" => KeyCode::F(9),
4323 "f10" => KeyCode::F(10),
4324 "f11" => KeyCode::F(11),
4325 "f12" => KeyCode::F(12),
4326 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4327 _ => return None,
4328 };
4329 Some(rpi_tui::KeyCombo::new(code, modifiers))
4330}
4331
4332fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4333 let mut bindings = rpi_tui::Keybindings::new();
4334 let settings = crate::settings::load_settings().unwrap_or_default();
4335 let Some(overrides) = settings.keybindings else {
4336 rpi_tui::set_keybindings(bindings.clone());
4337 return Arc::new(bindings);
4338 };
4339 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4340 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4341 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4342 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4343 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4344 (
4345 "app.model.cycleForward",
4346 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4347 ),
4348 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4349 (
4350 "app.thinking.toggle",
4351 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4352 ),
4353 (
4354 "app.editor.external",
4355 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4356 ),
4357 (
4358 "app.thinking.cycle",
4359 rpi_tui::keybindings::keys::THINKING_CYCLE,
4360 ),
4361 (
4362 "app.clipboard.pasteImage",
4363 rpi_tui::keybindings::keys::PASTE_IMAGE,
4364 ),
4365 ];
4366 for (name, id) in known {
4367 let Some(value) = overrides.get(*name) else {
4368 continue;
4369 };
4370 let values: Vec<String> = match value {
4371 serde_json::Value::String(value) => vec![value.clone()],
4372 serde_json::Value::Array(values) => values
4373 .iter()
4374 .filter_map(|v| v.as_str().map(str::to_string))
4375 .collect(),
4376 serde_json::Value::Null => Vec::new(),
4377 _ => continue,
4378 };
4379 let combos: Vec<_> = values
4380 .iter()
4381 .filter_map(|value| parse_configured_key(value))
4382 .collect();
4383 if values.is_empty() || !combos.is_empty() {
4384 bindings.set(id, combos);
4385 }
4386 }
4387 rpi_tui::set_keybindings(bindings.clone());
4388 Arc::new(bindings)
4389}
4390
4391fn keybinding_matches(
4392 bindings: &rpi_tui::Keybindings,
4393 event: &crossterm::event::KeyEvent,
4394 id: rpi_tui::KeybindingId,
4395) -> bool {
4396 if bindings.matches(event, id) {
4397 return true;
4398 }
4399 if event.code == KeyCode::BackTab {
4402 let normalized =
4403 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4404 bindings.matches(&normalized, id)
4405 } else {
4406 false
4407 }
4408}
4409
4410fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4411 last.is_some_and(|previous| {
4412 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4413 })
4414}
4415
4416fn transcript_page_size(viewport_height: usize) -> i32 {
4417 viewport_height
4418 .saturating_sub(PAGE_SCROLL_OVERLAP)
4419 .max(1)
4420 .min(i32::MAX as usize) as i32
4421}
4422
4423fn should_dispatch_key(kind: KeyEventKind) -> bool {
4424 kind != KeyEventKind::Release
4425}
4426
4427fn format_tokens(n: i64) -> String {
4429 if n >= 1_000_000 {
4430 format!("{:.1}M", n as f64 / 1_000_000.0)
4431 } else if n >= 1_000 {
4432 format!("{:.1}K", n as f64 / 1_000.0)
4433 } else {
4434 n.to_string()
4435 }
4436}
4437
4438fn push_history(state: &Arc<TuiState>, text: &str) {
4442 let trimmed = text.trim().to_string();
4443 if trimmed.is_empty() {
4444 return;
4445 }
4446 let mut history = state.history.lock().unwrap();
4447 if history.first() == Some(&trimmed) {
4448 return;
4449 }
4450 history.insert(0, trimmed);
4451 history.truncate(HISTORY_LIMIT);
4452 *state.history_index.lock().unwrap() = -1;
4453 *state.history_draft.lock().unwrap() = None;
4454}
4455
4456fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4461 let history = state.history.lock().unwrap();
4462 if history.is_empty() {
4463 return;
4464 }
4465 let mut index = state.history_index.lock().unwrap();
4466 let new_index = *index - direction as isize;
4467 if new_index < -1 || new_index >= history.len() as isize {
4468 return;
4469 }
4470 if *index == -1 && new_index >= 0 {
4471 *state.history_draft.lock().unwrap() = Some(editor.get_text());
4473 }
4474 *index = new_index;
4475 if new_index == -1 {
4476 let draft = state.history_draft.lock().unwrap().take();
4478 match draft {
4479 Some(d) => {
4480 let len = d.len();
4481 editor.set_text(&d);
4482 editor.set_cursor(0, len);
4483 }
4484 None => editor.set_text(""),
4485 }
4486 } else {
4487 let text = history[new_index as usize].clone();
4488 let len = text.len();
4489 editor.set_text(&text);
4490 editor.set_cursor(0, len);
4491 }
4492}
4493
4494impl TuiState {
4495 fn begin_js_preparation(&self) -> CancellationToken {
4496 let cancellation = CancellationToken::new();
4497 if let Some(previous) = self
4498 .js_preparation_cancel
4499 .lock()
4500 .unwrap()
4501 .replace(cancellation.clone())
4502 {
4503 previous.cancel();
4504 }
4505 cancellation
4506 }
4507
4508 fn finish_js_preparation(&self) {
4509 self.js_preparation_cancel.lock().unwrap().take();
4510 }
4511
4512 fn cancel_js_preparation(&self) -> bool {
4513 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4514 if let Some(cancellation) = cancellation {
4515 cancellation.cancel();
4516 true
4517 } else {
4518 false
4519 }
4520 }
4521
4522 fn set_status(&self, status: RunStatus) {
4523 *self.status.lock().unwrap() = status;
4524 self.apply_status(status);
4525 }
4526
4527 fn try_start_working(&self) -> bool {
4532 let mut status = self.status.lock().unwrap();
4533 if *status != RunStatus::Idle {
4534 return false;
4535 }
4536 *status = RunStatus::Working;
4537 drop(status);
4538 self.apply_status(RunStatus::Working);
4539 true
4540 }
4541
4542 fn apply_status(&self, status: RunStatus) {
4543 match status {
4544 RunStatus::Working => {
4545 self.footer.set_status("");
4549 if let Some(tui) = &self.tui {
4552 tui.set_title("rpi — working");
4553 }
4554 self.status_container.clear();
4555 if self.show_terminal_progress {
4556 self.loader.start();
4557 self.status_container.add_child(self.loader.clone());
4558 }
4559 }
4560 RunStatus::Aborting => {
4561 self.footer.set_status("Aborting…");
4562 self.loader.stop();
4565 self.status_container.clear();
4566 }
4567 RunStatus::Idle => {
4568 self.footer.set_status("");
4569 if let Some(tui) = &self.tui {
4570 tui.set_title("rpi");
4571 }
4572 self.loader.stop();
4573 self.status_container.clear();
4574 }
4575 }
4576 }
4577
4578 fn sync_working_loader_with_bash(&self) {
4582 if *self.status.lock().unwrap() != RunStatus::Working {
4583 return;
4584 }
4585
4586 self.status_container.clear();
4587 if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4588 self.status_container.add_child(self.loader.clone());
4589 }
4590 }
4591
4592 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4593 *self.status.lock().unwrap() = RunStatus::Working;
4594 self.footer.set_status("");
4595 if let Some(tui) = &self.tui {
4596 tui.set_title("rpi — retrying");
4597 }
4598 self.loader.stop();
4599 self.status_container.clear();
4600 self.status_container
4601 .add_child(Arc::new(StatusIndicator::retry(
4602 attempt,
4603 max_retries,
4604 std::time::Duration::from_millis(delay_ms),
4605 )));
4606 }
4607
4608 fn selector_open(&self) -> bool {
4610 self.active_selector.lock().unwrap().is_some()
4611 }
4612
4613 fn extension_editor_open(&self) -> bool {
4614 self.active_extension_editor.lock().unwrap().is_some()
4615 }
4616
4617 fn extension_input_open(&self) -> bool {
4618 self.active_extension_input.lock().unwrap().is_some()
4619 }
4620
4621 fn extension_dialog_open(&self) -> bool {
4622 self.extension_editor_open() || self.extension_input_open()
4623 }
4624
4625 fn set_hide_thinking(&self, hide: bool) {
4626 *self.hide_thinking.lock().unwrap() = hide;
4627 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4628 comp.set_hide_thinking(hide);
4629 }
4630 }
4631
4632 fn hide_thinking(&self) -> bool {
4633 *self.hide_thinking.lock().unwrap()
4634 }
4635
4636 fn toggle_thinking(&self) -> bool {
4637 let next = !self.hide_thinking();
4638 self.set_hide_thinking(next);
4639 next
4640 }
4641
4642 fn toggle_tool_outputs(&self) -> bool {
4643 let next = !*self.tool_outputs_expanded.lock().unwrap();
4644 *self.tool_outputs_expanded.lock().unwrap() = next;
4645 for comp in self.tool_components.lock().unwrap().values() {
4646 comp.set_expanded(next);
4647 }
4648 for comp in self.bash_components.lock().unwrap().values() {
4649 comp.set_expanded(next);
4650 }
4651 next
4652 }
4653
4654 fn current_model_id(&self) -> String {
4656 self.current_model_id.lock().unwrap().clone()
4657 }
4658
4659 fn set_current_model(&self, model: &rpi_ai::Model) {
4662 *self.current_model_id.lock().unwrap() = model.id.clone();
4663 self.footer.set_model(&short_model_name(&model.id));
4664 }
4665
4666 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4670 self.markdown_transformer.lock().unwrap().clone()
4671 }
4672
4673 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4682 *self.markdown_transformer.lock().unwrap() = transformer.clone();
4683 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4684 comp.set_markdown_transformer(transformer);
4685 }
4686 }
4687
4688 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4689 self.pending_images.lock().unwrap().push(image);
4690 }
4691
4692 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4693 std::mem::take(&mut *self.pending_images.lock().unwrap())
4694 }
4695}
4696
4697#[derive(Debug, PartialEq)]
4702struct TuiStartupSettings {
4703 editor_padding_x: usize,
4704 autocomplete_max_visible: usize,
4705 hide_thinking: bool,
4706 quiet_startup: bool,
4707 show_terminal_progress: bool,
4708}
4709
4710fn preferred_project_setting<T>(
4711 project_settings: &[crate::settings::Settings],
4712 field: impl Fn(&crate::settings::Settings) -> Option<T>,
4713) -> Option<T> {
4714 project_settings.iter().find_map(field)
4715}
4716
4717fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4718 verbose || !quiet_startup
4719}
4720
4721fn git_only_update_resources(
4722 mut resources: crate::packages::PackageResources,
4723) -> crate::packages::PackageResources {
4724 resources
4725 .packages
4726 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
4727 resources
4728}
4729
4730fn resolve_tui_startup_settings(
4734 global: &crate::settings::Settings,
4735 project_settings: &[crate::settings::Settings],
4736 project_trusted: bool,
4737) -> TuiStartupSettings {
4738 let project_settings = if project_trusted {
4739 project_settings
4740 } else {
4741 &[]
4742 };
4743 let editor_padding_x =
4744 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4745 .or(global.editor_padding_x)
4746 .unwrap_or(1)
4747 .min(16);
4748 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4749 settings.autocomplete_max_visible
4750 })
4751 .or(global.autocomplete_max_visible)
4752 .unwrap_or(5)
4753 .clamp(1, 20);
4754 let hide_thinking =
4755 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4756 .or(global.hide_thinking_block)
4757 .unwrap_or(false);
4758 let quiet_startup =
4759 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4760 .or(global.quiet_startup)
4761 .unwrap_or(false);
4762 let show_terminal_progress =
4763 preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
4764 .or(global.show_terminal_progress)
4765 .unwrap_or(true);
4766
4767 TuiStartupSettings {
4768 editor_padding_x,
4769 autocomplete_max_visible,
4770 hide_thinking,
4771 quiet_startup,
4772 show_terminal_progress,
4773 }
4774}
4775
4776pub async fn interactive_tui(
4790 harness: &AgentHarness,
4791 event_rx: Option<broadcast::Receiver<AgentEvent>>,
4792 args: &Args,
4793 model_catalog: Vec<rpi_ai::Model>,
4794 initial: Option<String>,
4795 extra_messages: &[String],
4796 initial_images: Vec<rpi_ai::types::ImageContent>,
4797 theme: Option<&str>,
4798 no_themes: bool,
4799 reload_context: &crate::session::ReloadContext,
4800) -> i32 {
4801 let lane: Arc<dyn AgentLane> = harness.lane("main");
4802 let cwd = reload_context.cwd.clone();
4806 let saved_settings = crate::settings::load_settings().unwrap_or_default();
4807 let project_trusted = reload_context.project_trusted;
4808 let project_settings = if project_trusted {
4809 crate::settings::load_project_settings(&cwd)
4810 } else {
4811 Vec::new()
4812 };
4813 let tui_settings =
4814 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
4815 let editor_padding_x = tui_settings.editor_padding_x;
4816 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
4817 let hide_thinking = tui_settings.hide_thinking;
4818 let show_terminal_progress = tui_settings.show_terminal_progress;
4819 let quiet_startup = tui_settings.quiet_startup;
4820 let update_checks_enabled =
4821 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
4822
4823 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4827 let model_name = short_model_name(&lane_model_id);
4828
4829 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4833 if active_tool_names.is_empty() {
4837 active_tool_names = harness
4838 .get_tools()
4839 .await
4840 .unwrap_or_default()
4841 .into_iter()
4842 .map(|tool| tool.tool.schema().name.clone())
4843 .collect();
4844 }
4845 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4846 let skill_names: Vec<String> = resources_snapshot
4847 .skills
4848 .as_deref()
4849 .unwrap_or(&[])
4850 .iter()
4851 .map(|skill| skill.name.clone())
4852 .collect();
4853
4854 let package_resources = reload_context.package_resources.clone();
4856
4857 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4859
4860 let theme_manager = Arc::new(ThemeManager::new());
4864 if let Some(preset) = match theme {
4865 Some("light") => Some(ThemePreset::Light),
4866 Some("monochrome") => Some(ThemePreset::Monochrome),
4867 Some("dark") => Some(ThemePreset::Dark),
4868 _ => None,
4869 } {
4870 apply_theme_preset(preset);
4871 theme_manager.apply_preset(preset);
4872 } else if !no_themes {
4873 if let Some(name) = theme {
4874 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4875 Ok(custom) => {
4876 rpi_tui::global_theme_manager().set(custom.clone());
4877 theme_manager.set(custom);
4878 }
4879 Err(error) => {
4880 eprintln!("warning: could not load package theme `{name}`: {error}");
4881 }
4882 }
4883 }
4884 }
4885
4886 let terminal = Box::new(ProcessTerminal::new());
4888 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4889 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4890 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
4895 ask_user_bridge.attach();
4896 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4897
4898 if let Some(js) = &reload_context.js_extension_session {
4899 if let Err(error) = js.install_ui_runtime(tui.clone()) {
4900 eprintln!("warning: could not enable JS custom UI bridge: {error}");
4901 } else if let Some(js_active) = js.active_tools() {
4902 let js_names = js.tool_names();
4906 let mut active = lane.get_active_tools().await.unwrap_or_default();
4907 active.retain(|name| {
4908 crate::session::tool_name_allowed(name, args)
4909 && !js_names.iter().any(|js_name| js_name == name)
4910 });
4911 active.extend(js_active.into_iter().filter(|name| {
4912 js_names.iter().any(|js_name| js_name == name)
4913 && crate::session::tool_name_allowed(name, args)
4914 }));
4915 active = crate::session::filter_active_tool_names(active, args);
4916 let _ = lane.set_active_tools(active).await;
4917 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4918 }
4919 }
4920
4921 let chat_container = Arc::new(Container::new());
4922 if let Some(js) = &reload_context.js_extension_session {
4923 let chat_notify = chat_container.clone();
4927 let tui_notify = tui.clone();
4928 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
4929 if action != "ui.notify" {
4930 return Err(format!("unsupported capability: {action}"));
4931 }
4932 let message = args
4933 .get("message")
4934 .and_then(serde_json::Value::as_str)
4935 .unwrap_or_default();
4936 if !message.is_empty() {
4937 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
4938 add_error_message(&chat_notify, message);
4939 } else {
4940 add_note_message(&chat_notify, message);
4941 }
4942 tui_notify.request_render(false);
4943 }
4944 Ok(serde_json::json!(true))
4945 })) {
4946 if args.verbose {
4947 eprintln!("warning: could not enable JS notification bridge: {error}");
4948 }
4949 }
4950 }
4951 if should_show_startup_listing(args.verbose, quiet_startup) {
4952 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
4953 }
4954
4955 crate::extras::maybe_first_time_setup(&chat_container);
4961
4962 let initial_transformer = build_markdown_transformer(
4967 reload_context
4968 .extension_session
4969 .lock()
4970 .unwrap()
4971 .snapshot_arc(),
4972 );
4973 if launch_restores_history(args) {
4977 render_session_history(
4978 &harness,
4979 &chat_container,
4980 initial_transformer.clone(),
4981 Some(reload_context.extension_session.clone()),
4982 )
4983 .await;
4984 }
4985
4986 let document_container = Arc::new(Container::new());
4989 document_container.add_child(chat_container.clone());
4990
4991 let scroll_view = Arc::new(ScrollView::new(
4992 document_container.clone(),
4993 ScrollViewOptions {
4994 follow: FollowMode::End,
4995 primary: true,
4996 overscroll: OverscrollMode::Chain,
4997 scrollbar: ScrollbarMode::Hidden,
5001 ..Default::default()
5002 },
5003 ));
5004
5005 let keybindings = configured_keybindings();
5010 let editor = Arc::new(Editor::new(
5011 EditorOptions {
5012 padding_x: editor_padding_x,
5013 autocomplete_max_visible,
5014 ..Default::default()
5015 },
5016 EditorStyle::default(),
5017 keybindings.clone(),
5018 ));
5019
5020 let footer = Arc::new(FooterComponent::new());
5022 footer.set_model(&model_name);
5023 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");
5024
5025 let status_container = Arc::new(Container::new());
5026 let loader = Arc::new(Loader::with_text("Working…"));
5027
5028 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5042 .prompt_templates
5043 .clone()
5044 .unwrap_or_default()
5045 .iter()
5046 .map(|t| SlashCommandEntry {
5047 name: format!("/{}", t.name),
5048 description: t
5049 .description
5050 .clone()
5051 .unwrap_or_else(|| "Expand prompt template".to_string()),
5052 })
5053 .collect();
5054 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5055 Arc::new(resources_snapshot);
5056 let mut command_registry = build_builtin_registry();
5062 register_extension_commands(
5063 &mut command_registry,
5064 reload_context.extension_session.clone(),
5065 );
5066 register_js_extension_commands(
5067 &mut command_registry,
5068 reload_context.js_extension_session.clone(),
5069 );
5070 let registry = Arc::new(command_registry);
5071 let mut all_slash_commands = registry.visible_entries();
5072 all_slash_commands.extend(template_slash_commands);
5073 let autocomplete = AutocompleteManager::new();
5074 {
5075 let mut combined = CombinedAutocompleteProvider::new();
5076 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5077 all_slash_commands,
5078 )));
5079 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5080 cwd.clone(),
5081 )));
5082 autocomplete.set_provider(Arc::new(combined));
5083 }
5084 let autocomplete_container = Arc::new(Container::new());
5085
5086 let state = Arc::new(TuiState {
5087 current_assistant: std::sync::Mutex::new(None),
5088 tool_components: std::sync::Mutex::new(HashMap::new()),
5089 bash_components: std::sync::Mutex::new(HashMap::new()),
5090 themes_enabled: !no_themes,
5091 hide_thinking: std::sync::Mutex::new(hide_thinking),
5092 tool_outputs_expanded: std::sync::Mutex::new(false),
5093 show_terminal_progress,
5094 status: std::sync::Mutex::new(RunStatus::Idle),
5095 js_preparation_cancel: std::sync::Mutex::new(None),
5096 footer: footer.clone(),
5097 status_container: status_container.clone(),
5098 chat_container: chat_container.clone(),
5099 loader: loader.clone(),
5100 last_assistant_text: std::sync::Mutex::new(String::new()),
5101 active_selector: std::sync::Mutex::new(None),
5102 active_extension_editor: std::sync::Mutex::new(None),
5103 active_extension_input: std::sync::Mutex::new(None),
5104 active_extension_cancel: std::sync::Mutex::new(None),
5105 autocomplete,
5106 autocomplete_container: autocomplete_container.clone(),
5107 autocomplete_max_visible,
5108 pending_images: std::sync::Mutex::new(Vec::new()),
5109 theme_manager,
5110 tui: Some(tui.clone()),
5111 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5112 show_images: std::sync::Mutex::new(true),
5113 history: std::sync::Mutex::new(Vec::new()),
5114 history_index: std::sync::Mutex::new(-1),
5115 history_draft: std::sync::Mutex::new(None),
5116 last_input_tokens: std::sync::Mutex::new(0),
5117 scoped_edit: std::sync::Mutex::new(None),
5118 markdown_transformer: std::sync::Mutex::new(initial_transformer),
5119 extension_session: reload_context.extension_session.clone(),
5120 });
5121
5122 let model_catalog_arc = Arc::new(model_catalog.clone());
5125 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5126
5127 let editor_container = Arc::new(Container::new());
5138 editor_container.add_child(editor.clone());
5139
5140 let dock = Arc::new(VStack::from_children(vec![
5141 StackChild::Entry(StackEntry::new(status_container.clone())),
5142 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5143 StackChild::Entry(
5144 StackEntry::new(editor_container.clone())
5145 .shrink(0)
5146 .min_size(3),
5147 ),
5148 StackChild::Entry(StackEntry::new(footer.clone())),
5149 ]));
5150
5151 let root = VStack::from_children(vec![
5152 StackChild::Entry(
5153 StackEntry::new(scroll_view.clone())
5154 .basis(0)
5155 .grow(1)
5156 .shrink(1)
5157 .min_size(1),
5158 ),
5159 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5160 ]);
5161
5162 tui.set_layout_root(Some(Arc::new(root)));
5163 tui.set_focus(Some(editor.clone()));
5164 editor.set_focused(true);
5165
5166 let ctx = CommandContext {
5178 chat: chat_container.clone(),
5179 tui: tui.clone(),
5180 tx: tx.clone(),
5181 state: state.clone(),
5182 editor: editor.clone(),
5183 editor_container: editor_container.clone(),
5184 lane: lane.clone(),
5185 model_catalog: model_catalog_arc.clone(),
5186 lane_model_id: lane_model_id.clone(),
5187 cwd: cwd.clone(),
5188 package_resources: package_resources.clone(),
5189 resources: resources_arc.clone(),
5190 reload_context: Arc::new(reload_context.clone()),
5191 };
5192
5193 if let Some(js) = &reload_context.js_extension_session {
5194 let bridge = js_dialog_bridge.clone();
5195 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5196 bridge.handle_runtime_request(action, args)
5197 })) {
5198 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5199 }
5200 }
5201
5202 let ctx_for_cb = ctx.clone();
5203 let registry_for_cb = registry.clone();
5204 editor.on_submit(Arc::new(move |text: &str| {
5205 let text = text.trim();
5206 if text.is_empty() {
5207 return;
5208 }
5209
5210 if text.starts_with('/') {
5211 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
5212 return;
5213 }
5214
5215 let run_status = *ctx_for_cb.state.status.lock().unwrap();
5216 if run_status != RunStatus::Idle {
5217 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5218 let lane = ctx_for_cb.lane.clone();
5219 let chat = ctx_for_cb.chat.clone();
5220 let tui = ctx_for_cb.tui.clone();
5221 tokio::spawn(async move {
5222 if let Err(error) = lane.steer(message).await {
5232 add_error_message(&chat, &format!("Could not queue message: {error}"));
5233 tui.render_now(false);
5234 }
5235 });
5236 add_user_message(&ctx_for_cb.chat, text);
5241 ctx_for_cb.tui.request_render(false);
5242 return;
5243 }
5244
5245 if !ctx_for_cb.state.try_start_working() {
5246 return;
5247 }
5248
5249 add_user_message(&ctx_for_cb.chat, text);
5250 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5253 scroll.scroll_to_end();
5254 }
5255 ctx_for_cb.tui.request_render(false);
5256 push_history(&ctx_for_cb.state, text);
5259 if ctx_for_cb
5260 .tx
5261 .send(TuiMessage::UserInput(text.to_string()))
5262 .is_err()
5263 {
5264 ctx_for_cb.state.set_status(RunStatus::Idle);
5265 }
5266 }));
5267
5268 tui.start_render_scheduler();
5276 tui.start_readerless();
5277
5278 let mut update_check_handles = Vec::new();
5282 let rpi_chat = chat_container.clone();
5283 let rpi_tui = tui.clone();
5284 update_check_handles.push(tokio::spawn(async move {
5285 let report = crate::updates::check_rpi_startup().await;
5286 if !report.is_empty() {
5287 add_update_notices(&rpi_chat, &report);
5288 }
5289 rpi_tui.request_render(false);
5290 }));
5291
5292 if update_checks_enabled {
5293 let update_args = args.clone();
5294 let update_cwd = cwd.clone();
5295 let update_project_trusted = project_trusted;
5296 let chat = chat_container.clone();
5297 let tui = tui.clone();
5298 update_check_handles.push(tokio::spawn(async move {
5299 let resources = crate::session::package_resources_for_update_check(
5300 &update_args,
5301 &update_cwd,
5302 update_project_trusted,
5303 );
5304 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
5305 {
5306 Ok(npm_command) => {
5307 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
5308 Some(&resources),
5309 &npm_command,
5310 update_project_trusted.then_some(update_cwd.as_path()),
5311 )
5312 .await
5313 }
5314 Err(error) => {
5315 add_error_message(
5316 &chat,
5317 &format!("npm package update checks disabled: {error}"),
5318 );
5319 let git_resources = git_only_update_resources(resources);
5320 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
5321 }
5322 };
5323 if !report.is_empty() {
5324 add_update_notices(&chat, &report);
5325 }
5326 tui.request_render(false);
5327 }));
5328 }
5329
5330 let drain_handle = if let Some(rx) = event_rx {
5332 let tui_drain = tui.clone();
5333 let state_drain = state.clone();
5334 let chat_drain = chat_container.clone();
5335 Some(tokio::spawn(async move {
5336 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
5337 }))
5338 } else {
5339 None
5340 };
5341
5342 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5352 reload_context.mailbox.install(reload_sig_tx);
5353 let reload_tx = tx.clone();
5354 let reload_bridge_handle = tokio::spawn(async move {
5355 while reload_sig_rx.recv().await.is_some() {
5356 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5357 break; }
5359 }
5360 });
5361
5362 let tui_tick = tui.clone();
5367 let state_tick = state.clone();
5368 let tick_handle = tokio::spawn(async move {
5369 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
5372 interval.tick().await; loop {
5374 interval.tick().await;
5375 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5376 if working {
5377 if state_tick.bash_components.lock().unwrap().is_empty() {
5378 tui_tick.request_render_reusing_scroll_content();
5382 } else {
5383 tui_tick.request_render(false);
5385 }
5386 }
5387 }
5388 });
5389
5390 let running = Arc::new(std::sync::Mutex::new(true));
5392 let running_key = running.clone();
5393 let tx_for_key = tx.clone();
5394 let tui_for_key = tui.clone();
5395 let editor_for_key = editor.clone();
5396 let scroll_for_key = scroll_view.clone();
5397 let lane_for_key = lane.clone();
5398 let state_for_key = state.clone();
5399 let model_catalog_for_key = model_catalog_arc.clone();
5400 let js_for_key = reload_context.js_extension_session.clone();
5401 let js_dialog_for_key = js_dialog_bridge.clone();
5402 let ask_user_for_key = ask_user_bridge.clone();
5403 let ctx_for_key = ctx.clone();
5407 let registry_for_key = registry.clone();
5408 let keybindings_for_key = keybindings.clone();
5409 let double_escape_action = crate::settings::load_settings()
5410 .ok()
5411 .and_then(|settings| settings.double_escape_action)
5412 .unwrap_or_else(|| "tree".to_string())
5413 .to_ascii_lowercase();
5414
5415 let key_handle = tokio::task::spawn_blocking(move || {
5416 let mut last_escape_time = None;
5417 loop {
5418 if !*running_key.lock().unwrap() {
5419 break;
5420 }
5421 if !state_for_key.selector_open()
5422 && !state_for_key.extension_dialog_open()
5423 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
5424 {
5425 if let Some(request) = js_dialog_for_key.take_pending() {
5426 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
5427 } else if let Some(request) = ask_user_for_key.take_pending() {
5428 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5429 }
5430 }
5431 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
5432 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
5435 Ok(true) => {}
5436 Ok(false) => continue,
5437 Err(_) => {
5438 state_for_key.cancel_js_preparation();
5439 let _ = tx_for_key.send(TuiMessage::Exit);
5440 break;
5441 }
5442 }
5443 let Ok(ev) = crossterm::event::read() else {
5444 state_for_key.cancel_js_preparation();
5445 let _ = tx_for_key.send(TuiMessage::Exit);
5446 break;
5447 };
5448 if let Event::Resize(_cols, _rows) = ev {
5453 tui_for_key.refresh_size();
5454 if let Some(js) = &js_for_key {
5455 if js.custom_active() {
5456 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
5457 tui_for_key.request_render(false);
5458 }
5459 }
5460 continue;
5461 }
5462 if let Event::Mouse(m) = ev {
5466 use crossterm::event::MouseEventKind;
5467 match m.kind {
5468 MouseEventKind::ScrollUp => {
5469 let delta = -MOUSE_WHEEL_SCROLL_LINES;
5470 if scroll_for_key.scroll_by(delta) != delta {
5471 tui_for_key.request_render_reusing_scroll_content();
5472 }
5473 }
5474 MouseEventKind::ScrollDown => {
5475 let delta = MOUSE_WHEEL_SCROLL_LINES;
5476 if scroll_for_key.scroll_by(delta) != delta {
5477 tui_for_key.request_render_reusing_scroll_content();
5478 }
5479 }
5480 _ => {}
5481 }
5482 continue;
5483 }
5484 let Event::Key(key) = ev else {
5485 if let Event::Paste(text) = ev {
5486 let candidate = text.trim().trim_matches(['\"', '\'']);
5487 let path = std::path::PathBuf::from(candidate);
5488 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5489 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5490 add_image_preview(&state_for_key.chat_container, &image);
5491 state_for_key.queue_image(image);
5492 add_note_message(
5493 &state_for_key.chat_container,
5494 "Dropped image attached to the next prompt.",
5495 );
5496 tui_for_key.request_render(false);
5497 continue;
5498 }
5499 }
5500 editor_for_key.insert(&text);
5501 refresh_autocomplete(&state_for_key, &editor_for_key);
5502 tui_for_key.request_render_reusing_scroll_content();
5503 }
5504 continue;
5505 };
5506 if !should_dispatch_key(key.kind) {
5511 continue;
5512 }
5513
5514 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5520 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5521 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5522 if prompt_abort && state_for_key.cancel_js_preparation() {
5523 state_for_key.set_status(RunStatus::Aborting);
5524 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5525 close_extension_editor(
5526 &state_for_key,
5527 &ctx_for_key.editor_container,
5528 &editor_for_key,
5529 &tui_for_key,
5530 );
5531 }
5532 js_dialog_for_key.cancel_open_requests();
5533 tui_for_key.set_render_suspended(false);
5534 tui_for_key.request_render(false);
5535 continue;
5536 }
5537
5538 if let Some(js) = &js_for_key {
5539 if js.custom_active() {
5540 let visible = js.custom_accepts_input();
5541 let data = key_event_to_input(key);
5542 if !data.is_empty() {
5543 if visible {
5549 let _ = js.send_custom_input(&data);
5550 continue;
5551 }
5552 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5553 if consumed {
5557 tui_for_key.request_render_reusing_scroll_content();
5558 continue;
5559 }
5560 }
5561 }
5562 }
5563
5564 if key.modifiers == KeyModifiers::CONTROL
5567 && key.code == KeyCode::Char('c')
5568 && state_for_key.selector_open()
5569 {
5570 let selector = state_for_key
5571 .active_selector
5572 .lock()
5573 .unwrap()
5574 .clone()
5575 .expect("selector_open guaranteed Some")
5576 .0;
5577 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5578 tui_for_key.request_render_reusing_scroll_content();
5579 continue;
5580 }
5581
5582 if state_for_key.extension_dialog_open() {
5586 let cancel = key.code == KeyCode::Esc
5587 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5588 if cancel {
5589 if !run_extension_cancel(&state_for_key) {
5590 close_extension_editor(
5591 &state_for_key,
5592 &ctx_for_key.editor_container,
5593 &editor_for_key,
5594 &tui_for_key,
5595 );
5596 }
5597 } else if let Some(extension_editor) = state_for_key
5598 .active_extension_editor
5599 .lock()
5600 .unwrap()
5601 .clone()
5602 {
5603 extension_editor.handle_key(key);
5604 } else if let Some(extension_input) =
5605 state_for_key.active_extension_input.lock().unwrap().clone()
5606 {
5607 extension_input.handle_key(key);
5608 }
5609 tui_for_key.request_render_reusing_scroll_content();
5610 continue;
5611 }
5612
5613 if keybinding_matches(
5618 &keybindings_for_key,
5619 &key,
5620 rpi_tui::keybindings::keys::CLEAR,
5621 ) {
5622 if !state_for_key.selector_open() && editor_for_key.has_selection() {
5623 editor_for_key.copy_selection();
5624 continue;
5625 }
5626 let status = *state_for_key.status.lock().unwrap();
5627 match status {
5628 RunStatus::Working => {
5629 state_for_key.set_status(RunStatus::Aborting);
5630 ask_user_for_key.cancel_all();
5632 if !run_extension_cancel(&state_for_key)
5634 && state_for_key.extension_dialog_open()
5635 {
5636 close_extension_editor(
5637 &state_for_key,
5638 &ctx_for_key.editor_container,
5639 &editor_for_key,
5640 &tui_for_key,
5641 );
5642 }
5643 let lane = lane_for_key.clone();
5644 tokio::spawn(async move {
5645 let _ = lane.abort().await;
5646 });
5647 }
5648 RunStatus::Aborting => {}
5652 RunStatus::Idle => {
5653 let _ = tx_for_key.send(TuiMessage::Exit);
5654 }
5655 }
5656 continue;
5657 }
5658
5659 if state_for_key.selector_open() {
5664 if key.code == KeyCode::Esc {
5670 let (selector, _kind) = state_for_key
5671 .active_selector
5672 .lock()
5673 .unwrap()
5674 .clone()
5675 .expect("selector_open guaranteed Some");
5676 selector.handle_key(key);
5677 continue;
5678 }
5679 let (selector, _kind) = state_for_key
5680 .active_selector
5681 .lock()
5682 .unwrap()
5683 .clone()
5684 .expect("selector_open guaranteed Some");
5685 selector.handle_key(key);
5686 tui_for_key.request_render_reusing_scroll_content();
5687 continue;
5688 }
5689
5690 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5695 let status = *state_for_key.status.lock().unwrap();
5696 match status {
5697 RunStatus::Working => {
5698 state_for_key.set_status(RunStatus::Aborting);
5699 ask_user_for_key.cancel_all();
5700 if !run_extension_cancel(&state_for_key)
5701 && state_for_key.extension_dialog_open()
5702 {
5703 close_extension_editor(
5704 &state_for_key,
5705 &ctx_for_key.editor_container,
5706 &editor_for_key,
5707 &tui_for_key,
5708 );
5709 }
5710 let lane = lane_for_key.clone();
5711 tokio::spawn(async move {
5712 let _ = lane.abort().await;
5713 });
5714 continue;
5715 }
5716 RunStatus::Aborting => continue,
5717 RunStatus::Idle => {}
5718 }
5719 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5720 editor_for_key.handle_key(key);
5722 refresh_autocomplete(&state_for_key, &editor_for_key);
5723 tui_for_key.request_render_reusing_scroll_content();
5724 continue;
5725 }
5726 let _ = tx_for_key.send(TuiMessage::Exit);
5727 continue;
5728 }
5729
5730 if keybinding_matches(
5735 &keybindings_for_key,
5736 &key,
5737 rpi_tui::keybindings::keys::INTERRUPT,
5738 ) {
5739 let status = *state_for_key.status.lock().unwrap();
5740 if status == RunStatus::Working {
5741 state_for_key.set_status(RunStatus::Aborting);
5742 ask_user_for_key.cancel_all();
5743 if !run_extension_cancel(&state_for_key)
5744 && state_for_key.extension_dialog_open()
5745 {
5746 close_extension_editor(
5747 &state_for_key,
5748 &ctx_for_key.editor_container,
5749 &editor_for_key,
5750 &tui_for_key,
5751 );
5752 }
5753 let lane = lane_for_key.clone();
5754 tokio::spawn(async move {
5755 let _ = lane.abort().await;
5756 });
5757 continue;
5758 }
5759 if status == RunStatus::Idle
5760 && editor_for_key.get_text().trim().is_empty()
5761 && double_escape_action != "none"
5762 {
5763 let now = std::time::Instant::now();
5764 if double_escape_trigger(last_escape_time, now) {
5765 last_escape_time = None;
5766 match double_escape_action.as_str() {
5767 "tree" => {
5768 let _ = tx_for_key.send(TuiMessage::OpenTree);
5769 }
5770 "fork" => {
5771 let _ = tx_for_key.send(TuiMessage::ForkSession);
5772 }
5773 _ => {}
5774 }
5775 } else {
5776 last_escape_time = Some(now);
5777 }
5778 }
5779 continue;
5780 }
5781
5782 if keybinding_matches(
5785 &keybindings_for_key,
5786 &key,
5787 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5788 ) {
5789 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5790 continue;
5791 }
5792
5793 if keybinding_matches(
5796 &keybindings_for_key,
5797 &key,
5798 rpi_tui::keybindings::keys::TOOLS_EXPAND,
5799 ) {
5800 state_for_key.toggle_tool_outputs();
5801 tui_for_key.request_render(false);
5802 continue;
5803 }
5804
5805 if keybinding_matches(
5807 &keybindings_for_key,
5808 &key,
5809 rpi_tui::keybindings::keys::THINKING_TOGGLE,
5810 ) {
5811 state_for_key.toggle_thinking();
5812 tui_for_key.request_render(false);
5813 continue;
5814 }
5815
5816 if keybinding_matches(
5822 &keybindings_for_key,
5823 &key,
5824 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5825 ) {
5826 let current = state_for_key.current_model_id();
5827 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
5830 if let Some(next) = cycle_next_model(&scope, ¤t) {
5831 state_for_key.set_current_model(&next);
5832 let lane = lane_for_key.clone();
5833 tokio::spawn(async move {
5834 let _ = lane.set_model(next).await;
5835 });
5836 tui_for_key.request_render_reusing_scroll_content();
5837 }
5838 continue;
5839 }
5840
5841 if keybinding_matches(
5844 &keybindings_for_key,
5845 &key,
5846 rpi_tui::keybindings::keys::THINKING_CYCLE,
5847 ) {
5848 let lane = lane_for_key.clone();
5849 let catalog = model_catalog_for_key.clone();
5850 let state = state_for_key.clone();
5851 tokio::spawn(async move {
5852 let Ok(current_model) = lane.get_model().await else {
5853 return;
5854 };
5855 let levels = catalog
5856 .iter()
5857 .find(|model| {
5858 model.provider == current_model.provider && model.id == current_model.id
5859 })
5860 .map(|model| model.supported_thinking_levels())
5861 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5862 if levels.is_empty() {
5863 return;
5864 }
5865 let current = lane
5866 .get_thinking_level()
5867 .await
5868 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
5869 let next = levels
5870 .iter()
5871 .position(|level| *level == current)
5872 .map(|index| levels[(index + 1) % levels.len()])
5873 .unwrap_or(levels[0]);
5874 if lane.set_thinking_level(next).await.is_ok() {
5875 state
5876 .footer
5877 .set_thinking_level(Some(thinking_level_name(next)));
5878 state.tui.as_ref().map(|tui| tui.request_render(false));
5879 }
5880 });
5881 continue;
5882 }
5883
5884 if keybinding_matches(
5888 &keybindings_for_key,
5889 &key,
5890 rpi_tui::keybindings::keys::PASTE_IMAGE,
5891 ) {
5892 match read_clipboard_image() {
5893 Ok(Some(image)) => {
5894 add_image_preview(&state_for_key.chat_container, &image);
5895 state_for_key.queue_image(image);
5896 add_note_message(
5897 &state_for_key.chat_container,
5898 "Clipboard image attached to the next prompt.",
5899 );
5900 tui_for_key.request_render(false);
5901 continue;
5902 }
5903 Ok(None) | Err(_) => {}
5904 }
5905 }
5906
5907 if keybinding_matches(
5911 &keybindings_for_key,
5912 &key,
5913 rpi_tui::keybindings::keys::MODEL_SELECT,
5914 ) {
5915 if let Some(cmd) = registry_for_key.find("/model") {
5916 cmd.execute(&ctx_for_key, "");
5917 }
5918 continue;
5919 }
5920
5921 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
5923 if accept_top_suggestion(&state_for_key, &editor_for_key) {
5924 tui_for_key.request_render_reusing_scroll_content();
5925 }
5926 continue;
5927 }
5928
5929 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
5933 let delta = -transcript_page_size(scroll_for_key.viewport_height());
5934 if scroll_for_key.scroll_by(delta) != delta {
5935 tui_for_key.request_render_reusing_scroll_content();
5936 }
5937 continue;
5938 }
5939 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
5940 let delta = transcript_page_size(scroll_for_key.viewport_height());
5941 if scroll_for_key.scroll_by(delta) != delta {
5942 tui_for_key.request_render_reusing_scroll_content();
5943 }
5944 continue;
5945 }
5946 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
5947 scroll_for_key.scroll_to_start();
5948 tui_for_key.request_render_reusing_scroll_content();
5949 continue;
5950 }
5951 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
5952 scroll_for_key.scroll_to_end();
5953 tui_for_key.request_render_reusing_scroll_content();
5954 continue;
5955 }
5956
5957 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
5966 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5967 if editor_for_key.get_text().is_empty() || browsing {
5968 navigate_history(&state_for_key, &editor_for_key, -1);
5969 tui_for_key.request_render_reusing_scroll_content();
5970 continue;
5971 }
5972 }
5973 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
5974 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5975 if editor_for_key.get_text().is_empty() || browsing {
5976 navigate_history(&state_for_key, &editor_for_key, 1);
5977 tui_for_key.request_render_reusing_scroll_content();
5978 continue;
5979 }
5980 }
5981
5982 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
5986 let prompt = editor_for_key.get_text().trim().to_string();
5987 if prompt.is_empty() {
5988 continue;
5989 }
5990 editor_for_key.clear();
5991 let status = *state_for_key.status.lock().unwrap();
5992 if status == RunStatus::Idle {
5993 if state_for_key.try_start_working() {
5994 add_user_message(&state_for_key.chat_container, &prompt);
5995 push_history(&state_for_key, &prompt);
5996 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
5997 }
5998 } else {
5999 add_user_message(&state_for_key.chat_container, &prompt);
6002 let message = AgentMessage::User(UserMessage::new(prompt, 0));
6003 let lane = lane_for_key.clone();
6004 let chat = state_for_key.chat_container.clone();
6005 let tui = tui_for_key.clone();
6006 tokio::spawn(async move {
6007 if let Err(error) = lane.follow_up(message).await {
6008 add_error_message(&chat, &format!("Could not queue message: {error}"));
6009 tui.request_render(false);
6010 }
6011 });
6012 }
6013 tui_for_key.request_render(false);
6014 continue;
6015 }
6016
6017 editor_for_key.handle_key(key);
6019 refresh_autocomplete(&state_for_key, &editor_for_key);
6020 tui_for_key.request_render_reusing_scroll_content();
6021 }
6022 });
6023
6024 let mut prompts: Vec<String> = Vec::new();
6026 if let Some(init) = initial {
6027 prompts.push(init);
6028 }
6029 for m in extra_messages {
6030 prompts.push(m.clone());
6031 }
6032 let mut images = initial_images;
6033 for prompt in prompts {
6034 if !*running.lock().unwrap() {
6035 break;
6036 }
6037 add_user_message(&chat_container, &prompt);
6038 tui.request_render(false);
6039 run_prompt_streaming(
6040 &lane,
6041 &prompt,
6042 &tui,
6043 &state,
6044 drain_handle.is_some(),
6045 reload_context.js_extension_session.as_ref(),
6046 &js_dialog_bridge,
6047 args,
6048 std::mem::take(&mut images),
6049 )
6050 .await;
6051 }
6052
6053 loop {
6055 if !*running.lock().unwrap() {
6056 break;
6057 }
6058 match rx.recv().await {
6059 Some(TuiMessage::UserInput(prompt)) => {
6060 editor.clear();
6065 let prompt_images = state.take_pending_images();
6066 if !prompt_images.is_empty() {
6067 add_note_message(
6068 &chat_container,
6069 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6070 );
6071 }
6072 run_prompt_streaming(
6073 &lane,
6074 &prompt,
6075 &tui,
6076 &state,
6077 drain_handle.is_some(),
6078 reload_context.js_extension_session.as_ref(),
6079 &js_dialog_bridge,
6080 args,
6081 prompt_images,
6082 )
6083 .await;
6084 }
6085 Some(TuiMessage::ExternalEditorResult(result)) => {
6086 match result {
6087 Ok(text) => {
6088 let cursor = text.chars().count();
6089 editor.set_text(&text);
6090 editor.set_cursor(0, cursor);
6091 add_note_message(&chat_container, "Draft updated from external editor.");
6092 }
6093 Err(error) => add_error_message(&chat_container, &error),
6094 }
6095 tui.request_render(false);
6096 }
6097 Some(TuiMessage::OpenTree) => {
6098 if *state.status.lock().unwrap() != RunStatus::Idle {
6099 add_note_message(
6100 &chat_container,
6101 "Wait for the current run to finish before opening the tree.",
6102 );
6103 tui.request_render(false);
6104 } else {
6105 open_tree_selector(
6106 &harness,
6107 &state,
6108 &editor_container,
6109 &editor,
6110 &tui,
6111 &chat_container,
6112 &tx,
6113 )
6114 .await;
6115 }
6116 }
6117 Some(TuiMessage::NavigateTree(entry_id)) => {
6118 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6119 Ok(result) => match result.outcome {
6120 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6121 chat_container.clear();
6122 add_welcome_message(&chat_container);
6123 render_session_history(
6124 &harness,
6125 &chat_container,
6126 state.markdown_transformer(),
6127 Some(state.extension_session.clone()),
6128 )
6129 .await;
6130 add_note_message(
6131 &chat_container,
6132 "Moved to the selected session entry.",
6133 );
6134 }
6135 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6136 add_error_message(&chat_container, &error.message);
6137 }
6138 _ => add_note_message(
6139 &chat_container,
6140 "The selected entry could not be opened.",
6141 ),
6142 },
6143 Err(error) => add_error_message(
6144 &chat_container,
6145 &format!("Could not navigate session tree: {error}"),
6146 ),
6147 }
6148 tui.request_render(false);
6149 }
6150 Some(TuiMessage::ClearChat) => {
6151 chat_container.clear();
6152 add_welcome_message(&chat_container);
6153 tui.request_render(false);
6154 }
6155 Some(TuiMessage::Compact) => {
6156 run_compact(&lane, &tui, &state).await;
6157 }
6158 Some(TuiMessage::Copy) => {
6159 copy_last_assistant(&state, &chat_container);
6160 tui.request_render(false);
6161 }
6162 Some(TuiMessage::Exit) => {
6163 *running.lock().unwrap() = false;
6164 break;
6165 }
6166 Some(TuiMessage::SwitchSession(id)) => {
6167 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6168 tui.request_render(false);
6169 }
6170 Some(TuiMessage::ImportSession(path)) => {
6171 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6172 tui.request_render(false);
6173 }
6174 Some(TuiMessage::ShareSession) => {
6175 share_session(&harness, &chat_container).await;
6176 tui.request_render(false);
6177 }
6178 Some(TuiMessage::SetSessionName(name)) => {
6179 let outcome = harness.session().set_name(Some(&name)).await;
6180 match outcome {
6181 Ok(_) => add_note_message(
6182 &chat_container,
6183 &format!("Session renamed to \"{name}\"."),
6184 ),
6185 Err(e) => add_error_message(
6186 &chat_container,
6187 &format!("Could not rename session: {e}"),
6188 ),
6189 }
6190 tui.request_render(false);
6191 }
6192 Some(TuiMessage::ExportSession) => {
6193 export_session(&harness, &chat_container, &cwd).await;
6194 tui.request_render(false);
6195 }
6196 Some(TuiMessage::ForkSession) => {
6197 fork_session(&harness, &cwd, &chat_container, &state).await;
6198 tui.request_render(false);
6199 }
6200 Some(TuiMessage::ReloadExtensions) => {
6201 let reload_ctx = ctx.reload_context.clone();
6208 add_note_message(&chat_container, "Reloading extensions + resources…");
6209 tui.request_render(false);
6210 let outcome =
6211 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6212 let fresh_transformer = build_markdown_transformer(
6221 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6222 );
6223 state.set_markdown_transformer_with_reinstall(fresh_transformer);
6224 if outcome.had_errors {
6225 add_error_message(&chat_container, &outcome.summary);
6229 } else if outcome.had_warnings {
6230 add_note_message(
6235 &chat_container,
6236 &format!(
6237 "{} (with warnings — see stderr for details).",
6238 outcome.summary
6239 ),
6240 );
6241 } else {
6242 add_note_message(&chat_container, &outcome.summary);
6243 }
6244 tui.request_render(false);
6245 }
6246 None => break,
6247 }
6248 }
6249
6250 js_dialog_bridge.cancel_all();
6254 ask_user_bridge.shutdown();
6255 *running.lock().unwrap() = false;
6256 for handle in update_check_handles {
6257 handle.abort();
6258 let _ = handle.await;
6259 }
6260 if let Some(js) = &reload_context.js_extension_session {
6264 js.shutdown();
6265 }
6266 let _ = key_handle.await;
6269 tick_handle.abort();
6270 if let Some(handle) = drain_handle {
6271 handle.abort();
6272 }
6273 reload_context.mailbox.clear();
6277 reload_bridge_handle.abort();
6278 tui.stop(Default::default());
6279 println!("\nGoodbye!");
6280 let _ = args;
6281
6282 0
6283}
6284
6285async fn ensure_js_runtime_before_prompt(
6295 js: Option<&crate::js_extensions::JsExtensionSession>,
6296 lane: &Arc<dyn AgentLane>,
6297 state: &Arc<TuiState>,
6298 dialog_bridge: &JsDialogBridge,
6299 args: &Args,
6300) -> bool {
6301 let Some(js) = js else {
6302 return true;
6303 };
6304 let cancellation = state.begin_js_preparation();
6305 let worker_cancellation = cancellation.clone();
6306 let js_for_start = js.clone();
6307 let mut worker = tokio::task::spawn_blocking(move || {
6308 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6309 });
6310 let result = tokio::select! {
6311 result = &mut worker => result,
6312 _ = cancellation.cancelled() => {
6313 dialog_bridge.cancel_open_requests();
6314 let js_for_cancel = js.clone();
6315 let _ = tokio::task::spawn_blocking(move || {
6316 js_for_cancel.cancel_prompt_preparation();
6317 }).await;
6318 worker.await
6319 }
6320 };
6321 let was_cancelled = cancellation.is_cancelled();
6322 if was_cancelled {
6323 dialog_bridge.cancel_open_requests();
6324 let js_for_cancel = js.clone();
6325 let _ = tokio::task::spawn_blocking(move || {
6326 js_for_cancel.cancel_prompt_preparation();
6327 })
6328 .await;
6329 state.finish_js_preparation();
6330 dialog_bridge.reopen();
6331 return false;
6332 }
6333 state.finish_js_preparation();
6334 match result {
6335 Ok(Ok(())) => {
6336 if let Some(js_active) = js.active_tools() {
6340 let js_names = js.tool_names();
6341 let mut active = lane.get_active_tools().await.unwrap_or_default();
6342 active.retain(|name| {
6343 crate::session::tool_name_allowed(name, args)
6344 && !js_names.iter().any(|js_name| js_name == name)
6345 });
6346 active.extend(js_active.into_iter().filter(|name| {
6347 js_names.iter().any(|js_name| js_name == name)
6348 && crate::session::tool_name_allowed(name, args)
6349 }));
6350 active = crate::session::filter_active_tool_names(active, args);
6351 let _ = lane.set_active_tools(active).await;
6352 }
6353 }
6354 Ok(Err(error)) => {
6355 if args.verbose {
6356 eprintln!("warning: could not start JS extension runtime: {error}");
6357 }
6358 }
6359 Err(error) => {
6360 if args.verbose {
6361 eprintln!("warning: JS extension runtime worker failed: {error}");
6362 }
6363 }
6364 }
6365 true
6366}
6367
6368fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6369 std::thread::spawn(move || {
6370 let file = match tempfile::Builder::new()
6371 .prefix("rpi-draft-")
6372 .suffix(".md")
6373 .tempfile()
6374 {
6375 Ok(file) => file,
6376 Err(error) => {
6377 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6378 "Could not create editor file: {error}"
6379 ))));
6380 return;
6381 }
6382 };
6383 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6384 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6385 "Could not write editor file: {error}"
6386 ))));
6387 return;
6388 }
6389 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6390 .ok()
6391 .filter(|value| !value.trim().is_empty())
6392 .or_else(|| std::env::var("VISUAL").ok())
6393 .or_else(|| std::env::var("EDITOR").ok())
6394 .unwrap_or_else(|| {
6395 if cfg!(windows) {
6396 "notepad".to_string()
6397 } else {
6398 "nano".to_string()
6399 }
6400 });
6401 let status = std::process::Command::new(&editor)
6402 .arg(file.path())
6403 .status();
6404 let result = match status {
6405 Ok(status) if status.success() => std::fs::read_to_string(file.path())
6406 .map_err(|error| format!("Could not read editor file: {error}")),
6407 Ok(status) => Err(format!("External editor exited with {status}")),
6408 Err(error) => Err(format!(
6409 "Could not launch external editor `{editor}`: {error}"
6410 )),
6411 };
6412 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6413 });
6414}
6415
6416fn reconcile_streamed_assistant_completion(
6419 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6420 last_assistant_text: &Mutex<String>,
6421 final_message: Option<&AssistantMessage>,
6422) {
6423 let final_blocks = final_message.map(assistant_blocks);
6424 let final_text = final_message.map(assistant_text);
6425 let component = current_assistant.lock().unwrap().take();
6426
6427 if let Some(component) = component {
6428 if let Some(blocks) = final_blocks.as_deref() {
6429 component.update_blocks(blocks);
6430 }
6431 component.set_streaming(false);
6432 }
6433
6434 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6435 *last_assistant_text.lock().unwrap() = text;
6436 }
6437}
6438
6439async fn run_prompt_streaming(
6444 lane: &Arc<dyn AgentLane>,
6445 prompt: &str,
6446 tui: &Arc<TuiAltScreen>,
6447 state: &Arc<TuiState>,
6448 streaming: bool,
6449 js: Option<&crate::js_extensions::JsExtensionSession>,
6450 dialog_bridge: &JsDialogBridge,
6451 args: &Args,
6452 images: Vec<rpi_ai::types::ImageContent>,
6453) {
6454 state.set_status(RunStatus::Working);
6463 tui.request_render(false);
6464 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6465 state.set_status(RunStatus::Idle);
6466 tui.request_render(false);
6467 return;
6468 }
6469
6470 let outcome = lane.prompt_text(prompt, images).await;
6471
6472 if streaming {
6473 let final_message = match &outcome {
6478 Ok(result) => match &result.outcome {
6479 HarnessRunOutcome::Completed { final_message, .. }
6480 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6481 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6482 HarnessRunOutcome::Suspended { .. } => None,
6483 },
6484 Err(_) => None,
6485 };
6486 reconcile_streamed_assistant_completion(
6487 &state.current_assistant,
6488 &state.last_assistant_text,
6489 final_message,
6490 );
6491 }
6492
6493 state.set_status(RunStatus::Idle);
6494
6495 match outcome {
6496 Ok(result) => match &result.outcome {
6497 HarnessRunOutcome::Failed {
6498 error,
6499 final_message,
6500 ..
6501 } => {
6502 let already_rendered = streaming && final_message.is_some();
6510 if !already_rendered {
6511 let msg = final_message
6512 .as_ref()
6513 .and_then(|m| m.error_message.clone())
6514 .unwrap_or_else(|| format!("{error:?}"));
6515 add_error_message(&state.chat_container, &msg);
6516 }
6517 }
6518 HarnessRunOutcome::Suspended { .. } => {
6519 add_error_message(
6520 &state.chat_container,
6521 "Run suspended (deferred) — resume is not supported in v1.",
6522 );
6523 }
6524 HarnessRunOutcome::Aborted { final_message, .. } => {
6525 if !streaming {
6528 add_error_message(&state.chat_container, "Request aborted.");
6529 let _ = final_message; }
6531 }
6532 HarnessRunOutcome::Completed { final_message, .. } => {
6533 if !streaming {
6534 let text = assistant_text(final_message);
6535 if !text.is_empty() {
6536 add_assistant_message_blocking(
6537 &state.chat_container,
6538 &text,
6539 state.markdown_transformer(),
6540 );
6541 *state.last_assistant_text.lock().unwrap() = text;
6542 }
6543 }
6544 }
6545 },
6546 Err(e) => {
6547 add_error_message(&state.chat_container, &e.to_string());
6548 }
6549 }
6550
6551 tui.request_render(false);
6552}
6553
6554async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6559 state.set_status(RunStatus::Working);
6560 tui.request_render(false);
6561 match lane.compact(None).await {
6562 Ok(_) => {
6563 add_note_message(&state.chat_container, "Conversation compacted.");
6564 }
6565 Err(e) => {
6566 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6567 }
6568 }
6569 state.set_status(RunStatus::Idle);
6570 tui.request_render(false);
6571}
6572
6573fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6577 let text = state.last_assistant_text.lock().unwrap().clone();
6578 if text.is_empty() {
6579 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6580 return;
6581 }
6582 if copy_to_clipboard(&text) {
6583 add_note_message(chat, "Copied last reply to the clipboard.");
6584 } else {
6585 let preview: String = text.chars().take(200).collect();
6588 add_note_message(
6589 chat,
6590 &format!(
6591 "Clipboard unavailable. Last reply: {preview}{}",
6592 if text.chars().count() > 200 {
6593 "…"
6594 } else {
6595 ""
6596 }
6597 ),
6598 );
6599 }
6600}
6601
6602#[cfg(feature = "clipboard")]
6605fn copy_to_clipboard(text: &str) -> bool {
6606 match arboard::Clipboard::new() {
6607 Ok(mut cb) => cb.set_text(text).is_ok(),
6608 Err(_) => false,
6609 }
6610}
6611
6612#[cfg(not(feature = "clipboard"))]
6613fn copy_to_clipboard(_text: &str) -> bool {
6614 false
6615}
6616
6617#[cfg(feature = "clipboard")]
6621fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6622 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6623 let image = match clipboard.get_image() {
6624 Ok(image) => image,
6625 Err(_) => return Ok(None),
6626 };
6627 let width =
6628 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6629 let height =
6630 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6631 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6632 return Err("clipboard image dimensions are outside the supported range".into());
6633 }
6634 let mut bytes = Vec::new();
6635 {
6636 let mut encoder = png::Encoder::new(&mut bytes, width, height);
6637 encoder.set_color(png::ColorType::Rgba);
6638 encoder.set_depth(png::BitDepth::Eight);
6639 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6640 writer
6641 .write_image_data(&image.bytes)
6642 .map_err(|e| e.to_string())?;
6643 }
6644 Ok(Some(rpi_ai::types::ImageContent {
6645 kind: rpi_ai::types::ImageContentType,
6646 data: base64::engine::general_purpose::STANDARD.encode(bytes),
6647 mime_type: "image/png".into(),
6648 }))
6649}
6650
6651fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6652 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6653 let mut options = ImageOptions::default();
6654 options.width = Some(48);
6655 options.alt_text = Some("Attached image".into());
6656 chat.add_child(Arc::new(Image::from_data(bytes, options)));
6657 chat.add_child(Arc::new(Spacer::new(1)));
6658 }
6659}
6660
6661#[cfg(not(feature = "clipboard"))]
6662fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6663 Ok(None)
6664}
6665
6666fn add_assistant_message_blocking(
6673 container: &Arc<Container>,
6674 text: &str,
6675 transformer: Option<MarkdownTransformer>,
6676) {
6677 if text.is_empty() {
6678 return;
6679 }
6680 let msg = Arc::new(AssistantMessageComponent::new(
6681 AssistantMessageOptions::default(),
6682 ));
6683 if let Some(t) = &transformer {
6684 msg.set_markdown_transformer(Some(t.clone()));
6685 }
6686 msg.update_text(text);
6687 container.add_child(msg);
6688 container.add_child(Arc::new(Spacer::new(1)));
6689}
6690
6691async fn drain_agent_events(
6699 mut rx: broadcast::Receiver<AgentEvent>,
6700 tui: Arc<TuiAltScreen>,
6701 state: Arc<TuiState>,
6702 chat: Arc<Container>,
6703) {
6704 loop {
6705 match rx.recv().await {
6706 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6707 Err(broadcast::error::RecvError::Lagged(_)) => {
6708 continue;
6711 }
6712 Err(broadcast::error::RecvError::Closed) => break,
6713 }
6714 }
6715}
6716
6717async fn handle_agent_event(
6720 event: AgentEvent,
6721 tui: &Arc<TuiAltScreen>,
6722 state: &Arc<TuiState>,
6723 chat: &Arc<Container>,
6724) {
6725 match event {
6726 AgentEvent::AgentStart => {
6727 state.set_status(RunStatus::Working);
6728 tui.request_render(false);
6729 }
6730
6731 AgentEvent::AgentEnd { .. } => {
6732 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6734 comp.set_streaming(false);
6735 }
6736 state.set_status(RunStatus::Idle);
6737 tui.request_render(false);
6738 }
6739
6740 AgentEvent::RetryScheduled {
6741 attempt,
6742 max_retries,
6743 delay_ms,
6744 ..
6745 } => {
6746 state.show_retry(attempt, max_retries, delay_ms);
6747 tui.request_render(false);
6748 }
6749
6750 AgentEvent::TurnStart => {
6751 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6754 comp.set_streaming(false);
6755 }
6756 }
6757
6758 AgentEvent::TurnEnd {
6759 message,
6760 tool_results,
6761 } => {
6762 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6764 if let AgentMessage::Assistant(a) = &message {
6765 comp.update_blocks(&assistant_blocks(a));
6766 }
6767 comp.set_streaming(false);
6768 }
6769 let tools = state.tool_components.lock().unwrap();
6774 for tr in &tool_results {
6775 if tools.contains_key(&tr.tool_call_id) {
6776 let _ = tr;
6779 }
6780 }
6781 drop(tools);
6782 tui.request_render(false);
6783 }
6784
6785 AgentEvent::MessageStart { message } => match message {
6786 AgentMessage::Assistant(a) => {
6787 let comp = Arc::new(AssistantMessageComponent::new(
6788 AssistantMessageOptions::default(),
6789 ));
6790 if let Some(t) = state.markdown_transformer() {
6795 comp.set_markdown_transformer(Some(t));
6796 }
6797 comp.set_hide_thinking(state.hide_thinking());
6798 comp.set_streaming(true);
6799 comp.update_blocks(&assistant_blocks(&a));
6802 chat.add_child(comp.clone());
6803 chat.add_child(Arc::new(Spacer::new(1)));
6806 *state.current_assistant.lock().unwrap() = Some(comp);
6807 tui.request_render(false);
6808 }
6809 AgentMessage::Custom(custom) => {
6810 let payload = serde_json::json!({
6811 "customType": custom.role,
6812 "content": custom.content,
6813 "details": custom.data,
6814 "expanded": false,
6815 "outputPad": 1,
6816 });
6817 if let Some(component) = extension_message_component(
6818 &state.extension_session,
6819 &custom.role,
6820 &payload,
6821 state.markdown_transformer(),
6822 ) {
6823 chat.add_child(component);
6824 chat.add_child(Arc::new(Spacer::new(1)));
6825 tui.request_render(false);
6826 } else {
6827 add_note_message(chat, &custom_message_fallback(&custom));
6828 tui.request_render(false);
6829 }
6830 }
6831 _ => {}
6834 },
6835
6836 AgentEvent::MessageUpdate {
6837 message,
6838 assistant_message_event,
6839 } => {
6840 if let AgentMessage::Assistant(a) = &message {
6841 let text = assistant_text(a);
6842 let mut saw_bash_tool_call = false;
6843 for c in &a.content {
6848 if let Content::ToolCall(tc) = c {
6849 if tc.name.trim().is_empty() {
6853 continue;
6854 }
6855 if tc.name == "bash" {
6856 let command = tc
6862 .arguments
6863 .get("command")
6864 .and_then(|v| v.as_str())
6865 .unwrap_or("");
6866 if command.trim().is_empty() {
6873 continue;
6874 }
6875 saw_bash_tool_call = true;
6876 let mut bash = state.bash_components.lock().unwrap();
6877 if let Some(existing) = bash.get(&tc.id) {
6878 existing.set_command(command);
6883 } else {
6884 let comp = Arc::new(BashExecutionComponent::new(command));
6885 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6886 chat.add_child(comp.clone());
6887 bash.insert(tc.id.clone(), comp);
6888 }
6889 } else {
6890 let mut tools = state.tool_components.lock().unwrap();
6891 let display_args = if is_ask_user_tool(&tc.name) {
6892 ask_user_args_display(&tc.arguments)
6893 } else {
6894 tc.arguments.to_string()
6895 };
6896 if let Some(existing) = tools.get(&tc.id) {
6897 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
6901 existing.set_args(&display_args);
6902 }
6903 } else {
6904 let comp = Arc::new(ToolExecutionComponent::new(
6905 &tc.name,
6906 &display_args,
6907 ));
6908 if is_ask_user_tool(&tc.name) {
6909 comp.set_display_title("ASK USER");
6910 }
6911 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6912 comp.set_running();
6913 chat.add_child(comp.clone());
6914 tools.insert(tc.id.clone(), comp);
6915 }
6916 }
6917 }
6918 }
6919 if saw_bash_tool_call {
6924 state.sync_working_loader_with_bash();
6925 }
6926 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6928 comp.update_blocks(&assistant_blocks(a));
6931 }
6932 *state.last_assistant_text.lock().unwrap() = text;
6933 tui.request_render(false);
6934 }
6935 }
6936
6937 AgentEvent::MessageEnd { message } => {
6938 if let AgentMessage::Assistant(a) = &message {
6939 let text = assistant_text(a);
6940 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6941 comp.update_blocks(&assistant_blocks(a));
6942 comp.set_streaming(false);
6943 }
6944 if !text.is_empty() {
6946 *state.last_assistant_text.lock().unwrap() = text;
6947 }
6948 let usage = &a.usage;
6954 let prev_input = *state.last_input_tokens.lock().unwrap();
6955 if prev_input > 0
6956 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6957 && usage.cache_read == 0
6958 {
6959 add_note_message(
6960 &state.chat_container,
6961 &format!(
6962 "Cache miss: {} tokens re-billed",
6963 format_tokens(usage.input)
6964 ),
6965 );
6966 }
6967 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6968 add_note_message(chat, &text);
6969 }
6970 if let Some(error) = assistant_error_text(a) {
6975 add_error_message(chat, &error);
6976 }
6977 *state.last_input_tokens.lock().unwrap() = usage.input;
6978 }
6979 tui.request_render(false);
6980 }
6981
6982 AgentEvent::ToolExecutionStart {
6983 tool_call_id,
6984 tool_name,
6985 args,
6986 } => {
6987 if tool_name.trim().is_empty() {
6990 return;
6991 }
6992 if tool_name == "bash" {
6993 let command = args
6998 .get("command")
6999 .and_then(|v| v.as_str())
7000 .unwrap_or("")
7001 .to_string();
7002 if command.trim().is_empty() {
7006 return;
7007 }
7008 let mut bash_map = state.bash_components.lock().unwrap();
7009 if let Some(existing) = bash_map.get(&tool_call_id) {
7010 existing.set_command(&command);
7015 } else {
7016 let comp = Arc::new(BashExecutionComponent::new(command));
7017 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7018 chat.add_child(comp.clone());
7019 bash_map.insert(tool_call_id.clone(), comp);
7020 }
7021 } else {
7022 let _comp = {
7023 let mut tools = state.tool_components.lock().unwrap();
7024 if let Some(existing) = tools.get(&tool_call_id) {
7025 if is_ask_user_tool(&tool_name) {
7026 existing.set_display_title("ASK USER");
7027 existing.set_args(&ask_user_args_display(&args));
7028 } else {
7029 existing.set_args(&args.to_string());
7030 }
7031 existing.clone()
7032 } else {
7033 let display_args = if is_ask_user_tool(&tool_name) {
7034 ask_user_args_display(&args)
7035 } else {
7036 args.to_string()
7037 };
7038 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7039 if is_ask_user_tool(&tool_name) {
7040 comp.set_display_title("ASK USER");
7041 }
7042 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7043 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7048 comp.set_skill_name(skill);
7049 }
7050 comp.set_running();
7051 chat.add_child(comp.clone());
7052 tools.insert(tool_call_id.clone(), comp.clone());
7053 comp
7054 }
7055 };
7056 }
7057 state.sync_working_loader_with_bash();
7058 tui.request_render(false);
7059 }
7060
7061 AgentEvent::ToolExecutionUpdate {
7062 tool_call_id,
7063 tool_name,
7064 args,
7065 partial_result,
7066 } => {
7067 if tool_name.trim().is_empty() {
7068 return;
7069 }
7070 let partial_text = if is_ask_user_tool(&tool_name) {
7071 ask_user_progress_text(&args, &partial_result)
7072 } else {
7073 tool_result_text(&partial_result)
7074 };
7075 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7076 if tool_name == "bash" {
7077 let chunk = partial_text;
7082 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7083 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7086 bash.set_command(command);
7087 }
7088 if has_partial_payload {
7089 bash.append_output(&chunk);
7090 }
7091 } else if has_partial_payload {
7092 }
7098 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7099 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7100 comp.set_skill_name(skill);
7101 }
7102 if is_ask_user_tool(&tool_name) {
7103 comp.set_display_title("ASK USER");
7104 comp.set_args(&ask_user_args_display(&args));
7105 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7106 comp.set_args(&args.to_string());
7107 }
7108 if has_partial_payload {
7111 comp.set_result(&partial_text, false);
7112 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7113 }
7114 } else if has_partial_payload {
7115 let display_args = if is_ask_user_tool(&tool_name) {
7120 ask_user_args_display(&args)
7121 } else {
7122 args.to_string()
7123 };
7124 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7125 if is_ask_user_tool(&tool_name) {
7126 comp.set_display_title("ASK USER");
7127 }
7128 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7129 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7130 comp.set_skill_name(skill);
7131 }
7132 comp.set_running();
7133 comp.set_result(&partial_text, false);
7134 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7135 chat.add_child(comp.clone());
7136 state
7137 .tool_components
7138 .lock()
7139 .unwrap()
7140 .insert(tool_call_id.clone(), comp.clone());
7141 }
7142 state.sync_working_loader_with_bash();
7143 tui.request_render(false);
7144 }
7145
7146 AgentEvent::ToolExecutionEnd {
7147 tool_call_id,
7148 tool_name,
7149 result,
7150 is_error,
7151 } => {
7152 if tool_name.trim().is_empty() {
7153 return;
7154 }
7155 if tool_name == "bash" {
7156 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7157 if let Some(bash) = bash {
7158 finalize_bash(&bash, &result, is_error);
7159 } else {
7160 let command = result
7163 .details
7164 .get("command")
7165 .and_then(|v| v.as_str())
7166 .unwrap_or("")
7167 .to_string();
7168 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7169 state.sync_working_loader_with_bash();
7170 tui.render_now(false);
7173 return;
7174 }
7175 let comp = Arc::new(BashExecutionComponent::new(command));
7176 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7177 comp.append_output(&tool_result_text(&result));
7178 finalize_bash(&comp, &result, is_error);
7179 chat.add_child(comp);
7180 }
7181 } else {
7182 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7183 if let Some(comp) = comp {
7184 let result_text = if is_ask_user_tool(&tool_name) {
7185 ask_user_result_text(&result)
7186 } else {
7187 tool_result_text(&result)
7188 };
7189 comp.set_result(&result_text, is_error);
7190 if !is_ask_user_tool(&tool_name) {
7191 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7192 }
7193 } else {
7194 let comp = Arc::new(ToolExecutionComponent::new(
7197 &tool_name,
7198 &if is_ask_user_tool(&tool_name) {
7199 ask_user_args_display(&serde_json::Value::Null)
7200 } else {
7201 "".to_string()
7202 },
7203 ));
7204 if is_ask_user_tool(&tool_name) {
7205 comp.set_display_title("ASK USER");
7206 }
7207 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7208 let result_text = if is_ask_user_tool(&tool_name) {
7209 ask_user_result_text(&result)
7210 } else {
7211 tool_result_text(&result)
7212 };
7213 comp.set_result(&result_text, is_error);
7214 if !is_ask_user_tool(&tool_name) {
7215 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7216 }
7217 chat.add_child(comp.clone());
7218 }
7219 }
7220 state.sync_working_loader_with_bash();
7221 tui.render_now(false);
7227 }
7228 }
7229}
7230
7231fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7235 let fallback = match message.stop_reason {
7236 rpi_ai::StopReason::Error => "Provider request failed.",
7237 rpi_ai::StopReason::Aborted => "Request aborted.",
7238 _ => return None,
7239 };
7240 Some(
7241 message
7242 .error_message
7243 .as_deref()
7244 .filter(|text| !text.trim().is_empty())
7245 .unwrap_or(fallback)
7246 .to_string(),
7247 )
7248}
7249
7250fn finalize_bash(
7254 comp: &Arc<BashExecutionComponent>,
7255 result: &rpi_agent::AgentToolResult,
7256 is_error: bool,
7257) {
7258 let exit_code = if is_error { Some(1) } else { Some(0) };
7261 let truncated = result
7262 .details
7263 .get("truncation")
7264 .and_then(|t| t.get("truncated"))
7265 .and_then(|v| v.as_bool())
7266 .unwrap_or(false);
7267 let full_output_path = result
7268 .details
7269 .get("full_output_path")
7270 .and_then(|v| v.as_str())
7271 .map(|s| s.to_string());
7272 let truncation = BashTruncation {
7273 truncated,
7274 full_output_path,
7275 };
7276 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7280 comp.set_command(command);
7281 }
7282 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
7284}
7285
7286fn apply_edit_diff(
7291 comp: &Arc<ToolExecutionComponent>,
7292 tool_name: &str,
7293 details: &serde_json::Value,
7294 tui: &Arc<TuiAltScreen>,
7295) {
7296 if tool_name != "edit" {
7297 return;
7298 }
7299 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7300 return;
7301 };
7302 if diff_text.is_empty() {
7303 return;
7304 }
7305 let width = tui.width();
7306 let lines = render_diff(diff_text, width);
7307 comp.set_diff(lines);
7308}
7309
7310fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7315 if tool_name != "read" {
7316 return None;
7317 }
7318 let path = args.get("path").and_then(|value| value.as_str())?;
7319 let normalized = path.replace('\\', "/");
7320 let file_name = normalized.rsplit('/').next()?;
7321 if !file_name.eq_ignore_ascii_case("SKILL.md") {
7322 return None;
7323 }
7324 normalized
7325 .trim_end_matches('/')
7326 .rsplit('/')
7327 .nth(1)
7328 .filter(|name| !name.is_empty())
7329 .map(str::to_string)
7330}
7331
7332fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7335 use rpi_agent::TextContentOrImage;
7336 let mut parts: Vec<String> = Vec::new();
7337 for c in &result.content {
7338 if let TextContentOrImage::Text(t) = c {
7339 parts.push(t.text.clone());
7340 }
7341 }
7342 let joined = parts.join("\n");
7343 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7345 if one_line.chars().count() > 200 {
7346 let truncated: String = one_line.chars().take(200).collect();
7347 format!("{truncated}…")
7348 } else {
7349 one_line
7350 }
7351}
7352
7353fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7358 use rpi_agent::TextContentOrImage;
7359 let mut parts: Vec<String> = Vec::new();
7360 for c in &result.content {
7361 if let TextContentOrImage::Text(t) = c {
7362 parts.push(t.text.clone());
7363 }
7364 }
7365 parts.join("\n")
7366}
7367
7368fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7372 !text.trim().is_empty() || !result.details.is_null()
7373}
7374
7375fn is_ask_user_tool(tool_name: &str) -> bool {
7378 matches!(tool_name, "ask_user" | "ask_user_question")
7379}
7380
7381fn ask_user_args_display(args: &serde_json::Value) -> String {
7384 if args.is_null() {
7385 return String::new();
7386 }
7387 if let Some(summary) = args
7388 .get("summary")
7389 .and_then(serde_json::Value::as_str)
7390 .map(str::trim)
7391 .filter(|text| !text.is_empty())
7392 {
7393 return format!("Confirmation requested: {summary}");
7394 }
7395
7396 let mut lines: Vec<String> = Vec::new();
7397 let questions: Vec<&serde_json::Value> = args
7398 .get("questions")
7399 .and_then(serde_json::Value::as_array)
7400 .filter(|items| !items.is_empty())
7401 .map(|items| items.iter().collect())
7402 .unwrap_or_else(|| vec![args]);
7403
7404 for question in questions {
7405 let prompt = question
7406 .get("question")
7407 .and_then(serde_json::Value::as_str)
7408 .or_else(|| {
7409 args.get("question")
7410 .and_then(serde_json::Value::as_str)
7411 })
7412 .map(str::trim)
7413 .unwrap_or_default();
7414 if prompt.is_empty() {
7415 continue;
7416 }
7417 if let Some(header) = question
7418 .get("header")
7419 .and_then(serde_json::Value::as_str)
7420 .map(str::trim)
7421 .filter(|text| !text.is_empty())
7422 {
7423 lines.push(format!("{header}: {prompt}"));
7424 } else {
7425 lines.push(prompt.to_string());
7426 }
7427 let context = question
7428 .get("context")
7429 .and_then(serde_json::Value::as_str)
7430 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7431 .map(str::trim)
7432 .filter(|text| !text.is_empty());
7433 if let Some(context) = context {
7434 lines.push(format!(" {context}"));
7435 }
7436 let options = question
7437 .get("options")
7438 .and_then(serde_json::Value::as_array)
7439 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7440 if let Some(options) = options {
7441 let labels: Vec<String> = options
7442 .iter()
7443 .filter_map(|option| {
7444 option
7445 .get("title")
7446 .and_then(serde_json::Value::as_str)
7447 .or_else(|| option.as_str())
7448 .map(str::trim)
7449 .filter(|label| !label.is_empty())
7450 .map(str::to_string)
7451 })
7452 .collect();
7453 if !labels.is_empty() {
7454 lines.push(format!("Choices: {}", labels.join(", ")));
7455 }
7456 }
7457 if let Some(suggest) = question
7458 .get("suggest")
7459 .and_then(serde_json::Value::as_str)
7460 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7461 .map(str::trim)
7462 .filter(|text| !text.is_empty())
7463 {
7464 lines.push(format!("Suggestions: {suggest}"));
7465 }
7466 }
7467
7468 lines.join("\n")
7469}
7470
7471fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7474 let text = tool_result_text(result);
7475 if !text.trim().is_empty() {
7476 return text;
7477 }
7478 let args_text = ask_user_args_display(args);
7479 if args_text.trim().is_empty() {
7480 "Waiting for your answer…".to_string()
7481 } else {
7482 args_text
7483 }
7484}
7485
7486fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7489 let text = tool_result_text(result);
7490 if text.trim().is_empty() {
7491 "Answer recorded.".to_string()
7492 } else {
7493 text
7494 }
7495}
7496
7497fn open_selector(
7505 state: &Arc<TuiState>,
7506 editor_container: &Arc<Container>,
7507 editor: &Arc<Editor>,
7508 tui: &Arc<TuiAltScreen>,
7509 list: Arc<SelectList>,
7510 kind: SelectorKind,
7511) {
7512 open_selector_with_view(
7513 state,
7514 editor_container,
7515 editor,
7516 tui,
7517 list.clone(),
7518 list,
7519 kind,
7520 );
7521}
7522
7523fn open_selector_with_view<C: Component + 'static>(
7527 state: &Arc<TuiState>,
7528 editor_container: &Arc<Container>,
7529 editor: &Arc<Editor>,
7530 tui: &Arc<TuiAltScreen>,
7531 list: Arc<SelectList>,
7532 view: Arc<C>,
7533 kind: SelectorKind,
7534) {
7535 editor.set_focused(false);
7537 state.autocomplete_container.clear();
7540 editor_container.clear();
7542 editor_container.add_child(view.clone());
7543 *state.active_selector.lock().unwrap() = Some((list, kind));
7544 let focused: Arc<dyn Component> = view;
7545 tui.set_focus(Some(focused));
7546 tui.request_render(false);
7547}
7548
7549fn close_selector(
7552 state: &Arc<TuiState>,
7553 editor_container: &Arc<Container>,
7554 editor: &Arc<Editor>,
7555 tui: &Arc<TuiAltScreen>,
7556) {
7557 editor_container.clear();
7558 editor_container.add_child(editor.clone());
7559 state.autocomplete_container.clear();
7560 editor.set_focused(true);
7561 *state.active_selector.lock().unwrap() = None;
7562 *state.active_extension_cancel.lock().unwrap() = None;
7563 tui.set_focus(Some(editor.clone()));
7564 tui.request_render(false);
7565}
7566
7567fn open_model_selector(
7573 state: &Arc<TuiState>,
7574 editor_container: &Arc<Container>,
7575 editor: &Arc<Editor>,
7576 tui: &Arc<TuiAltScreen>,
7577 catalog: &[rpi_ai::Model],
7578 lane: &Arc<dyn AgentLane>,
7579 lane_model_id: &str,
7580 chat: &Arc<Container>,
7581) {
7582 let items = model_selector_items(catalog, lane_model_id);
7583 if items.is_empty() {
7584 add_note_message(
7585 chat,
7586 "No models in the catalog. Use --model at startup to select one.",
7587 );
7588 tui.request_render(false);
7589 return;
7590 }
7591 let list = Arc::new(SelectList::new(items, 10));
7592
7593 let catalog_arc = catalog.to_vec();
7597 let state_sel = state.clone();
7598 let ec_sel = editor_container.clone();
7599 let editor_sel = editor.clone();
7600 let tui_sel = tui.clone();
7601 let chat_sel = chat.clone();
7602 let lane_sel = lane.clone();
7603 list.on_select(Arc::new(move |item| {
7604 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7605 add_note_message(
7606 &chat_sel,
7607 &format!("Model {} not found in catalog.", item.label),
7608 );
7609 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7610 return;
7611 };
7612 state_sel.set_current_model(&model);
7613 let lane = lane_sel.clone();
7614 tokio::spawn(async move {
7615 let _ = lane.set_model(model).await;
7616 });
7617 add_note_message(
7618 &chat_sel,
7619 &format!(
7620 "Model set to {} — applies to the next message.",
7621 short_model_name(&item.value)
7622 ),
7623 );
7624 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7625 }));
7626 let state_cancel = state.clone();
7627 let ec_cancel = editor_container.clone();
7628 let editor_cancel = editor.clone();
7629 let tui_cancel = tui.clone();
7630 list.on_cancel(Arc::new(move || {
7631 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7632 }));
7633
7634 open_selector(
7635 state,
7636 editor_container,
7637 editor,
7638 tui,
7639 list,
7640 SelectorKind::Model,
7641 );
7642}
7643
7644fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
7649 if catalog.is_empty() {
7650 return None;
7651 }
7652 let idx = catalog
7653 .iter()
7654 .position(|m| m.id.eq_ignore_ascii_case(current_id));
7655 match idx {
7656 Some(i) => {
7657 let next = (i + 1) % catalog.len();
7658 Some(catalog[next].clone())
7659 }
7660 None => Some(catalog[0].clone()),
7661 }
7662}
7663
7664fn open_session_selector(
7670 state: &Arc<TuiState>,
7671 editor_container: &Arc<Container>,
7672 editor: &Arc<Editor>,
7673 tui: &Arc<TuiAltScreen>,
7674 cwd: &std::path::Path,
7675 tx: &mpsc::UnboundedSender<TuiMessage>,
7676) {
7677 let dir = crate::session::default_session_dir(cwd);
7678 let mut items: Vec<SelectItem> = Vec::new();
7679 if let Ok(entries) = std::fs::read_dir(&dir) {
7680 for entry in entries.flatten() {
7681 let path = entry.path();
7682 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7683 continue;
7684 }
7685 let stem = path
7686 .file_stem()
7687 .and_then(|s| s.to_str())
7688 .unwrap_or("(unnamed)")
7689 .to_string();
7690 let display = path
7691 .file_name()
7692 .and_then(|s| s.to_str())
7693 .unwrap_or(&stem)
7694 .to_string();
7695 items.push(SelectItem::new(&stem, &display));
7696 }
7697 }
7698 if items.is_empty() {
7699 add_note_message(
7700 &state.chat_container,
7701 "No saved sessions found. Sessions are created automatically in interactive mode.",
7702 );
7703 tui.request_render(false);
7704 return;
7705 }
7706 let list = Arc::new(SelectList::new(items, 10));
7707
7708 let state_sel = state.clone();
7709 let ec_sel = editor_container.clone();
7710 let editor_sel = editor.clone();
7711 let tui_sel = tui.clone();
7712 let tx_sel = tx.clone();
7713 list.on_select(Arc::new(move |item| {
7714 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7718 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
7719 }));
7720 let state_cancel = state.clone();
7721 let ec_cancel = editor_container.clone();
7722 let editor_cancel = editor.clone();
7723 let tui_cancel = tui.clone();
7724 list.on_cancel(Arc::new(move || {
7725 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7726 }));
7727
7728 open_selector(
7729 state,
7730 editor_container,
7731 editor,
7732 tui,
7733 list,
7734 SelectorKind::Session,
7735 );
7736}
7737
7738fn custom_entry_display_text(
7739 custom_type: &str,
7740 data: Option<&serde_json::Value>,
7741) -> Option<String> {
7742 let data = data?;
7743 let text = data
7744 .get("summary")
7745 .or_else(|| data.get("text"))
7746 .or_else(|| data.get("output"))
7747 .and_then(|value| value.as_str())
7748 .filter(|value| !value.trim().is_empty())?;
7749 let label = match custom_type {
7750 "compactionSummary" => "Compaction summary",
7751 "branchSummary" => "Branch summary",
7752 "bashExecution" => "Command output",
7753 other => other,
7754 };
7755 Some(format!("{label}: {text}"))
7756}
7757
7758async fn open_tree_selector(
7762 harness: &AgentHarness,
7763 state: &Arc<TuiState>,
7764 editor_container: &Arc<Container>,
7765 editor: &Arc<Editor>,
7766 tui: &Arc<TuiAltScreen>,
7767 chat: &Arc<Container>,
7768 tx: &mpsc::UnboundedSender<TuiMessage>,
7769) {
7770 let entries = match harness
7771 .session()
7772 .view("main")
7773 .find_entries(&EntryQuery {
7774 order: Some(EntryOrder::OldestFirst),
7775 ..Default::default()
7776 })
7777 .await
7778 {
7779 Ok(entries) => entries,
7780 Err(error) => {
7781 add_error_message(chat, &format!("Could not read session tree: {error}"));
7782 tui.request_render(false);
7783 return;
7784 }
7785 };
7786 let current = harness.session().get_leaf_id().await.ok().flatten();
7787 let items: Vec<SelectItem> = entries
7788 .iter()
7789 .map(|entry| {
7790 let marker = if current.as_deref() == Some(entry.id()) {
7791 " (current)"
7792 } else {
7793 ""
7794 };
7795 SelectItem::new(
7796 entry.id(),
7797 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7798 )
7799 .with_description(&entry.id()[..entry.id().len().min(12)])
7800 })
7801 .collect();
7802 if items.is_empty() {
7803 add_note_message(chat, "The current session has no entries to navigate.");
7804 tui.request_render(false);
7805 return;
7806 }
7807 let list = Arc::new(SelectList::new(items, 12));
7808 let state_sel = state.clone();
7809 let ec_sel = editor_container.clone();
7810 let editor_sel = editor.clone();
7811 let tui_sel = tui.clone();
7812 let tx_sel = tx.clone();
7813 list.on_select(Arc::new(move |item| {
7814 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7815 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7816 }));
7817 let state_cancel = state.clone();
7818 let ec_cancel = editor_container.clone();
7819 let editor_cancel = editor.clone();
7820 let tui_cancel = tui.clone();
7821 list.on_cancel(Arc::new(move || {
7822 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7823 }));
7824 open_selector(
7825 state,
7826 editor_container,
7827 editor,
7828 tui,
7829 list,
7830 SelectorKind::Tree,
7831 );
7832}
7833
7834fn open_theme_selector(
7837 state: &Arc<TuiState>,
7838 editor_container: &Arc<Container>,
7839 editor: &Arc<Editor>,
7840 tui: &Arc<TuiAltScreen>,
7841 cwd: &std::path::Path,
7842 package_resources: &Arc<crate::packages::PackageResources>,
7843) {
7844 let mut items = vec![
7845 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7846 SelectItem::new("light", "Light").with_description("Light background"),
7847 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7848 ];
7849 if state.themes_enabled {
7850 for path in package_resources.theme_files() {
7851 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7852 items.push(SelectItem::new(name, name).with_description("Package theme"));
7853 }
7854 }
7855 }
7856 let list = Arc::new(SelectList::new(items, 10));
7857
7858 let state_sel = state.clone();
7859 let ec_sel = editor_container.clone();
7860 let editor_sel = editor.clone();
7861 let tui_sel = tui.clone();
7862 let chat_sel = state.chat_container.clone();
7863 let cwd_sel = cwd.to_path_buf();
7864 let package_resources_sel = package_resources.clone();
7865 list.on_select(Arc::new(move |item| {
7866 let preset = match item.value.as_str() {
7867 "light" => Some(ThemePreset::Light),
7868 "monochrome" => Some(ThemePreset::Monochrome),
7869 "dark" => Some(ThemePreset::Dark),
7870 name => {
7871 if state_sel.themes_enabled {
7872 if let Ok(custom) = crate::packages::load_theme_with_resources(
7873 &cwd_sel,
7874 name,
7875 &package_resources_sel,
7876 ) {
7877 rpi_tui::global_theme_manager().set(custom.clone());
7878 state_sel.theme_manager.set(custom);
7879 }
7880 }
7881 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7882 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7883 tui_sel.render_now(true);
7884 return;
7885 }
7886 };
7887 let Some(preset) = preset else { return };
7888 apply_theme_preset(preset);
7889 state_sel.theme_manager.apply_preset(preset);
7890 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7893 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7894 tui_sel.render_now(true);
7895 }));
7896 let state_cancel = state.clone();
7897 let ec_cancel = editor_container.clone();
7898 let editor_cancel = editor.clone();
7899 let tui_cancel = tui.clone();
7900 list.on_cancel(Arc::new(move || {
7901 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7902 }));
7903
7904 open_selector(
7905 state,
7906 editor_container,
7907 editor,
7908 tui,
7909 list,
7910 SelectorKind::Theme,
7911 );
7912}
7913
7914fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7921 use rpi_ai::types::ThinkingLevel::*;
7922 match level {
7923 Off => "Off — No reasoning",
7924 Minimal => "Minimal — Brief reasoning (~1k tokens)",
7925 Low => "Low — Light reasoning (~1k tokens)",
7926 Medium => "Medium — Moderate reasoning (~80% of max)",
7927 High => "High — Extensive reasoning (~95% of max)",
7928 Xhigh => "Xhigh — Near-maximal reasoning",
7929 Max => "Max — Maximum reasoning",
7930 }
7931}
7932
7933fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7936 use rpi_ai::types::ThinkingLevel::*;
7937 match level {
7938 Off => "off",
7939 Minimal => "minimal",
7940 Low => "low",
7941 Medium => "medium",
7942 High => "high",
7943 Xhigh => "xhigh",
7944 Max => "max",
7945 }
7946}
7947
7948fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7951 use rpi_ai::types::ThinkingLevel::*;
7952 match name.to_ascii_lowercase().as_str() {
7953 "off" => Some(Off),
7954 "minimal" => Some(Minimal),
7955 "low" => Some(Low),
7956 "medium" => Some(Medium),
7957 "high" => Some(High),
7958 "xhigh" => Some(Xhigh),
7959 "max" => Some(Max),
7960 _ => None,
7961 }
7962}
7963
7964fn open_thinking_selector(
7974 state: &Arc<TuiState>,
7975 editor_container: &Arc<Container>,
7976 editor: &Arc<Editor>,
7977 tui: &Arc<TuiAltScreen>,
7978 lane: &Arc<dyn AgentLane>,
7979 catalog: &[rpi_ai::Model],
7980 lane_model_id: &str,
7981 chat: &Arc<Container>,
7982) {
7983 let model = catalog
7986 .iter()
7987 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7988 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7989 .map(|m| m.supported_thinking_levels())
7990 .unwrap_or_else(|| {
7991 use rpi_ai::types::ThinkingLevel::*;
7992 vec![Off, Minimal, Low, Medium, High]
7993 });
7994 let mut items: Vec<SelectItem> = Vec::new();
7995 for lvl in &levels {
7996 let name = thinking_level_name(*lvl);
7997 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7998 }
7999 if items.is_empty() {
8000 add_note_message(chat, "This model has no supported thinking levels.");
8001 tui.request_render(false);
8002 return;
8003 }
8004 let list = Arc::new(SelectList::new(items, 10));
8005
8006 let state_sel = state.clone();
8007 let ec_sel = editor_container.clone();
8008 let editor_sel = editor.clone();
8009 let tui_sel = tui.clone();
8010 let chat_sel = chat.clone();
8011 let lane_sel = lane.clone();
8012 list.on_select(Arc::new(move |item| {
8013 let Some(level) = thinking_level_from_name(&item.value) else {
8014 add_note_message(
8015 &chat_sel,
8016 &format!("Unknown thinking level: {}.", item.label),
8017 );
8018 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8019 return;
8020 };
8021 let lane = lane_sel.clone();
8022 let footer_sel = state_sel.footer.clone();
8023 tokio::spawn(async move {
8024 let _ = lane.set_thinking_level(level).await;
8025 });
8026 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8030 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8031 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8032 }));
8033 let state_cancel = state.clone();
8034 let ec_cancel = editor_container.clone();
8035 let editor_cancel = editor.clone();
8036 let tui_cancel = tui.clone();
8037 list.on_cancel(Arc::new(move || {
8038 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8039 }));
8040
8041 open_selector(
8042 state,
8043 editor_container,
8044 editor,
8045 tui,
8046 list,
8047 SelectorKind::Thinking,
8048 );
8049}
8050
8051fn open_tools_selector(
8057 state: &Arc<TuiState>,
8058 editor_container: &Arc<Container>,
8059 editor: &Arc<Editor>,
8060 tui: &Arc<TuiAltScreen>,
8061 lane: &Arc<dyn AgentLane>,
8062 chat: &Arc<Container>,
8063) {
8064 let mut active = match tokio::runtime::Handle::try_current() {
8069 Ok(h) => h
8070 .block_on(async { lane.get_active_tools().await })
8071 .unwrap_or_default(),
8072 Err(_) => Vec::new(),
8073 };
8074 if active.is_empty() {
8079 active = crate::session::BUILTIN_TOOL_NAMES
8080 .iter()
8081 .map(|name| (*name).to_string())
8082 .collect();
8083 }
8084 let mut items: Vec<SelectItem> = Vec::new();
8085 for name in crate::session::BUILTIN_TOOL_NAMES {
8086 let on = active.iter().any(|a| a == name);
8087 let label = if on {
8088 format!("{name} (on)")
8089 } else {
8090 (*name).to_string()
8091 };
8092 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8093 }
8094 let list = Arc::new(SelectList::new(items, 10));
8095
8096 let active_captured = active.clone();
8098 let state_sel = state.clone();
8099 let ec_sel = editor_container.clone();
8100 let editor_sel = editor.clone();
8101 let tui_sel = tui.clone();
8102 let chat_sel = chat.clone();
8103 let lane_sel = lane.clone();
8104 list.on_select(Arc::new(move |item| {
8105 let mut next = active_captured.clone();
8106 if let Some(pos) = next.iter().position(|a| a == &item.value) {
8107 next.remove(pos);
8108 } else {
8109 next.push(item.value.clone());
8110 }
8111 let on = next.iter().any(|a| a == &item.value);
8112 let lane = lane_sel.clone();
8113 let next_clone = next.clone();
8114 tokio::spawn(async move {
8115 let _ = lane.set_active_tools(next_clone).await;
8116 });
8117 let list_str = if next.is_empty() {
8118 "(none)".to_string()
8119 } else {
8120 next.join(", ")
8121 };
8122 add_note_message(
8123 &chat_sel,
8124 &format!(
8125 "{} {} — active tools: {}",
8126 item.value,
8127 if on { "enabled" } else { "disabled" },
8128 list_str
8129 ),
8130 );
8131 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8132 }));
8133 let state_cancel = state.clone();
8134 let ec_cancel = editor_container.clone();
8135 let editor_cancel = editor.clone();
8136 let tui_cancel = tui.clone();
8137 list.on_cancel(Arc::new(move || {
8138 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8139 }));
8140
8141 open_selector(
8142 state,
8143 editor_container,
8144 editor,
8145 tui,
8146 list,
8147 SelectorKind::Tools,
8148 );
8149}
8150
8151fn open_images_selector(
8155 state: &Arc<TuiState>,
8156 editor_container: &Arc<Container>,
8157 editor: &Arc<Editor>,
8158 tui: &Arc<TuiAltScreen>,
8159 chat: &Arc<Container>,
8160) {
8161 let current = *state.show_images.lock().unwrap();
8162 let items = vec![
8163 SelectItem::new("yes", "Yes").with_description(if current {
8164 "Inline images (current)"
8165 } else {
8166 "Inline images"
8167 }),
8168 SelectItem::new("no", "No").with_description(if current {
8169 "Placeholder only"
8170 } else {
8171 "Placeholder only (current)"
8172 }),
8173 ];
8174 let list = Arc::new(SelectList::new(items, 5));
8175
8176 let state_sel = state.clone();
8177 let ec_sel = editor_container.clone();
8178 let editor_sel = editor.clone();
8179 let tui_sel = tui.clone();
8180 let chat_sel = chat.clone();
8181 list.on_select(Arc::new(move |item| {
8182 let on = item.value == "yes";
8183 *state_sel.show_images.lock().unwrap() = on;
8184 add_note_message(
8185 &chat_sel,
8186 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8187 );
8188 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8189 }));
8190 let state_cancel = state.clone();
8191 let ec_cancel = editor_container.clone();
8192 let editor_cancel = editor.clone();
8193 let tui_cancel = tui.clone();
8194 list.on_cancel(Arc::new(move || {
8195 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8196 }));
8197
8198 open_selector(
8199 state,
8200 editor_container,
8201 editor,
8202 tui,
8203 list,
8204 SelectorKind::Images,
8205 );
8206}
8207
8208fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8216 let text = editor.get_text();
8217 let cursor = editor_cursor_offset(editor, &text);
8218 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8219 render_autocomplete(state, suggestions);
8220}
8221
8222fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8225 let (row, col) = editor.cursor_position();
8226 let mut offset = 0;
8227 for (index, line) in text.split('\n').enumerate() {
8228 if index == row {
8229 return (offset + col.min(line.len())).min(text.len());
8230 }
8231 offset = offset.saturating_add(line.len() + 1);
8232 }
8233 text.len()
8234}
8235
8236fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8239 let offset = offset.min(text.len());
8240 let mut consumed = 0;
8241 for (row, line) in text.split('\n').enumerate() {
8242 let end = consumed + line.len();
8243 if offset <= end {
8244 editor.set_cursor(row, offset - consumed);
8245 return;
8246 }
8247 consumed = end + 1;
8248 }
8249 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8250 editor.set_cursor(
8251 last_row,
8252 text.rsplit('\n').next().map(str::len).unwrap_or(0),
8253 );
8254}
8255
8256fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8258 state.autocomplete_container.clear();
8259 let Some(sugg) = suggestions else {
8260 return;
8261 };
8262 if sugg.items.is_empty() {
8263 return;
8264 }
8265 let accent = state.theme_manager.get().colors.accent;
8268 let muted = state.theme_manager.get().colors.muted;
8269 for (i, item) in sugg
8270 .items
8271 .iter()
8272 .take(state.autocomplete_max_visible)
8273 .enumerate()
8274 {
8275 let prefix = if i == 0 { "→ " } else { " " };
8276 let label = item.display_text();
8277 let line = if i == 0 {
8278 format!(
8279 "{prefix}{} {}",
8280 accent.fg(label),
8281 muted.fg(item.description.as_deref().unwrap_or(""))
8282 )
8283 } else {
8284 format!(
8285 "{prefix}{} {}",
8286 muted.fg(label),
8287 muted.fg(item.description.as_deref().unwrap_or(""))
8288 )
8289 };
8290 state
8291 .autocomplete_container
8292 .add_child(Arc::new(Text::new(line, 1, 0)));
8293 }
8294}
8295
8296fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8300 let text = editor.get_text();
8301 let cursor = editor_cursor_offset(editor, &text);
8302 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8303 return false;
8304 };
8305 let Some(top) = sugg.items.first() else {
8306 return false;
8307 };
8308 let start = sugg.start.min(text.len());
8312 let end = sugg.end.min(text.len());
8313 let mut replaced = String::with_capacity(text.len() + top.text.len());
8314 replaced.push_str(&text[..start]);
8315 replaced.push_str(&top.text);
8316 replaced.push_str(&text[end..]);
8319 if top.insert_space && !replaced.ends_with('/') {
8320 replaced.push(' ');
8321 }
8322 let new_cursor = replaced.len().min(
8325 start
8326 + top.text.len()
8327 + if top.insert_space && !top.text.ends_with('/') {
8328 1
8329 } else {
8330 0
8331 },
8332 );
8333 editor.set_text(&replaced);
8334 set_editor_cursor_offset(editor, &replaced, new_cursor);
8335 state.autocomplete_container.clear();
8336 true
8337}
8338
8339fn add_welcome_message(container: &Arc<Container>) {
8345 add_welcome_message_with_capabilities(container, &[], &[]);
8346}
8347
8348fn add_welcome_message_with_capabilities(
8352 container: &Arc<Container>,
8353 active_tools: &[String],
8354 skills: &[String],
8355) {
8356 let c = current_theme().colors;
8357 let title = format!(
8361 "{} {}",
8362 c.accent.fg(&tui_bold("rpi")),
8363 c.muted.fg("interactive TUI")
8364 );
8365 container.add_child(Arc::new(Text::new(title, 1, 0)));
8366 container.add_child(Arc::new(Spacer::new(1)));
8367 container.add_child(Arc::new(Text::new(
8368 c.dim.fg("Type your message and press Enter to send."),
8369 1,
8370 0,
8371 )));
8372 let hint = c
8373 .dim
8374 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8375 container.add_child(Arc::new(Text::new(hint, 1, 0)));
8376 container.add_child(Arc::new(Spacer::new(1)));
8377 container.add_child(Arc::new(Text::new(
8378 welcome_capability_line("Tools", active_tools),
8379 1,
8380 0,
8381 )));
8382 container.add_child(Arc::new(Text::new(
8383 welcome_capability_line("Skills", skills),
8384 1,
8385 0,
8386 )));
8387 container.add_child(Arc::new(DynamicBorder::new()));
8388}
8389
8390fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8393 let colors = current_theme().colors;
8394 let group = Arc::new(Container::new());
8395
8396 for warning in &report.warnings {
8397 let body = format!(
8398 "{}\n{} {}{}",
8399 colors.error.fg(&warning.message),
8400 colors.muted.fg("Run"),
8401 colors.accent.fg(&warning.command),
8402 colors.muted.fg(" to retry.")
8403 );
8404 add_update_panel(&group, "Update Failed", &body, colors.error);
8405 }
8406
8407 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8408 let body = format!(
8409 "{} {}{}",
8410 colors
8411 .muted
8412 .fg(&format!("New version {} is available. Run", notice.latest)),
8413 colors.accent.fg(¬ice.command),
8414 colors.muted.fg(".")
8415 );
8416 add_update_panel(&group, "Update Available", &body, colors.warning);
8417 }
8418
8419 let package_notices = report
8420 .notices
8421 .iter()
8422 .filter(|notice| notice.name != "rpi")
8423 .collect::<Vec<_>>();
8424 if !package_notices.is_empty() {
8425 let command = package_notices[0].command.as_str();
8426 let mut lines = vec![format!(
8427 "{} {}{}",
8428 colors.muted.fg("Package updates are available. Run"),
8429 colors.accent.fg(command),
8430 colors.muted.fg(".")
8431 )];
8432 lines.push(colors.muted.fg("Packages:"));
8433 lines.extend(
8434 package_notices
8435 .into_iter()
8436 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8437 );
8438 add_update_panel(
8439 &group,
8440 "Package Updates Available",
8441 &lines.join("\n"),
8442 colors.warning,
8443 );
8444 }
8445
8446 if group.child_count() > 0 {
8450 container.add_child(group);
8451 }
8452}
8453
8454fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8455 container.add_child(Arc::new(Spacer::new(1)));
8456 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8457 container.add_child(Arc::new(Text::new(
8458 format!("{}\n{body}", color.fg(&tui_bold(title))),
8459 1,
8460 0,
8461 )));
8462 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8463}
8464
8465fn welcome_capability_line(label: &str, names: &[String]) -> String {
8466 let c = current_theme().colors;
8467 let value = if names.is_empty() {
8468 "none".to_string()
8469 } else {
8470 names.join(" · ")
8471 };
8472 format!(
8473 "{} {}",
8474 c.accent.fg(&format!("{label} ({})", names.len())),
8475 c.muted.fg(&value)
8476 )
8477}
8478
8479fn add_help_message(container: &Arc<Container>) {
8481 let c = current_theme().colors;
8482 container.add_child(Arc::new(Text::new(
8486 c.md_heading.fg(&tui_bold("📚 Available Commands")),
8487 1,
8488 0,
8489 )));
8490 container.add_child(Arc::new(Spacer::new(1)));
8491
8492 let cmds: &[(&str, &str)] = &[
8493 ("/help, /?", "Show this help message"),
8494 ("/clear, /new", "Clear the conversation"),
8495 ("/exit, /quit, /q", "Exit the application"),
8496 ("/version, /v", "Show version information"),
8497 ("/changelog", "Show recent release changes"),
8498 ("/model, /m", "Choose a model (live switch)"),
8499 ("/thinking, /think", "Set reasoning depth (selector)"),
8500 ("/tools", "Toggle built-in tools on/off"),
8501 ("/images", "Toggle inline image rendering"),
8502 ("/session", "List saved sessions"),
8503 ("/theme", "Choose a theme (selector)"),
8504 ("/compact", "Compact the conversation"),
8505 ("/copy", "Copy last reply to clipboard"),
8506 ("/hotkeys", "Show keyboard shortcuts"),
8507 ("/armin", "🐾 Easter egg"),
8508 ("/earendil", "Earendil announcement"),
8509 ];
8510 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8511 for (cmd, desc) in cmds {
8512 let row = format!(
8513 " {:<cmd_w$} {} {}",
8514 c.accent.fg(cmd),
8515 c.dim.fg("—"),
8516 c.muted.fg(desc)
8517 );
8518 container.add_child(Arc::new(Text::new(row, 1, 0)));
8519 }
8520 container.add_child(Arc::new(Spacer::new(1)));
8521}
8522
8523fn add_version_message(container: &Arc<Container>) {
8525 let c = current_theme().colors;
8526 container.add_child(Arc::new(Text::new(
8527 c.md_heading.fg(&tui_bold("📦 Version Information")),
8528 1,
8529 0,
8530 )));
8531 container.add_child(Arc::new(Spacer::new(1)));
8532 container.add_child(Arc::new(Text::new(
8535 format!(
8536 " {} {}",
8537 c.muted.fg("rpi-cli"),
8538 c.text.fg(&format!("v{}", crate::VERSION))
8539 ),
8540 1,
8541 0,
8542 )));
8543 container.add_child(Arc::new(Text::new(
8544 format!(
8545 " {}",
8546 c.dim.fg("Rust implementation of pi coding agent TUI")
8547 ),
8548 1,
8549 0,
8550 )));
8551 container.add_child(Arc::new(Spacer::new(1)));
8552}
8553
8554fn add_changelog_message(container: &Arc<Container>) {
8558 let c = current_theme().colors;
8559 container.add_child(Arc::new(Text::new(
8560 c.md_heading.fg(&tui_bold("Recent Changes")),
8561 1,
8562 0,
8563 )));
8564 container.add_child(Arc::new(Spacer::new(1)));
8565 let entries = [
8566 (
8567 "Native parity phase 1",
8568 "models, images, trust, export, and JSON events",
8569 ),
8570 (
8571 "TUI controls",
8572 "external editor, thinking levels, and tool output toggles",
8573 ),
8574 (
8575 "Provider auth",
8576 "OpenAI-compatible API key aliases and gateway headers",
8577 ),
8578 ];
8579 for (release, summary) in entries {
8580 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
8581 container.add_child(Arc::new(Text::new(row, 1, 0)));
8582 }
8583 container.add_child(Arc::new(Text::new(
8584 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8585 1,
8586 0,
8587 )));
8588 container.add_child(Arc::new(Spacer::new(1)));
8589}
8590
8591fn add_hotkeys_message(container: &Arc<Container>) {
8593 let c = current_theme().colors;
8594 container.add_child(Arc::new(Text::new(
8595 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
8596 1,
8597 0,
8598 )));
8599 container.add_child(Arc::new(Spacer::new(1)));
8600 let keys: &[(&str, &str)] = &[
8601 ("Enter", "Send message"),
8602 ("Shift+Enter", "New line"),
8603 ("Tab", "Accept autocomplete suggestion"),
8604 ("Ctrl+A / Ctrl+E", "Line start / end"),
8605 (
8606 "Ctrl+K / Ctrl+U",
8607 "Kill to end / start of line (Ctrl+Y yanks)",
8608 ),
8609 ("Ctrl+- / Ctrl+R", "Undo / redo"),
8610 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8611 ("Alt+Backspace", "Kill previous word"),
8612 ("Ctrl+C", "Abort a run, or exit when idle"),
8613 ("Esc", "Abort a running prompt"),
8614 ("Ctrl+L", "Open model selector"),
8615 ("Ctrl+M", "Cycle to the next model (live)"),
8616 ("Ctrl+O", "Expand/collapse all tool output"),
8617 ("Ctrl+T", "Show/hide reasoning blocks"),
8618 ("PageUp/Down", "Scroll transcript by one page"),
8619 ("Home / End", "Jump to transcript start / latest output"),
8620 ];
8621 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8622 for (key, desc) in keys {
8623 let row = format!(
8624 " {:<key_w$} {} {}",
8625 c.accent.fg(key),
8626 c.dim.fg("—"),
8627 c.muted.fg(desc)
8628 );
8629 container.add_child(Arc::new(Text::new(row, 1, 0)));
8630 }
8631 container.add_child(Arc::new(Spacer::new(1)));
8632}
8633
8634fn add_user_message(container: &Arc<Container>, text: &str) {
8640 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
8641 container.add_child(Arc::new(Spacer::new(1)));
8642}
8643
8644fn add_error_message(container: &Arc<Container>, text: &str) {
8646 let c = current_theme().colors;
8647 let text = sanitize_error_message(text);
8648 container.add_child(Arc::new(Text::new(
8649 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
8650 1,
8651 0,
8652 )));
8653 container.add_child(Arc::new(Spacer::new(1)));
8654}
8655
8656fn sanitize_error_message(text: &str) -> String {
8661 const MAX_ERROR_CHARS: usize = 16 * 1024;
8662 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
8663 let mut count = 0;
8664 let mut escape_mode = 0u8;
8666 for ch in text.chars() {
8667 if escape_mode != 0 {
8668 match escape_mode {
8669 1 if ch == '[' => escape_mode = 2,
8670 1 if ch == ']' => escape_mode = 3,
8671 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
8672 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
8673 3 if ch == '\x07' => escape_mode = 0,
8674 _ => {}
8675 }
8676 continue;
8677 }
8678 if ch == '\x1b' {
8679 escape_mode = 1;
8680 continue;
8681 }
8682 if count >= MAX_ERROR_CHARS {
8683 result.push_str("…");
8684 break;
8685 }
8686 match ch {
8687 '\n' | '\t' => {
8688 result.push(ch);
8689 count += 1;
8690 }
8691 '\r' => {}
8692 c if c.is_control() => {}
8693 c => {
8694 result.push(c);
8695 count += 1;
8696 }
8697 }
8698 }
8699 if result.trim().is_empty() {
8700 "Provider request failed.".to_string()
8701 } else {
8702 result
8703 }
8704}
8705
8706fn add_note_message(container: &Arc<Container>, text: &str) {
8708 let c = current_theme().colors;
8709 container.add_child(Arc::new(Text::new(
8710 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
8711 1,
8712 0,
8713 )));
8714 container.add_child(Arc::new(Spacer::new(1)));
8715}
8716
8717fn show_context_panel(
8726 chat: &Arc<Container>,
8727 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
8728) {
8729 let skills = resources.skills.as_deref().unwrap_or(&[]);
8730 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
8731 let mut lines: Vec<String> = Vec::new();
8732 lines.push("📂 Discovered resources for this session:".into());
8733
8734 if skills.is_empty() {
8735 lines.push(
8736 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
8737 );
8738 } else {
8739 lines.push(format!(" Skills ({}):", skills.len()));
8740 for s in skills {
8741 let marker = if s.disable_model_invocation == Some(true) {
8742 " [hidden]"
8743 } else {
8744 ""
8745 };
8746 let desc: String = s.description.chars().take(72).collect();
8747 lines.push(format!(" • {}{marker} — {desc}", s.name));
8748 }
8749 }
8750
8751 if templates.is_empty() {
8752 lines.push(
8753 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8754 );
8755 } else {
8756 lines.push(format!(" Prompt templates ({}):", templates.len()));
8757 for t in templates {
8758 let desc = t
8759 .description
8760 .as_deref()
8761 .unwrap_or("(no description)")
8762 .chars()
8763 .take(72)
8764 .collect::<String>();
8765 lines.push(format!(" • /{} — {desc}", t.name));
8766 }
8767 }
8768 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8769 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8770 lines.push(
8771 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8772 .into(),
8773 );
8774 let body = lines.join("\n");
8775 container_note_block(chat, &body);
8776}
8777
8778fn container_note_block(container: &Arc<Container>, body: &str) {
8780 for line in body.lines() {
8781 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8782 }
8783 container.add_child(Arc::new(Spacer::new(1)));
8784}
8785
8786pub fn is_tui_supported() -> bool {
8792 std::io::stdout().is_terminal()
8793}
8794
8795#[allow(unused_imports)]
8797use rpi_tui::Color as _Color;
8798
8799#[cfg(test)]
8800mod tests {
8801 use super::*;
8802 use rpi_tui::Component;
8803
8804 #[test]
8805 fn verbose_overrides_quiet_startup_listing() {
8806 assert!(should_show_startup_listing(false, false));
8807 assert!(should_show_startup_listing(true, false));
8808 assert!(should_show_startup_listing(true, true));
8809 assert!(!should_show_startup_listing(false, true));
8810 }
8811
8812 #[test]
8813 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8814 let tmp = tempfile::tempdir().unwrap();
8815 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8816 let local_root = tmp.path().join("local-package");
8817 std::fs::create_dir_all(git_root.join(".git")).unwrap();
8818 std::fs::create_dir_all(&local_root).unwrap();
8819 std::fs::write(
8820 git_root.join("package.json"),
8821 r#"{"name":"git-demo","version":"1.0.0"}"#,
8822 )
8823 .unwrap();
8824 std::fs::write(
8825 local_root.join("package.json"),
8826 r#"{"name":"local-demo","version":"1.0.0"}"#,
8827 )
8828 .unwrap();
8829 let resources = crate::packages::discover(
8830 tmp.path(),
8831 &[
8832 "git:github.com/example/repo".to_string(),
8833 local_root.to_string_lossy().into_owned(),
8834 ],
8835 );
8836 assert_eq!(resources.packages.len(), 2);
8837
8838 let filtered = git_only_update_resources(resources);
8839
8840 assert_eq!(filtered.packages.len(), 1);
8841 assert!(matches!(
8842 filtered.packages[0].source,
8843 crate::packages::PackageSource::Git
8844 ));
8845 }
8846
8847 #[test]
8848 fn trust_command_uses_the_session_cwd_after_process_chdir() {
8849 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8850 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8851 const TEST_NAME: &str =
8852 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8853
8854 if std::env::var_os(CHILD_ENV).is_some() {
8855 let session_cwd = std::path::PathBuf::from(
8856 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8857 );
8858 let changed_cwd = std::env::current_dir().unwrap();
8859
8860 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8861
8862 assert_eq!(
8863 crate::config::project_trust_decision(&session_cwd).unwrap(),
8864 Some(true)
8865 );
8866 assert_eq!(
8867 crate::config::project_trust_decision(&changed_cwd).unwrap(),
8868 None
8869 );
8870 return;
8871 }
8872
8873 let tmp = tempfile::tempdir().unwrap();
8874 let session_cwd = tmp.path().join("session-project");
8875 let changed_cwd = tmp.path().join("extension-cwd");
8876 let agent_dir = tmp.path().join("agent");
8877 std::fs::create_dir_all(&session_cwd).unwrap();
8878 std::fs::create_dir_all(&changed_cwd).unwrap();
8879 std::fs::create_dir_all(&agent_dir).unwrap();
8880
8881 let status = std::process::Command::new(std::env::current_exe().unwrap())
8882 .arg("--exact")
8883 .arg(TEST_NAME)
8884 .arg("--nocapture")
8885 .env(CHILD_ENV, "1")
8886 .env(SESSION_CWD_ENV, &session_cwd)
8887 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8888 .current_dir(&changed_cwd)
8889 .status()
8890 .unwrap();
8891
8892 assert!(status.success(), "child test process failed: {status}");
8893 }
8894
8895 #[test]
8896 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8897 let global = crate::settings::Settings {
8898 editor_padding_x: Some(3),
8899 autocomplete_max_visible: Some(6),
8900 hide_thinking_block: Some(false),
8901 quiet_startup: Some(true),
8902 show_terminal_progress: Some(true),
8903 ..Default::default()
8904 };
8905 let rpi_project = crate::settings::Settings {
8906 editor_padding_x: Some(0),
8907 hide_thinking_block: Some(true),
8908 quiet_startup: Some(false),
8909 show_terminal_progress: Some(false),
8910 ..Default::default()
8911 };
8912 let pi_project = crate::settings::Settings {
8913 editor_padding_x: Some(9),
8914 autocomplete_max_visible: Some(12),
8915 quiet_startup: Some(true),
8916 ..Default::default()
8917 };
8918
8919 assert_eq!(
8920 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8921 TuiStartupSettings {
8922 editor_padding_x: 0,
8923 autocomplete_max_visible: 12,
8924 hide_thinking: true,
8925 quiet_startup: false,
8926 show_terminal_progress: false,
8927 }
8928 );
8929 }
8930
8931 #[test]
8932 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8933 let global = crate::settings::Settings {
8934 quiet_startup: Some(false),
8935 ..Default::default()
8936 };
8937 let rpi_project = crate::settings::Settings::default();
8938 let pi_project = crate::settings::Settings {
8939 quiet_startup: Some(true),
8940 ..Default::default()
8941 };
8942
8943 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8944
8945 assert!(resolved.quiet_startup);
8946 }
8947
8948 #[test]
8949 fn tui_startup_settings_use_global_values_without_project_fields() {
8950 let global = crate::settings::Settings {
8951 editor_padding_x: Some(7),
8952 autocomplete_max_visible: Some(8),
8953 hide_thinking_block: Some(true),
8954 quiet_startup: Some(true),
8955 show_terminal_progress: Some(false),
8956 ..Default::default()
8957 };
8958
8959 assert_eq!(
8960 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8961 TuiStartupSettings {
8962 editor_padding_x: 7,
8963 autocomplete_max_visible: 8,
8964 hide_thinking: true,
8965 quiet_startup: true,
8966 show_terminal_progress: false,
8967 }
8968 );
8969 }
8970
8971 #[test]
8972 fn tui_startup_settings_ignore_untrusted_project_values() {
8973 let global = crate::settings::Settings {
8974 quiet_startup: Some(false),
8975 show_terminal_progress: Some(true),
8976 ..Default::default()
8977 };
8978 let project = crate::settings::Settings {
8979 quiet_startup: Some(true),
8980 show_terminal_progress: Some(false),
8981 ..Default::default()
8982 };
8983
8984 let resolved = resolve_tui_startup_settings(&global, &[project], false);
8985
8986 assert!(!resolved.quiet_startup);
8987 assert!(resolved.show_terminal_progress);
8988 }
8989
8990 #[test]
8991 fn transcript_page_uses_viewport_with_overlap() {
8992 assert_eq!(transcript_page_size(24), 20);
8993 assert_eq!(transcript_page_size(4), 1);
8994 assert_eq!(transcript_page_size(0), 1);
8995 }
8996
8997 #[test]
8998 fn key_repeat_is_dispatched_but_release_is_not() {
8999 assert!(should_dispatch_key(KeyEventKind::Press));
9000 assert!(should_dispatch_key(KeyEventKind::Repeat));
9001 assert!(!should_dispatch_key(KeyEventKind::Release));
9002 }
9003
9004 #[test]
9005 fn key_event_encoding_matches_pi_keybinding_protocol() {
9006 let key = |code, modifiers| KeyEvent::new(code, modifiers);
9007 assert_eq!(
9008 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9009 "\r"
9010 );
9011 assert_eq!(
9012 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9013 "\x1b[13;2u"
9014 );
9015 assert_eq!(
9016 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9017 "\x1b[9;2u"
9018 );
9019 assert_eq!(
9020 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9021 "\x1b[Z"
9022 );
9023 assert_eq!(
9024 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9025 "\x1b[Z"
9026 );
9027 assert_eq!(
9028 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9029 "\x03"
9030 );
9031 assert_eq!(
9032 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9033 "\x0f"
9034 );
9035 assert_eq!(
9036 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9037 "!"
9038 );
9039 assert_eq!(
9040 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9041 "1"
9042 );
9043 }
9044
9045 #[test]
9046 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9047 let bridge = JsDialogBridge::default();
9048 let (sender, receiver) = std_mpsc::channel();
9049 bridge.pending.lock().unwrap().push_back(JsDialogPending {
9050 request: JsDialogRequest {
9051 id: "dialog-1".into(),
9052 method: "input".into(),
9053 title: String::new(),
9054 message: String::new(),
9055 options: Vec::new(),
9056 placeholder: None,
9057 prefill: None,
9058 },
9059 result: sender,
9060 });
9061
9062 bridge.cancel("dialog-1");
9065 assert!(bridge.take_pending().is_none());
9066 assert_eq!(
9067 receiver.recv().unwrap(),
9068 serde_json::json!({ "cancelled": true })
9069 );
9070 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9071 }
9072
9073 #[test]
9074 fn test_layout_renders_welcome_message() {
9075 let chat = Arc::new(Container::new());
9076 add_welcome_message(&chat);
9077
9078 let scroll = Arc::new(ScrollView::new(
9079 chat.clone(),
9080 ScrollViewOptions {
9081 follow: FollowMode::End,
9082 primary: true,
9083 ..Default::default()
9084 },
9085 ));
9086
9087 let editor = Arc::new(Editor::new(
9088 EditorOptions {
9089 padding_x: 1,
9090 ..Default::default()
9091 },
9092 EditorStyle::default(),
9093 Arc::new(rpi_tui::Keybindings::new()),
9094 ));
9095 let dock = Arc::new(Container::new());
9096 dock.add_child(editor);
9097
9098 let footer = Arc::new(FooterComponent::new());
9099
9100 let root = VStack::from_children(vec![
9101 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9102 StackChild::Entry(StackEntry::new(dock)),
9103 StackChild::Entry(StackEntry::new(footer)),
9104 ]);
9105
9106 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9107
9108 let all: String = frame.lines.join("\n");
9109 assert!(
9110 all.contains("rpi"),
9111 "Welcome message not found. Rendered: {}",
9112 all
9113 );
9114 assert!(
9115 all.contains("Type your message"),
9116 "Help text not found. Rendered: {}",
9117 all
9118 );
9119 }
9120
9121 #[test]
9122 fn test_chat_container_has_welcome_content() {
9123 let chat = Arc::new(Container::new());
9124 add_welcome_message_with_capabilities(
9125 &chat,
9126 &["read".into(), "bash".into(), "web_fetch".into()],
9127 &["rust-review".into(), "release".into()],
9128 );
9129
9130 let lines = chat.render(80);
9131 let all: String = lines.join("\n");
9132 let plain = strip_ansi(&all);
9135 assert!(
9136 plain.contains("rpi"),
9137 "Welcome message not in chat container: {:?}",
9138 lines
9139 );
9140 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9141 assert!(
9142 plain.contains("read · bash · web_fetch"),
9143 "Tool names missing: {plain}"
9144 );
9145 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9146 assert!(
9147 plain.contains("rust-review · release"),
9148 "Skill names missing: {plain}"
9149 );
9150 }
9151
9152 #[test]
9153 fn update_notices_render_inside_the_transcript() {
9154 let chat = Arc::new(Container::new());
9155 let report = crate::updates::UpdateReport {
9156 notices: vec![
9157 crate::updates::UpdateNotice {
9158 name: "rpi".into(),
9159 current: "0.1.10".into(),
9160 latest: "0.1.11".into(),
9161 command: "rpi update".into(),
9162 },
9163 crate::updates::UpdateNotice {
9164 name: "rpi-search".into(),
9165 current: "0.1.0".into(),
9166 latest: "0.1.1".into(),
9167 command: "rpi pi-package update".into(),
9168 },
9169 ],
9170 warnings: vec![crate::updates::UpdateWarning {
9171 message: "The previously scheduled rpi update failed: access denied".into(),
9172 command: "rpi update".into(),
9173 }],
9174 };
9175
9176 add_update_notices(&chat, &report);
9177
9178 assert_eq!(chat.child_count(), 1);
9179 let plain = strip_ansi(&chat.render(80).join("\n"));
9180 assert!(plain.contains("Update Failed"), "{plain}");
9181 assert!(
9182 plain.contains("rpi update failed: access denied"),
9183 "{plain}"
9184 );
9185 assert!(plain.contains("Update Available"), "{plain}");
9186 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9187 assert!(plain.contains("rpi update"), "{plain}");
9188 assert!(plain.contains("Package Updates Available"), "{plain}");
9189 assert!(plain.contains("rpi pi-package update"), "{plain}");
9190 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9191 }
9192
9193 #[test]
9194 fn empty_update_report_does_not_add_transcript_content() {
9195 let chat = Arc::new(Container::new());
9196
9197 add_update_notices(&chat, &crate::updates::UpdateReport::default());
9198
9199 assert_eq!(chat.child_count(), 0);
9200 }
9201
9202 #[test]
9203 fn skill_reads_are_detected_by_path() {
9204 let name = skill_tool_name(
9205 "read",
9206 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9207 );
9208 assert_eq!(name.as_deref(), Some("release"));
9209
9210 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9211 assert!(name.is_none());
9212
9213 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9215 }
9216
9217 #[test]
9218 fn welcome_capabilities_show_empty_state() {
9219 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9220 assert_eq!(plain, "Skills (0) none");
9221 }
9222
9223 #[test]
9224 fn empty_tool_progress_is_deferred_until_start() {
9225 let empty = rpi_agent::AgentToolResult::default();
9226 assert!(!tool_update_has_payload("", &empty));
9227
9228 let text = rpi_agent::AgentToolResult::text("partial output");
9229 assert!(tool_update_has_payload("partial output", &text));
9230
9231 let details = rpi_agent::AgentToolResult {
9232 details: serde_json::json!({"path": "src/lib.rs"}),
9233 ..Default::default()
9234 };
9235 assert!(tool_update_has_payload("", &details));
9236 }
9237
9238 #[test]
9239 fn completed_stream_reconciles_the_final_tail_before_detaching() {
9240 let component = Arc::new(AssistantMessageComponent::new(
9241 AssistantMessageOptions::default(),
9242 ));
9243 component.set_streaming(true);
9244 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9245
9246 let current = Mutex::new(Some(component.clone()));
9247 let cached = Mutex::new("partial response".to_string());
9248 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9249 final_message.content = vec![Content::text(
9250 "partial response with the previously missing final tail",
9251 )];
9252 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9253
9254 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
9255
9256 assert!(current.lock().unwrap().is_none());
9257 assert_eq!(
9258 cached.lock().unwrap().as_str(),
9259 "partial response with the previously missing final tail"
9260 );
9261 let rendered = strip_ansi(&component.render(100).join("\n"));
9262 assert!(
9263 rendered.contains("previously missing final tail"),
9264 "{rendered}"
9265 );
9266 }
9267
9268 #[test]
9274 fn tab_accept_suggestion_reflects_in_next_render() {
9275 use rpi_tui::render_layout_frame;
9276
9277 let editor = Arc::new(Editor::new(
9278 EditorOptions {
9279 padding_x: 1,
9280 ..Default::default()
9281 },
9282 EditorStyle::default(),
9283 Arc::new(rpi_tui::Keybindings::new()),
9284 ));
9285 editor.set_focused(true);
9286 let editor_container = Arc::new(Container::new());
9287 editor_container.add_child(editor.clone());
9288 let autocomplete_container = Arc::new(Container::new());
9289 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9290 let dock = Arc::new(VStack::from_children(vec![
9291 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9292 StackChild::Entry(
9293 StackEntry::new(editor_container.clone())
9294 .shrink(0)
9295 .min_size(3),
9296 ),
9297 StackChild::Entry(StackEntry::new(footer)),
9298 ]));
9299
9300 let mut manager = AutocompleteManager::new();
9302 let mut combined = CombinedAutocompleteProvider::new();
9303 combined.add_provider(Arc::new(
9304 SlashCommandAutocompleteProvider::with_default_commands(),
9305 ));
9306 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9307 manager.set_provider(Arc::new(combined));
9308 editor.insert("/mo");
9311 assert_eq!(editor.cursor_position(), (0, 3));
9312
9313 let frame_before = render_layout_frame(dock.clone(), 80, 10);
9314 assert!(
9315 frame_before.lines.iter().any(|l| l.contains("/mo")),
9316 "precondition: editor shows the typed prefix. Frame rows:\n{}",
9317 frame_before
9318 .lines
9319 .iter()
9320 .map(|l| format!(" [{l}]"))
9321 .collect::<Vec<_>>()
9322 .join("\n")
9323 );
9324
9325 let text = editor.get_text();
9327 let cursor = editor_cursor_offset(&editor, &text);
9328 let sugg = manager
9329 .get_suggestions(&text, cursor)
9330 .expect("slash suggestions for /mo");
9331 let top = sugg.items.first().expect("at least one suggestion");
9332 let start = sugg.start.min(text.len());
9333 let end = sugg.end.min(text.len());
9334 let mut replaced = String::new();
9335 replaced.push_str(&text[..start]);
9336 replaced.push_str(&top.text);
9337 replaced.push_str(&text[end..]);
9338 if top.insert_space && !replaced.ends_with('/') {
9339 replaced.push(' ');
9340 }
9341 let new_cursor = start + top.text.len();
9342 editor.set_text(&replaced);
9343 set_editor_cursor_offset(&editor, &replaced, new_cursor);
9344 autocomplete_container.clear();
9345 assert_eq!(editor.get_text(), "/model");
9346
9347 let frame_after = render_layout_frame(dock, 80, 10);
9349 let all: String = frame_after.lines.join("\n");
9350 assert!(
9351 all.contains("/model"),
9352 "completed text missing from next render. Got:\n{all}"
9353 );
9354 let editor_line = frame_after
9358 .lines
9359 .iter()
9360 .find(|l| l.contains("/model"))
9361 .expect("editor row with completed text");
9362 assert!(
9363 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9364 "caret must follow the full completed text. Got: {editor_line:?}"
9365 );
9366 }
9367
9368 #[test]
9369 fn multiline_autocomplete_preserves_row_and_column() {
9370 let editor = Arc::new(Editor::simple());
9371 editor.set_text("first\n/mo");
9372 editor.set_cursor(1, 3);
9373
9374 let text = editor.get_text();
9375 assert_eq!(editor_cursor_offset(&editor, &text), 9);
9376
9377 let replaced = "first\n/model";
9378 editor.set_text(replaced);
9379 set_editor_cursor_offset(&editor, replaced, 12);
9380 assert_eq!(editor.cursor_position(), (1, 6));
9381 }
9382
9383 #[test]
9384 fn test_slash_command_dispatch() {
9385 let registry = build_builtin_registry();
9390
9391 let resolves_to = |token: &str, canonical: &str| {
9393 let found = registry.find(token).expect("{token} should resolve");
9394 assert_eq!(
9395 found.name(),
9396 canonical,
9397 "{token} resolved to {} (expected {canonical})",
9398 found.name()
9399 );
9400 };
9401
9402 resolves_to("/help", "/help");
9403 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
9405 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
9409 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
9411 resolves_to("/hotkeys", "/hotkeys");
9412 resolves_to("/model", "/model");
9413 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
9415 resolves_to("/session", "/session");
9416 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
9418 resolves_to("/copy", "/copy");
9419 resolves_to("/thinking", "/thinking");
9420 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
9422 resolves_to("/images", "/images");
9423 resolves_to("/armin", "/armin");
9424 resolves_to("/earendil", "/earendil");
9425 resolves_to("/context", "/context");
9426 resolves_to("/settings", "/settings");
9428 resolves_to("/name", "/name");
9429 resolves_to("/export", "/export");
9430
9431 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9433 }
9434
9435 #[test]
9436
9437 fn test_registry_visible_entries_cover_dispatch() {
9438 let registry = build_builtin_registry();
9443 let names: Vec<String> = registry
9444 .visible_entries()
9445 .iter()
9446 .map(|c| c.name.clone())
9447 .collect();
9448 for recognized in [
9449 "/help",
9450 "/clear",
9451 "/new",
9452 "/exit",
9453 "/quit",
9454 "/version",
9455 "/changelog",
9456 "/model",
9457 "/session",
9458 "/theme",
9459 "/compact",
9460 "/copy",
9461 "/hotkeys",
9462 "/tools",
9463 "/images",
9464 "/thinking",
9465 "/armin",
9466 "/earendil",
9467 ] {
9468 assert!(
9469 names.contains(&recognized.to_string()),
9470 "{recognized} missing from autocomplete list"
9471 );
9472 }
9473 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9475 assert!(
9476 !names.contains(&hidden.to_string()),
9477 "{hidden} should be hidden from autocomplete"
9478 );
9479 }
9480 }
9481
9482 #[test]
9483 fn test_agent_event_mapping_creates_assistant_and_tool() {
9484 use rpi_ai::types::{
9487 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9488 ToolCall, ToolCallType, Usage,
9489 };
9490
9491 let state = Arc::new(TuiState {
9492 current_assistant: std::sync::Mutex::new(None),
9493 tool_components: std::sync::Mutex::new(HashMap::new()),
9494 bash_components: std::sync::Mutex::new(HashMap::new()),
9495 themes_enabled: true,
9496 hide_thinking: std::sync::Mutex::new(false),
9497 tool_outputs_expanded: std::sync::Mutex::new(false),
9498 show_terminal_progress: true,
9499 status: std::sync::Mutex::new(RunStatus::Idle),
9500 js_preparation_cancel: std::sync::Mutex::new(None),
9501 footer: Arc::new(FooterComponent::new()),
9502 status_container: Arc::new(Container::new()),
9503 chat_container: Arc::new(Container::new()),
9504 loader: Arc::new(Loader::new()),
9505 last_assistant_text: std::sync::Mutex::new(String::new()),
9506 active_selector: std::sync::Mutex::new(None),
9507 active_extension_editor: std::sync::Mutex::new(None),
9508 active_extension_input: std::sync::Mutex::new(None),
9509 active_extension_cancel: std::sync::Mutex::new(None),
9510 autocomplete: AutocompleteManager::new(),
9511 autocomplete_container: Arc::new(Container::new()),
9512 autocomplete_max_visible: 5,
9513 pending_images: std::sync::Mutex::new(Vec::new()),
9514 theme_manager: Arc::new(ThemeManager::new()),
9515 tui: None,
9516 current_model_id: std::sync::Mutex::new(String::new()),
9517 show_images: std::sync::Mutex::new(true),
9518 history: std::sync::Mutex::new(Vec::new()),
9519 history_index: std::sync::Mutex::new(-1),
9520 history_draft: std::sync::Mutex::new(None),
9521 last_input_tokens: std::sync::Mutex::new(0),
9522 scoped_edit: std::sync::Mutex::new(None),
9523 markdown_transformer: std::sync::Mutex::new(None),
9524 extension_session: Arc::new(std::sync::Mutex::new(
9525 rpi_extensions::ExtensionSession::none(),
9526 )),
9527 });
9528
9529 let assistant = AssistantMessage {
9533 role: rpi_ai::types::AssistantRole,
9534 content: vec![
9535 Content::Thinking(ThinkingContent {
9536 kind: ThinkingContentType,
9537 thinking: "Reasoning about the reply.".into(),
9538 thinking_signature: None,
9539 redacted: false,
9540 }),
9541 Content::Text(TextContent {
9542 kind: TextContentType,
9543 text: "Hello.".into(),
9544 text_signature: None,
9545 }),
9546 Content::ToolCall(ToolCall {
9547 kind: ToolCallType,
9548 id: "tc1".into(),
9549 name: "bash".into(),
9550 arguments: serde_json::json!({"command": "echo hi"}),
9551 thought_signature: None,
9552 namespace: None,
9553 }),
9554 ],
9555 api: rpi_ai::Api::AnthropicMessages,
9556 provider: "anthropic".into(),
9557 model: "claude-sonnet-5".into(),
9558 response_model: None,
9559 response_id: None,
9560 usage: Usage::zero(),
9561 stop_reason: StopReason::Stop,
9562 deferred: None,
9563 error_message: None,
9564 raw_stop_reason: None,
9565 end_turn: None,
9566 timestamp: 0,
9567 };
9568
9569 let comp = Arc::new(AssistantMessageComponent::new(
9572 AssistantMessageOptions::default(),
9573 ));
9574 comp.set_streaming(true);
9575 comp.update_blocks(&assistant_blocks(&assistant));
9576 let chat = Arc::new(Container::new());
9577 chat.add_child(comp.clone());
9578 *state.current_assistant.lock().unwrap() = Some(comp);
9579
9580 for c in &assistant.content {
9582 if let Content::ToolCall(tc) = c {
9583 let mut tools = state.tool_components.lock().unwrap();
9584 if !tools.contains_key(&tc.id) {
9585 let tc_comp = Arc::new(ToolExecutionComponent::new(
9586 &tc.name,
9587 &tc.arguments.to_string(),
9588 ));
9589 tc_comp.set_running();
9590 chat.add_child(tc_comp.clone());
9591 tools.insert(tc.id.clone(), tc_comp);
9592 }
9593 }
9594 }
9595
9596 let rendered = chat.render(80);
9600 let joined: String = rendered.join("\n");
9601 assert!(
9602 joined.contains("Hello."),
9603 "assistant text not rendered: {joined}"
9604 );
9605 assert!(
9606 joined.contains("Reasoning about the reply."),
9607 "thinking block not rendered: {joined}"
9608 );
9609 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9610 assert!(state.current_assistant.lock().unwrap().is_some());
9611
9612 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
9614 ended.set_result("hi", false);
9615 assert!(state.tool_components.lock().unwrap().is_empty());
9616
9617 assert!(state.try_start_working());
9621 assert!(
9622 !state.try_start_working(),
9623 "a second submit must be rejected"
9624 );
9625 state.set_status(RunStatus::Idle);
9626 state.set_status(RunStatus::Working);
9627 assert_eq!(state.status_container.child_count(), 1);
9628 assert!(state.footer.get_status().is_empty());
9629 state.show_retry(3, 10, 8_000);
9630 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
9631 assert!(retry_status.contains("Retrying (3/10)"));
9632 state.set_status(RunStatus::Working);
9633 {
9634 let mut bash = state.bash_components.lock().unwrap();
9635 bash.insert(
9636 "bash-1".into(),
9637 Arc::new(BashExecutionComponent::new("one")),
9638 );
9639 bash.insert(
9640 "bash-2".into(),
9641 Arc::new(BashExecutionComponent::new("two")),
9642 );
9643 }
9644 state.sync_working_loader_with_bash();
9645 assert_eq!(state.status_container.child_count(), 0);
9646 state.bash_components.lock().unwrap().remove("bash-1");
9647 state.sync_working_loader_with_bash();
9648 assert_eq!(state.status_container.child_count(), 0);
9649 state.bash_components.lock().unwrap().remove("bash-2");
9650 state.sync_working_loader_with_bash();
9651 assert_eq!(state.status_container.child_count(), 1);
9652
9653 state.set_status(RunStatus::Aborting);
9654 assert_eq!(state.status_container.child_count(), 0);
9655 assert!(!state.loader.is_running());
9656 }
9657
9658 #[test]
9659 fn fresh_launch_does_not_restore_old_history() {
9660 let fresh = Args::default();
9661 assert!(!launch_restores_history(&fresh));
9662
9663 let continued = Args {
9664 continue_session: true,
9665 ..Args::default()
9666 };
9667 assert!(launch_restores_history(&continued));
9668
9669 let selected = Args {
9670 session: Some("session-id".into()),
9671 ..Args::default()
9672 };
9673 assert!(launch_restores_history(&selected));
9674 }
9675
9676 #[test]
9677 fn test_short_model_name() {
9678 assert_eq!(
9679 short_model_name("anthropic:claude-sonnet-5"),
9680 "claude-sonnet-5"
9681 );
9682 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
9683 }
9684
9685 #[test]
9686 fn model_selector_items_are_deduplicated_and_provider_qualified() {
9687 use rpi_ai::{Api, Model};
9688
9689 let mut gateway = Model::new(
9690 "gpt-5.6-sol",
9691 "GPT 5.6 Sol",
9692 Api::OpenaiCompletions,
9693 "routeryo-copy",
9694 "https://gateway.example.com",
9695 );
9696 let duplicate = gateway.clone();
9697 let anthropic = Model::new(
9698 "claude-sonnet-5",
9699 "Claude Sonnet 5",
9700 Api::AnthropicMessages,
9701 "anthropic",
9702 "https://api.anthropic.com",
9703 );
9704 gateway.headers = Some(std::collections::BTreeMap::from([(
9705 "authorization".into(),
9706 "Bearer test".into(),
9707 )]));
9708
9709 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
9710 assert_eq!(items.len(), 2);
9711 assert_eq!(items[0].value, "gpt-5.6-sol");
9712 assert_eq!(items[0].label, "GPT 5.6 Sol");
9713 assert_eq!(
9714 items[0].description.as_deref(),
9715 Some("routeryo-copy/gpt-5.6-sol (current)")
9716 );
9717 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
9718 }
9719
9720 #[test]
9721 fn model_selector_match_accepts_bare_and_qualified_ids() {
9722 use rpi_ai::{Api, Model};
9723
9724 let gateway = Model::new(
9725 "gpt-5.6-sol",
9726 "GPT 5.6 Sol",
9727 Api::OpenaiCompletions,
9728 "routeryo-copy",
9729 "https://gateway.example.com",
9730 );
9731 let anthropic = Model::new(
9732 "claude-sonnet-5",
9733 "Claude Sonnet 5",
9734 Api::AnthropicMessages,
9735 "anthropic",
9736 "https://api.anthropic.com",
9737 );
9738 let catalog = [gateway, anthropic];
9739 assert_eq!(
9740 find_model_selector_match(&catalog, "gpt-5.6-sol")
9741 .unwrap()
9742 .provider,
9743 "routeryo-copy"
9744 );
9745 assert_eq!(
9746 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
9747 .unwrap()
9748 .id,
9749 "gpt-5.6-sol"
9750 );
9751 assert_eq!(
9752 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9753 .unwrap()
9754 .id,
9755 "claude-sonnet-5"
9756 );
9757 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9758 }
9759
9760 #[test]
9761 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9762 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9763
9764 let failed = AssistantMessage {
9765 role: AssistantRole,
9766 content: Vec::new(),
9767 api: rpi_ai::Api::AnthropicMessages,
9768 provider: "anthropic".into(),
9769 model: "claude-sonnet-5".into(),
9770 response_model: None,
9771 response_id: None,
9772 usage: Usage::zero(),
9773 stop_reason: StopReason::Error,
9774 deferred: None,
9775 error_message: Some("upstream returned 401".into()),
9776 raw_stop_reason: None,
9777 end_turn: None,
9778 timestamp: 0,
9779 };
9780 assert_eq!(
9781 assistant_error_text(&failed).as_deref(),
9782 Some("upstream returned 401")
9783 );
9784
9785 let mut no_detail = failed;
9786 no_detail.error_message = Some(" ".into());
9787 assert_eq!(
9788 assistant_error_text(&no_detail).as_deref(),
9789 Some("Provider request failed.")
9790 );
9791
9792 let mut aborted = no_detail;
9793 aborted.stop_reason = StopReason::Aborted;
9794 aborted.error_message = Some("abort error: Request aborted".into());
9795 assert_eq!(
9796 assistant_error_text(&aborted).as_deref(),
9797 Some("abort error: Request aborted")
9798 );
9799
9800 aborted.error_message = None;
9801 assert_eq!(
9802 assistant_error_text(&aborted).as_deref(),
9803 Some("Request aborted.")
9804 );
9805 }
9806
9807 #[test]
9808 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9809 assert_eq!(
9810 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9811 "405\nMethod Not Allowed"
9812 );
9813 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9814 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
9815 let long = "x".repeat(20_000);
9816 let cleaned = sanitize_error_message(&long);
9817 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9818 assert!(cleaned.ends_with('…'));
9819 }
9820
9821 #[test]
9822 fn test_cycle_next_model_wraps_around() {
9823 use rpi_ai::{Api, Model};
9824 let mk = |id: &str| {
9825 Model::new(
9826 id,
9827 id,
9828 Api::AnthropicMessages,
9829 "anthropic",
9830 "https://api.anthropic.com",
9831 )
9832 };
9833 let catalog = [mk("a"), mk("b"), mk("c")];
9834 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9836 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9837 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9839 let empty: Vec<Model> = vec![];
9841 assert!(cycle_next_model(&empty, "a").is_none());
9842 }
9843
9844 #[test]
9845 fn test_autocomplete_slash_suggestions_render() {
9846 let state = Arc::new(TuiState {
9849 current_assistant: std::sync::Mutex::new(None),
9850 tool_components: std::sync::Mutex::new(HashMap::new()),
9851 bash_components: std::sync::Mutex::new(HashMap::new()),
9852 themes_enabled: true,
9853 hide_thinking: std::sync::Mutex::new(false),
9854 tool_outputs_expanded: std::sync::Mutex::new(false),
9855 show_terminal_progress: true,
9856 status: std::sync::Mutex::new(RunStatus::Idle),
9857 js_preparation_cancel: std::sync::Mutex::new(None),
9858 footer: Arc::new(FooterComponent::new()),
9859 status_container: Arc::new(Container::new()),
9860 chat_container: Arc::new(Container::new()),
9861 loader: Arc::new(Loader::new()),
9862 last_assistant_text: std::sync::Mutex::new(String::new()),
9863 active_selector: std::sync::Mutex::new(None),
9864 active_extension_editor: std::sync::Mutex::new(None),
9865 active_extension_input: std::sync::Mutex::new(None),
9866 active_extension_cancel: std::sync::Mutex::new(None),
9867 autocomplete: AutocompleteManager::new(),
9868 autocomplete_container: Arc::new(Container::new()),
9869 autocomplete_max_visible: 5,
9870 pending_images: std::sync::Mutex::new(Vec::new()),
9871 theme_manager: Arc::new(ThemeManager::new()),
9872 tui: None,
9873 current_model_id: std::sync::Mutex::new(String::new()),
9874 show_images: std::sync::Mutex::new(true),
9875 history: std::sync::Mutex::new(Vec::new()),
9876 history_index: std::sync::Mutex::new(-1),
9877 history_draft: std::sync::Mutex::new(None),
9878 last_input_tokens: std::sync::Mutex::new(0),
9879 scoped_edit: std::sync::Mutex::new(None),
9880 markdown_transformer: std::sync::Mutex::new(None),
9881 extension_session: Arc::new(std::sync::Mutex::new(
9882 rpi_extensions::ExtensionSession::none(),
9883 )),
9884 });
9885 {
9886 let mut combined = CombinedAutocompleteProvider::new();
9887 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9888 build_builtin_registry().visible_entries(),
9889 )));
9890 state.autocomplete.set_provider(Arc::new(combined));
9891 }
9892
9893 let editor = Arc::new(Editor::simple());
9894 editor.set_text("/he");
9895 editor.set_cursor(0, 3);
9896 refresh_autocomplete(&state, &editor);
9897 let lines = state.autocomplete_container.render(80);
9898 let joined: String = lines.join("\n");
9899 assert!(
9900 joined.contains("/help"),
9901 "slash suggestions not rendered: {joined}"
9902 );
9903
9904 editor.set_text("hello");
9906 editor.set_cursor(0, 5);
9907 refresh_autocomplete(&state, &editor);
9908 assert!(state.autocomplete_container.render(80).is_empty());
9909 }
9910
9911 #[test]
9912 fn test_select_list_swap_restores_editor() {
9913 let state = Arc::new(TuiState {
9917 current_assistant: std::sync::Mutex::new(None),
9918 tool_components: std::sync::Mutex::new(HashMap::new()),
9919 bash_components: std::sync::Mutex::new(HashMap::new()),
9920 themes_enabled: true,
9921 hide_thinking: std::sync::Mutex::new(false),
9922 tool_outputs_expanded: std::sync::Mutex::new(false),
9923 show_terminal_progress: true,
9924 status: std::sync::Mutex::new(RunStatus::Idle),
9925 js_preparation_cancel: std::sync::Mutex::new(None),
9926 footer: Arc::new(FooterComponent::new()),
9927 status_container: Arc::new(Container::new()),
9928 chat_container: Arc::new(Container::new()),
9929 loader: Arc::new(Loader::new()),
9930 last_assistant_text: std::sync::Mutex::new(String::new()),
9931 active_selector: std::sync::Mutex::new(None),
9932 active_extension_editor: std::sync::Mutex::new(None),
9933 active_extension_input: std::sync::Mutex::new(None),
9934 active_extension_cancel: std::sync::Mutex::new(None),
9935 autocomplete: AutocompleteManager::new(),
9936 autocomplete_container: Arc::new(Container::new()),
9937 autocomplete_max_visible: 5,
9938 pending_images: std::sync::Mutex::new(Vec::new()),
9939 theme_manager: Arc::new(ThemeManager::new()),
9940 tui: None,
9941 current_model_id: std::sync::Mutex::new(String::new()),
9942 show_images: std::sync::Mutex::new(true),
9943 history: std::sync::Mutex::new(Vec::new()),
9944 history_index: std::sync::Mutex::new(-1),
9945 history_draft: std::sync::Mutex::new(None),
9946 last_input_tokens: std::sync::Mutex::new(0),
9947 scoped_edit: std::sync::Mutex::new(None),
9948 markdown_transformer: std::sync::Mutex::new(None),
9949 extension_session: Arc::new(std::sync::Mutex::new(
9950 rpi_extensions::ExtensionSession::none(),
9951 )),
9952 });
9953 let editor_container = Arc::new(Container::new());
9954 let editor = Arc::new(Editor::simple());
9955 editor_container.add_child(editor.clone());
9956 assert!(!state.selector_open());
9957
9958 let tui_terminal = Box::new(ProcessTerminal::new());
9959 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9960 let list = Arc::new(SelectList::new(
9961 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9962 5,
9963 ));
9964 open_selector(
9965 &state,
9966 &editor_container,
9967 &editor,
9968 &tui,
9969 list,
9970 SelectorKind::Theme,
9971 );
9972 assert!(state.selector_open());
9973 assert_eq!(editor_container.child_count(), 1);
9975
9976 close_selector(&state, &editor_container, &editor, &tui);
9977 assert!(!state.selector_open());
9978 assert_eq!(editor_container.child_count(), 1);
9980 }
9981
9982 #[test]
9983 fn test_message_history_browse_restores_draft() {
9984 let state = Arc::new(TuiState {
9988 current_assistant: std::sync::Mutex::new(None),
9989 tool_components: std::sync::Mutex::new(HashMap::new()),
9990 bash_components: std::sync::Mutex::new(HashMap::new()),
9991 themes_enabled: true,
9992 hide_thinking: std::sync::Mutex::new(false),
9993 tool_outputs_expanded: std::sync::Mutex::new(false),
9994 show_terminal_progress: true,
9995 status: std::sync::Mutex::new(RunStatus::Idle),
9996 js_preparation_cancel: std::sync::Mutex::new(None),
9997 footer: Arc::new(FooterComponent::new()),
9998 status_container: Arc::new(Container::new()),
9999 chat_container: Arc::new(Container::new()),
10000 loader: Arc::new(Loader::new()),
10001 last_assistant_text: std::sync::Mutex::new(String::new()),
10002 active_selector: std::sync::Mutex::new(None),
10003 active_extension_editor: std::sync::Mutex::new(None),
10004 active_extension_input: std::sync::Mutex::new(None),
10005 active_extension_cancel: std::sync::Mutex::new(None),
10006 autocomplete: AutocompleteManager::new(),
10007 autocomplete_container: Arc::new(Container::new()),
10008 autocomplete_max_visible: 5,
10009 pending_images: std::sync::Mutex::new(Vec::new()),
10010 theme_manager: Arc::new(ThemeManager::new()),
10011 tui: None,
10012 current_model_id: std::sync::Mutex::new(String::new()),
10013 show_images: std::sync::Mutex::new(true),
10014 history: std::sync::Mutex::new(Vec::new()),
10015 history_index: std::sync::Mutex::new(-1),
10016 history_draft: std::sync::Mutex::new(None),
10017 last_input_tokens: std::sync::Mutex::new(0),
10018 scoped_edit: std::sync::Mutex::new(None),
10019 markdown_transformer: std::sync::Mutex::new(None),
10020 extension_session: Arc::new(std::sync::Mutex::new(
10021 rpi_extensions::ExtensionSession::none(),
10022 )),
10023 });
10024 let editor = Arc::new(Editor::simple());
10025
10026 push_history(&state, "first message");
10027 push_history(&state, "second message");
10028 push_history(&state, "second message");
10030 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
10032 assert_eq!(state.history.lock().unwrap()[0], "second message");
10033
10034 editor.set_text("half-typed");
10036 editor.set_cursor(0, 11);
10037
10038 navigate_history(&state, &editor, -1);
10040 assert_eq!(editor.get_text(), "second message");
10041 assert_eq!(*state.history_index.lock().unwrap(), 0);
10042 navigate_history(&state, &editor, -1);
10044 assert_eq!(editor.get_text(), "first message");
10045 assert_eq!(*state.history_index.lock().unwrap(), 1);
10046 navigate_history(&state, &editor, -1);
10048 assert_eq!(editor.get_text(), "first message");
10049 navigate_history(&state, &editor, 1);
10051 assert_eq!(editor.get_text(), "second message");
10052 navigate_history(&state, &editor, 1);
10054 assert_eq!(editor.get_text(), "half-typed");
10055 assert_eq!(*state.history_index.lock().unwrap(), -1);
10056 }
10057
10058 #[test]
10059 fn test_accept_top_suggestion_replaces_prefix() {
10060 let state = Arc::new(TuiState {
10062 current_assistant: std::sync::Mutex::new(None),
10063 tool_components: std::sync::Mutex::new(HashMap::new()),
10064 bash_components: std::sync::Mutex::new(HashMap::new()),
10065 themes_enabled: true,
10066 hide_thinking: std::sync::Mutex::new(false),
10067 tool_outputs_expanded: std::sync::Mutex::new(false),
10068 show_terminal_progress: true,
10069 status: std::sync::Mutex::new(RunStatus::Idle),
10070 js_preparation_cancel: std::sync::Mutex::new(None),
10071 footer: Arc::new(FooterComponent::new()),
10072 status_container: Arc::new(Container::new()),
10073 chat_container: Arc::new(Container::new()),
10074 loader: Arc::new(Loader::new()),
10075 last_assistant_text: std::sync::Mutex::new(String::new()),
10076 active_selector: std::sync::Mutex::new(None),
10077 active_extension_editor: std::sync::Mutex::new(None),
10078 active_extension_input: std::sync::Mutex::new(None),
10079 active_extension_cancel: std::sync::Mutex::new(None),
10080 autocomplete: AutocompleteManager::new(),
10081 autocomplete_container: Arc::new(Container::new()),
10082 autocomplete_max_visible: 5,
10083 pending_images: std::sync::Mutex::new(Vec::new()),
10084 theme_manager: Arc::new(ThemeManager::new()),
10085 tui: None,
10086 current_model_id: std::sync::Mutex::new(String::new()),
10087 show_images: std::sync::Mutex::new(true),
10088 history: std::sync::Mutex::new(Vec::new()),
10089 history_index: std::sync::Mutex::new(-1),
10090 history_draft: std::sync::Mutex::new(None),
10091 last_input_tokens: std::sync::Mutex::new(0),
10092 scoped_edit: std::sync::Mutex::new(None),
10093 markdown_transformer: std::sync::Mutex::new(None),
10094 extension_session: Arc::new(std::sync::Mutex::new(
10095 rpi_extensions::ExtensionSession::none(),
10096 )),
10097 });
10098 {
10099 let mut combined = CombinedAutocompleteProvider::new();
10100 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10101 build_builtin_registry().visible_entries(),
10102 )));
10103 state.autocomplete.set_provider(Arc::new(combined));
10104 }
10105 let editor = Arc::new(Editor::simple());
10106 editor.set_text("/he");
10107 editor.set_cursor(0, 3);
10108 refresh_autocomplete(&state, &editor);
10109 let accepted = accept_top_suggestion(&state, &editor);
10110 assert!(accepted, "should accept the top suggestion");
10111 let text = editor.get_text();
10112 assert!(
10113 text.starts_with("/help"),
10114 "editor text should start with /help, got {text}"
10115 );
10116 }
10117
10118 #[test]
10119 fn configured_key_parser_supports_native_notation() {
10120 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10121 assert_eq!(combo.code, KeyCode::Char('g'));
10122 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10123 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10124 assert_eq!(combo.code, KeyCode::BackTab);
10125 }
10126
10127 #[test]
10128 fn double_escape_trigger_has_half_second_window() {
10129 let now = std::time::Instant::now();
10130 assert!(!double_escape_trigger(None, now));
10131 assert!(double_escape_trigger(
10132 Some(now - std::time::Duration::from_millis(500)),
10133 now
10134 ));
10135 assert!(!double_escape_trigger(
10136 Some(now - std::time::Duration::from_millis(501)),
10137 now
10138 ));
10139 }
10140
10141 #[test]
10144 fn ask_user_tool_names_are_recognized() {
10145 assert!(is_ask_user_tool("ask_user"));
10146 assert!(is_ask_user_tool("ask_user_question"));
10147 assert!(!is_ask_user_tool("bash"));
10148 }
10149
10150 #[test]
10151 fn ask_user_args_display_never_leaks_transport_json() {
10152 let text = ask_user_args_display(&serde_json::json!({
10153 "question": "你的代理端口是多少?",
10154 "context": "本机",
10155 "options": ["7890", {"title": "7897", "description": "clash"}],
10156 "suggest": "1080"
10157 }));
10158 assert!(text.contains("你的代理端口是多少?"));
10159 assert!(text.contains("本机"));
10160 assert!(text.contains("Choices: 7890, 7897"));
10161 assert!(text.contains("Suggestions: 1080"));
10162 assert!(!text.trim_start().starts_with('{'));
10163
10164 let multi = ask_user_args_display(&serde_json::json!({
10165 "questions": [
10166 {"id": "a", "question": "Q1"},
10167 {"id": "b", "header": "H", "question": "Q2"}
10168 ]
10169 }));
10170 assert!(multi.contains("Q1"));
10171 assert!(multi.contains("H: Q2"));
10172
10173 assert_eq!(
10174 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10175 "Confirmation requested: Deploy?"
10176 );
10177 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10178 }
10179
10180 #[test]
10181 fn ask_user_result_and_progress_prefer_readable_content() {
10182 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10183 assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10184 .contains("端口?"));
10185 let empty = rpi_agent::AgentToolResult::default();
10186 assert!(
10187 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10188 .contains("端口?")
10189 );
10190 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10191 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10192 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10193 }
10194
10195 #[test]
10196 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10197 let flat = rpi_extensions::UiDialogRequest {
10198 request_id: "r1".into(),
10199 tool_call_id: Some("c1".into()),
10200 ui: serde_json::json!({
10201 "kind": "selector",
10202 "id": "proxy_port",
10203 "header": "Proxy",
10204 "question": "端口?",
10205 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10206 "allowMultiple": true,
10207 "allowFreeform": false,
10208 "suggest": "1080"
10209 }),
10210 raw: serde_json::json!({}),
10211 };
10212 let prompt = parse_ask_user_prompt(&flat);
10213 assert_eq!(prompt.question_id, "proxy_port");
10214 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10215 assert_eq!(prompt.options.len(), 2);
10216 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10217 assert!(prompt.allow_multiple);
10218 assert!(!prompt.allow_freeform);
10219 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10220
10221 let alias = rpi_extensions::UiDialogRequest {
10222 ui: serde_json::json!({
10223 "questions": [{"id": "q", "question": "Q?", "options": []}]
10224 }),
10225 ..flat.clone()
10226 };
10227 let prompt = parse_ask_user_prompt(&alias);
10228 assert_eq!(prompt.question_id, "q");
10229 assert!(prompt.allow_freeform, "no options defaults to freeform");
10230
10231 let confirm = rpi_extensions::UiDialogRequest {
10232 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10233 ..flat.clone()
10234 };
10235 let prompt = parse_ask_user_prompt(&confirm);
10236 assert_eq!(prompt.kind, "confirm");
10237 assert_eq!(prompt.options.len(), 2);
10238 assert_eq!(prompt.options[0].0, "Yes");
10239 }
10240
10241 #[test]
10242 fn ask_user_bridge_answers_and_cancels_by_request_id() {
10243 let mailbox = rpi_extensions::UiDialogMailbox::new();
10244 mailbox.attach();
10245 let bridge = AskUserBridge::new(mailbox.clone());
10246
10247 mailbox
10249 .handle(serde_json::json!({
10250 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10251 "ui": {"kind": "input", "question": "Port?"}
10252 }))
10253 .unwrap();
10254 mailbox
10255 .handle(serde_json::json!({
10256 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10257 "ui": {"kind": "input", "question": "Level?"}
10258 }))
10259 .unwrap();
10260
10261 let first = bridge.take_pending().expect("r1 visible");
10262 assert_eq!(first.request_id, "r1");
10263 assert!(
10264 bridge.take_pending().is_none(),
10265 "only one request occupies the input slot"
10266 );
10267 bridge.respond("r1", serde_json::json!({"text": "7897"}));
10268 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10269
10270 let second = bridge.take_pending().expect("r2 visible");
10271 assert_eq!(second.request_id, "r2");
10272 bridge.cancel("r2");
10273 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10274
10275 mailbox
10277 .handle(serde_json::json!({
10278 "op": "open", "requestId": "r3",
10279 "ui": {"kind": "input", "question": "X?"}
10280 }))
10281 .unwrap();
10282 bridge.cancel_all();
10283 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10284
10285 bridge.shutdown();
10287 assert!(mailbox
10288 .handle(serde_json::json!({
10289 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10290 }))
10291 .is_err());
10292 }
10293}