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