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) -> bool {
3175 if *state.status.lock().unwrap() == RunStatus::Working {
3176 state.set_status(RunStatus::Aborting);
3177 let _ = lane.abort().await;
3178 }
3179 let cwd_str = cwd.to_string_lossy().to_string();
3180 match crate::session::open_session_by_id(id, &cwd_str).await {
3181 Ok(new_session) => {
3182 let _ = harness.set_session(new_session).await;
3183 chat.clear();
3184 add_welcome_message(chat);
3185 render_session_history(
3186 harness,
3187 chat,
3188 state.markdown_transformer(),
3189 Some(state.extension_session.clone()),
3190 )
3191 .await;
3192 state.set_status(RunStatus::Idle);
3193 add_note_message(chat, &format!("Switched to session {id}."));
3194 true
3195 }
3196 Err(e) => {
3197 state.set_status(RunStatus::Idle);
3198 add_error_message(chat, &format!("Could not open session {id}: {e}"));
3199 false
3200 }
3201 }
3202}
3203
3204async fn import_session(
3208 harness: &AgentHarness,
3209 lane: &Arc<dyn AgentLane>,
3210 path: &str,
3211 cwd: &std::path::Path,
3212 chat: &Arc<Container>,
3213 state: &Arc<TuiState>,
3214) {
3215 use std::path::Path as FsPath;
3216
3217 let src = FsPath::new(path);
3218 if !src.is_file() {
3219 add_error_message(chat, &format!("Import source not found: {path}"));
3220 return;
3221 }
3222 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3223 add_error_message(chat, "Import source has no file name.");
3224 return;
3225 };
3226 if !fname.ends_with(".jsonl") {
3227 add_error_message(chat, "Import source must be a .jsonl session file.");
3228 return;
3229 }
3230 let dir = crate::session::default_session_dir(cwd);
3231 if let Err(e) = std::fs::create_dir_all(&dir) {
3232 add_error_message(chat, &format!("Could not create session dir: {e}"));
3233 return;
3234 }
3235 let dest = dir.join(fname);
3236 match std::fs::copy(src, &dest) {
3237 Ok(_) => {
3238 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3239 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3240 add_note_message(chat, &format!("Imported session from {path}"));
3241 }
3242 }
3243 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3244 }
3245}
3246
3247async fn fork_session(
3248 harness: &AgentHarness,
3249 cwd: &std::path::Path,
3250 chat: &Arc<Container>,
3251 state: &Arc<TuiState>,
3252) {
3253 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3254 use rpi_tools::FileSystem;
3255
3256 let cwd_str = cwd.to_string_lossy().to_string();
3257 let dir = crate::session::default_session_dir(cwd);
3258 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3259 let fs: Arc<dyn FileSystem> = env.clone();
3260 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3261 fs,
3262 sessions_root: dir.to_string_lossy().into_owned(),
3263 clock: Arc::new(rpi_harness::session::memory::SystemClock),
3264 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3265 });
3266 let id = harness.session().storage().metadata().id.clone();
3269 let metas = match crate::session::list_session_metadata(&cwd_str).await {
3270 Ok(m) => m,
3271 Err(e) => {
3272 add_error_message(chat, &format!("Could not list sessions: {e}"));
3273 return;
3274 }
3275 };
3276 let Some(source) = metas.iter().find(|m| m.id == id) else {
3277 add_error_message(chat, &format!("Current session {id} not found on disk."));
3278 return;
3279 };
3280 let fork_storage = match repo
3281 .fork_typed(
3282 source,
3283 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3284 id: None,
3285 parent_session_id: Some(source.id.clone()),
3286 cwd: cwd_str.clone(),
3287 metadata: None,
3288 },
3289 &rpi_harness::session::types::ForkOptions::default(),
3290 )
3291 .await
3292 {
3293 Ok(s) => s,
3294 Err(e) => {
3295 add_error_message(chat, &format!("Could not fork session: {e}"));
3296 return;
3297 }
3298 };
3299 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3300 let _ = harness.set_session(new_session).await;
3301 chat.clear();
3302 add_welcome_message(chat);
3303 render_session_history(
3304 harness,
3305 chat,
3306 state.markdown_transformer(),
3307 Some(state.extension_session.clone()),
3308 )
3309 .await;
3310 state.set_status(RunStatus::Idle);
3311 add_note_message(chat, "Forked into a new session.");
3312}
3313
3314async fn render_session_history(
3324 harness: &AgentHarness,
3325 chat: &Arc<Container>,
3326 transformer: Option<MarkdownTransformer>,
3327 extension_session: Option<crate::session::ExtensionSessionCell>,
3328) {
3329 let tree = harness.session().view("main");
3330 let entries = match tree
3331 .find_entries(&EntryQuery {
3332 entry_type: None,
3333 custom_type: None,
3334 order: Some(EntryOrder::OldestFirst),
3338 limit: None,
3339 cursor: None,
3340 })
3341 .await
3342 {
3343 Ok(e) => e,
3344 Err(_) => return,
3345 };
3346 let mut rendered_any = false;
3347 for e in entries {
3348 match e {
3349 Entry::Message(me) => match &me.message {
3350 AgentMessage::User(u) => {
3351 add_user_message(chat, &user_message_text(u));
3352 rendered_any = true;
3353 }
3354 AgentMessage::Assistant(a) => {
3355 let comp = Arc::new(AssistantMessageComponent::new(
3356 AssistantMessageOptions::default(),
3357 ));
3358 if let Some(t) = &transformer {
3359 comp.set_markdown_transformer(Some(t.clone()));
3360 }
3361 comp.update_blocks(&assistant_blocks(a));
3362 chat.add_child(comp);
3363 chat.add_child(Arc::new(Spacer::new(1)));
3366 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3367 add_note_message(chat, &text);
3368 }
3369 if let Some(error) = assistant_error_text(a) {
3370 add_error_message(chat, &error);
3371 }
3372 rendered_any = true;
3373 }
3374 AgentMessage::ToolResult(result) => {
3375 let comp = Arc::new(ToolExecutionComponent::new(&result.tool_name, ""));
3380 comp.set_result(&tool_result_message_text(result), result.is_error);
3381 if tool_result_requests_markdown(result.details.as_ref()) {
3382 comp.set_result_markdown(true);
3383 }
3384 chat.add_child(comp);
3385 chat.add_child(Arc::new(Spacer::new(1)));
3386 rendered_any = true;
3387 }
3388 AgentMessage::Custom(custom) => {
3389 if let Some(session) = &extension_session {
3390 if let Some(component) = extension_message_component(
3391 session,
3392 &custom.role,
3393 &serde_json::json!({
3394 "customType": custom.role,
3395 "content": custom.content,
3396 "details": custom.data,
3397 }),
3398 transformer.clone(),
3399 ) {
3400 chat.add_child(component);
3401 chat.add_child(Arc::new(Spacer::new(1)));
3402 rendered_any = true;
3403 continue;
3404 }
3405 }
3406 add_note_message(chat, &custom_message_fallback(&custom));
3407 rendered_any = true;
3408 }
3409 },
3410 Entry::Compaction(compaction) => {
3411 add_note_message(
3412 chat,
3413 &format!(
3414 "Compacted {} tokens: {}",
3415 compaction.tokens_before, compaction.summary
3416 ),
3417 );
3418 rendered_any = true;
3419 }
3420 Entry::BranchSummary(summary) => {
3421 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3422 rendered_any = true;
3423 }
3424 Entry::Custom(custom) => {
3425 let rendered = extension_session.as_ref().and_then(|session| {
3426 extension_entry_component(session, &custom.custom_type, custom.data.clone())
3427 });
3428 if let Some(component) = rendered {
3429 chat.add_child(component);
3430 chat.add_child(Arc::new(Spacer::new(1)));
3431 rendered_any = true;
3432 } else if let Some(text) =
3433 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3434 {
3435 add_note_message(chat, &text);
3436 rendered_any = true;
3437 }
3438 }
3439 Entry::ModelChange(change) => {
3440 add_note_message(
3441 chat,
3442 &format!("Model changed to {}:{}", change.provider, change.model_id),
3443 );
3444 rendered_any = true;
3445 }
3446 Entry::ThinkingLevel(change) => {
3447 add_note_message(
3448 chat,
3449 &format!("Thinking level: {:?}", change.thinking_level),
3450 );
3451 rendered_any = true;
3452 }
3453 Entry::ActiveTools(change) => {
3454 add_note_message(
3455 chat,
3456 &format!("Active tools: {}", change.active_tool_names.join(", ")),
3457 );
3458 rendered_any = true;
3459 }
3460 }
3461 }
3462 if rendered_any {
3463 }
3466}
3467
3468fn invoke_extension_renderer(
3469 session: &crate::session::ExtensionSessionCell,
3470 kind: rpi_extensions::RegisteredRendererKind,
3471 payload: &serde_json::Value,
3472) -> Option<serde_json::Value> {
3473 let snapshot = session.lock().ok()?.snapshot_arc()?;
3474 let input = serde_json::to_string(payload).ok()?;
3475 for renderer in snapshot.renderers_of(kind) {
3476 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3477 let mut out = rpi_plugin_sdk::StbString::empty();
3478 let rc = (renderer.render_fn)(
3479 rpi_plugin_sdk::StbStringRef::from_str(&input),
3480 &mut out as *mut rpi_plugin_sdk::StbString,
3481 renderer.user_data,
3482 );
3483 let text = if rc == 0 {
3484 Some(out.to_string_lossy())
3485 } else {
3486 None
3487 };
3488 out.free_with(Some(renderer.plugin_free_string));
3489 text
3490 }))
3491 .ok()
3492 .flatten();
3493 let Some(text) = outcome else { continue };
3494 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3495 return Some(value);
3496 }
3497 }
3498 None
3499}
3500
3501fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3502 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3503 let text = lines
3504 .iter()
3505 .filter_map(|line| line.as_str())
3506 .collect::<Vec<_>>()
3507 .join("\n");
3508 return Some(Arc::new(Text::new(text, 0, 0)));
3509 }
3510 let text = value.get("text").and_then(|v| v.as_str())?;
3511 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3512 let component = Arc::new(AssistantMessageComponent::new(
3513 AssistantMessageOptions::default(),
3514 ));
3515 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3516 Some(component)
3517 } else {
3518 Some(Arc::new(Text::new(text, 0, 0)))
3519 }
3520}
3521
3522fn extension_message_component(
3523 session: &crate::session::ExtensionSessionCell,
3524 custom_type: &str,
3525 payload: &serde_json::Value,
3526 transformer: Option<MarkdownTransformer>,
3527) -> Option<Arc<dyn rpi_tui::Component>> {
3528 let value = invoke_extension_renderer(
3529 session,
3530 rpi_extensions::RegisteredRendererKind::Message,
3531 payload,
3532 )?;
3533 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3534 let text = value.get("text").and_then(|v| v.as_str())?;
3535 let component = Arc::new(AssistantMessageComponent::new(
3536 AssistantMessageOptions::default(),
3537 ));
3538 if let Some(transformer) = transformer {
3539 component.set_markdown_transformer(Some(transformer));
3540 }
3541 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3542 return Some(component);
3543 }
3544 extension_text_component(&value)
3545 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
3546}
3547
3548fn extension_usage_text(
3551 session: Option<&crate::session::ExtensionSessionCell>,
3552 usage: &rpi_ai::types::Usage,
3553) -> Option<String> {
3554 let session = session?;
3555 let payload = serde_json::json!({
3556 "customType": "token-usage",
3557 "usage": usage,
3558 });
3559 let value = invoke_extension_renderer(
3560 session,
3561 rpi_extensions::RegisteredRendererKind::Message,
3562 &payload,
3563 )?;
3564 value
3565 .get("text")
3566 .and_then(|value| value.as_str())
3567 .filter(|text| !text.trim().is_empty())
3568 .map(ToOwned::to_owned)
3569}
3570
3571fn extension_entry_component(
3572 session: &crate::session::ExtensionSessionCell,
3573 custom_type: &str,
3574 data: Option<serde_json::Value>,
3575) -> Option<Arc<dyn rpi_tui::Component>> {
3576 let payload = serde_json::json!({
3577 "customType": custom_type,
3578 "data": data,
3579 });
3580 let value = invoke_extension_renderer(
3581 session,
3582 rpi_extensions::RegisteredRendererKind::Entry,
3583 &payload,
3584 )?;
3585 extension_text_component(&value)
3586}
3587
3588fn launch_restores_history(args: &Args) -> bool {
3594 args.continue_session
3595 || args.resume
3596 || args.session.is_some()
3597 || args.session_id.is_some()
3598 || args.fork.is_some()
3599}
3600
3601fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
3602 let content = custom
3603 .content
3604 .iter()
3605 .filter_map(|item| match item {
3606 Content::Text(text) => Some(text.text.as_str()),
3607 _ => None,
3608 })
3609 .collect::<Vec<_>>()
3610 .join("\n");
3611 if content.is_empty() {
3612 format!("{}: {}", custom.role, custom.data)
3613 } else {
3614 format!("{}: {}", custom.role, content)
3615 }
3616}
3617
3618fn find_git_head_path(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
3620 let mut dir = Some(cwd);
3621 while let Some(current) = dir {
3622 let head = current.join(".git").join("HEAD");
3623 if head.exists() {
3624 return Some(head);
3625 }
3626 dir = current.parent();
3627 }
3628 None
3629}
3630
3631fn git_branch_for(cwd: &std::path::Path) -> Option<String> {
3635 let mut dir = Some(cwd);
3636 while let Some(current) = dir {
3637 let head = current.join(".git").join("HEAD");
3638 if let Ok(contents) = std::fs::read_to_string(&head) {
3639 let trimmed = contents.trim();
3640 if let Some(reference) = trimmed.strip_prefix("ref: ") {
3641 let branch = reference
3642 .rsplit('/')
3643 .next()
3644 .unwrap_or(reference)
3645 .trim()
3646 .to_string();
3647 if !branch.is_empty() {
3648 return Some(branch);
3649 }
3650 }
3651 if !trimmed.is_empty() {
3652 return Some(trimmed.chars().take(7).collect());
3653 }
3654 }
3655 dir = current.parent();
3656 }
3657 None
3658}
3659
3660fn short_model_name(id: &str) -> String {
3663 id.rsplit([':', '/'])
3664 .next()
3665 .filter(|s| !s.is_empty())
3666 .unwrap_or(id)
3667 .to_string()
3668}
3669
3670#[derive(Clone, Copy, PartialEq, Eq)]
3676enum RunStatus {
3677 Idle,
3678 Working,
3679 Aborting,
3680}
3681
3682#[derive(Clone, Copy, PartialEq, Eq)]
3684enum SelectorKind {
3685 Model,
3687 Thinking,
3689 Tools,
3691 Images,
3693 Session,
3695 Theme,
3697 ScopedModels,
3699 Settings,
3701 Tree,
3703 Extension,
3705}
3706
3707#[derive(Clone)]
3714enum SelectorView {
3715 List(Arc<SelectList>),
3717 Searchable(Arc<SearchableSelectList>),
3719 Settings(Arc<SettingsList>),
3722}
3723
3724impl SelectorView {
3725 fn handle_key(&self, key: KeyEvent) {
3727 match self {
3728 Self::List(list) => list.handle_key(key),
3729 Self::Searchable(searchable) => searchable.handle_key(key),
3730 Self::Settings(settings) => settings.handle_key(key),
3731 }
3732 }
3733}
3734
3735#[derive(Debug, Clone)]
3746struct AutoSendPending {
3747 deadline: std::time::Instant,
3749 text: String,
3751}
3752
3753#[derive(Debug, PartialEq, Eq)]
3757enum AutoSendStep {
3758 Idle,
3760 Cancel,
3763 Submit,
3765 Wait(std::time::Duration),
3767}
3768
3769fn auto_send_step(
3771 pending: Option<&AutoSendPending>,
3772 current_text: &str,
3773 idle: bool,
3774 now: std::time::Instant,
3775) -> AutoSendStep {
3776 let Some(pending) = pending else {
3777 return AutoSendStep::Idle;
3778 };
3779 if current_text != pending.text || current_text.trim().is_empty() {
3782 return AutoSendStep::Cancel;
3783 }
3784 if !idle {
3785 return AutoSendStep::Cancel;
3786 }
3787 let remaining = pending.deadline.saturating_duration_since(now);
3788 if remaining.is_zero() {
3789 AutoSendStep::Submit
3790 } else {
3791 AutoSendStep::Wait(remaining)
3792 }
3793}
3794
3795struct TuiState {
3796 current_assistant: Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
3800 tool_components: Arc<std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>>,
3802 bash_components: Arc<std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>>,
3806 hide_thinking: Arc<std::sync::Mutex<bool>>,
3809 tool_outputs_expanded: Arc<std::sync::Mutex<bool>>,
3812 show_terminal_progress: bool,
3814 status: std::sync::Mutex<RunStatus>,
3816 ext_status: rpi_extensions::ExtensionStatusMailbox,
3819 ext_status_revision: std::sync::atomic::AtomicU64,
3821 editor_text: rpi_extensions::EditorTextMailbox,
3825 auto_send: std::sync::Mutex<Option<AutoSendPending>>,
3828 last_editor_text: std::sync::Mutex<String>,
3831 programmatic_editor_write: std::sync::atomic::AtomicBool,
3836 js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
3840 user_bash_cancel: std::sync::Mutex<Option<CancellationToken>>,
3844 pending_bash_messages: std::sync::Mutex<Vec<AgentMessage>>,
3848 footer: Arc<FooterComponent>,
3850 status_container: Arc<Container>,
3853 chat_container: Arc<Container>,
3855 loader: Arc<Loader>,
3857 editor: Arc<Editor>,
3860 last_assistant_text: std::sync::Mutex<String>,
3863 active_selector: std::sync::Mutex<Option<(SelectorView, SelectorKind)>>,
3867 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
3869 active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
3871 active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
3873 autocomplete: AutocompleteManager,
3876 autocomplete_container: Arc<Container>,
3879 autocomplete_max_visible: usize,
3881 pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
3883 theme_manager: Arc<ThemeManager>,
3887 tui: Option<Arc<TuiAltScreen>>,
3891 current_model_id: std::sync::Mutex<String>,
3895 show_images: std::sync::Mutex<bool>,
3899 cache_miss_notices: std::sync::Mutex<bool>,
3902 history: std::sync::Mutex<Vec<String>>,
3905 history_index: std::sync::Mutex<isize>,
3908 history_draft: std::sync::Mutex<Option<String>>,
3911 cache_tracker: std::sync::Mutex<rpi_harness::cache_stats::CacheMissTracker>,
3915 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
3918 markdown_transformer: Arc<std::sync::Mutex<Option<MarkdownTransformer>>>,
3928 extension_session: crate::session::ExtensionSessionCell,
3930 search: Arc<AltScreenSearch>,
3932 search_bar: Arc<SearchBar>,
3934 pending_container: Arc<Container>,
3938 dequeue_hint: String,
3941 pending_snapshot: std::sync::Mutex<rpi_harness::agent_harness::QueuedMessages>,
3945 selection_start: std::sync::Mutex<Option<(u16, u16)>>,
3947 selection_end: std::sync::Mutex<Option<(u16, u16)>>,
3948}
3949
3950const HISTORY_LIMIT: usize = 100;
3953
3954const AUTO_SEND_PREFIX: &str = "Auto-send in ";
3958
3959const PAGE_SCROLL_OVERLAP: usize = 4;
3962
3963const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
3967
3968fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
3971 let mut modifiers = KeyModifiers::NONE;
3972 let mut key = None;
3973 for part in value.trim().to_ascii_lowercase().split('+') {
3974 match part {
3975 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
3976 "shift" => modifiers |= KeyModifiers::SHIFT,
3977 "alt" | "option" => modifiers |= KeyModifiers::ALT,
3978 "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
3979 part if !part.is_empty() => key = Some(part.to_string()),
3980 _ => {}
3981 }
3982 }
3983 let key = key?;
3984 let code = match key.as_str() {
3985 "esc" | "escape" => KeyCode::Esc,
3986 "enter" | "return" => KeyCode::Enter,
3987 "tab" => {
3988 if modifiers.contains(KeyModifiers::SHIFT) {
3989 return Some(rpi_tui::KeyCombo::new(
3990 KeyCode::BackTab,
3991 modifiers & !KeyModifiers::SHIFT,
3992 ));
3993 }
3994 KeyCode::Tab
3995 }
3996 "backspace" | "back" => KeyCode::Backspace,
3997 "delete" | "del" => KeyCode::Delete,
3998 "up" | "arrowup" => KeyCode::Up,
3999 "down" | "arrowdown" => KeyCode::Down,
4000 "left" | "arrowleft" => KeyCode::Left,
4001 "right" | "arrowright" => KeyCode::Right,
4002 "home" => KeyCode::Home,
4003 "end" => KeyCode::End,
4004 "pageup" | "page-up" => KeyCode::PageUp,
4005 "pagedown" | "page-down" => KeyCode::PageDown,
4006 "space" => KeyCode::Char(' '),
4007 "f1" => KeyCode::F(1),
4008 "f2" => KeyCode::F(2),
4009 "f3" => KeyCode::F(3),
4010 "f4" => KeyCode::F(4),
4011 "f5" => KeyCode::F(5),
4012 "f6" => KeyCode::F(6),
4013 "f7" => KeyCode::F(7),
4014 "f8" => KeyCode::F(8),
4015 "f9" => KeyCode::F(9),
4016 "f10" => KeyCode::F(10),
4017 "f11" => KeyCode::F(11),
4018 "f12" => KeyCode::F(12),
4019 value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4020 _ => return None,
4021 };
4022 Some(rpi_tui::KeyCombo::new(code, modifiers))
4023}
4024
4025fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4026 let mut bindings = rpi_tui::Keybindings::new();
4027 let settings = crate::settings::load_settings().unwrap_or_default();
4028 let Some(overrides) = settings.keybindings else {
4029 rpi_tui::set_keybindings(bindings.clone());
4030 return Arc::new(bindings);
4031 };
4032 let known: &[(&str, rpi_tui::KeybindingId)] = &[
4033 ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4034 ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4035 ("app.exit", rpi_tui::keybindings::keys::EXIT),
4036 ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4037 (
4038 "app.model.cycleForward",
4039 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4040 ),
4041 ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4042 (
4043 "app.thinking.toggle",
4044 rpi_tui::keybindings::keys::THINKING_TOGGLE,
4045 ),
4046 (
4047 "app.editor.external",
4048 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4049 ),
4050 (
4051 "app.thinking.cycle",
4052 rpi_tui::keybindings::keys::THINKING_CYCLE,
4053 ),
4054 (
4055 "app.clipboard.pasteImage",
4056 rpi_tui::keybindings::keys::PASTE_IMAGE,
4057 ),
4058 ("app.message.dequeue", rpi_tui::keybindings::keys::DEQUEUE),
4059 ];
4060 for (name, id) in known {
4061 let Some(value) = overrides.get(*name) else {
4062 continue;
4063 };
4064 let values: Vec<String> = match value {
4065 serde_json::Value::String(value) => vec![value.clone()],
4066 serde_json::Value::Array(values) => values
4067 .iter()
4068 .filter_map(|v| v.as_str().map(str::to_string))
4069 .collect(),
4070 serde_json::Value::Null => Vec::new(),
4071 _ => continue,
4072 };
4073 let combos: Vec<_> = values
4074 .iter()
4075 .filter_map(|value| parse_configured_key(value))
4076 .collect();
4077 if values.is_empty() || !combos.is_empty() {
4078 bindings.set(id, combos);
4079 }
4080 }
4081 rpi_tui::set_keybindings(bindings.clone());
4082 Arc::new(bindings)
4083}
4084
4085fn keybinding_matches(
4086 bindings: &rpi_tui::Keybindings,
4087 event: &crossterm::event::KeyEvent,
4088 id: rpi_tui::KeybindingId,
4089) -> bool {
4090 if bindings.matches(event, id) {
4091 return true;
4092 }
4093 if event.code == KeyCode::BackTab {
4096 let normalized =
4097 crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4098 bindings.matches(&normalized, id)
4099 } else {
4100 false
4101 }
4102}
4103
4104fn pending_dequeue_hint(bindings: &rpi_tui::Keybindings) -> String {
4108 let keys = bindings.get_keys(rpi_tui::keybindings::keys::DEQUEUE);
4109 if keys.is_empty() {
4110 return if cfg!(windows) { "Alt+Q" } else { "Alt+Up" }.to_string();
4111 }
4112 keys.iter()
4113 .map(|combo| combo.display())
4114 .collect::<Vec<_>>()
4115 .join("/")
4116}
4117
4118fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4122 last.is_some_and(|previous| {
4123 now.duration_since(previous) <= std::time::Duration::from_millis(500)
4124 })
4125}
4126
4127#[derive(Debug, PartialEq, Eq)]
4129enum CtrlCAction {
4130 Clear,
4132 Exit,
4134}
4135
4136fn ctrl_c_action(last_sigint: Option<std::time::Instant>, now: std::time::Instant) -> CtrlCAction {
4139 if double_escape_trigger(last_sigint, now) {
4140 CtrlCAction::Exit
4141 } else {
4142 CtrlCAction::Clear
4143 }
4144}
4145
4146const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4150
4151const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4154
4155#[derive(Default)]
4160struct WindowsPasteRun {
4161 text: String,
4162 last_at: Option<std::time::Instant>,
4163 burst: bool,
4164}
4165
4166impl WindowsPasteRun {
4167 fn grace(&self) -> std::time::Duration {
4168 std::time::Duration::from_millis(if self.burst { 120 } else { 15 })
4169 }
4170
4171 fn feed(&mut self, ch: char, now: std::time::Instant) {
4172 if self
4173 .last_at
4174 .is_some_and(|at| now.duration_since(at) <= PASTE_BURST_GAP)
4175 {
4176 self.burst = true;
4177 }
4178 self.text.push(ch);
4179 self.last_at = Some(now);
4180 }
4181
4182 fn flush(&mut self, editor: &Editor) {
4183 if !self.text.is_empty() {
4184 editor.handle_paste(&self.text);
4185 }
4186 *self = Self::default();
4187 }
4188}
4189
4190fn windows_enter_in_paste(
4194 run: &WindowsPasteRun,
4195 more_queued: bool,
4196 clipboard: Option<&str>,
4197) -> bool {
4198 if more_queued {
4199 return true;
4200 }
4201 if run.text.is_empty() {
4202 return false;
4203 }
4204 run.burst
4205 && clipboard.is_some_and(|text| {
4206 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
4207 let prefix = format!("{}\n", run.text);
4208 normalized.starts_with(&prefix) && normalized.matches('\n').count() >= 10
4209 })
4210}
4211
4212#[cfg(test)]
4233fn enter_is_paste_burst(
4234 last_text_key_at: Option<std::time::Instant>,
4235 now: std::time::Instant,
4236 more_queued: bool,
4237) -> bool {
4238 if more_queued {
4239 return true;
4240 }
4241 last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4242}
4243
4244fn probe_paste_input(
4255 pending: Option<Event>,
4256 mut next_event: impl FnMut() -> Option<Event>,
4257) -> (bool, Option<Event>) {
4258 let mut pending = pending;
4259 let mut more_queued = pending.is_some();
4260 while !more_queued {
4261 match next_event() {
4262 Some(Event::Key(key)) if key.kind == KeyEventKind::Release => continue,
4263 Some(ev) => {
4264 pending = Some(ev);
4265 more_queued = true;
4266 }
4267 None => break,
4268 }
4269 }
4270 (more_queued, pending)
4271}
4272
4273fn transcript_page_size(viewport_height: usize) -> i32 {
4274 viewport_height
4275 .saturating_sub(PAGE_SCROLL_OVERLAP)
4276 .max(1)
4277 .min(i32::MAX as usize) as i32
4278}
4279
4280fn should_dispatch_key(kind: KeyEventKind) -> bool {
4281 kind != KeyEventKind::Release
4282}
4283
4284fn emergency_exit(tui: &Arc<TuiAltScreen>) -> ! {
4294 tui.stop(Default::default());
4298 eprintln!("\n\x1b[2mrpi: aborted (forced quit).\x1b[0m");
4299 std::process::exit(130);
4300}
4301
4302#[cfg(unix)]
4306fn handle_suspend(tui: &Arc<TuiAltScreen>) {
4307 tui.suspend();
4308 let _ = nix::sys::signal::raise(nix::sys::signal::Signal::SIGTSTP);
4311 tui.resume();
4312}
4313
4314#[cfg(not(unix))]
4317fn handle_suspend(_tui: &Arc<TuiAltScreen>) {}
4318
4319fn format_tokens(n: i64) -> String {
4321 if n >= 1_000_000 {
4322 format!("{:.1}M", n as f64 / 1_000_000.0)
4323 } else if n >= 1_000 {
4324 format!("{:.1}K", n as f64 / 1_000.0)
4325 } else {
4326 n.to_string()
4327 }
4328}
4329
4330fn push_history(state: &Arc<TuiState>, text: &str) {
4334 let trimmed = text.trim().to_string();
4335 if trimmed.is_empty() {
4336 return;
4337 }
4338 let mut history = state.history.lock().unwrap();
4339 if history.first() == Some(&trimmed) {
4340 return;
4341 }
4342 history.insert(0, trimmed);
4343 history.truncate(HISTORY_LIMIT);
4344 *state.history_index.lock().unwrap() = -1;
4345 *state.history_draft.lock().unwrap() = None;
4346}
4347
4348fn submit_draft(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>, text: &str) {
4355 if text.trim().is_empty() || !state.try_start_working() {
4356 return;
4357 }
4358 add_user_message(&state.chat_container, text);
4359 if let Some(tui) = &state.tui {
4360 if let Some(scroll) = tui.get_primary_scroll_view() {
4361 scroll.scroll_to_end();
4362 }
4363 tui.request_render(false);
4364 }
4365 push_history(state, text);
4366 let _ = tx.send(TuiMessage::UserInput(text.to_string()));
4367}
4368
4369fn poll_auto_send(state: &Arc<TuiState>, tx: &mpsc::UnboundedSender<TuiMessage>) -> bool {
4378 let pending = state.auto_send.lock().unwrap().clone();
4381 let step = auto_send_step(
4382 pending.as_ref(),
4383 &state.editor.get_text(),
4384 *state.status.lock().unwrap() == RunStatus::Idle,
4385 std::time::Instant::now(),
4386 );
4387
4388 match step {
4389 AutoSendStep::Idle => false,
4390 AutoSendStep::Cancel => {
4391 state.clear_auto_send();
4392 true
4393 }
4394 AutoSendStep::Submit => {
4395 let text = state.editor.get_text();
4396 state.clear_auto_send();
4397 state.clear_editor_programmatically();
4398 submit_draft(state, tx, &text);
4399 true
4400 }
4401 AutoSendStep::Wait(remaining) => {
4402 let secs = remaining.as_millis() as f64 / 1000.0;
4403 let label = format!("{AUTO_SEND_PREFIX}{secs:.1}s · any key to edit");
4404 if state.footer.get_status() != label {
4405 state.footer.set_status(&label);
4406 true
4407 } else {
4408 false
4409 }
4410 }
4411 }
4412}
4413
4414fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4419 let history = state.history.lock().unwrap();
4420 if history.is_empty() {
4421 return;
4422 }
4423 let mut index = state.history_index.lock().unwrap();
4424 let new_index = *index - direction as isize;
4425 if new_index < -1 || new_index >= history.len() as isize {
4426 return;
4427 }
4428 if *index == -1 && new_index >= 0 {
4429 *state.history_draft.lock().unwrap() = Some(editor.get_text());
4431 }
4432 *index = new_index;
4433 if new_index == -1 {
4434 let draft = state.history_draft.lock().unwrap().take();
4436 match draft {
4437 Some(d) => {
4438 let len = d.len();
4439 editor.set_text(&d);
4440 editor.set_cursor(0, len);
4441 }
4442 None => editor.set_text(""),
4443 }
4444 } else {
4445 let text = history[new_index as usize].clone();
4446 let len = text.len();
4447 editor.set_text(&text);
4448 editor.set_cursor(0, len);
4449 }
4450}
4451
4452impl TuiState {
4453 fn cancel_js_preparation(&self) -> bool {
4454 let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4455 if let Some(cancellation) = cancellation {
4456 cancellation.cancel();
4457 true
4458 } else {
4459 false
4460 }
4461 }
4462
4463 fn user_bash_running(&self) -> bool {
4465 self.user_bash_cancel.lock().unwrap().is_some()
4466 }
4467
4468 fn begin_user_bash(&self) -> CancellationToken {
4471 let cancellation = CancellationToken::new();
4472 if let Some(previous) = self
4473 .user_bash_cancel
4474 .lock()
4475 .unwrap()
4476 .replace(cancellation.clone())
4477 {
4478 previous.cancel();
4479 }
4480 cancellation
4481 }
4482
4483 fn finish_user_bash(&self) {
4486 self.user_bash_cancel.lock().unwrap().take();
4487 }
4488
4489 fn cancel_user_bash(&self) -> bool {
4493 let cancellation = self.user_bash_cancel.lock().unwrap().as_ref().cloned();
4494 if let Some(cancellation) = cancellation {
4495 cancellation.cancel();
4496 true
4497 } else {
4498 false
4499 }
4500 }
4501
4502 fn set_status(&self, status: RunStatus) {
4503 *self.status.lock().unwrap() = status;
4504 self.apply_status(status);
4505 }
4506
4507 fn pending_container(&self) -> &Arc<Container> {
4510 &self.pending_container
4511 }
4512
4513 fn set_pending_queue(&self, messages: rpi_harness::agent_harness::QueuedMessages) {
4519 {
4520 let mut current = self.pending_snapshot.lock().unwrap();
4521 if *current == messages {
4522 return;
4523 }
4524 *current = messages.clone();
4525 }
4526 let container = &self.pending_container;
4527 container.clear();
4528 if messages.is_empty() {
4529 return;
4530 }
4531 let colors = current_theme().colors;
4532 container.add_child(Arc::new(Spacer::new(1)));
4533 for text in &messages.steering {
4534 container.add_child(Arc::new(Text::new(
4535 colors.muted.fg(&format!("Steering: {text}")),
4536 1,
4537 0,
4538 )));
4539 }
4540 for text in &messages.follow_up {
4541 container.add_child(Arc::new(Text::new(
4542 colors.muted.fg(&format!("Follow-up: {text}")),
4543 1,
4544 0,
4545 )));
4546 }
4547 container.add_child(Arc::new(Text::new(
4548 colors.muted.fg(&format!(
4549 "↳ {} to edit all queued messages",
4550 self.dequeue_hint
4551 )),
4552 1,
4553 0,
4554 )));
4555 }
4556
4557 fn try_start_working(&self) -> bool {
4562 let mut status = self.status.lock().unwrap();
4563 if *status != RunStatus::Idle {
4564 return false;
4565 }
4566 *status = RunStatus::Working;
4567 drop(status);
4568 self.apply_status(RunStatus::Working);
4569 true
4570 }
4571
4572 fn apply_status(&self, status: RunStatus) {
4573 match status {
4574 RunStatus::Working => {
4575 self.footer.set_status("");
4579 if let Some(tui) = &self.tui {
4582 tui.set_title(&crate::brand::window_title("⟳"));
4583 }
4584 self.status_container.clear();
4585 if self.show_terminal_progress {
4588 self.editor.set_working(Some(WorkingState {
4589 frame: 0,
4590 started_at: std::time::Instant::now(),
4591 message: "Working…".to_string(),
4592 }));
4593 } else {
4594 self.editor.set_working(None);
4595 }
4596 }
4597 RunStatus::Aborting => {
4598 self.footer.set_status("Aborting…");
4599 self.loader.stop();
4602 self.status_container.clear();
4603 self.editor.set_working(None);
4604 }
4605 RunStatus::Idle => {
4606 self.footer.set_status("");
4607 if let Some(tui) = &self.tui {
4608 tui.set_title(&crate::brand::window_title(""));
4609 }
4610 self.loader.stop();
4611 self.status_container.clear();
4612 self.editor.set_working(None);
4613 }
4614 }
4615 }
4616
4617 fn sync_extension_status(&self) -> bool {
4621 let revision = self.ext_status.revision();
4622 if self
4623 .ext_status_revision
4624 .load(std::sync::atomic::Ordering::SeqCst)
4625 == revision
4626 {
4627 return false;
4628 }
4629 self.ext_status_revision
4630 .store(revision, std::sync::atomic::Ordering::SeqCst);
4631 self.footer.set_extension_status(&self.ext_status.text());
4632 true
4633 }
4634
4635 fn drain_editor_text(&self) -> bool {
4646 let mut applied = false;
4647 while let Some(edit) = self.editor_text.take_pending() {
4648 let current = self.editor.get_text();
4649 let text = match edit.mode {
4650 rpi_extensions::EditorTextMode::Replace => edit.text,
4651 rpi_extensions::EditorTextMode::Append => {
4652 if current.is_empty() {
4653 edit.text
4654 } else {
4655 let mut joined = current.trim_end().to_string();
4658 joined.push(' ');
4659 joined.push_str(&edit.text);
4660 joined
4661 }
4662 }
4663 };
4664 set_editor_text_caret_at_end(&self.editor, &text);
4665 self.programmatic_editor_write
4667 .store(true, std::sync::atomic::Ordering::SeqCst);
4668 let pending = edit
4669 .auto_send_ms
4670 .filter(|ms| *ms > 0)
4671 .map(|ms| AutoSendPending {
4672 deadline: std::time::Instant::now() + std::time::Duration::from_millis(ms),
4673 text: text.clone(),
4674 });
4675 *self.auto_send.lock().unwrap() = pending;
4676 applied = true;
4677 }
4678 applied
4679 }
4680
4681 fn clear_editor_programmatically(&self) {
4691 self.programmatic_editor_write
4692 .store(true, std::sync::atomic::Ordering::SeqCst);
4693 self.editor.clear();
4694 }
4695
4696 fn clear_auto_send(&self) {
4699 if self.auto_send.lock().unwrap().take().is_some()
4700 && self.footer.get_status().starts_with(AUTO_SEND_PREFIX)
4701 {
4702 self.footer.set_status("");
4703 }
4704 }
4705
4706 fn cancel_auto_send_on_key(&self) -> bool {
4710 let had = self.auto_send.lock().unwrap().is_some();
4711 if had {
4712 self.clear_auto_send();
4713 }
4714 had
4715 }
4716
4717 fn sync_editor_change(&self) {
4734 use rpi_plugin_sdk::EventTag;
4735
4736 let Some(payload) = self.take_editor_change() else {
4737 return;
4738 };
4739 let Ok(session) = self.extension_session.lock() else {
4742 return;
4743 };
4744 let Some(snapshot) = session.snapshot_arc() else {
4745 return;
4746 };
4747 if snapshot.handlers_for(EventTag::EditorChange).is_empty() {
4748 return;
4749 }
4750 rpi_extensions::dispatch_data_event(
4751 &snapshot,
4752 EventTag::EditorChange,
4753 &payload.to_string(),
4754 );
4755 }
4756
4757 fn take_editor_change(&self) -> Option<serde_json::Value> {
4766 let text = self.editor.get_text();
4767 {
4768 let mut last = self.last_editor_text.lock().unwrap();
4769 if *last == text {
4770 return None;
4771 }
4772 *last = text.clone();
4773 }
4774 let programmatic = self
4775 .programmatic_editor_write
4776 .swap(false, std::sync::atomic::Ordering::SeqCst);
4777 Some(serde_json::json!({
4778 "chars": text.chars().count(),
4779 "empty": text.is_empty(),
4780 "source": if programmatic {
4781 "extension"
4783 } else {
4784 "user"
4785 },
4786 }))
4787 }
4788
4789 fn sync_working_loader_with_bash(&self) {
4793 if *self.status.lock().unwrap() != RunStatus::Working {
4794 return;
4795 }
4796
4797 self.status_container.clear();
4802 }
4803
4804 fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4805 *self.status.lock().unwrap() = RunStatus::Working;
4806 self.footer.set_status("");
4807 if let Some(tui) = &self.tui {
4808 tui.set_title(&crate::brand::window_title("↻"));
4809 }
4810 self.loader.stop();
4811 self.status_container.clear();
4812 self.status_container
4813 .add_child(Arc::new(StatusIndicator::retry(
4814 attempt,
4815 max_retries,
4816 std::time::Duration::from_millis(delay_ms),
4817 )));
4818 }
4819
4820 fn selector_open(&self) -> bool {
4822 self.active_selector.lock().unwrap().is_some()
4823 }
4824
4825 fn extension_editor_open(&self) -> bool {
4826 self.active_extension_editor.lock().unwrap().is_some()
4827 }
4828
4829 fn extension_input_open(&self) -> bool {
4830 self.active_extension_input.lock().unwrap().is_some()
4831 }
4832
4833 fn extension_dialog_open(&self) -> bool {
4834 self.extension_editor_open() || self.extension_input_open()
4835 }
4836
4837 fn set_hide_thinking(&self, hide: bool) {
4838 *self.hide_thinking.lock().unwrap() = hide;
4839 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4840 comp.set_hide_thinking(hide);
4841 }
4842 }
4843
4844 fn hide_thinking(&self) -> bool {
4845 *self.hide_thinking.lock().unwrap()
4846 }
4847
4848 fn toggle_thinking(&self) -> bool {
4849 let next = !self.hide_thinking();
4850 self.set_hide_thinking(next);
4851 next
4852 }
4853
4854 fn toggle_tool_outputs(&self) -> bool {
4855 let next = !*self.tool_outputs_expanded.lock().unwrap();
4856 *self.tool_outputs_expanded.lock().unwrap() = next;
4857 for comp in self.tool_components.lock().unwrap().values() {
4858 comp.set_expanded(next);
4859 }
4860 for comp in self.bash_components.lock().unwrap().values() {
4861 comp.set_expanded(next);
4862 }
4863 next
4864 }
4865
4866 fn current_model_id(&self) -> String {
4868 self.current_model_id.lock().unwrap().clone()
4869 }
4870
4871 fn set_current_model(&self, model: &rpi_ai::Model) {
4874 *self.current_model_id.lock().unwrap() = model.id.clone();
4875 self.footer.set_model(&short_model_name(&model.id));
4876 self.footer.set_context_window(model.context_window as i64);
4879 }
4880
4881 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4885 self.markdown_transformer.lock().unwrap().clone()
4886 }
4887
4888 fn transcript_view(&self) -> crate::transcript_view::TranscriptView {
4894 crate::transcript_view::TranscriptView::with_maps(
4895 self.chat_container.clone(),
4896 self.current_assistant.clone(),
4897 self.tool_components.clone(),
4898 self.bash_components.clone(),
4899 self.hide_thinking.clone(),
4900 self.tool_outputs_expanded.clone(),
4901 self.markdown_transformer.clone(),
4902 )
4903 }
4904
4905 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4914 *self.markdown_transformer.lock().unwrap() = transformer.clone();
4915 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4916 comp.set_markdown_transformer(transformer);
4917 }
4918 }
4919
4920 fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4921 self.pending_images.lock().unwrap().push(image);
4922 }
4923
4924 fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4925 std::mem::take(&mut *self.pending_images.lock().unwrap())
4926 }
4927}
4928
4929#[derive(Debug, PartialEq)]
4934struct TuiStartupSettings {
4935 editor_padding_x: usize,
4936 autocomplete_max_visible: usize,
4937 hide_thinking: bool,
4938 quiet_startup: bool,
4939 show_terminal_progress: bool,
4940 show_images: bool,
4942 cache_miss_notices: bool,
4944}
4945
4946fn preferred_project_setting<T>(
4947 project_settings: &[crate::settings::Settings],
4948 field: impl Fn(&crate::settings::Settings) -> Option<T>,
4949) -> Option<T> {
4950 project_settings.iter().find_map(field)
4951}
4952
4953fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4954 verbose || !quiet_startup
4955}
4956
4957fn resolve_tui_startup_settings(
4960 global: &crate::settings::Settings,
4961 project_settings: &[crate::settings::Settings],
4962 project_trusted: bool,
4963) -> TuiStartupSettings {
4964 let project_settings = if project_trusted {
4965 project_settings
4966 } else {
4967 &[]
4968 };
4969 let editor_padding_x =
4970 preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4971 .or(global.editor_padding_x)
4972 .unwrap_or(1)
4973 .min(16);
4974 let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4975 settings.autocomplete_max_visible
4976 })
4977 .or(global.autocomplete_max_visible)
4978 .unwrap_or(5)
4979 .clamp(1, 20);
4980 let hide_thinking =
4981 preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4982 .or(global.hide_thinking_block)
4983 .unwrap_or(false);
4984 let quiet_startup =
4985 preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4986 .or(global.quiet_startup)
4987 .unwrap_or(false);
4988 let show_terminal_progress = preferred_project_setting(project_settings, |settings| {
4989 settings.show_terminal_progress()
4990 })
4991 .or_else(|| global.show_terminal_progress())
4992 .unwrap_or(true);
4993 let show_images =
4994 preferred_project_setting(project_settings, |settings| settings.show_images())
4995 .or_else(|| global.show_images())
4996 .unwrap_or(true);
4997 let cache_miss_notices = preferred_project_setting(project_settings, |settings| {
4998 settings.show_cache_miss_notices
4999 })
5000 .or(global.show_cache_miss_notices)
5001 .unwrap_or(false);
5002
5003 TuiStartupSettings {
5004 editor_padding_x,
5005 autocomplete_max_visible,
5006 hide_thinking,
5007 quiet_startup,
5008 show_terminal_progress,
5009 show_images,
5010 cache_miss_notices,
5011 }
5012}
5013
5014pub async fn interactive_tui(
5028 harness: &AgentHarness,
5029 event_rx: Option<broadcast::Receiver<AgentEvent>>,
5030 args: &Args,
5031 model_catalog: Vec<rpi_ai::Model>,
5032 initial: Option<String>,
5033 extra_messages: &[String],
5034 initial_images: Vec<rpi_ai::types::ImageContent>,
5035 theme: Option<&str>,
5036 _no_themes: bool,
5037 reload_context: &crate::session::ReloadContext,
5038) -> i32 {
5039 let lane: Arc<dyn AgentLane> = harness.lane("main");
5040 let cwd = reload_context.cwd.clone();
5044 let saved_settings = crate::settings::load_settings().unwrap_or_default();
5045 let project_trusted = reload_context.project_trusted;
5046 let project_settings = if project_trusted {
5047 crate::settings::load_project_settings(&cwd)
5048 } else {
5049 Vec::new()
5050 };
5051 let tui_settings =
5052 resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
5053 let editor_padding_x = tui_settings.editor_padding_x;
5054 let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
5055 let mut hide_thinking = tui_settings.hide_thinking;
5056 let show_terminal_progress = tui_settings.show_terminal_progress;
5057 let show_images = tui_settings.show_images;
5058 let cache_miss_notices = tui_settings.cache_miss_notices;
5059 let quiet_startup = tui_settings.quiet_startup;
5060 let session_values = rpi_harness::session::SessionValues::load(harness.session())
5068 .await
5069 .unwrap_or_default();
5070 if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
5071 hide_thinking = persisted;
5072 }
5073
5074 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5078 let model_name = short_model_name(&lane_model_id);
5079
5080 let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
5084 if active_tool_names.is_empty() {
5088 active_tool_names = harness
5089 .get_tools()
5090 .await
5091 .unwrap_or_default()
5092 .into_iter()
5093 .map(|tool| tool.tool.schema().name.clone())
5094 .collect();
5095 }
5096 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
5097 let skill_names: Vec<String> = resources_snapshot
5098 .skills
5099 .as_deref()
5100 .unwrap_or(&[])
5101 .iter()
5102 .map(|skill| skill.name.clone())
5103 .collect();
5104
5105 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
5107
5108 let theme_manager = Arc::new(ThemeManager::new());
5112 if let Some(preset) = match theme {
5113 Some("light") => Some(ThemePreset::Light),
5114 Some("monochrome") => Some(ThemePreset::Monochrome),
5115 Some("dark") => Some(ThemePreset::Dark),
5116 _ => None,
5117 } {
5118 apply_theme_preset(preset);
5119 theme_manager.apply_preset(preset);
5120 }
5121
5122 let terminal = Box::new(ProcessTerminal::new());
5124 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
5125 let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
5130 ask_user_bridge.attach();
5131 tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
5132
5133 let chat_container = Arc::new(Container::new());
5134 if should_show_startup_listing(args.verbose, quiet_startup) {
5135 add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5136 }
5137
5138 crate::extras::maybe_first_time_setup(&chat_container);
5142
5143 let initial_transformer = build_markdown_transformer(
5148 reload_context
5149 .extension_session
5150 .lock()
5151 .unwrap()
5152 .snapshot_arc(),
5153 );
5154 if launch_restores_history(args) {
5158 render_session_history(
5159 &harness,
5160 &chat_container,
5161 initial_transformer.clone(),
5162 Some(reload_context.extension_session.clone()),
5163 )
5164 .await;
5165 }
5166
5167 let document_container = Arc::new(Container::new());
5170 document_container.add_child(chat_container.clone());
5171 document_container.add_child(Arc::new(Spacer::new(1)));
5173
5174 let scroll_view = Arc::new(ScrollView::new(
5175 document_container.clone(),
5176 ScrollViewOptions {
5177 follow: FollowMode::End,
5178 primary: true,
5179 overscroll: OverscrollMode::Chain,
5180 scrollbar: ScrollbarMode::Hidden,
5184 ..Default::default()
5185 },
5186 ));
5187
5188 let keybindings = configured_keybindings();
5193 let editor = Arc::new(Editor::new(
5194 EditorOptions {
5195 padding_x: editor_padding_x,
5196 autocomplete_max_visible,
5197 ..Default::default()
5198 },
5199 EditorStyle::default(),
5200 keybindings.clone(),
5201 ));
5202
5203 {
5208 let editor_for_change = editor.clone();
5209 editor.on_change(Arc::new(move |text: &str| {
5210 let colors = current_theme().colors;
5211 let is_bash = text.trim_start().starts_with('!');
5212 editor_for_change.set_border_color(if is_bash {
5213 Some(colors.bash_mode)
5214 } else {
5215 None
5216 });
5217 }));
5218 }
5219
5220 let footer = Arc::new(FooterComponent::new());
5222 footer.set_model(&model_name);
5223 footer.set_cwd(&cwd.to_string_lossy());
5224 footer.set_git_branch(git_branch_for(&cwd).as_deref());
5225 if let Some(m) = model_catalog.iter().find(|m| m.id == lane_model_id) {
5226 footer.set_context_window(m.context_window as i64);
5227 }
5228 footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Clear/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+P: Cycle | Ctrl+T: Expand tool | /help");
5229
5230 let _branch_watcher = find_git_head_path(&cwd).map(|head| {
5233 let footer_ref = footer.clone();
5234 let cwd_ref = cwd.clone();
5235 crate::fs_watch::watch_path(head, move || {
5236 footer_ref.set_git_branch(git_branch_for(&cwd_ref).as_deref());
5237 })
5238 });
5239
5240 let status_container = Arc::new(Container::new());
5241 let loader = Arc::new(Loader::with_text("Working…"));
5242
5243 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5257 .prompt_templates
5258 .clone()
5259 .unwrap_or_default()
5260 .iter()
5261 .map(|t| SlashCommandEntry {
5262 name: format!("/{}", t.name),
5263 description: t
5264 .description
5265 .clone()
5266 .unwrap_or_else(|| "Expand prompt template".to_string()),
5267 })
5268 .collect();
5269 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5270 Arc::new(resources_snapshot);
5271 let mut command_registry = build_builtin_registry();
5277 register_extension_commands(
5278 &mut command_registry,
5279 reload_context.extension_session.clone(),
5280 );
5281 let registry = Arc::new(command_registry);
5282 let mut all_slash_commands = registry.visible_entries();
5283 all_slash_commands.extend(template_slash_commands);
5284 let autocomplete = AutocompleteManager::new();
5285 {
5286 let mut combined = CombinedAutocompleteProvider::new();
5287 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5288 all_slash_commands,
5289 )));
5290 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5291 cwd.clone(),
5292 )));
5293 autocomplete.set_provider(Arc::new(combined));
5294 }
5295 let autocomplete_container = Arc::new(Container::new());
5296
5297 let tool_outputs_expanded = session_values
5298 .get_as::<bool>("display.tool_outputs_expanded")
5299 .unwrap_or(false);
5300 let state = Arc::new(TuiState {
5301 current_assistant: Arc::new(std::sync::Mutex::new(None)),
5302 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
5303 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
5304 hide_thinking: Arc::new(std::sync::Mutex::new(hide_thinking)),
5305 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(tool_outputs_expanded)),
5306 show_terminal_progress,
5307 status: std::sync::Mutex::new(RunStatus::Idle),
5308 ext_status: reload_context.ext_status.clone(),
5309 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
5310 editor_text: reload_context.editor_text.clone(),
5311 auto_send: std::sync::Mutex::new(None),
5312 last_editor_text: std::sync::Mutex::new(String::new()),
5313 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
5314 js_preparation_cancel: std::sync::Mutex::new(None),
5315 user_bash_cancel: std::sync::Mutex::new(None),
5316 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
5317 footer: footer.clone(),
5318 status_container: status_container.clone(),
5319 chat_container: chat_container.clone(),
5320 loader: loader.clone(),
5321 editor: editor.clone(),
5322 last_assistant_text: std::sync::Mutex::new(String::new()),
5323 active_selector: std::sync::Mutex::new(None),
5324 active_extension_editor: std::sync::Mutex::new(None),
5325 active_extension_input: std::sync::Mutex::new(None),
5326 active_extension_cancel: std::sync::Mutex::new(None),
5327 autocomplete,
5328 autocomplete_container: autocomplete_container.clone(),
5329 autocomplete_max_visible,
5330 pending_images: std::sync::Mutex::new(Vec::new()),
5331 theme_manager,
5332 tui: Some(tui.clone()),
5333 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5334 show_images: std::sync::Mutex::new(show_images),
5335 cache_miss_notices: std::sync::Mutex::new(cache_miss_notices),
5336 history: std::sync::Mutex::new(Vec::new()),
5337 history_index: std::sync::Mutex::new(-1),
5338 history_draft: std::sync::Mutex::new(None),
5339 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
5340 scoped_edit: std::sync::Mutex::new(None),
5341 markdown_transformer: Arc::new(std::sync::Mutex::new(initial_transformer)),
5342 extension_session: reload_context.extension_session.clone(),
5343 search: Arc::new(AltScreenSearch::new()),
5344 search_bar: Arc::new(SearchBar::new()),
5345 pending_container: Arc::new(Container::new()),
5346 dequeue_hint: pending_dequeue_hint(&keybindings),
5347 pending_snapshot: std::sync::Mutex::new(
5348 rpi_harness::agent_harness::QueuedMessages::default(),
5349 ),
5350 selection_start: std::sync::Mutex::new(None),
5351 selection_end: std::sync::Mutex::new(None),
5352 });
5353
5354 let model_catalog_arc = Arc::new(model_catalog.clone());
5357 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5358
5359 let editor_container = Arc::new(Container::new());
5370 editor_container.add_child(editor.clone());
5371
5372 let dock = Arc::new(VStack::from_children(vec![
5373 StackChild::Entry(StackEntry::new(state.pending_container().clone())),
5374 StackChild::Entry(StackEntry::new(status_container.clone())),
5375 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5376 StackChild::Entry(
5382 StackEntry::new(editor_container.clone())
5383 .shrink(0)
5384 .min_size(3),
5385 ),
5386 StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5387 StackChild::Entry(StackEntry::new(footer.clone())),
5388 ]));
5389
5390 let root = VStack::from_children(vec![
5391 StackChild::Entry(
5392 StackEntry::new(scroll_view.clone())
5393 .basis(0)
5394 .grow(1)
5395 .shrink(1)
5396 .min_size(1),
5397 ),
5398 StackChild::Entry(StackEntry::new(dock).shrink(1)),
5399 ]);
5400
5401 tui.set_layout_root(Some(Arc::new(root)));
5402 tui.set_focus(Some(editor.clone()));
5403 editor.set_focused(true);
5404
5405 let scoped_models = args
5421 .model
5422 .as_deref()
5423 .map(crate::agent_session::ScopedModel::parse)
5424 .into_iter()
5425 .collect();
5426 let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5427 harness.clone(),
5428 model_catalog.clone(),
5429 scoped_models,
5430 cwd.clone(),
5431 ));
5432 let ctx = CommandContext {
5433 chat: chat_container.clone(),
5434 tui: tui.clone(),
5435 tx: tx.clone(),
5436 state: state.clone(),
5437 editor: editor.clone(),
5438 editor_container: editor_container.clone(),
5439 lane: lane.clone(),
5440 model_catalog: model_catalog_arc.clone(),
5441 lane_model_id: lane_model_id.clone(),
5442 cwd: cwd.clone(),
5443 resources: resources_arc.clone(),
5444 reload_context: Arc::new(reload_context.clone()),
5445 session: agent_session.clone(),
5446 };
5447
5448 let ctx_for_cb = ctx.clone();
5449 let registry_for_cb = registry.clone();
5450 editor.on_submit(Arc::new(move |text: &str| {
5451 let text = text.trim();
5452 if text.is_empty() {
5453 return;
5454 }
5455
5456 if text.starts_with('/') && dispatch_slash(text, &ctx_for_cb, ®istry_for_cb) {
5457 return;
5458 }
5459
5460 if let Some((command, exclude_from_context)) = parse_user_bash(text) {
5465 if ctx_for_cb.state.user_bash_running() {
5466 add_error_message(
5467 &ctx_for_cb.chat,
5468 "A bash command is already running. Press Esc to cancel it first.",
5469 );
5470 ctx_for_cb.tui.request_render(false);
5471 return;
5472 }
5473 start_user_bash(&ctx_for_cb, command, exclude_from_context);
5474 return;
5475 }
5476
5477 let run_status = *ctx_for_cb.state.status.lock().unwrap();
5478 if run_status != RunStatus::Idle {
5479 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5480 let lane = ctx_for_cb.lane.clone();
5481 let chat = ctx_for_cb.chat.clone();
5482 let tui = ctx_for_cb.tui.clone();
5483 let state = ctx_for_cb.state.clone();
5484 tokio::spawn(async move {
5485 if let Err(error) = lane.steer(message).await {
5495 add_error_message(&chat, &format!("Could not queue message: {error}"));
5496 tui.render_now(false);
5497 return;
5498 }
5499 refresh_pending_messages(&state, &lane).await;
5502 tui.request_render(false);
5503 });
5504 ctx_for_cb.tui.request_render(false);
5510 return;
5511 }
5512
5513 if !ctx_for_cb.state.try_start_working() {
5514 return;
5515 }
5516
5517 add_user_message(&ctx_for_cb.chat, text);
5528 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5531 scroll.scroll_to_end();
5532 }
5533 ctx_for_cb.tui.request_render(false);
5534 push_history(&ctx_for_cb.state, text);
5537 if ctx_for_cb
5538 .tx
5539 .send(TuiMessage::UserInput(text.to_string()))
5540 .is_err()
5541 {
5542 ctx_for_cb.state.set_status(RunStatus::Idle);
5543 }
5544 }));
5545
5546 tui.start_render_scheduler();
5554 tui.start_readerless();
5555
5556 let mut update_check_handles = Vec::new();
5560 let rpi_chat = chat_container.clone();
5561 let rpi_tui = tui.clone();
5562 update_check_handles.push(tokio::spawn(async move {
5563 let report = crate::updates::check_rpi_startup().await;
5564 if !report.is_empty() {
5565 add_update_notices(&rpi_chat, &report);
5566 }
5567 rpi_tui.request_render(false);
5568 }));
5569
5570 let drain_handle = if let Some(rx) = event_rx {
5574 let tui_drain = tui.clone();
5575 let state_drain = state.clone();
5576 let chat_drain = chat_container.clone();
5577 let lane_drain = lane.clone();
5578 Some(tokio::spawn(async move {
5579 drain_agent_events(rx, tui_drain, state_drain, chat_drain, lane_drain).await;
5580 }))
5581 } else {
5582 None
5583 };
5584
5585 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5595 reload_context.mailbox.install(reload_sig_tx);
5596 let reload_tx = tx.clone();
5597 let reload_bridge_handle = tokio::spawn(async move {
5598 while reload_sig_rx.recv().await.is_some() {
5599 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5600 break; }
5602 }
5603 });
5604
5605 let tui_tick = tui.clone();
5610 let state_tick = state.clone();
5611 let tx_tick = tx.clone();
5614 let tick_handle = tokio::spawn(async move {
5615 let mut interval = tokio::time::interval(std::time::Duration::from_millis(
5624 rpi_tui::loader::SPINNER_FRAME_MS,
5625 ));
5626 interval.tick().await; loop {
5628 interval.tick().await;
5629 if state_tick.sync_extension_status() {
5632 tui_tick.request_render(false);
5633 }
5634 if state_tick.drain_editor_text() {
5637 tui_tick.request_render(false);
5638 }
5639 state_tick.sync_editor_change();
5642 if poll_auto_send(&state_tick, &tx_tick) {
5644 tui_tick.request_render(false);
5645 }
5646 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5647 if working {
5648 let bash_present = !state_tick.bash_components.lock().unwrap().is_empty();
5656 let has_live_panel = {
5657 let tools = state_tick.tool_components.lock().unwrap();
5658 transcript_has_live_panel(bash_present, &tools)
5659 };
5660 if has_live_panel {
5661 tui_tick.request_render(false);
5662 } else {
5663 tui_tick.request_render_reusing_scroll_content();
5667 }
5668 }
5669 }
5670 });
5671
5672 let running = Arc::new(std::sync::Mutex::new(true));
5674 let running_key = running.clone();
5675 let tx_for_key = tx.clone();
5676 let tui_for_key = tui.clone();
5677 let editor_for_key = editor.clone();
5678 let scroll_for_key = scroll_view.clone();
5679 let lane_for_key = lane.clone();
5680 let state_for_key = state.clone();
5681 let model_catalog_for_key = model_catalog_arc.clone();
5682 let ask_user_for_key = ask_user_bridge.clone();
5683 let ctx_for_key = ctx.clone();
5687 let registry_for_key = registry.clone();
5688 let keybindings_for_key = keybindings.clone();
5689 let double_escape_action = crate::settings::load_settings()
5690 .ok()
5691 .and_then(|settings| settings.double_escape_action)
5692 .unwrap_or_else(|| "tree".to_string())
5693 .to_ascii_lowercase();
5694
5695 let key_handle = tokio::task::spawn_blocking(move || {
5696 let mut last_escape_time = None;
5697 let mut last_sigint_time: Option<std::time::Instant> = None;
5700 let mut pending_event: Option<Event> = None;
5704 let mut windows_paste_run = WindowsPasteRun::default();
5705 if crate::key_trace::enabled() {
5706 crate::key_trace::note(&format!(
5707 "--- rpi TUI key trace start pid={} TERM={} raw_mode={} ---",
5708 std::process::id(),
5709 std::env::var("TERM").unwrap_or_else(|_| "(unset)".to_string()),
5710 crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
5711 ));
5712 }
5713 loop {
5714 if !*running_key.lock().unwrap() {
5715 break;
5716 }
5717 if !state_for_key.selector_open() && !state_for_key.extension_dialog_open() {
5718 if let Some(request) = ask_user_for_key.take_pending() {
5719 open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5720 }
5721 }
5722 let ev = if let Some(ev) = pending_event.take() {
5723 ev
5724 } else {
5725 match crossterm::event::poll(
5728 if cfg!(windows) && !windows_paste_run.text.is_empty() {
5729 windows_paste_run.grace()
5730 } else {
5731 std::time::Duration::from_millis(50)
5732 },
5733 ) {
5734 Ok(true) => {}
5735 Ok(false) => {
5736 if cfg!(windows) && !windows_paste_run.text.is_empty() {
5737 windows_paste_run.flush(&editor_for_key);
5738 refresh_autocomplete(&state_for_key, &editor_for_key);
5739 tui_for_key.request_render_reusing_scroll_content();
5740 }
5741 continue;
5742 }
5743 Err(_) => {
5744 state_for_key.cancel_js_preparation();
5745 let _ = tx_for_key.send(TuiMessage::Exit);
5746 break;
5747 }
5748 }
5749 let Ok(ev) = crossterm::event::read() else {
5750 state_for_key.cancel_js_preparation();
5751 let _ = tx_for_key.send(TuiMessage::Exit);
5752 break;
5753 };
5754 ev
5755 };
5756 if let Event::Resize(_cols, _rows) = ev {
5761 tui_for_key.refresh_size();
5762 continue;
5763 }
5764 if let Event::Mouse(m) = ev {
5768 use crossterm::event::MouseEventKind;
5769 match m.kind {
5770 MouseEventKind::ScrollUp => {
5771 let delta = -MOUSE_WHEEL_SCROLL_LINES;
5772 if scroll_for_key.scroll_by(delta) != delta {
5773 tui_for_key.request_render_reusing_scroll_content();
5774 }
5775 }
5776 MouseEventKind::ScrollDown => {
5777 let delta = MOUSE_WHEEL_SCROLL_LINES;
5778 if scroll_for_key.scroll_by(delta) != delta {
5779 tui_for_key.request_render_reusing_scroll_content();
5780 }
5781 }
5782 MouseEventKind::Down(crossterm::event::MouseButton::Right) if cfg!(windows) => {
5783 if let Some(text) = read_clipboard_text() {
5789 if !text.is_empty() {
5790 editor_for_key.handle_paste(&text);
5791 refresh_autocomplete(&state_for_key, &editor_for_key);
5792 tui_for_key.request_render_reusing_scroll_content();
5793 }
5794 }
5795 }
5796 MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
5797 *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
5801 *state_for_key.selection_end.lock().unwrap() = None;
5802 }
5803 MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
5804 if state_for_key.selection_start.lock().unwrap().is_some() {
5808 *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
5809 }
5810 }
5811 MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
5812 if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
5814 let end = (m.column, m.row);
5815
5816 let is_drag = state_for_key.selection_end.lock().unwrap().is_some();
5825 *state_for_key.selection_end.lock().unwrap() = Some(end);
5826
5827 let auto_copy = crate::settings::load_settings()
5829 .ok()
5830 .and_then(|s| s.fullscreen_copy_on_select)
5831 .unwrap_or(true);
5832
5833 if auto_copy && is_drag {
5834 if let Some(selected_text) =
5836 extract_selected_text(&state_for_key.chat_container, start, end)
5837 {
5838 if !selected_text.trim().is_empty() {
5839 let _ = copy_to_clipboard(&selected_text);
5840 }
5841 }
5842 }
5843 }
5844 *state_for_key.selection_start.lock().unwrap() = None;
5845 *state_for_key.selection_end.lock().unwrap() = None;
5846 }
5847 _ => {}
5848 }
5849 continue;
5850 }
5851 let Event::Key(key) = ev else {
5852 if let Event::Paste(text) = ev {
5853 crate::key_trace::note(&format!(
5854 "paste event ({} bytes, bracketed paste supported)",
5855 text.len()
5856 ));
5857 let candidate = text.trim().trim_matches(['\"', '\'']);
5858 let path = std::path::PathBuf::from(candidate);
5859 if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5860 if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5861 add_image_preview(&state_for_key.chat_container, &image);
5862 state_for_key.queue_image(image);
5863 add_note_message(
5864 &state_for_key.chat_container,
5865 "Dropped image attached to the next prompt.",
5866 );
5867 tui_for_key.request_render(false);
5868 continue;
5869 }
5870 }
5871 editor_for_key.handle_paste(&text);
5872 refresh_autocomplete(&state_for_key, &editor_for_key);
5873 tui_for_key.request_render_reusing_scroll_content();
5874 }
5875 continue;
5876 };
5877 crate::key_trace::key(&key, "");
5881 if key.kind != KeyEventKind::Release && state_for_key.cancel_auto_send_on_key() {
5885 tui_for_key.request_render(false);
5886 }
5887 if dispatch_key_event(&state_for_key, &key, &editor_for_key) {
5892 continue;
5893 }
5894 if !should_dispatch_key(key.kind) {
5899 continue;
5900 }
5901
5902 let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5908 && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5909 || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5910 if prompt_abort && state_for_key.cancel_js_preparation() {
5911 state_for_key.set_status(RunStatus::Aborting);
5912 if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5913 close_extension_editor(
5914 &state_for_key,
5915 &ctx_for_key.editor_container,
5916 &editor_for_key,
5917 &tui_for_key,
5918 );
5919 }
5920 tui_for_key.set_render_suspended(false);
5921 tui_for_key.request_render(false);
5922 continue;
5923 }
5924
5925 if key.modifiers == KeyModifiers::CONTROL
5928 && key.code == KeyCode::Char('c')
5929 && state_for_key.selector_open()
5930 {
5931 let selector = state_for_key
5932 .active_selector
5933 .lock()
5934 .unwrap()
5935 .clone()
5936 .expect("selector_open guaranteed Some")
5937 .0;
5938 selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5939 tui_for_key.request_render_reusing_scroll_content();
5940 continue;
5941 }
5942
5943 if state_for_key.extension_dialog_open() {
5947 let cancel = key.code == KeyCode::Esc
5948 || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5949 if cancel {
5950 if !run_extension_cancel(&state_for_key) {
5951 close_extension_editor(
5952 &state_for_key,
5953 &ctx_for_key.editor_container,
5954 &editor_for_key,
5955 &tui_for_key,
5956 );
5957 }
5958 } else if let Some(extension_editor) = state_for_key
5959 .active_extension_editor
5960 .lock()
5961 .unwrap()
5962 .clone()
5963 {
5964 extension_editor.handle_key(key);
5965 } else if let Some(extension_input) =
5966 state_for_key.active_extension_input.lock().unwrap().clone()
5967 {
5968 extension_input.handle_key(key);
5969 }
5970 tui_for_key.request_render_reusing_scroll_content();
5971 continue;
5972 }
5973
5974 if keybinding_matches(
5982 &keybindings_for_key,
5983 &key,
5984 rpi_tui::keybindings::keys::CLEAR,
5985 ) {
5986 if key.kind != KeyEventKind::Repeat {
5990 let now = std::time::Instant::now();
5991 match ctrl_c_action(last_sigint_time, now) {
5992 CtrlCAction::Exit => {
5993 last_sigint_time = None;
5994 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
6000 let _ = tx_for_key.send(TuiMessage::Exit);
6001 } else {
6002 emergency_exit(&tui_for_key);
6003 }
6004 }
6005 CtrlCAction::Clear => {
6006 editor_for_key.clear();
6007 refresh_autocomplete(&state_for_key, &editor_for_key);
6008 last_sigint_time = Some(now);
6009 tui_for_key.request_render(false);
6010 }
6011 }
6012 }
6013 continue;
6014 }
6015
6016 if !state_for_key.selector_open()
6020 && keybinding_matches(
6021 &keybindings_for_key,
6022 &key,
6023 rpi_tui::keybindings::keys::MESSAGE_COPY,
6024 )
6025 {
6026 if editor_for_key.has_selection() && editor_for_key.copy_selection() {
6027 add_note_message(&state_for_key.chat_container, "Copied!");
6028 } else {
6029 copy_last_assistant(&state_for_key, &state_for_key.chat_container);
6030 }
6031 tui_for_key.request_render(false);
6032 continue;
6033 }
6034
6035 if keybinding_matches(
6040 &keybindings_for_key,
6041 &key,
6042 rpi_tui::keybindings::keys::SUSPEND,
6043 ) {
6044 handle_suspend(&tui_for_key);
6045 continue;
6046 }
6047
6048 if state_for_key.selector_open() {
6053 if key.code == KeyCode::Esc {
6059 let (selector, _kind) = state_for_key
6060 .active_selector
6061 .lock()
6062 .unwrap()
6063 .clone()
6064 .expect("selector_open guaranteed Some");
6065 selector.handle_key(key);
6066 continue;
6067 }
6068 let (selector, _kind) = state_for_key
6069 .active_selector
6070 .lock()
6071 .unwrap()
6072 .clone()
6073 .expect("selector_open guaranteed Some");
6074 selector.handle_key(key);
6075 tui_for_key.request_render_reusing_scroll_content();
6076 continue;
6077 }
6078
6079 if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
6084 if !editor_for_key.get_text().is_empty() {
6085 editor_for_key.handle_key(key);
6087 refresh_autocomplete(&state_for_key, &editor_for_key);
6088 tui_for_key.request_render_reusing_scroll_content();
6089 continue;
6090 }
6091 if *state_for_key.status.lock().unwrap() == RunStatus::Idle {
6094 let _ = tx_for_key.send(TuiMessage::Exit);
6095 } else {
6096 emergency_exit(&tui_for_key);
6097 }
6098 continue;
6099 }
6100
6101 if keybinding_matches(
6106 &keybindings_for_key,
6107 &key,
6108 rpi_tui::keybindings::keys::INTERRUPT,
6109 ) {
6110 let status = *state_for_key.status.lock().unwrap();
6111 if status == RunStatus::Working {
6112 state_for_key.set_status(RunStatus::Aborting);
6113 ask_user_for_key.cancel_all();
6114 if !run_extension_cancel(&state_for_key)
6115 && state_for_key.extension_dialog_open()
6116 {
6117 close_extension_editor(
6118 &state_for_key,
6119 &ctx_for_key.editor_container,
6120 &editor_for_key,
6121 &tui_for_key,
6122 );
6123 }
6124 abort_run_restoring_queue(
6127 lane_for_key.clone(),
6128 editor_for_key.clone(),
6129 state_for_key.clone(),
6130 tui_for_key.clone(),
6131 );
6132 continue;
6133 }
6134 if state_for_key.user_bash_running() {
6140 state_for_key.cancel_user_bash();
6141 tui_for_key.request_render(false);
6142 continue;
6143 }
6144 if status == RunStatus::Idle
6145 && editor_for_key.get_text().trim().is_empty()
6146 && double_escape_action != "none"
6147 {
6148 let now = std::time::Instant::now();
6149 if double_escape_trigger(last_escape_time, now) {
6150 last_escape_time = None;
6151 match double_escape_action.as_str() {
6152 "tree" => {
6153 let _ = tx_for_key.send(TuiMessage::OpenTree);
6154 }
6155 "fork" => {
6156 let _ = tx_for_key.send(TuiMessage::ForkSession);
6157 }
6158 _ => {}
6159 }
6160 } else {
6161 last_escape_time = Some(now);
6162 }
6163 }
6164 continue;
6165 }
6166
6167 if keybinding_matches(
6170 &keybindings_for_key,
6171 &key,
6172 rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
6173 ) {
6174 launch_external_editor(editor_for_key.get_expanded_text(), tx_for_key.clone());
6175 continue;
6176 }
6177
6178 if keybinding_matches(
6181 &keybindings_for_key,
6182 &key,
6183 rpi_tui::keybindings::keys::TOOLS_EXPAND,
6184 ) {
6185 let expanded = state_for_key.toggle_tool_outputs();
6186 let session = ctx_for_key.session.clone();
6188 tokio::spawn(async move {
6189 let _ = session
6190 .set_value("display.tool_outputs_expanded", serde_json::json!(expanded))
6191 .await;
6192 });
6193 tui_for_key.request_render(false);
6194 continue;
6195 }
6196
6197 if keybinding_matches(
6199 &keybindings_for_key,
6200 &key,
6201 rpi_tui::keybindings::keys::THINKING_TOGGLE,
6202 ) {
6203 let hide = state_for_key.toggle_thinking();
6204 let session = ctx_for_key.session.clone();
6206 tokio::spawn(async move {
6207 let _ = session
6208 .set_value("display.hide_thinking", serde_json::json!(hide))
6209 .await;
6210 });
6211 tui_for_key.request_render(false);
6212 continue;
6213 }
6214
6215 if keybinding_matches(
6221 &keybindings_for_key,
6222 &key,
6223 rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
6224 ) {
6225 let current = state_for_key.current_model_id();
6226 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6229 if let Some(next) = cycle_next_model(&scope, ¤t) {
6230 state_for_key.set_current_model(&next);
6231 let lane = lane_for_key.clone();
6232 tokio::spawn(async move {
6233 let _ = lane.set_model(next).await;
6234 });
6235 tui_for_key.request_render_reusing_scroll_content();
6236 }
6237 continue;
6238 }
6239
6240 if keybinding_matches(
6243 &keybindings_for_key,
6244 &key,
6245 rpi_tui::keybindings::keys::MODEL_CYCLE_BACKWARD,
6246 ) {
6247 let current = state_for_key.current_model_id();
6248 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
6249 if let Some(prev) = cycle_prev_model(&scope, ¤t) {
6250 state_for_key.set_current_model(&prev);
6251 let lane = lane_for_key.clone();
6252 tokio::spawn(async move {
6253 let _ = lane.set_model(prev).await;
6254 });
6255 tui_for_key.request_render_reusing_scroll_content();
6256 }
6257 continue;
6258 }
6259
6260 if keybinding_matches(
6263 &keybindings_for_key,
6264 &key,
6265 rpi_tui::keybindings::keys::THINKING_CYCLE,
6266 ) {
6267 let lane = lane_for_key.clone();
6268 let catalog = model_catalog_for_key.clone();
6269 let state = state_for_key.clone();
6270 tokio::spawn(async move {
6271 let Ok(current_model) = lane.get_model().await else {
6272 return;
6273 };
6274 let levels = catalog
6275 .iter()
6276 .find(|model| {
6277 model.provider == current_model.provider && model.id == current_model.id
6278 })
6279 .map(|model| model.supported_thinking_levels())
6280 .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
6281 if levels.is_empty() {
6282 return;
6283 }
6284 let current = lane
6285 .get_thinking_level()
6286 .await
6287 .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6288 let next = levels
6289 .iter()
6290 .position(|level| *level == current)
6291 .map(|index| levels[(index + 1) % levels.len()])
6292 .unwrap_or(levels[0]);
6293 if lane.set_thinking_level(next).await.is_ok() {
6294 state
6295 .footer
6296 .set_thinking_level(Some(thinking_level_name(next)));
6297 state.tui.as_ref().map(|tui| tui.request_render(false));
6298 }
6299 });
6300 continue;
6301 }
6302
6303 if keybinding_matches(
6313 &keybindings_for_key,
6314 &key,
6315 rpi_tui::keybindings::keys::PASTE_IMAGE,
6316 ) {
6317 match read_clipboard_image() {
6318 Ok(Some(image)) => {
6319 add_image_preview(&state_for_key.chat_container, &image);
6320 state_for_key.queue_image(image);
6321 add_note_message(
6322 &state_for_key.chat_container,
6323 "Clipboard image attached to the next prompt.",
6324 );
6325 tui_for_key.request_render(false);
6326 continue;
6327 }
6328 Ok(None) | Err(_) => {}
6329 }
6330 if cfg!(windows) {
6341 if let Some(text) = read_clipboard_text() {
6342 if !text.is_empty() {
6343 editor_for_key.handle_paste(&text);
6344 refresh_autocomplete(&state_for_key, &editor_for_key);
6345 tui_for_key.request_render_reusing_scroll_content();
6346 continue;
6347 }
6348 }
6349 }
6350 }
6351
6352 if key
6354 .modifiers
6355 .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6356 && key.code == KeyCode::Char('F')
6357 {
6358 state_for_key.search.activate();
6359 state_for_key.search_bar.set_visible(true);
6360 state_for_key.search_bar.set_query("");
6361 state_for_key.search_bar.set_match_info(0, 0);
6362 tui_for_key.request_render(false);
6363 continue;
6364 }
6365
6366 if state_for_key.search.is_active() {
6368 let search_bar = state_for_key.search_bar.clone();
6369 match key.code {
6370 KeyCode::Esc => {
6371 state_for_key.search.deactivate();
6372 search_bar.set_visible(false);
6373 tui_for_key.request_render(false);
6374 continue;
6375 }
6376 KeyCode::Enter => {
6377 if key.modifiers.contains(KeyModifiers::SHIFT) {
6378 state_for_key.search.previous_match();
6379 } else {
6380 state_for_key.search.next_match();
6381 }
6382 let match_index = state_for_key.search.get_match_index();
6384 let match_count = state_for_key.search.get_match_count();
6385 search_bar.set_match_info(match_index, match_count);
6386 tui_for_key.request_render(false);
6387 continue;
6388 }
6389 KeyCode::Backspace => {
6390 state_for_key.search.backspace();
6391 let lines = collect_transcript_lines(&state_for_key.chat_container);
6393 state_for_key.search.find_matches(&lines);
6394 let query = state_for_key.search.get_query();
6396 let match_count = state_for_key.search.get_match_count();
6397 search_bar.set_query(&query);
6398 search_bar.set_match_info(0, match_count);
6399 tui_for_key.request_render(false);
6400 continue;
6401 }
6402 KeyCode::Char(c) => {
6403 state_for_key.search.append_char(c);
6404 let lines = collect_transcript_lines(&state_for_key.chat_container);
6406 state_for_key.search.find_matches(&lines);
6407 let query = state_for_key.search.get_query();
6409 let match_count = state_for_key.search.get_match_count();
6410 search_bar.set_query(&query);
6411 search_bar.set_match_info(0, match_count);
6412 tui_for_key.request_render(false);
6413 continue;
6414 }
6415 _ => continue,
6416 }
6417 }
6418
6419 if keybinding_matches(
6423 &keybindings_for_key,
6424 &key,
6425 rpi_tui::keybindings::keys::MODEL_SELECT,
6426 ) {
6427 if let Some(cmd) = registry_for_key.find("/model") {
6428 cmd.execute(&ctx_for_key, "");
6429 }
6430 continue;
6431 }
6432
6433 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6435 if accept_top_suggestion(&state_for_key, &editor_for_key) {
6436 tui_for_key.request_render_reusing_scroll_content();
6437 }
6438 continue;
6439 }
6440
6441 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6445 let delta = -transcript_page_size(scroll_for_key.viewport_height());
6446 if scroll_for_key.scroll_by(delta) != delta {
6447 tui_for_key.request_render_reusing_scroll_content();
6448 }
6449 continue;
6450 }
6451 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
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::Home {
6459 scroll_for_key.scroll_to_start();
6460 tui_for_key.request_render_reusing_scroll_content();
6461 continue;
6462 }
6463 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
6464 scroll_for_key.scroll_to_end();
6465 tui_for_key.request_render_reusing_scroll_content();
6466 continue;
6467 }
6468
6469 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
6478 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6479 if editor_for_key.get_text().is_empty() || browsing {
6480 navigate_history(&state_for_key, &editor_for_key, -1);
6481 tui_for_key.request_render_reusing_scroll_content();
6482 continue;
6483 }
6484 }
6485 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
6486 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6487 if editor_for_key.get_text().is_empty() || browsing {
6488 navigate_history(&state_for_key, &editor_for_key, 1);
6489 tui_for_key.request_render_reusing_scroll_content();
6490 continue;
6491 }
6492 }
6493
6494 if keybinding_matches(
6499 &keybindings_for_key,
6500 &key,
6501 rpi_tui::keybindings::keys::DEQUEUE,
6502 ) {
6503 tokio::spawn(handle_dequeue(
6515 lane_for_key.clone(),
6516 editor_for_key.clone(),
6517 state_for_key.clone(),
6518 tui_for_key.clone(),
6519 ));
6520 continue;
6521 }
6522
6523 if keybinding_matches(
6528 &keybindings_for_key,
6529 &key,
6530 rpi_tui::keybindings::keys::MESSAGE_FOLLOW_UP,
6531 ) {
6532 let prompt = editor_for_key.get_expanded_text().trim().to_string();
6533 if prompt.is_empty() {
6534 continue;
6535 }
6536 editor_for_key.clear();
6537 let status = *state_for_key.status.lock().unwrap();
6538 if status == RunStatus::Idle {
6539 if state_for_key.try_start_working() {
6540 add_user_message(&state_for_key.chat_container, &prompt);
6544 push_history(&state_for_key, &prompt);
6545 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
6546 }
6547 } else {
6548 let message = AgentMessage::User(UserMessage::new(prompt, 0));
6552 let lane = lane_for_key.clone();
6553 let chat = state_for_key.chat_container.clone();
6554 let tui = tui_for_key.clone();
6555 let state = state_for_key.clone();
6556 tokio::spawn(async move {
6557 if let Err(error) = lane.follow_up(message).await {
6558 add_error_message(&chat, &format!("Could not queue message: {error}"));
6559 tui.request_render(false);
6560 return;
6561 }
6562 refresh_pending_messages(&state, &lane).await;
6563 tui.request_render(false);
6564 });
6565 }
6566 tui_for_key.request_render(false);
6567 continue;
6568 }
6569
6570 if cfg!(windows) {
6575 let text_key = match key.code {
6576 KeyCode::Char(c)
6577 if !key
6578 .modifiers
6579 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
6580 {
6581 Some(c)
6582 }
6583 _ => None,
6584 };
6585 if let Some(ch) = text_key {
6586 if ch == '\r' || ch == '\n' {
6587 } else {
6589 windows_paste_run.feed(ch, std::time::Instant::now());
6590 continue;
6591 }
6592 }
6593 if key.modifiers.is_empty()
6594 && matches!(
6595 key.code,
6596 KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
6597 )
6598 {
6599 let (more_queued, stashed) = probe_paste_input(pending_event.take(), || {
6600 if crossterm::event::poll(PASTE_PROBE).unwrap_or(false) {
6601 crossterm::event::read().ok()
6602 } else {
6603 None
6604 }
6605 });
6606 pending_event = stashed;
6607 let clipboard = if !more_queued && windows_paste_run.burst {
6608 read_clipboard_text()
6609 } else {
6610 None
6611 };
6612 if windows_enter_in_paste(&windows_paste_run, more_queued, clipboard.as_deref())
6613 {
6614 windows_paste_run.feed('\n', std::time::Instant::now());
6615 crate::key_trace::note("windows paste run -> newline");
6616 continue;
6617 }
6618 crate::key_trace::note("windows paste run -> flush + submit");
6619 windows_paste_run.flush(&editor_for_key);
6620 } else {
6621 windows_paste_run.flush(&editor_for_key);
6622 }
6623 }
6624
6625 editor_for_key.handle_key(key);
6631 refresh_autocomplete(&state_for_key, &editor_for_key);
6632 tui_for_key.request_render_reusing_scroll_content();
6633 }
6634 });
6635
6636 if let Some(reason) = crate::session::dispatch_session_event_async(
6642 reload_context,
6643 rpi_plugin_sdk::EventTag::SessionStart,
6644 )
6645 .await
6646 {
6647 tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
6648 }
6649
6650 let mut prompts: Vec<String> = Vec::new();
6652 if let Some(init) = initial {
6653 prompts.push(init);
6654 }
6655 for m in extra_messages {
6656 prompts.push(m.clone());
6657 }
6658 if prompts.is_empty() && lane.has_pending_resume().await {
6665 run_prompt_streaming(
6666 &lane,
6667 "",
6668 true,
6669 &tui,
6670 &state,
6671 drain_handle.is_some(),
6672 Vec::new(),
6673 )
6674 .await;
6675 }
6676
6677 let mut images = initial_images;
6678 for prompt in prompts {
6679 if !*running.lock().unwrap() {
6680 break;
6681 }
6682 add_user_message(&chat_container, &prompt);
6686 tui.request_render(false);
6687 run_prompt_streaming(
6688 &lane,
6689 &prompt,
6690 false,
6691 &tui,
6692 &state,
6693 drain_handle.is_some(),
6694 std::mem::take(&mut images),
6695 )
6696 .await;
6697 }
6698
6699 loop {
6701 if !*running.lock().unwrap() {
6702 break;
6703 }
6704 match rx.recv().await {
6705 Some(TuiMessage::UserInput(prompt)) => {
6706 state.clear_editor_programmatically();
6714 let prompt_images = state.take_pending_images();
6715 if !prompt_images.is_empty() {
6716 add_note_message(
6717 &chat_container,
6718 &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6719 );
6720 }
6721 run_prompt_streaming(
6722 &lane,
6723 &prompt,
6724 false,
6725 &tui,
6726 &state,
6727 drain_handle.is_some(),
6728 prompt_images,
6729 )
6730 .await;
6731 }
6732 Some(TuiMessage::ExternalEditorResult(result)) => {
6733 match result {
6734 Ok(text) => {
6735 let cursor = text.chars().count();
6736 editor.set_text(&text);
6737 editor.set_cursor(0, cursor);
6738 add_note_message(&chat_container, "Draft updated from external editor.");
6739 }
6740 Err(error) => add_error_message(&chat_container, &error),
6741 }
6742 tui.request_render(false);
6743 }
6744 Some(TuiMessage::UserBashFinished(report)) => {
6745 state.finish_user_bash();
6748 let message = rpi_harness::messages::bash_execution_message(
6749 report.command,
6750 report.output,
6751 report.exit_code,
6752 report.cancelled,
6753 report.truncated,
6754 report.full_output_path,
6755 Some(report.exclude_from_context),
6756 now_ms(),
6757 );
6758 if *state.status.lock().unwrap() == RunStatus::Idle {
6762 if let Err(error) = lane.append_message(message).await {
6763 add_error_message(
6764 &chat_container,
6765 &format!("Could not record bash result: {error}"),
6766 );
6767 tui.request_render(false);
6768 }
6769 } else {
6770 state.pending_bash_messages.lock().unwrap().push(message);
6771 }
6772 }
6773 Some(TuiMessage::OpenTree) => {
6774 if *state.status.lock().unwrap() != RunStatus::Idle {
6775 add_note_message(
6776 &chat_container,
6777 "Wait for the current run to finish before opening the tree.",
6778 );
6779 tui.request_render(false);
6780 } else {
6781 open_tree_selector(
6782 &harness,
6783 &state,
6784 &editor_container,
6785 &editor,
6786 &tui,
6787 &chat_container,
6788 &tx,
6789 )
6790 .await;
6791 }
6792 }
6793 Some(TuiMessage::NavigateTree(entry_id)) => {
6794 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6795 Ok(result) => match result.outcome {
6796 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6797 chat_container.clear();
6798 add_welcome_message(&chat_container);
6799 render_session_history(
6800 &harness,
6801 &chat_container,
6802 state.markdown_transformer(),
6803 Some(state.extension_session.clone()),
6804 )
6805 .await;
6806 add_note_message(
6807 &chat_container,
6808 "Moved to the selected session entry.",
6809 );
6810 }
6811 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6812 add_error_message(&chat_container, &error.message);
6813 }
6814 _ => add_note_message(
6815 &chat_container,
6816 "The selected entry could not be opened.",
6817 ),
6818 },
6819 Err(error) => add_error_message(
6820 &chat_container,
6821 &format!("Could not navigate session tree: {error}"),
6822 ),
6823 }
6824 tui.request_render(false);
6825 }
6826 Some(TuiMessage::ClearChat) => {
6827 chat_container.clear();
6828 add_welcome_message(&chat_container);
6829 tui.request_render(false);
6830 }
6831 Some(TuiMessage::Compact) => {
6832 run_compact(&lane, &tui, &state).await;
6833 }
6834 Some(TuiMessage::Copy) => {
6835 copy_last_assistant(&state, &chat_container);
6836 tui.request_render(false);
6837 }
6838 Some(TuiMessage::Exit) => {
6839 *running.lock().unwrap() = false;
6840 break;
6841 }
6842 Some(TuiMessage::SwitchSession(id)) => {
6843 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6844 tui.request_render(false);
6845 }
6846 Some(TuiMessage::ImportSession(path)) => {
6847 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6848 tui.request_render(false);
6849 }
6850 Some(TuiMessage::ShareSession) => {
6851 share_session(&harness, &chat_container).await;
6852 tui.request_render(false);
6853 }
6854 Some(TuiMessage::SetSessionName(name)) => {
6855 let outcome = harness.session().set_name(Some(&name)).await;
6856 match outcome {
6857 Ok(_) => add_note_message(
6858 &chat_container,
6859 &format!("Session renamed to \"{name}\"."),
6860 ),
6861 Err(e) => add_error_message(
6862 &chat_container,
6863 &format!("Could not rename session: {e}"),
6864 ),
6865 }
6866 tui.request_render(false);
6867 }
6868 Some(TuiMessage::ExportSession) => {
6869 let file_name = agent_session
6870 .default_export_file_name(crate::export::ExportFormat::Markdown)
6871 .await;
6872 let path = cwd.join(&file_name);
6873 match agent_session.export_to_markdown(&path).await {
6874 Ok(_) => add_note_message(
6875 &chat_container,
6876 &format!("Exported session to {}", path.display()),
6877 ),
6878 Err(e) => {
6879 add_error_message(&chat_container, &format!("Could not write export: {e}"))
6880 }
6881 }
6882 tui.request_render(false);
6883 }
6884 Some(TuiMessage::ExportSessionWithFormat(format)) => {
6885 let file_name = agent_session.default_export_file_name(format.clone()).await;
6886 let path = cwd.join(&file_name);
6887 match agent_session.export(format, &path).await {
6888 Ok(_) => add_note_message(
6889 &chat_container,
6890 &format!("Exported session to {}", path.display()),
6891 ),
6892 Err(e) => {
6893 add_error_message(&chat_container, &format!("Could not write export: {e}"))
6894 }
6895 }
6896 tui.request_render(false);
6897 }
6898 Some(TuiMessage::ShowUsage) => {
6899 let stats = agent_session.usage_stats().await.ok();
6900 let total = agent_session.total_usage().await.ok();
6901 let snapshot = agent_session.snapshot().await.ok();
6902 match stats {
6903 Some(stats) => {
6904 let mut lines = vec![
6905 "## Usage".to_string(),
6906 String::new(),
6907 format!("- messages: {}", stats.message_count),
6908 format!("- total tokens: {}", stats.total_tokens),
6909 format!("- cached tokens: {}", stats.cached_tokens),
6910 format!("- uncached tokens: {}", stats.uncached_tokens),
6911 format!("- cost: ${:.4}", stats.cost_total),
6912 ];
6913 match stats.cache_hit_ratio() {
6914 Some(ratio) => {
6915 lines.push(format!("- cache hit ratio: {:.1}%", ratio * 100.0))
6916 }
6917 None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
6918 }
6919 if let Some(total) = total {
6920 lines.push(String::new());
6921 lines.push(format!(
6922 "- last cumulative input/output: {} / {}",
6923 total.input, total.output
6924 ));
6925 }
6926 if let Some(snapshot) = snapshot {
6927 lines.push(String::new());
6928 lines.push(format!("- lane: `{}`", snapshot.lane));
6929 lines.push(format!(
6930 "- leaf: `{}`",
6931 snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
6932 ));
6933 if let Some(active) = &snapshot.active {
6934 lines.push(format!(
6935 "- active operation: {} (`{}`)",
6936 active.kind.as_str(),
6937 active.run_id
6938 ));
6939 }
6940 }
6941 add_note_message(&chat_container, &lines.join("\n"));
6942 }
6943 None => {
6944 add_error_message(&chat_container, "Could not read session usage stats.")
6945 }
6946 }
6947 tui.request_render(false);
6948 }
6949 Some(TuiMessage::ForkSession) => {
6950 fork_session(&harness, &cwd, &chat_container, &state).await;
6951 tui.request_render(false);
6952 }
6953 Some(TuiMessage::ReloadExtensions) => {
6954 let reload_ctx = ctx.reload_context.clone();
6961 add_note_message(&chat_container, "Reloading extensions + resources…");
6962 tui.request_render(false);
6963 let outcome =
6964 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6965 let fresh_transformer = build_markdown_transformer(
6974 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6975 );
6976 state.set_markdown_transformer_with_reinstall(fresh_transformer);
6977 if outcome.had_errors {
6978 add_error_message(&chat_container, &outcome.summary);
6982 } else if outcome.had_warnings {
6983 add_note_message(
6988 &chat_container,
6989 &format!(
6990 "{} (with warnings — see stderr for details).",
6991 outcome.summary
6992 ),
6993 );
6994 } else {
6995 add_note_message(&chat_container, &outcome.summary);
6996 }
6997 tui.request_render(false);
6998 }
6999 None => break,
7000 }
7001 }
7002
7003 ask_user_bridge.shutdown();
7005 *running.lock().unwrap() = false;
7006 for handle in update_check_handles {
7007 handle.abort();
7008 let _ = handle.await;
7009 }
7010 let _ = key_handle.await;
7013 tick_handle.abort();
7014 if let Some(handle) = drain_handle {
7015 handle.abort();
7016 }
7017 reload_context.mailbox.clear();
7021 reload_bridge_handle.abort();
7022 tui.stop(Default::default());
7023
7024 let session_id = harness.session().storage().metadata().id;
7028 if let Some(command) = format_resume_command(&session_id, &cwd, args.session_dir.as_deref()) {
7029 println!("\x1b[2mTo resume this session:\x1b[0m {command}");
7030 }
7031 println!("\nGoodbye!");
7032 let _ = args;
7033
7034 0
7035}
7036
7037fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
7042 std::thread::spawn(move || {
7043 let file = match tempfile::Builder::new()
7044 .prefix("rpi-draft-")
7045 .suffix(".md")
7046 .tempfile()
7047 {
7048 Ok(file) => file,
7049 Err(error) => {
7050 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7051 "Could not create editor file: {error}"
7052 ))));
7053 return;
7054 }
7055 };
7056 if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
7057 let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
7058 "Could not write editor file: {error}"
7059 ))));
7060 return;
7061 }
7062 let editor = std::env::var("RPI_EXTERNAL_EDITOR")
7063 .ok()
7064 .filter(|value| !value.trim().is_empty())
7065 .or_else(|| std::env::var("VISUAL").ok())
7066 .or_else(|| std::env::var("EDITOR").ok())
7067 .unwrap_or_else(|| {
7068 if cfg!(windows) {
7069 "notepad".to_string()
7070 } else {
7071 "nano".to_string()
7072 }
7073 });
7074 let status = std::process::Command::new(&editor)
7075 .arg(file.path())
7076 .status();
7077 let result = match status {
7078 Ok(status) if status.success() => std::fs::read_to_string(file.path())
7079 .map_err(|error| format!("Could not read editor file: {error}")),
7080 Ok(status) => Err(format!("External editor exited with {status}")),
7081 Err(error) => Err(format!(
7082 "Could not launch external editor `{editor}`: {error}"
7083 )),
7084 };
7085 let _ = tx.send(TuiMessage::ExternalEditorResult(result));
7086 });
7087}
7088
7089fn reconcile_streamed_assistant_completion(
7092 current_assistant: &Arc<std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>>,
7093 last_assistant_text: &Mutex<String>,
7094 final_message: Option<&AssistantMessage>,
7095) {
7096 let final_blocks = final_message.map(assistant_blocks);
7097 let final_text = final_message.map(assistant_text);
7098 let component = current_assistant.lock().unwrap().take();
7099
7100 if let Some(component) = component {
7101 if let Some(blocks) = final_blocks.as_deref() {
7102 component.update_blocks(blocks);
7103 }
7104 component.set_streaming(false);
7105 }
7106
7107 if let Some(text) = final_text.filter(|text| !text.is_empty()) {
7108 *last_assistant_text.lock().unwrap() = text;
7109 }
7110}
7111
7112async fn run_prompt_streaming(
7122 lane: &Arc<dyn AgentLane>,
7123 prompt: &str,
7124 resume: bool,
7125 tui: &Arc<TuiAltScreen>,
7126 state: &Arc<TuiState>,
7127 streaming: bool,
7128 images: Vec<rpi_ai::types::ImageContent>,
7129) {
7130 state.set_status(RunStatus::Working);
7131 tui.request_render(false);
7132
7133 let outcome = if resume {
7134 match lane.resume_pending().await {
7137 Ok(Some(result)) => Ok(result),
7138 Ok(None) => {
7141 state.set_status(RunStatus::Idle);
7142 tui.request_render(false);
7143 return;
7144 }
7145 Err(error) => Err(error),
7146 }
7147 } else {
7148 lane.prompt_text(prompt, images).await
7149 };
7150
7151 if streaming {
7152 let final_message = match &outcome {
7157 Ok(result) => match &result.outcome {
7158 HarnessRunOutcome::Completed { final_message, .. }
7159 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7160 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7161 HarnessRunOutcome::Suspended { .. } => None,
7162 },
7163 Err(_) => None,
7164 };
7165 reconcile_streamed_assistant_completion(
7166 &state.current_assistant,
7167 &state.last_assistant_text,
7168 final_message,
7169 );
7170 }
7171
7172 state.set_status(RunStatus::Idle);
7173 refresh_pending_messages(state, lane).await;
7177
7178 if let Ok(result) = &outcome {
7183 let final_message: Option<&AssistantMessage> = match &result.outcome {
7184 HarnessRunOutcome::Completed { final_message, .. }
7185 | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
7186 HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
7187 HarnessRunOutcome::Suspended { .. } => None,
7188 };
7189 if let Some(a) = final_message {
7190 record_context_usage(&state.footer, a);
7191 }
7192 }
7193
7194 match outcome {
7195 Ok(result) => match &result.outcome {
7196 HarnessRunOutcome::Failed {
7197 error,
7198 final_message,
7199 ..
7200 } => {
7201 let already_rendered = streaming && final_message.is_some();
7209 if !already_rendered {
7210 let msg = final_message
7211 .as_ref()
7212 .and_then(|m| m.error_message.clone())
7213 .unwrap_or_else(|| format!("{error:?}"));
7214 add_error_message(&state.chat_container, &msg);
7215 }
7216 }
7217 HarnessRunOutcome::Suspended { .. } => {
7218 add_error_message(
7219 &state.chat_container,
7220 "Run suspended (deferred) — resume is not supported in v1.",
7221 );
7222 }
7223 HarnessRunOutcome::Aborted { final_message, .. } => {
7224 if !streaming {
7227 add_error_message(&state.chat_container, "Request aborted.");
7228 let _ = final_message; }
7230 }
7231 HarnessRunOutcome::Completed { final_message, .. } => {
7232 if !streaming {
7233 let text = assistant_text(final_message);
7234 if !text.is_empty() {
7235 add_assistant_message_blocking(
7236 &state.chat_container,
7237 &text,
7238 state.markdown_transformer(),
7239 );
7240 *state.last_assistant_text.lock().unwrap() = text;
7241 }
7242 }
7243 }
7244 },
7245 Err(e) => {
7246 add_error_message(&state.chat_container, &e.to_string());
7247 }
7248 }
7249
7250 tui.request_render(false);
7251}
7252
7253async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
7258 state.set_status(RunStatus::Working);
7259 tui.request_render(false);
7260 match lane.compact(None).await {
7261 Ok(_) => {
7262 add_note_message(&state.chat_container, "Conversation compacted.");
7263 state.footer.set_context_tokens(None);
7267 }
7268 Err(e) => {
7269 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
7270 }
7271 }
7272 state.set_status(RunStatus::Idle);
7273 tui.request_render(false);
7274}
7275
7276fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
7280 let text = state.last_assistant_text.lock().unwrap().clone();
7281 if text.is_empty() {
7282 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
7283 return;
7284 }
7285 if copy_to_clipboard(&text) {
7286 add_note_message(chat, "Copied last reply to the clipboard.");
7287 } else {
7288 let preview: String = text.chars().take(200).collect();
7291 add_note_message(
7292 chat,
7293 &format!(
7294 "Clipboard unavailable. Last reply: {preview}{}",
7295 if text.chars().count() > 200 {
7296 "…"
7297 } else {
7298 ""
7299 }
7300 ),
7301 );
7302 }
7303}
7304
7305fn extract_selected_text(
7308 chat_container: &Arc<Container>,
7309 start: (u16, u16),
7310 end: (u16, u16),
7311) -> Option<String> {
7312 use rpi_tui::Component;
7313
7314 let lines = chat_container.render(80); if lines.is_empty() {
7317 return None;
7318 }
7319
7320 let (start_row, end_row) = if start.1 <= end.1 {
7322 (start.1 as usize, end.1 as usize)
7323 } else {
7324 (end.1 as usize, start.1 as usize)
7325 };
7326
7327 let start_row = start_row.min(lines.len().saturating_sub(1));
7329 let end_row = end_row.min(lines.len().saturating_sub(1));
7330
7331 if start_row > end_row {
7332 return None;
7333 }
7334
7335 let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
7337
7338 if selected_lines.is_empty() {
7339 return None;
7340 }
7341
7342 Some(selected_lines.join("\n"))
7343}
7344
7345#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7349fn copy_to_clipboard(text: &str) -> bool {
7350 match arboard::Clipboard::new() {
7351 Ok(mut cb) => cb.set_text(text).is_ok(),
7352 Err(_) => false,
7353 }
7354}
7355
7356#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7357fn copy_to_clipboard(_text: &str) -> bool {
7358 false
7359}
7360
7361#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7365fn read_clipboard_text() -> Option<String> {
7366 let mut clipboard = arboard::Clipboard::new().ok()?;
7367 clipboard.get_text().ok()
7368}
7369
7370#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7371fn read_clipboard_text() -> Option<String> {
7372 None
7373}
7374
7375#[cfg(all(feature = "clipboard", not(target_os = "android")))]
7379fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7380 let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
7381 let image = match clipboard.get_image() {
7382 Ok(image) => image,
7383 Err(_) => return Ok(None),
7384 };
7385 let width =
7386 u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
7387 let height =
7388 u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
7389 if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
7390 return Err("clipboard image dimensions are outside the supported range".into());
7391 }
7392 let mut bytes = Vec::new();
7393 {
7394 let mut encoder = png::Encoder::new(&mut bytes, width, height);
7395 encoder.set_color(png::ColorType::Rgba);
7396 encoder.set_depth(png::BitDepth::Eight);
7397 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
7398 writer
7399 .write_image_data(&image.bytes)
7400 .map_err(|e| e.to_string())?;
7401 }
7402 Ok(Some(rpi_ai::types::ImageContent {
7403 kind: rpi_ai::types::ImageContentType,
7404 data: base64::engine::general_purpose::STANDARD.encode(bytes),
7405 mime_type: "image/png".into(),
7406 }))
7407}
7408
7409fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
7410 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
7411 let mut options = ImageOptions::default();
7412 options.width = Some(48);
7413 options.alt_text = Some("Attached image".into());
7414 chat.add_child(Arc::new(Image::from_data(bytes, options)));
7415 chat.add_child(Arc::new(Spacer::new(1)));
7416 }
7417}
7418
7419#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7420fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7421 Ok(None)
7422}
7423
7424fn add_assistant_message_blocking(
7431 container: &Arc<Container>,
7432 text: &str,
7433 transformer: Option<MarkdownTransformer>,
7434) {
7435 if text.is_empty() {
7436 return;
7437 }
7438 let msg = Arc::new(AssistantMessageComponent::new(
7439 AssistantMessageOptions::default(),
7440 ));
7441 if let Some(t) = &transformer {
7442 msg.set_markdown_transformer(Some(t.clone()));
7443 }
7444 msg.update_text(text);
7445 container.add_child(msg);
7446 container.add_child(Arc::new(Spacer::new(1)));
7447}
7448
7449async fn refresh_pending_messages(state: &Arc<TuiState>, lane: &Arc<dyn AgentLane>) {
7460 if let Ok(queued) = lane.queued_messages().await {
7461 state.set_pending_queue(queued);
7462 }
7463}
7464
7465fn transcript_has_live_panel(
7471 bash_components_present: bool,
7472 tool_components: &HashMap<String, Arc<ToolExecutionComponent>>,
7473) -> bool {
7474 bash_components_present
7475 || tool_components
7476 .values()
7477 .any(|component| component.status() == ToolStatus::Running)
7478}
7479
7480fn merge_queued_into_draft(queued_text: &str, current: &str) -> String {
7483 if current.trim().is_empty() {
7484 queued_text.to_string()
7485 } else {
7486 format!("{queued_text}\n\n{current}")
7487 }
7488}
7489
7490fn set_editor_text_caret_at_end(editor: &Arc<Editor>, text: &str) {
7495 editor.set_text(text);
7496 let last_row = text.split('\n').count().saturating_sub(1);
7497 let last_len = text.split('\n').next_back().map(str::len).unwrap_or(0);
7498 editor.set_cursor(last_row, last_len);
7499}
7500
7501async fn restore_queued_messages_to_editor(
7511 lane: Arc<dyn AgentLane>,
7512 editor: Arc<Editor>,
7513 state: Arc<TuiState>,
7514 tui: Arc<TuiAltScreen>,
7515) -> usize {
7516 match lane.clear_queue().await {
7517 Ok(queued) => {
7518 let all: Vec<String> = queued
7519 .steering
7520 .iter()
7521 .chain(queued.follow_up.iter())
7522 .cloned()
7523 .collect();
7524 if !all.is_empty() {
7525 let queued_text = all.join("\n\n");
7526 let current = editor.get_expanded_text();
7531 let combined = merge_queued_into_draft(&queued_text, ¤t);
7532 set_editor_text_caret_at_end(&editor, &combined);
7533 }
7534 refresh_pending_messages(&state, &lane).await;
7536 tui.request_render(false);
7537 all.len()
7538 }
7539 Err(error) => {
7540 add_error_message(
7541 &state.chat_container,
7542 &format!("Could not restore queued messages: {error}"),
7543 );
7544 tui.request_render(false);
7545 0
7546 }
7547 }
7548}
7549
7550async fn handle_dequeue(
7555 lane: Arc<dyn AgentLane>,
7556 editor: Arc<Editor>,
7557 state: Arc<TuiState>,
7558 tui: Arc<TuiAltScreen>,
7559) {
7560 let restored =
7561 restore_queued_messages_to_editor(lane, editor, state.clone(), tui.clone()).await;
7562 if restored == 0 {
7563 add_note_message(&state.chat_container, "No queued messages to restore");
7564 } else {
7565 let plural = if restored > 1 { "s" } else { "" };
7566 add_note_message(
7567 &state.chat_container,
7568 &format!("Restored {restored} queued message{plural} to editor"),
7569 );
7570 }
7571 tui.request_render(false);
7572}
7573
7574fn abort_run_restoring_queue(
7581 lane: Arc<dyn AgentLane>,
7582 editor: Arc<Editor>,
7583 state: Arc<TuiState>,
7584 tui: Arc<TuiAltScreen>,
7585) {
7586 tokio::spawn(async move {
7587 let _ = restore_queued_messages_to_editor(lane.clone(), editor, state, tui.clone()).await;
7588 let _ = lane.abort().await;
7589 tui.request_render(false);
7590 });
7591}
7592
7593async fn drain_agent_events(
7594 mut rx: broadcast::Receiver<AgentEvent>,
7595 tui: Arc<TuiAltScreen>,
7596 state: Arc<TuiState>,
7597 chat: Arc<Container>,
7598 lane: Arc<dyn AgentLane>,
7599) {
7600 loop {
7601 match rx.recv().await {
7602 Ok(event) => handle_agent_event(event, &tui, &state, &chat, &lane).await,
7603 Err(broadcast::error::RecvError::Lagged(_)) => {
7604 continue;
7607 }
7608 Err(broadcast::error::RecvError::Closed) => break,
7609 }
7610 }
7611}
7612
7613async fn handle_agent_event(
7616 event: AgentEvent,
7617 tui: &Arc<TuiAltScreen>,
7618 state: &Arc<TuiState>,
7619 chat: &Arc<Container>,
7620 lane: &Arc<dyn AgentLane>,
7621) {
7622 match event {
7623 AgentEvent::AgentStart => {
7624 state.set_status(RunStatus::Working);
7625 tui.request_render(false);
7626 }
7627
7628 AgentEvent::AgentEnd { .. } => {
7629 state.transcript_view().finalize_assistant();
7631 state.set_status(RunStatus::Idle);
7632 let pending: Vec<AgentMessage> =
7636 std::mem::take(&mut *state.pending_bash_messages.lock().unwrap());
7637 for message in pending {
7638 if let Err(error) = lane.append_message(message).await {
7639 add_error_message(chat, &format!("Could not record bash result: {error}"));
7640 }
7641 }
7642 refresh_pending_messages(state, lane).await;
7645 tui.request_render(false);
7646 }
7647
7648 AgentEvent::RetryScheduled {
7649 attempt,
7650 max_retries,
7651 delay_ms,
7652 ..
7653 } => {
7654 state.show_retry(attempt, max_retries, delay_ms);
7655 tui.request_render(false);
7656 }
7657
7658 AgentEvent::TurnStart => {
7659 state.transcript_view().finalize_assistant();
7662 }
7663
7664 AgentEvent::TurnEnd {
7665 message,
7666 tool_results,
7667 } => {
7668 let view = state.transcript_view();
7670 if let AgentMessage::Assistant(a) = &message {
7671 view.finalize_assistant_blocks(&assistant_blocks(a));
7672 } else {
7673 view.finalize_assistant();
7674 }
7675 let _ = tool_results;
7679 tui.request_render(false);
7680 }
7681
7682 AgentEvent::MessageStart { message } => match message {
7683 AgentMessage::Assistant(a) => {
7684 state.transcript_view().apply(
7688 &UiEvent::AssistantStart {
7689 blocks: assistant_blocks(&a),
7690 },
7691 tui.width(),
7692 );
7693 tui.request_render(false);
7694 }
7695 AgentMessage::Custom(custom) => {
7696 let payload = serde_json::json!({
7697 "customType": custom.role,
7698 "content": custom.content,
7699 "details": custom.data,
7700 "expanded": false,
7701 "outputPad": 1,
7702 });
7703 if let Some(component) = extension_message_component(
7704 &state.extension_session,
7705 &custom.role,
7706 &payload,
7707 state.markdown_transformer(),
7708 ) {
7709 chat.add_child(component);
7710 chat.add_child(Arc::new(Spacer::new(1)));
7711 tui.request_render(false);
7712 } else {
7713 add_note_message(chat, &custom_message_fallback(&custom));
7714 tui.request_render(false);
7715 }
7716 }
7717 AgentMessage::User(user) => {
7727 state.transcript_view().add_user(&user_message_text(&user));
7728 refresh_pending_messages(state, lane).await;
7730 tui.request_render(false);
7731 }
7732 _ => {}
7735 },
7736
7737 AgentEvent::MessageUpdate {
7738 message,
7739 assistant_message_event,
7740 } => {
7741 let _ = assistant_message_event; let a: &rpi_ai::types::AssistantMessage = &message;
7743 let text = assistant_text(a);
7744 state.transcript_view().apply(
7748 &UiEvent::AssistantUpdate {
7749 blocks: assistant_blocks(a),
7750 tool_calls: assistant_tool_calls(a),
7751 },
7752 tui.width(),
7753 );
7754 state.sync_working_loader_with_bash();
7756 *state.last_assistant_text.lock().unwrap() = text;
7757 tui.request_render(false);
7758 }
7759
7760 AgentEvent::MessageEnd { message } => {
7761 if let AgentMessage::Assistant(a) = &message {
7762 let text = assistant_text(a);
7763 state
7764 .transcript_view()
7765 .finalize_assistant_blocks(&assistant_blocks(a));
7766 if !text.is_empty() {
7768 *state.last_assistant_text.lock().unwrap() = text;
7769 }
7770 let usage = &a.usage;
7776 state.footer.add_usage(
7778 usage.input,
7779 usage.output,
7780 usage.cache_read,
7781 usage.cache_write,
7782 usage.cost.total,
7783 );
7784 let denom = usage.input + usage.cache_read + usage.cache_write;
7785 if denom > 0 && (usage.cache_read + usage.cache_write) > 0 {
7786 state
7787 .footer
7788 .set_cache_hit_rate(Some(usage.cache_read as f64 / denom as f64 * 100.0));
7789 }
7790 record_context_usage(&state.footer, a);
7794 let miss = state
7795 .cache_tracker
7796 .lock()
7797 .unwrap()
7798 .observe(a, &rpi_harness::cache_stats::NoPrices);
7799 let cache_miss_notices = *state.cache_miss_notices.lock().unwrap();
7803 if let Some(miss) = miss.filter(|_| cache_miss_notices) {
7804 let cost = if miss.missed_cost > 0.0 {
7805 format!(" (~${:.4})", miss.missed_cost)
7806 } else {
7807 String::new()
7808 };
7809 add_note_message(
7810 &state.chat_container,
7811 &format!(
7812 "Cache miss: {} tokens re-billed{}",
7813 format_tokens(miss.missed_tokens),
7814 cost
7815 ),
7816 );
7817 }
7818 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
7819 add_note_message(chat, &text);
7820 }
7821 if let Some(error) = assistant_error_text(a) {
7826 add_error_message(chat, &error);
7827 }
7828 }
7829 tui.request_render(false);
7830 }
7831
7832 AgentEvent::ToolExecutionStart {
7833 tool_call_id,
7834 tool_name,
7835 args,
7836 } => {
7837 state.transcript_view().apply(
7838 &UiEvent::ToolStart {
7839 id: tool_call_id,
7840 name: tool_name,
7841 args,
7842 },
7843 tui.width(),
7844 );
7845 state.sync_working_loader_with_bash();
7846 tui.request_render(false);
7847 }
7848
7849 AgentEvent::ToolExecutionUpdate {
7850 tool_call_id,
7851 tool_name,
7852 args,
7853 partial_result,
7854 } => {
7855 state.transcript_view().apply(
7856 &UiEvent::ToolUpdate {
7857 id: tool_call_id,
7858 name: tool_name,
7859 args,
7860 result: crate::remote::protocol::tool_result_json(&partial_result),
7861 },
7862 tui.width(),
7863 );
7864 state.sync_working_loader_with_bash();
7865 tui.request_render(false);
7866 }
7867
7868 AgentEvent::ToolExecutionEnd {
7869 tool_call_id,
7870 tool_name,
7871 result,
7872 is_error,
7873 } => {
7874 state.transcript_view().apply(
7875 &UiEvent::ToolEnd {
7876 id: tool_call_id,
7877 name: tool_name,
7878 result: crate::remote::protocol::tool_result_json(&result),
7879 is_error,
7880 },
7881 tui.width(),
7882 );
7883 state.sync_working_loader_with_bash();
7884 tui.render_now(false);
7888 }
7889 }
7890}
7891
7892fn record_context_usage(footer: &rpi_tui::FooterComponent, message: &rpi_ai::AssistantMessage) {
7900 if message.stop_reason == rpi_ai::StopReason::Aborted
7901 || message.stop_reason == rpi_ai::StopReason::Error
7902 {
7903 return;
7904 }
7905 let tokens = message.usage.context_tokens();
7906 if tokens > 0 {
7907 footer.set_context_tokens(Some(tokens));
7908 }
7909}
7910
7911fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7915 let fallback = match message.stop_reason {
7916 rpi_ai::StopReason::Error => "Provider request failed.",
7917 rpi_ai::StopReason::Aborted => "Request aborted.",
7918 _ => return None,
7919 };
7920 Some(
7921 message
7922 .error_message
7923 .as_deref()
7924 .filter(|text| !text.trim().is_empty())
7925 .unwrap_or(fallback)
7926 .to_string(),
7927 )
7928}
7929
7930fn tool_result_requests_markdown(details: Option<&serde_json::Value>) -> bool {
7939 details
7940 .and_then(|details| details.get("markdown"))
7941 .and_then(|value| value.as_bool())
7942 .unwrap_or(false)
7943}
7944
7945fn open_selector(
7953 state: &Arc<TuiState>,
7954 editor_container: &Arc<Container>,
7955 editor: &Arc<Editor>,
7956 tui: &Arc<TuiAltScreen>,
7957 list: Arc<SelectList>,
7958 kind: SelectorKind,
7959) {
7960 open_selector_with_view(
7961 state,
7962 editor_container,
7963 editor,
7964 tui,
7965 SelectorView::List(list.clone()),
7966 list,
7967 kind,
7968 );
7969}
7970
7971fn open_searchable_selector(
7976 state: &Arc<TuiState>,
7977 editor_container: &Arc<Container>,
7978 editor: &Arc<Editor>,
7979 tui: &Arc<TuiAltScreen>,
7980 title: Option<&str>,
7981 description: Option<&str>,
7982 list: Arc<SelectList>,
7983 kind: SelectorKind,
7984) -> Arc<SearchableSelectList> {
7985 let searchable = Arc::new(SearchableSelectList::new(title, description, list));
7986 open_selector_with_view(
7987 state,
7988 editor_container,
7989 editor,
7990 tui,
7991 SelectorView::Searchable(searchable.clone()),
7992 searchable.clone(),
7993 kind,
7994 );
7995 searchable.set_focused(true);
7999 searchable
8000}
8001
8002fn open_selector_with_view<V: Component + 'static>(
8010 state: &Arc<TuiState>,
8011 editor_container: &Arc<Container>,
8012 editor: &Arc<Editor>,
8013 tui: &Arc<TuiAltScreen>,
8014 view_router: SelectorView,
8015 view: Arc<V>,
8016 kind: SelectorKind,
8017) {
8018 dispatch_ui_prompt_event(state);
8020
8021 editor.set_focused(false);
8023 state.autocomplete_container.clear();
8026 editor_container.clear();
8028 editor_container.add_child(view.clone());
8029 *state.active_selector.lock().unwrap() = Some((view_router, kind));
8030 let focused: Arc<dyn Component> = view;
8031 tui.set_focus(Some(focused));
8032 tui.request_render(false);
8033}
8034
8035fn close_selector(
8038 state: &Arc<TuiState>,
8039 editor_container: &Arc<Container>,
8040 editor: &Arc<Editor>,
8041 tui: &Arc<TuiAltScreen>,
8042) {
8043 dispatch_ui_prompt_event_end(state);
8045
8046 editor_container.clear();
8047 editor_container.add_child(editor.clone());
8048 state.autocomplete_container.clear();
8049 editor.set_focused(true);
8050 *state.active_selector.lock().unwrap() = None;
8051 *state.active_extension_cancel.lock().unwrap() = None;
8052 tui.set_focus(Some(editor.clone()));
8053 tui.request_render(false);
8054}
8055
8056fn open_model_selector(
8062 state: &Arc<TuiState>,
8063 editor_container: &Arc<Container>,
8064 editor: &Arc<Editor>,
8065 tui: &Arc<TuiAltScreen>,
8066 catalog: &[rpi_ai::Model],
8067 lane: &Arc<dyn AgentLane>,
8068 lane_model_id: &str,
8069 chat: &Arc<Container>,
8070 initial_search: Option<&str>,
8071) -> Option<Arc<SearchableSelectList>> {
8072 let items = model_selector_items(catalog, lane_model_id);
8073 if items.is_empty() {
8074 add_note_message(
8075 chat,
8076 "No models in the catalog. Use --model at startup to select one.",
8077 );
8078 tui.request_render(false);
8079 return None;
8080 }
8081 let list = Arc::new(SelectList::new(items, 10));
8082
8083 let catalog_arc = catalog.to_vec();
8087 let state_sel = state.clone();
8088 let ec_sel = editor_container.clone();
8089 let editor_sel = editor.clone();
8090 let tui_sel = tui.clone();
8091 let chat_sel = chat.clone();
8092 let lane_sel = lane.clone();
8093 list.on_select(Arc::new(move |item| {
8094 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
8095 add_note_message(
8096 &chat_sel,
8097 &format!("Model {} not found in catalog.", item.label),
8098 );
8099 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8100 return;
8101 };
8102 state_sel.set_current_model(&model);
8103 let lane = lane_sel.clone();
8104 tokio::spawn(async move {
8105 let _ = lane.set_model(model).await;
8106 });
8107 add_note_message(
8108 &chat_sel,
8109 &format!(
8110 "Model set to {} — applies to the next message.",
8111 short_model_name(&item.value)
8112 ),
8113 );
8114 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8115 }));
8116 let state_cancel = state.clone();
8117 let ec_cancel = editor_container.clone();
8118 let editor_cancel = editor.clone();
8119 let tui_cancel = tui.clone();
8120 list.on_cancel(Arc::new(move || {
8121 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8122 }));
8123
8124 let searchable = open_searchable_selector(
8125 state,
8126 editor_container,
8127 editor,
8128 tui,
8129 Some("Select model"),
8130 Some("Type to filter by name, provider, or id"),
8131 list,
8132 SelectorKind::Model,
8133 );
8134 if let Some(term) = initial_search {
8135 searchable.set_search_text(term);
8136 }
8137 Some(searchable)
8138}
8139
8140fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8145 if catalog.is_empty() {
8146 return None;
8147 }
8148 let idx = catalog
8149 .iter()
8150 .position(|m| m.id.eq_ignore_ascii_case(current_id));
8151 match idx {
8152 Some(i) => {
8153 let next = (i + 1) % catalog.len();
8154 Some(catalog[next].clone())
8155 }
8156 None => Some(catalog[0].clone()),
8157 }
8158}
8159
8160fn cycle_prev_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8163 if catalog.is_empty() {
8164 return None;
8165 }
8166 let idx = catalog
8167 .iter()
8168 .position(|m| m.id.eq_ignore_ascii_case(current_id));
8169 match idx {
8170 Some(i) => {
8171 let prev = (i + catalog.len() - 1) % catalog.len();
8172 Some(catalog[prev].clone())
8173 }
8174 None => Some(catalog[0].clone()),
8175 }
8176}
8177
8178fn open_session_selector(
8184 state: &Arc<TuiState>,
8185 editor_container: &Arc<Container>,
8186 editor: &Arc<Editor>,
8187 tui: &Arc<TuiAltScreen>,
8188 cwd: &std::path::Path,
8189 tx: &mpsc::UnboundedSender<TuiMessage>,
8190) {
8191 let dir = crate::session::default_session_dir(cwd);
8192 let mut items: Vec<SelectItem> = Vec::new();
8193 for path in crate::session::list_session_files_sync(&dir) {
8194 let summary = crate::session::summarize_session_file(&path);
8195 if summary.empty {
8196 continue;
8197 }
8198 let stem = path
8199 .file_stem()
8200 .and_then(|s| s.to_str())
8201 .unwrap_or("(unnamed)")
8202 .to_string();
8203 let label = crate::session::session_display_label(&summary);
8204 let id_tail = stem.rsplit('_').next().unwrap_or(&stem);
8207 let short_id = crate::session::short_session_id(id_tail);
8208 let description = format!(
8209 "{} · {}",
8210 crate::session::format_session_bytes(summary.bytes),
8211 short_id,
8212 );
8213 items.push(
8214 SelectItem::new(&stem, &label)
8215 .with_description(&description)
8216 .with_search_text(&format!("{label} {short_id} {stem}")),
8217 );
8218 }
8219 if items.is_empty() {
8220 add_note_message(
8221 &state.chat_container,
8222 "No saved sessions found. Sessions are created automatically in interactive mode.",
8223 );
8224 tui.request_render(false);
8225 return;
8226 }
8227 let mut list = SelectList::new(items, 10);
8228 list.set_layout(rpi_tui::SelectListLayoutOptions {
8229 min_primary_column_width: Some(56),
8230 max_primary_column_width: Some(72),
8231 truncate_primary: None,
8232 });
8233 let list = Arc::new(list);
8234
8235 let state_sel = state.clone();
8236 let ec_sel = editor_container.clone();
8237 let editor_sel = editor.clone();
8238 let tui_sel = tui.clone();
8239 let tx_sel = tx.clone();
8240 list.on_select(Arc::new(move |item| {
8241 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8245 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
8246 }));
8247 let state_cancel = state.clone();
8248 let ec_cancel = editor_container.clone();
8249 let editor_cancel = editor.clone();
8250 let tui_cancel = tui.clone();
8251 list.on_cancel(Arc::new(move || {
8252 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8253 }));
8254
8255 open_searchable_selector(
8256 state,
8257 editor_container,
8258 editor,
8259 tui,
8260 Some("Resume session"),
8261 Some("Type to filter sessions"),
8262 list,
8263 SelectorKind::Session,
8264 );
8265}
8266
8267fn custom_entry_display_text(
8268 custom_type: &str,
8269 data: Option<&serde_json::Value>,
8270) -> Option<String> {
8271 let data = data?;
8272 let text = data
8273 .get("summary")
8274 .or_else(|| data.get("text"))
8275 .or_else(|| data.get("output"))
8276 .and_then(|value| value.as_str())
8277 .filter(|value| !value.trim().is_empty())?;
8278 let label = match custom_type {
8279 "compactionSummary" => "Compaction summary",
8280 "branchSummary" => "Branch summary",
8281 "bashExecution" => "Command output",
8282 other => other,
8283 };
8284 Some(format!("{label}: {text}"))
8285}
8286
8287async fn open_tree_selector(
8291 harness: &AgentHarness,
8292 state: &Arc<TuiState>,
8293 editor_container: &Arc<Container>,
8294 editor: &Arc<Editor>,
8295 tui: &Arc<TuiAltScreen>,
8296 chat: &Arc<Container>,
8297 tx: &mpsc::UnboundedSender<TuiMessage>,
8298) {
8299 let entries = match harness
8300 .session()
8301 .view("main")
8302 .find_entries(&EntryQuery {
8303 order: Some(EntryOrder::OldestFirst),
8304 ..Default::default()
8305 })
8306 .await
8307 {
8308 Ok(entries) => entries,
8309 Err(error) => {
8310 add_error_message(chat, &format!("Could not read session tree: {error}"));
8311 tui.request_render(false);
8312 return;
8313 }
8314 };
8315 let current = harness.session().get_leaf_id().await.ok().flatten();
8316 let items: Vec<SelectItem> = entries
8317 .iter()
8318 .map(|entry| {
8319 let label = if current.as_deref() == Some(entry.id()) {
8320 format!("{} #{} (current)", entry.entry_type(), entry.seq())
8321 } else {
8322 format!("{} #{}", entry.entry_type(), entry.seq())
8323 };
8324 let short_id = &entry.id()[..entry.id().len().min(12)];
8325 let search_text = format!("{} {} {}", entry.entry_type(), entry.seq(), entry.id());
8328 SelectItem::new(entry.id(), &label)
8329 .with_description(short_id)
8330 .with_search_text(&search_text)
8331 })
8332 .collect();
8333 if items.is_empty() {
8334 add_note_message(chat, "The current session has no entries to navigate.");
8335 tui.request_render(false);
8336 return;
8337 }
8338 let list = Arc::new(SelectList::new(items, 12));
8339 let state_sel = state.clone();
8340 let ec_sel = editor_container.clone();
8341 let editor_sel = editor.clone();
8342 let tui_sel = tui.clone();
8343 let tx_sel = tx.clone();
8344 list.on_select(Arc::new(move |item| {
8345 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
8346 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8347 }));
8348 let state_cancel = state.clone();
8349 let ec_cancel = editor_container.clone();
8350 let editor_cancel = editor.clone();
8351 let tui_cancel = tui.clone();
8352 list.on_cancel(Arc::new(move || {
8353 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8354 }));
8355 open_searchable_selector(
8356 state,
8357 editor_container,
8358 editor,
8359 tui,
8360 Some("Session tree"),
8361 Some("Type to filter by entry type, sequence, or id"),
8362 list,
8363 SelectorKind::Tree,
8364 );
8365}
8366
8367fn open_theme_selector(
8370 state: &Arc<TuiState>,
8371 editor_container: &Arc<Container>,
8372 editor: &Arc<Editor>,
8373 tui: &Arc<TuiAltScreen>,
8374) {
8375 let items = vec![
8376 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
8377 SelectItem::new("light", "Light").with_description("Light background"),
8378 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
8379 ];
8380 let list = Arc::new(SelectList::new(items, 10));
8381
8382 let state_sel = state.clone();
8383 let ec_sel = editor_container.clone();
8384 let editor_sel = editor.clone();
8385 let tui_sel = tui.clone();
8386 let chat_sel = state.chat_container.clone();
8387 list.on_select(Arc::new(move |item| {
8388 let preset = match item.value.as_str() {
8389 "light" => Some(ThemePreset::Light),
8390 "monochrome" => Some(ThemePreset::Monochrome),
8391 "dark" => Some(ThemePreset::Dark),
8392 name => {
8393 add_error_message(&chat_sel, &format!("Unknown theme `{name}`."));
8394 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8395 tui_sel.render_now(true);
8396 return;
8397 }
8398 };
8399 let Some(preset) = preset else { return };
8400 apply_theme_preset(preset);
8401 state_sel.theme_manager.apply_preset(preset);
8402 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8405 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8406 tui_sel.render_now(true);
8407 }));
8408 let state_cancel = state.clone();
8409 let ec_cancel = editor_container.clone();
8410 let editor_cancel = editor.clone();
8411 let tui_cancel = tui.clone();
8412 list.on_cancel(Arc::new(move || {
8413 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8414 }));
8415
8416 open_selector(
8417 state,
8418 editor_container,
8419 editor,
8420 tui,
8421 list,
8422 SelectorKind::Theme,
8423 );
8424}
8425
8426fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8433 use rpi_ai::types::ThinkingLevel::*;
8434 match level {
8435 Off => "Off — No reasoning",
8436 Minimal => "Minimal — Brief reasoning (~1k tokens)",
8437 Low => "Low — Light reasoning (~1k tokens)",
8438 Medium => "Medium — Moderate reasoning (~80% of max)",
8439 High => "High — Extensive reasoning (~95% of max)",
8440 Xhigh => "Xhigh — Near-maximal reasoning",
8441 Max => "Max — Maximum reasoning",
8442 }
8443}
8444
8445fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8448 use rpi_ai::types::ThinkingLevel::*;
8449 match level {
8450 Off => "off",
8451 Minimal => "minimal",
8452 Low => "low",
8453 Medium => "medium",
8454 High => "high",
8455 Xhigh => "xhigh",
8456 Max => "max",
8457 }
8458}
8459
8460fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
8463 use rpi_ai::types::ThinkingLevel::*;
8464 match name.to_ascii_lowercase().as_str() {
8465 "off" => Some(Off),
8466 "minimal" => Some(Minimal),
8467 "low" => Some(Low),
8468 "medium" => Some(Medium),
8469 "high" => Some(High),
8470 "xhigh" => Some(Xhigh),
8471 "max" => Some(Max),
8472 _ => None,
8473 }
8474}
8475
8476fn open_thinking_selector(
8486 state: &Arc<TuiState>,
8487 editor_container: &Arc<Container>,
8488 editor: &Arc<Editor>,
8489 tui: &Arc<TuiAltScreen>,
8490 lane: &Arc<dyn AgentLane>,
8491 catalog: &[rpi_ai::Model],
8492 lane_model_id: &str,
8493 chat: &Arc<Container>,
8494) {
8495 let model = catalog
8498 .iter()
8499 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8500 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8501 .map(|m| m.supported_thinking_levels())
8502 .unwrap_or_else(|| {
8503 use rpi_ai::types::ThinkingLevel::*;
8504 vec![Off, Minimal, Low, Medium, High]
8505 });
8506 let mut items: Vec<SelectItem> = Vec::new();
8507 for lvl in &levels {
8508 let name = thinking_level_name(*lvl);
8509 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8510 }
8511 if items.is_empty() {
8512 add_note_message(chat, "This model has no supported thinking levels.");
8513 tui.request_render(false);
8514 return;
8515 }
8516 let list = Arc::new(SelectList::new(items, 10));
8517
8518 let state_sel = state.clone();
8519 let ec_sel = editor_container.clone();
8520 let editor_sel = editor.clone();
8521 let tui_sel = tui.clone();
8522 let chat_sel = chat.clone();
8523 let lane_sel = lane.clone();
8524 list.on_select(Arc::new(move |item| {
8525 let Some(level) = thinking_level_from_name(&item.value) else {
8526 add_note_message(
8527 &chat_sel,
8528 &format!("Unknown thinking level: {}.", item.label),
8529 );
8530 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8531 return;
8532 };
8533 let lane = lane_sel.clone();
8534 let footer_sel = state_sel.footer.clone();
8535 tokio::spawn(async move {
8536 let _ = lane.set_thinking_level(level).await;
8537 });
8538 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8542 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8543 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8544 }));
8545 let state_cancel = state.clone();
8546 let ec_cancel = editor_container.clone();
8547 let editor_cancel = editor.clone();
8548 let tui_cancel = tui.clone();
8549 list.on_cancel(Arc::new(move || {
8550 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8551 }));
8552
8553 open_selector(
8554 state,
8555 editor_container,
8556 editor,
8557 tui,
8558 list,
8559 SelectorKind::Thinking,
8560 );
8561}
8562
8563fn open_tools_selector(
8569 state: &Arc<TuiState>,
8570 editor_container: &Arc<Container>,
8571 editor: &Arc<Editor>,
8572 tui: &Arc<TuiAltScreen>,
8573 lane: &Arc<dyn AgentLane>,
8574 chat: &Arc<Container>,
8575) {
8576 let mut active = match tokio::runtime::Handle::try_current() {
8581 Ok(h) => h
8582 .block_on(async { lane.get_active_tools().await })
8583 .unwrap_or_default(),
8584 Err(_) => Vec::new(),
8585 };
8586 if active.is_empty() {
8591 active = crate::session::BUILTIN_TOOL_NAMES
8592 .iter()
8593 .map(|name| (*name).to_string())
8594 .collect();
8595 }
8596 let mut items: Vec<SelectItem> = Vec::new();
8597 for name in crate::session::BUILTIN_TOOL_NAMES {
8598 let on = active.iter().any(|a| a == name);
8599 let label = if on {
8600 format!("{name} (on)")
8601 } else {
8602 (*name).to_string()
8603 };
8604 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8605 }
8606 let list = Arc::new(SelectList::new(items, 10));
8607
8608 let active_captured = active.clone();
8610 let state_sel = state.clone();
8611 let ec_sel = editor_container.clone();
8612 let editor_sel = editor.clone();
8613 let tui_sel = tui.clone();
8614 let chat_sel = chat.clone();
8615 let lane_sel = lane.clone();
8616 list.on_select(Arc::new(move |item| {
8617 let mut next = active_captured.clone();
8618 if let Some(pos) = next.iter().position(|a| a == &item.value) {
8619 next.remove(pos);
8620 } else {
8621 next.push(item.value.clone());
8622 }
8623 let on = next.iter().any(|a| a == &item.value);
8624 let lane = lane_sel.clone();
8625 let next_clone = next.clone();
8626 tokio::spawn(async move {
8627 let _ = lane.set_active_tools(next_clone).await;
8628 });
8629 let list_str = if next.is_empty() {
8630 "(none)".to_string()
8631 } else {
8632 next.join(", ")
8633 };
8634 add_note_message(
8635 &chat_sel,
8636 &format!(
8637 "{} {} — active tools: {}",
8638 item.value,
8639 if on { "enabled" } else { "disabled" },
8640 list_str
8641 ),
8642 );
8643 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8644 }));
8645 let state_cancel = state.clone();
8646 let ec_cancel = editor_container.clone();
8647 let editor_cancel = editor.clone();
8648 let tui_cancel = tui.clone();
8649 list.on_cancel(Arc::new(move || {
8650 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8651 }));
8652
8653 open_selector(
8654 state,
8655 editor_container,
8656 editor,
8657 tui,
8658 list,
8659 SelectorKind::Tools,
8660 );
8661}
8662
8663fn open_images_selector(
8667 state: &Arc<TuiState>,
8668 editor_container: &Arc<Container>,
8669 editor: &Arc<Editor>,
8670 tui: &Arc<TuiAltScreen>,
8671 chat: &Arc<Container>,
8672) {
8673 let current = *state.show_images.lock().unwrap();
8674 let items = vec![
8675 SelectItem::new("yes", "Yes").with_description(if current {
8676 "Inline images (current)"
8677 } else {
8678 "Inline images"
8679 }),
8680 SelectItem::new("no", "No").with_description(if current {
8681 "Placeholder only"
8682 } else {
8683 "Placeholder only (current)"
8684 }),
8685 ];
8686 let list = Arc::new(SelectList::new(items, 5));
8687
8688 let state_sel = state.clone();
8689 let ec_sel = editor_container.clone();
8690 let editor_sel = editor.clone();
8691 let tui_sel = tui.clone();
8692 let chat_sel = chat.clone();
8693 list.on_select(Arc::new(move |item| {
8694 let on = item.value == "yes";
8695 *state_sel.show_images.lock().unwrap() = on;
8696 add_note_message(
8697 &chat_sel,
8698 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8699 );
8700 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8701 }));
8702 let state_cancel = state.clone();
8703 let ec_cancel = editor_container.clone();
8704 let editor_cancel = editor.clone();
8705 let tui_cancel = tui.clone();
8706 list.on_cancel(Arc::new(move || {
8707 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8708 }));
8709
8710 open_selector(
8711 state,
8712 editor_container,
8713 editor,
8714 tui,
8715 list,
8716 SelectorKind::Images,
8717 );
8718}
8719
8720fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8728 let text = editor.get_text();
8729 let cursor = editor_cursor_offset(editor, &text);
8730 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8731 render_autocomplete(state, suggestions);
8732}
8733
8734fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8737 let (row, col) = editor.cursor_position();
8738 let mut offset = 0;
8739 for (index, line) in text.split('\n').enumerate() {
8740 if index == row {
8741 return (offset + col.min(line.len())).min(text.len());
8742 }
8743 offset = offset.saturating_add(line.len() + 1);
8744 }
8745 text.len()
8746}
8747
8748fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8751 let offset = offset.min(text.len());
8752 let mut consumed = 0;
8753 for (row, line) in text.split('\n').enumerate() {
8754 let end = consumed + line.len();
8755 if offset <= end {
8756 editor.set_cursor(row, offset - consumed);
8757 return;
8758 }
8759 consumed = end + 1;
8760 }
8761 let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8762 editor.set_cursor(
8763 last_row,
8764 text.rsplit('\n').next().map(str::len).unwrap_or(0),
8765 );
8766}
8767
8768fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8770 state.autocomplete_container.clear();
8771 let Some(sugg) = suggestions else {
8772 return;
8773 };
8774 if sugg.items.is_empty() {
8775 return;
8776 }
8777 let accent = state.theme_manager.get().colors.accent;
8780 let muted = state.theme_manager.get().colors.muted;
8781 for (i, item) in sugg
8782 .items
8783 .iter()
8784 .take(state.autocomplete_max_visible)
8785 .enumerate()
8786 {
8787 let prefix = if i == 0 { "→ " } else { " " };
8788 let label = item.display_text();
8789 let line = if i == 0 {
8790 format!(
8791 "{prefix}{} {}",
8792 accent.fg(label),
8793 muted.fg(item.description.as_deref().unwrap_or(""))
8794 )
8795 } else {
8796 format!(
8797 "{prefix}{} {}",
8798 muted.fg(label),
8799 muted.fg(item.description.as_deref().unwrap_or(""))
8800 )
8801 };
8802 state
8803 .autocomplete_container
8804 .add_child(Arc::new(Text::new(line, 1, 0)));
8805 }
8806}
8807
8808fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8812 let text = editor.get_text();
8813 let cursor = editor_cursor_offset(editor, &text);
8814 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8815 return false;
8816 };
8817 let Some(top) = sugg.items.first() else {
8818 return false;
8819 };
8820 let start = sugg.start.min(text.len());
8824 let end = sugg.end.min(text.len());
8825 let mut replaced = String::with_capacity(text.len() + top.text.len());
8826 replaced.push_str(&text[..start]);
8827 replaced.push_str(&top.text);
8828 replaced.push_str(&text[end..]);
8831 if top.insert_space && !replaced.ends_with('/') {
8832 replaced.push(' ');
8833 }
8834 let new_cursor = replaced.len().min(
8837 start
8838 + top.text.len()
8839 + if top.insert_space && !top.text.ends_with('/') {
8840 1
8841 } else {
8842 0
8843 },
8844 );
8845 editor.set_text(&replaced);
8846 set_editor_cursor_offset(editor, &replaced, new_cursor);
8847 state.autocomplete_container.clear();
8848 true
8849}
8850
8851fn add_welcome_message(container: &Arc<Container>) {
8857 add_welcome_message_with_capabilities(container, &[], &[]);
8858}
8859
8860fn add_welcome_message_with_capabilities(
8864 container: &Arc<Container>,
8865 active_tools: &[String],
8866 skills: &[String],
8867) {
8868 let c = current_theme().colors;
8869 container.add_child(Arc::new(crate::brand::BrandLockup::new()));
8872 container.add_child(Arc::new(Spacer::new(1)));
8873 let title = c.muted.fg("interactive TUI · library-first agent runtime");
8876 container.add_child(Arc::new(Text::new(title, 1, 0)));
8877 container.add_child(Arc::new(Spacer::new(1)));
8878 container.add_child(Arc::new(Text::new(
8879 c.dim.fg("Type your message and press Enter to send."),
8880 1,
8881 0,
8882 )));
8883 let hint = c
8884 .dim
8885 .fg("Enter send · Shift+Enter newline · Ctrl+C clear · Esc abort · /help");
8886 container.add_child(Arc::new(Text::new(hint, 1, 0)));
8887 container.add_child(Arc::new(Spacer::new(1)));
8888 container.add_child(Arc::new(Text::new(
8889 welcome_capability_line("Tools", active_tools),
8890 1,
8891 0,
8892 )));
8893 container.add_child(Arc::new(Text::new(
8894 welcome_capability_line("Skills", skills),
8895 1,
8896 0,
8897 )));
8898 container.add_child(Arc::new(DynamicBorder::new()));
8899}
8900
8901fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8904 let colors = current_theme().colors;
8905 let group = Arc::new(Container::new());
8906
8907 for warning in &report.warnings {
8908 let body = format!(
8909 "{}\n{} {}{}",
8910 colors.error.fg(&warning.message),
8911 colors.muted.fg("Run"),
8912 colors.accent.fg(&warning.command),
8913 colors.muted.fg(" to retry.")
8914 );
8915 add_update_panel(&group, "Update Failed", &body, colors.error);
8916 }
8917
8918 if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8919 let body = format!(
8920 "{} {}{}",
8921 colors
8922 .muted
8923 .fg(&format!("New version {} is available. Run", notice.latest)),
8924 colors.accent.fg(¬ice.command),
8925 colors.muted.fg(".")
8926 );
8927 add_update_panel(&group, "Update Available", &body, colors.warning);
8928 }
8929
8930 if group.child_count() > 0 {
8934 container.add_child(group);
8935 }
8936}
8937
8938fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8939 container.add_child(Arc::new(Spacer::new(1)));
8940 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8941 container.add_child(Arc::new(Text::new(
8942 format!("{}\n{body}", color.fg(&tui_bold(title))),
8943 1,
8944 0,
8945 )));
8946 container.add_child(Arc::new(DynamicBorder::with_color(color)));
8947}
8948
8949fn welcome_capability_line(label: &str, names: &[String]) -> String {
8950 let c = current_theme().colors;
8951 let value = if names.is_empty() {
8952 "none".to_string()
8953 } else {
8954 names.join(" · ")
8955 };
8956 format!(
8957 "{} {}",
8958 c.accent.fg(&format!("{label} ({})", names.len())),
8959 c.muted.fg(&value)
8960 )
8961}
8962
8963fn add_help_message(container: &Arc<Container>) {
8965 let c = current_theme().colors;
8966 container.add_child(Arc::new(Text::new(
8970 c.md_heading.fg(&tui_bold("📚 Available Commands")),
8971 1,
8972 0,
8973 )));
8974 container.add_child(Arc::new(Spacer::new(1)));
8975
8976 let cmds: &[(&str, &str)] = &[
8977 ("/help, /?", "Show this help message"),
8978 ("/clear, /new", "Clear the conversation"),
8979 ("/exit, /quit, /q", "Exit the application"),
8980 ("/version, /v", "Show version information"),
8981 ("/changelog", "Show recent release changes"),
8982 ("/model, /m", "Choose a model (live switch)"),
8983 ("/thinking, /think", "Set reasoning depth (selector)"),
8984 ("/tools", "Toggle built-in tools on/off"),
8985 ("/images", "Toggle inline image rendering"),
8986 ("/session", "List saved sessions"),
8987 ("/theme", "Choose a theme (selector)"),
8988 ("/compact", "Compact the conversation"),
8989 ("/copy", "Copy last reply to clipboard"),
8990 ("/hotkeys", "Show keyboard shortcuts"),
8991 ];
8992 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8993 for (cmd, desc) in cmds {
8994 let row = format!(
8995 " {:<cmd_w$} {} {}",
8996 c.accent.fg(cmd),
8997 c.dim.fg("—"),
8998 c.muted.fg(desc)
8999 );
9000 container.add_child(Arc::new(Text::new(row, 1, 0)));
9001 }
9002 container.add_child(Arc::new(Spacer::new(1)));
9003}
9004
9005fn add_version_message(container: &Arc<Container>) {
9007 let c = current_theme().colors;
9008 container.add_child(Arc::new(Text::new(
9009 c.md_heading.fg(&tui_bold("📦 Version Information")),
9010 1,
9011 0,
9012 )));
9013 container.add_child(Arc::new(Spacer::new(1)));
9014 container.add_child(Arc::new(Text::new(
9017 format!(
9018 " {} {}",
9019 c.muted.fg("rpi-cli"),
9020 c.text.fg(&format!("v{}", crate::VERSION))
9021 ),
9022 1,
9023 0,
9024 )));
9025 container.add_child(Arc::new(Text::new(
9026 format!(
9027 " {}",
9028 c.dim.fg("Rust implementation of pi coding agent TUI")
9029 ),
9030 1,
9031 0,
9032 )));
9033 container.add_child(Arc::new(Spacer::new(1)));
9034}
9035
9036fn add_changelog_message(container: &Arc<Container>) {
9040 let c = current_theme().colors;
9041 container.add_child(Arc::new(Text::new(
9042 c.md_heading.fg(&tui_bold("Recent Changes")),
9043 1,
9044 0,
9045 )));
9046 container.add_child(Arc::new(Spacer::new(1)));
9047 let entries = [
9048 (
9049 "Native parity phase 1",
9050 "models, images, trust, export, and JSON events",
9051 ),
9052 (
9053 "TUI controls",
9054 "external editor, thinking levels, and tool output toggles",
9055 ),
9056 (
9057 "Provider auth",
9058 "OpenAI-compatible API key aliases and gateway headers",
9059 ),
9060 ];
9061 for (release, summary) in entries {
9062 let row = format!(" {} {}", c.accent.fg(release), c.muted.fg(summary));
9063 container.add_child(Arc::new(Text::new(row, 1, 0)));
9064 }
9065 container.add_child(Arc::new(Text::new(
9066 format!(" {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
9067 1,
9068 0,
9069 )));
9070 container.add_child(Arc::new(Spacer::new(1)));
9071}
9072
9073fn add_hotkeys_message(container: &Arc<Container>) {
9075 let c = current_theme().colors;
9076 container.add_child(Arc::new(Text::new(
9077 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
9078 1,
9079 0,
9080 )));
9081 container.add_child(Arc::new(Spacer::new(1)));
9082 let keys: &[(&str, &str)] = &[
9083 ("Enter", "Send message"),
9084 ("Shift+Enter", "New line"),
9085 ("Tab", "Accept autocomplete suggestion"),
9086 ("Ctrl+A / Ctrl+E", "Line start / end"),
9087 (
9088 "Ctrl+K / Ctrl+U",
9089 "Kill to end / start of line (Ctrl+Y yanks)",
9090 ),
9091 ("Ctrl+- / Ctrl+R", "Undo / redo"),
9092 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
9093 ("Alt+Backspace", "Kill previous word"),
9094 ("Ctrl+C", "Clear the editor (press twice quickly to exit)"),
9095 ("Ctrl+X", "Copy the editor selection or the last reply"),
9096 ("Esc", "Abort a running prompt"),
9097 ("Ctrl+L", "Open model selector"),
9098 ("Ctrl+P", "Cycle to the next model (live)"),
9099 ("Ctrl+O", "Expand/collapse all tool output"),
9100 ("Ctrl+T", "Show/hide reasoning blocks"),
9101 ("PageUp/Down", "Scroll transcript by one page"),
9102 ("Home / End", "Jump to transcript start / latest output"),
9103 ];
9104 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9105 for (key, desc) in keys {
9106 let row = format!(
9107 " {:<key_w$} {} {}",
9108 c.accent.fg(key),
9109 c.dim.fg("—"),
9110 c.muted.fg(desc)
9111 );
9112 container.add_child(Arc::new(Text::new(row, 1, 0)));
9113 }
9114 container.add_child(Arc::new(Spacer::new(1)));
9115}
9116
9117fn add_user_message(container: &Arc<Container>, text: &str) {
9123 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
9124 container.add_child(Arc::new(Spacer::new(1)));
9125}
9126
9127fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
9130 let width = 80; chat_container.render(width)
9132}
9133
9134fn format_resume_command(
9139 session_id: &str,
9140 cwd: &std::path::Path,
9141 session_dir: Option<&std::path::Path>,
9142) -> Option<String> {
9143 if session_id.is_empty() {
9144 return None;
9145 }
9146 let mut args = vec![crate::APP_NAME.to_string()];
9147 if let Some(dir) = session_dir {
9148 let default = crate::session::default_session_dir(cwd);
9149 if dir != default.as_path() {
9150 args.push("--session-dir".to_string());
9151 args.push(quote_shell_arg(&dir.to_string_lossy()));
9152 }
9153 }
9154 args.push("--session".to_string());
9155 args.push(session_id.to_string());
9156 Some(args.join(" "))
9157}
9158
9159fn quote_shell_arg(value: &str) -> String {
9162 let needs_quoting = value.is_empty()
9163 || value
9164 .chars()
9165 .any(|c| c.is_whitespace() || "\"'`$&|;<>()*?[]{}!~#\\".contains(c));
9166 if needs_quoting {
9167 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
9168 } else {
9169 value.to_string()
9170 }
9171}
9172
9173fn add_error_message(container: &Arc<Container>, text: &str) {
9174 let c = current_theme().colors;
9175 let text = sanitize_error_message(text);
9176 container.add_child(Arc::new(Text::new(
9177 format!(" {} {}", c.error.fg("✗"), c.error.fg(&text)),
9178 1,
9179 0,
9180 )));
9181 container.add_child(Arc::new(Spacer::new(1)));
9182}
9183
9184fn sanitize_error_message(text: &str) -> String {
9189 const MAX_ERROR_CHARS: usize = 16 * 1024;
9190 let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
9191 let mut count = 0;
9192 let mut escape_mode = 0u8;
9194 for ch in text.chars() {
9195 if escape_mode != 0 {
9196 match escape_mode {
9197 1 if ch == '[' => escape_mode = 2,
9198 1 if ch == ']' => escape_mode = 3,
9199 1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
9200 2 if ('@'..='~').contains(&ch) => escape_mode = 0,
9201 3 if ch == '\x07' => escape_mode = 0,
9202 _ => {}
9203 }
9204 continue;
9205 }
9206 if ch == '\x1b' {
9207 escape_mode = 1;
9208 continue;
9209 }
9210 if count >= MAX_ERROR_CHARS {
9211 result.push_str("…");
9212 break;
9213 }
9214 match ch {
9215 '\n' | '\t' => {
9216 result.push(ch);
9217 count += 1;
9218 }
9219 '\r' => {}
9220 c if c.is_control() => {}
9221 c => {
9222 result.push(c);
9223 count += 1;
9224 }
9225 }
9226 }
9227 if result.trim().is_empty() {
9228 "Provider request failed.".to_string()
9229 } else {
9230 result
9231 }
9232}
9233
9234fn add_note_message(container: &Arc<Container>, text: &str) {
9236 let c = current_theme().colors;
9237 container.add_child(Arc::new(Text::new(
9238 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
9239 1,
9240 0,
9241 )));
9242 container.add_child(Arc::new(Spacer::new(1)));
9243}
9244
9245fn show_context_panel(
9254 chat: &Arc<Container>,
9255 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
9256) {
9257 let skills = resources.skills.as_deref().unwrap_or(&[]);
9258 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
9259 let mut lines: Vec<String> = Vec::new();
9260 lines.push("📂 Discovered resources for this session:".into());
9261
9262 if skills.is_empty() {
9263 lines.push(
9264 " Skills: (none discovered — create .rpi/skills/ or ~/.rpi/agent/skills/)".into(),
9265 );
9266 } else {
9267 lines.push(format!(" Skills ({}):", skills.len()));
9268 for s in skills {
9269 let marker = if s.disable_model_invocation == Some(true) {
9270 " [hidden]"
9271 } else {
9272 ""
9273 };
9274 let desc: String = s.description.chars().take(72).collect();
9275 lines.push(format!(" • {}{marker} — {desc}", s.name));
9276 }
9277 }
9278
9279 if templates.is_empty() {
9280 lines.push(
9281 " Prompt templates: (none — create .rpi/prompts/ or ~/.rpi/agent/prompts/)".into(),
9282 );
9283 } else {
9284 lines.push(format!(" Prompt templates ({}):", templates.len()));
9285 for t in templates {
9286 let desc = t
9287 .description
9288 .as_deref()
9289 .unwrap_or("(no description)")
9290 .chars()
9291 .take(72)
9292 .collect::<String>();
9293 lines.push(format!(" • /{} — {desc}", t.name));
9294 }
9295 }
9296 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
9297 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
9298 lines.push(
9299 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
9300 .into(),
9301 );
9302 let body = lines.join("\n");
9303 container_note_block(chat, &body);
9304}
9305
9306fn container_note_block(container: &Arc<Container>, body: &str) {
9308 for line in body.lines() {
9309 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
9310 }
9311 container.add_child(Arc::new(Spacer::new(1)));
9312}
9313
9314pub fn is_tui_supported() -> bool {
9320 std::io::stdout().is_terminal()
9321}
9322
9323#[allow(unused_imports)]
9325use rpi_tui::Color as _Color;
9326
9327#[cfg(test)]
9328mod tests {
9329 use super::*;
9330 use rpi_tui::Component;
9331
9332 #[test]
9336 fn auto_send_cancels_when_the_user_edits_the_draft() {
9337 let now = std::time::Instant::now();
9338 let pending = AutoSendPending {
9339 deadline: now + std::time::Duration::from_secs(2),
9340 text: "hello world".to_string(),
9341 };
9342 assert!(matches!(
9344 auto_send_step(Some(&pending), "hello world", true, now),
9345 AutoSendStep::Wait(_)
9346 ));
9347 assert_eq!(
9350 auto_send_step(Some(&pending), "hello worlds", true, now),
9351 AutoSendStep::Cancel
9352 );
9353 assert_eq!(
9355 auto_send_step(Some(&pending), "", true, now),
9356 AutoSendStep::Cancel
9357 );
9358 assert_eq!(
9360 auto_send_step(Some(&pending), " ", true, now),
9361 AutoSendStep::Cancel
9362 );
9363 }
9364
9365 #[test]
9368 fn auto_send_submits_at_the_deadline_only_while_idle() {
9369 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2000);
9370 let pending = AutoSendPending {
9371 deadline,
9372 text: "go".to_string(),
9373 };
9374 assert!(matches!(
9375 auto_send_step(
9376 Some(&pending),
9377 "go",
9378 true,
9379 deadline - std::time::Duration::from_millis(1)
9380 ),
9381 AutoSendStep::Wait(_)
9382 ));
9383 assert_eq!(
9384 auto_send_step(Some(&pending), "go", true, deadline),
9385 AutoSendStep::Submit
9386 );
9387 assert_eq!(
9390 auto_send_step(Some(&pending), "go", false, deadline),
9391 AutoSendStep::Cancel
9392 );
9393 assert_eq!(
9395 auto_send_step(None, "go", true, deadline),
9396 AutoSendStep::Idle
9397 );
9398 }
9399
9400 #[test]
9403 fn auto_send_footer_label_is_recognisable() {
9404 let label = format!("{AUTO_SEND_PREFIX}2.0s · any key to edit");
9405 assert!(label.starts_with(AUTO_SEND_PREFIX));
9406 assert!(!"Working…".starts_with(AUTO_SEND_PREFIX));
9407 }
9408
9409 #[test]
9410 fn markdown_flag_only_comes_from_an_explicit_true() {
9411 assert!(tool_result_requests_markdown(Some(
9413 &serde_json::json!({"kind": "todo", "markdown": true})
9414 )));
9415 assert!(!tool_result_requests_markdown(None));
9418 assert!(!tool_result_requests_markdown(Some(
9419 &serde_json::json!({"kind": "todo"})
9420 )));
9421 assert!(!tool_result_requests_markdown(Some(
9422 &serde_json::json!({"markdown": false})
9423 )));
9424 assert!(!tool_result_requests_markdown(Some(
9425 &serde_json::json!({"markdown": "true"})
9426 )));
9427 }
9428
9429 fn test_tui_state() -> Arc<TuiState> {
9432 Arc::new(TuiState {
9433 current_assistant: Arc::new(std::sync::Mutex::new(None)),
9434 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
9435 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
9436 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
9437 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
9438 show_terminal_progress: true,
9439 status: std::sync::Mutex::new(RunStatus::Idle),
9440 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
9441 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
9442 editor_text: rpi_extensions::EditorTextMailbox::new(),
9443 auto_send: std::sync::Mutex::new(None),
9444 last_editor_text: std::sync::Mutex::new(String::new()),
9445 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
9446 js_preparation_cancel: std::sync::Mutex::new(None),
9447 user_bash_cancel: std::sync::Mutex::new(None),
9448 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
9449 footer: Arc::new(FooterComponent::new()),
9450 status_container: Arc::new(Container::new()),
9451 chat_container: Arc::new(Container::new()),
9452 loader: Arc::new(Loader::new()),
9453 editor: Arc::new(Editor::simple()),
9454 last_assistant_text: std::sync::Mutex::new(String::new()),
9455 active_selector: std::sync::Mutex::new(None),
9456 active_extension_editor: std::sync::Mutex::new(None),
9457 active_extension_input: std::sync::Mutex::new(None),
9458 active_extension_cancel: std::sync::Mutex::new(None),
9459 autocomplete: AutocompleteManager::new(),
9460 autocomplete_container: Arc::new(Container::new()),
9461 autocomplete_max_visible: 5,
9462 pending_images: std::sync::Mutex::new(Vec::new()),
9463 theme_manager: Arc::new(ThemeManager::new()),
9464 tui: None,
9465 current_model_id: std::sync::Mutex::new(String::new()),
9466 show_images: std::sync::Mutex::new(true),
9467 cache_miss_notices: std::sync::Mutex::new(false),
9468 history: std::sync::Mutex::new(Vec::new()),
9469 history_index: std::sync::Mutex::new(-1),
9470 history_draft: std::sync::Mutex::new(None),
9471 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
9472 scoped_edit: std::sync::Mutex::new(None),
9473 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
9474 extension_session: Arc::new(std::sync::Mutex::new(
9475 rpi_extensions::ExtensionSession::none(),
9476 )),
9477 search: Arc::new(AltScreenSearch::new()),
9478 search_bar: Arc::new(SearchBar::new()),
9479 pending_container: Arc::new(Container::new()),
9480 dequeue_hint: String::new(),
9481 pending_snapshot: std::sync::Mutex::new(
9482 rpi_harness::agent_harness::QueuedMessages::default(),
9483 ),
9484 selection_start: std::sync::Mutex::new(None),
9485 selection_end: std::sync::Mutex::new(None),
9486 })
9487 }
9488
9489 #[test]
9496 fn editor_change_source_separates_tui_writes_from_typing() {
9497 use std::sync::atomic::Ordering;
9498 let state = test_tui_state();
9499
9500 assert!(state.take_editor_change().is_none());
9502
9503 state.editor_text.push(rpi_extensions::EditorTextEdit {
9505 text: "你好".to_string(),
9506 mode: rpi_extensions::EditorTextMode::Replace,
9507 auto_send_ms: Some(2000),
9508 });
9509 assert!(state.drain_editor_text(), "the queued draft must land");
9510 assert!(
9511 state.programmatic_editor_write.load(Ordering::SeqCst),
9512 "a mailbox-applied edit must be marked programmatic"
9513 );
9514
9515 let payload = state
9517 .take_editor_change()
9518 .expect("draft applied ⇒ a change");
9519 assert_eq!(payload["source"], serde_json::json!("extension"));
9520 assert_eq!(payload["chars"], serde_json::json!(2));
9521 assert_eq!(payload["empty"], serde_json::json!(false));
9522
9523 assert!(state.take_editor_change().is_none());
9526 state.editor.set_text("你好啊");
9527 let payload = state.take_editor_change().expect("typing ⇒ a change");
9528 assert_eq!(payload["source"], serde_json::json!("user"));
9529 assert_eq!(payload["chars"], serde_json::json!(3));
9530 }
9531
9532 #[test]
9535 fn programmatic_mark_is_consumed_even_with_no_subscriber() {
9536 use std::sync::atomic::Ordering;
9537 let state = test_tui_state();
9538 assert!(
9539 state
9540 .extension_session
9541 .lock()
9542 .unwrap()
9543 .snapshot_arc()
9544 .is_none(),
9545 "this test relies on nobody subscribing"
9546 );
9547
9548 state.editor_text.push(rpi_extensions::EditorTextEdit {
9549 text: "draft".to_string(),
9550 mode: rpi_extensions::EditorTextMode::Replace,
9551 auto_send_ms: None,
9552 });
9553 state.drain_editor_text();
9554 state.sync_editor_change();
9556 assert!(!state.programmatic_editor_write.load(Ordering::SeqCst));
9558 state.editor.set_text("draft +");
9559 let payload = state.take_editor_change().expect("typing ⇒ a change");
9560 assert_eq!(payload["source"], serde_json::json!("user"));
9561 }
9562
9563 #[test]
9572 fn submit_clear_is_attributed_to_the_host_not_the_user() {
9573 let state = test_tui_state();
9574
9575 state.editor.set_text("/voice auto");
9577 assert_eq!(
9578 state.take_editor_change().unwrap()["source"],
9579 serde_json::json!("user")
9580 );
9581
9582 state.clear_editor_programmatically();
9584 let payload = state.take_editor_change().expect("clearing is a change");
9585 assert_eq!(payload["empty"], serde_json::json!(true));
9586 assert_eq!(
9587 payload["source"],
9588 serde_json::json!("extension"),
9589 "a submit-clear must not be reported as the user typing"
9590 );
9591 }
9592
9593 #[test]
9596 fn user_initiated_clear_stays_a_user_change() {
9597 let state = test_tui_state();
9598 state.editor.set_text("draft");
9599 assert_eq!(
9600 state.take_editor_change().unwrap()["source"],
9601 serde_json::json!("user")
9602 );
9603 state.editor.clear();
9604 let payload = state.take_editor_change().expect("clearing is a change");
9605 assert_eq!(payload["source"], serde_json::json!("user"));
9606 }
9607
9608 #[test]
9609 fn live_panel_repaints_while_a_tool_or_bash_is_running() {
9610 let empty: HashMap<String, Arc<ToolExecutionComponent>> = HashMap::new();
9613 assert!(!transcript_has_live_panel(false, &empty));
9614
9615 let mut tools = HashMap::new();
9618 let tool = Arc::new(ToolExecutionComponent::new("search", "{}"));
9619 tool.set_running();
9620 tools.insert("tc1".to_string(), tool);
9621 assert!(transcript_has_live_panel(false, &tools));
9622
9623 tools.get("tc1").unwrap().set_result("done", false);
9626 assert!(!transcript_has_live_panel(false, &tools));
9627 assert!(transcript_has_live_panel(true, &tools));
9628 }
9629
9630 #[test]
9631 fn dequeue_merge_preserves_a_typed_draft() {
9632 assert_eq!(merge_queued_into_draft("queued", ""), "queued");
9633 assert_eq!(merge_queued_into_draft("queued", " "), "queued");
9634 assert_eq!(
9635 merge_queued_into_draft("queued", "typed"),
9636 "queued\n\ntyped"
9637 );
9638 }
9639
9640 #[test]
9641 fn dequeue_places_the_caret_at_the_end_of_the_last_line_in_bytes() {
9642 let editor = Arc::new(Editor::simple());
9646 let text = "排队\n第二行";
9649 set_editor_text_caret_at_end(&editor, text);
9650 assert_eq!(editor.get_text(), text);
9651 assert_eq!(
9652 editor.cursor_position(),
9653 (1, "第二行".len()),
9654 "caret must sit at the end of the last line, in bytes"
9655 );
9656 }
9657
9658 #[test]
9659 fn settings_menu_covers_the_wired_settings_surface() {
9660 let state = test_tui_state();
9661 let settings = crate::settings::Settings::default();
9662 let items = settings_menu_items(&settings, &state, "claude-sonnet-5");
9663 let keys: Vec<&str> = items.iter().map(|item| item.key.as_str()).collect();
9664 assert_eq!(
9665 keys,
9666 [
9667 "theme",
9668 "model",
9669 "thinking",
9670 "scoped-models",
9671 "hide-thinking",
9672 "show-images",
9673 "cache-miss-notices",
9674 "quiet-startup",
9675 "terminal-progress",
9676 "fullscreen-copy-on-select",
9677 "double-escape-action",
9678 "editor-padding",
9679 "autocomplete-max-items",
9680 "http-idle-timeout",
9681 ]
9682 );
9683 for item in items.iter().filter(|item| item.has_submenu) {
9685 assert!(
9686 item.values.is_empty(),
9687 "{} should be submenu-only",
9688 item.key
9689 );
9690 }
9691 for item in items.iter().filter(|item| !item.has_submenu) {
9693 assert!(!item.values.is_empty(), "{} has no values", item.key);
9694 assert!(
9695 item.values.iter().any(|value| value == &item.value),
9696 "{} current value {} not in {:?}",
9697 item.key,
9698 item.value,
9699 item.values
9700 );
9701 }
9702 assert_eq!(items[1].value, "claude-sonnet-5");
9704 assert_eq!(items[6].value, "false");
9706 assert_eq!(items[8].value, "true");
9707 }
9708
9709 #[test]
9710 fn apply_setting_change_updates_settings_and_live_state() {
9711 let state = test_tui_state();
9712 let mut settings = crate::settings::Settings::default();
9713
9714 let note = apply_setting_change(&state, "hide-thinking", "true", &mut settings);
9716 assert!(state.hide_thinking());
9717 assert_eq!(settings.hide_thinking_block, Some(true));
9718 assert!(note.expect("user-visible note").contains("hidden"));
9719
9720 apply_setting_change(&state, "show-images", "false", &mut settings);
9722 assert!(!*state.show_images.lock().unwrap());
9723 assert_eq!(settings.show_images, Some(false));
9724
9725 apply_setting_change(&state, "cache-miss-notices", "true", &mut settings);
9727 assert!(*state.cache_miss_notices.lock().unwrap());
9728 assert_eq!(settings.show_cache_miss_notices, Some(true));
9729
9730 assert_eq!(
9732 apply_setting_change(&state, "editor-padding", "3", &mut settings),
9733 None
9734 );
9735 assert_eq!(settings.editor_padding_x, Some(3));
9736 apply_setting_change(&state, "double-escape-action", "none", &mut settings);
9737 assert_eq!(settings.double_escape_action.as_deref(), Some("none"));
9738 apply_setting_change(&state, "autocomplete-max-items", "20", &mut settings);
9739 assert_eq!(settings.autocomplete_max_visible, Some(20));
9740
9741 apply_setting_change(&state, "http-idle-timeout", "disabled", &mut settings);
9743 assert_eq!(settings.http_idle_timeout_ms(), Some(0));
9744 apply_setting_change(&state, "http-idle-timeout", "1m", &mut settings);
9745 assert_eq!(settings.http_idle_timeout_ms(), Some(60_000));
9746
9747 assert_eq!(
9749 apply_setting_change(&state, "nope", "x", &mut settings),
9750 None
9751 );
9752 }
9753
9754 #[test]
9755 fn add_user_message_renders_a_visible_user_bubble() {
9756 let chat = Arc::new(Container::new());
9760 add_user_message(&chat, "hello from the user");
9761 let rendered = crate::interactive_tui::collect_transcript_lines(&chat);
9762 let plain = strip_ansi(&rendered.join("\n"));
9763 assert!(
9764 plain.contains("hello from the user"),
9765 "user bubble must show the prompt text; got: {plain:?}"
9766 );
9767 }
9768
9769 #[test]
9770 fn verbose_overrides_quiet_startup_listing() {
9771 assert!(should_show_startup_listing(false, false));
9772 assert!(should_show_startup_listing(true, false));
9773 assert!(should_show_startup_listing(true, true));
9774 assert!(!should_show_startup_listing(false, true));
9775 }
9776
9777 #[test]
9778 fn resume_command_omits_the_default_session_dir() {
9779 let cwd = std::path::Path::new("/proj/demo");
9780 assert_eq!(format_resume_command("", cwd, None), None);
9781 assert_eq!(
9782 format_resume_command("abc-123", cwd, None).as_deref(),
9783 Some("rpi --session abc-123")
9784 );
9785 let default = crate::session::default_session_dir(cwd);
9787 assert_eq!(
9788 format_resume_command("abc-123", cwd, Some(default.as_path())).as_deref(),
9789 Some("rpi --session abc-123")
9790 );
9791 let custom = std::path::Path::new("/tmp/my sessions");
9793 assert_eq!(
9794 format_resume_command("abc-123", cwd, Some(custom)).as_deref(),
9795 Some("rpi --session-dir \"/tmp/my sessions\" --session abc-123")
9796 );
9797 }
9798
9799 #[test]
9800 fn quote_shell_arg_quotes_only_when_needed() {
9801 assert_eq!(quote_shell_arg("plain"), "plain");
9802 assert_eq!(quote_shell_arg("with space"), "\"with space\"");
9803 assert_eq!(quote_shell_arg("a&b"), "\"a&b\"");
9804 }
9805
9806 #[test]
9807 fn trust_command_uses_the_session_cwd_after_process_chdir() {
9808 const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
9809 const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
9810 const TEST_NAME: &str =
9811 "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
9812
9813 if std::env::var_os(CHILD_ENV).is_some() {
9814 let session_cwd = std::path::PathBuf::from(
9815 std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
9816 );
9817 let changed_cwd = std::env::current_dir().unwrap();
9818
9819 set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
9820
9821 assert_eq!(
9822 crate::config::project_trust_decision(&session_cwd).unwrap(),
9823 Some(true)
9824 );
9825 assert_eq!(
9826 crate::config::project_trust_decision(&changed_cwd).unwrap(),
9827 None
9828 );
9829 return;
9830 }
9831
9832 let tmp = tempfile::tempdir().unwrap();
9833 let session_cwd = tmp.path().join("session-project");
9834 let changed_cwd = tmp.path().join("extension-cwd");
9835 let agent_dir = tmp.path().join("agent");
9836 std::fs::create_dir_all(&session_cwd).unwrap();
9837 std::fs::create_dir_all(&changed_cwd).unwrap();
9838 std::fs::create_dir_all(&agent_dir).unwrap();
9839
9840 let status = std::process::Command::new(std::env::current_exe().unwrap())
9841 .arg("--exact")
9842 .arg(TEST_NAME)
9843 .arg("--nocapture")
9844 .env(CHILD_ENV, "1")
9845 .env(SESSION_CWD_ENV, &session_cwd)
9846 .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
9847 .current_dir(&changed_cwd)
9848 .status()
9849 .unwrap();
9850
9851 assert!(status.success(), "child test process failed: {status}");
9852 }
9853
9854 #[test]
9855 fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
9856 let global = crate::settings::Settings {
9857 editor_padding_x: Some(3),
9858 autocomplete_max_visible: Some(6),
9859 hide_thinking_block: Some(false),
9860 quiet_startup: Some(true),
9861 show_terminal_progress: Some(true),
9862 ..Default::default()
9863 };
9864 let rpi_project = crate::settings::Settings {
9865 editor_padding_x: Some(0),
9866 hide_thinking_block: Some(true),
9867 quiet_startup: Some(false),
9868 show_terminal_progress: Some(false),
9869 ..Default::default()
9870 };
9871 let pi_project = crate::settings::Settings {
9872 editor_padding_x: Some(9),
9873 autocomplete_max_visible: Some(12),
9874 quiet_startup: Some(true),
9875 ..Default::default()
9876 };
9877
9878 assert_eq!(
9879 resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
9880 TuiStartupSettings {
9881 editor_padding_x: 0,
9882 autocomplete_max_visible: 12,
9883 hide_thinking: true,
9884 quiet_startup: false,
9885 show_terminal_progress: false,
9886 show_images: true,
9887 cache_miss_notices: false,
9888 }
9889 );
9890 }
9891
9892 #[test]
9893 fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
9894 let global = crate::settings::Settings {
9895 quiet_startup: Some(false),
9896 ..Default::default()
9897 };
9898 let rpi_project = crate::settings::Settings::default();
9899 let pi_project = crate::settings::Settings {
9900 quiet_startup: Some(true),
9901 ..Default::default()
9902 };
9903
9904 let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
9905
9906 assert!(resolved.quiet_startup);
9907 }
9908
9909 #[test]
9910 fn tui_startup_settings_use_global_values_without_project_fields() {
9911 let global = crate::settings::Settings {
9912 editor_padding_x: Some(7),
9913 autocomplete_max_visible: Some(8),
9914 hide_thinking_block: Some(true),
9915 quiet_startup: Some(true),
9916 show_terminal_progress: Some(false),
9917 show_images: Some(false),
9918 show_cache_miss_notices: Some(true),
9919 ..Default::default()
9920 };
9921
9922 assert_eq!(
9923 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
9924 TuiStartupSettings {
9925 editor_padding_x: 7,
9926 autocomplete_max_visible: 8,
9927 hide_thinking: true,
9928 quiet_startup: true,
9929 show_terminal_progress: false,
9930 show_images: false,
9931 cache_miss_notices: true,
9932 }
9933 );
9934 }
9935
9936 #[test]
9937 fn tui_startup_settings_read_native_nested_terminal_block() {
9938 let global = crate::settings::Settings {
9941 terminal: Some(crate::settings::TerminalSettings {
9942 show_images: Some(false),
9943 show_terminal_progress: Some(false),
9944 clear_on_shrink: None,
9945 }),
9946 ..Default::default()
9947 };
9948 let resolved =
9949 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
9950 assert!(!resolved.show_images);
9951 assert!(!resolved.show_terminal_progress);
9952 }
9953
9954 #[test]
9955 fn tui_startup_settings_flat_keys_win_over_nested_terminal_block() {
9956 let global = crate::settings::Settings {
9957 show_images: Some(true),
9958 terminal: Some(crate::settings::TerminalSettings {
9959 show_images: Some(false),
9960 show_terminal_progress: None,
9961 clear_on_shrink: None,
9962 }),
9963 ..Default::default()
9964 };
9965 let resolved =
9966 resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true);
9967 assert!(resolved.show_images);
9968 }
9969
9970 #[test]
9971 fn tui_startup_settings_ignore_untrusted_project_values() {
9972 let global = crate::settings::Settings {
9973 quiet_startup: Some(false),
9974 show_terminal_progress: Some(true),
9975 ..Default::default()
9976 };
9977 let project = crate::settings::Settings {
9978 quiet_startup: Some(true),
9979 show_terminal_progress: Some(false),
9980 ..Default::default()
9981 };
9982
9983 let resolved = resolve_tui_startup_settings(&global, &[project], false);
9984
9985 assert!(!resolved.quiet_startup);
9986 assert!(resolved.show_terminal_progress);
9987 }
9988
9989 #[test]
9990 fn transcript_page_uses_viewport_with_overlap() {
9991 assert_eq!(transcript_page_size(24), 20);
9992 assert_eq!(transcript_page_size(4), 1);
9993 assert_eq!(transcript_page_size(0), 1);
9994 }
9995
9996 #[test]
9997 fn parse_user_bash_distinguishes_single_and_double_bang() {
9998 assert_eq!(parse_user_bash("!ls -la"), Some(("ls -la", false)));
10000 assert_eq!(parse_user_bash("!!ls -la"), Some(("ls -la", true)));
10002 assert_eq!(parse_user_bash("! echo hi "), Some(("echo hi", false)));
10004 assert_eq!(parse_user_bash("!! echo hi"), Some(("echo hi", true)));
10005 }
10006
10007 #[test]
10008 fn parse_user_bash_ignores_bare_bang_and_plain_text() {
10009 assert_eq!(parse_user_bash("!"), None);
10012 assert_eq!(parse_user_bash("!!"), None);
10013 assert_eq!(parse_user_bash("! "), None);
10014 assert_eq!(parse_user_bash("hello"), None);
10015 assert_eq!(parse_user_bash(""), None);
10016 assert_eq!(parse_user_bash("/model"), None);
10018 }
10019
10020 #[test]
10021 fn user_bash_run_slot_guards_and_cancels() {
10022 let state = test_tui_state();
10023 assert!(!state.user_bash_running());
10024 let cancel = state.begin_user_bash();
10025 assert!(state.user_bash_running());
10026 assert!(state.cancel_user_bash());
10029 assert!(state.user_bash_running());
10030 assert!(cancel.is_cancelled());
10031 state.finish_user_bash();
10032 assert!(!state.user_bash_running());
10033 assert!(!state.cancel_user_bash());
10035 }
10036
10037 #[test]
10038 fn key_repeat_is_dispatched_but_release_is_not() {
10039 assert!(should_dispatch_key(KeyEventKind::Press));
10040 assert!(should_dispatch_key(KeyEventKind::Repeat));
10041 assert!(!should_dispatch_key(KeyEventKind::Release));
10042 }
10043
10044 #[test]
10045 fn test_layout_renders_welcome_message() {
10046 let chat = Arc::new(Container::new());
10047 add_welcome_message(&chat);
10048
10049 let scroll = Arc::new(ScrollView::new(
10050 chat.clone(),
10051 ScrollViewOptions {
10052 follow: FollowMode::End,
10053 primary: true,
10054 ..Default::default()
10055 },
10056 ));
10057
10058 let editor = Arc::new(Editor::new(
10059 EditorOptions {
10060 padding_x: 1,
10061 ..Default::default()
10062 },
10063 EditorStyle::default(),
10064 Arc::new(rpi_tui::Keybindings::new()),
10065 ));
10066 let dock = Arc::new(Container::new());
10067 dock.add_child(editor);
10068
10069 let footer = Arc::new(FooterComponent::new());
10070
10071 let root = VStack::from_children(vec![
10072 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
10073 StackChild::Entry(StackEntry::new(dock)),
10074 StackChild::Entry(StackEntry::new(footer)),
10075 ]);
10076
10077 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
10078
10079 let all: String = frame.lines.join("\n");
10080 assert!(
10081 all.contains("rpi"),
10082 "Welcome message not found. Rendered: {}",
10083 all
10084 );
10085 assert!(
10086 all.contains("Type your message"),
10087 "Help text not found. Rendered: {}",
10088 all
10089 );
10090 }
10091
10092 #[test]
10093 fn test_chat_container_has_welcome_content() {
10094 let chat = Arc::new(Container::new());
10095 add_welcome_message_with_capabilities(
10096 &chat,
10097 &["read".into(), "bash".into(), "web_fetch".into()],
10098 &["rust-review".into(), "release".into()],
10099 );
10100
10101 let lines = chat.render(80);
10102 let all: String = lines.join("\n");
10103 let plain = strip_ansi(&all);
10106 assert!(
10107 plain.contains("rpi"),
10108 "Welcome message not in chat container: {:?}",
10109 lines
10110 );
10111 assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
10112 assert!(
10113 plain.contains("read · bash · web_fetch"),
10114 "Tool names missing: {plain}"
10115 );
10116 assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
10117 assert!(
10118 plain.contains("rust-review · release"),
10119 "Skill names missing: {plain}"
10120 );
10121 }
10122
10123 #[test]
10124 fn welcome_header_renders_the_brand_mark() {
10125 let chat = Arc::new(Container::new());
10126 add_welcome_message(&chat);
10127 let lines: Vec<String> = chat.render(80).iter().map(|l| strip_ansi(l)).collect();
10128 let all = lines.join("\n");
10129
10130 assert!(
10133 lines.iter().any(|l| l.contains("██ ██ ██")),
10134 "brand mark missing: {all}"
10135 );
10136 assert!(
10137 all.contains("rpi"),
10138 "the mark must carry the product name: {all}"
10139 );
10140
10141 assert!(
10147 !all.contains('π'),
10148 "the header must not use east-asian-ambiguous glyphs: {all}"
10149 );
10150 }
10151
10152 #[test]
10153 fn update_notices_render_inside_the_transcript() {
10154 let chat = Arc::new(Container::new());
10155 let report = crate::updates::UpdateReport {
10156 notices: vec![crate::updates::UpdateNotice {
10157 name: "rpi".into(),
10158 current: "0.1.10".into(),
10159 latest: "0.1.11".into(),
10160 command: "rpi update".into(),
10161 }],
10162 warnings: vec![crate::updates::UpdateWarning {
10163 message: "The previously scheduled rpi update failed: access denied".into(),
10164 command: "rpi update".into(),
10165 }],
10166 };
10167
10168 add_update_notices(&chat, &report);
10169
10170 assert_eq!(chat.child_count(), 1);
10171 let plain = strip_ansi(&chat.render(80).join("\n"));
10172 assert!(plain.contains("Update Failed"), "{plain}");
10173 assert!(
10174 plain.contains("rpi update failed: access denied"),
10175 "{plain}"
10176 );
10177 assert!(plain.contains("Update Available"), "{plain}");
10178 assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
10179 assert!(plain.contains("rpi update"), "{plain}");
10180 }
10181
10182 #[test]
10183 fn empty_update_report_does_not_add_transcript_content() {
10184 let chat = Arc::new(Container::new());
10185
10186 add_update_notices(&chat, &crate::updates::UpdateReport::default());
10187
10188 assert_eq!(chat.child_count(), 0);
10189 }
10190
10191 #[test]
10192 fn welcome_capabilities_show_empty_state() {
10193 let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
10194 assert_eq!(plain, "Skills (0) none");
10195 }
10196
10197 #[test]
10198 fn completed_stream_reconciles_the_final_tail_before_detaching() {
10199 let component = Arc::new(AssistantMessageComponent::new(
10200 AssistantMessageOptions::default(),
10201 ));
10202 component.set_streaming(true);
10203 component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
10204
10205 let current = Arc::new(Mutex::new(Some(component.clone())));
10206 let cached = Mutex::new("partial response".to_string());
10207 let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
10208 final_message.content = vec![Content::text(
10209 "partial response with the previously missing final tail",
10210 )];
10211 final_message.stop_reason = rpi_ai::types::StopReason::Stop;
10212
10213 reconcile_streamed_assistant_completion(¤t, &cached, Some(&final_message));
10214
10215 assert!(current.lock().unwrap().is_none());
10216 assert_eq!(
10217 cached.lock().unwrap().as_str(),
10218 "partial response with the previously missing final tail"
10219 );
10220 let rendered = strip_ansi(&component.render(100).join("\n"));
10221 assert!(
10222 rendered.contains("previously missing final tail"),
10223 "{rendered}"
10224 );
10225 }
10226
10227 #[test]
10233 fn tab_accept_suggestion_reflects_in_next_render() {
10234 use rpi_tui::render_layout_frame;
10235
10236 let editor = Arc::new(Editor::new(
10237 EditorOptions {
10238 padding_x: 1,
10239 ..Default::default()
10240 },
10241 EditorStyle::default(),
10242 Arc::new(rpi_tui::Keybindings::new()),
10243 ));
10244 editor.set_focused(true);
10245 let editor_container = Arc::new(Container::new());
10246 editor_container.add_child(editor.clone());
10247 let autocomplete_container = Arc::new(Container::new());
10248 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
10249 let dock = Arc::new(VStack::from_children(vec![
10250 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
10251 StackChild::Entry(
10252 StackEntry::new(editor_container.clone())
10253 .shrink(0)
10254 .min_size(3),
10255 ),
10256 StackChild::Entry(StackEntry::new(footer)),
10257 ]));
10258
10259 let manager = AutocompleteManager::new();
10261 let mut combined = CombinedAutocompleteProvider::new();
10262 combined.add_provider(Arc::new(
10263 SlashCommandAutocompleteProvider::with_default_commands(),
10264 ));
10265 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
10266 manager.set_provider(Arc::new(combined));
10267 editor.insert("/mo");
10270 assert_eq!(editor.cursor_position(), (0, 3));
10271
10272 let frame_before = render_layout_frame(dock.clone(), 80, 10);
10273 assert!(
10274 frame_before.lines.iter().any(|l| l.contains("/mo")),
10275 "precondition: editor shows the typed prefix. Frame rows:\n{}",
10276 frame_before
10277 .lines
10278 .iter()
10279 .map(|l| format!(" [{l}]"))
10280 .collect::<Vec<_>>()
10281 .join("\n")
10282 );
10283
10284 let text = editor.get_text();
10286 let cursor = editor_cursor_offset(&editor, &text);
10287 let sugg = manager
10288 .get_suggestions(&text, cursor)
10289 .expect("slash suggestions for /mo");
10290 let top = sugg.items.first().expect("at least one suggestion");
10291 let start = sugg.start.min(text.len());
10292 let end = sugg.end.min(text.len());
10293 let mut replaced = String::new();
10294 replaced.push_str(&text[..start]);
10295 replaced.push_str(&top.text);
10296 replaced.push_str(&text[end..]);
10297 if top.insert_space && !replaced.ends_with('/') {
10298 replaced.push(' ');
10299 }
10300 let new_cursor = start + top.text.len();
10301 editor.set_text(&replaced);
10302 set_editor_cursor_offset(&editor, &replaced, new_cursor);
10303 autocomplete_container.clear();
10304 assert_eq!(editor.get_text(), "/model");
10305
10306 let frame_after = render_layout_frame(dock, 80, 10);
10308 let all: String = frame_after.lines.join("\n");
10309 assert!(
10310 all.contains("/model"),
10311 "completed text missing from next render. Got:\n{all}"
10312 );
10313 let editor_line = frame_after
10317 .lines
10318 .iter()
10319 .find(|l| l.contains("/model"))
10320 .expect("editor row with completed text");
10321 assert!(
10322 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
10323 "caret must follow the full completed text. Got: {editor_line:?}"
10324 );
10325 }
10326
10327 #[test]
10328 fn multiline_autocomplete_preserves_row_and_column() {
10329 let editor = Arc::new(Editor::simple());
10330 editor.set_text("first\n/mo");
10331 editor.set_cursor(1, 3);
10332
10333 let text = editor.get_text();
10334 assert_eq!(editor_cursor_offset(&editor, &text), 9);
10335
10336 let replaced = "first\n/model";
10337 editor.set_text(replaced);
10338 set_editor_cursor_offset(&editor, replaced, 12);
10339 assert_eq!(editor.cursor_position(), (1, 6));
10340 }
10341
10342 #[test]
10343 fn test_slash_command_dispatch() {
10344 let registry = build_builtin_registry();
10349
10350 let resolves_to = |token: &str, canonical: &str| {
10352 let found = registry.find(token).expect("{token} should resolve");
10353 assert_eq!(
10354 found.name(),
10355 canonical,
10356 "{token} resolved to {} (expected {canonical})",
10357 found.name()
10358 );
10359 };
10360
10361 resolves_to("/help", "/help");
10362 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
10364 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
10368 resolves_to("/v", "/version"); resolves_to("/changelog", "/changelog");
10370 resolves_to("/hotkeys", "/hotkeys");
10371 resolves_to("/model", "/model");
10372 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
10374 resolves_to("/session", "/session");
10375 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
10377 resolves_to("/copy", "/copy");
10378 resolves_to("/thinking", "/thinking");
10379 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
10381 resolves_to("/images", "/images");
10382 resolves_to("/context", "/context");
10383 resolves_to("/settings", "/settings");
10385 resolves_to("/name", "/name");
10386 resolves_to("/export", "/export");
10387
10388 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
10390 }
10391
10392 #[test]
10393
10394 fn test_registry_visible_entries_cover_dispatch() {
10395 let registry = build_builtin_registry();
10400 let names: Vec<String> = registry
10401 .visible_entries()
10402 .iter()
10403 .map(|c| c.name.clone())
10404 .collect();
10405 for recognized in [
10406 "/help",
10407 "/clear",
10408 "/new",
10409 "/exit",
10410 "/quit",
10411 "/version",
10412 "/changelog",
10413 "/model",
10414 "/session",
10415 "/theme",
10416 "/compact",
10417 "/copy",
10418 "/hotkeys",
10419 "/tools",
10420 "/images",
10421 "/thinking",
10422 "/usage",
10423 ] {
10424 assert!(
10425 names.contains(&recognized.to_string()),
10426 "{recognized} missing from autocomplete list"
10427 );
10428 }
10429 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
10431 assert!(
10432 !names.contains(&hidden.to_string()),
10433 "{hidden} should be hidden from autocomplete"
10434 );
10435 }
10436 }
10437
10438 #[test]
10439 fn test_agent_event_mapping_creates_assistant_and_tool() {
10440 use rpi_ai::types::{
10443 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
10444 ToolCall, ToolCallType, Usage,
10445 };
10446
10447 let state = Arc::new(TuiState {
10448 current_assistant: Arc::new(std::sync::Mutex::new(None)),
10449 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10450 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10451 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10452 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10453 show_terminal_progress: true,
10454 status: std::sync::Mutex::new(RunStatus::Idle),
10455 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10456 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10457 editor_text: rpi_extensions::EditorTextMailbox::new(),
10458 auto_send: std::sync::Mutex::new(None),
10459 last_editor_text: std::sync::Mutex::new(String::new()),
10460 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10461 js_preparation_cancel: std::sync::Mutex::new(None),
10462 user_bash_cancel: std::sync::Mutex::new(None),
10463 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10464 footer: Arc::new(FooterComponent::new()),
10465 status_container: Arc::new(Container::new()),
10466 chat_container: Arc::new(Container::new()),
10467 loader: Arc::new(Loader::new()),
10468 editor: Arc::new(Editor::simple()),
10469 last_assistant_text: std::sync::Mutex::new(String::new()),
10470 active_selector: std::sync::Mutex::new(None),
10471 active_extension_editor: std::sync::Mutex::new(None),
10472 active_extension_input: std::sync::Mutex::new(None),
10473 active_extension_cancel: std::sync::Mutex::new(None),
10474 autocomplete: AutocompleteManager::new(),
10475 autocomplete_container: Arc::new(Container::new()),
10476 autocomplete_max_visible: 5,
10477 pending_images: std::sync::Mutex::new(Vec::new()),
10478 theme_manager: Arc::new(ThemeManager::new()),
10479 tui: None,
10480 current_model_id: std::sync::Mutex::new(String::new()),
10481 show_images: std::sync::Mutex::new(true),
10482 cache_miss_notices: std::sync::Mutex::new(false),
10483 history: std::sync::Mutex::new(Vec::new()),
10484 history_index: std::sync::Mutex::new(-1),
10485 history_draft: std::sync::Mutex::new(None),
10486 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10487 scoped_edit: std::sync::Mutex::new(None),
10488 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10489 extension_session: Arc::new(std::sync::Mutex::new(
10490 rpi_extensions::ExtensionSession::none(),
10491 )),
10492 search: Arc::new(AltScreenSearch::new()),
10493 search_bar: Arc::new(SearchBar::new()),
10494 pending_container: Arc::new(Container::new()),
10495 dequeue_hint: String::new(),
10496 pending_snapshot: std::sync::Mutex::new(
10497 rpi_harness::agent_harness::QueuedMessages::default(),
10498 ),
10499 selection_start: std::sync::Mutex::new(None),
10500 selection_end: std::sync::Mutex::new(None),
10501 });
10502
10503 let assistant = AssistantMessage {
10507 role: rpi_ai::types::AssistantRole,
10508 content: vec![
10509 Content::Thinking(ThinkingContent {
10510 kind: ThinkingContentType,
10511 thinking: "Reasoning about the reply.".into(),
10512 thinking_signature: None,
10513 redacted: false,
10514 }),
10515 Content::Text(TextContent {
10516 kind: TextContentType,
10517 text: "Hello.".into(),
10518 text_signature: None,
10519 }),
10520 Content::ToolCall(ToolCall {
10521 kind: ToolCallType,
10522 id: "tc1".into(),
10523 name: "bash".into(),
10524 arguments: serde_json::json!({"command": "echo hi"}),
10525 thought_signature: None,
10526 namespace: None,
10527 }),
10528 ],
10529 api: rpi_ai::Api::AnthropicMessages,
10530 provider: "anthropic".into(),
10531 model: "claude-sonnet-5".into(),
10532 response_model: None,
10533 response_id: None,
10534 usage: Usage::zero(),
10535 stop_reason: StopReason::Stop,
10536 deferred: None,
10537 error_message: None,
10538 raw_stop_reason: None,
10539 end_turn: None,
10540 timestamp: 0,
10541 };
10542
10543 let comp = Arc::new(AssistantMessageComponent::new(
10546 AssistantMessageOptions::default(),
10547 ));
10548 comp.set_streaming(true);
10549 comp.update_blocks(&assistant_blocks(&assistant));
10550 let chat = Arc::new(Container::new());
10551 chat.add_child(comp.clone());
10552 *state.current_assistant.lock().unwrap() = Some(comp);
10553
10554 for c in &assistant.content {
10556 if let Content::ToolCall(tc) = c {
10557 let mut tools = state.tool_components.lock().unwrap();
10558 if !tools.contains_key(&tc.id) {
10559 let tc_comp = Arc::new(ToolExecutionComponent::new(
10560 &tc.name,
10561 &tc.arguments.to_string(),
10562 ));
10563 tc_comp.set_running();
10564 chat.add_child(tc_comp.clone());
10565 tools.insert(tc.id.clone(), tc_comp);
10566 }
10567 }
10568 }
10569
10570 let rendered = chat.render(80);
10574 let joined: String = rendered.join("\n");
10575 assert!(
10576 joined.contains("Hello."),
10577 "assistant text not rendered: {joined}"
10578 );
10579 assert!(
10580 joined.contains("Reasoning about the reply."),
10581 "thinking block not rendered: {joined}"
10582 );
10583 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
10584 assert!(state.current_assistant.lock().unwrap().is_some());
10585
10586 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
10588 ended.set_result("hi", false);
10589 assert!(state.tool_components.lock().unwrap().is_empty());
10590
10591 assert!(state.try_start_working());
10595 assert!(
10596 !state.try_start_working(),
10597 "a second submit must be rejected"
10598 );
10599 state.set_status(RunStatus::Idle);
10600 state.set_status(RunStatus::Working);
10601 assert!(state.editor.working().is_some());
10603 assert_eq!(state.status_container.child_count(), 0);
10604 assert!(state.footer.get_status().is_empty());
10605 state.show_retry(3, 10, 8_000);
10606 let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
10607 assert!(retry_status.contains("Retrying (3/10)"));
10608 state.set_status(RunStatus::Working);
10609 {
10610 let mut bash = state.bash_components.lock().unwrap();
10611 bash.insert(
10612 "bash-1".into(),
10613 Arc::new(BashExecutionComponent::new("one")),
10614 );
10615 bash.insert(
10616 "bash-2".into(),
10617 Arc::new(BashExecutionComponent::new("two")),
10618 );
10619 }
10620 state.sync_working_loader_with_bash();
10621 assert_eq!(state.status_container.child_count(), 0);
10622 state.bash_components.lock().unwrap().remove("bash-1");
10623 state.sync_working_loader_with_bash();
10624 assert_eq!(state.status_container.child_count(), 0);
10625 state.bash_components.lock().unwrap().remove("bash-2");
10626 state.sync_working_loader_with_bash();
10627 assert_eq!(state.status_container.child_count(), 0);
10630 assert!(state.editor.working().is_some());
10631
10632 state.set_status(RunStatus::Aborting);
10633 assert_eq!(state.status_container.child_count(), 0);
10634 assert!(!state.loader.is_running());
10635 }
10636
10637 #[test]
10638 fn fresh_launch_does_not_restore_old_history() {
10639 let fresh = Args::default();
10640 assert!(!launch_restores_history(&fresh));
10641
10642 let continued = Args {
10643 continue_session: true,
10644 ..Args::default()
10645 };
10646 assert!(launch_restores_history(&continued));
10647
10648 let selected = Args {
10649 session: Some("session-id".into()),
10650 ..Args::default()
10651 };
10652 assert!(launch_restores_history(&selected));
10653 }
10654
10655 #[test]
10656 fn context_badge_ignores_aborted_and_error_turns() {
10657 use rpi_ai::types::{Api, StopReason, Usage};
10658
10659 let footer = Arc::new(FooterComponent::new());
10660 footer.set_context_window(100_000);
10661
10662 let mut ok = rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10664 ok.stop_reason = StopReason::Stop;
10665 ok.usage = Usage {
10666 input: 25_000,
10667 ..Usage::zero()
10668 };
10669 record_context_usage(&footer, &ok);
10670 let stats = strip_ansi(&footer.render(120).join("\n"));
10671 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10672
10673 let mut aborted =
10675 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10676 aborted.stop_reason = StopReason::Aborted;
10677 aborted.usage = Usage {
10678 input: 90_000,
10679 ..Usage::zero()
10680 };
10681 record_context_usage(&footer, &aborted);
10682 let stats = strip_ansi(&footer.render(120).join("\n"));
10683 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10684
10685 let mut errored =
10687 rpi_ai::AssistantMessage::empty(Api::AnthropicMessages, "anthropic", "m", 0);
10688 errored.stop_reason = StopReason::Error;
10689 errored.usage = Usage {
10690 input: 90_000,
10691 ..Usage::zero()
10692 };
10693 record_context_usage(&footer, &errored);
10694 let stats = strip_ansi(&footer.render(120).join("\n"));
10695 assert!(stats.contains("25.0%/100k"), "stats: {stats}");
10696 }
10697
10698 #[test]
10699 fn test_short_model_name() {
10700 assert_eq!(
10701 short_model_name("anthropic:claude-sonnet-5"),
10702 "claude-sonnet-5"
10703 );
10704 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
10705 }
10706
10707 #[test]
10708 fn model_selector_items_are_deduplicated_and_provider_qualified() {
10709 use rpi_ai::{Api, Model};
10710
10711 let mut gateway = Model::new(
10712 "gpt-5.6-sol",
10713 "GPT 5.6 Sol",
10714 Api::OpenaiCompletions,
10715 "routeryo-copy",
10716 "https://gateway.example.com",
10717 );
10718 let duplicate = gateway.clone();
10719 let anthropic = Model::new(
10720 "claude-sonnet-5",
10721 "Claude Sonnet 5",
10722 Api::AnthropicMessages,
10723 "anthropic",
10724 "https://api.anthropic.com",
10725 );
10726 gateway.headers = Some(std::collections::BTreeMap::from([(
10727 "authorization".into(),
10728 "Bearer test".into(),
10729 )]));
10730
10731 let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
10732 assert_eq!(items.len(), 2);
10733 assert_eq!(items[0].value, "gpt-5.6-sol");
10734 assert_eq!(items[0].label, "GPT 5.6 Sol");
10735 assert_eq!(
10736 items[0].description.as_deref(),
10737 Some("routeryo-copy/gpt-5.6-sol (current)")
10738 );
10739 assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
10740 }
10741
10742 #[test]
10743 fn model_selector_search_text_covers_provider_name_and_qualified_id() {
10744 use rpi_ai::{Api, Model};
10745
10746 let anthropic = Model::new(
10747 "claude-sonnet-5",
10748 "Claude Sonnet 5",
10749 Api::AnthropicMessages,
10750 "anthropic",
10751 "https://api.anthropic.com",
10752 );
10753 let items = model_selector_items(&[anthropic], "");
10754 let search = items[0].search_key();
10755 assert!(search.starts_with("anthropic anthropic/claude-sonnet-5"));
10758 assert!(search.contains("Claude Sonnet 5"), "{search}");
10759
10760 use rpi_tui::fuzzy_filter;
10763 for query in ["anthropic", "sonnet", "Claude Sonnet", "claude-sonnet-5"] {
10764 let matched = fuzzy_filter(&items, query, |item| item.search_key());
10765 assert_eq!(matched.len(), 1, "query {query:?} matched nothing");
10766 }
10767 }
10768
10769 #[test]
10770 fn model_selector_match_accepts_bare_and_qualified_ids() {
10771 use rpi_ai::{Api, Model};
10772
10773 let gateway = Model::new(
10774 "gpt-5.6-sol",
10775 "GPT 5.6 Sol",
10776 Api::OpenaiCompletions,
10777 "routeryo-copy",
10778 "https://gateway.example.com",
10779 );
10780 let anthropic = Model::new(
10781 "claude-sonnet-5",
10782 "Claude Sonnet 5",
10783 Api::AnthropicMessages,
10784 "anthropic",
10785 "https://api.anthropic.com",
10786 );
10787 let catalog = [gateway, anthropic];
10788 assert_eq!(
10789 find_model_selector_match(&catalog, "gpt-5.6-sol")
10790 .unwrap()
10791 .provider,
10792 "routeryo-copy"
10793 );
10794 assert_eq!(
10795 find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
10796 .unwrap()
10797 .id,
10798 "gpt-5.6-sol"
10799 );
10800 assert_eq!(
10801 find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
10802 .unwrap()
10803 .id,
10804 "claude-sonnet-5"
10805 );
10806 assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
10807 }
10808
10809 #[test]
10810 fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
10811 use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
10812
10813 let failed = AssistantMessage {
10814 role: AssistantRole,
10815 content: Vec::new(),
10816 api: rpi_ai::Api::AnthropicMessages,
10817 provider: "anthropic".into(),
10818 model: "claude-sonnet-5".into(),
10819 response_model: None,
10820 response_id: None,
10821 usage: Usage::zero(),
10822 stop_reason: StopReason::Error,
10823 deferred: None,
10824 error_message: Some("upstream returned 401".into()),
10825 raw_stop_reason: None,
10826 end_turn: None,
10827 timestamp: 0,
10828 };
10829 assert_eq!(
10830 assistant_error_text(&failed).as_deref(),
10831 Some("upstream returned 401")
10832 );
10833
10834 let mut no_detail = failed;
10835 no_detail.error_message = Some(" ".into());
10836 assert_eq!(
10837 assistant_error_text(&no_detail).as_deref(),
10838 Some("Provider request failed.")
10839 );
10840
10841 let mut aborted = no_detail;
10842 aborted.stop_reason = StopReason::Aborted;
10843 aborted.error_message = Some("abort error: Request aborted".into());
10844 assert_eq!(
10845 assistant_error_text(&aborted).as_deref(),
10846 Some("abort error: Request aborted")
10847 );
10848
10849 aborted.error_message = None;
10850 assert_eq!(
10851 assistant_error_text(&aborted).as_deref(),
10852 Some("Request aborted.")
10853 );
10854 }
10855
10856 #[test]
10857 fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
10858 assert_eq!(
10859 sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
10860 "405\nMethod Not Allowed"
10861 );
10862 assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
10863 assert_eq!(sanitize_error_message(" "), "Provider request failed.");
10864 let long = "x".repeat(20_000);
10865 let cleaned = sanitize_error_message(&long);
10866 assert!(cleaned.chars().count() <= 16 * 1024 + 1);
10867 assert!(cleaned.ends_with('…'));
10868 }
10869
10870 #[test]
10871 fn test_cycle_next_model_wraps_around() {
10872 use rpi_ai::{Api, Model};
10873 let mk = |id: &str| {
10874 Model::new(
10875 id,
10876 id,
10877 Api::AnthropicMessages,
10878 "anthropic",
10879 "https://api.anthropic.com",
10880 )
10881 };
10882 let catalog = [mk("a"), mk("b"), mk("c")];
10883 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
10885 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
10886 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
10888 let empty: Vec<Model> = vec![];
10890 assert!(cycle_next_model(&empty, "a").is_none());
10891 }
10892
10893 #[test]
10894 fn test_cycle_prev_model_wraps_around() {
10895 use rpi_ai::{Api, Model};
10896 let mk = |id: &str| {
10897 Model::new(
10898 id,
10899 id,
10900 Api::AnthropicMessages,
10901 "anthropic",
10902 "https://api.anthropic.com",
10903 )
10904 };
10905 let catalog = [mk("a"), mk("b"), mk("c")];
10906 assert_eq!(cycle_prev_model(&catalog, "b").unwrap().id, "a");
10908 assert_eq!(cycle_prev_model(&catalog, "a").unwrap().id, "c");
10909 assert_eq!(cycle_prev_model(&catalog, "zzz").unwrap().id, "a");
10911 let empty: Vec<Model> = vec![];
10913 assert!(cycle_prev_model(&empty, "a").is_none());
10914 }
10915
10916 #[test]
10917 fn test_autocomplete_slash_suggestions_render() {
10918 let state = Arc::new(TuiState {
10921 current_assistant: Arc::new(std::sync::Mutex::new(None)),
10922 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10923 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
10924 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
10925 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
10926 show_terminal_progress: true,
10927 status: std::sync::Mutex::new(RunStatus::Idle),
10928 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
10929 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
10930 editor_text: rpi_extensions::EditorTextMailbox::new(),
10931 auto_send: std::sync::Mutex::new(None),
10932 last_editor_text: std::sync::Mutex::new(String::new()),
10933 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
10934 js_preparation_cancel: std::sync::Mutex::new(None),
10935 user_bash_cancel: std::sync::Mutex::new(None),
10936 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
10937 footer: Arc::new(FooterComponent::new()),
10938 status_container: Arc::new(Container::new()),
10939 chat_container: Arc::new(Container::new()),
10940 loader: Arc::new(Loader::new()),
10941 editor: Arc::new(Editor::simple()),
10942 last_assistant_text: std::sync::Mutex::new(String::new()),
10943 active_selector: std::sync::Mutex::new(None),
10944 active_extension_editor: std::sync::Mutex::new(None),
10945 active_extension_input: std::sync::Mutex::new(None),
10946 active_extension_cancel: std::sync::Mutex::new(None),
10947 autocomplete: AutocompleteManager::new(),
10948 autocomplete_container: Arc::new(Container::new()),
10949 autocomplete_max_visible: 5,
10950 pending_images: std::sync::Mutex::new(Vec::new()),
10951 theme_manager: Arc::new(ThemeManager::new()),
10952 tui: None,
10953 current_model_id: std::sync::Mutex::new(String::new()),
10954 show_images: std::sync::Mutex::new(true),
10955 cache_miss_notices: std::sync::Mutex::new(false),
10956 history: std::sync::Mutex::new(Vec::new()),
10957 history_index: std::sync::Mutex::new(-1),
10958 history_draft: std::sync::Mutex::new(None),
10959 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
10960 scoped_edit: std::sync::Mutex::new(None),
10961 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
10962 extension_session: Arc::new(std::sync::Mutex::new(
10963 rpi_extensions::ExtensionSession::none(),
10964 )),
10965 search: Arc::new(AltScreenSearch::new()),
10966 search_bar: Arc::new(SearchBar::new()),
10967 pending_container: Arc::new(Container::new()),
10968 dequeue_hint: String::new(),
10969 pending_snapshot: std::sync::Mutex::new(
10970 rpi_harness::agent_harness::QueuedMessages::default(),
10971 ),
10972 selection_start: std::sync::Mutex::new(None),
10973 selection_end: std::sync::Mutex::new(None),
10974 });
10975 {
10976 let mut combined = CombinedAutocompleteProvider::new();
10977 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10978 build_builtin_registry().visible_entries(),
10979 )));
10980 state.autocomplete.set_provider(Arc::new(combined));
10981 }
10982
10983 let editor = Arc::new(Editor::simple());
10984 editor.set_text("/he");
10985 editor.set_cursor(0, 3);
10986 refresh_autocomplete(&state, &editor);
10987 let lines = state.autocomplete_container.render(80);
10988 let joined: String = lines.join("\n");
10989 assert!(
10990 joined.contains("/help"),
10991 "slash suggestions not rendered: {joined}"
10992 );
10993
10994 editor.set_text("hello");
10996 editor.set_cursor(0, 5);
10997 refresh_autocomplete(&state, &editor);
10998 assert!(state.autocomplete_container.render(80).is_empty());
10999 }
11000
11001 #[test]
11002 fn test_select_list_swap_restores_editor() {
11003 let state = Arc::new(TuiState {
11007 current_assistant: Arc::new(std::sync::Mutex::new(None)),
11008 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11009 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11010 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11011 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11012 show_terminal_progress: true,
11013 status: std::sync::Mutex::new(RunStatus::Idle),
11014 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11015 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11016 editor_text: rpi_extensions::EditorTextMailbox::new(),
11017 auto_send: std::sync::Mutex::new(None),
11018 last_editor_text: std::sync::Mutex::new(String::new()),
11019 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11020 js_preparation_cancel: std::sync::Mutex::new(None),
11021 user_bash_cancel: std::sync::Mutex::new(None),
11022 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11023 footer: Arc::new(FooterComponent::new()),
11024 status_container: Arc::new(Container::new()),
11025 chat_container: Arc::new(Container::new()),
11026 loader: Arc::new(Loader::new()),
11027 editor: Arc::new(Editor::simple()),
11028 last_assistant_text: std::sync::Mutex::new(String::new()),
11029 active_selector: std::sync::Mutex::new(None),
11030 active_extension_editor: std::sync::Mutex::new(None),
11031 active_extension_input: std::sync::Mutex::new(None),
11032 active_extension_cancel: std::sync::Mutex::new(None),
11033 autocomplete: AutocompleteManager::new(),
11034 autocomplete_container: Arc::new(Container::new()),
11035 autocomplete_max_visible: 5,
11036 pending_images: std::sync::Mutex::new(Vec::new()),
11037 theme_manager: Arc::new(ThemeManager::new()),
11038 tui: None,
11039 current_model_id: std::sync::Mutex::new(String::new()),
11040 show_images: std::sync::Mutex::new(true),
11041 cache_miss_notices: std::sync::Mutex::new(false),
11042 history: std::sync::Mutex::new(Vec::new()),
11043 history_index: std::sync::Mutex::new(-1),
11044 history_draft: std::sync::Mutex::new(None),
11045 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11046 scoped_edit: std::sync::Mutex::new(None),
11047 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11048 extension_session: Arc::new(std::sync::Mutex::new(
11049 rpi_extensions::ExtensionSession::none(),
11050 )),
11051 search: Arc::new(AltScreenSearch::new()),
11052 search_bar: Arc::new(SearchBar::new()),
11053 pending_container: Arc::new(Container::new()),
11054 dequeue_hint: String::new(),
11055 pending_snapshot: std::sync::Mutex::new(
11056 rpi_harness::agent_harness::QueuedMessages::default(),
11057 ),
11058 selection_start: std::sync::Mutex::new(None),
11059 selection_end: std::sync::Mutex::new(None),
11060 });
11061 let editor_container = Arc::new(Container::new());
11062 let editor = Arc::new(Editor::simple());
11063 editor_container.add_child(editor.clone());
11064 assert!(!state.selector_open());
11065
11066 let tui_terminal = Box::new(ProcessTerminal::new());
11067 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
11068 let list = Arc::new(SelectList::new(
11069 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
11070 5,
11071 ));
11072 open_selector(
11073 &state,
11074 &editor_container,
11075 &editor,
11076 &tui,
11077 list,
11078 SelectorKind::Theme,
11079 );
11080 assert!(state.selector_open());
11081 assert_eq!(editor_container.child_count(), 1);
11083
11084 close_selector(&state, &editor_container, &editor, &tui);
11085 assert!(!state.selector_open());
11086 assert_eq!(editor_container.child_count(), 1);
11088 }
11089
11090 #[test]
11091 fn test_message_history_browse_restores_draft() {
11092 let state = Arc::new(TuiState {
11096 current_assistant: Arc::new(std::sync::Mutex::new(None)),
11097 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11098 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11099 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11100 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11101 show_terminal_progress: true,
11102 status: std::sync::Mutex::new(RunStatus::Idle),
11103 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11104 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11105 editor_text: rpi_extensions::EditorTextMailbox::new(),
11106 auto_send: std::sync::Mutex::new(None),
11107 last_editor_text: std::sync::Mutex::new(String::new()),
11108 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11109 js_preparation_cancel: std::sync::Mutex::new(None),
11110 user_bash_cancel: std::sync::Mutex::new(None),
11111 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11112 footer: Arc::new(FooterComponent::new()),
11113 status_container: Arc::new(Container::new()),
11114 chat_container: Arc::new(Container::new()),
11115 loader: Arc::new(Loader::new()),
11116 editor: Arc::new(Editor::simple()),
11117 last_assistant_text: std::sync::Mutex::new(String::new()),
11118 active_selector: std::sync::Mutex::new(None),
11119 active_extension_editor: std::sync::Mutex::new(None),
11120 active_extension_input: std::sync::Mutex::new(None),
11121 active_extension_cancel: std::sync::Mutex::new(None),
11122 autocomplete: AutocompleteManager::new(),
11123 autocomplete_container: Arc::new(Container::new()),
11124 autocomplete_max_visible: 5,
11125 pending_images: std::sync::Mutex::new(Vec::new()),
11126 theme_manager: Arc::new(ThemeManager::new()),
11127 tui: None,
11128 current_model_id: std::sync::Mutex::new(String::new()),
11129 show_images: std::sync::Mutex::new(true),
11130 cache_miss_notices: std::sync::Mutex::new(false),
11131 history: std::sync::Mutex::new(Vec::new()),
11132 history_index: std::sync::Mutex::new(-1),
11133 history_draft: std::sync::Mutex::new(None),
11134 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11135 scoped_edit: std::sync::Mutex::new(None),
11136 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11137 extension_session: Arc::new(std::sync::Mutex::new(
11138 rpi_extensions::ExtensionSession::none(),
11139 )),
11140 search: Arc::new(AltScreenSearch::new()),
11141 search_bar: Arc::new(SearchBar::new()),
11142 pending_container: Arc::new(Container::new()),
11143 dequeue_hint: String::new(),
11144 pending_snapshot: std::sync::Mutex::new(
11145 rpi_harness::agent_harness::QueuedMessages::default(),
11146 ),
11147 selection_start: std::sync::Mutex::new(None),
11148 selection_end: std::sync::Mutex::new(None),
11149 });
11150 let editor = Arc::new(Editor::simple());
11151
11152 push_history(&state, "first message");
11153 push_history(&state, "second message");
11154 push_history(&state, "second message");
11156 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
11158 assert_eq!(state.history.lock().unwrap()[0], "second message");
11159
11160 editor.set_text("half-typed");
11162 editor.set_cursor(0, 11);
11163
11164 navigate_history(&state, &editor, -1);
11166 assert_eq!(editor.get_text(), "second message");
11167 assert_eq!(*state.history_index.lock().unwrap(), 0);
11168 navigate_history(&state, &editor, -1);
11170 assert_eq!(editor.get_text(), "first message");
11171 assert_eq!(*state.history_index.lock().unwrap(), 1);
11172 navigate_history(&state, &editor, -1);
11174 assert_eq!(editor.get_text(), "first message");
11175 navigate_history(&state, &editor, 1);
11177 assert_eq!(editor.get_text(), "second message");
11178 navigate_history(&state, &editor, 1);
11180 assert_eq!(editor.get_text(), "half-typed");
11181 assert_eq!(*state.history_index.lock().unwrap(), -1);
11182 }
11183
11184 #[test]
11185 fn test_accept_top_suggestion_replaces_prefix() {
11186 let state = Arc::new(TuiState {
11188 current_assistant: Arc::new(std::sync::Mutex::new(None)),
11189 tool_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11190 bash_components: Arc::new(std::sync::Mutex::new(HashMap::new())),
11191 hide_thinking: Arc::new(std::sync::Mutex::new(false)),
11192 tool_outputs_expanded: Arc::new(std::sync::Mutex::new(false)),
11193 show_terminal_progress: true,
11194 status: std::sync::Mutex::new(RunStatus::Idle),
11195 ext_status: rpi_extensions::ExtensionStatusMailbox::new(),
11196 ext_status_revision: std::sync::atomic::AtomicU64::new(u64::MAX),
11197 editor_text: rpi_extensions::EditorTextMailbox::new(),
11198 auto_send: std::sync::Mutex::new(None),
11199 last_editor_text: std::sync::Mutex::new(String::new()),
11200 programmatic_editor_write: std::sync::atomic::AtomicBool::new(false),
11201 js_preparation_cancel: std::sync::Mutex::new(None),
11202 user_bash_cancel: std::sync::Mutex::new(None),
11203 pending_bash_messages: std::sync::Mutex::new(Vec::new()),
11204 footer: Arc::new(FooterComponent::new()),
11205 status_container: Arc::new(Container::new()),
11206 chat_container: Arc::new(Container::new()),
11207 loader: Arc::new(Loader::new()),
11208 editor: Arc::new(Editor::simple()),
11209 last_assistant_text: std::sync::Mutex::new(String::new()),
11210 active_selector: std::sync::Mutex::new(None),
11211 active_extension_editor: std::sync::Mutex::new(None),
11212 active_extension_input: std::sync::Mutex::new(None),
11213 active_extension_cancel: std::sync::Mutex::new(None),
11214 autocomplete: AutocompleteManager::new(),
11215 autocomplete_container: Arc::new(Container::new()),
11216 autocomplete_max_visible: 5,
11217 pending_images: std::sync::Mutex::new(Vec::new()),
11218 theme_manager: Arc::new(ThemeManager::new()),
11219 tui: None,
11220 current_model_id: std::sync::Mutex::new(String::new()),
11221 show_images: std::sync::Mutex::new(true),
11222 cache_miss_notices: std::sync::Mutex::new(false),
11223 history: std::sync::Mutex::new(Vec::new()),
11224 history_index: std::sync::Mutex::new(-1),
11225 history_draft: std::sync::Mutex::new(None),
11226 cache_tracker: std::sync::Mutex::new(rpi_harness::cache_stats::CacheMissTracker::new()),
11227 scoped_edit: std::sync::Mutex::new(None),
11228 markdown_transformer: Arc::new(std::sync::Mutex::new(None)),
11229 extension_session: Arc::new(std::sync::Mutex::new(
11230 rpi_extensions::ExtensionSession::none(),
11231 )),
11232 search: Arc::new(AltScreenSearch::new()),
11233 search_bar: Arc::new(SearchBar::new()),
11234 pending_container: Arc::new(Container::new()),
11235 dequeue_hint: String::new(),
11236 pending_snapshot: std::sync::Mutex::new(
11237 rpi_harness::agent_harness::QueuedMessages::default(),
11238 ),
11239 selection_start: std::sync::Mutex::new(None),
11240 selection_end: std::sync::Mutex::new(None),
11241 });
11242 {
11243 let mut combined = CombinedAutocompleteProvider::new();
11244 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
11245 build_builtin_registry().visible_entries(),
11246 )));
11247 state.autocomplete.set_provider(Arc::new(combined));
11248 }
11249 let editor = Arc::new(Editor::simple());
11250 editor.set_text("/he");
11251 editor.set_cursor(0, 3);
11252 refresh_autocomplete(&state, &editor);
11253 let accepted = accept_top_suggestion(&state, &editor);
11254 assert!(accepted, "should accept the top suggestion");
11255 let text = editor.get_text();
11256 assert!(
11257 text.starts_with("/help"),
11258 "editor text should start with /help, got {text}"
11259 );
11260 }
11261
11262 #[test]
11263 fn configured_key_parser_supports_native_notation() {
11264 let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
11265 assert_eq!(combo.code, KeyCode::Char('g'));
11266 assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
11267 let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
11268 assert_eq!(combo.code, KeyCode::BackTab);
11269 }
11270
11271 #[test]
11272 fn double_escape_trigger_has_half_second_window() {
11273 let now = std::time::Instant::now();
11274 assert!(!double_escape_trigger(None, now));
11275 assert!(double_escape_trigger(
11276 Some(now - std::time::Duration::from_millis(500)),
11277 now
11278 ));
11279 assert!(!double_escape_trigger(
11280 Some(now - std::time::Duration::from_millis(501)),
11281 now
11282 ));
11283 }
11284
11285 #[test]
11286 fn ctrl_c_clears_first_and_only_exits_inside_the_window() {
11287 let now = std::time::Instant::now();
11291 assert_eq!(ctrl_c_action(None, now), CtrlCAction::Clear);
11292 assert_eq!(
11293 ctrl_c_action(Some(now - std::time::Duration::from_millis(500)), now),
11294 CtrlCAction::Exit
11295 );
11296 assert_eq!(
11298 ctrl_c_action(Some(now - std::time::Duration::from_millis(501)), now),
11299 CtrlCAction::Clear
11300 );
11301 }
11302
11303 #[test]
11304 fn pasted_multi_line_block_becomes_one_prompt_not_many() {
11305 use std::sync::atomic::{AtomicUsize, Ordering};
11311
11312 let editor = rpi_tui::Editor::simple();
11313 let submits = Arc::new(AtomicUsize::new(0));
11314 let submits_for_cb = submits.clone();
11315 editor.on_submit(Arc::new(move |_text: &str| {
11316 submits_for_cb.fetch_add(1, Ordering::SeqCst);
11317 }));
11318
11319 let now = std::time::Instant::now();
11322 let mut last_text_key_at: Option<std::time::Instant> = None;
11323 let block = ["l1", "l2", "l3"];
11324 for (index, line) in block.iter().enumerate() {
11325 for ch in line.chars() {
11326 editor.insert(&ch.to_string());
11327 last_text_key_at = Some(now);
11328 }
11329 if index + 1 < block.len() {
11330 let more_queued = true;
11332 assert!(
11333 enter_is_paste_burst(last_text_key_at, now, more_queued),
11334 "pasted newline {index} must not submit"
11335 );
11336 editor.insert("\n");
11337 last_text_key_at = Some(now);
11338 }
11339 }
11340 assert_eq!(editor.get_text(), "l1\nl2\nl3");
11341 assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
11342
11343 let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
11345 assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
11346 editor.handle_key(crossterm::event::KeyEvent::new(
11347 KeyCode::Enter,
11348 KeyModifiers::NONE,
11349 ));
11350 assert_eq!(
11351 submits.load(Ordering::SeqCst),
11352 1,
11353 "typed Enter submits once"
11354 );
11355 }
11356
11357 #[test]
11358 fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
11359 let editor = rpi_tui::Editor::simple();
11362 editor.insert("alpha\r\nbeta\r\n");
11363 assert_eq!(editor.get_text(), "alpha\nbeta\n");
11364 assert!(!editor.get_text().contains('\r'));
11365 }
11366
11367 #[test]
11368 fn pasted_enter_is_not_a_submit() {
11369 let now = std::time::Instant::now();
11370
11371 assert!(enter_is_paste_burst(None, now, true));
11373
11374 assert!(enter_is_paste_burst(
11377 Some(now - std::time::Duration::from_millis(1)),
11378 now,
11379 false
11380 ));
11381 assert!(enter_is_paste_burst(
11382 Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
11383 now,
11384 false
11385 ));
11386
11387 assert!(!enter_is_paste_burst(None, now, false));
11389 assert!(!enter_is_paste_burst(
11390 Some(now - PASTE_BURST_GAP),
11391 now,
11392 false
11393 ));
11394 assert!(!enter_is_paste_burst(
11395 Some(now - std::time::Duration::from_millis(120)),
11396 now,
11397 false
11398 ));
11399 }
11400
11401 #[test]
11402 fn paste_probe_ignores_the_enter_keys_own_release() {
11403 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
11404 let release =
11405 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release);
11406
11407 let mut queued = vec![Event::Key(release)].into_iter();
11410 let (more, stashed) = probe_paste_input(None, || queued.next());
11411 assert!(!more, "a key release alone is not queued paste input");
11412 assert!(stashed.is_none());
11413
11414 let next = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
11417 let mut queued = vec![Event::Key(release), Event::Key(next)].into_iter();
11418 let (more, stashed) = probe_paste_input(None, || queued.next());
11419 assert!(more, "a queued key press is paste continuation");
11420 assert!(matches!(stashed, Some(Event::Key(_))));
11421
11422 let (more, stashed) = probe_paste_input(None, || None);
11424 assert!(!more);
11425 assert!(stashed.is_none());
11426
11427 let (more, stashed) = probe_paste_input(Some(Event::Key(next)), || {
11429 panic!("must not read when an event is already stashed")
11430 });
11431 assert!(more);
11432 assert!(matches!(stashed, Some(Event::Key(_))));
11433 }
11434
11435 #[test]
11438 fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
11439 let flat = rpi_extensions::UiDialogRequest {
11440 request_id: "r1".into(),
11441 tool_call_id: Some("c1".into()),
11442 ui: serde_json::json!({
11443 "kind": "selector",
11444 "id": "proxy_port",
11445 "header": "Proxy",
11446 "question": "端口?",
11447 "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
11448 "allowMultiple": true,
11449 "allowFreeform": false,
11450 "suggest": "1080"
11451 }),
11452 raw: serde_json::json!({}),
11453 };
11454 let prompt = parse_ask_user_prompt(&flat);
11455 assert_eq!(prompt.question_id, "proxy_port");
11456 assert_eq!(prompt.header.as_deref(), Some("Proxy"));
11457 assert_eq!(prompt.options.len(), 2);
11458 assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
11459 assert!(prompt.allow_multiple);
11460 assert!(!prompt.allow_freeform);
11461 assert_eq!(prompt.suggest.as_deref(), Some("1080"));
11462
11463 let alias = rpi_extensions::UiDialogRequest {
11464 ui: serde_json::json!({
11465 "questions": [{"id": "q", "question": "Q?", "options": []}]
11466 }),
11467 ..flat.clone()
11468 };
11469 let prompt = parse_ask_user_prompt(&alias);
11470 assert_eq!(prompt.question_id, "q");
11471 assert!(prompt.allow_freeform, "no options defaults to freeform");
11472
11473 let confirm = rpi_extensions::UiDialogRequest {
11474 ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
11475 ..flat.clone()
11476 };
11477 let prompt = parse_ask_user_prompt(&confirm);
11478 assert_eq!(prompt.kind, "confirm");
11479 assert_eq!(prompt.options.len(), 2);
11480 assert_eq!(prompt.options[0].0, "Yes");
11481 }
11482
11483 #[test]
11484 fn ask_user_bridge_answers_and_cancels_by_request_id() {
11485 let mailbox = rpi_extensions::UiDialogMailbox::new();
11486 mailbox.attach();
11487 let bridge = AskUserBridge::new(mailbox.clone());
11488
11489 mailbox
11491 .handle(serde_json::json!({
11492 "op": "open", "requestId": "r1", "toolCallId": "call-r1",
11493 "ui": {"kind": "input", "question": "Port?"}
11494 }))
11495 .unwrap();
11496 mailbox
11497 .handle(serde_json::json!({
11498 "op": "open", "requestId": "r2", "toolCallId": "call-r2",
11499 "ui": {"kind": "input", "question": "Level?"}
11500 }))
11501 .unwrap();
11502
11503 let first = bridge.take_pending().expect("r1 visible");
11504 assert_eq!(first.request_id, "r1");
11505 assert!(
11506 bridge.take_pending().is_none(),
11507 "only one request occupies the input slot"
11508 );
11509 bridge.respond("r1", serde_json::json!({"text": "7897"}));
11510 assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
11511
11512 let second = bridge.take_pending().expect("r2 visible");
11513 assert_eq!(second.request_id, "r2");
11514 bridge.cancel("r2");
11515 assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
11516
11517 mailbox
11519 .handle(serde_json::json!({
11520 "op": "open", "requestId": "r3",
11521 "ui": {"kind": "input", "question": "X?"}
11522 }))
11523 .unwrap();
11524 bridge.cancel_all();
11525 assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
11526
11527 bridge.shutdown();
11529 assert!(mailbox
11530 .handle(serde_json::json!({
11531 "op": "open", "requestId": "r4", "ui": {"kind": "input"}
11532 }))
11533 .is_err());
11534 }
11535}