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, Text, ThemeManager,
50 ThemePreset, ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack, TUI,
51};
52use rpi_tui::{bold as tui_bold, theme as current_theme};
53
54#[allow(unused_imports)]
55use rpi_tui::BashStatus;
56
57use crate::args::Args;
58
59type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
66
67#[derive(Clone, Default)]
73struct JsDialogBridge {
74 pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
75 active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
76 visible: Arc<Mutex<Option<String>>>,
80 cancelled_before_open: Arc<Mutex<HashSet<String>>>,
81 closed: Arc<Mutex<bool>>,
82}
83
84struct JsDialogPending {
85 request: JsDialogRequest,
86 result: std_mpsc::Sender<serde_json::Value>,
87}
88
89struct JsDialogActive {
90 result: std_mpsc::Sender<serde_json::Value>,
91 cancel_requested: bool,
92}
93
94#[derive(Clone, Debug)]
95struct JsDialogRequest {
96 id: String,
97 method: String,
98 title: String,
99 message: String,
100 options: Vec<String>,
101 placeholder: Option<String>,
102 prefill: Option<String>,
103}
104
105impl JsDialogRequest {
106 fn parse(args: &serde_json::Value) -> Result<Self, String> {
107 let id = args
108 .get("dialogId")
109 .and_then(serde_json::Value::as_str)
110 .filter(|value| !value.is_empty())
111 .ok_or("ui.dialog missing dialogId")?
112 .to_string();
113 let method = args
114 .get("method")
115 .and_then(serde_json::Value::as_str)
116 .ok_or("ui.dialog missing method")?
117 .to_string();
118 if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
119 return Err(format!("unsupported UI dialog method: {method}"));
120 }
121 let options = args
122 .get("options")
123 .and_then(serde_json::Value::as_array)
124 .map(|values| {
125 values
126 .iter()
127 .filter_map(serde_json::Value::as_str)
128 .map(ToOwned::to_owned)
129 .collect()
130 })
131 .unwrap_or_default();
132 Ok(Self {
133 id,
134 method,
135 title: args
136 .get("title")
137 .and_then(serde_json::Value::as_str)
138 .unwrap_or_default()
139 .to_string(),
140 message: args
141 .get("message")
142 .and_then(serde_json::Value::as_str)
143 .unwrap_or_default()
144 .to_string(),
145 options,
146 placeholder: args
147 .get("placeholder")
148 .and_then(serde_json::Value::as_str)
149 .map(ToOwned::to_owned),
150 prefill: args
151 .get("prefill")
152 .and_then(serde_json::Value::as_str)
153 .map(ToOwned::to_owned),
154 })
155 }
156}
157
158impl JsDialogBridge {
159 fn handle_runtime_request(
160 &self,
161 action: &str,
162 args: serde_json::Value,
163 ) -> Result<serde_json::Value, String> {
164 match action {
165 "ui.dialog" => self.wait_for_dialog(args),
166 "ui.dialog.cancel" => {
167 let id = args
168 .get("dialogId")
169 .and_then(serde_json::Value::as_str)
170 .ok_or("ui.dialog.cancel missing dialogId")?;
171 self.cancel(id);
172 Ok(serde_json::json!(true))
173 }
174 _ => Err(format!("unsupported capability: {action}")),
175 }
176 }
177
178 fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
179 let request = JsDialogRequest::parse(&args)?;
180 let (sender, receiver) = std_mpsc::channel();
181 let _closed = self
185 .closed
186 .lock()
187 .map_err(|_| "JS dialog bridge poisoned")?;
188 if *_closed {
189 return Ok(serde_json::json!({ "cancelled": true }));
190 }
191 let cancelled_before_open = self
192 .cancelled_before_open
193 .lock()
194 .map_err(|_| "JS dialog cancellation state poisoned")?
195 .remove(&request.id);
196 if cancelled_before_open {
197 return Ok(serde_json::json!({ "cancelled": true }));
198 }
199 self.pending
202 .lock()
203 .map_err(|_| "JS dialog pending state poisoned")?
204 .push_back(JsDialogPending {
205 request,
206 result: sender,
207 });
208 drop(_closed);
209 receiver
210 .recv()
211 .map_err(|_| "JS dialog closed before it received an answer".to_string())
212 }
213
214 fn take_pending(&self) -> Option<JsDialogRequest> {
217 loop {
218 let pending = self.pending.lock().ok()?.pop_front()?;
219 let mut active = self.active.lock().ok()?;
220 if self
227 .cancelled_before_open
228 .lock()
229 .ok()?
230 .remove(&pending.request.id)
231 {
232 drop(active);
233 let _ = pending
234 .result
235 .send(serde_json::json!({ "cancelled": true }));
236 continue;
237 }
238 active.insert(
239 pending.request.id.clone(),
240 JsDialogActive {
241 result: pending.result,
242 cancel_requested: false,
243 },
244 );
245 if let Ok(mut visible) = self.visible.lock() {
246 *visible = Some(pending.request.id.clone());
247 }
248 return Some(pending.request);
249 }
250 }
251
252 fn respond(&self, id: &str, result: serde_json::Value) {
253 if let Ok(mut active) = self.active.lock() {
254 if let Some(entry) = active.remove(id) {
255 let _ = entry.result.send(result);
256 }
257 }
258 if let Ok(mut visible) = self.visible.lock() {
259 if visible.as_deref() == Some(id) {
260 *visible = None;
261 }
262 }
263 }
264
265 fn cancel(&self, id: &str) {
266 if let Ok(mut pending) = self.pending.lock() {
267 if let Some(index) = pending.iter().position(|item| item.request.id == id) {
268 if let Some(item) = pending.remove(index) {
269 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
270 return;
271 }
272 }
273 }
274 if let Ok(mut active) = self.active.lock() {
275 if let Some(entry) = active.get_mut(id) {
276 if !entry.cancel_requested {
277 entry.cancel_requested = true;
278 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
279 }
280 return;
281 }
282 }
283 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
284 cancelled.insert(id.to_string());
285 }
286 }
287
288 fn cancelled_active_ids(&self) -> Vec<String> {
289 self.active
290 .lock()
291 .map(|active| {
292 active
293 .iter()
294 .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
295 .collect()
296 })
297 .unwrap_or_default()
298 }
299
300 fn is_visible(&self, id: &str) -> bool {
301 self.visible
302 .lock()
303 .map(|visible| visible.as_deref() == Some(id))
304 .unwrap_or(false)
305 }
306
307 fn finish(&self, id: &str) {
308 if let Ok(mut active) = self.active.lock() {
309 active.remove(id);
310 }
311 if let Ok(mut visible) = self.visible.lock() {
312 if visible.as_deref() == Some(id) {
313 *visible = None;
314 }
315 }
316 }
317
318 fn cancel_all(&self) {
319 let Ok(mut closed) = self.closed.lock() else {
323 return;
324 };
325 *closed = true;
326 if let Ok(mut pending) = self.pending.lock() {
327 for item in pending.drain(..) {
328 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
329 }
330 }
331 if let Ok(mut active) = self.active.lock() {
332 for (_, entry) in active.drain() {
333 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
334 }
335 }
336 if let Ok(mut visible) = self.visible.lock() {
337 *visible = None;
338 }
339 drop(closed);
340 }
341
342 fn cancel_open_requests(&self) {
345 let Ok(mut closed) = self.closed.lock() else {
349 return;
350 };
351 *closed = true;
352 if let Ok(mut pending) = self.pending.lock() {
353 for item in pending.drain(..) {
354 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
355 }
356 }
357 if let Ok(mut active) = self.active.lock() {
358 for (_, entry) in active.drain() {
359 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
360 }
361 }
362 if let Ok(mut visible) = self.visible.lock() {
363 *visible = None;
364 }
365 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
366 cancelled.clear();
367 }
368 drop(closed);
369 }
370
371 fn reopen(&self) {
372 if let Ok(mut closed) = self.closed.lock() {
373 *closed = false;
374 }
375 }
376}
377
378fn build_markdown_transformer(
400 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
401) -> Option<MarkdownTransformer> {
402 let snapshot = snapshot?;
403 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
408 if renderers.is_empty() {
409 return None;
410 }
411 Some(Arc::new(move |raw: &str| -> String {
412 transform_markdown_chain(&snapshot, &renderers, raw)
413 }))
414}
415
416fn transform_markdown_chain(
424 snapshot: &rpi_extensions::RegistrySnapshot,
425 renderers: &[rpi_extensions::RegisteredRenderer],
426 raw: &str,
427) -> String {
428 if !snapshot.is_active() {
433 return raw.to_string();
434 }
435
436 let mut current = raw.to_string();
437 for renderer in renderers {
438 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
439 Ok(s) => s,
440 Err(_) => return current, };
442 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
452 let mut out = rpi_plugin_sdk::StbString::empty();
453 let rc = (renderer.render_fn)(
454 rpi_plugin_sdk::StbStringRef::from_str(&input),
455 &mut out as *mut rpi_plugin_sdk::StbString,
456 renderer.user_data,
457 );
458 let text = if rc == 0 {
459 let s = out.to_string_lossy();
460 Some(s)
461 } else {
462 None
463 };
464 out.free_with(Some(renderer.plugin_free_string));
468 text
469 }));
470 let out_text = match outcome {
471 Ok(Some(s)) => s,
472 Ok(None) => return current, Err(_) => return current, };
478 let next = serde_json::from_str::<serde_json::Value>(&out_text)
481 .ok()
482 .and_then(|v| {
483 v.get("markdown")
484 .and_then(|m| m.as_str())
485 .map(|s| s.to_string())
486 })
487 .unwrap_or(current);
488 current = next;
489 }
490 current
491}
492
493#[derive(Clone)]
517struct CommandContext {
518 chat: Arc<Container>,
519 tui: Arc<TuiAltScreen>,
520 tx: mpsc::UnboundedSender<TuiMessage>,
521 state: Arc<TuiState>,
522 editor: Arc<Editor>,
523 editor_container: Arc<Container>,
524 lane: Arc<dyn AgentLane>,
525 model_catalog: Arc<Vec<rpi_ai::Model>>,
526 lane_model_id: String,
530 cwd: std::path::PathBuf,
531 package_resources: Arc<crate::packages::PackageResources>,
535 resources: Arc<rpi_harness::types::AgentHarnessResources>,
539 reload_context: Arc<crate::session::ReloadContext>,
545}
546
547trait SlashCommand: Send + Sync {
549 fn name(&self) -> &str;
551 fn aliases(&self) -> &'static [&'static str] {
555 &[]
556 }
557 fn visible(&self) -> bool {
560 true
561 }
562 fn alias_visible(&self) -> &'static [&'static str] {
566 &[]
567 }
568 fn description(&self) -> &'static str {
571 ""
572 }
573 fn description_owned(&self) -> String {
574 self.description().to_string()
575 }
576 fn execute(&self, ctx: &CommandContext, args: &str);
582}
583
584struct CommandRegistry {
587 commands: Vec<Arc<dyn SlashCommand>>,
588}
589
590impl CommandRegistry {
591 fn new() -> Self {
592 Self {
593 commands: Vec::new(),
594 }
595 }
596
597 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
598 self.commands.push(cmd);
599 }
600
601 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
604 self.commands
605 .iter()
606 .find(|c| c.name() == token || c.aliases().contains(&token))
607 }
608
609 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
615 let mut out: Vec<SlashCommandEntry> = Vec::new();
616 for c in &self.commands {
617 let description = c.description_owned();
618 if c.visible() && !description.is_empty() {
619 out.push(SlashCommandEntry {
620 name: c.name().into(),
621 description: description.clone(),
622 });
623 }
624 for alias in c.alias_visible() {
626 out.push(SlashCommandEntry {
627 name: (*alias).into(),
628 description: description.clone(),
629 });
630 }
631 }
632 out
633 }
634}
635
636fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
640 let mut parts = text.split_whitespace();
641 let token = parts.next().unwrap_or("");
642 let args = parts.collect::<Vec<_>>().join(" ");
643 match registry.find(token) {
644 Some(cmd) => cmd.execute(ctx, &args),
645 None => {
646 add_error_message(
647 &ctx.chat,
648 &format!("Unknown command: {text}. Type /help for available commands."),
649 );
650 ctx.tui.request_render(false);
651 }
652 }
653}
654
655fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
660 use crossterm::event::{KeyCode, KeyModifiers};
661
662 let modifiers = key.modifiers;
663 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
664 let shift = modifiers.contains(KeyModifiers::SHIFT);
665 let alt = modifiers.contains(KeyModifiers::ALT);
666 let super_key = modifiers.contains(KeyModifiers::SUPER);
667
668 if modifiers == KeyModifiers::NONE {
669 return match key.code {
670 KeyCode::Char(ch) => ch.to_string(),
671 KeyCode::Enter => "\r".into(),
672 KeyCode::Esc => "\x1b".into(),
673 KeyCode::Backspace => "\x7f".into(),
674 KeyCode::Tab => "\t".into(),
675 KeyCode::BackTab => "\x1b[Z".into(),
679 KeyCode::Up => "\x1b[A".into(),
680 KeyCode::Down => "\x1b[B".into(),
681 KeyCode::Right => "\x1b[C".into(),
682 KeyCode::Left => "\x1b[D".into(),
683 KeyCode::Home => "\x1b[H".into(),
684 KeyCode::End => "\x1b[F".into(),
685 KeyCode::PageUp => "\x1b[5~".into(),
686 KeyCode::PageDown => "\x1b[6~".into(),
687 KeyCode::Delete => "\x1b[3~".into(),
688 KeyCode::Insert => "\x1b[2~".into(),
689 KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
690 _ => String::new(),
691 };
692 }
693
694 if ctrl && !shift && !alt && !super_key {
697 if let KeyCode::Char(ch) = key.code {
698 if let Some(code) = control_code(ch) {
699 return char::from(code).to_string();
700 }
701 }
702 }
703
704 if alt && !ctrl && !shift && !super_key {
707 if let KeyCode::Char(ch) = key.code {
708 return format!("\x1b{ch}");
709 }
710 }
711
712 if shift && !ctrl && !alt && !super_key {
718 if let KeyCode::Char(ch) = key.code {
719 return ch.to_string();
720 }
721 }
722
723 if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
724 return sequence;
725 }
726 let Some(codepoint) = key_codepoint(key.code, ctrl) else {
727 return String::new();
728 };
729 kitty_key_sequence(codepoint, modifiers)
730}
731
732fn control_code(ch: char) -> Option<u8> {
733 let ch = ch.to_ascii_lowercase();
734 Some(match ch {
735 '@' | ' ' => 0,
736 'a'..='z' => (ch as u8) & 0x1f,
737 '[' => 0x1b,
738 '\\' => 0x1c,
739 ']' => 0x1d,
740 '^' => 0x1e,
741 '_' | '-' => 0x1f,
742 _ => return None,
743 })
744}
745
746fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
747 use crossterm::event::KeyCode;
748 Some(match code {
749 KeyCode::Char(ch) => {
750 if ctrl {
751 ch.to_ascii_lowercase() as u32
752 } else {
753 ch as u32
754 }
755 }
756 KeyCode::Enter => 13,
757 KeyCode::Esc => 27,
758 KeyCode::Backspace => 127,
759 KeyCode::Tab => 9,
760 KeyCode::BackTab => 9,
763 _ => return None,
764 })
765}
766
767fn modified_functional_sequence(
768 code: crossterm::event::KeyCode,
769 modifiers: crossterm::event::KeyModifiers,
770) -> Option<String> {
771 use crossterm::event::{KeyCode, KeyModifiers};
772 if code == KeyCode::BackTab
776 && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
777 {
778 return Some("\x1b[Z".into());
779 }
780 let modifier = kitty_modifier(modifiers);
781 let sequence = match code {
782 KeyCode::Up => format!("\x1b[1;{modifier}A"),
783 KeyCode::Down => format!("\x1b[1;{modifier}B"),
784 KeyCode::Right => format!("\x1b[1;{modifier}C"),
785 KeyCode::Left => format!("\x1b[1;{modifier}D"),
786 KeyCode::Home => format!("\x1b[1;{modifier}H"),
787 KeyCode::End => format!("\x1b[1;{modifier}F"),
788 KeyCode::Insert => format!("\x1b[2;{modifier}~"),
789 KeyCode::Delete => format!("\x1b[3;{modifier}~"),
790 KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
791 KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
792 _ => return None,
793 };
794 Some(sequence)
795}
796
797fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
798 use crossterm::event::KeyModifiers;
799 let mut modifier = 1u8;
800 if modifiers.contains(KeyModifiers::SHIFT) {
801 modifier += 1;
802 }
803 if modifiers.contains(KeyModifiers::ALT) {
804 modifier += 2;
805 }
806 if modifiers.contains(KeyModifiers::CONTROL) {
807 modifier += 4;
808 }
809 if modifiers.contains(KeyModifiers::SUPER) {
810 modifier += 8;
811 }
812 modifier
813}
814
815fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
816 let modifier = kitty_modifier(modifiers);
817 format!("\x1b[{codepoint};{modifier}u")
818}
819
820struct ExtensionCommand {
825 name: String,
826 description: String,
827 session: crate::session::ExtensionSessionCell,
828}
829
830struct JsExtensionCommand {
831 name: String,
832 session: crate::js_extensions::JsExtensionSession,
833}
834
835impl SlashCommand for JsExtensionCommand {
836 fn name(&self) -> &str {
837 &self.name
838 }
839 fn description(&self) -> &'static str {
840 "JS extension command"
841 }
842 fn description_owned(&self) -> String {
843 "JS extension command".to_string()
844 }
845 fn execute(&self, ctx: &CommandContext, args: &str) {
846 let session = self.session.clone();
851 let command = self.name.trim_start_matches('/').to_string();
852 let args = args.to_string();
853 let ctx = ctx.clone();
854 let initial_editor_text = ctx.editor.get_text();
858 tokio::task::spawn_blocking(move || {
859 match session.invoke_command_with_context(
860 &command,
861 &args,
862 serde_json::json!({"editorText": initial_editor_text}),
863 ) {
864 Ok(value) => {
865 if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
866 if ctx.editor.get_text() == initial_editor_text
867 && editor_text != initial_editor_text
868 {
869 let cursor = editor_text.chars().count();
870 ctx.editor.set_text(editor_text);
871 ctx.editor.set_cursor(0, cursor);
872 }
873 }
874 if let Some(notifications) =
875 value.get("notifications").and_then(|v| v.as_array())
876 {
877 for notification in notifications {
878 let message = notification
879 .get("message")
880 .and_then(|v| v.as_str())
881 .unwrap_or_default();
882 if message.is_empty() {
883 continue;
884 }
885 match notification.get("level").and_then(|v| v.as_str()) {
886 Some("error") => add_error_message(&ctx.chat, message),
887 _ => add_note_message(&ctx.chat, message),
888 }
889 }
890 }
891 let result = value.get("result").unwrap_or(&value);
892 let text = result
893 .get("text")
894 .and_then(|item| item.as_str())
895 .map(str::to_string)
896 .or_else(|| result.as_str().map(str::to_string))
897 .filter(|text| !text.is_empty() && text != "null");
898 if let Some(text) = text {
899 add_note_message(&ctx.chat, &text);
900 }
901 }
902 Err(error) => {
903 add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
904 }
905 }
906 ctx.tui.request_render(false);
907 });
908 }
909}
910
911impl SlashCommand for ExtensionCommand {
912 fn name(&self) -> &str {
913 &self.name
914 }
915
916 fn description(&self) -> &'static str {
917 "extension command"
918 }
919
920 fn description_owned(&self) -> String {
921 self.description.clone()
922 }
923
924 fn execute(&self, ctx: &CommandContext, args: &str) {
925 let result = invoke_extension_command(&self.session, &self.name, args);
926 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
927 }
928}
929
930fn invoke_extension_command(
931 session: &crate::session::ExtensionSessionCell,
932 name: &str,
933 args: &str,
934) -> Option<serde_json::Value> {
935 let command = session
936 .lock()
937 .ok()
938 .and_then(|s| s.snapshot_arc())
939 .and_then(|snap| {
940 snap.commands()
941 .iter()
942 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
943 .cloned()
944 })?;
945 let input = serde_json::json!({ "args": args, "command": name });
946 let input = serde_json::to_string(&input).ok()?;
947 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
948 let mut out = rpi_plugin_sdk::StbString::empty();
949 let rc = (command.handler)(
950 rpi_plugin_sdk::StbStringRef::from_str(&input),
951 &mut out as *mut rpi_plugin_sdk::StbString,
952 command.user_data,
953 );
954 let text = if rc == 0 {
955 Some(out.to_string_lossy())
956 } else {
957 None
958 };
959 rpi_extensions::host_free_string(out);
960 text
961 }))
962 .ok()
963 .flatten()?;
964 serde_json::from_str(&outcome).ok()
965}
966
967fn handle_extension_ui_result(
968 result: Option<serde_json::Value>,
969 ctx: &CommandContext,
970 session: crate::session::ExtensionSessionCell,
971 command_name: String,
972) {
973 let Some(value) = result else {
974 add_error_message(&ctx.chat, "Extension command failed.");
975 ctx.tui.request_render(false);
976 return;
977 };
978 if value.is_null() {
982 ctx.tui.request_render(false);
983 return;
984 }
985 match value.get("kind").and_then(|v| v.as_str()) {
986 Some("message") | None => {
987 let fallback = value.to_string();
988 let text = value
989 .get("text")
990 .and_then(|v| v.as_str())
991 .unwrap_or(&fallback)
992 .to_string();
993 if !text.is_empty() {
994 add_note_message(&ctx.chat, &text);
995 }
996 ctx.tui.request_render(false);
997 }
998 Some("selector") => open_extension_selector(ctx, session, command_name, value),
999 Some("editor") => open_extension_editor(ctx, session, command_name, value),
1000 Some("input") => open_extension_input(ctx, session, command_name, value),
1004 Some(other) => {
1005 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1006 ctx.tui.request_render(false);
1007 }
1008 }
1009}
1010
1011fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1015 let colors = current_theme().colors;
1016 let text = if bold {
1017 tui_bold(title)
1018 } else {
1019 title.to_string()
1020 };
1021 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1022}
1023
1024fn extension_dialog_hint(label: &str) -> Arc<Text> {
1025 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1026}
1027
1028fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1032 let callback = state.active_extension_cancel.lock().unwrap().take();
1033 if let Some(callback) = callback {
1034 callback();
1035 true
1036 } else {
1037 false
1038 }
1039}
1040
1041fn open_extension_input(
1042 ctx: &CommandContext,
1043 session: crate::session::ExtensionSessionCell,
1044 command_name: String,
1045 value: serde_json::Value,
1046) {
1047 let title = value
1048 .get("title")
1049 .and_then(|v| v.as_str())
1050 .filter(|title| !title.is_empty())
1051 .unwrap_or("Input");
1052 let input = value
1053 .get("placeholder")
1054 .and_then(|v| v.as_str())
1055 .map(Input::with_placeholder)
1056 .unwrap_or_default();
1057 let input = Arc::new(input);
1058 if let Some(initial) = value
1059 .get("initialText")
1060 .or_else(|| value.get("text"))
1061 .and_then(|v| v.as_str())
1062 {
1063 input.set_value(initial);
1064 }
1065 input.set_focused(true);
1066
1067 let frame = Arc::new(Container::new());
1068 frame.add_child(Arc::new(DynamicBorder::new()));
1069 frame.add_child(Arc::new(Spacer::new(1)));
1070 frame.add_child(extension_dialog_title(title, false));
1071 frame.add_child(Arc::new(Spacer::new(1)));
1072 frame.add_child(input.clone());
1073 frame.add_child(Arc::new(Spacer::new(1)));
1074 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1075 frame.add_child(Arc::new(Spacer::new(1)));
1076 frame.add_child(Arc::new(DynamicBorder::new()));
1077
1078 *ctx.state.active_extension_editor.lock().unwrap() = None;
1079 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1080 ctx.editor_container.clear();
1081 ctx.editor_container.add_child(frame);
1082
1083 let state = ctx.state.clone();
1084 let ec = ctx.editor_container.clone();
1085 let original = ctx.editor.clone();
1086 let tui = ctx.tui.clone();
1087 let session_submit = session.clone();
1088 let command_submit = command_name.clone();
1089 let ctx_submit = ctx.clone();
1090 input.on_submit(Arc::new(move |text| {
1091 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1092 let result = invoke_extension_command(
1093 &session_submit,
1094 &command_submit,
1095 &serde_json::to_string(&args).unwrap_or_default(),
1096 );
1097 close_extension_editor(&state, &ec, &original, &tui);
1098 handle_extension_ui_result(
1099 result,
1100 &ctx_submit,
1101 session_submit.clone(),
1102 command_submit.clone(),
1103 );
1104 }));
1105
1106 let state_cancel = ctx.state.clone();
1107 let ec_cancel = ctx.editor_container.clone();
1108 let original_cancel = ctx.editor.clone();
1109 let tui_cancel = ctx.tui.clone();
1110 let session_cancel = session.clone();
1111 let command_cancel = command_name.clone();
1112 let ctx_cancel = ctx.clone();
1113 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1114 let args = serde_json::json!({ "action": "cancel" });
1115 let result = invoke_extension_command(
1116 &session_cancel,
1117 &command_cancel,
1118 &serde_json::to_string(&args).unwrap_or_default(),
1119 );
1120 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1121 handle_extension_ui_result(
1122 result,
1123 &ctx_cancel,
1124 session_cancel.clone(),
1125 command_cancel.clone(),
1126 );
1127 }));
1128
1129 ctx.tui.set_focus(Some(input));
1130 ctx.tui.request_render(false);
1131}
1132
1133fn open_extension_selector(
1134 ctx: &CommandContext,
1135 session: crate::session::ExtensionSessionCell,
1136 command_name: String,
1137 value: serde_json::Value,
1138) {
1139 let items = value
1140 .get("items")
1141 .and_then(|v| v.as_array())
1142 .map(|items| {
1143 items
1144 .iter()
1145 .filter_map(|item| {
1146 let value = if let Some(value) = item.as_str() {
1149 value
1150 } else {
1151 item.get("value")?.as_str()?
1152 };
1153 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1154 let mut out = SelectItem::new(value, label);
1155 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1156 out = out.with_description(desc);
1157 }
1158 Some(out)
1159 })
1160 .collect::<Vec<_>>()
1161 })
1162 .unwrap_or_default();
1163 if items.is_empty() {
1164 add_error_message(&ctx.chat, "Extension selector has no items.");
1165 ctx.tui.request_render(false);
1166 return;
1167 }
1168 let list = Arc::new(SelectList::new(items, 10));
1169 let title = value
1170 .get("title")
1171 .and_then(|v| v.as_str())
1172 .filter(|title| !title.is_empty())
1173 .unwrap_or("Select");
1174 let frame = Arc::new(Container::new());
1175 frame.add_child(Arc::new(DynamicBorder::new()));
1176 frame.add_child(Arc::new(Spacer::new(1)));
1177 frame.add_child(extension_dialog_title(title, true));
1178 frame.add_child(Arc::new(Spacer::new(1)));
1179 frame.add_child(list.clone());
1180 frame.add_child(Arc::new(Spacer::new(1)));
1181 frame.add_child(extension_dialog_hint(
1182 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1183 ));
1184 frame.add_child(Arc::new(Spacer::new(1)));
1185 frame.add_child(Arc::new(DynamicBorder::new()));
1186
1187 let state = ctx.state.clone();
1188 let ec = ctx.editor_container.clone();
1189 let editor = ctx.editor.clone();
1190 let tui = ctx.tui.clone();
1191 let session_select = session.clone();
1192 let command_select = command_name.clone();
1193 let ctx_select = ctx.clone();
1194 list.on_select(Arc::new(move |item| {
1195 let args = serde_json::json!({ "action": "select", "value": item.value });
1196 let result = invoke_extension_command(
1197 &session_select,
1198 &command_select,
1199 &serde_json::to_string(&args).unwrap_or_default(),
1200 );
1201 close_selector(&state, &ec, &editor, &tui);
1202 handle_extension_ui_result(
1203 result,
1204 &ctx_select,
1205 session_select.clone(),
1206 command_select.clone(),
1207 );
1208 }));
1209 let state_cancel = ctx.state.clone();
1210 let ec_cancel = ctx.editor_container.clone();
1211 let editor_cancel = ctx.editor.clone();
1212 let tui_cancel = ctx.tui.clone();
1213 list.on_cancel(Arc::new(move || {
1214 if !run_extension_cancel(&state_cancel) {
1215 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1216 }
1217 }));
1218 let state_cancel = ctx.state.clone();
1219 let ec_cancel = ctx.editor_container.clone();
1220 let editor_cancel = ctx.editor.clone();
1221 let tui_cancel = ctx.tui.clone();
1222 let session_cancel = session.clone();
1223 let command_cancel = command_name.clone();
1224 let ctx_cancel = ctx.clone();
1225 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1226 let args = serde_json::json!({ "action": "cancel" });
1227 let result = invoke_extension_command(
1228 &session_cancel,
1229 &command_cancel,
1230 &serde_json::to_string(&args).unwrap_or_default(),
1231 );
1232 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1233 handle_extension_ui_result(
1234 result,
1235 &ctx_cancel,
1236 session_cancel.clone(),
1237 command_cancel.clone(),
1238 );
1239 }));
1240 open_selector_with_view(
1241 &ctx.state,
1242 &ctx.editor_container,
1243 &ctx.editor,
1244 &ctx.tui,
1245 list,
1246 frame,
1247 SelectorKind::Extension,
1248 );
1249}
1250
1251fn open_extension_editor(
1252 ctx: &CommandContext,
1253 session: crate::session::ExtensionSessionCell,
1254 command_name: String,
1255 value: serde_json::Value,
1256) {
1257 let initial = value
1258 .get("initialText")
1259 .or_else(|| value.get("text"))
1260 .and_then(|v| v.as_str())
1261 .unwrap_or_default()
1262 .to_string();
1263 let title = value
1264 .get("title")
1265 .and_then(|v| v.as_str())
1266 .filter(|title| !title.is_empty())
1267 .unwrap_or("Editor");
1268 let editor = Arc::new(Editor::new(
1269 EditorOptions {
1270 padding_x: 1,
1271 autocomplete_max_visible: 0,
1272 placeholder: value
1273 .get("placeholder")
1274 .and_then(|v| v.as_str())
1275 .map(str::to_string),
1276 initial_text: Some(initial),
1277 },
1278 EditorStyle {
1279 prompt: "> ".to_string(),
1280 placeholder: String::new(),
1281 },
1282 Arc::new(rpi_tui::Keybindings::new()),
1283 ));
1284 editor.set_focused(true);
1285 let frame = Arc::new(Container::new());
1286 frame.add_child(Arc::new(DynamicBorder::new()));
1287 frame.add_child(Arc::new(Spacer::new(1)));
1288 frame.add_child(extension_dialog_title(title, false));
1289 frame.add_child(Arc::new(Spacer::new(1)));
1290 frame.add_child(editor.clone());
1291 frame.add_child(Arc::new(Spacer::new(1)));
1292 frame.add_child(extension_dialog_hint(
1293 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1294 ));
1295 frame.add_child(Arc::new(Spacer::new(1)));
1296 frame.add_child(Arc::new(DynamicBorder::new()));
1297
1298 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1299 *ctx.state.active_extension_input.lock().unwrap() = None;
1300 ctx.editor_container.clear();
1301 ctx.editor_container.add_child(frame);
1302
1303 let state = ctx.state.clone();
1304 let ec = ctx.editor_container.clone();
1305 let original = ctx.editor.clone();
1306 let tui = ctx.tui.clone();
1307 let session_submit = session.clone();
1308 let command_submit = command_name.clone();
1309 let ctx_submit = ctx.clone();
1310 editor.on_submit(Arc::new(move |text| {
1311 let args = serde_json::json!({ "action": "edit", "text": text });
1312 let result = invoke_extension_command(
1313 &session_submit,
1314 &command_submit,
1315 &serde_json::to_string(&args).unwrap_or_default(),
1316 );
1317 close_extension_editor(&state, &ec, &original, &tui);
1318 handle_extension_ui_result(
1319 result,
1320 &ctx_submit,
1321 session_submit.clone(),
1322 command_submit.clone(),
1323 );
1324 }));
1325
1326 let state_cancel = ctx.state.clone();
1327 let ec_cancel = ctx.editor_container.clone();
1328 let original_cancel = ctx.editor.clone();
1329 let tui_cancel = ctx.tui.clone();
1330 let session_cancel = session.clone();
1331 let command_cancel = command_name.clone();
1332 let ctx_cancel = ctx.clone();
1333 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1334 let args = serde_json::json!({ "action": "cancel" });
1335 let result = invoke_extension_command(
1336 &session_cancel,
1337 &command_cancel,
1338 &serde_json::to_string(&args).unwrap_or_default(),
1339 );
1340 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1341 handle_extension_ui_result(
1342 result,
1343 &ctx_cancel,
1344 session_cancel.clone(),
1345 command_cancel.clone(),
1346 );
1347 }));
1348
1349 ctx.tui.set_focus(Some(editor));
1350 ctx.tui.request_render(false);
1351}
1352
1353fn close_extension_editor(
1354 state: &Arc<TuiState>,
1355 editor_container: &Arc<Container>,
1356 editor: &Arc<Editor>,
1357 tui: &Arc<TuiAltScreen>,
1358) {
1359 editor_container.clear();
1360 editor_container.add_child(editor.clone());
1361 *state.active_extension_editor.lock().unwrap() = None;
1362 *state.active_extension_input.lock().unwrap() = None;
1363 *state.active_extension_cancel.lock().unwrap() = None;
1364 editor.set_focused(true);
1365 tui.set_focus(Some(editor.clone()));
1366 tui.request_render(false);
1367}
1368
1369fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1373 match request.method.as_str() {
1374 "select" => open_js_selector(ctx, bridge, request, false),
1375 "confirm" => open_js_selector(ctx, bridge, request, true),
1376 "input" => open_js_input(ctx, bridge, request),
1377 "editor" => open_js_editor(ctx, bridge, request),
1378 _ => {
1379 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1380 }
1381 }
1382}
1383
1384fn open_js_selector(
1385 ctx: &CommandContext,
1386 bridge: Arc<JsDialogBridge>,
1387 request: JsDialogRequest,
1388 confirm: bool,
1389) {
1390 let values = if confirm {
1391 vec!["Yes".to_string(), "No".to_string()]
1392 } else {
1393 request.options.clone()
1394 };
1395 if values.is_empty() {
1396 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1397 return;
1398 }
1399 let items = values
1400 .iter()
1401 .map(|value| SelectItem::new(value, value))
1402 .collect::<Vec<_>>();
1403 let list = Arc::new(SelectList::new(items, 10));
1404 let frame = Arc::new(Container::new());
1405 frame.add_child(Arc::new(DynamicBorder::new()));
1406 frame.add_child(Arc::new(Spacer::new(1)));
1407 frame.add_child(extension_dialog_title(
1408 if request.title.is_empty() {
1409 if confirm {
1410 "Confirm"
1411 } else {
1412 "Select"
1413 }
1414 } else {
1415 request.title.as_str()
1416 },
1417 true,
1418 ));
1419 if !request.message.is_empty() {
1420 frame.add_child(Arc::new(Spacer::new(1)));
1421 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1422 }
1423 frame.add_child(Arc::new(Spacer::new(1)));
1424 frame.add_child(list.clone());
1425 frame.add_child(Arc::new(Spacer::new(1)));
1426 frame.add_child(extension_dialog_hint(
1427 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1428 ));
1429 frame.add_child(Arc::new(Spacer::new(1)));
1430 frame.add_child(Arc::new(DynamicBorder::new()));
1431
1432 let id = request.id.clone();
1433 let bridge_select = bridge.clone();
1434 let state_select = ctx.state.clone();
1435 let ec_select = ctx.editor_container.clone();
1436 let editor_select = ctx.editor.clone();
1437 let tui_select = ctx.tui.clone();
1438 list.on_select(Arc::new(move |item| {
1439 let result = if confirm {
1440 serde_json::json!({ "confirmed": item.value == "Yes" })
1441 } else {
1442 serde_json::json!({ "value": item.value })
1443 };
1444 bridge_select.respond(&id, result);
1445 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1446 }));
1447
1448 let id_cancel = request.id.clone();
1449 let bridge_cancel = bridge.clone();
1450 let state_cancel = ctx.state.clone();
1451 let ec_cancel = ctx.editor_container.clone();
1452 let editor_cancel = ctx.editor.clone();
1453 let tui_cancel = ctx.tui.clone();
1454 list.on_cancel(Arc::new(move || {
1455 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1456 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1457 }));
1458
1459 let id_abort = request.id.clone();
1460 let bridge_abort = bridge.clone();
1461 let state_abort = ctx.state.clone();
1462 let ec_abort = ctx.editor_container.clone();
1463 let editor_abort = ctx.editor.clone();
1464 let tui_abort = ctx.tui.clone();
1465 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1466 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1467 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1468 }));
1469
1470 open_selector_with_view(
1471 &ctx.state,
1472 &ctx.editor_container,
1473 &ctx.editor,
1474 &ctx.tui,
1475 list,
1476 frame,
1477 SelectorKind::Extension,
1478 );
1479}
1480
1481fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1482 let input = request
1483 .placeholder
1484 .as_deref()
1485 .map(Input::with_placeholder)
1486 .unwrap_or_default();
1487 let input = Arc::new(input);
1488 input.set_focused(true);
1489
1490 let frame = Arc::new(Container::new());
1491 frame.add_child(Arc::new(DynamicBorder::new()));
1492 frame.add_child(Arc::new(Spacer::new(1)));
1493 frame.add_child(extension_dialog_title(
1494 if request.title.is_empty() {
1495 "Input"
1496 } else {
1497 &request.title
1498 },
1499 false,
1500 ));
1501 frame.add_child(Arc::new(Spacer::new(1)));
1502 frame.add_child(input.clone());
1503 frame.add_child(Arc::new(Spacer::new(1)));
1504 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1505 frame.add_child(Arc::new(Spacer::new(1)));
1506 frame.add_child(Arc::new(DynamicBorder::new()));
1507
1508 *ctx.state.active_extension_editor.lock().unwrap() = None;
1509 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1510 ctx.editor_container.clear();
1511 ctx.editor_container.add_child(frame);
1512
1513 let id = request.id.clone();
1514 let bridge_submit = bridge.clone();
1515 let state_submit = ctx.state.clone();
1516 let ec_submit = ctx.editor_container.clone();
1517 let editor_submit = ctx.editor.clone();
1518 let tui_submit = ctx.tui.clone();
1519 input.on_submit(Arc::new(move |value| {
1520 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1521 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1522 }));
1523
1524 let id_cancel = request.id.clone();
1525 let bridge_cancel = bridge.clone();
1526 let state_cancel = ctx.state.clone();
1527 let ec_cancel = ctx.editor_container.clone();
1528 let editor_cancel = ctx.editor.clone();
1529 let tui_cancel = ctx.tui.clone();
1530 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1531 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1532 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1533 }));
1534 ctx.tui.set_focus(Some(input));
1535 ctx.tui.request_render(false);
1536}
1537
1538fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1539 let editor = Arc::new(Editor::new(
1540 EditorOptions {
1541 padding_x: 1,
1542 autocomplete_max_visible: 0,
1543 initial_text: request.prefill.clone(),
1544 ..Default::default()
1545 },
1546 EditorStyle {
1547 prompt: "> ".to_string(),
1548 placeholder: String::new(),
1549 },
1550 Arc::new(rpi_tui::Keybindings::new()),
1551 ));
1552 editor.set_focused(true);
1553
1554 let frame = Arc::new(Container::new());
1555 frame.add_child(Arc::new(DynamicBorder::new()));
1556 frame.add_child(Arc::new(Spacer::new(1)));
1557 frame.add_child(extension_dialog_title(
1558 if request.title.is_empty() {
1559 "Editor"
1560 } else {
1561 &request.title
1562 },
1563 false,
1564 ));
1565 frame.add_child(Arc::new(Spacer::new(1)));
1566 frame.add_child(editor.clone());
1567 frame.add_child(Arc::new(Spacer::new(1)));
1568 frame.add_child(extension_dialog_hint(
1569 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1570 ));
1571 frame.add_child(Arc::new(Spacer::new(1)));
1572 frame.add_child(Arc::new(DynamicBorder::new()));
1573
1574 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1575 *ctx.state.active_extension_input.lock().unwrap() = None;
1576 ctx.editor_container.clear();
1577 ctx.editor_container.add_child(frame);
1578
1579 let id = request.id.clone();
1580 let bridge_submit = bridge.clone();
1581 let state_submit = ctx.state.clone();
1582 let ec_submit = ctx.editor_container.clone();
1583 let editor_submit = ctx.editor.clone();
1584 let tui_submit = ctx.tui.clone();
1585 editor.on_submit(Arc::new(move |value| {
1586 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1587 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1588 }));
1589
1590 let id_cancel = request.id.clone();
1591 let bridge_cancel = bridge.clone();
1592 let state_cancel = ctx.state.clone();
1593 let ec_cancel = ctx.editor_container.clone();
1594 let editor_cancel = ctx.editor.clone();
1595 let tui_cancel = ctx.tui.clone();
1596 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1597 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1598 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1599 }));
1600 ctx.tui.set_focus(Some(editor));
1601 ctx.tui.request_render(false);
1602}
1603
1604fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1605 for id in bridge.cancelled_active_ids() {
1606 if !bridge.is_visible(&id) {
1610 bridge.finish(&id);
1611 continue;
1612 }
1613 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1614 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1615 } else if ctx.state.extension_dialog_open() {
1616 if !run_extension_cancel(&ctx.state) {
1617 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1618 }
1619 }
1620 bridge.finish(&id);
1624 }
1625}
1626
1627struct HelpCommand;
1630impl SlashCommand for HelpCommand {
1631 fn name(&self) -> &'static str {
1632 "/help"
1633 }
1634 fn aliases(&self) -> &'static [&'static str] {
1635 &["/?"]
1636 }
1637 fn description(&self) -> &'static str {
1638 "Show available commands"
1639 }
1640 fn execute(&self, ctx: &CommandContext, _args: &str) {
1641 add_help_message(&ctx.chat);
1642 ctx.tui.request_render(false);
1643 }
1644}
1645
1646struct ClearChatCommand;
1647impl SlashCommand for ClearChatCommand {
1648 fn name(&self) -> &'static str {
1649 "/clear"
1650 }
1651 fn aliases(&self) -> &'static [&'static str] {
1652 &["/new"]
1653 }
1654 fn alias_visible(&self) -> &'static [&'static str] {
1656 &["/new"]
1657 }
1658 fn description(&self) -> &'static str {
1659 "Clear the conversation"
1660 }
1661 fn execute(&self, ctx: &CommandContext, _args: &str) {
1662 let _ = ctx.tx.send(TuiMessage::ClearChat);
1663 }
1664}
1665
1666struct ExitCommand;
1667impl SlashCommand for ExitCommand {
1668 fn name(&self) -> &'static str {
1669 "/exit"
1670 }
1671 fn aliases(&self) -> &'static [&'static str] {
1672 &["/quit", "/q"]
1673 }
1674 fn alias_visible(&self) -> &'static [&'static str] {
1676 &["/quit"]
1677 }
1678 fn description(&self) -> &'static str {
1679 "Exit the application"
1680 }
1681 fn execute(&self, ctx: &CommandContext, _args: &str) {
1682 ctx.state.cancel_js_preparation();
1683 let _ = ctx.tx.send(TuiMessage::Exit);
1684 }
1685}
1686
1687struct VersionCommand;
1688impl SlashCommand for VersionCommand {
1689 fn name(&self) -> &'static str {
1690 "/version"
1691 }
1692 fn aliases(&self) -> &'static [&'static str] {
1693 &["/v"]
1694 }
1695 fn description(&self) -> &'static str {
1696 "Show version information"
1697 }
1698 fn execute(&self, ctx: &CommandContext, _args: &str) {
1699 add_version_message(&ctx.chat);
1700 ctx.tui.request_render(false);
1701 }
1702}
1703
1704struct ChangelogCommand;
1705impl SlashCommand for ChangelogCommand {
1706 fn name(&self) -> &'static str {
1707 "/changelog"
1708 }
1709 fn description(&self) -> &'static str {
1710 "Show recent release changes"
1711 }
1712 fn execute(&self, ctx: &CommandContext, _args: &str) {
1713 add_changelog_message(&ctx.chat);
1714 ctx.tui.request_render(false);
1715 }
1716}
1717
1718struct HotkeysCommand;
1719impl SlashCommand for HotkeysCommand {
1720 fn name(&self) -> &'static str {
1721 "/hotkeys"
1722 }
1723 fn description(&self) -> &'static str {
1724 "Show keyboard shortcuts"
1725 }
1726 fn execute(&self, ctx: &CommandContext, _args: &str) {
1727 add_hotkeys_message(&ctx.chat);
1728 ctx.tui.request_render(false);
1729 }
1730}
1731
1732struct ModelCommand;
1733impl SlashCommand for ModelCommand {
1734 fn name(&self) -> &'static str {
1735 "/model"
1736 }
1737 fn aliases(&self) -> &'static [&'static str] {
1738 &["/m"]
1739 }
1740 fn description(&self) -> &'static str {
1741 "Choose a model (selector)"
1742 }
1743 fn execute(&self, ctx: &CommandContext, args: &str) {
1744 let term = args.trim();
1745 if !term.is_empty() {
1746 let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
1748 add_error_message(
1749 &ctx.chat,
1750 &format!("No model matches \"{term}\". Try /model for the list."),
1751 );
1752 ctx.tui.request_render(false);
1753 return;
1754 };
1755 let model_id = model.id.clone();
1756 ctx.state.set_current_model(&model);
1757 let lane = ctx.lane.clone();
1758 tokio::spawn(async move {
1759 let _ = lane.set_model(model).await;
1760 });
1761 add_note_message(
1762 &ctx.chat,
1763 &format!(
1764 "Model set to {} — applies to the next message.",
1765 short_model_name(&model_id)
1766 ),
1767 );
1768 ctx.tui.request_render(false);
1769 return;
1770 }
1771 open_model_selector(
1772 &ctx.state,
1773 &ctx.editor_container,
1774 &ctx.editor,
1775 &ctx.tui,
1776 &ctx.model_catalog,
1777 &ctx.lane,
1778 &ctx.lane_model_id,
1779 &ctx.chat,
1780 );
1781 }
1782}
1783
1784struct ThinkingCommand;
1785impl SlashCommand for ThinkingCommand {
1786 fn name(&self) -> &'static str {
1787 "/thinking"
1788 }
1789 fn aliases(&self) -> &'static [&'static str] {
1790 &["/think"]
1791 }
1792 fn description(&self) -> &'static str {
1793 "Set thinking level (selector)"
1794 }
1795 fn execute(&self, ctx: &CommandContext, args: &str) {
1796 let level_name = args.trim();
1797 if !level_name.is_empty() {
1798 let Some(level) = thinking_level_from_name(level_name) else {
1800 add_error_message(
1801 &ctx.chat,
1802 &format!(
1803 "Unknown thinking level \"{level_name}\". Valid: {}",
1804 crate::args::VALID_THINKING_LEVELS.join(", ")
1805 ),
1806 );
1807 ctx.tui.request_render(false);
1808 return;
1809 };
1810 let lane = ctx.lane.clone();
1811 let footer = ctx.state.footer.clone();
1812 tokio::spawn(async move {
1813 let _ = lane.set_thinking_level(level).await;
1814 });
1815 footer.set_thinking_level(Some(thinking_level_name(level)));
1816 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
1817 ctx.tui.request_render(false);
1818 return;
1819 }
1820 open_thinking_selector(
1821 &ctx.state,
1822 &ctx.editor_container,
1823 &ctx.editor,
1824 &ctx.tui,
1825 &ctx.lane,
1826 &ctx.model_catalog,
1827 &ctx.lane_model_id,
1828 &ctx.chat,
1829 );
1830 }
1831}
1832
1833struct ToolsCommand;
1834impl SlashCommand for ToolsCommand {
1835 fn name(&self) -> &'static str {
1836 "/tools"
1837 }
1838 fn description(&self) -> &'static str {
1839 "Toggle tools on/off"
1840 }
1841 fn execute(&self, ctx: &CommandContext, _args: &str) {
1842 open_tools_selector(
1843 &ctx.state,
1844 &ctx.editor_container,
1845 &ctx.editor,
1846 &ctx.tui,
1847 &ctx.lane,
1848 &ctx.chat,
1849 );
1850 }
1851}
1852
1853struct ImagesCommand;
1854impl SlashCommand for ImagesCommand {
1855 fn name(&self) -> &'static str {
1856 "/images"
1857 }
1858 fn description(&self) -> &'static str {
1859 "Toggle inline images"
1860 }
1861 fn execute(&self, ctx: &CommandContext, _args: &str) {
1862 open_images_selector(
1863 &ctx.state,
1864 &ctx.editor_container,
1865 &ctx.editor,
1866 &ctx.tui,
1867 &ctx.chat,
1868 );
1869 }
1870}
1871
1872struct SessionCommand;
1873impl SlashCommand for SessionCommand {
1874 fn name(&self) -> &'static str {
1875 "/session"
1876 }
1877 fn aliases(&self) -> &'static [&'static str] {
1878 &["/resume"]
1879 }
1880 fn description(&self) -> &'static str {
1881 "List saved sessions"
1882 }
1883 fn execute(&self, ctx: &CommandContext, _args: &str) {
1884 open_session_selector(
1885 &ctx.state,
1886 &ctx.editor_container,
1887 &ctx.editor,
1888 &ctx.tui,
1889 &ctx.cwd,
1890 &ctx.tx,
1891 );
1892 }
1893}
1894
1895struct ThemeCommand;
1896impl SlashCommand for ThemeCommand {
1897 fn name(&self) -> &'static str {
1898 "/theme"
1899 }
1900 fn description(&self) -> &'static str {
1901 "Choose a theme (selector)"
1902 }
1903 fn execute(&self, ctx: &CommandContext, args: &str) {
1904 let name = args.trim().to_ascii_lowercase();
1905 if !name.is_empty() {
1906 let preset = match name.as_str() {
1908 "light" => ThemePreset::Light,
1909 "monochrome" => ThemePreset::Monochrome,
1910 "dark" => ThemePreset::Dark,
1911 _ => {
1912 add_error_message(
1913 &ctx.chat,
1914 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
1915 );
1916 ctx.tui.request_render(false);
1917 return;
1918 }
1919 };
1920 apply_theme_preset(preset);
1921 let mut settings = crate::settings::load_settings().unwrap_or_default();
1922 settings.theme = Some(name.clone());
1923 let _ = crate::settings::save_settings(&settings);
1924 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
1925 ctx.tui.request_render(false);
1926 ctx.tui.render_now(true);
1927 return;
1928 }
1929 open_theme_selector(
1930 &ctx.state,
1931 &ctx.editor_container,
1932 &ctx.editor,
1933 &ctx.tui,
1934 &ctx.package_resources,
1935 );
1936 }
1937}
1938
1939struct CompactCommand;
1940impl SlashCommand for CompactCommand {
1941 fn name(&self) -> &'static str {
1942 "/compact"
1943 }
1944 fn description(&self) -> &'static str {
1945 "Compact the conversation"
1946 }
1947 fn execute(&self, ctx: &CommandContext, _args: &str) {
1948 let _ = ctx.tx.send(TuiMessage::Compact);
1949 }
1950}
1951
1952struct CopyCommand;
1953impl SlashCommand for CopyCommand {
1954 fn name(&self) -> &'static str {
1955 "/copy"
1956 }
1957 fn description(&self) -> &'static str {
1958 "Copy last reply to clipboard"
1959 }
1960 fn execute(&self, ctx: &CommandContext, _args: &str) {
1961 let _ = ctx.tx.send(TuiMessage::Copy);
1962 }
1963}
1964
1965struct ExportCommand;
1966impl SlashCommand for ExportCommand {
1967 fn name(&self) -> &'static str {
1968 "/export"
1969 }
1970 fn description(&self) -> &'static str {
1971 "Export session to a markdown file"
1972 }
1973 fn execute(&self, ctx: &CommandContext, _args: &str) {
1974 let _ = ctx.tx.send(TuiMessage::ExportSession);
1975 }
1976}
1977
1978struct ForkCommand;
1979impl SlashCommand for ForkCommand {
1980 fn name(&self) -> &'static str {
1981 "/fork"
1982 }
1983 fn description(&self) -> &'static str {
1984 "Fork the session into a new one"
1985 }
1986 fn execute(&self, ctx: &CommandContext, _args: &str) {
1987 let _ = ctx.tx.send(TuiMessage::ForkSession);
1988 }
1989}
1990
1991struct CloneCommand;
1995impl SlashCommand for CloneCommand {
1996 fn name(&self) -> &'static str {
1997 "/clone"
1998 }
1999 fn description(&self) -> &'static str {
2000 "Duplicate the current session"
2001 }
2002 fn execute(&self, ctx: &CommandContext, _args: &str) {
2003 let _ = ctx.tx.send(TuiMessage::ForkSession);
2004 }
2005}
2006
2007struct TreeCommand;
2008impl SlashCommand for TreeCommand {
2009 fn name(&self) -> &'static str {
2010 "/tree"
2011 }
2012 fn description(&self) -> &'static str {
2013 "Navigate the current session tree"
2014 }
2015 fn execute(&self, ctx: &CommandContext, _args: &str) {
2016 let _ = ctx.tx.send(TuiMessage::OpenTree);
2017 }
2018}
2019
2020struct LoginCommand;
2021impl SlashCommand for LoginCommand {
2022 fn name(&self) -> &'static str {
2023 "/login"
2024 }
2025 fn description(&self) -> &'static str {
2026 "Save an Anthropic API key"
2027 }
2028 fn execute(&self, ctx: &CommandContext, args: &str) {
2029 let key = args.trim();
2030 if key.is_empty() {
2031 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2032 } else {
2033 let result = crate::config::upsert_credential(
2034 "anthropic",
2035 crate::config::Credential::ApiKey {
2036 key: Some(key.to_string()),
2037 env: None,
2038 },
2039 );
2040 match result {
2041 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2042 Err(error) => {
2043 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2044 }
2045 }
2046 }
2047 ctx.tui.request_render(false);
2048 }
2049}
2050
2051struct LogoutCommand;
2052impl SlashCommand for LogoutCommand {
2053 fn name(&self) -> &'static str {
2054 "/logout"
2055 }
2056 fn description(&self) -> &'static str {
2057 "Remove saved Anthropic credentials"
2058 }
2059 fn execute(&self, ctx: &CommandContext, _args: &str) {
2060 match crate::config::delete_credential("anthropic") {
2061 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2062 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2063 Err(error) => {
2064 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2065 }
2066 }
2067 ctx.tui.request_render(false);
2068 }
2069}
2070
2071struct TrustCommand;
2072impl SlashCommand for TrustCommand {
2073 fn name(&self) -> &'static str {
2074 "/trust"
2075 }
2076 fn description(&self) -> &'static str {
2077 "Trust the current project"
2078 }
2079 fn execute(&self, ctx: &CommandContext, args: &str) {
2080 let value = match args.trim().to_ascii_lowercase().as_str() {
2081 "" | "yes" | "y" | "true" => Some(true),
2082 "no" | "n" | "false" => Some(false),
2083 "clear" | "reset" | "none" => None,
2084 _ => {
2085 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2086 ctx.tui.request_render(false);
2087 return;
2088 }
2089 };
2090 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2091 match crate::config::set_project_trust(&cwd, value) {
2092 Ok(()) => {
2093 let label = match value {
2094 Some(true) => "trusted",
2095 Some(false) => "untrusted",
2096 None => "trust decision cleared",
2097 };
2098 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2099 }
2100 Err(error) => add_error_message(
2101 &ctx.chat,
2102 &format!("Could not save trust decision: {error}"),
2103 ),
2104 }
2105 ctx.tui.request_render(false);
2106 }
2107}
2108
2109struct NameCommand;
2110impl SlashCommand for NameCommand {
2111 fn name(&self) -> &'static str {
2112 "/name"
2113 }
2114 fn description(&self) -> &'static str {
2115 "Set session display name"
2116 }
2117 fn execute(&self, ctx: &CommandContext, args: &str) {
2118 let name = args.trim();
2119 if name.is_empty() {
2120 add_note_message(
2121 &ctx.chat,
2122 "Usage: /name <display name> — sets the current session's name.",
2123 );
2124 ctx.tui.request_render(false);
2125 return;
2126 }
2127 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2128 }
2129}
2130
2131struct ImportCommand;
2132impl SlashCommand for ImportCommand {
2133 fn name(&self) -> &'static str {
2134 "/import"
2135 }
2136 fn description(&self) -> &'static str {
2137 "Import a session file (path)"
2138 }
2139 fn execute(&self, ctx: &CommandContext, args: &str) {
2140 let path = args.trim();
2141 if path.is_empty() {
2142 add_note_message(
2143 &ctx.chat,
2144 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2145 );
2146 ctx.tui.request_render(false);
2147 return;
2148 }
2149 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2150 }
2151}
2152
2153struct SettingsCommand;
2154impl SlashCommand for SettingsCommand {
2155 fn name(&self) -> &'static str {
2156 "/settings"
2157 }
2158 fn description(&self) -> &'static str {
2159 "Open settings menu"
2160 }
2161 fn execute(&self, ctx: &CommandContext, _args: &str) {
2162 open_settings_selector(
2163 &ctx.state,
2164 &ctx.editor_container,
2165 &ctx.editor,
2166 &ctx.tui,
2167 &ctx.lane,
2168 &ctx.model_catalog,
2169 &ctx.lane_model_id,
2170 &ctx.chat,
2171 &ctx.package_resources,
2172 );
2173 }
2174}
2175
2176struct ScopedModelsCommand;
2177impl SlashCommand for ScopedModelsCommand {
2178 fn name(&self) -> &'static str {
2179 "/scoped-models"
2180 }
2181 fn description(&self) -> &'static str {
2182 "Choose models for Ctrl+M cycling"
2183 }
2184 fn execute(&self, ctx: &CommandContext, _args: &str) {
2185 open_scoped_models_selector(
2186 &ctx.state,
2187 &ctx.editor_container,
2188 &ctx.editor,
2189 &ctx.tui,
2190 &ctx.model_catalog,
2191 &ctx.chat,
2192 );
2193 }
2194}
2195
2196struct ShareCommand;
2197impl SlashCommand for ShareCommand {
2198 fn name(&self) -> &'static str {
2199 "/share"
2200 }
2201 fn description(&self) -> &'static str {
2202 "Share session (gist via gh, or clipboard)"
2203 }
2204 fn execute(&self, ctx: &CommandContext, _args: &str) {
2205 let _ = ctx.tx.send(TuiMessage::ShareSession);
2206 }
2207}
2208
2209struct ArminCommand;
2210impl SlashCommand for ArminCommand {
2211 fn name(&self) -> &'static str {
2212 "/armin"
2213 }
2214 fn description(&self) -> &'static str {
2215 "??? (easter egg)"
2216 }
2217 fn execute(&self, ctx: &CommandContext, _args: &str) {
2218 crate::extras::add_armin(&ctx.chat);
2219 ctx.tui.request_render(false);
2220 }
2221}
2222
2223struct EarendilCommand;
2224impl SlashCommand for EarendilCommand {
2225 fn name(&self) -> &'static str {
2226 "/earendil"
2227 }
2228 fn description(&self) -> &'static str {
2229 "Announcement"
2230 }
2231 fn execute(&self, ctx: &CommandContext, _args: &str) {
2232 crate::extras::add_earendil(&ctx.chat);
2233 ctx.tui.request_render(false);
2234 }
2235}
2236
2237struct ContextCommand;
2241impl SlashCommand for ContextCommand {
2242 fn name(&self) -> &'static str {
2243 "/context"
2244 }
2245 fn visible(&self) -> bool {
2246 false
2247 }
2248 fn execute(&self, ctx: &CommandContext, _args: &str) {
2249 show_context_panel(&ctx.chat, &ctx.resources);
2250 ctx.tui.request_render(false);
2251 }
2252}
2253
2254struct ReloadCommand;
2265impl SlashCommand for ReloadCommand {
2266 fn name(&self) -> &'static str {
2267 "/reload"
2268 }
2269 fn description(&self) -> &'static str {
2270 "Reload extensions, skills, prompts"
2271 }
2272 fn execute(&self, ctx: &CommandContext, _args: &str) {
2273 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2277 ctx.tui.request_render(false);
2278 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2279 }
2280}
2281
2282fn build_builtin_registry() -> CommandRegistry {
2287 let mut r = CommandRegistry::new();
2288 r.register(Arc::new(HelpCommand));
2289 r.register(Arc::new(ClearChatCommand));
2290 r.register(Arc::new(ExitCommand));
2291 r.register(Arc::new(VersionCommand));
2292 r.register(Arc::new(ChangelogCommand));
2293 r.register(Arc::new(ModelCommand));
2294 r.register(Arc::new(ThinkingCommand));
2295 r.register(Arc::new(ToolsCommand));
2296 r.register(Arc::new(ImagesCommand));
2297 r.register(Arc::new(SessionCommand));
2298 r.register(Arc::new(ThemeCommand));
2299 r.register(Arc::new(CompactCommand));
2300 r.register(Arc::new(CopyCommand));
2301 r.register(Arc::new(HotkeysCommand));
2302 r.register(Arc::new(ArminCommand));
2303 r.register(Arc::new(EarendilCommand));
2304 r.register(Arc::new(ContextCommand));
2305 r.register(Arc::new(NameCommand));
2309 r.register(Arc::new(SettingsCommand));
2310 r.register(Arc::new(ScopedModelsCommand));
2311 r.register(Arc::new(ExportCommand));
2312 r.register(Arc::new(ImportCommand));
2313 r.register(Arc::new(ShareCommand));
2314 r.register(Arc::new(ForkCommand));
2315 r.register(Arc::new(CloneCommand));
2316 r.register(Arc::new(TreeCommand));
2317 r.register(Arc::new(TrustCommand));
2318 r.register(Arc::new(LoginCommand));
2319 r.register(Arc::new(LogoutCommand));
2320 r.register(Arc::new(ReloadCommand));
2321 r
2322}
2323
2324fn register_extension_commands(
2325 registry: &mut CommandRegistry,
2326 session: crate::session::ExtensionSessionCell,
2327) {
2328 let commands = session
2329 .lock()
2330 .ok()
2331 .and_then(|s| s.snapshot_arc())
2332 .map(|snap| snap.commands().to_vec())
2333 .unwrap_or_default();
2334 for command in commands {
2335 let name = if command.name.starts_with('/') {
2336 command.name.clone()
2337 } else {
2338 format!("/{}", command.name)
2339 };
2340 if registry.find(&name).is_some() {
2341 continue;
2342 }
2343 registry.register(Arc::new(ExtensionCommand {
2344 name,
2345 description: command.description,
2346 session: session.clone(),
2347 }));
2348 }
2349}
2350
2351fn register_js_extension_commands(
2352 registry: &mut CommandRegistry,
2353 session: Option<crate::js_extensions::JsExtensionSession>,
2354) {
2355 let Some(session) = session else {
2356 return;
2357 };
2358 for command in &session.commands {
2359 let name = if command.starts_with('/') {
2360 command.clone()
2361 } else {
2362 format!("/{command}")
2363 };
2364 if registry.find(&name).is_none() {
2365 registry.register(Arc::new(JsExtensionCommand {
2366 name,
2367 session: session.clone(),
2368 }));
2369 }
2370 }
2371}
2372
2373enum TuiMessage {
2380 UserInput(String),
2381 OpenTree,
2382 NavigateTree(String),
2383 Exit,
2384 ClearChat,
2386 Compact,
2388 Copy,
2390 SwitchSession(String),
2393 ExportSession,
2395 ForkSession,
2397 SetSessionName(String),
2399 ImportSession(String),
2402 ShareSession,
2405 ReloadExtensions,
2410 ExternalEditorResult(Result<String, String>),
2414}
2415
2416fn assistant_text(msg: &AssistantMessage) -> String {
2419 msg.content
2420 .iter()
2421 .filter_map(|c| match c {
2422 Content::Text(t) => Some(t.text.clone()),
2423 _ => None,
2424 })
2425 .collect()
2426}
2427
2428fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2431 match &msg.content {
2432 rpi_ai::types::UserContent::Text(s) => s.clone(),
2433 rpi_ai::types::UserContent::Blocks(blocks) => blocks
2434 .iter()
2435 .filter_map(|c| match c {
2436 Content::Text(t) => Some(t.text.clone()),
2437 _ => None,
2438 })
2439 .collect(),
2440 }
2441}
2442
2443fn show_settings_panel(chat: &Arc<Container>) {
2448 let s = crate::settings::load_settings().unwrap_or_default();
2449 let mut lines: Vec<String> = Vec::new();
2450 lines.push("⚙️ Saved settings:".into());
2451 lines.push(format!(
2452 " Theme: {} (edit with /theme)",
2453 s.theme.as_deref().unwrap_or("(default)")
2454 ));
2455 lines.push(format!(
2456 " Default model: {} (set at launch with --model)",
2457 s.default_model.as_deref().unwrap_or("(none)")
2458 ));
2459 lines.push(format!(
2460 " Default thinking: {} (set at launch with --thinking)",
2461 s.default_thinking_level.as_deref().unwrap_or("(default)")
2462 ));
2463 match &s.scoped_models {
2464 Some(list) if !list.is_empty() => lines.push(format!(
2465 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
2466 list.join(", ")
2467 )),
2468 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
2469 }
2470 let body = lines.join("\n");
2471 container_note_block(chat, &body);
2472}
2473
2474fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2478 let scoped = crate::settings::load_settings()
2479 .ok()
2480 .and_then(|s| s.scoped_models)
2481 .unwrap_or_default();
2482 if scoped.is_empty() {
2483 return catalog.to_vec();
2484 }
2485 let mut out: Vec<rpi_ai::Model> = catalog
2486 .iter()
2487 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2488 .cloned()
2489 .collect();
2490 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2492 if let Some(cur) = catalog
2493 .iter()
2494 .find(|m| m.id.eq_ignore_ascii_case(current_id))
2495 {
2496 out.push(cur.clone());
2497 }
2498 }
2499 out
2500}
2501
2502fn open_settings_selector(
2509 state: &Arc<TuiState>,
2510 editor_container: &Arc<Container>,
2511 editor: &Arc<Editor>,
2512 tui: &Arc<TuiAltScreen>,
2513 lane: &Arc<dyn AgentLane>,
2514 catalog: &[rpi_ai::Model],
2515 lane_model_id: &str,
2516 chat: &Arc<Container>,
2517 package_resources: &Arc<crate::packages::PackageResources>,
2518) {
2519 let settings = crate::settings::load_settings().unwrap_or_default();
2520 let mut items: Vec<SelectItem> = Vec::new();
2521 items.push(
2522 SelectItem::new("theme", "Theme")
2523 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
2524 );
2525 items.push(
2526 SelectItem::new("model", "Default model").with_description(
2527 &settings
2528 .default_model
2529 .clone()
2530 .unwrap_or_else(|| "(none)".into()),
2531 ),
2532 );
2533 items.push(
2534 SelectItem::new("thinking", "Default thinking").with_description(
2535 &settings
2536 .default_thinking_level
2537 .clone()
2538 .unwrap_or_else(|| "(default)".into()),
2539 ),
2540 );
2541 let scope_desc = match &settings.scoped_models {
2542 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
2543 _ => "all models".to_string(),
2544 };
2545 items
2546 .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
2547 let list = Arc::new(SelectList::new(items, 10));
2548
2549 let state_sel = state.clone();
2550 let ec_sel = editor_container.clone();
2551 let editor_sel = editor.clone();
2552 let tui_sel = tui.clone();
2553 let lane_sel = lane.clone();
2554 let chat_sel = chat.clone();
2555 let catalog_sel = catalog.to_vec();
2556 let lane_model_sel = lane_model_id.to_string();
2557 let package_resources_sel = package_resources.clone();
2558 list.on_select(Arc::new(move |item| {
2559 match item.value.as_str() {
2562 "theme" => open_settings_theme_selector(
2563 &state_sel,
2564 &ec_sel,
2565 &editor_sel,
2566 &tui_sel,
2567 &chat_sel,
2568 &package_resources_sel,
2569 ),
2570 "model" => open_settings_model_selector(
2571 &state_sel,
2572 &ec_sel,
2573 &editor_sel,
2574 &tui_sel,
2575 &lane_sel,
2576 &catalog_sel,
2577 &lane_model_sel,
2578 &chat_sel,
2579 ),
2580 "thinking" => open_settings_thinking_selector(
2581 &state_sel,
2582 &ec_sel,
2583 &editor_sel,
2584 &tui_sel,
2585 &lane_sel,
2586 &catalog_sel,
2587 &lane_model_sel,
2588 &chat_sel,
2589 ),
2590 "scoped-models" => open_scoped_models_selector(
2591 &state_sel,
2592 &ec_sel,
2593 &editor_sel,
2594 &tui_sel,
2595 &catalog_sel,
2596 &chat_sel,
2597 ),
2598 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
2599 }
2600 }));
2601 let state_cancel = state.clone();
2602 let ec_cancel = editor_container.clone();
2603 let editor_cancel = editor.clone();
2604 let tui_cancel = tui.clone();
2605 list.on_cancel(Arc::new(move || {
2606 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2607 }));
2608
2609 open_selector(
2610 state,
2611 editor_container,
2612 editor,
2613 tui,
2614 list,
2615 SelectorKind::Settings,
2616 );
2617}
2618
2619fn open_settings_theme_selector(
2621 state: &Arc<TuiState>,
2622 editor_container: &Arc<Container>,
2623 editor: &Arc<Editor>,
2624 tui: &Arc<TuiAltScreen>,
2625 chat: &Arc<Container>,
2626 package_resources: &Arc<crate::packages::PackageResources>,
2627) {
2628 let mut items = vec![
2629 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
2630 SelectItem::new("light", "Light").with_description("Light background"),
2631 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
2632 ];
2633 if state.themes_enabled {
2634 for path in package_resources.theme_files() {
2635 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
2636 items.push(SelectItem::new(name, name).with_description("Package theme"));
2637 }
2638 }
2639 }
2640 let list = Arc::new(SelectList::new(items, 10));
2641
2642 let state_sel = state.clone();
2643 let ec_sel = editor_container.clone();
2644 let editor_sel = editor.clone();
2645 let tui_sel = tui.clone();
2646 let chat_sel = chat.clone();
2647 let package_resources_sel = package_resources.clone();
2648 list.on_select(Arc::new(move |item| {
2649 let preset = match item.value.as_str() {
2650 "light" => Some(ThemePreset::Light),
2651 "monochrome" => Some(ThemePreset::Monochrome),
2652 "dark" => Some(ThemePreset::Dark),
2653 name => {
2654 if let Ok(cwd) = std::env::current_dir() {
2655 if state_sel.themes_enabled {
2656 if let Ok(custom) = crate::packages::load_theme_with_resources(
2657 &cwd,
2658 name,
2659 &package_resources_sel,
2660 ) {
2661 rpi_tui::global_theme_manager().set(custom.clone());
2662 state_sel.theme_manager.set(custom);
2663 }
2664 }
2665 }
2666 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
2667 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2668 tui_sel.render_now(true);
2669 return;
2670 }
2671 };
2672 let Some(preset) = preset else { return };
2673 apply_theme_preset(preset);
2674 state_sel.theme_manager.apply_preset(preset);
2675 let mut settings = crate::settings::load_settings().unwrap_or_default();
2676 settings.theme = Some(item.value.clone());
2677 let saved = crate::settings::save_settings(&settings);
2678 add_note_message(
2679 &chat_sel,
2680 &format!(
2681 "Theme set to {} (saved{})",
2682 item.label,
2683 if saved.is_ok() { "" } else { ", not saved" },
2684 ),
2685 );
2686 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2687 tui_sel.render_now(true);
2688 }));
2689 let state_cancel = state.clone();
2690 let ec_cancel = editor_container.clone();
2691 let editor_cancel = editor.clone();
2692 let tui_cancel = tui.clone();
2693 list.on_cancel(Arc::new(move || {
2694 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2695 }));
2696
2697 open_selector(
2698 state,
2699 editor_container,
2700 editor,
2701 tui,
2702 list,
2703 SelectorKind::Settings,
2704 );
2705}
2706
2707fn open_settings_model_selector(
2711 state: &Arc<TuiState>,
2712 editor_container: &Arc<Container>,
2713 editor: &Arc<Editor>,
2714 tui: &Arc<TuiAltScreen>,
2715 lane: &Arc<dyn AgentLane>,
2716 catalog: &[rpi_ai::Model],
2717 lane_model_id: &str,
2718 chat: &Arc<Container>,
2719) {
2720 let items = model_selector_items(catalog, lane_model_id);
2721 if items.is_empty() {
2722 add_note_message(chat, "No models in the catalog.");
2723 tui.request_render(false);
2724 return;
2725 }
2726 let list = Arc::new(SelectList::new(items, 10));
2727
2728 let catalog_arc = catalog.to_vec();
2729 let state_sel = state.clone();
2730 let ec_sel = editor_container.clone();
2731 let editor_sel = editor.clone();
2732 let tui_sel = tui.clone();
2733 let chat_sel = chat.clone();
2734 let lane_sel = lane.clone();
2735 list.on_select(Arc::new(move |item| {
2736 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
2737 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
2738 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2739 return;
2740 };
2741 state_sel.set_current_model(&model);
2742 let lane = lane_sel.clone();
2743 tokio::spawn(async move {
2744 let _ = lane.set_model(model).await;
2745 });
2746 let mut settings = crate::settings::load_settings().unwrap_or_default();
2747 settings.default_model = Some(item.value.clone());
2748 let saved = crate::settings::save_settings(&settings);
2749 add_note_message(
2750 &chat_sel,
2751 &format!(
2752 "Default model set to {} (saved{}",
2753 short_model_name(&item.value),
2754 if saved.is_ok() { ")" } else { ", not saved)" },
2755 ),
2756 );
2757 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2758 }));
2759 let state_cancel = state.clone();
2760 let ec_cancel = editor_container.clone();
2761 let editor_cancel = editor.clone();
2762 let tui_cancel = tui.clone();
2763 list.on_cancel(Arc::new(move || {
2764 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2765 }));
2766
2767 open_selector(
2768 state,
2769 editor_container,
2770 editor,
2771 tui,
2772 list,
2773 SelectorKind::Settings,
2774 );
2775}
2776
2777fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
2782 let mut seen = std::collections::HashSet::new();
2783 catalog
2784 .iter()
2785 .filter(|m| {
2786 seen.insert((
2787 m.api.clone(),
2788 m.provider.to_ascii_lowercase(),
2789 m.id.to_ascii_lowercase(),
2790 ))
2791 })
2792 .map(|m| {
2793 let label = if m.name.is_empty() {
2794 short_model_name(&m.id)
2795 } else {
2796 m.name.clone()
2797 };
2798 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
2799 && m.provider.eq_ignore_ascii_case("anthropic")
2800 {
2801 m.id.clone()
2802 } else {
2803 format!("{}/{}", m.provider, m.id)
2804 };
2805 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
2806 " (current)"
2807 } else {
2808 ""
2809 };
2810 SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
2811 })
2812 .collect()
2813}
2814
2815fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
2819 let (provider, id) = input
2820 .split_once('/')
2821 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
2822 .map_or((None, input), |(provider, id)| (Some(provider), id));
2823 catalog
2824 .iter()
2825 .find(|model| {
2826 model.id.eq_ignore_ascii_case(id)
2827 && provider.map_or(true, |provider| {
2828 model.provider.eq_ignore_ascii_case(provider)
2829 || (provider.eq_ignore_ascii_case("anthropic")
2830 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
2831 })
2832 })
2833 .cloned()
2834}
2835
2836fn open_settings_thinking_selector(
2840 state: &Arc<TuiState>,
2841 editor_container: &Arc<Container>,
2842 editor: &Arc<Editor>,
2843 tui: &Arc<TuiAltScreen>,
2844 lane: &Arc<dyn AgentLane>,
2845 catalog: &[rpi_ai::Model],
2846 lane_model_id: &str,
2847 chat: &Arc<Container>,
2848) {
2849 let model = catalog
2850 .iter()
2851 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
2852 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
2853 .map(|m| m.supported_thinking_levels())
2854 .unwrap_or_else(|| {
2855 use rpi_ai::types::ThinkingLevel::*;
2856 vec![Off, Minimal, Low, Medium, High]
2857 });
2858 let mut items: Vec<SelectItem> = Vec::new();
2859 for lvl in &levels {
2860 let name = thinking_level_name(*lvl);
2861 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
2862 }
2863 if items.is_empty() {
2864 add_note_message(chat, "This model has no supported thinking levels.");
2865 tui.request_render(false);
2866 return;
2867 }
2868 let list = Arc::new(SelectList::new(items, 10));
2869
2870 let state_sel = state.clone();
2871 let ec_sel = editor_container.clone();
2872 let editor_sel = editor.clone();
2873 let tui_sel = tui.clone();
2874 let chat_sel = chat.clone();
2875 let lane_sel = lane.clone();
2876 list.on_select(Arc::new(move |item| {
2877 let Some(level) = thinking_level_from_name(&item.value) else {
2878 add_note_message(
2879 &chat_sel,
2880 &format!("Unknown thinking level: {}.", item.label),
2881 );
2882 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2883 return;
2884 };
2885 let lane = lane_sel.clone();
2886 let footer_sel = state_sel.footer.clone();
2887 tokio::spawn(async move {
2888 let _ = lane.set_thinking_level(level).await;
2889 });
2890 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
2891 let mut settings = crate::settings::load_settings().unwrap_or_default();
2892 settings.default_thinking_level = Some(item.value.clone());
2893 let saved = crate::settings::save_settings(&settings);
2894 add_note_message(
2895 &chat_sel,
2896 &format!(
2897 "Default thinking set to {} (saved{}",
2898 item.label,
2899 if saved.is_ok() { ")" } else { ", not saved)" },
2900 ),
2901 );
2902 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2903 }));
2904 let state_cancel = state.clone();
2905 let ec_cancel = editor_container.clone();
2906 let editor_cancel = editor.clone();
2907 let tui_cancel = tui.clone();
2908 list.on_cancel(Arc::new(move || {
2909 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2910 }));
2911
2912 open_selector(
2913 state,
2914 editor_container,
2915 editor,
2916 tui,
2917 list,
2918 SelectorKind::Settings,
2919 );
2920}
2921
2922fn open_scoped_models_selector(
2927 state: &Arc<TuiState>,
2928 editor_container: &Arc<Container>,
2929 editor: &Arc<Editor>,
2930 tui: &Arc<TuiAltScreen>,
2931 catalog: &[rpi_ai::Model],
2932 chat: &Arc<Container>,
2933) {
2934 if catalog.is_empty() {
2935 add_note_message(chat, "No models in the catalog.");
2936 tui.request_render(false);
2937 return;
2938 }
2939 let seed: Vec<String> = crate::settings::load_settings()
2941 .ok()
2942 .and_then(|s| s.scoped_models)
2943 .unwrap_or_default();
2944 *state.scoped_edit.lock().unwrap() = Some(seed);
2945
2946 let mut items: Vec<SelectItem> = Vec::new();
2947 for m in catalog {
2948 items.push(SelectItem::new(&m.id, &m.id));
2949 }
2950 let list = Arc::new(SelectList::new(items, 10));
2951
2952 let state_sel = state.clone();
2953 let chat_sel = chat.clone();
2954 let tui_sel = tui.clone();
2955 list.on_select(Arc::new(move |item| {
2956 let mut set = state_sel.scoped_edit.lock().unwrap();
2958 let set = set.get_or_insert_with(Vec::new);
2959 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
2960 set.remove(pos);
2961 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
2962 } else {
2963 set.push(item.value.clone());
2964 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
2965 }
2966 tui_sel.request_render(false);
2967 }));
2968 let state_cancel = state.clone();
2969 let ec_cancel = editor_container.clone();
2970 let editor_cancel = editor.clone();
2971 let tui_cancel = tui.clone();
2972 let chat_cancel = chat.clone();
2973 list.on_cancel(Arc::new(move || {
2974 let set = state_cancel
2976 .scoped_edit
2977 .lock()
2978 .unwrap()
2979 .take()
2980 .unwrap_or_default();
2981 let mut settings = crate::settings::load_settings().unwrap_or_default();
2982 settings.scoped_models = if set.is_empty() {
2983 None
2984 } else {
2985 Some(set.clone())
2986 };
2987 match crate::settings::save_settings(&settings) {
2988 Ok(()) => {
2989 if set.is_empty() {
2990 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
2991 } else {
2992 add_note_message(
2993 &chat_cancel,
2994 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
2995 );
2996 }
2997 }
2998 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
2999 }
3000 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3001 }));
3002
3003 open_selector(
3004 state,
3005 editor_container,
3006 editor,
3007 tui,
3008 list,
3009 SelectorKind::ScopedModels,
3010 );
3011}
3012
3013async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3017 use std::process::Stdio;
3018
3019 let tree = harness.session().view("main");
3021 let entries = match tree
3022 .find_entries(&EntryQuery {
3023 entry_type: None,
3024 custom_type: None,
3025 order: Some(EntryOrder::OldestFirst),
3028 limit: None,
3029 cursor: None,
3030 })
3031 .await
3032 {
3033 Ok(e) => e,
3034 Err(e) => {
3035 add_error_message(chat, &format!("Could not read session: {e}"));
3036 return;
3037 }
3038 };
3039 let mut md = String::from("# Session\n\n");
3040 for e in entries {
3041 let Entry::Message(me) = e else { continue };
3042 match &me.message {
3043 AgentMessage::User(u) => {
3044 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3045 }
3046 AgentMessage::Assistant(a) => {
3047 let text = assistant_text(a);
3048 if !text.is_empty() {
3049 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3050 }
3051 }
3052 _ => {}
3053 }
3054 }
3055
3056 let gh = std::process::Command::new("gh")
3058 .arg("gist")
3059 .arg("create")
3060 .arg("--filename")
3061 .arg("session.md")
3062 .arg("-")
3063 .stdin(Stdio::piped())
3064 .stdout(Stdio::piped())
3065 .stderr(Stdio::null())
3066 .spawn();
3067 if let Ok(mut child) = gh {
3068 use std::io::Write;
3069 if let Some(mut stdin) = child.stdin.take() {
3070 let _ = stdin.write_all(md.as_bytes());
3071 let _ = stdin.flush();
3072 }
3073 let out = child.wait_with_output().ok();
3074 if let Some(out) = out {
3075 if out.status.success() {
3076 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3077 add_note_message(chat, &format!("Shared session: {url}"));
3078 return;
3079 }
3080 }
3081 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3082 } else {
3083 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3084 }
3085 if copy_to_clipboard(&md) {
3087 add_note_message(chat, "Session transcript copied to the clipboard.");
3088 } else {
3089 add_note_message(
3090 chat,
3091 "Clipboard unavailable — use /export to write the transcript to a file.",
3092 );
3093 }
3094}
3095
3096async fn export_session(harness: &AgentHarness, chat: &Arc<Container>) {
3105 let tree = harness.session().view("main");
3106 let entries = match tree
3107 .find_entries(&EntryQuery {
3108 entry_type: None,
3109 custom_type: None,
3110 order: Some(EntryOrder::OldestFirst),
3113 limit: None,
3114 cursor: None,
3115 })
3116 .await
3117 {
3118 Ok(e) => e,
3119 Err(e) => {
3120 add_error_message(chat, &format!("Could not read session: {e}"));
3121 return;
3122 }
3123 };
3124 let name = tree.get_name().await.ok().flatten().unwrap_or_default();
3125 let id = tree
3126 .get_leaf_id()
3127 .await
3128 .ok()
3129 .flatten()
3130 .unwrap_or_else(|| "session".to_string());
3131 let mut md = String::from("# Session\n\n");
3132 for e in entries {
3133 let Entry::Message(me) = e else { continue };
3134 match &me.message {
3135 AgentMessage::User(u) => {
3136 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3137 }
3138 AgentMessage::Assistant(a) => {
3139 let text = assistant_text(a);
3140 if !text.is_empty() {
3141 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3142 }
3143 }
3144 _ => {}
3145 }
3146 }
3147 let file_name = if name.is_empty() {
3148 format!("{id}.md")
3149 } else {
3150 format!("{name}.md")
3151 };
3152 let path = std::env::current_dir()
3153 .unwrap_or_else(|_| std::path::PathBuf::from("."))
3154 .join(&file_name);
3155 match std::fs::write(&path, md) {
3156 Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
3157 Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
3158 }
3159}
3160
3161async fn switch_to_session(
3171 harness: &AgentHarness,
3172 lane: &Arc<dyn AgentLane>,
3173 id: &str,
3174 cwd: &std::path::Path,
3175 chat: &Arc<Container>,
3176 state: &Arc<TuiState>,
3177) -> bool {
3178 if *state.status.lock().unwrap() == RunStatus::Working {
3179 state.set_status(RunStatus::Aborting);
3180 let _ = lane.abort().await;
3181 }
3182 let cwd_str = cwd.to_string_lossy().to_string();
3183 match crate::session::open_session_by_id(id, &cwd_str).await {
3184 Ok(new_session) => {
3185 let _ = harness.set_session(new_session).await;
3186 chat.clear();
3187 add_welcome_message(chat);
3188 render_session_history(
3189 harness,
3190 chat,
3191 state.markdown_transformer(),
3192 Some(state.extension_session.clone()),
3193 )
3194 .await;
3195 state.set_status(RunStatus::Idle);
3196 add_note_message(chat, &format!("Switched to session {id}."));
3197 true
3198 }
3199 Err(e) => {
3200 state.set_status(RunStatus::Idle);
3201 add_error_message(chat, &format!("Could not open session {id}: {e}"));
3202 false
3203 }
3204 }
3205}
3206
3207async fn import_session(
3211 harness: &AgentHarness,
3212 lane: &Arc<dyn AgentLane>,
3213 path: &str,
3214 cwd: &std::path::Path,
3215 chat: &Arc<Container>,
3216 state: &Arc<TuiState>,
3217) {
3218 use std::path::Path as FsPath;
3219
3220 let src = FsPath::new(path);
3221 if !src.is_file() {
3222 add_error_message(chat, &format!("Import source not found: {path}"));
3223 return;
3224 }
3225 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3226 add_error_message(chat, "Import source has no file name.");
3227 return;
3228 };
3229 if !fname.ends_with(".jsonl") {
3230 add_error_message(chat, "Import source must be a .jsonl session file.");
3231 return;
3232 }
3233 let dir = crate::session::default_session_dir(cwd);
3234 if let Err(e) = std::fs::create_dir_all(&dir) {
3235 add_error_message(chat, &format!("Could not create session dir: {e}"));
3236 return;
3237 }
3238 let dest = dir.join(fname);
3239 match std::fs::copy(src, &dest) {
3240 Ok(_) => {
3241 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3242 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3243 add_note_message(chat, &format!("Imported session from {path}"));
3244 }
3245 }
3246 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3247 }
3248}
3249
3250async fn fork_session(
3251 harness: &AgentHarness,
3252 cwd: &std::path::Path,
3253 chat: &Arc<Container>,
3254 state: &Arc<TuiState>,
3255) {
3256 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3257 use rpi_tools::FileSystem;
3258
3259 let cwd_str = cwd.to_string_lossy().to_string();
3260 let dir = crate::session::default_session_dir(cwd);
3261 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3262 let fs: Arc<dyn FileSystem> = env.clone();
3263 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3264 fs,
3265 sessions_root: dir.to_string_lossy().into_owned(),
3266 clock: Arc::new(rpi_harness::session::memory::SystemClock),
3267 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3268 });
3269 let id = harness.session().storage().metadata().id.clone();
3272 let metas = match crate::session::list_session_metadata(&cwd_str).await {
3273 Ok(m) => m,
3274 Err(e) => {
3275 add_error_message(chat, &format!("Could not list sessions: {e}"));
3276 return;
3277 }
3278 };
3279 let Some(source) = metas.iter().find(|m| m.id == id) else {
3280 add_error_message(chat, &format!("Current session {id} not found on disk."));
3281 return;
3282 };
3283 let fork_storage = match repo
3284 .fork_typed(
3285 source,
3286 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3287 id: None,
3288 parent_session_id: Some(source.id.clone()),
3289 cwd: cwd_str.clone(),
3290 metadata: None,
3291 },
3292 &rpi_harness::session::types::ForkOptions::default(),
3293 )
3294 .await
3295 {
3296 Ok(s) => s,
3297 Err(e) => {
3298 add_error_message(chat, &format!("Could not fork session: {e}"));
3299 return;
3300 }
3301 };
3302 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3303 let _ = harness.set_session(new_session).await;
3304 chat.clear();
3305 add_welcome_message(chat);
3306 render_session_history(
3307 harness,
3308 chat,
3309 state.markdown_transformer(),
3310 Some(state.extension_session.clone()),
3311 )
3312 .await;
3313 state.set_status(RunStatus::Idle);
3314 add_note_message(chat, "Forked into a new session.");
3315}
3316
3317async fn render_session_history(
3327 harness: &AgentHarness,
3328 chat: &Arc<Container>,
3329 transformer: Option<MarkdownTransformer>,
3330 extension_session: Option<crate::session::ExtensionSessionCell>,
3331) {
3332 let tree = harness.session().view("main");
3333 let entries = match tree
3334 .find_entries(&EntryQuery {
3335 entry_type: None,
3336 custom_type: None,
3337 order: Some(EntryOrder::OldestFirst),
3341 limit: None,
3342 cursor: None,
3343 })
3344 .await
3345 {
3346 Ok(e) => e,
3347 Err(_) => return,
3348 };
3349 let mut rendered_any = false;
3350 for e in entries {
3351 match e {
3352 Entry::Message(me) => match &me.message {
3353 AgentMessage::User(u) => {
3354 add_user_message(chat, &user_message_text(u));
3355 rendered_any = true;
3356 }
3357 AgentMessage::Assistant(a) => {
3358 let comp = Arc::new(AssistantMessageComponent::new(
3359 AssistantMessageOptions::default(),
3360 ));
3361 if let Some(t) = &transformer {
3362 comp.set_markdown_transformer(Some(t.clone()));
3363 }
3364 comp.update_blocks(&assistant_blocks(a));
3365 chat.add_child(comp);
3366 chat.add_child(Arc::new(Spacer::new(1)));
3369 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3370 add_note_message(chat, &text);
3371 }
3372 rendered_any = true;
3373 }
3374 AgentMessage::Custom(custom) => {
3375 if let Some(session) = &extension_session {
3376 if let Some(component) = extension_message_component(
3377 session,
3378 &custom.role,
3379 &serde_json::json!({
3380 "customType": custom.role,
3381 "content": custom.content,
3382 "details": custom.data,
3383 }),
3384 transformer.clone(),
3385 ) {
3386 chat.add_child(component);
3387 chat.add_child(Arc::new(Spacer::new(1)));
3388 rendered_any = true;
3389 continue;
3390 }
3391 }
3392 add_note_message(chat, &custom_message_fallback(&custom));
3393 rendered_any = true;
3394 }
3395 _ => {}
3396 },
3397 Entry::Compaction(compaction) => {
3398 add_note_message(
3399 chat,
3400 &format!(
3401 "Compacted {} tokens: {}",
3402 compaction.tokens_before, compaction.summary
3403 ),
3404 );
3405 rendered_any = true;
3406 }
3407 Entry::BranchSummary(summary) => {
3408 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3409 rendered_any = true;
3410 }
3411 Entry::Custom(custom) => {
3412 let rendered = extension_session.as_ref().and_then(|session| {
3413 extension_entry_component(session, &custom.custom_type, custom.data.clone())
3414 });
3415 if let Some(component) = rendered {
3416 chat.add_child(component);
3417 chat.add_child(Arc::new(Spacer::new(1)));
3418 rendered_any = true;
3419 } else if let Some(text) =
3420 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3421 {
3422 add_note_message(chat, &text);
3423 rendered_any = true;
3424 }
3425 }
3426 Entry::ModelChange(change) => {
3427 add_note_message(
3428 chat,
3429 &format!("Model changed to {}:{}", change.provider, change.model_id),
3430 );
3431 rendered_any = true;
3432 }
3433 Entry::ThinkingLevel(change) => {
3434 add_note_message(
3435 chat,
3436 &format!("Thinking level: {:?}", change.thinking_level),
3437 );
3438 rendered_any = true;
3439 }
3440 Entry::ActiveTools(change) => {
3441 add_note_message(
3442 chat,
3443 &format!("Active tools: {}", change.active_tool_names.join(", ")),
3444 );
3445 rendered_any = true;
3446 }
3447 }
3448 }
3449 if rendered_any {
3450 }
3453}
3454
3455fn invoke_extension_renderer(
3456 session: &crate::session::ExtensionSessionCell,
3457 kind: rpi_extensions::RegisteredRendererKind,
3458 payload: &serde_json::Value,
3459) -> Option<serde_json::Value> {
3460 let snapshot = session.lock().ok()?.snapshot_arc()?;
3461 let input = serde_json::to_string(payload).ok()?;
3462 for renderer in snapshot.renderers_of(kind) {
3463 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3464 let mut out = rpi_plugin_sdk::StbString::empty();
3465 let rc = (renderer.render_fn)(
3466 rpi_plugin_sdk::StbStringRef::from_str(&input),
3467 &mut out as *mut rpi_plugin_sdk::StbString,
3468 renderer.user_data,
3469 );
3470 let text = if rc == 0 {
3471 Some(out.to_string_lossy())
3472 } else {
3473 None
3474 };
3475 out.free_with(Some(renderer.plugin_free_string));
3476 text
3477 }))
3478 .ok()
3479 .flatten();
3480 let Some(text) = outcome else { continue };
3481 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3482 return Some(value);
3483 }
3484 }
3485 None
3486}
3487
3488fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3489 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3490 let text = lines
3491 .iter()
3492 .filter_map(|line| line.as_str())
3493 .collect::<Vec<_>>()
3494 .join("\n");
3495 return Some(Arc::new(Text::new(text, 0, 0)));
3496 }
3497 let text = value.get("text").and_then(|v| v.as_str())?;
3498 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3499 let component = Arc::new(AssistantMessageComponent::new(
3500 AssistantMessageOptions::default(),
3501 ));
3502 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3503 Some(component)
3504 } else {
3505 Some(Arc::new(Text::new(text, 0, 0)))
3506 }
3507}
3508
3509fn extension_message_component(
3510 session: &crate::session::ExtensionSessionCell,
3511 custom_type: &str,
3512 payload: &serde_json::Value,
3513 transformer: Option<MarkdownTransformer>,
3514) -> Option<Arc<dyn rpi_tui::Component>> {
3515 let value = invoke_extension_renderer(
3516 session,
3517 rpi_extensions::RegisteredRendererKind::Message,
3518 payload,
3519 )?;
3520 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3521 let text = value.get("text").and_then(|v| v.as_str())?;
3522 let component = Arc::new(AssistantMessageComponent::new(
3523 AssistantMessageOptions::default(),
3524 ));
3525 if let Some(transformer) = transformer {
3526 component.set_markdown_transformer(Some(transformer));
3527 }
3528 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3529 return Some(component);
3530 }
3531 extension_text_component(&value)
3532 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
3533}
3534
3535fn extension_usage_text(
3538 session: Option<&crate::session::ExtensionSessionCell>,
3539 usage: &rpi_ai::types::Usage,
3540) -> Option<String> {
3541 let session = session?;
3542 let payload = serde_json::json!({
3543 "customType": "token-usage",
3544 "usage": usage,
3545 });
3546 let value = invoke_extension_renderer(
3547 session,
3548 rpi_extensions::RegisteredRendererKind::Message,
3549 &payload,
3550 )?;
3551 value
3552 .get("text")
3553 .and_then(|value| value.as_str())
3554 .filter(|text| !text.trim().is_empty())
3555 .map(ToOwned::to_owned)
3556}
3557
3558fn extension_entry_component(
3559 session: &crate::session::ExtensionSessionCell,
3560 custom_type: &str,
3561 data: Option<serde_json::Value>,
3562) -> Option<Arc<dyn rpi_tui::Component>> {
3563 let payload = serde_json::json!({
3564 "customType": custom_type,
3565 "data": data,
3566 });
3567 let value = invoke_extension_renderer(
3568 session,
3569 rpi_extensions::RegisteredRendererKind::Entry,
3570 &payload,
3571 )?;
3572 extension_text_component(&value)
3573}
3574
3575fn launch_restores_history(args: &Args) -> bool {
3581 args.continue_session
3582 || args.resume
3583 || args.session.is_some()
3584 || args.session_id.is_some()
3585 || args.fork.is_some()
3586}
3587
3588fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
3589 msg.content
3590 .iter()
3591 .filter_map(|c| match c {
3592 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
3593 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
3594 Content::Image(image) => base64::engine::general_purpose::STANDARD
3595 .decode(&image.data)
3596 .ok()
3597 .filter(|data| !data.is_empty())
3598 .map(AssistantBlock::Image),
3599 _ => None,
3600 })
3601 .collect()
3602}
3603
3604fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
3605 let content = custom
3606 .content
3607 .iter()
3608 .filter_map(|item| match item {
3609 Content::Text(text) => Some(text.text.as_str()),
3610 _ => None,
3611 })
3612 .collect::<Vec<_>>()
3613 .join("\n");
3614 if content.is_empty() {
3615 format!("{}: {}", custom.role, custom.data)
3616 } else {
3617 format!("{}: {}", custom.role, content)
3618 }
3619}
3620
3621fn short_model_name(id: &str) -> String {
3624 id.rsplit([':', '/'])
3625 .next()
3626 .filter(|s| !s.is_empty())
3627 .unwrap_or(id)
3628 .to_string()
3629}
3630
3631#[derive(Clone, Copy, PartialEq, Eq)]
3637enum RunStatus {
3638 Idle,
3639 Working,
3640 Aborting,
3641}
3642
3643#[derive(Clone, Copy, PartialEq, Eq)]
3645enum SelectorKind {
3646 Model,
3648 Thinking,
3650 Tools,
3652 Images,
3654 Session,
3656 Theme,
3658 ScopedModels,
3660 Settings,
3662 Tree,
3664 Extension,
3666}
3667
3668struct TuiState {
3671 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
3673 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
3675 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
3679 themes_enabled: bool,
3681 hide_thinking: std::sync::Mutex<bool>,
3683 tool_outputs_expanded: std::sync::Mutex<bool>,
3685 show_terminal_progress: bool,
3687 status: std::sync::Mutex<RunStatus>,
3689 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
3693 footer: Arc<FooterComponent>,
3695 status_container: Arc<Container>,
3698 chat_container: Arc<Container>,
3700 loader: Arc<Loader>,
3702 last_assistant_text: std::sync::Mutex<String>,
3705 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
3709 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
3711 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
3713 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
3715 autocomplete: AutocompleteManager,
3718 autocomplete_container: Arc<Container>,
3721 autocomplete_max_visible: usize,
3723 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
3725 theme_manager: Arc<ThemeManager>,
3729 tui: Option<Arc<TuiAltScreen>>,
3733 current_model_id: std::sync::Mutex<String>,
3737 show_images: std::sync::Mutex<bool>,
3741 history: std::sync::Mutex<Vec<String>>,
3744 history_index: std::sync::Mutex<isize>,
3747 history_draft: std::sync::Mutex<Option<String>>,
3750 last_input_tokens: std::sync::Mutex<i64>,
3754 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
3757 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
3767 extension_session: crate::session::ExtensionSessionCell,
3769}
3770
3771const HISTORY_LIMIT: usize = 100;
3774
3775const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
3778
3779const PAGE_SCROLL_OVERLAP: usize = 4;
3782
3783const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
3787
3788fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
3791 let mut modifiers = KeyModifiers::NONE;
3792 let mut key = None;
3793 for part in value.trim().to_ascii_lowercase().split('+') {
3794 match part {
3795 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
3796 "shift" => modifiers |= KeyModifiers::SHIFT,
3797 "alt" | "option" => modifiers |= KeyModifiers::ALT,
3798 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
3799 part if !part.is_empty() => key = Some(part.to_string()),
3800 _ => {}
3801 }
3802 }
3803 let key = key?;
3804 let code = match key.as_str() {
3805 "esc" | "escape" => KeyCode::Esc,
3806 "enter" | "return" => KeyCode::Enter,
3807 "tab" => {
3808 if modifiers.contains(KeyModifiers::SHIFT) {
3809 return Some(rpi_tui::KeyCombo::new(
3810 KeyCode::BackTab,
3811 modifiers & !KeyModifiers::SHIFT,
3812 ));
3813 }
3814 KeyCode::Tab
3815 }
3816 "backspace" | "back" => KeyCode::Backspace,
3817 "delete" | "del" => KeyCode::Delete,
3818 "up" | "arrowup" => KeyCode::Up,
3819 "down" | "arrowdown" => KeyCode::Down,
3820 "left" | "arrowleft" => KeyCode::Left,
3821 "right" | "arrowright" => KeyCode::Right,
3822 "home" => KeyCode::Home,
3823 "end" => KeyCode::End,
3824 "pageup" | "page-up" => KeyCode::PageUp,
3825 "pagedown" | "page-down" => KeyCode::PageDown,
3826 "space" => KeyCode::Char(' '),
3827 "f1" => KeyCode::F(1),
3828 "f2" => KeyCode::F(2),
3829 "f3" => KeyCode::F(3),
3830 "f4" => KeyCode::F(4),
3831 "f5" => KeyCode::F(5),
3832 "f6" => KeyCode::F(6),
3833 "f7" => KeyCode::F(7),
3834 "f8" => KeyCode::F(8),
3835 "f9" => KeyCode::F(9),
3836 "f10" => KeyCode::F(10),
3837 "f11" => KeyCode::F(11),
3838 "f12" => KeyCode::F(12),
3839 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
3840 _ => return None,
3841 };
3842 Some(rpi_tui::KeyCombo::new(code, modifiers))
3843}
3844
3845fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
3846 let mut bindings = rpi_tui::Keybindings::new();
3847 let settings = crate::settings::load_settings().unwrap_or_default();
3848 let Some(overrides) = settings.keybindings else {
3849 rpi_tui::set_keybindings(bindings.clone());
3850 return Arc::new(bindings);
3851 };
3852 let known: &[(&str, rpi_tui::KeybindingId)] = &[
3853 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
3854 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
3855 ("app.exit", rpi_tui::keybindings::keys::EXIT),
3856 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
3857 (
3858 "app.model.cycleForward",
3859 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
3860 ),
3861 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
3862 (
3863 "app.thinking.toggle",
3864 rpi_tui::keybindings::keys::THINKING_TOGGLE,
3865 ),
3866 (
3867 "app.editor.external",
3868 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
3869 ),
3870 (
3871 "app.thinking.cycle",
3872 rpi_tui::keybindings::keys::THINKING_CYCLE,
3873 ),
3874 (
3875 "app.clipboard.pasteImage",
3876 rpi_tui::keybindings::keys::PASTE_IMAGE,
3877 ),
3878 ];
3879 for (name, id) in known {
3880 let Some(value) = overrides.get(*name) else {
3881 continue;
3882 };
3883 let values: Vec<String> = match value {
3884 serde_json::Value::String(value) => vec![value.clone()],
3885 serde_json::Value::Array(values) => values
3886 .iter()
3887 .filter_map(|v| v.as_str().map(str::to_string))
3888 .collect(),
3889 serde_json::Value::Null => Vec::new(),
3890 _ => continue,
3891 };
3892 let combos: Vec<_> = values
3893 .iter()
3894 .filter_map(|value| parse_configured_key(value))
3895 .collect();
3896 if values.is_empty() || !combos.is_empty() {
3897 bindings.set(id, combos);
3898 }
3899 }
3900 rpi_tui::set_keybindings(bindings.clone());
3901 Arc::new(bindings)
3902}
3903
3904fn keybinding_matches(
3905 bindings: &rpi_tui::Keybindings,
3906 event: &crossterm::event::KeyEvent,
3907 id: rpi_tui::KeybindingId,
3908) -> bool {
3909 if bindings.matches(event, id) {
3910 return true;
3911 }
3912 if event.code == KeyCode::BackTab {
3915 let normalized =
3916 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
3917 bindings.matches(&normalized, id)
3918 } else {
3919 false
3920 }
3921}
3922
3923fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
3924 last.is_some_and(|previous| {
3925 now.duration_since(previous) <= std::time::Duration::from_millis(500)
3926 })
3927}
3928
3929fn transcript_page_size(viewport_height: usize) -> i32 {
3930 viewport_height
3931 .saturating_sub(PAGE_SCROLL_OVERLAP)
3932 .max(1)
3933 .min(i32::MAX as usize) as i32
3934}
3935
3936fn should_dispatch_key(kind: KeyEventKind) -> bool {
3937 kind != KeyEventKind::Release
3938}
3939
3940fn format_tokens(n: i64) -> String {
3942 if n >= 1_000_000 {
3943 format!("{:.1}M", n as f64 / 1_000_000.0)
3944 } else if n >= 1_000 {
3945 format!("{:.1}K", n as f64 / 1_000.0)
3946 } else {
3947 n.to_string()
3948 }
3949}
3950
3951fn push_history(state: &Arc<TuiState>, text: &str) {
3955 let trimmed = text.trim().to_string();
3956 if trimmed.is_empty() {
3957 return;
3958 }
3959 let mut history = state.history.lock().unwrap();
3960 if history.first() == Some(&trimmed) {
3961 return;
3962 }
3963 history.insert(0, trimmed);
3964 history.truncate(HISTORY_LIMIT);
3965 *state.history_index.lock().unwrap() = -1;
3966 *state.history_draft.lock().unwrap() = None;
3967}
3968
3969fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
3974 let history = state.history.lock().unwrap();
3975 if history.is_empty() {
3976 return;
3977 }
3978 let mut index = state.history_index.lock().unwrap();
3979 let new_index = *index - direction as isize;
3980 if new_index < -1 || new_index >= history.len() as isize {
3981 return;
3982 }
3983 if *index == -1 && new_index >= 0 {
3984 *state.history_draft.lock().unwrap() = Some(editor.get_text());
3986 }
3987 *index = new_index;
3988 if new_index == -1 {
3989 let draft = state.history_draft.lock().unwrap().take();
3991 match draft {
3992 Some(d) => {
3993 let len = d.len();
3994 editor.set_text(&d);
3995 editor.set_cursor(0, len);
3996 }
3997 None => editor.set_text(""),
3998 }
3999 } else {
4000 let text = history[new_index as usize].clone();
4001 let len = text.len();
4002 editor.set_text(&text);
4003 editor.set_cursor(0, len);
4004 }
4005}
4006
4007impl TuiState {
4008 fn begin_js_preparation(&self) -> CancellationToken {
4009 let cancellation = CancellationToken::new();
4010 if let Some(previous) = self
4011 .js_preparation_cancel
4012 .lock()
4013 .unwrap()
4014 .replace(cancellation.clone())
4015 {
4016 previous.cancel();
4017 }
4018 cancellation
4019 }
4020
4021 fn finish_js_preparation(&self) {
4022 self.js_preparation_cancel.lock().unwrap().take();
4023 }
4024
4025 fn cancel_js_preparation(&self) -> bool {
4026 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4027 if let Some(cancellation) = cancellation {
4028 cancellation.cancel();
4029 true
4030 } else {
4031 false
4032 }
4033 }
4034
4035 fn set_status(&self, status: RunStatus) {
4036 *self.status.lock().unwrap() = status;
4037 self.apply_status(status);
4038 }
4039
4040 fn try_start_working(&self) -> bool {
4045 let mut status = self.status.lock().unwrap();
4046 if *status != RunStatus::Idle {
4047 return false;
4048 }
4049 *status = RunStatus::Working;
4050 drop(status);
4051 self.apply_status(RunStatus::Working);
4052 true
4053 }
4054
4055 fn apply_status(&self, status: RunStatus) {
4056 match status {
4057 RunStatus::Working => {
4058 self.footer.set_status("Working…");
4059 if let Some(tui) = &self.tui {
4062 tui.set_title("rpi — working");
4063 }
4064 self.status_container.clear();
4065 if self.show_terminal_progress {
4066 self.loader.start();
4067 self.status_container.add_child(self.loader.clone());
4068 }
4069 }
4070 RunStatus::Aborting => {
4071 self.footer.set_status("Aborting…");
4072 self.loader.stop();
4075 self.status_container.clear();
4076 }
4077 RunStatus::Idle => {
4078 self.footer.set_status("");
4079 if let Some(tui) = &self.tui {
4080 tui.set_title("rpi");
4081 }
4082 self.loader.stop();
4083 self.status_container.clear();
4084 }
4085 }
4086 }
4087
4088 fn sync_working_loader_with_bash(&self) {
4092 if *self.status.lock().unwrap() != RunStatus::Working {
4093 return;
4094 }
4095
4096 self.status_container.clear();
4097 if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4098 self.status_container.add_child(self.loader.clone());
4099 }
4100 }
4101
4102 fn selector_open(&self) -> bool {
4104 self.active_selector.lock().unwrap().is_some()
4105 }
4106
4107 fn extension_editor_open(&self) -> bool {
4108 self.active_extension_editor.lock().unwrap().is_some()
4109 }
4110
4111 fn extension_input_open(&self) -> bool {
4112 self.active_extension_input.lock().unwrap().is_some()
4113 }
4114
4115 fn extension_dialog_open(&self) -> bool {
4116 self.extension_editor_open() || self.extension_input_open()
4117 }
4118
4119 fn set_hide_thinking(&self, hide: bool) {
4120 *self.hide_thinking.lock().unwrap() = hide;
4121 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4122 comp.set_hide_thinking(hide);
4123 }
4124 }
4125
4126 fn hide_thinking(&self) -> bool {
4127 *self.hide_thinking.lock().unwrap()
4128 }
4129
4130 fn toggle_thinking(&self) -> bool {
4131 let next = !self.hide_thinking();
4132 self.set_hide_thinking(next);
4133 next
4134 }
4135
4136 fn toggle_tool_outputs(&self) -> bool {
4137 let next = !*self.tool_outputs_expanded.lock().unwrap();
4138 *self.tool_outputs_expanded.lock().unwrap() = next;
4139 for comp in self.tool_components.lock().unwrap().values() {
4140 comp.set_expanded(next);
4141 }
4142 for comp in self.bash_components.lock().unwrap().values() {
4143 comp.set_expanded(next);
4144 }
4145 next
4146 }
4147
4148 fn current_model_id(&self) -> String {
4150 self.current_model_id.lock().unwrap().clone()
4151 }
4152
4153 fn set_current_model(&self, model: &rpi_ai::Model) {
4156 *self.current_model_id.lock().unwrap() = model.id.clone();
4157 self.footer.set_model(&short_model_name(&model.id));
4158 }
4159
4160 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4164 self.markdown_transformer.lock().unwrap().clone()
4165 }
4166
4167 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4176 *self.markdown_transformer.lock().unwrap() = transformer.clone();
4177 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4178 comp.set_markdown_transformer(transformer);
4179 }
4180 }
4181
4182 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4183 self.pending_images.lock().unwrap().push(image);
4184 }
4185
4186 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4187 std::mem::take(&mut *self.pending_images.lock().unwrap())
4188 }
4189}
4190
4191pub async fn interactive_tui(
4209 harness: &AgentHarness,
4210 event_rx: Option<broadcast::Receiver<AgentEvent>>,
4211 args: &Args,
4212 model_catalog: Vec<rpi_ai::Model>,
4213 initial: Option<String>,
4214 extra_messages: &[String],
4215 initial_images: Vec<rpi_ai::types::ImageContent>,
4216 theme: Option<&str>,
4217 no_themes: bool,
4218 reload_context: &crate::session::ReloadContext,
4219) -> i32 {
4220 let lane: Arc<dyn AgentLane> = harness.lane("main");
4221 let saved_settings = crate::settings::load_settings().unwrap_or_default();
4222 let editor_padding_x = saved_settings.editor_padding_x.unwrap_or(1).min(16);
4223 let autocomplete_max_visible = saved_settings
4224 .autocomplete_max_visible
4225 .unwrap_or(5)
4226 .clamp(1, 20);
4227 let hide_thinking = saved_settings.hide_thinking_block.unwrap_or(false);
4228 let show_terminal_progress = saved_settings.show_terminal_progress.unwrap_or(true);
4229
4230 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4234 let model_name = short_model_name(&lane_model_id);
4235
4236 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4240 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4241 let skill_names: Vec<String> = resources_snapshot
4242 .skills
4243 .as_deref()
4244 .unwrap_or(&[])
4245 .iter()
4246 .map(|skill| skill.name.clone())
4247 .collect();
4248
4249 let cwd = std::env::current_dir()
4251 .map(|p| p.to_path_buf())
4252 .unwrap_or_else(|_| std::path::PathBuf::from("."));
4253 let package_resources = Arc::new(crate::session::package_resources_for(args, &cwd));
4254
4255 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4257
4258 let theme_manager = Arc::new(ThemeManager::new());
4262 if let Some(preset) = match theme {
4263 Some("light") => Some(ThemePreset::Light),
4264 Some("monochrome") => Some(ThemePreset::Monochrome),
4265 Some("dark") => Some(ThemePreset::Dark),
4266 _ => None,
4267 } {
4268 apply_theme_preset(preset);
4269 theme_manager.apply_preset(preset);
4270 } else if !no_themes {
4271 if let Some(name) = theme {
4272 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4273 Ok(custom) => {
4274 rpi_tui::global_theme_manager().set(custom.clone());
4275 theme_manager.set(custom);
4276 }
4277 Err(error) => {
4278 eprintln!("warning: could not load package theme `{name}`: {error}");
4279 }
4280 }
4281 }
4282 }
4283
4284 let terminal = Box::new(ProcessTerminal::new());
4286 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4287 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4288 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4289
4290 if let Some(js) = &reload_context.js_extension_session {
4291 if let Err(error) = js.install_ui_runtime(tui.clone()) {
4292 eprintln!("warning: could not enable JS custom UI bridge: {error}");
4293 } else if let Some(js_active) = js.active_tools() {
4294 let js_names = js.tool_names();
4298 let mut active = lane.get_active_tools().await.unwrap_or_default();
4299 active.retain(|name| {
4300 crate::session::tool_name_allowed(name, args)
4301 && !js_names.iter().any(|js_name| js_name == name)
4302 });
4303 active.extend(js_active.into_iter().filter(|name| {
4304 js_names.iter().any(|js_name| js_name == name)
4305 && crate::session::tool_name_allowed(name, args)
4306 }));
4307 active = crate::session::filter_active_tool_names(active, args);
4308 let _ = lane.set_active_tools(active).await;
4309 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4310 }
4311 }
4312
4313 let chat_container = Arc::new(Container::new());
4314 if let Some(js) = &reload_context.js_extension_session {
4315 let chat_notify = chat_container.clone();
4319 let tui_notify = tui.clone();
4320 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
4321 if action != "ui.notify" {
4322 return Err(format!("unsupported capability: {action}"));
4323 }
4324 let message = args
4325 .get("message")
4326 .and_then(serde_json::Value::as_str)
4327 .unwrap_or_default();
4328 if !message.is_empty() {
4329 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
4330 add_error_message(&chat_notify, message);
4331 } else {
4332 add_note_message(&chat_notify, message);
4333 }
4334 tui_notify.request_render(false);
4335 }
4336 Ok(serde_json::json!(true))
4337 })) {
4338 if args.verbose {
4339 eprintln!("warning: could not enable JS notification bridge: {error}");
4340 }
4341 }
4342 }
4343 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
4344
4345 crate::extras::maybe_first_time_setup(&chat_container);
4351
4352 let initial_transformer = build_markdown_transformer(
4357 reload_context
4358 .extension_session
4359 .lock()
4360 .unwrap()
4361 .snapshot_arc(),
4362 );
4363 if launch_restores_history(args) {
4367 render_session_history(
4368 &harness,
4369 &chat_container,
4370 initial_transformer.clone(),
4371 Some(reload_context.extension_session.clone()),
4372 )
4373 .await;
4374 }
4375
4376 let document_container = Arc::new(Container::new());
4379 document_container.add_child(chat_container.clone());
4380
4381 let scroll_view = Arc::new(ScrollView::new(
4382 document_container.clone(),
4383 ScrollViewOptions {
4384 follow: FollowMode::End,
4385 primary: true,
4386 overscroll: OverscrollMode::Chain,
4387 scrollbar: ScrollbarMode::Hidden,
4391 ..Default::default()
4392 },
4393 ));
4394
4395 let keybindings = configured_keybindings();
4400 let editor = Arc::new(Editor::new(
4401 EditorOptions {
4402 padding_x: editor_padding_x,
4403 autocomplete_max_visible,
4404 ..Default::default()
4405 },
4406 EditorStyle::default(),
4407 keybindings.clone(),
4408 ));
4409
4410 let footer = Arc::new(FooterComponent::new());
4412 footer.set_model(&model_name);
4413 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");
4414
4415 let status_container = Arc::new(Container::new());
4416 let loader = Arc::new(Loader::with_text("Working…"));
4417
4418 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
4432 .prompt_templates
4433 .clone()
4434 .unwrap_or_default()
4435 .iter()
4436 .map(|t| SlashCommandEntry {
4437 name: format!("/{}", t.name),
4438 description: t
4439 .description
4440 .clone()
4441 .unwrap_or_else(|| "Expand prompt template".to_string()),
4442 })
4443 .collect();
4444 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
4445 Arc::new(resources_snapshot);
4446 let mut command_registry = build_builtin_registry();
4452 register_extension_commands(
4453 &mut command_registry,
4454 reload_context.extension_session.clone(),
4455 );
4456 register_js_extension_commands(
4457 &mut command_registry,
4458 reload_context.js_extension_session.clone(),
4459 );
4460 let registry = Arc::new(command_registry);
4461 let mut all_slash_commands = registry.visible_entries();
4462 all_slash_commands.extend(template_slash_commands);
4463 let autocomplete = AutocompleteManager::new();
4464 {
4465 let mut combined = CombinedAutocompleteProvider::new();
4466 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
4467 all_slash_commands,
4468 )));
4469 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
4470 cwd.clone(),
4471 )));
4472 autocomplete.set_provider(Arc::new(combined));
4473 }
4474 let autocomplete_container = Arc::new(Container::new());
4475
4476 let state = Arc::new(TuiState {
4477 current_assistant: std::sync::Mutex::new(None),
4478 tool_components: std::sync::Mutex::new(HashMap::new()),
4479 bash_components: std::sync::Mutex::new(HashMap::new()),
4480 themes_enabled: !no_themes,
4481 hide_thinking: std::sync::Mutex::new(hide_thinking),
4482 tool_outputs_expanded: std::sync::Mutex::new(false),
4483 show_terminal_progress,
4484 status: std::sync::Mutex::new(RunStatus::Idle),
4485 js_preparation_cancel: std::sync::Mutex::new(None),
4486 footer: footer.clone(),
4487 status_container: status_container.clone(),
4488 chat_container: chat_container.clone(),
4489 loader: loader.clone(),
4490 last_assistant_text: std::sync::Mutex::new(String::new()),
4491 active_selector: std::sync::Mutex::new(None),
4492 active_extension_editor: std::sync::Mutex::new(None),
4493 active_extension_input: std::sync::Mutex::new(None),
4494 active_extension_cancel: std::sync::Mutex::new(None),
4495 autocomplete,
4496 autocomplete_container: autocomplete_container.clone(),
4497 autocomplete_max_visible,
4498 pending_images: std::sync::Mutex::new(Vec::new()),
4499 theme_manager,
4500 tui: Some(tui.clone()),
4501 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
4502 show_images: std::sync::Mutex::new(true),
4503 history: std::sync::Mutex::new(Vec::new()),
4504 history_index: std::sync::Mutex::new(-1),
4505 history_draft: std::sync::Mutex::new(None),
4506 last_input_tokens: std::sync::Mutex::new(0),
4507 scoped_edit: std::sync::Mutex::new(None),
4508 markdown_transformer: std::sync::Mutex::new(initial_transformer),
4509 extension_session: reload_context.extension_session.clone(),
4510 });
4511
4512 let model_catalog_arc = Arc::new(model_catalog.clone());
4515 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4516
4517 let editor_container = Arc::new(Container::new());
4528 editor_container.add_child(editor.clone());
4529
4530 let dock = Arc::new(VStack::from_children(vec![
4531 StackChild::Entry(StackEntry::new(status_container.clone())),
4532 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
4533 StackChild::Entry(
4534 StackEntry::new(editor_container.clone())
4535 .shrink(0)
4536 .min_size(3),
4537 ),
4538 StackChild::Entry(StackEntry::new(footer.clone())),
4539 ]));
4540
4541 let root = VStack::from_children(vec![
4542 StackChild::Entry(
4543 StackEntry::new(scroll_view.clone())
4544 .basis(0)
4545 .grow(1)
4546 .shrink(1)
4547 .min_size(1),
4548 ),
4549 StackChild::Entry(StackEntry::new(dock).shrink(1)),
4550 ]);
4551
4552 tui.set_layout_root(Some(Arc::new(root)));
4553 tui.set_focus(Some(editor.clone()));
4554 editor.set_focused(true);
4555
4556 let ctx = CommandContext {
4568 chat: chat_container.clone(),
4569 tui: tui.clone(),
4570 tx: tx.clone(),
4571 state: state.clone(),
4572 editor: editor.clone(),
4573 editor_container: editor_container.clone(),
4574 lane: lane.clone(),
4575 model_catalog: model_catalog_arc.clone(),
4576 lane_model_id: lane_model_id.clone(),
4577 cwd: cwd.clone(),
4578 package_resources: package_resources.clone(),
4579 resources: resources_arc.clone(),
4580 reload_context: Arc::new(reload_context.clone()),
4581 };
4582
4583 if let Some(js) = &reload_context.js_extension_session {
4584 let bridge = js_dialog_bridge.clone();
4585 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
4586 bridge.handle_runtime_request(action, args)
4587 })) {
4588 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
4589 }
4590 }
4591
4592 let ctx_for_cb = ctx.clone();
4593 let registry_for_cb = registry.clone();
4594 editor.on_submit(Arc::new(move |text: &str| {
4595 let text = text.trim();
4596 if text.is_empty() {
4597 return;
4598 }
4599
4600 if text.starts_with('/') {
4601 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
4602 return;
4603 }
4604
4605 let run_status = *ctx_for_cb.state.status.lock().unwrap();
4606 if run_status != RunStatus::Idle {
4607 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
4608 let aborting = run_status == RunStatus::Aborting;
4609 let lane = ctx_for_cb.lane.clone();
4610 let chat = ctx_for_cb.chat.clone();
4611 let tui = ctx_for_cb.tui.clone();
4612 tokio::spawn(async move {
4613 let result = if aborting {
4618 lane.next_run(message).await
4619 } else {
4620 lane.steer(message).await
4621 };
4622 if let Err(error) = result {
4623 add_error_message(&chat, &format!("Could not queue message: {error}"));
4624 tui.request_render(false);
4625 }
4626 });
4627 add_note_message(
4628 &ctx_for_cb.chat,
4629 &format!("Queued steering message: {text}"),
4630 );
4631 ctx_for_cb.tui.request_render(false);
4632 return;
4633 }
4634
4635 if !ctx_for_cb.state.try_start_working() {
4636 return;
4637 }
4638
4639 add_user_message(&ctx_for_cb.chat, text);
4640 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
4643 scroll.scroll_to_end();
4644 }
4645 ctx_for_cb.tui.request_render(false);
4646 push_history(&ctx_for_cb.state, text);
4649 if ctx_for_cb
4650 .tx
4651 .send(TuiMessage::UserInput(text.to_string()))
4652 .is_err()
4653 {
4654 ctx_for_cb.state.set_status(RunStatus::Idle);
4655 }
4656 }));
4657
4658 tui.start_readerless();
4659
4660 let drain_handle = if let Some(rx) = event_rx {
4662 let tui_drain = tui.clone();
4663 let state_drain = state.clone();
4664 let chat_drain = chat_container.clone();
4665 Some(tokio::spawn(async move {
4666 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
4667 }))
4668 } else {
4669 None
4670 };
4671
4672 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
4682 reload_context.mailbox.install(reload_sig_tx);
4683 let reload_tx = tx.clone();
4684 let reload_bridge_handle = tokio::spawn(async move {
4685 while reload_sig_rx.recv().await.is_some() {
4686 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
4687 break; }
4689 }
4690 });
4691
4692 let tui_tick = tui.clone();
4697 let state_tick = state.clone();
4698 let tick_handle = tokio::spawn(async move {
4699 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
4702 interval.tick().await; loop {
4704 interval.tick().await;
4705 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
4706 if working {
4707 if state_tick.bash_components.lock().unwrap().is_empty() {
4708 tui_tick.request_render_reusing_scroll_content();
4712 } else {
4713 tui_tick.request_render(false);
4715 }
4716 }
4717 }
4718 });
4719
4720 let running = Arc::new(std::sync::Mutex::new(true));
4722 let running_key = running.clone();
4723 let tx_for_key = tx.clone();
4724 let tui_for_key = tui.clone();
4725 let editor_for_key = editor.clone();
4726 let scroll_for_key = scroll_view.clone();
4727 let lane_for_key = lane.clone();
4728 let state_for_key = state.clone();
4729 let model_catalog_for_key = model_catalog_arc.clone();
4730 let js_for_key = reload_context.js_extension_session.clone();
4731 let js_dialog_for_key = js_dialog_bridge.clone();
4732 let ctx_for_key = ctx.clone();
4736 let registry_for_key = registry.clone();
4737 let keybindings_for_key = keybindings.clone();
4738 let double_escape_action = crate::settings::load_settings()
4739 .ok()
4740 .and_then(|settings| settings.double_escape_action)
4741 .unwrap_or_else(|| "tree".to_string())
4742 .to_ascii_lowercase();
4743
4744 let key_handle = tokio::task::spawn_blocking(move || {
4745 let mut last_escape_time = None;
4746 loop {
4747 if !*running_key.lock().unwrap() {
4748 break;
4749 }
4750 if !state_for_key.selector_open()
4751 && !state_for_key.extension_dialog_open()
4752 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
4753 {
4754 if let Some(request) = js_dialog_for_key.take_pending() {
4755 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
4756 }
4757 }
4758 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
4759 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
4762 Ok(true) => {}
4763 Ok(false) => continue,
4764 Err(_) => {
4765 state_for_key.cancel_js_preparation();
4766 let _ = tx_for_key.send(TuiMessage::Exit);
4767 break;
4768 }
4769 }
4770 let Ok(ev) = crossterm::event::read() else {
4771 state_for_key.cancel_js_preparation();
4772 let _ = tx_for_key.send(TuiMessage::Exit);
4773 break;
4774 };
4775 if let Event::Resize(_cols, _rows) = ev {
4780 tui_for_key.refresh_size();
4781 if let Some(js) = &js_for_key {
4782 if js.custom_active() {
4783 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
4784 tui_for_key.request_render(false);
4785 }
4786 }
4787 continue;
4788 }
4789 if let Event::Mouse(m) = ev {
4793 use crossterm::event::MouseEventKind;
4794 match m.kind {
4795 MouseEventKind::ScrollUp => {
4796 let delta = -MOUSE_WHEEL_SCROLL_LINES;
4797 if scroll_for_key.scroll_by(delta) != delta {
4798 tui_for_key.request_render_reusing_scroll_content();
4799 }
4800 }
4801 MouseEventKind::ScrollDown => {
4802 let delta = MOUSE_WHEEL_SCROLL_LINES;
4803 if scroll_for_key.scroll_by(delta) != delta {
4804 tui_for_key.request_render_reusing_scroll_content();
4805 }
4806 }
4807 _ => {}
4808 }
4809 continue;
4810 }
4811 let Event::Key(key) = ev else {
4812 if let Event::Paste(text) = ev {
4813 let candidate = text.trim().trim_matches(['\"', '\'']);
4814 let path = std::path::PathBuf::from(candidate);
4815 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
4816 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
4817 add_image_preview(&state_for_key.chat_container, &image);
4818 state_for_key.queue_image(image);
4819 add_note_message(
4820 &state_for_key.chat_container,
4821 "Dropped image attached to the next prompt.",
4822 );
4823 tui_for_key.request_render(false);
4824 continue;
4825 }
4826 }
4827 editor_for_key.insert(&text);
4828 refresh_autocomplete(&state_for_key, &editor_for_key);
4829 tui_for_key.request_render_reusing_scroll_content();
4830 }
4831 continue;
4832 };
4833 if !should_dispatch_key(key.kind) {
4838 continue;
4839 }
4840
4841 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
4847 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
4848 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
4849 if prompt_abort && state_for_key.cancel_js_preparation() {
4850 state_for_key.set_status(RunStatus::Aborting);
4851 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
4852 close_extension_editor(
4853 &state_for_key,
4854 &ctx_for_key.editor_container,
4855 &editor_for_key,
4856 &tui_for_key,
4857 );
4858 }
4859 js_dialog_for_key.cancel_open_requests();
4860 tui_for_key.set_render_suspended(false);
4861 tui_for_key.request_render(false);
4862 continue;
4863 }
4864
4865 if let Some(js) = &js_for_key {
4866 if js.custom_active() {
4867 let visible = js.custom_accepts_input();
4868 let data = key_event_to_input(key);
4869 if !data.is_empty() {
4870 if visible {
4876 let _ = js.send_custom_input(&data);
4877 continue;
4878 }
4879 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
4880 if consumed {
4884 tui_for_key.request_render_reusing_scroll_content();
4885 continue;
4886 }
4887 }
4888 }
4889 }
4890
4891 if key.modifiers == KeyModifiers::CONTROL
4894 && key.code == KeyCode::Char('c')
4895 && state_for_key.selector_open()
4896 {
4897 let selector = state_for_key
4898 .active_selector
4899 .lock()
4900 .unwrap()
4901 .clone()
4902 .expect("selector_open guaranteed Some")
4903 .0;
4904 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
4905 tui_for_key.request_render_reusing_scroll_content();
4906 continue;
4907 }
4908
4909 if state_for_key.extension_dialog_open() {
4913 let cancel = key.code == KeyCode::Esc
4914 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
4915 if cancel {
4916 if !run_extension_cancel(&state_for_key) {
4917 close_extension_editor(
4918 &state_for_key,
4919 &ctx_for_key.editor_container,
4920 &editor_for_key,
4921 &tui_for_key,
4922 );
4923 }
4924 } else if let Some(extension_editor) = state_for_key
4925 .active_extension_editor
4926 .lock()
4927 .unwrap()
4928 .clone()
4929 {
4930 extension_editor.handle_key(key);
4931 } else if let Some(extension_input) =
4932 state_for_key.active_extension_input.lock().unwrap().clone()
4933 {
4934 extension_input.handle_key(key);
4935 }
4936 tui_for_key.request_render_reusing_scroll_content();
4937 continue;
4938 }
4939
4940 if keybinding_matches(
4945 &keybindings_for_key,
4946 &key,
4947 rpi_tui::keybindings::keys::CLEAR,
4948 ) {
4949 if !state_for_key.selector_open() && editor_for_key.has_selection() {
4950 editor_for_key.copy_selection();
4951 continue;
4952 }
4953 let status = *state_for_key.status.lock().unwrap();
4954 match status {
4955 RunStatus::Working => {
4956 state_for_key.set_status(RunStatus::Aborting);
4957 let lane = lane_for_key.clone();
4958 tokio::spawn(async move {
4959 let _ = lane.abort().await;
4960 });
4961 }
4962 RunStatus::Aborting => {}
4966 RunStatus::Idle => {
4967 let _ = tx_for_key.send(TuiMessage::Exit);
4968 }
4969 }
4970 continue;
4971 }
4972
4973 if state_for_key.selector_open() {
4978 if key.code == KeyCode::Esc {
4984 let (selector, _kind) = state_for_key
4985 .active_selector
4986 .lock()
4987 .unwrap()
4988 .clone()
4989 .expect("selector_open guaranteed Some");
4990 selector.handle_key(key);
4991 continue;
4992 }
4993 let (selector, _kind) = state_for_key
4994 .active_selector
4995 .lock()
4996 .unwrap()
4997 .clone()
4998 .expect("selector_open guaranteed Some");
4999 selector.handle_key(key);
5000 tui_for_key.request_render_reusing_scroll_content();
5001 continue;
5002 }
5003
5004 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5009 let status = *state_for_key.status.lock().unwrap();
5010 match status {
5011 RunStatus::Working => {
5012 state_for_key.set_status(RunStatus::Aborting);
5013 let lane = lane_for_key.clone();
5014 tokio::spawn(async move {
5015 let _ = lane.abort().await;
5016 });
5017 continue;
5018 }
5019 RunStatus::Aborting => continue,
5020 RunStatus::Idle => {}
5021 }
5022 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5023 editor_for_key.handle_key(key);
5025 refresh_autocomplete(&state_for_key, &editor_for_key);
5026 tui_for_key.request_render_reusing_scroll_content();
5027 continue;
5028 }
5029 let _ = tx_for_key.send(TuiMessage::Exit);
5030 continue;
5031 }
5032
5033 if keybinding_matches(
5038 &keybindings_for_key,
5039 &key,
5040 rpi_tui::keybindings::keys::INTERRUPT,
5041 ) {
5042 let status = *state_for_key.status.lock().unwrap();
5043 if status == RunStatus::Working {
5044 state_for_key.set_status(RunStatus::Aborting);
5045 let lane = lane_for_key.clone();
5046 tokio::spawn(async move {
5047 let _ = lane.abort().await;
5048 });
5049 continue;
5050 }
5051 if status == RunStatus::Idle
5052 && editor_for_key.get_text().trim().is_empty()
5053 && double_escape_action != "none"
5054 {
5055 let now = std::time::Instant::now();
5056 if double_escape_trigger(last_escape_time, now) {
5057 last_escape_time = None;
5058 match double_escape_action.as_str() {
5059 "tree" => {
5060 let _ = tx_for_key.send(TuiMessage::OpenTree);
5061 }
5062 "fork" => {
5063 let _ = tx_for_key.send(TuiMessage::ForkSession);
5064 }
5065 _ => {}
5066 }
5067 } else {
5068 last_escape_time = Some(now);
5069 }
5070 }
5071 continue;
5072 }
5073
5074 if keybinding_matches(
5077 &keybindings_for_key,
5078 &key,
5079 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5080 ) {
5081 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5082 continue;
5083 }
5084
5085 if keybinding_matches(
5088 &keybindings_for_key,
5089 &key,
5090 rpi_tui::keybindings::keys::TOOLS_EXPAND,
5091 ) {
5092 state_for_key.toggle_tool_outputs();
5093 tui_for_key.request_render(false);
5094 continue;
5095 }
5096
5097 if keybinding_matches(
5099 &keybindings_for_key,
5100 &key,
5101 rpi_tui::keybindings::keys::THINKING_TOGGLE,
5102 ) {
5103 state_for_key.toggle_thinking();
5104 tui_for_key.request_render(false);
5105 continue;
5106 }
5107
5108 if keybinding_matches(
5114 &keybindings_for_key,
5115 &key,
5116 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5117 ) {
5118 let current = state_for_key.current_model_id();
5119 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
5122 if let Some(next) = cycle_next_model(&scope, ¤t) {
5123 state_for_key.set_current_model(&next);
5124 let lane = lane_for_key.clone();
5125 tokio::spawn(async move {
5126 let _ = lane.set_model(next).await;
5127 });
5128 tui_for_key.request_render_reusing_scroll_content();
5129 }
5130 continue;
5131 }
5132
5133 if keybinding_matches(
5136 &keybindings_for_key,
5137 &key,
5138 rpi_tui::keybindings::keys::THINKING_CYCLE,
5139 ) {
5140 let lane = lane_for_key.clone();
5141 let catalog = model_catalog_for_key.clone();
5142 let state = state_for_key.clone();
5143 tokio::spawn(async move {
5144 let Ok(current_model) = lane.get_model().await else {
5145 return;
5146 };
5147 let levels = catalog
5148 .iter()
5149 .find(|model| {
5150 model.provider == current_model.provider && model.id == current_model.id
5151 })
5152 .map(|model| model.supported_thinking_levels())
5153 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5154 if levels.is_empty() {
5155 return;
5156 }
5157 let current = lane
5158 .get_thinking_level()
5159 .await
5160 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
5161 let next = levels
5162 .iter()
5163 .position(|level| *level == current)
5164 .map(|index| levels[(index + 1) % levels.len()])
5165 .unwrap_or(levels[0]);
5166 if lane.set_thinking_level(next).await.is_ok() {
5167 state
5168 .footer
5169 .set_thinking_level(Some(thinking_level_name(next)));
5170 state.tui.as_ref().map(|tui| tui.request_render(false));
5171 }
5172 });
5173 continue;
5174 }
5175
5176 if keybinding_matches(
5180 &keybindings_for_key,
5181 &key,
5182 rpi_tui::keybindings::keys::PASTE_IMAGE,
5183 ) {
5184 match read_clipboard_image() {
5185 Ok(Some(image)) => {
5186 add_image_preview(&state_for_key.chat_container, &image);
5187 state_for_key.queue_image(image);
5188 add_note_message(
5189 &state_for_key.chat_container,
5190 "Clipboard image attached to the next prompt.",
5191 );
5192 tui_for_key.request_render(false);
5193 continue;
5194 }
5195 Ok(None) | Err(_) => {}
5196 }
5197 }
5198
5199 if keybinding_matches(
5203 &keybindings_for_key,
5204 &key,
5205 rpi_tui::keybindings::keys::MODEL_SELECT,
5206 ) {
5207 if let Some(cmd) = registry_for_key.find("/model") {
5208 cmd.execute(&ctx_for_key, "");
5209 }
5210 continue;
5211 }
5212
5213 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
5215 if accept_top_suggestion(&state_for_key, &editor_for_key) {
5216 tui_for_key.request_render_reusing_scroll_content();
5217 }
5218 continue;
5219 }
5220
5221 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
5225 let delta = -transcript_page_size(scroll_for_key.viewport_height());
5226 if scroll_for_key.scroll_by(delta) != delta {
5227 tui_for_key.request_render_reusing_scroll_content();
5228 }
5229 continue;
5230 }
5231 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
5232 let delta = transcript_page_size(scroll_for_key.viewport_height());
5233 if scroll_for_key.scroll_by(delta) != delta {
5234 tui_for_key.request_render_reusing_scroll_content();
5235 }
5236 continue;
5237 }
5238 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
5239 scroll_for_key.scroll_to_start();
5240 tui_for_key.request_render_reusing_scroll_content();
5241 continue;
5242 }
5243 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
5244 scroll_for_key.scroll_to_end();
5245 tui_for_key.request_render_reusing_scroll_content();
5246 continue;
5247 }
5248
5249 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
5258 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5259 if editor_for_key.get_text().is_empty() || browsing {
5260 navigate_history(&state_for_key, &editor_for_key, -1);
5261 tui_for_key.request_render_reusing_scroll_content();
5262 continue;
5263 }
5264 }
5265 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
5266 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5267 if editor_for_key.get_text().is_empty() || browsing {
5268 navigate_history(&state_for_key, &editor_for_key, 1);
5269 tui_for_key.request_render_reusing_scroll_content();
5270 continue;
5271 }
5272 }
5273
5274 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
5278 let prompt = editor_for_key.get_text().trim().to_string();
5279 if prompt.is_empty() {
5280 continue;
5281 }
5282 editor_for_key.clear();
5283 let status = *state_for_key.status.lock().unwrap();
5284 if status == RunStatus::Idle {
5285 if state_for_key.try_start_working() {
5286 add_user_message(&state_for_key.chat_container, &prompt);
5287 push_history(&state_for_key, &prompt);
5288 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
5289 }
5290 } else {
5291 add_note_message(
5292 &state_for_key.chat_container,
5293 &format!("Queued follow-up message: {prompt}"),
5294 );
5295 let message = AgentMessage::User(UserMessage::new(prompt, 0));
5296 let lane = lane_for_key.clone();
5297 let chat = state_for_key.chat_container.clone();
5298 let tui = tui_for_key.clone();
5299 tokio::spawn(async move {
5300 if let Err(error) = lane.follow_up(message).await {
5301 add_error_message(&chat, &format!("Could not queue message: {error}"));
5302 tui.request_render(false);
5303 }
5304 });
5305 }
5306 tui_for_key.request_render(false);
5307 continue;
5308 }
5309
5310 editor_for_key.handle_key(key);
5312 refresh_autocomplete(&state_for_key, &editor_for_key);
5313 tui_for_key.request_render_reusing_scroll_content();
5314 }
5315 });
5316
5317 let mut prompts: Vec<String> = Vec::new();
5319 if let Some(init) = initial {
5320 prompts.push(init);
5321 }
5322 for m in extra_messages {
5323 prompts.push(m.clone());
5324 }
5325 let mut images = initial_images;
5326 for prompt in prompts {
5327 if !*running.lock().unwrap() {
5328 break;
5329 }
5330 add_user_message(&chat_container, &prompt);
5331 tui.request_render(false);
5332 run_prompt_streaming(
5333 &lane,
5334 &prompt,
5335 &tui,
5336 &state,
5337 drain_handle.is_some(),
5338 reload_context.js_extension_session.as_ref(),
5339 &js_dialog_bridge,
5340 args,
5341 std::mem::take(&mut images),
5342 )
5343 .await;
5344 }
5345
5346 loop {
5348 if !*running.lock().unwrap() {
5349 break;
5350 }
5351 match rx.recv().await {
5352 Some(TuiMessage::UserInput(prompt)) => {
5353 editor.clear();
5358 let prompt_images = state.take_pending_images();
5359 if !prompt_images.is_empty() {
5360 add_note_message(
5361 &chat_container,
5362 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
5363 );
5364 }
5365 run_prompt_streaming(
5366 &lane,
5367 &prompt,
5368 &tui,
5369 &state,
5370 drain_handle.is_some(),
5371 reload_context.js_extension_session.as_ref(),
5372 &js_dialog_bridge,
5373 args,
5374 prompt_images,
5375 )
5376 .await;
5377 }
5378 Some(TuiMessage::ExternalEditorResult(result)) => {
5379 match result {
5380 Ok(text) => {
5381 let cursor = text.chars().count();
5382 editor.set_text(&text);
5383 editor.set_cursor(0, cursor);
5384 add_note_message(&chat_container, "Draft updated from external editor.");
5385 }
5386 Err(error) => add_error_message(&chat_container, &error),
5387 }
5388 tui.request_render(false);
5389 }
5390 Some(TuiMessage::OpenTree) => {
5391 if *state.status.lock().unwrap() != RunStatus::Idle {
5392 add_note_message(
5393 &chat_container,
5394 "Wait for the current run to finish before opening the tree.",
5395 );
5396 tui.request_render(false);
5397 } else {
5398 open_tree_selector(
5399 &harness,
5400 &state,
5401 &editor_container,
5402 &editor,
5403 &tui,
5404 &chat_container,
5405 &tx,
5406 )
5407 .await;
5408 }
5409 }
5410 Some(TuiMessage::NavigateTree(entry_id)) => {
5411 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
5412 Ok(result) => match result.outcome {
5413 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
5414 chat_container.clear();
5415 add_welcome_message(&chat_container);
5416 render_session_history(
5417 &harness,
5418 &chat_container,
5419 state.markdown_transformer(),
5420 Some(state.extension_session.clone()),
5421 )
5422 .await;
5423 add_note_message(
5424 &chat_container,
5425 "Moved to the selected session entry.",
5426 );
5427 }
5428 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
5429 add_error_message(&chat_container, &error.message);
5430 }
5431 _ => add_note_message(
5432 &chat_container,
5433 "The selected entry could not be opened.",
5434 ),
5435 },
5436 Err(error) => add_error_message(
5437 &chat_container,
5438 &format!("Could not navigate session tree: {error}"),
5439 ),
5440 }
5441 tui.request_render(false);
5442 }
5443 Some(TuiMessage::ClearChat) => {
5444 chat_container.clear();
5445 add_welcome_message(&chat_container);
5446 tui.request_render(false);
5447 }
5448 Some(TuiMessage::Compact) => {
5449 run_compact(&lane, &tui, &state).await;
5450 }
5451 Some(TuiMessage::Copy) => {
5452 copy_last_assistant(&state, &chat_container);
5453 tui.request_render(false);
5454 }
5455 Some(TuiMessage::Exit) => {
5456 *running.lock().unwrap() = false;
5457 break;
5458 }
5459 Some(TuiMessage::SwitchSession(id)) => {
5460 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
5461 tui.request_render(false);
5462 }
5463 Some(TuiMessage::ImportSession(path)) => {
5464 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
5465 tui.request_render(false);
5466 }
5467 Some(TuiMessage::ShareSession) => {
5468 share_session(&harness, &chat_container).await;
5469 tui.request_render(false);
5470 }
5471 Some(TuiMessage::SetSessionName(name)) => {
5472 let outcome = harness.session().set_name(Some(&name)).await;
5473 match outcome {
5474 Ok(_) => add_note_message(
5475 &chat_container,
5476 &format!("Session renamed to \"{name}\"."),
5477 ),
5478 Err(e) => add_error_message(
5479 &chat_container,
5480 &format!("Could not rename session: {e}"),
5481 ),
5482 }
5483 tui.request_render(false);
5484 }
5485 Some(TuiMessage::ExportSession) => {
5486 export_session(&harness, &chat_container).await;
5487 tui.request_render(false);
5488 }
5489 Some(TuiMessage::ForkSession) => {
5490 fork_session(&harness, &cwd, &chat_container, &state).await;
5491 tui.request_render(false);
5492 }
5493 Some(TuiMessage::ReloadExtensions) => {
5494 let reload_ctx = ctx.reload_context.clone();
5501 add_note_message(&chat_container, "Reloading extensions + resources…");
5502 tui.request_render(false);
5503 let outcome =
5504 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
5505 let fresh_transformer = build_markdown_transformer(
5514 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
5515 );
5516 state.set_markdown_transformer_with_reinstall(fresh_transformer);
5517 if outcome.had_warnings {
5518 add_error_message(
5519 &chat_container,
5520 &format!(
5521 "{} (with warnings — see stderr for details).",
5522 outcome.summary
5523 ),
5524 );
5525 } else {
5526 add_note_message(&chat_container, &outcome.summary);
5527 }
5528 tui.request_render(false);
5529 }
5530 None => break,
5531 }
5532 }
5533
5534 js_dialog_bridge.cancel_all();
5538 *running.lock().unwrap() = false;
5539 if let Some(js) = &reload_context.js_extension_session {
5543 js.shutdown();
5544 }
5545 let _ = key_handle.await;
5548 tick_handle.abort();
5549 if let Some(handle) = drain_handle {
5550 handle.abort();
5551 }
5552 reload_context.mailbox.clear();
5556 reload_bridge_handle.abort();
5557 tui.stop(Default::default());
5558 println!("\nGoodbye!");
5559 let _ = args;
5560
5561 0
5562}
5563
5564async fn ensure_js_runtime_before_prompt(
5574 js: Option<&crate::js_extensions::JsExtensionSession>,
5575 lane: &Arc<dyn AgentLane>,
5576 state: &Arc<TuiState>,
5577 dialog_bridge: &JsDialogBridge,
5578 args: &Args,
5579) -> bool {
5580 let Some(js) = js else {
5581 return true;
5582 };
5583 let cancellation = state.begin_js_preparation();
5584 let worker_cancellation = cancellation.clone();
5585 let js_for_start = js.clone();
5586 let mut worker = tokio::task::spawn_blocking(move || {
5587 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
5588 });
5589 let result = tokio::select! {
5590 result = &mut worker => result,
5591 _ = cancellation.cancelled() => {
5592 dialog_bridge.cancel_open_requests();
5593 let js_for_cancel = js.clone();
5594 let _ = tokio::task::spawn_blocking(move || {
5595 js_for_cancel.cancel_prompt_preparation();
5596 }).await;
5597 worker.await
5598 }
5599 };
5600 let was_cancelled = cancellation.is_cancelled();
5601 if was_cancelled {
5602 dialog_bridge.cancel_open_requests();
5603 let js_for_cancel = js.clone();
5604 let _ = tokio::task::spawn_blocking(move || {
5605 js_for_cancel.cancel_prompt_preparation();
5606 })
5607 .await;
5608 state.finish_js_preparation();
5609 dialog_bridge.reopen();
5610 return false;
5611 }
5612 state.finish_js_preparation();
5613 match result {
5614 Ok(Ok(())) => {
5615 if let Some(js_active) = js.active_tools() {
5619 let js_names = js.tool_names();
5620 let mut active = lane.get_active_tools().await.unwrap_or_default();
5621 active.retain(|name| {
5622 crate::session::tool_name_allowed(name, args)
5623 && !js_names.iter().any(|js_name| js_name == name)
5624 });
5625 active.extend(js_active.into_iter().filter(|name| {
5626 js_names.iter().any(|js_name| js_name == name)
5627 && crate::session::tool_name_allowed(name, args)
5628 }));
5629 active = crate::session::filter_active_tool_names(active, args);
5630 let _ = lane.set_active_tools(active).await;
5631 }
5632 }
5633 Ok(Err(error)) => {
5634 if args.verbose {
5635 eprintln!("warning: could not start JS extension runtime: {error}");
5636 }
5637 }
5638 Err(error) => {
5639 if args.verbose {
5640 eprintln!("warning: JS extension runtime worker failed: {error}");
5641 }
5642 }
5643 }
5644 true
5645}
5646
5647fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
5648 std::thread::spawn(move || {
5649 let file = match tempfile::Builder::new()
5650 .prefix("rpi-draft-")
5651 .suffix(".md")
5652 .tempfile()
5653 {
5654 Ok(file) => file,
5655 Err(error) => {
5656 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
5657 "Could not create editor file: {error}"
5658 ))));
5659 return;
5660 }
5661 };
5662 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
5663 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
5664 "Could not write editor file: {error}"
5665 ))));
5666 return;
5667 }
5668 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
5669 .ok()
5670 .filter(|value| !value.trim().is_empty())
5671 .or_else(|| std::env::var("VISUAL").ok())
5672 .or_else(|| std::env::var("EDITOR").ok())
5673 .unwrap_or_else(|| {
5674 if cfg!(windows) {
5675 "notepad".to_string()
5676 } else {
5677 "nano".to_string()
5678 }
5679 });
5680 let status = std::process::Command::new(&editor)
5681 .arg(file.path())
5682 .status();
5683 let result = match status {
5684 Ok(status) if status.success() => std::fs::read_to_string(file.path())
5685 .map_err(|error| format!("Could not read editor file: {error}")),
5686 Ok(status) => Err(format!("External editor exited with {status}")),
5687 Err(error) => Err(format!(
5688 "Could not launch external editor `{editor}`: {error}"
5689 )),
5690 };
5691 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
5692 });
5693}
5694
5695async fn run_prompt_streaming(
5700 lane: &Arc<dyn AgentLane>,
5701 prompt: &str,
5702 tui: &Arc<TuiAltScreen>,
5703 state: &Arc<TuiState>,
5704 streaming: bool,
5705 js: Option<&crate::js_extensions::JsExtensionSession>,
5706 dialog_bridge: &JsDialogBridge,
5707 args: &Args,
5708 images: Vec<rpi_ai::types::ImageContent>,
5709) {
5710 state.set_status(RunStatus::Working);
5719 tui.request_render(false);
5720 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
5721 state.set_status(RunStatus::Idle);
5722 tui.request_render(false);
5723 return;
5724 }
5725
5726 let outcome = lane.prompt_text(prompt, images).await;
5727
5728 {
5732 let mut cur = state.current_assistant.lock().unwrap();
5733 if let Some(comp) = cur.take() {
5734 comp.set_streaming(false);
5735 }
5736 }
5737
5738 state.set_status(RunStatus::Idle);
5739
5740 match outcome {
5741 Ok(result) => match &result.outcome {
5742 HarnessRunOutcome::Failed {
5743 error,
5744 final_message,
5745 ..
5746 } => {
5747 let already_rendered = final_message.is_some();
5751 if !already_rendered {
5752 let msg = final_message
5753 .as_ref()
5754 .and_then(|m| m.error_message.clone())
5755 .unwrap_or_else(|| format!("{error:?}"));
5756 add_error_message(&state.chat_container, &msg);
5757 }
5758 }
5759 HarnessRunOutcome::Suspended { .. } => {
5760 add_error_message(
5761 &state.chat_container,
5762 "Run suspended (deferred) — resume is not supported in v1.",
5763 );
5764 }
5765 HarnessRunOutcome::Aborted { final_message, .. } => {
5766 if !streaming {
5769 add_error_message(&state.chat_container, "Request aborted.");
5770 let _ = final_message; }
5772 }
5773 HarnessRunOutcome::Completed { final_message, .. } => {
5774 if !streaming {
5775 let text = assistant_text(final_message);
5776 if !text.is_empty() {
5777 add_assistant_message_blocking(
5778 &state.chat_container,
5779 &text,
5780 state.markdown_transformer(),
5781 );
5782 *state.last_assistant_text.lock().unwrap() = text;
5783 }
5784 }
5785 }
5786 },
5787 Err(e) => {
5788 add_error_message(&state.chat_container, &e.to_string());
5789 }
5790 }
5791
5792 tui.request_render(false);
5793}
5794
5795async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
5800 state.set_status(RunStatus::Working);
5801 tui.request_render(false);
5802 match lane.compact(None).await {
5803 Ok(_) => {
5804 add_note_message(&state.chat_container, "Conversation compacted.");
5805 }
5806 Err(e) => {
5807 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
5808 }
5809 }
5810 state.set_status(RunStatus::Idle);
5811 tui.request_render(false);
5812}
5813
5814fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
5818 let text = state.last_assistant_text.lock().unwrap().clone();
5819 if text.is_empty() {
5820 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
5821 return;
5822 }
5823 if copy_to_clipboard(&text) {
5824 add_note_message(chat, "Copied last reply to the clipboard.");
5825 } else {
5826 let preview: String = text.chars().take(200).collect();
5829 add_note_message(
5830 chat,
5831 &format!(
5832 "Clipboard unavailable. Last reply: {preview}{}",
5833 if text.chars().count() > 200 {
5834 "…"
5835 } else {
5836 ""
5837 }
5838 ),
5839 );
5840 }
5841}
5842
5843#[cfg(feature = "clipboard")]
5846fn copy_to_clipboard(text: &str) -> bool {
5847 match arboard::Clipboard::new() {
5848 Ok(mut cb) => cb.set_text(text).is_ok(),
5849 Err(_) => false,
5850 }
5851}
5852
5853#[cfg(not(feature = "clipboard"))]
5854fn copy_to_clipboard(_text: &str) -> bool {
5855 false
5856}
5857
5858#[cfg(feature = "clipboard")]
5862fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
5863 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
5864 let image = match clipboard.get_image() {
5865 Ok(image) => image,
5866 Err(_) => return Ok(None),
5867 };
5868 let width =
5869 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
5870 let height =
5871 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
5872 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
5873 return Err("clipboard image dimensions are outside the supported range".into());
5874 }
5875 let mut bytes = Vec::new();
5876 {
5877 let mut encoder = png::Encoder::new(&mut bytes, width, height);
5878 encoder.set_color(png::ColorType::Rgba);
5879 encoder.set_depth(png::BitDepth::Eight);
5880 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
5881 writer
5882 .write_image_data(&image.bytes)
5883 .map_err(|e| e.to_string())?;
5884 }
5885 Ok(Some(rpi_ai::types::ImageContent {
5886 kind: rpi_ai::types::ImageContentType,
5887 data: base64::engine::general_purpose::STANDARD.encode(bytes),
5888 mime_type: "image/png".into(),
5889 }))
5890}
5891
5892fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
5893 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
5894 let mut options = ImageOptions::default();
5895 options.width = Some(48);
5896 options.alt_text = Some("Attached image".into());
5897 chat.add_child(Arc::new(Image::from_data(bytes, options)));
5898 chat.add_child(Arc::new(Spacer::new(1)));
5899 }
5900}
5901
5902#[cfg(not(feature = "clipboard"))]
5903fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
5904 Ok(None)
5905}
5906
5907fn add_assistant_message_blocking(
5914 container: &Arc<Container>,
5915 text: &str,
5916 transformer: Option<MarkdownTransformer>,
5917) {
5918 if text.is_empty() {
5919 return;
5920 }
5921 let msg = Arc::new(AssistantMessageComponent::new(
5922 AssistantMessageOptions::default(),
5923 ));
5924 if let Some(t) = &transformer {
5925 msg.set_markdown_transformer(Some(t.clone()));
5926 }
5927 msg.update_text(text);
5928 container.add_child(msg);
5929 container.add_child(Arc::new(Spacer::new(1)));
5930}
5931
5932async fn drain_agent_events(
5940 mut rx: broadcast::Receiver<AgentEvent>,
5941 tui: Arc<TuiAltScreen>,
5942 state: Arc<TuiState>,
5943 chat: Arc<Container>,
5944) {
5945 loop {
5946 match rx.recv().await {
5947 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
5948 Err(broadcast::error::RecvError::Lagged(_)) => {
5949 continue;
5952 }
5953 Err(broadcast::error::RecvError::Closed) => break,
5954 }
5955 }
5956}
5957
5958async fn handle_agent_event(
5961 event: AgentEvent,
5962 tui: &Arc<TuiAltScreen>,
5963 state: &Arc<TuiState>,
5964 chat: &Arc<Container>,
5965) {
5966 match event {
5967 AgentEvent::AgentStart => {
5968 state.set_status(RunStatus::Working);
5969 tui.request_render(false);
5970 }
5971
5972 AgentEvent::AgentEnd { .. } => {
5973 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
5975 comp.set_streaming(false);
5976 }
5977 state.set_status(RunStatus::Idle);
5978 tui.request_render(false);
5979 }
5980
5981 AgentEvent::TurnStart => {
5982 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
5985 comp.set_streaming(false);
5986 }
5987 }
5988
5989 AgentEvent::TurnEnd {
5990 message,
5991 tool_results,
5992 } => {
5993 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
5995 if let AgentMessage::Assistant(a) = &message {
5996 comp.update_blocks(&assistant_blocks(a));
5997 }
5998 comp.set_streaming(false);
5999 }
6000 let tools = state.tool_components.lock().unwrap();
6005 for tr in &tool_results {
6006 if tools.contains_key(&tr.tool_call_id) {
6007 let _ = tr;
6010 }
6011 }
6012 drop(tools);
6013 tui.request_render(false);
6014 }
6015
6016 AgentEvent::MessageStart { message } => match message {
6017 AgentMessage::Assistant(a) => {
6018 let comp = Arc::new(AssistantMessageComponent::new(
6019 AssistantMessageOptions::default(),
6020 ));
6021 if let Some(t) = state.markdown_transformer() {
6026 comp.set_markdown_transformer(Some(t));
6027 }
6028 comp.set_hide_thinking(state.hide_thinking());
6029 comp.set_streaming(true);
6030 comp.update_blocks(&assistant_blocks(&a));
6033 chat.add_child(comp.clone());
6034 chat.add_child(Arc::new(Spacer::new(1)));
6037 *state.current_assistant.lock().unwrap() = Some(comp);
6038 tui.request_render(false);
6039 }
6040 AgentMessage::Custom(custom) => {
6041 let payload = serde_json::json!({
6042 "customType": custom.role,
6043 "content": custom.content,
6044 "details": custom.data,
6045 "expanded": false,
6046 "outputPad": 1,
6047 });
6048 if let Some(component) = extension_message_component(
6049 &state.extension_session,
6050 &custom.role,
6051 &payload,
6052 state.markdown_transformer(),
6053 ) {
6054 chat.add_child(component);
6055 chat.add_child(Arc::new(Spacer::new(1)));
6056 tui.request_render(false);
6057 } else {
6058 add_note_message(chat, &custom_message_fallback(&custom));
6059 tui.request_render(false);
6060 }
6061 }
6062 _ => {}
6065 },
6066
6067 AgentEvent::MessageUpdate {
6068 message,
6069 assistant_message_event,
6070 } => {
6071 if let AgentMessage::Assistant(a) = &message {
6072 let text = assistant_text(a);
6073 let mut saw_bash_tool_call = false;
6074 for c in &a.content {
6079 if let Content::ToolCall(tc) = c {
6080 if tc.name == "bash" {
6081 saw_bash_tool_call = true;
6082 let command = tc
6088 .arguments
6089 .get("command")
6090 .and_then(|v| v.as_str())
6091 .unwrap_or("");
6092 let mut bash = state.bash_components.lock().unwrap();
6093 if !bash.contains_key(&tc.id) {
6094 let comp = Arc::new(BashExecutionComponent::new(command));
6095 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6096 chat.add_child(comp.clone());
6097 bash.insert(tc.id.clone(), comp);
6098 }
6099 } else {
6100 let mut tools = state.tool_components.lock().unwrap();
6101 if !tools.contains_key(&tc.id) {
6102 let comp = Arc::new(ToolExecutionComponent::new(
6103 &tc.name,
6104 &tc.arguments.to_string(),
6105 ));
6106 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6107 comp.set_running();
6108 chat.add_child(comp.clone());
6109 tools.insert(tc.id.clone(), comp);
6110 }
6111 }
6112 }
6113 }
6114 if saw_bash_tool_call {
6119 state.sync_working_loader_with_bash();
6120 }
6121 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6123 comp.update_blocks(&assistant_blocks(a));
6126 }
6127 *state.last_assistant_text.lock().unwrap() = text;
6128 tui.request_render(false);
6129 }
6130 }
6131
6132 AgentEvent::MessageEnd { message } => {
6133 if let AgentMessage::Assistant(a) = &message {
6134 let text = assistant_text(a);
6135 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6136 comp.update_blocks(&assistant_blocks(a));
6137 comp.set_streaming(false);
6138 }
6139 if !text.is_empty() {
6141 *state.last_assistant_text.lock().unwrap() = text;
6142 }
6143 let usage = &a.usage;
6149 let prev_input = *state.last_input_tokens.lock().unwrap();
6150 if prev_input > 0
6151 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6152 && usage.cache_read == 0
6153 {
6154 add_note_message(
6155 &state.chat_container,
6156 &format!(
6157 "Cache miss: {} tokens re-billed",
6158 format_tokens(usage.input)
6159 ),
6160 );
6161 }
6162 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6163 add_note_message(chat, &text);
6164 }
6165 if let Some(error) = assistant_error_text(a) {
6170 add_error_message(chat, &error);
6171 }
6172 *state.last_input_tokens.lock().unwrap() = usage.input;
6173 }
6174 tui.request_render(false);
6175 }
6176
6177 AgentEvent::ToolExecutionStart {
6178 tool_call_id,
6179 tool_name,
6180 args,
6181 } => {
6182 if tool_name == "bash" {
6183 let command = args
6188 .get("command")
6189 .and_then(|v| v.as_str())
6190 .unwrap_or("")
6191 .to_string();
6192 let mut bash_map = state.bash_components.lock().unwrap();
6193 if let Some(existing) = bash_map.get(&tool_call_id) {
6194 existing.set_command(&command);
6199 } else {
6200 let comp = Arc::new(BashExecutionComponent::new(command));
6201 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6202 chat.add_child(comp.clone());
6203 bash_map.insert(tool_call_id.clone(), comp);
6204 }
6205 } else {
6206 let _comp = {
6207 let mut tools = state.tool_components.lock().unwrap();
6208 if let Some(existing) = tools.get(&tool_call_id) {
6209 existing.set_args(&args.to_string());
6210 existing.clone()
6211 } else {
6212 let comp =
6213 Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6214 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6215 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6220 comp.set_skill_name(skill);
6221 }
6222 comp.set_running();
6223 chat.add_child(comp.clone());
6224 tools.insert(tool_call_id.clone(), comp.clone());
6225 comp
6226 }
6227 };
6228 }
6229 state.sync_working_loader_with_bash();
6230 tui.request_render(false);
6231 }
6232
6233 AgentEvent::ToolExecutionUpdate {
6234 tool_call_id,
6235 tool_name,
6236 args,
6237 partial_result,
6238 } => {
6239 if tool_name == "bash" {
6240 let chunk = tool_result_text(&partial_result);
6245 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
6246 bash.append_output(&chunk);
6247 } else {
6248 let comp = Arc::new(BashExecutionComponent::new(""));
6251 comp.append_output(&chunk);
6252 chat.add_child(comp.clone());
6253 state
6254 .bash_components
6255 .lock()
6256 .unwrap()
6257 .insert(tool_call_id.clone(), comp);
6258 }
6259 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
6260 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6261 comp.set_skill_name(skill);
6262 }
6263 comp.set_result(&tool_result_text(&partial_result), false);
6266 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
6267 } else {
6268 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
6270 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6271 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6272 comp.set_skill_name(skill);
6273 }
6274 comp.set_running();
6275 comp.set_result(&tool_result_text(&partial_result), false);
6276 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
6277 chat.add_child(comp.clone());
6278 state
6279 .tool_components
6280 .lock()
6281 .unwrap()
6282 .insert(tool_call_id.clone(), comp.clone());
6283 }
6284 state.sync_working_loader_with_bash();
6285 tui.request_render(false);
6286 }
6287
6288 AgentEvent::ToolExecutionEnd {
6289 tool_call_id,
6290 tool_name,
6291 result,
6292 is_error,
6293 } => {
6294 if tool_name == "bash" {
6295 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
6296 if let Some(bash) = bash {
6297 finalize_bash(&bash, &result, is_error);
6298 } else {
6299 let command = result
6302 .details
6303 .get("command")
6304 .and_then(|v| v.as_str())
6305 .unwrap_or("")
6306 .to_string();
6307 let comp = Arc::new(BashExecutionComponent::new(command));
6308 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6309 comp.append_output(&tool_result_text(&result));
6310 finalize_bash(&comp, &result, is_error);
6311 chat.add_child(comp);
6312 }
6313 } else {
6314 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
6315 if let Some(comp) = comp {
6316 comp.set_result(&tool_result_text(&result), is_error);
6317 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6318 } else {
6319 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
6322 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6323 comp.set_result(&tool_result_text(&result), is_error);
6324 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6325 chat.add_child(comp.clone());
6326 }
6327 }
6328 state.sync_working_loader_with_bash();
6329 tui.request_render(false);
6330 }
6331 }
6332}
6333
6334fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
6338 if message.stop_reason != rpi_ai::StopReason::Error {
6339 return None;
6340 }
6341 Some(
6342 message
6343 .error_message
6344 .as_deref()
6345 .filter(|text| !text.trim().is_empty())
6346 .unwrap_or("Provider request failed.")
6347 .to_string(),
6348 )
6349}
6350
6351fn finalize_bash(
6355 comp: &Arc<BashExecutionComponent>,
6356 result: &rpi_agent::AgentToolResult,
6357 is_error: bool,
6358) {
6359 let exit_code = if is_error { Some(1) } else { Some(0) };
6362 let truncated = result
6363 .details
6364 .get("truncation")
6365 .and_then(|t| t.get("truncated"))
6366 .and_then(|v| v.as_bool())
6367 .unwrap_or(false);
6368 let full_output_path = result
6369 .details
6370 .get("full_output_path")
6371 .and_then(|v| v.as_str())
6372 .map(|s| s.to_string());
6373 let truncation = BashTruncation {
6374 truncated,
6375 full_output_path,
6376 };
6377 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
6379}
6380
6381fn apply_edit_diff(
6386 comp: &Arc<ToolExecutionComponent>,
6387 tool_name: &str,
6388 details: &serde_json::Value,
6389 tui: &Arc<TuiAltScreen>,
6390) {
6391 if tool_name != "edit" {
6392 return;
6393 }
6394 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
6395 return;
6396 };
6397 if diff_text.is_empty() {
6398 return;
6399 }
6400 let width = tui.width();
6401 let lines = render_diff(diff_text, width);
6402 comp.set_diff(lines);
6403}
6404
6405fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
6410 if tool_name != "read" {
6411 return None;
6412 }
6413 let path = args.get("path").and_then(|value| value.as_str())?;
6414 let normalized = path.replace('\\', "/");
6415 let file_name = normalized.rsplit('/').next()?;
6416 if !file_name.eq_ignore_ascii_case("SKILL.md") {
6417 return None;
6418 }
6419 normalized
6420 .trim_end_matches('/')
6421 .rsplit('/')
6422 .nth(1)
6423 .filter(|name| !name.is_empty())
6424 .map(str::to_string)
6425}
6426
6427fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
6430 use rpi_agent::TextContentOrImage;
6431 let mut parts: Vec<String> = Vec::new();
6432 for c in &result.content {
6433 if let TextContentOrImage::Text(t) = c {
6434 parts.push(t.text.clone());
6435 }
6436 }
6437 let joined = parts.join("\n");
6438 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
6440 if one_line.chars().count() > 200 {
6441 let truncated: String = one_line.chars().take(200).collect();
6442 format!("{truncated}…")
6443 } else {
6444 one_line
6445 }
6446}
6447
6448fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
6453 use rpi_agent::TextContentOrImage;
6454 let mut parts: Vec<String> = Vec::new();
6455 for c in &result.content {
6456 if let TextContentOrImage::Text(t) = c {
6457 parts.push(t.text.clone());
6458 }
6459 }
6460 parts.join("\n")
6461}
6462
6463fn open_selector(
6471 state: &Arc<TuiState>,
6472 editor_container: &Arc<Container>,
6473 editor: &Arc<Editor>,
6474 tui: &Arc<TuiAltScreen>,
6475 list: Arc<SelectList>,
6476 kind: SelectorKind,
6477) {
6478 open_selector_with_view(
6479 state,
6480 editor_container,
6481 editor,
6482 tui,
6483 list.clone(),
6484 list,
6485 kind,
6486 );
6487}
6488
6489fn open_selector_with_view<C: Component + 'static>(
6493 state: &Arc<TuiState>,
6494 editor_container: &Arc<Container>,
6495 editor: &Arc<Editor>,
6496 tui: &Arc<TuiAltScreen>,
6497 list: Arc<SelectList>,
6498 view: Arc<C>,
6499 kind: SelectorKind,
6500) {
6501 editor.set_focused(false);
6503 editor_container.clear();
6505 editor_container.add_child(view.clone());
6506 *state.active_selector.lock().unwrap() = Some((list, kind));
6507 let focused: Arc<dyn Component> = view;
6508 tui.set_focus(Some(focused));
6509 tui.request_render(false);
6510}
6511
6512fn close_selector(
6515 state: &Arc<TuiState>,
6516 editor_container: &Arc<Container>,
6517 editor: &Arc<Editor>,
6518 tui: &Arc<TuiAltScreen>,
6519) {
6520 editor_container.clear();
6521 editor_container.add_child(editor.clone());
6522 editor.set_focused(true);
6523 *state.active_selector.lock().unwrap() = None;
6524 *state.active_extension_cancel.lock().unwrap() = None;
6525 tui.set_focus(Some(editor.clone()));
6526 tui.request_render(false);
6527}
6528
6529fn open_model_selector(
6535 state: &Arc<TuiState>,
6536 editor_container: &Arc<Container>,
6537 editor: &Arc<Editor>,
6538 tui: &Arc<TuiAltScreen>,
6539 catalog: &[rpi_ai::Model],
6540 lane: &Arc<dyn AgentLane>,
6541 lane_model_id: &str,
6542 chat: &Arc<Container>,
6543) {
6544 let items = model_selector_items(catalog, lane_model_id);
6545 if items.is_empty() {
6546 add_note_message(
6547 chat,
6548 "No models in the catalog. Use --model at startup to select one.",
6549 );
6550 tui.request_render(false);
6551 return;
6552 }
6553 let list = Arc::new(SelectList::new(items, 10));
6554
6555 let catalog_arc = catalog.to_vec();
6559 let state_sel = state.clone();
6560 let ec_sel = editor_container.clone();
6561 let editor_sel = editor.clone();
6562 let tui_sel = tui.clone();
6563 let chat_sel = chat.clone();
6564 let lane_sel = lane.clone();
6565 list.on_select(Arc::new(move |item| {
6566 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
6567 add_note_message(
6568 &chat_sel,
6569 &format!("Model {} not found in catalog.", item.label),
6570 );
6571 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6572 return;
6573 };
6574 state_sel.set_current_model(&model);
6575 let lane = lane_sel.clone();
6576 tokio::spawn(async move {
6577 let _ = lane.set_model(model).await;
6578 });
6579 add_note_message(
6580 &chat_sel,
6581 &format!(
6582 "Model set to {} — applies to the next message.",
6583 short_model_name(&item.value)
6584 ),
6585 );
6586 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6587 }));
6588 let state_cancel = state.clone();
6589 let ec_cancel = editor_container.clone();
6590 let editor_cancel = editor.clone();
6591 let tui_cancel = tui.clone();
6592 list.on_cancel(Arc::new(move || {
6593 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6594 }));
6595
6596 open_selector(
6597 state,
6598 editor_container,
6599 editor,
6600 tui,
6601 list,
6602 SelectorKind::Model,
6603 );
6604}
6605
6606fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
6611 if catalog.is_empty() {
6612 return None;
6613 }
6614 let idx = catalog
6615 .iter()
6616 .position(|m| m.id.eq_ignore_ascii_case(current_id));
6617 match idx {
6618 Some(i) => {
6619 let next = (i + 1) % catalog.len();
6620 Some(catalog[next].clone())
6621 }
6622 None => Some(catalog[0].clone()),
6623 }
6624}
6625
6626fn open_session_selector(
6632 state: &Arc<TuiState>,
6633 editor_container: &Arc<Container>,
6634 editor: &Arc<Editor>,
6635 tui: &Arc<TuiAltScreen>,
6636 cwd: &std::path::Path,
6637 tx: &mpsc::UnboundedSender<TuiMessage>,
6638) {
6639 let dir = crate::session::default_session_dir(cwd);
6640 let mut items: Vec<SelectItem> = Vec::new();
6641 if let Ok(entries) = std::fs::read_dir(&dir) {
6642 for entry in entries.flatten() {
6643 let path = entry.path();
6644 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
6645 continue;
6646 }
6647 let stem = path
6648 .file_stem()
6649 .and_then(|s| s.to_str())
6650 .unwrap_or("(unnamed)")
6651 .to_string();
6652 let display = path
6653 .file_name()
6654 .and_then(|s| s.to_str())
6655 .unwrap_or(&stem)
6656 .to_string();
6657 items.push(SelectItem::new(&stem, &display));
6658 }
6659 }
6660 if items.is_empty() {
6661 add_note_message(
6662 &state.chat_container,
6663 "No saved sessions found. Sessions are created automatically in interactive mode.",
6664 );
6665 tui.request_render(false);
6666 return;
6667 }
6668 let list = Arc::new(SelectList::new(items, 10));
6669
6670 let state_sel = state.clone();
6671 let ec_sel = editor_container.clone();
6672 let editor_sel = editor.clone();
6673 let tui_sel = tui.clone();
6674 let tx_sel = tx.clone();
6675 list.on_select(Arc::new(move |item| {
6676 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6680 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
6681 }));
6682 let state_cancel = state.clone();
6683 let ec_cancel = editor_container.clone();
6684 let editor_cancel = editor.clone();
6685 let tui_cancel = tui.clone();
6686 list.on_cancel(Arc::new(move || {
6687 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6688 }));
6689
6690 open_selector(
6691 state,
6692 editor_container,
6693 editor,
6694 tui,
6695 list,
6696 SelectorKind::Session,
6697 );
6698}
6699
6700fn custom_entry_display_text(
6701 custom_type: &str,
6702 data: Option<&serde_json::Value>,
6703) -> Option<String> {
6704 let data = data?;
6705 let text = data
6706 .get("summary")
6707 .or_else(|| data.get("text"))
6708 .or_else(|| data.get("output"))
6709 .and_then(|value| value.as_str())
6710 .filter(|value| !value.trim().is_empty())?;
6711 let label = match custom_type {
6712 "compactionSummary" => "Compaction summary",
6713 "branchSummary" => "Branch summary",
6714 "bashExecution" => "Command output",
6715 other => other,
6716 };
6717 Some(format!("{label}: {text}"))
6718}
6719
6720async fn open_tree_selector(
6724 harness: &AgentHarness,
6725 state: &Arc<TuiState>,
6726 editor_container: &Arc<Container>,
6727 editor: &Arc<Editor>,
6728 tui: &Arc<TuiAltScreen>,
6729 chat: &Arc<Container>,
6730 tx: &mpsc::UnboundedSender<TuiMessage>,
6731) {
6732 let entries = match harness
6733 .session()
6734 .view("main")
6735 .find_entries(&EntryQuery {
6736 order: Some(EntryOrder::OldestFirst),
6737 ..Default::default()
6738 })
6739 .await
6740 {
6741 Ok(entries) => entries,
6742 Err(error) => {
6743 add_error_message(chat, &format!("Could not read session tree: {error}"));
6744 tui.request_render(false);
6745 return;
6746 }
6747 };
6748 let current = harness.session().get_leaf_id().await.ok().flatten();
6749 let items: Vec<SelectItem> = entries
6750 .iter()
6751 .map(|entry| {
6752 let marker = if current.as_deref() == Some(entry.id()) {
6753 " (current)"
6754 } else {
6755 ""
6756 };
6757 SelectItem::new(
6758 entry.id(),
6759 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
6760 )
6761 .with_description(&entry.id()[..entry.id().len().min(12)])
6762 })
6763 .collect();
6764 if items.is_empty() {
6765 add_note_message(chat, "The current session has no entries to navigate.");
6766 tui.request_render(false);
6767 return;
6768 }
6769 let list = Arc::new(SelectList::new(items, 12));
6770 let state_sel = state.clone();
6771 let ec_sel = editor_container.clone();
6772 let editor_sel = editor.clone();
6773 let tui_sel = tui.clone();
6774 let tx_sel = tx.clone();
6775 list.on_select(Arc::new(move |item| {
6776 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
6777 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6778 }));
6779 let state_cancel = state.clone();
6780 let ec_cancel = editor_container.clone();
6781 let editor_cancel = editor.clone();
6782 let tui_cancel = tui.clone();
6783 list.on_cancel(Arc::new(move || {
6784 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6785 }));
6786 open_selector(
6787 state,
6788 editor_container,
6789 editor,
6790 tui,
6791 list,
6792 SelectorKind::Tree,
6793 );
6794}
6795
6796fn open_theme_selector(
6799 state: &Arc<TuiState>,
6800 editor_container: &Arc<Container>,
6801 editor: &Arc<Editor>,
6802 tui: &Arc<TuiAltScreen>,
6803 package_resources: &Arc<crate::packages::PackageResources>,
6804) {
6805 let mut items = vec![
6806 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
6807 SelectItem::new("light", "Light").with_description("Light background"),
6808 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
6809 ];
6810 if state.themes_enabled {
6811 for path in package_resources.theme_files() {
6812 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
6813 items.push(SelectItem::new(name, name).with_description("Package theme"));
6814 }
6815 }
6816 }
6817 let list = Arc::new(SelectList::new(items, 10));
6818
6819 let state_sel = state.clone();
6820 let ec_sel = editor_container.clone();
6821 let editor_sel = editor.clone();
6822 let tui_sel = tui.clone();
6823 let chat_sel = state.chat_container.clone();
6824 let package_resources_sel = package_resources.clone();
6825 list.on_select(Arc::new(move |item| {
6826 let preset = match item.value.as_str() {
6827 "light" => Some(ThemePreset::Light),
6828 "monochrome" => Some(ThemePreset::Monochrome),
6829 "dark" => Some(ThemePreset::Dark),
6830 name => {
6831 if let Ok(cwd) = std::env::current_dir() {
6832 if state_sel.themes_enabled {
6833 if let Ok(custom) = crate::packages::load_theme_with_resources(
6834 &cwd,
6835 name,
6836 &package_resources_sel,
6837 ) {
6838 rpi_tui::global_theme_manager().set(custom.clone());
6839 state_sel.theme_manager.set(custom);
6840 }
6841 }
6842 }
6843 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
6844 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6845 tui_sel.render_now(true);
6846 return;
6847 }
6848 };
6849 let Some(preset) = preset else { return };
6850 apply_theme_preset(preset);
6851 state_sel.theme_manager.apply_preset(preset);
6852 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
6855 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6856 tui_sel.render_now(true);
6857 }));
6858 let state_cancel = state.clone();
6859 let ec_cancel = editor_container.clone();
6860 let editor_cancel = editor.clone();
6861 let tui_cancel = tui.clone();
6862 list.on_cancel(Arc::new(move || {
6863 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6864 }));
6865
6866 open_selector(
6867 state,
6868 editor_container,
6869 editor,
6870 tui,
6871 list,
6872 SelectorKind::Theme,
6873 );
6874}
6875
6876fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
6883 use rpi_ai::types::ThinkingLevel::*;
6884 match level {
6885 Off => "Off — No reasoning",
6886 Minimal => "Minimal — Brief reasoning (~1k tokens)",
6887 Low => "Low — Light reasoning (~1k tokens)",
6888 Medium => "Medium — Moderate reasoning (~80% of max)",
6889 High => "High — Extensive reasoning (~95% of max)",
6890 Xhigh => "Xhigh — Near-maximal reasoning",
6891 Max => "Max — Maximum reasoning",
6892 }
6893}
6894
6895fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
6898 use rpi_ai::types::ThinkingLevel::*;
6899 match level {
6900 Off => "off",
6901 Minimal => "minimal",
6902 Low => "low",
6903 Medium => "medium",
6904 High => "high",
6905 Xhigh => "xhigh",
6906 Max => "max",
6907 }
6908}
6909
6910fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
6913 use rpi_ai::types::ThinkingLevel::*;
6914 match name.to_ascii_lowercase().as_str() {
6915 "off" => Some(Off),
6916 "minimal" => Some(Minimal),
6917 "low" => Some(Low),
6918 "medium" => Some(Medium),
6919 "high" => Some(High),
6920 "xhigh" => Some(Xhigh),
6921 "max" => Some(Max),
6922 _ => None,
6923 }
6924}
6925
6926fn open_thinking_selector(
6936 state: &Arc<TuiState>,
6937 editor_container: &Arc<Container>,
6938 editor: &Arc<Editor>,
6939 tui: &Arc<TuiAltScreen>,
6940 lane: &Arc<dyn AgentLane>,
6941 catalog: &[rpi_ai::Model],
6942 lane_model_id: &str,
6943 chat: &Arc<Container>,
6944) {
6945 let model = catalog
6948 .iter()
6949 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
6950 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
6951 .map(|m| m.supported_thinking_levels())
6952 .unwrap_or_else(|| {
6953 use rpi_ai::types::ThinkingLevel::*;
6954 vec![Off, Minimal, Low, Medium, High]
6955 });
6956 let mut items: Vec<SelectItem> = Vec::new();
6957 for lvl in &levels {
6958 let name = thinking_level_name(*lvl);
6959 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
6960 }
6961 if items.is_empty() {
6962 add_note_message(chat, "This model has no supported thinking levels.");
6963 tui.request_render(false);
6964 return;
6965 }
6966 let list = Arc::new(SelectList::new(items, 10));
6967
6968 let state_sel = state.clone();
6969 let ec_sel = editor_container.clone();
6970 let editor_sel = editor.clone();
6971 let tui_sel = tui.clone();
6972 let chat_sel = chat.clone();
6973 let lane_sel = lane.clone();
6974 list.on_select(Arc::new(move |item| {
6975 let Some(level) = thinking_level_from_name(&item.value) else {
6976 add_note_message(
6977 &chat_sel,
6978 &format!("Unknown thinking level: {}.", item.label),
6979 );
6980 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6981 return;
6982 };
6983 let lane = lane_sel.clone();
6984 let footer_sel = state_sel.footer.clone();
6985 tokio::spawn(async move {
6986 let _ = lane.set_thinking_level(level).await;
6987 });
6988 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
6992 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
6993 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6994 }));
6995 let state_cancel = state.clone();
6996 let ec_cancel = editor_container.clone();
6997 let editor_cancel = editor.clone();
6998 let tui_cancel = tui.clone();
6999 list.on_cancel(Arc::new(move || {
7000 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7001 }));
7002
7003 open_selector(
7004 state,
7005 editor_container,
7006 editor,
7007 tui,
7008 list,
7009 SelectorKind::Thinking,
7010 );
7011}
7012
7013fn open_tools_selector(
7019 state: &Arc<TuiState>,
7020 editor_container: &Arc<Container>,
7021 editor: &Arc<Editor>,
7022 tui: &Arc<TuiAltScreen>,
7023 lane: &Arc<dyn AgentLane>,
7024 chat: &Arc<Container>,
7025) {
7026 let active = match tokio::runtime::Handle::try_current() {
7031 Ok(h) => h
7032 .block_on(async { lane.get_active_tools().await })
7033 .unwrap_or_default(),
7034 Err(_) => Vec::new(),
7035 };
7036 let mut items: Vec<SelectItem> = Vec::new();
7037 for name in crate::session::BUILTIN_TOOL_NAMES {
7038 let on = active.iter().any(|a| a == name);
7039 let label = if on {
7040 format!("{name} (on)")
7041 } else {
7042 (*name).to_string()
7043 };
7044 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
7045 }
7046 let list = Arc::new(SelectList::new(items, 10));
7047
7048 let active_captured = active.clone();
7050 let state_sel = state.clone();
7051 let ec_sel = editor_container.clone();
7052 let editor_sel = editor.clone();
7053 let tui_sel = tui.clone();
7054 let chat_sel = chat.clone();
7055 let lane_sel = lane.clone();
7056 list.on_select(Arc::new(move |item| {
7057 let mut next = active_captured.clone();
7058 if let Some(pos) = next.iter().position(|a| a == &item.value) {
7059 next.remove(pos);
7060 } else {
7061 next.push(item.value.clone());
7062 }
7063 let on = next.iter().any(|a| a == &item.value);
7064 let lane = lane_sel.clone();
7065 let next_clone = next.clone();
7066 tokio::spawn(async move {
7067 let _ = lane.set_active_tools(next_clone).await;
7068 });
7069 let list_str = if next.is_empty() {
7070 "(none)".to_string()
7071 } else {
7072 next.join(", ")
7073 };
7074 add_note_message(
7075 &chat_sel,
7076 &format!(
7077 "{} {} — active tools: {}",
7078 item.value,
7079 if on { "enabled" } else { "disabled" },
7080 list_str
7081 ),
7082 );
7083 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7084 }));
7085 let state_cancel = state.clone();
7086 let ec_cancel = editor_container.clone();
7087 let editor_cancel = editor.clone();
7088 let tui_cancel = tui.clone();
7089 list.on_cancel(Arc::new(move || {
7090 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7091 }));
7092
7093 open_selector(
7094 state,
7095 editor_container,
7096 editor,
7097 tui,
7098 list,
7099 SelectorKind::Tools,
7100 );
7101}
7102
7103fn open_images_selector(
7107 state: &Arc<TuiState>,
7108 editor_container: &Arc<Container>,
7109 editor: &Arc<Editor>,
7110 tui: &Arc<TuiAltScreen>,
7111 chat: &Arc<Container>,
7112) {
7113 let current = *state.show_images.lock().unwrap();
7114 let items = vec![
7115 SelectItem::new("yes", "Yes").with_description(if current {
7116 "Inline images (current)"
7117 } else {
7118 "Inline images"
7119 }),
7120 SelectItem::new("no", "No").with_description(if current {
7121 "Placeholder only"
7122 } else {
7123 "Placeholder only (current)"
7124 }),
7125 ];
7126 let list = Arc::new(SelectList::new(items, 5));
7127
7128 let state_sel = state.clone();
7129 let ec_sel = editor_container.clone();
7130 let editor_sel = editor.clone();
7131 let tui_sel = tui.clone();
7132 let chat_sel = chat.clone();
7133 list.on_select(Arc::new(move |item| {
7134 let on = item.value == "yes";
7135 *state_sel.show_images.lock().unwrap() = on;
7136 add_note_message(
7137 &chat_sel,
7138 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
7139 );
7140 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7141 }));
7142 let state_cancel = state.clone();
7143 let ec_cancel = editor_container.clone();
7144 let editor_cancel = editor.clone();
7145 let tui_cancel = tui.clone();
7146 list.on_cancel(Arc::new(move || {
7147 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7148 }));
7149
7150 open_selector(
7151 state,
7152 editor_container,
7153 editor,
7154 tui,
7155 list,
7156 SelectorKind::Images,
7157 );
7158}
7159
7160fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
7168 let text = editor.get_text();
7169 let (_row, col) = editor.cursor_position();
7170 let cursor = col.min(text.len());
7178 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
7179 render_autocomplete(state, suggestions);
7180}
7181
7182fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
7184 state.autocomplete_container.clear();
7185 let Some(sugg) = suggestions else {
7186 return;
7187 };
7188 if sugg.items.is_empty() {
7189 return;
7190 }
7191 let accent = state.theme_manager.get().colors.accent;
7194 let muted = state.theme_manager.get().colors.muted;
7195 for (i, item) in sugg
7196 .items
7197 .iter()
7198 .take(state.autocomplete_max_visible)
7199 .enumerate()
7200 {
7201 let prefix = if i == 0 { "→ " } else { " " };
7202 let label = item.display_text();
7203 let line = if i == 0 {
7204 format!(
7205 "{prefix}{} {}",
7206 accent.fg(label),
7207 muted.fg(item.description.as_deref().unwrap_or(""))
7208 )
7209 } else {
7210 format!(
7211 "{prefix}{} {}",
7212 muted.fg(label),
7213 muted.fg(item.description.as_deref().unwrap_or(""))
7214 )
7215 };
7216 state
7217 .autocomplete_container
7218 .add_child(Arc::new(Text::new(line, 1, 0)));
7219 }
7220}
7221
7222fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
7226 let text = editor.get_text();
7227 let (_row, col) = editor.cursor_position();
7228 let cursor = col.min(text.len());
7229 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
7230 return false;
7231 };
7232 let Some(top) = sugg.items.first() else {
7233 return false;
7234 };
7235 let start = sugg.start.min(text.len());
7239 let end = sugg.end.min(text.len());
7240 let mut replaced = String::with_capacity(text.len() + top.text.len());
7241 replaced.push_str(&text[..start]);
7242 replaced.push_str(&top.text);
7243 replaced.push_str(&text[end..]);
7246 if top.insert_space && !replaced.ends_with('/') {
7247 replaced.push(' ');
7248 }
7249 let new_cursor = replaced.len().min(
7252 start
7253 + top.text.len()
7254 + if top.insert_space && !top.text.ends_with('/') {
7255 1
7256 } else {
7257 0
7258 },
7259 );
7260 editor.set_text(&replaced);
7261 editor.set_cursor(0, new_cursor);
7262 state.autocomplete_container.clear();
7263 true
7264}
7265
7266fn add_welcome_message(container: &Arc<Container>) {
7272 add_welcome_message_with_capabilities(container, &[], &[]);
7273}
7274
7275fn add_welcome_message_with_capabilities(
7279 container: &Arc<Container>,
7280 active_tools: &[String],
7281 skills: &[String],
7282) {
7283 let c = current_theme().colors;
7284 let title = format!(
7288 "{} {}",
7289 c.accent.fg(&tui_bold("rpi")),
7290 c.muted.fg("interactive TUI")
7291 );
7292 container.add_child(Arc::new(Text::new(title, 1, 0)));
7293 container.add_child(Arc::new(Spacer::new(1)));
7294 container.add_child(Arc::new(Text::new(
7295 c.dim.fg("Type your message and press Enter to send."),
7296 1,
7297 0,
7298 )));
7299 let hint = c
7300 .dim
7301 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
7302 container.add_child(Arc::new(Text::new(hint, 1, 0)));
7303 container.add_child(Arc::new(Spacer::new(1)));
7304 container.add_child(Arc::new(Text::new(
7305 welcome_capability_line("Tools", active_tools),
7306 1,
7307 0,
7308 )));
7309 container.add_child(Arc::new(Text::new(
7310 welcome_capability_line("Skills", skills),
7311 1,
7312 0,
7313 )));
7314 container.add_child(Arc::new(DynamicBorder::new()));
7315}
7316
7317fn welcome_capability_line(label: &str, names: &[String]) -> String {
7318 let c = current_theme().colors;
7319 let value = if names.is_empty() {
7320 "none".to_string()
7321 } else {
7322 names.join(" · ")
7323 };
7324 format!(
7325 "{} {}",
7326 c.accent.fg(&format!("{label} ({})", names.len())),
7327 c.muted.fg(&value)
7328 )
7329}
7330
7331fn add_help_message(container: &Arc<Container>) {
7333 let c = current_theme().colors;
7334 container.add_child(Arc::new(Text::new(
7338 c.md_heading.fg(&tui_bold("📚 Available Commands")),
7339 1,
7340 0,
7341 )));
7342 container.add_child(Arc::new(Spacer::new(1)));
7343
7344 let cmds: &[(&str, &str)] = &[
7345 ("/help, /?", "Show this help message"),
7346 ("/clear, /new", "Clear the conversation"),
7347 ("/exit, /quit, /q", "Exit the application"),
7348 ("/version, /v", "Show version information"),
7349 ("/changelog", "Show recent release changes"),
7350 ("/model, /m", "Choose a model (live switch)"),
7351 ("/thinking, /think", "Set reasoning depth (selector)"),
7352 ("/tools", "Toggle built-in tools on/off"),
7353 ("/images", "Toggle inline image rendering"),
7354 ("/session", "List saved sessions"),
7355 ("/theme", "Choose a theme (selector)"),
7356 ("/compact", "Compact the conversation"),
7357 ("/copy", "Copy last reply to clipboard"),
7358 ("/hotkeys", "Show keyboard shortcuts"),
7359 ("/armin", "🐾 Easter egg"),
7360 ("/earendil", "Earendil announcement"),
7361 ];
7362 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7363 for (cmd, desc) in cmds {
7364 let row = format!(
7365 " {:<cmd_w$} {} {}",
7366 c.accent.fg(cmd),
7367 c.dim.fg("—"),
7368 c.muted.fg(desc)
7369 );
7370 container.add_child(Arc::new(Text::new(row, 1, 0)));
7371 }
7372 container.add_child(Arc::new(Spacer::new(1)));
7373}
7374
7375fn add_version_message(container: &Arc<Container>) {
7377 let c = current_theme().colors;
7378 container.add_child(Arc::new(Text::new(
7379 c.md_heading.fg(&tui_bold("📦 Version Information")),
7380 1,
7381 0,
7382 )));
7383 container.add_child(Arc::new(Spacer::new(1)));
7384 container.add_child(Arc::new(Text::new(
7387 format!(
7388 " {} {}",
7389 c.muted.fg("rpi-cli"),
7390 c.text.fg(&format!("v{}", crate::VERSION))
7391 ),
7392 1,
7393 0,
7394 )));
7395 container.add_child(Arc::new(Text::new(
7396 format!(
7397 " {}",
7398 c.dim.fg("Rust implementation of pi coding agent TUI")
7399 ),
7400 1,
7401 0,
7402 )));
7403 container.add_child(Arc::new(Spacer::new(1)));
7404}
7405
7406fn add_changelog_message(container: &Arc<Container>) {
7410 let c = current_theme().colors;
7411 container.add_child(Arc::new(Text::new(
7412 c.md_heading.fg(&tui_bold("Recent Changes")),
7413 1,
7414 0,
7415 )));
7416 container.add_child(Arc::new(Spacer::new(1)));
7417 let entries = [
7418 (
7419 "Native parity phase 1",
7420 "models, images, trust, export, and JSON events",
7421 ),
7422 (
7423 "TUI controls",
7424 "external editor, thinking levels, and tool output toggles",
7425 ),
7426 (
7427 "Provider auth",
7428 "OpenAI-compatible API key aliases and gateway headers",
7429 ),
7430 ];
7431 for (release, summary) in entries {
7432 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
7433 container.add_child(Arc::new(Text::new(row, 1, 0)));
7434 }
7435 container.add_child(Arc::new(Text::new(
7436 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
7437 1,
7438 0,
7439 )));
7440 container.add_child(Arc::new(Spacer::new(1)));
7441}
7442
7443fn add_hotkeys_message(container: &Arc<Container>) {
7445 let c = current_theme().colors;
7446 container.add_child(Arc::new(Text::new(
7447 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
7448 1,
7449 0,
7450 )));
7451 container.add_child(Arc::new(Spacer::new(1)));
7452 let keys: &[(&str, &str)] = &[
7453 ("Enter", "Send message"),
7454 ("Shift+Enter", "New line"),
7455 ("Tab", "Accept autocomplete suggestion"),
7456 ("Ctrl+A / Ctrl+E", "Line start / end"),
7457 (
7458 "Ctrl+K / Ctrl+U",
7459 "Kill to end / start of line (Ctrl+Y yanks)",
7460 ),
7461 ("Ctrl+- / Ctrl+R", "Undo / redo"),
7462 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
7463 ("Alt+Backspace", "Kill previous word"),
7464 ("Ctrl+C", "Abort a run, or exit when idle"),
7465 ("Esc", "Abort a running prompt"),
7466 ("Ctrl+L", "Open model selector"),
7467 ("Ctrl+M", "Cycle to the next model (live)"),
7468 ("Ctrl+O", "Expand/collapse all tool output"),
7469 ("Ctrl+T", "Show/hide reasoning blocks"),
7470 ("PageUp/Down", "Scroll transcript by one page"),
7471 ("Home / End", "Jump to transcript start / latest output"),
7472 ];
7473 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7474 for (key, desc) in keys {
7475 let row = format!(
7476 " {:<key_w$} {} {}",
7477 c.accent.fg(key),
7478 c.dim.fg("—"),
7479 c.muted.fg(desc)
7480 );
7481 container.add_child(Arc::new(Text::new(row, 1, 0)));
7482 }
7483 container.add_child(Arc::new(Spacer::new(1)));
7484}
7485
7486fn add_user_message(container: &Arc<Container>, text: &str) {
7492 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
7493 container.add_child(Arc::new(Spacer::new(1)));
7494}
7495
7496fn add_error_message(container: &Arc<Container>, text: &str) {
7498 let c = current_theme().colors;
7499 container.add_child(Arc::new(Text::new(
7500 format!(" {} {}", c.error.fg("✗"), c.error.fg(text)),
7501 1,
7502 0,
7503 )));
7504 container.add_child(Arc::new(Spacer::new(1)));
7505}
7506
7507fn add_note_message(container: &Arc<Container>, text: &str) {
7509 let c = current_theme().colors;
7510 container.add_child(Arc::new(Text::new(
7511 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
7512 1,
7513 0,
7514 )));
7515 container.add_child(Arc::new(Spacer::new(1)));
7516}
7517
7518fn show_context_panel(
7527 chat: &Arc<Container>,
7528 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
7529) {
7530 let skills = resources.skills.as_deref().unwrap_or(&[]);
7531 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
7532 let mut lines: Vec<String> = Vec::new();
7533 lines.push("📂 Discovered resources for this session:".into());
7534
7535 if skills.is_empty() {
7536 lines.push(
7537 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
7538 );
7539 } else {
7540 lines.push(format!(" Skills ({}):", skills.len()));
7541 for s in skills {
7542 let marker = if s.disable_model_invocation == Some(true) {
7543 " [hidden]"
7544 } else {
7545 ""
7546 };
7547 let desc: String = s.description.chars().take(72).collect();
7548 lines.push(format!(" • {}{marker} — {desc}", s.name));
7549 }
7550 }
7551
7552 if templates.is_empty() {
7553 lines.push(
7554 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
7555 );
7556 } else {
7557 lines.push(format!(" Prompt templates ({}):", templates.len()));
7558 for t in templates {
7559 let desc = t
7560 .description
7561 .as_deref()
7562 .unwrap_or("(no description)")
7563 .chars()
7564 .take(72)
7565 .collect::<String>();
7566 lines.push(format!(" • /{} — {desc}", t.name));
7567 }
7568 }
7569 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
7570 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
7571 lines.push(
7572 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
7573 .into(),
7574 );
7575 let body = lines.join("\n");
7576 container_note_block(chat, &body);
7577}
7578
7579fn container_note_block(container: &Arc<Container>, body: &str) {
7581 for line in body.lines() {
7582 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
7583 }
7584 container.add_child(Arc::new(Spacer::new(1)));
7585}
7586
7587pub fn is_tui_supported() -> bool {
7593 std::io::stdout().is_terminal()
7594}
7595
7596#[allow(unused_imports)]
7598use rpi_tui::Color as _Color;
7599
7600#[cfg(test)]
7601mod tests {
7602 use super::*;
7603 use rpi_tui::Component;
7604
7605 #[test]
7606 fn transcript_page_uses_viewport_with_overlap() {
7607 assert_eq!(transcript_page_size(24), 20);
7608 assert_eq!(transcript_page_size(4), 1);
7609 assert_eq!(transcript_page_size(0), 1);
7610 }
7611
7612 #[test]
7613 fn key_repeat_is_dispatched_but_release_is_not() {
7614 assert!(should_dispatch_key(KeyEventKind::Press));
7615 assert!(should_dispatch_key(KeyEventKind::Repeat));
7616 assert!(!should_dispatch_key(KeyEventKind::Release));
7617 }
7618
7619 #[test]
7620 fn key_event_encoding_matches_pi_keybinding_protocol() {
7621 let key = |code, modifiers| KeyEvent::new(code, modifiers);
7622 assert_eq!(
7623 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
7624 "\r"
7625 );
7626 assert_eq!(
7627 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
7628 "\x1b[13;2u"
7629 );
7630 assert_eq!(
7631 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
7632 "\x1b[9;2u"
7633 );
7634 assert_eq!(
7635 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
7636 "\x1b[Z"
7637 );
7638 assert_eq!(
7639 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
7640 "\x1b[Z"
7641 );
7642 assert_eq!(
7643 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
7644 "\x03"
7645 );
7646 assert_eq!(
7647 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
7648 "\x0f"
7649 );
7650 assert_eq!(
7651 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
7652 "!"
7653 );
7654 assert_eq!(
7655 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
7656 "1"
7657 );
7658 }
7659
7660 #[test]
7661 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
7662 let bridge = JsDialogBridge::default();
7663 let (sender, receiver) = std_mpsc::channel();
7664 bridge.pending.lock().unwrap().push_back(JsDialogPending {
7665 request: JsDialogRequest {
7666 id: "dialog-1".into(),
7667 method: "input".into(),
7668 title: String::new(),
7669 message: String::new(),
7670 options: Vec::new(),
7671 placeholder: None,
7672 prefill: None,
7673 },
7674 result: sender,
7675 });
7676
7677 bridge.cancel("dialog-1");
7680 assert!(bridge.take_pending().is_none());
7681 assert_eq!(
7682 receiver.recv().unwrap(),
7683 serde_json::json!({ "cancelled": true })
7684 );
7685 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
7686 }
7687
7688 #[test]
7689 fn test_layout_renders_welcome_message() {
7690 let chat = Arc::new(Container::new());
7691 add_welcome_message(&chat);
7692
7693 let scroll = Arc::new(ScrollView::new(
7694 chat.clone(),
7695 ScrollViewOptions {
7696 follow: FollowMode::End,
7697 primary: true,
7698 ..Default::default()
7699 },
7700 ));
7701
7702 let editor = Arc::new(Editor::new(
7703 EditorOptions {
7704 padding_x: 1,
7705 ..Default::default()
7706 },
7707 EditorStyle::default(),
7708 Arc::new(rpi_tui::Keybindings::new()),
7709 ));
7710 let dock = Arc::new(Container::new());
7711 dock.add_child(editor);
7712
7713 let footer = Arc::new(FooterComponent::new());
7714
7715 let root = VStack::from_children(vec![
7716 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
7717 StackChild::Entry(StackEntry::new(dock)),
7718 StackChild::Entry(StackEntry::new(footer)),
7719 ]);
7720
7721 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
7722
7723 let all: String = frame.lines.join("\n");
7724 assert!(
7725 all.contains("rpi"),
7726 "Welcome message not found. Rendered: {}",
7727 all
7728 );
7729 assert!(
7730 all.contains("Type your message"),
7731 "Help text not found. Rendered: {}",
7732 all
7733 );
7734 }
7735
7736 #[test]
7737 fn test_chat_container_has_welcome_content() {
7738 let chat = Arc::new(Container::new());
7739 add_welcome_message_with_capabilities(
7740 &chat,
7741 &["read".into(), "bash".into(), "web_fetch".into()],
7742 &["rust-review".into(), "release".into()],
7743 );
7744
7745 let lines = chat.render(80);
7746 let all: String = lines.join("\n");
7747 let plain = strip_ansi(&all);
7750 assert!(
7751 plain.contains("rpi"),
7752 "Welcome message not in chat container: {:?}",
7753 lines
7754 );
7755 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
7756 assert!(
7757 plain.contains("read · bash · web_fetch"),
7758 "Tool names missing: {plain}"
7759 );
7760 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
7761 assert!(
7762 plain.contains("rust-review · release"),
7763 "Skill names missing: {plain}"
7764 );
7765 }
7766
7767 #[test]
7768 fn skill_reads_are_detected_by_path() {
7769 let name = skill_tool_name(
7770 "read",
7771 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
7772 );
7773 assert_eq!(name.as_deref(), Some("release"));
7774
7775 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
7776 assert!(name.is_none());
7777
7778 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
7780 }
7781
7782 #[test]
7783 fn welcome_capabilities_show_empty_state() {
7784 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
7785 assert_eq!(plain, "Skills (0) none");
7786 }
7787
7788 #[test]
7794 fn tab_accept_suggestion_reflects_in_next_render() {
7795 use rpi_tui::render_layout_frame;
7796
7797 let editor = Arc::new(Editor::new(
7798 EditorOptions {
7799 padding_x: 1,
7800 ..Default::default()
7801 },
7802 EditorStyle::default(),
7803 Arc::new(rpi_tui::Keybindings::new()),
7804 ));
7805 editor.set_focused(true);
7806 let editor_container = Arc::new(Container::new());
7807 editor_container.add_child(editor.clone());
7808 let autocomplete_container = Arc::new(Container::new());
7809 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
7810 let dock = Arc::new(VStack::from_children(vec![
7811 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
7812 StackChild::Entry(
7813 StackEntry::new(editor_container.clone())
7814 .shrink(0)
7815 .min_size(3),
7816 ),
7817 StackChild::Entry(StackEntry::new(footer)),
7818 ]));
7819
7820 let mut manager = AutocompleteManager::new();
7822 let mut combined = CombinedAutocompleteProvider::new();
7823 combined.add_provider(Arc::new(
7824 SlashCommandAutocompleteProvider::with_default_commands(),
7825 ));
7826 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
7827 manager.set_provider(Arc::new(combined));
7828 editor.insert("/mo");
7831 assert_eq!(editor.cursor_position(), (0, 3));
7832
7833 let frame_before = render_layout_frame(dock.clone(), 80, 10);
7834 assert!(
7835 frame_before.lines.iter().any(|l| l.contains("/mo")),
7836 "precondition: editor shows the typed prefix. Frame rows:\n{}",
7837 frame_before
7838 .lines
7839 .iter()
7840 .map(|l| format!(" [{l}]"))
7841 .collect::<Vec<_>>()
7842 .join("\n")
7843 );
7844
7845 let text = editor.get_text();
7847 let (_row, col) = editor.cursor_position();
7848 let cursor = col.min(text.len());
7849 let sugg = manager
7850 .get_suggestions(&text, cursor)
7851 .expect("slash suggestions for /mo");
7852 let top = sugg.items.first().expect("at least one suggestion");
7853 let start = sugg.start.min(text.len());
7854 let end = sugg.end.min(text.len());
7855 let mut replaced = String::new();
7856 replaced.push_str(&text[..start]);
7857 replaced.push_str(&top.text);
7858 replaced.push_str(&text[end..]);
7859 if top.insert_space && !replaced.ends_with('/') {
7860 replaced.push(' ');
7861 }
7862 editor.set_text(&replaced);
7863 editor.set_cursor(0, replaced.len().min(start + top.text.len()));
7864 autocomplete_container.clear();
7865 assert_eq!(editor.get_text(), "/model");
7866
7867 let frame_after = render_layout_frame(dock, 80, 10);
7869 let all: String = frame_after.lines.join("\n");
7870 assert!(
7871 all.contains("/model"),
7872 "completed text missing from next render. Got:\n{all}"
7873 );
7874 let editor_line = frame_after
7878 .lines
7879 .iter()
7880 .find(|l| l.contains("/model"))
7881 .expect("editor row with completed text");
7882 assert!(
7883 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
7884 "caret must follow the full completed text. Got: {editor_line:?}"
7885 );
7886 }
7887
7888 #[test]
7889 fn test_slash_command_dispatch() {
7890 let registry = build_builtin_registry();
7895
7896 let resolves_to = |token: &str, canonical: &str| {
7898 let found = registry.find(token).expect("{token} should resolve");
7899 assert_eq!(
7900 found.name(),
7901 canonical,
7902 "{token} resolved to {} (expected {canonical})",
7903 found.name()
7904 );
7905 };
7906
7907 resolves_to("/help", "/help");
7908 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
7910 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
7914 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
7916 resolves_to("/hotkeys", "/hotkeys");
7917 resolves_to("/model", "/model");
7918 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
7920 resolves_to("/session", "/session");
7921 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
7923 resolves_to("/copy", "/copy");
7924 resolves_to("/thinking", "/thinking");
7925 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
7927 resolves_to("/images", "/images");
7928 resolves_to("/armin", "/armin");
7929 resolves_to("/earendil", "/earendil");
7930 resolves_to("/context", "/context");
7931 resolves_to("/settings", "/settings");
7933 resolves_to("/name", "/name");
7934 resolves_to("/export", "/export");
7935
7936 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
7938 }
7939
7940 #[test]
7941
7942 fn test_registry_visible_entries_cover_dispatch() {
7943 let registry = build_builtin_registry();
7948 let names: Vec<String> = registry
7949 .visible_entries()
7950 .iter()
7951 .map(|c| c.name.clone())
7952 .collect();
7953 for recognized in [
7954 "/help",
7955 "/clear",
7956 "/new",
7957 "/exit",
7958 "/quit",
7959 "/version",
7960 "/changelog",
7961 "/model",
7962 "/session",
7963 "/theme",
7964 "/compact",
7965 "/copy",
7966 "/hotkeys",
7967 "/tools",
7968 "/images",
7969 "/thinking",
7970 "/armin",
7971 "/earendil",
7972 ] {
7973 assert!(
7974 names.contains(&recognized.to_string()),
7975 "{recognized} missing from autocomplete list"
7976 );
7977 }
7978 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
7980 assert!(
7981 !names.contains(&hidden.to_string()),
7982 "{hidden} should be hidden from autocomplete"
7983 );
7984 }
7985 }
7986
7987 #[test]
7988 fn test_agent_event_mapping_creates_assistant_and_tool() {
7989 use rpi_ai::types::{
7992 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
7993 ToolCall, ToolCallType, Usage,
7994 };
7995
7996 let state = Arc::new(TuiState {
7997 current_assistant: std::sync::Mutex::new(None),
7998 tool_components: std::sync::Mutex::new(HashMap::new()),
7999 bash_components: std::sync::Mutex::new(HashMap::new()),
8000 themes_enabled: true,
8001 hide_thinking: std::sync::Mutex::new(false),
8002 tool_outputs_expanded: std::sync::Mutex::new(false),
8003 show_terminal_progress: true,
8004 status: std::sync::Mutex::new(RunStatus::Idle),
8005 js_preparation_cancel: std::sync::Mutex::new(None),
8006 footer: Arc::new(FooterComponent::new()),
8007 status_container: Arc::new(Container::new()),
8008 chat_container: Arc::new(Container::new()),
8009 loader: Arc::new(Loader::new()),
8010 last_assistant_text: std::sync::Mutex::new(String::new()),
8011 active_selector: std::sync::Mutex::new(None),
8012 active_extension_editor: std::sync::Mutex::new(None),
8013 active_extension_input: std::sync::Mutex::new(None),
8014 active_extension_cancel: std::sync::Mutex::new(None),
8015 autocomplete: AutocompleteManager::new(),
8016 autocomplete_container: Arc::new(Container::new()),
8017 autocomplete_max_visible: 5,
8018 pending_images: std::sync::Mutex::new(Vec::new()),
8019 theme_manager: Arc::new(ThemeManager::new()),
8020 tui: None,
8021 current_model_id: std::sync::Mutex::new(String::new()),
8022 show_images: std::sync::Mutex::new(true),
8023 history: std::sync::Mutex::new(Vec::new()),
8024 history_index: std::sync::Mutex::new(-1),
8025 history_draft: std::sync::Mutex::new(None),
8026 last_input_tokens: std::sync::Mutex::new(0),
8027 scoped_edit: std::sync::Mutex::new(None),
8028 markdown_transformer: std::sync::Mutex::new(None),
8029 extension_session: Arc::new(std::sync::Mutex::new(
8030 rpi_extensions::ExtensionSession::none(),
8031 )),
8032 });
8033
8034 let assistant = AssistantMessage {
8038 role: rpi_ai::types::AssistantRole,
8039 content: vec![
8040 Content::Thinking(ThinkingContent {
8041 kind: ThinkingContentType,
8042 thinking: "Reasoning about the reply.".into(),
8043 thinking_signature: None,
8044 redacted: false,
8045 }),
8046 Content::Text(TextContent {
8047 kind: TextContentType,
8048 text: "Hello.".into(),
8049 text_signature: None,
8050 }),
8051 Content::ToolCall(ToolCall {
8052 kind: ToolCallType,
8053 id: "tc1".into(),
8054 name: "bash".into(),
8055 arguments: serde_json::json!({"command": "echo hi"}),
8056 thought_signature: None,
8057 namespace: None,
8058 }),
8059 ],
8060 api: rpi_ai::Api::AnthropicMessages,
8061 provider: "anthropic".into(),
8062 model: "claude-sonnet-5".into(),
8063 response_model: None,
8064 response_id: None,
8065 usage: Usage::zero(),
8066 stop_reason: StopReason::Stop,
8067 deferred: None,
8068 error_message: None,
8069 raw_stop_reason: None,
8070 end_turn: None,
8071 timestamp: 0,
8072 };
8073
8074 let comp = Arc::new(AssistantMessageComponent::new(
8077 AssistantMessageOptions::default(),
8078 ));
8079 comp.set_streaming(true);
8080 comp.update_blocks(&assistant_blocks(&assistant));
8081 let chat = Arc::new(Container::new());
8082 chat.add_child(comp.clone());
8083 *state.current_assistant.lock().unwrap() = Some(comp);
8084
8085 for c in &assistant.content {
8087 if let Content::ToolCall(tc) = c {
8088 let mut tools = state.tool_components.lock().unwrap();
8089 if !tools.contains_key(&tc.id) {
8090 let tc_comp = Arc::new(ToolExecutionComponent::new(
8091 &tc.name,
8092 &tc.arguments.to_string(),
8093 ));
8094 tc_comp.set_running();
8095 chat.add_child(tc_comp.clone());
8096 tools.insert(tc.id.clone(), tc_comp);
8097 }
8098 }
8099 }
8100
8101 let rendered = chat.render(80);
8105 let joined: String = rendered.join("\n");
8106 assert!(
8107 joined.contains("Hello."),
8108 "assistant text not rendered: {joined}"
8109 );
8110 assert!(
8111 joined.contains("Reasoning about the reply."),
8112 "thinking block not rendered: {joined}"
8113 );
8114 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
8115 assert!(state.current_assistant.lock().unwrap().is_some());
8116
8117 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
8119 ended.set_result("hi", false);
8120 assert!(state.tool_components.lock().unwrap().is_empty());
8121
8122 assert!(state.try_start_working());
8126 assert!(
8127 !state.try_start_working(),
8128 "a second submit must be rejected"
8129 );
8130 state.set_status(RunStatus::Idle);
8131 state.set_status(RunStatus::Working);
8132 assert_eq!(state.status_container.child_count(), 1);
8133 {
8134 let mut bash = state.bash_components.lock().unwrap();
8135 bash.insert(
8136 "bash-1".into(),
8137 Arc::new(BashExecutionComponent::new("one")),
8138 );
8139 bash.insert(
8140 "bash-2".into(),
8141 Arc::new(BashExecutionComponent::new("two")),
8142 );
8143 }
8144 state.sync_working_loader_with_bash();
8145 assert_eq!(state.status_container.child_count(), 0);
8146 state.bash_components.lock().unwrap().remove("bash-1");
8147 state.sync_working_loader_with_bash();
8148 assert_eq!(state.status_container.child_count(), 0);
8149 state.bash_components.lock().unwrap().remove("bash-2");
8150 state.sync_working_loader_with_bash();
8151 assert_eq!(state.status_container.child_count(), 1);
8152
8153 state.set_status(RunStatus::Aborting);
8154 assert_eq!(state.status_container.child_count(), 0);
8155 assert!(!state.loader.is_running());
8156 }
8157
8158 #[test]
8159 fn fresh_launch_does_not_restore_old_history() {
8160 let fresh = Args::default();
8161 assert!(!launch_restores_history(&fresh));
8162
8163 let continued = Args {
8164 continue_session: true,
8165 ..Args::default()
8166 };
8167 assert!(launch_restores_history(&continued));
8168
8169 let selected = Args {
8170 session: Some("session-id".into()),
8171 ..Args::default()
8172 };
8173 assert!(launch_restores_history(&selected));
8174 }
8175
8176 #[test]
8177 fn test_short_model_name() {
8178 assert_eq!(
8179 short_model_name("anthropic:claude-sonnet-5"),
8180 "claude-sonnet-5"
8181 );
8182 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
8183 }
8184
8185 #[test]
8186 fn model_selector_items_are_deduplicated_and_provider_qualified() {
8187 use rpi_ai::{Api, Model};
8188
8189 let mut gateway = Model::new(
8190 "gpt-5.6-sol",
8191 "GPT 5.6 Sol",
8192 Api::OpenaiCompletions,
8193 "routeryo-copy",
8194 "https://gateway.example.com",
8195 );
8196 let duplicate = gateway.clone();
8197 let anthropic = Model::new(
8198 "claude-sonnet-5",
8199 "Claude Sonnet 5",
8200 Api::AnthropicMessages,
8201 "anthropic",
8202 "https://api.anthropic.com",
8203 );
8204 gateway.headers = Some(std::collections::BTreeMap::from([(
8205 "authorization".into(),
8206 "Bearer test".into(),
8207 )]));
8208
8209 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
8210 assert_eq!(items.len(), 2);
8211 assert_eq!(items[0].value, "gpt-5.6-sol");
8212 assert_eq!(items[0].label, "GPT 5.6 Sol");
8213 assert_eq!(
8214 items[0].description.as_deref(),
8215 Some("routeryo-copy/gpt-5.6-sol (current)")
8216 );
8217 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
8218 }
8219
8220 #[test]
8221 fn model_selector_match_accepts_bare_and_qualified_ids() {
8222 use rpi_ai::{Api, Model};
8223
8224 let gateway = Model::new(
8225 "gpt-5.6-sol",
8226 "GPT 5.6 Sol",
8227 Api::OpenaiCompletions,
8228 "routeryo-copy",
8229 "https://gateway.example.com",
8230 );
8231 let anthropic = Model::new(
8232 "claude-sonnet-5",
8233 "Claude Sonnet 5",
8234 Api::AnthropicMessages,
8235 "anthropic",
8236 "https://api.anthropic.com",
8237 );
8238 let catalog = [gateway, anthropic];
8239 assert_eq!(
8240 find_model_selector_match(&catalog, "gpt-5.6-sol")
8241 .unwrap()
8242 .provider,
8243 "routeryo-copy"
8244 );
8245 assert_eq!(
8246 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
8247 .unwrap()
8248 .id,
8249 "gpt-5.6-sol"
8250 );
8251 assert_eq!(
8252 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
8253 .unwrap()
8254 .id,
8255 "claude-sonnet-5"
8256 );
8257 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
8258 }
8259
8260 #[test]
8261 fn assistant_error_text_keeps_provider_diagnostic_visible() {
8262 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
8263
8264 let failed = AssistantMessage {
8265 role: AssistantRole,
8266 content: Vec::new(),
8267 api: rpi_ai::Api::AnthropicMessages,
8268 provider: "anthropic".into(),
8269 model: "claude-sonnet-5".into(),
8270 response_model: None,
8271 response_id: None,
8272 usage: Usage::zero(),
8273 stop_reason: StopReason::Error,
8274 deferred: None,
8275 error_message: Some("upstream returned 401".into()),
8276 raw_stop_reason: None,
8277 end_turn: None,
8278 timestamp: 0,
8279 };
8280 assert_eq!(
8281 assistant_error_text(&failed).as_deref(),
8282 Some("upstream returned 401")
8283 );
8284
8285 let mut no_detail = failed;
8286 no_detail.error_message = Some(" ".into());
8287 assert_eq!(
8288 assistant_error_text(&no_detail).as_deref(),
8289 Some("Provider request failed.")
8290 );
8291 }
8292
8293 #[test]
8294 fn test_cycle_next_model_wraps_around() {
8295 use rpi_ai::{Api, Model};
8296 let mk = |id: &str| {
8297 Model::new(
8298 id,
8299 id,
8300 Api::AnthropicMessages,
8301 "anthropic",
8302 "https://api.anthropic.com",
8303 )
8304 };
8305 let catalog = [mk("a"), mk("b"), mk("c")];
8306 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
8308 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
8309 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
8311 let empty: Vec<Model> = vec![];
8313 assert!(cycle_next_model(&empty, "a").is_none());
8314 }
8315
8316 #[test]
8317 fn test_autocomplete_slash_suggestions_render() {
8318 let state = Arc::new(TuiState {
8321 current_assistant: std::sync::Mutex::new(None),
8322 tool_components: std::sync::Mutex::new(HashMap::new()),
8323 bash_components: std::sync::Mutex::new(HashMap::new()),
8324 themes_enabled: true,
8325 hide_thinking: std::sync::Mutex::new(false),
8326 tool_outputs_expanded: std::sync::Mutex::new(false),
8327 show_terminal_progress: true,
8328 status: std::sync::Mutex::new(RunStatus::Idle),
8329 js_preparation_cancel: std::sync::Mutex::new(None),
8330 footer: Arc::new(FooterComponent::new()),
8331 status_container: Arc::new(Container::new()),
8332 chat_container: Arc::new(Container::new()),
8333 loader: Arc::new(Loader::new()),
8334 last_assistant_text: std::sync::Mutex::new(String::new()),
8335 active_selector: std::sync::Mutex::new(None),
8336 active_extension_editor: std::sync::Mutex::new(None),
8337 active_extension_input: std::sync::Mutex::new(None),
8338 active_extension_cancel: std::sync::Mutex::new(None),
8339 autocomplete: AutocompleteManager::new(),
8340 autocomplete_container: Arc::new(Container::new()),
8341 autocomplete_max_visible: 5,
8342 pending_images: std::sync::Mutex::new(Vec::new()),
8343 theme_manager: Arc::new(ThemeManager::new()),
8344 tui: None,
8345 current_model_id: std::sync::Mutex::new(String::new()),
8346 show_images: std::sync::Mutex::new(true),
8347 history: std::sync::Mutex::new(Vec::new()),
8348 history_index: std::sync::Mutex::new(-1),
8349 history_draft: std::sync::Mutex::new(None),
8350 last_input_tokens: std::sync::Mutex::new(0),
8351 scoped_edit: std::sync::Mutex::new(None),
8352 markdown_transformer: std::sync::Mutex::new(None),
8353 extension_session: Arc::new(std::sync::Mutex::new(
8354 rpi_extensions::ExtensionSession::none(),
8355 )),
8356 });
8357 {
8358 let mut combined = CombinedAutocompleteProvider::new();
8359 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
8360 build_builtin_registry().visible_entries(),
8361 )));
8362 state.autocomplete.set_provider(Arc::new(combined));
8363 }
8364
8365 let editor = Arc::new(Editor::simple());
8366 editor.set_text("/he");
8367 editor.set_cursor(0, 3);
8368 refresh_autocomplete(&state, &editor);
8369 let lines = state.autocomplete_container.render(80);
8370 let joined: String = lines.join("\n");
8371 assert!(
8372 joined.contains("/help"),
8373 "slash suggestions not rendered: {joined}"
8374 );
8375
8376 editor.set_text("hello");
8378 editor.set_cursor(0, 5);
8379 refresh_autocomplete(&state, &editor);
8380 assert!(state.autocomplete_container.render(80).is_empty());
8381 }
8382
8383 #[test]
8384 fn test_select_list_swap_restores_editor() {
8385 let state = Arc::new(TuiState {
8389 current_assistant: std::sync::Mutex::new(None),
8390 tool_components: std::sync::Mutex::new(HashMap::new()),
8391 bash_components: std::sync::Mutex::new(HashMap::new()),
8392 themes_enabled: true,
8393 hide_thinking: std::sync::Mutex::new(false),
8394 tool_outputs_expanded: std::sync::Mutex::new(false),
8395 show_terminal_progress: true,
8396 status: std::sync::Mutex::new(RunStatus::Idle),
8397 js_preparation_cancel: std::sync::Mutex::new(None),
8398 footer: Arc::new(FooterComponent::new()),
8399 status_container: Arc::new(Container::new()),
8400 chat_container: Arc::new(Container::new()),
8401 loader: Arc::new(Loader::new()),
8402 last_assistant_text: std::sync::Mutex::new(String::new()),
8403 active_selector: std::sync::Mutex::new(None),
8404 active_extension_editor: std::sync::Mutex::new(None),
8405 active_extension_input: std::sync::Mutex::new(None),
8406 active_extension_cancel: std::sync::Mutex::new(None),
8407 autocomplete: AutocompleteManager::new(),
8408 autocomplete_container: Arc::new(Container::new()),
8409 autocomplete_max_visible: 5,
8410 pending_images: std::sync::Mutex::new(Vec::new()),
8411 theme_manager: Arc::new(ThemeManager::new()),
8412 tui: None,
8413 current_model_id: std::sync::Mutex::new(String::new()),
8414 show_images: std::sync::Mutex::new(true),
8415 history: std::sync::Mutex::new(Vec::new()),
8416 history_index: std::sync::Mutex::new(-1),
8417 history_draft: std::sync::Mutex::new(None),
8418 last_input_tokens: std::sync::Mutex::new(0),
8419 scoped_edit: std::sync::Mutex::new(None),
8420 markdown_transformer: std::sync::Mutex::new(None),
8421 extension_session: Arc::new(std::sync::Mutex::new(
8422 rpi_extensions::ExtensionSession::none(),
8423 )),
8424 });
8425 let editor_container = Arc::new(Container::new());
8426 let editor = Arc::new(Editor::simple());
8427 editor_container.add_child(editor.clone());
8428 assert!(!state.selector_open());
8429
8430 let tui_terminal = Box::new(ProcessTerminal::new());
8431 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
8432 let list = Arc::new(SelectList::new(
8433 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
8434 5,
8435 ));
8436 open_selector(
8437 &state,
8438 &editor_container,
8439 &editor,
8440 &tui,
8441 list,
8442 SelectorKind::Theme,
8443 );
8444 assert!(state.selector_open());
8445 assert_eq!(editor_container.child_count(), 1);
8447
8448 close_selector(&state, &editor_container, &editor, &tui);
8449 assert!(!state.selector_open());
8450 assert_eq!(editor_container.child_count(), 1);
8452 }
8453
8454 #[test]
8455 fn test_message_history_browse_restores_draft() {
8456 let state = Arc::new(TuiState {
8460 current_assistant: std::sync::Mutex::new(None),
8461 tool_components: std::sync::Mutex::new(HashMap::new()),
8462 bash_components: std::sync::Mutex::new(HashMap::new()),
8463 themes_enabled: true,
8464 hide_thinking: std::sync::Mutex::new(false),
8465 tool_outputs_expanded: std::sync::Mutex::new(false),
8466 show_terminal_progress: true,
8467 status: std::sync::Mutex::new(RunStatus::Idle),
8468 js_preparation_cancel: std::sync::Mutex::new(None),
8469 footer: Arc::new(FooterComponent::new()),
8470 status_container: Arc::new(Container::new()),
8471 chat_container: Arc::new(Container::new()),
8472 loader: Arc::new(Loader::new()),
8473 last_assistant_text: std::sync::Mutex::new(String::new()),
8474 active_selector: std::sync::Mutex::new(None),
8475 active_extension_editor: std::sync::Mutex::new(None),
8476 active_extension_input: std::sync::Mutex::new(None),
8477 active_extension_cancel: std::sync::Mutex::new(None),
8478 autocomplete: AutocompleteManager::new(),
8479 autocomplete_container: Arc::new(Container::new()),
8480 autocomplete_max_visible: 5,
8481 pending_images: std::sync::Mutex::new(Vec::new()),
8482 theme_manager: Arc::new(ThemeManager::new()),
8483 tui: None,
8484 current_model_id: std::sync::Mutex::new(String::new()),
8485 show_images: std::sync::Mutex::new(true),
8486 history: std::sync::Mutex::new(Vec::new()),
8487 history_index: std::sync::Mutex::new(-1),
8488 history_draft: std::sync::Mutex::new(None),
8489 last_input_tokens: std::sync::Mutex::new(0),
8490 scoped_edit: std::sync::Mutex::new(None),
8491 markdown_transformer: std::sync::Mutex::new(None),
8492 extension_session: Arc::new(std::sync::Mutex::new(
8493 rpi_extensions::ExtensionSession::none(),
8494 )),
8495 });
8496 let editor = Arc::new(Editor::simple());
8497
8498 push_history(&state, "first message");
8499 push_history(&state, "second message");
8500 push_history(&state, "second message");
8502 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
8504 assert_eq!(state.history.lock().unwrap()[0], "second message");
8505
8506 editor.set_text("half-typed");
8508 editor.set_cursor(0, 11);
8509
8510 navigate_history(&state, &editor, -1);
8512 assert_eq!(editor.get_text(), "second message");
8513 assert_eq!(*state.history_index.lock().unwrap(), 0);
8514 navigate_history(&state, &editor, -1);
8516 assert_eq!(editor.get_text(), "first message");
8517 assert_eq!(*state.history_index.lock().unwrap(), 1);
8518 navigate_history(&state, &editor, -1);
8520 assert_eq!(editor.get_text(), "first message");
8521 navigate_history(&state, &editor, 1);
8523 assert_eq!(editor.get_text(), "second message");
8524 navigate_history(&state, &editor, 1);
8526 assert_eq!(editor.get_text(), "half-typed");
8527 assert_eq!(*state.history_index.lock().unwrap(), -1);
8528 }
8529
8530 #[test]
8531 fn test_accept_top_suggestion_replaces_prefix() {
8532 let state = Arc::new(TuiState {
8534 current_assistant: std::sync::Mutex::new(None),
8535 tool_components: std::sync::Mutex::new(HashMap::new()),
8536 bash_components: std::sync::Mutex::new(HashMap::new()),
8537 themes_enabled: true,
8538 hide_thinking: std::sync::Mutex::new(false),
8539 tool_outputs_expanded: std::sync::Mutex::new(false),
8540 show_terminal_progress: true,
8541 status: std::sync::Mutex::new(RunStatus::Idle),
8542 js_preparation_cancel: std::sync::Mutex::new(None),
8543 footer: Arc::new(FooterComponent::new()),
8544 status_container: Arc::new(Container::new()),
8545 chat_container: Arc::new(Container::new()),
8546 loader: Arc::new(Loader::new()),
8547 last_assistant_text: std::sync::Mutex::new(String::new()),
8548 active_selector: std::sync::Mutex::new(None),
8549 active_extension_editor: std::sync::Mutex::new(None),
8550 active_extension_input: std::sync::Mutex::new(None),
8551 active_extension_cancel: std::sync::Mutex::new(None),
8552 autocomplete: AutocompleteManager::new(),
8553 autocomplete_container: Arc::new(Container::new()),
8554 autocomplete_max_visible: 5,
8555 pending_images: std::sync::Mutex::new(Vec::new()),
8556 theme_manager: Arc::new(ThemeManager::new()),
8557 tui: None,
8558 current_model_id: std::sync::Mutex::new(String::new()),
8559 show_images: std::sync::Mutex::new(true),
8560 history: std::sync::Mutex::new(Vec::new()),
8561 history_index: std::sync::Mutex::new(-1),
8562 history_draft: std::sync::Mutex::new(None),
8563 last_input_tokens: std::sync::Mutex::new(0),
8564 scoped_edit: std::sync::Mutex::new(None),
8565 markdown_transformer: std::sync::Mutex::new(None),
8566 extension_session: Arc::new(std::sync::Mutex::new(
8567 rpi_extensions::ExtensionSession::none(),
8568 )),
8569 });
8570 {
8571 let mut combined = CombinedAutocompleteProvider::new();
8572 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
8573 build_builtin_registry().visible_entries(),
8574 )));
8575 state.autocomplete.set_provider(Arc::new(combined));
8576 }
8577 let editor = Arc::new(Editor::simple());
8578 editor.set_text("/he");
8579 editor.set_cursor(0, 3);
8580 refresh_autocomplete(&state, &editor);
8581 let accepted = accept_top_suggestion(&state, &editor);
8582 assert!(accepted, "should accept the top suggestion");
8583 let text = editor.get_text();
8584 assert!(
8585 text.starts_with("/help"),
8586 "editor text should start with /help, got {text}"
8587 );
8588 }
8589
8590 #[test]
8591 fn configured_key_parser_supports_native_notation() {
8592 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
8593 assert_eq!(combo.code, KeyCode::Char('g'));
8594 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
8595 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
8596 assert_eq!(combo.code, KeyCode::BackTab);
8597 }
8598
8599 #[test]
8600 fn double_escape_trigger_has_half_second_window() {
8601 let now = std::time::Instant::now();
8602 assert!(!double_escape_trigger(None, now));
8603 assert!(double_escape_trigger(
8604 Some(now - std::time::Duration::from_millis(500)),
8605 now
8606 ));
8607 assert!(!double_escape_trigger(
8608 Some(now - std::time::Duration::from_millis(501)),
8609 now
8610 ));
8611 }
8612}