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 if let Some(reason) = crate::session::dispatch_session_event_async(
6030 reload_context,
6031 rpi_plugin_sdk::EventTag::SessionStart,
6032 )
6033 .await
6034 {
6035 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
6036 }
6037
6038 let mut prompts: Vec<String> = Vec::new();
6040 if let Some(init) = initial {
6041 prompts.push(init);
6042 }
6043 for m in extra_messages {
6044 prompts.push(m.clone());
6045 }
6046 let mut images = initial_images;
6047 for prompt in prompts {
6048 if !*running.lock().unwrap() {
6049 break;
6050 }
6051 add_user_message(&chat_container, &prompt);
6052 tui.request_render(false);
6053 run_prompt_streaming(
6054 &lane,
6055 &prompt,
6056 &tui,
6057 &state,
6058 drain_handle.is_some(),
6059 reload_context.js_extension_session.as_ref(),
6060 &js_dialog_bridge,
6061 args,
6062 std::mem::take(&mut images),
6063 )
6064 .await;
6065 }
6066
6067 loop {
6069 if !*running.lock().unwrap() {
6070 break;
6071 }
6072 match rx.recv().await {
6073 Some(TuiMessage::UserInput(prompt)) => {
6074 editor.clear();
6079 let prompt_images = state.take_pending_images();
6080 if !prompt_images.is_empty() {
6081 add_note_message(
6082 &chat_container,
6083 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6084 );
6085 }
6086 run_prompt_streaming(
6087 &lane,
6088 &prompt,
6089 &tui,
6090 &state,
6091 drain_handle.is_some(),
6092 reload_context.js_extension_session.as_ref(),
6093 &js_dialog_bridge,
6094 args,
6095 prompt_images,
6096 )
6097 .await;
6098 }
6099 Some(TuiMessage::ExternalEditorResult(result)) => {
6100 match result {
6101 Ok(text) => {
6102 let cursor = text.chars().count();
6103 editor.set_text(&text);
6104 editor.set_cursor(0, cursor);
6105 add_note_message(&chat_container, "Draft updated from external editor.");
6106 }
6107 Err(error) => add_error_message(&chat_container, &error),
6108 }
6109 tui.request_render(false);
6110 }
6111 Some(TuiMessage::OpenTree) => {
6112 if *state.status.lock().unwrap() != RunStatus::Idle {
6113 add_note_message(
6114 &chat_container,
6115 "Wait for the current run to finish before opening the tree.",
6116 );
6117 tui.request_render(false);
6118 } else {
6119 open_tree_selector(
6120 &harness,
6121 &state,
6122 &editor_container,
6123 &editor,
6124 &tui,
6125 &chat_container,
6126 &tx,
6127 )
6128 .await;
6129 }
6130 }
6131 Some(TuiMessage::NavigateTree(entry_id)) => {
6132 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6133 Ok(result) => match result.outcome {
6134 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6135 chat_container.clear();
6136 add_welcome_message(&chat_container);
6137 render_session_history(
6138 &harness,
6139 &chat_container,
6140 state.markdown_transformer(),
6141 Some(state.extension_session.clone()),
6142 )
6143 .await;
6144 add_note_message(
6145 &chat_container,
6146 "Moved to the selected session entry.",
6147 );
6148 }
6149 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6150 add_error_message(&chat_container, &error.message);
6151 }
6152 _ => add_note_message(
6153 &chat_container,
6154 "The selected entry could not be opened.",
6155 ),
6156 },
6157 Err(error) => add_error_message(
6158 &chat_container,
6159 &format!("Could not navigate session tree: {error}"),
6160 ),
6161 }
6162 tui.request_render(false);
6163 }
6164 Some(TuiMessage::ClearChat) => {
6165 chat_container.clear();
6166 add_welcome_message(&chat_container);
6167 tui.request_render(false);
6168 }
6169 Some(TuiMessage::Compact) => {
6170 run_compact(&lane, &tui, &state).await;
6171 }
6172 Some(TuiMessage::Copy) => {
6173 copy_last_assistant(&state, &chat_container);
6174 tui.request_render(false);
6175 }
6176 Some(TuiMessage::Exit) => {
6177 *running.lock().unwrap() = false;
6178 break;
6179 }
6180 Some(TuiMessage::SwitchSession(id)) => {
6181 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6182 tui.request_render(false);
6183 }
6184 Some(TuiMessage::ImportSession(path)) => {
6185 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6186 tui.request_render(false);
6187 }
6188 Some(TuiMessage::ShareSession) => {
6189 share_session(&harness, &chat_container).await;
6190 tui.request_render(false);
6191 }
6192 Some(TuiMessage::SetSessionName(name)) => {
6193 let outcome = harness.session().set_name(Some(&name)).await;
6194 match outcome {
6195 Ok(_) => add_note_message(
6196 &chat_container,
6197 &format!("Session renamed to \"{name}\"."),
6198 ),
6199 Err(e) => add_error_message(
6200 &chat_container,
6201 &format!("Could not rename session: {e}"),
6202 ),
6203 }
6204 tui.request_render(false);
6205 }
6206 Some(TuiMessage::ExportSession) => {
6207 export_session(&harness, &chat_container, &cwd).await;
6208 tui.request_render(false);
6209 }
6210 Some(TuiMessage::ForkSession) => {
6211 fork_session(&harness, &cwd, &chat_container, &state).await;
6212 tui.request_render(false);
6213 }
6214 Some(TuiMessage::ReloadExtensions) => {
6215 let reload_ctx = ctx.reload_context.clone();
6222 add_note_message(&chat_container, "Reloading extensions + resources…");
6223 tui.request_render(false);
6224 let outcome =
6225 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6226 let fresh_transformer = build_markdown_transformer(
6235 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6236 );
6237 state.set_markdown_transformer_with_reinstall(fresh_transformer);
6238 if outcome.had_errors {
6239 add_error_message(&chat_container, &outcome.summary);
6243 } else if outcome.had_warnings {
6244 add_note_message(
6249 &chat_container,
6250 &format!(
6251 "{} (with warnings — see stderr for details).",
6252 outcome.summary
6253 ),
6254 );
6255 } else {
6256 add_note_message(&chat_container, &outcome.summary);
6257 }
6258 tui.request_render(false);
6259 }
6260 None => break,
6261 }
6262 }
6263
6264 js_dialog_bridge.cancel_all();
6268 ask_user_bridge.shutdown();
6269 *running.lock().unwrap() = false;
6270 for handle in update_check_handles {
6271 handle.abort();
6272 let _ = handle.await;
6273 }
6274 if let Some(js) = &reload_context.js_extension_session {
6278 js.shutdown();
6279 }
6280 let _ = key_handle.await;
6283 tick_handle.abort();
6284 if let Some(handle) = drain_handle {
6285 handle.abort();
6286 }
6287 reload_context.mailbox.clear();
6291 reload_bridge_handle.abort();
6292 tui.stop(Default::default());
6293 println!("\nGoodbye!");
6294 let _ = args;
6295
6296 0
6297}
6298
6299async fn ensure_js_runtime_before_prompt(
6309 js: Option<&crate::js_extensions::JsExtensionSession>,
6310 lane: &Arc<dyn AgentLane>,
6311 state: &Arc<TuiState>,
6312 dialog_bridge: &JsDialogBridge,
6313 args: &Args,
6314) -> bool {
6315 let Some(js) = js else {
6316 return true;
6317 };
6318 let cancellation = state.begin_js_preparation();
6319 let worker_cancellation = cancellation.clone();
6320 let js_for_start = js.clone();
6321 let mut worker = tokio::task::spawn_blocking(move || {
6322 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6323 });
6324 let result = tokio::select! {
6325 result = &mut worker => result,
6326 _ = cancellation.cancelled() => {
6327 dialog_bridge.cancel_open_requests();
6328 let js_for_cancel = js.clone();
6329 let _ = tokio::task::spawn_blocking(move || {
6330 js_for_cancel.cancel_prompt_preparation();
6331 }).await;
6332 worker.await
6333 }
6334 };
6335 let was_cancelled = cancellation.is_cancelled();
6336 if was_cancelled {
6337 dialog_bridge.cancel_open_requests();
6338 let js_for_cancel = js.clone();
6339 let _ = tokio::task::spawn_blocking(move || {
6340 js_for_cancel.cancel_prompt_preparation();
6341 })
6342 .await;
6343 state.finish_js_preparation();
6344 dialog_bridge.reopen();
6345 return false;
6346 }
6347 state.finish_js_preparation();
6348 match result {
6349 Ok(Ok(())) => {
6350 if let Some(js_active) = js.active_tools() {
6354 let js_names = js.tool_names();
6355 let mut active = lane.get_active_tools().await.unwrap_or_default();
6356 active.retain(|name| {
6357 crate::session::tool_name_allowed(name, args)
6358 && !js_names.iter().any(|js_name| js_name == name)
6359 });
6360 active.extend(js_active.into_iter().filter(|name| {
6361 js_names.iter().any(|js_name| js_name == name)
6362 && crate::session::tool_name_allowed(name, args)
6363 }));
6364 active = crate::session::filter_active_tool_names(active, args);
6365 let _ = lane.set_active_tools(active).await;
6366 }
6367 }
6368 Ok(Err(error)) => {
6369 if args.verbose {
6370 eprintln!("warning: could not start JS extension runtime: {error}");
6371 }
6372 }
6373 Err(error) => {
6374 if args.verbose {
6375 eprintln!("warning: JS extension runtime worker failed: {error}");
6376 }
6377 }
6378 }
6379 true
6380}
6381
6382fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6383 std::thread::spawn(move || {
6384 let file = match tempfile::Builder::new()
6385 .prefix("rpi-draft-")
6386 .suffix(".md")
6387 .tempfile()
6388 {
6389 Ok(file) => file,
6390 Err(error) => {
6391 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6392 "Could not create editor file: {error}"
6393 ))));
6394 return;
6395 }
6396 };
6397 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6398 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6399 "Could not write editor file: {error}"
6400 ))));
6401 return;
6402 }
6403 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6404 .ok()
6405 .filter(|value| !value.trim().is_empty())
6406 .or_else(|| std::env::var("VISUAL").ok())
6407 .or_else(|| std::env::var("EDITOR").ok())
6408 .unwrap_or_else(|| {
6409 if cfg!(windows) {
6410 "notepad".to_string()
6411 } else {
6412 "nano".to_string()
6413 }
6414 });
6415 let status = std::process::Command::new(&editor)
6416 .arg(file.path())
6417 .status();
6418 let result = match status {
6419 Ok(status) if status.success() => std::fs::read_to_string(file.path())
6420 .map_err(|error| format!("Could not read editor file: {error}")),
6421 Ok(status) => Err(format!("External editor exited with {status}")),
6422 Err(error) => Err(format!(
6423 "Could not launch external editor `{editor}`: {error}"
6424 )),
6425 };
6426 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6427 });
6428}
6429
6430fn reconcile_streamed_assistant_completion(
6433 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6434 last_assistant_text: &Mutex<String>,
6435 final_message: Option<&AssistantMessage>,
6436) {
6437 let final_blocks = final_message.map(assistant_blocks);
6438 let final_text = final_message.map(assistant_text);
6439 let component = current_assistant.lock().unwrap().take();
6440
6441 if let Some(component) = component {
6442 if let Some(blocks) = final_blocks.as_deref() {
6443 component.update_blocks(blocks);
6444 }
6445 component.set_streaming(false);
6446 }
6447
6448 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6449 *last_assistant_text.lock().unwrap() = text;
6450 }
6451}
6452
6453async fn run_prompt_streaming(
6458 lane: &Arc<dyn AgentLane>,
6459 prompt: &str,
6460 tui: &Arc<TuiAltScreen>,
6461 state: &Arc<TuiState>,
6462 streaming: bool,
6463 js: Option<&crate::js_extensions::JsExtensionSession>,
6464 dialog_bridge: &JsDialogBridge,
6465 args: &Args,
6466 images: Vec<rpi_ai::types::ImageContent>,
6467) {
6468 state.set_status(RunStatus::Working);
6477 tui.request_render(false);
6478 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6479 state.set_status(RunStatus::Idle);
6480 tui.request_render(false);
6481 return;
6482 }
6483
6484 let outcome = lane.prompt_text(prompt, images).await;
6485
6486 if streaming {
6487 let final_message = match &outcome {
6492 Ok(result) => match &result.outcome {
6493 HarnessRunOutcome::Completed { final_message, .. }
6494 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6495 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6496 HarnessRunOutcome::Suspended { .. } => None,
6497 },
6498 Err(_) => None,
6499 };
6500 reconcile_streamed_assistant_completion(
6501 &state.current_assistant,
6502 &state.last_assistant_text,
6503 final_message,
6504 );
6505 }
6506
6507 state.set_status(RunStatus::Idle);
6508
6509 match outcome {
6510 Ok(result) => match &result.outcome {
6511 HarnessRunOutcome::Failed {
6512 error,
6513 final_message,
6514 ..
6515 } => {
6516 let already_rendered = streaming && final_message.is_some();
6524 if !already_rendered {
6525 let msg = final_message
6526 .as_ref()
6527 .and_then(|m| m.error_message.clone())
6528 .unwrap_or_else(|| format!("{error:?}"));
6529 add_error_message(&state.chat_container, &msg);
6530 }
6531 }
6532 HarnessRunOutcome::Suspended { .. } => {
6533 add_error_message(
6534 &state.chat_container,
6535 "Run suspended (deferred) — resume is not supported in v1.",
6536 );
6537 }
6538 HarnessRunOutcome::Aborted { final_message, .. } => {
6539 if !streaming {
6542 add_error_message(&state.chat_container, "Request aborted.");
6543 let _ = final_message; }
6545 }
6546 HarnessRunOutcome::Completed { final_message, .. } => {
6547 if !streaming {
6548 let text = assistant_text(final_message);
6549 if !text.is_empty() {
6550 add_assistant_message_blocking(
6551 &state.chat_container,
6552 &text,
6553 state.markdown_transformer(),
6554 );
6555 *state.last_assistant_text.lock().unwrap() = text;
6556 }
6557 }
6558 }
6559 },
6560 Err(e) => {
6561 add_error_message(&state.chat_container, &e.to_string());
6562 }
6563 }
6564
6565 tui.request_render(false);
6566}
6567
6568async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6573 state.set_status(RunStatus::Working);
6574 tui.request_render(false);
6575 match lane.compact(None).await {
6576 Ok(_) => {
6577 add_note_message(&state.chat_container, "Conversation compacted.");
6578 }
6579 Err(e) => {
6580 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6581 }
6582 }
6583 state.set_status(RunStatus::Idle);
6584 tui.request_render(false);
6585}
6586
6587fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6591 let text = state.last_assistant_text.lock().unwrap().clone();
6592 if text.is_empty() {
6593 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6594 return;
6595 }
6596 if copy_to_clipboard(&text) {
6597 add_note_message(chat, "Copied last reply to the clipboard.");
6598 } else {
6599 let preview: String = text.chars().take(200).collect();
6602 add_note_message(
6603 chat,
6604 &format!(
6605 "Clipboard unavailable. Last reply: {preview}{}",
6606 if text.chars().count() > 200 {
6607 "…"
6608 } else {
6609 ""
6610 }
6611 ),
6612 );
6613 }
6614}
6615
6616#[cfg(feature = "clipboard")]
6619fn copy_to_clipboard(text: &str) -> bool {
6620 match arboard::Clipboard::new() {
6621 Ok(mut cb) => cb.set_text(text).is_ok(),
6622 Err(_) => false,
6623 }
6624}
6625
6626#[cfg(not(feature = "clipboard"))]
6627fn copy_to_clipboard(_text: &str) -> bool {
6628 false
6629}
6630
6631#[cfg(feature = "clipboard")]
6635fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6636 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6637 let image = match clipboard.get_image() {
6638 Ok(image) => image,
6639 Err(_) => return Ok(None),
6640 };
6641 let width =
6642 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6643 let height =
6644 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6645 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6646 return Err("clipboard image dimensions are outside the supported range".into());
6647 }
6648 let mut bytes = Vec::new();
6649 {
6650 let mut encoder = png::Encoder::new(&mut bytes, width, height);
6651 encoder.set_color(png::ColorType::Rgba);
6652 encoder.set_depth(png::BitDepth::Eight);
6653 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6654 writer
6655 .write_image_data(&image.bytes)
6656 .map_err(|e| e.to_string())?;
6657 }
6658 Ok(Some(rpi_ai::types::ImageContent {
6659 kind: rpi_ai::types::ImageContentType,
6660 data: base64::engine::general_purpose::STANDARD.encode(bytes),
6661 mime_type: "image/png".into(),
6662 }))
6663}
6664
6665fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6666 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6667 let mut options = ImageOptions::default();
6668 options.width = Some(48);
6669 options.alt_text = Some("Attached image".into());
6670 chat.add_child(Arc::new(Image::from_data(bytes, options)));
6671 chat.add_child(Arc::new(Spacer::new(1)));
6672 }
6673}
6674
6675#[cfg(not(feature = "clipboard"))]
6676fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6677 Ok(None)
6678}
6679
6680fn add_assistant_message_blocking(
6687 container: &Arc<Container>,
6688 text: &str,
6689 transformer: Option<MarkdownTransformer>,
6690) {
6691 if text.is_empty() {
6692 return;
6693 }
6694 let msg = Arc::new(AssistantMessageComponent::new(
6695 AssistantMessageOptions::default(),
6696 ));
6697 if let Some(t) = &transformer {
6698 msg.set_markdown_transformer(Some(t.clone()));
6699 }
6700 msg.update_text(text);
6701 container.add_child(msg);
6702 container.add_child(Arc::new(Spacer::new(1)));
6703}
6704
6705async fn drain_agent_events(
6713 mut rx: broadcast::Receiver<AgentEvent>,
6714 tui: Arc<TuiAltScreen>,
6715 state: Arc<TuiState>,
6716 chat: Arc<Container>,
6717) {
6718 loop {
6719 match rx.recv().await {
6720 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6721 Err(broadcast::error::RecvError::Lagged(_)) => {
6722 continue;
6725 }
6726 Err(broadcast::error::RecvError::Closed) => break,
6727 }
6728 }
6729}
6730
6731async fn handle_agent_event(
6734 event: AgentEvent,
6735 tui: &Arc<TuiAltScreen>,
6736 state: &Arc<TuiState>,
6737 chat: &Arc<Container>,
6738) {
6739 match event {
6740 AgentEvent::AgentStart => {
6741 state.set_status(RunStatus::Working);
6742 tui.request_render(false);
6743 }
6744
6745 AgentEvent::AgentEnd { .. } => {
6746 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6748 comp.set_streaming(false);
6749 }
6750 state.set_status(RunStatus::Idle);
6751 tui.request_render(false);
6752 }
6753
6754 AgentEvent::RetryScheduled {
6755 attempt,
6756 max_retries,
6757 delay_ms,
6758 ..
6759 } => {
6760 state.show_retry(attempt, max_retries, delay_ms);
6761 tui.request_render(false);
6762 }
6763
6764 AgentEvent::TurnStart => {
6765 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6768 comp.set_streaming(false);
6769 }
6770 }
6771
6772 AgentEvent::TurnEnd {
6773 message,
6774 tool_results,
6775 } => {
6776 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6778 if let AgentMessage::Assistant(a) = &message {
6779 comp.update_blocks(&assistant_blocks(a));
6780 }
6781 comp.set_streaming(false);
6782 }
6783 let tools = state.tool_components.lock().unwrap();
6788 for tr in &tool_results {
6789 if tools.contains_key(&tr.tool_call_id) {
6790 let _ = tr;
6793 }
6794 }
6795 drop(tools);
6796 tui.request_render(false);
6797 }
6798
6799 AgentEvent::MessageStart { message } => match message {
6800 AgentMessage::Assistant(a) => {
6801 let comp = Arc::new(AssistantMessageComponent::new(
6802 AssistantMessageOptions::default(),
6803 ));
6804 if let Some(t) = state.markdown_transformer() {
6809 comp.set_markdown_transformer(Some(t));
6810 }
6811 comp.set_hide_thinking(state.hide_thinking());
6812 comp.set_streaming(true);
6813 comp.update_blocks(&assistant_blocks(&a));
6816 chat.add_child(comp.clone());
6817 chat.add_child(Arc::new(Spacer::new(1)));
6820 *state.current_assistant.lock().unwrap() = Some(comp);
6821 tui.request_render(false);
6822 }
6823 AgentMessage::Custom(custom) => {
6824 let payload = serde_json::json!({
6825 "customType": custom.role,
6826 "content": custom.content,
6827 "details": custom.data,
6828 "expanded": false,
6829 "outputPad": 1,
6830 });
6831 if let Some(component) = extension_message_component(
6832 &state.extension_session,
6833 &custom.role,
6834 &payload,
6835 state.markdown_transformer(),
6836 ) {
6837 chat.add_child(component);
6838 chat.add_child(Arc::new(Spacer::new(1)));
6839 tui.request_render(false);
6840 } else {
6841 add_note_message(chat, &custom_message_fallback(&custom));
6842 tui.request_render(false);
6843 }
6844 }
6845 _ => {}
6848 },
6849
6850 AgentEvent::MessageUpdate {
6851 message,
6852 assistant_message_event,
6853 } => {
6854 if let AgentMessage::Assistant(a) = &message {
6855 let text = assistant_text(a);
6856 let mut saw_bash_tool_call = false;
6857 for c in &a.content {
6862 if let Content::ToolCall(tc) = c {
6863 if tc.name.trim().is_empty() {
6867 continue;
6868 }
6869 if tc.name == "bash" {
6870 let command = tc
6876 .arguments
6877 .get("command")
6878 .and_then(|v| v.as_str())
6879 .unwrap_or("");
6880 if command.trim().is_empty() {
6887 continue;
6888 }
6889 saw_bash_tool_call = true;
6890 let mut bash = state.bash_components.lock().unwrap();
6891 if let Some(existing) = bash.get(&tc.id) {
6892 existing.set_command(command);
6897 } else {
6898 let comp = Arc::new(BashExecutionComponent::new(command));
6899 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6900 chat.add_child(comp.clone());
6901 bash.insert(tc.id.clone(), comp);
6902 }
6903 } else {
6904 let mut tools = state.tool_components.lock().unwrap();
6905 let display_args = if is_ask_user_tool(&tc.name) {
6906 ask_user_args_display(&tc.arguments)
6907 } else {
6908 tc.arguments.to_string()
6909 };
6910 if let Some(existing) = tools.get(&tc.id) {
6911 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
6915 existing.set_args(&display_args);
6916 }
6917 } else {
6918 let comp = Arc::new(ToolExecutionComponent::new(
6919 &tc.name,
6920 &display_args,
6921 ));
6922 if is_ask_user_tool(&tc.name) {
6923 comp.set_display_title("ASK USER");
6924 }
6925 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6926 comp.set_running();
6927 chat.add_child(comp.clone());
6928 tools.insert(tc.id.clone(), comp);
6929 }
6930 }
6931 }
6932 }
6933 if saw_bash_tool_call {
6938 state.sync_working_loader_with_bash();
6939 }
6940 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6942 comp.update_blocks(&assistant_blocks(a));
6945 }
6946 *state.last_assistant_text.lock().unwrap() = text;
6947 tui.request_render(false);
6948 }
6949 }
6950
6951 AgentEvent::MessageEnd { message } => {
6952 if let AgentMessage::Assistant(a) = &message {
6953 let text = assistant_text(a);
6954 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6955 comp.update_blocks(&assistant_blocks(a));
6956 comp.set_streaming(false);
6957 }
6958 if !text.is_empty() {
6960 *state.last_assistant_text.lock().unwrap() = text;
6961 }
6962 let usage = &a.usage;
6968 let prev_input = *state.last_input_tokens.lock().unwrap();
6969 if prev_input > 0
6970 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6971 && usage.cache_read == 0
6972 {
6973 add_note_message(
6974 &state.chat_container,
6975 &format!(
6976 "Cache miss: {} tokens re-billed",
6977 format_tokens(usage.input)
6978 ),
6979 );
6980 }
6981 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6982 add_note_message(chat, &text);
6983 }
6984 if let Some(error) = assistant_error_text(a) {
6989 add_error_message(chat, &error);
6990 }
6991 *state.last_input_tokens.lock().unwrap() = usage.input;
6992 }
6993 tui.request_render(false);
6994 }
6995
6996 AgentEvent::ToolExecutionStart {
6997 tool_call_id,
6998 tool_name,
6999 args,
7000 } => {
7001 if tool_name.trim().is_empty() {
7004 return;
7005 }
7006 if tool_name == "bash" {
7007 let command = args
7012 .get("command")
7013 .and_then(|v| v.as_str())
7014 .unwrap_or("")
7015 .to_string();
7016 if command.trim().is_empty() {
7020 return;
7021 }
7022 let mut bash_map = state.bash_components.lock().unwrap();
7023 if let Some(existing) = bash_map.get(&tool_call_id) {
7024 existing.set_command(&command);
7029 } else {
7030 let comp = Arc::new(BashExecutionComponent::new(command));
7031 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7032 chat.add_child(comp.clone());
7033 bash_map.insert(tool_call_id.clone(), comp);
7034 }
7035 } else {
7036 let _comp = {
7037 let mut tools = state.tool_components.lock().unwrap();
7038 if let Some(existing) = tools.get(&tool_call_id) {
7039 if is_ask_user_tool(&tool_name) {
7040 existing.set_display_title("ASK USER");
7041 existing.set_args(&ask_user_args_display(&args));
7042 } else {
7043 existing.set_args(&args.to_string());
7044 }
7045 existing.clone()
7046 } else {
7047 let display_args = if is_ask_user_tool(&tool_name) {
7048 ask_user_args_display(&args)
7049 } else {
7050 args.to_string()
7051 };
7052 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7053 if is_ask_user_tool(&tool_name) {
7054 comp.set_display_title("ASK USER");
7055 }
7056 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7057 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7062 comp.set_skill_name(skill);
7063 }
7064 comp.set_running();
7065 chat.add_child(comp.clone());
7066 tools.insert(tool_call_id.clone(), comp.clone());
7067 comp
7068 }
7069 };
7070 }
7071 state.sync_working_loader_with_bash();
7072 tui.request_render(false);
7073 }
7074
7075 AgentEvent::ToolExecutionUpdate {
7076 tool_call_id,
7077 tool_name,
7078 args,
7079 partial_result,
7080 } => {
7081 if tool_name.trim().is_empty() {
7082 return;
7083 }
7084 let partial_text = if is_ask_user_tool(&tool_name) {
7085 ask_user_progress_text(&args, &partial_result)
7086 } else {
7087 tool_result_text(&partial_result)
7088 };
7089 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7090 if tool_name == "bash" {
7091 let chunk = partial_text;
7096 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7097 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7100 bash.set_command(command);
7101 }
7102 if has_partial_payload {
7103 bash.append_output(&chunk);
7104 }
7105 } else if has_partial_payload {
7106 }
7112 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7113 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7114 comp.set_skill_name(skill);
7115 }
7116 if is_ask_user_tool(&tool_name) {
7117 comp.set_display_title("ASK USER");
7118 comp.set_args(&ask_user_args_display(&args));
7119 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7120 comp.set_args(&args.to_string());
7121 }
7122 if has_partial_payload {
7125 comp.set_result(&partial_text, false);
7126 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7127 }
7128 } else if has_partial_payload {
7129 let display_args = if is_ask_user_tool(&tool_name) {
7134 ask_user_args_display(&args)
7135 } else {
7136 args.to_string()
7137 };
7138 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7139 if is_ask_user_tool(&tool_name) {
7140 comp.set_display_title("ASK USER");
7141 }
7142 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7143 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7144 comp.set_skill_name(skill);
7145 }
7146 comp.set_running();
7147 comp.set_result(&partial_text, false);
7148 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7149 chat.add_child(comp.clone());
7150 state
7151 .tool_components
7152 .lock()
7153 .unwrap()
7154 .insert(tool_call_id.clone(), comp.clone());
7155 }
7156 state.sync_working_loader_with_bash();
7157 tui.request_render(false);
7158 }
7159
7160 AgentEvent::ToolExecutionEnd {
7161 tool_call_id,
7162 tool_name,
7163 result,
7164 is_error,
7165 } => {
7166 if tool_name.trim().is_empty() {
7167 return;
7168 }
7169 if tool_name == "bash" {
7170 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7171 if let Some(bash) = bash {
7172 finalize_bash(&bash, &result, is_error);
7173 } else {
7174 let command = result
7177 .details
7178 .get("command")
7179 .and_then(|v| v.as_str())
7180 .unwrap_or("")
7181 .to_string();
7182 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7183 state.sync_working_loader_with_bash();
7184 tui.render_now(false);
7187 return;
7188 }
7189 let comp = Arc::new(BashExecutionComponent::new(command));
7190 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7191 comp.append_output(&tool_result_text(&result));
7192 finalize_bash(&comp, &result, is_error);
7193 chat.add_child(comp);
7194 }
7195 } else {
7196 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7197 if let Some(comp) = comp {
7198 let result_text = if is_ask_user_tool(&tool_name) {
7199 ask_user_result_text(&result)
7200 } else {
7201 tool_result_text(&result)
7202 };
7203 comp.set_result(&result_text, is_error);
7204 if !is_ask_user_tool(&tool_name) {
7205 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7206 }
7207 } else {
7208 let comp = Arc::new(ToolExecutionComponent::new(
7211 &tool_name,
7212 &if is_ask_user_tool(&tool_name) {
7213 ask_user_args_display(&serde_json::Value::Null)
7214 } else {
7215 "".to_string()
7216 },
7217 ));
7218 if is_ask_user_tool(&tool_name) {
7219 comp.set_display_title("ASK USER");
7220 }
7221 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7222 let result_text = if is_ask_user_tool(&tool_name) {
7223 ask_user_result_text(&result)
7224 } else {
7225 tool_result_text(&result)
7226 };
7227 comp.set_result(&result_text, is_error);
7228 if !is_ask_user_tool(&tool_name) {
7229 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7230 }
7231 chat.add_child(comp.clone());
7232 }
7233 }
7234 state.sync_working_loader_with_bash();
7235 tui.render_now(false);
7241 }
7242 }
7243}
7244
7245fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7249 let fallback = match message.stop_reason {
7250 rpi_ai::StopReason::Error => "Provider request failed.",
7251 rpi_ai::StopReason::Aborted => "Request aborted.",
7252 _ => return None,
7253 };
7254 Some(
7255 message
7256 .error_message
7257 .as_deref()
7258 .filter(|text| !text.trim().is_empty())
7259 .unwrap_or(fallback)
7260 .to_string(),
7261 )
7262}
7263
7264fn finalize_bash(
7268 comp: &Arc<BashExecutionComponent>,
7269 result: &rpi_agent::AgentToolResult,
7270 is_error: bool,
7271) {
7272 let exit_code = if is_error { Some(1) } else { Some(0) };
7275 let truncated = result
7276 .details
7277 .get("truncation")
7278 .and_then(|t| t.get("truncated"))
7279 .and_then(|v| v.as_bool())
7280 .unwrap_or(false);
7281 let full_output_path = result
7282 .details
7283 .get("full_output_path")
7284 .and_then(|v| v.as_str())
7285 .map(|s| s.to_string());
7286 let truncation = BashTruncation {
7287 truncated,
7288 full_output_path,
7289 };
7290 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7294 comp.set_command(command);
7295 }
7296 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
7298}
7299
7300fn apply_edit_diff(
7305 comp: &Arc<ToolExecutionComponent>,
7306 tool_name: &str,
7307 details: &serde_json::Value,
7308 tui: &Arc<TuiAltScreen>,
7309) {
7310 if tool_name != "edit" {
7311 return;
7312 }
7313 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7314 return;
7315 };
7316 if diff_text.is_empty() {
7317 return;
7318 }
7319 let width = tui.width();
7320 let lines = render_diff(diff_text, width);
7321 comp.set_diff(lines);
7322}
7323
7324fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7329 if tool_name != "read" {
7330 return None;
7331 }
7332 let path = args.get("path").and_then(|value| value.as_str())?;
7333 let normalized = path.replace('\\', "/");
7334 let file_name = normalized.rsplit('/').next()?;
7335 if !file_name.eq_ignore_ascii_case("SKILL.md") {
7336 return None;
7337 }
7338 normalized
7339 .trim_end_matches('/')
7340 .rsplit('/')
7341 .nth(1)
7342 .filter(|name| !name.is_empty())
7343 .map(str::to_string)
7344}
7345
7346fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7349 use rpi_agent::TextContentOrImage;
7350 let mut parts: Vec<String> = Vec::new();
7351 for c in &result.content {
7352 if let TextContentOrImage::Text(t) = c {
7353 parts.push(t.text.clone());
7354 }
7355 }
7356 let joined = parts.join("\n");
7357 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7359 if one_line.chars().count() > 200 {
7360 let truncated: String = one_line.chars().take(200).collect();
7361 format!("{truncated}…")
7362 } else {
7363 one_line
7364 }
7365}
7366
7367fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7372 use rpi_agent::TextContentOrImage;
7373 let mut parts: Vec<String> = Vec::new();
7374 for c in &result.content {
7375 if let TextContentOrImage::Text(t) = c {
7376 parts.push(t.text.clone());
7377 }
7378 }
7379 parts.join("\n")
7380}
7381
7382fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7386 !text.trim().is_empty() || !result.details.is_null()
7387}
7388
7389fn is_ask_user_tool(tool_name: &str) -> bool {
7392 matches!(tool_name, "ask_user" | "ask_user_question")
7393}
7394
7395fn ask_user_args_display(args: &serde_json::Value) -> String {
7398 if args.is_null() {
7399 return String::new();
7400 }
7401 if let Some(summary) = args
7402 .get("summary")
7403 .and_then(serde_json::Value::as_str)
7404 .map(str::trim)
7405 .filter(|text| !text.is_empty())
7406 {
7407 return format!("Confirmation requested: {summary}");
7408 }
7409
7410 let mut lines: Vec<String> = Vec::new();
7411 let questions: Vec<&serde_json::Value> = args
7412 .get("questions")
7413 .and_then(serde_json::Value::as_array)
7414 .filter(|items| !items.is_empty())
7415 .map(|items| items.iter().collect())
7416 .unwrap_or_else(|| vec![args]);
7417
7418 for question in questions {
7419 let prompt = question
7420 .get("question")
7421 .and_then(serde_json::Value::as_str)
7422 .or_else(|| {
7423 args.get("question")
7424 .and_then(serde_json::Value::as_str)
7425 })
7426 .map(str::trim)
7427 .unwrap_or_default();
7428 if prompt.is_empty() {
7429 continue;
7430 }
7431 if let Some(header) = question
7432 .get("header")
7433 .and_then(serde_json::Value::as_str)
7434 .map(str::trim)
7435 .filter(|text| !text.is_empty())
7436 {
7437 lines.push(format!("{header}: {prompt}"));
7438 } else {
7439 lines.push(prompt.to_string());
7440 }
7441 let context = question
7442 .get("context")
7443 .and_then(serde_json::Value::as_str)
7444 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7445 .map(str::trim)
7446 .filter(|text| !text.is_empty());
7447 if let Some(context) = context {
7448 lines.push(format!(" {context}"));
7449 }
7450 let options = question
7451 .get("options")
7452 .and_then(serde_json::Value::as_array)
7453 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7454 if let Some(options) = options {
7455 let labels: Vec<String> = options
7456 .iter()
7457 .filter_map(|option| {
7458 option
7459 .get("title")
7460 .and_then(serde_json::Value::as_str)
7461 .or_else(|| option.as_str())
7462 .map(str::trim)
7463 .filter(|label| !label.is_empty())
7464 .map(str::to_string)
7465 })
7466 .collect();
7467 if !labels.is_empty() {
7468 lines.push(format!("Choices: {}", labels.join(", ")));
7469 }
7470 }
7471 if let Some(suggest) = question
7472 .get("suggest")
7473 .and_then(serde_json::Value::as_str)
7474 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7475 .map(str::trim)
7476 .filter(|text| !text.is_empty())
7477 {
7478 lines.push(format!("Suggestions: {suggest}"));
7479 }
7480 }
7481
7482 lines.join("\n")
7483}
7484
7485fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7488 let text = tool_result_text(result);
7489 if !text.trim().is_empty() {
7490 return text;
7491 }
7492 let args_text = ask_user_args_display(args);
7493 if args_text.trim().is_empty() {
7494 "Waiting for your answer…".to_string()
7495 } else {
7496 args_text
7497 }
7498}
7499
7500fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7503 let text = tool_result_text(result);
7504 if text.trim().is_empty() {
7505 "Answer recorded.".to_string()
7506 } else {
7507 text
7508 }
7509}
7510
7511fn open_selector(
7519 state: &Arc<TuiState>,
7520 editor_container: &Arc<Container>,
7521 editor: &Arc<Editor>,
7522 tui: &Arc<TuiAltScreen>,
7523 list: Arc<SelectList>,
7524 kind: SelectorKind,
7525) {
7526 open_selector_with_view(
7527 state,
7528 editor_container,
7529 editor,
7530 tui,
7531 list.clone(),
7532 list,
7533 kind,
7534 );
7535}
7536
7537fn open_selector_with_view<C: Component + 'static>(
7541 state: &Arc<TuiState>,
7542 editor_container: &Arc<Container>,
7543 editor: &Arc<Editor>,
7544 tui: &Arc<TuiAltScreen>,
7545 list: Arc<SelectList>,
7546 view: Arc<C>,
7547 kind: SelectorKind,
7548) {
7549 editor.set_focused(false);
7551 state.autocomplete_container.clear();
7554 editor_container.clear();
7556 editor_container.add_child(view.clone());
7557 *state.active_selector.lock().unwrap() = Some((list, kind));
7558 let focused: Arc<dyn Component> = view;
7559 tui.set_focus(Some(focused));
7560 tui.request_render(false);
7561}
7562
7563fn close_selector(
7566 state: &Arc<TuiState>,
7567 editor_container: &Arc<Container>,
7568 editor: &Arc<Editor>,
7569 tui: &Arc<TuiAltScreen>,
7570) {
7571 editor_container.clear();
7572 editor_container.add_child(editor.clone());
7573 state.autocomplete_container.clear();
7574 editor.set_focused(true);
7575 *state.active_selector.lock().unwrap() = None;
7576 *state.active_extension_cancel.lock().unwrap() = None;
7577 tui.set_focus(Some(editor.clone()));
7578 tui.request_render(false);
7579}
7580
7581fn open_model_selector(
7587 state: &Arc<TuiState>,
7588 editor_container: &Arc<Container>,
7589 editor: &Arc<Editor>,
7590 tui: &Arc<TuiAltScreen>,
7591 catalog: &[rpi_ai::Model],
7592 lane: &Arc<dyn AgentLane>,
7593 lane_model_id: &str,
7594 chat: &Arc<Container>,
7595) {
7596 let items = model_selector_items(catalog, lane_model_id);
7597 if items.is_empty() {
7598 add_note_message(
7599 chat,
7600 "No models in the catalog. Use --model at startup to select one.",
7601 );
7602 tui.request_render(false);
7603 return;
7604 }
7605 let list = Arc::new(SelectList::new(items, 10));
7606
7607 let catalog_arc = catalog.to_vec();
7611 let state_sel = state.clone();
7612 let ec_sel = editor_container.clone();
7613 let editor_sel = editor.clone();
7614 let tui_sel = tui.clone();
7615 let chat_sel = chat.clone();
7616 let lane_sel = lane.clone();
7617 list.on_select(Arc::new(move |item| {
7618 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7619 add_note_message(
7620 &chat_sel,
7621 &format!("Model {} not found in catalog.", item.label),
7622 );
7623 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7624 return;
7625 };
7626 state_sel.set_current_model(&model);
7627 let lane = lane_sel.clone();
7628 tokio::spawn(async move {
7629 let _ = lane.set_model(model).await;
7630 });
7631 add_note_message(
7632 &chat_sel,
7633 &format!(
7634 "Model set to {} — applies to the next message.",
7635 short_model_name(&item.value)
7636 ),
7637 );
7638 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7639 }));
7640 let state_cancel = state.clone();
7641 let ec_cancel = editor_container.clone();
7642 let editor_cancel = editor.clone();
7643 let tui_cancel = tui.clone();
7644 list.on_cancel(Arc::new(move || {
7645 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7646 }));
7647
7648 open_selector(
7649 state,
7650 editor_container,
7651 editor,
7652 tui,
7653 list,
7654 SelectorKind::Model,
7655 );
7656}
7657
7658fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
7663 if catalog.is_empty() {
7664 return None;
7665 }
7666 let idx = catalog
7667 .iter()
7668 .position(|m| m.id.eq_ignore_ascii_case(current_id));
7669 match idx {
7670 Some(i) => {
7671 let next = (i + 1) % catalog.len();
7672 Some(catalog[next].clone())
7673 }
7674 None => Some(catalog[0].clone()),
7675 }
7676}
7677
7678fn open_session_selector(
7684 state: &Arc<TuiState>,
7685 editor_container: &Arc<Container>,
7686 editor: &Arc<Editor>,
7687 tui: &Arc<TuiAltScreen>,
7688 cwd: &std::path::Path,
7689 tx: &mpsc::UnboundedSender<TuiMessage>,
7690) {
7691 let dir = crate::session::default_session_dir(cwd);
7692 let mut items: Vec<SelectItem> = Vec::new();
7693 if let Ok(entries) = std::fs::read_dir(&dir) {
7694 for entry in entries.flatten() {
7695 let path = entry.path();
7696 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7697 continue;
7698 }
7699 let stem = path
7700 .file_stem()
7701 .and_then(|s| s.to_str())
7702 .unwrap_or("(unnamed)")
7703 .to_string();
7704 let display = path
7705 .file_name()
7706 .and_then(|s| s.to_str())
7707 .unwrap_or(&stem)
7708 .to_string();
7709 items.push(SelectItem::new(&stem, &display));
7710 }
7711 }
7712 if items.is_empty() {
7713 add_note_message(
7714 &state.chat_container,
7715 "No saved sessions found. Sessions are created automatically in interactive mode.",
7716 );
7717 tui.request_render(false);
7718 return;
7719 }
7720 let list = Arc::new(SelectList::new(items, 10));
7721
7722 let state_sel = state.clone();
7723 let ec_sel = editor_container.clone();
7724 let editor_sel = editor.clone();
7725 let tui_sel = tui.clone();
7726 let tx_sel = tx.clone();
7727 list.on_select(Arc::new(move |item| {
7728 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7732 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
7733 }));
7734 let state_cancel = state.clone();
7735 let ec_cancel = editor_container.clone();
7736 let editor_cancel = editor.clone();
7737 let tui_cancel = tui.clone();
7738 list.on_cancel(Arc::new(move || {
7739 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7740 }));
7741
7742 open_selector(
7743 state,
7744 editor_container,
7745 editor,
7746 tui,
7747 list,
7748 SelectorKind::Session,
7749 );
7750}
7751
7752fn custom_entry_display_text(
7753 custom_type: &str,
7754 data: Option<&serde_json::Value>,
7755) -> Option<String> {
7756 let data = data?;
7757 let text = data
7758 .get("summary")
7759 .or_else(|| data.get("text"))
7760 .or_else(|| data.get("output"))
7761 .and_then(|value| value.as_str())
7762 .filter(|value| !value.trim().is_empty())?;
7763 let label = match custom_type {
7764 "compactionSummary" => "Compaction summary",
7765 "branchSummary" => "Branch summary",
7766 "bashExecution" => "Command output",
7767 other => other,
7768 };
7769 Some(format!("{label}: {text}"))
7770}
7771
7772async fn open_tree_selector(
7776 harness: &AgentHarness,
7777 state: &Arc<TuiState>,
7778 editor_container: &Arc<Container>,
7779 editor: &Arc<Editor>,
7780 tui: &Arc<TuiAltScreen>,
7781 chat: &Arc<Container>,
7782 tx: &mpsc::UnboundedSender<TuiMessage>,
7783) {
7784 let entries = match harness
7785 .session()
7786 .view("main")
7787 .find_entries(&EntryQuery {
7788 order: Some(EntryOrder::OldestFirst),
7789 ..Default::default()
7790 })
7791 .await
7792 {
7793 Ok(entries) => entries,
7794 Err(error) => {
7795 add_error_message(chat, &format!("Could not read session tree: {error}"));
7796 tui.request_render(false);
7797 return;
7798 }
7799 };
7800 let current = harness.session().get_leaf_id().await.ok().flatten();
7801 let items: Vec<SelectItem> = entries
7802 .iter()
7803 .map(|entry| {
7804 let marker = if current.as_deref() == Some(entry.id()) {
7805 " (current)"
7806 } else {
7807 ""
7808 };
7809 SelectItem::new(
7810 entry.id(),
7811 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7812 )
7813 .with_description(&entry.id()[..entry.id().len().min(12)])
7814 })
7815 .collect();
7816 if items.is_empty() {
7817 add_note_message(chat, "The current session has no entries to navigate.");
7818 tui.request_render(false);
7819 return;
7820 }
7821 let list = Arc::new(SelectList::new(items, 12));
7822 let state_sel = state.clone();
7823 let ec_sel = editor_container.clone();
7824 let editor_sel = editor.clone();
7825 let tui_sel = tui.clone();
7826 let tx_sel = tx.clone();
7827 list.on_select(Arc::new(move |item| {
7828 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7829 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7830 }));
7831 let state_cancel = state.clone();
7832 let ec_cancel = editor_container.clone();
7833 let editor_cancel = editor.clone();
7834 let tui_cancel = tui.clone();
7835 list.on_cancel(Arc::new(move || {
7836 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7837 }));
7838 open_selector(
7839 state,
7840 editor_container,
7841 editor,
7842 tui,
7843 list,
7844 SelectorKind::Tree,
7845 );
7846}
7847
7848fn open_theme_selector(
7851 state: &Arc<TuiState>,
7852 editor_container: &Arc<Container>,
7853 editor: &Arc<Editor>,
7854 tui: &Arc<TuiAltScreen>,
7855 cwd: &std::path::Path,
7856 package_resources: &Arc<crate::packages::PackageResources>,
7857) {
7858 let mut items = vec![
7859 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7860 SelectItem::new("light", "Light").with_description("Light background"),
7861 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7862 ];
7863 if state.themes_enabled {
7864 for path in package_resources.theme_files() {
7865 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7866 items.push(SelectItem::new(name, name).with_description("Package theme"));
7867 }
7868 }
7869 }
7870 let list = Arc::new(SelectList::new(items, 10));
7871
7872 let state_sel = state.clone();
7873 let ec_sel = editor_container.clone();
7874 let editor_sel = editor.clone();
7875 let tui_sel = tui.clone();
7876 let chat_sel = state.chat_container.clone();
7877 let cwd_sel = cwd.to_path_buf();
7878 let package_resources_sel = package_resources.clone();
7879 list.on_select(Arc::new(move |item| {
7880 let preset = match item.value.as_str() {
7881 "light" => Some(ThemePreset::Light),
7882 "monochrome" => Some(ThemePreset::Monochrome),
7883 "dark" => Some(ThemePreset::Dark),
7884 name => {
7885 if state_sel.themes_enabled {
7886 if let Ok(custom) = crate::packages::load_theme_with_resources(
7887 &cwd_sel,
7888 name,
7889 &package_resources_sel,
7890 ) {
7891 rpi_tui::global_theme_manager().set(custom.clone());
7892 state_sel.theme_manager.set(custom);
7893 }
7894 }
7895 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7896 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7897 tui_sel.render_now(true);
7898 return;
7899 }
7900 };
7901 let Some(preset) = preset else { return };
7902 apply_theme_preset(preset);
7903 state_sel.theme_manager.apply_preset(preset);
7904 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7907 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7908 tui_sel.render_now(true);
7909 }));
7910 let state_cancel = state.clone();
7911 let ec_cancel = editor_container.clone();
7912 let editor_cancel = editor.clone();
7913 let tui_cancel = tui.clone();
7914 list.on_cancel(Arc::new(move || {
7915 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7916 }));
7917
7918 open_selector(
7919 state,
7920 editor_container,
7921 editor,
7922 tui,
7923 list,
7924 SelectorKind::Theme,
7925 );
7926}
7927
7928fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7935 use rpi_ai::types::ThinkingLevel::*;
7936 match level {
7937 Off => "Off — No reasoning",
7938 Minimal => "Minimal — Brief reasoning (~1k tokens)",
7939 Low => "Low — Light reasoning (~1k tokens)",
7940 Medium => "Medium — Moderate reasoning (~80% of max)",
7941 High => "High — Extensive reasoning (~95% of max)",
7942 Xhigh => "Xhigh — Near-maximal reasoning",
7943 Max => "Max — Maximum reasoning",
7944 }
7945}
7946
7947fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7950 use rpi_ai::types::ThinkingLevel::*;
7951 match level {
7952 Off => "off",
7953 Minimal => "minimal",
7954 Low => "low",
7955 Medium => "medium",
7956 High => "high",
7957 Xhigh => "xhigh",
7958 Max => "max",
7959 }
7960}
7961
7962fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7965 use rpi_ai::types::ThinkingLevel::*;
7966 match name.to_ascii_lowercase().as_str() {
7967 "off" => Some(Off),
7968 "minimal" => Some(Minimal),
7969 "low" => Some(Low),
7970 "medium" => Some(Medium),
7971 "high" => Some(High),
7972 "xhigh" => Some(Xhigh),
7973 "max" => Some(Max),
7974 _ => None,
7975 }
7976}
7977
7978fn open_thinking_selector(
7988 state: &Arc<TuiState>,
7989 editor_container: &Arc<Container>,
7990 editor: &Arc<Editor>,
7991 tui: &Arc<TuiAltScreen>,
7992 lane: &Arc<dyn AgentLane>,
7993 catalog: &[rpi_ai::Model],
7994 lane_model_id: &str,
7995 chat: &Arc<Container>,
7996) {
7997 let model = catalog
8000 .iter()
8001 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8002 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8003 .map(|m| m.supported_thinking_levels())
8004 .unwrap_or_else(|| {
8005 use rpi_ai::types::ThinkingLevel::*;
8006 vec![Off, Minimal, Low, Medium, High]
8007 });
8008 let mut items: Vec<SelectItem> = Vec::new();
8009 for lvl in &levels {
8010 let name = thinking_level_name(*lvl);
8011 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8012 }
8013 if items.is_empty() {
8014 add_note_message(chat, "This model has no supported thinking levels.");
8015 tui.request_render(false);
8016 return;
8017 }
8018 let list = Arc::new(SelectList::new(items, 10));
8019
8020 let state_sel = state.clone();
8021 let ec_sel = editor_container.clone();
8022 let editor_sel = editor.clone();
8023 let tui_sel = tui.clone();
8024 let chat_sel = chat.clone();
8025 let lane_sel = lane.clone();
8026 list.on_select(Arc::new(move |item| {
8027 let Some(level) = thinking_level_from_name(&item.value) else {
8028 add_note_message(
8029 &chat_sel,
8030 &format!("Unknown thinking level: {}.", item.label),
8031 );
8032 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8033 return;
8034 };
8035 let lane = lane_sel.clone();
8036 let footer_sel = state_sel.footer.clone();
8037 tokio::spawn(async move {
8038 let _ = lane.set_thinking_level(level).await;
8039 });
8040 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8044 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8045 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8046 }));
8047 let state_cancel = state.clone();
8048 let ec_cancel = editor_container.clone();
8049 let editor_cancel = editor.clone();
8050 let tui_cancel = tui.clone();
8051 list.on_cancel(Arc::new(move || {
8052 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8053 }));
8054
8055 open_selector(
8056 state,
8057 editor_container,
8058 editor,
8059 tui,
8060 list,
8061 SelectorKind::Thinking,
8062 );
8063}
8064
8065fn open_tools_selector(
8071 state: &Arc<TuiState>,
8072 editor_container: &Arc<Container>,
8073 editor: &Arc<Editor>,
8074 tui: &Arc<TuiAltScreen>,
8075 lane: &Arc<dyn AgentLane>,
8076 chat: &Arc<Container>,
8077) {
8078 let mut active = match tokio::runtime::Handle::try_current() {
8083 Ok(h) => h
8084 .block_on(async { lane.get_active_tools().await })
8085 .unwrap_or_default(),
8086 Err(_) => Vec::new(),
8087 };
8088 if active.is_empty() {
8093 active = crate::session::BUILTIN_TOOL_NAMES
8094 .iter()
8095 .map(|name| (*name).to_string())
8096 .collect();
8097 }
8098 let mut items: Vec<SelectItem> = Vec::new();
8099 for name in crate::session::BUILTIN_TOOL_NAMES {
8100 let on = active.iter().any(|a| a == name);
8101 let label = if on {
8102 format!("{name} (on)")
8103 } else {
8104 (*name).to_string()
8105 };
8106 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8107 }
8108 let list = Arc::new(SelectList::new(items, 10));
8109
8110 let active_captured = active.clone();
8112 let state_sel = state.clone();
8113 let ec_sel = editor_container.clone();
8114 let editor_sel = editor.clone();
8115 let tui_sel = tui.clone();
8116 let chat_sel = chat.clone();
8117 let lane_sel = lane.clone();
8118 list.on_select(Arc::new(move |item| {
8119 let mut next = active_captured.clone();
8120 if let Some(pos) = next.iter().position(|a| a == &item.value) {
8121 next.remove(pos);
8122 } else {
8123 next.push(item.value.clone());
8124 }
8125 let on = next.iter().any(|a| a == &item.value);
8126 let lane = lane_sel.clone();
8127 let next_clone = next.clone();
8128 tokio::spawn(async move {
8129 let _ = lane.set_active_tools(next_clone).await;
8130 });
8131 let list_str = if next.is_empty() {
8132 "(none)".to_string()
8133 } else {
8134 next.join(", ")
8135 };
8136 add_note_message(
8137 &chat_sel,
8138 &format!(
8139 "{} {} — active tools: {}",
8140 item.value,
8141 if on { "enabled" } else { "disabled" },
8142 list_str
8143 ),
8144 );
8145 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8146 }));
8147 let state_cancel = state.clone();
8148 let ec_cancel = editor_container.clone();
8149 let editor_cancel = editor.clone();
8150 let tui_cancel = tui.clone();
8151 list.on_cancel(Arc::new(move || {
8152 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8153 }));
8154
8155 open_selector(
8156 state,
8157 editor_container,
8158 editor,
8159 tui,
8160 list,
8161 SelectorKind::Tools,
8162 );
8163}
8164
8165fn open_images_selector(
8169 state: &Arc<TuiState>,
8170 editor_container: &Arc<Container>,
8171 editor: &Arc<Editor>,
8172 tui: &Arc<TuiAltScreen>,
8173 chat: &Arc<Container>,
8174) {
8175 let current = *state.show_images.lock().unwrap();
8176 let items = vec![
8177 SelectItem::new("yes", "Yes").with_description(if current {
8178 "Inline images (current)"
8179 } else {
8180 "Inline images"
8181 }),
8182 SelectItem::new("no", "No").with_description(if current {
8183 "Placeholder only"
8184 } else {
8185 "Placeholder only (current)"
8186 }),
8187 ];
8188 let list = Arc::new(SelectList::new(items, 5));
8189
8190 let state_sel = state.clone();
8191 let ec_sel = editor_container.clone();
8192 let editor_sel = editor.clone();
8193 let tui_sel = tui.clone();
8194 let chat_sel = chat.clone();
8195 list.on_select(Arc::new(move |item| {
8196 let on = item.value == "yes";
8197 *state_sel.show_images.lock().unwrap() = on;
8198 add_note_message(
8199 &chat_sel,
8200 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8201 );
8202 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8203 }));
8204 let state_cancel = state.clone();
8205 let ec_cancel = editor_container.clone();
8206 let editor_cancel = editor.clone();
8207 let tui_cancel = tui.clone();
8208 list.on_cancel(Arc::new(move || {
8209 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8210 }));
8211
8212 open_selector(
8213 state,
8214 editor_container,
8215 editor,
8216 tui,
8217 list,
8218 SelectorKind::Images,
8219 );
8220}
8221
8222fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8230 let text = editor.get_text();
8231 let cursor = editor_cursor_offset(editor, &text);
8232 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8233 render_autocomplete(state, suggestions);
8234}
8235
8236fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8239 let (row, col) = editor.cursor_position();
8240 let mut offset = 0;
8241 for (index, line) in text.split('\n').enumerate() {
8242 if index == row {
8243 return (offset + col.min(line.len())).min(text.len());
8244 }
8245 offset = offset.saturating_add(line.len() + 1);
8246 }
8247 text.len()
8248}
8249
8250fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8253 let offset = offset.min(text.len());
8254 let mut consumed = 0;
8255 for (row, line) in text.split('\n').enumerate() {
8256 let end = consumed + line.len();
8257 if offset <= end {
8258 editor.set_cursor(row, offset - consumed);
8259 return;
8260 }
8261 consumed = end + 1;
8262 }
8263 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8264 editor.set_cursor(
8265 last_row,
8266 text.rsplit('\n').next().map(str::len).unwrap_or(0),
8267 );
8268}
8269
8270fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8272 state.autocomplete_container.clear();
8273 let Some(sugg) = suggestions else {
8274 return;
8275 };
8276 if sugg.items.is_empty() {
8277 return;
8278 }
8279 let accent = state.theme_manager.get().colors.accent;
8282 let muted = state.theme_manager.get().colors.muted;
8283 for (i, item) in sugg
8284 .items
8285 .iter()
8286 .take(state.autocomplete_max_visible)
8287 .enumerate()
8288 {
8289 let prefix = if i == 0 { "→ " } else { " " };
8290 let label = item.display_text();
8291 let line = if i == 0 {
8292 format!(
8293 "{prefix}{} {}",
8294 accent.fg(label),
8295 muted.fg(item.description.as_deref().unwrap_or(""))
8296 )
8297 } else {
8298 format!(
8299 "{prefix}{} {}",
8300 muted.fg(label),
8301 muted.fg(item.description.as_deref().unwrap_or(""))
8302 )
8303 };
8304 state
8305 .autocomplete_container
8306 .add_child(Arc::new(Text::new(line, 1, 0)));
8307 }
8308}
8309
8310fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8314 let text = editor.get_text();
8315 let cursor = editor_cursor_offset(editor, &text);
8316 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8317 return false;
8318 };
8319 let Some(top) = sugg.items.first() else {
8320 return false;
8321 };
8322 let start = sugg.start.min(text.len());
8326 let end = sugg.end.min(text.len());
8327 let mut replaced = String::with_capacity(text.len() + top.text.len());
8328 replaced.push_str(&text[..start]);
8329 replaced.push_str(&top.text);
8330 replaced.push_str(&text[end..]);
8333 if top.insert_space && !replaced.ends_with('/') {
8334 replaced.push(' ');
8335 }
8336 let new_cursor = replaced.len().min(
8339 start
8340 + top.text.len()
8341 + if top.insert_space && !top.text.ends_with('/') {
8342 1
8343 } else {
8344 0
8345 },
8346 );
8347 editor.set_text(&replaced);
8348 set_editor_cursor_offset(editor, &replaced, new_cursor);
8349 state.autocomplete_container.clear();
8350 true
8351}
8352
8353fn add_welcome_message(container: &Arc<Container>) {
8359 add_welcome_message_with_capabilities(container, &[], &[]);
8360}
8361
8362fn add_welcome_message_with_capabilities(
8366 container: &Arc<Container>,
8367 active_tools: &[String],
8368 skills: &[String],
8369) {
8370 let c = current_theme().colors;
8371 let title = format!(
8375 "{} {}",
8376 c.accent.fg(&tui_bold("rpi")),
8377 c.muted.fg("interactive TUI")
8378 );
8379 container.add_child(Arc::new(Text::new(title, 1, 0)));
8380 container.add_child(Arc::new(Spacer::new(1)));
8381 container.add_child(Arc::new(Text::new(
8382 c.dim.fg("Type your message and press Enter to send."),
8383 1,
8384 0,
8385 )));
8386 let hint = c
8387 .dim
8388 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8389 container.add_child(Arc::new(Text::new(hint, 1, 0)));
8390 container.add_child(Arc::new(Spacer::new(1)));
8391 container.add_child(Arc::new(Text::new(
8392 welcome_capability_line("Tools", active_tools),
8393 1,
8394 0,
8395 )));
8396 container.add_child(Arc::new(Text::new(
8397 welcome_capability_line("Skills", skills),
8398 1,
8399 0,
8400 )));
8401 container.add_child(Arc::new(DynamicBorder::new()));
8402}
8403
8404fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8407 let colors = current_theme().colors;
8408 let group = Arc::new(Container::new());
8409
8410 for warning in &report.warnings {
8411 let body = format!(
8412 "{}\n{} {}{}",
8413 colors.error.fg(&warning.message),
8414 colors.muted.fg("Run"),
8415 colors.accent.fg(&warning.command),
8416 colors.muted.fg(" to retry.")
8417 );
8418 add_update_panel(&group, "Update Failed", &body, colors.error);
8419 }
8420
8421 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8422 let body = format!(
8423 "{} {}{}",
8424 colors
8425 .muted
8426 .fg(&format!("New version {} is available. Run", notice.latest)),
8427 colors.accent.fg(¬ice.command),
8428 colors.muted.fg(".")
8429 );
8430 add_update_panel(&group, "Update Available", &body, colors.warning);
8431 }
8432
8433 let package_notices = report
8434 .notices
8435 .iter()
8436 .filter(|notice| notice.name != "rpi")
8437 .collect::<Vec<_>>();
8438 if !package_notices.is_empty() {
8439 let command = package_notices[0].command.as_str();
8440 let mut lines = vec![format!(
8441 "{} {}{}",
8442 colors.muted.fg("Package updates are available. Run"),
8443 colors.accent.fg(command),
8444 colors.muted.fg(".")
8445 )];
8446 lines.push(colors.muted.fg("Packages:"));
8447 lines.extend(
8448 package_notices
8449 .into_iter()
8450 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8451 );
8452 add_update_panel(
8453 &group,
8454 "Package Updates Available",
8455 &lines.join("\n"),
8456 colors.warning,
8457 );
8458 }
8459
8460 if group.child_count() > 0 {
8464 container.add_child(group);
8465 }
8466}
8467
8468fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8469 container.add_child(Arc::new(Spacer::new(1)));
8470 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8471 container.add_child(Arc::new(Text::new(
8472 format!("{}\n{body}", color.fg(&tui_bold(title))),
8473 1,
8474 0,
8475 )));
8476 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8477}
8478
8479fn welcome_capability_line(label: &str, names: &[String]) -> String {
8480 let c = current_theme().colors;
8481 let value = if names.is_empty() {
8482 "none".to_string()
8483 } else {
8484 names.join(" · ")
8485 };
8486 format!(
8487 "{} {}",
8488 c.accent.fg(&format!("{label} ({})", names.len())),
8489 c.muted.fg(&value)
8490 )
8491}
8492
8493fn add_help_message(container: &Arc<Container>) {
8495 let c = current_theme().colors;
8496 container.add_child(Arc::new(Text::new(
8500 c.md_heading.fg(&tui_bold("📚 Available Commands")),
8501 1,
8502 0,
8503 )));
8504 container.add_child(Arc::new(Spacer::new(1)));
8505
8506 let cmds: &[(&str, &str)] = &[
8507 ("/help, /?", "Show this help message"),
8508 ("/clear, /new", "Clear the conversation"),
8509 ("/exit, /quit, /q", "Exit the application"),
8510 ("/version, /v", "Show version information"),
8511 ("/changelog", "Show recent release changes"),
8512 ("/model, /m", "Choose a model (live switch)"),
8513 ("/thinking, /think", "Set reasoning depth (selector)"),
8514 ("/tools", "Toggle built-in tools on/off"),
8515 ("/images", "Toggle inline image rendering"),
8516 ("/session", "List saved sessions"),
8517 ("/theme", "Choose a theme (selector)"),
8518 ("/compact", "Compact the conversation"),
8519 ("/copy", "Copy last reply to clipboard"),
8520 ("/hotkeys", "Show keyboard shortcuts"),
8521 ("/armin", "🐾 Easter egg"),
8522 ("/earendil", "Earendil announcement"),
8523 ];
8524 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8525 for (cmd, desc) in cmds {
8526 let row = format!(
8527 " {:<cmd_w$} {} {}",
8528 c.accent.fg(cmd),
8529 c.dim.fg("—"),
8530 c.muted.fg(desc)
8531 );
8532 container.add_child(Arc::new(Text::new(row, 1, 0)));
8533 }
8534 container.add_child(Arc::new(Spacer::new(1)));
8535}
8536
8537fn add_version_message(container: &Arc<Container>) {
8539 let c = current_theme().colors;
8540 container.add_child(Arc::new(Text::new(
8541 c.md_heading.fg(&tui_bold("📦 Version Information")),
8542 1,
8543 0,
8544 )));
8545 container.add_child(Arc::new(Spacer::new(1)));
8546 container.add_child(Arc::new(Text::new(
8549 format!(
8550 " {} {}",
8551 c.muted.fg("rpi-cli"),
8552 c.text.fg(&format!("v{}", crate::VERSION))
8553 ),
8554 1,
8555 0,
8556 )));
8557 container.add_child(Arc::new(Text::new(
8558 format!(
8559 " {}",
8560 c.dim.fg("Rust implementation of pi coding agent TUI")
8561 ),
8562 1,
8563 0,
8564 )));
8565 container.add_child(Arc::new(Spacer::new(1)));
8566}
8567
8568fn add_changelog_message(container: &Arc<Container>) {
8572 let c = current_theme().colors;
8573 container.add_child(Arc::new(Text::new(
8574 c.md_heading.fg(&tui_bold("Recent Changes")),
8575 1,
8576 0,
8577 )));
8578 container.add_child(Arc::new(Spacer::new(1)));
8579 let entries = [
8580 (
8581 "Native parity phase 1",
8582 "models, images, trust, export, and JSON events",
8583 ),
8584 (
8585 "TUI controls",
8586 "external editor, thinking levels, and tool output toggles",
8587 ),
8588 (
8589 "Provider auth",
8590 "OpenAI-compatible API key aliases and gateway headers",
8591 ),
8592 ];
8593 for (release, summary) in entries {
8594 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
8595 container.add_child(Arc::new(Text::new(row, 1, 0)));
8596 }
8597 container.add_child(Arc::new(Text::new(
8598 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8599 1,
8600 0,
8601 )));
8602 container.add_child(Arc::new(Spacer::new(1)));
8603}
8604
8605fn add_hotkeys_message(container: &Arc<Container>) {
8607 let c = current_theme().colors;
8608 container.add_child(Arc::new(Text::new(
8609 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
8610 1,
8611 0,
8612 )));
8613 container.add_child(Arc::new(Spacer::new(1)));
8614 let keys: &[(&str, &str)] = &[
8615 ("Enter", "Send message"),
8616 ("Shift+Enter", "New line"),
8617 ("Tab", "Accept autocomplete suggestion"),
8618 ("Ctrl+A / Ctrl+E", "Line start / end"),
8619 (
8620 "Ctrl+K / Ctrl+U",
8621 "Kill to end / start of line (Ctrl+Y yanks)",
8622 ),
8623 ("Ctrl+- / Ctrl+R", "Undo / redo"),
8624 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8625 ("Alt+Backspace", "Kill previous word"),
8626 ("Ctrl+C", "Abort a run, or exit when idle"),
8627 ("Esc", "Abort a running prompt"),
8628 ("Ctrl+L", "Open model selector"),
8629 ("Ctrl+M", "Cycle to the next model (live)"),
8630 ("Ctrl+O", "Expand/collapse all tool output"),
8631 ("Ctrl+T", "Show/hide reasoning blocks"),
8632 ("PageUp/Down", "Scroll transcript by one page"),
8633 ("Home / End", "Jump to transcript start / latest output"),
8634 ];
8635 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8636 for (key, desc) in keys {
8637 let row = format!(
8638 " {:<key_w$} {} {}",
8639 c.accent.fg(key),
8640 c.dim.fg("—"),
8641 c.muted.fg(desc)
8642 );
8643 container.add_child(Arc::new(Text::new(row, 1, 0)));
8644 }
8645 container.add_child(Arc::new(Spacer::new(1)));
8646}
8647
8648fn add_user_message(container: &Arc<Container>, text: &str) {
8654 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
8655 container.add_child(Arc::new(Spacer::new(1)));
8656}
8657
8658fn add_error_message(container: &Arc<Container>, text: &str) {
8660 let c = current_theme().colors;
8661 let text = sanitize_error_message(text);
8662 container.add_child(Arc::new(Text::new(
8663 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
8664 1,
8665 0,
8666 )));
8667 container.add_child(Arc::new(Spacer::new(1)));
8668}
8669
8670fn sanitize_error_message(text: &str) -> String {
8675 const MAX_ERROR_CHARS: usize = 16 * 1024;
8676 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
8677 let mut count = 0;
8678 let mut escape_mode = 0u8;
8680 for ch in text.chars() {
8681 if escape_mode != 0 {
8682 match escape_mode {
8683 1 if ch == '[' => escape_mode = 2,
8684 1 if ch == ']' => escape_mode = 3,
8685 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
8686 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
8687 3 if ch == '\x07' => escape_mode = 0,
8688 _ => {}
8689 }
8690 continue;
8691 }
8692 if ch == '\x1b' {
8693 escape_mode = 1;
8694 continue;
8695 }
8696 if count >= MAX_ERROR_CHARS {
8697 result.push_str("…");
8698 break;
8699 }
8700 match ch {
8701 '\n' | '\t' => {
8702 result.push(ch);
8703 count += 1;
8704 }
8705 '\r' => {}
8706 c if c.is_control() => {}
8707 c => {
8708 result.push(c);
8709 count += 1;
8710 }
8711 }
8712 }
8713 if result.trim().is_empty() {
8714 "Provider request failed.".to_string()
8715 } else {
8716 result
8717 }
8718}
8719
8720fn add_note_message(container: &Arc<Container>, text: &str) {
8722 let c = current_theme().colors;
8723 container.add_child(Arc::new(Text::new(
8724 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
8725 1,
8726 0,
8727 )));
8728 container.add_child(Arc::new(Spacer::new(1)));
8729}
8730
8731fn show_context_panel(
8740 chat: &Arc<Container>,
8741 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
8742) {
8743 let skills = resources.skills.as_deref().unwrap_or(&[]);
8744 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
8745 let mut lines: Vec<String> = Vec::new();
8746 lines.push("📂 Discovered resources for this session:".into());
8747
8748 if skills.is_empty() {
8749 lines.push(
8750 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
8751 );
8752 } else {
8753 lines.push(format!(" Skills ({}):", skills.len()));
8754 for s in skills {
8755 let marker = if s.disable_model_invocation == Some(true) {
8756 " [hidden]"
8757 } else {
8758 ""
8759 };
8760 let desc: String = s.description.chars().take(72).collect();
8761 lines.push(format!(" • {}{marker} — {desc}", s.name));
8762 }
8763 }
8764
8765 if templates.is_empty() {
8766 lines.push(
8767 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8768 );
8769 } else {
8770 lines.push(format!(" Prompt templates ({}):", templates.len()));
8771 for t in templates {
8772 let desc = t
8773 .description
8774 .as_deref()
8775 .unwrap_or("(no description)")
8776 .chars()
8777 .take(72)
8778 .collect::<String>();
8779 lines.push(format!(" • /{} — {desc}", t.name));
8780 }
8781 }
8782 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8783 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8784 lines.push(
8785 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8786 .into(),
8787 );
8788 let body = lines.join("\n");
8789 container_note_block(chat, &body);
8790}
8791
8792fn container_note_block(container: &Arc<Container>, body: &str) {
8794 for line in body.lines() {
8795 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8796 }
8797 container.add_child(Arc::new(Spacer::new(1)));
8798}
8799
8800pub fn is_tui_supported() -> bool {
8806 std::io::stdout().is_terminal()
8807}
8808
8809#[allow(unused_imports)]
8811use rpi_tui::Color as _Color;
8812
8813#[cfg(test)]
8814mod tests {
8815 use super::*;
8816 use rpi_tui::Component;
8817
8818 #[test]
8819 fn verbose_overrides_quiet_startup_listing() {
8820 assert!(should_show_startup_listing(false, false));
8821 assert!(should_show_startup_listing(true, false));
8822 assert!(should_show_startup_listing(true, true));
8823 assert!(!should_show_startup_listing(false, true));
8824 }
8825
8826 #[test]
8827 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8828 let tmp = tempfile::tempdir().unwrap();
8829 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8830 let local_root = tmp.path().join("local-package");
8831 std::fs::create_dir_all(git_root.join(".git")).unwrap();
8832 std::fs::create_dir_all(&local_root).unwrap();
8833 std::fs::write(
8834 git_root.join("package.json"),
8835 r#"{"name":"git-demo","version":"1.0.0"}"#,
8836 )
8837 .unwrap();
8838 std::fs::write(
8839 local_root.join("package.json"),
8840 r#"{"name":"local-demo","version":"1.0.0"}"#,
8841 )
8842 .unwrap();
8843 let resources = crate::packages::discover(
8844 tmp.path(),
8845 &[
8846 "git:github.com/example/repo".to_string(),
8847 local_root.to_string_lossy().into_owned(),
8848 ],
8849 );
8850 assert_eq!(resources.packages.len(), 2);
8851
8852 let filtered = git_only_update_resources(resources);
8853
8854 assert_eq!(filtered.packages.len(), 1);
8855 assert!(matches!(
8856 filtered.packages[0].source,
8857 crate::packages::PackageSource::Git
8858 ));
8859 }
8860
8861 #[test]
8862 fn trust_command_uses_the_session_cwd_after_process_chdir() {
8863 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8864 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8865 const TEST_NAME: &str =
8866 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8867
8868 if std::env::var_os(CHILD_ENV).is_some() {
8869 let session_cwd = std::path::PathBuf::from(
8870 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8871 );
8872 let changed_cwd = std::env::current_dir().unwrap();
8873
8874 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8875
8876 assert_eq!(
8877 crate::config::project_trust_decision(&session_cwd).unwrap(),
8878 Some(true)
8879 );
8880 assert_eq!(
8881 crate::config::project_trust_decision(&changed_cwd).unwrap(),
8882 None
8883 );
8884 return;
8885 }
8886
8887 let tmp = tempfile::tempdir().unwrap();
8888 let session_cwd = tmp.path().join("session-project");
8889 let changed_cwd = tmp.path().join("extension-cwd");
8890 let agent_dir = tmp.path().join("agent");
8891 std::fs::create_dir_all(&session_cwd).unwrap();
8892 std::fs::create_dir_all(&changed_cwd).unwrap();
8893 std::fs::create_dir_all(&agent_dir).unwrap();
8894
8895 let status = std::process::Command::new(std::env::current_exe().unwrap())
8896 .arg("--exact")
8897 .arg(TEST_NAME)
8898 .arg("--nocapture")
8899 .env(CHILD_ENV, "1")
8900 .env(SESSION_CWD_ENV, &session_cwd)
8901 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8902 .current_dir(&changed_cwd)
8903 .status()
8904 .unwrap();
8905
8906 assert!(status.success(), "child test process failed: {status}");
8907 }
8908
8909 #[test]
8910 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8911 let global = crate::settings::Settings {
8912 editor_padding_x: Some(3),
8913 autocomplete_max_visible: Some(6),
8914 hide_thinking_block: Some(false),
8915 quiet_startup: Some(true),
8916 show_terminal_progress: Some(true),
8917 ..Default::default()
8918 };
8919 let rpi_project = crate::settings::Settings {
8920 editor_padding_x: Some(0),
8921 hide_thinking_block: Some(true),
8922 quiet_startup: Some(false),
8923 show_terminal_progress: Some(false),
8924 ..Default::default()
8925 };
8926 let pi_project = crate::settings::Settings {
8927 editor_padding_x: Some(9),
8928 autocomplete_max_visible: Some(12),
8929 quiet_startup: Some(true),
8930 ..Default::default()
8931 };
8932
8933 assert_eq!(
8934 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8935 TuiStartupSettings {
8936 editor_padding_x: 0,
8937 autocomplete_max_visible: 12,
8938 hide_thinking: true,
8939 quiet_startup: false,
8940 show_terminal_progress: false,
8941 }
8942 );
8943 }
8944
8945 #[test]
8946 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8947 let global = crate::settings::Settings {
8948 quiet_startup: Some(false),
8949 ..Default::default()
8950 };
8951 let rpi_project = crate::settings::Settings::default();
8952 let pi_project = crate::settings::Settings {
8953 quiet_startup: Some(true),
8954 ..Default::default()
8955 };
8956
8957 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8958
8959 assert!(resolved.quiet_startup);
8960 }
8961
8962 #[test]
8963 fn tui_startup_settings_use_global_values_without_project_fields() {
8964 let global = crate::settings::Settings {
8965 editor_padding_x: Some(7),
8966 autocomplete_max_visible: Some(8),
8967 hide_thinking_block: Some(true),
8968 quiet_startup: Some(true),
8969 show_terminal_progress: Some(false),
8970 ..Default::default()
8971 };
8972
8973 assert_eq!(
8974 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8975 TuiStartupSettings {
8976 editor_padding_x: 7,
8977 autocomplete_max_visible: 8,
8978 hide_thinking: true,
8979 quiet_startup: true,
8980 show_terminal_progress: false,
8981 }
8982 );
8983 }
8984
8985 #[test]
8986 fn tui_startup_settings_ignore_untrusted_project_values() {
8987 let global = crate::settings::Settings {
8988 quiet_startup: Some(false),
8989 show_terminal_progress: Some(true),
8990 ..Default::default()
8991 };
8992 let project = crate::settings::Settings {
8993 quiet_startup: Some(true),
8994 show_terminal_progress: Some(false),
8995 ..Default::default()
8996 };
8997
8998 let resolved = resolve_tui_startup_settings(&global, &[project], false);
8999
9000 assert!(!resolved.quiet_startup);
9001 assert!(resolved.show_terminal_progress);
9002 }
9003
9004 #[test]
9005 fn transcript_page_uses_viewport_with_overlap() {
9006 assert_eq!(transcript_page_size(24), 20);
9007 assert_eq!(transcript_page_size(4), 1);
9008 assert_eq!(transcript_page_size(0), 1);
9009 }
9010
9011 #[test]
9012 fn key_repeat_is_dispatched_but_release_is_not() {
9013 assert!(should_dispatch_key(KeyEventKind::Press));
9014 assert!(should_dispatch_key(KeyEventKind::Repeat));
9015 assert!(!should_dispatch_key(KeyEventKind::Release));
9016 }
9017
9018 #[test]
9019 fn key_event_encoding_matches_pi_keybinding_protocol() {
9020 let key = |code, modifiers| KeyEvent::new(code, modifiers);
9021 assert_eq!(
9022 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9023 "\r"
9024 );
9025 assert_eq!(
9026 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9027 "\x1b[13;2u"
9028 );
9029 assert_eq!(
9030 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9031 "\x1b[9;2u"
9032 );
9033 assert_eq!(
9034 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9035 "\x1b[Z"
9036 );
9037 assert_eq!(
9038 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9039 "\x1b[Z"
9040 );
9041 assert_eq!(
9042 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9043 "\x03"
9044 );
9045 assert_eq!(
9046 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9047 "\x0f"
9048 );
9049 assert_eq!(
9050 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9051 "!"
9052 );
9053 assert_eq!(
9054 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9055 "1"
9056 );
9057 }
9058
9059 #[test]
9060 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9061 let bridge = JsDialogBridge::default();
9062 let (sender, receiver) = std_mpsc::channel();
9063 bridge.pending.lock().unwrap().push_back(JsDialogPending {
9064 request: JsDialogRequest {
9065 id: "dialog-1".into(),
9066 method: "input".into(),
9067 title: String::new(),
9068 message: String::new(),
9069 options: Vec::new(),
9070 placeholder: None,
9071 prefill: None,
9072 },
9073 result: sender,
9074 });
9075
9076 bridge.cancel("dialog-1");
9079 assert!(bridge.take_pending().is_none());
9080 assert_eq!(
9081 receiver.recv().unwrap(),
9082 serde_json::json!({ "cancelled": true })
9083 );
9084 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9085 }
9086
9087 #[test]
9088 fn test_layout_renders_welcome_message() {
9089 let chat = Arc::new(Container::new());
9090 add_welcome_message(&chat);
9091
9092 let scroll = Arc::new(ScrollView::new(
9093 chat.clone(),
9094 ScrollViewOptions {
9095 follow: FollowMode::End,
9096 primary: true,
9097 ..Default::default()
9098 },
9099 ));
9100
9101 let editor = Arc::new(Editor::new(
9102 EditorOptions {
9103 padding_x: 1,
9104 ..Default::default()
9105 },
9106 EditorStyle::default(),
9107 Arc::new(rpi_tui::Keybindings::new()),
9108 ));
9109 let dock = Arc::new(Container::new());
9110 dock.add_child(editor);
9111
9112 let footer = Arc::new(FooterComponent::new());
9113
9114 let root = VStack::from_children(vec![
9115 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9116 StackChild::Entry(StackEntry::new(dock)),
9117 StackChild::Entry(StackEntry::new(footer)),
9118 ]);
9119
9120 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9121
9122 let all: String = frame.lines.join("\n");
9123 assert!(
9124 all.contains("rpi"),
9125 "Welcome message not found. Rendered: {}",
9126 all
9127 );
9128 assert!(
9129 all.contains("Type your message"),
9130 "Help text not found. Rendered: {}",
9131 all
9132 );
9133 }
9134
9135 #[test]
9136 fn test_chat_container_has_welcome_content() {
9137 let chat = Arc::new(Container::new());
9138 add_welcome_message_with_capabilities(
9139 &chat,
9140 &["read".into(), "bash".into(), "web_fetch".into()],
9141 &["rust-review".into(), "release".into()],
9142 );
9143
9144 let lines = chat.render(80);
9145 let all: String = lines.join("\n");
9146 let plain = strip_ansi(&all);
9149 assert!(
9150 plain.contains("rpi"),
9151 "Welcome message not in chat container: {:?}",
9152 lines
9153 );
9154 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9155 assert!(
9156 plain.contains("read · bash · web_fetch"),
9157 "Tool names missing: {plain}"
9158 );
9159 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9160 assert!(
9161 plain.contains("rust-review · release"),
9162 "Skill names missing: {plain}"
9163 );
9164 }
9165
9166 #[test]
9167 fn update_notices_render_inside_the_transcript() {
9168 let chat = Arc::new(Container::new());
9169 let report = crate::updates::UpdateReport {
9170 notices: vec![
9171 crate::updates::UpdateNotice {
9172 name: "rpi".into(),
9173 current: "0.1.10".into(),
9174 latest: "0.1.11".into(),
9175 command: "rpi update".into(),
9176 },
9177 crate::updates::UpdateNotice {
9178 name: "rpi-search".into(),
9179 current: "0.1.0".into(),
9180 latest: "0.1.1".into(),
9181 command: "rpi pi-package update".into(),
9182 },
9183 ],
9184 warnings: vec![crate::updates::UpdateWarning {
9185 message: "The previously scheduled rpi update failed: access denied".into(),
9186 command: "rpi update".into(),
9187 }],
9188 };
9189
9190 add_update_notices(&chat, &report);
9191
9192 assert_eq!(chat.child_count(), 1);
9193 let plain = strip_ansi(&chat.render(80).join("\n"));
9194 assert!(plain.contains("Update Failed"), "{plain}");
9195 assert!(
9196 plain.contains("rpi update failed: access denied"),
9197 "{plain}"
9198 );
9199 assert!(plain.contains("Update Available"), "{plain}");
9200 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9201 assert!(plain.contains("rpi update"), "{plain}");
9202 assert!(plain.contains("Package Updates Available"), "{plain}");
9203 assert!(plain.contains("rpi pi-package update"), "{plain}");
9204 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9205 }
9206
9207 #[test]
9208 fn empty_update_report_does_not_add_transcript_content() {
9209 let chat = Arc::new(Container::new());
9210
9211 add_update_notices(&chat, &crate::updates::UpdateReport::default());
9212
9213 assert_eq!(chat.child_count(), 0);
9214 }
9215
9216 #[test]
9217 fn skill_reads_are_detected_by_path() {
9218 let name = skill_tool_name(
9219 "read",
9220 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9221 );
9222 assert_eq!(name.as_deref(), Some("release"));
9223
9224 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9225 assert!(name.is_none());
9226
9227 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9229 }
9230
9231 #[test]
9232 fn welcome_capabilities_show_empty_state() {
9233 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9234 assert_eq!(plain, "Skills (0) none");
9235 }
9236
9237 #[test]
9238 fn empty_tool_progress_is_deferred_until_start() {
9239 let empty = rpi_agent::AgentToolResult::default();
9240 assert!(!tool_update_has_payload("", &empty));
9241
9242 let text = rpi_agent::AgentToolResult::text("partial output");
9243 assert!(tool_update_has_payload("partial output", &text));
9244
9245 let details = rpi_agent::AgentToolResult {
9246 details: serde_json::json!({"path": "src/lib.rs"}),
9247 ..Default::default()
9248 };
9249 assert!(tool_update_has_payload("", &details));
9250 }
9251
9252 #[test]
9253 fn completed_stream_reconciles_the_final_tail_before_detaching() {
9254 let component = Arc::new(AssistantMessageComponent::new(
9255 AssistantMessageOptions::default(),
9256 ));
9257 component.set_streaming(true);
9258 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9259
9260 let current = Mutex::new(Some(component.clone()));
9261 let cached = Mutex::new("partial response".to_string());
9262 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9263 final_message.content = vec![Content::text(
9264 "partial response with the previously missing final tail",
9265 )];
9266 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9267
9268 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
9269
9270 assert!(current.lock().unwrap().is_none());
9271 assert_eq!(
9272 cached.lock().unwrap().as_str(),
9273 "partial response with the previously missing final tail"
9274 );
9275 let rendered = strip_ansi(&component.render(100).join("\n"));
9276 assert!(
9277 rendered.contains("previously missing final tail"),
9278 "{rendered}"
9279 );
9280 }
9281
9282 #[test]
9288 fn tab_accept_suggestion_reflects_in_next_render() {
9289 use rpi_tui::render_layout_frame;
9290
9291 let editor = Arc::new(Editor::new(
9292 EditorOptions {
9293 padding_x: 1,
9294 ..Default::default()
9295 },
9296 EditorStyle::default(),
9297 Arc::new(rpi_tui::Keybindings::new()),
9298 ));
9299 editor.set_focused(true);
9300 let editor_container = Arc::new(Container::new());
9301 editor_container.add_child(editor.clone());
9302 let autocomplete_container = Arc::new(Container::new());
9303 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9304 let dock = Arc::new(VStack::from_children(vec![
9305 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9306 StackChild::Entry(
9307 StackEntry::new(editor_container.clone())
9308 .shrink(0)
9309 .min_size(3),
9310 ),
9311 StackChild::Entry(StackEntry::new(footer)),
9312 ]));
9313
9314 let mut manager = AutocompleteManager::new();
9316 let mut combined = CombinedAutocompleteProvider::new();
9317 combined.add_provider(Arc::new(
9318 SlashCommandAutocompleteProvider::with_default_commands(),
9319 ));
9320 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9321 manager.set_provider(Arc::new(combined));
9322 editor.insert("/mo");
9325 assert_eq!(editor.cursor_position(), (0, 3));
9326
9327 let frame_before = render_layout_frame(dock.clone(), 80, 10);
9328 assert!(
9329 frame_before.lines.iter().any(|l| l.contains("/mo")),
9330 "precondition: editor shows the typed prefix. Frame rows:\n{}",
9331 frame_before
9332 .lines
9333 .iter()
9334 .map(|l| format!(" [{l}]"))
9335 .collect::<Vec<_>>()
9336 .join("\n")
9337 );
9338
9339 let text = editor.get_text();
9341 let cursor = editor_cursor_offset(&editor, &text);
9342 let sugg = manager
9343 .get_suggestions(&text, cursor)
9344 .expect("slash suggestions for /mo");
9345 let top = sugg.items.first().expect("at least one suggestion");
9346 let start = sugg.start.min(text.len());
9347 let end = sugg.end.min(text.len());
9348 let mut replaced = String::new();
9349 replaced.push_str(&text[..start]);
9350 replaced.push_str(&top.text);
9351 replaced.push_str(&text[end..]);
9352 if top.insert_space && !replaced.ends_with('/') {
9353 replaced.push(' ');
9354 }
9355 let new_cursor = start + top.text.len();
9356 editor.set_text(&replaced);
9357 set_editor_cursor_offset(&editor, &replaced, new_cursor);
9358 autocomplete_container.clear();
9359 assert_eq!(editor.get_text(), "/model");
9360
9361 let frame_after = render_layout_frame(dock, 80, 10);
9363 let all: String = frame_after.lines.join("\n");
9364 assert!(
9365 all.contains("/model"),
9366 "completed text missing from next render. Got:\n{all}"
9367 );
9368 let editor_line = frame_after
9372 .lines
9373 .iter()
9374 .find(|l| l.contains("/model"))
9375 .expect("editor row with completed text");
9376 assert!(
9377 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9378 "caret must follow the full completed text. Got: {editor_line:?}"
9379 );
9380 }
9381
9382 #[test]
9383 fn multiline_autocomplete_preserves_row_and_column() {
9384 let editor = Arc::new(Editor::simple());
9385 editor.set_text("first\n/mo");
9386 editor.set_cursor(1, 3);
9387
9388 let text = editor.get_text();
9389 assert_eq!(editor_cursor_offset(&editor, &text), 9);
9390
9391 let replaced = "first\n/model";
9392 editor.set_text(replaced);
9393 set_editor_cursor_offset(&editor, replaced, 12);
9394 assert_eq!(editor.cursor_position(), (1, 6));
9395 }
9396
9397 #[test]
9398 fn test_slash_command_dispatch() {
9399 let registry = build_builtin_registry();
9404
9405 let resolves_to = |token: &str, canonical: &str| {
9407 let found = registry.find(token).expect("{token} should resolve");
9408 assert_eq!(
9409 found.name(),
9410 canonical,
9411 "{token} resolved to {} (expected {canonical})",
9412 found.name()
9413 );
9414 };
9415
9416 resolves_to("/help", "/help");
9417 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
9419 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
9423 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
9425 resolves_to("/hotkeys", "/hotkeys");
9426 resolves_to("/model", "/model");
9427 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
9429 resolves_to("/session", "/session");
9430 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
9432 resolves_to("/copy", "/copy");
9433 resolves_to("/thinking", "/thinking");
9434 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
9436 resolves_to("/images", "/images");
9437 resolves_to("/armin", "/armin");
9438 resolves_to("/earendil", "/earendil");
9439 resolves_to("/context", "/context");
9440 resolves_to("/settings", "/settings");
9442 resolves_to("/name", "/name");
9443 resolves_to("/export", "/export");
9444
9445 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9447 }
9448
9449 #[test]
9450
9451 fn test_registry_visible_entries_cover_dispatch() {
9452 let registry = build_builtin_registry();
9457 let names: Vec<String> = registry
9458 .visible_entries()
9459 .iter()
9460 .map(|c| c.name.clone())
9461 .collect();
9462 for recognized in [
9463 "/help",
9464 "/clear",
9465 "/new",
9466 "/exit",
9467 "/quit",
9468 "/version",
9469 "/changelog",
9470 "/model",
9471 "/session",
9472 "/theme",
9473 "/compact",
9474 "/copy",
9475 "/hotkeys",
9476 "/tools",
9477 "/images",
9478 "/thinking",
9479 "/armin",
9480 "/earendil",
9481 ] {
9482 assert!(
9483 names.contains(&recognized.to_string()),
9484 "{recognized} missing from autocomplete list"
9485 );
9486 }
9487 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9489 assert!(
9490 !names.contains(&hidden.to_string()),
9491 "{hidden} should be hidden from autocomplete"
9492 );
9493 }
9494 }
9495
9496 #[test]
9497 fn test_agent_event_mapping_creates_assistant_and_tool() {
9498 use rpi_ai::types::{
9501 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9502 ToolCall, ToolCallType, Usage,
9503 };
9504
9505 let state = Arc::new(TuiState {
9506 current_assistant: std::sync::Mutex::new(None),
9507 tool_components: std::sync::Mutex::new(HashMap::new()),
9508 bash_components: std::sync::Mutex::new(HashMap::new()),
9509 themes_enabled: true,
9510 hide_thinking: std::sync::Mutex::new(false),
9511 tool_outputs_expanded: std::sync::Mutex::new(false),
9512 show_terminal_progress: true,
9513 status: std::sync::Mutex::new(RunStatus::Idle),
9514 js_preparation_cancel: std::sync::Mutex::new(None),
9515 footer: Arc::new(FooterComponent::new()),
9516 status_container: Arc::new(Container::new()),
9517 chat_container: Arc::new(Container::new()),
9518 loader: Arc::new(Loader::new()),
9519 last_assistant_text: std::sync::Mutex::new(String::new()),
9520 active_selector: std::sync::Mutex::new(None),
9521 active_extension_editor: std::sync::Mutex::new(None),
9522 active_extension_input: std::sync::Mutex::new(None),
9523 active_extension_cancel: std::sync::Mutex::new(None),
9524 autocomplete: AutocompleteManager::new(),
9525 autocomplete_container: Arc::new(Container::new()),
9526 autocomplete_max_visible: 5,
9527 pending_images: std::sync::Mutex::new(Vec::new()),
9528 theme_manager: Arc::new(ThemeManager::new()),
9529 tui: None,
9530 current_model_id: std::sync::Mutex::new(String::new()),
9531 show_images: std::sync::Mutex::new(true),
9532 history: std::sync::Mutex::new(Vec::new()),
9533 history_index: std::sync::Mutex::new(-1),
9534 history_draft: std::sync::Mutex::new(None),
9535 last_input_tokens: std::sync::Mutex::new(0),
9536 scoped_edit: std::sync::Mutex::new(None),
9537 markdown_transformer: std::sync::Mutex::new(None),
9538 extension_session: Arc::new(std::sync::Mutex::new(
9539 rpi_extensions::ExtensionSession::none(),
9540 )),
9541 });
9542
9543 let assistant = AssistantMessage {
9547 role: rpi_ai::types::AssistantRole,
9548 content: vec![
9549 Content::Thinking(ThinkingContent {
9550 kind: ThinkingContentType,
9551 thinking: "Reasoning about the reply.".into(),
9552 thinking_signature: None,
9553 redacted: false,
9554 }),
9555 Content::Text(TextContent {
9556 kind: TextContentType,
9557 text: "Hello.".into(),
9558 text_signature: None,
9559 }),
9560 Content::ToolCall(ToolCall {
9561 kind: ToolCallType,
9562 id: "tc1".into(),
9563 name: "bash".into(),
9564 arguments: serde_json::json!({"command": "echo hi"}),
9565 thought_signature: None,
9566 namespace: None,
9567 }),
9568 ],
9569 api: rpi_ai::Api::AnthropicMessages,
9570 provider: "anthropic".into(),
9571 model: "claude-sonnet-5".into(),
9572 response_model: None,
9573 response_id: None,
9574 usage: Usage::zero(),
9575 stop_reason: StopReason::Stop,
9576 deferred: None,
9577 error_message: None,
9578 raw_stop_reason: None,
9579 end_turn: None,
9580 timestamp: 0,
9581 };
9582
9583 let comp = Arc::new(AssistantMessageComponent::new(
9586 AssistantMessageOptions::default(),
9587 ));
9588 comp.set_streaming(true);
9589 comp.update_blocks(&assistant_blocks(&assistant));
9590 let chat = Arc::new(Container::new());
9591 chat.add_child(comp.clone());
9592 *state.current_assistant.lock().unwrap() = Some(comp);
9593
9594 for c in &assistant.content {
9596 if let Content::ToolCall(tc) = c {
9597 let mut tools = state.tool_components.lock().unwrap();
9598 if !tools.contains_key(&tc.id) {
9599 let tc_comp = Arc::new(ToolExecutionComponent::new(
9600 &tc.name,
9601 &tc.arguments.to_string(),
9602 ));
9603 tc_comp.set_running();
9604 chat.add_child(tc_comp.clone());
9605 tools.insert(tc.id.clone(), tc_comp);
9606 }
9607 }
9608 }
9609
9610 let rendered = chat.render(80);
9614 let joined: String = rendered.join("\n");
9615 assert!(
9616 joined.contains("Hello."),
9617 "assistant text not rendered: {joined}"
9618 );
9619 assert!(
9620 joined.contains("Reasoning about the reply."),
9621 "thinking block not rendered: {joined}"
9622 );
9623 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9624 assert!(state.current_assistant.lock().unwrap().is_some());
9625
9626 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
9628 ended.set_result("hi", false);
9629 assert!(state.tool_components.lock().unwrap().is_empty());
9630
9631 assert!(state.try_start_working());
9635 assert!(
9636 !state.try_start_working(),
9637 "a second submit must be rejected"
9638 );
9639 state.set_status(RunStatus::Idle);
9640 state.set_status(RunStatus::Working);
9641 assert_eq!(state.status_container.child_count(), 1);
9642 assert!(state.footer.get_status().is_empty());
9643 state.show_retry(3, 10, 8_000);
9644 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
9645 assert!(retry_status.contains("Retrying (3/10)"));
9646 state.set_status(RunStatus::Working);
9647 {
9648 let mut bash = state.bash_components.lock().unwrap();
9649 bash.insert(
9650 "bash-1".into(),
9651 Arc::new(BashExecutionComponent::new("one")),
9652 );
9653 bash.insert(
9654 "bash-2".into(),
9655 Arc::new(BashExecutionComponent::new("two")),
9656 );
9657 }
9658 state.sync_working_loader_with_bash();
9659 assert_eq!(state.status_container.child_count(), 0);
9660 state.bash_components.lock().unwrap().remove("bash-1");
9661 state.sync_working_loader_with_bash();
9662 assert_eq!(state.status_container.child_count(), 0);
9663 state.bash_components.lock().unwrap().remove("bash-2");
9664 state.sync_working_loader_with_bash();
9665 assert_eq!(state.status_container.child_count(), 1);
9666
9667 state.set_status(RunStatus::Aborting);
9668 assert_eq!(state.status_container.child_count(), 0);
9669 assert!(!state.loader.is_running());
9670 }
9671
9672 #[test]
9673 fn fresh_launch_does_not_restore_old_history() {
9674 let fresh = Args::default();
9675 assert!(!launch_restores_history(&fresh));
9676
9677 let continued = Args {
9678 continue_session: true,
9679 ..Args::default()
9680 };
9681 assert!(launch_restores_history(&continued));
9682
9683 let selected = Args {
9684 session: Some("session-id".into()),
9685 ..Args::default()
9686 };
9687 assert!(launch_restores_history(&selected));
9688 }
9689
9690 #[test]
9691 fn test_short_model_name() {
9692 assert_eq!(
9693 short_model_name("anthropic:claude-sonnet-5"),
9694 "claude-sonnet-5"
9695 );
9696 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
9697 }
9698
9699 #[test]
9700 fn model_selector_items_are_deduplicated_and_provider_qualified() {
9701 use rpi_ai::{Api, Model};
9702
9703 let mut gateway = Model::new(
9704 "gpt-5.6-sol",
9705 "GPT 5.6 Sol",
9706 Api::OpenaiCompletions,
9707 "routeryo-copy",
9708 "https://gateway.example.com",
9709 );
9710 let duplicate = gateway.clone();
9711 let anthropic = Model::new(
9712 "claude-sonnet-5",
9713 "Claude Sonnet 5",
9714 Api::AnthropicMessages,
9715 "anthropic",
9716 "https://api.anthropic.com",
9717 );
9718 gateway.headers = Some(std::collections::BTreeMap::from([(
9719 "authorization".into(),
9720 "Bearer test".into(),
9721 )]));
9722
9723 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
9724 assert_eq!(items.len(), 2);
9725 assert_eq!(items[0].value, "gpt-5.6-sol");
9726 assert_eq!(items[0].label, "GPT 5.6 Sol");
9727 assert_eq!(
9728 items[0].description.as_deref(),
9729 Some("routeryo-copy/gpt-5.6-sol (current)")
9730 );
9731 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
9732 }
9733
9734 #[test]
9735 fn model_selector_match_accepts_bare_and_qualified_ids() {
9736 use rpi_ai::{Api, Model};
9737
9738 let gateway = Model::new(
9739 "gpt-5.6-sol",
9740 "GPT 5.6 Sol",
9741 Api::OpenaiCompletions,
9742 "routeryo-copy",
9743 "https://gateway.example.com",
9744 );
9745 let anthropic = Model::new(
9746 "claude-sonnet-5",
9747 "Claude Sonnet 5",
9748 Api::AnthropicMessages,
9749 "anthropic",
9750 "https://api.anthropic.com",
9751 );
9752 let catalog = [gateway, anthropic];
9753 assert_eq!(
9754 find_model_selector_match(&catalog, "gpt-5.6-sol")
9755 .unwrap()
9756 .provider,
9757 "routeryo-copy"
9758 );
9759 assert_eq!(
9760 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
9761 .unwrap()
9762 .id,
9763 "gpt-5.6-sol"
9764 );
9765 assert_eq!(
9766 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9767 .unwrap()
9768 .id,
9769 "claude-sonnet-5"
9770 );
9771 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9772 }
9773
9774 #[test]
9775 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9776 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9777
9778 let failed = AssistantMessage {
9779 role: AssistantRole,
9780 content: Vec::new(),
9781 api: rpi_ai::Api::AnthropicMessages,
9782 provider: "anthropic".into(),
9783 model: "claude-sonnet-5".into(),
9784 response_model: None,
9785 response_id: None,
9786 usage: Usage::zero(),
9787 stop_reason: StopReason::Error,
9788 deferred: None,
9789 error_message: Some("upstream returned 401".into()),
9790 raw_stop_reason: None,
9791 end_turn: None,
9792 timestamp: 0,
9793 };
9794 assert_eq!(
9795 assistant_error_text(&failed).as_deref(),
9796 Some("upstream returned 401")
9797 );
9798
9799 let mut no_detail = failed;
9800 no_detail.error_message = Some(" ".into());
9801 assert_eq!(
9802 assistant_error_text(&no_detail).as_deref(),
9803 Some("Provider request failed.")
9804 );
9805
9806 let mut aborted = no_detail;
9807 aborted.stop_reason = StopReason::Aborted;
9808 aborted.error_message = Some("abort error: Request aborted".into());
9809 assert_eq!(
9810 assistant_error_text(&aborted).as_deref(),
9811 Some("abort error: Request aborted")
9812 );
9813
9814 aborted.error_message = None;
9815 assert_eq!(
9816 assistant_error_text(&aborted).as_deref(),
9817 Some("Request aborted.")
9818 );
9819 }
9820
9821 #[test]
9822 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9823 assert_eq!(
9824 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9825 "405\nMethod Not Allowed"
9826 );
9827 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9828 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
9829 let long = "x".repeat(20_000);
9830 let cleaned = sanitize_error_message(&long);
9831 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9832 assert!(cleaned.ends_with('…'));
9833 }
9834
9835 #[test]
9836 fn test_cycle_next_model_wraps_around() {
9837 use rpi_ai::{Api, Model};
9838 let mk = |id: &str| {
9839 Model::new(
9840 id,
9841 id,
9842 Api::AnthropicMessages,
9843 "anthropic",
9844 "https://api.anthropic.com",
9845 )
9846 };
9847 let catalog = [mk("a"), mk("b"), mk("c")];
9848 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9850 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9851 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9853 let empty: Vec<Model> = vec![];
9855 assert!(cycle_next_model(&empty, "a").is_none());
9856 }
9857
9858 #[test]
9859 fn test_autocomplete_slash_suggestions_render() {
9860 let state = Arc::new(TuiState {
9863 current_assistant: std::sync::Mutex::new(None),
9864 tool_components: std::sync::Mutex::new(HashMap::new()),
9865 bash_components: std::sync::Mutex::new(HashMap::new()),
9866 themes_enabled: true,
9867 hide_thinking: std::sync::Mutex::new(false),
9868 tool_outputs_expanded: std::sync::Mutex::new(false),
9869 show_terminal_progress: true,
9870 status: std::sync::Mutex::new(RunStatus::Idle),
9871 js_preparation_cancel: std::sync::Mutex::new(None),
9872 footer: Arc::new(FooterComponent::new()),
9873 status_container: Arc::new(Container::new()),
9874 chat_container: Arc::new(Container::new()),
9875 loader: Arc::new(Loader::new()),
9876 last_assistant_text: std::sync::Mutex::new(String::new()),
9877 active_selector: std::sync::Mutex::new(None),
9878 active_extension_editor: std::sync::Mutex::new(None),
9879 active_extension_input: std::sync::Mutex::new(None),
9880 active_extension_cancel: std::sync::Mutex::new(None),
9881 autocomplete: AutocompleteManager::new(),
9882 autocomplete_container: Arc::new(Container::new()),
9883 autocomplete_max_visible: 5,
9884 pending_images: std::sync::Mutex::new(Vec::new()),
9885 theme_manager: Arc::new(ThemeManager::new()),
9886 tui: None,
9887 current_model_id: std::sync::Mutex::new(String::new()),
9888 show_images: std::sync::Mutex::new(true),
9889 history: std::sync::Mutex::new(Vec::new()),
9890 history_index: std::sync::Mutex::new(-1),
9891 history_draft: std::sync::Mutex::new(None),
9892 last_input_tokens: std::sync::Mutex::new(0),
9893 scoped_edit: std::sync::Mutex::new(None),
9894 markdown_transformer: std::sync::Mutex::new(None),
9895 extension_session: Arc::new(std::sync::Mutex::new(
9896 rpi_extensions::ExtensionSession::none(),
9897 )),
9898 });
9899 {
9900 let mut combined = CombinedAutocompleteProvider::new();
9901 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9902 build_builtin_registry().visible_entries(),
9903 )));
9904 state.autocomplete.set_provider(Arc::new(combined));
9905 }
9906
9907 let editor = Arc::new(Editor::simple());
9908 editor.set_text("/he");
9909 editor.set_cursor(0, 3);
9910 refresh_autocomplete(&state, &editor);
9911 let lines = state.autocomplete_container.render(80);
9912 let joined: String = lines.join("\n");
9913 assert!(
9914 joined.contains("/help"),
9915 "slash suggestions not rendered: {joined}"
9916 );
9917
9918 editor.set_text("hello");
9920 editor.set_cursor(0, 5);
9921 refresh_autocomplete(&state, &editor);
9922 assert!(state.autocomplete_container.render(80).is_empty());
9923 }
9924
9925 #[test]
9926 fn test_select_list_swap_restores_editor() {
9927 let state = Arc::new(TuiState {
9931 current_assistant: std::sync::Mutex::new(None),
9932 tool_components: std::sync::Mutex::new(HashMap::new()),
9933 bash_components: std::sync::Mutex::new(HashMap::new()),
9934 themes_enabled: true,
9935 hide_thinking: std::sync::Mutex::new(false),
9936 tool_outputs_expanded: std::sync::Mutex::new(false),
9937 show_terminal_progress: true,
9938 status: std::sync::Mutex::new(RunStatus::Idle),
9939 js_preparation_cancel: std::sync::Mutex::new(None),
9940 footer: Arc::new(FooterComponent::new()),
9941 status_container: Arc::new(Container::new()),
9942 chat_container: Arc::new(Container::new()),
9943 loader: Arc::new(Loader::new()),
9944 last_assistant_text: std::sync::Mutex::new(String::new()),
9945 active_selector: std::sync::Mutex::new(None),
9946 active_extension_editor: std::sync::Mutex::new(None),
9947 active_extension_input: std::sync::Mutex::new(None),
9948 active_extension_cancel: std::sync::Mutex::new(None),
9949 autocomplete: AutocompleteManager::new(),
9950 autocomplete_container: Arc::new(Container::new()),
9951 autocomplete_max_visible: 5,
9952 pending_images: std::sync::Mutex::new(Vec::new()),
9953 theme_manager: Arc::new(ThemeManager::new()),
9954 tui: None,
9955 current_model_id: std::sync::Mutex::new(String::new()),
9956 show_images: std::sync::Mutex::new(true),
9957 history: std::sync::Mutex::new(Vec::new()),
9958 history_index: std::sync::Mutex::new(-1),
9959 history_draft: std::sync::Mutex::new(None),
9960 last_input_tokens: std::sync::Mutex::new(0),
9961 scoped_edit: std::sync::Mutex::new(None),
9962 markdown_transformer: std::sync::Mutex::new(None),
9963 extension_session: Arc::new(std::sync::Mutex::new(
9964 rpi_extensions::ExtensionSession::none(),
9965 )),
9966 });
9967 let editor_container = Arc::new(Container::new());
9968 let editor = Arc::new(Editor::simple());
9969 editor_container.add_child(editor.clone());
9970 assert!(!state.selector_open());
9971
9972 let tui_terminal = Box::new(ProcessTerminal::new());
9973 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9974 let list = Arc::new(SelectList::new(
9975 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9976 5,
9977 ));
9978 open_selector(
9979 &state,
9980 &editor_container,
9981 &editor,
9982 &tui,
9983 list,
9984 SelectorKind::Theme,
9985 );
9986 assert!(state.selector_open());
9987 assert_eq!(editor_container.child_count(), 1);
9989
9990 close_selector(&state, &editor_container, &editor, &tui);
9991 assert!(!state.selector_open());
9992 assert_eq!(editor_container.child_count(), 1);
9994 }
9995
9996 #[test]
9997 fn test_message_history_browse_restores_draft() {
9998 let state = Arc::new(TuiState {
10002 current_assistant: std::sync::Mutex::new(None),
10003 tool_components: std::sync::Mutex::new(HashMap::new()),
10004 bash_components: std::sync::Mutex::new(HashMap::new()),
10005 themes_enabled: true,
10006 hide_thinking: std::sync::Mutex::new(false),
10007 tool_outputs_expanded: std::sync::Mutex::new(false),
10008 show_terminal_progress: true,
10009 status: std::sync::Mutex::new(RunStatus::Idle),
10010 js_preparation_cancel: std::sync::Mutex::new(None),
10011 footer: Arc::new(FooterComponent::new()),
10012 status_container: Arc::new(Container::new()),
10013 chat_container: Arc::new(Container::new()),
10014 loader: Arc::new(Loader::new()),
10015 last_assistant_text: std::sync::Mutex::new(String::new()),
10016 active_selector: std::sync::Mutex::new(None),
10017 active_extension_editor: std::sync::Mutex::new(None),
10018 active_extension_input: std::sync::Mutex::new(None),
10019 active_extension_cancel: std::sync::Mutex::new(None),
10020 autocomplete: AutocompleteManager::new(),
10021 autocomplete_container: Arc::new(Container::new()),
10022 autocomplete_max_visible: 5,
10023 pending_images: std::sync::Mutex::new(Vec::new()),
10024 theme_manager: Arc::new(ThemeManager::new()),
10025 tui: None,
10026 current_model_id: std::sync::Mutex::new(String::new()),
10027 show_images: std::sync::Mutex::new(true),
10028 history: std::sync::Mutex::new(Vec::new()),
10029 history_index: std::sync::Mutex::new(-1),
10030 history_draft: std::sync::Mutex::new(None),
10031 last_input_tokens: std::sync::Mutex::new(0),
10032 scoped_edit: std::sync::Mutex::new(None),
10033 markdown_transformer: std::sync::Mutex::new(None),
10034 extension_session: Arc::new(std::sync::Mutex::new(
10035 rpi_extensions::ExtensionSession::none(),
10036 )),
10037 });
10038 let editor = Arc::new(Editor::simple());
10039
10040 push_history(&state, "first message");
10041 push_history(&state, "second message");
10042 push_history(&state, "second message");
10044 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
10046 assert_eq!(state.history.lock().unwrap()[0], "second message");
10047
10048 editor.set_text("half-typed");
10050 editor.set_cursor(0, 11);
10051
10052 navigate_history(&state, &editor, -1);
10054 assert_eq!(editor.get_text(), "second message");
10055 assert_eq!(*state.history_index.lock().unwrap(), 0);
10056 navigate_history(&state, &editor, -1);
10058 assert_eq!(editor.get_text(), "first message");
10059 assert_eq!(*state.history_index.lock().unwrap(), 1);
10060 navigate_history(&state, &editor, -1);
10062 assert_eq!(editor.get_text(), "first message");
10063 navigate_history(&state, &editor, 1);
10065 assert_eq!(editor.get_text(), "second message");
10066 navigate_history(&state, &editor, 1);
10068 assert_eq!(editor.get_text(), "half-typed");
10069 assert_eq!(*state.history_index.lock().unwrap(), -1);
10070 }
10071
10072 #[test]
10073 fn test_accept_top_suggestion_replaces_prefix() {
10074 let state = Arc::new(TuiState {
10076 current_assistant: std::sync::Mutex::new(None),
10077 tool_components: std::sync::Mutex::new(HashMap::new()),
10078 bash_components: std::sync::Mutex::new(HashMap::new()),
10079 themes_enabled: true,
10080 hide_thinking: std::sync::Mutex::new(false),
10081 tool_outputs_expanded: std::sync::Mutex::new(false),
10082 show_terminal_progress: true,
10083 status: std::sync::Mutex::new(RunStatus::Idle),
10084 js_preparation_cancel: std::sync::Mutex::new(None),
10085 footer: Arc::new(FooterComponent::new()),
10086 status_container: Arc::new(Container::new()),
10087 chat_container: Arc::new(Container::new()),
10088 loader: Arc::new(Loader::new()),
10089 last_assistant_text: std::sync::Mutex::new(String::new()),
10090 active_selector: std::sync::Mutex::new(None),
10091 active_extension_editor: std::sync::Mutex::new(None),
10092 active_extension_input: std::sync::Mutex::new(None),
10093 active_extension_cancel: std::sync::Mutex::new(None),
10094 autocomplete: AutocompleteManager::new(),
10095 autocomplete_container: Arc::new(Container::new()),
10096 autocomplete_max_visible: 5,
10097 pending_images: std::sync::Mutex::new(Vec::new()),
10098 theme_manager: Arc::new(ThemeManager::new()),
10099 tui: None,
10100 current_model_id: std::sync::Mutex::new(String::new()),
10101 show_images: std::sync::Mutex::new(true),
10102 history: std::sync::Mutex::new(Vec::new()),
10103 history_index: std::sync::Mutex::new(-1),
10104 history_draft: std::sync::Mutex::new(None),
10105 last_input_tokens: std::sync::Mutex::new(0),
10106 scoped_edit: std::sync::Mutex::new(None),
10107 markdown_transformer: std::sync::Mutex::new(None),
10108 extension_session: Arc::new(std::sync::Mutex::new(
10109 rpi_extensions::ExtensionSession::none(),
10110 )),
10111 });
10112 {
10113 let mut combined = CombinedAutocompleteProvider::new();
10114 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10115 build_builtin_registry().visible_entries(),
10116 )));
10117 state.autocomplete.set_provider(Arc::new(combined));
10118 }
10119 let editor = Arc::new(Editor::simple());
10120 editor.set_text("/he");
10121 editor.set_cursor(0, 3);
10122 refresh_autocomplete(&state, &editor);
10123 let accepted = accept_top_suggestion(&state, &editor);
10124 assert!(accepted, "should accept the top suggestion");
10125 let text = editor.get_text();
10126 assert!(
10127 text.starts_with("/help"),
10128 "editor text should start with /help, got {text}"
10129 );
10130 }
10131
10132 #[test]
10133 fn configured_key_parser_supports_native_notation() {
10134 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10135 assert_eq!(combo.code, KeyCode::Char('g'));
10136 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10137 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10138 assert_eq!(combo.code, KeyCode::BackTab);
10139 }
10140
10141 #[test]
10142 fn double_escape_trigger_has_half_second_window() {
10143 let now = std::time::Instant::now();
10144 assert!(!double_escape_trigger(None, now));
10145 assert!(double_escape_trigger(
10146 Some(now - std::time::Duration::from_millis(500)),
10147 now
10148 ));
10149 assert!(!double_escape_trigger(
10150 Some(now - std::time::Duration::from_millis(501)),
10151 now
10152 ));
10153 }
10154
10155 #[test]
10158 fn ask_user_tool_names_are_recognized() {
10159 assert!(is_ask_user_tool("ask_user"));
10160 assert!(is_ask_user_tool("ask_user_question"));
10161 assert!(!is_ask_user_tool("bash"));
10162 }
10163
10164 #[test]
10165 fn ask_user_args_display_never_leaks_transport_json() {
10166 let text = ask_user_args_display(&serde_json::json!({
10167 "question": "你的代理端口是多少?",
10168 "context": "本机",
10169 "options": ["7890", {"title": "7897", "description": "clash"}],
10170 "suggest": "1080"
10171 }));
10172 assert!(text.contains("你的代理端口是多少?"));
10173 assert!(text.contains("本机"));
10174 assert!(text.contains("Choices: 7890, 7897"));
10175 assert!(text.contains("Suggestions: 1080"));
10176 assert!(!text.trim_start().starts_with('{'));
10177
10178 let multi = ask_user_args_display(&serde_json::json!({
10179 "questions": [
10180 {"id": "a", "question": "Q1"},
10181 {"id": "b", "header": "H", "question": "Q2"}
10182 ]
10183 }));
10184 assert!(multi.contains("Q1"));
10185 assert!(multi.contains("H: Q2"));
10186
10187 assert_eq!(
10188 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10189 "Confirmation requested: Deploy?"
10190 );
10191 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10192 }
10193
10194 #[test]
10195 fn ask_user_result_and_progress_prefer_readable_content() {
10196 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10197 assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10198 .contains("端口?"));
10199 let empty = rpi_agent::AgentToolResult::default();
10200 assert!(
10201 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10202 .contains("端口?")
10203 );
10204 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10205 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10206 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10207 }
10208
10209 #[test]
10210 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10211 let flat = rpi_extensions::UiDialogRequest {
10212 request_id: "r1".into(),
10213 tool_call_id: Some("c1".into()),
10214 ui: serde_json::json!({
10215 "kind": "selector",
10216 "id": "proxy_port",
10217 "header": "Proxy",
10218 "question": "端口?",
10219 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10220 "allowMultiple": true,
10221 "allowFreeform": false,
10222 "suggest": "1080"
10223 }),
10224 raw: serde_json::json!({}),
10225 };
10226 let prompt = parse_ask_user_prompt(&flat);
10227 assert_eq!(prompt.question_id, "proxy_port");
10228 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10229 assert_eq!(prompt.options.len(), 2);
10230 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10231 assert!(prompt.allow_multiple);
10232 assert!(!prompt.allow_freeform);
10233 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10234
10235 let alias = rpi_extensions::UiDialogRequest {
10236 ui: serde_json::json!({
10237 "questions": [{"id": "q", "question": "Q?", "options": []}]
10238 }),
10239 ..flat.clone()
10240 };
10241 let prompt = parse_ask_user_prompt(&alias);
10242 assert_eq!(prompt.question_id, "q");
10243 assert!(prompt.allow_freeform, "no options defaults to freeform");
10244
10245 let confirm = rpi_extensions::UiDialogRequest {
10246 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10247 ..flat.clone()
10248 };
10249 let prompt = parse_ask_user_prompt(&confirm);
10250 assert_eq!(prompt.kind, "confirm");
10251 assert_eq!(prompt.options.len(), 2);
10252 assert_eq!(prompt.options[0].0, "Yes");
10253 }
10254
10255 #[test]
10256 fn ask_user_bridge_answers_and_cancels_by_request_id() {
10257 let mailbox = rpi_extensions::UiDialogMailbox::new();
10258 mailbox.attach();
10259 let bridge = AskUserBridge::new(mailbox.clone());
10260
10261 mailbox
10263 .handle(serde_json::json!({
10264 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10265 "ui": {"kind": "input", "question": "Port?"}
10266 }))
10267 .unwrap();
10268 mailbox
10269 .handle(serde_json::json!({
10270 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10271 "ui": {"kind": "input", "question": "Level?"}
10272 }))
10273 .unwrap();
10274
10275 let first = bridge.take_pending().expect("r1 visible");
10276 assert_eq!(first.request_id, "r1");
10277 assert!(
10278 bridge.take_pending().is_none(),
10279 "only one request occupies the input slot"
10280 );
10281 bridge.respond("r1", serde_json::json!({"text": "7897"}));
10282 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10283
10284 let second = bridge.take_pending().expect("r2 visible");
10285 assert_eq!(second.request_id, "r2");
10286 bridge.cancel("r2");
10287 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10288
10289 mailbox
10291 .handle(serde_json::json!({
10292 "op": "open", "requestId": "r3",
10293 "ui": {"kind": "input", "question": "X?"}
10294 }))
10295 .unwrap();
10296 bridge.cancel_all();
10297 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10298
10299 bridge.shutdown();
10301 assert!(mailbox
10302 .handle(serde_json::json!({
10303 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10304 }))
10305 .is_err());
10306 }
10307}