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