1use std::collections::HashMap;
27use std::io::IsTerminal;
28use std::sync::{Arc, Mutex};
29
30use base64::Engine;
31use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
32use tokio::sync::{broadcast, mpsc};
33use tokio_util::sync::CancellationToken;
34
35use rpi_agent::{AgentEvent, AgentMessage};
36use rpi_ai::types::{AssistantMessage, Content, UserMessage};
37use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
38use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
39use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
40#[cfg(test)]
41use rpi_tui::strip_ansi;
42use rpi_tui::{
43 apply_theme_preset, AltScreenSearch, AssistantBlock, AssistantMessageComponent,
44 AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
45 BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
46 EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
47 FooterComponent, Image, ImageOptions, Input, Loader, Markdown, ProcessTerminal, ScrollView,
48 ScrollViewOptions, SearchBar, SearchableSelectList, SelectItem, SelectList, SettingItem,
49 SettingsList, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
50 StackChild, StackEntry, StatusIndicator, Text, ThemeManager, ThemePreset,
51 ToolExecutionComponent, ToolStatus, TuiAltScreen, UserMessageComponent, VStack, WorkingState,
52 TUI,
53};
54use rpi_tui::{bold as tui_bold, theme as current_theme};
55
56#[allow(unused_imports)]
57use rpi_tui::BashStatus;
58
59use crate::args::Args;
60use crate::session_driver::{assistant_blocks, assistant_tool_calls};
61use crate::transcript_view::UiEvent;
62
63type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
70
71fn build_markdown_transformer(
96 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
97) -> Option<MarkdownTransformer> {
98 let snapshot = snapshot?;
99 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
104 if renderers.is_empty() {
105 return None;
106 }
107 Some(Arc::new(move |raw: &str| -> String {
108 transform_markdown_chain(&snapshot, &renderers, raw)
109 }))
110}
111
112fn transform_markdown_chain(
120 snapshot: &rpi_extensions::RegistrySnapshot,
121 renderers: &[rpi_extensions::RegisteredRenderer],
122 raw: &str,
123) -> String {
124 if !snapshot.is_active() {
129 return raw.to_string();
130 }
131
132 let mut current = raw.to_string();
133 for renderer in renderers {
134 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
135 Ok(s) => s,
136 Err(_) => return current, };
138 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
148 let mut out = rpi_plugin_sdk::StbString::empty();
149 let rc = (renderer.render_fn)(
150 rpi_plugin_sdk::StbStringRef::from_str(&input),
151 &mut out as *mut rpi_plugin_sdk::StbString,
152 renderer.user_data,
153 );
154 let text = if rc == 0 {
155 let s = out.to_string_lossy();
156 Some(s)
157 } else {
158 None
159 };
160 out.free_with(Some(renderer.plugin_free_string));
164 text
165 }));
166 let out_text = match outcome {
167 Ok(Some(s)) => s,
168 Ok(None) => return current, Err(_) => return current, };
174 let next = serde_json::from_str::<serde_json::Value>(&out_text)
177 .ok()
178 .and_then(|v| {
179 v.get("markdown")
180 .and_then(|m| m.as_str())
181 .map(|s| s.to_string())
182 })
183 .unwrap_or(current);
184 current = next;
185 }
186 current
187}
188
189#[derive(Clone)]
213struct CommandContext {
214 chat: Arc<Container>,
215 tui: Arc<TuiAltScreen>,
216 tx: mpsc::UnboundedSender<TuiMessage>,
217 state: Arc<TuiState>,
218 editor: Arc<Editor>,
219 editor_container: Arc<Container>,
220 lane: Arc<dyn AgentLane>,
221 model_catalog: Arc<Vec<rpi_ai::Model>>,
222 lane_model_id: String,
226 cwd: std::path::PathBuf,
227 resources: Arc<rpi_harness::types::AgentHarnessResources>,
231 reload_context: Arc<crate::session::ReloadContext>,
237 session: Arc<crate::agent_session::AgentSession>,
241}
242
243trait SlashCommand: Send + Sync {
245 fn name(&self) -> &str;
247 fn aliases(&self) -> &'static [&'static str] {
251 &[]
252 }
253 fn visible(&self) -> bool {
256 true
257 }
258 fn alias_visible(&self) -> &'static [&'static str] {
262 &[]
263 }
264 fn description(&self) -> &'static str {
267 ""
268 }
269 fn description_owned(&self) -> String {
270 self.description().to_string()
271 }
272 fn execute(&self, ctx: &CommandContext, args: &str);
278}
279
280struct CommandRegistry {
283 commands: Vec<Arc<dyn SlashCommand>>,
284}
285
286impl CommandRegistry {
287 fn new() -> Self {
288 Self {
289 commands: Vec::new(),
290 }
291 }
292
293 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
294 self.commands.push(cmd);
295 }
296
297 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
300 self.commands
301 .iter()
302 .find(|c| c.name() == token || c.aliases().contains(&token))
303 }
304
305 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
311 let mut out: Vec<SlashCommandEntry> = Vec::new();
312 for c in &self.commands {
313 let description = c.description_owned();
314 if c.visible() && !description.is_empty() {
315 out.push(SlashCommandEntry {
316 name: c.name().into(),
317 description: description.clone(),
318 });
319 }
320 for alias in c.alias_visible() {
322 out.push(SlashCommandEntry {
323 name: (*alias).into(),
324 description: description.clone(),
325 });
326 }
327 }
328 out
329 }
330}
331
332fn dispatch_ui_prompt_event(state: &TuiState) {
334 use rpi_plugin_sdk::EventTag;
335
336 if let Ok(session) = state.extension_session.lock() {
337 if let Some(snapshot) = session.snapshot_arc() {
338 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
339 }
340 }
341}
342
343fn dispatch_ui_prompt_event_end(state: &TuiState) {
345 use rpi_plugin_sdk::EventTag;
346
347 if let Ok(session) = state.extension_session.lock() {
348 if let Some(snapshot) = session.snapshot_arc() {
349 rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
350 }
351 }
352}
353
354fn key_shortcut_name(code: KeyCode) -> Option<&'static str> {
358 Some(match code {
359 KeyCode::Char(' ') => "space",
360 KeyCode::Enter => "enter",
361 KeyCode::Esc => "escape",
362 KeyCode::Tab => "tab",
363 KeyCode::BackTab => "backtab",
364 KeyCode::Backspace => "backspace",
365 KeyCode::Delete => "delete",
366 KeyCode::Up => "up",
367 KeyCode::Down => "down",
368 KeyCode::Left => "left",
369 KeyCode::Right => "right",
370 KeyCode::Home => "home",
371 KeyCode::End => "end",
372 KeyCode::PageUp => "pageup",
373 KeyCode::PageDown => "pagedown",
374 _ => return None,
375 })
376}
377
378fn char_shortcut_name(ch: char) -> Option<String> {
381 if ch.is_ascii_graphic() {
382 Some(ch.to_ascii_lowercase().to_string())
383 } else {
384 None
385 }
386}
387
388fn dispatch_key_event(state: &TuiState, key: &KeyEvent, editor: &Editor) -> bool {
398 use rpi_plugin_sdk::EventTag;
399
400 let name = key_shortcut_name(key.code)
401 .map(str::to_string)
402 .or_else(|| match key.code {
403 KeyCode::Char(ch) => char_shortcut_name(ch),
404 KeyCode::F(n) => Some(format!("f{n}")),
405 _ => None,
406 });
407 let Some(name) = name else {
408 return false;
409 };
410
411 let Ok(session) = state.extension_session.lock() else {
412 return false;
413 };
414 let Some(snapshot) = session.snapshot_arc() else {
415 return false;
416 };
417 if !snapshot.has_shortcut(&name) || snapshot.handlers_for(EventTag::Input).is_empty() {
419 return false;
420 }
421 if !editor.get_text().is_empty() {
424 return false;
425 }
426
427 let kind = match key.kind {
428 KeyEventKind::Press => "press",
429 KeyEventKind::Repeat => "repeat",
430 KeyEventKind::Release => "release",
431 };
432 let payload = serde_json::json!({
433 "type": "key",
434 "key": name,
435 "kind": kind,
436 "ctrl": key.modifiers.contains(KeyModifiers::CONTROL),
437 "alt": key.modifiers.contains(KeyModifiers::ALT),
438 "shift": key.modifiers.contains(KeyModifiers::SHIFT),
439 })
440 .to_string();
441 rpi_extensions::dispatch_data_event_claiming(&snapshot, EventTag::Input, &payload)
446}
447
448fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) -> bool {
456 let mut parts = text.split_whitespace();
457 let token = parts.next().unwrap_or("");
458 let args = parts.collect::<Vec<_>>().join(" ");
459 match registry.find(token) {
460 Some(cmd) => {
461 cmd.execute(ctx, &args);
462 true
463 }
464 None => false,
465 }
466}
467
468fn now_ms() -> i64 {
472 std::time::SystemTime::now()
473 .duration_since(std::time::UNIX_EPOCH)
474 .map(|d| d.as_millis() as i64)
475 .unwrap_or(0)
476}
477
478fn parse_user_bash(text: &str) -> Option<(&str, bool)> {
485 let rest = text.strip_prefix('!')?;
486 let (command, exclude_from_context) = match rest.strip_prefix('!') {
487 Some(command) => (command, true),
488 None => (rest, false),
489 };
490 let command = command.trim();
491 if command.is_empty() {
492 return None;
493 }
494 Some((command, exclude_from_context))
495}
496
497fn start_user_bash(ctx: &CommandContext, command: &str, exclude_from_context: bool) {
505 let comp = Arc::new(BashExecutionComponent::new_with_context(
506 command,
507 exclude_from_context,
508 ));
509 comp.set_expanded(*ctx.state.tool_outputs_expanded.lock().unwrap());
510 ctx.chat.add_child(comp.clone());
511
512 let cancel = ctx.state.begin_user_bash();
515 ctx.tui.request_render(false);
516
517 let env: Arc<dyn rpi_tools::ExecutionEnv> =
518 Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
519 let tui = ctx.tui.clone();
520 let tx = ctx.tx.clone();
521 let cwd = ctx.cwd.clone();
522 let command_owned = command.to_string();
523
524 tokio::spawn(async move {
525 let comp_chunk = comp.clone();
526 let tui_chunk = tui.clone();
527 let on_chunk: Box<
528 dyn FnMut(&str, &dyn Fn() -> rpi_tools::shell_output::ShellCaptureProgress) + Send,
529 > = Box::new(move |_chunk, get_progress| {
530 comp_chunk.append_output(&get_progress().output);
533 tui_chunk.request_render(false);
534 });
535 let options = rpi_tools::shell_output::ShellCaptureOptions {
536 cwd: Some(cwd),
537 cancel: Some(&cancel),
538 on_chunk: Some(on_chunk),
539 return_execution_errors: true,
540 ..Default::default()
541 };
542 let result =
543 rpi_tools::shell_output::execute_shell_with_capture(&env, &command_owned, options)
544 .await;
545
546 let (exit_code, cancelled, output, truncated, full_output_path) = match result {
549 Ok(capture) => (
550 capture.exit_code,
551 capture.cancelled,
552 capture.output.clone(),
553 capture.truncated,
554 capture
555 .full_output_path
556 .as_ref()
557 .map(|path| path.to_string_lossy().into_owned()),
558 ),
559 Err(error) => (
560 None,
561 false,
562 format!("Bash command failed: {error}"),
563 false,
564 None,
565 ),
566 };
567 comp.append_output(&output);
568 comp.set_complete(
569 exit_code,
570 cancelled,
571 BashTruncation {
572 truncated,
573 full_output_path: full_output_path.clone(),
574 },
575 );
576 tui.render_now(false);
577
578 let _ = tx.send(TuiMessage::UserBashFinished(UserBashReport {
579 command: command_owned,
580 output,
581 exit_code,
582 cancelled,
583 truncated,
584 full_output_path,
585 exclude_from_context,
586 }));
587 });
588}
589
590struct ExtensionCommand {
595 name: String,
596 description: String,
597 session: crate::session::ExtensionSessionCell,
598}
599
600impl SlashCommand for ExtensionCommand {
601 fn name(&self) -> &str {
602 &self.name
603 }
604
605 fn description(&self) -> &'static str {
606 "extension command"
607 }
608
609 fn description_owned(&self) -> String {
610 self.description.clone()
611 }
612
613 fn execute(&self, ctx: &CommandContext, args: &str) {
614 let result = invoke_extension_command(&self.session, &self.name, args);
615 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
616 }
617}
618
619fn invoke_extension_command(
620 session: &crate::session::ExtensionSessionCell,
621 name: &str,
622 args: &str,
623) -> Option<serde_json::Value> {
624 let command = session
625 .lock()
626 .ok()
627 .and_then(|s| s.snapshot_arc())
628 .and_then(|snap| {
629 snap.commands()
630 .iter()
631 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
632 .cloned()
633 })?;
634 let input = serde_json::json!({ "args": args, "command": name });
635 let input = serde_json::to_string(&input).ok()?;
636 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
637 let mut out = rpi_plugin_sdk::StbString::empty();
638 let rc = (command.handler)(
639 rpi_plugin_sdk::StbStringRef::from_str(&input),
640 &mut out as *mut rpi_plugin_sdk::StbString,
641 command.user_data,
642 );
643 let text = if rc == 0 {
644 Some(out.to_string_lossy())
645 } else {
646 None
647 };
648 rpi_extensions::host_free_string(out);
649 text
650 }))
651 .ok()
652 .flatten()?;
653 serde_json::from_str(&outcome).ok()
654}
655
656fn handle_extension_ui_result(
657 result: Option<serde_json::Value>,
658 ctx: &CommandContext,
659 session: crate::session::ExtensionSessionCell,
660 command_name: String,
661) {
662 let Some(value) = result else {
663 add_error_message(&ctx.chat, "Extension command failed.");
664 ctx.tui.request_render(false);
665 return;
666 };
667 if value.is_null() {
671 ctx.tui.request_render(false);
672 return;
673 }
674 match value.get("kind").and_then(|v| v.as_str()) {
675 Some("message") | None => {
676 let fallback = value.to_string();
677 let text = value
678 .get("text")
679 .and_then(|v| v.as_str())
680 .unwrap_or(&fallback)
681 .to_string();
682 if !text.is_empty() {
683 add_note_message(&ctx.chat, &text);
684 }
685 ctx.tui.request_render(false);
686 }
687 Some("selector") => open_extension_selector(ctx, session, command_name, value),
688 Some("editor") => open_extension_editor(ctx, session, command_name, value),
689 Some("input") => open_extension_input(ctx, session, command_name, value),
693 Some(other) => {
694 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
695 ctx.tui.request_render(false);
696 }
697 }
698}
699
700fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
704 let colors = current_theme().colors;
705 let text = if bold {
706 tui_bold(title)
707 } else {
708 title.to_string()
709 };
710 Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
711}
712
713fn extension_dialog_hint(label: &str) -> Arc<Text> {
714 Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
715}
716
717fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
720 let text = if bold {
721 format!("**{}**", title)
722 } else {
723 title.to_string()
724 };
725 Arc::new(Markdown::new(text, 1, 0))
726}
727
728fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
731 Arc::new(Markdown::new(label, 1, 0))
732}
733
734fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
738 let callback = state.active_extension_cancel.lock().unwrap().take();
739 if let Some(callback) = callback {
740 callback();
741 true
742 } else {
743 false
744 }
745}
746
747fn open_extension_input(
748 ctx: &CommandContext,
749 session: crate::session::ExtensionSessionCell,
750 command_name: String,
751 value: serde_json::Value,
752) {
753 let title = value
754 .get("title")
755 .and_then(|v| v.as_str())
756 .filter(|title| !title.is_empty())
757 .unwrap_or("Input");
758 let input = value
759 .get("placeholder")
760 .and_then(|v| v.as_str())
761 .map(Input::with_placeholder)
762 .unwrap_or_default();
763 let input = Arc::new(input);
764 if let Some(initial) = value
765 .get("initialText")
766 .or_else(|| value.get("text"))
767 .and_then(|v| v.as_str())
768 {
769 input.set_value(initial);
770 }
771 input.set_focused(true);
772
773 let frame = Arc::new(Container::new());
774 frame.add_child(Arc::new(DynamicBorder::new()));
775 frame.add_child(Arc::new(Spacer::new(1)));
776 frame.add_child(extension_dialog_title(title, false));
777 frame.add_child(Arc::new(Spacer::new(1)));
778 frame.add_child(input.clone());
779 frame.add_child(Arc::new(Spacer::new(1)));
780 frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
781 frame.add_child(Arc::new(Spacer::new(1)));
782 frame.add_child(Arc::new(DynamicBorder::new()));
783
784 *ctx.state.active_extension_editor.lock().unwrap() = None;
785 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
786 ctx.editor_container.clear();
787 ctx.editor_container.add_child(frame);
788
789 let state = ctx.state.clone();
790 let ec = ctx.editor_container.clone();
791 let original = ctx.editor.clone();
792 let tui = ctx.tui.clone();
793 let session_submit = session.clone();
794 let command_submit = command_name.clone();
795 let ctx_submit = ctx.clone();
796 input.on_submit(Arc::new(move |text| {
797 let args = serde_json::json!({ "action": "input", "value": text, "text": text });
798 let result = invoke_extension_command(
799 &session_submit,
800 &command_submit,
801 &serde_json::to_string(&args).unwrap_or_default(),
802 );
803 close_extension_editor(&state, &ec, &original, &tui);
804 handle_extension_ui_result(
805 result,
806 &ctx_submit,
807 session_submit.clone(),
808 command_submit.clone(),
809 );
810 }));
811
812 let state_cancel = ctx.state.clone();
813 let ec_cancel = ctx.editor_container.clone();
814 let original_cancel = ctx.editor.clone();
815 let tui_cancel = ctx.tui.clone();
816 let session_cancel = session.clone();
817 let command_cancel = command_name.clone();
818 let ctx_cancel = ctx.clone();
819 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
820 let args = serde_json::json!({ "action": "cancel" });
821 let result = invoke_extension_command(
822 &session_cancel,
823 &command_cancel,
824 &serde_json::to_string(&args).unwrap_or_default(),
825 );
826 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
827 handle_extension_ui_result(
828 result,
829 &ctx_cancel,
830 session_cancel.clone(),
831 command_cancel.clone(),
832 );
833 }));
834
835 ctx.tui.set_focus(Some(input));
836 ctx.tui.request_render(false);
837}
838
839fn open_extension_selector(
840 ctx: &CommandContext,
841 session: crate::session::ExtensionSessionCell,
842 command_name: String,
843 value: serde_json::Value,
844) {
845 let items = value
846 .get("items")
847 .and_then(|v| v.as_array())
848 .map(|items| {
849 items
850 .iter()
851 .filter_map(|item| {
852 let value = if let Some(value) = item.as_str() {
855 value
856 } else {
857 item.get("value")?.as_str()?
858 };
859 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
860 let mut out = SelectItem::new(value, label);
861 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
862 out = out.with_description(desc);
863 }
864 Some(out)
865 })
866 .collect::<Vec<_>>()
867 })
868 .unwrap_or_default();
869 if items.is_empty() {
870 add_error_message(&ctx.chat, "Extension selector has no items.");
871 ctx.tui.request_render(false);
872 return;
873 }
874 let list = Arc::new(SelectList::new(items, 10));
875 let title = value
876 .get("title")
877 .and_then(|v| v.as_str())
878 .filter(|title| !title.is_empty())
879 .unwrap_or("Select");
880 let frame = Arc::new(Container::new());
881 frame.add_child(Arc::new(DynamicBorder::new()));
882 frame.add_child(Arc::new(Spacer::new(1)));
883 frame.add_child(extension_dialog_title(title, true));
884 frame.add_child(Arc::new(Spacer::new(1)));
885 frame.add_child(list.clone());
886 frame.add_child(Arc::new(Spacer::new(1)));
887 frame.add_child(extension_dialog_hint(
888 "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
889 ));
890 frame.add_child(Arc::new(Spacer::new(1)));
891 frame.add_child(Arc::new(DynamicBorder::new()));
892
893 let state = ctx.state.clone();
894 let ec = ctx.editor_container.clone();
895 let editor = ctx.editor.clone();
896 let tui = ctx.tui.clone();
897 let session_select = session.clone();
898 let command_select = command_name.clone();
899 let ctx_select = ctx.clone();
900 list.on_select(Arc::new(move |item| {
901 let args = serde_json::json!({ "action": "select", "value": item.value });
902 let result = invoke_extension_command(
903 &session_select,
904 &command_select,
905 &serde_json::to_string(&args).unwrap_or_default(),
906 );
907 close_selector(&state, &ec, &editor, &tui);
908 handle_extension_ui_result(
909 result,
910 &ctx_select,
911 session_select.clone(),
912 command_select.clone(),
913 );
914 }));
915 let state_cancel = ctx.state.clone();
916 let ec_cancel = ctx.editor_container.clone();
917 let editor_cancel = ctx.editor.clone();
918 let tui_cancel = ctx.tui.clone();
919 list.on_cancel(Arc::new(move || {
920 if !run_extension_cancel(&state_cancel) {
921 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
922 }
923 }));
924 let state_cancel = ctx.state.clone();
925 let ec_cancel = ctx.editor_container.clone();
926 let editor_cancel = ctx.editor.clone();
927 let tui_cancel = ctx.tui.clone();
928 let session_cancel = session.clone();
929 let command_cancel = command_name.clone();
930 let ctx_cancel = ctx.clone();
931 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
932 let args = serde_json::json!({ "action": "cancel" });
933 let result = invoke_extension_command(
934 &session_cancel,
935 &command_cancel,
936 &serde_json::to_string(&args).unwrap_or_default(),
937 );
938 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
939 handle_extension_ui_result(
940 result,
941 &ctx_cancel,
942 session_cancel.clone(),
943 command_cancel.clone(),
944 );
945 }));
946 open_selector_with_view(
947 &ctx.state,
948 &ctx.editor_container,
949 &ctx.editor,
950 &ctx.tui,
951 SelectorView::List(list),
952 frame,
953 SelectorKind::Extension,
954 );
955}
956
957fn open_extension_editor(
958 ctx: &CommandContext,
959 session: crate::session::ExtensionSessionCell,
960 command_name: String,
961 value: serde_json::Value,
962) {
963 let initial = value
964 .get("initialText")
965 .or_else(|| value.get("text"))
966 .and_then(|v| v.as_str())
967 .unwrap_or_default()
968 .to_string();
969 let title = value
970 .get("title")
971 .and_then(|v| v.as_str())
972 .filter(|title| !title.is_empty())
973 .unwrap_or("Editor");
974 let editor = Arc::new(Editor::new(
975 EditorOptions {
976 padding_x: 1,
977 autocomplete_max_visible: 0,
978 placeholder: value
979 .get("placeholder")
980 .and_then(|v| v.as_str())
981 .map(str::to_string),
982 initial_text: Some(initial),
983 },
984 EditorStyle {
985 prompt: "> ".to_string(),
986 placeholder: String::new(),
987 },
988 Arc::new(rpi_tui::Keybindings::new()),
989 ));
990 editor.set_focused(true);
991 let frame = Arc::new(Container::new());
992 frame.add_child(Arc::new(DynamicBorder::new()));
993 frame.add_child(Arc::new(Spacer::new(1)));
994 frame.add_child(extension_dialog_title(title, false));
995 frame.add_child(Arc::new(Spacer::new(1)));
996 frame.add_child(editor.clone());
997 frame.add_child(Arc::new(Spacer::new(1)));
998 frame.add_child(extension_dialog_hint(
999 "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1000 ));
1001 frame.add_child(Arc::new(Spacer::new(1)));
1002 frame.add_child(Arc::new(DynamicBorder::new()));
1003
1004 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1005 *ctx.state.active_extension_input.lock().unwrap() = None;
1006 ctx.editor_container.clear();
1007 ctx.editor_container.add_child(frame);
1008
1009 let state = ctx.state.clone();
1010 let ec = ctx.editor_container.clone();
1011 let original = ctx.editor.clone();
1012 let tui = ctx.tui.clone();
1013 let session_submit = session.clone();
1014 let command_submit = command_name.clone();
1015 let ctx_submit = ctx.clone();
1016 editor.on_submit(Arc::new(move |text| {
1017 let args = serde_json::json!({ "action": "edit", "text": text });
1018 let result = invoke_extension_command(
1019 &session_submit,
1020 &command_submit,
1021 &serde_json::to_string(&args).unwrap_or_default(),
1022 );
1023 close_extension_editor(&state, &ec, &original, &tui);
1024 handle_extension_ui_result(
1025 result,
1026 &ctx_submit,
1027 session_submit.clone(),
1028 command_submit.clone(),
1029 );
1030 }));
1031
1032 let state_cancel = ctx.state.clone();
1033 let ec_cancel = ctx.editor_container.clone();
1034 let original_cancel = ctx.editor.clone();
1035 let tui_cancel = ctx.tui.clone();
1036 let session_cancel = session.clone();
1037 let command_cancel = command_name.clone();
1038 let ctx_cancel = ctx.clone();
1039 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1040 let args = serde_json::json!({ "action": "cancel" });
1041 let result = invoke_extension_command(
1042 &session_cancel,
1043 &command_cancel,
1044 &serde_json::to_string(&args).unwrap_or_default(),
1045 );
1046 close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1047 handle_extension_ui_result(
1048 result,
1049 &ctx_cancel,
1050 session_cancel.clone(),
1051 command_cancel.clone(),
1052 );
1053 }));
1054
1055 ctx.tui.set_focus(Some(editor));
1056 ctx.tui.request_render(false);
1057}
1058
1059fn close_extension_editor(
1060 state: &Arc<TuiState>,
1061 editor_container: &Arc<Container>,
1062 editor: &Arc<Editor>,
1063 tui: &Arc<TuiAltScreen>,
1064) {
1065 editor_container.clear();
1066 editor_container.add_child(editor.clone());
1067 state.autocomplete_container.clear();
1068 *state.active_extension_editor.lock().unwrap() = None;
1069 *state.active_extension_input.lock().unwrap() = None;
1070 *state.active_extension_cancel.lock().unwrap() = None;
1071 editor.set_focused(true);
1072 tui.set_focus(Some(editor.clone()));
1073 tui.request_render(false);
1074}
1075
1076const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1079
1080#[derive(Clone)]
1085struct AskUserBridge {
1086 mailbox: rpi_extensions::UiDialogMailbox,
1087 visible: Arc<Mutex<Option<String>>>,
1088}
1089
1090impl AskUserBridge {
1091 fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1092 Self {
1093 mailbox,
1094 visible: Arc::new(Mutex::new(None)),
1095 }
1096 }
1097
1098 fn attach(&self) {
1100 self.mailbox.attach();
1101 }
1102
1103 fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1107 let mut visible = self.visible.lock().ok()?;
1108 if visible.is_some() {
1109 return None;
1110 }
1111 let request = self.mailbox.take_pending()?;
1112 *visible = Some(request.request_id.clone());
1113 Some(request)
1114 }
1115
1116 fn clear_visible(&self, id: &str) {
1117 if let Ok(mut visible) = self.visible.lock() {
1118 if visible.as_deref() == Some(id) {
1119 *visible = None;
1120 }
1121 }
1122 }
1123
1124 fn respond(&self, id: &str, answer: serde_json::Value) {
1126 let _ = self.mailbox.respond(id, answer);
1127 self.clear_visible(id);
1128 }
1129
1130 fn cancel(&self, id: &str) {
1132 let _ = self.mailbox.cancel(id);
1133 self.clear_visible(id);
1134 }
1135
1136 fn cancel_all(&self) {
1139 self.mailbox.cancel_all();
1140 if let Ok(mut visible) = self.visible.lock() {
1141 *visible = None;
1142 }
1143 }
1144
1145 fn shutdown(&self) {
1148 self.mailbox.detach();
1149 if let Ok(mut visible) = self.visible.lock() {
1150 *visible = None;
1151 }
1152 }
1153}
1154
1155#[derive(Clone)]
1159struct AskUserPrompt {
1160 question_id: String,
1161 question: String,
1162 header: Option<String>,
1163 context: Option<String>,
1164 options: Vec<(String, Option<String>)>,
1165 allow_multiple: bool,
1166 allow_freeform: bool,
1167 suggest: Option<String>,
1168 kind: String,
1169}
1170
1171fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1172 keys.iter().find_map(|key| {
1173 value
1174 .get(*key)
1175 .and_then(serde_json::Value::as_str)
1176 .map(str::trim)
1177 .filter(|text| !text.is_empty())
1178 .map(str::to_string)
1179 })
1180}
1181
1182fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1183 keys.iter()
1184 .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1185}
1186
1187fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1188 let mut options = Vec::new();
1189 let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1190 return options;
1191 };
1192 for item in items {
1193 if let Some(title) = item.as_str() {
1194 let title = title.trim();
1195 if !title.is_empty() {
1196 options.push((title.to_string(), None));
1197 }
1198 continue;
1199 }
1200 let Some(object) = item.as_object() else {
1201 continue;
1202 };
1203 let title = ["title", "label", "text", "value", "name"]
1204 .iter()
1205 .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1206 .map(str::trim)
1207 .filter(|title| !title.is_empty());
1208 let Some(title) = title else { continue };
1209 let description = object
1210 .get("description")
1211 .and_then(serde_json::Value::as_str)
1212 .map(str::trim)
1213 .filter(|text| !text.is_empty())
1214 .map(str::to_string);
1215 options.push((title.to_string(), description));
1216 }
1217 options
1218}
1219
1220fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1221 let ui = &request.ui;
1222 let kind = ui
1223 .get("kind")
1224 .and_then(serde_json::Value::as_str)
1225 .unwrap_or("selector")
1226 .to_string();
1227 let nested = ui
1231 .get("questions")
1232 .and_then(serde_json::Value::as_array)
1233 .and_then(|items| items.first());
1234 let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1235
1236 let question = ask_user_str(source, &["question", "summary", "title"])
1237 .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1238 .unwrap_or_default();
1239 let question_id = ask_user_str(source, &["id", "questionId"])
1240 .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1241 .unwrap_or_else(|| request.request_id.clone());
1242 let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1243 let context = ask_user_str(source, &["context", "message"])
1244 .or_else(|| ask_user_str(ui, &["context", "message"]));
1245 let mut options = ask_user_options(source);
1246 if options.is_empty() {
1247 options = ask_user_options(ui);
1248 }
1249 if kind == "confirm" && options.is_empty() {
1250 options = vec![("Yes".to_string(), None), ("No".to_string(), None)];
1251 }
1252 let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1253 .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1254 .unwrap_or(false);
1255 let allow_freeform =
1258 ask_user_bool(source, &["allowFreeform", "allow_freeform", "allow_custom"])
1259 .or_else(|| ask_user_bool(ui, &["allowFreeform", "allow_freeform", "allow_custom"]))
1260 .unwrap_or(options.is_empty());
1261 let suggest = ask_user_str(source, &["suggest", "placeholder"])
1262 .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1263
1264 AskUserPrompt {
1265 question_id,
1266 question,
1267 header,
1268 context,
1269 options,
1270 allow_multiple,
1271 allow_freeform,
1272 suggest,
1273 kind,
1274 }
1275}
1276
1277fn ask_user_frame(prompt: &AskUserPrompt, body: Arc<dyn Component>, hint: &str) -> Arc<Container> {
1280 let frame = Arc::new(Container::new());
1281 frame.add_child(Arc::new(DynamicBorder::new()));
1282 frame.add_child(Arc::new(Spacer::new(1)));
1283 if let Some(header) = prompt.header.as_deref() {
1284 frame.add_child(extension_dialog_title_md(header, true));
1285 }
1286 if !prompt.question.trim().is_empty() {
1287 frame.add_child(extension_dialog_title_md(
1288 &prompt.question,
1289 prompt.header.is_none(),
1290 ));
1291 }
1292 if let Some(context) = prompt.context.as_deref() {
1293 frame.add_child(extension_dialog_hint_md(context));
1294 }
1295 frame.add_child(Arc::new(Spacer::new(1)));
1296 frame.add_child(body);
1297 frame.add_child(Arc::new(Spacer::new(1)));
1298 frame.add_child(extension_dialog_hint(hint));
1299 frame.add_child(Arc::new(Spacer::new(1)));
1300 frame.add_child(Arc::new(DynamicBorder::new()));
1301 frame
1302}
1303
1304fn open_ask_user_dialog(
1306 ctx: &CommandContext,
1307 bridge: AskUserBridge,
1308 request: rpi_extensions::UiDialogRequest,
1309) {
1310 let prompt = parse_ask_user_prompt(&request);
1311 if prompt.kind == "input" || prompt.options.is_empty() {
1312 open_ask_user_input(ctx, bridge, request, prompt);
1313 } else {
1314 open_ask_user_selector(ctx, bridge, request, prompt);
1315 }
1316}
1317
1318fn open_ask_user_selector(
1319 ctx: &CommandContext,
1320 bridge: AskUserBridge,
1321 request: rpi_extensions::UiDialogRequest,
1322 prompt: AskUserPrompt,
1323) {
1324 let multi = prompt.allow_multiple && prompt.kind != "confirm";
1325 let mut items: Vec<SelectItem> = Vec::new();
1326 for (title, description) in &prompt.options {
1327 let item = SelectItem::new(title, title);
1328 let item = match description.as_deref() {
1329 Some(description) => item.with_description(description),
1330 None => item,
1331 };
1332 items.push(item);
1333 }
1334 if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1337 items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1338 }
1339 let list = if multi {
1340 Arc::new(SelectList::new_multi(items, 10))
1341 } else {
1342 Arc::new(SelectList::new(items, 10))
1343 };
1344
1345 let request_id = request.request_id.clone();
1346 let state = ctx.state.clone();
1347 let editor_container = ctx.editor_container.clone();
1348 let editor = ctx.editor.clone();
1349 let tui = ctx.tui.clone();
1350
1351 let bridge_select = bridge.clone();
1354 let request_for_freeform = request.clone();
1355 let prompt_for_freeform = AskUserPrompt {
1356 question_id: prompt.question_id.clone(),
1357 question: prompt.question.clone(),
1358 header: prompt.header.clone(),
1359 context: prompt.context.clone(),
1360 options: Vec::new(),
1361 allow_multiple: false,
1362 allow_freeform: true,
1363 suggest: prompt.suggest.clone(),
1364 kind: "input".to_string(),
1365 };
1366 let state_select = state.clone();
1367 let ec_select = editor_container.clone();
1368 let editor_select = editor.clone();
1369 let tui_select = tui.clone();
1370 let ctx_select = ctx.clone();
1371 list.on_select(Arc::new(move |item: &SelectItem| {
1372 if item.value == ASK_USER_FREEFORM {
1373 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1374 open_ask_user_input(
1375 &ctx_select,
1376 bridge_select.clone(),
1377 request_for_freeform.clone(),
1378 prompt_for_freeform.clone(),
1379 );
1380 return;
1381 }
1382 bridge_select.respond(
1383 &request_id,
1384 serde_json::json!({
1385 "values": [item.display_value()],
1386 "kind": "selector",
1387 }),
1388 );
1389 close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1390 }));
1391
1392 if multi {
1393 let bridge_multi = bridge.clone();
1394 let request_id_multi = request.request_id.clone();
1395 let state_multi = state.clone();
1396 let ec_multi = editor_container.clone();
1397 let editor_multi = editor.clone();
1398 let tui_multi = tui.clone();
1399 list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
1400 let values: Vec<String> = selected
1401 .iter()
1402 .map(|item| item.display_value().to_string())
1403 .collect();
1404 bridge_multi.respond(
1405 &request_id_multi,
1406 serde_json::json!({"values": values, "kind": "selector"}),
1407 );
1408 close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
1409 }));
1410 }
1411
1412 let bridge_cancel = bridge.clone();
1413 let request_id_cancel = request.request_id.clone();
1414 let state_cancel = state.clone();
1415 let ec_cancel = editor_container.clone();
1416 let editor_cancel = editor.clone();
1417 let tui_cancel = tui.clone();
1418 list.on_cancel(Arc::new(move || {
1419 bridge_cancel.cancel(&request_id_cancel);
1420 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1421 }));
1422
1423 let hint = if multi {
1424 "Space toggle · Enter submit · Esc cancel"
1425 } else {
1426 "Enter select · Esc cancel"
1427 };
1428 let frame = ask_user_frame(&prompt, list.clone(), hint);
1429 open_selector_with_view(
1430 &state,
1431 &editor_container,
1432 &editor,
1433 &tui,
1434 SelectorView::List(list),
1435 frame,
1436 SelectorKind::Extension,
1437 );
1438}
1439
1440fn open_ask_user_input(
1441 ctx: &CommandContext,
1442 bridge: AskUserBridge,
1443 request: rpi_extensions::UiDialogRequest,
1444 prompt: AskUserPrompt,
1445) {
1446 let placeholder = prompt
1447 .suggest
1448 .clone()
1449 .unwrap_or_else(|| "Type your answer…".to_string());
1450 let input = Arc::new(Input::with_placeholder(&placeholder));
1451 input.set_focused(true);
1452
1453 let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
1454 *ctx.state.active_extension_editor.lock().unwrap() = None;
1455 *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1456 ctx.editor_container.clear();
1457 ctx.editor_container.add_child(frame);
1458 ctx.tui.set_focus(Some(input.clone()));
1459 ctx.tui.request_render(false);
1460
1461 let state = ctx.state.clone();
1462 let ec = ctx.editor_container.clone();
1463 let editor = ctx.editor.clone();
1464 let tui = ctx.tui.clone();
1465 let bridge_submit = bridge.clone();
1466 let request_id = request.request_id.clone();
1467 input.on_submit(Arc::new(move |text: &str| {
1468 bridge_submit.respond(
1469 &request_id,
1470 serde_json::json!({
1471 "values": [text],
1472 "value": text,
1473 "text": text,
1474 "kind": "input",
1475 }),
1476 );
1477 close_extension_editor(&state, &ec, &editor, &tui);
1478 }));
1479
1480 let state_cancel = ctx.state.clone();
1481 let ec_cancel = ctx.editor_container.clone();
1482 let editor_cancel = ctx.editor.clone();
1483 let tui_cancel = ctx.tui.clone();
1484 let bridge_cancel = bridge.clone();
1485 let request_id_cancel = request.request_id.clone();
1486 *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1487 bridge_cancel.cancel(&request_id_cancel);
1488 close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1489 }));
1490}
1491
1492struct HelpCommand;
1495impl SlashCommand for HelpCommand {
1496 fn name(&self) -> &'static str {
1497 "/help"
1498 }
1499 fn aliases(&self) -> &'static [&'static str] {
1500 &["/?"]
1501 }
1502 fn description(&self) -> &'static str {
1503 "Show available commands"
1504 }
1505 fn execute(&self, ctx: &CommandContext, _args: &str) {
1506 add_help_message(&ctx.chat);
1507 ctx.tui.request_render(false);
1508 }
1509}
1510
1511struct ClearChatCommand;
1512impl SlashCommand for ClearChatCommand {
1513 fn name(&self) -> &'static str {
1514 "/clear"
1515 }
1516 fn aliases(&self) -> &'static [&'static str] {
1517 &["/new"]
1518 }
1519 fn alias_visible(&self) -> &'static [&'static str] {
1521 &["/new"]
1522 }
1523 fn description(&self) -> &'static str {
1524 "Clear the conversation"
1525 }
1526 fn execute(&self, ctx: &CommandContext, _args: &str) {
1527 let _ = ctx.tx.send(TuiMessage::ClearChat);
1528 }
1529}
1530
1531struct ExitCommand;
1532impl SlashCommand for ExitCommand {
1533 fn name(&self) -> &'static str {
1534 "/exit"
1535 }
1536 fn aliases(&self) -> &'static [&'static str] {
1537 &["/quit", "/q"]
1538 }
1539 fn alias_visible(&self) -> &'static [&'static str] {
1541 &["/quit"]
1542 }
1543 fn description(&self) -> &'static str {
1544 "Exit the application"
1545 }
1546 fn execute(&self, ctx: &CommandContext, _args: &str) {
1547 ctx.state.cancel_js_preparation();
1548 let _ = ctx.tx.send(TuiMessage::Exit);
1549 }
1550}
1551
1552struct VersionCommand;
1553impl SlashCommand for VersionCommand {
1554 fn name(&self) -> &'static str {
1555 "/version"
1556 }
1557 fn aliases(&self) -> &'static [&'static str] {
1558 &["/v"]
1559 }
1560 fn description(&self) -> &'static str {
1561 "Show version information"
1562 }
1563 fn execute(&self, ctx: &CommandContext, _args: &str) {
1564 add_version_message(&ctx.chat);
1565 ctx.tui.request_render(false);
1566 }
1567}
1568
1569struct ChangelogCommand;
1570impl SlashCommand for ChangelogCommand {
1571 fn name(&self) -> &'static str {
1572 "/changelog"
1573 }
1574 fn description(&self) -> &'static str {
1575 "Show recent release changes"
1576 }
1577 fn execute(&self, ctx: &CommandContext, _args: &str) {
1578 add_changelog_message(&ctx.chat);
1579 ctx.tui.request_render(false);
1580 }
1581}
1582
1583struct HotkeysCommand;
1584impl SlashCommand for HotkeysCommand {
1585 fn name(&self) -> &'static str {
1586 "/hotkeys"
1587 }
1588 fn description(&self) -> &'static str {
1589 "Show keyboard shortcuts"
1590 }
1591 fn execute(&self, ctx: &CommandContext, _args: &str) {
1592 add_hotkeys_message(&ctx.chat);
1593 ctx.tui.request_render(false);
1594 }
1595}
1596
1597struct ModelCommand;
1598impl SlashCommand for ModelCommand {
1599 fn name(&self) -> &'static str {
1600 "/model"
1601 }
1602 fn aliases(&self) -> &'static [&'static str] {
1603 &["/m"]
1604 }
1605 fn description(&self) -> &'static str {
1606 "Choose a model (selector)"
1607 }
1608 fn execute(&self, ctx: &CommandContext, args: &str) {
1609 let term = args.trim();
1610 if !term.is_empty() {
1611 if let Some(model) = find_model_selector_match(&ctx.model_catalog, term) {
1617 let model_id = model.id.clone();
1618 ctx.state.set_current_model(&model);
1619 let lane = ctx.lane.clone();
1620 tokio::spawn(async move {
1621 let _ = lane.set_model(model).await;
1622 });
1623 add_note_message(
1624 &ctx.chat,
1625 &format!(
1626 "Model set to {} — applies to the next message.",
1627 short_model_name(&model_id)
1628 ),
1629 );
1630 ctx.tui.request_render(false);
1631 return;
1632 }
1633 }
1634 open_model_selector(
1635 &ctx.state,
1636 &ctx.editor_container,
1637 &ctx.editor,
1638 &ctx.tui,
1639 &ctx.model_catalog,
1640 &ctx.lane,
1641 &ctx.lane_model_id,
1642 &ctx.chat,
1643 (!term.is_empty()).then_some(term),
1644 );
1645 }
1646}
1647
1648struct ThinkingCommand;
1649impl SlashCommand for ThinkingCommand {
1650 fn name(&self) -> &'static str {
1651 "/thinking"
1652 }
1653 fn aliases(&self) -> &'static [&'static str] {
1654 &["/think"]
1655 }
1656 fn description(&self) -> &'static str {
1657 "Set thinking level (selector)"
1658 }
1659 fn execute(&self, ctx: &CommandContext, args: &str) {
1660 let level_name = args.trim();
1661 if !level_name.is_empty() {
1662 let Some(level) = thinking_level_from_name(level_name) else {
1664 add_error_message(
1665 &ctx.chat,
1666 &format!(
1667 "Unknown thinking level \"{level_name}\". Valid: {}",
1668 crate::args::VALID_THINKING_LEVELS.join(", ")
1669 ),
1670 );
1671 ctx.tui.request_render(false);
1672 return;
1673 };
1674 let lane = ctx.lane.clone();
1675 let footer = ctx.state.footer.clone();
1676 tokio::spawn(async move {
1677 let _ = lane.set_thinking_level(level).await;
1678 });
1679 footer.set_thinking_level(Some(thinking_level_name(level)));
1680 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
1681 ctx.tui.request_render(false);
1682 return;
1683 }
1684 open_thinking_selector(
1685 &ctx.state,
1686 &ctx.editor_container,
1687 &ctx.editor,
1688 &ctx.tui,
1689 &ctx.lane,
1690 &ctx.model_catalog,
1691 &ctx.lane_model_id,
1692 &ctx.chat,
1693 );
1694 }
1695}
1696
1697struct ToolsCommand;
1698impl SlashCommand for ToolsCommand {
1699 fn name(&self) -> &'static str {
1700 "/tools"
1701 }
1702 fn description(&self) -> &'static str {
1703 "Toggle tools on/off"
1704 }
1705 fn execute(&self, ctx: &CommandContext, _args: &str) {
1706 open_tools_selector(
1707 &ctx.state,
1708 &ctx.editor_container,
1709 &ctx.editor,
1710 &ctx.tui,
1711 &ctx.lane,
1712 &ctx.chat,
1713 );
1714 }
1715}
1716
1717struct ImagesCommand;
1718impl SlashCommand for ImagesCommand {
1719 fn name(&self) -> &'static str {
1720 "/images"
1721 }
1722 fn description(&self) -> &'static str {
1723 "Toggle inline images"
1724 }
1725 fn execute(&self, ctx: &CommandContext, _args: &str) {
1726 open_images_selector(
1727 &ctx.state,
1728 &ctx.editor_container,
1729 &ctx.editor,
1730 &ctx.tui,
1731 &ctx.chat,
1732 );
1733 }
1734}
1735
1736struct SessionCommand;
1737impl SlashCommand for SessionCommand {
1738 fn name(&self) -> &'static str {
1739 "/session"
1740 }
1741 fn aliases(&self) -> &'static [&'static str] {
1742 &["/resume"]
1743 }
1744 fn description(&self) -> &'static str {
1745 "List saved sessions"
1746 }
1747 fn execute(&self, ctx: &CommandContext, _args: &str) {
1748 open_session_selector(
1749 &ctx.state,
1750 &ctx.editor_container,
1751 &ctx.editor,
1752 &ctx.tui,
1753 &ctx.cwd,
1754 &ctx.tx,
1755 );
1756 }
1757}
1758
1759struct ThemeCommand;
1760impl SlashCommand for ThemeCommand {
1761 fn name(&self) -> &'static str {
1762 "/theme"
1763 }
1764 fn description(&self) -> &'static str {
1765 "Choose a theme (selector)"
1766 }
1767 fn execute(&self, ctx: &CommandContext, args: &str) {
1768 let name = args.trim().to_ascii_lowercase();
1769 if !name.is_empty() {
1770 let preset = match name.as_str() {
1772 "light" => ThemePreset::Light,
1773 "monochrome" => ThemePreset::Monochrome,
1774 "dark" => ThemePreset::Dark,
1775 _ => {
1776 add_error_message(
1777 &ctx.chat,
1778 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
1779 );
1780 ctx.tui.request_render(false);
1781 return;
1782 }
1783 };
1784 apply_theme_preset(preset);
1785 let mut settings = crate::settings::load_settings().unwrap_or_default();
1786 settings.theme = Some(name.clone());
1787 let _ = crate::settings::save_settings(&settings);
1788 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
1789 ctx.tui.request_render(false);
1790 ctx.tui.render_now(true);
1791 return;
1792 }
1793 open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1794 }
1795}
1796
1797struct CompactCommand;
1798impl SlashCommand for CompactCommand {
1799 fn name(&self) -> &'static str {
1800 "/compact"
1801 }
1802 fn description(&self) -> &'static str {
1803 "Compact the conversation"
1804 }
1805 fn execute(&self, ctx: &CommandContext, _args: &str) {
1806 let _ = ctx.tx.send(TuiMessage::Compact);
1807 }
1808}
1809
1810struct CopyCommand;
1811impl SlashCommand for CopyCommand {
1812 fn name(&self) -> &'static str {
1813 "/copy"
1814 }
1815 fn description(&self) -> &'static str {
1816 "Copy last reply to clipboard"
1817 }
1818 fn execute(&self, ctx: &CommandContext, _args: &str) {
1819 let _ = ctx.tx.send(TuiMessage::Copy);
1820 }
1821}
1822
1823struct ExportCommand;
1824impl SlashCommand for ExportCommand {
1825 fn name(&self) -> &'static str {
1826 "/export"
1827 }
1828 fn description(&self) -> &'static str {
1829 "Export session (supports: md, html, jsonl)"
1830 }
1831 fn execute(&self, ctx: &CommandContext, args: &str) {
1832 let format = args.trim().to_lowercase();
1833 if format.is_empty() || format == "md" || format == "markdown" {
1834 let _ = ctx.tx.send(TuiMessage::ExportSession);
1835 } else if format == "html" {
1836 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
1837 crate::export::ExportFormat::Html,
1838 ));
1839 } else if format == "jsonl" {
1840 let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(
1841 crate::export::ExportFormat::Jsonl,
1842 ));
1843 } else {
1844 add_error_message(
1846 &ctx.chat,
1847 &format!(
1848 "Unknown export format: {}. Supported: md, html, jsonl",
1849 args.trim()
1850 ),
1851 );
1852 ctx.tui.request_render(false);
1853 }
1854 }
1855}
1856
1857struct ForkCommand;
1858impl SlashCommand for ForkCommand {
1859 fn name(&self) -> &'static str {
1860 "/fork"
1861 }
1862 fn description(&self) -> &'static str {
1863 "Fork the session into a new one"
1864 }
1865 fn execute(&self, ctx: &CommandContext, _args: &str) {
1866 let _ = ctx.tx.send(TuiMessage::ForkSession);
1867 }
1868}
1869
1870struct CloneCommand;
1874impl SlashCommand for CloneCommand {
1875 fn name(&self) -> &'static str {
1876 "/clone"
1877 }
1878 fn description(&self) -> &'static str {
1879 "Duplicate the current session"
1880 }
1881 fn execute(&self, ctx: &CommandContext, _args: &str) {
1882 let _ = ctx.tx.send(TuiMessage::ForkSession);
1883 }
1884}
1885
1886struct TreeCommand;
1887impl SlashCommand for TreeCommand {
1888 fn name(&self) -> &'static str {
1889 "/tree"
1890 }
1891 fn description(&self) -> &'static str {
1892 "Navigate the current session tree"
1893 }
1894 fn execute(&self, ctx: &CommandContext, _args: &str) {
1895 let _ = ctx.tx.send(TuiMessage::OpenTree);
1896 }
1897}
1898
1899struct LoginCommand;
1900impl SlashCommand for LoginCommand {
1901 fn name(&self) -> &'static str {
1902 "/login"
1903 }
1904 fn description(&self) -> &'static str {
1905 "Save an Anthropic API key"
1906 }
1907 fn execute(&self, ctx: &CommandContext, args: &str) {
1908 let key = args.trim();
1909 if key.is_empty() {
1910 add_note_message(&ctx.chat, "Usage: /login <api-key>");
1911 } else {
1912 let result = crate::config::upsert_credential(
1913 "anthropic",
1914 crate::config::Credential::ApiKey {
1915 key: Some(key.to_string()),
1916 env: None,
1917 },
1918 );
1919 match result {
1920 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
1921 Err(error) => {
1922 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
1923 }
1924 }
1925 }
1926 ctx.tui.request_render(false);
1927 }
1928}
1929
1930struct LogoutCommand;
1931impl SlashCommand for LogoutCommand {
1932 fn name(&self) -> &'static str {
1933 "/logout"
1934 }
1935 fn description(&self) -> &'static str {
1936 "Remove saved Anthropic credentials"
1937 }
1938 fn execute(&self, ctx: &CommandContext, _args: &str) {
1939 match crate::config::delete_credential("anthropic") {
1940 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
1941 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
1942 Err(error) => {
1943 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
1944 }
1945 }
1946 ctx.tui.request_render(false);
1947 }
1948}
1949
1950fn set_project_trust_for_command(
1951 cwd: &std::path::Path,
1952 value: Option<bool>,
1953) -> Result<(), crate::config::ConfigError> {
1954 crate::config::set_project_trust(cwd, value)
1955}
1956
1957struct TrustCommand;
1958impl SlashCommand for TrustCommand {
1959 fn name(&self) -> &'static str {
1960 "/trust"
1961 }
1962 fn description(&self) -> &'static str {
1963 "Trust the current project"
1964 }
1965 fn execute(&self, ctx: &CommandContext, args: &str) {
1966 let value = match args.trim().to_ascii_lowercase().as_str() {
1967 "" | "yes" | "y" | "true" => Some(true),
1968 "no" | "n" | "false" => Some(false),
1969 "clear" | "reset" | "none" => None,
1970 _ => {
1971 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
1972 ctx.tui.request_render(false);
1973 return;
1974 }
1975 };
1976 match set_project_trust_for_command(&ctx.cwd, value) {
1977 Ok(()) => {
1978 let label = match value {
1979 Some(true) => "trusted",
1980 Some(false) => "untrusted",
1981 None => "trust decision cleared",
1982 };
1983 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
1984 }
1985 Err(error) => add_error_message(
1986 &ctx.chat,
1987 &format!("Could not save trust decision: {error}"),
1988 ),
1989 }
1990 ctx.tui.request_render(false);
1991 }
1992}
1993
1994struct NameCommand;
1995impl SlashCommand for NameCommand {
1996 fn name(&self) -> &'static str {
1997 "/name"
1998 }
1999 fn description(&self) -> &'static str {
2000 "Set session display name"
2001 }
2002 fn execute(&self, ctx: &CommandContext, args: &str) {
2003 let name = args.trim();
2004 if name.is_empty() {
2005 add_note_message(
2006 &ctx.chat,
2007 "Usage: /name <display name> — sets the current session's name.",
2008 );
2009 ctx.tui.request_render(false);
2010 return;
2011 }
2012 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2013 }
2014}
2015
2016struct ImportCommand;
2017impl SlashCommand for ImportCommand {
2018 fn name(&self) -> &'static str {
2019 "/import"
2020 }
2021 fn description(&self) -> &'static str {
2022 "Import a session file (path)"
2023 }
2024 fn execute(&self, ctx: &CommandContext, args: &str) {
2025 let path = args.trim();
2026 if path.is_empty() {
2027 add_note_message(
2028 &ctx.chat,
2029 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2030 );
2031 ctx.tui.request_render(false);
2032 return;
2033 }
2034 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2035 }
2036}
2037
2038struct SettingsCommand;
2039impl SlashCommand for SettingsCommand {
2040 fn name(&self) -> &'static str {
2041 "/settings"
2042 }
2043 fn description(&self) -> &'static str {
2044 "Open settings menu"
2045 }
2046 fn execute(&self, ctx: &CommandContext, _args: &str) {
2047 open_settings_selector(
2048 &ctx.state,
2049 &ctx.editor_container,
2050 &ctx.editor,
2051 &ctx.tui,
2052 &ctx.lane,
2053 &ctx.model_catalog,
2054 &ctx.lane_model_id,
2055 &ctx.chat,
2056 );
2057 }
2058}
2059
2060struct ScopedModelsCommand;
2061impl SlashCommand for ScopedModelsCommand {
2062 fn name(&self) -> &'static str {
2063 "/scoped-models"
2064 }
2065 fn description(&self) -> &'static str {
2066 "Choose models for Ctrl+P cycling"
2067 }
2068 fn execute(&self, ctx: &CommandContext, _args: &str) {
2069 open_scoped_models_selector(
2070 &ctx.state,
2071 &ctx.editor_container,
2072 &ctx.editor,
2073 &ctx.tui,
2074 &ctx.model_catalog,
2075 &ctx.chat,
2076 );
2077 }
2078}
2079
2080struct ShareCommand;
2081impl SlashCommand for ShareCommand {
2082 fn name(&self) -> &'static str {
2083 "/share"
2084 }
2085 fn description(&self) -> &'static str {
2086 "Share session (gist via gh, or clipboard)"
2087 }
2088 fn execute(&self, ctx: &CommandContext, _args: &str) {
2089 let _ = ctx.tx.send(TuiMessage::ShareSession);
2090 }
2091}
2092
2093struct ContextCommand;
2097impl SlashCommand for ContextCommand {
2098 fn name(&self) -> &'static str {
2099 "/context"
2100 }
2101 fn visible(&self) -> bool {
2102 false
2103 }
2104 fn execute(&self, ctx: &CommandContext, _args: &str) {
2105 show_context_panel(&ctx.chat, &ctx.resources);
2106 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2108 ctx.tui.request_render(false);
2109 }
2110}
2111
2112struct UsageCommand;
2116impl SlashCommand for UsageCommand {
2117 fn name(&self) -> &'static str {
2118 "/usage"
2119 }
2120 fn description(&self) -> &'static str {
2121 "Show token, cost, and cache totals for this session"
2122 }
2123 fn execute(&self, ctx: &CommandContext, _args: &str) {
2124 let _ = ctx.tx.send(TuiMessage::ShowUsage);
2125 ctx.tui.request_render(false);
2126 }
2127}
2128
2129struct ReloadCommand;
2140impl SlashCommand for ReloadCommand {
2141 fn name(&self) -> &'static str {
2142 "/reload"
2143 }
2144 fn description(&self) -> &'static str {
2145 "Reload extensions, skills, prompts"
2146 }
2147 fn execute(&self, ctx: &CommandContext, _args: &str) {
2148 add_note_message(&ctx.chat, "Reloading extensions + resources…");
2152 ctx.tui.request_render(false);
2153 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2154 }
2155}
2156
2157fn build_builtin_registry() -> CommandRegistry {
2162 let mut r = CommandRegistry::new();
2163 r.register(Arc::new(HelpCommand));
2164 r.register(Arc::new(ClearChatCommand));
2165 r.register(Arc::new(ExitCommand));
2166 r.register(Arc::new(VersionCommand));
2167 r.register(Arc::new(ChangelogCommand));
2168 r.register(Arc::new(ModelCommand));
2169 r.register(Arc::new(ThinkingCommand));
2170 r.register(Arc::new(ToolsCommand));
2171 r.register(Arc::new(ImagesCommand));
2172 r.register(Arc::new(SessionCommand));
2173 r.register(Arc::new(ThemeCommand));
2174 r.register(Arc::new(CompactCommand));
2175 r.register(Arc::new(CopyCommand));
2176 r.register(Arc::new(HotkeysCommand));
2177 r.register(Arc::new(ContextCommand));
2178 r.register(Arc::new(UsageCommand));
2179 r.register(Arc::new(NameCommand));
2183 r.register(Arc::new(SettingsCommand));
2184 r.register(Arc::new(ScopedModelsCommand));
2185 r.register(Arc::new(ExportCommand));
2186 r.register(Arc::new(ImportCommand));
2187 r.register(Arc::new(ShareCommand));
2188 r.register(Arc::new(ForkCommand));
2189 r.register(Arc::new(CloneCommand));
2190 r.register(Arc::new(TreeCommand));
2191 r.register(Arc::new(TrustCommand));
2192 r.register(Arc::new(LoginCommand));
2193 r.register(Arc::new(LogoutCommand));
2194 r.register(Arc::new(ReloadCommand));
2195 r
2196}
2197
2198fn register_extension_commands(
2199 registry: &mut CommandRegistry,
2200 session: crate::session::ExtensionSessionCell,
2201) {
2202 let commands = session
2203 .lock()
2204 .ok()
2205 .and_then(|s| s.snapshot_arc())
2206 .map(|snap| snap.commands().to_vec())
2207 .unwrap_or_default();
2208 for command in commands {
2209 let name = if command.name.starts_with('/') {
2210 command.name.clone()
2211 } else {
2212 format!("/{}", command.name)
2213 };
2214 if registry.find(&name).is_some() {
2215 continue;
2216 }
2217 registry.register(Arc::new(ExtensionCommand {
2218 name,
2219 description: command.description,
2220 session: session.clone(),
2221 }));
2222 }
2223}
2224
2225enum TuiMessage {
2232 UserInput(String),
2233 OpenTree,
2234 NavigateTree(String),
2235 Exit,
2236 ClearChat,
2238 Compact,
2240 Copy,
2242 SwitchSession(String),
2245 ExportSession,
2247 ExportSessionWithFormat(crate::export::ExportFormat),
2249 ShowUsage,
2251 ForkSession,
2253 SetSessionName(String),
2255 ImportSession(String),
2258 ShareSession,
2261 ReloadExtensions,
2266 ExternalEditorResult(Result<String, String>),
2270 UserBashFinished(UserBashReport),
2275}
2276
2277struct UserBashReport {
2282 command: String,
2283 output: String,
2284 exit_code: Option<i32>,
2285 cancelled: bool,
2286 truncated: bool,
2287 full_output_path: Option<String>,
2288 exclude_from_context: bool,
2289}
2290
2291fn assistant_text(msg: &AssistantMessage) -> String {
2294 msg.content
2295 .iter()
2296 .filter_map(|c| match c {
2297 Content::Text(t) => Some(t.text.clone()),
2298 _ => None,
2299 })
2300 .collect()
2301}
2302
2303fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2306 msg.content
2307 .iter()
2308 .filter_map(|c| match c {
2309 Content::Text(t) => Some(t.text.clone()),
2310 _ => None,
2311 })
2312 .collect::<Vec<_>>()
2313 .join("\n")
2314}
2315
2316fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2317 match &msg.content {
2318 rpi_ai::types::UserContent::Text(s) => s.clone(),
2319 rpi_ai::types::UserContent::Blocks(blocks) => blocks
2320 .iter()
2321 .filter_map(|c| match c {
2322 Content::Text(t) => Some(t.text.clone()),
2323 _ => None,
2324 })
2325 .collect(),
2326 }
2327}
2328
2329fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2333 let scoped = crate::settings::load_settings()
2334 .ok()
2335 .and_then(|s| s.scoped_models)
2336 .unwrap_or_default();
2337 if scoped.is_empty() {
2338 return catalog.to_vec();
2339 }
2340 let mut out: Vec<rpi_ai::Model> = catalog
2341 .iter()
2342 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2343 .cloned()
2344 .collect();
2345 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2347 if let Some(cur) = catalog
2348 .iter()
2349 .find(|m| m.id.eq_ignore_ascii_case(current_id))
2350 {
2351 out.push(cur.clone());
2352 }
2353 }
2354 out
2355}
2356
2357fn open_settings_selector(
2366 state: &Arc<TuiState>,
2367 editor_container: &Arc<Container>,
2368 editor: &Arc<Editor>,
2369 tui: &Arc<TuiAltScreen>,
2370 lane: &Arc<dyn AgentLane>,
2371 catalog: &[rpi_ai::Model],
2372 lane_model_id: &str,
2373 chat: &Arc<Container>,
2374) {
2375 let settings = crate::settings::load_settings().unwrap_or_default();
2376 let items = settings_menu_items(&settings, state, lane_model_id);
2377 let list = Arc::new(SettingsList::new(items));
2378
2379 let state_sel = state.clone();
2384 let ec_sel = editor_container.clone();
2385 let editor_sel = editor.clone();
2386 let tui_sel = tui.clone();
2387 let lane_sel = lane.clone();
2388 let catalog_sel = catalog.to_vec();
2389 let lane_model_sel = lane_model_id.to_string();
2390 let chat_sel = chat.clone();
2391 list.on_select(Arc::new(move |item| match item.key.as_str() {
2392 "theme" => {
2393 open_settings_theme_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel, &chat_sel)
2394 }
2395 "model" => open_settings_model_selector(
2396 &state_sel,
2397 &ec_sel,
2398 &editor_sel,
2399 &tui_sel,
2400 &lane_sel,
2401 &catalog_sel,
2402 &lane_model_sel,
2403 &chat_sel,
2404 ),
2405 "thinking" => open_settings_thinking_selector(
2406 &state_sel,
2407 &ec_sel,
2408 &editor_sel,
2409 &tui_sel,
2410 &lane_sel,
2411 &catalog_sel,
2412 &lane_model_sel,
2413 &chat_sel,
2414 ),
2415 "scoped-models" => open_scoped_models_selector(
2416 &state_sel,
2417 &ec_sel,
2418 &editor_sel,
2419 &tui_sel,
2420 &catalog_sel,
2421 &chat_sel,
2422 ),
2423 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
2424 }));
2425
2426 let state_change = state.clone();
2429 let chat_change = chat.clone();
2430 let tui_change = tui.clone();
2431 list.on_change(Arc::new(move |key, value| {
2432 let mut settings = crate::settings::load_settings().unwrap_or_default();
2433 let applied = apply_setting_change(&state_change, key, value, &mut settings);
2434 let saved = crate::settings::save_settings(&settings);
2435 if let Some(note) = applied {
2436 let suffix = if saved.is_ok() { "" } else { " (not saved)" };
2437 add_note_message(&chat_change, &format!("{note}{suffix}"));
2438 }
2439 tui_change.request_render(false);
2440 }));
2441
2442 let state_cancel = state.clone();
2443 let ec_cancel = editor_container.clone();
2444 let editor_cancel = editor.clone();
2445 let tui_cancel = tui.clone();
2446 list.on_cancel(Arc::new(move || {
2447 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2448 }));
2449
2450 open_selector_with_view(
2451 state,
2452 editor_container,
2453 editor,
2454 tui,
2455 SelectorView::Settings(list.clone()),
2456 list,
2457 SelectorKind::Settings,
2458 );
2459}
2460
2461fn settings_menu_items(
2467 settings: &crate::settings::Settings,
2468 state: &Arc<TuiState>,
2469 lane_model_id: &str,
2470) -> Vec<SettingItem> {
2471 let current_theme = settings.theme.clone().unwrap_or_else(|| "dark".to_string());
2472 let current_model = settings
2473 .default_model
2474 .clone()
2475 .unwrap_or_else(|| lane_model_id.to_string());
2476 let current_thinking = settings
2477 .default_thinking_level
2478 .clone()
2479 .unwrap_or_else(|| "(default)".to_string());
2480 let scoped_desc = match &settings.scoped_models {
2481 Some(list) if !list.is_empty() => format!("{} model(s)", list.len()),
2482 _ => "all models".to_string(),
2483 };
2484 let hide_thinking = state.hide_thinking();
2485 let show_images = state.show_images.lock().map(|guard| *guard).unwrap_or(true);
2486 let show_cache_miss = settings.show_cache_miss_notices.unwrap_or(false);
2487 let http_idle = settings
2488 .http_idle_timeout_ms()
2489 .map(|ms| {
2490 if ms == 0 {
2491 "disabled".to_string()
2492 } else {
2493 format!("{}s", ms / 1000)
2494 }
2495 })
2496 .unwrap_or_else(|| "5m".to_string());
2497
2498 vec![
2499 SettingItem::new("theme", "Theme", ¤t_theme)
2500 .with_description("Color theme for the interface")
2501 .with_submenu(),
2502 SettingItem::new("model", "Default model", ¤t_model)
2503 .with_description("Saved default model for new sessions")
2504 .with_submenu(),
2505 SettingItem::new("thinking", "Default thinking", ¤t_thinking)
2506 .with_description("Saved default thinking level")
2507 .with_submenu(),
2508 SettingItem::new("scoped-models", "Cycle scope", &scoped_desc)
2509 .with_description("Models enabled for Ctrl+P cycling")
2510 .with_submenu(),
2511 SettingItem::new(
2512 "hide-thinking",
2513 "Hide thinking",
2514 bool_setting_value(hide_thinking),
2515 )
2516 .with_description("Hide thinking blocks in assistant responses")
2517 .with_values(&["true", "false"]),
2518 SettingItem::new(
2519 "show-images",
2520 "Show images",
2521 bool_setting_value(show_images),
2522 )
2523 .with_description("Render images inline in the terminal")
2524 .with_values(&["true", "false"]),
2525 SettingItem::new(
2526 "cache-miss-notices",
2527 "Cache miss notices",
2528 bool_setting_value(show_cache_miss),
2529 )
2530 .with_description("Transcript notices for prompt-cache costs")
2531 .with_values(&["true", "false"]),
2532 SettingItem::new(
2533 "quiet-startup",
2534 "Quiet startup",
2535 bool_setting_value(settings.quiet_startup.unwrap_or(false)),
2536 )
2537 .with_description("Disable the verbose startup listing (applies on restart)")
2538 .with_values(&["true", "false"]),
2539 SettingItem::new(
2540 "terminal-progress",
2541 "Terminal progress",
2542 bool_setting_value(settings.show_terminal_progress().unwrap_or(true)),
2543 )
2544 .with_description("OSC 9;4 progress in the terminal tab bar (applies on restart)")
2545 .with_values(&["true", "false"]),
2546 SettingItem::new(
2547 "fullscreen-copy-on-select",
2548 "Fullscreen copy on select",
2549 bool_setting_value(settings.fullscreen_copy_on_select.unwrap_or(true)),
2550 )
2551 .with_description("Copy selected text automatically in fullscreen mode")
2552 .with_values(&["true", "false"]),
2553 SettingItem::new(
2554 "double-escape-action",
2555 "Double-escape action",
2556 settings
2557 .double_escape_action
2558 .clone()
2559 .unwrap_or_else(|| "tree".to_string())
2560 .as_str(),
2561 )
2562 .with_description("Esc Esc with an empty editor (applies on restart)")
2563 .with_values(&["tree", "fork", "none"]),
2564 SettingItem::new(
2565 "editor-padding",
2566 "Editor padding",
2567 settings.editor_padding_x.unwrap_or(1).to_string().as_str(),
2568 )
2569 .with_description("Horizontal editor padding, 0-3 (applies on restart)")
2570 .with_values(&["0", "1", "2", "3"]),
2571 SettingItem::new(
2572 "autocomplete-max-items",
2573 "Autocomplete max items",
2574 settings
2575 .autocomplete_max_visible
2576 .unwrap_or(5)
2577 .to_string()
2578 .as_str(),
2579 )
2580 .with_description("Max autocomplete rows, 3-20 (applies on restart)")
2581 .with_values(&["3", "5", "7", "10", "15", "20"]),
2582 SettingItem::new("http-idle-timeout", "HTTP idle timeout", &http_idle)
2583 .with_description("Idle gap allowed while awaiting HTTP data (applies on restart)")
2584 .with_values(&["30s", "1m", "5m", "disabled"]),
2585 ]
2586}
2587
2588fn bool_setting_value(value: bool) -> &'static str {
2590 if value {
2591 "true"
2592 } else {
2593 "false"
2594 }
2595}
2596
2597fn apply_setting_change(
2602 state: &Arc<TuiState>,
2603 key: &str,
2604 value: &str,
2605 settings: &mut crate::settings::Settings,
2606) -> Option<String> {
2607 let as_bool = || value == "true";
2608 match key {
2609 "hide-thinking" => {
2611 state.set_hide_thinking(as_bool());
2612 settings.hide_thinking_block = Some(as_bool());
2613 Some(format!(
2614 "Thinking blocks {}.",
2615 if as_bool() { "hidden" } else { "shown" }
2616 ))
2617 }
2618 "show-images" => {
2620 if let Ok(mut guard) = state.show_images.lock() {
2621 *guard = as_bool();
2622 }
2623 settings.show_images = Some(as_bool());
2624 Some(format!(
2625 "Inline images {}.",
2626 if as_bool() { "enabled" } else { "disabled" }
2627 ))
2628 }
2629 "cache-miss-notices" => {
2631 if let Ok(mut guard) = state.cache_miss_notices.lock() {
2632 *guard = as_bool();
2633 }
2634 settings.show_cache_miss_notices = Some(as_bool());
2635 Some(format!(
2636 "Cache miss notices {}.",
2637 if as_bool() { "enabled" } else { "disabled" }
2638 ))
2639 }
2640 "fullscreen-copy-on-select" => {
2642 settings.fullscreen_copy_on_select = Some(as_bool());
2643 Some(format!(
2644 "Copy on select {}.",
2645 if as_bool() { "enabled" } else { "disabled" }
2646 ))
2647 }
2648 "quiet-startup" => {
2650 settings.quiet_startup = Some(as_bool());
2651 None
2652 }
2653 "terminal-progress" => {
2654 settings.show_terminal_progress = Some(as_bool());
2655 None
2656 }
2657 "double-escape-action" => {
2658 settings.double_escape_action = Some(value.to_string());
2659 None
2660 }
2661 "editor-padding" => {
2662 settings.editor_padding_x = value.parse().ok();
2663 None
2664 }
2665 "autocomplete-max-items" => {
2666 settings.autocomplete_max_visible = value.parse().ok();
2667 None
2668 }
2669 "http-idle-timeout" => {
2670 settings.http_idle_timeout = Some(match value {
2671 "disabled" => serde_json::Value::String("disabled".to_string()),
2672 "30s" => serde_json::json!(30_000),
2673 "1m" => serde_json::json!(60_000),
2674 _ => serde_json::json!(300_000),
2675 });
2676 None
2677 }
2678 _ => None,
2679 }
2680}
2681
2682fn open_settings_theme_selector(
2684 state: &Arc<TuiState>,
2685 editor_container: &Arc<Container>,
2686 editor: &Arc<Editor>,
2687 tui: &Arc<TuiAltScreen>,
2688 chat: &Arc<Container>,
2689) {
2690 let items = vec![
2691 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
2692 SelectItem::new("light", "Light").with_description("Light background"),
2693 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
2694 ];
2695 let list = Arc::new(SelectList::new(items, 10));
2696
2697 let state_sel = state.clone();
2698 let ec_sel = editor_container.clone();
2699 let editor_sel = editor.clone();
2700 let tui_sel = tui.clone();
2701 let chat_sel = chat.clone();
2702 list.on_select(Arc::new(move |item| {
2703 let preset = match item.value.as_str() {
2704 "light" => Some(ThemePreset::Light),
2705 "monochrome" => Some(ThemePreset::Monochrome),
2706 "dark" => Some(ThemePreset::Dark),
2707 name => {
2708 add_error_message(&chat_sel, &format!("Unknown theme `{name}`."));
2709 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2710 tui_sel.render_now(true);
2711 return;
2712 }
2713 };
2714 let Some(preset) = preset else { return };
2715 apply_theme_preset(preset);
2716 state_sel.theme_manager.apply_preset(preset);
2717 let mut settings = crate::settings::load_settings().unwrap_or_default();
2718 settings.theme = Some(item.value.clone());
2719 let saved = crate::settings::save_settings(&settings);
2720 add_note_message(
2721 &chat_sel,
2722 &format!(
2723 "Theme set to {} (saved{})",
2724 item.label,
2725 if saved.is_ok() { "" } else { ", not saved" },
2726 ),
2727 );
2728 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2729 tui_sel.render_now(true);
2730 }));
2731 let state_cancel = state.clone();
2732 let ec_cancel = editor_container.clone();
2733 let editor_cancel = editor.clone();
2734 let tui_cancel = tui.clone();
2735 list.on_cancel(Arc::new(move || {
2736 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2737 }));
2738
2739 open_selector(
2740 state,
2741 editor_container,
2742 editor,
2743 tui,
2744 list,
2745 SelectorKind::Settings,
2746 );
2747}
2748
2749fn open_settings_model_selector(
2753 state: &Arc<TuiState>,
2754 editor_container: &Arc<Container>,
2755 editor: &Arc<Editor>,
2756 tui: &Arc<TuiAltScreen>,
2757 lane: &Arc<dyn AgentLane>,
2758 catalog: &[rpi_ai::Model],
2759 lane_model_id: &str,
2760 chat: &Arc<Container>,
2761) {
2762 let items = model_selector_items(catalog, lane_model_id);
2763 if items.is_empty() {
2764 add_note_message(chat, "No models in the catalog.");
2765 tui.request_render(false);
2766 return;
2767 }
2768 let list = Arc::new(SelectList::new(items, 10));
2769
2770 let catalog_arc = catalog.to_vec();
2771 let state_sel = state.clone();
2772 let ec_sel = editor_container.clone();
2773 let editor_sel = editor.clone();
2774 let tui_sel = tui.clone();
2775 let chat_sel = chat.clone();
2776 let lane_sel = lane.clone();
2777 list.on_select(Arc::new(move |item| {
2778 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
2779 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
2780 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2781 return;
2782 };
2783 state_sel.set_current_model(&model);
2784 let lane = lane_sel.clone();
2785 tokio::spawn(async move {
2786 let _ = lane.set_model(model).await;
2787 });
2788 let mut settings = crate::settings::load_settings().unwrap_or_default();
2789 settings.default_model = Some(item.value.clone());
2790 let saved = crate::settings::save_settings(&settings);
2791 add_note_message(
2792 &chat_sel,
2793 &format!(
2794 "Default model set to {} (saved{}",
2795 short_model_name(&item.value),
2796 if saved.is_ok() { ")" } else { ", not saved)" },
2797 ),
2798 );
2799 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2800 }));
2801 let state_cancel = state.clone();
2802 let ec_cancel = editor_container.clone();
2803 let editor_cancel = editor.clone();
2804 let tui_cancel = tui.clone();
2805 list.on_cancel(Arc::new(move || {
2806 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2807 }));
2808
2809 open_searchable_selector(
2810 state,
2811 editor_container,
2812 editor,
2813 tui,
2814 Some("Default model"),
2815 Some("Type to filter by name, provider, or id"),
2816 list,
2817 SelectorKind::Settings,
2818 );
2819}
2820
2821fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
2826 let mut seen = std::collections::HashSet::new();
2827 catalog
2828 .iter()
2829 .filter(|m| {
2830 seen.insert((
2831 m.api.clone(),
2832 m.provider.to_ascii_lowercase(),
2833 m.id.to_ascii_lowercase(),
2834 ))
2835 })
2836 .map(|m| {
2837 let label = if m.name.is_empty() {
2838 short_model_name(&m.id)
2839 } else {
2840 m.name.clone()
2841 };
2842 let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
2843 && m.provider.eq_ignore_ascii_case("anthropic")
2844 {
2845 m.id.clone()
2846 } else {
2847 format!("{}/{}", m.provider, m.id)
2848 };
2849 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
2850 " (current)"
2851 } else {
2852 ""
2853 };
2854 let name = if m.name.is_empty() {
2857 String::new()
2858 } else {
2859 format!(" {}", m.name)
2860 };
2861 let search_text = format!(
2862 "{} {}/{} {} {}{}",
2863 m.provider, m.provider, m.id, m.provider, m.id, name
2864 );
2865 SelectItem::new(&m.id, &label)
2866 .with_description(&format!("{identity}{marker}"))
2867 .with_search_text(&search_text)
2868 })
2869 .collect()
2870}
2871
2872fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
2876 let (provider, id) = input
2877 .split_once('/')
2878 .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
2879 .map_or((None, input), |(provider, id)| (Some(provider), id));
2880 catalog
2881 .iter()
2882 .find(|model| {
2883 model.id.eq_ignore_ascii_case(id)
2884 && provider.map_or(true, |provider| {
2885 model.provider.eq_ignore_ascii_case(provider)
2886 || (provider.eq_ignore_ascii_case("anthropic")
2887 && matches!(model.api, rpi_ai::Api::AnthropicMessages))
2888 })
2889 })
2890 .cloned()
2891}
2892
2893fn open_settings_thinking_selector(
2897 state: &Arc<TuiState>,
2898 editor_container: &Arc<Container>,
2899 editor: &Arc<Editor>,
2900 tui: &Arc<TuiAltScreen>,
2901 lane: &Arc<dyn AgentLane>,
2902 catalog: &[rpi_ai::Model],
2903 lane_model_id: &str,
2904 chat: &Arc<Container>,
2905) {
2906 let model = catalog
2907 .iter()
2908 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
2909 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
2910 .map(|m| m.supported_thinking_levels())
2911 .unwrap_or_else(|| {
2912 use rpi_ai::types::ThinkingLevel::*;
2913 vec![Off, Minimal, Low, Medium, High]
2914 });
2915 let mut items: Vec<SelectItem> = Vec::new();
2916 for lvl in &levels {
2917 let name = thinking_level_name(*lvl);
2918 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
2919 }
2920 if items.is_empty() {
2921 add_note_message(chat, "This model has no supported thinking levels.");
2922 tui.request_render(false);
2923 return;
2924 }
2925 let list = Arc::new(SelectList::new(items, 10));
2926
2927 let state_sel = state.clone();
2928 let ec_sel = editor_container.clone();
2929 let editor_sel = editor.clone();
2930 let tui_sel = tui.clone();
2931 let chat_sel = chat.clone();
2932 let lane_sel = lane.clone();
2933 list.on_select(Arc::new(move |item| {
2934 let Some(level) = thinking_level_from_name(&item.value) else {
2935 add_note_message(
2936 &chat_sel,
2937 &format!("Unknown thinking level: {}.", item.label),
2938 );
2939 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2940 return;
2941 };
2942 let lane = lane_sel.clone();
2943 let footer_sel = state_sel.footer.clone();
2944 tokio::spawn(async move {
2945 let _ = lane.set_thinking_level(level).await;
2946 });
2947 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
2948 let mut settings = crate::settings::load_settings().unwrap_or_default();
2949 settings.default_thinking_level = Some(item.value.clone());
2950 let saved = crate::settings::save_settings(&settings);
2951 add_note_message(
2952 &chat_sel,
2953 &format!(
2954 "Default thinking set to {} (saved{}",
2955 item.label,
2956 if saved.is_ok() { ")" } else { ", not saved)" },
2957 ),
2958 );
2959 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
2960 }));
2961 let state_cancel = state.clone();
2962 let ec_cancel = editor_container.clone();
2963 let editor_cancel = editor.clone();
2964 let tui_cancel = tui.clone();
2965 list.on_cancel(Arc::new(move || {
2966 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2967 }));
2968
2969 open_selector(
2970 state,
2971 editor_container,
2972 editor,
2973 tui,
2974 list,
2975 SelectorKind::Settings,
2976 );
2977}
2978
2979fn open_scoped_models_selector(
2984 state: &Arc<TuiState>,
2985 editor_container: &Arc<Container>,
2986 editor: &Arc<Editor>,
2987 tui: &Arc<TuiAltScreen>,
2988 catalog: &[rpi_ai::Model],
2989 chat: &Arc<Container>,
2990) {
2991 if catalog.is_empty() {
2992 add_note_message(chat, "No models in the catalog.");
2993 tui.request_render(false);
2994 return;
2995 }
2996 let seed: Vec<String> = crate::settings::load_settings()
2998 .ok()
2999 .and_then(|s| s.scoped_models)
3000 .unwrap_or_default();
3001 *state.scoped_edit.lock().unwrap() = Some(seed);
3002
3003 let mut items: Vec<SelectItem> = Vec::new();
3004 for m in catalog {
3005 items.push(SelectItem::new(&m.id, &m.id));
3006 }
3007 let list = Arc::new(SelectList::new(items, 10));
3008
3009 let state_sel = state.clone();
3010 let chat_sel = chat.clone();
3011 let tui_sel = tui.clone();
3012 list.on_select(Arc::new(move |item| {
3013 let mut set = state_sel.scoped_edit.lock().unwrap();
3015 let set = set.get_or_insert_with(Vec::new);
3016 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3017 set.remove(pos);
3018 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3019 } else {
3020 set.push(item.value.clone());
3021 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3022 }
3023 tui_sel.request_render(false);
3024 }));
3025 let state_cancel = state.clone();
3026 let ec_cancel = editor_container.clone();
3027 let editor_cancel = editor.clone();
3028 let tui_cancel = tui.clone();
3029 let chat_cancel = chat.clone();
3030 list.on_cancel(Arc::new(move || {
3031 let set = state_cancel
3033 .scoped_edit
3034 .lock()
3035 .unwrap()
3036 .take()
3037 .unwrap_or_default();
3038 let mut settings = crate::settings::load_settings().unwrap_or_default();
3039 settings.scoped_models = if set.is_empty() {
3040 None
3041 } else {
3042 Some(set.clone())
3043 };
3044 match crate::settings::save_settings(&settings) {
3045 Ok(()) => {
3046 if set.is_empty() {
3047 add_note_message(&chat_cancel, "Ctrl+P cycles all models (scope cleared).");
3048 } else {
3049 add_note_message(
3050 &chat_cancel,
3051 &format!("Ctrl+P cycle scope: {}", set.join(", ")),
3052 );
3053 }
3054 }
3055 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3056 }
3057 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3058 }));
3059
3060 open_selector(
3061 state,
3062 editor_container,
3063 editor,
3064 tui,
3065 list,
3066 SelectorKind::ScopedModels,
3067 );
3068}
3069
3070async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3074 use std::process::Stdio;
3075
3076 let tree = harness.session().view("main");
3078 let entries = match tree
3079 .find_entries(&EntryQuery {
3080 entry_type: None,
3081 custom_type: None,
3082 order: Some(EntryOrder::OldestFirst),
3085 limit: None,
3086 cursor: None,
3087 })
3088 .await
3089 {
3090 Ok(e) => e,
3091 Err(e) => {
3092 add_error_message(chat, &format!("Could not read session: {e}"));
3093 return;
3094 }
3095 };
3096 let mut md = String::from("# Session\n\n");
3097 for e in entries {
3098 let Entry::Message(me) = e else { continue };
3099 match &me.message {
3100 AgentMessage::User(u) => {
3101 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3102 }
3103 AgentMessage::Assistant(a) => {
3104 let text = assistant_text(a);
3105 if !text.is_empty() {
3106 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3107 }
3108 }
3109 _ => {}
3110 }
3111 }
3112
3113 let gh = std::process::Command::new("gh")
3115 .arg("gist")
3116 .arg("create")
3117 .arg("--filename")
3118 .arg("session.md")
3119 .arg("-")
3120 .stdin(Stdio::piped())
3121 .stdout(Stdio::piped())
3122 .stderr(Stdio::null())
3123 .spawn();
3124 if let Ok(mut child) = gh {
3125 use std::io::Write;
3126 if let Some(mut stdin) = child.stdin.take() {
3127 let _ = stdin.write_all(md.as_bytes());
3128 let _ = stdin.flush();
3129 }
3130 let out = child.wait_with_output().ok();
3131 if let Some(out) = out {
3132 if out.status.success() {
3133 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3134 add_note_message(chat, &format!("Shared session: {url}"));
3135 return;
3136 }
3137 }
3138 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3139 } else {
3140 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3141 }
3142 if copy_to_clipboard(&md) {
3144 add_note_message(chat, "Session transcript copied to the clipboard.");
3145 } else {
3146 add_note_message(
3147 chat,
3148 "Clipboard unavailable — use /export to write the transcript to a file.",
3149 );
3150 }
3151}
3152
3153async fn switch_to_session(
3168 harness: &AgentHarness,
3169 lane: &Arc<dyn AgentLane>,
3170 id: &str,
3171 cwd: &std::path::Path,
3172 chat: &Arc<Container>,
3173 state: &Arc<TuiState>,
3174 tool_context: &rpi_extensions::ToolCallContext,
3175) -> bool {
3176 if *state.status.lock().unwrap() == RunStatus::Working {
3177 state.set_status(RunStatus::Aborting);
3178 let _ = lane.abort().await;
3179 }
3180 let cwd_str = cwd.to_string_lossy().to_string();
3181 match crate::session::open_session_by_id(id, &cwd_str).await {
3182 Ok(new_session) => {
3183 let _ =
3187 crate::session::set_active_session(harness, new_session, Some(tool_context)).await;
3188 chat.clear();
3189 add_welcome_message(chat);
3190 render_session_history(
3191 harness,
3192 chat,
3193 state.markdown_transformer(),
3194 Some(state.extension_session.clone()),
3195 )
3196 .await;
3197 state.set_status(RunStatus::Idle);
3198 add_note_message(chat, &format!("Switched to session {id}."));
3199 true
3200 }
3201 Err(e) => {
3202 state.set_status(RunStatus::Idle);
3203 add_error_message(chat, &format!("Could not open session {id}: {e}"));
3204 false
3205 }
3206 }
3207}
3208
3209async fn import_session(
3213 harness: &AgentHarness,
3214 lane: &Arc<dyn AgentLane>,
3215 path: &str,
3216 cwd: &std::path::Path,
3217 chat: &Arc<Container>,
3218 state: &Arc<TuiState>,
3219 tool_context: &rpi_extensions::ToolCallContext,
3220) {
3221 use std::path::Path as FsPath;
3222
3223 let src = FsPath::new(path);
3224 if !src.is_file() {
3225 add_error_message(chat, &format!("Import source not found: {path}"));
3226 return;
3227 }
3228 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3229 add_error_message(chat, "Import source has no file name.");
3230 return;
3231 };
3232 if !fname.ends_with(".jsonl") {
3233 add_error_message(chat, "Import source must be a .jsonl session file.");
3234 return;
3235 }
3236 let dir = crate::session::default_session_dir(cwd);
3237 if let Err(e) = std::fs::create_dir_all(&dir) {
3238 add_error_message(chat, &format!("Could not create session dir: {e}"));
3239 return;
3240 }
3241 let dest = dir.join(fname);
3242 match std::fs::copy(src, &dest) {
3243 Ok(_) => {
3244 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3245 if switch_to_session(harness, lane, &id, cwd, chat, state, tool_context).await {
3246 add_note_message(chat, &format!("Imported session from {path}"));
3247 }
3248 }
3249 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3250 }
3251}
3252
3253async fn fork_session(
3254 harness: &AgentHarness,
3255 cwd: &std::path::Path,
3256 chat: &Arc<Container>,
3257 state: &Arc<TuiState>,
3258 tool_context: &rpi_extensions::ToolCallContext,
3259) {
3260 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3261 use rpi_tools::FileSystem;
3262
3263 let cwd_str = cwd.to_string_lossy().to_string();
3264 let dir = crate::session::default_session_dir(cwd);
3265 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3266 let fs: Arc<dyn FileSystem> = env.clone();
3267 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3268 fs,
3269 sessions_root: dir.to_string_lossy().into_owned(),
3270 clock: Arc::new(rpi_harness::session::memory::SystemClock),
3271 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3272 });
3273 let id = harness.session().storage().metadata().id.clone();
3276 let metas = match crate::session::list_session_metadata(&cwd_str).await {
3277 Ok(m) => m,
3278 Err(e) => {
3279 add_error_message(chat, &format!("Could not list sessions: {e}"));
3280 return;
3281 }
3282 };
3283 let Some(source) = metas.iter().find(|m| m.id == id) else {
3284 add_error_message(chat, &format!("Current session {id} not found on disk."));
3285 return;
3286 };
3287 let fork_storage = match repo
3288 .fork_typed(
3289 source,
3290 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3291 id: None,
3292 parent_session_id: Some(source.id.clone()),
3293 cwd: cwd_str.clone(),
3294 metadata: None,
3295 },
3296 &rpi_harness::session::types::ForkOptions::default(),
3297 )
3298 .await
3299 {
3300 Ok(s) => s,
3301 Err(e) => {
3302 add_error_message(chat, &format!("Could not fork session: {e}"));
3303 return;
3304 }
3305 };
3306 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3307 let _ = crate::session::set_active_session(harness, new_session, Some(tool_context)).await;
3308 chat.clear();
3309 add_welcome_message(chat);
3310 render_session_history(
3311 harness,
3312 chat,
3313 state.markdown_transformer(),
3314 Some(state.extension_session.clone()),
3315 )
3316 .await;
3317 state.set_status(RunStatus::Idle);
3318 add_note_message(chat, "Forked into a new session.");
3319}
3320
3321async fn render_session_history(
3331 harness: &AgentHarness,
3332 chat: &Arc<Container>,
3333 transformer: Option<MarkdownTransformer>,
3334 extension_session: Option<crate::session::ExtensionSessionCell>,
3335) {
3336 let tree = harness.session().view("main");
3337 let entries = match tree
3338 .find_entries(&EntryQuery {
3339 entry_type: None,
3340 custom_type: None,
3341 order: Some(EntryOrder::OldestFirst),
3345 limit: None,
3346 cursor: None,
3347 })
3348 .await
3349 {
3350 Ok(e) => e,
3351 Err(_) => return,
3352 };
3353 let mut rendered_any = false;
3354 for e in entries {
3355 match e {
3356 Entry::Message(me) => match &me.message {
3357 AgentMessage::User(u) => {
3358 add_user_message(chat, &user_message_text(u));
3359 rendered_any = true;
3360 }
3361 AgentMessage::Assistant(a) => {
3362 let comp = Arc::new(AssistantMessageComponent::new(
3363 AssistantMessageOptions::default(),
3364 ));
3365 if let Some(t) = &transformer {
3366 comp.set_markdown_transformer(Some(t.clone()));
3367 }
3368 comp.update_blocks(&assistant_blocks(a));
3369 chat.add_child(comp);
3370 chat.add_child(Arc::new(Spacer::new(1)));
3373 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3374 add_note_message(chat, &text);
3375 }
3376 if let Some(error) = assistant_error_text(a) {
3377 add_error_message(chat, &error);
3378 }
3379 rendered_any = true;
3380 }
3381 AgentMessage::ToolResult(result) => {
3382 let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
3387 comp.set_result(&tool_result_message_text(result), result.is_error);
3388 if tool_result_requests_markdown(result.details.as_ref()) {
3389 comp.set_result_markdown(true);
3390 }
3391 chat.add_child(comp);
3392 chat.add_child(Arc::new(Spacer::new(1)));
3393 rendered_any = true;
3394 }
3395 AgentMessage::Custom(custom) => {
3396 if let Some(session) = &extension_session {
3397 if let Some(component) = extension_message_component(
3398 session,
3399 &custom.role,
3400 &serde_json::json!({
3401 "customType": custom.role,
3402 "content": custom.content,
3403 "details": custom.data,
3404 }),
3405 transformer.clone(),
3406 ) {
3407 chat.add_child(component);
3408 chat.add_child(Arc::new(Spacer::new(1)));
3409 rendered_any = true;
3410 continue;
3411 }
3412 }
3413 add_note_message(chat, &custom_message_fallback(&custom));
3414 rendered_any = true;
3415 }
3416 },
3417 Entry::Compaction(compaction) => {
3418 add_note_message(
3419 chat,
3420 &format!(
3421 "Compacted {} tokens: {}",
3422 compaction.tokens_before, compaction.summary
3423 ),
3424 );
3425 rendered_any = true;
3426 }
3427 Entry::BranchSummary(summary) => {
3428 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3429 rendered_any = true;
3430 }
3431 Entry::Custom(custom) => {
3432 let rendered = extension_session.as_ref().and_then(|session| {
3433 extension_entry_component(session, &custom.custom_type, custom.data.clone())
3434 });
3435 if let Some(component) = rendered {
3436 chat.add_child(component);
3437 chat.add_child(Arc::new(Spacer::new(1)));
3438 rendered_any = true;
3439 } else if let Some(text) =
3440 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3441 {
3442 add_note_message(chat, &text);
3443 rendered_any = true;
3444 }
3445 }
3446 Entry::ModelChange(change) => {
3447 add_note_message(
3448 chat,
3449 &format!("Model changed to {}:{}", change.provider, change.model_id),
3450 );
3451 rendered_any = true;
3452 }
3453 Entry::ThinkingLevel(change) => {
3454 add_note_message(
3455 chat,
3456 &format!("Thinking level: {:?}", change.thinking_level),
3457 );
3458 rendered_any = true;
3459 }
3460 Entry::ActiveTools(change) => {
3461 add_note_message(
3462 chat,
3463 &format!("Active tools: {}", change.active_tool_names.join(", ")),
3464 );
3465 rendered_any = true;
3466 }
3467 }
3468 }
3469 if rendered_any {
3470 }
3473}
3474
3475fn invoke_extension_renderer(
3476 session: &crate::session::ExtensionSessionCell,
3477 kind: rpi_extensions::RegisteredRendererKind,
3478 payload: &serde_json::Value,
3479) -> Option<serde_json::Value> {
3480 let snapshot = session.lock().ok()?.snapshot_arc()?;
3481 let input = serde_json::to_string(payload).ok()?;
3482 for renderer in snapshot.renderers_of(kind) {
3483 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3484 let mut out = rpi_plugin_sdk::StbString::empty();
3485 let rc = (renderer.render_fn)(
3486 rpi_plugin_sdk::StbStringRef::from_str(&input),
3487 &mut out as *mut rpi_plugin_sdk::StbString,
3488 renderer.user_data,
3489 );
3490 let text = if rc == 0 {
3491 Some(out.to_string_lossy())
3492 } else {
3493 None
3494 };
3495 out.free_with(Some(renderer.plugin_free_string));
3496 text
3497 }))
3498 .ok()
3499 .flatten();
3500 let Some(text) = outcome else { continue };
3501 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3502 return Some(value);
3503 }
3504 }
3505 None
3506}
3507
3508fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3509 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3510 let text = lines
3511 .iter()
3512 .filter_map(|line| line.as_str())
3513 .collect::<Vec<_>>()
3514 .join("\n");
3515 return Some(Arc::new(Text::new(text, 0, 0)));
3516 }
3517 let text = value.get("text").and_then(|v| v.as_str())?;
3518 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3519 let component = Arc::new(AssistantMessageComponent::new(
3520 AssistantMessageOptions::default(),
3521 ));
3522 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3523 Some(component)
3524 } else {
3525 Some(Arc::new(Text::new(text, 0, 0)))
3526 }
3527}
3528
3529fn extension_message_component(
3530 session: &crate::session::ExtensionSessionCell,
3531 custom_type: &str,
3532 payload: &serde_json::Value,
3533 transformer: Option<MarkdownTransformer>,
3534) -> Option<Arc<dyn rpi_tui::Component>> {
3535 let value = invoke_extension_renderer(
3536 session,
3537 rpi_extensions::RegisteredRendererKind::Message,
3538 payload,
3539 )?;
3540 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3541 let text = value.get("text").and_then(|v| v.as_str())?;
3542 let component = Arc::new(AssistantMessageComponent::new(
3543 AssistantMessageOptions::default(),
3544 ));
3545 if let Some(transformer) = transformer {
3546 component.set_markdown_transformer(Some(transformer));
3547 }
3548 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3549 return Some(component);
3550 }
3551 extension_text_component(&value)
3552 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
3553}
3554
3555fn extension_usage_text(
3558 session: Option<&crate::session::ExtensionSessionCell>,
3559 usage: &rpi_ai::types::Usage,
3560) -> Option<String> {
3561 let session = session?;
3562 let payload = serde_json::json!({
3563 "customType": "token-usage",
3564 "usage": usage,
3565 });
3566 let value = invoke_extension_renderer(
3567 session,
3568 rpi_extensions::RegisteredRendererKind::Message,
3569 &payload,
3570 )?;
3571 value
3572 .get("text")
3573 .and_then(|value| value.as_str())
3574 .filter(|text| !text.trim().is_empty())
3575 .map(ToOwned::to_owned)
3576}
3577
3578fn extension_entry_component(
3579 session: &crate::session::ExtensionSessionCell,
3580 custom_type: &str,
3581 data: Option<serde_json::Value>,
3582) -> Option<Arc<dyn rpi_tui::Component>> {
3583 let payload = serde_json::json!({
3584 "customType": custom_type,
3585 "data": data,
3586 });
3587 let value = invoke_extension_renderer(
3588 session,
3589 rpi_extensions::RegisteredRendererKind::Entry,
3590 &payload,
3591 )?;
3592 extension_text_component(&value)
3593}
3594
3595fn launch_restores_history(args: &Args) -> bool {
3601 args.continue_session
3602 || args.resume
3603 || args.session.is_some()
3604 || args.session_id.is_some()
3605 || args.fork.is_some()
3606}
3607
3608fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
3609 let content = custom
3610 .content
3611 .iter()
3612 .filter_map(|item| match item {
3613 Content::Text(text) => Some(text.text.as_str()),
3614 _ => None,
3615 })
3616 .collect::<Vec<_>>()
3617 .join("\n");
3618 if content.is_empty() {
3619 format!("{}: {}", custom.role, custom.data)
3620 } else {
3621 format!("{}: {}", custom.role, content)
3622 }
3623}
3624
3625fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
3627 let mut dir = Some(cwd);
3628 while let Some(current) = dir {
3629 let head = current.join(".git").join("HEAD");
3630 if head.exists() {
3631 return Some(head);
3632 }
3633 dir = current.parent();
3634 }
3635 None
3636}
3637
3638fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
3642 let mut dir = Some(cwd);
3643 while let Some(current) = dir {
3644 let head = current.join(".git").join("HEAD");
3645 if let Ok(contents) = std::fs::read_to_string(&head) {
3646 let trimmed = contents.trim();
3647 if let Some(reference) = trimmed.strip_prefix("ref: ") {
3648 let branch = reference
3649 .rsplit('/')
3650 .next()
3651 .unwrap_or(reference)
3652 .trim()
3653 .to_string();
3654 if !branch.is_empty() {
3655 return Some(branch);
3656 }
3657 }
3658 if !trimmed.is_empty() {
3659 return Some(trimmed.chars().take(7).collect());
3660 }
3661 }
3662 dir = current.parent();
3663 }
3664 None
3665}
3666
3667fn short_model_name(id: &str) -> String {
3670 id.rsplit([':', '/'])
3671 .next()
3672 .filter(|s| !s.is_empty())
3673 .unwrap_or(id)
3674 .to_string()
3675}
3676
3677#[derive(Clone, Copy, PartialEq, Eq)]
3683enum RunStatus {
3684 Idle,
3685 Working,
3686 Aborting,
3687}
3688
3689#[derive(Clone, Copy, PartialEq, Eq)]
3691enum SelectorKind {
3692 Model,
3694 Thinking,
3696 Tools,
3698 Images,
3700 Session,
3702 Theme,
3704 ScopedModels,
3706 Settings,
3708 Tree,
3710 Extension,
3712}
3713
3714#[derive(Clone)]
3721enum SelectorView {
3722 List(Arc<SelectList>),
3724 Searchable(Arc<SearchableSelectList>),
3726 Settings(Arc<SettingsList>),
3729}
3730
3731impl SelectorView {
3732 fn handle_key(&self, key: KeyEvent) {
3734 match self {
3735 Self::List(list) => list.handle_key(key),
3736 Self::Searchable(searchable) => searchable.handle_key(key),
3737 Self::Settings(settings) => settings.handle_key(key),
3738 }
3739 }
3740}
3741
3742#[derive(Debug, Clone)]
3753struct AutoSendPending {
3754 deadline: std::time::Instant,
3756 text: String,
3758}
3759
3760#[derive(Debug, PartialEq, Eq)]
3764enum AutoSendStep {
3765 Idle,
3767 Cancel,
3770 Submit,
3772 Wait(std::time::Duration),
3774}
3775
3776fn auto_send_step(
3778 pending: Option<&AutoSendPending>,
3779 current_text: &str,
3780 idle: bool,
3781 now: std::time::Instant,
3782) -> AutoSendStep {
3783 let Some(pending) = pending else {
3784 return AutoSendStep::Idle;
3785 };
3786 if current_text != pending.text || current_text.trim().is_empty() {
3789 return AutoSendStep::Cancel;
3790 }
3791 if !idle {
3792 return AutoSendStep::Cancel;
3793 }
3794 let remaining = pending.deadline.saturating_duration_since(now);
3795 if remaining.is_zero() {
3796 AutoSendStep::Submit
3797 } else {
3798 AutoSendStep::Wait(remaining)
3799 }
3800}
3801
3802struct TuiState {
3803 current_assistant: Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
3807 tool_components: Arc<std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
3809 bash_components: Arc<std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
3813 hide_thinking: Arc<std::sync::Mutex<bool>>,
3816 tool_outputs_expanded: Arc<std::sync::Mutex<bool>>,
3819 show_terminal_progress: bool,
3821 status: std::sync::Mutex<RunStatus>,
3823 ext_status: rpi_extensions::ExtensionStatusMailbox,
3826 ext_status_revision: std::sync::atomic::AtomicU64,
3828 editor_text: rpi_extensions::EditorTextMailbox,
3832 auto_send: std::sync::Mutex<Option<AutoSendPending>>,
3835 last_editor_text: std::sync::Mutex<String>,
3838 programmatic_editor_write: std::sync::atomic::AtomicBool,
3843 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
3847 user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
3851 pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
3855 footer: Arc<FooterComponent>,
3857 status_container: Arc<Container>,
3860 chat_container: Arc<Container>,
3862 loader: Arc<Loader>,
3864 editor: Arc<Editor>,
3867 last_assistant_text: std::sync::Mutex<String>,
3870 active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
3874 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
3876 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
3878 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
3880 autocomplete: AutocompleteManager,
3883 autocomplete_container: Arc<Container>,
3886 autocomplete_max_visible: usize,
3888 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
3890 theme_manager: Arc<ThemeManager>,
3894 tui: Option<Arc<TuiAltScreen>>,
3898 current_model_id: std::sync::Mutex<String>,
3902 show_images: std::sync::Mutex<bool>,
3906 cache_miss_notices: std::sync::Mutex<bool>,
3909 history: std::sync::Mutex<Vec<String>>,
3912 history_index: std::sync::Mutex<isize>,
3915 history_draft: std::sync::Mutex<Option<String>>,
3918 cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
3922 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
3925 markdown_transformer: Arc<std::sync::Mutex<Option<MarkdownTransformer>>>,
3935 extension_session: crate::session::ExtensionSessionCell,
3937 search: Arc<AltScreenSearch>,
3939 search_bar: Arc<SearchBar>,
3941 pending_container: Arc<Container>,
3945 dequeue_hint: String,
3948 pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
3952 selection_start: std::sync::Mutex<Option<(u16, u16)>>,
3954 selection_end: std::sync::Mutex<Option<(u16, u16)>>,
3955}
3956
3957const HISTORY_LIMIT: usize = 100;
3960
3961const AUTO_SEND_PREFIX: &str = "Auto-send in ";
3965
3966const PAGE_SCROLL_OVERLAP: usize = 4;
3969
3970const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
3974
3975fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
3978 let mut modifiers = KeyModifiers::NONE;
3979 let mut key = None;
3980 for part in value.trim().to_ascii_lowercase().split('+') {
3981 match part {
3982 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
3983 "shift" => modifiers |= KeyModifiers::SHIFT,
3984 "alt" | "option" => modifiers |= KeyModifiers::ALT,
3985 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
3986 part if !part.is_empty() => key = Some(part.to_string()),
3987 _ => {}
3988 }
3989 }
3990 let key = key?;
3991 let code = match key.as_str() {
3992 "esc" | "escape" => KeyCode::Esc,
3993 "enter" | "return" => KeyCode::Enter,
3994 "tab" => {
3995 if modifiers.contains(KeyModifiers::SHIFT) {
3996 return Some(rpi_tui::KeyCombo::new(
3997 KeyCode::BackTab,
3998 modifiers & !KeyModifiers::SHIFT,
3999 ));
4000 }
4001 KeyCode::Tab
4002 }
4003 "backspace" | "back" => KeyCode::Backspace,
4004 "delete" | "del" => KeyCode::Delete,
4005 "up" | "arrowup" => KeyCode::Up,
4006 "down" | "arrowdown" => KeyCode::Down,
4007 "left" | "arrowleft" => KeyCode::Left,
4008 "right" | "arrowright" => KeyCode::Right,
4009 "home" => KeyCode::Home,
4010 "end" => KeyCode::End,
4011 "pageup" | "page-up" => KeyCode::PageUp,
4012 "pagedown" | "page-down" => KeyCode::PageDown,
4013 "space" => KeyCode::Char(' '),
4014 "f1" => KeyCode::F(1),
4015 "f2" => KeyCode::F(2),
4016 "f3" => KeyCode::F(3),
4017 "f4" => KeyCode::F(4),
4018 "f5" => KeyCode::F(5),
4019 "f6" => KeyCode::F(6),
4020 "f7" => KeyCode::F(7),
4021 "f8" => KeyCode::F(8),
4022 "f9" => KeyCode::F(9),
4023 "f10" => KeyCode::F(10),
4024 "f11" => KeyCode::F(11),
4025 "f12" => KeyCode::F(12),
4026 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4027 _ => return None,
4028 };
4029 Some(rpi_tui::KeyCombo::new(code, modifiers))
4030}
4031
4032fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4033 let mut bindings = rpi_tui::Keybindings::new();
4034 let settings = crate::settings::load_settings().unwrap_or_default();
4035 let Some(overrides) = settings.keybindings else {
4036 rpi_tui::set_keybindings(bindings.clone());
4037 return Arc::new(bindings);
4038 };
4039 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4040 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4041 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4042 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4043 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4044 (
4045 "app.model.cycleForward",
4046 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4047 ),
4048 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4049 (
4050 "app.thinking.toggle",
4051 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4052 ),
4053 (
4054 "app.editor.external",
4055 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4056 ),
4057 (
4058 "app.thinking.cycle",
4059 rpi_tui::keybindings::keys::THINKING_CYCLE,
4060 ),
4061 (
4062 "app.clipboard.pasteImage",
4063 rpi_tui::keybindings::keys::PASTE_IMAGE,
4064 ),
4065 ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4066 ];
4067 for (name, id) in known {
4068 let Some(value) = overrides.get(*name) else {
4069 continue;
4070 };
4071 let values: Vec<String> = match value {
4072 serde_json::Value::String(value) => vec![value.clone()],
4073 serde_json::Value::Array(values) => values
4074 .iter()
4075 .filter_map(|v| v.as_str().map(str::to_string))
4076 .collect(),
4077 serde_json::Value::Null => Vec::new(),
4078 _ => continue,
4079 };
4080 let combos: Vec<_> = values
4081 .iter()
4082 .filter_map(|value| parse_configured_key(value))
4083 .collect();
4084 if values.is_empty() || !combos.is_empty() {
4085 bindings.set(id, combos);
4086 }
4087 }
4088 rpi_tui::set_keybindings(bindings.clone());
4089 Arc::new(bindings)
4090}
4091
4092fn keybinding_matches(
4093 bindings: &rpi_tui::Keybindings,
4094 event: &crossterm::event::KeyEvent,
4095 id: rpi_tui::KeybindingId,
4096) -> bool {
4097 if bindings.matches(event, id) {
4098 return true;
4099 }
4100 if event.code == KeyCode::BackTab {
4103 let normalized =
4104 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4105 bindings.matches(&normalized, id)
4106 } else {
4107 false
4108 }
4109}
4110
4111fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4115 let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4116 if keys.is_empty() {
4117 return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4118 }
4119 keys.iter()
4120 .map(|combo| combo.display())
4121 .collect::<Vec<_>>()
4122 .join("/")
4123}
4124
4125fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4129 last.is_some_and(|previous| {
4130 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4131 })
4132}
4133
4134#[derive(Debug, PartialEq, Eq)]
4136enum CtrlCAction {
4137 Clear,
4139 Exit,
4141}
4142
4143fn ctrl_c_action(last_sigint: Option<std::time::Instant>, now: std::time::Instant) -> CtrlCAction {
4146 if double_escape_trigger(last_sigint, now) {
4147 CtrlCAction::Exit
4148 } else {
4149 CtrlCAction::Clear
4150 }
4151}
4152
4153const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4157
4158const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4161
4162#[derive(Default)]
4167struct WindowsPasteRun {
4168 text: String,
4169 last_at: Option<std::time::Instant>,
4170 burst: bool,
4171}
4172
4173impl WindowsPasteRun {
4174 fn grace(&self) -> std::time::Duration {
4175 std::time::Duration::from_millis(if self.burst { 120 } else { 15 })
4176 }
4177
4178 fn feed(&mut self, ch: char, now: std::time::Instant) {
4179 if self
4180 .last_at
4181 .is_some_and(|at| now.duration_since(at) <= PASTE_BURST_GAP)
4182 {
4183 self.burst = true;
4184 }
4185 self.text.push(ch);
4186 self.last_at = Some(now);
4187 }
4188
4189 fn flush(&mut self, editor: &Editor) {
4190 if !self.text.is_empty() {
4191 editor.handle_paste(&self.text);
4192 }
4193 *self = Self::default();
4194 }
4195}
4196
4197fn windows_enter_in_paste(
4201 run: &WindowsPasteRun,
4202 more_queued: bool,
4203 clipboard: Option<&str>,
4204) -> bool {
4205 if more_queued {
4206 return true;
4207 }
4208 if run.text.is_empty() {
4209 return false;
4210 }
4211 run.burst
4212 && clipboard.is_some_and(|text| {
4213 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
4214 let prefix = format!("{}\n", run.text);
4215 normalized.starts_with(&prefix) && normalized.matches('\n').count() >= 10
4216 })
4217}
4218
4219#[cfg(test)]
4240fn enter_is_paste_burst(
4241 last_text_key_at: Option<std::time::Instant>,
4242 now: std::time::Instant,
4243 more_queued: bool,
4244) -> bool {
4245 if more_queued {
4246 return true;
4247 }
4248 last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4249}
4250
4251fn probe_paste_input(
4262 pending: Option<Event>,
4263 mut next_event: impl FnMut() -> Option<Event>,
4264) -> (bool, Option<Event>) {
4265 let mut pending = pending;
4266 let mut more_queued = pending.is_some();
4267 while !more_queued {
4268 match next_event() {
4269 Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4270 Some(ev) => {
4271 pending = Some(ev);
4272 more_queued = true;
4273 }
4274 None => break,
4275 }
4276 }
4277 (more_queued, pending)
4278}
4279
4280fn transcript_page_size(viewport_height: usize) -> i32 {
4281 viewport_height
4282 .saturating_sub(PAGE_SCROLL_OVERLAP)
4283 .max(1)
4284 .min(i32::MAX as usize) as i32
4285}
4286
4287fn should_dispatch_key(kind: KeyEventKind) -> bool {
4288 kind != KeyEventKind::Release
4289}
4290
4291fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4301 tui.stop(Default::default());
4305 eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4306 std::process::exit(130);
4307}
4308
4309#[cfg(unix)]
4313fn handle_suspend(tui: &Arc<TuiAltScreen>) {
4314 tui.suspend();
4315 let _ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
4318 tui.resume();
4319}
4320
4321#[cfg(not(unix))]
4324fn handle_suspend(_tui: &Arc<TuiAltScreen>) {}
4325
4326fn format_tokens(n: i64) -> String {
4328 if n >= 1_000_000 {
4329 format!("{:.1}M", n as f64 / 1_000_000.0)
4330 } else if n >= 1_000 {
4331 format!("{:.1}K", n as f64 / 1_000.0)
4332 } else {
4333 n.to_string()
4334 }
4335}
4336
4337fn push_history(state: &Arc<TuiState>, text: &str) {
4341 let trimmed = text.trim().to_string();
4342 if trimmed.is_empty() {
4343 return;
4344 }
4345 let mut history = state.history.lock().unwrap();
4346 if history.first() == Some(&trimmed) {
4347 return;
4348 }
4349 history.insert(0, trimmed);
4350 history.truncate(HISTORY_LIMIT);
4351 *state.history_index.lock().unwrap() = -1;
4352 *state.history_draft.lock().unwrap() = None;
4353}
4354
4355fn submit_draft(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>, text: &str) {
4362 if text.trim().is_empty() || !state.try_start_working() {
4363 return;
4364 }
4365 add_user_message(&state.chat_container, text);
4366 if let Some(tui) = &state.tui {
4367 if let Some(scroll) = tui.get_primary_scroll_view() {
4368 scroll.scroll_to_end();
4369 }
4370 tui.request_render(false);
4371 }
4372 push_history(state, text);
4373 let _ = tx.send(TuiMessage::UserInput(text.to_string()));
4374}
4375
4376fn poll_auto_send(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>) -> bool {
4385 let pending = state.auto_send.lock().unwrap().clone();
4388 let step = auto_send_step(
4389 pending.as_ref(),
4390 &state.editor.get_text(),
4391 *state.status.lock().unwrap() == RunStatus::Idle,
4392 std::time::Instant::now(),
4393 );
4394
4395 match step {
4396 AutoSendStep::Idle => false,
4397 AutoSendStep::Cancel => {
4398 state.clear_auto_send();
4399 true
4400 }
4401 AutoSendStep::Submit => {
4402 let text = state.editor.get_text();
4403 state.clear_auto_send();
4404 state.clear_editor_programmatically();
4405 submit_draft(state, tx, &text);
4406 true
4407 }
4408 AutoSendStep::Wait(remaining) => {
4409 let secs = remaining.as_millis() as f64 / 1000.0;
4410 let label = format!("{AUTO_SEND_PREFIX}{secs:.1}s · any key to edit");
4411 if state.footer.get_status() != label {
4412 state.footer.set_status(&label);
4413 true
4414 } else {
4415 false
4416 }
4417 }
4418 }
4419}
4420
4421fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4426 let history = state.history.lock().unwrap();
4427 if history.is_empty() {
4428 return;
4429 }
4430 let mut index = state.history_index.lock().unwrap();
4431 let new_index = *index - direction as isize;
4432 if new_index < -1 || new_index >= history.len() as isize {
4433 return;
4434 }
4435 if *index == -1 && new_index >= 0 {
4436 *state.history_draft.lock().unwrap() = Some(editor.get_text());
4438 }
4439 *index = new_index;
4440 if new_index == -1 {
4441 let draft = state.history_draft.lock().unwrap().take();
4443 match draft {
4444 Some(d) => {
4445 let len = d.len();
4446 editor.set_text(&d);
4447 editor.set_cursor(0, len);
4448 }
4449 None => editor.set_text(""),
4450 }
4451 } else {
4452 let text = history[new_index as usize].clone();
4453 let len = text.len();
4454 editor.set_text(&text);
4455 editor.set_cursor(0, len);
4456 }
4457}
4458
4459impl TuiState {
4460 fn cancel_js_preparation(&self) -> bool {
4461 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4462 if let Some(cancellation) = cancellation {
4463 cancellation.cancel();
4464 true
4465 } else {
4466 false
4467 }
4468 }
4469
4470 fn user_bash_running(&self) -> bool {
4472 self.user_bash_cancel.lock().unwrap().is_some()
4473 }
4474
4475 fn begin_user_bash(&self) -> CancellationToken {
4478 let cancellation = CancellationToken::new();
4479 if let Some(previous) = self
4480 .user_bash_cancel
4481 .lock()
4482 .unwrap()
4483 .replace(cancellation.clone())
4484 {
4485 previous.cancel();
4486 }
4487 cancellation
4488 }
4489
4490 fn finish_user_bash(&self) {
4493 self.user_bash_cancel.lock().unwrap().take();
4494 }
4495
4496 fn cancel_user_bash(&self) -> bool {
4500 let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
4501 if let Some(cancellation) = cancellation {
4502 cancellation.cancel();
4503 true
4504 } else {
4505 false
4506 }
4507 }
4508
4509 fn set_status(&self, status: RunStatus) {
4510 *self.status.lock().unwrap() = status;
4511 self.apply_status(status);
4512 }
4513
4514 fn pending_container(&self) -> &Arc<Container> {
4517 &self.pending_container
4518 }
4519
4520 fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
4526 {
4527 let mut current = self.pending_snapshot.lock().unwrap();
4528 if *current == messages {
4529 return;
4530 }
4531 *current = messages.clone();
4532 }
4533 let container = &self.pending_container;
4534 container.clear();
4535 if messages.is_empty() {
4536 return;
4537 }
4538 let colors = current_theme().colors;
4539 container.add_child(Arc::new(Spacer::new(1)));
4540 for text in &messages.steering {
4541 container.add_child(Arc::new(Text::new(
4542 colors.muted.fg(&format!("Steering: {text}")),
4543 1,
4544 0,
4545 )));
4546 }
4547 for text in &messages.follow_up {
4548 container.add_child(Arc::new(Text::new(
4549 colors.muted.fg(&format!("Follow-up: {text}")),
4550 1,
4551 0,
4552 )));
4553 }
4554 container.add_child(Arc::new(Text::new(
4555 colors.muted.fg(&format!(
4556 "↳ {} to edit all queued messages",
4557 self.dequeue_hint
4558 )),
4559 1,
4560 0,
4561 )));
4562 }
4563
4564 fn try_start_working(&self) -> bool {
4569 let mut status = self.status.lock().unwrap();
4570 if *status != RunStatus::Idle {
4571 return false;
4572 }
4573 *status = RunStatus::Working;
4574 drop(status);
4575 self.apply_status(RunStatus::Working);
4576 true
4577 }
4578
4579 fn apply_status(&self, status: RunStatus) {
4580 match status {
4581 RunStatus::Working => {
4582 self.footer.set_status("");
4586 if let Some(tui) = &self.tui {
4589 tui.set_title(&crate::brand::window_title("⟳"));
4590 }
4591 self.status_container.clear();
4592 if self.show_terminal_progress {
4595 self.editor.set_working(Some(WorkingState {
4596 frame: 0,
4597 started_at: std::time::Instant::now(),
4598 message: "Working…".to_string(),
4599 }));
4600 } else {
4601 self.editor.set_working(None);
4602 }
4603 }
4604 RunStatus::Aborting => {
4605 self.footer.set_status("Aborting…");
4606 self.loader.stop();
4609 self.status_container.clear();
4610 self.editor.set_working(None);
4611 }
4612 RunStatus::Idle => {
4613 self.footer.set_status("");
4614 if let Some(tui) = &self.tui {
4615 tui.set_title(&crate::brand::window_title(""));
4616 }
4617 self.loader.stop();
4618 self.status_container.clear();
4619 self.editor.set_working(None);
4620 }
4621 }
4622 }
4623
4624 fn sync_extension_status(&self) -> bool {
4628 let revision = self.ext_status.revision();
4629 if self
4630 .ext_status_revision
4631 .load(std::sync::atomic::Ordering::SeqCst)
4632 == revision
4633 {
4634 return false;
4635 }
4636 self.ext_status_revision
4637 .store(revision, std::sync::atomic::Ordering::SeqCst);
4638 self.footer.set_extension_status(&self.ext_status.text());
4639 true
4640 }
4641
4642 fn drain_editor_text(&self) -> bool {
4653 let mut applied = false;
4654 while let Some(edit) = self.editor_text.take_pending() {
4655 let current = self.editor.get_text();
4656 let text = match edit.mode {
4657 rpi_extensions::EditorTextMode::Replace => edit.text,
4658 rpi_extensions::EditorTextMode::Append => {
4659 if current.is_empty() {
4660 edit.text
4661 } else {
4662 let mut joined = current.trim_end().to_string();
4665 joined.push(' ');
4666 joined.push_str(&edit.text);
4667 joined
4668 }
4669 }
4670 };
4671 set_editor_text_caret_at_end(&self.editor, &text);
4672 self.programmatic_editor_write
4674 .store(true, std::sync::atomic::Ordering::SeqCst);
4675 let pending = edit
4676 .auto_send_ms
4677 .filter(|ms| *ms > 0)
4678 .map(|ms| AutoSendPending {
4679 deadline: std::time::Instant::now() + std::time::Duration::from_millis(ms),
4680 text: text.clone(),
4681 });
4682 *self.auto_send.lock().unwrap() = pending;
4683 applied = true;
4684 }
4685 applied
4686 }
4687
4688 fn clear_editor_programmatically(&self) {
4698 self.programmatic_editor_write
4699 .store(true, std::sync::atomic::Ordering::SeqCst);
4700 self.editor.clear();
4701 }
4702
4703 fn clear_auto_send(&self) {
4706 if self.auto_send.lock().unwrap().take().is_some()
4707 && self.footer.get_status().starts_with(AUTO_SEND_PREFIX)
4708 {
4709 self.footer.set_status("");
4710 }
4711 }
4712
4713 fn cancel_auto_send_on_key(&self) -> bool {
4717 let had = self.auto_send.lock().unwrap().is_some();
4718 if had {
4719 self.clear_auto_send();
4720 }
4721 had
4722 }
4723
4724 fn sync_editor_change(&self) {
4741 use rpi_plugin_sdk::EventTag;
4742
4743 let Some(payload) = self.take_editor_change() else {
4744 return;
4745 };
4746 let Ok(session) = self.extension_session.lock() else {
4749 return;
4750 };
4751 let Some(snapshot) = session.snapshot_arc() else {
4752 return;
4753 };
4754 if snapshot.handlers_for(EventTag::EditorChange).is_empty() {
4755 return;
4756 }
4757 rpi_extensions::dispatch_data_event(
4758 &snapshot,
4759 EventTag::EditorChange,
4760 &payload.to_string(),
4761 );
4762 }
4763
4764 fn take_editor_change(&self) -> Option<serde_json::Value> {
4773 let text = self.editor.get_text();
4774 {
4775 let mut last = self.last_editor_text.lock().unwrap();
4776 if *last == text {
4777 return None;
4778 }
4779 *last = text.clone();
4780 }
4781 let programmatic = self
4782 .programmatic_editor_write
4783 .swap(false, std::sync::atomic::Ordering::SeqCst);
4784 Some(serde_json::json!({
4785 "chars": text.chars().count(),
4786 "empty": text.is_empty(),
4787 "source": if programmatic {
4788 "extension"
4790 } else {
4791 "user"
4792 },
4793 }))
4794 }
4795
4796 fn sync_working_loader_with_bash(&self) {
4800 if *self.status.lock().unwrap() != RunStatus::Working {
4801 return;
4802 }
4803
4804 self.status_container.clear();
4809 }
4810
4811 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4812 *self.status.lock().unwrap() = RunStatus::Working;
4813 self.footer.set_status("");
4814 if let Some(tui) = &self.tui {
4815 tui.set_title(&crate::brand::window_title("↻"));
4816 }
4817 self.loader.stop();
4818 self.status_container.clear();
4819 self.status_container
4820 .add_child(Arc::new(StatusIndicator::retry(
4821 attempt,
4822 max_retries,
4823 std::time::Duration::from_millis(delay_ms),
4824 )));
4825 }
4826
4827 fn selector_open(&self) -> bool {
4829 self.active_selector.lock().unwrap().is_some()
4830 }
4831
4832 fn extension_editor_open(&self) -> bool {
4833 self.active_extension_editor.lock().unwrap().is_some()
4834 }
4835
4836 fn extension_input_open(&self) -> bool {
4837 self.active_extension_input.lock().unwrap().is_some()
4838 }
4839
4840 fn extension_dialog_open(&self) -> bool {
4841 self.extension_editor_open() || self.extension_input_open()
4842 }
4843
4844 fn set_hide_thinking(&self, hide: bool) {
4845 *self.hide_thinking.lock().unwrap() = hide;
4846 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4847 comp.set_hide_thinking(hide);
4848 }
4849 }
4850
4851 fn hide_thinking(&self) -> bool {
4852 *self.hide_thinking.lock().unwrap()
4853 }
4854
4855 fn toggle_thinking(&self) -> bool {
4856 let next = !self.hide_thinking();
4857 self.set_hide_thinking(next);
4858 next
4859 }
4860
4861 fn toggle_tool_outputs(&self) -> bool {
4862 let next = !*self.tool_outputs_expanded.lock().unwrap();
4863 *self.tool_outputs_expanded.lock().unwrap() = next;
4864 for comp in self.tool_components.lock().unwrap().values() {
4865 comp.set_expanded(next);
4866 }
4867 for comp in self.bash_components.lock().unwrap().values() {
4868 comp.set_expanded(next);
4869 }
4870 next
4871 }
4872
4873 fn current_model_id(&self) -> String {
4875 self.current_model_id.lock().unwrap().clone()
4876 }
4877
4878 fn set_current_model(&self, model: &rpi_ai::Model) {
4881 *self.current_model_id.lock().unwrap() = model.id.clone();
4882 self.footer.set_model(&short_model_name(&model.id));
4883 self.footer.set_context_window(model.context_window as i64);
4886 }
4887
4888 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4892 self.markdown_transformer.lock().unwrap().clone()
4893 }
4894
4895 fn transcript_view(&self) -> crate::transcript_view::TranscriptView {
4901 crate::transcript_view::TranscriptView::with_maps(
4902 self.chat_container.clone(),
4903 self.current_assistant.clone(),
4904 self.tool_components.clone(),
4905 self.bash_components.clone(),
4906 self.hide_thinking.clone(),
4907 self.tool_outputs_expanded.clone(),
4908 self.markdown_transformer.clone(),
4909 )
4910 }
4911
4912 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4921 *self.markdown_transformer.lock().unwrap() = transformer.clone();
4922 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4923 comp.set_markdown_transformer(transformer);
4924 }
4925 }
4926
4927 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4928 self.pending_images.lock().unwrap().push(image);
4929 }
4930
4931 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4932 std::mem::take(&mut *self.pending_images.lock().unwrap())
4933 }
4934}
4935
4936#[derive(Debug, PartialEq)]
4941struct TuiStartupSettings {
4942 editor_padding_x: usize,
4943 autocomplete_max_visible: usize,
4944 hide_thinking: bool,
4945 quiet_startup: bool,
4946 show_terminal_progress: bool,
4947 show_images: bool,
4949 cache_miss_notices: bool,
4951}
4952
4953fn preferred_project_setting<T>(
4954 project_settings: &[crate::settings::Settings],
4955 field: impl Fn(&crate::settings::Settings) -> Option<T>,
4956) -> Option<T> {
4957 project_settings.iter().find_map(field)
4958}
4959
4960fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4961 verbose || !quiet_startup
4962}
4963
4964fn resolve_tui_startup_settings(
4967 global: &crate::settings::Settings,
4968 project_settings: &[crate::settings::Settings],
4969 project_trusted: bool,
4970) -> TuiStartupSettings {
4971 let project_settings = if project_trusted {
4972 project_settings
4973 } else {
4974 &[]
4975 };
4976 let editor_padding_x =
4977 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4978 .or(global.editor_padding_x)
4979 .unwrap_or(1)
4980 .min(16);
4981 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4982 settings.autocomplete_max_visible
4983 })
4984 .or(global.autocomplete_max_visible)
4985 .unwrap_or(5)
4986 .clamp(1, 20);
4987 let hide_thinking =
4988 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4989 .or(global.hide_thinking_block)
4990 .unwrap_or(false);
4991 let quiet_startup =
4992 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4993 .or(global.quiet_startup)
4994 .unwrap_or(false);
4995 let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
4996 settings.show_terminal_progress()
4997 })
4998 .or_else(|| global.show_terminal_progress())
4999 .unwrap_or(true);
5000 let show_images =
5001 preferred_project_setting(project_settings, |settings| settings.show_images())
5002 .or_else(|| global.show_images())
5003 .unwrap_or(true);
5004 let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
5005 settings.show_cache_miss_notices
5006 })
5007 .or(global.show_cache_miss_notices)
5008 .unwrap_or(false);
5009
5010 TuiStartupSettings {
5011 editor_padding_x,
5012 autocomplete_max_visible,
5013 hide_thinking,
5014 quiet_startup,
5015 show_terminal_progress,
5016 show_images,
5017 cache_miss_notices,
5018 }
5019}
5020
5021pub async fn interactive_tui(
5035 harness: &AgentHarness,
5036 event_rx: Option<broadcast::Receiver<AgentEvent>>,
5037 args: &Args,
5038 model_catalog: Vec<rpi_ai::Model>,
5039 initial: Option<String>,
5040 extra_messages: &[String],
5041 initial_images: Vec<rpi_ai::types::ImageContent>,
5042 theme: Option<&str>,
5043 _no_themes: bool,
5044 reload_context: &crate::session::ReloadContext,
5045) -> i32 {
5046 let lane: Arc<dyn AgentLane> = harness.lane("main");
5047 let cwd = reload_context.cwd.clone();
5051 let saved_settings = crate::settings::load_settings().unwrap_or_default();
5052 let project_trusted = reload_context.project_trusted;
5053 let project_settings = if project_trusted {
5054 crate::settings::load_project_settings(&cwd)
5055 } else {
5056 Vec::new()
5057 };
5058 let tui_settings =
5059 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5060 let editor_padding_x = tui_settings.editor_padding_x;
5061 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5062 let mut hide_thinking = tui_settings.hide_thinking;
5063 let show_terminal_progress = tui_settings.show_terminal_progress;
5064 let show_images = tui_settings.show_images;
5065 let cache_miss_notices = tui_settings.cache_miss_notices;
5066 let quiet_startup = tui_settings.quiet_startup;
5067 let session_values = rpi_harness::session::SessionValues::load(harness.session())
5075 .await
5076 .unwrap_or_default();
5077 if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5078 hide_thinking = persisted;
5079 }
5080
5081 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5085 let model_name = short_model_name(&lane_model_id);
5086
5087 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5091 if active_tool_names.is_empty() {
5095 active_tool_names = harness
5096 .get_tools()
5097 .await
5098 .unwrap_or_default()
5099 .into_iter()
5100 .map(|tool| tool.tool.schema().name.clone())
5101 .collect();
5102 }
5103 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5104 let skill_names: Vec<String> = resources_snapshot
5105 .skills
5106 .as_deref()
5107 .unwrap_or(&[])
5108 .iter()
5109 .map(|skill| skill.name.clone())
5110 .collect();
5111
5112 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5114
5115 let theme_manager = Arc::new(ThemeManager::new());
5119 if let Some(preset) = match theme {
5120 Some("light") => Some(ThemePreset::Light),
5121 Some("monochrome") => Some(ThemePreset::Monochrome),
5122 Some("dark") => Some(ThemePreset::Dark),
5123 _ => None,
5124 } {
5125 apply_theme_preset(preset);
5126 theme_manager.apply_preset(preset);
5127 }
5128
5129 let terminal = Box::new(ProcessTerminal::new());
5131 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5132 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5137 ask_user_bridge.attach();
5138 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5139
5140 let chat_container = Arc::new(Container::new());
5141 if should_show_startup_listing(args.verbose, quiet_startup) {
5142 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5143 }
5144
5145 crate::extras::maybe_first_time_setup(&chat_container);
5149
5150 let initial_transformer = build_markdown_transformer(
5155 reload_context
5156 .extension_session
5157 .lock()
5158 .unwrap()
5159 .snapshot_arc(),
5160 );
5161 if launch_restores_history(args) {
5165 render_session_history(
5166 &harness,
5167 &chat_container,
5168 initial_transformer.clone(),
5169 Some(reload_context.extension_session.clone()),
5170 )
5171 .await;
5172 }
5173
5174 let document_container = Arc::new(Container::new());
5177 document_container.add_child(chat_container.clone());
5178 document_container.add_child(Arc::new(Spacer::new(1)));
5180
5181 let scroll_view = Arc::new(ScrollView::new(
5182 document_container.clone(),
5183 ScrollViewOptions {
5184 follow: FollowMode::End,
5185 primary: true,
5186 overscroll: OverscrollMode::Chain,
5187 scrollbar: ScrollbarMode::Hidden,
5191 ..Default::default()
5192 },
5193 ));
5194
5195 let keybindings = configured_keybindings();
5200 let editor = Arc::new(Editor::new(
5201 EditorOptions {
5202 padding_x: editor_padding_x,
5203 autocomplete_max_visible,
5204 ..Default::default()
5205 },
5206 EditorStyle::default(),
5207 keybindings.clone(),
5208 ));
5209
5210 {
5215 let editor_for_change = editor.clone();
5216 editor.on_change(Arc::new(move |text: &str| {
5217 let colors = current_theme().colors;
5218 let is_bash = text.trim_start().starts_with('!');
5219 editor_for_change.set_border_color(if is_bash {
5220 Some(colors.bash_mode)
5221 } else {
5222 None
5223 });
5224 }));
5225 }
5226
5227 let footer = Arc::new(FooterComponent::new());
5229 footer.set_model(&model_name);
5230 footer.set_cwd(&cwd.to_string_lossy());
5231 footer.set_git_branch(git_branch_for(&cwd).as_deref());
5232 if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5233 footer.set_context_window(m.context_window as i64);
5234 }
5235 footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Clear/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+P: Cycle | Ctrl+O: Expand tool | /help");
5236
5237 let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5240 let footer_ref = footer.clone();
5241 let cwd_ref = cwd.clone();
5242 crate::fs_watch::watch_path(head, move || {
5243 footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5244 })
5245 });
5246
5247 let status_container = Arc::new(Container::new());
5248 let loader = Arc::new(Loader::with_text("Working…"));
5249
5250 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5264 .prompt_templates
5265 .clone()
5266 .unwrap_or_default()
5267 .iter()
5268 .map(|t| SlashCommandEntry {
5269 name: format!("/{}", t.name),
5270 description: t
5271 .description
5272 .clone()
5273 .unwrap_or_else(|| "Expand prompt template".to_string()),
5274 })
5275 .collect();
5276 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5277 Arc::new(resources_snapshot);
5278 let mut command_registry = build_builtin_registry();
5284 register_extension_commands(
5285 &mut command_registry,
5286 reload_context.extension_session.clone(),
5287 );
5288 let registry = Arc::new(command_registry);
5289 let mut all_slash_commands = registry.visible_entries();
5290 all_slash_commands.extend(template_slash_commands);
5291 let autocomplete = AutocompleteManager::new();
5292 {
5293 let mut combined = CombinedAutocompleteProvider::new();
5294 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5295 all_slash_commands,
5296 )));
5297 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5298 cwd.clone(),
5299 )));
5300 autocomplete.set_provider(Arc::new(combined));
5301 }
5302 let autocomplete_container = Arc::new(Container::new());
5303
5304 let tool_outputs_expanded = session_values
5305 .get_as::<bool>("display.tool_outputs_expanded")
5306 .unwrap_or(false);
5307 let state = Arc::new(TuiState {
5308 current_assistant: Arc::new(std::sync::Mutex::new(None)),
5309 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
5310 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
5311 hide_thinking: Arc::new(std::sync::Mutex::new(hide_thinking)),
5312 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(tool_outputs_expanded)),
5313 show_terminal_progress,
5314 status: std::sync::Mutex::new(RunStatus::Idle),
5315 ext_status: reload_context.ext_status.clone(),
5316 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
5317 editor_text: reload_context.editor_text.clone(),
5318 auto_send: std::sync::Mutex::new(None),
5319 last_editor_text: std::sync::Mutex::new(String::new()),
5320 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
5321 js_preparation_cancel: std::sync::Mutex::new(None),
5322 user_bash_cancel: std::sync::Mutex::new(None),
5323 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5324 footer: footer.clone(),
5325 status_container: status_container.clone(),
5326 chat_container: chat_container.clone(),
5327 loader: loader.clone(),
5328 editor: editor.clone(),
5329 last_assistant_text: std::sync::Mutex::new(String::new()),
5330 active_selector: std::sync::Mutex::new(None),
5331 active_extension_editor: std::sync::Mutex::new(None),
5332 active_extension_input: std::sync::Mutex::new(None),
5333 active_extension_cancel: std::sync::Mutex::new(None),
5334 autocomplete,
5335 autocomplete_container: autocomplete_container.clone(),
5336 autocomplete_max_visible,
5337 pending_images: std::sync::Mutex::new(Vec::new()),
5338 theme_manager,
5339 tui: Some(tui.clone()),
5340 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5341 show_images: std::sync::Mutex::new(show_images),
5342 cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5343 history: std::sync::Mutex::new(Vec::new()),
5344 history_index: std::sync::Mutex::new(-1),
5345 history_draft: std::sync::Mutex::new(None),
5346 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5347 scoped_edit: std::sync::Mutex::new(None),
5348 markdown_transformer: Arc::new(std::sync::Mutex::new(initial_transformer)),
5349 extension_session: reload_context.extension_session.clone(),
5350 search: Arc::new(AltScreenSearch::new()),
5351 search_bar: Arc::new(SearchBar::new()),
5352 pending_container: Arc::new(Container::new()),
5353 dequeue_hint: pending_dequeue_hint(&keybindings),
5354 pending_snapshot: std::sync::Mutex::new(
5355 rpi_harness::agent_harness::QueuedMessages::default(),
5356 ),
5357 selection_start: std::sync::Mutex::new(None),
5358 selection_end: std::sync::Mutex::new(None),
5359 });
5360
5361 let model_catalog_arc = Arc::new(model_catalog.clone());
5364 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5365
5366 let editor_container = Arc::new(Container::new());
5377 editor_container.add_child(editor.clone());
5378
5379 let dock = Arc::new(VStack::from_children(vec![
5380 StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5381 StackChild::Entry(StackEntry::new(status_container.clone())),
5382 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5383 StackChild::Entry(
5389 StackEntry::new(editor_container.clone())
5390 .shrink(0)
5391 .min_size(3),
5392 ),
5393 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5394 StackChild::Entry(StackEntry::new(footer.clone())),
5395 ]));
5396
5397 let root = VStack::from_children(vec![
5398 StackChild::Entry(
5399 StackEntry::new(scroll_view.clone())
5400 .basis(0)
5401 .grow(1)
5402 .shrink(1)
5403 .min_size(1),
5404 ),
5405 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5406 ]);
5407
5408 tui.set_layout_root(Some(Arc::new(root)));
5409 tui.set_focus(Some(editor.clone()));
5410 editor.set_focused(true);
5411
5412 let scoped_models = args
5428 .model
5429 .as_deref()
5430 .map(crate::agent_session::ScopedModel::parse)
5431 .into_iter()
5432 .collect();
5433 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5434 harness.clone(),
5435 model_catalog.clone(),
5436 scoped_models,
5437 cwd.clone(),
5438 ));
5439 let ctx = CommandContext {
5440 chat: chat_container.clone(),
5441 tui: tui.clone(),
5442 tx: tx.clone(),
5443 state: state.clone(),
5444 editor: editor.clone(),
5445 editor_container: editor_container.clone(),
5446 lane: lane.clone(),
5447 model_catalog: model_catalog_arc.clone(),
5448 lane_model_id: lane_model_id.clone(),
5449 cwd: cwd.clone(),
5450 resources: resources_arc.clone(),
5451 reload_context: Arc::new(reload_context.clone()),
5452 session: agent_session.clone(),
5453 };
5454
5455 let ctx_for_cb = ctx.clone();
5456 let registry_for_cb = registry.clone();
5457 editor.on_submit(Arc::new(move |text: &str| {
5458 let text = text.trim();
5459 if text.is_empty() {
5460 return;
5461 }
5462
5463 if text.starts_with('/') && dispatch_slash(text, &ctx_for_cb, ®istry_for_cb) {
5464 return;
5465 }
5466
5467 if let Some((command, exclude_from_context)) = parse_user_bash(text) {
5472 if ctx_for_cb.state.user_bash_running() {
5473 add_error_message(
5474 &ctx_for_cb.chat,
5475 "A bash command is already running. Press Esc to cancel it first.",
5476 );
5477 ctx_for_cb.tui.request_render(false);
5478 return;
5479 }
5480 start_user_bash(&ctx_for_cb, command, exclude_from_context);
5481 return;
5482 }
5483
5484 let run_status = *ctx_for_cb.state.status.lock().unwrap();
5485 if run_status != RunStatus::Idle {
5486 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5487 let lane = ctx_for_cb.lane.clone();
5488 let chat = ctx_for_cb.chat.clone();
5489 let tui = ctx_for_cb.tui.clone();
5490 let state = ctx_for_cb.state.clone();
5491 tokio::spawn(async move {
5492 if let Err(error) = lane.steer(message).await {
5502 add_error_message(&chat, &format!("Could not queue message: {error}"));
5503 tui.render_now(false);
5504 return;
5505 }
5506 refresh_pending_messages(&state, &lane).await;
5509 tui.request_render(false);
5510 });
5511 ctx_for_cb.tui.request_render(false);
5517 return;
5518 }
5519
5520 if !ctx_for_cb.state.try_start_working() {
5521 return;
5522 }
5523
5524 add_user_message(&ctx_for_cb.chat, text);
5535 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5538 scroll.scroll_to_end();
5539 }
5540 ctx_for_cb.tui.request_render(false);
5541 push_history(&ctx_for_cb.state, text);
5544 if ctx_for_cb
5545 .tx
5546 .send(TuiMessage::UserInput(text.to_string()))
5547 .is_err()
5548 {
5549 ctx_for_cb.state.set_status(RunStatus::Idle);
5550 }
5551 }));
5552
5553 tui.start_render_scheduler();
5561 tui.start_readerless();
5562
5563 let mut update_check_handles = Vec::new();
5567 let rpi_chat = chat_container.clone();
5568 let rpi_tui = tui.clone();
5569 update_check_handles.push(tokio::spawn(async move {
5570 let report = crate::updates::check_rpi_startup().await;
5571 if !report.is_empty() {
5572 add_update_notices(&rpi_chat, &report);
5573 }
5574 rpi_tui.request_render(false);
5575 }));
5576
5577 let drain_handle = if let Some(rx) = event_rx {
5581 let tui_drain = tui.clone();
5582 let state_drain = state.clone();
5583 let chat_drain = chat_container.clone();
5584 let lane_drain = lane.clone();
5585 Some(tokio::spawn(async move {
5586 drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
5587 }))
5588 } else {
5589 None
5590 };
5591
5592 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5602 reload_context.mailbox.install(reload_sig_tx);
5603 let reload_tx = tx.clone();
5604 let reload_bridge_handle = tokio::spawn(async move {
5605 while reload_sig_rx.recv().await.is_some() {
5606 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5607 break; }
5609 }
5610 });
5611
5612 let tui_tick = tui.clone();
5617 let state_tick = state.clone();
5618 let tx_tick = tx.clone();
5621 let tick_handle = tokio::spawn(async move {
5622 let mut interval = tokio::time::interval(std::time::Duration::from_millis(
5631 rpi_tui::loader::SPINNER_FRAME_MS,
5632 ));
5633 interval.tick().await; loop {
5635 interval.tick().await;
5636 if state_tick.sync_extension_status() {
5639 tui_tick.request_render(false);
5640 }
5641 if state_tick.drain_editor_text() {
5644 tui_tick.request_render(false);
5645 }
5646 state_tick.sync_editor_change();
5649 if poll_auto_send(&state_tick, &tx_tick) {
5651 tui_tick.request_render(false);
5652 }
5653 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5654 if working {
5655 let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
5663 let has_live_panel = {
5664 let tools = state_tick.tool_components.lock().unwrap();
5665 transcript_has_live_panel(bash_present, &tools)
5666 };
5667 if has_live_panel {
5668 tui_tick.request_render(false);
5669 } else {
5670 tui_tick.request_render_reusing_scroll_content();
5674 }
5675 }
5676 }
5677 });
5678
5679 let running = Arc::new(std::sync::Mutex::new(true));
5681 let running_key = running.clone();
5682 let tx_for_key = tx.clone();
5683 let tui_for_key = tui.clone();
5684 let editor_for_key = editor.clone();
5685 let scroll_for_key = scroll_view.clone();
5686 let lane_for_key = lane.clone();
5687 let state_for_key = state.clone();
5688 let model_catalog_for_key = model_catalog_arc.clone();
5689 let ask_user_for_key = ask_user_bridge.clone();
5690 let ctx_for_key = ctx.clone();
5694 let registry_for_key = registry.clone();
5695 let keybindings_for_key = keybindings.clone();
5696 let double_escape_action = crate::settings::load_settings()
5697 .ok()
5698 .and_then(|settings| settings.double_escape_action)
5699 .unwrap_or_else(|| "tree".to_string())
5700 .to_ascii_lowercase();
5701
5702 let key_handle = tokio::task::spawn_blocking(move || {
5703 let mut last_escape_time = None;
5704 let mut last_sigint_time: Option<std::time::Instant> = None;
5707 let mut pending_event: Option<Event> = None;
5711 let mut windows_paste_run = WindowsPasteRun::default();
5712 if crate::key_trace::enabled() {
5713 crate::key_trace::note(&format!(
5714 "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
5715 std::process::id(),
5716 std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
5717 crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
5718 ));
5719 }
5720 loop {
5721 if !*running_key.lock().unwrap() {
5722 break;
5723 }
5724 if !state_for_key.selector_open() && !state_for_key.extension_dialog_open() {
5725 if let Some(request) = ask_user_for_key.take_pending() {
5726 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5727 }
5728 }
5729 let ev = if let Some(ev) = pending_event.take() {
5730 ev
5731 } else {
5732 match crossterm::event::poll(
5735 if cfg!(windows) && !windows_paste_run.text.is_empty() {
5736 windows_paste_run.grace()
5737 } else {
5738 std::time::Duration::from_millis(50)
5739 },
5740 ) {
5741 Ok(true) => {}
5742 Ok(false) => {
5743 if cfg!(windows) && !windows_paste_run.text.is_empty() {
5744 windows_paste_run.flush(&editor_for_key);
5745 refresh_autocomplete(&state_for_key, &editor_for_key);
5746 tui_for_key.request_render_reusing_scroll_content();
5747 }
5748 continue;
5749 }
5750 Err(_) => {
5751 state_for_key.cancel_js_preparation();
5752 let _ = tx_for_key.send(TuiMessage::Exit);
5753 break;
5754 }
5755 }
5756 let Ok(ev) = crossterm::event::read() else {
5757 state_for_key.cancel_js_preparation();
5758 let _ = tx_for_key.send(TuiMessage::Exit);
5759 break;
5760 };
5761 ev
5762 };
5763 if let Event::Resize(_cols, _rows) = ev {
5768 tui_for_key.refresh_size();
5769 continue;
5770 }
5771 if let Event::Mouse(m) = ev {
5775 use crossterm::event::MouseEventKind;
5776 match m.kind {
5777 MouseEventKind::ScrollUp => {
5778 let delta = -MOUSE_WHEEL_SCROLL_LINES;
5779 if scroll_for_key.scroll_by(delta) != delta {
5780 tui_for_key.request_render_reusing_scroll_content();
5781 }
5782 }
5783 MouseEventKind::ScrollDown => {
5784 let delta = MOUSE_WHEEL_SCROLL_LINES;
5785 if scroll_for_key.scroll_by(delta) != delta {
5786 tui_for_key.request_render_reusing_scroll_content();
5787 }
5788 }
5789 MouseEventKind::Down(crossterm::event::MouseButton::Right) if cfg!(windows) => {
5790 if let Some(text) = read_clipboard_text() {
5796 if !text.is_empty() {
5797 editor_for_key.handle_paste(&text);
5798 refresh_autocomplete(&state_for_key, &editor_for_key);
5799 tui_for_key.request_render_reusing_scroll_content();
5800 }
5801 }
5802 }
5803 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
5804 *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
5808 *state_for_key.selection_end.lock().unwrap() = None;
5809 }
5810 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
5811 if state_for_key.selection_start.lock().unwrap().is_some() {
5815 *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
5816 }
5817 }
5818 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
5819 if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
5821 let end = (m.column, m.row);
5822
5823 let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
5832 *state_for_key.selection_end.lock().unwrap() = Some(end);
5833
5834 let auto_copy = crate::settings::load_settings()
5836 .ok()
5837 .and_then(|s| s.fullscreen_copy_on_select)
5838 .unwrap_or(true);
5839
5840 if auto_copy && is_drag {
5841 if let Some(selected_text) =
5843 extract_selected_text(&state_for_key.chat_container, start, end)
5844 {
5845 if !selected_text.trim().is_empty() {
5846 let _ = copy_to_clipboard(&selected_text);
5847 }
5848 }
5849 }
5850 }
5851 *state_for_key.selection_start.lock().unwrap() = None;
5852 *state_for_key.selection_end.lock().unwrap() = None;
5853 }
5854 _ => {}
5855 }
5856 continue;
5857 }
5858 let Event::Key(key) = ev else {
5859 if let Event::Paste(text) = ev {
5860 crate::key_trace::note(&format!(
5861 "paste event ({} bytes, bracketed paste supported)",
5862 text.len()
5863 ));
5864 let candidate = text.trim().trim_matches(['\"', '\'']);
5865 let path = std::path::PathBuf::from(candidate);
5866 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5867 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5868 add_image_preview(&state_for_key.chat_container, &image);
5869 state_for_key.queue_image(image);
5870 add_note_message(
5871 &state_for_key.chat_container,
5872 "Dropped image attached to the next prompt.",
5873 );
5874 tui_for_key.request_render(false);
5875 continue;
5876 }
5877 }
5878 editor_for_key.handle_paste(&text);
5879 refresh_autocomplete(&state_for_key, &editor_for_key);
5880 tui_for_key.request_render_reusing_scroll_content();
5881 }
5882 continue;
5883 };
5884 crate::key_trace::key(&key, "");
5888 if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
5892 tui_for_key.request_render(false);
5893 }
5894 if dispatch_key_event(&state_for_key, &key, &editor_for_key) {
5899 continue;
5900 }
5901 if !should_dispatch_key(key.kind) {
5906 continue;
5907 }
5908
5909 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5915 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5916 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5917 if prompt_abort && state_for_key.cancel_js_preparation() {
5918 state_for_key.set_status(RunStatus::Aborting);
5919 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5920 close_extension_editor(
5921 &state_for_key,
5922 &ctx_for_key.editor_container,
5923 &editor_for_key,
5924 &tui_for_key,
5925 );
5926 }
5927 tui_for_key.set_render_suspended(false);
5928 tui_for_key.request_render(false);
5929 continue;
5930 }
5931
5932 if key.modifiers == KeyModifiers::CONTROL
5935 && key.code == KeyCode::Char('c')
5936 && state_for_key.selector_open()
5937 {
5938 let selector = state_for_key
5939 .active_selector
5940 .lock()
5941 .unwrap()
5942 .clone()
5943 .expect("selector_open guaranteed Some")
5944 .0;
5945 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5946 tui_for_key.request_render_reusing_scroll_content();
5947 continue;
5948 }
5949
5950 if state_for_key.extension_dialog_open() {
5954 let cancel = key.code == KeyCode::Esc
5955 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5956 if cancel {
5957 if !run_extension_cancel(&state_for_key) {
5958 close_extension_editor(
5959 &state_for_key,
5960 &ctx_for_key.editor_container,
5961 &editor_for_key,
5962 &tui_for_key,
5963 );
5964 }
5965 } else if let Some(extension_editor) = state_for_key
5966 .active_extension_editor
5967 .lock()
5968 .unwrap()
5969 .clone()
5970 {
5971 extension_editor.handle_key(key);
5972 } else if let Some(extension_input) =
5973 state_for_key.active_extension_input.lock().unwrap().clone()
5974 {
5975 extension_input.handle_key(key);
5976 }
5977 tui_for_key.request_render_reusing_scroll_content();
5978 continue;
5979 }
5980
5981 if keybinding_matches(
5989 &keybindings_for_key,
5990 &key,
5991 rpi_tui::keybindings::keys::CLEAR,
5992 ) {
5993 if key.kind != KeyEventKind::Repeat {
5997 let now = std::time::Instant::now();
5998 match ctrl_c_action(last_sigint_time, now) {
5999 CtrlCAction::Exit => {
6000 last_sigint_time = None;
6001 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
6007 let _ = tx_for_key.send(TuiMessage::Exit);
6008 } else {
6009 emergency_exit(&tui_for_key);
6010 }
6011 }
6012 CtrlCAction::Clear => {
6013 editor_for_key.clear();
6014 refresh_autocomplete(&state_for_key, &editor_for_key);
6015 last_sigint_time = Some(now);
6016 tui_for_key.request_render(false);
6017 }
6018 }
6019 }
6020 continue;
6021 }
6022
6023 if !state_for_key.selector_open()
6027 && keybinding_matches(
6028 &keybindings_for_key,
6029 &key,
6030 rpi_tui::keybindings::keys::MESSAGE_COPY,
6031 )
6032 {
6033 if editor_for_key.has_selection() && editor_for_key.copy_selection() {
6034 add_note_message(&state_for_key.chat_container, "Copied!");
6035 } else {
6036 copy_last_assistant(&state_for_key, &state_for_key.chat_container);
6037 }
6038 tui_for_key.request_render(false);
6039 continue;
6040 }
6041
6042 if keybinding_matches(
6047 &keybindings_for_key,
6048 &key,
6049 rpi_tui::keybindings::keys::SUSPEND,
6050 ) {
6051 handle_suspend(&tui_for_key);
6052 continue;
6053 }
6054
6055 if state_for_key.selector_open() {
6060 if key.code == KeyCode::Esc {
6066 let (selector, _kind) = state_for_key
6067 .active_selector
6068 .lock()
6069 .unwrap()
6070 .clone()
6071 .expect("selector_open guaranteed Some");
6072 selector.handle_key(key);
6073 continue;
6074 }
6075 let (selector, _kind) = state_for_key
6076 .active_selector
6077 .lock()
6078 .unwrap()
6079 .clone()
6080 .expect("selector_open guaranteed Some");
6081 selector.handle_key(key);
6082 tui_for_key.request_render_reusing_scroll_content();
6083 continue;
6084 }
6085
6086 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6091 if !editor_for_key.get_text().is_empty() {
6092 editor_for_key.handle_key(key);
6094 refresh_autocomplete(&state_for_key, &editor_for_key);
6095 tui_for_key.request_render_reusing_scroll_content();
6096 continue;
6097 }
6098 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
6101 let _ = tx_for_key.send(TuiMessage::Exit);
6102 } else {
6103 emergency_exit(&tui_for_key);
6104 }
6105 continue;
6106 }
6107
6108 if keybinding_matches(
6113 &keybindings_for_key,
6114 &key,
6115 rpi_tui::keybindings::keys::INTERRUPT,
6116 ) {
6117 let status = *state_for_key.status.lock().unwrap();
6118 if status == RunStatus::Working {
6119 state_for_key.set_status(RunStatus::Aborting);
6120 ask_user_for_key.cancel_all();
6121 if !run_extension_cancel(&state_for_key)
6122 && state_for_key.extension_dialog_open()
6123 {
6124 close_extension_editor(
6125 &state_for_key,
6126 &ctx_for_key.editor_container,
6127 &editor_for_key,
6128 &tui_for_key,
6129 );
6130 }
6131 abort_run_restoring_queue(
6134 lane_for_key.clone(),
6135 editor_for_key.clone(),
6136 state_for_key.clone(),
6137 tui_for_key.clone(),
6138 );
6139 continue;
6140 }
6141 if state_for_key.user_bash_running() {
6147 state_for_key.cancel_user_bash();
6148 tui_for_key.request_render(false);
6149 continue;
6150 }
6151 if status == RunStatus::Idle
6152 && editor_for_key.get_text().trim().is_empty()
6153 && double_escape_action != "none"
6154 {
6155 let now = std::time::Instant::now();
6156 if double_escape_trigger(last_escape_time, now) {
6157 last_escape_time = None;
6158 match double_escape_action.as_str() {
6159 "tree" => {
6160 let _ = tx_for_key.send(TuiMessage::OpenTree);
6161 }
6162 "fork" => {
6163 let _ = tx_for_key.send(TuiMessage::ForkSession);
6164 }
6165 _ => {}
6166 }
6167 } else {
6168 last_escape_time = Some(now);
6169 }
6170 }
6171 continue;
6172 }
6173
6174 if keybinding_matches(
6177 &keybindings_for_key,
6178 &key,
6179 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6180 ) {
6181 launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
6182 continue;
6183 }
6184
6185 if keybinding_matches(
6188 &keybindings_for_key,
6189 &key,
6190 rpi_tui::keybindings::keys::TOOLS_EXPAND,
6191 ) {
6192 let expanded = state_for_key.toggle_tool_outputs();
6193 let session = ctx_for_key.session.clone();
6195 tokio::spawn(async move {
6196 let _ = session
6197 .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6198 .await;
6199 });
6200 tui_for_key.request_render(false);
6201 continue;
6202 }
6203
6204 if keybinding_matches(
6206 &keybindings_for_key,
6207 &key,
6208 rpi_tui::keybindings::keys::THINKING_TOGGLE,
6209 ) {
6210 let hide = state_for_key.toggle_thinking();
6211 let session = ctx_for_key.session.clone();
6213 tokio::spawn(async move {
6214 let _ = session
6215 .set_value("display.hide_thinking", serde_json::json!(hide))
6216 .await;
6217 });
6218 tui_for_key.request_render(false);
6219 continue;
6220 }
6221
6222 if keybinding_matches(
6228 &keybindings_for_key,
6229 &key,
6230 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6231 ) {
6232 let current = state_for_key.current_model_id();
6233 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6236 if let Some(next) = cycle_next_model(&scope, ¤t) {
6237 state_for_key.set_current_model(&next);
6238 let lane = lane_for_key.clone();
6239 tokio::spawn(async move {
6240 let _ = lane.set_model(next).await;
6241 });
6242 tui_for_key.request_render_reusing_scroll_content();
6243 }
6244 continue;
6245 }
6246
6247 if keybinding_matches(
6250 &keybindings_for_key,
6251 &key,
6252 rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
6253 ) {
6254 let current = state_for_key.current_model_id();
6255 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6256 if let Some(prev) = cycle_prev_model(&scope, ¤t) {
6257 state_for_key.set_current_model(&prev);
6258 let lane = lane_for_key.clone();
6259 tokio::spawn(async move {
6260 let _ = lane.set_model(prev).await;
6261 });
6262 tui_for_key.request_render_reusing_scroll_content();
6263 }
6264 continue;
6265 }
6266
6267 if keybinding_matches(
6270 &keybindings_for_key,
6271 &key,
6272 rpi_tui::keybindings::keys::THINKING_CYCLE,
6273 ) {
6274 let lane = lane_for_key.clone();
6275 let catalog = model_catalog_for_key.clone();
6276 let state = state_for_key.clone();
6277 tokio::spawn(async move {
6278 let Ok(current_model) = lane.get_model().await else {
6279 return;
6280 };
6281 let levels = catalog
6282 .iter()
6283 .find(|model| {
6284 model.provider == current_model.provider && model.id == current_model.id
6285 })
6286 .map(|model| model.supported_thinking_levels())
6287 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6288 if levels.is_empty() {
6289 return;
6290 }
6291 let current = lane
6292 .get_thinking_level()
6293 .await
6294 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6295 let next = levels
6296 .iter()
6297 .position(|level| *level == current)
6298 .map(|index| levels[(index + 1) % levels.len()])
6299 .unwrap_or(levels[0]);
6300 if lane.set_thinking_level(next).await.is_ok() {
6301 state
6302 .footer
6303 .set_thinking_level(Some(thinking_level_name(next)));
6304 state.tui.as_ref().map(|tui| tui.request_render(false));
6305 }
6306 });
6307 continue;
6308 }
6309
6310 if keybinding_matches(
6320 &keybindings_for_key,
6321 &key,
6322 rpi_tui::keybindings::keys::PASTE_IMAGE,
6323 ) {
6324 match read_clipboard_image() {
6325 Ok(Some(image)) => {
6326 add_image_preview(&state_for_key.chat_container, &image);
6327 state_for_key.queue_image(image);
6328 add_note_message(
6329 &state_for_key.chat_container,
6330 "Clipboard image attached to the next prompt.",
6331 );
6332 tui_for_key.request_render(false);
6333 continue;
6334 }
6335 Ok(None) | Err(_) => {}
6336 }
6337 if cfg!(windows) {
6348 if let Some(text) = read_clipboard_text() {
6349 if !text.is_empty() {
6350 editor_for_key.handle_paste(&text);
6351 refresh_autocomplete(&state_for_key, &editor_for_key);
6352 tui_for_key.request_render_reusing_scroll_content();
6353 continue;
6354 }
6355 }
6356 }
6357 }
6358
6359 if key
6361 .modifiers
6362 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6363 && key.code == KeyCode::Char('F')
6364 {
6365 state_for_key.search.activate();
6366 state_for_key.search_bar.set_visible(true);
6367 state_for_key.search_bar.set_query("");
6368 state_for_key.search_bar.set_match_info(0, 0);
6369 tui_for_key.request_render(false);
6370 continue;
6371 }
6372
6373 if state_for_key.search.is_active() {
6375 let search_bar = state_for_key.search_bar.clone();
6376 match key.code {
6377 KeyCode::Esc => {
6378 state_for_key.search.deactivate();
6379 search_bar.set_visible(false);
6380 tui_for_key.request_render(false);
6381 continue;
6382 }
6383 KeyCode::Enter => {
6384 if key.modifiers.contains(KeyModifiers::SHIFT) {
6385 state_for_key.search.previous_match();
6386 } else {
6387 state_for_key.search.next_match();
6388 }
6389 let match_index = state_for_key.search.get_match_index();
6391 let match_count = state_for_key.search.get_match_count();
6392 search_bar.set_match_info(match_index, match_count);
6393 tui_for_key.request_render(false);
6394 continue;
6395 }
6396 KeyCode::Backspace => {
6397 state_for_key.search.backspace();
6398 let lines = collect_transcript_lines(&state_for_key.chat_container);
6400 state_for_key.search.find_matches(&lines);
6401 let query = state_for_key.search.get_query();
6403 let match_count = state_for_key.search.get_match_count();
6404 search_bar.set_query(&query);
6405 search_bar.set_match_info(0, match_count);
6406 tui_for_key.request_render(false);
6407 continue;
6408 }
6409 KeyCode::Char(c) => {
6410 state_for_key.search.append_char(c);
6411 let lines = collect_transcript_lines(&state_for_key.chat_container);
6413 state_for_key.search.find_matches(&lines);
6414 let query = state_for_key.search.get_query();
6416 let match_count = state_for_key.search.get_match_count();
6417 search_bar.set_query(&query);
6418 search_bar.set_match_info(0, match_count);
6419 tui_for_key.request_render(false);
6420 continue;
6421 }
6422 _ => continue,
6423 }
6424 }
6425
6426 if keybinding_matches(
6430 &keybindings_for_key,
6431 &key,
6432 rpi_tui::keybindings::keys::MODEL_SELECT,
6433 ) {
6434 if let Some(cmd) = registry_for_key.find("/model") {
6435 cmd.execute(&ctx_for_key, "");
6436 }
6437 continue;
6438 }
6439
6440 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6442 if accept_top_suggestion(&state_for_key, &editor_for_key) {
6443 tui_for_key.request_render_reusing_scroll_content();
6444 }
6445 continue;
6446 }
6447
6448 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6452 let delta = -transcript_page_size(scroll_for_key.viewport_height());
6453 if scroll_for_key.scroll_by(delta) != delta {
6454 tui_for_key.request_render_reusing_scroll_content();
6455 }
6456 continue;
6457 }
6458 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6459 let delta = transcript_page_size(scroll_for_key.viewport_height());
6460 if scroll_for_key.scroll_by(delta) != delta {
6461 tui_for_key.request_render_reusing_scroll_content();
6462 }
6463 continue;
6464 }
6465 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6466 scroll_for_key.scroll_to_start();
6467 tui_for_key.request_render_reusing_scroll_content();
6468 continue;
6469 }
6470 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
6471 scroll_for_key.scroll_to_end();
6472 tui_for_key.request_render_reusing_scroll_content();
6473 continue;
6474 }
6475
6476 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
6485 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6486 if editor_for_key.get_text().is_empty() || browsing {
6487 navigate_history(&state_for_key, &editor_for_key, -1);
6488 tui_for_key.request_render_reusing_scroll_content();
6489 continue;
6490 }
6491 }
6492 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
6493 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6494 if editor_for_key.get_text().is_empty() || browsing {
6495 navigate_history(&state_for_key, &editor_for_key, 1);
6496 tui_for_key.request_render_reusing_scroll_content();
6497 continue;
6498 }
6499 }
6500
6501 if keybinding_matches(
6506 &keybindings_for_key,
6507 &key,
6508 rpi_tui::keybindings::keys::DEQUEUE,
6509 ) {
6510 tokio::spawn(handle_dequeue(
6522 lane_for_key.clone(),
6523 editor_for_key.clone(),
6524 state_for_key.clone(),
6525 tui_for_key.clone(),
6526 ));
6527 continue;
6528 }
6529
6530 if keybinding_matches(
6535 &keybindings_for_key,
6536 &key,
6537 rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
6538 ) {
6539 let prompt = editor_for_key.get_expanded_text().trim().to_string();
6540 if prompt.is_empty() {
6541 continue;
6542 }
6543 editor_for_key.clear();
6544 let status = *state_for_key.status.lock().unwrap();
6545 if status == RunStatus::Idle {
6546 if state_for_key.try_start_working() {
6547 add_user_message(&state_for_key.chat_container, &prompt);
6551 push_history(&state_for_key, &prompt);
6552 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
6553 }
6554 } else {
6555 let message = AgentMessage::User(UserMessage::new(prompt, 0));
6559 let lane = lane_for_key.clone();
6560 let chat = state_for_key.chat_container.clone();
6561 let tui = tui_for_key.clone();
6562 let state = state_for_key.clone();
6563 tokio::spawn(async move {
6564 if let Err(error) = lane.follow_up(message).await {
6565 add_error_message(&chat, &format!("Could not queue message: {error}"));
6566 tui.request_render(false);
6567 return;
6568 }
6569 refresh_pending_messages(&state, &lane).await;
6570 tui.request_render(false);
6571 });
6572 }
6573 tui_for_key.request_render(false);
6574 continue;
6575 }
6576
6577 if cfg!(windows) {
6582 let text_key = match key.code {
6583 KeyCode::Char(c)
6584 if !key
6585 .modifiers
6586 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
6587 {
6588 Some(c)
6589 }
6590 _ => None,
6591 };
6592 if let Some(ch) = text_key {
6593 if ch == '\r' || ch == '\n' {
6594 } else {
6596 windows_paste_run.feed(ch, std::time::Instant::now());
6597 continue;
6598 }
6599 }
6600 if key.modifiers.is_empty()
6601 && matches!(
6602 key.code,
6603 KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
6604 )
6605 {
6606 let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
6607 if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
6608 crossterm::event::read().ok()
6609 } else {
6610 None
6611 }
6612 });
6613 pending_event = stashed;
6614 let clipboard = if !more_queued && windows_paste_run.burst {
6615 read_clipboard_text()
6616 } else {
6617 None
6618 };
6619 if windows_enter_in_paste(&windows_paste_run, more_queued, clipboard.as_deref())
6620 {
6621 windows_paste_run.feed('\n', std::time::Instant::now());
6622 crate::key_trace::note("windows paste run -> newline");
6623 continue;
6624 }
6625 crate::key_trace::note("windows paste run -> flush + submit");
6626 windows_paste_run.flush(&editor_for_key);
6627 } else {
6628 windows_paste_run.flush(&editor_for_key);
6629 }
6630 }
6631
6632 editor_for_key.handle_key(key);
6638 refresh_autocomplete(&state_for_key, &editor_for_key);
6639 tui_for_key.request_render_reusing_scroll_content();
6640 }
6641 });
6642
6643 if let Some(reason) = crate::session::dispatch_session_event_async(
6649 reload_context,
6650 rpi_plugin_sdk::EventTag::SessionStart,
6651 )
6652 .await
6653 {
6654 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
6655 }
6656
6657 let mut prompts: Vec<String> = Vec::new();
6659 if let Some(init) = initial {
6660 prompts.push(init);
6661 }
6662 for m in extra_messages {
6663 prompts.push(m.clone());
6664 }
6665 if prompts.is_empty() && lane.has_pending_resume().await {
6672 run_prompt_streaming(
6673 &lane,
6674 "",
6675 true,
6676 &tui,
6677 &state,
6678 drain_handle.is_some(),
6679 Vec::new(),
6680 )
6681 .await;
6682 }
6683
6684 let mut images = initial_images;
6685 for prompt in prompts {
6686 if !*running.lock().unwrap() {
6687 break;
6688 }
6689 add_user_message(&chat_container, &prompt);
6693 tui.request_render(false);
6694 run_prompt_streaming(
6695 &lane,
6696 &prompt,
6697 false,
6698 &tui,
6699 &state,
6700 drain_handle.is_some(),
6701 std::mem::take(&mut images),
6702 )
6703 .await;
6704 }
6705
6706 loop {
6708 if !*running.lock().unwrap() {
6709 break;
6710 }
6711 match rx.recv().await {
6712 Some(TuiMessage::UserInput(prompt)) => {
6713 state.clear_editor_programmatically();
6721 let prompt_images = state.take_pending_images();
6722 if !prompt_images.is_empty() {
6723 add_note_message(
6724 &chat_container,
6725 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6726 );
6727 }
6728 run_prompt_streaming(
6729 &lane,
6730 &prompt,
6731 false,
6732 &tui,
6733 &state,
6734 drain_handle.is_some(),
6735 prompt_images,
6736 )
6737 .await;
6738 }
6739 Some(TuiMessage::ExternalEditorResult(result)) => {
6740 match result {
6741 Ok(text) => {
6742 let cursor = text.chars().count();
6743 editor.set_text(&text);
6744 editor.set_cursor(0, cursor);
6745 add_note_message(&chat_container, "Draft updated from external editor.");
6746 }
6747 Err(error) => add_error_message(&chat_container, &error),
6748 }
6749 tui.request_render(false);
6750 }
6751 Some(TuiMessage::UserBashFinished(report)) => {
6752 state.finish_user_bash();
6755 let message = rpi_harness::messages::bash_execution_message(
6756 report.command,
6757 report.output,
6758 report.exit_code,
6759 report.cancelled,
6760 report.truncated,
6761 report.full_output_path,
6762 Some(report.exclude_from_context),
6763 now_ms(),
6764 );
6765 if *state.status.lock().unwrap() == RunStatus::Idle {
6769 if let Err(error) = lane.append_message(message).await {
6770 add_error_message(
6771 &chat_container,
6772 &format!("Could not record bash result: {error}"),
6773 );
6774 tui.request_render(false);
6775 }
6776 } else {
6777 state.pending_bash_messages.lock().unwrap().push(message);
6778 }
6779 }
6780 Some(TuiMessage::OpenTree) => {
6781 if *state.status.lock().unwrap() != RunStatus::Idle {
6782 add_note_message(
6783 &chat_container,
6784 "Wait for the current run to finish before opening the tree.",
6785 );
6786 tui.request_render(false);
6787 } else {
6788 open_tree_selector(
6789 &harness,
6790 &state,
6791 &editor_container,
6792 &editor,
6793 &tui,
6794 &chat_container,
6795 &tx,
6796 )
6797 .await;
6798 }
6799 }
6800 Some(TuiMessage::NavigateTree(entry_id)) => {
6801 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6802 Ok(result) => match result.outcome {
6803 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6804 chat_container.clear();
6805 add_welcome_message(&chat_container);
6806 render_session_history(
6807 &harness,
6808 &chat_container,
6809 state.markdown_transformer(),
6810 Some(state.extension_session.clone()),
6811 )
6812 .await;
6813 add_note_message(
6814 &chat_container,
6815 "Moved to the selected session entry.",
6816 );
6817 }
6818 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6819 add_error_message(&chat_container, &error.message);
6820 }
6821 _ => add_note_message(
6822 &chat_container,
6823 "The selected entry could not be opened.",
6824 ),
6825 },
6826 Err(error) => add_error_message(
6827 &chat_container,
6828 &format!("Could not navigate session tree: {error}"),
6829 ),
6830 }
6831 tui.request_render(false);
6832 }
6833 Some(TuiMessage::ClearChat) => {
6834 chat_container.clear();
6835 add_welcome_message(&chat_container);
6836 tui.request_render(false);
6837 }
6838 Some(TuiMessage::Compact) => {
6839 run_compact(&lane, &tui, &state).await;
6840 }
6841 Some(TuiMessage::Copy) => {
6842 copy_last_assistant(&state, &chat_container);
6843 tui.request_render(false);
6844 }
6845 Some(TuiMessage::Exit) => {
6846 *running.lock().unwrap() = false;
6847 break;
6848 }
6849 Some(TuiMessage::SwitchSession(id)) => {
6850 switch_to_session(
6851 &harness,
6852 &lane,
6853 &id,
6854 &cwd,
6855 &chat_container,
6856 &state,
6857 &reload_context.tool_context,
6858 )
6859 .await;
6860 tui.request_render(false);
6861 }
6862 Some(TuiMessage::ImportSession(path)) => {
6863 import_session(
6864 &harness,
6865 &lane,
6866 &path,
6867 &cwd,
6868 &chat_container,
6869 &state,
6870 &reload_context.tool_context,
6871 )
6872 .await;
6873 tui.request_render(false);
6874 }
6875 Some(TuiMessage::ShareSession) => {
6876 share_session(&harness, &chat_container).await;
6877 tui.request_render(false);
6878 }
6879 Some(TuiMessage::SetSessionName(name)) => {
6880 let outcome = harness.session().set_name(Some(&name)).await;
6881 match outcome {
6882 Ok(_) => add_note_message(
6883 &chat_container,
6884 &format!("Session renamed to \"{name}\"."),
6885 ),
6886 Err(e) => add_error_message(
6887 &chat_container,
6888 &format!("Could not rename session: {e}"),
6889 ),
6890 }
6891 tui.request_render(false);
6892 }
6893 Some(TuiMessage::ExportSession) => {
6894 let file_name = agent_session
6895 .default_export_file_name(crate::export::ExportFormat::Markdown)
6896 .await;
6897 let path = cwd.join(&file_name);
6898 match agent_session.export_to_markdown(&path).await {
6899 Ok(_) => add_note_message(
6900 &chat_container,
6901 &format!("Exported session to {}", path.display()),
6902 ),
6903 Err(e) => {
6904 add_error_message(&chat_container, &format!("Could not write export: {e}"))
6905 }
6906 }
6907 tui.request_render(false);
6908 }
6909 Some(TuiMessage::ExportSessionWithFormat(format)) => {
6910 let file_name = agent_session.default_export_file_name(format.clone()).await;
6911 let path = cwd.join(&file_name);
6912 match agent_session.export(format, &path).await {
6913 Ok(_) => add_note_message(
6914 &chat_container,
6915 &format!("Exported session to {}", path.display()),
6916 ),
6917 Err(e) => {
6918 add_error_message(&chat_container, &format!("Could not write export: {e}"))
6919 }
6920 }
6921 tui.request_render(false);
6922 }
6923 Some(TuiMessage::ShowUsage) => {
6924 let stats = agent_session.usage_stats().await.ok();
6925 let total = agent_session.total_usage().await.ok();
6926 let snapshot = agent_session.snapshot().await.ok();
6927 match stats {
6928 Some(stats) => {
6929 let mut lines = vec![
6930 "## Usage".to_string(),
6931 String::new(),
6932 format!("- messages: {}", stats.message_count),
6933 format!("- total tokens: {}", stats.total_tokens),
6934 format!("- cached tokens: {}", stats.cached_tokens),
6935 format!("- uncached tokens: {}", stats.uncached_tokens),
6936 format!("- cost: ${:.4}", stats.cost_total),
6937 ];
6938 match stats.cache_hit_ratio() {
6939 Some(ratio) => {
6940 lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
6941 }
6942 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
6943 }
6944 if let Some(total) = total {
6945 lines.push(String::new());
6946 lines.push(format!(
6947 "- last cumulative input/output: {} / {}",
6948 total.input, total.output
6949 ));
6950 }
6951 if let Some(snapshot) = snapshot {
6952 lines.push(String::new());
6953 lines.push(format!("- lane: `{}`", snapshot.lane));
6954 lines.push(format!(
6955 "- leaf: `{}`",
6956 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
6957 ));
6958 if let Some(active) = &snapshot.active {
6959 lines.push(format!(
6960 "- active operation: {} (`{}`)",
6961 active.kind.as_str(),
6962 active.run_id
6963 ));
6964 }
6965 }
6966 add_note_message(&chat_container, &lines.join("\n"));
6967 }
6968 None => {
6969 add_error_message(&chat_container, "Could not read session usage stats.")
6970 }
6971 }
6972 tui.request_render(false);
6973 }
6974 Some(TuiMessage::ForkSession) => {
6975 fork_session(
6976 &harness,
6977 &cwd,
6978 &chat_container,
6979 &state,
6980 &reload_context.tool_context,
6981 )
6982 .await;
6983 tui.request_render(false);
6984 }
6985 Some(TuiMessage::ReloadExtensions) => {
6986 let reload_ctx = ctx.reload_context.clone();
6993 add_note_message(&chat_container, "Reloading extensions + resources…");
6994 tui.request_render(false);
6995 let outcome =
6996 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6997 let fresh_transformer = build_markdown_transformer(
7006 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
7007 );
7008 state.set_markdown_transformer_with_reinstall(fresh_transformer);
7009 if outcome.had_errors {
7010 add_error_message(&chat_container, &outcome.summary);
7014 } else if outcome.had_warnings {
7015 add_note_message(
7020 &chat_container,
7021 &format!(
7022 "{} (with warnings — see stderr for details).",
7023 outcome.summary
7024 ),
7025 );
7026 } else {
7027 add_note_message(&chat_container, &outcome.summary);
7028 }
7029 tui.request_render(false);
7030 }
7031 None => break,
7032 }
7033 }
7034
7035 ask_user_bridge.shutdown();
7037 *running.lock().unwrap() = false;
7038 for handle in update_check_handles {
7039 handle.abort();
7040 let _ = handle.await;
7041 }
7042 let _ = key_handle.await;
7045 tick_handle.abort();
7046 if let Some(handle) = drain_handle {
7047 handle.abort();
7048 }
7049 reload_context.mailbox.clear();
7053 reload_bridge_handle.abort();
7054 tui.stop(Default::default());
7055
7056 let session_id = harness.session().storage().metadata().id;
7060 if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7061 println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7062 }
7063 println!("\nGoodbye!");
7064 let _ = args;
7065
7066 0
7067}
7068
7069fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7074 std::thread::spawn(move || {
7075 let file = match tempfile::Builder::new()
7076 .prefix("rpi-draft-")
7077 .suffix(".md")
7078 .tempfile()
7079 {
7080 Ok(file) => file,
7081 Err(error) => {
7082 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7083 "Could not create editor file: {error}"
7084 ))));
7085 return;
7086 }
7087 };
7088 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7089 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7090 "Could not write editor file: {error}"
7091 ))));
7092 return;
7093 }
7094 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7095 .ok()
7096 .filter(|value| !value.trim().is_empty())
7097 .or_else(|| std::env::var("VISUAL").ok())
7098 .or_else(|| std::env::var("EDITOR").ok())
7099 .unwrap_or_else(|| {
7100 if cfg!(windows) {
7101 "notepad".to_string()
7102 } else {
7103 "nano".to_string()
7104 }
7105 });
7106 let status = std::process::Command::new(&editor)
7107 .arg(file.path())
7108 .status();
7109 let result = match status {
7110 Ok(status) if status.success() => std::fs::read_to_string(file.path())
7111 .map_err(|error| format!("Could not read editor file: {error}")),
7112 Ok(status) => Err(format!("External editor exited with {status}")),
7113 Err(error) => Err(format!(
7114 "Could not launch external editor `{editor}`: {error}"
7115 )),
7116 };
7117 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7118 });
7119}
7120
7121fn reconcile_streamed_assistant_completion(
7124 current_assistant: &Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
7125 last_assistant_text: &Mutex<String>,
7126 final_message: Option<&AssistantMessage>,
7127) {
7128 let final_blocks = final_message.map(assistant_blocks);
7129 let final_text = final_message.map(assistant_text);
7130 let component = current_assistant.lock().unwrap().take();
7131
7132 if let Some(component) = component {
7133 if let Some(blocks) = final_blocks.as_deref() {
7134 component.update_blocks(blocks);
7135 }
7136 component.set_streaming(false);
7137 }
7138
7139 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7140 *last_assistant_text.lock().unwrap() = text;
7141 }
7142}
7143
7144async fn run_prompt_streaming(
7154 lane: &Arc<dyn AgentLane>,
7155 prompt: &str,
7156 resume: bool,
7157 tui: &Arc<TuiAltScreen>,
7158 state: &Arc<TuiState>,
7159 streaming: bool,
7160 images: Vec<rpi_ai::types::ImageContent>,
7161) {
7162 state.set_status(RunStatus::Working);
7163 tui.request_render(false);
7164
7165 let outcome = if resume {
7166 match lane.resume_pending().await {
7169 Ok(Some(result)) => Ok(result),
7170 Ok(None) => {
7173 state.set_status(RunStatus::Idle);
7174 tui.request_render(false);
7175 return;
7176 }
7177 Err(error) => Err(error),
7178 }
7179 } else {
7180 lane.prompt_text(prompt, images).await
7181 };
7182
7183 if streaming {
7184 let final_message = match &outcome {
7189 Ok(result) => match &result.outcome {
7190 HarnessRunOutcome::Completed { final_message, .. }
7191 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7192 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7193 HarnessRunOutcome::Suspended { .. } => None,
7194 },
7195 Err(_) => None,
7196 };
7197 reconcile_streamed_assistant_completion(
7198 &state.current_assistant,
7199 &state.last_assistant_text,
7200 final_message,
7201 );
7202 }
7203
7204 state.set_status(RunStatus::Idle);
7205 refresh_pending_messages(state, lane).await;
7209
7210 if let Ok(result) = &outcome {
7215 let final_message: Option<&AssistantMessage> = match &result.outcome {
7216 HarnessRunOutcome::Completed { final_message, .. }
7217 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7218 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7219 HarnessRunOutcome::Suspended { .. } => None,
7220 };
7221 if let Some(a) = final_message {
7222 record_context_usage(&state.footer, a);
7223 }
7224 }
7225
7226 match outcome {
7227 Ok(result) => match &result.outcome {
7228 HarnessRunOutcome::Failed {
7229 error,
7230 final_message,
7231 ..
7232 } => {
7233 let already_rendered = streaming && final_message.is_some();
7241 if !already_rendered {
7242 let msg = final_message
7243 .as_ref()
7244 .and_then(|m| m.error_message.clone())
7245 .unwrap_or_else(|| format!("{error:?}"));
7246 add_error_message(&state.chat_container, &msg);
7247 }
7248 }
7249 HarnessRunOutcome::Suspended { .. } => {
7250 add_error_message(
7251 &state.chat_container,
7252 "Run suspended (deferred) — resume is not supported in v1.",
7253 );
7254 }
7255 HarnessRunOutcome::Aborted { final_message, .. } => {
7256 if !streaming {
7259 add_error_message(&state.chat_container, "Request aborted.");
7260 let _ = final_message; }
7262 }
7263 HarnessRunOutcome::Completed { final_message, .. } => {
7264 if !streaming {
7265 let text = assistant_text(final_message);
7266 if !text.is_empty() {
7267 add_assistant_message_blocking(
7268 &state.chat_container,
7269 &text,
7270 state.markdown_transformer(),
7271 );
7272 *state.last_assistant_text.lock().unwrap() = text;
7273 }
7274 }
7275 }
7276 },
7277 Err(e) => {
7278 add_error_message(&state.chat_container, &e.to_string());
7279 }
7280 }
7281
7282 tui.request_render(false);
7283}
7284
7285async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7290 state.set_status(RunStatus::Working);
7291 tui.request_render(false);
7292 match lane.compact(None).await {
7293 Ok(_) => {
7294 add_note_message(&state.chat_container, "Conversation compacted.");
7295 state.footer.set_context_tokens(None);
7299 }
7300 Err(e) => {
7301 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7302 }
7303 }
7304 state.set_status(RunStatus::Idle);
7305 tui.request_render(false);
7306}
7307
7308fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7312 let text = state.last_assistant_text.lock().unwrap().clone();
7313 if text.is_empty() {
7314 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7315 return;
7316 }
7317 if copy_to_clipboard(&text) {
7318 add_note_message(chat, "Copied last reply to the clipboard.");
7319 } else {
7320 let preview: String = text.chars().take(200).collect();
7323 add_note_message(
7324 chat,
7325 &format!(
7326 "Clipboard unavailable. Last reply: {preview}{}",
7327 if text.chars().count() > 200 {
7328 "…"
7329 } else {
7330 ""
7331 }
7332 ),
7333 );
7334 }
7335}
7336
7337fn extract_selected_text(
7340 chat_container: &Arc<Container>,
7341 start: (u16, u16),
7342 end: (u16, u16),
7343) -> Option<String> {
7344 use rpi_tui::Component;
7345
7346 let lines = chat_container.render(80); if lines.is_empty() {
7349 return None;
7350 }
7351
7352 let (start_row, end_row) = if start.1 <= end.1 {
7354 (start.1 as usize, end.1 as usize)
7355 } else {
7356 (end.1 as usize, start.1 as usize)
7357 };
7358
7359 let start_row = start_row.min(lines.len().saturating_sub(1));
7361 let end_row = end_row.min(lines.len().saturating_sub(1));
7362
7363 if start_row > end_row {
7364 return None;
7365 }
7366
7367 let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7369
7370 if selected_lines.is_empty() {
7371 return None;
7372 }
7373
7374 Some(selected_lines.join("\n"))
7375}
7376
7377#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7381fn copy_to_clipboard(text: &str) -> bool {
7382 match arboard::Clipboard::new() {
7383 Ok(mut cb) => cb.set_text(text).is_ok(),
7384 Err(_) => false,
7385 }
7386}
7387
7388#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7389fn copy_to_clipboard(_text: &str) -> bool {
7390 false
7391}
7392
7393#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7397fn read_clipboard_text() -> Option<String> {
7398 let mut clipboard = arboard::Clipboard::new().ok()?;
7399 clipboard.get_text().ok()
7400}
7401
7402#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7403fn read_clipboard_text() -> Option<String> {
7404 None
7405}
7406
7407#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7411fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7412 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
7413 let image = match clipboard.get_image() {
7414 Ok(image) => image,
7415 Err(_) => return Ok(None),
7416 };
7417 let width =
7418 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
7419 let height =
7420 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
7421 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
7422 return Err("clipboard image dimensions are outside the supported range".into());
7423 }
7424 let mut bytes = Vec::new();
7425 {
7426 let mut encoder = png::Encoder::new(&mut bytes, width, height);
7427 encoder.set_color(png::ColorType::Rgba);
7428 encoder.set_depth(png::BitDepth::Eight);
7429 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
7430 writer
7431 .write_image_data(&image.bytes)
7432 .map_err(|e| e.to_string())?;
7433 }
7434 Ok(Some(rpi_ai::types::ImageContent {
7435 kind: rpi_ai::types::ImageContentType,
7436 data: base64::engine::general_purpose::STANDARD.encode(bytes),
7437 mime_type: "image/png".into(),
7438 }))
7439}
7440
7441fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
7442 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
7443 let mut options = ImageOptions::default();
7444 options.width = Some(48);
7445 options.alt_text = Some("Attached image".into());
7446 chat.add_child(Arc::new(Image::from_data(bytes, options)));
7447 chat.add_child(Arc::new(Spacer::new(1)));
7448 }
7449}
7450
7451#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7452fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7453 Ok(None)
7454}
7455
7456fn add_assistant_message_blocking(
7463 container: &Arc<Container>,
7464 text: &str,
7465 transformer: Option<MarkdownTransformer>,
7466) {
7467 if text.is_empty() {
7468 return;
7469 }
7470 let msg = Arc::new(AssistantMessageComponent::new(
7471 AssistantMessageOptions::default(),
7472 ));
7473 if let Some(t) = &transformer {
7474 msg.set_markdown_transformer(Some(t.clone()));
7475 }
7476 msg.update_text(text);
7477 container.add_child(msg);
7478 container.add_child(Arc::new(Spacer::new(1)));
7479}
7480
7481async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
7492 if let Ok(queued) = lane.queued_messages().await {
7493 state.set_pending_queue(queued);
7494 }
7495}
7496
7497fn transcript_has_live_panel(
7503 bash_components_present: bool,
7504 tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
7505) -> bool {
7506 bash_components_present
7507 || tool_components
7508 .values()
7509 .any(|component| component.status() == ToolStatus::Running)
7510}
7511
7512fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
7515 if current.trim().is_empty() {
7516 queued_text.to_string()
7517 } else {
7518 format!("{queued_text}\n\n{current}")
7519 }
7520}
7521
7522fn set_editor_text_caret_at_end(editor: &Arc<Editor>, text: &str) {
7527 editor.set_text(text);
7528 let last_row = text.split('\n').count().saturating_sub(1);
7529 let last_len = text.split('\n').next_back().map(str::len).unwrap_or(0);
7530 editor.set_cursor(last_row, last_len);
7531}
7532
7533async fn restore_queued_messages_to_editor(
7543 lane: Arc<dyn AgentLane>,
7544 editor: Arc<Editor>,
7545 state: Arc<TuiState>,
7546 tui: Arc<TuiAltScreen>,
7547) -> usize {
7548 match lane.clear_queue().await {
7549 Ok(queued) => {
7550 let all: Vec<String> = queued
7551 .steering
7552 .iter()
7553 .chain(queued.follow_up.iter())
7554 .cloned()
7555 .collect();
7556 if !all.is_empty() {
7557 let queued_text = all.join("\n\n");
7558 let current = editor.get_expanded_text();
7563 let combined = merge_queued_into_draft(&queued_text, ¤t);
7564 set_editor_text_caret_at_end(&editor, &combined);
7565 }
7566 refresh_pending_messages(&state, &lane).await;
7568 tui.request_render(false);
7569 all.len()
7570 }
7571 Err(error) => {
7572 add_error_message(
7573 &state.chat_container,
7574 &format!("Could not restore queued messages: {error}"),
7575 );
7576 tui.request_render(false);
7577 0
7578 }
7579 }
7580}
7581
7582async fn handle_dequeue(
7587 lane: Arc<dyn AgentLane>,
7588 editor: Arc<Editor>,
7589 state: Arc<TuiState>,
7590 tui: Arc<TuiAltScreen>,
7591) {
7592 let restored =
7593 restore_queued_messages_to_editor(lane, editor, state.clone(), tui.clone()).await;
7594 if restored == 0 {
7595 add_note_message(&state.chat_container, "No queued messages to restore");
7596 } else {
7597 let plural = if restored > 1 { "s" } else { "" };
7598 add_note_message(
7599 &state.chat_container,
7600 &format!("Restored {restored} queued message{plural} to editor"),
7601 );
7602 }
7603 tui.request_render(false);
7604}
7605
7606fn abort_run_restoring_queue(
7613 lane: Arc<dyn AgentLane>,
7614 editor: Arc<Editor>,
7615 state: Arc<TuiState>,
7616 tui: Arc<TuiAltScreen>,
7617) {
7618 tokio::spawn(async move {
7619 let _ = restore_queued_messages_to_editor(lane.clone(), editor, state, tui.clone()).await;
7620 let _ = lane.abort().await;
7621 tui.request_render(false);
7622 });
7623}
7624
7625async fn drain_agent_events(
7626 mut rx: broadcast::Receiver<AgentEvent>,
7627 tui: Arc<TuiAltScreen>,
7628 state: Arc<TuiState>,
7629 chat: Arc<Container>,
7630 lane: Arc<dyn AgentLane>,
7631) {
7632 loop {
7633 match rx.recv().await {
7634 Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
7635 Err(broadcast::error::RecvError::Lagged(_)) => {
7636 continue;
7639 }
7640 Err(broadcast::error::RecvError::Closed) => break,
7641 }
7642 }
7643}
7644
7645async fn handle_agent_event(
7648 event: AgentEvent,
7649 tui: &Arc<TuiAltScreen>,
7650 state: &Arc<TuiState>,
7651 chat: &Arc<Container>,
7652 lane: &Arc<dyn AgentLane>,
7653) {
7654 match event {
7655 AgentEvent::AgentStart => {
7656 state.set_status(RunStatus::Working);
7657 tui.request_render(false);
7658 }
7659
7660 AgentEvent::AgentEnd { .. } => {
7661 state.transcript_view().finalize_assistant();
7663 state.set_status(RunStatus::Idle);
7664 let pending: Vec<AgentMessage> =
7668 std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
7669 for message in pending {
7670 if let Err(error) = lane.append_message(message).await {
7671 add_error_message(chat, &format!("Could not record bash result: {error}"));
7672 }
7673 }
7674 refresh_pending_messages(state, lane).await;
7677 tui.request_render(false);
7678 }
7679
7680 AgentEvent::RetryScheduled {
7681 attempt,
7682 max_retries,
7683 delay_ms,
7684 ..
7685 } => {
7686 state.show_retry(attempt, max_retries, delay_ms);
7687 tui.request_render(false);
7688 }
7689
7690 AgentEvent::TurnStart => {
7691 state.transcript_view().finalize_assistant();
7694 }
7695
7696 AgentEvent::TurnEnd {
7697 message,
7698 tool_results,
7699 } => {
7700 let view = state.transcript_view();
7702 if let AgentMessage::Assistant(a) = &message {
7703 view.finalize_assistant_blocks(&assistant_blocks(a));
7704 } else {
7705 view.finalize_assistant();
7706 }
7707 let _ = tool_results;
7711 tui.request_render(false);
7712 }
7713
7714 AgentEvent::MessageStart { message } => match message {
7715 AgentMessage::Assistant(a) => {
7716 state.transcript_view().apply(
7720 &UiEvent::AssistantStart {
7721 blocks: assistant_blocks(&a),
7722 },
7723 tui.width(),
7724 );
7725 tui.request_render(false);
7726 }
7727 AgentMessage::Custom(custom) => {
7728 let payload = serde_json::json!({
7729 "customType": custom.role,
7730 "content": custom.content,
7731 "details": custom.data,
7732 "expanded": false,
7733 "outputPad": 1,
7734 });
7735 if let Some(component) = extension_message_component(
7736 &state.extension_session,
7737 &custom.role,
7738 &payload,
7739 state.markdown_transformer(),
7740 ) {
7741 chat.add_child(component);
7742 chat.add_child(Arc::new(Spacer::new(1)));
7743 tui.request_render(false);
7744 } else {
7745 add_note_message(chat, &custom_message_fallback(&custom));
7746 tui.request_render(false);
7747 }
7748 }
7749 AgentMessage::User(user) => {
7759 state.transcript_view().add_user(&user_message_text(&user));
7760 refresh_pending_messages(state, lane).await;
7762 tui.request_render(false);
7763 }
7764 _ => {}
7767 },
7768
7769 AgentEvent::MessageUpdate {
7770 message,
7771 assistant_message_event,
7772 } => {
7773 let _ = assistant_message_event; let a: &rpi_ai::types::AssistantMessage = &message;
7775 let text = assistant_text(a);
7776 state.transcript_view().apply(
7780 &UiEvent::AssistantUpdate {
7781 blocks: assistant_blocks(a),
7782 tool_calls: assistant_tool_calls(a),
7783 },
7784 tui.width(),
7785 );
7786 state.sync_working_loader_with_bash();
7788 *state.last_assistant_text.lock().unwrap() = text;
7789 tui.request_render(false);
7790 }
7791
7792 AgentEvent::MessageEnd { message } => {
7793 if let AgentMessage::Assistant(a) = &message {
7794 let text = assistant_text(a);
7795 state
7796 .transcript_view()
7797 .finalize_assistant_blocks(&assistant_blocks(a));
7798 if !text.is_empty() {
7800 *state.last_assistant_text.lock().unwrap() = text;
7801 }
7802 let usage = &a.usage;
7808 state.footer.add_usage(
7810 usage.input,
7811 usage.output,
7812 usage.cache_read,
7813 usage.cache_write,
7814 usage.cost.total,
7815 );
7816 let denom = usage.input + usage.cache_read + usage.cache_write;
7817 if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
7818 state
7819 .footer
7820 .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
7821 }
7822 record_context_usage(&state.footer, a);
7826 let miss = state
7827 .cache_tracker
7828 .lock()
7829 .unwrap()
7830 .observe(a, &rpi_harness::cache_stats::NoPrices);
7831 let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
7835 if let Some(miss) = miss.filter(|_| cache_miss_notices) {
7836 let cost = if miss.missed_cost > 0.0 {
7837 format!(" (~${:.4})", miss.missed_cost)
7838 } else {
7839 String::new()
7840 };
7841 add_note_message(
7842 &state.chat_container,
7843 &format!(
7844 "Cache miss: {} tokens re-billed{}",
7845 format_tokens(miss.missed_tokens),
7846 cost
7847 ),
7848 );
7849 }
7850 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
7851 add_note_message(chat, &text);
7852 }
7853 if let Some(error) = assistant_error_text(a) {
7858 add_error_message(chat, &error);
7859 }
7860 }
7861 tui.request_render(false);
7862 }
7863
7864 AgentEvent::ToolExecutionStart {
7865 tool_call_id,
7866 tool_name,
7867 args,
7868 } => {
7869 state.transcript_view().apply(
7870 &UiEvent::ToolStart {
7871 id: tool_call_id,
7872 name: tool_name,
7873 args,
7874 },
7875 tui.width(),
7876 );
7877 state.sync_working_loader_with_bash();
7878 tui.request_render(false);
7879 }
7880
7881 AgentEvent::ToolExecutionUpdate {
7882 tool_call_id,
7883 tool_name,
7884 args,
7885 partial_result,
7886 } => {
7887 state.transcript_view().apply(
7888 &UiEvent::ToolUpdate {
7889 id: tool_call_id,
7890 name: tool_name,
7891 args,
7892 result: crate::remote::protocol::tool_result_json(&partial_result),
7893 },
7894 tui.width(),
7895 );
7896 state.sync_working_loader_with_bash();
7897 tui.request_render(false);
7898 }
7899
7900 AgentEvent::ToolExecutionEnd {
7901 tool_call_id,
7902 tool_name,
7903 result,
7904 is_error,
7905 } => {
7906 state.transcript_view().apply(
7907 &UiEvent::ToolEnd {
7908 id: tool_call_id,
7909 name: tool_name,
7910 result: crate::remote::protocol::tool_result_json(&result),
7911 is_error,
7912 },
7913 tui.width(),
7914 );
7915 state.sync_working_loader_with_bash();
7916 tui.render_now(false);
7920 }
7921 }
7922}
7923
7924fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
7932 if message.stop_reason == rpi_ai::StopReason::Aborted
7933 || message.stop_reason == rpi_ai::StopReason::Error
7934 {
7935 return;
7936 }
7937 let tokens = message.usage.context_tokens();
7938 if tokens > 0 {
7939 footer.set_context_tokens(Some(tokens));
7940 }
7941}
7942
7943fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7947 let fallback = match message.stop_reason {
7948 rpi_ai::StopReason::Error => "Provider request failed.",
7949 rpi_ai::StopReason::Aborted => "Request aborted.",
7950 _ => return None,
7951 };
7952 Some(
7953 message
7954 .error_message
7955 .as_deref()
7956 .filter(|text| !text.trim().is_empty())
7957 .unwrap_or(fallback)
7958 .to_string(),
7959 )
7960}
7961
7962fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
7971 details
7972 .and_then(|details| details.get("markdown"))
7973 .and_then(|value| value.as_bool())
7974 .unwrap_or(false)
7975}
7976
7977fn open_selector(
7985 state: &Arc<TuiState>,
7986 editor_container: &Arc<Container>,
7987 editor: &Arc<Editor>,
7988 tui: &Arc<TuiAltScreen>,
7989 list: Arc<SelectList>,
7990 kind: SelectorKind,
7991) {
7992 open_selector_with_view(
7993 state,
7994 editor_container,
7995 editor,
7996 tui,
7997 SelectorView::List(list.clone()),
7998 list,
7999 kind,
8000 );
8001}
8002
8003fn open_searchable_selector(
8008 state: &Arc<TuiState>,
8009 editor_container: &Arc<Container>,
8010 editor: &Arc<Editor>,
8011 tui: &Arc<TuiAltScreen>,
8012 title: Option<&str>,
8013 description: Option<&str>,
8014 list: Arc<SelectList>,
8015 kind: SelectorKind,
8016) -> Arc<SearchableSelectList> {
8017 let searchable = Arc::new(SearchableSelectList::new(title, description, list));
8018 open_selector_with_view(
8019 state,
8020 editor_container,
8021 editor,
8022 tui,
8023 SelectorView::Searchable(searchable.clone()),
8024 searchable.clone(),
8025 kind,
8026 );
8027 searchable.set_focused(true);
8031 searchable
8032}
8033
8034fn open_selector_with_view<V: Component + 'static>(
8042 state: &Arc<TuiState>,
8043 editor_container: &Arc<Container>,
8044 editor: &Arc<Editor>,
8045 tui: &Arc<TuiAltScreen>,
8046 view_router: SelectorView,
8047 view: Arc<V>,
8048 kind: SelectorKind,
8049) {
8050 dispatch_ui_prompt_event(state);
8052
8053 editor.set_focused(false);
8055 state.autocomplete_container.clear();
8058 editor_container.clear();
8060 editor_container.add_child(view.clone());
8061 *state.active_selector.lock().unwrap() = Some((view_router, kind));
8062 let focused: Arc<dyn Component> = view;
8063 tui.set_focus(Some(focused));
8064 tui.request_render(false);
8065}
8066
8067fn close_selector(
8070 state: &Arc<TuiState>,
8071 editor_container: &Arc<Container>,
8072 editor: &Arc<Editor>,
8073 tui: &Arc<TuiAltScreen>,
8074) {
8075 dispatch_ui_prompt_event_end(state);
8077
8078 editor_container.clear();
8079 editor_container.add_child(editor.clone());
8080 state.autocomplete_container.clear();
8081 editor.set_focused(true);
8082 *state.active_selector.lock().unwrap() = None;
8083 *state.active_extension_cancel.lock().unwrap() = None;
8084 tui.set_focus(Some(editor.clone()));
8085 tui.request_render(false);
8086}
8087
8088fn open_model_selector(
8094 state: &Arc<TuiState>,
8095 editor_container: &Arc<Container>,
8096 editor: &Arc<Editor>,
8097 tui: &Arc<TuiAltScreen>,
8098 catalog: &[rpi_ai::Model],
8099 lane: &Arc<dyn AgentLane>,
8100 lane_model_id: &str,
8101 chat: &Arc<Container>,
8102 initial_search: Option<&str>,
8103) -> Option<Arc<SearchableSelectList>> {
8104 let items = model_selector_items(catalog, lane_model_id);
8105 if items.is_empty() {
8106 add_note_message(
8107 chat,
8108 "No models in the catalog. Use --model at startup to select one.",
8109 );
8110 tui.request_render(false);
8111 return None;
8112 }
8113 let list = Arc::new(SelectList::new(items, 10));
8114
8115 let catalog_arc = catalog.to_vec();
8119 let state_sel = state.clone();
8120 let ec_sel = editor_container.clone();
8121 let editor_sel = editor.clone();
8122 let tui_sel = tui.clone();
8123 let chat_sel = chat.clone();
8124 let lane_sel = lane.clone();
8125 list.on_select(Arc::new(move |item| {
8126 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
8127 add_note_message(
8128 &chat_sel,
8129 &format!("Model {} not found in catalog.", item.label),
8130 );
8131 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8132 return;
8133 };
8134 state_sel.set_current_model(&model);
8135 let lane = lane_sel.clone();
8136 tokio::spawn(async move {
8137 let _ = lane.set_model(model).await;
8138 });
8139 add_note_message(
8140 &chat_sel,
8141 &format!(
8142 "Model set to {} — applies to the next message.",
8143 short_model_name(&item.value)
8144 ),
8145 );
8146 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8147 }));
8148 let state_cancel = state.clone();
8149 let ec_cancel = editor_container.clone();
8150 let editor_cancel = editor.clone();
8151 let tui_cancel = tui.clone();
8152 list.on_cancel(Arc::new(move || {
8153 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8154 }));
8155
8156 let searchable = open_searchable_selector(
8157 state,
8158 editor_container,
8159 editor,
8160 tui,
8161 Some("Select model"),
8162 Some("Type to filter by name, provider, or id"),
8163 list,
8164 SelectorKind::Model,
8165 );
8166 if let Some(term) = initial_search {
8167 searchable.set_search_text(term);
8168 }
8169 Some(searchable)
8170}
8171
8172fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8177 if catalog.is_empty() {
8178 return None;
8179 }
8180 let idx = catalog
8181 .iter()
8182 .position(|m| m.id.eq_ignore_ascii_case(current_id));
8183 match idx {
8184 Some(i) => {
8185 let next = (i + 1) % catalog.len();
8186 Some(catalog[next].clone())
8187 }
8188 None => Some(catalog[0].clone()),
8189 }
8190}
8191
8192fn cycle_prev_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8195 if catalog.is_empty() {
8196 return None;
8197 }
8198 let idx = catalog
8199 .iter()
8200 .position(|m| m.id.eq_ignore_ascii_case(current_id));
8201 match idx {
8202 Some(i) => {
8203 let prev = (i + catalog.len() - 1) % catalog.len();
8204 Some(catalog[prev].clone())
8205 }
8206 None => Some(catalog[0].clone()),
8207 }
8208}
8209
8210fn open_session_selector(
8216 state: &Arc<TuiState>,
8217 editor_container: &Arc<Container>,
8218 editor: &Arc<Editor>,
8219 tui: &Arc<TuiAltScreen>,
8220 cwd: &std::path::Path,
8221 tx: &mpsc::UnboundedSender<TuiMessage>,
8222) {
8223 let dir = crate::session::default_session_dir(cwd);
8224 let mut items: Vec<SelectItem> = Vec::new();
8225 for path in crate::session::list_session_files_sync(&dir) {
8226 let summary = crate::session::summarize_session_file(&path);
8227 if summary.empty {
8228 continue;
8229 }
8230 let stem = path
8231 .file_stem()
8232 .and_then(|s| s.to_str())
8233 .unwrap_or("(unnamed)")
8234 .to_string();
8235 let label = crate::session::session_display_label(&summary);
8236 let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
8239 let short_id = crate::session::short_session_id(id_tail);
8240 let description = format!(
8241 "{} · {}",
8242 crate::session::format_session_bytes(summary.bytes),
8243 short_id,
8244 );
8245 items.push(
8246 SelectItem::new(&stem, &label)
8247 .with_description(&description)
8248 .with_search_text(&format!("{label} {short_id} {stem}")),
8249 );
8250 }
8251 if items.is_empty() {
8252 add_note_message(
8253 &state.chat_container,
8254 "No saved sessions found. Sessions are created automatically in interactive mode.",
8255 );
8256 tui.request_render(false);
8257 return;
8258 }
8259 let mut list = SelectList::new(items, 10);
8260 list.set_layout(rpi_tui::SelectListLayoutOptions {
8261 min_primary_column_width: Some(56),
8262 max_primary_column_width: Some(72),
8263 truncate_primary: None,
8264 });
8265 let list = Arc::new(list);
8266
8267 let state_sel = state.clone();
8268 let ec_sel = editor_container.clone();
8269 let editor_sel = editor.clone();
8270 let tui_sel = tui.clone();
8271 let tx_sel = tx.clone();
8272 list.on_select(Arc::new(move |item| {
8273 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8277 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
8278 }));
8279 let state_cancel = state.clone();
8280 let ec_cancel = editor_container.clone();
8281 let editor_cancel = editor.clone();
8282 let tui_cancel = tui.clone();
8283 list.on_cancel(Arc::new(move || {
8284 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8285 }));
8286
8287 open_searchable_selector(
8288 state,
8289 editor_container,
8290 editor,
8291 tui,
8292 Some("Resume session"),
8293 Some("Type to filter sessions"),
8294 list,
8295 SelectorKind::Session,
8296 );
8297}
8298
8299fn custom_entry_display_text(
8300 custom_type: &str,
8301 data: Option<&serde_json::Value>,
8302) -> Option<String> {
8303 let data = data?;
8304 let text = data
8305 .get("summary")
8306 .or_else(|| data.get("text"))
8307 .or_else(|| data.get("output"))
8308 .and_then(|value| value.as_str())
8309 .filter(|value| !value.trim().is_empty())?;
8310 let label = match custom_type {
8311 "compactionSummary" => "Compaction summary",
8312 "branchSummary" => "Branch summary",
8313 "bashExecution" => "Command output",
8314 other => other,
8315 };
8316 Some(format!("{label}: {text}"))
8317}
8318
8319async fn open_tree_selector(
8323 harness: &AgentHarness,
8324 state: &Arc<TuiState>,
8325 editor_container: &Arc<Container>,
8326 editor: &Arc<Editor>,
8327 tui: &Arc<TuiAltScreen>,
8328 chat: &Arc<Container>,
8329 tx: &mpsc::UnboundedSender<TuiMessage>,
8330) {
8331 let entries = match harness
8332 .session()
8333 .view("main")
8334 .find_entries(&EntryQuery {
8335 order: Some(EntryOrder::OldestFirst),
8336 ..Default::default()
8337 })
8338 .await
8339 {
8340 Ok(entries) => entries,
8341 Err(error) => {
8342 add_error_message(chat, &format!("Could not read session tree: {error}"));
8343 tui.request_render(false);
8344 return;
8345 }
8346 };
8347 let current = harness.session().get_leaf_id().await.ok().flatten();
8348 let items: Vec<SelectItem> = entries
8349 .iter()
8350 .map(|entry| {
8351 let label = if current.as_deref() == Some(entry.id()) {
8352 format!("{} #{} (current)", entry.entry_type(), entry.seq())
8353 } else {
8354 format!("{} #{}", entry.entry_type(), entry.seq())
8355 };
8356 let short_id = &entry.id()[..entry.id().len().min(12)];
8357 let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
8360 SelectItem::new(entry.id(), &label)
8361 .with_description(short_id)
8362 .with_search_text(&search_text)
8363 })
8364 .collect();
8365 if items.is_empty() {
8366 add_note_message(chat, "The current session has no entries to navigate.");
8367 tui.request_render(false);
8368 return;
8369 }
8370 let list = Arc::new(SelectList::new(items, 12));
8371 let state_sel = state.clone();
8372 let ec_sel = editor_container.clone();
8373 let editor_sel = editor.clone();
8374 let tui_sel = tui.clone();
8375 let tx_sel = tx.clone();
8376 list.on_select(Arc::new(move |item| {
8377 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
8378 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8379 }));
8380 let state_cancel = state.clone();
8381 let ec_cancel = editor_container.clone();
8382 let editor_cancel = editor.clone();
8383 let tui_cancel = tui.clone();
8384 list.on_cancel(Arc::new(move || {
8385 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8386 }));
8387 open_searchable_selector(
8388 state,
8389 editor_container,
8390 editor,
8391 tui,
8392 Some("Session tree"),
8393 Some("Type to filter by entry type, sequence, or id"),
8394 list,
8395 SelectorKind::Tree,
8396 );
8397}
8398
8399fn open_theme_selector(
8402 state: &Arc<TuiState>,
8403 editor_container: &Arc<Container>,
8404 editor: &Arc<Editor>,
8405 tui: &Arc<TuiAltScreen>,
8406) {
8407 let items = vec![
8408 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
8409 SelectItem::new("light", "Light").with_description("Light background"),
8410 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
8411 ];
8412 let list = Arc::new(SelectList::new(items, 10));
8413
8414 let state_sel = state.clone();
8415 let ec_sel = editor_container.clone();
8416 let editor_sel = editor.clone();
8417 let tui_sel = tui.clone();
8418 let chat_sel = state.chat_container.clone();
8419 list.on_select(Arc::new(move |item| {
8420 let preset = match item.value.as_str() {
8421 "light" => Some(ThemePreset::Light),
8422 "monochrome" => Some(ThemePreset::Monochrome),
8423 "dark" => Some(ThemePreset::Dark),
8424 name => {
8425 add_error_message(&chat_sel, &format!("Unknown theme `{name}`."));
8426 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8427 tui_sel.render_now(true);
8428 return;
8429 }
8430 };
8431 let Some(preset) = preset else { return };
8432 apply_theme_preset(preset);
8433 state_sel.theme_manager.apply_preset(preset);
8434 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8437 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8438 tui_sel.render_now(true);
8439 }));
8440 let state_cancel = state.clone();
8441 let ec_cancel = editor_container.clone();
8442 let editor_cancel = editor.clone();
8443 let tui_cancel = tui.clone();
8444 list.on_cancel(Arc::new(move || {
8445 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8446 }));
8447
8448 open_selector(
8449 state,
8450 editor_container,
8451 editor,
8452 tui,
8453 list,
8454 SelectorKind::Theme,
8455 );
8456}
8457
8458fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8465 use rpi_ai::types::ThinkingLevel::*;
8466 match level {
8467 Off => "Off — No reasoning",
8468 Minimal => "Minimal — Brief reasoning (~1k tokens)",
8469 Low => "Low — Light reasoning (~1k tokens)",
8470 Medium => "Medium — Moderate reasoning (~80% of max)",
8471 High => "High — Extensive reasoning (~95% of max)",
8472 Xhigh => "Xhigh — Near-maximal reasoning",
8473 Max => "Max — Maximum reasoning",
8474 }
8475}
8476
8477fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8480 use rpi_ai::types::ThinkingLevel::*;
8481 match level {
8482 Off => "off",
8483 Minimal => "minimal",
8484 Low => "low",
8485 Medium => "medium",
8486 High => "high",
8487 Xhigh => "xhigh",
8488 Max => "max",
8489 }
8490}
8491
8492fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
8495 use rpi_ai::types::ThinkingLevel::*;
8496 match name.to_ascii_lowercase().as_str() {
8497 "off" => Some(Off),
8498 "minimal" => Some(Minimal),
8499 "low" => Some(Low),
8500 "medium" => Some(Medium),
8501 "high" => Some(High),
8502 "xhigh" => Some(Xhigh),
8503 "max" => Some(Max),
8504 _ => None,
8505 }
8506}
8507
8508fn open_thinking_selector(
8518 state: &Arc<TuiState>,
8519 editor_container: &Arc<Container>,
8520 editor: &Arc<Editor>,
8521 tui: &Arc<TuiAltScreen>,
8522 lane: &Arc<dyn AgentLane>,
8523 catalog: &[rpi_ai::Model],
8524 lane_model_id: &str,
8525 chat: &Arc<Container>,
8526) {
8527 let model = catalog
8530 .iter()
8531 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8532 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8533 .map(|m| m.supported_thinking_levels())
8534 .unwrap_or_else(|| {
8535 use rpi_ai::types::ThinkingLevel::*;
8536 vec![Off, Minimal, Low, Medium, High]
8537 });
8538 let mut items: Vec<SelectItem> = Vec::new();
8539 for lvl in &levels {
8540 let name = thinking_level_name(*lvl);
8541 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8542 }
8543 if items.is_empty() {
8544 add_note_message(chat, "This model has no supported thinking levels.");
8545 tui.request_render(false);
8546 return;
8547 }
8548 let list = Arc::new(SelectList::new(items, 10));
8549
8550 let state_sel = state.clone();
8551 let ec_sel = editor_container.clone();
8552 let editor_sel = editor.clone();
8553 let tui_sel = tui.clone();
8554 let chat_sel = chat.clone();
8555 let lane_sel = lane.clone();
8556 list.on_select(Arc::new(move |item| {
8557 let Some(level) = thinking_level_from_name(&item.value) else {
8558 add_note_message(
8559 &chat_sel,
8560 &format!("Unknown thinking level: {}.", item.label),
8561 );
8562 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8563 return;
8564 };
8565 let lane = lane_sel.clone();
8566 let footer_sel = state_sel.footer.clone();
8567 tokio::spawn(async move {
8568 let _ = lane.set_thinking_level(level).await;
8569 });
8570 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8574 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8575 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8576 }));
8577 let state_cancel = state.clone();
8578 let ec_cancel = editor_container.clone();
8579 let editor_cancel = editor.clone();
8580 let tui_cancel = tui.clone();
8581 list.on_cancel(Arc::new(move || {
8582 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8583 }));
8584
8585 open_selector(
8586 state,
8587 editor_container,
8588 editor,
8589 tui,
8590 list,
8591 SelectorKind::Thinking,
8592 );
8593}
8594
8595fn open_tools_selector(
8601 state: &Arc<TuiState>,
8602 editor_container: &Arc<Container>,
8603 editor: &Arc<Editor>,
8604 tui: &Arc<TuiAltScreen>,
8605 lane: &Arc<dyn AgentLane>,
8606 chat: &Arc<Container>,
8607) {
8608 let mut active = match tokio::runtime::Handle::try_current() {
8613 Ok(h) => h
8614 .block_on(async { lane.get_active_tools().await })
8615 .unwrap_or_default(),
8616 Err(_) => Vec::new(),
8617 };
8618 if active.is_empty() {
8623 active = crate::session::BUILTIN_TOOL_NAMES
8624 .iter()
8625 .map(|name| (*name).to_string())
8626 .collect();
8627 }
8628 let mut items: Vec<SelectItem> = Vec::new();
8629 for name in crate::session::BUILTIN_TOOL_NAMES {
8630 let on = active.iter().any(|a| a == name);
8631 let label = if on {
8632 format!("{name} (on)")
8633 } else {
8634 (*name).to_string()
8635 };
8636 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8637 }
8638 let list = Arc::new(SelectList::new(items, 10));
8639
8640 let active_captured = active.clone();
8642 let state_sel = state.clone();
8643 let ec_sel = editor_container.clone();
8644 let editor_sel = editor.clone();
8645 let tui_sel = tui.clone();
8646 let chat_sel = chat.clone();
8647 let lane_sel = lane.clone();
8648 list.on_select(Arc::new(move |item| {
8649 let mut next = active_captured.clone();
8650 if let Some(pos) = next.iter().position(|a| a == &item.value) {
8651 next.remove(pos);
8652 } else {
8653 next.push(item.value.clone());
8654 }
8655 let on = next.iter().any(|a| a == &item.value);
8656 let lane = lane_sel.clone();
8657 let next_clone = next.clone();
8658 tokio::spawn(async move {
8659 let _ = lane.set_active_tools(next_clone).await;
8660 });
8661 let list_str = if next.is_empty() {
8662 "(none)".to_string()
8663 } else {
8664 next.join(", ")
8665 };
8666 add_note_message(
8667 &chat_sel,
8668 &format!(
8669 "{} {} — active tools: {}",
8670 item.value,
8671 if on { "enabled" } else { "disabled" },
8672 list_str
8673 ),
8674 );
8675 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8676 }));
8677 let state_cancel = state.clone();
8678 let ec_cancel = editor_container.clone();
8679 let editor_cancel = editor.clone();
8680 let tui_cancel = tui.clone();
8681 list.on_cancel(Arc::new(move || {
8682 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8683 }));
8684
8685 open_selector(
8686 state,
8687 editor_container,
8688 editor,
8689 tui,
8690 list,
8691 SelectorKind::Tools,
8692 );
8693}
8694
8695fn open_images_selector(
8699 state: &Arc<TuiState>,
8700 editor_container: &Arc<Container>,
8701 editor: &Arc<Editor>,
8702 tui: &Arc<TuiAltScreen>,
8703 chat: &Arc<Container>,
8704) {
8705 let current = *state.show_images.lock().unwrap();
8706 let items = vec![
8707 SelectItem::new("yes", "Yes").with_description(if current {
8708 "Inline images (current)"
8709 } else {
8710 "Inline images"
8711 }),
8712 SelectItem::new("no", "No").with_description(if current {
8713 "Placeholder only"
8714 } else {
8715 "Placeholder only (current)"
8716 }),
8717 ];
8718 let list = Arc::new(SelectList::new(items, 5));
8719
8720 let state_sel = state.clone();
8721 let ec_sel = editor_container.clone();
8722 let editor_sel = editor.clone();
8723 let tui_sel = tui.clone();
8724 let chat_sel = chat.clone();
8725 list.on_select(Arc::new(move |item| {
8726 let on = item.value == "yes";
8727 *state_sel.show_images.lock().unwrap() = on;
8728 add_note_message(
8729 &chat_sel,
8730 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8731 );
8732 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8733 }));
8734 let state_cancel = state.clone();
8735 let ec_cancel = editor_container.clone();
8736 let editor_cancel = editor.clone();
8737 let tui_cancel = tui.clone();
8738 list.on_cancel(Arc::new(move || {
8739 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8740 }));
8741
8742 open_selector(
8743 state,
8744 editor_container,
8745 editor,
8746 tui,
8747 list,
8748 SelectorKind::Images,
8749 );
8750}
8751
8752fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8760 let text = editor.get_text();
8761 let cursor = editor_cursor_offset(editor, &text);
8762 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8763 render_autocomplete(state, suggestions);
8764}
8765
8766fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8769 let (row, col) = editor.cursor_position();
8770 let mut offset = 0;
8771 for (index, line) in text.split('\n').enumerate() {
8772 if index == row {
8773 return (offset + col.min(line.len())).min(text.len());
8774 }
8775 offset = offset.saturating_add(line.len() + 1);
8776 }
8777 text.len()
8778}
8779
8780fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8783 let offset = offset.min(text.len());
8784 let mut consumed = 0;
8785 for (row, line) in text.split('\n').enumerate() {
8786 let end = consumed + line.len();
8787 if offset <= end {
8788 editor.set_cursor(row, offset - consumed);
8789 return;
8790 }
8791 consumed = end + 1;
8792 }
8793 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8794 editor.set_cursor(
8795 last_row,
8796 text.rsplit('\n').next().map(str::len).unwrap_or(0),
8797 );
8798}
8799
8800fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8802 state.autocomplete_container.clear();
8803 let Some(sugg) = suggestions else {
8804 return;
8805 };
8806 if sugg.items.is_empty() {
8807 return;
8808 }
8809 let accent = state.theme_manager.get().colors.accent;
8812 let muted = state.theme_manager.get().colors.muted;
8813 for (i, item) in sugg
8814 .items
8815 .iter()
8816 .take(state.autocomplete_max_visible)
8817 .enumerate()
8818 {
8819 let prefix = if i == 0 { "→ " } else { " " };
8820 let label = item.display_text();
8821 let line = if i == 0 {
8822 format!(
8823 "{prefix}{} {}",
8824 accent.fg(label),
8825 muted.fg(item.description.as_deref().unwrap_or(""))
8826 )
8827 } else {
8828 format!(
8829 "{prefix}{} {}",
8830 muted.fg(label),
8831 muted.fg(item.description.as_deref().unwrap_or(""))
8832 )
8833 };
8834 state
8835 .autocomplete_container
8836 .add_child(Arc::new(Text::new(line, 1, 0)));
8837 }
8838}
8839
8840fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8844 let text = editor.get_text();
8845 let cursor = editor_cursor_offset(editor, &text);
8846 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8847 return false;
8848 };
8849 let Some(top) = sugg.items.first() else {
8850 return false;
8851 };
8852 let start = sugg.start.min(text.len());
8856 let end = sugg.end.min(text.len());
8857 let mut replaced = String::with_capacity(text.len() + top.text.len());
8858 replaced.push_str(&text[..start]);
8859 replaced.push_str(&top.text);
8860 replaced.push_str(&text[end..]);
8863 if top.insert_space && !replaced.ends_with('/') {
8864 replaced.push(' ');
8865 }
8866 let new_cursor = replaced.len().min(
8869 start
8870 + top.text.len()
8871 + if top.insert_space && !top.text.ends_with('/') {
8872 1
8873 } else {
8874 0
8875 },
8876 );
8877 editor.set_text(&replaced);
8878 set_editor_cursor_offset(editor, &replaced, new_cursor);
8879 state.autocomplete_container.clear();
8880 true
8881}
8882
8883fn add_welcome_message(container: &Arc<Container>) {
8889 add_welcome_message_with_capabilities(container, &[], &[]);
8890}
8891
8892fn add_welcome_message_with_capabilities(
8896 container: &Arc<Container>,
8897 active_tools: &[String],
8898 skills: &[String],
8899) {
8900 let c = current_theme().colors;
8901 container.add_child(Arc::new(crate::brand::BrandLockup::new()));
8904 container.add_child(Arc::new(Spacer::new(1)));
8905 let title = c.muted.fg("interactive TUI · library-first agent runtime");
8908 container.add_child(Arc::new(Text::new(title, 1, 0)));
8909 container.add_child(Arc::new(Spacer::new(1)));
8910 container.add_child(Arc::new(Text::new(
8911 c.dim.fg("Type your message and press Enter to send."),
8912 1,
8913 0,
8914 )));
8915 let hint = c
8916 .dim
8917 .fg("Enter send · Shift+Enter newline · Ctrl+C clear · Esc abort · /help");
8918 container.add_child(Arc::new(Text::new(hint, 1, 0)));
8919 container.add_child(Arc::new(Spacer::new(1)));
8920 container.add_child(Arc::new(Text::new(
8921 welcome_capability_line("Tools", active_tools),
8922 1,
8923 0,
8924 )));
8925 container.add_child(Arc::new(Text::new(
8926 welcome_capability_line("Skills", skills),
8927 1,
8928 0,
8929 )));
8930 container.add_child(Arc::new(DynamicBorder::new()));
8931}
8932
8933fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8936 let colors = current_theme().colors;
8937 let group = Arc::new(Container::new());
8938
8939 for warning in &report.warnings {
8940 let body = format!(
8941 "{}\n{} {}{}",
8942 colors.error.fg(&warning.message),
8943 colors.muted.fg("Run"),
8944 colors.accent.fg(&warning.command),
8945 colors.muted.fg(" to retry.")
8946 );
8947 add_update_panel(&group, "Update Failed", &body, colors.error);
8948 }
8949
8950 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8951 let body = format!(
8952 "{} {}{}",
8953 colors
8954 .muted
8955 .fg(&format!("New version {} is available. Run", notice.latest)),
8956 colors.accent.fg(¬ice.command),
8957 colors.muted.fg(".")
8958 );
8959 add_update_panel(&group, "Update Available", &body, colors.warning);
8960 }
8961
8962 if group.child_count() > 0 {
8966 container.add_child(group);
8967 }
8968}
8969
8970fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8971 container.add_child(Arc::new(Spacer::new(1)));
8972 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8973 container.add_child(Arc::new(Text::new(
8974 format!("{}\n{body}", color.fg(&tui_bold(title))),
8975 1,
8976 0,
8977 )));
8978 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8979}
8980
8981fn welcome_capability_line(label: &str, names: &[String]) -> String {
8982 let c = current_theme().colors;
8983 let value = if names.is_empty() {
8984 "none".to_string()
8985 } else {
8986 names.join(" · ")
8987 };
8988 format!(
8989 "{} {}",
8990 c.accent.fg(&format!("{label} ({})", names.len())),
8991 c.muted.fg(&value)
8992 )
8993}
8994
8995fn add_help_message(container: &Arc<Container>) {
8997 let c = current_theme().colors;
8998 container.add_child(Arc::new(Text::new(
9002 c.md_heading.fg(&tui_bold("📚 Available Commands")),
9003 1,
9004 0,
9005 )));
9006 container.add_child(Arc::new(Spacer::new(1)));
9007
9008 let cmds: &[(&str, &str)] = &[
9009 ("/help, /?", "Show this help message"),
9010 ("/clear, /new", "Clear the conversation"),
9011 ("/exit, /quit, /q", "Exit the application"),
9012 ("/version, /v", "Show version information"),
9013 ("/changelog", "Show recent release changes"),
9014 ("/model, /m", "Choose a model (live switch)"),
9015 ("/thinking, /think", "Set reasoning depth (selector)"),
9016 ("/tools", "Toggle built-in tools on/off"),
9017 ("/images", "Toggle inline image rendering"),
9018 ("/session", "List saved sessions"),
9019 ("/theme", "Choose a theme (selector)"),
9020 ("/compact", "Compact the conversation"),
9021 ("/copy", "Copy last reply to clipboard"),
9022 ("/hotkeys", "Show keyboard shortcuts"),
9023 ];
9024 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9025 for (cmd, desc) in cmds {
9026 let row = format!(
9027 " {:<cmd_w$} {} {}",
9028 c.accent.fg(cmd),
9029 c.dim.fg("—"),
9030 c.muted.fg(desc)
9031 );
9032 container.add_child(Arc::new(Text::new(row, 1, 0)));
9033 }
9034 container.add_child(Arc::new(Spacer::new(1)));
9035}
9036
9037fn add_version_message(container: &Arc<Container>) {
9039 let c = current_theme().colors;
9040 container.add_child(Arc::new(Text::new(
9041 c.md_heading.fg(&tui_bold("📦 Version Information")),
9042 1,
9043 0,
9044 )));
9045 container.add_child(Arc::new(Spacer::new(1)));
9046 container.add_child(Arc::new(Text::new(
9049 format!(
9050 " {} {}",
9051 c.muted.fg("rpi-cli"),
9052 c.text.fg(&format!("v{}", crate::VERSION))
9053 ),
9054 1,
9055 0,
9056 )));
9057 container.add_child(Arc::new(Text::new(
9058 format!(
9059 " {}",
9060 c.dim.fg("Rust implementation of pi coding agent TUI")
9061 ),
9062 1,
9063 0,
9064 )));
9065 container.add_child(Arc::new(Spacer::new(1)));
9066}
9067
9068fn add_changelog_message(container: &Arc<Container>) {
9072 let c = current_theme().colors;
9073 container.add_child(Arc::new(Text::new(
9074 c.md_heading.fg(&tui_bold("Recent Changes")),
9075 1,
9076 0,
9077 )));
9078 container.add_child(Arc::new(Spacer::new(1)));
9079 let entries = [
9080 (
9081 "Native parity phase 1",
9082 "models, images, trust, export, and JSON events",
9083 ),
9084 (
9085 "TUI controls",
9086 "external editor, thinking levels, and tool output toggles",
9087 ),
9088 (
9089 "Provider auth",
9090 "OpenAI-compatible API key aliases and gateway headers",
9091 ),
9092 ];
9093 for (release, summary) in entries {
9094 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
9095 container.add_child(Arc::new(Text::new(row, 1, 0)));
9096 }
9097 container.add_child(Arc::new(Text::new(
9098 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
9099 1,
9100 0,
9101 )));
9102 container.add_child(Arc::new(Spacer::new(1)));
9103}
9104
9105fn add_hotkeys_message(container: &Arc<Container>) {
9107 let c = current_theme().colors;
9108 container.add_child(Arc::new(Text::new(
9109 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
9110 1,
9111 0,
9112 )));
9113 container.add_child(Arc::new(Spacer::new(1)));
9114 let keys: &[(&str, &str)] = &[
9115 ("Enter", "Send message"),
9116 ("Shift+Enter", "New line"),
9117 ("Tab", "Accept autocomplete suggestion"),
9118 ("Ctrl+A / Ctrl+E", "Line start / end"),
9119 (
9120 "Ctrl+K / Ctrl+U",
9121 "Kill to end / start of line (Ctrl+Y yanks)",
9122 ),
9123 ("Ctrl+- / Ctrl+R", "Undo / redo"),
9124 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
9125 ("Alt+Backspace", "Kill previous word"),
9126 ("Ctrl+C", "Clear the editor (press twice quickly to exit)"),
9127 ("Ctrl+X", "Copy the editor selection or the last reply"),
9128 ("Esc", "Abort a running prompt"),
9129 ("Ctrl+L", "Open model selector"),
9130 ("Ctrl+P", "Cycle to the next model (live)"),
9131 ("Ctrl+O", "Expand/collapse all tool output"),
9132 ("Ctrl+T", "Show/hide reasoning blocks"),
9133 ("PageUp/Down", "Scroll transcript by one page"),
9134 ("Home / End", "Jump to transcript start / latest output"),
9135 ];
9136 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9137 for (key, desc) in keys {
9138 let row = format!(
9139 " {:<key_w$} {} {}",
9140 c.accent.fg(key),
9141 c.dim.fg("—"),
9142 c.muted.fg(desc)
9143 );
9144 container.add_child(Arc::new(Text::new(row, 1, 0)));
9145 }
9146 container.add_child(Arc::new(Spacer::new(1)));
9147}
9148
9149fn add_user_message(container: &Arc<Container>, text: &str) {
9155 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
9156 container.add_child(Arc::new(Spacer::new(1)));
9157}
9158
9159fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
9162 let width = 80; chat_container.render(width)
9164}
9165
9166fn format_resume_command(
9171 session_id: &str,
9172 cwd: &std::path::Path,
9173 session_dir: Option<&std::path::Path>,
9174) -> Option<String> {
9175 if session_id.is_empty() {
9176 return None;
9177 }
9178 let mut args = vec![crate::APP_NAME.to_string()];
9179 if let Some(dir) = session_dir {
9180 let default = crate::session::default_session_dir(cwd);
9181 if dir != default.as_path() {
9182 args.push("--session-dir".to_string());
9183 args.push(quote_shell_arg(&dir.to_string_lossy()));
9184 }
9185 }
9186 args.push("--session".to_string());
9187 args.push(session_id.to_string());
9188 Some(args.join(" "))
9189}
9190
9191fn quote_shell_arg(value: &str) -> String {
9194 let needs_quoting = value.is_empty()
9195 || value
9196 .chars()
9197 .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
9198 if needs_quoting {
9199 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
9200 } else {
9201 value.to_string()
9202 }
9203}
9204
9205fn add_error_message(container: &Arc<Container>, text: &str) {
9206 let c = current_theme().colors;
9207 let text = sanitize_error_message(text);
9208 container.add_child(Arc::new(Text::new(
9209 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
9210 1,
9211 0,
9212 )));
9213 container.add_child(Arc::new(Spacer::new(1)));
9214}
9215
9216fn sanitize_error_message(text: &str) -> String {
9221 const MAX_ERROR_CHARS: usize = 16 * 1024;
9222 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
9223 let mut count = 0;
9224 let mut escape_mode = 0u8;
9226 for ch in text.chars() {
9227 if escape_mode != 0 {
9228 match escape_mode {
9229 1 if ch == '[' => escape_mode = 2,
9230 1 if ch == ']' => escape_mode = 3,
9231 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
9232 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
9233 3 if ch == '\x07' => escape_mode = 0,
9234 _ => {}
9235 }
9236 continue;
9237 }
9238 if ch == '\x1b' {
9239 escape_mode = 1;
9240 continue;
9241 }
9242 if count >= MAX_ERROR_CHARS {
9243 result.push_str("…");
9244 break;
9245 }
9246 match ch {
9247 '\n' | '\t' => {
9248 result.push(ch);
9249 count += 1;
9250 }
9251 '\r' => {}
9252 c if c.is_control() => {}
9253 c => {
9254 result.push(c);
9255 count += 1;
9256 }
9257 }
9258 }
9259 if result.trim().is_empty() {
9260 "Provider request failed.".to_string()
9261 } else {
9262 result
9263 }
9264}
9265
9266fn add_note_message(container: &Arc<Container>, text: &str) {
9268 let c = current_theme().colors;
9269 container.add_child(Arc::new(Text::new(
9270 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
9271 1,
9272 0,
9273 )));
9274 container.add_child(Arc::new(Spacer::new(1)));
9275}
9276
9277fn show_context_panel(
9286 chat: &Arc<Container>,
9287 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
9288) {
9289 let skills = resources.skills.as_deref().unwrap_or(&[]);
9290 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
9291 let mut lines: Vec<String> = Vec::new();
9292 lines.push("📂 Discovered resources for this session:".into());
9293
9294 if skills.is_empty() {
9295 lines.push(
9296 " Skills: (none discovered — create .rpi/skills/ or ~/.rpi/agent/skills/)".into(),
9297 );
9298 } else {
9299 lines.push(format!(" Skills ({}):", skills.len()));
9300 for s in skills {
9301 let marker = if s.disable_model_invocation == Some(true) {
9302 " [hidden]"
9303 } else {
9304 ""
9305 };
9306 let desc: String = s.description.chars().take(72).collect();
9307 lines.push(format!(" • {}{marker} — {desc}", s.name));
9308 }
9309 }
9310
9311 if templates.is_empty() {
9312 lines.push(
9313 " Prompt templates: (none — create .rpi/prompts/ or ~/.rpi/agent/prompts/)".into(),
9314 );
9315 } else {
9316 lines.push(format!(" Prompt templates ({}):", templates.len()));
9317 for t in templates {
9318 let desc = t
9319 .description
9320 .as_deref()
9321 .unwrap_or("(no description)")
9322 .chars()
9323 .take(72)
9324 .collect::<String>();
9325 lines.push(format!(" • /{} — {desc}", t.name));
9326 }
9327 }
9328 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
9329 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
9330 lines.push(
9331 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
9332 .into(),
9333 );
9334 let body = lines.join("\n");
9335 container_note_block(chat, &body);
9336}
9337
9338fn container_note_block(container: &Arc<Container>, body: &str) {
9340 for line in body.lines() {
9341 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
9342 }
9343 container.add_child(Arc::new(Spacer::new(1)));
9344}
9345
9346pub fn is_tui_supported() -> bool {
9352 std::io::stdout().is_terminal()
9353}
9354
9355#[allow(unused_imports)]
9357use rpi_tui::Color as _Color;
9358
9359#[cfg(test)]
9360mod tests {
9361 use super::*;
9362 use rpi_tui::Component;
9363
9364 #[test]
9368 fn auto_send_cancels_when_the_user_edits_the_draft() {
9369 let now = std::time::Instant::now();
9370 let pending = AutoSendPending {
9371 deadline: now + std::time::Duration::from_secs(2),
9372 text: "hello world".to_string(),
9373 };
9374 assert!(matches!(
9376 auto_send_step(Some(&pending), "hello world", true, now),
9377 AutoSendStep::Wait(_)
9378 ));
9379 assert_eq!(
9382 auto_send_step(Some(&pending), "hello worlds", true, now),
9383 AutoSendStep::Cancel
9384 );
9385 assert_eq!(
9387 auto_send_step(Some(&pending), "", true, now),
9388 AutoSendStep::Cancel
9389 );
9390 assert_eq!(
9392 auto_send_step(Some(&pending), " ", true, now),
9393 AutoSendStep::Cancel
9394 );
9395 }
9396
9397 #[test]
9400 fn auto_send_submits_at_the_deadline_only_while_idle() {
9401 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2000);
9402 let pending = AutoSendPending {
9403 deadline,
9404 text: "go".to_string(),
9405 };
9406 assert!(matches!(
9407 auto_send_step(
9408 Some(&pending),
9409 "go",
9410 true,
9411 deadline - std::time::Duration::from_millis(1)
9412 ),
9413 AutoSendStep::Wait(_)
9414 ));
9415 assert_eq!(
9416 auto_send_step(Some(&pending), "go", true, deadline),
9417 AutoSendStep::Submit
9418 );
9419 assert_eq!(
9422 auto_send_step(Some(&pending), "go", false, deadline),
9423 AutoSendStep::Cancel
9424 );
9425 assert_eq!(
9427 auto_send_step(None, "go", true, deadline),
9428 AutoSendStep::Idle
9429 );
9430 }
9431
9432 #[test]
9435 fn auto_send_footer_label_is_recognisable() {
9436 let label = format!("{AUTO_SEND_PREFIX}2.0s · any key to edit");
9437 assert!(label.starts_with(AUTO_SEND_PREFIX));
9438 assert!(!"Working…".starts_with(AUTO_SEND_PREFIX));
9439 }
9440
9441 #[test]
9442 fn markdown_flag_only_comes_from_an_explicit_true() {
9443 assert!(tool_result_requests_markdown(Some(
9445 &serde_json::json!({"kind": "todo", "markdown": true})
9446 )));
9447 assert!(!tool_result_requests_markdown(None));
9450 assert!(!tool_result_requests_markdown(Some(
9451 &serde_json::json!({"kind": "todo"})
9452 )));
9453 assert!(!tool_result_requests_markdown(Some(
9454 &serde_json::json!({"markdown": false})
9455 )));
9456 assert!(!tool_result_requests_markdown(Some(
9457 &serde_json::json!({"markdown": "true"})
9458 )));
9459 }
9460
9461 fn test_tui_state() -> Arc<TuiState> {
9464 Arc::new(TuiState {
9465 current_assistant: Arc::new(std::sync::Mutex::new(None)),
9466 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
9467 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
9468 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
9469 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
9470 show_terminal_progress: true,
9471 status: std::sync::Mutex::new(RunStatus::Idle),
9472 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
9473 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
9474 editor_text: rpi_extensions::EditorTextMailbox::new(),
9475 auto_send: std::sync::Mutex::new(None),
9476 last_editor_text: std::sync::Mutex::new(String::new()),
9477 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
9478 js_preparation_cancel: std::sync::Mutex::new(None),
9479 user_bash_cancel: std::sync::Mutex::new(None),
9480 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
9481 footer: Arc::new(FooterComponent::new()),
9482 status_container: Arc::new(Container::new()),
9483 chat_container: Arc::new(Container::new()),
9484 loader: Arc::new(Loader::new()),
9485 editor: Arc::new(Editor::simple()),
9486 last_assistant_text: std::sync::Mutex::new(String::new()),
9487 active_selector: std::sync::Mutex::new(None),
9488 active_extension_editor: std::sync::Mutex::new(None),
9489 active_extension_input: std::sync::Mutex::new(None),
9490 active_extension_cancel: std::sync::Mutex::new(None),
9491 autocomplete: AutocompleteManager::new(),
9492 autocomplete_container: Arc::new(Container::new()),
9493 autocomplete_max_visible: 5,
9494 pending_images: std::sync::Mutex::new(Vec::new()),
9495 theme_manager: Arc::new(ThemeManager::new()),
9496 tui: None,
9497 current_model_id: std::sync::Mutex::new(String::new()),
9498 show_images: std::sync::Mutex::new(true),
9499 cache_miss_notices: std::sync::Mutex::new(false),
9500 history: std::sync::Mutex::new(Vec::new()),
9501 history_index: std::sync::Mutex::new(-1),
9502 history_draft: std::sync::Mutex::new(None),
9503 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
9504 scoped_edit: std::sync::Mutex::new(None),
9505 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
9506 extension_session: Arc::new(std::sync::Mutex::new(
9507 rpi_extensions::ExtensionSession::none(),
9508 )),
9509 search: Arc::new(AltScreenSearch::new()),
9510 search_bar: Arc::new(SearchBar::new()),
9511 pending_container: Arc::new(Container::new()),
9512 dequeue_hint: String::new(),
9513 pending_snapshot: std::sync::Mutex::new(
9514 rpi_harness::agent_harness::QueuedMessages::default(),
9515 ),
9516 selection_start: std::sync::Mutex::new(None),
9517 selection_end: std::sync::Mutex::new(None),
9518 })
9519 }
9520
9521 #[test]
9528 fn editor_change_source_separates_tui_writes_from_typing() {
9529 use std::sync::atomic::Ordering;
9530 let state = test_tui_state();
9531
9532 assert!(state.take_editor_change().is_none());
9534
9535 state.editor_text.push(rpi_extensions::EditorTextEdit {
9537 text: "你好".to_string(),
9538 mode: rpi_extensions::EditorTextMode::Replace,
9539 auto_send_ms: Some(2000),
9540 });
9541 assert!(state.drain_editor_text(), "the queued draft must land");
9542 assert!(
9543 state.programmatic_editor_write.load(Ordering::SeqCst),
9544 "a mailbox-applied edit must be marked programmatic"
9545 );
9546
9547 let payload = state
9549 .take_editor_change()
9550 .expect("draft applied ⇒ a change");
9551 assert_eq!(payload["source"], serde_json::json!("extension"));
9552 assert_eq!(payload["chars"], serde_json::json!(2));
9553 assert_eq!(payload["empty"], serde_json::json!(false));
9554
9555 assert!(state.take_editor_change().is_none());
9558 state.editor.set_text("你好啊");
9559 let payload = state.take_editor_change().expect("typing ⇒ a change");
9560 assert_eq!(payload["source"], serde_json::json!("user"));
9561 assert_eq!(payload["chars"], serde_json::json!(3));
9562 }
9563
9564 #[test]
9567 fn programmatic_mark_is_consumed_even_with_no_subscriber() {
9568 use std::sync::atomic::Ordering;
9569 let state = test_tui_state();
9570 assert!(
9571 state
9572 .extension_session
9573 .lock()
9574 .unwrap()
9575 .snapshot_arc()
9576 .is_none(),
9577 "this test relies on nobody subscribing"
9578 );
9579
9580 state.editor_text.push(rpi_extensions::EditorTextEdit {
9581 text: "draft".to_string(),
9582 mode: rpi_extensions::EditorTextMode::Replace,
9583 auto_send_ms: None,
9584 });
9585 state.drain_editor_text();
9586 state.sync_editor_change();
9588 assert!(!state.programmatic_editor_write.load(Ordering::SeqCst));
9590 state.editor.set_text("draft +");
9591 let payload = state.take_editor_change().expect("typing ⇒ a change");
9592 assert_eq!(payload["source"], serde_json::json!("user"));
9593 }
9594
9595 #[test]
9604 fn submit_clear_is_attributed_to_the_host_not_the_user() {
9605 let state = test_tui_state();
9606
9607 state.editor.set_text("/voice auto");
9609 assert_eq!(
9610 state.take_editor_change().unwrap()["source"],
9611 serde_json::json!("user")
9612 );
9613
9614 state.clear_editor_programmatically();
9616 let payload = state.take_editor_change().expect("clearing is a change");
9617 assert_eq!(payload["empty"], serde_json::json!(true));
9618 assert_eq!(
9619 payload["source"],
9620 serde_json::json!("extension"),
9621 "a submit-clear must not be reported as the user typing"
9622 );
9623 }
9624
9625 #[test]
9628 fn user_initiated_clear_stays_a_user_change() {
9629 let state = test_tui_state();
9630 state.editor.set_text("draft");
9631 assert_eq!(
9632 state.take_editor_change().unwrap()["source"],
9633 serde_json::json!("user")
9634 );
9635 state.editor.clear();
9636 let payload = state.take_editor_change().expect("clearing is a change");
9637 assert_eq!(payload["source"], serde_json::json!("user"));
9638 }
9639
9640 #[test]
9641 fn live_panel_repaints_while_a_tool_or_bash_is_running() {
9642 let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
9645 assert!(!transcript_has_live_panel(false, &empty));
9646
9647 let mut tools = HashMap::new();
9650 let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
9651 tool.set_running();
9652 tools.insert("tc1".to_string(), tool);
9653 assert!(transcript_has_live_panel(false, &tools));
9654
9655 tools.get("tc1").unwrap().set_result("done", false);
9658 assert!(!transcript_has_live_panel(false, &tools));
9659 assert!(transcript_has_live_panel(true, &tools));
9660 }
9661
9662 #[test]
9663 fn dequeue_merge_preserves_a_typed_draft() {
9664 assert_eq!(merge_queued_into_draft("queued", ""), "queued");
9665 assert_eq!(merge_queued_into_draft("queued", " "), "queued");
9666 assert_eq!(
9667 merge_queued_into_draft("queued", "typed"),
9668 "queued\n\ntyped"
9669 );
9670 }
9671
9672 #[test]
9673 fn dequeue_places_the_caret_at_the_end_of_the_last_line_in_bytes() {
9674 let editor = Arc::new(Editor::simple());
9678 let text = "排队\n第二行";
9681 set_editor_text_caret_at_end(&editor, text);
9682 assert_eq!(editor.get_text(), text);
9683 assert_eq!(
9684 editor.cursor_position(),
9685 (1, "第二行".len()),
9686 "caret must sit at the end of the last line, in bytes"
9687 );
9688 }
9689
9690 #[test]
9691 fn settings_menu_covers_the_wired_settings_surface() {
9692 let state = test_tui_state();
9693 let settings = crate::settings::Settings::default();
9694 let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
9695 let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
9696 assert_eq!(
9697 keys,
9698 [
9699 "theme",
9700 "model",
9701 "thinking",
9702 "scoped-models",
9703 "hide-thinking",
9704 "show-images",
9705 "cache-miss-notices",
9706 "quiet-startup",
9707 "terminal-progress",
9708 "fullscreen-copy-on-select",
9709 "double-escape-action",
9710 "editor-padding",
9711 "autocomplete-max-items",
9712 "http-idle-timeout",
9713 ]
9714 );
9715 for item in items.iter().filter(|item| item.has_submenu) {
9717 assert!(
9718 item.values.is_empty(),
9719 "{} should be submenu-only",
9720 item.key
9721 );
9722 }
9723 for item in items.iter().filter(|item| !item.has_submenu) {
9725 assert!(!item.values.is_empty(), "{} has no values", item.key);
9726 assert!(
9727 item.values.iter().any(|value| value == &item.value),
9728 "{} current value {} not in {:?}",
9729 item.key,
9730 item.value,
9731 item.values
9732 );
9733 }
9734 assert_eq!(items[1].value, "claude-sonnet-5");
9736 assert_eq!(items[6].value, "false");
9738 assert_eq!(items[8].value, "true");
9739 }
9740
9741 #[test]
9742 fn apply_setting_change_updates_settings_and_live_state() {
9743 let state = test_tui_state();
9744 let mut settings = crate::settings::Settings::default();
9745
9746 let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
9748 assert!(state.hide_thinking());
9749 assert_eq!(settings.hide_thinking_block, Some(true));
9750 assert!(note.expect("user-visible note").contains("hidden"));
9751
9752 apply_setting_change(&state, "show-images", "false", &mut settings);
9754 assert!(!*state.show_images.lock().unwrap());
9755 assert_eq!(settings.show_images, Some(false));
9756
9757 apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
9759 assert!(*state.cache_miss_notices.lock().unwrap());
9760 assert_eq!(settings.show_cache_miss_notices, Some(true));
9761
9762 assert_eq!(
9764 apply_setting_change(&state, "editor-padding", "3", &mut settings),
9765 None
9766 );
9767 assert_eq!(settings.editor_padding_x, Some(3));
9768 apply_setting_change(&state, "double-escape-action", "none", &mut settings);
9769 assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
9770 apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
9771 assert_eq!(settings.autocomplete_max_visible, Some(20));
9772
9773 apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
9775 assert_eq!(settings.http_idle_timeout_ms(), Some(0));
9776 apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
9777 assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
9778
9779 assert_eq!(
9781 apply_setting_change(&state, "nope", "x", &mut settings),
9782 None
9783 );
9784 }
9785
9786 #[test]
9787 fn add_user_message_renders_a_visible_user_bubble() {
9788 let chat = Arc::new(Container::new());
9792 add_user_message(&chat, "hello from the user");
9793 let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
9794 let plain = strip_ansi(&rendered.join("\n"));
9795 assert!(
9796 plain.contains("hello from the user"),
9797 "user bubble must show the prompt text; got: {plain:?}"
9798 );
9799 }
9800
9801 #[test]
9802 fn verbose_overrides_quiet_startup_listing() {
9803 assert!(should_show_startup_listing(false, false));
9804 assert!(should_show_startup_listing(true, false));
9805 assert!(should_show_startup_listing(true, true));
9806 assert!(!should_show_startup_listing(false, true));
9807 }
9808
9809 #[test]
9810 fn resume_command_omits_the_default_session_dir() {
9811 let cwd = std::path::Path::new("/proj/demo");
9812 assert_eq!(format_resume_command("", cwd, None), None);
9813 assert_eq!(
9814 format_resume_command("abc-123", cwd, None).as_deref(),
9815 Some("rpi --session abc-123")
9816 );
9817 let default = crate::session::default_session_dir(cwd);
9819 assert_eq!(
9820 format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
9821 Some("rpi --session abc-123")
9822 );
9823 let custom = std::path::Path::new("/tmp/my sessions");
9825 assert_eq!(
9826 format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
9827 Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
9828 );
9829 }
9830
9831 #[test]
9832 fn quote_shell_arg_quotes_only_when_needed() {
9833 assert_eq!(quote_shell_arg("plain"), "plain");
9834 assert_eq!(quote_shell_arg("with space"), "\"with space\"");
9835 assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
9836 }
9837
9838 #[test]
9839 fn trust_command_uses_the_session_cwd_after_process_chdir() {
9840 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
9841 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
9842 const TEST_NAME: &str =
9843 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
9844
9845 if std::env::var_os(CHILD_ENV).is_some() {
9846 let session_cwd = std::path::PathBuf::from(
9847 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
9848 );
9849 let changed_cwd = std::env::current_dir().unwrap();
9850
9851 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
9852
9853 assert_eq!(
9854 crate::config::project_trust_decision(&session_cwd).unwrap(),
9855 Some(true)
9856 );
9857 assert_eq!(
9858 crate::config::project_trust_decision(&changed_cwd).unwrap(),
9859 None
9860 );
9861 return;
9862 }
9863
9864 let tmp = tempfile::tempdir().unwrap();
9865 let session_cwd = tmp.path().join("session-project");
9866 let changed_cwd = tmp.path().join("extension-cwd");
9867 let agent_dir = tmp.path().join("agent");
9868 std::fs::create_dir_all(&session_cwd).unwrap();
9869 std::fs::create_dir_all(&changed_cwd).unwrap();
9870 std::fs::create_dir_all(&agent_dir).unwrap();
9871
9872 let status = std::process::Command::new(std::env::current_exe().unwrap())
9873 .arg("--exact")
9874 .arg(TEST_NAME)
9875 .arg("--nocapture")
9876 .env(CHILD_ENV, "1")
9877 .env(SESSION_CWD_ENV, &session_cwd)
9878 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
9879 .current_dir(&changed_cwd)
9880 .status()
9881 .unwrap();
9882
9883 assert!(status.success(), "child test process failed: {status}");
9884 }
9885
9886 #[test]
9887 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
9888 let global = crate::settings::Settings {
9889 editor_padding_x: Some(3),
9890 autocomplete_max_visible: Some(6),
9891 hide_thinking_block: Some(false),
9892 quiet_startup: Some(true),
9893 show_terminal_progress: Some(true),
9894 ..Default::default()
9895 };
9896 let rpi_project = crate::settings::Settings {
9897 editor_padding_x: Some(0),
9898 hide_thinking_block: Some(true),
9899 quiet_startup: Some(false),
9900 show_terminal_progress: Some(false),
9901 ..Default::default()
9902 };
9903 let pi_project = crate::settings::Settings {
9904 editor_padding_x: Some(9),
9905 autocomplete_max_visible: Some(12),
9906 quiet_startup: Some(true),
9907 ..Default::default()
9908 };
9909
9910 assert_eq!(
9911 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
9912 TuiStartupSettings {
9913 editor_padding_x: 0,
9914 autocomplete_max_visible: 12,
9915 hide_thinking: true,
9916 quiet_startup: false,
9917 show_terminal_progress: false,
9918 show_images: true,
9919 cache_miss_notices: false,
9920 }
9921 );
9922 }
9923
9924 #[test]
9925 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
9926 let global = crate::settings::Settings {
9927 quiet_startup: Some(false),
9928 ..Default::default()
9929 };
9930 let rpi_project = crate::settings::Settings::default();
9931 let pi_project = crate::settings::Settings {
9932 quiet_startup: Some(true),
9933 ..Default::default()
9934 };
9935
9936 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
9937
9938 assert!(resolved.quiet_startup);
9939 }
9940
9941 #[test]
9942 fn tui_startup_settings_use_global_values_without_project_fields() {
9943 let global = crate::settings::Settings {
9944 editor_padding_x: Some(7),
9945 autocomplete_max_visible: Some(8),
9946 hide_thinking_block: Some(true),
9947 quiet_startup: Some(true),
9948 show_terminal_progress: Some(false),
9949 show_images: Some(false),
9950 show_cache_miss_notices: Some(true),
9951 ..Default::default()
9952 };
9953
9954 assert_eq!(
9955 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
9956 TuiStartupSettings {
9957 editor_padding_x: 7,
9958 autocomplete_max_visible: 8,
9959 hide_thinking: true,
9960 quiet_startup: true,
9961 show_terminal_progress: false,
9962 show_images: false,
9963 cache_miss_notices: true,
9964 }
9965 );
9966 }
9967
9968 #[test]
9969 fn tui_startup_settings_read_native_nested_terminal_block() {
9970 let global = crate::settings::Settings {
9973 terminal: Some(crate::settings::TerminalSettings {
9974 show_images: Some(false),
9975 show_terminal_progress: Some(false),
9976 clear_on_shrink: None,
9977 }),
9978 ..Default::default()
9979 };
9980 let resolved =
9981 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
9982 assert!(!resolved.show_images);
9983 assert!(!resolved.show_terminal_progress);
9984 }
9985
9986 #[test]
9987 fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
9988 let global = crate::settings::Settings {
9989 show_images: Some(true),
9990 terminal: Some(crate::settings::TerminalSettings {
9991 show_images: Some(false),
9992 show_terminal_progress: None,
9993 clear_on_shrink: None,
9994 }),
9995 ..Default::default()
9996 };
9997 let resolved =
9998 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
9999 assert!(resolved.show_images);
10000 }
10001
10002 #[test]
10003 fn tui_startup_settings_ignore_untrusted_project_values() {
10004 let global = crate::settings::Settings {
10005 quiet_startup: Some(false),
10006 show_terminal_progress: Some(true),
10007 ..Default::default()
10008 };
10009 let project = crate::settings::Settings {
10010 quiet_startup: Some(true),
10011 show_terminal_progress: Some(false),
10012 ..Default::default()
10013 };
10014
10015 let resolved = resolve_tui_startup_settings(&global, &[project], false);
10016
10017 assert!(!resolved.quiet_startup);
10018 assert!(resolved.show_terminal_progress);
10019 }
10020
10021 #[test]
10022 fn transcript_page_uses_viewport_with_overlap() {
10023 assert_eq!(transcript_page_size(24), 20);
10024 assert_eq!(transcript_page_size(4), 1);
10025 assert_eq!(transcript_page_size(0), 1);
10026 }
10027
10028 #[test]
10029 fn parse_user_bash_distinguishes_single_and_double_bang() {
10030 assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10032 assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10034 assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
10036 assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
10037 }
10038
10039 #[test]
10040 fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10041 assert_eq!(parse_user_bash("!"), None);
10044 assert_eq!(parse_user_bash("!!"), None);
10045 assert_eq!(parse_user_bash("! "), None);
10046 assert_eq!(parse_user_bash("hello"), None);
10047 assert_eq!(parse_user_bash(""), None);
10048 assert_eq!(parse_user_bash("/model"), None);
10050 }
10051
10052 #[test]
10053 fn user_bash_run_slot_guards_and_cancels() {
10054 let state = test_tui_state();
10055 assert!(!state.user_bash_running());
10056 let cancel = state.begin_user_bash();
10057 assert!(state.user_bash_running());
10058 assert!(state.cancel_user_bash());
10061 assert!(state.user_bash_running());
10062 assert!(cancel.is_cancelled());
10063 state.finish_user_bash();
10064 assert!(!state.user_bash_running());
10065 assert!(!state.cancel_user_bash());
10067 }
10068
10069 #[test]
10070 fn key_repeat_is_dispatched_but_release_is_not() {
10071 assert!(should_dispatch_key(KeyEventKind::Press));
10072 assert!(should_dispatch_key(KeyEventKind::Repeat));
10073 assert!(!should_dispatch_key(KeyEventKind::Release));
10074 }
10075
10076 #[test]
10077 fn test_layout_renders_welcome_message() {
10078 let chat = Arc::new(Container::new());
10079 add_welcome_message(&chat);
10080
10081 let scroll = Arc::new(ScrollView::new(
10082 chat.clone(),
10083 ScrollViewOptions {
10084 follow: FollowMode::End,
10085 primary: true,
10086 ..Default::default()
10087 },
10088 ));
10089
10090 let editor = Arc::new(Editor::new(
10091 EditorOptions {
10092 padding_x: 1,
10093 ..Default::default()
10094 },
10095 EditorStyle::default(),
10096 Arc::new(rpi_tui::Keybindings::new()),
10097 ));
10098 let dock = Arc::new(Container::new());
10099 dock.add_child(editor);
10100
10101 let footer = Arc::new(FooterComponent::new());
10102
10103 let root = VStack::from_children(vec![
10104 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
10105 StackChild::Entry(StackEntry::new(dock)),
10106 StackChild::Entry(StackEntry::new(footer)),
10107 ]);
10108
10109 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
10110
10111 let all: String = frame.lines.join("\n");
10112 assert!(
10113 all.contains("rpi"),
10114 "Welcome message not found. Rendered: {}",
10115 all
10116 );
10117 assert!(
10118 all.contains("Type your message"),
10119 "Help text not found. Rendered: {}",
10120 all
10121 );
10122 }
10123
10124 #[test]
10125 fn test_chat_container_has_welcome_content() {
10126 let chat = Arc::new(Container::new());
10127 add_welcome_message_with_capabilities(
10128 &chat,
10129 &["read".into(), "bash".into(), "web_fetch".into()],
10130 &["rust-review".into(), "release".into()],
10131 );
10132
10133 let lines = chat.render(80);
10134 let all: String = lines.join("\n");
10135 let plain = strip_ansi(&all);
10138 assert!(
10139 plain.contains("rpi"),
10140 "Welcome message not in chat container: {:?}",
10141 lines
10142 );
10143 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
10144 assert!(
10145 plain.contains("read · bash · web_fetch"),
10146 "Tool names missing: {plain}"
10147 );
10148 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
10149 assert!(
10150 plain.contains("rust-review · release"),
10151 "Skill names missing: {plain}"
10152 );
10153 }
10154
10155 #[test]
10156 fn welcome_header_renders_the_brand_mark() {
10157 let chat = Arc::new(Container::new());
10158 add_welcome_message(&chat);
10159 let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
10160 let all = lines.join("\n");
10161
10162 assert!(
10165 lines.iter().any(|l| l.contains("██ ██ ██")),
10166 "brand mark missing: {all}"
10167 );
10168 assert!(
10169 all.contains("rpi"),
10170 "the mark must carry the product name: {all}"
10171 );
10172
10173 assert!(
10179 !all.contains('π'),
10180 "the header must not use east-asian-ambiguous glyphs: {all}"
10181 );
10182 }
10183
10184 #[test]
10185 fn update_notices_render_inside_the_transcript() {
10186 let chat = Arc::new(Container::new());
10187 let report = crate::updates::UpdateReport {
10188 notices: vec![crate::updates::UpdateNotice {
10189 name: "rpi".into(),
10190 current: "0.1.10".into(),
10191 latest: "0.1.11".into(),
10192 command: "rpi update".into(),
10193 }],
10194 warnings: vec![crate::updates::UpdateWarning {
10195 message: "The previously scheduled rpi update failed: access denied".into(),
10196 command: "rpi update".into(),
10197 }],
10198 };
10199
10200 add_update_notices(&chat, &report);
10201
10202 assert_eq!(chat.child_count(), 1);
10203 let plain = strip_ansi(&chat.render(80).join("\n"));
10204 assert!(plain.contains("Update Failed"), "{plain}");
10205 assert!(
10206 plain.contains("rpi update failed: access denied"),
10207 "{plain}"
10208 );
10209 assert!(plain.contains("Update Available"), "{plain}");
10210 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
10211 assert!(plain.contains("rpi update"), "{plain}");
10212 }
10213
10214 #[test]
10215 fn empty_update_report_does_not_add_transcript_content() {
10216 let chat = Arc::new(Container::new());
10217
10218 add_update_notices(&chat, &crate::updates::UpdateReport::default());
10219
10220 assert_eq!(chat.child_count(), 0);
10221 }
10222
10223 #[test]
10224 fn welcome_capabilities_show_empty_state() {
10225 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
10226 assert_eq!(plain, "Skills (0) none");
10227 }
10228
10229 #[test]
10230 fn completed_stream_reconciles_the_final_tail_before_detaching() {
10231 let component = Arc::new(AssistantMessageComponent::new(
10232 AssistantMessageOptions::default(),
10233 ));
10234 component.set_streaming(true);
10235 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
10236
10237 let current = Arc::new(Mutex::new(Some(component.clone())));
10238 let cached = Mutex::new("partial response".to_string());
10239 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
10240 final_message.content = vec![Content::text(
10241 "partial response with the previously missing final tail",
10242 )];
10243 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
10244
10245 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
10246
10247 assert!(current.lock().unwrap().is_none());
10248 assert_eq!(
10249 cached.lock().unwrap().as_str(),
10250 "partial response with the previously missing final tail"
10251 );
10252 let rendered = strip_ansi(&component.render(100).join("\n"));
10253 assert!(
10254 rendered.contains("previously missing final tail"),
10255 "{rendered}"
10256 );
10257 }
10258
10259 #[test]
10265 fn tab_accept_suggestion_reflects_in_next_render() {
10266 use rpi_tui::render_layout_frame;
10267
10268 let editor = Arc::new(Editor::new(
10269 EditorOptions {
10270 padding_x: 1,
10271 ..Default::default()
10272 },
10273 EditorStyle::default(),
10274 Arc::new(rpi_tui::Keybindings::new()),
10275 ));
10276 editor.set_focused(true);
10277 let editor_container = Arc::new(Container::new());
10278 editor_container.add_child(editor.clone());
10279 let autocomplete_container = Arc::new(Container::new());
10280 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
10281 let dock = Arc::new(VStack::from_children(vec![
10282 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
10283 StackChild::Entry(
10284 StackEntry::new(editor_container.clone())
10285 .shrink(0)
10286 .min_size(3),
10287 ),
10288 StackChild::Entry(StackEntry::new(footer)),
10289 ]));
10290
10291 let manager = AutocompleteManager::new();
10293 let mut combined = CombinedAutocompleteProvider::new();
10294 combined.add_provider(Arc::new(
10295 SlashCommandAutocompleteProvider::with_default_commands(),
10296 ));
10297 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
10298 manager.set_provider(Arc::new(combined));
10299 editor.insert("/mo");
10302 assert_eq!(editor.cursor_position(), (0, 3));
10303
10304 let frame_before = render_layout_frame(dock.clone(), 80, 10);
10305 assert!(
10306 frame_before.lines.iter().any(|l| l.contains("/mo")),
10307 "precondition: editor shows the typed prefix. Frame rows:\n{}",
10308 frame_before
10309 .lines
10310 .iter()
10311 .map(|l| format!(" [{l}]"))
10312 .collect::<Vec<_>>()
10313 .join("\n")
10314 );
10315
10316 let text = editor.get_text();
10318 let cursor = editor_cursor_offset(&editor, &text);
10319 let sugg = manager
10320 .get_suggestions(&text, cursor)
10321 .expect("slash suggestions for /mo");
10322 let top = sugg.items.first().expect("at least one suggestion");
10323 let start = sugg.start.min(text.len());
10324 let end = sugg.end.min(text.len());
10325 let mut replaced = String::new();
10326 replaced.push_str(&text[..start]);
10327 replaced.push_str(&top.text);
10328 replaced.push_str(&text[end..]);
10329 if top.insert_space && !replaced.ends_with('/') {
10330 replaced.push(' ');
10331 }
10332 let new_cursor = start + top.text.len();
10333 editor.set_text(&replaced);
10334 set_editor_cursor_offset(&editor, &replaced, new_cursor);
10335 autocomplete_container.clear();
10336 assert_eq!(editor.get_text(), "/model");
10337
10338 let frame_after = render_layout_frame(dock, 80, 10);
10340 let all: String = frame_after.lines.join("\n");
10341 assert!(
10342 all.contains("/model"),
10343 "completed text missing from next render. Got:\n{all}"
10344 );
10345 let editor_line = frame_after
10349 .lines
10350 .iter()
10351 .find(|l| l.contains("/model"))
10352 .expect("editor row with completed text");
10353 assert!(
10354 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
10355 "caret must follow the full completed text. Got: {editor_line:?}"
10356 );
10357 }
10358
10359 #[test]
10360 fn multiline_autocomplete_preserves_row_and_column() {
10361 let editor = Arc::new(Editor::simple());
10362 editor.set_text("first\n/mo");
10363 editor.set_cursor(1, 3);
10364
10365 let text = editor.get_text();
10366 assert_eq!(editor_cursor_offset(&editor, &text), 9);
10367
10368 let replaced = "first\n/model";
10369 editor.set_text(replaced);
10370 set_editor_cursor_offset(&editor, replaced, 12);
10371 assert_eq!(editor.cursor_position(), (1, 6));
10372 }
10373
10374 #[test]
10375 fn test_slash_command_dispatch() {
10376 let registry = build_builtin_registry();
10381
10382 let resolves_to = |token: &str, canonical: &str| {
10384 let found = registry.find(token).expect("{token} should resolve");
10385 assert_eq!(
10386 found.name(),
10387 canonical,
10388 "{token} resolved to {} (expected {canonical})",
10389 found.name()
10390 );
10391 };
10392
10393 resolves_to("/help", "/help");
10394 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
10396 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
10400 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
10402 resolves_to("/hotkeys", "/hotkeys");
10403 resolves_to("/model", "/model");
10404 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
10406 resolves_to("/session", "/session");
10407 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
10409 resolves_to("/copy", "/copy");
10410 resolves_to("/thinking", "/thinking");
10411 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
10413 resolves_to("/images", "/images");
10414 resolves_to("/context", "/context");
10415 resolves_to("/settings", "/settings");
10417 resolves_to("/name", "/name");
10418 resolves_to("/export", "/export");
10419
10420 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
10422 }
10423
10424 #[test]
10425
10426 fn test_registry_visible_entries_cover_dispatch() {
10427 let registry = build_builtin_registry();
10432 let names: Vec<String> = registry
10433 .visible_entries()
10434 .iter()
10435 .map(|c| c.name.clone())
10436 .collect();
10437 for recognized in [
10438 "/help",
10439 "/clear",
10440 "/new",
10441 "/exit",
10442 "/quit",
10443 "/version",
10444 "/changelog",
10445 "/model",
10446 "/session",
10447 "/theme",
10448 "/compact",
10449 "/copy",
10450 "/hotkeys",
10451 "/tools",
10452 "/images",
10453 "/thinking",
10454 "/usage",
10455 ] {
10456 assert!(
10457 names.contains(&recognized.to_string()),
10458 "{recognized} missing from autocomplete list"
10459 );
10460 }
10461 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
10463 assert!(
10464 !names.contains(&hidden.to_string()),
10465 "{hidden} should be hidden from autocomplete"
10466 );
10467 }
10468 }
10469
10470 #[test]
10471 fn test_agent_event_mapping_creates_assistant_and_tool() {
10472 use rpi_ai::types::{
10475 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
10476 ToolCall, ToolCallType, Usage,
10477 };
10478
10479 let state = Arc::new(TuiState {
10480 current_assistant: Arc::new(std::sync::Mutex::new(None)),
10481 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10482 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10483 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10484 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10485 show_terminal_progress: true,
10486 status: std::sync::Mutex::new(RunStatus::Idle),
10487 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10488 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10489 editor_text: rpi_extensions::EditorTextMailbox::new(),
10490 auto_send: std::sync::Mutex::new(None),
10491 last_editor_text: std::sync::Mutex::new(String::new()),
10492 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10493 js_preparation_cancel: std::sync::Mutex::new(None),
10494 user_bash_cancel: std::sync::Mutex::new(None),
10495 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10496 footer: Arc::new(FooterComponent::new()),
10497 status_container: Arc::new(Container::new()),
10498 chat_container: Arc::new(Container::new()),
10499 loader: Arc::new(Loader::new()),
10500 editor: Arc::new(Editor::simple()),
10501 last_assistant_text: std::sync::Mutex::new(String::new()),
10502 active_selector: std::sync::Mutex::new(None),
10503 active_extension_editor: std::sync::Mutex::new(None),
10504 active_extension_input: std::sync::Mutex::new(None),
10505 active_extension_cancel: std::sync::Mutex::new(None),
10506 autocomplete: AutocompleteManager::new(),
10507 autocomplete_container: Arc::new(Container::new()),
10508 autocomplete_max_visible: 5,
10509 pending_images: std::sync::Mutex::new(Vec::new()),
10510 theme_manager: Arc::new(ThemeManager::new()),
10511 tui: None,
10512 current_model_id: std::sync::Mutex::new(String::new()),
10513 show_images: std::sync::Mutex::new(true),
10514 cache_miss_notices: std::sync::Mutex::new(false),
10515 history: std::sync::Mutex::new(Vec::new()),
10516 history_index: std::sync::Mutex::new(-1),
10517 history_draft: std::sync::Mutex::new(None),
10518 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10519 scoped_edit: std::sync::Mutex::new(None),
10520 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10521 extension_session: Arc::new(std::sync::Mutex::new(
10522 rpi_extensions::ExtensionSession::none(),
10523 )),
10524 search: Arc::new(AltScreenSearch::new()),
10525 search_bar: Arc::new(SearchBar::new()),
10526 pending_container: Arc::new(Container::new()),
10527 dequeue_hint: String::new(),
10528 pending_snapshot: std::sync::Mutex::new(
10529 rpi_harness::agent_harness::QueuedMessages::default(),
10530 ),
10531 selection_start: std::sync::Mutex::new(None),
10532 selection_end: std::sync::Mutex::new(None),
10533 });
10534
10535 let assistant = AssistantMessage {
10539 role: rpi_ai::types::AssistantRole,
10540 content: vec![
10541 Content::Thinking(ThinkingContent {
10542 kind: ThinkingContentType,
10543 thinking: "Reasoning about the reply.".into(),
10544 thinking_signature: None,
10545 redacted: false,
10546 }),
10547 Content::Text(TextContent {
10548 kind: TextContentType,
10549 text: "Hello.".into(),
10550 text_signature: None,
10551 }),
10552 Content::ToolCall(ToolCall {
10553 kind: ToolCallType,
10554 id: "tc1".into(),
10555 name: "bash".into(),
10556 arguments: serde_json::json!({"command": "echo hi"}),
10557 thought_signature: None,
10558 namespace: None,
10559 }),
10560 ],
10561 api: rpi_ai::Api::AnthropicMessages,
10562 provider: "anthropic".into(),
10563 model: "claude-sonnet-5".into(),
10564 response_model: None,
10565 response_id: None,
10566 usage: Usage::zero(),
10567 stop_reason: StopReason::Stop,
10568 deferred: None,
10569 error_message: None,
10570 raw_stop_reason: None,
10571 end_turn: None,
10572 timestamp: 0,
10573 };
10574
10575 let comp = Arc::new(AssistantMessageComponent::new(
10578 AssistantMessageOptions::default(),
10579 ));
10580 comp.set_streaming(true);
10581 comp.update_blocks(&assistant_blocks(&assistant));
10582 let chat = Arc::new(Container::new());
10583 chat.add_child(comp.clone());
10584 *state.current_assistant.lock().unwrap() = Some(comp);
10585
10586 for c in &assistant.content {
10588 if let Content::ToolCall(tc) = c {
10589 let mut tools = state.tool_components.lock().unwrap();
10590 if !tools.contains_key(&tc.id) {
10591 let tc_comp = Arc::new(ToolExecutionComponent::new(
10592 &tc.name,
10593 &tc.arguments.to_string(),
10594 ));
10595 tc_comp.set_running();
10596 chat.add_child(tc_comp.clone());
10597 tools.insert(tc.id.clone(), tc_comp);
10598 }
10599 }
10600 }
10601
10602 let rendered = chat.render(80);
10606 let joined: String = rendered.join("\n");
10607 assert!(
10608 joined.contains("Hello."),
10609 "assistant text not rendered: {joined}"
10610 );
10611 assert!(
10612 joined.contains("Reasoning about the reply."),
10613 "thinking block not rendered: {joined}"
10614 );
10615 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
10616 assert!(state.current_assistant.lock().unwrap().is_some());
10617
10618 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
10620 ended.set_result("hi", false);
10621 assert!(state.tool_components.lock().unwrap().is_empty());
10622
10623 assert!(state.try_start_working());
10627 assert!(
10628 !state.try_start_working(),
10629 "a second submit must be rejected"
10630 );
10631 state.set_status(RunStatus::Idle);
10632 state.set_status(RunStatus::Working);
10633 assert!(state.editor.working().is_some());
10635 assert_eq!(state.status_container.child_count(), 0);
10636 assert!(state.footer.get_status().is_empty());
10637 state.show_retry(3, 10, 8_000);
10638 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
10639 assert!(retry_status.contains("Retrying (3/10)"));
10640 state.set_status(RunStatus::Working);
10641 {
10642 let mut bash = state.bash_components.lock().unwrap();
10643 bash.insert(
10644 "bash-1".into(),
10645 Arc::new(BashExecutionComponent::new("one")),
10646 );
10647 bash.insert(
10648 "bash-2".into(),
10649 Arc::new(BashExecutionComponent::new("two")),
10650 );
10651 }
10652 state.sync_working_loader_with_bash();
10653 assert_eq!(state.status_container.child_count(), 0);
10654 state.bash_components.lock().unwrap().remove("bash-1");
10655 state.sync_working_loader_with_bash();
10656 assert_eq!(state.status_container.child_count(), 0);
10657 state.bash_components.lock().unwrap().remove("bash-2");
10658 state.sync_working_loader_with_bash();
10659 assert_eq!(state.status_container.child_count(), 0);
10662 assert!(state.editor.working().is_some());
10663
10664 state.set_status(RunStatus::Aborting);
10665 assert_eq!(state.status_container.child_count(), 0);
10666 assert!(!state.loader.is_running());
10667 }
10668
10669 #[test]
10670 fn fresh_launch_does_not_restore_old_history() {
10671 let fresh = Args::default();
10672 assert!(!launch_restores_history(&fresh));
10673
10674 let continued = Args {
10675 continue_session: true,
10676 ..Args::default()
10677 };
10678 assert!(launch_restores_history(&continued));
10679
10680 let selected = Args {
10681 session: Some("session-id".into()),
10682 ..Args::default()
10683 };
10684 assert!(launch_restores_history(&selected));
10685 }
10686
10687 #[test]
10688 fn context_badge_ignores_aborted_and_error_turns() {
10689 use rpi_ai::types::{Api, StopReason, Usage};
10690
10691 let footer = Arc::new(FooterComponent::new());
10692 footer.set_context_window(100_000);
10693
10694 let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10696 ok.stop_reason = StopReason::Stop;
10697 ok.usage = Usage {
10698 input: 25_000,
10699 ..Usage::zero()
10700 };
10701 record_context_usage(&footer, &ok);
10702 let stats = strip_ansi(&footer.render(120).join("\n"));
10703 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10704
10705 let mut aborted =
10707 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10708 aborted.stop_reason = StopReason::Aborted;
10709 aborted.usage = Usage {
10710 input: 90_000,
10711 ..Usage::zero()
10712 };
10713 record_context_usage(&footer, &aborted);
10714 let stats = strip_ansi(&footer.render(120).join("\n"));
10715 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10716
10717 let mut errored =
10719 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10720 errored.stop_reason = StopReason::Error;
10721 errored.usage = Usage {
10722 input: 90_000,
10723 ..Usage::zero()
10724 };
10725 record_context_usage(&footer, &errored);
10726 let stats = strip_ansi(&footer.render(120).join("\n"));
10727 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10728 }
10729
10730 #[test]
10731 fn test_short_model_name() {
10732 assert_eq!(
10733 short_model_name("anthropic:claude-sonnet-5"),
10734 "claude-sonnet-5"
10735 );
10736 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
10737 }
10738
10739 #[test]
10740 fn model_selector_items_are_deduplicated_and_provider_qualified() {
10741 use rpi_ai::{Api, Model};
10742
10743 let mut gateway = Model::new(
10744 "gpt-5.6-sol",
10745 "GPT 5.6 Sol",
10746 Api::OpenaiCompletions,
10747 "routeryo-copy",
10748 "https://gateway.example.com",
10749 );
10750 let duplicate = gateway.clone();
10751 let anthropic = Model::new(
10752 "claude-sonnet-5",
10753 "Claude Sonnet 5",
10754 Api::AnthropicMessages,
10755 "anthropic",
10756 "https://api.anthropic.com",
10757 );
10758 gateway.headers = Some(std::collections::BTreeMap::from([(
10759 "authorization".into(),
10760 "Bearer test".into(),
10761 )]));
10762
10763 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
10764 assert_eq!(items.len(), 2);
10765 assert_eq!(items[0].value, "gpt-5.6-sol");
10766 assert_eq!(items[0].label, "GPT 5.6 Sol");
10767 assert_eq!(
10768 items[0].description.as_deref(),
10769 Some("routeryo-copy/gpt-5.6-sol (current)")
10770 );
10771 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
10772 }
10773
10774 #[test]
10775 fn model_selector_search_text_covers_provider_name_and_qualified_id() {
10776 use rpi_ai::{Api, Model};
10777
10778 let anthropic = Model::new(
10779 "claude-sonnet-5",
10780 "Claude Sonnet 5",
10781 Api::AnthropicMessages,
10782 "anthropic",
10783 "https://api.anthropic.com",
10784 );
10785 let items = model_selector_items(&[anthropic], "");
10786 let search = items[0].search_key();
10787 assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
10790 assert!(search.contains("Claude Sonnet 5"), "{search}");
10791
10792 use rpi_tui::fuzzy_filter;
10795 for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
10796 let matched = fuzzy_filter(&items, query, |item| item.search_key());
10797 assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
10798 }
10799 }
10800
10801 #[test]
10802 fn model_selector_match_accepts_bare_and_qualified_ids() {
10803 use rpi_ai::{Api, Model};
10804
10805 let gateway = Model::new(
10806 "gpt-5.6-sol",
10807 "GPT 5.6 Sol",
10808 Api::OpenaiCompletions,
10809 "routeryo-copy",
10810 "https://gateway.example.com",
10811 );
10812 let anthropic = Model::new(
10813 "claude-sonnet-5",
10814 "Claude Sonnet 5",
10815 Api::AnthropicMessages,
10816 "anthropic",
10817 "https://api.anthropic.com",
10818 );
10819 let catalog = [gateway, anthropic];
10820 assert_eq!(
10821 find_model_selector_match(&catalog, "gpt-5.6-sol")
10822 .unwrap()
10823 .provider,
10824 "routeryo-copy"
10825 );
10826 assert_eq!(
10827 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
10828 .unwrap()
10829 .id,
10830 "gpt-5.6-sol"
10831 );
10832 assert_eq!(
10833 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
10834 .unwrap()
10835 .id,
10836 "claude-sonnet-5"
10837 );
10838 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
10839 }
10840
10841 #[test]
10842 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
10843 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
10844
10845 let failed = AssistantMessage {
10846 role: AssistantRole,
10847 content: Vec::new(),
10848 api: rpi_ai::Api::AnthropicMessages,
10849 provider: "anthropic".into(),
10850 model: "claude-sonnet-5".into(),
10851 response_model: None,
10852 response_id: None,
10853 usage: Usage::zero(),
10854 stop_reason: StopReason::Error,
10855 deferred: None,
10856 error_message: Some("upstream returned 401".into()),
10857 raw_stop_reason: None,
10858 end_turn: None,
10859 timestamp: 0,
10860 };
10861 assert_eq!(
10862 assistant_error_text(&failed).as_deref(),
10863 Some("upstream returned 401")
10864 );
10865
10866 let mut no_detail = failed;
10867 no_detail.error_message = Some(" ".into());
10868 assert_eq!(
10869 assistant_error_text(&no_detail).as_deref(),
10870 Some("Provider request failed.")
10871 );
10872
10873 let mut aborted = no_detail;
10874 aborted.stop_reason = StopReason::Aborted;
10875 aborted.error_message = Some("abort error: Request aborted".into());
10876 assert_eq!(
10877 assistant_error_text(&aborted).as_deref(),
10878 Some("abort error: Request aborted")
10879 );
10880
10881 aborted.error_message = None;
10882 assert_eq!(
10883 assistant_error_text(&aborted).as_deref(),
10884 Some("Request aborted.")
10885 );
10886 }
10887
10888 #[test]
10889 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
10890 assert_eq!(
10891 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
10892 "405\nMethod Not Allowed"
10893 );
10894 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
10895 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
10896 let long = "x".repeat(20_000);
10897 let cleaned = sanitize_error_message(&long);
10898 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
10899 assert!(cleaned.ends_with('…'));
10900 }
10901
10902 #[test]
10903 fn test_cycle_next_model_wraps_around() {
10904 use rpi_ai::{Api, Model};
10905 let mk = |id: &str| {
10906 Model::new(
10907 id,
10908 id,
10909 Api::AnthropicMessages,
10910 "anthropic",
10911 "https://api.anthropic.com",
10912 )
10913 };
10914 let catalog = [mk("a"), mk("b"), mk("c")];
10915 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
10917 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
10918 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
10920 let empty: Vec<Model> = vec![];
10922 assert!(cycle_next_model(&empty, "a").is_none());
10923 }
10924
10925 #[test]
10926 fn test_cycle_prev_model_wraps_around() {
10927 use rpi_ai::{Api, Model};
10928 let mk = |id: &str| {
10929 Model::new(
10930 id,
10931 id,
10932 Api::AnthropicMessages,
10933 "anthropic",
10934 "https://api.anthropic.com",
10935 )
10936 };
10937 let catalog = [mk("a"), mk("b"), mk("c")];
10938 assert_eq!(cycle_prev_model(&catalog, "b").unwrap().id, "a");
10940 assert_eq!(cycle_prev_model(&catalog, "a").unwrap().id, "c");
10941 assert_eq!(cycle_prev_model(&catalog, "zzz").unwrap().id, "a");
10943 let empty: Vec<Model> = vec![];
10945 assert!(cycle_prev_model(&empty, "a").is_none());
10946 }
10947
10948 #[test]
10949 fn test_autocomplete_slash_suggestions_render() {
10950 let state = Arc::new(TuiState {
10953 current_assistant: Arc::new(std::sync::Mutex::new(None)),
10954 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10955 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10956 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10957 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10958 show_terminal_progress: true,
10959 status: std::sync::Mutex::new(RunStatus::Idle),
10960 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10961 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10962 editor_text: rpi_extensions::EditorTextMailbox::new(),
10963 auto_send: std::sync::Mutex::new(None),
10964 last_editor_text: std::sync::Mutex::new(String::new()),
10965 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10966 js_preparation_cancel: std::sync::Mutex::new(None),
10967 user_bash_cancel: std::sync::Mutex::new(None),
10968 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10969 footer: Arc::new(FooterComponent::new()),
10970 status_container: Arc::new(Container::new()),
10971 chat_container: Arc::new(Container::new()),
10972 loader: Arc::new(Loader::new()),
10973 editor: Arc::new(Editor::simple()),
10974 last_assistant_text: std::sync::Mutex::new(String::new()),
10975 active_selector: std::sync::Mutex::new(None),
10976 active_extension_editor: std::sync::Mutex::new(None),
10977 active_extension_input: std::sync::Mutex::new(None),
10978 active_extension_cancel: std::sync::Mutex::new(None),
10979 autocomplete: AutocompleteManager::new(),
10980 autocomplete_container: Arc::new(Container::new()),
10981 autocomplete_max_visible: 5,
10982 pending_images: std::sync::Mutex::new(Vec::new()),
10983 theme_manager: Arc::new(ThemeManager::new()),
10984 tui: None,
10985 current_model_id: std::sync::Mutex::new(String::new()),
10986 show_images: std::sync::Mutex::new(true),
10987 cache_miss_notices: std::sync::Mutex::new(false),
10988 history: std::sync::Mutex::new(Vec::new()),
10989 history_index: std::sync::Mutex::new(-1),
10990 history_draft: std::sync::Mutex::new(None),
10991 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10992 scoped_edit: std::sync::Mutex::new(None),
10993 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10994 extension_session: Arc::new(std::sync::Mutex::new(
10995 rpi_extensions::ExtensionSession::none(),
10996 )),
10997 search: Arc::new(AltScreenSearch::new()),
10998 search_bar: Arc::new(SearchBar::new()),
10999 pending_container: Arc::new(Container::new()),
11000 dequeue_hint: String::new(),
11001 pending_snapshot: std::sync::Mutex::new(
11002 rpi_harness::agent_harness::QueuedMessages::default(),
11003 ),
11004 selection_start: std::sync::Mutex::new(None),
11005 selection_end: std::sync::Mutex::new(None),
11006 });
11007 {
11008 let mut combined = CombinedAutocompleteProvider::new();
11009 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11010 build_builtin_registry().visible_entries(),
11011 )));
11012 state.autocomplete.set_provider(Arc::new(combined));
11013 }
11014
11015 let editor = Arc::new(Editor::simple());
11016 editor.set_text("/he");
11017 editor.set_cursor(0, 3);
11018 refresh_autocomplete(&state, &editor);
11019 let lines = state.autocomplete_container.render(80);
11020 let joined: String = lines.join("\n");
11021 assert!(
11022 joined.contains("/help"),
11023 "slash suggestions not rendered: {joined}"
11024 );
11025
11026 editor.set_text("hello");
11028 editor.set_cursor(0, 5);
11029 refresh_autocomplete(&state, &editor);
11030 assert!(state.autocomplete_container.render(80).is_empty());
11031 }
11032
11033 #[test]
11034 fn test_select_list_swap_restores_editor() {
11035 let state = Arc::new(TuiState {
11039 current_assistant: Arc::new(std::sync::Mutex::new(None)),
11040 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11041 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11042 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11043 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11044 show_terminal_progress: true,
11045 status: std::sync::Mutex::new(RunStatus::Idle),
11046 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11047 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11048 editor_text: rpi_extensions::EditorTextMailbox::new(),
11049 auto_send: std::sync::Mutex::new(None),
11050 last_editor_text: std::sync::Mutex::new(String::new()),
11051 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11052 js_preparation_cancel: std::sync::Mutex::new(None),
11053 user_bash_cancel: std::sync::Mutex::new(None),
11054 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11055 footer: Arc::new(FooterComponent::new()),
11056 status_container: Arc::new(Container::new()),
11057 chat_container: Arc::new(Container::new()),
11058 loader: Arc::new(Loader::new()),
11059 editor: Arc::new(Editor::simple()),
11060 last_assistant_text: std::sync::Mutex::new(String::new()),
11061 active_selector: std::sync::Mutex::new(None),
11062 active_extension_editor: std::sync::Mutex::new(None),
11063 active_extension_input: std::sync::Mutex::new(None),
11064 active_extension_cancel: std::sync::Mutex::new(None),
11065 autocomplete: AutocompleteManager::new(),
11066 autocomplete_container: Arc::new(Container::new()),
11067 autocomplete_max_visible: 5,
11068 pending_images: std::sync::Mutex::new(Vec::new()),
11069 theme_manager: Arc::new(ThemeManager::new()),
11070 tui: None,
11071 current_model_id: std::sync::Mutex::new(String::new()),
11072 show_images: std::sync::Mutex::new(true),
11073 cache_miss_notices: std::sync::Mutex::new(false),
11074 history: std::sync::Mutex::new(Vec::new()),
11075 history_index: std::sync::Mutex::new(-1),
11076 history_draft: std::sync::Mutex::new(None),
11077 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11078 scoped_edit: std::sync::Mutex::new(None),
11079 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11080 extension_session: Arc::new(std::sync::Mutex::new(
11081 rpi_extensions::ExtensionSession::none(),
11082 )),
11083 search: Arc::new(AltScreenSearch::new()),
11084 search_bar: Arc::new(SearchBar::new()),
11085 pending_container: Arc::new(Container::new()),
11086 dequeue_hint: String::new(),
11087 pending_snapshot: std::sync::Mutex::new(
11088 rpi_harness::agent_harness::QueuedMessages::default(),
11089 ),
11090 selection_start: std::sync::Mutex::new(None),
11091 selection_end: std::sync::Mutex::new(None),
11092 });
11093 let editor_container = Arc::new(Container::new());
11094 let editor = Arc::new(Editor::simple());
11095 editor_container.add_child(editor.clone());
11096 assert!(!state.selector_open());
11097
11098 let tui_terminal = Box::new(ProcessTerminal::new());
11099 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
11100 let list = Arc::new(SelectList::new(
11101 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
11102 5,
11103 ));
11104 open_selector(
11105 &state,
11106 &editor_container,
11107 &editor,
11108 &tui,
11109 list,
11110 SelectorKind::Theme,
11111 );
11112 assert!(state.selector_open());
11113 assert_eq!(editor_container.child_count(), 1);
11115
11116 close_selector(&state, &editor_container, &editor, &tui);
11117 assert!(!state.selector_open());
11118 assert_eq!(editor_container.child_count(), 1);
11120 }
11121
11122 #[test]
11123 fn test_message_history_browse_restores_draft() {
11124 let state = Arc::new(TuiState {
11128 current_assistant: Arc::new(std::sync::Mutex::new(None)),
11129 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11130 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11131 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11132 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11133 show_terminal_progress: true,
11134 status: std::sync::Mutex::new(RunStatus::Idle),
11135 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11136 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11137 editor_text: rpi_extensions::EditorTextMailbox::new(),
11138 auto_send: std::sync::Mutex::new(None),
11139 last_editor_text: std::sync::Mutex::new(String::new()),
11140 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11141 js_preparation_cancel: std::sync::Mutex::new(None),
11142 user_bash_cancel: std::sync::Mutex::new(None),
11143 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11144 footer: Arc::new(FooterComponent::new()),
11145 status_container: Arc::new(Container::new()),
11146 chat_container: Arc::new(Container::new()),
11147 loader: Arc::new(Loader::new()),
11148 editor: Arc::new(Editor::simple()),
11149 last_assistant_text: std::sync::Mutex::new(String::new()),
11150 active_selector: std::sync::Mutex::new(None),
11151 active_extension_editor: std::sync::Mutex::new(None),
11152 active_extension_input: std::sync::Mutex::new(None),
11153 active_extension_cancel: std::sync::Mutex::new(None),
11154 autocomplete: AutocompleteManager::new(),
11155 autocomplete_container: Arc::new(Container::new()),
11156 autocomplete_max_visible: 5,
11157 pending_images: std::sync::Mutex::new(Vec::new()),
11158 theme_manager: Arc::new(ThemeManager::new()),
11159 tui: None,
11160 current_model_id: std::sync::Mutex::new(String::new()),
11161 show_images: std::sync::Mutex::new(true),
11162 cache_miss_notices: std::sync::Mutex::new(false),
11163 history: std::sync::Mutex::new(Vec::new()),
11164 history_index: std::sync::Mutex::new(-1),
11165 history_draft: std::sync::Mutex::new(None),
11166 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11167 scoped_edit: std::sync::Mutex::new(None),
11168 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11169 extension_session: Arc::new(std::sync::Mutex::new(
11170 rpi_extensions::ExtensionSession::none(),
11171 )),
11172 search: Arc::new(AltScreenSearch::new()),
11173 search_bar: Arc::new(SearchBar::new()),
11174 pending_container: Arc::new(Container::new()),
11175 dequeue_hint: String::new(),
11176 pending_snapshot: std::sync::Mutex::new(
11177 rpi_harness::agent_harness::QueuedMessages::default(),
11178 ),
11179 selection_start: std::sync::Mutex::new(None),
11180 selection_end: std::sync::Mutex::new(None),
11181 });
11182 let editor = Arc::new(Editor::simple());
11183
11184 push_history(&state, "first message");
11185 push_history(&state, "second message");
11186 push_history(&state, "second message");
11188 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
11190 assert_eq!(state.history.lock().unwrap()[0], "second message");
11191
11192 editor.set_text("half-typed");
11194 editor.set_cursor(0, 11);
11195
11196 navigate_history(&state, &editor, -1);
11198 assert_eq!(editor.get_text(), "second message");
11199 assert_eq!(*state.history_index.lock().unwrap(), 0);
11200 navigate_history(&state, &editor, -1);
11202 assert_eq!(editor.get_text(), "first message");
11203 assert_eq!(*state.history_index.lock().unwrap(), 1);
11204 navigate_history(&state, &editor, -1);
11206 assert_eq!(editor.get_text(), "first message");
11207 navigate_history(&state, &editor, 1);
11209 assert_eq!(editor.get_text(), "second message");
11210 navigate_history(&state, &editor, 1);
11212 assert_eq!(editor.get_text(), "half-typed");
11213 assert_eq!(*state.history_index.lock().unwrap(), -1);
11214 }
11215
11216 #[test]
11217 fn test_accept_top_suggestion_replaces_prefix() {
11218 let state = Arc::new(TuiState {
11220 current_assistant: Arc::new(std::sync::Mutex::new(None)),
11221 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11222 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11223 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11224 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11225 show_terminal_progress: true,
11226 status: std::sync::Mutex::new(RunStatus::Idle),
11227 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11228 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11229 editor_text: rpi_extensions::EditorTextMailbox::new(),
11230 auto_send: std::sync::Mutex::new(None),
11231 last_editor_text: std::sync::Mutex::new(String::new()),
11232 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11233 js_preparation_cancel: std::sync::Mutex::new(None),
11234 user_bash_cancel: std::sync::Mutex::new(None),
11235 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11236 footer: Arc::new(FooterComponent::new()),
11237 status_container: Arc::new(Container::new()),
11238 chat_container: Arc::new(Container::new()),
11239 loader: Arc::new(Loader::new()),
11240 editor: Arc::new(Editor::simple()),
11241 last_assistant_text: std::sync::Mutex::new(String::new()),
11242 active_selector: std::sync::Mutex::new(None),
11243 active_extension_editor: std::sync::Mutex::new(None),
11244 active_extension_input: std::sync::Mutex::new(None),
11245 active_extension_cancel: std::sync::Mutex::new(None),
11246 autocomplete: AutocompleteManager::new(),
11247 autocomplete_container: Arc::new(Container::new()),
11248 autocomplete_max_visible: 5,
11249 pending_images: std::sync::Mutex::new(Vec::new()),
11250 theme_manager: Arc::new(ThemeManager::new()),
11251 tui: None,
11252 current_model_id: std::sync::Mutex::new(String::new()),
11253 show_images: std::sync::Mutex::new(true),
11254 cache_miss_notices: std::sync::Mutex::new(false),
11255 history: std::sync::Mutex::new(Vec::new()),
11256 history_index: std::sync::Mutex::new(-1),
11257 history_draft: std::sync::Mutex::new(None),
11258 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11259 scoped_edit: std::sync::Mutex::new(None),
11260 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11261 extension_session: Arc::new(std::sync::Mutex::new(
11262 rpi_extensions::ExtensionSession::none(),
11263 )),
11264 search: Arc::new(AltScreenSearch::new()),
11265 search_bar: Arc::new(SearchBar::new()),
11266 pending_container: Arc::new(Container::new()),
11267 dequeue_hint: String::new(),
11268 pending_snapshot: std::sync::Mutex::new(
11269 rpi_harness::agent_harness::QueuedMessages::default(),
11270 ),
11271 selection_start: std::sync::Mutex::new(None),
11272 selection_end: std::sync::Mutex::new(None),
11273 });
11274 {
11275 let mut combined = CombinedAutocompleteProvider::new();
11276 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11277 build_builtin_registry().visible_entries(),
11278 )));
11279 state.autocomplete.set_provider(Arc::new(combined));
11280 }
11281 let editor = Arc::new(Editor::simple());
11282 editor.set_text("/he");
11283 editor.set_cursor(0, 3);
11284 refresh_autocomplete(&state, &editor);
11285 let accepted = accept_top_suggestion(&state, &editor);
11286 assert!(accepted, "should accept the top suggestion");
11287 let text = editor.get_text();
11288 assert!(
11289 text.starts_with("/help"),
11290 "editor text should start with /help, got {text}"
11291 );
11292 }
11293
11294 #[test]
11295 fn configured_key_parser_supports_native_notation() {
11296 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
11297 assert_eq!(combo.code, KeyCode::Char('g'));
11298 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
11299 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
11300 assert_eq!(combo.code, KeyCode::BackTab);
11301 }
11302
11303 #[test]
11304 fn double_escape_trigger_has_half_second_window() {
11305 let now = std::time::Instant::now();
11306 assert!(!double_escape_trigger(None, now));
11307 assert!(double_escape_trigger(
11308 Some(now - std::time::Duration::from_millis(500)),
11309 now
11310 ));
11311 assert!(!double_escape_trigger(
11312 Some(now - std::time::Duration::from_millis(501)),
11313 now
11314 ));
11315 }
11316
11317 #[test]
11318 fn ctrl_c_clears_first_and_only_exits_inside_the_window() {
11319 let now = std::time::Instant::now();
11323 assert_eq!(ctrl_c_action(None, now), CtrlCAction::Clear);
11324 assert_eq!(
11325 ctrl_c_action(Some(now - std::time::Duration::from_millis(500)), now),
11326 CtrlCAction::Exit
11327 );
11328 assert_eq!(
11330 ctrl_c_action(Some(now - std::time::Duration::from_millis(501)), now),
11331 CtrlCAction::Clear
11332 );
11333 }
11334
11335 #[test]
11336 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
11337 use std::sync::atomic::{AtomicUsize, Ordering};
11343
11344 let editor = rpi_tui::Editor::simple();
11345 let submits = Arc::new(AtomicUsize::new(0));
11346 let submits_for_cb = submits.clone();
11347 editor.on_submit(Arc::new(move |_text: &str| {
11348 submits_for_cb.fetch_add(1, Ordering::SeqCst);
11349 }));
11350
11351 let now = std::time::Instant::now();
11354 let mut last_text_key_at: Option<std::time::Instant> = None;
11355 let block = ["l1", "l2", "l3"];
11356 for (index, line) in block.iter().enumerate() {
11357 for ch in line.chars() {
11358 editor.insert(&ch.to_string());
11359 last_text_key_at = Some(now);
11360 }
11361 if index + 1 < block.len() {
11362 let more_queued = true;
11364 assert!(
11365 enter_is_paste_burst(last_text_key_at, now, more_queued),
11366 "pasted newline {index} must not submit"
11367 );
11368 editor.insert("\n");
11369 last_text_key_at = Some(now);
11370 }
11371 }
11372 assert_eq!(editor.get_text(), "l1\nl2\nl3");
11373 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
11374
11375 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
11377 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
11378 editor.handle_key(crossterm::event::KeyEvent::new(
11379 KeyCode::Enter,
11380 KeyModifiers::NONE,
11381 ));
11382 assert_eq!(
11383 submits.load(Ordering::SeqCst),
11384 1,
11385 "typed Enter submits once"
11386 );
11387 }
11388
11389 #[test]
11390 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
11391 let editor = rpi_tui::Editor::simple();
11394 editor.insert("alpha\r\nbeta\r\n");
11395 assert_eq!(editor.get_text(), "alpha\nbeta\n");
11396 assert!(!editor.get_text().contains('\r'));
11397 }
11398
11399 #[test]
11400 fn pasted_enter_is_not_a_submit() {
11401 let now = std::time::Instant::now();
11402
11403 assert!(enter_is_paste_burst(None, now, true));
11405
11406 assert!(enter_is_paste_burst(
11409 Some(now - std::time::Duration::from_millis(1)),
11410 now,
11411 false
11412 ));
11413 assert!(enter_is_paste_burst(
11414 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
11415 now,
11416 false
11417 ));
11418
11419 assert!(!enter_is_paste_burst(None, now, false));
11421 assert!(!enter_is_paste_burst(
11422 Some(now - PASTE_BURST_GAP),
11423 now,
11424 false
11425 ));
11426 assert!(!enter_is_paste_burst(
11427 Some(now - std::time::Duration::from_millis(120)),
11428 now,
11429 false
11430 ));
11431 }
11432
11433 #[test]
11434 fn paste_probe_ignores_the_enter_keys_own_release() {
11435 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
11436 let release =
11437 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
11438
11439 let mut queued = vec![Event::Key(release)].into_iter();
11442 let (more, stashed) = probe_paste_input(None, || queued.next());
11443 assert!(!more, "a key release alone is not queued paste input");
11444 assert!(stashed.is_none());
11445
11446 let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
11449 let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
11450 let (more, stashed) = probe_paste_input(None, || queued.next());
11451 assert!(more, "a queued key press is paste continuation");
11452 assert!(matches!(stashed, Some(Event::Key(_))));
11453
11454 let (more, stashed) = probe_paste_input(None, || None);
11456 assert!(!more);
11457 assert!(stashed.is_none());
11458
11459 let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
11461 panic!("must not read when an event is already stashed")
11462 });
11463 assert!(more);
11464 assert!(matches!(stashed, Some(Event::Key(_))));
11465 }
11466
11467 #[test]
11470 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
11471 let flat = rpi_extensions::UiDialogRequest {
11472 request_id: "r1".into(),
11473 tool_call_id: Some("c1".into()),
11474 ui: serde_json::json!({
11475 "kind": "selector",
11476 "id": "proxy_port",
11477 "header": "Proxy",
11478 "question": "端口?",
11479 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
11480 "allowMultiple": true,
11481 "allowFreeform": false,
11482 "suggest": "1080"
11483 }),
11484 raw: serde_json::json!({}),
11485 };
11486 let prompt = parse_ask_user_prompt(&flat);
11487 assert_eq!(prompt.question_id, "proxy_port");
11488 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
11489 assert_eq!(prompt.options.len(), 2);
11490 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
11491 assert!(prompt.allow_multiple);
11492 assert!(!prompt.allow_freeform);
11493 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
11494
11495 let alias = rpi_extensions::UiDialogRequest {
11496 ui: serde_json::json!({
11497 "questions": [{"id": "q", "question": "Q?", "options": []}]
11498 }),
11499 ..flat.clone()
11500 };
11501 let prompt = parse_ask_user_prompt(&alias);
11502 assert_eq!(prompt.question_id, "q");
11503 assert!(prompt.allow_freeform, "no options defaults to freeform");
11504
11505 let confirm = rpi_extensions::UiDialogRequest {
11506 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
11507 ..flat.clone()
11508 };
11509 let prompt = parse_ask_user_prompt(&confirm);
11510 assert_eq!(prompt.kind, "confirm");
11511 assert_eq!(prompt.options.len(), 2);
11512 assert_eq!(prompt.options[0].0, "Yes");
11513 }
11514
11515 #[test]
11516 fn ask_user_bridge_answers_and_cancels_by_request_id() {
11517 let mailbox = rpi_extensions::UiDialogMailbox::new();
11518 mailbox.attach();
11519 let bridge = AskUserBridge::new(mailbox.clone());
11520
11521 mailbox
11523 .handle(serde_json::json!({
11524 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
11525 "ui": {"kind": "input", "question": "Port?"}
11526 }))
11527 .unwrap();
11528 mailbox
11529 .handle(serde_json::json!({
11530 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
11531 "ui": {"kind": "input", "question": "Level?"}
11532 }))
11533 .unwrap();
11534
11535 let first = bridge.take_pending().expect("r1 visible");
11536 assert_eq!(first.request_id, "r1");
11537 assert!(
11538 bridge.take_pending().is_none(),
11539 "only one request occupies the input slot"
11540 );
11541 bridge.respond("r1", serde_json::json!({"text": "7897"}));
11542 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
11543
11544 let second = bridge.take_pending().expect("r2 visible");
11545 assert_eq!(second.request_id, "r2");
11546 bridge.cancel("r2");
11547 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
11548
11549 mailbox
11551 .handle(serde_json::json!({
11552 "op": "open", "requestId": "r3",
11553 "ui": {"kind": "input", "question": "X?"}
11554 }))
11555 .unwrap();
11556 bridge.cancel_all();
11557 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
11558
11559 bridge.shutdown();
11561 assert!(mailbox
11562 .handle(serde_json::json!({
11563 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
11564 }))
11565 .is_err());
11566 }
11567}