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 if tool_result_requests_markdown(result.details.as_ref()) {
4197 comp.set_result_markdown(true);
4198 }
4199 chat.add_child(comp);
4200 chat.add_child(Arc::new(Spacer::new(1)));
4201 rendered_any = true;
4202 }
4203 AgentMessage::Custom(custom) => {
4204 if let Some(session) = &extension_session {
4205 if let Some(component) = extension_message_component(
4206 session,
4207 &custom.role,
4208 &serde_json::json!({
4209 "customType": custom.role,
4210 "content": custom.content,
4211 "details": custom.data,
4212 }),
4213 transformer.clone(),
4214 ) {
4215 chat.add_child(component);
4216 chat.add_child(Arc::new(Spacer::new(1)));
4217 rendered_any = true;
4218 continue;
4219 }
4220 }
4221 add_note_message(chat, &custom_message_fallback(&custom));
4222 rendered_any = true;
4223 }
4224 },
4225 Entry::Compaction(compaction) => {
4226 add_note_message(
4227 chat,
4228 &format!(
4229 "Compacted {} tokens: {}",
4230 compaction.tokens_before, compaction.summary
4231 ),
4232 );
4233 rendered_any = true;
4234 }
4235 Entry::BranchSummary(summary) => {
4236 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4237 rendered_any = true;
4238 }
4239 Entry::Custom(custom) => {
4240 let rendered = extension_session.as_ref().and_then(|session| {
4241 extension_entry_component(session, &custom.custom_type, custom.data.clone())
4242 });
4243 if let Some(component) = rendered {
4244 chat.add_child(component);
4245 chat.add_child(Arc::new(Spacer::new(1)));
4246 rendered_any = true;
4247 } else if let Some(text) =
4248 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4249 {
4250 add_note_message(chat, &text);
4251 rendered_any = true;
4252 }
4253 }
4254 Entry::ModelChange(change) => {
4255 add_note_message(
4256 chat,
4257 &format!("Model changed to {}:{}", change.provider, change.model_id),
4258 );
4259 rendered_any = true;
4260 }
4261 Entry::ThinkingLevel(change) => {
4262 add_note_message(
4263 chat,
4264 &format!("Thinking level: {:?}", change.thinking_level),
4265 );
4266 rendered_any = true;
4267 }
4268 Entry::ActiveTools(change) => {
4269 add_note_message(
4270 chat,
4271 &format!("Active tools: {}", change.active_tool_names.join(", ")),
4272 );
4273 rendered_any = true;
4274 }
4275 }
4276 }
4277 if rendered_any {
4278 }
4281}
4282
4283fn invoke_extension_renderer(
4284 session: &crate::session::ExtensionSessionCell,
4285 kind: rpi_extensions::RegisteredRendererKind,
4286 payload: &serde_json::Value,
4287) -> Option<serde_json::Value> {
4288 let snapshot = session.lock().ok()?.snapshot_arc()?;
4289 let input = serde_json::to_string(payload).ok()?;
4290 for renderer in snapshot.renderers_of(kind) {
4291 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4292 let mut out = rpi_plugin_sdk::StbString::empty();
4293 let rc = (renderer.render_fn)(
4294 rpi_plugin_sdk::StbStringRef::from_str(&input),
4295 &mut out as *mut rpi_plugin_sdk::StbString,
4296 renderer.user_data,
4297 );
4298 let text = if rc == 0 {
4299 Some(out.to_string_lossy())
4300 } else {
4301 None
4302 };
4303 out.free_with(Some(renderer.plugin_free_string));
4304 text
4305 }))
4306 .ok()
4307 .flatten();
4308 let Some(text) = outcome else { continue };
4309 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4310 return Some(value);
4311 }
4312 }
4313 None
4314}
4315
4316fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4317 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4318 let text = lines
4319 .iter()
4320 .filter_map(|line| line.as_str())
4321 .collect::<Vec<_>>()
4322 .join("\n");
4323 return Some(Arc::new(Text::new(text, 0, 0)));
4324 }
4325 let text = value.get("text").and_then(|v| v.as_str())?;
4326 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4327 let component = Arc::new(AssistantMessageComponent::new(
4328 AssistantMessageOptions::default(),
4329 ));
4330 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4331 Some(component)
4332 } else {
4333 Some(Arc::new(Text::new(text, 0, 0)))
4334 }
4335}
4336
4337fn extension_message_component(
4338 session: &crate::session::ExtensionSessionCell,
4339 custom_type: &str,
4340 payload: &serde_json::Value,
4341 transformer: Option<MarkdownTransformer>,
4342) -> Option<Arc<dyn rpi_tui::Component>> {
4343 let value = invoke_extension_renderer(
4344 session,
4345 rpi_extensions::RegisteredRendererKind::Message,
4346 payload,
4347 )?;
4348 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4349 let text = value.get("text").and_then(|v| v.as_str())?;
4350 let component = Arc::new(AssistantMessageComponent::new(
4351 AssistantMessageOptions::default(),
4352 ));
4353 if let Some(transformer) = transformer {
4354 component.set_markdown_transformer(Some(transformer));
4355 }
4356 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4357 return Some(component);
4358 }
4359 extension_text_component(&value)
4360 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4361}
4362
4363fn extension_usage_text(
4366 session: Option<&crate::session::ExtensionSessionCell>,
4367 usage: &rpi_ai::types::Usage,
4368) -> Option<String> {
4369 let session = session?;
4370 let payload = serde_json::json!({
4371 "customType": "token-usage",
4372 "usage": usage,
4373 });
4374 let value = invoke_extension_renderer(
4375 session,
4376 rpi_extensions::RegisteredRendererKind::Message,
4377 &payload,
4378 )?;
4379 value
4380 .get("text")
4381 .and_then(|value| value.as_str())
4382 .filter(|text| !text.trim().is_empty())
4383 .map(ToOwned::to_owned)
4384}
4385
4386fn extension_entry_component(
4387 session: &crate::session::ExtensionSessionCell,
4388 custom_type: &str,
4389 data: Option<serde_json::Value>,
4390) -> Option<Arc<dyn rpi_tui::Component>> {
4391 let payload = serde_json::json!({
4392 "customType": custom_type,
4393 "data": data,
4394 });
4395 let value = invoke_extension_renderer(
4396 session,
4397 rpi_extensions::RegisteredRendererKind::Entry,
4398 &payload,
4399 )?;
4400 extension_text_component(&value)
4401}
4402
4403fn launch_restores_history(args: &Args) -> bool {
4409 args.continue_session
4410 || args.resume
4411 || args.session.is_some()
4412 || args.session_id.is_some()
4413 || args.fork.is_some()
4414}
4415
4416fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4417 msg.content
4418 .iter()
4419 .filter_map(|c| match c {
4420 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4421 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4422 Content::Image(image) => base64::engine::general_purpose::STANDARD
4423 .decode(&image.data)
4424 .ok()
4425 .filter(|data| !data.is_empty())
4426 .map(AssistantBlock::Image),
4427 _ => None,
4428 })
4429 .collect()
4430}
4431
4432fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4433 let content = custom
4434 .content
4435 .iter()
4436 .filter_map(|item| match item {
4437 Content::Text(text) => Some(text.text.as_str()),
4438 _ => None,
4439 })
4440 .collect::<Vec<_>>()
4441 .join("\n");
4442 if content.is_empty() {
4443 format!("{}: {}", custom.role, custom.data)
4444 } else {
4445 format!("{}: {}", custom.role, content)
4446 }
4447}
4448
4449fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4451 let mut dir = Some(cwd);
4452 while let Some(current) = dir {
4453 let head = current.join(".git").join("HEAD");
4454 if head.exists() {
4455 return Some(head);
4456 }
4457 dir = current.parent();
4458 }
4459 None
4460}
4461
4462fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4466 let mut dir = Some(cwd);
4467 while let Some(current) = dir {
4468 let head = current.join(".git").join("HEAD");
4469 if let Ok(contents) = std::fs::read_to_string(&head) {
4470 let trimmed = contents.trim();
4471 if let Some(reference) = trimmed.strip_prefix("ref: ") {
4472 let branch = reference
4473 .rsplit('/')
4474 .next()
4475 .unwrap_or(reference)
4476 .trim()
4477 .to_string();
4478 if !branch.is_empty() {
4479 return Some(branch);
4480 }
4481 }
4482 if !trimmed.is_empty() {
4483 return Some(trimmed.chars().take(7).collect());
4484 }
4485 }
4486 dir = current.parent();
4487 }
4488 None
4489}
4490
4491fn short_model_name(id: &str) -> String {
4494 id.rsplit([':', '/'])
4495 .next()
4496 .filter(|s| !s.is_empty())
4497 .unwrap_or(id)
4498 .to_string()
4499}
4500
4501#[derive(Clone, Copy, PartialEq, Eq)]
4507enum RunStatus {
4508 Idle,
4509 Working,
4510 Aborting,
4511}
4512
4513#[derive(Clone, Copy, PartialEq, Eq)]
4515enum SelectorKind {
4516 Model,
4518 Thinking,
4520 Tools,
4522 Images,
4524 Session,
4526 Theme,
4528 ScopedModels,
4530 Settings,
4532 Tree,
4534 Extension,
4536}
4537
4538#[derive(Clone)]
4545enum SelectorView {
4546 List(Arc<SelectList>),
4548 Searchable(Arc<SearchableSelectList>),
4550 Settings(Arc<SettingsList>),
4553}
4554
4555impl SelectorView {
4556 fn handle_key(&self, key: KeyEvent) {
4558 match self {
4559 Self::List(list) => list.handle_key(key),
4560 Self::Searchable(searchable) => searchable.handle_key(key),
4561 Self::Settings(settings) => settings.handle_key(key),
4562 }
4563 }
4564}
4565
4566struct TuiState {
4569 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4571 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4573 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4577 themes_enabled: bool,
4579 hide_thinking: std::sync::Mutex<bool>,
4581 tool_outputs_expanded: std::sync::Mutex<bool>,
4583 show_terminal_progress: bool,
4585 status: std::sync::Mutex<RunStatus>,
4587 ext_status: rpi_extensions::ExtensionStatusMailbox,
4590 ext_status_revision: std::sync::atomic::AtomicU64,
4592 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4596 user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4600 pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4604 footer: Arc<FooterComponent>,
4606 status_container: Arc<Container>,
4609 chat_container: Arc<Container>,
4611 loader: Arc<Loader>,
4613 editor: Arc<Editor>,
4616 last_assistant_text: std::sync::Mutex<String>,
4619 active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4623 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4625 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4627 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4629 autocomplete: AutocompleteManager,
4632 autocomplete_container: Arc<Container>,
4635 autocomplete_max_visible: usize,
4637 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4639 theme_manager: Arc<ThemeManager>,
4643 tui: Option<Arc<TuiAltScreen>>,
4647 current_model_id: std::sync::Mutex<String>,
4651 show_images: std::sync::Mutex<bool>,
4655 cache_miss_notices: std::sync::Mutex<bool>,
4658 history: std::sync::Mutex<Vec<String>>,
4661 history_index: std::sync::Mutex<isize>,
4664 history_draft: std::sync::Mutex<Option<String>>,
4667 cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4671 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4674 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4684 extension_session: crate::session::ExtensionSessionCell,
4686 search: Arc<AltScreenSearch>,
4688 search_bar: Arc<SearchBar>,
4690 pending_container: Arc<Container>,
4694 dequeue_hint: String,
4697 pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4701 selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4703 selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4704}
4705
4706const HISTORY_LIMIT: usize = 100;
4709
4710const PAGE_SCROLL_OVERLAP: usize = 4;
4713
4714const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4718
4719fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4722 let mut modifiers = KeyModifiers::NONE;
4723 let mut key = None;
4724 for part in value.trim().to_ascii_lowercase().split('+') {
4725 match part {
4726 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4727 "shift" => modifiers |= KeyModifiers::SHIFT,
4728 "alt" | "option" => modifiers |= KeyModifiers::ALT,
4729 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4730 part if !part.is_empty() => key = Some(part.to_string()),
4731 _ => {}
4732 }
4733 }
4734 let key = key?;
4735 let code = match key.as_str() {
4736 "esc" | "escape" => KeyCode::Esc,
4737 "enter" | "return" => KeyCode::Enter,
4738 "tab" => {
4739 if modifiers.contains(KeyModifiers::SHIFT) {
4740 return Some(rpi_tui::KeyCombo::new(
4741 KeyCode::BackTab,
4742 modifiers & !KeyModifiers::SHIFT,
4743 ));
4744 }
4745 KeyCode::Tab
4746 }
4747 "backspace" | "back" => KeyCode::Backspace,
4748 "delete" | "del" => KeyCode::Delete,
4749 "up" | "arrowup" => KeyCode::Up,
4750 "down" | "arrowdown" => KeyCode::Down,
4751 "left" | "arrowleft" => KeyCode::Left,
4752 "right" | "arrowright" => KeyCode::Right,
4753 "home" => KeyCode::Home,
4754 "end" => KeyCode::End,
4755 "pageup" | "page-up" => KeyCode::PageUp,
4756 "pagedown" | "page-down" => KeyCode::PageDown,
4757 "space" => KeyCode::Char(' '),
4758 "f1" => KeyCode::F(1),
4759 "f2" => KeyCode::F(2),
4760 "f3" => KeyCode::F(3),
4761 "f4" => KeyCode::F(4),
4762 "f5" => KeyCode::F(5),
4763 "f6" => KeyCode::F(6),
4764 "f7" => KeyCode::F(7),
4765 "f8" => KeyCode::F(8),
4766 "f9" => KeyCode::F(9),
4767 "f10" => KeyCode::F(10),
4768 "f11" => KeyCode::F(11),
4769 "f12" => KeyCode::F(12),
4770 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4771 _ => return None,
4772 };
4773 Some(rpi_tui::KeyCombo::new(code, modifiers))
4774}
4775
4776fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4777 let mut bindings = rpi_tui::Keybindings::new();
4778 let settings = crate::settings::load_settings().unwrap_or_default();
4779 let Some(overrides) = settings.keybindings else {
4780 rpi_tui::set_keybindings(bindings.clone());
4781 return Arc::new(bindings);
4782 };
4783 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4784 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4785 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4786 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4787 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4788 (
4789 "app.model.cycleForward",
4790 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4791 ),
4792 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4793 (
4794 "app.thinking.toggle",
4795 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4796 ),
4797 (
4798 "app.editor.external",
4799 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4800 ),
4801 (
4802 "app.thinking.cycle",
4803 rpi_tui::keybindings::keys::THINKING_CYCLE,
4804 ),
4805 (
4806 "app.clipboard.pasteImage",
4807 rpi_tui::keybindings::keys::PASTE_IMAGE,
4808 ),
4809 ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4810 ];
4811 for (name, id) in known {
4812 let Some(value) = overrides.get(*name) else {
4813 continue;
4814 };
4815 let values: Vec<String> = match value {
4816 serde_json::Value::String(value) => vec![value.clone()],
4817 serde_json::Value::Array(values) => values
4818 .iter()
4819 .filter_map(|v| v.as_str().map(str::to_string))
4820 .collect(),
4821 serde_json::Value::Null => Vec::new(),
4822 _ => continue,
4823 };
4824 let combos: Vec<_> = values
4825 .iter()
4826 .filter_map(|value| parse_configured_key(value))
4827 .collect();
4828 if values.is_empty() || !combos.is_empty() {
4829 bindings.set(id, combos);
4830 }
4831 }
4832 rpi_tui::set_keybindings(bindings.clone());
4833 Arc::new(bindings)
4834}
4835
4836fn keybinding_matches(
4837 bindings: &rpi_tui::Keybindings,
4838 event: &crossterm::event::KeyEvent,
4839 id: rpi_tui::KeybindingId,
4840) -> bool {
4841 if bindings.matches(event, id) {
4842 return true;
4843 }
4844 if event.code == KeyCode::BackTab {
4847 let normalized =
4848 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4849 bindings.matches(&normalized, id)
4850 } else {
4851 false
4852 }
4853}
4854
4855fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4859 let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4860 if keys.is_empty() {
4861 return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4862 }
4863 keys.iter()
4864 .map(|combo| combo.display())
4865 .collect::<Vec<_>>()
4866 .join("/")
4867}
4868
4869fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4870 last.is_some_and(|previous| {
4871 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4872 })
4873}
4874
4875const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4879
4880const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4883
4884fn enter_is_paste_burst(
4905 last_text_key_at: Option<std::time::Instant>,
4906 now: std::time::Instant,
4907 more_queued: bool,
4908) -> bool {
4909 if more_queued {
4910 return true;
4911 }
4912 last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4913}
4914
4915fn probe_paste_input(
4926 pending: Option<Event>,
4927 mut next_event: impl FnMut() -> Option<Event>,
4928) -> (bool, Option<Event>) {
4929 let mut pending = pending;
4930 let mut more_queued = pending.is_some();
4931 while !more_queued {
4932 match next_event() {
4933 Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4934 Some(ev) => {
4935 pending = Some(ev);
4936 more_queued = true;
4937 }
4938 None => break,
4939 }
4940 }
4941 (more_queued, pending)
4942}
4943
4944fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
4947 match last_text_key_at {
4948 Some(previous) => std::time::Instant::now()
4949 .duration_since(previous)
4950 .as_millis()
4951 .to_string(),
4952 None => "-".to_string(),
4953 }
4954}
4955
4956fn transcript_page_size(viewport_height: usize) -> i32 {
4957 viewport_height
4958 .saturating_sub(PAGE_SCROLL_OVERLAP)
4959 .max(1)
4960 .min(i32::MAX as usize) as i32
4961}
4962
4963fn should_dispatch_key(kind: KeyEventKind) -> bool {
4964 kind != KeyEventKind::Release
4965}
4966
4967fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4977 tui.stop(Default::default());
4981 eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4982 std::process::exit(130);
4983}
4984
4985fn format_tokens(n: i64) -> String {
4987 if n >= 1_000_000 {
4988 format!("{:.1}M", n as f64 / 1_000_000.0)
4989 } else if n >= 1_000 {
4990 format!("{:.1}K", n as f64 / 1_000.0)
4991 } else {
4992 n.to_string()
4993 }
4994}
4995
4996fn push_history(state: &Arc<TuiState>, text: &str) {
5000 let trimmed = text.trim().to_string();
5001 if trimmed.is_empty() {
5002 return;
5003 }
5004 let mut history = state.history.lock().unwrap();
5005 if history.first() == Some(&trimmed) {
5006 return;
5007 }
5008 history.insert(0, trimmed);
5009 history.truncate(HISTORY_LIMIT);
5010 *state.history_index.lock().unwrap() = -1;
5011 *state.history_draft.lock().unwrap() = None;
5012}
5013
5014fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5019 let history = state.history.lock().unwrap();
5020 if history.is_empty() {
5021 return;
5022 }
5023 let mut index = state.history_index.lock().unwrap();
5024 let new_index = *index - direction as isize;
5025 if new_index < -1 || new_index >= history.len() as isize {
5026 return;
5027 }
5028 if *index == -1 && new_index >= 0 {
5029 *state.history_draft.lock().unwrap() = Some(editor.get_text());
5031 }
5032 *index = new_index;
5033 if new_index == -1 {
5034 let draft = state.history_draft.lock().unwrap().take();
5036 match draft {
5037 Some(d) => {
5038 let len = d.len();
5039 editor.set_text(&d);
5040 editor.set_cursor(0, len);
5041 }
5042 None => editor.set_text(""),
5043 }
5044 } else {
5045 let text = history[new_index as usize].clone();
5046 let len = text.len();
5047 editor.set_text(&text);
5048 editor.set_cursor(0, len);
5049 }
5050}
5051
5052impl TuiState {
5053 fn begin_js_preparation(&self) -> CancellationToken {
5054 let cancellation = CancellationToken::new();
5055 if let Some(previous) = self
5056 .js_preparation_cancel
5057 .lock()
5058 .unwrap()
5059 .replace(cancellation.clone())
5060 {
5061 previous.cancel();
5062 }
5063 cancellation
5064 }
5065
5066 fn finish_js_preparation(&self) {
5067 self.js_preparation_cancel.lock().unwrap().take();
5068 }
5069
5070 fn cancel_js_preparation(&self) -> bool {
5071 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5072 if let Some(cancellation) = cancellation {
5073 cancellation.cancel();
5074 true
5075 } else {
5076 false
5077 }
5078 }
5079
5080 fn user_bash_running(&self) -> bool {
5082 self.user_bash_cancel.lock().unwrap().is_some()
5083 }
5084
5085 fn begin_user_bash(&self) -> CancellationToken {
5088 let cancellation = CancellationToken::new();
5089 if let Some(previous) = self
5090 .user_bash_cancel
5091 .lock()
5092 .unwrap()
5093 .replace(cancellation.clone())
5094 {
5095 previous.cancel();
5096 }
5097 cancellation
5098 }
5099
5100 fn finish_user_bash(&self) {
5103 self.user_bash_cancel.lock().unwrap().take();
5104 }
5105
5106 fn cancel_user_bash(&self) -> bool {
5110 let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5111 if let Some(cancellation) = cancellation {
5112 cancellation.cancel();
5113 true
5114 } else {
5115 false
5116 }
5117 }
5118
5119 fn set_status(&self, status: RunStatus) {
5120 *self.status.lock().unwrap() = status;
5121 self.apply_status(status);
5122 }
5123
5124 fn pending_container(&self) -> &Arc<Container> {
5127 &self.pending_container
5128 }
5129
5130 fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5136 {
5137 let mut current = self.pending_snapshot.lock().unwrap();
5138 if *current == messages {
5139 return;
5140 }
5141 *current = messages.clone();
5142 }
5143 let container = &self.pending_container;
5144 container.clear();
5145 if messages.is_empty() {
5146 return;
5147 }
5148 let colors = current_theme().colors;
5149 container.add_child(Arc::new(Spacer::new(1)));
5150 for text in &messages.steering {
5151 container.add_child(Arc::new(Text::new(
5152 colors.muted.fg(&format!("Steering: {text}")),
5153 1,
5154 0,
5155 )));
5156 }
5157 for text in &messages.follow_up {
5158 container.add_child(Arc::new(Text::new(
5159 colors.muted.fg(&format!("Follow-up: {text}")),
5160 1,
5161 0,
5162 )));
5163 }
5164 container.add_child(Arc::new(Text::new(
5165 colors.muted.fg(&format!(
5166 "↳ {} to edit all queued messages",
5167 self.dequeue_hint
5168 )),
5169 1,
5170 0,
5171 )));
5172 }
5173
5174 fn try_start_working(&self) -> bool {
5179 let mut status = self.status.lock().unwrap();
5180 if *status != RunStatus::Idle {
5181 return false;
5182 }
5183 *status = RunStatus::Working;
5184 drop(status);
5185 self.apply_status(RunStatus::Working);
5186 true
5187 }
5188
5189 fn apply_status(&self, status: RunStatus) {
5190 match status {
5191 RunStatus::Working => {
5192 self.footer.set_status("");
5196 if let Some(tui) = &self.tui {
5199 tui.set_title("🦀π rpi ⟳");
5200 }
5201 self.status_container.clear();
5202 if self.show_terminal_progress {
5205 self.editor.set_working(Some(WorkingState {
5206 frame: 0,
5207 started_at: std::time::Instant::now(),
5208 message: "Working…".to_string(),
5209 }));
5210 } else {
5211 self.editor.set_working(None);
5212 }
5213 }
5214 RunStatus::Aborting => {
5215 self.footer.set_status("Aborting…");
5216 self.loader.stop();
5219 self.status_container.clear();
5220 self.editor.set_working(None);
5221 }
5222 RunStatus::Idle => {
5223 self.footer.set_status("");
5224 if let Some(tui) = &self.tui {
5225 tui.set_title("🦀π rpi");
5226 }
5227 self.loader.stop();
5228 self.status_container.clear();
5229 self.editor.set_working(None);
5230 }
5231 }
5232 }
5233
5234 fn sync_extension_status(&self) -> bool {
5238 let revision = self.ext_status.revision();
5239 if self
5240 .ext_status_revision
5241 .load(std::sync::atomic::Ordering::SeqCst)
5242 == revision
5243 {
5244 return false;
5245 }
5246 self.ext_status_revision
5247 .store(revision, std::sync::atomic::Ordering::SeqCst);
5248 self.footer.set_extension_status(&self.ext_status.text());
5249 true
5250 }
5251
5252 fn sync_working_loader_with_bash(&self) {
5256 if *self.status.lock().unwrap() != RunStatus::Working {
5257 return;
5258 }
5259
5260 self.status_container.clear();
5265 }
5266
5267 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5268 *self.status.lock().unwrap() = RunStatus::Working;
5269 self.footer.set_status("");
5270 if let Some(tui) = &self.tui {
5271 tui.set_title("🦀π rpi ↻");
5272 }
5273 self.loader.stop();
5274 self.status_container.clear();
5275 self.status_container
5276 .add_child(Arc::new(StatusIndicator::retry(
5277 attempt,
5278 max_retries,
5279 std::time::Duration::from_millis(delay_ms),
5280 )));
5281 }
5282
5283 fn selector_open(&self) -> bool {
5285 self.active_selector.lock().unwrap().is_some()
5286 }
5287
5288 fn extension_editor_open(&self) -> bool {
5289 self.active_extension_editor.lock().unwrap().is_some()
5290 }
5291
5292 fn extension_input_open(&self) -> bool {
5293 self.active_extension_input.lock().unwrap().is_some()
5294 }
5295
5296 fn extension_dialog_open(&self) -> bool {
5297 self.extension_editor_open() || self.extension_input_open()
5298 }
5299
5300 fn set_hide_thinking(&self, hide: bool) {
5301 *self.hide_thinking.lock().unwrap() = hide;
5302 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5303 comp.set_hide_thinking(hide);
5304 }
5305 }
5306
5307 fn hide_thinking(&self) -> bool {
5308 *self.hide_thinking.lock().unwrap()
5309 }
5310
5311 fn toggle_thinking(&self) -> bool {
5312 let next = !self.hide_thinking();
5313 self.set_hide_thinking(next);
5314 next
5315 }
5316
5317 fn toggle_tool_outputs(&self) -> bool {
5318 let next = !*self.tool_outputs_expanded.lock().unwrap();
5319 *self.tool_outputs_expanded.lock().unwrap() = next;
5320 for comp in self.tool_components.lock().unwrap().values() {
5321 comp.set_expanded(next);
5322 }
5323 for comp in self.bash_components.lock().unwrap().values() {
5324 comp.set_expanded(next);
5325 }
5326 next
5327 }
5328
5329 fn current_model_id(&self) -> String {
5331 self.current_model_id.lock().unwrap().clone()
5332 }
5333
5334 fn set_current_model(&self, model: &rpi_ai::Model) {
5337 *self.current_model_id.lock().unwrap() = model.id.clone();
5338 self.footer.set_model(&short_model_name(&model.id));
5339 self.footer.set_context_window(model.context_window as i64);
5342 }
5343
5344 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5348 self.markdown_transformer.lock().unwrap().clone()
5349 }
5350
5351 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5360 *self.markdown_transformer.lock().unwrap() = transformer.clone();
5361 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5362 comp.set_markdown_transformer(transformer);
5363 }
5364 }
5365
5366 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5367 self.pending_images.lock().unwrap().push(image);
5368 }
5369
5370 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5371 std::mem::take(&mut *self.pending_images.lock().unwrap())
5372 }
5373}
5374
5375#[derive(Debug, PartialEq)]
5380struct TuiStartupSettings {
5381 editor_padding_x: usize,
5382 autocomplete_max_visible: usize,
5383 hide_thinking: bool,
5384 quiet_startup: bool,
5385 show_terminal_progress: bool,
5386 show_images: bool,
5388 cache_miss_notices: bool,
5390}
5391
5392fn preferred_project_setting<T>(
5393 project_settings: &[crate::settings::Settings],
5394 field: impl Fn(&crate::settings::Settings) -> Option<T>,
5395) -> Option<T> {
5396 project_settings.iter().find_map(field)
5397}
5398
5399fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5400 verbose || !quiet_startup
5401}
5402
5403fn git_only_update_resources(
5404 mut resources: crate::packages::PackageResources,
5405) -> crate::packages::PackageResources {
5406 resources
5407 .packages
5408 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5409 resources
5410}
5411
5412fn resolve_tui_startup_settings(
5416 global: &crate::settings::Settings,
5417 project_settings: &[crate::settings::Settings],
5418 project_trusted: bool,
5419) -> TuiStartupSettings {
5420 let project_settings = if project_trusted {
5421 project_settings
5422 } else {
5423 &[]
5424 };
5425 let editor_padding_x =
5426 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5427 .or(global.editor_padding_x)
5428 .unwrap_or(1)
5429 .min(16);
5430 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5431 settings.autocomplete_max_visible
5432 })
5433 .or(global.autocomplete_max_visible)
5434 .unwrap_or(5)
5435 .clamp(1, 20);
5436 let hide_thinking =
5437 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5438 .or(global.hide_thinking_block)
5439 .unwrap_or(false);
5440 let quiet_startup =
5441 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5442 .or(global.quiet_startup)
5443 .unwrap_or(false);
5444 let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5445 settings.show_terminal_progress()
5446 })
5447 .or_else(|| global.show_terminal_progress())
5448 .unwrap_or(true);
5449 let show_images =
5450 preferred_project_setting(project_settings, |settings| settings.show_images())
5451 .or_else(|| global.show_images())
5452 .unwrap_or(true);
5453 let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5454 settings.show_cache_miss_notices
5455 })
5456 .or(global.show_cache_miss_notices)
5457 .unwrap_or(false);
5458
5459 TuiStartupSettings {
5460 editor_padding_x,
5461 autocomplete_max_visible,
5462 hide_thinking,
5463 quiet_startup,
5464 show_terminal_progress,
5465 show_images,
5466 cache_miss_notices,
5467 }
5468}
5469
5470pub async fn interactive_tui(
5484 harness: &AgentHarness,
5485 event_rx: Option<broadcast::Receiver<AgentEvent>>,
5486 args: &Args,
5487 model_catalog: Vec<rpi_ai::Model>,
5488 initial: Option<String>,
5489 extra_messages: &[String],
5490 initial_images: Vec<rpi_ai::types::ImageContent>,
5491 theme: Option<&str>,
5492 no_themes: bool,
5493 reload_context: &crate::session::ReloadContext,
5494) -> i32 {
5495 let lane: Arc<dyn AgentLane> = harness.lane("main");
5496 let cwd = reload_context.cwd.clone();
5500 let saved_settings = crate::settings::load_settings().unwrap_or_default();
5501 let project_trusted = reload_context.project_trusted;
5502 let project_settings = if project_trusted {
5503 crate::settings::load_project_settings(&cwd)
5504 } else {
5505 Vec::new()
5506 };
5507 let tui_settings =
5508 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5509 let editor_padding_x = tui_settings.editor_padding_x;
5510 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5511 let mut hide_thinking = tui_settings.hide_thinking;
5512 let show_terminal_progress = tui_settings.show_terminal_progress;
5513 let show_images = tui_settings.show_images;
5514 let cache_miss_notices = tui_settings.cache_miss_notices;
5515 let quiet_startup = tui_settings.quiet_startup;
5516 let update_checks_enabled =
5517 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5518
5519 let session_values = rpi_harness::session::SessionValues::load(harness.session())
5526 .await
5527 .unwrap_or_default();
5528 if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5529 hide_thinking = persisted;
5530 }
5531
5532 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5536 let model_name = short_model_name(&lane_model_id);
5537
5538 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5542 if active_tool_names.is_empty() {
5546 active_tool_names = harness
5547 .get_tools()
5548 .await
5549 .unwrap_or_default()
5550 .into_iter()
5551 .map(|tool| tool.tool.schema().name.clone())
5552 .collect();
5553 }
5554 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5555 let skill_names: Vec<String> = resources_snapshot
5556 .skills
5557 .as_deref()
5558 .unwrap_or(&[])
5559 .iter()
5560 .map(|skill| skill.name.clone())
5561 .collect();
5562
5563 let package_resources = reload_context.package_resources.clone();
5565
5566 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5568
5569 let theme_manager = Arc::new(ThemeManager::new());
5573 if let Some(preset) = match theme {
5574 Some("light") => Some(ThemePreset::Light),
5575 Some("monochrome") => Some(ThemePreset::Monochrome),
5576 Some("dark") => Some(ThemePreset::Dark),
5577 _ => None,
5578 } {
5579 apply_theme_preset(preset);
5580 theme_manager.apply_preset(preset);
5581 } else if !no_themes {
5582 if let Some(name) = theme {
5583 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
5584 Ok(custom) => {
5585 rpi_tui::global_theme_manager().set(custom.clone());
5586 theme_manager.set(custom);
5587 }
5588 Err(error) => {
5589 eprintln!("warning: could not load package theme `{name}`: {error}");
5590 }
5591 }
5592 }
5593 }
5594
5595 let terminal = Box::new(ProcessTerminal::new());
5597 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5598 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
5599 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5604 ask_user_bridge.attach();
5605 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5606
5607 if let Some(js) = &reload_context.js_extension_session {
5608 if let Err(error) = js.install_ui_runtime(tui.clone()) {
5609 eprintln!("warning: could not enable JS custom UI bridge: {error}");
5610 } else if let Some(js_active) = js.active_tools() {
5611 let js_names = js.tool_names();
5615 let mut active = lane.get_active_tools().await.unwrap_or_default();
5616 active.retain(|name| {
5617 crate::session::tool_name_allowed(name, args)
5618 && !js_names.iter().any(|js_name| js_name == name)
5619 });
5620 active.extend(js_active.into_iter().filter(|name| {
5621 js_names.iter().any(|js_name| js_name == name)
5622 && crate::session::tool_name_allowed(name, args)
5623 }));
5624 active = crate::session::filter_active_tool_names(active, args);
5625 let _ = lane.set_active_tools(active).await;
5626 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5627 }
5628 }
5629
5630 let chat_container = Arc::new(Container::new());
5631 if let Some(js) = &reload_context.js_extension_session {
5632 let chat_notify = chat_container.clone();
5636 let tui_notify = tui.clone();
5637 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
5638 if action != "ui.notify" {
5639 return Err(format!("unsupported capability: {action}"));
5640 }
5641 let message = args
5642 .get("message")
5643 .and_then(serde_json::Value::as_str)
5644 .unwrap_or_default();
5645 if !message.is_empty() {
5646 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
5647 add_error_message(&chat_notify, message);
5648 } else {
5649 add_note_message(&chat_notify, message);
5650 }
5651 tui_notify.request_render(false);
5652 }
5653 Ok(serde_json::json!(true))
5654 })) {
5655 if args.verbose {
5656 eprintln!("warning: could not enable JS notification bridge: {error}");
5657 }
5658 }
5659 }
5660 if should_show_startup_listing(args.verbose, quiet_startup) {
5661 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5662 }
5663
5664 crate::extras::maybe_first_time_setup(&chat_container);
5670
5671 let initial_transformer = build_markdown_transformer(
5676 reload_context
5677 .extension_session
5678 .lock()
5679 .unwrap()
5680 .snapshot_arc(),
5681 );
5682 if launch_restores_history(args) {
5686 render_session_history(
5687 &harness,
5688 &chat_container,
5689 initial_transformer.clone(),
5690 Some(reload_context.extension_session.clone()),
5691 )
5692 .await;
5693 }
5694
5695 let document_container = Arc::new(Container::new());
5698 document_container.add_child(chat_container.clone());
5699 document_container.add_child(Arc::new(Spacer::new(1)));
5701
5702 let scroll_view = Arc::new(ScrollView::new(
5703 document_container.clone(),
5704 ScrollViewOptions {
5705 follow: FollowMode::End,
5706 primary: true,
5707 overscroll: OverscrollMode::Chain,
5708 scrollbar: ScrollbarMode::Hidden,
5712 ..Default::default()
5713 },
5714 ));
5715
5716 let keybindings = configured_keybindings();
5721 let editor = Arc::new(Editor::new(
5722 EditorOptions {
5723 padding_x: editor_padding_x,
5724 autocomplete_max_visible,
5725 ..Default::default()
5726 },
5727 EditorStyle::default(),
5728 keybindings.clone(),
5729 ));
5730
5731 {
5736 let editor_for_change = editor.clone();
5737 editor.on_change(Arc::new(move |text: &str| {
5738 let colors = current_theme().colors;
5739 let is_bash = text.trim_start().starts_with('!');
5740 editor_for_change.set_border_color(if is_bash {
5741 Some(colors.bash_mode)
5742 } else {
5743 None
5744 });
5745 }));
5746 }
5747
5748 let footer = Arc::new(FooterComponent::new());
5750 footer.set_model(&model_name);
5751 footer.set_cwd(&cwd.to_string_lossy());
5752 footer.set_git_branch(git_branch_for(&cwd).as_deref());
5753 if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5754 footer.set_context_window(m.context_window as i64);
5755 }
5756 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");
5757
5758 let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5761 let footer_ref = footer.clone();
5762 let cwd_ref = cwd.clone();
5763 crate::fs_watch::watch_path(head, move || {
5764 footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5765 })
5766 });
5767
5768 let status_container = Arc::new(Container::new());
5769 let loader = Arc::new(Loader::with_text("Working…"));
5770
5771 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5785 .prompt_templates
5786 .clone()
5787 .unwrap_or_default()
5788 .iter()
5789 .map(|t| SlashCommandEntry {
5790 name: format!("/{}", t.name),
5791 description: t
5792 .description
5793 .clone()
5794 .unwrap_or_else(|| "Expand prompt template".to_string()),
5795 })
5796 .collect();
5797 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5798 Arc::new(resources_snapshot);
5799 let mut command_registry = build_builtin_registry();
5805 register_extension_commands(
5806 &mut command_registry,
5807 reload_context.extension_session.clone(),
5808 );
5809 register_js_extension_commands(
5810 &mut command_registry,
5811 reload_context.js_extension_session.clone(),
5812 );
5813 let registry = Arc::new(command_registry);
5814 let mut all_slash_commands = registry.visible_entries();
5815 all_slash_commands.extend(template_slash_commands);
5816 let autocomplete = AutocompleteManager::new();
5817 {
5818 let mut combined = CombinedAutocompleteProvider::new();
5819 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5820 all_slash_commands,
5821 )));
5822 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5823 cwd.clone(),
5824 )));
5825 autocomplete.set_provider(Arc::new(combined));
5826 }
5827 let autocomplete_container = Arc::new(Container::new());
5828
5829 let tool_outputs_expanded = session_values
5830 .get_as::<bool>("display.tool_outputs_expanded")
5831 .unwrap_or(false);
5832 let state = Arc::new(TuiState {
5833 current_assistant: std::sync::Mutex::new(None),
5834 tool_components: std::sync::Mutex::new(HashMap::new()),
5835 bash_components: std::sync::Mutex::new(HashMap::new()),
5836 themes_enabled: !no_themes,
5837 hide_thinking: std::sync::Mutex::new(hide_thinking),
5838 tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5839 show_terminal_progress,
5840 status: std::sync::Mutex::new(RunStatus::Idle),
5841 ext_status: reload_context.ext_status.clone(),
5842 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
5843 js_preparation_cancel: std::sync::Mutex::new(None),
5844 user_bash_cancel: std::sync::Mutex::new(None),
5845 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5846 footer: footer.clone(),
5847 status_container: status_container.clone(),
5848 chat_container: chat_container.clone(),
5849 loader: loader.clone(),
5850 editor: editor.clone(),
5851 last_assistant_text: std::sync::Mutex::new(String::new()),
5852 active_selector: std::sync::Mutex::new(None),
5853 active_extension_editor: std::sync::Mutex::new(None),
5854 active_extension_input: std::sync::Mutex::new(None),
5855 active_extension_cancel: std::sync::Mutex::new(None),
5856 autocomplete,
5857 autocomplete_container: autocomplete_container.clone(),
5858 autocomplete_max_visible,
5859 pending_images: std::sync::Mutex::new(Vec::new()),
5860 theme_manager,
5861 tui: Some(tui.clone()),
5862 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5863 show_images: std::sync::Mutex::new(show_images),
5864 cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5865 history: std::sync::Mutex::new(Vec::new()),
5866 history_index: std::sync::Mutex::new(-1),
5867 history_draft: std::sync::Mutex::new(None),
5868 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5869 scoped_edit: std::sync::Mutex::new(None),
5870 markdown_transformer: std::sync::Mutex::new(initial_transformer),
5871 extension_session: reload_context.extension_session.clone(),
5872 search: Arc::new(AltScreenSearch::new()),
5873 search_bar: Arc::new(SearchBar::new()),
5874 pending_container: Arc::new(Container::new()),
5875 dequeue_hint: pending_dequeue_hint(&keybindings),
5876 pending_snapshot: std::sync::Mutex::new(
5877 rpi_harness::agent_harness::QueuedMessages::default(),
5878 ),
5879 selection_start: std::sync::Mutex::new(None),
5880 selection_end: std::sync::Mutex::new(None),
5881 });
5882
5883 let model_catalog_arc = Arc::new(model_catalog.clone());
5886 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5887
5888 let editor_container = Arc::new(Container::new());
5899 editor_container.add_child(editor.clone());
5900
5901 let dock = Arc::new(VStack::from_children(vec![
5902 StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5903 StackChild::Entry(StackEntry::new(status_container.clone())),
5904 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5905 StackChild::Entry(
5911 StackEntry::new(editor_container.clone())
5912 .shrink(0)
5913 .min_size(3),
5914 ),
5915 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5916 StackChild::Entry(StackEntry::new(footer.clone())),
5917 ]));
5918
5919 let root = VStack::from_children(vec![
5920 StackChild::Entry(
5921 StackEntry::new(scroll_view.clone())
5922 .basis(0)
5923 .grow(1)
5924 .shrink(1)
5925 .min_size(1),
5926 ),
5927 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5928 ]);
5929
5930 tui.set_layout_root(Some(Arc::new(root)));
5931 tui.set_focus(Some(editor.clone()));
5932 editor.set_focused(true);
5933
5934 let scoped_models = args
5950 .model
5951 .as_deref()
5952 .map(crate::agent_session::ScopedModel::parse)
5953 .into_iter()
5954 .collect();
5955 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5956 harness.clone(),
5957 model_catalog.clone(),
5958 scoped_models,
5959 cwd.clone(),
5960 ));
5961 let ctx = CommandContext {
5962 chat: chat_container.clone(),
5963 tui: tui.clone(),
5964 tx: tx.clone(),
5965 state: state.clone(),
5966 editor: editor.clone(),
5967 editor_container: editor_container.clone(),
5968 lane: lane.clone(),
5969 model_catalog: model_catalog_arc.clone(),
5970 lane_model_id: lane_model_id.clone(),
5971 cwd: cwd.clone(),
5972 package_resources: package_resources.clone(),
5973 resources: resources_arc.clone(),
5974 reload_context: Arc::new(reload_context.clone()),
5975 session: agent_session.clone(),
5976 };
5977
5978 if let Some(js) = &reload_context.js_extension_session {
5979 let bridge = js_dialog_bridge.clone();
5980 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5981 bridge.handle_runtime_request(action, args)
5982 })) {
5983 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5984 }
5985 }
5986
5987 let ctx_for_cb = ctx.clone();
5988 let registry_for_cb = registry.clone();
5989 editor.on_submit(Arc::new(move |text: &str| {
5990 let text = text.trim();
5991 if text.is_empty() {
5992 return;
5993 }
5994
5995 if text.starts_with('/') {
5996 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
5997 return;
5998 }
5999
6000 if let Some((command, exclude_from_context)) = parse_user_bash(text) {
6005 if ctx_for_cb.state.user_bash_running() {
6006 add_error_message(
6007 &ctx_for_cb.chat,
6008 "A bash command is already running. Press Esc to cancel it first.",
6009 );
6010 ctx_for_cb.tui.request_render(false);
6011 return;
6012 }
6013 start_user_bash(&ctx_for_cb, command, exclude_from_context);
6014 return;
6015 }
6016
6017 let run_status = *ctx_for_cb.state.status.lock().unwrap();
6018 if run_status != RunStatus::Idle {
6019 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
6020 let lane = ctx_for_cb.lane.clone();
6021 let chat = ctx_for_cb.chat.clone();
6022 let tui = ctx_for_cb.tui.clone();
6023 let state = ctx_for_cb.state.clone();
6024 tokio::spawn(async move {
6025 if let Err(error) = lane.steer(message).await {
6035 add_error_message(&chat, &format!("Could not queue message: {error}"));
6036 tui.render_now(false);
6037 return;
6038 }
6039 refresh_pending_messages(&state, &lane).await;
6042 tui.request_render(false);
6043 });
6044 ctx_for_cb.tui.request_render(false);
6050 return;
6051 }
6052
6053 if !ctx_for_cb.state.try_start_working() {
6054 return;
6055 }
6056
6057 add_user_message(&ctx_for_cb.chat, text);
6068 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6071 scroll.scroll_to_end();
6072 }
6073 ctx_for_cb.tui.request_render(false);
6074 push_history(&ctx_for_cb.state, text);
6077 if ctx_for_cb
6078 .tx
6079 .send(TuiMessage::UserInput(text.to_string()))
6080 .is_err()
6081 {
6082 ctx_for_cb.state.set_status(RunStatus::Idle);
6083 }
6084 }));
6085
6086 tui.start_render_scheduler();
6094 tui.start_readerless();
6095
6096 let mut update_check_handles = Vec::new();
6100 let rpi_chat = chat_container.clone();
6101 let rpi_tui = tui.clone();
6102 update_check_handles.push(tokio::spawn(async move {
6103 let report = crate::updates::check_rpi_startup().await;
6104 if !report.is_empty() {
6105 add_update_notices(&rpi_chat, &report);
6106 }
6107 rpi_tui.request_render(false);
6108 }));
6109
6110 if update_checks_enabled {
6111 let update_args = args.clone();
6112 let update_cwd = cwd.clone();
6113 let update_project_trusted = project_trusted;
6114 let chat = chat_container.clone();
6115 let tui = tui.clone();
6116 update_check_handles.push(tokio::spawn(async move {
6117 let resources = crate::session::package_resources_for_update_check(
6118 &update_args,
6119 &update_cwd,
6120 update_project_trusted,
6121 );
6122 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6123 {
6124 Ok(npm_command) => {
6125 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6126 Some(&resources),
6127 &npm_command,
6128 update_project_trusted.then_some(update_cwd.as_path()),
6129 )
6130 .await
6131 }
6132 Err(error) => {
6133 add_error_message(
6134 &chat,
6135 &format!("npm package update checks disabled: {error}"),
6136 );
6137 let git_resources = git_only_update_resources(resources);
6138 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6139 }
6140 };
6141 if !report.is_empty() {
6142 add_update_notices(&chat, &report);
6143 }
6144 tui.request_render(false);
6145 }));
6146 }
6147
6148 let drain_handle = if let Some(rx) = event_rx {
6150 let tui_drain = tui.clone();
6151 let state_drain = state.clone();
6152 let chat_drain = chat_container.clone();
6153 let lane_drain = lane.clone();
6154 Some(tokio::spawn(async move {
6155 drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6156 }))
6157 } else {
6158 None
6159 };
6160
6161 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6171 reload_context.mailbox.install(reload_sig_tx);
6172 let reload_tx = tx.clone();
6173 let reload_bridge_handle = tokio::spawn(async move {
6174 while reload_sig_rx.recv().await.is_some() {
6175 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6176 break; }
6178 }
6179 });
6180
6181 let tui_tick = tui.clone();
6186 let state_tick = state.clone();
6187 let tick_handle = tokio::spawn(async move {
6188 let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6197 rpi_tui::loader::SPINNER_FRAME_MS,
6198 ));
6199 interval.tick().await; loop {
6201 interval.tick().await;
6202 if state_tick.sync_extension_status() {
6205 tui_tick.request_render(false);
6206 }
6207 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6208 if working {
6209 let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
6217 let has_live_panel = {
6218 let tools = state_tick.tool_components.lock().unwrap();
6219 transcript_has_live_panel(bash_present, &tools)
6220 };
6221 if has_live_panel {
6222 tui_tick.request_render(false);
6223 } else {
6224 tui_tick.request_render_reusing_scroll_content();
6228 }
6229 }
6230 }
6231 });
6232
6233 let running = Arc::new(std::sync::Mutex::new(true));
6235 let running_key = running.clone();
6236 let tx_for_key = tx.clone();
6237 let tui_for_key = tui.clone();
6238 let editor_for_key = editor.clone();
6239 let scroll_for_key = scroll_view.clone();
6240 let lane_for_key = lane.clone();
6241 let state_for_key = state.clone();
6242 let model_catalog_for_key = model_catalog_arc.clone();
6243 let js_for_key = reload_context.js_extension_session.clone();
6244 let js_dialog_for_key = js_dialog_bridge.clone();
6245 let ask_user_for_key = ask_user_bridge.clone();
6246 let ctx_for_key = ctx.clone();
6250 let registry_for_key = registry.clone();
6251 let keybindings_for_key = keybindings.clone();
6252 let double_escape_action = crate::settings::load_settings()
6253 .ok()
6254 .and_then(|settings| settings.double_escape_action)
6255 .unwrap_or_else(|| "tree".to_string())
6256 .to_ascii_lowercase();
6257
6258 let key_handle = tokio::task::spawn_blocking(move || {
6259 let mut last_escape_time = None;
6260 let mut last_text_key_at: Option<std::time::Instant> = None;
6263 let mut pending_event: Option<Event> = None;
6267 if crate::key_trace::enabled() {
6268 crate::key_trace::note(&format!(
6269 "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6270 std::process::id(),
6271 std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6272 crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6273 ));
6274 }
6275 loop {
6276 if !*running_key.lock().unwrap() {
6277 break;
6278 }
6279 if !state_for_key.selector_open()
6280 && !state_for_key.extension_dialog_open()
6281 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6282 {
6283 if let Some(request) = js_dialog_for_key.take_pending() {
6284 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6285 } else if let Some(request) = ask_user_for_key.take_pending() {
6286 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6287 }
6288 }
6289 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6290 let ev = if let Some(ev) = pending_event.take() {
6291 ev
6292 } else {
6293 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6296 Ok(true) => {}
6297 Ok(false) => continue,
6298 Err(_) => {
6299 state_for_key.cancel_js_preparation();
6300 let _ = tx_for_key.send(TuiMessage::Exit);
6301 break;
6302 }
6303 }
6304 let Ok(ev) = crossterm::event::read() else {
6305 state_for_key.cancel_js_preparation();
6306 let _ = tx_for_key.send(TuiMessage::Exit);
6307 break;
6308 };
6309 ev
6310 };
6311 if let Event::Resize(_cols, _rows) = ev {
6316 tui_for_key.refresh_size();
6317 if let Some(js) = &js_for_key {
6318 if js.custom_active() {
6319 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6320 tui_for_key.request_render(false);
6321 }
6322 }
6323 continue;
6324 }
6325 if let Event::Mouse(m) = ev {
6329 use crossterm::event::MouseEventKind;
6330 match m.kind {
6331 MouseEventKind::ScrollUp => {
6332 let delta = -MOUSE_WHEEL_SCROLL_LINES;
6333 if scroll_for_key.scroll_by(delta) != delta {
6334 tui_for_key.request_render_reusing_scroll_content();
6335 }
6336 }
6337 MouseEventKind::ScrollDown => {
6338 let delta = MOUSE_WHEEL_SCROLL_LINES;
6339 if scroll_for_key.scroll_by(delta) != delta {
6340 tui_for_key.request_render_reusing_scroll_content();
6341 }
6342 }
6343 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6344 *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6348 *state_for_key.selection_end.lock().unwrap() = None;
6349 }
6350 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6351 if state_for_key.selection_start.lock().unwrap().is_some() {
6355 *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6356 }
6357 }
6358 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6359 if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6361 let end = (m.column, m.row);
6362
6363 let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6372 *state_for_key.selection_end.lock().unwrap() = Some(end);
6373
6374 let auto_copy = crate::settings::load_settings()
6376 .ok()
6377 .and_then(|s| s.fullscreen_copy_on_select)
6378 .unwrap_or(true);
6379
6380 if auto_copy && is_drag {
6381 if let Some(selected_text) =
6383 extract_selected_text(&state_for_key.chat_container, start, end)
6384 {
6385 if !selected_text.trim().is_empty() {
6386 let _ = copy_to_clipboard(&selected_text);
6387 }
6388 }
6389 }
6390 }
6391 *state_for_key.selection_start.lock().unwrap() = None;
6392 *state_for_key.selection_end.lock().unwrap() = None;
6393 }
6394 _ => {}
6395 }
6396 continue;
6397 }
6398 let Event::Key(key) = ev else {
6399 if let Event::Paste(text) = ev {
6400 crate::key_trace::note(&format!(
6401 "paste event ({} bytes, bracketed paste supported)",
6402 text.len()
6403 ));
6404 let candidate = text.trim().trim_matches(['\"', '\'']);
6405 let path = std::path::PathBuf::from(candidate);
6406 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6407 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6408 add_image_preview(&state_for_key.chat_container, &image);
6409 state_for_key.queue_image(image);
6410 add_note_message(
6411 &state_for_key.chat_container,
6412 "Dropped image attached to the next prompt.",
6413 );
6414 tui_for_key.request_render(false);
6415 continue;
6416 }
6417 }
6418 editor_for_key.insert(&text);
6419 refresh_autocomplete(&state_for_key, &editor_for_key);
6420 tui_for_key.request_render_reusing_scroll_content();
6421 }
6422 continue;
6423 };
6424 if !should_dispatch_key(key.kind) {
6429 continue;
6430 }
6431 crate::key_trace::key(&key, "");
6435
6436 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6442 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6443 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6444 if prompt_abort && state_for_key.cancel_js_preparation() {
6445 state_for_key.set_status(RunStatus::Aborting);
6446 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6447 close_extension_editor(
6448 &state_for_key,
6449 &ctx_for_key.editor_container,
6450 &editor_for_key,
6451 &tui_for_key,
6452 );
6453 }
6454 js_dialog_for_key.cancel_open_requests();
6455 tui_for_key.set_render_suspended(false);
6456 tui_for_key.request_render(false);
6457 continue;
6458 }
6459
6460 if let Some(js) = &js_for_key {
6461 if js.custom_active() {
6462 let visible = js.custom_accepts_input();
6463 let data = key_event_to_input(key);
6464 if !data.is_empty() {
6465 if visible {
6471 let _ = js.send_custom_input(&data);
6472 continue;
6473 }
6474 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6475 if consumed {
6479 tui_for_key.request_render_reusing_scroll_content();
6480 continue;
6481 }
6482 }
6483 }
6484 }
6485
6486 if key.modifiers == KeyModifiers::CONTROL
6489 && key.code == KeyCode::Char('c')
6490 && state_for_key.selector_open()
6491 {
6492 let selector = state_for_key
6493 .active_selector
6494 .lock()
6495 .unwrap()
6496 .clone()
6497 .expect("selector_open guaranteed Some")
6498 .0;
6499 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6500 tui_for_key.request_render_reusing_scroll_content();
6501 continue;
6502 }
6503
6504 if state_for_key.extension_dialog_open() {
6508 let cancel = key.code == KeyCode::Esc
6509 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6510 if cancel {
6511 if !run_extension_cancel(&state_for_key) {
6512 close_extension_editor(
6513 &state_for_key,
6514 &ctx_for_key.editor_container,
6515 &editor_for_key,
6516 &tui_for_key,
6517 );
6518 }
6519 } else if let Some(extension_editor) = state_for_key
6520 .active_extension_editor
6521 .lock()
6522 .unwrap()
6523 .clone()
6524 {
6525 extension_editor.handle_key(key);
6526 } else if let Some(extension_input) =
6527 state_for_key.active_extension_input.lock().unwrap().clone()
6528 {
6529 extension_input.handle_key(key);
6530 }
6531 tui_for_key.request_render_reusing_scroll_content();
6532 continue;
6533 }
6534
6535 if keybinding_matches(
6540 &keybindings_for_key,
6541 &key,
6542 rpi_tui::keybindings::keys::CLEAR,
6543 ) {
6544 if !state_for_key.selector_open() && editor_for_key.has_selection() {
6545 editor_for_key.copy_selection();
6546 continue;
6547 }
6548 let status = *state_for_key.status.lock().unwrap();
6549 match status {
6550 RunStatus::Working => {
6551 state_for_key.set_status(RunStatus::Aborting);
6552 ask_user_for_key.cancel_all();
6554 if !run_extension_cancel(&state_for_key)
6556 && state_for_key.extension_dialog_open()
6557 {
6558 close_extension_editor(
6559 &state_for_key,
6560 &ctx_for_key.editor_container,
6561 &editor_for_key,
6562 &tui_for_key,
6563 );
6564 }
6565 let lane = lane_for_key.clone();
6566 tokio::spawn(async move {
6567 let _ = lane.abort().await;
6568 });
6569 }
6570 RunStatus::Aborting => {
6574 if key.kind != KeyEventKind::Repeat {
6587 emergency_exit(&tui_for_key);
6588 }
6589 }
6590 RunStatus::Idle => {
6591 let _ = tx_for_key.send(TuiMessage::Exit);
6592 }
6593 }
6594 continue;
6595 }
6596
6597 if state_for_key.selector_open() {
6602 if key.code == KeyCode::Esc {
6608 let (selector, _kind) = state_for_key
6609 .active_selector
6610 .lock()
6611 .unwrap()
6612 .clone()
6613 .expect("selector_open guaranteed Some");
6614 selector.handle_key(key);
6615 continue;
6616 }
6617 let (selector, _kind) = state_for_key
6618 .active_selector
6619 .lock()
6620 .unwrap()
6621 .clone()
6622 .expect("selector_open guaranteed Some");
6623 selector.handle_key(key);
6624 tui_for_key.request_render_reusing_scroll_content();
6625 continue;
6626 }
6627
6628 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6633 let status = *state_for_key.status.lock().unwrap();
6634 match status {
6635 RunStatus::Working => {
6636 state_for_key.set_status(RunStatus::Aborting);
6637 ask_user_for_key.cancel_all();
6638 if !run_extension_cancel(&state_for_key)
6639 && state_for_key.extension_dialog_open()
6640 {
6641 close_extension_editor(
6642 &state_for_key,
6643 &ctx_for_key.editor_container,
6644 &editor_for_key,
6645 &tui_for_key,
6646 );
6647 }
6648 let lane = lane_for_key.clone();
6649 tokio::spawn(async move {
6650 let _ = lane.abort().await;
6651 });
6652 continue;
6653 }
6654 RunStatus::Aborting => continue,
6655 RunStatus::Idle => {}
6656 }
6657 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
6658 editor_for_key.handle_key(key);
6660 refresh_autocomplete(&state_for_key, &editor_for_key);
6661 tui_for_key.request_render_reusing_scroll_content();
6662 continue;
6663 }
6664 let _ = tx_for_key.send(TuiMessage::Exit);
6665 continue;
6666 }
6667
6668 if keybinding_matches(
6673 &keybindings_for_key,
6674 &key,
6675 rpi_tui::keybindings::keys::INTERRUPT,
6676 ) {
6677 let status = *state_for_key.status.lock().unwrap();
6678 if status == RunStatus::Working {
6679 state_for_key.set_status(RunStatus::Aborting);
6680 ask_user_for_key.cancel_all();
6681 if !run_extension_cancel(&state_for_key)
6682 && state_for_key.extension_dialog_open()
6683 {
6684 close_extension_editor(
6685 &state_for_key,
6686 &ctx_for_key.editor_container,
6687 &editor_for_key,
6688 &tui_for_key,
6689 );
6690 }
6691 let lane = lane_for_key.clone();
6692 tokio::spawn(async move {
6693 let _ = lane.abort().await;
6694 });
6695 continue;
6696 }
6697 if state_for_key.user_bash_running() {
6703 state_for_key.cancel_user_bash();
6704 tui_for_key.request_render(false);
6705 continue;
6706 }
6707 if status == RunStatus::Idle
6708 && editor_for_key.get_text().trim().is_empty()
6709 && double_escape_action != "none"
6710 {
6711 let now = std::time::Instant::now();
6712 if double_escape_trigger(last_escape_time, now) {
6713 last_escape_time = None;
6714 match double_escape_action.as_str() {
6715 "tree" => {
6716 let _ = tx_for_key.send(TuiMessage::OpenTree);
6717 }
6718 "fork" => {
6719 let _ = tx_for_key.send(TuiMessage::ForkSession);
6720 }
6721 _ => {}
6722 }
6723 } else {
6724 last_escape_time = Some(now);
6725 }
6726 }
6727 continue;
6728 }
6729
6730 if keybinding_matches(
6733 &keybindings_for_key,
6734 &key,
6735 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6736 ) {
6737 launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
6738 continue;
6739 }
6740
6741 if keybinding_matches(
6744 &keybindings_for_key,
6745 &key,
6746 rpi_tui::keybindings::keys::TOOLS_EXPAND,
6747 ) {
6748 let expanded = state_for_key.toggle_tool_outputs();
6749 let session = ctx_for_key.session.clone();
6751 tokio::spawn(async move {
6752 let _ = session
6753 .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6754 .await;
6755 });
6756 tui_for_key.request_render(false);
6757 continue;
6758 }
6759
6760 if keybinding_matches(
6762 &keybindings_for_key,
6763 &key,
6764 rpi_tui::keybindings::keys::THINKING_TOGGLE,
6765 ) {
6766 let hide = state_for_key.toggle_thinking();
6767 let session = ctx_for_key.session.clone();
6769 tokio::spawn(async move {
6770 let _ = session
6771 .set_value("display.hide_thinking", serde_json::json!(hide))
6772 .await;
6773 });
6774 tui_for_key.request_render(false);
6775 continue;
6776 }
6777
6778 if keybinding_matches(
6784 &keybindings_for_key,
6785 &key,
6786 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6787 ) {
6788 let current = state_for_key.current_model_id();
6789 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6792 if let Some(next) = cycle_next_model(&scope, ¤t) {
6793 state_for_key.set_current_model(&next);
6794 let lane = lane_for_key.clone();
6795 tokio::spawn(async move {
6796 let _ = lane.set_model(next).await;
6797 });
6798 tui_for_key.request_render_reusing_scroll_content();
6799 }
6800 continue;
6801 }
6802
6803 if keybinding_matches(
6806 &keybindings_for_key,
6807 &key,
6808 rpi_tui::keybindings::keys::THINKING_CYCLE,
6809 ) {
6810 let lane = lane_for_key.clone();
6811 let catalog = model_catalog_for_key.clone();
6812 let state = state_for_key.clone();
6813 tokio::spawn(async move {
6814 let Ok(current_model) = lane.get_model().await else {
6815 return;
6816 };
6817 let levels = catalog
6818 .iter()
6819 .find(|model| {
6820 model.provider == current_model.provider && model.id == current_model.id
6821 })
6822 .map(|model| model.supported_thinking_levels())
6823 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6824 if levels.is_empty() {
6825 return;
6826 }
6827 let current = lane
6828 .get_thinking_level()
6829 .await
6830 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6831 let next = levels
6832 .iter()
6833 .position(|level| *level == current)
6834 .map(|index| levels[(index + 1) % levels.len()])
6835 .unwrap_or(levels[0]);
6836 if lane.set_thinking_level(next).await.is_ok() {
6837 state
6838 .footer
6839 .set_thinking_level(Some(thinking_level_name(next)));
6840 state.tui.as_ref().map(|tui| tui.request_render(false));
6841 }
6842 });
6843 continue;
6844 }
6845
6846 if keybinding_matches(
6856 &keybindings_for_key,
6857 &key,
6858 rpi_tui::keybindings::keys::PASTE_IMAGE,
6859 ) {
6860 match read_clipboard_image() {
6861 Ok(Some(image)) => {
6862 add_image_preview(&state_for_key.chat_container, &image);
6863 state_for_key.queue_image(image);
6864 add_note_message(
6865 &state_for_key.chat_container,
6866 "Clipboard image attached to the next prompt.",
6867 );
6868 tui_for_key.request_render(false);
6869 continue;
6870 }
6871 Ok(None) | Err(_) => {}
6872 }
6873 if cfg!(windows) {
6884 if let Some(text) = read_clipboard_text() {
6885 if !text.is_empty() {
6886 editor_for_key.insert(&text);
6887 refresh_autocomplete(&state_for_key, &editor_for_key);
6888 tui_for_key.request_render_reusing_scroll_content();
6889 continue;
6890 }
6891 }
6892 }
6893 }
6894
6895 if key
6897 .modifiers
6898 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6899 && key.code == KeyCode::Char('F')
6900 {
6901 state_for_key.search.activate();
6902 state_for_key.search_bar.set_visible(true);
6903 state_for_key.search_bar.set_query("");
6904 state_for_key.search_bar.set_match_info(0, 0);
6905 tui_for_key.request_render(false);
6906 continue;
6907 }
6908
6909 if state_for_key.search.is_active() {
6911 let search_bar = state_for_key.search_bar.clone();
6912 match key.code {
6913 KeyCode::Esc => {
6914 state_for_key.search.deactivate();
6915 search_bar.set_visible(false);
6916 tui_for_key.request_render(false);
6917 continue;
6918 }
6919 KeyCode::Enter => {
6920 if key.modifiers.contains(KeyModifiers::SHIFT) {
6921 state_for_key.search.previous_match();
6922 } else {
6923 state_for_key.search.next_match();
6924 }
6925 let match_index = state_for_key.search.get_match_index();
6927 let match_count = state_for_key.search.get_match_count();
6928 search_bar.set_match_info(match_index, match_count);
6929 tui_for_key.request_render(false);
6930 continue;
6931 }
6932 KeyCode::Backspace => {
6933 state_for_key.search.backspace();
6934 let lines = collect_transcript_lines(&state_for_key.chat_container);
6936 state_for_key.search.find_matches(&lines);
6937 let query = state_for_key.search.get_query();
6939 let match_count = state_for_key.search.get_match_count();
6940 search_bar.set_query(&query);
6941 search_bar.set_match_info(0, match_count);
6942 tui_for_key.request_render(false);
6943 continue;
6944 }
6945 KeyCode::Char(c) => {
6946 state_for_key.search.append_char(c);
6947 let lines = collect_transcript_lines(&state_for_key.chat_container);
6949 state_for_key.search.find_matches(&lines);
6950 let query = state_for_key.search.get_query();
6952 let match_count = state_for_key.search.get_match_count();
6953 search_bar.set_query(&query);
6954 search_bar.set_match_info(0, match_count);
6955 tui_for_key.request_render(false);
6956 continue;
6957 }
6958 _ => continue,
6959 }
6960 }
6961
6962 if keybinding_matches(
6966 &keybindings_for_key,
6967 &key,
6968 rpi_tui::keybindings::keys::MODEL_SELECT,
6969 ) {
6970 if let Some(cmd) = registry_for_key.find("/model") {
6971 cmd.execute(&ctx_for_key, "");
6972 }
6973 continue;
6974 }
6975
6976 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6978 if accept_top_suggestion(&state_for_key, &editor_for_key) {
6979 tui_for_key.request_render_reusing_scroll_content();
6980 }
6981 continue;
6982 }
6983
6984 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6988 let delta = -transcript_page_size(scroll_for_key.viewport_height());
6989 if scroll_for_key.scroll_by(delta) != delta {
6990 tui_for_key.request_render_reusing_scroll_content();
6991 }
6992 continue;
6993 }
6994 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6995 let delta = transcript_page_size(scroll_for_key.viewport_height());
6996 if scroll_for_key.scroll_by(delta) != delta {
6997 tui_for_key.request_render_reusing_scroll_content();
6998 }
6999 continue;
7000 }
7001 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
7002 scroll_for_key.scroll_to_start();
7003 tui_for_key.request_render_reusing_scroll_content();
7004 continue;
7005 }
7006 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
7007 scroll_for_key.scroll_to_end();
7008 tui_for_key.request_render_reusing_scroll_content();
7009 continue;
7010 }
7011
7012 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
7021 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7022 if editor_for_key.get_text().is_empty() || browsing {
7023 navigate_history(&state_for_key, &editor_for_key, -1);
7024 tui_for_key.request_render_reusing_scroll_content();
7025 continue;
7026 }
7027 }
7028 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
7029 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7030 if editor_for_key.get_text().is_empty() || browsing {
7031 navigate_history(&state_for_key, &editor_for_key, 1);
7032 tui_for_key.request_render_reusing_scroll_content();
7033 continue;
7034 }
7035 }
7036
7037 if keybinding_matches(
7042 &keybindings_for_key,
7043 &key,
7044 rpi_tui::keybindings::keys::DEQUEUE,
7045 ) {
7046 tokio::spawn(restore_queued_messages_to_editor(
7055 lane_for_key.clone(),
7056 editor_for_key.clone(),
7057 state_for_key.clone(),
7058 tui_for_key.clone(),
7059 ));
7060 continue;
7061 }
7062
7063 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
7067 let prompt = editor_for_key.get_text().trim().to_string();
7068 if prompt.is_empty() {
7069 continue;
7070 }
7071 editor_for_key.clear();
7072 let status = *state_for_key.status.lock().unwrap();
7073 if status == RunStatus::Idle {
7074 if state_for_key.try_start_working() {
7075 add_user_message(&state_for_key.chat_container, &prompt);
7079 push_history(&state_for_key, &prompt);
7080 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7081 }
7082 } else {
7083 let message = AgentMessage::User(UserMessage::new(prompt, 0));
7087 let lane = lane_for_key.clone();
7088 let chat = state_for_key.chat_container.clone();
7089 let tui = tui_for_key.clone();
7090 let state = state_for_key.clone();
7091 tokio::spawn(async move {
7092 if let Err(error) = lane.follow_up(message).await {
7093 add_error_message(&chat, &format!("Could not queue message: {error}"));
7094 tui.request_render(false);
7095 return;
7096 }
7097 refresh_pending_messages(&state, &lane).await;
7098 tui.request_render(false);
7099 });
7100 }
7101 tui_for_key.request_render(false);
7102 continue;
7103 }
7104
7105 if cfg!(windows)
7122 && key.modifiers.is_empty()
7123 && matches!(
7124 key.code,
7125 KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7126 )
7127 {
7128 let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7135 if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7136 crossterm::event::read().ok()
7137 } else {
7138 None
7139 }
7140 });
7141 pending_event = stashed;
7142 if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7143 crate::key_trace::note(&format!(
7144 "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7145 gap_ms(last_text_key_at),
7146 ));
7147 editor_for_key.insert("\n");
7150 last_text_key_at = Some(std::time::Instant::now());
7151 refresh_autocomplete(&state_for_key, &editor_for_key);
7152 tui_for_key.request_render_reusing_scroll_content();
7153 continue;
7154 }
7155 crate::key_trace::note(&format!(
7156 "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7157 gap_ms(last_text_key_at),
7158 ));
7159 last_text_key_at = None;
7162 } else if matches!(
7163 key.code,
7164 KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7165 ) && !key
7166 .modifiers
7167 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7168 {
7169 last_text_key_at = Some(std::time::Instant::now());
7172 } else {
7173 last_text_key_at = None;
7176 }
7177
7178 editor_for_key.handle_key(key);
7184 refresh_autocomplete(&state_for_key, &editor_for_key);
7185 tui_for_key.request_render_reusing_scroll_content();
7186 }
7187 });
7188
7189 if let Some(reason) = crate::session::dispatch_session_event_async(
7195 reload_context,
7196 rpi_plugin_sdk::EventTag::SessionStart,
7197 )
7198 .await
7199 {
7200 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7201 }
7202
7203 let mut prompts: Vec<String> = Vec::new();
7205 if let Some(init) = initial {
7206 prompts.push(init);
7207 }
7208 for m in extra_messages {
7209 prompts.push(m.clone());
7210 }
7211 if prompts.is_empty() && lane.has_pending_resume().await {
7218 run_prompt_streaming(
7219 &lane,
7220 "",
7221 true,
7222 &tui,
7223 &state,
7224 drain_handle.is_some(),
7225 reload_context.js_extension_session.as_ref(),
7226 &js_dialog_bridge,
7227 args,
7228 Vec::new(),
7229 )
7230 .await;
7231 }
7232
7233 let mut images = initial_images;
7234 for prompt in prompts {
7235 if !*running.lock().unwrap() {
7236 break;
7237 }
7238 add_user_message(&chat_container, &prompt);
7242 tui.request_render(false);
7243 run_prompt_streaming(
7244 &lane,
7245 &prompt,
7246 false,
7247 &tui,
7248 &state,
7249 drain_handle.is_some(),
7250 reload_context.js_extension_session.as_ref(),
7251 &js_dialog_bridge,
7252 args,
7253 std::mem::take(&mut images),
7254 )
7255 .await;
7256 }
7257
7258 loop {
7260 if !*running.lock().unwrap() {
7261 break;
7262 }
7263 match rx.recv().await {
7264 Some(TuiMessage::UserInput(prompt)) => {
7265 editor.clear();
7270 let prompt_images = state.take_pending_images();
7271 if !prompt_images.is_empty() {
7272 add_note_message(
7273 &chat_container,
7274 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7275 );
7276 }
7277 run_prompt_streaming(
7278 &lane,
7279 &prompt,
7280 false,
7281 &tui,
7282 &state,
7283 drain_handle.is_some(),
7284 reload_context.js_extension_session.as_ref(),
7285 &js_dialog_bridge,
7286 args,
7287 prompt_images,
7288 )
7289 .await;
7290 }
7291 Some(TuiMessage::ExternalEditorResult(result)) => {
7292 match result {
7293 Ok(text) => {
7294 let cursor = text.chars().count();
7295 editor.set_text(&text);
7296 editor.set_cursor(0, cursor);
7297 add_note_message(&chat_container, "Draft updated from external editor.");
7298 }
7299 Err(error) => add_error_message(&chat_container, &error),
7300 }
7301 tui.request_render(false);
7302 }
7303 Some(TuiMessage::UserBashFinished(report)) => {
7304 state.finish_user_bash();
7307 let message = rpi_harness::messages::bash_execution_message(
7308 report.command,
7309 report.output,
7310 report.exit_code,
7311 report.cancelled,
7312 report.truncated,
7313 report.full_output_path,
7314 Some(report.exclude_from_context),
7315 now_ms(),
7316 );
7317 if *state.status.lock().unwrap() == RunStatus::Idle {
7321 if let Err(error) = lane.append_message(message).await {
7322 add_error_message(
7323 &chat_container,
7324 &format!("Could not record bash result: {error}"),
7325 );
7326 tui.request_render(false);
7327 }
7328 } else {
7329 state.pending_bash_messages.lock().unwrap().push(message);
7330 }
7331 }
7332 Some(TuiMessage::OpenTree) => {
7333 if *state.status.lock().unwrap() != RunStatus::Idle {
7334 add_note_message(
7335 &chat_container,
7336 "Wait for the current run to finish before opening the tree.",
7337 );
7338 tui.request_render(false);
7339 } else {
7340 open_tree_selector(
7341 &harness,
7342 &state,
7343 &editor_container,
7344 &editor,
7345 &tui,
7346 &chat_container,
7347 &tx,
7348 )
7349 .await;
7350 }
7351 }
7352 Some(TuiMessage::NavigateTree(entry_id)) => {
7353 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7354 Ok(result) => match result.outcome {
7355 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7356 chat_container.clear();
7357 add_welcome_message(&chat_container);
7358 render_session_history(
7359 &harness,
7360 &chat_container,
7361 state.markdown_transformer(),
7362 Some(state.extension_session.clone()),
7363 )
7364 .await;
7365 add_note_message(
7366 &chat_container,
7367 "Moved to the selected session entry.",
7368 );
7369 }
7370 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7371 add_error_message(&chat_container, &error.message);
7372 }
7373 _ => add_note_message(
7374 &chat_container,
7375 "The selected entry could not be opened.",
7376 ),
7377 },
7378 Err(error) => add_error_message(
7379 &chat_container,
7380 &format!("Could not navigate session tree: {error}"),
7381 ),
7382 }
7383 tui.request_render(false);
7384 }
7385 Some(TuiMessage::ClearChat) => {
7386 chat_container.clear();
7387 add_welcome_message(&chat_container);
7388 tui.request_render(false);
7389 }
7390 Some(TuiMessage::Compact) => {
7391 run_compact(&lane, &tui, &state).await;
7392 }
7393 Some(TuiMessage::Copy) => {
7394 copy_last_assistant(&state, &chat_container);
7395 tui.request_render(false);
7396 }
7397 Some(TuiMessage::Exit) => {
7398 *running.lock().unwrap() = false;
7399 break;
7400 }
7401 Some(TuiMessage::SwitchSession(id)) => {
7402 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7403 tui.request_render(false);
7404 }
7405 Some(TuiMessage::ImportSession(path)) => {
7406 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7407 tui.request_render(false);
7408 }
7409 Some(TuiMessage::ShareSession) => {
7410 share_session(&harness, &chat_container).await;
7411 tui.request_render(false);
7412 }
7413 Some(TuiMessage::SetSessionName(name)) => {
7414 let outcome = harness.session().set_name(Some(&name)).await;
7415 match outcome {
7416 Ok(_) => add_note_message(
7417 &chat_container,
7418 &format!("Session renamed to \"{name}\"."),
7419 ),
7420 Err(e) => add_error_message(
7421 &chat_container,
7422 &format!("Could not rename session: {e}"),
7423 ),
7424 }
7425 tui.request_render(false);
7426 }
7427 Some(TuiMessage::ExportSession) => {
7428 let file_name = agent_session
7429 .default_export_file_name(crate::export::ExportFormat::Markdown)
7430 .await;
7431 let path = cwd.join(&file_name);
7432 match agent_session.export_to_markdown(&path).await {
7433 Ok(_) => add_note_message(
7434 &chat_container,
7435 &format!("Exported session to {}", path.display()),
7436 ),
7437 Err(e) => {
7438 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7439 }
7440 }
7441 tui.request_render(false);
7442 }
7443 Some(TuiMessage::ExportSessionWithFormat(format)) => {
7444 let file_name = agent_session.default_export_file_name(format.clone()).await;
7445 let path = cwd.join(&file_name);
7446 match agent_session.export(format, &path).await {
7447 Ok(_) => add_note_message(
7448 &chat_container,
7449 &format!("Exported session to {}", path.display()),
7450 ),
7451 Err(e) => {
7452 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7453 }
7454 }
7455 tui.request_render(false);
7456 }
7457 Some(TuiMessage::ShowUsage) => {
7458 let stats = agent_session.usage_stats().await.ok();
7459 let total = agent_session.total_usage().await.ok();
7460 let snapshot = agent_session.snapshot().await.ok();
7461 match stats {
7462 Some(stats) => {
7463 let mut lines = vec![
7464 "## Usage".to_string(),
7465 String::new(),
7466 format!("- messages: {}", stats.message_count),
7467 format!("- total tokens: {}", stats.total_tokens),
7468 format!("- cached tokens: {}", stats.cached_tokens),
7469 format!("- uncached tokens: {}", stats.uncached_tokens),
7470 format!("- cost: ${:.4}", stats.cost_total),
7471 ];
7472 match stats.cache_hit_ratio() {
7473 Some(ratio) => {
7474 lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7475 }
7476 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7477 }
7478 if let Some(total) = total {
7479 lines.push(String::new());
7480 lines.push(format!(
7481 "- last cumulative input/output: {} / {}",
7482 total.input, total.output
7483 ));
7484 }
7485 if let Some(snapshot) = snapshot {
7486 lines.push(String::new());
7487 lines.push(format!("- lane: `{}`", snapshot.lane));
7488 lines.push(format!(
7489 "- leaf: `{}`",
7490 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7491 ));
7492 if let Some(active) = &snapshot.active {
7493 lines.push(format!(
7494 "- active operation: {} (`{}`)",
7495 active.kind.as_str(),
7496 active.run_id
7497 ));
7498 }
7499 }
7500 add_note_message(&chat_container, &lines.join("\n"));
7501 }
7502 None => {
7503 add_error_message(&chat_container, "Could not read session usage stats.")
7504 }
7505 }
7506 tui.request_render(false);
7507 }
7508 Some(TuiMessage::ForkSession) => {
7509 fork_session(&harness, &cwd, &chat_container, &state).await;
7510 tui.request_render(false);
7511 }
7512 Some(TuiMessage::ReloadExtensions) => {
7513 let reload_ctx = ctx.reload_context.clone();
7520 add_note_message(&chat_container, "Reloading extensions + resources…");
7521 tui.request_render(false);
7522 let outcome =
7523 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
7524 let fresh_transformer = build_markdown_transformer(
7533 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
7534 );
7535 state.set_markdown_transformer_with_reinstall(fresh_transformer);
7536 if outcome.had_errors {
7537 add_error_message(&chat_container, &outcome.summary);
7541 } else if outcome.had_warnings {
7542 add_note_message(
7547 &chat_container,
7548 &format!(
7549 "{} (with warnings — see stderr for details).",
7550 outcome.summary
7551 ),
7552 );
7553 } else {
7554 add_note_message(&chat_container, &outcome.summary);
7555 }
7556 tui.request_render(false);
7557 }
7558 None => break,
7559 }
7560 }
7561
7562 js_dialog_bridge.cancel_all();
7566 ask_user_bridge.shutdown();
7567 *running.lock().unwrap() = false;
7568 for handle in update_check_handles {
7569 handle.abort();
7570 let _ = handle.await;
7571 }
7572 if let Some(js) = &reload_context.js_extension_session {
7576 js.shutdown();
7577 }
7578 let _ = key_handle.await;
7581 tick_handle.abort();
7582 if let Some(handle) = drain_handle {
7583 handle.abort();
7584 }
7585 reload_context.mailbox.clear();
7589 reload_bridge_handle.abort();
7590 tui.stop(Default::default());
7591
7592 let session_id = harness.session().storage().metadata().id;
7596 if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7597 println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7598 }
7599 println!("\nGoodbye!");
7600 let _ = args;
7601
7602 0
7603}
7604
7605async fn ensure_js_runtime_before_prompt(
7615 js: Option<&crate::js_extensions::JsExtensionSession>,
7616 lane: &Arc<dyn AgentLane>,
7617 state: &Arc<TuiState>,
7618 dialog_bridge: &JsDialogBridge,
7619 args: &Args,
7620) -> bool {
7621 let Some(js) = js else {
7622 return true;
7623 };
7624 let cancellation = state.begin_js_preparation();
7625 let worker_cancellation = cancellation.clone();
7626 let js_for_start = js.clone();
7627 let mut worker = tokio::task::spawn_blocking(move || {
7628 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
7629 });
7630 let result = tokio::select! {
7631 result = &mut worker => result,
7632 _ = cancellation.cancelled() => {
7633 dialog_bridge.cancel_open_requests();
7634 let js_for_cancel = js.clone();
7635 let _ = tokio::task::spawn_blocking(move || {
7636 js_for_cancel.cancel_prompt_preparation();
7637 }).await;
7638 worker.await
7639 }
7640 };
7641 let was_cancelled = cancellation.is_cancelled();
7642 if was_cancelled {
7643 dialog_bridge.cancel_open_requests();
7644 let js_for_cancel = js.clone();
7645 let _ = tokio::task::spawn_blocking(move || {
7646 js_for_cancel.cancel_prompt_preparation();
7647 })
7648 .await;
7649 state.finish_js_preparation();
7650 dialog_bridge.reopen();
7651 return false;
7652 }
7653 state.finish_js_preparation();
7654 match result {
7655 Ok(Ok(())) => {
7656 if let Some(js_active) = js.active_tools() {
7660 let js_names = js.tool_names();
7661 let mut active = lane.get_active_tools().await.unwrap_or_default();
7662 active.retain(|name| {
7663 crate::session::tool_name_allowed(name, args)
7664 && !js_names.iter().any(|js_name| js_name == name)
7665 });
7666 active.extend(js_active.into_iter().filter(|name| {
7667 js_names.iter().any(|js_name| js_name == name)
7668 && crate::session::tool_name_allowed(name, args)
7669 }));
7670 active = crate::session::filter_active_tool_names(active, args);
7671 let _ = lane.set_active_tools(active).await;
7672 }
7673 }
7674 Ok(Err(error)) => {
7675 if args.verbose {
7676 eprintln!("warning: could not start JS extension runtime: {error}");
7677 }
7678 }
7679 Err(error) => {
7680 if args.verbose {
7681 eprintln!("warning: JS extension runtime worker failed: {error}");
7682 }
7683 }
7684 }
7685 true
7686}
7687
7688fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7689 std::thread::spawn(move || {
7690 let file = match tempfile::Builder::new()
7691 .prefix("rpi-draft-")
7692 .suffix(".md")
7693 .tempfile()
7694 {
7695 Ok(file) => file,
7696 Err(error) => {
7697 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7698 "Could not create editor file: {error}"
7699 ))));
7700 return;
7701 }
7702 };
7703 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7704 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7705 "Could not write editor file: {error}"
7706 ))));
7707 return;
7708 }
7709 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7710 .ok()
7711 .filter(|value| !value.trim().is_empty())
7712 .or_else(|| std::env::var("VISUAL").ok())
7713 .or_else(|| std::env::var("EDITOR").ok())
7714 .unwrap_or_else(|| {
7715 if cfg!(windows) {
7716 "notepad".to_string()
7717 } else {
7718 "nano".to_string()
7719 }
7720 });
7721 let status = std::process::Command::new(&editor)
7722 .arg(file.path())
7723 .status();
7724 let result = match status {
7725 Ok(status) if status.success() => std::fs::read_to_string(file.path())
7726 .map_err(|error| format!("Could not read editor file: {error}")),
7727 Ok(status) => Err(format!("External editor exited with {status}")),
7728 Err(error) => Err(format!(
7729 "Could not launch external editor `{editor}`: {error}"
7730 )),
7731 };
7732 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7733 });
7734}
7735
7736fn reconcile_streamed_assistant_completion(
7739 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
7740 last_assistant_text: &Mutex<String>,
7741 final_message: Option<&AssistantMessage>,
7742) {
7743 let final_blocks = final_message.map(assistant_blocks);
7744 let final_text = final_message.map(assistant_text);
7745 let component = current_assistant.lock().unwrap().take();
7746
7747 if let Some(component) = component {
7748 if let Some(blocks) = final_blocks.as_deref() {
7749 component.update_blocks(blocks);
7750 }
7751 component.set_streaming(false);
7752 }
7753
7754 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7755 *last_assistant_text.lock().unwrap() = text;
7756 }
7757}
7758
7759async fn run_prompt_streaming(
7764 lane: &Arc<dyn AgentLane>,
7765 prompt: &str,
7766 resume: bool,
7767 tui: &Arc<TuiAltScreen>,
7768 state: &Arc<TuiState>,
7769 streaming: bool,
7770 js: Option<&crate::js_extensions::JsExtensionSession>,
7771 dialog_bridge: &JsDialogBridge,
7772 args: &Args,
7773 images: Vec<rpi_ai::types::ImageContent>,
7774) {
7775 state.set_status(RunStatus::Working);
7784 tui.request_render(false);
7785 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
7786 state.set_status(RunStatus::Idle);
7787 tui.request_render(false);
7788 return;
7789 }
7790
7791 let outcome = if resume {
7792 match lane.resume_pending().await {
7795 Ok(Some(result)) => Ok(result),
7796 Ok(None) => {
7799 state.set_status(RunStatus::Idle);
7800 tui.request_render(false);
7801 return;
7802 }
7803 Err(error) => Err(error),
7804 }
7805 } else {
7806 lane.prompt_text(prompt, images).await
7807 };
7808
7809 if streaming {
7810 let final_message = match &outcome {
7815 Ok(result) => match &result.outcome {
7816 HarnessRunOutcome::Completed { final_message, .. }
7817 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7818 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7819 HarnessRunOutcome::Suspended { .. } => None,
7820 },
7821 Err(_) => None,
7822 };
7823 reconcile_streamed_assistant_completion(
7824 &state.current_assistant,
7825 &state.last_assistant_text,
7826 final_message,
7827 );
7828 }
7829
7830 state.set_status(RunStatus::Idle);
7831 refresh_pending_messages(state, lane).await;
7835
7836 if let Ok(result) = &outcome {
7841 let final_message: Option<&AssistantMessage> = match &result.outcome {
7842 HarnessRunOutcome::Completed { final_message, .. }
7843 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7844 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7845 HarnessRunOutcome::Suspended { .. } => None,
7846 };
7847 if let Some(a) = final_message {
7848 record_context_usage(&state.footer, a);
7849 }
7850 }
7851
7852 match outcome {
7853 Ok(result) => match &result.outcome {
7854 HarnessRunOutcome::Failed {
7855 error,
7856 final_message,
7857 ..
7858 } => {
7859 let already_rendered = streaming && final_message.is_some();
7867 if !already_rendered {
7868 let msg = final_message
7869 .as_ref()
7870 .and_then(|m| m.error_message.clone())
7871 .unwrap_or_else(|| format!("{error:?}"));
7872 add_error_message(&state.chat_container, &msg);
7873 }
7874 }
7875 HarnessRunOutcome::Suspended { .. } => {
7876 add_error_message(
7877 &state.chat_container,
7878 "Run suspended (deferred) — resume is not supported in v1.",
7879 );
7880 }
7881 HarnessRunOutcome::Aborted { final_message, .. } => {
7882 if !streaming {
7885 add_error_message(&state.chat_container, "Request aborted.");
7886 let _ = final_message; }
7888 }
7889 HarnessRunOutcome::Completed { final_message, .. } => {
7890 if !streaming {
7891 let text = assistant_text(final_message);
7892 if !text.is_empty() {
7893 add_assistant_message_blocking(
7894 &state.chat_container,
7895 &text,
7896 state.markdown_transformer(),
7897 );
7898 *state.last_assistant_text.lock().unwrap() = text;
7899 }
7900 }
7901 }
7902 },
7903 Err(e) => {
7904 add_error_message(&state.chat_container, &e.to_string());
7905 }
7906 }
7907
7908 tui.request_render(false);
7909}
7910
7911async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7916 state.set_status(RunStatus::Working);
7917 tui.request_render(false);
7918 match lane.compact(None).await {
7919 Ok(_) => {
7920 add_note_message(&state.chat_container, "Conversation compacted.");
7921 state.footer.set_context_tokens(None);
7925 }
7926 Err(e) => {
7927 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7928 }
7929 }
7930 state.set_status(RunStatus::Idle);
7931 tui.request_render(false);
7932}
7933
7934fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7938 let text = state.last_assistant_text.lock().unwrap().clone();
7939 if text.is_empty() {
7940 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7941 return;
7942 }
7943 if copy_to_clipboard(&text) {
7944 add_note_message(chat, "Copied last reply to the clipboard.");
7945 } else {
7946 let preview: String = text.chars().take(200).collect();
7949 add_note_message(
7950 chat,
7951 &format!(
7952 "Clipboard unavailable. Last reply: {preview}{}",
7953 if text.chars().count() > 200 {
7954 "…"
7955 } else {
7956 ""
7957 }
7958 ),
7959 );
7960 }
7961}
7962
7963fn extract_selected_text(
7966 chat_container: &Arc<Container>,
7967 start: (u16, u16),
7968 end: (u16, u16),
7969) -> Option<String> {
7970 use rpi_tui::Component;
7971
7972 let lines = chat_container.render(80); if lines.is_empty() {
7975 return None;
7976 }
7977
7978 let (start_row, end_row) = if start.1 <= end.1 {
7980 (start.1 as usize, end.1 as usize)
7981 } else {
7982 (end.1 as usize, start.1 as usize)
7983 };
7984
7985 let start_row = start_row.min(lines.len().saturating_sub(1));
7987 let end_row = end_row.min(lines.len().saturating_sub(1));
7988
7989 if start_row > end_row {
7990 return None;
7991 }
7992
7993 let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7995
7996 if selected_lines.is_empty() {
7997 return None;
7998 }
7999
8000 Some(selected_lines.join("\n"))
8001}
8002
8003#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8007fn copy_to_clipboard(text: &str) -> bool {
8008 match arboard::Clipboard::new() {
8009 Ok(mut cb) => cb.set_text(text).is_ok(),
8010 Err(_) => false,
8011 }
8012}
8013
8014#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8015fn copy_to_clipboard(_text: &str) -> bool {
8016 false
8017}
8018
8019#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8023fn read_clipboard_text() -> Option<String> {
8024 let mut clipboard = arboard::Clipboard::new().ok()?;
8025 clipboard.get_text().ok()
8026}
8027
8028#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8029fn read_clipboard_text() -> Option<String> {
8030 None
8031}
8032
8033#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8037fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8038 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8039 let image = match clipboard.get_image() {
8040 Ok(image) => image,
8041 Err(_) => return Ok(None),
8042 };
8043 let width =
8044 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8045 let height =
8046 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8047 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8048 return Err("clipboard image dimensions are outside the supported range".into());
8049 }
8050 let mut bytes = Vec::new();
8051 {
8052 let mut encoder = png::Encoder::new(&mut bytes, width, height);
8053 encoder.set_color(png::ColorType::Rgba);
8054 encoder.set_depth(png::BitDepth::Eight);
8055 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8056 writer
8057 .write_image_data(&image.bytes)
8058 .map_err(|e| e.to_string())?;
8059 }
8060 Ok(Some(rpi_ai::types::ImageContent {
8061 kind: rpi_ai::types::ImageContentType,
8062 data: base64::engine::general_purpose::STANDARD.encode(bytes),
8063 mime_type: "image/png".into(),
8064 }))
8065}
8066
8067fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8068 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8069 let mut options = ImageOptions::default();
8070 options.width = Some(48);
8071 options.alt_text = Some("Attached image".into());
8072 chat.add_child(Arc::new(Image::from_data(bytes, options)));
8073 chat.add_child(Arc::new(Spacer::new(1)));
8074 }
8075}
8076
8077#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8078fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8079 Ok(None)
8080}
8081
8082fn add_assistant_message_blocking(
8089 container: &Arc<Container>,
8090 text: &str,
8091 transformer: Option<MarkdownTransformer>,
8092) {
8093 if text.is_empty() {
8094 return;
8095 }
8096 let msg = Arc::new(AssistantMessageComponent::new(
8097 AssistantMessageOptions::default(),
8098 ));
8099 if let Some(t) = &transformer {
8100 msg.set_markdown_transformer(Some(t.clone()));
8101 }
8102 msg.update_text(text);
8103 container.add_child(msg);
8104 container.add_child(Arc::new(Spacer::new(1)));
8105}
8106
8107async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8118 if let Ok(queued) = lane.queued_messages().await {
8119 state.set_pending_queue(queued);
8120 }
8121}
8122
8123fn transcript_has_live_panel(
8129 bash_components_present: bool,
8130 tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
8131) -> bool {
8132 bash_components_present
8133 || tool_components
8134 .values()
8135 .any(|component| component.status() == ToolStatus::Running)
8136}
8137
8138fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
8141 if current.trim().is_empty() {
8142 queued_text.to_string()
8143 } else {
8144 format!("{queued_text}\n\n{current}")
8145 }
8146}
8147
8148async fn restore_queued_messages_to_editor(
8158 lane: Arc<dyn AgentLane>,
8159 editor: Arc<Editor>,
8160 state: Arc<TuiState>,
8161 tui: Arc<TuiAltScreen>,
8162) {
8163 match lane.clear_queue().await {
8164 Ok(queued) => {
8165 let all: Vec<String> = queued
8166 .steering
8167 .iter()
8168 .chain(queued.follow_up.iter())
8169 .cloned()
8170 .collect();
8171 if !all.is_empty() {
8172 let queued_text = all.join("\n\n");
8173 let current = editor.get_text();
8174 let combined = merge_queued_into_draft(&queued_text, ¤t);
8175 let cursor = combined.chars().count();
8176 editor.set_text(&combined);
8177 editor.set_cursor(0, cursor);
8178 }
8179 refresh_pending_messages(&state, &lane).await;
8181 }
8182 Err(error) => {
8183 add_error_message(
8184 &state.chat_container,
8185 &format!("Could not restore queued messages: {error}"),
8186 );
8187 }
8188 }
8189 tui.request_render(false);
8190}
8191
8192async fn drain_agent_events(
8193 mut rx: broadcast::Receiver<AgentEvent>,
8194 tui: Arc<TuiAltScreen>,
8195 state: Arc<TuiState>,
8196 chat: Arc<Container>,
8197 lane: Arc<dyn AgentLane>,
8198) {
8199 loop {
8200 match rx.recv().await {
8201 Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8202 Err(broadcast::error::RecvError::Lagged(_)) => {
8203 continue;
8206 }
8207 Err(broadcast::error::RecvError::Closed) => break,
8208 }
8209 }
8210}
8211
8212async fn handle_agent_event(
8215 event: AgentEvent,
8216 tui: &Arc<TuiAltScreen>,
8217 state: &Arc<TuiState>,
8218 chat: &Arc<Container>,
8219 lane: &Arc<dyn AgentLane>,
8220) {
8221 match event {
8222 AgentEvent::AgentStart => {
8223 state.set_status(RunStatus::Working);
8224 tui.request_render(false);
8225 }
8226
8227 AgentEvent::AgentEnd { .. } => {
8228 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8230 comp.set_streaming(false);
8231 }
8232 state.set_status(RunStatus::Idle);
8233 let pending: Vec<AgentMessage> =
8237 std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8238 for message in pending {
8239 if let Err(error) = lane.append_message(message).await {
8240 add_error_message(chat, &format!("Could not record bash result: {error}"));
8241 }
8242 }
8243 refresh_pending_messages(state, lane).await;
8246 tui.request_render(false);
8247 }
8248
8249 AgentEvent::RetryScheduled {
8250 attempt,
8251 max_retries,
8252 delay_ms,
8253 ..
8254 } => {
8255 state.show_retry(attempt, max_retries, delay_ms);
8256 tui.request_render(false);
8257 }
8258
8259 AgentEvent::TurnStart => {
8260 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8263 comp.set_streaming(false);
8264 }
8265 }
8266
8267 AgentEvent::TurnEnd {
8268 message,
8269 tool_results,
8270 } => {
8271 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8273 if let AgentMessage::Assistant(a) = &message {
8274 comp.update_blocks(&assistant_blocks(a));
8275 }
8276 comp.set_streaming(false);
8277 }
8278 let tools = state.tool_components.lock().unwrap();
8283 for tr in &tool_results {
8284 if tools.contains_key(&tr.tool_call_id) {
8285 let _ = tr;
8288 }
8289 }
8290 drop(tools);
8291 tui.request_render(false);
8292 }
8293
8294 AgentEvent::MessageStart { message } => match message {
8295 AgentMessage::Assistant(a) => {
8296 let comp = Arc::new(AssistantMessageComponent::new(
8297 AssistantMessageOptions::default(),
8298 ));
8299 if let Some(t) = state.markdown_transformer() {
8304 comp.set_markdown_transformer(Some(t));
8305 }
8306 comp.set_hide_thinking(state.hide_thinking());
8307 comp.set_streaming(true);
8308 comp.update_blocks(&assistant_blocks(&a));
8311 chat.add_child(comp.clone());
8312 chat.add_child(Arc::new(Spacer::new(1)));
8315 *state.current_assistant.lock().unwrap() = Some(comp);
8316 tui.request_render(false);
8317 }
8318 AgentMessage::Custom(custom) => {
8319 let payload = serde_json::json!({
8320 "customType": custom.role,
8321 "content": custom.content,
8322 "details": custom.data,
8323 "expanded": false,
8324 "outputPad": 1,
8325 });
8326 if let Some(component) = extension_message_component(
8327 &state.extension_session,
8328 &custom.role,
8329 &payload,
8330 state.markdown_transformer(),
8331 ) {
8332 chat.add_child(component);
8333 chat.add_child(Arc::new(Spacer::new(1)));
8334 tui.request_render(false);
8335 } else {
8336 add_note_message(chat, &custom_message_fallback(&custom));
8337 tui.request_render(false);
8338 }
8339 }
8340 AgentMessage::User(user) => {
8350 add_user_message(chat, &user_message_text(&user));
8351 refresh_pending_messages(state, lane).await;
8353 tui.request_render(false);
8354 }
8355 _ => {}
8358 },
8359
8360 AgentEvent::MessageUpdate {
8361 message,
8362 assistant_message_event,
8363 } => {
8364 {
8365 let a: &rpi_ai::types::AssistantMessage = &message;
8370 let text = assistant_text(a);
8371 let mut saw_bash_tool_call = false;
8372 for c in &a.content {
8377 if let Content::ToolCall(tc) = c {
8378 if tc.name.trim().is_empty() {
8382 continue;
8383 }
8384 if tc.name == "bash" {
8385 let command = tc
8391 .arguments
8392 .get("command")
8393 .and_then(|v| v.as_str())
8394 .unwrap_or("");
8395 if command.trim().is_empty() {
8402 continue;
8403 }
8404 saw_bash_tool_call = true;
8405 let mut bash = state.bash_components.lock().unwrap();
8406 if let Some(existing) = bash.get(&tc.id) {
8407 existing.set_command(command);
8412 } else {
8413 let comp = Arc::new(BashExecutionComponent::new(command));
8414 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8415 chat.add_child(comp.clone());
8416 bash.insert(tc.id.clone(), comp);
8417 }
8418 } else {
8419 let mut tools = state.tool_components.lock().unwrap();
8420 let display_args = if is_ask_user_tool(&tc.name) {
8421 ask_user_args_display(&tc.arguments)
8422 } else {
8423 tc.arguments.to_string()
8424 };
8425 if let Some(existing) = tools.get(&tc.id) {
8426 if !display_args.trim().is_empty() && display_args.trim() != "{}" {
8430 existing.set_args(&display_args);
8431 }
8432 } else {
8433 let comp =
8434 Arc::new(ToolExecutionComponent::new(&tc.name, &display_args));
8435 if is_ask_user_tool(&tc.name) {
8436 comp.set_display_title("ASK USER");
8437 }
8438 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8439 comp.set_running();
8440 chat.add_child(comp.clone());
8441 tools.insert(tc.id.clone(), comp);
8442 }
8443 }
8444 }
8445 }
8446 if saw_bash_tool_call {
8451 state.sync_working_loader_with_bash();
8452 }
8453 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
8455 comp.update_blocks(&assistant_blocks(a));
8458 }
8459 *state.last_assistant_text.lock().unwrap() = text;
8460 tui.request_render(false);
8461 }
8462 }
8463
8464 AgentEvent::MessageEnd { message } => {
8465 if let AgentMessage::Assistant(a) = &message {
8466 let text = assistant_text(a);
8467 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
8468 comp.update_blocks(&assistant_blocks(a));
8469 comp.set_streaming(false);
8470 }
8471 if !text.is_empty() {
8473 *state.last_assistant_text.lock().unwrap() = text;
8474 }
8475 let usage = &a.usage;
8481 state.footer.add_usage(
8483 usage.input,
8484 usage.output,
8485 usage.cache_read,
8486 usage.cache_write,
8487 usage.cost.total,
8488 );
8489 let denom = usage.input + usage.cache_read + usage.cache_write;
8490 if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8491 state
8492 .footer
8493 .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8494 }
8495 record_context_usage(&state.footer, a);
8499 let miss = state
8500 .cache_tracker
8501 .lock()
8502 .unwrap()
8503 .observe(a, &rpi_harness::cache_stats::NoPrices);
8504 let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8508 if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8509 let cost = if miss.missed_cost > 0.0 {
8510 format!(" (~${:.4})", miss.missed_cost)
8511 } else {
8512 String::new()
8513 };
8514 add_note_message(
8515 &state.chat_container,
8516 &format!(
8517 "Cache miss: {} tokens re-billed{}",
8518 format_tokens(miss.missed_tokens),
8519 cost
8520 ),
8521 );
8522 }
8523 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8524 add_note_message(chat, &text);
8525 }
8526 if let Some(error) = assistant_error_text(a) {
8531 add_error_message(chat, &error);
8532 }
8533 }
8534 tui.request_render(false);
8535 }
8536
8537 AgentEvent::ToolExecutionStart {
8538 tool_call_id,
8539 tool_name,
8540 args,
8541 } => {
8542 if tool_name.trim().is_empty() {
8545 return;
8546 }
8547 if tool_name == "bash" {
8548 let command = args
8553 .get("command")
8554 .and_then(|v| v.as_str())
8555 .unwrap_or("")
8556 .to_string();
8557 if command.trim().is_empty() {
8561 return;
8562 }
8563 let mut bash_map = state.bash_components.lock().unwrap();
8564 if let Some(existing) = bash_map.get(&tool_call_id) {
8565 existing.set_command(&command);
8570 } else {
8571 let comp = Arc::new(BashExecutionComponent::new(command));
8572 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8573 chat.add_child(comp.clone());
8574 bash_map.insert(tool_call_id.clone(), comp);
8575 }
8576 } else {
8577 let _comp = {
8578 let mut tools = state.tool_components.lock().unwrap();
8579 if let Some(existing) = tools.get(&tool_call_id) {
8580 if is_ask_user_tool(&tool_name) {
8581 existing.set_display_title("ASK USER");
8582 existing.set_args(&ask_user_args_display(&args));
8583 } else {
8584 existing.set_args(&args.to_string());
8585 }
8586 existing.clone()
8587 } else {
8588 let display_args = if is_ask_user_tool(&tool_name) {
8589 ask_user_args_display(&args)
8590 } else {
8591 args.to_string()
8592 };
8593 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8594 if is_ask_user_tool(&tool_name) {
8595 comp.set_display_title("ASK USER");
8596 }
8597 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8598 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8603 comp.set_skill_name(skill);
8604 }
8605 comp.set_running();
8606 chat.add_child(comp.clone());
8607 tools.insert(tool_call_id.clone(), comp.clone());
8608 comp
8609 }
8610 };
8611 }
8612 state.sync_working_loader_with_bash();
8613 tui.request_render(false);
8614 }
8615
8616 AgentEvent::ToolExecutionUpdate {
8617 tool_call_id,
8618 tool_name,
8619 args,
8620 partial_result,
8621 } => {
8622 if tool_name.trim().is_empty() {
8623 return;
8624 }
8625 let partial_text = if is_ask_user_tool(&tool_name) {
8626 ask_user_progress_text(&args, &partial_result)
8627 } else {
8628 tool_result_text(&partial_result)
8629 };
8630 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
8631 if tool_name == "bash" {
8632 let chunk = partial_text;
8637 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
8638 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
8641 bash.set_command(command);
8642 }
8643 if has_partial_payload {
8644 bash.append_output(&chunk);
8645 }
8646 } else if has_partial_payload {
8647 }
8653 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
8654 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8655 comp.set_skill_name(skill);
8656 }
8657 if is_ask_user_tool(&tool_name) {
8658 comp.set_display_title("ASK USER");
8659 comp.set_args(&ask_user_args_display(&args));
8660 } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
8661 comp.set_args(&args.to_string());
8662 }
8663 if has_partial_payload {
8666 comp.set_result(&partial_text, false);
8667 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
8668 }
8669 } else if has_partial_payload {
8670 let display_args = if is_ask_user_tool(&tool_name) {
8675 ask_user_args_display(&args)
8676 } else {
8677 args.to_string()
8678 };
8679 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
8680 if is_ask_user_tool(&tool_name) {
8681 comp.set_display_title("ASK USER");
8682 }
8683 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8684 if let Some(skill) = skill_tool_name(&tool_name, &args) {
8685 comp.set_skill_name(skill);
8686 }
8687 comp.set_running();
8688 comp.set_result(&partial_text, false);
8689 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
8690 chat.add_child(comp.clone());
8691 state
8692 .tool_components
8693 .lock()
8694 .unwrap()
8695 .insert(tool_call_id.clone(), comp.clone());
8696 }
8697 state.sync_working_loader_with_bash();
8698 tui.request_render(false);
8699 }
8700
8701 AgentEvent::ToolExecutionEnd {
8702 tool_call_id,
8703 tool_name,
8704 result,
8705 is_error,
8706 } => {
8707 if tool_name.trim().is_empty() {
8708 return;
8709 }
8710 if tool_name == "bash" {
8711 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
8712 if let Some(bash) = bash {
8713 finalize_bash(&bash, &result, is_error);
8714 } else {
8715 let command = result
8718 .details
8719 .get("command")
8720 .and_then(|v| v.as_str())
8721 .unwrap_or("")
8722 .to_string();
8723 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
8724 state.sync_working_loader_with_bash();
8725 tui.render_now(false);
8728 return;
8729 }
8730 let comp = Arc::new(BashExecutionComponent::new(command));
8731 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8732 comp.append_output(&tool_result_text(&result));
8733 finalize_bash(&comp, &result, is_error);
8734 chat.add_child(comp);
8735 }
8736 } else {
8737 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
8738 if let Some(comp) = comp {
8739 let result_text = if is_ask_user_tool(&tool_name) {
8740 ask_user_result_text(&result)
8741 } else {
8742 tool_result_text(&result)
8743 };
8744 comp.set_result(&result_text, is_error);
8745 if !is_ask_user_tool(&tool_name) {
8746 if tool_result_requests_markdown(Some(&result.details)) {
8747 comp.set_result_markdown(true);
8748 }
8749 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8750 }
8751 } else {
8752 let comp = Arc::new(ToolExecutionComponent::new(
8755 &tool_name,
8756 &if is_ask_user_tool(&tool_name) {
8757 ask_user_args_display(&serde_json::Value::Null)
8758 } else {
8759 "".to_string()
8760 },
8761 ));
8762 if is_ask_user_tool(&tool_name) {
8763 comp.set_display_title("ASK USER");
8764 }
8765 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
8766 let result_text = if is_ask_user_tool(&tool_name) {
8767 ask_user_result_text(&result)
8768 } else {
8769 tool_result_text(&result)
8770 };
8771 comp.set_result(&result_text, is_error);
8772 if !is_ask_user_tool(&tool_name) {
8773 if tool_result_requests_markdown(Some(&result.details)) {
8774 comp.set_result_markdown(true);
8775 }
8776 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
8777 }
8778 chat.add_child(comp.clone());
8779 }
8780 }
8781 state.sync_working_loader_with_bash();
8782 tui.render_now(false);
8788 }
8789 }
8790}
8791
8792fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
8800 if message.stop_reason == rpi_ai::StopReason::Aborted
8801 || message.stop_reason == rpi_ai::StopReason::Error
8802 {
8803 return;
8804 }
8805 let tokens = message.usage.context_tokens();
8806 if tokens > 0 {
8807 footer.set_context_tokens(Some(tokens));
8808 }
8809}
8810
8811fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
8815 let fallback = match message.stop_reason {
8816 rpi_ai::StopReason::Error => "Provider request failed.",
8817 rpi_ai::StopReason::Aborted => "Request aborted.",
8818 _ => return None,
8819 };
8820 Some(
8821 message
8822 .error_message
8823 .as_deref()
8824 .filter(|text| !text.trim().is_empty())
8825 .unwrap_or(fallback)
8826 .to_string(),
8827 )
8828}
8829
8830fn finalize_bash(
8834 comp: &Arc<BashExecutionComponent>,
8835 result: &rpi_agent::AgentToolResult,
8836 is_error: bool,
8837) {
8838 let exit_code = if is_error { Some(1) } else { Some(0) };
8841 let truncated = result
8842 .details
8843 .get("truncation")
8844 .and_then(|t| t.get("truncated"))
8845 .and_then(|v| v.as_bool())
8846 .unwrap_or(false);
8847 let full_output_path = result
8848 .details
8849 .get("full_output_path")
8850 .and_then(|v| v.as_str())
8851 .map(|s| s.to_string());
8852 let truncation = BashTruncation {
8853 truncated,
8854 full_output_path,
8855 };
8856 if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
8860 comp.set_command(command);
8861 }
8862 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
8864}
8865
8866fn apply_edit_diff(
8871 comp: &Arc<ToolExecutionComponent>,
8872 tool_name: &str,
8873 details: &serde_json::Value,
8874 tui: &Arc<TuiAltScreen>,
8875) {
8876 if tool_name != "edit" {
8877 return;
8878 }
8879 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
8880 return;
8881 };
8882 if diff_text.is_empty() {
8883 return;
8884 }
8885 let width = tui.width();
8886 let lines = render_diff(diff_text, width);
8887 comp.set_diff(lines);
8888}
8889
8890fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
8899 details
8900 .and_then(|details| details.get("markdown"))
8901 .and_then(|value| value.as_bool())
8902 .unwrap_or(false)
8903}
8904
8905fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
8910 if tool_name != "read" {
8911 return None;
8912 }
8913 let path = args.get("path").and_then(|value| value.as_str())?;
8914 let normalized = path.replace('\\', "/");
8915 let file_name = normalized.rsplit('/').next()?;
8916 if !file_name.eq_ignore_ascii_case("SKILL.md") {
8917 return None;
8918 }
8919 normalized
8920 .trim_end_matches('/')
8921 .rsplit('/')
8922 .nth(1)
8923 .filter(|name| !name.is_empty())
8924 .map(str::to_string)
8925}
8926
8927fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
8932 use rpi_agent::TextContentOrImage;
8933 let mut parts: Vec<String> = Vec::new();
8934 for c in &result.content {
8935 if let TextContentOrImage::Text(t) = c {
8936 parts.push(t.text.clone());
8937 }
8938 }
8939 parts.join("\n")
8940}
8941
8942fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
8946 !text.trim().is_empty() || !result.details.is_null()
8947}
8948
8949fn is_ask_user_tool(tool_name: &str) -> bool {
8952 matches!(tool_name, "ask_user" | "ask_user_question")
8953}
8954
8955fn ask_user_args_display(args: &serde_json::Value) -> String {
8958 if args.is_null() {
8959 return String::new();
8960 }
8961 if let Some(summary) = args
8962 .get("summary")
8963 .and_then(serde_json::Value::as_str)
8964 .map(str::trim)
8965 .filter(|text| !text.is_empty())
8966 {
8967 return format!("Confirmation requested: {summary}");
8968 }
8969
8970 let mut lines: Vec<String> = Vec::new();
8971 let questions: Vec<&serde_json::Value> = args
8972 .get("questions")
8973 .and_then(serde_json::Value::as_array)
8974 .filter(|items| !items.is_empty())
8975 .map(|items| items.iter().collect())
8976 .unwrap_or_else(|| vec![args]);
8977
8978 for question in questions {
8979 let prompt = question
8980 .get("question")
8981 .and_then(serde_json::Value::as_str)
8982 .or_else(|| args.get("question").and_then(serde_json::Value::as_str))
8983 .map(str::trim)
8984 .unwrap_or_default();
8985 if prompt.is_empty() {
8986 continue;
8987 }
8988 if let Some(header) = question
8989 .get("header")
8990 .and_then(serde_json::Value::as_str)
8991 .map(str::trim)
8992 .filter(|text| !text.is_empty())
8993 {
8994 lines.push(format!("{header}: {prompt}"));
8995 } else {
8996 lines.push(prompt.to_string());
8997 }
8998 let context = question
8999 .get("context")
9000 .and_then(serde_json::Value::as_str)
9001 .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
9002 .map(str::trim)
9003 .filter(|text| !text.is_empty());
9004 if let Some(context) = context {
9005 lines.push(format!(" {context}"));
9006 }
9007 let options = question
9008 .get("options")
9009 .and_then(serde_json::Value::as_array)
9010 .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
9011 if let Some(options) = options {
9012 let labels: Vec<String> = options
9013 .iter()
9014 .filter_map(|option| {
9015 option
9016 .get("title")
9017 .and_then(serde_json::Value::as_str)
9018 .or_else(|| option.as_str())
9019 .map(str::trim)
9020 .filter(|label| !label.is_empty())
9021 .map(str::to_string)
9022 })
9023 .collect();
9024 if !labels.is_empty() {
9025 lines.push(format!("Choices: {}", labels.join(", ")));
9026 }
9027 }
9028 if let Some(suggest) = question
9029 .get("suggest")
9030 .and_then(serde_json::Value::as_str)
9031 .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
9032 .map(str::trim)
9033 .filter(|text| !text.is_empty())
9034 {
9035 lines.push(format!("Suggestions: {suggest}"));
9036 }
9037 }
9038
9039 lines.join("\n")
9040}
9041
9042fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
9045 let text = tool_result_text(result);
9046 if !text.trim().is_empty() {
9047 return text;
9048 }
9049 let args_text = ask_user_args_display(args);
9050 if args_text.trim().is_empty() {
9051 "Waiting for your answer…".to_string()
9052 } else {
9053 args_text
9054 }
9055}
9056
9057fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
9060 let text = tool_result_text(result);
9061 if text.trim().is_empty() {
9062 "Answer recorded.".to_string()
9063 } else {
9064 text
9065 }
9066}
9067
9068fn open_selector(
9076 state: &Arc<TuiState>,
9077 editor_container: &Arc<Container>,
9078 editor: &Arc<Editor>,
9079 tui: &Arc<TuiAltScreen>,
9080 list: Arc<SelectList>,
9081 kind: SelectorKind,
9082) {
9083 open_selector_with_view(
9084 state,
9085 editor_container,
9086 editor,
9087 tui,
9088 SelectorView::List(list.clone()),
9089 list,
9090 kind,
9091 );
9092}
9093
9094fn open_searchable_selector(
9099 state: &Arc<TuiState>,
9100 editor_container: &Arc<Container>,
9101 editor: &Arc<Editor>,
9102 tui: &Arc<TuiAltScreen>,
9103 title: Option<&str>,
9104 description: Option<&str>,
9105 list: Arc<SelectList>,
9106 kind: SelectorKind,
9107) -> Arc<SearchableSelectList> {
9108 let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9109 open_selector_with_view(
9110 state,
9111 editor_container,
9112 editor,
9113 tui,
9114 SelectorView::Searchable(searchable.clone()),
9115 searchable.clone(),
9116 kind,
9117 );
9118 searchable.set_focused(true);
9122 searchable
9123}
9124
9125fn open_selector_with_view<V: Component + 'static>(
9133 state: &Arc<TuiState>,
9134 editor_container: &Arc<Container>,
9135 editor: &Arc<Editor>,
9136 tui: &Arc<TuiAltScreen>,
9137 view_router: SelectorView,
9138 view: Arc<V>,
9139 kind: SelectorKind,
9140) {
9141 dispatch_ui_prompt_event(state);
9143
9144 editor.set_focused(false);
9146 state.autocomplete_container.clear();
9149 editor_container.clear();
9151 editor_container.add_child(view.clone());
9152 *state.active_selector.lock().unwrap() = Some((view_router, kind));
9153 let focused: Arc<dyn Component> = view;
9154 tui.set_focus(Some(focused));
9155 tui.request_render(false);
9156}
9157
9158fn close_selector(
9161 state: &Arc<TuiState>,
9162 editor_container: &Arc<Container>,
9163 editor: &Arc<Editor>,
9164 tui: &Arc<TuiAltScreen>,
9165) {
9166 dispatch_ui_prompt_event_end(state);
9168
9169 editor_container.clear();
9170 editor_container.add_child(editor.clone());
9171 state.autocomplete_container.clear();
9172 editor.set_focused(true);
9173 *state.active_selector.lock().unwrap() = None;
9174 *state.active_extension_cancel.lock().unwrap() = None;
9175 tui.set_focus(Some(editor.clone()));
9176 tui.request_render(false);
9177}
9178
9179fn open_model_selector(
9185 state: &Arc<TuiState>,
9186 editor_container: &Arc<Container>,
9187 editor: &Arc<Editor>,
9188 tui: &Arc<TuiAltScreen>,
9189 catalog: &[rpi_ai::Model],
9190 lane: &Arc<dyn AgentLane>,
9191 lane_model_id: &str,
9192 chat: &Arc<Container>,
9193 initial_search: Option<&str>,
9194) -> Option<Arc<SearchableSelectList>> {
9195 let items = model_selector_items(catalog, lane_model_id);
9196 if items.is_empty() {
9197 add_note_message(
9198 chat,
9199 "No models in the catalog. Use --model at startup to select one.",
9200 );
9201 tui.request_render(false);
9202 return None;
9203 }
9204 let list = Arc::new(SelectList::new(items, 10));
9205
9206 let catalog_arc = catalog.to_vec();
9210 let state_sel = state.clone();
9211 let ec_sel = editor_container.clone();
9212 let editor_sel = editor.clone();
9213 let tui_sel = tui.clone();
9214 let chat_sel = chat.clone();
9215 let lane_sel = lane.clone();
9216 list.on_select(Arc::new(move |item| {
9217 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9218 add_note_message(
9219 &chat_sel,
9220 &format!("Model {} not found in catalog.", item.label),
9221 );
9222 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9223 return;
9224 };
9225 state_sel.set_current_model(&model);
9226 let lane = lane_sel.clone();
9227 tokio::spawn(async move {
9228 let _ = lane.set_model(model).await;
9229 });
9230 add_note_message(
9231 &chat_sel,
9232 &format!(
9233 "Model set to {} — applies to the next message.",
9234 short_model_name(&item.value)
9235 ),
9236 );
9237 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9238 }));
9239 let state_cancel = state.clone();
9240 let ec_cancel = editor_container.clone();
9241 let editor_cancel = editor.clone();
9242 let tui_cancel = tui.clone();
9243 list.on_cancel(Arc::new(move || {
9244 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9245 }));
9246
9247 let searchable = open_searchable_selector(
9248 state,
9249 editor_container,
9250 editor,
9251 tui,
9252 Some("Select model"),
9253 Some("Type to filter by name, provider, or id"),
9254 list,
9255 SelectorKind::Model,
9256 );
9257 if let Some(term) = initial_search {
9258 searchable.set_search_text(term);
9259 }
9260 Some(searchable)
9261}
9262
9263fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9268 if catalog.is_empty() {
9269 return None;
9270 }
9271 let idx = catalog
9272 .iter()
9273 .position(|m| m.id.eq_ignore_ascii_case(current_id));
9274 match idx {
9275 Some(i) => {
9276 let next = (i + 1) % catalog.len();
9277 Some(catalog[next].clone())
9278 }
9279 None => Some(catalog[0].clone()),
9280 }
9281}
9282
9283fn open_session_selector(
9289 state: &Arc<TuiState>,
9290 editor_container: &Arc<Container>,
9291 editor: &Arc<Editor>,
9292 tui: &Arc<TuiAltScreen>,
9293 cwd: &std::path::Path,
9294 tx: &mpsc::UnboundedSender<TuiMessage>,
9295) {
9296 let dir = crate::session::default_session_dir(cwd);
9297 let mut items: Vec<SelectItem> = Vec::new();
9298 for path in crate::session::list_session_files_sync(&dir) {
9299 let summary = crate::session::summarize_session_file(&path);
9300 if summary.empty {
9301 continue;
9302 }
9303 let stem = path
9304 .file_stem()
9305 .and_then(|s| s.to_str())
9306 .unwrap_or("(unnamed)")
9307 .to_string();
9308 let label = crate::session::session_display_label(&summary);
9309 let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
9312 let short_id = crate::session::short_session_id(id_tail);
9313 let description = format!(
9314 "{} · {}",
9315 crate::session::format_session_bytes(summary.bytes),
9316 short_id,
9317 );
9318 items.push(
9319 SelectItem::new(&stem, &label)
9320 .with_description(&description)
9321 .with_search_text(&format!("{label} {short_id} {stem}")),
9322 );
9323 }
9324 if items.is_empty() {
9325 add_note_message(
9326 &state.chat_container,
9327 "No saved sessions found. Sessions are created automatically in interactive mode.",
9328 );
9329 tui.request_render(false);
9330 return;
9331 }
9332 let mut list = SelectList::new(items, 10);
9333 list.set_layout(rpi_tui::SelectListLayoutOptions {
9334 min_primary_column_width: Some(56),
9335 max_primary_column_width: Some(72),
9336 truncate_primary: None,
9337 });
9338 let list = Arc::new(list);
9339
9340 let state_sel = state.clone();
9341 let ec_sel = editor_container.clone();
9342 let editor_sel = editor.clone();
9343 let tui_sel = tui.clone();
9344 let tx_sel = tx.clone();
9345 list.on_select(Arc::new(move |item| {
9346 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9350 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9351 }));
9352 let state_cancel = state.clone();
9353 let ec_cancel = editor_container.clone();
9354 let editor_cancel = editor.clone();
9355 let tui_cancel = tui.clone();
9356 list.on_cancel(Arc::new(move || {
9357 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9358 }));
9359
9360 open_searchable_selector(
9361 state,
9362 editor_container,
9363 editor,
9364 tui,
9365 Some("Resume session"),
9366 Some("Type to filter sessions"),
9367 list,
9368 SelectorKind::Session,
9369 );
9370}
9371
9372fn custom_entry_display_text(
9373 custom_type: &str,
9374 data: Option<&serde_json::Value>,
9375) -> Option<String> {
9376 let data = data?;
9377 let text = data
9378 .get("summary")
9379 .or_else(|| data.get("text"))
9380 .or_else(|| data.get("output"))
9381 .and_then(|value| value.as_str())
9382 .filter(|value| !value.trim().is_empty())?;
9383 let label = match custom_type {
9384 "compactionSummary" => "Compaction summary",
9385 "branchSummary" => "Branch summary",
9386 "bashExecution" => "Command output",
9387 other => other,
9388 };
9389 Some(format!("{label}: {text}"))
9390}
9391
9392async fn open_tree_selector(
9396 harness: &AgentHarness,
9397 state: &Arc<TuiState>,
9398 editor_container: &Arc<Container>,
9399 editor: &Arc<Editor>,
9400 tui: &Arc<TuiAltScreen>,
9401 chat: &Arc<Container>,
9402 tx: &mpsc::UnboundedSender<TuiMessage>,
9403) {
9404 let entries = match harness
9405 .session()
9406 .view("main")
9407 .find_entries(&EntryQuery {
9408 order: Some(EntryOrder::OldestFirst),
9409 ..Default::default()
9410 })
9411 .await
9412 {
9413 Ok(entries) => entries,
9414 Err(error) => {
9415 add_error_message(chat, &format!("Could not read session tree: {error}"));
9416 tui.request_render(false);
9417 return;
9418 }
9419 };
9420 let current = harness.session().get_leaf_id().await.ok().flatten();
9421 let items: Vec<SelectItem> = entries
9422 .iter()
9423 .map(|entry| {
9424 let label = if current.as_deref() == Some(entry.id()) {
9425 format!("{} #{} (current)", entry.entry_type(), entry.seq())
9426 } else {
9427 format!("{} #{}", entry.entry_type(), entry.seq())
9428 };
9429 let short_id = &entry.id()[..entry.id().len().min(12)];
9430 let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9433 SelectItem::new(entry.id(), &label)
9434 .with_description(short_id)
9435 .with_search_text(&search_text)
9436 })
9437 .collect();
9438 if items.is_empty() {
9439 add_note_message(chat, "The current session has no entries to navigate.");
9440 tui.request_render(false);
9441 return;
9442 }
9443 let list = Arc::new(SelectList::new(items, 12));
9444 let state_sel = state.clone();
9445 let ec_sel = editor_container.clone();
9446 let editor_sel = editor.clone();
9447 let tui_sel = tui.clone();
9448 let tx_sel = tx.clone();
9449 list.on_select(Arc::new(move |item| {
9450 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9451 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9452 }));
9453 let state_cancel = state.clone();
9454 let ec_cancel = editor_container.clone();
9455 let editor_cancel = editor.clone();
9456 let tui_cancel = tui.clone();
9457 list.on_cancel(Arc::new(move || {
9458 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9459 }));
9460 open_searchable_selector(
9461 state,
9462 editor_container,
9463 editor,
9464 tui,
9465 Some("Session tree"),
9466 Some("Type to filter by entry type, sequence, or id"),
9467 list,
9468 SelectorKind::Tree,
9469 );
9470}
9471
9472fn open_theme_selector(
9475 state: &Arc<TuiState>,
9476 editor_container: &Arc<Container>,
9477 editor: &Arc<Editor>,
9478 tui: &Arc<TuiAltScreen>,
9479 cwd: &std::path::Path,
9480 package_resources: &Arc<crate::packages::PackageResources>,
9481) {
9482 let mut items = vec![
9483 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9484 SelectItem::new("light", "Light").with_description("Light background"),
9485 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9486 ];
9487 if state.themes_enabled {
9488 for path in package_resources.theme_files() {
9489 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9490 items.push(SelectItem::new(name, name).with_description("Package theme"));
9491 }
9492 }
9493 }
9494 let list = Arc::new(SelectList::new(items, 10));
9495
9496 let state_sel = state.clone();
9497 let ec_sel = editor_container.clone();
9498 let editor_sel = editor.clone();
9499 let tui_sel = tui.clone();
9500 let chat_sel = state.chat_container.clone();
9501 let cwd_sel = cwd.to_path_buf();
9502 let package_resources_sel = package_resources.clone();
9503 list.on_select(Arc::new(move |item| {
9504 let preset = match item.value.as_str() {
9505 "light" => Some(ThemePreset::Light),
9506 "monochrome" => Some(ThemePreset::Monochrome),
9507 "dark" => Some(ThemePreset::Dark),
9508 name => {
9509 if state_sel.themes_enabled {
9510 if let Ok(custom) = crate::packages::load_theme_with_resources(
9511 &cwd_sel,
9512 name,
9513 &package_resources_sel,
9514 ) {
9515 rpi_tui::global_theme_manager().set(custom.clone());
9516 state_sel.theme_manager.set(custom);
9517 }
9518 }
9519 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9520 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9521 tui_sel.render_now(true);
9522 return;
9523 }
9524 };
9525 let Some(preset) = preset else { return };
9526 apply_theme_preset(preset);
9527 state_sel.theme_manager.apply_preset(preset);
9528 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9531 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9532 tui_sel.render_now(true);
9533 }));
9534 let state_cancel = state.clone();
9535 let ec_cancel = editor_container.clone();
9536 let editor_cancel = editor.clone();
9537 let tui_cancel = tui.clone();
9538 list.on_cancel(Arc::new(move || {
9539 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9540 }));
9541
9542 open_selector(
9543 state,
9544 editor_container,
9545 editor,
9546 tui,
9547 list,
9548 SelectorKind::Theme,
9549 );
9550}
9551
9552fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9559 use rpi_ai::types::ThinkingLevel::*;
9560 match level {
9561 Off => "Off — No reasoning",
9562 Minimal => "Minimal — Brief reasoning (~1k tokens)",
9563 Low => "Low — Light reasoning (~1k tokens)",
9564 Medium => "Medium — Moderate reasoning (~80% of max)",
9565 High => "High — Extensive reasoning (~95% of max)",
9566 Xhigh => "Xhigh — Near-maximal reasoning",
9567 Max => "Max — Maximum reasoning",
9568 }
9569}
9570
9571fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9574 use rpi_ai::types::ThinkingLevel::*;
9575 match level {
9576 Off => "off",
9577 Minimal => "minimal",
9578 Low => "low",
9579 Medium => "medium",
9580 High => "high",
9581 Xhigh => "xhigh",
9582 Max => "max",
9583 }
9584}
9585
9586fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9589 use rpi_ai::types::ThinkingLevel::*;
9590 match name.to_ascii_lowercase().as_str() {
9591 "off" => Some(Off),
9592 "minimal" => Some(Minimal),
9593 "low" => Some(Low),
9594 "medium" => Some(Medium),
9595 "high" => Some(High),
9596 "xhigh" => Some(Xhigh),
9597 "max" => Some(Max),
9598 _ => None,
9599 }
9600}
9601
9602fn open_thinking_selector(
9612 state: &Arc<TuiState>,
9613 editor_container: &Arc<Container>,
9614 editor: &Arc<Editor>,
9615 tui: &Arc<TuiAltScreen>,
9616 lane: &Arc<dyn AgentLane>,
9617 catalog: &[rpi_ai::Model],
9618 lane_model_id: &str,
9619 chat: &Arc<Container>,
9620) {
9621 let model = catalog
9624 .iter()
9625 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9626 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9627 .map(|m| m.supported_thinking_levels())
9628 .unwrap_or_else(|| {
9629 use rpi_ai::types::ThinkingLevel::*;
9630 vec![Off, Minimal, Low, Medium, High]
9631 });
9632 let mut items: Vec<SelectItem> = Vec::new();
9633 for lvl in &levels {
9634 let name = thinking_level_name(*lvl);
9635 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9636 }
9637 if items.is_empty() {
9638 add_note_message(chat, "This model has no supported thinking levels.");
9639 tui.request_render(false);
9640 return;
9641 }
9642 let list = Arc::new(SelectList::new(items, 10));
9643
9644 let state_sel = state.clone();
9645 let ec_sel = editor_container.clone();
9646 let editor_sel = editor.clone();
9647 let tui_sel = tui.clone();
9648 let chat_sel = chat.clone();
9649 let lane_sel = lane.clone();
9650 list.on_select(Arc::new(move |item| {
9651 let Some(level) = thinking_level_from_name(&item.value) else {
9652 add_note_message(
9653 &chat_sel,
9654 &format!("Unknown thinking level: {}.", item.label),
9655 );
9656 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9657 return;
9658 };
9659 let lane = lane_sel.clone();
9660 let footer_sel = state_sel.footer.clone();
9661 tokio::spawn(async move {
9662 let _ = lane.set_thinking_level(level).await;
9663 });
9664 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9668 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9669 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9670 }));
9671 let state_cancel = state.clone();
9672 let ec_cancel = editor_container.clone();
9673 let editor_cancel = editor.clone();
9674 let tui_cancel = tui.clone();
9675 list.on_cancel(Arc::new(move || {
9676 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9677 }));
9678
9679 open_selector(
9680 state,
9681 editor_container,
9682 editor,
9683 tui,
9684 list,
9685 SelectorKind::Thinking,
9686 );
9687}
9688
9689fn open_tools_selector(
9695 state: &Arc<TuiState>,
9696 editor_container: &Arc<Container>,
9697 editor: &Arc<Editor>,
9698 tui: &Arc<TuiAltScreen>,
9699 lane: &Arc<dyn AgentLane>,
9700 chat: &Arc<Container>,
9701) {
9702 let mut active = match tokio::runtime::Handle::try_current() {
9707 Ok(h) => h
9708 .block_on(async { lane.get_active_tools().await })
9709 .unwrap_or_default(),
9710 Err(_) => Vec::new(),
9711 };
9712 if active.is_empty() {
9717 active = crate::session::BUILTIN_TOOL_NAMES
9718 .iter()
9719 .map(|name| (*name).to_string())
9720 .collect();
9721 }
9722 let mut items: Vec<SelectItem> = Vec::new();
9723 for name in crate::session::BUILTIN_TOOL_NAMES {
9724 let on = active.iter().any(|a| a == name);
9725 let label = if on {
9726 format!("{name} (on)")
9727 } else {
9728 (*name).to_string()
9729 };
9730 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9731 }
9732 let list = Arc::new(SelectList::new(items, 10));
9733
9734 let active_captured = active.clone();
9736 let state_sel = state.clone();
9737 let ec_sel = editor_container.clone();
9738 let editor_sel = editor.clone();
9739 let tui_sel = tui.clone();
9740 let chat_sel = chat.clone();
9741 let lane_sel = lane.clone();
9742 list.on_select(Arc::new(move |item| {
9743 let mut next = active_captured.clone();
9744 if let Some(pos) = next.iter().position(|a| a == &item.value) {
9745 next.remove(pos);
9746 } else {
9747 next.push(item.value.clone());
9748 }
9749 let on = next.iter().any(|a| a == &item.value);
9750 let lane = lane_sel.clone();
9751 let next_clone = next.clone();
9752 tokio::spawn(async move {
9753 let _ = lane.set_active_tools(next_clone).await;
9754 });
9755 let list_str = if next.is_empty() {
9756 "(none)".to_string()
9757 } else {
9758 next.join(", ")
9759 };
9760 add_note_message(
9761 &chat_sel,
9762 &format!(
9763 "{} {} — active tools: {}",
9764 item.value,
9765 if on { "enabled" } else { "disabled" },
9766 list_str
9767 ),
9768 );
9769 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9770 }));
9771 let state_cancel = state.clone();
9772 let ec_cancel = editor_container.clone();
9773 let editor_cancel = editor.clone();
9774 let tui_cancel = tui.clone();
9775 list.on_cancel(Arc::new(move || {
9776 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9777 }));
9778
9779 open_selector(
9780 state,
9781 editor_container,
9782 editor,
9783 tui,
9784 list,
9785 SelectorKind::Tools,
9786 );
9787}
9788
9789fn open_images_selector(
9793 state: &Arc<TuiState>,
9794 editor_container: &Arc<Container>,
9795 editor: &Arc<Editor>,
9796 tui: &Arc<TuiAltScreen>,
9797 chat: &Arc<Container>,
9798) {
9799 let current = *state.show_images.lock().unwrap();
9800 let items = vec![
9801 SelectItem::new("yes", "Yes").with_description(if current {
9802 "Inline images (current)"
9803 } else {
9804 "Inline images"
9805 }),
9806 SelectItem::new("no", "No").with_description(if current {
9807 "Placeholder only"
9808 } else {
9809 "Placeholder only (current)"
9810 }),
9811 ];
9812 let list = Arc::new(SelectList::new(items, 5));
9813
9814 let state_sel = state.clone();
9815 let ec_sel = editor_container.clone();
9816 let editor_sel = editor.clone();
9817 let tui_sel = tui.clone();
9818 let chat_sel = chat.clone();
9819 list.on_select(Arc::new(move |item| {
9820 let on = item.value == "yes";
9821 *state_sel.show_images.lock().unwrap() = on;
9822 add_note_message(
9823 &chat_sel,
9824 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9825 );
9826 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9827 }));
9828 let state_cancel = state.clone();
9829 let ec_cancel = editor_container.clone();
9830 let editor_cancel = editor.clone();
9831 let tui_cancel = tui.clone();
9832 list.on_cancel(Arc::new(move || {
9833 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9834 }));
9835
9836 open_selector(
9837 state,
9838 editor_container,
9839 editor,
9840 tui,
9841 list,
9842 SelectorKind::Images,
9843 );
9844}
9845
9846fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9854 let text = editor.get_text();
9855 let cursor = editor_cursor_offset(editor, &text);
9856 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9857 render_autocomplete(state, suggestions);
9858}
9859
9860fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9863 let (row, col) = editor.cursor_position();
9864 let mut offset = 0;
9865 for (index, line) in text.split('\n').enumerate() {
9866 if index == row {
9867 return (offset + col.min(line.len())).min(text.len());
9868 }
9869 offset = offset.saturating_add(line.len() + 1);
9870 }
9871 text.len()
9872}
9873
9874fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9877 let offset = offset.min(text.len());
9878 let mut consumed = 0;
9879 for (row, line) in text.split('\n').enumerate() {
9880 let end = consumed + line.len();
9881 if offset <= end {
9882 editor.set_cursor(row, offset - consumed);
9883 return;
9884 }
9885 consumed = end + 1;
9886 }
9887 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9888 editor.set_cursor(
9889 last_row,
9890 text.rsplit('\n').next().map(str::len).unwrap_or(0),
9891 );
9892}
9893
9894fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9896 state.autocomplete_container.clear();
9897 let Some(sugg) = suggestions else {
9898 return;
9899 };
9900 if sugg.items.is_empty() {
9901 return;
9902 }
9903 let accent = state.theme_manager.get().colors.accent;
9906 let muted = state.theme_manager.get().colors.muted;
9907 for (i, item) in sugg
9908 .items
9909 .iter()
9910 .take(state.autocomplete_max_visible)
9911 .enumerate()
9912 {
9913 let prefix = if i == 0 { "→ " } else { " " };
9914 let label = item.display_text();
9915 let line = if i == 0 {
9916 format!(
9917 "{prefix}{} {}",
9918 accent.fg(label),
9919 muted.fg(item.description.as_deref().unwrap_or(""))
9920 )
9921 } else {
9922 format!(
9923 "{prefix}{} {}",
9924 muted.fg(label),
9925 muted.fg(item.description.as_deref().unwrap_or(""))
9926 )
9927 };
9928 state
9929 .autocomplete_container
9930 .add_child(Arc::new(Text::new(line, 1, 0)));
9931 }
9932}
9933
9934fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9938 let text = editor.get_text();
9939 let cursor = editor_cursor_offset(editor, &text);
9940 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9941 return false;
9942 };
9943 let Some(top) = sugg.items.first() else {
9944 return false;
9945 };
9946 let start = sugg.start.min(text.len());
9950 let end = sugg.end.min(text.len());
9951 let mut replaced = String::with_capacity(text.len() + top.text.len());
9952 replaced.push_str(&text[..start]);
9953 replaced.push_str(&top.text);
9954 replaced.push_str(&text[end..]);
9957 if top.insert_space && !replaced.ends_with('/') {
9958 replaced.push(' ');
9959 }
9960 let new_cursor = replaced.len().min(
9963 start
9964 + top.text.len()
9965 + if top.insert_space && !top.text.ends_with('/') {
9966 1
9967 } else {
9968 0
9969 },
9970 );
9971 editor.set_text(&replaced);
9972 set_editor_cursor_offset(editor, &replaced, new_cursor);
9973 state.autocomplete_container.clear();
9974 true
9975}
9976
9977fn add_welcome_message(container: &Arc<Container>) {
9983 add_welcome_message_with_capabilities(container, &[], &[]);
9984}
9985
9986fn add_welcome_message_with_capabilities(
9990 container: &Arc<Container>,
9991 active_tools: &[String],
9992 skills: &[String],
9993) {
9994 let c = current_theme().colors;
9995 container.add_child(Arc::new(crate::brand::BrandLockup::new()));
9998 container.add_child(Arc::new(Spacer::new(1)));
9999 let title = c.muted.fg("interactive TUI · library-first agent runtime");
10002 container.add_child(Arc::new(Text::new(title, 1, 0)));
10003 container.add_child(Arc::new(Spacer::new(1)));
10004 container.add_child(Arc::new(Text::new(
10005 c.dim.fg("Type your message and press Enter to send."),
10006 1,
10007 0,
10008 )));
10009 let hint = c
10010 .dim
10011 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
10012 container.add_child(Arc::new(Text::new(hint, 1, 0)));
10013 container.add_child(Arc::new(Spacer::new(1)));
10014 container.add_child(Arc::new(Text::new(
10015 welcome_capability_line("Tools", active_tools),
10016 1,
10017 0,
10018 )));
10019 container.add_child(Arc::new(Text::new(
10020 welcome_capability_line("Skills", skills),
10021 1,
10022 0,
10023 )));
10024 container.add_child(Arc::new(DynamicBorder::new()));
10025}
10026
10027fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
10030 let colors = current_theme().colors;
10031 let group = Arc::new(Container::new());
10032
10033 for warning in &report.warnings {
10034 let body = format!(
10035 "{}\n{} {}{}",
10036 colors.error.fg(&warning.message),
10037 colors.muted.fg("Run"),
10038 colors.accent.fg(&warning.command),
10039 colors.muted.fg(" to retry.")
10040 );
10041 add_update_panel(&group, "Update Failed", &body, colors.error);
10042 }
10043
10044 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
10045 let body = format!(
10046 "{} {}{}",
10047 colors
10048 .muted
10049 .fg(&format!("New version {} is available. Run", notice.latest)),
10050 colors.accent.fg(¬ice.command),
10051 colors.muted.fg(".")
10052 );
10053 add_update_panel(&group, "Update Available", &body, colors.warning);
10054 }
10055
10056 let package_notices = report
10057 .notices
10058 .iter()
10059 .filter(|notice| notice.name != "rpi")
10060 .collect::<Vec<_>>();
10061 if !package_notices.is_empty() {
10062 let command = package_notices[0].command.as_str();
10063 let mut lines = vec![format!(
10064 "{} {}{}",
10065 colors.muted.fg("Package updates are available. Run"),
10066 colors.accent.fg(command),
10067 colors.muted.fg(".")
10068 )];
10069 lines.push(colors.muted.fg("Packages:"));
10070 lines.extend(
10071 package_notices
10072 .into_iter()
10073 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
10074 );
10075 add_update_panel(
10076 &group,
10077 "Package Updates Available",
10078 &lines.join("\n"),
10079 colors.warning,
10080 );
10081 }
10082
10083 if group.child_count() > 0 {
10087 container.add_child(group);
10088 }
10089}
10090
10091fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
10092 container.add_child(Arc::new(Spacer::new(1)));
10093 container.add_child(Arc::new(DynamicBorder::with_color(color)));
10094 container.add_child(Arc::new(Text::new(
10095 format!("{}\n{body}", color.fg(&tui_bold(title))),
10096 1,
10097 0,
10098 )));
10099 container.add_child(Arc::new(DynamicBorder::with_color(color)));
10100}
10101
10102fn welcome_capability_line(label: &str, names: &[String]) -> String {
10103 let c = current_theme().colors;
10104 let value = if names.is_empty() {
10105 "none".to_string()
10106 } else {
10107 names.join(" · ")
10108 };
10109 format!(
10110 "{} {}",
10111 c.accent.fg(&format!("{label} ({})", names.len())),
10112 c.muted.fg(&value)
10113 )
10114}
10115
10116fn add_help_message(container: &Arc<Container>) {
10118 let c = current_theme().colors;
10119 container.add_child(Arc::new(Text::new(
10123 c.md_heading.fg(&tui_bold("📚 Available Commands")),
10124 1,
10125 0,
10126 )));
10127 container.add_child(Arc::new(Spacer::new(1)));
10128
10129 let cmds: &[(&str, &str)] = &[
10130 ("/help, /?", "Show this help message"),
10131 ("/clear, /new", "Clear the conversation"),
10132 ("/exit, /quit, /q", "Exit the application"),
10133 ("/version, /v", "Show version information"),
10134 ("/changelog", "Show recent release changes"),
10135 ("/model, /m", "Choose a model (live switch)"),
10136 ("/thinking, /think", "Set reasoning depth (selector)"),
10137 ("/tools", "Toggle built-in tools on/off"),
10138 ("/images", "Toggle inline image rendering"),
10139 ("/session", "List saved sessions"),
10140 ("/theme", "Choose a theme (selector)"),
10141 ("/compact", "Compact the conversation"),
10142 ("/copy", "Copy last reply to clipboard"),
10143 ("/hotkeys", "Show keyboard shortcuts"),
10144 ("/armin", "🐾 Easter egg"),
10145 ("/earendil", "Earendil announcement"),
10146 ];
10147 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10148 for (cmd, desc) in cmds {
10149 let row = format!(
10150 " {:<cmd_w$} {} {}",
10151 c.accent.fg(cmd),
10152 c.dim.fg("—"),
10153 c.muted.fg(desc)
10154 );
10155 container.add_child(Arc::new(Text::new(row, 1, 0)));
10156 }
10157 container.add_child(Arc::new(Spacer::new(1)));
10158}
10159
10160fn add_version_message(container: &Arc<Container>) {
10162 let c = current_theme().colors;
10163 container.add_child(Arc::new(Text::new(
10164 c.md_heading.fg(&tui_bold("📦 Version Information")),
10165 1,
10166 0,
10167 )));
10168 container.add_child(Arc::new(Spacer::new(1)));
10169 container.add_child(Arc::new(Text::new(
10172 format!(
10173 " {} {}",
10174 c.muted.fg("rpi-cli"),
10175 c.text.fg(&format!("v{}", crate::VERSION))
10176 ),
10177 1,
10178 0,
10179 )));
10180 container.add_child(Arc::new(Text::new(
10181 format!(
10182 " {}",
10183 c.dim.fg("Rust implementation of pi coding agent TUI")
10184 ),
10185 1,
10186 0,
10187 )));
10188 container.add_child(Arc::new(Spacer::new(1)));
10189}
10190
10191fn add_changelog_message(container: &Arc<Container>) {
10195 let c = current_theme().colors;
10196 container.add_child(Arc::new(Text::new(
10197 c.md_heading.fg(&tui_bold("Recent Changes")),
10198 1,
10199 0,
10200 )));
10201 container.add_child(Arc::new(Spacer::new(1)));
10202 let entries = [
10203 (
10204 "Native parity phase 1",
10205 "models, images, trust, export, and JSON events",
10206 ),
10207 (
10208 "TUI controls",
10209 "external editor, thinking levels, and tool output toggles",
10210 ),
10211 (
10212 "Provider auth",
10213 "OpenAI-compatible API key aliases and gateway headers",
10214 ),
10215 ];
10216 for (release, summary) in entries {
10217 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
10218 container.add_child(Arc::new(Text::new(row, 1, 0)));
10219 }
10220 container.add_child(Arc::new(Text::new(
10221 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10222 1,
10223 0,
10224 )));
10225 container.add_child(Arc::new(Spacer::new(1)));
10226}
10227
10228fn add_hotkeys_message(container: &Arc<Container>) {
10230 let c = current_theme().colors;
10231 container.add_child(Arc::new(Text::new(
10232 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
10233 1,
10234 0,
10235 )));
10236 container.add_child(Arc::new(Spacer::new(1)));
10237 let keys: &[(&str, &str)] = &[
10238 ("Enter", "Send message"),
10239 ("Shift+Enter", "New line"),
10240 ("Tab", "Accept autocomplete suggestion"),
10241 ("Ctrl+A / Ctrl+E", "Line start / end"),
10242 (
10243 "Ctrl+K / Ctrl+U",
10244 "Kill to end / start of line (Ctrl+Y yanks)",
10245 ),
10246 ("Ctrl+- / Ctrl+R", "Undo / redo"),
10247 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10248 ("Alt+Backspace", "Kill previous word"),
10249 ("Ctrl+C", "Abort a run, or exit when idle"),
10250 ("Esc", "Abort a running prompt"),
10251 ("Ctrl+L", "Open model selector"),
10252 ("Ctrl+M", "Cycle to the next model (live)"),
10253 ("Ctrl+O", "Expand/collapse all tool output"),
10254 ("Ctrl+T", "Show/hide reasoning blocks"),
10255 ("PageUp/Down", "Scroll transcript by one page"),
10256 ("Home / End", "Jump to transcript start / latest output"),
10257 ];
10258 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10259 for (key, desc) in keys {
10260 let row = format!(
10261 " {:<key_w$} {} {}",
10262 c.accent.fg(key),
10263 c.dim.fg("—"),
10264 c.muted.fg(desc)
10265 );
10266 container.add_child(Arc::new(Text::new(row, 1, 0)));
10267 }
10268 container.add_child(Arc::new(Spacer::new(1)));
10269}
10270
10271fn add_user_message(container: &Arc<Container>, text: &str) {
10277 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10278 container.add_child(Arc::new(Spacer::new(1)));
10279}
10280
10281fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10284 let width = 80; chat_container.render(width)
10286}
10287
10288fn format_resume_command(
10293 session_id: &str,
10294 cwd: &std::path::Path,
10295 session_dir: Option<&std::path::Path>,
10296) -> Option<String> {
10297 if session_id.is_empty() {
10298 return None;
10299 }
10300 let mut args = vec![crate::APP_NAME.to_string()];
10301 if let Some(dir) = session_dir {
10302 let default = crate::session::default_session_dir(cwd);
10303 if dir != default.as_path() {
10304 args.push("--session-dir".to_string());
10305 args.push(quote_shell_arg(&dir.to_string_lossy()));
10306 }
10307 }
10308 args.push("--session".to_string());
10309 args.push(session_id.to_string());
10310 Some(args.join(" "))
10311}
10312
10313fn quote_shell_arg(value: &str) -> String {
10316 let needs_quoting = value.is_empty()
10317 || value
10318 .chars()
10319 .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10320 if needs_quoting {
10321 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10322 } else {
10323 value.to_string()
10324 }
10325}
10326
10327fn add_error_message(container: &Arc<Container>, text: &str) {
10328 let c = current_theme().colors;
10329 let text = sanitize_error_message(text);
10330 container.add_child(Arc::new(Text::new(
10331 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
10332 1,
10333 0,
10334 )));
10335 container.add_child(Arc::new(Spacer::new(1)));
10336}
10337
10338fn sanitize_error_message(text: &str) -> String {
10343 const MAX_ERROR_CHARS: usize = 16 * 1024;
10344 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10345 let mut count = 0;
10346 let mut escape_mode = 0u8;
10348 for ch in text.chars() {
10349 if escape_mode != 0 {
10350 match escape_mode {
10351 1 if ch == '[' => escape_mode = 2,
10352 1 if ch == ']' => escape_mode = 3,
10353 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10354 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10355 3 if ch == '\x07' => escape_mode = 0,
10356 _ => {}
10357 }
10358 continue;
10359 }
10360 if ch == '\x1b' {
10361 escape_mode = 1;
10362 continue;
10363 }
10364 if count >= MAX_ERROR_CHARS {
10365 result.push_str("…");
10366 break;
10367 }
10368 match ch {
10369 '\n' | '\t' => {
10370 result.push(ch);
10371 count += 1;
10372 }
10373 '\r' => {}
10374 c if c.is_control() => {}
10375 c => {
10376 result.push(c);
10377 count += 1;
10378 }
10379 }
10380 }
10381 if result.trim().is_empty() {
10382 "Provider request failed.".to_string()
10383 } else {
10384 result
10385 }
10386}
10387
10388fn add_note_message(container: &Arc<Container>, text: &str) {
10390 let c = current_theme().colors;
10391 container.add_child(Arc::new(Text::new(
10392 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10393 1,
10394 0,
10395 )));
10396 container.add_child(Arc::new(Spacer::new(1)));
10397}
10398
10399fn show_context_panel(
10408 chat: &Arc<Container>,
10409 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10410) {
10411 let skills = resources.skills.as_deref().unwrap_or(&[]);
10412 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10413 let mut lines: Vec<String> = Vec::new();
10414 lines.push("📂 Discovered resources for this session:".into());
10415
10416 if skills.is_empty() {
10417 lines.push(
10418 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10419 );
10420 } else {
10421 lines.push(format!(" Skills ({}):", skills.len()));
10422 for s in skills {
10423 let marker = if s.disable_model_invocation == Some(true) {
10424 " [hidden]"
10425 } else {
10426 ""
10427 };
10428 let desc: String = s.description.chars().take(72).collect();
10429 lines.push(format!(" • {}{marker} — {desc}", s.name));
10430 }
10431 }
10432
10433 if templates.is_empty() {
10434 lines.push(
10435 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10436 );
10437 } else {
10438 lines.push(format!(" Prompt templates ({}):", templates.len()));
10439 for t in templates {
10440 let desc = t
10441 .description
10442 .as_deref()
10443 .unwrap_or("(no description)")
10444 .chars()
10445 .take(72)
10446 .collect::<String>();
10447 lines.push(format!(" • /{} — {desc}", t.name));
10448 }
10449 }
10450 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10451 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10452 lines.push(
10453 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10454 .into(),
10455 );
10456 let body = lines.join("\n");
10457 container_note_block(chat, &body);
10458}
10459
10460fn container_note_block(container: &Arc<Container>, body: &str) {
10462 for line in body.lines() {
10463 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10464 }
10465 container.add_child(Arc::new(Spacer::new(1)));
10466}
10467
10468pub fn is_tui_supported() -> bool {
10474 std::io::stdout().is_terminal()
10475}
10476
10477#[allow(unused_imports)]
10479use rpi_tui::Color as _Color;
10480
10481#[cfg(test)]
10482mod tests {
10483 use super::*;
10484 use rpi_tui::Component;
10485
10486 #[test]
10487 fn markdown_flag_only_comes_from_an_explicit_true() {
10488 assert!(tool_result_requests_markdown(Some(
10490 &serde_json::json!({"kind": "todo", "markdown": true})
10491 )));
10492 assert!(!tool_result_requests_markdown(None));
10495 assert!(!tool_result_requests_markdown(Some(
10496 &serde_json::json!({"kind": "todo"})
10497 )));
10498 assert!(!tool_result_requests_markdown(Some(
10499 &serde_json::json!({"markdown": false})
10500 )));
10501 assert!(!tool_result_requests_markdown(Some(
10502 &serde_json::json!({"markdown": "true"})
10503 )));
10504 }
10505
10506 fn test_tui_state() -> Arc<TuiState> {
10509 Arc::new(TuiState {
10510 current_assistant: std::sync::Mutex::new(None),
10511 tool_components: std::sync::Mutex::new(HashMap::new()),
10512 bash_components: std::sync::Mutex::new(HashMap::new()),
10513 themes_enabled: true,
10514 hide_thinking: std::sync::Mutex::new(false),
10515 tool_outputs_expanded: std::sync::Mutex::new(false),
10516 show_terminal_progress: true,
10517 status: std::sync::Mutex::new(RunStatus::Idle),
10518 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10519 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10520 js_preparation_cancel: std::sync::Mutex::new(None),
10521 user_bash_cancel: std::sync::Mutex::new(None),
10522 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10523 footer: Arc::new(FooterComponent::new()),
10524 status_container: Arc::new(Container::new()),
10525 chat_container: Arc::new(Container::new()),
10526 loader: Arc::new(Loader::new()),
10527 editor: Arc::new(Editor::simple()),
10528 last_assistant_text: std::sync::Mutex::new(String::new()),
10529 active_selector: std::sync::Mutex::new(None),
10530 active_extension_editor: std::sync::Mutex::new(None),
10531 active_extension_input: std::sync::Mutex::new(None),
10532 active_extension_cancel: std::sync::Mutex::new(None),
10533 autocomplete: AutocompleteManager::new(),
10534 autocomplete_container: Arc::new(Container::new()),
10535 autocomplete_max_visible: 5,
10536 pending_images: std::sync::Mutex::new(Vec::new()),
10537 theme_manager: Arc::new(ThemeManager::new()),
10538 tui: None,
10539 current_model_id: std::sync::Mutex::new(String::new()),
10540 show_images: std::sync::Mutex::new(true),
10541 cache_miss_notices: std::sync::Mutex::new(false),
10542 history: std::sync::Mutex::new(Vec::new()),
10543 history_index: std::sync::Mutex::new(-1),
10544 history_draft: std::sync::Mutex::new(None),
10545 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10546 scoped_edit: std::sync::Mutex::new(None),
10547 markdown_transformer: std::sync::Mutex::new(None),
10548 extension_session: Arc::new(std::sync::Mutex::new(
10549 rpi_extensions::ExtensionSession::none(),
10550 )),
10551 search: Arc::new(AltScreenSearch::new()),
10552 search_bar: Arc::new(SearchBar::new()),
10553 pending_container: Arc::new(Container::new()),
10554 dequeue_hint: String::new(),
10555 pending_snapshot: std::sync::Mutex::new(
10556 rpi_harness::agent_harness::QueuedMessages::default(),
10557 ),
10558 selection_start: std::sync::Mutex::new(None),
10559 selection_end: std::sync::Mutex::new(None),
10560 })
10561 }
10562
10563 #[test]
10564 fn live_panel_repaints_while_a_tool_or_bash_is_running() {
10565 let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
10568 assert!(!transcript_has_live_panel(false, &empty));
10569
10570 let mut tools = HashMap::new();
10573 let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
10574 tool.set_running();
10575 tools.insert("tc1".to_string(), tool);
10576 assert!(transcript_has_live_panel(false, &tools));
10577
10578 tools.get("tc1").unwrap().set_result("done", false);
10581 assert!(!transcript_has_live_panel(false, &tools));
10582 assert!(transcript_has_live_panel(true, &tools));
10583 }
10584
10585 #[test]
10586 fn dequeue_merge_preserves_a_typed_draft() {
10587 assert_eq!(merge_queued_into_draft("queued", ""), "queued");
10588 assert_eq!(merge_queued_into_draft("queued", " "), "queued");
10589 assert_eq!(
10590 merge_queued_into_draft("queued", "typed"),
10591 "queued\n\ntyped"
10592 );
10593 }
10594
10595 #[test]
10596 fn settings_menu_covers_the_wired_settings_surface() {
10597 let state = test_tui_state();
10598 let settings = crate::settings::Settings::default();
10599 let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10600 let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10601 assert_eq!(
10602 keys,
10603 [
10604 "theme",
10605 "model",
10606 "thinking",
10607 "scoped-models",
10608 "hide-thinking",
10609 "show-images",
10610 "cache-miss-notices",
10611 "quiet-startup",
10612 "terminal-progress",
10613 "fullscreen-copy-on-select",
10614 "double-escape-action",
10615 "editor-padding",
10616 "autocomplete-max-items",
10617 "http-idle-timeout",
10618 ]
10619 );
10620 for item in items.iter().filter(|item| item.has_submenu) {
10622 assert!(
10623 item.values.is_empty(),
10624 "{} should be submenu-only",
10625 item.key
10626 );
10627 }
10628 for item in items.iter().filter(|item| !item.has_submenu) {
10630 assert!(!item.values.is_empty(), "{} has no values", item.key);
10631 assert!(
10632 item.values.iter().any(|value| value == &item.value),
10633 "{} current value {} not in {:?}",
10634 item.key,
10635 item.value,
10636 item.values
10637 );
10638 }
10639 assert_eq!(items[1].value, "claude-sonnet-5");
10641 assert_eq!(items[6].value, "false");
10643 assert_eq!(items[8].value, "true");
10644 }
10645
10646 #[test]
10647 fn apply_setting_change_updates_settings_and_live_state() {
10648 let state = test_tui_state();
10649 let mut settings = crate::settings::Settings::default();
10650
10651 let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10653 assert!(state.hide_thinking());
10654 assert_eq!(settings.hide_thinking_block, Some(true));
10655 assert!(note.expect("user-visible note").contains("hidden"));
10656
10657 apply_setting_change(&state, "show-images", "false", &mut settings);
10659 assert!(!*state.show_images.lock().unwrap());
10660 assert_eq!(settings.show_images, Some(false));
10661
10662 apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10664 assert!(*state.cache_miss_notices.lock().unwrap());
10665 assert_eq!(settings.show_cache_miss_notices, Some(true));
10666
10667 assert_eq!(
10669 apply_setting_change(&state, "editor-padding", "3", &mut settings),
10670 None
10671 );
10672 assert_eq!(settings.editor_padding_x, Some(3));
10673 apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10674 assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10675 apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10676 assert_eq!(settings.autocomplete_max_visible, Some(20));
10677
10678 apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10680 assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10681 apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10682 assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10683
10684 assert_eq!(
10686 apply_setting_change(&state, "nope", "x", &mut settings),
10687 None
10688 );
10689 }
10690
10691 #[test]
10692 fn add_user_message_renders_a_visible_user_bubble() {
10693 let chat = Arc::new(Container::new());
10697 add_user_message(&chat, "hello from the user");
10698 let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10699 let plain = strip_ansi(&rendered.join("\n"));
10700 assert!(
10701 plain.contains("hello from the user"),
10702 "user bubble must show the prompt text; got: {plain:?}"
10703 );
10704 }
10705
10706 #[test]
10707 fn verbose_overrides_quiet_startup_listing() {
10708 assert!(should_show_startup_listing(false, false));
10709 assert!(should_show_startup_listing(true, false));
10710 assert!(should_show_startup_listing(true, true));
10711 assert!(!should_show_startup_listing(false, true));
10712 }
10713
10714 #[test]
10715 fn resume_command_omits_the_default_session_dir() {
10716 let cwd = std::path::Path::new("/proj/demo");
10717 assert_eq!(format_resume_command("", cwd, None), None);
10718 assert_eq!(
10719 format_resume_command("abc-123", cwd, None).as_deref(),
10720 Some("rpi --session abc-123")
10721 );
10722 let default = crate::session::default_session_dir(cwd);
10724 assert_eq!(
10725 format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10726 Some("rpi --session abc-123")
10727 );
10728 let custom = std::path::Path::new("/tmp/my sessions");
10730 assert_eq!(
10731 format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
10732 Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
10733 );
10734 }
10735
10736 #[test]
10737 fn quote_shell_arg_quotes_only_when_needed() {
10738 assert_eq!(quote_shell_arg("plain"), "plain");
10739 assert_eq!(quote_shell_arg("with space"), "\"with space\"");
10740 assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
10741 }
10742
10743 #[test]
10744 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
10745 let tmp = tempfile::tempdir().unwrap();
10746 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
10747 let local_root = tmp.path().join("local-package");
10748 std::fs::create_dir_all(git_root.join(".git")).unwrap();
10749 std::fs::create_dir_all(&local_root).unwrap();
10750 std::fs::write(
10751 git_root.join("package.json"),
10752 r#"{"name":"git-demo","version":"1.0.0"}"#,
10753 )
10754 .unwrap();
10755 std::fs::write(
10756 local_root.join("package.json"),
10757 r#"{"name":"local-demo","version":"1.0.0"}"#,
10758 )
10759 .unwrap();
10760 let resources = crate::packages::discover(
10761 tmp.path(),
10762 &[
10763 "git:github.com/example/repo".to_string(),
10764 local_root.to_string_lossy().into_owned(),
10765 ],
10766 );
10767 assert_eq!(resources.packages.len(), 2);
10768
10769 let filtered = git_only_update_resources(resources);
10770
10771 assert_eq!(filtered.packages.len(), 1);
10772 assert!(matches!(
10773 filtered.packages[0].source,
10774 crate::packages::PackageSource::Git
10775 ));
10776 }
10777
10778 #[test]
10779 fn trust_command_uses_the_session_cwd_after_process_chdir() {
10780 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
10781 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
10782 const TEST_NAME: &str =
10783 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
10784
10785 if std::env::var_os(CHILD_ENV).is_some() {
10786 let session_cwd = std::path::PathBuf::from(
10787 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
10788 );
10789 let changed_cwd = std::env::current_dir().unwrap();
10790
10791 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
10792
10793 assert_eq!(
10794 crate::config::project_trust_decision(&session_cwd).unwrap(),
10795 Some(true)
10796 );
10797 assert_eq!(
10798 crate::config::project_trust_decision(&changed_cwd).unwrap(),
10799 None
10800 );
10801 return;
10802 }
10803
10804 let tmp = tempfile::tempdir().unwrap();
10805 let session_cwd = tmp.path().join("session-project");
10806 let changed_cwd = tmp.path().join("extension-cwd");
10807 let agent_dir = tmp.path().join("agent");
10808 std::fs::create_dir_all(&session_cwd).unwrap();
10809 std::fs::create_dir_all(&changed_cwd).unwrap();
10810 std::fs::create_dir_all(&agent_dir).unwrap();
10811
10812 let status = std::process::Command::new(std::env::current_exe().unwrap())
10813 .arg("--exact")
10814 .arg(TEST_NAME)
10815 .arg("--nocapture")
10816 .env(CHILD_ENV, "1")
10817 .env(SESSION_CWD_ENV, &session_cwd)
10818 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
10819 .current_dir(&changed_cwd)
10820 .status()
10821 .unwrap();
10822
10823 assert!(status.success(), "child test process failed: {status}");
10824 }
10825
10826 #[test]
10827 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
10828 let global = crate::settings::Settings {
10829 editor_padding_x: Some(3),
10830 autocomplete_max_visible: Some(6),
10831 hide_thinking_block: Some(false),
10832 quiet_startup: Some(true),
10833 show_terminal_progress: Some(true),
10834 ..Default::default()
10835 };
10836 let rpi_project = crate::settings::Settings {
10837 editor_padding_x: Some(0),
10838 hide_thinking_block: Some(true),
10839 quiet_startup: Some(false),
10840 show_terminal_progress: Some(false),
10841 ..Default::default()
10842 };
10843 let pi_project = crate::settings::Settings {
10844 editor_padding_x: Some(9),
10845 autocomplete_max_visible: Some(12),
10846 quiet_startup: Some(true),
10847 ..Default::default()
10848 };
10849
10850 assert_eq!(
10851 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
10852 TuiStartupSettings {
10853 editor_padding_x: 0,
10854 autocomplete_max_visible: 12,
10855 hide_thinking: true,
10856 quiet_startup: false,
10857 show_terminal_progress: false,
10858 show_images: true,
10859 cache_miss_notices: false,
10860 }
10861 );
10862 }
10863
10864 #[test]
10865 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
10866 let global = crate::settings::Settings {
10867 quiet_startup: Some(false),
10868 ..Default::default()
10869 };
10870 let rpi_project = crate::settings::Settings::default();
10871 let pi_project = crate::settings::Settings {
10872 quiet_startup: Some(true),
10873 ..Default::default()
10874 };
10875
10876 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
10877
10878 assert!(resolved.quiet_startup);
10879 }
10880
10881 #[test]
10882 fn tui_startup_settings_use_global_values_without_project_fields() {
10883 let global = crate::settings::Settings {
10884 editor_padding_x: Some(7),
10885 autocomplete_max_visible: Some(8),
10886 hide_thinking_block: Some(true),
10887 quiet_startup: Some(true),
10888 show_terminal_progress: Some(false),
10889 show_images: Some(false),
10890 show_cache_miss_notices: Some(true),
10891 ..Default::default()
10892 };
10893
10894 assert_eq!(
10895 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
10896 TuiStartupSettings {
10897 editor_padding_x: 7,
10898 autocomplete_max_visible: 8,
10899 hide_thinking: true,
10900 quiet_startup: true,
10901 show_terminal_progress: false,
10902 show_images: false,
10903 cache_miss_notices: true,
10904 }
10905 );
10906 }
10907
10908 #[test]
10909 fn tui_startup_settings_read_native_nested_terminal_block() {
10910 let global = crate::settings::Settings {
10913 terminal: Some(crate::settings::TerminalSettings {
10914 show_images: Some(false),
10915 show_terminal_progress: Some(false),
10916 clear_on_shrink: None,
10917 }),
10918 ..Default::default()
10919 };
10920 let resolved =
10921 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10922 assert!(!resolved.show_images);
10923 assert!(!resolved.show_terminal_progress);
10924 }
10925
10926 #[test]
10927 fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
10928 let global = crate::settings::Settings {
10929 show_images: Some(true),
10930 terminal: Some(crate::settings::TerminalSettings {
10931 show_images: Some(false),
10932 show_terminal_progress: None,
10933 clear_on_shrink: None,
10934 }),
10935 ..Default::default()
10936 };
10937 let resolved =
10938 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
10939 assert!(resolved.show_images);
10940 }
10941
10942 #[test]
10943 fn tui_startup_settings_ignore_untrusted_project_values() {
10944 let global = crate::settings::Settings {
10945 quiet_startup: Some(false),
10946 show_terminal_progress: Some(true),
10947 ..Default::default()
10948 };
10949 let project = crate::settings::Settings {
10950 quiet_startup: Some(true),
10951 show_terminal_progress: Some(false),
10952 ..Default::default()
10953 };
10954
10955 let resolved = resolve_tui_startup_settings(&global, &[project], false);
10956
10957 assert!(!resolved.quiet_startup);
10958 assert!(resolved.show_terminal_progress);
10959 }
10960
10961 #[test]
10962 fn transcript_page_uses_viewport_with_overlap() {
10963 assert_eq!(transcript_page_size(24), 20);
10964 assert_eq!(transcript_page_size(4), 1);
10965 assert_eq!(transcript_page_size(0), 1);
10966 }
10967
10968 #[test]
10969 fn parse_user_bash_distinguishes_single_and_double_bang() {
10970 assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10972 assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10974 assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
10976 assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
10977 }
10978
10979 #[test]
10980 fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10981 assert_eq!(parse_user_bash("!"), None);
10984 assert_eq!(parse_user_bash("!!"), None);
10985 assert_eq!(parse_user_bash("! "), None);
10986 assert_eq!(parse_user_bash("hello"), None);
10987 assert_eq!(parse_user_bash(""), None);
10988 assert_eq!(parse_user_bash("/model"), None);
10990 }
10991
10992 #[test]
10993 fn user_bash_run_slot_guards_and_cancels() {
10994 let state = test_tui_state();
10995 assert!(!state.user_bash_running());
10996 let cancel = state.begin_user_bash();
10997 assert!(state.user_bash_running());
10998 assert!(state.cancel_user_bash());
11001 assert!(state.user_bash_running());
11002 assert!(cancel.is_cancelled());
11003 state.finish_user_bash();
11004 assert!(!state.user_bash_running());
11005 assert!(!state.cancel_user_bash());
11007 }
11008
11009 #[test]
11010 fn key_repeat_is_dispatched_but_release_is_not() {
11011 assert!(should_dispatch_key(KeyEventKind::Press));
11012 assert!(should_dispatch_key(KeyEventKind::Repeat));
11013 assert!(!should_dispatch_key(KeyEventKind::Release));
11014 }
11015
11016 #[test]
11017 fn key_event_encoding_matches_pi_keybinding_protocol() {
11018 let key = |code, modifiers| KeyEvent::new(code, modifiers);
11019 assert_eq!(
11020 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
11021 "\r"
11022 );
11023 assert_eq!(
11024 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
11025 "\x1b[13;2u"
11026 );
11027 assert_eq!(
11028 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
11029 "\x1b[9;2u"
11030 );
11031 assert_eq!(
11032 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
11033 "\x1b[Z"
11034 );
11035 assert_eq!(
11036 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
11037 "\x1b[Z"
11038 );
11039 assert_eq!(
11040 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
11041 "\x03"
11042 );
11043 assert_eq!(
11044 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
11045 "\x0f"
11046 );
11047 assert_eq!(
11048 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
11049 "!"
11050 );
11051 assert_eq!(
11052 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
11053 "1"
11054 );
11055 }
11056
11057 #[test]
11058 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
11059 let bridge = JsDialogBridge::default();
11060 let (sender, receiver) = std_mpsc::channel();
11061 bridge.pending.lock().unwrap().push_back(JsDialogPending {
11062 request: JsDialogRequest {
11063 id: "dialog-1".into(),
11064 method: "input".into(),
11065 title: String::new(),
11066 message: String::new(),
11067 options: Vec::new(),
11068 placeholder: None,
11069 prefill: None,
11070 },
11071 result: sender,
11072 });
11073
11074 bridge.cancel("dialog-1");
11077 assert!(bridge.take_pending().is_none());
11078 assert_eq!(
11079 receiver.recv().unwrap(),
11080 serde_json::json!({ "cancelled": true })
11081 );
11082 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
11083 }
11084
11085 #[test]
11086 fn test_layout_renders_welcome_message() {
11087 let chat = Arc::new(Container::new());
11088 add_welcome_message(&chat);
11089
11090 let scroll = Arc::new(ScrollView::new(
11091 chat.clone(),
11092 ScrollViewOptions {
11093 follow: FollowMode::End,
11094 primary: true,
11095 ..Default::default()
11096 },
11097 ));
11098
11099 let editor = Arc::new(Editor::new(
11100 EditorOptions {
11101 padding_x: 1,
11102 ..Default::default()
11103 },
11104 EditorStyle::default(),
11105 Arc::new(rpi_tui::Keybindings::new()),
11106 ));
11107 let dock = Arc::new(Container::new());
11108 dock.add_child(editor);
11109
11110 let footer = Arc::new(FooterComponent::new());
11111
11112 let root = VStack::from_children(vec![
11113 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
11114 StackChild::Entry(StackEntry::new(dock)),
11115 StackChild::Entry(StackEntry::new(footer)),
11116 ]);
11117
11118 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
11119
11120 let all: String = frame.lines.join("\n");
11121 assert!(
11122 all.contains("rpi"),
11123 "Welcome message not found. Rendered: {}",
11124 all
11125 );
11126 assert!(
11127 all.contains("Type your message"),
11128 "Help text not found. Rendered: {}",
11129 all
11130 );
11131 }
11132
11133 #[test]
11134 fn test_chat_container_has_welcome_content() {
11135 let chat = Arc::new(Container::new());
11136 add_welcome_message_with_capabilities(
11137 &chat,
11138 &["read".into(), "bash".into(), "web_fetch".into()],
11139 &["rust-review".into(), "release".into()],
11140 );
11141
11142 let lines = chat.render(80);
11143 let all: String = lines.join("\n");
11144 let plain = strip_ansi(&all);
11147 assert!(
11148 plain.contains("rpi"),
11149 "Welcome message not in chat container: {:?}",
11150 lines
11151 );
11152 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
11153 assert!(
11154 plain.contains("read · bash · web_fetch"),
11155 "Tool names missing: {plain}"
11156 );
11157 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
11158 assert!(
11159 plain.contains("rust-review · release"),
11160 "Skill names missing: {plain}"
11161 );
11162 }
11163
11164 #[test]
11165 fn welcome_header_renders_the_brand_mark() {
11166 let chat = Arc::new(Container::new());
11167 add_welcome_message(&chat);
11168 let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
11169 let all = lines.join("\n");
11170
11171 assert!(
11174 lines.iter().any(|l| l.contains("██ ██ ██")),
11175 "brand mark missing: {all}"
11176 );
11177 assert!(
11178 all.contains("rpi"),
11179 "the mark must carry the product name: {all}"
11180 );
11181
11182 assert!(
11188 !all.contains('π'),
11189 "the header must not use east-asian-ambiguous glyphs: {all}"
11190 );
11191 }
11192
11193 #[test]
11194 fn update_notices_render_inside_the_transcript() {
11195 let chat = Arc::new(Container::new());
11196 let report = crate::updates::UpdateReport {
11197 notices: vec![
11198 crate::updates::UpdateNotice {
11199 name: "rpi".into(),
11200 current: "0.1.10".into(),
11201 latest: "0.1.11".into(),
11202 command: "rpi update".into(),
11203 },
11204 crate::updates::UpdateNotice {
11205 name: "rpi-search".into(),
11206 current: "0.1.0".into(),
11207 latest: "0.1.1".into(),
11208 command: "rpi pi-package update".into(),
11209 },
11210 ],
11211 warnings: vec![crate::updates::UpdateWarning {
11212 message: "The previously scheduled rpi update failed: access denied".into(),
11213 command: "rpi update".into(),
11214 }],
11215 };
11216
11217 add_update_notices(&chat, &report);
11218
11219 assert_eq!(chat.child_count(), 1);
11220 let plain = strip_ansi(&chat.render(80).join("\n"));
11221 assert!(plain.contains("Update Failed"), "{plain}");
11222 assert!(
11223 plain.contains("rpi update failed: access denied"),
11224 "{plain}"
11225 );
11226 assert!(plain.contains("Update Available"), "{plain}");
11227 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11228 assert!(plain.contains("rpi update"), "{plain}");
11229 assert!(plain.contains("Package Updates Available"), "{plain}");
11230 assert!(plain.contains("rpi pi-package update"), "{plain}");
11231 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11232 }
11233
11234 #[test]
11235 fn empty_update_report_does_not_add_transcript_content() {
11236 let chat = Arc::new(Container::new());
11237
11238 add_update_notices(&chat, &crate::updates::UpdateReport::default());
11239
11240 assert_eq!(chat.child_count(), 0);
11241 }
11242
11243 #[test]
11244 fn skill_reads_are_detected_by_path() {
11245 let name = skill_tool_name(
11246 "read",
11247 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
11248 );
11249 assert_eq!(name.as_deref(), Some("release"));
11250
11251 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
11252 assert!(name.is_none());
11253
11254 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
11256 }
11257
11258 #[test]
11259 fn welcome_capabilities_show_empty_state() {
11260 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11261 assert_eq!(plain, "Skills (0) none");
11262 }
11263
11264 #[test]
11265 fn empty_tool_progress_is_deferred_until_start() {
11266 let empty = rpi_agent::AgentToolResult::default();
11267 assert!(!tool_update_has_payload("", &empty));
11268
11269 let text = rpi_agent::AgentToolResult::text("partial output");
11270 assert!(tool_update_has_payload("partial output", &text));
11271
11272 let details = rpi_agent::AgentToolResult {
11273 details: serde_json::json!({"path": "src/lib.rs"}),
11274 ..Default::default()
11275 };
11276 assert!(tool_update_has_payload("", &details));
11277 }
11278
11279 #[test]
11280 fn completed_stream_reconciles_the_final_tail_before_detaching() {
11281 let component = Arc::new(AssistantMessageComponent::new(
11282 AssistantMessageOptions::default(),
11283 ));
11284 component.set_streaming(true);
11285 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11286
11287 let current = Mutex::new(Some(component.clone()));
11288 let cached = Mutex::new("partial response".to_string());
11289 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11290 final_message.content = vec![Content::text(
11291 "partial response with the previously missing final tail",
11292 )];
11293 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11294
11295 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
11296
11297 assert!(current.lock().unwrap().is_none());
11298 assert_eq!(
11299 cached.lock().unwrap().as_str(),
11300 "partial response with the previously missing final tail"
11301 );
11302 let rendered = strip_ansi(&component.render(100).join("\n"));
11303 assert!(
11304 rendered.contains("previously missing final tail"),
11305 "{rendered}"
11306 );
11307 }
11308
11309 #[test]
11315 fn tab_accept_suggestion_reflects_in_next_render() {
11316 use rpi_tui::render_layout_frame;
11317
11318 let editor = Arc::new(Editor::new(
11319 EditorOptions {
11320 padding_x: 1,
11321 ..Default::default()
11322 },
11323 EditorStyle::default(),
11324 Arc::new(rpi_tui::Keybindings::new()),
11325 ));
11326 editor.set_focused(true);
11327 let editor_container = Arc::new(Container::new());
11328 editor_container.add_child(editor.clone());
11329 let autocomplete_container = Arc::new(Container::new());
11330 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11331 let dock = Arc::new(VStack::from_children(vec![
11332 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11333 StackChild::Entry(
11334 StackEntry::new(editor_container.clone())
11335 .shrink(0)
11336 .min_size(3),
11337 ),
11338 StackChild::Entry(StackEntry::new(footer)),
11339 ]));
11340
11341 let manager = AutocompleteManager::new();
11343 let mut combined = CombinedAutocompleteProvider::new();
11344 combined.add_provider(Arc::new(
11345 SlashCommandAutocompleteProvider::with_default_commands(),
11346 ));
11347 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11348 manager.set_provider(Arc::new(combined));
11349 editor.insert("/mo");
11352 assert_eq!(editor.cursor_position(), (0, 3));
11353
11354 let frame_before = render_layout_frame(dock.clone(), 80, 10);
11355 assert!(
11356 frame_before.lines.iter().any(|l| l.contains("/mo")),
11357 "precondition: editor shows the typed prefix. Frame rows:\n{}",
11358 frame_before
11359 .lines
11360 .iter()
11361 .map(|l| format!(" [{l}]"))
11362 .collect::<Vec<_>>()
11363 .join("\n")
11364 );
11365
11366 let text = editor.get_text();
11368 let cursor = editor_cursor_offset(&editor, &text);
11369 let sugg = manager
11370 .get_suggestions(&text, cursor)
11371 .expect("slash suggestions for /mo");
11372 let top = sugg.items.first().expect("at least one suggestion");
11373 let start = sugg.start.min(text.len());
11374 let end = sugg.end.min(text.len());
11375 let mut replaced = String::new();
11376 replaced.push_str(&text[..start]);
11377 replaced.push_str(&top.text);
11378 replaced.push_str(&text[end..]);
11379 if top.insert_space && !replaced.ends_with('/') {
11380 replaced.push(' ');
11381 }
11382 let new_cursor = start + top.text.len();
11383 editor.set_text(&replaced);
11384 set_editor_cursor_offset(&editor, &replaced, new_cursor);
11385 autocomplete_container.clear();
11386 assert_eq!(editor.get_text(), "/model");
11387
11388 let frame_after = render_layout_frame(dock, 80, 10);
11390 let all: String = frame_after.lines.join("\n");
11391 assert!(
11392 all.contains("/model"),
11393 "completed text missing from next render. Got:\n{all}"
11394 );
11395 let editor_line = frame_after
11399 .lines
11400 .iter()
11401 .find(|l| l.contains("/model"))
11402 .expect("editor row with completed text");
11403 assert!(
11404 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11405 "caret must follow the full completed text. Got: {editor_line:?}"
11406 );
11407 }
11408
11409 #[test]
11410 fn multiline_autocomplete_preserves_row_and_column() {
11411 let editor = Arc::new(Editor::simple());
11412 editor.set_text("first\n/mo");
11413 editor.set_cursor(1, 3);
11414
11415 let text = editor.get_text();
11416 assert_eq!(editor_cursor_offset(&editor, &text), 9);
11417
11418 let replaced = "first\n/model";
11419 editor.set_text(replaced);
11420 set_editor_cursor_offset(&editor, replaced, 12);
11421 assert_eq!(editor.cursor_position(), (1, 6));
11422 }
11423
11424 #[test]
11425 fn test_slash_command_dispatch() {
11426 let registry = build_builtin_registry();
11431
11432 let resolves_to = |token: &str, canonical: &str| {
11434 let found = registry.find(token).expect("{token} should resolve");
11435 assert_eq!(
11436 found.name(),
11437 canonical,
11438 "{token} resolved to {} (expected {canonical})",
11439 found.name()
11440 );
11441 };
11442
11443 resolves_to("/help", "/help");
11444 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
11446 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
11450 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
11452 resolves_to("/hotkeys", "/hotkeys");
11453 resolves_to("/model", "/model");
11454 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
11456 resolves_to("/session", "/session");
11457 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
11459 resolves_to("/copy", "/copy");
11460 resolves_to("/thinking", "/thinking");
11461 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
11463 resolves_to("/images", "/images");
11464 resolves_to("/armin", "/armin");
11465 resolves_to("/earendil", "/earendil");
11466 resolves_to("/context", "/context");
11467 resolves_to("/settings", "/settings");
11469 resolves_to("/name", "/name");
11470 resolves_to("/export", "/export");
11471
11472 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11474 }
11475
11476 #[test]
11477
11478 fn test_registry_visible_entries_cover_dispatch() {
11479 let registry = build_builtin_registry();
11484 let names: Vec<String> = registry
11485 .visible_entries()
11486 .iter()
11487 .map(|c| c.name.clone())
11488 .collect();
11489 for recognized in [
11490 "/help",
11491 "/clear",
11492 "/new",
11493 "/exit",
11494 "/quit",
11495 "/version",
11496 "/changelog",
11497 "/model",
11498 "/session",
11499 "/theme",
11500 "/compact",
11501 "/copy",
11502 "/hotkeys",
11503 "/tools",
11504 "/images",
11505 "/thinking",
11506 "/usage",
11507 "/armin",
11508 "/earendil",
11509 ] {
11510 assert!(
11511 names.contains(&recognized.to_string()),
11512 "{recognized} missing from autocomplete list"
11513 );
11514 }
11515 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11517 assert!(
11518 !names.contains(&hidden.to_string()),
11519 "{hidden} should be hidden from autocomplete"
11520 );
11521 }
11522 }
11523
11524 #[test]
11525 fn test_agent_event_mapping_creates_assistant_and_tool() {
11526 use rpi_ai::types::{
11529 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11530 ToolCall, ToolCallType, Usage,
11531 };
11532
11533 let state = Arc::new(TuiState {
11534 current_assistant: std::sync::Mutex::new(None),
11535 tool_components: std::sync::Mutex::new(HashMap::new()),
11536 bash_components: std::sync::Mutex::new(HashMap::new()),
11537 themes_enabled: true,
11538 hide_thinking: std::sync::Mutex::new(false),
11539 tool_outputs_expanded: std::sync::Mutex::new(false),
11540 show_terminal_progress: true,
11541 status: std::sync::Mutex::new(RunStatus::Idle),
11542 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11543 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11544 js_preparation_cancel: std::sync::Mutex::new(None),
11545 user_bash_cancel: std::sync::Mutex::new(None),
11546 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11547 footer: Arc::new(FooterComponent::new()),
11548 status_container: Arc::new(Container::new()),
11549 chat_container: Arc::new(Container::new()),
11550 loader: Arc::new(Loader::new()),
11551 editor: Arc::new(Editor::simple()),
11552 last_assistant_text: std::sync::Mutex::new(String::new()),
11553 active_selector: std::sync::Mutex::new(None),
11554 active_extension_editor: std::sync::Mutex::new(None),
11555 active_extension_input: std::sync::Mutex::new(None),
11556 active_extension_cancel: std::sync::Mutex::new(None),
11557 autocomplete: AutocompleteManager::new(),
11558 autocomplete_container: Arc::new(Container::new()),
11559 autocomplete_max_visible: 5,
11560 pending_images: std::sync::Mutex::new(Vec::new()),
11561 theme_manager: Arc::new(ThemeManager::new()),
11562 tui: None,
11563 current_model_id: std::sync::Mutex::new(String::new()),
11564 show_images: std::sync::Mutex::new(true),
11565 cache_miss_notices: std::sync::Mutex::new(false),
11566 history: std::sync::Mutex::new(Vec::new()),
11567 history_index: std::sync::Mutex::new(-1),
11568 history_draft: std::sync::Mutex::new(None),
11569 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11570 scoped_edit: std::sync::Mutex::new(None),
11571 markdown_transformer: std::sync::Mutex::new(None),
11572 extension_session: Arc::new(std::sync::Mutex::new(
11573 rpi_extensions::ExtensionSession::none(),
11574 )),
11575 search: Arc::new(AltScreenSearch::new()),
11576 search_bar: Arc::new(SearchBar::new()),
11577 pending_container: Arc::new(Container::new()),
11578 dequeue_hint: String::new(),
11579 pending_snapshot: std::sync::Mutex::new(
11580 rpi_harness::agent_harness::QueuedMessages::default(),
11581 ),
11582 selection_start: std::sync::Mutex::new(None),
11583 selection_end: std::sync::Mutex::new(None),
11584 });
11585
11586 let assistant = AssistantMessage {
11590 role: rpi_ai::types::AssistantRole,
11591 content: vec![
11592 Content::Thinking(ThinkingContent {
11593 kind: ThinkingContentType,
11594 thinking: "Reasoning about the reply.".into(),
11595 thinking_signature: None,
11596 redacted: false,
11597 }),
11598 Content::Text(TextContent {
11599 kind: TextContentType,
11600 text: "Hello.".into(),
11601 text_signature: None,
11602 }),
11603 Content::ToolCall(ToolCall {
11604 kind: ToolCallType,
11605 id: "tc1".into(),
11606 name: "bash".into(),
11607 arguments: serde_json::json!({"command": "echo hi"}),
11608 thought_signature: None,
11609 namespace: None,
11610 }),
11611 ],
11612 api: rpi_ai::Api::AnthropicMessages,
11613 provider: "anthropic".into(),
11614 model: "claude-sonnet-5".into(),
11615 response_model: None,
11616 response_id: None,
11617 usage: Usage::zero(),
11618 stop_reason: StopReason::Stop,
11619 deferred: None,
11620 error_message: None,
11621 raw_stop_reason: None,
11622 end_turn: None,
11623 timestamp: 0,
11624 };
11625
11626 let comp = Arc::new(AssistantMessageComponent::new(
11629 AssistantMessageOptions::default(),
11630 ));
11631 comp.set_streaming(true);
11632 comp.update_blocks(&assistant_blocks(&assistant));
11633 let chat = Arc::new(Container::new());
11634 chat.add_child(comp.clone());
11635 *state.current_assistant.lock().unwrap() = Some(comp);
11636
11637 for c in &assistant.content {
11639 if let Content::ToolCall(tc) = c {
11640 let mut tools = state.tool_components.lock().unwrap();
11641 if !tools.contains_key(&tc.id) {
11642 let tc_comp = Arc::new(ToolExecutionComponent::new(
11643 &tc.name,
11644 &tc.arguments.to_string(),
11645 ));
11646 tc_comp.set_running();
11647 chat.add_child(tc_comp.clone());
11648 tools.insert(tc.id.clone(), tc_comp);
11649 }
11650 }
11651 }
11652
11653 let rendered = chat.render(80);
11657 let joined: String = rendered.join("\n");
11658 assert!(
11659 joined.contains("Hello."),
11660 "assistant text not rendered: {joined}"
11661 );
11662 assert!(
11663 joined.contains("Reasoning about the reply."),
11664 "thinking block not rendered: {joined}"
11665 );
11666 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11667 assert!(state.current_assistant.lock().unwrap().is_some());
11668
11669 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11671 ended.set_result("hi", false);
11672 assert!(state.tool_components.lock().unwrap().is_empty());
11673
11674 assert!(state.try_start_working());
11678 assert!(
11679 !state.try_start_working(),
11680 "a second submit must be rejected"
11681 );
11682 state.set_status(RunStatus::Idle);
11683 state.set_status(RunStatus::Working);
11684 assert!(state.editor.working().is_some());
11686 assert_eq!(state.status_container.child_count(), 0);
11687 assert!(state.footer.get_status().is_empty());
11688 state.show_retry(3, 10, 8_000);
11689 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11690 assert!(retry_status.contains("Retrying (3/10)"));
11691 state.set_status(RunStatus::Working);
11692 {
11693 let mut bash = state.bash_components.lock().unwrap();
11694 bash.insert(
11695 "bash-1".into(),
11696 Arc::new(BashExecutionComponent::new("one")),
11697 );
11698 bash.insert(
11699 "bash-2".into(),
11700 Arc::new(BashExecutionComponent::new("two")),
11701 );
11702 }
11703 state.sync_working_loader_with_bash();
11704 assert_eq!(state.status_container.child_count(), 0);
11705 state.bash_components.lock().unwrap().remove("bash-1");
11706 state.sync_working_loader_with_bash();
11707 assert_eq!(state.status_container.child_count(), 0);
11708 state.bash_components.lock().unwrap().remove("bash-2");
11709 state.sync_working_loader_with_bash();
11710 assert_eq!(state.status_container.child_count(), 0);
11713 assert!(state.editor.working().is_some());
11714
11715 state.set_status(RunStatus::Aborting);
11716 assert_eq!(state.status_container.child_count(), 0);
11717 assert!(!state.loader.is_running());
11718 }
11719
11720 #[test]
11721 fn fresh_launch_does_not_restore_old_history() {
11722 let fresh = Args::default();
11723 assert!(!launch_restores_history(&fresh));
11724
11725 let continued = Args {
11726 continue_session: true,
11727 ..Args::default()
11728 };
11729 assert!(launch_restores_history(&continued));
11730
11731 let selected = Args {
11732 session: Some("session-id".into()),
11733 ..Args::default()
11734 };
11735 assert!(launch_restores_history(&selected));
11736 }
11737
11738 #[test]
11739 fn context_badge_ignores_aborted_and_error_turns() {
11740 use rpi_ai::types::{Api, StopReason, Usage};
11741
11742 let footer = Arc::new(FooterComponent::new());
11743 footer.set_context_window(100_000);
11744
11745 let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11747 ok.stop_reason = StopReason::Stop;
11748 ok.usage = Usage {
11749 input: 25_000,
11750 ..Usage::zero()
11751 };
11752 record_context_usage(&footer, &ok);
11753 let stats = strip_ansi(&footer.render(120).join("\n"));
11754 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11755
11756 let mut aborted =
11758 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11759 aborted.stop_reason = StopReason::Aborted;
11760 aborted.usage = Usage {
11761 input: 90_000,
11762 ..Usage::zero()
11763 };
11764 record_context_usage(&footer, &aborted);
11765 let stats = strip_ansi(&footer.render(120).join("\n"));
11766 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11767
11768 let mut errored =
11770 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11771 errored.stop_reason = StopReason::Error;
11772 errored.usage = Usage {
11773 input: 90_000,
11774 ..Usage::zero()
11775 };
11776 record_context_usage(&footer, &errored);
11777 let stats = strip_ansi(&footer.render(120).join("\n"));
11778 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
11779 }
11780
11781 #[test]
11782 fn test_short_model_name() {
11783 assert_eq!(
11784 short_model_name("anthropic:claude-sonnet-5"),
11785 "claude-sonnet-5"
11786 );
11787 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
11788 }
11789
11790 #[test]
11791 fn model_selector_items_are_deduplicated_and_provider_qualified() {
11792 use rpi_ai::{Api, Model};
11793
11794 let mut gateway = Model::new(
11795 "gpt-5.6-sol",
11796 "GPT 5.6 Sol",
11797 Api::OpenaiCompletions,
11798 "routeryo-copy",
11799 "https://gateway.example.com",
11800 );
11801 let duplicate = gateway.clone();
11802 let anthropic = Model::new(
11803 "claude-sonnet-5",
11804 "Claude Sonnet 5",
11805 Api::AnthropicMessages,
11806 "anthropic",
11807 "https://api.anthropic.com",
11808 );
11809 gateway.headers = Some(std::collections::BTreeMap::from([(
11810 "authorization".into(),
11811 "Bearer test".into(),
11812 )]));
11813
11814 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
11815 assert_eq!(items.len(), 2);
11816 assert_eq!(items[0].value, "gpt-5.6-sol");
11817 assert_eq!(items[0].label, "GPT 5.6 Sol");
11818 assert_eq!(
11819 items[0].description.as_deref(),
11820 Some("routeryo-copy/gpt-5.6-sol (current)")
11821 );
11822 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
11823 }
11824
11825 #[test]
11826 fn model_selector_search_text_covers_provider_name_and_qualified_id() {
11827 use rpi_ai::{Api, Model};
11828
11829 let anthropic = Model::new(
11830 "claude-sonnet-5",
11831 "Claude Sonnet 5",
11832 Api::AnthropicMessages,
11833 "anthropic",
11834 "https://api.anthropic.com",
11835 );
11836 let items = model_selector_items(&[anthropic], "");
11837 let search = items[0].search_key();
11838 assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
11841 assert!(search.contains("Claude Sonnet 5"), "{search}");
11842
11843 use rpi_tui::fuzzy_filter;
11846 for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
11847 let matched = fuzzy_filter(&items, query, |item| item.search_key());
11848 assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
11849 }
11850 }
11851
11852 #[test]
11853 fn model_selector_match_accepts_bare_and_qualified_ids() {
11854 use rpi_ai::{Api, Model};
11855
11856 let gateway = Model::new(
11857 "gpt-5.6-sol",
11858 "GPT 5.6 Sol",
11859 Api::OpenaiCompletions,
11860 "routeryo-copy",
11861 "https://gateway.example.com",
11862 );
11863 let anthropic = Model::new(
11864 "claude-sonnet-5",
11865 "Claude Sonnet 5",
11866 Api::AnthropicMessages,
11867 "anthropic",
11868 "https://api.anthropic.com",
11869 );
11870 let catalog = [gateway, anthropic];
11871 assert_eq!(
11872 find_model_selector_match(&catalog, "gpt-5.6-sol")
11873 .unwrap()
11874 .provider,
11875 "routeryo-copy"
11876 );
11877 assert_eq!(
11878 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
11879 .unwrap()
11880 .id,
11881 "gpt-5.6-sol"
11882 );
11883 assert_eq!(
11884 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
11885 .unwrap()
11886 .id,
11887 "claude-sonnet-5"
11888 );
11889 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
11890 }
11891
11892 #[test]
11893 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
11894 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
11895
11896 let failed = AssistantMessage {
11897 role: AssistantRole,
11898 content: Vec::new(),
11899 api: rpi_ai::Api::AnthropicMessages,
11900 provider: "anthropic".into(),
11901 model: "claude-sonnet-5".into(),
11902 response_model: None,
11903 response_id: None,
11904 usage: Usage::zero(),
11905 stop_reason: StopReason::Error,
11906 deferred: None,
11907 error_message: Some("upstream returned 401".into()),
11908 raw_stop_reason: None,
11909 end_turn: None,
11910 timestamp: 0,
11911 };
11912 assert_eq!(
11913 assistant_error_text(&failed).as_deref(),
11914 Some("upstream returned 401")
11915 );
11916
11917 let mut no_detail = failed;
11918 no_detail.error_message = Some(" ".into());
11919 assert_eq!(
11920 assistant_error_text(&no_detail).as_deref(),
11921 Some("Provider request failed.")
11922 );
11923
11924 let mut aborted = no_detail;
11925 aborted.stop_reason = StopReason::Aborted;
11926 aborted.error_message = Some("abort error: Request aborted".into());
11927 assert_eq!(
11928 assistant_error_text(&aborted).as_deref(),
11929 Some("abort error: Request aborted")
11930 );
11931
11932 aborted.error_message = None;
11933 assert_eq!(
11934 assistant_error_text(&aborted).as_deref(),
11935 Some("Request aborted.")
11936 );
11937 }
11938
11939 #[test]
11940 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
11941 assert_eq!(
11942 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
11943 "405\nMethod Not Allowed"
11944 );
11945 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
11946 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
11947 let long = "x".repeat(20_000);
11948 let cleaned = sanitize_error_message(&long);
11949 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
11950 assert!(cleaned.ends_with('…'));
11951 }
11952
11953 #[test]
11954 fn test_cycle_next_model_wraps_around() {
11955 use rpi_ai::{Api, Model};
11956 let mk = |id: &str| {
11957 Model::new(
11958 id,
11959 id,
11960 Api::AnthropicMessages,
11961 "anthropic",
11962 "https://api.anthropic.com",
11963 )
11964 };
11965 let catalog = [mk("a"), mk("b"), mk("c")];
11966 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
11968 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
11969 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
11971 let empty: Vec<Model> = vec![];
11973 assert!(cycle_next_model(&empty, "a").is_none());
11974 }
11975
11976 #[test]
11977 fn test_autocomplete_slash_suggestions_render() {
11978 let state = Arc::new(TuiState {
11981 current_assistant: std::sync::Mutex::new(None),
11982 tool_components: std::sync::Mutex::new(HashMap::new()),
11983 bash_components: std::sync::Mutex::new(HashMap::new()),
11984 themes_enabled: true,
11985 hide_thinking: std::sync::Mutex::new(false),
11986 tool_outputs_expanded: std::sync::Mutex::new(false),
11987 show_terminal_progress: true,
11988 status: std::sync::Mutex::new(RunStatus::Idle),
11989 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11990 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11991 js_preparation_cancel: std::sync::Mutex::new(None),
11992 user_bash_cancel: std::sync::Mutex::new(None),
11993 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11994 footer: Arc::new(FooterComponent::new()),
11995 status_container: Arc::new(Container::new()),
11996 chat_container: Arc::new(Container::new()),
11997 loader: Arc::new(Loader::new()),
11998 editor: Arc::new(Editor::simple()),
11999 last_assistant_text: std::sync::Mutex::new(String::new()),
12000 active_selector: std::sync::Mutex::new(None),
12001 active_extension_editor: std::sync::Mutex::new(None),
12002 active_extension_input: std::sync::Mutex::new(None),
12003 active_extension_cancel: std::sync::Mutex::new(None),
12004 autocomplete: AutocompleteManager::new(),
12005 autocomplete_container: Arc::new(Container::new()),
12006 autocomplete_max_visible: 5,
12007 pending_images: std::sync::Mutex::new(Vec::new()),
12008 theme_manager: Arc::new(ThemeManager::new()),
12009 tui: None,
12010 current_model_id: std::sync::Mutex::new(String::new()),
12011 show_images: std::sync::Mutex::new(true),
12012 cache_miss_notices: std::sync::Mutex::new(false),
12013 history: std::sync::Mutex::new(Vec::new()),
12014 history_index: std::sync::Mutex::new(-1),
12015 history_draft: std::sync::Mutex::new(None),
12016 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12017 scoped_edit: std::sync::Mutex::new(None),
12018 markdown_transformer: std::sync::Mutex::new(None),
12019 extension_session: Arc::new(std::sync::Mutex::new(
12020 rpi_extensions::ExtensionSession::none(),
12021 )),
12022 search: Arc::new(AltScreenSearch::new()),
12023 search_bar: Arc::new(SearchBar::new()),
12024 pending_container: Arc::new(Container::new()),
12025 dequeue_hint: String::new(),
12026 pending_snapshot: std::sync::Mutex::new(
12027 rpi_harness::agent_harness::QueuedMessages::default(),
12028 ),
12029 selection_start: std::sync::Mutex::new(None),
12030 selection_end: std::sync::Mutex::new(None),
12031 });
12032 {
12033 let mut combined = CombinedAutocompleteProvider::new();
12034 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12035 build_builtin_registry().visible_entries(),
12036 )));
12037 state.autocomplete.set_provider(Arc::new(combined));
12038 }
12039
12040 let editor = Arc::new(Editor::simple());
12041 editor.set_text("/he");
12042 editor.set_cursor(0, 3);
12043 refresh_autocomplete(&state, &editor);
12044 let lines = state.autocomplete_container.render(80);
12045 let joined: String = lines.join("\n");
12046 assert!(
12047 joined.contains("/help"),
12048 "slash suggestions not rendered: {joined}"
12049 );
12050
12051 editor.set_text("hello");
12053 editor.set_cursor(0, 5);
12054 refresh_autocomplete(&state, &editor);
12055 assert!(state.autocomplete_container.render(80).is_empty());
12056 }
12057
12058 #[test]
12059 fn test_select_list_swap_restores_editor() {
12060 let state = Arc::new(TuiState {
12064 current_assistant: std::sync::Mutex::new(None),
12065 tool_components: std::sync::Mutex::new(HashMap::new()),
12066 bash_components: std::sync::Mutex::new(HashMap::new()),
12067 themes_enabled: true,
12068 hide_thinking: std::sync::Mutex::new(false),
12069 tool_outputs_expanded: std::sync::Mutex::new(false),
12070 show_terminal_progress: true,
12071 status: std::sync::Mutex::new(RunStatus::Idle),
12072 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12073 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12074 js_preparation_cancel: std::sync::Mutex::new(None),
12075 user_bash_cancel: std::sync::Mutex::new(None),
12076 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12077 footer: Arc::new(FooterComponent::new()),
12078 status_container: Arc::new(Container::new()),
12079 chat_container: Arc::new(Container::new()),
12080 loader: Arc::new(Loader::new()),
12081 editor: Arc::new(Editor::simple()),
12082 last_assistant_text: std::sync::Mutex::new(String::new()),
12083 active_selector: std::sync::Mutex::new(None),
12084 active_extension_editor: std::sync::Mutex::new(None),
12085 active_extension_input: std::sync::Mutex::new(None),
12086 active_extension_cancel: std::sync::Mutex::new(None),
12087 autocomplete: AutocompleteManager::new(),
12088 autocomplete_container: Arc::new(Container::new()),
12089 autocomplete_max_visible: 5,
12090 pending_images: std::sync::Mutex::new(Vec::new()),
12091 theme_manager: Arc::new(ThemeManager::new()),
12092 tui: None,
12093 current_model_id: std::sync::Mutex::new(String::new()),
12094 show_images: std::sync::Mutex::new(true),
12095 cache_miss_notices: std::sync::Mutex::new(false),
12096 history: std::sync::Mutex::new(Vec::new()),
12097 history_index: std::sync::Mutex::new(-1),
12098 history_draft: std::sync::Mutex::new(None),
12099 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12100 scoped_edit: std::sync::Mutex::new(None),
12101 markdown_transformer: std::sync::Mutex::new(None),
12102 extension_session: Arc::new(std::sync::Mutex::new(
12103 rpi_extensions::ExtensionSession::none(),
12104 )),
12105 search: Arc::new(AltScreenSearch::new()),
12106 search_bar: Arc::new(SearchBar::new()),
12107 pending_container: Arc::new(Container::new()),
12108 dequeue_hint: String::new(),
12109 pending_snapshot: std::sync::Mutex::new(
12110 rpi_harness::agent_harness::QueuedMessages::default(),
12111 ),
12112 selection_start: std::sync::Mutex::new(None),
12113 selection_end: std::sync::Mutex::new(None),
12114 });
12115 let editor_container = Arc::new(Container::new());
12116 let editor = Arc::new(Editor::simple());
12117 editor_container.add_child(editor.clone());
12118 assert!(!state.selector_open());
12119
12120 let tui_terminal = Box::new(ProcessTerminal::new());
12121 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
12122 let list = Arc::new(SelectList::new(
12123 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
12124 5,
12125 ));
12126 open_selector(
12127 &state,
12128 &editor_container,
12129 &editor,
12130 &tui,
12131 list,
12132 SelectorKind::Theme,
12133 );
12134 assert!(state.selector_open());
12135 assert_eq!(editor_container.child_count(), 1);
12137
12138 close_selector(&state, &editor_container, &editor, &tui);
12139 assert!(!state.selector_open());
12140 assert_eq!(editor_container.child_count(), 1);
12142 }
12143
12144 #[test]
12145 fn test_message_history_browse_restores_draft() {
12146 let state = Arc::new(TuiState {
12150 current_assistant: std::sync::Mutex::new(None),
12151 tool_components: std::sync::Mutex::new(HashMap::new()),
12152 bash_components: std::sync::Mutex::new(HashMap::new()),
12153 themes_enabled: true,
12154 hide_thinking: std::sync::Mutex::new(false),
12155 tool_outputs_expanded: std::sync::Mutex::new(false),
12156 show_terminal_progress: true,
12157 status: std::sync::Mutex::new(RunStatus::Idle),
12158 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12159 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12160 js_preparation_cancel: std::sync::Mutex::new(None),
12161 user_bash_cancel: std::sync::Mutex::new(None),
12162 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12163 footer: Arc::new(FooterComponent::new()),
12164 status_container: Arc::new(Container::new()),
12165 chat_container: Arc::new(Container::new()),
12166 loader: Arc::new(Loader::new()),
12167 editor: Arc::new(Editor::simple()),
12168 last_assistant_text: std::sync::Mutex::new(String::new()),
12169 active_selector: std::sync::Mutex::new(None),
12170 active_extension_editor: std::sync::Mutex::new(None),
12171 active_extension_input: std::sync::Mutex::new(None),
12172 active_extension_cancel: std::sync::Mutex::new(None),
12173 autocomplete: AutocompleteManager::new(),
12174 autocomplete_container: Arc::new(Container::new()),
12175 autocomplete_max_visible: 5,
12176 pending_images: std::sync::Mutex::new(Vec::new()),
12177 theme_manager: Arc::new(ThemeManager::new()),
12178 tui: None,
12179 current_model_id: std::sync::Mutex::new(String::new()),
12180 show_images: std::sync::Mutex::new(true),
12181 cache_miss_notices: std::sync::Mutex::new(false),
12182 history: std::sync::Mutex::new(Vec::new()),
12183 history_index: std::sync::Mutex::new(-1),
12184 history_draft: std::sync::Mutex::new(None),
12185 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12186 scoped_edit: std::sync::Mutex::new(None),
12187 markdown_transformer: std::sync::Mutex::new(None),
12188 extension_session: Arc::new(std::sync::Mutex::new(
12189 rpi_extensions::ExtensionSession::none(),
12190 )),
12191 search: Arc::new(AltScreenSearch::new()),
12192 search_bar: Arc::new(SearchBar::new()),
12193 pending_container: Arc::new(Container::new()),
12194 dequeue_hint: String::new(),
12195 pending_snapshot: std::sync::Mutex::new(
12196 rpi_harness::agent_harness::QueuedMessages::default(),
12197 ),
12198 selection_start: std::sync::Mutex::new(None),
12199 selection_end: std::sync::Mutex::new(None),
12200 });
12201 let editor = Arc::new(Editor::simple());
12202
12203 push_history(&state, "first message");
12204 push_history(&state, "second message");
12205 push_history(&state, "second message");
12207 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
12209 assert_eq!(state.history.lock().unwrap()[0], "second message");
12210
12211 editor.set_text("half-typed");
12213 editor.set_cursor(0, 11);
12214
12215 navigate_history(&state, &editor, -1);
12217 assert_eq!(editor.get_text(), "second message");
12218 assert_eq!(*state.history_index.lock().unwrap(), 0);
12219 navigate_history(&state, &editor, -1);
12221 assert_eq!(editor.get_text(), "first message");
12222 assert_eq!(*state.history_index.lock().unwrap(), 1);
12223 navigate_history(&state, &editor, -1);
12225 assert_eq!(editor.get_text(), "first message");
12226 navigate_history(&state, &editor, 1);
12228 assert_eq!(editor.get_text(), "second message");
12229 navigate_history(&state, &editor, 1);
12231 assert_eq!(editor.get_text(), "half-typed");
12232 assert_eq!(*state.history_index.lock().unwrap(), -1);
12233 }
12234
12235 #[test]
12236 fn test_accept_top_suggestion_replaces_prefix() {
12237 let state = Arc::new(TuiState {
12239 current_assistant: std::sync::Mutex::new(None),
12240 tool_components: std::sync::Mutex::new(HashMap::new()),
12241 bash_components: std::sync::Mutex::new(HashMap::new()),
12242 themes_enabled: true,
12243 hide_thinking: std::sync::Mutex::new(false),
12244 tool_outputs_expanded: std::sync::Mutex::new(false),
12245 show_terminal_progress: true,
12246 status: std::sync::Mutex::new(RunStatus::Idle),
12247 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12248 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12249 js_preparation_cancel: std::sync::Mutex::new(None),
12250 user_bash_cancel: std::sync::Mutex::new(None),
12251 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12252 footer: Arc::new(FooterComponent::new()),
12253 status_container: Arc::new(Container::new()),
12254 chat_container: Arc::new(Container::new()),
12255 loader: Arc::new(Loader::new()),
12256 editor: Arc::new(Editor::simple()),
12257 last_assistant_text: std::sync::Mutex::new(String::new()),
12258 active_selector: std::sync::Mutex::new(None),
12259 active_extension_editor: std::sync::Mutex::new(None),
12260 active_extension_input: std::sync::Mutex::new(None),
12261 active_extension_cancel: std::sync::Mutex::new(None),
12262 autocomplete: AutocompleteManager::new(),
12263 autocomplete_container: Arc::new(Container::new()),
12264 autocomplete_max_visible: 5,
12265 pending_images: std::sync::Mutex::new(Vec::new()),
12266 theme_manager: Arc::new(ThemeManager::new()),
12267 tui: None,
12268 current_model_id: std::sync::Mutex::new(String::new()),
12269 show_images: std::sync::Mutex::new(true),
12270 cache_miss_notices: std::sync::Mutex::new(false),
12271 history: std::sync::Mutex::new(Vec::new()),
12272 history_index: std::sync::Mutex::new(-1),
12273 history_draft: std::sync::Mutex::new(None),
12274 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12275 scoped_edit: std::sync::Mutex::new(None),
12276 markdown_transformer: std::sync::Mutex::new(None),
12277 extension_session: Arc::new(std::sync::Mutex::new(
12278 rpi_extensions::ExtensionSession::none(),
12279 )),
12280 search: Arc::new(AltScreenSearch::new()),
12281 search_bar: Arc::new(SearchBar::new()),
12282 pending_container: Arc::new(Container::new()),
12283 dequeue_hint: String::new(),
12284 pending_snapshot: std::sync::Mutex::new(
12285 rpi_harness::agent_harness::QueuedMessages::default(),
12286 ),
12287 selection_start: std::sync::Mutex::new(None),
12288 selection_end: std::sync::Mutex::new(None),
12289 });
12290 {
12291 let mut combined = CombinedAutocompleteProvider::new();
12292 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12293 build_builtin_registry().visible_entries(),
12294 )));
12295 state.autocomplete.set_provider(Arc::new(combined));
12296 }
12297 let editor = Arc::new(Editor::simple());
12298 editor.set_text("/he");
12299 editor.set_cursor(0, 3);
12300 refresh_autocomplete(&state, &editor);
12301 let accepted = accept_top_suggestion(&state, &editor);
12302 assert!(accepted, "should accept the top suggestion");
12303 let text = editor.get_text();
12304 assert!(
12305 text.starts_with("/help"),
12306 "editor text should start with /help, got {text}"
12307 );
12308 }
12309
12310 #[test]
12311 fn configured_key_parser_supports_native_notation() {
12312 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12313 assert_eq!(combo.code, KeyCode::Char('g'));
12314 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12315 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12316 assert_eq!(combo.code, KeyCode::BackTab);
12317 }
12318
12319 #[test]
12320 fn double_escape_trigger_has_half_second_window() {
12321 let now = std::time::Instant::now();
12322 assert!(!double_escape_trigger(None, now));
12323 assert!(double_escape_trigger(
12324 Some(now - std::time::Duration::from_millis(500)),
12325 now
12326 ));
12327 assert!(!double_escape_trigger(
12328 Some(now - std::time::Duration::from_millis(501)),
12329 now
12330 ));
12331 }
12332
12333 #[test]
12334 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12335 use std::sync::atomic::{AtomicUsize, Ordering};
12341
12342 let editor = rpi_tui::Editor::simple();
12343 let submits = Arc::new(AtomicUsize::new(0));
12344 let submits_for_cb = submits.clone();
12345 editor.on_submit(Arc::new(move |_text: &str| {
12346 submits_for_cb.fetch_add(1, Ordering::SeqCst);
12347 }));
12348
12349 let now = std::time::Instant::now();
12352 let mut last_text_key_at: Option<std::time::Instant> = None;
12353 let block = ["l1", "l2", "l3"];
12354 for (index, line) in block.iter().enumerate() {
12355 for ch in line.chars() {
12356 editor.insert(&ch.to_string());
12357 last_text_key_at = Some(now);
12358 }
12359 if index + 1 < block.len() {
12360 let more_queued = true;
12362 assert!(
12363 enter_is_paste_burst(last_text_key_at, now, more_queued),
12364 "pasted newline {index} must not submit"
12365 );
12366 editor.insert("\n");
12367 last_text_key_at = Some(now);
12368 }
12369 }
12370 assert_eq!(editor.get_text(), "l1\nl2\nl3");
12371 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12372
12373 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12375 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12376 editor.handle_key(crossterm::event::KeyEvent::new(
12377 KeyCode::Enter,
12378 KeyModifiers::NONE,
12379 ));
12380 assert_eq!(
12381 submits.load(Ordering::SeqCst),
12382 1,
12383 "typed Enter submits once"
12384 );
12385 }
12386
12387 #[test]
12388 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12389 let editor = rpi_tui::Editor::simple();
12392 editor.insert("alpha\r\nbeta\r\n");
12393 assert_eq!(editor.get_text(), "alpha\nbeta\n");
12394 assert!(!editor.get_text().contains('\r'));
12395 }
12396
12397 #[test]
12398 fn pasted_enter_is_not_a_submit() {
12399 let now = std::time::Instant::now();
12400
12401 assert!(enter_is_paste_burst(None, now, true));
12403
12404 assert!(enter_is_paste_burst(
12407 Some(now - std::time::Duration::from_millis(1)),
12408 now,
12409 false
12410 ));
12411 assert!(enter_is_paste_burst(
12412 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12413 now,
12414 false
12415 ));
12416
12417 assert!(!enter_is_paste_burst(None, now, false));
12419 assert!(!enter_is_paste_burst(
12420 Some(now - PASTE_BURST_GAP),
12421 now,
12422 false
12423 ));
12424 assert!(!enter_is_paste_burst(
12425 Some(now - std::time::Duration::from_millis(120)),
12426 now,
12427 false
12428 ));
12429 }
12430
12431 #[test]
12432 fn paste_probe_ignores_the_enter_keys_own_release() {
12433 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12434 let release =
12435 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12436
12437 let mut queued = vec![Event::Key(release)].into_iter();
12440 let (more, stashed) = probe_paste_input(None, || queued.next());
12441 assert!(!more, "a key release alone is not queued paste input");
12442 assert!(stashed.is_none());
12443
12444 let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12447 let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12448 let (more, stashed) = probe_paste_input(None, || queued.next());
12449 assert!(more, "a queued key press is paste continuation");
12450 assert!(matches!(stashed, Some(Event::Key(_))));
12451
12452 let (more, stashed) = probe_paste_input(None, || None);
12454 assert!(!more);
12455 assert!(stashed.is_none());
12456
12457 let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12459 panic!("must not read when an event is already stashed")
12460 });
12461 assert!(more);
12462 assert!(matches!(stashed, Some(Event::Key(_))));
12463 }
12464
12465 #[test]
12468 fn ask_user_tool_names_are_recognized() {
12469 assert!(is_ask_user_tool("ask_user"));
12470 assert!(is_ask_user_tool("ask_user_question"));
12471 assert!(!is_ask_user_tool("bash"));
12472 }
12473
12474 #[test]
12475 fn ask_user_args_display_never_leaks_transport_json() {
12476 let text = ask_user_args_display(&serde_json::json!({
12477 "question": "你的代理端口是多少?",
12478 "context": "本机",
12479 "options": ["7890", {"title": "7897", "description": "clash"}],
12480 "suggest": "1080"
12481 }));
12482 assert!(text.contains("你的代理端口是多少?"));
12483 assert!(text.contains("本机"));
12484 assert!(text.contains("Choices: 7890, 7897"));
12485 assert!(text.contains("Suggestions: 1080"));
12486 assert!(!text.trim_start().starts_with('{'));
12487
12488 let multi = ask_user_args_display(&serde_json::json!({
12489 "questions": [
12490 {"id": "a", "question": "Q1"},
12491 {"id": "b", "header": "H", "question": "Q2"}
12492 ]
12493 }));
12494 assert!(multi.contains("Q1"));
12495 assert!(multi.contains("H: Q2"));
12496
12497 assert_eq!(
12498 ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
12499 "Confirmation requested: Deploy?"
12500 );
12501 assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
12502 }
12503
12504 #[test]
12505 fn ask_user_result_and_progress_prefer_readable_content() {
12506 let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
12507 assert!(
12508 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
12509 .contains("端口?")
12510 );
12511 let empty = rpi_agent::AgentToolResult::default();
12512 assert!(
12513 ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
12514 .contains("端口?")
12515 );
12516 let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
12517 assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
12518 assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
12519 }
12520
12521 #[test]
12522 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12523 let flat = rpi_extensions::UiDialogRequest {
12524 request_id: "r1".into(),
12525 tool_call_id: Some("c1".into()),
12526 ui: serde_json::json!({
12527 "kind": "selector",
12528 "id": "proxy_port",
12529 "header": "Proxy",
12530 "question": "端口?",
12531 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12532 "allowMultiple": true,
12533 "allowFreeform": false,
12534 "suggest": "1080"
12535 }),
12536 raw: serde_json::json!({}),
12537 };
12538 let prompt = parse_ask_user_prompt(&flat);
12539 assert_eq!(prompt.question_id, "proxy_port");
12540 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12541 assert_eq!(prompt.options.len(), 2);
12542 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12543 assert!(prompt.allow_multiple);
12544 assert!(!prompt.allow_freeform);
12545 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12546
12547 let alias = rpi_extensions::UiDialogRequest {
12548 ui: serde_json::json!({
12549 "questions": [{"id": "q", "question": "Q?", "options": []}]
12550 }),
12551 ..flat.clone()
12552 };
12553 let prompt = parse_ask_user_prompt(&alias);
12554 assert_eq!(prompt.question_id, "q");
12555 assert!(prompt.allow_freeform, "no options defaults to freeform");
12556
12557 let confirm = rpi_extensions::UiDialogRequest {
12558 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12559 ..flat.clone()
12560 };
12561 let prompt = parse_ask_user_prompt(&confirm);
12562 assert_eq!(prompt.kind, "confirm");
12563 assert_eq!(prompt.options.len(), 2);
12564 assert_eq!(prompt.options[0].0, "Yes");
12565 }
12566
12567 #[test]
12568 fn ask_user_bridge_answers_and_cancels_by_request_id() {
12569 let mailbox = rpi_extensions::UiDialogMailbox::new();
12570 mailbox.attach();
12571 let bridge = AskUserBridge::new(mailbox.clone());
12572
12573 mailbox
12575 .handle(serde_json::json!({
12576 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12577 "ui": {"kind": "input", "question": "Port?"}
12578 }))
12579 .unwrap();
12580 mailbox
12581 .handle(serde_json::json!({
12582 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12583 "ui": {"kind": "input", "question": "Level?"}
12584 }))
12585 .unwrap();
12586
12587 let first = bridge.take_pending().expect("r1 visible");
12588 assert_eq!(first.request_id, "r1");
12589 assert!(
12590 bridge.take_pending().is_none(),
12591 "only one request occupies the input slot"
12592 );
12593 bridge.respond("r1", serde_json::json!({"text": "7897"}));
12594 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12595
12596 let second = bridge.take_pending().expect("r2 visible");
12597 assert_eq!(second.request_id, "r2");
12598 bridge.cancel("r2");
12599 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12600
12601 mailbox
12603 .handle(serde_json::json!({
12604 "op": "open", "requestId": "r3",
12605 "ui": {"kind": "input", "question": "X?"}
12606 }))
12607 .unwrap();
12608 bridge.cancel_all();
12609 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12610
12611 bridge.shutdown();
12613 assert!(mailbox
12614 .handle(serde_json::json!({
12615 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12616 }))
12617 .is_err());
12618 }
12619}