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, 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 run_extension_cancel(state: &Arc<TuiState>) -> bool {
1033 let callback = state.active_extension_cancel.lock().unwrap().take();
1034 if let Some(callback) = callback {
1035 callback();
1036 true
1037 } else {
1038 false
1039 }
1040}
1041
1042fn open_extension_input(
1043 ctx: &CommandContext,
1044 session: crate::session::ExtensionSessionCell,
1045 command_name: String,
1046 value: serde_json::Value,
1047) {
1048 let title = value
1049 .get("title")
1050 .and_then(|v| v.as_str())
1051 .filter(|title| !title.is_empty())
1052 .unwrap_or("Input");
1053 let input = value
1054 .get("placeholder")
1055 .and_then(|v| v.as_str())
1056 .map(Input::with_placeholder)
1057 .unwrap_or_default();
1058 let input = Arc::new(input);
1059 if let Some(initial) = value
1060 .get("initialText")
1061 .or_else(|| value.get("text"))
1062 .and_then(|v| v.as_str())
1063 {
1064 input.set_value(initial);
1065 }
1066 input.set_focused(true);
1067
1068 let frame = Arc::new(Container::new());
1069 frame.add_child(Arc::new(DynamicBorder::new()));
1070 frame.add_child(Arc::new(Spacer::new(1)));
1071 frame.add_child(extension_dialog_title(title, false));
1072 frame.add_child(Arc::new(Spacer::new(1)));
1073 frame.add_child(input.clone());
1074 frame.add_child(Arc::new(Spacer::new(1)));
1075 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1076 frame.add_child(Arc::new(Spacer::new(1)));
1077 frame.add_child(Arc::new(DynamicBorder::new()));
1078
1079 *ctx.state.active_extension_editor.lock().unwrap() = None;
1080 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1081 ctx.editor_container.clear();
1082 ctx.editor_container.add_child(frame);
1083
1084 let state = ctx.state.clone();
1085 let ec = ctx.editor_container.clone();
1086 let original = ctx.editor.clone();
1087 let tui = ctx.tui.clone();
1088 let session_submit = session.clone();
1089 let command_submit = command_name.clone();
1090 let ctx_submit = ctx.clone();
1091 input.on_submit(Arc::new(move |text| {
1092 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1093 let result = invoke_extension_command(
1094 &session_submit,
1095 &command_submit,
1096 &serde_json::to_string(&args).unwrap_or_default(),
1097 );
1098 close_extension_editor(&state, &ec, &original, &tui);
1099 handle_extension_ui_result(
1100 result,
1101 &ctx_submit,
1102 session_submit.clone(),
1103 command_submit.clone(),
1104 );
1105 }));
1106
1107 let state_cancel = ctx.state.clone();
1108 let ec_cancel = ctx.editor_container.clone();
1109 let original_cancel = ctx.editor.clone();
1110 let tui_cancel = ctx.tui.clone();
1111 let session_cancel = session.clone();
1112 let command_cancel = command_name.clone();
1113 let ctx_cancel = ctx.clone();
1114 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1115 let args = serde_json::json!({ "action": "cancel" });
1116 let result = invoke_extension_command(
1117 &session_cancel,
1118 &command_cancel,
1119 &serde_json::to_string(&args).unwrap_or_default(),
1120 );
1121 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1122 handle_extension_ui_result(
1123 result,
1124 &ctx_cancel,
1125 session_cancel.clone(),
1126 command_cancel.clone(),
1127 );
1128 }));
1129
1130 ctx.tui.set_focus(Some(input));
1131 ctx.tui.request_render(false);
1132}
1133
1134fn open_extension_selector(
1135 ctx: &CommandContext,
1136 session: crate::session::ExtensionSessionCell,
1137 command_name: String,
1138 value: serde_json::Value,
1139) {
1140 let items = value
1141 .get("items")
1142 .and_then(|v| v.as_array())
1143 .map(|items| {
1144 items
1145 .iter()
1146 .filter_map(|item| {
1147 let value = if let Some(value) = item.as_str() {
1150 value
1151 } else {
1152 item.get("value")?.as_str()?
1153 };
1154 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1155 let mut out = SelectItem::new(value, label);
1156 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1157 out = out.with_description(desc);
1158 }
1159 Some(out)
1160 })
1161 .collect::<Vec<_>>()
1162 })
1163 .unwrap_or_default();
1164 if items.is_empty() {
1165 add_error_message(&ctx.chat, "Extension selector has no items.");
1166 ctx.tui.request_render(false);
1167 return;
1168 }
1169 let list = Arc::new(SelectList::new(items, 10));
1170 let title = value
1171 .get("title")
1172 .and_then(|v| v.as_str())
1173 .filter(|title| !title.is_empty())
1174 .unwrap_or("Select");
1175 let frame = Arc::new(Container::new());
1176 frame.add_child(Arc::new(DynamicBorder::new()));
1177 frame.add_child(Arc::new(Spacer::new(1)));
1178 frame.add_child(extension_dialog_title(title, true));
1179 frame.add_child(Arc::new(Spacer::new(1)));
1180 frame.add_child(list.clone());
1181 frame.add_child(Arc::new(Spacer::new(1)));
1182 frame.add_child(extension_dialog_hint(
1183 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1184 ));
1185 frame.add_child(Arc::new(Spacer::new(1)));
1186 frame.add_child(Arc::new(DynamicBorder::new()));
1187
1188 let state = ctx.state.clone();
1189 let ec = ctx.editor_container.clone();
1190 let editor = ctx.editor.clone();
1191 let tui = ctx.tui.clone();
1192 let session_select = session.clone();
1193 let command_select = command_name.clone();
1194 let ctx_select = ctx.clone();
1195 list.on_select(Arc::new(move |item| {
1196 let args = serde_json::json!({ "action": "select", "value": item.value });
1197 let result = invoke_extension_command(
1198 &session_select,
1199 &command_select,
1200 &serde_json::to_string(&args).unwrap_or_default(),
1201 );
1202 close_selector(&state, &ec, &editor, &tui);
1203 handle_extension_ui_result(
1204 result,
1205 &ctx_select,
1206 session_select.clone(),
1207 command_select.clone(),
1208 );
1209 }));
1210 let state_cancel = ctx.state.clone();
1211 let ec_cancel = ctx.editor_container.clone();
1212 let editor_cancel = ctx.editor.clone();
1213 let tui_cancel = ctx.tui.clone();
1214 list.on_cancel(Arc::new(move || {
1215 if !run_extension_cancel(&state_cancel) {
1216 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1217 }
1218 }));
1219 let state_cancel = ctx.state.clone();
1220 let ec_cancel = ctx.editor_container.clone();
1221 let editor_cancel = ctx.editor.clone();
1222 let tui_cancel = ctx.tui.clone();
1223 let session_cancel = session.clone();
1224 let command_cancel = command_name.clone();
1225 let ctx_cancel = ctx.clone();
1226 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1227 let args = serde_json::json!({ "action": "cancel" });
1228 let result = invoke_extension_command(
1229 &session_cancel,
1230 &command_cancel,
1231 &serde_json::to_string(&args).unwrap_or_default(),
1232 );
1233 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1234 handle_extension_ui_result(
1235 result,
1236 &ctx_cancel,
1237 session_cancel.clone(),
1238 command_cancel.clone(),
1239 );
1240 }));
1241 open_selector_with_view(
1242 &ctx.state,
1243 &ctx.editor_container,
1244 &ctx.editor,
1245 &ctx.tui,
1246 list,
1247 frame,
1248 SelectorKind::Extension,
1249 );
1250}
1251
1252fn open_extension_editor(
1253 ctx: &CommandContext,
1254 session: crate::session::ExtensionSessionCell,
1255 command_name: String,
1256 value: serde_json::Value,
1257) {
1258 let initial = value
1259 .get("initialText")
1260 .or_else(|| value.get("text"))
1261 .and_then(|v| v.as_str())
1262 .unwrap_or_default()
1263 .to_string();
1264 let title = value
1265 .get("title")
1266 .and_then(|v| v.as_str())
1267 .filter(|title| !title.is_empty())
1268 .unwrap_or("Editor");
1269 let editor = Arc::new(Editor::new(
1270 EditorOptions {
1271 padding_x: 1,
1272 autocomplete_max_visible: 0,
1273 placeholder: value
1274 .get("placeholder")
1275 .and_then(|v| v.as_str())
1276 .map(str::to_string),
1277 initial_text: Some(initial),
1278 },
1279 EditorStyle {
1280 prompt: "> ".to_string(),
1281 placeholder: String::new(),
1282 },
1283 Arc::new(rpi_tui::Keybindings::new()),
1284 ));
1285 editor.set_focused(true);
1286 let frame = Arc::new(Container::new());
1287 frame.add_child(Arc::new(DynamicBorder::new()));
1288 frame.add_child(Arc::new(Spacer::new(1)));
1289 frame.add_child(extension_dialog_title(title, false));
1290 frame.add_child(Arc::new(Spacer::new(1)));
1291 frame.add_child(editor.clone());
1292 frame.add_child(Arc::new(Spacer::new(1)));
1293 frame.add_child(extension_dialog_hint(
1294 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1295 ));
1296 frame.add_child(Arc::new(Spacer::new(1)));
1297 frame.add_child(Arc::new(DynamicBorder::new()));
1298
1299 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1300 *ctx.state.active_extension_input.lock().unwrap() = None;
1301 ctx.editor_container.clear();
1302 ctx.editor_container.add_child(frame);
1303
1304 let state = ctx.state.clone();
1305 let ec = ctx.editor_container.clone();
1306 let original = ctx.editor.clone();
1307 let tui = ctx.tui.clone();
1308 let session_submit = session.clone();
1309 let command_submit = command_name.clone();
1310 let ctx_submit = ctx.clone();
1311 editor.on_submit(Arc::new(move |text| {
1312 let args = serde_json::json!({ "action": "edit", "text": text });
1313 let result = invoke_extension_command(
1314 &session_submit,
1315 &command_submit,
1316 &serde_json::to_string(&args).unwrap_or_default(),
1317 );
1318 close_extension_editor(&state, &ec, &original, &tui);
1319 handle_extension_ui_result(
1320 result,
1321 &ctx_submit,
1322 session_submit.clone(),
1323 command_submit.clone(),
1324 );
1325 }));
1326
1327 let state_cancel = ctx.state.clone();
1328 let ec_cancel = ctx.editor_container.clone();
1329 let original_cancel = ctx.editor.clone();
1330 let tui_cancel = ctx.tui.clone();
1331 let session_cancel = session.clone();
1332 let command_cancel = command_name.clone();
1333 let ctx_cancel = ctx.clone();
1334 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1335 let args = serde_json::json!({ "action": "cancel" });
1336 let result = invoke_extension_command(
1337 &session_cancel,
1338 &command_cancel,
1339 &serde_json::to_string(&args).unwrap_or_default(),
1340 );
1341 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1342 handle_extension_ui_result(
1343 result,
1344 &ctx_cancel,
1345 session_cancel.clone(),
1346 command_cancel.clone(),
1347 );
1348 }));
1349
1350 ctx.tui.set_focus(Some(editor));
1351 ctx.tui.request_render(false);
1352}
1353
1354fn close_extension_editor(
1355 state: &Arc<TuiState>,
1356 editor_container: &Arc<Container>,
1357 editor: &Arc<Editor>,
1358 tui: &Arc<TuiAltScreen>,
1359) {
1360 editor_container.clear();
1361 editor_container.add_child(editor.clone());
1362 state.autocomplete_container.clear();
1363 *state.active_extension_editor.lock().unwrap() = None;
1364 *state.active_extension_input.lock().unwrap() = None;
1365 *state.active_extension_cancel.lock().unwrap() = None;
1366 editor.set_focused(true);
1367 tui.set_focus(Some(editor.clone()));
1368 tui.request_render(false);
1369}
1370
1371fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1375 match request.method.as_str() {
1376 "select" => open_js_selector(ctx, bridge, request, false),
1377 "confirm" => open_js_selector(ctx, bridge, request, true),
1378 "input" => open_js_input(ctx, bridge, request),
1379 "editor" => open_js_editor(ctx, bridge, request),
1380 _ => {
1381 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1382 }
1383 }
1384}
1385
1386fn open_js_selector(
1387 ctx: &CommandContext,
1388 bridge: Arc<JsDialogBridge>,
1389 request: JsDialogRequest,
1390 confirm: bool,
1391) {
1392 let values = if confirm {
1393 vec!["Yes".to_string(), "No".to_string()]
1394 } else {
1395 request.options.clone()
1396 };
1397 if values.is_empty() {
1398 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1399 return;
1400 }
1401 let items = values
1402 .iter()
1403 .map(|value| SelectItem::new(value, value))
1404 .collect::<Vec<_>>();
1405 let list = Arc::new(SelectList::new(items, 10));
1406 let frame = Arc::new(Container::new());
1407 frame.add_child(Arc::new(DynamicBorder::new()));
1408 frame.add_child(Arc::new(Spacer::new(1)));
1409 frame.add_child(extension_dialog_title(
1410 if request.title.is_empty() {
1411 if confirm {
1412 "Confirm"
1413 } else {
1414 "Select"
1415 }
1416 } else {
1417 request.title.as_str()
1418 },
1419 true,
1420 ));
1421 if !request.message.is_empty() {
1422 frame.add_child(Arc::new(Spacer::new(1)));
1423 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1424 }
1425 frame.add_child(Arc::new(Spacer::new(1)));
1426 frame.add_child(list.clone());
1427 frame.add_child(Arc::new(Spacer::new(1)));
1428 frame.add_child(extension_dialog_hint(
1429 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1430 ));
1431 frame.add_child(Arc::new(Spacer::new(1)));
1432 frame.add_child(Arc::new(DynamicBorder::new()));
1433
1434 let id = request.id.clone();
1435 let bridge_select = bridge.clone();
1436 let state_select = ctx.state.clone();
1437 let ec_select = ctx.editor_container.clone();
1438 let editor_select = ctx.editor.clone();
1439 let tui_select = ctx.tui.clone();
1440 list.on_select(Arc::new(move |item| {
1441 let result = if confirm {
1442 serde_json::json!({ "confirmed": item.value == "Yes" })
1443 } else {
1444 serde_json::json!({ "value": item.value })
1445 };
1446 bridge_select.respond(&id, result);
1447 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1448 }));
1449
1450 let id_cancel = request.id.clone();
1451 let bridge_cancel = bridge.clone();
1452 let state_cancel = ctx.state.clone();
1453 let ec_cancel = ctx.editor_container.clone();
1454 let editor_cancel = ctx.editor.clone();
1455 let tui_cancel = ctx.tui.clone();
1456 list.on_cancel(Arc::new(move || {
1457 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1458 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1459 }));
1460
1461 let id_abort = request.id.clone();
1462 let bridge_abort = bridge.clone();
1463 let state_abort = ctx.state.clone();
1464 let ec_abort = ctx.editor_container.clone();
1465 let editor_abort = ctx.editor.clone();
1466 let tui_abort = ctx.tui.clone();
1467 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1468 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1469 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1470 }));
1471
1472 open_selector_with_view(
1473 &ctx.state,
1474 &ctx.editor_container,
1475 &ctx.editor,
1476 &ctx.tui,
1477 list,
1478 frame,
1479 SelectorKind::Extension,
1480 );
1481}
1482
1483fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1484 let input = request
1485 .placeholder
1486 .as_deref()
1487 .map(Input::with_placeholder)
1488 .unwrap_or_default();
1489 let input = Arc::new(input);
1490 input.set_focused(true);
1491
1492 let frame = Arc::new(Container::new());
1493 frame.add_child(Arc::new(DynamicBorder::new()));
1494 frame.add_child(Arc::new(Spacer::new(1)));
1495 frame.add_child(extension_dialog_title(
1496 if request.title.is_empty() {
1497 "Input"
1498 } else {
1499 &request.title
1500 },
1501 false,
1502 ));
1503 frame.add_child(Arc::new(Spacer::new(1)));
1504 frame.add_child(input.clone());
1505 frame.add_child(Arc::new(Spacer::new(1)));
1506 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1507 frame.add_child(Arc::new(Spacer::new(1)));
1508 frame.add_child(Arc::new(DynamicBorder::new()));
1509
1510 *ctx.state.active_extension_editor.lock().unwrap() = None;
1511 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1512 ctx.editor_container.clear();
1513 ctx.editor_container.add_child(frame);
1514
1515 let id = request.id.clone();
1516 let bridge_submit = bridge.clone();
1517 let state_submit = ctx.state.clone();
1518 let ec_submit = ctx.editor_container.clone();
1519 let editor_submit = ctx.editor.clone();
1520 let tui_submit = ctx.tui.clone();
1521 input.on_submit(Arc::new(move |value| {
1522 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1523 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1524 }));
1525
1526 let id_cancel = request.id.clone();
1527 let bridge_cancel = bridge.clone();
1528 let state_cancel = ctx.state.clone();
1529 let ec_cancel = ctx.editor_container.clone();
1530 let editor_cancel = ctx.editor.clone();
1531 let tui_cancel = ctx.tui.clone();
1532 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1533 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1534 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1535 }));
1536 ctx.tui.set_focus(Some(input));
1537 ctx.tui.request_render(false);
1538}
1539
1540fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1541 let editor = Arc::new(Editor::new(
1542 EditorOptions {
1543 padding_x: 1,
1544 autocomplete_max_visible: 0,
1545 initial_text: request.prefill.clone(),
1546 ..Default::default()
1547 },
1548 EditorStyle {
1549 prompt: "> ".to_string(),
1550 placeholder: String::new(),
1551 },
1552 Arc::new(rpi_tui::Keybindings::new()),
1553 ));
1554 editor.set_focused(true);
1555
1556 let frame = Arc::new(Container::new());
1557 frame.add_child(Arc::new(DynamicBorder::new()));
1558 frame.add_child(Arc::new(Spacer::new(1)));
1559 frame.add_child(extension_dialog_title(
1560 if request.title.is_empty() {
1561 "Editor"
1562 } else {
1563 &request.title
1564 },
1565 false,
1566 ));
1567 frame.add_child(Arc::new(Spacer::new(1)));
1568 frame.add_child(editor.clone());
1569 frame.add_child(Arc::new(Spacer::new(1)));
1570 frame.add_child(extension_dialog_hint(
1571 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1572 ));
1573 frame.add_child(Arc::new(Spacer::new(1)));
1574 frame.add_child(Arc::new(DynamicBorder::new()));
1575
1576 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1577 *ctx.state.active_extension_input.lock().unwrap() = None;
1578 ctx.editor_container.clear();
1579 ctx.editor_container.add_child(frame);
1580
1581 let id = request.id.clone();
1582 let bridge_submit = bridge.clone();
1583 let state_submit = ctx.state.clone();
1584 let ec_submit = ctx.editor_container.clone();
1585 let editor_submit = ctx.editor.clone();
1586 let tui_submit = ctx.tui.clone();
1587 editor.on_submit(Arc::new(move |value| {
1588 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1589 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1590 }));
1591
1592 let id_cancel = request.id.clone();
1593 let bridge_cancel = bridge.clone();
1594 let state_cancel = ctx.state.clone();
1595 let ec_cancel = ctx.editor_container.clone();
1596 let editor_cancel = ctx.editor.clone();
1597 let tui_cancel = ctx.tui.clone();
1598 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1599 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1600 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1601 }));
1602 ctx.tui.set_focus(Some(editor));
1603 ctx.tui.request_render(false);
1604}
1605
1606fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1607 for id in bridge.cancelled_active_ids() {
1608 if !bridge.is_visible(&id) {
1612 bridge.finish(&id);
1613 continue;
1614 }
1615 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1616 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1617 } else if ctx.state.extension_dialog_open() {
1618 if !run_extension_cancel(&ctx.state) {
1619 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1620 }
1621 }
1622 bridge.finish(&id);
1626 }
1627}
1628
1629const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1636
1637#[derive(Clone)]
1642struct AskUserBridge {
1643 mailbox: rpi_extensions::UiDialogMailbox,
1644 visible: Arc<Mutex<Option<String>>>,
1645}
1646
1647impl AskUserBridge {
1648 fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1649 Self {
1650 mailbox,
1651 visible: Arc::new(Mutex::new(None)),
1652 }
1653 }
1654
1655 fn attach(&self) {
1657 self.mailbox.attach();
1658 }
1659
1660 fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1664 let mut visible = self.visible.lock().ok()?;
1665 if visible.is_some() {
1666 return None;
1667 }
1668 let request = self.mailbox.take_pending()?;
1669 *visible = Some(request.request_id.clone());
1670 Some(request)
1671 }
1672
1673 fn clear_visible(&self, id: &str) {
1674 if let Ok(mut visible) = self.visible.lock() {
1675 if visible.as_deref() == Some(id) {
1676 *visible = None;
1677 }
1678 }
1679 }
1680
1681 fn respond(&self, id: &str, answer: serde_json::Value) {
1683 let _ = self.mailbox.respond(id, answer);
1684 self.clear_visible(id);
1685 }
1686
1687 fn cancel(&self, id: &str) {
1689 let _ = self.mailbox.cancel(id);
1690 self.clear_visible(id);
1691 }
1692
1693 fn cancel_all(&self) {
1696 self.mailbox.cancel_all();
1697 if let Ok(mut visible) = self.visible.lock() {
1698 *visible = None;
1699 }
1700 }
1701
1702 fn shutdown(&self) {
1705 self.mailbox.detach();
1706 if let Ok(mut visible) = self.visible.lock() {
1707 *visible = None;
1708 }
1709 }
1710}
1711
1712#[derive(Clone)]
1716struct AskUserPrompt {
1717 question_id: String,
1718 question: String,
1719 header: Option<String>,
1720 context: Option<String>,
1721 options: Vec<(String, Option<String>)>,
1722 allow_multiple: bool,
1723 allow_freeform: bool,
1724 suggest: Option<String>,
1725 kind: String,
1726}
1727
1728fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1729 keys.iter().find_map(|key| {
1730 value
1731 .get(*key)
1732 .and_then(serde_json::Value::as_str)
1733 .map(str::trim)
1734 .filter(|text| !text.is_empty())
1735 .map(str::to_string)
1736 })
1737}
1738
1739fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1740 keys.iter()
1741 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1742}
1743
1744fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1745 let mut options = Vec::new();
1746 let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1747 return options;
1748 };
1749 for item in items {
1750 if let Some(title) = item.as_str() {
1751 let title = title.trim();
1752 if !title.is_empty() {
1753 options.push((title.to_string(), None));
1754 }
1755 continue;
1756 }
1757 let Some(object) = item.as_object() else { continue };
1758 let title = ["title", "label", "text", "value", "name"]
1759 .iter()
1760 .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1761 .map(str::trim)
1762 .filter(|title| !title.is_empty());
1763 let Some(title) = title else { continue };
1764 let description = object
1765 .get("description")
1766 .and_then(serde_json::Value::as_str)
1767 .map(str::trim)
1768 .filter(|text| !text.is_empty())
1769 .map(str::to_string);
1770 options.push((title.to_string(), description));
1771 }
1772 options
1773}
1774
1775fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1776 let ui = &request.ui;
1777 let kind = ui
1778 .get("kind")
1779 .and_then(serde_json::Value::as_str)
1780 .unwrap_or("selector")
1781 .to_string();
1782 let nested = ui
1786 .get("questions")
1787 .and_then(serde_json::Value::as_array)
1788 .and_then(|items| items.first());
1789 let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1790
1791 let question = ask_user_str(source, &["question", "summary", "title"])
1792 .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1793 .unwrap_or_default();
1794 let question_id = ask_user_str(source, &["id", "questionId"])
1795 .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1796 .unwrap_or_else(|| request.request_id.clone());
1797 let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1798 let context = ask_user_str(source, &["context", "message"])
1799 .or_else(|| ask_user_str(ui, &["context", "message"]));
1800 let mut options = ask_user_options(source);
1801 if options.is_empty() {
1802 options = ask_user_options(ui);
1803 }
1804 if kind == "confirm" && options.is_empty() {
1805 options = vec![
1806 ("Yes".to_string(), None),
1807 ("No".to_string(), None),
1808 ];
1809 }
1810 let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1811 .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1812 .unwrap_or(false);
1813 let allow_freeform = ask_user_bool(
1816 source,
1817 &["allowFreeform", "allow_freeform", "allow_custom"],
1818 )
1819 .or_else(|| {
1820 ask_user_bool(
1821 ui,
1822 &["allowFreeform", "allow_freeform", "allow_custom"],
1823 )
1824 })
1825 .unwrap_or(options.is_empty());
1826 let suggest = ask_user_str(source, &["suggest", "placeholder"])
1827 .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1828
1829 AskUserPrompt {
1830 question_id,
1831 question,
1832 header,
1833 context,
1834 options,
1835 allow_multiple,
1836 allow_freeform,
1837 suggest,
1838 kind,
1839 }
1840}
1841
1842fn ask_user_frame(
1844 prompt: &AskUserPrompt,
1845 body: Arc<dyn Component>,
1846 hint: &str,
1847) -> Arc<Container> {
1848 let frame = Arc::new(Container::new());
1849 frame.add_child(Arc::new(DynamicBorder::new()));
1850 frame.add_child(Arc::new(Spacer::new(1)));
1851 if let Some(header) = prompt.header.as_deref() {
1852 frame.add_child(extension_dialog_title(header, true));
1853 }
1854 if !prompt.question.trim().is_empty() {
1855 frame.add_child(extension_dialog_title(
1856 &prompt.question,
1857 prompt.header.is_none(),
1858 ));
1859 }
1860 if let Some(context) = prompt.context.as_deref() {
1861 frame.add_child(extension_dialog_hint(context));
1862 }
1863 frame.add_child(Arc::new(Spacer::new(1)));
1864 frame.add_child(body);
1865 frame.add_child(Arc::new(Spacer::new(1)));
1866 frame.add_child(extension_dialog_hint(hint));
1867 frame.add_child(Arc::new(Spacer::new(1)));
1868 frame.add_child(Arc::new(DynamicBorder::new()));
1869 frame
1870}
1871
1872fn open_ask_user_dialog(
1874 ctx: &CommandContext,
1875 bridge: AskUserBridge,
1876 request: rpi_extensions::UiDialogRequest,
1877) {
1878 let prompt = parse_ask_user_prompt(&request);
1879 if prompt.kind == "input" || prompt.options.is_empty() {
1880 open_ask_user_input(ctx, bridge, request, prompt);
1881 } else {
1882 open_ask_user_selector(ctx, bridge, request, prompt);
1883 }
1884}
1885
1886fn open_ask_user_selector(
1887 ctx: &CommandContext,
1888 bridge: AskUserBridge,
1889 request: rpi_extensions::UiDialogRequest,
1890 prompt: AskUserPrompt,
1891) {
1892 let multi = prompt.allow_multiple && prompt.kind != "confirm";
1893 let mut items: Vec<SelectItem> = Vec::new();
1894 for (title, description) in &prompt.options {
1895 let item = SelectItem::new(title, title);
1896 let item = match description.as_deref() {
1897 Some(description) => item.with_description(description),
1898 None => item,
1899 };
1900 items.push(item);
1901 }
1902 if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1905 items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1906 }
1907 let list = if multi {
1908 Arc::new(SelectList::new_multi(items, 10))
1909 } else {
1910 Arc::new(SelectList::new(items, 10))
1911 };
1912
1913 let request_id = request.request_id.clone();
1914 let state = ctx.state.clone();
1915 let editor_container = ctx.editor_container.clone();
1916 let editor = ctx.editor.clone();
1917 let tui = ctx.tui.clone();
1918
1919 let bridge_select = bridge.clone();
1922 let request_for_freeform = request.clone();
1923 let prompt_for_freeform = AskUserPrompt {
1924 question_id: prompt.question_id.clone(),
1925 question: prompt.question.clone(),
1926 header: prompt.header.clone(),
1927 context: prompt.context.clone(),
1928 options: Vec::new(),
1929 allow_multiple: false,
1930 allow_freeform: true,
1931 suggest: prompt.suggest.clone(),
1932 kind: "input".to_string(),
1933 };
1934 let state_select = state.clone();
1935 let ec_select = editor_container.clone();
1936 let editor_select = editor.clone();
1937 let tui_select = tui.clone();
1938 let ctx_select = ctx.clone();
1939 list.on_select(Arc::new(move |item: &SelectItem| {
1940 if item.value == ASK_USER_FREEFORM {
1941 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1942 open_ask_user_input(
1943 &ctx_select,
1944 bridge_select.clone(),
1945 request_for_freeform.clone(),
1946 prompt_for_freeform.clone(),
1947 );
1948 return;
1949 }
1950 bridge_select.respond(
1951 &request_id,
1952 serde_json::json!({
1953 "values": [item.display_value()],
1954 "kind": "selector",
1955 }),
1956 );
1957 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1958 }));
1959
1960 if multi {
1961 let bridge_multi = bridge.clone();
1962 let request_id_multi = request.request_id.clone();
1963 let state_multi = state.clone();
1964 let ec_multi = editor_container.clone();
1965 let editor_multi = editor.clone();
1966 let tui_multi = tui.clone();
1967 list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
1968 let values: Vec<String> = selected
1969 .iter()
1970 .map(|item| item.display_value().to_string())
1971 .collect();
1972 bridge_multi.respond(
1973 &request_id_multi,
1974 serde_json::json!({"values": values, "kind": "selector"}),
1975 );
1976 close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
1977 }));
1978 }
1979
1980 let bridge_cancel = bridge.clone();
1981 let request_id_cancel = request.request_id.clone();
1982 let state_cancel = state.clone();
1983 let ec_cancel = editor_container.clone();
1984 let editor_cancel = editor.clone();
1985 let tui_cancel = tui.clone();
1986 list.on_cancel(Arc::new(move || {
1987 bridge_cancel.cancel(&request_id_cancel);
1988 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1989 }));
1990
1991 let hint = if multi {
1992 "Space toggle · Enter submit · Esc cancel"
1993 } else {
1994 "Enter select · Esc cancel"
1995 };
1996 let frame = ask_user_frame(&prompt, list.clone(), hint);
1997 open_selector_with_view(
1998 &state,
1999 &editor_container,
2000 &editor,
2001 &tui,
2002 list,
2003 frame,
2004 SelectorKind::Extension,
2005 );
2006}
2007
2008fn open_ask_user_input(
2009 ctx: &CommandContext,
2010 bridge: AskUserBridge,
2011 request: rpi_extensions::UiDialogRequest,
2012 prompt: AskUserPrompt,
2013) {
2014 let placeholder = prompt
2015 .suggest
2016 .clone()
2017 .unwrap_or_else(|| "Type your answer…".to_string());
2018 let input = Arc::new(Input::with_placeholder(&placeholder));
2019 input.set_focused(true);
2020
2021 let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2022 *ctx.state.active_extension_editor.lock().unwrap() = None;
2023 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2024 ctx.editor_container.clear();
2025 ctx.editor_container.add_child(frame);
2026 ctx.tui.set_focus(Some(input.clone()));
2027 ctx.tui.request_render(false);
2028
2029 let state = ctx.state.clone();
2030 let ec = ctx.editor_container.clone();
2031 let editor = ctx.editor.clone();
2032 let tui = ctx.tui.clone();
2033 let bridge_submit = bridge.clone();
2034 let request_id = request.request_id.clone();
2035 input.on_submit(Arc::new(move |text: &str| {
2036 bridge_submit.respond(
2037 &request_id,
2038 serde_json::json!({
2039 "values": [text],
2040 "value": text,
2041 "text": text,
2042 "kind": "input",
2043 }),
2044 );
2045 close_extension_editor(&state, &ec, &editor, &tui);
2046 }));
2047
2048 let state_cancel = ctx.state.clone();
2049 let ec_cancel = ctx.editor_container.clone();
2050 let editor_cancel = ctx.editor.clone();
2051 let tui_cancel = ctx.tui.clone();
2052 let bridge_cancel = bridge.clone();
2053 let request_id_cancel = request.request_id.clone();
2054 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2055 bridge_cancel.cancel(&request_id_cancel);
2056 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2057 }));
2058}
2059
2060struct HelpCommand;
2063impl SlashCommand for HelpCommand {
2064 fn name(&self) -> &'static str {
2065 "/help"
2066 }
2067 fn aliases(&self) -> &'static [&'static str] {
2068 &["/?"]
2069 }
2070 fn description(&self) -> &'static str {
2071 "Show available commands"
2072 }
2073 fn execute(&self, ctx: &CommandContext, _args: &str) {
2074 add_help_message(&ctx.chat);
2075 ctx.tui.request_render(false);
2076 }
2077}
2078
2079struct ClearChatCommand;
2080impl SlashCommand for ClearChatCommand {
2081 fn name(&self) -> &'static str {
2082 "/clear"
2083 }
2084 fn aliases(&self) -> &'static [&'static str] {
2085 &["/new"]
2086 }
2087 fn alias_visible(&self) -> &'static [&'static str] {
2089 &["/new"]
2090 }
2091 fn description(&self) -> &'static str {
2092 "Clear the conversation"
2093 }
2094 fn execute(&self, ctx: &CommandContext, _args: &str) {
2095 let _ = ctx.tx.send(TuiMessage::ClearChat);
2096 }
2097}
2098
2099struct ExitCommand;
2100impl SlashCommand for ExitCommand {
2101 fn name(&self) -> &'static str {
2102 "/exit"
2103 }
2104 fn aliases(&self) -> &'static [&'static str] {
2105 &["/quit", "/q"]
2106 }
2107 fn alias_visible(&self) -> &'static [&'static str] {
2109 &["/quit"]
2110 }
2111 fn description(&self) -> &'static str {
2112 "Exit the application"
2113 }
2114 fn execute(&self, ctx: &CommandContext, _args: &str) {
2115 ctx.state.cancel_js_preparation();
2116 let _ = ctx.tx.send(TuiMessage::Exit);
2117 }
2118}
2119
2120struct VersionCommand;
2121impl SlashCommand for VersionCommand {
2122 fn name(&self) -> &'static str {
2123 "/version"
2124 }
2125 fn aliases(&self) -> &'static [&'static str] {
2126 &["/v"]
2127 }
2128 fn description(&self) -> &'static str {
2129 "Show version information"
2130 }
2131 fn execute(&self, ctx: &CommandContext, _args: &str) {
2132 add_version_message(&ctx.chat);
2133 ctx.tui.request_render(false);
2134 }
2135}
2136
2137struct ChangelogCommand;
2138impl SlashCommand for ChangelogCommand {
2139 fn name(&self) -> &'static str {
2140 "/changelog"
2141 }
2142 fn description(&self) -> &'static str {
2143 "Show recent release changes"
2144 }
2145 fn execute(&self, ctx: &CommandContext, _args: &str) {
2146 add_changelog_message(&ctx.chat);
2147 ctx.tui.request_render(false);
2148 }
2149}
2150
2151struct HotkeysCommand;
2152impl SlashCommand for HotkeysCommand {
2153 fn name(&self) -> &'static str {
2154 "/hotkeys"
2155 }
2156 fn description(&self) -> &'static str {
2157 "Show keyboard shortcuts"
2158 }
2159 fn execute(&self, ctx: &CommandContext, _args: &str) {
2160 add_hotkeys_message(&ctx.chat);
2161 ctx.tui.request_render(false);
2162 }
2163}
2164
2165struct ModelCommand;
2166impl SlashCommand for ModelCommand {
2167 fn name(&self) -> &'static str {
2168 "/model"
2169 }
2170 fn aliases(&self) -> &'static [&'static str] {
2171 &["/m"]
2172 }
2173 fn description(&self) -> &'static str {
2174 "Choose a model (selector)"
2175 }
2176 fn execute(&self, ctx: &CommandContext, args: &str) {
2177 let term = args.trim();
2178 if !term.is_empty() {
2179 let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
2181 add_error_message(
2182 &ctx.chat,
2183 &format!("No model matches \"{term}\". Try /model for the list."),
2184 );
2185 ctx.tui.request_render(false);
2186 return;
2187 };
2188 let model_id = model.id.clone();
2189 ctx.state.set_current_model(&model);
2190 let lane = ctx.lane.clone();
2191 tokio::spawn(async move {
2192 let _ = lane.set_model(model).await;
2193 });
2194 add_note_message(
2195 &ctx.chat,
2196 &format!(
2197 "Model set to {} — applies to the next message.",
2198 short_model_name(&model_id)
2199 ),
2200 );
2201 ctx.tui.request_render(false);
2202 return;
2203 }
2204 open_model_selector(
2205 &ctx.state,
2206 &ctx.editor_container,
2207 &ctx.editor,
2208 &ctx.tui,
2209 &ctx.model_catalog,
2210 &ctx.lane,
2211 &ctx.lane_model_id,
2212 &ctx.chat,
2213 );
2214 }
2215}
2216
2217struct ThinkingCommand;
2218impl SlashCommand for ThinkingCommand {
2219 fn name(&self) -> &'static str {
2220 "/thinking"
2221 }
2222 fn aliases(&self) -> &'static [&'static str] {
2223 &["/think"]
2224 }
2225 fn description(&self) -> &'static str {
2226 "Set thinking level (selector)"
2227 }
2228 fn execute(&self, ctx: &CommandContext, args: &str) {
2229 let level_name = args.trim();
2230 if !level_name.is_empty() {
2231 let Some(level) = thinking_level_from_name(level_name) else {
2233 add_error_message(
2234 &ctx.chat,
2235 &format!(
2236 "Unknown thinking level \"{level_name}\". Valid: {}",
2237 crate::args::VALID_THINKING_LEVELS.join(", ")
2238 ),
2239 );
2240 ctx.tui.request_render(false);
2241 return;
2242 };
2243 let lane = ctx.lane.clone();
2244 let footer = ctx.state.footer.clone();
2245 tokio::spawn(async move {
2246 let _ = lane.set_thinking_level(level).await;
2247 });
2248 footer.set_thinking_level(Some(thinking_level_name(level)));
2249 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2250 ctx.tui.request_render(false);
2251 return;
2252 }
2253 open_thinking_selector(
2254 &ctx.state,
2255 &ctx.editor_container,
2256 &ctx.editor,
2257 &ctx.tui,
2258 &ctx.lane,
2259 &ctx.model_catalog,
2260 &ctx.lane_model_id,
2261 &ctx.chat,
2262 );
2263 }
2264}
2265
2266struct ToolsCommand;
2267impl SlashCommand for ToolsCommand {
2268 fn name(&self) -> &'static str {
2269 "/tools"
2270 }
2271 fn description(&self) -> &'static str {
2272 "Toggle tools on/off"
2273 }
2274 fn execute(&self, ctx: &CommandContext, _args: &str) {
2275 open_tools_selector(
2276 &ctx.state,
2277 &ctx.editor_container,
2278 &ctx.editor,
2279 &ctx.tui,
2280 &ctx.lane,
2281 &ctx.chat,
2282 );
2283 }
2284}
2285
2286struct ImagesCommand;
2287impl SlashCommand for ImagesCommand {
2288 fn name(&self) -> &'static str {
2289 "/images"
2290 }
2291 fn description(&self) -> &'static str {
2292 "Toggle inline images"
2293 }
2294 fn execute(&self, ctx: &CommandContext, _args: &str) {
2295 open_images_selector(
2296 &ctx.state,
2297 &ctx.editor_container,
2298 &ctx.editor,
2299 &ctx.tui,
2300 &ctx.chat,
2301 );
2302 }
2303}
2304
2305struct SessionCommand;
2306impl SlashCommand for SessionCommand {
2307 fn name(&self) -> &'static str {
2308 "/session"
2309 }
2310 fn aliases(&self) -> &'static [&'static str] {
2311 &["/resume"]
2312 }
2313 fn description(&self) -> &'static str {
2314 "List saved sessions"
2315 }
2316 fn execute(&self, ctx: &CommandContext, _args: &str) {
2317 open_session_selector(
2318 &ctx.state,
2319 &ctx.editor_container,
2320 &ctx.editor,
2321 &ctx.tui,
2322 &ctx.cwd,
2323 &ctx.tx,
2324 );
2325 }
2326}
2327
2328struct ThemeCommand;
2329impl SlashCommand for ThemeCommand {
2330 fn name(&self) -> &'static str {
2331 "/theme"
2332 }
2333 fn description(&self) -> &'static str {
2334 "Choose a theme (selector)"
2335 }
2336 fn execute(&self, ctx: &CommandContext, args: &str) {
2337 let name = args.trim().to_ascii_lowercase();
2338 if !name.is_empty() {
2339 let preset = match name.as_str() {
2341 "light" => ThemePreset::Light,
2342 "monochrome" => ThemePreset::Monochrome,
2343 "dark" => ThemePreset::Dark,
2344 _ => {
2345 add_error_message(
2346 &ctx.chat,
2347 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2348 );
2349 ctx.tui.request_render(false);
2350 return;
2351 }
2352 };
2353 apply_theme_preset(preset);
2354 let mut settings = crate::settings::load_settings().unwrap_or_default();
2355 settings.theme = Some(name.clone());
2356 let _ = crate::settings::save_settings(&settings);
2357 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2358 ctx.tui.request_render(false);
2359 ctx.tui.render_now(true);
2360 return;
2361 }
2362 open_theme_selector(
2363 &ctx.state,
2364 &ctx.editor_container,
2365 &ctx.editor,
2366 &ctx.tui,
2367 &ctx.cwd,
2368 &ctx.package_resources,
2369 );
2370 }
2371}
2372
2373struct CompactCommand;
2374impl SlashCommand for CompactCommand {
2375 fn name(&self) -> &'static str {
2376 "/compact"
2377 }
2378 fn description(&self) -> &'static str {
2379 "Compact the conversation"
2380 }
2381 fn execute(&self, ctx: &CommandContext, _args: &str) {
2382 let _ = ctx.tx.send(TuiMessage::Compact);
2383 }
2384}
2385
2386struct CopyCommand;
2387impl SlashCommand for CopyCommand {
2388 fn name(&self) -> &'static str {
2389 "/copy"
2390 }
2391 fn description(&self) -> &'static str {
2392 "Copy last reply to clipboard"
2393 }
2394 fn execute(&self, ctx: &CommandContext, _args: &str) {
2395 let _ = ctx.tx.send(TuiMessage::Copy);
2396 }
2397}
2398
2399struct ExportCommand;
2400impl SlashCommand for ExportCommand {
2401 fn name(&self) -> &'static str {
2402 "/export"
2403 }
2404 fn description(&self) -> &'static str {
2405 "Export session to a markdown file"
2406 }
2407 fn execute(&self, ctx: &CommandContext, _args: &str) {
2408 let _ = ctx.tx.send(TuiMessage::ExportSession);
2409 }
2410}
2411
2412struct ForkCommand;
2413impl SlashCommand for ForkCommand {
2414 fn name(&self) -> &'static str {
2415 "/fork"
2416 }
2417 fn description(&self) -> &'static str {
2418 "Fork the session into a new one"
2419 }
2420 fn execute(&self, ctx: &CommandContext, _args: &str) {
2421 let _ = ctx.tx.send(TuiMessage::ForkSession);
2422 }
2423}
2424
2425struct CloneCommand;
2429impl SlashCommand for CloneCommand {
2430 fn name(&self) -> &'static str {
2431 "/clone"
2432 }
2433 fn description(&self) -> &'static str {
2434 "Duplicate the current session"
2435 }
2436 fn execute(&self, ctx: &CommandContext, _args: &str) {
2437 let _ = ctx.tx.send(TuiMessage::ForkSession);
2438 }
2439}
2440
2441struct TreeCommand;
2442impl SlashCommand for TreeCommand {
2443 fn name(&self) -> &'static str {
2444 "/tree"
2445 }
2446 fn description(&self) -> &'static str {
2447 "Navigate the current session tree"
2448 }
2449 fn execute(&self, ctx: &CommandContext, _args: &str) {
2450 let _ = ctx.tx.send(TuiMessage::OpenTree);
2451 }
2452}
2453
2454struct LoginCommand;
2455impl SlashCommand for LoginCommand {
2456 fn name(&self) -> &'static str {
2457 "/login"
2458 }
2459 fn description(&self) -> &'static str {
2460 "Save an Anthropic API key"
2461 }
2462 fn execute(&self, ctx: &CommandContext, args: &str) {
2463 let key = args.trim();
2464 if key.is_empty() {
2465 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2466 } else {
2467 let result = crate::config::upsert_credential(
2468 "anthropic",
2469 crate::config::Credential::ApiKey {
2470 key: Some(key.to_string()),
2471 env: None,
2472 },
2473 );
2474 match result {
2475 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2476 Err(error) => {
2477 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2478 }
2479 }
2480 }
2481 ctx.tui.request_render(false);
2482 }
2483}
2484
2485struct LogoutCommand;
2486impl SlashCommand for LogoutCommand {
2487 fn name(&self) -> &'static str {
2488 "/logout"
2489 }
2490 fn description(&self) -> &'static str {
2491 "Remove saved Anthropic credentials"
2492 }
2493 fn execute(&self, ctx: &CommandContext, _args: &str) {
2494 match crate::config::delete_credential("anthropic") {
2495 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2496 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2497 Err(error) => {
2498 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2499 }
2500 }
2501 ctx.tui.request_render(false);
2502 }
2503}
2504
2505fn set_project_trust_for_command(
2506 cwd: &std::path::Path,
2507 value: Option<bool>,
2508) -> Result<(), crate::config::ConfigError> {
2509 crate::config::set_project_trust(cwd, value)
2510}
2511
2512struct TrustCommand;
2513impl SlashCommand for TrustCommand {
2514 fn name(&self) -> &'static str {
2515 "/trust"
2516 }
2517 fn description(&self) -> &'static str {
2518 "Trust the current project"
2519 }
2520 fn execute(&self, ctx: &CommandContext, args: &str) {
2521 let value = match args.trim().to_ascii_lowercase().as_str() {
2522 "" | "yes" | "y" | "true" => Some(true),
2523 "no" | "n" | "false" => Some(false),
2524 "clear" | "reset" | "none" => None,
2525 _ => {
2526 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2527 ctx.tui.request_render(false);
2528 return;
2529 }
2530 };
2531 match set_project_trust_for_command(&ctx.cwd, value) {
2532 Ok(()) => {
2533 let label = match value {
2534 Some(true) => "trusted",
2535 Some(false) => "untrusted",
2536 None => "trust decision cleared",
2537 };
2538 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2539 }
2540 Err(error) => add_error_message(
2541 &ctx.chat,
2542 &format!("Could not save trust decision: {error}"),
2543 ),
2544 }
2545 ctx.tui.request_render(false);
2546 }
2547}
2548
2549struct NameCommand;
2550impl SlashCommand for NameCommand {
2551 fn name(&self) -> &'static str {
2552 "/name"
2553 }
2554 fn description(&self) -> &'static str {
2555 "Set session display name"
2556 }
2557 fn execute(&self, ctx: &CommandContext, args: &str) {
2558 let name = args.trim();
2559 if name.is_empty() {
2560 add_note_message(
2561 &ctx.chat,
2562 "Usage: /name <display name> — sets the current session's name.",
2563 );
2564 ctx.tui.request_render(false);
2565 return;
2566 }
2567 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2568 }
2569}
2570
2571struct ImportCommand;
2572impl SlashCommand for ImportCommand {
2573 fn name(&self) -> &'static str {
2574 "/import"
2575 }
2576 fn description(&self) -> &'static str {
2577 "Import a session file (path)"
2578 }
2579 fn execute(&self, ctx: &CommandContext, args: &str) {
2580 let path = args.trim();
2581 if path.is_empty() {
2582 add_note_message(
2583 &ctx.chat,
2584 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2585 );
2586 ctx.tui.request_render(false);
2587 return;
2588 }
2589 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2590 }
2591}
2592
2593struct SettingsCommand;
2594impl SlashCommand for SettingsCommand {
2595 fn name(&self) -> &'static str {
2596 "/settings"
2597 }
2598 fn description(&self) -> &'static str {
2599 "Open settings menu"
2600 }
2601 fn execute(&self, ctx: &CommandContext, _args: &str) {
2602 open_settings_selector(
2603 &ctx.state,
2604 &ctx.editor_container,
2605 &ctx.editor,
2606 &ctx.tui,
2607 &ctx.lane,
2608 &ctx.model_catalog,
2609 &ctx.lane_model_id,
2610 &ctx.chat,
2611 &ctx.cwd,
2612 &ctx.package_resources,
2613 );
2614 }
2615}
2616
2617struct ScopedModelsCommand;
2618impl SlashCommand for ScopedModelsCommand {
2619 fn name(&self) -> &'static str {
2620 "/scoped-models"
2621 }
2622 fn description(&self) -> &'static str {
2623 "Choose models for Ctrl+M cycling"
2624 }
2625 fn execute(&self, ctx: &CommandContext, _args: &str) {
2626 open_scoped_models_selector(
2627 &ctx.state,
2628 &ctx.editor_container,
2629 &ctx.editor,
2630 &ctx.tui,
2631 &ctx.model_catalog,
2632 &ctx.chat,
2633 );
2634 }
2635}
2636
2637struct ShareCommand;
2638impl SlashCommand for ShareCommand {
2639 fn name(&self) -> &'static str {
2640 "/share"
2641 }
2642 fn description(&self) -> &'static str {
2643 "Share session (gist via gh, or clipboard)"
2644 }
2645 fn execute(&self, ctx: &CommandContext, _args: &str) {
2646 let _ = ctx.tx.send(TuiMessage::ShareSession);
2647 }
2648}
2649
2650struct ArminCommand;
2651impl SlashCommand for ArminCommand {
2652 fn name(&self) -> &'static str {
2653 "/armin"
2654 }
2655 fn description(&self) -> &'static str {
2656 "??? (easter egg)"
2657 }
2658 fn execute(&self, ctx: &CommandContext, _args: &str) {
2659 crate::extras::add_armin(&ctx.chat);
2660 ctx.tui.request_render(false);
2661 }
2662}
2663
2664struct EarendilCommand;
2665impl SlashCommand for EarendilCommand {
2666 fn name(&self) -> &'static str {
2667 "/earendil"
2668 }
2669 fn description(&self) -> &'static str {
2670 "Announcement"
2671 }
2672 fn execute(&self, ctx: &CommandContext, _args: &str) {
2673 crate::extras::add_earendil(&ctx.chat);
2674 ctx.tui.request_render(false);
2675 }
2676}
2677
2678struct ContextCommand;
2682impl SlashCommand for ContextCommand {
2683 fn name(&self) -> &'static str {
2684 "/context"
2685 }
2686 fn visible(&self) -> bool {
2687 false
2688 }
2689 fn execute(&self, ctx: &CommandContext, _args: &str) {
2690 show_context_panel(&ctx.chat, &ctx.resources);
2691 ctx.tui.request_render(false);
2692 }
2693}
2694
2695struct ReloadCommand;
2706impl SlashCommand for ReloadCommand {
2707 fn name(&self) -> &'static str {
2708 "/reload"
2709 }
2710 fn description(&self) -> &'static str {
2711 "Reload extensions, skills, prompts"
2712 }
2713 fn execute(&self, ctx: &CommandContext, _args: &str) {
2714 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2718 ctx.tui.request_render(false);
2719 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2720 }
2721}
2722
2723fn build_builtin_registry() -> CommandRegistry {
2728 let mut r = CommandRegistry::new();
2729 r.register(Arc::new(HelpCommand));
2730 r.register(Arc::new(ClearChatCommand));
2731 r.register(Arc::new(ExitCommand));
2732 r.register(Arc::new(VersionCommand));
2733 r.register(Arc::new(ChangelogCommand));
2734 r.register(Arc::new(ModelCommand));
2735 r.register(Arc::new(ThinkingCommand));
2736 r.register(Arc::new(ToolsCommand));
2737 r.register(Arc::new(ImagesCommand));
2738 r.register(Arc::new(SessionCommand));
2739 r.register(Arc::new(ThemeCommand));
2740 r.register(Arc::new(CompactCommand));
2741 r.register(Arc::new(CopyCommand));
2742 r.register(Arc::new(HotkeysCommand));
2743 r.register(Arc::new(ArminCommand));
2744 r.register(Arc::new(EarendilCommand));
2745 r.register(Arc::new(ContextCommand));
2746 r.register(Arc::new(NameCommand));
2750 r.register(Arc::new(SettingsCommand));
2751 r.register(Arc::new(ScopedModelsCommand));
2752 r.register(Arc::new(ExportCommand));
2753 r.register(Arc::new(ImportCommand));
2754 r.register(Arc::new(ShareCommand));
2755 r.register(Arc::new(ForkCommand));
2756 r.register(Arc::new(CloneCommand));
2757 r.register(Arc::new(TreeCommand));
2758 r.register(Arc::new(TrustCommand));
2759 r.register(Arc::new(LoginCommand));
2760 r.register(Arc::new(LogoutCommand));
2761 r.register(Arc::new(ReloadCommand));
2762 r
2763}
2764
2765fn register_extension_commands(
2766 registry: &mut CommandRegistry,
2767 session: crate::session::ExtensionSessionCell,
2768) {
2769 let commands = session
2770 .lock()
2771 .ok()
2772 .and_then(|s| s.snapshot_arc())
2773 .map(|snap| snap.commands().to_vec())
2774 .unwrap_or_default();
2775 for command in commands {
2776 let name = if command.name.starts_with('/') {
2777 command.name.clone()
2778 } else {
2779 format!("/{}", command.name)
2780 };
2781 if registry.find(&name).is_some() {
2782 continue;
2783 }
2784 registry.register(Arc::new(ExtensionCommand {
2785 name,
2786 description: command.description,
2787 session: session.clone(),
2788 }));
2789 }
2790}
2791
2792fn register_js_extension_commands(
2793 registry: &mut CommandRegistry,
2794 session: Option<crate::js_extensions::JsExtensionSession>,
2795) {
2796 let Some(session) = session else {
2797 return;
2798 };
2799 for command in &session.commands {
2800 let name = if command.starts_with('/') {
2801 command.clone()
2802 } else {
2803 format!("/{command}")
2804 };
2805 if registry.find(&name).is_none() {
2806 registry.register(Arc::new(JsExtensionCommand {
2807 name,
2808 session: session.clone(),
2809 }));
2810 }
2811 }
2812}
2813
2814enum TuiMessage {
2821 UserInput(String),
2822 OpenTree,
2823 NavigateTree(String),
2824 Exit,
2825 ClearChat,
2827 Compact,
2829 Copy,
2831 SwitchSession(String),
2834 ExportSession,
2836 ForkSession,
2838 SetSessionName(String),
2840 ImportSession(String),
2843 ShareSession,
2846 ReloadExtensions,
2851 ExternalEditorResult(Result<String, String>),
2855}
2856
2857fn assistant_text(msg: &AssistantMessage) -> String {
2860 msg.content
2861 .iter()
2862 .filter_map(|c| match c {
2863 Content::Text(t) => Some(t.text.clone()),
2864 _ => None,
2865 })
2866 .collect()
2867}
2868
2869fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2872 msg.content
2873 .iter()
2874 .filter_map(|c| match c {
2875 Content::Text(t) => Some(t.text.clone()),
2876 _ => None,
2877 })
2878 .collect::<Vec<_>>()
2879 .join("\n")
2880}
2881
2882fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2883 match &msg.content {
2884 rpi_ai::types::UserContent::Text(s) => s.clone(),
2885 rpi_ai::types::UserContent::Blocks(blocks) => blocks
2886 .iter()
2887 .filter_map(|c| match c {
2888 Content::Text(t) => Some(t.text.clone()),
2889 _ => None,
2890 })
2891 .collect(),
2892 }
2893}
2894
2895fn show_settings_panel(chat: &Arc<Container>) {
2900 let s = crate::settings::load_settings().unwrap_or_default();
2901 let mut lines: Vec<String> = Vec::new();
2902 lines.push("⚙️ Saved settings:".into());
2903 lines.push(format!(
2904 " Theme: {} (edit with /theme)",
2905 s.theme.as_deref().unwrap_or("(default)")
2906 ));
2907 lines.push(format!(
2908 " Default model: {} (set at launch with --model)",
2909 s.default_model.as_deref().unwrap_or("(none)")
2910 ));
2911 lines.push(format!(
2912 " Default thinking: {} (set at launch with --thinking)",
2913 s.default_thinking_level.as_deref().unwrap_or("(default)")
2914 ));
2915 match &s.scoped_models {
2916 Some(list) if !list.is_empty() => lines.push(format!(
2917 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
2918 list.join(", ")
2919 )),
2920 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
2921 }
2922 let body = lines.join("\n");
2923 container_note_block(chat, &body);
2924}
2925
2926fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2930 let scoped = crate::settings::load_settings()
2931 .ok()
2932 .and_then(|s| s.scoped_models)
2933 .unwrap_or_default();
2934 if scoped.is_empty() {
2935 return catalog.to_vec();
2936 }
2937 let mut out: Vec<rpi_ai::Model> = catalog
2938 .iter()
2939 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2940 .cloned()
2941 .collect();
2942 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2944 if let Some(cur) = catalog
2945 .iter()
2946 .find(|m| m.id.eq_ignore_ascii_case(current_id))
2947 {
2948 out.push(cur.clone());
2949 }
2950 }
2951 out
2952}
2953
2954fn open_settings_selector(
2961 state: &Arc<TuiState>,
2962 editor_container: &Arc<Container>,
2963 editor: &Arc<Editor>,
2964 tui: &Arc<TuiAltScreen>,
2965 lane: &Arc<dyn AgentLane>,
2966 catalog: &[rpi_ai::Model],
2967 lane_model_id: &str,
2968 chat: &Arc<Container>,
2969 cwd: &std::path::Path,
2970 package_resources: &Arc<crate::packages::PackageResources>,
2971) {
2972 let settings = crate::settings::load_settings().unwrap_or_default();
2973 let mut items: Vec<SelectItem> = Vec::new();
2974 items.push(
2975 SelectItem::new("theme", "Theme")
2976 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
2977 );
2978 items.push(
2979 SelectItem::new("model", "Default model").with_description(
2980 &settings
2981 .default_model
2982 .clone()
2983 .unwrap_or_else(|| "(none)".into()),
2984 ),
2985 );
2986 items.push(
2987 SelectItem::new("thinking", "Default thinking").with_description(
2988 &settings
2989 .default_thinking_level
2990 .clone()
2991 .unwrap_or_else(|| "(default)".into()),
2992 ),
2993 );
2994 let scope_desc = match &settings.scoped_models {
2995 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
2996 _ => "all models".to_string(),
2997 };
2998 items
2999 .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
3000 let list = Arc::new(SelectList::new(items, 10));
3001
3002 let state_sel = state.clone();
3003 let ec_sel = editor_container.clone();
3004 let editor_sel = editor.clone();
3005 let tui_sel = tui.clone();
3006 let lane_sel = lane.clone();
3007 let chat_sel = chat.clone();
3008 let catalog_sel = catalog.to_vec();
3009 let lane_model_sel = lane_model_id.to_string();
3010 let cwd_sel = cwd.to_path_buf();
3011 let package_resources_sel = package_resources.clone();
3012 list.on_select(Arc::new(move |item| {
3013 match item.value.as_str() {
3016 "theme" => open_settings_theme_selector(
3017 &state_sel,
3018 &ec_sel,
3019 &editor_sel,
3020 &tui_sel,
3021 &chat_sel,
3022 &cwd_sel,
3023 &package_resources_sel,
3024 ),
3025 "model" => open_settings_model_selector(
3026 &state_sel,
3027 &ec_sel,
3028 &editor_sel,
3029 &tui_sel,
3030 &lane_sel,
3031 &catalog_sel,
3032 &lane_model_sel,
3033 &chat_sel,
3034 ),
3035 "thinking" => open_settings_thinking_selector(
3036 &state_sel,
3037 &ec_sel,
3038 &editor_sel,
3039 &tui_sel,
3040 &lane_sel,
3041 &catalog_sel,
3042 &lane_model_sel,
3043 &chat_sel,
3044 ),
3045 "scoped-models" => open_scoped_models_selector(
3046 &state_sel,
3047 &ec_sel,
3048 &editor_sel,
3049 &tui_sel,
3050 &catalog_sel,
3051 &chat_sel,
3052 ),
3053 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3054 }
3055 }));
3056 let state_cancel = state.clone();
3057 let ec_cancel = editor_container.clone();
3058 let editor_cancel = editor.clone();
3059 let tui_cancel = tui.clone();
3060 list.on_cancel(Arc::new(move || {
3061 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3062 }));
3063
3064 open_selector(
3065 state,
3066 editor_container,
3067 editor,
3068 tui,
3069 list,
3070 SelectorKind::Settings,
3071 );
3072}
3073
3074fn open_settings_theme_selector(
3076 state: &Arc<TuiState>,
3077 editor_container: &Arc<Container>,
3078 editor: &Arc<Editor>,
3079 tui: &Arc<TuiAltScreen>,
3080 chat: &Arc<Container>,
3081 cwd: &std::path::Path,
3082 package_resources: &Arc<crate::packages::PackageResources>,
3083) {
3084 let mut items = vec![
3085 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3086 SelectItem::new("light", "Light").with_description("Light background"),
3087 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3088 ];
3089 if state.themes_enabled {
3090 for path in package_resources.theme_files() {
3091 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3092 items.push(SelectItem::new(name, name).with_description("Package theme"));
3093 }
3094 }
3095 }
3096 let list = Arc::new(SelectList::new(items, 10));
3097
3098 let state_sel = state.clone();
3099 let ec_sel = editor_container.clone();
3100 let editor_sel = editor.clone();
3101 let tui_sel = tui.clone();
3102 let chat_sel = chat.clone();
3103 let cwd_sel = cwd.to_path_buf();
3104 let package_resources_sel = package_resources.clone();
3105 list.on_select(Arc::new(move |item| {
3106 let preset = match item.value.as_str() {
3107 "light" => Some(ThemePreset::Light),
3108 "monochrome" => Some(ThemePreset::Monochrome),
3109 "dark" => Some(ThemePreset::Dark),
3110 name => {
3111 if state_sel.themes_enabled {
3112 if let Ok(custom) = crate::packages::load_theme_with_resources(
3113 &cwd_sel,
3114 name,
3115 &package_resources_sel,
3116 ) {
3117 rpi_tui::global_theme_manager().set(custom.clone());
3118 state_sel.theme_manager.set(custom);
3119 }
3120 }
3121 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3122 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3123 tui_sel.render_now(true);
3124 return;
3125 }
3126 };
3127 let Some(preset) = preset else { return };
3128 apply_theme_preset(preset);
3129 state_sel.theme_manager.apply_preset(preset);
3130 let mut settings = crate::settings::load_settings().unwrap_or_default();
3131 settings.theme = Some(item.value.clone());
3132 let saved = crate::settings::save_settings(&settings);
3133 add_note_message(
3134 &chat_sel,
3135 &format!(
3136 "Theme set to {} (saved{})",
3137 item.label,
3138 if saved.is_ok() { "" } else { ", not saved" },
3139 ),
3140 );
3141 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3142 tui_sel.render_now(true);
3143 }));
3144 let state_cancel = state.clone();
3145 let ec_cancel = editor_container.clone();
3146 let editor_cancel = editor.clone();
3147 let tui_cancel = tui.clone();
3148 list.on_cancel(Arc::new(move || {
3149 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3150 }));
3151
3152 open_selector(
3153 state,
3154 editor_container,
3155 editor,
3156 tui,
3157 list,
3158 SelectorKind::Settings,
3159 );
3160}
3161
3162fn open_settings_model_selector(
3166 state: &Arc<TuiState>,
3167 editor_container: &Arc<Container>,
3168 editor: &Arc<Editor>,
3169 tui: &Arc<TuiAltScreen>,
3170 lane: &Arc<dyn AgentLane>,
3171 catalog: &[rpi_ai::Model],
3172 lane_model_id: &str,
3173 chat: &Arc<Container>,
3174) {
3175 let items = model_selector_items(catalog, lane_model_id);
3176 if items.is_empty() {
3177 add_note_message(chat, "No models in the catalog.");
3178 tui.request_render(false);
3179 return;
3180 }
3181 let list = Arc::new(SelectList::new(items, 10));
3182
3183 let catalog_arc = catalog.to_vec();
3184 let state_sel = state.clone();
3185 let ec_sel = editor_container.clone();
3186 let editor_sel = editor.clone();
3187 let tui_sel = tui.clone();
3188 let chat_sel = chat.clone();
3189 let lane_sel = lane.clone();
3190 list.on_select(Arc::new(move |item| {
3191 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3192 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3193 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3194 return;
3195 };
3196 state_sel.set_current_model(&model);
3197 let lane = lane_sel.clone();
3198 tokio::spawn(async move {
3199 let _ = lane.set_model(model).await;
3200 });
3201 let mut settings = crate::settings::load_settings().unwrap_or_default();
3202 settings.default_model = Some(item.value.clone());
3203 let saved = crate::settings::save_settings(&settings);
3204 add_note_message(
3205 &chat_sel,
3206 &format!(
3207 "Default model set to {} (saved{}",
3208 short_model_name(&item.value),
3209 if saved.is_ok() { ")" } else { ", not saved)" },
3210 ),
3211 );
3212 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3213 }));
3214 let state_cancel = state.clone();
3215 let ec_cancel = editor_container.clone();
3216 let editor_cancel = editor.clone();
3217 let tui_cancel = tui.clone();
3218 list.on_cancel(Arc::new(move || {
3219 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3220 }));
3221
3222 open_selector(
3223 state,
3224 editor_container,
3225 editor,
3226 tui,
3227 list,
3228 SelectorKind::Settings,
3229 );
3230}
3231
3232fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3237 let mut seen = std::collections::HashSet::new();
3238 catalog
3239 .iter()
3240 .filter(|m| {
3241 seen.insert((
3242 m.api.clone(),
3243 m.provider.to_ascii_lowercase(),
3244 m.id.to_ascii_lowercase(),
3245 ))
3246 })
3247 .map(|m| {
3248 let label = if m.name.is_empty() {
3249 short_model_name(&m.id)
3250 } else {
3251 m.name.clone()
3252 };
3253 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3254 && m.provider.eq_ignore_ascii_case("anthropic")
3255 {
3256 m.id.clone()
3257 } else {
3258 format!("{}/{}", m.provider, m.id)
3259 };
3260 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3261 " (current)"
3262 } else {
3263 ""
3264 };
3265 SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
3266 })
3267 .collect()
3268}
3269
3270fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3274 let (provider, id) = input
3275 .split_once('/')
3276 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3277 .map_or((None, input), |(provider, id)| (Some(provider), id));
3278 catalog
3279 .iter()
3280 .find(|model| {
3281 model.id.eq_ignore_ascii_case(id)
3282 && provider.map_or(true, |provider| {
3283 model.provider.eq_ignore_ascii_case(provider)
3284 || (provider.eq_ignore_ascii_case("anthropic")
3285 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3286 })
3287 })
3288 .cloned()
3289}
3290
3291fn open_settings_thinking_selector(
3295 state: &Arc<TuiState>,
3296 editor_container: &Arc<Container>,
3297 editor: &Arc<Editor>,
3298 tui: &Arc<TuiAltScreen>,
3299 lane: &Arc<dyn AgentLane>,
3300 catalog: &[rpi_ai::Model],
3301 lane_model_id: &str,
3302 chat: &Arc<Container>,
3303) {
3304 let model = catalog
3305 .iter()
3306 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3307 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3308 .map(|m| m.supported_thinking_levels())
3309 .unwrap_or_else(|| {
3310 use rpi_ai::types::ThinkingLevel::*;
3311 vec![Off, Minimal, Low, Medium, High]
3312 });
3313 let mut items: Vec<SelectItem> = Vec::new();
3314 for lvl in &levels {
3315 let name = thinking_level_name(*lvl);
3316 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3317 }
3318 if items.is_empty() {
3319 add_note_message(chat, "This model has no supported thinking levels.");
3320 tui.request_render(false);
3321 return;
3322 }
3323 let list = Arc::new(SelectList::new(items, 10));
3324
3325 let state_sel = state.clone();
3326 let ec_sel = editor_container.clone();
3327 let editor_sel = editor.clone();
3328 let tui_sel = tui.clone();
3329 let chat_sel = chat.clone();
3330 let lane_sel = lane.clone();
3331 list.on_select(Arc::new(move |item| {
3332 let Some(level) = thinking_level_from_name(&item.value) else {
3333 add_note_message(
3334 &chat_sel,
3335 &format!("Unknown thinking level: {}.", item.label),
3336 );
3337 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3338 return;
3339 };
3340 let lane = lane_sel.clone();
3341 let footer_sel = state_sel.footer.clone();
3342 tokio::spawn(async move {
3343 let _ = lane.set_thinking_level(level).await;
3344 });
3345 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3346 let mut settings = crate::settings::load_settings().unwrap_or_default();
3347 settings.default_thinking_level = Some(item.value.clone());
3348 let saved = crate::settings::save_settings(&settings);
3349 add_note_message(
3350 &chat_sel,
3351 &format!(
3352 "Default thinking set to {} (saved{}",
3353 item.label,
3354 if saved.is_ok() { ")" } else { ", not saved)" },
3355 ),
3356 );
3357 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3358 }));
3359 let state_cancel = state.clone();
3360 let ec_cancel = editor_container.clone();
3361 let editor_cancel = editor.clone();
3362 let tui_cancel = tui.clone();
3363 list.on_cancel(Arc::new(move || {
3364 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3365 }));
3366
3367 open_selector(
3368 state,
3369 editor_container,
3370 editor,
3371 tui,
3372 list,
3373 SelectorKind::Settings,
3374 );
3375}
3376
3377fn open_scoped_models_selector(
3382 state: &Arc<TuiState>,
3383 editor_container: &Arc<Container>,
3384 editor: &Arc<Editor>,
3385 tui: &Arc<TuiAltScreen>,
3386 catalog: &[rpi_ai::Model],
3387 chat: &Arc<Container>,
3388) {
3389 if catalog.is_empty() {
3390 add_note_message(chat, "No models in the catalog.");
3391 tui.request_render(false);
3392 return;
3393 }
3394 let seed: Vec<String> = crate::settings::load_settings()
3396 .ok()
3397 .and_then(|s| s.scoped_models)
3398 .unwrap_or_default();
3399 *state.scoped_edit.lock().unwrap() = Some(seed);
3400
3401 let mut items: Vec<SelectItem> = Vec::new();
3402 for m in catalog {
3403 items.push(SelectItem::new(&m.id, &m.id));
3404 }
3405 let list = Arc::new(SelectList::new(items, 10));
3406
3407 let state_sel = state.clone();
3408 let chat_sel = chat.clone();
3409 let tui_sel = tui.clone();
3410 list.on_select(Arc::new(move |item| {
3411 let mut set = state_sel.scoped_edit.lock().unwrap();
3413 let set = set.get_or_insert_with(Vec::new);
3414 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3415 set.remove(pos);
3416 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3417 } else {
3418 set.push(item.value.clone());
3419 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3420 }
3421 tui_sel.request_render(false);
3422 }));
3423 let state_cancel = state.clone();
3424 let ec_cancel = editor_container.clone();
3425 let editor_cancel = editor.clone();
3426 let tui_cancel = tui.clone();
3427 let chat_cancel = chat.clone();
3428 list.on_cancel(Arc::new(move || {
3429 let set = state_cancel
3431 .scoped_edit
3432 .lock()
3433 .unwrap()
3434 .take()
3435 .unwrap_or_default();
3436 let mut settings = crate::settings::load_settings().unwrap_or_default();
3437 settings.scoped_models = if set.is_empty() {
3438 None
3439 } else {
3440 Some(set.clone())
3441 };
3442 match crate::settings::save_settings(&settings) {
3443 Ok(()) => {
3444 if set.is_empty() {
3445 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3446 } else {
3447 add_note_message(
3448 &chat_cancel,
3449 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3450 );
3451 }
3452 }
3453 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3454 }
3455 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3456 }));
3457
3458 open_selector(
3459 state,
3460 editor_container,
3461 editor,
3462 tui,
3463 list,
3464 SelectorKind::ScopedModels,
3465 );
3466}
3467
3468async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3472 use std::process::Stdio;
3473
3474 let tree = harness.session().view("main");
3476 let entries = match tree
3477 .find_entries(&EntryQuery {
3478 entry_type: None,
3479 custom_type: None,
3480 order: Some(EntryOrder::OldestFirst),
3483 limit: None,
3484 cursor: None,
3485 })
3486 .await
3487 {
3488 Ok(e) => e,
3489 Err(e) => {
3490 add_error_message(chat, &format!("Could not read session: {e}"));
3491 return;
3492 }
3493 };
3494 let mut md = String::from("# Session\n\n");
3495 for e in entries {
3496 let Entry::Message(me) = e else { continue };
3497 match &me.message {
3498 AgentMessage::User(u) => {
3499 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3500 }
3501 AgentMessage::Assistant(a) => {
3502 let text = assistant_text(a);
3503 if !text.is_empty() {
3504 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3505 }
3506 }
3507 _ => {}
3508 }
3509 }
3510
3511 let gh = std::process::Command::new("gh")
3513 .arg("gist")
3514 .arg("create")
3515 .arg("--filename")
3516 .arg("session.md")
3517 .arg("-")
3518 .stdin(Stdio::piped())
3519 .stdout(Stdio::piped())
3520 .stderr(Stdio::null())
3521 .spawn();
3522 if let Ok(mut child) = gh {
3523 use std::io::Write;
3524 if let Some(mut stdin) = child.stdin.take() {
3525 let _ = stdin.write_all(md.as_bytes());
3526 let _ = stdin.flush();
3527 }
3528 let out = child.wait_with_output().ok();
3529 if let Some(out) = out {
3530 if out.status.success() {
3531 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3532 add_note_message(chat, &format!("Shared session: {url}"));
3533 return;
3534 }
3535 }
3536 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3537 } else {
3538 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3539 }
3540 if copy_to_clipboard(&md) {
3542 add_note_message(chat, "Session transcript copied to the clipboard.");
3543 } else {
3544 add_note_message(
3545 chat,
3546 "Clipboard unavailable — use /export to write the transcript to a file.",
3547 );
3548 }
3549}
3550
3551async fn export_session(harness: &AgentHarness, chat: &Arc<Container>, cwd: &std::path::Path) {
3560 let tree = harness.session().view("main");
3561 let entries = match tree
3562 .find_entries(&EntryQuery {
3563 entry_type: None,
3564 custom_type: None,
3565 order: Some(EntryOrder::OldestFirst),
3568 limit: None,
3569 cursor: None,
3570 })
3571 .await
3572 {
3573 Ok(e) => e,
3574 Err(e) => {
3575 add_error_message(chat, &format!("Could not read session: {e}"));
3576 return;
3577 }
3578 };
3579 let name = tree.get_name().await.ok().flatten().unwrap_or_default();
3580 let id = tree
3581 .get_leaf_id()
3582 .await
3583 .ok()
3584 .flatten()
3585 .unwrap_or_else(|| "session".to_string());
3586 let mut md = String::from("# Session\n\n");
3587 for e in entries {
3588 let Entry::Message(me) = e else { continue };
3589 match &me.message {
3590 AgentMessage::User(u) => {
3591 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3592 }
3593 AgentMessage::Assistant(a) => {
3594 let text = assistant_text(a);
3595 if !text.is_empty() {
3596 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3597 }
3598 }
3599 _ => {}
3600 }
3601 }
3602 let file_name = if name.is_empty() {
3603 format!("{id}.md")
3604 } else {
3605 format!("{name}.md")
3606 };
3607 let path = cwd.join(&file_name);
3608 match std::fs::write(&path, md) {
3609 Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
3610 Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
3611 }
3612}
3613
3614async fn switch_to_session(
3624 harness: &AgentHarness,
3625 lane: &Arc<dyn AgentLane>,
3626 id: &str,
3627 cwd: &std::path::Path,
3628 chat: &Arc<Container>,
3629 state: &Arc<TuiState>,
3630) -> bool {
3631 if *state.status.lock().unwrap() == RunStatus::Working {
3632 state.set_status(RunStatus::Aborting);
3633 let _ = lane.abort().await;
3634 }
3635 let cwd_str = cwd.to_string_lossy().to_string();
3636 match crate::session::open_session_by_id(id, &cwd_str).await {
3637 Ok(new_session) => {
3638 let _ = harness.set_session(new_session).await;
3639 chat.clear();
3640 add_welcome_message(chat);
3641 render_session_history(
3642 harness,
3643 chat,
3644 state.markdown_transformer(),
3645 Some(state.extension_session.clone()),
3646 )
3647 .await;
3648 state.set_status(RunStatus::Idle);
3649 add_note_message(chat, &format!("Switched to session {id}."));
3650 true
3651 }
3652 Err(e) => {
3653 state.set_status(RunStatus::Idle);
3654 add_error_message(chat, &format!("Could not open session {id}: {e}"));
3655 false
3656 }
3657 }
3658}
3659
3660async fn import_session(
3664 harness: &AgentHarness,
3665 lane: &Arc<dyn AgentLane>,
3666 path: &str,
3667 cwd: &std::path::Path,
3668 chat: &Arc<Container>,
3669 state: &Arc<TuiState>,
3670) {
3671 use std::path::Path as FsPath;
3672
3673 let src = FsPath::new(path);
3674 if !src.is_file() {
3675 add_error_message(chat, &format!("Import source not found: {path}"));
3676 return;
3677 }
3678 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3679 add_error_message(chat, "Import source has no file name.");
3680 return;
3681 };
3682 if !fname.ends_with(".jsonl") {
3683 add_error_message(chat, "Import source must be a .jsonl session file.");
3684 return;
3685 }
3686 let dir = crate::session::default_session_dir(cwd);
3687 if let Err(e) = std::fs::create_dir_all(&dir) {
3688 add_error_message(chat, &format!("Could not create session dir: {e}"));
3689 return;
3690 }
3691 let dest = dir.join(fname);
3692 match std::fs::copy(src, &dest) {
3693 Ok(_) => {
3694 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3695 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3696 add_note_message(chat, &format!("Imported session from {path}"));
3697 }
3698 }
3699 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3700 }
3701}
3702
3703async fn fork_session(
3704 harness: &AgentHarness,
3705 cwd: &std::path::Path,
3706 chat: &Arc<Container>,
3707 state: &Arc<TuiState>,
3708) {
3709 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3710 use rpi_tools::FileSystem;
3711
3712 let cwd_str = cwd.to_string_lossy().to_string();
3713 let dir = crate::session::default_session_dir(cwd);
3714 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3715 let fs: Arc<dyn FileSystem> = env.clone();
3716 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3717 fs,
3718 sessions_root: dir.to_string_lossy().into_owned(),
3719 clock: Arc::new(rpi_harness::session::memory::SystemClock),
3720 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3721 });
3722 let id = harness.session().storage().metadata().id.clone();
3725 let metas = match crate::session::list_session_metadata(&cwd_str).await {
3726 Ok(m) => m,
3727 Err(e) => {
3728 add_error_message(chat, &format!("Could not list sessions: {e}"));
3729 return;
3730 }
3731 };
3732 let Some(source) = metas.iter().find(|m| m.id == id) else {
3733 add_error_message(chat, &format!("Current session {id} not found on disk."));
3734 return;
3735 };
3736 let fork_storage = match repo
3737 .fork_typed(
3738 source,
3739 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3740 id: None,
3741 parent_session_id: Some(source.id.clone()),
3742 cwd: cwd_str.clone(),
3743 metadata: None,
3744 },
3745 &rpi_harness::session::types::ForkOptions::default(),
3746 )
3747 .await
3748 {
3749 Ok(s) => s,
3750 Err(e) => {
3751 add_error_message(chat, &format!("Could not fork session: {e}"));
3752 return;
3753 }
3754 };
3755 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3756 let _ = harness.set_session(new_session).await;
3757 chat.clear();
3758 add_welcome_message(chat);
3759 render_session_history(
3760 harness,
3761 chat,
3762 state.markdown_transformer(),
3763 Some(state.extension_session.clone()),
3764 )
3765 .await;
3766 state.set_status(RunStatus::Idle);
3767 add_note_message(chat, "Forked into a new session.");
3768}
3769
3770async fn render_session_history(
3780 harness: &AgentHarness,
3781 chat: &Arc<Container>,
3782 transformer: Option<MarkdownTransformer>,
3783 extension_session: Option<crate::session::ExtensionSessionCell>,
3784) {
3785 let tree = harness.session().view("main");
3786 let entries = match tree
3787 .find_entries(&EntryQuery {
3788 entry_type: None,
3789 custom_type: None,
3790 order: Some(EntryOrder::OldestFirst),
3794 limit: None,
3795 cursor: None,
3796 })
3797 .await
3798 {
3799 Ok(e) => e,
3800 Err(_) => return,
3801 };
3802 let mut rendered_any = false;
3803 for e in entries {
3804 match e {
3805 Entry::Message(me) => match &me.message {
3806 AgentMessage::User(u) => {
3807 add_user_message(chat, &user_message_text(u));
3808 rendered_any = true;
3809 }
3810 AgentMessage::Assistant(a) => {
3811 let comp = Arc::new(AssistantMessageComponent::new(
3812 AssistantMessageOptions::default(),
3813 ));
3814 if let Some(t) = &transformer {
3815 comp.set_markdown_transformer(Some(t.clone()));
3816 }
3817 comp.update_blocks(&assistant_blocks(a));
3818 chat.add_child(comp);
3819 chat.add_child(Arc::new(Spacer::new(1)));
3822 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3823 add_note_message(chat, &text);
3824 }
3825 if let Some(error) = assistant_error_text(a) {
3826 add_error_message(chat, &error);
3827 }
3828 rendered_any = true;
3829 }
3830 AgentMessage::ToolResult(result) => {
3831 let comp = Arc::new(ToolExecutionComponent::new(
3836 &result.tool_name,
3837 "",
3838 ));
3839 comp.set_result(&tool_result_message_text(result), result.is_error);
3840 chat.add_child(comp);
3841 chat.add_child(Arc::new(Spacer::new(1)));
3842 rendered_any = true;
3843 }
3844 AgentMessage::Custom(custom) => {
3845 if let Some(session) = &extension_session {
3846 if let Some(component) = extension_message_component(
3847 session,
3848 &custom.role,
3849 &serde_json::json!({
3850 "customType": custom.role,
3851 "content": custom.content,
3852 "details": custom.data,
3853 }),
3854 transformer.clone(),
3855 ) {
3856 chat.add_child(component);
3857 chat.add_child(Arc::new(Spacer::new(1)));
3858 rendered_any = true;
3859 continue;
3860 }
3861 }
3862 add_note_message(chat, &custom_message_fallback(&custom));
3863 rendered_any = true;
3864 }
3865 },
3866 Entry::Compaction(compaction) => {
3867 add_note_message(
3868 chat,
3869 &format!(
3870 "Compacted {} tokens: {}",
3871 compaction.tokens_before, compaction.summary
3872 ),
3873 );
3874 rendered_any = true;
3875 }
3876 Entry::BranchSummary(summary) => {
3877 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3878 rendered_any = true;
3879 }
3880 Entry::Custom(custom) => {
3881 let rendered = extension_session.as_ref().and_then(|session| {
3882 extension_entry_component(session, &custom.custom_type, custom.data.clone())
3883 });
3884 if let Some(component) = rendered {
3885 chat.add_child(component);
3886 chat.add_child(Arc::new(Spacer::new(1)));
3887 rendered_any = true;
3888 } else if let Some(text) =
3889 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3890 {
3891 add_note_message(chat, &text);
3892 rendered_any = true;
3893 }
3894 }
3895 Entry::ModelChange(change) => {
3896 add_note_message(
3897 chat,
3898 &format!("Model changed to {}:{}", change.provider, change.model_id),
3899 );
3900 rendered_any = true;
3901 }
3902 Entry::ThinkingLevel(change) => {
3903 add_note_message(
3904 chat,
3905 &format!("Thinking level: {:?}", change.thinking_level),
3906 );
3907 rendered_any = true;
3908 }
3909 Entry::ActiveTools(change) => {
3910 add_note_message(
3911 chat,
3912 &format!("Active tools: {}", change.active_tool_names.join(", ")),
3913 );
3914 rendered_any = true;
3915 }
3916 }
3917 }
3918 if rendered_any {
3919 }
3922}
3923
3924fn invoke_extension_renderer(
3925 session: &crate::session::ExtensionSessionCell,
3926 kind: rpi_extensions::RegisteredRendererKind,
3927 payload: &serde_json::Value,
3928) -> Option<serde_json::Value> {
3929 let snapshot = session.lock().ok()?.snapshot_arc()?;
3930 let input = serde_json::to_string(payload).ok()?;
3931 for renderer in snapshot.renderers_of(kind) {
3932 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3933 let mut out = rpi_plugin_sdk::StbString::empty();
3934 let rc = (renderer.render_fn)(
3935 rpi_plugin_sdk::StbStringRef::from_str(&input),
3936 &mut out as *mut rpi_plugin_sdk::StbString,
3937 renderer.user_data,
3938 );
3939 let text = if rc == 0 {
3940 Some(out.to_string_lossy())
3941 } else {
3942 None
3943 };
3944 out.free_with(Some(renderer.plugin_free_string));
3945 text
3946 }))
3947 .ok()
3948 .flatten();
3949 let Some(text) = outcome else { continue };
3950 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3951 return Some(value);
3952 }
3953 }
3954 None
3955}
3956
3957fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3958 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3959 let text = lines
3960 .iter()
3961 .filter_map(|line| line.as_str())
3962 .collect::<Vec<_>>()
3963 .join("\n");
3964 return Some(Arc::new(Text::new(text, 0, 0)));
3965 }
3966 let text = value.get("text").and_then(|v| v.as_str())?;
3967 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3968 let component = Arc::new(AssistantMessageComponent::new(
3969 AssistantMessageOptions::default(),
3970 ));
3971 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3972 Some(component)
3973 } else {
3974 Some(Arc::new(Text::new(text, 0, 0)))
3975 }
3976}
3977
3978fn extension_message_component(
3979 session: &crate::session::ExtensionSessionCell,
3980 custom_type: &str,
3981 payload: &serde_json::Value,
3982 transformer: Option<MarkdownTransformer>,
3983) -> Option<Arc<dyn rpi_tui::Component>> {
3984 let value = invoke_extension_renderer(
3985 session,
3986 rpi_extensions::RegisteredRendererKind::Message,
3987 payload,
3988 )?;
3989 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3990 let text = value.get("text").and_then(|v| v.as_str())?;
3991 let component = Arc::new(AssistantMessageComponent::new(
3992 AssistantMessageOptions::default(),
3993 ));
3994 if let Some(transformer) = transformer {
3995 component.set_markdown_transformer(Some(transformer));
3996 }
3997 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3998 return Some(component);
3999 }
4000 extension_text_component(&value)
4001 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4002}
4003
4004fn extension_usage_text(
4007 session: Option<&crate::session::ExtensionSessionCell>,
4008 usage: &rpi_ai::types::Usage,
4009) -> Option<String> {
4010 let session = session?;
4011 let payload = serde_json::json!({
4012 "customType": "token-usage",
4013 "usage": usage,
4014 });
4015 let value = invoke_extension_renderer(
4016 session,
4017 rpi_extensions::RegisteredRendererKind::Message,
4018 &payload,
4019 )?;
4020 value
4021 .get("text")
4022 .and_then(|value| value.as_str())
4023 .filter(|text| !text.trim().is_empty())
4024 .map(ToOwned::to_owned)
4025}
4026
4027fn extension_entry_component(
4028 session: &crate::session::ExtensionSessionCell,
4029 custom_type: &str,
4030 data: Option<serde_json::Value>,
4031) -> Option<Arc<dyn rpi_tui::Component>> {
4032 let payload = serde_json::json!({
4033 "customType": custom_type,
4034 "data": data,
4035 });
4036 let value = invoke_extension_renderer(
4037 session,
4038 rpi_extensions::RegisteredRendererKind::Entry,
4039 &payload,
4040 )?;
4041 extension_text_component(&value)
4042}
4043
4044fn launch_restores_history(args: &Args) -> bool {
4050 args.continue_session
4051 || args.resume
4052 || args.session.is_some()
4053 || args.session_id.is_some()
4054 || args.fork.is_some()
4055}
4056
4057fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4058 msg.content
4059 .iter()
4060 .filter_map(|c| match c {
4061 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4062 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4063 Content::Image(image) => base64::engine::general_purpose::STANDARD
4064 .decode(&image.data)
4065 .ok()
4066 .filter(|data| !data.is_empty())
4067 .map(AssistantBlock::Image),
4068 _ => None,
4069 })
4070 .collect()
4071}
4072
4073fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4074 let content = custom
4075 .content
4076 .iter()
4077 .filter_map(|item| match item {
4078 Content::Text(text) => Some(text.text.as_str()),
4079 _ => None,
4080 })
4081 .collect::<Vec<_>>()
4082 .join("\n");
4083 if content.is_empty() {
4084 format!("{}: {}", custom.role, custom.data)
4085 } else {
4086 format!("{}: {}", custom.role, content)
4087 }
4088}
4089
4090fn short_model_name(id: &str) -> String {
4093 id.rsplit([':', '/'])
4094 .next()
4095 .filter(|s| !s.is_empty())
4096 .unwrap_or(id)
4097 .to_string()
4098}
4099
4100#[derive(Clone, Copy, PartialEq, Eq)]
4106enum RunStatus {
4107 Idle,
4108 Working,
4109 Aborting,
4110}
4111
4112#[derive(Clone, Copy, PartialEq, Eq)]
4114enum SelectorKind {
4115 Model,
4117 Thinking,
4119 Tools,
4121 Images,
4123 Session,
4125 Theme,
4127 ScopedModels,
4129 Settings,
4131 Tree,
4133 Extension,
4135}
4136
4137struct TuiState {
4140 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4142 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4144 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4148 themes_enabled: bool,
4150 hide_thinking: std::sync::Mutex<bool>,
4152 tool_outputs_expanded: std::sync::Mutex<bool>,
4154 show_terminal_progress: bool,
4156 status: std::sync::Mutex<RunStatus>,
4158 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4162 footer: Arc<FooterComponent>,
4164 status_container: Arc<Container>,
4167 chat_container: Arc<Container>,
4169 loader: Arc<Loader>,
4171 last_assistant_text: std::sync::Mutex<String>,
4174 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
4178 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4180 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4182 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4184 autocomplete: AutocompleteManager,
4187 autocomplete_container: Arc<Container>,
4190 autocomplete_max_visible: usize,
4192 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4194 theme_manager: Arc<ThemeManager>,
4198 tui: Option<Arc<TuiAltScreen>>,
4202 current_model_id: std::sync::Mutex<String>,
4206 show_images: std::sync::Mutex<bool>,
4210 history: std::sync::Mutex<Vec<String>>,
4213 history_index: std::sync::Mutex<isize>,
4216 history_draft: std::sync::Mutex<Option<String>>,
4219 last_input_tokens: std::sync::Mutex<i64>,
4223 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4226 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4236 extension_session: crate::session::ExtensionSessionCell,
4238}
4239
4240const HISTORY_LIMIT: usize = 100;
4243
4244const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
4247
4248const PAGE_SCROLL_OVERLAP: usize = 4;
4251
4252const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4256
4257fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4260 let mut modifiers = KeyModifiers::NONE;
4261 let mut key = None;
4262 for part in value.trim().to_ascii_lowercase().split('+') {
4263 match part {
4264 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4265 "shift" => modifiers |= KeyModifiers::SHIFT,
4266 "alt" | "option" => modifiers |= KeyModifiers::ALT,
4267 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4268 part if !part.is_empty() => key = Some(part.to_string()),
4269 _ => {}
4270 }
4271 }
4272 let key = key?;
4273 let code = match key.as_str() {
4274 "esc" | "escape" => KeyCode::Esc,
4275 "enter" | "return" => KeyCode::Enter,
4276 "tab" => {
4277 if modifiers.contains(KeyModifiers::SHIFT) {
4278 return Some(rpi_tui::KeyCombo::new(
4279 KeyCode::BackTab,
4280 modifiers & !KeyModifiers::SHIFT,
4281 ));
4282 }
4283 KeyCode::Tab
4284 }
4285 "backspace" | "back" => KeyCode::Backspace,
4286 "delete" | "del" => KeyCode::Delete,
4287 "up" | "arrowup" => KeyCode::Up,
4288 "down" | "arrowdown" => KeyCode::Down,
4289 "left" | "arrowleft" => KeyCode::Left,
4290 "right" | "arrowright" => KeyCode::Right,
4291 "home" => KeyCode::Home,
4292 "end" => KeyCode::End,
4293 "pageup" | "page-up" => KeyCode::PageUp,
4294 "pagedown" | "page-down" => KeyCode::PageDown,
4295 "space" => KeyCode::Char(' '),
4296 "f1" => KeyCode::F(1),
4297 "f2" => KeyCode::F(2),
4298 "f3" => KeyCode::F(3),
4299 "f4" => KeyCode::F(4),
4300 "f5" => KeyCode::F(5),
4301 "f6" => KeyCode::F(6),
4302 "f7" => KeyCode::F(7),
4303 "f8" => KeyCode::F(8),
4304 "f9" => KeyCode::F(9),
4305 "f10" => KeyCode::F(10),
4306 "f11" => KeyCode::F(11),
4307 "f12" => KeyCode::F(12),
4308 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4309 _ => return None,
4310 };
4311 Some(rpi_tui::KeyCombo::new(code, modifiers))
4312}
4313
4314fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4315 let mut bindings = rpi_tui::Keybindings::new();
4316 let settings = crate::settings::load_settings().unwrap_or_default();
4317 let Some(overrides) = settings.keybindings else {
4318 rpi_tui::set_keybindings(bindings.clone());
4319 return Arc::new(bindings);
4320 };
4321 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4322 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4323 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4324 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4325 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4326 (
4327 "app.model.cycleForward",
4328 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4329 ),
4330 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4331 (
4332 "app.thinking.toggle",
4333 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4334 ),
4335 (
4336 "app.editor.external",
4337 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4338 ),
4339 (
4340 "app.thinking.cycle",
4341 rpi_tui::keybindings::keys::THINKING_CYCLE,
4342 ),
4343 (
4344 "app.clipboard.pasteImage",
4345 rpi_tui::keybindings::keys::PASTE_IMAGE,
4346 ),
4347 ];
4348 for (name, id) in known {
4349 let Some(value) = overrides.get(*name) else {
4350 continue;
4351 };
4352 let values: Vec<String> = match value {
4353 serde_json::Value::String(value) => vec![value.clone()],
4354 serde_json::Value::Array(values) => values
4355 .iter()
4356 .filter_map(|v| v.as_str().map(str::to_string))
4357 .collect(),
4358 serde_json::Value::Null => Vec::new(),
4359 _ => continue,
4360 };
4361 let combos: Vec<_> = values
4362 .iter()
4363 .filter_map(|value| parse_configured_key(value))
4364 .collect();
4365 if values.is_empty() || !combos.is_empty() {
4366 bindings.set(id, combos);
4367 }
4368 }
4369 rpi_tui::set_keybindings(bindings.clone());
4370 Arc::new(bindings)
4371}
4372
4373fn keybinding_matches(
4374 bindings: &rpi_tui::Keybindings,
4375 event: &crossterm::event::KeyEvent,
4376 id: rpi_tui::KeybindingId,
4377) -> bool {
4378 if bindings.matches(event, id) {
4379 return true;
4380 }
4381 if event.code == KeyCode::BackTab {
4384 let normalized =
4385 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4386 bindings.matches(&normalized, id)
4387 } else {
4388 false
4389 }
4390}
4391
4392fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4393 last.is_some_and(|previous| {
4394 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4395 })
4396}
4397
4398fn transcript_page_size(viewport_height: usize) -> i32 {
4399 viewport_height
4400 .saturating_sub(PAGE_SCROLL_OVERLAP)
4401 .max(1)
4402 .min(i32::MAX as usize) as i32
4403}
4404
4405fn should_dispatch_key(kind: KeyEventKind) -> bool {
4406 kind != KeyEventKind::Release
4407}
4408
4409fn format_tokens(n: i64) -> String {
4411 if n >= 1_000_000 {
4412 format!("{:.1}M", n as f64 / 1_000_000.0)
4413 } else if n >= 1_000 {
4414 format!("{:.1}K", n as f64 / 1_000.0)
4415 } else {
4416 n.to_string()
4417 }
4418}
4419
4420fn push_history(state: &Arc<TuiState>, text: &str) {
4424 let trimmed = text.trim().to_string();
4425 if trimmed.is_empty() {
4426 return;
4427 }
4428 let mut history = state.history.lock().unwrap();
4429 if history.first() == Some(&trimmed) {
4430 return;
4431 }
4432 history.insert(0, trimmed);
4433 history.truncate(HISTORY_LIMIT);
4434 *state.history_index.lock().unwrap() = -1;
4435 *state.history_draft.lock().unwrap() = None;
4436}
4437
4438fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4443 let history = state.history.lock().unwrap();
4444 if history.is_empty() {
4445 return;
4446 }
4447 let mut index = state.history_index.lock().unwrap();
4448 let new_index = *index - direction as isize;
4449 if new_index < -1 || new_index >= history.len() as isize {
4450 return;
4451 }
4452 if *index == -1 && new_index >= 0 {
4453 *state.history_draft.lock().unwrap() = Some(editor.get_text());
4455 }
4456 *index = new_index;
4457 if new_index == -1 {
4458 let draft = state.history_draft.lock().unwrap().take();
4460 match draft {
4461 Some(d) => {
4462 let len = d.len();
4463 editor.set_text(&d);
4464 editor.set_cursor(0, len);
4465 }
4466 None => editor.set_text(""),
4467 }
4468 } else {
4469 let text = history[new_index as usize].clone();
4470 let len = text.len();
4471 editor.set_text(&text);
4472 editor.set_cursor(0, len);
4473 }
4474}
4475
4476impl TuiState {
4477 fn begin_js_preparation(&self) -> CancellationToken {
4478 let cancellation = CancellationToken::new();
4479 if let Some(previous) = self
4480 .js_preparation_cancel
4481 .lock()
4482 .unwrap()
4483 .replace(cancellation.clone())
4484 {
4485 previous.cancel();
4486 }
4487 cancellation
4488 }
4489
4490 fn finish_js_preparation(&self) {
4491 self.js_preparation_cancel.lock().unwrap().take();
4492 }
4493
4494 fn cancel_js_preparation(&self) -> bool {
4495 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4496 if let Some(cancellation) = cancellation {
4497 cancellation.cancel();
4498 true
4499 } else {
4500 false
4501 }
4502 }
4503
4504 fn set_status(&self, status: RunStatus) {
4505 *self.status.lock().unwrap() = status;
4506 self.apply_status(status);
4507 }
4508
4509 fn try_start_working(&self) -> bool {
4514 let mut status = self.status.lock().unwrap();
4515 if *status != RunStatus::Idle {
4516 return false;
4517 }
4518 *status = RunStatus::Working;
4519 drop(status);
4520 self.apply_status(RunStatus::Working);
4521 true
4522 }
4523
4524 fn apply_status(&self, status: RunStatus) {
4525 match status {
4526 RunStatus::Working => {
4527 self.footer.set_status("");
4531 if let Some(tui) = &self.tui {
4534 tui.set_title("rpi — working");
4535 }
4536 self.status_container.clear();
4537 if self.show_terminal_progress {
4538 self.loader.start();
4539 self.status_container.add_child(self.loader.clone());
4540 }
4541 }
4542 RunStatus::Aborting => {
4543 self.footer.set_status("Aborting…");
4544 self.loader.stop();
4547 self.status_container.clear();
4548 }
4549 RunStatus::Idle => {
4550 self.footer.set_status("");
4551 if let Some(tui) = &self.tui {
4552 tui.set_title("rpi");
4553 }
4554 self.loader.stop();
4555 self.status_container.clear();
4556 }
4557 }
4558 }
4559
4560 fn sync_working_loader_with_bash(&self) {
4564 if *self.status.lock().unwrap() != RunStatus::Working {
4565 return;
4566 }
4567
4568 self.status_container.clear();
4569 if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4570 self.status_container.add_child(self.loader.clone());
4571 }
4572 }
4573
4574 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4575 *self.status.lock().unwrap() = RunStatus::Working;
4576 self.footer.set_status("");
4577 if let Some(tui) = &self.tui {
4578 tui.set_title("rpi — retrying");
4579 }
4580 self.loader.stop();
4581 self.status_container.clear();
4582 self.status_container
4583 .add_child(Arc::new(StatusIndicator::retry(
4584 attempt,
4585 max_retries,
4586 std::time::Duration::from_millis(delay_ms),
4587 )));
4588 }
4589
4590 fn selector_open(&self) -> bool {
4592 self.active_selector.lock().unwrap().is_some()
4593 }
4594
4595 fn extension_editor_open(&self) -> bool {
4596 self.active_extension_editor.lock().unwrap().is_some()
4597 }
4598
4599 fn extension_input_open(&self) -> bool {
4600 self.active_extension_input.lock().unwrap().is_some()
4601 }
4602
4603 fn extension_dialog_open(&self) -> bool {
4604 self.extension_editor_open() || self.extension_input_open()
4605 }
4606
4607 fn set_hide_thinking(&self, hide: bool) {
4608 *self.hide_thinking.lock().unwrap() = hide;
4609 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4610 comp.set_hide_thinking(hide);
4611 }
4612 }
4613
4614 fn hide_thinking(&self) -> bool {
4615 *self.hide_thinking.lock().unwrap()
4616 }
4617
4618 fn toggle_thinking(&self) -> bool {
4619 let next = !self.hide_thinking();
4620 self.set_hide_thinking(next);
4621 next
4622 }
4623
4624 fn toggle_tool_outputs(&self) -> bool {
4625 let next = !*self.tool_outputs_expanded.lock().unwrap();
4626 *self.tool_outputs_expanded.lock().unwrap() = next;
4627 for comp in self.tool_components.lock().unwrap().values() {
4628 comp.set_expanded(next);
4629 }
4630 for comp in self.bash_components.lock().unwrap().values() {
4631 comp.set_expanded(next);
4632 }
4633 next
4634 }
4635
4636 fn current_model_id(&self) -> String {
4638 self.current_model_id.lock().unwrap().clone()
4639 }
4640
4641 fn set_current_model(&self, model: &rpi_ai::Model) {
4644 *self.current_model_id.lock().unwrap() = model.id.clone();
4645 self.footer.set_model(&short_model_name(&model.id));
4646 }
4647
4648 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4652 self.markdown_transformer.lock().unwrap().clone()
4653 }
4654
4655 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4664 *self.markdown_transformer.lock().unwrap() = transformer.clone();
4665 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4666 comp.set_markdown_transformer(transformer);
4667 }
4668 }
4669
4670 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4671 self.pending_images.lock().unwrap().push(image);
4672 }
4673
4674 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4675 std::mem::take(&mut *self.pending_images.lock().unwrap())
4676 }
4677}
4678
4679#[derive(Debug, PartialEq)]
4684struct TuiStartupSettings {
4685 editor_padding_x: usize,
4686 autocomplete_max_visible: usize,
4687 hide_thinking: bool,
4688 quiet_startup: bool,
4689 show_terminal_progress: bool,
4690}
4691
4692fn preferred_project_setting<T>(
4693 project_settings: &[crate::settings::Settings],
4694 field: impl Fn(&crate::settings::Settings) -> Option<T>,
4695) -> Option<T> {
4696 project_settings.iter().find_map(field)
4697}
4698
4699fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4700 verbose || !quiet_startup
4701}
4702
4703fn git_only_update_resources(
4704 mut resources: crate::packages::PackageResources,
4705) -> crate::packages::PackageResources {
4706 resources
4707 .packages
4708 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
4709 resources
4710}
4711
4712fn resolve_tui_startup_settings(
4716 global: &crate::settings::Settings,
4717 project_settings: &[crate::settings::Settings],
4718 project_trusted: bool,
4719) -> TuiStartupSettings {
4720 let project_settings = if project_trusted {
4721 project_settings
4722 } else {
4723 &[]
4724 };
4725 let editor_padding_x =
4726 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4727 .or(global.editor_padding_x)
4728 .unwrap_or(1)
4729 .min(16);
4730 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4731 settings.autocomplete_max_visible
4732 })
4733 .or(global.autocomplete_max_visible)
4734 .unwrap_or(5)
4735 .clamp(1, 20);
4736 let hide_thinking =
4737 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4738 .or(global.hide_thinking_block)
4739 .unwrap_or(false);
4740 let quiet_startup =
4741 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4742 .or(global.quiet_startup)
4743 .unwrap_or(false);
4744 let show_terminal_progress =
4745 preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
4746 .or(global.show_terminal_progress)
4747 .unwrap_or(true);
4748
4749 TuiStartupSettings {
4750 editor_padding_x,
4751 autocomplete_max_visible,
4752 hide_thinking,
4753 quiet_startup,
4754 show_terminal_progress,
4755 }
4756}
4757
4758pub async fn interactive_tui(
4772 harness: &AgentHarness,
4773 event_rx: Option<broadcast::Receiver<AgentEvent>>,
4774 args: &Args,
4775 model_catalog: Vec<rpi_ai::Model>,
4776 initial: Option<String>,
4777 extra_messages: &[String],
4778 initial_images: Vec<rpi_ai::types::ImageContent>,
4779 theme: Option<&str>,
4780 no_themes: bool,
4781 reload_context: &crate::session::ReloadContext,
4782) -> i32 {
4783 let lane: Arc<dyn AgentLane> = harness.lane("main");
4784 let cwd = reload_context.cwd.clone();
4788 let saved_settings = crate::settings::load_settings().unwrap_or_default();
4789 let project_trusted = reload_context.project_trusted;
4790 let project_settings = if project_trusted {
4791 crate::settings::load_project_settings(&cwd)
4792 } else {
4793 Vec::new()
4794 };
4795 let tui_settings =
4796 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
4797 let editor_padding_x = tui_settings.editor_padding_x;
4798 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
4799 let hide_thinking = tui_settings.hide_thinking;
4800 let show_terminal_progress = tui_settings.show_terminal_progress;
4801 let quiet_startup = tui_settings.quiet_startup;
4802 let update_checks_enabled =
4803 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
4804
4805 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4809 let model_name = short_model_name(&lane_model_id);
4810
4811 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4815 if active_tool_names.is_empty() {
4819 active_tool_names = harness
4820 .get_tools()
4821 .await
4822 .unwrap_or_default()
4823 .into_iter()
4824 .map(|tool| tool.tool.schema().name.clone())
4825 .collect();
4826 }
4827 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4828 let skill_names: Vec<String> = resources_snapshot
4829 .skills
4830 .as_deref()
4831 .unwrap_or(&[])
4832 .iter()
4833 .map(|skill| skill.name.clone())
4834 .collect();
4835
4836 let package_resources = reload_context.package_resources.clone();
4838
4839 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4841
4842 let theme_manager = Arc::new(ThemeManager::new());
4846 if let Some(preset) = match theme {
4847 Some("light") => Some(ThemePreset::Light),
4848 Some("monochrome") => Some(ThemePreset::Monochrome),
4849 Some("dark") => Some(ThemePreset::Dark),
4850 _ => None,
4851 } {
4852 apply_theme_preset(preset);
4853 theme_manager.apply_preset(preset);
4854 } else if !no_themes {
4855 if let Some(name) = theme {
4856 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4857 Ok(custom) => {
4858 rpi_tui::global_theme_manager().set(custom.clone());
4859 theme_manager.set(custom);
4860 }
4861 Err(error) => {
4862 eprintln!("warning: could not load package theme `{name}`: {error}");
4863 }
4864 }
4865 }
4866 }
4867
4868 let terminal = Box::new(ProcessTerminal::new());
4870 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4871 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4872 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
4877 ask_user_bridge.attach();
4878 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4879
4880 if let Some(js) = &reload_context.js_extension_session {
4881 if let Err(error) = js.install_ui_runtime(tui.clone()) {
4882 eprintln!("warning: could not enable JS custom UI bridge: {error}");
4883 } else if let Some(js_active) = js.active_tools() {
4884 let js_names = js.tool_names();
4888 let mut active = lane.get_active_tools().await.unwrap_or_default();
4889 active.retain(|name| {
4890 crate::session::tool_name_allowed(name, args)
4891 && !js_names.iter().any(|js_name| js_name == name)
4892 });
4893 active.extend(js_active.into_iter().filter(|name| {
4894 js_names.iter().any(|js_name| js_name == name)
4895 && crate::session::tool_name_allowed(name, args)
4896 }));
4897 active = crate::session::filter_active_tool_names(active, args);
4898 let _ = lane.set_active_tools(active).await;
4899 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4900 }
4901 }
4902
4903 let chat_container = Arc::new(Container::new());
4904 if let Some(js) = &reload_context.js_extension_session {
4905 let chat_notify = chat_container.clone();
4909 let tui_notify = tui.clone();
4910 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
4911 if action != "ui.notify" {
4912 return Err(format!("unsupported capability: {action}"));
4913 }
4914 let message = args
4915 .get("message")
4916 .and_then(serde_json::Value::as_str)
4917 .unwrap_or_default();
4918 if !message.is_empty() {
4919 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
4920 add_error_message(&chat_notify, message);
4921 } else {
4922 add_note_message(&chat_notify, message);
4923 }
4924 tui_notify.request_render(false);
4925 }
4926 Ok(serde_json::json!(true))
4927 })) {
4928 if args.verbose {
4929 eprintln!("warning: could not enable JS notification bridge: {error}");
4930 }
4931 }
4932 }
4933 if should_show_startup_listing(args.verbose, quiet_startup) {
4934 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
4935 }
4936
4937 crate::extras::maybe_first_time_setup(&chat_container);
4943
4944 let initial_transformer = build_markdown_transformer(
4949 reload_context
4950 .extension_session
4951 .lock()
4952 .unwrap()
4953 .snapshot_arc(),
4954 );
4955 if launch_restores_history(args) {
4959 render_session_history(
4960 &harness,
4961 &chat_container,
4962 initial_transformer.clone(),
4963 Some(reload_context.extension_session.clone()),
4964 )
4965 .await;
4966 }
4967
4968 let document_container = Arc::new(Container::new());
4971 document_container.add_child(chat_container.clone());
4972
4973 let scroll_view = Arc::new(ScrollView::new(
4974 document_container.clone(),
4975 ScrollViewOptions {
4976 follow: FollowMode::End,
4977 primary: true,
4978 overscroll: OverscrollMode::Chain,
4979 scrollbar: ScrollbarMode::Hidden,
4983 ..Default::default()
4984 },
4985 ));
4986
4987 let keybindings = configured_keybindings();
4992 let editor = Arc::new(Editor::new(
4993 EditorOptions {
4994 padding_x: editor_padding_x,
4995 autocomplete_max_visible,
4996 ..Default::default()
4997 },
4998 EditorStyle::default(),
4999 keybindings.clone(),
5000 ));
5001
5002 let footer = Arc::new(FooterComponent::new());
5004 footer.set_model(&model_name);
5005 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");
5006
5007 let status_container = Arc::new(Container::new());
5008 let loader = Arc::new(Loader::with_text("Working…"));
5009
5010 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5024 .prompt_templates
5025 .clone()
5026 .unwrap_or_default()
5027 .iter()
5028 .map(|t| SlashCommandEntry {
5029 name: format!("/{}", t.name),
5030 description: t
5031 .description
5032 .clone()
5033 .unwrap_or_else(|| "Expand prompt template".to_string()),
5034 })
5035 .collect();
5036 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5037 Arc::new(resources_snapshot);
5038 let mut command_registry = build_builtin_registry();
5044 register_extension_commands(
5045 &mut command_registry,
5046 reload_context.extension_session.clone(),
5047 );
5048 register_js_extension_commands(
5049 &mut command_registry,
5050 reload_context.js_extension_session.clone(),
5051 );
5052 let registry = Arc::new(command_registry);
5053 let mut all_slash_commands = registry.visible_entries();
5054 all_slash_commands.extend(template_slash_commands);
5055 let autocomplete = AutocompleteManager::new();
5056 {
5057 let mut combined = CombinedAutocompleteProvider::new();
5058 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5059 all_slash_commands,
5060 )));
5061 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5062 cwd.clone(),
5063 )));
5064 autocomplete.set_provider(Arc::new(combined));
5065 }
5066 let autocomplete_container = Arc::new(Container::new());
5067
5068 let state = Arc::new(TuiState {
5069 current_assistant: std::sync::Mutex::new(None),
5070 tool_components: std::sync::Mutex::new(HashMap::new()),
5071 bash_components: std::sync::Mutex::new(HashMap::new()),
5072 themes_enabled: !no_themes,
5073 hide_thinking: std::sync::Mutex::new(hide_thinking),
5074 tool_outputs_expanded: std::sync::Mutex::new(false),
5075 show_terminal_progress,
5076 status: std::sync::Mutex::new(RunStatus::Idle),
5077 js_preparation_cancel: std::sync::Mutex::new(None),
5078 footer: footer.clone(),
5079 status_container: status_container.clone(),
5080 chat_container: chat_container.clone(),
5081 loader: loader.clone(),
5082 last_assistant_text: std::sync::Mutex::new(String::new()),
5083 active_selector: std::sync::Mutex::new(None),
5084 active_extension_editor: std::sync::Mutex::new(None),
5085 active_extension_input: std::sync::Mutex::new(None),
5086 active_extension_cancel: std::sync::Mutex::new(None),
5087 autocomplete,
5088 autocomplete_container: autocomplete_container.clone(),
5089 autocomplete_max_visible,
5090 pending_images: std::sync::Mutex::new(Vec::new()),
5091 theme_manager,
5092 tui: Some(tui.clone()),
5093 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5094 show_images: std::sync::Mutex::new(true),
5095 history: std::sync::Mutex::new(Vec::new()),
5096 history_index: std::sync::Mutex::new(-1),
5097 history_draft: std::sync::Mutex::new(None),
5098 last_input_tokens: std::sync::Mutex::new(0),
5099 scoped_edit: std::sync::Mutex::new(None),
5100 markdown_transformer: std::sync::Mutex::new(initial_transformer),
5101 extension_session: reload_context.extension_session.clone(),
5102 });
5103
5104 let model_catalog_arc = Arc::new(model_catalog.clone());
5107 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5108
5109 let editor_container = Arc::new(Container::new());
5120 editor_container.add_child(editor.clone());
5121
5122 let dock = Arc::new(VStack::from_children(vec![
5123 StackChild::Entry(StackEntry::new(status_container.clone())),
5124 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5125 StackChild::Entry(
5126 StackEntry::new(editor_container.clone())
5127 .shrink(0)
5128 .min_size(3),
5129 ),
5130 StackChild::Entry(StackEntry::new(footer.clone())),
5131 ]));
5132
5133 let root = VStack::from_children(vec![
5134 StackChild::Entry(
5135 StackEntry::new(scroll_view.clone())
5136 .basis(0)
5137 .grow(1)
5138 .shrink(1)
5139 .min_size(1),
5140 ),
5141 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5142 ]);
5143
5144 tui.set_layout_root(Some(Arc::new(root)));
5145 tui.set_focus(Some(editor.clone()));
5146 editor.set_focused(true);
5147
5148 let ctx = CommandContext {
5160 chat: chat_container.clone(),
5161 tui: tui.clone(),
5162 tx: tx.clone(),
5163 state: state.clone(),
5164 editor: editor.clone(),
5165 editor_container: editor_container.clone(),
5166 lane: lane.clone(),
5167 model_catalog: model_catalog_arc.clone(),
5168 lane_model_id: lane_model_id.clone(),
5169 cwd: cwd.clone(),
5170 package_resources: package_resources.clone(),
5171 resources: resources_arc.clone(),
5172 reload_context: Arc::new(reload_context.clone()),
5173 };
5174
5175 if let Some(js) = &reload_context.js_extension_session {
5176 let bridge = js_dialog_bridge.clone();
5177 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5178 bridge.handle_runtime_request(action, args)
5179 })) {
5180 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5181 }
5182 }
5183
5184 let ctx_for_cb = ctx.clone();
5185 let registry_for_cb = registry.clone();
5186 editor.on_submit(Arc::new(move |text: &str| {
5187 let text = text.trim();
5188 if text.is_empty() {
5189 return;
5190 }
5191
5192 if text.starts_with('/') {
5193 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
5194 return;
5195 }
5196
5197 let run_status = *ctx_for_cb.state.status.lock().unwrap();
5198 if run_status != RunStatus::Idle {
5199 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5200 let lane = ctx_for_cb.lane.clone();
5201 let chat = ctx_for_cb.chat.clone();
5202 let tui = ctx_for_cb.tui.clone();
5203 tokio::spawn(async move {
5204 let result = match lane.steer(message.clone()).await {
5214 Ok(result) => Ok(result),
5215 Err(_) => lane.next_run(message).await,
5220 };
5221 if let Err(error) = result {
5222 add_error_message(&chat, &format!("Could not queue message: {error}"));
5223 tui.request_render(false);
5224 }
5225 });
5226 add_user_message(&ctx_for_cb.chat, text);
5231 ctx_for_cb.tui.request_render(false);
5232 return;
5233 }
5234
5235 if !ctx_for_cb.state.try_start_working() {
5236 return;
5237 }
5238
5239 add_user_message(&ctx_for_cb.chat, text);
5240 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5243 scroll.scroll_to_end();
5244 }
5245 ctx_for_cb.tui.request_render(false);
5246 push_history(&ctx_for_cb.state, text);
5249 if ctx_for_cb
5250 .tx
5251 .send(TuiMessage::UserInput(text.to_string()))
5252 .is_err()
5253 {
5254 ctx_for_cb.state.set_status(RunStatus::Idle);
5255 }
5256 }));
5257
5258 tui.start_readerless();
5259
5260 let mut update_check_handles = Vec::new();
5264 let rpi_chat = chat_container.clone();
5265 let rpi_tui = tui.clone();
5266 update_check_handles.push(tokio::spawn(async move {
5267 let report = crate::updates::check_rpi_startup().await;
5268 if !report.is_empty() {
5269 add_update_notices(&rpi_chat, &report);
5270 }
5271 rpi_tui.request_render(false);
5272 }));
5273
5274 if update_checks_enabled {
5275 let update_args = args.clone();
5276 let update_cwd = cwd.clone();
5277 let update_project_trusted = project_trusted;
5278 let chat = chat_container.clone();
5279 let tui = tui.clone();
5280 update_check_handles.push(tokio::spawn(async move {
5281 let resources = crate::session::package_resources_for_update_check(
5282 &update_args,
5283 &update_cwd,
5284 update_project_trusted,
5285 );
5286 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
5287 {
5288 Ok(npm_command) => {
5289 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
5290 Some(&resources),
5291 &npm_command,
5292 update_project_trusted.then_some(update_cwd.as_path()),
5293 )
5294 .await
5295 }
5296 Err(error) => {
5297 add_error_message(
5298 &chat,
5299 &format!("npm package update checks disabled: {error}"),
5300 );
5301 let git_resources = git_only_update_resources(resources);
5302 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
5303 }
5304 };
5305 if !report.is_empty() {
5306 add_update_notices(&chat, &report);
5307 }
5308 tui.request_render(false);
5309 }));
5310 }
5311
5312 let drain_handle = if let Some(rx) = event_rx {
5314 let tui_drain = tui.clone();
5315 let state_drain = state.clone();
5316 let chat_drain = chat_container.clone();
5317 Some(tokio::spawn(async move {
5318 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
5319 }))
5320 } else {
5321 None
5322 };
5323
5324 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5334 reload_context.mailbox.install(reload_sig_tx);
5335 let reload_tx = tx.clone();
5336 let reload_bridge_handle = tokio::spawn(async move {
5337 while reload_sig_rx.recv().await.is_some() {
5338 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5339 break; }
5341 }
5342 });
5343
5344 let tui_tick = tui.clone();
5349 let state_tick = state.clone();
5350 let tick_handle = tokio::spawn(async move {
5351 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
5354 interval.tick().await; loop {
5356 interval.tick().await;
5357 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5358 if working {
5359 if state_tick.bash_components.lock().unwrap().is_empty() {
5360 tui_tick.request_render_reusing_scroll_content();
5364 } else {
5365 tui_tick.request_render(false);
5367 }
5368 }
5369 }
5370 });
5371
5372 let running = Arc::new(std::sync::Mutex::new(true));
5374 let running_key = running.clone();
5375 let tx_for_key = tx.clone();
5376 let tui_for_key = tui.clone();
5377 let editor_for_key = editor.clone();
5378 let scroll_for_key = scroll_view.clone();
5379 let lane_for_key = lane.clone();
5380 let state_for_key = state.clone();
5381 let model_catalog_for_key = model_catalog_arc.clone();
5382 let js_for_key = reload_context.js_extension_session.clone();
5383 let js_dialog_for_key = js_dialog_bridge.clone();
5384 let ask_user_for_key = ask_user_bridge.clone();
5385 let ctx_for_key = ctx.clone();
5389 let registry_for_key = registry.clone();
5390 let keybindings_for_key = keybindings.clone();
5391 let double_escape_action = crate::settings::load_settings()
5392 .ok()
5393 .and_then(|settings| settings.double_escape_action)
5394 .unwrap_or_else(|| "tree".to_string())
5395 .to_ascii_lowercase();
5396
5397 let key_handle = tokio::task::spawn_blocking(move || {
5398 let mut last_escape_time = None;
5399 loop {
5400 if !*running_key.lock().unwrap() {
5401 break;
5402 }
5403 if !state_for_key.selector_open()
5404 && !state_for_key.extension_dialog_open()
5405 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
5406 {
5407 if let Some(request) = js_dialog_for_key.take_pending() {
5408 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
5409 } else if let Some(request) = ask_user_for_key.take_pending() {
5410 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5411 }
5412 }
5413 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
5414 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
5417 Ok(true) => {}
5418 Ok(false) => continue,
5419 Err(_) => {
5420 state_for_key.cancel_js_preparation();
5421 let _ = tx_for_key.send(TuiMessage::Exit);
5422 break;
5423 }
5424 }
5425 let Ok(ev) = crossterm::event::read() else {
5426 state_for_key.cancel_js_preparation();
5427 let _ = tx_for_key.send(TuiMessage::Exit);
5428 break;
5429 };
5430 if let Event::Resize(_cols, _rows) = ev {
5435 tui_for_key.refresh_size();
5436 if let Some(js) = &js_for_key {
5437 if js.custom_active() {
5438 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
5439 tui_for_key.request_render(false);
5440 }
5441 }
5442 continue;
5443 }
5444 if let Event::Mouse(m) = ev {
5448 use crossterm::event::MouseEventKind;
5449 match m.kind {
5450 MouseEventKind::ScrollUp => {
5451 let delta = -MOUSE_WHEEL_SCROLL_LINES;
5452 if scroll_for_key.scroll_by(delta) != delta {
5453 tui_for_key.request_render_reusing_scroll_content();
5454 }
5455 }
5456 MouseEventKind::ScrollDown => {
5457 let delta = MOUSE_WHEEL_SCROLL_LINES;
5458 if scroll_for_key.scroll_by(delta) != delta {
5459 tui_for_key.request_render_reusing_scroll_content();
5460 }
5461 }
5462 _ => {}
5463 }
5464 continue;
5465 }
5466 let Event::Key(key) = ev else {
5467 if let Event::Paste(text) = ev {
5468 let candidate = text.trim().trim_matches(['\"', '\'']);
5469 let path = std::path::PathBuf::from(candidate);
5470 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5471 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5472 add_image_preview(&state_for_key.chat_container, &image);
5473 state_for_key.queue_image(image);
5474 add_note_message(
5475 &state_for_key.chat_container,
5476 "Dropped image attached to the next prompt.",
5477 );
5478 tui_for_key.request_render(false);
5479 continue;
5480 }
5481 }
5482 editor_for_key.insert(&text);
5483 refresh_autocomplete(&state_for_key, &editor_for_key);
5484 tui_for_key.request_render_reusing_scroll_content();
5485 }
5486 continue;
5487 };
5488 if !should_dispatch_key(key.kind) {
5493 continue;
5494 }
5495
5496 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5502 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5503 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5504 if prompt_abort && state_for_key.cancel_js_preparation() {
5505 state_for_key.set_status(RunStatus::Aborting);
5506 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5507 close_extension_editor(
5508 &state_for_key,
5509 &ctx_for_key.editor_container,
5510 &editor_for_key,
5511 &tui_for_key,
5512 );
5513 }
5514 js_dialog_for_key.cancel_open_requests();
5515 tui_for_key.set_render_suspended(false);
5516 tui_for_key.request_render(false);
5517 continue;
5518 }
5519
5520 if let Some(js) = &js_for_key {
5521 if js.custom_active() {
5522 let visible = js.custom_accepts_input();
5523 let data = key_event_to_input(key);
5524 if !data.is_empty() {
5525 if visible {
5531 let _ = js.send_custom_input(&data);
5532 continue;
5533 }
5534 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5535 if consumed {
5539 tui_for_key.request_render_reusing_scroll_content();
5540 continue;
5541 }
5542 }
5543 }
5544 }
5545
5546 if key.modifiers == KeyModifiers::CONTROL
5549 && key.code == KeyCode::Char('c')
5550 && state_for_key.selector_open()
5551 {
5552 let selector = state_for_key
5553 .active_selector
5554 .lock()
5555 .unwrap()
5556 .clone()
5557 .expect("selector_open guaranteed Some")
5558 .0;
5559 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5560 tui_for_key.request_render_reusing_scroll_content();
5561 continue;
5562 }
5563
5564 if state_for_key.extension_dialog_open() {
5568 let cancel = key.code == KeyCode::Esc
5569 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5570 if cancel {
5571 if !run_extension_cancel(&state_for_key) {
5572 close_extension_editor(
5573 &state_for_key,
5574 &ctx_for_key.editor_container,
5575 &editor_for_key,
5576 &tui_for_key,
5577 );
5578 }
5579 } else if let Some(extension_editor) = state_for_key
5580 .active_extension_editor
5581 .lock()
5582 .unwrap()
5583 .clone()
5584 {
5585 extension_editor.handle_key(key);
5586 } else if let Some(extension_input) =
5587 state_for_key.active_extension_input.lock().unwrap().clone()
5588 {
5589 extension_input.handle_key(key);
5590 }
5591 tui_for_key.request_render_reusing_scroll_content();
5592 continue;
5593 }
5594
5595 if keybinding_matches(
5600 &keybindings_for_key,
5601 &key,
5602 rpi_tui::keybindings::keys::CLEAR,
5603 ) {
5604 if !state_for_key.selector_open() && editor_for_key.has_selection() {
5605 editor_for_key.copy_selection();
5606 continue;
5607 }
5608 let status = *state_for_key.status.lock().unwrap();
5609 match status {
5610 RunStatus::Working => {
5611 state_for_key.set_status(RunStatus::Aborting);
5612 ask_user_for_key.cancel_all();
5614 let lane = lane_for_key.clone();
5615 tokio::spawn(async move {
5616 let _ = lane.abort().await;
5617 });
5618 }
5619 RunStatus::Aborting => {}
5623 RunStatus::Idle => {
5624 let _ = tx_for_key.send(TuiMessage::Exit);
5625 }
5626 }
5627 continue;
5628 }
5629
5630 if state_for_key.selector_open() {
5635 if key.code == KeyCode::Esc {
5641 let (selector, _kind) = state_for_key
5642 .active_selector
5643 .lock()
5644 .unwrap()
5645 .clone()
5646 .expect("selector_open guaranteed Some");
5647 selector.handle_key(key);
5648 continue;
5649 }
5650 let (selector, _kind) = state_for_key
5651 .active_selector
5652 .lock()
5653 .unwrap()
5654 .clone()
5655 .expect("selector_open guaranteed Some");
5656 selector.handle_key(key);
5657 tui_for_key.request_render_reusing_scroll_content();
5658 continue;
5659 }
5660
5661 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5666 let status = *state_for_key.status.lock().unwrap();
5667 match status {
5668 RunStatus::Working => {
5669 state_for_key.set_status(RunStatus::Aborting);
5670 ask_user_for_key.cancel_all();
5671 let lane = lane_for_key.clone();
5672 tokio::spawn(async move {
5673 let _ = lane.abort().await;
5674 });
5675 continue;
5676 }
5677 RunStatus::Aborting => continue,
5678 RunStatus::Idle => {}
5679 }
5680 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5681 editor_for_key.handle_key(key);
5683 refresh_autocomplete(&state_for_key, &editor_for_key);
5684 tui_for_key.request_render_reusing_scroll_content();
5685 continue;
5686 }
5687 let _ = tx_for_key.send(TuiMessage::Exit);
5688 continue;
5689 }
5690
5691 if keybinding_matches(
5696 &keybindings_for_key,
5697 &key,
5698 rpi_tui::keybindings::keys::INTERRUPT,
5699 ) {
5700 let status = *state_for_key.status.lock().unwrap();
5701 if status == RunStatus::Working {
5702 state_for_key.set_status(RunStatus::Aborting);
5703 ask_user_for_key.cancel_all();
5704 let lane = lane_for_key.clone();
5705 tokio::spawn(async move {
5706 let _ = lane.abort().await;
5707 });
5708 continue;
5709 }
5710 if status == RunStatus::Idle
5711 && editor_for_key.get_text().trim().is_empty()
5712 && double_escape_action != "none"
5713 {
5714 let now = std::time::Instant::now();
5715 if double_escape_trigger(last_escape_time, now) {
5716 last_escape_time = None;
5717 match double_escape_action.as_str() {
5718 "tree" => {
5719 let _ = tx_for_key.send(TuiMessage::OpenTree);
5720 }
5721 "fork" => {
5722 let _ = tx_for_key.send(TuiMessage::ForkSession);
5723 }
5724 _ => {}
5725 }
5726 } else {
5727 last_escape_time = Some(now);
5728 }
5729 }
5730 continue;
5731 }
5732
5733 if keybinding_matches(
5736 &keybindings_for_key,
5737 &key,
5738 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5739 ) {
5740 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5741 continue;
5742 }
5743
5744 if keybinding_matches(
5747 &keybindings_for_key,
5748 &key,
5749 rpi_tui::keybindings::keys::TOOLS_EXPAND,
5750 ) {
5751 state_for_key.toggle_tool_outputs();
5752 tui_for_key.request_render(false);
5753 continue;
5754 }
5755
5756 if keybinding_matches(
5758 &keybindings_for_key,
5759 &key,
5760 rpi_tui::keybindings::keys::THINKING_TOGGLE,
5761 ) {
5762 state_for_key.toggle_thinking();
5763 tui_for_key.request_render(false);
5764 continue;
5765 }
5766
5767 if keybinding_matches(
5773 &keybindings_for_key,
5774 &key,
5775 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5776 ) {
5777 let current = state_for_key.current_model_id();
5778 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
5781 if let Some(next) = cycle_next_model(&scope, ¤t) {
5782 state_for_key.set_current_model(&next);
5783 let lane = lane_for_key.clone();
5784 tokio::spawn(async move {
5785 let _ = lane.set_model(next).await;
5786 });
5787 tui_for_key.request_render_reusing_scroll_content();
5788 }
5789 continue;
5790 }
5791
5792 if keybinding_matches(
5795 &keybindings_for_key,
5796 &key,
5797 rpi_tui::keybindings::keys::THINKING_CYCLE,
5798 ) {
5799 let lane = lane_for_key.clone();
5800 let catalog = model_catalog_for_key.clone();
5801 let state = state_for_key.clone();
5802 tokio::spawn(async move {
5803 let Ok(current_model) = lane.get_model().await else {
5804 return;
5805 };
5806 let levels = catalog
5807 .iter()
5808 .find(|model| {
5809 model.provider == current_model.provider && model.id == current_model.id
5810 })
5811 .map(|model| model.supported_thinking_levels())
5812 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5813 if levels.is_empty() {
5814 return;
5815 }
5816 let current = lane
5817 .get_thinking_level()
5818 .await
5819 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
5820 let next = levels
5821 .iter()
5822 .position(|level| *level == current)
5823 .map(|index| levels[(index + 1) % levels.len()])
5824 .unwrap_or(levels[0]);
5825 if lane.set_thinking_level(next).await.is_ok() {
5826 state
5827 .footer
5828 .set_thinking_level(Some(thinking_level_name(next)));
5829 state.tui.as_ref().map(|tui| tui.request_render(false));
5830 }
5831 });
5832 continue;
5833 }
5834
5835 if keybinding_matches(
5839 &keybindings_for_key,
5840 &key,
5841 rpi_tui::keybindings::keys::PASTE_IMAGE,
5842 ) {
5843 match read_clipboard_image() {
5844 Ok(Some(image)) => {
5845 add_image_preview(&state_for_key.chat_container, &image);
5846 state_for_key.queue_image(image);
5847 add_note_message(
5848 &state_for_key.chat_container,
5849 "Clipboard image attached to the next prompt.",
5850 );
5851 tui_for_key.request_render(false);
5852 continue;
5853 }
5854 Ok(None) | Err(_) => {}
5855 }
5856 }
5857
5858 if keybinding_matches(
5862 &keybindings_for_key,
5863 &key,
5864 rpi_tui::keybindings::keys::MODEL_SELECT,
5865 ) {
5866 if let Some(cmd) = registry_for_key.find("/model") {
5867 cmd.execute(&ctx_for_key, "");
5868 }
5869 continue;
5870 }
5871
5872 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
5874 if accept_top_suggestion(&state_for_key, &editor_for_key) {
5875 tui_for_key.request_render_reusing_scroll_content();
5876 }
5877 continue;
5878 }
5879
5880 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
5884 let delta = -transcript_page_size(scroll_for_key.viewport_height());
5885 if scroll_for_key.scroll_by(delta) != delta {
5886 tui_for_key.request_render_reusing_scroll_content();
5887 }
5888 continue;
5889 }
5890 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
5891 let delta = transcript_page_size(scroll_for_key.viewport_height());
5892 if scroll_for_key.scroll_by(delta) != delta {
5893 tui_for_key.request_render_reusing_scroll_content();
5894 }
5895 continue;
5896 }
5897 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
5898 scroll_for_key.scroll_to_start();
5899 tui_for_key.request_render_reusing_scroll_content();
5900 continue;
5901 }
5902 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
5903 scroll_for_key.scroll_to_end();
5904 tui_for_key.request_render_reusing_scroll_content();
5905 continue;
5906 }
5907
5908 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
5917 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5918 if editor_for_key.get_text().is_empty() || browsing {
5919 navigate_history(&state_for_key, &editor_for_key, -1);
5920 tui_for_key.request_render_reusing_scroll_content();
5921 continue;
5922 }
5923 }
5924 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
5925 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5926 if editor_for_key.get_text().is_empty() || browsing {
5927 navigate_history(&state_for_key, &editor_for_key, 1);
5928 tui_for_key.request_render_reusing_scroll_content();
5929 continue;
5930 }
5931 }
5932
5933 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
5937 let prompt = editor_for_key.get_text().trim().to_string();
5938 if prompt.is_empty() {
5939 continue;
5940 }
5941 editor_for_key.clear();
5942 let status = *state_for_key.status.lock().unwrap();
5943 if status == RunStatus::Idle {
5944 if state_for_key.try_start_working() {
5945 add_user_message(&state_for_key.chat_container, &prompt);
5946 push_history(&state_for_key, &prompt);
5947 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
5948 }
5949 } else {
5950 add_user_message(&state_for_key.chat_container, &prompt);
5953 let message = AgentMessage::User(UserMessage::new(prompt, 0));
5954 let lane = lane_for_key.clone();
5955 let chat = state_for_key.chat_container.clone();
5956 let tui = tui_for_key.clone();
5957 tokio::spawn(async move {
5958 if let Err(error) = lane.follow_up(message).await {
5959 add_error_message(&chat, &format!("Could not queue message: {error}"));
5960 tui.request_render(false);
5961 }
5962 });
5963 }
5964 tui_for_key.request_render(false);
5965 continue;
5966 }
5967
5968 editor_for_key.handle_key(key);
5970 refresh_autocomplete(&state_for_key, &editor_for_key);
5971 tui_for_key.request_render_reusing_scroll_content();
5972 }
5973 });
5974
5975 let mut prompts: Vec<String> = Vec::new();
5977 if let Some(init) = initial {
5978 prompts.push(init);
5979 }
5980 for m in extra_messages {
5981 prompts.push(m.clone());
5982 }
5983 let mut images = initial_images;
5984 for prompt in prompts {
5985 if !*running.lock().unwrap() {
5986 break;
5987 }
5988 add_user_message(&chat_container, &prompt);
5989 tui.request_render(false);
5990 run_prompt_streaming(
5991 &lane,
5992 &prompt,
5993 &tui,
5994 &state,
5995 drain_handle.is_some(),
5996 reload_context.js_extension_session.as_ref(),
5997 &js_dialog_bridge,
5998 args,
5999 std::mem::take(&mut images),
6000 )
6001 .await;
6002 }
6003
6004 loop {
6006 if !*running.lock().unwrap() {
6007 break;
6008 }
6009 match rx.recv().await {
6010 Some(TuiMessage::UserInput(prompt)) => {
6011 editor.clear();
6016 let prompt_images = state.take_pending_images();
6017 if !prompt_images.is_empty() {
6018 add_note_message(
6019 &chat_container,
6020 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6021 );
6022 }
6023 run_prompt_streaming(
6024 &lane,
6025 &prompt,
6026 &tui,
6027 &state,
6028 drain_handle.is_some(),
6029 reload_context.js_extension_session.as_ref(),
6030 &js_dialog_bridge,
6031 args,
6032 prompt_images,
6033 )
6034 .await;
6035 }
6036 Some(TuiMessage::ExternalEditorResult(result)) => {
6037 match result {
6038 Ok(text) => {
6039 let cursor = text.chars().count();
6040 editor.set_text(&text);
6041 editor.set_cursor(0, cursor);
6042 add_note_message(&chat_container, "Draft updated from external editor.");
6043 }
6044 Err(error) => add_error_message(&chat_container, &error),
6045 }
6046 tui.request_render(false);
6047 }
6048 Some(TuiMessage::OpenTree) => {
6049 if *state.status.lock().unwrap() != RunStatus::Idle {
6050 add_note_message(
6051 &chat_container,
6052 "Wait for the current run to finish before opening the tree.",
6053 );
6054 tui.request_render(false);
6055 } else {
6056 open_tree_selector(
6057 &harness,
6058 &state,
6059 &editor_container,
6060 &editor,
6061 &tui,
6062 &chat_container,
6063 &tx,
6064 )
6065 .await;
6066 }
6067 }
6068 Some(TuiMessage::NavigateTree(entry_id)) => {
6069 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6070 Ok(result) => match result.outcome {
6071 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6072 chat_container.clear();
6073 add_welcome_message(&chat_container);
6074 render_session_history(
6075 &harness,
6076 &chat_container,
6077 state.markdown_transformer(),
6078 Some(state.extension_session.clone()),
6079 )
6080 .await;
6081 add_note_message(
6082 &chat_container,
6083 "Moved to the selected session entry.",
6084 );
6085 }
6086 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6087 add_error_message(&chat_container, &error.message);
6088 }
6089 _ => add_note_message(
6090 &chat_container,
6091 "The selected entry could not be opened.",
6092 ),
6093 },
6094 Err(error) => add_error_message(
6095 &chat_container,
6096 &format!("Could not navigate session tree: {error}"),
6097 ),
6098 }
6099 tui.request_render(false);
6100 }
6101 Some(TuiMessage::ClearChat) => {
6102 chat_container.clear();
6103 add_welcome_message(&chat_container);
6104 tui.request_render(false);
6105 }
6106 Some(TuiMessage::Compact) => {
6107 run_compact(&lane, &tui, &state).await;
6108 }
6109 Some(TuiMessage::Copy) => {
6110 copy_last_assistant(&state, &chat_container);
6111 tui.request_render(false);
6112 }
6113 Some(TuiMessage::Exit) => {
6114 *running.lock().unwrap() = false;
6115 break;
6116 }
6117 Some(TuiMessage::SwitchSession(id)) => {
6118 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6119 tui.request_render(false);
6120 }
6121 Some(TuiMessage::ImportSession(path)) => {
6122 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6123 tui.request_render(false);
6124 }
6125 Some(TuiMessage::ShareSession) => {
6126 share_session(&harness, &chat_container).await;
6127 tui.request_render(false);
6128 }
6129 Some(TuiMessage::SetSessionName(name)) => {
6130 let outcome = harness.session().set_name(Some(&name)).await;
6131 match outcome {
6132 Ok(_) => add_note_message(
6133 &chat_container,
6134 &format!("Session renamed to \"{name}\"."),
6135 ),
6136 Err(e) => add_error_message(
6137 &chat_container,
6138 &format!("Could not rename session: {e}"),
6139 ),
6140 }
6141 tui.request_render(false);
6142 }
6143 Some(TuiMessage::ExportSession) => {
6144 export_session(&harness, &chat_container, &cwd).await;
6145 tui.request_render(false);
6146 }
6147 Some(TuiMessage::ForkSession) => {
6148 fork_session(&harness, &cwd, &chat_container, &state).await;
6149 tui.request_render(false);
6150 }
6151 Some(TuiMessage::ReloadExtensions) => {
6152 let reload_ctx = ctx.reload_context.clone();
6159 add_note_message(&chat_container, "Reloading extensions + resources…");
6160 tui.request_render(false);
6161 let outcome =
6162 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6163 let fresh_transformer = build_markdown_transformer(
6172 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6173 );
6174 state.set_markdown_transformer_with_reinstall(fresh_transformer);
6175 if outcome.had_warnings {
6176 add_error_message(
6177 &chat_container,
6178 &format!(
6179 "{} (with warnings — see stderr for details).",
6180 outcome.summary
6181 ),
6182 );
6183 } else {
6184 add_note_message(&chat_container, &outcome.summary);
6185 }
6186 tui.request_render(false);
6187 }
6188 None => break,
6189 }
6190 }
6191
6192 js_dialog_bridge.cancel_all();
6196 ask_user_bridge.shutdown();
6197 *running.lock().unwrap() = false;
6198 for handle in update_check_handles {
6199 handle.abort();
6200 let _ = handle.await;
6201 }
6202 if let Some(js) = &reload_context.js_extension_session {
6206 js.shutdown();
6207 }
6208 let _ = key_handle.await;
6211 tick_handle.abort();
6212 if let Some(handle) = drain_handle {
6213 handle.abort();
6214 }
6215 reload_context.mailbox.clear();
6219 reload_bridge_handle.abort();
6220 tui.stop(Default::default());
6221 println!("\nGoodbye!");
6222 let _ = args;
6223
6224 0
6225}
6226
6227async fn ensure_js_runtime_before_prompt(
6237 js: Option<&crate::js_extensions::JsExtensionSession>,
6238 lane: &Arc<dyn AgentLane>,
6239 state: &Arc<TuiState>,
6240 dialog_bridge: &JsDialogBridge,
6241 args: &Args,
6242) -> bool {
6243 let Some(js) = js else {
6244 return true;
6245 };
6246 let cancellation = state.begin_js_preparation();
6247 let worker_cancellation = cancellation.clone();
6248 let js_for_start = js.clone();
6249 let mut worker = tokio::task::spawn_blocking(move || {
6250 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6251 });
6252 let result = tokio::select! {
6253 result = &mut worker => result,
6254 _ = cancellation.cancelled() => {
6255 dialog_bridge.cancel_open_requests();
6256 let js_for_cancel = js.clone();
6257 let _ = tokio::task::spawn_blocking(move || {
6258 js_for_cancel.cancel_prompt_preparation();
6259 }).await;
6260 worker.await
6261 }
6262 };
6263 let was_cancelled = cancellation.is_cancelled();
6264 if was_cancelled {
6265 dialog_bridge.cancel_open_requests();
6266 let js_for_cancel = js.clone();
6267 let _ = tokio::task::spawn_blocking(move || {
6268 js_for_cancel.cancel_prompt_preparation();
6269 })
6270 .await;
6271 state.finish_js_preparation();
6272 dialog_bridge.reopen();
6273 return false;
6274 }
6275 state.finish_js_preparation();
6276 match result {
6277 Ok(Ok(())) => {
6278 if let Some(js_active) = js.active_tools() {
6282 let js_names = js.tool_names();
6283 let mut active = lane.get_active_tools().await.unwrap_or_default();
6284 active.retain(|name| {
6285 crate::session::tool_name_allowed(name, args)
6286 && !js_names.iter().any(|js_name| js_name == name)
6287 });
6288 active.extend(js_active.into_iter().filter(|name| {
6289 js_names.iter().any(|js_name| js_name == name)
6290 && crate::session::tool_name_allowed(name, args)
6291 }));
6292 active = crate::session::filter_active_tool_names(active, args);
6293 let _ = lane.set_active_tools(active).await;
6294 }
6295 }
6296 Ok(Err(error)) => {
6297 if args.verbose {
6298 eprintln!("warning: could not start JS extension runtime: {error}");
6299 }
6300 }
6301 Err(error) => {
6302 if args.verbose {
6303 eprintln!("warning: JS extension runtime worker failed: {error}");
6304 }
6305 }
6306 }
6307 true
6308}
6309
6310fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6311 std::thread::spawn(move || {
6312 let file = match tempfile::Builder::new()
6313 .prefix("rpi-draft-")
6314 .suffix(".md")
6315 .tempfile()
6316 {
6317 Ok(file) => file,
6318 Err(error) => {
6319 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6320 "Could not create editor file: {error}"
6321 ))));
6322 return;
6323 }
6324 };
6325 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6326 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6327 "Could not write editor file: {error}"
6328 ))));
6329 return;
6330 }
6331 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6332 .ok()
6333 .filter(|value| !value.trim().is_empty())
6334 .or_else(|| std::env::var("VISUAL").ok())
6335 .or_else(|| std::env::var("EDITOR").ok())
6336 .unwrap_or_else(|| {
6337 if cfg!(windows) {
6338 "notepad".to_string()
6339 } else {
6340 "nano".to_string()
6341 }
6342 });
6343 let status = std::process::Command::new(&editor)
6344 .arg(file.path())
6345 .status();
6346 let result = match status {
6347 Ok(status) if status.success() => std::fs::read_to_string(file.path())
6348 .map_err(|error| format!("Could not read editor file: {error}")),
6349 Ok(status) => Err(format!("External editor exited with {status}")),
6350 Err(error) => Err(format!(
6351 "Could not launch external editor `{editor}`: {error}"
6352 )),
6353 };
6354 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6355 });
6356}
6357
6358fn reconcile_streamed_assistant_completion(
6361 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6362 last_assistant_text: &Mutex<String>,
6363 final_message: Option<&AssistantMessage>,
6364) {
6365 let final_blocks = final_message.map(assistant_blocks);
6366 let final_text = final_message.map(assistant_text);
6367 let component = current_assistant.lock().unwrap().take();
6368
6369 if let Some(component) = component {
6370 if let Some(blocks) = final_blocks.as_deref() {
6371 component.update_blocks(blocks);
6372 }
6373 component.set_streaming(false);
6374 }
6375
6376 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6377 *last_assistant_text.lock().unwrap() = text;
6378 }
6379}
6380
6381async fn run_prompt_streaming(
6386 lane: &Arc<dyn AgentLane>,
6387 prompt: &str,
6388 tui: &Arc<TuiAltScreen>,
6389 state: &Arc<TuiState>,
6390 streaming: bool,
6391 js: Option<&crate::js_extensions::JsExtensionSession>,
6392 dialog_bridge: &JsDialogBridge,
6393 args: &Args,
6394 images: Vec<rpi_ai::types::ImageContent>,
6395) {
6396 state.set_status(RunStatus::Working);
6405 tui.request_render(false);
6406 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6407 state.set_status(RunStatus::Idle);
6408 tui.request_render(false);
6409 return;
6410 }
6411
6412 let outcome = lane.prompt_text(prompt, images).await;
6413
6414 if streaming {
6415 let final_message = match &outcome {
6420 Ok(result) => match &result.outcome {
6421 HarnessRunOutcome::Completed { final_message, .. }
6422 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6423 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6424 HarnessRunOutcome::Suspended { .. } => None,
6425 },
6426 Err(_) => None,
6427 };
6428 reconcile_streamed_assistant_completion(
6429 &state.current_assistant,
6430 &state.last_assistant_text,
6431 final_message,
6432 );
6433 }
6434
6435 state.set_status(RunStatus::Idle);
6436
6437 match outcome {
6438 Ok(result) => match &result.outcome {
6439 HarnessRunOutcome::Failed {
6440 error,
6441 final_message,
6442 ..
6443 } => {
6444 let already_rendered = streaming && final_message.is_some();
6452 if !already_rendered {
6453 let msg = final_message
6454 .as_ref()
6455 .and_then(|m| m.error_message.clone())
6456 .unwrap_or_else(|| format!("{error:?}"));
6457 add_error_message(&state.chat_container, &msg);
6458 }
6459 }
6460 HarnessRunOutcome::Suspended { .. } => {
6461 add_error_message(
6462 &state.chat_container,
6463 "Run suspended (deferred) — resume is not supported in v1.",
6464 );
6465 }
6466 HarnessRunOutcome::Aborted { final_message, .. } => {
6467 if !streaming {
6470 add_error_message(&state.chat_container, "Request aborted.");
6471 let _ = final_message; }
6473 }
6474 HarnessRunOutcome::Completed { final_message, .. } => {
6475 if !streaming {
6476 let text = assistant_text(final_message);
6477 if !text.is_empty() {
6478 add_assistant_message_blocking(
6479 &state.chat_container,
6480 &text,
6481 state.markdown_transformer(),
6482 );
6483 *state.last_assistant_text.lock().unwrap() = text;
6484 }
6485 }
6486 }
6487 },
6488 Err(e) => {
6489 add_error_message(&state.chat_container, &e.to_string());
6490 }
6491 }
6492
6493 tui.request_render(false);
6494}
6495
6496async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6501 state.set_status(RunStatus::Working);
6502 tui.request_render(false);
6503 match lane.compact(None).await {
6504 Ok(_) => {
6505 add_note_message(&state.chat_container, "Conversation compacted.");
6506 }
6507 Err(e) => {
6508 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6509 }
6510 }
6511 state.set_status(RunStatus::Idle);
6512 tui.request_render(false);
6513}
6514
6515fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6519 let text = state.last_assistant_text.lock().unwrap().clone();
6520 if text.is_empty() {
6521 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6522 return;
6523 }
6524 if copy_to_clipboard(&text) {
6525 add_note_message(chat, "Copied last reply to the clipboard.");
6526 } else {
6527 let preview: String = text.chars().take(200).collect();
6530 add_note_message(
6531 chat,
6532 &format!(
6533 "Clipboard unavailable. Last reply: {preview}{}",
6534 if text.chars().count() > 200 {
6535 "…"
6536 } else {
6537 ""
6538 }
6539 ),
6540 );
6541 }
6542}
6543
6544#[cfg(feature = "clipboard")]
6547fn copy_to_clipboard(text: &str) -> bool {
6548 match arboard::Clipboard::new() {
6549 Ok(mut cb) => cb.set_text(text).is_ok(),
6550 Err(_) => false,
6551 }
6552}
6553
6554#[cfg(not(feature = "clipboard"))]
6555fn copy_to_clipboard(_text: &str) -> bool {
6556 false
6557}
6558
6559#[cfg(feature = "clipboard")]
6563fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6564 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6565 let image = match clipboard.get_image() {
6566 Ok(image) => image,
6567 Err(_) => return Ok(None),
6568 };
6569 let width =
6570 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6571 let height =
6572 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6573 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6574 return Err("clipboard image dimensions are outside the supported range".into());
6575 }
6576 let mut bytes = Vec::new();
6577 {
6578 let mut encoder = png::Encoder::new(&mut bytes, width, height);
6579 encoder.set_color(png::ColorType::Rgba);
6580 encoder.set_depth(png::BitDepth::Eight);
6581 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6582 writer
6583 .write_image_data(&image.bytes)
6584 .map_err(|e| e.to_string())?;
6585 }
6586 Ok(Some(rpi_ai::types::ImageContent {
6587 kind: rpi_ai::types::ImageContentType,
6588 data: base64::engine::general_purpose::STANDARD.encode(bytes),
6589 mime_type: "image/png".into(),
6590 }))
6591}
6592
6593fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6594 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6595 let mut options = ImageOptions::default();
6596 options.width = Some(48);
6597 options.alt_text = Some("Attached image".into());
6598 chat.add_child(Arc::new(Image::from_data(bytes, options)));
6599 chat.add_child(Arc::new(Spacer::new(1)));
6600 }
6601}
6602
6603#[cfg(not(feature = "clipboard"))]
6604fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6605 Ok(None)
6606}
6607
6608fn add_assistant_message_blocking(
6615 container: &Arc<Container>,
6616 text: &str,
6617 transformer: Option<MarkdownTransformer>,
6618) {
6619 if text.is_empty() {
6620 return;
6621 }
6622 let msg = Arc::new(AssistantMessageComponent::new(
6623 AssistantMessageOptions::default(),
6624 ));
6625 if let Some(t) = &transformer {
6626 msg.set_markdown_transformer(Some(t.clone()));
6627 }
6628 msg.update_text(text);
6629 container.add_child(msg);
6630 container.add_child(Arc::new(Spacer::new(1)));
6631}
6632
6633async fn drain_agent_events(
6641 mut rx: broadcast::Receiver<AgentEvent>,
6642 tui: Arc<TuiAltScreen>,
6643 state: Arc<TuiState>,
6644 chat: Arc<Container>,
6645) {
6646 loop {
6647 match rx.recv().await {
6648 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6649 Err(broadcast::error::RecvError::Lagged(_)) => {
6650 continue;
6653 }
6654 Err(broadcast::error::RecvError::Closed) => break,
6655 }
6656 }
6657}
6658
6659async fn handle_agent_event(
6662 event: AgentEvent,
6663 tui: &Arc<TuiAltScreen>,
6664 state: &Arc<TuiState>,
6665 chat: &Arc<Container>,
6666) {
6667 match event {
6668 AgentEvent::AgentStart => {
6669 state.set_status(RunStatus::Working);
6670 tui.request_render(false);
6671 }
6672
6673 AgentEvent::AgentEnd { .. } => {
6674 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6676 comp.set_streaming(false);
6677 }
6678 state.set_status(RunStatus::Idle);
6679 tui.request_render(false);
6680 }
6681
6682 AgentEvent::RetryScheduled {
6683 attempt,
6684 max_retries,
6685 delay_ms,
6686 ..
6687 } => {
6688 state.show_retry(attempt, max_retries, delay_ms);
6689 tui.request_render(false);
6690 }
6691
6692 AgentEvent::TurnStart => {
6693 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6696 comp.set_streaming(false);
6697 }
6698 }
6699
6700 AgentEvent::TurnEnd {
6701 message,
6702 tool_results,
6703 } => {
6704 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6706 if let AgentMessage::Assistant(a) = &message {
6707 comp.update_blocks(&assistant_blocks(a));
6708 }
6709 comp.set_streaming(false);
6710 }
6711 let tools = state.tool_components.lock().unwrap();
6716 for tr in &tool_results {
6717 if tools.contains_key(&tr.tool_call_id) {
6718 let _ = tr;
6721 }
6722 }
6723 drop(tools);
6724 tui.request_render(false);
6725 }
6726
6727 AgentEvent::MessageStart { message } => match message {
6728 AgentMessage::Assistant(a) => {
6729 let comp = Arc::new(AssistantMessageComponent::new(
6730 AssistantMessageOptions::default(),
6731 ));
6732 if let Some(t) = state.markdown_transformer() {
6737 comp.set_markdown_transformer(Some(t));
6738 }
6739 comp.set_hide_thinking(state.hide_thinking());
6740 comp.set_streaming(true);
6741 comp.update_blocks(&assistant_blocks(&a));
6744 chat.add_child(comp.clone());
6745 chat.add_child(Arc::new(Spacer::new(1)));
6748 *state.current_assistant.lock().unwrap() = Some(comp);
6749 tui.request_render(false);
6750 }
6751 AgentMessage::Custom(custom) => {
6752 let payload = serde_json::json!({
6753 "customType": custom.role,
6754 "content": custom.content,
6755 "details": custom.data,
6756 "expanded": false,
6757 "outputPad": 1,
6758 });
6759 if let Some(component) = extension_message_component(
6760 &state.extension_session,
6761 &custom.role,
6762 &payload,
6763 state.markdown_transformer(),
6764 ) {
6765 chat.add_child(component);
6766 chat.add_child(Arc::new(Spacer::new(1)));
6767 tui.request_render(false);
6768 } else {
6769 add_note_message(chat, &custom_message_fallback(&custom));
6770 tui.request_render(false);
6771 }
6772 }
6773 _ => {}
6776 },
6777
6778 AgentEvent::MessageUpdate {
6779 message,
6780 assistant_message_event,
6781 } => {
6782 if let AgentMessage::Assistant(a) = &message {
6783 let text = assistant_text(a);
6784 let mut saw_bash_tool_call = false;
6785 for c in &a.content {
6790 if let Content::ToolCall(tc) = c {
6791 if tc.name.trim().is_empty() {
6795 continue;
6796 }
6797 if tc.name == "bash" {
6798 let command = tc
6804 .arguments
6805 .get("command")
6806 .and_then(|v| v.as_str())
6807 .unwrap_or("");
6808 if command.trim().is_empty() {
6815 continue;
6816 }
6817 saw_bash_tool_call = true;
6818 let mut bash = state.bash_components.lock().unwrap();
6819 if let Some(existing) = bash.get(&tc.id) {
6820 existing.set_command(command);
6825 } else {
6826 let comp = Arc::new(BashExecutionComponent::new(command));
6827 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6828 chat.add_child(comp.clone());
6829 bash.insert(tc.id.clone(), comp);
6830 }
6831 } else {
6832 let mut tools = state.tool_components.lock().unwrap();
6833 let display_args = if is_ask_user_tool(&tc.name) {
6834 ask_user_args_display(&tc.arguments)
6835 } else {
6836 tc.arguments.to_string()
6837 };
6838 if let Some(existing) = tools.get(&tc.id) {
6839 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
6843 existing.set_args(&display_args);
6844 }
6845 } else {
6846 let comp = Arc::new(ToolExecutionComponent::new(
6847 &tc.name,
6848 &display_args,
6849 ));
6850 if is_ask_user_tool(&tc.name) {
6851 comp.set_display_title("ASK USER");
6852 }
6853 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6854 comp.set_running();
6855 chat.add_child(comp.clone());
6856 tools.insert(tc.id.clone(), comp);
6857 }
6858 }
6859 }
6860 }
6861 if saw_bash_tool_call {
6866 state.sync_working_loader_with_bash();
6867 }
6868 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6870 comp.update_blocks(&assistant_blocks(a));
6873 }
6874 *state.last_assistant_text.lock().unwrap() = text;
6875 tui.request_render(false);
6876 }
6877 }
6878
6879 AgentEvent::MessageEnd { message } => {
6880 if let AgentMessage::Assistant(a) = &message {
6881 let text = assistant_text(a);
6882 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6883 comp.update_blocks(&assistant_blocks(a));
6884 comp.set_streaming(false);
6885 }
6886 if !text.is_empty() {
6888 *state.last_assistant_text.lock().unwrap() = text;
6889 }
6890 let usage = &a.usage;
6896 let prev_input = *state.last_input_tokens.lock().unwrap();
6897 if prev_input > 0
6898 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6899 && usage.cache_read == 0
6900 {
6901 add_note_message(
6902 &state.chat_container,
6903 &format!(
6904 "Cache miss: {} tokens re-billed",
6905 format_tokens(usage.input)
6906 ),
6907 );
6908 }
6909 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6910 add_note_message(chat, &text);
6911 }
6912 if let Some(error) = assistant_error_text(a) {
6917 add_error_message(chat, &error);
6918 }
6919 *state.last_input_tokens.lock().unwrap() = usage.input;
6920 }
6921 tui.request_render(false);
6922 }
6923
6924 AgentEvent::ToolExecutionStart {
6925 tool_call_id,
6926 tool_name,
6927 args,
6928 } => {
6929 if tool_name.trim().is_empty() {
6932 return;
6933 }
6934 if tool_name == "bash" {
6935 let command = args
6940 .get("command")
6941 .and_then(|v| v.as_str())
6942 .unwrap_or("")
6943 .to_string();
6944 if command.trim().is_empty() {
6948 return;
6949 }
6950 let mut bash_map = state.bash_components.lock().unwrap();
6951 if let Some(existing) = bash_map.get(&tool_call_id) {
6952 existing.set_command(&command);
6957 } else {
6958 let comp = Arc::new(BashExecutionComponent::new(command));
6959 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6960 chat.add_child(comp.clone());
6961 bash_map.insert(tool_call_id.clone(), comp);
6962 }
6963 } else {
6964 let _comp = {
6965 let mut tools = state.tool_components.lock().unwrap();
6966 if let Some(existing) = tools.get(&tool_call_id) {
6967 if is_ask_user_tool(&tool_name) {
6968 existing.set_display_title("ASK USER");
6969 existing.set_args(&ask_user_args_display(&args));
6970 } else {
6971 existing.set_args(&args.to_string());
6972 }
6973 existing.clone()
6974 } else {
6975 let display_args = if is_ask_user_tool(&tool_name) {
6976 ask_user_args_display(&args)
6977 } else {
6978 args.to_string()
6979 };
6980 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
6981 if is_ask_user_tool(&tool_name) {
6982 comp.set_display_title("ASK USER");
6983 }
6984 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6985 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6990 comp.set_skill_name(skill);
6991 }
6992 comp.set_running();
6993 chat.add_child(comp.clone());
6994 tools.insert(tool_call_id.clone(), comp.clone());
6995 comp
6996 }
6997 };
6998 }
6999 state.sync_working_loader_with_bash();
7000 tui.request_render(false);
7001 }
7002
7003 AgentEvent::ToolExecutionUpdate {
7004 tool_call_id,
7005 tool_name,
7006 args,
7007 partial_result,
7008 } => {
7009 if tool_name.trim().is_empty() {
7010 return;
7011 }
7012 let partial_text = if is_ask_user_tool(&tool_name) {
7013 ask_user_progress_text(&args, &partial_result)
7014 } else {
7015 tool_result_text(&partial_result)
7016 };
7017 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7018 if tool_name == "bash" {
7019 let chunk = partial_text;
7024 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7025 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7028 bash.set_command(command);
7029 }
7030 if has_partial_payload {
7031 bash.append_output(&chunk);
7032 }
7033 } else if has_partial_payload {
7034 }
7040 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7041 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7042 comp.set_skill_name(skill);
7043 }
7044 if is_ask_user_tool(&tool_name) {
7045 comp.set_display_title("ASK USER");
7046 comp.set_args(&ask_user_args_display(&args));
7047 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7048 comp.set_args(&args.to_string());
7049 }
7050 if has_partial_payload {
7053 comp.set_result(&partial_text, false);
7054 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7055 }
7056 } else if has_partial_payload {
7057 let display_args = if is_ask_user_tool(&tool_name) {
7062 ask_user_args_display(&args)
7063 } else {
7064 args.to_string()
7065 };
7066 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7067 if is_ask_user_tool(&tool_name) {
7068 comp.set_display_title("ASK USER");
7069 }
7070 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7071 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7072 comp.set_skill_name(skill);
7073 }
7074 comp.set_running();
7075 comp.set_result(&partial_text, false);
7076 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7077 chat.add_child(comp.clone());
7078 state
7079 .tool_components
7080 .lock()
7081 .unwrap()
7082 .insert(tool_call_id.clone(), comp.clone());
7083 }
7084 state.sync_working_loader_with_bash();
7085 tui.request_render(false);
7086 }
7087
7088 AgentEvent::ToolExecutionEnd {
7089 tool_call_id,
7090 tool_name,
7091 result,
7092 is_error,
7093 } => {
7094 if tool_name.trim().is_empty() {
7095 return;
7096 }
7097 if tool_name == "bash" {
7098 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7099 if let Some(bash) = bash {
7100 finalize_bash(&bash, &result, is_error);
7101 } else {
7102 let command = result
7105 .details
7106 .get("command")
7107 .and_then(|v| v.as_str())
7108 .unwrap_or("")
7109 .to_string();
7110 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7111 state.sync_working_loader_with_bash();
7112 tui.request_render(false);
7113 return;
7114 }
7115 let comp = Arc::new(BashExecutionComponent::new(command));
7116 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7117 comp.append_output(&tool_result_text(&result));
7118 finalize_bash(&comp, &result, is_error);
7119 chat.add_child(comp);
7120 }
7121 } else {
7122 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7123 if let Some(comp) = comp {
7124 let result_text = if is_ask_user_tool(&tool_name) {
7125 ask_user_result_text(&result)
7126 } else {
7127 tool_result_text(&result)
7128 };
7129 comp.set_result(&result_text, is_error);
7130 if !is_ask_user_tool(&tool_name) {
7131 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7132 }
7133 } else {
7134 let comp = Arc::new(ToolExecutionComponent::new(
7137 &tool_name,
7138 &if is_ask_user_tool(&tool_name) {
7139 ask_user_args_display(&serde_json::Value::Null)
7140 } else {
7141 "".to_string()
7142 },
7143 ));
7144 if is_ask_user_tool(&tool_name) {
7145 comp.set_display_title("ASK USER");
7146 }
7147 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7148 let result_text = if is_ask_user_tool(&tool_name) {
7149 ask_user_result_text(&result)
7150 } else {
7151 tool_result_text(&result)
7152 };
7153 comp.set_result(&result_text, is_error);
7154 if !is_ask_user_tool(&tool_name) {
7155 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7156 }
7157 chat.add_child(comp.clone());
7158 }
7159 }
7160 state.sync_working_loader_with_bash();
7161 tui.request_render(false);
7162 }
7163 }
7164}
7165
7166fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7170 let fallback = match message.stop_reason {
7171 rpi_ai::StopReason::Error => "Provider request failed.",
7172 rpi_ai::StopReason::Aborted => "Request aborted.",
7173 _ => return None,
7174 };
7175 Some(
7176 message
7177 .error_message
7178 .as_deref()
7179 .filter(|text| !text.trim().is_empty())
7180 .unwrap_or(fallback)
7181 .to_string(),
7182 )
7183}
7184
7185fn finalize_bash(
7189 comp: &Arc<BashExecutionComponent>,
7190 result: &rpi_agent::AgentToolResult,
7191 is_error: bool,
7192) {
7193 let exit_code = if is_error { Some(1) } else { Some(0) };
7196 let truncated = result
7197 .details
7198 .get("truncation")
7199 .and_then(|t| t.get("truncated"))
7200 .and_then(|v| v.as_bool())
7201 .unwrap_or(false);
7202 let full_output_path = result
7203 .details
7204 .get("full_output_path")
7205 .and_then(|v| v.as_str())
7206 .map(|s| s.to_string());
7207 let truncation = BashTruncation {
7208 truncated,
7209 full_output_path,
7210 };
7211 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7215 comp.set_command(command);
7216 }
7217 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
7219}
7220
7221fn apply_edit_diff(
7226 comp: &Arc<ToolExecutionComponent>,
7227 tool_name: &str,
7228 details: &serde_json::Value,
7229 tui: &Arc<TuiAltScreen>,
7230) {
7231 if tool_name != "edit" {
7232 return;
7233 }
7234 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7235 return;
7236 };
7237 if diff_text.is_empty() {
7238 return;
7239 }
7240 let width = tui.width();
7241 let lines = render_diff(diff_text, width);
7242 comp.set_diff(lines);
7243}
7244
7245fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7250 if tool_name != "read" {
7251 return None;
7252 }
7253 let path = args.get("path").and_then(|value| value.as_str())?;
7254 let normalized = path.replace('\\', "/");
7255 let file_name = normalized.rsplit('/').next()?;
7256 if !file_name.eq_ignore_ascii_case("SKILL.md") {
7257 return None;
7258 }
7259 normalized
7260 .trim_end_matches('/')
7261 .rsplit('/')
7262 .nth(1)
7263 .filter(|name| !name.is_empty())
7264 .map(str::to_string)
7265}
7266
7267fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7270 use rpi_agent::TextContentOrImage;
7271 let mut parts: Vec<String> = Vec::new();
7272 for c in &result.content {
7273 if let TextContentOrImage::Text(t) = c {
7274 parts.push(t.text.clone());
7275 }
7276 }
7277 let joined = parts.join("\n");
7278 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7280 if one_line.chars().count() > 200 {
7281 let truncated: String = one_line.chars().take(200).collect();
7282 format!("{truncated}…")
7283 } else {
7284 one_line
7285 }
7286}
7287
7288fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7293 use rpi_agent::TextContentOrImage;
7294 let mut parts: Vec<String> = Vec::new();
7295 for c in &result.content {
7296 if let TextContentOrImage::Text(t) = c {
7297 parts.push(t.text.clone());
7298 }
7299 }
7300 parts.join("\n")
7301}
7302
7303fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7307 !text.trim().is_empty() || !result.details.is_null()
7308}
7309
7310fn is_ask_user_tool(tool_name: &str) -> bool {
7313 matches!(tool_name, "ask_user" | "ask_user_question")
7314}
7315
7316fn ask_user_args_display(args: &serde_json::Value) -> String {
7319 if args.is_null() {
7320 return String::new();
7321 }
7322 if let Some(summary) = args
7323 .get("summary")
7324 .and_then(serde_json::Value::as_str)
7325 .map(str::trim)
7326 .filter(|text| !text.is_empty())
7327 {
7328 return format!("Confirmation requested: {summary}");
7329 }
7330
7331 let mut lines: Vec<String> = Vec::new();
7332 let questions: Vec<&serde_json::Value> = args
7333 .get("questions")
7334 .and_then(serde_json::Value::as_array)
7335 .filter(|items| !items.is_empty())
7336 .map(|items| items.iter().collect())
7337 .unwrap_or_else(|| vec![args]);
7338
7339 for question in questions {
7340 let prompt = question
7341 .get("question")
7342 .and_then(serde_json::Value::as_str)
7343 .or_else(|| {
7344 args.get("question")
7345 .and_then(serde_json::Value::as_str)
7346 })
7347 .map(str::trim)
7348 .unwrap_or_default();
7349 if prompt.is_empty() {
7350 continue;
7351 }
7352 if let Some(header) = question
7353 .get("header")
7354 .and_then(serde_json::Value::as_str)
7355 .map(str::trim)
7356 .filter(|text| !text.is_empty())
7357 {
7358 lines.push(format!("{header}: {prompt}"));
7359 } else {
7360 lines.push(prompt.to_string());
7361 }
7362 let context = question
7363 .get("context")
7364 .and_then(serde_json::Value::as_str)
7365 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7366 .map(str::trim)
7367 .filter(|text| !text.is_empty());
7368 if let Some(context) = context {
7369 lines.push(format!(" {context}"));
7370 }
7371 let options = question
7372 .get("options")
7373 .and_then(serde_json::Value::as_array)
7374 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7375 if let Some(options) = options {
7376 let labels: Vec<String> = options
7377 .iter()
7378 .filter_map(|option| {
7379 option
7380 .get("title")
7381 .and_then(serde_json::Value::as_str)
7382 .or_else(|| option.as_str())
7383 .map(str::trim)
7384 .filter(|label| !label.is_empty())
7385 .map(str::to_string)
7386 })
7387 .collect();
7388 if !labels.is_empty() {
7389 lines.push(format!("Choices: {}", labels.join(", ")));
7390 }
7391 }
7392 if let Some(suggest) = question
7393 .get("suggest")
7394 .and_then(serde_json::Value::as_str)
7395 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7396 .map(str::trim)
7397 .filter(|text| !text.is_empty())
7398 {
7399 lines.push(format!("Suggestions: {suggest}"));
7400 }
7401 }
7402
7403 lines.join("\n")
7404}
7405
7406fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7409 let text = tool_result_text(result);
7410 if !text.trim().is_empty() {
7411 return text;
7412 }
7413 let args_text = ask_user_args_display(args);
7414 if args_text.trim().is_empty() {
7415 "Waiting for your answer…".to_string()
7416 } else {
7417 args_text
7418 }
7419}
7420
7421fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7424 let text = tool_result_text(result);
7425 if text.trim().is_empty() {
7426 "Answer recorded.".to_string()
7427 } else {
7428 text
7429 }
7430}
7431
7432fn open_selector(
7440 state: &Arc<TuiState>,
7441 editor_container: &Arc<Container>,
7442 editor: &Arc<Editor>,
7443 tui: &Arc<TuiAltScreen>,
7444 list: Arc<SelectList>,
7445 kind: SelectorKind,
7446) {
7447 open_selector_with_view(
7448 state,
7449 editor_container,
7450 editor,
7451 tui,
7452 list.clone(),
7453 list,
7454 kind,
7455 );
7456}
7457
7458fn open_selector_with_view<C: Component + 'static>(
7462 state: &Arc<TuiState>,
7463 editor_container: &Arc<Container>,
7464 editor: &Arc<Editor>,
7465 tui: &Arc<TuiAltScreen>,
7466 list: Arc<SelectList>,
7467 view: Arc<C>,
7468 kind: SelectorKind,
7469) {
7470 editor.set_focused(false);
7472 state.autocomplete_container.clear();
7475 editor_container.clear();
7477 editor_container.add_child(view.clone());
7478 *state.active_selector.lock().unwrap() = Some((list, kind));
7479 let focused: Arc<dyn Component> = view;
7480 tui.set_focus(Some(focused));
7481 tui.request_render(false);
7482}
7483
7484fn close_selector(
7487 state: &Arc<TuiState>,
7488 editor_container: &Arc<Container>,
7489 editor: &Arc<Editor>,
7490 tui: &Arc<TuiAltScreen>,
7491) {
7492 editor_container.clear();
7493 editor_container.add_child(editor.clone());
7494 state.autocomplete_container.clear();
7495 editor.set_focused(true);
7496 *state.active_selector.lock().unwrap() = None;
7497 *state.active_extension_cancel.lock().unwrap() = None;
7498 tui.set_focus(Some(editor.clone()));
7499 tui.request_render(false);
7500}
7501
7502fn open_model_selector(
7508 state: &Arc<TuiState>,
7509 editor_container: &Arc<Container>,
7510 editor: &Arc<Editor>,
7511 tui: &Arc<TuiAltScreen>,
7512 catalog: &[rpi_ai::Model],
7513 lane: &Arc<dyn AgentLane>,
7514 lane_model_id: &str,
7515 chat: &Arc<Container>,
7516) {
7517 let items = model_selector_items(catalog, lane_model_id);
7518 if items.is_empty() {
7519 add_note_message(
7520 chat,
7521 "No models in the catalog. Use --model at startup to select one.",
7522 );
7523 tui.request_render(false);
7524 return;
7525 }
7526 let list = Arc::new(SelectList::new(items, 10));
7527
7528 let catalog_arc = catalog.to_vec();
7532 let state_sel = state.clone();
7533 let ec_sel = editor_container.clone();
7534 let editor_sel = editor.clone();
7535 let tui_sel = tui.clone();
7536 let chat_sel = chat.clone();
7537 let lane_sel = lane.clone();
7538 list.on_select(Arc::new(move |item| {
7539 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7540 add_note_message(
7541 &chat_sel,
7542 &format!("Model {} not found in catalog.", item.label),
7543 );
7544 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7545 return;
7546 };
7547 state_sel.set_current_model(&model);
7548 let lane = lane_sel.clone();
7549 tokio::spawn(async move {
7550 let _ = lane.set_model(model).await;
7551 });
7552 add_note_message(
7553 &chat_sel,
7554 &format!(
7555 "Model set to {} — applies to the next message.",
7556 short_model_name(&item.value)
7557 ),
7558 );
7559 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7560 }));
7561 let state_cancel = state.clone();
7562 let ec_cancel = editor_container.clone();
7563 let editor_cancel = editor.clone();
7564 let tui_cancel = tui.clone();
7565 list.on_cancel(Arc::new(move || {
7566 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7567 }));
7568
7569 open_selector(
7570 state,
7571 editor_container,
7572 editor,
7573 tui,
7574 list,
7575 SelectorKind::Model,
7576 );
7577}
7578
7579fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
7584 if catalog.is_empty() {
7585 return None;
7586 }
7587 let idx = catalog
7588 .iter()
7589 .position(|m| m.id.eq_ignore_ascii_case(current_id));
7590 match idx {
7591 Some(i) => {
7592 let next = (i + 1) % catalog.len();
7593 Some(catalog[next].clone())
7594 }
7595 None => Some(catalog[0].clone()),
7596 }
7597}
7598
7599fn open_session_selector(
7605 state: &Arc<TuiState>,
7606 editor_container: &Arc<Container>,
7607 editor: &Arc<Editor>,
7608 tui: &Arc<TuiAltScreen>,
7609 cwd: &std::path::Path,
7610 tx: &mpsc::UnboundedSender<TuiMessage>,
7611) {
7612 let dir = crate::session::default_session_dir(cwd);
7613 let mut items: Vec<SelectItem> = Vec::new();
7614 if let Ok(entries) = std::fs::read_dir(&dir) {
7615 for entry in entries.flatten() {
7616 let path = entry.path();
7617 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7618 continue;
7619 }
7620 let stem = path
7621 .file_stem()
7622 .and_then(|s| s.to_str())
7623 .unwrap_or("(unnamed)")
7624 .to_string();
7625 let display = path
7626 .file_name()
7627 .and_then(|s| s.to_str())
7628 .unwrap_or(&stem)
7629 .to_string();
7630 items.push(SelectItem::new(&stem, &display));
7631 }
7632 }
7633 if items.is_empty() {
7634 add_note_message(
7635 &state.chat_container,
7636 "No saved sessions found. Sessions are created automatically in interactive mode.",
7637 );
7638 tui.request_render(false);
7639 return;
7640 }
7641 let list = Arc::new(SelectList::new(items, 10));
7642
7643 let state_sel = state.clone();
7644 let ec_sel = editor_container.clone();
7645 let editor_sel = editor.clone();
7646 let tui_sel = tui.clone();
7647 let tx_sel = tx.clone();
7648 list.on_select(Arc::new(move |item| {
7649 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7653 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
7654 }));
7655 let state_cancel = state.clone();
7656 let ec_cancel = editor_container.clone();
7657 let editor_cancel = editor.clone();
7658 let tui_cancel = tui.clone();
7659 list.on_cancel(Arc::new(move || {
7660 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7661 }));
7662
7663 open_selector(
7664 state,
7665 editor_container,
7666 editor,
7667 tui,
7668 list,
7669 SelectorKind::Session,
7670 );
7671}
7672
7673fn custom_entry_display_text(
7674 custom_type: &str,
7675 data: Option<&serde_json::Value>,
7676) -> Option<String> {
7677 let data = data?;
7678 let text = data
7679 .get("summary")
7680 .or_else(|| data.get("text"))
7681 .or_else(|| data.get("output"))
7682 .and_then(|value| value.as_str())
7683 .filter(|value| !value.trim().is_empty())?;
7684 let label = match custom_type {
7685 "compactionSummary" => "Compaction summary",
7686 "branchSummary" => "Branch summary",
7687 "bashExecution" => "Command output",
7688 other => other,
7689 };
7690 Some(format!("{label}: {text}"))
7691}
7692
7693async fn open_tree_selector(
7697 harness: &AgentHarness,
7698 state: &Arc<TuiState>,
7699 editor_container: &Arc<Container>,
7700 editor: &Arc<Editor>,
7701 tui: &Arc<TuiAltScreen>,
7702 chat: &Arc<Container>,
7703 tx: &mpsc::UnboundedSender<TuiMessage>,
7704) {
7705 let entries = match harness
7706 .session()
7707 .view("main")
7708 .find_entries(&EntryQuery {
7709 order: Some(EntryOrder::OldestFirst),
7710 ..Default::default()
7711 })
7712 .await
7713 {
7714 Ok(entries) => entries,
7715 Err(error) => {
7716 add_error_message(chat, &format!("Could not read session tree: {error}"));
7717 tui.request_render(false);
7718 return;
7719 }
7720 };
7721 let current = harness.session().get_leaf_id().await.ok().flatten();
7722 let items: Vec<SelectItem> = entries
7723 .iter()
7724 .map(|entry| {
7725 let marker = if current.as_deref() == Some(entry.id()) {
7726 " (current)"
7727 } else {
7728 ""
7729 };
7730 SelectItem::new(
7731 entry.id(),
7732 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7733 )
7734 .with_description(&entry.id()[..entry.id().len().min(12)])
7735 })
7736 .collect();
7737 if items.is_empty() {
7738 add_note_message(chat, "The current session has no entries to navigate.");
7739 tui.request_render(false);
7740 return;
7741 }
7742 let list = Arc::new(SelectList::new(items, 12));
7743 let state_sel = state.clone();
7744 let ec_sel = editor_container.clone();
7745 let editor_sel = editor.clone();
7746 let tui_sel = tui.clone();
7747 let tx_sel = tx.clone();
7748 list.on_select(Arc::new(move |item| {
7749 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7750 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7751 }));
7752 let state_cancel = state.clone();
7753 let ec_cancel = editor_container.clone();
7754 let editor_cancel = editor.clone();
7755 let tui_cancel = tui.clone();
7756 list.on_cancel(Arc::new(move || {
7757 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7758 }));
7759 open_selector(
7760 state,
7761 editor_container,
7762 editor,
7763 tui,
7764 list,
7765 SelectorKind::Tree,
7766 );
7767}
7768
7769fn open_theme_selector(
7772 state: &Arc<TuiState>,
7773 editor_container: &Arc<Container>,
7774 editor: &Arc<Editor>,
7775 tui: &Arc<TuiAltScreen>,
7776 cwd: &std::path::Path,
7777 package_resources: &Arc<crate::packages::PackageResources>,
7778) {
7779 let mut items = vec![
7780 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7781 SelectItem::new("light", "Light").with_description("Light background"),
7782 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7783 ];
7784 if state.themes_enabled {
7785 for path in package_resources.theme_files() {
7786 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7787 items.push(SelectItem::new(name, name).with_description("Package theme"));
7788 }
7789 }
7790 }
7791 let list = Arc::new(SelectList::new(items, 10));
7792
7793 let state_sel = state.clone();
7794 let ec_sel = editor_container.clone();
7795 let editor_sel = editor.clone();
7796 let tui_sel = tui.clone();
7797 let chat_sel = state.chat_container.clone();
7798 let cwd_sel = cwd.to_path_buf();
7799 let package_resources_sel = package_resources.clone();
7800 list.on_select(Arc::new(move |item| {
7801 let preset = match item.value.as_str() {
7802 "light" => Some(ThemePreset::Light),
7803 "monochrome" => Some(ThemePreset::Monochrome),
7804 "dark" => Some(ThemePreset::Dark),
7805 name => {
7806 if state_sel.themes_enabled {
7807 if let Ok(custom) = crate::packages::load_theme_with_resources(
7808 &cwd_sel,
7809 name,
7810 &package_resources_sel,
7811 ) {
7812 rpi_tui::global_theme_manager().set(custom.clone());
7813 state_sel.theme_manager.set(custom);
7814 }
7815 }
7816 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7817 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7818 tui_sel.render_now(true);
7819 return;
7820 }
7821 };
7822 let Some(preset) = preset else { return };
7823 apply_theme_preset(preset);
7824 state_sel.theme_manager.apply_preset(preset);
7825 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7828 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7829 tui_sel.render_now(true);
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
7839 open_selector(
7840 state,
7841 editor_container,
7842 editor,
7843 tui,
7844 list,
7845 SelectorKind::Theme,
7846 );
7847}
7848
7849fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7856 use rpi_ai::types::ThinkingLevel::*;
7857 match level {
7858 Off => "Off — No reasoning",
7859 Minimal => "Minimal — Brief reasoning (~1k tokens)",
7860 Low => "Low — Light reasoning (~1k tokens)",
7861 Medium => "Medium — Moderate reasoning (~80% of max)",
7862 High => "High — Extensive reasoning (~95% of max)",
7863 Xhigh => "Xhigh — Near-maximal reasoning",
7864 Max => "Max — Maximum reasoning",
7865 }
7866}
7867
7868fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7871 use rpi_ai::types::ThinkingLevel::*;
7872 match level {
7873 Off => "off",
7874 Minimal => "minimal",
7875 Low => "low",
7876 Medium => "medium",
7877 High => "high",
7878 Xhigh => "xhigh",
7879 Max => "max",
7880 }
7881}
7882
7883fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7886 use rpi_ai::types::ThinkingLevel::*;
7887 match name.to_ascii_lowercase().as_str() {
7888 "off" => Some(Off),
7889 "minimal" => Some(Minimal),
7890 "low" => Some(Low),
7891 "medium" => Some(Medium),
7892 "high" => Some(High),
7893 "xhigh" => Some(Xhigh),
7894 "max" => Some(Max),
7895 _ => None,
7896 }
7897}
7898
7899fn open_thinking_selector(
7909 state: &Arc<TuiState>,
7910 editor_container: &Arc<Container>,
7911 editor: &Arc<Editor>,
7912 tui: &Arc<TuiAltScreen>,
7913 lane: &Arc<dyn AgentLane>,
7914 catalog: &[rpi_ai::Model],
7915 lane_model_id: &str,
7916 chat: &Arc<Container>,
7917) {
7918 let model = catalog
7921 .iter()
7922 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7923 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7924 .map(|m| m.supported_thinking_levels())
7925 .unwrap_or_else(|| {
7926 use rpi_ai::types::ThinkingLevel::*;
7927 vec![Off, Minimal, Low, Medium, High]
7928 });
7929 let mut items: Vec<SelectItem> = Vec::new();
7930 for lvl in &levels {
7931 let name = thinking_level_name(*lvl);
7932 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7933 }
7934 if items.is_empty() {
7935 add_note_message(chat, "This model has no supported thinking levels.");
7936 tui.request_render(false);
7937 return;
7938 }
7939 let list = Arc::new(SelectList::new(items, 10));
7940
7941 let state_sel = state.clone();
7942 let ec_sel = editor_container.clone();
7943 let editor_sel = editor.clone();
7944 let tui_sel = tui.clone();
7945 let chat_sel = chat.clone();
7946 let lane_sel = lane.clone();
7947 list.on_select(Arc::new(move |item| {
7948 let Some(level) = thinking_level_from_name(&item.value) else {
7949 add_note_message(
7950 &chat_sel,
7951 &format!("Unknown thinking level: {}.", item.label),
7952 );
7953 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7954 return;
7955 };
7956 let lane = lane_sel.clone();
7957 let footer_sel = state_sel.footer.clone();
7958 tokio::spawn(async move {
7959 let _ = lane.set_thinking_level(level).await;
7960 });
7961 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
7965 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
7966 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7967 }));
7968 let state_cancel = state.clone();
7969 let ec_cancel = editor_container.clone();
7970 let editor_cancel = editor.clone();
7971 let tui_cancel = tui.clone();
7972 list.on_cancel(Arc::new(move || {
7973 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7974 }));
7975
7976 open_selector(
7977 state,
7978 editor_container,
7979 editor,
7980 tui,
7981 list,
7982 SelectorKind::Thinking,
7983 );
7984}
7985
7986fn open_tools_selector(
7992 state: &Arc<TuiState>,
7993 editor_container: &Arc<Container>,
7994 editor: &Arc<Editor>,
7995 tui: &Arc<TuiAltScreen>,
7996 lane: &Arc<dyn AgentLane>,
7997 chat: &Arc<Container>,
7998) {
7999 let mut active = match tokio::runtime::Handle::try_current() {
8004 Ok(h) => h
8005 .block_on(async { lane.get_active_tools().await })
8006 .unwrap_or_default(),
8007 Err(_) => Vec::new(),
8008 };
8009 if active.is_empty() {
8014 active = crate::session::BUILTIN_TOOL_NAMES
8015 .iter()
8016 .map(|name| (*name).to_string())
8017 .collect();
8018 }
8019 let mut items: Vec<SelectItem> = Vec::new();
8020 for name in crate::session::BUILTIN_TOOL_NAMES {
8021 let on = active.iter().any(|a| a == name);
8022 let label = if on {
8023 format!("{name} (on)")
8024 } else {
8025 (*name).to_string()
8026 };
8027 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8028 }
8029 let list = Arc::new(SelectList::new(items, 10));
8030
8031 let active_captured = active.clone();
8033 let state_sel = state.clone();
8034 let ec_sel = editor_container.clone();
8035 let editor_sel = editor.clone();
8036 let tui_sel = tui.clone();
8037 let chat_sel = chat.clone();
8038 let lane_sel = lane.clone();
8039 list.on_select(Arc::new(move |item| {
8040 let mut next = active_captured.clone();
8041 if let Some(pos) = next.iter().position(|a| a == &item.value) {
8042 next.remove(pos);
8043 } else {
8044 next.push(item.value.clone());
8045 }
8046 let on = next.iter().any(|a| a == &item.value);
8047 let lane = lane_sel.clone();
8048 let next_clone = next.clone();
8049 tokio::spawn(async move {
8050 let _ = lane.set_active_tools(next_clone).await;
8051 });
8052 let list_str = if next.is_empty() {
8053 "(none)".to_string()
8054 } else {
8055 next.join(", ")
8056 };
8057 add_note_message(
8058 &chat_sel,
8059 &format!(
8060 "{} {} — active tools: {}",
8061 item.value,
8062 if on { "enabled" } else { "disabled" },
8063 list_str
8064 ),
8065 );
8066 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8067 }));
8068 let state_cancel = state.clone();
8069 let ec_cancel = editor_container.clone();
8070 let editor_cancel = editor.clone();
8071 let tui_cancel = tui.clone();
8072 list.on_cancel(Arc::new(move || {
8073 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8074 }));
8075
8076 open_selector(
8077 state,
8078 editor_container,
8079 editor,
8080 tui,
8081 list,
8082 SelectorKind::Tools,
8083 );
8084}
8085
8086fn open_images_selector(
8090 state: &Arc<TuiState>,
8091 editor_container: &Arc<Container>,
8092 editor: &Arc<Editor>,
8093 tui: &Arc<TuiAltScreen>,
8094 chat: &Arc<Container>,
8095) {
8096 let current = *state.show_images.lock().unwrap();
8097 let items = vec![
8098 SelectItem::new("yes", "Yes").with_description(if current {
8099 "Inline images (current)"
8100 } else {
8101 "Inline images"
8102 }),
8103 SelectItem::new("no", "No").with_description(if current {
8104 "Placeholder only"
8105 } else {
8106 "Placeholder only (current)"
8107 }),
8108 ];
8109 let list = Arc::new(SelectList::new(items, 5));
8110
8111 let state_sel = state.clone();
8112 let ec_sel = editor_container.clone();
8113 let editor_sel = editor.clone();
8114 let tui_sel = tui.clone();
8115 let chat_sel = chat.clone();
8116 list.on_select(Arc::new(move |item| {
8117 let on = item.value == "yes";
8118 *state_sel.show_images.lock().unwrap() = on;
8119 add_note_message(
8120 &chat_sel,
8121 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8122 );
8123 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8124 }));
8125 let state_cancel = state.clone();
8126 let ec_cancel = editor_container.clone();
8127 let editor_cancel = editor.clone();
8128 let tui_cancel = tui.clone();
8129 list.on_cancel(Arc::new(move || {
8130 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8131 }));
8132
8133 open_selector(
8134 state,
8135 editor_container,
8136 editor,
8137 tui,
8138 list,
8139 SelectorKind::Images,
8140 );
8141}
8142
8143fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8151 let text = editor.get_text();
8152 let cursor = editor_cursor_offset(editor, &text);
8153 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8154 render_autocomplete(state, suggestions);
8155}
8156
8157fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8160 let (row, col) = editor.cursor_position();
8161 let mut offset = 0;
8162 for (index, line) in text.split('\n').enumerate() {
8163 if index == row {
8164 return (offset + col.min(line.len())).min(text.len());
8165 }
8166 offset = offset.saturating_add(line.len() + 1);
8167 }
8168 text.len()
8169}
8170
8171fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8174 let offset = offset.min(text.len());
8175 let mut consumed = 0;
8176 for (row, line) in text.split('\n').enumerate() {
8177 let end = consumed + line.len();
8178 if offset <= end {
8179 editor.set_cursor(row, offset - consumed);
8180 return;
8181 }
8182 consumed = end + 1;
8183 }
8184 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8185 editor.set_cursor(
8186 last_row,
8187 text.rsplit('\n').next().map(str::len).unwrap_or(0),
8188 );
8189}
8190
8191fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8193 state.autocomplete_container.clear();
8194 let Some(sugg) = suggestions else {
8195 return;
8196 };
8197 if sugg.items.is_empty() {
8198 return;
8199 }
8200 let accent = state.theme_manager.get().colors.accent;
8203 let muted = state.theme_manager.get().colors.muted;
8204 for (i, item) in sugg
8205 .items
8206 .iter()
8207 .take(state.autocomplete_max_visible)
8208 .enumerate()
8209 {
8210 let prefix = if i == 0 { "→ " } else { " " };
8211 let label = item.display_text();
8212 let line = if i == 0 {
8213 format!(
8214 "{prefix}{} {}",
8215 accent.fg(label),
8216 muted.fg(item.description.as_deref().unwrap_or(""))
8217 )
8218 } else {
8219 format!(
8220 "{prefix}{} {}",
8221 muted.fg(label),
8222 muted.fg(item.description.as_deref().unwrap_or(""))
8223 )
8224 };
8225 state
8226 .autocomplete_container
8227 .add_child(Arc::new(Text::new(line, 1, 0)));
8228 }
8229}
8230
8231fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8235 let text = editor.get_text();
8236 let cursor = editor_cursor_offset(editor, &text);
8237 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8238 return false;
8239 };
8240 let Some(top) = sugg.items.first() else {
8241 return false;
8242 };
8243 let start = sugg.start.min(text.len());
8247 let end = sugg.end.min(text.len());
8248 let mut replaced = String::with_capacity(text.len() + top.text.len());
8249 replaced.push_str(&text[..start]);
8250 replaced.push_str(&top.text);
8251 replaced.push_str(&text[end..]);
8254 if top.insert_space && !replaced.ends_with('/') {
8255 replaced.push(' ');
8256 }
8257 let new_cursor = replaced.len().min(
8260 start
8261 + top.text.len()
8262 + if top.insert_space && !top.text.ends_with('/') {
8263 1
8264 } else {
8265 0
8266 },
8267 );
8268 editor.set_text(&replaced);
8269 set_editor_cursor_offset(editor, &replaced, new_cursor);
8270 state.autocomplete_container.clear();
8271 true
8272}
8273
8274fn add_welcome_message(container: &Arc<Container>) {
8280 add_welcome_message_with_capabilities(container, &[], &[]);
8281}
8282
8283fn add_welcome_message_with_capabilities(
8287 container: &Arc<Container>,
8288 active_tools: &[String],
8289 skills: &[String],
8290) {
8291 let c = current_theme().colors;
8292 let title = format!(
8296 "{} {}",
8297 c.accent.fg(&tui_bold("rpi")),
8298 c.muted.fg("interactive TUI")
8299 );
8300 container.add_child(Arc::new(Text::new(title, 1, 0)));
8301 container.add_child(Arc::new(Spacer::new(1)));
8302 container.add_child(Arc::new(Text::new(
8303 c.dim.fg("Type your message and press Enter to send."),
8304 1,
8305 0,
8306 )));
8307 let hint = c
8308 .dim
8309 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8310 container.add_child(Arc::new(Text::new(hint, 1, 0)));
8311 container.add_child(Arc::new(Spacer::new(1)));
8312 container.add_child(Arc::new(Text::new(
8313 welcome_capability_line("Tools", active_tools),
8314 1,
8315 0,
8316 )));
8317 container.add_child(Arc::new(Text::new(
8318 welcome_capability_line("Skills", skills),
8319 1,
8320 0,
8321 )));
8322 container.add_child(Arc::new(DynamicBorder::new()));
8323}
8324
8325fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8328 let colors = current_theme().colors;
8329 let group = Arc::new(Container::new());
8330
8331 for warning in &report.warnings {
8332 let body = format!(
8333 "{}\n{} {}{}",
8334 colors.error.fg(&warning.message),
8335 colors.muted.fg("Run"),
8336 colors.accent.fg(&warning.command),
8337 colors.muted.fg(" to retry.")
8338 );
8339 add_update_panel(&group, "Update Failed", &body, colors.error);
8340 }
8341
8342 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8343 let body = format!(
8344 "{} {}{}",
8345 colors
8346 .muted
8347 .fg(&format!("New version {} is available. Run", notice.latest)),
8348 colors.accent.fg(¬ice.command),
8349 colors.muted.fg(".")
8350 );
8351 add_update_panel(&group, "Update Available", &body, colors.warning);
8352 }
8353
8354 let package_notices = report
8355 .notices
8356 .iter()
8357 .filter(|notice| notice.name != "rpi")
8358 .collect::<Vec<_>>();
8359 if !package_notices.is_empty() {
8360 let command = package_notices[0].command.as_str();
8361 let mut lines = vec![format!(
8362 "{} {}{}",
8363 colors.muted.fg("Package updates are available. Run"),
8364 colors.accent.fg(command),
8365 colors.muted.fg(".")
8366 )];
8367 lines.push(colors.muted.fg("Packages:"));
8368 lines.extend(
8369 package_notices
8370 .into_iter()
8371 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8372 );
8373 add_update_panel(
8374 &group,
8375 "Package Updates Available",
8376 &lines.join("\n"),
8377 colors.warning,
8378 );
8379 }
8380
8381 if group.child_count() > 0 {
8385 container.add_child(group);
8386 }
8387}
8388
8389fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8390 container.add_child(Arc::new(Spacer::new(1)));
8391 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8392 container.add_child(Arc::new(Text::new(
8393 format!("{}\n{body}", color.fg(&tui_bold(title))),
8394 1,
8395 0,
8396 )));
8397 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8398}
8399
8400fn welcome_capability_line(label: &str, names: &[String]) -> String {
8401 let c = current_theme().colors;
8402 let value = if names.is_empty() {
8403 "none".to_string()
8404 } else {
8405 names.join(" · ")
8406 };
8407 format!(
8408 "{} {}",
8409 c.accent.fg(&format!("{label} ({})", names.len())),
8410 c.muted.fg(&value)
8411 )
8412}
8413
8414fn add_help_message(container: &Arc<Container>) {
8416 let c = current_theme().colors;
8417 container.add_child(Arc::new(Text::new(
8421 c.md_heading.fg(&tui_bold("📚 Available Commands")),
8422 1,
8423 0,
8424 )));
8425 container.add_child(Arc::new(Spacer::new(1)));
8426
8427 let cmds: &[(&str, &str)] = &[
8428 ("/help, /?", "Show this help message"),
8429 ("/clear, /new", "Clear the conversation"),
8430 ("/exit, /quit, /q", "Exit the application"),
8431 ("/version, /v", "Show version information"),
8432 ("/changelog", "Show recent release changes"),
8433 ("/model, /m", "Choose a model (live switch)"),
8434 ("/thinking, /think", "Set reasoning depth (selector)"),
8435 ("/tools", "Toggle built-in tools on/off"),
8436 ("/images", "Toggle inline image rendering"),
8437 ("/session", "List saved sessions"),
8438 ("/theme", "Choose a theme (selector)"),
8439 ("/compact", "Compact the conversation"),
8440 ("/copy", "Copy last reply to clipboard"),
8441 ("/hotkeys", "Show keyboard shortcuts"),
8442 ("/armin", "🐾 Easter egg"),
8443 ("/earendil", "Earendil announcement"),
8444 ];
8445 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8446 for (cmd, desc) in cmds {
8447 let row = format!(
8448 " {:<cmd_w$} {} {}",
8449 c.accent.fg(cmd),
8450 c.dim.fg("—"),
8451 c.muted.fg(desc)
8452 );
8453 container.add_child(Arc::new(Text::new(row, 1, 0)));
8454 }
8455 container.add_child(Arc::new(Spacer::new(1)));
8456}
8457
8458fn add_version_message(container: &Arc<Container>) {
8460 let c = current_theme().colors;
8461 container.add_child(Arc::new(Text::new(
8462 c.md_heading.fg(&tui_bold("📦 Version Information")),
8463 1,
8464 0,
8465 )));
8466 container.add_child(Arc::new(Spacer::new(1)));
8467 container.add_child(Arc::new(Text::new(
8470 format!(
8471 " {} {}",
8472 c.muted.fg("rpi-cli"),
8473 c.text.fg(&format!("v{}", crate::VERSION))
8474 ),
8475 1,
8476 0,
8477 )));
8478 container.add_child(Arc::new(Text::new(
8479 format!(
8480 " {}",
8481 c.dim.fg("Rust implementation of pi coding agent TUI")
8482 ),
8483 1,
8484 0,
8485 )));
8486 container.add_child(Arc::new(Spacer::new(1)));
8487}
8488
8489fn add_changelog_message(container: &Arc<Container>) {
8493 let c = current_theme().colors;
8494 container.add_child(Arc::new(Text::new(
8495 c.md_heading.fg(&tui_bold("Recent Changes")),
8496 1,
8497 0,
8498 )));
8499 container.add_child(Arc::new(Spacer::new(1)));
8500 let entries = [
8501 (
8502 "Native parity phase 1",
8503 "models, images, trust, export, and JSON events",
8504 ),
8505 (
8506 "TUI controls",
8507 "external editor, thinking levels, and tool output toggles",
8508 ),
8509 (
8510 "Provider auth",
8511 "OpenAI-compatible API key aliases and gateway headers",
8512 ),
8513 ];
8514 for (release, summary) in entries {
8515 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
8516 container.add_child(Arc::new(Text::new(row, 1, 0)));
8517 }
8518 container.add_child(Arc::new(Text::new(
8519 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8520 1,
8521 0,
8522 )));
8523 container.add_child(Arc::new(Spacer::new(1)));
8524}
8525
8526fn add_hotkeys_message(container: &Arc<Container>) {
8528 let c = current_theme().colors;
8529 container.add_child(Arc::new(Text::new(
8530 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
8531 1,
8532 0,
8533 )));
8534 container.add_child(Arc::new(Spacer::new(1)));
8535 let keys: &[(&str, &str)] = &[
8536 ("Enter", "Send message"),
8537 ("Shift+Enter", "New line"),
8538 ("Tab", "Accept autocomplete suggestion"),
8539 ("Ctrl+A / Ctrl+E", "Line start / end"),
8540 (
8541 "Ctrl+K / Ctrl+U",
8542 "Kill to end / start of line (Ctrl+Y yanks)",
8543 ),
8544 ("Ctrl+- / Ctrl+R", "Undo / redo"),
8545 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8546 ("Alt+Backspace", "Kill previous word"),
8547 ("Ctrl+C", "Abort a run, or exit when idle"),
8548 ("Esc", "Abort a running prompt"),
8549 ("Ctrl+L", "Open model selector"),
8550 ("Ctrl+M", "Cycle to the next model (live)"),
8551 ("Ctrl+O", "Expand/collapse all tool output"),
8552 ("Ctrl+T", "Show/hide reasoning blocks"),
8553 ("PageUp/Down", "Scroll transcript by one page"),
8554 ("Home / End", "Jump to transcript start / latest output"),
8555 ];
8556 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8557 for (key, desc) in keys {
8558 let row = format!(
8559 " {:<key_w$} {} {}",
8560 c.accent.fg(key),
8561 c.dim.fg("—"),
8562 c.muted.fg(desc)
8563 );
8564 container.add_child(Arc::new(Text::new(row, 1, 0)));
8565 }
8566 container.add_child(Arc::new(Spacer::new(1)));
8567}
8568
8569fn add_user_message(container: &Arc<Container>, text: &str) {
8575 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
8576 container.add_child(Arc::new(Spacer::new(1)));
8577}
8578
8579fn add_error_message(container: &Arc<Container>, text: &str) {
8581 let c = current_theme().colors;
8582 let text = sanitize_error_message(text);
8583 container.add_child(Arc::new(Text::new(
8584 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
8585 1,
8586 0,
8587 )));
8588 container.add_child(Arc::new(Spacer::new(1)));
8589}
8590
8591fn sanitize_error_message(text: &str) -> String {
8596 const MAX_ERROR_CHARS: usize = 16 * 1024;
8597 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
8598 let mut count = 0;
8599 let mut escape_mode = 0u8;
8601 for ch in text.chars() {
8602 if escape_mode != 0 {
8603 match escape_mode {
8604 1 if ch == '[' => escape_mode = 2,
8605 1 if ch == ']' => escape_mode = 3,
8606 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
8607 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
8608 3 if ch == '\x07' => escape_mode = 0,
8609 _ => {}
8610 }
8611 continue;
8612 }
8613 if ch == '\x1b' {
8614 escape_mode = 1;
8615 continue;
8616 }
8617 if count >= MAX_ERROR_CHARS {
8618 result.push_str("…");
8619 break;
8620 }
8621 match ch {
8622 '\n' | '\t' => {
8623 result.push(ch);
8624 count += 1;
8625 }
8626 '\r' => {}
8627 c if c.is_control() => {}
8628 c => {
8629 result.push(c);
8630 count += 1;
8631 }
8632 }
8633 }
8634 if result.trim().is_empty() {
8635 "Provider request failed.".to_string()
8636 } else {
8637 result
8638 }
8639}
8640
8641fn add_note_message(container: &Arc<Container>, text: &str) {
8643 let c = current_theme().colors;
8644 container.add_child(Arc::new(Text::new(
8645 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
8646 1,
8647 0,
8648 )));
8649 container.add_child(Arc::new(Spacer::new(1)));
8650}
8651
8652fn show_context_panel(
8661 chat: &Arc<Container>,
8662 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
8663) {
8664 let skills = resources.skills.as_deref().unwrap_or(&[]);
8665 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
8666 let mut lines: Vec<String> = Vec::new();
8667 lines.push("📂 Discovered resources for this session:".into());
8668
8669 if skills.is_empty() {
8670 lines.push(
8671 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
8672 );
8673 } else {
8674 lines.push(format!(" Skills ({}):", skills.len()));
8675 for s in skills {
8676 let marker = if s.disable_model_invocation == Some(true) {
8677 " [hidden]"
8678 } else {
8679 ""
8680 };
8681 let desc: String = s.description.chars().take(72).collect();
8682 lines.push(format!(" • {}{marker} — {desc}", s.name));
8683 }
8684 }
8685
8686 if templates.is_empty() {
8687 lines.push(
8688 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8689 );
8690 } else {
8691 lines.push(format!(" Prompt templates ({}):", templates.len()));
8692 for t in templates {
8693 let desc = t
8694 .description
8695 .as_deref()
8696 .unwrap_or("(no description)")
8697 .chars()
8698 .take(72)
8699 .collect::<String>();
8700 lines.push(format!(" • /{} — {desc}", t.name));
8701 }
8702 }
8703 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8704 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8705 lines.push(
8706 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8707 .into(),
8708 );
8709 let body = lines.join("\n");
8710 container_note_block(chat, &body);
8711}
8712
8713fn container_note_block(container: &Arc<Container>, body: &str) {
8715 for line in body.lines() {
8716 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8717 }
8718 container.add_child(Arc::new(Spacer::new(1)));
8719}
8720
8721pub fn is_tui_supported() -> bool {
8727 std::io::stdout().is_terminal()
8728}
8729
8730#[allow(unused_imports)]
8732use rpi_tui::Color as _Color;
8733
8734#[cfg(test)]
8735mod tests {
8736 use super::*;
8737 use rpi_tui::Component;
8738
8739 #[test]
8740 fn verbose_overrides_quiet_startup_listing() {
8741 assert!(should_show_startup_listing(false, false));
8742 assert!(should_show_startup_listing(true, false));
8743 assert!(should_show_startup_listing(true, true));
8744 assert!(!should_show_startup_listing(false, true));
8745 }
8746
8747 #[test]
8748 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8749 let tmp = tempfile::tempdir().unwrap();
8750 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8751 let local_root = tmp.path().join("local-package");
8752 std::fs::create_dir_all(git_root.join(".git")).unwrap();
8753 std::fs::create_dir_all(&local_root).unwrap();
8754 std::fs::write(
8755 git_root.join("package.json"),
8756 r#"{"name":"git-demo","version":"1.0.0"}"#,
8757 )
8758 .unwrap();
8759 std::fs::write(
8760 local_root.join("package.json"),
8761 r#"{"name":"local-demo","version":"1.0.0"}"#,
8762 )
8763 .unwrap();
8764 let resources = crate::packages::discover(
8765 tmp.path(),
8766 &[
8767 "git:github.com/example/repo".to_string(),
8768 local_root.to_string_lossy().into_owned(),
8769 ],
8770 );
8771 assert_eq!(resources.packages.len(), 2);
8772
8773 let filtered = git_only_update_resources(resources);
8774
8775 assert_eq!(filtered.packages.len(), 1);
8776 assert!(matches!(
8777 filtered.packages[0].source,
8778 crate::packages::PackageSource::Git
8779 ));
8780 }
8781
8782 #[test]
8783 fn trust_command_uses_the_session_cwd_after_process_chdir() {
8784 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8785 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8786 const TEST_NAME: &str =
8787 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8788
8789 if std::env::var_os(CHILD_ENV).is_some() {
8790 let session_cwd = std::path::PathBuf::from(
8791 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8792 );
8793 let changed_cwd = std::env::current_dir().unwrap();
8794
8795 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8796
8797 assert_eq!(
8798 crate::config::project_trust_decision(&session_cwd).unwrap(),
8799 Some(true)
8800 );
8801 assert_eq!(
8802 crate::config::project_trust_decision(&changed_cwd).unwrap(),
8803 None
8804 );
8805 return;
8806 }
8807
8808 let tmp = tempfile::tempdir().unwrap();
8809 let session_cwd = tmp.path().join("session-project");
8810 let changed_cwd = tmp.path().join("extension-cwd");
8811 let agent_dir = tmp.path().join("agent");
8812 std::fs::create_dir_all(&session_cwd).unwrap();
8813 std::fs::create_dir_all(&changed_cwd).unwrap();
8814 std::fs::create_dir_all(&agent_dir).unwrap();
8815
8816 let status = std::process::Command::new(std::env::current_exe().unwrap())
8817 .arg("--exact")
8818 .arg(TEST_NAME)
8819 .arg("--nocapture")
8820 .env(CHILD_ENV, "1")
8821 .env(SESSION_CWD_ENV, &session_cwd)
8822 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8823 .current_dir(&changed_cwd)
8824 .status()
8825 .unwrap();
8826
8827 assert!(status.success(), "child test process failed: {status}");
8828 }
8829
8830 #[test]
8831 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8832 let global = crate::settings::Settings {
8833 editor_padding_x: Some(3),
8834 autocomplete_max_visible: Some(6),
8835 hide_thinking_block: Some(false),
8836 quiet_startup: Some(true),
8837 show_terminal_progress: Some(true),
8838 ..Default::default()
8839 };
8840 let rpi_project = crate::settings::Settings {
8841 editor_padding_x: Some(0),
8842 hide_thinking_block: Some(true),
8843 quiet_startup: Some(false),
8844 show_terminal_progress: Some(false),
8845 ..Default::default()
8846 };
8847 let pi_project = crate::settings::Settings {
8848 editor_padding_x: Some(9),
8849 autocomplete_max_visible: Some(12),
8850 quiet_startup: Some(true),
8851 ..Default::default()
8852 };
8853
8854 assert_eq!(
8855 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8856 TuiStartupSettings {
8857 editor_padding_x: 0,
8858 autocomplete_max_visible: 12,
8859 hide_thinking: true,
8860 quiet_startup: false,
8861 show_terminal_progress: false,
8862 }
8863 );
8864 }
8865
8866 #[test]
8867 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8868 let global = crate::settings::Settings {
8869 quiet_startup: Some(false),
8870 ..Default::default()
8871 };
8872 let rpi_project = crate::settings::Settings::default();
8873 let pi_project = crate::settings::Settings {
8874 quiet_startup: Some(true),
8875 ..Default::default()
8876 };
8877
8878 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8879
8880 assert!(resolved.quiet_startup);
8881 }
8882
8883 #[test]
8884 fn tui_startup_settings_use_global_values_without_project_fields() {
8885 let global = crate::settings::Settings {
8886 editor_padding_x: Some(7),
8887 autocomplete_max_visible: Some(8),
8888 hide_thinking_block: Some(true),
8889 quiet_startup: Some(true),
8890 show_terminal_progress: Some(false),
8891 ..Default::default()
8892 };
8893
8894 assert_eq!(
8895 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8896 TuiStartupSettings {
8897 editor_padding_x: 7,
8898 autocomplete_max_visible: 8,
8899 hide_thinking: true,
8900 quiet_startup: true,
8901 show_terminal_progress: false,
8902 }
8903 );
8904 }
8905
8906 #[test]
8907 fn tui_startup_settings_ignore_untrusted_project_values() {
8908 let global = crate::settings::Settings {
8909 quiet_startup: Some(false),
8910 show_terminal_progress: Some(true),
8911 ..Default::default()
8912 };
8913 let project = crate::settings::Settings {
8914 quiet_startup: Some(true),
8915 show_terminal_progress: Some(false),
8916 ..Default::default()
8917 };
8918
8919 let resolved = resolve_tui_startup_settings(&global, &[project], false);
8920
8921 assert!(!resolved.quiet_startup);
8922 assert!(resolved.show_terminal_progress);
8923 }
8924
8925 #[test]
8926 fn transcript_page_uses_viewport_with_overlap() {
8927 assert_eq!(transcript_page_size(24), 20);
8928 assert_eq!(transcript_page_size(4), 1);
8929 assert_eq!(transcript_page_size(0), 1);
8930 }
8931
8932 #[test]
8933 fn key_repeat_is_dispatched_but_release_is_not() {
8934 assert!(should_dispatch_key(KeyEventKind::Press));
8935 assert!(should_dispatch_key(KeyEventKind::Repeat));
8936 assert!(!should_dispatch_key(KeyEventKind::Release));
8937 }
8938
8939 #[test]
8940 fn key_event_encoding_matches_pi_keybinding_protocol() {
8941 let key = |code, modifiers| KeyEvent::new(code, modifiers);
8942 assert_eq!(
8943 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
8944 "\r"
8945 );
8946 assert_eq!(
8947 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
8948 "\x1b[13;2u"
8949 );
8950 assert_eq!(
8951 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
8952 "\x1b[9;2u"
8953 );
8954 assert_eq!(
8955 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
8956 "\x1b[Z"
8957 );
8958 assert_eq!(
8959 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
8960 "\x1b[Z"
8961 );
8962 assert_eq!(
8963 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
8964 "\x03"
8965 );
8966 assert_eq!(
8967 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
8968 "\x0f"
8969 );
8970 assert_eq!(
8971 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
8972 "!"
8973 );
8974 assert_eq!(
8975 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
8976 "1"
8977 );
8978 }
8979
8980 #[test]
8981 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
8982 let bridge = JsDialogBridge::default();
8983 let (sender, receiver) = std_mpsc::channel();
8984 bridge.pending.lock().unwrap().push_back(JsDialogPending {
8985 request: JsDialogRequest {
8986 id: "dialog-1".into(),
8987 method: "input".into(),
8988 title: String::new(),
8989 message: String::new(),
8990 options: Vec::new(),
8991 placeholder: None,
8992 prefill: None,
8993 },
8994 result: sender,
8995 });
8996
8997 bridge.cancel("dialog-1");
9000 assert!(bridge.take_pending().is_none());
9001 assert_eq!(
9002 receiver.recv().unwrap(),
9003 serde_json::json!({ "cancelled": true })
9004 );
9005 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9006 }
9007
9008 #[test]
9009 fn test_layout_renders_welcome_message() {
9010 let chat = Arc::new(Container::new());
9011 add_welcome_message(&chat);
9012
9013 let scroll = Arc::new(ScrollView::new(
9014 chat.clone(),
9015 ScrollViewOptions {
9016 follow: FollowMode::End,
9017 primary: true,
9018 ..Default::default()
9019 },
9020 ));
9021
9022 let editor = Arc::new(Editor::new(
9023 EditorOptions {
9024 padding_x: 1,
9025 ..Default::default()
9026 },
9027 EditorStyle::default(),
9028 Arc::new(rpi_tui::Keybindings::new()),
9029 ));
9030 let dock = Arc::new(Container::new());
9031 dock.add_child(editor);
9032
9033 let footer = Arc::new(FooterComponent::new());
9034
9035 let root = VStack::from_children(vec![
9036 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9037 StackChild::Entry(StackEntry::new(dock)),
9038 StackChild::Entry(StackEntry::new(footer)),
9039 ]);
9040
9041 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9042
9043 let all: String = frame.lines.join("\n");
9044 assert!(
9045 all.contains("rpi"),
9046 "Welcome message not found. Rendered: {}",
9047 all
9048 );
9049 assert!(
9050 all.contains("Type your message"),
9051 "Help text not found. Rendered: {}",
9052 all
9053 );
9054 }
9055
9056 #[test]
9057 fn test_chat_container_has_welcome_content() {
9058 let chat = Arc::new(Container::new());
9059 add_welcome_message_with_capabilities(
9060 &chat,
9061 &["read".into(), "bash".into(), "web_fetch".into()],
9062 &["rust-review".into(), "release".into()],
9063 );
9064
9065 let lines = chat.render(80);
9066 let all: String = lines.join("\n");
9067 let plain = strip_ansi(&all);
9070 assert!(
9071 plain.contains("rpi"),
9072 "Welcome message not in chat container: {:?}",
9073 lines
9074 );
9075 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9076 assert!(
9077 plain.contains("read · bash · web_fetch"),
9078 "Tool names missing: {plain}"
9079 );
9080 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9081 assert!(
9082 plain.contains("rust-review · release"),
9083 "Skill names missing: {plain}"
9084 );
9085 }
9086
9087 #[test]
9088 fn update_notices_render_inside_the_transcript() {
9089 let chat = Arc::new(Container::new());
9090 let report = crate::updates::UpdateReport {
9091 notices: vec![
9092 crate::updates::UpdateNotice {
9093 name: "rpi".into(),
9094 current: "0.1.10".into(),
9095 latest: "0.1.11".into(),
9096 command: "rpi update".into(),
9097 },
9098 crate::updates::UpdateNotice {
9099 name: "rpi-search".into(),
9100 current: "0.1.0".into(),
9101 latest: "0.1.1".into(),
9102 command: "rpi pi-package update".into(),
9103 },
9104 ],
9105 warnings: vec![crate::updates::UpdateWarning {
9106 message: "The previously scheduled rpi update failed: access denied".into(),
9107 command: "rpi update".into(),
9108 }],
9109 };
9110
9111 add_update_notices(&chat, &report);
9112
9113 assert_eq!(chat.child_count(), 1);
9114 let plain = strip_ansi(&chat.render(80).join("\n"));
9115 assert!(plain.contains("Update Failed"), "{plain}");
9116 assert!(
9117 plain.contains("rpi update failed: access denied"),
9118 "{plain}"
9119 );
9120 assert!(plain.contains("Update Available"), "{plain}");
9121 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9122 assert!(plain.contains("rpi update"), "{plain}");
9123 assert!(plain.contains("Package Updates Available"), "{plain}");
9124 assert!(plain.contains("rpi pi-package update"), "{plain}");
9125 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9126 }
9127
9128 #[test]
9129 fn empty_update_report_does_not_add_transcript_content() {
9130 let chat = Arc::new(Container::new());
9131
9132 add_update_notices(&chat, &crate::updates::UpdateReport::default());
9133
9134 assert_eq!(chat.child_count(), 0);
9135 }
9136
9137 #[test]
9138 fn skill_reads_are_detected_by_path() {
9139 let name = skill_tool_name(
9140 "read",
9141 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9142 );
9143 assert_eq!(name.as_deref(), Some("release"));
9144
9145 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9146 assert!(name.is_none());
9147
9148 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9150 }
9151
9152 #[test]
9153 fn welcome_capabilities_show_empty_state() {
9154 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9155 assert_eq!(plain, "Skills (0) none");
9156 }
9157
9158 #[test]
9159 fn empty_tool_progress_is_deferred_until_start() {
9160 let empty = rpi_agent::AgentToolResult::default();
9161 assert!(!tool_update_has_payload("", &empty));
9162
9163 let text = rpi_agent::AgentToolResult::text("partial output");
9164 assert!(tool_update_has_payload("partial output", &text));
9165
9166 let details = rpi_agent::AgentToolResult {
9167 details: serde_json::json!({"path": "src/lib.rs"}),
9168 ..Default::default()
9169 };
9170 assert!(tool_update_has_payload("", &details));
9171 }
9172
9173 #[test]
9174 fn completed_stream_reconciles_the_final_tail_before_detaching() {
9175 let component = Arc::new(AssistantMessageComponent::new(
9176 AssistantMessageOptions::default(),
9177 ));
9178 component.set_streaming(true);
9179 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9180
9181 let current = Mutex::new(Some(component.clone()));
9182 let cached = Mutex::new("partial response".to_string());
9183 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9184 final_message.content = vec![Content::text(
9185 "partial response with the previously missing final tail",
9186 )];
9187 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9188
9189 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
9190
9191 assert!(current.lock().unwrap().is_none());
9192 assert_eq!(
9193 cached.lock().unwrap().as_str(),
9194 "partial response with the previously missing final tail"
9195 );
9196 let rendered = strip_ansi(&component.render(100).join("\n"));
9197 assert!(
9198 rendered.contains("previously missing final tail"),
9199 "{rendered}"
9200 );
9201 }
9202
9203 #[test]
9209 fn tab_accept_suggestion_reflects_in_next_render() {
9210 use rpi_tui::render_layout_frame;
9211
9212 let editor = Arc::new(Editor::new(
9213 EditorOptions {
9214 padding_x: 1,
9215 ..Default::default()
9216 },
9217 EditorStyle::default(),
9218 Arc::new(rpi_tui::Keybindings::new()),
9219 ));
9220 editor.set_focused(true);
9221 let editor_container = Arc::new(Container::new());
9222 editor_container.add_child(editor.clone());
9223 let autocomplete_container = Arc::new(Container::new());
9224 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9225 let dock = Arc::new(VStack::from_children(vec![
9226 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9227 StackChild::Entry(
9228 StackEntry::new(editor_container.clone())
9229 .shrink(0)
9230 .min_size(3),
9231 ),
9232 StackChild::Entry(StackEntry::new(footer)),
9233 ]));
9234
9235 let mut manager = AutocompleteManager::new();
9237 let mut combined = CombinedAutocompleteProvider::new();
9238 combined.add_provider(Arc::new(
9239 SlashCommandAutocompleteProvider::with_default_commands(),
9240 ));
9241 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9242 manager.set_provider(Arc::new(combined));
9243 editor.insert("/mo");
9246 assert_eq!(editor.cursor_position(), (0, 3));
9247
9248 let frame_before = render_layout_frame(dock.clone(), 80, 10);
9249 assert!(
9250 frame_before.lines.iter().any(|l| l.contains("/mo")),
9251 "precondition: editor shows the typed prefix. Frame rows:\n{}",
9252 frame_before
9253 .lines
9254 .iter()
9255 .map(|l| format!(" [{l}]"))
9256 .collect::<Vec<_>>()
9257 .join("\n")
9258 );
9259
9260 let text = editor.get_text();
9262 let cursor = editor_cursor_offset(&editor, &text);
9263 let sugg = manager
9264 .get_suggestions(&text, cursor)
9265 .expect("slash suggestions for /mo");
9266 let top = sugg.items.first().expect("at least one suggestion");
9267 let start = sugg.start.min(text.len());
9268 let end = sugg.end.min(text.len());
9269 let mut replaced = String::new();
9270 replaced.push_str(&text[..start]);
9271 replaced.push_str(&top.text);
9272 replaced.push_str(&text[end..]);
9273 if top.insert_space && !replaced.ends_with('/') {
9274 replaced.push(' ');
9275 }
9276 let new_cursor = start + top.text.len();
9277 editor.set_text(&replaced);
9278 set_editor_cursor_offset(&editor, &replaced, new_cursor);
9279 autocomplete_container.clear();
9280 assert_eq!(editor.get_text(), "/model");
9281
9282 let frame_after = render_layout_frame(dock, 80, 10);
9284 let all: String = frame_after.lines.join("\n");
9285 assert!(
9286 all.contains("/model"),
9287 "completed text missing from next render. Got:\n{all}"
9288 );
9289 let editor_line = frame_after
9293 .lines
9294 .iter()
9295 .find(|l| l.contains("/model"))
9296 .expect("editor row with completed text");
9297 assert!(
9298 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9299 "caret must follow the full completed text. Got: {editor_line:?}"
9300 );
9301 }
9302
9303 #[test]
9304 fn multiline_autocomplete_preserves_row_and_column() {
9305 let editor = Arc::new(Editor::simple());
9306 editor.set_text("first\n/mo");
9307 editor.set_cursor(1, 3);
9308
9309 let text = editor.get_text();
9310 assert_eq!(editor_cursor_offset(&editor, &text), 9);
9311
9312 let replaced = "first\n/model";
9313 editor.set_text(replaced);
9314 set_editor_cursor_offset(&editor, replaced, 12);
9315 assert_eq!(editor.cursor_position(), (1, 6));
9316 }
9317
9318 #[test]
9319 fn test_slash_command_dispatch() {
9320 let registry = build_builtin_registry();
9325
9326 let resolves_to = |token: &str, canonical: &str| {
9328 let found = registry.find(token).expect("{token} should resolve");
9329 assert_eq!(
9330 found.name(),
9331 canonical,
9332 "{token} resolved to {} (expected {canonical})",
9333 found.name()
9334 );
9335 };
9336
9337 resolves_to("/help", "/help");
9338 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
9340 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
9344 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
9346 resolves_to("/hotkeys", "/hotkeys");
9347 resolves_to("/model", "/model");
9348 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
9350 resolves_to("/session", "/session");
9351 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
9353 resolves_to("/copy", "/copy");
9354 resolves_to("/thinking", "/thinking");
9355 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
9357 resolves_to("/images", "/images");
9358 resolves_to("/armin", "/armin");
9359 resolves_to("/earendil", "/earendil");
9360 resolves_to("/context", "/context");
9361 resolves_to("/settings", "/settings");
9363 resolves_to("/name", "/name");
9364 resolves_to("/export", "/export");
9365
9366 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9368 }
9369
9370 #[test]
9371
9372 fn test_registry_visible_entries_cover_dispatch() {
9373 let registry = build_builtin_registry();
9378 let names: Vec<String> = registry
9379 .visible_entries()
9380 .iter()
9381 .map(|c| c.name.clone())
9382 .collect();
9383 for recognized in [
9384 "/help",
9385 "/clear",
9386 "/new",
9387 "/exit",
9388 "/quit",
9389 "/version",
9390 "/changelog",
9391 "/model",
9392 "/session",
9393 "/theme",
9394 "/compact",
9395 "/copy",
9396 "/hotkeys",
9397 "/tools",
9398 "/images",
9399 "/thinking",
9400 "/armin",
9401 "/earendil",
9402 ] {
9403 assert!(
9404 names.contains(&recognized.to_string()),
9405 "{recognized} missing from autocomplete list"
9406 );
9407 }
9408 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9410 assert!(
9411 !names.contains(&hidden.to_string()),
9412 "{hidden} should be hidden from autocomplete"
9413 );
9414 }
9415 }
9416
9417 #[test]
9418 fn test_agent_event_mapping_creates_assistant_and_tool() {
9419 use rpi_ai::types::{
9422 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9423 ToolCall, ToolCallType, Usage,
9424 };
9425
9426 let state = Arc::new(TuiState {
9427 current_assistant: std::sync::Mutex::new(None),
9428 tool_components: std::sync::Mutex::new(HashMap::new()),
9429 bash_components: std::sync::Mutex::new(HashMap::new()),
9430 themes_enabled: true,
9431 hide_thinking: std::sync::Mutex::new(false),
9432 tool_outputs_expanded: std::sync::Mutex::new(false),
9433 show_terminal_progress: true,
9434 status: std::sync::Mutex::new(RunStatus::Idle),
9435 js_preparation_cancel: std::sync::Mutex::new(None),
9436 footer: Arc::new(FooterComponent::new()),
9437 status_container: Arc::new(Container::new()),
9438 chat_container: Arc::new(Container::new()),
9439 loader: Arc::new(Loader::new()),
9440 last_assistant_text: std::sync::Mutex::new(String::new()),
9441 active_selector: std::sync::Mutex::new(None),
9442 active_extension_editor: std::sync::Mutex::new(None),
9443 active_extension_input: std::sync::Mutex::new(None),
9444 active_extension_cancel: std::sync::Mutex::new(None),
9445 autocomplete: AutocompleteManager::new(),
9446 autocomplete_container: Arc::new(Container::new()),
9447 autocomplete_max_visible: 5,
9448 pending_images: std::sync::Mutex::new(Vec::new()),
9449 theme_manager: Arc::new(ThemeManager::new()),
9450 tui: None,
9451 current_model_id: std::sync::Mutex::new(String::new()),
9452 show_images: std::sync::Mutex::new(true),
9453 history: std::sync::Mutex::new(Vec::new()),
9454 history_index: std::sync::Mutex::new(-1),
9455 history_draft: std::sync::Mutex::new(None),
9456 last_input_tokens: std::sync::Mutex::new(0),
9457 scoped_edit: std::sync::Mutex::new(None),
9458 markdown_transformer: std::sync::Mutex::new(None),
9459 extension_session: Arc::new(std::sync::Mutex::new(
9460 rpi_extensions::ExtensionSession::none(),
9461 )),
9462 });
9463
9464 let assistant = AssistantMessage {
9468 role: rpi_ai::types::AssistantRole,
9469 content: vec![
9470 Content::Thinking(ThinkingContent {
9471 kind: ThinkingContentType,
9472 thinking: "Reasoning about the reply.".into(),
9473 thinking_signature: None,
9474 redacted: false,
9475 }),
9476 Content::Text(TextContent {
9477 kind: TextContentType,
9478 text: "Hello.".into(),
9479 text_signature: None,
9480 }),
9481 Content::ToolCall(ToolCall {
9482 kind: ToolCallType,
9483 id: "tc1".into(),
9484 name: "bash".into(),
9485 arguments: serde_json::json!({"command": "echo hi"}),
9486 thought_signature: None,
9487 namespace: None,
9488 }),
9489 ],
9490 api: rpi_ai::Api::AnthropicMessages,
9491 provider: "anthropic".into(),
9492 model: "claude-sonnet-5".into(),
9493 response_model: None,
9494 response_id: None,
9495 usage: Usage::zero(),
9496 stop_reason: StopReason::Stop,
9497 deferred: None,
9498 error_message: None,
9499 raw_stop_reason: None,
9500 end_turn: None,
9501 timestamp: 0,
9502 };
9503
9504 let comp = Arc::new(AssistantMessageComponent::new(
9507 AssistantMessageOptions::default(),
9508 ));
9509 comp.set_streaming(true);
9510 comp.update_blocks(&assistant_blocks(&assistant));
9511 let chat = Arc::new(Container::new());
9512 chat.add_child(comp.clone());
9513 *state.current_assistant.lock().unwrap() = Some(comp);
9514
9515 for c in &assistant.content {
9517 if let Content::ToolCall(tc) = c {
9518 let mut tools = state.tool_components.lock().unwrap();
9519 if !tools.contains_key(&tc.id) {
9520 let tc_comp = Arc::new(ToolExecutionComponent::new(
9521 &tc.name,
9522 &tc.arguments.to_string(),
9523 ));
9524 tc_comp.set_running();
9525 chat.add_child(tc_comp.clone());
9526 tools.insert(tc.id.clone(), tc_comp);
9527 }
9528 }
9529 }
9530
9531 let rendered = chat.render(80);
9535 let joined: String = rendered.join("\n");
9536 assert!(
9537 joined.contains("Hello."),
9538 "assistant text not rendered: {joined}"
9539 );
9540 assert!(
9541 joined.contains("Reasoning about the reply."),
9542 "thinking block not rendered: {joined}"
9543 );
9544 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9545 assert!(state.current_assistant.lock().unwrap().is_some());
9546
9547 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
9549 ended.set_result("hi", false);
9550 assert!(state.tool_components.lock().unwrap().is_empty());
9551
9552 assert!(state.try_start_working());
9556 assert!(
9557 !state.try_start_working(),
9558 "a second submit must be rejected"
9559 );
9560 state.set_status(RunStatus::Idle);
9561 state.set_status(RunStatus::Working);
9562 assert_eq!(state.status_container.child_count(), 1);
9563 assert!(state.footer.get_status().is_empty());
9564 state.show_retry(3, 10, 8_000);
9565 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
9566 assert!(retry_status.contains("Retrying (3/10)"));
9567 state.set_status(RunStatus::Working);
9568 {
9569 let mut bash = state.bash_components.lock().unwrap();
9570 bash.insert(
9571 "bash-1".into(),
9572 Arc::new(BashExecutionComponent::new("one")),
9573 );
9574 bash.insert(
9575 "bash-2".into(),
9576 Arc::new(BashExecutionComponent::new("two")),
9577 );
9578 }
9579 state.sync_working_loader_with_bash();
9580 assert_eq!(state.status_container.child_count(), 0);
9581 state.bash_components.lock().unwrap().remove("bash-1");
9582 state.sync_working_loader_with_bash();
9583 assert_eq!(state.status_container.child_count(), 0);
9584 state.bash_components.lock().unwrap().remove("bash-2");
9585 state.sync_working_loader_with_bash();
9586 assert_eq!(state.status_container.child_count(), 1);
9587
9588 state.set_status(RunStatus::Aborting);
9589 assert_eq!(state.status_container.child_count(), 0);
9590 assert!(!state.loader.is_running());
9591 }
9592
9593 #[test]
9594 fn fresh_launch_does_not_restore_old_history() {
9595 let fresh = Args::default();
9596 assert!(!launch_restores_history(&fresh));
9597
9598 let continued = Args {
9599 continue_session: true,
9600 ..Args::default()
9601 };
9602 assert!(launch_restores_history(&continued));
9603
9604 let selected = Args {
9605 session: Some("session-id".into()),
9606 ..Args::default()
9607 };
9608 assert!(launch_restores_history(&selected));
9609 }
9610
9611 #[test]
9612 fn test_short_model_name() {
9613 assert_eq!(
9614 short_model_name("anthropic:claude-sonnet-5"),
9615 "claude-sonnet-5"
9616 );
9617 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
9618 }
9619
9620 #[test]
9621 fn model_selector_items_are_deduplicated_and_provider_qualified() {
9622 use rpi_ai::{Api, Model};
9623
9624 let mut gateway = Model::new(
9625 "gpt-5.6-sol",
9626 "GPT 5.6 Sol",
9627 Api::OpenaiCompletions,
9628 "routeryo-copy",
9629 "https://gateway.example.com",
9630 );
9631 let duplicate = gateway.clone();
9632 let anthropic = Model::new(
9633 "claude-sonnet-5",
9634 "Claude Sonnet 5",
9635 Api::AnthropicMessages,
9636 "anthropic",
9637 "https://api.anthropic.com",
9638 );
9639 gateway.headers = Some(std::collections::BTreeMap::from([(
9640 "authorization".into(),
9641 "Bearer test".into(),
9642 )]));
9643
9644 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
9645 assert_eq!(items.len(), 2);
9646 assert_eq!(items[0].value, "gpt-5.6-sol");
9647 assert_eq!(items[0].label, "GPT 5.6 Sol");
9648 assert_eq!(
9649 items[0].description.as_deref(),
9650 Some("routeryo-copy/gpt-5.6-sol (current)")
9651 );
9652 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
9653 }
9654
9655 #[test]
9656 fn model_selector_match_accepts_bare_and_qualified_ids() {
9657 use rpi_ai::{Api, Model};
9658
9659 let gateway = Model::new(
9660 "gpt-5.6-sol",
9661 "GPT 5.6 Sol",
9662 Api::OpenaiCompletions,
9663 "routeryo-copy",
9664 "https://gateway.example.com",
9665 );
9666 let anthropic = Model::new(
9667 "claude-sonnet-5",
9668 "Claude Sonnet 5",
9669 Api::AnthropicMessages,
9670 "anthropic",
9671 "https://api.anthropic.com",
9672 );
9673 let catalog = [gateway, anthropic];
9674 assert_eq!(
9675 find_model_selector_match(&catalog, "gpt-5.6-sol")
9676 .unwrap()
9677 .provider,
9678 "routeryo-copy"
9679 );
9680 assert_eq!(
9681 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
9682 .unwrap()
9683 .id,
9684 "gpt-5.6-sol"
9685 );
9686 assert_eq!(
9687 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9688 .unwrap()
9689 .id,
9690 "claude-sonnet-5"
9691 );
9692 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9693 }
9694
9695 #[test]
9696 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9697 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9698
9699 let failed = AssistantMessage {
9700 role: AssistantRole,
9701 content: Vec::new(),
9702 api: rpi_ai::Api::AnthropicMessages,
9703 provider: "anthropic".into(),
9704 model: "claude-sonnet-5".into(),
9705 response_model: None,
9706 response_id: None,
9707 usage: Usage::zero(),
9708 stop_reason: StopReason::Error,
9709 deferred: None,
9710 error_message: Some("upstream returned 401".into()),
9711 raw_stop_reason: None,
9712 end_turn: None,
9713 timestamp: 0,
9714 };
9715 assert_eq!(
9716 assistant_error_text(&failed).as_deref(),
9717 Some("upstream returned 401")
9718 );
9719
9720 let mut no_detail = failed;
9721 no_detail.error_message = Some(" ".into());
9722 assert_eq!(
9723 assistant_error_text(&no_detail).as_deref(),
9724 Some("Provider request failed.")
9725 );
9726
9727 let mut aborted = no_detail;
9728 aborted.stop_reason = StopReason::Aborted;
9729 aborted.error_message = Some("abort error: Request aborted".into());
9730 assert_eq!(
9731 assistant_error_text(&aborted).as_deref(),
9732 Some("abort error: Request aborted")
9733 );
9734
9735 aborted.error_message = None;
9736 assert_eq!(
9737 assistant_error_text(&aborted).as_deref(),
9738 Some("Request aborted.")
9739 );
9740 }
9741
9742 #[test]
9743 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9744 assert_eq!(
9745 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9746 "405\nMethod Not Allowed"
9747 );
9748 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9749 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
9750 let long = "x".repeat(20_000);
9751 let cleaned = sanitize_error_message(&long);
9752 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9753 assert!(cleaned.ends_with('…'));
9754 }
9755
9756 #[test]
9757 fn test_cycle_next_model_wraps_around() {
9758 use rpi_ai::{Api, Model};
9759 let mk = |id: &str| {
9760 Model::new(
9761 id,
9762 id,
9763 Api::AnthropicMessages,
9764 "anthropic",
9765 "https://api.anthropic.com",
9766 )
9767 };
9768 let catalog = [mk("a"), mk("b"), mk("c")];
9769 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9771 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9772 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9774 let empty: Vec<Model> = vec![];
9776 assert!(cycle_next_model(&empty, "a").is_none());
9777 }
9778
9779 #[test]
9780 fn test_autocomplete_slash_suggestions_render() {
9781 let state = Arc::new(TuiState {
9784 current_assistant: std::sync::Mutex::new(None),
9785 tool_components: std::sync::Mutex::new(HashMap::new()),
9786 bash_components: std::sync::Mutex::new(HashMap::new()),
9787 themes_enabled: true,
9788 hide_thinking: std::sync::Mutex::new(false),
9789 tool_outputs_expanded: std::sync::Mutex::new(false),
9790 show_terminal_progress: true,
9791 status: std::sync::Mutex::new(RunStatus::Idle),
9792 js_preparation_cancel: std::sync::Mutex::new(None),
9793 footer: Arc::new(FooterComponent::new()),
9794 status_container: Arc::new(Container::new()),
9795 chat_container: Arc::new(Container::new()),
9796 loader: Arc::new(Loader::new()),
9797 last_assistant_text: std::sync::Mutex::new(String::new()),
9798 active_selector: std::sync::Mutex::new(None),
9799 active_extension_editor: std::sync::Mutex::new(None),
9800 active_extension_input: std::sync::Mutex::new(None),
9801 active_extension_cancel: std::sync::Mutex::new(None),
9802 autocomplete: AutocompleteManager::new(),
9803 autocomplete_container: Arc::new(Container::new()),
9804 autocomplete_max_visible: 5,
9805 pending_images: std::sync::Mutex::new(Vec::new()),
9806 theme_manager: Arc::new(ThemeManager::new()),
9807 tui: None,
9808 current_model_id: std::sync::Mutex::new(String::new()),
9809 show_images: std::sync::Mutex::new(true),
9810 history: std::sync::Mutex::new(Vec::new()),
9811 history_index: std::sync::Mutex::new(-1),
9812 history_draft: std::sync::Mutex::new(None),
9813 last_input_tokens: std::sync::Mutex::new(0),
9814 scoped_edit: std::sync::Mutex::new(None),
9815 markdown_transformer: std::sync::Mutex::new(None),
9816 extension_session: Arc::new(std::sync::Mutex::new(
9817 rpi_extensions::ExtensionSession::none(),
9818 )),
9819 });
9820 {
9821 let mut combined = CombinedAutocompleteProvider::new();
9822 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9823 build_builtin_registry().visible_entries(),
9824 )));
9825 state.autocomplete.set_provider(Arc::new(combined));
9826 }
9827
9828 let editor = Arc::new(Editor::simple());
9829 editor.set_text("/he");
9830 editor.set_cursor(0, 3);
9831 refresh_autocomplete(&state, &editor);
9832 let lines = state.autocomplete_container.render(80);
9833 let joined: String = lines.join("\n");
9834 assert!(
9835 joined.contains("/help"),
9836 "slash suggestions not rendered: {joined}"
9837 );
9838
9839 editor.set_text("hello");
9841 editor.set_cursor(0, 5);
9842 refresh_autocomplete(&state, &editor);
9843 assert!(state.autocomplete_container.render(80).is_empty());
9844 }
9845
9846 #[test]
9847 fn test_select_list_swap_restores_editor() {
9848 let state = Arc::new(TuiState {
9852 current_assistant: std::sync::Mutex::new(None),
9853 tool_components: std::sync::Mutex::new(HashMap::new()),
9854 bash_components: std::sync::Mutex::new(HashMap::new()),
9855 themes_enabled: true,
9856 hide_thinking: std::sync::Mutex::new(false),
9857 tool_outputs_expanded: std::sync::Mutex::new(false),
9858 show_terminal_progress: true,
9859 status: std::sync::Mutex::new(RunStatus::Idle),
9860 js_preparation_cancel: std::sync::Mutex::new(None),
9861 footer: Arc::new(FooterComponent::new()),
9862 status_container: Arc::new(Container::new()),
9863 chat_container: Arc::new(Container::new()),
9864 loader: Arc::new(Loader::new()),
9865 last_assistant_text: std::sync::Mutex::new(String::new()),
9866 active_selector: std::sync::Mutex::new(None),
9867 active_extension_editor: std::sync::Mutex::new(None),
9868 active_extension_input: std::sync::Mutex::new(None),
9869 active_extension_cancel: std::sync::Mutex::new(None),
9870 autocomplete: AutocompleteManager::new(),
9871 autocomplete_container: Arc::new(Container::new()),
9872 autocomplete_max_visible: 5,
9873 pending_images: std::sync::Mutex::new(Vec::new()),
9874 theme_manager: Arc::new(ThemeManager::new()),
9875 tui: None,
9876 current_model_id: std::sync::Mutex::new(String::new()),
9877 show_images: std::sync::Mutex::new(true),
9878 history: std::sync::Mutex::new(Vec::new()),
9879 history_index: std::sync::Mutex::new(-1),
9880 history_draft: std::sync::Mutex::new(None),
9881 last_input_tokens: std::sync::Mutex::new(0),
9882 scoped_edit: std::sync::Mutex::new(None),
9883 markdown_transformer: std::sync::Mutex::new(None),
9884 extension_session: Arc::new(std::sync::Mutex::new(
9885 rpi_extensions::ExtensionSession::none(),
9886 )),
9887 });
9888 let editor_container = Arc::new(Container::new());
9889 let editor = Arc::new(Editor::simple());
9890 editor_container.add_child(editor.clone());
9891 assert!(!state.selector_open());
9892
9893 let tui_terminal = Box::new(ProcessTerminal::new());
9894 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9895 let list = Arc::new(SelectList::new(
9896 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9897 5,
9898 ));
9899 open_selector(
9900 &state,
9901 &editor_container,
9902 &editor,
9903 &tui,
9904 list,
9905 SelectorKind::Theme,
9906 );
9907 assert!(state.selector_open());
9908 assert_eq!(editor_container.child_count(), 1);
9910
9911 close_selector(&state, &editor_container, &editor, &tui);
9912 assert!(!state.selector_open());
9913 assert_eq!(editor_container.child_count(), 1);
9915 }
9916
9917 #[test]
9918 fn test_message_history_browse_restores_draft() {
9919 let state = Arc::new(TuiState {
9923 current_assistant: std::sync::Mutex::new(None),
9924 tool_components: std::sync::Mutex::new(HashMap::new()),
9925 bash_components: std::sync::Mutex::new(HashMap::new()),
9926 themes_enabled: true,
9927 hide_thinking: std::sync::Mutex::new(false),
9928 tool_outputs_expanded: std::sync::Mutex::new(false),
9929 show_terminal_progress: true,
9930 status: std::sync::Mutex::new(RunStatus::Idle),
9931 js_preparation_cancel: std::sync::Mutex::new(None),
9932 footer: Arc::new(FooterComponent::new()),
9933 status_container: Arc::new(Container::new()),
9934 chat_container: Arc::new(Container::new()),
9935 loader: Arc::new(Loader::new()),
9936 last_assistant_text: std::sync::Mutex::new(String::new()),
9937 active_selector: std::sync::Mutex::new(None),
9938 active_extension_editor: std::sync::Mutex::new(None),
9939 active_extension_input: std::sync::Mutex::new(None),
9940 active_extension_cancel: std::sync::Mutex::new(None),
9941 autocomplete: AutocompleteManager::new(),
9942 autocomplete_container: Arc::new(Container::new()),
9943 autocomplete_max_visible: 5,
9944 pending_images: std::sync::Mutex::new(Vec::new()),
9945 theme_manager: Arc::new(ThemeManager::new()),
9946 tui: None,
9947 current_model_id: std::sync::Mutex::new(String::new()),
9948 show_images: std::sync::Mutex::new(true),
9949 history: std::sync::Mutex::new(Vec::new()),
9950 history_index: std::sync::Mutex::new(-1),
9951 history_draft: std::sync::Mutex::new(None),
9952 last_input_tokens: std::sync::Mutex::new(0),
9953 scoped_edit: std::sync::Mutex::new(None),
9954 markdown_transformer: std::sync::Mutex::new(None),
9955 extension_session: Arc::new(std::sync::Mutex::new(
9956 rpi_extensions::ExtensionSession::none(),
9957 )),
9958 });
9959 let editor = Arc::new(Editor::simple());
9960
9961 push_history(&state, "first message");
9962 push_history(&state, "second message");
9963 push_history(&state, "second message");
9965 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
9967 assert_eq!(state.history.lock().unwrap()[0], "second message");
9968
9969 editor.set_text("half-typed");
9971 editor.set_cursor(0, 11);
9972
9973 navigate_history(&state, &editor, -1);
9975 assert_eq!(editor.get_text(), "second message");
9976 assert_eq!(*state.history_index.lock().unwrap(), 0);
9977 navigate_history(&state, &editor, -1);
9979 assert_eq!(editor.get_text(), "first message");
9980 assert_eq!(*state.history_index.lock().unwrap(), 1);
9981 navigate_history(&state, &editor, -1);
9983 assert_eq!(editor.get_text(), "first message");
9984 navigate_history(&state, &editor, 1);
9986 assert_eq!(editor.get_text(), "second message");
9987 navigate_history(&state, &editor, 1);
9989 assert_eq!(editor.get_text(), "half-typed");
9990 assert_eq!(*state.history_index.lock().unwrap(), -1);
9991 }
9992
9993 #[test]
9994 fn test_accept_top_suggestion_replaces_prefix() {
9995 let state = Arc::new(TuiState {
9997 current_assistant: std::sync::Mutex::new(None),
9998 tool_components: std::sync::Mutex::new(HashMap::new()),
9999 bash_components: std::sync::Mutex::new(HashMap::new()),
10000 themes_enabled: true,
10001 hide_thinking: std::sync::Mutex::new(false),
10002 tool_outputs_expanded: std::sync::Mutex::new(false),
10003 show_terminal_progress: true,
10004 status: std::sync::Mutex::new(RunStatus::Idle),
10005 js_preparation_cancel: std::sync::Mutex::new(None),
10006 footer: Arc::new(FooterComponent::new()),
10007 status_container: Arc::new(Container::new()),
10008 chat_container: Arc::new(Container::new()),
10009 loader: Arc::new(Loader::new()),
10010 last_assistant_text: std::sync::Mutex::new(String::new()),
10011 active_selector: std::sync::Mutex::new(None),
10012 active_extension_editor: std::sync::Mutex::new(None),
10013 active_extension_input: std::sync::Mutex::new(None),
10014 active_extension_cancel: std::sync::Mutex::new(None),
10015 autocomplete: AutocompleteManager::new(),
10016 autocomplete_container: Arc::new(Container::new()),
10017 autocomplete_max_visible: 5,
10018 pending_images: std::sync::Mutex::new(Vec::new()),
10019 theme_manager: Arc::new(ThemeManager::new()),
10020 tui: None,
10021 current_model_id: std::sync::Mutex::new(String::new()),
10022 show_images: std::sync::Mutex::new(true),
10023 history: std::sync::Mutex::new(Vec::new()),
10024 history_index: std::sync::Mutex::new(-1),
10025 history_draft: std::sync::Mutex::new(None),
10026 last_input_tokens: std::sync::Mutex::new(0),
10027 scoped_edit: std::sync::Mutex::new(None),
10028 markdown_transformer: std::sync::Mutex::new(None),
10029 extension_session: Arc::new(std::sync::Mutex::new(
10030 rpi_extensions::ExtensionSession::none(),
10031 )),
10032 });
10033 {
10034 let mut combined = CombinedAutocompleteProvider::new();
10035 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10036 build_builtin_registry().visible_entries(),
10037 )));
10038 state.autocomplete.set_provider(Arc::new(combined));
10039 }
10040 let editor = Arc::new(Editor::simple());
10041 editor.set_text("/he");
10042 editor.set_cursor(0, 3);
10043 refresh_autocomplete(&state, &editor);
10044 let accepted = accept_top_suggestion(&state, &editor);
10045 assert!(accepted, "should accept the top suggestion");
10046 let text = editor.get_text();
10047 assert!(
10048 text.starts_with("/help"),
10049 "editor text should start with /help, got {text}"
10050 );
10051 }
10052
10053 #[test]
10054 fn configured_key_parser_supports_native_notation() {
10055 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10056 assert_eq!(combo.code, KeyCode::Char('g'));
10057 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10058 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10059 assert_eq!(combo.code, KeyCode::BackTab);
10060 }
10061
10062 #[test]
10063 fn double_escape_trigger_has_half_second_window() {
10064 let now = std::time::Instant::now();
10065 assert!(!double_escape_trigger(None, now));
10066 assert!(double_escape_trigger(
10067 Some(now - std::time::Duration::from_millis(500)),
10068 now
10069 ));
10070 assert!(!double_escape_trigger(
10071 Some(now - std::time::Duration::from_millis(501)),
10072 now
10073 ));
10074 }
10075
10076 #[test]
10079 fn ask_user_tool_names_are_recognized() {
10080 assert!(is_ask_user_tool("ask_user"));
10081 assert!(is_ask_user_tool("ask_user_question"));
10082 assert!(!is_ask_user_tool("bash"));
10083 }
10084
10085 #[test]
10086 fn ask_user_args_display_never_leaks_transport_json() {
10087 let text = ask_user_args_display(&serde_json::json!({
10088 "question": "你的代理端口是多少?",
10089 "context": "本机",
10090 "options": ["7890", {"title": "7897", "description": "clash"}],
10091 "suggest": "1080"
10092 }));
10093 assert!(text.contains("你的代理端口是多少?"));
10094 assert!(text.contains("本机"));
10095 assert!(text.contains("Choices: 7890, 7897"));
10096 assert!(text.contains("Suggestions: 1080"));
10097 assert!(!text.trim_start().starts_with('{'));
10098
10099 let multi = ask_user_args_display(&serde_json::json!({
10100 "questions": [
10101 {"id": "a", "question": "Q1"},
10102 {"id": "b", "header": "H", "question": "Q2"}
10103 ]
10104 }));
10105 assert!(multi.contains("Q1"));
10106 assert!(multi.contains("H: Q2"));
10107
10108 assert_eq!(
10109 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10110 "Confirmation requested: Deploy?"
10111 );
10112 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10113 }
10114
10115 #[test]
10116 fn ask_user_result_and_progress_prefer_readable_content() {
10117 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10118 assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10119 .contains("端口?"));
10120 let empty = rpi_agent::AgentToolResult::default();
10121 assert!(
10122 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10123 .contains("端口?")
10124 );
10125 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10126 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10127 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10128 }
10129
10130 #[test]
10131 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10132 let flat = rpi_extensions::UiDialogRequest {
10133 request_id: "r1".into(),
10134 tool_call_id: Some("c1".into()),
10135 ui: serde_json::json!({
10136 "kind": "selector",
10137 "id": "proxy_port",
10138 "header": "Proxy",
10139 "question": "端口?",
10140 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10141 "allowMultiple": true,
10142 "allowFreeform": false,
10143 "suggest": "1080"
10144 }),
10145 raw: serde_json::json!({}),
10146 };
10147 let prompt = parse_ask_user_prompt(&flat);
10148 assert_eq!(prompt.question_id, "proxy_port");
10149 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10150 assert_eq!(prompt.options.len(), 2);
10151 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10152 assert!(prompt.allow_multiple);
10153 assert!(!prompt.allow_freeform);
10154 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10155
10156 let alias = rpi_extensions::UiDialogRequest {
10157 ui: serde_json::json!({
10158 "questions": [{"id": "q", "question": "Q?", "options": []}]
10159 }),
10160 ..flat.clone()
10161 };
10162 let prompt = parse_ask_user_prompt(&alias);
10163 assert_eq!(prompt.question_id, "q");
10164 assert!(prompt.allow_freeform, "no options defaults to freeform");
10165
10166 let confirm = rpi_extensions::UiDialogRequest {
10167 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10168 ..flat.clone()
10169 };
10170 let prompt = parse_ask_user_prompt(&confirm);
10171 assert_eq!(prompt.kind, "confirm");
10172 assert_eq!(prompt.options.len(), 2);
10173 assert_eq!(prompt.options[0].0, "Yes");
10174 }
10175
10176 #[test]
10177 fn ask_user_bridge_answers_and_cancels_by_request_id() {
10178 let mailbox = rpi_extensions::UiDialogMailbox::new();
10179 mailbox.attach();
10180 let bridge = AskUserBridge::new(mailbox.clone());
10181
10182 mailbox
10184 .handle(serde_json::json!({
10185 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10186 "ui": {"kind": "input", "question": "Port?"}
10187 }))
10188 .unwrap();
10189 mailbox
10190 .handle(serde_json::json!({
10191 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10192 "ui": {"kind": "input", "question": "Level?"}
10193 }))
10194 .unwrap();
10195
10196 let first = bridge.take_pending().expect("r1 visible");
10197 assert_eq!(first.request_id, "r1");
10198 assert!(
10199 bridge.take_pending().is_none(),
10200 "only one request occupies the input slot"
10201 );
10202 bridge.respond("r1", serde_json::json!({"text": "7897"}));
10203 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10204
10205 let second = bridge.take_pending().expect("r2 visible");
10206 assert_eq!(second.request_id, "r2");
10207 bridge.cancel("r2");
10208 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10209
10210 mailbox
10212 .handle(serde_json::json!({
10213 "op": "open", "requestId": "r3",
10214 "ui": {"kind": "input", "question": "X?"}
10215 }))
10216 .unwrap();
10217 bridge.cancel_all();
10218 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10219
10220 bridge.shutdown();
10222 assert!(mailbox
10223 .handle(serde_json::json!({
10224 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10225 }))
10226 .is_err());
10227 }
10228}