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
5878fn reconcile_streamed_assistant_completion(
5881 current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
5882 last_assistant_text: &Mutex<String>,
5883 final_message: Option<&AssistantMessage>,
5884) {
5885 let final_blocks = final_message.map(assistant_blocks);
5886 let final_text = final_message.map(assistant_text);
5887 let component = current_assistant.lock().unwrap().take();
5888
5889 if let Some(component) = component {
5890 if let Some(blocks) = final_blocks.as_deref() {
5891 component.update_blocks(blocks);
5892 }
5893 component.set_streaming(false);
5894 }
5895
5896 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
5897 *last_assistant_text.lock().unwrap() = text;
5898 }
5899}
5900
5901async fn run_prompt_streaming(
5906 lane: &Arc<dyn AgentLane>,
5907 prompt: &str,
5908 tui: &Arc<TuiAltScreen>,
5909 state: &Arc<TuiState>,
5910 streaming: bool,
5911 js: Option<&crate::js_extensions::JsExtensionSession>,
5912 dialog_bridge: &JsDialogBridge,
5913 args: &Args,
5914 images: Vec<rpi_ai::types::ImageContent>,
5915) {
5916 state.set_status(RunStatus::Working);
5925 tui.request_render(false);
5926 if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
5927 state.set_status(RunStatus::Idle);
5928 tui.request_render(false);
5929 return;
5930 }
5931
5932 let outcome = lane.prompt_text(prompt, images).await;
5933
5934 if streaming {
5935 let final_message = match &outcome {
5940 Ok(result) => match &result.outcome {
5941 HarnessRunOutcome::Completed { final_message, .. }
5942 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
5943 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
5944 HarnessRunOutcome::Suspended { .. } => None,
5945 },
5946 Err(_) => None,
5947 };
5948 reconcile_streamed_assistant_completion(
5949 &state.current_assistant,
5950 &state.last_assistant_text,
5951 final_message,
5952 );
5953 }
5954
5955 state.set_status(RunStatus::Idle);
5956
5957 match outcome {
5958 Ok(result) => match &result.outcome {
5959 HarnessRunOutcome::Failed {
5960 error,
5961 final_message,
5962 ..
5963 } => {
5964 let already_rendered = final_message.is_some();
5968 if !already_rendered {
5969 let msg = final_message
5970 .as_ref()
5971 .and_then(|m| m.error_message.clone())
5972 .unwrap_or_else(|| format!("{error:?}"));
5973 add_error_message(&state.chat_container, &msg);
5974 }
5975 }
5976 HarnessRunOutcome::Suspended { .. } => {
5977 add_error_message(
5978 &state.chat_container,
5979 "Run suspended (deferred) — resume is not supported in v1.",
5980 );
5981 }
5982 HarnessRunOutcome::Aborted { final_message, .. } => {
5983 if !streaming {
5986 add_error_message(&state.chat_container, "Request aborted.");
5987 let _ = final_message; }
5989 }
5990 HarnessRunOutcome::Completed { final_message, .. } => {
5991 if !streaming {
5992 let text = assistant_text(final_message);
5993 if !text.is_empty() {
5994 add_assistant_message_blocking(
5995 &state.chat_container,
5996 &text,
5997 state.markdown_transformer(),
5998 );
5999 *state.last_assistant_text.lock().unwrap() = text;
6000 }
6001 }
6002 }
6003 },
6004 Err(e) => {
6005 add_error_message(&state.chat_container, &e.to_string());
6006 }
6007 }
6008
6009 tui.request_render(false);
6010}
6011
6012async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6017 state.set_status(RunStatus::Working);
6018 tui.request_render(false);
6019 match lane.compact(None).await {
6020 Ok(_) => {
6021 add_note_message(&state.chat_container, "Conversation compacted.");
6022 }
6023 Err(e) => {
6024 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6025 }
6026 }
6027 state.set_status(RunStatus::Idle);
6028 tui.request_render(false);
6029}
6030
6031fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6035 let text = state.last_assistant_text.lock().unwrap().clone();
6036 if text.is_empty() {
6037 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6038 return;
6039 }
6040 if copy_to_clipboard(&text) {
6041 add_note_message(chat, "Copied last reply to the clipboard.");
6042 } else {
6043 let preview: String = text.chars().take(200).collect();
6046 add_note_message(
6047 chat,
6048 &format!(
6049 "Clipboard unavailable. Last reply: {preview}{}",
6050 if text.chars().count() > 200 {
6051 "…"
6052 } else {
6053 ""
6054 }
6055 ),
6056 );
6057 }
6058}
6059
6060#[cfg(feature = "clipboard")]
6063fn copy_to_clipboard(text: &str) -> bool {
6064 match arboard::Clipboard::new() {
6065 Ok(mut cb) => cb.set_text(text).is_ok(),
6066 Err(_) => false,
6067 }
6068}
6069
6070#[cfg(not(feature = "clipboard"))]
6071fn copy_to_clipboard(_text: &str) -> bool {
6072 false
6073}
6074
6075#[cfg(feature = "clipboard")]
6079fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6080 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6081 let image = match clipboard.get_image() {
6082 Ok(image) => image,
6083 Err(_) => return Ok(None),
6084 };
6085 let width =
6086 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6087 let height =
6088 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6089 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6090 return Err("clipboard image dimensions are outside the supported range".into());
6091 }
6092 let mut bytes = Vec::new();
6093 {
6094 let mut encoder = png::Encoder::new(&mut bytes, width, height);
6095 encoder.set_color(png::ColorType::Rgba);
6096 encoder.set_depth(png::BitDepth::Eight);
6097 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6098 writer
6099 .write_image_data(&image.bytes)
6100 .map_err(|e| e.to_string())?;
6101 }
6102 Ok(Some(rpi_ai::types::ImageContent {
6103 kind: rpi_ai::types::ImageContentType,
6104 data: base64::engine::general_purpose::STANDARD.encode(bytes),
6105 mime_type: "image/png".into(),
6106 }))
6107}
6108
6109fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6110 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6111 let mut options = ImageOptions::default();
6112 options.width = Some(48);
6113 options.alt_text = Some("Attached image".into());
6114 chat.add_child(Arc::new(Image::from_data(bytes, options)));
6115 chat.add_child(Arc::new(Spacer::new(1)));
6116 }
6117}
6118
6119#[cfg(not(feature = "clipboard"))]
6120fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6121 Ok(None)
6122}
6123
6124fn add_assistant_message_blocking(
6131 container: &Arc<Container>,
6132 text: &str,
6133 transformer: Option<MarkdownTransformer>,
6134) {
6135 if text.is_empty() {
6136 return;
6137 }
6138 let msg = Arc::new(AssistantMessageComponent::new(
6139 AssistantMessageOptions::default(),
6140 ));
6141 if let Some(t) = &transformer {
6142 msg.set_markdown_transformer(Some(t.clone()));
6143 }
6144 msg.update_text(text);
6145 container.add_child(msg);
6146 container.add_child(Arc::new(Spacer::new(1)));
6147}
6148
6149async fn drain_agent_events(
6157 mut rx: broadcast::Receiver<AgentEvent>,
6158 tui: Arc<TuiAltScreen>,
6159 state: Arc<TuiState>,
6160 chat: Arc<Container>,
6161) {
6162 loop {
6163 match rx.recv().await {
6164 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6165 Err(broadcast::error::RecvError::Lagged(_)) => {
6166 continue;
6169 }
6170 Err(broadcast::error::RecvError::Closed) => break,
6171 }
6172 }
6173}
6174
6175async fn handle_agent_event(
6178 event: AgentEvent,
6179 tui: &Arc<TuiAltScreen>,
6180 state: &Arc<TuiState>,
6181 chat: &Arc<Container>,
6182) {
6183 match event {
6184 AgentEvent::AgentStart => {
6185 state.set_status(RunStatus::Working);
6186 tui.request_render(false);
6187 }
6188
6189 AgentEvent::AgentEnd { .. } => {
6190 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6192 comp.set_streaming(false);
6193 }
6194 state.set_status(RunStatus::Idle);
6195 tui.request_render(false);
6196 }
6197
6198 AgentEvent::RetryScheduled {
6199 attempt,
6200 max_retries,
6201 delay_ms,
6202 ..
6203 } => {
6204 state.show_retry(attempt, max_retries, delay_ms);
6205 tui.request_render(false);
6206 }
6207
6208 AgentEvent::TurnStart => {
6209 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6212 comp.set_streaming(false);
6213 }
6214 }
6215
6216 AgentEvent::TurnEnd {
6217 message,
6218 tool_results,
6219 } => {
6220 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6222 if let AgentMessage::Assistant(a) = &message {
6223 comp.update_blocks(&assistant_blocks(a));
6224 }
6225 comp.set_streaming(false);
6226 }
6227 let tools = state.tool_components.lock().unwrap();
6232 for tr in &tool_results {
6233 if tools.contains_key(&tr.tool_call_id) {
6234 let _ = tr;
6237 }
6238 }
6239 drop(tools);
6240 tui.request_render(false);
6241 }
6242
6243 AgentEvent::MessageStart { message } => match message {
6244 AgentMessage::Assistant(a) => {
6245 let comp = Arc::new(AssistantMessageComponent::new(
6246 AssistantMessageOptions::default(),
6247 ));
6248 if let Some(t) = state.markdown_transformer() {
6253 comp.set_markdown_transformer(Some(t));
6254 }
6255 comp.set_hide_thinking(state.hide_thinking());
6256 comp.set_streaming(true);
6257 comp.update_blocks(&assistant_blocks(&a));
6260 chat.add_child(comp.clone());
6261 chat.add_child(Arc::new(Spacer::new(1)));
6264 *state.current_assistant.lock().unwrap() = Some(comp);
6265 tui.request_render(false);
6266 }
6267 AgentMessage::Custom(custom) => {
6268 let payload = serde_json::json!({
6269 "customType": custom.role,
6270 "content": custom.content,
6271 "details": custom.data,
6272 "expanded": false,
6273 "outputPad": 1,
6274 });
6275 if let Some(component) = extension_message_component(
6276 &state.extension_session,
6277 &custom.role,
6278 &payload,
6279 state.markdown_transformer(),
6280 ) {
6281 chat.add_child(component);
6282 chat.add_child(Arc::new(Spacer::new(1)));
6283 tui.request_render(false);
6284 } else {
6285 add_note_message(chat, &custom_message_fallback(&custom));
6286 tui.request_render(false);
6287 }
6288 }
6289 _ => {}
6292 },
6293
6294 AgentEvent::MessageUpdate {
6295 message,
6296 assistant_message_event,
6297 } => {
6298 if let AgentMessage::Assistant(a) = &message {
6299 let text = assistant_text(a);
6300 let mut saw_bash_tool_call = false;
6301 for c in &a.content {
6306 if let Content::ToolCall(tc) = c {
6307 if tc.name.trim().is_empty() {
6311 continue;
6312 }
6313 if tc.name == "bash" {
6314 let command = tc
6320 .arguments
6321 .get("command")
6322 .and_then(|v| v.as_str())
6323 .unwrap_or("");
6324 if command.trim().is_empty() {
6331 continue;
6332 }
6333 saw_bash_tool_call = true;
6334 let mut bash = state.bash_components.lock().unwrap();
6335 if !bash.contains_key(&tc.id) {
6336 let comp = Arc::new(BashExecutionComponent::new(command));
6337 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6338 chat.add_child(comp.clone());
6339 bash.insert(tc.id.clone(), comp);
6340 }
6341 } else {
6342 let mut tools = state.tool_components.lock().unwrap();
6343 if !tools.contains_key(&tc.id) {
6344 let comp = Arc::new(ToolExecutionComponent::new(
6345 &tc.name,
6346 &tc.arguments.to_string(),
6347 ));
6348 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6349 comp.set_running();
6350 chat.add_child(comp.clone());
6351 tools.insert(tc.id.clone(), comp);
6352 }
6353 }
6354 }
6355 }
6356 if saw_bash_tool_call {
6361 state.sync_working_loader_with_bash();
6362 }
6363 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6365 comp.update_blocks(&assistant_blocks(a));
6368 }
6369 *state.last_assistant_text.lock().unwrap() = text;
6370 tui.request_render(false);
6371 }
6372 }
6373
6374 AgentEvent::MessageEnd { message } => {
6375 if let AgentMessage::Assistant(a) = &message {
6376 let text = assistant_text(a);
6377 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6378 comp.update_blocks(&assistant_blocks(a));
6379 comp.set_streaming(false);
6380 }
6381 if !text.is_empty() {
6383 *state.last_assistant_text.lock().unwrap() = text;
6384 }
6385 let usage = &a.usage;
6391 let prev_input = *state.last_input_tokens.lock().unwrap();
6392 if prev_input > 0
6393 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6394 && usage.cache_read == 0
6395 {
6396 add_note_message(
6397 &state.chat_container,
6398 &format!(
6399 "Cache miss: {} tokens re-billed",
6400 format_tokens(usage.input)
6401 ),
6402 );
6403 }
6404 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6405 add_note_message(chat, &text);
6406 }
6407 if let Some(error) = assistant_error_text(a) {
6412 add_error_message(chat, &error);
6413 }
6414 *state.last_input_tokens.lock().unwrap() = usage.input;
6415 }
6416 tui.request_render(false);
6417 }
6418
6419 AgentEvent::ToolExecutionStart {
6420 tool_call_id,
6421 tool_name,
6422 args,
6423 } => {
6424 if tool_name.trim().is_empty() {
6427 return;
6428 }
6429 if tool_name == "bash" {
6430 let command = args
6435 .get("command")
6436 .and_then(|v| v.as_str())
6437 .unwrap_or("")
6438 .to_string();
6439 if command.trim().is_empty() {
6443 return;
6444 }
6445 let mut bash_map = state.bash_components.lock().unwrap();
6446 if let Some(existing) = bash_map.get(&tool_call_id) {
6447 existing.set_command(&command);
6452 } else {
6453 let comp = Arc::new(BashExecutionComponent::new(command));
6454 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6455 chat.add_child(comp.clone());
6456 bash_map.insert(tool_call_id.clone(), comp);
6457 }
6458 } else {
6459 let _comp = {
6460 let mut tools = state.tool_components.lock().unwrap();
6461 if let Some(existing) = tools.get(&tool_call_id) {
6462 existing.set_args(&args.to_string());
6463 existing.clone()
6464 } else {
6465 let comp =
6466 Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6467 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6468 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6473 comp.set_skill_name(skill);
6474 }
6475 comp.set_running();
6476 chat.add_child(comp.clone());
6477 tools.insert(tool_call_id.clone(), comp.clone());
6478 comp
6479 }
6480 };
6481 }
6482 state.sync_working_loader_with_bash();
6483 tui.request_render(false);
6484 }
6485
6486 AgentEvent::ToolExecutionUpdate {
6487 tool_call_id,
6488 tool_name,
6489 args,
6490 partial_result,
6491 } => {
6492 if tool_name.trim().is_empty() {
6493 return;
6494 }
6495 let partial_text = tool_result_text(&partial_result);
6496 let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
6497 if tool_name == "bash" {
6498 let chunk = partial_text;
6503 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
6504 if has_partial_payload {
6505 bash.append_output(&chunk);
6506 }
6507 } else if has_partial_payload {
6508 }
6514 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
6515 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6516 comp.set_skill_name(skill);
6517 }
6518 if has_partial_payload {
6521 comp.set_result(&partial_text, false);
6522 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
6523 }
6524 } else if has_partial_payload {
6525 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6530 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6531 if let Some(skill) = skill_tool_name(&tool_name, &args) {
6532 comp.set_skill_name(skill);
6533 }
6534 comp.set_running();
6535 comp.set_result(&partial_text, false);
6536 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
6537 chat.add_child(comp.clone());
6538 state
6539 .tool_components
6540 .lock()
6541 .unwrap()
6542 .insert(tool_call_id.clone(), comp.clone());
6543 }
6544 state.sync_working_loader_with_bash();
6545 tui.request_render(false);
6546 }
6547
6548 AgentEvent::ToolExecutionEnd {
6549 tool_call_id,
6550 tool_name,
6551 result,
6552 is_error,
6553 } => {
6554 if tool_name.trim().is_empty() {
6555 return;
6556 }
6557 if tool_name == "bash" {
6558 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
6559 if let Some(bash) = bash {
6560 finalize_bash(&bash, &result, is_error);
6561 } else {
6562 let command = result
6565 .details
6566 .get("command")
6567 .and_then(|v| v.as_str())
6568 .unwrap_or("")
6569 .to_string();
6570 if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
6571 state.sync_working_loader_with_bash();
6572 tui.request_render(false);
6573 return;
6574 }
6575 let comp = Arc::new(BashExecutionComponent::new(command));
6576 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6577 comp.append_output(&tool_result_text(&result));
6578 finalize_bash(&comp, &result, is_error);
6579 chat.add_child(comp);
6580 }
6581 } else {
6582 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
6583 if let Some(comp) = comp {
6584 comp.set_result(&tool_result_text(&result), is_error);
6585 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6586 } else {
6587 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
6590 comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6591 comp.set_result(&tool_result_text(&result), is_error);
6592 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6593 chat.add_child(comp.clone());
6594 }
6595 }
6596 state.sync_working_loader_with_bash();
6597 tui.request_render(false);
6598 }
6599 }
6600}
6601
6602fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
6606 let fallback = match message.stop_reason {
6607 rpi_ai::StopReason::Error => "Provider request failed.",
6608 rpi_ai::StopReason::Aborted => "Request aborted.",
6609 _ => return None,
6610 };
6611 Some(
6612 message
6613 .error_message
6614 .as_deref()
6615 .filter(|text| !text.trim().is_empty())
6616 .unwrap_or(fallback)
6617 .to_string(),
6618 )
6619}
6620
6621fn finalize_bash(
6625 comp: &Arc<BashExecutionComponent>,
6626 result: &rpi_agent::AgentToolResult,
6627 is_error: bool,
6628) {
6629 let exit_code = if is_error { Some(1) } else { Some(0) };
6632 let truncated = result
6633 .details
6634 .get("truncation")
6635 .and_then(|t| t.get("truncated"))
6636 .and_then(|v| v.as_bool())
6637 .unwrap_or(false);
6638 let full_output_path = result
6639 .details
6640 .get("full_output_path")
6641 .and_then(|v| v.as_str())
6642 .map(|s| s.to_string());
6643 let truncation = BashTruncation {
6644 truncated,
6645 full_output_path,
6646 };
6647 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
6649}
6650
6651fn apply_edit_diff(
6656 comp: &Arc<ToolExecutionComponent>,
6657 tool_name: &str,
6658 details: &serde_json::Value,
6659 tui: &Arc<TuiAltScreen>,
6660) {
6661 if tool_name != "edit" {
6662 return;
6663 }
6664 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
6665 return;
6666 };
6667 if diff_text.is_empty() {
6668 return;
6669 }
6670 let width = tui.width();
6671 let lines = render_diff(diff_text, width);
6672 comp.set_diff(lines);
6673}
6674
6675fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
6680 if tool_name != "read" {
6681 return None;
6682 }
6683 let path = args.get("path").and_then(|value| value.as_str())?;
6684 let normalized = path.replace('\\', "/");
6685 let file_name = normalized.rsplit('/').next()?;
6686 if !file_name.eq_ignore_ascii_case("SKILL.md") {
6687 return None;
6688 }
6689 normalized
6690 .trim_end_matches('/')
6691 .rsplit('/')
6692 .nth(1)
6693 .filter(|name| !name.is_empty())
6694 .map(str::to_string)
6695}
6696
6697fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
6700 use rpi_agent::TextContentOrImage;
6701 let mut parts: Vec<String> = Vec::new();
6702 for c in &result.content {
6703 if let TextContentOrImage::Text(t) = c {
6704 parts.push(t.text.clone());
6705 }
6706 }
6707 let joined = parts.join("\n");
6708 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
6710 if one_line.chars().count() > 200 {
6711 let truncated: String = one_line.chars().take(200).collect();
6712 format!("{truncated}…")
6713 } else {
6714 one_line
6715 }
6716}
6717
6718fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
6723 use rpi_agent::TextContentOrImage;
6724 let mut parts: Vec<String> = Vec::new();
6725 for c in &result.content {
6726 if let TextContentOrImage::Text(t) = c {
6727 parts.push(t.text.clone());
6728 }
6729 }
6730 parts.join("\n")
6731}
6732
6733fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
6737 !text.trim().is_empty() || !result.details.is_null()
6738}
6739
6740fn open_selector(
6748 state: &Arc<TuiState>,
6749 editor_container: &Arc<Container>,
6750 editor: &Arc<Editor>,
6751 tui: &Arc<TuiAltScreen>,
6752 list: Arc<SelectList>,
6753 kind: SelectorKind,
6754) {
6755 open_selector_with_view(
6756 state,
6757 editor_container,
6758 editor,
6759 tui,
6760 list.clone(),
6761 list,
6762 kind,
6763 );
6764}
6765
6766fn open_selector_with_view<C: Component + 'static>(
6770 state: &Arc<TuiState>,
6771 editor_container: &Arc<Container>,
6772 editor: &Arc<Editor>,
6773 tui: &Arc<TuiAltScreen>,
6774 list: Arc<SelectList>,
6775 view: Arc<C>,
6776 kind: SelectorKind,
6777) {
6778 editor.set_focused(false);
6780 editor_container.clear();
6782 editor_container.add_child(view.clone());
6783 *state.active_selector.lock().unwrap() = Some((list, kind));
6784 let focused: Arc<dyn Component> = view;
6785 tui.set_focus(Some(focused));
6786 tui.request_render(false);
6787}
6788
6789fn close_selector(
6792 state: &Arc<TuiState>,
6793 editor_container: &Arc<Container>,
6794 editor: &Arc<Editor>,
6795 tui: &Arc<TuiAltScreen>,
6796) {
6797 editor_container.clear();
6798 editor_container.add_child(editor.clone());
6799 editor.set_focused(true);
6800 *state.active_selector.lock().unwrap() = None;
6801 *state.active_extension_cancel.lock().unwrap() = None;
6802 tui.set_focus(Some(editor.clone()));
6803 tui.request_render(false);
6804}
6805
6806fn open_model_selector(
6812 state: &Arc<TuiState>,
6813 editor_container: &Arc<Container>,
6814 editor: &Arc<Editor>,
6815 tui: &Arc<TuiAltScreen>,
6816 catalog: &[rpi_ai::Model],
6817 lane: &Arc<dyn AgentLane>,
6818 lane_model_id: &str,
6819 chat: &Arc<Container>,
6820) {
6821 let items = model_selector_items(catalog, lane_model_id);
6822 if items.is_empty() {
6823 add_note_message(
6824 chat,
6825 "No models in the catalog. Use --model at startup to select one.",
6826 );
6827 tui.request_render(false);
6828 return;
6829 }
6830 let list = Arc::new(SelectList::new(items, 10));
6831
6832 let catalog_arc = catalog.to_vec();
6836 let state_sel = state.clone();
6837 let ec_sel = editor_container.clone();
6838 let editor_sel = editor.clone();
6839 let tui_sel = tui.clone();
6840 let chat_sel = chat.clone();
6841 let lane_sel = lane.clone();
6842 list.on_select(Arc::new(move |item| {
6843 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
6844 add_note_message(
6845 &chat_sel,
6846 &format!("Model {} not found in catalog.", item.label),
6847 );
6848 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6849 return;
6850 };
6851 state_sel.set_current_model(&model);
6852 let lane = lane_sel.clone();
6853 tokio::spawn(async move {
6854 let _ = lane.set_model(model).await;
6855 });
6856 add_note_message(
6857 &chat_sel,
6858 &format!(
6859 "Model set to {} — applies to the next message.",
6860 short_model_name(&item.value)
6861 ),
6862 );
6863 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6864 }));
6865 let state_cancel = state.clone();
6866 let ec_cancel = editor_container.clone();
6867 let editor_cancel = editor.clone();
6868 let tui_cancel = tui.clone();
6869 list.on_cancel(Arc::new(move || {
6870 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6871 }));
6872
6873 open_selector(
6874 state,
6875 editor_container,
6876 editor,
6877 tui,
6878 list,
6879 SelectorKind::Model,
6880 );
6881}
6882
6883fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
6888 if catalog.is_empty() {
6889 return None;
6890 }
6891 let idx = catalog
6892 .iter()
6893 .position(|m| m.id.eq_ignore_ascii_case(current_id));
6894 match idx {
6895 Some(i) => {
6896 let next = (i + 1) % catalog.len();
6897 Some(catalog[next].clone())
6898 }
6899 None => Some(catalog[0].clone()),
6900 }
6901}
6902
6903fn open_session_selector(
6909 state: &Arc<TuiState>,
6910 editor_container: &Arc<Container>,
6911 editor: &Arc<Editor>,
6912 tui: &Arc<TuiAltScreen>,
6913 cwd: &std::path::Path,
6914 tx: &mpsc::UnboundedSender<TuiMessage>,
6915) {
6916 let dir = crate::session::default_session_dir(cwd);
6917 let mut items: Vec<SelectItem> = Vec::new();
6918 if let Ok(entries) = std::fs::read_dir(&dir) {
6919 for entry in entries.flatten() {
6920 let path = entry.path();
6921 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
6922 continue;
6923 }
6924 let stem = path
6925 .file_stem()
6926 .and_then(|s| s.to_str())
6927 .unwrap_or("(unnamed)")
6928 .to_string();
6929 let display = path
6930 .file_name()
6931 .and_then(|s| s.to_str())
6932 .unwrap_or(&stem)
6933 .to_string();
6934 items.push(SelectItem::new(&stem, &display));
6935 }
6936 }
6937 if items.is_empty() {
6938 add_note_message(
6939 &state.chat_container,
6940 "No saved sessions found. Sessions are created automatically in interactive mode.",
6941 );
6942 tui.request_render(false);
6943 return;
6944 }
6945 let list = Arc::new(SelectList::new(items, 10));
6946
6947 let state_sel = state.clone();
6948 let ec_sel = editor_container.clone();
6949 let editor_sel = editor.clone();
6950 let tui_sel = tui.clone();
6951 let tx_sel = tx.clone();
6952 list.on_select(Arc::new(move |item| {
6953 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6957 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
6958 }));
6959 let state_cancel = state.clone();
6960 let ec_cancel = editor_container.clone();
6961 let editor_cancel = editor.clone();
6962 let tui_cancel = tui.clone();
6963 list.on_cancel(Arc::new(move || {
6964 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6965 }));
6966
6967 open_selector(
6968 state,
6969 editor_container,
6970 editor,
6971 tui,
6972 list,
6973 SelectorKind::Session,
6974 );
6975}
6976
6977fn custom_entry_display_text(
6978 custom_type: &str,
6979 data: Option<&serde_json::Value>,
6980) -> Option<String> {
6981 let data = data?;
6982 let text = data
6983 .get("summary")
6984 .or_else(|| data.get("text"))
6985 .or_else(|| data.get("output"))
6986 .and_then(|value| value.as_str())
6987 .filter(|value| !value.trim().is_empty())?;
6988 let label = match custom_type {
6989 "compactionSummary" => "Compaction summary",
6990 "branchSummary" => "Branch summary",
6991 "bashExecution" => "Command output",
6992 other => other,
6993 };
6994 Some(format!("{label}: {text}"))
6995}
6996
6997async fn open_tree_selector(
7001 harness: &AgentHarness,
7002 state: &Arc<TuiState>,
7003 editor_container: &Arc<Container>,
7004 editor: &Arc<Editor>,
7005 tui: &Arc<TuiAltScreen>,
7006 chat: &Arc<Container>,
7007 tx: &mpsc::UnboundedSender<TuiMessage>,
7008) {
7009 let entries = match harness
7010 .session()
7011 .view("main")
7012 .find_entries(&EntryQuery {
7013 order: Some(EntryOrder::OldestFirst),
7014 ..Default::default()
7015 })
7016 .await
7017 {
7018 Ok(entries) => entries,
7019 Err(error) => {
7020 add_error_message(chat, &format!("Could not read session tree: {error}"));
7021 tui.request_render(false);
7022 return;
7023 }
7024 };
7025 let current = harness.session().get_leaf_id().await.ok().flatten();
7026 let items: Vec<SelectItem> = entries
7027 .iter()
7028 .map(|entry| {
7029 let marker = if current.as_deref() == Some(entry.id()) {
7030 " (current)"
7031 } else {
7032 ""
7033 };
7034 SelectItem::new(
7035 entry.id(),
7036 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7037 )
7038 .with_description(&entry.id()[..entry.id().len().min(12)])
7039 })
7040 .collect();
7041 if items.is_empty() {
7042 add_note_message(chat, "The current session has no entries to navigate.");
7043 tui.request_render(false);
7044 return;
7045 }
7046 let list = Arc::new(SelectList::new(items, 12));
7047 let state_sel = state.clone();
7048 let ec_sel = editor_container.clone();
7049 let editor_sel = editor.clone();
7050 let tui_sel = tui.clone();
7051 let tx_sel = tx.clone();
7052 list.on_select(Arc::new(move |item| {
7053 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7054 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7055 }));
7056 let state_cancel = state.clone();
7057 let ec_cancel = editor_container.clone();
7058 let editor_cancel = editor.clone();
7059 let tui_cancel = tui.clone();
7060 list.on_cancel(Arc::new(move || {
7061 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7062 }));
7063 open_selector(
7064 state,
7065 editor_container,
7066 editor,
7067 tui,
7068 list,
7069 SelectorKind::Tree,
7070 );
7071}
7072
7073fn open_theme_selector(
7076 state: &Arc<TuiState>,
7077 editor_container: &Arc<Container>,
7078 editor: &Arc<Editor>,
7079 tui: &Arc<TuiAltScreen>,
7080 cwd: &std::path::Path,
7081 package_resources: &Arc<crate::packages::PackageResources>,
7082) {
7083 let mut items = vec![
7084 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7085 SelectItem::new("light", "Light").with_description("Light background"),
7086 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7087 ];
7088 if state.themes_enabled {
7089 for path in package_resources.theme_files() {
7090 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7091 items.push(SelectItem::new(name, name).with_description("Package theme"));
7092 }
7093 }
7094 }
7095 let list = Arc::new(SelectList::new(items, 10));
7096
7097 let state_sel = state.clone();
7098 let ec_sel = editor_container.clone();
7099 let editor_sel = editor.clone();
7100 let tui_sel = tui.clone();
7101 let chat_sel = state.chat_container.clone();
7102 let cwd_sel = cwd.to_path_buf();
7103 let package_resources_sel = package_resources.clone();
7104 list.on_select(Arc::new(move |item| {
7105 let preset = match item.value.as_str() {
7106 "light" => Some(ThemePreset::Light),
7107 "monochrome" => Some(ThemePreset::Monochrome),
7108 "dark" => Some(ThemePreset::Dark),
7109 name => {
7110 if state_sel.themes_enabled {
7111 if let Ok(custom) = crate::packages::load_theme_with_resources(
7112 &cwd_sel,
7113 name,
7114 &package_resources_sel,
7115 ) {
7116 rpi_tui::global_theme_manager().set(custom.clone());
7117 state_sel.theme_manager.set(custom);
7118 }
7119 }
7120 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7121 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7122 tui_sel.render_now(true);
7123 return;
7124 }
7125 };
7126 let Some(preset) = preset else { return };
7127 apply_theme_preset(preset);
7128 state_sel.theme_manager.apply_preset(preset);
7129 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7132 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7133 tui_sel.render_now(true);
7134 }));
7135 let state_cancel = state.clone();
7136 let ec_cancel = editor_container.clone();
7137 let editor_cancel = editor.clone();
7138 let tui_cancel = tui.clone();
7139 list.on_cancel(Arc::new(move || {
7140 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7141 }));
7142
7143 open_selector(
7144 state,
7145 editor_container,
7146 editor,
7147 tui,
7148 list,
7149 SelectorKind::Theme,
7150 );
7151}
7152
7153fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7160 use rpi_ai::types::ThinkingLevel::*;
7161 match level {
7162 Off => "Off — No reasoning",
7163 Minimal => "Minimal — Brief reasoning (~1k tokens)",
7164 Low => "Low — Light reasoning (~1k tokens)",
7165 Medium => "Medium — Moderate reasoning (~80% of max)",
7166 High => "High — Extensive reasoning (~95% of max)",
7167 Xhigh => "Xhigh — Near-maximal reasoning",
7168 Max => "Max — Maximum reasoning",
7169 }
7170}
7171
7172fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7175 use rpi_ai::types::ThinkingLevel::*;
7176 match level {
7177 Off => "off",
7178 Minimal => "minimal",
7179 Low => "low",
7180 Medium => "medium",
7181 High => "high",
7182 Xhigh => "xhigh",
7183 Max => "max",
7184 }
7185}
7186
7187fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7190 use rpi_ai::types::ThinkingLevel::*;
7191 match name.to_ascii_lowercase().as_str() {
7192 "off" => Some(Off),
7193 "minimal" => Some(Minimal),
7194 "low" => Some(Low),
7195 "medium" => Some(Medium),
7196 "high" => Some(High),
7197 "xhigh" => Some(Xhigh),
7198 "max" => Some(Max),
7199 _ => None,
7200 }
7201}
7202
7203fn open_thinking_selector(
7213 state: &Arc<TuiState>,
7214 editor_container: &Arc<Container>,
7215 editor: &Arc<Editor>,
7216 tui: &Arc<TuiAltScreen>,
7217 lane: &Arc<dyn AgentLane>,
7218 catalog: &[rpi_ai::Model],
7219 lane_model_id: &str,
7220 chat: &Arc<Container>,
7221) {
7222 let model = catalog
7225 .iter()
7226 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7227 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7228 .map(|m| m.supported_thinking_levels())
7229 .unwrap_or_else(|| {
7230 use rpi_ai::types::ThinkingLevel::*;
7231 vec![Off, Minimal, Low, Medium, High]
7232 });
7233 let mut items: Vec<SelectItem> = Vec::new();
7234 for lvl in &levels {
7235 let name = thinking_level_name(*lvl);
7236 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7237 }
7238 if items.is_empty() {
7239 add_note_message(chat, "This model has no supported thinking levels.");
7240 tui.request_render(false);
7241 return;
7242 }
7243 let list = Arc::new(SelectList::new(items, 10));
7244
7245 let state_sel = state.clone();
7246 let ec_sel = editor_container.clone();
7247 let editor_sel = editor.clone();
7248 let tui_sel = tui.clone();
7249 let chat_sel = chat.clone();
7250 let lane_sel = lane.clone();
7251 list.on_select(Arc::new(move |item| {
7252 let Some(level) = thinking_level_from_name(&item.value) else {
7253 add_note_message(
7254 &chat_sel,
7255 &format!("Unknown thinking level: {}.", item.label),
7256 );
7257 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7258 return;
7259 };
7260 let lane = lane_sel.clone();
7261 let footer_sel = state_sel.footer.clone();
7262 tokio::spawn(async move {
7263 let _ = lane.set_thinking_level(level).await;
7264 });
7265 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
7269 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
7270 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7271 }));
7272 let state_cancel = state.clone();
7273 let ec_cancel = editor_container.clone();
7274 let editor_cancel = editor.clone();
7275 let tui_cancel = tui.clone();
7276 list.on_cancel(Arc::new(move || {
7277 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7278 }));
7279
7280 open_selector(
7281 state,
7282 editor_container,
7283 editor,
7284 tui,
7285 list,
7286 SelectorKind::Thinking,
7287 );
7288}
7289
7290fn open_tools_selector(
7296 state: &Arc<TuiState>,
7297 editor_container: &Arc<Container>,
7298 editor: &Arc<Editor>,
7299 tui: &Arc<TuiAltScreen>,
7300 lane: &Arc<dyn AgentLane>,
7301 chat: &Arc<Container>,
7302) {
7303 let mut active = match tokio::runtime::Handle::try_current() {
7308 Ok(h) => h
7309 .block_on(async { lane.get_active_tools().await })
7310 .unwrap_or_default(),
7311 Err(_) => Vec::new(),
7312 };
7313 if active.is_empty() {
7318 active = crate::session::BUILTIN_TOOL_NAMES
7319 .iter()
7320 .map(|name| (*name).to_string())
7321 .collect();
7322 }
7323 let mut items: Vec<SelectItem> = Vec::new();
7324 for name in crate::session::BUILTIN_TOOL_NAMES {
7325 let on = active.iter().any(|a| a == name);
7326 let label = if on {
7327 format!("{name} (on)")
7328 } else {
7329 (*name).to_string()
7330 };
7331 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
7332 }
7333 let list = Arc::new(SelectList::new(items, 10));
7334
7335 let active_captured = active.clone();
7337 let state_sel = state.clone();
7338 let ec_sel = editor_container.clone();
7339 let editor_sel = editor.clone();
7340 let tui_sel = tui.clone();
7341 let chat_sel = chat.clone();
7342 let lane_sel = lane.clone();
7343 list.on_select(Arc::new(move |item| {
7344 let mut next = active_captured.clone();
7345 if let Some(pos) = next.iter().position(|a| a == &item.value) {
7346 next.remove(pos);
7347 } else {
7348 next.push(item.value.clone());
7349 }
7350 let on = next.iter().any(|a| a == &item.value);
7351 let lane = lane_sel.clone();
7352 let next_clone = next.clone();
7353 tokio::spawn(async move {
7354 let _ = lane.set_active_tools(next_clone).await;
7355 });
7356 let list_str = if next.is_empty() {
7357 "(none)".to_string()
7358 } else {
7359 next.join(", ")
7360 };
7361 add_note_message(
7362 &chat_sel,
7363 &format!(
7364 "{} {} — active tools: {}",
7365 item.value,
7366 if on { "enabled" } else { "disabled" },
7367 list_str
7368 ),
7369 );
7370 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7371 }));
7372 let state_cancel = state.clone();
7373 let ec_cancel = editor_container.clone();
7374 let editor_cancel = editor.clone();
7375 let tui_cancel = tui.clone();
7376 list.on_cancel(Arc::new(move || {
7377 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7378 }));
7379
7380 open_selector(
7381 state,
7382 editor_container,
7383 editor,
7384 tui,
7385 list,
7386 SelectorKind::Tools,
7387 );
7388}
7389
7390fn open_images_selector(
7394 state: &Arc<TuiState>,
7395 editor_container: &Arc<Container>,
7396 editor: &Arc<Editor>,
7397 tui: &Arc<TuiAltScreen>,
7398 chat: &Arc<Container>,
7399) {
7400 let current = *state.show_images.lock().unwrap();
7401 let items = vec![
7402 SelectItem::new("yes", "Yes").with_description(if current {
7403 "Inline images (current)"
7404 } else {
7405 "Inline images"
7406 }),
7407 SelectItem::new("no", "No").with_description(if current {
7408 "Placeholder only"
7409 } else {
7410 "Placeholder only (current)"
7411 }),
7412 ];
7413 let list = Arc::new(SelectList::new(items, 5));
7414
7415 let state_sel = state.clone();
7416 let ec_sel = editor_container.clone();
7417 let editor_sel = editor.clone();
7418 let tui_sel = tui.clone();
7419 let chat_sel = chat.clone();
7420 list.on_select(Arc::new(move |item| {
7421 let on = item.value == "yes";
7422 *state_sel.show_images.lock().unwrap() = on;
7423 add_note_message(
7424 &chat_sel,
7425 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
7426 );
7427 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7428 }));
7429 let state_cancel = state.clone();
7430 let ec_cancel = editor_container.clone();
7431 let editor_cancel = editor.clone();
7432 let tui_cancel = tui.clone();
7433 list.on_cancel(Arc::new(move || {
7434 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7435 }));
7436
7437 open_selector(
7438 state,
7439 editor_container,
7440 editor,
7441 tui,
7442 list,
7443 SelectorKind::Images,
7444 );
7445}
7446
7447fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
7455 let text = editor.get_text();
7456 let (_row, col) = editor.cursor_position();
7457 let cursor = col.min(text.len());
7465 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
7466 render_autocomplete(state, suggestions);
7467}
7468
7469fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
7471 state.autocomplete_container.clear();
7472 let Some(sugg) = suggestions else {
7473 return;
7474 };
7475 if sugg.items.is_empty() {
7476 return;
7477 }
7478 let accent = state.theme_manager.get().colors.accent;
7481 let muted = state.theme_manager.get().colors.muted;
7482 for (i, item) in sugg
7483 .items
7484 .iter()
7485 .take(state.autocomplete_max_visible)
7486 .enumerate()
7487 {
7488 let prefix = if i == 0 { "→ " } else { " " };
7489 let label = item.display_text();
7490 let line = if i == 0 {
7491 format!(
7492 "{prefix}{} {}",
7493 accent.fg(label),
7494 muted.fg(item.description.as_deref().unwrap_or(""))
7495 )
7496 } else {
7497 format!(
7498 "{prefix}{} {}",
7499 muted.fg(label),
7500 muted.fg(item.description.as_deref().unwrap_or(""))
7501 )
7502 };
7503 state
7504 .autocomplete_container
7505 .add_child(Arc::new(Text::new(line, 1, 0)));
7506 }
7507}
7508
7509fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
7513 let text = editor.get_text();
7514 let (_row, col) = editor.cursor_position();
7515 let cursor = col.min(text.len());
7516 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
7517 return false;
7518 };
7519 let Some(top) = sugg.items.first() else {
7520 return false;
7521 };
7522 let start = sugg.start.min(text.len());
7526 let end = sugg.end.min(text.len());
7527 let mut replaced = String::with_capacity(text.len() + top.text.len());
7528 replaced.push_str(&text[..start]);
7529 replaced.push_str(&top.text);
7530 replaced.push_str(&text[end..]);
7533 if top.insert_space && !replaced.ends_with('/') {
7534 replaced.push(' ');
7535 }
7536 let new_cursor = replaced.len().min(
7539 start
7540 + top.text.len()
7541 + if top.insert_space && !top.text.ends_with('/') {
7542 1
7543 } else {
7544 0
7545 },
7546 );
7547 editor.set_text(&replaced);
7548 editor.set_cursor(0, new_cursor);
7549 state.autocomplete_container.clear();
7550 true
7551}
7552
7553fn add_welcome_message(container: &Arc<Container>) {
7559 add_welcome_message_with_capabilities(container, &[], &[]);
7560}
7561
7562fn add_welcome_message_with_capabilities(
7566 container: &Arc<Container>,
7567 active_tools: &[String],
7568 skills: &[String],
7569) {
7570 let c = current_theme().colors;
7571 let title = format!(
7575 "{} {}",
7576 c.accent.fg(&tui_bold("rpi")),
7577 c.muted.fg("interactive TUI")
7578 );
7579 container.add_child(Arc::new(Text::new(title, 1, 0)));
7580 container.add_child(Arc::new(Spacer::new(1)));
7581 container.add_child(Arc::new(Text::new(
7582 c.dim.fg("Type your message and press Enter to send."),
7583 1,
7584 0,
7585 )));
7586 let hint = c
7587 .dim
7588 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
7589 container.add_child(Arc::new(Text::new(hint, 1, 0)));
7590 container.add_child(Arc::new(Spacer::new(1)));
7591 container.add_child(Arc::new(Text::new(
7592 welcome_capability_line("Tools", active_tools),
7593 1,
7594 0,
7595 )));
7596 container.add_child(Arc::new(Text::new(
7597 welcome_capability_line("Skills", skills),
7598 1,
7599 0,
7600 )));
7601 container.add_child(Arc::new(DynamicBorder::new()));
7602}
7603
7604fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
7607 let colors = current_theme().colors;
7608 let group = Arc::new(Container::new());
7609
7610 for warning in &report.warnings {
7611 let body = format!(
7612 "{}\n{} {}{}",
7613 colors.error.fg(&warning.message),
7614 colors.muted.fg("Run"),
7615 colors.accent.fg(&warning.command),
7616 colors.muted.fg(" to retry.")
7617 );
7618 add_update_panel(&group, "Update Failed", &body, colors.error);
7619 }
7620
7621 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
7622 let body = format!(
7623 "{} {}{}",
7624 colors
7625 .muted
7626 .fg(&format!("New version {} is available. Run", notice.latest)),
7627 colors.accent.fg(¬ice.command),
7628 colors.muted.fg(".")
7629 );
7630 add_update_panel(&group, "Update Available", &body, colors.warning);
7631 }
7632
7633 let package_notices = report
7634 .notices
7635 .iter()
7636 .filter(|notice| notice.name != "rpi")
7637 .collect::<Vec<_>>();
7638 if !package_notices.is_empty() {
7639 let command = package_notices[0].command.as_str();
7640 let mut lines = vec![format!(
7641 "{} {}{}",
7642 colors.muted.fg("Package updates are available. Run"),
7643 colors.accent.fg(command),
7644 colors.muted.fg(".")
7645 )];
7646 lines.push(colors.muted.fg("Packages:"));
7647 lines.extend(
7648 package_notices
7649 .into_iter()
7650 .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
7651 );
7652 add_update_panel(
7653 &group,
7654 "Package Updates Available",
7655 &lines.join("\n"),
7656 colors.warning,
7657 );
7658 }
7659
7660 if group.child_count() > 0 {
7664 container.add_child(group);
7665 }
7666}
7667
7668fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
7669 container.add_child(Arc::new(Spacer::new(1)));
7670 container.add_child(Arc::new(DynamicBorder::with_color(color)));
7671 container.add_child(Arc::new(Text::new(
7672 format!("{}\n{body}", color.fg(&tui_bold(title))),
7673 1,
7674 0,
7675 )));
7676 container.add_child(Arc::new(DynamicBorder::with_color(color)));
7677}
7678
7679fn welcome_capability_line(label: &str, names: &[String]) -> String {
7680 let c = current_theme().colors;
7681 let value = if names.is_empty() {
7682 "none".to_string()
7683 } else {
7684 names.join(" · ")
7685 };
7686 format!(
7687 "{} {}",
7688 c.accent.fg(&format!("{label} ({})", names.len())),
7689 c.muted.fg(&value)
7690 )
7691}
7692
7693fn add_help_message(container: &Arc<Container>) {
7695 let c = current_theme().colors;
7696 container.add_child(Arc::new(Text::new(
7700 c.md_heading.fg(&tui_bold("📚 Available Commands")),
7701 1,
7702 0,
7703 )));
7704 container.add_child(Arc::new(Spacer::new(1)));
7705
7706 let cmds: &[(&str, &str)] = &[
7707 ("/help, /?", "Show this help message"),
7708 ("/clear, /new", "Clear the conversation"),
7709 ("/exit, /quit, /q", "Exit the application"),
7710 ("/version, /v", "Show version information"),
7711 ("/changelog", "Show recent release changes"),
7712 ("/model, /m", "Choose a model (live switch)"),
7713 ("/thinking, /think", "Set reasoning depth (selector)"),
7714 ("/tools", "Toggle built-in tools on/off"),
7715 ("/images", "Toggle inline image rendering"),
7716 ("/session", "List saved sessions"),
7717 ("/theme", "Choose a theme (selector)"),
7718 ("/compact", "Compact the conversation"),
7719 ("/copy", "Copy last reply to clipboard"),
7720 ("/hotkeys", "Show keyboard shortcuts"),
7721 ("/armin", "🐾 Easter egg"),
7722 ("/earendil", "Earendil announcement"),
7723 ];
7724 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7725 for (cmd, desc) in cmds {
7726 let row = format!(
7727 " {:<cmd_w$} {} {}",
7728 c.accent.fg(cmd),
7729 c.dim.fg("—"),
7730 c.muted.fg(desc)
7731 );
7732 container.add_child(Arc::new(Text::new(row, 1, 0)));
7733 }
7734 container.add_child(Arc::new(Spacer::new(1)));
7735}
7736
7737fn add_version_message(container: &Arc<Container>) {
7739 let c = current_theme().colors;
7740 container.add_child(Arc::new(Text::new(
7741 c.md_heading.fg(&tui_bold("📦 Version Information")),
7742 1,
7743 0,
7744 )));
7745 container.add_child(Arc::new(Spacer::new(1)));
7746 container.add_child(Arc::new(Text::new(
7749 format!(
7750 " {} {}",
7751 c.muted.fg("rpi-cli"),
7752 c.text.fg(&format!("v{}", crate::VERSION))
7753 ),
7754 1,
7755 0,
7756 )));
7757 container.add_child(Arc::new(Text::new(
7758 format!(
7759 " {}",
7760 c.dim.fg("Rust implementation of pi coding agent TUI")
7761 ),
7762 1,
7763 0,
7764 )));
7765 container.add_child(Arc::new(Spacer::new(1)));
7766}
7767
7768fn add_changelog_message(container: &Arc<Container>) {
7772 let c = current_theme().colors;
7773 container.add_child(Arc::new(Text::new(
7774 c.md_heading.fg(&tui_bold("Recent Changes")),
7775 1,
7776 0,
7777 )));
7778 container.add_child(Arc::new(Spacer::new(1)));
7779 let entries = [
7780 (
7781 "Native parity phase 1",
7782 "models, images, trust, export, and JSON events",
7783 ),
7784 (
7785 "TUI controls",
7786 "external editor, thinking levels, and tool output toggles",
7787 ),
7788 (
7789 "Provider auth",
7790 "OpenAI-compatible API key aliases and gateway headers",
7791 ),
7792 ];
7793 for (release, summary) in entries {
7794 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
7795 container.add_child(Arc::new(Text::new(row, 1, 0)));
7796 }
7797 container.add_child(Arc::new(Text::new(
7798 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
7799 1,
7800 0,
7801 )));
7802 container.add_child(Arc::new(Spacer::new(1)));
7803}
7804
7805fn add_hotkeys_message(container: &Arc<Container>) {
7807 let c = current_theme().colors;
7808 container.add_child(Arc::new(Text::new(
7809 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
7810 1,
7811 0,
7812 )));
7813 container.add_child(Arc::new(Spacer::new(1)));
7814 let keys: &[(&str, &str)] = &[
7815 ("Enter", "Send message"),
7816 ("Shift+Enter", "New line"),
7817 ("Tab", "Accept autocomplete suggestion"),
7818 ("Ctrl+A / Ctrl+E", "Line start / end"),
7819 (
7820 "Ctrl+K / Ctrl+U",
7821 "Kill to end / start of line (Ctrl+Y yanks)",
7822 ),
7823 ("Ctrl+- / Ctrl+R", "Undo / redo"),
7824 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
7825 ("Alt+Backspace", "Kill previous word"),
7826 ("Ctrl+C", "Abort a run, or exit when idle"),
7827 ("Esc", "Abort a running prompt"),
7828 ("Ctrl+L", "Open model selector"),
7829 ("Ctrl+M", "Cycle to the next model (live)"),
7830 ("Ctrl+O", "Expand/collapse all tool output"),
7831 ("Ctrl+T", "Show/hide reasoning blocks"),
7832 ("PageUp/Down", "Scroll transcript by one page"),
7833 ("Home / End", "Jump to transcript start / latest output"),
7834 ];
7835 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7836 for (key, desc) in keys {
7837 let row = format!(
7838 " {:<key_w$} {} {}",
7839 c.accent.fg(key),
7840 c.dim.fg("—"),
7841 c.muted.fg(desc)
7842 );
7843 container.add_child(Arc::new(Text::new(row, 1, 0)));
7844 }
7845 container.add_child(Arc::new(Spacer::new(1)));
7846}
7847
7848fn add_user_message(container: &Arc<Container>, text: &str) {
7854 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
7855 container.add_child(Arc::new(Spacer::new(1)));
7856}
7857
7858fn add_error_message(container: &Arc<Container>, text: &str) {
7860 let c = current_theme().colors;
7861 container.add_child(Arc::new(Text::new(
7862 format!(" {} {}", c.error.fg("✗"), c.error.fg(text)),
7863 1,
7864 0,
7865 )));
7866 container.add_child(Arc::new(Spacer::new(1)));
7867}
7868
7869fn add_note_message(container: &Arc<Container>, text: &str) {
7871 let c = current_theme().colors;
7872 container.add_child(Arc::new(Text::new(
7873 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
7874 1,
7875 0,
7876 )));
7877 container.add_child(Arc::new(Spacer::new(1)));
7878}
7879
7880fn show_context_panel(
7889 chat: &Arc<Container>,
7890 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
7891) {
7892 let skills = resources.skills.as_deref().unwrap_or(&[]);
7893 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
7894 let mut lines: Vec<String> = Vec::new();
7895 lines.push("📂 Discovered resources for this session:".into());
7896
7897 if skills.is_empty() {
7898 lines.push(
7899 " Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
7900 );
7901 } else {
7902 lines.push(format!(" Skills ({}):", skills.len()));
7903 for s in skills {
7904 let marker = if s.disable_model_invocation == Some(true) {
7905 " [hidden]"
7906 } else {
7907 ""
7908 };
7909 let desc: String = s.description.chars().take(72).collect();
7910 lines.push(format!(" • {}{marker} — {desc}", s.name));
7911 }
7912 }
7913
7914 if templates.is_empty() {
7915 lines.push(
7916 " Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
7917 );
7918 } else {
7919 lines.push(format!(" Prompt templates ({}):", templates.len()));
7920 for t in templates {
7921 let desc = t
7922 .description
7923 .as_deref()
7924 .unwrap_or("(no description)")
7925 .chars()
7926 .take(72)
7927 .collect::<String>();
7928 lines.push(format!(" • /{} — {desc}", t.name));
7929 }
7930 }
7931 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
7932 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
7933 lines.push(
7934 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
7935 .into(),
7936 );
7937 let body = lines.join("\n");
7938 container_note_block(chat, &body);
7939}
7940
7941fn container_note_block(container: &Arc<Container>, body: &str) {
7943 for line in body.lines() {
7944 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
7945 }
7946 container.add_child(Arc::new(Spacer::new(1)));
7947}
7948
7949pub fn is_tui_supported() -> bool {
7955 std::io::stdout().is_terminal()
7956}
7957
7958#[allow(unused_imports)]
7960use rpi_tui::Color as _Color;
7961
7962#[cfg(test)]
7963mod tests {
7964 use super::*;
7965 use rpi_tui::Component;
7966
7967 #[test]
7968 fn verbose_overrides_quiet_startup_listing() {
7969 assert!(should_show_startup_listing(false, false));
7970 assert!(should_show_startup_listing(true, false));
7971 assert!(should_show_startup_listing(true, true));
7972 assert!(!should_show_startup_listing(false, true));
7973 }
7974
7975 #[test]
7976 fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
7977 let tmp = tempfile::tempdir().unwrap();
7978 let git_root = tmp.path().join(".pi/git/github.com/example/repo");
7979 let local_root = tmp.path().join("local-package");
7980 std::fs::create_dir_all(git_root.join(".git")).unwrap();
7981 std::fs::create_dir_all(&local_root).unwrap();
7982 std::fs::write(
7983 git_root.join("package.json"),
7984 r#"{"name":"git-demo","version":"1.0.0"}"#,
7985 )
7986 .unwrap();
7987 std::fs::write(
7988 local_root.join("package.json"),
7989 r#"{"name":"local-demo","version":"1.0.0"}"#,
7990 )
7991 .unwrap();
7992 let resources = crate::packages::discover(
7993 tmp.path(),
7994 &[
7995 "git:github.com/example/repo".to_string(),
7996 local_root.to_string_lossy().into_owned(),
7997 ],
7998 );
7999 assert_eq!(resources.packages.len(), 2);
8000
8001 let filtered = git_only_update_resources(resources);
8002
8003 assert_eq!(filtered.packages.len(), 1);
8004 assert!(matches!(
8005 filtered.packages[0].source,
8006 crate::packages::PackageSource::Git
8007 ));
8008 }
8009
8010 #[test]
8011 fn trust_command_uses_the_session_cwd_after_process_chdir() {
8012 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8013 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8014 const TEST_NAME: &str =
8015 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8016
8017 if std::env::var_os(CHILD_ENV).is_some() {
8018 let session_cwd = std::path::PathBuf::from(
8019 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8020 );
8021 let changed_cwd = std::env::current_dir().unwrap();
8022
8023 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8024
8025 assert_eq!(
8026 crate::config::project_trust_decision(&session_cwd).unwrap(),
8027 Some(true)
8028 );
8029 assert_eq!(
8030 crate::config::project_trust_decision(&changed_cwd).unwrap(),
8031 None
8032 );
8033 return;
8034 }
8035
8036 let tmp = tempfile::tempdir().unwrap();
8037 let session_cwd = tmp.path().join("session-project");
8038 let changed_cwd = tmp.path().join("extension-cwd");
8039 let agent_dir = tmp.path().join("agent");
8040 std::fs::create_dir_all(&session_cwd).unwrap();
8041 std::fs::create_dir_all(&changed_cwd).unwrap();
8042 std::fs::create_dir_all(&agent_dir).unwrap();
8043
8044 let status = std::process::Command::new(std::env::current_exe().unwrap())
8045 .arg("--exact")
8046 .arg(TEST_NAME)
8047 .arg("--nocapture")
8048 .env(CHILD_ENV, "1")
8049 .env(SESSION_CWD_ENV, &session_cwd)
8050 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8051 .current_dir(&changed_cwd)
8052 .status()
8053 .unwrap();
8054
8055 assert!(status.success(), "child test process failed: {status}");
8056 }
8057
8058 #[test]
8059 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8060 let global = crate::settings::Settings {
8061 editor_padding_x: Some(3),
8062 autocomplete_max_visible: Some(6),
8063 hide_thinking_block: Some(false),
8064 quiet_startup: Some(true),
8065 show_terminal_progress: Some(true),
8066 ..Default::default()
8067 };
8068 let rpi_project = crate::settings::Settings {
8069 editor_padding_x: Some(0),
8070 hide_thinking_block: Some(true),
8071 quiet_startup: Some(false),
8072 show_terminal_progress: Some(false),
8073 ..Default::default()
8074 };
8075 let pi_project = crate::settings::Settings {
8076 editor_padding_x: Some(9),
8077 autocomplete_max_visible: Some(12),
8078 quiet_startup: Some(true),
8079 ..Default::default()
8080 };
8081
8082 assert_eq!(
8083 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8084 TuiStartupSettings {
8085 editor_padding_x: 0,
8086 autocomplete_max_visible: 12,
8087 hide_thinking: true,
8088 quiet_startup: false,
8089 show_terminal_progress: false,
8090 }
8091 );
8092 }
8093
8094 #[test]
8095 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8096 let global = crate::settings::Settings {
8097 quiet_startup: Some(false),
8098 ..Default::default()
8099 };
8100 let rpi_project = crate::settings::Settings::default();
8101 let pi_project = crate::settings::Settings {
8102 quiet_startup: Some(true),
8103 ..Default::default()
8104 };
8105
8106 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8107
8108 assert!(resolved.quiet_startup);
8109 }
8110
8111 #[test]
8112 fn tui_startup_settings_use_global_values_without_project_fields() {
8113 let global = crate::settings::Settings {
8114 editor_padding_x: Some(7),
8115 autocomplete_max_visible: Some(8),
8116 hide_thinking_block: Some(true),
8117 quiet_startup: Some(true),
8118 show_terminal_progress: Some(false),
8119 ..Default::default()
8120 };
8121
8122 assert_eq!(
8123 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8124 TuiStartupSettings {
8125 editor_padding_x: 7,
8126 autocomplete_max_visible: 8,
8127 hide_thinking: true,
8128 quiet_startup: true,
8129 show_terminal_progress: false,
8130 }
8131 );
8132 }
8133
8134 #[test]
8135 fn tui_startup_settings_ignore_untrusted_project_values() {
8136 let global = crate::settings::Settings {
8137 quiet_startup: Some(false),
8138 show_terminal_progress: Some(true),
8139 ..Default::default()
8140 };
8141 let project = crate::settings::Settings {
8142 quiet_startup: Some(true),
8143 show_terminal_progress: Some(false),
8144 ..Default::default()
8145 };
8146
8147 let resolved = resolve_tui_startup_settings(&global, &[project], false);
8148
8149 assert!(!resolved.quiet_startup);
8150 assert!(resolved.show_terminal_progress);
8151 }
8152
8153 #[test]
8154 fn transcript_page_uses_viewport_with_overlap() {
8155 assert_eq!(transcript_page_size(24), 20);
8156 assert_eq!(transcript_page_size(4), 1);
8157 assert_eq!(transcript_page_size(0), 1);
8158 }
8159
8160 #[test]
8161 fn key_repeat_is_dispatched_but_release_is_not() {
8162 assert!(should_dispatch_key(KeyEventKind::Press));
8163 assert!(should_dispatch_key(KeyEventKind::Repeat));
8164 assert!(!should_dispatch_key(KeyEventKind::Release));
8165 }
8166
8167 #[test]
8168 fn key_event_encoding_matches_pi_keybinding_protocol() {
8169 let key = |code, modifiers| KeyEvent::new(code, modifiers);
8170 assert_eq!(
8171 key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
8172 "\r"
8173 );
8174 assert_eq!(
8175 key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
8176 "\x1b[13;2u"
8177 );
8178 assert_eq!(
8179 key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
8180 "\x1b[9;2u"
8181 );
8182 assert_eq!(
8183 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
8184 "\x1b[Z"
8185 );
8186 assert_eq!(
8187 key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
8188 "\x1b[Z"
8189 );
8190 assert_eq!(
8191 key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
8192 "\x03"
8193 );
8194 assert_eq!(
8195 key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
8196 "\x0f"
8197 );
8198 assert_eq!(
8199 key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
8200 "!"
8201 );
8202 assert_eq!(
8203 key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
8204 "1"
8205 );
8206 }
8207
8208 #[test]
8209 fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
8210 let bridge = JsDialogBridge::default();
8211 let (sender, receiver) = std_mpsc::channel();
8212 bridge.pending.lock().unwrap().push_back(JsDialogPending {
8213 request: JsDialogRequest {
8214 id: "dialog-1".into(),
8215 method: "input".into(),
8216 title: String::new(),
8217 message: String::new(),
8218 options: Vec::new(),
8219 placeholder: None,
8220 prefill: None,
8221 },
8222 result: sender,
8223 });
8224
8225 bridge.cancel("dialog-1");
8228 assert!(bridge.take_pending().is_none());
8229 assert_eq!(
8230 receiver.recv().unwrap(),
8231 serde_json::json!({ "cancelled": true })
8232 );
8233 assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
8234 }
8235
8236 #[test]
8237 fn test_layout_renders_welcome_message() {
8238 let chat = Arc::new(Container::new());
8239 add_welcome_message(&chat);
8240
8241 let scroll = Arc::new(ScrollView::new(
8242 chat.clone(),
8243 ScrollViewOptions {
8244 follow: FollowMode::End,
8245 primary: true,
8246 ..Default::default()
8247 },
8248 ));
8249
8250 let editor = Arc::new(Editor::new(
8251 EditorOptions {
8252 padding_x: 1,
8253 ..Default::default()
8254 },
8255 EditorStyle::default(),
8256 Arc::new(rpi_tui::Keybindings::new()),
8257 ));
8258 let dock = Arc::new(Container::new());
8259 dock.add_child(editor);
8260
8261 let footer = Arc::new(FooterComponent::new());
8262
8263 let root = VStack::from_children(vec![
8264 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
8265 StackChild::Entry(StackEntry::new(dock)),
8266 StackChild::Entry(StackEntry::new(footer)),
8267 ]);
8268
8269 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
8270
8271 let all: String = frame.lines.join("\n");
8272 assert!(
8273 all.contains("rpi"),
8274 "Welcome message not found. Rendered: {}",
8275 all
8276 );
8277 assert!(
8278 all.contains("Type your message"),
8279 "Help text not found. Rendered: {}",
8280 all
8281 );
8282 }
8283
8284 #[test]
8285 fn test_chat_container_has_welcome_content() {
8286 let chat = Arc::new(Container::new());
8287 add_welcome_message_with_capabilities(
8288 &chat,
8289 &["read".into(), "bash".into(), "web_fetch".into()],
8290 &["rust-review".into(), "release".into()],
8291 );
8292
8293 let lines = chat.render(80);
8294 let all: String = lines.join("\n");
8295 let plain = strip_ansi(&all);
8298 assert!(
8299 plain.contains("rpi"),
8300 "Welcome message not in chat container: {:?}",
8301 lines
8302 );
8303 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
8304 assert!(
8305 plain.contains("read · bash · web_fetch"),
8306 "Tool names missing: {plain}"
8307 );
8308 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
8309 assert!(
8310 plain.contains("rust-review · release"),
8311 "Skill names missing: {plain}"
8312 );
8313 }
8314
8315 #[test]
8316 fn update_notices_render_inside_the_transcript() {
8317 let chat = Arc::new(Container::new());
8318 let report = crate::updates::UpdateReport {
8319 notices: vec![
8320 crate::updates::UpdateNotice {
8321 name: "rpi".into(),
8322 current: "0.1.10".into(),
8323 latest: "0.1.11".into(),
8324 command: "rpi self-update".into(),
8325 },
8326 crate::updates::UpdateNotice {
8327 name: "rpi-search".into(),
8328 current: "0.1.0".into(),
8329 latest: "0.1.1".into(),
8330 command: "rpi pi-update".into(),
8331 },
8332 ],
8333 warnings: vec![crate::updates::UpdateWarning {
8334 message: "The previously scheduled rpi self-update failed: access denied".into(),
8335 command: "rpi self-update".into(),
8336 }],
8337 };
8338
8339 add_update_notices(&chat, &report);
8340
8341 assert_eq!(chat.child_count(), 1);
8342 let plain = strip_ansi(&chat.render(80).join("\n"));
8343 assert!(plain.contains("Update Failed"), "{plain}");
8344 assert!(
8345 plain.contains("self-update failed: access denied"),
8346 "{plain}"
8347 );
8348 assert!(plain.contains("Update Available"), "{plain}");
8349 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
8350 assert!(plain.contains("rpi self-update"), "{plain}");
8351 assert!(plain.contains("Package Updates Available"), "{plain}");
8352 assert!(plain.contains("rpi pi-update"), "{plain}");
8353 assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
8354 }
8355
8356 #[test]
8357 fn empty_update_report_does_not_add_transcript_content() {
8358 let chat = Arc::new(Container::new());
8359
8360 add_update_notices(&chat, &crate::updates::UpdateReport::default());
8361
8362 assert_eq!(chat.child_count(), 0);
8363 }
8364
8365 #[test]
8366 fn skill_reads_are_detected_by_path() {
8367 let name = skill_tool_name(
8368 "read",
8369 &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
8370 );
8371 assert_eq!(name.as_deref(), Some("release"));
8372
8373 let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
8374 assert!(name.is_none());
8375
8376 assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
8378 }
8379
8380 #[test]
8381 fn welcome_capabilities_show_empty_state() {
8382 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
8383 assert_eq!(plain, "Skills (0) none");
8384 }
8385
8386 #[test]
8387 fn empty_tool_progress_is_deferred_until_start() {
8388 let empty = rpi_agent::AgentToolResult::default();
8389 assert!(!tool_update_has_payload("", &empty));
8390
8391 let text = rpi_agent::AgentToolResult::text("partial output");
8392 assert!(tool_update_has_payload("partial output", &text));
8393
8394 let details = rpi_agent::AgentToolResult {
8395 details: serde_json::json!({"path": "src/lib.rs"}),
8396 ..Default::default()
8397 };
8398 assert!(tool_update_has_payload("", &details));
8399 }
8400
8401 #[test]
8402 fn completed_stream_reconciles_the_final_tail_before_detaching() {
8403 let component = Arc::new(AssistantMessageComponent::new(
8404 AssistantMessageOptions::default(),
8405 ));
8406 component.set_streaming(true);
8407 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
8408
8409 let current = Mutex::new(Some(component.clone()));
8410 let cached = Mutex::new("partial response".to_string());
8411 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
8412 final_message.content = vec![Content::text(
8413 "partial response with the previously missing final tail",
8414 )];
8415 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
8416
8417 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
8418
8419 assert!(current.lock().unwrap().is_none());
8420 assert_eq!(
8421 cached.lock().unwrap().as_str(),
8422 "partial response with the previously missing final tail"
8423 );
8424 let rendered = strip_ansi(&component.render(100).join("\n"));
8425 assert!(
8426 rendered.contains("previously missing final tail"),
8427 "{rendered}"
8428 );
8429 }
8430
8431 #[test]
8437 fn tab_accept_suggestion_reflects_in_next_render() {
8438 use rpi_tui::render_layout_frame;
8439
8440 let editor = Arc::new(Editor::new(
8441 EditorOptions {
8442 padding_x: 1,
8443 ..Default::default()
8444 },
8445 EditorStyle::default(),
8446 Arc::new(rpi_tui::Keybindings::new()),
8447 ));
8448 editor.set_focused(true);
8449 let editor_container = Arc::new(Container::new());
8450 editor_container.add_child(editor.clone());
8451 let autocomplete_container = Arc::new(Container::new());
8452 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
8453 let dock = Arc::new(VStack::from_children(vec![
8454 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
8455 StackChild::Entry(
8456 StackEntry::new(editor_container.clone())
8457 .shrink(0)
8458 .min_size(3),
8459 ),
8460 StackChild::Entry(StackEntry::new(footer)),
8461 ]));
8462
8463 let mut manager = AutocompleteManager::new();
8465 let mut combined = CombinedAutocompleteProvider::new();
8466 combined.add_provider(Arc::new(
8467 SlashCommandAutocompleteProvider::with_default_commands(),
8468 ));
8469 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
8470 manager.set_provider(Arc::new(combined));
8471 editor.insert("/mo");
8474 assert_eq!(editor.cursor_position(), (0, 3));
8475
8476 let frame_before = render_layout_frame(dock.clone(), 80, 10);
8477 assert!(
8478 frame_before.lines.iter().any(|l| l.contains("/mo")),
8479 "precondition: editor shows the typed prefix. Frame rows:\n{}",
8480 frame_before
8481 .lines
8482 .iter()
8483 .map(|l| format!(" [{l}]"))
8484 .collect::<Vec<_>>()
8485 .join("\n")
8486 );
8487
8488 let text = editor.get_text();
8490 let (_row, col) = editor.cursor_position();
8491 let cursor = col.min(text.len());
8492 let sugg = manager
8493 .get_suggestions(&text, cursor)
8494 .expect("slash suggestions for /mo");
8495 let top = sugg.items.first().expect("at least one suggestion");
8496 let start = sugg.start.min(text.len());
8497 let end = sugg.end.min(text.len());
8498 let mut replaced = String::new();
8499 replaced.push_str(&text[..start]);
8500 replaced.push_str(&top.text);
8501 replaced.push_str(&text[end..]);
8502 if top.insert_space && !replaced.ends_with('/') {
8503 replaced.push(' ');
8504 }
8505 editor.set_text(&replaced);
8506 editor.set_cursor(0, replaced.len().min(start + top.text.len()));
8507 autocomplete_container.clear();
8508 assert_eq!(editor.get_text(), "/model");
8509
8510 let frame_after = render_layout_frame(dock, 80, 10);
8512 let all: String = frame_after.lines.join("\n");
8513 assert!(
8514 all.contains("/model"),
8515 "completed text missing from next render. Got:\n{all}"
8516 );
8517 let editor_line = frame_after
8521 .lines
8522 .iter()
8523 .find(|l| l.contains("/model"))
8524 .expect("editor row with completed text");
8525 assert!(
8526 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
8527 "caret must follow the full completed text. Got: {editor_line:?}"
8528 );
8529 }
8530
8531 #[test]
8532 fn test_slash_command_dispatch() {
8533 let registry = build_builtin_registry();
8538
8539 let resolves_to = |token: &str, canonical: &str| {
8541 let found = registry.find(token).expect("{token} should resolve");
8542 assert_eq!(
8543 found.name(),
8544 canonical,
8545 "{token} resolved to {} (expected {canonical})",
8546 found.name()
8547 );
8548 };
8549
8550 resolves_to("/help", "/help");
8551 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
8553 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
8557 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
8559 resolves_to("/hotkeys", "/hotkeys");
8560 resolves_to("/model", "/model");
8561 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
8563 resolves_to("/session", "/session");
8564 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
8566 resolves_to("/copy", "/copy");
8567 resolves_to("/thinking", "/thinking");
8568 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
8570 resolves_to("/images", "/images");
8571 resolves_to("/armin", "/armin");
8572 resolves_to("/earendil", "/earendil");
8573 resolves_to("/context", "/context");
8574 resolves_to("/settings", "/settings");
8576 resolves_to("/name", "/name");
8577 resolves_to("/export", "/export");
8578
8579 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
8581 }
8582
8583 #[test]
8584
8585 fn test_registry_visible_entries_cover_dispatch() {
8586 let registry = build_builtin_registry();
8591 let names: Vec<String> = registry
8592 .visible_entries()
8593 .iter()
8594 .map(|c| c.name.clone())
8595 .collect();
8596 for recognized in [
8597 "/help",
8598 "/clear",
8599 "/new",
8600 "/exit",
8601 "/quit",
8602 "/version",
8603 "/changelog",
8604 "/model",
8605 "/session",
8606 "/theme",
8607 "/compact",
8608 "/copy",
8609 "/hotkeys",
8610 "/tools",
8611 "/images",
8612 "/thinking",
8613 "/armin",
8614 "/earendil",
8615 ] {
8616 assert!(
8617 names.contains(&recognized.to_string()),
8618 "{recognized} missing from autocomplete list"
8619 );
8620 }
8621 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
8623 assert!(
8624 !names.contains(&hidden.to_string()),
8625 "{hidden} should be hidden from autocomplete"
8626 );
8627 }
8628 }
8629
8630 #[test]
8631 fn test_agent_event_mapping_creates_assistant_and_tool() {
8632 use rpi_ai::types::{
8635 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
8636 ToolCall, ToolCallType, Usage,
8637 };
8638
8639 let state = Arc::new(TuiState {
8640 current_assistant: std::sync::Mutex::new(None),
8641 tool_components: std::sync::Mutex::new(HashMap::new()),
8642 bash_components: std::sync::Mutex::new(HashMap::new()),
8643 themes_enabled: true,
8644 hide_thinking: std::sync::Mutex::new(false),
8645 tool_outputs_expanded: std::sync::Mutex::new(false),
8646 show_terminal_progress: true,
8647 status: std::sync::Mutex::new(RunStatus::Idle),
8648 js_preparation_cancel: std::sync::Mutex::new(None),
8649 footer: Arc::new(FooterComponent::new()),
8650 status_container: Arc::new(Container::new()),
8651 chat_container: Arc::new(Container::new()),
8652 loader: Arc::new(Loader::new()),
8653 last_assistant_text: std::sync::Mutex::new(String::new()),
8654 active_selector: std::sync::Mutex::new(None),
8655 active_extension_editor: std::sync::Mutex::new(None),
8656 active_extension_input: std::sync::Mutex::new(None),
8657 active_extension_cancel: std::sync::Mutex::new(None),
8658 autocomplete: AutocompleteManager::new(),
8659 autocomplete_container: Arc::new(Container::new()),
8660 autocomplete_max_visible: 5,
8661 pending_images: std::sync::Mutex::new(Vec::new()),
8662 theme_manager: Arc::new(ThemeManager::new()),
8663 tui: None,
8664 current_model_id: std::sync::Mutex::new(String::new()),
8665 show_images: std::sync::Mutex::new(true),
8666 history: std::sync::Mutex::new(Vec::new()),
8667 history_index: std::sync::Mutex::new(-1),
8668 history_draft: std::sync::Mutex::new(None),
8669 last_input_tokens: std::sync::Mutex::new(0),
8670 scoped_edit: std::sync::Mutex::new(None),
8671 markdown_transformer: std::sync::Mutex::new(None),
8672 extension_session: Arc::new(std::sync::Mutex::new(
8673 rpi_extensions::ExtensionSession::none(),
8674 )),
8675 });
8676
8677 let assistant = AssistantMessage {
8681 role: rpi_ai::types::AssistantRole,
8682 content: vec![
8683 Content::Thinking(ThinkingContent {
8684 kind: ThinkingContentType,
8685 thinking: "Reasoning about the reply.".into(),
8686 thinking_signature: None,
8687 redacted: false,
8688 }),
8689 Content::Text(TextContent {
8690 kind: TextContentType,
8691 text: "Hello.".into(),
8692 text_signature: None,
8693 }),
8694 Content::ToolCall(ToolCall {
8695 kind: ToolCallType,
8696 id: "tc1".into(),
8697 name: "bash".into(),
8698 arguments: serde_json::json!({"command": "echo hi"}),
8699 thought_signature: None,
8700 namespace: None,
8701 }),
8702 ],
8703 api: rpi_ai::Api::AnthropicMessages,
8704 provider: "anthropic".into(),
8705 model: "claude-sonnet-5".into(),
8706 response_model: None,
8707 response_id: None,
8708 usage: Usage::zero(),
8709 stop_reason: StopReason::Stop,
8710 deferred: None,
8711 error_message: None,
8712 raw_stop_reason: None,
8713 end_turn: None,
8714 timestamp: 0,
8715 };
8716
8717 let comp = Arc::new(AssistantMessageComponent::new(
8720 AssistantMessageOptions::default(),
8721 ));
8722 comp.set_streaming(true);
8723 comp.update_blocks(&assistant_blocks(&assistant));
8724 let chat = Arc::new(Container::new());
8725 chat.add_child(comp.clone());
8726 *state.current_assistant.lock().unwrap() = Some(comp);
8727
8728 for c in &assistant.content {
8730 if let Content::ToolCall(tc) = c {
8731 let mut tools = state.tool_components.lock().unwrap();
8732 if !tools.contains_key(&tc.id) {
8733 let tc_comp = Arc::new(ToolExecutionComponent::new(
8734 &tc.name,
8735 &tc.arguments.to_string(),
8736 ));
8737 tc_comp.set_running();
8738 chat.add_child(tc_comp.clone());
8739 tools.insert(tc.id.clone(), tc_comp);
8740 }
8741 }
8742 }
8743
8744 let rendered = chat.render(80);
8748 let joined: String = rendered.join("\n");
8749 assert!(
8750 joined.contains("Hello."),
8751 "assistant text not rendered: {joined}"
8752 );
8753 assert!(
8754 joined.contains("Reasoning about the reply."),
8755 "thinking block not rendered: {joined}"
8756 );
8757 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
8758 assert!(state.current_assistant.lock().unwrap().is_some());
8759
8760 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
8762 ended.set_result("hi", false);
8763 assert!(state.tool_components.lock().unwrap().is_empty());
8764
8765 assert!(state.try_start_working());
8769 assert!(
8770 !state.try_start_working(),
8771 "a second submit must be rejected"
8772 );
8773 state.set_status(RunStatus::Idle);
8774 state.set_status(RunStatus::Working);
8775 assert_eq!(state.status_container.child_count(), 1);
8776 assert!(state.footer.get_status().is_empty());
8777 state.show_retry(3, 10, 8_000);
8778 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
8779 assert!(retry_status.contains("Retrying (3/10)"));
8780 state.set_status(RunStatus::Working);
8781 {
8782 let mut bash = state.bash_components.lock().unwrap();
8783 bash.insert(
8784 "bash-1".into(),
8785 Arc::new(BashExecutionComponent::new("one")),
8786 );
8787 bash.insert(
8788 "bash-2".into(),
8789 Arc::new(BashExecutionComponent::new("two")),
8790 );
8791 }
8792 state.sync_working_loader_with_bash();
8793 assert_eq!(state.status_container.child_count(), 0);
8794 state.bash_components.lock().unwrap().remove("bash-1");
8795 state.sync_working_loader_with_bash();
8796 assert_eq!(state.status_container.child_count(), 0);
8797 state.bash_components.lock().unwrap().remove("bash-2");
8798 state.sync_working_loader_with_bash();
8799 assert_eq!(state.status_container.child_count(), 1);
8800
8801 state.set_status(RunStatus::Aborting);
8802 assert_eq!(state.status_container.child_count(), 0);
8803 assert!(!state.loader.is_running());
8804 }
8805
8806 #[test]
8807 fn fresh_launch_does_not_restore_old_history() {
8808 let fresh = Args::default();
8809 assert!(!launch_restores_history(&fresh));
8810
8811 let continued = Args {
8812 continue_session: true,
8813 ..Args::default()
8814 };
8815 assert!(launch_restores_history(&continued));
8816
8817 let selected = Args {
8818 session: Some("session-id".into()),
8819 ..Args::default()
8820 };
8821 assert!(launch_restores_history(&selected));
8822 }
8823
8824 #[test]
8825 fn test_short_model_name() {
8826 assert_eq!(
8827 short_model_name("anthropic:claude-sonnet-5"),
8828 "claude-sonnet-5"
8829 );
8830 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
8831 }
8832
8833 #[test]
8834 fn model_selector_items_are_deduplicated_and_provider_qualified() {
8835 use rpi_ai::{Api, Model};
8836
8837 let mut gateway = Model::new(
8838 "gpt-5.6-sol",
8839 "GPT 5.6 Sol",
8840 Api::OpenaiCompletions,
8841 "routeryo-copy",
8842 "https://gateway.example.com",
8843 );
8844 let duplicate = gateway.clone();
8845 let anthropic = Model::new(
8846 "claude-sonnet-5",
8847 "Claude Sonnet 5",
8848 Api::AnthropicMessages,
8849 "anthropic",
8850 "https://api.anthropic.com",
8851 );
8852 gateway.headers = Some(std::collections::BTreeMap::from([(
8853 "authorization".into(),
8854 "Bearer test".into(),
8855 )]));
8856
8857 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
8858 assert_eq!(items.len(), 2);
8859 assert_eq!(items[0].value, "gpt-5.6-sol");
8860 assert_eq!(items[0].label, "GPT 5.6 Sol");
8861 assert_eq!(
8862 items[0].description.as_deref(),
8863 Some("routeryo-copy/gpt-5.6-sol (current)")
8864 );
8865 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
8866 }
8867
8868 #[test]
8869 fn model_selector_match_accepts_bare_and_qualified_ids() {
8870 use rpi_ai::{Api, Model};
8871
8872 let gateway = Model::new(
8873 "gpt-5.6-sol",
8874 "GPT 5.6 Sol",
8875 Api::OpenaiCompletions,
8876 "routeryo-copy",
8877 "https://gateway.example.com",
8878 );
8879 let anthropic = Model::new(
8880 "claude-sonnet-5",
8881 "Claude Sonnet 5",
8882 Api::AnthropicMessages,
8883 "anthropic",
8884 "https://api.anthropic.com",
8885 );
8886 let catalog = [gateway, anthropic];
8887 assert_eq!(
8888 find_model_selector_match(&catalog, "gpt-5.6-sol")
8889 .unwrap()
8890 .provider,
8891 "routeryo-copy"
8892 );
8893 assert_eq!(
8894 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
8895 .unwrap()
8896 .id,
8897 "gpt-5.6-sol"
8898 );
8899 assert_eq!(
8900 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
8901 .unwrap()
8902 .id,
8903 "claude-sonnet-5"
8904 );
8905 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
8906 }
8907
8908 #[test]
8909 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
8910 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
8911
8912 let failed = AssistantMessage {
8913 role: AssistantRole,
8914 content: Vec::new(),
8915 api: rpi_ai::Api::AnthropicMessages,
8916 provider: "anthropic".into(),
8917 model: "claude-sonnet-5".into(),
8918 response_model: None,
8919 response_id: None,
8920 usage: Usage::zero(),
8921 stop_reason: StopReason::Error,
8922 deferred: None,
8923 error_message: Some("upstream returned 401".into()),
8924 raw_stop_reason: None,
8925 end_turn: None,
8926 timestamp: 0,
8927 };
8928 assert_eq!(
8929 assistant_error_text(&failed).as_deref(),
8930 Some("upstream returned 401")
8931 );
8932
8933 let mut no_detail = failed;
8934 no_detail.error_message = Some(" ".into());
8935 assert_eq!(
8936 assistant_error_text(&no_detail).as_deref(),
8937 Some("Provider request failed.")
8938 );
8939
8940 let mut aborted = no_detail;
8941 aborted.stop_reason = StopReason::Aborted;
8942 aborted.error_message = Some("abort error: Request aborted".into());
8943 assert_eq!(
8944 assistant_error_text(&aborted).as_deref(),
8945 Some("abort error: Request aborted")
8946 );
8947
8948 aborted.error_message = None;
8949 assert_eq!(
8950 assistant_error_text(&aborted).as_deref(),
8951 Some("Request aborted.")
8952 );
8953 }
8954
8955 #[test]
8956 fn test_cycle_next_model_wraps_around() {
8957 use rpi_ai::{Api, Model};
8958 let mk = |id: &str| {
8959 Model::new(
8960 id,
8961 id,
8962 Api::AnthropicMessages,
8963 "anthropic",
8964 "https://api.anthropic.com",
8965 )
8966 };
8967 let catalog = [mk("a"), mk("b"), mk("c")];
8968 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
8970 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
8971 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
8973 let empty: Vec<Model> = vec![];
8975 assert!(cycle_next_model(&empty, "a").is_none());
8976 }
8977
8978 #[test]
8979 fn test_autocomplete_slash_suggestions_render() {
8980 let state = Arc::new(TuiState {
8983 current_assistant: std::sync::Mutex::new(None),
8984 tool_components: std::sync::Mutex::new(HashMap::new()),
8985 bash_components: std::sync::Mutex::new(HashMap::new()),
8986 themes_enabled: true,
8987 hide_thinking: std::sync::Mutex::new(false),
8988 tool_outputs_expanded: std::sync::Mutex::new(false),
8989 show_terminal_progress: true,
8990 status: std::sync::Mutex::new(RunStatus::Idle),
8991 js_preparation_cancel: std::sync::Mutex::new(None),
8992 footer: Arc::new(FooterComponent::new()),
8993 status_container: Arc::new(Container::new()),
8994 chat_container: Arc::new(Container::new()),
8995 loader: Arc::new(Loader::new()),
8996 last_assistant_text: std::sync::Mutex::new(String::new()),
8997 active_selector: std::sync::Mutex::new(None),
8998 active_extension_editor: std::sync::Mutex::new(None),
8999 active_extension_input: std::sync::Mutex::new(None),
9000 active_extension_cancel: std::sync::Mutex::new(None),
9001 autocomplete: AutocompleteManager::new(),
9002 autocomplete_container: Arc::new(Container::new()),
9003 autocomplete_max_visible: 5,
9004 pending_images: std::sync::Mutex::new(Vec::new()),
9005 theme_manager: Arc::new(ThemeManager::new()),
9006 tui: None,
9007 current_model_id: std::sync::Mutex::new(String::new()),
9008 show_images: std::sync::Mutex::new(true),
9009 history: std::sync::Mutex::new(Vec::new()),
9010 history_index: std::sync::Mutex::new(-1),
9011 history_draft: std::sync::Mutex::new(None),
9012 last_input_tokens: std::sync::Mutex::new(0),
9013 scoped_edit: std::sync::Mutex::new(None),
9014 markdown_transformer: std::sync::Mutex::new(None),
9015 extension_session: Arc::new(std::sync::Mutex::new(
9016 rpi_extensions::ExtensionSession::none(),
9017 )),
9018 });
9019 {
9020 let mut combined = CombinedAutocompleteProvider::new();
9021 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9022 build_builtin_registry().visible_entries(),
9023 )));
9024 state.autocomplete.set_provider(Arc::new(combined));
9025 }
9026
9027 let editor = Arc::new(Editor::simple());
9028 editor.set_text("/he");
9029 editor.set_cursor(0, 3);
9030 refresh_autocomplete(&state, &editor);
9031 let lines = state.autocomplete_container.render(80);
9032 let joined: String = lines.join("\n");
9033 assert!(
9034 joined.contains("/help"),
9035 "slash suggestions not rendered: {joined}"
9036 );
9037
9038 editor.set_text("hello");
9040 editor.set_cursor(0, 5);
9041 refresh_autocomplete(&state, &editor);
9042 assert!(state.autocomplete_container.render(80).is_empty());
9043 }
9044
9045 #[test]
9046 fn test_select_list_swap_restores_editor() {
9047 let state = Arc::new(TuiState {
9051 current_assistant: std::sync::Mutex::new(None),
9052 tool_components: std::sync::Mutex::new(HashMap::new()),
9053 bash_components: std::sync::Mutex::new(HashMap::new()),
9054 themes_enabled: true,
9055 hide_thinking: std::sync::Mutex::new(false),
9056 tool_outputs_expanded: std::sync::Mutex::new(false),
9057 show_terminal_progress: true,
9058 status: std::sync::Mutex::new(RunStatus::Idle),
9059 js_preparation_cancel: std::sync::Mutex::new(None),
9060 footer: Arc::new(FooterComponent::new()),
9061 status_container: Arc::new(Container::new()),
9062 chat_container: Arc::new(Container::new()),
9063 loader: Arc::new(Loader::new()),
9064 last_assistant_text: std::sync::Mutex::new(String::new()),
9065 active_selector: std::sync::Mutex::new(None),
9066 active_extension_editor: std::sync::Mutex::new(None),
9067 active_extension_input: std::sync::Mutex::new(None),
9068 active_extension_cancel: std::sync::Mutex::new(None),
9069 autocomplete: AutocompleteManager::new(),
9070 autocomplete_container: Arc::new(Container::new()),
9071 autocomplete_max_visible: 5,
9072 pending_images: std::sync::Mutex::new(Vec::new()),
9073 theme_manager: Arc::new(ThemeManager::new()),
9074 tui: None,
9075 current_model_id: std::sync::Mutex::new(String::new()),
9076 show_images: std::sync::Mutex::new(true),
9077 history: std::sync::Mutex::new(Vec::new()),
9078 history_index: std::sync::Mutex::new(-1),
9079 history_draft: std::sync::Mutex::new(None),
9080 last_input_tokens: std::sync::Mutex::new(0),
9081 scoped_edit: std::sync::Mutex::new(None),
9082 markdown_transformer: std::sync::Mutex::new(None),
9083 extension_session: Arc::new(std::sync::Mutex::new(
9084 rpi_extensions::ExtensionSession::none(),
9085 )),
9086 });
9087 let editor_container = Arc::new(Container::new());
9088 let editor = Arc::new(Editor::simple());
9089 editor_container.add_child(editor.clone());
9090 assert!(!state.selector_open());
9091
9092 let tui_terminal = Box::new(ProcessTerminal::new());
9093 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9094 let list = Arc::new(SelectList::new(
9095 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9096 5,
9097 ));
9098 open_selector(
9099 &state,
9100 &editor_container,
9101 &editor,
9102 &tui,
9103 list,
9104 SelectorKind::Theme,
9105 );
9106 assert!(state.selector_open());
9107 assert_eq!(editor_container.child_count(), 1);
9109
9110 close_selector(&state, &editor_container, &editor, &tui);
9111 assert!(!state.selector_open());
9112 assert_eq!(editor_container.child_count(), 1);
9114 }
9115
9116 #[test]
9117 fn test_message_history_browse_restores_draft() {
9118 let state = Arc::new(TuiState {
9122 current_assistant: std::sync::Mutex::new(None),
9123 tool_components: std::sync::Mutex::new(HashMap::new()),
9124 bash_components: std::sync::Mutex::new(HashMap::new()),
9125 themes_enabled: true,
9126 hide_thinking: std::sync::Mutex::new(false),
9127 tool_outputs_expanded: std::sync::Mutex::new(false),
9128 show_terminal_progress: true,
9129 status: std::sync::Mutex::new(RunStatus::Idle),
9130 js_preparation_cancel: std::sync::Mutex::new(None),
9131 footer: Arc::new(FooterComponent::new()),
9132 status_container: Arc::new(Container::new()),
9133 chat_container: Arc::new(Container::new()),
9134 loader: Arc::new(Loader::new()),
9135 last_assistant_text: std::sync::Mutex::new(String::new()),
9136 active_selector: std::sync::Mutex::new(None),
9137 active_extension_editor: std::sync::Mutex::new(None),
9138 active_extension_input: std::sync::Mutex::new(None),
9139 active_extension_cancel: std::sync::Mutex::new(None),
9140 autocomplete: AutocompleteManager::new(),
9141 autocomplete_container: Arc::new(Container::new()),
9142 autocomplete_max_visible: 5,
9143 pending_images: std::sync::Mutex::new(Vec::new()),
9144 theme_manager: Arc::new(ThemeManager::new()),
9145 tui: None,
9146 current_model_id: std::sync::Mutex::new(String::new()),
9147 show_images: std::sync::Mutex::new(true),
9148 history: std::sync::Mutex::new(Vec::new()),
9149 history_index: std::sync::Mutex::new(-1),
9150 history_draft: std::sync::Mutex::new(None),
9151 last_input_tokens: std::sync::Mutex::new(0),
9152 scoped_edit: std::sync::Mutex::new(None),
9153 markdown_transformer: std::sync::Mutex::new(None),
9154 extension_session: Arc::new(std::sync::Mutex::new(
9155 rpi_extensions::ExtensionSession::none(),
9156 )),
9157 });
9158 let editor = Arc::new(Editor::simple());
9159
9160 push_history(&state, "first message");
9161 push_history(&state, "second message");
9162 push_history(&state, "second message");
9164 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
9166 assert_eq!(state.history.lock().unwrap()[0], "second message");
9167
9168 editor.set_text("half-typed");
9170 editor.set_cursor(0, 11);
9171
9172 navigate_history(&state, &editor, -1);
9174 assert_eq!(editor.get_text(), "second message");
9175 assert_eq!(*state.history_index.lock().unwrap(), 0);
9176 navigate_history(&state, &editor, -1);
9178 assert_eq!(editor.get_text(), "first message");
9179 assert_eq!(*state.history_index.lock().unwrap(), 1);
9180 navigate_history(&state, &editor, -1);
9182 assert_eq!(editor.get_text(), "first message");
9183 navigate_history(&state, &editor, 1);
9185 assert_eq!(editor.get_text(), "second message");
9186 navigate_history(&state, &editor, 1);
9188 assert_eq!(editor.get_text(), "half-typed");
9189 assert_eq!(*state.history_index.lock().unwrap(), -1);
9190 }
9191
9192 #[test]
9193 fn test_accept_top_suggestion_replaces_prefix() {
9194 let state = Arc::new(TuiState {
9196 current_assistant: std::sync::Mutex::new(None),
9197 tool_components: std::sync::Mutex::new(HashMap::new()),
9198 bash_components: std::sync::Mutex::new(HashMap::new()),
9199 themes_enabled: true,
9200 hide_thinking: std::sync::Mutex::new(false),
9201 tool_outputs_expanded: std::sync::Mutex::new(false),
9202 show_terminal_progress: true,
9203 status: std::sync::Mutex::new(RunStatus::Idle),
9204 js_preparation_cancel: std::sync::Mutex::new(None),
9205 footer: Arc::new(FooterComponent::new()),
9206 status_container: Arc::new(Container::new()),
9207 chat_container: Arc::new(Container::new()),
9208 loader: Arc::new(Loader::new()),
9209 last_assistant_text: std::sync::Mutex::new(String::new()),
9210 active_selector: std::sync::Mutex::new(None),
9211 active_extension_editor: std::sync::Mutex::new(None),
9212 active_extension_input: std::sync::Mutex::new(None),
9213 active_extension_cancel: std::sync::Mutex::new(None),
9214 autocomplete: AutocompleteManager::new(),
9215 autocomplete_container: Arc::new(Container::new()),
9216 autocomplete_max_visible: 5,
9217 pending_images: std::sync::Mutex::new(Vec::new()),
9218 theme_manager: Arc::new(ThemeManager::new()),
9219 tui: None,
9220 current_model_id: std::sync::Mutex::new(String::new()),
9221 show_images: std::sync::Mutex::new(true),
9222 history: std::sync::Mutex::new(Vec::new()),
9223 history_index: std::sync::Mutex::new(-1),
9224 history_draft: std::sync::Mutex::new(None),
9225 last_input_tokens: std::sync::Mutex::new(0),
9226 scoped_edit: std::sync::Mutex::new(None),
9227 markdown_transformer: std::sync::Mutex::new(None),
9228 extension_session: Arc::new(std::sync::Mutex::new(
9229 rpi_extensions::ExtensionSession::none(),
9230 )),
9231 });
9232 {
9233 let mut combined = CombinedAutocompleteProvider::new();
9234 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9235 build_builtin_registry().visible_entries(),
9236 )));
9237 state.autocomplete.set_provider(Arc::new(combined));
9238 }
9239 let editor = Arc::new(Editor::simple());
9240 editor.set_text("/he");
9241 editor.set_cursor(0, 3);
9242 refresh_autocomplete(&state, &editor);
9243 let accepted = accept_top_suggestion(&state, &editor);
9244 assert!(accepted, "should accept the top suggestion");
9245 let text = editor.get_text();
9246 assert!(
9247 text.starts_with("/help"),
9248 "editor text should start with /help, got {text}"
9249 );
9250 }
9251
9252 #[test]
9253 fn configured_key_parser_supports_native_notation() {
9254 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
9255 assert_eq!(combo.code, KeyCode::Char('g'));
9256 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
9257 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
9258 assert_eq!(combo.code, KeyCode::BackTab);
9259 }
9260
9261 #[test]
9262 fn double_escape_trigger_has_half_second_window() {
9263 let now = std::time::Instant::now();
9264 assert!(!double_escape_trigger(None, now));
9265 assert!(double_escape_trigger(
9266 Some(now - std::time::Duration::from_millis(500)),
9267 now
9268 ));
9269 assert!(!double_escape_trigger(
9270 Some(now - std::time::Duration::from_millis(501)),
9271 now
9272 ));
9273 }
9274}