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