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, 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;
60use crate::session_driver::{assistant_blocks, assistant_tool_calls};
61use crate::transcript_view::UiEvent;
62
63type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
70
71#[derive(Clone, Default)]
77struct JsDialogBridge {
78 pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
79 active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
80 visible: Arc<Mutex<Option<String>>>,
84 cancelled_before_open: Arc<Mutex<HashSet<String>>>,
85 closed: Arc<Mutex<bool>>,
86}
87
88struct JsDialogPending {
89 request: JsDialogRequest,
90 result: std_mpsc::Sender<serde_json::Value>,
91}
92
93struct JsDialogActive {
94 result: std_mpsc::Sender<serde_json::Value>,
95 cancel_requested: bool,
96}
97
98#[derive(Clone, Debug)]
99struct JsDialogRequest {
100 id: String,
101 method: String,
102 title: String,
103 message: String,
104 options: Vec<String>,
105 placeholder: Option<String>,
106 prefill: Option<String>,
107}
108
109impl JsDialogRequest {
110 fn parse(args: &serde_json::Value) -> Result<Self, String> {
111 let id = args
112 .get("dialogId")
113 .and_then(serde_json::Value::as_str)
114 .filter(|value| !value.is_empty())
115 .ok_or("ui.dialog missing dialogId")?
116 .to_string();
117 let method = args
118 .get("method")
119 .and_then(serde_json::Value::as_str)
120 .ok_or("ui.dialog missing method")?
121 .to_string();
122 if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
123 return Err(format!("unsupported UI dialog method: {method}"));
124 }
125 let options = args
126 .get("options")
127 .and_then(serde_json::Value::as_array)
128 .map(|values| {
129 values
130 .iter()
131 .filter_map(serde_json::Value::as_str)
132 .map(ToOwned::to_owned)
133 .collect()
134 })
135 .unwrap_or_default();
136 Ok(Self {
137 id,
138 method,
139 title: args
140 .get("title")
141 .and_then(serde_json::Value::as_str)
142 .unwrap_or_default()
143 .to_string(),
144 message: args
145 .get("message")
146 .and_then(serde_json::Value::as_str)
147 .unwrap_or_default()
148 .to_string(),
149 options,
150 placeholder: args
151 .get("placeholder")
152 .and_then(serde_json::Value::as_str)
153 .map(ToOwned::to_owned),
154 prefill: args
155 .get("prefill")
156 .and_then(serde_json::Value::as_str)
157 .map(ToOwned::to_owned),
158 })
159 }
160}
161
162impl JsDialogBridge {
163 fn handle_runtime_request(
164 &self,
165 action: &str,
166 args: serde_json::Value,
167 ) -> Result<serde_json::Value, String> {
168 match action {
169 "ui.dialog" => self.wait_for_dialog(args),
170 "ui.dialog.cancel" => {
171 let id = args
172 .get("dialogId")
173 .and_then(serde_json::Value::as_str)
174 .ok_or("ui.dialog.cancel missing dialogId")?;
175 self.cancel(id);
176 Ok(serde_json::json!(true))
177 }
178 _ => Err(format!("unsupported capability: {action}")),
179 }
180 }
181
182 fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
183 let request = JsDialogRequest::parse(&args)?;
184 let (sender, receiver) = std_mpsc::channel();
185 let _closed = self
189 .closed
190 .lock()
191 .map_err(|_| "JS dialog bridge poisoned")?;
192 if *_closed {
193 return Ok(serde_json::json!({ "cancelled": true }));
194 }
195 let cancelled_before_open = self
196 .cancelled_before_open
197 .lock()
198 .map_err(|_| "JS dialog cancellation state poisoned")?
199 .remove(&request.id);
200 if cancelled_before_open {
201 return Ok(serde_json::json!({ "cancelled": true }));
202 }
203 self.pending
206 .lock()
207 .map_err(|_| "JS dialog pending state poisoned")?
208 .push_back(JsDialogPending {
209 request,
210 result: sender,
211 });
212 drop(_closed);
213 receiver
214 .recv()
215 .map_err(|_| "JS dialog closed before it received an answer".to_string())
216 }
217
218 fn take_pending(&self) -> Option<JsDialogRequest> {
221 loop {
222 let pending = self.pending.lock().ok()?.pop_front()?;
223 let mut active = self.active.lock().ok()?;
224 if self
231 .cancelled_before_open
232 .lock()
233 .ok()?
234 .remove(&pending.request.id)
235 {
236 drop(active);
237 let _ = pending
238 .result
239 .send(serde_json::json!({ "cancelled": true }));
240 continue;
241 }
242 active.insert(
243 pending.request.id.clone(),
244 JsDialogActive {
245 result: pending.result,
246 cancel_requested: false,
247 },
248 );
249 if let Ok(mut visible) = self.visible.lock() {
250 *visible = Some(pending.request.id.clone());
251 }
252 return Some(pending.request);
253 }
254 }
255
256 fn respond(&self, id: &str, result: serde_json::Value) {
257 if let Ok(mut active) = self.active.lock() {
258 if let Some(entry) = active.remove(id) {
259 let _ = entry.result.send(result);
260 }
261 }
262 if let Ok(mut visible) = self.visible.lock() {
263 if visible.as_deref() == Some(id) {
264 *visible = None;
265 }
266 }
267 }
268
269 fn cancel(&self, id: &str) {
270 if let Ok(mut pending) = self.pending.lock() {
271 if let Some(index) = pending.iter().position(|item| item.request.id == id) {
272 if let Some(item) = pending.remove(index) {
273 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
274 return;
275 }
276 }
277 }
278 if let Ok(mut active) = self.active.lock() {
279 if let Some(entry) = active.get_mut(id) {
280 if !entry.cancel_requested {
281 entry.cancel_requested = true;
282 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
283 }
284 return;
285 }
286 }
287 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
288 cancelled.insert(id.to_string());
289 }
290 }
291
292 fn cancelled_active_ids(&self) -> Vec<String> {
293 self.active
294 .lock()
295 .map(|active| {
296 active
297 .iter()
298 .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
299 .collect()
300 })
301 .unwrap_or_default()
302 }
303
304 fn is_visible(&self, id: &str) -> bool {
305 self.visible
306 .lock()
307 .map(|visible| visible.as_deref() == Some(id))
308 .unwrap_or(false)
309 }
310
311 fn finish(&self, id: &str) {
312 if let Ok(mut active) = self.active.lock() {
313 active.remove(id);
314 }
315 if let Ok(mut visible) = self.visible.lock() {
316 if visible.as_deref() == Some(id) {
317 *visible = None;
318 }
319 }
320 }
321
322 fn cancel_all(&self) {
323 let Ok(mut closed) = self.closed.lock() else {
327 return;
328 };
329 *closed = true;
330 if let Ok(mut pending) = self.pending.lock() {
331 for item in pending.drain(..) {
332 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
333 }
334 }
335 if let Ok(mut active) = self.active.lock() {
336 for (_, entry) in active.drain() {
337 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
338 }
339 }
340 if let Ok(mut visible) = self.visible.lock() {
341 *visible = None;
342 }
343 drop(closed);
344 }
345
346 fn cancel_open_requests(&self) {
349 let Ok(mut closed) = self.closed.lock() else {
353 return;
354 };
355 *closed = true;
356 if let Ok(mut pending) = self.pending.lock() {
357 for item in pending.drain(..) {
358 let _ = item.result.send(serde_json::json!({ "cancelled": true }));
359 }
360 }
361 if let Ok(mut active) = self.active.lock() {
362 for (_, entry) in active.drain() {
363 let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
364 }
365 }
366 if let Ok(mut visible) = self.visible.lock() {
367 *visible = None;
368 }
369 if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
370 cancelled.clear();
371 }
372 drop(closed);
373 }
374
375 fn reopen(&self) {
376 if let Ok(mut closed) = self.closed.lock() {
377 *closed = false;
378 }
379 }
380}
381
382fn build_markdown_transformer(
404 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
405) -> Option<MarkdownTransformer> {
406 let snapshot = snapshot?;
407 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
412 if renderers.is_empty() {
413 return None;
414 }
415 Some(Arc::new(move |raw: &str| -> String {
416 transform_markdown_chain(&snapshot, &renderers, raw)
417 }))
418}
419
420fn transform_markdown_chain(
428 snapshot: &rpi_extensions::RegistrySnapshot,
429 renderers: &[rpi_extensions::RegisteredRenderer],
430 raw: &str,
431) -> String {
432 if !snapshot.is_active() {
437 return raw.to_string();
438 }
439
440 let mut current = raw.to_string();
441 for renderer in renderers {
442 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
443 Ok(s) => s,
444 Err(_) => return current, };
446 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
456 let mut out = rpi_plugin_sdk::StbString::empty();
457 let rc = (renderer.render_fn)(
458 rpi_plugin_sdk::StbStringRef::from_str(&input),
459 &mut out as *mut rpi_plugin_sdk::StbString,
460 renderer.user_data,
461 );
462 let text = if rc == 0 {
463 let s = out.to_string_lossy();
464 Some(s)
465 } else {
466 None
467 };
468 out.free_with(Some(renderer.plugin_free_string));
472 text
473 }));
474 let out_text = match outcome {
475 Ok(Some(s)) => s,
476 Ok(None) => return current, Err(_) => return current, };
482 let next = serde_json::from_str::<serde_json::Value>(&out_text)
485 .ok()
486 .and_then(|v| {
487 v.get("markdown")
488 .and_then(|m| m.as_str())
489 .map(|s| s.to_string())
490 })
491 .unwrap_or(current);
492 current = next;
493 }
494 current
495}
496
497#[derive(Clone)]
521struct CommandContext {
522 chat: Arc<Container>,
523 tui: Arc<TuiAltScreen>,
524 tx: mpsc::UnboundedSender<TuiMessage>,
525 state: Arc<TuiState>,
526 editor: Arc<Editor>,
527 editor_container: Arc<Container>,
528 lane: Arc<dyn AgentLane>,
529 model_catalog: Arc<Vec<rpi_ai::Model>>,
530 lane_model_id: String,
534 cwd: std::path::PathBuf,
535 package_resources: Arc<crate::packages::PackageResources>,
539 resources: Arc<rpi_harness::types::AgentHarnessResources>,
543 reload_context: Arc<crate::session::ReloadContext>,
549 session: Arc<crate::agent_session::AgentSession>,
553}
554
555trait SlashCommand: Send + Sync {
557 fn name(&self) -> &str;
559 fn aliases(&self) -> &'static [&'static str] {
563 &[]
564 }
565 fn visible(&self) -> bool {
568 true
569 }
570 fn alias_visible(&self) -> &'static [&'static str] {
574 &[]
575 }
576 fn description(&self) -> &'static str {
579 ""
580 }
581 fn description_owned(&self) -> String {
582 self.description().to_string()
583 }
584 fn execute(&self, ctx: &CommandContext, args: &str);
590}
591
592struct CommandRegistry {
595 commands: Vec<Arc<dyn SlashCommand>>,
596}
597
598impl CommandRegistry {
599 fn new() -> Self {
600 Self {
601 commands: Vec::new(),
602 }
603 }
604
605 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
606 self.commands.push(cmd);
607 }
608
609 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
612 self.commands
613 .iter()
614 .find(|c| c.name() == token || c.aliases().contains(&token))
615 }
616
617 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
623 let mut out: Vec<SlashCommandEntry> = Vec::new();
624 for c in &self.commands {
625 let description = c.description_owned();
626 if c.visible() && !description.is_empty() {
627 out.push(SlashCommandEntry {
628 name: c.name().into(),
629 description: description.clone(),
630 });
631 }
632 for alias in c.alias_visible() {
634 out.push(SlashCommandEntry {
635 name: (*alias).into(),
636 description: description.clone(),
637 });
638 }
639 }
640 out
641 }
642}
643
644fn dispatch_ui_prompt_event(state: &TuiState) {
646 use rpi_plugin_sdk::EventTag;
647
648 if let Ok(session) = state.extension_session.lock() {
649 if let Some(snapshot) = session.snapshot_arc() {
650 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
651 }
652 }
653}
654
655fn dispatch_ui_prompt_event_end(state: &TuiState) {
657 use rpi_plugin_sdk::EventTag;
658
659 if let Ok(session) = state.extension_session.lock() {
660 if let Some(snapshot) = session.snapshot_arc() {
661 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
662 }
663 }
664}
665
666fn key_shortcut_name(code: KeyCode) -> Option<&'static str> {
670 Some(match code {
671 KeyCode::Char(' ') => "space",
672 KeyCode::Enter => "enter",
673 KeyCode::Esc => "escape",
674 KeyCode::Tab => "tab",
675 KeyCode::BackTab => "backtab",
676 KeyCode::Backspace => "backspace",
677 KeyCode::Delete => "delete",
678 KeyCode::Up => "up",
679 KeyCode::Down => "down",
680 KeyCode::Left => "left",
681 KeyCode::Right => "right",
682 KeyCode::Home => "home",
683 KeyCode::End => "end",
684 KeyCode::PageUp => "pageup",
685 KeyCode::PageDown => "pagedown",
686 _ => return None,
687 })
688}
689
690fn char_shortcut_name(ch: char) -> Option<String> {
693 if ch.is_ascii_graphic() {
694 Some(ch.to_ascii_lowercase().to_string())
695 } else {
696 None
697 }
698}
699
700fn dispatch_key_event(state: &TuiState, key: &KeyEvent, editor: &Editor) -> bool {
710 use rpi_plugin_sdk::EventTag;
711
712 let name = key_shortcut_name(key.code)
713 .map(str::to_string)
714 .or_else(|| match key.code {
715 KeyCode::Char(ch) => char_shortcut_name(ch),
716 KeyCode::F(n) => Some(format!("f{n}")),
717 _ => None,
718 });
719 let Some(name) = name else {
720 return false;
721 };
722
723 let Ok(session) = state.extension_session.lock() else {
724 return false;
725 };
726 let Some(snapshot) = session.snapshot_arc() else {
727 return false;
728 };
729 if !snapshot.has_shortcut(&name) || snapshot.handlers_for(EventTag::Input).is_empty() {
731 return false;
732 }
733 if !editor.get_text().is_empty() {
736 return false;
737 }
738
739 let kind = match key.kind {
740 KeyEventKind::Press => "press",
741 KeyEventKind::Repeat => "repeat",
742 KeyEventKind::Release => "release",
743 };
744 let payload = serde_json::json!({
745 "type": "key",
746 "key": name,
747 "kind": kind,
748 "ctrl": key.modifiers.contains(KeyModifiers::CONTROL),
749 "alt": key.modifiers.contains(KeyModifiers::ALT),
750 "shift": key.modifiers.contains(KeyModifiers::SHIFT),
751 })
752 .to_string();
753 rpi_extensions::dispatch_data_event_claiming(&snapshot, EventTag::Input, &payload)
758}
759
760fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
764 let mut parts = text.split_whitespace();
765 let token = parts.next().unwrap_or("");
766 let args = parts.collect::<Vec<_>>().join(" ");
767 match registry.find(token) {
768 Some(cmd) => cmd.execute(ctx, &args),
769 None => {
770 add_error_message(
771 &ctx.chat,
772 &format!("Unknown command: {text}. Type /help for available commands."),
773 );
774 ctx.tui.request_render(false);
775 }
776 }
777}
778
779fn now_ms() -> i64 {
783 std::time::SystemTime::now()
784 .duration_since(std::time::UNIX_EPOCH)
785 .map(|d| d.as_millis() as i64)
786 .unwrap_or(0)
787}
788
789fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
796 let rest = text.strip_prefix('!')?;
797 let (command, exclude_from_context) = match rest.strip_prefix('!') {
798 Some(command) => (command, true),
799 None => (rest, false),
800 };
801 let command = command.trim();
802 if command.is_empty() {
803 return None;
804 }
805 Some((command, exclude_from_context))
806}
807
808fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
816 let comp = Arc::new(BashExecutionComponent::new_with_context(
817 command,
818 exclude_from_context,
819 ));
820 comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
821 ctx.chat.add_child(comp.clone());
822
823 let cancel = ctx.state.begin_user_bash();
826 ctx.tui.request_render(false);
827
828 let env: Arc<dyn rpi_tools::ExecutionEnv> =
829 Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
830 let tui = ctx.tui.clone();
831 let tx = ctx.tx.clone();
832 let cwd = ctx.cwd.clone();
833 let command_owned = command.to_string();
834
835 tokio::spawn(async move {
836 let comp_chunk = comp.clone();
837 let tui_chunk = tui.clone();
838 let on_chunk: Box<
839 dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
840 > = Box::new(move |_chunk, get_progress| {
841 comp_chunk.append_output(&get_progress().output);
844 tui_chunk.request_render(false);
845 });
846 let options = rpi_tools::shell_output::ShellCaptureOptions {
847 cwd: Some(cwd),
848 cancel: Some(&cancel),
849 on_chunk: Some(on_chunk),
850 return_execution_errors: true,
851 ..Default::default()
852 };
853 let result =
854 rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
855 .await;
856
857 let (exit_code, cancelled, output, truncated, full_output_path) = match result {
860 Ok(capture) => (
861 capture.exit_code,
862 capture.cancelled,
863 capture.output.clone(),
864 capture.truncated,
865 capture
866 .full_output_path
867 .as_ref()
868 .map(|path| path.to_string_lossy().into_owned()),
869 ),
870 Err(error) => (
871 None,
872 false,
873 format!("Bash command failed: {error}"),
874 false,
875 None,
876 ),
877 };
878 comp.append_output(&output);
879 comp.set_complete(
880 exit_code,
881 cancelled,
882 BashTruncation {
883 truncated,
884 full_output_path: full_output_path.clone(),
885 },
886 );
887 tui.render_now(false);
888
889 let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
890 command: command_owned,
891 output,
892 exit_code,
893 cancelled,
894 truncated,
895 full_output_path,
896 exclude_from_context,
897 }));
898 });
899}
900
901fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
906 use crossterm::event::{KeyCode, KeyModifiers};
907
908 let modifiers = key.modifiers;
909 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
910 let shift = modifiers.contains(KeyModifiers::SHIFT);
911 let alt = modifiers.contains(KeyModifiers::ALT);
912 let super_key = modifiers.contains(KeyModifiers::SUPER);
913
914 if modifiers == KeyModifiers::NONE {
915 return match key.code {
916 KeyCode::Char(ch) => ch.to_string(),
917 KeyCode::Enter => "\r".into(),
918 KeyCode::Esc => "\x1b".into(),
919 KeyCode::Backspace => "\x7f".into(),
920 KeyCode::Tab => "\t".into(),
921 KeyCode::BackTab => "\x1b[Z".into(),
925 KeyCode::Up => "\x1b[A".into(),
926 KeyCode::Down => "\x1b[B".into(),
927 KeyCode::Right => "\x1b[C".into(),
928 KeyCode::Left => "\x1b[D".into(),
929 KeyCode::Home => "\x1b[H".into(),
930 KeyCode::End => "\x1b[F".into(),
931 KeyCode::PageUp => "\x1b[5~".into(),
932 KeyCode::PageDown => "\x1b[6~".into(),
933 KeyCode::Delete => "\x1b[3~".into(),
934 KeyCode::Insert => "\x1b[2~".into(),
935 KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
936 _ => String::new(),
937 };
938 }
939
940 if ctrl && !shift && !alt && !super_key {
943 if let KeyCode::Char(ch) = key.code {
944 if let Some(code) = control_code(ch) {
945 return char::from(code).to_string();
946 }
947 }
948 }
949
950 if alt && !ctrl && !shift && !super_key {
953 if let KeyCode::Char(ch) = key.code {
954 return format!("\x1b{ch}");
955 }
956 }
957
958 if shift && !ctrl && !alt && !super_key {
964 if let KeyCode::Char(ch) = key.code {
965 return ch.to_string();
966 }
967 }
968
969 if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
970 return sequence;
971 }
972 let Some(codepoint) = key_codepoint(key.code, ctrl) else {
973 return String::new();
974 };
975 kitty_key_sequence(codepoint, modifiers)
976}
977
978fn control_code(ch: char) -> Option<u8> {
979 let ch = ch.to_ascii_lowercase();
980 Some(match ch {
981 '@' | ' ' => 0,
982 'a'..='z' => (ch as u8) & 0x1f,
983 '[' => 0x1b,
984 '\\' => 0x1c,
985 ']' => 0x1d,
986 '^' => 0x1e,
987 '_' | '-' => 0x1f,
988 _ => return None,
989 })
990}
991
992fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
993 use crossterm::event::KeyCode;
994 Some(match code {
995 KeyCode::Char(ch) => {
996 if ctrl {
997 ch.to_ascii_lowercase() as u32
998 } else {
999 ch as u32
1000 }
1001 }
1002 KeyCode::Enter => 13,
1003 KeyCode::Esc => 27,
1004 KeyCode::Backspace => 127,
1005 KeyCode::Tab => 9,
1006 KeyCode::BackTab => 9,
1009 _ => return None,
1010 })
1011}
1012
1013fn modified_functional_sequence(
1014 code: crossterm::event::KeyCode,
1015 modifiers: crossterm::event::KeyModifiers,
1016) -> Option<String> {
1017 use crossterm::event::{KeyCode, KeyModifiers};
1018 if code == KeyCode::BackTab
1022 && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
1023 {
1024 return Some("\x1b[Z".into());
1025 }
1026 let modifier = kitty_modifier(modifiers);
1027 let sequence = match code {
1028 KeyCode::Up => format!("\x1b[1;{modifier}A"),
1029 KeyCode::Down => format!("\x1b[1;{modifier}B"),
1030 KeyCode::Right => format!("\x1b[1;{modifier}C"),
1031 KeyCode::Left => format!("\x1b[1;{modifier}D"),
1032 KeyCode::Home => format!("\x1b[1;{modifier}H"),
1033 KeyCode::End => format!("\x1b[1;{modifier}F"),
1034 KeyCode::Insert => format!("\x1b[2;{modifier}~"),
1035 KeyCode::Delete => format!("\x1b[3;{modifier}~"),
1036 KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
1037 KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
1038 _ => return None,
1039 };
1040 Some(sequence)
1041}
1042
1043fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
1044 use crossterm::event::KeyModifiers;
1045 let mut modifier = 1u8;
1046 if modifiers.contains(KeyModifiers::SHIFT) {
1047 modifier += 1;
1048 }
1049 if modifiers.contains(KeyModifiers::ALT) {
1050 modifier += 2;
1051 }
1052 if modifiers.contains(KeyModifiers::CONTROL) {
1053 modifier += 4;
1054 }
1055 if modifiers.contains(KeyModifiers::SUPER) {
1056 modifier += 8;
1057 }
1058 modifier
1059}
1060
1061fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
1062 let modifier = kitty_modifier(modifiers);
1063 format!("\x1b[{codepoint};{modifier}u")
1064}
1065
1066struct ExtensionCommand {
1071 name: String,
1072 description: String,
1073 session: crate::session::ExtensionSessionCell,
1074}
1075
1076struct JsExtensionCommand {
1077 name: String,
1078 session: crate::js_extensions::JsExtensionSession,
1079}
1080
1081impl SlashCommand for JsExtensionCommand {
1082 fn name(&self) -> &str {
1083 &self.name
1084 }
1085 fn description(&self) -> &'static str {
1086 "JS extension command"
1087 }
1088 fn description_owned(&self) -> String {
1089 "JS extension command".to_string()
1090 }
1091 fn execute(&self, ctx: &CommandContext, args: &str) {
1092 let session = self.session.clone();
1097 let command = self.name.trim_start_matches('/').to_string();
1098 let args = args.to_string();
1099 let ctx = ctx.clone();
1100 let initial_editor_text = ctx.editor.get_text();
1104 tokio::task::spawn_blocking(move || {
1105 match session.invoke_command_with_context(
1106 &command,
1107 &args,
1108 serde_json::json!({"editorText": initial_editor_text}),
1109 ) {
1110 Ok(value) => {
1111 if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
1112 if ctx.editor.get_text() == initial_editor_text
1113 && editor_text != initial_editor_text
1114 {
1115 let cursor = editor_text.chars().count();
1116 ctx.editor.set_text(editor_text);
1117 ctx.editor.set_cursor(0, cursor);
1118 }
1119 }
1120 if let Some(notifications) =
1121 value.get("notifications").and_then(|v| v.as_array())
1122 {
1123 for notification in notifications {
1124 let message = notification
1125 .get("message")
1126 .and_then(|v| v.as_str())
1127 .unwrap_or_default();
1128 if message.is_empty() {
1129 continue;
1130 }
1131 match notification.get("level").and_then(|v| v.as_str()) {
1132 Some("error") => add_error_message(&ctx.chat, message),
1133 _ => add_note_message(&ctx.chat, message),
1134 }
1135 }
1136 }
1137 let result = value.get("result").unwrap_or(&value);
1138 let text = result
1139 .get("text")
1140 .and_then(|item| item.as_str())
1141 .map(str::to_string)
1142 .or_else(|| result.as_str().map(str::to_string))
1143 .filter(|text| !text.is_empty() && text != "null");
1144 if let Some(text) = text {
1145 add_note_message(&ctx.chat, &text);
1146 }
1147 }
1148 Err(error) => {
1149 add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
1150 }
1151 }
1152 ctx.tui.request_render(false);
1153 });
1154 }
1155}
1156
1157impl SlashCommand for ExtensionCommand {
1158 fn name(&self) -> &str {
1159 &self.name
1160 }
1161
1162 fn description(&self) -> &'static str {
1163 "extension command"
1164 }
1165
1166 fn description_owned(&self) -> String {
1167 self.description.clone()
1168 }
1169
1170 fn execute(&self, ctx: &CommandContext, args: &str) {
1171 let result = invoke_extension_command(&self.session, &self.name, args);
1172 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
1173 }
1174}
1175
1176fn invoke_extension_command(
1177 session: &crate::session::ExtensionSessionCell,
1178 name: &str,
1179 args: &str,
1180) -> Option<serde_json::Value> {
1181 let command = session
1182 .lock()
1183 .ok()
1184 .and_then(|s| s.snapshot_arc())
1185 .and_then(|snap| {
1186 snap.commands()
1187 .iter()
1188 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1189 .cloned()
1190 })?;
1191 let input = serde_json::json!({ "args": args, "command": name });
1192 let input = serde_json::to_string(&input).ok()?;
1193 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1194 let mut out = rpi_plugin_sdk::StbString::empty();
1195 let rc = (command.handler)(
1196 rpi_plugin_sdk::StbStringRef::from_str(&input),
1197 &mut out as *mut rpi_plugin_sdk::StbString,
1198 command.user_data,
1199 );
1200 let text = if rc == 0 {
1201 Some(out.to_string_lossy())
1202 } else {
1203 None
1204 };
1205 rpi_extensions::host_free_string(out);
1206 text
1207 }))
1208 .ok()
1209 .flatten()?;
1210 serde_json::from_str(&outcome).ok()
1211}
1212
1213fn handle_extension_ui_result(
1214 result: Option<serde_json::Value>,
1215 ctx: &CommandContext,
1216 session: crate::session::ExtensionSessionCell,
1217 command_name: String,
1218) {
1219 let Some(value) = result else {
1220 add_error_message(&ctx.chat, "Extension command failed.");
1221 ctx.tui.request_render(false);
1222 return;
1223 };
1224 if value.is_null() {
1228 ctx.tui.request_render(false);
1229 return;
1230 }
1231 match value.get("kind").and_then(|v| v.as_str()) {
1232 Some("message") | None => {
1233 let fallback = value.to_string();
1234 let text = value
1235 .get("text")
1236 .and_then(|v| v.as_str())
1237 .unwrap_or(&fallback)
1238 .to_string();
1239 if !text.is_empty() {
1240 add_note_message(&ctx.chat, &text);
1241 }
1242 ctx.tui.request_render(false);
1243 }
1244 Some("selector") => open_extension_selector(ctx, session, command_name, value),
1245 Some("editor") => open_extension_editor(ctx, session, command_name, value),
1246 Some("input") => open_extension_input(ctx, session, command_name, value),
1250 Some(other) => {
1251 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1252 ctx.tui.request_render(false);
1253 }
1254 }
1255}
1256
1257fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1261 let colors = current_theme().colors;
1262 let text = if bold {
1263 tui_bold(title)
1264 } else {
1265 title.to_string()
1266 };
1267 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1268}
1269
1270fn extension_dialog_hint(label: &str) -> Arc<Text> {
1271 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1272}
1273
1274fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1277 let text = if bold {
1278 format!("**{}**", title)
1279 } else {
1280 title.to_string()
1281 };
1282 Arc::new(Markdown::new(text, 1, 0))
1283}
1284
1285fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1288 Arc::new(Markdown::new(label, 1, 0))
1289}
1290
1291fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1295 let callback = state.active_extension_cancel.lock().unwrap().take();
1296 if let Some(callback) = callback {
1297 callback();
1298 true
1299 } else {
1300 false
1301 }
1302}
1303
1304fn open_extension_input(
1305 ctx: &CommandContext,
1306 session: crate::session::ExtensionSessionCell,
1307 command_name: String,
1308 value: serde_json::Value,
1309) {
1310 let title = value
1311 .get("title")
1312 .and_then(|v| v.as_str())
1313 .filter(|title| !title.is_empty())
1314 .unwrap_or("Input");
1315 let input = value
1316 .get("placeholder")
1317 .and_then(|v| v.as_str())
1318 .map(Input::with_placeholder)
1319 .unwrap_or_default();
1320 let input = Arc::new(input);
1321 if let Some(initial) = value
1322 .get("initialText")
1323 .or_else(|| value.get("text"))
1324 .and_then(|v| v.as_str())
1325 {
1326 input.set_value(initial);
1327 }
1328 input.set_focused(true);
1329
1330 let frame = Arc::new(Container::new());
1331 frame.add_child(Arc::new(DynamicBorder::new()));
1332 frame.add_child(Arc::new(Spacer::new(1)));
1333 frame.add_child(extension_dialog_title(title, false));
1334 frame.add_child(Arc::new(Spacer::new(1)));
1335 frame.add_child(input.clone());
1336 frame.add_child(Arc::new(Spacer::new(1)));
1337 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1338 frame.add_child(Arc::new(Spacer::new(1)));
1339 frame.add_child(Arc::new(DynamicBorder::new()));
1340
1341 *ctx.state.active_extension_editor.lock().unwrap() = None;
1342 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1343 ctx.editor_container.clear();
1344 ctx.editor_container.add_child(frame);
1345
1346 let state = ctx.state.clone();
1347 let ec = ctx.editor_container.clone();
1348 let original = ctx.editor.clone();
1349 let tui = ctx.tui.clone();
1350 let session_submit = session.clone();
1351 let command_submit = command_name.clone();
1352 let ctx_submit = ctx.clone();
1353 input.on_submit(Arc::new(move |text| {
1354 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1355 let result = invoke_extension_command(
1356 &session_submit,
1357 &command_submit,
1358 &serde_json::to_string(&args).unwrap_or_default(),
1359 );
1360 close_extension_editor(&state, &ec, &original, &tui);
1361 handle_extension_ui_result(
1362 result,
1363 &ctx_submit,
1364 session_submit.clone(),
1365 command_submit.clone(),
1366 );
1367 }));
1368
1369 let state_cancel = ctx.state.clone();
1370 let ec_cancel = ctx.editor_container.clone();
1371 let original_cancel = ctx.editor.clone();
1372 let tui_cancel = ctx.tui.clone();
1373 let session_cancel = session.clone();
1374 let command_cancel = command_name.clone();
1375 let ctx_cancel = ctx.clone();
1376 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1377 let args = serde_json::json!({ "action": "cancel" });
1378 let result = invoke_extension_command(
1379 &session_cancel,
1380 &command_cancel,
1381 &serde_json::to_string(&args).unwrap_or_default(),
1382 );
1383 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1384 handle_extension_ui_result(
1385 result,
1386 &ctx_cancel,
1387 session_cancel.clone(),
1388 command_cancel.clone(),
1389 );
1390 }));
1391
1392 ctx.tui.set_focus(Some(input));
1393 ctx.tui.request_render(false);
1394}
1395
1396fn open_extension_selector(
1397 ctx: &CommandContext,
1398 session: crate::session::ExtensionSessionCell,
1399 command_name: String,
1400 value: serde_json::Value,
1401) {
1402 let items = value
1403 .get("items")
1404 .and_then(|v| v.as_array())
1405 .map(|items| {
1406 items
1407 .iter()
1408 .filter_map(|item| {
1409 let value = if let Some(value) = item.as_str() {
1412 value
1413 } else {
1414 item.get("value")?.as_str()?
1415 };
1416 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1417 let mut out = SelectItem::new(value, label);
1418 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1419 out = out.with_description(desc);
1420 }
1421 Some(out)
1422 })
1423 .collect::<Vec<_>>()
1424 })
1425 .unwrap_or_default();
1426 if items.is_empty() {
1427 add_error_message(&ctx.chat, "Extension selector has no items.");
1428 ctx.tui.request_render(false);
1429 return;
1430 }
1431 let list = Arc::new(SelectList::new(items, 10));
1432 let title = value
1433 .get("title")
1434 .and_then(|v| v.as_str())
1435 .filter(|title| !title.is_empty())
1436 .unwrap_or("Select");
1437 let frame = Arc::new(Container::new());
1438 frame.add_child(Arc::new(DynamicBorder::new()));
1439 frame.add_child(Arc::new(Spacer::new(1)));
1440 frame.add_child(extension_dialog_title(title, true));
1441 frame.add_child(Arc::new(Spacer::new(1)));
1442 frame.add_child(list.clone());
1443 frame.add_child(Arc::new(Spacer::new(1)));
1444 frame.add_child(extension_dialog_hint(
1445 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1446 ));
1447 frame.add_child(Arc::new(Spacer::new(1)));
1448 frame.add_child(Arc::new(DynamicBorder::new()));
1449
1450 let state = ctx.state.clone();
1451 let ec = ctx.editor_container.clone();
1452 let editor = ctx.editor.clone();
1453 let tui = ctx.tui.clone();
1454 let session_select = session.clone();
1455 let command_select = command_name.clone();
1456 let ctx_select = ctx.clone();
1457 list.on_select(Arc::new(move |item| {
1458 let args = serde_json::json!({ "action": "select", "value": item.value });
1459 let result = invoke_extension_command(
1460 &session_select,
1461 &command_select,
1462 &serde_json::to_string(&args).unwrap_or_default(),
1463 );
1464 close_selector(&state, &ec, &editor, &tui);
1465 handle_extension_ui_result(
1466 result,
1467 &ctx_select,
1468 session_select.clone(),
1469 command_select.clone(),
1470 );
1471 }));
1472 let state_cancel = ctx.state.clone();
1473 let ec_cancel = ctx.editor_container.clone();
1474 let editor_cancel = ctx.editor.clone();
1475 let tui_cancel = ctx.tui.clone();
1476 list.on_cancel(Arc::new(move || {
1477 if !run_extension_cancel(&state_cancel) {
1478 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1479 }
1480 }));
1481 let state_cancel = ctx.state.clone();
1482 let ec_cancel = ctx.editor_container.clone();
1483 let editor_cancel = ctx.editor.clone();
1484 let tui_cancel = ctx.tui.clone();
1485 let session_cancel = session.clone();
1486 let command_cancel = command_name.clone();
1487 let ctx_cancel = ctx.clone();
1488 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1489 let args = serde_json::json!({ "action": "cancel" });
1490 let result = invoke_extension_command(
1491 &session_cancel,
1492 &command_cancel,
1493 &serde_json::to_string(&args).unwrap_or_default(),
1494 );
1495 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1496 handle_extension_ui_result(
1497 result,
1498 &ctx_cancel,
1499 session_cancel.clone(),
1500 command_cancel.clone(),
1501 );
1502 }));
1503 open_selector_with_view(
1504 &ctx.state,
1505 &ctx.editor_container,
1506 &ctx.editor,
1507 &ctx.tui,
1508 SelectorView::List(list),
1509 frame,
1510 SelectorKind::Extension,
1511 );
1512}
1513
1514fn open_extension_editor(
1515 ctx: &CommandContext,
1516 session: crate::session::ExtensionSessionCell,
1517 command_name: String,
1518 value: serde_json::Value,
1519) {
1520 let initial = value
1521 .get("initialText")
1522 .or_else(|| value.get("text"))
1523 .and_then(|v| v.as_str())
1524 .unwrap_or_default()
1525 .to_string();
1526 let title = value
1527 .get("title")
1528 .and_then(|v| v.as_str())
1529 .filter(|title| !title.is_empty())
1530 .unwrap_or("Editor");
1531 let editor = Arc::new(Editor::new(
1532 EditorOptions {
1533 padding_x: 1,
1534 autocomplete_max_visible: 0,
1535 placeholder: value
1536 .get("placeholder")
1537 .and_then(|v| v.as_str())
1538 .map(str::to_string),
1539 initial_text: Some(initial),
1540 },
1541 EditorStyle {
1542 prompt: "> ".to_string(),
1543 placeholder: String::new(),
1544 },
1545 Arc::new(rpi_tui::Keybindings::new()),
1546 ));
1547 editor.set_focused(true);
1548 let frame = Arc::new(Container::new());
1549 frame.add_child(Arc::new(DynamicBorder::new()));
1550 frame.add_child(Arc::new(Spacer::new(1)));
1551 frame.add_child(extension_dialog_title(title, false));
1552 frame.add_child(Arc::new(Spacer::new(1)));
1553 frame.add_child(editor.clone());
1554 frame.add_child(Arc::new(Spacer::new(1)));
1555 frame.add_child(extension_dialog_hint(
1556 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1557 ));
1558 frame.add_child(Arc::new(Spacer::new(1)));
1559 frame.add_child(Arc::new(DynamicBorder::new()));
1560
1561 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1562 *ctx.state.active_extension_input.lock().unwrap() = None;
1563 ctx.editor_container.clear();
1564 ctx.editor_container.add_child(frame);
1565
1566 let state = ctx.state.clone();
1567 let ec = ctx.editor_container.clone();
1568 let original = ctx.editor.clone();
1569 let tui = ctx.tui.clone();
1570 let session_submit = session.clone();
1571 let command_submit = command_name.clone();
1572 let ctx_submit = ctx.clone();
1573 editor.on_submit(Arc::new(move |text| {
1574 let args = serde_json::json!({ "action": "edit", "text": text });
1575 let result = invoke_extension_command(
1576 &session_submit,
1577 &command_submit,
1578 &serde_json::to_string(&args).unwrap_or_default(),
1579 );
1580 close_extension_editor(&state, &ec, &original, &tui);
1581 handle_extension_ui_result(
1582 result,
1583 &ctx_submit,
1584 session_submit.clone(),
1585 command_submit.clone(),
1586 );
1587 }));
1588
1589 let state_cancel = ctx.state.clone();
1590 let ec_cancel = ctx.editor_container.clone();
1591 let original_cancel = ctx.editor.clone();
1592 let tui_cancel = ctx.tui.clone();
1593 let session_cancel = session.clone();
1594 let command_cancel = command_name.clone();
1595 let ctx_cancel = ctx.clone();
1596 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1597 let args = serde_json::json!({ "action": "cancel" });
1598 let result = invoke_extension_command(
1599 &session_cancel,
1600 &command_cancel,
1601 &serde_json::to_string(&args).unwrap_or_default(),
1602 );
1603 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1604 handle_extension_ui_result(
1605 result,
1606 &ctx_cancel,
1607 session_cancel.clone(),
1608 command_cancel.clone(),
1609 );
1610 }));
1611
1612 ctx.tui.set_focus(Some(editor));
1613 ctx.tui.request_render(false);
1614}
1615
1616fn close_extension_editor(
1617 state: &Arc<TuiState>,
1618 editor_container: &Arc<Container>,
1619 editor: &Arc<Editor>,
1620 tui: &Arc<TuiAltScreen>,
1621) {
1622 editor_container.clear();
1623 editor_container.add_child(editor.clone());
1624 state.autocomplete_container.clear();
1625 *state.active_extension_editor.lock().unwrap() = None;
1626 *state.active_extension_input.lock().unwrap() = None;
1627 *state.active_extension_cancel.lock().unwrap() = None;
1628 editor.set_focused(true);
1629 tui.set_focus(Some(editor.clone()));
1630 tui.request_render(false);
1631}
1632
1633fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1637 match request.method.as_str() {
1638 "select" => open_js_selector(ctx, bridge, request, false),
1639 "confirm" => open_js_selector(ctx, bridge, request, true),
1640 "input" => open_js_input(ctx, bridge, request),
1641 "editor" => open_js_editor(ctx, bridge, request),
1642 _ => {
1643 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1644 }
1645 }
1646}
1647
1648fn open_js_selector(
1649 ctx: &CommandContext,
1650 bridge: Arc<JsDialogBridge>,
1651 request: JsDialogRequest,
1652 confirm: bool,
1653) {
1654 let values = if confirm {
1655 vec!["Yes".to_string(), "No".to_string()]
1656 } else {
1657 request.options.clone()
1658 };
1659 if values.is_empty() {
1660 bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1661 return;
1662 }
1663 let items = values
1664 .iter()
1665 .map(|value| SelectItem::new(value, value))
1666 .collect::<Vec<_>>();
1667 let list = Arc::new(SelectList::new(items, 10));
1668 let frame = Arc::new(Container::new());
1669 frame.add_child(Arc::new(DynamicBorder::new()));
1670 frame.add_child(Arc::new(Spacer::new(1)));
1671 frame.add_child(extension_dialog_title(
1672 if request.title.is_empty() {
1673 if confirm {
1674 "Confirm"
1675 } else {
1676 "Select"
1677 }
1678 } else {
1679 request.title.as_str()
1680 },
1681 true,
1682 ));
1683 if !request.message.is_empty() {
1684 frame.add_child(Arc::new(Spacer::new(1)));
1685 frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1686 }
1687 frame.add_child(Arc::new(Spacer::new(1)));
1688 frame.add_child(list.clone());
1689 frame.add_child(Arc::new(Spacer::new(1)));
1690 frame.add_child(extension_dialog_hint(
1691 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1692 ));
1693 frame.add_child(Arc::new(Spacer::new(1)));
1694 frame.add_child(Arc::new(DynamicBorder::new()));
1695
1696 let id = request.id.clone();
1697 let bridge_select = bridge.clone();
1698 let state_select = ctx.state.clone();
1699 let ec_select = ctx.editor_container.clone();
1700 let editor_select = ctx.editor.clone();
1701 let tui_select = ctx.tui.clone();
1702 list.on_select(Arc::new(move |item| {
1703 let result = if confirm {
1704 serde_json::json!({ "confirmed": item.value == "Yes" })
1705 } else {
1706 serde_json::json!({ "value": item.value })
1707 };
1708 bridge_select.respond(&id, result);
1709 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1710 }));
1711
1712 let id_cancel = request.id.clone();
1713 let bridge_cancel = bridge.clone();
1714 let state_cancel = ctx.state.clone();
1715 let ec_cancel = ctx.editor_container.clone();
1716 let editor_cancel = ctx.editor.clone();
1717 let tui_cancel = ctx.tui.clone();
1718 list.on_cancel(Arc::new(move || {
1719 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1720 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1721 }));
1722
1723 let id_abort = request.id.clone();
1724 let bridge_abort = bridge.clone();
1725 let state_abort = ctx.state.clone();
1726 let ec_abort = ctx.editor_container.clone();
1727 let editor_abort = ctx.editor.clone();
1728 let tui_abort = ctx.tui.clone();
1729 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1730 bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1731 close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1732 }));
1733
1734 open_selector_with_view(
1735 &ctx.state,
1736 &ctx.editor_container,
1737 &ctx.editor,
1738 &ctx.tui,
1739 SelectorView::List(list),
1740 frame,
1741 SelectorKind::Extension,
1742 );
1743}
1744
1745fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1746 let input = request
1747 .placeholder
1748 .as_deref()
1749 .map(Input::with_placeholder)
1750 .unwrap_or_default();
1751 let input = Arc::new(input);
1752 input.set_focused(true);
1753
1754 let frame = Arc::new(Container::new());
1755 frame.add_child(Arc::new(DynamicBorder::new()));
1756 frame.add_child(Arc::new(Spacer::new(1)));
1757 frame.add_child(extension_dialog_title(
1758 if request.title.is_empty() {
1759 "Input"
1760 } else {
1761 &request.title
1762 },
1763 false,
1764 ));
1765 frame.add_child(Arc::new(Spacer::new(1)));
1766 frame.add_child(input.clone());
1767 frame.add_child(Arc::new(Spacer::new(1)));
1768 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1769 frame.add_child(Arc::new(Spacer::new(1)));
1770 frame.add_child(Arc::new(DynamicBorder::new()));
1771
1772 *ctx.state.active_extension_editor.lock().unwrap() = None;
1773 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1774 ctx.editor_container.clear();
1775 ctx.editor_container.add_child(frame);
1776
1777 let id = request.id.clone();
1778 let bridge_submit = bridge.clone();
1779 let state_submit = ctx.state.clone();
1780 let ec_submit = ctx.editor_container.clone();
1781 let editor_submit = ctx.editor.clone();
1782 let tui_submit = ctx.tui.clone();
1783 input.on_submit(Arc::new(move |value| {
1784 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1785 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1786 }));
1787
1788 let id_cancel = request.id.clone();
1789 let bridge_cancel = bridge.clone();
1790 let state_cancel = ctx.state.clone();
1791 let ec_cancel = ctx.editor_container.clone();
1792 let editor_cancel = ctx.editor.clone();
1793 let tui_cancel = ctx.tui.clone();
1794 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1795 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1796 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1797 }));
1798 ctx.tui.set_focus(Some(input));
1799 ctx.tui.request_render(false);
1800}
1801
1802fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1803 let editor = Arc::new(Editor::new(
1804 EditorOptions {
1805 padding_x: 1,
1806 autocomplete_max_visible: 0,
1807 initial_text: request.prefill.clone(),
1808 ..Default::default()
1809 },
1810 EditorStyle {
1811 prompt: "> ".to_string(),
1812 placeholder: String::new(),
1813 },
1814 Arc::new(rpi_tui::Keybindings::new()),
1815 ));
1816 editor.set_focused(true);
1817
1818 let frame = Arc::new(Container::new());
1819 frame.add_child(Arc::new(DynamicBorder::new()));
1820 frame.add_child(Arc::new(Spacer::new(1)));
1821 frame.add_child(extension_dialog_title(
1822 if request.title.is_empty() {
1823 "Editor"
1824 } else {
1825 &request.title
1826 },
1827 false,
1828 ));
1829 frame.add_child(Arc::new(Spacer::new(1)));
1830 frame.add_child(editor.clone());
1831 frame.add_child(Arc::new(Spacer::new(1)));
1832 frame.add_child(extension_dialog_hint(
1833 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1834 ));
1835 frame.add_child(Arc::new(Spacer::new(1)));
1836 frame.add_child(Arc::new(DynamicBorder::new()));
1837
1838 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1839 *ctx.state.active_extension_input.lock().unwrap() = None;
1840 ctx.editor_container.clear();
1841 ctx.editor_container.add_child(frame);
1842
1843 let id = request.id.clone();
1844 let bridge_submit = bridge.clone();
1845 let state_submit = ctx.state.clone();
1846 let ec_submit = ctx.editor_container.clone();
1847 let editor_submit = ctx.editor.clone();
1848 let tui_submit = ctx.tui.clone();
1849 editor.on_submit(Arc::new(move |value| {
1850 bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1851 close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1852 }));
1853
1854 let id_cancel = request.id.clone();
1855 let bridge_cancel = bridge.clone();
1856 let state_cancel = ctx.state.clone();
1857 let ec_cancel = ctx.editor_container.clone();
1858 let editor_cancel = ctx.editor.clone();
1859 let tui_cancel = ctx.tui.clone();
1860 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1861 bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1862 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1863 }));
1864 ctx.tui.set_focus(Some(editor));
1865 ctx.tui.request_render(false);
1866}
1867
1868fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1869 for id in bridge.cancelled_active_ids() {
1870 if !bridge.is_visible(&id) {
1874 bridge.finish(&id);
1875 continue;
1876 }
1877 if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1878 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1879 } else if ctx.state.extension_dialog_open() {
1880 if !run_extension_cancel(&ctx.state) {
1881 close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1882 }
1883 }
1884 bridge.finish(&id);
1888 }
1889}
1890
1891const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1898
1899#[derive(Clone)]
1904struct AskUserBridge {
1905 mailbox: rpi_extensions::UiDialogMailbox,
1906 visible: Arc<Mutex<Option<String>>>,
1907}
1908
1909impl AskUserBridge {
1910 fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1911 Self {
1912 mailbox,
1913 visible: Arc::new(Mutex::new(None)),
1914 }
1915 }
1916
1917 fn attach(&self) {
1919 self.mailbox.attach();
1920 }
1921
1922 fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1926 let mut visible = self.visible.lock().ok()?;
1927 if visible.is_some() {
1928 return None;
1929 }
1930 let request = self.mailbox.take_pending()?;
1931 *visible = Some(request.request_id.clone());
1932 Some(request)
1933 }
1934
1935 fn clear_visible(&self, id: &str) {
1936 if let Ok(mut visible) = self.visible.lock() {
1937 if visible.as_deref() == Some(id) {
1938 *visible = None;
1939 }
1940 }
1941 }
1942
1943 fn respond(&self, id: &str, answer: serde_json::Value) {
1945 let _ = self.mailbox.respond(id, answer);
1946 self.clear_visible(id);
1947 }
1948
1949 fn cancel(&self, id: &str) {
1951 let _ = self.mailbox.cancel(id);
1952 self.clear_visible(id);
1953 }
1954
1955 fn cancel_all(&self) {
1958 self.mailbox.cancel_all();
1959 if let Ok(mut visible) = self.visible.lock() {
1960 *visible = None;
1961 }
1962 }
1963
1964 fn shutdown(&self) {
1967 self.mailbox.detach();
1968 if let Ok(mut visible) = self.visible.lock() {
1969 *visible = None;
1970 }
1971 }
1972}
1973
1974#[derive(Clone)]
1978struct AskUserPrompt {
1979 question_id: String,
1980 question: String,
1981 header: Option<String>,
1982 context: Option<String>,
1983 options: Vec<(String, Option<String>)>,
1984 allow_multiple: bool,
1985 allow_freeform: bool,
1986 suggest: Option<String>,
1987 kind: String,
1988}
1989
1990fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1991 keys.iter().find_map(|key| {
1992 value
1993 .get(*key)
1994 .and_then(serde_json::Value::as_str)
1995 .map(str::trim)
1996 .filter(|text| !text.is_empty())
1997 .map(str::to_string)
1998 })
1999}
2000
2001fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
2002 keys.iter()
2003 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
2004}
2005
2006fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
2007 let mut options = Vec::new();
2008 let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
2009 return options;
2010 };
2011 for item in items {
2012 if let Some(title) = item.as_str() {
2013 let title = title.trim();
2014 if !title.is_empty() {
2015 options.push((title.to_string(), None));
2016 }
2017 continue;
2018 }
2019 let Some(object) = item.as_object() else {
2020 continue;
2021 };
2022 let title = ["title", "label", "text", "value", "name"]
2023 .iter()
2024 .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
2025 .map(str::trim)
2026 .filter(|title| !title.is_empty());
2027 let Some(title) = title else { continue };
2028 let description = object
2029 .get("description")
2030 .and_then(serde_json::Value::as_str)
2031 .map(str::trim)
2032 .filter(|text| !text.is_empty())
2033 .map(str::to_string);
2034 options.push((title.to_string(), description));
2035 }
2036 options
2037}
2038
2039fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
2040 let ui = &request.ui;
2041 let kind = ui
2042 .get("kind")
2043 .and_then(serde_json::Value::as_str)
2044 .unwrap_or("selector")
2045 .to_string();
2046 let nested = ui
2050 .get("questions")
2051 .and_then(serde_json::Value::as_array)
2052 .and_then(|items| items.first());
2053 let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
2054
2055 let question = ask_user_str(source, &["question", "summary", "title"])
2056 .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
2057 .unwrap_or_default();
2058 let question_id = ask_user_str(source, &["id", "questionId"])
2059 .or_else(|| ask_user_str(ui, &["id", "questionId"]))
2060 .unwrap_or_else(|| request.request_id.clone());
2061 let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
2062 let context = ask_user_str(source, &["context", "message"])
2063 .or_else(|| ask_user_str(ui, &["context", "message"]));
2064 let mut options = ask_user_options(source);
2065 if options.is_empty() {
2066 options = ask_user_options(ui);
2067 }
2068 if kind == "confirm" && options.is_empty() {
2069 options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
2070 }
2071 let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
2072 .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
2073 .unwrap_or(false);
2074 let allow_freeform =
2077 ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
2078 .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
2079 .unwrap_or(options.is_empty());
2080 let suggest = ask_user_str(source, &["suggest", "placeholder"])
2081 .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
2082
2083 AskUserPrompt {
2084 question_id,
2085 question,
2086 header,
2087 context,
2088 options,
2089 allow_multiple,
2090 allow_freeform,
2091 suggest,
2092 kind,
2093 }
2094}
2095
2096fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
2099 let frame = Arc::new(Container::new());
2100 frame.add_child(Arc::new(DynamicBorder::new()));
2101 frame.add_child(Arc::new(Spacer::new(1)));
2102 if let Some(header) = prompt.header.as_deref() {
2103 frame.add_child(extension_dialog_title_md(header, true));
2104 }
2105 if !prompt.question.trim().is_empty() {
2106 frame.add_child(extension_dialog_title_md(
2107 &prompt.question,
2108 prompt.header.is_none(),
2109 ));
2110 }
2111 if let Some(context) = prompt.context.as_deref() {
2112 frame.add_child(extension_dialog_hint_md(context));
2113 }
2114 frame.add_child(Arc::new(Spacer::new(1)));
2115 frame.add_child(body);
2116 frame.add_child(Arc::new(Spacer::new(1)));
2117 frame.add_child(extension_dialog_hint(hint));
2118 frame.add_child(Arc::new(Spacer::new(1)));
2119 frame.add_child(Arc::new(DynamicBorder::new()));
2120 frame
2121}
2122
2123fn open_ask_user_dialog(
2125 ctx: &CommandContext,
2126 bridge: AskUserBridge,
2127 request: rpi_extensions::UiDialogRequest,
2128) {
2129 let prompt = parse_ask_user_prompt(&request);
2130 if prompt.kind == "input" || prompt.options.is_empty() {
2131 open_ask_user_input(ctx, bridge, request, prompt);
2132 } else {
2133 open_ask_user_selector(ctx, bridge, request, prompt);
2134 }
2135}
2136
2137fn open_ask_user_selector(
2138 ctx: &CommandContext,
2139 bridge: AskUserBridge,
2140 request: rpi_extensions::UiDialogRequest,
2141 prompt: AskUserPrompt,
2142) {
2143 let multi = prompt.allow_multiple && prompt.kind != "confirm";
2144 let mut items: Vec<SelectItem> = Vec::new();
2145 for (title, description) in &prompt.options {
2146 let item = SelectItem::new(title, title);
2147 let item = match description.as_deref() {
2148 Some(description) => item.with_description(description),
2149 None => item,
2150 };
2151 items.push(item);
2152 }
2153 if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
2156 items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
2157 }
2158 let list = if multi {
2159 Arc::new(SelectList::new_multi(items, 10))
2160 } else {
2161 Arc::new(SelectList::new(items, 10))
2162 };
2163
2164 let request_id = request.request_id.clone();
2165 let state = ctx.state.clone();
2166 let editor_container = ctx.editor_container.clone();
2167 let editor = ctx.editor.clone();
2168 let tui = ctx.tui.clone();
2169
2170 let bridge_select = bridge.clone();
2173 let request_for_freeform = request.clone();
2174 let prompt_for_freeform = AskUserPrompt {
2175 question_id: prompt.question_id.clone(),
2176 question: prompt.question.clone(),
2177 header: prompt.header.clone(),
2178 context: prompt.context.clone(),
2179 options: Vec::new(),
2180 allow_multiple: false,
2181 allow_freeform: true,
2182 suggest: prompt.suggest.clone(),
2183 kind: "input".to_string(),
2184 };
2185 let state_select = state.clone();
2186 let ec_select = editor_container.clone();
2187 let editor_select = editor.clone();
2188 let tui_select = tui.clone();
2189 let ctx_select = ctx.clone();
2190 list.on_select(Arc::new(move |item: &SelectItem| {
2191 if item.value == ASK_USER_FREEFORM {
2192 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2193 open_ask_user_input(
2194 &ctx_select,
2195 bridge_select.clone(),
2196 request_for_freeform.clone(),
2197 prompt_for_freeform.clone(),
2198 );
2199 return;
2200 }
2201 bridge_select.respond(
2202 &request_id,
2203 serde_json::json!({
2204 "values": [item.display_value()],
2205 "kind": "selector",
2206 }),
2207 );
2208 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2209 }));
2210
2211 if multi {
2212 let bridge_multi = bridge.clone();
2213 let request_id_multi = request.request_id.clone();
2214 let state_multi = state.clone();
2215 let ec_multi = editor_container.clone();
2216 let editor_multi = editor.clone();
2217 let tui_multi = tui.clone();
2218 list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2219 let values: Vec<String> = selected
2220 .iter()
2221 .map(|item| item.display_value().to_string())
2222 .collect();
2223 bridge_multi.respond(
2224 &request_id_multi,
2225 serde_json::json!({"values": values, "kind": "selector"}),
2226 );
2227 close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2228 }));
2229 }
2230
2231 let bridge_cancel = bridge.clone();
2232 let request_id_cancel = request.request_id.clone();
2233 let state_cancel = state.clone();
2234 let ec_cancel = editor_container.clone();
2235 let editor_cancel = editor.clone();
2236 let tui_cancel = tui.clone();
2237 list.on_cancel(Arc::new(move || {
2238 bridge_cancel.cancel(&request_id_cancel);
2239 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2240 }));
2241
2242 let hint = if multi {
2243 "Space toggle · Enter submit · Esc cancel"
2244 } else {
2245 "Enter select · Esc cancel"
2246 };
2247 let frame = ask_user_frame(&prompt, list.clone(), hint);
2248 open_selector_with_view(
2249 &state,
2250 &editor_container,
2251 &editor,
2252 &tui,
2253 SelectorView::List(list),
2254 frame,
2255 SelectorKind::Extension,
2256 );
2257}
2258
2259fn open_ask_user_input(
2260 ctx: &CommandContext,
2261 bridge: AskUserBridge,
2262 request: rpi_extensions::UiDialogRequest,
2263 prompt: AskUserPrompt,
2264) {
2265 let placeholder = prompt
2266 .suggest
2267 .clone()
2268 .unwrap_or_else(|| "Type your answer…".to_string());
2269 let input = Arc::new(Input::with_placeholder(&placeholder));
2270 input.set_focused(true);
2271
2272 let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2273 *ctx.state.active_extension_editor.lock().unwrap() = None;
2274 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2275 ctx.editor_container.clear();
2276 ctx.editor_container.add_child(frame);
2277 ctx.tui.set_focus(Some(input.clone()));
2278 ctx.tui.request_render(false);
2279
2280 let state = ctx.state.clone();
2281 let ec = ctx.editor_container.clone();
2282 let editor = ctx.editor.clone();
2283 let tui = ctx.tui.clone();
2284 let bridge_submit = bridge.clone();
2285 let request_id = request.request_id.clone();
2286 input.on_submit(Arc::new(move |text: &str| {
2287 bridge_submit.respond(
2288 &request_id,
2289 serde_json::json!({
2290 "values": [text],
2291 "value": text,
2292 "text": text,
2293 "kind": "input",
2294 }),
2295 );
2296 close_extension_editor(&state, &ec, &editor, &tui);
2297 }));
2298
2299 let state_cancel = ctx.state.clone();
2300 let ec_cancel = ctx.editor_container.clone();
2301 let editor_cancel = ctx.editor.clone();
2302 let tui_cancel = ctx.tui.clone();
2303 let bridge_cancel = bridge.clone();
2304 let request_id_cancel = request.request_id.clone();
2305 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2306 bridge_cancel.cancel(&request_id_cancel);
2307 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2308 }));
2309}
2310
2311struct HelpCommand;
2314impl SlashCommand for HelpCommand {
2315 fn name(&self) -> &'static str {
2316 "/help"
2317 }
2318 fn aliases(&self) -> &'static [&'static str] {
2319 &["/?"]
2320 }
2321 fn description(&self) -> &'static str {
2322 "Show available commands"
2323 }
2324 fn execute(&self, ctx: &CommandContext, _args: &str) {
2325 add_help_message(&ctx.chat);
2326 ctx.tui.request_render(false);
2327 }
2328}
2329
2330struct ClearChatCommand;
2331impl SlashCommand for ClearChatCommand {
2332 fn name(&self) -> &'static str {
2333 "/clear"
2334 }
2335 fn aliases(&self) -> &'static [&'static str] {
2336 &["/new"]
2337 }
2338 fn alias_visible(&self) -> &'static [&'static str] {
2340 &["/new"]
2341 }
2342 fn description(&self) -> &'static str {
2343 "Clear the conversation"
2344 }
2345 fn execute(&self, ctx: &CommandContext, _args: &str) {
2346 let _ = ctx.tx.send(TuiMessage::ClearChat);
2347 }
2348}
2349
2350struct ExitCommand;
2351impl SlashCommand for ExitCommand {
2352 fn name(&self) -> &'static str {
2353 "/exit"
2354 }
2355 fn aliases(&self) -> &'static [&'static str] {
2356 &["/quit", "/q"]
2357 }
2358 fn alias_visible(&self) -> &'static [&'static str] {
2360 &["/quit"]
2361 }
2362 fn description(&self) -> &'static str {
2363 "Exit the application"
2364 }
2365 fn execute(&self, ctx: &CommandContext, _args: &str) {
2366 ctx.state.cancel_js_preparation();
2367 let _ = ctx.tx.send(TuiMessage::Exit);
2368 }
2369}
2370
2371struct VersionCommand;
2372impl SlashCommand for VersionCommand {
2373 fn name(&self) -> &'static str {
2374 "/version"
2375 }
2376 fn aliases(&self) -> &'static [&'static str] {
2377 &["/v"]
2378 }
2379 fn description(&self) -> &'static str {
2380 "Show version information"
2381 }
2382 fn execute(&self, ctx: &CommandContext, _args: &str) {
2383 add_version_message(&ctx.chat);
2384 ctx.tui.request_render(false);
2385 }
2386}
2387
2388struct ChangelogCommand;
2389impl SlashCommand for ChangelogCommand {
2390 fn name(&self) -> &'static str {
2391 "/changelog"
2392 }
2393 fn description(&self) -> &'static str {
2394 "Show recent release changes"
2395 }
2396 fn execute(&self, ctx: &CommandContext, _args: &str) {
2397 add_changelog_message(&ctx.chat);
2398 ctx.tui.request_render(false);
2399 }
2400}
2401
2402struct HotkeysCommand;
2403impl SlashCommand for HotkeysCommand {
2404 fn name(&self) -> &'static str {
2405 "/hotkeys"
2406 }
2407 fn description(&self) -> &'static str {
2408 "Show keyboard shortcuts"
2409 }
2410 fn execute(&self, ctx: &CommandContext, _args: &str) {
2411 add_hotkeys_message(&ctx.chat);
2412 ctx.tui.request_render(false);
2413 }
2414}
2415
2416struct ModelCommand;
2417impl SlashCommand for ModelCommand {
2418 fn name(&self) -> &'static str {
2419 "/model"
2420 }
2421 fn aliases(&self) -> &'static [&'static str] {
2422 &["/m"]
2423 }
2424 fn description(&self) -> &'static str {
2425 "Choose a model (selector)"
2426 }
2427 fn execute(&self, ctx: &CommandContext, args: &str) {
2428 let term = args.trim();
2429 if !term.is_empty() {
2430 if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
2436 let model_id = model.id.clone();
2437 ctx.state.set_current_model(&model);
2438 let lane = ctx.lane.clone();
2439 tokio::spawn(async move {
2440 let _ = lane.set_model(model).await;
2441 });
2442 add_note_message(
2443 &ctx.chat,
2444 &format!(
2445 "Model set to {} — applies to the next message.",
2446 short_model_name(&model_id)
2447 ),
2448 );
2449 ctx.tui.request_render(false);
2450 return;
2451 }
2452 }
2453 open_model_selector(
2454 &ctx.state,
2455 &ctx.editor_container,
2456 &ctx.editor,
2457 &ctx.tui,
2458 &ctx.model_catalog,
2459 &ctx.lane,
2460 &ctx.lane_model_id,
2461 &ctx.chat,
2462 (!term.is_empty()).then_some(term),
2463 );
2464 }
2465}
2466
2467struct ThinkingCommand;
2468impl SlashCommand for ThinkingCommand {
2469 fn name(&self) -> &'static str {
2470 "/thinking"
2471 }
2472 fn aliases(&self) -> &'static [&'static str] {
2473 &["/think"]
2474 }
2475 fn description(&self) -> &'static str {
2476 "Set thinking level (selector)"
2477 }
2478 fn execute(&self, ctx: &CommandContext, args: &str) {
2479 let level_name = args.trim();
2480 if !level_name.is_empty() {
2481 let Some(level) = thinking_level_from_name(level_name) else {
2483 add_error_message(
2484 &ctx.chat,
2485 &format!(
2486 "Unknown thinking level \"{level_name}\". Valid: {}",
2487 crate::args::VALID_THINKING_LEVELS.join(", ")
2488 ),
2489 );
2490 ctx.tui.request_render(false);
2491 return;
2492 };
2493 let lane = ctx.lane.clone();
2494 let footer = ctx.state.footer.clone();
2495 tokio::spawn(async move {
2496 let _ = lane.set_thinking_level(level).await;
2497 });
2498 footer.set_thinking_level(Some(thinking_level_name(level)));
2499 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2500 ctx.tui.request_render(false);
2501 return;
2502 }
2503 open_thinking_selector(
2504 &ctx.state,
2505 &ctx.editor_container,
2506 &ctx.editor,
2507 &ctx.tui,
2508 &ctx.lane,
2509 &ctx.model_catalog,
2510 &ctx.lane_model_id,
2511 &ctx.chat,
2512 );
2513 }
2514}
2515
2516struct ToolsCommand;
2517impl SlashCommand for ToolsCommand {
2518 fn name(&self) -> &'static str {
2519 "/tools"
2520 }
2521 fn description(&self) -> &'static str {
2522 "Toggle tools on/off"
2523 }
2524 fn execute(&self, ctx: &CommandContext, _args: &str) {
2525 open_tools_selector(
2526 &ctx.state,
2527 &ctx.editor_container,
2528 &ctx.editor,
2529 &ctx.tui,
2530 &ctx.lane,
2531 &ctx.chat,
2532 );
2533 }
2534}
2535
2536struct ImagesCommand;
2537impl SlashCommand for ImagesCommand {
2538 fn name(&self) -> &'static str {
2539 "/images"
2540 }
2541 fn description(&self) -> &'static str {
2542 "Toggle inline images"
2543 }
2544 fn execute(&self, ctx: &CommandContext, _args: &str) {
2545 open_images_selector(
2546 &ctx.state,
2547 &ctx.editor_container,
2548 &ctx.editor,
2549 &ctx.tui,
2550 &ctx.chat,
2551 );
2552 }
2553}
2554
2555struct SessionCommand;
2556impl SlashCommand for SessionCommand {
2557 fn name(&self) -> &'static str {
2558 "/session"
2559 }
2560 fn aliases(&self) -> &'static [&'static str] {
2561 &["/resume"]
2562 }
2563 fn description(&self) -> &'static str {
2564 "List saved sessions"
2565 }
2566 fn execute(&self, ctx: &CommandContext, _args: &str) {
2567 open_session_selector(
2568 &ctx.state,
2569 &ctx.editor_container,
2570 &ctx.editor,
2571 &ctx.tui,
2572 &ctx.cwd,
2573 &ctx.tx,
2574 );
2575 }
2576}
2577
2578struct ThemeCommand;
2579impl SlashCommand for ThemeCommand {
2580 fn name(&self) -> &'static str {
2581 "/theme"
2582 }
2583 fn description(&self) -> &'static str {
2584 "Choose a theme (selector)"
2585 }
2586 fn execute(&self, ctx: &CommandContext, args: &str) {
2587 let name = args.trim().to_ascii_lowercase();
2588 if !name.is_empty() {
2589 let preset = match name.as_str() {
2591 "light" => ThemePreset::Light,
2592 "monochrome" => ThemePreset::Monochrome,
2593 "dark" => ThemePreset::Dark,
2594 _ => {
2595 add_error_message(
2596 &ctx.chat,
2597 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2598 );
2599 ctx.tui.request_render(false);
2600 return;
2601 }
2602 };
2603 apply_theme_preset(preset);
2604 let mut settings = crate::settings::load_settings().unwrap_or_default();
2605 settings.theme = Some(name.clone());
2606 let _ = crate::settings::save_settings(&settings);
2607 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2608 ctx.tui.request_render(false);
2609 ctx.tui.render_now(true);
2610 return;
2611 }
2612 open_theme_selector(
2613 &ctx.state,
2614 &ctx.editor_container,
2615 &ctx.editor,
2616 &ctx.tui,
2617 &ctx.cwd,
2618 &ctx.package_resources,
2619 );
2620 }
2621}
2622
2623struct CompactCommand;
2624impl SlashCommand for CompactCommand {
2625 fn name(&self) -> &'static str {
2626 "/compact"
2627 }
2628 fn description(&self) -> &'static str {
2629 "Compact the conversation"
2630 }
2631 fn execute(&self, ctx: &CommandContext, _args: &str) {
2632 let _ = ctx.tx.send(TuiMessage::Compact);
2633 }
2634}
2635
2636struct CopyCommand;
2637impl SlashCommand for CopyCommand {
2638 fn name(&self) -> &'static str {
2639 "/copy"
2640 }
2641 fn description(&self) -> &'static str {
2642 "Copy last reply to clipboard"
2643 }
2644 fn execute(&self, ctx: &CommandContext, _args: &str) {
2645 let _ = ctx.tx.send(TuiMessage::Copy);
2646 }
2647}
2648
2649struct ExportCommand;
2650impl SlashCommand for ExportCommand {
2651 fn name(&self) -> &'static str {
2652 "/export"
2653 }
2654 fn description(&self) -> &'static str {
2655 "Export session (supports: md, html, jsonl)"
2656 }
2657 fn execute(&self, ctx: &CommandContext, args: &str) {
2658 let format = args.trim().to_lowercase();
2659 if format.is_empty() || format == "md" || format == "markdown" {
2660 let _ = ctx.tx.send(TuiMessage::ExportSession);
2661 } else if format == "html" {
2662 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2663 crate::export::ExportFormat::Html,
2664 ));
2665 } else if format == "jsonl" {
2666 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
2667 crate::export::ExportFormat::Jsonl,
2668 ));
2669 } else {
2670 add_error_message(
2672 &ctx.chat,
2673 &format!(
2674 "Unknown export format: {}. Supported: md, html, jsonl",
2675 args.trim()
2676 ),
2677 );
2678 ctx.tui.request_render(false);
2679 }
2680 }
2681}
2682
2683struct ForkCommand;
2684impl SlashCommand for ForkCommand {
2685 fn name(&self) -> &'static str {
2686 "/fork"
2687 }
2688 fn description(&self) -> &'static str {
2689 "Fork the session into a new one"
2690 }
2691 fn execute(&self, ctx: &CommandContext, _args: &str) {
2692 let _ = ctx.tx.send(TuiMessage::ForkSession);
2693 }
2694}
2695
2696struct CloneCommand;
2700impl SlashCommand for CloneCommand {
2701 fn name(&self) -> &'static str {
2702 "/clone"
2703 }
2704 fn description(&self) -> &'static str {
2705 "Duplicate the current session"
2706 }
2707 fn execute(&self, ctx: &CommandContext, _args: &str) {
2708 let _ = ctx.tx.send(TuiMessage::ForkSession);
2709 }
2710}
2711
2712struct TreeCommand;
2713impl SlashCommand for TreeCommand {
2714 fn name(&self) -> &'static str {
2715 "/tree"
2716 }
2717 fn description(&self) -> &'static str {
2718 "Navigate the current session tree"
2719 }
2720 fn execute(&self, ctx: &CommandContext, _args: &str) {
2721 let _ = ctx.tx.send(TuiMessage::OpenTree);
2722 }
2723}
2724
2725struct LoginCommand;
2726impl SlashCommand for LoginCommand {
2727 fn name(&self) -> &'static str {
2728 "/login"
2729 }
2730 fn description(&self) -> &'static str {
2731 "Save an Anthropic API key"
2732 }
2733 fn execute(&self, ctx: &CommandContext, args: &str) {
2734 let key = args.trim();
2735 if key.is_empty() {
2736 add_note_message(&ctx.chat, "Usage: /login <api-key>");
2737 } else {
2738 let result = crate::config::upsert_credential(
2739 "anthropic",
2740 crate::config::Credential::ApiKey {
2741 key: Some(key.to_string()),
2742 env: None,
2743 },
2744 );
2745 match result {
2746 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2747 Err(error) => {
2748 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2749 }
2750 }
2751 }
2752 ctx.tui.request_render(false);
2753 }
2754}
2755
2756struct LogoutCommand;
2757impl SlashCommand for LogoutCommand {
2758 fn name(&self) -> &'static str {
2759 "/logout"
2760 }
2761 fn description(&self) -> &'static str {
2762 "Remove saved Anthropic credentials"
2763 }
2764 fn execute(&self, ctx: &CommandContext, _args: &str) {
2765 match crate::config::delete_credential("anthropic") {
2766 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2767 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2768 Err(error) => {
2769 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2770 }
2771 }
2772 ctx.tui.request_render(false);
2773 }
2774}
2775
2776fn set_project_trust_for_command(
2777 cwd: &std::path::Path,
2778 value: Option<bool>,
2779) -> Result<(), crate::config::ConfigError> {
2780 crate::config::set_project_trust(cwd, value)
2781}
2782
2783struct TrustCommand;
2784impl SlashCommand for TrustCommand {
2785 fn name(&self) -> &'static str {
2786 "/trust"
2787 }
2788 fn description(&self) -> &'static str {
2789 "Trust the current project"
2790 }
2791 fn execute(&self, ctx: &CommandContext, args: &str) {
2792 let value = match args.trim().to_ascii_lowercase().as_str() {
2793 "" | "yes" | "y" | "true" => Some(true),
2794 "no" | "n" | "false" => Some(false),
2795 "clear" | "reset" | "none" => None,
2796 _ => {
2797 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2798 ctx.tui.request_render(false);
2799 return;
2800 }
2801 };
2802 match set_project_trust_for_command(&ctx.cwd, value) {
2803 Ok(()) => {
2804 let label = match value {
2805 Some(true) => "trusted",
2806 Some(false) => "untrusted",
2807 None => "trust decision cleared",
2808 };
2809 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2810 }
2811 Err(error) => add_error_message(
2812 &ctx.chat,
2813 &format!("Could not save trust decision: {error}"),
2814 ),
2815 }
2816 ctx.tui.request_render(false);
2817 }
2818}
2819
2820struct NameCommand;
2821impl SlashCommand for NameCommand {
2822 fn name(&self) -> &'static str {
2823 "/name"
2824 }
2825 fn description(&self) -> &'static str {
2826 "Set session display name"
2827 }
2828 fn execute(&self, ctx: &CommandContext, args: &str) {
2829 let name = args.trim();
2830 if name.is_empty() {
2831 add_note_message(
2832 &ctx.chat,
2833 "Usage: /name <display name> — sets the current session's name.",
2834 );
2835 ctx.tui.request_render(false);
2836 return;
2837 }
2838 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2839 }
2840}
2841
2842struct ImportCommand;
2843impl SlashCommand for ImportCommand {
2844 fn name(&self) -> &'static str {
2845 "/import"
2846 }
2847 fn description(&self) -> &'static str {
2848 "Import a session file (path)"
2849 }
2850 fn execute(&self, ctx: &CommandContext, args: &str) {
2851 let path = args.trim();
2852 if path.is_empty() {
2853 add_note_message(
2854 &ctx.chat,
2855 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2856 );
2857 ctx.tui.request_render(false);
2858 return;
2859 }
2860 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2861 }
2862}
2863
2864struct SettingsCommand;
2865impl SlashCommand for SettingsCommand {
2866 fn name(&self) -> &'static str {
2867 "/settings"
2868 }
2869 fn description(&self) -> &'static str {
2870 "Open settings menu"
2871 }
2872 fn execute(&self, ctx: &CommandContext, _args: &str) {
2873 open_settings_selector(
2874 &ctx.state,
2875 &ctx.editor_container,
2876 &ctx.editor,
2877 &ctx.tui,
2878 &ctx.lane,
2879 &ctx.model_catalog,
2880 &ctx.lane_model_id,
2881 &ctx.chat,
2882 &ctx.cwd,
2883 &ctx.package_resources,
2884 );
2885 }
2886}
2887
2888struct ScopedModelsCommand;
2889impl SlashCommand for ScopedModelsCommand {
2890 fn name(&self) -> &'static str {
2891 "/scoped-models"
2892 }
2893 fn description(&self) -> &'static str {
2894 "Choose models for Ctrl+P cycling"
2895 }
2896 fn execute(&self, ctx: &CommandContext, _args: &str) {
2897 open_scoped_models_selector(
2898 &ctx.state,
2899 &ctx.editor_container,
2900 &ctx.editor,
2901 &ctx.tui,
2902 &ctx.model_catalog,
2903 &ctx.chat,
2904 );
2905 }
2906}
2907
2908struct ShareCommand;
2909impl SlashCommand for ShareCommand {
2910 fn name(&self) -> &'static str {
2911 "/share"
2912 }
2913 fn description(&self) -> &'static str {
2914 "Share session (gist via gh, or clipboard)"
2915 }
2916 fn execute(&self, ctx: &CommandContext, _args: &str) {
2917 let _ = ctx.tx.send(TuiMessage::ShareSession);
2918 }
2919}
2920
2921struct ArminCommand;
2922impl SlashCommand for ArminCommand {
2923 fn name(&self) -> &'static str {
2924 "/armin"
2925 }
2926 fn description(&self) -> &'static str {
2927 "??? (easter egg)"
2928 }
2929 fn execute(&self, ctx: &CommandContext, _args: &str) {
2930 crate::extras::add_armin(&ctx.chat);
2931 ctx.tui.request_render(false);
2932 }
2933}
2934
2935struct EarendilCommand;
2936impl SlashCommand for EarendilCommand {
2937 fn name(&self) -> &'static str {
2938 "/earendil"
2939 }
2940 fn description(&self) -> &'static str {
2941 "Announcement"
2942 }
2943 fn execute(&self, ctx: &CommandContext, _args: &str) {
2944 crate::extras::add_earendil(&ctx.chat);
2945 ctx.tui.request_render(false);
2946 }
2947}
2948
2949struct ContextCommand;
2953impl SlashCommand for ContextCommand {
2954 fn name(&self) -> &'static str {
2955 "/context"
2956 }
2957 fn visible(&self) -> bool {
2958 false
2959 }
2960 fn execute(&self, ctx: &CommandContext, _args: &str) {
2961 show_context_panel(&ctx.chat, &ctx.resources);
2962 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2964 ctx.tui.request_render(false);
2965 }
2966}
2967
2968struct UsageCommand;
2972impl SlashCommand for UsageCommand {
2973 fn name(&self) -> &'static str {
2974 "/usage"
2975 }
2976 fn description(&self) -> &'static str {
2977 "Show token, cost, and cache totals for this session"
2978 }
2979 fn execute(&self, ctx: &CommandContext, _args: &str) {
2980 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2981 ctx.tui.request_render(false);
2982 }
2983}
2984
2985struct ReloadCommand;
2996impl SlashCommand for ReloadCommand {
2997 fn name(&self) -> &'static str {
2998 "/reload"
2999 }
3000 fn description(&self) -> &'static str {
3001 "Reload extensions, skills, prompts"
3002 }
3003 fn execute(&self, ctx: &CommandContext, _args: &str) {
3004 add_note_message(&ctx.chat, "Reloading extensions + resources…");
3008 ctx.tui.request_render(false);
3009 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
3010 }
3011}
3012
3013fn build_builtin_registry() -> CommandRegistry {
3018 let mut r = CommandRegistry::new();
3019 r.register(Arc::new(HelpCommand));
3020 r.register(Arc::new(ClearChatCommand));
3021 r.register(Arc::new(ExitCommand));
3022 r.register(Arc::new(VersionCommand));
3023 r.register(Arc::new(ChangelogCommand));
3024 r.register(Arc::new(ModelCommand));
3025 r.register(Arc::new(ThinkingCommand));
3026 r.register(Arc::new(ToolsCommand));
3027 r.register(Arc::new(ImagesCommand));
3028 r.register(Arc::new(SessionCommand));
3029 r.register(Arc::new(ThemeCommand));
3030 r.register(Arc::new(CompactCommand));
3031 r.register(Arc::new(CopyCommand));
3032 r.register(Arc::new(HotkeysCommand));
3033 r.register(Arc::new(ArminCommand));
3034 r.register(Arc::new(EarendilCommand));
3035 r.register(Arc::new(ContextCommand));
3036 r.register(Arc::new(UsageCommand));
3037 r.register(Arc::new(NameCommand));
3041 r.register(Arc::new(SettingsCommand));
3042 r.register(Arc::new(ScopedModelsCommand));
3043 r.register(Arc::new(ExportCommand));
3044 r.register(Arc::new(ImportCommand));
3045 r.register(Arc::new(ShareCommand));
3046 r.register(Arc::new(ForkCommand));
3047 r.register(Arc::new(CloneCommand));
3048 r.register(Arc::new(TreeCommand));
3049 r.register(Arc::new(TrustCommand));
3050 r.register(Arc::new(LoginCommand));
3051 r.register(Arc::new(LogoutCommand));
3052 r.register(Arc::new(ReloadCommand));
3053 r
3054}
3055
3056fn register_extension_commands(
3057 registry: &mut CommandRegistry,
3058 session: crate::session::ExtensionSessionCell,
3059) {
3060 let commands = session
3061 .lock()
3062 .ok()
3063 .and_then(|s| s.snapshot_arc())
3064 .map(|snap| snap.commands().to_vec())
3065 .unwrap_or_default();
3066 for command in commands {
3067 let name = if command.name.starts_with('/') {
3068 command.name.clone()
3069 } else {
3070 format!("/{}", command.name)
3071 };
3072 if registry.find(&name).is_some() {
3073 continue;
3074 }
3075 registry.register(Arc::new(ExtensionCommand {
3076 name,
3077 description: command.description,
3078 session: session.clone(),
3079 }));
3080 }
3081}
3082
3083fn register_js_extension_commands(
3084 registry: &mut CommandRegistry,
3085 session: Option<crate::js_extensions::JsExtensionSession>,
3086) {
3087 let Some(session) = session else {
3088 return;
3089 };
3090 for command in &session.commands {
3091 let name = if command.starts_with('/') {
3092 command.clone()
3093 } else {
3094 format!("/{command}")
3095 };
3096 if registry.find(&name).is_none() {
3097 registry.register(Arc::new(JsExtensionCommand {
3098 name,
3099 session: session.clone(),
3100 }));
3101 }
3102 }
3103}
3104
3105enum TuiMessage {
3112 UserInput(String),
3113 OpenTree,
3114 NavigateTree(String),
3115 Exit,
3116 ClearChat,
3118 Compact,
3120 Copy,
3122 SwitchSession(String),
3125 ExportSession,
3127 ExportSessionWithFormat(crate::export::ExportFormat),
3129 ShowUsage,
3131 ForkSession,
3133 SetSessionName(String),
3135 ImportSession(String),
3138 ShareSession,
3141 ReloadExtensions,
3146 ExternalEditorResult(Result<String, String>),
3150 UserBashFinished(UserBashReport),
3155}
3156
3157struct UserBashReport {
3162 command: String,
3163 output: String,
3164 exit_code: Option<i32>,
3165 cancelled: bool,
3166 truncated: bool,
3167 full_output_path: Option<String>,
3168 exclude_from_context: bool,
3169}
3170
3171fn assistant_text(msg: &AssistantMessage) -> String {
3174 msg.content
3175 .iter()
3176 .filter_map(|c| match c {
3177 Content::Text(t) => Some(t.text.clone()),
3178 _ => None,
3179 })
3180 .collect()
3181}
3182
3183fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
3186 msg.content
3187 .iter()
3188 .filter_map(|c| match c {
3189 Content::Text(t) => Some(t.text.clone()),
3190 _ => None,
3191 })
3192 .collect::<Vec<_>>()
3193 .join("\n")
3194}
3195
3196fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
3197 match &msg.content {
3198 rpi_ai::types::UserContent::Text(s) => s.clone(),
3199 rpi_ai::types::UserContent::Blocks(blocks) => blocks
3200 .iter()
3201 .filter_map(|c| match c {
3202 Content::Text(t) => Some(t.text.clone()),
3203 _ => None,
3204 })
3205 .collect(),
3206 }
3207}
3208
3209fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3213 let scoped = crate::settings::load_settings()
3214 .ok()
3215 .and_then(|s| s.scoped_models)
3216 .unwrap_or_default();
3217 if scoped.is_empty() {
3218 return catalog.to_vec();
3219 }
3220 let mut out: Vec<rpi_ai::Model> = catalog
3221 .iter()
3222 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3223 .cloned()
3224 .collect();
3225 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3227 if let Some(cur) = catalog
3228 .iter()
3229 .find(|m| m.id.eq_ignore_ascii_case(current_id))
3230 {
3231 out.push(cur.clone());
3232 }
3233 }
3234 out
3235}
3236
3237fn open_settings_selector(
3246 state: &Arc<TuiState>,
3247 editor_container: &Arc<Container>,
3248 editor: &Arc<Editor>,
3249 tui: &Arc<TuiAltScreen>,
3250 lane: &Arc<dyn AgentLane>,
3251 catalog: &[rpi_ai::Model],
3252 lane_model_id: &str,
3253 chat: &Arc<Container>,
3254 cwd: &std::path::Path,
3255 package_resources: &Arc<crate::packages::PackageResources>,
3256) {
3257 let settings = crate::settings::load_settings().unwrap_or_default();
3258 let items = settings_menu_items(&settings, state, lane_model_id);
3259 let list = Arc::new(SettingsList::new(items));
3260
3261 let state_sel = state.clone();
3266 let ec_sel = editor_container.clone();
3267 let editor_sel = editor.clone();
3268 let tui_sel = tui.clone();
3269 let lane_sel = lane.clone();
3270 let catalog_sel = catalog.to_vec();
3271 let lane_model_sel = lane_model_id.to_string();
3272 let chat_sel = chat.clone();
3273 let cwd_sel = cwd.to_path_buf();
3274 let package_resources_sel = package_resources.clone();
3275 list.on_select(Arc::new(move |item| match item.key.as_str() {
3276 "theme" => open_settings_theme_selector(
3277 &state_sel,
3278 &ec_sel,
3279 &editor_sel,
3280 &tui_sel,
3281 &chat_sel,
3282 &cwd_sel,
3283 &package_resources_sel,
3284 ),
3285 "model" => open_settings_model_selector(
3286 &state_sel,
3287 &ec_sel,
3288 &editor_sel,
3289 &tui_sel,
3290 &lane_sel,
3291 &catalog_sel,
3292 &lane_model_sel,
3293 &chat_sel,
3294 ),
3295 "thinking" => open_settings_thinking_selector(
3296 &state_sel,
3297 &ec_sel,
3298 &editor_sel,
3299 &tui_sel,
3300 &lane_sel,
3301 &catalog_sel,
3302 &lane_model_sel,
3303 &chat_sel,
3304 ),
3305 "scoped-models" => open_scoped_models_selector(
3306 &state_sel,
3307 &ec_sel,
3308 &editor_sel,
3309 &tui_sel,
3310 &catalog_sel,
3311 &chat_sel,
3312 ),
3313 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3314 }));
3315
3316 let state_change = state.clone();
3319 let chat_change = chat.clone();
3320 let tui_change = tui.clone();
3321 list.on_change(Arc::new(move |key, value| {
3322 let mut settings = crate::settings::load_settings().unwrap_or_default();
3323 let applied = apply_setting_change(&state_change, key, value, &mut settings);
3324 let saved = crate::settings::save_settings(&settings);
3325 if let Some(note) = applied {
3326 let suffix = if saved.is_ok() { "" } else { " (not saved)" };
3327 add_note_message(&chat_change, &format!("{note}{suffix}"));
3328 }
3329 tui_change.request_render(false);
3330 }));
3331
3332 let state_cancel = state.clone();
3333 let ec_cancel = editor_container.clone();
3334 let editor_cancel = editor.clone();
3335 let tui_cancel = tui.clone();
3336 list.on_cancel(Arc::new(move || {
3337 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3338 }));
3339
3340 open_selector_with_view(
3341 state,
3342 editor_container,
3343 editor,
3344 tui,
3345 SelectorView::Settings(list.clone()),
3346 list,
3347 SelectorKind::Settings,
3348 );
3349}
3350
3351fn settings_menu_items(
3357 settings: &crate::settings::Settings,
3358 state: &Arc<TuiState>,
3359 lane_model_id: &str,
3360) -> Vec<SettingItem> {
3361 let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
3362 let current_model = settings
3363 .default_model
3364 .clone()
3365 .unwrap_or_else(|| lane_model_id.to_string());
3366 let current_thinking = settings
3367 .default_thinking_level
3368 .clone()
3369 .unwrap_or_else(|| "(default)".to_string());
3370 let scoped_desc = match &settings.scoped_models {
3371 Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
3372 _ => "all models".to_string(),
3373 };
3374 let hide_thinking = state.hide_thinking();
3375 let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
3376 let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
3377 let http_idle = settings
3378 .http_idle_timeout_ms()
3379 .map(|ms| {
3380 if ms == 0 {
3381 "disabled".to_string()
3382 } else {
3383 format!("{}s", ms / 1000)
3384 }
3385 })
3386 .unwrap_or_else(|| "5m".to_string());
3387
3388 vec![
3389 SettingItem::new("theme", "Theme", ¤t_theme)
3390 .with_description("Color theme for the interface")
3391 .with_submenu(),
3392 SettingItem::new("model", "Default model", ¤t_model)
3393 .with_description("Saved default model for new sessions")
3394 .with_submenu(),
3395 SettingItem::new("thinking", "Default thinking", ¤t_thinking)
3396 .with_description("Saved default thinking level")
3397 .with_submenu(),
3398 SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
3399 .with_description("Models enabled for Ctrl+P cycling")
3400 .with_submenu(),
3401 SettingItem::new(
3402 "hide-thinking",
3403 "Hide thinking",
3404 bool_setting_value(hide_thinking),
3405 )
3406 .with_description("Hide thinking blocks in assistant responses")
3407 .with_values(&["true", "false"]),
3408 SettingItem::new(
3409 "show-images",
3410 "Show images",
3411 bool_setting_value(show_images),
3412 )
3413 .with_description("Render images inline in the terminal")
3414 .with_values(&["true", "false"]),
3415 SettingItem::new(
3416 "cache-miss-notices",
3417 "Cache miss notices",
3418 bool_setting_value(show_cache_miss),
3419 )
3420 .with_description("Transcript notices for prompt-cache costs")
3421 .with_values(&["true", "false"]),
3422 SettingItem::new(
3423 "quiet-startup",
3424 "Quiet startup",
3425 bool_setting_value(settings.quiet_startup.unwrap_or(false)),
3426 )
3427 .with_description("Disable the verbose startup listing (applies on restart)")
3428 .with_values(&["true", "false"]),
3429 SettingItem::new(
3430 "terminal-progress",
3431 "Terminal progress",
3432 bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
3433 )
3434 .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
3435 .with_values(&["true", "false"]),
3436 SettingItem::new(
3437 "fullscreen-copy-on-select",
3438 "Fullscreen copy on select",
3439 bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
3440 )
3441 .with_description("Copy selected text automatically in fullscreen mode")
3442 .with_values(&["true", "false"]),
3443 SettingItem::new(
3444 "double-escape-action",
3445 "Double-escape action",
3446 settings
3447 .double_escape_action
3448 .clone()
3449 .unwrap_or_else(|| "tree".to_string())
3450 .as_str(),
3451 )
3452 .with_description("Esc Esc with an empty editor (applies on restart)")
3453 .with_values(&["tree", "fork", "none"]),
3454 SettingItem::new(
3455 "editor-padding",
3456 "Editor padding",
3457 settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
3458 )
3459 .with_description("Horizontal editor padding, 0-3 (applies on restart)")
3460 .with_values(&["0", "1", "2", "3"]),
3461 SettingItem::new(
3462 "autocomplete-max-items",
3463 "Autocomplete max items",
3464 settings
3465 .autocomplete_max_visible
3466 .unwrap_or(5)
3467 .to_string()
3468 .as_str(),
3469 )
3470 .with_description("Max autocomplete rows, 3-20 (applies on restart)")
3471 .with_values(&["3", "5", "7", "10", "15", "20"]),
3472 SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
3473 .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
3474 .with_values(&["30s", "1m", "5m", "disabled"]),
3475 ]
3476}
3477
3478fn bool_setting_value(value: bool) -> &'static str {
3480 if value {
3481 "true"
3482 } else {
3483 "false"
3484 }
3485}
3486
3487fn apply_setting_change(
3492 state: &Arc<TuiState>,
3493 key: &str,
3494 value: &str,
3495 settings: &mut crate::settings::Settings,
3496) -> Option<String> {
3497 let as_bool = || value == "true";
3498 match key {
3499 "hide-thinking" => {
3501 state.set_hide_thinking(as_bool());
3502 settings.hide_thinking_block = Some(as_bool());
3503 Some(format!(
3504 "Thinking blocks {}.",
3505 if as_bool() { "hidden" } else { "shown" }
3506 ))
3507 }
3508 "show-images" => {
3510 if let Ok(mut guard) = state.show_images.lock() {
3511 *guard = as_bool();
3512 }
3513 settings.show_images = Some(as_bool());
3514 Some(format!(
3515 "Inline images {}.",
3516 if as_bool() { "enabled" } else { "disabled" }
3517 ))
3518 }
3519 "cache-miss-notices" => {
3521 if let Ok(mut guard) = state.cache_miss_notices.lock() {
3522 *guard = as_bool();
3523 }
3524 settings.show_cache_miss_notices = Some(as_bool());
3525 Some(format!(
3526 "Cache miss notices {}.",
3527 if as_bool() { "enabled" } else { "disabled" }
3528 ))
3529 }
3530 "fullscreen-copy-on-select" => {
3532 settings.fullscreen_copy_on_select = Some(as_bool());
3533 Some(format!(
3534 "Copy on select {}.",
3535 if as_bool() { "enabled" } else { "disabled" }
3536 ))
3537 }
3538 "quiet-startup" => {
3540 settings.quiet_startup = Some(as_bool());
3541 None
3542 }
3543 "terminal-progress" => {
3544 settings.show_terminal_progress = Some(as_bool());
3545 None
3546 }
3547 "double-escape-action" => {
3548 settings.double_escape_action = Some(value.to_string());
3549 None
3550 }
3551 "editor-padding" => {
3552 settings.editor_padding_x = value.parse().ok();
3553 None
3554 }
3555 "autocomplete-max-items" => {
3556 settings.autocomplete_max_visible = value.parse().ok();
3557 None
3558 }
3559 "http-idle-timeout" => {
3560 settings.http_idle_timeout = Some(match value {
3561 "disabled" => serde_json::Value::String("disabled".to_string()),
3562 "30s" => serde_json::json!(30_000),
3563 "1m" => serde_json::json!(60_000),
3564 _ => serde_json::json!(300_000),
3565 });
3566 None
3567 }
3568 _ => None,
3569 }
3570}
3571
3572fn open_settings_theme_selector(
3574 state: &Arc<TuiState>,
3575 editor_container: &Arc<Container>,
3576 editor: &Arc<Editor>,
3577 tui: &Arc<TuiAltScreen>,
3578 chat: &Arc<Container>,
3579 cwd: &std::path::Path,
3580 package_resources: &Arc<crate::packages::PackageResources>,
3581) {
3582 let mut items = vec![
3583 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3584 SelectItem::new("light", "Light").with_description("Light background"),
3585 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3586 ];
3587 if state.themes_enabled {
3588 for path in package_resources.theme_files() {
3589 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3590 items.push(SelectItem::new(name, name).with_description("Package theme"));
3591 }
3592 }
3593 }
3594 let list = Arc::new(SelectList::new(items, 10));
3595
3596 let state_sel = state.clone();
3597 let ec_sel = editor_container.clone();
3598 let editor_sel = editor.clone();
3599 let tui_sel = tui.clone();
3600 let chat_sel = chat.clone();
3601 let cwd_sel = cwd.to_path_buf();
3602 let package_resources_sel = package_resources.clone();
3603 list.on_select(Arc::new(move |item| {
3604 let preset = match item.value.as_str() {
3605 "light" => Some(ThemePreset::Light),
3606 "monochrome" => Some(ThemePreset::Monochrome),
3607 "dark" => Some(ThemePreset::Dark),
3608 name => {
3609 if state_sel.themes_enabled {
3610 if let Ok(custom) = crate::packages::load_theme_with_resources(
3611 &cwd_sel,
3612 name,
3613 &package_resources_sel,
3614 ) {
3615 rpi_tui::global_theme_manager().set(custom.clone());
3616 state_sel.theme_manager.set(custom);
3617 }
3618 }
3619 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3620 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3621 tui_sel.render_now(true);
3622 return;
3623 }
3624 };
3625 let Some(preset) = preset else { return };
3626 apply_theme_preset(preset);
3627 state_sel.theme_manager.apply_preset(preset);
3628 let mut settings = crate::settings::load_settings().unwrap_or_default();
3629 settings.theme = Some(item.value.clone());
3630 let saved = crate::settings::save_settings(&settings);
3631 add_note_message(
3632 &chat_sel,
3633 &format!(
3634 "Theme set to {} (saved{})",
3635 item.label,
3636 if saved.is_ok() { "" } else { ", not saved" },
3637 ),
3638 );
3639 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3640 tui_sel.render_now(true);
3641 }));
3642 let state_cancel = state.clone();
3643 let ec_cancel = editor_container.clone();
3644 let editor_cancel = editor.clone();
3645 let tui_cancel = tui.clone();
3646 list.on_cancel(Arc::new(move || {
3647 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3648 }));
3649
3650 open_selector(
3651 state,
3652 editor_container,
3653 editor,
3654 tui,
3655 list,
3656 SelectorKind::Settings,
3657 );
3658}
3659
3660fn open_settings_model_selector(
3664 state: &Arc<TuiState>,
3665 editor_container: &Arc<Container>,
3666 editor: &Arc<Editor>,
3667 tui: &Arc<TuiAltScreen>,
3668 lane: &Arc<dyn AgentLane>,
3669 catalog: &[rpi_ai::Model],
3670 lane_model_id: &str,
3671 chat: &Arc<Container>,
3672) {
3673 let items = model_selector_items(catalog, lane_model_id);
3674 if items.is_empty() {
3675 add_note_message(chat, "No models in the catalog.");
3676 tui.request_render(false);
3677 return;
3678 }
3679 let list = Arc::new(SelectList::new(items, 10));
3680
3681 let catalog_arc = catalog.to_vec();
3682 let state_sel = state.clone();
3683 let ec_sel = editor_container.clone();
3684 let editor_sel = editor.clone();
3685 let tui_sel = tui.clone();
3686 let chat_sel = chat.clone();
3687 let lane_sel = lane.clone();
3688 list.on_select(Arc::new(move |item| {
3689 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3690 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3691 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3692 return;
3693 };
3694 state_sel.set_current_model(&model);
3695 let lane = lane_sel.clone();
3696 tokio::spawn(async move {
3697 let _ = lane.set_model(model).await;
3698 });
3699 let mut settings = crate::settings::load_settings().unwrap_or_default();
3700 settings.default_model = Some(item.value.clone());
3701 let saved = crate::settings::save_settings(&settings);
3702 add_note_message(
3703 &chat_sel,
3704 &format!(
3705 "Default model set to {} (saved{}",
3706 short_model_name(&item.value),
3707 if saved.is_ok() { ")" } else { ", not saved)" },
3708 ),
3709 );
3710 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3711 }));
3712 let state_cancel = state.clone();
3713 let ec_cancel = editor_container.clone();
3714 let editor_cancel = editor.clone();
3715 let tui_cancel = tui.clone();
3716 list.on_cancel(Arc::new(move || {
3717 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3718 }));
3719
3720 open_searchable_selector(
3721 state,
3722 editor_container,
3723 editor,
3724 tui,
3725 Some("Default model"),
3726 Some("Type to filter by name, provider, or id"),
3727 list,
3728 SelectorKind::Settings,
3729 );
3730}
3731
3732fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3737 let mut seen = std::collections::HashSet::new();
3738 catalog
3739 .iter()
3740 .filter(|m| {
3741 seen.insert((
3742 m.api.clone(),
3743 m.provider.to_ascii_lowercase(),
3744 m.id.to_ascii_lowercase(),
3745 ))
3746 })
3747 .map(|m| {
3748 let label = if m.name.is_empty() {
3749 short_model_name(&m.id)
3750 } else {
3751 m.name.clone()
3752 };
3753 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3754 && m.provider.eq_ignore_ascii_case("anthropic")
3755 {
3756 m.id.clone()
3757 } else {
3758 format!("{}/{}", m.provider, m.id)
3759 };
3760 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3761 " (current)"
3762 } else {
3763 ""
3764 };
3765 let name = if m.name.is_empty() {
3768 String::new()
3769 } else {
3770 format!(" {}", m.name)
3771 };
3772 let search_text = format!(
3773 "{} {}/{} {} {}{}",
3774 m.provider, m.provider, m.id, m.provider, m.id, name
3775 );
3776 SelectItem::new(&m.id, &label)
3777 .with_description(&format!("{identity}{marker}"))
3778 .with_search_text(&search_text)
3779 })
3780 .collect()
3781}
3782
3783fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3787 let (provider, id) = input
3788 .split_once('/')
3789 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3790 .map_or((None, input), |(provider, id)| (Some(provider), id));
3791 catalog
3792 .iter()
3793 .find(|model| {
3794 model.id.eq_ignore_ascii_case(id)
3795 && provider.map_or(true, |provider| {
3796 model.provider.eq_ignore_ascii_case(provider)
3797 || (provider.eq_ignore_ascii_case("anthropic")
3798 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3799 })
3800 })
3801 .cloned()
3802}
3803
3804fn open_settings_thinking_selector(
3808 state: &Arc<TuiState>,
3809 editor_container: &Arc<Container>,
3810 editor: &Arc<Editor>,
3811 tui: &Arc<TuiAltScreen>,
3812 lane: &Arc<dyn AgentLane>,
3813 catalog: &[rpi_ai::Model],
3814 lane_model_id: &str,
3815 chat: &Arc<Container>,
3816) {
3817 let model = catalog
3818 .iter()
3819 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3820 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3821 .map(|m| m.supported_thinking_levels())
3822 .unwrap_or_else(|| {
3823 use rpi_ai::types::ThinkingLevel::*;
3824 vec![Off, Minimal, Low, Medium, High]
3825 });
3826 let mut items: Vec<SelectItem> = Vec::new();
3827 for lvl in &levels {
3828 let name = thinking_level_name(*lvl);
3829 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3830 }
3831 if items.is_empty() {
3832 add_note_message(chat, "This model has no supported thinking levels.");
3833 tui.request_render(false);
3834 return;
3835 }
3836 let list = Arc::new(SelectList::new(items, 10));
3837
3838 let state_sel = state.clone();
3839 let ec_sel = editor_container.clone();
3840 let editor_sel = editor.clone();
3841 let tui_sel = tui.clone();
3842 let chat_sel = chat.clone();
3843 let lane_sel = lane.clone();
3844 list.on_select(Arc::new(move |item| {
3845 let Some(level) = thinking_level_from_name(&item.value) else {
3846 add_note_message(
3847 &chat_sel,
3848 &format!("Unknown thinking level: {}.", item.label),
3849 );
3850 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3851 return;
3852 };
3853 let lane = lane_sel.clone();
3854 let footer_sel = state_sel.footer.clone();
3855 tokio::spawn(async move {
3856 let _ = lane.set_thinking_level(level).await;
3857 });
3858 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3859 let mut settings = crate::settings::load_settings().unwrap_or_default();
3860 settings.default_thinking_level = Some(item.value.clone());
3861 let saved = crate::settings::save_settings(&settings);
3862 add_note_message(
3863 &chat_sel,
3864 &format!(
3865 "Default thinking set to {} (saved{}",
3866 item.label,
3867 if saved.is_ok() { ")" } else { ", not saved)" },
3868 ),
3869 );
3870 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3871 }));
3872 let state_cancel = state.clone();
3873 let ec_cancel = editor_container.clone();
3874 let editor_cancel = editor.clone();
3875 let tui_cancel = tui.clone();
3876 list.on_cancel(Arc::new(move || {
3877 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3878 }));
3879
3880 open_selector(
3881 state,
3882 editor_container,
3883 editor,
3884 tui,
3885 list,
3886 SelectorKind::Settings,
3887 );
3888}
3889
3890fn open_scoped_models_selector(
3895 state: &Arc<TuiState>,
3896 editor_container: &Arc<Container>,
3897 editor: &Arc<Editor>,
3898 tui: &Arc<TuiAltScreen>,
3899 catalog: &[rpi_ai::Model],
3900 chat: &Arc<Container>,
3901) {
3902 if catalog.is_empty() {
3903 add_note_message(chat, "No models in the catalog.");
3904 tui.request_render(false);
3905 return;
3906 }
3907 let seed: Vec<String> = crate::settings::load_settings()
3909 .ok()
3910 .and_then(|s| s.scoped_models)
3911 .unwrap_or_default();
3912 *state.scoped_edit.lock().unwrap() = Some(seed);
3913
3914 let mut items: Vec<SelectItem> = Vec::new();
3915 for m in catalog {
3916 items.push(SelectItem::new(&m.id, &m.id));
3917 }
3918 let list = Arc::new(SelectList::new(items, 10));
3919
3920 let state_sel = state.clone();
3921 let chat_sel = chat.clone();
3922 let tui_sel = tui.clone();
3923 list.on_select(Arc::new(move |item| {
3924 let mut set = state_sel.scoped_edit.lock().unwrap();
3926 let set = set.get_or_insert_with(Vec::new);
3927 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3928 set.remove(pos);
3929 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3930 } else {
3931 set.push(item.value.clone());
3932 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3933 }
3934 tui_sel.request_render(false);
3935 }));
3936 let state_cancel = state.clone();
3937 let ec_cancel = editor_container.clone();
3938 let editor_cancel = editor.clone();
3939 let tui_cancel = tui.clone();
3940 let chat_cancel = chat.clone();
3941 list.on_cancel(Arc::new(move || {
3942 let set = state_cancel
3944 .scoped_edit
3945 .lock()
3946 .unwrap()
3947 .take()
3948 .unwrap_or_default();
3949 let mut settings = crate::settings::load_settings().unwrap_or_default();
3950 settings.scoped_models = if set.is_empty() {
3951 None
3952 } else {
3953 Some(set.clone())
3954 };
3955 match crate::settings::save_settings(&settings) {
3956 Ok(()) => {
3957 if set.is_empty() {
3958 add_note_message(&chat_cancel, "Ctrl+P cycles all models (scope cleared).");
3959 } else {
3960 add_note_message(
3961 &chat_cancel,
3962 &format!("Ctrl+P cycle scope: {}", set.join(", ")),
3963 );
3964 }
3965 }
3966 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3967 }
3968 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3969 }));
3970
3971 open_selector(
3972 state,
3973 editor_container,
3974 editor,
3975 tui,
3976 list,
3977 SelectorKind::ScopedModels,
3978 );
3979}
3980
3981async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3985 use std::process::Stdio;
3986
3987 let tree = harness.session().view("main");
3989 let entries = match tree
3990 .find_entries(&EntryQuery {
3991 entry_type: None,
3992 custom_type: None,
3993 order: Some(EntryOrder::OldestFirst),
3996 limit: None,
3997 cursor: None,
3998 })
3999 .await
4000 {
4001 Ok(e) => e,
4002 Err(e) => {
4003 add_error_message(chat, &format!("Could not read session: {e}"));
4004 return;
4005 }
4006 };
4007 let mut md = String::from("# Session\n\n");
4008 for e in entries {
4009 let Entry::Message(me) = e else { continue };
4010 match &me.message {
4011 AgentMessage::User(u) => {
4012 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
4013 }
4014 AgentMessage::Assistant(a) => {
4015 let text = assistant_text(a);
4016 if !text.is_empty() {
4017 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
4018 }
4019 }
4020 _ => {}
4021 }
4022 }
4023
4024 let gh = std::process::Command::new("gh")
4026 .arg("gist")
4027 .arg("create")
4028 .arg("--filename")
4029 .arg("session.md")
4030 .arg("-")
4031 .stdin(Stdio::piped())
4032 .stdout(Stdio::piped())
4033 .stderr(Stdio::null())
4034 .spawn();
4035 if let Ok(mut child) = gh {
4036 use std::io::Write;
4037 if let Some(mut stdin) = child.stdin.take() {
4038 let _ = stdin.write_all(md.as_bytes());
4039 let _ = stdin.flush();
4040 }
4041 let out = child.wait_with_output().ok();
4042 if let Some(out) = out {
4043 if out.status.success() {
4044 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
4045 add_note_message(chat, &format!("Shared session: {url}"));
4046 return;
4047 }
4048 }
4049 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
4050 } else {
4051 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
4052 }
4053 if copy_to_clipboard(&md) {
4055 add_note_message(chat, "Session transcript copied to the clipboard.");
4056 } else {
4057 add_note_message(
4058 chat,
4059 "Clipboard unavailable — use /export to write the transcript to a file.",
4060 );
4061 }
4062}
4063
4064async fn switch_to_session(
4079 harness: &AgentHarness,
4080 lane: &Arc<dyn AgentLane>,
4081 id: &str,
4082 cwd: &std::path::Path,
4083 chat: &Arc<Container>,
4084 state: &Arc<TuiState>,
4085) -> bool {
4086 if *state.status.lock().unwrap() == RunStatus::Working {
4087 state.set_status(RunStatus::Aborting);
4088 let _ = lane.abort().await;
4089 }
4090 let cwd_str = cwd.to_string_lossy().to_string();
4091 match crate::session::open_session_by_id(id, &cwd_str).await {
4092 Ok(new_session) => {
4093 let _ = harness.set_session(new_session).await;
4094 chat.clear();
4095 add_welcome_message(chat);
4096 render_session_history(
4097 harness,
4098 chat,
4099 state.markdown_transformer(),
4100 Some(state.extension_session.clone()),
4101 )
4102 .await;
4103 state.set_status(RunStatus::Idle);
4104 add_note_message(chat, &format!("Switched to session {id}."));
4105 true
4106 }
4107 Err(e) => {
4108 state.set_status(RunStatus::Idle);
4109 add_error_message(chat, &format!("Could not open session {id}: {e}"));
4110 false
4111 }
4112 }
4113}
4114
4115async fn import_session(
4119 harness: &AgentHarness,
4120 lane: &Arc<dyn AgentLane>,
4121 path: &str,
4122 cwd: &std::path::Path,
4123 chat: &Arc<Container>,
4124 state: &Arc<TuiState>,
4125) {
4126 use std::path::Path as FsPath;
4127
4128 let src = FsPath::new(path);
4129 if !src.is_file() {
4130 add_error_message(chat, &format!("Import source not found: {path}"));
4131 return;
4132 }
4133 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
4134 add_error_message(chat, "Import source has no file name.");
4135 return;
4136 };
4137 if !fname.ends_with(".jsonl") {
4138 add_error_message(chat, "Import source must be a .jsonl session file.");
4139 return;
4140 }
4141 let dir = crate::session::default_session_dir(cwd);
4142 if let Err(e) = std::fs::create_dir_all(&dir) {
4143 add_error_message(chat, &format!("Could not create session dir: {e}"));
4144 return;
4145 }
4146 let dest = dir.join(fname);
4147 match std::fs::copy(src, &dest) {
4148 Ok(_) => {
4149 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
4150 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
4151 add_note_message(chat, &format!("Imported session from {path}"));
4152 }
4153 }
4154 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
4155 }
4156}
4157
4158async fn fork_session(
4159 harness: &AgentHarness,
4160 cwd: &std::path::Path,
4161 chat: &Arc<Container>,
4162 state: &Arc<TuiState>,
4163) {
4164 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
4165 use rpi_tools::FileSystem;
4166
4167 let cwd_str = cwd.to_string_lossy().to_string();
4168 let dir = crate::session::default_session_dir(cwd);
4169 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
4170 let fs: Arc<dyn FileSystem> = env.clone();
4171 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
4172 fs,
4173 sessions_root: dir.to_string_lossy().into_owned(),
4174 clock: Arc::new(rpi_harness::session::memory::SystemClock),
4175 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
4176 });
4177 let id = harness.session().storage().metadata().id.clone();
4180 let metas = match crate::session::list_session_metadata(&cwd_str).await {
4181 Ok(m) => m,
4182 Err(e) => {
4183 add_error_message(chat, &format!("Could not list sessions: {e}"));
4184 return;
4185 }
4186 };
4187 let Some(source) = metas.iter().find(|m| m.id == id) else {
4188 add_error_message(chat, &format!("Current session {id} not found on disk."));
4189 return;
4190 };
4191 let fork_storage = match repo
4192 .fork_typed(
4193 source,
4194 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
4195 id: None,
4196 parent_session_id: Some(source.id.clone()),
4197 cwd: cwd_str.clone(),
4198 metadata: None,
4199 },
4200 &rpi_harness::session::types::ForkOptions::default(),
4201 )
4202 .await
4203 {
4204 Ok(s) => s,
4205 Err(e) => {
4206 add_error_message(chat, &format!("Could not fork session: {e}"));
4207 return;
4208 }
4209 };
4210 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
4211 let _ = harness.set_session(new_session).await;
4212 chat.clear();
4213 add_welcome_message(chat);
4214 render_session_history(
4215 harness,
4216 chat,
4217 state.markdown_transformer(),
4218 Some(state.extension_session.clone()),
4219 )
4220 .await;
4221 state.set_status(RunStatus::Idle);
4222 add_note_message(chat, "Forked into a new session.");
4223}
4224
4225async fn render_session_history(
4235 harness: &AgentHarness,
4236 chat: &Arc<Container>,
4237 transformer: Option<MarkdownTransformer>,
4238 extension_session: Option<crate::session::ExtensionSessionCell>,
4239) {
4240 let tree = harness.session().view("main");
4241 let entries = match tree
4242 .find_entries(&EntryQuery {
4243 entry_type: None,
4244 custom_type: None,
4245 order: Some(EntryOrder::OldestFirst),
4249 limit: None,
4250 cursor: None,
4251 })
4252 .await
4253 {
4254 Ok(e) => e,
4255 Err(_) => return,
4256 };
4257 let mut rendered_any = false;
4258 for e in entries {
4259 match e {
4260 Entry::Message(me) => match &me.message {
4261 AgentMessage::User(u) => {
4262 add_user_message(chat, &user_message_text(u));
4263 rendered_any = true;
4264 }
4265 AgentMessage::Assistant(a) => {
4266 let comp = Arc::new(AssistantMessageComponent::new(
4267 AssistantMessageOptions::default(),
4268 ));
4269 if let Some(t) = &transformer {
4270 comp.set_markdown_transformer(Some(t.clone()));
4271 }
4272 comp.update_blocks(&assistant_blocks(a));
4273 chat.add_child(comp);
4274 chat.add_child(Arc::new(Spacer::new(1)));
4277 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
4278 add_note_message(chat, &text);
4279 }
4280 if let Some(error) = assistant_error_text(a) {
4281 add_error_message(chat, &error);
4282 }
4283 rendered_any = true;
4284 }
4285 AgentMessage::ToolResult(result) => {
4286 let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
4291 comp.set_result(&tool_result_message_text(result), result.is_error);
4292 if tool_result_requests_markdown(result.details.as_ref()) {
4293 comp.set_result_markdown(true);
4294 }
4295 chat.add_child(comp);
4296 chat.add_child(Arc::new(Spacer::new(1)));
4297 rendered_any = true;
4298 }
4299 AgentMessage::Custom(custom) => {
4300 if let Some(session) = &extension_session {
4301 if let Some(component) = extension_message_component(
4302 session,
4303 &custom.role,
4304 &serde_json::json!({
4305 "customType": custom.role,
4306 "content": custom.content,
4307 "details": custom.data,
4308 }),
4309 transformer.clone(),
4310 ) {
4311 chat.add_child(component);
4312 chat.add_child(Arc::new(Spacer::new(1)));
4313 rendered_any = true;
4314 continue;
4315 }
4316 }
4317 add_note_message(chat, &custom_message_fallback(&custom));
4318 rendered_any = true;
4319 }
4320 },
4321 Entry::Compaction(compaction) => {
4322 add_note_message(
4323 chat,
4324 &format!(
4325 "Compacted {} tokens: {}",
4326 compaction.tokens_before, compaction.summary
4327 ),
4328 );
4329 rendered_any = true;
4330 }
4331 Entry::BranchSummary(summary) => {
4332 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
4333 rendered_any = true;
4334 }
4335 Entry::Custom(custom) => {
4336 let rendered = extension_session.as_ref().and_then(|session| {
4337 extension_entry_component(session, &custom.custom_type, custom.data.clone())
4338 });
4339 if let Some(component) = rendered {
4340 chat.add_child(component);
4341 chat.add_child(Arc::new(Spacer::new(1)));
4342 rendered_any = true;
4343 } else if let Some(text) =
4344 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
4345 {
4346 add_note_message(chat, &text);
4347 rendered_any = true;
4348 }
4349 }
4350 Entry::ModelChange(change) => {
4351 add_note_message(
4352 chat,
4353 &format!("Model changed to {}:{}", change.provider, change.model_id),
4354 );
4355 rendered_any = true;
4356 }
4357 Entry::ThinkingLevel(change) => {
4358 add_note_message(
4359 chat,
4360 &format!("Thinking level: {:?}", change.thinking_level),
4361 );
4362 rendered_any = true;
4363 }
4364 Entry::ActiveTools(change) => {
4365 add_note_message(
4366 chat,
4367 &format!("Active tools: {}", change.active_tool_names.join(", ")),
4368 );
4369 rendered_any = true;
4370 }
4371 }
4372 }
4373 if rendered_any {
4374 }
4377}
4378
4379fn invoke_extension_renderer(
4380 session: &crate::session::ExtensionSessionCell,
4381 kind: rpi_extensions::RegisteredRendererKind,
4382 payload: &serde_json::Value,
4383) -> Option<serde_json::Value> {
4384 let snapshot = session.lock().ok()?.snapshot_arc()?;
4385 let input = serde_json::to_string(payload).ok()?;
4386 for renderer in snapshot.renderers_of(kind) {
4387 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4388 let mut out = rpi_plugin_sdk::StbString::empty();
4389 let rc = (renderer.render_fn)(
4390 rpi_plugin_sdk::StbStringRef::from_str(&input),
4391 &mut out as *mut rpi_plugin_sdk::StbString,
4392 renderer.user_data,
4393 );
4394 let text = if rc == 0 {
4395 Some(out.to_string_lossy())
4396 } else {
4397 None
4398 };
4399 out.free_with(Some(renderer.plugin_free_string));
4400 text
4401 }))
4402 .ok()
4403 .flatten();
4404 let Some(text) = outcome else { continue };
4405 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
4406 return Some(value);
4407 }
4408 }
4409 None
4410}
4411
4412fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
4413 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
4414 let text = lines
4415 .iter()
4416 .filter_map(|line| line.as_str())
4417 .collect::<Vec<_>>()
4418 .join("\n");
4419 return Some(Arc::new(Text::new(text, 0, 0)));
4420 }
4421 let text = value.get("text").and_then(|v| v.as_str())?;
4422 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4423 let component = Arc::new(AssistantMessageComponent::new(
4424 AssistantMessageOptions::default(),
4425 ));
4426 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4427 Some(component)
4428 } else {
4429 Some(Arc::new(Text::new(text, 0, 0)))
4430 }
4431}
4432
4433fn extension_message_component(
4434 session: &crate::session::ExtensionSessionCell,
4435 custom_type: &str,
4436 payload: &serde_json::Value,
4437 transformer: Option<MarkdownTransformer>,
4438) -> Option<Arc<dyn rpi_tui::Component>> {
4439 let value = invoke_extension_renderer(
4440 session,
4441 rpi_extensions::RegisteredRendererKind::Message,
4442 payload,
4443 )?;
4444 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4445 let text = value.get("text").and_then(|v| v.as_str())?;
4446 let component = Arc::new(AssistantMessageComponent::new(
4447 AssistantMessageOptions::default(),
4448 ));
4449 if let Some(transformer) = transformer {
4450 component.set_markdown_transformer(Some(transformer));
4451 }
4452 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4453 return Some(component);
4454 }
4455 extension_text_component(&value)
4456 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4457}
4458
4459fn extension_usage_text(
4462 session: Option<&crate::session::ExtensionSessionCell>,
4463 usage: &rpi_ai::types::Usage,
4464) -> Option<String> {
4465 let session = session?;
4466 let payload = serde_json::json!({
4467 "customType": "token-usage",
4468 "usage": usage,
4469 });
4470 let value = invoke_extension_renderer(
4471 session,
4472 rpi_extensions::RegisteredRendererKind::Message,
4473 &payload,
4474 )?;
4475 value
4476 .get("text")
4477 .and_then(|value| value.as_str())
4478 .filter(|text| !text.trim().is_empty())
4479 .map(ToOwned::to_owned)
4480}
4481
4482fn extension_entry_component(
4483 session: &crate::session::ExtensionSessionCell,
4484 custom_type: &str,
4485 data: Option<serde_json::Value>,
4486) -> Option<Arc<dyn rpi_tui::Component>> {
4487 let payload = serde_json::json!({
4488 "customType": custom_type,
4489 "data": data,
4490 });
4491 let value = invoke_extension_renderer(
4492 session,
4493 rpi_extensions::RegisteredRendererKind::Entry,
4494 &payload,
4495 )?;
4496 extension_text_component(&value)
4497}
4498
4499fn launch_restores_history(args: &Args) -> bool {
4505 args.continue_session
4506 || args.resume
4507 || args.session.is_some()
4508 || args.session_id.is_some()
4509 || args.fork.is_some()
4510}
4511
4512fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4513 let content = custom
4514 .content
4515 .iter()
4516 .filter_map(|item| match item {
4517 Content::Text(text) => Some(text.text.as_str()),
4518 _ => None,
4519 })
4520 .collect::<Vec<_>>()
4521 .join("\n");
4522 if content.is_empty() {
4523 format!("{}: {}", custom.role, custom.data)
4524 } else {
4525 format!("{}: {}", custom.role, content)
4526 }
4527}
4528
4529fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
4531 let mut dir = Some(cwd);
4532 while let Some(current) = dir {
4533 let head = current.join(".git").join("HEAD");
4534 if head.exists() {
4535 return Some(head);
4536 }
4537 dir = current.parent();
4538 }
4539 None
4540}
4541
4542fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
4546 let mut dir = Some(cwd);
4547 while let Some(current) = dir {
4548 let head = current.join(".git").join("HEAD");
4549 if let Ok(contents) = std::fs::read_to_string(&head) {
4550 let trimmed = contents.trim();
4551 if let Some(reference) = trimmed.strip_prefix("ref: ") {
4552 let branch = reference
4553 .rsplit('/')
4554 .next()
4555 .unwrap_or(reference)
4556 .trim()
4557 .to_string();
4558 if !branch.is_empty() {
4559 return Some(branch);
4560 }
4561 }
4562 if !trimmed.is_empty() {
4563 return Some(trimmed.chars().take(7).collect());
4564 }
4565 }
4566 dir = current.parent();
4567 }
4568 None
4569}
4570
4571fn short_model_name(id: &str) -> String {
4574 id.rsplit([':', '/'])
4575 .next()
4576 .filter(|s| !s.is_empty())
4577 .unwrap_or(id)
4578 .to_string()
4579}
4580
4581#[derive(Clone, Copy, PartialEq, Eq)]
4587enum RunStatus {
4588 Idle,
4589 Working,
4590 Aborting,
4591}
4592
4593#[derive(Clone, Copy, PartialEq, Eq)]
4595enum SelectorKind {
4596 Model,
4598 Thinking,
4600 Tools,
4602 Images,
4604 Session,
4606 Theme,
4608 ScopedModels,
4610 Settings,
4612 Tree,
4614 Extension,
4616}
4617
4618#[derive(Clone)]
4625enum SelectorView {
4626 List(Arc<SelectList>),
4628 Searchable(Arc<SearchableSelectList>),
4630 Settings(Arc<SettingsList>),
4633}
4634
4635impl SelectorView {
4636 fn handle_key(&self, key: KeyEvent) {
4638 match self {
4639 Self::List(list) => list.handle_key(key),
4640 Self::Searchable(searchable) => searchable.handle_key(key),
4641 Self::Settings(settings) => settings.handle_key(key),
4642 }
4643 }
4644}
4645
4646#[derive(Debug, Clone)]
4657struct AutoSendPending {
4658 deadline: std::time::Instant,
4660 text: String,
4662}
4663
4664#[derive(Debug, PartialEq, Eq)]
4668enum AutoSendStep {
4669 Idle,
4671 Cancel,
4674 Submit,
4676 Wait(std::time::Duration),
4678}
4679
4680fn auto_send_step(
4682 pending: Option<&AutoSendPending>,
4683 current_text: &str,
4684 idle: bool,
4685 now: std::time::Instant,
4686) -> AutoSendStep {
4687 let Some(pending) = pending else {
4688 return AutoSendStep::Idle;
4689 };
4690 if current_text != pending.text || current_text.trim().is_empty() {
4693 return AutoSendStep::Cancel;
4694 }
4695 if !idle {
4696 return AutoSendStep::Cancel;
4697 }
4698 let remaining = pending.deadline.saturating_duration_since(now);
4699 if remaining.is_zero() {
4700 AutoSendStep::Submit
4701 } else {
4702 AutoSendStep::Wait(remaining)
4703 }
4704}
4705
4706struct TuiState {
4707 current_assistant: Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
4711 tool_components: Arc<std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
4713 bash_components: Arc<std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
4717 themes_enabled: bool,
4719 hide_thinking: Arc<std::sync::Mutex<bool>>,
4721 tool_outputs_expanded: Arc<std::sync::Mutex<bool>>,
4724 show_terminal_progress: bool,
4726 status: std::sync::Mutex<RunStatus>,
4728 ext_status: rpi_extensions::ExtensionStatusMailbox,
4731 ext_status_revision: std::sync::atomic::AtomicU64,
4733 editor_text: rpi_extensions::EditorTextMailbox,
4737 auto_send: std::sync::Mutex<Option<AutoSendPending>>,
4740 last_editor_text: std::sync::Mutex<String>,
4743 programmatic_editor_write: std::sync::atomic::AtomicBool,
4748 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4752 user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
4756 pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
4760 footer: Arc<FooterComponent>,
4762 status_container: Arc<Container>,
4765 chat_container: Arc<Container>,
4767 loader: Arc<Loader>,
4769 editor: Arc<Editor>,
4772 last_assistant_text: std::sync::Mutex<String>,
4775 active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
4779 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4781 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4783 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4785 autocomplete: AutocompleteManager,
4788 autocomplete_container: Arc<Container>,
4791 autocomplete_max_visible: usize,
4793 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4795 theme_manager: Arc<ThemeManager>,
4799 tui: Option<Arc<TuiAltScreen>>,
4803 current_model_id: std::sync::Mutex<String>,
4807 show_images: std::sync::Mutex<bool>,
4811 cache_miss_notices: std::sync::Mutex<bool>,
4814 history: std::sync::Mutex<Vec<String>>,
4817 history_index: std::sync::Mutex<isize>,
4820 history_draft: std::sync::Mutex<Option<String>>,
4823 cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
4827 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4830 markdown_transformer: Arc<std::sync::Mutex<Option<MarkdownTransformer>>>,
4840 extension_session: crate::session::ExtensionSessionCell,
4842 search: Arc<AltScreenSearch>,
4844 search_bar: Arc<SearchBar>,
4846 pending_container: Arc<Container>,
4850 dequeue_hint: String,
4853 pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
4857 selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4859 selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4860}
4861
4862const HISTORY_LIMIT: usize = 100;
4865
4866const AUTO_SEND_PREFIX: &str = "Auto-send in ";
4870
4871const PAGE_SCROLL_OVERLAP: usize = 4;
4874
4875const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4879
4880fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4883 let mut modifiers = KeyModifiers::NONE;
4884 let mut key = None;
4885 for part in value.trim().to_ascii_lowercase().split('+') {
4886 match part {
4887 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4888 "shift" => modifiers |= KeyModifiers::SHIFT,
4889 "alt" | "option" => modifiers |= KeyModifiers::ALT,
4890 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4891 part if !part.is_empty() => key = Some(part.to_string()),
4892 _ => {}
4893 }
4894 }
4895 let key = key?;
4896 let code = match key.as_str() {
4897 "esc" | "escape" => KeyCode::Esc,
4898 "enter" | "return" => KeyCode::Enter,
4899 "tab" => {
4900 if modifiers.contains(KeyModifiers::SHIFT) {
4901 return Some(rpi_tui::KeyCombo::new(
4902 KeyCode::BackTab,
4903 modifiers & !KeyModifiers::SHIFT,
4904 ));
4905 }
4906 KeyCode::Tab
4907 }
4908 "backspace" | "back" => KeyCode::Backspace,
4909 "delete" | "del" => KeyCode::Delete,
4910 "up" | "arrowup" => KeyCode::Up,
4911 "down" | "arrowdown" => KeyCode::Down,
4912 "left" | "arrowleft" => KeyCode::Left,
4913 "right" | "arrowright" => KeyCode::Right,
4914 "home" => KeyCode::Home,
4915 "end" => KeyCode::End,
4916 "pageup" | "page-up" => KeyCode::PageUp,
4917 "pagedown" | "page-down" => KeyCode::PageDown,
4918 "space" => KeyCode::Char(' '),
4919 "f1" => KeyCode::F(1),
4920 "f2" => KeyCode::F(2),
4921 "f3" => KeyCode::F(3),
4922 "f4" => KeyCode::F(4),
4923 "f5" => KeyCode::F(5),
4924 "f6" => KeyCode::F(6),
4925 "f7" => KeyCode::F(7),
4926 "f8" => KeyCode::F(8),
4927 "f9" => KeyCode::F(9),
4928 "f10" => KeyCode::F(10),
4929 "f11" => KeyCode::F(11),
4930 "f12" => KeyCode::F(12),
4931 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4932 _ => return None,
4933 };
4934 Some(rpi_tui::KeyCombo::new(code, modifiers))
4935}
4936
4937fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4938 let mut bindings = rpi_tui::Keybindings::new();
4939 let settings = crate::settings::load_settings().unwrap_or_default();
4940 let Some(overrides) = settings.keybindings else {
4941 rpi_tui::set_keybindings(bindings.clone());
4942 return Arc::new(bindings);
4943 };
4944 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4945 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4946 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4947 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4948 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4949 (
4950 "app.model.cycleForward",
4951 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4952 ),
4953 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4954 (
4955 "app.thinking.toggle",
4956 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4957 ),
4958 (
4959 "app.editor.external",
4960 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4961 ),
4962 (
4963 "app.thinking.cycle",
4964 rpi_tui::keybindings::keys::THINKING_CYCLE,
4965 ),
4966 (
4967 "app.clipboard.pasteImage",
4968 rpi_tui::keybindings::keys::PASTE_IMAGE,
4969 ),
4970 ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4971 ];
4972 for (name, id) in known {
4973 let Some(value) = overrides.get(*name) else {
4974 continue;
4975 };
4976 let values: Vec<String> = match value {
4977 serde_json::Value::String(value) => vec![value.clone()],
4978 serde_json::Value::Array(values) => values
4979 .iter()
4980 .filter_map(|v| v.as_str().map(str::to_string))
4981 .collect(),
4982 serde_json::Value::Null => Vec::new(),
4983 _ => continue,
4984 };
4985 let combos: Vec<_> = values
4986 .iter()
4987 .filter_map(|value| parse_configured_key(value))
4988 .collect();
4989 if values.is_empty() || !combos.is_empty() {
4990 bindings.set(id, combos);
4991 }
4992 }
4993 rpi_tui::set_keybindings(bindings.clone());
4994 Arc::new(bindings)
4995}
4996
4997fn keybinding_matches(
4998 bindings: &rpi_tui::Keybindings,
4999 event: &crossterm::event::KeyEvent,
5000 id: rpi_tui::KeybindingId,
5001) -> bool {
5002 if bindings.matches(event, id) {
5003 return true;
5004 }
5005 if event.code == KeyCode::BackTab {
5008 let normalized =
5009 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
5010 bindings.matches(&normalized, id)
5011 } else {
5012 false
5013 }
5014}
5015
5016fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
5020 let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
5021 if keys.is_empty() {
5022 return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
5023 }
5024 keys.iter()
5025 .map(|combo| combo.display())
5026 .collect::<Vec<_>>()
5027 .join("/")
5028}
5029
5030fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
5034 last.is_some_and(|previous| {
5035 now.duration_since(previous) <= std::time::Duration::from_millis(500)
5036 })
5037}
5038
5039#[derive(Debug, PartialEq, Eq)]
5041enum CtrlCAction {
5042 Clear,
5044 Exit,
5046}
5047
5048fn ctrl_c_action(last_sigint: Option<std::time::Instant>, now: std::time::Instant) -> CtrlCAction {
5051 if double_escape_trigger(last_sigint, now) {
5052 CtrlCAction::Exit
5053 } else {
5054 CtrlCAction::Clear
5055 }
5056}
5057
5058const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
5062
5063const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
5066
5067fn enter_is_paste_burst(
5088 last_text_key_at: Option<std::time::Instant>,
5089 now: std::time::Instant,
5090 more_queued: bool,
5091) -> bool {
5092 if more_queued {
5093 return true;
5094 }
5095 last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
5096}
5097
5098fn probe_paste_input(
5109 pending: Option<Event>,
5110 mut next_event: impl FnMut() -> Option<Event>,
5111) -> (bool, Option<Event>) {
5112 let mut pending = pending;
5113 let mut more_queued = pending.is_some();
5114 while !more_queued {
5115 match next_event() {
5116 Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
5117 Some(ev) => {
5118 pending = Some(ev);
5119 more_queued = true;
5120 }
5121 None => break,
5122 }
5123 }
5124 (more_queued, pending)
5125}
5126
5127fn gap_ms(last_text_key_at: Option<std::time::Instant>) -> String {
5130 match last_text_key_at {
5131 Some(previous) => std::time::Instant::now()
5132 .duration_since(previous)
5133 .as_millis()
5134 .to_string(),
5135 None => "-".to_string(),
5136 }
5137}
5138
5139fn transcript_page_size(viewport_height: usize) -> i32 {
5140 viewport_height
5141 .saturating_sub(PAGE_SCROLL_OVERLAP)
5142 .max(1)
5143 .min(i32::MAX as usize) as i32
5144}
5145
5146fn should_dispatch_key(kind: KeyEventKind) -> bool {
5147 kind != KeyEventKind::Release
5148}
5149
5150fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
5160 tui.stop(Default::default());
5164 eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
5165 std::process::exit(130);
5166}
5167
5168#[cfg(unix)]
5172fn handle_suspend(tui: &Arc<TuiAltScreen>) {
5173 tui.suspend();
5174 let _ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
5177 tui.resume();
5178}
5179
5180#[cfg(not(unix))]
5183fn handle_suspend(_tui: &Arc<TuiAltScreen>) {}
5184
5185fn format_tokens(n: i64) -> String {
5187 if n >= 1_000_000 {
5188 format!("{:.1}M", n as f64 / 1_000_000.0)
5189 } else if n >= 1_000 {
5190 format!("{:.1}K", n as f64 / 1_000.0)
5191 } else {
5192 n.to_string()
5193 }
5194}
5195
5196fn push_history(state: &Arc<TuiState>, text: &str) {
5200 let trimmed = text.trim().to_string();
5201 if trimmed.is_empty() {
5202 return;
5203 }
5204 let mut history = state.history.lock().unwrap();
5205 if history.first() == Some(&trimmed) {
5206 return;
5207 }
5208 history.insert(0, trimmed);
5209 history.truncate(HISTORY_LIMIT);
5210 *state.history_index.lock().unwrap() = -1;
5211 *state.history_draft.lock().unwrap() = None;
5212}
5213
5214fn submit_draft(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>, text: &str) {
5221 if text.trim().is_empty() || !state.try_start_working() {
5222 return;
5223 }
5224 add_user_message(&state.chat_container, text);
5225 if let Some(tui) = &state.tui {
5226 if let Some(scroll) = tui.get_primary_scroll_view() {
5227 scroll.scroll_to_end();
5228 }
5229 tui.request_render(false);
5230 }
5231 push_history(state, text);
5232 let _ = tx.send(TuiMessage::UserInput(text.to_string()));
5233}
5234
5235fn poll_auto_send(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>) -> bool {
5244 let pending = state.auto_send.lock().unwrap().clone();
5247 let step = auto_send_step(
5248 pending.as_ref(),
5249 &state.editor.get_text(),
5250 *state.status.lock().unwrap() == RunStatus::Idle,
5251 std::time::Instant::now(),
5252 );
5253
5254 match step {
5255 AutoSendStep::Idle => false,
5256 AutoSendStep::Cancel => {
5257 state.clear_auto_send();
5258 true
5259 }
5260 AutoSendStep::Submit => {
5261 let text = state.editor.get_text();
5262 state.clear_auto_send();
5263 state.clear_editor_programmatically();
5264 submit_draft(state, tx, &text);
5265 true
5266 }
5267 AutoSendStep::Wait(remaining) => {
5268 let secs = remaining.as_millis() as f64 / 1000.0;
5269 let label = format!("{AUTO_SEND_PREFIX}{secs:.1}s · any key to edit");
5270 if state.footer.get_status() != label {
5271 state.footer.set_status(&label);
5272 true
5273 } else {
5274 false
5275 }
5276 }
5277 }
5278}
5279
5280fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
5285 let history = state.history.lock().unwrap();
5286 if history.is_empty() {
5287 return;
5288 }
5289 let mut index = state.history_index.lock().unwrap();
5290 let new_index = *index - direction as isize;
5291 if new_index < -1 || new_index >= history.len() as isize {
5292 return;
5293 }
5294 if *index == -1 && new_index >= 0 {
5295 *state.history_draft.lock().unwrap() = Some(editor.get_text());
5297 }
5298 *index = new_index;
5299 if new_index == -1 {
5300 let draft = state.history_draft.lock().unwrap().take();
5302 match draft {
5303 Some(d) => {
5304 let len = d.len();
5305 editor.set_text(&d);
5306 editor.set_cursor(0, len);
5307 }
5308 None => editor.set_text(""),
5309 }
5310 } else {
5311 let text = history[new_index as usize].clone();
5312 let len = text.len();
5313 editor.set_text(&text);
5314 editor.set_cursor(0, len);
5315 }
5316}
5317
5318impl TuiState {
5319 fn begin_js_preparation(&self) -> CancellationToken {
5320 let cancellation = CancellationToken::new();
5321 if let Some(previous) = self
5322 .js_preparation_cancel
5323 .lock()
5324 .unwrap()
5325 .replace(cancellation.clone())
5326 {
5327 previous.cancel();
5328 }
5329 cancellation
5330 }
5331
5332 fn finish_js_preparation(&self) {
5333 self.js_preparation_cancel.lock().unwrap().take();
5334 }
5335
5336 fn cancel_js_preparation(&self) -> bool {
5337 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
5338 if let Some(cancellation) = cancellation {
5339 cancellation.cancel();
5340 true
5341 } else {
5342 false
5343 }
5344 }
5345
5346 fn user_bash_running(&self) -> bool {
5348 self.user_bash_cancel.lock().unwrap().is_some()
5349 }
5350
5351 fn begin_user_bash(&self) -> CancellationToken {
5354 let cancellation = CancellationToken::new();
5355 if let Some(previous) = self
5356 .user_bash_cancel
5357 .lock()
5358 .unwrap()
5359 .replace(cancellation.clone())
5360 {
5361 previous.cancel();
5362 }
5363 cancellation
5364 }
5365
5366 fn finish_user_bash(&self) {
5369 self.user_bash_cancel.lock().unwrap().take();
5370 }
5371
5372 fn cancel_user_bash(&self) -> bool {
5376 let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
5377 if let Some(cancellation) = cancellation {
5378 cancellation.cancel();
5379 true
5380 } else {
5381 false
5382 }
5383 }
5384
5385 fn set_status(&self, status: RunStatus) {
5386 *self.status.lock().unwrap() = status;
5387 self.apply_status(status);
5388 }
5389
5390 fn pending_container(&self) -> &Arc<Container> {
5393 &self.pending_container
5394 }
5395
5396 fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
5402 {
5403 let mut current = self.pending_snapshot.lock().unwrap();
5404 if *current == messages {
5405 return;
5406 }
5407 *current = messages.clone();
5408 }
5409 let container = &self.pending_container;
5410 container.clear();
5411 if messages.is_empty() {
5412 return;
5413 }
5414 let colors = current_theme().colors;
5415 container.add_child(Arc::new(Spacer::new(1)));
5416 for text in &messages.steering {
5417 container.add_child(Arc::new(Text::new(
5418 colors.muted.fg(&format!("Steering: {text}")),
5419 1,
5420 0,
5421 )));
5422 }
5423 for text in &messages.follow_up {
5424 container.add_child(Arc::new(Text::new(
5425 colors.muted.fg(&format!("Follow-up: {text}")),
5426 1,
5427 0,
5428 )));
5429 }
5430 container.add_child(Arc::new(Text::new(
5431 colors.muted.fg(&format!(
5432 "↳ {} to edit all queued messages",
5433 self.dequeue_hint
5434 )),
5435 1,
5436 0,
5437 )));
5438 }
5439
5440 fn try_start_working(&self) -> bool {
5445 let mut status = self.status.lock().unwrap();
5446 if *status != RunStatus::Idle {
5447 return false;
5448 }
5449 *status = RunStatus::Working;
5450 drop(status);
5451 self.apply_status(RunStatus::Working);
5452 true
5453 }
5454
5455 fn apply_status(&self, status: RunStatus) {
5456 match status {
5457 RunStatus::Working => {
5458 self.footer.set_status("");
5462 if let Some(tui) = &self.tui {
5465 tui.set_title(&crate::brand::window_title("⟳"));
5466 }
5467 self.status_container.clear();
5468 if self.show_terminal_progress {
5471 self.editor.set_working(Some(WorkingState {
5472 frame: 0,
5473 started_at: std::time::Instant::now(),
5474 message: "Working…".to_string(),
5475 }));
5476 } else {
5477 self.editor.set_working(None);
5478 }
5479 }
5480 RunStatus::Aborting => {
5481 self.footer.set_status("Aborting…");
5482 self.loader.stop();
5485 self.status_container.clear();
5486 self.editor.set_working(None);
5487 }
5488 RunStatus::Idle => {
5489 self.footer.set_status("");
5490 if let Some(tui) = &self.tui {
5491 tui.set_title(&crate::brand::window_title(""));
5492 }
5493 self.loader.stop();
5494 self.status_container.clear();
5495 self.editor.set_working(None);
5496 }
5497 }
5498 }
5499
5500 fn sync_extension_status(&self) -> bool {
5504 let revision = self.ext_status.revision();
5505 if self
5506 .ext_status_revision
5507 .load(std::sync::atomic::Ordering::SeqCst)
5508 == revision
5509 {
5510 return false;
5511 }
5512 self.ext_status_revision
5513 .store(revision, std::sync::atomic::Ordering::SeqCst);
5514 self.footer.set_extension_status(&self.ext_status.text());
5515 true
5516 }
5517
5518 fn drain_editor_text(&self) -> bool {
5529 let mut applied = false;
5530 while let Some(edit) = self.editor_text.take_pending() {
5531 let current = self.editor.get_text();
5532 let text = match edit.mode {
5533 rpi_extensions::EditorTextMode::Replace => edit.text,
5534 rpi_extensions::EditorTextMode::Append => {
5535 if current.is_empty() {
5536 edit.text
5537 } else {
5538 let mut joined = current.trim_end().to_string();
5541 joined.push(' ');
5542 joined.push_str(&edit.text);
5543 joined
5544 }
5545 }
5546 };
5547 set_editor_text_caret_at_end(&self.editor, &text);
5548 self.programmatic_editor_write
5550 .store(true, std::sync::atomic::Ordering::SeqCst);
5551 let pending = edit
5552 .auto_send_ms
5553 .filter(|ms| *ms > 0)
5554 .map(|ms| AutoSendPending {
5555 deadline: std::time::Instant::now() + std::time::Duration::from_millis(ms),
5556 text: text.clone(),
5557 });
5558 *self.auto_send.lock().unwrap() = pending;
5559 applied = true;
5560 }
5561 applied
5562 }
5563
5564 fn clear_editor_programmatically(&self) {
5574 self.programmatic_editor_write
5575 .store(true, std::sync::atomic::Ordering::SeqCst);
5576 self.editor.clear();
5577 }
5578
5579 fn clear_auto_send(&self) {
5582 if self.auto_send.lock().unwrap().take().is_some()
5583 && self.footer.get_status().starts_with(AUTO_SEND_PREFIX)
5584 {
5585 self.footer.set_status("");
5586 }
5587 }
5588
5589 fn cancel_auto_send_on_key(&self) -> bool {
5593 let had = self.auto_send.lock().unwrap().is_some();
5594 if had {
5595 self.clear_auto_send();
5596 }
5597 had
5598 }
5599
5600 fn sync_editor_change(&self) {
5617 use rpi_plugin_sdk::EventTag;
5618
5619 let Some(payload) = self.take_editor_change() else {
5620 return;
5621 };
5622 let Ok(session) = self.extension_session.lock() else {
5625 return;
5626 };
5627 let Some(snapshot) = session.snapshot_arc() else {
5628 return;
5629 };
5630 if snapshot.handlers_for(EventTag::EditorChange).is_empty() {
5631 return;
5632 }
5633 rpi_extensions::dispatch_data_event(
5634 &snapshot,
5635 EventTag::EditorChange,
5636 &payload.to_string(),
5637 );
5638 }
5639
5640 fn take_editor_change(&self) -> Option<serde_json::Value> {
5649 let text = self.editor.get_text();
5650 {
5651 let mut last = self.last_editor_text.lock().unwrap();
5652 if *last == text {
5653 return None;
5654 }
5655 *last = text.clone();
5656 }
5657 let programmatic = self
5658 .programmatic_editor_write
5659 .swap(false, std::sync::atomic::Ordering::SeqCst);
5660 Some(serde_json::json!({
5661 "chars": text.chars().count(),
5662 "empty": text.is_empty(),
5663 "source": if programmatic {
5664 "extension"
5666 } else {
5667 "user"
5668 },
5669 }))
5670 }
5671
5672 fn sync_working_loader_with_bash(&self) {
5676 if *self.status.lock().unwrap() != RunStatus::Working {
5677 return;
5678 }
5679
5680 self.status_container.clear();
5685 }
5686
5687 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
5688 *self.status.lock().unwrap() = RunStatus::Working;
5689 self.footer.set_status("");
5690 if let Some(tui) = &self.tui {
5691 tui.set_title(&crate::brand::window_title("↻"));
5692 }
5693 self.loader.stop();
5694 self.status_container.clear();
5695 self.status_container
5696 .add_child(Arc::new(StatusIndicator::retry(
5697 attempt,
5698 max_retries,
5699 std::time::Duration::from_millis(delay_ms),
5700 )));
5701 }
5702
5703 fn selector_open(&self) -> bool {
5705 self.active_selector.lock().unwrap().is_some()
5706 }
5707
5708 fn extension_editor_open(&self) -> bool {
5709 self.active_extension_editor.lock().unwrap().is_some()
5710 }
5711
5712 fn extension_input_open(&self) -> bool {
5713 self.active_extension_input.lock().unwrap().is_some()
5714 }
5715
5716 fn extension_dialog_open(&self) -> bool {
5717 self.extension_editor_open() || self.extension_input_open()
5718 }
5719
5720 fn set_hide_thinking(&self, hide: bool) {
5721 *self.hide_thinking.lock().unwrap() = hide;
5722 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5723 comp.set_hide_thinking(hide);
5724 }
5725 }
5726
5727 fn hide_thinking(&self) -> bool {
5728 *self.hide_thinking.lock().unwrap()
5729 }
5730
5731 fn toggle_thinking(&self) -> bool {
5732 let next = !self.hide_thinking();
5733 self.set_hide_thinking(next);
5734 next
5735 }
5736
5737 fn toggle_tool_outputs(&self) -> bool {
5738 let next = !*self.tool_outputs_expanded.lock().unwrap();
5739 *self.tool_outputs_expanded.lock().unwrap() = next;
5740 for comp in self.tool_components.lock().unwrap().values() {
5741 comp.set_expanded(next);
5742 }
5743 for comp in self.bash_components.lock().unwrap().values() {
5744 comp.set_expanded(next);
5745 }
5746 next
5747 }
5748
5749 fn current_model_id(&self) -> String {
5751 self.current_model_id.lock().unwrap().clone()
5752 }
5753
5754 fn set_current_model(&self, model: &rpi_ai::Model) {
5757 *self.current_model_id.lock().unwrap() = model.id.clone();
5758 self.footer.set_model(&short_model_name(&model.id));
5759 self.footer.set_context_window(model.context_window as i64);
5762 }
5763
5764 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
5768 self.markdown_transformer.lock().unwrap().clone()
5769 }
5770
5771 fn transcript_view(&self) -> crate::transcript_view::TranscriptView {
5777 crate::transcript_view::TranscriptView::with_maps(
5778 self.chat_container.clone(),
5779 self.current_assistant.clone(),
5780 self.tool_components.clone(),
5781 self.bash_components.clone(),
5782 self.hide_thinking.clone(),
5783 self.tool_outputs_expanded.clone(),
5784 self.markdown_transformer.clone(),
5785 )
5786 }
5787
5788 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
5797 *self.markdown_transformer.lock().unwrap() = transformer.clone();
5798 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
5799 comp.set_markdown_transformer(transformer);
5800 }
5801 }
5802
5803 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
5804 self.pending_images.lock().unwrap().push(image);
5805 }
5806
5807 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
5808 std::mem::take(&mut *self.pending_images.lock().unwrap())
5809 }
5810}
5811
5812#[derive(Debug, PartialEq)]
5817struct TuiStartupSettings {
5818 editor_padding_x: usize,
5819 autocomplete_max_visible: usize,
5820 hide_thinking: bool,
5821 quiet_startup: bool,
5822 show_terminal_progress: bool,
5823 show_images: bool,
5825 cache_miss_notices: bool,
5827}
5828
5829fn preferred_project_setting<T>(
5830 project_settings: &[crate::settings::Settings],
5831 field: impl Fn(&crate::settings::Settings) -> Option<T>,
5832) -> Option<T> {
5833 project_settings.iter().find_map(field)
5834}
5835
5836fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
5837 verbose || !quiet_startup
5838}
5839
5840fn git_only_update_resources(
5841 mut resources: crate::packages::PackageResources,
5842) -> crate::packages::PackageResources {
5843 resources
5844 .packages
5845 .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
5846 resources
5847}
5848
5849fn resolve_tui_startup_settings(
5853 global: &crate::settings::Settings,
5854 project_settings: &[crate::settings::Settings],
5855 project_trusted: bool,
5856) -> TuiStartupSettings {
5857 let project_settings = if project_trusted {
5858 project_settings
5859 } else {
5860 &[]
5861 };
5862 let editor_padding_x =
5863 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
5864 .or(global.editor_padding_x)
5865 .unwrap_or(1)
5866 .min(16);
5867 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
5868 settings.autocomplete_max_visible
5869 })
5870 .or(global.autocomplete_max_visible)
5871 .unwrap_or(5)
5872 .clamp(1, 20);
5873 let hide_thinking =
5874 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
5875 .or(global.hide_thinking_block)
5876 .unwrap_or(false);
5877 let quiet_startup =
5878 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
5879 .or(global.quiet_startup)
5880 .unwrap_or(false);
5881 let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
5882 settings.show_terminal_progress()
5883 })
5884 .or_else(|| global.show_terminal_progress())
5885 .unwrap_or(true);
5886 let show_images =
5887 preferred_project_setting(project_settings, |settings| settings.show_images())
5888 .or_else(|| global.show_images())
5889 .unwrap_or(true);
5890 let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5891 settings.show_cache_miss_notices
5892 })
5893 .or(global.show_cache_miss_notices)
5894 .unwrap_or(false);
5895
5896 TuiStartupSettings {
5897 editor_padding_x,
5898 autocomplete_max_visible,
5899 hide_thinking,
5900 quiet_startup,
5901 show_terminal_progress,
5902 show_images,
5903 cache_miss_notices,
5904 }
5905}
5906
5907pub async fn interactive_tui(
5921 harness: &AgentHarness,
5922 event_rx: Option<broadcast::Receiver<AgentEvent>>,
5923 args: &Args,
5924 model_catalog: Vec<rpi_ai::Model>,
5925 initial: Option<String>,
5926 extra_messages: &[String],
5927 initial_images: Vec<rpi_ai::types::ImageContent>,
5928 theme: Option<&str>,
5929 no_themes: bool,
5930 reload_context: &crate::session::ReloadContext,
5931) -> i32 {
5932 let lane: Arc<dyn AgentLane> = harness.lane("main");
5933 let cwd = reload_context.cwd.clone();
5937 let saved_settings = crate::settings::load_settings().unwrap_or_default();
5938 let project_trusted = reload_context.project_trusted;
5939 let project_settings = if project_trusted {
5940 crate::settings::load_project_settings(&cwd)
5941 } else {
5942 Vec::new()
5943 };
5944 let tui_settings =
5945 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5946 let editor_padding_x = tui_settings.editor_padding_x;
5947 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5948 let mut hide_thinking = tui_settings.hide_thinking;
5949 let show_terminal_progress = tui_settings.show_terminal_progress;
5950 let show_images = tui_settings.show_images;
5951 let cache_miss_notices = tui_settings.cache_miss_notices;
5952 let quiet_startup = tui_settings.quiet_startup;
5953 let update_checks_enabled =
5954 !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
5955
5956 let session_values = rpi_harness::session::SessionValues::load(harness.session())
5963 .await
5964 .unwrap_or_default();
5965 if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5966 hide_thinking = persisted;
5967 }
5968
5969 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5973 let model_name = short_model_name(&lane_model_id);
5974
5975 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5979 if active_tool_names.is_empty() {
5983 active_tool_names = harness
5984 .get_tools()
5985 .await
5986 .unwrap_or_default()
5987 .into_iter()
5988 .map(|tool| tool.tool.schema().name.clone())
5989 .collect();
5990 }
5991 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5992 let skill_names: Vec<String> = resources_snapshot
5993 .skills
5994 .as_deref()
5995 .unwrap_or(&[])
5996 .iter()
5997 .map(|skill| skill.name.clone())
5998 .collect();
5999
6000 let package_resources = reload_context.package_resources.clone();
6002
6003 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
6005
6006 let theme_manager = Arc::new(ThemeManager::new());
6010 if let Some(preset) = match theme {
6011 Some("light") => Some(ThemePreset::Light),
6012 Some("monochrome") => Some(ThemePreset::Monochrome),
6013 Some("dark") => Some(ThemePreset::Dark),
6014 _ => None,
6015 } {
6016 apply_theme_preset(preset);
6017 theme_manager.apply_preset(preset);
6018 } else if !no_themes {
6019 if let Some(name) = theme {
6020 match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
6021 Ok(custom) => {
6022 rpi_tui::global_theme_manager().set(custom.clone());
6023 theme_manager.set(custom);
6024 }
6025 Err(error) => {
6026 eprintln!("warning: could not load package theme `{name}`: {error}");
6027 }
6028 }
6029 }
6030 }
6031
6032 let terminal = Box::new(ProcessTerminal::new());
6034 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
6035 let js_dialog_bridge = Arc::new(JsDialogBridge::default());
6036 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
6041 ask_user_bridge.attach();
6042 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
6043
6044 if let Some(js) = &reload_context.js_extension_session {
6045 if let Err(error) = js.install_ui_runtime(tui.clone()) {
6046 eprintln!("warning: could not enable JS custom UI bridge: {error}");
6047 } else if let Some(js_active) = js.active_tools() {
6048 let js_names = js.tool_names();
6052 let mut active = lane.get_active_tools().await.unwrap_or_default();
6053 active.retain(|name| {
6054 crate::session::tool_name_allowed(name, args)
6055 && !js_names.iter().any(|js_name| js_name == name)
6056 });
6057 active.extend(js_active.into_iter().filter(|name| {
6058 js_names.iter().any(|js_name| js_name == name)
6059 && crate::session::tool_name_allowed(name, args)
6060 }));
6061 active = crate::session::filter_active_tool_names(active, args);
6062 let _ = lane.set_active_tools(active).await;
6063 active_tool_names = lane.get_active_tools().await.unwrap_or_default();
6064 }
6065 }
6066
6067 let chat_container = Arc::new(Container::new());
6068 if let Some(js) = &reload_context.js_extension_session {
6069 let chat_notify = chat_container.clone();
6073 let tui_notify = tui.clone();
6074 if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
6075 if action != "ui.notify" {
6076 return Err(format!("unsupported capability: {action}"));
6077 }
6078 let message = args
6079 .get("message")
6080 .and_then(serde_json::Value::as_str)
6081 .unwrap_or_default();
6082 if !message.is_empty() {
6083 if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
6084 add_error_message(&chat_notify, message);
6085 } else {
6086 add_note_message(&chat_notify, message);
6087 }
6088 tui_notify.request_render(false);
6089 }
6090 Ok(serde_json::json!(true))
6091 })) {
6092 if args.verbose {
6093 eprintln!("warning: could not enable JS notification bridge: {error}");
6094 }
6095 }
6096 }
6097 if should_show_startup_listing(args.verbose, quiet_startup) {
6098 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
6099 }
6100
6101 crate::extras::maybe_first_time_setup(&chat_container);
6107
6108 let initial_transformer = build_markdown_transformer(
6113 reload_context
6114 .extension_session
6115 .lock()
6116 .unwrap()
6117 .snapshot_arc(),
6118 );
6119 if launch_restores_history(args) {
6123 render_session_history(
6124 &harness,
6125 &chat_container,
6126 initial_transformer.clone(),
6127 Some(reload_context.extension_session.clone()),
6128 )
6129 .await;
6130 }
6131
6132 let document_container = Arc::new(Container::new());
6135 document_container.add_child(chat_container.clone());
6136 document_container.add_child(Arc::new(Spacer::new(1)));
6138
6139 let scroll_view = Arc::new(ScrollView::new(
6140 document_container.clone(),
6141 ScrollViewOptions {
6142 follow: FollowMode::End,
6143 primary: true,
6144 overscroll: OverscrollMode::Chain,
6145 scrollbar: ScrollbarMode::Hidden,
6149 ..Default::default()
6150 },
6151 ));
6152
6153 let keybindings = configured_keybindings();
6158 let editor = Arc::new(Editor::new(
6159 EditorOptions {
6160 padding_x: editor_padding_x,
6161 autocomplete_max_visible,
6162 ..Default::default()
6163 },
6164 EditorStyle::default(),
6165 keybindings.clone(),
6166 ));
6167
6168 {
6173 let editor_for_change = editor.clone();
6174 editor.on_change(Arc::new(move |text: &str| {
6175 let colors = current_theme().colors;
6176 let is_bash = text.trim_start().starts_with('!');
6177 editor_for_change.set_border_color(if is_bash {
6178 Some(colors.bash_mode)
6179 } else {
6180 None
6181 });
6182 }));
6183 }
6184
6185 let footer = Arc::new(FooterComponent::new());
6187 footer.set_model(&model_name);
6188 footer.set_cwd(&cwd.to_string_lossy());
6189 footer.set_git_branch(git_branch_for(&cwd).as_deref());
6190 if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
6191 footer.set_context_window(m.context_window as i64);
6192 }
6193 footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Clear/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+P: Cycle | Ctrl+T: Expand tool | /help");
6194
6195 let _branch_watcher = find_git_head_path(&cwd).map(|head| {
6198 let footer_ref = footer.clone();
6199 let cwd_ref = cwd.clone();
6200 crate::fs_watch::watch_path(head, move || {
6201 footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
6202 })
6203 });
6204
6205 let status_container = Arc::new(Container::new());
6206 let loader = Arc::new(Loader::with_text("Working…"));
6207
6208 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
6222 .prompt_templates
6223 .clone()
6224 .unwrap_or_default()
6225 .iter()
6226 .map(|t| SlashCommandEntry {
6227 name: format!("/{}", t.name),
6228 description: t
6229 .description
6230 .clone()
6231 .unwrap_or_else(|| "Expand prompt template".to_string()),
6232 })
6233 .collect();
6234 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
6235 Arc::new(resources_snapshot);
6236 let mut command_registry = build_builtin_registry();
6242 register_extension_commands(
6243 &mut command_registry,
6244 reload_context.extension_session.clone(),
6245 );
6246 register_js_extension_commands(
6247 &mut command_registry,
6248 reload_context.js_extension_session.clone(),
6249 );
6250 let registry = Arc::new(command_registry);
6251 let mut all_slash_commands = registry.visible_entries();
6252 all_slash_commands.extend(template_slash_commands);
6253 let autocomplete = AutocompleteManager::new();
6254 {
6255 let mut combined = CombinedAutocompleteProvider::new();
6256 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
6257 all_slash_commands,
6258 )));
6259 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
6260 cwd.clone(),
6261 )));
6262 autocomplete.set_provider(Arc::new(combined));
6263 }
6264 let autocomplete_container = Arc::new(Container::new());
6265
6266 let tool_outputs_expanded = session_values
6267 .get_as::<bool>("display.tool_outputs_expanded")
6268 .unwrap_or(false);
6269 let state = Arc::new(TuiState {
6270 current_assistant: Arc::new(std::sync::Mutex::new(None)),
6271 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
6272 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
6273 themes_enabled: !no_themes,
6274 hide_thinking: Arc::new(std::sync::Mutex::new(hide_thinking)),
6275 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(tool_outputs_expanded)),
6276 show_terminal_progress,
6277 status: std::sync::Mutex::new(RunStatus::Idle),
6278 ext_status: reload_context.ext_status.clone(),
6279 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
6280 editor_text: reload_context.editor_text.clone(),
6281 auto_send: std::sync::Mutex::new(None),
6282 last_editor_text: std::sync::Mutex::new(String::new()),
6283 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
6284 js_preparation_cancel: std::sync::Mutex::new(None),
6285 user_bash_cancel: std::sync::Mutex::new(None),
6286 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
6287 footer: footer.clone(),
6288 status_container: status_container.clone(),
6289 chat_container: chat_container.clone(),
6290 loader: loader.clone(),
6291 editor: editor.clone(),
6292 last_assistant_text: std::sync::Mutex::new(String::new()),
6293 active_selector: std::sync::Mutex::new(None),
6294 active_extension_editor: std::sync::Mutex::new(None),
6295 active_extension_input: std::sync::Mutex::new(None),
6296 active_extension_cancel: std::sync::Mutex::new(None),
6297 autocomplete,
6298 autocomplete_container: autocomplete_container.clone(),
6299 autocomplete_max_visible,
6300 pending_images: std::sync::Mutex::new(Vec::new()),
6301 theme_manager,
6302 tui: Some(tui.clone()),
6303 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
6304 show_images: std::sync::Mutex::new(show_images),
6305 cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
6306 history: std::sync::Mutex::new(Vec::new()),
6307 history_index: std::sync::Mutex::new(-1),
6308 history_draft: std::sync::Mutex::new(None),
6309 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
6310 scoped_edit: std::sync::Mutex::new(None),
6311 markdown_transformer: Arc::new(std::sync::Mutex::new(initial_transformer)),
6312 extension_session: reload_context.extension_session.clone(),
6313 search: Arc::new(AltScreenSearch::new()),
6314 search_bar: Arc::new(SearchBar::new()),
6315 pending_container: Arc::new(Container::new()),
6316 dequeue_hint: pending_dequeue_hint(&keybindings),
6317 pending_snapshot: std::sync::Mutex::new(
6318 rpi_harness::agent_harness::QueuedMessages::default(),
6319 ),
6320 selection_start: std::sync::Mutex::new(None),
6321 selection_end: std::sync::Mutex::new(None),
6322 });
6323
6324 let model_catalog_arc = Arc::new(model_catalog.clone());
6327 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
6328
6329 let editor_container = Arc::new(Container::new());
6340 editor_container.add_child(editor.clone());
6341
6342 let dock = Arc::new(VStack::from_children(vec![
6343 StackChild::Entry(StackEntry::new(state.pending_container().clone())),
6344 StackChild::Entry(StackEntry::new(status_container.clone())),
6345 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
6346 StackChild::Entry(
6352 StackEntry::new(editor_container.clone())
6353 .shrink(0)
6354 .min_size(3),
6355 ),
6356 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
6357 StackChild::Entry(StackEntry::new(footer.clone())),
6358 ]));
6359
6360 let root = VStack::from_children(vec![
6361 StackChild::Entry(
6362 StackEntry::new(scroll_view.clone())
6363 .basis(0)
6364 .grow(1)
6365 .shrink(1)
6366 .min_size(1),
6367 ),
6368 StackChild::Entry(StackEntry::new(dock).shrink(1)),
6369 ]);
6370
6371 tui.set_layout_root(Some(Arc::new(root)));
6372 tui.set_focus(Some(editor.clone()));
6373 editor.set_focused(true);
6374
6375 let scoped_models = args
6391 .model
6392 .as_deref()
6393 .map(crate::agent_session::ScopedModel::parse)
6394 .into_iter()
6395 .collect();
6396 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
6397 harness.clone(),
6398 model_catalog.clone(),
6399 scoped_models,
6400 cwd.clone(),
6401 ));
6402 let ctx = CommandContext {
6403 chat: chat_container.clone(),
6404 tui: tui.clone(),
6405 tx: tx.clone(),
6406 state: state.clone(),
6407 editor: editor.clone(),
6408 editor_container: editor_container.clone(),
6409 lane: lane.clone(),
6410 model_catalog: model_catalog_arc.clone(),
6411 lane_model_id: lane_model_id.clone(),
6412 cwd: cwd.clone(),
6413 package_resources: package_resources.clone(),
6414 resources: resources_arc.clone(),
6415 reload_context: Arc::new(reload_context.clone()),
6416 session: agent_session.clone(),
6417 };
6418
6419 if let Some(js) = &reload_context.js_extension_session {
6420 let bridge = js_dialog_bridge.clone();
6421 if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
6422 bridge.handle_runtime_request(action, args)
6423 })) {
6424 eprintln!("warning: could not enable JS dialog UI bridge: {error}");
6425 }
6426 }
6427
6428 let ctx_for_cb = ctx.clone();
6429 let registry_for_cb = registry.clone();
6430 editor.on_submit(Arc::new(move |text: &str| {
6431 let text = text.trim();
6432 if text.is_empty() {
6433 return;
6434 }
6435
6436 if text.starts_with('/') {
6437 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
6438 return;
6439 }
6440
6441 if let Some((command, exclude_from_context)) = parse_user_bash(text) {
6446 if ctx_for_cb.state.user_bash_running() {
6447 add_error_message(
6448 &ctx_for_cb.chat,
6449 "A bash command is already running. Press Esc to cancel it first.",
6450 );
6451 ctx_for_cb.tui.request_render(false);
6452 return;
6453 }
6454 start_user_bash(&ctx_for_cb, command, exclude_from_context);
6455 return;
6456 }
6457
6458 let run_status = *ctx_for_cb.state.status.lock().unwrap();
6459 if run_status != RunStatus::Idle {
6460 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
6461 let lane = ctx_for_cb.lane.clone();
6462 let chat = ctx_for_cb.chat.clone();
6463 let tui = ctx_for_cb.tui.clone();
6464 let state = ctx_for_cb.state.clone();
6465 tokio::spawn(async move {
6466 if let Err(error) = lane.steer(message).await {
6476 add_error_message(&chat, &format!("Could not queue message: {error}"));
6477 tui.render_now(false);
6478 return;
6479 }
6480 refresh_pending_messages(&state, &lane).await;
6483 tui.request_render(false);
6484 });
6485 ctx_for_cb.tui.request_render(false);
6491 return;
6492 }
6493
6494 if !ctx_for_cb.state.try_start_working() {
6495 return;
6496 }
6497
6498 add_user_message(&ctx_for_cb.chat, text);
6509 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
6512 scroll.scroll_to_end();
6513 }
6514 ctx_for_cb.tui.request_render(false);
6515 push_history(&ctx_for_cb.state, text);
6518 if ctx_for_cb
6519 .tx
6520 .send(TuiMessage::UserInput(text.to_string()))
6521 .is_err()
6522 {
6523 ctx_for_cb.state.set_status(RunStatus::Idle);
6524 }
6525 }));
6526
6527 tui.start_render_scheduler();
6535 tui.start_readerless();
6536
6537 let mut update_check_handles = Vec::new();
6541 let rpi_chat = chat_container.clone();
6542 let rpi_tui = tui.clone();
6543 update_check_handles.push(tokio::spawn(async move {
6544 let report = crate::updates::check_rpi_startup().await;
6545 if !report.is_empty() {
6546 add_update_notices(&rpi_chat, &report);
6547 }
6548 rpi_tui.request_render(false);
6549 }));
6550
6551 if update_checks_enabled {
6552 let update_args = args.clone();
6553 let update_cwd = cwd.clone();
6554 let update_project_trusted = project_trusted;
6555 let chat = chat_container.clone();
6556 let tui = tui.clone();
6557 update_check_handles.push(tokio::spawn(async move {
6558 let resources = crate::session::package_resources_for_update_check(
6559 &update_args,
6560 &update_cwd,
6561 update_project_trusted,
6562 );
6563 let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
6564 {
6565 Ok(npm_command) => {
6566 crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
6567 Some(&resources),
6568 &npm_command,
6569 update_project_trusted.then_some(update_cwd.as_path()),
6570 )
6571 .await
6572 }
6573 Err(error) => {
6574 add_error_message(
6575 &chat,
6576 &format!("npm package update checks disabled: {error}"),
6577 );
6578 let git_resources = git_only_update_resources(resources);
6579 crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
6580 }
6581 };
6582 if !report.is_empty() {
6583 add_update_notices(&chat, &report);
6584 }
6585 tui.request_render(false);
6586 }));
6587 }
6588
6589 let drain_handle = if let Some(rx) = event_rx {
6591 let tui_drain = tui.clone();
6592 let state_drain = state.clone();
6593 let chat_drain = chat_container.clone();
6594 let lane_drain = lane.clone();
6595 Some(tokio::spawn(async move {
6596 drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
6597 }))
6598 } else {
6599 None
6600 };
6601
6602 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
6612 reload_context.mailbox.install(reload_sig_tx);
6613 let reload_tx = tx.clone();
6614 let reload_bridge_handle = tokio::spawn(async move {
6615 while reload_sig_rx.recv().await.is_some() {
6616 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
6617 break; }
6619 }
6620 });
6621
6622 let tui_tick = tui.clone();
6627 let state_tick = state.clone();
6628 let tx_tick = tx.clone();
6631 let tick_handle = tokio::spawn(async move {
6632 let mut interval = tokio::time::interval(std::time::Duration::from_millis(
6641 rpi_tui::loader::SPINNER_FRAME_MS,
6642 ));
6643 interval.tick().await; loop {
6645 interval.tick().await;
6646 if state_tick.sync_extension_status() {
6649 tui_tick.request_render(false);
6650 }
6651 if state_tick.drain_editor_text() {
6654 tui_tick.request_render(false);
6655 }
6656 state_tick.sync_editor_change();
6659 if poll_auto_send(&state_tick, &tx_tick) {
6661 tui_tick.request_render(false);
6662 }
6663 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
6664 if working {
6665 let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
6673 let has_live_panel = {
6674 let tools = state_tick.tool_components.lock().unwrap();
6675 transcript_has_live_panel(bash_present, &tools)
6676 };
6677 if has_live_panel {
6678 tui_tick.request_render(false);
6679 } else {
6680 tui_tick.request_render_reusing_scroll_content();
6684 }
6685 }
6686 }
6687 });
6688
6689 let running = Arc::new(std::sync::Mutex::new(true));
6691 let running_key = running.clone();
6692 let tx_for_key = tx.clone();
6693 let tui_for_key = tui.clone();
6694 let editor_for_key = editor.clone();
6695 let scroll_for_key = scroll_view.clone();
6696 let lane_for_key = lane.clone();
6697 let state_for_key = state.clone();
6698 let model_catalog_for_key = model_catalog_arc.clone();
6699 let js_for_key = reload_context.js_extension_session.clone();
6700 let js_dialog_for_key = js_dialog_bridge.clone();
6701 let ask_user_for_key = ask_user_bridge.clone();
6702 let ctx_for_key = ctx.clone();
6706 let registry_for_key = registry.clone();
6707 let keybindings_for_key = keybindings.clone();
6708 let double_escape_action = crate::settings::load_settings()
6709 .ok()
6710 .and_then(|settings| settings.double_escape_action)
6711 .unwrap_or_else(|| "tree".to_string())
6712 .to_ascii_lowercase();
6713
6714 let key_handle = tokio::task::spawn_blocking(move || {
6715 let mut last_escape_time = None;
6716 let mut last_sigint_time: Option<std::time::Instant> = None;
6719 let mut last_text_key_at: Option<std::time::Instant> = None;
6722 let mut pending_event: Option<Event> = None;
6726 if crate::key_trace::enabled() {
6727 crate::key_trace::note(&format!(
6728 "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
6729 std::process::id(),
6730 std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
6731 crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
6732 ));
6733 }
6734 loop {
6735 if !*running_key.lock().unwrap() {
6736 break;
6737 }
6738 if !state_for_key.selector_open()
6739 && !state_for_key.extension_dialog_open()
6740 && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
6741 {
6742 if let Some(request) = js_dialog_for_key.take_pending() {
6743 open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
6744 } else if let Some(request) = ask_user_for_key.take_pending() {
6745 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
6746 }
6747 }
6748 cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
6749 let ev = if let Some(ev) = pending_event.take() {
6750 ev
6751 } else {
6752 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
6755 Ok(true) => {}
6756 Ok(false) => continue,
6757 Err(_) => {
6758 state_for_key.cancel_js_preparation();
6759 let _ = tx_for_key.send(TuiMessage::Exit);
6760 break;
6761 }
6762 }
6763 let Ok(ev) = crossterm::event::read() else {
6764 state_for_key.cancel_js_preparation();
6765 let _ = tx_for_key.send(TuiMessage::Exit);
6766 break;
6767 };
6768 ev
6769 };
6770 if let Event::Resize(_cols, _rows) = ev {
6775 tui_for_key.refresh_size();
6776 if let Some(js) = &js_for_key {
6777 if js.custom_active() {
6778 let _ = js.send_custom_resize(_cols as usize, _rows as usize);
6779 tui_for_key.request_render(false);
6780 }
6781 }
6782 continue;
6783 }
6784 if let Event::Mouse(m) = ev {
6788 use crossterm::event::MouseEventKind;
6789 match m.kind {
6790 MouseEventKind::ScrollUp => {
6791 let delta = -MOUSE_WHEEL_SCROLL_LINES;
6792 if scroll_for_key.scroll_by(delta) != delta {
6793 tui_for_key.request_render_reusing_scroll_content();
6794 }
6795 }
6796 MouseEventKind::ScrollDown => {
6797 let delta = MOUSE_WHEEL_SCROLL_LINES;
6798 if scroll_for_key.scroll_by(delta) != delta {
6799 tui_for_key.request_render_reusing_scroll_content();
6800 }
6801 }
6802 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
6803 *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
6807 *state_for_key.selection_end.lock().unwrap() = None;
6808 }
6809 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
6810 if state_for_key.selection_start.lock().unwrap().is_some() {
6814 *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
6815 }
6816 }
6817 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
6818 if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
6820 let end = (m.column, m.row);
6821
6822 let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
6831 *state_for_key.selection_end.lock().unwrap() = Some(end);
6832
6833 let auto_copy = crate::settings::load_settings()
6835 .ok()
6836 .and_then(|s| s.fullscreen_copy_on_select)
6837 .unwrap_or(true);
6838
6839 if auto_copy && is_drag {
6840 if let Some(selected_text) =
6842 extract_selected_text(&state_for_key.chat_container, start, end)
6843 {
6844 if !selected_text.trim().is_empty() {
6845 let _ = copy_to_clipboard(&selected_text);
6846 }
6847 }
6848 }
6849 }
6850 *state_for_key.selection_start.lock().unwrap() = None;
6851 *state_for_key.selection_end.lock().unwrap() = None;
6852 }
6853 _ => {}
6854 }
6855 continue;
6856 }
6857 let Event::Key(key) = ev else {
6858 if let Event::Paste(text) = ev {
6859 crate::key_trace::note(&format!(
6860 "paste event ({} bytes, bracketed paste supported)",
6861 text.len()
6862 ));
6863 let candidate = text.trim().trim_matches(['\"', '\'']);
6864 let path = std::path::PathBuf::from(candidate);
6865 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
6866 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
6867 add_image_preview(&state_for_key.chat_container, &image);
6868 state_for_key.queue_image(image);
6869 add_note_message(
6870 &state_for_key.chat_container,
6871 "Dropped image attached to the next prompt.",
6872 );
6873 tui_for_key.request_render(false);
6874 continue;
6875 }
6876 }
6877 editor_for_key.handle_paste(&text);
6878 refresh_autocomplete(&state_for_key, &editor_for_key);
6879 tui_for_key.request_render_reusing_scroll_content();
6880 }
6881 continue;
6882 };
6883 crate::key_trace::key(&key, "");
6887 if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
6891 tui_for_key.request_render(false);
6892 }
6893 if dispatch_key_event(&state_for_key, &key, &editor_for_key) {
6898 continue;
6899 }
6900 if !should_dispatch_key(key.kind) {
6905 continue;
6906 }
6907
6908 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
6914 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
6915 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
6916 if prompt_abort && state_for_key.cancel_js_preparation() {
6917 state_for_key.set_status(RunStatus::Aborting);
6918 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
6919 close_extension_editor(
6920 &state_for_key,
6921 &ctx_for_key.editor_container,
6922 &editor_for_key,
6923 &tui_for_key,
6924 );
6925 }
6926 js_dialog_for_key.cancel_open_requests();
6927 tui_for_key.set_render_suspended(false);
6928 tui_for_key.request_render(false);
6929 continue;
6930 }
6931
6932 if let Some(js) = &js_for_key {
6933 if js.custom_active() {
6934 let visible = js.custom_accepts_input();
6935 let data = key_event_to_input(key);
6936 if !data.is_empty() {
6937 if visible {
6943 let _ = js.send_custom_input(&data);
6944 continue;
6945 }
6946 let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
6947 if consumed {
6951 tui_for_key.request_render_reusing_scroll_content();
6952 continue;
6953 }
6954 }
6955 }
6956 }
6957
6958 if key.modifiers == KeyModifiers::CONTROL
6961 && key.code == KeyCode::Char('c')
6962 && state_for_key.selector_open()
6963 {
6964 let selector = state_for_key
6965 .active_selector
6966 .lock()
6967 .unwrap()
6968 .clone()
6969 .expect("selector_open guaranteed Some")
6970 .0;
6971 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
6972 tui_for_key.request_render_reusing_scroll_content();
6973 continue;
6974 }
6975
6976 if state_for_key.extension_dialog_open() {
6980 let cancel = key.code == KeyCode::Esc
6981 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
6982 if cancel {
6983 if !run_extension_cancel(&state_for_key) {
6984 close_extension_editor(
6985 &state_for_key,
6986 &ctx_for_key.editor_container,
6987 &editor_for_key,
6988 &tui_for_key,
6989 );
6990 }
6991 } else if let Some(extension_editor) = state_for_key
6992 .active_extension_editor
6993 .lock()
6994 .unwrap()
6995 .clone()
6996 {
6997 extension_editor.handle_key(key);
6998 } else if let Some(extension_input) =
6999 state_for_key.active_extension_input.lock().unwrap().clone()
7000 {
7001 extension_input.handle_key(key);
7002 }
7003 tui_for_key.request_render_reusing_scroll_content();
7004 continue;
7005 }
7006
7007 if keybinding_matches(
7015 &keybindings_for_key,
7016 &key,
7017 rpi_tui::keybindings::keys::CLEAR,
7018 ) {
7019 if key.kind != KeyEventKind::Repeat {
7023 let now = std::time::Instant::now();
7024 match ctrl_c_action(last_sigint_time, now) {
7025 CtrlCAction::Exit => {
7026 last_sigint_time = None;
7027 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
7033 let _ = tx_for_key.send(TuiMessage::Exit);
7034 } else {
7035 emergency_exit(&tui_for_key);
7036 }
7037 }
7038 CtrlCAction::Clear => {
7039 editor_for_key.clear();
7040 refresh_autocomplete(&state_for_key, &editor_for_key);
7041 last_sigint_time = Some(now);
7042 tui_for_key.request_render(false);
7043 }
7044 }
7045 }
7046 continue;
7047 }
7048
7049 if !state_for_key.selector_open()
7053 && keybinding_matches(
7054 &keybindings_for_key,
7055 &key,
7056 rpi_tui::keybindings::keys::MESSAGE_COPY,
7057 )
7058 {
7059 if editor_for_key.has_selection() && editor_for_key.copy_selection() {
7060 add_note_message(&state_for_key.chat_container, "Copied!");
7061 } else {
7062 copy_last_assistant(&state_for_key, &state_for_key.chat_container);
7063 }
7064 tui_for_key.request_render(false);
7065 continue;
7066 }
7067
7068 if keybinding_matches(
7073 &keybindings_for_key,
7074 &key,
7075 rpi_tui::keybindings::keys::SUSPEND,
7076 ) {
7077 handle_suspend(&tui_for_key);
7078 continue;
7079 }
7080
7081 if state_for_key.selector_open() {
7086 if key.code == KeyCode::Esc {
7092 let (selector, _kind) = state_for_key
7093 .active_selector
7094 .lock()
7095 .unwrap()
7096 .clone()
7097 .expect("selector_open guaranteed Some");
7098 selector.handle_key(key);
7099 continue;
7100 }
7101 let (selector, _kind) = state_for_key
7102 .active_selector
7103 .lock()
7104 .unwrap()
7105 .clone()
7106 .expect("selector_open guaranteed Some");
7107 selector.handle_key(key);
7108 tui_for_key.request_render_reusing_scroll_content();
7109 continue;
7110 }
7111
7112 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
7117 if !editor_for_key.get_text().is_empty() {
7118 editor_for_key.handle_key(key);
7120 refresh_autocomplete(&state_for_key, &editor_for_key);
7121 tui_for_key.request_render_reusing_scroll_content();
7122 continue;
7123 }
7124 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
7127 let _ = tx_for_key.send(TuiMessage::Exit);
7128 } else {
7129 emergency_exit(&tui_for_key);
7130 }
7131 continue;
7132 }
7133
7134 if keybinding_matches(
7139 &keybindings_for_key,
7140 &key,
7141 rpi_tui::keybindings::keys::INTERRUPT,
7142 ) {
7143 let status = *state_for_key.status.lock().unwrap();
7144 if status == RunStatus::Working {
7145 state_for_key.set_status(RunStatus::Aborting);
7146 ask_user_for_key.cancel_all();
7147 if !run_extension_cancel(&state_for_key)
7148 && state_for_key.extension_dialog_open()
7149 {
7150 close_extension_editor(
7151 &state_for_key,
7152 &ctx_for_key.editor_container,
7153 &editor_for_key,
7154 &tui_for_key,
7155 );
7156 }
7157 abort_run_restoring_queue(
7160 lane_for_key.clone(),
7161 editor_for_key.clone(),
7162 state_for_key.clone(),
7163 tui_for_key.clone(),
7164 );
7165 continue;
7166 }
7167 if state_for_key.user_bash_running() {
7173 state_for_key.cancel_user_bash();
7174 tui_for_key.request_render(false);
7175 continue;
7176 }
7177 if status == RunStatus::Idle
7178 && editor_for_key.get_text().trim().is_empty()
7179 && double_escape_action != "none"
7180 {
7181 let now = std::time::Instant::now();
7182 if double_escape_trigger(last_escape_time, now) {
7183 last_escape_time = None;
7184 match double_escape_action.as_str() {
7185 "tree" => {
7186 let _ = tx_for_key.send(TuiMessage::OpenTree);
7187 }
7188 "fork" => {
7189 let _ = tx_for_key.send(TuiMessage::ForkSession);
7190 }
7191 _ => {}
7192 }
7193 } else {
7194 last_escape_time = Some(now);
7195 }
7196 }
7197 continue;
7198 }
7199
7200 if keybinding_matches(
7203 &keybindings_for_key,
7204 &key,
7205 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
7206 ) {
7207 launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
7208 continue;
7209 }
7210
7211 if keybinding_matches(
7214 &keybindings_for_key,
7215 &key,
7216 rpi_tui::keybindings::keys::TOOLS_EXPAND,
7217 ) {
7218 let expanded = state_for_key.toggle_tool_outputs();
7219 let session = ctx_for_key.session.clone();
7221 tokio::spawn(async move {
7222 let _ = session
7223 .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
7224 .await;
7225 });
7226 tui_for_key.request_render(false);
7227 continue;
7228 }
7229
7230 if keybinding_matches(
7232 &keybindings_for_key,
7233 &key,
7234 rpi_tui::keybindings::keys::THINKING_TOGGLE,
7235 ) {
7236 let hide = state_for_key.toggle_thinking();
7237 let session = ctx_for_key.session.clone();
7239 tokio::spawn(async move {
7240 let _ = session
7241 .set_value("display.hide_thinking", serde_json::json!(hide))
7242 .await;
7243 });
7244 tui_for_key.request_render(false);
7245 continue;
7246 }
7247
7248 if keybinding_matches(
7254 &keybindings_for_key,
7255 &key,
7256 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
7257 ) {
7258 let current = state_for_key.current_model_id();
7259 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
7262 if let Some(next) = cycle_next_model(&scope, ¤t) {
7263 state_for_key.set_current_model(&next);
7264 let lane = lane_for_key.clone();
7265 tokio::spawn(async move {
7266 let _ = lane.set_model(next).await;
7267 });
7268 tui_for_key.request_render_reusing_scroll_content();
7269 }
7270 continue;
7271 }
7272
7273 if keybinding_matches(
7276 &keybindings_for_key,
7277 &key,
7278 rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
7279 ) {
7280 let current = state_for_key.current_model_id();
7281 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
7282 if let Some(prev) = cycle_prev_model(&scope, ¤t) {
7283 state_for_key.set_current_model(&prev);
7284 let lane = lane_for_key.clone();
7285 tokio::spawn(async move {
7286 let _ = lane.set_model(prev).await;
7287 });
7288 tui_for_key.request_render_reusing_scroll_content();
7289 }
7290 continue;
7291 }
7292
7293 if keybinding_matches(
7296 &keybindings_for_key,
7297 &key,
7298 rpi_tui::keybindings::keys::THINKING_CYCLE,
7299 ) {
7300 let lane = lane_for_key.clone();
7301 let catalog = model_catalog_for_key.clone();
7302 let state = state_for_key.clone();
7303 tokio::spawn(async move {
7304 let Ok(current_model) = lane.get_model().await else {
7305 return;
7306 };
7307 let levels = catalog
7308 .iter()
7309 .find(|model| {
7310 model.provider == current_model.provider && model.id == current_model.id
7311 })
7312 .map(|model| model.supported_thinking_levels())
7313 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
7314 if levels.is_empty() {
7315 return;
7316 }
7317 let current = lane
7318 .get_thinking_level()
7319 .await
7320 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
7321 let next = levels
7322 .iter()
7323 .position(|level| *level == current)
7324 .map(|index| levels[(index + 1) % levels.len()])
7325 .unwrap_or(levels[0]);
7326 if lane.set_thinking_level(next).await.is_ok() {
7327 state
7328 .footer
7329 .set_thinking_level(Some(thinking_level_name(next)));
7330 state.tui.as_ref().map(|tui| tui.request_render(false));
7331 }
7332 });
7333 continue;
7334 }
7335
7336 if keybinding_matches(
7346 &keybindings_for_key,
7347 &key,
7348 rpi_tui::keybindings::keys::PASTE_IMAGE,
7349 ) {
7350 match read_clipboard_image() {
7351 Ok(Some(image)) => {
7352 add_image_preview(&state_for_key.chat_container, &image);
7353 state_for_key.queue_image(image);
7354 add_note_message(
7355 &state_for_key.chat_container,
7356 "Clipboard image attached to the next prompt.",
7357 );
7358 tui_for_key.request_render(false);
7359 continue;
7360 }
7361 Ok(None) | Err(_) => {}
7362 }
7363 if cfg!(windows) {
7374 if let Some(text) = read_clipboard_text() {
7375 if !text.is_empty() {
7376 editor_for_key.handle_paste(&text);
7377 refresh_autocomplete(&state_for_key, &editor_for_key);
7378 tui_for_key.request_render_reusing_scroll_content();
7379 continue;
7380 }
7381 }
7382 }
7383 }
7384
7385 if key
7387 .modifiers
7388 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
7389 && key.code == KeyCode::Char('F')
7390 {
7391 state_for_key.search.activate();
7392 state_for_key.search_bar.set_visible(true);
7393 state_for_key.search_bar.set_query("");
7394 state_for_key.search_bar.set_match_info(0, 0);
7395 tui_for_key.request_render(false);
7396 continue;
7397 }
7398
7399 if state_for_key.search.is_active() {
7401 let search_bar = state_for_key.search_bar.clone();
7402 match key.code {
7403 KeyCode::Esc => {
7404 state_for_key.search.deactivate();
7405 search_bar.set_visible(false);
7406 tui_for_key.request_render(false);
7407 continue;
7408 }
7409 KeyCode::Enter => {
7410 if key.modifiers.contains(KeyModifiers::SHIFT) {
7411 state_for_key.search.previous_match();
7412 } else {
7413 state_for_key.search.next_match();
7414 }
7415 let match_index = state_for_key.search.get_match_index();
7417 let match_count = state_for_key.search.get_match_count();
7418 search_bar.set_match_info(match_index, match_count);
7419 tui_for_key.request_render(false);
7420 continue;
7421 }
7422 KeyCode::Backspace => {
7423 state_for_key.search.backspace();
7424 let lines = collect_transcript_lines(&state_for_key.chat_container);
7426 state_for_key.search.find_matches(&lines);
7427 let query = state_for_key.search.get_query();
7429 let match_count = state_for_key.search.get_match_count();
7430 search_bar.set_query(&query);
7431 search_bar.set_match_info(0, match_count);
7432 tui_for_key.request_render(false);
7433 continue;
7434 }
7435 KeyCode::Char(c) => {
7436 state_for_key.search.append_char(c);
7437 let lines = collect_transcript_lines(&state_for_key.chat_container);
7439 state_for_key.search.find_matches(&lines);
7440 let query = state_for_key.search.get_query();
7442 let match_count = state_for_key.search.get_match_count();
7443 search_bar.set_query(&query);
7444 search_bar.set_match_info(0, match_count);
7445 tui_for_key.request_render(false);
7446 continue;
7447 }
7448 _ => continue,
7449 }
7450 }
7451
7452 if keybinding_matches(
7456 &keybindings_for_key,
7457 &key,
7458 rpi_tui::keybindings::keys::MODEL_SELECT,
7459 ) {
7460 if let Some(cmd) = registry_for_key.find("/model") {
7461 cmd.execute(&ctx_for_key, "");
7462 }
7463 continue;
7464 }
7465
7466 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
7468 if accept_top_suggestion(&state_for_key, &editor_for_key) {
7469 tui_for_key.request_render_reusing_scroll_content();
7470 }
7471 continue;
7472 }
7473
7474 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
7478 let delta = -transcript_page_size(scroll_for_key.viewport_height());
7479 if scroll_for_key.scroll_by(delta) != delta {
7480 tui_for_key.request_render_reusing_scroll_content();
7481 }
7482 continue;
7483 }
7484 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
7485 let delta = transcript_page_size(scroll_for_key.viewport_height());
7486 if scroll_for_key.scroll_by(delta) != delta {
7487 tui_for_key.request_render_reusing_scroll_content();
7488 }
7489 continue;
7490 }
7491 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
7492 scroll_for_key.scroll_to_start();
7493 tui_for_key.request_render_reusing_scroll_content();
7494 continue;
7495 }
7496 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
7497 scroll_for_key.scroll_to_end();
7498 tui_for_key.request_render_reusing_scroll_content();
7499 continue;
7500 }
7501
7502 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
7511 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7512 if editor_for_key.get_text().is_empty() || browsing {
7513 navigate_history(&state_for_key, &editor_for_key, -1);
7514 tui_for_key.request_render_reusing_scroll_content();
7515 continue;
7516 }
7517 }
7518 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
7519 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
7520 if editor_for_key.get_text().is_empty() || browsing {
7521 navigate_history(&state_for_key, &editor_for_key, 1);
7522 tui_for_key.request_render_reusing_scroll_content();
7523 continue;
7524 }
7525 }
7526
7527 if keybinding_matches(
7532 &keybindings_for_key,
7533 &key,
7534 rpi_tui::keybindings::keys::DEQUEUE,
7535 ) {
7536 tokio::spawn(handle_dequeue(
7548 lane_for_key.clone(),
7549 editor_for_key.clone(),
7550 state_for_key.clone(),
7551 tui_for_key.clone(),
7552 ));
7553 continue;
7554 }
7555
7556 if keybinding_matches(
7561 &keybindings_for_key,
7562 &key,
7563 rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
7564 ) {
7565 let prompt = editor_for_key.get_expanded_text().trim().to_string();
7566 if prompt.is_empty() {
7567 continue;
7568 }
7569 editor_for_key.clear();
7570 let status = *state_for_key.status.lock().unwrap();
7571 if status == RunStatus::Idle {
7572 if state_for_key.try_start_working() {
7573 add_user_message(&state_for_key.chat_container, &prompt);
7577 push_history(&state_for_key, &prompt);
7578 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
7579 }
7580 } else {
7581 let message = AgentMessage::User(UserMessage::new(prompt, 0));
7585 let lane = lane_for_key.clone();
7586 let chat = state_for_key.chat_container.clone();
7587 let tui = tui_for_key.clone();
7588 let state = state_for_key.clone();
7589 tokio::spawn(async move {
7590 if let Err(error) = lane.follow_up(message).await {
7591 add_error_message(&chat, &format!("Could not queue message: {error}"));
7592 tui.request_render(false);
7593 return;
7594 }
7595 refresh_pending_messages(&state, &lane).await;
7596 tui.request_render(false);
7597 });
7598 }
7599 tui_for_key.request_render(false);
7600 continue;
7601 }
7602
7603 if cfg!(windows)
7620 && key.modifiers.is_empty()
7621 && matches!(
7622 key.code,
7623 KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
7624 )
7625 {
7626 let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
7633 if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
7634 crossterm::event::read().ok()
7635 } else {
7636 None
7637 }
7638 });
7639 pending_event = stashed;
7640 if enter_is_paste_burst(last_text_key_at, std::time::Instant::now(), more_queued) {
7641 crate::key_trace::note(&format!(
7642 "enter-decision -> newline (paste burst) gap_ms={} more_queued={more_queued}",
7643 gap_ms(last_text_key_at),
7644 ));
7645 editor_for_key.insert("\n");
7648 last_text_key_at = Some(std::time::Instant::now());
7649 refresh_autocomplete(&state_for_key, &editor_for_key);
7650 tui_for_key.request_render_reusing_scroll_content();
7651 continue;
7652 }
7653 crate::key_trace::note(&format!(
7654 "enter-decision -> submit gap_ms={} more_queued={more_queued}",
7655 gap_ms(last_text_key_at),
7656 ));
7657 last_text_key_at = None;
7660 } else if matches!(
7661 key.code,
7662 KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
7663 ) && !key
7664 .modifiers
7665 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
7666 {
7667 last_text_key_at = Some(std::time::Instant::now());
7670 } else {
7671 last_text_key_at = None;
7674 }
7675
7676 editor_for_key.handle_key(key);
7682 refresh_autocomplete(&state_for_key, &editor_for_key);
7683 tui_for_key.request_render_reusing_scroll_content();
7684 }
7685 });
7686
7687 if let Some(reason) = crate::session::dispatch_session_event_async(
7693 reload_context,
7694 rpi_plugin_sdk::EventTag::SessionStart,
7695 )
7696 .await
7697 {
7698 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
7699 }
7700
7701 let mut prompts: Vec<String> = Vec::new();
7703 if let Some(init) = initial {
7704 prompts.push(init);
7705 }
7706 for m in extra_messages {
7707 prompts.push(m.clone());
7708 }
7709 if prompts.is_empty() && lane.has_pending_resume().await {
7716 run_prompt_streaming(
7717 &lane,
7718 "",
7719 true,
7720 &tui,
7721 &state,
7722 drain_handle.is_some(),
7723 reload_context.js_extension_session.as_ref(),
7724 &js_dialog_bridge,
7725 args,
7726 Vec::new(),
7727 )
7728 .await;
7729 }
7730
7731 let mut images = initial_images;
7732 for prompt in prompts {
7733 if !*running.lock().unwrap() {
7734 break;
7735 }
7736 add_user_message(&chat_container, &prompt);
7740 tui.request_render(false);
7741 run_prompt_streaming(
7742 &lane,
7743 &prompt,
7744 false,
7745 &tui,
7746 &state,
7747 drain_handle.is_some(),
7748 reload_context.js_extension_session.as_ref(),
7749 &js_dialog_bridge,
7750 args,
7751 std::mem::take(&mut images),
7752 )
7753 .await;
7754 }
7755
7756 loop {
7758 if !*running.lock().unwrap() {
7759 break;
7760 }
7761 match rx.recv().await {
7762 Some(TuiMessage::UserInput(prompt)) => {
7763 state.clear_editor_programmatically();
7771 let prompt_images = state.take_pending_images();
7772 if !prompt_images.is_empty() {
7773 add_note_message(
7774 &chat_container,
7775 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
7776 );
7777 }
7778 run_prompt_streaming(
7779 &lane,
7780 &prompt,
7781 false,
7782 &tui,
7783 &state,
7784 drain_handle.is_some(),
7785 reload_context.js_extension_session.as_ref(),
7786 &js_dialog_bridge,
7787 args,
7788 prompt_images,
7789 )
7790 .await;
7791 }
7792 Some(TuiMessage::ExternalEditorResult(result)) => {
7793 match result {
7794 Ok(text) => {
7795 let cursor = text.chars().count();
7796 editor.set_text(&text);
7797 editor.set_cursor(0, cursor);
7798 add_note_message(&chat_container, "Draft updated from external editor.");
7799 }
7800 Err(error) => add_error_message(&chat_container, &error),
7801 }
7802 tui.request_render(false);
7803 }
7804 Some(TuiMessage::UserBashFinished(report)) => {
7805 state.finish_user_bash();
7808 let message = rpi_harness::messages::bash_execution_message(
7809 report.command,
7810 report.output,
7811 report.exit_code,
7812 report.cancelled,
7813 report.truncated,
7814 report.full_output_path,
7815 Some(report.exclude_from_context),
7816 now_ms(),
7817 );
7818 if *state.status.lock().unwrap() == RunStatus::Idle {
7822 if let Err(error) = lane.append_message(message).await {
7823 add_error_message(
7824 &chat_container,
7825 &format!("Could not record bash result: {error}"),
7826 );
7827 tui.request_render(false);
7828 }
7829 } else {
7830 state.pending_bash_messages.lock().unwrap().push(message);
7831 }
7832 }
7833 Some(TuiMessage::OpenTree) => {
7834 if *state.status.lock().unwrap() != RunStatus::Idle {
7835 add_note_message(
7836 &chat_container,
7837 "Wait for the current run to finish before opening the tree.",
7838 );
7839 tui.request_render(false);
7840 } else {
7841 open_tree_selector(
7842 &harness,
7843 &state,
7844 &editor_container,
7845 &editor,
7846 &tui,
7847 &chat_container,
7848 &tx,
7849 )
7850 .await;
7851 }
7852 }
7853 Some(TuiMessage::NavigateTree(entry_id)) => {
7854 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
7855 Ok(result) => match result.outcome {
7856 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
7857 chat_container.clear();
7858 add_welcome_message(&chat_container);
7859 render_session_history(
7860 &harness,
7861 &chat_container,
7862 state.markdown_transformer(),
7863 Some(state.extension_session.clone()),
7864 )
7865 .await;
7866 add_note_message(
7867 &chat_container,
7868 "Moved to the selected session entry.",
7869 );
7870 }
7871 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
7872 add_error_message(&chat_container, &error.message);
7873 }
7874 _ => add_note_message(
7875 &chat_container,
7876 "The selected entry could not be opened.",
7877 ),
7878 },
7879 Err(error) => add_error_message(
7880 &chat_container,
7881 &format!("Could not navigate session tree: {error}"),
7882 ),
7883 }
7884 tui.request_render(false);
7885 }
7886 Some(TuiMessage::ClearChat) => {
7887 chat_container.clear();
7888 add_welcome_message(&chat_container);
7889 tui.request_render(false);
7890 }
7891 Some(TuiMessage::Compact) => {
7892 run_compact(&lane, &tui, &state).await;
7893 }
7894 Some(TuiMessage::Copy) => {
7895 copy_last_assistant(&state, &chat_container);
7896 tui.request_render(false);
7897 }
7898 Some(TuiMessage::Exit) => {
7899 *running.lock().unwrap() = false;
7900 break;
7901 }
7902 Some(TuiMessage::SwitchSession(id)) => {
7903 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
7904 tui.request_render(false);
7905 }
7906 Some(TuiMessage::ImportSession(path)) => {
7907 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
7908 tui.request_render(false);
7909 }
7910 Some(TuiMessage::ShareSession) => {
7911 share_session(&harness, &chat_container).await;
7912 tui.request_render(false);
7913 }
7914 Some(TuiMessage::SetSessionName(name)) => {
7915 let outcome = harness.session().set_name(Some(&name)).await;
7916 match outcome {
7917 Ok(_) => add_note_message(
7918 &chat_container,
7919 &format!("Session renamed to \"{name}\"."),
7920 ),
7921 Err(e) => add_error_message(
7922 &chat_container,
7923 &format!("Could not rename session: {e}"),
7924 ),
7925 }
7926 tui.request_render(false);
7927 }
7928 Some(TuiMessage::ExportSession) => {
7929 let file_name = agent_session
7930 .default_export_file_name(crate::export::ExportFormat::Markdown)
7931 .await;
7932 let path = cwd.join(&file_name);
7933 match agent_session.export_to_markdown(&path).await {
7934 Ok(_) => add_note_message(
7935 &chat_container,
7936 &format!("Exported session to {}", path.display()),
7937 ),
7938 Err(e) => {
7939 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7940 }
7941 }
7942 tui.request_render(false);
7943 }
7944 Some(TuiMessage::ExportSessionWithFormat(format)) => {
7945 let file_name = agent_session.default_export_file_name(format.clone()).await;
7946 let path = cwd.join(&file_name);
7947 match agent_session.export(format, &path).await {
7948 Ok(_) => add_note_message(
7949 &chat_container,
7950 &format!("Exported session to {}", path.display()),
7951 ),
7952 Err(e) => {
7953 add_error_message(&chat_container, &format!("Could not write export: {e}"))
7954 }
7955 }
7956 tui.request_render(false);
7957 }
7958 Some(TuiMessage::ShowUsage) => {
7959 let stats = agent_session.usage_stats().await.ok();
7960 let total = agent_session.total_usage().await.ok();
7961 let snapshot = agent_session.snapshot().await.ok();
7962 match stats {
7963 Some(stats) => {
7964 let mut lines = vec![
7965 "## Usage".to_string(),
7966 String::new(),
7967 format!("- messages: {}", stats.message_count),
7968 format!("- total tokens: {}", stats.total_tokens),
7969 format!("- cached tokens: {}", stats.cached_tokens),
7970 format!("- uncached tokens: {}", stats.uncached_tokens),
7971 format!("- cost: ${:.4}", stats.cost_total),
7972 ];
7973 match stats.cache_hit_ratio() {
7974 Some(ratio) => {
7975 lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
7976 }
7977 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
7978 }
7979 if let Some(total) = total {
7980 lines.push(String::new());
7981 lines.push(format!(
7982 "- last cumulative input/output: {} / {}",
7983 total.input, total.output
7984 ));
7985 }
7986 if let Some(snapshot) = snapshot {
7987 lines.push(String::new());
7988 lines.push(format!("- lane: `{}`", snapshot.lane));
7989 lines.push(format!(
7990 "- leaf: `{}`",
7991 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
7992 ));
7993 if let Some(active) = &snapshot.active {
7994 lines.push(format!(
7995 "- active operation: {} (`{}`)",
7996 active.kind.as_str(),
7997 active.run_id
7998 ));
7999 }
8000 }
8001 add_note_message(&chat_container, &lines.join("\n"));
8002 }
8003 None => {
8004 add_error_message(&chat_container, "Could not read session usage stats.")
8005 }
8006 }
8007 tui.request_render(false);
8008 }
8009 Some(TuiMessage::ForkSession) => {
8010 fork_session(&harness, &cwd, &chat_container, &state).await;
8011 tui.request_render(false);
8012 }
8013 Some(TuiMessage::ReloadExtensions) => {
8014 let reload_ctx = ctx.reload_context.clone();
8021 add_note_message(&chat_container, "Reloading extensions + resources…");
8022 tui.request_render(false);
8023 let outcome =
8024 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
8025 let fresh_transformer = build_markdown_transformer(
8034 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
8035 );
8036 state.set_markdown_transformer_with_reinstall(fresh_transformer);
8037 if outcome.had_errors {
8038 add_error_message(&chat_container, &outcome.summary);
8042 } else if outcome.had_warnings {
8043 add_note_message(
8048 &chat_container,
8049 &format!(
8050 "{} (with warnings — see stderr for details).",
8051 outcome.summary
8052 ),
8053 );
8054 } else {
8055 add_note_message(&chat_container, &outcome.summary);
8056 }
8057 tui.request_render(false);
8058 }
8059 None => break,
8060 }
8061 }
8062
8063 js_dialog_bridge.cancel_all();
8067 ask_user_bridge.shutdown();
8068 *running.lock().unwrap() = false;
8069 for handle in update_check_handles {
8070 handle.abort();
8071 let _ = handle.await;
8072 }
8073 if let Some(js) = &reload_context.js_extension_session {
8077 js.shutdown();
8078 }
8079 let _ = key_handle.await;
8082 tick_handle.abort();
8083 if let Some(handle) = drain_handle {
8084 handle.abort();
8085 }
8086 reload_context.mailbox.clear();
8090 reload_bridge_handle.abort();
8091 tui.stop(Default::default());
8092
8093 let session_id = harness.session().storage().metadata().id;
8097 if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
8098 println!("\x1b[2mTo resume this session:\x1b[0m {command}");
8099 }
8100 println!("\nGoodbye!");
8101 let _ = args;
8102
8103 0
8104}
8105
8106async fn ensure_js_runtime_before_prompt(
8116 js: Option<&crate::js_extensions::JsExtensionSession>,
8117 lane: &Arc<dyn AgentLane>,
8118 state: &Arc<TuiState>,
8119 dialog_bridge: &JsDialogBridge,
8120 args: &Args,
8121) -> bool {
8122 let Some(js) = js else {
8123 return true;
8124 };
8125 let cancellation = state.begin_js_preparation();
8126 let worker_cancellation = cancellation.clone();
8127 let js_for_start = js.clone();
8128 let mut worker = tokio::task::spawn_blocking(move || {
8129 js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
8130 });
8131 let result = tokio::select! {
8132 result = &mut worker => result,
8133 _ = cancellation.cancelled() => {
8134 dialog_bridge.cancel_open_requests();
8135 let js_for_cancel = js.clone();
8136 let _ = tokio::task::spawn_blocking(move || {
8137 js_for_cancel.cancel_prompt_preparation();
8138 }).await;
8139 worker.await
8140 }
8141 };
8142 let was_cancelled = cancellation.is_cancelled();
8143 if was_cancelled {
8144 dialog_bridge.cancel_open_requests();
8145 let js_for_cancel = js.clone();
8146 let _ = tokio::task::spawn_blocking(move || {
8147 js_for_cancel.cancel_prompt_preparation();
8148 })
8149 .await;
8150 state.finish_js_preparation();
8151 dialog_bridge.reopen();
8152 return false;
8153 }
8154 state.finish_js_preparation();
8155 match result {
8156 Ok(Ok(())) => {
8157 if let Some(js_active) = js.active_tools() {
8161 let js_names = js.tool_names();
8162 let mut active = lane.get_active_tools().await.unwrap_or_default();
8163 active.retain(|name| {
8164 crate::session::tool_name_allowed(name, args)
8165 && !js_names.iter().any(|js_name| js_name == name)
8166 });
8167 active.extend(js_active.into_iter().filter(|name| {
8168 js_names.iter().any(|js_name| js_name == name)
8169 && crate::session::tool_name_allowed(name, args)
8170 }));
8171 active = crate::session::filter_active_tool_names(active, args);
8172 let _ = lane.set_active_tools(active).await;
8173 }
8174 }
8175 Ok(Err(error)) => {
8176 if args.verbose {
8177 eprintln!("warning: could not start JS extension runtime: {error}");
8178 }
8179 }
8180 Err(error) => {
8181 if args.verbose {
8182 eprintln!("warning: JS extension runtime worker failed: {error}");
8183 }
8184 }
8185 }
8186 true
8187}
8188
8189fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
8190 std::thread::spawn(move || {
8191 let file = match tempfile::Builder::new()
8192 .prefix("rpi-draft-")
8193 .suffix(".md")
8194 .tempfile()
8195 {
8196 Ok(file) => file,
8197 Err(error) => {
8198 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
8199 "Could not create editor file: {error}"
8200 ))));
8201 return;
8202 }
8203 };
8204 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
8205 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
8206 "Could not write editor file: {error}"
8207 ))));
8208 return;
8209 }
8210 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
8211 .ok()
8212 .filter(|value| !value.trim().is_empty())
8213 .or_else(|| std::env::var("VISUAL").ok())
8214 .or_else(|| std::env::var("EDITOR").ok())
8215 .unwrap_or_else(|| {
8216 if cfg!(windows) {
8217 "notepad".to_string()
8218 } else {
8219 "nano".to_string()
8220 }
8221 });
8222 let status = std::process::Command::new(&editor)
8223 .arg(file.path())
8224 .status();
8225 let result = match status {
8226 Ok(status) if status.success() => std::fs::read_to_string(file.path())
8227 .map_err(|error| format!("Could not read editor file: {error}")),
8228 Ok(status) => Err(format!("External editor exited with {status}")),
8229 Err(error) => Err(format!(
8230 "Could not launch external editor `{editor}`: {error}"
8231 )),
8232 };
8233 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
8234 });
8235}
8236
8237fn reconcile_streamed_assistant_completion(
8240 current_assistant: &Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
8241 last_assistant_text: &Mutex<String>,
8242 final_message: Option<&AssistantMessage>,
8243) {
8244 let final_blocks = final_message.map(assistant_blocks);
8245 let final_text = final_message.map(assistant_text);
8246 let component = current_assistant.lock().unwrap().take();
8247
8248 if let Some(component) = component {
8249 if let Some(blocks) = final_blocks.as_deref() {
8250 component.update_blocks(blocks);
8251 }
8252 component.set_streaming(false);
8253 }
8254
8255 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
8256 *last_assistant_text.lock().unwrap() = text;
8257 }
8258}
8259
8260async fn run_prompt_streaming(
8265 lane: &Arc<dyn AgentLane>,
8266 prompt: &str,
8267 resume: bool,
8268 tui: &Arc<TuiAltScreen>,
8269 state: &Arc<TuiState>,
8270 streaming: bool,
8271 js: Option<&crate::js_extensions::JsExtensionSession>,
8272 dialog_bridge: &JsDialogBridge,
8273 args: &Args,
8274 images: Vec<rpi_ai::types::ImageContent>,
8275) {
8276 state.set_status(RunStatus::Working);
8285 tui.request_render(false);
8286 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
8287 state.set_status(RunStatus::Idle);
8288 tui.request_render(false);
8289 return;
8290 }
8291
8292 let outcome = if resume {
8293 match lane.resume_pending().await {
8296 Ok(Some(result)) => Ok(result),
8297 Ok(None) => {
8300 state.set_status(RunStatus::Idle);
8301 tui.request_render(false);
8302 return;
8303 }
8304 Err(error) => Err(error),
8305 }
8306 } else {
8307 lane.prompt_text(prompt, images).await
8308 };
8309
8310 if streaming {
8311 let final_message = match &outcome {
8316 Ok(result) => match &result.outcome {
8317 HarnessRunOutcome::Completed { final_message, .. }
8318 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
8319 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
8320 HarnessRunOutcome::Suspended { .. } => None,
8321 },
8322 Err(_) => None,
8323 };
8324 reconcile_streamed_assistant_completion(
8325 &state.current_assistant,
8326 &state.last_assistant_text,
8327 final_message,
8328 );
8329 }
8330
8331 state.set_status(RunStatus::Idle);
8332 refresh_pending_messages(state, lane).await;
8336
8337 if let Ok(result) = &outcome {
8342 let final_message: Option<&AssistantMessage> = match &result.outcome {
8343 HarnessRunOutcome::Completed { final_message, .. }
8344 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
8345 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
8346 HarnessRunOutcome::Suspended { .. } => None,
8347 };
8348 if let Some(a) = final_message {
8349 record_context_usage(&state.footer, a);
8350 }
8351 }
8352
8353 match outcome {
8354 Ok(result) => match &result.outcome {
8355 HarnessRunOutcome::Failed {
8356 error,
8357 final_message,
8358 ..
8359 } => {
8360 let already_rendered = streaming && final_message.is_some();
8368 if !already_rendered {
8369 let msg = final_message
8370 .as_ref()
8371 .and_then(|m| m.error_message.clone())
8372 .unwrap_or_else(|| format!("{error:?}"));
8373 add_error_message(&state.chat_container, &msg);
8374 }
8375 }
8376 HarnessRunOutcome::Suspended { .. } => {
8377 add_error_message(
8378 &state.chat_container,
8379 "Run suspended (deferred) — resume is not supported in v1.",
8380 );
8381 }
8382 HarnessRunOutcome::Aborted { final_message, .. } => {
8383 if !streaming {
8386 add_error_message(&state.chat_container, "Request aborted.");
8387 let _ = final_message; }
8389 }
8390 HarnessRunOutcome::Completed { final_message, .. } => {
8391 if !streaming {
8392 let text = assistant_text(final_message);
8393 if !text.is_empty() {
8394 add_assistant_message_blocking(
8395 &state.chat_container,
8396 &text,
8397 state.markdown_transformer(),
8398 );
8399 *state.last_assistant_text.lock().unwrap() = text;
8400 }
8401 }
8402 }
8403 },
8404 Err(e) => {
8405 add_error_message(&state.chat_container, &e.to_string());
8406 }
8407 }
8408
8409 tui.request_render(false);
8410}
8411
8412async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
8417 state.set_status(RunStatus::Working);
8418 tui.request_render(false);
8419 match lane.compact(None).await {
8420 Ok(_) => {
8421 add_note_message(&state.chat_container, "Conversation compacted.");
8422 state.footer.set_context_tokens(None);
8426 }
8427 Err(e) => {
8428 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
8429 }
8430 }
8431 state.set_status(RunStatus::Idle);
8432 tui.request_render(false);
8433}
8434
8435fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
8439 let text = state.last_assistant_text.lock().unwrap().clone();
8440 if text.is_empty() {
8441 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
8442 return;
8443 }
8444 if copy_to_clipboard(&text) {
8445 add_note_message(chat, "Copied last reply to the clipboard.");
8446 } else {
8447 let preview: String = text.chars().take(200).collect();
8450 add_note_message(
8451 chat,
8452 &format!(
8453 "Clipboard unavailable. Last reply: {preview}{}",
8454 if text.chars().count() > 200 {
8455 "…"
8456 } else {
8457 ""
8458 }
8459 ),
8460 );
8461 }
8462}
8463
8464fn extract_selected_text(
8467 chat_container: &Arc<Container>,
8468 start: (u16, u16),
8469 end: (u16, u16),
8470) -> Option<String> {
8471 use rpi_tui::Component;
8472
8473 let lines = chat_container.render(80); if lines.is_empty() {
8476 return None;
8477 }
8478
8479 let (start_row, end_row) = if start.1 <= end.1 {
8481 (start.1 as usize, end.1 as usize)
8482 } else {
8483 (end.1 as usize, start.1 as usize)
8484 };
8485
8486 let start_row = start_row.min(lines.len().saturating_sub(1));
8488 let end_row = end_row.min(lines.len().saturating_sub(1));
8489
8490 if start_row > end_row {
8491 return None;
8492 }
8493
8494 let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
8496
8497 if selected_lines.is_empty() {
8498 return None;
8499 }
8500
8501 Some(selected_lines.join("\n"))
8502}
8503
8504#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8508fn copy_to_clipboard(text: &str) -> bool {
8509 match arboard::Clipboard::new() {
8510 Ok(mut cb) => cb.set_text(text).is_ok(),
8511 Err(_) => false,
8512 }
8513}
8514
8515#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8516fn copy_to_clipboard(_text: &str) -> bool {
8517 false
8518}
8519
8520#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8524fn read_clipboard_text() -> Option<String> {
8525 let mut clipboard = arboard::Clipboard::new().ok()?;
8526 clipboard.get_text().ok()
8527}
8528
8529#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8530fn read_clipboard_text() -> Option<String> {
8531 None
8532}
8533
8534#[cfg(all(feature = "clipboard", not(target_os = "android")))]
8538fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8539 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
8540 let image = match clipboard.get_image() {
8541 Ok(image) => image,
8542 Err(_) => return Ok(None),
8543 };
8544 let width =
8545 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
8546 let height =
8547 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
8548 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
8549 return Err("clipboard image dimensions are outside the supported range".into());
8550 }
8551 let mut bytes = Vec::new();
8552 {
8553 let mut encoder = png::Encoder::new(&mut bytes, width, height);
8554 encoder.set_color(png::ColorType::Rgba);
8555 encoder.set_depth(png::BitDepth::Eight);
8556 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
8557 writer
8558 .write_image_data(&image.bytes)
8559 .map_err(|e| e.to_string())?;
8560 }
8561 Ok(Some(rpi_ai::types::ImageContent {
8562 kind: rpi_ai::types::ImageContentType,
8563 data: base64::engine::general_purpose::STANDARD.encode(bytes),
8564 mime_type: "image/png".into(),
8565 }))
8566}
8567
8568fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
8569 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
8570 let mut options = ImageOptions::default();
8571 options.width = Some(48);
8572 options.alt_text = Some("Attached image".into());
8573 chat.add_child(Arc::new(Image::from_data(bytes, options)));
8574 chat.add_child(Arc::new(Spacer::new(1)));
8575 }
8576}
8577
8578#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
8579fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
8580 Ok(None)
8581}
8582
8583fn add_assistant_message_blocking(
8590 container: &Arc<Container>,
8591 text: &str,
8592 transformer: Option<MarkdownTransformer>,
8593) {
8594 if text.is_empty() {
8595 return;
8596 }
8597 let msg = Arc::new(AssistantMessageComponent::new(
8598 AssistantMessageOptions::default(),
8599 ));
8600 if let Some(t) = &transformer {
8601 msg.set_markdown_transformer(Some(t.clone()));
8602 }
8603 msg.update_text(text);
8604 container.add_child(msg);
8605 container.add_child(Arc::new(Spacer::new(1)));
8606}
8607
8608async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
8619 if let Ok(queued) = lane.queued_messages().await {
8620 state.set_pending_queue(queued);
8621 }
8622}
8623
8624fn transcript_has_live_panel(
8630 bash_components_present: bool,
8631 tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
8632) -> bool {
8633 bash_components_present
8634 || tool_components
8635 .values()
8636 .any(|component| component.status() == ToolStatus::Running)
8637}
8638
8639fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
8642 if current.trim().is_empty() {
8643 queued_text.to_string()
8644 } else {
8645 format!("{queued_text}\n\n{current}")
8646 }
8647}
8648
8649fn set_editor_text_caret_at_end(editor: &Arc<Editor>, text: &str) {
8654 editor.set_text(text);
8655 let last_row = text.split('\n').count().saturating_sub(1);
8656 let last_len = text.split('\n').next_back().map(str::len).unwrap_or(0);
8657 editor.set_cursor(last_row, last_len);
8658}
8659
8660async fn restore_queued_messages_to_editor(
8670 lane: Arc<dyn AgentLane>,
8671 editor: Arc<Editor>,
8672 state: Arc<TuiState>,
8673 tui: Arc<TuiAltScreen>,
8674) -> usize {
8675 match lane.clear_queue().await {
8676 Ok(queued) => {
8677 let all: Vec<String> = queued
8678 .steering
8679 .iter()
8680 .chain(queued.follow_up.iter())
8681 .cloned()
8682 .collect();
8683 if !all.is_empty() {
8684 let queued_text = all.join("\n\n");
8685 let current = editor.get_expanded_text();
8690 let combined = merge_queued_into_draft(&queued_text, ¤t);
8691 set_editor_text_caret_at_end(&editor, &combined);
8692 }
8693 refresh_pending_messages(&state, &lane).await;
8695 tui.request_render(false);
8696 all.len()
8697 }
8698 Err(error) => {
8699 add_error_message(
8700 &state.chat_container,
8701 &format!("Could not restore queued messages: {error}"),
8702 );
8703 tui.request_render(false);
8704 0
8705 }
8706 }
8707}
8708
8709async fn handle_dequeue(
8714 lane: Arc<dyn AgentLane>,
8715 editor: Arc<Editor>,
8716 state: Arc<TuiState>,
8717 tui: Arc<TuiAltScreen>,
8718) {
8719 let restored =
8720 restore_queued_messages_to_editor(lane, editor, state.clone(), tui.clone()).await;
8721 if restored == 0 {
8722 add_note_message(&state.chat_container, "No queued messages to restore");
8723 } else {
8724 let plural = if restored > 1 { "s" } else { "" };
8725 add_note_message(
8726 &state.chat_container,
8727 &format!("Restored {restored} queued message{plural} to editor"),
8728 );
8729 }
8730 tui.request_render(false);
8731}
8732
8733fn abort_run_restoring_queue(
8740 lane: Arc<dyn AgentLane>,
8741 editor: Arc<Editor>,
8742 state: Arc<TuiState>,
8743 tui: Arc<TuiAltScreen>,
8744) {
8745 tokio::spawn(async move {
8746 let _ = restore_queued_messages_to_editor(lane.clone(), editor, state, tui.clone()).await;
8747 let _ = lane.abort().await;
8748 tui.request_render(false);
8749 });
8750}
8751
8752async fn drain_agent_events(
8753 mut rx: broadcast::Receiver<AgentEvent>,
8754 tui: Arc<TuiAltScreen>,
8755 state: Arc<TuiState>,
8756 chat: Arc<Container>,
8757 lane: Arc<dyn AgentLane>,
8758) {
8759 loop {
8760 match rx.recv().await {
8761 Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
8762 Err(broadcast::error::RecvError::Lagged(_)) => {
8763 continue;
8766 }
8767 Err(broadcast::error::RecvError::Closed) => break,
8768 }
8769 }
8770}
8771
8772async fn handle_agent_event(
8775 event: AgentEvent,
8776 tui: &Arc<TuiAltScreen>,
8777 state: &Arc<TuiState>,
8778 chat: &Arc<Container>,
8779 lane: &Arc<dyn AgentLane>,
8780) {
8781 match event {
8782 AgentEvent::AgentStart => {
8783 state.set_status(RunStatus::Working);
8784 tui.request_render(false);
8785 }
8786
8787 AgentEvent::AgentEnd { .. } => {
8788 state.transcript_view().finalize_assistant();
8790 state.set_status(RunStatus::Idle);
8791 let pending: Vec<AgentMessage> =
8795 std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
8796 for message in pending {
8797 if let Err(error) = lane.append_message(message).await {
8798 add_error_message(chat, &format!("Could not record bash result: {error}"));
8799 }
8800 }
8801 refresh_pending_messages(state, lane).await;
8804 tui.request_render(false);
8805 }
8806
8807 AgentEvent::RetryScheduled {
8808 attempt,
8809 max_retries,
8810 delay_ms,
8811 ..
8812 } => {
8813 state.show_retry(attempt, max_retries, delay_ms);
8814 tui.request_render(false);
8815 }
8816
8817 AgentEvent::TurnStart => {
8818 state.transcript_view().finalize_assistant();
8821 }
8822
8823 AgentEvent::TurnEnd {
8824 message,
8825 tool_results,
8826 } => {
8827 let view = state.transcript_view();
8829 if let AgentMessage::Assistant(a) = &message {
8830 view.finalize_assistant_blocks(&assistant_blocks(a));
8831 } else {
8832 view.finalize_assistant();
8833 }
8834 let _ = tool_results;
8838 tui.request_render(false);
8839 }
8840
8841 AgentEvent::MessageStart { message } => match message {
8842 AgentMessage::Assistant(a) => {
8843 state.transcript_view().apply(
8847 &UiEvent::AssistantStart {
8848 blocks: assistant_blocks(&a),
8849 },
8850 tui.width(),
8851 );
8852 tui.request_render(false);
8853 }
8854 AgentMessage::Custom(custom) => {
8855 let payload = serde_json::json!({
8856 "customType": custom.role,
8857 "content": custom.content,
8858 "details": custom.data,
8859 "expanded": false,
8860 "outputPad": 1,
8861 });
8862 if let Some(component) = extension_message_component(
8863 &state.extension_session,
8864 &custom.role,
8865 &payload,
8866 state.markdown_transformer(),
8867 ) {
8868 chat.add_child(component);
8869 chat.add_child(Arc::new(Spacer::new(1)));
8870 tui.request_render(false);
8871 } else {
8872 add_note_message(chat, &custom_message_fallback(&custom));
8873 tui.request_render(false);
8874 }
8875 }
8876 AgentMessage::User(user) => {
8886 state.transcript_view().add_user(&user_message_text(&user));
8887 refresh_pending_messages(state, lane).await;
8889 tui.request_render(false);
8890 }
8891 _ => {}
8894 },
8895
8896 AgentEvent::MessageUpdate {
8897 message,
8898 assistant_message_event,
8899 } => {
8900 let _ = assistant_message_event; let a: &rpi_ai::types::AssistantMessage = &message;
8902 let text = assistant_text(a);
8903 state.transcript_view().apply(
8907 &UiEvent::AssistantUpdate {
8908 blocks: assistant_blocks(a),
8909 tool_calls: assistant_tool_calls(a),
8910 },
8911 tui.width(),
8912 );
8913 state.sync_working_loader_with_bash();
8915 *state.last_assistant_text.lock().unwrap() = text;
8916 tui.request_render(false);
8917 }
8918
8919 AgentEvent::MessageEnd { message } => {
8920 if let AgentMessage::Assistant(a) = &message {
8921 let text = assistant_text(a);
8922 state
8923 .transcript_view()
8924 .finalize_assistant_blocks(&assistant_blocks(a));
8925 if !text.is_empty() {
8927 *state.last_assistant_text.lock().unwrap() = text;
8928 }
8929 let usage = &a.usage;
8935 state.footer.add_usage(
8937 usage.input,
8938 usage.output,
8939 usage.cache_read,
8940 usage.cache_write,
8941 usage.cost.total,
8942 );
8943 let denom = usage.input + usage.cache_read + usage.cache_write;
8944 if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
8945 state
8946 .footer
8947 .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
8948 }
8949 record_context_usage(&state.footer, a);
8953 let miss = state
8954 .cache_tracker
8955 .lock()
8956 .unwrap()
8957 .observe(a, &rpi_harness::cache_stats::NoPrices);
8958 let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
8962 if let Some(miss) = miss.filter(|_| cache_miss_notices) {
8963 let cost = if miss.missed_cost > 0.0 {
8964 format!(" (~${:.4})", miss.missed_cost)
8965 } else {
8966 String::new()
8967 };
8968 add_note_message(
8969 &state.chat_container,
8970 &format!(
8971 "Cache miss: {} tokens re-billed{}",
8972 format_tokens(miss.missed_tokens),
8973 cost
8974 ),
8975 );
8976 }
8977 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
8978 add_note_message(chat, &text);
8979 }
8980 if let Some(error) = assistant_error_text(a) {
8985 add_error_message(chat, &error);
8986 }
8987 }
8988 tui.request_render(false);
8989 }
8990
8991 AgentEvent::ToolExecutionStart {
8992 tool_call_id,
8993 tool_name,
8994 args,
8995 } => {
8996 state.transcript_view().apply(
8997 &UiEvent::ToolStart {
8998 id: tool_call_id,
8999 name: tool_name,
9000 args,
9001 },
9002 tui.width(),
9003 );
9004 state.sync_working_loader_with_bash();
9005 tui.request_render(false);
9006 }
9007
9008 AgentEvent::ToolExecutionUpdate {
9009 tool_call_id,
9010 tool_name,
9011 args,
9012 partial_result,
9013 } => {
9014 state.transcript_view().apply(
9015 &UiEvent::ToolUpdate {
9016 id: tool_call_id,
9017 name: tool_name,
9018 args,
9019 result: crate::remote::protocol::tool_result_json(&partial_result),
9020 },
9021 tui.width(),
9022 );
9023 state.sync_working_loader_with_bash();
9024 tui.request_render(false);
9025 }
9026
9027 AgentEvent::ToolExecutionEnd {
9028 tool_call_id,
9029 tool_name,
9030 result,
9031 is_error,
9032 } => {
9033 state.transcript_view().apply(
9034 &UiEvent::ToolEnd {
9035 id: tool_call_id,
9036 name: tool_name,
9037 result: crate::remote::protocol::tool_result_json(&result),
9038 is_error,
9039 },
9040 tui.width(),
9041 );
9042 state.sync_working_loader_with_bash();
9043 tui.render_now(false);
9047 }
9048 }
9049}
9050
9051fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
9059 if message.stop_reason == rpi_ai::StopReason::Aborted
9060 || message.stop_reason == rpi_ai::StopReason::Error
9061 {
9062 return;
9063 }
9064 let tokens = message.usage.context_tokens();
9065 if tokens > 0 {
9066 footer.set_context_tokens(Some(tokens));
9067 }
9068}
9069
9070fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
9074 let fallback = match message.stop_reason {
9075 rpi_ai::StopReason::Error => "Provider request failed.",
9076 rpi_ai::StopReason::Aborted => "Request aborted.",
9077 _ => return None,
9078 };
9079 Some(
9080 message
9081 .error_message
9082 .as_deref()
9083 .filter(|text| !text.trim().is_empty())
9084 .unwrap_or(fallback)
9085 .to_string(),
9086 )
9087}
9088
9089fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
9098 details
9099 .and_then(|details| details.get("markdown"))
9100 .and_then(|value| value.as_bool())
9101 .unwrap_or(false)
9102}
9103
9104fn open_selector(
9112 state: &Arc<TuiState>,
9113 editor_container: &Arc<Container>,
9114 editor: &Arc<Editor>,
9115 tui: &Arc<TuiAltScreen>,
9116 list: Arc<SelectList>,
9117 kind: SelectorKind,
9118) {
9119 open_selector_with_view(
9120 state,
9121 editor_container,
9122 editor,
9123 tui,
9124 SelectorView::List(list.clone()),
9125 list,
9126 kind,
9127 );
9128}
9129
9130fn open_searchable_selector(
9135 state: &Arc<TuiState>,
9136 editor_container: &Arc<Container>,
9137 editor: &Arc<Editor>,
9138 tui: &Arc<TuiAltScreen>,
9139 title: Option<&str>,
9140 description: Option<&str>,
9141 list: Arc<SelectList>,
9142 kind: SelectorKind,
9143) -> Arc<SearchableSelectList> {
9144 let searchable = Arc::new(SearchableSelectList::new(title, description, list));
9145 open_selector_with_view(
9146 state,
9147 editor_container,
9148 editor,
9149 tui,
9150 SelectorView::Searchable(searchable.clone()),
9151 searchable.clone(),
9152 kind,
9153 );
9154 searchable.set_focused(true);
9158 searchable
9159}
9160
9161fn open_selector_with_view<V: Component + 'static>(
9169 state: &Arc<TuiState>,
9170 editor_container: &Arc<Container>,
9171 editor: &Arc<Editor>,
9172 tui: &Arc<TuiAltScreen>,
9173 view_router: SelectorView,
9174 view: Arc<V>,
9175 kind: SelectorKind,
9176) {
9177 dispatch_ui_prompt_event(state);
9179
9180 editor.set_focused(false);
9182 state.autocomplete_container.clear();
9185 editor_container.clear();
9187 editor_container.add_child(view.clone());
9188 *state.active_selector.lock().unwrap() = Some((view_router, kind));
9189 let focused: Arc<dyn Component> = view;
9190 tui.set_focus(Some(focused));
9191 tui.request_render(false);
9192}
9193
9194fn close_selector(
9197 state: &Arc<TuiState>,
9198 editor_container: &Arc<Container>,
9199 editor: &Arc<Editor>,
9200 tui: &Arc<TuiAltScreen>,
9201) {
9202 dispatch_ui_prompt_event_end(state);
9204
9205 editor_container.clear();
9206 editor_container.add_child(editor.clone());
9207 state.autocomplete_container.clear();
9208 editor.set_focused(true);
9209 *state.active_selector.lock().unwrap() = None;
9210 *state.active_extension_cancel.lock().unwrap() = None;
9211 tui.set_focus(Some(editor.clone()));
9212 tui.request_render(false);
9213}
9214
9215fn open_model_selector(
9221 state: &Arc<TuiState>,
9222 editor_container: &Arc<Container>,
9223 editor: &Arc<Editor>,
9224 tui: &Arc<TuiAltScreen>,
9225 catalog: &[rpi_ai::Model],
9226 lane: &Arc<dyn AgentLane>,
9227 lane_model_id: &str,
9228 chat: &Arc<Container>,
9229 initial_search: Option<&str>,
9230) -> Option<Arc<SearchableSelectList>> {
9231 let items = model_selector_items(catalog, lane_model_id);
9232 if items.is_empty() {
9233 add_note_message(
9234 chat,
9235 "No models in the catalog. Use --model at startup to select one.",
9236 );
9237 tui.request_render(false);
9238 return None;
9239 }
9240 let list = Arc::new(SelectList::new(items, 10));
9241
9242 let catalog_arc = catalog.to_vec();
9246 let state_sel = state.clone();
9247 let ec_sel = editor_container.clone();
9248 let editor_sel = editor.clone();
9249 let tui_sel = tui.clone();
9250 let chat_sel = chat.clone();
9251 let lane_sel = lane.clone();
9252 list.on_select(Arc::new(move |item| {
9253 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
9254 add_note_message(
9255 &chat_sel,
9256 &format!("Model {} not found in catalog.", item.label),
9257 );
9258 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9259 return;
9260 };
9261 state_sel.set_current_model(&model);
9262 let lane = lane_sel.clone();
9263 tokio::spawn(async move {
9264 let _ = lane.set_model(model).await;
9265 });
9266 add_note_message(
9267 &chat_sel,
9268 &format!(
9269 "Model set to {} — applies to the next message.",
9270 short_model_name(&item.value)
9271 ),
9272 );
9273 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9274 }));
9275 let state_cancel = state.clone();
9276 let ec_cancel = editor_container.clone();
9277 let editor_cancel = editor.clone();
9278 let tui_cancel = tui.clone();
9279 list.on_cancel(Arc::new(move || {
9280 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9281 }));
9282
9283 let searchable = open_searchable_selector(
9284 state,
9285 editor_container,
9286 editor,
9287 tui,
9288 Some("Select model"),
9289 Some("Type to filter by name, provider, or id"),
9290 list,
9291 SelectorKind::Model,
9292 );
9293 if let Some(term) = initial_search {
9294 searchable.set_search_text(term);
9295 }
9296 Some(searchable)
9297}
9298
9299fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9304 if catalog.is_empty() {
9305 return None;
9306 }
9307 let idx = catalog
9308 .iter()
9309 .position(|m| m.id.eq_ignore_ascii_case(current_id));
9310 match idx {
9311 Some(i) => {
9312 let next = (i + 1) % catalog.len();
9313 Some(catalog[next].clone())
9314 }
9315 None => Some(catalog[0].clone()),
9316 }
9317}
9318
9319fn cycle_prev_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
9322 if catalog.is_empty() {
9323 return None;
9324 }
9325 let idx = catalog
9326 .iter()
9327 .position(|m| m.id.eq_ignore_ascii_case(current_id));
9328 match idx {
9329 Some(i) => {
9330 let prev = (i + catalog.len() - 1) % catalog.len();
9331 Some(catalog[prev].clone())
9332 }
9333 None => Some(catalog[0].clone()),
9334 }
9335}
9336
9337fn open_session_selector(
9343 state: &Arc<TuiState>,
9344 editor_container: &Arc<Container>,
9345 editor: &Arc<Editor>,
9346 tui: &Arc<TuiAltScreen>,
9347 cwd: &std::path::Path,
9348 tx: &mpsc::UnboundedSender<TuiMessage>,
9349) {
9350 let dir = crate::session::default_session_dir(cwd);
9351 let mut items: Vec<SelectItem> = Vec::new();
9352 for path in crate::session::list_session_files_sync(&dir) {
9353 let summary = crate::session::summarize_session_file(&path);
9354 if summary.empty {
9355 continue;
9356 }
9357 let stem = path
9358 .file_stem()
9359 .and_then(|s| s.to_str())
9360 .unwrap_or("(unnamed)")
9361 .to_string();
9362 let label = crate::session::session_display_label(&summary);
9363 let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
9366 let short_id = crate::session::short_session_id(id_tail);
9367 let description = format!(
9368 "{} · {}",
9369 crate::session::format_session_bytes(summary.bytes),
9370 short_id,
9371 );
9372 items.push(
9373 SelectItem::new(&stem, &label)
9374 .with_description(&description)
9375 .with_search_text(&format!("{label} {short_id} {stem}")),
9376 );
9377 }
9378 if items.is_empty() {
9379 add_note_message(
9380 &state.chat_container,
9381 "No saved sessions found. Sessions are created automatically in interactive mode.",
9382 );
9383 tui.request_render(false);
9384 return;
9385 }
9386 let mut list = SelectList::new(items, 10);
9387 list.set_layout(rpi_tui::SelectListLayoutOptions {
9388 min_primary_column_width: Some(56),
9389 max_primary_column_width: Some(72),
9390 truncate_primary: None,
9391 });
9392 let list = Arc::new(list);
9393
9394 let state_sel = state.clone();
9395 let ec_sel = editor_container.clone();
9396 let editor_sel = editor.clone();
9397 let tui_sel = tui.clone();
9398 let tx_sel = tx.clone();
9399 list.on_select(Arc::new(move |item| {
9400 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9404 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
9405 }));
9406 let state_cancel = state.clone();
9407 let ec_cancel = editor_container.clone();
9408 let editor_cancel = editor.clone();
9409 let tui_cancel = tui.clone();
9410 list.on_cancel(Arc::new(move || {
9411 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9412 }));
9413
9414 open_searchable_selector(
9415 state,
9416 editor_container,
9417 editor,
9418 tui,
9419 Some("Resume session"),
9420 Some("Type to filter sessions"),
9421 list,
9422 SelectorKind::Session,
9423 );
9424}
9425
9426fn custom_entry_display_text(
9427 custom_type: &str,
9428 data: Option<&serde_json::Value>,
9429) -> Option<String> {
9430 let data = data?;
9431 let text = data
9432 .get("summary")
9433 .or_else(|| data.get("text"))
9434 .or_else(|| data.get("output"))
9435 .and_then(|value| value.as_str())
9436 .filter(|value| !value.trim().is_empty())?;
9437 let label = match custom_type {
9438 "compactionSummary" => "Compaction summary",
9439 "branchSummary" => "Branch summary",
9440 "bashExecution" => "Command output",
9441 other => other,
9442 };
9443 Some(format!("{label}: {text}"))
9444}
9445
9446async fn open_tree_selector(
9450 harness: &AgentHarness,
9451 state: &Arc<TuiState>,
9452 editor_container: &Arc<Container>,
9453 editor: &Arc<Editor>,
9454 tui: &Arc<TuiAltScreen>,
9455 chat: &Arc<Container>,
9456 tx: &mpsc::UnboundedSender<TuiMessage>,
9457) {
9458 let entries = match harness
9459 .session()
9460 .view("main")
9461 .find_entries(&EntryQuery {
9462 order: Some(EntryOrder::OldestFirst),
9463 ..Default::default()
9464 })
9465 .await
9466 {
9467 Ok(entries) => entries,
9468 Err(error) => {
9469 add_error_message(chat, &format!("Could not read session tree: {error}"));
9470 tui.request_render(false);
9471 return;
9472 }
9473 };
9474 let current = harness.session().get_leaf_id().await.ok().flatten();
9475 let items: Vec<SelectItem> = entries
9476 .iter()
9477 .map(|entry| {
9478 let label = if current.as_deref() == Some(entry.id()) {
9479 format!("{} #{} (current)", entry.entry_type(), entry.seq())
9480 } else {
9481 format!("{} #{}", entry.entry_type(), entry.seq())
9482 };
9483 let short_id = &entry.id()[..entry.id().len().min(12)];
9484 let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
9487 SelectItem::new(entry.id(), &label)
9488 .with_description(short_id)
9489 .with_search_text(&search_text)
9490 })
9491 .collect();
9492 if items.is_empty() {
9493 add_note_message(chat, "The current session has no entries to navigate.");
9494 tui.request_render(false);
9495 return;
9496 }
9497 let list = Arc::new(SelectList::new(items, 12));
9498 let state_sel = state.clone();
9499 let ec_sel = editor_container.clone();
9500 let editor_sel = editor.clone();
9501 let tui_sel = tui.clone();
9502 let tx_sel = tx.clone();
9503 list.on_select(Arc::new(move |item| {
9504 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
9505 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9506 }));
9507 let state_cancel = state.clone();
9508 let ec_cancel = editor_container.clone();
9509 let editor_cancel = editor.clone();
9510 let tui_cancel = tui.clone();
9511 list.on_cancel(Arc::new(move || {
9512 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9513 }));
9514 open_searchable_selector(
9515 state,
9516 editor_container,
9517 editor,
9518 tui,
9519 Some("Session tree"),
9520 Some("Type to filter by entry type, sequence, or id"),
9521 list,
9522 SelectorKind::Tree,
9523 );
9524}
9525
9526fn open_theme_selector(
9529 state: &Arc<TuiState>,
9530 editor_container: &Arc<Container>,
9531 editor: &Arc<Editor>,
9532 tui: &Arc<TuiAltScreen>,
9533 cwd: &std::path::Path,
9534 package_resources: &Arc<crate::packages::PackageResources>,
9535) {
9536 let mut items = vec![
9537 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
9538 SelectItem::new("light", "Light").with_description("Light background"),
9539 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
9540 ];
9541 if state.themes_enabled {
9542 for path in package_resources.theme_files() {
9543 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
9544 items.push(SelectItem::new(name, name).with_description("Package theme"));
9545 }
9546 }
9547 }
9548 let list = Arc::new(SelectList::new(items, 10));
9549
9550 let state_sel = state.clone();
9551 let ec_sel = editor_container.clone();
9552 let editor_sel = editor.clone();
9553 let tui_sel = tui.clone();
9554 let chat_sel = state.chat_container.clone();
9555 let cwd_sel = cwd.to_path_buf();
9556 let package_resources_sel = package_resources.clone();
9557 list.on_select(Arc::new(move |item| {
9558 let preset = match item.value.as_str() {
9559 "light" => Some(ThemePreset::Light),
9560 "monochrome" => Some(ThemePreset::Monochrome),
9561 "dark" => Some(ThemePreset::Dark),
9562 name => {
9563 if state_sel.themes_enabled {
9564 if let Ok(custom) = crate::packages::load_theme_with_resources(
9565 &cwd_sel,
9566 name,
9567 &package_resources_sel,
9568 ) {
9569 rpi_tui::global_theme_manager().set(custom.clone());
9570 state_sel.theme_manager.set(custom);
9571 }
9572 }
9573 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9574 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9575 tui_sel.render_now(true);
9576 return;
9577 }
9578 };
9579 let Some(preset) = preset else { return };
9580 apply_theme_preset(preset);
9581 state_sel.theme_manager.apply_preset(preset);
9582 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
9585 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9586 tui_sel.render_now(true);
9587 }));
9588 let state_cancel = state.clone();
9589 let ec_cancel = editor_container.clone();
9590 let editor_cancel = editor.clone();
9591 let tui_cancel = tui.clone();
9592 list.on_cancel(Arc::new(move || {
9593 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9594 }));
9595
9596 open_selector(
9597 state,
9598 editor_container,
9599 editor,
9600 tui,
9601 list,
9602 SelectorKind::Theme,
9603 );
9604}
9605
9606fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9613 use rpi_ai::types::ThinkingLevel::*;
9614 match level {
9615 Off => "Off — No reasoning",
9616 Minimal => "Minimal — Brief reasoning (~1k tokens)",
9617 Low => "Low — Light reasoning (~1k tokens)",
9618 Medium => "Medium — Moderate reasoning (~80% of max)",
9619 High => "High — Extensive reasoning (~95% of max)",
9620 Xhigh => "Xhigh — Near-maximal reasoning",
9621 Max => "Max — Maximum reasoning",
9622 }
9623}
9624
9625fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
9628 use rpi_ai::types::ThinkingLevel::*;
9629 match level {
9630 Off => "off",
9631 Minimal => "minimal",
9632 Low => "low",
9633 Medium => "medium",
9634 High => "high",
9635 Xhigh => "xhigh",
9636 Max => "max",
9637 }
9638}
9639
9640fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
9643 use rpi_ai::types::ThinkingLevel::*;
9644 match name.to_ascii_lowercase().as_str() {
9645 "off" => Some(Off),
9646 "minimal" => Some(Minimal),
9647 "low" => Some(Low),
9648 "medium" => Some(Medium),
9649 "high" => Some(High),
9650 "xhigh" => Some(Xhigh),
9651 "max" => Some(Max),
9652 _ => None,
9653 }
9654}
9655
9656fn open_thinking_selector(
9666 state: &Arc<TuiState>,
9667 editor_container: &Arc<Container>,
9668 editor: &Arc<Editor>,
9669 tui: &Arc<TuiAltScreen>,
9670 lane: &Arc<dyn AgentLane>,
9671 catalog: &[rpi_ai::Model],
9672 lane_model_id: &str,
9673 chat: &Arc<Container>,
9674) {
9675 let model = catalog
9678 .iter()
9679 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
9680 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
9681 .map(|m| m.supported_thinking_levels())
9682 .unwrap_or_else(|| {
9683 use rpi_ai::types::ThinkingLevel::*;
9684 vec![Off, Minimal, Low, Medium, High]
9685 });
9686 let mut items: Vec<SelectItem> = Vec::new();
9687 for lvl in &levels {
9688 let name = thinking_level_name(*lvl);
9689 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
9690 }
9691 if items.is_empty() {
9692 add_note_message(chat, "This model has no supported thinking levels.");
9693 tui.request_render(false);
9694 return;
9695 }
9696 let list = Arc::new(SelectList::new(items, 10));
9697
9698 let state_sel = state.clone();
9699 let ec_sel = editor_container.clone();
9700 let editor_sel = editor.clone();
9701 let tui_sel = tui.clone();
9702 let chat_sel = chat.clone();
9703 let lane_sel = lane.clone();
9704 list.on_select(Arc::new(move |item| {
9705 let Some(level) = thinking_level_from_name(&item.value) else {
9706 add_note_message(
9707 &chat_sel,
9708 &format!("Unknown thinking level: {}.", item.label),
9709 );
9710 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9711 return;
9712 };
9713 let lane = lane_sel.clone();
9714 let footer_sel = state_sel.footer.clone();
9715 tokio::spawn(async move {
9716 let _ = lane.set_thinking_level(level).await;
9717 });
9718 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
9722 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
9723 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9724 }));
9725 let state_cancel = state.clone();
9726 let ec_cancel = editor_container.clone();
9727 let editor_cancel = editor.clone();
9728 let tui_cancel = tui.clone();
9729 list.on_cancel(Arc::new(move || {
9730 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9731 }));
9732
9733 open_selector(
9734 state,
9735 editor_container,
9736 editor,
9737 tui,
9738 list,
9739 SelectorKind::Thinking,
9740 );
9741}
9742
9743fn open_tools_selector(
9749 state: &Arc<TuiState>,
9750 editor_container: &Arc<Container>,
9751 editor: &Arc<Editor>,
9752 tui: &Arc<TuiAltScreen>,
9753 lane: &Arc<dyn AgentLane>,
9754 chat: &Arc<Container>,
9755) {
9756 let mut active = match tokio::runtime::Handle::try_current() {
9761 Ok(h) => h
9762 .block_on(async { lane.get_active_tools().await })
9763 .unwrap_or_default(),
9764 Err(_) => Vec::new(),
9765 };
9766 if active.is_empty() {
9771 active = crate::session::BUILTIN_TOOL_NAMES
9772 .iter()
9773 .map(|name| (*name).to_string())
9774 .collect();
9775 }
9776 let mut items: Vec<SelectItem> = Vec::new();
9777 for name in crate::session::BUILTIN_TOOL_NAMES {
9778 let on = active.iter().any(|a| a == name);
9779 let label = if on {
9780 format!("{name} (on)")
9781 } else {
9782 (*name).to_string()
9783 };
9784 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
9785 }
9786 let list = Arc::new(SelectList::new(items, 10));
9787
9788 let active_captured = active.clone();
9790 let state_sel = state.clone();
9791 let ec_sel = editor_container.clone();
9792 let editor_sel = editor.clone();
9793 let tui_sel = tui.clone();
9794 let chat_sel = chat.clone();
9795 let lane_sel = lane.clone();
9796 list.on_select(Arc::new(move |item| {
9797 let mut next = active_captured.clone();
9798 if let Some(pos) = next.iter().position(|a| a == &item.value) {
9799 next.remove(pos);
9800 } else {
9801 next.push(item.value.clone());
9802 }
9803 let on = next.iter().any(|a| a == &item.value);
9804 let lane = lane_sel.clone();
9805 let next_clone = next.clone();
9806 tokio::spawn(async move {
9807 let _ = lane.set_active_tools(next_clone).await;
9808 });
9809 let list_str = if next.is_empty() {
9810 "(none)".to_string()
9811 } else {
9812 next.join(", ")
9813 };
9814 add_note_message(
9815 &chat_sel,
9816 &format!(
9817 "{} {} — active tools: {}",
9818 item.value,
9819 if on { "enabled" } else { "disabled" },
9820 list_str
9821 ),
9822 );
9823 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9824 }));
9825 let state_cancel = state.clone();
9826 let ec_cancel = editor_container.clone();
9827 let editor_cancel = editor.clone();
9828 let tui_cancel = tui.clone();
9829 list.on_cancel(Arc::new(move || {
9830 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9831 }));
9832
9833 open_selector(
9834 state,
9835 editor_container,
9836 editor,
9837 tui,
9838 list,
9839 SelectorKind::Tools,
9840 );
9841}
9842
9843fn open_images_selector(
9847 state: &Arc<TuiState>,
9848 editor_container: &Arc<Container>,
9849 editor: &Arc<Editor>,
9850 tui: &Arc<TuiAltScreen>,
9851 chat: &Arc<Container>,
9852) {
9853 let current = *state.show_images.lock().unwrap();
9854 let items = vec![
9855 SelectItem::new("yes", "Yes").with_description(if current {
9856 "Inline images (current)"
9857 } else {
9858 "Inline images"
9859 }),
9860 SelectItem::new("no", "No").with_description(if current {
9861 "Placeholder only"
9862 } else {
9863 "Placeholder only (current)"
9864 }),
9865 ];
9866 let list = Arc::new(SelectList::new(items, 5));
9867
9868 let state_sel = state.clone();
9869 let ec_sel = editor_container.clone();
9870 let editor_sel = editor.clone();
9871 let tui_sel = tui.clone();
9872 let chat_sel = chat.clone();
9873 list.on_select(Arc::new(move |item| {
9874 let on = item.value == "yes";
9875 *state_sel.show_images.lock().unwrap() = on;
9876 add_note_message(
9877 &chat_sel,
9878 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
9879 );
9880 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
9881 }));
9882 let state_cancel = state.clone();
9883 let ec_cancel = editor_container.clone();
9884 let editor_cancel = editor.clone();
9885 let tui_cancel = tui.clone();
9886 list.on_cancel(Arc::new(move || {
9887 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
9888 }));
9889
9890 open_selector(
9891 state,
9892 editor_container,
9893 editor,
9894 tui,
9895 list,
9896 SelectorKind::Images,
9897 );
9898}
9899
9900fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
9908 let text = editor.get_text();
9909 let cursor = editor_cursor_offset(editor, &text);
9910 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
9911 render_autocomplete(state, suggestions);
9912}
9913
9914fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
9917 let (row, col) = editor.cursor_position();
9918 let mut offset = 0;
9919 for (index, line) in text.split('\n').enumerate() {
9920 if index == row {
9921 return (offset + col.min(line.len())).min(text.len());
9922 }
9923 offset = offset.saturating_add(line.len() + 1);
9924 }
9925 text.len()
9926}
9927
9928fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
9931 let offset = offset.min(text.len());
9932 let mut consumed = 0;
9933 for (row, line) in text.split('\n').enumerate() {
9934 let end = consumed + line.len();
9935 if offset <= end {
9936 editor.set_cursor(row, offset - consumed);
9937 return;
9938 }
9939 consumed = end + 1;
9940 }
9941 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
9942 editor.set_cursor(
9943 last_row,
9944 text.rsplit('\n').next().map(str::len).unwrap_or(0),
9945 );
9946}
9947
9948fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
9950 state.autocomplete_container.clear();
9951 let Some(sugg) = suggestions else {
9952 return;
9953 };
9954 if sugg.items.is_empty() {
9955 return;
9956 }
9957 let accent = state.theme_manager.get().colors.accent;
9960 let muted = state.theme_manager.get().colors.muted;
9961 for (i, item) in sugg
9962 .items
9963 .iter()
9964 .take(state.autocomplete_max_visible)
9965 .enumerate()
9966 {
9967 let prefix = if i == 0 { "→ " } else { " " };
9968 let label = item.display_text();
9969 let line = if i == 0 {
9970 format!(
9971 "{prefix}{} {}",
9972 accent.fg(label),
9973 muted.fg(item.description.as_deref().unwrap_or(""))
9974 )
9975 } else {
9976 format!(
9977 "{prefix}{} {}",
9978 muted.fg(label),
9979 muted.fg(item.description.as_deref().unwrap_or(""))
9980 )
9981 };
9982 state
9983 .autocomplete_container
9984 .add_child(Arc::new(Text::new(line, 1, 0)));
9985 }
9986}
9987
9988fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
9992 let text = editor.get_text();
9993 let cursor = editor_cursor_offset(editor, &text);
9994 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
9995 return false;
9996 };
9997 let Some(top) = sugg.items.first() else {
9998 return false;
9999 };
10000 let start = sugg.start.min(text.len());
10004 let end = sugg.end.min(text.len());
10005 let mut replaced = String::with_capacity(text.len() + top.text.len());
10006 replaced.push_str(&text[..start]);
10007 replaced.push_str(&top.text);
10008 replaced.push_str(&text[end..]);
10011 if top.insert_space && !replaced.ends_with('/') {
10012 replaced.push(' ');
10013 }
10014 let new_cursor = replaced.len().min(
10017 start
10018 + top.text.len()
10019 + if top.insert_space && !top.text.ends_with('/') {
10020 1
10021 } else {
10022 0
10023 },
10024 );
10025 editor.set_text(&replaced);
10026 set_editor_cursor_offset(editor, &replaced, new_cursor);
10027 state.autocomplete_container.clear();
10028 true
10029}
10030
10031fn add_welcome_message(container: &Arc<Container>) {
10037 add_welcome_message_with_capabilities(container, &[], &[]);
10038}
10039
10040fn add_welcome_message_with_capabilities(
10044 container: &Arc<Container>,
10045 active_tools: &[String],
10046 skills: &[String],
10047) {
10048 let c = current_theme().colors;
10049 container.add_child(Arc::new(crate::brand::BrandLockup::new()));
10052 container.add_child(Arc::new(Spacer::new(1)));
10053 let title = c.muted.fg("interactive TUI · library-first agent runtime");
10056 container.add_child(Arc::new(Text::new(title, 1, 0)));
10057 container.add_child(Arc::new(Spacer::new(1)));
10058 container.add_child(Arc::new(Text::new(
10059 c.dim.fg("Type your message and press Enter to send."),
10060 1,
10061 0,
10062 )));
10063 let hint = c
10064 .dim
10065 .fg("Enter send · Shift+Enter newline · Ctrl+C clear · Esc abort · /help");
10066 container.add_child(Arc::new(Text::new(hint, 1, 0)));
10067 container.add_child(Arc::new(Spacer::new(1)));
10068 container.add_child(Arc::new(Text::new(
10069 welcome_capability_line("Tools", active_tools),
10070 1,
10071 0,
10072 )));
10073 container.add_child(Arc::new(Text::new(
10074 welcome_capability_line("Skills", skills),
10075 1,
10076 0,
10077 )));
10078 container.add_child(Arc::new(DynamicBorder::new()));
10079}
10080
10081fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
10084 let colors = current_theme().colors;
10085 let group = Arc::new(Container::new());
10086
10087 for warning in &report.warnings {
10088 let body = format!(
10089 "{}\n{} {}{}",
10090 colors.error.fg(&warning.message),
10091 colors.muted.fg("Run"),
10092 colors.accent.fg(&warning.command),
10093 colors.muted.fg(" to retry.")
10094 );
10095 add_update_panel(&group, "Update Failed", &body, colors.error);
10096 }
10097
10098 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
10099 let body = format!(
10100 "{} {}{}",
10101 colors
10102 .muted
10103 .fg(&format!("New version {} is available. Run", notice.latest)),
10104 colors.accent.fg(¬ice.command),
10105 colors.muted.fg(".")
10106 );
10107 add_update_panel(&group, "Update Available", &body, colors.warning);
10108 }
10109
10110 let package_notices = report
10111 .notices
10112 .iter()
10113 .filter(|notice| notice.name != "rpi")
10114 .collect::<Vec<_>>();
10115 if !package_notices.is_empty() {
10116 let command = package_notices[0].command.as_str();
10117 let mut lines = vec![format!(
10118 "{} {}{}",
10119 colors.muted.fg("Package updates are available. Run"),
10120 colors.accent.fg(command),
10121 colors.muted.fg(".")
10122 )];
10123 lines.push(colors.muted.fg("Packages:"));
10124 lines.extend(
10125 package_notices
10126 .into_iter()
10127 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
10128 );
10129 add_update_panel(
10130 &group,
10131 "Package Updates Available",
10132 &lines.join("\n"),
10133 colors.warning,
10134 );
10135 }
10136
10137 if group.child_count() > 0 {
10141 container.add_child(group);
10142 }
10143}
10144
10145fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
10146 container.add_child(Arc::new(Spacer::new(1)));
10147 container.add_child(Arc::new(DynamicBorder::with_color(color)));
10148 container.add_child(Arc::new(Text::new(
10149 format!("{}\n{body}", color.fg(&tui_bold(title))),
10150 1,
10151 0,
10152 )));
10153 container.add_child(Arc::new(DynamicBorder::with_color(color)));
10154}
10155
10156fn welcome_capability_line(label: &str, names: &[String]) -> String {
10157 let c = current_theme().colors;
10158 let value = if names.is_empty() {
10159 "none".to_string()
10160 } else {
10161 names.join(" · ")
10162 };
10163 format!(
10164 "{} {}",
10165 c.accent.fg(&format!("{label} ({})", names.len())),
10166 c.muted.fg(&value)
10167 )
10168}
10169
10170fn add_help_message(container: &Arc<Container>) {
10172 let c = current_theme().colors;
10173 container.add_child(Arc::new(Text::new(
10177 c.md_heading.fg(&tui_bold("📚 Available Commands")),
10178 1,
10179 0,
10180 )));
10181 container.add_child(Arc::new(Spacer::new(1)));
10182
10183 let cmds: &[(&str, &str)] = &[
10184 ("/help, /?", "Show this help message"),
10185 ("/clear, /new", "Clear the conversation"),
10186 ("/exit, /quit, /q", "Exit the application"),
10187 ("/version, /v", "Show version information"),
10188 ("/changelog", "Show recent release changes"),
10189 ("/model, /m", "Choose a model (live switch)"),
10190 ("/thinking, /think", "Set reasoning depth (selector)"),
10191 ("/tools", "Toggle built-in tools on/off"),
10192 ("/images", "Toggle inline image rendering"),
10193 ("/session", "List saved sessions"),
10194 ("/theme", "Choose a theme (selector)"),
10195 ("/compact", "Compact the conversation"),
10196 ("/copy", "Copy last reply to clipboard"),
10197 ("/hotkeys", "Show keyboard shortcuts"),
10198 ("/armin", "🐾 Easter egg"),
10199 ("/earendil", "Earendil announcement"),
10200 ];
10201 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10202 for (cmd, desc) in cmds {
10203 let row = format!(
10204 " {:<cmd_w$} {} {}",
10205 c.accent.fg(cmd),
10206 c.dim.fg("—"),
10207 c.muted.fg(desc)
10208 );
10209 container.add_child(Arc::new(Text::new(row, 1, 0)));
10210 }
10211 container.add_child(Arc::new(Spacer::new(1)));
10212}
10213
10214fn add_version_message(container: &Arc<Container>) {
10216 let c = current_theme().colors;
10217 container.add_child(Arc::new(Text::new(
10218 c.md_heading.fg(&tui_bold("📦 Version Information")),
10219 1,
10220 0,
10221 )));
10222 container.add_child(Arc::new(Spacer::new(1)));
10223 container.add_child(Arc::new(Text::new(
10226 format!(
10227 " {} {}",
10228 c.muted.fg("rpi-cli"),
10229 c.text.fg(&format!("v{}", crate::VERSION))
10230 ),
10231 1,
10232 0,
10233 )));
10234 container.add_child(Arc::new(Text::new(
10235 format!(
10236 " {}",
10237 c.dim.fg("Rust implementation of pi coding agent TUI")
10238 ),
10239 1,
10240 0,
10241 )));
10242 container.add_child(Arc::new(Spacer::new(1)));
10243}
10244
10245fn add_changelog_message(container: &Arc<Container>) {
10249 let c = current_theme().colors;
10250 container.add_child(Arc::new(Text::new(
10251 c.md_heading.fg(&tui_bold("Recent Changes")),
10252 1,
10253 0,
10254 )));
10255 container.add_child(Arc::new(Spacer::new(1)));
10256 let entries = [
10257 (
10258 "Native parity phase 1",
10259 "models, images, trust, export, and JSON events",
10260 ),
10261 (
10262 "TUI controls",
10263 "external editor, thinking levels, and tool output toggles",
10264 ),
10265 (
10266 "Provider auth",
10267 "OpenAI-compatible API key aliases and gateway headers",
10268 ),
10269 ];
10270 for (release, summary) in entries {
10271 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
10272 container.add_child(Arc::new(Text::new(row, 1, 0)));
10273 }
10274 container.add_child(Arc::new(Text::new(
10275 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
10276 1,
10277 0,
10278 )));
10279 container.add_child(Arc::new(Spacer::new(1)));
10280}
10281
10282fn add_hotkeys_message(container: &Arc<Container>) {
10284 let c = current_theme().colors;
10285 container.add_child(Arc::new(Text::new(
10286 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
10287 1,
10288 0,
10289 )));
10290 container.add_child(Arc::new(Spacer::new(1)));
10291 let keys: &[(&str, &str)] = &[
10292 ("Enter", "Send message"),
10293 ("Shift+Enter", "New line"),
10294 ("Tab", "Accept autocomplete suggestion"),
10295 ("Ctrl+A / Ctrl+E", "Line start / end"),
10296 (
10297 "Ctrl+K / Ctrl+U",
10298 "Kill to end / start of line (Ctrl+Y yanks)",
10299 ),
10300 ("Ctrl+- / Ctrl+R", "Undo / redo"),
10301 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
10302 ("Alt+Backspace", "Kill previous word"),
10303 ("Ctrl+C", "Clear the editor (press twice quickly to exit)"),
10304 ("Ctrl+X", "Copy the editor selection or the last reply"),
10305 ("Esc", "Abort a running prompt"),
10306 ("Ctrl+L", "Open model selector"),
10307 ("Ctrl+P", "Cycle to the next model (live)"),
10308 ("Ctrl+O", "Expand/collapse all tool output"),
10309 ("Ctrl+T", "Show/hide reasoning blocks"),
10310 ("PageUp/Down", "Scroll transcript by one page"),
10311 ("Home / End", "Jump to transcript start / latest output"),
10312 ];
10313 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
10314 for (key, desc) in keys {
10315 let row = format!(
10316 " {:<key_w$} {} {}",
10317 c.accent.fg(key),
10318 c.dim.fg("—"),
10319 c.muted.fg(desc)
10320 );
10321 container.add_child(Arc::new(Text::new(row, 1, 0)));
10322 }
10323 container.add_child(Arc::new(Spacer::new(1)));
10324}
10325
10326fn add_user_message(container: &Arc<Container>, text: &str) {
10332 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
10333 container.add_child(Arc::new(Spacer::new(1)));
10334}
10335
10336fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
10339 let width = 80; chat_container.render(width)
10341}
10342
10343fn format_resume_command(
10348 session_id: &str,
10349 cwd: &std::path::Path,
10350 session_dir: Option<&std::path::Path>,
10351) -> Option<String> {
10352 if session_id.is_empty() {
10353 return None;
10354 }
10355 let mut args = vec![crate::APP_NAME.to_string()];
10356 if let Some(dir) = session_dir {
10357 let default = crate::session::default_session_dir(cwd);
10358 if dir != default.as_path() {
10359 args.push("--session-dir".to_string());
10360 args.push(quote_shell_arg(&dir.to_string_lossy()));
10361 }
10362 }
10363 args.push("--session".to_string());
10364 args.push(session_id.to_string());
10365 Some(args.join(" "))
10366}
10367
10368fn quote_shell_arg(value: &str) -> String {
10371 let needs_quoting = value.is_empty()
10372 || value
10373 .chars()
10374 .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
10375 if needs_quoting {
10376 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
10377 } else {
10378 value.to_string()
10379 }
10380}
10381
10382fn add_error_message(container: &Arc<Container>, text: &str) {
10383 let c = current_theme().colors;
10384 let text = sanitize_error_message(text);
10385 container.add_child(Arc::new(Text::new(
10386 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
10387 1,
10388 0,
10389 )));
10390 container.add_child(Arc::new(Spacer::new(1)));
10391}
10392
10393fn sanitize_error_message(text: &str) -> String {
10398 const MAX_ERROR_CHARS: usize = 16 * 1024;
10399 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
10400 let mut count = 0;
10401 let mut escape_mode = 0u8;
10403 for ch in text.chars() {
10404 if escape_mode != 0 {
10405 match escape_mode {
10406 1 if ch == '[' => escape_mode = 2,
10407 1 if ch == ']' => escape_mode = 3,
10408 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
10409 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
10410 3 if ch == '\x07' => escape_mode = 0,
10411 _ => {}
10412 }
10413 continue;
10414 }
10415 if ch == '\x1b' {
10416 escape_mode = 1;
10417 continue;
10418 }
10419 if count >= MAX_ERROR_CHARS {
10420 result.push_str("…");
10421 break;
10422 }
10423 match ch {
10424 '\n' | '\t' => {
10425 result.push(ch);
10426 count += 1;
10427 }
10428 '\r' => {}
10429 c if c.is_control() => {}
10430 c => {
10431 result.push(c);
10432 count += 1;
10433 }
10434 }
10435 }
10436 if result.trim().is_empty() {
10437 "Provider request failed.".to_string()
10438 } else {
10439 result
10440 }
10441}
10442
10443fn add_note_message(container: &Arc<Container>, text: &str) {
10445 let c = current_theme().colors;
10446 container.add_child(Arc::new(Text::new(
10447 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
10448 1,
10449 0,
10450 )));
10451 container.add_child(Arc::new(Spacer::new(1)));
10452}
10453
10454fn show_context_panel(
10463 chat: &Arc<Container>,
10464 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
10465) {
10466 let skills = resources.skills.as_deref().unwrap_or(&[]);
10467 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
10468 let mut lines: Vec<String> = Vec::new();
10469 lines.push("📂 Discovered resources for this session:".into());
10470
10471 if skills.is_empty() {
10472 lines.push(
10473 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
10474 );
10475 } else {
10476 lines.push(format!(" Skills ({}):", skills.len()));
10477 for s in skills {
10478 let marker = if s.disable_model_invocation == Some(true) {
10479 " [hidden]"
10480 } else {
10481 ""
10482 };
10483 let desc: String = s.description.chars().take(72).collect();
10484 lines.push(format!(" • {}{marker} — {desc}", s.name));
10485 }
10486 }
10487
10488 if templates.is_empty() {
10489 lines.push(
10490 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
10491 );
10492 } else {
10493 lines.push(format!(" Prompt templates ({}):", templates.len()));
10494 for t in templates {
10495 let desc = t
10496 .description
10497 .as_deref()
10498 .unwrap_or("(no description)")
10499 .chars()
10500 .take(72)
10501 .collect::<String>();
10502 lines.push(format!(" • /{} — {desc}", t.name));
10503 }
10504 }
10505 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
10506 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
10507 lines.push(
10508 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
10509 .into(),
10510 );
10511 let body = lines.join("\n");
10512 container_note_block(chat, &body);
10513}
10514
10515fn container_note_block(container: &Arc<Container>, body: &str) {
10517 for line in body.lines() {
10518 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
10519 }
10520 container.add_child(Arc::new(Spacer::new(1)));
10521}
10522
10523pub fn is_tui_supported() -> bool {
10529 std::io::stdout().is_terminal()
10530}
10531
10532#[allow(unused_imports)]
10534use rpi_tui::Color as _Color;
10535
10536#[cfg(test)]
10537mod tests {
10538 use super::*;
10539 use rpi_tui::Component;
10540
10541 #[test]
10545 fn auto_send_cancels_when_the_user_edits_the_draft() {
10546 let now = std::time::Instant::now();
10547 let pending = AutoSendPending {
10548 deadline: now + std::time::Duration::from_secs(2),
10549 text: "hello world".to_string(),
10550 };
10551 assert!(matches!(
10553 auto_send_step(Some(&pending), "hello world", true, now),
10554 AutoSendStep::Wait(_)
10555 ));
10556 assert_eq!(
10559 auto_send_step(Some(&pending), "hello worlds", true, now),
10560 AutoSendStep::Cancel
10561 );
10562 assert_eq!(
10564 auto_send_step(Some(&pending), "", true, now),
10565 AutoSendStep::Cancel
10566 );
10567 assert_eq!(
10569 auto_send_step(Some(&pending), " ", true, now),
10570 AutoSendStep::Cancel
10571 );
10572 }
10573
10574 #[test]
10577 fn auto_send_submits_at_the_deadline_only_while_idle() {
10578 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2000);
10579 let pending = AutoSendPending {
10580 deadline,
10581 text: "go".to_string(),
10582 };
10583 assert!(matches!(
10584 auto_send_step(
10585 Some(&pending),
10586 "go",
10587 true,
10588 deadline - std::time::Duration::from_millis(1)
10589 ),
10590 AutoSendStep::Wait(_)
10591 ));
10592 assert_eq!(
10593 auto_send_step(Some(&pending), "go", true, deadline),
10594 AutoSendStep::Submit
10595 );
10596 assert_eq!(
10599 auto_send_step(Some(&pending), "go", false, deadline),
10600 AutoSendStep::Cancel
10601 );
10602 assert_eq!(
10604 auto_send_step(None, "go", true, deadline),
10605 AutoSendStep::Idle
10606 );
10607 }
10608
10609 #[test]
10612 fn auto_send_footer_label_is_recognisable() {
10613 let label = format!("{AUTO_SEND_PREFIX}2.0s · any key to edit");
10614 assert!(label.starts_with(AUTO_SEND_PREFIX));
10615 assert!(!"Working…".starts_with(AUTO_SEND_PREFIX));
10616 }
10617
10618 #[test]
10619 fn markdown_flag_only_comes_from_an_explicit_true() {
10620 assert!(tool_result_requests_markdown(Some(
10622 &serde_json::json!({"kind": "todo", "markdown": true})
10623 )));
10624 assert!(!tool_result_requests_markdown(None));
10627 assert!(!tool_result_requests_markdown(Some(
10628 &serde_json::json!({"kind": "todo"})
10629 )));
10630 assert!(!tool_result_requests_markdown(Some(
10631 &serde_json::json!({"markdown": false})
10632 )));
10633 assert!(!tool_result_requests_markdown(Some(
10634 &serde_json::json!({"markdown": "true"})
10635 )));
10636 }
10637
10638 fn test_tui_state() -> Arc<TuiState> {
10641 Arc::new(TuiState {
10642 current_assistant: Arc::new(std::sync::Mutex::new(None)),
10643 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10644 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10645 themes_enabled: true,
10646 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10647 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10648 show_terminal_progress: true,
10649 status: std::sync::Mutex::new(RunStatus::Idle),
10650 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10651 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10652 editor_text: rpi_extensions::EditorTextMailbox::new(),
10653 auto_send: std::sync::Mutex::new(None),
10654 last_editor_text: std::sync::Mutex::new(String::new()),
10655 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10656 js_preparation_cancel: std::sync::Mutex::new(None),
10657 user_bash_cancel: std::sync::Mutex::new(None),
10658 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10659 footer: Arc::new(FooterComponent::new()),
10660 status_container: Arc::new(Container::new()),
10661 chat_container: Arc::new(Container::new()),
10662 loader: Arc::new(Loader::new()),
10663 editor: Arc::new(Editor::simple()),
10664 last_assistant_text: std::sync::Mutex::new(String::new()),
10665 active_selector: std::sync::Mutex::new(None),
10666 active_extension_editor: std::sync::Mutex::new(None),
10667 active_extension_input: std::sync::Mutex::new(None),
10668 active_extension_cancel: std::sync::Mutex::new(None),
10669 autocomplete: AutocompleteManager::new(),
10670 autocomplete_container: Arc::new(Container::new()),
10671 autocomplete_max_visible: 5,
10672 pending_images: std::sync::Mutex::new(Vec::new()),
10673 theme_manager: Arc::new(ThemeManager::new()),
10674 tui: None,
10675 current_model_id: std::sync::Mutex::new(String::new()),
10676 show_images: std::sync::Mutex::new(true),
10677 cache_miss_notices: std::sync::Mutex::new(false),
10678 history: std::sync::Mutex::new(Vec::new()),
10679 history_index: std::sync::Mutex::new(-1),
10680 history_draft: std::sync::Mutex::new(None),
10681 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10682 scoped_edit: std::sync::Mutex::new(None),
10683 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10684 extension_session: Arc::new(std::sync::Mutex::new(
10685 rpi_extensions::ExtensionSession::none(),
10686 )),
10687 search: Arc::new(AltScreenSearch::new()),
10688 search_bar: Arc::new(SearchBar::new()),
10689 pending_container: Arc::new(Container::new()),
10690 dequeue_hint: String::new(),
10691 pending_snapshot: std::sync::Mutex::new(
10692 rpi_harness::agent_harness::QueuedMessages::default(),
10693 ),
10694 selection_start: std::sync::Mutex::new(None),
10695 selection_end: std::sync::Mutex::new(None),
10696 })
10697 }
10698
10699 #[test]
10706 fn editor_change_source_separates_tui_writes_from_typing() {
10707 use std::sync::atomic::Ordering;
10708 let state = test_tui_state();
10709
10710 assert!(state.take_editor_change().is_none());
10712
10713 state.editor_text.push(rpi_extensions::EditorTextEdit {
10715 text: "你好".to_string(),
10716 mode: rpi_extensions::EditorTextMode::Replace,
10717 auto_send_ms: Some(2000),
10718 });
10719 assert!(state.drain_editor_text(), "the queued draft must land");
10720 assert!(
10721 state.programmatic_editor_write.load(Ordering::SeqCst),
10722 "a mailbox-applied edit must be marked programmatic"
10723 );
10724
10725 let payload = state
10727 .take_editor_change()
10728 .expect("draft applied ⇒ a change");
10729 assert_eq!(payload["source"], serde_json::json!("extension"));
10730 assert_eq!(payload["chars"], serde_json::json!(2));
10731 assert_eq!(payload["empty"], serde_json::json!(false));
10732
10733 assert!(state.take_editor_change().is_none());
10736 state.editor.set_text("你好啊");
10737 let payload = state.take_editor_change().expect("typing ⇒ a change");
10738 assert_eq!(payload["source"], serde_json::json!("user"));
10739 assert_eq!(payload["chars"], serde_json::json!(3));
10740 }
10741
10742 #[test]
10745 fn programmatic_mark_is_consumed_even_with_no_subscriber() {
10746 use std::sync::atomic::Ordering;
10747 let state = test_tui_state();
10748 assert!(
10749 state
10750 .extension_session
10751 .lock()
10752 .unwrap()
10753 .snapshot_arc()
10754 .is_none(),
10755 "this test relies on nobody subscribing"
10756 );
10757
10758 state.editor_text.push(rpi_extensions::EditorTextEdit {
10759 text: "draft".to_string(),
10760 mode: rpi_extensions::EditorTextMode::Replace,
10761 auto_send_ms: None,
10762 });
10763 state.drain_editor_text();
10764 state.sync_editor_change();
10766 assert!(!state.programmatic_editor_write.load(Ordering::SeqCst));
10768 state.editor.set_text("draft +");
10769 let payload = state.take_editor_change().expect("typing ⇒ a change");
10770 assert_eq!(payload["source"], serde_json::json!("user"));
10771 }
10772
10773 #[test]
10782 fn submit_clear_is_attributed_to_the_host_not_the_user() {
10783 let state = test_tui_state();
10784
10785 state.editor.set_text("/voice auto");
10787 assert_eq!(
10788 state.take_editor_change().unwrap()["source"],
10789 serde_json::json!("user")
10790 );
10791
10792 state.clear_editor_programmatically();
10794 let payload = state.take_editor_change().expect("clearing is a change");
10795 assert_eq!(payload["empty"], serde_json::json!(true));
10796 assert_eq!(
10797 payload["source"],
10798 serde_json::json!("extension"),
10799 "a submit-clear must not be reported as the user typing"
10800 );
10801 }
10802
10803 #[test]
10806 fn user_initiated_clear_stays_a_user_change() {
10807 let state = test_tui_state();
10808 state.editor.set_text("draft");
10809 assert_eq!(
10810 state.take_editor_change().unwrap()["source"],
10811 serde_json::json!("user")
10812 );
10813 state.editor.clear();
10814 let payload = state.take_editor_change().expect("clearing is a change");
10815 assert_eq!(payload["source"], serde_json::json!("user"));
10816 }
10817
10818 #[test]
10819 fn live_panel_repaints_while_a_tool_or_bash_is_running() {
10820 let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
10823 assert!(!transcript_has_live_panel(false, &empty));
10824
10825 let mut tools = HashMap::new();
10828 let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
10829 tool.set_running();
10830 tools.insert("tc1".to_string(), tool);
10831 assert!(transcript_has_live_panel(false, &tools));
10832
10833 tools.get("tc1").unwrap().set_result("done", false);
10836 assert!(!transcript_has_live_panel(false, &tools));
10837 assert!(transcript_has_live_panel(true, &tools));
10838 }
10839
10840 #[test]
10841 fn dequeue_merge_preserves_a_typed_draft() {
10842 assert_eq!(merge_queued_into_draft("queued", ""), "queued");
10843 assert_eq!(merge_queued_into_draft("queued", " "), "queued");
10844 assert_eq!(
10845 merge_queued_into_draft("queued", "typed"),
10846 "queued\n\ntyped"
10847 );
10848 }
10849
10850 #[test]
10851 fn dequeue_places_the_caret_at_the_end_of_the_last_line_in_bytes() {
10852 let editor = Arc::new(Editor::simple());
10856 let text = "排队\n第二行";
10859 set_editor_text_caret_at_end(&editor, text);
10860 assert_eq!(editor.get_text(), text);
10861 assert_eq!(
10862 editor.cursor_position(),
10863 (1, "第二行".len()),
10864 "caret must sit at the end of the last line, in bytes"
10865 );
10866 }
10867
10868 #[test]
10869 fn settings_menu_covers_the_wired_settings_surface() {
10870 let state = test_tui_state();
10871 let settings = crate::settings::Settings::default();
10872 let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
10873 let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
10874 assert_eq!(
10875 keys,
10876 [
10877 "theme",
10878 "model",
10879 "thinking",
10880 "scoped-models",
10881 "hide-thinking",
10882 "show-images",
10883 "cache-miss-notices",
10884 "quiet-startup",
10885 "terminal-progress",
10886 "fullscreen-copy-on-select",
10887 "double-escape-action",
10888 "editor-padding",
10889 "autocomplete-max-items",
10890 "http-idle-timeout",
10891 ]
10892 );
10893 for item in items.iter().filter(|item| item.has_submenu) {
10895 assert!(
10896 item.values.is_empty(),
10897 "{} should be submenu-only",
10898 item.key
10899 );
10900 }
10901 for item in items.iter().filter(|item| !item.has_submenu) {
10903 assert!(!item.values.is_empty(), "{} has no values", item.key);
10904 assert!(
10905 item.values.iter().any(|value| value == &item.value),
10906 "{} current value {} not in {:?}",
10907 item.key,
10908 item.value,
10909 item.values
10910 );
10911 }
10912 assert_eq!(items[1].value, "claude-sonnet-5");
10914 assert_eq!(items[6].value, "false");
10916 assert_eq!(items[8].value, "true");
10917 }
10918
10919 #[test]
10920 fn apply_setting_change_updates_settings_and_live_state() {
10921 let state = test_tui_state();
10922 let mut settings = crate::settings::Settings::default();
10923
10924 let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
10926 assert!(state.hide_thinking());
10927 assert_eq!(settings.hide_thinking_block, Some(true));
10928 assert!(note.expect("user-visible note").contains("hidden"));
10929
10930 apply_setting_change(&state, "show-images", "false", &mut settings);
10932 assert!(!*state.show_images.lock().unwrap());
10933 assert_eq!(settings.show_images, Some(false));
10934
10935 apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
10937 assert!(*state.cache_miss_notices.lock().unwrap());
10938 assert_eq!(settings.show_cache_miss_notices, Some(true));
10939
10940 assert_eq!(
10942 apply_setting_change(&state, "editor-padding", "3", &mut settings),
10943 None
10944 );
10945 assert_eq!(settings.editor_padding_x, Some(3));
10946 apply_setting_change(&state, "double-escape-action", "none", &mut settings);
10947 assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
10948 apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
10949 assert_eq!(settings.autocomplete_max_visible, Some(20));
10950
10951 apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
10953 assert_eq!(settings.http_idle_timeout_ms(), Some(0));
10954 apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
10955 assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
10956
10957 assert_eq!(
10959 apply_setting_change(&state, "nope", "x", &mut settings),
10960 None
10961 );
10962 }
10963
10964 #[test]
10965 fn add_user_message_renders_a_visible_user_bubble() {
10966 let chat = Arc::new(Container::new());
10970 add_user_message(&chat, "hello from the user");
10971 let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
10972 let plain = strip_ansi(&rendered.join("\n"));
10973 assert!(
10974 plain.contains("hello from the user"),
10975 "user bubble must show the prompt text; got: {plain:?}"
10976 );
10977 }
10978
10979 #[test]
10980 fn verbose_overrides_quiet_startup_listing() {
10981 assert!(should_show_startup_listing(false, false));
10982 assert!(should_show_startup_listing(true, false));
10983 assert!(should_show_startup_listing(true, true));
10984 assert!(!should_show_startup_listing(false, true));
10985 }
10986
10987 #[test]
10988 fn resume_command_omits_the_default_session_dir() {
10989 let cwd = std::path::Path::new("/proj/demo");
10990 assert_eq!(format_resume_command("", cwd, None), None);
10991 assert_eq!(
10992 format_resume_command("abc-123", cwd, None).as_deref(),
10993 Some("rpi --session abc-123")
10994 );
10995 let default = crate::session::default_session_dir(cwd);
10997 assert_eq!(
10998 format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
10999 Some("rpi --session abc-123")
11000 );
11001 let custom = std::path::Path::new("/tmp/my sessions");
11003 assert_eq!(
11004 format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
11005 Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
11006 );
11007 }
11008
11009 #[test]
11010 fn quote_shell_arg_quotes_only_when_needed() {
11011 assert_eq!(quote_shell_arg("plain"), "plain");
11012 assert_eq!(quote_shell_arg("with space"), "\"with space\"");
11013 assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
11014 }
11015
11016 #[test]
11017 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
11018 let tmp = tempfile::tempdir().unwrap();
11019 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
11020 let local_root = tmp.path().join("local-package");
11021 std::fs::create_dir_all(git_root.join(".git")).unwrap();
11022 std::fs::create_dir_all(&local_root).unwrap();
11023 std::fs::write(
11024 git_root.join("package.json"),
11025 r#"{"name":"git-demo","version":"1.0.0"}"#,
11026 )
11027 .unwrap();
11028 std::fs::write(
11029 local_root.join("package.json"),
11030 r#"{"name":"local-demo","version":"1.0.0"}"#,
11031 )
11032 .unwrap();
11033 let resources = crate::packages::discover(
11034 tmp.path(),
11035 &[
11036 "git:github.com/example/repo".to_string(),
11037 local_root.to_string_lossy().into_owned(),
11038 ],
11039 );
11040 assert_eq!(resources.packages.len(), 2);
11041
11042 let filtered = git_only_update_resources(resources);
11043
11044 assert_eq!(filtered.packages.len(), 1);
11045 assert!(matches!(
11046 filtered.packages[0].source,
11047 crate::packages::PackageSource::Git
11048 ));
11049 }
11050
11051 #[test]
11052 fn trust_command_uses_the_session_cwd_after_process_chdir() {
11053 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
11054 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
11055 const TEST_NAME: &str =
11056 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
11057
11058 if std::env::var_os(CHILD_ENV).is_some() {
11059 let session_cwd = std::path::PathBuf::from(
11060 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
11061 );
11062 let changed_cwd = std::env::current_dir().unwrap();
11063
11064 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
11065
11066 assert_eq!(
11067 crate::config::project_trust_decision(&session_cwd).unwrap(),
11068 Some(true)
11069 );
11070 assert_eq!(
11071 crate::config::project_trust_decision(&changed_cwd).unwrap(),
11072 None
11073 );
11074 return;
11075 }
11076
11077 let tmp = tempfile::tempdir().unwrap();
11078 let session_cwd = tmp.path().join("session-project");
11079 let changed_cwd = tmp.path().join("extension-cwd");
11080 let agent_dir = tmp.path().join("agent");
11081 std::fs::create_dir_all(&session_cwd).unwrap();
11082 std::fs::create_dir_all(&changed_cwd).unwrap();
11083 std::fs::create_dir_all(&agent_dir).unwrap();
11084
11085 let status = std::process::Command::new(std::env::current_exe().unwrap())
11086 .arg("--exact")
11087 .arg(TEST_NAME)
11088 .arg("--nocapture")
11089 .env(CHILD_ENV, "1")
11090 .env(SESSION_CWD_ENV, &session_cwd)
11091 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
11092 .current_dir(&changed_cwd)
11093 .status()
11094 .unwrap();
11095
11096 assert!(status.success(), "child test process failed: {status}");
11097 }
11098
11099 #[test]
11100 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
11101 let global = crate::settings::Settings {
11102 editor_padding_x: Some(3),
11103 autocomplete_max_visible: Some(6),
11104 hide_thinking_block: Some(false),
11105 quiet_startup: Some(true),
11106 show_terminal_progress: Some(true),
11107 ..Default::default()
11108 };
11109 let rpi_project = crate::settings::Settings {
11110 editor_padding_x: Some(0),
11111 hide_thinking_block: Some(true),
11112 quiet_startup: Some(false),
11113 show_terminal_progress: Some(false),
11114 ..Default::default()
11115 };
11116 let pi_project = crate::settings::Settings {
11117 editor_padding_x: Some(9),
11118 autocomplete_max_visible: Some(12),
11119 quiet_startup: Some(true),
11120 ..Default::default()
11121 };
11122
11123 assert_eq!(
11124 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
11125 TuiStartupSettings {
11126 editor_padding_x: 0,
11127 autocomplete_max_visible: 12,
11128 hide_thinking: true,
11129 quiet_startup: false,
11130 show_terminal_progress: false,
11131 show_images: true,
11132 cache_miss_notices: false,
11133 }
11134 );
11135 }
11136
11137 #[test]
11138 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
11139 let global = crate::settings::Settings {
11140 quiet_startup: Some(false),
11141 ..Default::default()
11142 };
11143 let rpi_project = crate::settings::Settings::default();
11144 let pi_project = crate::settings::Settings {
11145 quiet_startup: Some(true),
11146 ..Default::default()
11147 };
11148
11149 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
11150
11151 assert!(resolved.quiet_startup);
11152 }
11153
11154 #[test]
11155 fn tui_startup_settings_use_global_values_without_project_fields() {
11156 let global = crate::settings::Settings {
11157 editor_padding_x: Some(7),
11158 autocomplete_max_visible: Some(8),
11159 hide_thinking_block: Some(true),
11160 quiet_startup: Some(true),
11161 show_terminal_progress: Some(false),
11162 show_images: Some(false),
11163 show_cache_miss_notices: Some(true),
11164 ..Default::default()
11165 };
11166
11167 assert_eq!(
11168 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
11169 TuiStartupSettings {
11170 editor_padding_x: 7,
11171 autocomplete_max_visible: 8,
11172 hide_thinking: true,
11173 quiet_startup: true,
11174 show_terminal_progress: false,
11175 show_images: false,
11176 cache_miss_notices: true,
11177 }
11178 );
11179 }
11180
11181 #[test]
11182 fn tui_startup_settings_read_native_nested_terminal_block() {
11183 let global = crate::settings::Settings {
11186 terminal: Some(crate::settings::TerminalSettings {
11187 show_images: Some(false),
11188 show_terminal_progress: Some(false),
11189 clear_on_shrink: None,
11190 }),
11191 ..Default::default()
11192 };
11193 let resolved =
11194 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
11195 assert!(!resolved.show_images);
11196 assert!(!resolved.show_terminal_progress);
11197 }
11198
11199 #[test]
11200 fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
11201 let global = crate::settings::Settings {
11202 show_images: Some(true),
11203 terminal: Some(crate::settings::TerminalSettings {
11204 show_images: Some(false),
11205 show_terminal_progress: None,
11206 clear_on_shrink: None,
11207 }),
11208 ..Default::default()
11209 };
11210 let resolved =
11211 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
11212 assert!(resolved.show_images);
11213 }
11214
11215 #[test]
11216 fn tui_startup_settings_ignore_untrusted_project_values() {
11217 let global = crate::settings::Settings {
11218 quiet_startup: Some(false),
11219 show_terminal_progress: Some(true),
11220 ..Default::default()
11221 };
11222 let project = crate::settings::Settings {
11223 quiet_startup: Some(true),
11224 show_terminal_progress: Some(false),
11225 ..Default::default()
11226 };
11227
11228 let resolved = resolve_tui_startup_settings(&global, &[project], false);
11229
11230 assert!(!resolved.quiet_startup);
11231 assert!(resolved.show_terminal_progress);
11232 }
11233
11234 #[test]
11235 fn transcript_page_uses_viewport_with_overlap() {
11236 assert_eq!(transcript_page_size(24), 20);
11237 assert_eq!(transcript_page_size(4), 1);
11238 assert_eq!(transcript_page_size(0), 1);
11239 }
11240
11241 #[test]
11242 fn parse_user_bash_distinguishes_single_and_double_bang() {
11243 assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
11245 assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
11247 assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
11249 assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
11250 }
11251
11252 #[test]
11253 fn parse_user_bash_ignores_bare_bang_and_plain_text() {
11254 assert_eq!(parse_user_bash("!"), None);
11257 assert_eq!(parse_user_bash("!!"), None);
11258 assert_eq!(parse_user_bash("! "), None);
11259 assert_eq!(parse_user_bash("hello"), None);
11260 assert_eq!(parse_user_bash(""), None);
11261 assert_eq!(parse_user_bash("/model"), None);
11263 }
11264
11265 #[test]
11266 fn user_bash_run_slot_guards_and_cancels() {
11267 let state = test_tui_state();
11268 assert!(!state.user_bash_running());
11269 let cancel = state.begin_user_bash();
11270 assert!(state.user_bash_running());
11271 assert!(state.cancel_user_bash());
11274 assert!(state.user_bash_running());
11275 assert!(cancel.is_cancelled());
11276 state.finish_user_bash();
11277 assert!(!state.user_bash_running());
11278 assert!(!state.cancel_user_bash());
11280 }
11281
11282 #[test]
11283 fn key_repeat_is_dispatched_but_release_is_not() {
11284 assert!(should_dispatch_key(KeyEventKind::Press));
11285 assert!(should_dispatch_key(KeyEventKind::Repeat));
11286 assert!(!should_dispatch_key(KeyEventKind::Release));
11287 }
11288
11289 #[test]
11290 fn key_event_encoding_matches_pi_keybinding_protocol() {
11291 let key = |code, modifiers| KeyEvent::new(code, modifiers);
11292 assert_eq!(
11293 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
11294 "\r"
11295 );
11296 assert_eq!(
11297 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
11298 "\x1b[13;2u"
11299 );
11300 assert_eq!(
11301 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
11302 "\x1b[9;2u"
11303 );
11304 assert_eq!(
11305 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
11306 "\x1b[Z"
11307 );
11308 assert_eq!(
11309 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
11310 "\x1b[Z"
11311 );
11312 assert_eq!(
11313 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
11314 "\x03"
11315 );
11316 assert_eq!(
11317 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
11318 "\x0f"
11319 );
11320 assert_eq!(
11321 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
11322 "!"
11323 );
11324 assert_eq!(
11325 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
11326 "1"
11327 );
11328 }
11329
11330 #[test]
11331 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
11332 let bridge = JsDialogBridge::default();
11333 let (sender, receiver) = std_mpsc::channel();
11334 bridge.pending.lock().unwrap().push_back(JsDialogPending {
11335 request: JsDialogRequest {
11336 id: "dialog-1".into(),
11337 method: "input".into(),
11338 title: String::new(),
11339 message: String::new(),
11340 options: Vec::new(),
11341 placeholder: None,
11342 prefill: None,
11343 },
11344 result: sender,
11345 });
11346
11347 bridge.cancel("dialog-1");
11350 assert!(bridge.take_pending().is_none());
11351 assert_eq!(
11352 receiver.recv().unwrap(),
11353 serde_json::json!({ "cancelled": true })
11354 );
11355 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
11356 }
11357
11358 #[test]
11359 fn test_layout_renders_welcome_message() {
11360 let chat = Arc::new(Container::new());
11361 add_welcome_message(&chat);
11362
11363 let scroll = Arc::new(ScrollView::new(
11364 chat.clone(),
11365 ScrollViewOptions {
11366 follow: FollowMode::End,
11367 primary: true,
11368 ..Default::default()
11369 },
11370 ));
11371
11372 let editor = Arc::new(Editor::new(
11373 EditorOptions {
11374 padding_x: 1,
11375 ..Default::default()
11376 },
11377 EditorStyle::default(),
11378 Arc::new(rpi_tui::Keybindings::new()),
11379 ));
11380 let dock = Arc::new(Container::new());
11381 dock.add_child(editor);
11382
11383 let footer = Arc::new(FooterComponent::new());
11384
11385 let root = VStack::from_children(vec![
11386 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
11387 StackChild::Entry(StackEntry::new(dock)),
11388 StackChild::Entry(StackEntry::new(footer)),
11389 ]);
11390
11391 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
11392
11393 let all: String = frame.lines.join("\n");
11394 assert!(
11395 all.contains("rpi"),
11396 "Welcome message not found. Rendered: {}",
11397 all
11398 );
11399 assert!(
11400 all.contains("Type your message"),
11401 "Help text not found. Rendered: {}",
11402 all
11403 );
11404 }
11405
11406 #[test]
11407 fn test_chat_container_has_welcome_content() {
11408 let chat = Arc::new(Container::new());
11409 add_welcome_message_with_capabilities(
11410 &chat,
11411 &["read".into(), "bash".into(), "web_fetch".into()],
11412 &["rust-review".into(), "release".into()],
11413 );
11414
11415 let lines = chat.render(80);
11416 let all: String = lines.join("\n");
11417 let plain = strip_ansi(&all);
11420 assert!(
11421 plain.contains("rpi"),
11422 "Welcome message not in chat container: {:?}",
11423 lines
11424 );
11425 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
11426 assert!(
11427 plain.contains("read · bash · web_fetch"),
11428 "Tool names missing: {plain}"
11429 );
11430 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
11431 assert!(
11432 plain.contains("rust-review · release"),
11433 "Skill names missing: {plain}"
11434 );
11435 }
11436
11437 #[test]
11438 fn welcome_header_renders_the_brand_mark() {
11439 let chat = Arc::new(Container::new());
11440 add_welcome_message(&chat);
11441 let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
11442 let all = lines.join("\n");
11443
11444 assert!(
11447 lines.iter().any(|l| l.contains("██ ██ ██")),
11448 "brand mark missing: {all}"
11449 );
11450 assert!(
11451 all.contains("rpi"),
11452 "the mark must carry the product name: {all}"
11453 );
11454
11455 assert!(
11461 !all.contains('π'),
11462 "the header must not use east-asian-ambiguous glyphs: {all}"
11463 );
11464 }
11465
11466 #[test]
11467 fn update_notices_render_inside_the_transcript() {
11468 let chat = Arc::new(Container::new());
11469 let report = crate::updates::UpdateReport {
11470 notices: vec![
11471 crate::updates::UpdateNotice {
11472 name: "rpi".into(),
11473 current: "0.1.10".into(),
11474 latest: "0.1.11".into(),
11475 command: "rpi update".into(),
11476 },
11477 crate::updates::UpdateNotice {
11478 name: "rpi-search".into(),
11479 current: "0.1.0".into(),
11480 latest: "0.1.1".into(),
11481 command: "rpi pi-package update".into(),
11482 },
11483 ],
11484 warnings: vec![crate::updates::UpdateWarning {
11485 message: "The previously scheduled rpi update failed: access denied".into(),
11486 command: "rpi update".into(),
11487 }],
11488 };
11489
11490 add_update_notices(&chat, &report);
11491
11492 assert_eq!(chat.child_count(), 1);
11493 let plain = strip_ansi(&chat.render(80).join("\n"));
11494 assert!(plain.contains("Update Failed"), "{plain}");
11495 assert!(
11496 plain.contains("rpi update failed: access denied"),
11497 "{plain}"
11498 );
11499 assert!(plain.contains("Update Available"), "{plain}");
11500 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
11501 assert!(plain.contains("rpi update"), "{plain}");
11502 assert!(plain.contains("Package Updates Available"), "{plain}");
11503 assert!(plain.contains("rpi pi-package update"), "{plain}");
11504 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
11505 }
11506
11507 #[test]
11508 fn empty_update_report_does_not_add_transcript_content() {
11509 let chat = Arc::new(Container::new());
11510
11511 add_update_notices(&chat, &crate::updates::UpdateReport::default());
11512
11513 assert_eq!(chat.child_count(), 0);
11514 }
11515
11516 #[test]
11517 fn welcome_capabilities_show_empty_state() {
11518 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
11519 assert_eq!(plain, "Skills (0) none");
11520 }
11521
11522 #[test]
11523 fn completed_stream_reconciles_the_final_tail_before_detaching() {
11524 let component = Arc::new(AssistantMessageComponent::new(
11525 AssistantMessageOptions::default(),
11526 ));
11527 component.set_streaming(true);
11528 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
11529
11530 let current = Arc::new(Mutex::new(Some(component.clone())));
11531 let cached = Mutex::new("partial response".to_string());
11532 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
11533 final_message.content = vec![Content::text(
11534 "partial response with the previously missing final tail",
11535 )];
11536 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
11537
11538 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
11539
11540 assert!(current.lock().unwrap().is_none());
11541 assert_eq!(
11542 cached.lock().unwrap().as_str(),
11543 "partial response with the previously missing final tail"
11544 );
11545 let rendered = strip_ansi(&component.render(100).join("\n"));
11546 assert!(
11547 rendered.contains("previously missing final tail"),
11548 "{rendered}"
11549 );
11550 }
11551
11552 #[test]
11558 fn tab_accept_suggestion_reflects_in_next_render() {
11559 use rpi_tui::render_layout_frame;
11560
11561 let editor = Arc::new(Editor::new(
11562 EditorOptions {
11563 padding_x: 1,
11564 ..Default::default()
11565 },
11566 EditorStyle::default(),
11567 Arc::new(rpi_tui::Keybindings::new()),
11568 ));
11569 editor.set_focused(true);
11570 let editor_container = Arc::new(Container::new());
11571 editor_container.add_child(editor.clone());
11572 let autocomplete_container = Arc::new(Container::new());
11573 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
11574 let dock = Arc::new(VStack::from_children(vec![
11575 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
11576 StackChild::Entry(
11577 StackEntry::new(editor_container.clone())
11578 .shrink(0)
11579 .min_size(3),
11580 ),
11581 StackChild::Entry(StackEntry::new(footer)),
11582 ]));
11583
11584 let manager = AutocompleteManager::new();
11586 let mut combined = CombinedAutocompleteProvider::new();
11587 combined.add_provider(Arc::new(
11588 SlashCommandAutocompleteProvider::with_default_commands(),
11589 ));
11590 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
11591 manager.set_provider(Arc::new(combined));
11592 editor.insert("/mo");
11595 assert_eq!(editor.cursor_position(), (0, 3));
11596
11597 let frame_before = render_layout_frame(dock.clone(), 80, 10);
11598 assert!(
11599 frame_before.lines.iter().any(|l| l.contains("/mo")),
11600 "precondition: editor shows the typed prefix. Frame rows:\n{}",
11601 frame_before
11602 .lines
11603 .iter()
11604 .map(|l| format!(" [{l}]"))
11605 .collect::<Vec<_>>()
11606 .join("\n")
11607 );
11608
11609 let text = editor.get_text();
11611 let cursor = editor_cursor_offset(&editor, &text);
11612 let sugg = manager
11613 .get_suggestions(&text, cursor)
11614 .expect("slash suggestions for /mo");
11615 let top = sugg.items.first().expect("at least one suggestion");
11616 let start = sugg.start.min(text.len());
11617 let end = sugg.end.min(text.len());
11618 let mut replaced = String::new();
11619 replaced.push_str(&text[..start]);
11620 replaced.push_str(&top.text);
11621 replaced.push_str(&text[end..]);
11622 if top.insert_space && !replaced.ends_with('/') {
11623 replaced.push(' ');
11624 }
11625 let new_cursor = start + top.text.len();
11626 editor.set_text(&replaced);
11627 set_editor_cursor_offset(&editor, &replaced, new_cursor);
11628 autocomplete_container.clear();
11629 assert_eq!(editor.get_text(), "/model");
11630
11631 let frame_after = render_layout_frame(dock, 80, 10);
11633 let all: String = frame_after.lines.join("\n");
11634 assert!(
11635 all.contains("/model"),
11636 "completed text missing from next render. Got:\n{all}"
11637 );
11638 let editor_line = frame_after
11642 .lines
11643 .iter()
11644 .find(|l| l.contains("/model"))
11645 .expect("editor row with completed text");
11646 assert!(
11647 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
11648 "caret must follow the full completed text. Got: {editor_line:?}"
11649 );
11650 }
11651
11652 #[test]
11653 fn multiline_autocomplete_preserves_row_and_column() {
11654 let editor = Arc::new(Editor::simple());
11655 editor.set_text("first\n/mo");
11656 editor.set_cursor(1, 3);
11657
11658 let text = editor.get_text();
11659 assert_eq!(editor_cursor_offset(&editor, &text), 9);
11660
11661 let replaced = "first\n/model";
11662 editor.set_text(replaced);
11663 set_editor_cursor_offset(&editor, replaced, 12);
11664 assert_eq!(editor.cursor_position(), (1, 6));
11665 }
11666
11667 #[test]
11668 fn test_slash_command_dispatch() {
11669 let registry = build_builtin_registry();
11674
11675 let resolves_to = |token: &str, canonical: &str| {
11677 let found = registry.find(token).expect("{token} should resolve");
11678 assert_eq!(
11679 found.name(),
11680 canonical,
11681 "{token} resolved to {} (expected {canonical})",
11682 found.name()
11683 );
11684 };
11685
11686 resolves_to("/help", "/help");
11687 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
11689 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
11693 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
11695 resolves_to("/hotkeys", "/hotkeys");
11696 resolves_to("/model", "/model");
11697 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
11699 resolves_to("/session", "/session");
11700 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
11702 resolves_to("/copy", "/copy");
11703 resolves_to("/thinking", "/thinking");
11704 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
11706 resolves_to("/images", "/images");
11707 resolves_to("/armin", "/armin");
11708 resolves_to("/earendil", "/earendil");
11709 resolves_to("/context", "/context");
11710 resolves_to("/settings", "/settings");
11712 resolves_to("/name", "/name");
11713 resolves_to("/export", "/export");
11714
11715 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
11717 }
11718
11719 #[test]
11720
11721 fn test_registry_visible_entries_cover_dispatch() {
11722 let registry = build_builtin_registry();
11727 let names: Vec<String> = registry
11728 .visible_entries()
11729 .iter()
11730 .map(|c| c.name.clone())
11731 .collect();
11732 for recognized in [
11733 "/help",
11734 "/clear",
11735 "/new",
11736 "/exit",
11737 "/quit",
11738 "/version",
11739 "/changelog",
11740 "/model",
11741 "/session",
11742 "/theme",
11743 "/compact",
11744 "/copy",
11745 "/hotkeys",
11746 "/tools",
11747 "/images",
11748 "/thinking",
11749 "/usage",
11750 "/armin",
11751 "/earendil",
11752 ] {
11753 assert!(
11754 names.contains(&recognized.to_string()),
11755 "{recognized} missing from autocomplete list"
11756 );
11757 }
11758 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
11760 assert!(
11761 !names.contains(&hidden.to_string()),
11762 "{hidden} should be hidden from autocomplete"
11763 );
11764 }
11765 }
11766
11767 #[test]
11768 fn test_agent_event_mapping_creates_assistant_and_tool() {
11769 use rpi_ai::types::{
11772 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
11773 ToolCall, ToolCallType, Usage,
11774 };
11775
11776 let state = Arc::new(TuiState {
11777 current_assistant: Arc::new(std::sync::Mutex::new(None)),
11778 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11779 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11780 themes_enabled: true,
11781 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11782 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11783 show_terminal_progress: true,
11784 status: std::sync::Mutex::new(RunStatus::Idle),
11785 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11786 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11787 editor_text: rpi_extensions::EditorTextMailbox::new(),
11788 auto_send: std::sync::Mutex::new(None),
11789 last_editor_text: std::sync::Mutex::new(String::new()),
11790 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11791 js_preparation_cancel: std::sync::Mutex::new(None),
11792 user_bash_cancel: std::sync::Mutex::new(None),
11793 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11794 footer: Arc::new(FooterComponent::new()),
11795 status_container: Arc::new(Container::new()),
11796 chat_container: Arc::new(Container::new()),
11797 loader: Arc::new(Loader::new()),
11798 editor: Arc::new(Editor::simple()),
11799 last_assistant_text: std::sync::Mutex::new(String::new()),
11800 active_selector: std::sync::Mutex::new(None),
11801 active_extension_editor: std::sync::Mutex::new(None),
11802 active_extension_input: std::sync::Mutex::new(None),
11803 active_extension_cancel: std::sync::Mutex::new(None),
11804 autocomplete: AutocompleteManager::new(),
11805 autocomplete_container: Arc::new(Container::new()),
11806 autocomplete_max_visible: 5,
11807 pending_images: std::sync::Mutex::new(Vec::new()),
11808 theme_manager: Arc::new(ThemeManager::new()),
11809 tui: None,
11810 current_model_id: std::sync::Mutex::new(String::new()),
11811 show_images: std::sync::Mutex::new(true),
11812 cache_miss_notices: std::sync::Mutex::new(false),
11813 history: std::sync::Mutex::new(Vec::new()),
11814 history_index: std::sync::Mutex::new(-1),
11815 history_draft: std::sync::Mutex::new(None),
11816 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11817 scoped_edit: std::sync::Mutex::new(None),
11818 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11819 extension_session: Arc::new(std::sync::Mutex::new(
11820 rpi_extensions::ExtensionSession::none(),
11821 )),
11822 search: Arc::new(AltScreenSearch::new()),
11823 search_bar: Arc::new(SearchBar::new()),
11824 pending_container: Arc::new(Container::new()),
11825 dequeue_hint: String::new(),
11826 pending_snapshot: std::sync::Mutex::new(
11827 rpi_harness::agent_harness::QueuedMessages::default(),
11828 ),
11829 selection_start: std::sync::Mutex::new(None),
11830 selection_end: std::sync::Mutex::new(None),
11831 });
11832
11833 let assistant = AssistantMessage {
11837 role: rpi_ai::types::AssistantRole,
11838 content: vec![
11839 Content::Thinking(ThinkingContent {
11840 kind: ThinkingContentType,
11841 thinking: "Reasoning about the reply.".into(),
11842 thinking_signature: None,
11843 redacted: false,
11844 }),
11845 Content::Text(TextContent {
11846 kind: TextContentType,
11847 text: "Hello.".into(),
11848 text_signature: None,
11849 }),
11850 Content::ToolCall(ToolCall {
11851 kind: ToolCallType,
11852 id: "tc1".into(),
11853 name: "bash".into(),
11854 arguments: serde_json::json!({"command": "echo hi"}),
11855 thought_signature: None,
11856 namespace: None,
11857 }),
11858 ],
11859 api: rpi_ai::Api::AnthropicMessages,
11860 provider: "anthropic".into(),
11861 model: "claude-sonnet-5".into(),
11862 response_model: None,
11863 response_id: None,
11864 usage: Usage::zero(),
11865 stop_reason: StopReason::Stop,
11866 deferred: None,
11867 error_message: None,
11868 raw_stop_reason: None,
11869 end_turn: None,
11870 timestamp: 0,
11871 };
11872
11873 let comp = Arc::new(AssistantMessageComponent::new(
11876 AssistantMessageOptions::default(),
11877 ));
11878 comp.set_streaming(true);
11879 comp.update_blocks(&assistant_blocks(&assistant));
11880 let chat = Arc::new(Container::new());
11881 chat.add_child(comp.clone());
11882 *state.current_assistant.lock().unwrap() = Some(comp);
11883
11884 for c in &assistant.content {
11886 if let Content::ToolCall(tc) = c {
11887 let mut tools = state.tool_components.lock().unwrap();
11888 if !tools.contains_key(&tc.id) {
11889 let tc_comp = Arc::new(ToolExecutionComponent::new(
11890 &tc.name,
11891 &tc.arguments.to_string(),
11892 ));
11893 tc_comp.set_running();
11894 chat.add_child(tc_comp.clone());
11895 tools.insert(tc.id.clone(), tc_comp);
11896 }
11897 }
11898 }
11899
11900 let rendered = chat.render(80);
11904 let joined: String = rendered.join("\n");
11905 assert!(
11906 joined.contains("Hello."),
11907 "assistant text not rendered: {joined}"
11908 );
11909 assert!(
11910 joined.contains("Reasoning about the reply."),
11911 "thinking block not rendered: {joined}"
11912 );
11913 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
11914 assert!(state.current_assistant.lock().unwrap().is_some());
11915
11916 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
11918 ended.set_result("hi", false);
11919 assert!(state.tool_components.lock().unwrap().is_empty());
11920
11921 assert!(state.try_start_working());
11925 assert!(
11926 !state.try_start_working(),
11927 "a second submit must be rejected"
11928 );
11929 state.set_status(RunStatus::Idle);
11930 state.set_status(RunStatus::Working);
11931 assert!(state.editor.working().is_some());
11933 assert_eq!(state.status_container.child_count(), 0);
11934 assert!(state.footer.get_status().is_empty());
11935 state.show_retry(3, 10, 8_000);
11936 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
11937 assert!(retry_status.contains("Retrying (3/10)"));
11938 state.set_status(RunStatus::Working);
11939 {
11940 let mut bash = state.bash_components.lock().unwrap();
11941 bash.insert(
11942 "bash-1".into(),
11943 Arc::new(BashExecutionComponent::new("one")),
11944 );
11945 bash.insert(
11946 "bash-2".into(),
11947 Arc::new(BashExecutionComponent::new("two")),
11948 );
11949 }
11950 state.sync_working_loader_with_bash();
11951 assert_eq!(state.status_container.child_count(), 0);
11952 state.bash_components.lock().unwrap().remove("bash-1");
11953 state.sync_working_loader_with_bash();
11954 assert_eq!(state.status_container.child_count(), 0);
11955 state.bash_components.lock().unwrap().remove("bash-2");
11956 state.sync_working_loader_with_bash();
11957 assert_eq!(state.status_container.child_count(), 0);
11960 assert!(state.editor.working().is_some());
11961
11962 state.set_status(RunStatus::Aborting);
11963 assert_eq!(state.status_container.child_count(), 0);
11964 assert!(!state.loader.is_running());
11965 }
11966
11967 #[test]
11968 fn fresh_launch_does_not_restore_old_history() {
11969 let fresh = Args::default();
11970 assert!(!launch_restores_history(&fresh));
11971
11972 let continued = Args {
11973 continue_session: true,
11974 ..Args::default()
11975 };
11976 assert!(launch_restores_history(&continued));
11977
11978 let selected = Args {
11979 session: Some("session-id".into()),
11980 ..Args::default()
11981 };
11982 assert!(launch_restores_history(&selected));
11983 }
11984
11985 #[test]
11986 fn context_badge_ignores_aborted_and_error_turns() {
11987 use rpi_ai::types::{Api, StopReason, Usage};
11988
11989 let footer = Arc::new(FooterComponent::new());
11990 footer.set_context_window(100_000);
11991
11992 let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
11994 ok.stop_reason = StopReason::Stop;
11995 ok.usage = Usage {
11996 input: 25_000,
11997 ..Usage::zero()
11998 };
11999 record_context_usage(&footer, &ok);
12000 let stats = strip_ansi(&footer.render(120).join("\n"));
12001 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
12002
12003 let mut aborted =
12005 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
12006 aborted.stop_reason = StopReason::Aborted;
12007 aborted.usage = Usage {
12008 input: 90_000,
12009 ..Usage::zero()
12010 };
12011 record_context_usage(&footer, &aborted);
12012 let stats = strip_ansi(&footer.render(120).join("\n"));
12013 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
12014
12015 let mut errored =
12017 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
12018 errored.stop_reason = StopReason::Error;
12019 errored.usage = Usage {
12020 input: 90_000,
12021 ..Usage::zero()
12022 };
12023 record_context_usage(&footer, &errored);
12024 let stats = strip_ansi(&footer.render(120).join("\n"));
12025 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
12026 }
12027
12028 #[test]
12029 fn test_short_model_name() {
12030 assert_eq!(
12031 short_model_name("anthropic:claude-sonnet-5"),
12032 "claude-sonnet-5"
12033 );
12034 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
12035 }
12036
12037 #[test]
12038 fn model_selector_items_are_deduplicated_and_provider_qualified() {
12039 use rpi_ai::{Api, Model};
12040
12041 let mut gateway = Model::new(
12042 "gpt-5.6-sol",
12043 "GPT 5.6 Sol",
12044 Api::OpenaiCompletions,
12045 "routeryo-copy",
12046 "https://gateway.example.com",
12047 );
12048 let duplicate = gateway.clone();
12049 let anthropic = Model::new(
12050 "claude-sonnet-5",
12051 "Claude Sonnet 5",
12052 Api::AnthropicMessages,
12053 "anthropic",
12054 "https://api.anthropic.com",
12055 );
12056 gateway.headers = Some(std::collections::BTreeMap::from([(
12057 "authorization".into(),
12058 "Bearer test".into(),
12059 )]));
12060
12061 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
12062 assert_eq!(items.len(), 2);
12063 assert_eq!(items[0].value, "gpt-5.6-sol");
12064 assert_eq!(items[0].label, "GPT 5.6 Sol");
12065 assert_eq!(
12066 items[0].description.as_deref(),
12067 Some("routeryo-copy/gpt-5.6-sol (current)")
12068 );
12069 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
12070 }
12071
12072 #[test]
12073 fn model_selector_search_text_covers_provider_name_and_qualified_id() {
12074 use rpi_ai::{Api, Model};
12075
12076 let anthropic = Model::new(
12077 "claude-sonnet-5",
12078 "Claude Sonnet 5",
12079 Api::AnthropicMessages,
12080 "anthropic",
12081 "https://api.anthropic.com",
12082 );
12083 let items = model_selector_items(&[anthropic], "");
12084 let search = items[0].search_key();
12085 assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
12088 assert!(search.contains("Claude Sonnet 5"), "{search}");
12089
12090 use rpi_tui::fuzzy_filter;
12093 for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
12094 let matched = fuzzy_filter(&items, query, |item| item.search_key());
12095 assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
12096 }
12097 }
12098
12099 #[test]
12100 fn model_selector_match_accepts_bare_and_qualified_ids() {
12101 use rpi_ai::{Api, Model};
12102
12103 let gateway = Model::new(
12104 "gpt-5.6-sol",
12105 "GPT 5.6 Sol",
12106 Api::OpenaiCompletions,
12107 "routeryo-copy",
12108 "https://gateway.example.com",
12109 );
12110 let anthropic = Model::new(
12111 "claude-sonnet-5",
12112 "Claude Sonnet 5",
12113 Api::AnthropicMessages,
12114 "anthropic",
12115 "https://api.anthropic.com",
12116 );
12117 let catalog = [gateway, anthropic];
12118 assert_eq!(
12119 find_model_selector_match(&catalog, "gpt-5.6-sol")
12120 .unwrap()
12121 .provider,
12122 "routeryo-copy"
12123 );
12124 assert_eq!(
12125 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
12126 .unwrap()
12127 .id,
12128 "gpt-5.6-sol"
12129 );
12130 assert_eq!(
12131 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
12132 .unwrap()
12133 .id,
12134 "claude-sonnet-5"
12135 );
12136 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
12137 }
12138
12139 #[test]
12140 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
12141 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
12142
12143 let failed = AssistantMessage {
12144 role: AssistantRole,
12145 content: Vec::new(),
12146 api: rpi_ai::Api::AnthropicMessages,
12147 provider: "anthropic".into(),
12148 model: "claude-sonnet-5".into(),
12149 response_model: None,
12150 response_id: None,
12151 usage: Usage::zero(),
12152 stop_reason: StopReason::Error,
12153 deferred: None,
12154 error_message: Some("upstream returned 401".into()),
12155 raw_stop_reason: None,
12156 end_turn: None,
12157 timestamp: 0,
12158 };
12159 assert_eq!(
12160 assistant_error_text(&failed).as_deref(),
12161 Some("upstream returned 401")
12162 );
12163
12164 let mut no_detail = failed;
12165 no_detail.error_message = Some(" ".into());
12166 assert_eq!(
12167 assistant_error_text(&no_detail).as_deref(),
12168 Some("Provider request failed.")
12169 );
12170
12171 let mut aborted = no_detail;
12172 aborted.stop_reason = StopReason::Aborted;
12173 aborted.error_message = Some("abort error: Request aborted".into());
12174 assert_eq!(
12175 assistant_error_text(&aborted).as_deref(),
12176 Some("abort error: Request aborted")
12177 );
12178
12179 aborted.error_message = None;
12180 assert_eq!(
12181 assistant_error_text(&aborted).as_deref(),
12182 Some("Request aborted.")
12183 );
12184 }
12185
12186 #[test]
12187 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
12188 assert_eq!(
12189 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
12190 "405\nMethod Not Allowed"
12191 );
12192 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
12193 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
12194 let long = "x".repeat(20_000);
12195 let cleaned = sanitize_error_message(&long);
12196 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
12197 assert!(cleaned.ends_with('…'));
12198 }
12199
12200 #[test]
12201 fn test_cycle_next_model_wraps_around() {
12202 use rpi_ai::{Api, Model};
12203 let mk = |id: &str| {
12204 Model::new(
12205 id,
12206 id,
12207 Api::AnthropicMessages,
12208 "anthropic",
12209 "https://api.anthropic.com",
12210 )
12211 };
12212 let catalog = [mk("a"), mk("b"), mk("c")];
12213 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
12215 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
12216 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
12218 let empty: Vec<Model> = vec![];
12220 assert!(cycle_next_model(&empty, "a").is_none());
12221 }
12222
12223 #[test]
12224 fn test_cycle_prev_model_wraps_around() {
12225 use rpi_ai::{Api, Model};
12226 let mk = |id: &str| {
12227 Model::new(
12228 id,
12229 id,
12230 Api::AnthropicMessages,
12231 "anthropic",
12232 "https://api.anthropic.com",
12233 )
12234 };
12235 let catalog = [mk("a"), mk("b"), mk("c")];
12236 assert_eq!(cycle_prev_model(&catalog, "b").unwrap().id, "a");
12238 assert_eq!(cycle_prev_model(&catalog, "a").unwrap().id, "c");
12239 assert_eq!(cycle_prev_model(&catalog, "zzz").unwrap().id, "a");
12241 let empty: Vec<Model> = vec![];
12243 assert!(cycle_prev_model(&empty, "a").is_none());
12244 }
12245
12246 #[test]
12247 fn test_autocomplete_slash_suggestions_render() {
12248 let state = Arc::new(TuiState {
12251 current_assistant: Arc::new(std::sync::Mutex::new(None)),
12252 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12253 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12254 themes_enabled: true,
12255 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12256 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12257 show_terminal_progress: true,
12258 status: std::sync::Mutex::new(RunStatus::Idle),
12259 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12260 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12261 editor_text: rpi_extensions::EditorTextMailbox::new(),
12262 auto_send: std::sync::Mutex::new(None),
12263 last_editor_text: std::sync::Mutex::new(String::new()),
12264 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12265 js_preparation_cancel: std::sync::Mutex::new(None),
12266 user_bash_cancel: std::sync::Mutex::new(None),
12267 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12268 footer: Arc::new(FooterComponent::new()),
12269 status_container: Arc::new(Container::new()),
12270 chat_container: Arc::new(Container::new()),
12271 loader: Arc::new(Loader::new()),
12272 editor: Arc::new(Editor::simple()),
12273 last_assistant_text: std::sync::Mutex::new(String::new()),
12274 active_selector: std::sync::Mutex::new(None),
12275 active_extension_editor: std::sync::Mutex::new(None),
12276 active_extension_input: std::sync::Mutex::new(None),
12277 active_extension_cancel: std::sync::Mutex::new(None),
12278 autocomplete: AutocompleteManager::new(),
12279 autocomplete_container: Arc::new(Container::new()),
12280 autocomplete_max_visible: 5,
12281 pending_images: std::sync::Mutex::new(Vec::new()),
12282 theme_manager: Arc::new(ThemeManager::new()),
12283 tui: None,
12284 current_model_id: std::sync::Mutex::new(String::new()),
12285 show_images: std::sync::Mutex::new(true),
12286 cache_miss_notices: std::sync::Mutex::new(false),
12287 history: std::sync::Mutex::new(Vec::new()),
12288 history_index: std::sync::Mutex::new(-1),
12289 history_draft: std::sync::Mutex::new(None),
12290 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12291 scoped_edit: std::sync::Mutex::new(None),
12292 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12293 extension_session: Arc::new(std::sync::Mutex::new(
12294 rpi_extensions::ExtensionSession::none(),
12295 )),
12296 search: Arc::new(AltScreenSearch::new()),
12297 search_bar: Arc::new(SearchBar::new()),
12298 pending_container: Arc::new(Container::new()),
12299 dequeue_hint: String::new(),
12300 pending_snapshot: std::sync::Mutex::new(
12301 rpi_harness::agent_harness::QueuedMessages::default(),
12302 ),
12303 selection_start: std::sync::Mutex::new(None),
12304 selection_end: std::sync::Mutex::new(None),
12305 });
12306 {
12307 let mut combined = CombinedAutocompleteProvider::new();
12308 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12309 build_builtin_registry().visible_entries(),
12310 )));
12311 state.autocomplete.set_provider(Arc::new(combined));
12312 }
12313
12314 let editor = Arc::new(Editor::simple());
12315 editor.set_text("/he");
12316 editor.set_cursor(0, 3);
12317 refresh_autocomplete(&state, &editor);
12318 let lines = state.autocomplete_container.render(80);
12319 let joined: String = lines.join("\n");
12320 assert!(
12321 joined.contains("/help"),
12322 "slash suggestions not rendered: {joined}"
12323 );
12324
12325 editor.set_text("hello");
12327 editor.set_cursor(0, 5);
12328 refresh_autocomplete(&state, &editor);
12329 assert!(state.autocomplete_container.render(80).is_empty());
12330 }
12331
12332 #[test]
12333 fn test_select_list_swap_restores_editor() {
12334 let state = Arc::new(TuiState {
12338 current_assistant: Arc::new(std::sync::Mutex::new(None)),
12339 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12340 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12341 themes_enabled: true,
12342 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12343 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12344 show_terminal_progress: true,
12345 status: std::sync::Mutex::new(RunStatus::Idle),
12346 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12347 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12348 editor_text: rpi_extensions::EditorTextMailbox::new(),
12349 auto_send: std::sync::Mutex::new(None),
12350 last_editor_text: std::sync::Mutex::new(String::new()),
12351 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12352 js_preparation_cancel: std::sync::Mutex::new(None),
12353 user_bash_cancel: std::sync::Mutex::new(None),
12354 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12355 footer: Arc::new(FooterComponent::new()),
12356 status_container: Arc::new(Container::new()),
12357 chat_container: Arc::new(Container::new()),
12358 loader: Arc::new(Loader::new()),
12359 editor: Arc::new(Editor::simple()),
12360 last_assistant_text: std::sync::Mutex::new(String::new()),
12361 active_selector: std::sync::Mutex::new(None),
12362 active_extension_editor: std::sync::Mutex::new(None),
12363 active_extension_input: std::sync::Mutex::new(None),
12364 active_extension_cancel: std::sync::Mutex::new(None),
12365 autocomplete: AutocompleteManager::new(),
12366 autocomplete_container: Arc::new(Container::new()),
12367 autocomplete_max_visible: 5,
12368 pending_images: std::sync::Mutex::new(Vec::new()),
12369 theme_manager: Arc::new(ThemeManager::new()),
12370 tui: None,
12371 current_model_id: std::sync::Mutex::new(String::new()),
12372 show_images: std::sync::Mutex::new(true),
12373 cache_miss_notices: std::sync::Mutex::new(false),
12374 history: std::sync::Mutex::new(Vec::new()),
12375 history_index: std::sync::Mutex::new(-1),
12376 history_draft: std::sync::Mutex::new(None),
12377 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12378 scoped_edit: std::sync::Mutex::new(None),
12379 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12380 extension_session: Arc::new(std::sync::Mutex::new(
12381 rpi_extensions::ExtensionSession::none(),
12382 )),
12383 search: Arc::new(AltScreenSearch::new()),
12384 search_bar: Arc::new(SearchBar::new()),
12385 pending_container: Arc::new(Container::new()),
12386 dequeue_hint: String::new(),
12387 pending_snapshot: std::sync::Mutex::new(
12388 rpi_harness::agent_harness::QueuedMessages::default(),
12389 ),
12390 selection_start: std::sync::Mutex::new(None),
12391 selection_end: std::sync::Mutex::new(None),
12392 });
12393 let editor_container = Arc::new(Container::new());
12394 let editor = Arc::new(Editor::simple());
12395 editor_container.add_child(editor.clone());
12396 assert!(!state.selector_open());
12397
12398 let tui_terminal = Box::new(ProcessTerminal::new());
12399 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
12400 let list = Arc::new(SelectList::new(
12401 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
12402 5,
12403 ));
12404 open_selector(
12405 &state,
12406 &editor_container,
12407 &editor,
12408 &tui,
12409 list,
12410 SelectorKind::Theme,
12411 );
12412 assert!(state.selector_open());
12413 assert_eq!(editor_container.child_count(), 1);
12415
12416 close_selector(&state, &editor_container, &editor, &tui);
12417 assert!(!state.selector_open());
12418 assert_eq!(editor_container.child_count(), 1);
12420 }
12421
12422 #[test]
12423 fn test_message_history_browse_restores_draft() {
12424 let state = Arc::new(TuiState {
12428 current_assistant: Arc::new(std::sync::Mutex::new(None)),
12429 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12430 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12431 themes_enabled: true,
12432 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12433 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12434 show_terminal_progress: true,
12435 status: std::sync::Mutex::new(RunStatus::Idle),
12436 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12437 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12438 editor_text: rpi_extensions::EditorTextMailbox::new(),
12439 auto_send: std::sync::Mutex::new(None),
12440 last_editor_text: std::sync::Mutex::new(String::new()),
12441 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12442 js_preparation_cancel: std::sync::Mutex::new(None),
12443 user_bash_cancel: std::sync::Mutex::new(None),
12444 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12445 footer: Arc::new(FooterComponent::new()),
12446 status_container: Arc::new(Container::new()),
12447 chat_container: Arc::new(Container::new()),
12448 loader: Arc::new(Loader::new()),
12449 editor: Arc::new(Editor::simple()),
12450 last_assistant_text: std::sync::Mutex::new(String::new()),
12451 active_selector: std::sync::Mutex::new(None),
12452 active_extension_editor: std::sync::Mutex::new(None),
12453 active_extension_input: std::sync::Mutex::new(None),
12454 active_extension_cancel: std::sync::Mutex::new(None),
12455 autocomplete: AutocompleteManager::new(),
12456 autocomplete_container: Arc::new(Container::new()),
12457 autocomplete_max_visible: 5,
12458 pending_images: std::sync::Mutex::new(Vec::new()),
12459 theme_manager: Arc::new(ThemeManager::new()),
12460 tui: None,
12461 current_model_id: std::sync::Mutex::new(String::new()),
12462 show_images: std::sync::Mutex::new(true),
12463 cache_miss_notices: std::sync::Mutex::new(false),
12464 history: std::sync::Mutex::new(Vec::new()),
12465 history_index: std::sync::Mutex::new(-1),
12466 history_draft: std::sync::Mutex::new(None),
12467 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12468 scoped_edit: std::sync::Mutex::new(None),
12469 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12470 extension_session: Arc::new(std::sync::Mutex::new(
12471 rpi_extensions::ExtensionSession::none(),
12472 )),
12473 search: Arc::new(AltScreenSearch::new()),
12474 search_bar: Arc::new(SearchBar::new()),
12475 pending_container: Arc::new(Container::new()),
12476 dequeue_hint: String::new(),
12477 pending_snapshot: std::sync::Mutex::new(
12478 rpi_harness::agent_harness::QueuedMessages::default(),
12479 ),
12480 selection_start: std::sync::Mutex::new(None),
12481 selection_end: std::sync::Mutex::new(None),
12482 });
12483 let editor = Arc::new(Editor::simple());
12484
12485 push_history(&state, "first message");
12486 push_history(&state, "second message");
12487 push_history(&state, "second message");
12489 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
12491 assert_eq!(state.history.lock().unwrap()[0], "second message");
12492
12493 editor.set_text("half-typed");
12495 editor.set_cursor(0, 11);
12496
12497 navigate_history(&state, &editor, -1);
12499 assert_eq!(editor.get_text(), "second message");
12500 assert_eq!(*state.history_index.lock().unwrap(), 0);
12501 navigate_history(&state, &editor, -1);
12503 assert_eq!(editor.get_text(), "first message");
12504 assert_eq!(*state.history_index.lock().unwrap(), 1);
12505 navigate_history(&state, &editor, -1);
12507 assert_eq!(editor.get_text(), "first message");
12508 navigate_history(&state, &editor, 1);
12510 assert_eq!(editor.get_text(), "second message");
12511 navigate_history(&state, &editor, 1);
12513 assert_eq!(editor.get_text(), "half-typed");
12514 assert_eq!(*state.history_index.lock().unwrap(), -1);
12515 }
12516
12517 #[test]
12518 fn test_accept_top_suggestion_replaces_prefix() {
12519 let state = Arc::new(TuiState {
12521 current_assistant: Arc::new(std::sync::Mutex::new(None)),
12522 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12523 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
12524 themes_enabled: true,
12525 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
12526 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
12527 show_terminal_progress: true,
12528 status: std::sync::Mutex::new(RunStatus::Idle),
12529 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
12530 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
12531 editor_text: rpi_extensions::EditorTextMailbox::new(),
12532 auto_send: std::sync::Mutex::new(None),
12533 last_editor_text: std::sync::Mutex::new(String::new()),
12534 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
12535 js_preparation_cancel: std::sync::Mutex::new(None),
12536 user_bash_cancel: std::sync::Mutex::new(None),
12537 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
12538 footer: Arc::new(FooterComponent::new()),
12539 status_container: Arc::new(Container::new()),
12540 chat_container: Arc::new(Container::new()),
12541 loader: Arc::new(Loader::new()),
12542 editor: Arc::new(Editor::simple()),
12543 last_assistant_text: std::sync::Mutex::new(String::new()),
12544 active_selector: std::sync::Mutex::new(None),
12545 active_extension_editor: std::sync::Mutex::new(None),
12546 active_extension_input: std::sync::Mutex::new(None),
12547 active_extension_cancel: std::sync::Mutex::new(None),
12548 autocomplete: AutocompleteManager::new(),
12549 autocomplete_container: Arc::new(Container::new()),
12550 autocomplete_max_visible: 5,
12551 pending_images: std::sync::Mutex::new(Vec::new()),
12552 theme_manager: Arc::new(ThemeManager::new()),
12553 tui: None,
12554 current_model_id: std::sync::Mutex::new(String::new()),
12555 show_images: std::sync::Mutex::new(true),
12556 cache_miss_notices: std::sync::Mutex::new(false),
12557 history: std::sync::Mutex::new(Vec::new()),
12558 history_index: std::sync::Mutex::new(-1),
12559 history_draft: std::sync::Mutex::new(None),
12560 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
12561 scoped_edit: std::sync::Mutex::new(None),
12562 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
12563 extension_session: Arc::new(std::sync::Mutex::new(
12564 rpi_extensions::ExtensionSession::none(),
12565 )),
12566 search: Arc::new(AltScreenSearch::new()),
12567 search_bar: Arc::new(SearchBar::new()),
12568 pending_container: Arc::new(Container::new()),
12569 dequeue_hint: String::new(),
12570 pending_snapshot: std::sync::Mutex::new(
12571 rpi_harness::agent_harness::QueuedMessages::default(),
12572 ),
12573 selection_start: std::sync::Mutex::new(None),
12574 selection_end: std::sync::Mutex::new(None),
12575 });
12576 {
12577 let mut combined = CombinedAutocompleteProvider::new();
12578 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
12579 build_builtin_registry().visible_entries(),
12580 )));
12581 state.autocomplete.set_provider(Arc::new(combined));
12582 }
12583 let editor = Arc::new(Editor::simple());
12584 editor.set_text("/he");
12585 editor.set_cursor(0, 3);
12586 refresh_autocomplete(&state, &editor);
12587 let accepted = accept_top_suggestion(&state, &editor);
12588 assert!(accepted, "should accept the top suggestion");
12589 let text = editor.get_text();
12590 assert!(
12591 text.starts_with("/help"),
12592 "editor text should start with /help, got {text}"
12593 );
12594 }
12595
12596 #[test]
12597 fn configured_key_parser_supports_native_notation() {
12598 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
12599 assert_eq!(combo.code, KeyCode::Char('g'));
12600 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
12601 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
12602 assert_eq!(combo.code, KeyCode::BackTab);
12603 }
12604
12605 #[test]
12606 fn double_escape_trigger_has_half_second_window() {
12607 let now = std::time::Instant::now();
12608 assert!(!double_escape_trigger(None, now));
12609 assert!(double_escape_trigger(
12610 Some(now - std::time::Duration::from_millis(500)),
12611 now
12612 ));
12613 assert!(!double_escape_trigger(
12614 Some(now - std::time::Duration::from_millis(501)),
12615 now
12616 ));
12617 }
12618
12619 #[test]
12620 fn ctrl_c_clears_first_and_only_exits_inside_the_window() {
12621 let now = std::time::Instant::now();
12625 assert_eq!(ctrl_c_action(None, now), CtrlCAction::Clear);
12626 assert_eq!(
12627 ctrl_c_action(Some(now - std::time::Duration::from_millis(500)), now),
12628 CtrlCAction::Exit
12629 );
12630 assert_eq!(
12632 ctrl_c_action(Some(now - std::time::Duration::from_millis(501)), now),
12633 CtrlCAction::Clear
12634 );
12635 }
12636
12637 #[test]
12638 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
12639 use std::sync::atomic::{AtomicUsize, Ordering};
12645
12646 let editor = rpi_tui::Editor::simple();
12647 let submits = Arc::new(AtomicUsize::new(0));
12648 let submits_for_cb = submits.clone();
12649 editor.on_submit(Arc::new(move |_text: &str| {
12650 submits_for_cb.fetch_add(1, Ordering::SeqCst);
12651 }));
12652
12653 let now = std::time::Instant::now();
12656 let mut last_text_key_at: Option<std::time::Instant> = None;
12657 let block = ["l1", "l2", "l3"];
12658 for (index, line) in block.iter().enumerate() {
12659 for ch in line.chars() {
12660 editor.insert(&ch.to_string());
12661 last_text_key_at = Some(now);
12662 }
12663 if index + 1 < block.len() {
12664 let more_queued = true;
12666 assert!(
12667 enter_is_paste_burst(last_text_key_at, now, more_queued),
12668 "pasted newline {index} must not submit"
12669 );
12670 editor.insert("\n");
12671 last_text_key_at = Some(now);
12672 }
12673 }
12674 assert_eq!(editor.get_text(), "l1\nl2\nl3");
12675 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
12676
12677 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
12679 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
12680 editor.handle_key(crossterm::event::KeyEvent::new(
12681 KeyCode::Enter,
12682 KeyModifiers::NONE,
12683 ));
12684 assert_eq!(
12685 submits.load(Ordering::SeqCst),
12686 1,
12687 "typed Enter submits once"
12688 );
12689 }
12690
12691 #[test]
12692 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
12693 let editor = rpi_tui::Editor::simple();
12696 editor.insert("alpha\r\nbeta\r\n");
12697 assert_eq!(editor.get_text(), "alpha\nbeta\n");
12698 assert!(!editor.get_text().contains('\r'));
12699 }
12700
12701 #[test]
12702 fn pasted_enter_is_not_a_submit() {
12703 let now = std::time::Instant::now();
12704
12705 assert!(enter_is_paste_burst(None, now, true));
12707
12708 assert!(enter_is_paste_burst(
12711 Some(now - std::time::Duration::from_millis(1)),
12712 now,
12713 false
12714 ));
12715 assert!(enter_is_paste_burst(
12716 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
12717 now,
12718 false
12719 ));
12720
12721 assert!(!enter_is_paste_burst(None, now, false));
12723 assert!(!enter_is_paste_burst(
12724 Some(now - PASTE_BURST_GAP),
12725 now,
12726 false
12727 ));
12728 assert!(!enter_is_paste_burst(
12729 Some(now - std::time::Duration::from_millis(120)),
12730 now,
12731 false
12732 ));
12733 }
12734
12735 #[test]
12736 fn paste_probe_ignores_the_enter_keys_own_release() {
12737 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
12738 let release =
12739 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
12740
12741 let mut queued = vec![Event::Key(release)].into_iter();
12744 let (more, stashed) = probe_paste_input(None, || queued.next());
12745 assert!(!more, "a key release alone is not queued paste input");
12746 assert!(stashed.is_none());
12747
12748 let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
12751 let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
12752 let (more, stashed) = probe_paste_input(None, || queued.next());
12753 assert!(more, "a queued key press is paste continuation");
12754 assert!(matches!(stashed, Some(Event::Key(_))));
12755
12756 let (more, stashed) = probe_paste_input(None, || None);
12758 assert!(!more);
12759 assert!(stashed.is_none());
12760
12761 let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
12763 panic!("must not read when an event is already stashed")
12764 });
12765 assert!(more);
12766 assert!(matches!(stashed, Some(Event::Key(_))));
12767 }
12768
12769 #[test]
12772 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
12773 let flat = rpi_extensions::UiDialogRequest {
12774 request_id: "r1".into(),
12775 tool_call_id: Some("c1".into()),
12776 ui: serde_json::json!({
12777 "kind": "selector",
12778 "id": "proxy_port",
12779 "header": "Proxy",
12780 "question": "端口?",
12781 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
12782 "allowMultiple": true,
12783 "allowFreeform": false,
12784 "suggest": "1080"
12785 }),
12786 raw: serde_json::json!({}),
12787 };
12788 let prompt = parse_ask_user_prompt(&flat);
12789 assert_eq!(prompt.question_id, "proxy_port");
12790 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
12791 assert_eq!(prompt.options.len(), 2);
12792 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
12793 assert!(prompt.allow_multiple);
12794 assert!(!prompt.allow_freeform);
12795 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
12796
12797 let alias = rpi_extensions::UiDialogRequest {
12798 ui: serde_json::json!({
12799 "questions": [{"id": "q", "question": "Q?", "options": []}]
12800 }),
12801 ..flat.clone()
12802 };
12803 let prompt = parse_ask_user_prompt(&alias);
12804 assert_eq!(prompt.question_id, "q");
12805 assert!(prompt.allow_freeform, "no options defaults to freeform");
12806
12807 let confirm = rpi_extensions::UiDialogRequest {
12808 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
12809 ..flat.clone()
12810 };
12811 let prompt = parse_ask_user_prompt(&confirm);
12812 assert_eq!(prompt.kind, "confirm");
12813 assert_eq!(prompt.options.len(), 2);
12814 assert_eq!(prompt.options[0].0, "Yes");
12815 }
12816
12817 #[test]
12818 fn ask_user_bridge_answers_and_cancels_by_request_id() {
12819 let mailbox = rpi_extensions::UiDialogMailbox::new();
12820 mailbox.attach();
12821 let bridge = AskUserBridge::new(mailbox.clone());
12822
12823 mailbox
12825 .handle(serde_json::json!({
12826 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
12827 "ui": {"kind": "input", "question": "Port?"}
12828 }))
12829 .unwrap();
12830 mailbox
12831 .handle(serde_json::json!({
12832 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
12833 "ui": {"kind": "input", "question": "Level?"}
12834 }))
12835 .unwrap();
12836
12837 let first = bridge.take_pending().expect("r1 visible");
12838 assert_eq!(first.request_id, "r1");
12839 assert!(
12840 bridge.take_pending().is_none(),
12841 "only one request occupies the input slot"
12842 );
12843 bridge.respond("r1", serde_json::json!({"text": "7897"}));
12844 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
12845
12846 let second = bridge.take_pending().expect("r2 visible");
12847 assert_eq!(second.request_id, "r2");
12848 bridge.cancel("r2");
12849 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
12850
12851 mailbox
12853 .handle(serde_json::json!({
12854 "op": "open", "requestId": "r3",
12855 "ui": {"kind": "input", "question": "X?"}
12856 }))
12857 .unwrap();
12858 bridge.cancel_all();
12859 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
12860
12861 bridge.shutdown();
12863 assert!(mailbox
12864 .handle(serde_json::json!({
12865 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
12866 }))
12867 .is_err());
12868 }
12869}