1use std::collections::HashMap;
27use std::io::IsTerminal;
28use std::sync::Arc;
29
30use base64::Engine;
31use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
32use tokio::sync::{broadcast, mpsc};
33
34use rpi_agent::{AgentEvent, AgentMessage};
35use rpi_ai::types::{AssistantMessage, Content, UserMessage};
36use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
37use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
38use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
39#[cfg(test)]
40use rpi_tui::strip_ansi;
41use rpi_tui::{
42 apply_theme_preset, render_diff, AssistantBlock, AssistantMessageComponent,
43 AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
44 BashTruncation, CombinedAutocompleteProvider, Container, DynamicBorder, Editor, EditorOptions,
45 EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, FooterComponent, Loader,
46 ProcessTerminal, ScrollView, ScrollViewOptions, SelectItem, SelectList,
47 SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer, StackChild,
48 StackEntry, Text, ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen,
49 UserMessageComponent, VStack, TUI,
50};
51use rpi_tui::{bold as tui_bold, theme as current_theme};
52
53#[allow(unused_imports)]
54use rpi_tui::BashStatus;
55
56use crate::args::Args;
57
58type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
65
66fn build_markdown_transformer(
88 snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
89) -> Option<MarkdownTransformer> {
90 let snapshot = snapshot?;
91 let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
96 if renderers.is_empty() {
97 return None;
98 }
99 Some(Arc::new(move |raw: &str| -> String {
100 transform_markdown_chain(&snapshot, &renderers, raw)
101 }))
102}
103
104fn transform_markdown_chain(
112 snapshot: &rpi_extensions::RegistrySnapshot,
113 renderers: &[rpi_extensions::RegisteredRenderer],
114 raw: &str,
115) -> String {
116 if !snapshot.is_active() {
121 return raw.to_string();
122 }
123
124 let mut current = raw.to_string();
125 for renderer in renderers {
126 let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
127 Ok(s) => s,
128 Err(_) => return current, };
130 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
140 let mut out = rpi_plugin_sdk::StbString::empty();
141 let rc = (renderer.render_fn)(
142 rpi_plugin_sdk::StbStringRef::from_str(&input),
143 &mut out as *mut rpi_plugin_sdk::StbString,
144 renderer.user_data,
145 );
146 let text = if rc == 0 {
147 let s = out.to_string_lossy();
148 Some(s)
149 } else {
150 None
151 };
152 out.free_with(Some(renderer.plugin_free_string));
156 text
157 }));
158 let out_text = match outcome {
159 Ok(Some(s)) => s,
160 Ok(None) => return current, Err(_) => return current, };
166 let next = serde_json::from_str::<serde_json::Value>(&out_text)
169 .ok()
170 .and_then(|v| {
171 v.get("markdown")
172 .and_then(|m| m.as_str())
173 .map(|s| s.to_string())
174 })
175 .unwrap_or(current);
176 current = next;
177 }
178 current
179}
180
181#[derive(Clone)]
205struct CommandContext {
206 chat: Arc<Container>,
207 tui: Arc<TuiAltScreen>,
208 tx: mpsc::UnboundedSender<TuiMessage>,
209 state: Arc<TuiState>,
210 editor: Arc<Editor>,
211 editor_container: Arc<Container>,
212 lane: Arc<dyn AgentLane>,
213 model_catalog: Arc<Vec<rpi_ai::Model>>,
214 lane_model_id: String,
218 cwd: std::path::PathBuf,
219 resources: Arc<rpi_harness::types::AgentHarnessResources>,
223 reload_context: Arc<crate::session::ReloadContext>,
229}
230
231trait SlashCommand: Send + Sync {
233 fn name(&self) -> &str;
235 fn aliases(&self) -> &'static [&'static str] {
239 &[]
240 }
241 fn visible(&self) -> bool {
244 true
245 }
246 fn alias_visible(&self) -> &'static [&'static str] {
250 &[]
251 }
252 fn description(&self) -> &'static str {
255 ""
256 }
257 fn description_owned(&self) -> String {
258 self.description().to_string()
259 }
260 fn execute(&self, ctx: &CommandContext, args: &str);
266}
267
268struct CommandRegistry {
271 commands: Vec<Arc<dyn SlashCommand>>,
272}
273
274impl CommandRegistry {
275 fn new() -> Self {
276 Self {
277 commands: Vec::new(),
278 }
279 }
280
281 fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
282 self.commands.push(cmd);
283 }
284
285 fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
288 self.commands
289 .iter()
290 .find(|c| c.name() == token || c.aliases().contains(&token))
291 }
292
293 fn visible_entries(&self) -> Vec<SlashCommandEntry> {
299 let mut out: Vec<SlashCommandEntry> = Vec::new();
300 for c in &self.commands {
301 let description = c.description_owned();
302 if c.visible() && !description.is_empty() {
303 out.push(SlashCommandEntry {
304 name: c.name().into(),
305 description: description.clone(),
306 });
307 }
308 for alias in c.alias_visible() {
310 out.push(SlashCommandEntry {
311 name: (*alias).into(),
312 description: description.clone(),
313 });
314 }
315 }
316 out
317 }
318}
319
320fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
324 let mut parts = text.split_whitespace();
325 let token = parts.next().unwrap_or("");
326 let args = parts.collect::<Vec<_>>().join(" ");
327 match registry.find(token) {
328 Some(cmd) => cmd.execute(ctx, &args),
329 None => {
330 add_error_message(
331 &ctx.chat,
332 &format!("Unknown command: {text}. Type /help for available commands."),
333 );
334 ctx.tui.request_render(false);
335 }
336 }
337}
338
339struct ExtensionCommand {
344 name: String,
345 description: String,
346 session: crate::session::ExtensionSessionCell,
347}
348
349impl SlashCommand for ExtensionCommand {
350 fn name(&self) -> &str {
351 &self.name
352 }
353
354 fn description(&self) -> &'static str {
355 "extension command"
356 }
357
358 fn description_owned(&self) -> String {
359 self.description.clone()
360 }
361
362 fn execute(&self, ctx: &CommandContext, args: &str) {
363 let result = invoke_extension_command(&self.session, &self.name, args);
364 handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
365 }
366}
367
368fn invoke_extension_command(
369 session: &crate::session::ExtensionSessionCell,
370 name: &str,
371 args: &str,
372) -> Option<serde_json::Value> {
373 let command = session
374 .lock()
375 .ok()
376 .and_then(|s| s.snapshot_arc())
377 .and_then(|snap| {
378 snap.commands()
379 .iter()
380 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
381 .cloned()
382 })?;
383 let input = serde_json::json!({ "args": args, "command": name });
384 let input = serde_json::to_string(&input).ok()?;
385 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
386 let mut out = rpi_plugin_sdk::StbString::empty();
387 let rc = (command.handler)(
388 rpi_plugin_sdk::StbStringRef::from_str(&input),
389 &mut out as *mut rpi_plugin_sdk::StbString,
390 command.user_data,
391 );
392 let text = if rc == 0 {
393 Some(out.to_string_lossy())
394 } else {
395 None
396 };
397 rpi_extensions::host_free_string(out);
398 text
399 }))
400 .ok()
401 .flatten()?;
402 serde_json::from_str(&outcome).ok()
403}
404
405fn handle_extension_ui_result(
406 result: Option<serde_json::Value>,
407 ctx: &CommandContext,
408 session: crate::session::ExtensionSessionCell,
409 command_name: String,
410) {
411 let Some(value) = result else {
412 add_error_message(&ctx.chat, "Extension command failed.");
413 ctx.tui.request_render(false);
414 return;
415 };
416 match value.get("kind").and_then(|v| v.as_str()) {
417 Some("message") | None => {
418 let fallback = value.to_string();
419 let text = value
420 .get("text")
421 .and_then(|v| v.as_str())
422 .unwrap_or(&fallback)
423 .to_string();
424 if !text.is_empty() {
425 add_note_message(&ctx.chat, &text);
426 }
427 ctx.tui.request_render(false);
428 }
429 Some("selector") => open_extension_selector(ctx, session, command_name, value),
430 Some("editor") => open_extension_editor(ctx, session, command_name, value),
431 Some(other) => {
432 add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
433 ctx.tui.request_render(false);
434 }
435 }
436}
437
438fn open_extension_selector(
439 ctx: &CommandContext,
440 session: crate::session::ExtensionSessionCell,
441 command_name: String,
442 value: serde_json::Value,
443) {
444 let items = value
445 .get("items")
446 .and_then(|v| v.as_array())
447 .map(|items| {
448 items
449 .iter()
450 .filter_map(|item| {
451 let value = item.get("value")?.as_str()?;
452 let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
453 let mut out = SelectItem::new(value, label);
454 if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
455 out = out.with_description(desc);
456 }
457 Some(out)
458 })
459 .collect::<Vec<_>>()
460 })
461 .unwrap_or_default();
462 if items.is_empty() {
463 add_error_message(&ctx.chat, "Extension selector has no items.");
464 ctx.tui.request_render(false);
465 return;
466 }
467 let list = Arc::new(SelectList::new(items, 10));
468 let state = ctx.state.clone();
469 let ec = ctx.editor_container.clone();
470 let editor = ctx.editor.clone();
471 let tui = ctx.tui.clone();
472 let session_select = session.clone();
473 let command_select = command_name.clone();
474 let ctx_select = ctx.clone();
475 list.on_select(Arc::new(move |item| {
476 let args = serde_json::json!({ "action": "select", "value": item.value });
477 let result = invoke_extension_command(
478 &session_select,
479 &command_select,
480 &serde_json::to_string(&args).unwrap_or_default(),
481 );
482 close_selector(&state, &ec, &editor, &tui);
483 handle_extension_ui_result(
484 result,
485 &ctx_select,
486 session_select.clone(),
487 command_select.clone(),
488 );
489 }));
490 let state_cancel = ctx.state.clone();
491 let ec_cancel = ctx.editor_container.clone();
492 let editor_cancel = ctx.editor.clone();
493 let tui_cancel = ctx.tui.clone();
494 list.on_cancel(Arc::new(move || {
495 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
496 }));
497 open_selector(
498 &ctx.state,
499 &ctx.editor_container,
500 &ctx.editor,
501 &ctx.tui,
502 list,
503 SelectorKind::Extension,
504 );
505}
506
507fn open_extension_editor(
508 ctx: &CommandContext,
509 session: crate::session::ExtensionSessionCell,
510 command_name: String,
511 value: serde_json::Value,
512) {
513 let initial = value
514 .get("initialText")
515 .or_else(|| value.get("text"))
516 .and_then(|v| v.as_str())
517 .unwrap_or_default()
518 .to_string();
519 let editor = Arc::new(Editor::new(
520 EditorOptions {
521 padding_x: 1,
522 autocomplete_max_visible: 0,
523 placeholder: value
524 .get("placeholder")
525 .and_then(|v| v.as_str())
526 .map(str::to_string),
527 initial_text: Some(initial),
528 },
529 EditorStyle {
530 prompt: "> ".to_string(),
531 placeholder: String::new(),
532 },
533 Arc::new(rpi_tui::Keybindings::new()),
534 ));
535 editor.set_focused(true);
536 *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
537 ctx.editor_container.clear();
538 ctx.editor_container.add_child(editor.clone());
539
540 let state = ctx.state.clone();
541 let ec = ctx.editor_container.clone();
542 let original = ctx.editor.clone();
543 let session_submit = session.clone();
544 let command_submit = command_name.clone();
545 let ctx_submit = ctx.clone();
546 editor.on_submit(Arc::new(move |text| {
547 let args = serde_json::json!({ "action": "edit", "text": text });
548 let result = invoke_extension_command(
549 &session_submit,
550 &command_submit,
551 &serde_json::to_string(&args).unwrap_or_default(),
552 );
553 close_extension_editor(&state, &ec, &original);
554 handle_extension_ui_result(
555 result,
556 &ctx_submit,
557 session_submit.clone(),
558 command_submit.clone(),
559 );
560 }));
561 ctx.tui.set_focus(Some(editor));
562 ctx.tui.request_render(false);
563}
564
565fn close_extension_editor(
566 state: &Arc<TuiState>,
567 editor_container: &Arc<Container>,
568 editor: &Arc<Editor>,
569) {
570 editor_container.clear();
571 editor_container.add_child(editor.clone());
572 *state.active_extension_editor.lock().unwrap() = None;
573 editor.set_focused(true);
574}
575
576struct HelpCommand;
579impl SlashCommand for HelpCommand {
580 fn name(&self) -> &'static str {
581 "/help"
582 }
583 fn aliases(&self) -> &'static [&'static str] {
584 &["/?"]
585 }
586 fn description(&self) -> &'static str {
587 "Show available commands"
588 }
589 fn execute(&self, ctx: &CommandContext, _args: &str) {
590 add_help_message(&ctx.chat);
591 ctx.tui.request_render(false);
592 }
593}
594
595struct ClearChatCommand;
596impl SlashCommand for ClearChatCommand {
597 fn name(&self) -> &'static str {
598 "/clear"
599 }
600 fn aliases(&self) -> &'static [&'static str] {
601 &["/new"]
602 }
603 fn alias_visible(&self) -> &'static [&'static str] {
605 &["/new"]
606 }
607 fn description(&self) -> &'static str {
608 "Clear the conversation"
609 }
610 fn execute(&self, ctx: &CommandContext, _args: &str) {
611 let _ = ctx.tx.send(TuiMessage::ClearChat);
612 }
613}
614
615struct ExitCommand;
616impl SlashCommand for ExitCommand {
617 fn name(&self) -> &'static str {
618 "/exit"
619 }
620 fn aliases(&self) -> &'static [&'static str] {
621 &["/quit", "/q"]
622 }
623 fn alias_visible(&self) -> &'static [&'static str] {
625 &["/quit"]
626 }
627 fn description(&self) -> &'static str {
628 "Exit the application"
629 }
630 fn execute(&self, ctx: &CommandContext, _args: &str) {
631 let _ = ctx.tx.send(TuiMessage::Exit);
632 }
633}
634
635struct VersionCommand;
636impl SlashCommand for VersionCommand {
637 fn name(&self) -> &'static str {
638 "/version"
639 }
640 fn aliases(&self) -> &'static [&'static str] {
641 &["/v"]
642 }
643 fn description(&self) -> &'static str {
644 "Show version information"
645 }
646 fn execute(&self, ctx: &CommandContext, _args: &str) {
647 add_version_message(&ctx.chat);
648 ctx.tui.request_render(false);
649 }
650}
651
652struct HotkeysCommand;
653impl SlashCommand for HotkeysCommand {
654 fn name(&self) -> &'static str {
655 "/hotkeys"
656 }
657 fn description(&self) -> &'static str {
658 "Show keyboard shortcuts"
659 }
660 fn execute(&self, ctx: &CommandContext, _args: &str) {
661 add_hotkeys_message(&ctx.chat);
662 ctx.tui.request_render(false);
663 }
664}
665
666struct ModelCommand;
667impl SlashCommand for ModelCommand {
668 fn name(&self) -> &'static str {
669 "/model"
670 }
671 fn aliases(&self) -> &'static [&'static str] {
672 &["/m"]
673 }
674 fn description(&self) -> &'static str {
675 "Choose a model (selector)"
676 }
677 fn execute(&self, ctx: &CommandContext, args: &str) {
678 let term = args.trim();
679 if !term.is_empty() {
680 let Some(model) = ctx
682 .model_catalog
683 .iter()
684 .find(|m| m.id.eq_ignore_ascii_case(term))
685 .cloned()
686 else {
687 add_error_message(
688 &ctx.chat,
689 &format!("No model matches \"{term}\". Try /model for the list."),
690 );
691 ctx.tui.request_render(false);
692 return;
693 };
694 let model_id = model.id.clone();
695 ctx.state.set_current_model(&model);
696 let lane = ctx.lane.clone();
697 tokio::spawn(async move {
698 let _ = lane.set_model(model).await;
699 });
700 add_note_message(
701 &ctx.chat,
702 &format!(
703 "Model set to {} — applies to the next message.",
704 short_model_name(&model_id)
705 ),
706 );
707 ctx.tui.request_render(false);
708 return;
709 }
710 open_model_selector(
711 &ctx.state,
712 &ctx.editor_container,
713 &ctx.editor,
714 &ctx.tui,
715 &ctx.model_catalog,
716 &ctx.lane,
717 &ctx.lane_model_id,
718 &ctx.chat,
719 );
720 }
721}
722
723struct ThinkingCommand;
724impl SlashCommand for ThinkingCommand {
725 fn name(&self) -> &'static str {
726 "/thinking"
727 }
728 fn aliases(&self) -> &'static [&'static str] {
729 &["/think"]
730 }
731 fn description(&self) -> &'static str {
732 "Set thinking level (selector)"
733 }
734 fn execute(&self, ctx: &CommandContext, args: &str) {
735 let level_name = args.trim();
736 if !level_name.is_empty() {
737 let Some(level) = thinking_level_from_name(level_name) else {
739 add_error_message(
740 &ctx.chat,
741 &format!(
742 "Unknown thinking level \"{level_name}\". Valid: {}",
743 crate::args::VALID_THINKING_LEVELS.join(", ")
744 ),
745 );
746 ctx.tui.request_render(false);
747 return;
748 };
749 let lane = ctx.lane.clone();
750 let footer = ctx.state.footer.clone();
751 tokio::spawn(async move {
752 let _ = lane.set_thinking_level(level).await;
753 });
754 footer.set_thinking_level(Some(thinking_level_name(level)));
755 add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
756 ctx.tui.request_render(false);
757 return;
758 }
759 open_thinking_selector(
760 &ctx.state,
761 &ctx.editor_container,
762 &ctx.editor,
763 &ctx.tui,
764 &ctx.lane,
765 &ctx.model_catalog,
766 &ctx.lane_model_id,
767 &ctx.chat,
768 );
769 }
770}
771
772struct ToolsCommand;
773impl SlashCommand for ToolsCommand {
774 fn name(&self) -> &'static str {
775 "/tools"
776 }
777 fn description(&self) -> &'static str {
778 "Toggle tools on/off"
779 }
780 fn execute(&self, ctx: &CommandContext, _args: &str) {
781 open_tools_selector(
782 &ctx.state,
783 &ctx.editor_container,
784 &ctx.editor,
785 &ctx.tui,
786 &ctx.lane,
787 &ctx.chat,
788 );
789 }
790}
791
792struct ImagesCommand;
793impl SlashCommand for ImagesCommand {
794 fn name(&self) -> &'static str {
795 "/images"
796 }
797 fn description(&self) -> &'static str {
798 "Toggle inline images"
799 }
800 fn execute(&self, ctx: &CommandContext, _args: &str) {
801 open_images_selector(
802 &ctx.state,
803 &ctx.editor_container,
804 &ctx.editor,
805 &ctx.tui,
806 &ctx.chat,
807 );
808 }
809}
810
811struct SessionCommand;
812impl SlashCommand for SessionCommand {
813 fn name(&self) -> &'static str {
814 "/session"
815 }
816 fn aliases(&self) -> &'static [&'static str] {
817 &["/resume"]
818 }
819 fn description(&self) -> &'static str {
820 "List saved sessions"
821 }
822 fn execute(&self, ctx: &CommandContext, _args: &str) {
823 open_session_selector(
824 &ctx.state,
825 &ctx.editor_container,
826 &ctx.editor,
827 &ctx.tui,
828 &ctx.cwd,
829 &ctx.tx,
830 );
831 }
832}
833
834struct ThemeCommand;
835impl SlashCommand for ThemeCommand {
836 fn name(&self) -> &'static str {
837 "/theme"
838 }
839 fn description(&self) -> &'static str {
840 "Choose a theme (selector)"
841 }
842 fn execute(&self, ctx: &CommandContext, args: &str) {
843 let name = args.trim().to_ascii_lowercase();
844 if !name.is_empty() {
845 let preset = match name.as_str() {
847 "light" => ThemePreset::Light,
848 "monochrome" => ThemePreset::Monochrome,
849 "dark" => ThemePreset::Dark,
850 _ => {
851 add_error_message(
852 &ctx.chat,
853 &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
854 );
855 ctx.tui.request_render(false);
856 return;
857 }
858 };
859 apply_theme_preset(preset);
860 let mut settings = crate::settings::load_settings().unwrap_or_default();
861 settings.theme = Some(name.clone());
862 let _ = crate::settings::save_settings(&settings);
863 add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
864 ctx.tui.request_render(false);
865 ctx.tui.render_now(true);
866 return;
867 }
868 open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
869 }
870}
871
872struct CompactCommand;
873impl SlashCommand for CompactCommand {
874 fn name(&self) -> &'static str {
875 "/compact"
876 }
877 fn description(&self) -> &'static str {
878 "Compact the conversation"
879 }
880 fn execute(&self, ctx: &CommandContext, _args: &str) {
881 let _ = ctx.tx.send(TuiMessage::Compact);
882 }
883}
884
885struct CopyCommand;
886impl SlashCommand for CopyCommand {
887 fn name(&self) -> &'static str {
888 "/copy"
889 }
890 fn description(&self) -> &'static str {
891 "Copy last reply to clipboard"
892 }
893 fn execute(&self, ctx: &CommandContext, _args: &str) {
894 let _ = ctx.tx.send(TuiMessage::Copy);
895 }
896}
897
898struct ExportCommand;
899impl SlashCommand for ExportCommand {
900 fn name(&self) -> &'static str {
901 "/export"
902 }
903 fn description(&self) -> &'static str {
904 "Export session to a markdown file"
905 }
906 fn execute(&self, ctx: &CommandContext, _args: &str) {
907 let _ = ctx.tx.send(TuiMessage::ExportSession);
908 }
909}
910
911struct ForkCommand;
912impl SlashCommand for ForkCommand {
913 fn name(&self) -> &'static str {
914 "/fork"
915 }
916 fn description(&self) -> &'static str {
917 "Fork the session into a new one"
918 }
919 fn execute(&self, ctx: &CommandContext, _args: &str) {
920 let _ = ctx.tx.send(TuiMessage::ForkSession);
921 }
922}
923
924struct CloneCommand;
928impl SlashCommand for CloneCommand {
929 fn name(&self) -> &'static str {
930 "/clone"
931 }
932 fn description(&self) -> &'static str {
933 "Duplicate the current session"
934 }
935 fn execute(&self, ctx: &CommandContext, _args: &str) {
936 let _ = ctx.tx.send(TuiMessage::ForkSession);
937 }
938}
939
940struct TreeCommand;
941impl SlashCommand for TreeCommand {
942 fn name(&self) -> &'static str {
943 "/tree"
944 }
945 fn description(&self) -> &'static str {
946 "Navigate the current session tree"
947 }
948 fn execute(&self, ctx: &CommandContext, _args: &str) {
949 let _ = ctx.tx.send(TuiMessage::OpenTree);
950 }
951}
952
953struct LoginCommand;
954impl SlashCommand for LoginCommand {
955 fn name(&self) -> &'static str {
956 "/login"
957 }
958 fn description(&self) -> &'static str {
959 "Save an Anthropic API key"
960 }
961 fn execute(&self, ctx: &CommandContext, args: &str) {
962 let key = args.trim();
963 if key.is_empty() {
964 add_note_message(&ctx.chat, "Usage: /login <api-key>");
965 } else {
966 let result = crate::config::upsert_credential(
967 "anthropic",
968 crate::config::Credential::ApiKey {
969 key: Some(key.to_string()),
970 env: None,
971 },
972 );
973 match result {
974 Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
975 Err(error) => {
976 add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
977 }
978 }
979 }
980 ctx.tui.request_render(false);
981 }
982}
983
984struct LogoutCommand;
985impl SlashCommand for LogoutCommand {
986 fn name(&self) -> &'static str {
987 "/logout"
988 }
989 fn description(&self) -> &'static str {
990 "Remove saved Anthropic credentials"
991 }
992 fn execute(&self, ctx: &CommandContext, _args: &str) {
993 match crate::config::delete_credential("anthropic") {
994 Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
995 Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
996 Err(error) => {
997 add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
998 }
999 }
1000 ctx.tui.request_render(false);
1001 }
1002}
1003
1004struct TrustCommand;
1005impl SlashCommand for TrustCommand {
1006 fn name(&self) -> &'static str {
1007 "/trust"
1008 }
1009 fn description(&self) -> &'static str {
1010 "Trust the current project"
1011 }
1012 fn execute(&self, ctx: &CommandContext, args: &str) {
1013 let value = match args.trim().to_ascii_lowercase().as_str() {
1014 "" | "yes" | "y" | "true" => Some(true),
1015 "no" | "n" | "false" => Some(false),
1016 "clear" | "reset" | "none" => None,
1017 _ => {
1018 add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
1019 ctx.tui.request_render(false);
1020 return;
1021 }
1022 };
1023 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1024 match crate::config::set_project_trust(&cwd, value) {
1025 Ok(()) => {
1026 let label = match value {
1027 Some(true) => "trusted",
1028 Some(false) => "untrusted",
1029 None => "trust decision cleared",
1030 };
1031 add_note_message(&ctx.chat, &format!("Current project marked {label}."));
1032 }
1033 Err(error) => add_error_message(
1034 &ctx.chat,
1035 &format!("Could not save trust decision: {error}"),
1036 ),
1037 }
1038 ctx.tui.request_render(false);
1039 }
1040}
1041
1042struct NameCommand;
1043impl SlashCommand for NameCommand {
1044 fn name(&self) -> &'static str {
1045 "/name"
1046 }
1047 fn description(&self) -> &'static str {
1048 "Set session display name"
1049 }
1050 fn execute(&self, ctx: &CommandContext, args: &str) {
1051 let name = args.trim();
1052 if name.is_empty() {
1053 add_note_message(
1054 &ctx.chat,
1055 "Usage: /name <display name> — sets the current session's name.",
1056 );
1057 ctx.tui.request_render(false);
1058 return;
1059 }
1060 let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
1061 }
1062}
1063
1064struct ImportCommand;
1065impl SlashCommand for ImportCommand {
1066 fn name(&self) -> &'static str {
1067 "/import"
1068 }
1069 fn description(&self) -> &'static str {
1070 "Import a session file (path)"
1071 }
1072 fn execute(&self, ctx: &CommandContext, args: &str) {
1073 let path = args.trim();
1074 if path.is_empty() {
1075 add_note_message(
1076 &ctx.chat,
1077 "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
1078 );
1079 ctx.tui.request_render(false);
1080 return;
1081 }
1082 let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
1083 }
1084}
1085
1086struct SettingsCommand;
1087impl SlashCommand for SettingsCommand {
1088 fn name(&self) -> &'static str {
1089 "/settings"
1090 }
1091 fn description(&self) -> &'static str {
1092 "Open settings menu"
1093 }
1094 fn execute(&self, ctx: &CommandContext, _args: &str) {
1095 open_settings_selector(
1096 &ctx.state,
1097 &ctx.editor_container,
1098 &ctx.editor,
1099 &ctx.tui,
1100 &ctx.lane,
1101 &ctx.model_catalog,
1102 &ctx.lane_model_id,
1103 &ctx.chat,
1104 );
1105 }
1106}
1107
1108struct ScopedModelsCommand;
1109impl SlashCommand for ScopedModelsCommand {
1110 fn name(&self) -> &'static str {
1111 "/scoped-models"
1112 }
1113 fn description(&self) -> &'static str {
1114 "Choose models for Ctrl+M cycling"
1115 }
1116 fn execute(&self, ctx: &CommandContext, _args: &str) {
1117 open_scoped_models_selector(
1118 &ctx.state,
1119 &ctx.editor_container,
1120 &ctx.editor,
1121 &ctx.tui,
1122 &ctx.model_catalog,
1123 &ctx.chat,
1124 );
1125 }
1126}
1127
1128struct ShareCommand;
1129impl SlashCommand for ShareCommand {
1130 fn name(&self) -> &'static str {
1131 "/share"
1132 }
1133 fn description(&self) -> &'static str {
1134 "Share session (gist via gh, or clipboard)"
1135 }
1136 fn execute(&self, ctx: &CommandContext, _args: &str) {
1137 let _ = ctx.tx.send(TuiMessage::ShareSession);
1138 }
1139}
1140
1141struct ArminCommand;
1142impl SlashCommand for ArminCommand {
1143 fn name(&self) -> &'static str {
1144 "/armin"
1145 }
1146 fn description(&self) -> &'static str {
1147 "??? (easter egg)"
1148 }
1149 fn execute(&self, ctx: &CommandContext, _args: &str) {
1150 crate::extras::add_armin(&ctx.chat);
1151 ctx.tui.request_render(false);
1152 }
1153}
1154
1155struct EarendilCommand;
1156impl SlashCommand for EarendilCommand {
1157 fn name(&self) -> &'static str {
1158 "/earendil"
1159 }
1160 fn description(&self) -> &'static str {
1161 "Announcement"
1162 }
1163 fn execute(&self, ctx: &CommandContext, _args: &str) {
1164 crate::extras::add_earendil(&ctx.chat);
1165 ctx.tui.request_render(false);
1166 }
1167}
1168
1169struct ContextCommand;
1173impl SlashCommand for ContextCommand {
1174 fn name(&self) -> &'static str {
1175 "/context"
1176 }
1177 fn visible(&self) -> bool {
1178 false
1179 }
1180 fn execute(&self, ctx: &CommandContext, _args: &str) {
1181 show_context_panel(&ctx.chat, &ctx.resources);
1182 ctx.tui.request_render(false);
1183 }
1184}
1185
1186struct ReloadCommand;
1197impl SlashCommand for ReloadCommand {
1198 fn name(&self) -> &'static str {
1199 "/reload"
1200 }
1201 fn description(&self) -> &'static str {
1202 "Reload extensions, skills, prompts"
1203 }
1204 fn execute(&self, ctx: &CommandContext, _args: &str) {
1205 add_note_message(&ctx.chat, "Reloading extensions + resources…");
1209 ctx.tui.request_render(false);
1210 let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
1211 }
1212}
1213
1214fn build_builtin_registry() -> CommandRegistry {
1219 let mut r = CommandRegistry::new();
1220 r.register(Arc::new(HelpCommand));
1221 r.register(Arc::new(ClearChatCommand));
1222 r.register(Arc::new(ExitCommand));
1223 r.register(Arc::new(VersionCommand));
1224 r.register(Arc::new(ModelCommand));
1225 r.register(Arc::new(ThinkingCommand));
1226 r.register(Arc::new(ToolsCommand));
1227 r.register(Arc::new(ImagesCommand));
1228 r.register(Arc::new(SessionCommand));
1229 r.register(Arc::new(ThemeCommand));
1230 r.register(Arc::new(CompactCommand));
1231 r.register(Arc::new(CopyCommand));
1232 r.register(Arc::new(HotkeysCommand));
1233 r.register(Arc::new(ArminCommand));
1234 r.register(Arc::new(EarendilCommand));
1235 r.register(Arc::new(ContextCommand));
1236 r.register(Arc::new(NameCommand));
1240 r.register(Arc::new(SettingsCommand));
1241 r.register(Arc::new(ScopedModelsCommand));
1242 r.register(Arc::new(ExportCommand));
1243 r.register(Arc::new(ImportCommand));
1244 r.register(Arc::new(ShareCommand));
1245 r.register(Arc::new(ForkCommand));
1246 r.register(Arc::new(CloneCommand));
1247 r.register(Arc::new(TreeCommand));
1248 r.register(Arc::new(TrustCommand));
1249 r.register(Arc::new(LoginCommand));
1250 r.register(Arc::new(LogoutCommand));
1251 r.register(Arc::new(ReloadCommand));
1252 r
1253}
1254
1255fn register_extension_commands(
1256 registry: &mut CommandRegistry,
1257 session: crate::session::ExtensionSessionCell,
1258) {
1259 let commands = session
1260 .lock()
1261 .ok()
1262 .and_then(|s| s.snapshot_arc())
1263 .map(|snap| snap.commands().to_vec())
1264 .unwrap_or_default();
1265 for command in commands {
1266 let name = if command.name.starts_with('/') {
1267 command.name.clone()
1268 } else {
1269 format!("/{}", command.name)
1270 };
1271 if registry.find(&name).is_some() {
1272 continue;
1273 }
1274 registry.register(Arc::new(ExtensionCommand {
1275 name,
1276 description: command.description,
1277 session: session.clone(),
1278 }));
1279 }
1280}
1281
1282enum TuiMessage {
1289 UserInput(String),
1290 OpenTree,
1291 NavigateTree(String),
1292 Exit,
1293 ClearChat,
1295 Compact,
1297 Copy,
1299 SwitchSession(String),
1302 ExportSession,
1304 ForkSession,
1306 SetSessionName(String),
1308 ImportSession(String),
1311 ShareSession,
1314 ReloadExtensions,
1319}
1320
1321fn assistant_text(msg: &AssistantMessage) -> String {
1324 msg.content
1325 .iter()
1326 .filter_map(|c| match c {
1327 Content::Text(t) => Some(t.text.clone()),
1328 _ => None,
1329 })
1330 .collect()
1331}
1332
1333fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
1336 match &msg.content {
1337 rpi_ai::types::UserContent::Text(s) => s.clone(),
1338 rpi_ai::types::UserContent::Blocks(blocks) => blocks
1339 .iter()
1340 .filter_map(|c| match c {
1341 Content::Text(t) => Some(t.text.clone()),
1342 _ => None,
1343 })
1344 .collect(),
1345 }
1346}
1347
1348fn show_settings_panel(chat: &Arc<Container>) {
1353 let s = crate::settings::load_settings().unwrap_or_default();
1354 let mut lines: Vec<String> = Vec::new();
1355 lines.push("⚙️ Saved settings:".into());
1356 lines.push(format!(
1357 " Theme: {} (edit with /theme)",
1358 s.theme.as_deref().unwrap_or("(default)")
1359 ));
1360 lines.push(format!(
1361 " Default model: {} (set at launch with --model)",
1362 s.default_model.as_deref().unwrap_or("(none)")
1363 ));
1364 lines.push(format!(
1365 " Default thinking: {} (set at launch with --thinking)",
1366 s.default_thinking_level.as_deref().unwrap_or("(default)")
1367 ));
1368 match &s.scoped_models {
1369 Some(list) if !list.is_empty() => lines.push(format!(
1370 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
1371 list.join(", ")
1372 )),
1373 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
1374 }
1375 let body = lines.join("\n");
1376 container_note_block(chat, &body);
1377}
1378
1379fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
1383 let scoped = crate::settings::load_settings()
1384 .ok()
1385 .and_then(|s| s.scoped_models)
1386 .unwrap_or_default();
1387 if scoped.is_empty() {
1388 return catalog.to_vec();
1389 }
1390 let mut out: Vec<rpi_ai::Model> = catalog
1391 .iter()
1392 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
1393 .cloned()
1394 .collect();
1395 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
1397 if let Some(cur) = catalog
1398 .iter()
1399 .find(|m| m.id.eq_ignore_ascii_case(current_id))
1400 {
1401 out.push(cur.clone());
1402 }
1403 }
1404 out
1405}
1406
1407fn open_settings_selector(
1414 state: &Arc<TuiState>,
1415 editor_container: &Arc<Container>,
1416 editor: &Arc<Editor>,
1417 tui: &Arc<TuiAltScreen>,
1418 lane: &Arc<dyn AgentLane>,
1419 catalog: &[rpi_ai::Model],
1420 lane_model_id: &str,
1421 chat: &Arc<Container>,
1422) {
1423 let settings = crate::settings::load_settings().unwrap_or_default();
1424 let mut items: Vec<SelectItem> = Vec::new();
1425 items.push(
1426 SelectItem::new("theme", "Theme")
1427 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
1428 );
1429 items.push(
1430 SelectItem::new("model", "Default model").with_description(
1431 &settings
1432 .default_model
1433 .clone()
1434 .unwrap_or_else(|| "(none)".into()),
1435 ),
1436 );
1437 items.push(
1438 SelectItem::new("thinking", "Default thinking").with_description(
1439 &settings
1440 .default_thinking_level
1441 .clone()
1442 .unwrap_or_else(|| "(default)".into()),
1443 ),
1444 );
1445 let scope_desc = match &settings.scoped_models {
1446 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
1447 _ => "all models".to_string(),
1448 };
1449 items
1450 .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
1451 let list = Arc::new(SelectList::new(items, 10));
1452
1453 let state_sel = state.clone();
1454 let ec_sel = editor_container.clone();
1455 let editor_sel = editor.clone();
1456 let tui_sel = tui.clone();
1457 let lane_sel = lane.clone();
1458 let chat_sel = chat.clone();
1459 let catalog_sel = catalog.to_vec();
1460 let lane_model_sel = lane_model_id.to_string();
1461 list.on_select(Arc::new(move |item| {
1462 match item.value.as_str() {
1465 "theme" => {
1466 open_settings_theme_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel, &chat_sel)
1467 }
1468 "model" => open_settings_model_selector(
1469 &state_sel,
1470 &ec_sel,
1471 &editor_sel,
1472 &tui_sel,
1473 &lane_sel,
1474 &catalog_sel,
1475 &lane_model_sel,
1476 &chat_sel,
1477 ),
1478 "thinking" => open_settings_thinking_selector(
1479 &state_sel,
1480 &ec_sel,
1481 &editor_sel,
1482 &tui_sel,
1483 &lane_sel,
1484 &catalog_sel,
1485 &lane_model_sel,
1486 &chat_sel,
1487 ),
1488 "scoped-models" => open_scoped_models_selector(
1489 &state_sel,
1490 &ec_sel,
1491 &editor_sel,
1492 &tui_sel,
1493 &catalog_sel,
1494 &chat_sel,
1495 ),
1496 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
1497 }
1498 }));
1499 let state_cancel = state.clone();
1500 let ec_cancel = editor_container.clone();
1501 let editor_cancel = editor.clone();
1502 let tui_cancel = tui.clone();
1503 list.on_cancel(Arc::new(move || {
1504 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1505 }));
1506
1507 open_selector(
1508 state,
1509 editor_container,
1510 editor,
1511 tui,
1512 list,
1513 SelectorKind::Settings,
1514 );
1515}
1516
1517fn open_settings_theme_selector(
1519 state: &Arc<TuiState>,
1520 editor_container: &Arc<Container>,
1521 editor: &Arc<Editor>,
1522 tui: &Arc<TuiAltScreen>,
1523 chat: &Arc<Container>,
1524) {
1525 let items = vec![
1526 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
1527 SelectItem::new("light", "Light").with_description("Light background"),
1528 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
1529 ];
1530 let list = Arc::new(SelectList::new(items, 10));
1531
1532 let state_sel = state.clone();
1533 let ec_sel = editor_container.clone();
1534 let editor_sel = editor.clone();
1535 let tui_sel = tui.clone();
1536 let chat_sel = chat.clone();
1537 list.on_select(Arc::new(move |item| {
1538 let preset = match item.value.as_str() {
1539 "light" => ThemePreset::Light,
1540 "monochrome" => ThemePreset::Monochrome,
1541 _ => ThemePreset::Dark,
1542 };
1543 apply_theme_preset(preset);
1544 let mut settings = crate::settings::load_settings().unwrap_or_default();
1545 settings.theme = Some(item.value.clone());
1546 let saved = crate::settings::save_settings(&settings);
1547 add_note_message(
1548 &chat_sel,
1549 &format!(
1550 "Theme set to {} (saved{})",
1551 item.label,
1552 if saved.is_ok() { "" } else { ", not saved" },
1553 ),
1554 );
1555 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1556 tui_sel.render_now(true);
1557 }));
1558 let state_cancel = state.clone();
1559 let ec_cancel = editor_container.clone();
1560 let editor_cancel = editor.clone();
1561 let tui_cancel = tui.clone();
1562 list.on_cancel(Arc::new(move || {
1563 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1564 }));
1565
1566 open_selector(
1567 state,
1568 editor_container,
1569 editor,
1570 tui,
1571 list,
1572 SelectorKind::Settings,
1573 );
1574}
1575
1576fn open_settings_model_selector(
1580 state: &Arc<TuiState>,
1581 editor_container: &Arc<Container>,
1582 editor: &Arc<Editor>,
1583 tui: &Arc<TuiAltScreen>,
1584 lane: &Arc<dyn AgentLane>,
1585 catalog: &[rpi_ai::Model],
1586 lane_model_id: &str,
1587 chat: &Arc<Container>,
1588) {
1589 let mut items: Vec<SelectItem> = Vec::new();
1590 for m in catalog {
1591 let label = if m.name.is_empty() {
1592 short_model_name(&m.id)
1593 } else {
1594 m.name.clone()
1595 };
1596 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
1597 " (current)"
1598 } else {
1599 ""
1600 };
1601 items.push(
1602 SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)),
1603 );
1604 }
1605 if items.is_empty() {
1606 add_note_message(chat, "No models in the catalog.");
1607 tui.request_render(false);
1608 return;
1609 }
1610 let list = Arc::new(SelectList::new(items, 10));
1611
1612 let catalog_arc = catalog.to_vec();
1613 let state_sel = state.clone();
1614 let ec_sel = editor_container.clone();
1615 let editor_sel = editor.clone();
1616 let tui_sel = tui.clone();
1617 let chat_sel = chat.clone();
1618 let lane_sel = lane.clone();
1619 list.on_select(Arc::new(move |item| {
1620 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
1621 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
1622 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1623 return;
1624 };
1625 state_sel.set_current_model(&model);
1626 let lane = lane_sel.clone();
1627 tokio::spawn(async move {
1628 let _ = lane.set_model(model).await;
1629 });
1630 let mut settings = crate::settings::load_settings().unwrap_or_default();
1631 settings.default_model = Some(item.value.clone());
1632 let saved = crate::settings::save_settings(&settings);
1633 add_note_message(
1634 &chat_sel,
1635 &format!(
1636 "Default model set to {} (saved{}",
1637 short_model_name(&item.value),
1638 if saved.is_ok() { ")" } else { ", not saved)" },
1639 ),
1640 );
1641 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1642 }));
1643 let state_cancel = state.clone();
1644 let ec_cancel = editor_container.clone();
1645 let editor_cancel = editor.clone();
1646 let tui_cancel = tui.clone();
1647 list.on_cancel(Arc::new(move || {
1648 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1649 }));
1650
1651 open_selector(
1652 state,
1653 editor_container,
1654 editor,
1655 tui,
1656 list,
1657 SelectorKind::Settings,
1658 );
1659}
1660
1661fn open_settings_thinking_selector(
1665 state: &Arc<TuiState>,
1666 editor_container: &Arc<Container>,
1667 editor: &Arc<Editor>,
1668 tui: &Arc<TuiAltScreen>,
1669 lane: &Arc<dyn AgentLane>,
1670 catalog: &[rpi_ai::Model],
1671 lane_model_id: &str,
1672 chat: &Arc<Container>,
1673) {
1674 let model = catalog
1675 .iter()
1676 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
1677 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
1678 .map(|m| m.supported_thinking_levels())
1679 .unwrap_or_else(|| {
1680 use rpi_ai::types::ThinkingLevel::*;
1681 vec![Off, Minimal, Low, Medium, High]
1682 });
1683 let mut items: Vec<SelectItem> = Vec::new();
1684 for lvl in &levels {
1685 let name = thinking_level_name(*lvl);
1686 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
1687 }
1688 if items.is_empty() {
1689 add_note_message(chat, "This model has no supported thinking levels.");
1690 tui.request_render(false);
1691 return;
1692 }
1693 let list = Arc::new(SelectList::new(items, 10));
1694
1695 let state_sel = state.clone();
1696 let ec_sel = editor_container.clone();
1697 let editor_sel = editor.clone();
1698 let tui_sel = tui.clone();
1699 let chat_sel = chat.clone();
1700 let lane_sel = lane.clone();
1701 list.on_select(Arc::new(move |item| {
1702 let Some(level) = thinking_level_from_name(&item.value) else {
1703 add_note_message(
1704 &chat_sel,
1705 &format!("Unknown thinking level: {}.", item.label),
1706 );
1707 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1708 return;
1709 };
1710 let lane = lane_sel.clone();
1711 let footer_sel = state_sel.footer.clone();
1712 tokio::spawn(async move {
1713 let _ = lane.set_thinking_level(level).await;
1714 });
1715 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
1716 let mut settings = crate::settings::load_settings().unwrap_or_default();
1717 settings.default_thinking_level = Some(item.value.clone());
1718 let saved = crate::settings::save_settings(&settings);
1719 add_note_message(
1720 &chat_sel,
1721 &format!(
1722 "Default thinking set to {} (saved{}",
1723 item.label,
1724 if saved.is_ok() { ")" } else { ", not saved)" },
1725 ),
1726 );
1727 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1728 }));
1729 let state_cancel = state.clone();
1730 let ec_cancel = editor_container.clone();
1731 let editor_cancel = editor.clone();
1732 let tui_cancel = tui.clone();
1733 list.on_cancel(Arc::new(move || {
1734 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1735 }));
1736
1737 open_selector(
1738 state,
1739 editor_container,
1740 editor,
1741 tui,
1742 list,
1743 SelectorKind::Settings,
1744 );
1745}
1746
1747fn open_scoped_models_selector(
1752 state: &Arc<TuiState>,
1753 editor_container: &Arc<Container>,
1754 editor: &Arc<Editor>,
1755 tui: &Arc<TuiAltScreen>,
1756 catalog: &[rpi_ai::Model],
1757 chat: &Arc<Container>,
1758) {
1759 if catalog.is_empty() {
1760 add_note_message(chat, "No models in the catalog.");
1761 tui.request_render(false);
1762 return;
1763 }
1764 let seed: Vec<String> = crate::settings::load_settings()
1766 .ok()
1767 .and_then(|s| s.scoped_models)
1768 .unwrap_or_default();
1769 *state.scoped_edit.lock().unwrap() = Some(seed);
1770
1771 let mut items: Vec<SelectItem> = Vec::new();
1772 for m in catalog {
1773 items.push(SelectItem::new(&m.id, &m.id));
1774 }
1775 let list = Arc::new(SelectList::new(items, 10));
1776
1777 let state_sel = state.clone();
1778 let chat_sel = chat.clone();
1779 let tui_sel = tui.clone();
1780 list.on_select(Arc::new(move |item| {
1781 let mut set = state_sel.scoped_edit.lock().unwrap();
1783 let set = set.get_or_insert_with(Vec::new);
1784 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
1785 set.remove(pos);
1786 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
1787 } else {
1788 set.push(item.value.clone());
1789 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
1790 }
1791 tui_sel.request_render(false);
1792 }));
1793 let state_cancel = state.clone();
1794 let ec_cancel = editor_container.clone();
1795 let editor_cancel = editor.clone();
1796 let tui_cancel = tui.clone();
1797 let chat_cancel = chat.clone();
1798 list.on_cancel(Arc::new(move || {
1799 let set = state_cancel
1801 .scoped_edit
1802 .lock()
1803 .unwrap()
1804 .take()
1805 .unwrap_or_default();
1806 let mut settings = crate::settings::load_settings().unwrap_or_default();
1807 settings.scoped_models = if set.is_empty() {
1808 None
1809 } else {
1810 Some(set.clone())
1811 };
1812 match crate::settings::save_settings(&settings) {
1813 Ok(()) => {
1814 if set.is_empty() {
1815 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
1816 } else {
1817 add_note_message(
1818 &chat_cancel,
1819 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
1820 );
1821 }
1822 }
1823 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
1824 }
1825 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1826 }));
1827
1828 open_selector(
1829 state,
1830 editor_container,
1831 editor,
1832 tui,
1833 list,
1834 SelectorKind::ScopedModels,
1835 );
1836}
1837
1838async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
1842 use std::process::Stdio;
1843
1844 let tree = harness.session().view("main");
1846 let entries = match tree
1847 .find_entries(&EntryQuery {
1848 entry_type: None,
1849 custom_type: None,
1850 order: Some(EntryOrder::OldestFirst),
1853 limit: None,
1854 cursor: None,
1855 })
1856 .await
1857 {
1858 Ok(e) => e,
1859 Err(e) => {
1860 add_error_message(chat, &format!("Could not read session: {e}"));
1861 return;
1862 }
1863 };
1864 let mut md = String::from("# Session\n\n");
1865 for e in entries {
1866 let Entry::Message(me) = e else { continue };
1867 match &me.message {
1868 AgentMessage::User(u) => {
1869 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1870 }
1871 AgentMessage::Assistant(a) => {
1872 let text = assistant_text(a);
1873 if !text.is_empty() {
1874 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1875 }
1876 }
1877 _ => {}
1878 }
1879 }
1880
1881 let gh = std::process::Command::new("gh")
1883 .arg("gist")
1884 .arg("create")
1885 .arg("--filename")
1886 .arg("session.md")
1887 .arg("-")
1888 .stdin(Stdio::piped())
1889 .stdout(Stdio::piped())
1890 .stderr(Stdio::null())
1891 .spawn();
1892 if let Ok(mut child) = gh {
1893 use std::io::Write;
1894 if let Some(mut stdin) = child.stdin.take() {
1895 let _ = stdin.write_all(md.as_bytes());
1896 let _ = stdin.flush();
1897 }
1898 let out = child.wait_with_output().ok();
1899 if let Some(out) = out {
1900 if out.status.success() {
1901 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
1902 add_note_message(chat, &format!("Shared session: {url}"));
1903 return;
1904 }
1905 }
1906 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
1907 } else {
1908 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
1909 }
1910 if copy_to_clipboard(&md) {
1912 add_note_message(chat, "Session transcript copied to the clipboard.");
1913 } else {
1914 add_note_message(
1915 chat,
1916 "Clipboard unavailable — use /export to write the transcript to a file.",
1917 );
1918 }
1919}
1920
1921async fn export_session(harness: &AgentHarness, chat: &Arc<Container>) {
1930 let tree = harness.session().view("main");
1931 let entries = match tree
1932 .find_entries(&EntryQuery {
1933 entry_type: None,
1934 custom_type: None,
1935 order: Some(EntryOrder::OldestFirst),
1938 limit: None,
1939 cursor: None,
1940 })
1941 .await
1942 {
1943 Ok(e) => e,
1944 Err(e) => {
1945 add_error_message(chat, &format!("Could not read session: {e}"));
1946 return;
1947 }
1948 };
1949 let name = tree.get_name().await.ok().flatten().unwrap_or_default();
1950 let id = tree
1951 .get_leaf_id()
1952 .await
1953 .ok()
1954 .flatten()
1955 .unwrap_or_else(|| "session".to_string());
1956 let mut md = String::from("# Session\n\n");
1957 for e in entries {
1958 let Entry::Message(me) = e else { continue };
1959 match &me.message {
1960 AgentMessage::User(u) => {
1961 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1962 }
1963 AgentMessage::Assistant(a) => {
1964 let text = assistant_text(a);
1965 if !text.is_empty() {
1966 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1967 }
1968 }
1969 _ => {}
1970 }
1971 }
1972 let file_name = if name.is_empty() {
1973 format!("{id}.md")
1974 } else {
1975 format!("{name}.md")
1976 };
1977 let path = std::env::current_dir()
1978 .unwrap_or_else(|_| std::path::PathBuf::from("."))
1979 .join(&file_name);
1980 match std::fs::write(&path, md) {
1981 Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
1982 Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
1983 }
1984}
1985
1986async fn switch_to_session(
1996 harness: &AgentHarness,
1997 lane: &Arc<dyn AgentLane>,
1998 id: &str,
1999 cwd: &std::path::Path,
2000 chat: &Arc<Container>,
2001 state: &Arc<TuiState>,
2002) -> bool {
2003 if *state.status.lock().unwrap() == RunStatus::Working {
2004 state.set_status(RunStatus::Aborting);
2005 let _ = lane.abort().await;
2006 }
2007 let cwd_str = cwd.to_string_lossy().to_string();
2008 match crate::session::open_session_by_id(id, &cwd_str).await {
2009 Ok(new_session) => {
2010 let _ = harness.set_session(new_session).await;
2011 chat.clear();
2012 add_welcome_message(chat);
2013 render_session_history(
2014 harness,
2015 chat,
2016 state.markdown_transformer(),
2017 Some(state.extension_session.clone()),
2018 )
2019 .await;
2020 state.set_status(RunStatus::Idle);
2021 add_note_message(chat, &format!("Switched to session {id}."));
2022 true
2023 }
2024 Err(e) => {
2025 state.set_status(RunStatus::Idle);
2026 add_error_message(chat, &format!("Could not open session {id}: {e}"));
2027 false
2028 }
2029 }
2030}
2031
2032async fn import_session(
2036 harness: &AgentHarness,
2037 lane: &Arc<dyn AgentLane>,
2038 path: &str,
2039 cwd: &std::path::Path,
2040 chat: &Arc<Container>,
2041 state: &Arc<TuiState>,
2042) {
2043 use std::path::Path as FsPath;
2044
2045 let src = FsPath::new(path);
2046 if !src.is_file() {
2047 add_error_message(chat, &format!("Import source not found: {path}"));
2048 return;
2049 }
2050 let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
2051 add_error_message(chat, "Import source has no file name.");
2052 return;
2053 };
2054 if !fname.ends_with(".jsonl") {
2055 add_error_message(chat, "Import source must be a .jsonl session file.");
2056 return;
2057 }
2058 let dir = crate::session::default_session_dir(cwd);
2059 if let Err(e) = std::fs::create_dir_all(&dir) {
2060 add_error_message(chat, &format!("Could not create session dir: {e}"));
2061 return;
2062 }
2063 let dest = dir.join(fname);
2064 match std::fs::copy(src, &dest) {
2065 Ok(_) => {
2066 let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
2067 if switch_to_session(harness, lane, &id, cwd, chat, state).await {
2068 add_note_message(chat, &format!("Imported session from {path}"));
2069 }
2070 }
2071 Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
2072 }
2073}
2074
2075async fn fork_session(
2076 harness: &AgentHarness,
2077 cwd: &std::path::Path,
2078 chat: &Arc<Container>,
2079 state: &Arc<TuiState>,
2080) {
2081 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2082 use rpi_tools::FileSystem;
2083
2084 let cwd_str = cwd.to_string_lossy().to_string();
2085 let dir = crate::session::default_session_dir(cwd);
2086 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
2087 let fs: Arc<dyn FileSystem> = env.clone();
2088 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2089 fs,
2090 sessions_root: dir.to_string_lossy().into_owned(),
2091 clock: Arc::new(rpi_harness::session::memory::SystemClock),
2092 ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
2093 });
2094 let id = harness.session().storage().metadata().id.clone();
2097 let metas = match crate::session::list_session_metadata(&cwd_str).await {
2098 Ok(m) => m,
2099 Err(e) => {
2100 add_error_message(chat, &format!("Could not list sessions: {e}"));
2101 return;
2102 }
2103 };
2104 let Some(source) = metas.iter().find(|m| m.id == id) else {
2105 add_error_message(chat, &format!("Current session {id} not found on disk."));
2106 return;
2107 };
2108 let fork_storage = match repo
2109 .fork_typed(
2110 source,
2111 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2112 id: None,
2113 parent_session_id: Some(source.id.clone()),
2114 cwd: cwd_str.clone(),
2115 metadata: None,
2116 },
2117 &rpi_harness::session::types::ForkOptions::default(),
2118 )
2119 .await
2120 {
2121 Ok(s) => s,
2122 Err(e) => {
2123 add_error_message(chat, &format!("Could not fork session: {e}"));
2124 return;
2125 }
2126 };
2127 let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
2128 let _ = harness.set_session(new_session).await;
2129 chat.clear();
2130 add_welcome_message(chat);
2131 render_session_history(
2132 harness,
2133 chat,
2134 state.markdown_transformer(),
2135 Some(state.extension_session.clone()),
2136 )
2137 .await;
2138 state.set_status(RunStatus::Idle);
2139 add_note_message(chat, "Forked into a new session.");
2140}
2141
2142async fn render_session_history(
2152 harness: &AgentHarness,
2153 chat: &Arc<Container>,
2154 transformer: Option<MarkdownTransformer>,
2155 extension_session: Option<crate::session::ExtensionSessionCell>,
2156) {
2157 let tree = harness.session().view("main");
2158 let entries = match tree
2159 .find_entries(&EntryQuery {
2160 entry_type: None,
2161 custom_type: None,
2162 order: Some(EntryOrder::OldestFirst),
2166 limit: None,
2167 cursor: None,
2168 })
2169 .await
2170 {
2171 Ok(e) => e,
2172 Err(_) => return,
2173 };
2174 let mut rendered_any = false;
2175 for e in entries {
2176 match e {
2177 Entry::Message(me) => match &me.message {
2178 AgentMessage::User(u) => {
2179 add_user_message(chat, &user_message_text(u));
2180 rendered_any = true;
2181 }
2182 AgentMessage::Assistant(a) => {
2183 let comp = Arc::new(AssistantMessageComponent::new(
2184 AssistantMessageOptions::default(),
2185 ));
2186 if let Some(t) = &transformer {
2187 comp.set_markdown_transformer(Some(t.clone()));
2188 }
2189 comp.update_blocks(&assistant_blocks(a));
2190 chat.add_child(comp);
2191 chat.add_child(Arc::new(Spacer::new(1)));
2194 if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
2195 add_note_message(chat, &text);
2196 }
2197 rendered_any = true;
2198 }
2199 AgentMessage::Custom(custom) => {
2200 if let Some(session) = &extension_session {
2201 if let Some(component) = extension_message_component(
2202 session,
2203 &custom.role,
2204 &serde_json::json!({
2205 "customType": custom.role,
2206 "content": custom.content,
2207 "details": custom.data,
2208 }),
2209 transformer.clone(),
2210 ) {
2211 chat.add_child(component);
2212 chat.add_child(Arc::new(Spacer::new(1)));
2213 rendered_any = true;
2214 continue;
2215 }
2216 }
2217 add_note_message(chat, &custom_message_fallback(&custom));
2218 rendered_any = true;
2219 }
2220 _ => {}
2221 },
2222 Entry::Compaction(compaction) => {
2223 add_note_message(
2224 chat,
2225 &format!(
2226 "Compacted {} tokens: {}",
2227 compaction.tokens_before, compaction.summary
2228 ),
2229 );
2230 rendered_any = true;
2231 }
2232 Entry::BranchSummary(summary) => {
2233 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
2234 rendered_any = true;
2235 }
2236 Entry::Custom(custom) => {
2237 let rendered = extension_session.as_ref().and_then(|session| {
2238 extension_entry_component(session, &custom.custom_type, custom.data.clone())
2239 });
2240 if let Some(component) = rendered {
2241 chat.add_child(component);
2242 chat.add_child(Arc::new(Spacer::new(1)));
2243 rendered_any = true;
2244 } else if let Some(text) =
2245 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
2246 {
2247 add_note_message(chat, &text);
2248 rendered_any = true;
2249 }
2250 }
2251 Entry::ModelChange(change) => {
2252 add_note_message(
2253 chat,
2254 &format!("Model changed to {}:{}", change.provider, change.model_id),
2255 );
2256 rendered_any = true;
2257 }
2258 Entry::ThinkingLevel(change) => {
2259 add_note_message(
2260 chat,
2261 &format!("Thinking level: {:?}", change.thinking_level),
2262 );
2263 rendered_any = true;
2264 }
2265 Entry::ActiveTools(change) => {
2266 add_note_message(
2267 chat,
2268 &format!("Active tools: {}", change.active_tool_names.join(", ")),
2269 );
2270 rendered_any = true;
2271 }
2272 }
2273 }
2274 if rendered_any {
2275 }
2278}
2279
2280fn invoke_extension_renderer(
2281 session: &crate::session::ExtensionSessionCell,
2282 kind: rpi_extensions::RegisteredRendererKind,
2283 payload: &serde_json::Value,
2284) -> Option<serde_json::Value> {
2285 let snapshot = session.lock().ok()?.snapshot_arc()?;
2286 let input = serde_json::to_string(payload).ok()?;
2287 for renderer in snapshot.renderers_of(kind) {
2288 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2289 let mut out = rpi_plugin_sdk::StbString::empty();
2290 let rc = (renderer.render_fn)(
2291 rpi_plugin_sdk::StbStringRef::from_str(&input),
2292 &mut out as *mut rpi_plugin_sdk::StbString,
2293 renderer.user_data,
2294 );
2295 let text = if rc == 0 {
2296 Some(out.to_string_lossy())
2297 } else {
2298 None
2299 };
2300 out.free_with(Some(renderer.plugin_free_string));
2301 text
2302 }))
2303 .ok()
2304 .flatten();
2305 let Some(text) = outcome else { continue };
2306 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
2307 return Some(value);
2308 }
2309 }
2310 None
2311}
2312
2313fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
2314 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
2315 let text = lines
2316 .iter()
2317 .filter_map(|line| line.as_str())
2318 .collect::<Vec<_>>()
2319 .join("\n");
2320 return Some(Arc::new(Text::new(text, 0, 0)));
2321 }
2322 let text = value.get("text").and_then(|v| v.as_str())?;
2323 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2324 let component = Arc::new(AssistantMessageComponent::new(
2325 AssistantMessageOptions::default(),
2326 ));
2327 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2328 Some(component)
2329 } else {
2330 Some(Arc::new(Text::new(text, 0, 0)))
2331 }
2332}
2333
2334fn extension_message_component(
2335 session: &crate::session::ExtensionSessionCell,
2336 custom_type: &str,
2337 payload: &serde_json::Value,
2338 transformer: Option<MarkdownTransformer>,
2339) -> Option<Arc<dyn rpi_tui::Component>> {
2340 let value = invoke_extension_renderer(
2341 session,
2342 rpi_extensions::RegisteredRendererKind::Message,
2343 payload,
2344 )?;
2345 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2346 let text = value.get("text").and_then(|v| v.as_str())?;
2347 let component = Arc::new(AssistantMessageComponent::new(
2348 AssistantMessageOptions::default(),
2349 ));
2350 if let Some(transformer) = transformer {
2351 component.set_markdown_transformer(Some(transformer));
2352 }
2353 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2354 return Some(component);
2355 }
2356 extension_text_component(&value)
2357 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
2358}
2359
2360fn extension_usage_text(
2363 session: Option<&crate::session::ExtensionSessionCell>,
2364 usage: &rpi_ai::types::Usage,
2365) -> Option<String> {
2366 let session = session?;
2367 let payload = serde_json::json!({
2368 "customType": "token-usage",
2369 "usage": usage,
2370 });
2371 let value = invoke_extension_renderer(
2372 session,
2373 rpi_extensions::RegisteredRendererKind::Message,
2374 &payload,
2375 )?;
2376 value
2377 .get("text")
2378 .and_then(|value| value.as_str())
2379 .filter(|text| !text.trim().is_empty())
2380 .map(ToOwned::to_owned)
2381}
2382
2383fn extension_entry_component(
2384 session: &crate::session::ExtensionSessionCell,
2385 custom_type: &str,
2386 data: Option<serde_json::Value>,
2387) -> Option<Arc<dyn rpi_tui::Component>> {
2388 let payload = serde_json::json!({
2389 "customType": custom_type,
2390 "data": data,
2391 });
2392 let value = invoke_extension_renderer(
2393 session,
2394 rpi_extensions::RegisteredRendererKind::Entry,
2395 &payload,
2396 )?;
2397 extension_text_component(&value)
2398}
2399
2400fn launch_restores_history(args: &Args) -> bool {
2406 args.continue_session
2407 || args.resume
2408 || args.session.is_some()
2409 || args.session_id.is_some()
2410 || args.fork.is_some()
2411}
2412
2413fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
2414 msg.content
2415 .iter()
2416 .filter_map(|c| match c {
2417 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
2418 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
2419 Content::Image(image) => base64::engine::general_purpose::STANDARD
2420 .decode(&image.data)
2421 .ok()
2422 .filter(|data| !data.is_empty())
2423 .map(AssistantBlock::Image),
2424 _ => None,
2425 })
2426 .collect()
2427}
2428
2429fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
2430 let content = custom
2431 .content
2432 .iter()
2433 .filter_map(|item| match item {
2434 Content::Text(text) => Some(text.text.as_str()),
2435 _ => None,
2436 })
2437 .collect::<Vec<_>>()
2438 .join("\n");
2439 if content.is_empty() {
2440 format!("{}: {}", custom.role, custom.data)
2441 } else {
2442 format!("{}: {}", custom.role, content)
2443 }
2444}
2445
2446fn short_model_name(id: &str) -> String {
2449 id.rsplit([':', '/'])
2450 .next()
2451 .filter(|s| !s.is_empty())
2452 .unwrap_or(id)
2453 .to_string()
2454}
2455
2456#[derive(Clone, Copy, PartialEq, Eq)]
2462enum RunStatus {
2463 Idle,
2464 Working,
2465 Aborting,
2466}
2467
2468#[derive(Clone, Copy, PartialEq, Eq)]
2470enum SelectorKind {
2471 Model,
2473 Thinking,
2475 Tools,
2477 Images,
2479 Session,
2481 Theme,
2483 ScopedModels,
2485 Settings,
2487 Tree,
2489 Extension,
2491}
2492
2493struct TuiState {
2496 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
2498 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
2500 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
2504 last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
2508 status: std::sync::Mutex<RunStatus>,
2510 footer: Arc<FooterComponent>,
2512 status_container: Arc<Container>,
2515 chat_container: Arc<Container>,
2517 loader: Arc<Loader>,
2519 last_assistant_text: std::sync::Mutex<String>,
2522 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
2526 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
2528 autocomplete: AutocompleteManager,
2531 autocomplete_container: Arc<Container>,
2534 theme_manager: Arc<ThemeManager>,
2538 tui: Option<Arc<TuiAltScreen>>,
2542 current_model_id: std::sync::Mutex<String>,
2546 show_images: std::sync::Mutex<bool>,
2550 history: std::sync::Mutex<Vec<String>>,
2553 history_index: std::sync::Mutex<isize>,
2556 history_draft: std::sync::Mutex<Option<String>>,
2559 last_input_tokens: std::sync::Mutex<i64>,
2563 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
2566 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
2576 extension_session: crate::session::ExtensionSessionCell,
2578}
2579
2580const HISTORY_LIMIT: usize = 100;
2583
2584const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
2587
2588const PAGE_SCROLL_OVERLAP: usize = 4;
2591
2592const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
2596
2597fn transcript_page_size(viewport_height: usize) -> i32 {
2598 viewport_height
2599 .saturating_sub(PAGE_SCROLL_OVERLAP)
2600 .max(1)
2601 .min(i32::MAX as usize) as i32
2602}
2603
2604fn should_dispatch_key(kind: KeyEventKind) -> bool {
2605 kind != KeyEventKind::Release
2606}
2607
2608fn format_tokens(n: i64) -> String {
2610 if n >= 1_000_000 {
2611 format!("{:.1}M", n as f64 / 1_000_000.0)
2612 } else if n >= 1_000 {
2613 format!("{:.1}K", n as f64 / 1_000.0)
2614 } else {
2615 n.to_string()
2616 }
2617}
2618
2619fn push_history(state: &Arc<TuiState>, text: &str) {
2623 let trimmed = text.trim().to_string();
2624 if trimmed.is_empty() {
2625 return;
2626 }
2627 let mut history = state.history.lock().unwrap();
2628 if history.first() == Some(&trimmed) {
2629 return;
2630 }
2631 history.insert(0, trimmed);
2632 history.truncate(HISTORY_LIMIT);
2633 *state.history_index.lock().unwrap() = -1;
2634 *state.history_draft.lock().unwrap() = None;
2635}
2636
2637fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
2642 let history = state.history.lock().unwrap();
2643 if history.is_empty() {
2644 return;
2645 }
2646 let mut index = state.history_index.lock().unwrap();
2647 let new_index = *index - direction as isize;
2648 if new_index < -1 || new_index >= history.len() as isize {
2649 return;
2650 }
2651 if *index == -1 && new_index >= 0 {
2652 *state.history_draft.lock().unwrap() = Some(editor.get_text());
2654 }
2655 *index = new_index;
2656 if new_index == -1 {
2657 let draft = state.history_draft.lock().unwrap().take();
2659 match draft {
2660 Some(d) => {
2661 let len = d.len();
2662 editor.set_text(&d);
2663 editor.set_cursor(0, len);
2664 }
2665 None => editor.set_text(""),
2666 }
2667 } else {
2668 let text = history[new_index as usize].clone();
2669 let len = text.len();
2670 editor.set_text(&text);
2671 editor.set_cursor(0, len);
2672 }
2673}
2674
2675impl TuiState {
2676 fn set_status(&self, status: RunStatus) {
2677 *self.status.lock().unwrap() = status;
2678 self.apply_status(status);
2679 }
2680
2681 fn try_start_working(&self) -> bool {
2686 let mut status = self.status.lock().unwrap();
2687 if *status != RunStatus::Idle {
2688 return false;
2689 }
2690 *status = RunStatus::Working;
2691 drop(status);
2692 self.apply_status(RunStatus::Working);
2693 true
2694 }
2695
2696 fn apply_status(&self, status: RunStatus) {
2697 match status {
2698 RunStatus::Working => {
2699 self.footer.set_status("Working…");
2700 if let Some(tui) = &self.tui {
2703 tui.set_title("rpi — working");
2704 }
2705 self.status_container.clear();
2706 self.loader.start();
2707 self.status_container.add_child(self.loader.clone());
2708 }
2709 RunStatus::Aborting => {
2710 self.footer.set_status("Aborting…");
2711 self.loader.stop();
2714 self.status_container.clear();
2715 }
2716 RunStatus::Idle => {
2717 self.footer.set_status("");
2718 if let Some(tui) = &self.tui {
2719 tui.set_title("rpi");
2720 }
2721 self.loader.stop();
2722 self.status_container.clear();
2723 }
2724 }
2725 }
2726
2727 fn sync_working_loader_with_bash(&self) {
2731 if *self.status.lock().unwrap() != RunStatus::Working {
2732 return;
2733 }
2734
2735 self.status_container.clear();
2736 if self.bash_components.lock().unwrap().is_empty() {
2737 self.status_container.add_child(self.loader.clone());
2738 }
2739 }
2740
2741 fn selector_open(&self) -> bool {
2743 self.active_selector.lock().unwrap().is_some()
2744 }
2745
2746 fn extension_editor_open(&self) -> bool {
2747 self.active_extension_editor.lock().unwrap().is_some()
2748 }
2749
2750 fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
2753 *self.last_tool_comp.lock().unwrap() = Some(comp);
2754 }
2755
2756 fn toggle_expand_last_tool(&self) -> bool {
2762 if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
2763 let cur = comp.is_expanded();
2764 comp.set_expanded(!cur);
2765 true
2766 } else {
2767 false
2768 }
2769 }
2770
2771 fn current_model_id(&self) -> String {
2773 self.current_model_id.lock().unwrap().clone()
2774 }
2775
2776 fn set_current_model(&self, model: &rpi_ai::Model) {
2779 *self.current_model_id.lock().unwrap() = model.id.clone();
2780 self.footer.set_model(&short_model_name(&model.id));
2781 }
2782
2783 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
2787 self.markdown_transformer.lock().unwrap().clone()
2788 }
2789
2790 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
2799 *self.markdown_transformer.lock().unwrap() = transformer.clone();
2800 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
2801 comp.set_markdown_transformer(transformer);
2802 }
2803 }
2804}
2805
2806pub async fn interactive_tui(
2824 harness: &AgentHarness,
2825 event_rx: Option<broadcast::Receiver<AgentEvent>>,
2826 args: &Args,
2827 model_catalog: Vec<rpi_ai::Model>,
2828 initial: Option<String>,
2829 extra_messages: &[String],
2830 theme: Option<&str>,
2831 reload_context: &crate::session::ReloadContext,
2832) -> i32 {
2833 let lane: Arc<dyn AgentLane> = harness.lane("main");
2834
2835 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
2839 let model_name = short_model_name(&lane_model_id);
2840
2841 let cwd = std::env::current_dir()
2843 .map(|p| p.to_path_buf())
2844 .unwrap_or_else(|_| std::path::PathBuf::from("."));
2845
2846 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
2848
2849 if let Some(preset) = match theme {
2853 Some("light") => Some(ThemePreset::Light),
2854 Some("monochrome") => Some(ThemePreset::Monochrome),
2855 Some("dark") => Some(ThemePreset::Dark),
2856 _ => None,
2857 } {
2858 apply_theme_preset(preset);
2859 }
2860
2861 let terminal = Box::new(ProcessTerminal::new());
2863 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
2864
2865 let chat_container = Arc::new(Container::new());
2866 add_welcome_message(&chat_container);
2867
2868 crate::extras::maybe_first_time_setup(&chat_container);
2874
2875 let initial_transformer = build_markdown_transformer(
2880 reload_context
2881 .extension_session
2882 .lock()
2883 .unwrap()
2884 .snapshot_arc(),
2885 );
2886 if launch_restores_history(args) {
2890 render_session_history(
2891 &harness,
2892 &chat_container,
2893 initial_transformer.clone(),
2894 Some(reload_context.extension_session.clone()),
2895 )
2896 .await;
2897 }
2898
2899 let document_container = Arc::new(Container::new());
2902 document_container.add_child(chat_container.clone());
2903
2904 let scroll_view = Arc::new(ScrollView::new(
2905 document_container.clone(),
2906 ScrollViewOptions {
2907 follow: FollowMode::End,
2908 primary: true,
2909 overscroll: OverscrollMode::Chain,
2910 scrollbar: ScrollbarMode::Hidden,
2914 ..Default::default()
2915 },
2916 ));
2917
2918 let editor = Arc::new(Editor::new(
2923 EditorOptions {
2924 padding_x: 1,
2925 ..Default::default()
2926 },
2927 EditorStyle::default(),
2928 Arc::new(rpi_tui::Keybindings::new()),
2929 ));
2930
2931 let footer = Arc::new(FooterComponent::new());
2933 footer.set_model(&model_name);
2934 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");
2935
2936 let status_container = Arc::new(Container::new());
2937 let loader = Arc::new(Loader::with_text("Working…"));
2938
2939 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
2953 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
2954 .prompt_templates
2955 .clone()
2956 .unwrap_or_default()
2957 .iter()
2958 .map(|t| SlashCommandEntry {
2959 name: format!("/{}", t.name),
2960 description: t
2961 .description
2962 .clone()
2963 .unwrap_or_else(|| "Expand prompt template".to_string()),
2964 })
2965 .collect();
2966 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
2967 Arc::new(resources_snapshot);
2968 let mut command_registry = build_builtin_registry();
2974 register_extension_commands(
2975 &mut command_registry,
2976 reload_context.extension_session.clone(),
2977 );
2978 let registry = Arc::new(command_registry);
2979 let mut all_slash_commands = registry.visible_entries();
2980 all_slash_commands.extend(template_slash_commands);
2981 let autocomplete = AutocompleteManager::new();
2982 {
2983 let mut combined = CombinedAutocompleteProvider::new();
2984 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2985 all_slash_commands,
2986 )));
2987 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
2988 cwd.clone(),
2989 )));
2990 autocomplete.set_provider(Arc::new(combined));
2991 }
2992 let autocomplete_container = Arc::new(Container::new());
2993
2994 let state = Arc::new(TuiState {
2995 current_assistant: std::sync::Mutex::new(None),
2996 tool_components: std::sync::Mutex::new(HashMap::new()),
2997 bash_components: std::sync::Mutex::new(HashMap::new()),
2998 last_tool_comp: std::sync::Mutex::new(None),
2999 status: std::sync::Mutex::new(RunStatus::Idle),
3000 footer: footer.clone(),
3001 status_container: status_container.clone(),
3002 chat_container: chat_container.clone(),
3003 loader: loader.clone(),
3004 last_assistant_text: std::sync::Mutex::new(String::new()),
3005 active_selector: std::sync::Mutex::new(None),
3006 active_extension_editor: std::sync::Mutex::new(None),
3007 autocomplete,
3008 autocomplete_container: autocomplete_container.clone(),
3009 theme_manager: Arc::new(ThemeManager::new()),
3010 tui: Some(tui.clone()),
3011 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
3012 show_images: std::sync::Mutex::new(true),
3013 history: std::sync::Mutex::new(Vec::new()),
3014 history_index: std::sync::Mutex::new(-1),
3015 history_draft: std::sync::Mutex::new(None),
3016 last_input_tokens: std::sync::Mutex::new(0),
3017 scoped_edit: std::sync::Mutex::new(None),
3018 markdown_transformer: std::sync::Mutex::new(initial_transformer),
3019 extension_session: reload_context.extension_session.clone(),
3020 });
3021
3022 let model_catalog_arc = Arc::new(model_catalog.clone());
3025 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
3026
3027 let editor_container = Arc::new(Container::new());
3038 editor_container.add_child(editor.clone());
3039
3040 let dock = Arc::new(VStack::from_children(vec![
3041 StackChild::Entry(StackEntry::new(status_container.clone())),
3042 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
3043 StackChild::Entry(
3044 StackEntry::new(editor_container.clone())
3045 .shrink(0)
3046 .min_size(3),
3047 ),
3048 StackChild::Entry(StackEntry::new(footer.clone())),
3049 ]));
3050
3051 let root = VStack::from_children(vec![
3052 StackChild::Entry(
3053 StackEntry::new(scroll_view.clone())
3054 .basis(0)
3055 .grow(1)
3056 .shrink(1)
3057 .min_size(1),
3058 ),
3059 StackChild::Entry(StackEntry::new(dock).shrink(1)),
3060 ]);
3061
3062 tui.set_layout_root(Some(Arc::new(root)));
3063 tui.set_focus(Some(editor.clone()));
3064 editor.set_focused(true);
3065
3066 let ctx = CommandContext {
3078 chat: chat_container.clone(),
3079 tui: tui.clone(),
3080 tx: tx.clone(),
3081 state: state.clone(),
3082 editor: editor.clone(),
3083 editor_container: editor_container.clone(),
3084 lane: lane.clone(),
3085 model_catalog: model_catalog_arc.clone(),
3086 lane_model_id: lane_model_id.clone(),
3087 cwd: cwd.clone(),
3088 resources: resources_arc.clone(),
3089 reload_context: Arc::new(reload_context.clone()),
3090 };
3091 let ctx_for_cb = ctx.clone();
3092 let registry_for_cb = registry.clone();
3093 editor.on_submit(Arc::new(move |text: &str| {
3094 let text = text.trim();
3095 if text.is_empty() {
3096 return;
3097 }
3098
3099 if text.starts_with('/') {
3100 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
3101 return;
3102 }
3103
3104 let run_status = *ctx_for_cb.state.status.lock().unwrap();
3105 if run_status != RunStatus::Idle {
3106 let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
3107 let aborting = run_status == RunStatus::Aborting;
3108 let lane = ctx_for_cb.lane.clone();
3109 let chat = ctx_for_cb.chat.clone();
3110 let tui = ctx_for_cb.tui.clone();
3111 tokio::spawn(async move {
3112 let result = if aborting {
3117 lane.next_run(message).await
3118 } else {
3119 lane.steer(message).await
3120 };
3121 if let Err(error) = result {
3122 add_error_message(&chat, &format!("Could not queue message: {error}"));
3123 tui.request_render(false);
3124 }
3125 });
3126 add_note_message(
3127 &ctx_for_cb.chat,
3128 &format!("Queued steering message: {text}"),
3129 );
3130 ctx_for_cb.tui.request_render(false);
3131 return;
3132 }
3133
3134 if !ctx_for_cb.state.try_start_working() {
3135 return;
3136 }
3137
3138 add_user_message(&ctx_for_cb.chat, text);
3139 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
3142 scroll.scroll_to_end();
3143 }
3144 ctx_for_cb.tui.request_render(false);
3145 push_history(&ctx_for_cb.state, text);
3148 if ctx_for_cb
3149 .tx
3150 .send(TuiMessage::UserInput(text.to_string()))
3151 .is_err()
3152 {
3153 ctx_for_cb.state.set_status(RunStatus::Idle);
3154 }
3155 }));
3156
3157 tui.start_readerless();
3158
3159 let drain_handle = if let Some(rx) = event_rx {
3161 let tui_drain = tui.clone();
3162 let state_drain = state.clone();
3163 let chat_drain = chat_container.clone();
3164 Some(tokio::spawn(async move {
3165 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
3166 }))
3167 } else {
3168 None
3169 };
3170
3171 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
3181 reload_context.mailbox.install(reload_sig_tx);
3182 let reload_tx = tx.clone();
3183 let reload_bridge_handle = tokio::spawn(async move {
3184 while reload_sig_rx.recv().await.is_some() {
3185 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
3186 break; }
3188 }
3189 });
3190
3191 let tui_tick = tui.clone();
3196 let state_tick = state.clone();
3197 let tick_handle = tokio::spawn(async move {
3198 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
3201 interval.tick().await; loop {
3203 interval.tick().await;
3204 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
3205 if working {
3206 if state_tick.bash_components.lock().unwrap().is_empty() {
3207 tui_tick.request_render_reusing_scroll_content();
3211 } else {
3212 tui_tick.request_render(false);
3214 }
3215 }
3216 }
3217 });
3218
3219 let running = Arc::new(std::sync::Mutex::new(true));
3221 let running_key = running.clone();
3222 let tx_for_key = tx.clone();
3223 let tui_for_key = tui.clone();
3224 let editor_for_key = editor.clone();
3225 let scroll_for_key = scroll_view.clone();
3226 let lane_for_key = lane.clone();
3227 let state_for_key = state.clone();
3228 let ctx_for_key = ctx.clone();
3232 let registry_for_key = registry.clone();
3233
3234 let key_handle = tokio::task::spawn_blocking(move || {
3235 loop {
3236 if !*running_key.lock().unwrap() {
3237 break;
3238 }
3239 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
3242 Ok(true) => {}
3243 Ok(false) => continue,
3244 Err(_) => {
3245 let _ = tx_for_key.send(TuiMessage::Exit);
3246 break;
3247 }
3248 }
3249 let Ok(ev) = crossterm::event::read() else {
3250 let _ = tx_for_key.send(TuiMessage::Exit);
3251 break;
3252 };
3253 if let Event::Resize(_cols, _rows) = ev {
3258 tui_for_key.refresh_size();
3259 continue;
3260 }
3261 if let Event::Mouse(m) = ev {
3265 use crossterm::event::MouseEventKind;
3266 match m.kind {
3267 MouseEventKind::ScrollUp => {
3268 let delta = -MOUSE_WHEEL_SCROLL_LINES;
3269 if scroll_for_key.scroll_by(delta) != delta {
3270 tui_for_key.request_render_reusing_scroll_content();
3271 }
3272 }
3273 MouseEventKind::ScrollDown => {
3274 let delta = MOUSE_WHEEL_SCROLL_LINES;
3275 if scroll_for_key.scroll_by(delta) != delta {
3276 tui_for_key.request_render_reusing_scroll_content();
3277 }
3278 }
3279 _ => {}
3280 }
3281 continue;
3282 }
3283 let Event::Key(key) = ev else {
3284 continue;
3285 };
3286 if !should_dispatch_key(key.kind) {
3291 continue;
3292 }
3293
3294 if state_for_key.extension_editor_open()
3295 && key.modifiers == KeyModifiers::CONTROL
3296 && key.code == KeyCode::Char('c')
3297 {
3298 close_extension_editor(
3299 &state_for_key,
3300 &ctx_for_key.editor_container,
3301 &editor_for_key,
3302 );
3303 tui_for_key.request_render_reusing_scroll_content();
3304 continue;
3305 }
3306
3307 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
3312 if !state_for_key.selector_open() && editor_for_key.has_selection() {
3313 editor_for_key.copy_selection();
3314 continue;
3315 }
3316 let status = *state_for_key.status.lock().unwrap();
3317 match status {
3318 RunStatus::Working => {
3319 state_for_key.set_status(RunStatus::Aborting);
3320 let lane = lane_for_key.clone();
3321 tokio::spawn(async move {
3322 let _ = lane.abort().await;
3323 });
3324 }
3325 RunStatus::Aborting => {}
3329 RunStatus::Idle => {
3330 let _ = tx_for_key.send(TuiMessage::Exit);
3331 }
3332 }
3333 continue;
3334 }
3335
3336 if state_for_key.selector_open() {
3341 if key.code == KeyCode::Esc {
3347 let (selector, _kind) = state_for_key
3348 .active_selector
3349 .lock()
3350 .unwrap()
3351 .clone()
3352 .expect("selector_open guaranteed Some");
3353 selector.handle_key(key);
3354 continue;
3355 }
3356 let (selector, _kind) = state_for_key
3357 .active_selector
3358 .lock()
3359 .unwrap()
3360 .clone()
3361 .expect("selector_open guaranteed Some");
3362 selector.handle_key(key);
3363 tui_for_key.request_render_reusing_scroll_content();
3364 continue;
3365 }
3366
3367 if state_for_key.extension_editor_open() {
3371 let extension_editor = state_for_key
3372 .active_extension_editor
3373 .lock()
3374 .unwrap()
3375 .clone()
3376 .expect("extension_editor_open guaranteed Some");
3377 if key.code == KeyCode::Esc {
3378 close_extension_editor(
3379 &state_for_key,
3380 &ctx_for_key.editor_container,
3381 &editor_for_key,
3382 );
3383 } else {
3384 extension_editor.handle_key(key);
3385 }
3386 tui_for_key.request_render_reusing_scroll_content();
3387 continue;
3388 }
3389
3390 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
3395 let status = *state_for_key.status.lock().unwrap();
3396 match status {
3397 RunStatus::Working => {
3398 state_for_key.set_status(RunStatus::Aborting);
3399 let lane = lane_for_key.clone();
3400 tokio::spawn(async move {
3401 let _ = lane.abort().await;
3402 });
3403 continue;
3404 }
3405 RunStatus::Aborting => continue,
3406 RunStatus::Idle => {}
3407 }
3408 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
3409 editor_for_key.handle_key(key);
3411 refresh_autocomplete(&state_for_key, &editor_for_key);
3412 tui_for_key.request_render_reusing_scroll_content();
3413 continue;
3414 }
3415 let _ = tx_for_key.send(TuiMessage::Exit);
3416 continue;
3417 }
3418
3419 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
3424 let status = *state_for_key.status.lock().unwrap();
3425 if status == RunStatus::Working {
3426 state_for_key.set_status(RunStatus::Aborting);
3427 let lane = lane_for_key.clone();
3428 tokio::spawn(async move {
3429 let _ = lane.abort().await;
3430 });
3431 continue;
3432 }
3433 }
3434
3435 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
3440 state_for_key.toggle_expand_last_tool();
3441 tui_for_key.request_render(false);
3442 continue;
3443 }
3444
3445 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
3451 let current = state_for_key.current_model_id();
3452 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
3455 if let Some(next) = cycle_next_model(&scope, ¤t) {
3456 state_for_key.set_current_model(&next);
3457 let lane = lane_for_key.clone();
3458 tokio::spawn(async move {
3459 let _ = lane.set_model(next).await;
3460 });
3461 tui_for_key.request_render_reusing_scroll_content();
3462 }
3463 continue;
3464 }
3465
3466 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
3470 if let Some(cmd) = registry_for_key.find("/model") {
3471 cmd.execute(&ctx_for_key, "");
3472 }
3473 continue;
3474 }
3475
3476 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
3478 if accept_top_suggestion(&state_for_key, &editor_for_key) {
3479 tui_for_key.request_render_reusing_scroll_content();
3480 }
3481 continue;
3482 }
3483
3484 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
3488 let delta = -transcript_page_size(scroll_for_key.viewport_height());
3489 if scroll_for_key.scroll_by(delta) != delta {
3490 tui_for_key.request_render_reusing_scroll_content();
3491 }
3492 continue;
3493 }
3494 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
3495 let delta = transcript_page_size(scroll_for_key.viewport_height());
3496 if scroll_for_key.scroll_by(delta) != delta {
3497 tui_for_key.request_render_reusing_scroll_content();
3498 }
3499 continue;
3500 }
3501 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
3502 scroll_for_key.scroll_to_start();
3503 tui_for_key.request_render_reusing_scroll_content();
3504 continue;
3505 }
3506 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
3507 scroll_for_key.scroll_to_end();
3508 tui_for_key.request_render_reusing_scroll_content();
3509 continue;
3510 }
3511
3512 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
3521 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3522 if editor_for_key.get_text().is_empty() || browsing {
3523 navigate_history(&state_for_key, &editor_for_key, -1);
3524 tui_for_key.request_render_reusing_scroll_content();
3525 continue;
3526 }
3527 }
3528 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
3529 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3530 if editor_for_key.get_text().is_empty() || browsing {
3531 navigate_history(&state_for_key, &editor_for_key, 1);
3532 tui_for_key.request_render_reusing_scroll_content();
3533 continue;
3534 }
3535 }
3536
3537 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
3541 let prompt = editor_for_key.get_text().trim().to_string();
3542 if prompt.is_empty() {
3543 continue;
3544 }
3545 editor_for_key.clear();
3546 let status = *state_for_key.status.lock().unwrap();
3547 if status == RunStatus::Idle {
3548 if state_for_key.try_start_working() {
3549 add_user_message(&state_for_key.chat_container, &prompt);
3550 push_history(&state_for_key, &prompt);
3551 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
3552 }
3553 } else {
3554 add_note_message(
3555 &state_for_key.chat_container,
3556 &format!("Queued follow-up message: {prompt}"),
3557 );
3558 let message = AgentMessage::User(UserMessage::new(prompt, 0));
3559 let lane = lane_for_key.clone();
3560 let chat = state_for_key.chat_container.clone();
3561 let tui = tui_for_key.clone();
3562 tokio::spawn(async move {
3563 if let Err(error) = lane.follow_up(message).await {
3564 add_error_message(&chat, &format!("Could not queue message: {error}"));
3565 tui.request_render(false);
3566 }
3567 });
3568 }
3569 tui_for_key.request_render(false);
3570 continue;
3571 }
3572
3573 editor_for_key.handle_key(key);
3575 refresh_autocomplete(&state_for_key, &editor_for_key);
3576 tui_for_key.request_render_reusing_scroll_content();
3577 }
3578 });
3579
3580 let mut prompts: Vec<String> = Vec::new();
3582 if let Some(init) = initial {
3583 prompts.push(init);
3584 }
3585 for m in extra_messages {
3586 prompts.push(m.clone());
3587 }
3588 for prompt in prompts {
3589 if !*running.lock().unwrap() {
3590 break;
3591 }
3592 add_user_message(&chat_container, &prompt);
3593 tui.request_render(false);
3594 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3595 }
3596
3597 loop {
3599 if !*running.lock().unwrap() {
3600 break;
3601 }
3602 match rx.recv().await {
3603 Some(TuiMessage::UserInput(prompt)) => {
3604 editor.clear();
3609 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3610 }
3611 Some(TuiMessage::OpenTree) => {
3612 if *state.status.lock().unwrap() != RunStatus::Idle {
3613 add_note_message(
3614 &chat_container,
3615 "Wait for the current run to finish before opening the tree.",
3616 );
3617 tui.request_render(false);
3618 } else {
3619 open_tree_selector(
3620 &harness,
3621 &state,
3622 &editor_container,
3623 &editor,
3624 &tui,
3625 &chat_container,
3626 &tx,
3627 )
3628 .await;
3629 }
3630 }
3631 Some(TuiMessage::NavigateTree(entry_id)) => {
3632 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
3633 Ok(result) => match result.outcome {
3634 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
3635 chat_container.clear();
3636 add_welcome_message(&chat_container);
3637 render_session_history(
3638 &harness,
3639 &chat_container,
3640 state.markdown_transformer(),
3641 Some(state.extension_session.clone()),
3642 )
3643 .await;
3644 add_note_message(
3645 &chat_container,
3646 "Moved to the selected session entry.",
3647 );
3648 }
3649 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
3650 add_error_message(&chat_container, &error.message);
3651 }
3652 _ => add_note_message(
3653 &chat_container,
3654 "The selected entry could not be opened.",
3655 ),
3656 },
3657 Err(error) => add_error_message(
3658 &chat_container,
3659 &format!("Could not navigate session tree: {error}"),
3660 ),
3661 }
3662 tui.request_render(false);
3663 }
3664 Some(TuiMessage::ClearChat) => {
3665 chat_container.clear();
3666 add_welcome_message(&chat_container);
3667 tui.request_render(false);
3668 }
3669 Some(TuiMessage::Compact) => {
3670 run_compact(&lane, &tui, &state).await;
3671 }
3672 Some(TuiMessage::Copy) => {
3673 copy_last_assistant(&state, &chat_container);
3674 tui.request_render(false);
3675 }
3676 Some(TuiMessage::Exit) => {
3677 *running.lock().unwrap() = false;
3678 break;
3679 }
3680 Some(TuiMessage::SwitchSession(id)) => {
3681 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
3682 tui.request_render(false);
3683 }
3684 Some(TuiMessage::ImportSession(path)) => {
3685 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
3686 tui.request_render(false);
3687 }
3688 Some(TuiMessage::ShareSession) => {
3689 share_session(&harness, &chat_container).await;
3690 tui.request_render(false);
3691 }
3692 Some(TuiMessage::SetSessionName(name)) => {
3693 let outcome = harness.session().set_name(Some(&name)).await;
3694 match outcome {
3695 Ok(_) => add_note_message(
3696 &chat_container,
3697 &format!("Session renamed to \"{name}\"."),
3698 ),
3699 Err(e) => add_error_message(
3700 &chat_container,
3701 &format!("Could not rename session: {e}"),
3702 ),
3703 }
3704 tui.request_render(false);
3705 }
3706 Some(TuiMessage::ExportSession) => {
3707 export_session(&harness, &chat_container).await;
3708 tui.request_render(false);
3709 }
3710 Some(TuiMessage::ForkSession) => {
3711 fork_session(&harness, &cwd, &chat_container, &state).await;
3712 tui.request_render(false);
3713 }
3714 Some(TuiMessage::ReloadExtensions) => {
3715 let reload_ctx = ctx.reload_context.clone();
3722 add_note_message(&chat_container, "Reloading extensions + resources…");
3723 tui.request_render(false);
3724 let outcome =
3725 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
3726 let fresh_transformer = build_markdown_transformer(
3735 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
3736 );
3737 state.set_markdown_transformer_with_reinstall(fresh_transformer);
3738 if outcome.had_warnings {
3739 add_error_message(
3740 &chat_container,
3741 &format!(
3742 "{} (with warnings — see stderr for details).",
3743 outcome.summary
3744 ),
3745 );
3746 } else {
3747 add_note_message(&chat_container, &outcome.summary);
3748 }
3749 tui.request_render(false);
3750 }
3751 None => break,
3752 }
3753 }
3754
3755 *running.lock().unwrap() = false;
3757 let _ = key_handle.await;
3760 tick_handle.abort();
3761 if let Some(handle) = drain_handle {
3762 handle.abort();
3763 }
3764 reload_context.mailbox.clear();
3768 reload_bridge_handle.abort();
3769 tui.stop(Default::default());
3770 println!("\nGoodbye!");
3771 let _ = args;
3772
3773 0
3774}
3775
3776async fn run_prompt_streaming(
3785 lane: &Arc<dyn AgentLane>,
3786 prompt: &str,
3787 tui: &Arc<TuiAltScreen>,
3788 state: &Arc<TuiState>,
3789 streaming: bool,
3790) {
3791 state.set_status(RunStatus::Working);
3793 tui.request_render(false);
3794
3795 let outcome = lane.prompt_text(prompt, Vec::new()).await;
3796
3797 {
3801 let mut cur = state.current_assistant.lock().unwrap();
3802 if let Some(comp) = cur.take() {
3803 comp.set_streaming(false);
3804 }
3805 }
3806
3807 state.set_status(RunStatus::Idle);
3808
3809 match outcome {
3810 Ok(result) => match &result.outcome {
3811 HarnessRunOutcome::Failed {
3812 error,
3813 final_message,
3814 ..
3815 } => {
3816 let already_rendered = final_message.is_some();
3820 if !already_rendered {
3821 let msg = final_message
3822 .as_ref()
3823 .and_then(|m| m.error_message.clone())
3824 .unwrap_or_else(|| format!("{error:?}"));
3825 add_error_message(&state.chat_container, &msg);
3826 }
3827 }
3828 HarnessRunOutcome::Suspended { .. } => {
3829 add_error_message(
3830 &state.chat_container,
3831 "Run suspended (deferred) — resume is not supported in v1.",
3832 );
3833 }
3834 HarnessRunOutcome::Aborted { final_message, .. } => {
3835 if !streaming {
3838 add_error_message(&state.chat_container, "Request aborted.");
3839 let _ = final_message; }
3841 }
3842 HarnessRunOutcome::Completed { final_message, .. } => {
3843 if !streaming {
3844 let text = assistant_text(final_message);
3845 if !text.is_empty() {
3846 add_assistant_message_blocking(
3847 &state.chat_container,
3848 &text,
3849 state.markdown_transformer(),
3850 );
3851 *state.last_assistant_text.lock().unwrap() = text;
3852 }
3853 }
3854 }
3855 },
3856 Err(e) => {
3857 add_error_message(&state.chat_container, &e.to_string());
3858 }
3859 }
3860
3861 tui.request_render(false);
3862}
3863
3864async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
3869 state.set_status(RunStatus::Working);
3870 tui.request_render(false);
3871 match lane.compact(None).await {
3872 Ok(_) => {
3873 add_note_message(&state.chat_container, "Conversation compacted.");
3874 }
3875 Err(e) => {
3876 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
3877 }
3878 }
3879 state.set_status(RunStatus::Idle);
3880 tui.request_render(false);
3881}
3882
3883fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
3887 let text = state.last_assistant_text.lock().unwrap().clone();
3888 if text.is_empty() {
3889 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
3890 return;
3891 }
3892 if copy_to_clipboard(&text) {
3893 add_note_message(chat, "Copied last reply to the clipboard.");
3894 } else {
3895 let preview: String = text.chars().take(200).collect();
3898 add_note_message(
3899 chat,
3900 &format!(
3901 "Clipboard unavailable. Last reply: {preview}{}",
3902 if text.chars().count() > 200 {
3903 "…"
3904 } else {
3905 ""
3906 }
3907 ),
3908 );
3909 }
3910}
3911
3912#[cfg(feature = "clipboard")]
3915fn copy_to_clipboard(text: &str) -> bool {
3916 match arboard::Clipboard::new() {
3917 Ok(mut cb) => cb.set_text(text).is_ok(),
3918 Err(_) => false,
3919 }
3920}
3921
3922#[cfg(not(feature = "clipboard"))]
3923fn copy_to_clipboard(_text: &str) -> bool {
3924 false
3925}
3926
3927fn add_assistant_message_blocking(
3934 container: &Arc<Container>,
3935 text: &str,
3936 transformer: Option<MarkdownTransformer>,
3937) {
3938 if text.is_empty() {
3939 return;
3940 }
3941 let msg = Arc::new(AssistantMessageComponent::new(
3942 AssistantMessageOptions::default(),
3943 ));
3944 if let Some(t) = &transformer {
3945 msg.set_markdown_transformer(Some(t.clone()));
3946 }
3947 msg.update_text(text);
3948 container.add_child(msg);
3949 container.add_child(Arc::new(Spacer::new(1)));
3950}
3951
3952async fn drain_agent_events(
3960 mut rx: broadcast::Receiver<AgentEvent>,
3961 tui: Arc<TuiAltScreen>,
3962 state: Arc<TuiState>,
3963 chat: Arc<Container>,
3964) {
3965 loop {
3966 match rx.recv().await {
3967 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
3968 Err(broadcast::error::RecvError::Lagged(_)) => {
3969 continue;
3972 }
3973 Err(broadcast::error::RecvError::Closed) => break,
3974 }
3975 }
3976}
3977
3978async fn handle_agent_event(
3981 event: AgentEvent,
3982 tui: &Arc<TuiAltScreen>,
3983 state: &Arc<TuiState>,
3984 chat: &Arc<Container>,
3985) {
3986 match event {
3987 AgentEvent::AgentStart => {
3988 state.set_status(RunStatus::Working);
3989 tui.request_render(false);
3990 }
3991
3992 AgentEvent::AgentEnd { .. } => {
3993 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3995 comp.set_streaming(false);
3996 }
3997 state.set_status(RunStatus::Idle);
3998 tui.request_render(false);
3999 }
4000
4001 AgentEvent::TurnStart => {
4002 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4005 comp.set_streaming(false);
4006 }
4007 }
4008
4009 AgentEvent::TurnEnd {
4010 message,
4011 tool_results,
4012 } => {
4013 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4015 if let AgentMessage::Assistant(a) = &message {
4016 comp.update_blocks(&assistant_blocks(a));
4017 }
4018 comp.set_streaming(false);
4019 }
4020 let tools = state.tool_components.lock().unwrap();
4025 for tr in &tool_results {
4026 if tools.contains_key(&tr.tool_call_id) {
4027 let _ = tr;
4030 }
4031 }
4032 drop(tools);
4033 tui.request_render(false);
4034 }
4035
4036 AgentEvent::MessageStart { message } => match message {
4037 AgentMessage::Assistant(a) => {
4038 let comp = Arc::new(AssistantMessageComponent::new(
4039 AssistantMessageOptions::default(),
4040 ));
4041 if let Some(t) = state.markdown_transformer() {
4046 comp.set_markdown_transformer(Some(t));
4047 }
4048 comp.set_streaming(true);
4049 comp.update_blocks(&assistant_blocks(&a));
4052 chat.add_child(comp.clone());
4053 chat.add_child(Arc::new(Spacer::new(1)));
4056 *state.current_assistant.lock().unwrap() = Some(comp);
4057 tui.request_render(false);
4058 }
4059 AgentMessage::Custom(custom) => {
4060 let payload = serde_json::json!({
4061 "customType": custom.role,
4062 "content": custom.content,
4063 "details": custom.data,
4064 "expanded": false,
4065 "outputPad": 1,
4066 });
4067 if let Some(component) = extension_message_component(
4068 &state.extension_session,
4069 &custom.role,
4070 &payload,
4071 state.markdown_transformer(),
4072 ) {
4073 chat.add_child(component);
4074 chat.add_child(Arc::new(Spacer::new(1)));
4075 tui.request_render(false);
4076 } else {
4077 add_note_message(chat, &custom_message_fallback(&custom));
4078 tui.request_render(false);
4079 }
4080 }
4081 _ => {}
4084 },
4085
4086 AgentEvent::MessageUpdate {
4087 message,
4088 assistant_message_event,
4089 } => {
4090 if let AgentMessage::Assistant(a) = &message {
4091 let text = assistant_text(a);
4092 let mut saw_bash_tool_call = false;
4093 for c in &a.content {
4098 if let Content::ToolCall(tc) = c {
4099 if tc.name == "bash" {
4100 saw_bash_tool_call = true;
4101 let command = tc
4107 .arguments
4108 .get("command")
4109 .and_then(|v| v.as_str())
4110 .unwrap_or("");
4111 let mut bash = state.bash_components.lock().unwrap();
4112 if !bash.contains_key(&tc.id) {
4113 let comp = Arc::new(BashExecutionComponent::new(command));
4114 chat.add_child(comp.clone());
4115 bash.insert(tc.id.clone(), comp);
4116 }
4117 } else {
4118 let mut tools = state.tool_components.lock().unwrap();
4119 if !tools.contains_key(&tc.id) {
4120 let comp = Arc::new(ToolExecutionComponent::new(
4121 &tc.name,
4122 &tc.arguments.to_string(),
4123 ));
4124 comp.set_running();
4125 chat.add_child(comp.clone());
4126 tools.insert(tc.id.clone(), comp);
4127 }
4128 }
4129 }
4130 }
4131 if saw_bash_tool_call {
4136 state.sync_working_loader_with_bash();
4137 }
4138 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
4140 comp.update_blocks(&assistant_blocks(a));
4143 }
4144 *state.last_assistant_text.lock().unwrap() = text;
4145 tui.request_render(false);
4146 }
4147 }
4148
4149 AgentEvent::MessageEnd { message } => {
4150 if let AgentMessage::Assistant(a) = &message {
4151 let text = assistant_text(a);
4152 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4153 comp.update_blocks(&assistant_blocks(a));
4154 comp.set_streaming(false);
4155 }
4156 if !text.is_empty() {
4158 *state.last_assistant_text.lock().unwrap() = text;
4159 }
4160 let usage = &a.usage;
4166 let prev_input = *state.last_input_tokens.lock().unwrap();
4167 if prev_input > 0
4168 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
4169 && usage.cache_read == 0
4170 {
4171 add_note_message(
4172 &state.chat_container,
4173 &format!(
4174 "Cache miss: {} tokens re-billed",
4175 format_tokens(usage.input)
4176 ),
4177 );
4178 }
4179 if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
4180 add_note_message(chat, &text);
4181 }
4182 *state.last_input_tokens.lock().unwrap() = usage.input;
4183 }
4184 tui.request_render(false);
4185 }
4186
4187 AgentEvent::ToolExecutionStart {
4188 tool_call_id,
4189 tool_name,
4190 args,
4191 } => {
4192 if tool_name == "bash" {
4193 let command = args
4198 .get("command")
4199 .and_then(|v| v.as_str())
4200 .unwrap_or("")
4201 .to_string();
4202 let mut bash_map = state.bash_components.lock().unwrap();
4203 if let Some(existing) = bash_map.get(&tool_call_id) {
4204 existing.set_command(&command);
4209 } else {
4210 let comp = Arc::new(BashExecutionComponent::new(command));
4211 chat.add_child(comp.clone());
4212 bash_map.insert(tool_call_id.clone(), comp);
4213 }
4214 } else {
4215 let comp = {
4216 let mut tools = state.tool_components.lock().unwrap();
4217 if let Some(existing) = tools.get(&tool_call_id) {
4218 existing.set_args(&args.to_string());
4219 existing.clone()
4220 } else {
4221 let comp =
4222 Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
4223 comp.set_running();
4224 chat.add_child(comp.clone());
4225 tools.insert(tool_call_id.clone(), comp.clone());
4226 comp
4227 }
4228 };
4229 state.remember_tool(comp);
4230 }
4231 state.sync_working_loader_with_bash();
4232 tui.request_render(false);
4233 }
4234
4235 AgentEvent::ToolExecutionUpdate {
4236 tool_call_id,
4237 tool_name,
4238 partial_result,
4239 ..
4240 } => {
4241 if tool_name == "bash" {
4242 let chunk = tool_result_text(&partial_result);
4247 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
4248 bash.append_output(&chunk);
4249 } else {
4250 let comp = Arc::new(BashExecutionComponent::new(""));
4253 comp.append_output(&chunk);
4254 chat.add_child(comp.clone());
4255 state
4256 .bash_components
4257 .lock()
4258 .unwrap()
4259 .insert(tool_call_id.clone(), comp);
4260 }
4261 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
4262 comp.set_result(&tool_result_text(&partial_result), false);
4265 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
4266 state.remember_tool(comp.clone());
4267 } else {
4268 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4270 comp.set_running();
4271 comp.set_result(&tool_result_text(&partial_result), false);
4272 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
4273 chat.add_child(comp.clone());
4274 state
4275 .tool_components
4276 .lock()
4277 .unwrap()
4278 .insert(tool_call_id.clone(), comp.clone());
4279 state.remember_tool(comp);
4280 }
4281 state.sync_working_loader_with_bash();
4282 tui.request_render(false);
4283 }
4284
4285 AgentEvent::ToolExecutionEnd {
4286 tool_call_id,
4287 tool_name,
4288 result,
4289 is_error,
4290 } => {
4291 if tool_name == "bash" {
4292 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
4293 if let Some(bash) = bash {
4294 finalize_bash(&bash, &result, is_error);
4295 } else {
4296 let command = result
4299 .details
4300 .get("command")
4301 .and_then(|v| v.as_str())
4302 .unwrap_or("")
4303 .to_string();
4304 let comp = Arc::new(BashExecutionComponent::new(command));
4305 comp.append_output(&tool_result_text(&result));
4306 finalize_bash(&comp, &result, is_error);
4307 chat.add_child(comp);
4308 }
4309 } else {
4310 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
4311 if let Some(comp) = comp {
4312 comp.set_result(&tool_result_text(&result), is_error);
4313 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4314 } else {
4315 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4318 comp.set_result(&tool_result_text(&result), is_error);
4319 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4320 chat.add_child(comp.clone());
4321 state.remember_tool(comp);
4322 }
4323 }
4324 state.sync_working_loader_with_bash();
4325 tui.request_render(false);
4326 }
4327 }
4328}
4329
4330fn finalize_bash(
4334 comp: &Arc<BashExecutionComponent>,
4335 result: &rpi_agent::AgentToolResult,
4336 is_error: bool,
4337) {
4338 let exit_code = if is_error { Some(1) } else { Some(0) };
4341 let truncated = result
4342 .details
4343 .get("truncation")
4344 .and_then(|t| t.get("truncated"))
4345 .and_then(|v| v.as_bool())
4346 .unwrap_or(false);
4347 let full_output_path = result
4348 .details
4349 .get("full_output_path")
4350 .and_then(|v| v.as_str())
4351 .map(|s| s.to_string());
4352 let truncation = BashTruncation {
4353 truncated,
4354 full_output_path,
4355 };
4356 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
4358}
4359
4360fn apply_edit_diff(
4365 comp: &Arc<ToolExecutionComponent>,
4366 tool_name: &str,
4367 details: &serde_json::Value,
4368 tui: &Arc<TuiAltScreen>,
4369) {
4370 if tool_name != "edit" {
4371 return;
4372 }
4373 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
4374 return;
4375 };
4376 if diff_text.is_empty() {
4377 return;
4378 }
4379 let width = tui.width();
4380 let lines = render_diff(diff_text, width);
4381 comp.set_diff(lines);
4382}
4383
4384fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
4387 use rpi_agent::TextContentOrImage;
4388 let mut parts: Vec<String> = Vec::new();
4389 for c in &result.content {
4390 if let TextContentOrImage::Text(t) = c {
4391 parts.push(t.text.clone());
4392 }
4393 }
4394 let joined = parts.join("\n");
4395 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
4397 if one_line.chars().count() > 200 {
4398 let truncated: String = one_line.chars().take(200).collect();
4399 format!("{truncated}…")
4400 } else {
4401 one_line
4402 }
4403}
4404
4405fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
4410 use rpi_agent::TextContentOrImage;
4411 let mut parts: Vec<String> = Vec::new();
4412 for c in &result.content {
4413 if let TextContentOrImage::Text(t) = c {
4414 parts.push(t.text.clone());
4415 }
4416 }
4417 parts.join("\n")
4418}
4419
4420fn open_selector(
4428 state: &Arc<TuiState>,
4429 editor_container: &Arc<Container>,
4430 editor: &Arc<Editor>,
4431 tui: &Arc<TuiAltScreen>,
4432 list: Arc<SelectList>,
4433 kind: SelectorKind,
4434) {
4435 editor.set_focused(false);
4437 editor_container.clear();
4439 editor_container.add_child(list.clone());
4440 *state.active_selector.lock().unwrap() = Some((list, kind));
4441 tui.request_render(false);
4442}
4443
4444fn close_selector(
4447 state: &Arc<TuiState>,
4448 editor_container: &Arc<Container>,
4449 editor: &Arc<Editor>,
4450 tui: &Arc<TuiAltScreen>,
4451) {
4452 editor_container.clear();
4453 editor_container.add_child(editor.clone());
4454 editor.set_focused(true);
4455 *state.active_selector.lock().unwrap() = None;
4456 tui.request_render(false);
4457}
4458
4459fn open_model_selector(
4465 state: &Arc<TuiState>,
4466 editor_container: &Arc<Container>,
4467 editor: &Arc<Editor>,
4468 tui: &Arc<TuiAltScreen>,
4469 catalog: &[rpi_ai::Model],
4470 lane: &Arc<dyn AgentLane>,
4471 lane_model_id: &str,
4472 chat: &Arc<Container>,
4473) {
4474 let mut items: Vec<SelectItem> = Vec::new();
4475 for m in catalog {
4476 let label = if m.name.is_empty() {
4477 short_model_name(&m.id)
4478 } else {
4479 m.name.clone()
4480 };
4481 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
4482 " (current)"
4483 } else {
4484 ""
4485 };
4486 items.push(
4487 SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)),
4488 );
4489 }
4490 if items.is_empty() {
4491 add_note_message(
4492 chat,
4493 "No models in the catalog. Use --model at startup to select one.",
4494 );
4495 tui.request_render(false);
4496 return;
4497 }
4498 let list = Arc::new(SelectList::new(items, 10));
4499
4500 let catalog_arc = catalog.to_vec();
4504 let state_sel = state.clone();
4505 let ec_sel = editor_container.clone();
4506 let editor_sel = editor.clone();
4507 let tui_sel = tui.clone();
4508 let chat_sel = chat.clone();
4509 let lane_sel = lane.clone();
4510 list.on_select(Arc::new(move |item| {
4511 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
4512 add_note_message(
4513 &chat_sel,
4514 &format!("Model {} not found in catalog.", item.label),
4515 );
4516 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4517 return;
4518 };
4519 state_sel.set_current_model(&model);
4520 let lane = lane_sel.clone();
4521 tokio::spawn(async move {
4522 let _ = lane.set_model(model).await;
4523 });
4524 add_note_message(
4525 &chat_sel,
4526 &format!(
4527 "Model set to {} — applies to the next message.",
4528 short_model_name(&item.value)
4529 ),
4530 );
4531 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4532 }));
4533 let state_cancel = state.clone();
4534 let ec_cancel = editor_container.clone();
4535 let editor_cancel = editor.clone();
4536 let tui_cancel = tui.clone();
4537 list.on_cancel(Arc::new(move || {
4538 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4539 }));
4540
4541 open_selector(
4542 state,
4543 editor_container,
4544 editor,
4545 tui,
4546 list,
4547 SelectorKind::Model,
4548 );
4549}
4550
4551fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
4556 if catalog.is_empty() {
4557 return None;
4558 }
4559 let idx = catalog
4560 .iter()
4561 .position(|m| m.id.eq_ignore_ascii_case(current_id));
4562 match idx {
4563 Some(i) => {
4564 let next = (i + 1) % catalog.len();
4565 Some(catalog[next].clone())
4566 }
4567 None => Some(catalog[0].clone()),
4568 }
4569}
4570
4571fn open_session_selector(
4576 state: &Arc<TuiState>,
4577 editor_container: &Arc<Container>,
4578 editor: &Arc<Editor>,
4579 tui: &Arc<TuiAltScreen>,
4580 cwd: &std::path::Path,
4581 tx: &mpsc::UnboundedSender<TuiMessage>,
4582) {
4583 let dir = crate::session::default_session_dir(cwd);
4584 let mut items: Vec<SelectItem> = Vec::new();
4585 if let Ok(entries) = std::fs::read_dir(&dir) {
4586 for entry in entries.flatten() {
4587 let path = entry.path();
4588 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
4589 continue;
4590 }
4591 let stem = path
4592 .file_stem()
4593 .and_then(|s| s.to_str())
4594 .unwrap_or("(unnamed)")
4595 .to_string();
4596 let display = path
4597 .file_name()
4598 .and_then(|s| s.to_str())
4599 .unwrap_or(&stem)
4600 .to_string();
4601 items.push(SelectItem::new(&stem, &display));
4602 }
4603 }
4604 if items.is_empty() {
4605 add_note_message(
4606 &state.chat_container,
4607 "No saved sessions found. Sessions are created automatically in interactive mode.",
4608 );
4609 tui.request_render(false);
4610 return;
4611 }
4612 let list = Arc::new(SelectList::new(items, 10));
4613
4614 let state_sel = state.clone();
4615 let ec_sel = editor_container.clone();
4616 let editor_sel = editor.clone();
4617 let tui_sel = tui.clone();
4618 let tx_sel = tx.clone();
4619 list.on_select(Arc::new(move |item| {
4620 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4624 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
4625 }));
4626 let state_cancel = state.clone();
4627 let ec_cancel = editor_container.clone();
4628 let editor_cancel = editor.clone();
4629 let tui_cancel = tui.clone();
4630 list.on_cancel(Arc::new(move || {
4631 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4632 }));
4633
4634 open_selector(
4635 state,
4636 editor_container,
4637 editor,
4638 tui,
4639 list,
4640 SelectorKind::Session,
4641 );
4642}
4643
4644fn custom_entry_display_text(
4645 custom_type: &str,
4646 data: Option<&serde_json::Value>,
4647) -> Option<String> {
4648 let data = data?;
4649 let text = data
4650 .get("summary")
4651 .or_else(|| data.get("text"))
4652 .or_else(|| data.get("output"))
4653 .and_then(|value| value.as_str())
4654 .filter(|value| !value.trim().is_empty())?;
4655 let label = match custom_type {
4656 "compactionSummary" => "Compaction summary",
4657 "branchSummary" => "Branch summary",
4658 "bashExecution" => "Command output",
4659 other => other,
4660 };
4661 Some(format!("{label}: {text}"))
4662}
4663
4664async fn open_tree_selector(
4668 harness: &AgentHarness,
4669 state: &Arc<TuiState>,
4670 editor_container: &Arc<Container>,
4671 editor: &Arc<Editor>,
4672 tui: &Arc<TuiAltScreen>,
4673 chat: &Arc<Container>,
4674 tx: &mpsc::UnboundedSender<TuiMessage>,
4675) {
4676 let entries = match harness
4677 .session()
4678 .view("main")
4679 .find_entries(&EntryQuery {
4680 order: Some(EntryOrder::OldestFirst),
4681 ..Default::default()
4682 })
4683 .await
4684 {
4685 Ok(entries) => entries,
4686 Err(error) => {
4687 add_error_message(chat, &format!("Could not read session tree: {error}"));
4688 tui.request_render(false);
4689 return;
4690 }
4691 };
4692 let current = harness.session().get_leaf_id().await.ok().flatten();
4693 let items: Vec<SelectItem> = entries
4694 .iter()
4695 .map(|entry| {
4696 let marker = if current.as_deref() == Some(entry.id()) {
4697 " (current)"
4698 } else {
4699 ""
4700 };
4701 SelectItem::new(
4702 entry.id(),
4703 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
4704 )
4705 .with_description(&entry.id()[..entry.id().len().min(12)])
4706 })
4707 .collect();
4708 if items.is_empty() {
4709 add_note_message(chat, "The current session has no entries to navigate.");
4710 tui.request_render(false);
4711 return;
4712 }
4713 let list = Arc::new(SelectList::new(items, 12));
4714 let state_sel = state.clone();
4715 let ec_sel = editor_container.clone();
4716 let editor_sel = editor.clone();
4717 let tui_sel = tui.clone();
4718 let tx_sel = tx.clone();
4719 list.on_select(Arc::new(move |item| {
4720 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
4721 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4722 }));
4723 let state_cancel = state.clone();
4724 let ec_cancel = editor_container.clone();
4725 let editor_cancel = editor.clone();
4726 let tui_cancel = tui.clone();
4727 list.on_cancel(Arc::new(move || {
4728 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4729 }));
4730 open_selector(
4731 state,
4732 editor_container,
4733 editor,
4734 tui,
4735 list,
4736 SelectorKind::Tree,
4737 );
4738}
4739
4740fn open_theme_selector(
4743 state: &Arc<TuiState>,
4744 editor_container: &Arc<Container>,
4745 editor: &Arc<Editor>,
4746 tui: &Arc<TuiAltScreen>,
4747) {
4748 let items = vec![
4749 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
4750 SelectItem::new("light", "Light").with_description("Light background"),
4751 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
4752 ];
4753 let list = Arc::new(SelectList::new(items, 10));
4754
4755 let state_sel = state.clone();
4756 let ec_sel = editor_container.clone();
4757 let editor_sel = editor.clone();
4758 let tui_sel = tui.clone();
4759 let chat_sel = state.chat_container.clone();
4760 list.on_select(Arc::new(move |item| {
4761 let preset = match item.value.as_str() {
4762 "light" => ThemePreset::Light,
4763 "monochrome" => ThemePreset::Monochrome,
4764 _ => ThemePreset::Dark,
4765 };
4766 apply_theme_preset(preset);
4767 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
4770 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4771 tui_sel.render_now(true);
4772 }));
4773 let state_cancel = state.clone();
4774 let ec_cancel = editor_container.clone();
4775 let editor_cancel = editor.clone();
4776 let tui_cancel = tui.clone();
4777 list.on_cancel(Arc::new(move || {
4778 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4779 }));
4780
4781 open_selector(
4782 state,
4783 editor_container,
4784 editor,
4785 tui,
4786 list,
4787 SelectorKind::Theme,
4788 );
4789}
4790
4791fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4798 use rpi_ai::types::ThinkingLevel::*;
4799 match level {
4800 Off => "Off — No reasoning",
4801 Minimal => "Minimal — Brief reasoning (~1k tokens)",
4802 Low => "Low — Light reasoning (~1k tokens)",
4803 Medium => "Medium — Moderate reasoning (~80% of max)",
4804 High => "High — Extensive reasoning (~95% of max)",
4805 Xhigh => "Xhigh — Near-maximal reasoning",
4806 Max => "Max — Maximum reasoning",
4807 }
4808}
4809
4810fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4813 use rpi_ai::types::ThinkingLevel::*;
4814 match level {
4815 Off => "off",
4816 Minimal => "minimal",
4817 Low => "low",
4818 Medium => "medium",
4819 High => "high",
4820 Xhigh => "xhigh",
4821 Max => "max",
4822 }
4823}
4824
4825fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
4828 use rpi_ai::types::ThinkingLevel::*;
4829 match name.to_ascii_lowercase().as_str() {
4830 "off" => Some(Off),
4831 "minimal" => Some(Minimal),
4832 "low" => Some(Low),
4833 "medium" => Some(Medium),
4834 "high" => Some(High),
4835 "xhigh" => Some(Xhigh),
4836 "max" => Some(Max),
4837 _ => None,
4838 }
4839}
4840
4841fn open_thinking_selector(
4851 state: &Arc<TuiState>,
4852 editor_container: &Arc<Container>,
4853 editor: &Arc<Editor>,
4854 tui: &Arc<TuiAltScreen>,
4855 lane: &Arc<dyn AgentLane>,
4856 catalog: &[rpi_ai::Model],
4857 lane_model_id: &str,
4858 chat: &Arc<Container>,
4859) {
4860 let model = catalog
4863 .iter()
4864 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
4865 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
4866 .map(|m| m.supported_thinking_levels())
4867 .unwrap_or_else(|| {
4868 use rpi_ai::types::ThinkingLevel::*;
4869 vec![Off, Minimal, Low, Medium, High]
4870 });
4871 let mut items: Vec<SelectItem> = Vec::new();
4872 for lvl in &levels {
4873 let name = thinking_level_name(*lvl);
4874 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
4875 }
4876 if items.is_empty() {
4877 add_note_message(chat, "This model has no supported thinking levels.");
4878 tui.request_render(false);
4879 return;
4880 }
4881 let list = Arc::new(SelectList::new(items, 10));
4882
4883 let state_sel = state.clone();
4884 let ec_sel = editor_container.clone();
4885 let editor_sel = editor.clone();
4886 let tui_sel = tui.clone();
4887 let chat_sel = chat.clone();
4888 let lane_sel = lane.clone();
4889 list.on_select(Arc::new(move |item| {
4890 let Some(level) = thinking_level_from_name(&item.value) else {
4891 add_note_message(
4892 &chat_sel,
4893 &format!("Unknown thinking level: {}.", item.label),
4894 );
4895 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4896 return;
4897 };
4898 let lane = lane_sel.clone();
4899 let footer_sel = state_sel.footer.clone();
4900 tokio::spawn(async move {
4901 let _ = lane.set_thinking_level(level).await;
4902 });
4903 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
4907 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
4908 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4909 }));
4910 let state_cancel = state.clone();
4911 let ec_cancel = editor_container.clone();
4912 let editor_cancel = editor.clone();
4913 let tui_cancel = tui.clone();
4914 list.on_cancel(Arc::new(move || {
4915 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4916 }));
4917
4918 open_selector(
4919 state,
4920 editor_container,
4921 editor,
4922 tui,
4923 list,
4924 SelectorKind::Thinking,
4925 );
4926}
4927
4928fn open_tools_selector(
4934 state: &Arc<TuiState>,
4935 editor_container: &Arc<Container>,
4936 editor: &Arc<Editor>,
4937 tui: &Arc<TuiAltScreen>,
4938 lane: &Arc<dyn AgentLane>,
4939 chat: &Arc<Container>,
4940) {
4941 let active = match tokio::runtime::Handle::try_current() {
4946 Ok(h) => h
4947 .block_on(async { lane.get_active_tools().await })
4948 .unwrap_or_default(),
4949 Err(_) => Vec::new(),
4950 };
4951 let mut items: Vec<SelectItem> = Vec::new();
4952 for name in crate::session::BUILTIN_TOOL_NAMES {
4953 let on = active.iter().any(|a| a == name);
4954 let label = if on {
4955 format!("{name} (on)")
4956 } else {
4957 (*name).to_string()
4958 };
4959 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
4960 }
4961 let list = Arc::new(SelectList::new(items, 10));
4962
4963 let active_captured = active.clone();
4965 let state_sel = state.clone();
4966 let ec_sel = editor_container.clone();
4967 let editor_sel = editor.clone();
4968 let tui_sel = tui.clone();
4969 let chat_sel = chat.clone();
4970 let lane_sel = lane.clone();
4971 list.on_select(Arc::new(move |item| {
4972 let mut next = active_captured.clone();
4973 if let Some(pos) = next.iter().position(|a| a == &item.value) {
4974 next.remove(pos);
4975 } else {
4976 next.push(item.value.clone());
4977 }
4978 let on = next.iter().any(|a| a == &item.value);
4979 let lane = lane_sel.clone();
4980 let next_clone = next.clone();
4981 tokio::spawn(async move {
4982 let _ = lane.set_active_tools(next_clone).await;
4983 });
4984 let list_str = if next.is_empty() {
4985 "(none)".to_string()
4986 } else {
4987 next.join(", ")
4988 };
4989 add_note_message(
4990 &chat_sel,
4991 &format!(
4992 "{} {} — active tools: {}",
4993 item.value,
4994 if on { "enabled" } else { "disabled" },
4995 list_str
4996 ),
4997 );
4998 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4999 }));
5000 let state_cancel = state.clone();
5001 let ec_cancel = editor_container.clone();
5002 let editor_cancel = editor.clone();
5003 let tui_cancel = tui.clone();
5004 list.on_cancel(Arc::new(move || {
5005 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
5006 }));
5007
5008 open_selector(
5009 state,
5010 editor_container,
5011 editor,
5012 tui,
5013 list,
5014 SelectorKind::Tools,
5015 );
5016}
5017
5018fn open_images_selector(
5022 state: &Arc<TuiState>,
5023 editor_container: &Arc<Container>,
5024 editor: &Arc<Editor>,
5025 tui: &Arc<TuiAltScreen>,
5026 chat: &Arc<Container>,
5027) {
5028 let current = *state.show_images.lock().unwrap();
5029 let items = vec![
5030 SelectItem::new("yes", "Yes").with_description(if current {
5031 "Inline images (current)"
5032 } else {
5033 "Inline images"
5034 }),
5035 SelectItem::new("no", "No").with_description(if current {
5036 "Placeholder only"
5037 } else {
5038 "Placeholder only (current)"
5039 }),
5040 ];
5041 let list = Arc::new(SelectList::new(items, 5));
5042
5043 let state_sel = state.clone();
5044 let ec_sel = editor_container.clone();
5045 let editor_sel = editor.clone();
5046 let tui_sel = tui.clone();
5047 let chat_sel = chat.clone();
5048 list.on_select(Arc::new(move |item| {
5049 let on = item.value == "yes";
5050 *state_sel.show_images.lock().unwrap() = on;
5051 add_note_message(
5052 &chat_sel,
5053 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
5054 );
5055 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
5056 }));
5057 let state_cancel = state.clone();
5058 let ec_cancel = editor_container.clone();
5059 let editor_cancel = editor.clone();
5060 let tui_cancel = tui.clone();
5061 list.on_cancel(Arc::new(move || {
5062 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
5063 }));
5064
5065 open_selector(
5066 state,
5067 editor_container,
5068 editor,
5069 tui,
5070 list,
5071 SelectorKind::Images,
5072 );
5073}
5074
5075fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
5083 let text = editor.get_text();
5084 let (_row, col) = editor.cursor_position();
5085 let cursor = col.min(text.len());
5093 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
5094 render_autocomplete(state, suggestions);
5095}
5096
5097fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
5099 state.autocomplete_container.clear();
5100 let Some(sugg) = suggestions else {
5101 return;
5102 };
5103 if sugg.items.is_empty() {
5104 return;
5105 }
5106 let accent = state.theme_manager.get().colors.accent;
5109 let muted = state.theme_manager.get().colors.muted;
5110 for (i, item) in sugg.items.iter().take(5).enumerate() {
5111 let prefix = if i == 0 { "→ " } else { " " };
5112 let label = item.display_text();
5113 let line = if i == 0 {
5114 format!(
5115 "{prefix}{} {}",
5116 accent.fg(label),
5117 muted.fg(item.description.as_deref().unwrap_or(""))
5118 )
5119 } else {
5120 format!(
5121 "{prefix}{} {}",
5122 muted.fg(label),
5123 muted.fg(item.description.as_deref().unwrap_or(""))
5124 )
5125 };
5126 state
5127 .autocomplete_container
5128 .add_child(Arc::new(Text::new(line, 1, 0)));
5129 }
5130}
5131
5132fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
5136 let text = editor.get_text();
5137 let (_row, col) = editor.cursor_position();
5138 let cursor = col.min(text.len());
5139 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
5140 return false;
5141 };
5142 let Some(top) = sugg.items.first() else {
5143 return false;
5144 };
5145 let start = sugg.start.min(text.len());
5149 let end = sugg.end.min(text.len());
5150 let mut replaced = String::with_capacity(text.len() + top.text.len());
5151 replaced.push_str(&text[..start]);
5152 replaced.push_str(&top.text);
5153 replaced.push_str(&text[end..]);
5156 if top.insert_space && !replaced.ends_with('/') {
5157 replaced.push(' ');
5158 }
5159 let new_cursor = replaced.len().min(
5162 start
5163 + top.text.len()
5164 + if top.insert_space && !top.text.ends_with('/') {
5165 1
5166 } else {
5167 0
5168 },
5169 );
5170 editor.set_text(&replaced);
5171 editor.set_cursor(0, new_cursor);
5172 state.autocomplete_container.clear();
5173 true
5174}
5175
5176fn add_welcome_message(container: &Arc<Container>) {
5182 let c = current_theme().colors;
5183 let title = format!(
5187 "{} {}",
5188 c.accent.fg(&tui_bold("rpi")),
5189 c.muted.fg("interactive TUI")
5190 );
5191 container.add_child(Arc::new(Text::new(title, 1, 0)));
5192 container.add_child(Arc::new(Spacer::new(1)));
5193 container.add_child(Arc::new(Text::new(
5194 c.dim.fg("Type your message and press Enter to send."),
5195 1,
5196 0,
5197 )));
5198 let hint = c
5199 .dim
5200 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
5201 container.add_child(Arc::new(Text::new(hint, 1, 0)));
5202 container.add_child(Arc::new(DynamicBorder::new()));
5203}
5204
5205fn add_help_message(container: &Arc<Container>) {
5207 let c = current_theme().colors;
5208 container.add_child(Arc::new(Text::new(
5212 c.md_heading.fg(&tui_bold("📚 Available Commands")),
5213 1,
5214 0,
5215 )));
5216 container.add_child(Arc::new(Spacer::new(1)));
5217
5218 let cmds: &[(&str, &str)] = &[
5219 ("/help, /?", "Show this help message"),
5220 ("/clear, /new", "Clear the conversation"),
5221 ("/exit, /quit, /q", "Exit the application"),
5222 ("/version, /v", "Show version information"),
5223 ("/model, /m", "Choose a model (live switch)"),
5224 ("/thinking, /think", "Set reasoning depth (selector)"),
5225 ("/tools", "Toggle built-in tools on/off"),
5226 ("/images", "Toggle inline image rendering"),
5227 ("/session", "List saved sessions"),
5228 ("/theme", "Choose a theme (selector)"),
5229 ("/compact", "Compact the conversation"),
5230 ("/copy", "Copy last reply to clipboard"),
5231 ("/hotkeys", "Show keyboard shortcuts"),
5232 ("/armin", "🐾 Easter egg"),
5233 ("/earendil", "Earendil announcement"),
5234 ];
5235 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5236 for (cmd, desc) in cmds {
5237 let row = format!(
5238 " {:<cmd_w$} {} {}",
5239 c.accent.fg(cmd),
5240 c.dim.fg("—"),
5241 c.muted.fg(desc)
5242 );
5243 container.add_child(Arc::new(Text::new(row, 1, 0)));
5244 }
5245 container.add_child(Arc::new(Spacer::new(1)));
5246}
5247
5248fn add_version_message(container: &Arc<Container>) {
5250 let c = current_theme().colors;
5251 container.add_child(Arc::new(Text::new(
5252 c.md_heading.fg(&tui_bold("📦 Version Information")),
5253 1,
5254 0,
5255 )));
5256 container.add_child(Arc::new(Spacer::new(1)));
5257 container.add_child(Arc::new(Text::new(
5260 format!(
5261 " {} {}",
5262 c.muted.fg("rpi-cli"),
5263 c.text.fg(&format!("v{}", crate::VERSION))
5264 ),
5265 1,
5266 0,
5267 )));
5268 container.add_child(Arc::new(Text::new(
5269 format!(
5270 " {}",
5271 c.dim.fg("Rust implementation of pi coding agent TUI")
5272 ),
5273 1,
5274 0,
5275 )));
5276 container.add_child(Arc::new(Spacer::new(1)));
5277}
5278
5279fn add_hotkeys_message(container: &Arc<Container>) {
5281 let c = current_theme().colors;
5282 container.add_child(Arc::new(Text::new(
5283 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
5284 1,
5285 0,
5286 )));
5287 container.add_child(Arc::new(Spacer::new(1)));
5288 let keys: &[(&str, &str)] = &[
5289 ("Enter", "Send message"),
5290 ("Shift+Enter", "New line"),
5291 ("Tab", "Accept autocomplete suggestion"),
5292 ("Ctrl+A / Ctrl+E", "Line start / end"),
5293 (
5294 "Ctrl+K / Ctrl+U",
5295 "Kill to end / start of line (Ctrl+Y yanks)",
5296 ),
5297 ("Ctrl+- / Ctrl+R", "Undo / redo"),
5298 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
5299 ("Alt+Backspace", "Kill previous word"),
5300 ("Ctrl+C", "Abort a run, or exit when idle"),
5301 ("Esc", "Abort a running prompt"),
5302 ("Ctrl+L", "Open model selector"),
5303 ("Ctrl+M", "Cycle to the next model (live)"),
5304 ("Ctrl+T", "Expand/collapse last tool result"),
5305 ("PageUp/Down", "Scroll transcript by one page"),
5306 ("Home / End", "Jump to transcript start / latest output"),
5307 ];
5308 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5309 for (key, desc) in keys {
5310 let row = format!(
5311 " {:<key_w$} {} {}",
5312 c.accent.fg(key),
5313 c.dim.fg("—"),
5314 c.muted.fg(desc)
5315 );
5316 container.add_child(Arc::new(Text::new(row, 1, 0)));
5317 }
5318 container.add_child(Arc::new(Spacer::new(1)));
5319}
5320
5321fn add_user_message(container: &Arc<Container>, text: &str) {
5327 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
5328 container.add_child(Arc::new(Spacer::new(1)));
5329}
5330
5331fn add_error_message(container: &Arc<Container>, text: &str) {
5333 let c = current_theme().colors;
5334 container.add_child(Arc::new(Text::new(
5335 format!(" {} {}", c.error.fg("✗"), c.error.fg(text)),
5336 1,
5337 0,
5338 )));
5339 container.add_child(Arc::new(Spacer::new(1)));
5340}
5341
5342fn add_note_message(container: &Arc<Container>, text: &str) {
5344 let c = current_theme().colors;
5345 container.add_child(Arc::new(Text::new(
5346 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
5347 1,
5348 0,
5349 )));
5350 container.add_child(Arc::new(Spacer::new(1)));
5351}
5352
5353fn show_context_panel(
5362 chat: &Arc<Container>,
5363 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
5364) {
5365 let skills = resources.skills.as_deref().unwrap_or(&[]);
5366 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
5367 let mut lines: Vec<String> = Vec::new();
5368 lines.push("📂 Discovered resources for this session:".into());
5369
5370 if skills.is_empty() {
5371 lines.push(
5372 " Skills: (none discovered — create .pi/skills/ or ~/.rpi/agent/skills/)".into(),
5373 );
5374 } else {
5375 lines.push(format!(" Skills ({}):", skills.len()));
5376 for s in skills {
5377 let marker = if s.disable_model_invocation == Some(true) {
5378 " [hidden]"
5379 } else {
5380 ""
5381 };
5382 let desc: String = s.description.chars().take(72).collect();
5383 lines.push(format!(" • {}{marker} — {desc}", s.name));
5384 }
5385 }
5386
5387 if templates.is_empty() {
5388 lines.push(
5389 " Prompt templates: (none — create .pi/prompts/ or ~/.rpi/agent/prompts/)".into(),
5390 );
5391 } else {
5392 lines.push(format!(" Prompt templates ({}):", templates.len()));
5393 for t in templates {
5394 let desc = t
5395 .description
5396 .as_deref()
5397 .unwrap_or("(no description)")
5398 .chars()
5399 .take(72)
5400 .collect::<String>();
5401 lines.push(format!(" • /{} — {desc}", t.name));
5402 }
5403 }
5404 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
5405 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
5406 lines.push(
5407 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
5408 .into(),
5409 );
5410 let body = lines.join("\n");
5411 container_note_block(chat, &body);
5412}
5413
5414fn container_note_block(container: &Arc<Container>, body: &str) {
5416 for line in body.lines() {
5417 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
5418 }
5419 container.add_child(Arc::new(Spacer::new(1)));
5420}
5421
5422pub fn is_tui_supported() -> bool {
5428 std::io::stdout().is_terminal()
5429}
5430
5431#[allow(unused_imports)]
5433use rpi_tui::Color as _Color;
5434
5435#[cfg(test)]
5436mod tests {
5437 use super::*;
5438 use rpi_tui::Component;
5439
5440 #[test]
5441 fn transcript_page_uses_viewport_with_overlap() {
5442 assert_eq!(transcript_page_size(24), 20);
5443 assert_eq!(transcript_page_size(4), 1);
5444 assert_eq!(transcript_page_size(0), 1);
5445 }
5446
5447 #[test]
5448 fn key_repeat_is_dispatched_but_release_is_not() {
5449 assert!(should_dispatch_key(KeyEventKind::Press));
5450 assert!(should_dispatch_key(KeyEventKind::Repeat));
5451 assert!(!should_dispatch_key(KeyEventKind::Release));
5452 }
5453
5454 #[test]
5455 fn test_layout_renders_welcome_message() {
5456 let chat = Arc::new(Container::new());
5457 add_welcome_message(&chat);
5458
5459 let scroll = Arc::new(ScrollView::new(
5460 chat.clone(),
5461 ScrollViewOptions {
5462 follow: FollowMode::End,
5463 primary: true,
5464 ..Default::default()
5465 },
5466 ));
5467
5468 let editor = Arc::new(Editor::new(
5469 EditorOptions {
5470 padding_x: 1,
5471 ..Default::default()
5472 },
5473 EditorStyle::default(),
5474 Arc::new(rpi_tui::Keybindings::new()),
5475 ));
5476 let dock = Arc::new(Container::new());
5477 dock.add_child(editor);
5478
5479 let footer = Arc::new(FooterComponent::new());
5480
5481 let root = VStack::from_children(vec![
5482 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
5483 StackChild::Entry(StackEntry::new(dock)),
5484 StackChild::Entry(StackEntry::new(footer)),
5485 ]);
5486
5487 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
5488
5489 let all: String = frame.lines.join("\n");
5490 assert!(
5491 all.contains("rpi"),
5492 "Welcome message not found. Rendered: {}",
5493 all
5494 );
5495 assert!(
5496 all.contains("Type your message"),
5497 "Help text not found. Rendered: {}",
5498 all
5499 );
5500 }
5501
5502 #[test]
5503 fn test_chat_container_has_welcome_content() {
5504 let chat = Arc::new(Container::new());
5505 add_welcome_message(&chat);
5506
5507 let lines = chat.render(80);
5508 let all: String = lines.join("\n");
5509 let plain = strip_ansi(&all);
5512 assert!(
5513 plain.contains("rpi"),
5514 "Welcome message not in chat container: {:?}",
5515 lines
5516 );
5517 }
5518
5519 #[test]
5525 fn tab_accept_suggestion_reflects_in_next_render() {
5526 use rpi_tui::render_layout_frame;
5527
5528 let editor = Arc::new(Editor::new(
5529 EditorOptions {
5530 padding_x: 1,
5531 ..Default::default()
5532 },
5533 EditorStyle::default(),
5534 Arc::new(rpi_tui::Keybindings::new()),
5535 ));
5536 editor.set_focused(true);
5537 let editor_container = Arc::new(Container::new());
5538 editor_container.add_child(editor.clone());
5539 let autocomplete_container = Arc::new(Container::new());
5540 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
5541 let dock = Arc::new(VStack::from_children(vec![
5542 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5543 StackChild::Entry(
5544 StackEntry::new(editor_container.clone())
5545 .shrink(0)
5546 .min_size(3),
5547 ),
5548 StackChild::Entry(StackEntry::new(footer)),
5549 ]));
5550
5551 let mut manager = AutocompleteManager::new();
5553 let mut combined = CombinedAutocompleteProvider::new();
5554 combined.add_provider(Arc::new(
5555 SlashCommandAutocompleteProvider::with_default_commands(),
5556 ));
5557 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
5558 manager.set_provider(Arc::new(combined));
5559 editor.insert("/mo");
5562 assert_eq!(editor.cursor_position(), (0, 3));
5563
5564 let frame_before = render_layout_frame(dock.clone(), 80, 10);
5565 assert!(
5566 frame_before.lines.iter().any(|l| l.contains("/mo")),
5567 "precondition: editor shows the typed prefix. Frame rows:\n{}",
5568 frame_before
5569 .lines
5570 .iter()
5571 .map(|l| format!(" [{l}]"))
5572 .collect::<Vec<_>>()
5573 .join("\n")
5574 );
5575
5576 let text = editor.get_text();
5578 let (_row, col) = editor.cursor_position();
5579 let cursor = col.min(text.len());
5580 let sugg = manager
5581 .get_suggestions(&text, cursor)
5582 .expect("slash suggestions for /mo");
5583 let top = sugg.items.first().expect("at least one suggestion");
5584 let start = sugg.start.min(text.len());
5585 let end = sugg.end.min(text.len());
5586 let mut replaced = String::new();
5587 replaced.push_str(&text[..start]);
5588 replaced.push_str(&top.text);
5589 replaced.push_str(&text[end..]);
5590 if top.insert_space && !replaced.ends_with('/') {
5591 replaced.push(' ');
5592 }
5593 editor.set_text(&replaced);
5594 editor.set_cursor(0, replaced.len().min(start + top.text.len()));
5595 autocomplete_container.clear();
5596 assert_eq!(editor.get_text(), "/model");
5597
5598 let frame_after = render_layout_frame(dock, 80, 10);
5600 let all: String = frame_after.lines.join("\n");
5601 assert!(
5602 all.contains("/model"),
5603 "completed text missing from next render. Got:\n{all}"
5604 );
5605 let editor_line = frame_after
5609 .lines
5610 .iter()
5611 .find(|l| l.contains("/model"))
5612 .expect("editor row with completed text");
5613 assert!(
5614 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
5615 "caret must follow the full completed text. Got: {editor_line:?}"
5616 );
5617 }
5618
5619 #[test]
5620 fn test_slash_command_dispatch() {
5621 let registry = build_builtin_registry();
5626
5627 let resolves_to = |token: &str, canonical: &str| {
5629 let found = registry.find(token).expect("{token} should resolve");
5630 assert_eq!(
5631 found.name(),
5632 canonical,
5633 "{token} resolved to {} (expected {canonical})",
5634 found.name()
5635 );
5636 };
5637
5638 resolves_to("/help", "/help");
5639 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
5641 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
5645 resolves_to("/v", "/version"); resolves_to("/hotkeys", "/hotkeys");
5647 resolves_to("/model", "/model");
5648 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
5650 resolves_to("/session", "/session");
5651 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
5653 resolves_to("/copy", "/copy");
5654 resolves_to("/thinking", "/thinking");
5655 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
5657 resolves_to("/images", "/images");
5658 resolves_to("/armin", "/armin");
5659 resolves_to("/earendil", "/earendil");
5660 resolves_to("/context", "/context");
5661 resolves_to("/settings", "/settings");
5663 resolves_to("/name", "/name");
5664 resolves_to("/export", "/export");
5665
5666 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
5668 }
5669
5670 #[test]
5671
5672 fn test_registry_visible_entries_cover_dispatch() {
5673 let registry = build_builtin_registry();
5678 let names: Vec<String> = registry
5679 .visible_entries()
5680 .iter()
5681 .map(|c| c.name.clone())
5682 .collect();
5683 for recognized in [
5684 "/help",
5685 "/clear",
5686 "/new",
5687 "/exit",
5688 "/quit",
5689 "/version",
5690 "/model",
5691 "/session",
5692 "/theme",
5693 "/compact",
5694 "/copy",
5695 "/hotkeys",
5696 "/tools",
5697 "/images",
5698 "/thinking",
5699 "/armin",
5700 "/earendil",
5701 ] {
5702 assert!(
5703 names.contains(&recognized.to_string()),
5704 "{recognized} missing from autocomplete list"
5705 );
5706 }
5707 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
5709 assert!(
5710 !names.contains(&hidden.to_string()),
5711 "{hidden} should be hidden from autocomplete"
5712 );
5713 }
5714 }
5715
5716 #[test]
5717 fn test_agent_event_mapping_creates_assistant_and_tool() {
5718 use rpi_ai::types::{
5721 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
5722 ToolCall, ToolCallType, Usage,
5723 };
5724
5725 let state = Arc::new(TuiState {
5726 current_assistant: std::sync::Mutex::new(None),
5727 tool_components: std::sync::Mutex::new(HashMap::new()),
5728 bash_components: std::sync::Mutex::new(HashMap::new()),
5729 last_tool_comp: std::sync::Mutex::new(None),
5730 status: std::sync::Mutex::new(RunStatus::Idle),
5731 footer: Arc::new(FooterComponent::new()),
5732 status_container: Arc::new(Container::new()),
5733 chat_container: Arc::new(Container::new()),
5734 loader: Arc::new(Loader::new()),
5735 last_assistant_text: std::sync::Mutex::new(String::new()),
5736 active_selector: std::sync::Mutex::new(None),
5737 active_extension_editor: std::sync::Mutex::new(None),
5738 autocomplete: AutocompleteManager::new(),
5739 autocomplete_container: Arc::new(Container::new()),
5740 theme_manager: Arc::new(ThemeManager::new()),
5741 tui: None,
5742 current_model_id: std::sync::Mutex::new(String::new()),
5743 show_images: std::sync::Mutex::new(true),
5744 history: std::sync::Mutex::new(Vec::new()),
5745 history_index: std::sync::Mutex::new(-1),
5746 history_draft: std::sync::Mutex::new(None),
5747 last_input_tokens: std::sync::Mutex::new(0),
5748 scoped_edit: std::sync::Mutex::new(None),
5749 markdown_transformer: std::sync::Mutex::new(None),
5750 extension_session: Arc::new(std::sync::Mutex::new(
5751 rpi_extensions::ExtensionSession::none(),
5752 )),
5753 });
5754
5755 let assistant = AssistantMessage {
5759 role: rpi_ai::types::AssistantRole,
5760 content: vec![
5761 Content::Thinking(ThinkingContent {
5762 kind: ThinkingContentType,
5763 thinking: "Reasoning about the reply.".into(),
5764 thinking_signature: None,
5765 redacted: false,
5766 }),
5767 Content::Text(TextContent {
5768 kind: TextContentType,
5769 text: "Hello.".into(),
5770 text_signature: None,
5771 }),
5772 Content::ToolCall(ToolCall {
5773 kind: ToolCallType,
5774 id: "tc1".into(),
5775 name: "bash".into(),
5776 arguments: serde_json::json!({"command": "echo hi"}),
5777 thought_signature: None,
5778 namespace: None,
5779 }),
5780 ],
5781 api: rpi_ai::Api::AnthropicMessages,
5782 provider: "anthropic".into(),
5783 model: "claude-sonnet-5".into(),
5784 response_model: None,
5785 response_id: None,
5786 usage: Usage::zero(),
5787 stop_reason: StopReason::Stop,
5788 deferred: None,
5789 error_message: None,
5790 raw_stop_reason: None,
5791 end_turn: None,
5792 timestamp: 0,
5793 };
5794
5795 let comp = Arc::new(AssistantMessageComponent::new(
5798 AssistantMessageOptions::default(),
5799 ));
5800 comp.set_streaming(true);
5801 comp.update_blocks(&assistant_blocks(&assistant));
5802 let chat = Arc::new(Container::new());
5803 chat.add_child(comp.clone());
5804 *state.current_assistant.lock().unwrap() = Some(comp);
5805
5806 for c in &assistant.content {
5808 if let Content::ToolCall(tc) = c {
5809 let mut tools = state.tool_components.lock().unwrap();
5810 if !tools.contains_key(&tc.id) {
5811 let tc_comp = Arc::new(ToolExecutionComponent::new(
5812 &tc.name,
5813 &tc.arguments.to_string(),
5814 ));
5815 tc_comp.set_running();
5816 chat.add_child(tc_comp.clone());
5817 tools.insert(tc.id.clone(), tc_comp);
5818 }
5819 }
5820 }
5821
5822 let rendered = chat.render(80);
5826 let joined: String = rendered.join("\n");
5827 assert!(
5828 joined.contains("Hello."),
5829 "assistant text not rendered: {joined}"
5830 );
5831 assert!(
5832 joined.contains("Reasoning about the reply."),
5833 "thinking block not rendered: {joined}"
5834 );
5835 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
5836 assert!(state.current_assistant.lock().unwrap().is_some());
5837
5838 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
5840 ended.set_result("hi", false);
5841 assert!(state.tool_components.lock().unwrap().is_empty());
5842
5843 assert!(state.try_start_working());
5847 assert!(
5848 !state.try_start_working(),
5849 "a second submit must be rejected"
5850 );
5851 state.set_status(RunStatus::Idle);
5852 state.set_status(RunStatus::Working);
5853 assert_eq!(state.status_container.child_count(), 1);
5854 {
5855 let mut bash = state.bash_components.lock().unwrap();
5856 bash.insert(
5857 "bash-1".into(),
5858 Arc::new(BashExecutionComponent::new("one")),
5859 );
5860 bash.insert(
5861 "bash-2".into(),
5862 Arc::new(BashExecutionComponent::new("two")),
5863 );
5864 }
5865 state.sync_working_loader_with_bash();
5866 assert_eq!(state.status_container.child_count(), 0);
5867 state.bash_components.lock().unwrap().remove("bash-1");
5868 state.sync_working_loader_with_bash();
5869 assert_eq!(state.status_container.child_count(), 0);
5870 state.bash_components.lock().unwrap().remove("bash-2");
5871 state.sync_working_loader_with_bash();
5872 assert_eq!(state.status_container.child_count(), 1);
5873
5874 state.set_status(RunStatus::Aborting);
5875 assert_eq!(state.status_container.child_count(), 0);
5876 assert!(!state.loader.is_running());
5877 }
5878
5879 #[test]
5880 fn fresh_launch_does_not_restore_old_history() {
5881 let fresh = Args::default();
5882 assert!(!launch_restores_history(&fresh));
5883
5884 let continued = Args {
5885 continue_session: true,
5886 ..Args::default()
5887 };
5888 assert!(launch_restores_history(&continued));
5889
5890 let selected = Args {
5891 session: Some("session-id".into()),
5892 ..Args::default()
5893 };
5894 assert!(launch_restores_history(&selected));
5895 }
5896
5897 #[test]
5898 fn test_short_model_name() {
5899 assert_eq!(
5900 short_model_name("anthropic:claude-sonnet-5"),
5901 "claude-sonnet-5"
5902 );
5903 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
5904 }
5905
5906 #[test]
5907 fn test_cycle_next_model_wraps_around() {
5908 use rpi_ai::{Api, Model};
5909 let mk = |id: &str| {
5910 Model::new(
5911 id,
5912 id,
5913 Api::AnthropicMessages,
5914 "anthropic",
5915 "https://api.anthropic.com",
5916 )
5917 };
5918 let catalog = [mk("a"), mk("b"), mk("c")];
5919 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
5921 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
5922 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
5924 let empty: Vec<Model> = vec![];
5926 assert!(cycle_next_model(&empty, "a").is_none());
5927 }
5928
5929 #[test]
5930 fn test_autocomplete_slash_suggestions_render() {
5931 let state = Arc::new(TuiState {
5934 current_assistant: std::sync::Mutex::new(None),
5935 tool_components: std::sync::Mutex::new(HashMap::new()),
5936 bash_components: std::sync::Mutex::new(HashMap::new()),
5937 last_tool_comp: std::sync::Mutex::new(None),
5938 status: std::sync::Mutex::new(RunStatus::Idle),
5939 footer: Arc::new(FooterComponent::new()),
5940 status_container: Arc::new(Container::new()),
5941 chat_container: Arc::new(Container::new()),
5942 loader: Arc::new(Loader::new()),
5943 last_assistant_text: std::sync::Mutex::new(String::new()),
5944 active_selector: std::sync::Mutex::new(None),
5945 active_extension_editor: std::sync::Mutex::new(None),
5946 autocomplete: AutocompleteManager::new(),
5947 autocomplete_container: Arc::new(Container::new()),
5948 theme_manager: Arc::new(ThemeManager::new()),
5949 tui: None,
5950 current_model_id: std::sync::Mutex::new(String::new()),
5951 show_images: std::sync::Mutex::new(true),
5952 history: std::sync::Mutex::new(Vec::new()),
5953 history_index: std::sync::Mutex::new(-1),
5954 history_draft: std::sync::Mutex::new(None),
5955 last_input_tokens: std::sync::Mutex::new(0),
5956 scoped_edit: std::sync::Mutex::new(None),
5957 markdown_transformer: std::sync::Mutex::new(None),
5958 extension_session: Arc::new(std::sync::Mutex::new(
5959 rpi_extensions::ExtensionSession::none(),
5960 )),
5961 });
5962 {
5963 let mut combined = CombinedAutocompleteProvider::new();
5964 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5965 build_builtin_registry().visible_entries(),
5966 )));
5967 state.autocomplete.set_provider(Arc::new(combined));
5968 }
5969
5970 let editor = Arc::new(Editor::simple());
5971 editor.set_text("/he");
5972 editor.set_cursor(0, 3);
5973 refresh_autocomplete(&state, &editor);
5974 let lines = state.autocomplete_container.render(80);
5975 let joined: String = lines.join("\n");
5976 assert!(
5977 joined.contains("/help"),
5978 "slash suggestions not rendered: {joined}"
5979 );
5980
5981 editor.set_text("hello");
5983 editor.set_cursor(0, 5);
5984 refresh_autocomplete(&state, &editor);
5985 assert!(state.autocomplete_container.render(80).is_empty());
5986 }
5987
5988 #[test]
5989 fn test_select_list_swap_restores_editor() {
5990 let state = Arc::new(TuiState {
5994 current_assistant: std::sync::Mutex::new(None),
5995 tool_components: std::sync::Mutex::new(HashMap::new()),
5996 bash_components: std::sync::Mutex::new(HashMap::new()),
5997 last_tool_comp: std::sync::Mutex::new(None),
5998 status: std::sync::Mutex::new(RunStatus::Idle),
5999 footer: Arc::new(FooterComponent::new()),
6000 status_container: Arc::new(Container::new()),
6001 chat_container: Arc::new(Container::new()),
6002 loader: Arc::new(Loader::new()),
6003 last_assistant_text: std::sync::Mutex::new(String::new()),
6004 active_selector: std::sync::Mutex::new(None),
6005 active_extension_editor: std::sync::Mutex::new(None),
6006 autocomplete: AutocompleteManager::new(),
6007 autocomplete_container: Arc::new(Container::new()),
6008 theme_manager: Arc::new(ThemeManager::new()),
6009 tui: None,
6010 current_model_id: std::sync::Mutex::new(String::new()),
6011 show_images: std::sync::Mutex::new(true),
6012 history: std::sync::Mutex::new(Vec::new()),
6013 history_index: std::sync::Mutex::new(-1),
6014 history_draft: std::sync::Mutex::new(None),
6015 last_input_tokens: std::sync::Mutex::new(0),
6016 scoped_edit: std::sync::Mutex::new(None),
6017 markdown_transformer: std::sync::Mutex::new(None),
6018 extension_session: Arc::new(std::sync::Mutex::new(
6019 rpi_extensions::ExtensionSession::none(),
6020 )),
6021 });
6022 let editor_container = Arc::new(Container::new());
6023 let editor = Arc::new(Editor::simple());
6024 editor_container.add_child(editor.clone());
6025 assert!(!state.selector_open());
6026
6027 let tui_terminal = Box::new(ProcessTerminal::new());
6028 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
6029 let list = Arc::new(SelectList::new(
6030 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
6031 5,
6032 ));
6033 open_selector(
6034 &state,
6035 &editor_container,
6036 &editor,
6037 &tui,
6038 list,
6039 SelectorKind::Theme,
6040 );
6041 assert!(state.selector_open());
6042 assert_eq!(editor_container.child_count(), 1);
6044
6045 close_selector(&state, &editor_container, &editor, &tui);
6046 assert!(!state.selector_open());
6047 assert_eq!(editor_container.child_count(), 1);
6049 }
6050
6051 #[test]
6052 fn test_message_history_browse_restores_draft() {
6053 let state = Arc::new(TuiState {
6057 current_assistant: std::sync::Mutex::new(None),
6058 tool_components: std::sync::Mutex::new(HashMap::new()),
6059 bash_components: std::sync::Mutex::new(HashMap::new()),
6060 last_tool_comp: std::sync::Mutex::new(None),
6061 status: std::sync::Mutex::new(RunStatus::Idle),
6062 footer: Arc::new(FooterComponent::new()),
6063 status_container: Arc::new(Container::new()),
6064 chat_container: Arc::new(Container::new()),
6065 loader: Arc::new(Loader::new()),
6066 last_assistant_text: std::sync::Mutex::new(String::new()),
6067 active_selector: std::sync::Mutex::new(None),
6068 active_extension_editor: std::sync::Mutex::new(None),
6069 autocomplete: AutocompleteManager::new(),
6070 autocomplete_container: Arc::new(Container::new()),
6071 theme_manager: Arc::new(ThemeManager::new()),
6072 tui: None,
6073 current_model_id: std::sync::Mutex::new(String::new()),
6074 show_images: std::sync::Mutex::new(true),
6075 history: std::sync::Mutex::new(Vec::new()),
6076 history_index: std::sync::Mutex::new(-1),
6077 history_draft: std::sync::Mutex::new(None),
6078 last_input_tokens: std::sync::Mutex::new(0),
6079 scoped_edit: std::sync::Mutex::new(None),
6080 markdown_transformer: std::sync::Mutex::new(None),
6081 extension_session: Arc::new(std::sync::Mutex::new(
6082 rpi_extensions::ExtensionSession::none(),
6083 )),
6084 });
6085 let editor = Arc::new(Editor::simple());
6086
6087 push_history(&state, "first message");
6088 push_history(&state, "second message");
6089 push_history(&state, "second message");
6091 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
6093 assert_eq!(state.history.lock().unwrap()[0], "second message");
6094
6095 editor.set_text("half-typed");
6097 editor.set_cursor(0, 11);
6098
6099 navigate_history(&state, &editor, -1);
6101 assert_eq!(editor.get_text(), "second message");
6102 assert_eq!(*state.history_index.lock().unwrap(), 0);
6103 navigate_history(&state, &editor, -1);
6105 assert_eq!(editor.get_text(), "first message");
6106 assert_eq!(*state.history_index.lock().unwrap(), 1);
6107 navigate_history(&state, &editor, -1);
6109 assert_eq!(editor.get_text(), "first message");
6110 navigate_history(&state, &editor, 1);
6112 assert_eq!(editor.get_text(), "second message");
6113 navigate_history(&state, &editor, 1);
6115 assert_eq!(editor.get_text(), "half-typed");
6116 assert_eq!(*state.history_index.lock().unwrap(), -1);
6117 }
6118
6119 #[test]
6120 fn test_accept_top_suggestion_replaces_prefix() {
6121 let state = Arc::new(TuiState {
6123 current_assistant: std::sync::Mutex::new(None),
6124 tool_components: std::sync::Mutex::new(HashMap::new()),
6125 bash_components: std::sync::Mutex::new(HashMap::new()),
6126 last_tool_comp: std::sync::Mutex::new(None),
6127 status: std::sync::Mutex::new(RunStatus::Idle),
6128 footer: Arc::new(FooterComponent::new()),
6129 status_container: Arc::new(Container::new()),
6130 chat_container: Arc::new(Container::new()),
6131 loader: Arc::new(Loader::new()),
6132 last_assistant_text: std::sync::Mutex::new(String::new()),
6133 active_selector: std::sync::Mutex::new(None),
6134 active_extension_editor: std::sync::Mutex::new(None),
6135 autocomplete: AutocompleteManager::new(),
6136 autocomplete_container: Arc::new(Container::new()),
6137 theme_manager: Arc::new(ThemeManager::new()),
6138 tui: None,
6139 current_model_id: std::sync::Mutex::new(String::new()),
6140 show_images: std::sync::Mutex::new(true),
6141 history: std::sync::Mutex::new(Vec::new()),
6142 history_index: std::sync::Mutex::new(-1),
6143 history_draft: std::sync::Mutex::new(None),
6144 last_input_tokens: std::sync::Mutex::new(0),
6145 scoped_edit: std::sync::Mutex::new(None),
6146 markdown_transformer: std::sync::Mutex::new(None),
6147 extension_session: Arc::new(std::sync::Mutex::new(
6148 rpi_extensions::ExtensionSession::none(),
6149 )),
6150 });
6151 {
6152 let mut combined = CombinedAutocompleteProvider::new();
6153 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
6154 build_builtin_registry().visible_entries(),
6155 )));
6156 state.autocomplete.set_provider(Arc::new(combined));
6157 }
6158 let editor = Arc::new(Editor::simple());
6159 editor.set_text("/he");
6160 editor.set_cursor(0, 3);
6161 refresh_autocomplete(&state, &editor);
6162 let accepted = accept_top_suggestion(&state, &editor);
6163 assert!(accepted, "should accept the top suggestion");
6164 let text = editor.get_text();
6165 assert!(
6166 text.starts_with("/help"),
6167 "editor text should start with /help, got {text}"
6168 );
6169 }
6170}