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 lane = ctx_for_cb.lane.clone();
4737 let chat = ctx_for_cb.chat.clone();
4738 let tui = ctx_for_cb.tui.clone();
4739 tokio::spawn(async move {
4740 let result = match lane.steer(message.clone()).await {
4750 Ok(result) => Ok(result),
4751 Err(_) => lane.next_run(message).await,
4756 };
4757 if let Err(error) = result {
4758 add_error_message(&chat, &format!("Could not queue message: {error}"));
4759 tui.request_render(false);
4760 }
4761 });
4762 add_note_message(
4763 &ctx_for_cb.chat,
4764 &format!("Queued steering message: {text}"),
4765 );
4766 ctx_for_cb.tui.request_render(false);
4767 return;
4768 }
4769
4770 if !ctx_for_cb.state.try_start_working() {
4771 return;
4772 }
4773
4774 add_user_message(&ctx_for_cb.chat, text);
4775 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
4778 scroll.scroll_to_end();
4779 }
4780 ctx_for_cb.tui.request_render(false);
4781 push_history(&ctx_for_cb.state, text);
4784 if ctx_for_cb
4785 .tx
4786 .send(TuiMessage::UserInput(text.to_string()))
4787 .is_err()
4788 {
4789 ctx_for_cb.state.set_status(RunStatus::Idle);
4790 }
4791 }));
4792
4793 tui.start_readerless();
4794
4795 let mut update_check_handles = Vec::new();
4799 let rpi_chat = chat_container.clone();
4800 let rpi_tui = tui.clone();
4801 update_check_handles.push(tokio::spawn(async move {
4802 let report = crate::updates::check_rpi_startup().await;
4803 if !report.is_empty() {
4804 add_update_notices(&rpi_chat, &report);
4805 }
4806 rpi_tui.request_render(false);
4807 }));
4808
4809 if update_checks_enabled {
4810 let update_args = args.clone();
4811 let update_cwd = cwd.clone();
4812 let update_project_trusted = project_trusted;
4813 let chat = chat_container.clone();
4814 let tui = tui.clone();
4815 update_check_handles.push(tokio::spawn(async move {
4816 let resources = crate::session::package_resources_for_update_check(
4817 &update_args,
4818 &update_cwd,
4819 update_project_trusted,
4820 );
4821 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
4822 {
4823 Ok(npm_command) => {
4824 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
4825 Some(&resources),
4826 &npm_command,
4827 update_project_trusted.then_some(update_cwd.as_path()),
4828 )
4829 .await
4830 }
4831 Err(error) => {
4832 add_error_message(
4833 &chat,
4834 &format!("npm package update checks disabled: {error}"),
4835 );
4836 let git_resources = git_only_update_resources(resources);
4837 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
4838 }
4839 };
4840 if !report.is_empty() {
4841 add_update_notices(&chat, &report);
4842 }
4843 tui.request_render(false);
4844 }));
4845 }
4846
4847 let drain_handle = if let Some(rx) = event_rx {
4849 let tui_drain = tui.clone();
4850 let state_drain = state.clone();
4851 let chat_drain = chat_container.clone();
4852 Some(tokio::spawn(async move {
4853 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
4854 }))
4855 } else {
4856 None
4857 };
4858
4859 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
4869 reload_context.mailbox.install(reload_sig_tx);
4870 let reload_tx = tx.clone();
4871 let reload_bridge_handle = tokio::spawn(async move {
4872 while reload_sig_rx.recv().await.is_some() {
4873 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
4874 break; }
4876 }
4877 });
4878
4879 let tui_tick = tui.clone();
4884 let state_tick = state.clone();
4885 let tick_handle = tokio::spawn(async move {
4886 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
4889 interval.tick().await; loop {
4891 interval.tick().await;
4892 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
4893 if working {
4894 if state_tick.bash_components.lock().unwrap().is_empty() {
4895 tui_tick.request_render_reusing_scroll_content();
4899 } else {
4900 tui_tick.request_render(false);
4902 }
4903 }
4904 }
4905 });
4906
4907 let running = Arc::new(std::sync::Mutex::new(true));
4909 let running_key = running.clone();
4910 let tx_for_key = tx.clone();
4911 let tui_for_key = tui.clone();
4912 let editor_for_key = editor.clone();
4913 let scroll_for_key = scroll_view.clone();
4914 let lane_for_key = lane.clone();
4915 let state_for_key = state.clone();
4916 let model_catalog_for_key = model_catalog_arc.clone();
4917 let js_for_key = reload_context.js_extension_session.clone();
4918 let js_dialog_for_key = js_dialog_bridge.clone();
4919 let ctx_for_key = ctx.clone();
4923 let registry_for_key = registry.clone();
4924 let keybindings_for_key = keybindings.clone();
4925 let double_escape_action = crate::settings::load_settings()
4926 .ok()
4927 .and_then(|settings| settings.double_escape_action)
4928 .unwrap_or_else(|| "tree".to_string())
4929 .to_ascii_lowercase();
4930
4931 let key_handle = tokio::task::spawn_blocking(move || {
4932 let mut last_escape_time = None;
4933 loop {
4934 if !*running_key.lock().unwrap() {
4935 break;
4936 }
4937 if !state_for_key.selector_open()
4938 && !state_for_key.extension_dialog_open()
4939 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
4940 {
4941 if let Some(request) = js_dialog_for_key.take_pending() {
4942 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
4943 }
4944 }
4945 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
4946 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
4949 Ok(true) => {}
4950 Ok(false) => continue,
4951 Err(_) => {
4952 state_for_key.cancel_js_preparation();
4953 let _ = tx_for_key.send(TuiMessage::Exit);
4954 break;
4955 }
4956 }
4957 let Ok(ev) = crossterm::event::read() else {
4958 state_for_key.cancel_js_preparation();
4959 let _ = tx_for_key.send(TuiMessage::Exit);
4960 break;
4961 };
4962 if let Event::Resize(_cols, _rows) = ev {
4967 tui_for_key.refresh_size();
4968 if let Some(js) = &js_for_key {
4969 if js.custom_active() {
4970 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
4971 tui_for_key.request_render(false);
4972 }
4973 }
4974 continue;
4975 }
4976 if let Event::Mouse(m) = ev {
4980 use crossterm::event::MouseEventKind;
4981 match m.kind {
4982 MouseEventKind::ScrollUp => {
4983 let delta = -MOUSE_WHEEL_SCROLL_LINES;
4984 if scroll_for_key.scroll_by(delta) != delta {
4985 tui_for_key.request_render_reusing_scroll_content();
4986 }
4987 }
4988 MouseEventKind::ScrollDown => {
4989 let delta = MOUSE_WHEEL_SCROLL_LINES;
4990 if scroll_for_key.scroll_by(delta) != delta {
4991 tui_for_key.request_render_reusing_scroll_content();
4992 }
4993 }
4994 _ => {}
4995 }
4996 continue;
4997 }
4998 let Event::Key(key) = ev else {
4999 if let Event::Paste(text) = ev {
5000 let candidate = text.trim().trim_matches(['\"', '\'']);
5001 let path = std::path::PathBuf::from(candidate);
5002 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5003 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5004 add_image_preview(&state_for_key.chat_container, &image);
5005 state_for_key.queue_image(image);
5006 add_note_message(
5007 &state_for_key.chat_container,
5008 "Dropped image attached to the next prompt.",
5009 );
5010 tui_for_key.request_render(false);
5011 continue;
5012 }
5013 }
5014 editor_for_key.insert(&text);
5015 refresh_autocomplete(&state_for_key, &editor_for_key);
5016 tui_for_key.request_render_reusing_scroll_content();
5017 }
5018 continue;
5019 };
5020 if !should_dispatch_key(key.kind) {
5025 continue;
5026 }
5027
5028 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5034 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5035 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5036 if prompt_abort && state_for_key.cancel_js_preparation() {
5037 state_for_key.set_status(RunStatus::Aborting);
5038 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5039 close_extension_editor(
5040 &state_for_key,
5041 &ctx_for_key.editor_container,
5042 &editor_for_key,
5043 &tui_for_key,
5044 );
5045 }
5046 js_dialog_for_key.cancel_open_requests();
5047 tui_for_key.set_render_suspended(false);
5048 tui_for_key.request_render(false);
5049 continue;
5050 }
5051
5052 if let Some(js) = &js_for_key {
5053 if js.custom_active() {
5054 let visible = js.custom_accepts_input();
5055 let data = key_event_to_input(key);
5056 if !data.is_empty() {
5057 if visible {
5063 let _ = js.send_custom_input(&data);
5064 continue;
5065 }
5066 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5067 if consumed {
5071 tui_for_key.request_render_reusing_scroll_content();
5072 continue;
5073 }
5074 }
5075 }
5076 }
5077
5078 if key.modifiers == KeyModifiers::CONTROL
5081 && key.code == KeyCode::Char('c')
5082 && state_for_key.selector_open()
5083 {
5084 let selector = state_for_key
5085 .active_selector
5086 .lock()
5087 .unwrap()
5088 .clone()
5089 .expect("selector_open guaranteed Some")
5090 .0;
5091 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5092 tui_for_key.request_render_reusing_scroll_content();
5093 continue;
5094 }
5095
5096 if state_for_key.extension_dialog_open() {
5100 let cancel = key.code == KeyCode::Esc
5101 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5102 if cancel {
5103 if !run_extension_cancel(&state_for_key) {
5104 close_extension_editor(
5105 &state_for_key,
5106 &ctx_for_key.editor_container,
5107 &editor_for_key,
5108 &tui_for_key,
5109 );
5110 }
5111 } else if let Some(extension_editor) = state_for_key
5112 .active_extension_editor
5113 .lock()
5114 .unwrap()
5115 .clone()
5116 {
5117 extension_editor.handle_key(key);
5118 } else if let Some(extension_input) =
5119 state_for_key.active_extension_input.lock().unwrap().clone()
5120 {
5121 extension_input.handle_key(key);
5122 }
5123 tui_for_key.request_render_reusing_scroll_content();
5124 continue;
5125 }
5126
5127 if keybinding_matches(
5132 &keybindings_for_key,
5133 &key,
5134 rpi_tui::keybindings::keys::CLEAR,
5135 ) {
5136 if !state_for_key.selector_open() && editor_for_key.has_selection() {
5137 editor_for_key.copy_selection();
5138 continue;
5139 }
5140 let status = *state_for_key.status.lock().unwrap();
5141 match status {
5142 RunStatus::Working => {
5143 state_for_key.set_status(RunStatus::Aborting);
5144 let lane = lane_for_key.clone();
5145 tokio::spawn(async move {
5146 let _ = lane.abort().await;
5147 });
5148 }
5149 RunStatus::Aborting => {}
5153 RunStatus::Idle => {
5154 let _ = tx_for_key.send(TuiMessage::Exit);
5155 }
5156 }
5157 continue;
5158 }
5159
5160 if state_for_key.selector_open() {
5165 if key.code == KeyCode::Esc {
5171 let (selector, _kind) = state_for_key
5172 .active_selector
5173 .lock()
5174 .unwrap()
5175 .clone()
5176 .expect("selector_open guaranteed Some");
5177 selector.handle_key(key);
5178 continue;
5179 }
5180 let (selector, _kind) = state_for_key
5181 .active_selector
5182 .lock()
5183 .unwrap()
5184 .clone()
5185 .expect("selector_open guaranteed Some");
5186 selector.handle_key(key);
5187 tui_for_key.request_render_reusing_scroll_content();
5188 continue;
5189 }
5190
5191 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5196 let status = *state_for_key.status.lock().unwrap();
5197 match status {
5198 RunStatus::Working => {
5199 state_for_key.set_status(RunStatus::Aborting);
5200 let lane = lane_for_key.clone();
5201 tokio::spawn(async move {
5202 let _ = lane.abort().await;
5203 });
5204 continue;
5205 }
5206 RunStatus::Aborting => continue,
5207 RunStatus::Idle => {}
5208 }
5209 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5210 editor_for_key.handle_key(key);
5212 refresh_autocomplete(&state_for_key, &editor_for_key);
5213 tui_for_key.request_render_reusing_scroll_content();
5214 continue;
5215 }
5216 let _ = tx_for_key.send(TuiMessage::Exit);
5217 continue;
5218 }
5219
5220 if keybinding_matches(
5225 &keybindings_for_key,
5226 &key,
5227 rpi_tui::keybindings::keys::INTERRUPT,
5228 ) {
5229 let status = *state_for_key.status.lock().unwrap();
5230 if status == RunStatus::Working {
5231 state_for_key.set_status(RunStatus::Aborting);
5232 let lane = lane_for_key.clone();
5233 tokio::spawn(async move {
5234 let _ = lane.abort().await;
5235 });
5236 continue;
5237 }
5238 if status == RunStatus::Idle
5239 && editor_for_key.get_text().trim().is_empty()
5240 && double_escape_action != "none"
5241 {
5242 let now = std::time::Instant::now();
5243 if double_escape_trigger(last_escape_time, now) {
5244 last_escape_time = None;
5245 match double_escape_action.as_str() {
5246 "tree" => {
5247 let _ = tx_for_key.send(TuiMessage::OpenTree);
5248 }
5249 "fork" => {
5250 let _ = tx_for_key.send(TuiMessage::ForkSession);
5251 }
5252 _ => {}
5253 }
5254 } else {
5255 last_escape_time = Some(now);
5256 }
5257 }
5258 continue;
5259 }
5260
5261 if keybinding_matches(
5264 &keybindings_for_key,
5265 &key,
5266 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5267 ) {
5268 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5269 continue;
5270 }
5271
5272 if keybinding_matches(
5275 &keybindings_for_key,
5276 &key,
5277 rpi_tui::keybindings::keys::TOOLS_EXPAND,
5278 ) {
5279 state_for_key.toggle_tool_outputs();
5280 tui_for_key.request_render(false);
5281 continue;
5282 }
5283
5284 if keybinding_matches(
5286 &keybindings_for_key,
5287 &key,
5288 rpi_tui::keybindings::keys::THINKING_TOGGLE,
5289 ) {
5290 state_for_key.toggle_thinking();
5291 tui_for_key.request_render(false);
5292 continue;
5293 }
5294
5295 if keybinding_matches(
5301 &keybindings_for_key,
5302 &key,
5303 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5304 ) {
5305 let current = state_for_key.current_model_id();
5306 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
5309 if let Some(next) = cycle_next_model(&scope, ¤t) {
5310 state_for_key.set_current_model(&next);
5311 let lane = lane_for_key.clone();
5312 tokio::spawn(async move {
5313 let _ = lane.set_model(next).await;
5314 });
5315 tui_for_key.request_render_reusing_scroll_content();
5316 }
5317 continue;
5318 }
5319
5320 if keybinding_matches(
5323 &keybindings_for_key,
5324 &key,
5325 rpi_tui::keybindings::keys::THINKING_CYCLE,
5326 ) {
5327 let lane = lane_for_key.clone();
5328 let catalog = model_catalog_for_key.clone();
5329 let state = state_for_key.clone();
5330 tokio::spawn(async move {
5331 let Ok(current_model) = lane.get_model().await else {
5332 return;
5333 };
5334 let levels = catalog
5335 .iter()
5336 .find(|model| {
5337 model.provider == current_model.provider && model.id == current_model.id
5338 })
5339 .map(|model| model.supported_thinking_levels())
5340 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5341 if levels.is_empty() {
5342 return;
5343 }
5344 let current = lane
5345 .get_thinking_level()
5346 .await
5347 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
5348 let next = levels
5349 .iter()
5350 .position(|level| *level == current)
5351 .map(|index| levels[(index + 1) % levels.len()])
5352 .unwrap_or(levels[0]);
5353 if lane.set_thinking_level(next).await.is_ok() {
5354 state
5355 .footer
5356 .set_thinking_level(Some(thinking_level_name(next)));
5357 state.tui.as_ref().map(|tui| tui.request_render(false));
5358 }
5359 });
5360 continue;
5361 }
5362
5363 if keybinding_matches(
5367 &keybindings_for_key,
5368 &key,
5369 rpi_tui::keybindings::keys::PASTE_IMAGE,
5370 ) {
5371 match read_clipboard_image() {
5372 Ok(Some(image)) => {
5373 add_image_preview(&state_for_key.chat_container, &image);
5374 state_for_key.queue_image(image);
5375 add_note_message(
5376 &state_for_key.chat_container,
5377 "Clipboard image attached to the next prompt.",
5378 );
5379 tui_for_key.request_render(false);
5380 continue;
5381 }
5382 Ok(None) | Err(_) => {}
5383 }
5384 }
5385
5386 if keybinding_matches(
5390 &keybindings_for_key,
5391 &key,
5392 rpi_tui::keybindings::keys::MODEL_SELECT,
5393 ) {
5394 if let Some(cmd) = registry_for_key.find("/model") {
5395 cmd.execute(&ctx_for_key, "");
5396 }
5397 continue;
5398 }
5399
5400 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
5402 if accept_top_suggestion(&state_for_key, &editor_for_key) {
5403 tui_for_key.request_render_reusing_scroll_content();
5404 }
5405 continue;
5406 }
5407
5408 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
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::PageDown {
5419 let delta = transcript_page_size(scroll_for_key.viewport_height());
5420 if scroll_for_key.scroll_by(delta) != delta {
5421 tui_for_key.request_render_reusing_scroll_content();
5422 }
5423 continue;
5424 }
5425 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
5426 scroll_for_key.scroll_to_start();
5427 tui_for_key.request_render_reusing_scroll_content();
5428 continue;
5429 }
5430 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
5431 scroll_for_key.scroll_to_end();
5432 tui_for_key.request_render_reusing_scroll_content();
5433 continue;
5434 }
5435
5436 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
5445 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5446 if editor_for_key.get_text().is_empty() || browsing {
5447 navigate_history(&state_for_key, &editor_for_key, -1);
5448 tui_for_key.request_render_reusing_scroll_content();
5449 continue;
5450 }
5451 }
5452 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
5453 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5454 if editor_for_key.get_text().is_empty() || browsing {
5455 navigate_history(&state_for_key, &editor_for_key, 1);
5456 tui_for_key.request_render_reusing_scroll_content();
5457 continue;
5458 }
5459 }
5460
5461 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
5465 let prompt = editor_for_key.get_text().trim().to_string();
5466 if prompt.is_empty() {
5467 continue;
5468 }
5469 editor_for_key.clear();
5470 let status = *state_for_key.status.lock().unwrap();
5471 if status == RunStatus::Idle {
5472 if state_for_key.try_start_working() {
5473 add_user_message(&state_for_key.chat_container, &prompt);
5474 push_history(&state_for_key, &prompt);
5475 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
5476 }
5477 } else {
5478 add_note_message(
5479 &state_for_key.chat_container,
5480 &format!("Queued follow-up message: {prompt}"),
5481 );
5482 let message = AgentMessage::User(UserMessage::new(prompt, 0));
5483 let lane = lane_for_key.clone();
5484 let chat = state_for_key.chat_container.clone();
5485 let tui = tui_for_key.clone();
5486 tokio::spawn(async move {
5487 if let Err(error) = lane.follow_up(message).await {
5488 add_error_message(&chat, &format!("Could not queue message: {error}"));
5489 tui.request_render(false);
5490 }
5491 });
5492 }
5493 tui_for_key.request_render(false);
5494 continue;
5495 }
5496
5497 editor_for_key.handle_key(key);
5499 refresh_autocomplete(&state_for_key, &editor_for_key);
5500 tui_for_key.request_render_reusing_scroll_content();
5501 }
5502 });
5503
5504 let mut prompts: Vec<String> = Vec::new();
5506 if let Some(init) = initial {
5507 prompts.push(init);
5508 }
5509 for m in extra_messages {
5510 prompts.push(m.clone());
5511 }
5512 let mut images = initial_images;
5513 for prompt in prompts {
5514 if !*running.lock().unwrap() {
5515 break;
5516 }
5517 add_user_message(&chat_container, &prompt);
5518 tui.request_render(false);
5519 run_prompt_streaming(
5520 &lane,
5521 &prompt,
5522 &tui,
5523 &state,
5524 drain_handle.is_some(),
5525 reload_context.js_extension_session.as_ref(),
5526 &js_dialog_bridge,
5527 args,
5528 std::mem::take(&mut images),
5529 )
5530 .await;
5531 }
5532
5533 loop {
5535 if !*running.lock().unwrap() {
5536 break;
5537 }
5538 match rx.recv().await {
5539 Some(TuiMessage::UserInput(prompt)) => {
5540 editor.clear();
5545 let prompt_images = state.take_pending_images();
5546 if !prompt_images.is_empty() {
5547 add_note_message(
5548 &chat_container,
5549 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
5550 );
5551 }
5552 run_prompt_streaming(
5553 &lane,
5554 &prompt,
5555 &tui,
5556 &state,
5557 drain_handle.is_some(),
5558 reload_context.js_extension_session.as_ref(),
5559 &js_dialog_bridge,
5560 args,
5561 prompt_images,
5562 )
5563 .await;
5564 }
5565 Some(TuiMessage::ExternalEditorResult(result)) => {
5566 match result {
5567 Ok(text) => {
5568 let cursor = text.chars().count();
5569 editor.set_text(&text);
5570 editor.set_cursor(0, cursor);
5571 add_note_message(&chat_container, "Draft updated from external editor.");
5572 }
5573 Err(error) => add_error_message(&chat_container, &error),
5574 }
5575 tui.request_render(false);
5576 }
5577 Some(TuiMessage::OpenTree) => {
5578 if *state.status.lock().unwrap() != RunStatus::Idle {
5579 add_note_message(
5580 &chat_container,
5581 "Wait for the current run to finish before opening the tree.",
5582 );
5583 tui.request_render(false);
5584 } else {
5585 open_tree_selector(
5586 &harness,
5587 &state,
5588 &editor_container,
5589 &editor,
5590 &tui,
5591 &chat_container,
5592 &tx,
5593 )
5594 .await;
5595 }
5596 }
5597 Some(TuiMessage::NavigateTree(entry_id)) => {
5598 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
5599 Ok(result) => match result.outcome {
5600 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
5601 chat_container.clear();
5602 add_welcome_message(&chat_container);
5603 render_session_history(
5604 &harness,
5605 &chat_container,
5606 state.markdown_transformer(),
5607 Some(state.extension_session.clone()),
5608 )
5609 .await;
5610 add_note_message(
5611 &chat_container,
5612 "Moved to the selected session entry.",
5613 );
5614 }
5615 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
5616 add_error_message(&chat_container, &error.message);
5617 }
5618 _ => add_note_message(
5619 &chat_container,
5620 "The selected entry could not be opened.",
5621 ),
5622 },
5623 Err(error) => add_error_message(
5624 &chat_container,
5625 &format!("Could not navigate session tree: {error}"),
5626 ),
5627 }
5628 tui.request_render(false);
5629 }
5630 Some(TuiMessage::ClearChat) => {
5631 chat_container.clear();
5632 add_welcome_message(&chat_container);
5633 tui.request_render(false);
5634 }
5635 Some(TuiMessage::Compact) => {
5636 run_compact(&lane, &tui, &state).await;
5637 }
5638 Some(TuiMessage::Copy) => {
5639 copy_last_assistant(&state, &chat_container);
5640 tui.request_render(false);
5641 }
5642 Some(TuiMessage::Exit) => {
5643 *running.lock().unwrap() = false;
5644 break;
5645 }
5646 Some(TuiMessage::SwitchSession(id)) => {
5647 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
5648 tui.request_render(false);
5649 }
5650 Some(TuiMessage::ImportSession(path)) => {
5651 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
5652 tui.request_render(false);
5653 }
5654 Some(TuiMessage::ShareSession) => {
5655 share_session(&harness, &chat_container).await;
5656 tui.request_render(false);
5657 }
5658 Some(TuiMessage::SetSessionName(name)) => {
5659 let outcome = harness.session().set_name(Some(&name)).await;
5660 match outcome {
5661 Ok(_) => add_note_message(
5662 &chat_container,
5663 &format!("Session renamed to \"{name}\"."),
5664 ),
5665 Err(e) => add_error_message(
5666 &chat_container,
5667 &format!("Could not rename session: {e}"),
5668 ),
5669 }
5670 tui.request_render(false);
5671 }
5672 Some(TuiMessage::ExportSession) => {
5673 export_session(&harness, &chat_container, &cwd).await;
5674 tui.request_render(false);
5675 }
5676 Some(TuiMessage::ForkSession) => {
5677 fork_session(&harness, &cwd, &chat_container, &state).await;
5678 tui.request_render(false);
5679 }
5680 Some(TuiMessage::ReloadExtensions) => {
5681 let reload_ctx = ctx.reload_context.clone();
5688 add_note_message(&chat_container, "Reloading extensions + resources…");
5689 tui.request_render(false);
5690 let outcome =
5691 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
5692 let fresh_transformer = build_markdown_transformer(
5701 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
5702 );
5703 state.set_markdown_transformer_with_reinstall(fresh_transformer);
5704 if outcome.had_warnings {
5705 add_error_message(
5706 &chat_container,
5707 &format!(
5708 "{} (with warnings — see stderr for details).",
5709 outcome.summary
5710 ),
5711 );
5712 } else {
5713 add_note_message(&chat_container, &outcome.summary);
5714 }
5715 tui.request_render(false);
5716 }
5717 None => break,
5718 }
5719 }
5720
5721 js_dialog_bridge.cancel_all();
5725 *running.lock().unwrap() = false;
5726 for handle in update_check_handles {
5727 handle.abort();
5728 let _ = handle.await;
5729 }
5730 if let Some(js) = &reload_context.js_extension_session {
5734 js.shutdown();
5735 }
5736 let _ = key_handle.await;
5739 tick_handle.abort();
5740 if let Some(handle) = drain_handle {
5741 handle.abort();
5742 }
5743 reload_context.mailbox.clear();
5747 reload_bridge_handle.abort();
5748 tui.stop(Default::default());
5749 println!("\nGoodbye!");
5750 let _ = args;
5751
5752 0
5753}
5754
5755async fn ensure_js_runtime_before_prompt(
5765 js: Option<&crate::js_extensions::JsExtensionSession>,
5766 lane: &Arc<dyn AgentLane>,
5767 state: &Arc<TuiState>,
5768 dialog_bridge: &JsDialogBridge,
5769 args: &Args,
5770) -> bool {
5771 let Some(js) = js else {
5772 return true;
5773 };
5774 let cancellation = state.begin_js_preparation();
5775 let worker_cancellation = cancellation.clone();
5776 let js_for_start = js.clone();
5777 let mut worker = tokio::task::spawn_blocking(move || {
5778 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
5779 });
5780 let result = tokio::select! {
5781 result = &mut worker => result,
5782 _ = cancellation.cancelled() => {
5783 dialog_bridge.cancel_open_requests();
5784 let js_for_cancel = js.clone();
5785 let _ = tokio::task::spawn_blocking(move || {
5786 js_for_cancel.cancel_prompt_preparation();
5787 }).await;
5788 worker.await
5789 }
5790 };
5791 let was_cancelled = cancellation.is_cancelled();
5792 if was_cancelled {
5793 dialog_bridge.cancel_open_requests();
5794 let js_for_cancel = js.clone();
5795 let _ = tokio::task::spawn_blocking(move || {
5796 js_for_cancel.cancel_prompt_preparation();
5797 })
5798 .await;
5799 state.finish_js_preparation();
5800 dialog_bridge.reopen();
5801 return false;
5802 }
5803 state.finish_js_preparation();
5804 match result {
5805 Ok(Ok(())) => {
5806 if let Some(js_active) = js.active_tools() {
5810 let js_names = js.tool_names();
5811 let mut active = lane.get_active_tools().await.unwrap_or_default();
5812 active.retain(|name| {
5813 crate::session::tool_name_allowed(name, args)
5814 && !js_names.iter().any(|js_name| js_name == name)
5815 });
5816 active.extend(js_active.into_iter().filter(|name| {
5817 js_names.iter().any(|js_name| js_name == name)
5818 && crate::session::tool_name_allowed(name, args)
5819 }));
5820 active = crate::session::filter_active_tool_names(active, args);
5821 let _ = lane.set_active_tools(active).await;
5822 }
5823 }
5824 Ok(Err(error)) => {
5825 if args.verbose {
5826 eprintln!("warning: could not start JS extension runtime: {error}");
5827 }
5828 }
5829 Err(error) => {
5830 if args.verbose {
5831 eprintln!("warning: JS extension runtime worker failed: {error}");
5832 }
5833 }
5834 }
5835 true
5836}
5837
5838fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
5839 std::thread::spawn(move || {
5840 let file = match tempfile::Builder::new()
5841 .prefix("rpi-draft-")
5842 .suffix(".md")
5843 .tempfile()
5844 {
5845 Ok(file) => file,
5846 Err(error) => {
5847 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
5848 "Could not create editor file: {error}"
5849 ))));
5850 return;
5851 }
5852 };
5853 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
5854 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
5855 "Could not write editor file: {error}"
5856 ))));
5857 return;
5858 }
5859 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
5860 .ok()
5861 .filter(|value| !value.trim().is_empty())
5862 .or_else(|| std::env::var("VISUAL").ok())
5863 .or_else(|| std::env::var("EDITOR").ok())
5864 .unwrap_or_else(|| {
5865 if cfg!(windows) {
5866 "notepad".to_string()
5867 } else {
5868 "nano".to_string()
5869 }
5870 });
5871 let status = std::process::Command::new(&editor)
5872 .arg(file.path())
5873 .status();
5874 let result = match status {
5875 Ok(status) if status.success() => std::fs::read_to_string(file.path())
5876 .map_err(|error| format!("Could not read editor file: {error}")),
5877 Ok(status) => Err(format!("External editor exited with {status}")),
5878 Err(error) => Err(format!(
5879 "Could not launch external editor `{editor}`: {error}"
5880 )),
5881 };
5882 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
5883 });
5884}
5885
5886fn reconcile_streamed_assistant_completion(
5889 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
5890 last_assistant_text: &Mutex<String>,
5891 final_message: Option<&AssistantMessage>,
5892) {
5893 let final_blocks = final_message.map(assistant_blocks);
5894 let final_text = final_message.map(assistant_text);
5895 let component = current_assistant.lock().unwrap().take();
5896
5897 if let Some(component) = component {
5898 if let Some(blocks) = final_blocks.as_deref() {
5899 component.update_blocks(blocks);
5900 }
5901 component.set_streaming(false);
5902 }
5903
5904 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
5905 *last_assistant_text.lock().unwrap() = text;
5906 }
5907}
5908
5909async fn run_prompt_streaming(
5914 lane: &Arc<dyn AgentLane>,
5915 prompt: &str,
5916 tui: &Arc<TuiAltScreen>,
5917 state: &Arc<TuiState>,
5918 streaming: bool,
5919 js: Option<&crate::js_extensions::JsExtensionSession>,
5920 dialog_bridge: &JsDialogBridge,
5921 args: &Args,
5922 images: Vec<rpi_ai::types::ImageContent>,
5923) {
5924 state.set_status(RunStatus::Working);
5933 tui.request_render(false);
5934 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
5935 state.set_status(RunStatus::Idle);
5936 tui.request_render(false);
5937 return;
5938 }
5939
5940 let outcome = lane.prompt_text(prompt, images).await;
5941
5942 if streaming {
5943 let final_message = match &outcome {
5948 Ok(result) => match &result.outcome {
5949 HarnessRunOutcome::Completed { final_message, .. }
5950 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
5951 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
5952 HarnessRunOutcome::Suspended { .. } => None,
5953 },
5954 Err(_) => None,
5955 };
5956 reconcile_streamed_assistant_completion(
5957 &state.current_assistant,
5958 &state.last_assistant_text,
5959 final_message,
5960 );
5961 }
5962
5963 state.set_status(RunStatus::Idle);
5964
5965 match outcome {
5966 Ok(result) => match &result.outcome {
5967 HarnessRunOutcome::Failed {
5968 error,
5969 final_message,
5970 ..
5971 } => {
5972 let already_rendered = streaming && final_message.is_some();
5980 if !already_rendered {
5981 let msg = final_message
5982 .as_ref()
5983 .and_then(|m| m.error_message.clone())
5984 .unwrap_or_else(|| format!("{error:?}"));
5985 add_error_message(&state.chat_container, &msg);
5986 }
5987 }
5988 HarnessRunOutcome::Suspended { .. } => {
5989 add_error_message(
5990 &state.chat_container,
5991 "Run suspended (deferred) — resume is not supported in v1.",
5992 );
5993 }
5994 HarnessRunOutcome::Aborted { final_message, .. } => {
5995 if !streaming {
5998 add_error_message(&state.chat_container, "Request aborted.");
5999 let _ = final_message; }
6001 }
6002 HarnessRunOutcome::Completed { final_message, .. } => {
6003 if !streaming {
6004 let text = assistant_text(final_message);
6005 if !text.is_empty() {
6006 add_assistant_message_blocking(
6007 &state.chat_container,
6008 &text,
6009 state.markdown_transformer(),
6010 );
6011 *state.last_assistant_text.lock().unwrap() = text;
6012 }
6013 }
6014 }
6015 },
6016 Err(e) => {
6017 add_error_message(&state.chat_container, &e.to_string());
6018 }
6019 }
6020
6021 tui.request_render(false);
6022}
6023
6024async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6029 state.set_status(RunStatus::Working);
6030 tui.request_render(false);
6031 match lane.compact(None).await {
6032 Ok(_) => {
6033 add_note_message(&state.chat_container, "Conversation compacted.");
6034 }
6035 Err(e) => {
6036 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6037 }
6038 }
6039 state.set_status(RunStatus::Idle);
6040 tui.request_render(false);
6041}
6042
6043fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6047 let text = state.last_assistant_text.lock().unwrap().clone();
6048 if text.is_empty() {
6049 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6050 return;
6051 }
6052 if copy_to_clipboard(&text) {
6053 add_note_message(chat, "Copied last reply to the clipboard.");
6054 } else {
6055 let preview: String = text.chars().take(200).collect();
6058 add_note_message(
6059 chat,
6060 &format!(
6061 "Clipboard unavailable. Last reply: {preview}{}",
6062 if text.chars().count() > 200 {
6063 "…"
6064 } else {
6065 ""
6066 }
6067 ),
6068 );
6069 }
6070}
6071
6072#[cfg(feature = "clipboard")]
6075fn copy_to_clipboard(text: &str) -> bool {
6076 match arboard::Clipboard::new() {
6077 Ok(mut cb) => cb.set_text(text).is_ok(),
6078 Err(_) => false,
6079 }
6080}
6081
6082#[cfg(not(feature = "clipboard"))]
6083fn copy_to_clipboard(_text: &str) -> bool {
6084 false
6085}
6086
6087#[cfg(feature = "clipboard")]
6091fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6092 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6093 let image = match clipboard.get_image() {
6094 Ok(image) => image,
6095 Err(_) => return Ok(None),
6096 };
6097 let width =
6098 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6099 let height =
6100 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6101 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6102 return Err("clipboard image dimensions are outside the supported range".into());
6103 }
6104 let mut bytes = Vec::new();
6105 {
6106 let mut encoder = png::Encoder::new(&mut bytes, width, height);
6107 encoder.set_color(png::ColorType::Rgba);
6108 encoder.set_depth(png::BitDepth::Eight);
6109 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6110 writer
6111 .write_image_data(&image.bytes)
6112 .map_err(|e| e.to_string())?;
6113 }
6114 Ok(Some(rpi_ai::types::ImageContent {
6115 kind: rpi_ai::types::ImageContentType,
6116 data: base64::engine::general_purpose::STANDARD.encode(bytes),
6117 mime_type: "image/png".into(),
6118 }))
6119}
6120
6121fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6122 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6123 let mut options = ImageOptions::default();
6124 options.width = Some(48);
6125 options.alt_text = Some("Attached image".into());
6126 chat.add_child(Arc::new(Image::from_data(bytes, options)));
6127 chat.add_child(Arc::new(Spacer::new(1)));
6128 }
6129}
6130
6131#[cfg(not(feature = "clipboard"))]
6132fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6133 Ok(None)
6134}
6135
6136fn add_assistant_message_blocking(
6143 container: &Arc<Container>,
6144 text: &str,
6145 transformer: Option<MarkdownTransformer>,
6146) {
6147 if text.is_empty() {
6148 return;
6149 }
6150 let msg = Arc::new(AssistantMessageComponent::new(
6151 AssistantMessageOptions::default(),
6152 ));
6153 if let Some(t) = &transformer {
6154 msg.set_markdown_transformer(Some(t.clone()));
6155 }
6156 msg.update_text(text);
6157 container.add_child(msg);
6158 container.add_child(Arc::new(Spacer::new(1)));
6159}
6160
6161async fn drain_agent_events(
6169 mut rx: broadcast::Receiver<AgentEvent>,
6170 tui: Arc<TuiAltScreen>,
6171 state: Arc<TuiState>,
6172 chat: Arc<Container>,
6173) {
6174 loop {
6175 match rx.recv().await {
6176 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6177 Err(broadcast::error::RecvError::Lagged(_)) => {
6178 continue;
6181 }
6182 Err(broadcast::error::RecvError::Closed) => break,
6183 }
6184 }
6185}
6186
6187async fn handle_agent_event(
6190 event: AgentEvent,
6191 tui: &Arc<TuiAltScreen>,
6192 state: &Arc<TuiState>,
6193 chat: &Arc<Container>,
6194) {
6195 match event {
6196 AgentEvent::AgentStart => {
6197 state.set_status(RunStatus::Working);
6198 tui.request_render(false);
6199 }
6200
6201 AgentEvent::AgentEnd { .. } => {
6202 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6204 comp.set_streaming(false);
6205 }
6206 state.set_status(RunStatus::Idle);
6207 tui.request_render(false);
6208 }
6209
6210 AgentEvent::RetryScheduled {
6211 attempt,
6212 max_retries,
6213 delay_ms,
6214 ..
6215 } => {
6216 state.show_retry(attempt, max_retries, delay_ms);
6217 tui.request_render(false);
6218 }
6219
6220 AgentEvent::TurnStart => {
6221 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6224 comp.set_streaming(false);
6225 }
6226 }
6227
6228 AgentEvent::TurnEnd {
6229 message,
6230 tool_results,
6231 } => {
6232 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6234 if let AgentMessage::Assistant(a) = &message {
6235 comp.update_blocks(&assistant_blocks(a));
6236 }
6237 comp.set_streaming(false);
6238 }
6239 let tools = state.tool_components.lock().unwrap();
6244 for tr in &tool_results {
6245 if tools.contains_key(&tr.tool_call_id) {
6246 let _ = tr;
6249 }
6250 }
6251 drop(tools);
6252 tui.request_render(false);
6253 }
6254
6255 AgentEvent::MessageStart { message } => match message {
6256 AgentMessage::Assistant(a) => {
6257 let comp = Arc::new(AssistantMessageComponent::new(
6258 AssistantMessageOptions::default(),
6259 ));
6260 if let Some(t) = state.markdown_transformer() {
6265 comp.set_markdown_transformer(Some(t));
6266 }
6267 comp.set_hide_thinking(state.hide_thinking());
6268 comp.set_streaming(true);
6269 comp.update_blocks(&assistant_blocks(&a));
6272 chat.add_child(comp.clone());
6273 chat.add_child(Arc::new(Spacer::new(1)));
6276 *state.current_assistant.lock().unwrap() = Some(comp);
6277 tui.request_render(false);
6278 }
6279 AgentMessage::Custom(custom) => {
6280 let payload = serde_json::json!({
6281 "customType": custom.role,
6282 "content": custom.content,
6283 "details": custom.data,
6284 "expanded": false,
6285 "outputPad": 1,
6286 });
6287 if let Some(component) = extension_message_component(
6288 &state.extension_session,
6289 &custom.role,
6290 &payload,
6291 state.markdown_transformer(),
6292 ) {
6293 chat.add_child(component);
6294 chat.add_child(Arc::new(Spacer::new(1)));
6295 tui.request_render(false);
6296 } else {
6297 add_note_message(chat, &custom_message_fallback(&custom));
6298 tui.request_render(false);
6299 }
6300 }
6301 _ => {}
6304 },
6305
6306 AgentEvent::MessageUpdate {
6307 message,
6308 assistant_message_event,
6309 } => {
6310 if let AgentMessage::Assistant(a) = &message {
6311 let text = assistant_text(a);
6312 let mut saw_bash_tool_call = false;
6313 for c in &a.content {
6318 if let Content::ToolCall(tc) = c {
6319 if tc.name.trim().is_empty() {
6323 continue;
6324 }
6325 if tc.name == "bash" {
6326 let command = tc
6332 .arguments
6333 .get("command")
6334 .and_then(|v| v.as_str())
6335 .unwrap_or("");
6336 if command.trim().is_empty() {
6343 continue;
6344 }
6345 saw_bash_tool_call = true;
6346 let mut bash = state.bash_components.lock().unwrap();
6347 if !bash.contains_key(&tc.id) {
6348 let comp = Arc::new(BashExecutionComponent::new(command));
6349 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6350 chat.add_child(comp.clone());
6351 bash.insert(tc.id.clone(), comp);
6352 }
6353 } else {
6354 let mut tools = state.tool_components.lock().unwrap();
6355 if !tools.contains_key(&tc.id) {
6356 let comp = Arc::new(ToolExecutionComponent::new(
6357 &tc.name,
6358 &tc.arguments.to_string(),
6359 ));
6360 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6361 comp.set_running();
6362 chat.add_child(comp.clone());
6363 tools.insert(tc.id.clone(), comp);
6364 }
6365 }
6366 }
6367 }
6368 if saw_bash_tool_call {
6373 state.sync_working_loader_with_bash();
6374 }
6375 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6377 comp.update_blocks(&assistant_blocks(a));
6380 }
6381 *state.last_assistant_text.lock().unwrap() = text;
6382 tui.request_render(false);
6383 }
6384 }
6385
6386 AgentEvent::MessageEnd { message } => {
6387 if let AgentMessage::Assistant(a) = &message {
6388 let text = assistant_text(a);
6389 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6390 comp.update_blocks(&assistant_blocks(a));
6391 comp.set_streaming(false);
6392 }
6393 if !text.is_empty() {
6395 *state.last_assistant_text.lock().unwrap() = text;
6396 }
6397 let usage = &a.usage;
6403 let prev_input = *state.last_input_tokens.lock().unwrap();
6404 if prev_input > 0
6405 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6406 && usage.cache_read == 0
6407 {
6408 add_note_message(
6409 &state.chat_container,
6410 &format!(
6411 "Cache miss: {} tokens re-billed",
6412 format_tokens(usage.input)
6413 ),
6414 );
6415 }
6416 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6417 add_note_message(chat, &text);
6418 }
6419 if let Some(error) = assistant_error_text(a) {
6424 add_error_message(chat, &error);
6425 }
6426 *state.last_input_tokens.lock().unwrap() = usage.input;
6427 }
6428 tui.request_render(false);
6429 }
6430
6431 AgentEvent::ToolExecutionStart {
6432 tool_call_id,
6433 tool_name,
6434 args,
6435 } => {
6436 if tool_name.trim().is_empty() {
6439 return;
6440 }
6441 if tool_name == "bash" {
6442 let command = args
6447 .get("command")
6448 .and_then(|v| v.as_str())
6449 .unwrap_or("")
6450 .to_string();
6451 if command.trim().is_empty() {
6455 return;
6456 }
6457 let mut bash_map = state.bash_components.lock().unwrap();
6458 if let Some(existing) = bash_map.get(&tool_call_id) {
6459 existing.set_command(&command);
6464 } else {
6465 let comp = Arc::new(BashExecutionComponent::new(command));
6466 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6467 chat.add_child(comp.clone());
6468 bash_map.insert(tool_call_id.clone(), comp);
6469 }
6470 } else {
6471 let _comp = {
6472 let mut tools = state.tool_components.lock().unwrap();
6473 if let Some(existing) = tools.get(&tool_call_id) {
6474 existing.set_args(&args.to_string());
6475 existing.clone()
6476 } else {
6477 let comp =
6478 Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6479 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6480 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6485 comp.set_skill_name(skill);
6486 }
6487 comp.set_running();
6488 chat.add_child(comp.clone());
6489 tools.insert(tool_call_id.clone(), comp.clone());
6490 comp
6491 }
6492 };
6493 }
6494 state.sync_working_loader_with_bash();
6495 tui.request_render(false);
6496 }
6497
6498 AgentEvent::ToolExecutionUpdate {
6499 tool_call_id,
6500 tool_name,
6501 args,
6502 partial_result,
6503 } => {
6504 if tool_name.trim().is_empty() {
6505 return;
6506 }
6507 let partial_text = tool_result_text(&partial_result);
6508 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
6509 if tool_name == "bash" {
6510 let chunk = partial_text;
6515 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
6516 if has_partial_payload {
6517 bash.append_output(&chunk);
6518 }
6519 } else if has_partial_payload {
6520 }
6526 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
6527 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6528 comp.set_skill_name(skill);
6529 }
6530 if has_partial_payload {
6533 comp.set_result(&partial_text, false);
6534 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
6535 }
6536 } else if has_partial_payload {
6537 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6542 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6543 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6544 comp.set_skill_name(skill);
6545 }
6546 comp.set_running();
6547 comp.set_result(&partial_text, false);
6548 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
6549 chat.add_child(comp.clone());
6550 state
6551 .tool_components
6552 .lock()
6553 .unwrap()
6554 .insert(tool_call_id.clone(), comp.clone());
6555 }
6556 state.sync_working_loader_with_bash();
6557 tui.request_render(false);
6558 }
6559
6560 AgentEvent::ToolExecutionEnd {
6561 tool_call_id,
6562 tool_name,
6563 result,
6564 is_error,
6565 } => {
6566 if tool_name.trim().is_empty() {
6567 return;
6568 }
6569 if tool_name == "bash" {
6570 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
6571 if let Some(bash) = bash {
6572 finalize_bash(&bash, &result, is_error);
6573 } else {
6574 let command = result
6577 .details
6578 .get("command")
6579 .and_then(|v| v.as_str())
6580 .unwrap_or("")
6581 .to_string();
6582 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
6583 state.sync_working_loader_with_bash();
6584 tui.request_render(false);
6585 return;
6586 }
6587 let comp = Arc::new(BashExecutionComponent::new(command));
6588 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6589 comp.append_output(&tool_result_text(&result));
6590 finalize_bash(&comp, &result, is_error);
6591 chat.add_child(comp);
6592 }
6593 } else {
6594 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
6595 if let Some(comp) = comp {
6596 comp.set_result(&tool_result_text(&result), is_error);
6597 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6598 } else {
6599 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
6602 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6603 comp.set_result(&tool_result_text(&result), is_error);
6604 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6605 chat.add_child(comp.clone());
6606 }
6607 }
6608 state.sync_working_loader_with_bash();
6609 tui.request_render(false);
6610 }
6611 }
6612}
6613
6614fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
6618 let fallback = match message.stop_reason {
6619 rpi_ai::StopReason::Error => "Provider request failed.",
6620 rpi_ai::StopReason::Aborted => "Request aborted.",
6621 _ => return None,
6622 };
6623 Some(
6624 message
6625 .error_message
6626 .as_deref()
6627 .filter(|text| !text.trim().is_empty())
6628 .unwrap_or(fallback)
6629 .to_string(),
6630 )
6631}
6632
6633fn finalize_bash(
6637 comp: &Arc<BashExecutionComponent>,
6638 result: &rpi_agent::AgentToolResult,
6639 is_error: bool,
6640) {
6641 let exit_code = if is_error { Some(1) } else { Some(0) };
6644 let truncated = result
6645 .details
6646 .get("truncation")
6647 .and_then(|t| t.get("truncated"))
6648 .and_then(|v| v.as_bool())
6649 .unwrap_or(false);
6650 let full_output_path = result
6651 .details
6652 .get("full_output_path")
6653 .and_then(|v| v.as_str())
6654 .map(|s| s.to_string());
6655 let truncation = BashTruncation {
6656 truncated,
6657 full_output_path,
6658 };
6659 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
6661}
6662
6663fn apply_edit_diff(
6668 comp: &Arc<ToolExecutionComponent>,
6669 tool_name: &str,
6670 details: &serde_json::Value,
6671 tui: &Arc<TuiAltScreen>,
6672) {
6673 if tool_name != "edit" {
6674 return;
6675 }
6676 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
6677 return;
6678 };
6679 if diff_text.is_empty() {
6680 return;
6681 }
6682 let width = tui.width();
6683 let lines = render_diff(diff_text, width);
6684 comp.set_diff(lines);
6685}
6686
6687fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
6692 if tool_name != "read" {
6693 return None;
6694 }
6695 let path = args.get("path").and_then(|value| value.as_str())?;
6696 let normalized = path.replace('\\', "/");
6697 let file_name = normalized.rsplit('/').next()?;
6698 if !file_name.eq_ignore_ascii_case("SKILL.md") {
6699 return None;
6700 }
6701 normalized
6702 .trim_end_matches('/')
6703 .rsplit('/')
6704 .nth(1)
6705 .filter(|name| !name.is_empty())
6706 .map(str::to_string)
6707}
6708
6709fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
6712 use rpi_agent::TextContentOrImage;
6713 let mut parts: Vec<String> = Vec::new();
6714 for c in &result.content {
6715 if let TextContentOrImage::Text(t) = c {
6716 parts.push(t.text.clone());
6717 }
6718 }
6719 let joined = parts.join("\n");
6720 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
6722 if one_line.chars().count() > 200 {
6723 let truncated: String = one_line.chars().take(200).collect();
6724 format!("{truncated}…")
6725 } else {
6726 one_line
6727 }
6728}
6729
6730fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
6735 use rpi_agent::TextContentOrImage;
6736 let mut parts: Vec<String> = Vec::new();
6737 for c in &result.content {
6738 if let TextContentOrImage::Text(t) = c {
6739 parts.push(t.text.clone());
6740 }
6741 }
6742 parts.join("\n")
6743}
6744
6745fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
6749 !text.trim().is_empty() || !result.details.is_null()
6750}
6751
6752fn open_selector(
6760 state: &Arc<TuiState>,
6761 editor_container: &Arc<Container>,
6762 editor: &Arc<Editor>,
6763 tui: &Arc<TuiAltScreen>,
6764 list: Arc<SelectList>,
6765 kind: SelectorKind,
6766) {
6767 open_selector_with_view(
6768 state,
6769 editor_container,
6770 editor,
6771 tui,
6772 list.clone(),
6773 list,
6774 kind,
6775 );
6776}
6777
6778fn open_selector_with_view<C: Component + 'static>(
6782 state: &Arc<TuiState>,
6783 editor_container: &Arc<Container>,
6784 editor: &Arc<Editor>,
6785 tui: &Arc<TuiAltScreen>,
6786 list: Arc<SelectList>,
6787 view: Arc<C>,
6788 kind: SelectorKind,
6789) {
6790 editor.set_focused(false);
6792 state.autocomplete_container.clear();
6795 editor_container.clear();
6797 editor_container.add_child(view.clone());
6798 *state.active_selector.lock().unwrap() = Some((list, kind));
6799 let focused: Arc<dyn Component> = view;
6800 tui.set_focus(Some(focused));
6801 tui.request_render(false);
6802}
6803
6804fn close_selector(
6807 state: &Arc<TuiState>,
6808 editor_container: &Arc<Container>,
6809 editor: &Arc<Editor>,
6810 tui: &Arc<TuiAltScreen>,
6811) {
6812 editor_container.clear();
6813 editor_container.add_child(editor.clone());
6814 state.autocomplete_container.clear();
6815 editor.set_focused(true);
6816 *state.active_selector.lock().unwrap() = None;
6817 *state.active_extension_cancel.lock().unwrap() = None;
6818 tui.set_focus(Some(editor.clone()));
6819 tui.request_render(false);
6820}
6821
6822fn open_model_selector(
6828 state: &Arc<TuiState>,
6829 editor_container: &Arc<Container>,
6830 editor: &Arc<Editor>,
6831 tui: &Arc<TuiAltScreen>,
6832 catalog: &[rpi_ai::Model],
6833 lane: &Arc<dyn AgentLane>,
6834 lane_model_id: &str,
6835 chat: &Arc<Container>,
6836) {
6837 let items = model_selector_items(catalog, lane_model_id);
6838 if items.is_empty() {
6839 add_note_message(
6840 chat,
6841 "No models in the catalog. Use --model at startup to select one.",
6842 );
6843 tui.request_render(false);
6844 return;
6845 }
6846 let list = Arc::new(SelectList::new(items, 10));
6847
6848 let catalog_arc = catalog.to_vec();
6852 let state_sel = state.clone();
6853 let ec_sel = editor_container.clone();
6854 let editor_sel = editor.clone();
6855 let tui_sel = tui.clone();
6856 let chat_sel = chat.clone();
6857 let lane_sel = lane.clone();
6858 list.on_select(Arc::new(move |item| {
6859 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
6860 add_note_message(
6861 &chat_sel,
6862 &format!("Model {} not found in catalog.", item.label),
6863 );
6864 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6865 return;
6866 };
6867 state_sel.set_current_model(&model);
6868 let lane = lane_sel.clone();
6869 tokio::spawn(async move {
6870 let _ = lane.set_model(model).await;
6871 });
6872 add_note_message(
6873 &chat_sel,
6874 &format!(
6875 "Model set to {} — applies to the next message.",
6876 short_model_name(&item.value)
6877 ),
6878 );
6879 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6880 }));
6881 let state_cancel = state.clone();
6882 let ec_cancel = editor_container.clone();
6883 let editor_cancel = editor.clone();
6884 let tui_cancel = tui.clone();
6885 list.on_cancel(Arc::new(move || {
6886 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6887 }));
6888
6889 open_selector(
6890 state,
6891 editor_container,
6892 editor,
6893 tui,
6894 list,
6895 SelectorKind::Model,
6896 );
6897}
6898
6899fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
6904 if catalog.is_empty() {
6905 return None;
6906 }
6907 let idx = catalog
6908 .iter()
6909 .position(|m| m.id.eq_ignore_ascii_case(current_id));
6910 match idx {
6911 Some(i) => {
6912 let next = (i + 1) % catalog.len();
6913 Some(catalog[next].clone())
6914 }
6915 None => Some(catalog[0].clone()),
6916 }
6917}
6918
6919fn open_session_selector(
6925 state: &Arc<TuiState>,
6926 editor_container: &Arc<Container>,
6927 editor: &Arc<Editor>,
6928 tui: &Arc<TuiAltScreen>,
6929 cwd: &std::path::Path,
6930 tx: &mpsc::UnboundedSender<TuiMessage>,
6931) {
6932 let dir = crate::session::default_session_dir(cwd);
6933 let mut items: Vec<SelectItem> = Vec::new();
6934 if let Ok(entries) = std::fs::read_dir(&dir) {
6935 for entry in entries.flatten() {
6936 let path = entry.path();
6937 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
6938 continue;
6939 }
6940 let stem = path
6941 .file_stem()
6942 .and_then(|s| s.to_str())
6943 .unwrap_or("(unnamed)")
6944 .to_string();
6945 let display = path
6946 .file_name()
6947 .and_then(|s| s.to_str())
6948 .unwrap_or(&stem)
6949 .to_string();
6950 items.push(SelectItem::new(&stem, &display));
6951 }
6952 }
6953 if items.is_empty() {
6954 add_note_message(
6955 &state.chat_container,
6956 "No saved sessions found. Sessions are created automatically in interactive mode.",
6957 );
6958 tui.request_render(false);
6959 return;
6960 }
6961 let list = Arc::new(SelectList::new(items, 10));
6962
6963 let state_sel = state.clone();
6964 let ec_sel = editor_container.clone();
6965 let editor_sel = editor.clone();
6966 let tui_sel = tui.clone();
6967 let tx_sel = tx.clone();
6968 list.on_select(Arc::new(move |item| {
6969 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6973 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
6974 }));
6975 let state_cancel = state.clone();
6976 let ec_cancel = editor_container.clone();
6977 let editor_cancel = editor.clone();
6978 let tui_cancel = tui.clone();
6979 list.on_cancel(Arc::new(move || {
6980 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6981 }));
6982
6983 open_selector(
6984 state,
6985 editor_container,
6986 editor,
6987 tui,
6988 list,
6989 SelectorKind::Session,
6990 );
6991}
6992
6993fn custom_entry_display_text(
6994 custom_type: &str,
6995 data: Option<&serde_json::Value>,
6996) -> Option<String> {
6997 let data = data?;
6998 let text = data
6999 .get("summary")
7000 .or_else(|| data.get("text"))
7001 .or_else(|| data.get("output"))
7002 .and_then(|value| value.as_str())
7003 .filter(|value| !value.trim().is_empty())?;
7004 let label = match custom_type {
7005 "compactionSummary" => "Compaction summary",
7006 "branchSummary" => "Branch summary",
7007 "bashExecution" => "Command output",
7008 other => other,
7009 };
7010 Some(format!("{label}: {text}"))
7011}
7012
7013async fn open_tree_selector(
7017 harness: &AgentHarness,
7018 state: &Arc<TuiState>,
7019 editor_container: &Arc<Container>,
7020 editor: &Arc<Editor>,
7021 tui: &Arc<TuiAltScreen>,
7022 chat: &Arc<Container>,
7023 tx: &mpsc::UnboundedSender<TuiMessage>,
7024) {
7025 let entries = match harness
7026 .session()
7027 .view("main")
7028 .find_entries(&EntryQuery {
7029 order: Some(EntryOrder::OldestFirst),
7030 ..Default::default()
7031 })
7032 .await
7033 {
7034 Ok(entries) => entries,
7035 Err(error) => {
7036 add_error_message(chat, &format!("Could not read session tree: {error}"));
7037 tui.request_render(false);
7038 return;
7039 }
7040 };
7041 let current = harness.session().get_leaf_id().await.ok().flatten();
7042 let items: Vec<SelectItem> = entries
7043 .iter()
7044 .map(|entry| {
7045 let marker = if current.as_deref() == Some(entry.id()) {
7046 " (current)"
7047 } else {
7048 ""
7049 };
7050 SelectItem::new(
7051 entry.id(),
7052 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7053 )
7054 .with_description(&entry.id()[..entry.id().len().min(12)])
7055 })
7056 .collect();
7057 if items.is_empty() {
7058 add_note_message(chat, "The current session has no entries to navigate.");
7059 tui.request_render(false);
7060 return;
7061 }
7062 let list = Arc::new(SelectList::new(items, 12));
7063 let state_sel = state.clone();
7064 let ec_sel = editor_container.clone();
7065 let editor_sel = editor.clone();
7066 let tui_sel = tui.clone();
7067 let tx_sel = tx.clone();
7068 list.on_select(Arc::new(move |item| {
7069 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7070 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7071 }));
7072 let state_cancel = state.clone();
7073 let ec_cancel = editor_container.clone();
7074 let editor_cancel = editor.clone();
7075 let tui_cancel = tui.clone();
7076 list.on_cancel(Arc::new(move || {
7077 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7078 }));
7079 open_selector(
7080 state,
7081 editor_container,
7082 editor,
7083 tui,
7084 list,
7085 SelectorKind::Tree,
7086 );
7087}
7088
7089fn open_theme_selector(
7092 state: &Arc<TuiState>,
7093 editor_container: &Arc<Container>,
7094 editor: &Arc<Editor>,
7095 tui: &Arc<TuiAltScreen>,
7096 cwd: &std::path::Path,
7097 package_resources: &Arc<crate::packages::PackageResources>,
7098) {
7099 let mut items = vec![
7100 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7101 SelectItem::new("light", "Light").with_description("Light background"),
7102 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7103 ];
7104 if state.themes_enabled {
7105 for path in package_resources.theme_files() {
7106 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7107 items.push(SelectItem::new(name, name).with_description("Package theme"));
7108 }
7109 }
7110 }
7111 let list = Arc::new(SelectList::new(items, 10));
7112
7113 let state_sel = state.clone();
7114 let ec_sel = editor_container.clone();
7115 let editor_sel = editor.clone();
7116 let tui_sel = tui.clone();
7117 let chat_sel = state.chat_container.clone();
7118 let cwd_sel = cwd.to_path_buf();
7119 let package_resources_sel = package_resources.clone();
7120 list.on_select(Arc::new(move |item| {
7121 let preset = match item.value.as_str() {
7122 "light" => Some(ThemePreset::Light),
7123 "monochrome" => Some(ThemePreset::Monochrome),
7124 "dark" => Some(ThemePreset::Dark),
7125 name => {
7126 if state_sel.themes_enabled {
7127 if let Ok(custom) = crate::packages::load_theme_with_resources(
7128 &cwd_sel,
7129 name,
7130 &package_resources_sel,
7131 ) {
7132 rpi_tui::global_theme_manager().set(custom.clone());
7133 state_sel.theme_manager.set(custom);
7134 }
7135 }
7136 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7137 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7138 tui_sel.render_now(true);
7139 return;
7140 }
7141 };
7142 let Some(preset) = preset else { return };
7143 apply_theme_preset(preset);
7144 state_sel.theme_manager.apply_preset(preset);
7145 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7148 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7149 tui_sel.render_now(true);
7150 }));
7151 let state_cancel = state.clone();
7152 let ec_cancel = editor_container.clone();
7153 let editor_cancel = editor.clone();
7154 let tui_cancel = tui.clone();
7155 list.on_cancel(Arc::new(move || {
7156 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7157 }));
7158
7159 open_selector(
7160 state,
7161 editor_container,
7162 editor,
7163 tui,
7164 list,
7165 SelectorKind::Theme,
7166 );
7167}
7168
7169fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7176 use rpi_ai::types::ThinkingLevel::*;
7177 match level {
7178 Off => "Off — No reasoning",
7179 Minimal => "Minimal — Brief reasoning (~1k tokens)",
7180 Low => "Low — Light reasoning (~1k tokens)",
7181 Medium => "Medium — Moderate reasoning (~80% of max)",
7182 High => "High — Extensive reasoning (~95% of max)",
7183 Xhigh => "Xhigh — Near-maximal reasoning",
7184 Max => "Max — Maximum reasoning",
7185 }
7186}
7187
7188fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7191 use rpi_ai::types::ThinkingLevel::*;
7192 match level {
7193 Off => "off",
7194 Minimal => "minimal",
7195 Low => "low",
7196 Medium => "medium",
7197 High => "high",
7198 Xhigh => "xhigh",
7199 Max => "max",
7200 }
7201}
7202
7203fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7206 use rpi_ai::types::ThinkingLevel::*;
7207 match name.to_ascii_lowercase().as_str() {
7208 "off" => Some(Off),
7209 "minimal" => Some(Minimal),
7210 "low" => Some(Low),
7211 "medium" => Some(Medium),
7212 "high" => Some(High),
7213 "xhigh" => Some(Xhigh),
7214 "max" => Some(Max),
7215 _ => None,
7216 }
7217}
7218
7219fn open_thinking_selector(
7229 state: &Arc<TuiState>,
7230 editor_container: &Arc<Container>,
7231 editor: &Arc<Editor>,
7232 tui: &Arc<TuiAltScreen>,
7233 lane: &Arc<dyn AgentLane>,
7234 catalog: &[rpi_ai::Model],
7235 lane_model_id: &str,
7236 chat: &Arc<Container>,
7237) {
7238 let model = catalog
7241 .iter()
7242 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7243 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7244 .map(|m| m.supported_thinking_levels())
7245 .unwrap_or_else(|| {
7246 use rpi_ai::types::ThinkingLevel::*;
7247 vec![Off, Minimal, Low, Medium, High]
7248 });
7249 let mut items: Vec<SelectItem> = Vec::new();
7250 for lvl in &levels {
7251 let name = thinking_level_name(*lvl);
7252 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7253 }
7254 if items.is_empty() {
7255 add_note_message(chat, "This model has no supported thinking levels.");
7256 tui.request_render(false);
7257 return;
7258 }
7259 let list = Arc::new(SelectList::new(items, 10));
7260
7261 let state_sel = state.clone();
7262 let ec_sel = editor_container.clone();
7263 let editor_sel = editor.clone();
7264 let tui_sel = tui.clone();
7265 let chat_sel = chat.clone();
7266 let lane_sel = lane.clone();
7267 list.on_select(Arc::new(move |item| {
7268 let Some(level) = thinking_level_from_name(&item.value) else {
7269 add_note_message(
7270 &chat_sel,
7271 &format!("Unknown thinking level: {}.", item.label),
7272 );
7273 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7274 return;
7275 };
7276 let lane = lane_sel.clone();
7277 let footer_sel = state_sel.footer.clone();
7278 tokio::spawn(async move {
7279 let _ = lane.set_thinking_level(level).await;
7280 });
7281 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
7285 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
7286 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7287 }));
7288 let state_cancel = state.clone();
7289 let ec_cancel = editor_container.clone();
7290 let editor_cancel = editor.clone();
7291 let tui_cancel = tui.clone();
7292 list.on_cancel(Arc::new(move || {
7293 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7294 }));
7295
7296 open_selector(
7297 state,
7298 editor_container,
7299 editor,
7300 tui,
7301 list,
7302 SelectorKind::Thinking,
7303 );
7304}
7305
7306fn open_tools_selector(
7312 state: &Arc<TuiState>,
7313 editor_container: &Arc<Container>,
7314 editor: &Arc<Editor>,
7315 tui: &Arc<TuiAltScreen>,
7316 lane: &Arc<dyn AgentLane>,
7317 chat: &Arc<Container>,
7318) {
7319 let mut active = match tokio::runtime::Handle::try_current() {
7324 Ok(h) => h
7325 .block_on(async { lane.get_active_tools().await })
7326 .unwrap_or_default(),
7327 Err(_) => Vec::new(),
7328 };
7329 if active.is_empty() {
7334 active = crate::session::BUILTIN_TOOL_NAMES
7335 .iter()
7336 .map(|name| (*name).to_string())
7337 .collect();
7338 }
7339 let mut items: Vec<SelectItem> = Vec::new();
7340 for name in crate::session::BUILTIN_TOOL_NAMES {
7341 let on = active.iter().any(|a| a == name);
7342 let label = if on {
7343 format!("{name} (on)")
7344 } else {
7345 (*name).to_string()
7346 };
7347 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
7348 }
7349 let list = Arc::new(SelectList::new(items, 10));
7350
7351 let active_captured = active.clone();
7353 let state_sel = state.clone();
7354 let ec_sel = editor_container.clone();
7355 let editor_sel = editor.clone();
7356 let tui_sel = tui.clone();
7357 let chat_sel = chat.clone();
7358 let lane_sel = lane.clone();
7359 list.on_select(Arc::new(move |item| {
7360 let mut next = active_captured.clone();
7361 if let Some(pos) = next.iter().position(|a| a == &item.value) {
7362 next.remove(pos);
7363 } else {
7364 next.push(item.value.clone());
7365 }
7366 let on = next.iter().any(|a| a == &item.value);
7367 let lane = lane_sel.clone();
7368 let next_clone = next.clone();
7369 tokio::spawn(async move {
7370 let _ = lane.set_active_tools(next_clone).await;
7371 });
7372 let list_str = if next.is_empty() {
7373 "(none)".to_string()
7374 } else {
7375 next.join(", ")
7376 };
7377 add_note_message(
7378 &chat_sel,
7379 &format!(
7380 "{} {} — active tools: {}",
7381 item.value,
7382 if on { "enabled" } else { "disabled" },
7383 list_str
7384 ),
7385 );
7386 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7387 }));
7388 let state_cancel = state.clone();
7389 let ec_cancel = editor_container.clone();
7390 let editor_cancel = editor.clone();
7391 let tui_cancel = tui.clone();
7392 list.on_cancel(Arc::new(move || {
7393 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7394 }));
7395
7396 open_selector(
7397 state,
7398 editor_container,
7399 editor,
7400 tui,
7401 list,
7402 SelectorKind::Tools,
7403 );
7404}
7405
7406fn open_images_selector(
7410 state: &Arc<TuiState>,
7411 editor_container: &Arc<Container>,
7412 editor: &Arc<Editor>,
7413 tui: &Arc<TuiAltScreen>,
7414 chat: &Arc<Container>,
7415) {
7416 let current = *state.show_images.lock().unwrap();
7417 let items = vec![
7418 SelectItem::new("yes", "Yes").with_description(if current {
7419 "Inline images (current)"
7420 } else {
7421 "Inline images"
7422 }),
7423 SelectItem::new("no", "No").with_description(if current {
7424 "Placeholder only"
7425 } else {
7426 "Placeholder only (current)"
7427 }),
7428 ];
7429 let list = Arc::new(SelectList::new(items, 5));
7430
7431 let state_sel = state.clone();
7432 let ec_sel = editor_container.clone();
7433 let editor_sel = editor.clone();
7434 let tui_sel = tui.clone();
7435 let chat_sel = chat.clone();
7436 list.on_select(Arc::new(move |item| {
7437 let on = item.value == "yes";
7438 *state_sel.show_images.lock().unwrap() = on;
7439 add_note_message(
7440 &chat_sel,
7441 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
7442 );
7443 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7444 }));
7445 let state_cancel = state.clone();
7446 let ec_cancel = editor_container.clone();
7447 let editor_cancel = editor.clone();
7448 let tui_cancel = tui.clone();
7449 list.on_cancel(Arc::new(move || {
7450 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7451 }));
7452
7453 open_selector(
7454 state,
7455 editor_container,
7456 editor,
7457 tui,
7458 list,
7459 SelectorKind::Images,
7460 );
7461}
7462
7463fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
7471 let text = editor.get_text();
7472 let cursor = editor_cursor_offset(editor, &text);
7473 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
7474 render_autocomplete(state, suggestions);
7475}
7476
7477fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
7480 let (row, col) = editor.cursor_position();
7481 let mut offset = 0;
7482 for (index, line) in text.split('\n').enumerate() {
7483 if index == row {
7484 return (offset + col.min(line.len())).min(text.len());
7485 }
7486 offset = offset.saturating_add(line.len() + 1);
7487 }
7488 text.len()
7489}
7490
7491fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
7494 let offset = offset.min(text.len());
7495 let mut consumed = 0;
7496 for (row, line) in text.split('\n').enumerate() {
7497 let end = consumed + line.len();
7498 if offset <= end {
7499 editor.set_cursor(row, offset - consumed);
7500 return;
7501 }
7502 consumed = end + 1;
7503 }
7504 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
7505 editor.set_cursor(
7506 last_row,
7507 text.rsplit('\n').next().map(str::len).unwrap_or(0),
7508 );
7509}
7510
7511fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
7513 state.autocomplete_container.clear();
7514 let Some(sugg) = suggestions else {
7515 return;
7516 };
7517 if sugg.items.is_empty() {
7518 return;
7519 }
7520 let accent = state.theme_manager.get().colors.accent;
7523 let muted = state.theme_manager.get().colors.muted;
7524 for (i, item) in sugg
7525 .items
7526 .iter()
7527 .take(state.autocomplete_max_visible)
7528 .enumerate()
7529 {
7530 let prefix = if i == 0 { "→ " } else { " " };
7531 let label = item.display_text();
7532 let line = if i == 0 {
7533 format!(
7534 "{prefix}{} {}",
7535 accent.fg(label),
7536 muted.fg(item.description.as_deref().unwrap_or(""))
7537 )
7538 } else {
7539 format!(
7540 "{prefix}{} {}",
7541 muted.fg(label),
7542 muted.fg(item.description.as_deref().unwrap_or(""))
7543 )
7544 };
7545 state
7546 .autocomplete_container
7547 .add_child(Arc::new(Text::new(line, 1, 0)));
7548 }
7549}
7550
7551fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
7555 let text = editor.get_text();
7556 let cursor = editor_cursor_offset(editor, &text);
7557 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
7558 return false;
7559 };
7560 let Some(top) = sugg.items.first() else {
7561 return false;
7562 };
7563 let start = sugg.start.min(text.len());
7567 let end = sugg.end.min(text.len());
7568 let mut replaced = String::with_capacity(text.len() + top.text.len());
7569 replaced.push_str(&text[..start]);
7570 replaced.push_str(&top.text);
7571 replaced.push_str(&text[end..]);
7574 if top.insert_space && !replaced.ends_with('/') {
7575 replaced.push(' ');
7576 }
7577 let new_cursor = replaced.len().min(
7580 start
7581 + top.text.len()
7582 + if top.insert_space && !top.text.ends_with('/') {
7583 1
7584 } else {
7585 0
7586 },
7587 );
7588 editor.set_text(&replaced);
7589 set_editor_cursor_offset(editor, &replaced, new_cursor);
7590 state.autocomplete_container.clear();
7591 true
7592}
7593
7594fn add_welcome_message(container: &Arc<Container>) {
7600 add_welcome_message_with_capabilities(container, &[], &[]);
7601}
7602
7603fn add_welcome_message_with_capabilities(
7607 container: &Arc<Container>,
7608 active_tools: &[String],
7609 skills: &[String],
7610) {
7611 let c = current_theme().colors;
7612 let title = format!(
7616 "{} {}",
7617 c.accent.fg(&tui_bold("rpi")),
7618 c.muted.fg("interactive TUI")
7619 );
7620 container.add_child(Arc::new(Text::new(title, 1, 0)));
7621 container.add_child(Arc::new(Spacer::new(1)));
7622 container.add_child(Arc::new(Text::new(
7623 c.dim.fg("Type your message and press Enter to send."),
7624 1,
7625 0,
7626 )));
7627 let hint = c
7628 .dim
7629 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
7630 container.add_child(Arc::new(Text::new(hint, 1, 0)));
7631 container.add_child(Arc::new(Spacer::new(1)));
7632 container.add_child(Arc::new(Text::new(
7633 welcome_capability_line("Tools", active_tools),
7634 1,
7635 0,
7636 )));
7637 container.add_child(Arc::new(Text::new(
7638 welcome_capability_line("Skills", skills),
7639 1,
7640 0,
7641 )));
7642 container.add_child(Arc::new(DynamicBorder::new()));
7643}
7644
7645fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
7648 let colors = current_theme().colors;
7649 let group = Arc::new(Container::new());
7650
7651 for warning in &report.warnings {
7652 let body = format!(
7653 "{}\n{} {}{}",
7654 colors.error.fg(&warning.message),
7655 colors.muted.fg("Run"),
7656 colors.accent.fg(&warning.command),
7657 colors.muted.fg(" to retry.")
7658 );
7659 add_update_panel(&group, "Update Failed", &body, colors.error);
7660 }
7661
7662 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
7663 let body = format!(
7664 "{} {}{}",
7665 colors
7666 .muted
7667 .fg(&format!("New version {} is available. Run", notice.latest)),
7668 colors.accent.fg(¬ice.command),
7669 colors.muted.fg(".")
7670 );
7671 add_update_panel(&group, "Update Available", &body, colors.warning);
7672 }
7673
7674 let package_notices = report
7675 .notices
7676 .iter()
7677 .filter(|notice| notice.name != "rpi")
7678 .collect::<Vec<_>>();
7679 if !package_notices.is_empty() {
7680 let command = package_notices[0].command.as_str();
7681 let mut lines = vec![format!(
7682 "{} {}{}",
7683 colors.muted.fg("Package updates are available. Run"),
7684 colors.accent.fg(command),
7685 colors.muted.fg(".")
7686 )];
7687 lines.push(colors.muted.fg("Packages:"));
7688 lines.extend(
7689 package_notices
7690 .into_iter()
7691 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
7692 );
7693 add_update_panel(
7694 &group,
7695 "Package Updates Available",
7696 &lines.join("\n"),
7697 colors.warning,
7698 );
7699 }
7700
7701 if group.child_count() > 0 {
7705 container.add_child(group);
7706 }
7707}
7708
7709fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
7710 container.add_child(Arc::new(Spacer::new(1)));
7711 container.add_child(Arc::new(DynamicBorder::with_color(color)));
7712 container.add_child(Arc::new(Text::new(
7713 format!("{}\n{body}", color.fg(&tui_bold(title))),
7714 1,
7715 0,
7716 )));
7717 container.add_child(Arc::new(DynamicBorder::with_color(color)));
7718}
7719
7720fn welcome_capability_line(label: &str, names: &[String]) -> String {
7721 let c = current_theme().colors;
7722 let value = if names.is_empty() {
7723 "none".to_string()
7724 } else {
7725 names.join(" · ")
7726 };
7727 format!(
7728 "{} {}",
7729 c.accent.fg(&format!("{label} ({})", names.len())),
7730 c.muted.fg(&value)
7731 )
7732}
7733
7734fn add_help_message(container: &Arc<Container>) {
7736 let c = current_theme().colors;
7737 container.add_child(Arc::new(Text::new(
7741 c.md_heading.fg(&tui_bold("📚 Available Commands")),
7742 1,
7743 0,
7744 )));
7745 container.add_child(Arc::new(Spacer::new(1)));
7746
7747 let cmds: &[(&str, &str)] = &[
7748 ("/help, /?", "Show this help message"),
7749 ("/clear, /new", "Clear the conversation"),
7750 ("/exit, /quit, /q", "Exit the application"),
7751 ("/version, /v", "Show version information"),
7752 ("/changelog", "Show recent release changes"),
7753 ("/model, /m", "Choose a model (live switch)"),
7754 ("/thinking, /think", "Set reasoning depth (selector)"),
7755 ("/tools", "Toggle built-in tools on/off"),
7756 ("/images", "Toggle inline image rendering"),
7757 ("/session", "List saved sessions"),
7758 ("/theme", "Choose a theme (selector)"),
7759 ("/compact", "Compact the conversation"),
7760 ("/copy", "Copy last reply to clipboard"),
7761 ("/hotkeys", "Show keyboard shortcuts"),
7762 ("/armin", "🐾 Easter egg"),
7763 ("/earendil", "Earendil announcement"),
7764 ];
7765 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7766 for (cmd, desc) in cmds {
7767 let row = format!(
7768 " {:<cmd_w$} {} {}",
7769 c.accent.fg(cmd),
7770 c.dim.fg("—"),
7771 c.muted.fg(desc)
7772 );
7773 container.add_child(Arc::new(Text::new(row, 1, 0)));
7774 }
7775 container.add_child(Arc::new(Spacer::new(1)));
7776}
7777
7778fn add_version_message(container: &Arc<Container>) {
7780 let c = current_theme().colors;
7781 container.add_child(Arc::new(Text::new(
7782 c.md_heading.fg(&tui_bold("📦 Version Information")),
7783 1,
7784 0,
7785 )));
7786 container.add_child(Arc::new(Spacer::new(1)));
7787 container.add_child(Arc::new(Text::new(
7790 format!(
7791 " {} {}",
7792 c.muted.fg("rpi-cli"),
7793 c.text.fg(&format!("v{}", crate::VERSION))
7794 ),
7795 1,
7796 0,
7797 )));
7798 container.add_child(Arc::new(Text::new(
7799 format!(
7800 " {}",
7801 c.dim.fg("Rust implementation of pi coding agent TUI")
7802 ),
7803 1,
7804 0,
7805 )));
7806 container.add_child(Arc::new(Spacer::new(1)));
7807}
7808
7809fn add_changelog_message(container: &Arc<Container>) {
7813 let c = current_theme().colors;
7814 container.add_child(Arc::new(Text::new(
7815 c.md_heading.fg(&tui_bold("Recent Changes")),
7816 1,
7817 0,
7818 )));
7819 container.add_child(Arc::new(Spacer::new(1)));
7820 let entries = [
7821 (
7822 "Native parity phase 1",
7823 "models, images, trust, export, and JSON events",
7824 ),
7825 (
7826 "TUI controls",
7827 "external editor, thinking levels, and tool output toggles",
7828 ),
7829 (
7830 "Provider auth",
7831 "OpenAI-compatible API key aliases and gateway headers",
7832 ),
7833 ];
7834 for (release, summary) in entries {
7835 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
7836 container.add_child(Arc::new(Text::new(row, 1, 0)));
7837 }
7838 container.add_child(Arc::new(Text::new(
7839 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
7840 1,
7841 0,
7842 )));
7843 container.add_child(Arc::new(Spacer::new(1)));
7844}
7845
7846fn add_hotkeys_message(container: &Arc<Container>) {
7848 let c = current_theme().colors;
7849 container.add_child(Arc::new(Text::new(
7850 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
7851 1,
7852 0,
7853 )));
7854 container.add_child(Arc::new(Spacer::new(1)));
7855 let keys: &[(&str, &str)] = &[
7856 ("Enter", "Send message"),
7857 ("Shift+Enter", "New line"),
7858 ("Tab", "Accept autocomplete suggestion"),
7859 ("Ctrl+A / Ctrl+E", "Line start / end"),
7860 (
7861 "Ctrl+K / Ctrl+U",
7862 "Kill to end / start of line (Ctrl+Y yanks)",
7863 ),
7864 ("Ctrl+- / Ctrl+R", "Undo / redo"),
7865 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
7866 ("Alt+Backspace", "Kill previous word"),
7867 ("Ctrl+C", "Abort a run, or exit when idle"),
7868 ("Esc", "Abort a running prompt"),
7869 ("Ctrl+L", "Open model selector"),
7870 ("Ctrl+M", "Cycle to the next model (live)"),
7871 ("Ctrl+O", "Expand/collapse all tool output"),
7872 ("Ctrl+T", "Show/hide reasoning blocks"),
7873 ("PageUp/Down", "Scroll transcript by one page"),
7874 ("Home / End", "Jump to transcript start / latest output"),
7875 ];
7876 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7877 for (key, desc) in keys {
7878 let row = format!(
7879 " {:<key_w$} {} {}",
7880 c.accent.fg(key),
7881 c.dim.fg("—"),
7882 c.muted.fg(desc)
7883 );
7884 container.add_child(Arc::new(Text::new(row, 1, 0)));
7885 }
7886 container.add_child(Arc::new(Spacer::new(1)));
7887}
7888
7889fn add_user_message(container: &Arc<Container>, text: &str) {
7895 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
7896 container.add_child(Arc::new(Spacer::new(1)));
7897}
7898
7899fn add_error_message(container: &Arc<Container>, text: &str) {
7901 let c = current_theme().colors;
7902 let text = sanitize_error_message(text);
7903 container.add_child(Arc::new(Text::new(
7904 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
7905 1,
7906 0,
7907 )));
7908 container.add_child(Arc::new(Spacer::new(1)));
7909}
7910
7911fn sanitize_error_message(text: &str) -> String {
7916 const MAX_ERROR_CHARS: usize = 16 * 1024;
7917 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
7918 let mut count = 0;
7919 let mut escape_mode = 0u8;
7921 for ch in text.chars() {
7922 if escape_mode != 0 {
7923 match escape_mode {
7924 1 if ch == '[' => escape_mode = 2,
7925 1 if ch == ']' => escape_mode = 3,
7926 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
7927 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
7928 3 if ch == '\x07' => escape_mode = 0,
7929 _ => {}
7930 }
7931 continue;
7932 }
7933 if ch == '\x1b' {
7934 escape_mode = 1;
7935 continue;
7936 }
7937 if count >= MAX_ERROR_CHARS {
7938 result.push_str("…");
7939 break;
7940 }
7941 match ch {
7942 '\n' | '\t' => {
7943 result.push(ch);
7944 count += 1;
7945 }
7946 '\r' => {}
7947 c if c.is_control() => {}
7948 c => {
7949 result.push(c);
7950 count += 1;
7951 }
7952 }
7953 }
7954 if result.trim().is_empty() {
7955 "Provider request failed.".to_string()
7956 } else {
7957 result
7958 }
7959}
7960
7961fn add_note_message(container: &Arc<Container>, text: &str) {
7963 let c = current_theme().colors;
7964 container.add_child(Arc::new(Text::new(
7965 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
7966 1,
7967 0,
7968 )));
7969 container.add_child(Arc::new(Spacer::new(1)));
7970}
7971
7972fn show_context_panel(
7981 chat: &Arc<Container>,
7982 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
7983) {
7984 let skills = resources.skills.as_deref().unwrap_or(&[]);
7985 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
7986 let mut lines: Vec<String> = Vec::new();
7987 lines.push("📂 Discovered resources for this session:".into());
7988
7989 if skills.is_empty() {
7990 lines.push(
7991 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
7992 );
7993 } else {
7994 lines.push(format!(" Skills ({}):", skills.len()));
7995 for s in skills {
7996 let marker = if s.disable_model_invocation == Some(true) {
7997 " [hidden]"
7998 } else {
7999 ""
8000 };
8001 let desc: String = s.description.chars().take(72).collect();
8002 lines.push(format!(" • {}{marker} — {desc}", s.name));
8003 }
8004 }
8005
8006 if templates.is_empty() {
8007 lines.push(
8008 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8009 );
8010 } else {
8011 lines.push(format!(" Prompt templates ({}):", templates.len()));
8012 for t in templates {
8013 let desc = t
8014 .description
8015 .as_deref()
8016 .unwrap_or("(no description)")
8017 .chars()
8018 .take(72)
8019 .collect::<String>();
8020 lines.push(format!(" • /{} — {desc}", t.name));
8021 }
8022 }
8023 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8024 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8025 lines.push(
8026 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8027 .into(),
8028 );
8029 let body = lines.join("\n");
8030 container_note_block(chat, &body);
8031}
8032
8033fn container_note_block(container: &Arc<Container>, body: &str) {
8035 for line in body.lines() {
8036 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8037 }
8038 container.add_child(Arc::new(Spacer::new(1)));
8039}
8040
8041pub fn is_tui_supported() -> bool {
8047 std::io::stdout().is_terminal()
8048}
8049
8050#[allow(unused_imports)]
8052use rpi_tui::Color as _Color;
8053
8054#[cfg(test)]
8055mod tests {
8056 use super::*;
8057 use rpi_tui::Component;
8058
8059 #[test]
8060 fn verbose_overrides_quiet_startup_listing() {
8061 assert!(should_show_startup_listing(false, false));
8062 assert!(should_show_startup_listing(true, false));
8063 assert!(should_show_startup_listing(true, true));
8064 assert!(!should_show_startup_listing(false, true));
8065 }
8066
8067 #[test]
8068 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8069 let tmp = tempfile::tempdir().unwrap();
8070 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8071 let local_root = tmp.path().join("local-package");
8072 std::fs::create_dir_all(git_root.join(".git")).unwrap();
8073 std::fs::create_dir_all(&local_root).unwrap();
8074 std::fs::write(
8075 git_root.join("package.json"),
8076 r#"{"name":"git-demo","version":"1.0.0"}"#,
8077 )
8078 .unwrap();
8079 std::fs::write(
8080 local_root.join("package.json"),
8081 r#"{"name":"local-demo","version":"1.0.0"}"#,
8082 )
8083 .unwrap();
8084 let resources = crate::packages::discover(
8085 tmp.path(),
8086 &[
8087 "git:github.com/example/repo".to_string(),
8088 local_root.to_string_lossy().into_owned(),
8089 ],
8090 );
8091 assert_eq!(resources.packages.len(), 2);
8092
8093 let filtered = git_only_update_resources(resources);
8094
8095 assert_eq!(filtered.packages.len(), 1);
8096 assert!(matches!(
8097 filtered.packages[0].source,
8098 crate::packages::PackageSource::Git
8099 ));
8100 }
8101
8102 #[test]
8103 fn trust_command_uses_the_session_cwd_after_process_chdir() {
8104 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8105 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8106 const TEST_NAME: &str =
8107 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8108
8109 if std::env::var_os(CHILD_ENV).is_some() {
8110 let session_cwd = std::path::PathBuf::from(
8111 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8112 );
8113 let changed_cwd = std::env::current_dir().unwrap();
8114
8115 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8116
8117 assert_eq!(
8118 crate::config::project_trust_decision(&session_cwd).unwrap(),
8119 Some(true)
8120 );
8121 assert_eq!(
8122 crate::config::project_trust_decision(&changed_cwd).unwrap(),
8123 None
8124 );
8125 return;
8126 }
8127
8128 let tmp = tempfile::tempdir().unwrap();
8129 let session_cwd = tmp.path().join("session-project");
8130 let changed_cwd = tmp.path().join("extension-cwd");
8131 let agent_dir = tmp.path().join("agent");
8132 std::fs::create_dir_all(&session_cwd).unwrap();
8133 std::fs::create_dir_all(&changed_cwd).unwrap();
8134 std::fs::create_dir_all(&agent_dir).unwrap();
8135
8136 let status = std::process::Command::new(std::env::current_exe().unwrap())
8137 .arg("--exact")
8138 .arg(TEST_NAME)
8139 .arg("--nocapture")
8140 .env(CHILD_ENV, "1")
8141 .env(SESSION_CWD_ENV, &session_cwd)
8142 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8143 .current_dir(&changed_cwd)
8144 .status()
8145 .unwrap();
8146
8147 assert!(status.success(), "child test process failed: {status}");
8148 }
8149
8150 #[test]
8151 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8152 let global = crate::settings::Settings {
8153 editor_padding_x: Some(3),
8154 autocomplete_max_visible: Some(6),
8155 hide_thinking_block: Some(false),
8156 quiet_startup: Some(true),
8157 show_terminal_progress: Some(true),
8158 ..Default::default()
8159 };
8160 let rpi_project = crate::settings::Settings {
8161 editor_padding_x: Some(0),
8162 hide_thinking_block: Some(true),
8163 quiet_startup: Some(false),
8164 show_terminal_progress: Some(false),
8165 ..Default::default()
8166 };
8167 let pi_project = crate::settings::Settings {
8168 editor_padding_x: Some(9),
8169 autocomplete_max_visible: Some(12),
8170 quiet_startup: Some(true),
8171 ..Default::default()
8172 };
8173
8174 assert_eq!(
8175 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8176 TuiStartupSettings {
8177 editor_padding_x: 0,
8178 autocomplete_max_visible: 12,
8179 hide_thinking: true,
8180 quiet_startup: false,
8181 show_terminal_progress: false,
8182 }
8183 );
8184 }
8185
8186 #[test]
8187 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8188 let global = crate::settings::Settings {
8189 quiet_startup: Some(false),
8190 ..Default::default()
8191 };
8192 let rpi_project = crate::settings::Settings::default();
8193 let pi_project = crate::settings::Settings {
8194 quiet_startup: Some(true),
8195 ..Default::default()
8196 };
8197
8198 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8199
8200 assert!(resolved.quiet_startup);
8201 }
8202
8203 #[test]
8204 fn tui_startup_settings_use_global_values_without_project_fields() {
8205 let global = crate::settings::Settings {
8206 editor_padding_x: Some(7),
8207 autocomplete_max_visible: Some(8),
8208 hide_thinking_block: Some(true),
8209 quiet_startup: Some(true),
8210 show_terminal_progress: Some(false),
8211 ..Default::default()
8212 };
8213
8214 assert_eq!(
8215 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8216 TuiStartupSettings {
8217 editor_padding_x: 7,
8218 autocomplete_max_visible: 8,
8219 hide_thinking: true,
8220 quiet_startup: true,
8221 show_terminal_progress: false,
8222 }
8223 );
8224 }
8225
8226 #[test]
8227 fn tui_startup_settings_ignore_untrusted_project_values() {
8228 let global = crate::settings::Settings {
8229 quiet_startup: Some(false),
8230 show_terminal_progress: Some(true),
8231 ..Default::default()
8232 };
8233 let project = crate::settings::Settings {
8234 quiet_startup: Some(true),
8235 show_terminal_progress: Some(false),
8236 ..Default::default()
8237 };
8238
8239 let resolved = resolve_tui_startup_settings(&global, &[project], false);
8240
8241 assert!(!resolved.quiet_startup);
8242 assert!(resolved.show_terminal_progress);
8243 }
8244
8245 #[test]
8246 fn transcript_page_uses_viewport_with_overlap() {
8247 assert_eq!(transcript_page_size(24), 20);
8248 assert_eq!(transcript_page_size(4), 1);
8249 assert_eq!(transcript_page_size(0), 1);
8250 }
8251
8252 #[test]
8253 fn key_repeat_is_dispatched_but_release_is_not() {
8254 assert!(should_dispatch_key(KeyEventKind::Press));
8255 assert!(should_dispatch_key(KeyEventKind::Repeat));
8256 assert!(!should_dispatch_key(KeyEventKind::Release));
8257 }
8258
8259 #[test]
8260 fn key_event_encoding_matches_pi_keybinding_protocol() {
8261 let key = |code, modifiers| KeyEvent::new(code, modifiers);
8262 assert_eq!(
8263 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
8264 "\r"
8265 );
8266 assert_eq!(
8267 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
8268 "\x1b[13;2u"
8269 );
8270 assert_eq!(
8271 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
8272 "\x1b[9;2u"
8273 );
8274 assert_eq!(
8275 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
8276 "\x1b[Z"
8277 );
8278 assert_eq!(
8279 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
8280 "\x1b[Z"
8281 );
8282 assert_eq!(
8283 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
8284 "\x03"
8285 );
8286 assert_eq!(
8287 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
8288 "\x0f"
8289 );
8290 assert_eq!(
8291 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
8292 "!"
8293 );
8294 assert_eq!(
8295 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
8296 "1"
8297 );
8298 }
8299
8300 #[test]
8301 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
8302 let bridge = JsDialogBridge::default();
8303 let (sender, receiver) = std_mpsc::channel();
8304 bridge.pending.lock().unwrap().push_back(JsDialogPending {
8305 request: JsDialogRequest {
8306 id: "dialog-1".into(),
8307 method: "input".into(),
8308 title: String::new(),
8309 message: String::new(),
8310 options: Vec::new(),
8311 placeholder: None,
8312 prefill: None,
8313 },
8314 result: sender,
8315 });
8316
8317 bridge.cancel("dialog-1");
8320 assert!(bridge.take_pending().is_none());
8321 assert_eq!(
8322 receiver.recv().unwrap(),
8323 serde_json::json!({ "cancelled": true })
8324 );
8325 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
8326 }
8327
8328 #[test]
8329 fn test_layout_renders_welcome_message() {
8330 let chat = Arc::new(Container::new());
8331 add_welcome_message(&chat);
8332
8333 let scroll = Arc::new(ScrollView::new(
8334 chat.clone(),
8335 ScrollViewOptions {
8336 follow: FollowMode::End,
8337 primary: true,
8338 ..Default::default()
8339 },
8340 ));
8341
8342 let editor = Arc::new(Editor::new(
8343 EditorOptions {
8344 padding_x: 1,
8345 ..Default::default()
8346 },
8347 EditorStyle::default(),
8348 Arc::new(rpi_tui::Keybindings::new()),
8349 ));
8350 let dock = Arc::new(Container::new());
8351 dock.add_child(editor);
8352
8353 let footer = Arc::new(FooterComponent::new());
8354
8355 let root = VStack::from_children(vec![
8356 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
8357 StackChild::Entry(StackEntry::new(dock)),
8358 StackChild::Entry(StackEntry::new(footer)),
8359 ]);
8360
8361 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
8362
8363 let all: String = frame.lines.join("\n");
8364 assert!(
8365 all.contains("rpi"),
8366 "Welcome message not found. Rendered: {}",
8367 all
8368 );
8369 assert!(
8370 all.contains("Type your message"),
8371 "Help text not found. Rendered: {}",
8372 all
8373 );
8374 }
8375
8376 #[test]
8377 fn test_chat_container_has_welcome_content() {
8378 let chat = Arc::new(Container::new());
8379 add_welcome_message_with_capabilities(
8380 &chat,
8381 &["read".into(), "bash".into(), "web_fetch".into()],
8382 &["rust-review".into(), "release".into()],
8383 );
8384
8385 let lines = chat.render(80);
8386 let all: String = lines.join("\n");
8387 let plain = strip_ansi(&all);
8390 assert!(
8391 plain.contains("rpi"),
8392 "Welcome message not in chat container: {:?}",
8393 lines
8394 );
8395 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
8396 assert!(
8397 plain.contains("read · bash · web_fetch"),
8398 "Tool names missing: {plain}"
8399 );
8400 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
8401 assert!(
8402 plain.contains("rust-review · release"),
8403 "Skill names missing: {plain}"
8404 );
8405 }
8406
8407 #[test]
8408 fn update_notices_render_inside_the_transcript() {
8409 let chat = Arc::new(Container::new());
8410 let report = crate::updates::UpdateReport {
8411 notices: vec![
8412 crate::updates::UpdateNotice {
8413 name: "rpi".into(),
8414 current: "0.1.10".into(),
8415 latest: "0.1.11".into(),
8416 command: "rpi update".into(),
8417 },
8418 crate::updates::UpdateNotice {
8419 name: "rpi-search".into(),
8420 current: "0.1.0".into(),
8421 latest: "0.1.1".into(),
8422 command: "rpi pi-package update".into(),
8423 },
8424 ],
8425 warnings: vec![crate::updates::UpdateWarning {
8426 message: "The previously scheduled rpi update failed: access denied".into(),
8427 command: "rpi update".into(),
8428 }],
8429 };
8430
8431 add_update_notices(&chat, &report);
8432
8433 assert_eq!(chat.child_count(), 1);
8434 let plain = strip_ansi(&chat.render(80).join("\n"));
8435 assert!(plain.contains("Update Failed"), "{plain}");
8436 assert!(
8437 plain.contains("rpi update failed: access denied"),
8438 "{plain}"
8439 );
8440 assert!(plain.contains("Update Available"), "{plain}");
8441 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
8442 assert!(plain.contains("rpi update"), "{plain}");
8443 assert!(plain.contains("Package Updates Available"), "{plain}");
8444 assert!(plain.contains("rpi pi-package update"), "{plain}");
8445 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
8446 }
8447
8448 #[test]
8449 fn empty_update_report_does_not_add_transcript_content() {
8450 let chat = Arc::new(Container::new());
8451
8452 add_update_notices(&chat, &crate::updates::UpdateReport::default());
8453
8454 assert_eq!(chat.child_count(), 0);
8455 }
8456
8457 #[test]
8458 fn skill_reads_are_detected_by_path() {
8459 let name = skill_tool_name(
8460 "read",
8461 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
8462 );
8463 assert_eq!(name.as_deref(), Some("release"));
8464
8465 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
8466 assert!(name.is_none());
8467
8468 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
8470 }
8471
8472 #[test]
8473 fn welcome_capabilities_show_empty_state() {
8474 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
8475 assert_eq!(plain, "Skills (0) none");
8476 }
8477
8478 #[test]
8479 fn empty_tool_progress_is_deferred_until_start() {
8480 let empty = rpi_agent::AgentToolResult::default();
8481 assert!(!tool_update_has_payload("", &empty));
8482
8483 let text = rpi_agent::AgentToolResult::text("partial output");
8484 assert!(tool_update_has_payload("partial output", &text));
8485
8486 let details = rpi_agent::AgentToolResult {
8487 details: serde_json::json!({"path": "src/lib.rs"}),
8488 ..Default::default()
8489 };
8490 assert!(tool_update_has_payload("", &details));
8491 }
8492
8493 #[test]
8494 fn completed_stream_reconciles_the_final_tail_before_detaching() {
8495 let component = Arc::new(AssistantMessageComponent::new(
8496 AssistantMessageOptions::default(),
8497 ));
8498 component.set_streaming(true);
8499 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
8500
8501 let current = Mutex::new(Some(component.clone()));
8502 let cached = Mutex::new("partial response".to_string());
8503 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
8504 final_message.content = vec![Content::text(
8505 "partial response with the previously missing final tail",
8506 )];
8507 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
8508
8509 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
8510
8511 assert!(current.lock().unwrap().is_none());
8512 assert_eq!(
8513 cached.lock().unwrap().as_str(),
8514 "partial response with the previously missing final tail"
8515 );
8516 let rendered = strip_ansi(&component.render(100).join("\n"));
8517 assert!(
8518 rendered.contains("previously missing final tail"),
8519 "{rendered}"
8520 );
8521 }
8522
8523 #[test]
8529 fn tab_accept_suggestion_reflects_in_next_render() {
8530 use rpi_tui::render_layout_frame;
8531
8532 let editor = Arc::new(Editor::new(
8533 EditorOptions {
8534 padding_x: 1,
8535 ..Default::default()
8536 },
8537 EditorStyle::default(),
8538 Arc::new(rpi_tui::Keybindings::new()),
8539 ));
8540 editor.set_focused(true);
8541 let editor_container = Arc::new(Container::new());
8542 editor_container.add_child(editor.clone());
8543 let autocomplete_container = Arc::new(Container::new());
8544 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
8545 let dock = Arc::new(VStack::from_children(vec![
8546 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
8547 StackChild::Entry(
8548 StackEntry::new(editor_container.clone())
8549 .shrink(0)
8550 .min_size(3),
8551 ),
8552 StackChild::Entry(StackEntry::new(footer)),
8553 ]));
8554
8555 let mut manager = AutocompleteManager::new();
8557 let mut combined = CombinedAutocompleteProvider::new();
8558 combined.add_provider(Arc::new(
8559 SlashCommandAutocompleteProvider::with_default_commands(),
8560 ));
8561 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
8562 manager.set_provider(Arc::new(combined));
8563 editor.insert("/mo");
8566 assert_eq!(editor.cursor_position(), (0, 3));
8567
8568 let frame_before = render_layout_frame(dock.clone(), 80, 10);
8569 assert!(
8570 frame_before.lines.iter().any(|l| l.contains("/mo")),
8571 "precondition: editor shows the typed prefix. Frame rows:\n{}",
8572 frame_before
8573 .lines
8574 .iter()
8575 .map(|l| format!(" [{l}]"))
8576 .collect::<Vec<_>>()
8577 .join("\n")
8578 );
8579
8580 let text = editor.get_text();
8582 let cursor = editor_cursor_offset(&editor, &text);
8583 let sugg = manager
8584 .get_suggestions(&text, cursor)
8585 .expect("slash suggestions for /mo");
8586 let top = sugg.items.first().expect("at least one suggestion");
8587 let start = sugg.start.min(text.len());
8588 let end = sugg.end.min(text.len());
8589 let mut replaced = String::new();
8590 replaced.push_str(&text[..start]);
8591 replaced.push_str(&top.text);
8592 replaced.push_str(&text[end..]);
8593 if top.insert_space && !replaced.ends_with('/') {
8594 replaced.push(' ');
8595 }
8596 let new_cursor = start + top.text.len();
8597 editor.set_text(&replaced);
8598 set_editor_cursor_offset(&editor, &replaced, new_cursor);
8599 autocomplete_container.clear();
8600 assert_eq!(editor.get_text(), "/model");
8601
8602 let frame_after = render_layout_frame(dock, 80, 10);
8604 let all: String = frame_after.lines.join("\n");
8605 assert!(
8606 all.contains("/model"),
8607 "completed text missing from next render. Got:\n{all}"
8608 );
8609 let editor_line = frame_after
8613 .lines
8614 .iter()
8615 .find(|l| l.contains("/model"))
8616 .expect("editor row with completed text");
8617 assert!(
8618 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
8619 "caret must follow the full completed text. Got: {editor_line:?}"
8620 );
8621 }
8622
8623 #[test]
8624 fn multiline_autocomplete_preserves_row_and_column() {
8625 let editor = Arc::new(Editor::simple());
8626 editor.set_text("first\n/mo");
8627 editor.set_cursor(1, 3);
8628
8629 let text = editor.get_text();
8630 assert_eq!(editor_cursor_offset(&editor, &text), 9);
8631
8632 let replaced = "first\n/model";
8633 editor.set_text(replaced);
8634 set_editor_cursor_offset(&editor, replaced, 12);
8635 assert_eq!(editor.cursor_position(), (1, 6));
8636 }
8637
8638 #[test]
8639 fn test_slash_command_dispatch() {
8640 let registry = build_builtin_registry();
8645
8646 let resolves_to = |token: &str, canonical: &str| {
8648 let found = registry.find(token).expect("{token} should resolve");
8649 assert_eq!(
8650 found.name(),
8651 canonical,
8652 "{token} resolved to {} (expected {canonical})",
8653 found.name()
8654 );
8655 };
8656
8657 resolves_to("/help", "/help");
8658 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
8660 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
8664 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
8666 resolves_to("/hotkeys", "/hotkeys");
8667 resolves_to("/model", "/model");
8668 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
8670 resolves_to("/session", "/session");
8671 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
8673 resolves_to("/copy", "/copy");
8674 resolves_to("/thinking", "/thinking");
8675 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
8677 resolves_to("/images", "/images");
8678 resolves_to("/armin", "/armin");
8679 resolves_to("/earendil", "/earendil");
8680 resolves_to("/context", "/context");
8681 resolves_to("/settings", "/settings");
8683 resolves_to("/name", "/name");
8684 resolves_to("/export", "/export");
8685
8686 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
8688 }
8689
8690 #[test]
8691
8692 fn test_registry_visible_entries_cover_dispatch() {
8693 let registry = build_builtin_registry();
8698 let names: Vec<String> = registry
8699 .visible_entries()
8700 .iter()
8701 .map(|c| c.name.clone())
8702 .collect();
8703 for recognized in [
8704 "/help",
8705 "/clear",
8706 "/new",
8707 "/exit",
8708 "/quit",
8709 "/version",
8710 "/changelog",
8711 "/model",
8712 "/session",
8713 "/theme",
8714 "/compact",
8715 "/copy",
8716 "/hotkeys",
8717 "/tools",
8718 "/images",
8719 "/thinking",
8720 "/armin",
8721 "/earendil",
8722 ] {
8723 assert!(
8724 names.contains(&recognized.to_string()),
8725 "{recognized} missing from autocomplete list"
8726 );
8727 }
8728 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
8730 assert!(
8731 !names.contains(&hidden.to_string()),
8732 "{hidden} should be hidden from autocomplete"
8733 );
8734 }
8735 }
8736
8737 #[test]
8738 fn test_agent_event_mapping_creates_assistant_and_tool() {
8739 use rpi_ai::types::{
8742 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
8743 ToolCall, ToolCallType, Usage,
8744 };
8745
8746 let state = Arc::new(TuiState {
8747 current_assistant: std::sync::Mutex::new(None),
8748 tool_components: std::sync::Mutex::new(HashMap::new()),
8749 bash_components: std::sync::Mutex::new(HashMap::new()),
8750 themes_enabled: true,
8751 hide_thinking: std::sync::Mutex::new(false),
8752 tool_outputs_expanded: std::sync::Mutex::new(false),
8753 show_terminal_progress: true,
8754 status: std::sync::Mutex::new(RunStatus::Idle),
8755 js_preparation_cancel: std::sync::Mutex::new(None),
8756 footer: Arc::new(FooterComponent::new()),
8757 status_container: Arc::new(Container::new()),
8758 chat_container: Arc::new(Container::new()),
8759 loader: Arc::new(Loader::new()),
8760 last_assistant_text: std::sync::Mutex::new(String::new()),
8761 active_selector: std::sync::Mutex::new(None),
8762 active_extension_editor: std::sync::Mutex::new(None),
8763 active_extension_input: std::sync::Mutex::new(None),
8764 active_extension_cancel: std::sync::Mutex::new(None),
8765 autocomplete: AutocompleteManager::new(),
8766 autocomplete_container: Arc::new(Container::new()),
8767 autocomplete_max_visible: 5,
8768 pending_images: std::sync::Mutex::new(Vec::new()),
8769 theme_manager: Arc::new(ThemeManager::new()),
8770 tui: None,
8771 current_model_id: std::sync::Mutex::new(String::new()),
8772 show_images: std::sync::Mutex::new(true),
8773 history: std::sync::Mutex::new(Vec::new()),
8774 history_index: std::sync::Mutex::new(-1),
8775 history_draft: std::sync::Mutex::new(None),
8776 last_input_tokens: std::sync::Mutex::new(0),
8777 scoped_edit: std::sync::Mutex::new(None),
8778 markdown_transformer: std::sync::Mutex::new(None),
8779 extension_session: Arc::new(std::sync::Mutex::new(
8780 rpi_extensions::ExtensionSession::none(),
8781 )),
8782 });
8783
8784 let assistant = AssistantMessage {
8788 role: rpi_ai::types::AssistantRole,
8789 content: vec![
8790 Content::Thinking(ThinkingContent {
8791 kind: ThinkingContentType,
8792 thinking: "Reasoning about the reply.".into(),
8793 thinking_signature: None,
8794 redacted: false,
8795 }),
8796 Content::Text(TextContent {
8797 kind: TextContentType,
8798 text: "Hello.".into(),
8799 text_signature: None,
8800 }),
8801 Content::ToolCall(ToolCall {
8802 kind: ToolCallType,
8803 id: "tc1".into(),
8804 name: "bash".into(),
8805 arguments: serde_json::json!({"command": "echo hi"}),
8806 thought_signature: None,
8807 namespace: None,
8808 }),
8809 ],
8810 api: rpi_ai::Api::AnthropicMessages,
8811 provider: "anthropic".into(),
8812 model: "claude-sonnet-5".into(),
8813 response_model: None,
8814 response_id: None,
8815 usage: Usage::zero(),
8816 stop_reason: StopReason::Stop,
8817 deferred: None,
8818 error_message: None,
8819 raw_stop_reason: None,
8820 end_turn: None,
8821 timestamp: 0,
8822 };
8823
8824 let comp = Arc::new(AssistantMessageComponent::new(
8827 AssistantMessageOptions::default(),
8828 ));
8829 comp.set_streaming(true);
8830 comp.update_blocks(&assistant_blocks(&assistant));
8831 let chat = Arc::new(Container::new());
8832 chat.add_child(comp.clone());
8833 *state.current_assistant.lock().unwrap() = Some(comp);
8834
8835 for c in &assistant.content {
8837 if let Content::ToolCall(tc) = c {
8838 let mut tools = state.tool_components.lock().unwrap();
8839 if !tools.contains_key(&tc.id) {
8840 let tc_comp = Arc::new(ToolExecutionComponent::new(
8841 &tc.name,
8842 &tc.arguments.to_string(),
8843 ));
8844 tc_comp.set_running();
8845 chat.add_child(tc_comp.clone());
8846 tools.insert(tc.id.clone(), tc_comp);
8847 }
8848 }
8849 }
8850
8851 let rendered = chat.render(80);
8855 let joined: String = rendered.join("\n");
8856 assert!(
8857 joined.contains("Hello."),
8858 "assistant text not rendered: {joined}"
8859 );
8860 assert!(
8861 joined.contains("Reasoning about the reply."),
8862 "thinking block not rendered: {joined}"
8863 );
8864 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
8865 assert!(state.current_assistant.lock().unwrap().is_some());
8866
8867 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
8869 ended.set_result("hi", false);
8870 assert!(state.tool_components.lock().unwrap().is_empty());
8871
8872 assert!(state.try_start_working());
8876 assert!(
8877 !state.try_start_working(),
8878 "a second submit must be rejected"
8879 );
8880 state.set_status(RunStatus::Idle);
8881 state.set_status(RunStatus::Working);
8882 assert_eq!(state.status_container.child_count(), 1);
8883 assert!(state.footer.get_status().is_empty());
8884 state.show_retry(3, 10, 8_000);
8885 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
8886 assert!(retry_status.contains("Retrying (3/10)"));
8887 state.set_status(RunStatus::Working);
8888 {
8889 let mut bash = state.bash_components.lock().unwrap();
8890 bash.insert(
8891 "bash-1".into(),
8892 Arc::new(BashExecutionComponent::new("one")),
8893 );
8894 bash.insert(
8895 "bash-2".into(),
8896 Arc::new(BashExecutionComponent::new("two")),
8897 );
8898 }
8899 state.sync_working_loader_with_bash();
8900 assert_eq!(state.status_container.child_count(), 0);
8901 state.bash_components.lock().unwrap().remove("bash-1");
8902 state.sync_working_loader_with_bash();
8903 assert_eq!(state.status_container.child_count(), 0);
8904 state.bash_components.lock().unwrap().remove("bash-2");
8905 state.sync_working_loader_with_bash();
8906 assert_eq!(state.status_container.child_count(), 1);
8907
8908 state.set_status(RunStatus::Aborting);
8909 assert_eq!(state.status_container.child_count(), 0);
8910 assert!(!state.loader.is_running());
8911 }
8912
8913 #[test]
8914 fn fresh_launch_does_not_restore_old_history() {
8915 let fresh = Args::default();
8916 assert!(!launch_restores_history(&fresh));
8917
8918 let continued = Args {
8919 continue_session: true,
8920 ..Args::default()
8921 };
8922 assert!(launch_restores_history(&continued));
8923
8924 let selected = Args {
8925 session: Some("session-id".into()),
8926 ..Args::default()
8927 };
8928 assert!(launch_restores_history(&selected));
8929 }
8930
8931 #[test]
8932 fn test_short_model_name() {
8933 assert_eq!(
8934 short_model_name("anthropic:claude-sonnet-5"),
8935 "claude-sonnet-5"
8936 );
8937 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
8938 }
8939
8940 #[test]
8941 fn model_selector_items_are_deduplicated_and_provider_qualified() {
8942 use rpi_ai::{Api, Model};
8943
8944 let mut gateway = Model::new(
8945 "gpt-5.6-sol",
8946 "GPT 5.6 Sol",
8947 Api::OpenaiCompletions,
8948 "routeryo-copy",
8949 "https://gateway.example.com",
8950 );
8951 let duplicate = gateway.clone();
8952 let anthropic = Model::new(
8953 "claude-sonnet-5",
8954 "Claude Sonnet 5",
8955 Api::AnthropicMessages,
8956 "anthropic",
8957 "https://api.anthropic.com",
8958 );
8959 gateway.headers = Some(std::collections::BTreeMap::from([(
8960 "authorization".into(),
8961 "Bearer test".into(),
8962 )]));
8963
8964 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
8965 assert_eq!(items.len(), 2);
8966 assert_eq!(items[0].value, "gpt-5.6-sol");
8967 assert_eq!(items[0].label, "GPT 5.6 Sol");
8968 assert_eq!(
8969 items[0].description.as_deref(),
8970 Some("routeryo-copy/gpt-5.6-sol (current)")
8971 );
8972 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
8973 }
8974
8975 #[test]
8976 fn model_selector_match_accepts_bare_and_qualified_ids() {
8977 use rpi_ai::{Api, Model};
8978
8979 let gateway = Model::new(
8980 "gpt-5.6-sol",
8981 "GPT 5.6 Sol",
8982 Api::OpenaiCompletions,
8983 "routeryo-copy",
8984 "https://gateway.example.com",
8985 );
8986 let anthropic = Model::new(
8987 "claude-sonnet-5",
8988 "Claude Sonnet 5",
8989 Api::AnthropicMessages,
8990 "anthropic",
8991 "https://api.anthropic.com",
8992 );
8993 let catalog = [gateway, anthropic];
8994 assert_eq!(
8995 find_model_selector_match(&catalog, "gpt-5.6-sol")
8996 .unwrap()
8997 .provider,
8998 "routeryo-copy"
8999 );
9000 assert_eq!(
9001 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
9002 .unwrap()
9003 .id,
9004 "gpt-5.6-sol"
9005 );
9006 assert_eq!(
9007 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9008 .unwrap()
9009 .id,
9010 "claude-sonnet-5"
9011 );
9012 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9013 }
9014
9015 #[test]
9016 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9017 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9018
9019 let failed = AssistantMessage {
9020 role: AssistantRole,
9021 content: Vec::new(),
9022 api: rpi_ai::Api::AnthropicMessages,
9023 provider: "anthropic".into(),
9024 model: "claude-sonnet-5".into(),
9025 response_model: None,
9026 response_id: None,
9027 usage: Usage::zero(),
9028 stop_reason: StopReason::Error,
9029 deferred: None,
9030 error_message: Some("upstream returned 401".into()),
9031 raw_stop_reason: None,
9032 end_turn: None,
9033 timestamp: 0,
9034 };
9035 assert_eq!(
9036 assistant_error_text(&failed).as_deref(),
9037 Some("upstream returned 401")
9038 );
9039
9040 let mut no_detail = failed;
9041 no_detail.error_message = Some(" ".into());
9042 assert_eq!(
9043 assistant_error_text(&no_detail).as_deref(),
9044 Some("Provider request failed.")
9045 );
9046
9047 let mut aborted = no_detail;
9048 aborted.stop_reason = StopReason::Aborted;
9049 aborted.error_message = Some("abort error: Request aborted".into());
9050 assert_eq!(
9051 assistant_error_text(&aborted).as_deref(),
9052 Some("abort error: Request aborted")
9053 );
9054
9055 aborted.error_message = None;
9056 assert_eq!(
9057 assistant_error_text(&aborted).as_deref(),
9058 Some("Request aborted.")
9059 );
9060 }
9061
9062 #[test]
9063 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9064 assert_eq!(
9065 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9066 "405\nMethod Not Allowed"
9067 );
9068 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9069 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
9070 let long = "x".repeat(20_000);
9071 let cleaned = sanitize_error_message(&long);
9072 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9073 assert!(cleaned.ends_with('…'));
9074 }
9075
9076 #[test]
9077 fn test_cycle_next_model_wraps_around() {
9078 use rpi_ai::{Api, Model};
9079 let mk = |id: &str| {
9080 Model::new(
9081 id,
9082 id,
9083 Api::AnthropicMessages,
9084 "anthropic",
9085 "https://api.anthropic.com",
9086 )
9087 };
9088 let catalog = [mk("a"), mk("b"), mk("c")];
9089 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9091 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9092 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9094 let empty: Vec<Model> = vec![];
9096 assert!(cycle_next_model(&empty, "a").is_none());
9097 }
9098
9099 #[test]
9100 fn test_autocomplete_slash_suggestions_render() {
9101 let state = Arc::new(TuiState {
9104 current_assistant: std::sync::Mutex::new(None),
9105 tool_components: std::sync::Mutex::new(HashMap::new()),
9106 bash_components: std::sync::Mutex::new(HashMap::new()),
9107 themes_enabled: true,
9108 hide_thinking: std::sync::Mutex::new(false),
9109 tool_outputs_expanded: std::sync::Mutex::new(false),
9110 show_terminal_progress: true,
9111 status: std::sync::Mutex::new(RunStatus::Idle),
9112 js_preparation_cancel: std::sync::Mutex::new(None),
9113 footer: Arc::new(FooterComponent::new()),
9114 status_container: Arc::new(Container::new()),
9115 chat_container: Arc::new(Container::new()),
9116 loader: Arc::new(Loader::new()),
9117 last_assistant_text: std::sync::Mutex::new(String::new()),
9118 active_selector: std::sync::Mutex::new(None),
9119 active_extension_editor: std::sync::Mutex::new(None),
9120 active_extension_input: std::sync::Mutex::new(None),
9121 active_extension_cancel: std::sync::Mutex::new(None),
9122 autocomplete: AutocompleteManager::new(),
9123 autocomplete_container: Arc::new(Container::new()),
9124 autocomplete_max_visible: 5,
9125 pending_images: std::sync::Mutex::new(Vec::new()),
9126 theme_manager: Arc::new(ThemeManager::new()),
9127 tui: None,
9128 current_model_id: std::sync::Mutex::new(String::new()),
9129 show_images: std::sync::Mutex::new(true),
9130 history: std::sync::Mutex::new(Vec::new()),
9131 history_index: std::sync::Mutex::new(-1),
9132 history_draft: std::sync::Mutex::new(None),
9133 last_input_tokens: std::sync::Mutex::new(0),
9134 scoped_edit: std::sync::Mutex::new(None),
9135 markdown_transformer: std::sync::Mutex::new(None),
9136 extension_session: Arc::new(std::sync::Mutex::new(
9137 rpi_extensions::ExtensionSession::none(),
9138 )),
9139 });
9140 {
9141 let mut combined = CombinedAutocompleteProvider::new();
9142 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9143 build_builtin_registry().visible_entries(),
9144 )));
9145 state.autocomplete.set_provider(Arc::new(combined));
9146 }
9147
9148 let editor = Arc::new(Editor::simple());
9149 editor.set_text("/he");
9150 editor.set_cursor(0, 3);
9151 refresh_autocomplete(&state, &editor);
9152 let lines = state.autocomplete_container.render(80);
9153 let joined: String = lines.join("\n");
9154 assert!(
9155 joined.contains("/help"),
9156 "slash suggestions not rendered: {joined}"
9157 );
9158
9159 editor.set_text("hello");
9161 editor.set_cursor(0, 5);
9162 refresh_autocomplete(&state, &editor);
9163 assert!(state.autocomplete_container.render(80).is_empty());
9164 }
9165
9166 #[test]
9167 fn test_select_list_swap_restores_editor() {
9168 let state = Arc::new(TuiState {
9172 current_assistant: std::sync::Mutex::new(None),
9173 tool_components: std::sync::Mutex::new(HashMap::new()),
9174 bash_components: std::sync::Mutex::new(HashMap::new()),
9175 themes_enabled: true,
9176 hide_thinking: std::sync::Mutex::new(false),
9177 tool_outputs_expanded: std::sync::Mutex::new(false),
9178 show_terminal_progress: true,
9179 status: std::sync::Mutex::new(RunStatus::Idle),
9180 js_preparation_cancel: std::sync::Mutex::new(None),
9181 footer: Arc::new(FooterComponent::new()),
9182 status_container: Arc::new(Container::new()),
9183 chat_container: Arc::new(Container::new()),
9184 loader: Arc::new(Loader::new()),
9185 last_assistant_text: std::sync::Mutex::new(String::new()),
9186 active_selector: std::sync::Mutex::new(None),
9187 active_extension_editor: std::sync::Mutex::new(None),
9188 active_extension_input: std::sync::Mutex::new(None),
9189 active_extension_cancel: std::sync::Mutex::new(None),
9190 autocomplete: AutocompleteManager::new(),
9191 autocomplete_container: Arc::new(Container::new()),
9192 autocomplete_max_visible: 5,
9193 pending_images: std::sync::Mutex::new(Vec::new()),
9194 theme_manager: Arc::new(ThemeManager::new()),
9195 tui: None,
9196 current_model_id: std::sync::Mutex::new(String::new()),
9197 show_images: std::sync::Mutex::new(true),
9198 history: std::sync::Mutex::new(Vec::new()),
9199 history_index: std::sync::Mutex::new(-1),
9200 history_draft: std::sync::Mutex::new(None),
9201 last_input_tokens: std::sync::Mutex::new(0),
9202 scoped_edit: std::sync::Mutex::new(None),
9203 markdown_transformer: std::sync::Mutex::new(None),
9204 extension_session: Arc::new(std::sync::Mutex::new(
9205 rpi_extensions::ExtensionSession::none(),
9206 )),
9207 });
9208 let editor_container = Arc::new(Container::new());
9209 let editor = Arc::new(Editor::simple());
9210 editor_container.add_child(editor.clone());
9211 assert!(!state.selector_open());
9212
9213 let tui_terminal = Box::new(ProcessTerminal::new());
9214 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9215 let list = Arc::new(SelectList::new(
9216 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9217 5,
9218 ));
9219 open_selector(
9220 &state,
9221 &editor_container,
9222 &editor,
9223 &tui,
9224 list,
9225 SelectorKind::Theme,
9226 );
9227 assert!(state.selector_open());
9228 assert_eq!(editor_container.child_count(), 1);
9230
9231 close_selector(&state, &editor_container, &editor, &tui);
9232 assert!(!state.selector_open());
9233 assert_eq!(editor_container.child_count(), 1);
9235 }
9236
9237 #[test]
9238 fn test_message_history_browse_restores_draft() {
9239 let state = Arc::new(TuiState {
9243 current_assistant: std::sync::Mutex::new(None),
9244 tool_components: std::sync::Mutex::new(HashMap::new()),
9245 bash_components: std::sync::Mutex::new(HashMap::new()),
9246 themes_enabled: true,
9247 hide_thinking: std::sync::Mutex::new(false),
9248 tool_outputs_expanded: std::sync::Mutex::new(false),
9249 show_terminal_progress: true,
9250 status: std::sync::Mutex::new(RunStatus::Idle),
9251 js_preparation_cancel: std::sync::Mutex::new(None),
9252 footer: Arc::new(FooterComponent::new()),
9253 status_container: Arc::new(Container::new()),
9254 chat_container: Arc::new(Container::new()),
9255 loader: Arc::new(Loader::new()),
9256 last_assistant_text: std::sync::Mutex::new(String::new()),
9257 active_selector: std::sync::Mutex::new(None),
9258 active_extension_editor: std::sync::Mutex::new(None),
9259 active_extension_input: std::sync::Mutex::new(None),
9260 active_extension_cancel: std::sync::Mutex::new(None),
9261 autocomplete: AutocompleteManager::new(),
9262 autocomplete_container: Arc::new(Container::new()),
9263 autocomplete_max_visible: 5,
9264 pending_images: std::sync::Mutex::new(Vec::new()),
9265 theme_manager: Arc::new(ThemeManager::new()),
9266 tui: None,
9267 current_model_id: std::sync::Mutex::new(String::new()),
9268 show_images: std::sync::Mutex::new(true),
9269 history: std::sync::Mutex::new(Vec::new()),
9270 history_index: std::sync::Mutex::new(-1),
9271 history_draft: std::sync::Mutex::new(None),
9272 last_input_tokens: std::sync::Mutex::new(0),
9273 scoped_edit: std::sync::Mutex::new(None),
9274 markdown_transformer: std::sync::Mutex::new(None),
9275 extension_session: Arc::new(std::sync::Mutex::new(
9276 rpi_extensions::ExtensionSession::none(),
9277 )),
9278 });
9279 let editor = Arc::new(Editor::simple());
9280
9281 push_history(&state, "first message");
9282 push_history(&state, "second message");
9283 push_history(&state, "second message");
9285 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
9287 assert_eq!(state.history.lock().unwrap()[0], "second message");
9288
9289 editor.set_text("half-typed");
9291 editor.set_cursor(0, 11);
9292
9293 navigate_history(&state, &editor, -1);
9295 assert_eq!(editor.get_text(), "second message");
9296 assert_eq!(*state.history_index.lock().unwrap(), 0);
9297 navigate_history(&state, &editor, -1);
9299 assert_eq!(editor.get_text(), "first message");
9300 assert_eq!(*state.history_index.lock().unwrap(), 1);
9301 navigate_history(&state, &editor, -1);
9303 assert_eq!(editor.get_text(), "first message");
9304 navigate_history(&state, &editor, 1);
9306 assert_eq!(editor.get_text(), "second message");
9307 navigate_history(&state, &editor, 1);
9309 assert_eq!(editor.get_text(), "half-typed");
9310 assert_eq!(*state.history_index.lock().unwrap(), -1);
9311 }
9312
9313 #[test]
9314 fn test_accept_top_suggestion_replaces_prefix() {
9315 let state = Arc::new(TuiState {
9317 current_assistant: std::sync::Mutex::new(None),
9318 tool_components: std::sync::Mutex::new(HashMap::new()),
9319 bash_components: std::sync::Mutex::new(HashMap::new()),
9320 themes_enabled: true,
9321 hide_thinking: std::sync::Mutex::new(false),
9322 tool_outputs_expanded: std::sync::Mutex::new(false),
9323 show_terminal_progress: true,
9324 status: std::sync::Mutex::new(RunStatus::Idle),
9325 js_preparation_cancel: std::sync::Mutex::new(None),
9326 footer: Arc::new(FooterComponent::new()),
9327 status_container: Arc::new(Container::new()),
9328 chat_container: Arc::new(Container::new()),
9329 loader: Arc::new(Loader::new()),
9330 last_assistant_text: std::sync::Mutex::new(String::new()),
9331 active_selector: std::sync::Mutex::new(None),
9332 active_extension_editor: std::sync::Mutex::new(None),
9333 active_extension_input: std::sync::Mutex::new(None),
9334 active_extension_cancel: std::sync::Mutex::new(None),
9335 autocomplete: AutocompleteManager::new(),
9336 autocomplete_container: Arc::new(Container::new()),
9337 autocomplete_max_visible: 5,
9338 pending_images: std::sync::Mutex::new(Vec::new()),
9339 theme_manager: Arc::new(ThemeManager::new()),
9340 tui: None,
9341 current_model_id: std::sync::Mutex::new(String::new()),
9342 show_images: std::sync::Mutex::new(true),
9343 history: std::sync::Mutex::new(Vec::new()),
9344 history_index: std::sync::Mutex::new(-1),
9345 history_draft: std::sync::Mutex::new(None),
9346 last_input_tokens: std::sync::Mutex::new(0),
9347 scoped_edit: std::sync::Mutex::new(None),
9348 markdown_transformer: std::sync::Mutex::new(None),
9349 extension_session: Arc::new(std::sync::Mutex::new(
9350 rpi_extensions::ExtensionSession::none(),
9351 )),
9352 });
9353 {
9354 let mut combined = CombinedAutocompleteProvider::new();
9355 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9356 build_builtin_registry().visible_entries(),
9357 )));
9358 state.autocomplete.set_provider(Arc::new(combined));
9359 }
9360 let editor = Arc::new(Editor::simple());
9361 editor.set_text("/he");
9362 editor.set_cursor(0, 3);
9363 refresh_autocomplete(&state, &editor);
9364 let accepted = accept_top_suggestion(&state, &editor);
9365 assert!(accepted, "should accept the top suggestion");
9366 let text = editor.get_text();
9367 assert!(
9368 text.starts_with("/help"),
9369 "editor text should start with /help, got {text}"
9370 );
9371 }
9372
9373 #[test]
9374 fn configured_key_parser_supports_native_notation() {
9375 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
9376 assert_eq!(combo.code, KeyCode::Char('g'));
9377 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
9378 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
9379 assert_eq!(combo.code, KeyCode::BackTab);
9380 }
9381
9382 #[test]
9383 fn double_escape_trigger_has_half_second_window() {
9384 let now = std::time::Instant::now();
9385 assert!(!double_escape_trigger(None, now));
9386 assert!(double_escape_trigger(
9387 Some(now - std::time::Duration::from_millis(500)),
9388 now
9389 ));
9390 assert!(!double_escape_trigger(
9391 Some(now - std::time::Duration::from_millis(501)),
9392 now
9393 ));
9394 }
9395}