1use std::collections::{HashMap, HashSet, VecDeque};
27use std::io::IsTerminal;
28use std::sync::{mpsc as std_mpsc, Arc, Mutex};
29
30use base64::Engine;
31use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
32use tokio::sync::{broadcast, mpsc};
33use tokio_util::sync::CancellationToken;
34
35use rpi_agent::{AgentEvent, AgentMessage};
36use rpi_ai::types::{AssistantMessage, Content, UserMessage};
37use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
38use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
39use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
40#[cfg(test)]
41use rpi_tui::strip_ansi;
42use rpi_tui::{
43 apply_theme_preset, render_diff, AssistantBlock, AssistantMessageComponent,
44 AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
45 BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
46 EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
47 FooterComponent, Image, ImageOptions, Input, Loader, ProcessTerminal, ScrollView,
48 ScrollViewOptions, SelectItem, SelectList, SlashCommand as SlashCommandEntry,
49 SlashCommandAutocompleteProvider, Spacer, StackChild, StackEntry, StatusIndicator, Text,
50 ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack,
51 TUI,
52};
53use rpi_tui::{bold as tui_bold, theme as current_theme};
54
55#[allow(unused_imports)]
56use rpi_tui::BashStatus;
57
58use crate::args::Args;
59
60type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
67
68#[derive(Clone, Default)]
74struct JsDialogBridge {
75 pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
76 active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
77 visible: Arc<Mutex<Option<String>>>,
81 cancelled_before_open: Arc<Mutex<HashSet<String>>>,
82 closed: Arc<Mutex<bool>>,
83}
84
85struct JsDialogPending {
86 request: JsDialogRequest,
87 result: std_mpsc::Sender<serde_json::Value>,
88}
89
90struct JsDialogActive {
91 result: std_mpsc::Sender<serde_json::Value>,
92 cancel_requested: bool,
93}
94
95#[derive(Clone, Debug)]
96struct JsDialogRequest {
97 id: String,
98 method: String,
99 title: String,
100 message: String,
101 options: Vec<String>,
102 placeholder: Option<String>,
103 prefill: Option<String>,
104}
105
106impl JsDialogRequest {
107 fn parse(args: &serde_json::Value) -> Result<Self, String> {
108 let id = args
109 .get("dialogId")
110 .and_then(serde_json::Value::as_str)
111 .filter(|value| !value.is_empty())
112 .ok_or("ui.dialog missing dialogId")?
113 .to_string();
114 let method = args
115 .get("method")
116 .and_then(serde_json::Value::as_str)
117 .ok_or("ui.dialog missing method")?
118 .to_string();
119 if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
120 return Err(format!("unsupported UI dialog method: {method}"));
121 }
122 let options = args
123 .get("options")
124 .and_then(serde_json::Value::as_array)
125 .map(|values| {
126 values
127 .iter()
128 .filter_map(serde_json::Value::as_str)
129 .map(ToOwned::to_owned)
130 .collect()
131 })
132 .unwrap_or_default();
133 Ok(Self {
134 id,
135 method,
136 title: args
137 .get("title")
138 .and_then(serde_json::Value::as_str)
139 .unwrap_or_default()
140 .to_string(),
141 message: args
142 .get("message")
143 .and_then(serde_json::Value::as_str)
144 .unwrap_or_default()
145 .to_string(),
146 options,
147 placeholder: args
148 .get("placeholder")
149 .and_then(serde_json::Value::as_str)
150 .map(ToOwned::to_owned),
151 prefill: args
152 .get("prefill")
153 .and_then(serde_json::Value::as_str)
154 .map(ToOwned::to_owned),
155 })
156 }
157}
158
159impl JsDialogBridge {
160 fn handle_runtime_request(
161 &self,
162 action: &str,
163 args: serde_json::Value,
164 ) -> Result<serde_json::Value, String> {
165 match action {
166 "ui.dialog" => self.wait_for_dialog(args),
167 "ui.dialog.cancel" => {
168 let id = args
169 .get("dialogId")
170 .and_then(serde_json::Value::as_str)
171 .ok_or("ui.dialog.cancel missing dialogId")?;
172 self.cancel(id);
173 Ok(serde_json::json!(true))
174 }
175 _ => Err(format!("unsupported capability: {action}")),
176 }
177 }
178
179 fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
180 let request = JsDialogRequest::parse(&args)?;
181 let (sender, receiver) = std_mpsc::channel();
182 let _closed = self
186 .closed
187 .lock()
188 .map_err(|_| "JS dialog bridge poisoned")?;
189 if *_closed {
190 return Ok(serde_json::json!({ "cancelled": true }));
191 }
192 let cancelled_before_open = self
193 .cancelled_before_open
194 .lock()
195 .map_err(|_| "JS dialog cancellation state poisoned")?
196 .remove(&request.id);
197 if cancelled_before_open {
198 return Ok(serde_json::json!({ "cancelled": true }));
199 }
200 self.pending
203 .lock()
204 .map_err(|_| "JS dialog pending state poisoned")?
205 .push_back(JsDialogPending {
206 request,
207 result: sender,
208 });
209 drop(_closed);
210 receiver
211 .recv()
212 .map_err(|_| "JS dialog closed before it received an answer".to_string())
213 }
214
215 fn take_pending(&self) -> Option<JsDialogRequest> {
218 loop {
219 let pending = self.pending.lock().ok()?.pop_front()?;
220 let mut active = self.active.lock().ok()?;
221 if self
228 .cancelled_before_open
229 .lock()
230 .ok()?
231 .remove(&pending.request.id)
232 {
233 drop(active);
234 let _ = pending
235 .result
236 .send(serde_json::json!({ "cancelled": true }));
237 continue;
238 }
239 active.insert(
240 pending.request.id.clone(),
241 JsDialogActive {
242 result: pending.result,
243 cancel_requested: false,
244 },
245 );
246 if let Ok(mut visible) = self.visible.lock() {
247 *visible = Some(pending.request.id.clone());
248 }
249 return Some(pending.request);
250 }
251 }
252
253 fn respond(&self, id: &str, result: serde_json::Value) {
254 if let Ok(mut active) = self.active.lock() {
255 if let Some(entry) = active.remove(id) {
256 let _ = entry.result.send(result);
257 }
258 }
259 if let Ok(mut visible) = self.visible.lock() {
260 if visible.as_deref() == Some(id) {
261 *visible = None;
262 }
263 }
264 }
265
266 fn cancel(&self, id: &str) {
267 if let Ok(mut pending) = self.pending.lock() {
268 if let Some(index) = pending.iter().position(|item| item.request.id == id) {
269 if let Some(item) = pending.remove(index) {
270 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
271 return;
272 }
273 }
274 }
275 if let Ok(mut active) = self.active.lock() {
276 if let Some(entry) = active.get_mut(id) {
277 if !entry.cancel_requested {
278 entry.cancel_requested = true;
279 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
280 }
281 return;
282 }
283 }
284 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
285 cancelled.insert(id.to_string());
286 }
287 }
288
289 fn cancelled_active_ids(&self) -> Vec<String> {
290 self.active
291 .lock()
292 .map(|active| {
293 active
294 .iter()
295 .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
296 .collect()
297 })
298 .unwrap_or_default()
299 }
300
301 fn is_visible(&self, id: &str) -> bool {
302 self.visible
303 .lock()
304 .map(|visible| visible.as_deref() == Some(id))
305 .unwrap_or(false)
306 }
307
308 fn finish(&self, id: &str) {
309 if let Ok(mut active) = self.active.lock() {
310 active.remove(id);
311 }
312 if let Ok(mut visible) = self.visible.lock() {
313 if visible.as_deref() == Some(id) {
314 *visible = None;
315 }
316 }
317 }
318
319 fn cancel_all(&self) {
320 let Ok(mut closed) = self.closed.lock() else {
324 return;
325 };
326 *closed = true;
327 if let Ok(mut pending) = self.pending.lock() {
328 for item in pending.drain(..) {
329 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
330 }
331 }
332 if let Ok(mut active) = self.active.lock() {
333 for (_, entry) in active.drain() {
334 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
335 }
336 }
337 if let Ok(mut visible) = self.visible.lock() {
338 *visible = None;
339 }
340 drop(closed);
341 }
342
343 fn cancel_open_requests(&self) {
346 let Ok(mut closed) = self.closed.lock() else {
350 return;
351 };
352 *closed = true;
353 if let Ok(mut pending) = self.pending.lock() {
354 for item in pending.drain(..) {
355 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
356 }
357 }
358 if let Ok(mut active) = self.active.lock() {
359 for (_, entry) in active.drain() {
360 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
361 }
362 }
363 if let Ok(mut visible) = self.visible.lock() {
364 *visible = None;
365 }
366 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
367 cancelled.clear();
368 }
369 drop(closed);
370 }
371
372 fn reopen(&self) {
373 if let Ok(mut closed) = self.closed.lock() {
374 *closed = false;
375 }
376 }
377}
378
379fn build_markdown_transformer(
401 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
402) -> Option<MarkdownTransformer> {
403 let snapshot = snapshot?;
404 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
409 if renderers.is_empty() {
410 return None;
411 }
412 Some(Arc::new(move |raw: &str| -> String {
413 transform_markdown_chain(&snapshot, &renderers, raw)
414 }))
415}
416
417fn transform_markdown_chain(
425 snapshot: &rpi_extensions::RegistrySnapshot,
426 renderers: &[rpi_extensions::RegisteredRenderer],
427 raw: &str,
428) -> String {
429 if !snapshot.is_active() {
434 return raw.to_string();
435 }
436
437 let mut current = raw.to_string();
438 for renderer in renderers {
439 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
440 Ok(s) => s,
441 Err(_) => return current, };
443 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
453 let mut out = rpi_plugin_sdk::StbString::empty();
454 let rc = (renderer.render_fn)(
455 rpi_plugin_sdk::StbStringRef::from_str(&input),
456 &mut out as *mut rpi_plugin_sdk::StbString,
457 renderer.user_data,
458 );
459 let text = if rc == 0 {
460 let s = out.to_string_lossy();
461 Some(s)
462 } else {
463 None
464 };
465 out.free_with(Some(renderer.plugin_free_string));
469 text
470 }));
471 let out_text = match outcome {
472 Ok(Some(s)) => s,
473 Ok(None) => return current, Err(_) => return current, };
479 let next = serde_json::from_str::<serde_json::Value>(&out_text)
482 .ok()
483 .and_then(|v| {
484 v.get("markdown")
485 .and_then(|m| m.as_str())
486 .map(|s| s.to_string())
487 })
488 .unwrap_or(current);
489 current = next;
490 }
491 current
492}
493
494#[derive(Clone)]
518struct CommandContext {
519 chat: Arc<Container>,
520 tui: Arc<TuiAltScreen>,
521 tx: mpsc::UnboundedSender<TuiMessage>,
522 state: Arc<TuiState>,
523 editor: Arc<Editor>,
524 editor_container: Arc<Container>,
525 lane: Arc<dyn AgentLane>,
526 model_catalog: Arc<Vec<rpi_ai::Model>>,
527 lane_model_id: String,
531 cwd: std::path::PathBuf,
532 package_resources: Arc<crate::packages::PackageResources>,
536 resources: Arc<rpi_harness::types::AgentHarnessResources>,
540 reload_context: Arc<crate::session::ReloadContext>,
546}
547
548trait SlashCommand: Send + Sync {
550 fn name(&self) -> &str;
552 fn aliases(&self) -> &'static [&'static str] {
556 &[]
557 }
558 fn visible(&self) -> bool {
561 true
562 }
563 fn alias_visible(&self) -> &'static [&'static str] {
567 &[]
568 }
569 fn description(&self) -> &'static str {
572 ""
573 }
574 fn description_owned(&self) -> String {
575 self.description().to_string()
576 }
577 fn execute(&self, ctx: &CommandContext, args: &str);
583}
584
585struct CommandRegistry {
588 commands: Vec<Arc<dyn SlashCommand>>,
589}
590
591impl CommandRegistry {
592 fn new() -> Self {
593 Self {
594 commands: Vec::new(),
595 }
596 }
597
598 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
599 self.commands.push(cmd);
600 }
601
602 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
605 self.commands
606 .iter()
607 .find(|c| c.name() == token || c.aliases().contains(&token))
608 }
609
610 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
616 let mut out: Vec<SlashCommandEntry> = Vec::new();
617 for c in &self.commands {
618 let description = c.description_owned();
619 if c.visible() && !description.is_empty() {
620 out.push(SlashCommandEntry {
621 name: c.name().into(),
622 description: description.clone(),
623 });
624 }
625 for alias in c.alias_visible() {
627 out.push(SlashCommandEntry {
628 name: (*alias).into(),
629 description: description.clone(),
630 });
631 }
632 }
633 out
634 }
635}
636
637fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
641 let mut parts = text.split_whitespace();
642 let token = parts.next().unwrap_or("");
643 let args = parts.collect::<Vec<_>>().join(" ");
644 match registry.find(token) {
645 Some(cmd) => cmd.execute(ctx, &args),
646 None => {
647 add_error_message(
648 &ctx.chat,
649 &format!("Unknown command: {text}. Type /help for available commands."),
650 );
651 ctx.tui.request_render(false);
652 }
653 }
654}
655
656fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
661 use crossterm::event::{KeyCode, KeyModifiers};
662
663 let modifiers = key.modifiers;
664 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
665 let shift = modifiers.contains(KeyModifiers::SHIFT);
666 let alt = modifiers.contains(KeyModifiers::ALT);
667 let super_key = modifiers.contains(KeyModifiers::SUPER);
668
669 if modifiers == KeyModifiers::NONE {
670 return match key.code {
671 KeyCode::Char(ch) => ch.to_string(),
672 KeyCode::Enter => "\r".into(),
673 KeyCode::Esc => "\x1b".into(),
674 KeyCode::Backspace => "\x7f".into(),
675 KeyCode::Tab => "\t".into(),
676 KeyCode::BackTab => "\x1b[Z".into(),
680 KeyCode::Up => "\x1b[A".into(),
681 KeyCode::Down => "\x1b[B".into(),
682 KeyCode::Right => "\x1b[C".into(),
683 KeyCode::Left => "\x1b[D".into(),
684 KeyCode::Home => "\x1b[H".into(),
685 KeyCode::End => "\x1b[F".into(),
686 KeyCode::PageUp => "\x1b[5~".into(),
687 KeyCode::PageDown => "\x1b[6~".into(),
688 KeyCode::Delete => "\x1b[3~".into(),
689 KeyCode::Insert => "\x1b[2~".into(),
690 KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
691 _ => String::new(),
692 };
693 }
694
695 if ctrl && !shift && !alt && !super_key {
698 if let KeyCode::Char(ch) = key.code {
699 if let Some(code) = control_code(ch) {
700 return char::from(code).to_string();
701 }
702 }
703 }
704
705 if alt && !ctrl && !shift && !super_key {
708 if let KeyCode::Char(ch) = key.code {
709 return format!("\x1b{ch}");
710 }
711 }
712
713 if shift && !ctrl && !alt && !super_key {
719 if let KeyCode::Char(ch) = key.code {
720 return ch.to_string();
721 }
722 }
723
724 if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
725 return sequence;
726 }
727 let Some(codepoint) = key_codepoint(key.code, ctrl) else {
728 return String::new();
729 };
730 kitty_key_sequence(codepoint, modifiers)
731}
732
733fn control_code(ch: char) -> Option<u8> {
734 let ch = ch.to_ascii_lowercase();
735 Some(match ch {
736 '@' | ' ' => 0,
737 'a'..='z' => (ch as u8) & 0x1f,
738 '[' => 0x1b,
739 '\\' => 0x1c,
740 ']' => 0x1d,
741 '^' => 0x1e,
742 '_' | '-' => 0x1f,
743 _ => return None,
744 })
745}
746
747fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
748 use crossterm::event::KeyCode;
749 Some(match code {
750 KeyCode::Char(ch) => {
751 if ctrl {
752 ch.to_ascii_lowercase() as u32
753 } else {
754 ch as u32
755 }
756 }
757 KeyCode::Enter => 13,
758 KeyCode::Esc => 27,
759 KeyCode::Backspace => 127,
760 KeyCode::Tab => 9,
761 KeyCode::BackTab => 9,
764 _ => return None,
765 })
766}
767
768fn modified_functional_sequence(
769 code: crossterm::event::KeyCode,
770 modifiers: crossterm::event::KeyModifiers,
771) -> Option<String> {
772 use crossterm::event::{KeyCode, KeyModifiers};
773 if code == KeyCode::BackTab
777 && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
778 {
779 return Some("\x1b[Z".into());
780 }
781 let modifier = kitty_modifier(modifiers);
782 let sequence = match code {
783 KeyCode::Up => format!("\x1b[1;{modifier}A"),
784 KeyCode::Down => format!("\x1b[1;{modifier}B"),
785 KeyCode::Right => format!("\x1b[1;{modifier}C"),
786 KeyCode::Left => format!("\x1b[1;{modifier}D"),
787 KeyCode::Home => format!("\x1b[1;{modifier}H"),
788 KeyCode::End => format!("\x1b[1;{modifier}F"),
789 KeyCode::Insert => format!("\x1b[2;{modifier}~"),
790 KeyCode::Delete => format!("\x1b[3;{modifier}~"),
791 KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
792 KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
793 _ => return None,
794 };
795 Some(sequence)
796}
797
798fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
799 use crossterm::event::KeyModifiers;
800 let mut modifier = 1u8;
801 if modifiers.contains(KeyModifiers::SHIFT) {
802 modifier += 1;
803 }
804 if modifiers.contains(KeyModifiers::ALT) {
805 modifier += 2;
806 }
807 if modifiers.contains(KeyModifiers::CONTROL) {
808 modifier += 4;
809 }
810 if modifiers.contains(KeyModifiers::SUPER) {
811 modifier += 8;
812 }
813 modifier
814}
815
816fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
817 let modifier = kitty_modifier(modifiers);
818 format!("\x1b[{codepoint};{modifier}u")
819}
820
821struct ExtensionCommand {
826 name: String,
827 description: String,
828 session: crate::session::ExtensionSessionCell,
829}
830
831struct JsExtensionCommand {
832 name: String,
833 session: crate::js_extensions::JsExtensionSession,
834}
835
836impl SlashCommand for JsExtensionCommand {
837 fn name(&self) -> &str {
838 &self.name
839 }
840 fn description(&self) -> &'static str {
841 "JS extension command"
842 }
843 fn description_owned(&self) -> String {
844 "JS extension command".to_string()
845 }
846 fn execute(&self, ctx: &CommandContext, args: &str) {
847 let session = self.session.clone();
852 let command = self.name.trim_start_matches('/').to_string();
853 let args = args.to_string();
854 let ctx = ctx.clone();
855 let initial_editor_text = ctx.editor.get_text();
859 tokio::task::spawn_blocking(move || {
860 match session.invoke_command_with_context(
861 &command,
862 &args,
863 serde_json::json!({"editorText": initial_editor_text}),
864 ) {
865 Ok(value) => {
866 if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
867 if ctx.editor.get_text() == initial_editor_text
868 && editor_text != initial_editor_text
869 {
870 let cursor = editor_text.chars().count();
871 ctx.editor.set_text(editor_text);
872 ctx.editor.set_cursor(0, cursor);
873 }
874 }
875 if let Some(notifications) =
876 value.get("notifications").and_then(|v| v.as_array())
877 {
878 for notification in notifications {
879 let message = notification
880 .get("message")
881 .and_then(|v| v.as_str())
882 .unwrap_or_default();
883 if message.is_empty() {
884 continue;
885 }
886 match notification.get("level").and_then(|v| v.as_str()) {
887 Some("error") => add_error_message(&ctx.chat, message),
888 _ => add_note_message(&ctx.chat, message),
889 }
890 }
891 }
892 let result = value.get("result").unwrap_or(&value);
893 let text = result
894 .get("text")
895 .and_then(|item| item.as_str())
896 .map(str::to_string)
897 .or_else(|| result.as_str().map(str::to_string))
898 .filter(|text| !text.is_empty() && text != "null");
899 if let Some(text) = text {
900 add_note_message(&ctx.chat, &text);
901 }
902 }
903 Err(error) => {
904 add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
905 }
906 }
907 ctx.tui.request_render(false);
908 });
909 }
910}
911
912impl SlashCommand for ExtensionCommand {
913 fn name(&self) -> &str {
914 &self.name
915 }
916
917 fn description(&self) -> &'static str {
918 "extension command"
919 }
920
921 fn description_owned(&self) -> String {
922 self.description.clone()
923 }
924
925 fn execute(&self, ctx: &CommandContext, args: &str) {
926 let result = invoke_extension_command(&self.session, &self.name, args);
927 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
928 }
929}
930
931fn invoke_extension_command(
932 session: &crate::session::ExtensionSessionCell,
933 name: &str,
934 args: &str,
935) -> Option<serde_json::Value> {
936 let command = session
937 .lock()
938 .ok()
939 .and_then(|s| s.snapshot_arc())
940 .and_then(|snap| {
941 snap.commands()
942 .iter()
943 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
944 .cloned()
945 })?;
946 let input = serde_json::json!({ "args": args, "command": name });
947 let input = serde_json::to_string(&input).ok()?;
948 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
949 let mut out = rpi_plugin_sdk::StbString::empty();
950 let rc = (command.handler)(
951 rpi_plugin_sdk::StbStringRef::from_str(&input),
952 &mut out as *mut rpi_plugin_sdk::StbString,
953 command.user_data,
954 );
955 let text = if rc == 0 {
956 Some(out.to_string_lossy())
957 } else {
958 None
959 };
960 rpi_extensions::host_free_string(out);
961 text
962 }))
963 .ok()
964 .flatten()?;
965 serde_json::from_str(&outcome).ok()
966}
967
968fn handle_extension_ui_result(
969 result: Option<serde_json::Value>,
970 ctx: &CommandContext,
971 session: crate::session::ExtensionSessionCell,
972 command_name: String,
973) {
974 let Some(value) = result else {
975 add_error_message(&ctx.chat, "Extension command failed.");
976 ctx.tui.request_render(false);
977 return;
978 };
979 if value.is_null() {
983 ctx.tui.request_render(false);
984 return;
985 }
986 match value.get("kind").and_then(|v| v.as_str()) {
987 Some("message") | None => {
988 let fallback = value.to_string();
989 let text = value
990 .get("text")
991 .and_then(|v| v.as_str())
992 .unwrap_or(&fallback)
993 .to_string();
994 if !text.is_empty() {
995 add_note_message(&ctx.chat, &text);
996 }
997 ctx.tui.request_render(false);
998 }
999 Some("selector") => open_extension_selector(ctx, session, command_name, value),
1000 Some("editor") => open_extension_editor(ctx, session, command_name, value),
1001 Some("input") => open_extension_input(ctx, session, command_name, value),
1005 Some(other) => {
1006 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1007 ctx.tui.request_render(false);
1008 }
1009 }
1010}
1011
1012fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1016 let colors = current_theme().colors;
1017 let text = if bold {
1018 tui_bold(title)
1019 } else {
1020 title.to_string()
1021 };
1022 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1023}
1024
1025fn extension_dialog_hint(label: &str) -> Arc<Text> {
1026 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1027}
1028
1029fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1033 let callback = state.active_extension_cancel.lock().unwrap().take();
1034 if let Some(callback) = callback {
1035 callback();
1036 true
1037 } else {
1038 false
1039 }
1040}
1041
1042fn open_extension_input(
1043 ctx: &CommandContext,
1044 session: crate::session::ExtensionSessionCell,
1045 command_name: String,
1046 value: serde_json::Value,
1047) {
1048 let title = value
1049 .get("title")
1050 .and_then(|v| v.as_str())
1051 .filter(|title| !title.is_empty())
1052 .unwrap_or("Input");
1053 let input = value
1054 .get("placeholder")
1055 .and_then(|v| v.as_str())
1056 .map(Input::with_placeholder)
1057 .unwrap_or_default();
1058 let input = Arc::new(input);
1059 if let Some(initial) = value
1060 .get("initialText")
1061 .or_else(|| value.get("text"))
1062 .and_then(|v| v.as_str())
1063 {
1064 input.set_value(initial);
1065 }
1066 input.set_focused(true);
1067
1068 let frame = Arc::new(Container::new());
1069 frame.add_child(Arc::new(DynamicBorder::new()));
1070 frame.add_child(Arc::new(Spacer::new(1)));
1071 frame.add_child(extension_dialog_title(title, false));
1072 frame.add_child(Arc::new(Spacer::new(1)));
1073 frame.add_child(input.clone());
1074 frame.add_child(Arc::new(Spacer::new(1)));
1075 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1076 frame.add_child(Arc::new(Spacer::new(1)));
1077 frame.add_child(Arc::new(DynamicBorder::new()));
1078
1079 *ctx.state.active_extension_editor.lock().unwrap() = None;
1080 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1081 ctx.editor_container.clear();
1082 ctx.editor_container.add_child(frame);
1083
1084 let state = ctx.state.clone();
1085 let ec = ctx.editor_container.clone();
1086 let original = ctx.editor.clone();
1087 let tui = ctx.tui.clone();
1088 let session_submit = session.clone();
1089 let command_submit = command_name.clone();
1090 let ctx_submit = ctx.clone();
1091 input.on_submit(Arc::new(move |text| {
1092 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1093 let result = invoke_extension_command(
1094 &session_submit,
1095 &command_submit,
1096 &serde_json::to_string(&args).unwrap_or_default(),
1097 );
1098 close_extension_editor(&state, &ec, &original, &tui);
1099 handle_extension_ui_result(
1100 result,
1101 &ctx_submit,
1102 session_submit.clone(),
1103 command_submit.clone(),
1104 );
1105 }));
1106
1107 let state_cancel = ctx.state.clone();
1108 let ec_cancel = ctx.editor_container.clone();
1109 let original_cancel = ctx.editor.clone();
1110 let tui_cancel = ctx.tui.clone();
1111 let session_cancel = session.clone();
1112 let command_cancel = command_name.clone();
1113 let ctx_cancel = ctx.clone();
1114 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1115 let args = serde_json::json!({ "action": "cancel" });
1116 let result = invoke_extension_command(
1117 &session_cancel,
1118 &command_cancel,
1119 &serde_json::to_string(&args).unwrap_or_default(),
1120 );
1121 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1122 handle_extension_ui_result(
1123 result,
1124 &ctx_cancel,
1125 session_cancel.clone(),
1126 command_cancel.clone(),
1127 );
1128 }));
1129
1130 ctx.tui.set_focus(Some(input));
1131 ctx.tui.request_render(false);
1132}
1133
1134fn open_extension_selector(
1135 ctx: &CommandContext,
1136 session: crate::session::ExtensionSessionCell,
1137 command_name: String,
1138 value: serde_json::Value,
1139) {
1140 let items = value
1141 .get("items")
1142 .and_then(|v| v.as_array())
1143 .map(|items| {
1144 items
1145 .iter()
1146 .filter_map(|item| {
1147 let value = if let Some(value) = item.as_str() {
1150 value
1151 } else {
1152 item.get("value")?.as_str()?
1153 };
1154 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1155 let mut out = SelectItem::new(value, label);
1156 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1157 out = out.with_description(desc);
1158 }
1159 Some(out)
1160 })
1161 .collect::<Vec<_>>()
1162 })
1163 .unwrap_or_default();
1164 if items.is_empty() {
1165 add_error_message(&ctx.chat, "Extension selector has no items.");
1166 ctx.tui.request_render(false);
1167 return;
1168 }
1169 let list = Arc::new(SelectList::new(items, 10));
1170 let title = value
1171 .get("title")
1172 .and_then(|v| v.as_str())
1173 .filter(|title| !title.is_empty())
1174 .unwrap_or("Select");
1175 let frame = Arc::new(Container::new());
1176 frame.add_child(Arc::new(DynamicBorder::new()));
1177 frame.add_child(Arc::new(Spacer::new(1)));
1178 frame.add_child(extension_dialog_title(title, true));
1179 frame.add_child(Arc::new(Spacer::new(1)));
1180 frame.add_child(list.clone());
1181 frame.add_child(Arc::new(Spacer::new(1)));
1182 frame.add_child(extension_dialog_hint(
1183 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1184 ));
1185 frame.add_child(Arc::new(Spacer::new(1)));
1186 frame.add_child(Arc::new(DynamicBorder::new()));
1187
1188 let state = ctx.state.clone();
1189 let ec = ctx.editor_container.clone();
1190 let editor = ctx.editor.clone();
1191 let tui = ctx.tui.clone();
1192 let session_select = session.clone();
1193 let command_select = command_name.clone();
1194 let ctx_select = ctx.clone();
1195 list.on_select(Arc::new(move |item| {
1196 let args = serde_json::json!({ "action": "select", "value": item.value });
1197 let result = invoke_extension_command(
1198 &session_select,
1199 &command_select,
1200 &serde_json::to_string(&args).unwrap_or_default(),
1201 );
1202 close_selector(&state, &ec, &editor, &tui);
1203 handle_extension_ui_result(
1204 result,
1205 &ctx_select,
1206 session_select.clone(),
1207 command_select.clone(),
1208 );
1209 }));
1210 let state_cancel = ctx.state.clone();
1211 let ec_cancel = ctx.editor_container.clone();
1212 let editor_cancel = ctx.editor.clone();
1213 let tui_cancel = ctx.tui.clone();
1214 list.on_cancel(Arc::new(move || {
1215 if !run_extension_cancel(&state_cancel) {
1216 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1217 }
1218 }));
1219 let state_cancel = ctx.state.clone();
1220 let ec_cancel = ctx.editor_container.clone();
1221 let editor_cancel = ctx.editor.clone();
1222 let tui_cancel = ctx.tui.clone();
1223 let session_cancel = session.clone();
1224 let command_cancel = command_name.clone();
1225 let ctx_cancel = ctx.clone();
1226 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1227 let args = serde_json::json!({ "action": "cancel" });
1228 let result = invoke_extension_command(
1229 &session_cancel,
1230 &command_cancel,
1231 &serde_json::to_string(&args).unwrap_or_default(),
1232 );
1233 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1234 handle_extension_ui_result(
1235 result,
1236 &ctx_cancel,
1237 session_cancel.clone(),
1238 command_cancel.clone(),
1239 );
1240 }));
1241 open_selector_with_view(
1242 &ctx.state,
1243 &ctx.editor_container,
1244 &ctx.editor,
1245 &ctx.tui,
1246 list,
1247 frame,
1248 SelectorKind::Extension,
1249 );
1250}
1251
1252fn open_extension_editor(
1253 ctx: &CommandContext,
1254 session: crate::session::ExtensionSessionCell,
1255 command_name: String,
1256 value: serde_json::Value,
1257) {
1258 let initial = value
1259 .get("initialText")
1260 .or_else(|| value.get("text"))
1261 .and_then(|v| v.as_str())
1262 .unwrap_or_default()
1263 .to_string();
1264 let title = value
1265 .get("title")
1266 .and_then(|v| v.as_str())
1267 .filter(|title| !title.is_empty())
1268 .unwrap_or("Editor");
1269 let editor = Arc::new(Editor::new(
1270 EditorOptions {
1271 padding_x: 1,
1272 autocomplete_max_visible: 0,
1273 placeholder: value
1274 .get("placeholder")
1275 .and_then(|v| v.as_str())
1276 .map(str::to_string),
1277 initial_text: Some(initial),
1278 },
1279 EditorStyle {
1280 prompt: "> ".to_string(),
1281 placeholder: String::new(),
1282 },
1283 Arc::new(rpi_tui::Keybindings::new()),
1284 ));
1285 editor.set_focused(true);
1286 let frame = Arc::new(Container::new());
1287 frame.add_child(Arc::new(DynamicBorder::new()));
1288 frame.add_child(Arc::new(Spacer::new(1)));
1289 frame.add_child(extension_dialog_title(title, false));
1290 frame.add_child(Arc::new(Spacer::new(1)));
1291 frame.add_child(editor.clone());
1292 frame.add_child(Arc::new(Spacer::new(1)));
1293 frame.add_child(extension_dialog_hint(
1294 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1295 ));
1296 frame.add_child(Arc::new(Spacer::new(1)));
1297 frame.add_child(Arc::new(DynamicBorder::new()));
1298
1299 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1300 *ctx.state.active_extension_input.lock().unwrap() = None;
1301 ctx.editor_container.clear();
1302 ctx.editor_container.add_child(frame);
1303
1304 let state = ctx.state.clone();
1305 let ec = ctx.editor_container.clone();
1306 let original = ctx.editor.clone();
1307 let tui = ctx.tui.clone();
1308 let session_submit = session.clone();
1309 let command_submit = command_name.clone();
1310 let ctx_submit = ctx.clone();
1311 editor.on_submit(Arc::new(move |text| {
1312 let args = serde_json::json!({ "action": "edit", "text": text });
1313 let result = invoke_extension_command(
1314 &session_submit,
1315 &command_submit,
1316 &serde_json::to_string(&args).unwrap_or_default(),
1317 );
1318 close_extension_editor(&state, &ec, &original, &tui);
1319 handle_extension_ui_result(
1320 result,
1321 &ctx_submit,
1322 session_submit.clone(),
1323 command_submit.clone(),
1324 );
1325 }));
1326
1327 let state_cancel = ctx.state.clone();
1328 let ec_cancel = ctx.editor_container.clone();
1329 let original_cancel = ctx.editor.clone();
1330 let tui_cancel = ctx.tui.clone();
1331 let session_cancel = session.clone();
1332 let command_cancel = command_name.clone();
1333 let ctx_cancel = ctx.clone();
1334 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1335 let args = serde_json::json!({ "action": "cancel" });
1336 let result = invoke_extension_command(
1337 &session_cancel,
1338 &command_cancel,
1339 &serde_json::to_string(&args).unwrap_or_default(),
1340 );
1341 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1342 handle_extension_ui_result(
1343 result,
1344 &ctx_cancel,
1345 session_cancel.clone(),
1346 command_cancel.clone(),
1347 );
1348 }));
1349
1350 ctx.tui.set_focus(Some(editor));
1351 ctx.tui.request_render(false);
1352}
1353
1354fn close_extension_editor(
1355 state: &Arc<TuiState>,
1356 editor_container: &Arc<Container>,
1357 editor: &Arc<Editor>,
1358 tui: &Arc<TuiAltScreen>,
1359) {
1360 editor_container.clear();
1361 editor_container.add_child(editor.clone());
1362 state.autocomplete_container.clear();
1363 *state.active_extension_editor.lock().unwrap() = None;
1364 *state.active_extension_input.lock().unwrap() = None;
1365 *state.active_extension_cancel.lock().unwrap() = None;
1366 editor.set_focused(true);
1367 tui.set_focus(Some(editor.clone()));
1368 tui.request_render(false);
1369}
1370
1371fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1375 match request.method.as_str() {
1376 "select" => open_js_selector(ctx, bridge, request, false),
1377 "confirm" => open_js_selector(ctx, bridge, request, true),
1378 "input" => open_js_input(ctx, bridge, request),
1379 "editor" => open_js_editor(ctx, bridge, request),
1380 _ => {
1381 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1382 }
1383 }
1384}
1385
1386fn open_js_selector(
1387 ctx: &CommandContext,
1388 bridge: Arc<JsDialogBridge>,
1389 request: JsDialogRequest,
1390 confirm: bool,
1391) {
1392 let values = if confirm {
1393 vec!["Yes".to_string(), "No".to_string()]
1394 } else {
1395 request.options.clone()
1396 };
1397 if values.is_empty() {
1398 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1399 return;
1400 }
1401 let items = values
1402 .iter()
1403 .map(|value| SelectItem::new(value, value))
1404 .collect::<Vec<_>>();
1405 let list = Arc::new(SelectList::new(items, 10));
1406 let frame = Arc::new(Container::new());
1407 frame.add_child(Arc::new(DynamicBorder::new()));
1408 frame.add_child(Arc::new(Spacer::new(1)));
1409 frame.add_child(extension_dialog_title(
1410 if request.title.is_empty() {
1411 if confirm {
1412 "Confirm"
1413 } else {
1414 "Select"
1415 }
1416 } else {
1417 request.title.as_str()
1418 },
1419 true,
1420 ));
1421 if !request.message.is_empty() {
1422 frame.add_child(Arc::new(Spacer::new(1)));
1423 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1424 }
1425 frame.add_child(Arc::new(Spacer::new(1)));
1426 frame.add_child(list.clone());
1427 frame.add_child(Arc::new(Spacer::new(1)));
1428 frame.add_child(extension_dialog_hint(
1429 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1430 ));
1431 frame.add_child(Arc::new(Spacer::new(1)));
1432 frame.add_child(Arc::new(DynamicBorder::new()));
1433
1434 let id = request.id.clone();
1435 let bridge_select = bridge.clone();
1436 let state_select = ctx.state.clone();
1437 let ec_select = ctx.editor_container.clone();
1438 let editor_select = ctx.editor.clone();
1439 let tui_select = ctx.tui.clone();
1440 list.on_select(Arc::new(move |item| {
1441 let result = if confirm {
1442 serde_json::json!({ "confirmed": item.value == "Yes" })
1443 } else {
1444 serde_json::json!({ "value": item.value })
1445 };
1446 bridge_select.respond(&id, result);
1447 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1448 }));
1449
1450 let id_cancel = request.id.clone();
1451 let bridge_cancel = bridge.clone();
1452 let state_cancel = ctx.state.clone();
1453 let ec_cancel = ctx.editor_container.clone();
1454 let editor_cancel = ctx.editor.clone();
1455 let tui_cancel = ctx.tui.clone();
1456 list.on_cancel(Arc::new(move || {
1457 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1458 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1459 }));
1460
1461 let id_abort = request.id.clone();
1462 let bridge_abort = bridge.clone();
1463 let state_abort = ctx.state.clone();
1464 let ec_abort = ctx.editor_container.clone();
1465 let editor_abort = ctx.editor.clone();
1466 let tui_abort = ctx.tui.clone();
1467 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1468 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1469 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1470 }));
1471
1472 open_selector_with_view(
1473 &ctx.state,
1474 &ctx.editor_container,
1475 &ctx.editor,
1476 &ctx.tui,
1477 list,
1478 frame,
1479 SelectorKind::Extension,
1480 );
1481}
1482
1483fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1484 let input = request
1485 .placeholder
1486 .as_deref()
1487 .map(Input::with_placeholder)
1488 .unwrap_or_default();
1489 let input = Arc::new(input);
1490 input.set_focused(true);
1491
1492 let frame = Arc::new(Container::new());
1493 frame.add_child(Arc::new(DynamicBorder::new()));
1494 frame.add_child(Arc::new(Spacer::new(1)));
1495 frame.add_child(extension_dialog_title(
1496 if request.title.is_empty() {
1497 "Input"
1498 } else {
1499 &request.title
1500 },
1501 false,
1502 ));
1503 frame.add_child(Arc::new(Spacer::new(1)));
1504 frame.add_child(input.clone());
1505 frame.add_child(Arc::new(Spacer::new(1)));
1506 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1507 frame.add_child(Arc::new(Spacer::new(1)));
1508 frame.add_child(Arc::new(DynamicBorder::new()));
1509
1510 *ctx.state.active_extension_editor.lock().unwrap() = None;
1511 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1512 ctx.editor_container.clear();
1513 ctx.editor_container.add_child(frame);
1514
1515 let id = request.id.clone();
1516 let bridge_submit = bridge.clone();
1517 let state_submit = ctx.state.clone();
1518 let ec_submit = ctx.editor_container.clone();
1519 let editor_submit = ctx.editor.clone();
1520 let tui_submit = ctx.tui.clone();
1521 input.on_submit(Arc::new(move |value| {
1522 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1523 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1524 }));
1525
1526 let id_cancel = request.id.clone();
1527 let bridge_cancel = bridge.clone();
1528 let state_cancel = ctx.state.clone();
1529 let ec_cancel = ctx.editor_container.clone();
1530 let editor_cancel = ctx.editor.clone();
1531 let tui_cancel = ctx.tui.clone();
1532 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1533 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1534 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1535 }));
1536 ctx.tui.set_focus(Some(input));
1537 ctx.tui.request_render(false);
1538}
1539
1540fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1541 let editor = Arc::new(Editor::new(
1542 EditorOptions {
1543 padding_x: 1,
1544 autocomplete_max_visible: 0,
1545 initial_text: request.prefill.clone(),
1546 ..Default::default()
1547 },
1548 EditorStyle {
1549 prompt: "> ".to_string(),
1550 placeholder: String::new(),
1551 },
1552 Arc::new(rpi_tui::Keybindings::new()),
1553 ));
1554 editor.set_focused(true);
1555
1556 let frame = Arc::new(Container::new());
1557 frame.add_child(Arc::new(DynamicBorder::new()));
1558 frame.add_child(Arc::new(Spacer::new(1)));
1559 frame.add_child(extension_dialog_title(
1560 if request.title.is_empty() {
1561 "Editor"
1562 } else {
1563 &request.title
1564 },
1565 false,
1566 ));
1567 frame.add_child(Arc::new(Spacer::new(1)));
1568 frame.add_child(editor.clone());
1569 frame.add_child(Arc::new(Spacer::new(1)));
1570 frame.add_child(extension_dialog_hint(
1571 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1572 ));
1573 frame.add_child(Arc::new(Spacer::new(1)));
1574 frame.add_child(Arc::new(DynamicBorder::new()));
1575
1576 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1577 *ctx.state.active_extension_input.lock().unwrap() = None;
1578 ctx.editor_container.clear();
1579 ctx.editor_container.add_child(frame);
1580
1581 let id = request.id.clone();
1582 let bridge_submit = bridge.clone();
1583 let state_submit = ctx.state.clone();
1584 let ec_submit = ctx.editor_container.clone();
1585 let editor_submit = ctx.editor.clone();
1586 let tui_submit = ctx.tui.clone();
1587 editor.on_submit(Arc::new(move |value| {
1588 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1589 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1590 }));
1591
1592 let id_cancel = request.id.clone();
1593 let bridge_cancel = bridge.clone();
1594 let state_cancel = ctx.state.clone();
1595 let ec_cancel = ctx.editor_container.clone();
1596 let editor_cancel = ctx.editor.clone();
1597 let tui_cancel = ctx.tui.clone();
1598 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1599 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1600 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1601 }));
1602 ctx.tui.set_focus(Some(editor));
1603 ctx.tui.request_render(false);
1604}
1605
1606fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1607 for id in bridge.cancelled_active_ids() {
1608 if !bridge.is_visible(&id) {
1612 bridge.finish(&id);
1613 continue;
1614 }
1615 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1616 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1617 } else if ctx.state.extension_dialog_open() {
1618 if !run_extension_cancel(&ctx.state) {
1619 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1620 }
1621 }
1622 bridge.finish(&id);
1626 }
1627}
1628
1629struct HelpCommand;
1632impl SlashCommand for HelpCommand {
1633 fn name(&self) -> &'static str {
1634 "/help"
1635 }
1636 fn aliases(&self) -> &'static [&'static str] {
1637 &["/?"]
1638 }
1639 fn description(&self) -> &'static str {
1640 "Show available commands"
1641 }
1642 fn execute(&self, ctx: &CommandContext, _args: &str) {
1643 add_help_message(&ctx.chat);
1644 ctx.tui.request_render(false);
1645 }
1646}
1647
1648struct ClearChatCommand;
1649impl SlashCommand for ClearChatCommand {
1650 fn name(&self) -> &'static str {
1651 "/clear"
1652 }
1653 fn aliases(&self) -> &'static [&'static str] {
1654 &["/new"]
1655 }
1656 fn alias_visible(&self) -> &'static [&'static str] {
1658 &["/new"]
1659 }
1660 fn description(&self) -> &'static str {
1661 "Clear the conversation"
1662 }
1663 fn execute(&self, ctx: &CommandContext, _args: &str) {
1664 let _ = ctx.tx.send(TuiMessage::ClearChat);
1665 }
1666}
1667
1668struct ExitCommand;
1669impl SlashCommand for ExitCommand {
1670 fn name(&self) -> &'static str {
1671 "/exit"
1672 }
1673 fn aliases(&self) -> &'static [&'static str] {
1674 &["/quit", "/q"]
1675 }
1676 fn alias_visible(&self) -> &'static [&'static str] {
1678 &["/quit"]
1679 }
1680 fn description(&self) -> &'static str {
1681 "Exit the application"
1682 }
1683 fn execute(&self, ctx: &CommandContext, _args: &str) {
1684 ctx.state.cancel_js_preparation();
1685 let _ = ctx.tx.send(TuiMessage::Exit);
1686 }
1687}
1688
1689struct VersionCommand;
1690impl SlashCommand for VersionCommand {
1691 fn name(&self) -> &'static str {
1692 "/version"
1693 }
1694 fn aliases(&self) -> &'static [&'static str] {
1695 &["/v"]
1696 }
1697 fn description(&self) -> &'static str {
1698 "Show version information"
1699 }
1700 fn execute(&self, ctx: &CommandContext, _args: &str) {
1701 add_version_message(&ctx.chat);
1702 ctx.tui.request_render(false);
1703 }
1704}
1705
1706struct ChangelogCommand;
1707impl SlashCommand for ChangelogCommand {
1708 fn name(&self) -> &'static str {
1709 "/changelog"
1710 }
1711 fn description(&self) -> &'static str {
1712 "Show recent release changes"
1713 }
1714 fn execute(&self, ctx: &CommandContext, _args: &str) {
1715 add_changelog_message(&ctx.chat);
1716 ctx.tui.request_render(false);
1717 }
1718}
1719
1720struct HotkeysCommand;
1721impl SlashCommand for HotkeysCommand {
1722 fn name(&self) -> &'static str {
1723 "/hotkeys"
1724 }
1725 fn description(&self) -> &'static str {
1726 "Show keyboard shortcuts"
1727 }
1728 fn execute(&self, ctx: &CommandContext, _args: &str) {
1729 add_hotkeys_message(&ctx.chat);
1730 ctx.tui.request_render(false);
1731 }
1732}
1733
1734struct ModelCommand;
1735impl SlashCommand for ModelCommand {
1736 fn name(&self) -> &'static str {
1737 "/model"
1738 }
1739 fn aliases(&self) -> &'static [&'static str] {
1740 &["/m"]
1741 }
1742 fn description(&self) -> &'static str {
1743 "Choose a model (selector)"
1744 }
1745 fn execute(&self, ctx: &CommandContext, args: &str) {
1746 let term = args.trim();
1747 if !term.is_empty() {
1748 let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
1750 add_error_message(
1751 &ctx.chat,
1752 &format!("No model matches \"{term}\". Try /model for the list."),
1753 );
1754 ctx.tui.request_render(false);
1755 return;
1756 };
1757 let model_id = model.id.clone();
1758 ctx.state.set_current_model(&model);
1759 let lane = ctx.lane.clone();
1760 tokio::spawn(async move {
1761 let _ = lane.set_model(model).await;
1762 });
1763 add_note_message(
1764 &ctx.chat,
1765 &format!(
1766 "Model set to {} — applies to the next message.",
1767 short_model_name(&model_id)
1768 ),
1769 );
1770 ctx.tui.request_render(false);
1771 return;
1772 }
1773 open_model_selector(
1774 &ctx.state,
1775 &ctx.editor_container,
1776 &ctx.editor,
1777 &ctx.tui,
1778 &ctx.model_catalog,
1779 &ctx.lane,
1780 &ctx.lane_model_id,
1781 &ctx.chat,
1782 );
1783 }
1784}
1785
1786struct ThinkingCommand;
1787impl SlashCommand for ThinkingCommand {
1788 fn name(&self) -> &'static str {
1789 "/thinking"
1790 }
1791 fn aliases(&self) -> &'static [&'static str] {
1792 &["/think"]
1793 }
1794 fn description(&self) -> &'static str {
1795 "Set thinking level (selector)"
1796 }
1797 fn execute(&self, ctx: &CommandContext, args: &str) {
1798 let level_name = args.trim();
1799 if !level_name.is_empty() {
1800 let Some(level) = thinking_level_from_name(level_name) else {
1802 add_error_message(
1803 &ctx.chat,
1804 &format!(
1805 "Unknown thinking level \"{level_name}\". Valid: {}",
1806 crate::args::VALID_THINKING_LEVELS.join(", ")
1807 ),
1808 );
1809 ctx.tui.request_render(false);
1810 return;
1811 };
1812 let lane = ctx.lane.clone();
1813 let footer = ctx.state.footer.clone();
1814 tokio::spawn(async move {
1815 let _ = lane.set_thinking_level(level).await;
1816 });
1817 footer.set_thinking_level(Some(thinking_level_name(level)));
1818 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
1819 ctx.tui.request_render(false);
1820 return;
1821 }
1822 open_thinking_selector(
1823 &ctx.state,
1824 &ctx.editor_container,
1825 &ctx.editor,
1826 &ctx.tui,
1827 &ctx.lane,
1828 &ctx.model_catalog,
1829 &ctx.lane_model_id,
1830 &ctx.chat,
1831 );
1832 }
1833}
1834
1835struct ToolsCommand;
1836impl SlashCommand for ToolsCommand {
1837 fn name(&self) -> &'static str {
1838 "/tools"
1839 }
1840 fn description(&self) -> &'static str {
1841 "Toggle tools on/off"
1842 }
1843 fn execute(&self, ctx: &CommandContext, _args: &str) {
1844 open_tools_selector(
1845 &ctx.state,
1846 &ctx.editor_container,
1847 &ctx.editor,
1848 &ctx.tui,
1849 &ctx.lane,
1850 &ctx.chat,
1851 );
1852 }
1853}
1854
1855struct ImagesCommand;
1856impl SlashCommand for ImagesCommand {
1857 fn name(&self) -> &'static str {
1858 "/images"
1859 }
1860 fn description(&self) -> &'static str {
1861 "Toggle inline images"
1862 }
1863 fn execute(&self, ctx: &CommandContext, _args: &str) {
1864 open_images_selector(
1865 &ctx.state,
1866 &ctx.editor_container,
1867 &ctx.editor,
1868 &ctx.tui,
1869 &ctx.chat,
1870 );
1871 }
1872}
1873
1874struct SessionCommand;
1875impl SlashCommand for SessionCommand {
1876 fn name(&self) -> &'static str {
1877 "/session"
1878 }
1879 fn aliases(&self) -> &'static [&'static str] {
1880 &["/resume"]
1881 }
1882 fn description(&self) -> &'static str {
1883 "List saved sessions"
1884 }
1885 fn execute(&self, ctx: &CommandContext, _args: &str) {
1886 open_session_selector(
1887 &ctx.state,
1888 &ctx.editor_container,
1889 &ctx.editor,
1890 &ctx.tui,
1891 &ctx.cwd,
1892 &ctx.tx,
1893 );
1894 }
1895}
1896
1897struct ThemeCommand;
1898impl SlashCommand for ThemeCommand {
1899 fn name(&self) -> &'static str {
1900 "/theme"
1901 }
1902 fn description(&self) -> &'static str {
1903 "Choose a theme (selector)"
1904 }
1905 fn execute(&self, ctx: &CommandContext, args: &str) {
1906 let name = args.trim().to_ascii_lowercase();
1907 if !name.is_empty() {
1908 let preset = match name.as_str() {
1910 "light" => ThemePreset::Light,
1911 "monochrome" => ThemePreset::Monochrome,
1912 "dark" => ThemePreset::Dark,
1913 _ => {
1914 add_error_message(
1915 &ctx.chat,
1916 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
1917 );
1918 ctx.tui.request_render(false);
1919 return;
1920 }
1921 };
1922 apply_theme_preset(preset);
1923 let mut settings = crate::settings::load_settings().unwrap_or_default();
1924 settings.theme = Some(name.clone());
1925 let _ = crate::settings::save_settings(&settings);
1926 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
1927 ctx.tui.request_render(false);
1928 ctx.tui.render_now(true);
1929 return;
1930 }
1931 open_theme_selector(
1932 &ctx.state,
1933 &ctx.editor_container,
1934 &ctx.editor,
1935 &ctx.tui,
1936 &ctx.cwd,
1937 &ctx.package_resources,
1938 );
1939 }
1940}
1941
1942struct CompactCommand;
1943impl SlashCommand for CompactCommand {
1944 fn name(&self) -> &'static str {
1945 "/compact"
1946 }
1947 fn description(&self) -> &'static str {
1948 "Compact the conversation"
1949 }
1950 fn execute(&self, ctx: &CommandContext, _args: &str) {
1951 let _ = ctx.tx.send(TuiMessage::Compact);
1952 }
1953}
1954
1955struct CopyCommand;
1956impl SlashCommand for CopyCommand {
1957 fn name(&self) -> &'static str {
1958 "/copy"
1959 }
1960 fn description(&self) -> &'static str {
1961 "Copy last reply to clipboard"
1962 }
1963 fn execute(&self, ctx: &CommandContext, _args: &str) {
1964 let _ = ctx.tx.send(TuiMessage::Copy);
1965 }
1966}
1967
1968struct ExportCommand;
1969impl SlashCommand for ExportCommand {
1970 fn name(&self) -> &'static str {
1971 "/export"
1972 }
1973 fn description(&self) -> &'static str {
1974 "Export session to a markdown file"
1975 }
1976 fn execute(&self, ctx: &CommandContext, _args: &str) {
1977 let _ = ctx.tx.send(TuiMessage::ExportSession);
1978 }
1979}
1980
1981struct ForkCommand;
1982impl SlashCommand for ForkCommand {
1983 fn name(&self) -> &'static str {
1984 "/fork"
1985 }
1986 fn description(&self) -> &'static str {
1987 "Fork the session into a new one"
1988 }
1989 fn execute(&self, ctx: &CommandContext, _args: &str) {
1990 let _ = ctx.tx.send(TuiMessage::ForkSession);
1991 }
1992}
1993
1994struct CloneCommand;
1998impl SlashCommand for CloneCommand {
1999 fn name(&self) -> &'static str {
2000 "/clone"
2001 }
2002 fn description(&self) -> &'static str {
2003 "Duplicate the current session"
2004 }
2005 fn execute(&self, ctx: &CommandContext, _args: &str) {
2006 let _ = ctx.tx.send(TuiMessage::ForkSession);
2007 }
2008}
2009
2010struct TreeCommand;
2011impl SlashCommand for TreeCommand {
2012 fn name(&self) -> &'static str {
2013 "/tree"
2014 }
2015 fn description(&self) -> &'static str {
2016 "Navigate the current session tree"
2017 }
2018 fn execute(&self, ctx: &CommandContext, _args: &str) {
2019 let _ = ctx.tx.send(TuiMessage::OpenTree);
2020 }
2021}
2022
2023struct LoginCommand;
2024impl SlashCommand for LoginCommand {
2025 fn name(&self) -> &'static str {
2026 "/login"
2027 }
2028 fn description(&self) -> &'static str {
2029 "Save an Anthropic API key"
2030 }
2031 fn execute(&self, ctx: &CommandContext, args: &str) {
2032 let key = args.trim();
2033 if key.is_empty() {
2034 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2035 } else {
2036 let result = crate::config::upsert_credential(
2037 "anthropic",
2038 crate::config::Credential::ApiKey {
2039 key: Some(key.to_string()),
2040 env: None,
2041 },
2042 );
2043 match result {
2044 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2045 Err(error) => {
2046 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2047 }
2048 }
2049 }
2050 ctx.tui.request_render(false);
2051 }
2052}
2053
2054struct LogoutCommand;
2055impl SlashCommand for LogoutCommand {
2056 fn name(&self) -> &'static str {
2057 "/logout"
2058 }
2059 fn description(&self) -> &'static str {
2060 "Remove saved Anthropic credentials"
2061 }
2062 fn execute(&self, ctx: &CommandContext, _args: &str) {
2063 match crate::config::delete_credential("anthropic") {
2064 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2065 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2066 Err(error) => {
2067 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2068 }
2069 }
2070 ctx.tui.request_render(false);
2071 }
2072}
2073
2074fn set_project_trust_for_command(
2075 cwd: &std::path::Path,
2076 value: Option<bool>,
2077) -> Result<(), crate::config::ConfigError> {
2078 crate::config::set_project_trust(cwd, value)
2079}
2080
2081struct TrustCommand;
2082impl SlashCommand for TrustCommand {
2083 fn name(&self) -> &'static str {
2084 "/trust"
2085 }
2086 fn description(&self) -> &'static str {
2087 "Trust the current project"
2088 }
2089 fn execute(&self, ctx: &CommandContext, args: &str) {
2090 let value = match args.trim().to_ascii_lowercase().as_str() {
2091 "" | "yes" | "y" | "true" => Some(true),
2092 "no" | "n" | "false" => Some(false),
2093 "clear" | "reset" | "none" => None,
2094 _ => {
2095 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2096 ctx.tui.request_render(false);
2097 return;
2098 }
2099 };
2100 match set_project_trust_for_command(&ctx.cwd, value) {
2101 Ok(()) => {
2102 let label = match value {
2103 Some(true) => "trusted",
2104 Some(false) => "untrusted",
2105 None => "trust decision cleared",
2106 };
2107 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2108 }
2109 Err(error) => add_error_message(
2110 &ctx.chat,
2111 &format!("Could not save trust decision: {error}"),
2112 ),
2113 }
2114 ctx.tui.request_render(false);
2115 }
2116}
2117
2118struct NameCommand;
2119impl SlashCommand for NameCommand {
2120 fn name(&self) -> &'static str {
2121 "/name"
2122 }
2123 fn description(&self) -> &'static str {
2124 "Set session display name"
2125 }
2126 fn execute(&self, ctx: &CommandContext, args: &str) {
2127 let name = args.trim();
2128 if name.is_empty() {
2129 add_note_message(
2130 &ctx.chat,
2131 "Usage: /name <display name> — sets the current session's name.",
2132 );
2133 ctx.tui.request_render(false);
2134 return;
2135 }
2136 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2137 }
2138}
2139
2140struct ImportCommand;
2141impl SlashCommand for ImportCommand {
2142 fn name(&self) -> &'static str {
2143 "/import"
2144 }
2145 fn description(&self) -> &'static str {
2146 "Import a session file (path)"
2147 }
2148 fn execute(&self, ctx: &CommandContext, args: &str) {
2149 let path = args.trim();
2150 if path.is_empty() {
2151 add_note_message(
2152 &ctx.chat,
2153 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2154 );
2155 ctx.tui.request_render(false);
2156 return;
2157 }
2158 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2159 }
2160}
2161
2162struct SettingsCommand;
2163impl SlashCommand for SettingsCommand {
2164 fn name(&self) -> &'static str {
2165 "/settings"
2166 }
2167 fn description(&self) -> &'static str {
2168 "Open settings menu"
2169 }
2170 fn execute(&self, ctx: &CommandContext, _args: &str) {
2171 open_settings_selector(
2172 &ctx.state,
2173 &ctx.editor_container,
2174 &ctx.editor,
2175 &ctx.tui,
2176 &ctx.lane,
2177 &ctx.model_catalog,
2178 &ctx.lane_model_id,
2179 &ctx.chat,
2180 &ctx.cwd,
2181 &ctx.package_resources,
2182 );
2183 }
2184}
2185
2186struct ScopedModelsCommand;
2187impl SlashCommand for ScopedModelsCommand {
2188 fn name(&self) -> &'static str {
2189 "/scoped-models"
2190 }
2191 fn description(&self) -> &'static str {
2192 "Choose models for Ctrl+M cycling"
2193 }
2194 fn execute(&self, ctx: &CommandContext, _args: &str) {
2195 open_scoped_models_selector(
2196 &ctx.state,
2197 &ctx.editor_container,
2198 &ctx.editor,
2199 &ctx.tui,
2200 &ctx.model_catalog,
2201 &ctx.chat,
2202 );
2203 }
2204}
2205
2206struct ShareCommand;
2207impl SlashCommand for ShareCommand {
2208 fn name(&self) -> &'static str {
2209 "/share"
2210 }
2211 fn description(&self) -> &'static str {
2212 "Share session (gist via gh, or clipboard)"
2213 }
2214 fn execute(&self, ctx: &CommandContext, _args: &str) {
2215 let _ = ctx.tx.send(TuiMessage::ShareSession);
2216 }
2217}
2218
2219struct ArminCommand;
2220impl SlashCommand for ArminCommand {
2221 fn name(&self) -> &'static str {
2222 "/armin"
2223 }
2224 fn description(&self) -> &'static str {
2225 "??? (easter egg)"
2226 }
2227 fn execute(&self, ctx: &CommandContext, _args: &str) {
2228 crate::extras::add_armin(&ctx.chat);
2229 ctx.tui.request_render(false);
2230 }
2231}
2232
2233struct EarendilCommand;
2234impl SlashCommand for EarendilCommand {
2235 fn name(&self) -> &'static str {
2236 "/earendil"
2237 }
2238 fn description(&self) -> &'static str {
2239 "Announcement"
2240 }
2241 fn execute(&self, ctx: &CommandContext, _args: &str) {
2242 crate::extras::add_earendil(&ctx.chat);
2243 ctx.tui.request_render(false);
2244 }
2245}
2246
2247struct ContextCommand;
2251impl SlashCommand for ContextCommand {
2252 fn name(&self) -> &'static str {
2253 "/context"
2254 }
2255 fn visible(&self) -> bool {
2256 false
2257 }
2258 fn execute(&self, ctx: &CommandContext, _args: &str) {
2259 show_context_panel(&ctx.chat, &ctx.resources);
2260 ctx.tui.request_render(false);
2261 }
2262}
2263
2264struct ReloadCommand;
2275impl SlashCommand for ReloadCommand {
2276 fn name(&self) -> &'static str {
2277 "/reload"
2278 }
2279 fn description(&self) -> &'static str {
2280 "Reload extensions, skills, prompts"
2281 }
2282 fn execute(&self, ctx: &CommandContext, _args: &str) {
2283 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2287 ctx.tui.request_render(false);
2288 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2289 }
2290}
2291
2292fn build_builtin_registry() -> CommandRegistry {
2297 let mut r = CommandRegistry::new();
2298 r.register(Arc::new(HelpCommand));
2299 r.register(Arc::new(ClearChatCommand));
2300 r.register(Arc::new(ExitCommand));
2301 r.register(Arc::new(VersionCommand));
2302 r.register(Arc::new(ChangelogCommand));
2303 r.register(Arc::new(ModelCommand));
2304 r.register(Arc::new(ThinkingCommand));
2305 r.register(Arc::new(ToolsCommand));
2306 r.register(Arc::new(ImagesCommand));
2307 r.register(Arc::new(SessionCommand));
2308 r.register(Arc::new(ThemeCommand));
2309 r.register(Arc::new(CompactCommand));
2310 r.register(Arc::new(CopyCommand));
2311 r.register(Arc::new(HotkeysCommand));
2312 r.register(Arc::new(ArminCommand));
2313 r.register(Arc::new(EarendilCommand));
2314 r.register(Arc::new(ContextCommand));
2315 r.register(Arc::new(NameCommand));
2319 r.register(Arc::new(SettingsCommand));
2320 r.register(Arc::new(ScopedModelsCommand));
2321 r.register(Arc::new(ExportCommand));
2322 r.register(Arc::new(ImportCommand));
2323 r.register(Arc::new(ShareCommand));
2324 r.register(Arc::new(ForkCommand));
2325 r.register(Arc::new(CloneCommand));
2326 r.register(Arc::new(TreeCommand));
2327 r.register(Arc::new(TrustCommand));
2328 r.register(Arc::new(LoginCommand));
2329 r.register(Arc::new(LogoutCommand));
2330 r.register(Arc::new(ReloadCommand));
2331 r
2332}
2333
2334fn register_extension_commands(
2335 registry: &mut CommandRegistry,
2336 session: crate::session::ExtensionSessionCell,
2337) {
2338 let commands = session
2339 .lock()
2340 .ok()
2341 .and_then(|s| s.snapshot_arc())
2342 .map(|snap| snap.commands().to_vec())
2343 .unwrap_or_default();
2344 for command in commands {
2345 let name = if command.name.starts_with('/') {
2346 command.name.clone()
2347 } else {
2348 format!("/{}", command.name)
2349 };
2350 if registry.find(&name).is_some() {
2351 continue;
2352 }
2353 registry.register(Arc::new(ExtensionCommand {
2354 name,
2355 description: command.description,
2356 session: session.clone(),
2357 }));
2358 }
2359}
2360
2361fn register_js_extension_commands(
2362 registry: &mut CommandRegistry,
2363 session: Option<crate::js_extensions::JsExtensionSession>,
2364) {
2365 let Some(session) = session else {
2366 return;
2367 };
2368 for command in &session.commands {
2369 let name = if command.starts_with('/') {
2370 command.clone()
2371 } else {
2372 format!("/{command}")
2373 };
2374 if registry.find(&name).is_none() {
2375 registry.register(Arc::new(JsExtensionCommand {
2376 name,
2377 session: session.clone(),
2378 }));
2379 }
2380 }
2381}
2382
2383enum TuiMessage {
2390 UserInput(String),
2391 OpenTree,
2392 NavigateTree(String),
2393 Exit,
2394 ClearChat,
2396 Compact,
2398 Copy,
2400 SwitchSession(String),
2403 ExportSession,
2405 ForkSession,
2407 SetSessionName(String),
2409 ImportSession(String),
2412 ShareSession,
2415 ReloadExtensions,
2420 ExternalEditorResult(Result<String, String>),
2424}
2425
2426fn assistant_text(msg: &AssistantMessage) -> String {
2429 msg.content
2430 .iter()
2431 .filter_map(|c| match c {
2432 Content::Text(t) => Some(t.text.clone()),
2433 _ => None,
2434 })
2435 .collect()
2436}
2437
2438fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2441 match &msg.content {
2442 rpi_ai::types::UserContent::Text(s) => s.clone(),
2443 rpi_ai::types::UserContent::Blocks(blocks) => blocks
2444 .iter()
2445 .filter_map(|c| match c {
2446 Content::Text(t) => Some(t.text.clone()),
2447 _ => None,
2448 })
2449 .collect(),
2450 }
2451}
2452
2453fn show_settings_panel(chat: &Arc<Container>) {
2458 let s = crate::settings::load_settings().unwrap_or_default();
2459 let mut lines: Vec<String> = Vec::new();
2460 lines.push("⚙️ Saved settings:".into());
2461 lines.push(format!(
2462 " Theme: {} (edit with /theme)",
2463 s.theme.as_deref().unwrap_or("(default)")
2464 ));
2465 lines.push(format!(
2466 " Default model: {} (set at launch with --model)",
2467 s.default_model.as_deref().unwrap_or("(none)")
2468 ));
2469 lines.push(format!(
2470 " Default thinking: {} (set at launch with --thinking)",
2471 s.default_thinking_level.as_deref().unwrap_or("(default)")
2472 ));
2473 match &s.scoped_models {
2474 Some(list) if !list.is_empty() => lines.push(format!(
2475 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
2476 list.join(", ")
2477 )),
2478 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
2479 }
2480 let body = lines.join("\n");
2481 container_note_block(chat, &body);
2482}
2483
2484fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2488 let scoped = crate::settings::load_settings()
2489 .ok()
2490 .and_then(|s| s.scoped_models)
2491 .unwrap_or_default();
2492 if scoped.is_empty() {
2493 return catalog.to_vec();
2494 }
2495 let mut out: Vec<rpi_ai::Model> = catalog
2496 .iter()
2497 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2498 .cloned()
2499 .collect();
2500 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2502 if let Some(cur) = catalog
2503 .iter()
2504 .find(|m| m.id.eq_ignore_ascii_case(current_id))
2505 {
2506 out.push(cur.clone());
2507 }
2508 }
2509 out
2510}
2511
2512fn open_settings_selector(
2519 state: &Arc<TuiState>,
2520 editor_container: &Arc<Container>,
2521 editor: &Arc<Editor>,
2522 tui: &Arc<TuiAltScreen>,
2523 lane: &Arc<dyn AgentLane>,
2524 catalog: &[rpi_ai::Model],
2525 lane_model_id: &str,
2526 chat: &Arc<Container>,
2527 cwd: &std::path::Path,
2528 package_resources: &Arc<crate::packages::PackageResources>,
2529) {
2530 let settings = crate::settings::load_settings().unwrap_or_default();
2531 let mut items: Vec<SelectItem> = Vec::new();
2532 items.push(
2533 SelectItem::new("theme", "Theme")
2534 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
2535 );
2536 items.push(
2537 SelectItem::new("model", "Default model").with_description(
2538 &settings
2539 .default_model
2540 .clone()
2541 .unwrap_or_else(|| "(none)".into()),
2542 ),
2543 );
2544 items.push(
2545 SelectItem::new("thinking", "Default thinking").with_description(
2546 &settings
2547 .default_thinking_level
2548 .clone()
2549 .unwrap_or_else(|| "(default)".into()),
2550 ),
2551 );
2552 let scope_desc = match &settings.scoped_models {
2553 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
2554 _ => "all models".to_string(),
2555 };
2556 items
2557 .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
2558 let list = Arc::new(SelectList::new(items, 10));
2559
2560 let state_sel = state.clone();
2561 let ec_sel = editor_container.clone();
2562 let editor_sel = editor.clone();
2563 let tui_sel = tui.clone();
2564 let lane_sel = lane.clone();
2565 let chat_sel = chat.clone();
2566 let catalog_sel = catalog.to_vec();
2567 let lane_model_sel = lane_model_id.to_string();
2568 let cwd_sel = cwd.to_path_buf();
2569 let package_resources_sel = package_resources.clone();
2570 list.on_select(Arc::new(move |item| {
2571 match item.value.as_str() {
2574 "theme" => open_settings_theme_selector(
2575 &state_sel,
2576 &ec_sel,
2577 &editor_sel,
2578 &tui_sel,
2579 &chat_sel,
2580 &cwd_sel,
2581 &package_resources_sel,
2582 ),
2583 "model" => open_settings_model_selector(
2584 &state_sel,
2585 &ec_sel,
2586 &editor_sel,
2587 &tui_sel,
2588 &lane_sel,
2589 &catalog_sel,
2590 &lane_model_sel,
2591 &chat_sel,
2592 ),
2593 "thinking" => open_settings_thinking_selector(
2594 &state_sel,
2595 &ec_sel,
2596 &editor_sel,
2597 &tui_sel,
2598 &lane_sel,
2599 &catalog_sel,
2600 &lane_model_sel,
2601 &chat_sel,
2602 ),
2603 "scoped-models" => open_scoped_models_selector(
2604 &state_sel,
2605 &ec_sel,
2606 &editor_sel,
2607 &tui_sel,
2608 &catalog_sel,
2609 &chat_sel,
2610 ),
2611 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
2612 }
2613 }));
2614 let state_cancel = state.clone();
2615 let ec_cancel = editor_container.clone();
2616 let editor_cancel = editor.clone();
2617 let tui_cancel = tui.clone();
2618 list.on_cancel(Arc::new(move || {
2619 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2620 }));
2621
2622 open_selector(
2623 state,
2624 editor_container,
2625 editor,
2626 tui,
2627 list,
2628 SelectorKind::Settings,
2629 );
2630}
2631
2632fn open_settings_theme_selector(
2634 state: &Arc<TuiState>,
2635 editor_container: &Arc<Container>,
2636 editor: &Arc<Editor>,
2637 tui: &Arc<TuiAltScreen>,
2638 chat: &Arc<Container>,
2639 cwd: &std::path::Path,
2640 package_resources: &Arc<crate::packages::PackageResources>,
2641) {
2642 let mut items = vec![
2643 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
2644 SelectItem::new("light", "Light").with_description("Light background"),
2645 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
2646 ];
2647 if state.themes_enabled {
2648 for path in package_resources.theme_files() {
2649 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
2650 items.push(SelectItem::new(name, name).with_description("Package theme"));
2651 }
2652 }
2653 }
2654 let list = Arc::new(SelectList::new(items, 10));
2655
2656 let state_sel = state.clone();
2657 let ec_sel = editor_container.clone();
2658 let editor_sel = editor.clone();
2659 let tui_sel = tui.clone();
2660 let chat_sel = chat.clone();
2661 let cwd_sel = cwd.to_path_buf();
2662 let package_resources_sel = package_resources.clone();
2663 list.on_select(Arc::new(move |item| {
2664 let preset = match item.value.as_str() {
2665 "light" => Some(ThemePreset::Light),
2666 "monochrome" => Some(ThemePreset::Monochrome),
2667 "dark" => Some(ThemePreset::Dark),
2668 name => {
2669 if state_sel.themes_enabled {
2670 if let Ok(custom) = crate::packages::load_theme_with_resources(
2671 &cwd_sel,
2672 name,
2673 &package_resources_sel,
2674 ) {
2675 rpi_tui::global_theme_manager().set(custom.clone());
2676 state_sel.theme_manager.set(custom);
2677 }
2678 }
2679 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
2680 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2681 tui_sel.render_now(true);
2682 return;
2683 }
2684 };
2685 let Some(preset) = preset else { return };
2686 apply_theme_preset(preset);
2687 state_sel.theme_manager.apply_preset(preset);
2688 let mut settings = crate::settings::load_settings().unwrap_or_default();
2689 settings.theme = Some(item.value.clone());
2690 let saved = crate::settings::save_settings(&settings);
2691 add_note_message(
2692 &chat_sel,
2693 &format!(
2694 "Theme set to {} (saved{})",
2695 item.label,
2696 if saved.is_ok() { "" } else { ", not saved" },
2697 ),
2698 );
2699 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2700 tui_sel.render_now(true);
2701 }));
2702 let state_cancel = state.clone();
2703 let ec_cancel = editor_container.clone();
2704 let editor_cancel = editor.clone();
2705 let tui_cancel = tui.clone();
2706 list.on_cancel(Arc::new(move || {
2707 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2708 }));
2709
2710 open_selector(
2711 state,
2712 editor_container,
2713 editor,
2714 tui,
2715 list,
2716 SelectorKind::Settings,
2717 );
2718}
2719
2720fn open_settings_model_selector(
2724 state: &Arc<TuiState>,
2725 editor_container: &Arc<Container>,
2726 editor: &Arc<Editor>,
2727 tui: &Arc<TuiAltScreen>,
2728 lane: &Arc<dyn AgentLane>,
2729 catalog: &[rpi_ai::Model],
2730 lane_model_id: &str,
2731 chat: &Arc<Container>,
2732) {
2733 let items = model_selector_items(catalog, lane_model_id);
2734 if items.is_empty() {
2735 add_note_message(chat, "No models in the catalog.");
2736 tui.request_render(false);
2737 return;
2738 }
2739 let list = Arc::new(SelectList::new(items, 10));
2740
2741 let catalog_arc = catalog.to_vec();
2742 let state_sel = state.clone();
2743 let ec_sel = editor_container.clone();
2744 let editor_sel = editor.clone();
2745 let tui_sel = tui.clone();
2746 let chat_sel = chat.clone();
2747 let lane_sel = lane.clone();
2748 list.on_select(Arc::new(move |item| {
2749 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
2750 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
2751 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2752 return;
2753 };
2754 state_sel.set_current_model(&model);
2755 let lane = lane_sel.clone();
2756 tokio::spawn(async move {
2757 let _ = lane.set_model(model).await;
2758 });
2759 let mut settings = crate::settings::load_settings().unwrap_or_default();
2760 settings.default_model = Some(item.value.clone());
2761 let saved = crate::settings::save_settings(&settings);
2762 add_note_message(
2763 &chat_sel,
2764 &format!(
2765 "Default model set to {} (saved{}",
2766 short_model_name(&item.value),
2767 if saved.is_ok() { ")" } else { ", not saved)" },
2768 ),
2769 );
2770 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2771 }));
2772 let state_cancel = state.clone();
2773 let ec_cancel = editor_container.clone();
2774 let editor_cancel = editor.clone();
2775 let tui_cancel = tui.clone();
2776 list.on_cancel(Arc::new(move || {
2777 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2778 }));
2779
2780 open_selector(
2781 state,
2782 editor_container,
2783 editor,
2784 tui,
2785 list,
2786 SelectorKind::Settings,
2787 );
2788}
2789
2790fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
2795 let mut seen = std::collections::HashSet::new();
2796 catalog
2797 .iter()
2798 .filter(|m| {
2799 seen.insert((
2800 m.api.clone(),
2801 m.provider.to_ascii_lowercase(),
2802 m.id.to_ascii_lowercase(),
2803 ))
2804 })
2805 .map(|m| {
2806 let label = if m.name.is_empty() {
2807 short_model_name(&m.id)
2808 } else {
2809 m.name.clone()
2810 };
2811 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
2812 && m.provider.eq_ignore_ascii_case("anthropic")
2813 {
2814 m.id.clone()
2815 } else {
2816 format!("{}/{}", m.provider, m.id)
2817 };
2818 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
2819 " (current)"
2820 } else {
2821 ""
2822 };
2823 SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
2824 })
2825 .collect()
2826}
2827
2828fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
2832 let (provider, id) = input
2833 .split_once('/')
2834 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
2835 .map_or((None, input), |(provider, id)| (Some(provider), id));
2836 catalog
2837 .iter()
2838 .find(|model| {
2839 model.id.eq_ignore_ascii_case(id)
2840 && provider.map_or(true, |provider| {
2841 model.provider.eq_ignore_ascii_case(provider)
2842 || (provider.eq_ignore_ascii_case("anthropic")
2843 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
2844 })
2845 })
2846 .cloned()
2847}
2848
2849fn open_settings_thinking_selector(
2853 state: &Arc<TuiState>,
2854 editor_container: &Arc<Container>,
2855 editor: &Arc<Editor>,
2856 tui: &Arc<TuiAltScreen>,
2857 lane: &Arc<dyn AgentLane>,
2858 catalog: &[rpi_ai::Model],
2859 lane_model_id: &str,
2860 chat: &Arc<Container>,
2861) {
2862 let model = catalog
2863 .iter()
2864 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
2865 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
2866 .map(|m| m.supported_thinking_levels())
2867 .unwrap_or_else(|| {
2868 use rpi_ai::types::ThinkingLevel::*;
2869 vec![Off, Minimal, Low, Medium, High]
2870 });
2871 let mut items: Vec<SelectItem> = Vec::new();
2872 for lvl in &levels {
2873 let name = thinking_level_name(*lvl);
2874 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
2875 }
2876 if items.is_empty() {
2877 add_note_message(chat, "This model has no supported thinking levels.");
2878 tui.request_render(false);
2879 return;
2880 }
2881 let list = Arc::new(SelectList::new(items, 10));
2882
2883 let state_sel = state.clone();
2884 let ec_sel = editor_container.clone();
2885 let editor_sel = editor.clone();
2886 let tui_sel = tui.clone();
2887 let chat_sel = chat.clone();
2888 let lane_sel = lane.clone();
2889 list.on_select(Arc::new(move |item| {
2890 let Some(level) = thinking_level_from_name(&item.value) else {
2891 add_note_message(
2892 &chat_sel,
2893 &format!("Unknown thinking level: {}.", item.label),
2894 );
2895 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2896 return;
2897 };
2898 let lane = lane_sel.clone();
2899 let footer_sel = state_sel.footer.clone();
2900 tokio::spawn(async move {
2901 let _ = lane.set_thinking_level(level).await;
2902 });
2903 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
2904 let mut settings = crate::settings::load_settings().unwrap_or_default();
2905 settings.default_thinking_level = Some(item.value.clone());
2906 let saved = crate::settings::save_settings(&settings);
2907 add_note_message(
2908 &chat_sel,
2909 &format!(
2910 "Default thinking set to {} (saved{}",
2911 item.label,
2912 if saved.is_ok() { ")" } else { ", not saved)" },
2913 ),
2914 );
2915 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2916 }));
2917 let state_cancel = state.clone();
2918 let ec_cancel = editor_container.clone();
2919 let editor_cancel = editor.clone();
2920 let tui_cancel = tui.clone();
2921 list.on_cancel(Arc::new(move || {
2922 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2923 }));
2924
2925 open_selector(
2926 state,
2927 editor_container,
2928 editor,
2929 tui,
2930 list,
2931 SelectorKind::Settings,
2932 );
2933}
2934
2935fn open_scoped_models_selector(
2940 state: &Arc<TuiState>,
2941 editor_container: &Arc<Container>,
2942 editor: &Arc<Editor>,
2943 tui: &Arc<TuiAltScreen>,
2944 catalog: &[rpi_ai::Model],
2945 chat: &Arc<Container>,
2946) {
2947 if catalog.is_empty() {
2948 add_note_message(chat, "No models in the catalog.");
2949 tui.request_render(false);
2950 return;
2951 }
2952 let seed: Vec<String> = crate::settings::load_settings()
2954 .ok()
2955 .and_then(|s| s.scoped_models)
2956 .unwrap_or_default();
2957 *state.scoped_edit.lock().unwrap() = Some(seed);
2958
2959 let mut items: Vec<SelectItem> = Vec::new();
2960 for m in catalog {
2961 items.push(SelectItem::new(&m.id, &m.id));
2962 }
2963 let list = Arc::new(SelectList::new(items, 10));
2964
2965 let state_sel = state.clone();
2966 let chat_sel = chat.clone();
2967 let tui_sel = tui.clone();
2968 list.on_select(Arc::new(move |item| {
2969 let mut set = state_sel.scoped_edit.lock().unwrap();
2971 let set = set.get_or_insert_with(Vec::new);
2972 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
2973 set.remove(pos);
2974 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
2975 } else {
2976 set.push(item.value.clone());
2977 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
2978 }
2979 tui_sel.request_render(false);
2980 }));
2981 let state_cancel = state.clone();
2982 let ec_cancel = editor_container.clone();
2983 let editor_cancel = editor.clone();
2984 let tui_cancel = tui.clone();
2985 let chat_cancel = chat.clone();
2986 list.on_cancel(Arc::new(move || {
2987 let set = state_cancel
2989 .scoped_edit
2990 .lock()
2991 .unwrap()
2992 .take()
2993 .unwrap_or_default();
2994 let mut settings = crate::settings::load_settings().unwrap_or_default();
2995 settings.scoped_models = if set.is_empty() {
2996 None
2997 } else {
2998 Some(set.clone())
2999 };
3000 match crate::settings::save_settings(&settings) {
3001 Ok(()) => {
3002 if set.is_empty() {
3003 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3004 } else {
3005 add_note_message(
3006 &chat_cancel,
3007 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3008 );
3009 }
3010 }
3011 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3012 }
3013 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3014 }));
3015
3016 open_selector(
3017 state,
3018 editor_container,
3019 editor,
3020 tui,
3021 list,
3022 SelectorKind::ScopedModels,
3023 );
3024}
3025
3026async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3030 use std::process::Stdio;
3031
3032 let tree = harness.session().view("main");
3034 let entries = match tree
3035 .find_entries(&EntryQuery {
3036 entry_type: None,
3037 custom_type: None,
3038 order: Some(EntryOrder::OldestFirst),
3041 limit: None,
3042 cursor: None,
3043 })
3044 .await
3045 {
3046 Ok(e) => e,
3047 Err(e) => {
3048 add_error_message(chat, &format!("Could not read session: {e}"));
3049 return;
3050 }
3051 };
3052 let mut md = String::from("# Session\n\n");
3053 for e in entries {
3054 let Entry::Message(me) = e else { continue };
3055 match &me.message {
3056 AgentMessage::User(u) => {
3057 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3058 }
3059 AgentMessage::Assistant(a) => {
3060 let text = assistant_text(a);
3061 if !text.is_empty() {
3062 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3063 }
3064 }
3065 _ => {}
3066 }
3067 }
3068
3069 let gh = std::process::Command::new("gh")
3071 .arg("gist")
3072 .arg("create")
3073 .arg("--filename")
3074 .arg("session.md")
3075 .arg("-")
3076 .stdin(Stdio::piped())
3077 .stdout(Stdio::piped())
3078 .stderr(Stdio::null())
3079 .spawn();
3080 if let Ok(mut child) = gh {
3081 use std::io::Write;
3082 if let Some(mut stdin) = child.stdin.take() {
3083 let _ = stdin.write_all(md.as_bytes());
3084 let _ = stdin.flush();
3085 }
3086 let out = child.wait_with_output().ok();
3087 if let Some(out) = out {
3088 if out.status.success() {
3089 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3090 add_note_message(chat, &format!("Shared session: {url}"));
3091 return;
3092 }
3093 }
3094 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3095 } else {
3096 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3097 }
3098 if copy_to_clipboard(&md) {
3100 add_note_message(chat, "Session transcript copied to the clipboard.");
3101 } else {
3102 add_note_message(
3103 chat,
3104 "Clipboard unavailable — use /export to write the transcript to a file.",
3105 );
3106 }
3107}
3108
3109async fn export_session(harness: &AgentHarness, chat: &Arc<Container>, cwd: &std::path::Path) {
3118 let tree = harness.session().view("main");
3119 let entries = match tree
3120 .find_entries(&EntryQuery {
3121 entry_type: None,
3122 custom_type: None,
3123 order: Some(EntryOrder::OldestFirst),
3126 limit: None,
3127 cursor: None,
3128 })
3129 .await
3130 {
3131 Ok(e) => e,
3132 Err(e) => {
3133 add_error_message(chat, &format!("Could not read session: {e}"));
3134 return;
3135 }
3136 };
3137 let name = tree.get_name().await.ok().flatten().unwrap_or_default();
3138 let id = tree
3139 .get_leaf_id()
3140 .await
3141 .ok()
3142 .flatten()
3143 .unwrap_or_else(|| "session".to_string());
3144 let mut md = String::from("# Session\n\n");
3145 for e in entries {
3146 let Entry::Message(me) = e else { continue };
3147 match &me.message {
3148 AgentMessage::User(u) => {
3149 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3150 }
3151 AgentMessage::Assistant(a) => {
3152 let text = assistant_text(a);
3153 if !text.is_empty() {
3154 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3155 }
3156 }
3157 _ => {}
3158 }
3159 }
3160 let file_name = if name.is_empty() {
3161 format!("{id}.md")
3162 } else {
3163 format!("{name}.md")
3164 };
3165 let path = cwd.join(&file_name);
3166 match std::fs::write(&path, md) {
3167 Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
3168 Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
3169 }
3170}
3171
3172async fn switch_to_session(
3182 harness: &AgentHarness,
3183 lane: &Arc<dyn AgentLane>,
3184 id: &str,
3185 cwd: &std::path::Path,
3186 chat: &Arc<Container>,
3187 state: &Arc<TuiState>,
3188) -> bool {
3189 if *state.status.lock().unwrap() == RunStatus::Working {
3190 state.set_status(RunStatus::Aborting);
3191 let _ = lane.abort().await;
3192 }
3193 let cwd_str = cwd.to_string_lossy().to_string();
3194 match crate::session::open_session_by_id(id, &cwd_str).await {
3195 Ok(new_session) => {
3196 let _ = harness.set_session(new_session).await;
3197 chat.clear();
3198 add_welcome_message(chat);
3199 render_session_history(
3200 harness,
3201 chat,
3202 state.markdown_transformer(),
3203 Some(state.extension_session.clone()),
3204 )
3205 .await;
3206 state.set_status(RunStatus::Idle);
3207 add_note_message(chat, &format!("Switched to session {id}."));
3208 true
3209 }
3210 Err(e) => {
3211 state.set_status(RunStatus::Idle);
3212 add_error_message(chat, &format!("Could not open session {id}: {e}"));
3213 false
3214 }
3215 }
3216}
3217
3218async fn import_session(
3222 harness: &AgentHarness,
3223 lane: &Arc<dyn AgentLane>,
3224 path: &str,
3225 cwd: &std::path::Path,
3226 chat: &Arc<Container>,
3227 state: &Arc<TuiState>,
3228) {
3229 use std::path::Path as FsPath;
3230
3231 let src = FsPath::new(path);
3232 if !src.is_file() {
3233 add_error_message(chat, &format!("Import source not found: {path}"));
3234 return;
3235 }
3236 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3237 add_error_message(chat, "Import source has no file name.");
3238 return;
3239 };
3240 if !fname.ends_with(".jsonl") {
3241 add_error_message(chat, "Import source must be a .jsonl session file.");
3242 return;
3243 }
3244 let dir = crate::session::default_session_dir(cwd);
3245 if let Err(e) = std::fs::create_dir_all(&dir) {
3246 add_error_message(chat, &format!("Could not create session dir: {e}"));
3247 return;
3248 }
3249 let dest = dir.join(fname);
3250 match std::fs::copy(src, &dest) {
3251 Ok(_) => {
3252 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3253 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3254 add_note_message(chat, &format!("Imported session from {path}"));
3255 }
3256 }
3257 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3258 }
3259}
3260
3261async fn fork_session(
3262 harness: &AgentHarness,
3263 cwd: &std::path::Path,
3264 chat: &Arc<Container>,
3265 state: &Arc<TuiState>,
3266) {
3267 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3268 use rpi_tools::FileSystem;
3269
3270 let cwd_str = cwd.to_string_lossy().to_string();
3271 let dir = crate::session::default_session_dir(cwd);
3272 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3273 let fs: Arc<dyn FileSystem> = env.clone();
3274 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3275 fs,
3276 sessions_root: dir.to_string_lossy().into_owned(),
3277 clock: Arc::new(rpi_harness::session::memory::SystemClock),
3278 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3279 });
3280 let id = harness.session().storage().metadata().id.clone();
3283 let metas = match crate::session::list_session_metadata(&cwd_str).await {
3284 Ok(m) => m,
3285 Err(e) => {
3286 add_error_message(chat, &format!("Could not list sessions: {e}"));
3287 return;
3288 }
3289 };
3290 let Some(source) = metas.iter().find(|m| m.id == id) else {
3291 add_error_message(chat, &format!("Current session {id} not found on disk."));
3292 return;
3293 };
3294 let fork_storage = match repo
3295 .fork_typed(
3296 source,
3297 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3298 id: None,
3299 parent_session_id: Some(source.id.clone()),
3300 cwd: cwd_str.clone(),
3301 metadata: None,
3302 },
3303 &rpi_harness::session::types::ForkOptions::default(),
3304 )
3305 .await
3306 {
3307 Ok(s) => s,
3308 Err(e) => {
3309 add_error_message(chat, &format!("Could not fork session: {e}"));
3310 return;
3311 }
3312 };
3313 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3314 let _ = harness.set_session(new_session).await;
3315 chat.clear();
3316 add_welcome_message(chat);
3317 render_session_history(
3318 harness,
3319 chat,
3320 state.markdown_transformer(),
3321 Some(state.extension_session.clone()),
3322 )
3323 .await;
3324 state.set_status(RunStatus::Idle);
3325 add_note_message(chat, "Forked into a new session.");
3326}
3327
3328async fn render_session_history(
3338 harness: &AgentHarness,
3339 chat: &Arc<Container>,
3340 transformer: Option<MarkdownTransformer>,
3341 extension_session: Option<crate::session::ExtensionSessionCell>,
3342) {
3343 let tree = harness.session().view("main");
3344 let entries = match tree
3345 .find_entries(&EntryQuery {
3346 entry_type: None,
3347 custom_type: None,
3348 order: Some(EntryOrder::OldestFirst),
3352 limit: None,
3353 cursor: None,
3354 })
3355 .await
3356 {
3357 Ok(e) => e,
3358 Err(_) => return,
3359 };
3360 let mut rendered_any = false;
3361 for e in entries {
3362 match e {
3363 Entry::Message(me) => match &me.message {
3364 AgentMessage::User(u) => {
3365 add_user_message(chat, &user_message_text(u));
3366 rendered_any = true;
3367 }
3368 AgentMessage::Assistant(a) => {
3369 let comp = Arc::new(AssistantMessageComponent::new(
3370 AssistantMessageOptions::default(),
3371 ));
3372 if let Some(t) = &transformer {
3373 comp.set_markdown_transformer(Some(t.clone()));
3374 }
3375 comp.update_blocks(&assistant_blocks(a));
3376 chat.add_child(comp);
3377 chat.add_child(Arc::new(Spacer::new(1)));
3380 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3381 add_note_message(chat, &text);
3382 }
3383 rendered_any = true;
3384 }
3385 AgentMessage::Custom(custom) => {
3386 if let Some(session) = &extension_session {
3387 if let Some(component) = extension_message_component(
3388 session,
3389 &custom.role,
3390 &serde_json::json!({
3391 "customType": custom.role,
3392 "content": custom.content,
3393 "details": custom.data,
3394 }),
3395 transformer.clone(),
3396 ) {
3397 chat.add_child(component);
3398 chat.add_child(Arc::new(Spacer::new(1)));
3399 rendered_any = true;
3400 continue;
3401 }
3402 }
3403 add_note_message(chat, &custom_message_fallback(&custom));
3404 rendered_any = true;
3405 }
3406 _ => {}
3407 },
3408 Entry::Compaction(compaction) => {
3409 add_note_message(
3410 chat,
3411 &format!(
3412 "Compacted {} tokens: {}",
3413 compaction.tokens_before, compaction.summary
3414 ),
3415 );
3416 rendered_any = true;
3417 }
3418 Entry::BranchSummary(summary) => {
3419 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3420 rendered_any = true;
3421 }
3422 Entry::Custom(custom) => {
3423 let rendered = extension_session.as_ref().and_then(|session| {
3424 extension_entry_component(session, &custom.custom_type, custom.data.clone())
3425 });
3426 if let Some(component) = rendered {
3427 chat.add_child(component);
3428 chat.add_child(Arc::new(Spacer::new(1)));
3429 rendered_any = true;
3430 } else if let Some(text) =
3431 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3432 {
3433 add_note_message(chat, &text);
3434 rendered_any = true;
3435 }
3436 }
3437 Entry::ModelChange(change) => {
3438 add_note_message(
3439 chat,
3440 &format!("Model changed to {}:{}", change.provider, change.model_id),
3441 );
3442 rendered_any = true;
3443 }
3444 Entry::ThinkingLevel(change) => {
3445 add_note_message(
3446 chat,
3447 &format!("Thinking level: {:?}", change.thinking_level),
3448 );
3449 rendered_any = true;
3450 }
3451 Entry::ActiveTools(change) => {
3452 add_note_message(
3453 chat,
3454 &format!("Active tools: {}", change.active_tool_names.join(", ")),
3455 );
3456 rendered_any = true;
3457 }
3458 }
3459 }
3460 if rendered_any {
3461 }
3464}
3465
3466fn invoke_extension_renderer(
3467 session: &crate::session::ExtensionSessionCell,
3468 kind: rpi_extensions::RegisteredRendererKind,
3469 payload: &serde_json::Value,
3470) -> Option<serde_json::Value> {
3471 let snapshot = session.lock().ok()?.snapshot_arc()?;
3472 let input = serde_json::to_string(payload).ok()?;
3473 for renderer in snapshot.renderers_of(kind) {
3474 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3475 let mut out = rpi_plugin_sdk::StbString::empty();
3476 let rc = (renderer.render_fn)(
3477 rpi_plugin_sdk::StbStringRef::from_str(&input),
3478 &mut out as *mut rpi_plugin_sdk::StbString,
3479 renderer.user_data,
3480 );
3481 let text = if rc == 0 {
3482 Some(out.to_string_lossy())
3483 } else {
3484 None
3485 };
3486 out.free_with(Some(renderer.plugin_free_string));
3487 text
3488 }))
3489 .ok()
3490 .flatten();
3491 let Some(text) = outcome else { continue };
3492 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3493 return Some(value);
3494 }
3495 }
3496 None
3497}
3498
3499fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3500 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3501 let text = lines
3502 .iter()
3503 .filter_map(|line| line.as_str())
3504 .collect::<Vec<_>>()
3505 .join("\n");
3506 return Some(Arc::new(Text::new(text, 0, 0)));
3507 }
3508 let text = value.get("text").and_then(|v| v.as_str())?;
3509 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3510 let component = Arc::new(AssistantMessageComponent::new(
3511 AssistantMessageOptions::default(),
3512 ));
3513 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3514 Some(component)
3515 } else {
3516 Some(Arc::new(Text::new(text, 0, 0)))
3517 }
3518}
3519
3520fn extension_message_component(
3521 session: &crate::session::ExtensionSessionCell,
3522 custom_type: &str,
3523 payload: &serde_json::Value,
3524 transformer: Option<MarkdownTransformer>,
3525) -> Option<Arc<dyn rpi_tui::Component>> {
3526 let value = invoke_extension_renderer(
3527 session,
3528 rpi_extensions::RegisteredRendererKind::Message,
3529 payload,
3530 )?;
3531 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3532 let text = value.get("text").and_then(|v| v.as_str())?;
3533 let component = Arc::new(AssistantMessageComponent::new(
3534 AssistantMessageOptions::default(),
3535 ));
3536 if let Some(transformer) = transformer {
3537 component.set_markdown_transformer(Some(transformer));
3538 }
3539 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3540 return Some(component);
3541 }
3542 extension_text_component(&value)
3543 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
3544}
3545
3546fn extension_usage_text(
3549 session: Option<&crate::session::ExtensionSessionCell>,
3550 usage: &rpi_ai::types::Usage,
3551) -> Option<String> {
3552 let session = session?;
3553 let payload = serde_json::json!({
3554 "customType": "token-usage",
3555 "usage": usage,
3556 });
3557 let value = invoke_extension_renderer(
3558 session,
3559 rpi_extensions::RegisteredRendererKind::Message,
3560 &payload,
3561 )?;
3562 value
3563 .get("text")
3564 .and_then(|value| value.as_str())
3565 .filter(|text| !text.trim().is_empty())
3566 .map(ToOwned::to_owned)
3567}
3568
3569fn extension_entry_component(
3570 session: &crate::session::ExtensionSessionCell,
3571 custom_type: &str,
3572 data: Option<serde_json::Value>,
3573) -> Option<Arc<dyn rpi_tui::Component>> {
3574 let payload = serde_json::json!({
3575 "customType": custom_type,
3576 "data": data,
3577 });
3578 let value = invoke_extension_renderer(
3579 session,
3580 rpi_extensions::RegisteredRendererKind::Entry,
3581 &payload,
3582 )?;
3583 extension_text_component(&value)
3584}
3585
3586fn launch_restores_history(args: &Args) -> bool {
3592 args.continue_session
3593 || args.resume
3594 || args.session.is_some()
3595 || args.session_id.is_some()
3596 || args.fork.is_some()
3597}
3598
3599fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
3600 msg.content
3601 .iter()
3602 .filter_map(|c| match c {
3603 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
3604 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
3605 Content::Image(image) => base64::engine::general_purpose::STANDARD
3606 .decode(&image.data)
3607 .ok()
3608 .filter(|data| !data.is_empty())
3609 .map(AssistantBlock::Image),
3610 _ => None,
3611 })
3612 .collect()
3613}
3614
3615fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
3616 let content = custom
3617 .content
3618 .iter()
3619 .filter_map(|item| match item {
3620 Content::Text(text) => Some(text.text.as_str()),
3621 _ => None,
3622 })
3623 .collect::<Vec<_>>()
3624 .join("\n");
3625 if content.is_empty() {
3626 format!("{}: {}", custom.role, custom.data)
3627 } else {
3628 format!("{}: {}", custom.role, content)
3629 }
3630}
3631
3632fn short_model_name(id: &str) -> String {
3635 id.rsplit([':', '/'])
3636 .next()
3637 .filter(|s| !s.is_empty())
3638 .unwrap_or(id)
3639 .to_string()
3640}
3641
3642#[derive(Clone, Copy, PartialEq, Eq)]
3648enum RunStatus {
3649 Idle,
3650 Working,
3651 Aborting,
3652}
3653
3654#[derive(Clone, Copy, PartialEq, Eq)]
3656enum SelectorKind {
3657 Model,
3659 Thinking,
3661 Tools,
3663 Images,
3665 Session,
3667 Theme,
3669 ScopedModels,
3671 Settings,
3673 Tree,
3675 Extension,
3677}
3678
3679struct TuiState {
3682 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
3684 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
3686 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
3690 themes_enabled: bool,
3692 hide_thinking: std::sync::Mutex<bool>,
3694 tool_outputs_expanded: std::sync::Mutex<bool>,
3696 show_terminal_progress: bool,
3698 status: std::sync::Mutex<RunStatus>,
3700 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
3704 footer: Arc<FooterComponent>,
3706 status_container: Arc<Container>,
3709 chat_container: Arc<Container>,
3711 loader: Arc<Loader>,
3713 last_assistant_text: std::sync::Mutex<String>,
3716 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
3720 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
3722 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
3724 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
3726 autocomplete: AutocompleteManager,
3729 autocomplete_container: Arc<Container>,
3732 autocomplete_max_visible: usize,
3734 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
3736 theme_manager: Arc<ThemeManager>,
3740 tui: Option<Arc<TuiAltScreen>>,
3744 current_model_id: std::sync::Mutex<String>,
3748 show_images: std::sync::Mutex<bool>,
3752 history: std::sync::Mutex<Vec<String>>,
3755 history_index: std::sync::Mutex<isize>,
3758 history_draft: std::sync::Mutex<Option<String>>,
3761 last_input_tokens: std::sync::Mutex<i64>,
3765 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
3768 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
3778 extension_session: crate::session::ExtensionSessionCell,
3780}
3781
3782const HISTORY_LIMIT: usize = 100;
3785
3786const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
3789
3790const PAGE_SCROLL_OVERLAP: usize = 4;
3793
3794const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
3798
3799fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
3802 let mut modifiers = KeyModifiers::NONE;
3803 let mut key = None;
3804 for part in value.trim().to_ascii_lowercase().split('+') {
3805 match part {
3806 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
3807 "shift" => modifiers |= KeyModifiers::SHIFT,
3808 "alt" | "option" => modifiers |= KeyModifiers::ALT,
3809 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
3810 part if !part.is_empty() => key = Some(part.to_string()),
3811 _ => {}
3812 }
3813 }
3814 let key = key?;
3815 let code = match key.as_str() {
3816 "esc" | "escape" => KeyCode::Esc,
3817 "enter" | "return" => KeyCode::Enter,
3818 "tab" => {
3819 if modifiers.contains(KeyModifiers::SHIFT) {
3820 return Some(rpi_tui::KeyCombo::new(
3821 KeyCode::BackTab,
3822 modifiers & !KeyModifiers::SHIFT,
3823 ));
3824 }
3825 KeyCode::Tab
3826 }
3827 "backspace" | "back" => KeyCode::Backspace,
3828 "delete" | "del" => KeyCode::Delete,
3829 "up" | "arrowup" => KeyCode::Up,
3830 "down" | "arrowdown" => KeyCode::Down,
3831 "left" | "arrowleft" => KeyCode::Left,
3832 "right" | "arrowright" => KeyCode::Right,
3833 "home" => KeyCode::Home,
3834 "end" => KeyCode::End,
3835 "pageup" | "page-up" => KeyCode::PageUp,
3836 "pagedown" | "page-down" => KeyCode::PageDown,
3837 "space" => KeyCode::Char(' '),
3838 "f1" => KeyCode::F(1),
3839 "f2" => KeyCode::F(2),
3840 "f3" => KeyCode::F(3),
3841 "f4" => KeyCode::F(4),
3842 "f5" => KeyCode::F(5),
3843 "f6" => KeyCode::F(6),
3844 "f7" => KeyCode::F(7),
3845 "f8" => KeyCode::F(8),
3846 "f9" => KeyCode::F(9),
3847 "f10" => KeyCode::F(10),
3848 "f11" => KeyCode::F(11),
3849 "f12" => KeyCode::F(12),
3850 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
3851 _ => return None,
3852 };
3853 Some(rpi_tui::KeyCombo::new(code, modifiers))
3854}
3855
3856fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
3857 let mut bindings = rpi_tui::Keybindings::new();
3858 let settings = crate::settings::load_settings().unwrap_or_default();
3859 let Some(overrides) = settings.keybindings else {
3860 rpi_tui::set_keybindings(bindings.clone());
3861 return Arc::new(bindings);
3862 };
3863 let known: &[(&str, rpi_tui::KeybindingId)] = &[
3864 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
3865 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
3866 ("app.exit", rpi_tui::keybindings::keys::EXIT),
3867 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
3868 (
3869 "app.model.cycleForward",
3870 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
3871 ),
3872 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
3873 (
3874 "app.thinking.toggle",
3875 rpi_tui::keybindings::keys::THINKING_TOGGLE,
3876 ),
3877 (
3878 "app.editor.external",
3879 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
3880 ),
3881 (
3882 "app.thinking.cycle",
3883 rpi_tui::keybindings::keys::THINKING_CYCLE,
3884 ),
3885 (
3886 "app.clipboard.pasteImage",
3887 rpi_tui::keybindings::keys::PASTE_IMAGE,
3888 ),
3889 ];
3890 for (name, id) in known {
3891 let Some(value) = overrides.get(*name) else {
3892 continue;
3893 };
3894 let values: Vec<String> = match value {
3895 serde_json::Value::String(value) => vec![value.clone()],
3896 serde_json::Value::Array(values) => values
3897 .iter()
3898 .filter_map(|v| v.as_str().map(str::to_string))
3899 .collect(),
3900 serde_json::Value::Null => Vec::new(),
3901 _ => continue,
3902 };
3903 let combos: Vec<_> = values
3904 .iter()
3905 .filter_map(|value| parse_configured_key(value))
3906 .collect();
3907 if values.is_empty() || !combos.is_empty() {
3908 bindings.set(id, combos);
3909 }
3910 }
3911 rpi_tui::set_keybindings(bindings.clone());
3912 Arc::new(bindings)
3913}
3914
3915fn keybinding_matches(
3916 bindings: &rpi_tui::Keybindings,
3917 event: &crossterm::event::KeyEvent,
3918 id: rpi_tui::KeybindingId,
3919) -> bool {
3920 if bindings.matches(event, id) {
3921 return true;
3922 }
3923 if event.code == KeyCode::BackTab {
3926 let normalized =
3927 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
3928 bindings.matches(&normalized, id)
3929 } else {
3930 false
3931 }
3932}
3933
3934fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
3935 last.is_some_and(|previous| {
3936 now.duration_since(previous) <= std::time::Duration::from_millis(500)
3937 })
3938}
3939
3940fn transcript_page_size(viewport_height: usize) -> i32 {
3941 viewport_height
3942 .saturating_sub(PAGE_SCROLL_OVERLAP)
3943 .max(1)
3944 .min(i32::MAX as usize) as i32
3945}
3946
3947fn should_dispatch_key(kind: KeyEventKind) -> bool {
3948 kind != KeyEventKind::Release
3949}
3950
3951fn format_tokens(n: i64) -> String {
3953 if n >= 1_000_000 {
3954 format!("{:.1}M", n as f64 / 1_000_000.0)
3955 } else if n >= 1_000 {
3956 format!("{:.1}K", n as f64 / 1_000.0)
3957 } else {
3958 n.to_string()
3959 }
3960}
3961
3962fn push_history(state: &Arc<TuiState>, text: &str) {
3966 let trimmed = text.trim().to_string();
3967 if trimmed.is_empty() {
3968 return;
3969 }
3970 let mut history = state.history.lock().unwrap();
3971 if history.first() == Some(&trimmed) {
3972 return;
3973 }
3974 history.insert(0, trimmed);
3975 history.truncate(HISTORY_LIMIT);
3976 *state.history_index.lock().unwrap() = -1;
3977 *state.history_draft.lock().unwrap() = None;
3978}
3979
3980fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
3985 let history = state.history.lock().unwrap();
3986 if history.is_empty() {
3987 return;
3988 }
3989 let mut index = state.history_index.lock().unwrap();
3990 let new_index = *index - direction as isize;
3991 if new_index < -1 || new_index >= history.len() as isize {
3992 return;
3993 }
3994 if *index == -1 && new_index >= 0 {
3995 *state.history_draft.lock().unwrap() = Some(editor.get_text());
3997 }
3998 *index = new_index;
3999 if new_index == -1 {
4000 let draft = state.history_draft.lock().unwrap().take();
4002 match draft {
4003 Some(d) => {
4004 let len = d.len();
4005 editor.set_text(&d);
4006 editor.set_cursor(0, len);
4007 }
4008 None => editor.set_text(""),
4009 }
4010 } else {
4011 let text = history[new_index as usize].clone();
4012 let len = text.len();
4013 editor.set_text(&text);
4014 editor.set_cursor(0, len);
4015 }
4016}
4017
4018impl TuiState {
4019 fn begin_js_preparation(&self) -> CancellationToken {
4020 let cancellation = CancellationToken::new();
4021 if let Some(previous) = self
4022 .js_preparation_cancel
4023 .lock()
4024 .unwrap()
4025 .replace(cancellation.clone())
4026 {
4027 previous.cancel();
4028 }
4029 cancellation
4030 }
4031
4032 fn finish_js_preparation(&self) {
4033 self.js_preparation_cancel.lock().unwrap().take();
4034 }
4035
4036 fn cancel_js_preparation(&self) -> bool {
4037 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4038 if let Some(cancellation) = cancellation {
4039 cancellation.cancel();
4040 true
4041 } else {
4042 false
4043 }
4044 }
4045
4046 fn set_status(&self, status: RunStatus) {
4047 *self.status.lock().unwrap() = status;
4048 self.apply_status(status);
4049 }
4050
4051 fn try_start_working(&self) -> bool {
4056 let mut status = self.status.lock().unwrap();
4057 if *status != RunStatus::Idle {
4058 return false;
4059 }
4060 *status = RunStatus::Working;
4061 drop(status);
4062 self.apply_status(RunStatus::Working);
4063 true
4064 }
4065
4066 fn apply_status(&self, status: RunStatus) {
4067 match status {
4068 RunStatus::Working => {
4069 self.footer.set_status("");
4073 if let Some(tui) = &self.tui {
4076 tui.set_title("rpi — working");
4077 }
4078 self.status_container.clear();
4079 if self.show_terminal_progress {
4080 self.loader.start();
4081 self.status_container.add_child(self.loader.clone());
4082 }
4083 }
4084 RunStatus::Aborting => {
4085 self.footer.set_status("Aborting…");
4086 self.loader.stop();
4089 self.status_container.clear();
4090 }
4091 RunStatus::Idle => {
4092 self.footer.set_status("");
4093 if let Some(tui) = &self.tui {
4094 tui.set_title("rpi");
4095 }
4096 self.loader.stop();
4097 self.status_container.clear();
4098 }
4099 }
4100 }
4101
4102 fn sync_working_loader_with_bash(&self) {
4106 if *self.status.lock().unwrap() != RunStatus::Working {
4107 return;
4108 }
4109
4110 self.status_container.clear();
4111 if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4112 self.status_container.add_child(self.loader.clone());
4113 }
4114 }
4115
4116 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4117 *self.status.lock().unwrap() = RunStatus::Working;
4118 self.footer.set_status("");
4119 if let Some(tui) = &self.tui {
4120 tui.set_title("rpi — retrying");
4121 }
4122 self.loader.stop();
4123 self.status_container.clear();
4124 self.status_container
4125 .add_child(Arc::new(StatusIndicator::retry(
4126 attempt,
4127 max_retries,
4128 std::time::Duration::from_millis(delay_ms),
4129 )));
4130 }
4131
4132 fn selector_open(&self) -> bool {
4134 self.active_selector.lock().unwrap().is_some()
4135 }
4136
4137 fn extension_editor_open(&self) -> bool {
4138 self.active_extension_editor.lock().unwrap().is_some()
4139 }
4140
4141 fn extension_input_open(&self) -> bool {
4142 self.active_extension_input.lock().unwrap().is_some()
4143 }
4144
4145 fn extension_dialog_open(&self) -> bool {
4146 self.extension_editor_open() || self.extension_input_open()
4147 }
4148
4149 fn set_hide_thinking(&self, hide: bool) {
4150 *self.hide_thinking.lock().unwrap() = hide;
4151 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4152 comp.set_hide_thinking(hide);
4153 }
4154 }
4155
4156 fn hide_thinking(&self) -> bool {
4157 *self.hide_thinking.lock().unwrap()
4158 }
4159
4160 fn toggle_thinking(&self) -> bool {
4161 let next = !self.hide_thinking();
4162 self.set_hide_thinking(next);
4163 next
4164 }
4165
4166 fn toggle_tool_outputs(&self) -> bool {
4167 let next = !*self.tool_outputs_expanded.lock().unwrap();
4168 *self.tool_outputs_expanded.lock().unwrap() = next;
4169 for comp in self.tool_components.lock().unwrap().values() {
4170 comp.set_expanded(next);
4171 }
4172 for comp in self.bash_components.lock().unwrap().values() {
4173 comp.set_expanded(next);
4174 }
4175 next
4176 }
4177
4178 fn current_model_id(&self) -> String {
4180 self.current_model_id.lock().unwrap().clone()
4181 }
4182
4183 fn set_current_model(&self, model: &rpi_ai::Model) {
4186 *self.current_model_id.lock().unwrap() = model.id.clone();
4187 self.footer.set_model(&short_model_name(&model.id));
4188 }
4189
4190 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4194 self.markdown_transformer.lock().unwrap().clone()
4195 }
4196
4197 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4206 *self.markdown_transformer.lock().unwrap() = transformer.clone();
4207 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4208 comp.set_markdown_transformer(transformer);
4209 }
4210 }
4211
4212 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4213 self.pending_images.lock().unwrap().push(image);
4214 }
4215
4216 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4217 std::mem::take(&mut *self.pending_images.lock().unwrap())
4218 }
4219}
4220
4221#[derive(Debug, PartialEq)]
4226struct TuiStartupSettings {
4227 editor_padding_x: usize,
4228 autocomplete_max_visible: usize,
4229 hide_thinking: bool,
4230 quiet_startup: bool,
4231 show_terminal_progress: bool,
4232}
4233
4234fn preferred_project_setting<T>(
4235 project_settings: &[crate::settings::Settings],
4236 field: impl Fn(&crate::settings::Settings) -> Option<T>,
4237) -> Option<T> {
4238 project_settings.iter().find_map(field)
4239}
4240
4241fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4242 verbose || !quiet_startup
4243}
4244
4245fn git_only_update_resources(
4246 mut resources: crate::packages::PackageResources,
4247) -> crate::packages::PackageResources {
4248 resources
4249 .packages
4250 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
4251 resources
4252}
4253
4254fn resolve_tui_startup_settings(
4258 global: &crate::settings::Settings,
4259 project_settings: &[crate::settings::Settings],
4260 project_trusted: bool,
4261) -> TuiStartupSettings {
4262 let project_settings = if project_trusted {
4263 project_settings
4264 } else {
4265 &[]
4266 };
4267 let editor_padding_x =
4268 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4269 .or(global.editor_padding_x)
4270 .unwrap_or(1)
4271 .min(16);
4272 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4273 settings.autocomplete_max_visible
4274 })
4275 .or(global.autocomplete_max_visible)
4276 .unwrap_or(5)
4277 .clamp(1, 20);
4278 let hide_thinking =
4279 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4280 .or(global.hide_thinking_block)
4281 .unwrap_or(false);
4282 let quiet_startup =
4283 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4284 .or(global.quiet_startup)
4285 .unwrap_or(false);
4286 let show_terminal_progress =
4287 preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
4288 .or(global.show_terminal_progress)
4289 .unwrap_or(true);
4290
4291 TuiStartupSettings {
4292 editor_padding_x,
4293 autocomplete_max_visible,
4294 hide_thinking,
4295 quiet_startup,
4296 show_terminal_progress,
4297 }
4298}
4299
4300pub async fn interactive_tui(
4314 harness: &AgentHarness,
4315 event_rx: Option<broadcast::Receiver<AgentEvent>>,
4316 args: &Args,
4317 model_catalog: Vec<rpi_ai::Model>,
4318 initial: Option<String>,
4319 extra_messages: &[String],
4320 initial_images: Vec<rpi_ai::types::ImageContent>,
4321 theme: Option<&str>,
4322 no_themes: bool,
4323 reload_context: &crate::session::ReloadContext,
4324) -> i32 {
4325 let lane: Arc<dyn AgentLane> = harness.lane("main");
4326 let cwd = reload_context.cwd.clone();
4330 let saved_settings = crate::settings::load_settings().unwrap_or_default();
4331 let project_trusted = reload_context.project_trusted;
4332 let project_settings = if project_trusted {
4333 crate::settings::load_project_settings(&cwd)
4334 } else {
4335 Vec::new()
4336 };
4337 let tui_settings =
4338 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
4339 let editor_padding_x = tui_settings.editor_padding_x;
4340 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
4341 let hide_thinking = tui_settings.hide_thinking;
4342 let show_terminal_progress = tui_settings.show_terminal_progress;
4343 let quiet_startup = tui_settings.quiet_startup;
4344 let update_checks_enabled =
4345 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
4346
4347 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4351 let model_name = short_model_name(&lane_model_id);
4352
4353 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4357 if active_tool_names.is_empty() {
4361 active_tool_names = harness
4362 .get_tools()
4363 .await
4364 .unwrap_or_default()
4365 .into_iter()
4366 .map(|tool| tool.tool.schema().name.clone())
4367 .collect();
4368 }
4369 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4370 let skill_names: Vec<String> = resources_snapshot
4371 .skills
4372 .as_deref()
4373 .unwrap_or(&[])
4374 .iter()
4375 .map(|skill| skill.name.clone())
4376 .collect();
4377
4378 let package_resources = reload_context.package_resources.clone();
4380
4381 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4383
4384 let theme_manager = Arc::new(ThemeManager::new());
4388 if let Some(preset) = match theme {
4389 Some("light") => Some(ThemePreset::Light),
4390 Some("monochrome") => Some(ThemePreset::Monochrome),
4391 Some("dark") => Some(ThemePreset::Dark),
4392 _ => None,
4393 } {
4394 apply_theme_preset(preset);
4395 theme_manager.apply_preset(preset);
4396 } else if !no_themes {
4397 if let Some(name) = theme {
4398 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4399 Ok(custom) => {
4400 rpi_tui::global_theme_manager().set(custom.clone());
4401 theme_manager.set(custom);
4402 }
4403 Err(error) => {
4404 eprintln!("warning: could not load package theme `{name}`: {error}");
4405 }
4406 }
4407 }
4408 }
4409
4410 let terminal = Box::new(ProcessTerminal::new());
4412 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4413 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4414 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4415
4416 if let Some(js) = &reload_context.js_extension_session {
4417 if let Err(error) = js.install_ui_runtime(tui.clone()) {
4418 eprintln!("warning: could not enable JS custom UI bridge: {error}");
4419 } else if let Some(js_active) = js.active_tools() {
4420 let js_names = js.tool_names();
4424 let mut active = lane.get_active_tools().await.unwrap_or_default();
4425 active.retain(|name| {
4426 crate::session::tool_name_allowed(name, args)
4427 && !js_names.iter().any(|js_name| js_name == name)
4428 });
4429 active.extend(js_active.into_iter().filter(|name| {
4430 js_names.iter().any(|js_name| js_name == name)
4431 && crate::session::tool_name_allowed(name, args)
4432 }));
4433 active = crate::session::filter_active_tool_names(active, args);
4434 let _ = lane.set_active_tools(active).await;
4435 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4436 }
4437 }
4438
4439 let chat_container = Arc::new(Container::new());
4440 if let Some(js) = &reload_context.js_extension_session {
4441 let chat_notify = chat_container.clone();
4445 let tui_notify = tui.clone();
4446 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
4447 if action != "ui.notify" {
4448 return Err(format!("unsupported capability: {action}"));
4449 }
4450 let message = args
4451 .get("message")
4452 .and_then(serde_json::Value::as_str)
4453 .unwrap_or_default();
4454 if !message.is_empty() {
4455 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
4456 add_error_message(&chat_notify, message);
4457 } else {
4458 add_note_message(&chat_notify, message);
4459 }
4460 tui_notify.request_render(false);
4461 }
4462 Ok(serde_json::json!(true))
4463 })) {
4464 if args.verbose {
4465 eprintln!("warning: could not enable JS notification bridge: {error}");
4466 }
4467 }
4468 }
4469 if should_show_startup_listing(args.verbose, quiet_startup) {
4470 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
4471 }
4472
4473 crate::extras::maybe_first_time_setup(&chat_container);
4479
4480 let initial_transformer = build_markdown_transformer(
4485 reload_context
4486 .extension_session
4487 .lock()
4488 .unwrap()
4489 .snapshot_arc(),
4490 );
4491 if launch_restores_history(args) {
4495 render_session_history(
4496 &harness,
4497 &chat_container,
4498 initial_transformer.clone(),
4499 Some(reload_context.extension_session.clone()),
4500 )
4501 .await;
4502 }
4503
4504 let document_container = Arc::new(Container::new());
4507 document_container.add_child(chat_container.clone());
4508
4509 let scroll_view = Arc::new(ScrollView::new(
4510 document_container.clone(),
4511 ScrollViewOptions {
4512 follow: FollowMode::End,
4513 primary: true,
4514 overscroll: OverscrollMode::Chain,
4515 scrollbar: ScrollbarMode::Hidden,
4519 ..Default::default()
4520 },
4521 ));
4522
4523 let keybindings = configured_keybindings();
4528 let editor = Arc::new(Editor::new(
4529 EditorOptions {
4530 padding_x: editor_padding_x,
4531 autocomplete_max_visible,
4532 ..Default::default()
4533 },
4534 EditorStyle::default(),
4535 keybindings.clone(),
4536 ));
4537
4538 let footer = Arc::new(FooterComponent::new());
4540 footer.set_model(&model_name);
4541 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");
4542
4543 let status_container = Arc::new(Container::new());
4544 let loader = Arc::new(Loader::with_text("Working…"));
4545
4546 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
4560 .prompt_templates
4561 .clone()
4562 .unwrap_or_default()
4563 .iter()
4564 .map(|t| SlashCommandEntry {
4565 name: format!("/{}", t.name),
4566 description: t
4567 .description
4568 .clone()
4569 .unwrap_or_else(|| "Expand prompt template".to_string()),
4570 })
4571 .collect();
4572 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
4573 Arc::new(resources_snapshot);
4574 let mut command_registry = build_builtin_registry();
4580 register_extension_commands(
4581 &mut command_registry,
4582 reload_context.extension_session.clone(),
4583 );
4584 register_js_extension_commands(
4585 &mut command_registry,
4586 reload_context.js_extension_session.clone(),
4587 );
4588 let registry = Arc::new(command_registry);
4589 let mut all_slash_commands = registry.visible_entries();
4590 all_slash_commands.extend(template_slash_commands);
4591 let autocomplete = AutocompleteManager::new();
4592 {
4593 let mut combined = CombinedAutocompleteProvider::new();
4594 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
4595 all_slash_commands,
4596 )));
4597 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
4598 cwd.clone(),
4599 )));
4600 autocomplete.set_provider(Arc::new(combined));
4601 }
4602 let autocomplete_container = Arc::new(Container::new());
4603
4604 let state = Arc::new(TuiState {
4605 current_assistant: std::sync::Mutex::new(None),
4606 tool_components: std::sync::Mutex::new(HashMap::new()),
4607 bash_components: std::sync::Mutex::new(HashMap::new()),
4608 themes_enabled: !no_themes,
4609 hide_thinking: std::sync::Mutex::new(hide_thinking),
4610 tool_outputs_expanded: std::sync::Mutex::new(false),
4611 show_terminal_progress,
4612 status: std::sync::Mutex::new(RunStatus::Idle),
4613 js_preparation_cancel: std::sync::Mutex::new(None),
4614 footer: footer.clone(),
4615 status_container: status_container.clone(),
4616 chat_container: chat_container.clone(),
4617 loader: loader.clone(),
4618 last_assistant_text: std::sync::Mutex::new(String::new()),
4619 active_selector: std::sync::Mutex::new(None),
4620 active_extension_editor: std::sync::Mutex::new(None),
4621 active_extension_input: std::sync::Mutex::new(None),
4622 active_extension_cancel: std::sync::Mutex::new(None),
4623 autocomplete,
4624 autocomplete_container: autocomplete_container.clone(),
4625 autocomplete_max_visible,
4626 pending_images: std::sync::Mutex::new(Vec::new()),
4627 theme_manager,
4628 tui: Some(tui.clone()),
4629 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
4630 show_images: std::sync::Mutex::new(true),
4631 history: std::sync::Mutex::new(Vec::new()),
4632 history_index: std::sync::Mutex::new(-1),
4633 history_draft: std::sync::Mutex::new(None),
4634 last_input_tokens: std::sync::Mutex::new(0),
4635 scoped_edit: std::sync::Mutex::new(None),
4636 markdown_transformer: std::sync::Mutex::new(initial_transformer),
4637 extension_session: reload_context.extension_session.clone(),
4638 });
4639
4640 let model_catalog_arc = Arc::new(model_catalog.clone());
4643 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4644
4645 let editor_container = Arc::new(Container::new());
4656 editor_container.add_child(editor.clone());
4657
4658 let dock = Arc::new(VStack::from_children(vec![
4659 StackChild::Entry(StackEntry::new(status_container.clone())),
4660 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
4661 StackChild::Entry(
4662 StackEntry::new(editor_container.clone())
4663 .shrink(0)
4664 .min_size(3),
4665 ),
4666 StackChild::Entry(StackEntry::new(footer.clone())),
4667 ]));
4668
4669 let root = VStack::from_children(vec![
4670 StackChild::Entry(
4671 StackEntry::new(scroll_view.clone())
4672 .basis(0)
4673 .grow(1)
4674 .shrink(1)
4675 .min_size(1),
4676 ),
4677 StackChild::Entry(StackEntry::new(dock).shrink(1)),
4678 ]);
4679
4680 tui.set_layout_root(Some(Arc::new(root)));
4681 tui.set_focus(Some(editor.clone()));
4682 editor.set_focused(true);
4683
4684 let ctx = CommandContext {
4696 chat: chat_container.clone(),
4697 tui: tui.clone(),
4698 tx: tx.clone(),
4699 state: state.clone(),
4700 editor: editor.clone(),
4701 editor_container: editor_container.clone(),
4702 lane: lane.clone(),
4703 model_catalog: model_catalog_arc.clone(),
4704 lane_model_id: lane_model_id.clone(),
4705 cwd: cwd.clone(),
4706 package_resources: package_resources.clone(),
4707 resources: resources_arc.clone(),
4708 reload_context: Arc::new(reload_context.clone()),
4709 };
4710
4711 if let Some(js) = &reload_context.js_extension_session {
4712 let bridge = js_dialog_bridge.clone();
4713 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
4714 bridge.handle_runtime_request(action, args)
4715 })) {
4716 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
4717 }
4718 }
4719
4720 let ctx_for_cb = ctx.clone();
4721 let registry_for_cb = registry.clone();
4722 editor.on_submit(Arc::new(move |text: &str| {
4723 let text = text.trim();
4724 if text.is_empty() {
4725 return;
4726 }
4727
4728 if text.starts_with('/') {
4729 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
4730 return;
4731 }
4732
4733 let run_status = *ctx_for_cb.state.status.lock().unwrap();
4734 if run_status != RunStatus::Idle {
4735 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
4736 let aborting = run_status == RunStatus::Aborting;
4737 let lane = ctx_for_cb.lane.clone();
4738 let chat = ctx_for_cb.chat.clone();
4739 let tui = ctx_for_cb.tui.clone();
4740 tokio::spawn(async move {
4741 let result = if aborting {
4746 lane.next_run(message).await
4747 } else {
4748 lane.steer(message).await
4749 };
4750 if let Err(error) = result {
4751 add_error_message(&chat, &format!("Could not queue message: {error}"));
4752 tui.request_render(false);
4753 }
4754 });
4755 add_note_message(
4756 &ctx_for_cb.chat,
4757 &format!("Queued steering message: {text}"),
4758 );
4759 ctx_for_cb.tui.request_render(false);
4760 return;
4761 }
4762
4763 if !ctx_for_cb.state.try_start_working() {
4764 return;
4765 }
4766
4767 add_user_message(&ctx_for_cb.chat, text);
4768 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
4771 scroll.scroll_to_end();
4772 }
4773 ctx_for_cb.tui.request_render(false);
4774 push_history(&ctx_for_cb.state, text);
4777 if ctx_for_cb
4778 .tx
4779 .send(TuiMessage::UserInput(text.to_string()))
4780 .is_err()
4781 {
4782 ctx_for_cb.state.set_status(RunStatus::Idle);
4783 }
4784 }));
4785
4786 tui.start_readerless();
4787
4788 let mut update_check_handles = Vec::new();
4792 let rpi_chat = chat_container.clone();
4793 let rpi_tui = tui.clone();
4794 update_check_handles.push(tokio::spawn(async move {
4795 let report = crate::updates::check_rpi_startup().await;
4796 if !report.is_empty() {
4797 add_update_notices(&rpi_chat, &report);
4798 }
4799 rpi_tui.request_render(false);
4800 }));
4801
4802 if update_checks_enabled {
4803 let update_args = args.clone();
4804 let update_cwd = cwd.clone();
4805 let update_project_trusted = project_trusted;
4806 let chat = chat_container.clone();
4807 let tui = tui.clone();
4808 update_check_handles.push(tokio::spawn(async move {
4809 let resources = crate::session::package_resources_for_update_check(
4810 &update_args,
4811 &update_cwd,
4812 update_project_trusted,
4813 );
4814 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
4815 {
4816 Ok(npm_command) => {
4817 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
4818 Some(&resources),
4819 &npm_command,
4820 update_project_trusted.then_some(update_cwd.as_path()),
4821 )
4822 .await
4823 }
4824 Err(error) => {
4825 add_error_message(
4826 &chat,
4827 &format!("npm package update checks disabled: {error}"),
4828 );
4829 let git_resources = git_only_update_resources(resources);
4830 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
4831 }
4832 };
4833 if !report.is_empty() {
4834 add_update_notices(&chat, &report);
4835 }
4836 tui.request_render(false);
4837 }));
4838 }
4839
4840 let drain_handle = if let Some(rx) = event_rx {
4842 let tui_drain = tui.clone();
4843 let state_drain = state.clone();
4844 let chat_drain = chat_container.clone();
4845 Some(tokio::spawn(async move {
4846 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
4847 }))
4848 } else {
4849 None
4850 };
4851
4852 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
4862 reload_context.mailbox.install(reload_sig_tx);
4863 let reload_tx = tx.clone();
4864 let reload_bridge_handle = tokio::spawn(async move {
4865 while reload_sig_rx.recv().await.is_some() {
4866 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
4867 break; }
4869 }
4870 });
4871
4872 let tui_tick = tui.clone();
4877 let state_tick = state.clone();
4878 let tick_handle = tokio::spawn(async move {
4879 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
4882 interval.tick().await; loop {
4884 interval.tick().await;
4885 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
4886 if working {
4887 if state_tick.bash_components.lock().unwrap().is_empty() {
4888 tui_tick.request_render_reusing_scroll_content();
4892 } else {
4893 tui_tick.request_render(false);
4895 }
4896 }
4897 }
4898 });
4899
4900 let running = Arc::new(std::sync::Mutex::new(true));
4902 let running_key = running.clone();
4903 let tx_for_key = tx.clone();
4904 let tui_for_key = tui.clone();
4905 let editor_for_key = editor.clone();
4906 let scroll_for_key = scroll_view.clone();
4907 let lane_for_key = lane.clone();
4908 let state_for_key = state.clone();
4909 let model_catalog_for_key = model_catalog_arc.clone();
4910 let js_for_key = reload_context.js_extension_session.clone();
4911 let js_dialog_for_key = js_dialog_bridge.clone();
4912 let ctx_for_key = ctx.clone();
4916 let registry_for_key = registry.clone();
4917 let keybindings_for_key = keybindings.clone();
4918 let double_escape_action = crate::settings::load_settings()
4919 .ok()
4920 .and_then(|settings| settings.double_escape_action)
4921 .unwrap_or_else(|| "tree".to_string())
4922 .to_ascii_lowercase();
4923
4924 let key_handle = tokio::task::spawn_blocking(move || {
4925 let mut last_escape_time = None;
4926 loop {
4927 if !*running_key.lock().unwrap() {
4928 break;
4929 }
4930 if !state_for_key.selector_open()
4931 && !state_for_key.extension_dialog_open()
4932 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
4933 {
4934 if let Some(request) = js_dialog_for_key.take_pending() {
4935 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
4936 }
4937 }
4938 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
4939 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
4942 Ok(true) => {}
4943 Ok(false) => continue,
4944 Err(_) => {
4945 state_for_key.cancel_js_preparation();
4946 let _ = tx_for_key.send(TuiMessage::Exit);
4947 break;
4948 }
4949 }
4950 let Ok(ev) = crossterm::event::read() else {
4951 state_for_key.cancel_js_preparation();
4952 let _ = tx_for_key.send(TuiMessage::Exit);
4953 break;
4954 };
4955 if let Event::Resize(_cols, _rows) = ev {
4960 tui_for_key.refresh_size();
4961 if let Some(js) = &js_for_key {
4962 if js.custom_active() {
4963 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
4964 tui_for_key.request_render(false);
4965 }
4966 }
4967 continue;
4968 }
4969 if let Event::Mouse(m) = ev {
4973 use crossterm::event::MouseEventKind;
4974 match m.kind {
4975 MouseEventKind::ScrollUp => {
4976 let delta = -MOUSE_WHEEL_SCROLL_LINES;
4977 if scroll_for_key.scroll_by(delta) != delta {
4978 tui_for_key.request_render_reusing_scroll_content();
4979 }
4980 }
4981 MouseEventKind::ScrollDown => {
4982 let delta = MOUSE_WHEEL_SCROLL_LINES;
4983 if scroll_for_key.scroll_by(delta) != delta {
4984 tui_for_key.request_render_reusing_scroll_content();
4985 }
4986 }
4987 _ => {}
4988 }
4989 continue;
4990 }
4991 let Event::Key(key) = ev else {
4992 if let Event::Paste(text) = ev {
4993 let candidate = text.trim().trim_matches(['\"', '\'']);
4994 let path = std::path::PathBuf::from(candidate);
4995 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
4996 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
4997 add_image_preview(&state_for_key.chat_container, &image);
4998 state_for_key.queue_image(image);
4999 add_note_message(
5000 &state_for_key.chat_container,
5001 "Dropped image attached to the next prompt.",
5002 );
5003 tui_for_key.request_render(false);
5004 continue;
5005 }
5006 }
5007 editor_for_key.insert(&text);
5008 refresh_autocomplete(&state_for_key, &editor_for_key);
5009 tui_for_key.request_render_reusing_scroll_content();
5010 }
5011 continue;
5012 };
5013 if !should_dispatch_key(key.kind) {
5018 continue;
5019 }
5020
5021 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5027 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5028 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5029 if prompt_abort && state_for_key.cancel_js_preparation() {
5030 state_for_key.set_status(RunStatus::Aborting);
5031 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5032 close_extension_editor(
5033 &state_for_key,
5034 &ctx_for_key.editor_container,
5035 &editor_for_key,
5036 &tui_for_key,
5037 );
5038 }
5039 js_dialog_for_key.cancel_open_requests();
5040 tui_for_key.set_render_suspended(false);
5041 tui_for_key.request_render(false);
5042 continue;
5043 }
5044
5045 if let Some(js) = &js_for_key {
5046 if js.custom_active() {
5047 let visible = js.custom_accepts_input();
5048 let data = key_event_to_input(key);
5049 if !data.is_empty() {
5050 if visible {
5056 let _ = js.send_custom_input(&data);
5057 continue;
5058 }
5059 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5060 if consumed {
5064 tui_for_key.request_render_reusing_scroll_content();
5065 continue;
5066 }
5067 }
5068 }
5069 }
5070
5071 if key.modifiers == KeyModifiers::CONTROL
5074 && key.code == KeyCode::Char('c')
5075 && state_for_key.selector_open()
5076 {
5077 let selector = state_for_key
5078 .active_selector
5079 .lock()
5080 .unwrap()
5081 .clone()
5082 .expect("selector_open guaranteed Some")
5083 .0;
5084 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5085 tui_for_key.request_render_reusing_scroll_content();
5086 continue;
5087 }
5088
5089 if state_for_key.extension_dialog_open() {
5093 let cancel = key.code == KeyCode::Esc
5094 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5095 if cancel {
5096 if !run_extension_cancel(&state_for_key) {
5097 close_extension_editor(
5098 &state_for_key,
5099 &ctx_for_key.editor_container,
5100 &editor_for_key,
5101 &tui_for_key,
5102 );
5103 }
5104 } else if let Some(extension_editor) = state_for_key
5105 .active_extension_editor
5106 .lock()
5107 .unwrap()
5108 .clone()
5109 {
5110 extension_editor.handle_key(key);
5111 } else if let Some(extension_input) =
5112 state_for_key.active_extension_input.lock().unwrap().clone()
5113 {
5114 extension_input.handle_key(key);
5115 }
5116 tui_for_key.request_render_reusing_scroll_content();
5117 continue;
5118 }
5119
5120 if keybinding_matches(
5125 &keybindings_for_key,
5126 &key,
5127 rpi_tui::keybindings::keys::CLEAR,
5128 ) {
5129 if !state_for_key.selector_open() && editor_for_key.has_selection() {
5130 editor_for_key.copy_selection();
5131 continue;
5132 }
5133 let status = *state_for_key.status.lock().unwrap();
5134 match status {
5135 RunStatus::Working => {
5136 state_for_key.set_status(RunStatus::Aborting);
5137 let lane = lane_for_key.clone();
5138 tokio::spawn(async move {
5139 let _ = lane.abort().await;
5140 });
5141 }
5142 RunStatus::Aborting => {}
5146 RunStatus::Idle => {
5147 let _ = tx_for_key.send(TuiMessage::Exit);
5148 }
5149 }
5150 continue;
5151 }
5152
5153 if state_for_key.selector_open() {
5158 if key.code == KeyCode::Esc {
5164 let (selector, _kind) = state_for_key
5165 .active_selector
5166 .lock()
5167 .unwrap()
5168 .clone()
5169 .expect("selector_open guaranteed Some");
5170 selector.handle_key(key);
5171 continue;
5172 }
5173 let (selector, _kind) = state_for_key
5174 .active_selector
5175 .lock()
5176 .unwrap()
5177 .clone()
5178 .expect("selector_open guaranteed Some");
5179 selector.handle_key(key);
5180 tui_for_key.request_render_reusing_scroll_content();
5181 continue;
5182 }
5183
5184 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5189 let status = *state_for_key.status.lock().unwrap();
5190 match status {
5191 RunStatus::Working => {
5192 state_for_key.set_status(RunStatus::Aborting);
5193 let lane = lane_for_key.clone();
5194 tokio::spawn(async move {
5195 let _ = lane.abort().await;
5196 });
5197 continue;
5198 }
5199 RunStatus::Aborting => continue,
5200 RunStatus::Idle => {}
5201 }
5202 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5203 editor_for_key.handle_key(key);
5205 refresh_autocomplete(&state_for_key, &editor_for_key);
5206 tui_for_key.request_render_reusing_scroll_content();
5207 continue;
5208 }
5209 let _ = tx_for_key.send(TuiMessage::Exit);
5210 continue;
5211 }
5212
5213 if keybinding_matches(
5218 &keybindings_for_key,
5219 &key,
5220 rpi_tui::keybindings::keys::INTERRUPT,
5221 ) {
5222 let status = *state_for_key.status.lock().unwrap();
5223 if status == RunStatus::Working {
5224 state_for_key.set_status(RunStatus::Aborting);
5225 let lane = lane_for_key.clone();
5226 tokio::spawn(async move {
5227 let _ = lane.abort().await;
5228 });
5229 continue;
5230 }
5231 if status == RunStatus::Idle
5232 && editor_for_key.get_text().trim().is_empty()
5233 && double_escape_action != "none"
5234 {
5235 let now = std::time::Instant::now();
5236 if double_escape_trigger(last_escape_time, now) {
5237 last_escape_time = None;
5238 match double_escape_action.as_str() {
5239 "tree" => {
5240 let _ = tx_for_key.send(TuiMessage::OpenTree);
5241 }
5242 "fork" => {
5243 let _ = tx_for_key.send(TuiMessage::ForkSession);
5244 }
5245 _ => {}
5246 }
5247 } else {
5248 last_escape_time = Some(now);
5249 }
5250 }
5251 continue;
5252 }
5253
5254 if keybinding_matches(
5257 &keybindings_for_key,
5258 &key,
5259 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5260 ) {
5261 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5262 continue;
5263 }
5264
5265 if keybinding_matches(
5268 &keybindings_for_key,
5269 &key,
5270 rpi_tui::keybindings::keys::TOOLS_EXPAND,
5271 ) {
5272 state_for_key.toggle_tool_outputs();
5273 tui_for_key.request_render(false);
5274 continue;
5275 }
5276
5277 if keybinding_matches(
5279 &keybindings_for_key,
5280 &key,
5281 rpi_tui::keybindings::keys::THINKING_TOGGLE,
5282 ) {
5283 state_for_key.toggle_thinking();
5284 tui_for_key.request_render(false);
5285 continue;
5286 }
5287
5288 if keybinding_matches(
5294 &keybindings_for_key,
5295 &key,
5296 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5297 ) {
5298 let current = state_for_key.current_model_id();
5299 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
5302 if let Some(next) = cycle_next_model(&scope, ¤t) {
5303 state_for_key.set_current_model(&next);
5304 let lane = lane_for_key.clone();
5305 tokio::spawn(async move {
5306 let _ = lane.set_model(next).await;
5307 });
5308 tui_for_key.request_render_reusing_scroll_content();
5309 }
5310 continue;
5311 }
5312
5313 if keybinding_matches(
5316 &keybindings_for_key,
5317 &key,
5318 rpi_tui::keybindings::keys::THINKING_CYCLE,
5319 ) {
5320 let lane = lane_for_key.clone();
5321 let catalog = model_catalog_for_key.clone();
5322 let state = state_for_key.clone();
5323 tokio::spawn(async move {
5324 let Ok(current_model) = lane.get_model().await else {
5325 return;
5326 };
5327 let levels = catalog
5328 .iter()
5329 .find(|model| {
5330 model.provider == current_model.provider && model.id == current_model.id
5331 })
5332 .map(|model| model.supported_thinking_levels())
5333 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5334 if levels.is_empty() {
5335 return;
5336 }
5337 let current = lane
5338 .get_thinking_level()
5339 .await
5340 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
5341 let next = levels
5342 .iter()
5343 .position(|level| *level == current)
5344 .map(|index| levels[(index + 1) % levels.len()])
5345 .unwrap_or(levels[0]);
5346 if lane.set_thinking_level(next).await.is_ok() {
5347 state
5348 .footer
5349 .set_thinking_level(Some(thinking_level_name(next)));
5350 state.tui.as_ref().map(|tui| tui.request_render(false));
5351 }
5352 });
5353 continue;
5354 }
5355
5356 if keybinding_matches(
5360 &keybindings_for_key,
5361 &key,
5362 rpi_tui::keybindings::keys::PASTE_IMAGE,
5363 ) {
5364 match read_clipboard_image() {
5365 Ok(Some(image)) => {
5366 add_image_preview(&state_for_key.chat_container, &image);
5367 state_for_key.queue_image(image);
5368 add_note_message(
5369 &state_for_key.chat_container,
5370 "Clipboard image attached to the next prompt.",
5371 );
5372 tui_for_key.request_render(false);
5373 continue;
5374 }
5375 Ok(None) | Err(_) => {}
5376 }
5377 }
5378
5379 if keybinding_matches(
5383 &keybindings_for_key,
5384 &key,
5385 rpi_tui::keybindings::keys::MODEL_SELECT,
5386 ) {
5387 if let Some(cmd) = registry_for_key.find("/model") {
5388 cmd.execute(&ctx_for_key, "");
5389 }
5390 continue;
5391 }
5392
5393 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
5395 if accept_top_suggestion(&state_for_key, &editor_for_key) {
5396 tui_for_key.request_render_reusing_scroll_content();
5397 }
5398 continue;
5399 }
5400
5401 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
5405 let delta = -transcript_page_size(scroll_for_key.viewport_height());
5406 if scroll_for_key.scroll_by(delta) != delta {
5407 tui_for_key.request_render_reusing_scroll_content();
5408 }
5409 continue;
5410 }
5411 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
5412 let delta = transcript_page_size(scroll_for_key.viewport_height());
5413 if scroll_for_key.scroll_by(delta) != delta {
5414 tui_for_key.request_render_reusing_scroll_content();
5415 }
5416 continue;
5417 }
5418 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
5419 scroll_for_key.scroll_to_start();
5420 tui_for_key.request_render_reusing_scroll_content();
5421 continue;
5422 }
5423 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
5424 scroll_for_key.scroll_to_end();
5425 tui_for_key.request_render_reusing_scroll_content();
5426 continue;
5427 }
5428
5429 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
5438 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5439 if editor_for_key.get_text().is_empty() || browsing {
5440 navigate_history(&state_for_key, &editor_for_key, -1);
5441 tui_for_key.request_render_reusing_scroll_content();
5442 continue;
5443 }
5444 }
5445 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
5446 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5447 if editor_for_key.get_text().is_empty() || browsing {
5448 navigate_history(&state_for_key, &editor_for_key, 1);
5449 tui_for_key.request_render_reusing_scroll_content();
5450 continue;
5451 }
5452 }
5453
5454 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
5458 let prompt = editor_for_key.get_text().trim().to_string();
5459 if prompt.is_empty() {
5460 continue;
5461 }
5462 editor_for_key.clear();
5463 let status = *state_for_key.status.lock().unwrap();
5464 if status == RunStatus::Idle {
5465 if state_for_key.try_start_working() {
5466 add_user_message(&state_for_key.chat_container, &prompt);
5467 push_history(&state_for_key, &prompt);
5468 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
5469 }
5470 } else {
5471 add_note_message(
5472 &state_for_key.chat_container,
5473 &format!("Queued follow-up message: {prompt}"),
5474 );
5475 let message = AgentMessage::User(UserMessage::new(prompt, 0));
5476 let lane = lane_for_key.clone();
5477 let chat = state_for_key.chat_container.clone();
5478 let tui = tui_for_key.clone();
5479 tokio::spawn(async move {
5480 if let Err(error) = lane.follow_up(message).await {
5481 add_error_message(&chat, &format!("Could not queue message: {error}"));
5482 tui.request_render(false);
5483 }
5484 });
5485 }
5486 tui_for_key.request_render(false);
5487 continue;
5488 }
5489
5490 editor_for_key.handle_key(key);
5492 refresh_autocomplete(&state_for_key, &editor_for_key);
5493 tui_for_key.request_render_reusing_scroll_content();
5494 }
5495 });
5496
5497 let mut prompts: Vec<String> = Vec::new();
5499 if let Some(init) = initial {
5500 prompts.push(init);
5501 }
5502 for m in extra_messages {
5503 prompts.push(m.clone());
5504 }
5505 let mut images = initial_images;
5506 for prompt in prompts {
5507 if !*running.lock().unwrap() {
5508 break;
5509 }
5510 add_user_message(&chat_container, &prompt);
5511 tui.request_render(false);
5512 run_prompt_streaming(
5513 &lane,
5514 &prompt,
5515 &tui,
5516 &state,
5517 drain_handle.is_some(),
5518 reload_context.js_extension_session.as_ref(),
5519 &js_dialog_bridge,
5520 args,
5521 std::mem::take(&mut images),
5522 )
5523 .await;
5524 }
5525
5526 loop {
5528 if !*running.lock().unwrap() {
5529 break;
5530 }
5531 match rx.recv().await {
5532 Some(TuiMessage::UserInput(prompt)) => {
5533 editor.clear();
5538 let prompt_images = state.take_pending_images();
5539 if !prompt_images.is_empty() {
5540 add_note_message(
5541 &chat_container,
5542 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
5543 );
5544 }
5545 run_prompt_streaming(
5546 &lane,
5547 &prompt,
5548 &tui,
5549 &state,
5550 drain_handle.is_some(),
5551 reload_context.js_extension_session.as_ref(),
5552 &js_dialog_bridge,
5553 args,
5554 prompt_images,
5555 )
5556 .await;
5557 }
5558 Some(TuiMessage::ExternalEditorResult(result)) => {
5559 match result {
5560 Ok(text) => {
5561 let cursor = text.chars().count();
5562 editor.set_text(&text);
5563 editor.set_cursor(0, cursor);
5564 add_note_message(&chat_container, "Draft updated from external editor.");
5565 }
5566 Err(error) => add_error_message(&chat_container, &error),
5567 }
5568 tui.request_render(false);
5569 }
5570 Some(TuiMessage::OpenTree) => {
5571 if *state.status.lock().unwrap() != RunStatus::Idle {
5572 add_note_message(
5573 &chat_container,
5574 "Wait for the current run to finish before opening the tree.",
5575 );
5576 tui.request_render(false);
5577 } else {
5578 open_tree_selector(
5579 &harness,
5580 &state,
5581 &editor_container,
5582 &editor,
5583 &tui,
5584 &chat_container,
5585 &tx,
5586 )
5587 .await;
5588 }
5589 }
5590 Some(TuiMessage::NavigateTree(entry_id)) => {
5591 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
5592 Ok(result) => match result.outcome {
5593 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
5594 chat_container.clear();
5595 add_welcome_message(&chat_container);
5596 render_session_history(
5597 &harness,
5598 &chat_container,
5599 state.markdown_transformer(),
5600 Some(state.extension_session.clone()),
5601 )
5602 .await;
5603 add_note_message(
5604 &chat_container,
5605 "Moved to the selected session entry.",
5606 );
5607 }
5608 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
5609 add_error_message(&chat_container, &error.message);
5610 }
5611 _ => add_note_message(
5612 &chat_container,
5613 "The selected entry could not be opened.",
5614 ),
5615 },
5616 Err(error) => add_error_message(
5617 &chat_container,
5618 &format!("Could not navigate session tree: {error}"),
5619 ),
5620 }
5621 tui.request_render(false);
5622 }
5623 Some(TuiMessage::ClearChat) => {
5624 chat_container.clear();
5625 add_welcome_message(&chat_container);
5626 tui.request_render(false);
5627 }
5628 Some(TuiMessage::Compact) => {
5629 run_compact(&lane, &tui, &state).await;
5630 }
5631 Some(TuiMessage::Copy) => {
5632 copy_last_assistant(&state, &chat_container);
5633 tui.request_render(false);
5634 }
5635 Some(TuiMessage::Exit) => {
5636 *running.lock().unwrap() = false;
5637 break;
5638 }
5639 Some(TuiMessage::SwitchSession(id)) => {
5640 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
5641 tui.request_render(false);
5642 }
5643 Some(TuiMessage::ImportSession(path)) => {
5644 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
5645 tui.request_render(false);
5646 }
5647 Some(TuiMessage::ShareSession) => {
5648 share_session(&harness, &chat_container).await;
5649 tui.request_render(false);
5650 }
5651 Some(TuiMessage::SetSessionName(name)) => {
5652 let outcome = harness.session().set_name(Some(&name)).await;
5653 match outcome {
5654 Ok(_) => add_note_message(
5655 &chat_container,
5656 &format!("Session renamed to \"{name}\"."),
5657 ),
5658 Err(e) => add_error_message(
5659 &chat_container,
5660 &format!("Could not rename session: {e}"),
5661 ),
5662 }
5663 tui.request_render(false);
5664 }
5665 Some(TuiMessage::ExportSession) => {
5666 export_session(&harness, &chat_container, &cwd).await;
5667 tui.request_render(false);
5668 }
5669 Some(TuiMessage::ForkSession) => {
5670 fork_session(&harness, &cwd, &chat_container, &state).await;
5671 tui.request_render(false);
5672 }
5673 Some(TuiMessage::ReloadExtensions) => {
5674 let reload_ctx = ctx.reload_context.clone();
5681 add_note_message(&chat_container, "Reloading extensions + resources…");
5682 tui.request_render(false);
5683 let outcome =
5684 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
5685 let fresh_transformer = build_markdown_transformer(
5694 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
5695 );
5696 state.set_markdown_transformer_with_reinstall(fresh_transformer);
5697 if outcome.had_warnings {
5698 add_error_message(
5699 &chat_container,
5700 &format!(
5701 "{} (with warnings — see stderr for details).",
5702 outcome.summary
5703 ),
5704 );
5705 } else {
5706 add_note_message(&chat_container, &outcome.summary);
5707 }
5708 tui.request_render(false);
5709 }
5710 None => break,
5711 }
5712 }
5713
5714 js_dialog_bridge.cancel_all();
5718 *running.lock().unwrap() = false;
5719 for handle in update_check_handles {
5720 handle.abort();
5721 let _ = handle.await;
5722 }
5723 if let Some(js) = &reload_context.js_extension_session {
5727 js.shutdown();
5728 }
5729 let _ = key_handle.await;
5732 tick_handle.abort();
5733 if let Some(handle) = drain_handle {
5734 handle.abort();
5735 }
5736 reload_context.mailbox.clear();
5740 reload_bridge_handle.abort();
5741 tui.stop(Default::default());
5742 println!("\nGoodbye!");
5743 let _ = args;
5744
5745 0
5746}
5747
5748async fn ensure_js_runtime_before_prompt(
5758 js: Option<&crate::js_extensions::JsExtensionSession>,
5759 lane: &Arc<dyn AgentLane>,
5760 state: &Arc<TuiState>,
5761 dialog_bridge: &JsDialogBridge,
5762 args: &Args,
5763) -> bool {
5764 let Some(js) = js else {
5765 return true;
5766 };
5767 let cancellation = state.begin_js_preparation();
5768 let worker_cancellation = cancellation.clone();
5769 let js_for_start = js.clone();
5770 let mut worker = tokio::task::spawn_blocking(move || {
5771 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
5772 });
5773 let result = tokio::select! {
5774 result = &mut worker => result,
5775 _ = cancellation.cancelled() => {
5776 dialog_bridge.cancel_open_requests();
5777 let js_for_cancel = js.clone();
5778 let _ = tokio::task::spawn_blocking(move || {
5779 js_for_cancel.cancel_prompt_preparation();
5780 }).await;
5781 worker.await
5782 }
5783 };
5784 let was_cancelled = cancellation.is_cancelled();
5785 if was_cancelled {
5786 dialog_bridge.cancel_open_requests();
5787 let js_for_cancel = js.clone();
5788 let _ = tokio::task::spawn_blocking(move || {
5789 js_for_cancel.cancel_prompt_preparation();
5790 })
5791 .await;
5792 state.finish_js_preparation();
5793 dialog_bridge.reopen();
5794 return false;
5795 }
5796 state.finish_js_preparation();
5797 match result {
5798 Ok(Ok(())) => {
5799 if let Some(js_active) = js.active_tools() {
5803 let js_names = js.tool_names();
5804 let mut active = lane.get_active_tools().await.unwrap_or_default();
5805 active.retain(|name| {
5806 crate::session::tool_name_allowed(name, args)
5807 && !js_names.iter().any(|js_name| js_name == name)
5808 });
5809 active.extend(js_active.into_iter().filter(|name| {
5810 js_names.iter().any(|js_name| js_name == name)
5811 && crate::session::tool_name_allowed(name, args)
5812 }));
5813 active = crate::session::filter_active_tool_names(active, args);
5814 let _ = lane.set_active_tools(active).await;
5815 }
5816 }
5817 Ok(Err(error)) => {
5818 if args.verbose {
5819 eprintln!("warning: could not start JS extension runtime: {error}");
5820 }
5821 }
5822 Err(error) => {
5823 if args.verbose {
5824 eprintln!("warning: JS extension runtime worker failed: {error}");
5825 }
5826 }
5827 }
5828 true
5829}
5830
5831fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
5832 std::thread::spawn(move || {
5833 let file = match tempfile::Builder::new()
5834 .prefix("rpi-draft-")
5835 .suffix(".md")
5836 .tempfile()
5837 {
5838 Ok(file) => file,
5839 Err(error) => {
5840 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
5841 "Could not create editor file: {error}"
5842 ))));
5843 return;
5844 }
5845 };
5846 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
5847 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
5848 "Could not write editor file: {error}"
5849 ))));
5850 return;
5851 }
5852 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
5853 .ok()
5854 .filter(|value| !value.trim().is_empty())
5855 .or_else(|| std::env::var("VISUAL").ok())
5856 .or_else(|| std::env::var("EDITOR").ok())
5857 .unwrap_or_else(|| {
5858 if cfg!(windows) {
5859 "notepad".to_string()
5860 } else {
5861 "nano".to_string()
5862 }
5863 });
5864 let status = std::process::Command::new(&editor)
5865 .arg(file.path())
5866 .status();
5867 let result = match status {
5868 Ok(status) if status.success() => std::fs::read_to_string(file.path())
5869 .map_err(|error| format!("Could not read editor file: {error}")),
5870 Ok(status) => Err(format!("External editor exited with {status}")),
5871 Err(error) => Err(format!(
5872 "Could not launch external editor `{editor}`: {error}"
5873 )),
5874 };
5875 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
5876 });
5877}
5878
5879fn reconcile_streamed_assistant_completion(
5882 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
5883 last_assistant_text: &Mutex<String>,
5884 final_message: Option<&AssistantMessage>,
5885) {
5886 let final_blocks = final_message.map(assistant_blocks);
5887 let final_text = final_message.map(assistant_text);
5888 let component = current_assistant.lock().unwrap().take();
5889
5890 if let Some(component) = component {
5891 if let Some(blocks) = final_blocks.as_deref() {
5892 component.update_blocks(blocks);
5893 }
5894 component.set_streaming(false);
5895 }
5896
5897 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
5898 *last_assistant_text.lock().unwrap() = text;
5899 }
5900}
5901
5902async fn run_prompt_streaming(
5907 lane: &Arc<dyn AgentLane>,
5908 prompt: &str,
5909 tui: &Arc<TuiAltScreen>,
5910 state: &Arc<TuiState>,
5911 streaming: bool,
5912 js: Option<&crate::js_extensions::JsExtensionSession>,
5913 dialog_bridge: &JsDialogBridge,
5914 args: &Args,
5915 images: Vec<rpi_ai::types::ImageContent>,
5916) {
5917 state.set_status(RunStatus::Working);
5926 tui.request_render(false);
5927 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
5928 state.set_status(RunStatus::Idle);
5929 tui.request_render(false);
5930 return;
5931 }
5932
5933 let outcome = lane.prompt_text(prompt, images).await;
5934
5935 if streaming {
5936 let final_message = match &outcome {
5941 Ok(result) => match &result.outcome {
5942 HarnessRunOutcome::Completed { final_message, .. }
5943 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
5944 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
5945 HarnessRunOutcome::Suspended { .. } => None,
5946 },
5947 Err(_) => None,
5948 };
5949 reconcile_streamed_assistant_completion(
5950 &state.current_assistant,
5951 &state.last_assistant_text,
5952 final_message,
5953 );
5954 }
5955
5956 state.set_status(RunStatus::Idle);
5957
5958 match outcome {
5959 Ok(result) => match &result.outcome {
5960 HarnessRunOutcome::Failed {
5961 error,
5962 final_message,
5963 ..
5964 } => {
5965 let already_rendered = streaming && final_message.is_some();
5973 if !already_rendered {
5974 let msg = final_message
5975 .as_ref()
5976 .and_then(|m| m.error_message.clone())
5977 .unwrap_or_else(|| format!("{error:?}"));
5978 add_error_message(&state.chat_container, &msg);
5979 }
5980 }
5981 HarnessRunOutcome::Suspended { .. } => {
5982 add_error_message(
5983 &state.chat_container,
5984 "Run suspended (deferred) — resume is not supported in v1.",
5985 );
5986 }
5987 HarnessRunOutcome::Aborted { final_message, .. } => {
5988 if !streaming {
5991 add_error_message(&state.chat_container, "Request aborted.");
5992 let _ = final_message; }
5994 }
5995 HarnessRunOutcome::Completed { final_message, .. } => {
5996 if !streaming {
5997 let text = assistant_text(final_message);
5998 if !text.is_empty() {
5999 add_assistant_message_blocking(
6000 &state.chat_container,
6001 &text,
6002 state.markdown_transformer(),
6003 );
6004 *state.last_assistant_text.lock().unwrap() = text;
6005 }
6006 }
6007 }
6008 },
6009 Err(e) => {
6010 add_error_message(&state.chat_container, &e.to_string());
6011 }
6012 }
6013
6014 tui.request_render(false);
6015}
6016
6017async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6022 state.set_status(RunStatus::Working);
6023 tui.request_render(false);
6024 match lane.compact(None).await {
6025 Ok(_) => {
6026 add_note_message(&state.chat_container, "Conversation compacted.");
6027 }
6028 Err(e) => {
6029 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6030 }
6031 }
6032 state.set_status(RunStatus::Idle);
6033 tui.request_render(false);
6034}
6035
6036fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6040 let text = state.last_assistant_text.lock().unwrap().clone();
6041 if text.is_empty() {
6042 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6043 return;
6044 }
6045 if copy_to_clipboard(&text) {
6046 add_note_message(chat, "Copied last reply to the clipboard.");
6047 } else {
6048 let preview: String = text.chars().take(200).collect();
6051 add_note_message(
6052 chat,
6053 &format!(
6054 "Clipboard unavailable. Last reply: {preview}{}",
6055 if text.chars().count() > 200 {
6056 "…"
6057 } else {
6058 ""
6059 }
6060 ),
6061 );
6062 }
6063}
6064
6065#[cfg(feature = "clipboard")]
6068fn copy_to_clipboard(text: &str) -> bool {
6069 match arboard::Clipboard::new() {
6070 Ok(mut cb) => cb.set_text(text).is_ok(),
6071 Err(_) => false,
6072 }
6073}
6074
6075#[cfg(not(feature = "clipboard"))]
6076fn copy_to_clipboard(_text: &str) -> bool {
6077 false
6078}
6079
6080#[cfg(feature = "clipboard")]
6084fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6085 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6086 let image = match clipboard.get_image() {
6087 Ok(image) => image,
6088 Err(_) => return Ok(None),
6089 };
6090 let width =
6091 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6092 let height =
6093 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6094 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6095 return Err("clipboard image dimensions are outside the supported range".into());
6096 }
6097 let mut bytes = Vec::new();
6098 {
6099 let mut encoder = png::Encoder::new(&mut bytes, width, height);
6100 encoder.set_color(png::ColorType::Rgba);
6101 encoder.set_depth(png::BitDepth::Eight);
6102 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6103 writer
6104 .write_image_data(&image.bytes)
6105 .map_err(|e| e.to_string())?;
6106 }
6107 Ok(Some(rpi_ai::types::ImageContent {
6108 kind: rpi_ai::types::ImageContentType,
6109 data: base64::engine::general_purpose::STANDARD.encode(bytes),
6110 mime_type: "image/png".into(),
6111 }))
6112}
6113
6114fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6115 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6116 let mut options = ImageOptions::default();
6117 options.width = Some(48);
6118 options.alt_text = Some("Attached image".into());
6119 chat.add_child(Arc::new(Image::from_data(bytes, options)));
6120 chat.add_child(Arc::new(Spacer::new(1)));
6121 }
6122}
6123
6124#[cfg(not(feature = "clipboard"))]
6125fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6126 Ok(None)
6127}
6128
6129fn add_assistant_message_blocking(
6136 container: &Arc<Container>,
6137 text: &str,
6138 transformer: Option<MarkdownTransformer>,
6139) {
6140 if text.is_empty() {
6141 return;
6142 }
6143 let msg = Arc::new(AssistantMessageComponent::new(
6144 AssistantMessageOptions::default(),
6145 ));
6146 if let Some(t) = &transformer {
6147 msg.set_markdown_transformer(Some(t.clone()));
6148 }
6149 msg.update_text(text);
6150 container.add_child(msg);
6151 container.add_child(Arc::new(Spacer::new(1)));
6152}
6153
6154async fn drain_agent_events(
6162 mut rx: broadcast::Receiver<AgentEvent>,
6163 tui: Arc<TuiAltScreen>,
6164 state: Arc<TuiState>,
6165 chat: Arc<Container>,
6166) {
6167 loop {
6168 match rx.recv().await {
6169 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6170 Err(broadcast::error::RecvError::Lagged(_)) => {
6171 continue;
6174 }
6175 Err(broadcast::error::RecvError::Closed) => break,
6176 }
6177 }
6178}
6179
6180async fn handle_agent_event(
6183 event: AgentEvent,
6184 tui: &Arc<TuiAltScreen>,
6185 state: &Arc<TuiState>,
6186 chat: &Arc<Container>,
6187) {
6188 match event {
6189 AgentEvent::AgentStart => {
6190 state.set_status(RunStatus::Working);
6191 tui.request_render(false);
6192 }
6193
6194 AgentEvent::AgentEnd { .. } => {
6195 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6197 comp.set_streaming(false);
6198 }
6199 state.set_status(RunStatus::Idle);
6200 tui.request_render(false);
6201 }
6202
6203 AgentEvent::RetryScheduled {
6204 attempt,
6205 max_retries,
6206 delay_ms,
6207 ..
6208 } => {
6209 state.show_retry(attempt, max_retries, delay_ms);
6210 tui.request_render(false);
6211 }
6212
6213 AgentEvent::TurnStart => {
6214 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6217 comp.set_streaming(false);
6218 }
6219 }
6220
6221 AgentEvent::TurnEnd {
6222 message,
6223 tool_results,
6224 } => {
6225 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6227 if let AgentMessage::Assistant(a) = &message {
6228 comp.update_blocks(&assistant_blocks(a));
6229 }
6230 comp.set_streaming(false);
6231 }
6232 let tools = state.tool_components.lock().unwrap();
6237 for tr in &tool_results {
6238 if tools.contains_key(&tr.tool_call_id) {
6239 let _ = tr;
6242 }
6243 }
6244 drop(tools);
6245 tui.request_render(false);
6246 }
6247
6248 AgentEvent::MessageStart { message } => match message {
6249 AgentMessage::Assistant(a) => {
6250 let comp = Arc::new(AssistantMessageComponent::new(
6251 AssistantMessageOptions::default(),
6252 ));
6253 if let Some(t) = state.markdown_transformer() {
6258 comp.set_markdown_transformer(Some(t));
6259 }
6260 comp.set_hide_thinking(state.hide_thinking());
6261 comp.set_streaming(true);
6262 comp.update_blocks(&assistant_blocks(&a));
6265 chat.add_child(comp.clone());
6266 chat.add_child(Arc::new(Spacer::new(1)));
6269 *state.current_assistant.lock().unwrap() = Some(comp);
6270 tui.request_render(false);
6271 }
6272 AgentMessage::Custom(custom) => {
6273 let payload = serde_json::json!({
6274 "customType": custom.role,
6275 "content": custom.content,
6276 "details": custom.data,
6277 "expanded": false,
6278 "outputPad": 1,
6279 });
6280 if let Some(component) = extension_message_component(
6281 &state.extension_session,
6282 &custom.role,
6283 &payload,
6284 state.markdown_transformer(),
6285 ) {
6286 chat.add_child(component);
6287 chat.add_child(Arc::new(Spacer::new(1)));
6288 tui.request_render(false);
6289 } else {
6290 add_note_message(chat, &custom_message_fallback(&custom));
6291 tui.request_render(false);
6292 }
6293 }
6294 _ => {}
6297 },
6298
6299 AgentEvent::MessageUpdate {
6300 message,
6301 assistant_message_event,
6302 } => {
6303 if let AgentMessage::Assistant(a) = &message {
6304 let text = assistant_text(a);
6305 let mut saw_bash_tool_call = false;
6306 for c in &a.content {
6311 if let Content::ToolCall(tc) = c {
6312 if tc.name.trim().is_empty() {
6316 continue;
6317 }
6318 if tc.name == "bash" {
6319 let command = tc
6325 .arguments
6326 .get("command")
6327 .and_then(|v| v.as_str())
6328 .unwrap_or("");
6329 if command.trim().is_empty() {
6336 continue;
6337 }
6338 saw_bash_tool_call = true;
6339 let mut bash = state.bash_components.lock().unwrap();
6340 if !bash.contains_key(&tc.id) {
6341 let comp = Arc::new(BashExecutionComponent::new(command));
6342 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6343 chat.add_child(comp.clone());
6344 bash.insert(tc.id.clone(), comp);
6345 }
6346 } else {
6347 let mut tools = state.tool_components.lock().unwrap();
6348 if !tools.contains_key(&tc.id) {
6349 let comp = Arc::new(ToolExecutionComponent::new(
6350 &tc.name,
6351 &tc.arguments.to_string(),
6352 ));
6353 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6354 comp.set_running();
6355 chat.add_child(comp.clone());
6356 tools.insert(tc.id.clone(), comp);
6357 }
6358 }
6359 }
6360 }
6361 if saw_bash_tool_call {
6366 state.sync_working_loader_with_bash();
6367 }
6368 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6370 comp.update_blocks(&assistant_blocks(a));
6373 }
6374 *state.last_assistant_text.lock().unwrap() = text;
6375 tui.request_render(false);
6376 }
6377 }
6378
6379 AgentEvent::MessageEnd { message } => {
6380 if let AgentMessage::Assistant(a) = &message {
6381 let text = assistant_text(a);
6382 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6383 comp.update_blocks(&assistant_blocks(a));
6384 comp.set_streaming(false);
6385 }
6386 if !text.is_empty() {
6388 *state.last_assistant_text.lock().unwrap() = text;
6389 }
6390 let usage = &a.usage;
6396 let prev_input = *state.last_input_tokens.lock().unwrap();
6397 if prev_input > 0
6398 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6399 && usage.cache_read == 0
6400 {
6401 add_note_message(
6402 &state.chat_container,
6403 &format!(
6404 "Cache miss: {} tokens re-billed",
6405 format_tokens(usage.input)
6406 ),
6407 );
6408 }
6409 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6410 add_note_message(chat, &text);
6411 }
6412 if let Some(error) = assistant_error_text(a) {
6417 add_error_message(chat, &error);
6418 }
6419 *state.last_input_tokens.lock().unwrap() = usage.input;
6420 }
6421 tui.request_render(false);
6422 }
6423
6424 AgentEvent::ToolExecutionStart {
6425 tool_call_id,
6426 tool_name,
6427 args,
6428 } => {
6429 if tool_name.trim().is_empty() {
6432 return;
6433 }
6434 if tool_name == "bash" {
6435 let command = args
6440 .get("command")
6441 .and_then(|v| v.as_str())
6442 .unwrap_or("")
6443 .to_string();
6444 if command.trim().is_empty() {
6448 return;
6449 }
6450 let mut bash_map = state.bash_components.lock().unwrap();
6451 if let Some(existing) = bash_map.get(&tool_call_id) {
6452 existing.set_command(&command);
6457 } else {
6458 let comp = Arc::new(BashExecutionComponent::new(command));
6459 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6460 chat.add_child(comp.clone());
6461 bash_map.insert(tool_call_id.clone(), comp);
6462 }
6463 } else {
6464 let _comp = {
6465 let mut tools = state.tool_components.lock().unwrap();
6466 if let Some(existing) = tools.get(&tool_call_id) {
6467 existing.set_args(&args.to_string());
6468 existing.clone()
6469 } else {
6470 let comp =
6471 Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6472 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6473 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6478 comp.set_skill_name(skill);
6479 }
6480 comp.set_running();
6481 chat.add_child(comp.clone());
6482 tools.insert(tool_call_id.clone(), comp.clone());
6483 comp
6484 }
6485 };
6486 }
6487 state.sync_working_loader_with_bash();
6488 tui.request_render(false);
6489 }
6490
6491 AgentEvent::ToolExecutionUpdate {
6492 tool_call_id,
6493 tool_name,
6494 args,
6495 partial_result,
6496 } => {
6497 if tool_name.trim().is_empty() {
6498 return;
6499 }
6500 let partial_text = tool_result_text(&partial_result);
6501 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
6502 if tool_name == "bash" {
6503 let chunk = partial_text;
6508 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
6509 if has_partial_payload {
6510 bash.append_output(&chunk);
6511 }
6512 } else if has_partial_payload {
6513 }
6519 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
6520 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6521 comp.set_skill_name(skill);
6522 }
6523 if has_partial_payload {
6526 comp.set_result(&partial_text, false);
6527 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
6528 }
6529 } else if has_partial_payload {
6530 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6535 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6536 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6537 comp.set_skill_name(skill);
6538 }
6539 comp.set_running();
6540 comp.set_result(&partial_text, false);
6541 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
6542 chat.add_child(comp.clone());
6543 state
6544 .tool_components
6545 .lock()
6546 .unwrap()
6547 .insert(tool_call_id.clone(), comp.clone());
6548 }
6549 state.sync_working_loader_with_bash();
6550 tui.request_render(false);
6551 }
6552
6553 AgentEvent::ToolExecutionEnd {
6554 tool_call_id,
6555 tool_name,
6556 result,
6557 is_error,
6558 } => {
6559 if tool_name.trim().is_empty() {
6560 return;
6561 }
6562 if tool_name == "bash" {
6563 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
6564 if let Some(bash) = bash {
6565 finalize_bash(&bash, &result, is_error);
6566 } else {
6567 let command = result
6570 .details
6571 .get("command")
6572 .and_then(|v| v.as_str())
6573 .unwrap_or("")
6574 .to_string();
6575 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
6576 state.sync_working_loader_with_bash();
6577 tui.request_render(false);
6578 return;
6579 }
6580 let comp = Arc::new(BashExecutionComponent::new(command));
6581 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6582 comp.append_output(&tool_result_text(&result));
6583 finalize_bash(&comp, &result, is_error);
6584 chat.add_child(comp);
6585 }
6586 } else {
6587 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
6588 if let Some(comp) = comp {
6589 comp.set_result(&tool_result_text(&result), is_error);
6590 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6591 } else {
6592 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
6595 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6596 comp.set_result(&tool_result_text(&result), is_error);
6597 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6598 chat.add_child(comp.clone());
6599 }
6600 }
6601 state.sync_working_loader_with_bash();
6602 tui.request_render(false);
6603 }
6604 }
6605}
6606
6607fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
6611 let fallback = match message.stop_reason {
6612 rpi_ai::StopReason::Error => "Provider request failed.",
6613 rpi_ai::StopReason::Aborted => "Request aborted.",
6614 _ => return None,
6615 };
6616 Some(
6617 message
6618 .error_message
6619 .as_deref()
6620 .filter(|text| !text.trim().is_empty())
6621 .unwrap_or(fallback)
6622 .to_string(),
6623 )
6624}
6625
6626fn finalize_bash(
6630 comp: &Arc<BashExecutionComponent>,
6631 result: &rpi_agent::AgentToolResult,
6632 is_error: bool,
6633) {
6634 let exit_code = if is_error { Some(1) } else { Some(0) };
6637 let truncated = result
6638 .details
6639 .get("truncation")
6640 .and_then(|t| t.get("truncated"))
6641 .and_then(|v| v.as_bool())
6642 .unwrap_or(false);
6643 let full_output_path = result
6644 .details
6645 .get("full_output_path")
6646 .and_then(|v| v.as_str())
6647 .map(|s| s.to_string());
6648 let truncation = BashTruncation {
6649 truncated,
6650 full_output_path,
6651 };
6652 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
6654}
6655
6656fn apply_edit_diff(
6661 comp: &Arc<ToolExecutionComponent>,
6662 tool_name: &str,
6663 details: &serde_json::Value,
6664 tui: &Arc<TuiAltScreen>,
6665) {
6666 if tool_name != "edit" {
6667 return;
6668 }
6669 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
6670 return;
6671 };
6672 if diff_text.is_empty() {
6673 return;
6674 }
6675 let width = tui.width();
6676 let lines = render_diff(diff_text, width);
6677 comp.set_diff(lines);
6678}
6679
6680fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
6685 if tool_name != "read" {
6686 return None;
6687 }
6688 let path = args.get("path").and_then(|value| value.as_str())?;
6689 let normalized = path.replace('\\', "/");
6690 let file_name = normalized.rsplit('/').next()?;
6691 if !file_name.eq_ignore_ascii_case("SKILL.md") {
6692 return None;
6693 }
6694 normalized
6695 .trim_end_matches('/')
6696 .rsplit('/')
6697 .nth(1)
6698 .filter(|name| !name.is_empty())
6699 .map(str::to_string)
6700}
6701
6702fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
6705 use rpi_agent::TextContentOrImage;
6706 let mut parts: Vec<String> = Vec::new();
6707 for c in &result.content {
6708 if let TextContentOrImage::Text(t) = c {
6709 parts.push(t.text.clone());
6710 }
6711 }
6712 let joined = parts.join("\n");
6713 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
6715 if one_line.chars().count() > 200 {
6716 let truncated: String = one_line.chars().take(200).collect();
6717 format!("{truncated}…")
6718 } else {
6719 one_line
6720 }
6721}
6722
6723fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
6728 use rpi_agent::TextContentOrImage;
6729 let mut parts: Vec<String> = Vec::new();
6730 for c in &result.content {
6731 if let TextContentOrImage::Text(t) = c {
6732 parts.push(t.text.clone());
6733 }
6734 }
6735 parts.join("\n")
6736}
6737
6738fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
6742 !text.trim().is_empty() || !result.details.is_null()
6743}
6744
6745fn open_selector(
6753 state: &Arc<TuiState>,
6754 editor_container: &Arc<Container>,
6755 editor: &Arc<Editor>,
6756 tui: &Arc<TuiAltScreen>,
6757 list: Arc<SelectList>,
6758 kind: SelectorKind,
6759) {
6760 open_selector_with_view(
6761 state,
6762 editor_container,
6763 editor,
6764 tui,
6765 list.clone(),
6766 list,
6767 kind,
6768 );
6769}
6770
6771fn open_selector_with_view<C: Component + 'static>(
6775 state: &Arc<TuiState>,
6776 editor_container: &Arc<Container>,
6777 editor: &Arc<Editor>,
6778 tui: &Arc<TuiAltScreen>,
6779 list: Arc<SelectList>,
6780 view: Arc<C>,
6781 kind: SelectorKind,
6782) {
6783 editor.set_focused(false);
6785 state.autocomplete_container.clear();
6788 editor_container.clear();
6790 editor_container.add_child(view.clone());
6791 *state.active_selector.lock().unwrap() = Some((list, kind));
6792 let focused: Arc<dyn Component> = view;
6793 tui.set_focus(Some(focused));
6794 tui.request_render(false);
6795}
6796
6797fn close_selector(
6800 state: &Arc<TuiState>,
6801 editor_container: &Arc<Container>,
6802 editor: &Arc<Editor>,
6803 tui: &Arc<TuiAltScreen>,
6804) {
6805 editor_container.clear();
6806 editor_container.add_child(editor.clone());
6807 state.autocomplete_container.clear();
6808 editor.set_focused(true);
6809 *state.active_selector.lock().unwrap() = None;
6810 *state.active_extension_cancel.lock().unwrap() = None;
6811 tui.set_focus(Some(editor.clone()));
6812 tui.request_render(false);
6813}
6814
6815fn open_model_selector(
6821 state: &Arc<TuiState>,
6822 editor_container: &Arc<Container>,
6823 editor: &Arc<Editor>,
6824 tui: &Arc<TuiAltScreen>,
6825 catalog: &[rpi_ai::Model],
6826 lane: &Arc<dyn AgentLane>,
6827 lane_model_id: &str,
6828 chat: &Arc<Container>,
6829) {
6830 let items = model_selector_items(catalog, lane_model_id);
6831 if items.is_empty() {
6832 add_note_message(
6833 chat,
6834 "No models in the catalog. Use --model at startup to select one.",
6835 );
6836 tui.request_render(false);
6837 return;
6838 }
6839 let list = Arc::new(SelectList::new(items, 10));
6840
6841 let catalog_arc = catalog.to_vec();
6845 let state_sel = state.clone();
6846 let ec_sel = editor_container.clone();
6847 let editor_sel = editor.clone();
6848 let tui_sel = tui.clone();
6849 let chat_sel = chat.clone();
6850 let lane_sel = lane.clone();
6851 list.on_select(Arc::new(move |item| {
6852 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
6853 add_note_message(
6854 &chat_sel,
6855 &format!("Model {} not found in catalog.", item.label),
6856 );
6857 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6858 return;
6859 };
6860 state_sel.set_current_model(&model);
6861 let lane = lane_sel.clone();
6862 tokio::spawn(async move {
6863 let _ = lane.set_model(model).await;
6864 });
6865 add_note_message(
6866 &chat_sel,
6867 &format!(
6868 "Model set to {} — applies to the next message.",
6869 short_model_name(&item.value)
6870 ),
6871 );
6872 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6873 }));
6874 let state_cancel = state.clone();
6875 let ec_cancel = editor_container.clone();
6876 let editor_cancel = editor.clone();
6877 let tui_cancel = tui.clone();
6878 list.on_cancel(Arc::new(move || {
6879 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6880 }));
6881
6882 open_selector(
6883 state,
6884 editor_container,
6885 editor,
6886 tui,
6887 list,
6888 SelectorKind::Model,
6889 );
6890}
6891
6892fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
6897 if catalog.is_empty() {
6898 return None;
6899 }
6900 let idx = catalog
6901 .iter()
6902 .position(|m| m.id.eq_ignore_ascii_case(current_id));
6903 match idx {
6904 Some(i) => {
6905 let next = (i + 1) % catalog.len();
6906 Some(catalog[next].clone())
6907 }
6908 None => Some(catalog[0].clone()),
6909 }
6910}
6911
6912fn open_session_selector(
6918 state: &Arc<TuiState>,
6919 editor_container: &Arc<Container>,
6920 editor: &Arc<Editor>,
6921 tui: &Arc<TuiAltScreen>,
6922 cwd: &std::path::Path,
6923 tx: &mpsc::UnboundedSender<TuiMessage>,
6924) {
6925 let dir = crate::session::default_session_dir(cwd);
6926 let mut items: Vec<SelectItem> = Vec::new();
6927 if let Ok(entries) = std::fs::read_dir(&dir) {
6928 for entry in entries.flatten() {
6929 let path = entry.path();
6930 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
6931 continue;
6932 }
6933 let stem = path
6934 .file_stem()
6935 .and_then(|s| s.to_str())
6936 .unwrap_or("(unnamed)")
6937 .to_string();
6938 let display = path
6939 .file_name()
6940 .and_then(|s| s.to_str())
6941 .unwrap_or(&stem)
6942 .to_string();
6943 items.push(SelectItem::new(&stem, &display));
6944 }
6945 }
6946 if items.is_empty() {
6947 add_note_message(
6948 &state.chat_container,
6949 "No saved sessions found. Sessions are created automatically in interactive mode.",
6950 );
6951 tui.request_render(false);
6952 return;
6953 }
6954 let list = Arc::new(SelectList::new(items, 10));
6955
6956 let state_sel = state.clone();
6957 let ec_sel = editor_container.clone();
6958 let editor_sel = editor.clone();
6959 let tui_sel = tui.clone();
6960 let tx_sel = tx.clone();
6961 list.on_select(Arc::new(move |item| {
6962 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6966 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
6967 }));
6968 let state_cancel = state.clone();
6969 let ec_cancel = editor_container.clone();
6970 let editor_cancel = editor.clone();
6971 let tui_cancel = tui.clone();
6972 list.on_cancel(Arc::new(move || {
6973 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6974 }));
6975
6976 open_selector(
6977 state,
6978 editor_container,
6979 editor,
6980 tui,
6981 list,
6982 SelectorKind::Session,
6983 );
6984}
6985
6986fn custom_entry_display_text(
6987 custom_type: &str,
6988 data: Option<&serde_json::Value>,
6989) -> Option<String> {
6990 let data = data?;
6991 let text = data
6992 .get("summary")
6993 .or_else(|| data.get("text"))
6994 .or_else(|| data.get("output"))
6995 .and_then(|value| value.as_str())
6996 .filter(|value| !value.trim().is_empty())?;
6997 let label = match custom_type {
6998 "compactionSummary" => "Compaction summary",
6999 "branchSummary" => "Branch summary",
7000 "bashExecution" => "Command output",
7001 other => other,
7002 };
7003 Some(format!("{label}: {text}"))
7004}
7005
7006async fn open_tree_selector(
7010 harness: &AgentHarness,
7011 state: &Arc<TuiState>,
7012 editor_container: &Arc<Container>,
7013 editor: &Arc<Editor>,
7014 tui: &Arc<TuiAltScreen>,
7015 chat: &Arc<Container>,
7016 tx: &mpsc::UnboundedSender<TuiMessage>,
7017) {
7018 let entries = match harness
7019 .session()
7020 .view("main")
7021 .find_entries(&EntryQuery {
7022 order: Some(EntryOrder::OldestFirst),
7023 ..Default::default()
7024 })
7025 .await
7026 {
7027 Ok(entries) => entries,
7028 Err(error) => {
7029 add_error_message(chat, &format!("Could not read session tree: {error}"));
7030 tui.request_render(false);
7031 return;
7032 }
7033 };
7034 let current = harness.session().get_leaf_id().await.ok().flatten();
7035 let items: Vec<SelectItem> = entries
7036 .iter()
7037 .map(|entry| {
7038 let marker = if current.as_deref() == Some(entry.id()) {
7039 " (current)"
7040 } else {
7041 ""
7042 };
7043 SelectItem::new(
7044 entry.id(),
7045 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7046 )
7047 .with_description(&entry.id()[..entry.id().len().min(12)])
7048 })
7049 .collect();
7050 if items.is_empty() {
7051 add_note_message(chat, "The current session has no entries to navigate.");
7052 tui.request_render(false);
7053 return;
7054 }
7055 let list = Arc::new(SelectList::new(items, 12));
7056 let state_sel = state.clone();
7057 let ec_sel = editor_container.clone();
7058 let editor_sel = editor.clone();
7059 let tui_sel = tui.clone();
7060 let tx_sel = tx.clone();
7061 list.on_select(Arc::new(move |item| {
7062 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7063 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7064 }));
7065 let state_cancel = state.clone();
7066 let ec_cancel = editor_container.clone();
7067 let editor_cancel = editor.clone();
7068 let tui_cancel = tui.clone();
7069 list.on_cancel(Arc::new(move || {
7070 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7071 }));
7072 open_selector(
7073 state,
7074 editor_container,
7075 editor,
7076 tui,
7077 list,
7078 SelectorKind::Tree,
7079 );
7080}
7081
7082fn open_theme_selector(
7085 state: &Arc<TuiState>,
7086 editor_container: &Arc<Container>,
7087 editor: &Arc<Editor>,
7088 tui: &Arc<TuiAltScreen>,
7089 cwd: &std::path::Path,
7090 package_resources: &Arc<crate::packages::PackageResources>,
7091) {
7092 let mut items = vec![
7093 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7094 SelectItem::new("light", "Light").with_description("Light background"),
7095 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7096 ];
7097 if state.themes_enabled {
7098 for path in package_resources.theme_files() {
7099 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7100 items.push(SelectItem::new(name, name).with_description("Package theme"));
7101 }
7102 }
7103 }
7104 let list = Arc::new(SelectList::new(items, 10));
7105
7106 let state_sel = state.clone();
7107 let ec_sel = editor_container.clone();
7108 let editor_sel = editor.clone();
7109 let tui_sel = tui.clone();
7110 let chat_sel = state.chat_container.clone();
7111 let cwd_sel = cwd.to_path_buf();
7112 let package_resources_sel = package_resources.clone();
7113 list.on_select(Arc::new(move |item| {
7114 let preset = match item.value.as_str() {
7115 "light" => Some(ThemePreset::Light),
7116 "monochrome" => Some(ThemePreset::Monochrome),
7117 "dark" => Some(ThemePreset::Dark),
7118 name => {
7119 if state_sel.themes_enabled {
7120 if let Ok(custom) = crate::packages::load_theme_with_resources(
7121 &cwd_sel,
7122 name,
7123 &package_resources_sel,
7124 ) {
7125 rpi_tui::global_theme_manager().set(custom.clone());
7126 state_sel.theme_manager.set(custom);
7127 }
7128 }
7129 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7130 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7131 tui_sel.render_now(true);
7132 return;
7133 }
7134 };
7135 let Some(preset) = preset else { return };
7136 apply_theme_preset(preset);
7137 state_sel.theme_manager.apply_preset(preset);
7138 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7141 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7142 tui_sel.render_now(true);
7143 }));
7144 let state_cancel = state.clone();
7145 let ec_cancel = editor_container.clone();
7146 let editor_cancel = editor.clone();
7147 let tui_cancel = tui.clone();
7148 list.on_cancel(Arc::new(move || {
7149 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7150 }));
7151
7152 open_selector(
7153 state,
7154 editor_container,
7155 editor,
7156 tui,
7157 list,
7158 SelectorKind::Theme,
7159 );
7160}
7161
7162fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7169 use rpi_ai::types::ThinkingLevel::*;
7170 match level {
7171 Off => "Off — No reasoning",
7172 Minimal => "Minimal — Brief reasoning (~1k tokens)",
7173 Low => "Low — Light reasoning (~1k tokens)",
7174 Medium => "Medium — Moderate reasoning (~80% of max)",
7175 High => "High — Extensive reasoning (~95% of max)",
7176 Xhigh => "Xhigh — Near-maximal reasoning",
7177 Max => "Max — Maximum reasoning",
7178 }
7179}
7180
7181fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7184 use rpi_ai::types::ThinkingLevel::*;
7185 match level {
7186 Off => "off",
7187 Minimal => "minimal",
7188 Low => "low",
7189 Medium => "medium",
7190 High => "high",
7191 Xhigh => "xhigh",
7192 Max => "max",
7193 }
7194}
7195
7196fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7199 use rpi_ai::types::ThinkingLevel::*;
7200 match name.to_ascii_lowercase().as_str() {
7201 "off" => Some(Off),
7202 "minimal" => Some(Minimal),
7203 "low" => Some(Low),
7204 "medium" => Some(Medium),
7205 "high" => Some(High),
7206 "xhigh" => Some(Xhigh),
7207 "max" => Some(Max),
7208 _ => None,
7209 }
7210}
7211
7212fn open_thinking_selector(
7222 state: &Arc<TuiState>,
7223 editor_container: &Arc<Container>,
7224 editor: &Arc<Editor>,
7225 tui: &Arc<TuiAltScreen>,
7226 lane: &Arc<dyn AgentLane>,
7227 catalog: &[rpi_ai::Model],
7228 lane_model_id: &str,
7229 chat: &Arc<Container>,
7230) {
7231 let model = catalog
7234 .iter()
7235 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7236 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7237 .map(|m| m.supported_thinking_levels())
7238 .unwrap_or_else(|| {
7239 use rpi_ai::types::ThinkingLevel::*;
7240 vec![Off, Minimal, Low, Medium, High]
7241 });
7242 let mut items: Vec<SelectItem> = Vec::new();
7243 for lvl in &levels {
7244 let name = thinking_level_name(*lvl);
7245 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7246 }
7247 if items.is_empty() {
7248 add_note_message(chat, "This model has no supported thinking levels.");
7249 tui.request_render(false);
7250 return;
7251 }
7252 let list = Arc::new(SelectList::new(items, 10));
7253
7254 let state_sel = state.clone();
7255 let ec_sel = editor_container.clone();
7256 let editor_sel = editor.clone();
7257 let tui_sel = tui.clone();
7258 let chat_sel = chat.clone();
7259 let lane_sel = lane.clone();
7260 list.on_select(Arc::new(move |item| {
7261 let Some(level) = thinking_level_from_name(&item.value) else {
7262 add_note_message(
7263 &chat_sel,
7264 &format!("Unknown thinking level: {}.", item.label),
7265 );
7266 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7267 return;
7268 };
7269 let lane = lane_sel.clone();
7270 let footer_sel = state_sel.footer.clone();
7271 tokio::spawn(async move {
7272 let _ = lane.set_thinking_level(level).await;
7273 });
7274 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
7278 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
7279 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7280 }));
7281 let state_cancel = state.clone();
7282 let ec_cancel = editor_container.clone();
7283 let editor_cancel = editor.clone();
7284 let tui_cancel = tui.clone();
7285 list.on_cancel(Arc::new(move || {
7286 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7287 }));
7288
7289 open_selector(
7290 state,
7291 editor_container,
7292 editor,
7293 tui,
7294 list,
7295 SelectorKind::Thinking,
7296 );
7297}
7298
7299fn open_tools_selector(
7305 state: &Arc<TuiState>,
7306 editor_container: &Arc<Container>,
7307 editor: &Arc<Editor>,
7308 tui: &Arc<TuiAltScreen>,
7309 lane: &Arc<dyn AgentLane>,
7310 chat: &Arc<Container>,
7311) {
7312 let mut active = match tokio::runtime::Handle::try_current() {
7317 Ok(h) => h
7318 .block_on(async { lane.get_active_tools().await })
7319 .unwrap_or_default(),
7320 Err(_) => Vec::new(),
7321 };
7322 if active.is_empty() {
7327 active = crate::session::BUILTIN_TOOL_NAMES
7328 .iter()
7329 .map(|name| (*name).to_string())
7330 .collect();
7331 }
7332 let mut items: Vec<SelectItem> = Vec::new();
7333 for name in crate::session::BUILTIN_TOOL_NAMES {
7334 let on = active.iter().any(|a| a == name);
7335 let label = if on {
7336 format!("{name} (on)")
7337 } else {
7338 (*name).to_string()
7339 };
7340 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
7341 }
7342 let list = Arc::new(SelectList::new(items, 10));
7343
7344 let active_captured = active.clone();
7346 let state_sel = state.clone();
7347 let ec_sel = editor_container.clone();
7348 let editor_sel = editor.clone();
7349 let tui_sel = tui.clone();
7350 let chat_sel = chat.clone();
7351 let lane_sel = lane.clone();
7352 list.on_select(Arc::new(move |item| {
7353 let mut next = active_captured.clone();
7354 if let Some(pos) = next.iter().position(|a| a == &item.value) {
7355 next.remove(pos);
7356 } else {
7357 next.push(item.value.clone());
7358 }
7359 let on = next.iter().any(|a| a == &item.value);
7360 let lane = lane_sel.clone();
7361 let next_clone = next.clone();
7362 tokio::spawn(async move {
7363 let _ = lane.set_active_tools(next_clone).await;
7364 });
7365 let list_str = if next.is_empty() {
7366 "(none)".to_string()
7367 } else {
7368 next.join(", ")
7369 };
7370 add_note_message(
7371 &chat_sel,
7372 &format!(
7373 "{} {} — active tools: {}",
7374 item.value,
7375 if on { "enabled" } else { "disabled" },
7376 list_str
7377 ),
7378 );
7379 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7380 }));
7381 let state_cancel = state.clone();
7382 let ec_cancel = editor_container.clone();
7383 let editor_cancel = editor.clone();
7384 let tui_cancel = tui.clone();
7385 list.on_cancel(Arc::new(move || {
7386 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7387 }));
7388
7389 open_selector(
7390 state,
7391 editor_container,
7392 editor,
7393 tui,
7394 list,
7395 SelectorKind::Tools,
7396 );
7397}
7398
7399fn open_images_selector(
7403 state: &Arc<TuiState>,
7404 editor_container: &Arc<Container>,
7405 editor: &Arc<Editor>,
7406 tui: &Arc<TuiAltScreen>,
7407 chat: &Arc<Container>,
7408) {
7409 let current = *state.show_images.lock().unwrap();
7410 let items = vec![
7411 SelectItem::new("yes", "Yes").with_description(if current {
7412 "Inline images (current)"
7413 } else {
7414 "Inline images"
7415 }),
7416 SelectItem::new("no", "No").with_description(if current {
7417 "Placeholder only"
7418 } else {
7419 "Placeholder only (current)"
7420 }),
7421 ];
7422 let list = Arc::new(SelectList::new(items, 5));
7423
7424 let state_sel = state.clone();
7425 let ec_sel = editor_container.clone();
7426 let editor_sel = editor.clone();
7427 let tui_sel = tui.clone();
7428 let chat_sel = chat.clone();
7429 list.on_select(Arc::new(move |item| {
7430 let on = item.value == "yes";
7431 *state_sel.show_images.lock().unwrap() = on;
7432 add_note_message(
7433 &chat_sel,
7434 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
7435 );
7436 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7437 }));
7438 let state_cancel = state.clone();
7439 let ec_cancel = editor_container.clone();
7440 let editor_cancel = editor.clone();
7441 let tui_cancel = tui.clone();
7442 list.on_cancel(Arc::new(move || {
7443 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7444 }));
7445
7446 open_selector(
7447 state,
7448 editor_container,
7449 editor,
7450 tui,
7451 list,
7452 SelectorKind::Images,
7453 );
7454}
7455
7456fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
7464 let text = editor.get_text();
7465 let cursor = editor_cursor_offset(editor, &text);
7466 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
7467 render_autocomplete(state, suggestions);
7468}
7469
7470fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
7473 let (row, col) = editor.cursor_position();
7474 let mut offset = 0;
7475 for (index, line) in text.split('\n').enumerate() {
7476 if index == row {
7477 return (offset + col.min(line.len())).min(text.len());
7478 }
7479 offset = offset.saturating_add(line.len() + 1);
7480 }
7481 text.len()
7482}
7483
7484fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
7487 let offset = offset.min(text.len());
7488 let mut consumed = 0;
7489 for (row, line) in text.split('\n').enumerate() {
7490 let end = consumed + line.len();
7491 if offset <= end {
7492 editor.set_cursor(row, offset - consumed);
7493 return;
7494 }
7495 consumed = end + 1;
7496 }
7497 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
7498 editor.set_cursor(
7499 last_row,
7500 text.rsplit('\n').next().map(str::len).unwrap_or(0),
7501 );
7502}
7503
7504fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
7506 state.autocomplete_container.clear();
7507 let Some(sugg) = suggestions else {
7508 return;
7509 };
7510 if sugg.items.is_empty() {
7511 return;
7512 }
7513 let accent = state.theme_manager.get().colors.accent;
7516 let muted = state.theme_manager.get().colors.muted;
7517 for (i, item) in sugg
7518 .items
7519 .iter()
7520 .take(state.autocomplete_max_visible)
7521 .enumerate()
7522 {
7523 let prefix = if i == 0 { "→ " } else { " " };
7524 let label = item.display_text();
7525 let line = if i == 0 {
7526 format!(
7527 "{prefix}{} {}",
7528 accent.fg(label),
7529 muted.fg(item.description.as_deref().unwrap_or(""))
7530 )
7531 } else {
7532 format!(
7533 "{prefix}{} {}",
7534 muted.fg(label),
7535 muted.fg(item.description.as_deref().unwrap_or(""))
7536 )
7537 };
7538 state
7539 .autocomplete_container
7540 .add_child(Arc::new(Text::new(line, 1, 0)));
7541 }
7542}
7543
7544fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
7548 let text = editor.get_text();
7549 let cursor = editor_cursor_offset(editor, &text);
7550 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
7551 return false;
7552 };
7553 let Some(top) = sugg.items.first() else {
7554 return false;
7555 };
7556 let start = sugg.start.min(text.len());
7560 let end = sugg.end.min(text.len());
7561 let mut replaced = String::with_capacity(text.len() + top.text.len());
7562 replaced.push_str(&text[..start]);
7563 replaced.push_str(&top.text);
7564 replaced.push_str(&text[end..]);
7567 if top.insert_space && !replaced.ends_with('/') {
7568 replaced.push(' ');
7569 }
7570 let new_cursor = replaced.len().min(
7573 start
7574 + top.text.len()
7575 + if top.insert_space && !top.text.ends_with('/') {
7576 1
7577 } else {
7578 0
7579 },
7580 );
7581 editor.set_text(&replaced);
7582 set_editor_cursor_offset(editor, &replaced, new_cursor);
7583 state.autocomplete_container.clear();
7584 true
7585}
7586
7587fn add_welcome_message(container: &Arc<Container>) {
7593 add_welcome_message_with_capabilities(container, &[], &[]);
7594}
7595
7596fn add_welcome_message_with_capabilities(
7600 container: &Arc<Container>,
7601 active_tools: &[String],
7602 skills: &[String],
7603) {
7604 let c = current_theme().colors;
7605 let title = format!(
7609 "{} {}",
7610 c.accent.fg(&tui_bold("rpi")),
7611 c.muted.fg("interactive TUI")
7612 );
7613 container.add_child(Arc::new(Text::new(title, 1, 0)));
7614 container.add_child(Arc::new(Spacer::new(1)));
7615 container.add_child(Arc::new(Text::new(
7616 c.dim.fg("Type your message and press Enter to send."),
7617 1,
7618 0,
7619 )));
7620 let hint = c
7621 .dim
7622 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
7623 container.add_child(Arc::new(Text::new(hint, 1, 0)));
7624 container.add_child(Arc::new(Spacer::new(1)));
7625 container.add_child(Arc::new(Text::new(
7626 welcome_capability_line("Tools", active_tools),
7627 1,
7628 0,
7629 )));
7630 container.add_child(Arc::new(Text::new(
7631 welcome_capability_line("Skills", skills),
7632 1,
7633 0,
7634 )));
7635 container.add_child(Arc::new(DynamicBorder::new()));
7636}
7637
7638fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
7641 let colors = current_theme().colors;
7642 let group = Arc::new(Container::new());
7643
7644 for warning in &report.warnings {
7645 let body = format!(
7646 "{}\n{} {}{}",
7647 colors.error.fg(&warning.message),
7648 colors.muted.fg("Run"),
7649 colors.accent.fg(&warning.command),
7650 colors.muted.fg(" to retry.")
7651 );
7652 add_update_panel(&group, "Update Failed", &body, colors.error);
7653 }
7654
7655 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
7656 let body = format!(
7657 "{} {}{}",
7658 colors
7659 .muted
7660 .fg(&format!("New version {} is available. Run", notice.latest)),
7661 colors.accent.fg(¬ice.command),
7662 colors.muted.fg(".")
7663 );
7664 add_update_panel(&group, "Update Available", &body, colors.warning);
7665 }
7666
7667 let package_notices = report
7668 .notices
7669 .iter()
7670 .filter(|notice| notice.name != "rpi")
7671 .collect::<Vec<_>>();
7672 if !package_notices.is_empty() {
7673 let command = package_notices[0].command.as_str();
7674 let mut lines = vec![format!(
7675 "{} {}{}",
7676 colors.muted.fg("Package updates are available. Run"),
7677 colors.accent.fg(command),
7678 colors.muted.fg(".")
7679 )];
7680 lines.push(colors.muted.fg("Packages:"));
7681 lines.extend(
7682 package_notices
7683 .into_iter()
7684 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
7685 );
7686 add_update_panel(
7687 &group,
7688 "Package Updates Available",
7689 &lines.join("\n"),
7690 colors.warning,
7691 );
7692 }
7693
7694 if group.child_count() > 0 {
7698 container.add_child(group);
7699 }
7700}
7701
7702fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
7703 container.add_child(Arc::new(Spacer::new(1)));
7704 container.add_child(Arc::new(DynamicBorder::with_color(color)));
7705 container.add_child(Arc::new(Text::new(
7706 format!("{}\n{body}", color.fg(&tui_bold(title))),
7707 1,
7708 0,
7709 )));
7710 container.add_child(Arc::new(DynamicBorder::with_color(color)));
7711}
7712
7713fn welcome_capability_line(label: &str, names: &[String]) -> String {
7714 let c = current_theme().colors;
7715 let value = if names.is_empty() {
7716 "none".to_string()
7717 } else {
7718 names.join(" · ")
7719 };
7720 format!(
7721 "{} {}",
7722 c.accent.fg(&format!("{label} ({})", names.len())),
7723 c.muted.fg(&value)
7724 )
7725}
7726
7727fn add_help_message(container: &Arc<Container>) {
7729 let c = current_theme().colors;
7730 container.add_child(Arc::new(Text::new(
7734 c.md_heading.fg(&tui_bold("📚 Available Commands")),
7735 1,
7736 0,
7737 )));
7738 container.add_child(Arc::new(Spacer::new(1)));
7739
7740 let cmds: &[(&str, &str)] = &[
7741 ("/help, /?", "Show this help message"),
7742 ("/clear, /new", "Clear the conversation"),
7743 ("/exit, /quit, /q", "Exit the application"),
7744 ("/version, /v", "Show version information"),
7745 ("/changelog", "Show recent release changes"),
7746 ("/model, /m", "Choose a model (live switch)"),
7747 ("/thinking, /think", "Set reasoning depth (selector)"),
7748 ("/tools", "Toggle built-in tools on/off"),
7749 ("/images", "Toggle inline image rendering"),
7750 ("/session", "List saved sessions"),
7751 ("/theme", "Choose a theme (selector)"),
7752 ("/compact", "Compact the conversation"),
7753 ("/copy", "Copy last reply to clipboard"),
7754 ("/hotkeys", "Show keyboard shortcuts"),
7755 ("/armin", "🐾 Easter egg"),
7756 ("/earendil", "Earendil announcement"),
7757 ];
7758 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7759 for (cmd, desc) in cmds {
7760 let row = format!(
7761 " {:<cmd_w$} {} {}",
7762 c.accent.fg(cmd),
7763 c.dim.fg("—"),
7764 c.muted.fg(desc)
7765 );
7766 container.add_child(Arc::new(Text::new(row, 1, 0)));
7767 }
7768 container.add_child(Arc::new(Spacer::new(1)));
7769}
7770
7771fn add_version_message(container: &Arc<Container>) {
7773 let c = current_theme().colors;
7774 container.add_child(Arc::new(Text::new(
7775 c.md_heading.fg(&tui_bold("📦 Version Information")),
7776 1,
7777 0,
7778 )));
7779 container.add_child(Arc::new(Spacer::new(1)));
7780 container.add_child(Arc::new(Text::new(
7783 format!(
7784 " {} {}",
7785 c.muted.fg("rpi-cli"),
7786 c.text.fg(&format!("v{}", crate::VERSION))
7787 ),
7788 1,
7789 0,
7790 )));
7791 container.add_child(Arc::new(Text::new(
7792 format!(
7793 " {}",
7794 c.dim.fg("Rust implementation of pi coding agent TUI")
7795 ),
7796 1,
7797 0,
7798 )));
7799 container.add_child(Arc::new(Spacer::new(1)));
7800}
7801
7802fn add_changelog_message(container: &Arc<Container>) {
7806 let c = current_theme().colors;
7807 container.add_child(Arc::new(Text::new(
7808 c.md_heading.fg(&tui_bold("Recent Changes")),
7809 1,
7810 0,
7811 )));
7812 container.add_child(Arc::new(Spacer::new(1)));
7813 let entries = [
7814 (
7815 "Native parity phase 1",
7816 "models, images, trust, export, and JSON events",
7817 ),
7818 (
7819 "TUI controls",
7820 "external editor, thinking levels, and tool output toggles",
7821 ),
7822 (
7823 "Provider auth",
7824 "OpenAI-compatible API key aliases and gateway headers",
7825 ),
7826 ];
7827 for (release, summary) in entries {
7828 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
7829 container.add_child(Arc::new(Text::new(row, 1, 0)));
7830 }
7831 container.add_child(Arc::new(Text::new(
7832 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
7833 1,
7834 0,
7835 )));
7836 container.add_child(Arc::new(Spacer::new(1)));
7837}
7838
7839fn add_hotkeys_message(container: &Arc<Container>) {
7841 let c = current_theme().colors;
7842 container.add_child(Arc::new(Text::new(
7843 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
7844 1,
7845 0,
7846 )));
7847 container.add_child(Arc::new(Spacer::new(1)));
7848 let keys: &[(&str, &str)] = &[
7849 ("Enter", "Send message"),
7850 ("Shift+Enter", "New line"),
7851 ("Tab", "Accept autocomplete suggestion"),
7852 ("Ctrl+A / Ctrl+E", "Line start / end"),
7853 (
7854 "Ctrl+K / Ctrl+U",
7855 "Kill to end / start of line (Ctrl+Y yanks)",
7856 ),
7857 ("Ctrl+- / Ctrl+R", "Undo / redo"),
7858 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
7859 ("Alt+Backspace", "Kill previous word"),
7860 ("Ctrl+C", "Abort a run, or exit when idle"),
7861 ("Esc", "Abort a running prompt"),
7862 ("Ctrl+L", "Open model selector"),
7863 ("Ctrl+M", "Cycle to the next model (live)"),
7864 ("Ctrl+O", "Expand/collapse all tool output"),
7865 ("Ctrl+T", "Show/hide reasoning blocks"),
7866 ("PageUp/Down", "Scroll transcript by one page"),
7867 ("Home / End", "Jump to transcript start / latest output"),
7868 ];
7869 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7870 for (key, desc) in keys {
7871 let row = format!(
7872 " {:<key_w$} {} {}",
7873 c.accent.fg(key),
7874 c.dim.fg("—"),
7875 c.muted.fg(desc)
7876 );
7877 container.add_child(Arc::new(Text::new(row, 1, 0)));
7878 }
7879 container.add_child(Arc::new(Spacer::new(1)));
7880}
7881
7882fn add_user_message(container: &Arc<Container>, text: &str) {
7888 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
7889 container.add_child(Arc::new(Spacer::new(1)));
7890}
7891
7892fn add_error_message(container: &Arc<Container>, text: &str) {
7894 let c = current_theme().colors;
7895 let text = sanitize_error_message(text);
7896 container.add_child(Arc::new(Text::new(
7897 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
7898 1,
7899 0,
7900 )));
7901 container.add_child(Arc::new(Spacer::new(1)));
7902}
7903
7904fn sanitize_error_message(text: &str) -> String {
7909 const MAX_ERROR_CHARS: usize = 16 * 1024;
7910 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
7911 let mut count = 0;
7912 let mut escape_mode = 0u8;
7914 for ch in text.chars() {
7915 if escape_mode != 0 {
7916 match escape_mode {
7917 1 if ch == '[' => escape_mode = 2,
7918 1 if ch == ']' => escape_mode = 3,
7919 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
7920 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
7921 3 if ch == '\x07' => escape_mode = 0,
7922 _ => {}
7923 }
7924 continue;
7925 }
7926 if ch == '\x1b' {
7927 escape_mode = 1;
7928 continue;
7929 }
7930 if count >= MAX_ERROR_CHARS {
7931 result.push_str("…");
7932 break;
7933 }
7934 match ch {
7935 '\n' | '\t' => {
7936 result.push(ch);
7937 count += 1;
7938 }
7939 '\r' => {}
7940 c if c.is_control() => {}
7941 c => {
7942 result.push(c);
7943 count += 1;
7944 }
7945 }
7946 }
7947 if result.trim().is_empty() {
7948 "Provider request failed.".to_string()
7949 } else {
7950 result
7951 }
7952}
7953
7954fn add_note_message(container: &Arc<Container>, text: &str) {
7956 let c = current_theme().colors;
7957 container.add_child(Arc::new(Text::new(
7958 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
7959 1,
7960 0,
7961 )));
7962 container.add_child(Arc::new(Spacer::new(1)));
7963}
7964
7965fn show_context_panel(
7974 chat: &Arc<Container>,
7975 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
7976) {
7977 let skills = resources.skills.as_deref().unwrap_or(&[]);
7978 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
7979 let mut lines: Vec<String> = Vec::new();
7980 lines.push("📂 Discovered resources for this session:".into());
7981
7982 if skills.is_empty() {
7983 lines.push(
7984 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
7985 );
7986 } else {
7987 lines.push(format!(" Skills ({}):", skills.len()));
7988 for s in skills {
7989 let marker = if s.disable_model_invocation == Some(true) {
7990 " [hidden]"
7991 } else {
7992 ""
7993 };
7994 let desc: String = s.description.chars().take(72).collect();
7995 lines.push(format!(" • {}{marker} — {desc}", s.name));
7996 }
7997 }
7998
7999 if templates.is_empty() {
8000 lines.push(
8001 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8002 );
8003 } else {
8004 lines.push(format!(" Prompt templates ({}):", templates.len()));
8005 for t in templates {
8006 let desc = t
8007 .description
8008 .as_deref()
8009 .unwrap_or("(no description)")
8010 .chars()
8011 .take(72)
8012 .collect::<String>();
8013 lines.push(format!(" • /{} — {desc}", t.name));
8014 }
8015 }
8016 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8017 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8018 lines.push(
8019 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8020 .into(),
8021 );
8022 let body = lines.join("\n");
8023 container_note_block(chat, &body);
8024}
8025
8026fn container_note_block(container: &Arc<Container>, body: &str) {
8028 for line in body.lines() {
8029 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8030 }
8031 container.add_child(Arc::new(Spacer::new(1)));
8032}
8033
8034pub fn is_tui_supported() -> bool {
8040 std::io::stdout().is_terminal()
8041}
8042
8043#[allow(unused_imports)]
8045use rpi_tui::Color as _Color;
8046
8047#[cfg(test)]
8048mod tests {
8049 use super::*;
8050 use rpi_tui::Component;
8051
8052 #[test]
8053 fn verbose_overrides_quiet_startup_listing() {
8054 assert!(should_show_startup_listing(false, false));
8055 assert!(should_show_startup_listing(true, false));
8056 assert!(should_show_startup_listing(true, true));
8057 assert!(!should_show_startup_listing(false, true));
8058 }
8059
8060 #[test]
8061 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8062 let tmp = tempfile::tempdir().unwrap();
8063 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8064 let local_root = tmp.path().join("local-package");
8065 std::fs::create_dir_all(git_root.join(".git")).unwrap();
8066 std::fs::create_dir_all(&local_root).unwrap();
8067 std::fs::write(
8068 git_root.join("package.json"),
8069 r#"{"name":"git-demo","version":"1.0.0"}"#,
8070 )
8071 .unwrap();
8072 std::fs::write(
8073 local_root.join("package.json"),
8074 r#"{"name":"local-demo","version":"1.0.0"}"#,
8075 )
8076 .unwrap();
8077 let resources = crate::packages::discover(
8078 tmp.path(),
8079 &[
8080 "git:github.com/example/repo".to_string(),
8081 local_root.to_string_lossy().into_owned(),
8082 ],
8083 );
8084 assert_eq!(resources.packages.len(), 2);
8085
8086 let filtered = git_only_update_resources(resources);
8087
8088 assert_eq!(filtered.packages.len(), 1);
8089 assert!(matches!(
8090 filtered.packages[0].source,
8091 crate::packages::PackageSource::Git
8092 ));
8093 }
8094
8095 #[test]
8096 fn trust_command_uses_the_session_cwd_after_process_chdir() {
8097 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8098 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8099 const TEST_NAME: &str =
8100 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8101
8102 if std::env::var_os(CHILD_ENV).is_some() {
8103 let session_cwd = std::path::PathBuf::from(
8104 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8105 );
8106 let changed_cwd = std::env::current_dir().unwrap();
8107
8108 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8109
8110 assert_eq!(
8111 crate::config::project_trust_decision(&session_cwd).unwrap(),
8112 Some(true)
8113 );
8114 assert_eq!(
8115 crate::config::project_trust_decision(&changed_cwd).unwrap(),
8116 None
8117 );
8118 return;
8119 }
8120
8121 let tmp = tempfile::tempdir().unwrap();
8122 let session_cwd = tmp.path().join("session-project");
8123 let changed_cwd = tmp.path().join("extension-cwd");
8124 let agent_dir = tmp.path().join("agent");
8125 std::fs::create_dir_all(&session_cwd).unwrap();
8126 std::fs::create_dir_all(&changed_cwd).unwrap();
8127 std::fs::create_dir_all(&agent_dir).unwrap();
8128
8129 let status = std::process::Command::new(std::env::current_exe().unwrap())
8130 .arg("--exact")
8131 .arg(TEST_NAME)
8132 .arg("--nocapture")
8133 .env(CHILD_ENV, "1")
8134 .env(SESSION_CWD_ENV, &session_cwd)
8135 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8136 .current_dir(&changed_cwd)
8137 .status()
8138 .unwrap();
8139
8140 assert!(status.success(), "child test process failed: {status}");
8141 }
8142
8143 #[test]
8144 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8145 let global = crate::settings::Settings {
8146 editor_padding_x: Some(3),
8147 autocomplete_max_visible: Some(6),
8148 hide_thinking_block: Some(false),
8149 quiet_startup: Some(true),
8150 show_terminal_progress: Some(true),
8151 ..Default::default()
8152 };
8153 let rpi_project = crate::settings::Settings {
8154 editor_padding_x: Some(0),
8155 hide_thinking_block: Some(true),
8156 quiet_startup: Some(false),
8157 show_terminal_progress: Some(false),
8158 ..Default::default()
8159 };
8160 let pi_project = crate::settings::Settings {
8161 editor_padding_x: Some(9),
8162 autocomplete_max_visible: Some(12),
8163 quiet_startup: Some(true),
8164 ..Default::default()
8165 };
8166
8167 assert_eq!(
8168 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8169 TuiStartupSettings {
8170 editor_padding_x: 0,
8171 autocomplete_max_visible: 12,
8172 hide_thinking: true,
8173 quiet_startup: false,
8174 show_terminal_progress: false,
8175 }
8176 );
8177 }
8178
8179 #[test]
8180 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8181 let global = crate::settings::Settings {
8182 quiet_startup: Some(false),
8183 ..Default::default()
8184 };
8185 let rpi_project = crate::settings::Settings::default();
8186 let pi_project = crate::settings::Settings {
8187 quiet_startup: Some(true),
8188 ..Default::default()
8189 };
8190
8191 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8192
8193 assert!(resolved.quiet_startup);
8194 }
8195
8196 #[test]
8197 fn tui_startup_settings_use_global_values_without_project_fields() {
8198 let global = crate::settings::Settings {
8199 editor_padding_x: Some(7),
8200 autocomplete_max_visible: Some(8),
8201 hide_thinking_block: Some(true),
8202 quiet_startup: Some(true),
8203 show_terminal_progress: Some(false),
8204 ..Default::default()
8205 };
8206
8207 assert_eq!(
8208 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8209 TuiStartupSettings {
8210 editor_padding_x: 7,
8211 autocomplete_max_visible: 8,
8212 hide_thinking: true,
8213 quiet_startup: true,
8214 show_terminal_progress: false,
8215 }
8216 );
8217 }
8218
8219 #[test]
8220 fn tui_startup_settings_ignore_untrusted_project_values() {
8221 let global = crate::settings::Settings {
8222 quiet_startup: Some(false),
8223 show_terminal_progress: Some(true),
8224 ..Default::default()
8225 };
8226 let project = crate::settings::Settings {
8227 quiet_startup: Some(true),
8228 show_terminal_progress: Some(false),
8229 ..Default::default()
8230 };
8231
8232 let resolved = resolve_tui_startup_settings(&global, &[project], false);
8233
8234 assert!(!resolved.quiet_startup);
8235 assert!(resolved.show_terminal_progress);
8236 }
8237
8238 #[test]
8239 fn transcript_page_uses_viewport_with_overlap() {
8240 assert_eq!(transcript_page_size(24), 20);
8241 assert_eq!(transcript_page_size(4), 1);
8242 assert_eq!(transcript_page_size(0), 1);
8243 }
8244
8245 #[test]
8246 fn key_repeat_is_dispatched_but_release_is_not() {
8247 assert!(should_dispatch_key(KeyEventKind::Press));
8248 assert!(should_dispatch_key(KeyEventKind::Repeat));
8249 assert!(!should_dispatch_key(KeyEventKind::Release));
8250 }
8251
8252 #[test]
8253 fn key_event_encoding_matches_pi_keybinding_protocol() {
8254 let key = |code, modifiers| KeyEvent::new(code, modifiers);
8255 assert_eq!(
8256 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
8257 "\r"
8258 );
8259 assert_eq!(
8260 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
8261 "\x1b[13;2u"
8262 );
8263 assert_eq!(
8264 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
8265 "\x1b[9;2u"
8266 );
8267 assert_eq!(
8268 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
8269 "\x1b[Z"
8270 );
8271 assert_eq!(
8272 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
8273 "\x1b[Z"
8274 );
8275 assert_eq!(
8276 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
8277 "\x03"
8278 );
8279 assert_eq!(
8280 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
8281 "\x0f"
8282 );
8283 assert_eq!(
8284 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
8285 "!"
8286 );
8287 assert_eq!(
8288 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
8289 "1"
8290 );
8291 }
8292
8293 #[test]
8294 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
8295 let bridge = JsDialogBridge::default();
8296 let (sender, receiver) = std_mpsc::channel();
8297 bridge.pending.lock().unwrap().push_back(JsDialogPending {
8298 request: JsDialogRequest {
8299 id: "dialog-1".into(),
8300 method: "input".into(),
8301 title: String::new(),
8302 message: String::new(),
8303 options: Vec::new(),
8304 placeholder: None,
8305 prefill: None,
8306 },
8307 result: sender,
8308 });
8309
8310 bridge.cancel("dialog-1");
8313 assert!(bridge.take_pending().is_none());
8314 assert_eq!(
8315 receiver.recv().unwrap(),
8316 serde_json::json!({ "cancelled": true })
8317 );
8318 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
8319 }
8320
8321 #[test]
8322 fn test_layout_renders_welcome_message() {
8323 let chat = Arc::new(Container::new());
8324 add_welcome_message(&chat);
8325
8326 let scroll = Arc::new(ScrollView::new(
8327 chat.clone(),
8328 ScrollViewOptions {
8329 follow: FollowMode::End,
8330 primary: true,
8331 ..Default::default()
8332 },
8333 ));
8334
8335 let editor = Arc::new(Editor::new(
8336 EditorOptions {
8337 padding_x: 1,
8338 ..Default::default()
8339 },
8340 EditorStyle::default(),
8341 Arc::new(rpi_tui::Keybindings::new()),
8342 ));
8343 let dock = Arc::new(Container::new());
8344 dock.add_child(editor);
8345
8346 let footer = Arc::new(FooterComponent::new());
8347
8348 let root = VStack::from_children(vec![
8349 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
8350 StackChild::Entry(StackEntry::new(dock)),
8351 StackChild::Entry(StackEntry::new(footer)),
8352 ]);
8353
8354 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
8355
8356 let all: String = frame.lines.join("\n");
8357 assert!(
8358 all.contains("rpi"),
8359 "Welcome message not found. Rendered: {}",
8360 all
8361 );
8362 assert!(
8363 all.contains("Type your message"),
8364 "Help text not found. Rendered: {}",
8365 all
8366 );
8367 }
8368
8369 #[test]
8370 fn test_chat_container_has_welcome_content() {
8371 let chat = Arc::new(Container::new());
8372 add_welcome_message_with_capabilities(
8373 &chat,
8374 &["read".into(), "bash".into(), "web_fetch".into()],
8375 &["rust-review".into(), "release".into()],
8376 );
8377
8378 let lines = chat.render(80);
8379 let all: String = lines.join("\n");
8380 let plain = strip_ansi(&all);
8383 assert!(
8384 plain.contains("rpi"),
8385 "Welcome message not in chat container: {:?}",
8386 lines
8387 );
8388 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
8389 assert!(
8390 plain.contains("read · bash · web_fetch"),
8391 "Tool names missing: {plain}"
8392 );
8393 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
8394 assert!(
8395 plain.contains("rust-review · release"),
8396 "Skill names missing: {plain}"
8397 );
8398 }
8399
8400 #[test]
8401 fn update_notices_render_inside_the_transcript() {
8402 let chat = Arc::new(Container::new());
8403 let report = crate::updates::UpdateReport {
8404 notices: vec![
8405 crate::updates::UpdateNotice {
8406 name: "rpi".into(),
8407 current: "0.1.10".into(),
8408 latest: "0.1.11".into(),
8409 command: "rpi update".into(),
8410 },
8411 crate::updates::UpdateNotice {
8412 name: "rpi-search".into(),
8413 current: "0.1.0".into(),
8414 latest: "0.1.1".into(),
8415 command: "rpi pi-package update".into(),
8416 },
8417 ],
8418 warnings: vec![crate::updates::UpdateWarning {
8419 message: "The previously scheduled rpi update failed: access denied".into(),
8420 command: "rpi update".into(),
8421 }],
8422 };
8423
8424 add_update_notices(&chat, &report);
8425
8426 assert_eq!(chat.child_count(), 1);
8427 let plain = strip_ansi(&chat.render(80).join("\n"));
8428 assert!(plain.contains("Update Failed"), "{plain}");
8429 assert!(
8430 plain.contains("rpi update failed: access denied"),
8431 "{plain}"
8432 );
8433 assert!(plain.contains("Update Available"), "{plain}");
8434 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
8435 assert!(plain.contains("rpi update"), "{plain}");
8436 assert!(plain.contains("Package Updates Available"), "{plain}");
8437 assert!(plain.contains("rpi pi-package update"), "{plain}");
8438 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
8439 }
8440
8441 #[test]
8442 fn empty_update_report_does_not_add_transcript_content() {
8443 let chat = Arc::new(Container::new());
8444
8445 add_update_notices(&chat, &crate::updates::UpdateReport::default());
8446
8447 assert_eq!(chat.child_count(), 0);
8448 }
8449
8450 #[test]
8451 fn skill_reads_are_detected_by_path() {
8452 let name = skill_tool_name(
8453 "read",
8454 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
8455 );
8456 assert_eq!(name.as_deref(), Some("release"));
8457
8458 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
8459 assert!(name.is_none());
8460
8461 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
8463 }
8464
8465 #[test]
8466 fn welcome_capabilities_show_empty_state() {
8467 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
8468 assert_eq!(plain, "Skills (0) none");
8469 }
8470
8471 #[test]
8472 fn empty_tool_progress_is_deferred_until_start() {
8473 let empty = rpi_agent::AgentToolResult::default();
8474 assert!(!tool_update_has_payload("", &empty));
8475
8476 let text = rpi_agent::AgentToolResult::text("partial output");
8477 assert!(tool_update_has_payload("partial output", &text));
8478
8479 let details = rpi_agent::AgentToolResult {
8480 details: serde_json::json!({"path": "src/lib.rs"}),
8481 ..Default::default()
8482 };
8483 assert!(tool_update_has_payload("", &details));
8484 }
8485
8486 #[test]
8487 fn completed_stream_reconciles_the_final_tail_before_detaching() {
8488 let component = Arc::new(AssistantMessageComponent::new(
8489 AssistantMessageOptions::default(),
8490 ));
8491 component.set_streaming(true);
8492 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
8493
8494 let current = Mutex::new(Some(component.clone()));
8495 let cached = Mutex::new("partial response".to_string());
8496 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
8497 final_message.content = vec![Content::text(
8498 "partial response with the previously missing final tail",
8499 )];
8500 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
8501
8502 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
8503
8504 assert!(current.lock().unwrap().is_none());
8505 assert_eq!(
8506 cached.lock().unwrap().as_str(),
8507 "partial response with the previously missing final tail"
8508 );
8509 let rendered = strip_ansi(&component.render(100).join("\n"));
8510 assert!(
8511 rendered.contains("previously missing final tail"),
8512 "{rendered}"
8513 );
8514 }
8515
8516 #[test]
8522 fn tab_accept_suggestion_reflects_in_next_render() {
8523 use rpi_tui::render_layout_frame;
8524
8525 let editor = Arc::new(Editor::new(
8526 EditorOptions {
8527 padding_x: 1,
8528 ..Default::default()
8529 },
8530 EditorStyle::default(),
8531 Arc::new(rpi_tui::Keybindings::new()),
8532 ));
8533 editor.set_focused(true);
8534 let editor_container = Arc::new(Container::new());
8535 editor_container.add_child(editor.clone());
8536 let autocomplete_container = Arc::new(Container::new());
8537 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
8538 let dock = Arc::new(VStack::from_children(vec![
8539 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
8540 StackChild::Entry(
8541 StackEntry::new(editor_container.clone())
8542 .shrink(0)
8543 .min_size(3),
8544 ),
8545 StackChild::Entry(StackEntry::new(footer)),
8546 ]));
8547
8548 let mut manager = AutocompleteManager::new();
8550 let mut combined = CombinedAutocompleteProvider::new();
8551 combined.add_provider(Arc::new(
8552 SlashCommandAutocompleteProvider::with_default_commands(),
8553 ));
8554 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
8555 manager.set_provider(Arc::new(combined));
8556 editor.insert("/mo");
8559 assert_eq!(editor.cursor_position(), (0, 3));
8560
8561 let frame_before = render_layout_frame(dock.clone(), 80, 10);
8562 assert!(
8563 frame_before.lines.iter().any(|l| l.contains("/mo")),
8564 "precondition: editor shows the typed prefix. Frame rows:\n{}",
8565 frame_before
8566 .lines
8567 .iter()
8568 .map(|l| format!(" [{l}]"))
8569 .collect::<Vec<_>>()
8570 .join("\n")
8571 );
8572
8573 let text = editor.get_text();
8575 let cursor = editor_cursor_offset(&editor, &text);
8576 let sugg = manager
8577 .get_suggestions(&text, cursor)
8578 .expect("slash suggestions for /mo");
8579 let top = sugg.items.first().expect("at least one suggestion");
8580 let start = sugg.start.min(text.len());
8581 let end = sugg.end.min(text.len());
8582 let mut replaced = String::new();
8583 replaced.push_str(&text[..start]);
8584 replaced.push_str(&top.text);
8585 replaced.push_str(&text[end..]);
8586 if top.insert_space && !replaced.ends_with('/') {
8587 replaced.push(' ');
8588 }
8589 let new_cursor = start + top.text.len();
8590 editor.set_text(&replaced);
8591 set_editor_cursor_offset(&editor, &replaced, new_cursor);
8592 autocomplete_container.clear();
8593 assert_eq!(editor.get_text(), "/model");
8594
8595 let frame_after = render_layout_frame(dock, 80, 10);
8597 let all: String = frame_after.lines.join("\n");
8598 assert!(
8599 all.contains("/model"),
8600 "completed text missing from next render. Got:\n{all}"
8601 );
8602 let editor_line = frame_after
8606 .lines
8607 .iter()
8608 .find(|l| l.contains("/model"))
8609 .expect("editor row with completed text");
8610 assert!(
8611 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
8612 "caret must follow the full completed text. Got: {editor_line:?}"
8613 );
8614 }
8615
8616 #[test]
8617 fn multiline_autocomplete_preserves_row_and_column() {
8618 let editor = Arc::new(Editor::simple());
8619 editor.set_text("first\n/mo");
8620 editor.set_cursor(1, 3);
8621
8622 let text = editor.get_text();
8623 assert_eq!(editor_cursor_offset(&editor, &text), 9);
8624
8625 let replaced = "first\n/model";
8626 editor.set_text(replaced);
8627 set_editor_cursor_offset(&editor, replaced, 12);
8628 assert_eq!(editor.cursor_position(), (1, 6));
8629 }
8630
8631 #[test]
8632 fn test_slash_command_dispatch() {
8633 let registry = build_builtin_registry();
8638
8639 let resolves_to = |token: &str, canonical: &str| {
8641 let found = registry.find(token).expect("{token} should resolve");
8642 assert_eq!(
8643 found.name(),
8644 canonical,
8645 "{token} resolved to {} (expected {canonical})",
8646 found.name()
8647 );
8648 };
8649
8650 resolves_to("/help", "/help");
8651 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
8653 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
8657 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
8659 resolves_to("/hotkeys", "/hotkeys");
8660 resolves_to("/model", "/model");
8661 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
8663 resolves_to("/session", "/session");
8664 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
8666 resolves_to("/copy", "/copy");
8667 resolves_to("/thinking", "/thinking");
8668 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
8670 resolves_to("/images", "/images");
8671 resolves_to("/armin", "/armin");
8672 resolves_to("/earendil", "/earendil");
8673 resolves_to("/context", "/context");
8674 resolves_to("/settings", "/settings");
8676 resolves_to("/name", "/name");
8677 resolves_to("/export", "/export");
8678
8679 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
8681 }
8682
8683 #[test]
8684
8685 fn test_registry_visible_entries_cover_dispatch() {
8686 let registry = build_builtin_registry();
8691 let names: Vec<String> = registry
8692 .visible_entries()
8693 .iter()
8694 .map(|c| c.name.clone())
8695 .collect();
8696 for recognized in [
8697 "/help",
8698 "/clear",
8699 "/new",
8700 "/exit",
8701 "/quit",
8702 "/version",
8703 "/changelog",
8704 "/model",
8705 "/session",
8706 "/theme",
8707 "/compact",
8708 "/copy",
8709 "/hotkeys",
8710 "/tools",
8711 "/images",
8712 "/thinking",
8713 "/armin",
8714 "/earendil",
8715 ] {
8716 assert!(
8717 names.contains(&recognized.to_string()),
8718 "{recognized} missing from autocomplete list"
8719 );
8720 }
8721 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
8723 assert!(
8724 !names.contains(&hidden.to_string()),
8725 "{hidden} should be hidden from autocomplete"
8726 );
8727 }
8728 }
8729
8730 #[test]
8731 fn test_agent_event_mapping_creates_assistant_and_tool() {
8732 use rpi_ai::types::{
8735 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
8736 ToolCall, ToolCallType, Usage,
8737 };
8738
8739 let state = Arc::new(TuiState {
8740 current_assistant: std::sync::Mutex::new(None),
8741 tool_components: std::sync::Mutex::new(HashMap::new()),
8742 bash_components: std::sync::Mutex::new(HashMap::new()),
8743 themes_enabled: true,
8744 hide_thinking: std::sync::Mutex::new(false),
8745 tool_outputs_expanded: std::sync::Mutex::new(false),
8746 show_terminal_progress: true,
8747 status: std::sync::Mutex::new(RunStatus::Idle),
8748 js_preparation_cancel: std::sync::Mutex::new(None),
8749 footer: Arc::new(FooterComponent::new()),
8750 status_container: Arc::new(Container::new()),
8751 chat_container: Arc::new(Container::new()),
8752 loader: Arc::new(Loader::new()),
8753 last_assistant_text: std::sync::Mutex::new(String::new()),
8754 active_selector: std::sync::Mutex::new(None),
8755 active_extension_editor: std::sync::Mutex::new(None),
8756 active_extension_input: std::sync::Mutex::new(None),
8757 active_extension_cancel: std::sync::Mutex::new(None),
8758 autocomplete: AutocompleteManager::new(),
8759 autocomplete_container: Arc::new(Container::new()),
8760 autocomplete_max_visible: 5,
8761 pending_images: std::sync::Mutex::new(Vec::new()),
8762 theme_manager: Arc::new(ThemeManager::new()),
8763 tui: None,
8764 current_model_id: std::sync::Mutex::new(String::new()),
8765 show_images: std::sync::Mutex::new(true),
8766 history: std::sync::Mutex::new(Vec::new()),
8767 history_index: std::sync::Mutex::new(-1),
8768 history_draft: std::sync::Mutex::new(None),
8769 last_input_tokens: std::sync::Mutex::new(0),
8770 scoped_edit: std::sync::Mutex::new(None),
8771 markdown_transformer: std::sync::Mutex::new(None),
8772 extension_session: Arc::new(std::sync::Mutex::new(
8773 rpi_extensions::ExtensionSession::none(),
8774 )),
8775 });
8776
8777 let assistant = AssistantMessage {
8781 role: rpi_ai::types::AssistantRole,
8782 content: vec![
8783 Content::Thinking(ThinkingContent {
8784 kind: ThinkingContentType,
8785 thinking: "Reasoning about the reply.".into(),
8786 thinking_signature: None,
8787 redacted: false,
8788 }),
8789 Content::Text(TextContent {
8790 kind: TextContentType,
8791 text: "Hello.".into(),
8792 text_signature: None,
8793 }),
8794 Content::ToolCall(ToolCall {
8795 kind: ToolCallType,
8796 id: "tc1".into(),
8797 name: "bash".into(),
8798 arguments: serde_json::json!({"command": "echo hi"}),
8799 thought_signature: None,
8800 namespace: None,
8801 }),
8802 ],
8803 api: rpi_ai::Api::AnthropicMessages,
8804 provider: "anthropic".into(),
8805 model: "claude-sonnet-5".into(),
8806 response_model: None,
8807 response_id: None,
8808 usage: Usage::zero(),
8809 stop_reason: StopReason::Stop,
8810 deferred: None,
8811 error_message: None,
8812 raw_stop_reason: None,
8813 end_turn: None,
8814 timestamp: 0,
8815 };
8816
8817 let comp = Arc::new(AssistantMessageComponent::new(
8820 AssistantMessageOptions::default(),
8821 ));
8822 comp.set_streaming(true);
8823 comp.update_blocks(&assistant_blocks(&assistant));
8824 let chat = Arc::new(Container::new());
8825 chat.add_child(comp.clone());
8826 *state.current_assistant.lock().unwrap() = Some(comp);
8827
8828 for c in &assistant.content {
8830 if let Content::ToolCall(tc) = c {
8831 let mut tools = state.tool_components.lock().unwrap();
8832 if !tools.contains_key(&tc.id) {
8833 let tc_comp = Arc::new(ToolExecutionComponent::new(
8834 &tc.name,
8835 &tc.arguments.to_string(),
8836 ));
8837 tc_comp.set_running();
8838 chat.add_child(tc_comp.clone());
8839 tools.insert(tc.id.clone(), tc_comp);
8840 }
8841 }
8842 }
8843
8844 let rendered = chat.render(80);
8848 let joined: String = rendered.join("\n");
8849 assert!(
8850 joined.contains("Hello."),
8851 "assistant text not rendered: {joined}"
8852 );
8853 assert!(
8854 joined.contains("Reasoning about the reply."),
8855 "thinking block not rendered: {joined}"
8856 );
8857 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
8858 assert!(state.current_assistant.lock().unwrap().is_some());
8859
8860 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
8862 ended.set_result("hi", false);
8863 assert!(state.tool_components.lock().unwrap().is_empty());
8864
8865 assert!(state.try_start_working());
8869 assert!(
8870 !state.try_start_working(),
8871 "a second submit must be rejected"
8872 );
8873 state.set_status(RunStatus::Idle);
8874 state.set_status(RunStatus::Working);
8875 assert_eq!(state.status_container.child_count(), 1);
8876 assert!(state.footer.get_status().is_empty());
8877 state.show_retry(3, 10, 8_000);
8878 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
8879 assert!(retry_status.contains("Retrying (3/10)"));
8880 state.set_status(RunStatus::Working);
8881 {
8882 let mut bash = state.bash_components.lock().unwrap();
8883 bash.insert(
8884 "bash-1".into(),
8885 Arc::new(BashExecutionComponent::new("one")),
8886 );
8887 bash.insert(
8888 "bash-2".into(),
8889 Arc::new(BashExecutionComponent::new("two")),
8890 );
8891 }
8892 state.sync_working_loader_with_bash();
8893 assert_eq!(state.status_container.child_count(), 0);
8894 state.bash_components.lock().unwrap().remove("bash-1");
8895 state.sync_working_loader_with_bash();
8896 assert_eq!(state.status_container.child_count(), 0);
8897 state.bash_components.lock().unwrap().remove("bash-2");
8898 state.sync_working_loader_with_bash();
8899 assert_eq!(state.status_container.child_count(), 1);
8900
8901 state.set_status(RunStatus::Aborting);
8902 assert_eq!(state.status_container.child_count(), 0);
8903 assert!(!state.loader.is_running());
8904 }
8905
8906 #[test]
8907 fn fresh_launch_does_not_restore_old_history() {
8908 let fresh = Args::default();
8909 assert!(!launch_restores_history(&fresh));
8910
8911 let continued = Args {
8912 continue_session: true,
8913 ..Args::default()
8914 };
8915 assert!(launch_restores_history(&continued));
8916
8917 let selected = Args {
8918 session: Some("session-id".into()),
8919 ..Args::default()
8920 };
8921 assert!(launch_restores_history(&selected));
8922 }
8923
8924 #[test]
8925 fn test_short_model_name() {
8926 assert_eq!(
8927 short_model_name("anthropic:claude-sonnet-5"),
8928 "claude-sonnet-5"
8929 );
8930 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
8931 }
8932
8933 #[test]
8934 fn model_selector_items_are_deduplicated_and_provider_qualified() {
8935 use rpi_ai::{Api, Model};
8936
8937 let mut gateway = Model::new(
8938 "gpt-5.6-sol",
8939 "GPT 5.6 Sol",
8940 Api::OpenaiCompletions,
8941 "routeryo-copy",
8942 "https://gateway.example.com",
8943 );
8944 let duplicate = gateway.clone();
8945 let anthropic = Model::new(
8946 "claude-sonnet-5",
8947 "Claude Sonnet 5",
8948 Api::AnthropicMessages,
8949 "anthropic",
8950 "https://api.anthropic.com",
8951 );
8952 gateway.headers = Some(std::collections::BTreeMap::from([(
8953 "authorization".into(),
8954 "Bearer test".into(),
8955 )]));
8956
8957 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
8958 assert_eq!(items.len(), 2);
8959 assert_eq!(items[0].value, "gpt-5.6-sol");
8960 assert_eq!(items[0].label, "GPT 5.6 Sol");
8961 assert_eq!(
8962 items[0].description.as_deref(),
8963 Some("routeryo-copy/gpt-5.6-sol (current)")
8964 );
8965 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
8966 }
8967
8968 #[test]
8969 fn model_selector_match_accepts_bare_and_qualified_ids() {
8970 use rpi_ai::{Api, Model};
8971
8972 let gateway = Model::new(
8973 "gpt-5.6-sol",
8974 "GPT 5.6 Sol",
8975 Api::OpenaiCompletions,
8976 "routeryo-copy",
8977 "https://gateway.example.com",
8978 );
8979 let anthropic = Model::new(
8980 "claude-sonnet-5",
8981 "Claude Sonnet 5",
8982 Api::AnthropicMessages,
8983 "anthropic",
8984 "https://api.anthropic.com",
8985 );
8986 let catalog = [gateway, anthropic];
8987 assert_eq!(
8988 find_model_selector_match(&catalog, "gpt-5.6-sol")
8989 .unwrap()
8990 .provider,
8991 "routeryo-copy"
8992 );
8993 assert_eq!(
8994 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
8995 .unwrap()
8996 .id,
8997 "gpt-5.6-sol"
8998 );
8999 assert_eq!(
9000 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9001 .unwrap()
9002 .id,
9003 "claude-sonnet-5"
9004 );
9005 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9006 }
9007
9008 #[test]
9009 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9010 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9011
9012 let failed = AssistantMessage {
9013 role: AssistantRole,
9014 content: Vec::new(),
9015 api: rpi_ai::Api::AnthropicMessages,
9016 provider: "anthropic".into(),
9017 model: "claude-sonnet-5".into(),
9018 response_model: None,
9019 response_id: None,
9020 usage: Usage::zero(),
9021 stop_reason: StopReason::Error,
9022 deferred: None,
9023 error_message: Some("upstream returned 401".into()),
9024 raw_stop_reason: None,
9025 end_turn: None,
9026 timestamp: 0,
9027 };
9028 assert_eq!(
9029 assistant_error_text(&failed).as_deref(),
9030 Some("upstream returned 401")
9031 );
9032
9033 let mut no_detail = failed;
9034 no_detail.error_message = Some(" ".into());
9035 assert_eq!(
9036 assistant_error_text(&no_detail).as_deref(),
9037 Some("Provider request failed.")
9038 );
9039
9040 let mut aborted = no_detail;
9041 aborted.stop_reason = StopReason::Aborted;
9042 aborted.error_message = Some("abort error: Request aborted".into());
9043 assert_eq!(
9044 assistant_error_text(&aborted).as_deref(),
9045 Some("abort error: Request aborted")
9046 );
9047
9048 aborted.error_message = None;
9049 assert_eq!(
9050 assistant_error_text(&aborted).as_deref(),
9051 Some("Request aborted.")
9052 );
9053 }
9054
9055 #[test]
9056 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9057 assert_eq!(
9058 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9059 "405\nMethod Not Allowed"
9060 );
9061 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9062 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
9063 let long = "x".repeat(20_000);
9064 let cleaned = sanitize_error_message(&long);
9065 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9066 assert!(cleaned.ends_with('…'));
9067 }
9068
9069 #[test]
9070 fn test_cycle_next_model_wraps_around() {
9071 use rpi_ai::{Api, Model};
9072 let mk = |id: &str| {
9073 Model::new(
9074 id,
9075 id,
9076 Api::AnthropicMessages,
9077 "anthropic",
9078 "https://api.anthropic.com",
9079 )
9080 };
9081 let catalog = [mk("a"), mk("b"), mk("c")];
9082 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9084 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9085 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9087 let empty: Vec<Model> = vec![];
9089 assert!(cycle_next_model(&empty, "a").is_none());
9090 }
9091
9092 #[test]
9093 fn test_autocomplete_slash_suggestions_render() {
9094 let state = Arc::new(TuiState {
9097 current_assistant: std::sync::Mutex::new(None),
9098 tool_components: std::sync::Mutex::new(HashMap::new()),
9099 bash_components: std::sync::Mutex::new(HashMap::new()),
9100 themes_enabled: true,
9101 hide_thinking: std::sync::Mutex::new(false),
9102 tool_outputs_expanded: std::sync::Mutex::new(false),
9103 show_terminal_progress: true,
9104 status: std::sync::Mutex::new(RunStatus::Idle),
9105 js_preparation_cancel: std::sync::Mutex::new(None),
9106 footer: Arc::new(FooterComponent::new()),
9107 status_container: Arc::new(Container::new()),
9108 chat_container: Arc::new(Container::new()),
9109 loader: Arc::new(Loader::new()),
9110 last_assistant_text: std::sync::Mutex::new(String::new()),
9111 active_selector: std::sync::Mutex::new(None),
9112 active_extension_editor: std::sync::Mutex::new(None),
9113 active_extension_input: std::sync::Mutex::new(None),
9114 active_extension_cancel: std::sync::Mutex::new(None),
9115 autocomplete: AutocompleteManager::new(),
9116 autocomplete_container: Arc::new(Container::new()),
9117 autocomplete_max_visible: 5,
9118 pending_images: std::sync::Mutex::new(Vec::new()),
9119 theme_manager: Arc::new(ThemeManager::new()),
9120 tui: None,
9121 current_model_id: std::sync::Mutex::new(String::new()),
9122 show_images: std::sync::Mutex::new(true),
9123 history: std::sync::Mutex::new(Vec::new()),
9124 history_index: std::sync::Mutex::new(-1),
9125 history_draft: std::sync::Mutex::new(None),
9126 last_input_tokens: std::sync::Mutex::new(0),
9127 scoped_edit: std::sync::Mutex::new(None),
9128 markdown_transformer: std::sync::Mutex::new(None),
9129 extension_session: Arc::new(std::sync::Mutex::new(
9130 rpi_extensions::ExtensionSession::none(),
9131 )),
9132 });
9133 {
9134 let mut combined = CombinedAutocompleteProvider::new();
9135 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9136 build_builtin_registry().visible_entries(),
9137 )));
9138 state.autocomplete.set_provider(Arc::new(combined));
9139 }
9140
9141 let editor = Arc::new(Editor::simple());
9142 editor.set_text("/he");
9143 editor.set_cursor(0, 3);
9144 refresh_autocomplete(&state, &editor);
9145 let lines = state.autocomplete_container.render(80);
9146 let joined: String = lines.join("\n");
9147 assert!(
9148 joined.contains("/help"),
9149 "slash suggestions not rendered: {joined}"
9150 );
9151
9152 editor.set_text("hello");
9154 editor.set_cursor(0, 5);
9155 refresh_autocomplete(&state, &editor);
9156 assert!(state.autocomplete_container.render(80).is_empty());
9157 }
9158
9159 #[test]
9160 fn test_select_list_swap_restores_editor() {
9161 let state = Arc::new(TuiState {
9165 current_assistant: std::sync::Mutex::new(None),
9166 tool_components: std::sync::Mutex::new(HashMap::new()),
9167 bash_components: std::sync::Mutex::new(HashMap::new()),
9168 themes_enabled: true,
9169 hide_thinking: std::sync::Mutex::new(false),
9170 tool_outputs_expanded: std::sync::Mutex::new(false),
9171 show_terminal_progress: true,
9172 status: std::sync::Mutex::new(RunStatus::Idle),
9173 js_preparation_cancel: std::sync::Mutex::new(None),
9174 footer: Arc::new(FooterComponent::new()),
9175 status_container: Arc::new(Container::new()),
9176 chat_container: Arc::new(Container::new()),
9177 loader: Arc::new(Loader::new()),
9178 last_assistant_text: std::sync::Mutex::new(String::new()),
9179 active_selector: std::sync::Mutex::new(None),
9180 active_extension_editor: std::sync::Mutex::new(None),
9181 active_extension_input: std::sync::Mutex::new(None),
9182 active_extension_cancel: std::sync::Mutex::new(None),
9183 autocomplete: AutocompleteManager::new(),
9184 autocomplete_container: Arc::new(Container::new()),
9185 autocomplete_max_visible: 5,
9186 pending_images: std::sync::Mutex::new(Vec::new()),
9187 theme_manager: Arc::new(ThemeManager::new()),
9188 tui: None,
9189 current_model_id: std::sync::Mutex::new(String::new()),
9190 show_images: std::sync::Mutex::new(true),
9191 history: std::sync::Mutex::new(Vec::new()),
9192 history_index: std::sync::Mutex::new(-1),
9193 history_draft: std::sync::Mutex::new(None),
9194 last_input_tokens: std::sync::Mutex::new(0),
9195 scoped_edit: std::sync::Mutex::new(None),
9196 markdown_transformer: std::sync::Mutex::new(None),
9197 extension_session: Arc::new(std::sync::Mutex::new(
9198 rpi_extensions::ExtensionSession::none(),
9199 )),
9200 });
9201 let editor_container = Arc::new(Container::new());
9202 let editor = Arc::new(Editor::simple());
9203 editor_container.add_child(editor.clone());
9204 assert!(!state.selector_open());
9205
9206 let tui_terminal = Box::new(ProcessTerminal::new());
9207 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9208 let list = Arc::new(SelectList::new(
9209 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9210 5,
9211 ));
9212 open_selector(
9213 &state,
9214 &editor_container,
9215 &editor,
9216 &tui,
9217 list,
9218 SelectorKind::Theme,
9219 );
9220 assert!(state.selector_open());
9221 assert_eq!(editor_container.child_count(), 1);
9223
9224 close_selector(&state, &editor_container, &editor, &tui);
9225 assert!(!state.selector_open());
9226 assert_eq!(editor_container.child_count(), 1);
9228 }
9229
9230 #[test]
9231 fn test_message_history_browse_restores_draft() {
9232 let state = Arc::new(TuiState {
9236 current_assistant: std::sync::Mutex::new(None),
9237 tool_components: std::sync::Mutex::new(HashMap::new()),
9238 bash_components: std::sync::Mutex::new(HashMap::new()),
9239 themes_enabled: true,
9240 hide_thinking: std::sync::Mutex::new(false),
9241 tool_outputs_expanded: std::sync::Mutex::new(false),
9242 show_terminal_progress: true,
9243 status: std::sync::Mutex::new(RunStatus::Idle),
9244 js_preparation_cancel: std::sync::Mutex::new(None),
9245 footer: Arc::new(FooterComponent::new()),
9246 status_container: Arc::new(Container::new()),
9247 chat_container: Arc::new(Container::new()),
9248 loader: Arc::new(Loader::new()),
9249 last_assistant_text: std::sync::Mutex::new(String::new()),
9250 active_selector: std::sync::Mutex::new(None),
9251 active_extension_editor: std::sync::Mutex::new(None),
9252 active_extension_input: std::sync::Mutex::new(None),
9253 active_extension_cancel: std::sync::Mutex::new(None),
9254 autocomplete: AutocompleteManager::new(),
9255 autocomplete_container: Arc::new(Container::new()),
9256 autocomplete_max_visible: 5,
9257 pending_images: std::sync::Mutex::new(Vec::new()),
9258 theme_manager: Arc::new(ThemeManager::new()),
9259 tui: None,
9260 current_model_id: std::sync::Mutex::new(String::new()),
9261 show_images: std::sync::Mutex::new(true),
9262 history: std::sync::Mutex::new(Vec::new()),
9263 history_index: std::sync::Mutex::new(-1),
9264 history_draft: std::sync::Mutex::new(None),
9265 last_input_tokens: std::sync::Mutex::new(0),
9266 scoped_edit: std::sync::Mutex::new(None),
9267 markdown_transformer: std::sync::Mutex::new(None),
9268 extension_session: Arc::new(std::sync::Mutex::new(
9269 rpi_extensions::ExtensionSession::none(),
9270 )),
9271 });
9272 let editor = Arc::new(Editor::simple());
9273
9274 push_history(&state, "first message");
9275 push_history(&state, "second message");
9276 push_history(&state, "second message");
9278 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
9280 assert_eq!(state.history.lock().unwrap()[0], "second message");
9281
9282 editor.set_text("half-typed");
9284 editor.set_cursor(0, 11);
9285
9286 navigate_history(&state, &editor, -1);
9288 assert_eq!(editor.get_text(), "second message");
9289 assert_eq!(*state.history_index.lock().unwrap(), 0);
9290 navigate_history(&state, &editor, -1);
9292 assert_eq!(editor.get_text(), "first message");
9293 assert_eq!(*state.history_index.lock().unwrap(), 1);
9294 navigate_history(&state, &editor, -1);
9296 assert_eq!(editor.get_text(), "first message");
9297 navigate_history(&state, &editor, 1);
9299 assert_eq!(editor.get_text(), "second message");
9300 navigate_history(&state, &editor, 1);
9302 assert_eq!(editor.get_text(), "half-typed");
9303 assert_eq!(*state.history_index.lock().unwrap(), -1);
9304 }
9305
9306 #[test]
9307 fn test_accept_top_suggestion_replaces_prefix() {
9308 let state = Arc::new(TuiState {
9310 current_assistant: std::sync::Mutex::new(None),
9311 tool_components: std::sync::Mutex::new(HashMap::new()),
9312 bash_components: std::sync::Mutex::new(HashMap::new()),
9313 themes_enabled: true,
9314 hide_thinking: std::sync::Mutex::new(false),
9315 tool_outputs_expanded: std::sync::Mutex::new(false),
9316 show_terminal_progress: true,
9317 status: std::sync::Mutex::new(RunStatus::Idle),
9318 js_preparation_cancel: std::sync::Mutex::new(None),
9319 footer: Arc::new(FooterComponent::new()),
9320 status_container: Arc::new(Container::new()),
9321 chat_container: Arc::new(Container::new()),
9322 loader: Arc::new(Loader::new()),
9323 last_assistant_text: std::sync::Mutex::new(String::new()),
9324 active_selector: std::sync::Mutex::new(None),
9325 active_extension_editor: std::sync::Mutex::new(None),
9326 active_extension_input: std::sync::Mutex::new(None),
9327 active_extension_cancel: std::sync::Mutex::new(None),
9328 autocomplete: AutocompleteManager::new(),
9329 autocomplete_container: Arc::new(Container::new()),
9330 autocomplete_max_visible: 5,
9331 pending_images: std::sync::Mutex::new(Vec::new()),
9332 theme_manager: Arc::new(ThemeManager::new()),
9333 tui: None,
9334 current_model_id: std::sync::Mutex::new(String::new()),
9335 show_images: std::sync::Mutex::new(true),
9336 history: std::sync::Mutex::new(Vec::new()),
9337 history_index: std::sync::Mutex::new(-1),
9338 history_draft: std::sync::Mutex::new(None),
9339 last_input_tokens: std::sync::Mutex::new(0),
9340 scoped_edit: std::sync::Mutex::new(None),
9341 markdown_transformer: std::sync::Mutex::new(None),
9342 extension_session: Arc::new(std::sync::Mutex::new(
9343 rpi_extensions::ExtensionSession::none(),
9344 )),
9345 });
9346 {
9347 let mut combined = CombinedAutocompleteProvider::new();
9348 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9349 build_builtin_registry().visible_entries(),
9350 )));
9351 state.autocomplete.set_provider(Arc::new(combined));
9352 }
9353 let editor = Arc::new(Editor::simple());
9354 editor.set_text("/he");
9355 editor.set_cursor(0, 3);
9356 refresh_autocomplete(&state, &editor);
9357 let accepted = accept_top_suggestion(&state, &editor);
9358 assert!(accepted, "should accept the top suggestion");
9359 let text = editor.get_text();
9360 assert!(
9361 text.starts_with("/help"),
9362 "editor text should start with /help, got {text}"
9363 );
9364 }
9365
9366 #[test]
9367 fn configured_key_parser_supports_native_notation() {
9368 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
9369 assert_eq!(combo.code, KeyCode::Char('g'));
9370 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
9371 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
9372 assert_eq!(combo.code, KeyCode::BackTab);
9373 }
9374
9375 #[test]
9376 fn double_escape_trigger_has_half_second_window() {
9377 let now = std::time::Instant::now();
9378 assert!(!double_escape_trigger(None, now));
9379 assert!(double_escape_trigger(
9380 Some(now - std::time::Duration::from_millis(500)),
9381 now
9382 ));
9383 assert!(!double_escape_trigger(
9384 Some(now - std::time::Duration::from_millis(501)),
9385 now
9386 ));
9387 }
9388}