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(feature = "clipboard")]
6977fn copy_to_clipboard(text: &str) -> bool {
6978 match arboard::Clipboard::new() {
6979 Ok(mut cb) => cb.set_text(text).is_ok(),
6980 Err(_) => false,
6981 }
6982}
6983
6984#[cfg(not(feature = "clipboard"))]
6985fn copy_to_clipboard(_text: &str) -> bool {
6986 false
6987}
6988
6989#[cfg(feature = "clipboard")]
6993fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6994 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6995 let image = match clipboard.get_image() {
6996 Ok(image) => image,
6997 Err(_) => return Ok(None),
6998 };
6999 let width =
7000 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
7001 let height =
7002 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
7003 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
7004 return Err("clipboard image dimensions are outside the supported range".into());
7005 }
7006 let mut bytes = Vec::new();
7007 {
7008 let mut encoder = png::Encoder::new(&mut bytes, width, height);
7009 encoder.set_color(png::ColorType::Rgba);
7010 encoder.set_depth(png::BitDepth::Eight);
7011 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
7012 writer
7013 .write_image_data(&image.bytes)
7014 .map_err(|e| e.to_string())?;
7015 }
7016 Ok(Some(rpi_ai::types::ImageContent {
7017 kind: rpi_ai::types::ImageContentType,
7018 data: base64::engine::general_purpose::STANDARD.encode(bytes),
7019 mime_type: "image/png".into(),
7020 }))
7021}
7022
7023fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
7024 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
7025 let mut options = ImageOptions::default();
7026 options.width = Some(48);
7027 options.alt_text = Some("Attached image".into());
7028 chat.add_child(Arc::new(Image::from_data(bytes, options)));
7029 chat.add_child(Arc::new(Spacer::new(1)));
7030 }
7031}
7032
7033#[cfg(not(feature = "clipboard"))]
7034fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7035 Ok(None)
7036}
7037
7038fn add_assistant_message_blocking(
7045 container: &Arc<Container>,
7046 text: &str,
7047 transformer: Option<MarkdownTransformer>,
7048) {
7049 if text.is_empty() {
7050 return;
7051 }
7052 let msg = Arc::new(AssistantMessageComponent::new(
7053 AssistantMessageOptions::default(),
7054 ));
7055 if let Some(t) = &transformer {
7056 msg.set_markdown_transformer(Some(t.clone()));
7057 }
7058 msg.update_text(text);
7059 container.add_child(msg);
7060 container.add_child(Arc::new(Spacer::new(1)));
7061}
7062
7063async fn drain_agent_events(
7071 mut rx: broadcast::Receiver<AgentEvent>,
7072 tui: Arc<TuiAltScreen>,
7073 state: Arc<TuiState>,
7074 chat: Arc<Container>,
7075) {
7076 loop {
7077 match rx.recv().await {
7078 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
7079 Err(broadcast::error::RecvError::Lagged(_)) => {
7080 continue;
7083 }
7084 Err(broadcast::error::RecvError::Closed) => break,
7085 }
7086 }
7087}
7088
7089async fn handle_agent_event(
7092 event: AgentEvent,
7093 tui: &Arc<TuiAltScreen>,
7094 state: &Arc<TuiState>,
7095 chat: &Arc<Container>,
7096) {
7097 match event {
7098 AgentEvent::AgentStart => {
7099 state.set_status(RunStatus::Working);
7100 tui.request_render(false);
7101 }
7102
7103 AgentEvent::AgentEnd { .. } => {
7104 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7106 comp.set_streaming(false);
7107 }
7108 state.set_status(RunStatus::Idle);
7109 tui.request_render(false);
7110 }
7111
7112 AgentEvent::RetryScheduled {
7113 attempt,
7114 max_retries,
7115 delay_ms,
7116 ..
7117 } => {
7118 state.show_retry(attempt, max_retries, delay_ms);
7119 tui.request_render(false);
7120 }
7121
7122 AgentEvent::TurnStart => {
7123 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7126 comp.set_streaming(false);
7127 }
7128 }
7129
7130 AgentEvent::TurnEnd {
7131 message,
7132 tool_results,
7133 } => {
7134 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7136 if let AgentMessage::Assistant(a) = &message {
7137 comp.update_blocks(&assistant_blocks(a));
7138 }
7139 comp.set_streaming(false);
7140 }
7141 let tools = state.tool_components.lock().unwrap();
7146 for tr in &tool_results {
7147 if tools.contains_key(&tr.tool_call_id) {
7148 let _ = tr;
7151 }
7152 }
7153 drop(tools);
7154 tui.request_render(false);
7155 }
7156
7157 AgentEvent::MessageStart { message } => match message {
7158 AgentMessage::Assistant(a) => {
7159 let comp = Arc::new(AssistantMessageComponent::new(
7160 AssistantMessageOptions::default(),
7161 ));
7162 if let Some(t) = state.markdown_transformer() {
7167 comp.set_markdown_transformer(Some(t));
7168 }
7169 comp.set_hide_thinking(state.hide_thinking());
7170 comp.set_streaming(true);
7171 comp.update_blocks(&assistant_blocks(&a));
7174 chat.add_child(comp.clone());
7175 chat.add_child(Arc::new(Spacer::new(1)));
7178 *state.current_assistant.lock().unwrap() = Some(comp);
7179 tui.request_render(false);
7180 }
7181 AgentMessage::Custom(custom) => {
7182 let payload = serde_json::json!({
7183 "customType": custom.role,
7184 "content": custom.content,
7185 "details": custom.data,
7186 "expanded": false,
7187 "outputPad": 1,
7188 });
7189 if let Some(component) = extension_message_component(
7190 &state.extension_session,
7191 &custom.role,
7192 &payload,
7193 state.markdown_transformer(),
7194 ) {
7195 chat.add_child(component);
7196 chat.add_child(Arc::new(Spacer::new(1)));
7197 tui.request_render(false);
7198 } else {
7199 add_note_message(chat, &custom_message_fallback(&custom));
7200 tui.request_render(false);
7201 }
7202 }
7203 _ => {}
7206 },
7207
7208 AgentEvent::MessageUpdate {
7209 message,
7210 assistant_message_event,
7211 } => {
7212 if let AgentMessage::Assistant(a) = &message {
7213 let text = assistant_text(a);
7214 let mut saw_bash_tool_call = false;
7215 for c in &a.content {
7220 if let Content::ToolCall(tc) = c {
7221 if tc.name.trim().is_empty() {
7225 continue;
7226 }
7227 if tc.name == "bash" {
7228 let command = tc
7234 .arguments
7235 .get("command")
7236 .and_then(|v| v.as_str())
7237 .unwrap_or("");
7238 if command.trim().is_empty() {
7245 continue;
7246 }
7247 saw_bash_tool_call = true;
7248 let mut bash = state.bash_components.lock().unwrap();
7249 if let Some(existing) = bash.get(&tc.id) {
7250 existing.set_command(command);
7255 } else {
7256 let comp = Arc::new(BashExecutionComponent::new(command));
7257 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7258 chat.add_child(comp.clone());
7259 bash.insert(tc.id.clone(), comp);
7260 }
7261 } else {
7262 let mut tools = state.tool_components.lock().unwrap();
7263 let display_args = if is_ask_user_tool(&tc.name) {
7264 ask_user_args_display(&tc.arguments)
7265 } else {
7266 tc.arguments.to_string()
7267 };
7268 if let Some(existing) = tools.get(&tc.id) {
7269 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
7273 existing.set_args(&display_args);
7274 }
7275 } else {
7276 let comp = Arc::new(ToolExecutionComponent::new(
7277 &tc.name,
7278 &display_args,
7279 ));
7280 if is_ask_user_tool(&tc.name) {
7281 comp.set_display_title("ASK USER");
7282 }
7283 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7284 comp.set_running();
7285 chat.add_child(comp.clone());
7286 tools.insert(tc.id.clone(), comp);
7287 }
7288 }
7289 }
7290 }
7291 if saw_bash_tool_call {
7296 state.sync_working_loader_with_bash();
7297 }
7298 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
7300 comp.update_blocks(&assistant_blocks(a));
7303 }
7304 *state.last_assistant_text.lock().unwrap() = text;
7305 tui.request_render(false);
7306 }
7307 }
7308
7309 AgentEvent::MessageEnd { message } => {
7310 if let AgentMessage::Assistant(a) = &message {
7311 let text = assistant_text(a);
7312 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7313 comp.update_blocks(&assistant_blocks(a));
7314 comp.set_streaming(false);
7315 }
7316 if !text.is_empty() {
7318 *state.last_assistant_text.lock().unwrap() = text;
7319 }
7320 let usage = &a.usage;
7326 let prev_input = *state.last_input_tokens.lock().unwrap();
7327 if prev_input > 0
7328 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
7329 && usage.cache_read == 0
7330 {
7331 add_note_message(
7332 &state.chat_container,
7333 &format!(
7334 "Cache miss: {} tokens re-billed",
7335 format_tokens(usage.input)
7336 ),
7337 );
7338 }
7339 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
7340 add_note_message(chat, &text);
7341 }
7342 if let Some(error) = assistant_error_text(a) {
7347 add_error_message(chat, &error);
7348 }
7349 *state.last_input_tokens.lock().unwrap() = usage.input;
7350 }
7351 tui.request_render(false);
7352 }
7353
7354 AgentEvent::ToolExecutionStart {
7355 tool_call_id,
7356 tool_name,
7357 args,
7358 } => {
7359 if tool_name.trim().is_empty() {
7362 return;
7363 }
7364 if tool_name == "bash" {
7365 let command = args
7370 .get("command")
7371 .and_then(|v| v.as_str())
7372 .unwrap_or("")
7373 .to_string();
7374 if command.trim().is_empty() {
7378 return;
7379 }
7380 let mut bash_map = state.bash_components.lock().unwrap();
7381 if let Some(existing) = bash_map.get(&tool_call_id) {
7382 existing.set_command(&command);
7387 } else {
7388 let comp = Arc::new(BashExecutionComponent::new(command));
7389 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7390 chat.add_child(comp.clone());
7391 bash_map.insert(tool_call_id.clone(), comp);
7392 }
7393 } else {
7394 let _comp = {
7395 let mut tools = state.tool_components.lock().unwrap();
7396 if let Some(existing) = tools.get(&tool_call_id) {
7397 if is_ask_user_tool(&tool_name) {
7398 existing.set_display_title("ASK USER");
7399 existing.set_args(&ask_user_args_display(&args));
7400 } else {
7401 existing.set_args(&args.to_string());
7402 }
7403 existing.clone()
7404 } else {
7405 let display_args = if is_ask_user_tool(&tool_name) {
7406 ask_user_args_display(&args)
7407 } else {
7408 args.to_string()
7409 };
7410 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7411 if is_ask_user_tool(&tool_name) {
7412 comp.set_display_title("ASK USER");
7413 }
7414 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7415 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7420 comp.set_skill_name(skill);
7421 }
7422 comp.set_running();
7423 chat.add_child(comp.clone());
7424 tools.insert(tool_call_id.clone(), comp.clone());
7425 comp
7426 }
7427 };
7428 }
7429 state.sync_working_loader_with_bash();
7430 tui.request_render(false);
7431 }
7432
7433 AgentEvent::ToolExecutionUpdate {
7434 tool_call_id,
7435 tool_name,
7436 args,
7437 partial_result,
7438 } => {
7439 if tool_name.trim().is_empty() {
7440 return;
7441 }
7442 let partial_text = if is_ask_user_tool(&tool_name) {
7443 ask_user_progress_text(&args, &partial_result)
7444 } else {
7445 tool_result_text(&partial_result)
7446 };
7447 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7448 if tool_name == "bash" {
7449 let chunk = partial_text;
7454 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7455 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7458 bash.set_command(command);
7459 }
7460 if has_partial_payload {
7461 bash.append_output(&chunk);
7462 }
7463 } else if has_partial_payload {
7464 }
7470 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7471 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7472 comp.set_skill_name(skill);
7473 }
7474 if is_ask_user_tool(&tool_name) {
7475 comp.set_display_title("ASK USER");
7476 comp.set_args(&ask_user_args_display(&args));
7477 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7478 comp.set_args(&args.to_string());
7479 }
7480 if has_partial_payload {
7483 comp.set_result(&partial_text, false);
7484 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7485 }
7486 } else if has_partial_payload {
7487 let display_args = if is_ask_user_tool(&tool_name) {
7492 ask_user_args_display(&args)
7493 } else {
7494 args.to_string()
7495 };
7496 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7497 if is_ask_user_tool(&tool_name) {
7498 comp.set_display_title("ASK USER");
7499 }
7500 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7501 if let Some(skill) = skill_tool_name(&tool_name, &args) {
7502 comp.set_skill_name(skill);
7503 }
7504 comp.set_running();
7505 comp.set_result(&partial_text, false);
7506 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7507 chat.add_child(comp.clone());
7508 state
7509 .tool_components
7510 .lock()
7511 .unwrap()
7512 .insert(tool_call_id.clone(), comp.clone());
7513 }
7514 state.sync_working_loader_with_bash();
7515 tui.request_render(false);
7516 }
7517
7518 AgentEvent::ToolExecutionEnd {
7519 tool_call_id,
7520 tool_name,
7521 result,
7522 is_error,
7523 } => {
7524 if tool_name.trim().is_empty() {
7525 return;
7526 }
7527 if tool_name == "bash" {
7528 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7529 if let Some(bash) = bash {
7530 finalize_bash(&bash, &result, is_error);
7531 } else {
7532 let command = result
7535 .details
7536 .get("command")
7537 .and_then(|v| v.as_str())
7538 .unwrap_or("")
7539 .to_string();
7540 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7541 state.sync_working_loader_with_bash();
7542 tui.render_now(false);
7545 return;
7546 }
7547 let comp = Arc::new(BashExecutionComponent::new(command));
7548 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7549 comp.append_output(&tool_result_text(&result));
7550 finalize_bash(&comp, &result, is_error);
7551 chat.add_child(comp);
7552 }
7553 } else {
7554 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7555 if let Some(comp) = comp {
7556 let result_text = if is_ask_user_tool(&tool_name) {
7557 ask_user_result_text(&result)
7558 } else {
7559 tool_result_text(&result)
7560 };
7561 comp.set_result(&result_text, is_error);
7562 if !is_ask_user_tool(&tool_name) {
7563 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7564 }
7565 } else {
7566 let comp = Arc::new(ToolExecutionComponent::new(
7569 &tool_name,
7570 &if is_ask_user_tool(&tool_name) {
7571 ask_user_args_display(&serde_json::Value::Null)
7572 } else {
7573 "".to_string()
7574 },
7575 ));
7576 if is_ask_user_tool(&tool_name) {
7577 comp.set_display_title("ASK USER");
7578 }
7579 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7580 let result_text = if is_ask_user_tool(&tool_name) {
7581 ask_user_result_text(&result)
7582 } else {
7583 tool_result_text(&result)
7584 };
7585 comp.set_result(&result_text, is_error);
7586 if !is_ask_user_tool(&tool_name) {
7587 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7588 }
7589 chat.add_child(comp.clone());
7590 }
7591 }
7592 state.sync_working_loader_with_bash();
7593 tui.render_now(false);
7599 }
7600 }
7601}
7602
7603fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7607 let fallback = match message.stop_reason {
7608 rpi_ai::StopReason::Error => "Provider request failed.",
7609 rpi_ai::StopReason::Aborted => "Request aborted.",
7610 _ => return None,
7611 };
7612 Some(
7613 message
7614 .error_message
7615 .as_deref()
7616 .filter(|text| !text.trim().is_empty())
7617 .unwrap_or(fallback)
7618 .to_string(),
7619 )
7620}
7621
7622fn finalize_bash(
7626 comp: &Arc<BashExecutionComponent>,
7627 result: &rpi_agent::AgentToolResult,
7628 is_error: bool,
7629) {
7630 let exit_code = if is_error { Some(1) } else { Some(0) };
7633 let truncated = result
7634 .details
7635 .get("truncation")
7636 .and_then(|t| t.get("truncated"))
7637 .and_then(|v| v.as_bool())
7638 .unwrap_or(false);
7639 let full_output_path = result
7640 .details
7641 .get("full_output_path")
7642 .and_then(|v| v.as_str())
7643 .map(|s| s.to_string());
7644 let truncation = BashTruncation {
7645 truncated,
7646 full_output_path,
7647 };
7648 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7652 comp.set_command(command);
7653 }
7654 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
7656}
7657
7658fn apply_edit_diff(
7663 comp: &Arc<ToolExecutionComponent>,
7664 tool_name: &str,
7665 details: &serde_json::Value,
7666 tui: &Arc<TuiAltScreen>,
7667) {
7668 if tool_name != "edit" {
7669 return;
7670 }
7671 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7672 return;
7673 };
7674 if diff_text.is_empty() {
7675 return;
7676 }
7677 let width = tui.width();
7678 let lines = render_diff(diff_text, width);
7679 comp.set_diff(lines);
7680}
7681
7682fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7687 if tool_name != "read" {
7688 return None;
7689 }
7690 let path = args.get("path").and_then(|value| value.as_str())?;
7691 let normalized = path.replace('\\', "/");
7692 let file_name = normalized.rsplit('/').next()?;
7693 if !file_name.eq_ignore_ascii_case("SKILL.md") {
7694 return None;
7695 }
7696 normalized
7697 .trim_end_matches('/')
7698 .rsplit('/')
7699 .nth(1)
7700 .filter(|name| !name.is_empty())
7701 .map(str::to_string)
7702}
7703
7704fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7707 use rpi_agent::TextContentOrImage;
7708 let mut parts: Vec<String> = Vec::new();
7709 for c in &result.content {
7710 if let TextContentOrImage::Text(t) = c {
7711 parts.push(t.text.clone());
7712 }
7713 }
7714 let joined = parts.join("\n");
7715 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7717 if one_line.chars().count() > 200 {
7718 let truncated: String = one_line.chars().take(200).collect();
7719 format!("{truncated}…")
7720 } else {
7721 one_line
7722 }
7723}
7724
7725fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7730 use rpi_agent::TextContentOrImage;
7731 let mut parts: Vec<String> = Vec::new();
7732 for c in &result.content {
7733 if let TextContentOrImage::Text(t) = c {
7734 parts.push(t.text.clone());
7735 }
7736 }
7737 parts.join("\n")
7738}
7739
7740fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7744 !text.trim().is_empty() || !result.details.is_null()
7745}
7746
7747fn is_ask_user_tool(tool_name: &str) -> bool {
7750 matches!(tool_name, "ask_user" | "ask_user_question")
7751}
7752
7753fn ask_user_args_display(args: &serde_json::Value) -> String {
7756 if args.is_null() {
7757 return String::new();
7758 }
7759 if let Some(summary) = args
7760 .get("summary")
7761 .and_then(serde_json::Value::as_str)
7762 .map(str::trim)
7763 .filter(|text| !text.is_empty())
7764 {
7765 return format!("Confirmation requested: {summary}");
7766 }
7767
7768 let mut lines: Vec<String> = Vec::new();
7769 let questions: Vec<&serde_json::Value> = args
7770 .get("questions")
7771 .and_then(serde_json::Value::as_array)
7772 .filter(|items| !items.is_empty())
7773 .map(|items| items.iter().collect())
7774 .unwrap_or_else(|| vec![args]);
7775
7776 for question in questions {
7777 let prompt = question
7778 .get("question")
7779 .and_then(serde_json::Value::as_str)
7780 .or_else(|| {
7781 args.get("question")
7782 .and_then(serde_json::Value::as_str)
7783 })
7784 .map(str::trim)
7785 .unwrap_or_default();
7786 if prompt.is_empty() {
7787 continue;
7788 }
7789 if let Some(header) = question
7790 .get("header")
7791 .and_then(serde_json::Value::as_str)
7792 .map(str::trim)
7793 .filter(|text| !text.is_empty())
7794 {
7795 lines.push(format!("{header}: {prompt}"));
7796 } else {
7797 lines.push(prompt.to_string());
7798 }
7799 let context = question
7800 .get("context")
7801 .and_then(serde_json::Value::as_str)
7802 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7803 .map(str::trim)
7804 .filter(|text| !text.is_empty());
7805 if let Some(context) = context {
7806 lines.push(format!(" {context}"));
7807 }
7808 let options = question
7809 .get("options")
7810 .and_then(serde_json::Value::as_array)
7811 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7812 if let Some(options) = options {
7813 let labels: Vec<String> = options
7814 .iter()
7815 .filter_map(|option| {
7816 option
7817 .get("title")
7818 .and_then(serde_json::Value::as_str)
7819 .or_else(|| option.as_str())
7820 .map(str::trim)
7821 .filter(|label| !label.is_empty())
7822 .map(str::to_string)
7823 })
7824 .collect();
7825 if !labels.is_empty() {
7826 lines.push(format!("Choices: {}", labels.join(", ")));
7827 }
7828 }
7829 if let Some(suggest) = question
7830 .get("suggest")
7831 .and_then(serde_json::Value::as_str)
7832 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7833 .map(str::trim)
7834 .filter(|text| !text.is_empty())
7835 {
7836 lines.push(format!("Suggestions: {suggest}"));
7837 }
7838 }
7839
7840 lines.join("\n")
7841}
7842
7843fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7846 let text = tool_result_text(result);
7847 if !text.trim().is_empty() {
7848 return text;
7849 }
7850 let args_text = ask_user_args_display(args);
7851 if args_text.trim().is_empty() {
7852 "Waiting for your answer…".to_string()
7853 } else {
7854 args_text
7855 }
7856}
7857
7858fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7861 let text = tool_result_text(result);
7862 if text.trim().is_empty() {
7863 "Answer recorded.".to_string()
7864 } else {
7865 text
7866 }
7867}
7868
7869fn open_selector(
7877 state: &Arc<TuiState>,
7878 editor_container: &Arc<Container>,
7879 editor: &Arc<Editor>,
7880 tui: &Arc<TuiAltScreen>,
7881 list: Arc<SelectList>,
7882 kind: SelectorKind,
7883) {
7884 open_selector_with_view(
7885 state,
7886 editor_container,
7887 editor,
7888 tui,
7889 list.clone(),
7890 list,
7891 kind,
7892 );
7893}
7894
7895fn open_selector_with_view<C: Component + 'static>(
7899 state: &Arc<TuiState>,
7900 editor_container: &Arc<Container>,
7901 editor: &Arc<Editor>,
7902 tui: &Arc<TuiAltScreen>,
7903 list: Arc<SelectList>,
7904 view: Arc<C>,
7905 kind: SelectorKind,
7906) {
7907 dispatch_ui_prompt_event(state);
7909
7910 editor.set_focused(false);
7912 state.autocomplete_container.clear();
7915 editor_container.clear();
7917 editor_container.add_child(view.clone());
7918 *state.active_selector.lock().unwrap() = Some((list, kind));
7919 let focused: Arc<dyn Component> = view;
7920 tui.set_focus(Some(focused));
7921 tui.request_render(false);
7922}
7923
7924fn close_selector(
7927 state: &Arc<TuiState>,
7928 editor_container: &Arc<Container>,
7929 editor: &Arc<Editor>,
7930 tui: &Arc<TuiAltScreen>,
7931) {
7932 dispatch_ui_prompt_event_end(state);
7934
7935 editor_container.clear();
7936 editor_container.add_child(editor.clone());
7937 state.autocomplete_container.clear();
7938 editor.set_focused(true);
7939 *state.active_selector.lock().unwrap() = None;
7940 *state.active_extension_cancel.lock().unwrap() = None;
7941 tui.set_focus(Some(editor.clone()));
7942 tui.request_render(false);
7943}
7944
7945fn open_model_selector(
7951 state: &Arc<TuiState>,
7952 editor_container: &Arc<Container>,
7953 editor: &Arc<Editor>,
7954 tui: &Arc<TuiAltScreen>,
7955 catalog: &[rpi_ai::Model],
7956 lane: &Arc<dyn AgentLane>,
7957 lane_model_id: &str,
7958 chat: &Arc<Container>,
7959) {
7960 let items = model_selector_items(catalog, lane_model_id);
7961 if items.is_empty() {
7962 add_note_message(
7963 chat,
7964 "No models in the catalog. Use --model at startup to select one.",
7965 );
7966 tui.request_render(false);
7967 return;
7968 }
7969 let list = Arc::new(SelectList::new(items, 10));
7970
7971 let catalog_arc = catalog.to_vec();
7975 let state_sel = state.clone();
7976 let ec_sel = editor_container.clone();
7977 let editor_sel = editor.clone();
7978 let tui_sel = tui.clone();
7979 let chat_sel = chat.clone();
7980 let lane_sel = lane.clone();
7981 list.on_select(Arc::new(move |item| {
7982 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7983 add_note_message(
7984 &chat_sel,
7985 &format!("Model {} not found in catalog.", item.label),
7986 );
7987 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7988 return;
7989 };
7990 state_sel.set_current_model(&model);
7991 let lane = lane_sel.clone();
7992 tokio::spawn(async move {
7993 let _ = lane.set_model(model).await;
7994 });
7995 add_note_message(
7996 &chat_sel,
7997 &format!(
7998 "Model set to {} — applies to the next message.",
7999 short_model_name(&item.value)
8000 ),
8001 );
8002 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8003 }));
8004 let state_cancel = state.clone();
8005 let ec_cancel = editor_container.clone();
8006 let editor_cancel = editor.clone();
8007 let tui_cancel = tui.clone();
8008 list.on_cancel(Arc::new(move || {
8009 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8010 }));
8011
8012 open_selector(
8013 state,
8014 editor_container,
8015 editor,
8016 tui,
8017 list,
8018 SelectorKind::Model,
8019 );
8020}
8021
8022fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8027 if catalog.is_empty() {
8028 return None;
8029 }
8030 let idx = catalog
8031 .iter()
8032 .position(|m| m.id.eq_ignore_ascii_case(current_id));
8033 match idx {
8034 Some(i) => {
8035 let next = (i + 1) % catalog.len();
8036 Some(catalog[next].clone())
8037 }
8038 None => Some(catalog[0].clone()),
8039 }
8040}
8041
8042fn open_session_selector(
8048 state: &Arc<TuiState>,
8049 editor_container: &Arc<Container>,
8050 editor: &Arc<Editor>,
8051 tui: &Arc<TuiAltScreen>,
8052 cwd: &std::path::Path,
8053 tx: &mpsc::UnboundedSender<TuiMessage>,
8054) {
8055 let dir = crate::session::default_session_dir(cwd);
8056 let mut items: Vec<SelectItem> = Vec::new();
8057 if let Ok(entries) = std::fs::read_dir(&dir) {
8058 for entry in entries.flatten() {
8059 let path = entry.path();
8060 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
8061 continue;
8062 }
8063 let stem = path
8064 .file_stem()
8065 .and_then(|s| s.to_str())
8066 .unwrap_or("(unnamed)")
8067 .to_string();
8068 let display = path
8069 .file_name()
8070 .and_then(|s| s.to_str())
8071 .unwrap_or(&stem)
8072 .to_string();
8073 items.push(SelectItem::new(&stem, &display));
8074 }
8075 }
8076 if items.is_empty() {
8077 add_note_message(
8078 &state.chat_container,
8079 "No saved sessions found. Sessions are created automatically in interactive mode.",
8080 );
8081 tui.request_render(false);
8082 return;
8083 }
8084 let list = Arc::new(SelectList::new(items, 10));
8085
8086 let state_sel = state.clone();
8087 let ec_sel = editor_container.clone();
8088 let editor_sel = editor.clone();
8089 let tui_sel = tui.clone();
8090 let tx_sel = tx.clone();
8091 list.on_select(Arc::new(move |item| {
8092 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8096 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
8097 }));
8098 let state_cancel = state.clone();
8099 let ec_cancel = editor_container.clone();
8100 let editor_cancel = editor.clone();
8101 let tui_cancel = tui.clone();
8102 list.on_cancel(Arc::new(move || {
8103 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8104 }));
8105
8106 open_selector(
8107 state,
8108 editor_container,
8109 editor,
8110 tui,
8111 list,
8112 SelectorKind::Session,
8113 );
8114}
8115
8116fn custom_entry_display_text(
8117 custom_type: &str,
8118 data: Option<&serde_json::Value>,
8119) -> Option<String> {
8120 let data = data?;
8121 let text = data
8122 .get("summary")
8123 .or_else(|| data.get("text"))
8124 .or_else(|| data.get("output"))
8125 .and_then(|value| value.as_str())
8126 .filter(|value| !value.trim().is_empty())?;
8127 let label = match custom_type {
8128 "compactionSummary" => "Compaction summary",
8129 "branchSummary" => "Branch summary",
8130 "bashExecution" => "Command output",
8131 other => other,
8132 };
8133 Some(format!("{label}: {text}"))
8134}
8135
8136async fn open_tree_selector(
8140 harness: &AgentHarness,
8141 state: &Arc<TuiState>,
8142 editor_container: &Arc<Container>,
8143 editor: &Arc<Editor>,
8144 tui: &Arc<TuiAltScreen>,
8145 chat: &Arc<Container>,
8146 tx: &mpsc::UnboundedSender<TuiMessage>,
8147) {
8148 let entries = match harness
8149 .session()
8150 .view("main")
8151 .find_entries(&EntryQuery {
8152 order: Some(EntryOrder::OldestFirst),
8153 ..Default::default()
8154 })
8155 .await
8156 {
8157 Ok(entries) => entries,
8158 Err(error) => {
8159 add_error_message(chat, &format!("Could not read session tree: {error}"));
8160 tui.request_render(false);
8161 return;
8162 }
8163 };
8164 let current = harness.session().get_leaf_id().await.ok().flatten();
8165 let items: Vec<SelectItem> = entries
8166 .iter()
8167 .map(|entry| {
8168 let marker = if current.as_deref() == Some(entry.id()) {
8169 " (current)"
8170 } else {
8171 ""
8172 };
8173 SelectItem::new(
8174 entry.id(),
8175 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
8176 )
8177 .with_description(&entry.id()[..entry.id().len().min(12)])
8178 })
8179 .collect();
8180 if items.is_empty() {
8181 add_note_message(chat, "The current session has no entries to navigate.");
8182 tui.request_render(false);
8183 return;
8184 }
8185 let list = Arc::new(SelectList::new(items, 12));
8186 let state_sel = state.clone();
8187 let ec_sel = editor_container.clone();
8188 let editor_sel = editor.clone();
8189 let tui_sel = tui.clone();
8190 let tx_sel = tx.clone();
8191 list.on_select(Arc::new(move |item| {
8192 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
8193 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8194 }));
8195 let state_cancel = state.clone();
8196 let ec_cancel = editor_container.clone();
8197 let editor_cancel = editor.clone();
8198 let tui_cancel = tui.clone();
8199 list.on_cancel(Arc::new(move || {
8200 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8201 }));
8202 open_selector(
8203 state,
8204 editor_container,
8205 editor,
8206 tui,
8207 list,
8208 SelectorKind::Tree,
8209 );
8210}
8211
8212fn open_theme_selector(
8215 state: &Arc<TuiState>,
8216 editor_container: &Arc<Container>,
8217 editor: &Arc<Editor>,
8218 tui: &Arc<TuiAltScreen>,
8219 cwd: &std::path::Path,
8220 package_resources: &Arc<crate::packages::PackageResources>,
8221) {
8222 let mut items = vec![
8223 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
8224 SelectItem::new("light", "Light").with_description("Light background"),
8225 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
8226 ];
8227 if state.themes_enabled {
8228 for path in package_resources.theme_files() {
8229 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
8230 items.push(SelectItem::new(name, name).with_description("Package theme"));
8231 }
8232 }
8233 }
8234 let list = Arc::new(SelectList::new(items, 10));
8235
8236 let state_sel = state.clone();
8237 let ec_sel = editor_container.clone();
8238 let editor_sel = editor.clone();
8239 let tui_sel = tui.clone();
8240 let chat_sel = state.chat_container.clone();
8241 let cwd_sel = cwd.to_path_buf();
8242 let package_resources_sel = package_resources.clone();
8243 list.on_select(Arc::new(move |item| {
8244 let preset = match item.value.as_str() {
8245 "light" => Some(ThemePreset::Light),
8246 "monochrome" => Some(ThemePreset::Monochrome),
8247 "dark" => Some(ThemePreset::Dark),
8248 name => {
8249 if state_sel.themes_enabled {
8250 if let Ok(custom) = crate::packages::load_theme_with_resources(
8251 &cwd_sel,
8252 name,
8253 &package_resources_sel,
8254 ) {
8255 rpi_tui::global_theme_manager().set(custom.clone());
8256 state_sel.theme_manager.set(custom);
8257 }
8258 }
8259 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8260 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8261 tui_sel.render_now(true);
8262 return;
8263 }
8264 };
8265 let Some(preset) = preset else { return };
8266 apply_theme_preset(preset);
8267 state_sel.theme_manager.apply_preset(preset);
8268 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8271 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8272 tui_sel.render_now(true);
8273 }));
8274 let state_cancel = state.clone();
8275 let ec_cancel = editor_container.clone();
8276 let editor_cancel = editor.clone();
8277 let tui_cancel = tui.clone();
8278 list.on_cancel(Arc::new(move || {
8279 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8280 }));
8281
8282 open_selector(
8283 state,
8284 editor_container,
8285 editor,
8286 tui,
8287 list,
8288 SelectorKind::Theme,
8289 );
8290}
8291
8292fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8299 use rpi_ai::types::ThinkingLevel::*;
8300 match level {
8301 Off => "Off — No reasoning",
8302 Minimal => "Minimal — Brief reasoning (~1k tokens)",
8303 Low => "Low — Light reasoning (~1k tokens)",
8304 Medium => "Medium — Moderate reasoning (~80% of max)",
8305 High => "High — Extensive reasoning (~95% of max)",
8306 Xhigh => "Xhigh — Near-maximal reasoning",
8307 Max => "Max — Maximum reasoning",
8308 }
8309}
8310
8311fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8314 use rpi_ai::types::ThinkingLevel::*;
8315 match level {
8316 Off => "off",
8317 Minimal => "minimal",
8318 Low => "low",
8319 Medium => "medium",
8320 High => "high",
8321 Xhigh => "xhigh",
8322 Max => "max",
8323 }
8324}
8325
8326fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
8329 use rpi_ai::types::ThinkingLevel::*;
8330 match name.to_ascii_lowercase().as_str() {
8331 "off" => Some(Off),
8332 "minimal" => Some(Minimal),
8333 "low" => Some(Low),
8334 "medium" => Some(Medium),
8335 "high" => Some(High),
8336 "xhigh" => Some(Xhigh),
8337 "max" => Some(Max),
8338 _ => None,
8339 }
8340}
8341
8342fn open_thinking_selector(
8352 state: &Arc<TuiState>,
8353 editor_container: &Arc<Container>,
8354 editor: &Arc<Editor>,
8355 tui: &Arc<TuiAltScreen>,
8356 lane: &Arc<dyn AgentLane>,
8357 catalog: &[rpi_ai::Model],
8358 lane_model_id: &str,
8359 chat: &Arc<Container>,
8360) {
8361 let model = catalog
8364 .iter()
8365 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8366 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8367 .map(|m| m.supported_thinking_levels())
8368 .unwrap_or_else(|| {
8369 use rpi_ai::types::ThinkingLevel::*;
8370 vec![Off, Minimal, Low, Medium, High]
8371 });
8372 let mut items: Vec<SelectItem> = Vec::new();
8373 for lvl in &levels {
8374 let name = thinking_level_name(*lvl);
8375 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8376 }
8377 if items.is_empty() {
8378 add_note_message(chat, "This model has no supported thinking levels.");
8379 tui.request_render(false);
8380 return;
8381 }
8382 let list = Arc::new(SelectList::new(items, 10));
8383
8384 let state_sel = state.clone();
8385 let ec_sel = editor_container.clone();
8386 let editor_sel = editor.clone();
8387 let tui_sel = tui.clone();
8388 let chat_sel = chat.clone();
8389 let lane_sel = lane.clone();
8390 list.on_select(Arc::new(move |item| {
8391 let Some(level) = thinking_level_from_name(&item.value) else {
8392 add_note_message(
8393 &chat_sel,
8394 &format!("Unknown thinking level: {}.", item.label),
8395 );
8396 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8397 return;
8398 };
8399 let lane = lane_sel.clone();
8400 let footer_sel = state_sel.footer.clone();
8401 tokio::spawn(async move {
8402 let _ = lane.set_thinking_level(level).await;
8403 });
8404 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8408 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8409 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8410 }));
8411 let state_cancel = state.clone();
8412 let ec_cancel = editor_container.clone();
8413 let editor_cancel = editor.clone();
8414 let tui_cancel = tui.clone();
8415 list.on_cancel(Arc::new(move || {
8416 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8417 }));
8418
8419 open_selector(
8420 state,
8421 editor_container,
8422 editor,
8423 tui,
8424 list,
8425 SelectorKind::Thinking,
8426 );
8427}
8428
8429fn open_tools_selector(
8435 state: &Arc<TuiState>,
8436 editor_container: &Arc<Container>,
8437 editor: &Arc<Editor>,
8438 tui: &Arc<TuiAltScreen>,
8439 lane: &Arc<dyn AgentLane>,
8440 chat: &Arc<Container>,
8441) {
8442 let mut active = match tokio::runtime::Handle::try_current() {
8447 Ok(h) => h
8448 .block_on(async { lane.get_active_tools().await })
8449 .unwrap_or_default(),
8450 Err(_) => Vec::new(),
8451 };
8452 if active.is_empty() {
8457 active = crate::session::BUILTIN_TOOL_NAMES
8458 .iter()
8459 .map(|name| (*name).to_string())
8460 .collect();
8461 }
8462 let mut items: Vec<SelectItem> = Vec::new();
8463 for name in crate::session::BUILTIN_TOOL_NAMES {
8464 let on = active.iter().any(|a| a == name);
8465 let label = if on {
8466 format!("{name} (on)")
8467 } else {
8468 (*name).to_string()
8469 };
8470 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8471 }
8472 let list = Arc::new(SelectList::new(items, 10));
8473
8474 let active_captured = active.clone();
8476 let state_sel = state.clone();
8477 let ec_sel = editor_container.clone();
8478 let editor_sel = editor.clone();
8479 let tui_sel = tui.clone();
8480 let chat_sel = chat.clone();
8481 let lane_sel = lane.clone();
8482 list.on_select(Arc::new(move |item| {
8483 let mut next = active_captured.clone();
8484 if let Some(pos) = next.iter().position(|a| a == &item.value) {
8485 next.remove(pos);
8486 } else {
8487 next.push(item.value.clone());
8488 }
8489 let on = next.iter().any(|a| a == &item.value);
8490 let lane = lane_sel.clone();
8491 let next_clone = next.clone();
8492 tokio::spawn(async move {
8493 let _ = lane.set_active_tools(next_clone).await;
8494 });
8495 let list_str = if next.is_empty() {
8496 "(none)".to_string()
8497 } else {
8498 next.join(", ")
8499 };
8500 add_note_message(
8501 &chat_sel,
8502 &format!(
8503 "{} {} — active tools: {}",
8504 item.value,
8505 if on { "enabled" } else { "disabled" },
8506 list_str
8507 ),
8508 );
8509 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8510 }));
8511 let state_cancel = state.clone();
8512 let ec_cancel = editor_container.clone();
8513 let editor_cancel = editor.clone();
8514 let tui_cancel = tui.clone();
8515 list.on_cancel(Arc::new(move || {
8516 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8517 }));
8518
8519 open_selector(
8520 state,
8521 editor_container,
8522 editor,
8523 tui,
8524 list,
8525 SelectorKind::Tools,
8526 );
8527}
8528
8529fn open_images_selector(
8533 state: &Arc<TuiState>,
8534 editor_container: &Arc<Container>,
8535 editor: &Arc<Editor>,
8536 tui: &Arc<TuiAltScreen>,
8537 chat: &Arc<Container>,
8538) {
8539 let current = *state.show_images.lock().unwrap();
8540 let items = vec![
8541 SelectItem::new("yes", "Yes").with_description(if current {
8542 "Inline images (current)"
8543 } else {
8544 "Inline images"
8545 }),
8546 SelectItem::new("no", "No").with_description(if current {
8547 "Placeholder only"
8548 } else {
8549 "Placeholder only (current)"
8550 }),
8551 ];
8552 let list = Arc::new(SelectList::new(items, 5));
8553
8554 let state_sel = state.clone();
8555 let ec_sel = editor_container.clone();
8556 let editor_sel = editor.clone();
8557 let tui_sel = tui.clone();
8558 let chat_sel = chat.clone();
8559 list.on_select(Arc::new(move |item| {
8560 let on = item.value == "yes";
8561 *state_sel.show_images.lock().unwrap() = on;
8562 add_note_message(
8563 &chat_sel,
8564 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8565 );
8566 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8567 }));
8568 let state_cancel = state.clone();
8569 let ec_cancel = editor_container.clone();
8570 let editor_cancel = editor.clone();
8571 let tui_cancel = tui.clone();
8572 list.on_cancel(Arc::new(move || {
8573 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8574 }));
8575
8576 open_selector(
8577 state,
8578 editor_container,
8579 editor,
8580 tui,
8581 list,
8582 SelectorKind::Images,
8583 );
8584}
8585
8586fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8594 let text = editor.get_text();
8595 let cursor = editor_cursor_offset(editor, &text);
8596 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8597 render_autocomplete(state, suggestions);
8598}
8599
8600fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8603 let (row, col) = editor.cursor_position();
8604 let mut offset = 0;
8605 for (index, line) in text.split('\n').enumerate() {
8606 if index == row {
8607 return (offset + col.min(line.len())).min(text.len());
8608 }
8609 offset = offset.saturating_add(line.len() + 1);
8610 }
8611 text.len()
8612}
8613
8614fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8617 let offset = offset.min(text.len());
8618 let mut consumed = 0;
8619 for (row, line) in text.split('\n').enumerate() {
8620 let end = consumed + line.len();
8621 if offset <= end {
8622 editor.set_cursor(row, offset - consumed);
8623 return;
8624 }
8625 consumed = end + 1;
8626 }
8627 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8628 editor.set_cursor(
8629 last_row,
8630 text.rsplit('\n').next().map(str::len).unwrap_or(0),
8631 );
8632}
8633
8634fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8636 state.autocomplete_container.clear();
8637 let Some(sugg) = suggestions else {
8638 return;
8639 };
8640 if sugg.items.is_empty() {
8641 return;
8642 }
8643 let accent = state.theme_manager.get().colors.accent;
8646 let muted = state.theme_manager.get().colors.muted;
8647 for (i, item) in sugg
8648 .items
8649 .iter()
8650 .take(state.autocomplete_max_visible)
8651 .enumerate()
8652 {
8653 let prefix = if i == 0 { "→ " } else { " " };
8654 let label = item.display_text();
8655 let line = if i == 0 {
8656 format!(
8657 "{prefix}{} {}",
8658 accent.fg(label),
8659 muted.fg(item.description.as_deref().unwrap_or(""))
8660 )
8661 } else {
8662 format!(
8663 "{prefix}{} {}",
8664 muted.fg(label),
8665 muted.fg(item.description.as_deref().unwrap_or(""))
8666 )
8667 };
8668 state
8669 .autocomplete_container
8670 .add_child(Arc::new(Text::new(line, 1, 0)));
8671 }
8672}
8673
8674fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8678 let text = editor.get_text();
8679 let cursor = editor_cursor_offset(editor, &text);
8680 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8681 return false;
8682 };
8683 let Some(top) = sugg.items.first() else {
8684 return false;
8685 };
8686 let start = sugg.start.min(text.len());
8690 let end = sugg.end.min(text.len());
8691 let mut replaced = String::with_capacity(text.len() + top.text.len());
8692 replaced.push_str(&text[..start]);
8693 replaced.push_str(&top.text);
8694 replaced.push_str(&text[end..]);
8697 if top.insert_space && !replaced.ends_with('/') {
8698 replaced.push(' ');
8699 }
8700 let new_cursor = replaced.len().min(
8703 start
8704 + top.text.len()
8705 + if top.insert_space && !top.text.ends_with('/') {
8706 1
8707 } else {
8708 0
8709 },
8710 );
8711 editor.set_text(&replaced);
8712 set_editor_cursor_offset(editor, &replaced, new_cursor);
8713 state.autocomplete_container.clear();
8714 true
8715}
8716
8717fn add_welcome_message(container: &Arc<Container>) {
8723 add_welcome_message_with_capabilities(container, &[], &[]);
8724}
8725
8726fn add_welcome_message_with_capabilities(
8730 container: &Arc<Container>,
8731 active_tools: &[String],
8732 skills: &[String],
8733) {
8734 let c = current_theme().colors;
8735 let title = format!(
8739 "{} {}",
8740 c.accent.fg(&tui_bold("rpi")),
8741 c.muted.fg("interactive TUI")
8742 );
8743 container.add_child(Arc::new(Text::new(title, 1, 0)));
8744 container.add_child(Arc::new(Spacer::new(1)));
8745 container.add_child(Arc::new(Text::new(
8746 c.dim.fg("Type your message and press Enter to send."),
8747 1,
8748 0,
8749 )));
8750 let hint = c
8751 .dim
8752 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8753 container.add_child(Arc::new(Text::new(hint, 1, 0)));
8754 container.add_child(Arc::new(Spacer::new(1)));
8755 container.add_child(Arc::new(Text::new(
8756 welcome_capability_line("Tools", active_tools),
8757 1,
8758 0,
8759 )));
8760 container.add_child(Arc::new(Text::new(
8761 welcome_capability_line("Skills", skills),
8762 1,
8763 0,
8764 )));
8765 container.add_child(Arc::new(DynamicBorder::new()));
8766}
8767
8768fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8771 let colors = current_theme().colors;
8772 let group = Arc::new(Container::new());
8773
8774 for warning in &report.warnings {
8775 let body = format!(
8776 "{}\n{} {}{}",
8777 colors.error.fg(&warning.message),
8778 colors.muted.fg("Run"),
8779 colors.accent.fg(&warning.command),
8780 colors.muted.fg(" to retry.")
8781 );
8782 add_update_panel(&group, "Update Failed", &body, colors.error);
8783 }
8784
8785 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8786 let body = format!(
8787 "{} {}{}",
8788 colors
8789 .muted
8790 .fg(&format!("New version {} is available. Run", notice.latest)),
8791 colors.accent.fg(¬ice.command),
8792 colors.muted.fg(".")
8793 );
8794 add_update_panel(&group, "Update Available", &body, colors.warning);
8795 }
8796
8797 let package_notices = report
8798 .notices
8799 .iter()
8800 .filter(|notice| notice.name != "rpi")
8801 .collect::<Vec<_>>();
8802 if !package_notices.is_empty() {
8803 let command = package_notices[0].command.as_str();
8804 let mut lines = vec![format!(
8805 "{} {}{}",
8806 colors.muted.fg("Package updates are available. Run"),
8807 colors.accent.fg(command),
8808 colors.muted.fg(".")
8809 )];
8810 lines.push(colors.muted.fg("Packages:"));
8811 lines.extend(
8812 package_notices
8813 .into_iter()
8814 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8815 );
8816 add_update_panel(
8817 &group,
8818 "Package Updates Available",
8819 &lines.join("\n"),
8820 colors.warning,
8821 );
8822 }
8823
8824 if group.child_count() > 0 {
8828 container.add_child(group);
8829 }
8830}
8831
8832fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8833 container.add_child(Arc::new(Spacer::new(1)));
8834 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8835 container.add_child(Arc::new(Text::new(
8836 format!("{}\n{body}", color.fg(&tui_bold(title))),
8837 1,
8838 0,
8839 )));
8840 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8841}
8842
8843fn welcome_capability_line(label: &str, names: &[String]) -> String {
8844 let c = current_theme().colors;
8845 let value = if names.is_empty() {
8846 "none".to_string()
8847 } else {
8848 names.join(" · ")
8849 };
8850 format!(
8851 "{} {}",
8852 c.accent.fg(&format!("{label} ({})", names.len())),
8853 c.muted.fg(&value)
8854 )
8855}
8856
8857fn add_help_message(container: &Arc<Container>) {
8859 let c = current_theme().colors;
8860 container.add_child(Arc::new(Text::new(
8864 c.md_heading.fg(&tui_bold("📚 Available Commands")),
8865 1,
8866 0,
8867 )));
8868 container.add_child(Arc::new(Spacer::new(1)));
8869
8870 let cmds: &[(&str, &str)] = &[
8871 ("/help, /?", "Show this help message"),
8872 ("/clear, /new", "Clear the conversation"),
8873 ("/exit, /quit, /q", "Exit the application"),
8874 ("/version, /v", "Show version information"),
8875 ("/changelog", "Show recent release changes"),
8876 ("/model, /m", "Choose a model (live switch)"),
8877 ("/thinking, /think", "Set reasoning depth (selector)"),
8878 ("/tools", "Toggle built-in tools on/off"),
8879 ("/images", "Toggle inline image rendering"),
8880 ("/session", "List saved sessions"),
8881 ("/theme", "Choose a theme (selector)"),
8882 ("/compact", "Compact the conversation"),
8883 ("/copy", "Copy last reply to clipboard"),
8884 ("/hotkeys", "Show keyboard shortcuts"),
8885 ("/armin", "🐾 Easter egg"),
8886 ("/earendil", "Earendil announcement"),
8887 ];
8888 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8889 for (cmd, desc) in cmds {
8890 let row = format!(
8891 " {:<cmd_w$} {} {}",
8892 c.accent.fg(cmd),
8893 c.dim.fg("—"),
8894 c.muted.fg(desc)
8895 );
8896 container.add_child(Arc::new(Text::new(row, 1, 0)));
8897 }
8898 container.add_child(Arc::new(Spacer::new(1)));
8899}
8900
8901fn add_version_message(container: &Arc<Container>) {
8903 let c = current_theme().colors;
8904 container.add_child(Arc::new(Text::new(
8905 c.md_heading.fg(&tui_bold("📦 Version Information")),
8906 1,
8907 0,
8908 )));
8909 container.add_child(Arc::new(Spacer::new(1)));
8910 container.add_child(Arc::new(Text::new(
8913 format!(
8914 " {} {}",
8915 c.muted.fg("rpi-cli"),
8916 c.text.fg(&format!("v{}", crate::VERSION))
8917 ),
8918 1,
8919 0,
8920 )));
8921 container.add_child(Arc::new(Text::new(
8922 format!(
8923 " {}",
8924 c.dim.fg("Rust implementation of pi coding agent TUI")
8925 ),
8926 1,
8927 0,
8928 )));
8929 container.add_child(Arc::new(Spacer::new(1)));
8930}
8931
8932fn add_changelog_message(container: &Arc<Container>) {
8936 let c = current_theme().colors;
8937 container.add_child(Arc::new(Text::new(
8938 c.md_heading.fg(&tui_bold("Recent Changes")),
8939 1,
8940 0,
8941 )));
8942 container.add_child(Arc::new(Spacer::new(1)));
8943 let entries = [
8944 (
8945 "Native parity phase 1",
8946 "models, images, trust, export, and JSON events",
8947 ),
8948 (
8949 "TUI controls",
8950 "external editor, thinking levels, and tool output toggles",
8951 ),
8952 (
8953 "Provider auth",
8954 "OpenAI-compatible API key aliases and gateway headers",
8955 ),
8956 ];
8957 for (release, summary) in entries {
8958 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
8959 container.add_child(Arc::new(Text::new(row, 1, 0)));
8960 }
8961 container.add_child(Arc::new(Text::new(
8962 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8963 1,
8964 0,
8965 )));
8966 container.add_child(Arc::new(Spacer::new(1)));
8967}
8968
8969fn add_hotkeys_message(container: &Arc<Container>) {
8971 let c = current_theme().colors;
8972 container.add_child(Arc::new(Text::new(
8973 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
8974 1,
8975 0,
8976 )));
8977 container.add_child(Arc::new(Spacer::new(1)));
8978 let keys: &[(&str, &str)] = &[
8979 ("Enter", "Send message"),
8980 ("Shift+Enter", "New line"),
8981 ("Tab", "Accept autocomplete suggestion"),
8982 ("Ctrl+A / Ctrl+E", "Line start / end"),
8983 (
8984 "Ctrl+K / Ctrl+U",
8985 "Kill to end / start of line (Ctrl+Y yanks)",
8986 ),
8987 ("Ctrl+- / Ctrl+R", "Undo / redo"),
8988 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8989 ("Alt+Backspace", "Kill previous word"),
8990 ("Ctrl+C", "Abort a run, or exit when idle"),
8991 ("Esc", "Abort a running prompt"),
8992 ("Ctrl+L", "Open model selector"),
8993 ("Ctrl+M", "Cycle to the next model (live)"),
8994 ("Ctrl+O", "Expand/collapse all tool output"),
8995 ("Ctrl+T", "Show/hide reasoning blocks"),
8996 ("PageUp/Down", "Scroll transcript by one page"),
8997 ("Home / End", "Jump to transcript start / latest output"),
8998 ];
8999 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9000 for (key, desc) in keys {
9001 let row = format!(
9002 " {:<key_w$} {} {}",
9003 c.accent.fg(key),
9004 c.dim.fg("—"),
9005 c.muted.fg(desc)
9006 );
9007 container.add_child(Arc::new(Text::new(row, 1, 0)));
9008 }
9009 container.add_child(Arc::new(Spacer::new(1)));
9010}
9011
9012fn add_user_message(container: &Arc<Container>, text: &str) {
9018 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
9019 container.add_child(Arc::new(Spacer::new(1)));
9020}
9021
9022fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
9025 let width = 80; chat_container.render(width)
9027}
9028
9029fn add_error_message(container: &Arc<Container>, text: &str) {
9030 let c = current_theme().colors;
9031 let text = sanitize_error_message(text);
9032 container.add_child(Arc::new(Text::new(
9033 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
9034 1,
9035 0,
9036 )));
9037 container.add_child(Arc::new(Spacer::new(1)));
9038}
9039
9040fn sanitize_error_message(text: &str) -> String {
9045 const MAX_ERROR_CHARS: usize = 16 * 1024;
9046 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
9047 let mut count = 0;
9048 let mut escape_mode = 0u8;
9050 for ch in text.chars() {
9051 if escape_mode != 0 {
9052 match escape_mode {
9053 1 if ch == '[' => escape_mode = 2,
9054 1 if ch == ']' => escape_mode = 3,
9055 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
9056 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
9057 3 if ch == '\x07' => escape_mode = 0,
9058 _ => {}
9059 }
9060 continue;
9061 }
9062 if ch == '\x1b' {
9063 escape_mode = 1;
9064 continue;
9065 }
9066 if count >= MAX_ERROR_CHARS {
9067 result.push_str("…");
9068 break;
9069 }
9070 match ch {
9071 '\n' | '\t' => {
9072 result.push(ch);
9073 count += 1;
9074 }
9075 '\r' => {}
9076 c if c.is_control() => {}
9077 c => {
9078 result.push(c);
9079 count += 1;
9080 }
9081 }
9082 }
9083 if result.trim().is_empty() {
9084 "Provider request failed.".to_string()
9085 } else {
9086 result
9087 }
9088}
9089
9090fn add_note_message(container: &Arc<Container>, text: &str) {
9092 let c = current_theme().colors;
9093 container.add_child(Arc::new(Text::new(
9094 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
9095 1,
9096 0,
9097 )));
9098 container.add_child(Arc::new(Spacer::new(1)));
9099}
9100
9101fn show_context_panel(
9110 chat: &Arc<Container>,
9111 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
9112) {
9113 let skills = resources.skills.as_deref().unwrap_or(&[]);
9114 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
9115 let mut lines: Vec<String> = Vec::new();
9116 lines.push("📂 Discovered resources for this session:".into());
9117
9118 if skills.is_empty() {
9119 lines.push(
9120 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
9121 );
9122 } else {
9123 lines.push(format!(" Skills ({}):", skills.len()));
9124 for s in skills {
9125 let marker = if s.disable_model_invocation == Some(true) {
9126 " [hidden]"
9127 } else {
9128 ""
9129 };
9130 let desc: String = s.description.chars().take(72).collect();
9131 lines.push(format!(" • {}{marker} — {desc}", s.name));
9132 }
9133 }
9134
9135 if templates.is_empty() {
9136 lines.push(
9137 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
9138 );
9139 } else {
9140 lines.push(format!(" Prompt templates ({}):", templates.len()));
9141 for t in templates {
9142 let desc = t
9143 .description
9144 .as_deref()
9145 .unwrap_or("(no description)")
9146 .chars()
9147 .take(72)
9148 .collect::<String>();
9149 lines.push(format!(" • /{} — {desc}", t.name));
9150 }
9151 }
9152 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
9153 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
9154 lines.push(
9155 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
9156 .into(),
9157 );
9158 let body = lines.join("\n");
9159 container_note_block(chat, &body);
9160}
9161
9162fn container_note_block(container: &Arc<Container>, body: &str) {
9164 for line in body.lines() {
9165 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
9166 }
9167 container.add_child(Arc::new(Spacer::new(1)));
9168}
9169
9170pub fn is_tui_supported() -> bool {
9176 std::io::stdout().is_terminal()
9177}
9178
9179#[allow(unused_imports)]
9181use rpi_tui::Color as _Color;
9182
9183#[cfg(test)]
9184mod tests {
9185 use super::*;
9186 use rpi_tui::Component;
9187
9188 #[test]
9189 fn verbose_overrides_quiet_startup_listing() {
9190 assert!(should_show_startup_listing(false, false));
9191 assert!(should_show_startup_listing(true, false));
9192 assert!(should_show_startup_listing(true, true));
9193 assert!(!should_show_startup_listing(false, true));
9194 }
9195
9196 #[test]
9197 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
9198 let tmp = tempfile::tempdir().unwrap();
9199 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
9200 let local_root = tmp.path().join("local-package");
9201 std::fs::create_dir_all(git_root.join(".git")).unwrap();
9202 std::fs::create_dir_all(&local_root).unwrap();
9203 std::fs::write(
9204 git_root.join("package.json"),
9205 r#"{"name":"git-demo","version":"1.0.0"}"#,
9206 )
9207 .unwrap();
9208 std::fs::write(
9209 local_root.join("package.json"),
9210 r#"{"name":"local-demo","version":"1.0.0"}"#,
9211 )
9212 .unwrap();
9213 let resources = crate::packages::discover(
9214 tmp.path(),
9215 &[
9216 "git:github.com/example/repo".to_string(),
9217 local_root.to_string_lossy().into_owned(),
9218 ],
9219 );
9220 assert_eq!(resources.packages.len(), 2);
9221
9222 let filtered = git_only_update_resources(resources);
9223
9224 assert_eq!(filtered.packages.len(), 1);
9225 assert!(matches!(
9226 filtered.packages[0].source,
9227 crate::packages::PackageSource::Git
9228 ));
9229 }
9230
9231 #[test]
9232 fn trust_command_uses_the_session_cwd_after_process_chdir() {
9233 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
9234 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
9235 const TEST_NAME: &str =
9236 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
9237
9238 if std::env::var_os(CHILD_ENV).is_some() {
9239 let session_cwd = std::path::PathBuf::from(
9240 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
9241 );
9242 let changed_cwd = std::env::current_dir().unwrap();
9243
9244 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
9245
9246 assert_eq!(
9247 crate::config::project_trust_decision(&session_cwd).unwrap(),
9248 Some(true)
9249 );
9250 assert_eq!(
9251 crate::config::project_trust_decision(&changed_cwd).unwrap(),
9252 None
9253 );
9254 return;
9255 }
9256
9257 let tmp = tempfile::tempdir().unwrap();
9258 let session_cwd = tmp.path().join("session-project");
9259 let changed_cwd = tmp.path().join("extension-cwd");
9260 let agent_dir = tmp.path().join("agent");
9261 std::fs::create_dir_all(&session_cwd).unwrap();
9262 std::fs::create_dir_all(&changed_cwd).unwrap();
9263 std::fs::create_dir_all(&agent_dir).unwrap();
9264
9265 let status = std::process::Command::new(std::env::current_exe().unwrap())
9266 .arg("--exact")
9267 .arg(TEST_NAME)
9268 .arg("--nocapture")
9269 .env(CHILD_ENV, "1")
9270 .env(SESSION_CWD_ENV, &session_cwd)
9271 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
9272 .current_dir(&changed_cwd)
9273 .status()
9274 .unwrap();
9275
9276 assert!(status.success(), "child test process failed: {status}");
9277 }
9278
9279 #[test]
9280 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
9281 let global = crate::settings::Settings {
9282 editor_padding_x: Some(3),
9283 autocomplete_max_visible: Some(6),
9284 hide_thinking_block: Some(false),
9285 quiet_startup: Some(true),
9286 show_terminal_progress: Some(true),
9287 ..Default::default()
9288 };
9289 let rpi_project = crate::settings::Settings {
9290 editor_padding_x: Some(0),
9291 hide_thinking_block: Some(true),
9292 quiet_startup: Some(false),
9293 show_terminal_progress: Some(false),
9294 ..Default::default()
9295 };
9296 let pi_project = crate::settings::Settings {
9297 editor_padding_x: Some(9),
9298 autocomplete_max_visible: Some(12),
9299 quiet_startup: Some(true),
9300 ..Default::default()
9301 };
9302
9303 assert_eq!(
9304 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
9305 TuiStartupSettings {
9306 editor_padding_x: 0,
9307 autocomplete_max_visible: 12,
9308 hide_thinking: true,
9309 quiet_startup: false,
9310 show_terminal_progress: false,
9311 }
9312 );
9313 }
9314
9315 #[test]
9316 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
9317 let global = crate::settings::Settings {
9318 quiet_startup: Some(false),
9319 ..Default::default()
9320 };
9321 let rpi_project = crate::settings::Settings::default();
9322 let pi_project = crate::settings::Settings {
9323 quiet_startup: Some(true),
9324 ..Default::default()
9325 };
9326
9327 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
9328
9329 assert!(resolved.quiet_startup);
9330 }
9331
9332 #[test]
9333 fn tui_startup_settings_use_global_values_without_project_fields() {
9334 let global = crate::settings::Settings {
9335 editor_padding_x: Some(7),
9336 autocomplete_max_visible: Some(8),
9337 hide_thinking_block: Some(true),
9338 quiet_startup: Some(true),
9339 show_terminal_progress: Some(false),
9340 ..Default::default()
9341 };
9342
9343 assert_eq!(
9344 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
9345 TuiStartupSettings {
9346 editor_padding_x: 7,
9347 autocomplete_max_visible: 8,
9348 hide_thinking: true,
9349 quiet_startup: true,
9350 show_terminal_progress: false,
9351 }
9352 );
9353 }
9354
9355 #[test]
9356 fn tui_startup_settings_ignore_untrusted_project_values() {
9357 let global = crate::settings::Settings {
9358 quiet_startup: Some(false),
9359 show_terminal_progress: Some(true),
9360 ..Default::default()
9361 };
9362 let project = crate::settings::Settings {
9363 quiet_startup: Some(true),
9364 show_terminal_progress: Some(false),
9365 ..Default::default()
9366 };
9367
9368 let resolved = resolve_tui_startup_settings(&global, &[project], false);
9369
9370 assert!(!resolved.quiet_startup);
9371 assert!(resolved.show_terminal_progress);
9372 }
9373
9374 #[test]
9375 fn transcript_page_uses_viewport_with_overlap() {
9376 assert_eq!(transcript_page_size(24), 20);
9377 assert_eq!(transcript_page_size(4), 1);
9378 assert_eq!(transcript_page_size(0), 1);
9379 }
9380
9381 #[test]
9382 fn key_repeat_is_dispatched_but_release_is_not() {
9383 assert!(should_dispatch_key(KeyEventKind::Press));
9384 assert!(should_dispatch_key(KeyEventKind::Repeat));
9385 assert!(!should_dispatch_key(KeyEventKind::Release));
9386 }
9387
9388 #[test]
9389 fn key_event_encoding_matches_pi_keybinding_protocol() {
9390 let key = |code, modifiers| KeyEvent::new(code, modifiers);
9391 assert_eq!(
9392 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9393 "\r"
9394 );
9395 assert_eq!(
9396 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9397 "\x1b[13;2u"
9398 );
9399 assert_eq!(
9400 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9401 "\x1b[9;2u"
9402 );
9403 assert_eq!(
9404 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9405 "\x1b[Z"
9406 );
9407 assert_eq!(
9408 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9409 "\x1b[Z"
9410 );
9411 assert_eq!(
9412 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9413 "\x03"
9414 );
9415 assert_eq!(
9416 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9417 "\x0f"
9418 );
9419 assert_eq!(
9420 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9421 "!"
9422 );
9423 assert_eq!(
9424 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9425 "1"
9426 );
9427 }
9428
9429 #[test]
9430 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9431 let bridge = JsDialogBridge::default();
9432 let (sender, receiver) = std_mpsc::channel();
9433 bridge.pending.lock().unwrap().push_back(JsDialogPending {
9434 request: JsDialogRequest {
9435 id: "dialog-1".into(),
9436 method: "input".into(),
9437 title: String::new(),
9438 message: String::new(),
9439 options: Vec::new(),
9440 placeholder: None,
9441 prefill: None,
9442 },
9443 result: sender,
9444 });
9445
9446 bridge.cancel("dialog-1");
9449 assert!(bridge.take_pending().is_none());
9450 assert_eq!(
9451 receiver.recv().unwrap(),
9452 serde_json::json!({ "cancelled": true })
9453 );
9454 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9455 }
9456
9457 #[test]
9458 fn test_layout_renders_welcome_message() {
9459 let chat = Arc::new(Container::new());
9460 add_welcome_message(&chat);
9461
9462 let scroll = Arc::new(ScrollView::new(
9463 chat.clone(),
9464 ScrollViewOptions {
9465 follow: FollowMode::End,
9466 primary: true,
9467 ..Default::default()
9468 },
9469 ));
9470
9471 let editor = Arc::new(Editor::new(
9472 EditorOptions {
9473 padding_x: 1,
9474 ..Default::default()
9475 },
9476 EditorStyle::default(),
9477 Arc::new(rpi_tui::Keybindings::new()),
9478 ));
9479 let dock = Arc::new(Container::new());
9480 dock.add_child(editor);
9481
9482 let footer = Arc::new(FooterComponent::new());
9483
9484 let root = VStack::from_children(vec![
9485 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9486 StackChild::Entry(StackEntry::new(dock)),
9487 StackChild::Entry(StackEntry::new(footer)),
9488 ]);
9489
9490 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9491
9492 let all: String = frame.lines.join("\n");
9493 assert!(
9494 all.contains("rpi"),
9495 "Welcome message not found. Rendered: {}",
9496 all
9497 );
9498 assert!(
9499 all.contains("Type your message"),
9500 "Help text not found. Rendered: {}",
9501 all
9502 );
9503 }
9504
9505 #[test]
9506 fn test_chat_container_has_welcome_content() {
9507 let chat = Arc::new(Container::new());
9508 add_welcome_message_with_capabilities(
9509 &chat,
9510 &["read".into(), "bash".into(), "web_fetch".into()],
9511 &["rust-review".into(), "release".into()],
9512 );
9513
9514 let lines = chat.render(80);
9515 let all: String = lines.join("\n");
9516 let plain = strip_ansi(&all);
9519 assert!(
9520 plain.contains("rpi"),
9521 "Welcome message not in chat container: {:?}",
9522 lines
9523 );
9524 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9525 assert!(
9526 plain.contains("read · bash · web_fetch"),
9527 "Tool names missing: {plain}"
9528 );
9529 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9530 assert!(
9531 plain.contains("rust-review · release"),
9532 "Skill names missing: {plain}"
9533 );
9534 }
9535
9536 #[test]
9537 fn update_notices_render_inside_the_transcript() {
9538 let chat = Arc::new(Container::new());
9539 let report = crate::updates::UpdateReport {
9540 notices: vec![
9541 crate::updates::UpdateNotice {
9542 name: "rpi".into(),
9543 current: "0.1.10".into(),
9544 latest: "0.1.11".into(),
9545 command: "rpi update".into(),
9546 },
9547 crate::updates::UpdateNotice {
9548 name: "rpi-search".into(),
9549 current: "0.1.0".into(),
9550 latest: "0.1.1".into(),
9551 command: "rpi pi-package update".into(),
9552 },
9553 ],
9554 warnings: vec![crate::updates::UpdateWarning {
9555 message: "The previously scheduled rpi update failed: access denied".into(),
9556 command: "rpi update".into(),
9557 }],
9558 };
9559
9560 add_update_notices(&chat, &report);
9561
9562 assert_eq!(chat.child_count(), 1);
9563 let plain = strip_ansi(&chat.render(80).join("\n"));
9564 assert!(plain.contains("Update Failed"), "{plain}");
9565 assert!(
9566 plain.contains("rpi update failed: access denied"),
9567 "{plain}"
9568 );
9569 assert!(plain.contains("Update Available"), "{plain}");
9570 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9571 assert!(plain.contains("rpi update"), "{plain}");
9572 assert!(plain.contains("Package Updates Available"), "{plain}");
9573 assert!(plain.contains("rpi pi-package update"), "{plain}");
9574 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9575 }
9576
9577 #[test]
9578 fn empty_update_report_does_not_add_transcript_content() {
9579 let chat = Arc::new(Container::new());
9580
9581 add_update_notices(&chat, &crate::updates::UpdateReport::default());
9582
9583 assert_eq!(chat.child_count(), 0);
9584 }
9585
9586 #[test]
9587 fn skill_reads_are_detected_by_path() {
9588 let name = skill_tool_name(
9589 "read",
9590 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9591 );
9592 assert_eq!(name.as_deref(), Some("release"));
9593
9594 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9595 assert!(name.is_none());
9596
9597 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9599 }
9600
9601 #[test]
9602 fn welcome_capabilities_show_empty_state() {
9603 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9604 assert_eq!(plain, "Skills (0) none");
9605 }
9606
9607 #[test]
9608 fn empty_tool_progress_is_deferred_until_start() {
9609 let empty = rpi_agent::AgentToolResult::default();
9610 assert!(!tool_update_has_payload("", &empty));
9611
9612 let text = rpi_agent::AgentToolResult::text("partial output");
9613 assert!(tool_update_has_payload("partial output", &text));
9614
9615 let details = rpi_agent::AgentToolResult {
9616 details: serde_json::json!({"path": "src/lib.rs"}),
9617 ..Default::default()
9618 };
9619 assert!(tool_update_has_payload("", &details));
9620 }
9621
9622 #[test]
9623 fn completed_stream_reconciles_the_final_tail_before_detaching() {
9624 let component = Arc::new(AssistantMessageComponent::new(
9625 AssistantMessageOptions::default(),
9626 ));
9627 component.set_streaming(true);
9628 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9629
9630 let current = Mutex::new(Some(component.clone()));
9631 let cached = Mutex::new("partial response".to_string());
9632 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9633 final_message.content = vec![Content::text(
9634 "partial response with the previously missing final tail",
9635 )];
9636 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9637
9638 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
9639
9640 assert!(current.lock().unwrap().is_none());
9641 assert_eq!(
9642 cached.lock().unwrap().as_str(),
9643 "partial response with the previously missing final tail"
9644 );
9645 let rendered = strip_ansi(&component.render(100).join("\n"));
9646 assert!(
9647 rendered.contains("previously missing final tail"),
9648 "{rendered}"
9649 );
9650 }
9651
9652 #[test]
9658 fn tab_accept_suggestion_reflects_in_next_render() {
9659 use rpi_tui::render_layout_frame;
9660
9661 let editor = Arc::new(Editor::new(
9662 EditorOptions {
9663 padding_x: 1,
9664 ..Default::default()
9665 },
9666 EditorStyle::default(),
9667 Arc::new(rpi_tui::Keybindings::new()),
9668 ));
9669 editor.set_focused(true);
9670 let editor_container = Arc::new(Container::new());
9671 editor_container.add_child(editor.clone());
9672 let autocomplete_container = Arc::new(Container::new());
9673 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9674 let dock = Arc::new(VStack::from_children(vec![
9675 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9676 StackChild::Entry(
9677 StackEntry::new(editor_container.clone())
9678 .shrink(0)
9679 .min_size(3),
9680 ),
9681 StackChild::Entry(StackEntry::new(footer)),
9682 ]));
9683
9684 let mut manager = AutocompleteManager::new();
9686 let mut combined = CombinedAutocompleteProvider::new();
9687 combined.add_provider(Arc::new(
9688 SlashCommandAutocompleteProvider::with_default_commands(),
9689 ));
9690 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9691 manager.set_provider(Arc::new(combined));
9692 editor.insert("/mo");
9695 assert_eq!(editor.cursor_position(), (0, 3));
9696
9697 let frame_before = render_layout_frame(dock.clone(), 80, 10);
9698 assert!(
9699 frame_before.lines.iter().any(|l| l.contains("/mo")),
9700 "precondition: editor shows the typed prefix. Frame rows:\n{}",
9701 frame_before
9702 .lines
9703 .iter()
9704 .map(|l| format!(" [{l}]"))
9705 .collect::<Vec<_>>()
9706 .join("\n")
9707 );
9708
9709 let text = editor.get_text();
9711 let cursor = editor_cursor_offset(&editor, &text);
9712 let sugg = manager
9713 .get_suggestions(&text, cursor)
9714 .expect("slash suggestions for /mo");
9715 let top = sugg.items.first().expect("at least one suggestion");
9716 let start = sugg.start.min(text.len());
9717 let end = sugg.end.min(text.len());
9718 let mut replaced = String::new();
9719 replaced.push_str(&text[..start]);
9720 replaced.push_str(&top.text);
9721 replaced.push_str(&text[end..]);
9722 if top.insert_space && !replaced.ends_with('/') {
9723 replaced.push(' ');
9724 }
9725 let new_cursor = start + top.text.len();
9726 editor.set_text(&replaced);
9727 set_editor_cursor_offset(&editor, &replaced, new_cursor);
9728 autocomplete_container.clear();
9729 assert_eq!(editor.get_text(), "/model");
9730
9731 let frame_after = render_layout_frame(dock, 80, 10);
9733 let all: String = frame_after.lines.join("\n");
9734 assert!(
9735 all.contains("/model"),
9736 "completed text missing from next render. Got:\n{all}"
9737 );
9738 let editor_line = frame_after
9742 .lines
9743 .iter()
9744 .find(|l| l.contains("/model"))
9745 .expect("editor row with completed text");
9746 assert!(
9747 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9748 "caret must follow the full completed text. Got: {editor_line:?}"
9749 );
9750 }
9751
9752 #[test]
9753 fn multiline_autocomplete_preserves_row_and_column() {
9754 let editor = Arc::new(Editor::simple());
9755 editor.set_text("first\n/mo");
9756 editor.set_cursor(1, 3);
9757
9758 let text = editor.get_text();
9759 assert_eq!(editor_cursor_offset(&editor, &text), 9);
9760
9761 let replaced = "first\n/model";
9762 editor.set_text(replaced);
9763 set_editor_cursor_offset(&editor, replaced, 12);
9764 assert_eq!(editor.cursor_position(), (1, 6));
9765 }
9766
9767 #[test]
9768 fn test_slash_command_dispatch() {
9769 let registry = build_builtin_registry();
9774
9775 let resolves_to = |token: &str, canonical: &str| {
9777 let found = registry.find(token).expect("{token} should resolve");
9778 assert_eq!(
9779 found.name(),
9780 canonical,
9781 "{token} resolved to {} (expected {canonical})",
9782 found.name()
9783 );
9784 };
9785
9786 resolves_to("/help", "/help");
9787 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
9789 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
9793 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
9795 resolves_to("/hotkeys", "/hotkeys");
9796 resolves_to("/model", "/model");
9797 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
9799 resolves_to("/session", "/session");
9800 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
9802 resolves_to("/copy", "/copy");
9803 resolves_to("/thinking", "/thinking");
9804 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
9806 resolves_to("/images", "/images");
9807 resolves_to("/armin", "/armin");
9808 resolves_to("/earendil", "/earendil");
9809 resolves_to("/context", "/context");
9810 resolves_to("/settings", "/settings");
9812 resolves_to("/name", "/name");
9813 resolves_to("/export", "/export");
9814
9815 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9817 }
9818
9819 #[test]
9820
9821 fn test_registry_visible_entries_cover_dispatch() {
9822 let registry = build_builtin_registry();
9827 let names: Vec<String> = registry
9828 .visible_entries()
9829 .iter()
9830 .map(|c| c.name.clone())
9831 .collect();
9832 for recognized in [
9833 "/help",
9834 "/clear",
9835 "/new",
9836 "/exit",
9837 "/quit",
9838 "/version",
9839 "/changelog",
9840 "/model",
9841 "/session",
9842 "/theme",
9843 "/compact",
9844 "/copy",
9845 "/hotkeys",
9846 "/tools",
9847 "/images",
9848 "/thinking",
9849 "/usage",
9850 "/armin",
9851 "/earendil",
9852 ] {
9853 assert!(
9854 names.contains(&recognized.to_string()),
9855 "{recognized} missing from autocomplete list"
9856 );
9857 }
9858 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9860 assert!(
9861 !names.contains(&hidden.to_string()),
9862 "{hidden} should be hidden from autocomplete"
9863 );
9864 }
9865 }
9866
9867 #[test]
9868 fn test_agent_event_mapping_creates_assistant_and_tool() {
9869 use rpi_ai::types::{
9872 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9873 ToolCall, ToolCallType, Usage,
9874 };
9875
9876 let state = Arc::new(TuiState {
9877 current_assistant: std::sync::Mutex::new(None),
9878 tool_components: std::sync::Mutex::new(HashMap::new()),
9879 bash_components: std::sync::Mutex::new(HashMap::new()),
9880 themes_enabled: true,
9881 hide_thinking: std::sync::Mutex::new(false),
9882 tool_outputs_expanded: std::sync::Mutex::new(false),
9883 show_terminal_progress: true,
9884 status: std::sync::Mutex::new(RunStatus::Idle),
9885 js_preparation_cancel: std::sync::Mutex::new(None),
9886 footer: Arc::new(FooterComponent::new()),
9887 status_container: Arc::new(Container::new()),
9888 chat_container: Arc::new(Container::new()),
9889 loader: Arc::new(Loader::new()),
9890 last_assistant_text: std::sync::Mutex::new(String::new()),
9891 active_selector: std::sync::Mutex::new(None),
9892 active_extension_editor: std::sync::Mutex::new(None),
9893 active_extension_input: std::sync::Mutex::new(None),
9894 active_extension_cancel: std::sync::Mutex::new(None),
9895 autocomplete: AutocompleteManager::new(),
9896 autocomplete_container: Arc::new(Container::new()),
9897 autocomplete_max_visible: 5,
9898 pending_images: std::sync::Mutex::new(Vec::new()),
9899 theme_manager: Arc::new(ThemeManager::new()),
9900 tui: None,
9901 current_model_id: std::sync::Mutex::new(String::new()),
9902 show_images: std::sync::Mutex::new(true),
9903 history: std::sync::Mutex::new(Vec::new()),
9904 history_index: std::sync::Mutex::new(-1),
9905 history_draft: std::sync::Mutex::new(None),
9906 last_input_tokens: std::sync::Mutex::new(0),
9907 scoped_edit: std::sync::Mutex::new(None),
9908 markdown_transformer: std::sync::Mutex::new(None),
9909 extension_session: Arc::new(std::sync::Mutex::new(
9910 rpi_extensions::ExtensionSession::none(),
9911 )),
9912 search: Arc::new(AltScreenSearch::new()),
9913 search_bar: Arc::new(SearchBar::new()),
9914 selection_start: std::sync::Mutex::new(None),
9915 selection_end: std::sync::Mutex::new(None),
9916 });
9917
9918 let assistant = AssistantMessage {
9922 role: rpi_ai::types::AssistantRole,
9923 content: vec![
9924 Content::Thinking(ThinkingContent {
9925 kind: ThinkingContentType,
9926 thinking: "Reasoning about the reply.".into(),
9927 thinking_signature: None,
9928 redacted: false,
9929 }),
9930 Content::Text(TextContent {
9931 kind: TextContentType,
9932 text: "Hello.".into(),
9933 text_signature: None,
9934 }),
9935 Content::ToolCall(ToolCall {
9936 kind: ToolCallType,
9937 id: "tc1".into(),
9938 name: "bash".into(),
9939 arguments: serde_json::json!({"command": "echo hi"}),
9940 thought_signature: None,
9941 namespace: None,
9942 }),
9943 ],
9944 api: rpi_ai::Api::AnthropicMessages,
9945 provider: "anthropic".into(),
9946 model: "claude-sonnet-5".into(),
9947 response_model: None,
9948 response_id: None,
9949 usage: Usage::zero(),
9950 stop_reason: StopReason::Stop,
9951 deferred: None,
9952 error_message: None,
9953 raw_stop_reason: None,
9954 end_turn: None,
9955 timestamp: 0,
9956 };
9957
9958 let comp = Arc::new(AssistantMessageComponent::new(
9961 AssistantMessageOptions::default(),
9962 ));
9963 comp.set_streaming(true);
9964 comp.update_blocks(&assistant_blocks(&assistant));
9965 let chat = Arc::new(Container::new());
9966 chat.add_child(comp.clone());
9967 *state.current_assistant.lock().unwrap() = Some(comp);
9968
9969 for c in &assistant.content {
9971 if let Content::ToolCall(tc) = c {
9972 let mut tools = state.tool_components.lock().unwrap();
9973 if !tools.contains_key(&tc.id) {
9974 let tc_comp = Arc::new(ToolExecutionComponent::new(
9975 &tc.name,
9976 &tc.arguments.to_string(),
9977 ));
9978 tc_comp.set_running();
9979 chat.add_child(tc_comp.clone());
9980 tools.insert(tc.id.clone(), tc_comp);
9981 }
9982 }
9983 }
9984
9985 let rendered = chat.render(80);
9989 let joined: String = rendered.join("\n");
9990 assert!(
9991 joined.contains("Hello."),
9992 "assistant text not rendered: {joined}"
9993 );
9994 assert!(
9995 joined.contains("Reasoning about the reply."),
9996 "thinking block not rendered: {joined}"
9997 );
9998 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9999 assert!(state.current_assistant.lock().unwrap().is_some());
10000
10001 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
10003 ended.set_result("hi", false);
10004 assert!(state.tool_components.lock().unwrap().is_empty());
10005
10006 assert!(state.try_start_working());
10010 assert!(
10011 !state.try_start_working(),
10012 "a second submit must be rejected"
10013 );
10014 state.set_status(RunStatus::Idle);
10015 state.set_status(RunStatus::Working);
10016 assert_eq!(state.status_container.child_count(), 1);
10017 assert!(state.footer.get_status().is_empty());
10018 state.show_retry(3, 10, 8_000);
10019 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
10020 assert!(retry_status.contains("Retrying (3/10)"));
10021 state.set_status(RunStatus::Working);
10022 {
10023 let mut bash = state.bash_components.lock().unwrap();
10024 bash.insert(
10025 "bash-1".into(),
10026 Arc::new(BashExecutionComponent::new("one")),
10027 );
10028 bash.insert(
10029 "bash-2".into(),
10030 Arc::new(BashExecutionComponent::new("two")),
10031 );
10032 }
10033 state.sync_working_loader_with_bash();
10034 assert_eq!(state.status_container.child_count(), 0);
10035 state.bash_components.lock().unwrap().remove("bash-1");
10036 state.sync_working_loader_with_bash();
10037 assert_eq!(state.status_container.child_count(), 0);
10038 state.bash_components.lock().unwrap().remove("bash-2");
10039 state.sync_working_loader_with_bash();
10040 assert_eq!(state.status_container.child_count(), 1);
10041
10042 state.set_status(RunStatus::Aborting);
10043 assert_eq!(state.status_container.child_count(), 0);
10044 assert!(!state.loader.is_running());
10045 }
10046
10047 #[test]
10048 fn fresh_launch_does_not_restore_old_history() {
10049 let fresh = Args::default();
10050 assert!(!launch_restores_history(&fresh));
10051
10052 let continued = Args {
10053 continue_session: true,
10054 ..Args::default()
10055 };
10056 assert!(launch_restores_history(&continued));
10057
10058 let selected = Args {
10059 session: Some("session-id".into()),
10060 ..Args::default()
10061 };
10062 assert!(launch_restores_history(&selected));
10063 }
10064
10065 #[test]
10066 fn test_short_model_name() {
10067 assert_eq!(
10068 short_model_name("anthropic:claude-sonnet-5"),
10069 "claude-sonnet-5"
10070 );
10071 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
10072 }
10073
10074 #[test]
10075 fn model_selector_items_are_deduplicated_and_provider_qualified() {
10076 use rpi_ai::{Api, Model};
10077
10078 let mut gateway = Model::new(
10079 "gpt-5.6-sol",
10080 "GPT 5.6 Sol",
10081 Api::OpenaiCompletions,
10082 "routeryo-copy",
10083 "https://gateway.example.com",
10084 );
10085 let duplicate = gateway.clone();
10086 let anthropic = Model::new(
10087 "claude-sonnet-5",
10088 "Claude Sonnet 5",
10089 Api::AnthropicMessages,
10090 "anthropic",
10091 "https://api.anthropic.com",
10092 );
10093 gateway.headers = Some(std::collections::BTreeMap::from([(
10094 "authorization".into(),
10095 "Bearer test".into(),
10096 )]));
10097
10098 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
10099 assert_eq!(items.len(), 2);
10100 assert_eq!(items[0].value, "gpt-5.6-sol");
10101 assert_eq!(items[0].label, "GPT 5.6 Sol");
10102 assert_eq!(
10103 items[0].description.as_deref(),
10104 Some("routeryo-copy/gpt-5.6-sol (current)")
10105 );
10106 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
10107 }
10108
10109 #[test]
10110 fn model_selector_match_accepts_bare_and_qualified_ids() {
10111 use rpi_ai::{Api, Model};
10112
10113 let gateway = Model::new(
10114 "gpt-5.6-sol",
10115 "GPT 5.6 Sol",
10116 Api::OpenaiCompletions,
10117 "routeryo-copy",
10118 "https://gateway.example.com",
10119 );
10120 let anthropic = Model::new(
10121 "claude-sonnet-5",
10122 "Claude Sonnet 5",
10123 Api::AnthropicMessages,
10124 "anthropic",
10125 "https://api.anthropic.com",
10126 );
10127 let catalog = [gateway, anthropic];
10128 assert_eq!(
10129 find_model_selector_match(&catalog, "gpt-5.6-sol")
10130 .unwrap()
10131 .provider,
10132 "routeryo-copy"
10133 );
10134 assert_eq!(
10135 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
10136 .unwrap()
10137 .id,
10138 "gpt-5.6-sol"
10139 );
10140 assert_eq!(
10141 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
10142 .unwrap()
10143 .id,
10144 "claude-sonnet-5"
10145 );
10146 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
10147 }
10148
10149 #[test]
10150 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
10151 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
10152
10153 let failed = AssistantMessage {
10154 role: AssistantRole,
10155 content: Vec::new(),
10156 api: rpi_ai::Api::AnthropicMessages,
10157 provider: "anthropic".into(),
10158 model: "claude-sonnet-5".into(),
10159 response_model: None,
10160 response_id: None,
10161 usage: Usage::zero(),
10162 stop_reason: StopReason::Error,
10163 deferred: None,
10164 error_message: Some("upstream returned 401".into()),
10165 raw_stop_reason: None,
10166 end_turn: None,
10167 timestamp: 0,
10168 };
10169 assert_eq!(
10170 assistant_error_text(&failed).as_deref(),
10171 Some("upstream returned 401")
10172 );
10173
10174 let mut no_detail = failed;
10175 no_detail.error_message = Some(" ".into());
10176 assert_eq!(
10177 assistant_error_text(&no_detail).as_deref(),
10178 Some("Provider request failed.")
10179 );
10180
10181 let mut aborted = no_detail;
10182 aborted.stop_reason = StopReason::Aborted;
10183 aborted.error_message = Some("abort error: Request aborted".into());
10184 assert_eq!(
10185 assistant_error_text(&aborted).as_deref(),
10186 Some("abort error: Request aborted")
10187 );
10188
10189 aborted.error_message = None;
10190 assert_eq!(
10191 assistant_error_text(&aborted).as_deref(),
10192 Some("Request aborted.")
10193 );
10194 }
10195
10196 #[test]
10197 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
10198 assert_eq!(
10199 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
10200 "405\nMethod Not Allowed"
10201 );
10202 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
10203 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
10204 let long = "x".repeat(20_000);
10205 let cleaned = sanitize_error_message(&long);
10206 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
10207 assert!(cleaned.ends_with('…'));
10208 }
10209
10210 #[test]
10211 fn test_cycle_next_model_wraps_around() {
10212 use rpi_ai::{Api, Model};
10213 let mk = |id: &str| {
10214 Model::new(
10215 id,
10216 id,
10217 Api::AnthropicMessages,
10218 "anthropic",
10219 "https://api.anthropic.com",
10220 )
10221 };
10222 let catalog = [mk("a"), mk("b"), mk("c")];
10223 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
10225 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
10226 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
10228 let empty: Vec<Model> = vec![];
10230 assert!(cycle_next_model(&empty, "a").is_none());
10231 }
10232
10233 #[test]
10234 fn test_autocomplete_slash_suggestions_render() {
10235 let state = Arc::new(TuiState {
10238 current_assistant: std::sync::Mutex::new(None),
10239 tool_components: std::sync::Mutex::new(HashMap::new()),
10240 bash_components: std::sync::Mutex::new(HashMap::new()),
10241 themes_enabled: true,
10242 hide_thinking: std::sync::Mutex::new(false),
10243 tool_outputs_expanded: std::sync::Mutex::new(false),
10244 show_terminal_progress: true,
10245 status: std::sync::Mutex::new(RunStatus::Idle),
10246 js_preparation_cancel: std::sync::Mutex::new(None),
10247 footer: Arc::new(FooterComponent::new()),
10248 status_container: Arc::new(Container::new()),
10249 chat_container: Arc::new(Container::new()),
10250 loader: Arc::new(Loader::new()),
10251 last_assistant_text: std::sync::Mutex::new(String::new()),
10252 active_selector: std::sync::Mutex::new(None),
10253 active_extension_editor: std::sync::Mutex::new(None),
10254 active_extension_input: std::sync::Mutex::new(None),
10255 active_extension_cancel: std::sync::Mutex::new(None),
10256 autocomplete: AutocompleteManager::new(),
10257 autocomplete_container: Arc::new(Container::new()),
10258 autocomplete_max_visible: 5,
10259 pending_images: std::sync::Mutex::new(Vec::new()),
10260 theme_manager: Arc::new(ThemeManager::new()),
10261 tui: None,
10262 current_model_id: std::sync::Mutex::new(String::new()),
10263 show_images: std::sync::Mutex::new(true),
10264 history: std::sync::Mutex::new(Vec::new()),
10265 history_index: std::sync::Mutex::new(-1),
10266 history_draft: std::sync::Mutex::new(None),
10267 last_input_tokens: std::sync::Mutex::new(0),
10268 scoped_edit: std::sync::Mutex::new(None),
10269 markdown_transformer: std::sync::Mutex::new(None),
10270 extension_session: Arc::new(std::sync::Mutex::new(
10271 rpi_extensions::ExtensionSession::none(),
10272 )),
10273 search: Arc::new(AltScreenSearch::new()),
10274 search_bar: Arc::new(SearchBar::new()),
10275 selection_start: std::sync::Mutex::new(None),
10276 selection_end: std::sync::Mutex::new(None),
10277 });
10278 {
10279 let mut combined = CombinedAutocompleteProvider::new();
10280 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10281 build_builtin_registry().visible_entries(),
10282 )));
10283 state.autocomplete.set_provider(Arc::new(combined));
10284 }
10285
10286 let editor = Arc::new(Editor::simple());
10287 editor.set_text("/he");
10288 editor.set_cursor(0, 3);
10289 refresh_autocomplete(&state, &editor);
10290 let lines = state.autocomplete_container.render(80);
10291 let joined: String = lines.join("\n");
10292 assert!(
10293 joined.contains("/help"),
10294 "slash suggestions not rendered: {joined}"
10295 );
10296
10297 editor.set_text("hello");
10299 editor.set_cursor(0, 5);
10300 refresh_autocomplete(&state, &editor);
10301 assert!(state.autocomplete_container.render(80).is_empty());
10302 }
10303
10304 #[test]
10305 fn test_select_list_swap_restores_editor() {
10306 let state = Arc::new(TuiState {
10310 current_assistant: std::sync::Mutex::new(None),
10311 tool_components: std::sync::Mutex::new(HashMap::new()),
10312 bash_components: std::sync::Mutex::new(HashMap::new()),
10313 themes_enabled: true,
10314 hide_thinking: std::sync::Mutex::new(false),
10315 tool_outputs_expanded: std::sync::Mutex::new(false),
10316 show_terminal_progress: true,
10317 status: std::sync::Mutex::new(RunStatus::Idle),
10318 js_preparation_cancel: std::sync::Mutex::new(None),
10319 footer: Arc::new(FooterComponent::new()),
10320 status_container: Arc::new(Container::new()),
10321 chat_container: Arc::new(Container::new()),
10322 loader: Arc::new(Loader::new()),
10323 last_assistant_text: std::sync::Mutex::new(String::new()),
10324 active_selector: std::sync::Mutex::new(None),
10325 active_extension_editor: std::sync::Mutex::new(None),
10326 active_extension_input: std::sync::Mutex::new(None),
10327 active_extension_cancel: std::sync::Mutex::new(None),
10328 autocomplete: AutocompleteManager::new(),
10329 autocomplete_container: Arc::new(Container::new()),
10330 autocomplete_max_visible: 5,
10331 pending_images: std::sync::Mutex::new(Vec::new()),
10332 theme_manager: Arc::new(ThemeManager::new()),
10333 tui: None,
10334 current_model_id: std::sync::Mutex::new(String::new()),
10335 show_images: std::sync::Mutex::new(true),
10336 history: std::sync::Mutex::new(Vec::new()),
10337 history_index: std::sync::Mutex::new(-1),
10338 history_draft: std::sync::Mutex::new(None),
10339 last_input_tokens: std::sync::Mutex::new(0),
10340 scoped_edit: std::sync::Mutex::new(None),
10341 markdown_transformer: std::sync::Mutex::new(None),
10342 extension_session: Arc::new(std::sync::Mutex::new(
10343 rpi_extensions::ExtensionSession::none(),
10344 )),
10345 search: Arc::new(AltScreenSearch::new()),
10346 search_bar: Arc::new(SearchBar::new()),
10347 selection_start: std::sync::Mutex::new(None),
10348 selection_end: std::sync::Mutex::new(None),
10349 });
10350 let editor_container = Arc::new(Container::new());
10351 let editor = Arc::new(Editor::simple());
10352 editor_container.add_child(editor.clone());
10353 assert!(!state.selector_open());
10354
10355 let tui_terminal = Box::new(ProcessTerminal::new());
10356 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
10357 let list = Arc::new(SelectList::new(
10358 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
10359 5,
10360 ));
10361 open_selector(
10362 &state,
10363 &editor_container,
10364 &editor,
10365 &tui,
10366 list,
10367 SelectorKind::Theme,
10368 );
10369 assert!(state.selector_open());
10370 assert_eq!(editor_container.child_count(), 1);
10372
10373 close_selector(&state, &editor_container, &editor, &tui);
10374 assert!(!state.selector_open());
10375 assert_eq!(editor_container.child_count(), 1);
10377 }
10378
10379 #[test]
10380 fn test_message_history_browse_restores_draft() {
10381 let state = Arc::new(TuiState {
10385 current_assistant: std::sync::Mutex::new(None),
10386 tool_components: std::sync::Mutex::new(HashMap::new()),
10387 bash_components: std::sync::Mutex::new(HashMap::new()),
10388 themes_enabled: true,
10389 hide_thinking: std::sync::Mutex::new(false),
10390 tool_outputs_expanded: std::sync::Mutex::new(false),
10391 show_terminal_progress: true,
10392 status: std::sync::Mutex::new(RunStatus::Idle),
10393 js_preparation_cancel: std::sync::Mutex::new(None),
10394 footer: Arc::new(FooterComponent::new()),
10395 status_container: Arc::new(Container::new()),
10396 chat_container: Arc::new(Container::new()),
10397 loader: Arc::new(Loader::new()),
10398 last_assistant_text: std::sync::Mutex::new(String::new()),
10399 active_selector: std::sync::Mutex::new(None),
10400 active_extension_editor: std::sync::Mutex::new(None),
10401 active_extension_input: std::sync::Mutex::new(None),
10402 active_extension_cancel: std::sync::Mutex::new(None),
10403 autocomplete: AutocompleteManager::new(),
10404 autocomplete_container: Arc::new(Container::new()),
10405 autocomplete_max_visible: 5,
10406 pending_images: std::sync::Mutex::new(Vec::new()),
10407 theme_manager: Arc::new(ThemeManager::new()),
10408 tui: None,
10409 current_model_id: std::sync::Mutex::new(String::new()),
10410 show_images: std::sync::Mutex::new(true),
10411 history: std::sync::Mutex::new(Vec::new()),
10412 history_index: std::sync::Mutex::new(-1),
10413 history_draft: std::sync::Mutex::new(None),
10414 last_input_tokens: std::sync::Mutex::new(0),
10415 scoped_edit: std::sync::Mutex::new(None),
10416 markdown_transformer: std::sync::Mutex::new(None),
10417 extension_session: Arc::new(std::sync::Mutex::new(
10418 rpi_extensions::ExtensionSession::none(),
10419 )),
10420 search: Arc::new(AltScreenSearch::new()),
10421 search_bar: Arc::new(SearchBar::new()),
10422 selection_start: std::sync::Mutex::new(None),
10423 selection_end: std::sync::Mutex::new(None),
10424 });
10425 let editor = Arc::new(Editor::simple());
10426
10427 push_history(&state, "first message");
10428 push_history(&state, "second message");
10429 push_history(&state, "second message");
10431 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
10433 assert_eq!(state.history.lock().unwrap()[0], "second message");
10434
10435 editor.set_text("half-typed");
10437 editor.set_cursor(0, 11);
10438
10439 navigate_history(&state, &editor, -1);
10441 assert_eq!(editor.get_text(), "second message");
10442 assert_eq!(*state.history_index.lock().unwrap(), 0);
10443 navigate_history(&state, &editor, -1);
10445 assert_eq!(editor.get_text(), "first message");
10446 assert_eq!(*state.history_index.lock().unwrap(), 1);
10447 navigate_history(&state, &editor, -1);
10449 assert_eq!(editor.get_text(), "first message");
10450 navigate_history(&state, &editor, 1);
10452 assert_eq!(editor.get_text(), "second message");
10453 navigate_history(&state, &editor, 1);
10455 assert_eq!(editor.get_text(), "half-typed");
10456 assert_eq!(*state.history_index.lock().unwrap(), -1);
10457 }
10458
10459 #[test]
10460 fn test_accept_top_suggestion_replaces_prefix() {
10461 let state = Arc::new(TuiState {
10463 current_assistant: std::sync::Mutex::new(None),
10464 tool_components: std::sync::Mutex::new(HashMap::new()),
10465 bash_components: std::sync::Mutex::new(HashMap::new()),
10466 themes_enabled: true,
10467 hide_thinking: std::sync::Mutex::new(false),
10468 tool_outputs_expanded: std::sync::Mutex::new(false),
10469 show_terminal_progress: true,
10470 status: std::sync::Mutex::new(RunStatus::Idle),
10471 js_preparation_cancel: std::sync::Mutex::new(None),
10472 footer: Arc::new(FooterComponent::new()),
10473 status_container: Arc::new(Container::new()),
10474 chat_container: Arc::new(Container::new()),
10475 loader: Arc::new(Loader::new()),
10476 last_assistant_text: std::sync::Mutex::new(String::new()),
10477 active_selector: std::sync::Mutex::new(None),
10478 active_extension_editor: std::sync::Mutex::new(None),
10479 active_extension_input: std::sync::Mutex::new(None),
10480 active_extension_cancel: std::sync::Mutex::new(None),
10481 autocomplete: AutocompleteManager::new(),
10482 autocomplete_container: Arc::new(Container::new()),
10483 autocomplete_max_visible: 5,
10484 pending_images: std::sync::Mutex::new(Vec::new()),
10485 theme_manager: Arc::new(ThemeManager::new()),
10486 tui: None,
10487 current_model_id: std::sync::Mutex::new(String::new()),
10488 show_images: std::sync::Mutex::new(true),
10489 history: std::sync::Mutex::new(Vec::new()),
10490 history_index: std::sync::Mutex::new(-1),
10491 history_draft: std::sync::Mutex::new(None),
10492 last_input_tokens: std::sync::Mutex::new(0),
10493 scoped_edit: std::sync::Mutex::new(None),
10494 markdown_transformer: std::sync::Mutex::new(None),
10495 extension_session: Arc::new(std::sync::Mutex::new(
10496 rpi_extensions::ExtensionSession::none(),
10497 )),
10498 search: Arc::new(AltScreenSearch::new()),
10499 search_bar: Arc::new(SearchBar::new()),
10500 selection_start: std::sync::Mutex::new(None),
10501 selection_end: std::sync::Mutex::new(None),
10502 });
10503 {
10504 let mut combined = CombinedAutocompleteProvider::new();
10505 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10506 build_builtin_registry().visible_entries(),
10507 )));
10508 state.autocomplete.set_provider(Arc::new(combined));
10509 }
10510 let editor = Arc::new(Editor::simple());
10511 editor.set_text("/he");
10512 editor.set_cursor(0, 3);
10513 refresh_autocomplete(&state, &editor);
10514 let accepted = accept_top_suggestion(&state, &editor);
10515 assert!(accepted, "should accept the top suggestion");
10516 let text = editor.get_text();
10517 assert!(
10518 text.starts_with("/help"),
10519 "editor text should start with /help, got {text}"
10520 );
10521 }
10522
10523 #[test]
10524 fn configured_key_parser_supports_native_notation() {
10525 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10526 assert_eq!(combo.code, KeyCode::Char('g'));
10527 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10528 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10529 assert_eq!(combo.code, KeyCode::BackTab);
10530 }
10531
10532 #[test]
10533 fn double_escape_trigger_has_half_second_window() {
10534 let now = std::time::Instant::now();
10535 assert!(!double_escape_trigger(None, now));
10536 assert!(double_escape_trigger(
10537 Some(now - std::time::Duration::from_millis(500)),
10538 now
10539 ));
10540 assert!(!double_escape_trigger(
10541 Some(now - std::time::Duration::from_millis(501)),
10542 now
10543 ));
10544 }
10545
10546 #[test]
10547 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
10548 use std::sync::atomic::{AtomicUsize, Ordering};
10554
10555 let editor = rpi_tui::Editor::simple();
10556 let submits = Arc::new(AtomicUsize::new(0));
10557 let submits_for_cb = submits.clone();
10558 editor.on_submit(Arc::new(move |_text: &str| {
10559 submits_for_cb.fetch_add(1, Ordering::SeqCst);
10560 }));
10561
10562 let now = std::time::Instant::now();
10565 let mut last_text_key_at: Option<std::time::Instant> = None;
10566 let block = ["l1", "l2", "l3"];
10567 for (index, line) in block.iter().enumerate() {
10568 for ch in line.chars() {
10569 editor.insert(&ch.to_string());
10570 last_text_key_at = Some(now);
10571 }
10572 if index + 1 < block.len() {
10573 let more_queued = true;
10575 assert!(
10576 enter_is_paste_burst(last_text_key_at, now, more_queued),
10577 "pasted newline {index} must not submit"
10578 );
10579 editor.insert("\n");
10580 last_text_key_at = Some(now);
10581 }
10582 }
10583 assert_eq!(editor.get_text(), "l1\nl2\nl3");
10584 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
10585
10586 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
10588 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
10589 editor.handle_key(crossterm::event::KeyEvent::new(
10590 KeyCode::Enter,
10591 KeyModifiers::NONE,
10592 ));
10593 assert_eq!(submits.load(Ordering::SeqCst), 1, "typed Enter submits once");
10594 }
10595
10596 #[test]
10597 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
10598 let editor = rpi_tui::Editor::simple();
10601 editor.insert("alpha\r\nbeta\r\n");
10602 assert_eq!(editor.get_text(), "alpha\nbeta\n");
10603 assert!(!editor.get_text().contains('\r'));
10604 }
10605
10606 #[test]
10607 fn pasted_enter_is_not_a_submit() {
10608 let now = std::time::Instant::now();
10609
10610 assert!(enter_is_paste_burst(None, now, true));
10612
10613 assert!(enter_is_paste_burst(
10616 Some(now - std::time::Duration::from_millis(1)),
10617 now,
10618 false
10619 ));
10620 assert!(enter_is_paste_burst(
10621 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
10622 now,
10623 false
10624 ));
10625
10626 assert!(!enter_is_paste_burst(None, now, false));
10628 assert!(!enter_is_paste_burst(
10629 Some(now - PASTE_BURST_GAP),
10630 now,
10631 false
10632 ));
10633 assert!(!enter_is_paste_burst(
10634 Some(now - std::time::Duration::from_millis(120)),
10635 now,
10636 false
10637 ));
10638 }
10639
10640 #[test]
10643 fn ask_user_tool_names_are_recognized() {
10644 assert!(is_ask_user_tool("ask_user"));
10645 assert!(is_ask_user_tool("ask_user_question"));
10646 assert!(!is_ask_user_tool("bash"));
10647 }
10648
10649 #[test]
10650 fn ask_user_args_display_never_leaks_transport_json() {
10651 let text = ask_user_args_display(&serde_json::json!({
10652 "question": "你的代理端口是多少?",
10653 "context": "本机",
10654 "options": ["7890", {"title": "7897", "description": "clash"}],
10655 "suggest": "1080"
10656 }));
10657 assert!(text.contains("你的代理端口是多少?"));
10658 assert!(text.contains("本机"));
10659 assert!(text.contains("Choices: 7890, 7897"));
10660 assert!(text.contains("Suggestions: 1080"));
10661 assert!(!text.trim_start().starts_with('{'));
10662
10663 let multi = ask_user_args_display(&serde_json::json!({
10664 "questions": [
10665 {"id": "a", "question": "Q1"},
10666 {"id": "b", "header": "H", "question": "Q2"}
10667 ]
10668 }));
10669 assert!(multi.contains("Q1"));
10670 assert!(multi.contains("H: Q2"));
10671
10672 assert_eq!(
10673 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10674 "Confirmation requested: Deploy?"
10675 );
10676 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10677 }
10678
10679 #[test]
10680 fn ask_user_result_and_progress_prefer_readable_content() {
10681 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10682 assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10683 .contains("端口?"));
10684 let empty = rpi_agent::AgentToolResult::default();
10685 assert!(
10686 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10687 .contains("端口?")
10688 );
10689 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10690 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10691 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10692 }
10693
10694 #[test]
10695 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10696 let flat = rpi_extensions::UiDialogRequest {
10697 request_id: "r1".into(),
10698 tool_call_id: Some("c1".into()),
10699 ui: serde_json::json!({
10700 "kind": "selector",
10701 "id": "proxy_port",
10702 "header": "Proxy",
10703 "question": "端口?",
10704 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10705 "allowMultiple": true,
10706 "allowFreeform": false,
10707 "suggest": "1080"
10708 }),
10709 raw: serde_json::json!({}),
10710 };
10711 let prompt = parse_ask_user_prompt(&flat);
10712 assert_eq!(prompt.question_id, "proxy_port");
10713 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10714 assert_eq!(prompt.options.len(), 2);
10715 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10716 assert!(prompt.allow_multiple);
10717 assert!(!prompt.allow_freeform);
10718 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10719
10720 let alias = rpi_extensions::UiDialogRequest {
10721 ui: serde_json::json!({
10722 "questions": [{"id": "q", "question": "Q?", "options": []}]
10723 }),
10724 ..flat.clone()
10725 };
10726 let prompt = parse_ask_user_prompt(&alias);
10727 assert_eq!(prompt.question_id, "q");
10728 assert!(prompt.allow_freeform, "no options defaults to freeform");
10729
10730 let confirm = rpi_extensions::UiDialogRequest {
10731 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10732 ..flat.clone()
10733 };
10734 let prompt = parse_ask_user_prompt(&confirm);
10735 assert_eq!(prompt.kind, "confirm");
10736 assert_eq!(prompt.options.len(), 2);
10737 assert_eq!(prompt.options[0].0, "Yes");
10738 }
10739
10740 #[test]
10741 fn ask_user_bridge_answers_and_cancels_by_request_id() {
10742 let mailbox = rpi_extensions::UiDialogMailbox::new();
10743 mailbox.attach();
10744 let bridge = AskUserBridge::new(mailbox.clone());
10745
10746 mailbox
10748 .handle(serde_json::json!({
10749 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10750 "ui": {"kind": "input", "question": "Port?"}
10751 }))
10752 .unwrap();
10753 mailbox
10754 .handle(serde_json::json!({
10755 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10756 "ui": {"kind": "input", "question": "Level?"}
10757 }))
10758 .unwrap();
10759
10760 let first = bridge.take_pending().expect("r1 visible");
10761 assert_eq!(first.request_id, "r1");
10762 assert!(
10763 bridge.take_pending().is_none(),
10764 "only one request occupies the input slot"
10765 );
10766 bridge.respond("r1", serde_json::json!({"text": "7897"}));
10767 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10768
10769 let second = bridge.take_pending().expect("r2 visible");
10770 assert_eq!(second.request_id, "r2");
10771 bridge.cancel("r2");
10772 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10773
10774 mailbox
10776 .handle(serde_json::json!({
10777 "op": "open", "requestId": "r3",
10778 "ui": {"kind": "input", "question": "X?"}
10779 }))
10780 .unwrap();
10781 bridge.cancel_all();
10782 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10783
10784 bridge.shutdown();
10786 assert!(mailbox
10787 .handle(serde_json::json!({
10788 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10789 }))
10790 .is_err());
10791 }
10792}