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