1use std::collections::HashMap;
27use std::io::IsTerminal;
28use std::sync::Arc;
29use std::sync::mpsc::{self, channel};
30
31use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
32use tokio::sync::broadcast;
33
34use rpi_agent::{AgentEvent, AgentMessage};
35use rpi_harness::session::types::{Entry, EntryQuery};
36use rpi_ai::types::{AssistantMessage, Content};
37use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
38use rpi_tui::{
39 AutocompleteManager, CombinedAutocompleteProvider, Container, Editor, EditorOptions,
40 EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, Loader, ProcessTerminal,
41 ScrollView, ScrollViewOptions, SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer,
42 StackChild, StackEntry, Text, TuiAltScreen, TUI, VStack, AssistantBlock,
43 AssistantMessageComponent, AssistantMessageOptions, AutocompleteSuggestions,
44 FooterComponent, SelectList, SelectItem, ThemeManager, ThemePreset,
45 ToolExecutionComponent, render_diff,
46 BashExecutionComponent, BashTruncation, UserMessageComponent,
47};
48
49#[allow(unused_imports)]
50use rpi_tui::BashStatus;
51
52use crate::args::Args;
53
54type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
61
62fn build_markdown_transformer(
84 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
85) -> Option<MarkdownTransformer> {
86 let snapshot = snapshot?;
87 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
92 if renderers.is_empty() {
93 return None;
94 }
95 Some(Arc::new(move |raw: &str| -> String {
96 transform_markdown_chain(&snapshot, &renderers, raw)
97 }))
98}
99
100fn transform_markdown_chain(
108 snapshot: &rpi_extensions::RegistrySnapshot,
109 renderers: &[rpi_extensions::RegisteredRenderer],
110 raw: &str,
111) -> String {
112 if !snapshot.is_active() {
117 return raw.to_string();
118 }
119
120 let mut current = raw.to_string();
121 for renderer in renderers {
122 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
123 Ok(s) => s,
124 Err(_) => return current, };
126 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
136 let mut out = rpi_plugin_sdk::StbString::empty();
137 let rc = (renderer.render_fn)(
138 rpi_plugin_sdk::StbStringRef::from_str(&input),
139 &mut out as *mut rpi_plugin_sdk::StbString,
140 renderer.user_data,
141 );
142 let text = if rc == 0 {
143 let s = out.to_string_lossy();
144 Some(s)
145 } else {
146 None
147 };
148 out.free_with(Some(renderer.plugin_free_string));
152 text
153 }));
154 let out_text = match outcome {
155 Ok(Some(s)) => s,
156 Ok(None) => return current, Err(_) => return current, };
162 let next = serde_json::from_str::<serde_json::Value>(&out_text)
165 .ok()
166 .and_then(|v| v.get("markdown").and_then(|m| m.as_str()).map(|s| s.to_string()))
167 .unwrap_or(current);
168 current = next;
169 }
170 current
171}
172
173
174
175#[derive(Clone)]
199struct CommandContext {
200 chat: Arc<Container>,
201 tui: Arc<TuiAltScreen>,
202 tx: mpsc::Sender<TuiMessage>,
203 state: Arc<TuiState>,
204 editor: Arc<Editor>,
205 editor_container: Arc<Container>,
206 lane: Arc<dyn AgentLane>,
207 model_catalog: Arc<Vec<rpi_ai::Model>>,
208 lane_model_id: String,
212 cwd: std::path::PathBuf,
213 resources: Arc<rpi_harness::types::AgentHarnessResources>,
217 reload_context: Arc<crate::session::ReloadContext>,
223}
224
225trait SlashCommand: Send + Sync {
227 fn name(&self) -> &'static str;
229 fn aliases(&self) -> &'static [&'static str] {
233 &[]
234 }
235 fn visible(&self) -> bool {
238 true
239 }
240 fn alias_visible(&self) -> &'static [&'static str] {
244 &[]
245 }
246 fn description(&self) -> &'static str {
249 ""
250 }
251 fn execute(&self, ctx: &CommandContext, args: &str);
257}
258
259struct CommandRegistry {
262 commands: Vec<Arc<dyn SlashCommand>>,
263}
264
265impl CommandRegistry {
266 fn new() -> Self {
267 Self { commands: Vec::new() }
268 }
269
270 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
271 self.commands.push(cmd);
272 }
273
274 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
277 self.commands
278 .iter()
279 .find(|c| c.name() == token || c.aliases().contains(&token))
280 }
281
282 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
288 let mut out: Vec<SlashCommandEntry> = Vec::new();
289 for c in &self.commands {
290 if c.visible() && !c.description().is_empty() {
291 out.push(SlashCommandEntry {
292 name: c.name().into(),
293 description: c.description().into(),
294 });
295 }
296 for alias in c.alias_visible() {
298 out.push(SlashCommandEntry {
299 name: (*alias).into(),
300 description: c.description().into(),
301 });
302 }
303 }
304 out
305 }
306}
307
308fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
312 let mut parts = text.split_whitespace();
313 let token = parts.next().unwrap_or("");
314 let args = parts.collect::<Vec<_>>().join(" ");
315 match registry.find(token) {
316 Some(cmd) => cmd.execute(ctx, &args),
317 None => {
318 add_error_message(
319 &ctx.chat,
320 &format!("Unknown command: {text}. Type /help for available commands."),
321 );
322 ctx.tui.request_render(false);
323 }
324 }
325}
326
327struct UnsupportedCommand {
331 name: &'static str,
332 desc: &'static str,
333}
334
335impl UnsupportedCommand {
336 fn new(name: &'static str, desc: &'static str) -> Self {
337 Self { name, desc }
338 }
339}
340
341impl SlashCommand for UnsupportedCommand {
342 fn name(&self) -> &'static str {
343 self.name
344 }
345 fn description(&self) -> &'static str {
348 self.desc
349 }
350 fn execute(&self, ctx: &CommandContext, _args: &str) {
351 add_note_message(&ctx.chat, &format!("{} is not supported in v1.", self.name));
352 ctx.tui.request_render(false);
353 }
354}
355
356struct HelpCommand;
359impl SlashCommand for HelpCommand {
360 fn name(&self) -> &'static str {
361 "/help"
362 }
363 fn aliases(&self) -> &'static [&'static str] {
364 &["/?"]
365 }
366 fn description(&self) -> &'static str {
367 "Show available commands"
368 }
369 fn execute(&self, ctx: &CommandContext, _args: &str) {
370 add_help_message(&ctx.chat);
371 ctx.tui.request_render(false);
372 }
373}
374
375struct ClearChatCommand;
376impl SlashCommand for ClearChatCommand {
377 fn name(&self) -> &'static str {
378 "/clear"
379 }
380 fn aliases(&self) -> &'static [&'static str] {
381 &["/new"]
382 }
383 fn alias_visible(&self) -> &'static [&'static str] {
385 &["/new"]
386 }
387 fn description(&self) -> &'static str {
388 "Clear the conversation"
389 }
390 fn execute(&self, ctx: &CommandContext, _args: &str) {
391 let _ = ctx.tx.send(TuiMessage::ClearChat);
392 }
393}
394
395struct ExitCommand;
396impl SlashCommand for ExitCommand {
397 fn name(&self) -> &'static str {
398 "/exit"
399 }
400 fn aliases(&self) -> &'static [&'static str] {
401 &["/quit", "/q"]
402 }
403 fn alias_visible(&self) -> &'static [&'static str] {
405 &["/quit"]
406 }
407 fn description(&self) -> &'static str {
408 "Exit the application"
409 }
410 fn execute(&self, ctx: &CommandContext, _args: &str) {
411 let _ = ctx.tx.send(TuiMessage::Exit);
412 }
413}
414
415struct VersionCommand;
416impl SlashCommand for VersionCommand {
417 fn name(&self) -> &'static str {
418 "/version"
419 }
420 fn aliases(&self) -> &'static [&'static str] {
421 &["/v"]
422 }
423 fn description(&self) -> &'static str {
424 "Show version information"
425 }
426 fn execute(&self, ctx: &CommandContext, _args: &str) {
427 add_version_message(&ctx.chat);
428 ctx.tui.request_render(false);
429 }
430}
431
432struct HotkeysCommand;
433impl SlashCommand for HotkeysCommand {
434 fn name(&self) -> &'static str {
435 "/hotkeys"
436 }
437 fn description(&self) -> &'static str {
438 "Show keyboard shortcuts"
439 }
440 fn execute(&self, ctx: &CommandContext, _args: &str) {
441 add_hotkeys_message(&ctx.chat);
442 ctx.tui.request_render(false);
443 }
444}
445
446struct ModelCommand;
447impl SlashCommand for ModelCommand {
448 fn name(&self) -> &'static str {
449 "/model"
450 }
451 fn aliases(&self) -> &'static [&'static str] {
452 &["/m"]
453 }
454 fn description(&self) -> &'static str {
455 "Choose a model (selector)"
456 }
457 fn execute(&self, ctx: &CommandContext, _args: &str) {
458 open_model_selector(
459 &ctx.state,
460 &ctx.editor_container,
461 &ctx.editor,
462 &ctx.tui,
463 &ctx.model_catalog,
464 &ctx.lane,
465 &ctx.lane_model_id,
466 &ctx.chat,
467 );
468 }
469}
470
471struct ThinkingCommand;
472impl SlashCommand for ThinkingCommand {
473 fn name(&self) -> &'static str {
474 "/thinking"
475 }
476 fn aliases(&self) -> &'static [&'static str] {
477 &["/think"]
478 }
479 fn description(&self) -> &'static str {
480 "Set thinking level (selector)"
481 }
482 fn execute(&self, ctx: &CommandContext, _args: &str) {
483 open_thinking_selector(
484 &ctx.state,
485 &ctx.editor_container,
486 &ctx.editor,
487 &ctx.tui,
488 &ctx.lane,
489 &ctx.model_catalog,
490 &ctx.lane_model_id,
491 &ctx.chat,
492 );
493 }
494}
495
496struct ToolsCommand;
497impl SlashCommand for ToolsCommand {
498 fn name(&self) -> &'static str {
499 "/tools"
500 }
501 fn description(&self) -> &'static str {
502 "Toggle tools on/off"
503 }
504 fn execute(&self, ctx: &CommandContext, _args: &str) {
505 open_tools_selector(
506 &ctx.state,
507 &ctx.editor_container,
508 &ctx.editor,
509 &ctx.tui,
510 &ctx.lane,
511 &ctx.chat,
512 );
513 }
514}
515
516struct ImagesCommand;
517impl SlashCommand for ImagesCommand {
518 fn name(&self) -> &'static str {
519 "/images"
520 }
521 fn description(&self) -> &'static str {
522 "Toggle inline images"
523 }
524 fn execute(&self, ctx: &CommandContext, _args: &str) {
525 open_images_selector(
526 &ctx.state,
527 &ctx.editor_container,
528 &ctx.editor,
529 &ctx.tui,
530 &ctx.chat,
531 );
532 }
533}
534
535struct SessionCommand;
536impl SlashCommand for SessionCommand {
537 fn name(&self) -> &'static str {
538 "/session"
539 }
540 fn aliases(&self) -> &'static [&'static str] {
541 &["/resume"]
542 }
543 fn description(&self) -> &'static str {
544 "List saved sessions"
545 }
546 fn execute(&self, ctx: &CommandContext, _args: &str) {
547 open_session_selector(
548 &ctx.state,
549 &ctx.editor_container,
550 &ctx.editor,
551 &ctx.tui,
552 &ctx.cwd,
553 &ctx.tx,
554 );
555 }
556}
557
558struct ThemeCommand;
559impl SlashCommand for ThemeCommand {
560 fn name(&self) -> &'static str {
561 "/theme"
562 }
563 fn description(&self) -> &'static str {
564 "Choose a theme (selector)"
565 }
566 fn execute(&self, ctx: &CommandContext, _args: &str) {
567 open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
568 }
569}
570
571struct CompactCommand;
572impl SlashCommand for CompactCommand {
573 fn name(&self) -> &'static str {
574 "/compact"
575 }
576 fn description(&self) -> &'static str {
577 "Compact the conversation"
578 }
579 fn execute(&self, ctx: &CommandContext, _args: &str) {
580 let _ = ctx.tx.send(TuiMessage::Compact);
581 }
582}
583
584struct CopyCommand;
585impl SlashCommand for CopyCommand {
586 fn name(&self) -> &'static str {
587 "/copy"
588 }
589 fn description(&self) -> &'static str {
590 "Copy last reply to clipboard"
591 }
592 fn execute(&self, ctx: &CommandContext, _args: &str) {
593 let _ = ctx.tx.send(TuiMessage::Copy);
594 }
595}
596
597struct ExportCommand;
598impl SlashCommand for ExportCommand {
599 fn name(&self) -> &'static str {
600 "/export"
601 }
602 fn description(&self) -> &'static str {
603 "Export session to a markdown file"
604 }
605 fn execute(&self, ctx: &CommandContext, _args: &str) {
606 let _ = ctx.tx.send(TuiMessage::ExportSession);
607 }
608}
609
610struct ForkCommand;
611impl SlashCommand for ForkCommand {
612 fn name(&self) -> &'static str {
613 "/fork"
614 }
615 fn description(&self) -> &'static str {
616 "Fork the session into a new one"
617 }
618 fn execute(&self, ctx: &CommandContext, _args: &str) {
619 let _ = ctx.tx.send(TuiMessage::ForkSession);
620 }
621}
622
623struct NameCommand;
624impl SlashCommand for NameCommand {
625 fn name(&self) -> &'static str {
626 "/name"
627 }
628 fn description(&self) -> &'static str {
629 "Set session display name"
630 }
631 fn execute(&self, ctx: &CommandContext, args: &str) {
632 let name = args.trim();
633 if name.is_empty() {
634 add_note_message(
635 &ctx.chat,
636 "Usage: /name <display name> — sets the current session's name.",
637 );
638 ctx.tui.request_render(false);
639 return;
640 }
641 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
642 }
643}
644
645struct ImportCommand;
646impl SlashCommand for ImportCommand {
647 fn name(&self) -> &'static str {
648 "/import"
649 }
650 fn description(&self) -> &'static str {
651 "Import a session file (path)"
652 }
653 fn execute(&self, ctx: &CommandContext, args: &str) {
654 let path = args.trim();
655 if path.is_empty() {
656 add_note_message(
657 &ctx.chat,
658 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
659 );
660 ctx.tui.request_render(false);
661 return;
662 }
663 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
664 }
665}
666
667struct SettingsCommand;
668impl SlashCommand for SettingsCommand {
669 fn name(&self) -> &'static str {
670 "/settings"
671 }
672 fn description(&self) -> &'static str {
673 "Open settings menu"
674 }
675 fn execute(&self, ctx: &CommandContext, _args: &str) {
676 open_settings_selector(
677 &ctx.state,
678 &ctx.editor_container,
679 &ctx.editor,
680 &ctx.tui,
681 &ctx.lane,
682 &ctx.model_catalog,
683 &ctx.lane_model_id,
684 &ctx.chat,
685 );
686 }
687}
688
689struct ScopedModelsCommand;
690impl SlashCommand for ScopedModelsCommand {
691 fn name(&self) -> &'static str {
692 "/scoped-models"
693 }
694 fn description(&self) -> &'static str {
695 "Choose models for Ctrl+M cycling"
696 }
697 fn execute(&self, ctx: &CommandContext, _args: &str) {
698 open_scoped_models_selector(
699 &ctx.state,
700 &ctx.editor_container,
701 &ctx.editor,
702 &ctx.tui,
703 &ctx.model_catalog,
704 &ctx.chat,
705 );
706 }
707}
708
709struct ShareCommand;
710impl SlashCommand for ShareCommand {
711 fn name(&self) -> &'static str {
712 "/share"
713 }
714 fn description(&self) -> &'static str {
715 "Share session (gist via gh, or clipboard)"
716 }
717 fn execute(&self, ctx: &CommandContext, _args: &str) {
718 let _ = ctx.tx.send(TuiMessage::ShareSession);
719 }
720}
721
722struct ArminCommand;
723impl SlashCommand for ArminCommand {
724 fn name(&self) -> &'static str {
725 "/armin"
726 }
727 fn description(&self) -> &'static str {
728 "??? (easter egg)"
729 }
730 fn execute(&self, ctx: &CommandContext, _args: &str) {
731 crate::extras::add_armin(&ctx.chat);
732 ctx.tui.request_render(false);
733 }
734}
735
736struct EarendilCommand;
737impl SlashCommand for EarendilCommand {
738 fn name(&self) -> &'static str {
739 "/earendil"
740 }
741 fn description(&self) -> &'static str {
742 "Announcement"
743 }
744 fn execute(&self, ctx: &CommandContext, _args: &str) {
745 crate::extras::add_earendil(&ctx.chat);
746 ctx.tui.request_render(false);
747 }
748}
749
750struct ContextCommand;
754impl SlashCommand for ContextCommand {
755 fn name(&self) -> &'static str {
756 "/context"
757 }
758 fn visible(&self) -> bool {
759 false
760 }
761 fn execute(&self, ctx: &CommandContext, _args: &str) {
762 show_context_panel(&ctx.chat, &ctx.resources);
763 ctx.tui.request_render(false);
764 }
765}
766
767struct ReloadCommand;
778impl SlashCommand for ReloadCommand {
779 fn name(&self) -> &'static str {
780 "/reload"
781 }
782 fn description(&self) -> &'static str {
783 "Reload extensions, skills, prompts"
784 }
785 fn execute(&self, ctx: &CommandContext, _args: &str) {
786 add_note_message(
790 &ctx.chat,
791 "Reloading extensions + resources…",
792 );
793 ctx.tui.request_render(false);
794 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
795 }
796}
797
798fn build_builtin_registry() -> CommandRegistry {
803 let mut r = CommandRegistry::new();
804 r.register(Arc::new(HelpCommand));
805 r.register(Arc::new(ClearChatCommand));
806 r.register(Arc::new(ExitCommand));
807 r.register(Arc::new(VersionCommand));
808 r.register(Arc::new(ModelCommand));
809 r.register(Arc::new(ThinkingCommand));
810 r.register(Arc::new(ToolsCommand));
811 r.register(Arc::new(ImagesCommand));
812 r.register(Arc::new(SessionCommand));
813 r.register(Arc::new(ThemeCommand));
814 r.register(Arc::new(CompactCommand));
815 r.register(Arc::new(CopyCommand));
816 r.register(Arc::new(HotkeysCommand));
817 r.register(Arc::new(ArminCommand));
818 r.register(Arc::new(EarendilCommand));
819 r.register(Arc::new(ContextCommand));
820 r.register(Arc::new(NameCommand));
824 r.register(Arc::new(SettingsCommand));
825 r.register(Arc::new(ScopedModelsCommand));
826 r.register(Arc::new(ExportCommand));
827 r.register(Arc::new(ImportCommand));
828 r.register(Arc::new(ShareCommand));
829 r.register(Arc::new(ForkCommand));
830 r.register(Arc::new(UnsupportedCommand::new(
831 "/clone",
832 "Duplicate the current session",
833 )));
834 r.register(Arc::new(UnsupportedCommand::new(
835 "/tree",
836 "Navigate session tree",
837 )));
838 r.register(Arc::new(UnsupportedCommand::new(
839 "/trust",
840 "Save project trust decision",
841 )));
842 r.register(Arc::new(UnsupportedCommand::new(
843 "/login",
844 "Configure provider authentication",
845 )));
846 r.register(Arc::new(UnsupportedCommand::new(
847 "/logout",
848 "Remove provider authentication",
849 )));
850 r.register(Arc::new(ReloadCommand));
851 r
852}
853
854enum TuiMessage {
861 UserInput(String),
862 Exit,
863 ClearChat,
865 Compact,
867 Copy,
869 SwitchSession(String),
872 ExportSession,
874 ForkSession,
876 SetSessionName(String),
878 ImportSession(String),
881 ShareSession,
884 ReloadExtensions,
889}
890
891fn assistant_text(msg: &AssistantMessage) -> String {
894 msg.content
895 .iter()
896 .filter_map(|c| match c {
897 Content::Text(t) => Some(t.text.clone()),
898 _ => None,
899 })
900 .collect()
901}
902
903fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
906 match &msg.content {
907 rpi_ai::types::UserContent::Text(s) => s.clone(),
908 rpi_ai::types::UserContent::Blocks(blocks) => blocks
909 .iter()
910 .filter_map(|c| match c {
911 Content::Text(t) => Some(t.text.clone()),
912 _ => None,
913 })
914 .collect(),
915 }
916}
917
918fn show_settings_panel(chat: &Arc<Container>) {
923 let s = crate::settings::load_settings().unwrap_or_default();
924 let mut lines: Vec<String> = Vec::new();
925 lines.push("⚙️ Saved settings:".into());
926 lines.push(format!(
927 " Theme: {} (edit with /theme)",
928 s.theme.as_deref().unwrap_or("(default)")
929 ));
930 lines.push(format!(
931 " Default model: {} (set at launch with --model)",
932 s.default_model.as_deref().unwrap_or("(none)")
933 ));
934 lines.push(format!(
935 " Default thinking: {} (set at launch with --thinking)",
936 s.default_thinking_level.as_deref().unwrap_or("(default)")
937 ));
938 match &s.scoped_models {
939 Some(list) if !list.is_empty() => lines.push(format!(
940 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
941 list.join(", ")
942 )),
943 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
944 }
945 let body = lines.join("\n");
946 container_note_block(chat, &body);
947}
948
949fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
953 let scoped = crate::settings::load_settings()
954 .ok()
955 .and_then(|s| s.scoped_models)
956 .unwrap_or_default();
957 if scoped.is_empty() {
958 return catalog.to_vec();
959 }
960 let mut out: Vec<rpi_ai::Model> = catalog
961 .iter()
962 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
963 .cloned()
964 .collect();
965 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
967 if let Some(cur) = catalog.iter().find(|m| m.id.eq_ignore_ascii_case(current_id)) {
968 out.push(cur.clone());
969 }
970 }
971 out
972}
973
974fn open_settings_selector(
981 state: &Arc<TuiState>,
982 editor_container: &Arc<Container>,
983 editor: &Arc<Editor>,
984 tui: &Arc<TuiAltScreen>,
985 lane: &Arc<dyn AgentLane>,
986 catalog: &[rpi_ai::Model],
987 lane_model_id: &str,
988 chat: &Arc<Container>,
989) {
990 let settings = crate::settings::load_settings().unwrap_or_default();
991 let mut items: Vec<SelectItem> = Vec::new();
992 items.push(
993 SelectItem::new("theme", "Theme")
994 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
995 );
996 items.push(
997 SelectItem::new("model", "Default model")
998 .with_description(&settings.default_model.clone().unwrap_or_else(|| "(none)".into())),
999 );
1000 items.push(
1001 SelectItem::new("thinking", "Default thinking")
1002 .with_description(&settings.default_thinking_level.clone().unwrap_or_else(|| "(default)".into())),
1003 );
1004 let scope_desc = match &settings.scoped_models {
1005 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
1006 _ => "all models".to_string(),
1007 };
1008 items.push(
1009 SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc),
1010 );
1011 let list = Arc::new(SelectList::new(items, 10));
1012
1013 let state_sel = state.clone();
1014 let ec_sel = editor_container.clone();
1015 let editor_sel = editor.clone();
1016 let tui_sel = tui.clone();
1017 let lane_sel = lane.clone();
1018 let chat_sel = chat.clone();
1019 let catalog_sel = catalog.to_vec();
1020 let lane_model_sel = lane_model_id.to_string();
1021 list.on_select(Arc::new(move |item| {
1022 match item.value.as_str() {
1025 "theme" => open_settings_theme_selector(
1026 &state_sel, &ec_sel, &editor_sel, &tui_sel, &chat_sel,
1027 ),
1028 "model" => open_settings_model_selector(
1029 &state_sel,
1030 &ec_sel,
1031 &editor_sel,
1032 &tui_sel,
1033 &lane_sel,
1034 &catalog_sel,
1035 &lane_model_sel,
1036 &chat_sel,
1037 ),
1038 "thinking" => open_settings_thinking_selector(
1039 &state_sel,
1040 &ec_sel,
1041 &editor_sel,
1042 &tui_sel,
1043 &lane_sel,
1044 &catalog_sel,
1045 &lane_model_sel,
1046 &chat_sel,
1047 ),
1048 "scoped-models" => open_scoped_models_selector(
1049 &state_sel, &ec_sel, &editor_sel, &tui_sel, &catalog_sel, &chat_sel,
1050 ),
1051 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
1052 }
1053 }));
1054 let state_cancel = state.clone();
1055 let ec_cancel = editor_container.clone();
1056 let editor_cancel = editor.clone();
1057 let tui_cancel = tui.clone();
1058 list.on_cancel(Arc::new(move || {
1059 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1060 }));
1061
1062 open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1063}
1064
1065fn open_settings_theme_selector(
1067 state: &Arc<TuiState>,
1068 editor_container: &Arc<Container>,
1069 editor: &Arc<Editor>,
1070 tui: &Arc<TuiAltScreen>,
1071 chat: &Arc<Container>,
1072) {
1073 let items = vec![
1074 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
1075 SelectItem::new("light", "Light").with_description("Light background"),
1076 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
1077 ];
1078 let list = Arc::new(SelectList::new(items, 10));
1079
1080 let state_sel = state.clone();
1081 let ec_sel = editor_container.clone();
1082 let editor_sel = editor.clone();
1083 let tui_sel = tui.clone();
1084 let chat_sel = chat.clone();
1085 list.on_select(Arc::new(move |item| {
1086 let preset = match item.value.as_str() {
1087 "light" => ThemePreset::Light,
1088 "monochrome" => ThemePreset::Monochrome,
1089 _ => ThemePreset::Dark,
1090 };
1091 state_sel.theme_manager.apply_preset(preset);
1092 let mut settings = crate::settings::load_settings().unwrap_or_default();
1093 settings.theme = Some(item.value.clone());
1094 let saved = crate::settings::save_settings(&settings);
1095 add_note_message(
1096 &chat_sel,
1097 &format!(
1098 "Theme set to {} (saved{})",
1099 item.label,
1100 if saved.is_ok() { "" } else { ", not saved" },
1101 ),
1102 );
1103 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1104 tui_sel.render_now(true);
1105 }));
1106 let state_cancel = state.clone();
1107 let ec_cancel = editor_container.clone();
1108 let editor_cancel = editor.clone();
1109 let tui_cancel = tui.clone();
1110 list.on_cancel(Arc::new(move || {
1111 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1112 }));
1113
1114 open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1115}
1116
1117fn open_settings_model_selector(
1121 state: &Arc<TuiState>,
1122 editor_container: &Arc<Container>,
1123 editor: &Arc<Editor>,
1124 tui: &Arc<TuiAltScreen>,
1125 lane: &Arc<dyn AgentLane>,
1126 catalog: &[rpi_ai::Model],
1127 lane_model_id: &str,
1128 chat: &Arc<Container>,
1129) {
1130 let mut items: Vec<SelectItem> = Vec::new();
1131 for m in catalog {
1132 let label = if m.name.is_empty() { short_model_name(&m.id) } else { m.name.clone() };
1133 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) { " (current)" } else { "" };
1134 items.push(SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)));
1135 }
1136 if items.is_empty() {
1137 add_note_message(chat, "No models in the catalog.");
1138 tui.request_render(false);
1139 return;
1140 }
1141 let list = Arc::new(SelectList::new(items, 10));
1142
1143 let catalog_arc = catalog.to_vec();
1144 let state_sel = state.clone();
1145 let ec_sel = editor_container.clone();
1146 let editor_sel = editor.clone();
1147 let tui_sel = tui.clone();
1148 let chat_sel = chat.clone();
1149 let lane_sel = lane.clone();
1150 list.on_select(Arc::new(move |item| {
1151 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
1152 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
1153 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1154 return;
1155 };
1156 state_sel.set_current_model(&model);
1157 let lane = lane_sel.clone();
1158 tokio::spawn(async move {
1159 let _ = lane.set_model(model).await;
1160 });
1161 let mut settings = crate::settings::load_settings().unwrap_or_default();
1162 settings.default_model = Some(item.value.clone());
1163 let saved = crate::settings::save_settings(&settings);
1164 add_note_message(
1165 &chat_sel,
1166 &format!(
1167 "Default model set to {} (saved{}",
1168 short_model_name(&item.value),
1169 if saved.is_ok() { ")" } else { ", not saved)" },
1170 ),
1171 );
1172 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1173 }));
1174 let state_cancel = state.clone();
1175 let ec_cancel = editor_container.clone();
1176 let editor_cancel = editor.clone();
1177 let tui_cancel = tui.clone();
1178 list.on_cancel(Arc::new(move || {
1179 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1180 }));
1181
1182 open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1183}
1184
1185fn open_settings_thinking_selector(
1189 state: &Arc<TuiState>,
1190 editor_container: &Arc<Container>,
1191 editor: &Arc<Editor>,
1192 tui: &Arc<TuiAltScreen>,
1193 lane: &Arc<dyn AgentLane>,
1194 catalog: &[rpi_ai::Model],
1195 lane_model_id: &str,
1196 chat: &Arc<Container>,
1197) {
1198 let model = catalog.iter().find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
1199 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
1200 .map(|m| m.supported_thinking_levels())
1201 .unwrap_or_else(|| {
1202 use rpi_ai::types::ThinkingLevel::*;
1203 vec![Off, Minimal, Low, Medium, High]
1204 });
1205 let mut items: Vec<SelectItem> = Vec::new();
1206 for lvl in &levels {
1207 let name = thinking_level_name(*lvl);
1208 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
1209 }
1210 if items.is_empty() {
1211 add_note_message(chat, "This model has no supported thinking levels.");
1212 tui.request_render(false);
1213 return;
1214 }
1215 let list = Arc::new(SelectList::new(items, 10));
1216
1217 let state_sel = state.clone();
1218 let ec_sel = editor_container.clone();
1219 let editor_sel = editor.clone();
1220 let tui_sel = tui.clone();
1221 let chat_sel = chat.clone();
1222 let lane_sel = lane.clone();
1223 list.on_select(Arc::new(move |item| {
1224 let Some(level) = thinking_level_from_name(&item.value) else {
1225 add_note_message(&chat_sel, &format!("Unknown thinking level: {}.", item.label));
1226 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1227 return;
1228 };
1229 let lane = lane_sel.clone();
1230 let footer_sel = state_sel.footer.clone();
1231 tokio::spawn(async move {
1232 let _ = lane.set_thinking_level(level).await;
1233 });
1234 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
1235 let mut settings = crate::settings::load_settings().unwrap_or_default();
1236 settings.default_thinking_level = Some(item.value.clone());
1237 let saved = crate::settings::save_settings(&settings);
1238 add_note_message(
1239 &chat_sel,
1240 &format!(
1241 "Default thinking set to {} (saved{}",
1242 item.label,
1243 if saved.is_ok() { ")" } else { ", not saved)" },
1244 ),
1245 );
1246 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1247 }));
1248 let state_cancel = state.clone();
1249 let ec_cancel = editor_container.clone();
1250 let editor_cancel = editor.clone();
1251 let tui_cancel = tui.clone();
1252 list.on_cancel(Arc::new(move || {
1253 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1254 }));
1255
1256 open_selector(state, editor_container, editor, tui, list, SelectorKind::Settings);
1257}
1258
1259fn open_scoped_models_selector(
1264 state: &Arc<TuiState>,
1265 editor_container: &Arc<Container>,
1266 editor: &Arc<Editor>,
1267 tui: &Arc<TuiAltScreen>,
1268 catalog: &[rpi_ai::Model],
1269 chat: &Arc<Container>,
1270) {
1271 if catalog.is_empty() {
1272 add_note_message(chat, "No models in the catalog.");
1273 tui.request_render(false);
1274 return;
1275 }
1276 let seed: Vec<String> = crate::settings::load_settings()
1278 .ok()
1279 .and_then(|s| s.scoped_models)
1280 .unwrap_or_default();
1281 *state.scoped_edit.lock().unwrap() = Some(seed);
1282
1283 let mut items: Vec<SelectItem> = Vec::new();
1284 for m in catalog {
1285 items.push(SelectItem::new(&m.id, &m.id));
1286 }
1287 let list = Arc::new(SelectList::new(items, 10));
1288
1289 let state_sel = state.clone();
1290 let chat_sel = chat.clone();
1291 let tui_sel = tui.clone();
1292 list.on_select(Arc::new(move |item| {
1293 let mut set = state_sel.scoped_edit.lock().unwrap();
1295 let set = set.get_or_insert_with(Vec::new);
1296 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
1297 set.remove(pos);
1298 add_note_message(
1299 &chat_sel,
1300 &format!("{} removed — Esc to save", item.label),
1301 );
1302 } else {
1303 set.push(item.value.clone());
1304 add_note_message(
1305 &chat_sel,
1306 &format!("{} added — Esc to save", item.label),
1307 );
1308 }
1309 tui_sel.request_render(false);
1310 }));
1311 let state_cancel = state.clone();
1312 let ec_cancel = editor_container.clone();
1313 let editor_cancel = editor.clone();
1314 let tui_cancel = tui.clone();
1315 let chat_cancel = chat.clone();
1316 list.on_cancel(Arc::new(move || {
1317 let set = state_cancel.scoped_edit.lock().unwrap().take().unwrap_or_default();
1319 let mut settings = crate::settings::load_settings().unwrap_or_default();
1320 settings.scoped_models = if set.is_empty() { None } else { Some(set.clone()) };
1321 match crate::settings::save_settings(&settings) {
1322 Ok(()) => {
1323 if set.is_empty() {
1324 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
1325 } else {
1326 add_note_message(
1327 &chat_cancel,
1328 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
1329 );
1330 }
1331 }
1332 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
1333 }
1334 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1335 }));
1336
1337 open_selector(state, editor_container, editor, tui, list, SelectorKind::ScopedModels);
1338}
1339
1340async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
1344 use std::process::Stdio;
1345
1346 let tree = harness.session().view("main");
1348 let entries = match tree.find_entries(&EntryQuery {
1349 entry_type: None,
1350 custom_type: None,
1351 order: None,
1352 limit: None,
1353 cursor: None,
1354 }).await {
1355 Ok(e) => e,
1356 Err(e) => {
1357 add_error_message(chat, &format!("Could not read session: {e}"));
1358 return;
1359 }
1360 };
1361 let mut md = String::from("# Session\n\n");
1362 for e in entries {
1363 let Entry::Message(me) = e else { continue };
1364 match &me.message {
1365 AgentMessage::User(u) => {
1366 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1367 }
1368 AgentMessage::Assistant(a) => {
1369 let text = assistant_text(a);
1370 if !text.is_empty() {
1371 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1372 }
1373 }
1374 _ => {}
1375 }
1376 }
1377
1378 let gh = std::process::Command::new("gh")
1380 .arg("gist")
1381 .arg("create")
1382 .arg("--filename")
1383 .arg("session.md")
1384 .arg("-")
1385 .stdin(Stdio::piped())
1386 .stdout(Stdio::piped())
1387 .stderr(Stdio::null())
1388 .spawn();
1389 if let Ok(mut child) = gh {
1390 use std::io::Write;
1391 if let Some(mut stdin) = child.stdin.take() {
1392 let _ = stdin.write_all(md.as_bytes());
1393 let _ = stdin.flush();
1394 }
1395 let out = child.wait_with_output().ok();
1396 if let Some(out) = out {
1397 if out.status.success() {
1398 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
1399 add_note_message(chat, &format!("Shared session: {url}"));
1400 return;
1401 }
1402 }
1403 add_note_message(
1404 chat,
1405 "gh gist failed — falling back to the clipboard.",
1406 );
1407 } else {
1408 add_note_message(
1409 chat,
1410 "gh CLI not found — falling back to the clipboard.",
1411 );
1412 }
1413 if copy_to_clipboard(&md) {
1415 add_note_message(chat, "Session transcript copied to the clipboard.");
1416 } else {
1417 add_note_message(
1418 chat,
1419 "Clipboard unavailable — use /export to write the transcript to a file.",
1420 );
1421 }
1422}
1423
1424async fn export_session(harness: &AgentHarness, chat: &Arc<Container>) {
1433 let tree = harness.session().view("main");
1434 let entries = match tree.find_entries(&EntryQuery {
1435 entry_type: None,
1436 custom_type: None,
1437 order: None,
1438 limit: None,
1439 cursor: None,
1440 }).await {
1441 Ok(e) => e,
1442 Err(e) => {
1443 add_error_message(chat, &format!("Could not read session: {e}"));
1444 return;
1445 }
1446 };
1447 let name = tree.get_name().await.ok().flatten().unwrap_or_default();
1448 let id = tree
1449 .get_leaf_id()
1450 .await
1451 .ok()
1452 .flatten()
1453 .unwrap_or_else(|| "session".to_string());
1454 let mut md = String::from("# Session\n\n");
1455 for e in entries {
1456 let Entry::Message(me) = e else { continue };
1457 match &me.message {
1458 AgentMessage::User(u) => {
1459 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1460 }
1461 AgentMessage::Assistant(a) => {
1462 let text = assistant_text(a);
1463 if !text.is_empty() {
1464 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1465 }
1466 }
1467 _ => {}
1468 }
1469 }
1470 let file_name = if name.is_empty() {
1471 format!("{id}.md")
1472 } else {
1473 format!("{name}.md")
1474 };
1475 let path = std::env::current_dir()
1476 .unwrap_or_else(|_| std::path::PathBuf::from("."))
1477 .join(&file_name);
1478 match std::fs::write(&path, md) {
1479 Ok(_) => add_note_message(
1480 chat,
1481 &format!("Exported session to {}", path.display()),
1482 ),
1483 Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
1484 }
1485}
1486
1487async fn switch_to_session(
1497 harness: &AgentHarness,
1498 lane: &Arc<dyn AgentLane>,
1499 id: &str,
1500 cwd: &std::path::Path,
1501 chat: &Arc<Container>,
1502 state: &Arc<TuiState>,
1503) -> bool {
1504 if *state.status.lock().unwrap() == RunStatus::Working {
1505 state.set_status(RunStatus::Aborting);
1506 let _ = lane.abort().await;
1507 }
1508 let cwd_str = cwd.to_string_lossy().to_string();
1509 match crate::session::open_session_by_id(id, &cwd_str).await {
1510 Ok(new_session) => {
1511 let _ = harness.set_session(new_session).await;
1512 chat.clear();
1513 add_welcome_message(chat);
1514 render_session_history(harness, chat, state.markdown_transformer()).await;
1515 state.set_status(RunStatus::Idle);
1516 add_note_message(chat, &format!("Switched to session {id}."));
1517 true
1518 }
1519 Err(e) => {
1520 state.set_status(RunStatus::Idle);
1521 add_error_message(chat, &format!("Could not open session {id}: {e}"));
1522 false
1523 }
1524 }
1525}
1526
1527async fn import_session(
1531 harness: &AgentHarness,
1532 lane: &Arc<dyn AgentLane>,
1533 path: &str,
1534 cwd: &std::path::Path,
1535 chat: &Arc<Container>,
1536 state: &Arc<TuiState>,
1537) {
1538 use std::path::Path as FsPath;
1539
1540 let src = FsPath::new(path);
1541 if !src.is_file() {
1542 add_error_message(chat, &format!("Import source not found: {path}"));
1543 return;
1544 }
1545 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
1546 add_error_message(chat, "Import source has no file name.");
1547 return;
1548 };
1549 if !fname.ends_with(".jsonl") {
1550 add_error_message(chat, "Import source must be a .jsonl session file.");
1551 return;
1552 }
1553 let dir = crate::session::default_session_dir(cwd);
1554 if let Err(e) = std::fs::create_dir_all(&dir) {
1555 add_error_message(chat, &format!("Could not create session dir: {e}"));
1556 return;
1557 }
1558 let dest = dir.join(fname);
1559 match std::fs::copy(src, &dest) {
1560 Ok(_) => {
1561 let id = fname
1562 .strip_suffix(".jsonl")
1563 .unwrap_or(fname)
1564 .to_string();
1565 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
1566 add_note_message(chat, &format!("Imported session from {path}"));
1567 }
1568 }
1569 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
1570 }
1571}
1572
1573async fn fork_session(
1574 harness: &AgentHarness,
1575 cwd: &std::path::Path,
1576 chat: &Arc<Container>,
1577 state: &Arc<TuiState>,
1578) {
1579 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1580 use rpi_tools::FileSystem;
1581
1582 let cwd_str = cwd.to_string_lossy().to_string();
1583 let dir = crate::session::default_session_dir(cwd);
1584 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
1585 let fs: Arc<dyn FileSystem> = env.clone();
1586 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1587 fs,
1588 sessions_root: dir.to_string_lossy().into_owned(),
1589 clock: Arc::new(rpi_harness::session::memory::SystemClock),
1590 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
1591 });
1592 let id = harness.session().storage().metadata().id.clone();
1595 let metas = match crate::session::list_session_metadata(&cwd_str).await {
1596 Ok(m) => m,
1597 Err(e) => {
1598 add_error_message(chat, &format!("Could not list sessions: {e}"));
1599 return;
1600 }
1601 };
1602 let Some(source) = metas.iter().find(|m| m.id == id) else {
1603 add_error_message(chat, &format!("Current session {id} not found on disk."));
1604 return;
1605 };
1606 let fork_storage = match repo
1607 .fork_typed(
1608 source,
1609 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
1610 id: None,
1611 parent_session_id: Some(source.id.clone()),
1612 cwd: cwd_str.clone(),
1613 metadata: None,
1614 },
1615 &rpi_harness::session::types::ForkOptions::default(),
1616 )
1617 .await
1618 {
1619 Ok(s) => s,
1620 Err(e) => {
1621 add_error_message(chat, &format!("Could not fork session: {e}"));
1622 return;
1623 }
1624 };
1625 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
1626 let _ = harness.set_session(new_session).await;
1627 chat.clear();
1628 add_welcome_message(chat);
1629 render_session_history(harness, chat, state.markdown_transformer()).await;
1630 state.set_status(RunStatus::Idle);
1631 add_note_message(chat, "Forked into a new session.");
1632}
1633
1634async fn render_session_history(
1644 harness: &AgentHarness,
1645 chat: &Arc<Container>,
1646 transformer: Option<MarkdownTransformer>,
1647) {
1648 let tree = harness.session().view("main");
1649 let entries = match tree.find_entries(&EntryQuery {
1650 entry_type: None,
1651 custom_type: None,
1652 order: None,
1653 limit: None,
1654 cursor: None,
1655 }).await {
1656 Ok(e) => e,
1657 Err(_) => return,
1658 };
1659 let mut rendered_any = false;
1660 for e in entries {
1661 let Entry::Message(me) = e else { continue };
1662 match &me.message {
1663 AgentMessage::User(u) => {
1664 add_user_message(chat, &user_message_text(u));
1665 rendered_any = true;
1666 }
1667 AgentMessage::Assistant(a) => {
1668 let comp = Arc::new(AssistantMessageComponent::new(
1669 AssistantMessageOptions::default(),
1670 ));
1671 if let Some(t) = &transformer {
1672 comp.set_markdown_transformer(Some(t.clone()));
1673 }
1674 comp.update_blocks(&assistant_blocks(a));
1675 chat.add_child(comp);
1676 chat.add_child(Arc::new(Spacer::new(1)));
1677 rendered_any = true;
1678 }
1679 _ => {}
1680 }
1681 }
1682 if rendered_any {
1683 chat.add_child(Arc::new(Spacer::new(1)));
1684 }
1685}
1686
1687fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
1694 msg.content
1695 .iter()
1696 .filter_map(|c| match c {
1697 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
1698 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
1699 _ => None,
1700 })
1701 .collect()
1702}
1703
1704fn short_model_name(id: &str) -> String {
1707 id.rsplit([':', '/'])
1708 .next()
1709 .filter(|s| !s.is_empty())
1710 .unwrap_or(id)
1711 .to_string()
1712}
1713
1714#[derive(Clone, Copy, PartialEq, Eq)]
1720enum RunStatus {
1721 Idle,
1722 Working,
1723 Aborting,
1724}
1725
1726#[derive(Clone, Copy, PartialEq, Eq)]
1728enum SelectorKind {
1729 Model,
1731 Thinking,
1733 Tools,
1735 Images,
1737 Session,
1739 Theme,
1741 ScopedModels,
1743 Settings,
1745}
1746
1747struct TuiState {
1750 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
1752 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
1754 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
1758 last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
1762 status: std::sync::Mutex<RunStatus>,
1764 footer: Arc<FooterComponent>,
1766 status_container: Arc<Container>,
1769 chat_container: Arc<Container>,
1771 loader: Arc<Loader>,
1773 last_assistant_text: std::sync::Mutex<String>,
1776 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
1780 autocomplete: AutocompleteManager,
1783 autocomplete_container: Arc<Container>,
1786 theme_manager: Arc<ThemeManager>,
1790 tui: Option<Arc<TuiAltScreen>>,
1794 current_model_id: std::sync::Mutex<String>,
1798 show_images: std::sync::Mutex<bool>,
1802 history: std::sync::Mutex<Vec<String>>,
1805 history_index: std::sync::Mutex<isize>,
1808 history_draft: std::sync::Mutex<Option<String>>,
1811 last_input_tokens: std::sync::Mutex<i64>,
1815 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
1818 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
1828}
1829
1830const HISTORY_LIMIT: usize = 100;
1833
1834const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
1837
1838fn format_tokens(n: i64) -> String {
1840 if n >= 1_000_000 {
1841 format!("{:.1}M", n as f64 / 1_000_000.0)
1842 } else if n >= 1_000 {
1843 format!("{:.1}K", n as f64 / 1_000.0)
1844 } else {
1845 n.to_string()
1846 }
1847}
1848
1849fn push_history(state: &Arc<TuiState>, text: &str) {
1853 let trimmed = text.trim().to_string();
1854 if trimmed.is_empty() {
1855 return;
1856 }
1857 let mut history = state.history.lock().unwrap();
1858 if history.first() == Some(&trimmed) {
1859 return;
1860 }
1861 history.insert(0, trimmed);
1862 history.truncate(HISTORY_LIMIT);
1863 *state.history_index.lock().unwrap() = -1;
1864 *state.history_draft.lock().unwrap() = None;
1865}
1866
1867fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
1872 let history = state.history.lock().unwrap();
1873 if history.is_empty() {
1874 return;
1875 }
1876 let mut index = state.history_index.lock().unwrap();
1877 let new_index = *index - direction as isize;
1878 if new_index < -1 || new_index >= history.len() as isize {
1879 return;
1880 }
1881 if *index == -1 && new_index >= 0 {
1882 *state.history_draft.lock().unwrap() = Some(editor.get_text());
1884 }
1885 *index = new_index;
1886 if new_index == -1 {
1887 let draft = state.history_draft.lock().unwrap().take();
1889 match draft {
1890 Some(d) => {
1891 let len = d.len();
1892 editor.set_text(&d);
1893 editor.set_cursor(0, len);
1894 }
1895 None => editor.set_text(""),
1896 }
1897 } else {
1898 let text = history[new_index as usize].clone();
1899 let len = text.len();
1900 editor.set_text(&text);
1901 editor.set_cursor(0, len);
1902 }
1903}
1904
1905impl TuiState {
1906 fn set_status(&self, status: RunStatus) {
1907 *self.status.lock().unwrap() = status;
1908 match status {
1909 RunStatus::Working => {
1910 self.footer.set_status("Working…");
1911 if let Some(tui) = &self.tui {
1914 tui.set_title("rpi — working");
1915 }
1916 self.status_container.clear();
1917 self.loader.start();
1918 self.status_container.add_child(self.loader.clone());
1919 }
1920 RunStatus::Aborting => {
1921 self.footer.set_status("Aborting…");
1922 }
1923 RunStatus::Idle => {
1924 self.footer.set_status("");
1925 if let Some(tui) = &self.tui {
1926 tui.set_title("rpi");
1927 }
1928 self.loader.stop();
1929 self.status_container.clear();
1930 }
1931 }
1932 }
1933
1934 fn selector_open(&self) -> bool {
1936 self.active_selector.lock().unwrap().is_some()
1937 }
1938
1939 fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
1942 *self.last_tool_comp.lock().unwrap() = Some(comp);
1943 }
1944
1945 fn toggle_expand_last_tool(&self) -> bool {
1951 if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
1952 let cur = comp.is_expanded();
1953 comp.set_expanded(!cur);
1954 true
1955 } else {
1956 false
1957 }
1958 }
1959
1960 fn current_model_id(&self) -> String {
1962 self.current_model_id.lock().unwrap().clone()
1963 }
1964
1965 fn set_current_model(&self, model: &rpi_ai::Model) {
1968 *self.current_model_id.lock().unwrap() = model.id.clone();
1969 self.footer.set_model(&short_model_name(&model.id));
1970 }
1971
1972 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
1976 self.markdown_transformer.lock().unwrap().clone()
1977 }
1978
1979 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
1988 *self.markdown_transformer.lock().unwrap() = transformer.clone();
1989 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
1990 comp.set_markdown_transformer(transformer);
1991 }
1992 }
1993}
1994
1995pub async fn interactive_tui(
2013 harness: &AgentHarness,
2014 event_rx: Option<broadcast::Receiver<AgentEvent>>,
2015 args: &Args,
2016 model_catalog: Vec<rpi_ai::Model>,
2017 initial: Option<String>,
2018 extra_messages: &[String],
2019 theme: Option<&str>,
2020 reload_context: &crate::session::ReloadContext,
2021) -> i32 {
2022 let lane: Arc<dyn AgentLane> = harness.lane("main");
2023
2024 let lane_model_id = lane
2028 .get_model()
2029 .await
2030 .map(|m| m.id)
2031 .unwrap_or_default();
2032 let model_name = short_model_name(&lane_model_id);
2033
2034 let cwd = std::env::current_dir()
2036 .map(|p| p.to_path_buf())
2037 .unwrap_or_else(|_| std::path::PathBuf::from("."));
2038
2039 let (tx, rx) = channel::<TuiMessage>();
2041
2042 let terminal = Box::new(ProcessTerminal::new());
2044 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
2045
2046 let chat_container = Arc::new(Container::new());
2047 add_welcome_message(&chat_container);
2048
2049 crate::extras::maybe_first_time_setup(&chat_container);
2055
2056 let initial_transformer = build_markdown_transformer(
2061 reload_context.extension_session.lock().unwrap().snapshot_arc(),
2062 );
2063 render_session_history(&harness, &chat_container, initial_transformer.clone()).await;
2064
2065 let document_container = Arc::new(Container::new());
2068 document_container.add_child(chat_container.clone());
2069
2070 let scroll_view = Arc::new(ScrollView::new(
2071 document_container.clone(),
2072 ScrollViewOptions {
2073 follow: FollowMode::End,
2074 primary: true,
2075 ..Default::default()
2076 },
2077 ));
2078
2079 let editor = Arc::new(Editor::new(
2084 EditorOptions {
2085 padding_x: 1,
2086 ..Default::default()
2087 },
2088 EditorStyle::default(),
2089 Arc::new(rpi_tui::Keybindings::new()),
2090 ));
2091
2092 let footer = Arc::new(FooterComponent::new());
2094 footer.set_model(&model_name);
2095 footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Abort/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help");
2096
2097 let status_container = Arc::new(Container::new());
2098 let loader = Arc::new(Loader::with_text("Working…"));
2099
2100 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
2114 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
2115 .prompt_templates
2116 .clone()
2117 .unwrap_or_default()
2118 .iter()
2119 .map(|t| SlashCommandEntry {
2120 name: format!("/{}", t.name),
2121 description: t
2122 .description
2123 .clone()
2124 .unwrap_or_else(|| "Expand prompt template".to_string()),
2125 })
2126 .collect();
2127 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> = Arc::new(resources_snapshot);
2128 let registry = Arc::new(build_builtin_registry());
2134 let mut all_slash_commands = registry.visible_entries();
2135 all_slash_commands.extend(template_slash_commands);
2136 let autocomplete = AutocompleteManager::new();
2137 {
2138 let mut combined = CombinedAutocompleteProvider::new();
2139 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2140 all_slash_commands,
2141 )));
2142 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(cwd.clone())));
2143 autocomplete.set_provider(Arc::new(combined));
2144 }
2145 let autocomplete_container = Arc::new(Container::new());
2146
2147 let state = Arc::new(TuiState {
2148 current_assistant: std::sync::Mutex::new(None),
2149 tool_components: std::sync::Mutex::new(HashMap::new()),
2150 bash_components: std::sync::Mutex::new(HashMap::new()),
2151 last_tool_comp: std::sync::Mutex::new(None),
2152 status: std::sync::Mutex::new(RunStatus::Idle),
2153 footer: footer.clone(),
2154 status_container: status_container.clone(),
2155 chat_container: chat_container.clone(),
2156 loader: loader.clone(),
2157 last_assistant_text: std::sync::Mutex::new(String::new()),
2158 active_selector: std::sync::Mutex::new(None),
2159 autocomplete,
2160 autocomplete_container: autocomplete_container.clone(),
2161 theme_manager: Arc::new(ThemeManager::new()),
2162 tui: Some(tui.clone()),
2163 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
2164 show_images: std::sync::Mutex::new(true),
2165 history: std::sync::Mutex::new(Vec::new()),
2166 history_index: std::sync::Mutex::new(-1),
2167 history_draft: std::sync::Mutex::new(None),
2168 last_input_tokens: std::sync::Mutex::new(0),
2169 scoped_edit: std::sync::Mutex::new(None),
2170 markdown_transformer: std::sync::Mutex::new(initial_transformer),
2171 });
2172
2173 if let Some(theme_name) = theme {
2178 let preset = match theme_name {
2179 "light" => Some(ThemePreset::Light),
2180 "monochrome" => Some(ThemePreset::Monochrome),
2181 "dark" => Some(ThemePreset::Dark),
2182 _ => None,
2183 };
2184 if let Some(preset) = preset {
2185 state.theme_manager.apply_preset(preset);
2186 }
2187 }
2188
2189 let model_catalog_arc = Arc::new(model_catalog.clone());
2192 let lane_model_id = lane
2193 .get_model()
2194 .await
2195 .map(|m| m.id)
2196 .unwrap_or_default();
2197
2198 let editor_container = Arc::new(Container::new());
2209 editor_container.add_child(editor.clone());
2210
2211 let dock = Arc::new(VStack::from_children(vec![
2212 StackChild::Entry(StackEntry::new(status_container.clone())),
2213 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
2214 StackChild::Entry(
2215 StackEntry::new(editor_container.clone())
2216 .shrink(0)
2217 .min_size(3),
2218 ),
2219 StackChild::Entry(StackEntry::new(footer.clone())),
2220 ]));
2221
2222 let root = VStack::from_children(vec![
2223 StackChild::Entry(
2224 StackEntry::new(scroll_view.clone())
2225 .basis(0)
2226 .grow(1)
2227 .shrink(1)
2228 .min_size(1),
2229 ),
2230 StackChild::Entry(StackEntry::new(dock).shrink(1)),
2231 ]);
2232
2233 tui.set_layout_root(Some(Arc::new(root)));
2234 tui.set_focus(Some(editor.clone()));
2235 editor.set_focused(true);
2236
2237 let ctx = CommandContext {
2249 chat: chat_container.clone(),
2250 tui: tui.clone(),
2251 tx: tx.clone(),
2252 state: state.clone(),
2253 editor: editor.clone(),
2254 editor_container: editor_container.clone(),
2255 lane: lane.clone(),
2256 model_catalog: model_catalog_arc.clone(),
2257 lane_model_id: lane_model_id.clone(),
2258 cwd: cwd.clone(),
2259 resources: resources_arc.clone(),
2260 reload_context: Arc::new(reload_context.clone()),
2261 };
2262 let ctx_for_cb = ctx.clone();
2263 let registry_for_cb = registry.clone();
2264 editor.on_submit(Arc::new(move |text: &str| {
2265 let text = text.trim();
2266 if text.is_empty() {
2267 return;
2268 }
2269
2270 if text.starts_with('/') {
2271 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
2272 return;
2273 }
2274
2275 add_user_message(&ctx_for_cb.chat, text);
2276 ctx_for_cb.tui.request_render(false);
2277 push_history(&ctx_for_cb.state, text);
2280 let _ = ctx_for_cb.tx.send(TuiMessage::UserInput(text.to_string()));
2281 }));
2282
2283 tui.start_readerless();
2284
2285 let drain_handle = if let Some(rx) = event_rx {
2287 let tui_drain = tui.clone();
2288 let state_drain = state.clone();
2289 let chat_drain = chat_container.clone();
2290 Some(tokio::spawn(async move {
2291 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
2292 }))
2293 } else {
2294 None
2295 };
2296
2297 let (reload_sig_tx, mut reload_sig_rx) =
2307 tokio::sync::mpsc::unbounded_channel::<()>();
2308 reload_context.mailbox.install(reload_sig_tx);
2309 let reload_tx = tx.clone();
2310 let reload_bridge_handle = tokio::spawn(async move {
2311 while reload_sig_rx.recv().await.is_some() {
2312 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
2313 break; }
2315 }
2316 });
2317
2318 let tui_tick = tui.clone();
2323 let state_tick = state.clone();
2324 let tick_handle = tokio::spawn(async move {
2325 let mut interval = tokio::time::interval(std::time::Duration::from_millis(120));
2326 interval.tick().await; loop {
2328 interval.tick().await;
2329 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
2330 if working {
2331 tui_tick.request_render(false);
2332 }
2333 }
2334 });
2335
2336 let running = Arc::new(std::sync::Mutex::new(true));
2338 let running_key = running.clone();
2339 let tx_for_key = tx.clone();
2340 let tui_for_key = tui.clone();
2341 let editor_for_key = editor.clone();
2342 let editor_container_for_key = editor_container.clone();
2343 let scroll_for_key = scroll_view.clone();
2344 let lane_for_key = lane.clone();
2345 let state_for_key = state.clone();
2346 let ctx_for_key = ctx.clone();
2350 let registry_for_key = registry.clone();
2351
2352 tokio::task::spawn_blocking(move || {
2353 loop {
2354 if !*running_key.lock().unwrap() {
2355 break;
2356 }
2357 let Ok(ev) = crossterm::event::read() else {
2358 continue;
2359 };
2360 if let Event::Resize(_cols, _rows) = ev {
2365 tui_for_key.refresh_size();
2366 continue;
2367 }
2368 let Event::Key(key) = ev else { continue; };
2369 if key.kind != KeyEventKind::Press {
2375 continue;
2376 }
2377
2378 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
2383 let status = *state_for_key.status.lock().unwrap();
2384 if status == RunStatus::Working {
2385 state_for_key.set_status(RunStatus::Aborting);
2386 let lane = lane_for_key.clone();
2387 tokio::spawn(async move {
2388 let _ = lane.abort().await;
2389 });
2390 } else {
2391 let _ = tx_for_key.send(TuiMessage::Exit);
2392 }
2393 continue;
2394 }
2395
2396 if state_for_key.selector_open() {
2401 if key.code == KeyCode::Esc {
2407 let (selector, _kind) = state_for_key
2408 .active_selector
2409 .lock()
2410 .unwrap()
2411 .clone()
2412 .expect("selector_open guaranteed Some");
2413 selector.handle_key(key);
2414 continue;
2415 }
2416 let (selector, _kind) = state_for_key
2417 .active_selector
2418 .lock()
2419 .unwrap()
2420 .clone()
2421 .expect("selector_open guaranteed Some");
2422 selector.handle_key(key);
2423 tui_for_key.request_render(false);
2424 continue;
2425 }
2426
2427 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
2431 let status = *state_for_key.status.lock().unwrap();
2432 if status == RunStatus::Working {
2433 state_for_key.set_status(RunStatus::Aborting);
2434 let lane = lane_for_key.clone();
2435 tokio::spawn(async move {
2436 let _ = lane.abort().await;
2437 });
2438 } else {
2439 let _ = tx_for_key.send(TuiMessage::Exit);
2440 }
2441 continue;
2442 }
2443
2444 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
2449 let status = *state_for_key.status.lock().unwrap();
2450 if status == RunStatus::Working {
2451 state_for_key.set_status(RunStatus::Aborting);
2452 let lane = lane_for_key.clone();
2453 tokio::spawn(async move {
2454 let _ = lane.abort().await;
2455 });
2456 continue;
2457 }
2458 }
2459
2460 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
2465 state_for_key.toggle_expand_last_tool();
2466 tui_for_key.request_render(false);
2467 continue;
2468 }
2469
2470 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
2476 let current = state_for_key.current_model_id();
2477 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
2480 if let Some(next) = cycle_next_model(&scope, ¤t) {
2481 state_for_key.set_current_model(&next);
2482 let lane = lane_for_key.clone();
2483 tokio::spawn(async move {
2484 let _ = lane.set_model(next).await;
2485 });
2486 tui_for_key.request_render(false);
2487 }
2488 continue;
2489 }
2490
2491 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
2495 if let Some(cmd) = registry_for_key.find("/model") {
2496 cmd.execute(&ctx_for_key, "");
2497 }
2498 continue;
2499 }
2500
2501 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
2503 if accept_top_suggestion(&state_for_key, &editor_for_key) {
2504 tui_for_key.request_render(false);
2505 }
2506 continue;
2507 }
2508
2509 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
2511 scroll_for_key.scroll_by(-10);
2512 tui_for_key.request_render(false);
2513 continue;
2514 }
2515 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
2516 scroll_for_key.scroll_by(10);
2517 tui_for_key.request_render(false);
2518 continue;
2519 }
2520
2521 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
2527 let (row, col) = editor_for_key.cursor_position();
2528 if row == 0 && col == 0 {
2529 navigate_history(&state_for_key, &editor_for_key, -1);
2530 tui_for_key.request_render(false);
2531 continue;
2532 }
2533 }
2534 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
2535 let text = editor_for_key.get_text();
2536 let (row, col) = editor_for_key.cursor_position();
2537 let last_row = text.lines().count().saturating_sub(1);
2538 let last_len = text.lines().last().map(str::len).unwrap_or(0);
2539 if row == last_row && col >= last_len {
2540 navigate_history(&state_for_key, &editor_for_key, 1);
2541 tui_for_key.request_render(false);
2542 continue;
2543 }
2544 }
2545
2546 editor_for_key.handle_key(key);
2548 refresh_autocomplete(&state_for_key, &editor_for_key);
2549 tui_for_key.request_render(false);
2550 }
2551 });
2552
2553 let mut prompts: Vec<String> = Vec::new();
2555 if let Some(init) = initial {
2556 prompts.push(init);
2557 }
2558 for m in extra_messages {
2559 prompts.push(m.clone());
2560 }
2561 for prompt in prompts {
2562 if !*running.lock().unwrap() {
2563 break;
2564 }
2565 add_user_message(&chat_container, &prompt);
2566 tui.request_render(false);
2567 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
2568 }
2569
2570 loop {
2572 if !*running.lock().unwrap() {
2573 break;
2574 }
2575 match rx.try_recv() {
2576 Ok(TuiMessage::UserInput(prompt)) => {
2577 editor.clear();
2582 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
2583 }
2584 Ok(TuiMessage::ClearChat) => {
2585 chat_container.clear();
2586 add_welcome_message(&chat_container);
2587 tui.request_render(false);
2588 }
2589 Ok(TuiMessage::Compact) => {
2590 run_compact(&lane, &tui, &state).await;
2591 }
2592 Ok(TuiMessage::Copy) => {
2593 copy_last_assistant(&state, &chat_container);
2594 tui.request_render(false);
2595 }
2596 Ok(TuiMessage::Exit) => {
2597 *running.lock().unwrap() = false;
2598 break;
2599 }
2600 Ok(TuiMessage::SwitchSession(id)) => {
2601 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
2602 tui.request_render(false);
2603 }
2604 Ok(TuiMessage::ImportSession(path)) => {
2605 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
2606 tui.request_render(false);
2607 }
2608 Ok(TuiMessage::ShareSession) => {
2609 share_session(&harness, &chat_container).await;
2610 tui.request_render(false);
2611 }
2612 Ok(TuiMessage::SetSessionName(name)) => {
2613 let outcome = harness.session().set_name(Some(&name)).await;
2614 match outcome {
2615 Ok(_) => add_note_message(
2616 &chat_container,
2617 &format!("Session renamed to \"{name}\"."),
2618 ),
2619 Err(e) => add_error_message(
2620 &chat_container,
2621 &format!("Could not rename session: {e}"),
2622 ),
2623 }
2624 tui.request_render(false);
2625 }
2626 Ok(TuiMessage::ExportSession) => {
2627 export_session(&harness, &chat_container).await;
2628 tui.request_render(false);
2629 }
2630 Ok(TuiMessage::ForkSession) => {
2631 fork_session(&harness, &cwd, &chat_container, &state).await;
2632 tui.request_render(false);
2633 }
2634 Ok(TuiMessage::ReloadExtensions) => {
2635 let reload_ctx = ctx.reload_context.clone();
2642 add_note_message(&chat_container, "Reloading extensions + resources…");
2643 tui.request_render(false);
2644 let outcome =
2645 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
2646 let fresh_transformer = build_markdown_transformer(
2655 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
2656 );
2657 state.set_markdown_transformer_with_reinstall(fresh_transformer);
2658 if outcome.had_warnings {
2659 add_error_message(
2660 &chat_container,
2661 &format!("{} (with warnings — see stderr for details).", outcome.summary),
2662 );
2663 } else {
2664 add_note_message(&chat_container, &outcome.summary);
2665 }
2666 tui.request_render(false);
2667 }
2668 Err(std::sync::mpsc::TryRecvError::Empty) => {
2669 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2670 }
2671 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
2672 }
2673 }
2674
2675 tick_handle.abort();
2677 if let Some(handle) = drain_handle {
2678 handle.abort();
2679 }
2680 reload_context.mailbox.clear();
2684 reload_bridge_handle.abort();
2685 tui.stop(Default::default());
2686 println!("\nGoodbye!");
2687 let _ = args;
2688
2689 0
2690}
2691
2692async fn run_prompt_streaming(
2701 lane: &Arc<dyn AgentLane>,
2702 prompt: &str,
2703 tui: &Arc<TuiAltScreen>,
2704 state: &Arc<TuiState>,
2705 streaming: bool,
2706) {
2707 state.set_status(RunStatus::Working);
2709 tui.request_render(false);
2710
2711 let outcome = lane.prompt_text(prompt, Vec::new()).await;
2712
2713 {
2717 let mut cur = state.current_assistant.lock().unwrap();
2718 if let Some(comp) = cur.take() {
2719 comp.set_streaming(false);
2720 }
2721 }
2722
2723 state.set_status(RunStatus::Idle);
2724
2725 match outcome {
2726 Ok(result) => match &result.outcome {
2727 HarnessRunOutcome::Failed { error, final_message, .. } => {
2728 let already_rendered = final_message.is_some();
2732 if !already_rendered {
2733 let msg = final_message
2734 .as_ref()
2735 .and_then(|m| m.error_message.clone())
2736 .unwrap_or_else(|| format!("{error:?}"));
2737 add_error_message(&state.chat_container, &msg);
2738 }
2739 }
2740 HarnessRunOutcome::Suspended { .. } => {
2741 add_error_message(
2742 &state.chat_container,
2743 "Run suspended (deferred) — resume is not supported in v1.",
2744 );
2745 }
2746 HarnessRunOutcome::Aborted { final_message, .. } => {
2747 if !streaming {
2750 add_error_message(&state.chat_container, "Request aborted.");
2751 let _ = final_message; }
2753 }
2754 HarnessRunOutcome::Completed { final_message, .. } => {
2755 if !streaming {
2756 let text = assistant_text(final_message);
2757 if !text.is_empty() {
2758 add_assistant_message_blocking(
2759 &state.chat_container,
2760 &text,
2761 state.markdown_transformer(),
2762 );
2763 *state.last_assistant_text.lock().unwrap() = text;
2764 }
2765 }
2766 }
2767 },
2768 Err(e) => {
2769 add_error_message(&state.chat_container, &e.to_string());
2770 }
2771 }
2772
2773 tui.request_render(false);
2774}
2775
2776async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
2781 state.set_status(RunStatus::Working);
2782 tui.request_render(false);
2783 match lane.compact(None).await {
2784 Ok(_) => {
2785 add_note_message(&state.chat_container, "Conversation compacted.");
2786 }
2787 Err(e) => {
2788 add_error_message(
2789 &state.chat_container,
2790 &format!("Compact failed: {e}"),
2791 );
2792 }
2793 }
2794 state.set_status(RunStatus::Idle);
2795 tui.request_render(false);
2796}
2797
2798fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
2802 let text = state.last_assistant_text.lock().unwrap().clone();
2803 if text.is_empty() {
2804 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
2805 return;
2806 }
2807 if copy_to_clipboard(&text) {
2808 add_note_message(chat, "Copied last reply to the clipboard.");
2809 } else {
2810 let preview: String = text.chars().take(200).collect();
2813 add_note_message(
2814 chat,
2815 &format!("Clipboard unavailable. Last reply: {preview}{}", if text.chars().count() > 200 { "…" } else { "" }),
2816 );
2817 }
2818}
2819
2820#[cfg(feature = "clipboard")]
2823fn copy_to_clipboard(text: &str) -> bool {
2824 match arboard::Clipboard::new() {
2825 Ok(mut cb) => cb.set_text(text).is_ok(),
2826 Err(_) => false,
2827 }
2828}
2829
2830#[cfg(not(feature = "clipboard"))]
2831fn copy_to_clipboard(_text: &str) -> bool {
2832 false
2833}
2834
2835fn add_assistant_message_blocking(
2842 container: &Arc<Container>,
2843 text: &str,
2844 transformer: Option<MarkdownTransformer>,
2845) {
2846 if text.is_empty() {
2847 return;
2848 }
2849 let msg = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
2850 if let Some(t) = &transformer {
2851 msg.set_markdown_transformer(Some(t.clone()));
2852 }
2853 msg.update_text(text);
2854 container.add_child(msg);
2855 container.add_child(Arc::new(Spacer::new(1)));
2856}
2857
2858async fn drain_agent_events(
2866 mut rx: broadcast::Receiver<AgentEvent>,
2867 tui: Arc<TuiAltScreen>,
2868 state: Arc<TuiState>,
2869 chat: Arc<Container>,
2870) {
2871 loop {
2872 match rx.recv().await {
2873 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
2874 Err(broadcast::error::RecvError::Lagged(_)) => {
2875 continue;
2878 }
2879 Err(broadcast::error::RecvError::Closed) => break,
2880 }
2881 }
2882}
2883
2884async fn handle_agent_event(
2887 event: AgentEvent,
2888 tui: &Arc<TuiAltScreen>,
2889 state: &Arc<TuiState>,
2890 chat: &Arc<Container>,
2891) {
2892 match event {
2893 AgentEvent::AgentStart => {
2894 state.set_status(RunStatus::Working);
2895 tui.request_render(false);
2896 }
2897
2898 AgentEvent::AgentEnd { .. } => {
2899 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
2901 comp.set_streaming(false);
2902 }
2903 state.set_status(RunStatus::Idle);
2904 tui.request_render(false);
2905 }
2906
2907 AgentEvent::TurnStart => {
2908 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
2911 comp.set_streaming(false);
2912 }
2913 }
2914
2915 AgentEvent::TurnEnd { message, tool_results } => {
2916 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
2918 if let AgentMessage::Assistant(a) = &message {
2919 comp.update_blocks(&assistant_blocks(a));
2920 }
2921 comp.set_streaming(false);
2922 }
2923 let tools = state.tool_components.lock().unwrap();
2928 for tr in &tool_results {
2929 if tools.contains_key(&tr.tool_call_id) {
2930 let _ = tr;
2933 }
2934 }
2935 drop(tools);
2936 tui.request_render(false);
2937 }
2938
2939 AgentEvent::MessageStart { message } => match message {
2940 AgentMessage::Assistant(a) => {
2941 let comp = Arc::new(AssistantMessageComponent::new(
2942 AssistantMessageOptions::default(),
2943 ));
2944 if let Some(t) = state.markdown_transformer() {
2949 comp.set_markdown_transformer(Some(t));
2950 }
2951 comp.set_streaming(true);
2952 comp.update_blocks(&assistant_blocks(&a));
2955 chat.add_child(comp.clone());
2956 chat.add_child(Arc::new(Spacer::new(0)));
2957 *state.current_assistant.lock().unwrap() = Some(comp);
2958 tui.request_render(false);
2959 }
2960 _ => {}
2963 },
2964
2965 AgentEvent::MessageUpdate { message, assistant_message_event } => {
2966 if let AgentMessage::Assistant(a) = &message {
2967 let text = assistant_text(a);
2968 for c in &a.content {
2973 if let Content::ToolCall(tc) = c {
2974 let mut tools = state.tool_components.lock().unwrap();
2975 if !tools.contains_key(&tc.id) {
2976 let comp = Arc::new(ToolExecutionComponent::new(
2977 &tc.name,
2978 &tc.arguments.to_string(),
2979 ));
2980 comp.set_running();
2981 chat.add_child(comp.clone());
2982 tools.insert(tc.id.clone(), comp);
2983 }
2984 }
2985 }
2986 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
2988 comp.update_blocks(&assistant_blocks(a));
2991 }
2992 *state.last_assistant_text.lock().unwrap() = text;
2993 tui.request_render(false);
2994 }
2995 }
2996
2997 AgentEvent::MessageEnd { message } => {
2998 if let AgentMessage::Assistant(a) = &message {
2999 let text = assistant_text(a);
3000 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3001 comp.update_blocks(&assistant_blocks(a));
3002 comp.set_streaming(false);
3003 }
3004 if !text.is_empty() {
3006 *state.last_assistant_text.lock().unwrap() = text;
3007 }
3008 let usage = &a.usage;
3014 let prev_input = *state.last_input_tokens.lock().unwrap();
3015 if prev_input > 0
3016 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
3017 && usage.cache_read == 0
3018 {
3019 add_note_message(
3020 &state.chat_container,
3021 &format!(
3022 "Cache miss: {} tokens re-billed",
3023 format_tokens(usage.input)
3024 ),
3025 );
3026 }
3027 *state.last_input_tokens.lock().unwrap() = usage.input;
3028 }
3029 tui.request_render(false);
3030 }
3031
3032 AgentEvent::ToolExecutionStart { tool_call_id, tool_name, args } => {
3033 if tool_name == "bash" {
3034 let command = args
3039 .get("command")
3040 .and_then(|v| v.as_str())
3041 .unwrap_or("")
3042 .to_string();
3043 let comp = Arc::new(BashExecutionComponent::new(command));
3044 chat.add_child(comp.clone());
3045 state
3046 .bash_components
3047 .lock()
3048 .unwrap()
3049 .insert(tool_call_id.clone(), comp);
3050 } else {
3051 let comp = {
3052 let mut tools = state.tool_components.lock().unwrap();
3053 if let Some(existing) = tools.get(&tool_call_id) {
3054 existing.set_args(&args.to_string());
3055 existing.clone()
3056 } else {
3057 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
3058 comp.set_running();
3059 chat.add_child(comp.clone());
3060 tools.insert(tool_call_id.clone(), comp.clone());
3061 comp
3062 }
3063 };
3064 state.remember_tool(comp);
3065 }
3066 tui.request_render(false);
3067 }
3068
3069 AgentEvent::ToolExecutionUpdate { tool_call_id, tool_name, partial_result, .. } => {
3070 if tool_name == "bash" {
3071 let chunk = summarize_tool_result(&partial_result);
3073 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
3074 bash.append_output(&chunk);
3075 } else {
3076 let comp = Arc::new(BashExecutionComponent::new(""));
3079 comp.append_output(&chunk);
3080 chat.add_child(comp.clone());
3081 state
3082 .bash_components
3083 .lock()
3084 .unwrap()
3085 .insert(tool_call_id.clone(), comp);
3086 }
3087 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
3088 let summary = summarize_tool_result(&partial_result);
3089 comp.set_result(&summary, false);
3090 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
3091 state.remember_tool(comp.clone());
3092 } else {
3093 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
3095 comp.set_running();
3096 comp.set_result(&summarize_tool_result(&partial_result), false);
3097 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
3098 chat.add_child(comp.clone());
3099 state
3100 .tool_components
3101 .lock()
3102 .unwrap()
3103 .insert(tool_call_id.clone(), comp.clone());
3104 state.remember_tool(comp);
3105 }
3106 tui.request_render(false);
3107 }
3108
3109 AgentEvent::ToolExecutionEnd { tool_call_id, tool_name, result, is_error } => {
3110 if tool_name == "bash" {
3111 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
3112 if let Some(bash) = bash {
3113 finalize_bash(&bash, &result, is_error);
3114 } else {
3115 let command = result
3118 .details
3119 .get("command")
3120 .and_then(|v| v.as_str())
3121 .unwrap_or("")
3122 .to_string();
3123 let comp = Arc::new(BashExecutionComponent::new(command));
3124 comp.append_output(&summarize_tool_result(&result));
3125 finalize_bash(&comp, &result, is_error);
3126 chat.add_child(comp);
3127 }
3128 } else {
3129 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
3130 if let Some(comp) = comp {
3131 comp.set_result(&summarize_tool_result(&result), is_error);
3132 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
3133 } else {
3134 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
3137 comp.set_result(&summarize_tool_result(&result), is_error);
3138 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
3139 chat.add_child(comp.clone());
3140 state.remember_tool(comp);
3141 }
3142 }
3143 tui.request_render(false);
3144 }
3145 }
3146}
3147
3148fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &rpi_agent::AgentToolResult, is_error: bool) {
3152 let exit_code = if is_error { Some(1) } else { Some(0) };
3155 let truncated = result
3156 .details
3157 .get("truncation")
3158 .and_then(|t| t.get("truncated"))
3159 .and_then(|v| v.as_bool())
3160 .unwrap_or(false);
3161 let full_output_path = result
3162 .details
3163 .get("full_output_path")
3164 .and_then(|v| v.as_str())
3165 .map(|s| s.to_string());
3166 let truncation = BashTruncation {
3167 truncated,
3168 full_output_path,
3169 };
3170 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
3172}
3173
3174fn apply_edit_diff(
3179 comp: &Arc<ToolExecutionComponent>,
3180 tool_name: &str,
3181 details: &serde_json::Value,
3182 tui: &Arc<TuiAltScreen>,
3183) {
3184 if tool_name != "edit" {
3185 return;
3186 }
3187 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
3188 return;
3189 };
3190 if diff_text.is_empty() {
3191 return;
3192 }
3193 let width = tui.width();
3194 let lines = render_diff(diff_text, width);
3195 comp.set_diff(lines);
3196}
3197
3198fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
3201 use rpi_agent::TextContentOrImage;
3202 let mut parts: Vec<String> = Vec::new();
3203 for c in &result.content {
3204 if let TextContentOrImage::Text(t) = c {
3205 parts.push(t.text.clone());
3206 }
3207 }
3208 let joined = parts.join("\n");
3209 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
3211 if one_line.chars().count() > 200 {
3212 let truncated: String = one_line.chars().take(200).collect();
3213 format!("{truncated}…")
3214 } else {
3215 one_line
3216 }
3217}
3218
3219fn open_selector(
3227 state: &Arc<TuiState>,
3228 editor_container: &Arc<Container>,
3229 editor: &Arc<Editor>,
3230 tui: &Arc<TuiAltScreen>,
3231 list: Arc<SelectList>,
3232 kind: SelectorKind,
3233) {
3234 editor.set_focused(false);
3236 editor_container.clear();
3238 editor_container.add_child(list.clone());
3239 *state.active_selector.lock().unwrap() = Some((list, kind));
3240 tui.request_render(false);
3241}
3242
3243fn close_selector(state: &Arc<TuiState>, editor_container: &Arc<Container>, editor: &Arc<Editor>, tui: &Arc<TuiAltScreen>) {
3246 editor_container.clear();
3247 editor_container.add_child(editor.clone());
3248 editor.set_focused(true);
3249 *state.active_selector.lock().unwrap() = None;
3250 tui.request_render(false);
3251}
3252
3253fn open_model_selector(
3259 state: &Arc<TuiState>,
3260 editor_container: &Arc<Container>,
3261 editor: &Arc<Editor>,
3262 tui: &Arc<TuiAltScreen>,
3263 catalog: &[rpi_ai::Model],
3264 lane: &Arc<dyn AgentLane>,
3265 lane_model_id: &str,
3266 chat: &Arc<Container>,
3267) {
3268 let mut items: Vec<SelectItem> = Vec::new();
3269 for m in catalog {
3270 let label = if m.name.is_empty() { short_model_name(&m.id) } else { m.name.clone() };
3271 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) { " (current)" } else { "" };
3272 items.push(
3273 SelectItem::new(&m.id, &label)
3274 .with_description(&format!("{id}{marker}", id = m.id)),
3275 );
3276 }
3277 if items.is_empty() {
3278 add_note_message(
3279 chat,
3280 "No models in the catalog. Use --model at startup to select one.",
3281 );
3282 tui.request_render(false);
3283 return;
3284 }
3285 let list = Arc::new(SelectList::new(items, 10));
3286
3287 let catalog_arc = catalog.to_vec();
3291 let state_sel = state.clone();
3292 let ec_sel = editor_container.clone();
3293 let editor_sel = editor.clone();
3294 let tui_sel = tui.clone();
3295 let chat_sel = chat.clone();
3296 let lane_sel = lane.clone();
3297 list.on_select(Arc::new(move |item| {
3298 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3299 add_note_message(&chat_sel, &format!("Model {} not found in catalog.", item.label));
3300 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3301 return;
3302 };
3303 state_sel.set_current_model(&model);
3304 let lane = lane_sel.clone();
3305 tokio::spawn(async move {
3306 let _ = lane.set_model(model).await;
3307 });
3308 add_note_message(
3309 &chat_sel,
3310 &format!(
3311 "Model set to {} — applies to the next message.",
3312 short_model_name(&item.value)
3313 ),
3314 );
3315 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3316 }));
3317 let state_cancel = state.clone();
3318 let ec_cancel = editor_container.clone();
3319 let editor_cancel = editor.clone();
3320 let tui_cancel = tui.clone();
3321 list.on_cancel(Arc::new(move || {
3322 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3323 }));
3324
3325 open_selector(state, editor_container, editor, tui, list, SelectorKind::Model);
3326}
3327
3328fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
3333 if catalog.is_empty() {
3334 return None;
3335 }
3336 let idx = catalog
3337 .iter()
3338 .position(|m| m.id.eq_ignore_ascii_case(current_id));
3339 match idx {
3340 Some(i) => {
3341 let next = (i + 1) % catalog.len();
3342 Some(catalog[next].clone())
3343 }
3344 None => Some(catalog[0].clone()),
3345 }
3346}
3347
3348fn open_session_selector(
3353 state: &Arc<TuiState>,
3354 editor_container: &Arc<Container>,
3355 editor: &Arc<Editor>,
3356 tui: &Arc<TuiAltScreen>,
3357 cwd: &std::path::Path,
3358 tx: &mpsc::Sender<TuiMessage>,
3359) {
3360 let dir = crate::session::default_session_dir(cwd);
3361 let mut items: Vec<SelectItem> = Vec::new();
3362 if let Ok(entries) = std::fs::read_dir(&dir) {
3363 for entry in entries.flatten() {
3364 let path = entry.path();
3365 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
3366 continue;
3367 }
3368 let stem = path
3369 .file_stem()
3370 .and_then(|s| s.to_str())
3371 .unwrap_or("(unnamed)")
3372 .to_string();
3373 let display = path
3374 .file_name()
3375 .and_then(|s| s.to_str())
3376 .unwrap_or(&stem)
3377 .to_string();
3378 items.push(SelectItem::new(&stem, &display));
3379 }
3380 }
3381 if items.is_empty() {
3382 add_note_message(
3383 &state.chat_container,
3384 "No saved sessions found. Sessions are created automatically in interactive mode.",
3385 );
3386 tui.request_render(false);
3387 return;
3388 }
3389 let list = Arc::new(SelectList::new(items, 10));
3390
3391 let state_sel = state.clone();
3392 let ec_sel = editor_container.clone();
3393 let editor_sel = editor.clone();
3394 let tui_sel = tui.clone();
3395 let tx_sel = tx.clone();
3396 list.on_select(Arc::new(move |item| {
3397 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3401 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
3402 }));
3403 let state_cancel = state.clone();
3404 let ec_cancel = editor_container.clone();
3405 let editor_cancel = editor.clone();
3406 let tui_cancel = tui.clone();
3407 list.on_cancel(Arc::new(move || {
3408 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3409 }));
3410
3411 open_selector(state, editor_container, editor, tui, list, SelectorKind::Session);
3412}
3413
3414fn open_theme_selector(
3417 state: &Arc<TuiState>,
3418 editor_container: &Arc<Container>,
3419 editor: &Arc<Editor>,
3420 tui: &Arc<TuiAltScreen>,
3421) {
3422 let items = vec![
3423 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3424 SelectItem::new("light", "Light").with_description("Light background"),
3425 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3426 ];
3427 let list = Arc::new(SelectList::new(items, 10));
3428
3429 let state_sel = state.clone();
3430 let ec_sel = editor_container.clone();
3431 let editor_sel = editor.clone();
3432 let tui_sel = tui.clone();
3433 let chat_sel = state.chat_container.clone();
3434 list.on_select(Arc::new(move |item| {
3435 let preset = match item.value.as_str() {
3436 "light" => ThemePreset::Light,
3437 "monochrome" => ThemePreset::Monochrome,
3438 _ => ThemePreset::Dark,
3439 };
3440 state_sel.theme_manager.apply_preset(preset);
3441 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3444 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3445 tui_sel.render_now(true);
3446 }));
3447 let state_cancel = state.clone();
3448 let ec_cancel = editor_container.clone();
3449 let editor_cancel = editor.clone();
3450 let tui_cancel = tui.clone();
3451 list.on_cancel(Arc::new(move || {
3452 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3453 }));
3454
3455 open_selector(state, editor_container, editor, tui, list, SelectorKind::Theme);
3456}
3457
3458fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
3465 use rpi_ai::types::ThinkingLevel::*;
3466 match level {
3467 Off => "Off — No reasoning",
3468 Minimal => "Minimal — Brief reasoning (~1k tokens)",
3469 Low => "Low — Light reasoning (~1k tokens)",
3470 Medium => "Medium — Moderate reasoning (~80% of max)",
3471 High => "High — Extensive reasoning (~95% of max)",
3472 Xhigh => "Xhigh — Near-maximal reasoning",
3473 Max => "Max — Maximum reasoning",
3474 }
3475}
3476
3477fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
3480 use rpi_ai::types::ThinkingLevel::*;
3481 match level {
3482 Off => "off",
3483 Minimal => "minimal",
3484 Low => "low",
3485 Medium => "medium",
3486 High => "high",
3487 Xhigh => "xhigh",
3488 Max => "max",
3489 }
3490}
3491
3492fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
3495 use rpi_ai::types::ThinkingLevel::*;
3496 match name.to_ascii_lowercase().as_str() {
3497 "off" => Some(Off),
3498 "minimal" => Some(Minimal),
3499 "low" => Some(Low),
3500 "medium" => Some(Medium),
3501 "high" => Some(High),
3502 "xhigh" => Some(Xhigh),
3503 "max" => Some(Max),
3504 _ => None,
3505 }
3506}
3507
3508fn open_thinking_selector(
3518 state: &Arc<TuiState>,
3519 editor_container: &Arc<Container>,
3520 editor: &Arc<Editor>,
3521 tui: &Arc<TuiAltScreen>,
3522 lane: &Arc<dyn AgentLane>,
3523 catalog: &[rpi_ai::Model],
3524 lane_model_id: &str,
3525 chat: &Arc<Container>,
3526) {
3527 let model = catalog
3530 .iter()
3531 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3532 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3533 .map(|m| m.supported_thinking_levels())
3534 .unwrap_or_else(|| {
3535 use rpi_ai::types::ThinkingLevel::*;
3536 vec![Off, Minimal, Low, Medium, High]
3537 });
3538 let mut items: Vec<SelectItem> = Vec::new();
3539 for lvl in &levels {
3540 let name = thinking_level_name(*lvl);
3541 items.push(
3542 SelectItem::new(name, name)
3543 .with_description(thinking_level_description(*lvl)),
3544 );
3545 }
3546 if items.is_empty() {
3547 add_note_message(chat, "This model has no supported thinking levels.");
3548 tui.request_render(false);
3549 return;
3550 }
3551 let list = Arc::new(SelectList::new(items, 10));
3552
3553 let state_sel = state.clone();
3554 let ec_sel = editor_container.clone();
3555 let editor_sel = editor.clone();
3556 let tui_sel = tui.clone();
3557 let chat_sel = chat.clone();
3558 let lane_sel = lane.clone();
3559 list.on_select(Arc::new(move |item| {
3560 let Some(level) = thinking_level_from_name(&item.value) else {
3561 add_note_message(&chat_sel, &format!("Unknown thinking level: {}.", item.label));
3562 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3563 return;
3564 };
3565 let lane = lane_sel.clone();
3566 let footer_sel = state_sel.footer.clone();
3567 tokio::spawn(async move {
3568 let _ = lane.set_thinking_level(level).await;
3569 });
3570 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3574 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
3575 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3576 }));
3577 let state_cancel = state.clone();
3578 let ec_cancel = editor_container.clone();
3579 let editor_cancel = editor.clone();
3580 let tui_cancel = tui.clone();
3581 list.on_cancel(Arc::new(move || {
3582 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3583 }));
3584
3585 open_selector(state, editor_container, editor, tui, list, SelectorKind::Thinking);
3586}
3587
3588fn open_tools_selector(
3594 state: &Arc<TuiState>,
3595 editor_container: &Arc<Container>,
3596 editor: &Arc<Editor>,
3597 tui: &Arc<TuiAltScreen>,
3598 lane: &Arc<dyn AgentLane>,
3599 chat: &Arc<Container>,
3600) {
3601 let active = match tokio::runtime::Handle::try_current() {
3606 Ok(h) => h.block_on(async { lane.get_active_tools().await }).unwrap_or_default(),
3607 Err(_) => Vec::new(),
3608 };
3609 let mut items: Vec<SelectItem> = Vec::new();
3610 for name in crate::session::BUILTIN_TOOL_NAMES {
3611 let on = active.iter().any(|a| a == name);
3612 let label = if on { format!("{name} (on)") } else { (*name).to_string() };
3613 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
3614 }
3615 let list = Arc::new(SelectList::new(items, 10));
3616
3617 let active_captured = active.clone();
3619 let state_sel = state.clone();
3620 let ec_sel = editor_container.clone();
3621 let editor_sel = editor.clone();
3622 let tui_sel = tui.clone();
3623 let chat_sel = chat.clone();
3624 let lane_sel = lane.clone();
3625 list.on_select(Arc::new(move |item| {
3626 let mut next = active_captured.clone();
3627 if let Some(pos) = next.iter().position(|a| a == &item.value) {
3628 next.remove(pos);
3629 } else {
3630 next.push(item.value.clone());
3631 }
3632 let on = next.iter().any(|a| a == &item.value);
3633 let lane = lane_sel.clone();
3634 let next_clone = next.clone();
3635 tokio::spawn(async move {
3636 let _ = lane.set_active_tools(next_clone).await;
3637 });
3638 let list_str = if next.is_empty() {
3639 "(none)".to_string()
3640 } else {
3641 next.join(", ")
3642 };
3643 add_note_message(
3644 &chat_sel,
3645 &format!(
3646 "{} {} — active tools: {}",
3647 item.value,
3648 if on { "enabled" } else { "disabled" },
3649 list_str
3650 ),
3651 );
3652 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3653 }));
3654 let state_cancel = state.clone();
3655 let ec_cancel = editor_container.clone();
3656 let editor_cancel = editor.clone();
3657 let tui_cancel = tui.clone();
3658 list.on_cancel(Arc::new(move || {
3659 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3660 }));
3661
3662 open_selector(state, editor_container, editor, tui, list, SelectorKind::Tools);
3663}
3664
3665fn open_images_selector(
3669 state: &Arc<TuiState>,
3670 editor_container: &Arc<Container>,
3671 editor: &Arc<Editor>,
3672 tui: &Arc<TuiAltScreen>,
3673 chat: &Arc<Container>,
3674) {
3675 let current = *state.show_images.lock().unwrap();
3676 let items = vec![
3677 SelectItem::new("yes", "Yes")
3678 .with_description(if current { "Inline images (current)" } else { "Inline images" }),
3679 SelectItem::new("no", "No")
3680 .with_description(if current { "Placeholder only" } else { "Placeholder only (current)" }),
3681 ];
3682 let list = Arc::new(SelectList::new(items, 5));
3683
3684 let state_sel = state.clone();
3685 let ec_sel = editor_container.clone();
3686 let editor_sel = editor.clone();
3687 let tui_sel = tui.clone();
3688 let chat_sel = chat.clone();
3689 list.on_select(Arc::new(move |item| {
3690 let on = item.value == "yes";
3691 *state_sel.show_images.lock().unwrap() = on;
3692 add_note_message(
3693 &chat_sel,
3694 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
3695 );
3696 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3697 }));
3698 let state_cancel = state.clone();
3699 let ec_cancel = editor_container.clone();
3700 let editor_cancel = editor.clone();
3701 let tui_cancel = tui.clone();
3702 list.on_cancel(Arc::new(move || {
3703 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3704 }));
3705
3706 open_selector(state, editor_container, editor, tui, list, SelectorKind::Images);
3707}
3708
3709fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
3717 let text = editor.get_text();
3718 let (_row, col) = editor.cursor_position();
3719 let cursor = col.min(text.len());
3727 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
3728 render_autocomplete(state, suggestions);
3729}
3730
3731fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
3733 state.autocomplete_container.clear();
3734 let Some(sugg) = suggestions else {
3735 return;
3736 };
3737 if sugg.items.is_empty() {
3738 return;
3739 }
3740 let accent = state.theme_manager.get().colors.accent;
3743 let muted = state.theme_manager.get().colors.muted;
3744 for (i, item) in sugg.items.iter().take(5).enumerate() {
3745 let prefix = if i == 0 { "→ " } else { " " };
3746 let label = item.display_text();
3747 let line = if i == 0 {
3748 format!("{prefix}{} {}", accent.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
3749 } else {
3750 format!("{prefix}{} {}", muted.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
3751 };
3752 state
3753 .autocomplete_container
3754 .add_child(Arc::new(Text::new(line, 1, 0)));
3755 }
3756}
3757
3758fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
3762 let text = editor.get_text();
3763 let (_row, col) = editor.cursor_position();
3764 let cursor = col.min(text.len());
3765 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
3766 return false;
3767 };
3768 let Some(top) = sugg.items.first() else {
3769 return false;
3770 };
3771 let start = sugg.start.min(text.len());
3775 let end = sugg.end.min(text.len());
3776 let mut replaced = String::with_capacity(text.len() + top.text.len());
3777 replaced.push_str(&text[..start]);
3778 replaced.push_str(&top.text);
3779 replaced.push_str(&text[end..]);
3782 if top.insert_space && !replaced.ends_with('/') {
3783 replaced.push(' ');
3784 }
3785 let new_cursor = replaced.len().min(
3788 start + top.text.len()
3789 + if top.insert_space && !top.text.ends_with('/') {
3790 1
3791 } else {
3792 0
3793 },
3794 );
3795 editor.set_text(&replaced);
3796 editor.set_cursor(0, new_cursor);
3797 state.autocomplete_container.clear();
3798 true
3799}
3800
3801fn add_welcome_message(container: &Arc<Container>) {
3807 container.add_child(Arc::new(Text::new("rpi interactive TUI", 1, 0)));
3808 container.add_child(Arc::new(Spacer::new(1)));
3809 container.add_child(Arc::new(Text::new(
3810 "Type your message and press Enter to send.",
3811 1, 0,
3812 )));
3813 container.add_child(Arc::new(Text::new(
3814 "Ctrl+C: Abort/Exit | Esc: Abort | Enter: Send | Shift+Enter: New line | Tab: Complete | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help",
3815 1, 0,
3816 )));
3817 container.add_child(Arc::new(Spacer::new(1)));
3818}
3819
3820fn add_help_message(container: &Arc<Container>) {
3822 container.add_child(Arc::new(Text::new("📚 Available Commands:", 1, 0)));
3823 container.add_child(Arc::new(Spacer::new(1)));
3824 container.add_child(Arc::new(Text::new(" /help, /? — Show this help message", 1, 0)));
3825 container.add_child(Arc::new(Text::new(" /clear, /new — Clear the conversation", 1, 0)));
3826 container.add_child(Arc::new(Text::new(" /exit, /quit, /q — Exit the application", 1, 0)));
3827 container.add_child(Arc::new(Text::new(" /version, /v — Show version information", 1, 0)));
3828 container.add_child(Arc::new(Text::new(" /model, /m — Choose a model (live switch)", 1, 0)));
3829 container.add_child(Arc::new(Text::new(" /thinking, /think — Set reasoning depth (selector)", 1, 0)));
3830 container.add_child(Arc::new(Text::new(" /tools — Toggle built-in tools on/off", 1, 0)));
3831 container.add_child(Arc::new(Text::new(" /images — Toggle inline image rendering", 1, 0)));
3832 container.add_child(Arc::new(Text::new(" /session — List saved sessions", 1, 0)));
3833 container.add_child(Arc::new(Text::new(" /theme — Choose a theme (selector)", 1, 0)));
3834 container.add_child(Arc::new(Text::new(" /compact — Compact the conversation", 1, 0)));
3835 container.add_child(Arc::new(Text::new(" /copy — Copy last reply to clipboard", 1, 0)));
3836 container.add_child(Arc::new(Text::new(" /hotkeys — Show keyboard shortcuts", 1, 0)));
3837 container.add_child(Arc::new(Text::new(" /armin — 🐾 Easter egg", 1, 0)));
3838 container.add_child(Arc::new(Text::new(" /earendil — Earendil announcement", 1, 0)));
3839 container.add_child(Arc::new(Spacer::new(1)));
3840}
3841
3842fn add_version_message(container: &Arc<Container>) {
3844 container.add_child(Arc::new(Text::new("📦 Version Information:", 1, 0)));
3845 container.add_child(Arc::new(Spacer::new(1)));
3846 container.add_child(Arc::new(Text::new(" rpi-cli v0.1.2", 1, 0)));
3847 container.add_child(Arc::new(Text::new(
3848 " Rust implementation of pi coding agent TUI",
3849 1, 0,
3850 )));
3851 container.add_child(Arc::new(Spacer::new(1)));
3852}
3853
3854fn add_hotkeys_message(container: &Arc<Container>) {
3856 container.add_child(Arc::new(Text::new("⌨️ Keyboard Shortcuts:", 1, 0)));
3857 container.add_child(Arc::new(Spacer::new(1)));
3858 container.add_child(Arc::new(Text::new(" Enter — Send message", 1, 0)));
3859 container.add_child(Arc::new(Text::new(" Shift+Enter — New line", 1, 0)));
3860 container.add_child(Arc::new(Text::new(" Tab — Accept autocomplete suggestion", 1, 0)));
3861 container.add_child(Arc::new(Text::new(" Ctrl+A / Ctrl+E — Line start / end", 1, 0)));
3862 container.add_child(Arc::new(Text::new(" Ctrl+K / Ctrl+U — Kill to end / start of line (Ctrl+Y yanks)", 1, 0)));
3863 container.add_child(Arc::new(Text::new(" Ctrl+- / Ctrl+R — Undo / redo", 1, 0)));
3864 container.add_child(Arc::new(Text::new(" Ctrl+Y / Alt+Y — Yank / yank-pop", 1, 0)));
3865 container.add_child(Arc::new(Text::new(" Alt+Backspace — Kill previous word", 1, 0)));
3866 container.add_child(Arc::new(Text::new(" Ctrl+C — Abort a run, or exit when idle", 1, 0)));
3867 container.add_child(Arc::new(Text::new(" Esc — Abort a running prompt", 1, 0)));
3868 container.add_child(Arc::new(Text::new(" Ctrl+L — Open model selector", 1, 0)));
3869 container.add_child(Arc::new(Text::new(" Ctrl+M — Cycle to the next model (live)", 1, 0)));
3870 container.add_child(Arc::new(Text::new(" Ctrl+T — Expand/collapse last tool result", 1, 0)));
3871 container.add_child(Arc::new(Text::new(" PageUp/Down — Scroll transcript", 1, 0)));
3872 container.add_child(Arc::new(Spacer::new(1)));
3873}
3874
3875fn add_user_message(container: &Arc<Container>, text: &str) {
3879 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
3880 container.add_child(Arc::new(Spacer::new(0)));
3881}
3882
3883fn add_error_message(container: &Arc<Container>, text: &str) {
3885 container.add_child(Arc::new(Text::new(format!("❌ {text}"), 1, 0)));
3886 container.add_child(Arc::new(Spacer::new(1)));
3887}
3888
3889fn add_note_message(container: &Arc<Container>, text: &str) {
3891 container.add_child(Arc::new(Text::new(format!("ℹ️ {text}"), 1, 0)));
3892 container.add_child(Arc::new(Spacer::new(1)));
3893}
3894
3895fn show_context_panel(
3904 chat: &Arc<Container>,
3905 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
3906) {
3907 let skills = resources.skills.as_deref().unwrap_or(&[]);
3908 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
3909 let mut lines: Vec<String> = Vec::new();
3910 lines.push("📂 Discovered resources for this session:".into());
3911
3912 if skills.is_empty() {
3913 lines.push(" Skills: (none discovered — create .pi/skills/ or ~/.rpi/agent/skills/)".into());
3914 } else {
3915 lines.push(format!(" Skills ({}):", skills.len()));
3916 for s in skills {
3917 let marker = if s.disable_model_invocation == Some(true) {
3918 " [hidden]"
3919 } else {
3920 ""
3921 };
3922 let desc: String = s.description.chars().take(72).collect();
3923 lines.push(format!(" • {}{marker} — {desc}", s.name));
3924 }
3925 }
3926
3927 if templates.is_empty() {
3928 lines.push(" Prompt templates: (none — create .pi/prompts/ or ~/.rpi/agent/prompts/)".into());
3929 } else {
3930 lines.push(format!(" Prompt templates ({}):", templates.len()));
3931 for t in templates {
3932 let desc = t
3933 .description
3934 .as_deref()
3935 .unwrap_or("(no description)")
3936 .chars()
3937 .take(72)
3938 .collect::<String>();
3939 lines.push(format!(" • /{} — {desc}", t.name));
3940 }
3941 }
3942 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
3943 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
3944 lines.push(" Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress.".into());
3945 let body = lines.join("\n");
3946 container_note_block(chat, &body);
3947}
3948
3949fn container_note_block(container: &Arc<Container>, body: &str) {
3951 for line in body.lines() {
3952 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
3953 }
3954 container.add_child(Arc::new(Spacer::new(1)));
3955}
3956
3957pub fn is_tui_supported() -> bool {
3963 std::io::stdout().is_terminal()
3964}
3965
3966#[allow(unused_imports)]
3968use rpi_tui::Color as _Color;
3969
3970#[cfg(test)]
3971mod tests {
3972 use super::*;
3973 use rpi_tui::Component;
3974
3975 #[test]
3976 fn test_layout_renders_welcome_message() {
3977 let chat = Arc::new(Container::new());
3978 add_welcome_message(&chat);
3979
3980 let scroll = Arc::new(ScrollView::new(
3981 chat.clone(),
3982 ScrollViewOptions {
3983 follow: FollowMode::End,
3984 primary: true,
3985 ..Default::default()
3986 },
3987 ));
3988
3989 let editor = Arc::new(Editor::new(
3990 EditorOptions {
3991 padding_x: 1,
3992 ..Default::default()
3993 },
3994 EditorStyle::default(),
3995 Arc::new(rpi_tui::Keybindings::new()),
3996 ));
3997 let dock = Arc::new(Container::new());
3998 dock.add_child(editor);
3999
4000 let footer = Arc::new(FooterComponent::new());
4001
4002 let root = VStack::from_children(vec![
4003 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
4004 StackChild::Entry(StackEntry::new(dock)),
4005 StackChild::Entry(StackEntry::new(footer)),
4006 ]);
4007
4008 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
4009
4010 let all: String = frame.lines.join("\n");
4011 assert!(all.contains("rpi interactive"), "Welcome message not found. Rendered: {}", all);
4012 assert!(all.contains("Type your message"), "Help text not found. Rendered: {}", all);
4013 }
4014
4015 #[test]
4016 fn test_chat_container_has_welcome_content() {
4017 let chat = Arc::new(Container::new());
4018 add_welcome_message(&chat);
4019
4020 let lines = chat.render(80);
4021 let all: String = lines.join("\n");
4022 assert!(all.contains("rpi interactive"), "Welcome message not in chat container: {:?}", lines);
4023 }
4024
4025 #[test]
4031 fn tab_accept_suggestion_reflects_in_next_render() {
4032 use rpi_tui::render_layout_frame;
4033
4034 let editor = Arc::new(Editor::new(
4035 EditorOptions { padding_x: 1, ..Default::default() },
4036 EditorStyle::default(),
4037 Arc::new(rpi_tui::Keybindings::new()),
4038 ));
4039 editor.set_focused(true);
4040 let editor_container = Arc::new(Container::new());
4041 editor_container.add_child(editor.clone());
4042 let autocomplete_container = Arc::new(Container::new());
4043 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
4044 let dock = Arc::new(VStack::from_children(vec![
4045 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
4046 StackChild::Entry(StackEntry::new(editor_container.clone()).shrink(0).min_size(3)),
4047 StackChild::Entry(StackEntry::new(footer)),
4048 ]));
4049
4050 let mut manager = AutocompleteManager::new();
4052 let mut combined = CombinedAutocompleteProvider::new();
4053 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::with_default_commands()));
4054 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
4055 manager.set_provider(Arc::new(combined));
4056 editor.insert("/mo");
4059 assert_eq!(editor.cursor_position(), (0, 3));
4060
4061 let frame_before = render_layout_frame(dock.clone(), 80, 10);
4062 assert!(
4063 frame_before.lines.iter().any(|l| l.contains("/mo")),
4064 "precondition: editor shows the typed prefix. Frame rows:\n{}",
4065 frame_before.lines.iter().map(|l| format!(" [{l}]")).collect::<Vec<_>>().join("\n")
4066 );
4067
4068 let text = editor.get_text();
4070 let (_row, col) = editor.cursor_position();
4071 let cursor = col.min(text.len());
4072 let sugg = manager
4073 .get_suggestions(&text, cursor)
4074 .expect("slash suggestions for /mo");
4075 let top = sugg.items.first().expect("at least one suggestion");
4076 let start = sugg.start.min(text.len());
4077 let end = sugg.end.min(text.len());
4078 let mut replaced = String::new();
4079 replaced.push_str(&text[..start]);
4080 replaced.push_str(&top.text);
4081 replaced.push_str(&text[end..]);
4082 if top.insert_space && !replaced.ends_with('/') {
4083 replaced.push(' ');
4084 }
4085 editor.set_text(&replaced);
4086 editor.set_cursor(0, replaced.len().min(start + top.text.len()));
4087 autocomplete_container.clear();
4088 assert_eq!(editor.get_text(), "/model");
4089
4090 let frame_after = render_layout_frame(dock, 80, 10);
4092 let all: String = frame_after.lines.join("\n");
4093 assert!(
4094 all.contains("/model"),
4095 "completed text missing from next render. Got:\n{all}"
4096 );
4097 let editor_line = frame_after
4101 .lines
4102 .iter()
4103 .find(|l| l.contains("/model"))
4104 .expect("editor row with completed text");
4105 assert!(
4106 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
4107 "caret must follow the full completed text. Got: {editor_line:?}"
4108 );
4109 }
4110
4111 #[test]
4112 fn test_slash_command_dispatch() {
4113 let registry = build_builtin_registry();
4118
4119 let resolves_to = |token: &str, canonical: &str| {
4121 let found = registry.find(token).expect("{token} should resolve");
4122 assert_eq!(
4123 found.name(),
4124 canonical,
4125 "{token} resolved to {} (expected {canonical})",
4126 found.name()
4127 );
4128 };
4129
4130 resolves_to("/help", "/help");
4131 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
4133 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
4137 resolves_to("/v", "/version"); resolves_to("/hotkeys", "/hotkeys");
4139 resolves_to("/model", "/model");
4140 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
4142 resolves_to("/session", "/session");
4143 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
4145 resolves_to("/copy", "/copy");
4146 resolves_to("/thinking", "/thinking");
4147 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
4149 resolves_to("/images", "/images");
4150 resolves_to("/armin", "/armin");
4151 resolves_to("/earendil", "/earendil");
4152 resolves_to("/context", "/context");
4153 resolves_to("/settings", "/settings");
4155 resolves_to("/name", "/name");
4156 resolves_to("/export", "/export");
4157
4158 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
4160 }
4161
4162 #[test]
4163
4164 fn test_registry_visible_entries_cover_dispatch() {
4165 let registry = build_builtin_registry();
4170 let names: Vec<String> = registry
4171 .visible_entries()
4172 .iter()
4173 .map(|c| c.name.clone())
4174 .collect();
4175 for recognized in [
4176 "/help", "/clear", "/new", "/exit", "/quit", "/version", "/model", "/session", "/theme",
4177 "/compact", "/copy", "/hotkeys", "/tools", "/images", "/thinking", "/armin",
4178 "/earendil",
4179 ] {
4180 assert!(
4181 names.contains(&recognized.to_string()),
4182 "{recognized} missing from autocomplete list"
4183 );
4184 }
4185 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
4187 assert!(
4188 !names.contains(&hidden.to_string()),
4189 "{hidden} should be hidden from autocomplete"
4190 );
4191 }
4192 }
4193
4194 #[test]
4195 fn test_agent_event_mapping_creates_assistant_and_tool() {
4196 use rpi_ai::types::{StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType, ToolCall, ToolCallType, Usage};
4199
4200 let state = Arc::new(TuiState {
4201 current_assistant: std::sync::Mutex::new(None),
4202 tool_components: std::sync::Mutex::new(HashMap::new()),
4203 bash_components: std::sync::Mutex::new(HashMap::new()),
4204 last_tool_comp: std::sync::Mutex::new(None),
4205 status: std::sync::Mutex::new(RunStatus::Idle),
4206 footer: Arc::new(FooterComponent::new()),
4207 status_container: Arc::new(Container::new()),
4208 chat_container: Arc::new(Container::new()),
4209 loader: Arc::new(Loader::new()),
4210 last_assistant_text: std::sync::Mutex::new(String::new()),
4211 active_selector: std::sync::Mutex::new(None),
4212 autocomplete: AutocompleteManager::new(),
4213 autocomplete_container: Arc::new(Container::new()),
4214 theme_manager: Arc::new(ThemeManager::new()),
4215 tui: None,
4216 current_model_id: std::sync::Mutex::new(String::new()),
4217 show_images: std::sync::Mutex::new(true),
4218 history: std::sync::Mutex::new(Vec::new()),
4219 history_index: std::sync::Mutex::new(-1),
4220 history_draft: std::sync::Mutex::new(None),
4221 last_input_tokens: std::sync::Mutex::new(0),
4222 scoped_edit: std::sync::Mutex::new(None),
4223 markdown_transformer: std::sync::Mutex::new(None),
4224 });
4225
4226 let assistant = AssistantMessage {
4230 role: rpi_ai::types::AssistantRole,
4231 content: vec![
4232 Content::Thinking(ThinkingContent {
4233 kind: ThinkingContentType,
4234 thinking: "Reasoning about the reply.".into(),
4235 thinking_signature: None,
4236 redacted: false,
4237 }),
4238 Content::Text(TextContent {
4239 kind: TextContentType,
4240 text: "Hello.".into(),
4241 text_signature: None,
4242 }),
4243 Content::ToolCall(ToolCall {
4244 kind: ToolCallType,
4245 id: "tc1".into(),
4246 name: "bash".into(),
4247 arguments: serde_json::json!({"command": "echo hi"}),
4248 thought_signature: None,
4249 namespace: None,
4250 }),
4251 ],
4252 api: rpi_ai::Api::AnthropicMessages,
4253 provider: "anthropic".into(),
4254 model: "claude-sonnet-5".into(),
4255 response_model: None,
4256 response_id: None,
4257 usage: Usage::zero(),
4258 stop_reason: StopReason::Stop,
4259 deferred: None,
4260 error_message: None,
4261 raw_stop_reason: None,
4262 end_turn: None,
4263 timestamp: 0,
4264 };
4265
4266 let comp = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
4269 comp.set_streaming(true);
4270 comp.update_blocks(&assistant_blocks(&assistant));
4271 let chat = Arc::new(Container::new());
4272 chat.add_child(comp.clone());
4273 *state.current_assistant.lock().unwrap() = Some(comp);
4274
4275 for c in &assistant.content {
4277 if let Content::ToolCall(tc) = c {
4278 let mut tools = state.tool_components.lock().unwrap();
4279 if !tools.contains_key(&tc.id) {
4280 let tc_comp = Arc::new(ToolExecutionComponent::new(
4281 &tc.name,
4282 &tc.arguments.to_string(),
4283 ));
4284 tc_comp.set_running();
4285 chat.add_child(tc_comp.clone());
4286 tools.insert(tc.id.clone(), tc_comp);
4287 }
4288 }
4289 }
4290
4291 let rendered = chat.render(80);
4295 let joined: String = rendered.join("\n");
4296 assert!(joined.contains("Hello."), "assistant text not rendered: {joined}");
4297 assert!(
4298 joined.contains("Reasoning about the reply."),
4299 "thinking block not rendered: {joined}"
4300 );
4301 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
4302 assert!(state.current_assistant.lock().unwrap().is_some());
4303
4304 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
4306 ended.set_result("hi", false);
4307 assert!(state.tool_components.lock().unwrap().is_empty());
4308 }
4309
4310 #[test]
4311 fn test_short_model_name() {
4312 assert_eq!(short_model_name("anthropic:claude-sonnet-5"), "claude-sonnet-5");
4313 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
4314 }
4315
4316 #[test]
4317 fn test_cycle_next_model_wraps_around() {
4318 use rpi_ai::{Api, Model};
4319 let mk = |id: &str| {
4320 Model::new(id, id, Api::AnthropicMessages, "anthropic", "https://api.anthropic.com")
4321 };
4322 let catalog = [mk("a"), mk("b"), mk("c")];
4323 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
4325 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
4326 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
4328 let empty: Vec<Model> = vec![];
4330 assert!(cycle_next_model(&empty, "a").is_none());
4331 }
4332
4333 #[test]
4334 fn test_autocomplete_slash_suggestions_render() {
4335 let state = Arc::new(TuiState {
4338 current_assistant: std::sync::Mutex::new(None),
4339 tool_components: std::sync::Mutex::new(HashMap::new()),
4340 bash_components: std::sync::Mutex::new(HashMap::new()),
4341 last_tool_comp: std::sync::Mutex::new(None),
4342 status: std::sync::Mutex::new(RunStatus::Idle),
4343 footer: Arc::new(FooterComponent::new()),
4344 status_container: Arc::new(Container::new()),
4345 chat_container: Arc::new(Container::new()),
4346 loader: Arc::new(Loader::new()),
4347 last_assistant_text: std::sync::Mutex::new(String::new()),
4348 active_selector: std::sync::Mutex::new(None),
4349 autocomplete: AutocompleteManager::new(),
4350 autocomplete_container: Arc::new(Container::new()),
4351 theme_manager: Arc::new(ThemeManager::new()),
4352 tui: None,
4353 current_model_id: std::sync::Mutex::new(String::new()),
4354 show_images: std::sync::Mutex::new(true),
4355 history: std::sync::Mutex::new(Vec::new()),
4356 history_index: std::sync::Mutex::new(-1),
4357 history_draft: std::sync::Mutex::new(None),
4358 last_input_tokens: std::sync::Mutex::new(0),
4359 scoped_edit: std::sync::Mutex::new(None),
4360 markdown_transformer: std::sync::Mutex::new(None),
4361 });
4362 {
4363 let mut combined = CombinedAutocompleteProvider::new();
4364 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
4365 build_builtin_registry().visible_entries(),
4366 )));
4367 state.autocomplete.set_provider(Arc::new(combined));
4368 }
4369
4370 let editor = Arc::new(Editor::simple());
4371 editor.set_text("/he");
4372 editor.set_cursor(0, 3);
4373 refresh_autocomplete(&state, &editor);
4374 let lines = state.autocomplete_container.render(80);
4375 let joined: String = lines.join("\n");
4376 assert!(joined.contains("/help"), "slash suggestions not rendered: {joined}");
4377
4378 editor.set_text("hello");
4380 editor.set_cursor(0, 5);
4381 refresh_autocomplete(&state, &editor);
4382 assert!(state.autocomplete_container.render(80).is_empty());
4383 }
4384
4385 #[test]
4386 fn test_select_list_swap_restores_editor() {
4387 let state = Arc::new(TuiState {
4391 current_assistant: std::sync::Mutex::new(None),
4392 tool_components: std::sync::Mutex::new(HashMap::new()),
4393 bash_components: std::sync::Mutex::new(HashMap::new()),
4394 last_tool_comp: std::sync::Mutex::new(None),
4395 status: std::sync::Mutex::new(RunStatus::Idle),
4396 footer: Arc::new(FooterComponent::new()),
4397 status_container: Arc::new(Container::new()),
4398 chat_container: Arc::new(Container::new()),
4399 loader: Arc::new(Loader::new()),
4400 last_assistant_text: std::sync::Mutex::new(String::new()),
4401 active_selector: std::sync::Mutex::new(None),
4402 autocomplete: AutocompleteManager::new(),
4403 autocomplete_container: Arc::new(Container::new()),
4404 theme_manager: Arc::new(ThemeManager::new()),
4405 tui: None,
4406 current_model_id: std::sync::Mutex::new(String::new()),
4407 show_images: std::sync::Mutex::new(true),
4408 history: std::sync::Mutex::new(Vec::new()),
4409 history_index: std::sync::Mutex::new(-1),
4410 history_draft: std::sync::Mutex::new(None),
4411 last_input_tokens: std::sync::Mutex::new(0),
4412 scoped_edit: std::sync::Mutex::new(None),
4413 markdown_transformer: std::sync::Mutex::new(None),
4414 });
4415 let editor_container = Arc::new(Container::new());
4416 let editor = Arc::new(Editor::simple());
4417 editor_container.add_child(editor.clone());
4418 assert!(!state.selector_open());
4419
4420 let tui_terminal = Box::new(ProcessTerminal::new());
4421 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
4422 let list = Arc::new(SelectList::new(
4423 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
4424 5,
4425 ));
4426 open_selector(&state, &editor_container, &editor, &tui, list, SelectorKind::Theme);
4427 assert!(state.selector_open());
4428 assert_eq!(editor_container.child_count(), 1);
4430
4431 close_selector(&state, &editor_container, &editor, &tui);
4432 assert!(!state.selector_open());
4433 assert_eq!(editor_container.child_count(), 1);
4435 }
4436
4437 #[test]
4438 fn test_message_history_browse_restores_draft() {
4439 let state = Arc::new(TuiState {
4443 current_assistant: std::sync::Mutex::new(None),
4444 tool_components: std::sync::Mutex::new(HashMap::new()),
4445 bash_components: std::sync::Mutex::new(HashMap::new()),
4446 last_tool_comp: std::sync::Mutex::new(None),
4447 status: std::sync::Mutex::new(RunStatus::Idle),
4448 footer: Arc::new(FooterComponent::new()),
4449 status_container: Arc::new(Container::new()),
4450 chat_container: Arc::new(Container::new()),
4451 loader: Arc::new(Loader::new()),
4452 last_assistant_text: std::sync::Mutex::new(String::new()),
4453 active_selector: std::sync::Mutex::new(None),
4454 autocomplete: AutocompleteManager::new(),
4455 autocomplete_container: Arc::new(Container::new()),
4456 theme_manager: Arc::new(ThemeManager::new()),
4457 tui: None,
4458 current_model_id: std::sync::Mutex::new(String::new()),
4459 show_images: std::sync::Mutex::new(true),
4460 history: std::sync::Mutex::new(Vec::new()),
4461 history_index: std::sync::Mutex::new(-1),
4462 history_draft: std::sync::Mutex::new(None),
4463 last_input_tokens: std::sync::Mutex::new(0),
4464 scoped_edit: std::sync::Mutex::new(None),
4465 markdown_transformer: std::sync::Mutex::new(None),
4466 });
4467 let editor = Arc::new(Editor::simple());
4468
4469 push_history(&state, "first message");
4470 push_history(&state, "second message");
4471 push_history(&state, "second message");
4473 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
4475 assert_eq!(state.history.lock().unwrap()[0], "second message");
4476
4477 editor.set_text("half-typed");
4479 editor.set_cursor(0, 11);
4480
4481 navigate_history(&state, &editor, -1);
4483 assert_eq!(editor.get_text(), "second message");
4484 assert_eq!(*state.history_index.lock().unwrap(), 0);
4485 navigate_history(&state, &editor, -1);
4487 assert_eq!(editor.get_text(), "first message");
4488 assert_eq!(*state.history_index.lock().unwrap(), 1);
4489 navigate_history(&state, &editor, -1);
4491 assert_eq!(editor.get_text(), "first message");
4492 navigate_history(&state, &editor, 1);
4494 assert_eq!(editor.get_text(), "second message");
4495 navigate_history(&state, &editor, 1);
4497 assert_eq!(editor.get_text(), "half-typed");
4498 assert_eq!(*state.history_index.lock().unwrap(), -1);
4499 }
4500
4501 #[test]
4502 fn test_accept_top_suggestion_replaces_prefix() {
4503 let state = Arc::new(TuiState {
4505 current_assistant: std::sync::Mutex::new(None),
4506 tool_components: std::sync::Mutex::new(HashMap::new()),
4507 bash_components: std::sync::Mutex::new(HashMap::new()),
4508 last_tool_comp: std::sync::Mutex::new(None),
4509 status: std::sync::Mutex::new(RunStatus::Idle),
4510 footer: Arc::new(FooterComponent::new()),
4511 status_container: Arc::new(Container::new()),
4512 chat_container: Arc::new(Container::new()),
4513 loader: Arc::new(Loader::new()),
4514 last_assistant_text: std::sync::Mutex::new(String::new()),
4515 active_selector: std::sync::Mutex::new(None),
4516 autocomplete: AutocompleteManager::new(),
4517 autocomplete_container: Arc::new(Container::new()),
4518 theme_manager: Arc::new(ThemeManager::new()),
4519 tui: None,
4520 current_model_id: std::sync::Mutex::new(String::new()),
4521 show_images: std::sync::Mutex::new(true),
4522 history: std::sync::Mutex::new(Vec::new()),
4523 history_index: std::sync::Mutex::new(-1),
4524 history_draft: std::sync::Mutex::new(None),
4525 last_input_tokens: std::sync::Mutex::new(0),
4526 scoped_edit: std::sync::Mutex::new(None),
4527 markdown_transformer: std::sync::Mutex::new(None),
4528 });
4529 {
4530 let mut combined = CombinedAutocompleteProvider::new();
4531 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
4532 build_builtin_registry().visible_entries(),
4533 )));
4534 state.autocomplete.set_provider(Arc::new(combined));
4535 }
4536 let editor = Arc::new(Editor::simple());
4537 editor.set_text("/he");
4538 editor.set_cursor(0, 3);
4539 refresh_autocomplete(&state, &editor);
4540 let accepted = accept_top_suggestion(&state, &editor);
4541 assert!(accepted, "should accept the top suggestion");
4542 let text = editor.get_text();
4543 assert!(
4544 text.starts_with("/help"),
4545 "editor text should start with /help, got {text}"
4546 );
4547 }
4548}