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, AltScreenSearch, AssistantBlock, AssistantMessageComponent,
44 AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
45 BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
46 EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
47 FooterComponent, Image, ImageOptions, Input, Loader, Markdown, ProcessTerminal, ScrollView,
48 ScrollViewOptions, SearchBar, SearchableSelectList, SelectItem, SelectList, SettingItem,
49 SettingsList, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
50 StackChild, StackEntry, StatusIndicator, Text, ThemeManager, ThemePreset,
51 ToolExecutionComponent, ToolStatus, TuiAltScreen, UserMessageComponent, VStack, WorkingState, TUI,
52};
53use rpi_tui::{bold as tui_bold, theme as current_theme};
54
55#[allow(unused_imports)]
56use rpi_tui::BashStatus;
57
58use crate::args::Args;
59
60type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
67
68#[derive(Clone, Default)]
74struct JsDialogBridge {
75 pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
76 active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
77 visible: Arc<Mutex<Option<String>>>,
81 cancelled_before_open: Arc<Mutex<HashSet<String>>>,
82 closed: Arc<Mutex<bool>>,
83}
84
85struct JsDialogPending {
86 request: JsDialogRequest,
87 result: std_mpsc::Sender<serde_json::Value>,
88}
89
90struct JsDialogActive {
91 result: std_mpsc::Sender<serde_json::Value>,
92 cancel_requested: bool,
93}
94
95#[derive(Clone, Debug)]
96struct JsDialogRequest {
97 id: String,
98 method: String,
99 title: String,
100 message: String,
101 options: Vec<String>,
102 placeholder: Option<String>,
103 prefill: Option<String>,
104}
105
106impl JsDialogRequest {
107 fn parse(args: &serde_json::Value) -> Result<Self, String> {
108 let id = args
109 .get("dialogId")
110 .and_then(serde_json::Value::as_str)
111 .filter(|value| !value.is_empty())
112 .ok_or("ui.dialog missing dialogId")?
113 .to_string();
114 let method = args
115 .get("method")
116 .and_then(serde_json::Value::as_str)
117 .ok_or("ui.dialog missing method")?
118 .to_string();
119 if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
120 return Err(format!("unsupported UI dialog method: {method}"));
121 }
122 let options = args
123 .get("options")
124 .and_then(serde_json::Value::as_array)
125 .map(|values| {
126 values
127 .iter()
128 .filter_map(serde_json::Value::as_str)
129 .map(ToOwned::to_owned)
130 .collect()
131 })
132 .unwrap_or_default();
133 Ok(Self {
134 id,
135 method,
136 title: args
137 .get("title")
138 .and_then(serde_json::Value::as_str)
139 .unwrap_or_default()
140 .to_string(),
141 message: args
142 .get("message")
143 .and_then(serde_json::Value::as_str)
144 .unwrap_or_default()
145 .to_string(),
146 options,
147 placeholder: args
148 .get("placeholder")
149 .and_then(serde_json::Value::as_str)
150 .map(ToOwned::to_owned),
151 prefill: args
152 .get("prefill")
153 .and_then(serde_json::Value::as_str)
154 .map(ToOwned::to_owned),
155 })
156 }
157}
158
159impl JsDialogBridge {
160 fn handle_runtime_request(
161 &self,
162 action: &str,
163 args: serde_json::Value,
164 ) -> Result<serde_json::Value, String> {
165 match action {
166 "ui.dialog" => self.wait_for_dialog(args),
167 "ui.dialog.cancel" => {
168 let id = args
169 .get("dialogId")
170 .and_then(serde_json::Value::as_str)
171 .ok_or("ui.dialog.cancel missing dialogId")?;
172 self.cancel(id);
173 Ok(serde_json::json!(true))
174 }
175 _ => Err(format!("unsupported capability: {action}")),
176 }
177 }
178
179 fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
180 let request = JsDialogRequest::parse(&args)?;
181 let (sender, receiver) = std_mpsc::channel();
182 let _closed = self
186 .closed
187 .lock()
188 .map_err(|_| "JS dialog bridge poisoned")?;
189 if *_closed {
190 return Ok(serde_json::json!({ "cancelled": true }));
191 }
192 let cancelled_before_open = self
193 .cancelled_before_open
194 .lock()
195 .map_err(|_| "JS dialog cancellation state poisoned")?
196 .remove(&request.id);
197 if cancelled_before_open {
198 return Ok(serde_json::json!({ "cancelled": true }));
199 }
200 self.pending
203 .lock()
204 .map_err(|_| "JS dialog pending state poisoned")?
205 .push_back(JsDialogPending {
206 request,
207 result: sender,
208 });
209 drop(_closed);
210 receiver
211 .recv()
212 .map_err(|_| "JS dialog closed before it received an answer".to_string())
213 }
214
215 fn take_pending(&self) -> Option<JsDialogRequest> {
218 loop {
219 let pending = self.pending.lock().ok()?.pop_front()?;
220 let mut active = self.active.lock().ok()?;
221 if self
228 .cancelled_before_open
229 .lock()
230 .ok()?
231 .remove(&pending.request.id)
232 {
233 drop(active);
234 let _ = pending
235 .result
236 .send(serde_json::json!({ "cancelled": true }));
237 continue;
238 }
239 active.insert(
240 pending.request.id.clone(),
241 JsDialogActive {
242 result: pending.result,
243 cancel_requested: false,
244 },
245 );
246 if let Ok(mut visible) = self.visible.lock() {
247 *visible = Some(pending.request.id.clone());
248 }
249 return Some(pending.request);
250 }
251 }
252
253 fn respond(&self, id: &str, result: serde_json::Value) {
254 if let Ok(mut active) = self.active.lock() {
255 if let Some(entry) = active.remove(id) {
256 let _ = entry.result.send(result);
257 }
258 }
259 if let Ok(mut visible) = self.visible.lock() {
260 if visible.as_deref() == Some(id) {
261 *visible = None;
262 }
263 }
264 }
265
266 fn cancel(&self, id: &str) {
267 if let Ok(mut pending) = self.pending.lock() {
268 if let Some(index) = pending.iter().position(|item| item.request.id == id) {
269 if let Some(item) = pending.remove(index) {
270 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
271 return;
272 }
273 }
274 }
275 if let Ok(mut active) = self.active.lock() {
276 if let Some(entry) = active.get_mut(id) {
277 if !entry.cancel_requested {
278 entry.cancel_requested = true;
279 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
280 }
281 return;
282 }
283 }
284 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
285 cancelled.insert(id.to_string());
286 }
287 }
288
289 fn cancelled_active_ids(&self) -> Vec<String> {
290 self.active
291 .lock()
292 .map(|active| {
293 active
294 .iter()
295 .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
296 .collect()
297 })
298 .unwrap_or_default()
299 }
300
301 fn is_visible(&self, id: &str) -> bool {
302 self.visible
303 .lock()
304 .map(|visible| visible.as_deref() == Some(id))
305 .unwrap_or(false)
306 }
307
308 fn finish(&self, id: &str) {
309 if let Ok(mut active) = self.active.lock() {
310 active.remove(id);
311 }
312 if let Ok(mut visible) = self.visible.lock() {
313 if visible.as_deref() == Some(id) {
314 *visible = None;
315 }
316 }
317 }
318
319 fn cancel_all(&self) {
320 let Ok(mut closed) = self.closed.lock() else {
324 return;
325 };
326 *closed = true;
327 if let Ok(mut pending) = self.pending.lock() {
328 for item in pending.drain(..) {
329 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
330 }
331 }
332 if let Ok(mut active) = self.active.lock() {
333 for (_, entry) in active.drain() {
334 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
335 }
336 }
337 if let Ok(mut visible) = self.visible.lock() {
338 *visible = None;
339 }
340 drop(closed);
341 }
342
343 fn cancel_open_requests(&self) {
346 let Ok(mut closed) = self.closed.lock() else {
350 return;
351 };
352 *closed = true;
353 if let Ok(mut pending) = self.pending.lock() {
354 for item in pending.drain(..) {
355 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
356 }
357 }
358 if let Ok(mut active) = self.active.lock() {
359 for (_, entry) in active.drain() {
360 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
361 }
362 }
363 if let Ok(mut visible) = self.visible.lock() {
364 *visible = None;
365 }
366 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
367 cancelled.clear();
368 }
369 drop(closed);
370 }
371
372 fn reopen(&self) {
373 if let Ok(mut closed) = self.closed.lock() {
374 *closed = false;
375 }
376 }
377}
378
379fn build_markdown_transformer(
401 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
402) -> Option<MarkdownTransformer> {
403 let snapshot = snapshot?;
404 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
409 if renderers.is_empty() {
410 return None;
411 }
412 Some(Arc::new(move |raw: &str| -> String {
413 transform_markdown_chain(&snapshot, &renderers, raw)
414 }))
415}
416
417fn transform_markdown_chain(
425 snapshot: &rpi_extensions::RegistrySnapshot,
426 renderers: &[rpi_extensions::RegisteredRenderer],
427 raw: &str,
428) -> String {
429 if !snapshot.is_active() {
434 return raw.to_string();
435 }
436
437 let mut current = raw.to_string();
438 for renderer in renderers {
439 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
440 Ok(s) => s,
441 Err(_) => return current, };
443 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
453 let mut out = rpi_plugin_sdk::StbString::empty();
454 let rc = (renderer.render_fn)(
455 rpi_plugin_sdk::StbStringRef::from_str(&input),
456 &mut out as *mut rpi_plugin_sdk::StbString,
457 renderer.user_data,
458 );
459 let text = if rc == 0 {
460 let s = out.to_string_lossy();
461 Some(s)
462 } else {
463 None
464 };
465 out.free_with(Some(renderer.plugin_free_string));
469 text
470 }));
471 let out_text = match outcome {
472 Ok(Some(s)) => s,
473 Ok(None) => return current, Err(_) => return current, };
479 let next = serde_json::from_str::<serde_json::Value>(&out_text)
482 .ok()
483 .and_then(|v| {
484 v.get("markdown")
485 .and_then(|m| m.as_str())
486 .map(|s| s.to_string())
487 })
488 .unwrap_or(current);
489 current = next;
490 }
491 current
492}
493
494#[derive(Clone)]
518struct CommandContext {
519 chat: Arc<Container>,
520 tui: Arc<TuiAltScreen>,
521 tx: mpsc::UnboundedSender<TuiMessage>,
522 state: Arc<TuiState>,
523 editor: Arc<Editor>,
524 editor_container: Arc<Container>,
525 lane: Arc<dyn AgentLane>,
526 model_catalog: Arc<Vec<rpi_ai::Model>>,
527 lane_model_id: String,
531 cwd: std::path::PathBuf,
532 package_resources: Arc<crate::packages::PackageResources>,
536 resources: Arc<rpi_harness::types::AgentHarnessResources>,
540 reload_context: Arc<crate::session::ReloadContext>,
546 session: Arc<crate::agent_session::AgentSession>,
550}
551
552trait SlashCommand: Send + Sync {
554 fn name(&self) -> &str;
556 fn aliases(&self) -> &'static [&'static str] {
560 &[]
561 }
562 fn visible(&self) -> bool {
565 true
566 }
567 fn alias_visible(&self) -> &'static [&'static str] {
571 &[]
572 }
573 fn description(&self) -> &'static str {
576 ""
577 }
578 fn description_owned(&self) -> String {
579 self.description().to_string()
580 }
581 fn execute(&self, ctx: &CommandContext, args: &str);
587}
588
589struct CommandRegistry {
592 commands: Vec<Arc<dyn SlashCommand>>,
593}
594
595impl CommandRegistry {
596 fn new() -> Self {
597 Self {
598 commands: Vec::new(),
599 }
600 }
601
602 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
603 self.commands.push(cmd);
604 }
605
606 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
609 self.commands
610 .iter()
611 .find(|c| c.name() == token || c.aliases().contains(&token))
612 }
613
614 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
620 let mut out: Vec<SlashCommandEntry> = Vec::new();
621 for c in &self.commands {
622 let description = c.description_owned();
623 if c.visible() && !description.is_empty() {
624 out.push(SlashCommandEntry {
625 name: c.name().into(),
626 description: description.clone(),
627 });
628 }
629 for alias in c.alias_visible() {
631 out.push(SlashCommandEntry {
632 name: (*alias).into(),
633 description: description.clone(),
634 });
635 }
636 }
637 out
638 }
639}
640
641fn dispatch_ui_prompt_event(state: &TuiState) {
643 use rpi_plugin_sdk::EventTag;
644
645 if let Ok(session) = state.extension_session.lock() {
646 if let Some(snapshot) = session.snapshot_arc() {
647 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
648 }
649 }
650}
651
652fn dispatch_ui_prompt_event_end(state: &TuiState) {
654 use rpi_plugin_sdk::EventTag;
655
656 if let Ok(session) = state.extension_session.lock() {
657 if let Some(snapshot) = session.snapshot_arc() {
658 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
659 }
660 }
661}
662
663fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
667 let mut parts = text.split_whitespace();
668 let token = parts.next().unwrap_or("");
669 let args = parts.collect::<Vec<_>>().join(" ");
670 match registry.find(token) {
671 Some(cmd) => cmd.execute(ctx, &args),
672 None => {
673 add_error_message(
674 &ctx.chat,
675 &format!("Unknown command: {text}. Type /help for available commands."),
676 );
677 ctx.tui.request_render(false);
678 }
679 }
680}
681
682fn now_ms() -> i64 {
686 std::time::SystemTime::now()
687 .duration_since(std::time::UNIX_EPOCH)
688 .map(|d| d.as_millis() as i64)
689 .unwrap_or(0)
690}
691
692fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
699 let rest = text.strip_prefix('!')?;
700 let (command, exclude_from_context) = match rest.strip_prefix('!') {
701 Some(command) => (command, true),
702 None => (rest, false),
703 };
704 let command = command.trim();
705 if command.is_empty() {
706 return None;
707 }
708 Some((command, exclude_from_context))
709}
710
711fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
719 let comp = Arc::new(BashExecutionComponent::new_with_context(
720 command,
721 exclude_from_context,
722 ));
723 comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
724 ctx.chat.add_child(comp.clone());
725
726 let cancel = ctx.state.begin_user_bash();
729 ctx.tui.request_render(false);
730
731 let env: Arc<dyn rpi_tools::ExecutionEnv> =
732 Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
733 let tui = ctx.tui.clone();
734 let tx = ctx.tx.clone();
735 let cwd = ctx.cwd.clone();
736 let command_owned = command.to_string();
737
738 tokio::spawn(async move {
739 let comp_chunk = comp.clone();
740 let tui_chunk = tui.clone();
741 let on_chunk: Box<
742 dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
743 > = Box::new(move |_chunk, get_progress| {
744 comp_chunk.append_output(&get_progress().output);
747 tui_chunk.request_render(false);
748 });
749 let options = rpi_tools::shell_output::ShellCaptureOptions {
750 cwd: Some(cwd),
751 cancel: Some(&cancel),
752 on_chunk: Some(on_chunk),
753 return_execution_errors: true,
754 ..Default::default()
755 };
756 let result =
757 rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
758 .await;
759
760 let (exit_code, cancelled, output, truncated, full_output_path) = match result {
763 Ok(capture) => (
764 capture.exit_code,
765 capture.cancelled,
766 capture.output.clone(),
767 capture.truncated,
768 capture
769 .full_output_path
770 .as_ref()
771 .map(|path| path.to_string_lossy().into_owned()),
772 ),
773 Err(error) => (
774 None,
775 false,
776 format!("Bash command failed: {error}"),
777 false,
778 None,
779 ),
780 };
781 comp.append_output(&output);
782 comp.set_complete(
783 exit_code,
784 cancelled,
785 BashTruncation {
786 truncated,
787 full_output_path: full_output_path.clone(),
788 },
789 );
790 tui.render_now(false);
791
792 let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
793 command: command_owned,
794 output,
795 exit_code,
796 cancelled,
797 truncated,
798 full_output_path,
799 exclude_from_context,
800 }));
801 });
802}
803
804fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
809 use crossterm::event::{KeyCode, KeyModifiers};
810
811 let modifiers = key.modifiers;
812 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
813 let shift = modifiers.contains(KeyModifiers::SHIFT);
814 let alt = modifiers.contains(KeyModifiers::ALT);
815 let super_key = modifiers.contains(KeyModifiers::SUPER);
816
817 if modifiers == KeyModifiers::NONE {
818 return match key.code {
819 KeyCode::Char(ch) => ch.to_string(),
820 KeyCode::Enter => "\r".into(),
821 KeyCode::Esc => "\x1b".into(),
822 KeyCode::Backspace => "\x7f".into(),
823 KeyCode::Tab => "\t".into(),
824 KeyCode::BackTab => "\x1b[Z".into(),
828 KeyCode::Up => "\x1b[A".into(),
829 KeyCode::Down => "\x1b[B".into(),
830 KeyCode::Right => "\x1b[C".into(),
831 KeyCode::Left => "\x1b[D".into(),
832 KeyCode::Home => "\x1b[H".into(),
833 KeyCode::End => "\x1b[F".into(),
834 KeyCode::PageUp => "\x1b[5~".into(),
835 KeyCode::PageDown => "\x1b[6~".into(),
836 KeyCode::Delete => "\x1b[3~".into(),
837 KeyCode::Insert => "\x1b[2~".into(),
838 KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
839 _ => String::new(),
840 };
841 }
842
843 if ctrl && !shift && !alt && !super_key {
846 if let KeyCode::Char(ch) = key.code {
847 if let Some(code) = control_code(ch) {
848 return char::from(code).to_string();
849 }
850 }
851 }
852
853 if alt && !ctrl && !shift && !super_key {
856 if let KeyCode::Char(ch) = key.code {
857 return format!("\x1b{ch}");
858 }
859 }
860
861 if shift && !ctrl && !alt && !super_key {
867 if let KeyCode::Char(ch) = key.code {
868 return ch.to_string();
869 }
870 }
871
872 if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
873 return sequence;
874 }
875 let Some(codepoint) = key_codepoint(key.code, ctrl) else {
876 return String::new();
877 };
878 kitty_key_sequence(codepoint, modifiers)
879}
880
881fn control_code(ch: char) -> Option<u8> {
882 let ch = ch.to_ascii_lowercase();
883 Some(match ch {
884 '@' | ' ' => 0,
885 'a'..='z' => (ch as u8) & 0x1f,
886 '[' => 0x1b,
887 '\\' => 0x1c,
888 ']' => 0x1d,
889 '^' => 0x1e,
890 '_' | '-' => 0x1f,
891 _ => return None,
892 })
893}
894
895fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
896 use crossterm::event::KeyCode;
897 Some(match code {
898 KeyCode::Char(ch) => {
899 if ctrl {
900 ch.to_ascii_lowercase() as u32
901 } else {
902 ch as u32
903 }
904 }
905 KeyCode::Enter => 13,
906 KeyCode::Esc => 27,
907 KeyCode::Backspace => 127,
908 KeyCode::Tab => 9,
909 KeyCode::BackTab => 9,
912 _ => return None,
913 })
914}
915
916fn modified_functional_sequence(
917 code: crossterm::event::KeyCode,
918 modifiers: crossterm::event::KeyModifiers,
919) -> Option<String> {
920 use crossterm::event::{KeyCode, KeyModifiers};
921 if code == KeyCode::BackTab
925 && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
926 {
927 return Some("\x1b[Z".into());
928 }
929 let modifier = kitty_modifier(modifiers);
930 let sequence = match code {
931 KeyCode::Up => format!("\x1b[1;{modifier}A"),
932 KeyCode::Down => format!("\x1b[1;{modifier}B"),
933 KeyCode::Right => format!("\x1b[1;{modifier}C"),
934 KeyCode::Left => format!("\x1b[1;{modifier}D"),
935 KeyCode::Home => format!("\x1b[1;{modifier}H"),
936 KeyCode::End => format!("\x1b[1;{modifier}F"),
937 KeyCode::Insert => format!("\x1b[2;{modifier}~"),
938 KeyCode::Delete => format!("\x1b[3;{modifier}~"),
939 KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
940 KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
941 _ => return None,
942 };
943 Some(sequence)
944}
945
946fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
947 use crossterm::event::KeyModifiers;
948 let mut modifier = 1u8;
949 if modifiers.contains(KeyModifiers::SHIFT) {
950 modifier += 1;
951 }
952 if modifiers.contains(KeyModifiers::ALT) {
953 modifier += 2;
954 }
955 if modifiers.contains(KeyModifiers::CONTROL) {
956 modifier += 4;
957 }
958 if modifiers.contains(KeyModifiers::SUPER) {
959 modifier += 8;
960 }
961 modifier
962}
963
964fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
965 let modifier = kitty_modifier(modifiers);
966 format!("\x1b[{codepoint};{modifier}u")
967}
968
969struct ExtensionCommand {
974 name: String,
975 description: String,
976 session: crate::session::ExtensionSessionCell,
977}
978
979struct JsExtensionCommand {
980 name: String,
981 session: crate::js_extensions::JsExtensionSession,
982}
983
984impl SlashCommand for JsExtensionCommand {
985 fn name(&self) -> &str {
986 &self.name
987 }
988 fn description(&self) -> &'static str {
989 "JS extension command"
990 }
991 fn description_owned(&self) -> String {
992 "JS extension command".to_string()
993 }
994 fn execute(&self, ctx: &CommandContext, args: &str) {
995 let session = self.session.clone();
1000 let command = self.name.trim_start_matches('/').to_string();
1001 let args = args.to_string();
1002 let ctx = ctx.clone();
1003 let initial_editor_text = ctx.editor.get_text();
1007 tokio::task::spawn_blocking(move || {
1008 match session.invoke_command_with_context(
1009 &command,
1010 &args,
1011 serde_json::json!({"editorText": initial_editor_text}),
1012 ) {
1013 Ok(value) => {
1014 if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
1015 if ctx.editor.get_text() == initial_editor_text
1016 && editor_text != initial_editor_text
1017 {
1018 let cursor = editor_text.chars().count();
1019 ctx.editor.set_text(editor_text);
1020 ctx.editor.set_cursor(0, cursor);
1021 }
1022 }
1023 if let Some(notifications) =
1024 value.get("notifications").and_then(|v| v.as_array())
1025 {
1026 for notification in notifications {
1027 let message = notification
1028 .get("message")
1029 .and_then(|v| v.as_str())
1030 .unwrap_or_default();
1031 if message.is_empty() {
1032 continue;
1033 }
1034 match notification.get("level").and_then(|v| v.as_str()) {
1035 Some("error") => add_error_message(&ctx.chat, message),
1036 _ => add_note_message(&ctx.chat, message),
1037 }
1038 }
1039 }
1040 let result = value.get("result").unwrap_or(&value);
1041 let text = result
1042 .get("text")
1043 .and_then(|item| item.as_str())
1044 .map(str::to_string)
1045 .or_else(|| result.as_str().map(str::to_string))
1046 .filter(|text| !text.is_empty() && text != "null");
1047 if let Some(text) = text {
1048 add_note_message(&ctx.chat, &text);
1049 }
1050 }
1051 Err(error) => {
1052 add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
1053 }
1054 }
1055 ctx.tui.request_render(false);
1056 });
1057 }
1058}
1059
1060impl SlashCommand for ExtensionCommand {
1061 fn name(&self) -> &str {
1062 &self.name
1063 }
1064
1065 fn description(&self) -> &'static str {
1066 "extension command"
1067 }
1068
1069 fn description_owned(&self) -> String {
1070 self.description.clone()
1071 }
1072
1073 fn execute(&self, ctx: &CommandContext, args: &str) {
1074 let result = invoke_extension_command(&self.session, &self.name, args);
1075 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
1076 }
1077}
1078
1079fn invoke_extension_command(
1080 session: &crate::session::ExtensionSessionCell,
1081 name: &str,
1082 args: &str,
1083) -> Option<serde_json::Value> {
1084 let command = session
1085 .lock()
1086 .ok()
1087 .and_then(|s| s.snapshot_arc())
1088 .and_then(|snap| {
1089 snap.commands()
1090 .iter()
1091 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1092 .cloned()
1093 })?;
1094 let input = serde_json::json!({ "args": args, "command": name });
1095 let input = serde_json::to_string(&input).ok()?;
1096 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1097 let mut out = rpi_plugin_sdk::StbString::empty();
1098 let rc = (command.handler)(
1099 rpi_plugin_sdk::StbStringRef::from_str(&input),
1100 &mut out as *mut rpi_plugin_sdk::StbString,
1101 command.user_data,
1102 );
1103 let text = if rc == 0 {
1104 Some(out.to_string_lossy())
1105 } else {
1106 None
1107 };
1108 rpi_extensions::host_free_string(out);
1109 text
1110 }))
1111 .ok()
1112 .flatten()?;
1113 serde_json::from_str(&outcome).ok()
1114}
1115
1116fn handle_extension_ui_result(
1117 result: Option<serde_json::Value>,
1118 ctx: &CommandContext,
1119 session: crate::session::ExtensionSessionCell,
1120 command_name: String,
1121) {
1122 let Some(value) = result else {
1123 add_error_message(&ctx.chat, "Extension command failed.");
1124 ctx.tui.request_render(false);
1125 return;
1126 };
1127 if value.is_null() {
1131 ctx.tui.request_render(false);
1132 return;
1133 }
1134 match value.get("kind").and_then(|v| v.as_str()) {
1135 Some("message") | None => {
1136 let fallback = value.to_string();
1137 let text = value
1138 .get("text")
1139 .and_then(|v| v.as_str())
1140 .unwrap_or(&fallback)
1141 .to_string();
1142 if !text.is_empty() {
1143 add_note_message(&ctx.chat, &text);
1144 }
1145 ctx.tui.request_render(false);
1146 }
1147 Some("selector") => open_extension_selector(ctx, session, command_name, value),
1148 Some("editor") => open_extension_editor(ctx, session, command_name, value),
1149 Some("input") => open_extension_input(ctx, session, command_name, value),
1153 Some(other) => {
1154 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1155 ctx.tui.request_render(false);
1156 }
1157 }
1158}
1159
1160fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1164 let colors = current_theme().colors;
1165 let text = if bold {
1166 tui_bold(title)
1167 } else {
1168 title.to_string()
1169 };
1170 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1171}
1172
1173fn extension_dialog_hint(label: &str) -> Arc<Text> {
1174 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1175}
1176
1177fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1180 let text = if bold {
1181 format!("**{}**", title)
1182 } else {
1183 title.to_string()
1184 };
1185 Arc::new(Markdown::new(text, 1, 0))
1186}
1187
1188fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1191 Arc::new(Markdown::new(label, 1, 0))
1192}
1193
1194fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1198 let callback = state.active_extension_cancel.lock().unwrap().take();
1199 if let Some(callback) = callback {
1200 callback();
1201 true
1202 } else {
1203 false
1204 }
1205}
1206
1207fn open_extension_input(
1208 ctx: &CommandContext,
1209 session: crate::session::ExtensionSessionCell,
1210 command_name: String,
1211 value: serde_json::Value,
1212) {
1213 let title = value
1214 .get("title")
1215 .and_then(|v| v.as_str())
1216 .filter(|title| !title.is_empty())
1217 .unwrap_or("Input");
1218 let input = value
1219 .get("placeholder")
1220 .and_then(|v| v.as_str())
1221 .map(Input::with_placeholder)
1222 .unwrap_or_default();
1223 let input = Arc::new(input);
1224 if let Some(initial) = value
1225 .get("initialText")
1226 .or_else(|| value.get("text"))
1227 .and_then(|v| v.as_str())
1228 {
1229 input.set_value(initial);
1230 }
1231 input.set_focused(true);
1232
1233 let frame = Arc::new(Container::new());
1234 frame.add_child(Arc::new(DynamicBorder::new()));
1235 frame.add_child(Arc::new(Spacer::new(1)));
1236 frame.add_child(extension_dialog_title(title, false));
1237 frame.add_child(Arc::new(Spacer::new(1)));
1238 frame.add_child(input.clone());
1239 frame.add_child(Arc::new(Spacer::new(1)));
1240 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1241 frame.add_child(Arc::new(Spacer::new(1)));
1242 frame.add_child(Arc::new(DynamicBorder::new()));
1243
1244 *ctx.state.active_extension_editor.lock().unwrap() = None;
1245 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1246 ctx.editor_container.clear();
1247 ctx.editor_container.add_child(frame);
1248
1249 let state = ctx.state.clone();
1250 let ec = ctx.editor_container.clone();
1251 let original = ctx.editor.clone();
1252 let tui = ctx.tui.clone();
1253 let session_submit = session.clone();
1254 let command_submit = command_name.clone();
1255 let ctx_submit = ctx.clone();
1256 input.on_submit(Arc::new(move |text| {
1257 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1258 let result = invoke_extension_command(
1259 &session_submit,
1260 &command_submit,
1261 &serde_json::to_string(&args).unwrap_or_default(),
1262 );
1263 close_extension_editor(&state, &ec, &original, &tui);
1264 handle_extension_ui_result(
1265 result,
1266 &ctx_submit,
1267 session_submit.clone(),
1268 command_submit.clone(),
1269 );
1270 }));
1271
1272 let state_cancel = ctx.state.clone();
1273 let ec_cancel = ctx.editor_container.clone();
1274 let original_cancel = ctx.editor.clone();
1275 let tui_cancel = ctx.tui.clone();
1276 let session_cancel = session.clone();
1277 let command_cancel = command_name.clone();
1278 let ctx_cancel = ctx.clone();
1279 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1280 let args = serde_json::json!({ "action": "cancel" });
1281 let result = invoke_extension_command(
1282 &session_cancel,
1283 &command_cancel,
1284 &serde_json::to_string(&args).unwrap_or_default(),
1285 );
1286 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1287 handle_extension_ui_result(
1288 result,
1289 &ctx_cancel,
1290 session_cancel.clone(),
1291 command_cancel.clone(),
1292 );
1293 }));
1294
1295 ctx.tui.set_focus(Some(input));
1296 ctx.tui.request_render(false);
1297}
1298
1299fn open_extension_selector(
1300 ctx: &CommandContext,
1301 session: crate::session::ExtensionSessionCell,
1302 command_name: String,
1303 value: serde_json::Value,
1304) {
1305 let items = value
1306 .get("items")
1307 .and_then(|v| v.as_array())
1308 .map(|items| {
1309 items
1310 .iter()
1311 .filter_map(|item| {
1312 let value = if let Some(value) = item.as_str() {
1315 value
1316 } else {
1317 item.get("value")?.as_str()?
1318 };
1319 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1320 let mut out = SelectItem::new(value, label);
1321 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1322 out = out.with_description(desc);
1323 }
1324 Some(out)
1325 })
1326 .collect::<Vec<_>>()
1327 })
1328 .unwrap_or_default();
1329 if items.is_empty() {
1330 add_error_message(&ctx.chat, "Extension selector has no items.");
1331 ctx.tui.request_render(false);
1332 return;
1333 }
1334 let list = Arc::new(SelectList::new(items, 10));
1335 let title = value
1336 .get("title")
1337 .and_then(|v| v.as_str())
1338 .filter(|title| !title.is_empty())
1339 .unwrap_or("Select");
1340 let frame = Arc::new(Container::new());
1341 frame.add_child(Arc::new(DynamicBorder::new()));
1342 frame.add_child(Arc::new(Spacer::new(1)));
1343 frame.add_child(extension_dialog_title(title, true));
1344 frame.add_child(Arc::new(Spacer::new(1)));
1345 frame.add_child(list.clone());
1346 frame.add_child(Arc::new(Spacer::new(1)));
1347 frame.add_child(extension_dialog_hint(
1348 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1349 ));
1350 frame.add_child(Arc::new(Spacer::new(1)));
1351 frame.add_child(Arc::new(DynamicBorder::new()));
1352
1353 let state = ctx.state.clone();
1354 let ec = ctx.editor_container.clone();
1355 let editor = ctx.editor.clone();
1356 let tui = ctx.tui.clone();
1357 let session_select = session.clone();
1358 let command_select = command_name.clone();
1359 let ctx_select = ctx.clone();
1360 list.on_select(Arc::new(move |item| {
1361 let args = serde_json::json!({ "action": "select", "value": item.value });
1362 let result = invoke_extension_command(
1363 &session_select,
1364 &command_select,
1365 &serde_json::to_string(&args).unwrap_or_default(),
1366 );
1367 close_selector(&state, &ec, &editor, &tui);
1368 handle_extension_ui_result(
1369 result,
1370 &ctx_select,
1371 session_select.clone(),
1372 command_select.clone(),
1373 );
1374 }));
1375 let state_cancel = ctx.state.clone();
1376 let ec_cancel = ctx.editor_container.clone();
1377 let editor_cancel = ctx.editor.clone();
1378 let tui_cancel = ctx.tui.clone();
1379 list.on_cancel(Arc::new(move || {
1380 if !run_extension_cancel(&state_cancel) {
1381 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1382 }
1383 }));
1384 let state_cancel = ctx.state.clone();
1385 let ec_cancel = ctx.editor_container.clone();
1386 let editor_cancel = ctx.editor.clone();
1387 let tui_cancel = ctx.tui.clone();
1388 let session_cancel = session.clone();
1389 let command_cancel = command_name.clone();
1390 let ctx_cancel = ctx.clone();
1391 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1392 let args = serde_json::json!({ "action": "cancel" });
1393 let result = invoke_extension_command(
1394 &session_cancel,
1395 &command_cancel,
1396 &serde_json::to_string(&args).unwrap_or_default(),
1397 );
1398 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1399 handle_extension_ui_result(
1400 result,
1401 &ctx_cancel,
1402 session_cancel.clone(),
1403 command_cancel.clone(),
1404 );
1405 }));
1406 open_selector_with_view(
1407 &ctx.state,
1408 &ctx.editor_container,
1409 &ctx.editor,
1410 &ctx.tui,
1411 SelectorView::List(list),
1412 frame,
1413 SelectorKind::Extension,
1414 );
1415}
1416
1417fn open_extension_editor(
1418 ctx: &CommandContext,
1419 session: crate::session::ExtensionSessionCell,
1420 command_name: String,
1421 value: serde_json::Value,
1422) {
1423 let initial = value
1424 .get("initialText")
1425 .or_else(|| value.get("text"))
1426 .and_then(|v| v.as_str())
1427 .unwrap_or_default()
1428 .to_string();
1429 let title = value
1430 .get("title")
1431 .and_then(|v| v.as_str())
1432 .filter(|title| !title.is_empty())
1433 .unwrap_or("Editor");
1434 let editor = Arc::new(Editor::new(
1435 EditorOptions {
1436 padding_x: 1,
1437 autocomplete_max_visible: 0,
1438 placeholder: value
1439 .get("placeholder")
1440 .and_then(|v| v.as_str())
1441 .map(str::to_string),
1442 initial_text: Some(initial),
1443 },
1444 EditorStyle {
1445 prompt: "> ".to_string(),
1446 placeholder: String::new(),
1447 },
1448 Arc::new(rpi_tui::Keybindings::new()),
1449 ));
1450 editor.set_focused(true);
1451 let frame = Arc::new(Container::new());
1452 frame.add_child(Arc::new(DynamicBorder::new()));
1453 frame.add_child(Arc::new(Spacer::new(1)));
1454 frame.add_child(extension_dialog_title(title, false));
1455 frame.add_child(Arc::new(Spacer::new(1)));
1456 frame.add_child(editor.clone());
1457 frame.add_child(Arc::new(Spacer::new(1)));
1458 frame.add_child(extension_dialog_hint(
1459 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1460 ));
1461 frame.add_child(Arc::new(Spacer::new(1)));
1462 frame.add_child(Arc::new(DynamicBorder::new()));
1463
1464 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1465 *ctx.state.active_extension_input.lock().unwrap() = None;
1466 ctx.editor_container.clear();
1467 ctx.editor_container.add_child(frame);
1468
1469 let state = ctx.state.clone();
1470 let ec = ctx.editor_container.clone();
1471 let original = ctx.editor.clone();
1472 let tui = ctx.tui.clone();
1473 let session_submit = session.clone();
1474 let command_submit = command_name.clone();
1475 let ctx_submit = ctx.clone();
1476 editor.on_submit(Arc::new(move |text| {
1477 let args = serde_json::json!({ "action": "edit", "text": text });
1478 let result = invoke_extension_command(
1479 &session_submit,
1480 &command_submit,
1481 &serde_json::to_string(&args).unwrap_or_default(),
1482 );
1483 close_extension_editor(&state, &ec, &original, &tui);
1484 handle_extension_ui_result(
1485 result,
1486 &ctx_submit,
1487 session_submit.clone(),
1488 command_submit.clone(),
1489 );
1490 }));
1491
1492 let state_cancel = ctx.state.clone();
1493 let ec_cancel = ctx.editor_container.clone();
1494 let original_cancel = ctx.editor.clone();
1495 let tui_cancel = ctx.tui.clone();
1496 let session_cancel = session.clone();
1497 let command_cancel = command_name.clone();
1498 let ctx_cancel = ctx.clone();
1499 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1500 let args = serde_json::json!({ "action": "cancel" });
1501 let result = invoke_extension_command(
1502 &session_cancel,
1503 &command_cancel,
1504 &serde_json::to_string(&args).unwrap_or_default(),
1505 );
1506 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1507 handle_extension_ui_result(
1508 result,
1509 &ctx_cancel,
1510 session_cancel.clone(),
1511 command_cancel.clone(),
1512 );
1513 }));
1514
1515 ctx.tui.set_focus(Some(editor));
1516 ctx.tui.request_render(false);
1517}
1518
1519fn close_extension_editor(
1520 state: &Arc<TuiState>,
1521 editor_container: &Arc<Container>,
1522 editor: &Arc<Editor>,
1523 tui: &Arc<TuiAltScreen>,
1524) {
1525 editor_container.clear();
1526 editor_container.add_child(editor.clone());
1527 state.autocomplete_container.clear();
1528 *state.active_extension_editor.lock().unwrap() = None;
1529 *state.active_extension_input.lock().unwrap() = None;
1530 *state.active_extension_cancel.lock().unwrap() = None;
1531 editor.set_focused(true);
1532 tui.set_focus(Some(editor.clone()));
1533 tui.request_render(false);
1534}
1535
1536fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1540 match request.method.as_str() {
1541 "select" => open_js_selector(ctx, bridge, request, false),
1542 "confirm" => open_js_selector(ctx, bridge, request, true),
1543 "input" => open_js_input(ctx, bridge, request),
1544 "editor" => open_js_editor(ctx, bridge, request),
1545 _ => {
1546 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1547 }
1548 }
1549}
1550
1551fn open_js_selector(
1552 ctx: &CommandContext,
1553 bridge: Arc<JsDialogBridge>,
1554 request: JsDialogRequest,
1555 confirm: bool,
1556) {
1557 let values = if confirm {
1558 vec!["Yes".to_string(), "No".to_string()]
1559 } else {
1560 request.options.clone()
1561 };
1562 if values.is_empty() {
1563 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1564 return;
1565 }
1566 let items = values
1567 .iter()
1568 .map(|value| SelectItem::new(value, value))
1569 .collect::<Vec<_>>();
1570 let list = Arc::new(SelectList::new(items, 10));
1571 let frame = Arc::new(Container::new());
1572 frame.add_child(Arc::new(DynamicBorder::new()));
1573 frame.add_child(Arc::new(Spacer::new(1)));
1574 frame.add_child(extension_dialog_title(
1575 if request.title.is_empty() {
1576 if confirm {
1577 "Confirm"
1578 } else {
1579 "Select"
1580 }
1581 } else {
1582 request.title.as_str()
1583 },
1584 true,
1585 ));
1586 if !request.message.is_empty() {
1587 frame.add_child(Arc::new(Spacer::new(1)));
1588 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1589 }
1590 frame.add_child(Arc::new(Spacer::new(1)));
1591 frame.add_child(list.clone());
1592 frame.add_child(Arc::new(Spacer::new(1)));
1593 frame.add_child(extension_dialog_hint(
1594 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1595 ));
1596 frame.add_child(Arc::new(Spacer::new(1)));
1597 frame.add_child(Arc::new(DynamicBorder::new()));
1598
1599 let id = request.id.clone();
1600 let bridge_select = bridge.clone();
1601 let state_select = ctx.state.clone();
1602 let ec_select = ctx.editor_container.clone();
1603 let editor_select = ctx.editor.clone();
1604 let tui_select = ctx.tui.clone();
1605 list.on_select(Arc::new(move |item| {
1606 let result = if confirm {
1607 serde_json::json!({ "confirmed": item.value == "Yes" })
1608 } else {
1609 serde_json::json!({ "value": item.value })
1610 };
1611 bridge_select.respond(&id, result);
1612 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1613 }));
1614
1615 let id_cancel = request.id.clone();
1616 let bridge_cancel = bridge.clone();
1617 let state_cancel = ctx.state.clone();
1618 let ec_cancel = ctx.editor_container.clone();
1619 let editor_cancel = ctx.editor.clone();
1620 let tui_cancel = ctx.tui.clone();
1621 list.on_cancel(Arc::new(move || {
1622 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1623 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1624 }));
1625
1626 let id_abort = request.id.clone();
1627 let bridge_abort = bridge.clone();
1628 let state_abort = ctx.state.clone();
1629 let ec_abort = ctx.editor_container.clone();
1630 let editor_abort = ctx.editor.clone();
1631 let tui_abort = ctx.tui.clone();
1632 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1633 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1634 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1635 }));
1636
1637 open_selector_with_view(
1638 &ctx.state,
1639 &ctx.editor_container,
1640 &ctx.editor,
1641 &ctx.tui,
1642 SelectorView::List(list),
1643 frame,
1644 SelectorKind::Extension,
1645 );
1646}
1647
1648fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1649 let input = request
1650 .placeholder
1651 .as_deref()
1652 .map(Input::with_placeholder)
1653 .unwrap_or_default();
1654 let input = Arc::new(input);
1655 input.set_focused(true);
1656
1657 let frame = Arc::new(Container::new());
1658 frame.add_child(Arc::new(DynamicBorder::new()));
1659 frame.add_child(Arc::new(Spacer::new(1)));
1660 frame.add_child(extension_dialog_title(
1661 if request.title.is_empty() {
1662 "Input"
1663 } else {
1664 &request.title
1665 },
1666 false,
1667 ));
1668 frame.add_child(Arc::new(Spacer::new(1)));
1669 frame.add_child(input.clone());
1670 frame.add_child(Arc::new(Spacer::new(1)));
1671 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1672 frame.add_child(Arc::new(Spacer::new(1)));
1673 frame.add_child(Arc::new(DynamicBorder::new()));
1674
1675 *ctx.state.active_extension_editor.lock().unwrap() = None;
1676 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1677 ctx.editor_container.clear();
1678 ctx.editor_container.add_child(frame);
1679
1680 let id = request.id.clone();
1681 let bridge_submit = bridge.clone();
1682 let state_submit = ctx.state.clone();
1683 let ec_submit = ctx.editor_container.clone();
1684 let editor_submit = ctx.editor.clone();
1685 let tui_submit = ctx.tui.clone();
1686 input.on_submit(Arc::new(move |value| {
1687 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1688 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1689 }));
1690
1691 let id_cancel = request.id.clone();
1692 let bridge_cancel = bridge.clone();
1693 let state_cancel = ctx.state.clone();
1694 let ec_cancel = ctx.editor_container.clone();
1695 let editor_cancel = ctx.editor.clone();
1696 let tui_cancel = ctx.tui.clone();
1697 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1698 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1699 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1700 }));
1701 ctx.tui.set_focus(Some(input));
1702 ctx.tui.request_render(false);
1703}
1704
1705fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1706 let editor = Arc::new(Editor::new(
1707 EditorOptions {
1708 padding_x: 1,
1709 autocomplete_max_visible: 0,
1710 initial_text: request.prefill.clone(),
1711 ..Default::default()
1712 },
1713 EditorStyle {
1714 prompt: "> ".to_string(),
1715 placeholder: String::new(),
1716 },
1717 Arc::new(rpi_tui::Keybindings::new()),
1718 ));
1719 editor.set_focused(true);
1720
1721 let frame = Arc::new(Container::new());
1722 frame.add_child(Arc::new(DynamicBorder::new()));
1723 frame.add_child(Arc::new(Spacer::new(1)));
1724 frame.add_child(extension_dialog_title(
1725 if request.title.is_empty() {
1726 "Editor"
1727 } else {
1728 &request.title
1729 },
1730 false,
1731 ));
1732 frame.add_child(Arc::new(Spacer::new(1)));
1733 frame.add_child(editor.clone());
1734 frame.add_child(Arc::new(Spacer::new(1)));
1735 frame.add_child(extension_dialog_hint(
1736 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1737 ));
1738 frame.add_child(Arc::new(Spacer::new(1)));
1739 frame.add_child(Arc::new(DynamicBorder::new()));
1740
1741 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1742 *ctx.state.active_extension_input.lock().unwrap() = None;
1743 ctx.editor_container.clear();
1744 ctx.editor_container.add_child(frame);
1745
1746 let id = request.id.clone();
1747 let bridge_submit = bridge.clone();
1748 let state_submit = ctx.state.clone();
1749 let ec_submit = ctx.editor_container.clone();
1750 let editor_submit = ctx.editor.clone();
1751 let tui_submit = ctx.tui.clone();
1752 editor.on_submit(Arc::new(move |value| {
1753 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1754 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1755 }));
1756
1757 let id_cancel = request.id.clone();
1758 let bridge_cancel = bridge.clone();
1759 let state_cancel = ctx.state.clone();
1760 let ec_cancel = ctx.editor_container.clone();
1761 let editor_cancel = ctx.editor.clone();
1762 let tui_cancel = ctx.tui.clone();
1763 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1764 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1765 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1766 }));
1767 ctx.tui.set_focus(Some(editor));
1768 ctx.tui.request_render(false);
1769}
1770
1771fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1772 for id in bridge.cancelled_active_ids() {
1773 if !bridge.is_visible(&id) {
1777 bridge.finish(&id);
1778 continue;
1779 }
1780 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1781 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1782 } else if ctx.state.extension_dialog_open() {
1783 if !run_extension_cancel(&ctx.state) {
1784 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1785 }
1786 }
1787 bridge.finish(&id);
1791 }
1792}
1793
1794const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1801
1802#[derive(Clone)]
1807struct AskUserBridge {
1808 mailbox: rpi_extensions::UiDialogMailbox,
1809 visible: Arc<Mutex<Option<String>>>,
1810}
1811
1812impl AskUserBridge {
1813 fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1814 Self {
1815 mailbox,
1816 visible: Arc::new(Mutex::new(None)),
1817 }
1818 }
1819
1820 fn attach(&self) {
1822 self.mailbox.attach();
1823 }
1824
1825 fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1829 let mut visible = self.visible.lock().ok()?;
1830 if visible.is_some() {
1831 return None;
1832 }
1833 let request = self.mailbox.take_pending()?;
1834 *visible = Some(request.request_id.clone());
1835 Some(request)
1836 }
1837
1838 fn clear_visible(&self, id: &str) {
1839 if let Ok(mut visible) = self.visible.lock() {
1840 if visible.as_deref() == Some(id) {
1841 *visible = None;
1842 }
1843 }
1844 }
1845
1846 fn respond(&self, id: &str, answer: serde_json::Value) {
1848 let _ = self.mailbox.respond(id, answer);
1849 self.clear_visible(id);
1850 }
1851
1852 fn cancel(&self, id: &str) {
1854 let _ = self.mailbox.cancel(id);
1855 self.clear_visible(id);
1856 }
1857
1858 fn cancel_all(&self) {
1861 self.mailbox.cancel_all();
1862 if let Ok(mut visible) = self.visible.lock() {
1863 *visible = None;
1864 }
1865 }
1866
1867 fn shutdown(&self) {
1870 self.mailbox.detach();
1871 if let Ok(mut visible) = self.visible.lock() {
1872 *visible = None;
1873 }
1874 }
1875}
1876
1877#[derive(Clone)]
1881struct AskUserPrompt {
1882 question_id: String,
1883 question: String,
1884 header: Option<String>,
1885 context: Option<String>,
1886 options: Vec<(String, Option<String>)>,
1887 allow_multiple: bool,
1888 allow_freeform: bool,
1889 suggest: Option<String>,
1890 kind: String,
1891}
1892
1893fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1894 keys.iter().find_map(|key| {
1895 value
1896 .get(*key)
1897 .and_then(serde_json::Value::as_str)
1898 .map(str::trim)
1899 .filter(|text| !text.is_empty())
1900 .map(str::to_string)
1901 })
1902}
1903
1904fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1905 keys.iter()
1906 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1907}
1908
1909fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1910 let mut options = Vec::new();
1911 let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1912 return options;
1913 };
1914 for item in items {
1915 if let Some(title) = item.as_str() {
1916 let title = title.trim();
1917 if !title.is_empty() {
1918 options.push((title.to_string(), None));
1919 }
1920 continue;
1921 }
1922 let Some(object) = item.as_object() else {
1923 continue;
1924 };
1925 let title = ["title", "label", "text", "value", "name"]
1926 .iter()
1927 .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1928 .map(str::trim)
1929 .filter(|title| !title.is_empty());
1930 let Some(title) = title else { continue };
1931 let description = object
1932 .get("description")
1933 .and_then(serde_json::Value::as_str)
1934 .map(str::trim)
1935 .filter(|text| !text.is_empty())
1936 .map(str::to_string);
1937 options.push((title.to_string(), description));
1938 }
1939 options
1940}
1941
1942fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1943 let ui = &request.ui;
1944 let kind = ui
1945 .get("kind")
1946 .and_then(serde_json::Value::as_str)
1947 .unwrap_or("selector")
1948 .to_string();
1949 let nested = ui
1953 .get("questions")
1954 .and_then(serde_json::Value::as_array)
1955 .and_then(|items| items.first());
1956 let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1957
1958 let question = ask_user_str(source, &["question", "summary", "title"])
1959 .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1960 .unwrap_or_default();
1961 let question_id = ask_user_str(source, &["id", "questionId"])
1962 .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1963 .unwrap_or_else(|| request.request_id.clone());
1964 let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1965 let context = ask_user_str(source, &["context", "message"])
1966 .or_else(|| ask_user_str(ui, &["context", "message"]));
1967 let mut options = ask_user_options(source);
1968 if options.is_empty() {
1969 options = ask_user_options(ui);
1970 }
1971 if kind == "confirm" && options.is_empty() {
1972 options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
1973 }
1974 let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1975 .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1976 .unwrap_or(false);
1977 let allow_freeform =
1980 ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
1981 .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
1982 .unwrap_or(options.is_empty());
1983 let suggest = ask_user_str(source, &["suggest", "placeholder"])
1984 .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1985
1986 AskUserPrompt {
1987 question_id,
1988 question,
1989 header,
1990 context,
1991 options,
1992 allow_multiple,
1993 allow_freeform,
1994 suggest,
1995 kind,
1996 }
1997}
1998
1999fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
2002 let frame = Arc::new(Container::new());
2003 frame.add_child(Arc::new(DynamicBorder::new()));
2004 frame.add_child(Arc::new(Spacer::new(1)));
2005 if let Some(header) = prompt.header.as_deref() {
2006 frame.add_child(extension_dialog_title_md(header, true));
2007 }
2008 if !prompt.question.trim().is_empty() {
2009 frame.add_child(extension_dialog_title_md(
2010 &prompt.question,
2011 prompt.header.is_none(),
2012 ));
2013 }
2014 if let Some(context) = prompt.context.as_deref() {
2015 frame.add_child(extension_dialog_hint_md(context));
2016 }
2017 frame.add_child(Arc::new(Spacer::new(1)));
2018 frame.add_child(body);
2019 frame.add_child(Arc::new(Spacer::new(1)));
2020 frame.add_child(extension_dialog_hint(hint));
2021 frame.add_child(Arc::new(Spacer::new(1)));
2022 frame.add_child(Arc::new(DynamicBorder::new()));
2023 frame
2024}
2025
2026fn open_ask_user_dialog(
2028 ctx: &CommandContext,
2029 bridge: AskUserBridge,
2030 request: rpi_extensions::UiDialogRequest,
2031) {
2032 let prompt = parse_ask_user_prompt(&request);
2033 if prompt.kind == "input" || prompt.options.is_empty() {
2034 open_ask_user_input(ctx, bridge, request, prompt);
2035 } else {
2036 open_ask_user_selector(ctx, bridge, request, prompt);
2037 }
2038}
2039
2040fn open_ask_user_selector(
2041 ctx: &CommandContext,
2042 bridge: AskUserBridge,
2043 request: rpi_extensions::UiDialogRequest,
2044 prompt: AskUserPrompt,
2045) {
2046 let multi = prompt.allow_multiple && prompt.kind != "confirm";
2047 let mut items: Vec<SelectItem> = Vec::new();
2048 for (title, description) in &prompt.options {
2049 let item = SelectItem::new(title, title);
2050 let item = match description.as_deref() {
2051 Some(description) => item.with_description(description),
2052 None => item,
2053 };
2054 items.push(item);
2055 }
2056 if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
2059 items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
2060 }
2061 let list = if multi {
2062 Arc::new(SelectList::new_multi(items, 10))
2063 } else {
2064 Arc::new(SelectList::new(items, 10))
2065 };
2066
2067 let request_id = request.request_id.clone();
2068 let state = ctx.state.clone();
2069 let editor_container = ctx.editor_container.clone();
2070 let editor = ctx.editor.clone();
2071 let tui = ctx.tui.clone();
2072
2073 let bridge_select = bridge.clone();
2076 let request_for_freeform = request.clone();
2077 let prompt_for_freeform = AskUserPrompt {
2078 question_id: prompt.question_id.clone(),
2079 question: prompt.question.clone(),
2080 header: prompt.header.clone(),
2081 context: prompt.context.clone(),
2082 options: Vec::new(),
2083 allow_multiple: false,
2084 allow_freeform: true,
2085 suggest: prompt.suggest.clone(),
2086 kind: "input".to_string(),
2087 };
2088 let state_select = state.clone();
2089 let ec_select = editor_container.clone();
2090 let editor_select = editor.clone();
2091 let tui_select = tui.clone();
2092 let ctx_select = ctx.clone();
2093 list.on_select(Arc::new(move |item: &SelectItem| {
2094 if item.value == ASK_USER_FREEFORM {
2095 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2096 open_ask_user_input(
2097 &ctx_select,
2098 bridge_select.clone(),
2099 request_for_freeform.clone(),
2100 prompt_for_freeform.clone(),
2101 );
2102 return;
2103 }
2104 bridge_select.respond(
2105 &request_id,
2106 serde_json::json!({
2107 "values": [item.display_value()],
2108 "kind": "selector",
2109 }),
2110 );
2111 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2112 }));
2113
2114 if multi {
2115 let bridge_multi = bridge.clone();
2116 let request_id_multi = request.request_id.clone();
2117 let state_multi = state.clone();
2118 let ec_multi = editor_container.clone();
2119 let editor_multi = editor.clone();
2120 let tui_multi = tui.clone();
2121 list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2122 let values: Vec<String> = selected
2123 .iter()
2124 .map(|item| item.display_value().to_string())
2125 .collect();
2126 bridge_multi.respond(
2127 &request_id_multi,
2128 serde_json::json!({"values": values, "kind": "selector"}),
2129 );
2130 close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2131 }));
2132 }
2133
2134 let bridge_cancel = bridge.clone();
2135 let request_id_cancel = request.request_id.clone();
2136 let state_cancel = state.clone();
2137 let ec_cancel = editor_container.clone();
2138 let editor_cancel = editor.clone();
2139 let tui_cancel = tui.clone();
2140 list.on_cancel(Arc::new(move || {
2141 bridge_cancel.cancel(&request_id_cancel);
2142 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2143 }));
2144
2145 let hint = if multi {
2146 "Space toggle · Enter submit · Esc cancel"
2147 } else {
2148 "Enter select · Esc cancel"
2149 };
2150 let frame = ask_user_frame(&prompt, list.clone(), hint);
2151 open_selector_with_view(
2152 &state,
2153 &editor_container,
2154 &editor,
2155 &tui,
2156 SelectorView::List(list),
2157 frame,
2158 SelectorKind::Extension,
2159 );
2160}
2161
2162fn open_ask_user_input(
2163 ctx: &CommandContext,
2164 bridge: AskUserBridge,
2165 request: rpi_extensions::UiDialogRequest,
2166 prompt: AskUserPrompt,
2167) {
2168 let placeholder = prompt
2169 .suggest
2170 .clone()
2171 .unwrap_or_else(|| "Type your answer…".to_string());
2172 let input = Arc::new(Input::with_placeholder(&placeholder));
2173 input.set_focused(true);
2174
2175 let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2176 *ctx.state.active_extension_editor.lock().unwrap() = None;
2177 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2178 ctx.editor_container.clear();
2179 ctx.editor_container.add_child(frame);
2180 ctx.tui.set_focus(Some(input.clone()));
2181 ctx.tui.request_render(false);
2182
2183 let state = ctx.state.clone();
2184 let ec = ctx.editor_container.clone();
2185 let editor = ctx.editor.clone();
2186 let tui = ctx.tui.clone();
2187 let bridge_submit = bridge.clone();
2188 let request_id = request.request_id.clone();
2189 input.on_submit(Arc::new(move |text: &str| {
2190 bridge_submit.respond(
2191 &request_id,
2192 serde_json::json!({
2193 "values": [text],
2194 "value": text,
2195 "text": text,
2196 "kind": "input",
2197 }),
2198 );
2199 close_extension_editor(&state, &ec, &editor, &tui);
2200 }));
2201
2202 let state_cancel = ctx.state.clone();
2203 let ec_cancel = ctx.editor_container.clone();
2204 let editor_cancel = ctx.editor.clone();
2205 let tui_cancel = ctx.tui.clone();
2206 let bridge_cancel = bridge.clone();
2207 let request_id_cancel = request.request_id.clone();
2208 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2209 bridge_cancel.cancel(&request_id_cancel);
2210 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2211 }));
2212}
2213
2214struct HelpCommand;
2217impl SlashCommand for HelpCommand {
2218 fn name(&self) -> &'static str {
2219 "/help"
2220 }
2221 fn aliases(&self) -> &'static [&'static str] {
2222 &["/?"]
2223 }
2224 fn description(&self) -> &'static str {
2225 "Show available commands"
2226 }
2227 fn execute(&self, ctx: &CommandContext, _args: &str) {
2228 add_help_message(&ctx.chat);
2229 ctx.tui.request_render(false);
2230 }
2231}
2232
2233struct ClearChatCommand;
2234impl SlashCommand for ClearChatCommand {
2235 fn name(&self) -> &'static str {
2236 "/clear"
2237 }
2238 fn aliases(&self) -> &'static [&'static str] {
2239 &["/new"]
2240 }
2241 fn alias_visible(&self) -> &'static [&'static str] {
2243 &["/new"]
2244 }
2245 fn description(&self) -> &'static str {
2246 "Clear the conversation"
2247 }
2248 fn execute(&self, ctx: &CommandContext, _args: &str) {
2249 let _ = ctx.tx.send(TuiMessage::ClearChat);
2250 }
2251}
2252
2253struct ExitCommand;
2254impl SlashCommand for ExitCommand {
2255 fn name(&self) -> &'static str {
2256 "/exit"
2257 }
2258 fn aliases(&self) -> &'static [&'static str] {
2259 &["/quit", "/q"]
2260 }
2261 fn alias_visible(&self) -> &'static [&'static str] {
2263 &["/quit"]
2264 }
2265 fn description(&self) -> &'static str {
2266 "Exit the application"
2267 }
2268 fn execute(&self, ctx: &CommandContext, _args: &str) {
2269 ctx.state.cancel_js_preparation();
2270 let _ = ctx.tx.send(TuiMessage::Exit);
2271 }
2272}
2273
2274struct VersionCommand;
2275impl SlashCommand for VersionCommand {
2276 fn name(&self) -> &'static str {
2277 "/version"
2278 }
2279 fn aliases(&self) -> &'static [&'static str] {
2280 &["/v"]
2281 }
2282 fn description(&self) -> &'static str {
2283 "Show version information"
2284 }
2285 fn execute(&self, ctx: &CommandContext, _args: &str) {
2286 add_version_message(&ctx.chat);
2287 ctx.tui.request_render(false);
2288 }
2289}
2290
2291struct ChangelogCommand;
2292impl SlashCommand for ChangelogCommand {
2293 fn name(&self) -> &'static str {
2294 "/changelog"
2295 }
2296 fn description(&self) -> &'static str {
2297 "Show recent release changes"
2298 }
2299 fn execute(&self, ctx: &CommandContext, _args: &str) {
2300 add_changelog_message(&ctx.chat);
2301 ctx.tui.request_render(false);
2302 }
2303}
2304
2305struct HotkeysCommand;
2306impl SlashCommand for HotkeysCommand {
2307 fn name(&self) -> &'static str {
2308 "/hotkeys"
2309 }
2310 fn description(&self) -> &'static str {
2311 "Show keyboard shortcuts"
2312 }
2313 fn execute(&self, ctx: &CommandContext, _args: &str) {
2314 add_hotkeys_message(&ctx.chat);
2315 ctx.tui.request_render(false);
2316 }
2317}
2318
2319struct ModelCommand;
2320impl SlashCommand for ModelCommand {
2321 fn name(&self) -> &'static str {
2322 "/model"
2323 }
2324 fn aliases(&self) -> &'static [&'static str] {
2325 &["/m"]
2326 }
2327 fn description(&self) -> &'static str {
2328 "Choose a model (selector)"
2329 }
2330 fn execute(&self, ctx: &CommandContext, args: &str) {
2331 let term = args.trim();
2332 if !term.is_empty() {
2333 if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
2339 let model_id = model.id.clone();
2340 ctx.state.set_current_model(&model);
2341 let lane = ctx.lane.clone();
2342 tokio::spawn(async move {
2343 let _ = lane.set_model(model).await;
2344 });
2345 add_note_message(
2346 &ctx.chat,
2347 &format!(
2348 "Model set to {} — applies to the next message.",
2349 short_model_name(&model_id)
2350 ),
2351 );
2352 ctx.tui.request_render(false);
2353 return;
2354 }
2355 }
2356 open_model_selector(
2357 &ctx.state,
2358 &ctx.editor_container,
2359 &ctx.editor,
2360 &ctx.tui,
2361 &ctx.model_catalog,
2362 &ctx.lane,
2363 &ctx.lane_model_id,
2364 &ctx.chat,
2365 (!term.is_empty()).then_some(term),
2366 );
2367 }
2368}
2369
2370struct ThinkingCommand;
2371impl SlashCommand for ThinkingCommand {
2372 fn name(&self) -> &'static str {
2373 "/thinking"
2374 }
2375 fn aliases(&self) -> &'static [&'static str] {
2376 &["/think"]
2377 }
2378 fn description(&self) -> &'static str {
2379 "Set thinking level (selector)"
2380 }
2381 fn execute(&self, ctx: &CommandContext, args: &str) {
2382 let level_name = args.trim();
2383 if !level_name.is_empty() {
2384 let Some(level) = thinking_level_from_name(level_name) else {
2386 add_error_message(
2387 &ctx.chat,
2388 &format!(
2389 "Unknown thinking level \"{level_name}\". Valid: {}",
2390 crate::args::VALID_THINKING_LEVELS.join(", ")
2391 ),
2392 );
2393 ctx.tui.request_render(false);
2394 return;
2395 };
2396 let lane = ctx.lane.clone();
2397 let footer = ctx.state.footer.clone();
2398 tokio::spawn(async move {
2399 let _ = lane.set_thinking_level(level).await;
2400 });
2401 footer.set_thinking_level(Some(thinking_level_name(level)));
2402 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2403 ctx.tui.request_render(false);
2404 return;
2405 }
2406 open_thinking_selector(
2407 &ctx.state,
2408 &ctx.editor_container,
2409 &ctx.editor,
2410 &ctx.tui,
2411 &ctx.lane,
2412 &ctx.model_catalog,
2413 &ctx.lane_model_id,
2414 &ctx.chat,
2415 );
2416 }
2417}
2418
2419struct ToolsCommand;
2420impl SlashCommand for ToolsCommand {
2421 fn name(&self) -> &'static str {
2422 "/tools"
2423 }
2424 fn description(&self) -> &'static str {
2425 "Toggle tools on/off"
2426 }
2427 fn execute(&self, ctx: &CommandContext, _args: &str) {
2428 open_tools_selector(
2429 &ctx.state,
2430 &ctx.editor_container,
2431 &ctx.editor,
2432 &ctx.tui,
2433 &ctx.lane,
2434 &ctx.chat,
2435 );
2436 }
2437}
2438
2439struct ImagesCommand;
2440impl SlashCommand for ImagesCommand {
2441 fn name(&self) -> &'static str {
2442 "/images"
2443 }
2444 fn description(&self) -> &'static str {
2445 "Toggle inline images"
2446 }
2447 fn execute(&self, ctx: &CommandContext, _args: &str) {
2448 open_images_selector(
2449 &ctx.state,
2450 &ctx.editor_container,
2451 &ctx.editor,
2452 &ctx.tui,
2453 &ctx.chat,
2454 );
2455 }
2456}
2457
2458struct SessionCommand;
2459impl SlashCommand for SessionCommand {
2460 fn name(&self) -> &'static str {
2461 "/session"
2462 }
2463 fn aliases(&self) -> &'static [&'static str] {
2464 &["/resume"]
2465 }
2466 fn description(&self) -> &'static str {
2467 "List saved sessions"
2468 }
2469 fn execute(&self, ctx: &CommandContext, _args: &str) {
2470 open_session_selector(
2471 &ctx.state,
2472 &ctx.editor_container,
2473 &ctx.editor,
2474 &ctx.tui,
2475 &ctx.cwd,
2476 &ctx.tx,
2477 );
2478 }
2479}
2480
2481struct ThemeCommand;
2482impl SlashCommand for ThemeCommand {
2483 fn name(&self) -> &'static str {
2484 "/theme"
2485 }
2486 fn description(&self) -> &'static str {
2487 "Choose a theme (selector)"
2488 }
2489 fn execute(&self, ctx: &CommandContext, args: &str) {
2490 let name = args.trim().to_ascii_lowercase();
2491 if !name.is_empty() {
2492 let preset = match name.as_str() {
2494 "light" => ThemePreset::Light,
2495 "monochrome" => ThemePreset::Monochrome,
2496 "dark" => ThemePreset::Dark,
2497 _ => {
2498 add_error_message(
2499 &ctx.chat,
2500 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2501 );
2502 ctx.tui.request_render(false);
2503 return;
2504 }
2505 };
2506 apply_theme_preset(preset);
2507 let mut settings = crate::settings::load_settings().unwrap_or_default();
2508 settings.theme = Some(name.clone());
2509 let _ = crate::settings::save_settings(&settings);
2510 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2511 ctx.tui.request_render(false);
2512 ctx.tui.render_now(true);
2513 return;
2514 }
2515 open_theme_selector(
2516 &ctx.state,
2517 &ctx.editor_container,
2518 &ctx.editor,
2519 &ctx.tui,
2520 &ctx.cwd,
2521 &ctx.package_resources,
2522 );
2523 }
2524}
2525
2526struct CompactCommand;
2527impl SlashCommand for CompactCommand {
2528 fn name(&self) -> &'static str {
2529 "/compact"
2530 }
2531 fn description(&self) -> &'static str {
2532 "Compact the conversation"
2533 }
2534 fn execute(&self, ctx: &CommandContext, _args: &str) {
2535 let _ = ctx.tx.send(TuiMessage::Compact);
2536 }
2537}
2538
2539struct CopyCommand;
2540impl SlashCommand for CopyCommand {
2541 fn name(&self) -> &'static str {
2542 "/copy"
2543 }
2544 fn description(&self) -> &'static str {
2545 "Copy last reply to clipboard"
2546 }
2547 fn execute(&self, ctx: &CommandContext, _args: &str) {
2548 let _ = ctx.tx.send(TuiMessage::Copy);
2549 }
2550}
2551
2552struct ExportCommand;
2553impl SlashCommand for ExportCommand {
2554 fn name(&self) -> &'static str {
2555 "/export"
2556 }
2557 fn description(&self) -> &'static str {
2558 "Export session (supports: md, html, jsonl)"
2559 }
2560 fn execute(&self, ctx: &CommandContext, args: &str) {
2561 let format = args.trim().to_lowercase();
2562 if format.is_empty() || format == "md" || format == "markdown" {
2563 let _ = ctx.tx.send(TuiMessage::ExportSession);
2564 } else if format == "html" {
2565 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2566 crate::export::ExportFormat::Html,
2567 ));
2568 } else if format == "jsonl" {
2569 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2570 crate::export::ExportFormat::Jsonl,
2571 ));
2572 } else {
2573 add_error_message(
2575 &ctx.chat,
2576 &format!(
2577 "Unknown export format: {}. Supported: md, html, jsonl",
2578 args.trim()
2579 ),
2580 );
2581 ctx.tui.request_render(false);
2582 }
2583 }
2584}
2585
2586struct ForkCommand;
2587impl SlashCommand for ForkCommand {
2588 fn name(&self) -> &'static str {
2589 "/fork"
2590 }
2591 fn description(&self) -> &'static str {
2592 "Fork the session into a new one"
2593 }
2594 fn execute(&self, ctx: &CommandContext, _args: &str) {
2595 let _ = ctx.tx.send(TuiMessage::ForkSession);
2596 }
2597}
2598
2599struct CloneCommand;
2603impl SlashCommand for CloneCommand {
2604 fn name(&self) -> &'static str {
2605 "/clone"
2606 }
2607 fn description(&self) -> &'static str {
2608 "Duplicate the current session"
2609 }
2610 fn execute(&self, ctx: &CommandContext, _args: &str) {
2611 let _ = ctx.tx.send(TuiMessage::ForkSession);
2612 }
2613}
2614
2615struct TreeCommand;
2616impl SlashCommand for TreeCommand {
2617 fn name(&self) -> &'static str {
2618 "/tree"
2619 }
2620 fn description(&self) -> &'static str {
2621 "Navigate the current session tree"
2622 }
2623 fn execute(&self, ctx: &CommandContext, _args: &str) {
2624 let _ = ctx.tx.send(TuiMessage::OpenTree);
2625 }
2626}
2627
2628struct LoginCommand;
2629impl SlashCommand for LoginCommand {
2630 fn name(&self) -> &'static str {
2631 "/login"
2632 }
2633 fn description(&self) -> &'static str {
2634 "Save an Anthropic API key"
2635 }
2636 fn execute(&self, ctx: &CommandContext, args: &str) {
2637 let key = args.trim();
2638 if key.is_empty() {
2639 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2640 } else {
2641 let result = crate::config::upsert_credential(
2642 "anthropic",
2643 crate::config::Credential::ApiKey {
2644 key: Some(key.to_string()),
2645 env: None,
2646 },
2647 );
2648 match result {
2649 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2650 Err(error) => {
2651 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2652 }
2653 }
2654 }
2655 ctx.tui.request_render(false);
2656 }
2657}
2658
2659struct LogoutCommand;
2660impl SlashCommand for LogoutCommand {
2661 fn name(&self) -> &'static str {
2662 "/logout"
2663 }
2664 fn description(&self) -> &'static str {
2665 "Remove saved Anthropic credentials"
2666 }
2667 fn execute(&self, ctx: &CommandContext, _args: &str) {
2668 match crate::config::delete_credential("anthropic") {
2669 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2670 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2671 Err(error) => {
2672 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2673 }
2674 }
2675 ctx.tui.request_render(false);
2676 }
2677}
2678
2679fn set_project_trust_for_command(
2680 cwd: &std::path::Path,
2681 value: Option<bool>,
2682) -> Result<(), crate::config::ConfigError> {
2683 crate::config::set_project_trust(cwd, value)
2684}
2685
2686struct TrustCommand;
2687impl SlashCommand for TrustCommand {
2688 fn name(&self) -> &'static str {
2689 "/trust"
2690 }
2691 fn description(&self) -> &'static str {
2692 "Trust the current project"
2693 }
2694 fn execute(&self, ctx: &CommandContext, args: &str) {
2695 let value = match args.trim().to_ascii_lowercase().as_str() {
2696 "" | "yes" | "y" | "true" => Some(true),
2697 "no" | "n" | "false" => Some(false),
2698 "clear" | "reset" | "none" => None,
2699 _ => {
2700 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2701 ctx.tui.request_render(false);
2702 return;
2703 }
2704 };
2705 match set_project_trust_for_command(&ctx.cwd, value) {
2706 Ok(()) => {
2707 let label = match value {
2708 Some(true) => "trusted",
2709 Some(false) => "untrusted",
2710 None => "trust decision cleared",
2711 };
2712 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2713 }
2714 Err(error) => add_error_message(
2715 &ctx.chat,
2716 &format!("Could not save trust decision: {error}"),
2717 ),
2718 }
2719 ctx.tui.request_render(false);
2720 }
2721}
2722
2723struct NameCommand;
2724impl SlashCommand for NameCommand {
2725 fn name(&self) -> &'static str {
2726 "/name"
2727 }
2728 fn description(&self) -> &'static str {
2729 "Set session display name"
2730 }
2731 fn execute(&self, ctx: &CommandContext, args: &str) {
2732 let name = args.trim();
2733 if name.is_empty() {
2734 add_note_message(
2735 &ctx.chat,
2736 "Usage: /name <display name> — sets the current session's name.",
2737 );
2738 ctx.tui.request_render(false);
2739 return;
2740 }
2741 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2742 }
2743}
2744
2745struct ImportCommand;
2746impl SlashCommand for ImportCommand {
2747 fn name(&self) -> &'static str {
2748 "/import"
2749 }
2750 fn description(&self) -> &'static str {
2751 "Import a session file (path)"
2752 }
2753 fn execute(&self, ctx: &CommandContext, args: &str) {
2754 let path = args.trim();
2755 if path.is_empty() {
2756 add_note_message(
2757 &ctx.chat,
2758 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2759 );
2760 ctx.tui.request_render(false);
2761 return;
2762 }
2763 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2764 }
2765}
2766
2767struct SettingsCommand;
2768impl SlashCommand for SettingsCommand {
2769 fn name(&self) -> &'static str {
2770 "/settings"
2771 }
2772 fn description(&self) -> &'static str {
2773 "Open settings menu"
2774 }
2775 fn execute(&self, ctx: &CommandContext, _args: &str) {
2776 open_settings_selector(
2777 &ctx.state,
2778 &ctx.editor_container,
2779 &ctx.editor,
2780 &ctx.tui,
2781 &ctx.lane,
2782 &ctx.model_catalog,
2783 &ctx.lane_model_id,
2784 &ctx.chat,
2785 &ctx.cwd,
2786 &ctx.package_resources,
2787 );
2788 }
2789}
2790
2791struct ScopedModelsCommand;
2792impl SlashCommand for ScopedModelsCommand {
2793 fn name(&self) -> &'static str {
2794 "/scoped-models"
2795 }
2796 fn description(&self) -> &'static str {
2797 "Choose models for Ctrl+M cycling"
2798 }
2799 fn execute(&self, ctx: &CommandContext, _args: &str) {
2800 open_scoped_models_selector(
2801 &ctx.state,
2802 &ctx.editor_container,
2803 &ctx.editor,
2804 &ctx.tui,
2805 &ctx.model_catalog,
2806 &ctx.chat,
2807 );
2808 }
2809}
2810
2811struct ShareCommand;
2812impl SlashCommand for ShareCommand {
2813 fn name(&self) -> &'static str {
2814 "/share"
2815 }
2816 fn description(&self) -> &'static str {
2817 "Share session (gist via gh, or clipboard)"
2818 }
2819 fn execute(&self, ctx: &CommandContext, _args: &str) {
2820 let _ = ctx.tx.send(TuiMessage::ShareSession);
2821 }
2822}
2823
2824struct ArminCommand;
2825impl SlashCommand for ArminCommand {
2826 fn name(&self) -> &'static str {
2827 "/armin"
2828 }
2829 fn description(&self) -> &'static str {
2830 "??? (easter egg)"
2831 }
2832 fn execute(&self, ctx: &CommandContext, _args: &str) {
2833 crate::extras::add_armin(&ctx.chat);
2834 ctx.tui.request_render(false);
2835 }
2836}
2837
2838struct EarendilCommand;
2839impl SlashCommand for EarendilCommand {
2840 fn name(&self) -> &'static str {
2841 "/earendil"
2842 }
2843 fn description(&self) -> &'static str {
2844 "Announcement"
2845 }
2846 fn execute(&self, ctx: &CommandContext, _args: &str) {
2847 crate::extras::add_earendil(&ctx.chat);
2848 ctx.tui.request_render(false);
2849 }
2850}
2851
2852struct ContextCommand;
2856impl SlashCommand for ContextCommand {
2857 fn name(&self) -> &'static str {
2858 "/context"
2859 }
2860 fn visible(&self) -> bool {
2861 false
2862 }
2863 fn execute(&self, ctx: &CommandContext, _args: &str) {
2864 show_context_panel(&ctx.chat, &ctx.resources);
2865 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2867 ctx.tui.request_render(false);
2868 }
2869}
2870
2871struct UsageCommand;
2875impl SlashCommand for UsageCommand {
2876 fn name(&self) -> &'static str {
2877 "/usage"
2878 }
2879 fn description(&self) -> &'static str {
2880 "Show token, cost, and cache totals for this session"
2881 }
2882 fn execute(&self, ctx: &CommandContext, _args: &str) {
2883 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2884 ctx.tui.request_render(false);
2885 }
2886}
2887
2888struct ReloadCommand;
2899impl SlashCommand for ReloadCommand {
2900 fn name(&self) -> &'static str {
2901 "/reload"
2902 }
2903 fn description(&self) -> &'static str {
2904 "Reload extensions, skills, prompts"
2905 }
2906 fn execute(&self, ctx: &CommandContext, _args: &str) {
2907 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2911 ctx.tui.request_render(false);
2912 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2913 }
2914}
2915
2916fn build_builtin_registry() -> CommandRegistry {
2921 let mut r = CommandRegistry::new();
2922 r.register(Arc::new(HelpCommand));
2923 r.register(Arc::new(ClearChatCommand));
2924 r.register(Arc::new(ExitCommand));
2925 r.register(Arc::new(VersionCommand));
2926 r.register(Arc::new(ChangelogCommand));
2927 r.register(Arc::new(ModelCommand));
2928 r.register(Arc::new(ThinkingCommand));
2929 r.register(Arc::new(ToolsCommand));
2930 r.register(Arc::new(ImagesCommand));
2931 r.register(Arc::new(SessionCommand));
2932 r.register(Arc::new(ThemeCommand));
2933 r.register(Arc::new(CompactCommand));
2934 r.register(Arc::new(CopyCommand));
2935 r.register(Arc::new(HotkeysCommand));
2936 r.register(Arc::new(ArminCommand));
2937 r.register(Arc::new(EarendilCommand));
2938 r.register(Arc::new(ContextCommand));
2939 r.register(Arc::new(UsageCommand));
2940 r.register(Arc::new(NameCommand));
2944 r.register(Arc::new(SettingsCommand));
2945 r.register(Arc::new(ScopedModelsCommand));
2946 r.register(Arc::new(ExportCommand));
2947 r.register(Arc::new(ImportCommand));
2948 r.register(Arc::new(ShareCommand));
2949 r.register(Arc::new(ForkCommand));
2950 r.register(Arc::new(CloneCommand));
2951 r.register(Arc::new(TreeCommand));
2952 r.register(Arc::new(TrustCommand));
2953 r.register(Arc::new(LoginCommand));
2954 r.register(Arc::new(LogoutCommand));
2955 r.register(Arc::new(ReloadCommand));
2956 r
2957}
2958
2959fn register_extension_commands(
2960 registry: &mut CommandRegistry,
2961 session: crate::session::ExtensionSessionCell,
2962) {
2963 let commands = session
2964 .lock()
2965 .ok()
2966 .and_then(|s| s.snapshot_arc())
2967 .map(|snap| snap.commands().to_vec())
2968 .unwrap_or_default();
2969 for command in commands {
2970 let name = if command.name.starts_with('/') {
2971 command.name.clone()
2972 } else {
2973 format!("/{}", command.name)
2974 };
2975 if registry.find(&name).is_some() {
2976 continue;
2977 }
2978 registry.register(Arc::new(ExtensionCommand {
2979 name,
2980 description: command.description,
2981 session: session.clone(),
2982 }));
2983 }
2984}
2985
2986fn register_js_extension_commands(
2987 registry: &mut CommandRegistry,
2988 session: Option<crate::js_extensions::JsExtensionSession>,
2989) {
2990 let Some(session) = session else {
2991 return;
2992 };
2993 for command in &session.commands {
2994 let name = if command.starts_with('/') {
2995 command.clone()
2996 } else {
2997 format!("/{command}")
2998 };
2999 if registry.find(&name).is_none() {
3000 registry.register(Arc::new(JsExtensionCommand {
3001 name,
3002 session: session.clone(),
3003 }));
3004 }
3005 }
3006}
3007
3008enum TuiMessage {
3015 UserInput(String),
3016 OpenTree,
3017 NavigateTree(String),
3018 Exit,
3019 ClearChat,
3021 Compact,
3023 Copy,
3025 SwitchSession(String),
3028 ExportSession,
3030 ExportSessionWithFormat(crate::export::ExportFormat),
3032 ShowUsage,
3034 ForkSession,
3036 SetSessionName(String),
3038 ImportSession(String),
3041 ShareSession,
3044 ReloadExtensions,
3049 ExternalEditorResult(Result<String, String>),
3053 UserBashFinished(UserBashReport),
3058}
3059
3060struct UserBashReport {
3065 command: String,
3066 output: String,
3067 exit_code: Option<i32>,
3068 cancelled: bool,
3069 truncated: bool,
3070 full_output_path: Option<String>,
3071 exclude_from_context: bool,
3072}
3073
3074fn assistant_text(msg: &AssistantMessage) -> String {
3077 msg.content
3078 .iter()
3079 .filter_map(|c| match c {
3080 Content::Text(t) => Some(t.text.clone()),
3081 _ => None,
3082 })
3083 .collect()
3084}
3085
3086fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
3089 msg.content
3090 .iter()
3091 .filter_map(|c| match c {
3092 Content::Text(t) => Some(t.text.clone()),
3093 _ => None,
3094 })
3095 .collect::<Vec<_>>()
3096 .join("\n")
3097}
3098
3099fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
3100 match &msg.content {
3101 rpi_ai::types::UserContent::Text(s) => s.clone(),
3102 rpi_ai::types::UserContent::Blocks(blocks) => blocks
3103 .iter()
3104 .filter_map(|c| match c {
3105 Content::Text(t) => Some(t.text.clone()),
3106 _ => None,
3107 })
3108 .collect(),
3109 }
3110}
3111
3112fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3116 let scoped = crate::settings::load_settings()
3117 .ok()
3118 .and_then(|s| s.scoped_models)
3119 .unwrap_or_default();
3120 if scoped.is_empty() {
3121 return catalog.to_vec();
3122 }
3123 let mut out: Vec<rpi_ai::Model> = catalog
3124 .iter()
3125 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3126 .cloned()
3127 .collect();
3128 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3130 if let Some(cur) = catalog
3131 .iter()
3132 .find(|m| m.id.eq_ignore_ascii_case(current_id))
3133 {
3134 out.push(cur.clone());
3135 }
3136 }
3137 out
3138}
3139
3140fn open_settings_selector(
3149 state: &Arc<TuiState>,
3150 editor_container: &Arc<Container>,
3151 editor: &Arc<Editor>,
3152 tui: &Arc<TuiAltScreen>,
3153 lane: &Arc<dyn AgentLane>,
3154 catalog: &[rpi_ai::Model],
3155 lane_model_id: &str,
3156 chat: &Arc<Container>,
3157 cwd: &std::path::Path,
3158 package_resources: &Arc<crate::packages::PackageResources>,
3159) {
3160 let settings = crate::settings::load_settings().unwrap_or_default();
3161 let items = settings_menu_items(&settings, state, lane_model_id);
3162 let list = Arc::new(SettingsList::new(items));
3163
3164 let state_sel = state.clone();
3169 let ec_sel = editor_container.clone();
3170 let editor_sel = editor.clone();
3171 let tui_sel = tui.clone();
3172 let lane_sel = lane.clone();
3173 let catalog_sel = catalog.to_vec();
3174 let lane_model_sel = lane_model_id.to_string();
3175 let chat_sel = chat.clone();
3176 let cwd_sel = cwd.to_path_buf();
3177 let package_resources_sel = package_resources.clone();
3178 list.on_select(Arc::new(move |item| match item.key.as_str() {
3179 "theme" => open_settings_theme_selector(
3180 &state_sel,
3181 &ec_sel,
3182 &editor_sel,
3183 &tui_sel,
3184 &chat_sel,
3185 &cwd_sel,
3186 &package_resources_sel,
3187 ),
3188 "model" => open_settings_model_selector(
3189 &state_sel,
3190 &ec_sel,
3191 &editor_sel,
3192 &tui_sel,
3193 &lane_sel,
3194 &catalog_sel,
3195 &lane_model_sel,
3196 &chat_sel,
3197 ),
3198 "thinking" => open_settings_thinking_selector(
3199 &state_sel,
3200 &ec_sel,
3201 &editor_sel,
3202 &tui_sel,
3203 &lane_sel,
3204 &catalog_sel,
3205 &lane_model_sel,
3206 &chat_sel,
3207 ),
3208 "scoped-models" => open_scoped_models_selector(
3209 &state_sel,
3210 &ec_sel,
3211 &editor_sel,
3212 &tui_sel,
3213 &catalog_sel,
3214 &chat_sel,
3215 ),
3216 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3217 }));
3218
3219 let state_change = state.clone();
3222 let chat_change = chat.clone();
3223 let tui_change = tui.clone();
3224 list.on_change(Arc::new(move |key, value| {
3225 let mut settings = crate::settings::load_settings().unwrap_or_default();
3226 let applied = apply_setting_change(&state_change, key, value, &mut settings);
3227 let saved = crate::settings::save_settings(&settings);
3228 if let Some(note) = applied {
3229 let suffix = if saved.is_ok() { "" } else { " (not saved)" };
3230 add_note_message(&chat_change, &format!("{note}{suffix}"));
3231 }
3232 tui_change.request_render(false);
3233 }));
3234
3235 let state_cancel = state.clone();
3236 let ec_cancel = editor_container.clone();
3237 let editor_cancel = editor.clone();
3238 let tui_cancel = tui.clone();
3239 list.on_cancel(Arc::new(move || {
3240 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3241 }));
3242
3243 open_selector_with_view(
3244 state,
3245 editor_container,
3246 editor,
3247 tui,
3248 SelectorView::Settings(list.clone()),
3249 list,
3250 SelectorKind::Settings,
3251 );
3252}
3253
3254fn settings_menu_items(
3260 settings: &crate::settings::Settings,
3261 state: &Arc<TuiState>,
3262 lane_model_id: &str,
3263) -> Vec<SettingItem> {
3264 let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
3265 let current_model = settings
3266 .default_model
3267 .clone()
3268 .unwrap_or_else(|| lane_model_id.to_string());
3269 let current_thinking = settings
3270 .default_thinking_level
3271 .clone()
3272 .unwrap_or_else(|| "(default)".to_string());
3273 let scoped_desc = match &settings.scoped_models {
3274 Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
3275 _ => "all models".to_string(),
3276 };
3277 let hide_thinking = state.hide_thinking();
3278 let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
3279 let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
3280 let http_idle = settings
3281 .http_idle_timeout_ms()
3282 .map(|ms| {
3283 if ms == 0 {
3284 "disabled".to_string()
3285 } else {
3286 format!("{}s", ms / 1000)
3287 }
3288 })
3289 .unwrap_or_else(|| "5m".to_string());
3290
3291 vec![
3292 SettingItem::new("theme", "Theme", ¤t_theme)
3293 .with_description("Color theme for the interface")
3294 .with_submenu(),
3295 SettingItem::new("model", "Default model", ¤t_model)
3296 .with_description("Saved default model for new sessions")
3297 .with_submenu(),
3298 SettingItem::new("thinking", "Default thinking", ¤t_thinking)
3299 .with_description("Saved default thinking level")
3300 .with_submenu(),
3301 SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
3302 .with_description("Models enabled for Ctrl+M cycling")
3303 .with_submenu(),
3304 SettingItem::new(
3305 "hide-thinking",
3306 "Hide thinking",
3307 bool_setting_value(hide_thinking),
3308 )
3309 .with_description("Hide thinking blocks in assistant responses")
3310 .with_values(&["true", "false"]),
3311 SettingItem::new(
3312 "show-images",
3313 "Show images",
3314 bool_setting_value(show_images),
3315 )
3316 .with_description("Render images inline in the terminal")
3317 .with_values(&["true", "false"]),
3318 SettingItem::new(
3319 "cache-miss-notices",
3320 "Cache miss notices",
3321 bool_setting_value(show_cache_miss),
3322 )
3323 .with_description("Transcript notices for prompt-cache costs")
3324 .with_values(&["true", "false"]),
3325 SettingItem::new(
3326 "quiet-startup",
3327 "Quiet startup",
3328 bool_setting_value(settings.quiet_startup.unwrap_or(false)),
3329 )
3330 .with_description("Disable the verbose startup listing (applies on restart)")
3331 .with_values(&["true", "false"]),
3332 SettingItem::new(
3333 "terminal-progress",
3334 "Terminal progress",
3335 bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
3336 )
3337 .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
3338 .with_values(&["true", "false"]),
3339 SettingItem::new(
3340 "fullscreen-copy-on-select",
3341 "Fullscreen copy on select",
3342 bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
3343 )
3344 .with_description("Copy selected text automatically in fullscreen mode")
3345 .with_values(&["true", "false"]),
3346 SettingItem::new(
3347 "double-escape-action",
3348 "Double-escape action",
3349 settings
3350 .double_escape_action
3351 .clone()
3352 .unwrap_or_else(|| "tree".to_string())
3353 .as_str(),
3354 )
3355 .with_description("Esc Esc with an empty editor (applies on restart)")
3356 .with_values(&["tree", "fork", "none"]),
3357 SettingItem::new(
3358 "editor-padding",
3359 "Editor padding",
3360 settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
3361 )
3362 .with_description("Horizontal editor padding, 0-3 (applies on restart)")
3363 .with_values(&["0", "1", "2", "3"]),
3364 SettingItem::new(
3365 "autocomplete-max-items",
3366 "Autocomplete max items",
3367 settings
3368 .autocomplete_max_visible
3369 .unwrap_or(5)
3370 .to_string()
3371 .as_str(),
3372 )
3373 .with_description("Max autocomplete rows, 3-20 (applies on restart)")
3374 .with_values(&["3", "5", "7", "10", "15", "20"]),
3375 SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
3376 .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
3377 .with_values(&["30s", "1m", "5m", "disabled"]),
3378 ]
3379}
3380
3381fn bool_setting_value(value: bool) -> &'static str {
3383 if value {
3384 "true"
3385 } else {
3386 "false"
3387 }
3388}
3389
3390fn apply_setting_change(
3395 state: &Arc<TuiState>,
3396 key: &str,
3397 value: &str,
3398 settings: &mut crate::settings::Settings,
3399) -> Option<String> {
3400 let as_bool = || value == "true";
3401 match key {
3402 "hide-thinking" => {
3404 state.set_hide_thinking(as_bool());
3405 settings.hide_thinking_block = Some(as_bool());
3406 Some(format!(
3407 "Thinking blocks {}.",
3408 if as_bool() { "hidden" } else { "shown" }
3409 ))
3410 }
3411 "show-images" => {
3413 if let Ok(mut guard) = state.show_images.lock() {
3414 *guard = as_bool();
3415 }
3416 settings.show_images = Some(as_bool());
3417 Some(format!(
3418 "Inline images {}.",
3419 if as_bool() { "enabled" } else { "disabled" }
3420 ))
3421 }
3422 "cache-miss-notices" => {
3424 if let Ok(mut guard) = state.cache_miss_notices.lock() {
3425 *guard = as_bool();
3426 }
3427 settings.show_cache_miss_notices = Some(as_bool());
3428 Some(format!(
3429 "Cache miss notices {}.",
3430 if as_bool() { "enabled" } else { "disabled" }
3431 ))
3432 }
3433 "fullscreen-copy-on-select" => {
3435 settings.fullscreen_copy_on_select = Some(as_bool());
3436 Some(format!(
3437 "Copy on select {}.",
3438 if as_bool() { "enabled" } else { "disabled" }
3439 ))
3440 }
3441 "quiet-startup" => {
3443 settings.quiet_startup = Some(as_bool());
3444 None
3445 }
3446 "terminal-progress" => {
3447 settings.show_terminal_progress = Some(as_bool());
3448 None
3449 }
3450 "double-escape-action" => {
3451 settings.double_escape_action = Some(value.to_string());
3452 None
3453 }
3454 "editor-padding" => {
3455 settings.editor_padding_x = value.parse().ok();
3456 None
3457 }
3458 "autocomplete-max-items" => {
3459 settings.autocomplete_max_visible = value.parse().ok();
3460 None
3461 }
3462 "http-idle-timeout" => {
3463 settings.http_idle_timeout = Some(match value {
3464 "disabled" => serde_json::Value::String("disabled".to_string()),
3465 "30s" => serde_json::json!(30_000),
3466 "1m" => serde_json::json!(60_000),
3467 _ => serde_json::json!(300_000),
3468 });
3469 None
3470 }
3471 _ => None,
3472 }
3473}
3474
3475fn open_settings_theme_selector(
3477 state: &Arc<TuiState>,
3478 editor_container: &Arc<Container>,
3479 editor: &Arc<Editor>,
3480 tui: &Arc<TuiAltScreen>,
3481 chat: &Arc<Container>,
3482 cwd: &std::path::Path,
3483 package_resources: &Arc<crate::packages::PackageResources>,
3484) {
3485 let mut items = vec![
3486 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3487 SelectItem::new("light", "Light").with_description("Light background"),
3488 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3489 ];
3490 if state.themes_enabled {
3491 for path in package_resources.theme_files() {
3492 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3493 items.push(SelectItem::new(name, name).with_description("Package theme"));
3494 }
3495 }
3496 }
3497 let list = Arc::new(SelectList::new(items, 10));
3498
3499 let state_sel = state.clone();
3500 let ec_sel = editor_container.clone();
3501 let editor_sel = editor.clone();
3502 let tui_sel = tui.clone();
3503 let chat_sel = chat.clone();
3504 let cwd_sel = cwd.to_path_buf();
3505 let package_resources_sel = package_resources.clone();
3506 list.on_select(Arc::new(move |item| {
3507 let preset = match item.value.as_str() {
3508 "light" => Some(ThemePreset::Light),
3509 "monochrome" => Some(ThemePreset::Monochrome),
3510 "dark" => Some(ThemePreset::Dark),
3511 name => {
3512 if state_sel.themes_enabled {
3513 if let Ok(custom) = crate::packages::load_theme_with_resources(
3514 &cwd_sel,
3515 name,
3516 &package_resources_sel,
3517 ) {
3518 rpi_tui::global_theme_manager().set(custom.clone());
3519 state_sel.theme_manager.set(custom);
3520 }
3521 }
3522 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3523 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3524 tui_sel.render_now(true);
3525 return;
3526 }
3527 };
3528 let Some(preset) = preset else { return };
3529 apply_theme_preset(preset);
3530 state_sel.theme_manager.apply_preset(preset);
3531 let mut settings = crate::settings::load_settings().unwrap_or_default();
3532 settings.theme = Some(item.value.clone());
3533 let saved = crate::settings::save_settings(&settings);
3534 add_note_message(
3535 &chat_sel,
3536 &format!(
3537 "Theme set to {} (saved{})",
3538 item.label,
3539 if saved.is_ok() { "" } else { ", not saved" },
3540 ),
3541 );
3542 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3543 tui_sel.render_now(true);
3544 }));
3545 let state_cancel = state.clone();
3546 let ec_cancel = editor_container.clone();
3547 let editor_cancel = editor.clone();
3548 let tui_cancel = tui.clone();
3549 list.on_cancel(Arc::new(move || {
3550 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3551 }));
3552
3553 open_selector(
3554 state,
3555 editor_container,
3556 editor,
3557 tui,
3558 list,
3559 SelectorKind::Settings,
3560 );
3561}
3562
3563fn open_settings_model_selector(
3567 state: &Arc<TuiState>,
3568 editor_container: &Arc<Container>,
3569 editor: &Arc<Editor>,
3570 tui: &Arc<TuiAltScreen>,
3571 lane: &Arc<dyn AgentLane>,
3572 catalog: &[rpi_ai::Model],
3573 lane_model_id: &str,
3574 chat: &Arc<Container>,
3575) {
3576 let items = model_selector_items(catalog, lane_model_id);
3577 if items.is_empty() {
3578 add_note_message(chat, "No models in the catalog.");
3579 tui.request_render(false);
3580 return;
3581 }
3582 let list = Arc::new(SelectList::new(items, 10));
3583
3584 let catalog_arc = catalog.to_vec();
3585 let state_sel = state.clone();
3586 let ec_sel = editor_container.clone();
3587 let editor_sel = editor.clone();
3588 let tui_sel = tui.clone();
3589 let chat_sel = chat.clone();
3590 let lane_sel = lane.clone();
3591 list.on_select(Arc::new(move |item| {
3592 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3593 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3594 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3595 return;
3596 };
3597 state_sel.set_current_model(&model);
3598 let lane = lane_sel.clone();
3599 tokio::spawn(async move {
3600 let _ = lane.set_model(model).await;
3601 });
3602 let mut settings = crate::settings::load_settings().unwrap_or_default();
3603 settings.default_model = Some(item.value.clone());
3604 let saved = crate::settings::save_settings(&settings);
3605 add_note_message(
3606 &chat_sel,
3607 &format!(
3608 "Default model set to {} (saved{}",
3609 short_model_name(&item.value),
3610 if saved.is_ok() { ")" } else { ", not saved)" },
3611 ),
3612 );
3613 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3614 }));
3615 let state_cancel = state.clone();
3616 let ec_cancel = editor_container.clone();
3617 let editor_cancel = editor.clone();
3618 let tui_cancel = tui.clone();
3619 list.on_cancel(Arc::new(move || {
3620 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3621 }));
3622
3623 open_searchable_selector(
3624 state,
3625 editor_container,
3626 editor,
3627 tui,
3628 Some("Default model"),
3629 Some("Type to filter by name, provider, or id"),
3630 list,
3631 SelectorKind::Settings,
3632 );
3633}
3634
3635fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3640 let mut seen = std::collections::HashSet::new();
3641 catalog
3642 .iter()
3643 .filter(|m| {
3644 seen.insert((
3645 m.api.clone(),
3646 m.provider.to_ascii_lowercase(),
3647 m.id.to_ascii_lowercase(),
3648 ))
3649 })
3650 .map(|m| {
3651 let label = if m.name.is_empty() {
3652 short_model_name(&m.id)
3653 } else {
3654 m.name.clone()
3655 };
3656 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3657 && m.provider.eq_ignore_ascii_case("anthropic")
3658 {
3659 m.id.clone()
3660 } else {
3661 format!("{}/{}", m.provider, m.id)
3662 };
3663 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3664 " (current)"
3665 } else {
3666 ""
3667 };
3668 let name = if m.name.is_empty() {
3671 String::new()
3672 } else {
3673 format!(" {}", m.name)
3674 };
3675 let search_text = format!(
3676 "{} {}/{} {} {}{}",
3677 m.provider, m.provider, m.id, m.provider, m.id, name
3678 );
3679 SelectItem::new(&m.id, &label)
3680 .with_description(&format!("{identity}{marker}"))
3681 .with_search_text(&search_text)
3682 })
3683 .collect()
3684}
3685
3686fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3690 let (provider, id) = input
3691 .split_once('/')
3692 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3693 .map_or((None, input), |(provider, id)| (Some(provider), id));
3694 catalog
3695 .iter()
3696 .find(|model| {
3697 model.id.eq_ignore_ascii_case(id)
3698 && provider.map_or(true, |provider| {
3699 model.provider.eq_ignore_ascii_case(provider)
3700 || (provider.eq_ignore_ascii_case("anthropic")
3701 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3702 })
3703 })
3704 .cloned()
3705}
3706
3707fn open_settings_thinking_selector(
3711 state: &Arc<TuiState>,
3712 editor_container: &Arc<Container>,
3713 editor: &Arc<Editor>,
3714 tui: &Arc<TuiAltScreen>,
3715 lane: &Arc<dyn AgentLane>,
3716 catalog: &[rpi_ai::Model],
3717 lane_model_id: &str,
3718 chat: &Arc<Container>,
3719) {
3720 let model = catalog
3721 .iter()
3722 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3723 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3724 .map(|m| m.supported_thinking_levels())
3725 .unwrap_or_else(|| {
3726 use rpi_ai::types::ThinkingLevel::*;
3727 vec![Off, Minimal, Low, Medium, High]
3728 });
3729 let mut items: Vec<SelectItem> = Vec::new();
3730 for lvl in &levels {
3731 let name = thinking_level_name(*lvl);
3732 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3733 }
3734 if items.is_empty() {
3735 add_note_message(chat, "This model has no supported thinking levels.");
3736 tui.request_render(false);
3737 return;
3738 }
3739 let list = Arc::new(SelectList::new(items, 10));
3740
3741 let state_sel = state.clone();
3742 let ec_sel = editor_container.clone();
3743 let editor_sel = editor.clone();
3744 let tui_sel = tui.clone();
3745 let chat_sel = chat.clone();
3746 let lane_sel = lane.clone();
3747 list.on_select(Arc::new(move |item| {
3748 let Some(level) = thinking_level_from_name(&item.value) else {
3749 add_note_message(
3750 &chat_sel,
3751 &format!("Unknown thinking level: {}.", item.label),
3752 );
3753 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3754 return;
3755 };
3756 let lane = lane_sel.clone();
3757 let footer_sel = state_sel.footer.clone();
3758 tokio::spawn(async move {
3759 let _ = lane.set_thinking_level(level).await;
3760 });
3761 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3762 let mut settings = crate::settings::load_settings().unwrap_or_default();
3763 settings.default_thinking_level = Some(item.value.clone());
3764 let saved = crate::settings::save_settings(&settings);
3765 add_note_message(
3766 &chat_sel,
3767 &format!(
3768 "Default thinking set to {} (saved{}",
3769 item.label,
3770 if saved.is_ok() { ")" } else { ", not saved)" },
3771 ),
3772 );
3773 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3774 }));
3775 let state_cancel = state.clone();
3776 let ec_cancel = editor_container.clone();
3777 let editor_cancel = editor.clone();
3778 let tui_cancel = tui.clone();
3779 list.on_cancel(Arc::new(move || {
3780 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3781 }));
3782
3783 open_selector(
3784 state,
3785 editor_container,
3786 editor,
3787 tui,
3788 list,
3789 SelectorKind::Settings,
3790 );
3791}
3792
3793fn open_scoped_models_selector(
3798 state: &Arc<TuiState>,
3799 editor_container: &Arc<Container>,
3800 editor: &Arc<Editor>,
3801 tui: &Arc<TuiAltScreen>,
3802 catalog: &[rpi_ai::Model],
3803 chat: &Arc<Container>,
3804) {
3805 if catalog.is_empty() {
3806 add_note_message(chat, "No models in the catalog.");
3807 tui.request_render(false);
3808 return;
3809 }
3810 let seed: Vec<String> = crate::settings::load_settings()
3812 .ok()
3813 .and_then(|s| s.scoped_models)
3814 .unwrap_or_default();
3815 *state.scoped_edit.lock().unwrap() = Some(seed);
3816
3817 let mut items: Vec<SelectItem> = Vec::new();
3818 for m in catalog {
3819 items.push(SelectItem::new(&m.id, &m.id));
3820 }
3821 let list = Arc::new(SelectList::new(items, 10));
3822
3823 let state_sel = state.clone();
3824 let chat_sel = chat.clone();
3825 let tui_sel = tui.clone();
3826 list.on_select(Arc::new(move |item| {
3827 let mut set = state_sel.scoped_edit.lock().unwrap();
3829 let set = set.get_or_insert_with(Vec::new);
3830 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3831 set.remove(pos);
3832 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3833 } else {
3834 set.push(item.value.clone());
3835 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3836 }
3837 tui_sel.request_render(false);
3838 }));
3839 let state_cancel = state.clone();
3840 let ec_cancel = editor_container.clone();
3841 let editor_cancel = editor.clone();
3842 let tui_cancel = tui.clone();
3843 let chat_cancel = chat.clone();
3844 list.on_cancel(Arc::new(move || {
3845 let set = state_cancel
3847 .scoped_edit
3848 .lock()
3849 .unwrap()
3850 .take()
3851 .unwrap_or_default();
3852 let mut settings = crate::settings::load_settings().unwrap_or_default();
3853 settings.scoped_models = if set.is_empty() {
3854 None
3855 } else {
3856 Some(set.clone())
3857 };
3858 match crate::settings::save_settings(&settings) {
3859 Ok(()) => {
3860 if set.is_empty() {
3861 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3862 } else {
3863 add_note_message(
3864 &chat_cancel,
3865 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3866 );
3867 }
3868 }
3869 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3870 }
3871 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3872 }));
3873
3874 open_selector(
3875 state,
3876 editor_container,
3877 editor,
3878 tui,
3879 list,
3880 SelectorKind::ScopedModels,
3881 );
3882}
3883
3884async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3888 use std::process::Stdio;
3889
3890 let tree = harness.session().view("main");
3892 let entries = match tree
3893 .find_entries(&EntryQuery {
3894 entry_type: None,
3895 custom_type: None,
3896 order: Some(EntryOrder::OldestFirst),
3899 limit: None,
3900 cursor: None,
3901 })
3902 .await
3903 {
3904 Ok(e) => e,
3905 Err(e) => {
3906 add_error_message(chat, &format!("Could not read session: {e}"));
3907 return;
3908 }
3909 };
3910 let mut md = String::from("# Session\n\n");
3911 for e in entries {
3912 let Entry::Message(me) = e else { continue };
3913 match &me.message {
3914 AgentMessage::User(u) => {
3915 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3916 }
3917 AgentMessage::Assistant(a) => {
3918 let text = assistant_text(a);
3919 if !text.is_empty() {
3920 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3921 }
3922 }
3923 _ => {}
3924 }
3925 }
3926
3927 let gh = std::process::Command::new("gh")
3929 .arg("gist")
3930 .arg("create")
3931 .arg("--filename")
3932 .arg("session.md")
3933 .arg("-")
3934 .stdin(Stdio::piped())
3935 .stdout(Stdio::piped())
3936 .stderr(Stdio::null())
3937 .spawn();
3938 if let Ok(mut child) = gh {
3939 use std::io::Write;
3940 if let Some(mut stdin) = child.stdin.take() {
3941 let _ = stdin.write_all(md.as_bytes());
3942 let _ = stdin.flush();
3943 }
3944 let out = child.wait_with_output().ok();
3945 if let Some(out) = out {
3946 if out.status.success() {
3947 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3948 add_note_message(chat, &format!("Shared session: {url}"));
3949 return;
3950 }
3951 }
3952 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3953 } else {
3954 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3955 }
3956 if copy_to_clipboard(&md) {
3958 add_note_message(chat, "Session transcript copied to the clipboard.");
3959 } else {
3960 add_note_message(
3961 chat,
3962 "Clipboard unavailable — use /export to write the transcript to a file.",
3963 );
3964 }
3965}
3966
3967async fn switch_to_session(
3982 harness: &AgentHarness,
3983 lane: &Arc<dyn AgentLane>,
3984 id: &str,
3985 cwd: &std::path::Path,
3986 chat: &Arc<Container>,
3987 state: &Arc<TuiState>,
3988) -> bool {
3989 if *state.status.lock().unwrap() == RunStatus::Working {
3990 state.set_status(RunStatus::Aborting);
3991 let _ = lane.abort().await;
3992 }
3993 let cwd_str = cwd.to_string_lossy().to_string();
3994 match crate::session::open_session_by_id(id, &cwd_str).await {
3995 Ok(new_session) => {
3996 let _ = harness.set_session(new_session).await;
3997 chat.clear();
3998 add_welcome_message(chat);
3999 render_session_history(
4000 harness,
4001 chat,
4002 state.markdown_transformer(),
4003 Some(state.extension_session.clone()),
4004 )
4005 .await;
4006 state.set_status(RunStatus::Idle);
4007 add_note_message(chat, &format!("Switched to session {id}."));
4008 true
4009 }
4010 Err(e) => {
4011 state.set_status(RunStatus::Idle);
4012 add_error_message(chat, &format!("Could not open session {id}: {e}"));
4013 false
4014 }
4015 }
4016}
4017
4018async fn import_session(
4022 harness: &AgentHarness,
4023 lane: &Arc<dyn AgentLane>,
4024 path: &str,
4025 cwd: &std::path::Path,
4026 chat: &Arc<Container>,
4027 state: &Arc<TuiState>,
4028) {
4029 use std::path::Path as FsPath;
4030
4031 let src = FsPath::new(path);
4032 if !src.is_file() {
4033 add_error_message(chat, &format!("Import source not found: {path}"));
4034 return;
4035 }
4036 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
4037 add_error_message(chat, "Import source has no file name.");
4038 return;
4039 };
4040 if !fname.ends_with(".jsonl") {
4041 add_error_message(chat, "Import source must be a .jsonl session file.");
4042 return;
4043 }
4044 let dir = crate::session::default_session_dir(cwd);
4045 if let Err(e) = std::fs::create_dir_all(&dir) {
4046 add_error_message(chat, &format!("Could not create session dir: {e}"));
4047 return;
4048 }
4049 let dest = dir.join(fname);
4050 match std::fs::copy(src, &dest) {
4051 Ok(_) => {
4052 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
4053 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
4054 add_note_message(chat, &format!("Imported session from {path}"));
4055 }
4056 }
4057 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
4058 }
4059}
4060
4061async fn fork_session(
4062 harness: &AgentHarness,
4063 cwd: &std::path::Path,
4064 chat: &Arc<Container>,
4065 state: &Arc<TuiState>,
4066) {
4067 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
4068 use rpi_tools::FileSystem;
4069
4070 let cwd_str = cwd.to_string_lossy().to_string();
4071 let dir = crate::session::default_session_dir(cwd);
4072 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
4073 let fs: Arc<dyn FileSystem> = env.clone();
4074 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4075 fs,
4076 sessions_root: dir.to_string_lossy().into_owned(),
4077 clock: Arc::new(rpi_harness::session::memory::SystemClock),
4078 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
4079 });
4080 let id = harness.session().storage().metadata().id.clone();
4083 let metas = match crate::session::list_session_metadata(&cwd_str).await {
4084 Ok(m) => m,
4085 Err(e) => {
4086 add_error_message(chat, &format!("Could not list sessions: {e}"));
4087 return;
4088 }
4089 };
4090 let Some(source) = metas.iter().find(|m| m.id == id) else {
4091 add_error_message(chat, &format!("Current session {id} not found on disk."));
4092 return;
4093 };
4094 let fork_storage = match repo
4095 .fork_typed(
4096 source,
4097 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
4098 id: None,
4099 parent_session_id: Some(source.id.clone()),
4100 cwd: cwd_str.clone(),
4101 metadata: None,
4102 },
4103 &rpi_harness::session::types::ForkOptions::default(),
4104 )
4105 .await
4106 {
4107 Ok(s) => s,
4108 Err(e) => {
4109 add_error_message(chat, &format!("Could not fork session: {e}"));
4110 return;
4111 }
4112 };
4113 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
4114 let _ = harness.set_session(new_session).await;
4115 chat.clear();
4116 add_welcome_message(chat);
4117 render_session_history(
4118 harness,
4119 chat,
4120 state.markdown_transformer(),
4121 Some(state.extension_session.clone()),
4122 )
4123 .await;
4124 state.set_status(RunStatus::Idle);
4125 add_note_message(chat, "Forked into a new session.");
4126}
4127
4128async fn render_session_history(
4138 harness: &AgentHarness,
4139 chat: &Arc<Container>,
4140 transformer: Option<MarkdownTransformer>,
4141 extension_session: Option<crate::session::ExtensionSessionCell>,
4142) {
4143 let tree = harness.session().view("main");
4144 let entries = match tree
4145 .find_entries(&EntryQuery {
4146 entry_type: None,
4147 custom_type: None,
4148 order: Some(EntryOrder::OldestFirst),
4152 limit: None,
4153 cursor: None,
4154 })
4155 .await
4156 {
4157 Ok(e) => e,
4158 Err(_) => return,
4159 };
4160 let mut rendered_any = false;
4161 for e in entries {
4162 match e {
4163 Entry::Message(me) => match &me.message {
4164 AgentMessage::User(u) => {
4165 add_user_message(chat, &user_message_text(u));
4166 rendered_any = true;
4167 }
4168 AgentMessage::Assistant(a) => {
4169 let comp = Arc::new(AssistantMessageComponent::new(
4170 AssistantMessageOptions::default(),
4171 ));
4172 if let Some(t) = &transformer {
4173 comp.set_markdown_transformer(Some(t.clone()));
4174 }
4175 comp.update_blocks(&assistant_blocks(a));
4176 chat.add_child(comp);
4177 chat.add_child(Arc::new(Spacer::new(1)));
4180 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
4181 add_note_message(chat, &text);
4182 }
4183 if let Some(error) = assistant_error_text(a) {
4184 add_error_message(chat, &error);
4185 }
4186 rendered_any = true;
4187 }
4188 AgentMessage::ToolResult(result) => {
4189 let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
4194 comp.set_result(&tool_result_message_text(result), result.is_error);
4195 chat.add_child(comp);
4196 chat.add_child(Arc::new(Spacer::new(1)));
4197 rendered_any = true;
4198 }
4199 AgentMessage::Custom(custom) => {
4200 if let Some(session) = &extension_session {
4201 if let Some(component) = extension_message_component(
4202 session,
4203 &custom.role,
4204 &serde_json::json!({
4205 "customType": custom.role,
4206 "content": custom.content,
4207 "details": custom.data,
4208 }),
4209 transformer.clone(),
4210 ) {
4211 chat.add_child(component);
4212 chat.add_child(Arc::new(Spacer::new(1)));
4213 rendered_any = true;
4214 continue;
4215 }
4216 }
4217 add_note_message(chat, &custom_message_fallback(&custom));
4218 rendered_any = true;
4219 }
4220 },
4221 Entry::Compaction(compaction) => {
4222 add_note_message(
4223 chat,
4224 &format!(
4225 "Compacted {} tokens: {}",
4226 compaction.tokens_before, compaction.summary
4227 ),
4228 );
4229 rendered_any = true;
4230 }
4231 Entry::BranchSummary(summary) => {
4232 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4233 rendered_any = true;
4234 }
4235 Entry::Custom(custom) => {
4236 let rendered = extension_session.as_ref().and_then(|session| {
4237 extension_entry_component(session, &custom.custom_type, custom.data.clone())
4238 });
4239 if let Some(component) = rendered {
4240 chat.add_child(component);
4241 chat.add_child(Arc::new(Spacer::new(1)));
4242 rendered_any = true;
4243 } else if let Some(text) =
4244 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4245 {
4246 add_note_message(chat, &text);
4247 rendered_any = true;
4248 }
4249 }
4250 Entry::ModelChange(change) => {
4251 add_note_message(
4252 chat,
4253 &format!("Model changed to {}:{}", change.provider, change.model_id),
4254 );
4255 rendered_any = true;
4256 }
4257 Entry::ThinkingLevel(change) => {
4258 add_note_message(
4259 chat,
4260 &format!("Thinking level: {:?}", change.thinking_level),
4261 );
4262 rendered_any = true;
4263 }
4264 Entry::ActiveTools(change) => {
4265 add_note_message(
4266 chat,
4267 &format!("Active tools: {}", change.active_tool_names.join(", ")),
4268 );
4269 rendered_any = true;
4270 }
4271 }
4272 }
4273 if rendered_any {
4274 }
4277}
4278
4279fn invoke_extension_renderer(
4280 session: &crate::session::ExtensionSessionCell,
4281 kind: rpi_extensions::RegisteredRendererKind,
4282 payload: &serde_json::Value,
4283) -> Option<serde_json::Value> {
4284 let snapshot = session.lock().ok()?.snapshot_arc()?;
4285 let input = serde_json::to_string(payload).ok()?;
4286 for renderer in snapshot.renderers_of(kind) {
4287 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4288 let mut out = rpi_plugin_sdk::StbString::empty();
4289 let rc = (renderer.render_fn)(
4290 rpi_plugin_sdk::StbStringRef::from_str(&input),
4291 &mut out as *mut rpi_plugin_sdk::StbString,
4292 renderer.user_data,
4293 );
4294 let text = if rc == 0 {
4295 Some(out.to_string_lossy())
4296 } else {
4297 None
4298 };
4299 out.free_with(Some(renderer.plugin_free_string));
4300 text
4301 }))
4302 .ok()
4303 .flatten();
4304 let Some(text) = outcome else { continue };
4305 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4306 return Some(value);
4307 }
4308 }
4309 None
4310}
4311
4312fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4313 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4314 let text = lines
4315 .iter()
4316 .filter_map(|line| line.as_str())
4317 .collect::<Vec<_>>()
4318 .join("\n");
4319 return Some(Arc::new(Text::new(text, 0, 0)));
4320 }
4321 let text = value.get("text").and_then(|v| v.as_str())?;
4322 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4323 let component = Arc::new(AssistantMessageComponent::new(
4324 AssistantMessageOptions::default(),
4325 ));
4326 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4327 Some(component)
4328 } else {
4329 Some(Arc::new(Text::new(text, 0, 0)))
4330 }
4331}
4332
4333fn extension_message_component(
4334 session: &crate::session::ExtensionSessionCell,
4335 custom_type: &str,
4336 payload: &serde_json::Value,
4337 transformer: Option<MarkdownTransformer>,
4338) -> Option<Arc<dyn rpi_tui::Component>> {
4339 let value = invoke_extension_renderer(
4340 session,
4341 rpi_extensions::RegisteredRendererKind::Message,
4342 payload,
4343 )?;
4344 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4345 let text = value.get("text").and_then(|v| v.as_str())?;
4346 let component = Arc::new(AssistantMessageComponent::new(
4347 AssistantMessageOptions::default(),
4348 ));
4349 if let Some(transformer) = transformer {
4350 component.set_markdown_transformer(Some(transformer));
4351 }
4352 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4353 return Some(component);
4354 }
4355 extension_text_component(&value)
4356 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4357}
4358
4359fn extension_usage_text(
4362 session: Option<&crate::session::ExtensionSessionCell>,
4363 usage: &rpi_ai::types::Usage,
4364) -> Option<String> {
4365 let session = session?;
4366 let payload = serde_json::json!({
4367 "customType": "token-usage",
4368 "usage": usage,
4369 });
4370 let value = invoke_extension_renderer(
4371 session,
4372 rpi_extensions::RegisteredRendererKind::Message,
4373 &payload,
4374 )?;
4375 value
4376 .get("text")
4377 .and_then(|value| value.as_str())
4378 .filter(|text| !text.trim().is_empty())
4379 .map(ToOwned::to_owned)
4380}
4381
4382fn extension_entry_component(
4383 session: &crate::session::ExtensionSessionCell,
4384 custom_type: &str,
4385 data: Option<serde_json::Value>,
4386) -> Option<Arc<dyn rpi_tui::Component>> {
4387 let payload = serde_json::json!({
4388 "customType": custom_type,
4389 "data": data,
4390 });
4391 let value = invoke_extension_renderer(
4392 session,
4393 rpi_extensions::RegisteredRendererKind::Entry,
4394 &payload,
4395 )?;
4396 extension_text_component(&value)
4397}
4398
4399fn launch_restores_history(args: &Args) -> bool {
4405 args.continue_session
4406 || args.resume
4407 || args.session.is_some()
4408 || args.session_id.is_some()
4409 || args.fork.is_some()
4410}
4411
4412fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4413 msg.content
4414 .iter()
4415 .filter_map(|c| match c {
4416 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4417 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4418 Content::Image(image) => base64::engine::general_purpose::STANDARD
4419 .decode(&image.data)
4420 .ok()
4421 .filter(|data| !data.is_empty())
4422 .map(AssistantBlock::Image),
4423 _ => None,
4424 })
4425 .collect()
4426}
4427
4428fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4429 let content = custom
4430 .content
4431 .iter()
4432 .filter_map(|item| match item {
4433 Content::Text(text) => Some(text.text.as_str()),
4434 _ => None,
4435 })
4436 .collect::<Vec<_>>()
4437 .join("\n");
4438 if content.is_empty() {
4439 format!("{}: {}", custom.role, custom.data)
4440 } else {
4441 format!("{}: {}", custom.role, content)
4442 }
4443}
4444
4445fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4447 let mut dir = Some(cwd);
4448 while let Some(current) = dir {
4449 let head = current.join(".git").join("HEAD");
4450 if head.exists() {
4451 return Some(head);
4452 }
4453 dir = current.parent();
4454 }
4455 None
4456}
4457
4458fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4462 let mut dir = Some(cwd);
4463 while let Some(current) = dir {
4464 let head = current.join(".git").join("HEAD");
4465 if let Ok(contents) = std::fs::read_to_string(&head) {
4466 let trimmed = contents.trim();
4467 if let Some(reference) = trimmed.strip_prefix("ref: ") {
4468 let branch = reference
4469 .rsplit('/')
4470 .next()
4471 .unwrap_or(reference)
4472 .trim()
4473 .to_string();
4474 if !branch.is_empty() {
4475 return Some(branch);
4476 }
4477 }
4478 if !trimmed.is_empty() {
4479 return Some(trimmed.chars().take(7).collect());
4480 }
4481 }
4482 dir = current.parent();
4483 }
4484 None
4485}
4486
4487fn short_model_name(id: &str) -> String {
4490 id.rsplit([':', '/'])
4491 .next()
4492 .filter(|s| !s.is_empty())
4493 .unwrap_or(id)
4494 .to_string()
4495}
4496
4497#[derive(Clone, Copy, PartialEq, Eq)]
4503enum RunStatus {
4504 Idle,
4505 Working,
4506 Aborting,
4507}
4508
4509#[derive(Clone, Copy, PartialEq, Eq)]
4511enum SelectorKind {
4512 Model,
4514 Thinking,
4516 Tools,
4518 Images,
4520 Session,
4522 Theme,
4524 ScopedModels,
4526 Settings,
4528 Tree,
4530 Extension,
4532}
4533
4534#[derive(Clone)]
4541enum SelectorView {
4542 List(Arc<SelectList>),
4544 Searchable(Arc<SearchableSelectList>),
4546 Settings(Arc<SettingsList>),
4549}
4550
4551impl SelectorView {
4552 fn handle_key(&self, key: KeyEvent) {
4554 match self {
4555 Self::List(list) => list.handle_key(key),
4556 Self::Searchable(searchable) => searchable.handle_key(key),
4557 Self::Settings(settings) => settings.handle_key(key),
4558 }
4559 }
4560}
4561
4562struct TuiState {
4565 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4567 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4569 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4573 themes_enabled: bool,
4575 hide_thinking: std::sync::Mutex<bool>,
4577 tool_outputs_expanded: std::sync::Mutex<bool>,
4579 show_terminal_progress: bool,
4581 status: std::sync::Mutex<RunStatus>,
4583 ext_status: rpi_extensions::ExtensionStatusMailbox,
4586 ext_status_revision: std::sync::atomic::AtomicU64,
4588 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4592 user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4596 pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4600 footer: Arc<FooterComponent>,
4602 status_container: Arc<Container>,
4605 chat_container: Arc<Container>,
4607 loader: Arc<Loader>,
4609 editor: Arc<Editor>,
4612 last_assistant_text: std::sync::Mutex<String>,
4615 active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4619 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4621 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4623 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4625 autocomplete: AutocompleteManager,
4628 autocomplete_container: Arc<Container>,
4631 autocomplete_max_visible: usize,
4633 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4635 theme_manager: Arc<ThemeManager>,
4639 tui: Option<Arc<TuiAltScreen>>,
4643 current_model_id: std::sync::Mutex<String>,
4647 show_images: std::sync::Mutex<bool>,
4651 cache_miss_notices: std::sync::Mutex<bool>,
4654 history: std::sync::Mutex<Vec<String>>,
4657 history_index: std::sync::Mutex<isize>,
4660 history_draft: std::sync::Mutex<Option<String>>,
4663 cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4667 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4670 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4680 extension_session: crate::session::ExtensionSessionCell,
4682 search: Arc<AltScreenSearch>,
4684 search_bar: Arc<SearchBar>,
4686 pending_container: Arc<Container>,
4690 dequeue_hint: String,
4693 pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4697 selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4699 selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4700}
4701
4702const HISTORY_LIMIT: usize = 100;
4705
4706const PAGE_SCROLL_OVERLAP: usize = 4;
4709
4710const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4714
4715fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4718 let mut modifiers = KeyModifiers::NONE;
4719 let mut key = None;
4720 for part in value.trim().to_ascii_lowercase().split('+') {
4721 match part {
4722 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4723 "shift" => modifiers |= KeyModifiers::SHIFT,
4724 "alt" | "option" => modifiers |= KeyModifiers::ALT,
4725 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4726 part if !part.is_empty() => key = Some(part.to_string()),
4727 _ => {}
4728 }
4729 }
4730 let key = key?;
4731 let code = match key.as_str() {
4732 "esc" | "escape" => KeyCode::Esc,
4733 "enter" | "return" => KeyCode::Enter,
4734 "tab" => {
4735 if modifiers.contains(KeyModifiers::SHIFT) {
4736 return Some(rpi_tui::KeyCombo::new(
4737 KeyCode::BackTab,
4738 modifiers & !KeyModifiers::SHIFT,
4739 ));
4740 }
4741 KeyCode::Tab
4742 }
4743 "backspace" | "back" => KeyCode::Backspace,
4744 "delete" | "del" => KeyCode::Delete,
4745 "up" | "arrowup" => KeyCode::Up,
4746 "down" | "arrowdown" => KeyCode::Down,
4747 "left" | "arrowleft" => KeyCode::Left,
4748 "right" | "arrowright" => KeyCode::Right,
4749 "home" => KeyCode::Home,
4750 "end" => KeyCode::End,
4751 "pageup" | "page-up" => KeyCode::PageUp,
4752 "pagedown" | "page-down" => KeyCode::PageDown,
4753 "space" => KeyCode::Char(' '),
4754 "f1" => KeyCode::F(1),
4755 "f2" => KeyCode::F(2),
4756 "f3" => KeyCode::F(3),
4757 "f4" => KeyCode::F(4),
4758 "f5" => KeyCode::F(5),
4759 "f6" => KeyCode::F(6),
4760 "f7" => KeyCode::F(7),
4761 "f8" => KeyCode::F(8),
4762 "f9" => KeyCode::F(9),
4763 "f10" => KeyCode::F(10),
4764 "f11" => KeyCode::F(11),
4765 "f12" => KeyCode::F(12),
4766 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4767 _ => return None,
4768 };
4769 Some(rpi_tui::KeyCombo::new(code, modifiers))
4770}
4771
4772fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4773 let mut bindings = rpi_tui::Keybindings::new();
4774 let settings = crate::settings::load_settings().unwrap_or_default();
4775 let Some(overrides) = settings.keybindings else {
4776 rpi_tui::set_keybindings(bindings.clone());
4777 return Arc::new(bindings);
4778 };
4779 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4780 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4781 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4782 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4783 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4784 (
4785 "app.model.cycleForward",
4786 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4787 ),
4788 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4789 (
4790 "app.thinking.toggle",
4791 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4792 ),
4793 (
4794 "app.editor.external",
4795 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4796 ),
4797 (
4798 "app.thinking.cycle",
4799 rpi_tui::keybindings::keys::THINKING_CYCLE,
4800 ),
4801 (
4802 "app.clipboard.pasteImage",
4803 rpi_tui::keybindings::keys::PASTE_IMAGE,
4804 ),
4805 ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4806 ];
4807 for (name, id) in known {
4808 let Some(value) = overrides.get(*name) else {
4809 continue;
4810 };
4811 let values: Vec<String> = match value {
4812 serde_json::Value::String(value) => vec![value.clone()],
4813 serde_json::Value::Array(values) => values
4814 .iter()
4815 .filter_map(|v| v.as_str().map(str::to_string))
4816 .collect(),
4817 serde_json::Value::Null => Vec::new(),
4818 _ => continue,
4819 };
4820 let combos: Vec<_> = values
4821 .iter()
4822 .filter_map(|value| parse_configured_key(value))
4823 .collect();
4824 if values.is_empty() || !combos.is_empty() {
4825 bindings.set(id, combos);
4826 }
4827 }
4828 rpi_tui::set_keybindings(bindings.clone());
4829 Arc::new(bindings)
4830}
4831
4832fn keybinding_matches(
4833 bindings: &rpi_tui::Keybindings,
4834 event: &crossterm::event::KeyEvent,
4835 id: rpi_tui::KeybindingId,
4836) -> bool {
4837 if bindings.matches(event, id) {
4838 return true;
4839 }
4840 if event.code == KeyCode::BackTab {
4843 let normalized =
4844 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4845 bindings.matches(&normalized, id)
4846 } else {
4847 false
4848 }
4849}
4850
4851fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4855 let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4856 if keys.is_empty() {
4857 return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4858 }
4859 keys.iter()
4860 .map(|combo| combo.display())
4861 .collect::<Vec<_>>()
4862 .join("/")
4863}
4864
4865fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4866 last.is_some_and(|previous| {
4867 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4868 })
4869}
4870
4871const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4875
4876const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4879
4880fn enter_is_paste_burst(
4901 last_text_key_at: Option<std::time::Instant>,
4902 now: std::time::Instant,
4903 more_queued: bool,
4904) -> bool {
4905 if more_queued {
4906 return true;
4907 }
4908 last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4909}
4910
4911fn probe_paste_input(
4922 pending: Option<Event>,
4923 mut next_event: impl FnMut() -> Option<Event>,
4924) -> (bool, Option<Event>) {
4925 let mut pending = pending;
4926 let mut more_queued = pending.is_some();
4927 while !more_queued {
4928 match next_event() {
4929 Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4930 Some(ev) => {
4931 pending = Some(ev);
4932 more_queued = true;
4933 }
4934 None => break,
4935 }
4936 }
4937 (more_queued, pending)
4938}
4939
4940fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
4943 match last_text_key_at {
4944 Some(previous) => std::time::Instant::now()
4945 .duration_since(previous)
4946 .as_millis()
4947 .to_string(),
4948 None => "-".to_string(),
4949 }
4950}
4951
4952fn transcript_page_size(viewport_height: usize) -> i32 {
4953 viewport_height
4954 .saturating_sub(PAGE_SCROLL_OVERLAP)
4955 .max(1)
4956 .min(i32::MAX as usize) as i32
4957}
4958
4959fn should_dispatch_key(kind: KeyEventKind) -> bool {
4960 kind != KeyEventKind::Release
4961}
4962
4963fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4973 tui.stop(Default::default());
4977 eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4978 std::process::exit(130);
4979}
4980
4981fn format_tokens(n: i64) -> String {
4983 if n >= 1_000_000 {
4984 format!("{:.1}M", n as f64 / 1_000_000.0)
4985 } else if n >= 1_000 {
4986 format!("{:.1}K", n as f64 / 1_000.0)
4987 } else {
4988 n.to_string()
4989 }
4990}
4991
4992fn push_history(state: &Arc<TuiState>, text: &str) {
4996 let trimmed = text.trim().to_string();
4997 if trimmed.is_empty() {
4998 return;
4999 }
5000 let mut history = state.history.lock().unwrap();
5001 if history.first() == Some(&trimmed) {
5002 return;
5003 }
5004 history.insert(0, trimmed);
5005 history.truncate(HISTORY_LIMIT);
5006 *state.history_index.lock().unwrap() = -1;
5007 *state.history_draft.lock().unwrap() = None;
5008}
5009
5010fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5015 let history = state.history.lock().unwrap();
5016 if history.is_empty() {
5017 return;
5018 }
5019 let mut index = state.history_index.lock().unwrap();
5020 let new_index = *index - direction as isize;
5021 if new_index < -1 || new_index >= history.len() as isize {
5022 return;
5023 }
5024 if *index == -1 && new_index >= 0 {
5025 *state.history_draft.lock().unwrap() = Some(editor.get_text());
5027 }
5028 *index = new_index;
5029 if new_index == -1 {
5030 let draft = state.history_draft.lock().unwrap().take();
5032 match draft {
5033 Some(d) => {
5034 let len = d.len();
5035 editor.set_text(&d);
5036 editor.set_cursor(0, len);
5037 }
5038 None => editor.set_text(""),
5039 }
5040 } else {
5041 let text = history[new_index as usize].clone();
5042 let len = text.len();
5043 editor.set_text(&text);
5044 editor.set_cursor(0, len);
5045 }
5046}
5047
5048impl TuiState {
5049 fn begin_js_preparation(&self) -> CancellationToken {
5050 let cancellation = CancellationToken::new();
5051 if let Some(previous) = self
5052 .js_preparation_cancel
5053 .lock()
5054 .unwrap()
5055 .replace(cancellation.clone())
5056 {
5057 previous.cancel();
5058 }
5059 cancellation
5060 }
5061
5062 fn finish_js_preparation(&self) {
5063 self.js_preparation_cancel.lock().unwrap().take();
5064 }
5065
5066 fn cancel_js_preparation(&self) -> bool {
5067 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5068 if let Some(cancellation) = cancellation {
5069 cancellation.cancel();
5070 true
5071 } else {
5072 false
5073 }
5074 }
5075
5076 fn user_bash_running(&self) -> bool {
5078 self.user_bash_cancel.lock().unwrap().is_some()
5079 }
5080
5081 fn begin_user_bash(&self) -> CancellationToken {
5084 let cancellation = CancellationToken::new();
5085 if let Some(previous) = self
5086 .user_bash_cancel
5087 .lock()
5088 .unwrap()
5089 .replace(cancellation.clone())
5090 {
5091 previous.cancel();
5092 }
5093 cancellation
5094 }
5095
5096 fn finish_user_bash(&self) {
5099 self.user_bash_cancel.lock().unwrap().take();
5100 }
5101
5102 fn cancel_user_bash(&self) -> bool {
5106 let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5107 if let Some(cancellation) = cancellation {
5108 cancellation.cancel();
5109 true
5110 } else {
5111 false
5112 }
5113 }
5114
5115 fn set_status(&self, status: RunStatus) {
5116 *self.status.lock().unwrap() = status;
5117 self.apply_status(status);
5118 }
5119
5120 fn pending_container(&self) -> &Arc<Container> {
5123 &self.pending_container
5124 }
5125
5126 fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5132 {
5133 let mut current = self.pending_snapshot.lock().unwrap();
5134 if *current == messages {
5135 return;
5136 }
5137 *current = messages.clone();
5138 }
5139 let container = &self.pending_container;
5140 container.clear();
5141 if messages.is_empty() {
5142 return;
5143 }
5144 let colors = current_theme().colors;
5145 container.add_child(Arc::new(Spacer::new(1)));
5146 for text in &messages.steering {
5147 container.add_child(Arc::new(Text::new(
5148 colors.muted.fg(&format!("Steering: {text}")),
5149 1,
5150 0,
5151 )));
5152 }
5153 for text in &messages.follow_up {
5154 container.add_child(Arc::new(Text::new(
5155 colors.muted.fg(&format!("Follow-up: {text}")),
5156 1,
5157 0,
5158 )));
5159 }
5160 container.add_child(Arc::new(Text::new(
5161 colors.muted.fg(&format!(
5162 "↳ {} to edit all queued messages",
5163 self.dequeue_hint
5164 )),
5165 1,
5166 0,
5167 )));
5168 }
5169
5170 fn try_start_working(&self) -> bool {
5175 let mut status = self.status.lock().unwrap();
5176 if *status != RunStatus::Idle {
5177 return false;
5178 }
5179 *status = RunStatus::Working;
5180 drop(status);
5181 self.apply_status(RunStatus::Working);
5182 true
5183 }
5184
5185 fn apply_status(&self, status: RunStatus) {
5186 match status {
5187 RunStatus::Working => {
5188 self.footer.set_status("");
5192 if let Some(tui) = &self.tui {
5195 tui.set_title("🦀π rpi ⟳");
5196 }
5197 self.status_container.clear();
5198 if self.show_terminal_progress {
5201 self.editor.set_working(Some(WorkingState {
5202 frame: 0,
5203 started_at: std::time::Instant::now(),
5204 message: "Working…".to_string(),
5205 }));
5206 } else {
5207 self.editor.set_working(None);
5208 }
5209 }
5210 RunStatus::Aborting => {
5211 self.footer.set_status("Aborting…");
5212 self.loader.stop();
5215 self.status_container.clear();
5216 self.editor.set_working(None);
5217 }
5218 RunStatus::Idle => {
5219 self.footer.set_status("");
5220 if let Some(tui) = &self.tui {
5221 tui.set_title("🦀π rpi");
5222 }
5223 self.loader.stop();
5224 self.status_container.clear();
5225 self.editor.set_working(None);
5226 }
5227 }
5228 }
5229
5230 fn sync_extension_status(&self) -> bool {
5234 let revision = self.ext_status.revision();
5235 if self
5236 .ext_status_revision
5237 .load(std::sync::atomic::Ordering::SeqCst)
5238 == revision
5239 {
5240 return false;
5241 }
5242 self.ext_status_revision
5243 .store(revision, std::sync::atomic::Ordering::SeqCst);
5244 self.footer.set_extension_status(&self.ext_status.text());
5245 true
5246 }
5247
5248 fn sync_working_loader_with_bash(&self) {
5252 if *self.status.lock().unwrap() != RunStatus::Working {
5253 return;
5254 }
5255
5256 self.status_container.clear();
5261 }
5262
5263 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5264 *self.status.lock().unwrap() = RunStatus::Working;
5265 self.footer.set_status("");
5266 if let Some(tui) = &self.tui {
5267 tui.set_title("🦀π rpi ↻");
5268 }
5269 self.loader.stop();
5270 self.status_container.clear();
5271 self.status_container
5272 .add_child(Arc::new(StatusIndicator::retry(
5273 attempt,
5274 max_retries,
5275 std::time::Duration::from_millis(delay_ms),
5276 )));
5277 }
5278
5279 fn selector_open(&self) -> bool {
5281 self.active_selector.lock().unwrap().is_some()
5282 }
5283
5284 fn extension_editor_open(&self) -> bool {
5285 self.active_extension_editor.lock().unwrap().is_some()
5286 }
5287
5288 fn extension_input_open(&self) -> bool {
5289 self.active_extension_input.lock().unwrap().is_some()
5290 }
5291
5292 fn extension_dialog_open(&self) -> bool {
5293 self.extension_editor_open() || self.extension_input_open()
5294 }
5295
5296 fn set_hide_thinking(&self, hide: bool) {
5297 *self.hide_thinking.lock().unwrap() = hide;
5298 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5299 comp.set_hide_thinking(hide);
5300 }
5301 }
5302
5303 fn hide_thinking(&self) -> bool {
5304 *self.hide_thinking.lock().unwrap()
5305 }
5306
5307 fn toggle_thinking(&self) -> bool {
5308 let next = !self.hide_thinking();
5309 self.set_hide_thinking(next);
5310 next
5311 }
5312
5313 fn toggle_tool_outputs(&self) -> bool {
5314 let next = !*self.tool_outputs_expanded.lock().unwrap();
5315 *self.tool_outputs_expanded.lock().unwrap() = next;
5316 for comp in self.tool_components.lock().unwrap().values() {
5317 comp.set_expanded(next);
5318 }
5319 for comp in self.bash_components.lock().unwrap().values() {
5320 comp.set_expanded(next);
5321 }
5322 next
5323 }
5324
5325 fn current_model_id(&self) -> String {
5327 self.current_model_id.lock().unwrap().clone()
5328 }
5329
5330 fn set_current_model(&self, model: &rpi_ai::Model) {
5333 *self.current_model_id.lock().unwrap() = model.id.clone();
5334 self.footer.set_model(&short_model_name(&model.id));
5335 self.footer.set_context_window(model.context_window as i64);
5338 }
5339
5340 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5344 self.markdown_transformer.lock().unwrap().clone()
5345 }
5346
5347 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5356 *self.markdown_transformer.lock().unwrap() = transformer.clone();
5357 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5358 comp.set_markdown_transformer(transformer);
5359 }
5360 }
5361
5362 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5363 self.pending_images.lock().unwrap().push(image);
5364 }
5365
5366 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5367 std::mem::take(&mut *self.pending_images.lock().unwrap())
5368 }
5369}
5370
5371#[derive(Debug, PartialEq)]
5376struct TuiStartupSettings {
5377 editor_padding_x: usize,
5378 autocomplete_max_visible: usize,
5379 hide_thinking: bool,
5380 quiet_startup: bool,
5381 show_terminal_progress: bool,
5382 show_images: bool,
5384 cache_miss_notices: bool,
5386}
5387
5388fn preferred_project_setting<T>(
5389 project_settings: &[crate::settings::Settings],
5390 field: impl Fn(&crate::settings::Settings) -> Option<T>,
5391) -> Option<T> {
5392 project_settings.iter().find_map(field)
5393}
5394
5395fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5396 verbose || !quiet_startup
5397}
5398
5399fn git_only_update_resources(
5400 mut resources: crate::packages::PackageResources,
5401) -> crate::packages::PackageResources {
5402 resources
5403 .packages
5404 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5405 resources
5406}
5407
5408fn resolve_tui_startup_settings(
5412 global: &crate::settings::Settings,
5413 project_settings: &[crate::settings::Settings],
5414 project_trusted: bool,
5415) -> TuiStartupSettings {
5416 let project_settings = if project_trusted {
5417 project_settings
5418 } else {
5419 &[]
5420 };
5421 let editor_padding_x =
5422 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5423 .or(global.editor_padding_x)
5424 .unwrap_or(1)
5425 .min(16);
5426 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5427 settings.autocomplete_max_visible
5428 })
5429 .or(global.autocomplete_max_visible)
5430 .unwrap_or(5)
5431 .clamp(1, 20);
5432 let hide_thinking =
5433 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5434 .or(global.hide_thinking_block)
5435 .unwrap_or(false);
5436 let quiet_startup =
5437 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5438 .or(global.quiet_startup)
5439 .unwrap_or(false);
5440 let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5441 settings.show_terminal_progress()
5442 })
5443 .or_else(|| global.show_terminal_progress())
5444 .unwrap_or(true);
5445 let show_images =
5446 preferred_project_setting(project_settings, |settings| settings.show_images())
5447 .or_else(|| global.show_images())
5448 .unwrap_or(true);
5449 let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5450 settings.show_cache_miss_notices
5451 })
5452 .or(global.show_cache_miss_notices)
5453 .unwrap_or(false);
5454
5455 TuiStartupSettings {
5456 editor_padding_x,
5457 autocomplete_max_visible,
5458 hide_thinking,
5459 quiet_startup,
5460 show_terminal_progress,
5461 show_images,
5462 cache_miss_notices,
5463 }
5464}
5465
5466pub async fn interactive_tui(
5480 harness: &AgentHarness,
5481 event_rx: Option<broadcast::Receiver<AgentEvent>>,
5482 args: &Args,
5483 model_catalog: Vec<rpi_ai::Model>,
5484 initial: Option<String>,
5485 extra_messages: &[String],
5486 initial_images: Vec<rpi_ai::types::ImageContent>,
5487 theme: Option<&str>,
5488 no_themes: bool,
5489 reload_context: &crate::session::ReloadContext,
5490) -> i32 {
5491 let lane: Arc<dyn AgentLane> = harness.lane("main");
5492 let cwd = reload_context.cwd.clone();
5496 let saved_settings = crate::settings::load_settings().unwrap_or_default();
5497 let project_trusted = reload_context.project_trusted;
5498 let project_settings = if project_trusted {
5499 crate::settings::load_project_settings(&cwd)
5500 } else {
5501 Vec::new()
5502 };
5503 let tui_settings =
5504 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5505 let editor_padding_x = tui_settings.editor_padding_x;
5506 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5507 let mut hide_thinking = tui_settings.hide_thinking;
5508 let show_terminal_progress = tui_settings.show_terminal_progress;
5509 let show_images = tui_settings.show_images;
5510 let cache_miss_notices = tui_settings.cache_miss_notices;
5511 let quiet_startup = tui_settings.quiet_startup;
5512 let update_checks_enabled =
5513 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5514
5515 let session_values = rpi_harness::session::SessionValues::load(harness.session())
5522 .await
5523 .unwrap_or_default();
5524 if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5525 hide_thinking = persisted;
5526 }
5527
5528 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5532 let model_name = short_model_name(&lane_model_id);
5533
5534 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5538 if active_tool_names.is_empty() {
5542 active_tool_names = harness
5543 .get_tools()
5544 .await
5545 .unwrap_or_default()
5546 .into_iter()
5547 .map(|tool| tool.tool.schema().name.clone())
5548 .collect();
5549 }
5550 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5551 let skill_names: Vec<String> = resources_snapshot
5552 .skills
5553 .as_deref()
5554 .unwrap_or(&[])
5555 .iter()
5556 .map(|skill| skill.name.clone())
5557 .collect();
5558
5559 let package_resources = reload_context.package_resources.clone();
5561
5562 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5564
5565 let theme_manager = Arc::new(ThemeManager::new());
5569 if let Some(preset) = match theme {
5570 Some("light") => Some(ThemePreset::Light),
5571 Some("monochrome") => Some(ThemePreset::Monochrome),
5572 Some("dark") => Some(ThemePreset::Dark),
5573 _ => None,
5574 } {
5575 apply_theme_preset(preset);
5576 theme_manager.apply_preset(preset);
5577 } else if !no_themes {
5578 if let Some(name) = theme {
5579 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
5580 Ok(custom) => {
5581 rpi_tui::global_theme_manager().set(custom.clone());
5582 theme_manager.set(custom);
5583 }
5584 Err(error) => {
5585 eprintln!("warning: could not load package theme `{name}`: {error}");
5586 }
5587 }
5588 }
5589 }
5590
5591 let terminal = Box::new(ProcessTerminal::new());
5593 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5594 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
5595 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5600 ask_user_bridge.attach();
5601 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5602
5603 if let Some(js) = &reload_context.js_extension_session {
5604 if let Err(error) = js.install_ui_runtime(tui.clone()) {
5605 eprintln!("warning: could not enable JS custom UI bridge: {error}");
5606 } else if let Some(js_active) = js.active_tools() {
5607 let js_names = js.tool_names();
5611 let mut active = lane.get_active_tools().await.unwrap_or_default();
5612 active.retain(|name| {
5613 crate::session::tool_name_allowed(name, args)
5614 && !js_names.iter().any(|js_name| js_name == name)
5615 });
5616 active.extend(js_active.into_iter().filter(|name| {
5617 js_names.iter().any(|js_name| js_name == name)
5618 && crate::session::tool_name_allowed(name, args)
5619 }));
5620 active = crate::session::filter_active_tool_names(active, args);
5621 let _ = lane.set_active_tools(active).await;
5622 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5623 }
5624 }
5625
5626 let chat_container = Arc::new(Container::new());
5627 if let Some(js) = &reload_context.js_extension_session {
5628 let chat_notify = chat_container.clone();
5632 let tui_notify = tui.clone();
5633 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
5634 if action != "ui.notify" {
5635 return Err(format!("unsupported capability: {action}"));
5636 }
5637 let message = args
5638 .get("message")
5639 .and_then(serde_json::Value::as_str)
5640 .unwrap_or_default();
5641 if !message.is_empty() {
5642 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
5643 add_error_message(&chat_notify, message);
5644 } else {
5645 add_note_message(&chat_notify, message);
5646 }
5647 tui_notify.request_render(false);
5648 }
5649 Ok(serde_json::json!(true))
5650 })) {
5651 if args.verbose {
5652 eprintln!("warning: could not enable JS notification bridge: {error}");
5653 }
5654 }
5655 }
5656 if should_show_startup_listing(args.verbose, quiet_startup) {
5657 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5658 }
5659
5660 crate::extras::maybe_first_time_setup(&chat_container);
5666
5667 let initial_transformer = build_markdown_transformer(
5672 reload_context
5673 .extension_session
5674 .lock()
5675 .unwrap()
5676 .snapshot_arc(),
5677 );
5678 if launch_restores_history(args) {
5682 render_session_history(
5683 &harness,
5684 &chat_container,
5685 initial_transformer.clone(),
5686 Some(reload_context.extension_session.clone()),
5687 )
5688 .await;
5689 }
5690
5691 let document_container = Arc::new(Container::new());
5694 document_container.add_child(chat_container.clone());
5695 document_container.add_child(Arc::new(Spacer::new(1)));
5697
5698 let scroll_view = Arc::new(ScrollView::new(
5699 document_container.clone(),
5700 ScrollViewOptions {
5701 follow: FollowMode::End,
5702 primary: true,
5703 overscroll: OverscrollMode::Chain,
5704 scrollbar: ScrollbarMode::Hidden,
5708 ..Default::default()
5709 },
5710 ));
5711
5712 let keybindings = configured_keybindings();
5717 let editor = Arc::new(Editor::new(
5718 EditorOptions {
5719 padding_x: editor_padding_x,
5720 autocomplete_max_visible,
5721 ..Default::default()
5722 },
5723 EditorStyle::default(),
5724 keybindings.clone(),
5725 ));
5726
5727 {
5732 let editor_for_change = editor.clone();
5733 editor.on_change(Arc::new(move |text: &str| {
5734 let colors = current_theme().colors;
5735 let is_bash = text.trim_start().starts_with('!');
5736 editor_for_change.set_border_color(if is_bash {
5737 Some(colors.bash_mode)
5738 } else {
5739 None
5740 });
5741 }));
5742 }
5743
5744 let footer = Arc::new(FooterComponent::new());
5746 footer.set_model(&model_name);
5747 footer.set_cwd(&cwd.to_string_lossy());
5748 footer.set_git_branch(git_branch_for(&cwd).as_deref());
5749 if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5750 footer.set_context_window(m.context_window as i64);
5751 }
5752 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");
5753
5754 let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5757 let footer_ref = footer.clone();
5758 let cwd_ref = cwd.clone();
5759 crate::fs_watch::watch_path(head, move || {
5760 footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5761 })
5762 });
5763
5764 let status_container = Arc::new(Container::new());
5765 let loader = Arc::new(Loader::with_text("Working…"));
5766
5767 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5781 .prompt_templates
5782 .clone()
5783 .unwrap_or_default()
5784 .iter()
5785 .map(|t| SlashCommandEntry {
5786 name: format!("/{}", t.name),
5787 description: t
5788 .description
5789 .clone()
5790 .unwrap_or_else(|| "Expand prompt template".to_string()),
5791 })
5792 .collect();
5793 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5794 Arc::new(resources_snapshot);
5795 let mut command_registry = build_builtin_registry();
5801 register_extension_commands(
5802 &mut command_registry,
5803 reload_context.extension_session.clone(),
5804 );
5805 register_js_extension_commands(
5806 &mut command_registry,
5807 reload_context.js_extension_session.clone(),
5808 );
5809 let registry = Arc::new(command_registry);
5810 let mut all_slash_commands = registry.visible_entries();
5811 all_slash_commands.extend(template_slash_commands);
5812 let autocomplete = AutocompleteManager::new();
5813 {
5814 let mut combined = CombinedAutocompleteProvider::new();
5815 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5816 all_slash_commands,
5817 )));
5818 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5819 cwd.clone(),
5820 )));
5821 autocomplete.set_provider(Arc::new(combined));
5822 }
5823 let autocomplete_container = Arc::new(Container::new());
5824
5825 let tool_outputs_expanded = session_values
5826 .get_as::<bool>("display.tool_outputs_expanded")
5827 .unwrap_or(false);
5828 let state = Arc::new(TuiState {
5829 current_assistant: std::sync::Mutex::new(None),
5830 tool_components: std::sync::Mutex::new(HashMap::new()),
5831 bash_components: std::sync::Mutex::new(HashMap::new()),
5832 themes_enabled: !no_themes,
5833 hide_thinking: std::sync::Mutex::new(hide_thinking),
5834 tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5835 show_terminal_progress,
5836 status: std::sync::Mutex::new(RunStatus::Idle),
5837 ext_status: reload_context.ext_status.clone(),
5838 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
5839 js_preparation_cancel: std::sync::Mutex::new(None),
5840 user_bash_cancel: std::sync::Mutex::new(None),
5841 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5842 footer: footer.clone(),
5843 status_container: status_container.clone(),
5844 chat_container: chat_container.clone(),
5845 loader: loader.clone(),
5846 editor: editor.clone(),
5847 last_assistant_text: std::sync::Mutex::new(String::new()),
5848 active_selector: std::sync::Mutex::new(None),
5849 active_extension_editor: std::sync::Mutex::new(None),
5850 active_extension_input: std::sync::Mutex::new(None),
5851 active_extension_cancel: std::sync::Mutex::new(None),
5852 autocomplete,
5853 autocomplete_container: autocomplete_container.clone(),
5854 autocomplete_max_visible,
5855 pending_images: std::sync::Mutex::new(Vec::new()),
5856 theme_manager,
5857 tui: Some(tui.clone()),
5858 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5859 show_images: std::sync::Mutex::new(show_images),
5860 cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5861 history: std::sync::Mutex::new(Vec::new()),
5862 history_index: std::sync::Mutex::new(-1),
5863 history_draft: std::sync::Mutex::new(None),
5864 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5865 scoped_edit: std::sync::Mutex::new(None),
5866 markdown_transformer: std::sync::Mutex::new(initial_transformer),
5867 extension_session: reload_context.extension_session.clone(),
5868 search: Arc::new(AltScreenSearch::new()),
5869 search_bar: Arc::new(SearchBar::new()),
5870 pending_container: Arc::new(Container::new()),
5871 dequeue_hint: pending_dequeue_hint(&keybindings),
5872 pending_snapshot: std::sync::Mutex::new(
5873 rpi_harness::agent_harness::QueuedMessages::default(),
5874 ),
5875 selection_start: std::sync::Mutex::new(None),
5876 selection_end: std::sync::Mutex::new(None),
5877 });
5878
5879 let model_catalog_arc = Arc::new(model_catalog.clone());
5882 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5883
5884 let editor_container = Arc::new(Container::new());
5895 editor_container.add_child(editor.clone());
5896
5897 let dock = Arc::new(VStack::from_children(vec![
5898 StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5899 StackChild::Entry(StackEntry::new(status_container.clone())),
5900 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5901 StackChild::Entry(
5907 StackEntry::new(editor_container.clone())
5908 .shrink(0)
5909 .min_size(3),
5910 ),
5911 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5912 StackChild::Entry(StackEntry::new(footer.clone())),
5913 ]));
5914
5915 let root = VStack::from_children(vec![
5916 StackChild::Entry(
5917 StackEntry::new(scroll_view.clone())
5918 .basis(0)
5919 .grow(1)
5920 .shrink(1)
5921 .min_size(1),
5922 ),
5923 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5924 ]);
5925
5926 tui.set_layout_root(Some(Arc::new(root)));
5927 tui.set_focus(Some(editor.clone()));
5928 editor.set_focused(true);
5929
5930 let scoped_models = args
5946 .model
5947 .as_deref()
5948 .map(crate::agent_session::ScopedModel::parse)
5949 .into_iter()
5950 .collect();
5951 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5952 harness.clone(),
5953 model_catalog.clone(),
5954 scoped_models,
5955 cwd.clone(),
5956 ));
5957 let ctx = CommandContext {
5958 chat: chat_container.clone(),
5959 tui: tui.clone(),
5960 tx: tx.clone(),
5961 state: state.clone(),
5962 editor: editor.clone(),
5963 editor_container: editor_container.clone(),
5964 lane: lane.clone(),
5965 model_catalog: model_catalog_arc.clone(),
5966 lane_model_id: lane_model_id.clone(),
5967 cwd: cwd.clone(),
5968 package_resources: package_resources.clone(),
5969 resources: resources_arc.clone(),
5970 reload_context: Arc::new(reload_context.clone()),
5971 session: agent_session.clone(),
5972 };
5973
5974 if let Some(js) = &reload_context.js_extension_session {
5975 let bridge = js_dialog_bridge.clone();
5976 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5977 bridge.handle_runtime_request(action, args)
5978 })) {
5979 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5980 }
5981 }
5982
5983 let ctx_for_cb = ctx.clone();
5984 let registry_for_cb = registry.clone();
5985 editor.on_submit(Arc::new(move |text: &str| {
5986 let text = text.trim();
5987 if text.is_empty() {
5988 return;
5989 }
5990
5991 if text.starts_with('/') {
5992 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
5993 return;
5994 }
5995
5996 if let Some((command, exclude_from_context)) = parse_user_bash(text) {
6001 if ctx_for_cb.state.user_bash_running() {
6002 add_error_message(
6003 &ctx_for_cb.chat,
6004 "A bash command is already running. Press Esc to cancel it first.",
6005 );
6006 ctx_for_cb.tui.request_render(false);
6007 return;
6008 }
6009 start_user_bash(&ctx_for_cb, command, exclude_from_context);
6010 return;
6011 }
6012
6013 let run_status = *ctx_for_cb.state.status.lock().unwrap();
6014 if run_status != RunStatus::Idle {
6015 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
6016 let lane = ctx_for_cb.lane.clone();
6017 let chat = ctx_for_cb.chat.clone();
6018 let tui = ctx_for_cb.tui.clone();
6019 let state = ctx_for_cb.state.clone();
6020 tokio::spawn(async move {
6021 if let Err(error) = lane.steer(message).await {
6031 add_error_message(&chat, &format!("Could not queue message: {error}"));
6032 tui.render_now(false);
6033 return;
6034 }
6035 refresh_pending_messages(&state, &lane).await;
6038 tui.request_render(false);
6039 });
6040 ctx_for_cb.tui.request_render(false);
6046 return;
6047 }
6048
6049 if !ctx_for_cb.state.try_start_working() {
6050 return;
6051 }
6052
6053 add_user_message(&ctx_for_cb.chat, text);
6064 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6067 scroll.scroll_to_end();
6068 }
6069 ctx_for_cb.tui.request_render(false);
6070 push_history(&ctx_for_cb.state, text);
6073 if ctx_for_cb
6074 .tx
6075 .send(TuiMessage::UserInput(text.to_string()))
6076 .is_err()
6077 {
6078 ctx_for_cb.state.set_status(RunStatus::Idle);
6079 }
6080 }));
6081
6082 tui.start_render_scheduler();
6090 tui.start_readerless();
6091
6092 let mut update_check_handles = Vec::new();
6096 let rpi_chat = chat_container.clone();
6097 let rpi_tui = tui.clone();
6098 update_check_handles.push(tokio::spawn(async move {
6099 let report = crate::updates::check_rpi_startup().await;
6100 if !report.is_empty() {
6101 add_update_notices(&rpi_chat, &report);
6102 }
6103 rpi_tui.request_render(false);
6104 }));
6105
6106 if update_checks_enabled {
6107 let update_args = args.clone();
6108 let update_cwd = cwd.clone();
6109 let update_project_trusted = project_trusted;
6110 let chat = chat_container.clone();
6111 let tui = tui.clone();
6112 update_check_handles.push(tokio::spawn(async move {
6113 let resources = crate::session::package_resources_for_update_check(
6114 &update_args,
6115 &update_cwd,
6116 update_project_trusted,
6117 );
6118 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6119 {
6120 Ok(npm_command) => {
6121 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6122 Some(&resources),
6123 &npm_command,
6124 update_project_trusted.then_some(update_cwd.as_path()),
6125 )
6126 .await
6127 }
6128 Err(error) => {
6129 add_error_message(
6130 &chat,
6131 &format!("npm package update checks disabled: {error}"),
6132 );
6133 let git_resources = git_only_update_resources(resources);
6134 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6135 }
6136 };
6137 if !report.is_empty() {
6138 add_update_notices(&chat, &report);
6139 }
6140 tui.request_render(false);
6141 }));
6142 }
6143
6144 let drain_handle = if let Some(rx) = event_rx {
6146 let tui_drain = tui.clone();
6147 let state_drain = state.clone();
6148 let chat_drain = chat_container.clone();
6149 let lane_drain = lane.clone();
6150 Some(tokio::spawn(async move {
6151 drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6152 }))
6153 } else {
6154 None
6155 };
6156
6157 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6167 reload_context.mailbox.install(reload_sig_tx);
6168 let reload_tx = tx.clone();
6169 let reload_bridge_handle = tokio::spawn(async move {
6170 while reload_sig_rx.recv().await.is_some() {
6171 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6172 break; }
6174 }
6175 });
6176
6177 let tui_tick = tui.clone();
6182 let state_tick = state.clone();
6183 let tick_handle = tokio::spawn(async move {
6184 let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6193 rpi_tui::loader::SPINNER_FRAME_MS,
6194 ));
6195 interval.tick().await; loop {
6197 interval.tick().await;
6198 if state_tick.sync_extension_status() {
6201 tui_tick.request_render(false);
6202 }
6203 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6204 if working {
6205 let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
6213 let has_live_panel = {
6214 let tools = state_tick.tool_components.lock().unwrap();
6215 transcript_has_live_panel(bash_present, &tools)
6216 };
6217 if has_live_panel {
6218 tui_tick.request_render(false);
6219 } else {
6220 tui_tick.request_render_reusing_scroll_content();
6224 }
6225 }
6226 }
6227 });
6228
6229 let running = Arc::new(std::sync::Mutex::new(true));
6231 let running_key = running.clone();
6232 let tx_for_key = tx.clone();
6233 let tui_for_key = tui.clone();
6234 let editor_for_key = editor.clone();
6235 let scroll_for_key = scroll_view.clone();
6236 let lane_for_key = lane.clone();
6237 let state_for_key = state.clone();
6238 let model_catalog_for_key = model_catalog_arc.clone();
6239 let js_for_key = reload_context.js_extension_session.clone();
6240 let js_dialog_for_key = js_dialog_bridge.clone();
6241 let ask_user_for_key = ask_user_bridge.clone();
6242 let ctx_for_key = ctx.clone();
6246 let registry_for_key = registry.clone();
6247 let keybindings_for_key = keybindings.clone();
6248 let double_escape_action = crate::settings::load_settings()
6249 .ok()
6250 .and_then(|settings| settings.double_escape_action)
6251 .unwrap_or_else(|| "tree".to_string())
6252 .to_ascii_lowercase();
6253
6254 let key_handle = tokio::task::spawn_blocking(move || {
6255 let mut last_escape_time = None;
6256 let mut last_text_key_at: Option<std::time::Instant> = None;
6259 let mut pending_event: Option<Event> = None;
6263 if crate::key_trace::enabled() {
6264 crate::key_trace::note(&format!(
6265 "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6266 std::process::id(),
6267 std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6268 crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6269 ));
6270 }
6271 loop {
6272 if !*running_key.lock().unwrap() {
6273 break;
6274 }
6275 if !state_for_key.selector_open()
6276 && !state_for_key.extension_dialog_open()
6277 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6278 {
6279 if let Some(request) = js_dialog_for_key.take_pending() {
6280 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6281 } else if let Some(request) = ask_user_for_key.take_pending() {
6282 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6283 }
6284 }
6285 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6286 let ev = if let Some(ev) = pending_event.take() {
6287 ev
6288 } else {
6289 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6292 Ok(true) => {}
6293 Ok(false) => continue,
6294 Err(_) => {
6295 state_for_key.cancel_js_preparation();
6296 let _ = tx_for_key.send(TuiMessage::Exit);
6297 break;
6298 }
6299 }
6300 let Ok(ev) = crossterm::event::read() else {
6301 state_for_key.cancel_js_preparation();
6302 let _ = tx_for_key.send(TuiMessage::Exit);
6303 break;
6304 };
6305 ev
6306 };
6307 if let Event::Resize(_cols, _rows) = ev {
6312 tui_for_key.refresh_size();
6313 if let Some(js) = &js_for_key {
6314 if js.custom_active() {
6315 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6316 tui_for_key.request_render(false);
6317 }
6318 }
6319 continue;
6320 }
6321 if let Event::Mouse(m) = ev {
6325 use crossterm::event::MouseEventKind;
6326 match m.kind {
6327 MouseEventKind::ScrollUp => {
6328 let delta = -MOUSE_WHEEL_SCROLL_LINES;
6329 if scroll_for_key.scroll_by(delta) != delta {
6330 tui_for_key.request_render_reusing_scroll_content();
6331 }
6332 }
6333 MouseEventKind::ScrollDown => {
6334 let delta = MOUSE_WHEEL_SCROLL_LINES;
6335 if scroll_for_key.scroll_by(delta) != delta {
6336 tui_for_key.request_render_reusing_scroll_content();
6337 }
6338 }
6339 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6340 *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6344 *state_for_key.selection_end.lock().unwrap() = None;
6345 }
6346 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6347 if state_for_key.selection_start.lock().unwrap().is_some() {
6351 *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6352 }
6353 }
6354 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6355 if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6357 let end = (m.column, m.row);
6358
6359 let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6368 *state_for_key.selection_end.lock().unwrap() = Some(end);
6369
6370 let auto_copy = crate::settings::load_settings()
6372 .ok()
6373 .and_then(|s| s.fullscreen_copy_on_select)
6374 .unwrap_or(true);
6375
6376 if auto_copy && is_drag {
6377 if let Some(selected_text) =
6379 extract_selected_text(&state_for_key.chat_container, start, end)
6380 {
6381 if !selected_text.trim().is_empty() {
6382 let _ = copy_to_clipboard(&selected_text);
6383 }
6384 }
6385 }
6386 }
6387 *state_for_key.selection_start.lock().unwrap() = None;
6388 *state_for_key.selection_end.lock().unwrap() = None;
6389 }
6390 _ => {}
6391 }
6392 continue;
6393 }
6394 let Event::Key(key) = ev else {
6395 if let Event::Paste(text) = ev {
6396 crate::key_trace::note(&format!(
6397 "paste event ({} bytes, bracketed paste supported)",
6398 text.len()
6399 ));
6400 let candidate = text.trim().trim_matches(['\"', '\'']);
6401 let path = std::path::PathBuf::from(candidate);
6402 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6403 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6404 add_image_preview(&state_for_key.chat_container, &image);
6405 state_for_key.queue_image(image);
6406 add_note_message(
6407 &state_for_key.chat_container,
6408 "Dropped image attached to the next prompt.",
6409 );
6410 tui_for_key.request_render(false);
6411 continue;
6412 }
6413 }
6414 editor_for_key.insert(&text);
6415 refresh_autocomplete(&state_for_key, &editor_for_key);
6416 tui_for_key.request_render_reusing_scroll_content();
6417 }
6418 continue;
6419 };
6420 if !should_dispatch_key(key.kind) {
6425 continue;
6426 }
6427 crate::key_trace::key(&key, "");
6431
6432 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6438 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6439 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6440 if prompt_abort && state_for_key.cancel_js_preparation() {
6441 state_for_key.set_status(RunStatus::Aborting);
6442 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6443 close_extension_editor(
6444 &state_for_key,
6445 &ctx_for_key.editor_container,
6446 &editor_for_key,
6447 &tui_for_key,
6448 );
6449 }
6450 js_dialog_for_key.cancel_open_requests();
6451 tui_for_key.set_render_suspended(false);
6452 tui_for_key.request_render(false);
6453 continue;
6454 }
6455
6456 if let Some(js) = &js_for_key {
6457 if js.custom_active() {
6458 let visible = js.custom_accepts_input();
6459 let data = key_event_to_input(key);
6460 if !data.is_empty() {
6461 if visible {
6467 let _ = js.send_custom_input(&data);
6468 continue;
6469 }
6470 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6471 if consumed {
6475 tui_for_key.request_render_reusing_scroll_content();
6476 continue;
6477 }
6478 }
6479 }
6480 }
6481
6482 if key.modifiers == KeyModifiers::CONTROL
6485 && key.code == KeyCode::Char('c')
6486 && state_for_key.selector_open()
6487 {
6488 let selector = state_for_key
6489 .active_selector
6490 .lock()
6491 .unwrap()
6492 .clone()
6493 .expect("selector_open guaranteed Some")
6494 .0;
6495 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6496 tui_for_key.request_render_reusing_scroll_content();
6497 continue;
6498 }
6499
6500 if state_for_key.extension_dialog_open() {
6504 let cancel = key.code == KeyCode::Esc
6505 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6506 if cancel {
6507 if !run_extension_cancel(&state_for_key) {
6508 close_extension_editor(
6509 &state_for_key,
6510 &ctx_for_key.editor_container,
6511 &editor_for_key,
6512 &tui_for_key,
6513 );
6514 }
6515 } else if let Some(extension_editor) = state_for_key
6516 .active_extension_editor
6517 .lock()
6518 .unwrap()
6519 .clone()
6520 {
6521 extension_editor.handle_key(key);
6522 } else if let Some(extension_input) =
6523 state_for_key.active_extension_input.lock().unwrap().clone()
6524 {
6525 extension_input.handle_key(key);
6526 }
6527 tui_for_key.request_render_reusing_scroll_content();
6528 continue;
6529 }
6530
6531 if keybinding_matches(
6536 &keybindings_for_key,
6537 &key,
6538 rpi_tui::keybindings::keys::CLEAR,
6539 ) {
6540 if !state_for_key.selector_open() && editor_for_key.has_selection() {
6541 editor_for_key.copy_selection();
6542 continue;
6543 }
6544 let status = *state_for_key.status.lock().unwrap();
6545 match status {
6546 RunStatus::Working => {
6547 state_for_key.set_status(RunStatus::Aborting);
6548 ask_user_for_key.cancel_all();
6550 if !run_extension_cancel(&state_for_key)
6552 && state_for_key.extension_dialog_open()
6553 {
6554 close_extension_editor(
6555 &state_for_key,
6556 &ctx_for_key.editor_container,
6557 &editor_for_key,
6558 &tui_for_key,
6559 );
6560 }
6561 let lane = lane_for_key.clone();
6562 tokio::spawn(async move {
6563 let _ = lane.abort().await;
6564 });
6565 }
6566 RunStatus::Aborting => {
6570 if key.kind != KeyEventKind::Repeat {
6583 emergency_exit(&tui_for_key);
6584 }
6585 }
6586 RunStatus::Idle => {
6587 let _ = tx_for_key.send(TuiMessage::Exit);
6588 }
6589 }
6590 continue;
6591 }
6592
6593 if state_for_key.selector_open() {
6598 if key.code == KeyCode::Esc {
6604 let (selector, _kind) = state_for_key
6605 .active_selector
6606 .lock()
6607 .unwrap()
6608 .clone()
6609 .expect("selector_open guaranteed Some");
6610 selector.handle_key(key);
6611 continue;
6612 }
6613 let (selector, _kind) = state_for_key
6614 .active_selector
6615 .lock()
6616 .unwrap()
6617 .clone()
6618 .expect("selector_open guaranteed Some");
6619 selector.handle_key(key);
6620 tui_for_key.request_render_reusing_scroll_content();
6621 continue;
6622 }
6623
6624 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6629 let status = *state_for_key.status.lock().unwrap();
6630 match status {
6631 RunStatus::Working => {
6632 state_for_key.set_status(RunStatus::Aborting);
6633 ask_user_for_key.cancel_all();
6634 if !run_extension_cancel(&state_for_key)
6635 && state_for_key.extension_dialog_open()
6636 {
6637 close_extension_editor(
6638 &state_for_key,
6639 &ctx_for_key.editor_container,
6640 &editor_for_key,
6641 &tui_for_key,
6642 );
6643 }
6644 let lane = lane_for_key.clone();
6645 tokio::spawn(async move {
6646 let _ = lane.abort().await;
6647 });
6648 continue;
6649 }
6650 RunStatus::Aborting => continue,
6651 RunStatus::Idle => {}
6652 }
6653 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
6654 editor_for_key.handle_key(key);
6656 refresh_autocomplete(&state_for_key, &editor_for_key);
6657 tui_for_key.request_render_reusing_scroll_content();
6658 continue;
6659 }
6660 let _ = tx_for_key.send(TuiMessage::Exit);
6661 continue;
6662 }
6663
6664 if keybinding_matches(
6669 &keybindings_for_key,
6670 &key,
6671 rpi_tui::keybindings::keys::INTERRUPT,
6672 ) {
6673 let status = *state_for_key.status.lock().unwrap();
6674 if status == RunStatus::Working {
6675 state_for_key.set_status(RunStatus::Aborting);
6676 ask_user_for_key.cancel_all();
6677 if !run_extension_cancel(&state_for_key)
6678 && state_for_key.extension_dialog_open()
6679 {
6680 close_extension_editor(
6681 &state_for_key,
6682 &ctx_for_key.editor_container,
6683 &editor_for_key,
6684 &tui_for_key,
6685 );
6686 }
6687 let lane = lane_for_key.clone();
6688 tokio::spawn(async move {
6689 let _ = lane.abort().await;
6690 });
6691 continue;
6692 }
6693 if state_for_key.user_bash_running() {
6699 state_for_key.cancel_user_bash();
6700 tui_for_key.request_render(false);
6701 continue;
6702 }
6703 if status == RunStatus::Idle
6704 && editor_for_key.get_text().trim().is_empty()
6705 && double_escape_action != "none"
6706 {
6707 let now = std::time::Instant::now();
6708 if double_escape_trigger(last_escape_time, now) {
6709 last_escape_time = None;
6710 match double_escape_action.as_str() {
6711 "tree" => {
6712 let _ = tx_for_key.send(TuiMessage::OpenTree);
6713 }
6714 "fork" => {
6715 let _ = tx_for_key.send(TuiMessage::ForkSession);
6716 }
6717 _ => {}
6718 }
6719 } else {
6720 last_escape_time = Some(now);
6721 }
6722 }
6723 continue;
6724 }
6725
6726 if keybinding_matches(
6729 &keybindings_for_key,
6730 &key,
6731 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6732 ) {
6733 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
6734 continue;
6735 }
6736
6737 if keybinding_matches(
6740 &keybindings_for_key,
6741 &key,
6742 rpi_tui::keybindings::keys::TOOLS_EXPAND,
6743 ) {
6744 let expanded = state_for_key.toggle_tool_outputs();
6745 let session = ctx_for_key.session.clone();
6747 tokio::spawn(async move {
6748 let _ = session
6749 .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6750 .await;
6751 });
6752 tui_for_key.request_render(false);
6753 continue;
6754 }
6755
6756 if keybinding_matches(
6758 &keybindings_for_key,
6759 &key,
6760 rpi_tui::keybindings::keys::THINKING_TOGGLE,
6761 ) {
6762 let hide = state_for_key.toggle_thinking();
6763 let session = ctx_for_key.session.clone();
6765 tokio::spawn(async move {
6766 let _ = session
6767 .set_value("display.hide_thinking", serde_json::json!(hide))
6768 .await;
6769 });
6770 tui_for_key.request_render(false);
6771 continue;
6772 }
6773
6774 if keybinding_matches(
6780 &keybindings_for_key,
6781 &key,
6782 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6783 ) {
6784 let current = state_for_key.current_model_id();
6785 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6788 if let Some(next) = cycle_next_model(&scope, ¤t) {
6789 state_for_key.set_current_model(&next);
6790 let lane = lane_for_key.clone();
6791 tokio::spawn(async move {
6792 let _ = lane.set_model(next).await;
6793 });
6794 tui_for_key.request_render_reusing_scroll_content();
6795 }
6796 continue;
6797 }
6798
6799 if keybinding_matches(
6802 &keybindings_for_key,
6803 &key,
6804 rpi_tui::keybindings::keys::THINKING_CYCLE,
6805 ) {
6806 let lane = lane_for_key.clone();
6807 let catalog = model_catalog_for_key.clone();
6808 let state = state_for_key.clone();
6809 tokio::spawn(async move {
6810 let Ok(current_model) = lane.get_model().await else {
6811 return;
6812 };
6813 let levels = catalog
6814 .iter()
6815 .find(|model| {
6816 model.provider == current_model.provider && model.id == current_model.id
6817 })
6818 .map(|model| model.supported_thinking_levels())
6819 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6820 if levels.is_empty() {
6821 return;
6822 }
6823 let current = lane
6824 .get_thinking_level()
6825 .await
6826 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6827 let next = levels
6828 .iter()
6829 .position(|level| *level == current)
6830 .map(|index| levels[(index + 1) % levels.len()])
6831 .unwrap_or(levels[0]);
6832 if lane.set_thinking_level(next).await.is_ok() {
6833 state
6834 .footer
6835 .set_thinking_level(Some(thinking_level_name(next)));
6836 state.tui.as_ref().map(|tui| tui.request_render(false));
6837 }
6838 });
6839 continue;
6840 }
6841
6842 if keybinding_matches(
6852 &keybindings_for_key,
6853 &key,
6854 rpi_tui::keybindings::keys::PASTE_IMAGE,
6855 ) {
6856 match read_clipboard_image() {
6857 Ok(Some(image)) => {
6858 add_image_preview(&state_for_key.chat_container, &image);
6859 state_for_key.queue_image(image);
6860 add_note_message(
6861 &state_for_key.chat_container,
6862 "Clipboard image attached to the next prompt.",
6863 );
6864 tui_for_key.request_render(false);
6865 continue;
6866 }
6867 Ok(None) | Err(_) => {}
6868 }
6869 if cfg!(windows) {
6880 if let Some(text) = read_clipboard_text() {
6881 if !text.is_empty() {
6882 editor_for_key.insert(&text);
6883 refresh_autocomplete(&state_for_key, &editor_for_key);
6884 tui_for_key.request_render_reusing_scroll_content();
6885 continue;
6886 }
6887 }
6888 }
6889 }
6890
6891 if key
6893 .modifiers
6894 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6895 && key.code == KeyCode::Char('F')
6896 {
6897 state_for_key.search.activate();
6898 state_for_key.search_bar.set_visible(true);
6899 state_for_key.search_bar.set_query("");
6900 state_for_key.search_bar.set_match_info(0, 0);
6901 tui_for_key.request_render(false);
6902 continue;
6903 }
6904
6905 if state_for_key.search.is_active() {
6907 let search_bar = state_for_key.search_bar.clone();
6908 match key.code {
6909 KeyCode::Esc => {
6910 state_for_key.search.deactivate();
6911 search_bar.set_visible(false);
6912 tui_for_key.request_render(false);
6913 continue;
6914 }
6915 KeyCode::Enter => {
6916 if key.modifiers.contains(KeyModifiers::SHIFT) {
6917 state_for_key.search.previous_match();
6918 } else {
6919 state_for_key.search.next_match();
6920 }
6921 let match_index = state_for_key.search.get_match_index();
6923 let match_count = state_for_key.search.get_match_count();
6924 search_bar.set_match_info(match_index, match_count);
6925 tui_for_key.request_render(false);
6926 continue;
6927 }
6928 KeyCode::Backspace => {
6929 state_for_key.search.backspace();
6930 let lines = collect_transcript_lines(&state_for_key.chat_container);
6932 state_for_key.search.find_matches(&lines);
6933 let query = state_for_key.search.get_query();
6935 let match_count = state_for_key.search.get_match_count();
6936 search_bar.set_query(&query);
6937 search_bar.set_match_info(0, match_count);
6938 tui_for_key.request_render(false);
6939 continue;
6940 }
6941 KeyCode::Char(c) => {
6942 state_for_key.search.append_char(c);
6943 let lines = collect_transcript_lines(&state_for_key.chat_container);
6945 state_for_key.search.find_matches(&lines);
6946 let query = state_for_key.search.get_query();
6948 let match_count = state_for_key.search.get_match_count();
6949 search_bar.set_query(&query);
6950 search_bar.set_match_info(0, match_count);
6951 tui_for_key.request_render(false);
6952 continue;
6953 }
6954 _ => continue,
6955 }
6956 }
6957
6958 if keybinding_matches(
6962 &keybindings_for_key,
6963 &key,
6964 rpi_tui::keybindings::keys::MODEL_SELECT,
6965 ) {
6966 if let Some(cmd) = registry_for_key.find("/model") {
6967 cmd.execute(&ctx_for_key, "");
6968 }
6969 continue;
6970 }
6971
6972 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6974 if accept_top_suggestion(&state_for_key, &editor_for_key) {
6975 tui_for_key.request_render_reusing_scroll_content();
6976 }
6977 continue;
6978 }
6979
6980 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6984 let delta = -transcript_page_size(scroll_for_key.viewport_height());
6985 if scroll_for_key.scroll_by(delta) != delta {
6986 tui_for_key.request_render_reusing_scroll_content();
6987 }
6988 continue;
6989 }
6990 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6991 let delta = transcript_page_size(scroll_for_key.viewport_height());
6992 if scroll_for_key.scroll_by(delta) != delta {
6993 tui_for_key.request_render_reusing_scroll_content();
6994 }
6995 continue;
6996 }
6997 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6998 scroll_for_key.scroll_to_start();
6999 tui_for_key.request_render_reusing_scroll_content();
7000 continue;
7001 }
7002 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
7003 scroll_for_key.scroll_to_end();
7004 tui_for_key.request_render_reusing_scroll_content();
7005 continue;
7006 }
7007
7008 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
7017 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7018 if editor_for_key.get_text().is_empty() || browsing {
7019 navigate_history(&state_for_key, &editor_for_key, -1);
7020 tui_for_key.request_render_reusing_scroll_content();
7021 continue;
7022 }
7023 }
7024 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
7025 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7026 if editor_for_key.get_text().is_empty() || browsing {
7027 navigate_history(&state_for_key, &editor_for_key, 1);
7028 tui_for_key.request_render_reusing_scroll_content();
7029 continue;
7030 }
7031 }
7032
7033 if keybinding_matches(
7038 &keybindings_for_key,
7039 &key,
7040 rpi_tui::keybindings::keys::DEQUEUE,
7041 ) {
7042 tokio::spawn(restore_queued_messages_to_editor(
7051 lane_for_key.clone(),
7052 editor_for_key.clone(),
7053 state_for_key.clone(),
7054 tui_for_key.clone(),
7055 ));
7056 continue;
7057 }
7058
7059 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
7063 let prompt = editor_for_key.get_text().trim().to_string();
7064 if prompt.is_empty() {
7065 continue;
7066 }
7067 editor_for_key.clear();
7068 let status = *state_for_key.status.lock().unwrap();
7069 if status == RunStatus::Idle {
7070 if state_for_key.try_start_working() {
7071 add_user_message(&state_for_key.chat_container, &prompt);
7075 push_history(&state_for_key, &prompt);
7076 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7077 }
7078 } else {
7079 let message = AgentMessage::User(UserMessage::new(prompt, 0));
7083 let lane = lane_for_key.clone();
7084 let chat = state_for_key.chat_container.clone();
7085 let tui = tui_for_key.clone();
7086 let state = state_for_key.clone();
7087 tokio::spawn(async move {
7088 if let Err(error) = lane.follow_up(message).await {
7089 add_error_message(&chat, &format!("Could not queue message: {error}"));
7090 tui.request_render(false);
7091 return;
7092 }
7093 refresh_pending_messages(&state, &lane).await;
7094 tui.request_render(false);
7095 });
7096 }
7097 tui_for_key.request_render(false);
7098 continue;
7099 }
7100
7101 if cfg!(windows)
7118 && key.modifiers.is_empty()
7119 && matches!(
7120 key.code,
7121 KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7122 )
7123 {
7124 let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7131 if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7132 crossterm::event::read().ok()
7133 } else {
7134 None
7135 }
7136 });
7137 pending_event = stashed;
7138 if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7139 crate::key_trace::note(&format!(
7140 "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7141 gap_ms(last_text_key_at),
7142 ));
7143 editor_for_key.insert("\n");
7146 last_text_key_at = Some(std::time::Instant::now());
7147 refresh_autocomplete(&state_for_key, &editor_for_key);
7148 tui_for_key.request_render_reusing_scroll_content();
7149 continue;
7150 }
7151 crate::key_trace::note(&format!(
7152 "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7153 gap_ms(last_text_key_at),
7154 ));
7155 last_text_key_at = None;
7158 } else if matches!(
7159 key.code,
7160 KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7161 ) && !key
7162 .modifiers
7163 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7164 {
7165 last_text_key_at = Some(std::time::Instant::now());
7168 } else {
7169 last_text_key_at = None;
7172 }
7173
7174 editor_for_key.handle_key(key);
7180 refresh_autocomplete(&state_for_key, &editor_for_key);
7181 tui_for_key.request_render_reusing_scroll_content();
7182 }
7183 });
7184
7185 if let Some(reason) = crate::session::dispatch_session_event_async(
7191 reload_context,
7192 rpi_plugin_sdk::EventTag::SessionStart,
7193 )
7194 .await
7195 {
7196 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7197 }
7198
7199 let mut prompts: Vec<String> = Vec::new();
7201 if let Some(init) = initial {
7202 prompts.push(init);
7203 }
7204 for m in extra_messages {
7205 prompts.push(m.clone());
7206 }
7207 if prompts.is_empty() && lane.has_pending_resume().await {
7214 run_prompt_streaming(
7215 &lane,
7216 "",
7217 true,
7218 &tui,
7219 &state,
7220 drain_handle.is_some(),
7221 reload_context.js_extension_session.as_ref(),
7222 &js_dialog_bridge,
7223 args,
7224 Vec::new(),
7225 )
7226 .await;
7227 }
7228
7229 let mut images = initial_images;
7230 for prompt in prompts {
7231 if !*running.lock().unwrap() {
7232 break;
7233 }
7234 add_user_message(&chat_container, &prompt);
7238 tui.request_render(false);
7239 run_prompt_streaming(
7240 &lane,
7241 &prompt,
7242 false,
7243 &tui,
7244 &state,
7245 drain_handle.is_some(),
7246 reload_context.js_extension_session.as_ref(),
7247 &js_dialog_bridge,
7248 args,
7249 std::mem::take(&mut images),
7250 )
7251 .await;
7252 }
7253
7254 loop {
7256 if !*running.lock().unwrap() {
7257 break;
7258 }
7259 match rx.recv().await {
7260 Some(TuiMessage::UserInput(prompt)) => {
7261 editor.clear();
7266 let prompt_images = state.take_pending_images();
7267 if !prompt_images.is_empty() {
7268 add_note_message(
7269 &chat_container,
7270 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7271 );
7272 }
7273 run_prompt_streaming(
7274 &lane,
7275 &prompt,
7276 false,
7277 &tui,
7278 &state,
7279 drain_handle.is_some(),
7280 reload_context.js_extension_session.as_ref(),
7281 &js_dialog_bridge,
7282 args,
7283 prompt_images,
7284 )
7285 .await;
7286 }
7287 Some(TuiMessage::ExternalEditorResult(result)) => {
7288 match result {
7289 Ok(text) => {
7290 let cursor = text.chars().count();
7291 editor.set_text(&text);
7292 editor.set_cursor(0, cursor);
7293 add_note_message(&chat_container, "Draft updated from external editor.");
7294 }
7295 Err(error) => add_error_message(&chat_container, &error),
7296 }
7297 tui.request_render(false);
7298 }
7299 Some(TuiMessage::UserBashFinished(report)) => {
7300 state.finish_user_bash();
7303 let message = rpi_harness::messages::bash_execution_message(
7304 report.command,
7305 report.output,
7306 report.exit_code,
7307 report.cancelled,
7308 report.truncated,
7309 report.full_output_path,
7310 Some(report.exclude_from_context),
7311 now_ms(),
7312 );
7313 if *state.status.lock().unwrap() == RunStatus::Idle {
7317 if let Err(error) = lane.append_message(message).await {
7318 add_error_message(
7319 &chat_container,
7320 &format!("Could not record bash result: {error}"),
7321 );
7322 tui.request_render(false);
7323 }
7324 } else {
7325 state.pending_bash_messages.lock().unwrap().push(message);
7326 }
7327 }
7328 Some(TuiMessage::OpenTree) => {
7329 if *state.status.lock().unwrap() != RunStatus::Idle {
7330 add_note_message(
7331 &chat_container,
7332 "Wait for the current run to finish before opening the tree.",
7333 );
7334 tui.request_render(false);
7335 } else {
7336 open_tree_selector(
7337 &harness,
7338 &state,
7339 &editor_container,
7340 &editor,
7341 &tui,
7342 &chat_container,
7343 &tx,
7344 )
7345 .await;
7346 }
7347 }
7348 Some(TuiMessage::NavigateTree(entry_id)) => {
7349 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7350 Ok(result) => match result.outcome {
7351 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7352 chat_container.clear();
7353 add_welcome_message(&chat_container);
7354 render_session_history(
7355 &harness,
7356 &chat_container,
7357 state.markdown_transformer(),
7358 Some(state.extension_session.clone()),
7359 )
7360 .await;
7361 add_note_message(
7362 &chat_container,
7363 "Moved to the selected session entry.",
7364 );
7365 }
7366 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7367 add_error_message(&chat_container, &error.message);
7368 }
7369 _ => add_note_message(
7370 &chat_container,
7371 "The selected entry could not be opened.",
7372 ),
7373 },
7374 Err(error) => add_error_message(
7375 &chat_container,
7376 &format!("Could not navigate session tree: {error}"),
7377 ),
7378 }
7379 tui.request_render(false);
7380 }
7381 Some(TuiMessage::ClearChat) => {
7382 chat_container.clear();
7383 add_welcome_message(&chat_container);
7384 tui.request_render(false);
7385 }
7386 Some(TuiMessage::Compact) => {
7387 run_compact(&lane, &tui, &state).await;
7388 }
7389 Some(TuiMessage::Copy) => {
7390 copy_last_assistant(&state, &chat_container);
7391 tui.request_render(false);
7392 }
7393 Some(TuiMessage::Exit) => {
7394 *running.lock().unwrap() = false;
7395 break;
7396 }
7397 Some(TuiMessage::SwitchSession(id)) => {
7398 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7399 tui.request_render(false);
7400 }
7401 Some(TuiMessage::ImportSession(path)) => {
7402 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7403 tui.request_render(false);
7404 }
7405 Some(TuiMessage::ShareSession) => {
7406 share_session(&harness, &chat_container).await;
7407 tui.request_render(false);
7408 }
7409 Some(TuiMessage::SetSessionName(name)) => {
7410 let outcome = harness.session().set_name(Some(&name)).await;
7411 match outcome {
7412 Ok(_) => add_note_message(
7413 &chat_container,
7414 &format!("Session renamed to \"{name}\"."),
7415 ),
7416 Err(e) => add_error_message(
7417 &chat_container,
7418 &format!("Could not rename session: {e}"),
7419 ),
7420 }
7421 tui.request_render(false);
7422 }
7423 Some(TuiMessage::ExportSession) => {
7424 let file_name = agent_session
7425 .default_export_file_name(crate::export::ExportFormat::Markdown)
7426 .await;
7427 let path = cwd.join(&file_name);
7428 match agent_session.export_to_markdown(&path).await {
7429 Ok(_) => add_note_message(
7430 &chat_container,
7431 &format!("Exported session to {}", path.display()),
7432 ),
7433 Err(e) => {
7434 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7435 }
7436 }
7437 tui.request_render(false);
7438 }
7439 Some(TuiMessage::ExportSessionWithFormat(format)) => {
7440 let file_name = agent_session.default_export_file_name(format.clone()).await;
7441 let path = cwd.join(&file_name);
7442 match agent_session.export(format, &path).await {
7443 Ok(_) => add_note_message(
7444 &chat_container,
7445 &format!("Exported session to {}", path.display()),
7446 ),
7447 Err(e) => {
7448 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7449 }
7450 }
7451 tui.request_render(false);
7452 }
7453 Some(TuiMessage::ShowUsage) => {
7454 let stats = agent_session.usage_stats().await.ok();
7455 let total = agent_session.total_usage().await.ok();
7456 let snapshot = agent_session.snapshot().await.ok();
7457 match stats {
7458 Some(stats) => {
7459 let mut lines = vec![
7460 "## Usage".to_string(),
7461 String::new(),
7462 format!("- messages: {}", stats.message_count),
7463 format!("- total tokens: {}", stats.total_tokens),
7464 format!("- cached tokens: {}", stats.cached_tokens),
7465 format!("- uncached tokens: {}", stats.uncached_tokens),
7466 format!("- cost: ${:.4}", stats.cost_total),
7467 ];
7468 match stats.cache_hit_ratio() {
7469 Some(ratio) => {
7470 lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7471 }
7472 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7473 }
7474 if let Some(total) = total {
7475 lines.push(String::new());
7476 lines.push(format!(
7477 "- last cumulative input/output: {} / {}",
7478 total.input, total.output
7479 ));
7480 }
7481 if let Some(snapshot) = snapshot {
7482 lines.push(String::new());
7483 lines.push(format!("- lane: `{}`", snapshot.lane));
7484 lines.push(format!(
7485 "- leaf: `{}`",
7486 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7487 ));
7488 if let Some(active) = &snapshot.active {
7489 lines.push(format!(
7490 "- active operation: {} (`{}`)",
7491 active.kind.as_str(),
7492 active.run_id
7493 ));
7494 }
7495 }
7496 add_note_message(&chat_container, &lines.join("\n"));
7497 }
7498 None => {
7499 add_error_message(&chat_container, "Could not read session usage stats.")
7500 }
7501 }
7502 tui.request_render(false);
7503 }
7504 Some(TuiMessage::ForkSession) => {
7505 fork_session(&harness, &cwd, &chat_container, &state).await;
7506 tui.request_render(false);
7507 }
7508 Some(TuiMessage::ReloadExtensions) => {
7509 let reload_ctx = ctx.reload_context.clone();
7516 add_note_message(&chat_container, "Reloading extensions + resources…");
7517 tui.request_render(false);
7518 let outcome =
7519 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
7520 let fresh_transformer = build_markdown_transformer(
7529 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
7530 );
7531 state.set_markdown_transformer_with_reinstall(fresh_transformer);
7532 if outcome.had_errors {
7533 add_error_message(&chat_container, &outcome.summary);
7537 } else if outcome.had_warnings {
7538 add_note_message(
7543 &chat_container,
7544 &format!(
7545 "{} (with warnings — see stderr for details).",
7546 outcome.summary
7547 ),
7548 );
7549 } else {
7550 add_note_message(&chat_container, &outcome.summary);
7551 }
7552 tui.request_render(false);
7553 }
7554 None => break,
7555 }
7556 }
7557
7558 js_dialog_bridge.cancel_all();
7562 ask_user_bridge.shutdown();
7563 *running.lock().unwrap() = false;
7564 for handle in update_check_handles {
7565 handle.abort();
7566 let _ = handle.await;
7567 }
7568 if let Some(js) = &reload_context.js_extension_session {
7572 js.shutdown();
7573 }
7574 let _ = key_handle.await;
7577 tick_handle.abort();
7578 if let Some(handle) = drain_handle {
7579 handle.abort();
7580 }
7581 reload_context.mailbox.clear();
7585 reload_bridge_handle.abort();
7586 tui.stop(Default::default());
7587
7588 let session_id = harness.session().storage().metadata().id;
7592 if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7593 println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7594 }
7595 println!("\nGoodbye!");
7596 let _ = args;
7597
7598 0
7599}
7600
7601async fn ensure_js_runtime_before_prompt(
7611 js: Option<&crate::js_extensions::JsExtensionSession>,
7612 lane: &Arc<dyn AgentLane>,
7613 state: &Arc<TuiState>,
7614 dialog_bridge: &JsDialogBridge,
7615 args: &Args,
7616) -> bool {
7617 let Some(js) = js else {
7618 return true;
7619 };
7620 let cancellation = state.begin_js_preparation();
7621 let worker_cancellation = cancellation.clone();
7622 let js_for_start = js.clone();
7623 let mut worker = tokio::task::spawn_blocking(move || {
7624 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
7625 });
7626 let result = tokio::select! {
7627 result = &mut worker => result,
7628 _ = cancellation.cancelled() => {
7629 dialog_bridge.cancel_open_requests();
7630 let js_for_cancel = js.clone();
7631 let _ = tokio::task::spawn_blocking(move || {
7632 js_for_cancel.cancel_prompt_preparation();
7633 }).await;
7634 worker.await
7635 }
7636 };
7637 let was_cancelled = cancellation.is_cancelled();
7638 if was_cancelled {
7639 dialog_bridge.cancel_open_requests();
7640 let js_for_cancel = js.clone();
7641 let _ = tokio::task::spawn_blocking(move || {
7642 js_for_cancel.cancel_prompt_preparation();
7643 })
7644 .await;
7645 state.finish_js_preparation();
7646 dialog_bridge.reopen();
7647 return false;
7648 }
7649 state.finish_js_preparation();
7650 match result {
7651 Ok(Ok(())) => {
7652 if let Some(js_active) = js.active_tools() {
7656 let js_names = js.tool_names();
7657 let mut active = lane.get_active_tools().await.unwrap_or_default();
7658 active.retain(|name| {
7659 crate::session::tool_name_allowed(name, args)
7660 && !js_names.iter().any(|js_name| js_name == name)
7661 });
7662 active.extend(js_active.into_iter().filter(|name| {
7663 js_names.iter().any(|js_name| js_name == name)
7664 && crate::session::tool_name_allowed(name, args)
7665 }));
7666 active = crate::session::filter_active_tool_names(active, args);
7667 let _ = lane.set_active_tools(active).await;
7668 }
7669 }
7670 Ok(Err(error)) => {
7671 if args.verbose {
7672 eprintln!("warning: could not start JS extension runtime: {error}");
7673 }
7674 }
7675 Err(error) => {
7676 if args.verbose {
7677 eprintln!("warning: JS extension runtime worker failed: {error}");
7678 }
7679 }
7680 }
7681 true
7682}
7683
7684fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7685 std::thread::spawn(move || {
7686 let file = match tempfile::Builder::new()
7687 .prefix("rpi-draft-")
7688 .suffix(".md")
7689 .tempfile()
7690 {
7691 Ok(file) => file,
7692 Err(error) => {
7693 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7694 "Could not create editor file: {error}"
7695 ))));
7696 return;
7697 }
7698 };
7699 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7700 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7701 "Could not write editor file: {error}"
7702 ))));
7703 return;
7704 }
7705 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7706 .ok()
7707 .filter(|value| !value.trim().is_empty())
7708 .or_else(|| std::env::var("VISUAL").ok())
7709 .or_else(|| std::env::var("EDITOR").ok())
7710 .unwrap_or_else(|| {
7711 if cfg!(windows) {
7712 "notepad".to_string()
7713 } else {
7714 "nano".to_string()
7715 }
7716 });
7717 let status = std::process::Command::new(&editor)
7718 .arg(file.path())
7719 .status();
7720 let result = match status {
7721 Ok(status) if status.success() => std::fs::read_to_string(file.path())
7722 .map_err(|error| format!("Could not read editor file: {error}")),
7723 Ok(status) => Err(format!("External editor exited with {status}")),
7724 Err(error) => Err(format!(
7725 "Could not launch external editor `{editor}`: {error}"
7726 )),
7727 };
7728 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7729 });
7730}
7731
7732fn reconcile_streamed_assistant_completion(
7735 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
7736 last_assistant_text: &Mutex<String>,
7737 final_message: Option<&AssistantMessage>,
7738) {
7739 let final_blocks = final_message.map(assistant_blocks);
7740 let final_text = final_message.map(assistant_text);
7741 let component = current_assistant.lock().unwrap().take();
7742
7743 if let Some(component) = component {
7744 if let Some(blocks) = final_blocks.as_deref() {
7745 component.update_blocks(blocks);
7746 }
7747 component.set_streaming(false);
7748 }
7749
7750 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7751 *last_assistant_text.lock().unwrap() = text;
7752 }
7753}
7754
7755async fn run_prompt_streaming(
7760 lane: &Arc<dyn AgentLane>,
7761 prompt: &str,
7762 resume: bool,
7763 tui: &Arc<TuiAltScreen>,
7764 state: &Arc<TuiState>,
7765 streaming: bool,
7766 js: Option<&crate::js_extensions::JsExtensionSession>,
7767 dialog_bridge: &JsDialogBridge,
7768 args: &Args,
7769 images: Vec<rpi_ai::types::ImageContent>,
7770) {
7771 state.set_status(RunStatus::Working);
7780 tui.request_render(false);
7781 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
7782 state.set_status(RunStatus::Idle);
7783 tui.request_render(false);
7784 return;
7785 }
7786
7787 let outcome = if resume {
7788 match lane.resume_pending().await {
7791 Ok(Some(result)) => Ok(result),
7792 Ok(None) => {
7795 state.set_status(RunStatus::Idle);
7796 tui.request_render(false);
7797 return;
7798 }
7799 Err(error) => Err(error),
7800 }
7801 } else {
7802 lane.prompt_text(prompt, images).await
7803 };
7804
7805 if streaming {
7806 let final_message = match &outcome {
7811 Ok(result) => match &result.outcome {
7812 HarnessRunOutcome::Completed { final_message, .. }
7813 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7814 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7815 HarnessRunOutcome::Suspended { .. } => None,
7816 },
7817 Err(_) => None,
7818 };
7819 reconcile_streamed_assistant_completion(
7820 &state.current_assistant,
7821 &state.last_assistant_text,
7822 final_message,
7823 );
7824 }
7825
7826 state.set_status(RunStatus::Idle);
7827 refresh_pending_messages(state, lane).await;
7831
7832 if let Ok(result) = &outcome {
7837 let final_message: Option<&AssistantMessage> = match &result.outcome {
7838 HarnessRunOutcome::Completed { final_message, .. }
7839 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7840 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7841 HarnessRunOutcome::Suspended { .. } => None,
7842 };
7843 if let Some(a) = final_message {
7844 record_context_usage(&state.footer, a);
7845 }
7846 }
7847
7848 match outcome {
7849 Ok(result) => match &result.outcome {
7850 HarnessRunOutcome::Failed {
7851 error,
7852 final_message,
7853 ..
7854 } => {
7855 let already_rendered = streaming && final_message.is_some();
7863 if !already_rendered {
7864 let msg = final_message
7865 .as_ref()
7866 .and_then(|m| m.error_message.clone())
7867 .unwrap_or_else(|| format!("{error:?}"));
7868 add_error_message(&state.chat_container, &msg);
7869 }
7870 }
7871 HarnessRunOutcome::Suspended { .. } => {
7872 add_error_message(
7873 &state.chat_container,
7874 "Run suspended (deferred) — resume is not supported in v1.",
7875 );
7876 }
7877 HarnessRunOutcome::Aborted { final_message, .. } => {
7878 if !streaming {
7881 add_error_message(&state.chat_container, "Request aborted.");
7882 let _ = final_message; }
7884 }
7885 HarnessRunOutcome::Completed { final_message, .. } => {
7886 if !streaming {
7887 let text = assistant_text(final_message);
7888 if !text.is_empty() {
7889 add_assistant_message_blocking(
7890 &state.chat_container,
7891 &text,
7892 state.markdown_transformer(),
7893 );
7894 *state.last_assistant_text.lock().unwrap() = text;
7895 }
7896 }
7897 }
7898 },
7899 Err(e) => {
7900 add_error_message(&state.chat_container, &e.to_string());
7901 }
7902 }
7903
7904 tui.request_render(false);
7905}
7906
7907async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7912 state.set_status(RunStatus::Working);
7913 tui.request_render(false);
7914 match lane.compact(None).await {
7915 Ok(_) => {
7916 add_note_message(&state.chat_container, "Conversation compacted.");
7917 state.footer.set_context_tokens(None);
7921 }
7922 Err(e) => {
7923 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7924 }
7925 }
7926 state.set_status(RunStatus::Idle);
7927 tui.request_render(false);
7928}
7929
7930fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7934 let text = state.last_assistant_text.lock().unwrap().clone();
7935 if text.is_empty() {
7936 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7937 return;
7938 }
7939 if copy_to_clipboard(&text) {
7940 add_note_message(chat, "Copied last reply to the clipboard.");
7941 } else {
7942 let preview: String = text.chars().take(200).collect();
7945 add_note_message(
7946 chat,
7947 &format!(
7948 "Clipboard unavailable. Last reply: {preview}{}",
7949 if text.chars().count() > 200 {
7950 "…"
7951 } else {
7952 ""
7953 }
7954 ),
7955 );
7956 }
7957}
7958
7959fn extract_selected_text(
7962 chat_container: &Arc<Container>,
7963 start: (u16, u16),
7964 end: (u16, u16),
7965) -> Option<String> {
7966 use rpi_tui::Component;
7967
7968 let lines = chat_container.render(80); if lines.is_empty() {
7971 return None;
7972 }
7973
7974 let (start_row, end_row) = if start.1 <= end.1 {
7976 (start.1 as usize, end.1 as usize)
7977 } else {
7978 (end.1 as usize, start.1 as usize)
7979 };
7980
7981 let start_row = start_row.min(lines.len().saturating_sub(1));
7983 let end_row = end_row.min(lines.len().saturating_sub(1));
7984
7985 if start_row > end_row {
7986 return None;
7987 }
7988
7989 let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7991
7992 if selected_lines.is_empty() {
7993 return None;
7994 }
7995
7996 Some(selected_lines.join("\n"))
7997}
7998
7999#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8003fn copy_to_clipboard(text: &str) -> bool {
8004 match arboard::Clipboard::new() {
8005 Ok(mut cb) => cb.set_text(text).is_ok(),
8006 Err(_) => false,
8007 }
8008}
8009
8010#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8011fn copy_to_clipboard(_text: &str) -> bool {
8012 false
8013}
8014
8015#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8019fn read_clipboard_text() -> Option<String> {
8020 let mut clipboard = arboard::Clipboard::new().ok()?;
8021 clipboard.get_text().ok()
8022}
8023
8024#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8025fn read_clipboard_text() -> Option<String> {
8026 None
8027}
8028
8029#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8033fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8034 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8035 let image = match clipboard.get_image() {
8036 Ok(image) => image,
8037 Err(_) => return Ok(None),
8038 };
8039 let width =
8040 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8041 let height =
8042 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8043 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8044 return Err("clipboard image dimensions are outside the supported range".into());
8045 }
8046 let mut bytes = Vec::new();
8047 {
8048 let mut encoder = png::Encoder::new(&mut bytes, width, height);
8049 encoder.set_color(png::ColorType::Rgba);
8050 encoder.set_depth(png::BitDepth::Eight);
8051 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8052 writer
8053 .write_image_data(&image.bytes)
8054 .map_err(|e| e.to_string())?;
8055 }
8056 Ok(Some(rpi_ai::types::ImageContent {
8057 kind: rpi_ai::types::ImageContentType,
8058 data: base64::engine::general_purpose::STANDARD.encode(bytes),
8059 mime_type: "image/png".into(),
8060 }))
8061}
8062
8063fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8064 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8065 let mut options = ImageOptions::default();
8066 options.width = Some(48);
8067 options.alt_text = Some("Attached image".into());
8068 chat.add_child(Arc::new(Image::from_data(bytes, options)));
8069 chat.add_child(Arc::new(Spacer::new(1)));
8070 }
8071}
8072
8073#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8074fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8075 Ok(None)
8076}
8077
8078fn add_assistant_message_blocking(
8085 container: &Arc<Container>,
8086 text: &str,
8087 transformer: Option<MarkdownTransformer>,
8088) {
8089 if text.is_empty() {
8090 return;
8091 }
8092 let msg = Arc::new(AssistantMessageComponent::new(
8093 AssistantMessageOptions::default(),
8094 ));
8095 if let Some(t) = &transformer {
8096 msg.set_markdown_transformer(Some(t.clone()));
8097 }
8098 msg.update_text(text);
8099 container.add_child(msg);
8100 container.add_child(Arc::new(Spacer::new(1)));
8101}
8102
8103async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8114 if let Ok(queued) = lane.queued_messages().await {
8115 state.set_pending_queue(queued);
8116 }
8117}
8118
8119fn transcript_has_live_panel(
8125 bash_components_present: bool,
8126 tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
8127) -> bool {
8128 bash_components_present
8129 || tool_components
8130 .values()
8131 .any(|component| component.status() == ToolStatus::Running)
8132}
8133
8134fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
8137 if current.trim().is_empty() {
8138 queued_text.to_string()
8139 } else {
8140 format!("{queued_text}\n\n{current}")
8141 }
8142}
8143
8144async fn restore_queued_messages_to_editor(
8154 lane: Arc<dyn AgentLane>,
8155 editor: Arc<Editor>,
8156 state: Arc<TuiState>,
8157 tui: Arc<TuiAltScreen>,
8158) {
8159 match lane.clear_queue().await {
8160 Ok(queued) => {
8161 let all: Vec<String> = queued
8162 .steering
8163 .iter()
8164 .chain(queued.follow_up.iter())
8165 .cloned()
8166 .collect();
8167 if !all.is_empty() {
8168 let queued_text = all.join("\n\n");
8169 let current = editor.get_text();
8170 let combined = merge_queued_into_draft(&queued_text, ¤t);
8171 let cursor = combined.chars().count();
8172 editor.set_text(&combined);
8173 editor.set_cursor(0, cursor);
8174 }
8175 refresh_pending_messages(&state, &lane).await;
8177 }
8178 Err(error) => {
8179 add_error_message(
8180 &state.chat_container,
8181 &format!("Could not restore queued messages: {error}"),
8182 );
8183 }
8184 }
8185 tui.request_render(false);
8186}
8187
8188async fn drain_agent_events(
8189 mut rx: broadcast::Receiver<AgentEvent>,
8190 tui: Arc<TuiAltScreen>,
8191 state: Arc<TuiState>,
8192 chat: Arc<Container>,
8193 lane: Arc<dyn AgentLane>,
8194) {
8195 loop {
8196 match rx.recv().await {
8197 Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8198 Err(broadcast::error::RecvError::Lagged(_)) => {
8199 continue;
8202 }
8203 Err(broadcast::error::RecvError::Closed) => break,
8204 }
8205 }
8206}
8207
8208async fn handle_agent_event(
8211 event: AgentEvent,
8212 tui: &Arc<TuiAltScreen>,
8213 state: &Arc<TuiState>,
8214 chat: &Arc<Container>,
8215 lane: &Arc<dyn AgentLane>,
8216) {
8217 match event {
8218 AgentEvent::AgentStart => {
8219 state.set_status(RunStatus::Working);
8220 tui.request_render(false);
8221 }
8222
8223 AgentEvent::AgentEnd { .. } => {
8224 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8226 comp.set_streaming(false);
8227 }
8228 state.set_status(RunStatus::Idle);
8229 let pending: Vec<AgentMessage> =
8233 std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8234 for message in pending {
8235 if let Err(error) = lane.append_message(message).await {
8236 add_error_message(chat, &format!("Could not record bash result: {error}"));
8237 }
8238 }
8239 refresh_pending_messages(state, lane).await;
8242 tui.request_render(false);
8243 }
8244
8245 AgentEvent::RetryScheduled {
8246 attempt,
8247 max_retries,
8248 delay_ms,
8249 ..
8250 } => {
8251 state.show_retry(attempt, max_retries, delay_ms);
8252 tui.request_render(false);
8253 }
8254
8255 AgentEvent::TurnStart => {
8256 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8259 comp.set_streaming(false);
8260 }
8261 }
8262
8263 AgentEvent::TurnEnd {
8264 message,
8265 tool_results,
8266 } => {
8267 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8269 if let AgentMessage::Assistant(a) = &message {
8270 comp.update_blocks(&assistant_blocks(a));
8271 }
8272 comp.set_streaming(false);
8273 }
8274 let tools = state.tool_components.lock().unwrap();
8279 for tr in &tool_results {
8280 if tools.contains_key(&tr.tool_call_id) {
8281 let _ = tr;
8284 }
8285 }
8286 drop(tools);
8287 tui.request_render(false);
8288 }
8289
8290 AgentEvent::MessageStart { message } => match message {
8291 AgentMessage::Assistant(a) => {
8292 let comp = Arc::new(AssistantMessageComponent::new(
8293 AssistantMessageOptions::default(),
8294 ));
8295 if let Some(t) = state.markdown_transformer() {
8300 comp.set_markdown_transformer(Some(t));
8301 }
8302 comp.set_hide_thinking(state.hide_thinking());
8303 comp.set_streaming(true);
8304 comp.update_blocks(&assistant_blocks(&a));
8307 chat.add_child(comp.clone());
8308 chat.add_child(Arc::new(Spacer::new(1)));
8311 *state.current_assistant.lock().unwrap() = Some(comp);
8312 tui.request_render(false);
8313 }
8314 AgentMessage::Custom(custom) => {
8315 let payload = serde_json::json!({
8316 "customType": custom.role,
8317 "content": custom.content,
8318 "details": custom.data,
8319 "expanded": false,
8320 "outputPad": 1,
8321 });
8322 if let Some(component) = extension_message_component(
8323 &state.extension_session,
8324 &custom.role,
8325 &payload,
8326 state.markdown_transformer(),
8327 ) {
8328 chat.add_child(component);
8329 chat.add_child(Arc::new(Spacer::new(1)));
8330 tui.request_render(false);
8331 } else {
8332 add_note_message(chat, &custom_message_fallback(&custom));
8333 tui.request_render(false);
8334 }
8335 }
8336 AgentMessage::User(user) => {
8346 add_user_message(chat, &user_message_text(&user));
8347 refresh_pending_messages(state, lane).await;
8349 tui.request_render(false);
8350 }
8351 _ => {}
8354 },
8355
8356 AgentEvent::MessageUpdate {
8357 message,
8358 assistant_message_event,
8359 } => {
8360 {
8361 let a: &rpi_ai::types::AssistantMessage = &message;
8366 let text = assistant_text(a);
8367 let mut saw_bash_tool_call = false;
8368 for c in &a.content {
8373 if let Content::ToolCall(tc) = c {
8374 if tc.name.trim().is_empty() {
8378 continue;
8379 }
8380 if tc.name == "bash" {
8381 let command = tc
8387 .arguments
8388 .get("command")
8389 .and_then(|v| v.as_str())
8390 .unwrap_or("");
8391 if command.trim().is_empty() {
8398 continue;
8399 }
8400 saw_bash_tool_call = true;
8401 let mut bash = state.bash_components.lock().unwrap();
8402 if let Some(existing) = bash.get(&tc.id) {
8403 existing.set_command(command);
8408 } else {
8409 let comp = Arc::new(BashExecutionComponent::new(command));
8410 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8411 chat.add_child(comp.clone());
8412 bash.insert(tc.id.clone(), comp);
8413 }
8414 } else {
8415 let mut tools = state.tool_components.lock().unwrap();
8416 let display_args = if is_ask_user_tool(&tc.name) {
8417 ask_user_args_display(&tc.arguments)
8418 } else {
8419 tc.arguments.to_string()
8420 };
8421 if let Some(existing) = tools.get(&tc.id) {
8422 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
8426 existing.set_args(&display_args);
8427 }
8428 } else {
8429 let comp =
8430 Arc::new(ToolExecutionComponent::new(&tc.name, &display_args));
8431 if is_ask_user_tool(&tc.name) {
8432 comp.set_display_title("ASK USER");
8433 }
8434 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8435 comp.set_running();
8436 chat.add_child(comp.clone());
8437 tools.insert(tc.id.clone(), comp);
8438 }
8439 }
8440 }
8441 }
8442 if saw_bash_tool_call {
8447 state.sync_working_loader_with_bash();
8448 }
8449 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
8451 comp.update_blocks(&assistant_blocks(a));
8454 }
8455 *state.last_assistant_text.lock().unwrap() = text;
8456 tui.request_render(false);
8457 }
8458 }
8459
8460 AgentEvent::MessageEnd { message } => {
8461 if let AgentMessage::Assistant(a) = &message {
8462 let text = assistant_text(a);
8463 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8464 comp.update_blocks(&assistant_blocks(a));
8465 comp.set_streaming(false);
8466 }
8467 if !text.is_empty() {
8469 *state.last_assistant_text.lock().unwrap() = text;
8470 }
8471 let usage = &a.usage;
8477 state.footer.add_usage(
8479 usage.input,
8480 usage.output,
8481 usage.cache_read,
8482 usage.cache_write,
8483 usage.cost.total,
8484 );
8485 let denom = usage.input + usage.cache_read + usage.cache_write;
8486 if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8487 state
8488 .footer
8489 .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8490 }
8491 record_context_usage(&state.footer, a);
8495 let miss = state
8496 .cache_tracker
8497 .lock()
8498 .unwrap()
8499 .observe(a, &rpi_harness::cache_stats::NoPrices);
8500 let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8504 if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8505 let cost = if miss.missed_cost > 0.0 {
8506 format!(" (~${:.4})", miss.missed_cost)
8507 } else {
8508 String::new()
8509 };
8510 add_note_message(
8511 &state.chat_container,
8512 &format!(
8513 "Cache miss: {} tokens re-billed{}",
8514 format_tokens(miss.missed_tokens),
8515 cost
8516 ),
8517 );
8518 }
8519 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8520 add_note_message(chat, &text);
8521 }
8522 if let Some(error) = assistant_error_text(a) {
8527 add_error_message(chat, &error);
8528 }
8529 }
8530 tui.request_render(false);
8531 }
8532
8533 AgentEvent::ToolExecutionStart {
8534 tool_call_id,
8535 tool_name,
8536 args,
8537 } => {
8538 if tool_name.trim().is_empty() {
8541 return;
8542 }
8543 if tool_name == "bash" {
8544 let command = args
8549 .get("command")
8550 .and_then(|v| v.as_str())
8551 .unwrap_or("")
8552 .to_string();
8553 if command.trim().is_empty() {
8557 return;
8558 }
8559 let mut bash_map = state.bash_components.lock().unwrap();
8560 if let Some(existing) = bash_map.get(&tool_call_id) {
8561 existing.set_command(&command);
8566 } else {
8567 let comp = Arc::new(BashExecutionComponent::new(command));
8568 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8569 chat.add_child(comp.clone());
8570 bash_map.insert(tool_call_id.clone(), comp);
8571 }
8572 } else {
8573 let _comp = {
8574 let mut tools = state.tool_components.lock().unwrap();
8575 if let Some(existing) = tools.get(&tool_call_id) {
8576 if is_ask_user_tool(&tool_name) {
8577 existing.set_display_title("ASK USER");
8578 existing.set_args(&ask_user_args_display(&args));
8579 } else {
8580 existing.set_args(&args.to_string());
8581 }
8582 existing.clone()
8583 } else {
8584 let display_args = if is_ask_user_tool(&tool_name) {
8585 ask_user_args_display(&args)
8586 } else {
8587 args.to_string()
8588 };
8589 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8590 if is_ask_user_tool(&tool_name) {
8591 comp.set_display_title("ASK USER");
8592 }
8593 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8594 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8599 comp.set_skill_name(skill);
8600 }
8601 comp.set_running();
8602 chat.add_child(comp.clone());
8603 tools.insert(tool_call_id.clone(), comp.clone());
8604 comp
8605 }
8606 };
8607 }
8608 state.sync_working_loader_with_bash();
8609 tui.request_render(false);
8610 }
8611
8612 AgentEvent::ToolExecutionUpdate {
8613 tool_call_id,
8614 tool_name,
8615 args,
8616 partial_result,
8617 } => {
8618 if tool_name.trim().is_empty() {
8619 return;
8620 }
8621 let partial_text = if is_ask_user_tool(&tool_name) {
8622 ask_user_progress_text(&args, &partial_result)
8623 } else {
8624 tool_result_text(&partial_result)
8625 };
8626 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
8627 if tool_name == "bash" {
8628 let chunk = partial_text;
8633 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
8634 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
8637 bash.set_command(command);
8638 }
8639 if has_partial_payload {
8640 bash.append_output(&chunk);
8641 }
8642 } else if has_partial_payload {
8643 }
8649 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
8650 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8651 comp.set_skill_name(skill);
8652 }
8653 if is_ask_user_tool(&tool_name) {
8654 comp.set_display_title("ASK USER");
8655 comp.set_args(&ask_user_args_display(&args));
8656 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
8657 comp.set_args(&args.to_string());
8658 }
8659 if has_partial_payload {
8662 comp.set_result(&partial_text, false);
8663 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
8664 }
8665 } else if has_partial_payload {
8666 let display_args = if is_ask_user_tool(&tool_name) {
8671 ask_user_args_display(&args)
8672 } else {
8673 args.to_string()
8674 };
8675 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8676 if is_ask_user_tool(&tool_name) {
8677 comp.set_display_title("ASK USER");
8678 }
8679 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8680 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8681 comp.set_skill_name(skill);
8682 }
8683 comp.set_running();
8684 comp.set_result(&partial_text, false);
8685 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
8686 chat.add_child(comp.clone());
8687 state
8688 .tool_components
8689 .lock()
8690 .unwrap()
8691 .insert(tool_call_id.clone(), comp.clone());
8692 }
8693 state.sync_working_loader_with_bash();
8694 tui.request_render(false);
8695 }
8696
8697 AgentEvent::ToolExecutionEnd {
8698 tool_call_id,
8699 tool_name,
8700 result,
8701 is_error,
8702 } => {
8703 if tool_name.trim().is_empty() {
8704 return;
8705 }
8706 if tool_name == "bash" {
8707 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
8708 if let Some(bash) = bash {
8709 finalize_bash(&bash, &result, is_error);
8710 } else {
8711 let command = result
8714 .details
8715 .get("command")
8716 .and_then(|v| v.as_str())
8717 .unwrap_or("")
8718 .to_string();
8719 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
8720 state.sync_working_loader_with_bash();
8721 tui.render_now(false);
8724 return;
8725 }
8726 let comp = Arc::new(BashExecutionComponent::new(command));
8727 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8728 comp.append_output(&tool_result_text(&result));
8729 finalize_bash(&comp, &result, is_error);
8730 chat.add_child(comp);
8731 }
8732 } else {
8733 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
8734 if let Some(comp) = comp {
8735 let result_text = if is_ask_user_tool(&tool_name) {
8736 ask_user_result_text(&result)
8737 } else {
8738 tool_result_text(&result)
8739 };
8740 comp.set_result(&result_text, is_error);
8741 if !is_ask_user_tool(&tool_name) {
8742 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8743 }
8744 } else {
8745 let comp = Arc::new(ToolExecutionComponent::new(
8748 &tool_name,
8749 &if is_ask_user_tool(&tool_name) {
8750 ask_user_args_display(&serde_json::Value::Null)
8751 } else {
8752 "".to_string()
8753 },
8754 ));
8755 if is_ask_user_tool(&tool_name) {
8756 comp.set_display_title("ASK USER");
8757 }
8758 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8759 let result_text = if is_ask_user_tool(&tool_name) {
8760 ask_user_result_text(&result)
8761 } else {
8762 tool_result_text(&result)
8763 };
8764 comp.set_result(&result_text, is_error);
8765 if !is_ask_user_tool(&tool_name) {
8766 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8767 }
8768 chat.add_child(comp.clone());
8769 }
8770 }
8771 state.sync_working_loader_with_bash();
8772 tui.render_now(false);
8778 }
8779 }
8780}
8781
8782fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
8790 if message.stop_reason == rpi_ai::StopReason::Aborted
8791 || message.stop_reason == rpi_ai::StopReason::Error
8792 {
8793 return;
8794 }
8795 let tokens = message.usage.context_tokens();
8796 if tokens > 0 {
8797 footer.set_context_tokens(Some(tokens));
8798 }
8799}
8800
8801fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
8805 let fallback = match message.stop_reason {
8806 rpi_ai::StopReason::Error => "Provider request failed.",
8807 rpi_ai::StopReason::Aborted => "Request aborted.",
8808 _ => return None,
8809 };
8810 Some(
8811 message
8812 .error_message
8813 .as_deref()
8814 .filter(|text| !text.trim().is_empty())
8815 .unwrap_or(fallback)
8816 .to_string(),
8817 )
8818}
8819
8820fn finalize_bash(
8824 comp: &Arc<BashExecutionComponent>,
8825 result: &rpi_agent::AgentToolResult,
8826 is_error: bool,
8827) {
8828 let exit_code = if is_error { Some(1) } else { Some(0) };
8831 let truncated = result
8832 .details
8833 .get("truncation")
8834 .and_then(|t| t.get("truncated"))
8835 .and_then(|v| v.as_bool())
8836 .unwrap_or(false);
8837 let full_output_path = result
8838 .details
8839 .get("full_output_path")
8840 .and_then(|v| v.as_str())
8841 .map(|s| s.to_string());
8842 let truncation = BashTruncation {
8843 truncated,
8844 full_output_path,
8845 };
8846 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
8850 comp.set_command(command);
8851 }
8852 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
8854}
8855
8856fn apply_edit_diff(
8861 comp: &Arc<ToolExecutionComponent>,
8862 tool_name: &str,
8863 details: &serde_json::Value,
8864 tui: &Arc<TuiAltScreen>,
8865) {
8866 if tool_name != "edit" {
8867 return;
8868 }
8869 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
8870 return;
8871 };
8872 if diff_text.is_empty() {
8873 return;
8874 }
8875 let width = tui.width();
8876 let lines = render_diff(diff_text, width);
8877 comp.set_diff(lines);
8878}
8879
8880fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
8885 if tool_name != "read" {
8886 return None;
8887 }
8888 let path = args.get("path").and_then(|value| value.as_str())?;
8889 let normalized = path.replace('\\', "/");
8890 let file_name = normalized.rsplit('/').next()?;
8891 if !file_name.eq_ignore_ascii_case("SKILL.md") {
8892 return None;
8893 }
8894 normalized
8895 .trim_end_matches('/')
8896 .rsplit('/')
8897 .nth(1)
8898 .filter(|name| !name.is_empty())
8899 .map(str::to_string)
8900}
8901
8902fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
8907 use rpi_agent::TextContentOrImage;
8908 let mut parts: Vec<String> = Vec::new();
8909 for c in &result.content {
8910 if let TextContentOrImage::Text(t) = c {
8911 parts.push(t.text.clone());
8912 }
8913 }
8914 parts.join("\n")
8915}
8916
8917fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
8921 !text.trim().is_empty() || !result.details.is_null()
8922}
8923
8924fn is_ask_user_tool(tool_name: &str) -> bool {
8927 matches!(tool_name, "ask_user" | "ask_user_question")
8928}
8929
8930fn ask_user_args_display(args: &serde_json::Value) -> String {
8933 if args.is_null() {
8934 return String::new();
8935 }
8936 if let Some(summary) = args
8937 .get("summary")
8938 .and_then(serde_json::Value::as_str)
8939 .map(str::trim)
8940 .filter(|text| !text.is_empty())
8941 {
8942 return format!("Confirmation requested: {summary}");
8943 }
8944
8945 let mut lines: Vec<String> = Vec::new();
8946 let questions: Vec<&serde_json::Value> = args
8947 .get("questions")
8948 .and_then(serde_json::Value::as_array)
8949 .filter(|items| !items.is_empty())
8950 .map(|items| items.iter().collect())
8951 .unwrap_or_else(|| vec![args]);
8952
8953 for question in questions {
8954 let prompt = question
8955 .get("question")
8956 .and_then(serde_json::Value::as_str)
8957 .or_else(|| args.get("question").and_then(serde_json::Value::as_str))
8958 .map(str::trim)
8959 .unwrap_or_default();
8960 if prompt.is_empty() {
8961 continue;
8962 }
8963 if let Some(header) = question
8964 .get("header")
8965 .and_then(serde_json::Value::as_str)
8966 .map(str::trim)
8967 .filter(|text| !text.is_empty())
8968 {
8969 lines.push(format!("{header}: {prompt}"));
8970 } else {
8971 lines.push(prompt.to_string());
8972 }
8973 let context = question
8974 .get("context")
8975 .and_then(serde_json::Value::as_str)
8976 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
8977 .map(str::trim)
8978 .filter(|text| !text.is_empty());
8979 if let Some(context) = context {
8980 lines.push(format!(" {context}"));
8981 }
8982 let options = question
8983 .get("options")
8984 .and_then(serde_json::Value::as_array)
8985 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
8986 if let Some(options) = options {
8987 let labels: Vec<String> = options
8988 .iter()
8989 .filter_map(|option| {
8990 option
8991 .get("title")
8992 .and_then(serde_json::Value::as_str)
8993 .or_else(|| option.as_str())
8994 .map(str::trim)
8995 .filter(|label| !label.is_empty())
8996 .map(str::to_string)
8997 })
8998 .collect();
8999 if !labels.is_empty() {
9000 lines.push(format!("Choices: {}", labels.join(", ")));
9001 }
9002 }
9003 if let Some(suggest) = question
9004 .get("suggest")
9005 .and_then(serde_json::Value::as_str)
9006 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
9007 .map(str::trim)
9008 .filter(|text| !text.is_empty())
9009 {
9010 lines.push(format!("Suggestions: {suggest}"));
9011 }
9012 }
9013
9014 lines.join("\n")
9015}
9016
9017fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
9020 let text = tool_result_text(result);
9021 if !text.trim().is_empty() {
9022 return text;
9023 }
9024 let args_text = ask_user_args_display(args);
9025 if args_text.trim().is_empty() {
9026 "Waiting for your answer…".to_string()
9027 } else {
9028 args_text
9029 }
9030}
9031
9032fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
9035 let text = tool_result_text(result);
9036 if text.trim().is_empty() {
9037 "Answer recorded.".to_string()
9038 } else {
9039 text
9040 }
9041}
9042
9043fn open_selector(
9051 state: &Arc<TuiState>,
9052 editor_container: &Arc<Container>,
9053 editor: &Arc<Editor>,
9054 tui: &Arc<TuiAltScreen>,
9055 list: Arc<SelectList>,
9056 kind: SelectorKind,
9057) {
9058 open_selector_with_view(
9059 state,
9060 editor_container,
9061 editor,
9062 tui,
9063 SelectorView::List(list.clone()),
9064 list,
9065 kind,
9066 );
9067}
9068
9069fn open_searchable_selector(
9074 state: &Arc<TuiState>,
9075 editor_container: &Arc<Container>,
9076 editor: &Arc<Editor>,
9077 tui: &Arc<TuiAltScreen>,
9078 title: Option<&str>,
9079 description: Option<&str>,
9080 list: Arc<SelectList>,
9081 kind: SelectorKind,
9082) -> Arc<SearchableSelectList> {
9083 let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9084 open_selector_with_view(
9085 state,
9086 editor_container,
9087 editor,
9088 tui,
9089 SelectorView::Searchable(searchable.clone()),
9090 searchable.clone(),
9091 kind,
9092 );
9093 searchable.set_focused(true);
9097 searchable
9098}
9099
9100fn open_selector_with_view<V: Component + 'static>(
9108 state: &Arc<TuiState>,
9109 editor_container: &Arc<Container>,
9110 editor: &Arc<Editor>,
9111 tui: &Arc<TuiAltScreen>,
9112 view_router: SelectorView,
9113 view: Arc<V>,
9114 kind: SelectorKind,
9115) {
9116 dispatch_ui_prompt_event(state);
9118
9119 editor.set_focused(false);
9121 state.autocomplete_container.clear();
9124 editor_container.clear();
9126 editor_container.add_child(view.clone());
9127 *state.active_selector.lock().unwrap() = Some((view_router, kind));
9128 let focused: Arc<dyn Component> = view;
9129 tui.set_focus(Some(focused));
9130 tui.request_render(false);
9131}
9132
9133fn close_selector(
9136 state: &Arc<TuiState>,
9137 editor_container: &Arc<Container>,
9138 editor: &Arc<Editor>,
9139 tui: &Arc<TuiAltScreen>,
9140) {
9141 dispatch_ui_prompt_event_end(state);
9143
9144 editor_container.clear();
9145 editor_container.add_child(editor.clone());
9146 state.autocomplete_container.clear();
9147 editor.set_focused(true);
9148 *state.active_selector.lock().unwrap() = None;
9149 *state.active_extension_cancel.lock().unwrap() = None;
9150 tui.set_focus(Some(editor.clone()));
9151 tui.request_render(false);
9152}
9153
9154fn open_model_selector(
9160 state: &Arc<TuiState>,
9161 editor_container: &Arc<Container>,
9162 editor: &Arc<Editor>,
9163 tui: &Arc<TuiAltScreen>,
9164 catalog: &[rpi_ai::Model],
9165 lane: &Arc<dyn AgentLane>,
9166 lane_model_id: &str,
9167 chat: &Arc<Container>,
9168 initial_search: Option<&str>,
9169) -> Option<Arc<SearchableSelectList>> {
9170 let items = model_selector_items(catalog, lane_model_id);
9171 if items.is_empty() {
9172 add_note_message(
9173 chat,
9174 "No models in the catalog. Use --model at startup to select one.",
9175 );
9176 tui.request_render(false);
9177 return None;
9178 }
9179 let list = Arc::new(SelectList::new(items, 10));
9180
9181 let catalog_arc = catalog.to_vec();
9185 let state_sel = state.clone();
9186 let ec_sel = editor_container.clone();
9187 let editor_sel = editor.clone();
9188 let tui_sel = tui.clone();
9189 let chat_sel = chat.clone();
9190 let lane_sel = lane.clone();
9191 list.on_select(Arc::new(move |item| {
9192 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9193 add_note_message(
9194 &chat_sel,
9195 &format!("Model {} not found in catalog.", item.label),
9196 );
9197 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9198 return;
9199 };
9200 state_sel.set_current_model(&model);
9201 let lane = lane_sel.clone();
9202 tokio::spawn(async move {
9203 let _ = lane.set_model(model).await;
9204 });
9205 add_note_message(
9206 &chat_sel,
9207 &format!(
9208 "Model set to {} — applies to the next message.",
9209 short_model_name(&item.value)
9210 ),
9211 );
9212 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9213 }));
9214 let state_cancel = state.clone();
9215 let ec_cancel = editor_container.clone();
9216 let editor_cancel = editor.clone();
9217 let tui_cancel = tui.clone();
9218 list.on_cancel(Arc::new(move || {
9219 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9220 }));
9221
9222 let searchable = open_searchable_selector(
9223 state,
9224 editor_container,
9225 editor,
9226 tui,
9227 Some("Select model"),
9228 Some("Type to filter by name, provider, or id"),
9229 list,
9230 SelectorKind::Model,
9231 );
9232 if let Some(term) = initial_search {
9233 searchable.set_search_text(term);
9234 }
9235 Some(searchable)
9236}
9237
9238fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9243 if catalog.is_empty() {
9244 return None;
9245 }
9246 let idx = catalog
9247 .iter()
9248 .position(|m| m.id.eq_ignore_ascii_case(current_id));
9249 match idx {
9250 Some(i) => {
9251 let next = (i + 1) % catalog.len();
9252 Some(catalog[next].clone())
9253 }
9254 None => Some(catalog[0].clone()),
9255 }
9256}
9257
9258fn open_session_selector(
9264 state: &Arc<TuiState>,
9265 editor_container: &Arc<Container>,
9266 editor: &Arc<Editor>,
9267 tui: &Arc<TuiAltScreen>,
9268 cwd: &std::path::Path,
9269 tx: &mpsc::UnboundedSender<TuiMessage>,
9270) {
9271 let dir = crate::session::default_session_dir(cwd);
9272 let mut items: Vec<SelectItem> = Vec::new();
9273 if let Ok(entries) = std::fs::read_dir(&dir) {
9274 for entry in entries.flatten() {
9275 let path = entry.path();
9276 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
9277 continue;
9278 }
9279 let stem = path
9280 .file_stem()
9281 .and_then(|s| s.to_str())
9282 .unwrap_or("(unnamed)")
9283 .to_string();
9284 let display = path
9285 .file_name()
9286 .and_then(|s| s.to_str())
9287 .unwrap_or(&stem)
9288 .to_string();
9289 items.push(SelectItem::new(&stem, &display));
9290 }
9291 }
9292 if items.is_empty() {
9293 add_note_message(
9294 &state.chat_container,
9295 "No saved sessions found. Sessions are created automatically in interactive mode.",
9296 );
9297 tui.request_render(false);
9298 return;
9299 }
9300 let list = Arc::new(SelectList::new(items, 10));
9301
9302 let state_sel = state.clone();
9303 let ec_sel = editor_container.clone();
9304 let editor_sel = editor.clone();
9305 let tui_sel = tui.clone();
9306 let tx_sel = tx.clone();
9307 list.on_select(Arc::new(move |item| {
9308 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9312 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9313 }));
9314 let state_cancel = state.clone();
9315 let ec_cancel = editor_container.clone();
9316 let editor_cancel = editor.clone();
9317 let tui_cancel = tui.clone();
9318 list.on_cancel(Arc::new(move || {
9319 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9320 }));
9321
9322 open_searchable_selector(
9323 state,
9324 editor_container,
9325 editor,
9326 tui,
9327 Some("Resume session"),
9328 Some("Type to filter by session id"),
9329 list,
9330 SelectorKind::Session,
9331 );
9332}
9333
9334fn custom_entry_display_text(
9335 custom_type: &str,
9336 data: Option<&serde_json::Value>,
9337) -> Option<String> {
9338 let data = data?;
9339 let text = data
9340 .get("summary")
9341 .or_else(|| data.get("text"))
9342 .or_else(|| data.get("output"))
9343 .and_then(|value| value.as_str())
9344 .filter(|value| !value.trim().is_empty())?;
9345 let label = match custom_type {
9346 "compactionSummary" => "Compaction summary",
9347 "branchSummary" => "Branch summary",
9348 "bashExecution" => "Command output",
9349 other => other,
9350 };
9351 Some(format!("{label}: {text}"))
9352}
9353
9354async fn open_tree_selector(
9358 harness: &AgentHarness,
9359 state: &Arc<TuiState>,
9360 editor_container: &Arc<Container>,
9361 editor: &Arc<Editor>,
9362 tui: &Arc<TuiAltScreen>,
9363 chat: &Arc<Container>,
9364 tx: &mpsc::UnboundedSender<TuiMessage>,
9365) {
9366 let entries = match harness
9367 .session()
9368 .view("main")
9369 .find_entries(&EntryQuery {
9370 order: Some(EntryOrder::OldestFirst),
9371 ..Default::default()
9372 })
9373 .await
9374 {
9375 Ok(entries) => entries,
9376 Err(error) => {
9377 add_error_message(chat, &format!("Could not read session tree: {error}"));
9378 tui.request_render(false);
9379 return;
9380 }
9381 };
9382 let current = harness.session().get_leaf_id().await.ok().flatten();
9383 let items: Vec<SelectItem> = entries
9384 .iter()
9385 .map(|entry| {
9386 let label = if current.as_deref() == Some(entry.id()) {
9387 format!("{} #{} (current)", entry.entry_type(), entry.seq())
9388 } else {
9389 format!("{} #{}", entry.entry_type(), entry.seq())
9390 };
9391 let short_id = &entry.id()[..entry.id().len().min(12)];
9392 let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9395 SelectItem::new(entry.id(), &label)
9396 .with_description(short_id)
9397 .with_search_text(&search_text)
9398 })
9399 .collect();
9400 if items.is_empty() {
9401 add_note_message(chat, "The current session has no entries to navigate.");
9402 tui.request_render(false);
9403 return;
9404 }
9405 let list = Arc::new(SelectList::new(items, 12));
9406 let state_sel = state.clone();
9407 let ec_sel = editor_container.clone();
9408 let editor_sel = editor.clone();
9409 let tui_sel = tui.clone();
9410 let tx_sel = tx.clone();
9411 list.on_select(Arc::new(move |item| {
9412 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9413 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9414 }));
9415 let state_cancel = state.clone();
9416 let ec_cancel = editor_container.clone();
9417 let editor_cancel = editor.clone();
9418 let tui_cancel = tui.clone();
9419 list.on_cancel(Arc::new(move || {
9420 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9421 }));
9422 open_searchable_selector(
9423 state,
9424 editor_container,
9425 editor,
9426 tui,
9427 Some("Session tree"),
9428 Some("Type to filter by entry type, sequence, or id"),
9429 list,
9430 SelectorKind::Tree,
9431 );
9432}
9433
9434fn open_theme_selector(
9437 state: &Arc<TuiState>,
9438 editor_container: &Arc<Container>,
9439 editor: &Arc<Editor>,
9440 tui: &Arc<TuiAltScreen>,
9441 cwd: &std::path::Path,
9442 package_resources: &Arc<crate::packages::PackageResources>,
9443) {
9444 let mut items = vec![
9445 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9446 SelectItem::new("light", "Light").with_description("Light background"),
9447 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9448 ];
9449 if state.themes_enabled {
9450 for path in package_resources.theme_files() {
9451 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9452 items.push(SelectItem::new(name, name).with_description("Package theme"));
9453 }
9454 }
9455 }
9456 let list = Arc::new(SelectList::new(items, 10));
9457
9458 let state_sel = state.clone();
9459 let ec_sel = editor_container.clone();
9460 let editor_sel = editor.clone();
9461 let tui_sel = tui.clone();
9462 let chat_sel = state.chat_container.clone();
9463 let cwd_sel = cwd.to_path_buf();
9464 let package_resources_sel = package_resources.clone();
9465 list.on_select(Arc::new(move |item| {
9466 let preset = match item.value.as_str() {
9467 "light" => Some(ThemePreset::Light),
9468 "monochrome" => Some(ThemePreset::Monochrome),
9469 "dark" => Some(ThemePreset::Dark),
9470 name => {
9471 if state_sel.themes_enabled {
9472 if let Ok(custom) = crate::packages::load_theme_with_resources(
9473 &cwd_sel,
9474 name,
9475 &package_resources_sel,
9476 ) {
9477 rpi_tui::global_theme_manager().set(custom.clone());
9478 state_sel.theme_manager.set(custom);
9479 }
9480 }
9481 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9482 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9483 tui_sel.render_now(true);
9484 return;
9485 }
9486 };
9487 let Some(preset) = preset else { return };
9488 apply_theme_preset(preset);
9489 state_sel.theme_manager.apply_preset(preset);
9490 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9493 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9494 tui_sel.render_now(true);
9495 }));
9496 let state_cancel = state.clone();
9497 let ec_cancel = editor_container.clone();
9498 let editor_cancel = editor.clone();
9499 let tui_cancel = tui.clone();
9500 list.on_cancel(Arc::new(move || {
9501 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9502 }));
9503
9504 open_selector(
9505 state,
9506 editor_container,
9507 editor,
9508 tui,
9509 list,
9510 SelectorKind::Theme,
9511 );
9512}
9513
9514fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9521 use rpi_ai::types::ThinkingLevel::*;
9522 match level {
9523 Off => "Off — No reasoning",
9524 Minimal => "Minimal — Brief reasoning (~1k tokens)",
9525 Low => "Low — Light reasoning (~1k tokens)",
9526 Medium => "Medium — Moderate reasoning (~80% of max)",
9527 High => "High — Extensive reasoning (~95% of max)",
9528 Xhigh => "Xhigh — Near-maximal reasoning",
9529 Max => "Max — Maximum reasoning",
9530 }
9531}
9532
9533fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9536 use rpi_ai::types::ThinkingLevel::*;
9537 match level {
9538 Off => "off",
9539 Minimal => "minimal",
9540 Low => "low",
9541 Medium => "medium",
9542 High => "high",
9543 Xhigh => "xhigh",
9544 Max => "max",
9545 }
9546}
9547
9548fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9551 use rpi_ai::types::ThinkingLevel::*;
9552 match name.to_ascii_lowercase().as_str() {
9553 "off" => Some(Off),
9554 "minimal" => Some(Minimal),
9555 "low" => Some(Low),
9556 "medium" => Some(Medium),
9557 "high" => Some(High),
9558 "xhigh" => Some(Xhigh),
9559 "max" => Some(Max),
9560 _ => None,
9561 }
9562}
9563
9564fn open_thinking_selector(
9574 state: &Arc<TuiState>,
9575 editor_container: &Arc<Container>,
9576 editor: &Arc<Editor>,
9577 tui: &Arc<TuiAltScreen>,
9578 lane: &Arc<dyn AgentLane>,
9579 catalog: &[rpi_ai::Model],
9580 lane_model_id: &str,
9581 chat: &Arc<Container>,
9582) {
9583 let model = catalog
9586 .iter()
9587 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9588 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9589 .map(|m| m.supported_thinking_levels())
9590 .unwrap_or_else(|| {
9591 use rpi_ai::types::ThinkingLevel::*;
9592 vec![Off, Minimal, Low, Medium, High]
9593 });
9594 let mut items: Vec<SelectItem> = Vec::new();
9595 for lvl in &levels {
9596 let name = thinking_level_name(*lvl);
9597 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9598 }
9599 if items.is_empty() {
9600 add_note_message(chat, "This model has no supported thinking levels.");
9601 tui.request_render(false);
9602 return;
9603 }
9604 let list = Arc::new(SelectList::new(items, 10));
9605
9606 let state_sel = state.clone();
9607 let ec_sel = editor_container.clone();
9608 let editor_sel = editor.clone();
9609 let tui_sel = tui.clone();
9610 let chat_sel = chat.clone();
9611 let lane_sel = lane.clone();
9612 list.on_select(Arc::new(move |item| {
9613 let Some(level) = thinking_level_from_name(&item.value) else {
9614 add_note_message(
9615 &chat_sel,
9616 &format!("Unknown thinking level: {}.", item.label),
9617 );
9618 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9619 return;
9620 };
9621 let lane = lane_sel.clone();
9622 let footer_sel = state_sel.footer.clone();
9623 tokio::spawn(async move {
9624 let _ = lane.set_thinking_level(level).await;
9625 });
9626 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9630 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9631 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9632 }));
9633 let state_cancel = state.clone();
9634 let ec_cancel = editor_container.clone();
9635 let editor_cancel = editor.clone();
9636 let tui_cancel = tui.clone();
9637 list.on_cancel(Arc::new(move || {
9638 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9639 }));
9640
9641 open_selector(
9642 state,
9643 editor_container,
9644 editor,
9645 tui,
9646 list,
9647 SelectorKind::Thinking,
9648 );
9649}
9650
9651fn open_tools_selector(
9657 state: &Arc<TuiState>,
9658 editor_container: &Arc<Container>,
9659 editor: &Arc<Editor>,
9660 tui: &Arc<TuiAltScreen>,
9661 lane: &Arc<dyn AgentLane>,
9662 chat: &Arc<Container>,
9663) {
9664 let mut active = match tokio::runtime::Handle::try_current() {
9669 Ok(h) => h
9670 .block_on(async { lane.get_active_tools().await })
9671 .unwrap_or_default(),
9672 Err(_) => Vec::new(),
9673 };
9674 if active.is_empty() {
9679 active = crate::session::BUILTIN_TOOL_NAMES
9680 .iter()
9681 .map(|name| (*name).to_string())
9682 .collect();
9683 }
9684 let mut items: Vec<SelectItem> = Vec::new();
9685 for name in crate::session::BUILTIN_TOOL_NAMES {
9686 let on = active.iter().any(|a| a == name);
9687 let label = if on {
9688 format!("{name} (on)")
9689 } else {
9690 (*name).to_string()
9691 };
9692 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9693 }
9694 let list = Arc::new(SelectList::new(items, 10));
9695
9696 let active_captured = active.clone();
9698 let state_sel = state.clone();
9699 let ec_sel = editor_container.clone();
9700 let editor_sel = editor.clone();
9701 let tui_sel = tui.clone();
9702 let chat_sel = chat.clone();
9703 let lane_sel = lane.clone();
9704 list.on_select(Arc::new(move |item| {
9705 let mut next = active_captured.clone();
9706 if let Some(pos) = next.iter().position(|a| a == &item.value) {
9707 next.remove(pos);
9708 } else {
9709 next.push(item.value.clone());
9710 }
9711 let on = next.iter().any(|a| a == &item.value);
9712 let lane = lane_sel.clone();
9713 let next_clone = next.clone();
9714 tokio::spawn(async move {
9715 let _ = lane.set_active_tools(next_clone).await;
9716 });
9717 let list_str = if next.is_empty() {
9718 "(none)".to_string()
9719 } else {
9720 next.join(", ")
9721 };
9722 add_note_message(
9723 &chat_sel,
9724 &format!(
9725 "{} {} — active tools: {}",
9726 item.value,
9727 if on { "enabled" } else { "disabled" },
9728 list_str
9729 ),
9730 );
9731 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9732 }));
9733 let state_cancel = state.clone();
9734 let ec_cancel = editor_container.clone();
9735 let editor_cancel = editor.clone();
9736 let tui_cancel = tui.clone();
9737 list.on_cancel(Arc::new(move || {
9738 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9739 }));
9740
9741 open_selector(
9742 state,
9743 editor_container,
9744 editor,
9745 tui,
9746 list,
9747 SelectorKind::Tools,
9748 );
9749}
9750
9751fn open_images_selector(
9755 state: &Arc<TuiState>,
9756 editor_container: &Arc<Container>,
9757 editor: &Arc<Editor>,
9758 tui: &Arc<TuiAltScreen>,
9759 chat: &Arc<Container>,
9760) {
9761 let current = *state.show_images.lock().unwrap();
9762 let items = vec![
9763 SelectItem::new("yes", "Yes").with_description(if current {
9764 "Inline images (current)"
9765 } else {
9766 "Inline images"
9767 }),
9768 SelectItem::new("no", "No").with_description(if current {
9769 "Placeholder only"
9770 } else {
9771 "Placeholder only (current)"
9772 }),
9773 ];
9774 let list = Arc::new(SelectList::new(items, 5));
9775
9776 let state_sel = state.clone();
9777 let ec_sel = editor_container.clone();
9778 let editor_sel = editor.clone();
9779 let tui_sel = tui.clone();
9780 let chat_sel = chat.clone();
9781 list.on_select(Arc::new(move |item| {
9782 let on = item.value == "yes";
9783 *state_sel.show_images.lock().unwrap() = on;
9784 add_note_message(
9785 &chat_sel,
9786 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9787 );
9788 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9789 }));
9790 let state_cancel = state.clone();
9791 let ec_cancel = editor_container.clone();
9792 let editor_cancel = editor.clone();
9793 let tui_cancel = tui.clone();
9794 list.on_cancel(Arc::new(move || {
9795 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9796 }));
9797
9798 open_selector(
9799 state,
9800 editor_container,
9801 editor,
9802 tui,
9803 list,
9804 SelectorKind::Images,
9805 );
9806}
9807
9808fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9816 let text = editor.get_text();
9817 let cursor = editor_cursor_offset(editor, &text);
9818 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9819 render_autocomplete(state, suggestions);
9820}
9821
9822fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9825 let (row, col) = editor.cursor_position();
9826 let mut offset = 0;
9827 for (index, line) in text.split('\n').enumerate() {
9828 if index == row {
9829 return (offset + col.min(line.len())).min(text.len());
9830 }
9831 offset = offset.saturating_add(line.len() + 1);
9832 }
9833 text.len()
9834}
9835
9836fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9839 let offset = offset.min(text.len());
9840 let mut consumed = 0;
9841 for (row, line) in text.split('\n').enumerate() {
9842 let end = consumed + line.len();
9843 if offset <= end {
9844 editor.set_cursor(row, offset - consumed);
9845 return;
9846 }
9847 consumed = end + 1;
9848 }
9849 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9850 editor.set_cursor(
9851 last_row,
9852 text.rsplit('\n').next().map(str::len).unwrap_or(0),
9853 );
9854}
9855
9856fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9858 state.autocomplete_container.clear();
9859 let Some(sugg) = suggestions else {
9860 return;
9861 };
9862 if sugg.items.is_empty() {
9863 return;
9864 }
9865 let accent = state.theme_manager.get().colors.accent;
9868 let muted = state.theme_manager.get().colors.muted;
9869 for (i, item) in sugg
9870 .items
9871 .iter()
9872 .take(state.autocomplete_max_visible)
9873 .enumerate()
9874 {
9875 let prefix = if i == 0 { "→ " } else { " " };
9876 let label = item.display_text();
9877 let line = if i == 0 {
9878 format!(
9879 "{prefix}{} {}",
9880 accent.fg(label),
9881 muted.fg(item.description.as_deref().unwrap_or(""))
9882 )
9883 } else {
9884 format!(
9885 "{prefix}{} {}",
9886 muted.fg(label),
9887 muted.fg(item.description.as_deref().unwrap_or(""))
9888 )
9889 };
9890 state
9891 .autocomplete_container
9892 .add_child(Arc::new(Text::new(line, 1, 0)));
9893 }
9894}
9895
9896fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9900 let text = editor.get_text();
9901 let cursor = editor_cursor_offset(editor, &text);
9902 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9903 return false;
9904 };
9905 let Some(top) = sugg.items.first() else {
9906 return false;
9907 };
9908 let start = sugg.start.min(text.len());
9912 let end = sugg.end.min(text.len());
9913 let mut replaced = String::with_capacity(text.len() + top.text.len());
9914 replaced.push_str(&text[..start]);
9915 replaced.push_str(&top.text);
9916 replaced.push_str(&text[end..]);
9919 if top.insert_space && !replaced.ends_with('/') {
9920 replaced.push(' ');
9921 }
9922 let new_cursor = replaced.len().min(
9925 start
9926 + top.text.len()
9927 + if top.insert_space && !top.text.ends_with('/') {
9928 1
9929 } else {
9930 0
9931 },
9932 );
9933 editor.set_text(&replaced);
9934 set_editor_cursor_offset(editor, &replaced, new_cursor);
9935 state.autocomplete_container.clear();
9936 true
9937}
9938
9939fn add_welcome_message(container: &Arc<Container>) {
9945 add_welcome_message_with_capabilities(container, &[], &[]);
9946}
9947
9948fn add_welcome_message_with_capabilities(
9952 container: &Arc<Container>,
9953 active_tools: &[String],
9954 skills: &[String],
9955) {
9956 let c = current_theme().colors;
9957 let logo = format!(
9959 "{}\n{}\n{}\n{}",
9960 c.accent.fg(" 🦀"),
9961 c.accent.fg(" ╭─π─╮"),
9962 c.accent.fg(" │") + &c.accent.fg(&tui_bold("rpi")) + &c.accent.fg("│"),
9963 c.accent.fg(" ╰───╯")
9964 );
9965 container.add_child(Arc::new(Text::new(logo, 1, 0)));
9966 container.add_child(Arc::new(Spacer::new(1)));
9967 let title = format!(
9971 "{} {}",
9972 c.accent.fg(&tui_bold("rpi")),
9973 c.muted.fg("interactive TUI")
9974 );
9975 container.add_child(Arc::new(Text::new(title, 1, 0)));
9976 container.add_child(Arc::new(Spacer::new(1)));
9977 container.add_child(Arc::new(Text::new(
9978 c.dim.fg("Type your message and press Enter to send."),
9979 1,
9980 0,
9981 )));
9982 let hint = c
9983 .dim
9984 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
9985 container.add_child(Arc::new(Text::new(hint, 1, 0)));
9986 container.add_child(Arc::new(Spacer::new(1)));
9987 container.add_child(Arc::new(Text::new(
9988 welcome_capability_line("Tools", active_tools),
9989 1,
9990 0,
9991 )));
9992 container.add_child(Arc::new(Text::new(
9993 welcome_capability_line("Skills", skills),
9994 1,
9995 0,
9996 )));
9997 container.add_child(Arc::new(DynamicBorder::new()));
9998}
9999
10000fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
10003 let colors = current_theme().colors;
10004 let group = Arc::new(Container::new());
10005
10006 for warning in &report.warnings {
10007 let body = format!(
10008 "{}\n{} {}{}",
10009 colors.error.fg(&warning.message),
10010 colors.muted.fg("Run"),
10011 colors.accent.fg(&warning.command),
10012 colors.muted.fg(" to retry.")
10013 );
10014 add_update_panel(&group, "Update Failed", &body, colors.error);
10015 }
10016
10017 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
10018 let body = format!(
10019 "{} {}{}",
10020 colors
10021 .muted
10022 .fg(&format!("New version {} is available. Run", notice.latest)),
10023 colors.accent.fg(¬ice.command),
10024 colors.muted.fg(".")
10025 );
10026 add_update_panel(&group, "Update Available", &body, colors.warning);
10027 }
10028
10029 let package_notices = report
10030 .notices
10031 .iter()
10032 .filter(|notice| notice.name != "rpi")
10033 .collect::<Vec<_>>();
10034 if !package_notices.is_empty() {
10035 let command = package_notices[0].command.as_str();
10036 let mut lines = vec![format!(
10037 "{} {}{}",
10038 colors.muted.fg("Package updates are available. Run"),
10039 colors.accent.fg(command),
10040 colors.muted.fg(".")
10041 )];
10042 lines.push(colors.muted.fg("Packages:"));
10043 lines.extend(
10044 package_notices
10045 .into_iter()
10046 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
10047 );
10048 add_update_panel(
10049 &group,
10050 "Package Updates Available",
10051 &lines.join("\n"),
10052 colors.warning,
10053 );
10054 }
10055
10056 if group.child_count() > 0 {
10060 container.add_child(group);
10061 }
10062}
10063
10064fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
10065 container.add_child(Arc::new(Spacer::new(1)));
10066 container.add_child(Arc::new(DynamicBorder::with_color(color)));
10067 container.add_child(Arc::new(Text::new(
10068 format!("{}\n{body}", color.fg(&tui_bold(title))),
10069 1,
10070 0,
10071 )));
10072 container.add_child(Arc::new(DynamicBorder::with_color(color)));
10073}
10074
10075fn welcome_capability_line(label: &str, names: &[String]) -> String {
10076 let c = current_theme().colors;
10077 let value = if names.is_empty() {
10078 "none".to_string()
10079 } else {
10080 names.join(" · ")
10081 };
10082 format!(
10083 "{} {}",
10084 c.accent.fg(&format!("{label} ({})", names.len())),
10085 c.muted.fg(&value)
10086 )
10087}
10088
10089fn add_help_message(container: &Arc<Container>) {
10091 let c = current_theme().colors;
10092 container.add_child(Arc::new(Text::new(
10096 c.md_heading.fg(&tui_bold("📚 Available Commands")),
10097 1,
10098 0,
10099 )));
10100 container.add_child(Arc::new(Spacer::new(1)));
10101
10102 let cmds: &[(&str, &str)] = &[
10103 ("/help, /?", "Show this help message"),
10104 ("/clear, /new", "Clear the conversation"),
10105 ("/exit, /quit, /q", "Exit the application"),
10106 ("/version, /v", "Show version information"),
10107 ("/changelog", "Show recent release changes"),
10108 ("/model, /m", "Choose a model (live switch)"),
10109 ("/thinking, /think", "Set reasoning depth (selector)"),
10110 ("/tools", "Toggle built-in tools on/off"),
10111 ("/images", "Toggle inline image rendering"),
10112 ("/session", "List saved sessions"),
10113 ("/theme", "Choose a theme (selector)"),
10114 ("/compact", "Compact the conversation"),
10115 ("/copy", "Copy last reply to clipboard"),
10116 ("/hotkeys", "Show keyboard shortcuts"),
10117 ("/armin", "🐾 Easter egg"),
10118 ("/earendil", "Earendil announcement"),
10119 ];
10120 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10121 for (cmd, desc) in cmds {
10122 let row = format!(
10123 " {:<cmd_w$} {} {}",
10124 c.accent.fg(cmd),
10125 c.dim.fg("—"),
10126 c.muted.fg(desc)
10127 );
10128 container.add_child(Arc::new(Text::new(row, 1, 0)));
10129 }
10130 container.add_child(Arc::new(Spacer::new(1)));
10131}
10132
10133fn add_version_message(container: &Arc<Container>) {
10135 let c = current_theme().colors;
10136 container.add_child(Arc::new(Text::new(
10137 c.md_heading.fg(&tui_bold("📦 Version Information")),
10138 1,
10139 0,
10140 )));
10141 container.add_child(Arc::new(Spacer::new(1)));
10142 container.add_child(Arc::new(Text::new(
10145 format!(
10146 " {} {}",
10147 c.muted.fg("rpi-cli"),
10148 c.text.fg(&format!("v{}", crate::VERSION))
10149 ),
10150 1,
10151 0,
10152 )));
10153 container.add_child(Arc::new(Text::new(
10154 format!(
10155 " {}",
10156 c.dim.fg("Rust implementation of pi coding agent TUI")
10157 ),
10158 1,
10159 0,
10160 )));
10161 container.add_child(Arc::new(Spacer::new(1)));
10162}
10163
10164fn add_changelog_message(container: &Arc<Container>) {
10168 let c = current_theme().colors;
10169 container.add_child(Arc::new(Text::new(
10170 c.md_heading.fg(&tui_bold("Recent Changes")),
10171 1,
10172 0,
10173 )));
10174 container.add_child(Arc::new(Spacer::new(1)));
10175 let entries = [
10176 (
10177 "Native parity phase 1",
10178 "models, images, trust, export, and JSON events",
10179 ),
10180 (
10181 "TUI controls",
10182 "external editor, thinking levels, and tool output toggles",
10183 ),
10184 (
10185 "Provider auth",
10186 "OpenAI-compatible API key aliases and gateway headers",
10187 ),
10188 ];
10189 for (release, summary) in entries {
10190 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
10191 container.add_child(Arc::new(Text::new(row, 1, 0)));
10192 }
10193 container.add_child(Arc::new(Text::new(
10194 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10195 1,
10196 0,
10197 )));
10198 container.add_child(Arc::new(Spacer::new(1)));
10199}
10200
10201fn add_hotkeys_message(container: &Arc<Container>) {
10203 let c = current_theme().colors;
10204 container.add_child(Arc::new(Text::new(
10205 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
10206 1,
10207 0,
10208 )));
10209 container.add_child(Arc::new(Spacer::new(1)));
10210 let keys: &[(&str, &str)] = &[
10211 ("Enter", "Send message"),
10212 ("Shift+Enter", "New line"),
10213 ("Tab", "Accept autocomplete suggestion"),
10214 ("Ctrl+A / Ctrl+E", "Line start / end"),
10215 (
10216 "Ctrl+K / Ctrl+U",
10217 "Kill to end / start of line (Ctrl+Y yanks)",
10218 ),
10219 ("Ctrl+- / Ctrl+R", "Undo / redo"),
10220 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10221 ("Alt+Backspace", "Kill previous word"),
10222 ("Ctrl+C", "Abort a run, or exit when idle"),
10223 ("Esc", "Abort a running prompt"),
10224 ("Ctrl+L", "Open model selector"),
10225 ("Ctrl+M", "Cycle to the next model (live)"),
10226 ("Ctrl+O", "Expand/collapse all tool output"),
10227 ("Ctrl+T", "Show/hide reasoning blocks"),
10228 ("PageUp/Down", "Scroll transcript by one page"),
10229 ("Home / End", "Jump to transcript start / latest output"),
10230 ];
10231 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10232 for (key, desc) in keys {
10233 let row = format!(
10234 " {:<key_w$} {} {}",
10235 c.accent.fg(key),
10236 c.dim.fg("—"),
10237 c.muted.fg(desc)
10238 );
10239 container.add_child(Arc::new(Text::new(row, 1, 0)));
10240 }
10241 container.add_child(Arc::new(Spacer::new(1)));
10242}
10243
10244fn add_user_message(container: &Arc<Container>, text: &str) {
10250 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10251 container.add_child(Arc::new(Spacer::new(1)));
10252}
10253
10254fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10257 let width = 80; chat_container.render(width)
10259}
10260
10261fn format_resume_command(
10266 session_id: &str,
10267 cwd: &std::path::Path,
10268 session_dir: Option<&std::path::Path>,
10269) -> Option<String> {
10270 if session_id.is_empty() {
10271 return None;
10272 }
10273 let mut args = vec![crate::APP_NAME.to_string()];
10274 if let Some(dir) = session_dir {
10275 let default = crate::session::default_session_dir(cwd);
10276 if dir != default.as_path() {
10277 args.push("--session-dir".to_string());
10278 args.push(quote_shell_arg(&dir.to_string_lossy()));
10279 }
10280 }
10281 args.push("--session".to_string());
10282 args.push(session_id.to_string());
10283 Some(args.join(" "))
10284}
10285
10286fn quote_shell_arg(value: &str) -> String {
10289 let needs_quoting = value.is_empty()
10290 || value
10291 .chars()
10292 .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10293 if needs_quoting {
10294 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10295 } else {
10296 value.to_string()
10297 }
10298}
10299
10300fn add_error_message(container: &Arc<Container>, text: &str) {
10301 let c = current_theme().colors;
10302 let text = sanitize_error_message(text);
10303 container.add_child(Arc::new(Text::new(
10304 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
10305 1,
10306 0,
10307 )));
10308 container.add_child(Arc::new(Spacer::new(1)));
10309}
10310
10311fn sanitize_error_message(text: &str) -> String {
10316 const MAX_ERROR_CHARS: usize = 16 * 1024;
10317 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10318 let mut count = 0;
10319 let mut escape_mode = 0u8;
10321 for ch in text.chars() {
10322 if escape_mode != 0 {
10323 match escape_mode {
10324 1 if ch == '[' => escape_mode = 2,
10325 1 if ch == ']' => escape_mode = 3,
10326 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10327 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10328 3 if ch == '\x07' => escape_mode = 0,
10329 _ => {}
10330 }
10331 continue;
10332 }
10333 if ch == '\x1b' {
10334 escape_mode = 1;
10335 continue;
10336 }
10337 if count >= MAX_ERROR_CHARS {
10338 result.push_str("…");
10339 break;
10340 }
10341 match ch {
10342 '\n' | '\t' => {
10343 result.push(ch);
10344 count += 1;
10345 }
10346 '\r' => {}
10347 c if c.is_control() => {}
10348 c => {
10349 result.push(c);
10350 count += 1;
10351 }
10352 }
10353 }
10354 if result.trim().is_empty() {
10355 "Provider request failed.".to_string()
10356 } else {
10357 result
10358 }
10359}
10360
10361fn add_note_message(container: &Arc<Container>, text: &str) {
10363 let c = current_theme().colors;
10364 container.add_child(Arc::new(Text::new(
10365 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10366 1,
10367 0,
10368 )));
10369 container.add_child(Arc::new(Spacer::new(1)));
10370}
10371
10372fn show_context_panel(
10381 chat: &Arc<Container>,
10382 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10383) {
10384 let skills = resources.skills.as_deref().unwrap_or(&[]);
10385 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10386 let mut lines: Vec<String> = Vec::new();
10387 lines.push("📂 Discovered resources for this session:".into());
10388
10389 if skills.is_empty() {
10390 lines.push(
10391 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10392 );
10393 } else {
10394 lines.push(format!(" Skills ({}):", skills.len()));
10395 for s in skills {
10396 let marker = if s.disable_model_invocation == Some(true) {
10397 " [hidden]"
10398 } else {
10399 ""
10400 };
10401 let desc: String = s.description.chars().take(72).collect();
10402 lines.push(format!(" • {}{marker} — {desc}", s.name));
10403 }
10404 }
10405
10406 if templates.is_empty() {
10407 lines.push(
10408 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10409 );
10410 } else {
10411 lines.push(format!(" Prompt templates ({}):", templates.len()));
10412 for t in templates {
10413 let desc = t
10414 .description
10415 .as_deref()
10416 .unwrap_or("(no description)")
10417 .chars()
10418 .take(72)
10419 .collect::<String>();
10420 lines.push(format!(" • /{} — {desc}", t.name));
10421 }
10422 }
10423 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10424 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10425 lines.push(
10426 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10427 .into(),
10428 );
10429 let body = lines.join("\n");
10430 container_note_block(chat, &body);
10431}
10432
10433fn container_note_block(container: &Arc<Container>, body: &str) {
10435 for line in body.lines() {
10436 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10437 }
10438 container.add_child(Arc::new(Spacer::new(1)));
10439}
10440
10441pub fn is_tui_supported() -> bool {
10447 std::io::stdout().is_terminal()
10448}
10449
10450#[allow(unused_imports)]
10452use rpi_tui::Color as _Color;
10453
10454#[cfg(test)]
10455mod tests {
10456 use super::*;
10457 use rpi_tui::Component;
10458
10459 fn test_tui_state() -> Arc<TuiState> {
10462 Arc::new(TuiState {
10463 current_assistant: std::sync::Mutex::new(None),
10464 tool_components: std::sync::Mutex::new(HashMap::new()),
10465 bash_components: std::sync::Mutex::new(HashMap::new()),
10466 themes_enabled: true,
10467 hide_thinking: std::sync::Mutex::new(false),
10468 tool_outputs_expanded: std::sync::Mutex::new(false),
10469 show_terminal_progress: true,
10470 status: std::sync::Mutex::new(RunStatus::Idle),
10471 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10472 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10473 js_preparation_cancel: std::sync::Mutex::new(None),
10474 user_bash_cancel: std::sync::Mutex::new(None),
10475 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10476 footer: Arc::new(FooterComponent::new()),
10477 status_container: Arc::new(Container::new()),
10478 chat_container: Arc::new(Container::new()),
10479 loader: Arc::new(Loader::new()),
10480 editor: Arc::new(Editor::simple()),
10481 last_assistant_text: std::sync::Mutex::new(String::new()),
10482 active_selector: std::sync::Mutex::new(None),
10483 active_extension_editor: std::sync::Mutex::new(None),
10484 active_extension_input: std::sync::Mutex::new(None),
10485 active_extension_cancel: std::sync::Mutex::new(None),
10486 autocomplete: AutocompleteManager::new(),
10487 autocomplete_container: Arc::new(Container::new()),
10488 autocomplete_max_visible: 5,
10489 pending_images: std::sync::Mutex::new(Vec::new()),
10490 theme_manager: Arc::new(ThemeManager::new()),
10491 tui: None,
10492 current_model_id: std::sync::Mutex::new(String::new()),
10493 show_images: std::sync::Mutex::new(true),
10494 cache_miss_notices: std::sync::Mutex::new(false),
10495 history: std::sync::Mutex::new(Vec::new()),
10496 history_index: std::sync::Mutex::new(-1),
10497 history_draft: std::sync::Mutex::new(None),
10498 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10499 scoped_edit: std::sync::Mutex::new(None),
10500 markdown_transformer: std::sync::Mutex::new(None),
10501 extension_session: Arc::new(std::sync::Mutex::new(
10502 rpi_extensions::ExtensionSession::none(),
10503 )),
10504 search: Arc::new(AltScreenSearch::new()),
10505 search_bar: Arc::new(SearchBar::new()),
10506 pending_container: Arc::new(Container::new()),
10507 dequeue_hint: String::new(),
10508 pending_snapshot: std::sync::Mutex::new(
10509 rpi_harness::agent_harness::QueuedMessages::default(),
10510 ),
10511 selection_start: std::sync::Mutex::new(None),
10512 selection_end: std::sync::Mutex::new(None),
10513 })
10514 }
10515
10516 #[test]
10517 fn live_panel_repaints_while_a_tool_or_bash_is_running() {
10518 let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
10521 assert!(!transcript_has_live_panel(false, &empty));
10522
10523 let mut tools = HashMap::new();
10526 let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
10527 tool.set_running();
10528 tools.insert("tc1".to_string(), tool);
10529 assert!(transcript_has_live_panel(false, &tools));
10530
10531 tools.get("tc1").unwrap().set_result("done", false);
10534 assert!(!transcript_has_live_panel(false, &tools));
10535 assert!(transcript_has_live_panel(true, &tools));
10536 }
10537
10538 #[test]
10539 fn dequeue_merge_preserves_a_typed_draft() {
10540 assert_eq!(merge_queued_into_draft("queued", ""), "queued");
10541 assert_eq!(merge_queued_into_draft("queued", " "), "queued");
10542 assert_eq!(
10543 merge_queued_into_draft("queued", "typed"),
10544 "queued\n\ntyped"
10545 );
10546 }
10547
10548 #[test]
10549 fn settings_menu_covers_the_wired_settings_surface() {
10550 let state = test_tui_state();
10551 let settings = crate::settings::Settings::default();
10552 let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10553 let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10554 assert_eq!(
10555 keys,
10556 [
10557 "theme",
10558 "model",
10559 "thinking",
10560 "scoped-models",
10561 "hide-thinking",
10562 "show-images",
10563 "cache-miss-notices",
10564 "quiet-startup",
10565 "terminal-progress",
10566 "fullscreen-copy-on-select",
10567 "double-escape-action",
10568 "editor-padding",
10569 "autocomplete-max-items",
10570 "http-idle-timeout",
10571 ]
10572 );
10573 for item in items.iter().filter(|item| item.has_submenu) {
10575 assert!(
10576 item.values.is_empty(),
10577 "{} should be submenu-only",
10578 item.key
10579 );
10580 }
10581 for item in items.iter().filter(|item| !item.has_submenu) {
10583 assert!(!item.values.is_empty(), "{} has no values", item.key);
10584 assert!(
10585 item.values.iter().any(|value| value == &item.value),
10586 "{} current value {} not in {:?}",
10587 item.key,
10588 item.value,
10589 item.values
10590 );
10591 }
10592 assert_eq!(items[1].value, "claude-sonnet-5");
10594 assert_eq!(items[6].value, "false");
10596 assert_eq!(items[8].value, "true");
10597 }
10598
10599 #[test]
10600 fn apply_setting_change_updates_settings_and_live_state() {
10601 let state = test_tui_state();
10602 let mut settings = crate::settings::Settings::default();
10603
10604 let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10606 assert!(state.hide_thinking());
10607 assert_eq!(settings.hide_thinking_block, Some(true));
10608 assert!(note.expect("user-visible note").contains("hidden"));
10609
10610 apply_setting_change(&state, "show-images", "false", &mut settings);
10612 assert!(!*state.show_images.lock().unwrap());
10613 assert_eq!(settings.show_images, Some(false));
10614
10615 apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10617 assert!(*state.cache_miss_notices.lock().unwrap());
10618 assert_eq!(settings.show_cache_miss_notices, Some(true));
10619
10620 assert_eq!(
10622 apply_setting_change(&state, "editor-padding", "3", &mut settings),
10623 None
10624 );
10625 assert_eq!(settings.editor_padding_x, Some(3));
10626 apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10627 assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10628 apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10629 assert_eq!(settings.autocomplete_max_visible, Some(20));
10630
10631 apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10633 assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10634 apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10635 assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10636
10637 assert_eq!(
10639 apply_setting_change(&state, "nope", "x", &mut settings),
10640 None
10641 );
10642 }
10643
10644 #[test]
10645 fn add_user_message_renders_a_visible_user_bubble() {
10646 let chat = Arc::new(Container::new());
10650 add_user_message(&chat, "hello from the user");
10651 let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10652 let plain = strip_ansi(&rendered.join("\n"));
10653 assert!(
10654 plain.contains("hello from the user"),
10655 "user bubble must show the prompt text; got: {plain:?}"
10656 );
10657 }
10658
10659 #[test]
10660 fn verbose_overrides_quiet_startup_listing() {
10661 assert!(should_show_startup_listing(false, false));
10662 assert!(should_show_startup_listing(true, false));
10663 assert!(should_show_startup_listing(true, true));
10664 assert!(!should_show_startup_listing(false, true));
10665 }
10666
10667 #[test]
10668 fn resume_command_omits_the_default_session_dir() {
10669 let cwd = std::path::Path::new("/proj/demo");
10670 assert_eq!(format_resume_command("", cwd, None), None);
10671 assert_eq!(
10672 format_resume_command("abc-123", cwd, None).as_deref(),
10673 Some("rpi --session abc-123")
10674 );
10675 let default = crate::session::default_session_dir(cwd);
10677 assert_eq!(
10678 format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10679 Some("rpi --session abc-123")
10680 );
10681 let custom = std::path::Path::new("/tmp/my sessions");
10683 assert_eq!(
10684 format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
10685 Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
10686 );
10687 }
10688
10689 #[test]
10690 fn quote_shell_arg_quotes_only_when_needed() {
10691 assert_eq!(quote_shell_arg("plain"), "plain");
10692 assert_eq!(quote_shell_arg("with space"), "\"with space\"");
10693 assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
10694 }
10695
10696 #[test]
10697 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
10698 let tmp = tempfile::tempdir().unwrap();
10699 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
10700 let local_root = tmp.path().join("local-package");
10701 std::fs::create_dir_all(git_root.join(".git")).unwrap();
10702 std::fs::create_dir_all(&local_root).unwrap();
10703 std::fs::write(
10704 git_root.join("package.json"),
10705 r#"{"name":"git-demo","version":"1.0.0"}"#,
10706 )
10707 .unwrap();
10708 std::fs::write(
10709 local_root.join("package.json"),
10710 r#"{"name":"local-demo","version":"1.0.0"}"#,
10711 )
10712 .unwrap();
10713 let resources = crate::packages::discover(
10714 tmp.path(),
10715 &[
10716 "git:github.com/example/repo".to_string(),
10717 local_root.to_string_lossy().into_owned(),
10718 ],
10719 );
10720 assert_eq!(resources.packages.len(), 2);
10721
10722 let filtered = git_only_update_resources(resources);
10723
10724 assert_eq!(filtered.packages.len(), 1);
10725 assert!(matches!(
10726 filtered.packages[0].source,
10727 crate::packages::PackageSource::Git
10728 ));
10729 }
10730
10731 #[test]
10732 fn trust_command_uses_the_session_cwd_after_process_chdir() {
10733 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
10734 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
10735 const TEST_NAME: &str =
10736 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
10737
10738 if std::env::var_os(CHILD_ENV).is_some() {
10739 let session_cwd = std::path::PathBuf::from(
10740 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
10741 );
10742 let changed_cwd = std::env::current_dir().unwrap();
10743
10744 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
10745
10746 assert_eq!(
10747 crate::config::project_trust_decision(&session_cwd).unwrap(),
10748 Some(true)
10749 );
10750 assert_eq!(
10751 crate::config::project_trust_decision(&changed_cwd).unwrap(),
10752 None
10753 );
10754 return;
10755 }
10756
10757 let tmp = tempfile::tempdir().unwrap();
10758 let session_cwd = tmp.path().join("session-project");
10759 let changed_cwd = tmp.path().join("extension-cwd");
10760 let agent_dir = tmp.path().join("agent");
10761 std::fs::create_dir_all(&session_cwd).unwrap();
10762 std::fs::create_dir_all(&changed_cwd).unwrap();
10763 std::fs::create_dir_all(&agent_dir).unwrap();
10764
10765 let status = std::process::Command::new(std::env::current_exe().unwrap())
10766 .arg("--exact")
10767 .arg(TEST_NAME)
10768 .arg("--nocapture")
10769 .env(CHILD_ENV, "1")
10770 .env(SESSION_CWD_ENV, &session_cwd)
10771 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
10772 .current_dir(&changed_cwd)
10773 .status()
10774 .unwrap();
10775
10776 assert!(status.success(), "child test process failed: {status}");
10777 }
10778
10779 #[test]
10780 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
10781 let global = crate::settings::Settings {
10782 editor_padding_x: Some(3),
10783 autocomplete_max_visible: Some(6),
10784 hide_thinking_block: Some(false),
10785 quiet_startup: Some(true),
10786 show_terminal_progress: Some(true),
10787 ..Default::default()
10788 };
10789 let rpi_project = crate::settings::Settings {
10790 editor_padding_x: Some(0),
10791 hide_thinking_block: Some(true),
10792 quiet_startup: Some(false),
10793 show_terminal_progress: Some(false),
10794 ..Default::default()
10795 };
10796 let pi_project = crate::settings::Settings {
10797 editor_padding_x: Some(9),
10798 autocomplete_max_visible: Some(12),
10799 quiet_startup: Some(true),
10800 ..Default::default()
10801 };
10802
10803 assert_eq!(
10804 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
10805 TuiStartupSettings {
10806 editor_padding_x: 0,
10807 autocomplete_max_visible: 12,
10808 hide_thinking: true,
10809 quiet_startup: false,
10810 show_terminal_progress: false,
10811 show_images: true,
10812 cache_miss_notices: false,
10813 }
10814 );
10815 }
10816
10817 #[test]
10818 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
10819 let global = crate::settings::Settings {
10820 quiet_startup: Some(false),
10821 ..Default::default()
10822 };
10823 let rpi_project = crate::settings::Settings::default();
10824 let pi_project = crate::settings::Settings {
10825 quiet_startup: Some(true),
10826 ..Default::default()
10827 };
10828
10829 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
10830
10831 assert!(resolved.quiet_startup);
10832 }
10833
10834 #[test]
10835 fn tui_startup_settings_use_global_values_without_project_fields() {
10836 let global = crate::settings::Settings {
10837 editor_padding_x: Some(7),
10838 autocomplete_max_visible: Some(8),
10839 hide_thinking_block: Some(true),
10840 quiet_startup: Some(true),
10841 show_terminal_progress: Some(false),
10842 show_images: Some(false),
10843 show_cache_miss_notices: Some(true),
10844 ..Default::default()
10845 };
10846
10847 assert_eq!(
10848 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
10849 TuiStartupSettings {
10850 editor_padding_x: 7,
10851 autocomplete_max_visible: 8,
10852 hide_thinking: true,
10853 quiet_startup: true,
10854 show_terminal_progress: false,
10855 show_images: false,
10856 cache_miss_notices: true,
10857 }
10858 );
10859 }
10860
10861 #[test]
10862 fn tui_startup_settings_read_native_nested_terminal_block() {
10863 let global = crate::settings::Settings {
10866 terminal: Some(crate::settings::TerminalSettings {
10867 show_images: Some(false),
10868 show_terminal_progress: Some(false),
10869 clear_on_shrink: None,
10870 }),
10871 ..Default::default()
10872 };
10873 let resolved =
10874 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10875 assert!(!resolved.show_images);
10876 assert!(!resolved.show_terminal_progress);
10877 }
10878
10879 #[test]
10880 fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
10881 let global = crate::settings::Settings {
10882 show_images: Some(true),
10883 terminal: Some(crate::settings::TerminalSettings {
10884 show_images: Some(false),
10885 show_terminal_progress: None,
10886 clear_on_shrink: None,
10887 }),
10888 ..Default::default()
10889 };
10890 let resolved =
10891 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10892 assert!(resolved.show_images);
10893 }
10894
10895 #[test]
10896 fn tui_startup_settings_ignore_untrusted_project_values() {
10897 let global = crate::settings::Settings {
10898 quiet_startup: Some(false),
10899 show_terminal_progress: Some(true),
10900 ..Default::default()
10901 };
10902 let project = crate::settings::Settings {
10903 quiet_startup: Some(true),
10904 show_terminal_progress: Some(false),
10905 ..Default::default()
10906 };
10907
10908 let resolved = resolve_tui_startup_settings(&global, &[project], false);
10909
10910 assert!(!resolved.quiet_startup);
10911 assert!(resolved.show_terminal_progress);
10912 }
10913
10914 #[test]
10915 fn transcript_page_uses_viewport_with_overlap() {
10916 assert_eq!(transcript_page_size(24), 20);
10917 assert_eq!(transcript_page_size(4), 1);
10918 assert_eq!(transcript_page_size(0), 1);
10919 }
10920
10921 #[test]
10922 fn parse_user_bash_distinguishes_single_and_double_bang() {
10923 assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10925 assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10927 assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
10929 assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
10930 }
10931
10932 #[test]
10933 fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10934 assert_eq!(parse_user_bash("!"), None);
10937 assert_eq!(parse_user_bash("!!"), None);
10938 assert_eq!(parse_user_bash("! "), None);
10939 assert_eq!(parse_user_bash("hello"), None);
10940 assert_eq!(parse_user_bash(""), None);
10941 assert_eq!(parse_user_bash("/model"), None);
10943 }
10944
10945 #[test]
10946 fn user_bash_run_slot_guards_and_cancels() {
10947 let state = test_tui_state();
10948 assert!(!state.user_bash_running());
10949 let cancel = state.begin_user_bash();
10950 assert!(state.user_bash_running());
10951 assert!(state.cancel_user_bash());
10954 assert!(state.user_bash_running());
10955 assert!(cancel.is_cancelled());
10956 state.finish_user_bash();
10957 assert!(!state.user_bash_running());
10958 assert!(!state.cancel_user_bash());
10960 }
10961
10962 #[test]
10963 fn key_repeat_is_dispatched_but_release_is_not() {
10964 assert!(should_dispatch_key(KeyEventKind::Press));
10965 assert!(should_dispatch_key(KeyEventKind::Repeat));
10966 assert!(!should_dispatch_key(KeyEventKind::Release));
10967 }
10968
10969 #[test]
10970 fn key_event_encoding_matches_pi_keybinding_protocol() {
10971 let key = |code, modifiers| KeyEvent::new(code, modifiers);
10972 assert_eq!(
10973 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
10974 "\r"
10975 );
10976 assert_eq!(
10977 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
10978 "\x1b[13;2u"
10979 );
10980 assert_eq!(
10981 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
10982 "\x1b[9;2u"
10983 );
10984 assert_eq!(
10985 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
10986 "\x1b[Z"
10987 );
10988 assert_eq!(
10989 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
10990 "\x1b[Z"
10991 );
10992 assert_eq!(
10993 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
10994 "\x03"
10995 );
10996 assert_eq!(
10997 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
10998 "\x0f"
10999 );
11000 assert_eq!(
11001 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
11002 "!"
11003 );
11004 assert_eq!(
11005 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
11006 "1"
11007 );
11008 }
11009
11010 #[test]
11011 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
11012 let bridge = JsDialogBridge::default();
11013 let (sender, receiver) = std_mpsc::channel();
11014 bridge.pending.lock().unwrap().push_back(JsDialogPending {
11015 request: JsDialogRequest {
11016 id: "dialog-1".into(),
11017 method: "input".into(),
11018 title: String::new(),
11019 message: String::new(),
11020 options: Vec::new(),
11021 placeholder: None,
11022 prefill: None,
11023 },
11024 result: sender,
11025 });
11026
11027 bridge.cancel("dialog-1");
11030 assert!(bridge.take_pending().is_none());
11031 assert_eq!(
11032 receiver.recv().unwrap(),
11033 serde_json::json!({ "cancelled": true })
11034 );
11035 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
11036 }
11037
11038 #[test]
11039 fn test_layout_renders_welcome_message() {
11040 let chat = Arc::new(Container::new());
11041 add_welcome_message(&chat);
11042
11043 let scroll = Arc::new(ScrollView::new(
11044 chat.clone(),
11045 ScrollViewOptions {
11046 follow: FollowMode::End,
11047 primary: true,
11048 ..Default::default()
11049 },
11050 ));
11051
11052 let editor = Arc::new(Editor::new(
11053 EditorOptions {
11054 padding_x: 1,
11055 ..Default::default()
11056 },
11057 EditorStyle::default(),
11058 Arc::new(rpi_tui::Keybindings::new()),
11059 ));
11060 let dock = Arc::new(Container::new());
11061 dock.add_child(editor);
11062
11063 let footer = Arc::new(FooterComponent::new());
11064
11065 let root = VStack::from_children(vec![
11066 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
11067 StackChild::Entry(StackEntry::new(dock)),
11068 StackChild::Entry(StackEntry::new(footer)),
11069 ]);
11070
11071 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
11072
11073 let all: String = frame.lines.join("\n");
11074 assert!(
11075 all.contains("rpi"),
11076 "Welcome message not found. Rendered: {}",
11077 all
11078 );
11079 assert!(
11080 all.contains("Type your message"),
11081 "Help text not found. Rendered: {}",
11082 all
11083 );
11084 }
11085
11086 #[test]
11087 fn test_chat_container_has_welcome_content() {
11088 let chat = Arc::new(Container::new());
11089 add_welcome_message_with_capabilities(
11090 &chat,
11091 &["read".into(), "bash".into(), "web_fetch".into()],
11092 &["rust-review".into(), "release".into()],
11093 );
11094
11095 let lines = chat.render(80);
11096 let all: String = lines.join("\n");
11097 let plain = strip_ansi(&all);
11100 assert!(
11101 plain.contains("rpi"),
11102 "Welcome message not in chat container: {:?}",
11103 lines
11104 );
11105 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
11106 assert!(
11107 plain.contains("read · bash · web_fetch"),
11108 "Tool names missing: {plain}"
11109 );
11110 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
11111 assert!(
11112 plain.contains("rust-review · release"),
11113 "Skill names missing: {plain}"
11114 );
11115 }
11116
11117 #[test]
11118 fn update_notices_render_inside_the_transcript() {
11119 let chat = Arc::new(Container::new());
11120 let report = crate::updates::UpdateReport {
11121 notices: vec![
11122 crate::updates::UpdateNotice {
11123 name: "rpi".into(),
11124 current: "0.1.10".into(),
11125 latest: "0.1.11".into(),
11126 command: "rpi update".into(),
11127 },
11128 crate::updates::UpdateNotice {
11129 name: "rpi-search".into(),
11130 current: "0.1.0".into(),
11131 latest: "0.1.1".into(),
11132 command: "rpi pi-package update".into(),
11133 },
11134 ],
11135 warnings: vec![crate::updates::UpdateWarning {
11136 message: "The previously scheduled rpi update failed: access denied".into(),
11137 command: "rpi update".into(),
11138 }],
11139 };
11140
11141 add_update_notices(&chat, &report);
11142
11143 assert_eq!(chat.child_count(), 1);
11144 let plain = strip_ansi(&chat.render(80).join("\n"));
11145 assert!(plain.contains("Update Failed"), "{plain}");
11146 assert!(
11147 plain.contains("rpi update failed: access denied"),
11148 "{plain}"
11149 );
11150 assert!(plain.contains("Update Available"), "{plain}");
11151 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11152 assert!(plain.contains("rpi update"), "{plain}");
11153 assert!(plain.contains("Package Updates Available"), "{plain}");
11154 assert!(plain.contains("rpi pi-package update"), "{plain}");
11155 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11156 }
11157
11158 #[test]
11159 fn empty_update_report_does_not_add_transcript_content() {
11160 let chat = Arc::new(Container::new());
11161
11162 add_update_notices(&chat, &crate::updates::UpdateReport::default());
11163
11164 assert_eq!(chat.child_count(), 0);
11165 }
11166
11167 #[test]
11168 fn skill_reads_are_detected_by_path() {
11169 let name = skill_tool_name(
11170 "read",
11171 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
11172 );
11173 assert_eq!(name.as_deref(), Some("release"));
11174
11175 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
11176 assert!(name.is_none());
11177
11178 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
11180 }
11181
11182 #[test]
11183 fn welcome_capabilities_show_empty_state() {
11184 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11185 assert_eq!(plain, "Skills (0) none");
11186 }
11187
11188 #[test]
11189 fn empty_tool_progress_is_deferred_until_start() {
11190 let empty = rpi_agent::AgentToolResult::default();
11191 assert!(!tool_update_has_payload("", &empty));
11192
11193 let text = rpi_agent::AgentToolResult::text("partial output");
11194 assert!(tool_update_has_payload("partial output", &text));
11195
11196 let details = rpi_agent::AgentToolResult {
11197 details: serde_json::json!({"path": "src/lib.rs"}),
11198 ..Default::default()
11199 };
11200 assert!(tool_update_has_payload("", &details));
11201 }
11202
11203 #[test]
11204 fn completed_stream_reconciles_the_final_tail_before_detaching() {
11205 let component = Arc::new(AssistantMessageComponent::new(
11206 AssistantMessageOptions::default(),
11207 ));
11208 component.set_streaming(true);
11209 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11210
11211 let current = Mutex::new(Some(component.clone()));
11212 let cached = Mutex::new("partial response".to_string());
11213 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11214 final_message.content = vec![Content::text(
11215 "partial response with the previously missing final tail",
11216 )];
11217 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11218
11219 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
11220
11221 assert!(current.lock().unwrap().is_none());
11222 assert_eq!(
11223 cached.lock().unwrap().as_str(),
11224 "partial response with the previously missing final tail"
11225 );
11226 let rendered = strip_ansi(&component.render(100).join("\n"));
11227 assert!(
11228 rendered.contains("previously missing final tail"),
11229 "{rendered}"
11230 );
11231 }
11232
11233 #[test]
11239 fn tab_accept_suggestion_reflects_in_next_render() {
11240 use rpi_tui::render_layout_frame;
11241
11242 let editor = Arc::new(Editor::new(
11243 EditorOptions {
11244 padding_x: 1,
11245 ..Default::default()
11246 },
11247 EditorStyle::default(),
11248 Arc::new(rpi_tui::Keybindings::new()),
11249 ));
11250 editor.set_focused(true);
11251 let editor_container = Arc::new(Container::new());
11252 editor_container.add_child(editor.clone());
11253 let autocomplete_container = Arc::new(Container::new());
11254 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11255 let dock = Arc::new(VStack::from_children(vec![
11256 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11257 StackChild::Entry(
11258 StackEntry::new(editor_container.clone())
11259 .shrink(0)
11260 .min_size(3),
11261 ),
11262 StackChild::Entry(StackEntry::new(footer)),
11263 ]));
11264
11265 let manager = AutocompleteManager::new();
11267 let mut combined = CombinedAutocompleteProvider::new();
11268 combined.add_provider(Arc::new(
11269 SlashCommandAutocompleteProvider::with_default_commands(),
11270 ));
11271 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11272 manager.set_provider(Arc::new(combined));
11273 editor.insert("/mo");
11276 assert_eq!(editor.cursor_position(), (0, 3));
11277
11278 let frame_before = render_layout_frame(dock.clone(), 80, 10);
11279 assert!(
11280 frame_before.lines.iter().any(|l| l.contains("/mo")),
11281 "precondition: editor shows the typed prefix. Frame rows:\n{}",
11282 frame_before
11283 .lines
11284 .iter()
11285 .map(|l| format!(" [{l}]"))
11286 .collect::<Vec<_>>()
11287 .join("\n")
11288 );
11289
11290 let text = editor.get_text();
11292 let cursor = editor_cursor_offset(&editor, &text);
11293 let sugg = manager
11294 .get_suggestions(&text, cursor)
11295 .expect("slash suggestions for /mo");
11296 let top = sugg.items.first().expect("at least one suggestion");
11297 let start = sugg.start.min(text.len());
11298 let end = sugg.end.min(text.len());
11299 let mut replaced = String::new();
11300 replaced.push_str(&text[..start]);
11301 replaced.push_str(&top.text);
11302 replaced.push_str(&text[end..]);
11303 if top.insert_space && !replaced.ends_with('/') {
11304 replaced.push(' ');
11305 }
11306 let new_cursor = start + top.text.len();
11307 editor.set_text(&replaced);
11308 set_editor_cursor_offset(&editor, &replaced, new_cursor);
11309 autocomplete_container.clear();
11310 assert_eq!(editor.get_text(), "/model");
11311
11312 let frame_after = render_layout_frame(dock, 80, 10);
11314 let all: String = frame_after.lines.join("\n");
11315 assert!(
11316 all.contains("/model"),
11317 "completed text missing from next render. Got:\n{all}"
11318 );
11319 let editor_line = frame_after
11323 .lines
11324 .iter()
11325 .find(|l| l.contains("/model"))
11326 .expect("editor row with completed text");
11327 assert!(
11328 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11329 "caret must follow the full completed text. Got: {editor_line:?}"
11330 );
11331 }
11332
11333 #[test]
11334 fn multiline_autocomplete_preserves_row_and_column() {
11335 let editor = Arc::new(Editor::simple());
11336 editor.set_text("first\n/mo");
11337 editor.set_cursor(1, 3);
11338
11339 let text = editor.get_text();
11340 assert_eq!(editor_cursor_offset(&editor, &text), 9);
11341
11342 let replaced = "first\n/model";
11343 editor.set_text(replaced);
11344 set_editor_cursor_offset(&editor, replaced, 12);
11345 assert_eq!(editor.cursor_position(), (1, 6));
11346 }
11347
11348 #[test]
11349 fn test_slash_command_dispatch() {
11350 let registry = build_builtin_registry();
11355
11356 let resolves_to = |token: &str, canonical: &str| {
11358 let found = registry.find(token).expect("{token} should resolve");
11359 assert_eq!(
11360 found.name(),
11361 canonical,
11362 "{token} resolved to {} (expected {canonical})",
11363 found.name()
11364 );
11365 };
11366
11367 resolves_to("/help", "/help");
11368 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
11370 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
11374 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
11376 resolves_to("/hotkeys", "/hotkeys");
11377 resolves_to("/model", "/model");
11378 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
11380 resolves_to("/session", "/session");
11381 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
11383 resolves_to("/copy", "/copy");
11384 resolves_to("/thinking", "/thinking");
11385 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
11387 resolves_to("/images", "/images");
11388 resolves_to("/armin", "/armin");
11389 resolves_to("/earendil", "/earendil");
11390 resolves_to("/context", "/context");
11391 resolves_to("/settings", "/settings");
11393 resolves_to("/name", "/name");
11394 resolves_to("/export", "/export");
11395
11396 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11398 }
11399
11400 #[test]
11401
11402 fn test_registry_visible_entries_cover_dispatch() {
11403 let registry = build_builtin_registry();
11408 let names: Vec<String> = registry
11409 .visible_entries()
11410 .iter()
11411 .map(|c| c.name.clone())
11412 .collect();
11413 for recognized in [
11414 "/help",
11415 "/clear",
11416 "/new",
11417 "/exit",
11418 "/quit",
11419 "/version",
11420 "/changelog",
11421 "/model",
11422 "/session",
11423 "/theme",
11424 "/compact",
11425 "/copy",
11426 "/hotkeys",
11427 "/tools",
11428 "/images",
11429 "/thinking",
11430 "/usage",
11431 "/armin",
11432 "/earendil",
11433 ] {
11434 assert!(
11435 names.contains(&recognized.to_string()),
11436 "{recognized} missing from autocomplete list"
11437 );
11438 }
11439 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11441 assert!(
11442 !names.contains(&hidden.to_string()),
11443 "{hidden} should be hidden from autocomplete"
11444 );
11445 }
11446 }
11447
11448 #[test]
11449 fn test_agent_event_mapping_creates_assistant_and_tool() {
11450 use rpi_ai::types::{
11453 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11454 ToolCall, ToolCallType, Usage,
11455 };
11456
11457 let state = Arc::new(TuiState {
11458 current_assistant: std::sync::Mutex::new(None),
11459 tool_components: std::sync::Mutex::new(HashMap::new()),
11460 bash_components: std::sync::Mutex::new(HashMap::new()),
11461 themes_enabled: true,
11462 hide_thinking: std::sync::Mutex::new(false),
11463 tool_outputs_expanded: std::sync::Mutex::new(false),
11464 show_terminal_progress: true,
11465 status: std::sync::Mutex::new(RunStatus::Idle),
11466 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11467 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11468 js_preparation_cancel: std::sync::Mutex::new(None),
11469 user_bash_cancel: std::sync::Mutex::new(None),
11470 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11471 footer: Arc::new(FooterComponent::new()),
11472 status_container: Arc::new(Container::new()),
11473 chat_container: Arc::new(Container::new()),
11474 loader: Arc::new(Loader::new()),
11475 editor: Arc::new(Editor::simple()),
11476 last_assistant_text: std::sync::Mutex::new(String::new()),
11477 active_selector: std::sync::Mutex::new(None),
11478 active_extension_editor: std::sync::Mutex::new(None),
11479 active_extension_input: std::sync::Mutex::new(None),
11480 active_extension_cancel: std::sync::Mutex::new(None),
11481 autocomplete: AutocompleteManager::new(),
11482 autocomplete_container: Arc::new(Container::new()),
11483 autocomplete_max_visible: 5,
11484 pending_images: std::sync::Mutex::new(Vec::new()),
11485 theme_manager: Arc::new(ThemeManager::new()),
11486 tui: None,
11487 current_model_id: std::sync::Mutex::new(String::new()),
11488 show_images: std::sync::Mutex::new(true),
11489 cache_miss_notices: std::sync::Mutex::new(false),
11490 history: std::sync::Mutex::new(Vec::new()),
11491 history_index: std::sync::Mutex::new(-1),
11492 history_draft: std::sync::Mutex::new(None),
11493 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11494 scoped_edit: std::sync::Mutex::new(None),
11495 markdown_transformer: std::sync::Mutex::new(None),
11496 extension_session: Arc::new(std::sync::Mutex::new(
11497 rpi_extensions::ExtensionSession::none(),
11498 )),
11499 search: Arc::new(AltScreenSearch::new()),
11500 search_bar: Arc::new(SearchBar::new()),
11501 pending_container: Arc::new(Container::new()),
11502 dequeue_hint: String::new(),
11503 pending_snapshot: std::sync::Mutex::new(
11504 rpi_harness::agent_harness::QueuedMessages::default(),
11505 ),
11506 selection_start: std::sync::Mutex::new(None),
11507 selection_end: std::sync::Mutex::new(None),
11508 });
11509
11510 let assistant = AssistantMessage {
11514 role: rpi_ai::types::AssistantRole,
11515 content: vec![
11516 Content::Thinking(ThinkingContent {
11517 kind: ThinkingContentType,
11518 thinking: "Reasoning about the reply.".into(),
11519 thinking_signature: None,
11520 redacted: false,
11521 }),
11522 Content::Text(TextContent {
11523 kind: TextContentType,
11524 text: "Hello.".into(),
11525 text_signature: None,
11526 }),
11527 Content::ToolCall(ToolCall {
11528 kind: ToolCallType,
11529 id: "tc1".into(),
11530 name: "bash".into(),
11531 arguments: serde_json::json!({"command": "echo hi"}),
11532 thought_signature: None,
11533 namespace: None,
11534 }),
11535 ],
11536 api: rpi_ai::Api::AnthropicMessages,
11537 provider: "anthropic".into(),
11538 model: "claude-sonnet-5".into(),
11539 response_model: None,
11540 response_id: None,
11541 usage: Usage::zero(),
11542 stop_reason: StopReason::Stop,
11543 deferred: None,
11544 error_message: None,
11545 raw_stop_reason: None,
11546 end_turn: None,
11547 timestamp: 0,
11548 };
11549
11550 let comp = Arc::new(AssistantMessageComponent::new(
11553 AssistantMessageOptions::default(),
11554 ));
11555 comp.set_streaming(true);
11556 comp.update_blocks(&assistant_blocks(&assistant));
11557 let chat = Arc::new(Container::new());
11558 chat.add_child(comp.clone());
11559 *state.current_assistant.lock().unwrap() = Some(comp);
11560
11561 for c in &assistant.content {
11563 if let Content::ToolCall(tc) = c {
11564 let mut tools = state.tool_components.lock().unwrap();
11565 if !tools.contains_key(&tc.id) {
11566 let tc_comp = Arc::new(ToolExecutionComponent::new(
11567 &tc.name,
11568 &tc.arguments.to_string(),
11569 ));
11570 tc_comp.set_running();
11571 chat.add_child(tc_comp.clone());
11572 tools.insert(tc.id.clone(), tc_comp);
11573 }
11574 }
11575 }
11576
11577 let rendered = chat.render(80);
11581 let joined: String = rendered.join("\n");
11582 assert!(
11583 joined.contains("Hello."),
11584 "assistant text not rendered: {joined}"
11585 );
11586 assert!(
11587 joined.contains("Reasoning about the reply."),
11588 "thinking block not rendered: {joined}"
11589 );
11590 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11591 assert!(state.current_assistant.lock().unwrap().is_some());
11592
11593 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11595 ended.set_result("hi", false);
11596 assert!(state.tool_components.lock().unwrap().is_empty());
11597
11598 assert!(state.try_start_working());
11602 assert!(
11603 !state.try_start_working(),
11604 "a second submit must be rejected"
11605 );
11606 state.set_status(RunStatus::Idle);
11607 state.set_status(RunStatus::Working);
11608 assert!(state.editor.working().is_some());
11610 assert_eq!(state.status_container.child_count(), 0);
11611 assert!(state.footer.get_status().is_empty());
11612 state.show_retry(3, 10, 8_000);
11613 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11614 assert!(retry_status.contains("Retrying (3/10)"));
11615 state.set_status(RunStatus::Working);
11616 {
11617 let mut bash = state.bash_components.lock().unwrap();
11618 bash.insert(
11619 "bash-1".into(),
11620 Arc::new(BashExecutionComponent::new("one")),
11621 );
11622 bash.insert(
11623 "bash-2".into(),
11624 Arc::new(BashExecutionComponent::new("two")),
11625 );
11626 }
11627 state.sync_working_loader_with_bash();
11628 assert_eq!(state.status_container.child_count(), 0);
11629 state.bash_components.lock().unwrap().remove("bash-1");
11630 state.sync_working_loader_with_bash();
11631 assert_eq!(state.status_container.child_count(), 0);
11632 state.bash_components.lock().unwrap().remove("bash-2");
11633 state.sync_working_loader_with_bash();
11634 assert_eq!(state.status_container.child_count(), 0);
11637 assert!(state.editor.working().is_some());
11638
11639 state.set_status(RunStatus::Aborting);
11640 assert_eq!(state.status_container.child_count(), 0);
11641 assert!(!state.loader.is_running());
11642 }
11643
11644 #[test]
11645 fn fresh_launch_does_not_restore_old_history() {
11646 let fresh = Args::default();
11647 assert!(!launch_restores_history(&fresh));
11648
11649 let continued = Args {
11650 continue_session: true,
11651 ..Args::default()
11652 };
11653 assert!(launch_restores_history(&continued));
11654
11655 let selected = Args {
11656 session: Some("session-id".into()),
11657 ..Args::default()
11658 };
11659 assert!(launch_restores_history(&selected));
11660 }
11661
11662 #[test]
11663 fn context_badge_ignores_aborted_and_error_turns() {
11664 use rpi_ai::types::{Api, StopReason, Usage};
11665
11666 let footer = Arc::new(FooterComponent::new());
11667 footer.set_context_window(100_000);
11668
11669 let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11671 ok.stop_reason = StopReason::Stop;
11672 ok.usage = Usage {
11673 input: 25_000,
11674 ..Usage::zero()
11675 };
11676 record_context_usage(&footer, &ok);
11677 let stats = strip_ansi(&footer.render(120).join("\n"));
11678 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11679
11680 let mut aborted =
11682 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11683 aborted.stop_reason = StopReason::Aborted;
11684 aborted.usage = Usage {
11685 input: 90_000,
11686 ..Usage::zero()
11687 };
11688 record_context_usage(&footer, &aborted);
11689 let stats = strip_ansi(&footer.render(120).join("\n"));
11690 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11691
11692 let mut errored =
11694 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11695 errored.stop_reason = StopReason::Error;
11696 errored.usage = Usage {
11697 input: 90_000,
11698 ..Usage::zero()
11699 };
11700 record_context_usage(&footer, &errored);
11701 let stats = strip_ansi(&footer.render(120).join("\n"));
11702 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11703 }
11704
11705 #[test]
11706 fn test_short_model_name() {
11707 assert_eq!(
11708 short_model_name("anthropic:claude-sonnet-5"),
11709 "claude-sonnet-5"
11710 );
11711 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
11712 }
11713
11714 #[test]
11715 fn model_selector_items_are_deduplicated_and_provider_qualified() {
11716 use rpi_ai::{Api, Model};
11717
11718 let mut gateway = Model::new(
11719 "gpt-5.6-sol",
11720 "GPT 5.6 Sol",
11721 Api::OpenaiCompletions,
11722 "routeryo-copy",
11723 "https://gateway.example.com",
11724 );
11725 let duplicate = gateway.clone();
11726 let anthropic = Model::new(
11727 "claude-sonnet-5",
11728 "Claude Sonnet 5",
11729 Api::AnthropicMessages,
11730 "anthropic",
11731 "https://api.anthropic.com",
11732 );
11733 gateway.headers = Some(std::collections::BTreeMap::from([(
11734 "authorization".into(),
11735 "Bearer test".into(),
11736 )]));
11737
11738 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
11739 assert_eq!(items.len(), 2);
11740 assert_eq!(items[0].value, "gpt-5.6-sol");
11741 assert_eq!(items[0].label, "GPT 5.6 Sol");
11742 assert_eq!(
11743 items[0].description.as_deref(),
11744 Some("routeryo-copy/gpt-5.6-sol (current)")
11745 );
11746 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
11747 }
11748
11749 #[test]
11750 fn model_selector_search_text_covers_provider_name_and_qualified_id() {
11751 use rpi_ai::{Api, Model};
11752
11753 let anthropic = Model::new(
11754 "claude-sonnet-5",
11755 "Claude Sonnet 5",
11756 Api::AnthropicMessages,
11757 "anthropic",
11758 "https://api.anthropic.com",
11759 );
11760 let items = model_selector_items(&[anthropic], "");
11761 let search = items[0].search_key();
11762 assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
11765 assert!(search.contains("Claude Sonnet 5"), "{search}");
11766
11767 use rpi_tui::fuzzy_filter;
11770 for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
11771 let matched = fuzzy_filter(&items, query, |item| item.search_key());
11772 assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
11773 }
11774 }
11775
11776 #[test]
11777 fn model_selector_match_accepts_bare_and_qualified_ids() {
11778 use rpi_ai::{Api, Model};
11779
11780 let gateway = Model::new(
11781 "gpt-5.6-sol",
11782 "GPT 5.6 Sol",
11783 Api::OpenaiCompletions,
11784 "routeryo-copy",
11785 "https://gateway.example.com",
11786 );
11787 let anthropic = Model::new(
11788 "claude-sonnet-5",
11789 "Claude Sonnet 5",
11790 Api::AnthropicMessages,
11791 "anthropic",
11792 "https://api.anthropic.com",
11793 );
11794 let catalog = [gateway, anthropic];
11795 assert_eq!(
11796 find_model_selector_match(&catalog, "gpt-5.6-sol")
11797 .unwrap()
11798 .provider,
11799 "routeryo-copy"
11800 );
11801 assert_eq!(
11802 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
11803 .unwrap()
11804 .id,
11805 "gpt-5.6-sol"
11806 );
11807 assert_eq!(
11808 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
11809 .unwrap()
11810 .id,
11811 "claude-sonnet-5"
11812 );
11813 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
11814 }
11815
11816 #[test]
11817 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
11818 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
11819
11820 let failed = AssistantMessage {
11821 role: AssistantRole,
11822 content: Vec::new(),
11823 api: rpi_ai::Api::AnthropicMessages,
11824 provider: "anthropic".into(),
11825 model: "claude-sonnet-5".into(),
11826 response_model: None,
11827 response_id: None,
11828 usage: Usage::zero(),
11829 stop_reason: StopReason::Error,
11830 deferred: None,
11831 error_message: Some("upstream returned 401".into()),
11832 raw_stop_reason: None,
11833 end_turn: None,
11834 timestamp: 0,
11835 };
11836 assert_eq!(
11837 assistant_error_text(&failed).as_deref(),
11838 Some("upstream returned 401")
11839 );
11840
11841 let mut no_detail = failed;
11842 no_detail.error_message = Some(" ".into());
11843 assert_eq!(
11844 assistant_error_text(&no_detail).as_deref(),
11845 Some("Provider request failed.")
11846 );
11847
11848 let mut aborted = no_detail;
11849 aborted.stop_reason = StopReason::Aborted;
11850 aborted.error_message = Some("abort error: Request aborted".into());
11851 assert_eq!(
11852 assistant_error_text(&aborted).as_deref(),
11853 Some("abort error: Request aborted")
11854 );
11855
11856 aborted.error_message = None;
11857 assert_eq!(
11858 assistant_error_text(&aborted).as_deref(),
11859 Some("Request aborted.")
11860 );
11861 }
11862
11863 #[test]
11864 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
11865 assert_eq!(
11866 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
11867 "405\nMethod Not Allowed"
11868 );
11869 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
11870 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
11871 let long = "x".repeat(20_000);
11872 let cleaned = sanitize_error_message(&long);
11873 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
11874 assert!(cleaned.ends_with('…'));
11875 }
11876
11877 #[test]
11878 fn test_cycle_next_model_wraps_around() {
11879 use rpi_ai::{Api, Model};
11880 let mk = |id: &str| {
11881 Model::new(
11882 id,
11883 id,
11884 Api::AnthropicMessages,
11885 "anthropic",
11886 "https://api.anthropic.com",
11887 )
11888 };
11889 let catalog = [mk("a"), mk("b"), mk("c")];
11890 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
11892 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
11893 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
11895 let empty: Vec<Model> = vec![];
11897 assert!(cycle_next_model(&empty, "a").is_none());
11898 }
11899
11900 #[test]
11901 fn test_autocomplete_slash_suggestions_render() {
11902 let state = Arc::new(TuiState {
11905 current_assistant: std::sync::Mutex::new(None),
11906 tool_components: std::sync::Mutex::new(HashMap::new()),
11907 bash_components: std::sync::Mutex::new(HashMap::new()),
11908 themes_enabled: true,
11909 hide_thinking: std::sync::Mutex::new(false),
11910 tool_outputs_expanded: std::sync::Mutex::new(false),
11911 show_terminal_progress: true,
11912 status: std::sync::Mutex::new(RunStatus::Idle),
11913 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11914 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11915 js_preparation_cancel: std::sync::Mutex::new(None),
11916 user_bash_cancel: std::sync::Mutex::new(None),
11917 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11918 footer: Arc::new(FooterComponent::new()),
11919 status_container: Arc::new(Container::new()),
11920 chat_container: Arc::new(Container::new()),
11921 loader: Arc::new(Loader::new()),
11922 editor: Arc::new(Editor::simple()),
11923 last_assistant_text: std::sync::Mutex::new(String::new()),
11924 active_selector: std::sync::Mutex::new(None),
11925 active_extension_editor: std::sync::Mutex::new(None),
11926 active_extension_input: std::sync::Mutex::new(None),
11927 active_extension_cancel: std::sync::Mutex::new(None),
11928 autocomplete: AutocompleteManager::new(),
11929 autocomplete_container: Arc::new(Container::new()),
11930 autocomplete_max_visible: 5,
11931 pending_images: std::sync::Mutex::new(Vec::new()),
11932 theme_manager: Arc::new(ThemeManager::new()),
11933 tui: None,
11934 current_model_id: std::sync::Mutex::new(String::new()),
11935 show_images: std::sync::Mutex::new(true),
11936 cache_miss_notices: std::sync::Mutex::new(false),
11937 history: std::sync::Mutex::new(Vec::new()),
11938 history_index: std::sync::Mutex::new(-1),
11939 history_draft: std::sync::Mutex::new(None),
11940 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11941 scoped_edit: std::sync::Mutex::new(None),
11942 markdown_transformer: std::sync::Mutex::new(None),
11943 extension_session: Arc::new(std::sync::Mutex::new(
11944 rpi_extensions::ExtensionSession::none(),
11945 )),
11946 search: Arc::new(AltScreenSearch::new()),
11947 search_bar: Arc::new(SearchBar::new()),
11948 pending_container: Arc::new(Container::new()),
11949 dequeue_hint: String::new(),
11950 pending_snapshot: std::sync::Mutex::new(
11951 rpi_harness::agent_harness::QueuedMessages::default(),
11952 ),
11953 selection_start: std::sync::Mutex::new(None),
11954 selection_end: std::sync::Mutex::new(None),
11955 });
11956 {
11957 let mut combined = CombinedAutocompleteProvider::new();
11958 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11959 build_builtin_registry().visible_entries(),
11960 )));
11961 state.autocomplete.set_provider(Arc::new(combined));
11962 }
11963
11964 let editor = Arc::new(Editor::simple());
11965 editor.set_text("/he");
11966 editor.set_cursor(0, 3);
11967 refresh_autocomplete(&state, &editor);
11968 let lines = state.autocomplete_container.render(80);
11969 let joined: String = lines.join("\n");
11970 assert!(
11971 joined.contains("/help"),
11972 "slash suggestions not rendered: {joined}"
11973 );
11974
11975 editor.set_text("hello");
11977 editor.set_cursor(0, 5);
11978 refresh_autocomplete(&state, &editor);
11979 assert!(state.autocomplete_container.render(80).is_empty());
11980 }
11981
11982 #[test]
11983 fn test_select_list_swap_restores_editor() {
11984 let state = Arc::new(TuiState {
11988 current_assistant: std::sync::Mutex::new(None),
11989 tool_components: std::sync::Mutex::new(HashMap::new()),
11990 bash_components: std::sync::Mutex::new(HashMap::new()),
11991 themes_enabled: true,
11992 hide_thinking: std::sync::Mutex::new(false),
11993 tool_outputs_expanded: std::sync::Mutex::new(false),
11994 show_terminal_progress: true,
11995 status: std::sync::Mutex::new(RunStatus::Idle),
11996 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11997 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11998 js_preparation_cancel: std::sync::Mutex::new(None),
11999 user_bash_cancel: std::sync::Mutex::new(None),
12000 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12001 footer: Arc::new(FooterComponent::new()),
12002 status_container: Arc::new(Container::new()),
12003 chat_container: Arc::new(Container::new()),
12004 loader: Arc::new(Loader::new()),
12005 editor: Arc::new(Editor::simple()),
12006 last_assistant_text: std::sync::Mutex::new(String::new()),
12007 active_selector: std::sync::Mutex::new(None),
12008 active_extension_editor: std::sync::Mutex::new(None),
12009 active_extension_input: std::sync::Mutex::new(None),
12010 active_extension_cancel: std::sync::Mutex::new(None),
12011 autocomplete: AutocompleteManager::new(),
12012 autocomplete_container: Arc::new(Container::new()),
12013 autocomplete_max_visible: 5,
12014 pending_images: std::sync::Mutex::new(Vec::new()),
12015 theme_manager: Arc::new(ThemeManager::new()),
12016 tui: None,
12017 current_model_id: std::sync::Mutex::new(String::new()),
12018 show_images: std::sync::Mutex::new(true),
12019 cache_miss_notices: std::sync::Mutex::new(false),
12020 history: std::sync::Mutex::new(Vec::new()),
12021 history_index: std::sync::Mutex::new(-1),
12022 history_draft: std::sync::Mutex::new(None),
12023 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12024 scoped_edit: std::sync::Mutex::new(None),
12025 markdown_transformer: std::sync::Mutex::new(None),
12026 extension_session: Arc::new(std::sync::Mutex::new(
12027 rpi_extensions::ExtensionSession::none(),
12028 )),
12029 search: Arc::new(AltScreenSearch::new()),
12030 search_bar: Arc::new(SearchBar::new()),
12031 pending_container: Arc::new(Container::new()),
12032 dequeue_hint: String::new(),
12033 pending_snapshot: std::sync::Mutex::new(
12034 rpi_harness::agent_harness::QueuedMessages::default(),
12035 ),
12036 selection_start: std::sync::Mutex::new(None),
12037 selection_end: std::sync::Mutex::new(None),
12038 });
12039 let editor_container = Arc::new(Container::new());
12040 let editor = Arc::new(Editor::simple());
12041 editor_container.add_child(editor.clone());
12042 assert!(!state.selector_open());
12043
12044 let tui_terminal = Box::new(ProcessTerminal::new());
12045 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
12046 let list = Arc::new(SelectList::new(
12047 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
12048 5,
12049 ));
12050 open_selector(
12051 &state,
12052 &editor_container,
12053 &editor,
12054 &tui,
12055 list,
12056 SelectorKind::Theme,
12057 );
12058 assert!(state.selector_open());
12059 assert_eq!(editor_container.child_count(), 1);
12061
12062 close_selector(&state, &editor_container, &editor, &tui);
12063 assert!(!state.selector_open());
12064 assert_eq!(editor_container.child_count(), 1);
12066 }
12067
12068 #[test]
12069 fn test_message_history_browse_restores_draft() {
12070 let state = Arc::new(TuiState {
12074 current_assistant: std::sync::Mutex::new(None),
12075 tool_components: std::sync::Mutex::new(HashMap::new()),
12076 bash_components: std::sync::Mutex::new(HashMap::new()),
12077 themes_enabled: true,
12078 hide_thinking: std::sync::Mutex::new(false),
12079 tool_outputs_expanded: std::sync::Mutex::new(false),
12080 show_terminal_progress: true,
12081 status: std::sync::Mutex::new(RunStatus::Idle),
12082 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12083 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12084 js_preparation_cancel: std::sync::Mutex::new(None),
12085 user_bash_cancel: std::sync::Mutex::new(None),
12086 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12087 footer: Arc::new(FooterComponent::new()),
12088 status_container: Arc::new(Container::new()),
12089 chat_container: Arc::new(Container::new()),
12090 loader: Arc::new(Loader::new()),
12091 editor: Arc::new(Editor::simple()),
12092 last_assistant_text: std::sync::Mutex::new(String::new()),
12093 active_selector: std::sync::Mutex::new(None),
12094 active_extension_editor: std::sync::Mutex::new(None),
12095 active_extension_input: std::sync::Mutex::new(None),
12096 active_extension_cancel: std::sync::Mutex::new(None),
12097 autocomplete: AutocompleteManager::new(),
12098 autocomplete_container: Arc::new(Container::new()),
12099 autocomplete_max_visible: 5,
12100 pending_images: std::sync::Mutex::new(Vec::new()),
12101 theme_manager: Arc::new(ThemeManager::new()),
12102 tui: None,
12103 current_model_id: std::sync::Mutex::new(String::new()),
12104 show_images: std::sync::Mutex::new(true),
12105 cache_miss_notices: std::sync::Mutex::new(false),
12106 history: std::sync::Mutex::new(Vec::new()),
12107 history_index: std::sync::Mutex::new(-1),
12108 history_draft: std::sync::Mutex::new(None),
12109 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12110 scoped_edit: std::sync::Mutex::new(None),
12111 markdown_transformer: std::sync::Mutex::new(None),
12112 extension_session: Arc::new(std::sync::Mutex::new(
12113 rpi_extensions::ExtensionSession::none(),
12114 )),
12115 search: Arc::new(AltScreenSearch::new()),
12116 search_bar: Arc::new(SearchBar::new()),
12117 pending_container: Arc::new(Container::new()),
12118 dequeue_hint: String::new(),
12119 pending_snapshot: std::sync::Mutex::new(
12120 rpi_harness::agent_harness::QueuedMessages::default(),
12121 ),
12122 selection_start: std::sync::Mutex::new(None),
12123 selection_end: std::sync::Mutex::new(None),
12124 });
12125 let editor = Arc::new(Editor::simple());
12126
12127 push_history(&state, "first message");
12128 push_history(&state, "second message");
12129 push_history(&state, "second message");
12131 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
12133 assert_eq!(state.history.lock().unwrap()[0], "second message");
12134
12135 editor.set_text("half-typed");
12137 editor.set_cursor(0, 11);
12138
12139 navigate_history(&state, &editor, -1);
12141 assert_eq!(editor.get_text(), "second message");
12142 assert_eq!(*state.history_index.lock().unwrap(), 0);
12143 navigate_history(&state, &editor, -1);
12145 assert_eq!(editor.get_text(), "first message");
12146 assert_eq!(*state.history_index.lock().unwrap(), 1);
12147 navigate_history(&state, &editor, -1);
12149 assert_eq!(editor.get_text(), "first message");
12150 navigate_history(&state, &editor, 1);
12152 assert_eq!(editor.get_text(), "second message");
12153 navigate_history(&state, &editor, 1);
12155 assert_eq!(editor.get_text(), "half-typed");
12156 assert_eq!(*state.history_index.lock().unwrap(), -1);
12157 }
12158
12159 #[test]
12160 fn test_accept_top_suggestion_replaces_prefix() {
12161 let state = Arc::new(TuiState {
12163 current_assistant: std::sync::Mutex::new(None),
12164 tool_components: std::sync::Mutex::new(HashMap::new()),
12165 bash_components: std::sync::Mutex::new(HashMap::new()),
12166 themes_enabled: true,
12167 hide_thinking: std::sync::Mutex::new(false),
12168 tool_outputs_expanded: std::sync::Mutex::new(false),
12169 show_terminal_progress: true,
12170 status: std::sync::Mutex::new(RunStatus::Idle),
12171 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12172 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12173 js_preparation_cancel: std::sync::Mutex::new(None),
12174 user_bash_cancel: std::sync::Mutex::new(None),
12175 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12176 footer: Arc::new(FooterComponent::new()),
12177 status_container: Arc::new(Container::new()),
12178 chat_container: Arc::new(Container::new()),
12179 loader: Arc::new(Loader::new()),
12180 editor: Arc::new(Editor::simple()),
12181 last_assistant_text: std::sync::Mutex::new(String::new()),
12182 active_selector: std::sync::Mutex::new(None),
12183 active_extension_editor: std::sync::Mutex::new(None),
12184 active_extension_input: std::sync::Mutex::new(None),
12185 active_extension_cancel: std::sync::Mutex::new(None),
12186 autocomplete: AutocompleteManager::new(),
12187 autocomplete_container: Arc::new(Container::new()),
12188 autocomplete_max_visible: 5,
12189 pending_images: std::sync::Mutex::new(Vec::new()),
12190 theme_manager: Arc::new(ThemeManager::new()),
12191 tui: None,
12192 current_model_id: std::sync::Mutex::new(String::new()),
12193 show_images: std::sync::Mutex::new(true),
12194 cache_miss_notices: std::sync::Mutex::new(false),
12195 history: std::sync::Mutex::new(Vec::new()),
12196 history_index: std::sync::Mutex::new(-1),
12197 history_draft: std::sync::Mutex::new(None),
12198 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12199 scoped_edit: std::sync::Mutex::new(None),
12200 markdown_transformer: std::sync::Mutex::new(None),
12201 extension_session: Arc::new(std::sync::Mutex::new(
12202 rpi_extensions::ExtensionSession::none(),
12203 )),
12204 search: Arc::new(AltScreenSearch::new()),
12205 search_bar: Arc::new(SearchBar::new()),
12206 pending_container: Arc::new(Container::new()),
12207 dequeue_hint: String::new(),
12208 pending_snapshot: std::sync::Mutex::new(
12209 rpi_harness::agent_harness::QueuedMessages::default(),
12210 ),
12211 selection_start: std::sync::Mutex::new(None),
12212 selection_end: std::sync::Mutex::new(None),
12213 });
12214 {
12215 let mut combined = CombinedAutocompleteProvider::new();
12216 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12217 build_builtin_registry().visible_entries(),
12218 )));
12219 state.autocomplete.set_provider(Arc::new(combined));
12220 }
12221 let editor = Arc::new(Editor::simple());
12222 editor.set_text("/he");
12223 editor.set_cursor(0, 3);
12224 refresh_autocomplete(&state, &editor);
12225 let accepted = accept_top_suggestion(&state, &editor);
12226 assert!(accepted, "should accept the top suggestion");
12227 let text = editor.get_text();
12228 assert!(
12229 text.starts_with("/help"),
12230 "editor text should start with /help, got {text}"
12231 );
12232 }
12233
12234 #[test]
12235 fn configured_key_parser_supports_native_notation() {
12236 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12237 assert_eq!(combo.code, KeyCode::Char('g'));
12238 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12239 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12240 assert_eq!(combo.code, KeyCode::BackTab);
12241 }
12242
12243 #[test]
12244 fn double_escape_trigger_has_half_second_window() {
12245 let now = std::time::Instant::now();
12246 assert!(!double_escape_trigger(None, now));
12247 assert!(double_escape_trigger(
12248 Some(now - std::time::Duration::from_millis(500)),
12249 now
12250 ));
12251 assert!(!double_escape_trigger(
12252 Some(now - std::time::Duration::from_millis(501)),
12253 now
12254 ));
12255 }
12256
12257 #[test]
12258 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12259 use std::sync::atomic::{AtomicUsize, Ordering};
12265
12266 let editor = rpi_tui::Editor::simple();
12267 let submits = Arc::new(AtomicUsize::new(0));
12268 let submits_for_cb = submits.clone();
12269 editor.on_submit(Arc::new(move |_text: &str| {
12270 submits_for_cb.fetch_add(1, Ordering::SeqCst);
12271 }));
12272
12273 let now = std::time::Instant::now();
12276 let mut last_text_key_at: Option<std::time::Instant> = None;
12277 let block = ["l1", "l2", "l3"];
12278 for (index, line) in block.iter().enumerate() {
12279 for ch in line.chars() {
12280 editor.insert(&ch.to_string());
12281 last_text_key_at = Some(now);
12282 }
12283 if index + 1 < block.len() {
12284 let more_queued = true;
12286 assert!(
12287 enter_is_paste_burst(last_text_key_at, now, more_queued),
12288 "pasted newline {index} must not submit"
12289 );
12290 editor.insert("\n");
12291 last_text_key_at = Some(now);
12292 }
12293 }
12294 assert_eq!(editor.get_text(), "l1\nl2\nl3");
12295 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12296
12297 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12299 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12300 editor.handle_key(crossterm::event::KeyEvent::new(
12301 KeyCode::Enter,
12302 KeyModifiers::NONE,
12303 ));
12304 assert_eq!(
12305 submits.load(Ordering::SeqCst),
12306 1,
12307 "typed Enter submits once"
12308 );
12309 }
12310
12311 #[test]
12312 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12313 let editor = rpi_tui::Editor::simple();
12316 editor.insert("alpha\r\nbeta\r\n");
12317 assert_eq!(editor.get_text(), "alpha\nbeta\n");
12318 assert!(!editor.get_text().contains('\r'));
12319 }
12320
12321 #[test]
12322 fn pasted_enter_is_not_a_submit() {
12323 let now = std::time::Instant::now();
12324
12325 assert!(enter_is_paste_burst(None, now, true));
12327
12328 assert!(enter_is_paste_burst(
12331 Some(now - std::time::Duration::from_millis(1)),
12332 now,
12333 false
12334 ));
12335 assert!(enter_is_paste_burst(
12336 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12337 now,
12338 false
12339 ));
12340
12341 assert!(!enter_is_paste_burst(None, now, false));
12343 assert!(!enter_is_paste_burst(
12344 Some(now - PASTE_BURST_GAP),
12345 now,
12346 false
12347 ));
12348 assert!(!enter_is_paste_burst(
12349 Some(now - std::time::Duration::from_millis(120)),
12350 now,
12351 false
12352 ));
12353 }
12354
12355 #[test]
12356 fn paste_probe_ignores_the_enter_keys_own_release() {
12357 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12358 let release =
12359 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12360
12361 let mut queued = vec![Event::Key(release)].into_iter();
12364 let (more, stashed) = probe_paste_input(None, || queued.next());
12365 assert!(!more, "a key release alone is not queued paste input");
12366 assert!(stashed.is_none());
12367
12368 let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12371 let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12372 let (more, stashed) = probe_paste_input(None, || queued.next());
12373 assert!(more, "a queued key press is paste continuation");
12374 assert!(matches!(stashed, Some(Event::Key(_))));
12375
12376 let (more, stashed) = probe_paste_input(None, || None);
12378 assert!(!more);
12379 assert!(stashed.is_none());
12380
12381 let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12383 panic!("must not read when an event is already stashed")
12384 });
12385 assert!(more);
12386 assert!(matches!(stashed, Some(Event::Key(_))));
12387 }
12388
12389 #[test]
12392 fn ask_user_tool_names_are_recognized() {
12393 assert!(is_ask_user_tool("ask_user"));
12394 assert!(is_ask_user_tool("ask_user_question"));
12395 assert!(!is_ask_user_tool("bash"));
12396 }
12397
12398 #[test]
12399 fn ask_user_args_display_never_leaks_transport_json() {
12400 let text = ask_user_args_display(&serde_json::json!({
12401 "question": "你的代理端口是多少?",
12402 "context": "本机",
12403 "options": ["7890", {"title": "7897", "description": "clash"}],
12404 "suggest": "1080"
12405 }));
12406 assert!(text.contains("你的代理端口是多少?"));
12407 assert!(text.contains("本机"));
12408 assert!(text.contains("Choices: 7890, 7897"));
12409 assert!(text.contains("Suggestions: 1080"));
12410 assert!(!text.trim_start().starts_with('{'));
12411
12412 let multi = ask_user_args_display(&serde_json::json!({
12413 "questions": [
12414 {"id": "a", "question": "Q1"},
12415 {"id": "b", "header": "H", "question": "Q2"}
12416 ]
12417 }));
12418 assert!(multi.contains("Q1"));
12419 assert!(multi.contains("H: Q2"));
12420
12421 assert_eq!(
12422 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
12423 "Confirmation requested: Deploy?"
12424 );
12425 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
12426 }
12427
12428 #[test]
12429 fn ask_user_result_and_progress_prefer_readable_content() {
12430 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
12431 assert!(
12432 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
12433 .contains("端口?")
12434 );
12435 let empty = rpi_agent::AgentToolResult::default();
12436 assert!(
12437 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
12438 .contains("端口?")
12439 );
12440 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
12441 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
12442 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
12443 }
12444
12445 #[test]
12446 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12447 let flat = rpi_extensions::UiDialogRequest {
12448 request_id: "r1".into(),
12449 tool_call_id: Some("c1".into()),
12450 ui: serde_json::json!({
12451 "kind": "selector",
12452 "id": "proxy_port",
12453 "header": "Proxy",
12454 "question": "端口?",
12455 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12456 "allowMultiple": true,
12457 "allowFreeform": false,
12458 "suggest": "1080"
12459 }),
12460 raw: serde_json::json!({}),
12461 };
12462 let prompt = parse_ask_user_prompt(&flat);
12463 assert_eq!(prompt.question_id, "proxy_port");
12464 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12465 assert_eq!(prompt.options.len(), 2);
12466 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12467 assert!(prompt.allow_multiple);
12468 assert!(!prompt.allow_freeform);
12469 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12470
12471 let alias = rpi_extensions::UiDialogRequest {
12472 ui: serde_json::json!({
12473 "questions": [{"id": "q", "question": "Q?", "options": []}]
12474 }),
12475 ..flat.clone()
12476 };
12477 let prompt = parse_ask_user_prompt(&alias);
12478 assert_eq!(prompt.question_id, "q");
12479 assert!(prompt.allow_freeform, "no options defaults to freeform");
12480
12481 let confirm = rpi_extensions::UiDialogRequest {
12482 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12483 ..flat.clone()
12484 };
12485 let prompt = parse_ask_user_prompt(&confirm);
12486 assert_eq!(prompt.kind, "confirm");
12487 assert_eq!(prompt.options.len(), 2);
12488 assert_eq!(prompt.options[0].0, "Yes");
12489 }
12490
12491 #[test]
12492 fn ask_user_bridge_answers_and_cancels_by_request_id() {
12493 let mailbox = rpi_extensions::UiDialogMailbox::new();
12494 mailbox.attach();
12495 let bridge = AskUserBridge::new(mailbox.clone());
12496
12497 mailbox
12499 .handle(serde_json::json!({
12500 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12501 "ui": {"kind": "input", "question": "Port?"}
12502 }))
12503 .unwrap();
12504 mailbox
12505 .handle(serde_json::json!({
12506 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12507 "ui": {"kind": "input", "question": "Level?"}
12508 }))
12509 .unwrap();
12510
12511 let first = bridge.take_pending().expect("r1 visible");
12512 assert_eq!(first.request_id, "r1");
12513 assert!(
12514 bridge.take_pending().is_none(),
12515 "only one request occupies the input slot"
12516 );
12517 bridge.respond("r1", serde_json::json!({"text": "7897"}));
12518 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12519
12520 let second = bridge.take_pending().expect("r2 visible");
12521 assert_eq!(second.request_id, "r2");
12522 bridge.cancel("r2");
12523 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12524
12525 mailbox
12527 .handle(serde_json::json!({
12528 "op": "open", "requestId": "r3",
12529 "ui": {"kind": "input", "question": "X?"}
12530 }))
12531 .unwrap();
12532 bridge.cancel_all();
12533 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12534
12535 bridge.shutdown();
12537 assert!(mailbox
12538 .handle(serde_json::json!({
12539 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12540 }))
12541 .is_err());
12542 }
12543}