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 QueueInput {
1291 prompt: String,
1292 follow_up: bool,
1293 },
1294 OpenTree,
1295 NavigateTree(String),
1296 Exit,
1297 ClearChat,
1299 Compact,
1301 Copy,
1303 SwitchSession(String),
1306 ExportSession,
1308 ForkSession,
1310 SetSessionName(String),
1312 ImportSession(String),
1315 ShareSession,
1318 ReloadExtensions,
1323}
1324
1325fn assistant_text(msg: &AssistantMessage) -> String {
1328 msg.content
1329 .iter()
1330 .filter_map(|c| match c {
1331 Content::Text(t) => Some(t.text.clone()),
1332 _ => None,
1333 })
1334 .collect()
1335}
1336
1337fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
1340 match &msg.content {
1341 rpi_ai::types::UserContent::Text(s) => s.clone(),
1342 rpi_ai::types::UserContent::Blocks(blocks) => blocks
1343 .iter()
1344 .filter_map(|c| match c {
1345 Content::Text(t) => Some(t.text.clone()),
1346 _ => None,
1347 })
1348 .collect(),
1349 }
1350}
1351
1352fn show_settings_panel(chat: &Arc<Container>) {
1357 let s = crate::settings::load_settings().unwrap_or_default();
1358 let mut lines: Vec<String> = Vec::new();
1359 lines.push("⚙️ Saved settings:".into());
1360 lines.push(format!(
1361 " Theme: {} (edit with /theme)",
1362 s.theme.as_deref().unwrap_or("(default)")
1363 ));
1364 lines.push(format!(
1365 " Default model: {} (set at launch with --model)",
1366 s.default_model.as_deref().unwrap_or("(none)")
1367 ));
1368 lines.push(format!(
1369 " Default thinking: {} (set at launch with --thinking)",
1370 s.default_thinking_level.as_deref().unwrap_or("(default)")
1371 ));
1372 match &s.scoped_models {
1373 Some(list) if !list.is_empty() => lines.push(format!(
1374 " Ctrl+M cycle scope: {} (edit with /scoped-models)",
1375 list.join(", ")
1376 )),
1377 _ => lines.push(" Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
1378 }
1379 let body = lines.join("\n");
1380 container_note_block(chat, &body);
1381}
1382
1383fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
1387 let scoped = crate::settings::load_settings()
1388 .ok()
1389 .and_then(|s| s.scoped_models)
1390 .unwrap_or_default();
1391 if scoped.is_empty() {
1392 return catalog.to_vec();
1393 }
1394 let mut out: Vec<rpi_ai::Model> = catalog
1395 .iter()
1396 .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
1397 .cloned()
1398 .collect();
1399 if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
1401 if let Some(cur) = catalog
1402 .iter()
1403 .find(|m| m.id.eq_ignore_ascii_case(current_id))
1404 {
1405 out.push(cur.clone());
1406 }
1407 }
1408 out
1409}
1410
1411fn open_settings_selector(
1418 state: &Arc<TuiState>,
1419 editor_container: &Arc<Container>,
1420 editor: &Arc<Editor>,
1421 tui: &Arc<TuiAltScreen>,
1422 lane: &Arc<dyn AgentLane>,
1423 catalog: &[rpi_ai::Model],
1424 lane_model_id: &str,
1425 chat: &Arc<Container>,
1426) {
1427 let settings = crate::settings::load_settings().unwrap_or_default();
1428 let mut items: Vec<SelectItem> = Vec::new();
1429 items.push(
1430 SelectItem::new("theme", "Theme")
1431 .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
1432 );
1433 items.push(
1434 SelectItem::new("model", "Default model").with_description(
1435 &settings
1436 .default_model
1437 .clone()
1438 .unwrap_or_else(|| "(none)".into()),
1439 ),
1440 );
1441 items.push(
1442 SelectItem::new("thinking", "Default thinking").with_description(
1443 &settings
1444 .default_thinking_level
1445 .clone()
1446 .unwrap_or_else(|| "(default)".into()),
1447 ),
1448 );
1449 let scope_desc = match &settings.scoped_models {
1450 Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
1451 _ => "all models".to_string(),
1452 };
1453 items
1454 .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
1455 let list = Arc::new(SelectList::new(items, 10));
1456
1457 let state_sel = state.clone();
1458 let ec_sel = editor_container.clone();
1459 let editor_sel = editor.clone();
1460 let tui_sel = tui.clone();
1461 let lane_sel = lane.clone();
1462 let chat_sel = chat.clone();
1463 let catalog_sel = catalog.to_vec();
1464 let lane_model_sel = lane_model_id.to_string();
1465 list.on_select(Arc::new(move |item| {
1466 match item.value.as_str() {
1469 "theme" => {
1470 open_settings_theme_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel, &chat_sel)
1471 }
1472 "model" => open_settings_model_selector(
1473 &state_sel,
1474 &ec_sel,
1475 &editor_sel,
1476 &tui_sel,
1477 &lane_sel,
1478 &catalog_sel,
1479 &lane_model_sel,
1480 &chat_sel,
1481 ),
1482 "thinking" => open_settings_thinking_selector(
1483 &state_sel,
1484 &ec_sel,
1485 &editor_sel,
1486 &tui_sel,
1487 &lane_sel,
1488 &catalog_sel,
1489 &lane_model_sel,
1490 &chat_sel,
1491 ),
1492 "scoped-models" => open_scoped_models_selector(
1493 &state_sel,
1494 &ec_sel,
1495 &editor_sel,
1496 &tui_sel,
1497 &catalog_sel,
1498 &chat_sel,
1499 ),
1500 _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
1501 }
1502 }));
1503 let state_cancel = state.clone();
1504 let ec_cancel = editor_container.clone();
1505 let editor_cancel = editor.clone();
1506 let tui_cancel = tui.clone();
1507 list.on_cancel(Arc::new(move || {
1508 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1509 }));
1510
1511 open_selector(
1512 state,
1513 editor_container,
1514 editor,
1515 tui,
1516 list,
1517 SelectorKind::Settings,
1518 );
1519}
1520
1521fn open_settings_theme_selector(
1523 state: &Arc<TuiState>,
1524 editor_container: &Arc<Container>,
1525 editor: &Arc<Editor>,
1526 tui: &Arc<TuiAltScreen>,
1527 chat: &Arc<Container>,
1528) {
1529 let items = vec![
1530 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
1531 SelectItem::new("light", "Light").with_description("Light background"),
1532 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
1533 ];
1534 let list = Arc::new(SelectList::new(items, 10));
1535
1536 let state_sel = state.clone();
1537 let ec_sel = editor_container.clone();
1538 let editor_sel = editor.clone();
1539 let tui_sel = tui.clone();
1540 let chat_sel = chat.clone();
1541 list.on_select(Arc::new(move |item| {
1542 let preset = match item.value.as_str() {
1543 "light" => ThemePreset::Light,
1544 "monochrome" => ThemePreset::Monochrome,
1545 _ => ThemePreset::Dark,
1546 };
1547 apply_theme_preset(preset);
1548 let mut settings = crate::settings::load_settings().unwrap_or_default();
1549 settings.theme = Some(item.value.clone());
1550 let saved = crate::settings::save_settings(&settings);
1551 add_note_message(
1552 &chat_sel,
1553 &format!(
1554 "Theme set to {} (saved{})",
1555 item.label,
1556 if saved.is_ok() { "" } else { ", not saved" },
1557 ),
1558 );
1559 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1560 tui_sel.render_now(true);
1561 }));
1562 let state_cancel = state.clone();
1563 let ec_cancel = editor_container.clone();
1564 let editor_cancel = editor.clone();
1565 let tui_cancel = tui.clone();
1566 list.on_cancel(Arc::new(move || {
1567 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1568 }));
1569
1570 open_selector(
1571 state,
1572 editor_container,
1573 editor,
1574 tui,
1575 list,
1576 SelectorKind::Settings,
1577 );
1578}
1579
1580fn open_settings_model_selector(
1584 state: &Arc<TuiState>,
1585 editor_container: &Arc<Container>,
1586 editor: &Arc<Editor>,
1587 tui: &Arc<TuiAltScreen>,
1588 lane: &Arc<dyn AgentLane>,
1589 catalog: &[rpi_ai::Model],
1590 lane_model_id: &str,
1591 chat: &Arc<Container>,
1592) {
1593 let mut items: Vec<SelectItem> = Vec::new();
1594 for m in catalog {
1595 let label = if m.name.is_empty() {
1596 short_model_name(&m.id)
1597 } else {
1598 m.name.clone()
1599 };
1600 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
1601 " (current)"
1602 } else {
1603 ""
1604 };
1605 items.push(
1606 SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)),
1607 );
1608 }
1609 if items.is_empty() {
1610 add_note_message(chat, "No models in the catalog.");
1611 tui.request_render(false);
1612 return;
1613 }
1614 let list = Arc::new(SelectList::new(items, 10));
1615
1616 let catalog_arc = catalog.to_vec();
1617 let state_sel = state.clone();
1618 let ec_sel = editor_container.clone();
1619 let editor_sel = editor.clone();
1620 let tui_sel = tui.clone();
1621 let chat_sel = chat.clone();
1622 let lane_sel = lane.clone();
1623 list.on_select(Arc::new(move |item| {
1624 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
1625 add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
1626 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1627 return;
1628 };
1629 state_sel.set_current_model(&model);
1630 let lane = lane_sel.clone();
1631 tokio::spawn(async move {
1632 let _ = lane.set_model(model).await;
1633 });
1634 let mut settings = crate::settings::load_settings().unwrap_or_default();
1635 settings.default_model = Some(item.value.clone());
1636 let saved = crate::settings::save_settings(&settings);
1637 add_note_message(
1638 &chat_sel,
1639 &format!(
1640 "Default model set to {} (saved{}",
1641 short_model_name(&item.value),
1642 if saved.is_ok() { ")" } else { ", not saved)" },
1643 ),
1644 );
1645 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1646 }));
1647 let state_cancel = state.clone();
1648 let ec_cancel = editor_container.clone();
1649 let editor_cancel = editor.clone();
1650 let tui_cancel = tui.clone();
1651 list.on_cancel(Arc::new(move || {
1652 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1653 }));
1654
1655 open_selector(
1656 state,
1657 editor_container,
1658 editor,
1659 tui,
1660 list,
1661 SelectorKind::Settings,
1662 );
1663}
1664
1665fn open_settings_thinking_selector(
1669 state: &Arc<TuiState>,
1670 editor_container: &Arc<Container>,
1671 editor: &Arc<Editor>,
1672 tui: &Arc<TuiAltScreen>,
1673 lane: &Arc<dyn AgentLane>,
1674 catalog: &[rpi_ai::Model],
1675 lane_model_id: &str,
1676 chat: &Arc<Container>,
1677) {
1678 let model = catalog
1679 .iter()
1680 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
1681 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
1682 .map(|m| m.supported_thinking_levels())
1683 .unwrap_or_else(|| {
1684 use rpi_ai::types::ThinkingLevel::*;
1685 vec![Off, Minimal, Low, Medium, High]
1686 });
1687 let mut items: Vec<SelectItem> = Vec::new();
1688 for lvl in &levels {
1689 let name = thinking_level_name(*lvl);
1690 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
1691 }
1692 if items.is_empty() {
1693 add_note_message(chat, "This model has no supported thinking levels.");
1694 tui.request_render(false);
1695 return;
1696 }
1697 let list = Arc::new(SelectList::new(items, 10));
1698
1699 let state_sel = state.clone();
1700 let ec_sel = editor_container.clone();
1701 let editor_sel = editor.clone();
1702 let tui_sel = tui.clone();
1703 let chat_sel = chat.clone();
1704 let lane_sel = lane.clone();
1705 list.on_select(Arc::new(move |item| {
1706 let Some(level) = thinking_level_from_name(&item.value) else {
1707 add_note_message(
1708 &chat_sel,
1709 &format!("Unknown thinking level: {}.", item.label),
1710 );
1711 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1712 return;
1713 };
1714 let lane = lane_sel.clone();
1715 let footer_sel = state_sel.footer.clone();
1716 tokio::spawn(async move {
1717 let _ = lane.set_thinking_level(level).await;
1718 });
1719 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
1720 let mut settings = crate::settings::load_settings().unwrap_or_default();
1721 settings.default_thinking_level = Some(item.value.clone());
1722 let saved = crate::settings::save_settings(&settings);
1723 add_note_message(
1724 &chat_sel,
1725 &format!(
1726 "Default thinking set to {} (saved{}",
1727 item.label,
1728 if saved.is_ok() { ")" } else { ", not saved)" },
1729 ),
1730 );
1731 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1732 }));
1733 let state_cancel = state.clone();
1734 let ec_cancel = editor_container.clone();
1735 let editor_cancel = editor.clone();
1736 let tui_cancel = tui.clone();
1737 list.on_cancel(Arc::new(move || {
1738 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1739 }));
1740
1741 open_selector(
1742 state,
1743 editor_container,
1744 editor,
1745 tui,
1746 list,
1747 SelectorKind::Settings,
1748 );
1749}
1750
1751fn open_scoped_models_selector(
1756 state: &Arc<TuiState>,
1757 editor_container: &Arc<Container>,
1758 editor: &Arc<Editor>,
1759 tui: &Arc<TuiAltScreen>,
1760 catalog: &[rpi_ai::Model],
1761 chat: &Arc<Container>,
1762) {
1763 if catalog.is_empty() {
1764 add_note_message(chat, "No models in the catalog.");
1765 tui.request_render(false);
1766 return;
1767 }
1768 let seed: Vec<String> = crate::settings::load_settings()
1770 .ok()
1771 .and_then(|s| s.scoped_models)
1772 .unwrap_or_default();
1773 *state.scoped_edit.lock().unwrap() = Some(seed);
1774
1775 let mut items: Vec<SelectItem> = Vec::new();
1776 for m in catalog {
1777 items.push(SelectItem::new(&m.id, &m.id));
1778 }
1779 let list = Arc::new(SelectList::new(items, 10));
1780
1781 let state_sel = state.clone();
1782 let chat_sel = chat.clone();
1783 let tui_sel = tui.clone();
1784 list.on_select(Arc::new(move |item| {
1785 let mut set = state_sel.scoped_edit.lock().unwrap();
1787 let set = set.get_or_insert_with(Vec::new);
1788 if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
1789 set.remove(pos);
1790 add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
1791 } else {
1792 set.push(item.value.clone());
1793 add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
1794 }
1795 tui_sel.request_render(false);
1796 }));
1797 let state_cancel = state.clone();
1798 let ec_cancel = editor_container.clone();
1799 let editor_cancel = editor.clone();
1800 let tui_cancel = tui.clone();
1801 let chat_cancel = chat.clone();
1802 list.on_cancel(Arc::new(move || {
1803 let set = state_cancel
1805 .scoped_edit
1806 .lock()
1807 .unwrap()
1808 .take()
1809 .unwrap_or_default();
1810 let mut settings = crate::settings::load_settings().unwrap_or_default();
1811 settings.scoped_models = if set.is_empty() {
1812 None
1813 } else {
1814 Some(set.clone())
1815 };
1816 match crate::settings::save_settings(&settings) {
1817 Ok(()) => {
1818 if set.is_empty() {
1819 add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
1820 } else {
1821 add_note_message(
1822 &chat_cancel,
1823 &format!("Ctrl+M cycle scope: {}", set.join(", ")),
1824 );
1825 }
1826 }
1827 Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
1828 }
1829 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1830 }));
1831
1832 open_selector(
1833 state,
1834 editor_container,
1835 editor,
1836 tui,
1837 list,
1838 SelectorKind::ScopedModels,
1839 );
1840}
1841
1842async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
1846 use std::process::Stdio;
1847
1848 let tree = harness.session().view("main");
1850 let entries = match tree
1851 .find_entries(&EntryQuery {
1852 entry_type: None,
1853 custom_type: None,
1854 order: None,
1855 limit: None,
1856 cursor: None,
1857 })
1858 .await
1859 {
1860 Ok(e) => e,
1861 Err(e) => {
1862 add_error_message(chat, &format!("Could not read session: {e}"));
1863 return;
1864 }
1865 };
1866 let mut md = String::from("# Session\n\n");
1867 for e in entries {
1868 let Entry::Message(me) = e else { continue };
1869 match &me.message {
1870 AgentMessage::User(u) => {
1871 md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1872 }
1873 AgentMessage::Assistant(a) => {
1874 let text = assistant_text(a);
1875 if !text.is_empty() {
1876 md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1877 }
1878 }
1879 _ => {}
1880 }
1881 }
1882
1883 let gh = std::process::Command::new("gh")
1885 .arg("gist")
1886 .arg("create")
1887 .arg("--filename")
1888 .arg("session.md")
1889 .arg("-")
1890 .stdin(Stdio::piped())
1891 .stdout(Stdio::piped())
1892 .stderr(Stdio::null())
1893 .spawn();
1894 if let Ok(mut child) = gh {
1895 use std::io::Write;
1896 if let Some(mut stdin) = child.stdin.take() {
1897 let _ = stdin.write_all(md.as_bytes());
1898 let _ = stdin.flush();
1899 }
1900 let out = child.wait_with_output().ok();
1901 if let Some(out) = out {
1902 if out.status.success() {
1903 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
1904 add_note_message(chat, &format!("Shared session: {url}"));
1905 return;
1906 }
1907 }
1908 add_note_message(chat, "gh gist failed — falling back to the clipboard.");
1909 } else {
1910 add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
1911 }
1912 if copy_to_clipboard(&md) {
1914 add_note_message(chat, "Session transcript copied to the clipboard.");
1915 } else {
1916 add_note_message(
1917 chat,
1918 "Clipboard unavailable — use /export to write the transcript to a file.",
1919 );
1920 }
1921}
1922
1923async fn export_session(harness: &AgentHarness, chat: &Arc<Container>) {
1932 let tree = harness.session().view("main");
1933 let entries = match tree
1934 .find_entries(&EntryQuery {
1935 entry_type: None,
1936 custom_type: None,
1937 order: None,
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: None,
2163 limit: None,
2164 cursor: None,
2165 })
2166 .await
2167 {
2168 Ok(e) => e,
2169 Err(_) => return,
2170 };
2171 let mut rendered_any = false;
2172 for e in entries {
2173 match e {
2174 Entry::Message(me) => match &me.message {
2175 AgentMessage::User(u) => {
2176 add_user_message(chat, &user_message_text(u));
2177 rendered_any = true;
2178 }
2179 AgentMessage::Assistant(a) => {
2180 let comp = Arc::new(AssistantMessageComponent::new(
2181 AssistantMessageOptions::default(),
2182 ));
2183 if let Some(t) = &transformer {
2184 comp.set_markdown_transformer(Some(t.clone()));
2185 }
2186 comp.update_blocks(&assistant_blocks(a));
2187 chat.add_child(comp);
2188 chat.add_child(Arc::new(Spacer::new(1)));
2191 rendered_any = true;
2192 }
2193 AgentMessage::Custom(custom) => {
2194 if let Some(session) = &extension_session {
2195 if let Some(component) = extension_message_component(
2196 session,
2197 &custom.role,
2198 &serde_json::json!({
2199 "customType": custom.role,
2200 "content": custom.content,
2201 "details": custom.data,
2202 }),
2203 transformer.clone(),
2204 ) {
2205 chat.add_child(component);
2206 chat.add_child(Arc::new(Spacer::new(1)));
2207 rendered_any = true;
2208 continue;
2209 }
2210 }
2211 add_note_message(chat, &custom_message_fallback(&custom));
2212 rendered_any = true;
2213 }
2214 _ => {}
2215 },
2216 Entry::Compaction(compaction) => {
2217 add_note_message(
2218 chat,
2219 &format!(
2220 "Compacted {} tokens: {}",
2221 compaction.tokens_before, compaction.summary
2222 ),
2223 );
2224 rendered_any = true;
2225 }
2226 Entry::BranchSummary(summary) => {
2227 add_note_message(chat, &format!("Branch summary: {}", summary.summary));
2228 rendered_any = true;
2229 }
2230 Entry::Custom(custom) => {
2231 let rendered = extension_session.as_ref().and_then(|session| {
2232 extension_entry_component(session, &custom.custom_type, custom.data.clone())
2233 });
2234 if let Some(component) = rendered {
2235 chat.add_child(component);
2236 chat.add_child(Arc::new(Spacer::new(1)));
2237 rendered_any = true;
2238 } else if let Some(text) =
2239 custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
2240 {
2241 add_note_message(chat, &text);
2242 rendered_any = true;
2243 }
2244 }
2245 Entry::ModelChange(change) => {
2246 add_note_message(
2247 chat,
2248 &format!("Model changed to {}:{}", change.provider, change.model_id),
2249 );
2250 rendered_any = true;
2251 }
2252 Entry::ThinkingLevel(change) => {
2253 add_note_message(
2254 chat,
2255 &format!("Thinking level: {:?}", change.thinking_level),
2256 );
2257 rendered_any = true;
2258 }
2259 Entry::ActiveTools(change) => {
2260 add_note_message(
2261 chat,
2262 &format!("Active tools: {}", change.active_tool_names.join(", ")),
2263 );
2264 rendered_any = true;
2265 }
2266 }
2267 }
2268 if rendered_any {
2269 }
2272}
2273
2274fn invoke_extension_renderer(
2275 session: &crate::session::ExtensionSessionCell,
2276 kind: rpi_extensions::RegisteredRendererKind,
2277 payload: &serde_json::Value,
2278) -> Option<serde_json::Value> {
2279 let snapshot = session.lock().ok()?.snapshot_arc()?;
2280 let input = serde_json::to_string(payload).ok()?;
2281 for renderer in snapshot.renderers_of(kind) {
2282 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2283 let mut out = rpi_plugin_sdk::StbString::empty();
2284 let rc = (renderer.render_fn)(
2285 rpi_plugin_sdk::StbStringRef::from_str(&input),
2286 &mut out as *mut rpi_plugin_sdk::StbString,
2287 renderer.user_data,
2288 );
2289 let text = if rc == 0 {
2290 Some(out.to_string_lossy())
2291 } else {
2292 None
2293 };
2294 out.free_with(Some(renderer.plugin_free_string));
2295 text
2296 }))
2297 .ok()
2298 .flatten();
2299 let Some(text) = outcome else { continue };
2300 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
2301 return Some(value);
2302 }
2303 }
2304 None
2305}
2306
2307fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
2308 if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
2309 let text = lines
2310 .iter()
2311 .filter_map(|line| line.as_str())
2312 .collect::<Vec<_>>()
2313 .join("\n");
2314 return Some(Arc::new(Text::new(text, 0, 0)));
2315 }
2316 let text = value.get("text").and_then(|v| v.as_str())?;
2317 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2318 let component = Arc::new(AssistantMessageComponent::new(
2319 AssistantMessageOptions::default(),
2320 ));
2321 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2322 Some(component)
2323 } else {
2324 Some(Arc::new(Text::new(text, 0, 0)))
2325 }
2326}
2327
2328fn extension_message_component(
2329 session: &crate::session::ExtensionSessionCell,
2330 custom_type: &str,
2331 payload: &serde_json::Value,
2332 transformer: Option<MarkdownTransformer>,
2333) -> Option<Arc<dyn rpi_tui::Component>> {
2334 let value = invoke_extension_renderer(
2335 session,
2336 rpi_extensions::RegisteredRendererKind::Message,
2337 payload,
2338 )?;
2339 if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2340 let text = value.get("text").and_then(|v| v.as_str())?;
2341 let component = Arc::new(AssistantMessageComponent::new(
2342 AssistantMessageOptions::default(),
2343 ));
2344 if let Some(transformer) = transformer {
2345 component.set_markdown_transformer(Some(transformer));
2346 }
2347 component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2348 return Some(component);
2349 }
2350 extension_text_component(&value)
2351 .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
2352}
2353
2354fn extension_entry_component(
2355 session: &crate::session::ExtensionSessionCell,
2356 custom_type: &str,
2357 data: Option<serde_json::Value>,
2358) -> Option<Arc<dyn rpi_tui::Component>> {
2359 let payload = serde_json::json!({
2360 "customType": custom_type,
2361 "data": data,
2362 });
2363 let value = invoke_extension_renderer(
2364 session,
2365 rpi_extensions::RegisteredRendererKind::Entry,
2366 &payload,
2367 )?;
2368 extension_text_component(&value)
2369}
2370
2371fn launch_restores_history(args: &Args) -> bool {
2377 args.continue_session
2378 || args.resume
2379 || args.session.is_some()
2380 || args.session_id.is_some()
2381 || args.fork.is_some()
2382}
2383
2384fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
2385 msg.content
2386 .iter()
2387 .filter_map(|c| match c {
2388 Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
2389 Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
2390 Content::Image(image) => base64::engine::general_purpose::STANDARD
2391 .decode(&image.data)
2392 .ok()
2393 .filter(|data| !data.is_empty())
2394 .map(AssistantBlock::Image),
2395 _ => None,
2396 })
2397 .collect()
2398}
2399
2400fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
2401 let content = custom
2402 .content
2403 .iter()
2404 .filter_map(|item| match item {
2405 Content::Text(text) => Some(text.text.as_str()),
2406 _ => None,
2407 })
2408 .collect::<Vec<_>>()
2409 .join("\n");
2410 if content.is_empty() {
2411 format!("{}: {}", custom.role, custom.data)
2412 } else {
2413 format!("{}: {}", custom.role, content)
2414 }
2415}
2416
2417fn short_model_name(id: &str) -> String {
2420 id.rsplit([':', '/'])
2421 .next()
2422 .filter(|s| !s.is_empty())
2423 .unwrap_or(id)
2424 .to_string()
2425}
2426
2427#[derive(Clone, Copy, PartialEq, Eq)]
2433enum RunStatus {
2434 Idle,
2435 Working,
2436 Aborting,
2437}
2438
2439#[derive(Clone, Copy, PartialEq, Eq)]
2441enum SelectorKind {
2442 Model,
2444 Thinking,
2446 Tools,
2448 Images,
2450 Session,
2452 Theme,
2454 ScopedModels,
2456 Settings,
2458 Tree,
2460 Extension,
2462}
2463
2464struct TuiState {
2467 current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
2469 tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
2471 bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
2475 last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
2479 status: std::sync::Mutex<RunStatus>,
2481 footer: Arc<FooterComponent>,
2483 status_container: Arc<Container>,
2486 chat_container: Arc<Container>,
2488 loader: Arc<Loader>,
2490 last_assistant_text: std::sync::Mutex<String>,
2493 active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
2497 active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
2499 autocomplete: AutocompleteManager,
2502 autocomplete_container: Arc<Container>,
2505 theme_manager: Arc<ThemeManager>,
2509 tui: Option<Arc<TuiAltScreen>>,
2513 current_model_id: std::sync::Mutex<String>,
2517 show_images: std::sync::Mutex<bool>,
2521 history: std::sync::Mutex<Vec<String>>,
2524 history_index: std::sync::Mutex<isize>,
2527 history_draft: std::sync::Mutex<Option<String>>,
2530 last_input_tokens: std::sync::Mutex<i64>,
2534 scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
2537 markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
2547 extension_session: crate::session::ExtensionSessionCell,
2549}
2550
2551const HISTORY_LIMIT: usize = 100;
2554
2555const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
2558
2559const PAGE_SCROLL_OVERLAP: usize = 4;
2562
2563const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
2567
2568fn transcript_page_size(viewport_height: usize) -> i32 {
2569 viewport_height
2570 .saturating_sub(PAGE_SCROLL_OVERLAP)
2571 .max(1)
2572 .min(i32::MAX as usize) as i32
2573}
2574
2575fn should_dispatch_key(kind: KeyEventKind) -> bool {
2576 kind != KeyEventKind::Release
2577}
2578
2579fn format_tokens(n: i64) -> String {
2581 if n >= 1_000_000 {
2582 format!("{:.1}M", n as f64 / 1_000_000.0)
2583 } else if n >= 1_000 {
2584 format!("{:.1}K", n as f64 / 1_000.0)
2585 } else {
2586 n.to_string()
2587 }
2588}
2589
2590fn push_history(state: &Arc<TuiState>, text: &str) {
2594 let trimmed = text.trim().to_string();
2595 if trimmed.is_empty() {
2596 return;
2597 }
2598 let mut history = state.history.lock().unwrap();
2599 if history.first() == Some(&trimmed) {
2600 return;
2601 }
2602 history.insert(0, trimmed);
2603 history.truncate(HISTORY_LIMIT);
2604 *state.history_index.lock().unwrap() = -1;
2605 *state.history_draft.lock().unwrap() = None;
2606}
2607
2608fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
2613 let history = state.history.lock().unwrap();
2614 if history.is_empty() {
2615 return;
2616 }
2617 let mut index = state.history_index.lock().unwrap();
2618 let new_index = *index - direction as isize;
2619 if new_index < -1 || new_index >= history.len() as isize {
2620 return;
2621 }
2622 if *index == -1 && new_index >= 0 {
2623 *state.history_draft.lock().unwrap() = Some(editor.get_text());
2625 }
2626 *index = new_index;
2627 if new_index == -1 {
2628 let draft = state.history_draft.lock().unwrap().take();
2630 match draft {
2631 Some(d) => {
2632 let len = d.len();
2633 editor.set_text(&d);
2634 editor.set_cursor(0, len);
2635 }
2636 None => editor.set_text(""),
2637 }
2638 } else {
2639 let text = history[new_index as usize].clone();
2640 let len = text.len();
2641 editor.set_text(&text);
2642 editor.set_cursor(0, len);
2643 }
2644}
2645
2646impl TuiState {
2647 fn set_status(&self, status: RunStatus) {
2648 *self.status.lock().unwrap() = status;
2649 self.apply_status(status);
2650 }
2651
2652 fn try_start_working(&self) -> bool {
2657 let mut status = self.status.lock().unwrap();
2658 if *status != RunStatus::Idle {
2659 return false;
2660 }
2661 *status = RunStatus::Working;
2662 drop(status);
2663 self.apply_status(RunStatus::Working);
2664 true
2665 }
2666
2667 fn apply_status(&self, status: RunStatus) {
2668 match status {
2669 RunStatus::Working => {
2670 self.footer.set_status("Working…");
2671 if let Some(tui) = &self.tui {
2674 tui.set_title("rpi — working");
2675 }
2676 self.status_container.clear();
2677 self.loader.start();
2678 self.status_container.add_child(self.loader.clone());
2679 }
2680 RunStatus::Aborting => {
2681 self.footer.set_status("Aborting…");
2682 self.loader.stop();
2685 self.status_container.clear();
2686 }
2687 RunStatus::Idle => {
2688 self.footer.set_status("");
2689 if let Some(tui) = &self.tui {
2690 tui.set_title("rpi");
2691 }
2692 self.loader.stop();
2693 self.status_container.clear();
2694 }
2695 }
2696 }
2697
2698 fn sync_working_loader_with_bash(&self) {
2702 if *self.status.lock().unwrap() != RunStatus::Working {
2703 return;
2704 }
2705
2706 self.status_container.clear();
2707 if self.bash_components.lock().unwrap().is_empty() {
2708 self.status_container.add_child(self.loader.clone());
2709 }
2710 }
2711
2712 fn selector_open(&self) -> bool {
2714 self.active_selector.lock().unwrap().is_some()
2715 }
2716
2717 fn extension_editor_open(&self) -> bool {
2718 self.active_extension_editor.lock().unwrap().is_some()
2719 }
2720
2721 fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
2724 *self.last_tool_comp.lock().unwrap() = Some(comp);
2725 }
2726
2727 fn toggle_expand_last_tool(&self) -> bool {
2733 if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
2734 let cur = comp.is_expanded();
2735 comp.set_expanded(!cur);
2736 true
2737 } else {
2738 false
2739 }
2740 }
2741
2742 fn current_model_id(&self) -> String {
2744 self.current_model_id.lock().unwrap().clone()
2745 }
2746
2747 fn set_current_model(&self, model: &rpi_ai::Model) {
2750 *self.current_model_id.lock().unwrap() = model.id.clone();
2751 self.footer.set_model(&short_model_name(&model.id));
2752 }
2753
2754 fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
2758 self.markdown_transformer.lock().unwrap().clone()
2759 }
2760
2761 fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
2770 *self.markdown_transformer.lock().unwrap() = transformer.clone();
2771 if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
2772 comp.set_markdown_transformer(transformer);
2773 }
2774 }
2775}
2776
2777pub async fn interactive_tui(
2795 harness: &AgentHarness,
2796 event_rx: Option<broadcast::Receiver<AgentEvent>>,
2797 args: &Args,
2798 model_catalog: Vec<rpi_ai::Model>,
2799 initial: Option<String>,
2800 extra_messages: &[String],
2801 theme: Option<&str>,
2802 reload_context: &crate::session::ReloadContext,
2803) -> i32 {
2804 let lane: Arc<dyn AgentLane> = harness.lane("main");
2805
2806 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
2810 let model_name = short_model_name(&lane_model_id);
2811
2812 let cwd = std::env::current_dir()
2814 .map(|p| p.to_path_buf())
2815 .unwrap_or_else(|_| std::path::PathBuf::from("."));
2816
2817 let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
2819
2820 if let Some(preset) = match theme {
2824 Some("light") => Some(ThemePreset::Light),
2825 Some("monochrome") => Some(ThemePreset::Monochrome),
2826 Some("dark") => Some(ThemePreset::Dark),
2827 _ => None,
2828 } {
2829 apply_theme_preset(preset);
2830 }
2831
2832 let terminal = Box::new(ProcessTerminal::new());
2834 let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
2835
2836 let chat_container = Arc::new(Container::new());
2837 add_welcome_message(&chat_container);
2838
2839 crate::extras::maybe_first_time_setup(&chat_container);
2845
2846 let initial_transformer = build_markdown_transformer(
2851 reload_context
2852 .extension_session
2853 .lock()
2854 .unwrap()
2855 .snapshot_arc(),
2856 );
2857 if launch_restores_history(args) {
2861 render_session_history(
2862 &harness,
2863 &chat_container,
2864 initial_transformer.clone(),
2865 Some(reload_context.extension_session.clone()),
2866 )
2867 .await;
2868 }
2869
2870 let document_container = Arc::new(Container::new());
2873 document_container.add_child(chat_container.clone());
2874
2875 let scroll_view = Arc::new(ScrollView::new(
2876 document_container.clone(),
2877 ScrollViewOptions {
2878 follow: FollowMode::End,
2879 primary: true,
2880 overscroll: OverscrollMode::Chain,
2881 scrollbar: ScrollbarMode::Hidden,
2885 ..Default::default()
2886 },
2887 ));
2888
2889 let editor = Arc::new(Editor::new(
2894 EditorOptions {
2895 padding_x: 1,
2896 ..Default::default()
2897 },
2898 EditorStyle::default(),
2899 Arc::new(rpi_tui::Keybindings::new()),
2900 ));
2901
2902 let footer = Arc::new(FooterComponent::new());
2904 footer.set_model(&model_name);
2905 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");
2906
2907 let status_container = Arc::new(Container::new());
2908 let loader = Arc::new(Loader::with_text("Working…"));
2909
2910 let resources_snapshot = harness.get_resources().await.unwrap_or_default();
2924 let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
2925 .prompt_templates
2926 .clone()
2927 .unwrap_or_default()
2928 .iter()
2929 .map(|t| SlashCommandEntry {
2930 name: format!("/{}", t.name),
2931 description: t
2932 .description
2933 .clone()
2934 .unwrap_or_else(|| "Expand prompt template".to_string()),
2935 })
2936 .collect();
2937 let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
2938 Arc::new(resources_snapshot);
2939 let mut command_registry = build_builtin_registry();
2945 register_extension_commands(
2946 &mut command_registry,
2947 reload_context.extension_session.clone(),
2948 );
2949 let registry = Arc::new(command_registry);
2950 let mut all_slash_commands = registry.visible_entries();
2951 all_slash_commands.extend(template_slash_commands);
2952 let autocomplete = AutocompleteManager::new();
2953 {
2954 let mut combined = CombinedAutocompleteProvider::new();
2955 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2956 all_slash_commands,
2957 )));
2958 combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
2959 cwd.clone(),
2960 )));
2961 autocomplete.set_provider(Arc::new(combined));
2962 }
2963 let autocomplete_container = Arc::new(Container::new());
2964
2965 let state = Arc::new(TuiState {
2966 current_assistant: std::sync::Mutex::new(None),
2967 tool_components: std::sync::Mutex::new(HashMap::new()),
2968 bash_components: std::sync::Mutex::new(HashMap::new()),
2969 last_tool_comp: std::sync::Mutex::new(None),
2970 status: std::sync::Mutex::new(RunStatus::Idle),
2971 footer: footer.clone(),
2972 status_container: status_container.clone(),
2973 chat_container: chat_container.clone(),
2974 loader: loader.clone(),
2975 last_assistant_text: std::sync::Mutex::new(String::new()),
2976 active_selector: std::sync::Mutex::new(None),
2977 active_extension_editor: std::sync::Mutex::new(None),
2978 autocomplete,
2979 autocomplete_container: autocomplete_container.clone(),
2980 theme_manager: Arc::new(ThemeManager::new()),
2981 tui: Some(tui.clone()),
2982 current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
2983 show_images: std::sync::Mutex::new(true),
2984 history: std::sync::Mutex::new(Vec::new()),
2985 history_index: std::sync::Mutex::new(-1),
2986 history_draft: std::sync::Mutex::new(None),
2987 last_input_tokens: std::sync::Mutex::new(0),
2988 scoped_edit: std::sync::Mutex::new(None),
2989 markdown_transformer: std::sync::Mutex::new(initial_transformer),
2990 extension_session: reload_context.extension_session.clone(),
2991 });
2992
2993 let model_catalog_arc = Arc::new(model_catalog.clone());
2996 let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
2997
2998 let editor_container = Arc::new(Container::new());
3009 editor_container.add_child(editor.clone());
3010
3011 let dock = Arc::new(VStack::from_children(vec![
3012 StackChild::Entry(StackEntry::new(status_container.clone())),
3013 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
3014 StackChild::Entry(
3015 StackEntry::new(editor_container.clone())
3016 .shrink(0)
3017 .min_size(3),
3018 ),
3019 StackChild::Entry(StackEntry::new(footer.clone())),
3020 ]));
3021
3022 let root = VStack::from_children(vec![
3023 StackChild::Entry(
3024 StackEntry::new(scroll_view.clone())
3025 .basis(0)
3026 .grow(1)
3027 .shrink(1)
3028 .min_size(1),
3029 ),
3030 StackChild::Entry(StackEntry::new(dock).shrink(1)),
3031 ]);
3032
3033 tui.set_layout_root(Some(Arc::new(root)));
3034 tui.set_focus(Some(editor.clone()));
3035 editor.set_focused(true);
3036
3037 let ctx = CommandContext {
3049 chat: chat_container.clone(),
3050 tui: tui.clone(),
3051 tx: tx.clone(),
3052 state: state.clone(),
3053 editor: editor.clone(),
3054 editor_container: editor_container.clone(),
3055 lane: lane.clone(),
3056 model_catalog: model_catalog_arc.clone(),
3057 lane_model_id: lane_model_id.clone(),
3058 cwd: cwd.clone(),
3059 resources: resources_arc.clone(),
3060 reload_context: Arc::new(reload_context.clone()),
3061 };
3062 let ctx_for_cb = ctx.clone();
3063 let registry_for_cb = registry.clone();
3064 editor.on_submit(Arc::new(move |text: &str| {
3065 let text = text.trim();
3066 if text.is_empty() {
3067 return;
3068 }
3069
3070 if text.starts_with('/') {
3071 dispatch_slash(text, &ctx_for_cb, ®istry_for_cb);
3072 return;
3073 }
3074
3075 if *ctx_for_cb.state.status.lock().unwrap() != RunStatus::Idle {
3076 let _ = ctx_for_cb.tx.send(TuiMessage::QueueInput {
3077 prompt: text.to_string(),
3078 follow_up: false,
3079 });
3080 add_note_message(
3081 &ctx_for_cb.chat,
3082 &format!("Queued steering message: {text}"),
3083 );
3084 ctx_for_cb.tui.request_render(false);
3085 return;
3086 }
3087
3088 if !ctx_for_cb.state.try_start_working() {
3089 return;
3090 }
3091
3092 add_user_message(&ctx_for_cb.chat, text);
3093 if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
3096 scroll.scroll_to_end();
3097 }
3098 ctx_for_cb.tui.request_render(false);
3099 push_history(&ctx_for_cb.state, text);
3102 if ctx_for_cb
3103 .tx
3104 .send(TuiMessage::UserInput(text.to_string()))
3105 .is_err()
3106 {
3107 ctx_for_cb.state.set_status(RunStatus::Idle);
3108 }
3109 }));
3110
3111 tui.start_readerless();
3112
3113 let drain_handle = if let Some(rx) = event_rx {
3115 let tui_drain = tui.clone();
3116 let state_drain = state.clone();
3117 let chat_drain = chat_container.clone();
3118 Some(tokio::spawn(async move {
3119 drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
3120 }))
3121 } else {
3122 None
3123 };
3124
3125 let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
3135 reload_context.mailbox.install(reload_sig_tx);
3136 let reload_tx = tx.clone();
3137 let reload_bridge_handle = tokio::spawn(async move {
3138 while reload_sig_rx.recv().await.is_some() {
3139 if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
3140 break; }
3142 }
3143 });
3144
3145 let tui_tick = tui.clone();
3150 let state_tick = state.clone();
3151 let tick_handle = tokio::spawn(async move {
3152 let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
3155 interval.tick().await; loop {
3157 interval.tick().await;
3158 let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
3159 if working {
3160 if state_tick.bash_components.lock().unwrap().is_empty() {
3161 tui_tick.request_render_reusing_scroll_content();
3165 } else {
3166 tui_tick.request_render(false);
3168 }
3169 }
3170 }
3171 });
3172
3173 let running = Arc::new(std::sync::Mutex::new(true));
3175 let running_key = running.clone();
3176 let tx_for_key = tx.clone();
3177 let tui_for_key = tui.clone();
3178 let editor_for_key = editor.clone();
3179 let scroll_for_key = scroll_view.clone();
3180 let lane_for_key = lane.clone();
3181 let state_for_key = state.clone();
3182 let ctx_for_key = ctx.clone();
3186 let registry_for_key = registry.clone();
3187
3188 let key_handle = tokio::task::spawn_blocking(move || {
3189 loop {
3190 if !*running_key.lock().unwrap() {
3191 break;
3192 }
3193 match crossterm::event::poll(std::time::Duration::from_millis(50)) {
3196 Ok(true) => {}
3197 Ok(false) => continue,
3198 Err(_) => {
3199 let _ = tx_for_key.send(TuiMessage::Exit);
3200 break;
3201 }
3202 }
3203 let Ok(ev) = crossterm::event::read() else {
3204 let _ = tx_for_key.send(TuiMessage::Exit);
3205 break;
3206 };
3207 if let Event::Resize(_cols, _rows) = ev {
3212 tui_for_key.refresh_size();
3213 continue;
3214 }
3215 if let Event::Mouse(m) = ev {
3219 use crossterm::event::MouseEventKind;
3220 match m.kind {
3221 MouseEventKind::ScrollUp => {
3222 let delta = -MOUSE_WHEEL_SCROLL_LINES;
3223 if scroll_for_key.scroll_by(delta) != delta {
3224 tui_for_key.request_render_reusing_scroll_content();
3225 }
3226 }
3227 MouseEventKind::ScrollDown => {
3228 let delta = MOUSE_WHEEL_SCROLL_LINES;
3229 if scroll_for_key.scroll_by(delta) != delta {
3230 tui_for_key.request_render_reusing_scroll_content();
3231 }
3232 }
3233 _ => {}
3234 }
3235 continue;
3236 }
3237 let Event::Key(key) = ev else {
3238 continue;
3239 };
3240 if !should_dispatch_key(key.kind) {
3245 continue;
3246 }
3247
3248 if state_for_key.extension_editor_open()
3249 && key.modifiers == KeyModifiers::CONTROL
3250 && key.code == KeyCode::Char('c')
3251 {
3252 close_extension_editor(
3253 &state_for_key,
3254 &ctx_for_key.editor_container,
3255 &editor_for_key,
3256 );
3257 tui_for_key.request_render_reusing_scroll_content();
3258 continue;
3259 }
3260
3261 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
3266 if !state_for_key.selector_open() && editor_for_key.has_selection() {
3267 editor_for_key.copy_selection();
3268 continue;
3269 }
3270 let status = *state_for_key.status.lock().unwrap();
3271 match status {
3272 RunStatus::Working => {
3273 state_for_key.set_status(RunStatus::Aborting);
3274 let lane = lane_for_key.clone();
3275 tokio::spawn(async move {
3276 let _ = lane.abort().await;
3277 });
3278 }
3279 RunStatus::Aborting => {}
3283 RunStatus::Idle => {
3284 let _ = tx_for_key.send(TuiMessage::Exit);
3285 }
3286 }
3287 continue;
3288 }
3289
3290 if state_for_key.selector_open() {
3295 if key.code == KeyCode::Esc {
3301 let (selector, _kind) = state_for_key
3302 .active_selector
3303 .lock()
3304 .unwrap()
3305 .clone()
3306 .expect("selector_open guaranteed Some");
3307 selector.handle_key(key);
3308 continue;
3309 }
3310 let (selector, _kind) = state_for_key
3311 .active_selector
3312 .lock()
3313 .unwrap()
3314 .clone()
3315 .expect("selector_open guaranteed Some");
3316 selector.handle_key(key);
3317 tui_for_key.request_render_reusing_scroll_content();
3318 continue;
3319 }
3320
3321 if state_for_key.extension_editor_open() {
3325 let extension_editor = state_for_key
3326 .active_extension_editor
3327 .lock()
3328 .unwrap()
3329 .clone()
3330 .expect("extension_editor_open guaranteed Some");
3331 if key.code == KeyCode::Esc {
3332 close_extension_editor(
3333 &state_for_key,
3334 &ctx_for_key.editor_container,
3335 &editor_for_key,
3336 );
3337 } else {
3338 extension_editor.handle_key(key);
3339 }
3340 tui_for_key.request_render_reusing_scroll_content();
3341 continue;
3342 }
3343
3344 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
3349 let status = *state_for_key.status.lock().unwrap();
3350 match status {
3351 RunStatus::Working => {
3352 state_for_key.set_status(RunStatus::Aborting);
3353 let lane = lane_for_key.clone();
3354 tokio::spawn(async move {
3355 let _ = lane.abort().await;
3356 });
3357 continue;
3358 }
3359 RunStatus::Aborting => continue,
3360 RunStatus::Idle => {}
3361 }
3362 if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
3363 editor_for_key.handle_key(key);
3365 refresh_autocomplete(&state_for_key, &editor_for_key);
3366 tui_for_key.request_render_reusing_scroll_content();
3367 continue;
3368 }
3369 let _ = tx_for_key.send(TuiMessage::Exit);
3370 continue;
3371 }
3372
3373 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
3378 let status = *state_for_key.status.lock().unwrap();
3379 if status == RunStatus::Working {
3380 state_for_key.set_status(RunStatus::Aborting);
3381 let lane = lane_for_key.clone();
3382 tokio::spawn(async move {
3383 let _ = lane.abort().await;
3384 });
3385 continue;
3386 }
3387 }
3388
3389 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
3394 state_for_key.toggle_expand_last_tool();
3395 tui_for_key.request_render(false);
3396 continue;
3397 }
3398
3399 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
3405 let current = state_for_key.current_model_id();
3406 let scope = scoped_catalog(&ctx_for_key.model_catalog, ¤t);
3409 if let Some(next) = cycle_next_model(&scope, ¤t) {
3410 state_for_key.set_current_model(&next);
3411 let lane = lane_for_key.clone();
3412 tokio::spawn(async move {
3413 let _ = lane.set_model(next).await;
3414 });
3415 tui_for_key.request_render_reusing_scroll_content();
3416 }
3417 continue;
3418 }
3419
3420 if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
3424 if let Some(cmd) = registry_for_key.find("/model") {
3425 cmd.execute(&ctx_for_key, "");
3426 }
3427 continue;
3428 }
3429
3430 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
3432 if accept_top_suggestion(&state_for_key, &editor_for_key) {
3433 tui_for_key.request_render_reusing_scroll_content();
3434 }
3435 continue;
3436 }
3437
3438 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
3442 let delta = -transcript_page_size(scroll_for_key.viewport_height());
3443 if scroll_for_key.scroll_by(delta) != delta {
3444 tui_for_key.request_render_reusing_scroll_content();
3445 }
3446 continue;
3447 }
3448 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
3449 let delta = transcript_page_size(scroll_for_key.viewport_height());
3450 if scroll_for_key.scroll_by(delta) != delta {
3451 tui_for_key.request_render_reusing_scroll_content();
3452 }
3453 continue;
3454 }
3455 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
3456 scroll_for_key.scroll_to_start();
3457 tui_for_key.request_render_reusing_scroll_content();
3458 continue;
3459 }
3460 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
3461 scroll_for_key.scroll_to_end();
3462 tui_for_key.request_render_reusing_scroll_content();
3463 continue;
3464 }
3465
3466 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
3475 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3476 if editor_for_key.get_text().is_empty() || browsing {
3477 navigate_history(&state_for_key, &editor_for_key, -1);
3478 tui_for_key.request_render_reusing_scroll_content();
3479 continue;
3480 }
3481 }
3482 if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
3483 let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3484 if editor_for_key.get_text().is_empty() || browsing {
3485 navigate_history(&state_for_key, &editor_for_key, 1);
3486 tui_for_key.request_render_reusing_scroll_content();
3487 continue;
3488 }
3489 }
3490
3491 if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
3495 let prompt = editor_for_key.get_text().trim().to_string();
3496 if prompt.is_empty() {
3497 continue;
3498 }
3499 editor_for_key.clear();
3500 let status = *state_for_key.status.lock().unwrap();
3501 if status == RunStatus::Idle {
3502 if state_for_key.try_start_working() {
3503 add_user_message(&state_for_key.chat_container, &prompt);
3504 push_history(&state_for_key, &prompt);
3505 let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
3506 }
3507 } else {
3508 add_note_message(
3509 &state_for_key.chat_container,
3510 &format!("Queued follow-up message: {prompt}"),
3511 );
3512 let _ = tx_for_key.send(TuiMessage::QueueInput {
3513 prompt,
3514 follow_up: true,
3515 });
3516 }
3517 tui_for_key.request_render(false);
3518 continue;
3519 }
3520
3521 editor_for_key.handle_key(key);
3523 refresh_autocomplete(&state_for_key, &editor_for_key);
3524 tui_for_key.request_render_reusing_scroll_content();
3525 }
3526 });
3527
3528 let mut prompts: Vec<String> = Vec::new();
3530 if let Some(init) = initial {
3531 prompts.push(init);
3532 }
3533 for m in extra_messages {
3534 prompts.push(m.clone());
3535 }
3536 for prompt in prompts {
3537 if !*running.lock().unwrap() {
3538 break;
3539 }
3540 add_user_message(&chat_container, &prompt);
3541 tui.request_render(false);
3542 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3543 }
3544
3545 loop {
3547 if !*running.lock().unwrap() {
3548 break;
3549 }
3550 match rx.recv().await {
3551 Some(TuiMessage::UserInput(prompt)) => {
3552 editor.clear();
3557 run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3558 }
3559 Some(TuiMessage::QueueInput { prompt, follow_up }) => {
3560 let message = AgentMessage::User(UserMessage::new(prompt.clone(), 0));
3561 let aborting = *state.status.lock().unwrap() == RunStatus::Aborting;
3562 let result = if aborting {
3563 lane.next_run(message).await
3566 } else if follow_up {
3567 lane.follow_up(message).await
3568 } else {
3569 lane.steer(message).await
3570 };
3571 if let Err(error) = result {
3572 add_error_message(
3573 &chat_container,
3574 &format!("Could not queue message: {error}"),
3575 );
3576 tui.request_render(false);
3577 }
3578 }
3579 Some(TuiMessage::OpenTree) => {
3580 if *state.status.lock().unwrap() != RunStatus::Idle {
3581 add_note_message(
3582 &chat_container,
3583 "Wait for the current run to finish before opening the tree.",
3584 );
3585 tui.request_render(false);
3586 } else {
3587 open_tree_selector(
3588 &harness,
3589 &state,
3590 &editor_container,
3591 &editor,
3592 &tui,
3593 &chat_container,
3594 &tx,
3595 )
3596 .await;
3597 }
3598 }
3599 Some(TuiMessage::NavigateTree(entry_id)) => {
3600 match lane.navigate_tree(Some(&entry_id), false, None, None).await {
3601 Ok(result) => match result.outcome {
3602 rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
3603 chat_container.clear();
3604 add_welcome_message(&chat_container);
3605 render_session_history(
3606 &harness,
3607 &chat_container,
3608 state.markdown_transformer(),
3609 Some(state.extension_session.clone()),
3610 )
3611 .await;
3612 add_note_message(
3613 &chat_container,
3614 "Moved to the selected session entry.",
3615 );
3616 }
3617 rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
3618 add_error_message(&chat_container, &error.message);
3619 }
3620 _ => add_note_message(
3621 &chat_container,
3622 "The selected entry could not be opened.",
3623 ),
3624 },
3625 Err(error) => add_error_message(
3626 &chat_container,
3627 &format!("Could not navigate session tree: {error}"),
3628 ),
3629 }
3630 tui.request_render(false);
3631 }
3632 Some(TuiMessage::ClearChat) => {
3633 chat_container.clear();
3634 add_welcome_message(&chat_container);
3635 tui.request_render(false);
3636 }
3637 Some(TuiMessage::Compact) => {
3638 run_compact(&lane, &tui, &state).await;
3639 }
3640 Some(TuiMessage::Copy) => {
3641 copy_last_assistant(&state, &chat_container);
3642 tui.request_render(false);
3643 }
3644 Some(TuiMessage::Exit) => {
3645 *running.lock().unwrap() = false;
3646 break;
3647 }
3648 Some(TuiMessage::SwitchSession(id)) => {
3649 switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
3650 tui.request_render(false);
3651 }
3652 Some(TuiMessage::ImportSession(path)) => {
3653 import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
3654 tui.request_render(false);
3655 }
3656 Some(TuiMessage::ShareSession) => {
3657 share_session(&harness, &chat_container).await;
3658 tui.request_render(false);
3659 }
3660 Some(TuiMessage::SetSessionName(name)) => {
3661 let outcome = harness.session().set_name(Some(&name)).await;
3662 match outcome {
3663 Ok(_) => add_note_message(
3664 &chat_container,
3665 &format!("Session renamed to \"{name}\"."),
3666 ),
3667 Err(e) => add_error_message(
3668 &chat_container,
3669 &format!("Could not rename session: {e}"),
3670 ),
3671 }
3672 tui.request_render(false);
3673 }
3674 Some(TuiMessage::ExportSession) => {
3675 export_session(&harness, &chat_container).await;
3676 tui.request_render(false);
3677 }
3678 Some(TuiMessage::ForkSession) => {
3679 fork_session(&harness, &cwd, &chat_container, &state).await;
3680 tui.request_render(false);
3681 }
3682 Some(TuiMessage::ReloadExtensions) => {
3683 let reload_ctx = ctx.reload_context.clone();
3690 add_note_message(&chat_container, "Reloading extensions + resources…");
3691 tui.request_render(false);
3692 let outcome =
3693 crate::session::reload_extension_resources(&harness, &reload_ctx).await;
3694 let fresh_transformer = build_markdown_transformer(
3703 reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
3704 );
3705 state.set_markdown_transformer_with_reinstall(fresh_transformer);
3706 if outcome.had_warnings {
3707 add_error_message(
3708 &chat_container,
3709 &format!(
3710 "{} (with warnings — see stderr for details).",
3711 outcome.summary
3712 ),
3713 );
3714 } else {
3715 add_note_message(&chat_container, &outcome.summary);
3716 }
3717 tui.request_render(false);
3718 }
3719 None => break,
3720 }
3721 }
3722
3723 *running.lock().unwrap() = false;
3725 let _ = key_handle.await;
3728 tick_handle.abort();
3729 if let Some(handle) = drain_handle {
3730 handle.abort();
3731 }
3732 reload_context.mailbox.clear();
3736 reload_bridge_handle.abort();
3737 tui.stop(Default::default());
3738 println!("\nGoodbye!");
3739 let _ = args;
3740
3741 0
3742}
3743
3744async fn run_prompt_streaming(
3753 lane: &Arc<dyn AgentLane>,
3754 prompt: &str,
3755 tui: &Arc<TuiAltScreen>,
3756 state: &Arc<TuiState>,
3757 streaming: bool,
3758) {
3759 state.set_status(RunStatus::Working);
3761 tui.request_render(false);
3762
3763 let outcome = lane.prompt_text(prompt, Vec::new()).await;
3764
3765 {
3769 let mut cur = state.current_assistant.lock().unwrap();
3770 if let Some(comp) = cur.take() {
3771 comp.set_streaming(false);
3772 }
3773 }
3774
3775 state.set_status(RunStatus::Idle);
3776
3777 match outcome {
3778 Ok(result) => match &result.outcome {
3779 HarnessRunOutcome::Failed {
3780 error,
3781 final_message,
3782 ..
3783 } => {
3784 let already_rendered = final_message.is_some();
3788 if !already_rendered {
3789 let msg = final_message
3790 .as_ref()
3791 .and_then(|m| m.error_message.clone())
3792 .unwrap_or_else(|| format!("{error:?}"));
3793 add_error_message(&state.chat_container, &msg);
3794 }
3795 }
3796 HarnessRunOutcome::Suspended { .. } => {
3797 add_error_message(
3798 &state.chat_container,
3799 "Run suspended (deferred) — resume is not supported in v1.",
3800 );
3801 }
3802 HarnessRunOutcome::Aborted { final_message, .. } => {
3803 if !streaming {
3806 add_error_message(&state.chat_container, "Request aborted.");
3807 let _ = final_message; }
3809 }
3810 HarnessRunOutcome::Completed { final_message, .. } => {
3811 if !streaming {
3812 let text = assistant_text(final_message);
3813 if !text.is_empty() {
3814 add_assistant_message_blocking(
3815 &state.chat_container,
3816 &text,
3817 state.markdown_transformer(),
3818 );
3819 *state.last_assistant_text.lock().unwrap() = text;
3820 }
3821 }
3822 }
3823 },
3824 Err(e) => {
3825 add_error_message(&state.chat_container, &e.to_string());
3826 }
3827 }
3828
3829 tui.request_render(false);
3830}
3831
3832async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
3837 state.set_status(RunStatus::Working);
3838 tui.request_render(false);
3839 match lane.compact(None).await {
3840 Ok(_) => {
3841 add_note_message(&state.chat_container, "Conversation compacted.");
3842 }
3843 Err(e) => {
3844 add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
3845 }
3846 }
3847 state.set_status(RunStatus::Idle);
3848 tui.request_render(false);
3849}
3850
3851fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
3855 let text = state.last_assistant_text.lock().unwrap().clone();
3856 if text.is_empty() {
3857 add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
3858 return;
3859 }
3860 if copy_to_clipboard(&text) {
3861 add_note_message(chat, "Copied last reply to the clipboard.");
3862 } else {
3863 let preview: String = text.chars().take(200).collect();
3866 add_note_message(
3867 chat,
3868 &format!(
3869 "Clipboard unavailable. Last reply: {preview}{}",
3870 if text.chars().count() > 200 {
3871 "…"
3872 } else {
3873 ""
3874 }
3875 ),
3876 );
3877 }
3878}
3879
3880#[cfg(feature = "clipboard")]
3883fn copy_to_clipboard(text: &str) -> bool {
3884 match arboard::Clipboard::new() {
3885 Ok(mut cb) => cb.set_text(text).is_ok(),
3886 Err(_) => false,
3887 }
3888}
3889
3890#[cfg(not(feature = "clipboard"))]
3891fn copy_to_clipboard(_text: &str) -> bool {
3892 false
3893}
3894
3895fn add_assistant_message_blocking(
3902 container: &Arc<Container>,
3903 text: &str,
3904 transformer: Option<MarkdownTransformer>,
3905) {
3906 if text.is_empty() {
3907 return;
3908 }
3909 let msg = Arc::new(AssistantMessageComponent::new(
3910 AssistantMessageOptions::default(),
3911 ));
3912 if let Some(t) = &transformer {
3913 msg.set_markdown_transformer(Some(t.clone()));
3914 }
3915 msg.update_text(text);
3916 container.add_child(msg);
3917 container.add_child(Arc::new(Spacer::new(1)));
3918}
3919
3920async fn drain_agent_events(
3928 mut rx: broadcast::Receiver<AgentEvent>,
3929 tui: Arc<TuiAltScreen>,
3930 state: Arc<TuiState>,
3931 chat: Arc<Container>,
3932) {
3933 loop {
3934 match rx.recv().await {
3935 Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
3936 Err(broadcast::error::RecvError::Lagged(_)) => {
3937 continue;
3940 }
3941 Err(broadcast::error::RecvError::Closed) => break,
3942 }
3943 }
3944}
3945
3946async fn handle_agent_event(
3949 event: AgentEvent,
3950 tui: &Arc<TuiAltScreen>,
3951 state: &Arc<TuiState>,
3952 chat: &Arc<Container>,
3953) {
3954 match event {
3955 AgentEvent::AgentStart => {
3956 state.set_status(RunStatus::Working);
3957 tui.request_render(false);
3958 }
3959
3960 AgentEvent::AgentEnd { .. } => {
3961 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3963 comp.set_streaming(false);
3964 }
3965 state.set_status(RunStatus::Idle);
3966 tui.request_render(false);
3967 }
3968
3969 AgentEvent::TurnStart => {
3970 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3973 comp.set_streaming(false);
3974 }
3975 }
3976
3977 AgentEvent::TurnEnd {
3978 message,
3979 tool_results,
3980 } => {
3981 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3983 if let AgentMessage::Assistant(a) = &message {
3984 comp.update_blocks(&assistant_blocks(a));
3985 }
3986 comp.set_streaming(false);
3987 }
3988 let tools = state.tool_components.lock().unwrap();
3993 for tr in &tool_results {
3994 if tools.contains_key(&tr.tool_call_id) {
3995 let _ = tr;
3998 }
3999 }
4000 drop(tools);
4001 tui.request_render(false);
4002 }
4003
4004 AgentEvent::MessageStart { message } => match message {
4005 AgentMessage::Assistant(a) => {
4006 let comp = Arc::new(AssistantMessageComponent::new(
4007 AssistantMessageOptions::default(),
4008 ));
4009 if let Some(t) = state.markdown_transformer() {
4014 comp.set_markdown_transformer(Some(t));
4015 }
4016 comp.set_streaming(true);
4017 comp.update_blocks(&assistant_blocks(&a));
4020 chat.add_child(comp.clone());
4021 chat.add_child(Arc::new(Spacer::new(1)));
4024 *state.current_assistant.lock().unwrap() = Some(comp);
4025 tui.request_render(false);
4026 }
4027 AgentMessage::Custom(custom) => {
4028 let payload = serde_json::json!({
4029 "customType": custom.role,
4030 "content": custom.content,
4031 "details": custom.data,
4032 "expanded": false,
4033 "outputPad": 1,
4034 });
4035 if let Some(component) = extension_message_component(
4036 &state.extension_session,
4037 &custom.role,
4038 &payload,
4039 state.markdown_transformer(),
4040 ) {
4041 chat.add_child(component);
4042 chat.add_child(Arc::new(Spacer::new(1)));
4043 tui.request_render(false);
4044 } else {
4045 add_note_message(chat, &custom_message_fallback(&custom));
4046 tui.request_render(false);
4047 }
4048 }
4049 _ => {}
4052 },
4053
4054 AgentEvent::MessageUpdate {
4055 message,
4056 assistant_message_event,
4057 } => {
4058 if let AgentMessage::Assistant(a) = &message {
4059 let text = assistant_text(a);
4060 for c in &a.content {
4065 if let Content::ToolCall(tc) = c {
4066 if tc.name == "bash" {
4067 let command = tc
4073 .arguments
4074 .get("command")
4075 .and_then(|v| v.as_str())
4076 .unwrap_or("");
4077 let mut bash = state.bash_components.lock().unwrap();
4078 if !bash.contains_key(&tc.id) {
4079 let comp = Arc::new(BashExecutionComponent::new(command));
4080 chat.add_child(comp.clone());
4081 bash.insert(tc.id.clone(), comp);
4082 }
4083 } else {
4084 let mut tools = state.tool_components.lock().unwrap();
4085 if !tools.contains_key(&tc.id) {
4086 let comp = Arc::new(ToolExecutionComponent::new(
4087 &tc.name,
4088 &tc.arguments.to_string(),
4089 ));
4090 comp.set_running();
4091 chat.add_child(comp.clone());
4092 tools.insert(tc.id.clone(), comp);
4093 }
4094 }
4095 }
4096 }
4097 let _ = assistant_message_event; if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
4099 comp.update_blocks(&assistant_blocks(a));
4102 }
4103 *state.last_assistant_text.lock().unwrap() = text;
4104 tui.request_render(false);
4105 }
4106 }
4107
4108 AgentEvent::MessageEnd { message } => {
4109 if let AgentMessage::Assistant(a) = &message {
4110 let text = assistant_text(a);
4111 if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4112 comp.update_blocks(&assistant_blocks(a));
4113 comp.set_streaming(false);
4114 }
4115 if !text.is_empty() {
4117 *state.last_assistant_text.lock().unwrap() = text;
4118 }
4119 let usage = &a.usage;
4125 let prev_input = *state.last_input_tokens.lock().unwrap();
4126 if prev_input > 0
4127 && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
4128 && usage.cache_read == 0
4129 {
4130 add_note_message(
4131 &state.chat_container,
4132 &format!(
4133 "Cache miss: {} tokens re-billed",
4134 format_tokens(usage.input)
4135 ),
4136 );
4137 }
4138 *state.last_input_tokens.lock().unwrap() = usage.input;
4139 }
4140 tui.request_render(false);
4141 }
4142
4143 AgentEvent::ToolExecutionStart {
4144 tool_call_id,
4145 tool_name,
4146 args,
4147 } => {
4148 if tool_name == "bash" {
4149 let command = args
4154 .get("command")
4155 .and_then(|v| v.as_str())
4156 .unwrap_or("")
4157 .to_string();
4158 let mut bash_map = state.bash_components.lock().unwrap();
4159 if let Some(existing) = bash_map.get(&tool_call_id) {
4160 existing.set_command(&command);
4165 } else {
4166 let comp = Arc::new(BashExecutionComponent::new(command));
4167 chat.add_child(comp.clone());
4168 bash_map.insert(tool_call_id.clone(), comp);
4169 }
4170 } else {
4171 let comp = {
4172 let mut tools = state.tool_components.lock().unwrap();
4173 if let Some(existing) = tools.get(&tool_call_id) {
4174 existing.set_args(&args.to_string());
4175 existing.clone()
4176 } else {
4177 let comp =
4178 Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
4179 comp.set_running();
4180 chat.add_child(comp.clone());
4181 tools.insert(tool_call_id.clone(), comp.clone());
4182 comp
4183 }
4184 };
4185 state.remember_tool(comp);
4186 }
4187 state.sync_working_loader_with_bash();
4188 tui.request_render(false);
4189 }
4190
4191 AgentEvent::ToolExecutionUpdate {
4192 tool_call_id,
4193 tool_name,
4194 partial_result,
4195 ..
4196 } => {
4197 if tool_name == "bash" {
4198 let chunk = tool_result_text(&partial_result);
4203 if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
4204 bash.append_output(&chunk);
4205 } else {
4206 let comp = Arc::new(BashExecutionComponent::new(""));
4209 comp.append_output(&chunk);
4210 chat.add_child(comp.clone());
4211 state
4212 .bash_components
4213 .lock()
4214 .unwrap()
4215 .insert(tool_call_id.clone(), comp);
4216 }
4217 } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
4218 comp.set_result(&tool_result_text(&partial_result), false);
4221 apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
4222 state.remember_tool(comp.clone());
4223 } else {
4224 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4226 comp.set_running();
4227 comp.set_result(&tool_result_text(&partial_result), false);
4228 apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
4229 chat.add_child(comp.clone());
4230 state
4231 .tool_components
4232 .lock()
4233 .unwrap()
4234 .insert(tool_call_id.clone(), comp.clone());
4235 state.remember_tool(comp);
4236 }
4237 state.sync_working_loader_with_bash();
4238 tui.request_render(false);
4239 }
4240
4241 AgentEvent::ToolExecutionEnd {
4242 tool_call_id,
4243 tool_name,
4244 result,
4245 is_error,
4246 } => {
4247 if tool_name == "bash" {
4248 let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
4249 if let Some(bash) = bash {
4250 finalize_bash(&bash, &result, is_error);
4251 } else {
4252 let command = result
4255 .details
4256 .get("command")
4257 .and_then(|v| v.as_str())
4258 .unwrap_or("")
4259 .to_string();
4260 let comp = Arc::new(BashExecutionComponent::new(command));
4261 comp.append_output(&tool_result_text(&result));
4262 finalize_bash(&comp, &result, is_error);
4263 chat.add_child(comp);
4264 }
4265 } else {
4266 let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
4267 if let Some(comp) = comp {
4268 comp.set_result(&tool_result_text(&result), is_error);
4269 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4270 } else {
4271 let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4274 comp.set_result(&tool_result_text(&result), is_error);
4275 apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4276 chat.add_child(comp.clone());
4277 state.remember_tool(comp);
4278 }
4279 }
4280 state.sync_working_loader_with_bash();
4281 tui.request_render(false);
4282 }
4283 }
4284}
4285
4286fn finalize_bash(
4290 comp: &Arc<BashExecutionComponent>,
4291 result: &rpi_agent::AgentToolResult,
4292 is_error: bool,
4293) {
4294 let exit_code = if is_error { Some(1) } else { Some(0) };
4297 let truncated = result
4298 .details
4299 .get("truncation")
4300 .and_then(|t| t.get("truncated"))
4301 .and_then(|v| v.as_bool())
4302 .unwrap_or(false);
4303 let full_output_path = result
4304 .details
4305 .get("full_output_path")
4306 .and_then(|v| v.as_str())
4307 .map(|s| s.to_string());
4308 let truncation = BashTruncation {
4309 truncated,
4310 full_output_path,
4311 };
4312 let cancelled = false; comp.set_complete(exit_code, cancelled, truncation);
4314}
4315
4316fn apply_edit_diff(
4321 comp: &Arc<ToolExecutionComponent>,
4322 tool_name: &str,
4323 details: &serde_json::Value,
4324 tui: &Arc<TuiAltScreen>,
4325) {
4326 if tool_name != "edit" {
4327 return;
4328 }
4329 let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
4330 return;
4331 };
4332 if diff_text.is_empty() {
4333 return;
4334 }
4335 let width = tui.width();
4336 let lines = render_diff(diff_text, width);
4337 comp.set_diff(lines);
4338}
4339
4340fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
4343 use rpi_agent::TextContentOrImage;
4344 let mut parts: Vec<String> = Vec::new();
4345 for c in &result.content {
4346 if let TextContentOrImage::Text(t) = c {
4347 parts.push(t.text.clone());
4348 }
4349 }
4350 let joined = parts.join("\n");
4351 let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
4353 if one_line.chars().count() > 200 {
4354 let truncated: String = one_line.chars().take(200).collect();
4355 format!("{truncated}…")
4356 } else {
4357 one_line
4358 }
4359}
4360
4361fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
4366 use rpi_agent::TextContentOrImage;
4367 let mut parts: Vec<String> = Vec::new();
4368 for c in &result.content {
4369 if let TextContentOrImage::Text(t) = c {
4370 parts.push(t.text.clone());
4371 }
4372 }
4373 parts.join("\n")
4374}
4375
4376fn open_selector(
4384 state: &Arc<TuiState>,
4385 editor_container: &Arc<Container>,
4386 editor: &Arc<Editor>,
4387 tui: &Arc<TuiAltScreen>,
4388 list: Arc<SelectList>,
4389 kind: SelectorKind,
4390) {
4391 editor.set_focused(false);
4393 editor_container.clear();
4395 editor_container.add_child(list.clone());
4396 *state.active_selector.lock().unwrap() = Some((list, kind));
4397 tui.request_render(false);
4398}
4399
4400fn close_selector(
4403 state: &Arc<TuiState>,
4404 editor_container: &Arc<Container>,
4405 editor: &Arc<Editor>,
4406 tui: &Arc<TuiAltScreen>,
4407) {
4408 editor_container.clear();
4409 editor_container.add_child(editor.clone());
4410 editor.set_focused(true);
4411 *state.active_selector.lock().unwrap() = None;
4412 tui.request_render(false);
4413}
4414
4415fn open_model_selector(
4421 state: &Arc<TuiState>,
4422 editor_container: &Arc<Container>,
4423 editor: &Arc<Editor>,
4424 tui: &Arc<TuiAltScreen>,
4425 catalog: &[rpi_ai::Model],
4426 lane: &Arc<dyn AgentLane>,
4427 lane_model_id: &str,
4428 chat: &Arc<Container>,
4429) {
4430 let mut items: Vec<SelectItem> = Vec::new();
4431 for m in catalog {
4432 let label = if m.name.is_empty() {
4433 short_model_name(&m.id)
4434 } else {
4435 m.name.clone()
4436 };
4437 let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
4438 " (current)"
4439 } else {
4440 ""
4441 };
4442 items.push(
4443 SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)),
4444 );
4445 }
4446 if items.is_empty() {
4447 add_note_message(
4448 chat,
4449 "No models in the catalog. Use --model at startup to select one.",
4450 );
4451 tui.request_render(false);
4452 return;
4453 }
4454 let list = Arc::new(SelectList::new(items, 10));
4455
4456 let catalog_arc = catalog.to_vec();
4460 let state_sel = state.clone();
4461 let ec_sel = editor_container.clone();
4462 let editor_sel = editor.clone();
4463 let tui_sel = tui.clone();
4464 let chat_sel = chat.clone();
4465 let lane_sel = lane.clone();
4466 list.on_select(Arc::new(move |item| {
4467 let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
4468 add_note_message(
4469 &chat_sel,
4470 &format!("Model {} not found in catalog.", item.label),
4471 );
4472 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4473 return;
4474 };
4475 state_sel.set_current_model(&model);
4476 let lane = lane_sel.clone();
4477 tokio::spawn(async move {
4478 let _ = lane.set_model(model).await;
4479 });
4480 add_note_message(
4481 &chat_sel,
4482 &format!(
4483 "Model set to {} — applies to the next message.",
4484 short_model_name(&item.value)
4485 ),
4486 );
4487 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4488 }));
4489 let state_cancel = state.clone();
4490 let ec_cancel = editor_container.clone();
4491 let editor_cancel = editor.clone();
4492 let tui_cancel = tui.clone();
4493 list.on_cancel(Arc::new(move || {
4494 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4495 }));
4496
4497 open_selector(
4498 state,
4499 editor_container,
4500 editor,
4501 tui,
4502 list,
4503 SelectorKind::Model,
4504 );
4505}
4506
4507fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
4512 if catalog.is_empty() {
4513 return None;
4514 }
4515 let idx = catalog
4516 .iter()
4517 .position(|m| m.id.eq_ignore_ascii_case(current_id));
4518 match idx {
4519 Some(i) => {
4520 let next = (i + 1) % catalog.len();
4521 Some(catalog[next].clone())
4522 }
4523 None => Some(catalog[0].clone()),
4524 }
4525}
4526
4527fn open_session_selector(
4532 state: &Arc<TuiState>,
4533 editor_container: &Arc<Container>,
4534 editor: &Arc<Editor>,
4535 tui: &Arc<TuiAltScreen>,
4536 cwd: &std::path::Path,
4537 tx: &mpsc::UnboundedSender<TuiMessage>,
4538) {
4539 let dir = crate::session::default_session_dir(cwd);
4540 let mut items: Vec<SelectItem> = Vec::new();
4541 if let Ok(entries) = std::fs::read_dir(&dir) {
4542 for entry in entries.flatten() {
4543 let path = entry.path();
4544 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
4545 continue;
4546 }
4547 let stem = path
4548 .file_stem()
4549 .and_then(|s| s.to_str())
4550 .unwrap_or("(unnamed)")
4551 .to_string();
4552 let display = path
4553 .file_name()
4554 .and_then(|s| s.to_str())
4555 .unwrap_or(&stem)
4556 .to_string();
4557 items.push(SelectItem::new(&stem, &display));
4558 }
4559 }
4560 if items.is_empty() {
4561 add_note_message(
4562 &state.chat_container,
4563 "No saved sessions found. Sessions are created automatically in interactive mode.",
4564 );
4565 tui.request_render(false);
4566 return;
4567 }
4568 let list = Arc::new(SelectList::new(items, 10));
4569
4570 let state_sel = state.clone();
4571 let ec_sel = editor_container.clone();
4572 let editor_sel = editor.clone();
4573 let tui_sel = tui.clone();
4574 let tx_sel = tx.clone();
4575 list.on_select(Arc::new(move |item| {
4576 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4580 let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
4581 }));
4582 let state_cancel = state.clone();
4583 let ec_cancel = editor_container.clone();
4584 let editor_cancel = editor.clone();
4585 let tui_cancel = tui.clone();
4586 list.on_cancel(Arc::new(move || {
4587 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4588 }));
4589
4590 open_selector(
4591 state,
4592 editor_container,
4593 editor,
4594 tui,
4595 list,
4596 SelectorKind::Session,
4597 );
4598}
4599
4600fn custom_entry_display_text(
4601 custom_type: &str,
4602 data: Option<&serde_json::Value>,
4603) -> Option<String> {
4604 let data = data?;
4605 let text = data
4606 .get("summary")
4607 .or_else(|| data.get("text"))
4608 .or_else(|| data.get("output"))
4609 .and_then(|value| value.as_str())
4610 .filter(|value| !value.trim().is_empty())?;
4611 let label = match custom_type {
4612 "compactionSummary" => "Compaction summary",
4613 "branchSummary" => "Branch summary",
4614 "bashExecution" => "Command output",
4615 other => other,
4616 };
4617 Some(format!("{label}: {text}"))
4618}
4619
4620async fn open_tree_selector(
4624 harness: &AgentHarness,
4625 state: &Arc<TuiState>,
4626 editor_container: &Arc<Container>,
4627 editor: &Arc<Editor>,
4628 tui: &Arc<TuiAltScreen>,
4629 chat: &Arc<Container>,
4630 tx: &mpsc::UnboundedSender<TuiMessage>,
4631) {
4632 let entries = match harness
4633 .session()
4634 .view("main")
4635 .find_entries(&EntryQuery {
4636 order: Some(EntryOrder::OldestFirst),
4637 ..Default::default()
4638 })
4639 .await
4640 {
4641 Ok(entries) => entries,
4642 Err(error) => {
4643 add_error_message(chat, &format!("Could not read session tree: {error}"));
4644 tui.request_render(false);
4645 return;
4646 }
4647 };
4648 let current = harness.session().get_leaf_id().await.ok().flatten();
4649 let items: Vec<SelectItem> = entries
4650 .iter()
4651 .map(|entry| {
4652 let marker = if current.as_deref() == Some(entry.id()) {
4653 " (current)"
4654 } else {
4655 ""
4656 };
4657 SelectItem::new(
4658 entry.id(),
4659 &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
4660 )
4661 .with_description(&entry.id()[..entry.id().len().min(12)])
4662 })
4663 .collect();
4664 if items.is_empty() {
4665 add_note_message(chat, "The current session has no entries to navigate.");
4666 tui.request_render(false);
4667 return;
4668 }
4669 let list = Arc::new(SelectList::new(items, 12));
4670 let state_sel = state.clone();
4671 let ec_sel = editor_container.clone();
4672 let editor_sel = editor.clone();
4673 let tui_sel = tui.clone();
4674 let tx_sel = tx.clone();
4675 list.on_select(Arc::new(move |item| {
4676 let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
4677 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4678 }));
4679 let state_cancel = state.clone();
4680 let ec_cancel = editor_container.clone();
4681 let editor_cancel = editor.clone();
4682 let tui_cancel = tui.clone();
4683 list.on_cancel(Arc::new(move || {
4684 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4685 }));
4686 open_selector(
4687 state,
4688 editor_container,
4689 editor,
4690 tui,
4691 list,
4692 SelectorKind::Tree,
4693 );
4694}
4695
4696fn open_theme_selector(
4699 state: &Arc<TuiState>,
4700 editor_container: &Arc<Container>,
4701 editor: &Arc<Editor>,
4702 tui: &Arc<TuiAltScreen>,
4703) {
4704 let items = vec![
4705 SelectItem::new("dark", "Dark").with_description("Default dark theme"),
4706 SelectItem::new("light", "Light").with_description("Light background"),
4707 SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
4708 ];
4709 let list = Arc::new(SelectList::new(items, 10));
4710
4711 let state_sel = state.clone();
4712 let ec_sel = editor_container.clone();
4713 let editor_sel = editor.clone();
4714 let tui_sel = tui.clone();
4715 let chat_sel = state.chat_container.clone();
4716 list.on_select(Arc::new(move |item| {
4717 let preset = match item.value.as_str() {
4718 "light" => ThemePreset::Light,
4719 "monochrome" => ThemePreset::Monochrome,
4720 _ => ThemePreset::Dark,
4721 };
4722 apply_theme_preset(preset);
4723 add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
4726 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4727 tui_sel.render_now(true);
4728 }));
4729 let state_cancel = state.clone();
4730 let ec_cancel = editor_container.clone();
4731 let editor_cancel = editor.clone();
4732 let tui_cancel = tui.clone();
4733 list.on_cancel(Arc::new(move || {
4734 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4735 }));
4736
4737 open_selector(
4738 state,
4739 editor_container,
4740 editor,
4741 tui,
4742 list,
4743 SelectorKind::Theme,
4744 );
4745}
4746
4747fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4754 use rpi_ai::types::ThinkingLevel::*;
4755 match level {
4756 Off => "Off — No reasoning",
4757 Minimal => "Minimal — Brief reasoning (~1k tokens)",
4758 Low => "Low — Light reasoning (~1k tokens)",
4759 Medium => "Medium — Moderate reasoning (~80% of max)",
4760 High => "High — Extensive reasoning (~95% of max)",
4761 Xhigh => "Xhigh — Near-maximal reasoning",
4762 Max => "Max — Maximum reasoning",
4763 }
4764}
4765
4766fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4769 use rpi_ai::types::ThinkingLevel::*;
4770 match level {
4771 Off => "off",
4772 Minimal => "minimal",
4773 Low => "low",
4774 Medium => "medium",
4775 High => "high",
4776 Xhigh => "xhigh",
4777 Max => "max",
4778 }
4779}
4780
4781fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
4784 use rpi_ai::types::ThinkingLevel::*;
4785 match name.to_ascii_lowercase().as_str() {
4786 "off" => Some(Off),
4787 "minimal" => Some(Minimal),
4788 "low" => Some(Low),
4789 "medium" => Some(Medium),
4790 "high" => Some(High),
4791 "xhigh" => Some(Xhigh),
4792 "max" => Some(Max),
4793 _ => None,
4794 }
4795}
4796
4797fn open_thinking_selector(
4807 state: &Arc<TuiState>,
4808 editor_container: &Arc<Container>,
4809 editor: &Arc<Editor>,
4810 tui: &Arc<TuiAltScreen>,
4811 lane: &Arc<dyn AgentLane>,
4812 catalog: &[rpi_ai::Model],
4813 lane_model_id: &str,
4814 chat: &Arc<Container>,
4815) {
4816 let model = catalog
4819 .iter()
4820 .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
4821 let levels: Vec<rpi_ai::types::ThinkingLevel> = model
4822 .map(|m| m.supported_thinking_levels())
4823 .unwrap_or_else(|| {
4824 use rpi_ai::types::ThinkingLevel::*;
4825 vec![Off, Minimal, Low, Medium, High]
4826 });
4827 let mut items: Vec<SelectItem> = Vec::new();
4828 for lvl in &levels {
4829 let name = thinking_level_name(*lvl);
4830 items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
4831 }
4832 if items.is_empty() {
4833 add_note_message(chat, "This model has no supported thinking levels.");
4834 tui.request_render(false);
4835 return;
4836 }
4837 let list = Arc::new(SelectList::new(items, 10));
4838
4839 let state_sel = state.clone();
4840 let ec_sel = editor_container.clone();
4841 let editor_sel = editor.clone();
4842 let tui_sel = tui.clone();
4843 let chat_sel = chat.clone();
4844 let lane_sel = lane.clone();
4845 list.on_select(Arc::new(move |item| {
4846 let Some(level) = thinking_level_from_name(&item.value) else {
4847 add_note_message(
4848 &chat_sel,
4849 &format!("Unknown thinking level: {}.", item.label),
4850 );
4851 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4852 return;
4853 };
4854 let lane = lane_sel.clone();
4855 let footer_sel = state_sel.footer.clone();
4856 tokio::spawn(async move {
4857 let _ = lane.set_thinking_level(level).await;
4858 });
4859 footer_sel.set_thinking_level(Some(thinking_level_name(level)));
4863 add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
4864 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4865 }));
4866 let state_cancel = state.clone();
4867 let ec_cancel = editor_container.clone();
4868 let editor_cancel = editor.clone();
4869 let tui_cancel = tui.clone();
4870 list.on_cancel(Arc::new(move || {
4871 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4872 }));
4873
4874 open_selector(
4875 state,
4876 editor_container,
4877 editor,
4878 tui,
4879 list,
4880 SelectorKind::Thinking,
4881 );
4882}
4883
4884fn open_tools_selector(
4890 state: &Arc<TuiState>,
4891 editor_container: &Arc<Container>,
4892 editor: &Arc<Editor>,
4893 tui: &Arc<TuiAltScreen>,
4894 lane: &Arc<dyn AgentLane>,
4895 chat: &Arc<Container>,
4896) {
4897 let active = match tokio::runtime::Handle::try_current() {
4902 Ok(h) => h
4903 .block_on(async { lane.get_active_tools().await })
4904 .unwrap_or_default(),
4905 Err(_) => Vec::new(),
4906 };
4907 let mut items: Vec<SelectItem> = Vec::new();
4908 for name in crate::session::BUILTIN_TOOL_NAMES {
4909 let on = active.iter().any(|a| a == name);
4910 let label = if on {
4911 format!("{name} (on)")
4912 } else {
4913 (*name).to_string()
4914 };
4915 items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
4916 }
4917 let list = Arc::new(SelectList::new(items, 10));
4918
4919 let active_captured = active.clone();
4921 let state_sel = state.clone();
4922 let ec_sel = editor_container.clone();
4923 let editor_sel = editor.clone();
4924 let tui_sel = tui.clone();
4925 let chat_sel = chat.clone();
4926 let lane_sel = lane.clone();
4927 list.on_select(Arc::new(move |item| {
4928 let mut next = active_captured.clone();
4929 if let Some(pos) = next.iter().position(|a| a == &item.value) {
4930 next.remove(pos);
4931 } else {
4932 next.push(item.value.clone());
4933 }
4934 let on = next.iter().any(|a| a == &item.value);
4935 let lane = lane_sel.clone();
4936 let next_clone = next.clone();
4937 tokio::spawn(async move {
4938 let _ = lane.set_active_tools(next_clone).await;
4939 });
4940 let list_str = if next.is_empty() {
4941 "(none)".to_string()
4942 } else {
4943 next.join(", ")
4944 };
4945 add_note_message(
4946 &chat_sel,
4947 &format!(
4948 "{} {} — active tools: {}",
4949 item.value,
4950 if on { "enabled" } else { "disabled" },
4951 list_str
4952 ),
4953 );
4954 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4955 }));
4956 let state_cancel = state.clone();
4957 let ec_cancel = editor_container.clone();
4958 let editor_cancel = editor.clone();
4959 let tui_cancel = tui.clone();
4960 list.on_cancel(Arc::new(move || {
4961 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4962 }));
4963
4964 open_selector(
4965 state,
4966 editor_container,
4967 editor,
4968 tui,
4969 list,
4970 SelectorKind::Tools,
4971 );
4972}
4973
4974fn open_images_selector(
4978 state: &Arc<TuiState>,
4979 editor_container: &Arc<Container>,
4980 editor: &Arc<Editor>,
4981 tui: &Arc<TuiAltScreen>,
4982 chat: &Arc<Container>,
4983) {
4984 let current = *state.show_images.lock().unwrap();
4985 let items = vec![
4986 SelectItem::new("yes", "Yes").with_description(if current {
4987 "Inline images (current)"
4988 } else {
4989 "Inline images"
4990 }),
4991 SelectItem::new("no", "No").with_description(if current {
4992 "Placeholder only"
4993 } else {
4994 "Placeholder only (current)"
4995 }),
4996 ];
4997 let list = Arc::new(SelectList::new(items, 5));
4998
4999 let state_sel = state.clone();
5000 let ec_sel = editor_container.clone();
5001 let editor_sel = editor.clone();
5002 let tui_sel = tui.clone();
5003 let chat_sel = chat.clone();
5004 list.on_select(Arc::new(move |item| {
5005 let on = item.value == "yes";
5006 *state_sel.show_images.lock().unwrap() = on;
5007 add_note_message(
5008 &chat_sel,
5009 &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
5010 );
5011 close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
5012 }));
5013 let state_cancel = state.clone();
5014 let ec_cancel = editor_container.clone();
5015 let editor_cancel = editor.clone();
5016 let tui_cancel = tui.clone();
5017 list.on_cancel(Arc::new(move || {
5018 close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
5019 }));
5020
5021 open_selector(
5022 state,
5023 editor_container,
5024 editor,
5025 tui,
5026 list,
5027 SelectorKind::Images,
5028 );
5029}
5030
5031fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
5039 let text = editor.get_text();
5040 let (_row, col) = editor.cursor_position();
5041 let cursor = col.min(text.len());
5049 let suggestions = state.autocomplete.get_suggestions(&text, cursor);
5050 render_autocomplete(state, suggestions);
5051}
5052
5053fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
5055 state.autocomplete_container.clear();
5056 let Some(sugg) = suggestions else {
5057 return;
5058 };
5059 if sugg.items.is_empty() {
5060 return;
5061 }
5062 let accent = state.theme_manager.get().colors.accent;
5065 let muted = state.theme_manager.get().colors.muted;
5066 for (i, item) in sugg.items.iter().take(5).enumerate() {
5067 let prefix = if i == 0 { "→ " } else { " " };
5068 let label = item.display_text();
5069 let line = if i == 0 {
5070 format!(
5071 "{prefix}{} {}",
5072 accent.fg(label),
5073 muted.fg(item.description.as_deref().unwrap_or(""))
5074 )
5075 } else {
5076 format!(
5077 "{prefix}{} {}",
5078 muted.fg(label),
5079 muted.fg(item.description.as_deref().unwrap_or(""))
5080 )
5081 };
5082 state
5083 .autocomplete_container
5084 .add_child(Arc::new(Text::new(line, 1, 0)));
5085 }
5086}
5087
5088fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
5092 let text = editor.get_text();
5093 let (_row, col) = editor.cursor_position();
5094 let cursor = col.min(text.len());
5095 let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
5096 return false;
5097 };
5098 let Some(top) = sugg.items.first() else {
5099 return false;
5100 };
5101 let start = sugg.start.min(text.len());
5105 let end = sugg.end.min(text.len());
5106 let mut replaced = String::with_capacity(text.len() + top.text.len());
5107 replaced.push_str(&text[..start]);
5108 replaced.push_str(&top.text);
5109 replaced.push_str(&text[end..]);
5112 if top.insert_space && !replaced.ends_with('/') {
5113 replaced.push(' ');
5114 }
5115 let new_cursor = replaced.len().min(
5118 start
5119 + top.text.len()
5120 + if top.insert_space && !top.text.ends_with('/') {
5121 1
5122 } else {
5123 0
5124 },
5125 );
5126 editor.set_text(&replaced);
5127 editor.set_cursor(0, new_cursor);
5128 state.autocomplete_container.clear();
5129 true
5130}
5131
5132fn add_welcome_message(container: &Arc<Container>) {
5138 let c = current_theme().colors;
5139 let title = format!(
5143 "{} {}",
5144 c.accent.fg(&tui_bold("rpi")),
5145 c.muted.fg("interactive TUI")
5146 );
5147 container.add_child(Arc::new(Text::new(title, 1, 0)));
5148 container.add_child(Arc::new(Spacer::new(1)));
5149 container.add_child(Arc::new(Text::new(
5150 c.dim.fg("Type your message and press Enter to send."),
5151 1,
5152 0,
5153 )));
5154 let hint = c
5155 .dim
5156 .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
5157 container.add_child(Arc::new(Text::new(hint, 1, 0)));
5158 container.add_child(Arc::new(DynamicBorder::new()));
5159}
5160
5161fn add_help_message(container: &Arc<Container>) {
5163 let c = current_theme().colors;
5164 container.add_child(Arc::new(Text::new(
5168 c.md_heading.fg(&tui_bold("📚 Available Commands")),
5169 1,
5170 0,
5171 )));
5172 container.add_child(Arc::new(Spacer::new(1)));
5173
5174 let cmds: &[(&str, &str)] = &[
5175 ("/help, /?", "Show this help message"),
5176 ("/clear, /new", "Clear the conversation"),
5177 ("/exit, /quit, /q", "Exit the application"),
5178 ("/version, /v", "Show version information"),
5179 ("/model, /m", "Choose a model (live switch)"),
5180 ("/thinking, /think", "Set reasoning depth (selector)"),
5181 ("/tools", "Toggle built-in tools on/off"),
5182 ("/images", "Toggle inline image rendering"),
5183 ("/session", "List saved sessions"),
5184 ("/theme", "Choose a theme (selector)"),
5185 ("/compact", "Compact the conversation"),
5186 ("/copy", "Copy last reply to clipboard"),
5187 ("/hotkeys", "Show keyboard shortcuts"),
5188 ("/armin", "🐾 Easter egg"),
5189 ("/earendil", "Earendil announcement"),
5190 ];
5191 let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5192 for (cmd, desc) in cmds {
5193 let row = format!(
5194 " {:<cmd_w$} {} {}",
5195 c.accent.fg(cmd),
5196 c.dim.fg("—"),
5197 c.muted.fg(desc)
5198 );
5199 container.add_child(Arc::new(Text::new(row, 1, 0)));
5200 }
5201 container.add_child(Arc::new(Spacer::new(1)));
5202}
5203
5204fn add_version_message(container: &Arc<Container>) {
5206 let c = current_theme().colors;
5207 container.add_child(Arc::new(Text::new(
5208 c.md_heading.fg(&tui_bold("📦 Version Information")),
5209 1,
5210 0,
5211 )));
5212 container.add_child(Arc::new(Spacer::new(1)));
5213 container.add_child(Arc::new(Text::new(
5216 format!(
5217 " {} {}",
5218 c.muted.fg("rpi-cli"),
5219 c.text.fg(&format!("v{}", crate::VERSION))
5220 ),
5221 1,
5222 0,
5223 )));
5224 container.add_child(Arc::new(Text::new(
5225 format!(
5226 " {}",
5227 c.dim.fg("Rust implementation of pi coding agent TUI")
5228 ),
5229 1,
5230 0,
5231 )));
5232 container.add_child(Arc::new(Spacer::new(1)));
5233}
5234
5235fn add_hotkeys_message(container: &Arc<Container>) {
5237 let c = current_theme().colors;
5238 container.add_child(Arc::new(Text::new(
5239 c.md_heading.fg(&tui_bold("⌨️ Keyboard Shortcuts")),
5240 1,
5241 0,
5242 )));
5243 container.add_child(Arc::new(Spacer::new(1)));
5244 let keys: &[(&str, &str)] = &[
5245 ("Enter", "Send message"),
5246 ("Shift+Enter", "New line"),
5247 ("Tab", "Accept autocomplete suggestion"),
5248 ("Ctrl+A / Ctrl+E", "Line start / end"),
5249 (
5250 "Ctrl+K / Ctrl+U",
5251 "Kill to end / start of line (Ctrl+Y yanks)",
5252 ),
5253 ("Ctrl+- / Ctrl+R", "Undo / redo"),
5254 ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
5255 ("Alt+Backspace", "Kill previous word"),
5256 ("Ctrl+C", "Abort a run, or exit when idle"),
5257 ("Esc", "Abort a running prompt"),
5258 ("Ctrl+L", "Open model selector"),
5259 ("Ctrl+M", "Cycle to the next model (live)"),
5260 ("Ctrl+T", "Expand/collapse last tool result"),
5261 ("PageUp/Down", "Scroll transcript by one page"),
5262 ("Home / End", "Jump to transcript start / latest output"),
5263 ];
5264 let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5265 for (key, desc) in keys {
5266 let row = format!(
5267 " {:<key_w$} {} {}",
5268 c.accent.fg(key),
5269 c.dim.fg("—"),
5270 c.muted.fg(desc)
5271 );
5272 container.add_child(Arc::new(Text::new(row, 1, 0)));
5273 }
5274 container.add_child(Arc::new(Spacer::new(1)));
5275}
5276
5277fn add_user_message(container: &Arc<Container>, text: &str) {
5283 container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
5284 container.add_child(Arc::new(Spacer::new(1)));
5285}
5286
5287fn add_error_message(container: &Arc<Container>, text: &str) {
5289 let c = current_theme().colors;
5290 container.add_child(Arc::new(Text::new(
5291 format!(" {} {}", c.error.fg("✗"), c.error.fg(text)),
5292 1,
5293 0,
5294 )));
5295 container.add_child(Arc::new(Spacer::new(1)));
5296}
5297
5298fn add_note_message(container: &Arc<Container>, text: &str) {
5300 let c = current_theme().colors;
5301 container.add_child(Arc::new(Text::new(
5302 format!(" {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
5303 1,
5304 0,
5305 )));
5306 container.add_child(Arc::new(Spacer::new(1)));
5307}
5308
5309fn show_context_panel(
5318 chat: &Arc<Container>,
5319 resources: &Arc<rpi_harness::types::AgentHarnessResources>,
5320) {
5321 let skills = resources.skills.as_deref().unwrap_or(&[]);
5322 let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
5323 let mut lines: Vec<String> = Vec::new();
5324 lines.push("📂 Discovered resources for this session:".into());
5325
5326 if skills.is_empty() {
5327 lines.push(
5328 " Skills: (none discovered — create .pi/skills/ or ~/.rpi/agent/skills/)".into(),
5329 );
5330 } else {
5331 lines.push(format!(" Skills ({}):", skills.len()));
5332 for s in skills {
5333 let marker = if s.disable_model_invocation == Some(true) {
5334 " [hidden]"
5335 } else {
5336 ""
5337 };
5338 let desc: String = s.description.chars().take(72).collect();
5339 lines.push(format!(" • {}{marker} — {desc}", s.name));
5340 }
5341 }
5342
5343 if templates.is_empty() {
5344 lines.push(
5345 " Prompt templates: (none — create .pi/prompts/ or ~/.rpi/agent/prompts/)".into(),
5346 );
5347 } else {
5348 lines.push(format!(" Prompt templates ({}):", templates.len()));
5349 for t in templates {
5350 let desc = t
5351 .description
5352 .as_deref()
5353 .unwrap_or("(no description)")
5354 .chars()
5355 .take(72)
5356 .collect::<String>();
5357 lines.push(format!(" • /{} — {desc}", t.name));
5358 }
5359 }
5360 lines.push(" Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
5361 lines.push(" SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
5362 lines.push(
5363 " Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
5364 .into(),
5365 );
5366 let body = lines.join("\n");
5367 container_note_block(chat, &body);
5368}
5369
5370fn container_note_block(container: &Arc<Container>, body: &str) {
5372 for line in body.lines() {
5373 container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
5374 }
5375 container.add_child(Arc::new(Spacer::new(1)));
5376}
5377
5378pub fn is_tui_supported() -> bool {
5384 std::io::stdout().is_terminal()
5385}
5386
5387#[allow(unused_imports)]
5389use rpi_tui::Color as _Color;
5390
5391#[cfg(test)]
5392mod tests {
5393 use super::*;
5394 use rpi_tui::Component;
5395
5396 #[test]
5397 fn transcript_page_uses_viewport_with_overlap() {
5398 assert_eq!(transcript_page_size(24), 20);
5399 assert_eq!(transcript_page_size(4), 1);
5400 assert_eq!(transcript_page_size(0), 1);
5401 }
5402
5403 #[test]
5404 fn key_repeat_is_dispatched_but_release_is_not() {
5405 assert!(should_dispatch_key(KeyEventKind::Press));
5406 assert!(should_dispatch_key(KeyEventKind::Repeat));
5407 assert!(!should_dispatch_key(KeyEventKind::Release));
5408 }
5409
5410 #[test]
5411 fn test_layout_renders_welcome_message() {
5412 let chat = Arc::new(Container::new());
5413 add_welcome_message(&chat);
5414
5415 let scroll = Arc::new(ScrollView::new(
5416 chat.clone(),
5417 ScrollViewOptions {
5418 follow: FollowMode::End,
5419 primary: true,
5420 ..Default::default()
5421 },
5422 ));
5423
5424 let editor = Arc::new(Editor::new(
5425 EditorOptions {
5426 padding_x: 1,
5427 ..Default::default()
5428 },
5429 EditorStyle::default(),
5430 Arc::new(rpi_tui::Keybindings::new()),
5431 ));
5432 let dock = Arc::new(Container::new());
5433 dock.add_child(editor);
5434
5435 let footer = Arc::new(FooterComponent::new());
5436
5437 let root = VStack::from_children(vec![
5438 StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
5439 StackChild::Entry(StackEntry::new(dock)),
5440 StackChild::Entry(StackEntry::new(footer)),
5441 ]);
5442
5443 let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
5444
5445 let all: String = frame.lines.join("\n");
5446 assert!(
5447 all.contains("rpi"),
5448 "Welcome message not found. Rendered: {}",
5449 all
5450 );
5451 assert!(
5452 all.contains("Type your message"),
5453 "Help text not found. Rendered: {}",
5454 all
5455 );
5456 }
5457
5458 #[test]
5459 fn test_chat_container_has_welcome_content() {
5460 let chat = Arc::new(Container::new());
5461 add_welcome_message(&chat);
5462
5463 let lines = chat.render(80);
5464 let all: String = lines.join("\n");
5465 let plain = strip_ansi(&all);
5468 assert!(
5469 plain.contains("rpi"),
5470 "Welcome message not in chat container: {:?}",
5471 lines
5472 );
5473 }
5474
5475 #[test]
5481 fn tab_accept_suggestion_reflects_in_next_render() {
5482 use rpi_tui::render_layout_frame;
5483
5484 let editor = Arc::new(Editor::new(
5485 EditorOptions {
5486 padding_x: 1,
5487 ..Default::default()
5488 },
5489 EditorStyle::default(),
5490 Arc::new(rpi_tui::Keybindings::new()),
5491 ));
5492 editor.set_focused(true);
5493 let editor_container = Arc::new(Container::new());
5494 editor_container.add_child(editor.clone());
5495 let autocomplete_container = Arc::new(Container::new());
5496 let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
5497 let dock = Arc::new(VStack::from_children(vec![
5498 StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5499 StackChild::Entry(
5500 StackEntry::new(editor_container.clone())
5501 .shrink(0)
5502 .min_size(3),
5503 ),
5504 StackChild::Entry(StackEntry::new(footer)),
5505 ]));
5506
5507 let mut manager = AutocompleteManager::new();
5509 let mut combined = CombinedAutocompleteProvider::new();
5510 combined.add_provider(Arc::new(
5511 SlashCommandAutocompleteProvider::with_default_commands(),
5512 ));
5513 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
5514 manager.set_provider(Arc::new(combined));
5515 editor.insert("/mo");
5518 assert_eq!(editor.cursor_position(), (0, 3));
5519
5520 let frame_before = render_layout_frame(dock.clone(), 80, 10);
5521 assert!(
5522 frame_before.lines.iter().any(|l| l.contains("/mo")),
5523 "precondition: editor shows the typed prefix. Frame rows:\n{}",
5524 frame_before
5525 .lines
5526 .iter()
5527 .map(|l| format!(" [{l}]"))
5528 .collect::<Vec<_>>()
5529 .join("\n")
5530 );
5531
5532 let text = editor.get_text();
5534 let (_row, col) = editor.cursor_position();
5535 let cursor = col.min(text.len());
5536 let sugg = manager
5537 .get_suggestions(&text, cursor)
5538 .expect("slash suggestions for /mo");
5539 let top = sugg.items.first().expect("at least one suggestion");
5540 let start = sugg.start.min(text.len());
5541 let end = sugg.end.min(text.len());
5542 let mut replaced = String::new();
5543 replaced.push_str(&text[..start]);
5544 replaced.push_str(&top.text);
5545 replaced.push_str(&text[end..]);
5546 if top.insert_space && !replaced.ends_with('/') {
5547 replaced.push(' ');
5548 }
5549 editor.set_text(&replaced);
5550 editor.set_cursor(0, replaced.len().min(start + top.text.len()));
5551 autocomplete_container.clear();
5552 assert_eq!(editor.get_text(), "/model");
5553
5554 let frame_after = render_layout_frame(dock, 80, 10);
5556 let all: String = frame_after.lines.join("\n");
5557 assert!(
5558 all.contains("/model"),
5559 "completed text missing from next render. Got:\n{all}"
5560 );
5561 let editor_line = frame_after
5565 .lines
5566 .iter()
5567 .find(|l| l.contains("/model"))
5568 .expect("editor row with completed text");
5569 assert!(
5570 editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
5571 "caret must follow the full completed text. Got: {editor_line:?}"
5572 );
5573 }
5574
5575 #[test]
5576 fn test_slash_command_dispatch() {
5577 let registry = build_builtin_registry();
5582
5583 let resolves_to = |token: &str, canonical: &str| {
5585 let found = registry.find(token).expect("{token} should resolve");
5586 assert_eq!(
5587 found.name(),
5588 canonical,
5589 "{token} resolved to {} (expected {canonical})",
5590 found.name()
5591 );
5592 };
5593
5594 resolves_to("/help", "/help");
5595 resolves_to("/?", "/help"); resolves_to("/clear", "/clear");
5597 resolves_to("/new", "/clear"); resolves_to("/q", "/exit"); resolves_to("/quit", "/exit"); resolves_to("/version", "/version");
5601 resolves_to("/v", "/version"); resolves_to("/hotkeys", "/hotkeys");
5603 resolves_to("/model", "/model");
5604 resolves_to("/m", "/model"); resolves_to("/theme", "/theme");
5606 resolves_to("/session", "/session");
5607 resolves_to("/resume", "/session"); resolves_to("/compact", "/compact");
5609 resolves_to("/copy", "/copy");
5610 resolves_to("/thinking", "/thinking");
5611 resolves_to("/think", "/thinking"); resolves_to("/tools", "/tools");
5613 resolves_to("/images", "/images");
5614 resolves_to("/armin", "/armin");
5615 resolves_to("/earendil", "/earendil");
5616 resolves_to("/context", "/context");
5617 resolves_to("/settings", "/settings");
5619 resolves_to("/name", "/name");
5620 resolves_to("/export", "/export");
5621
5622 assert!(registry.find("/nope").is_none(), "/nope should be unknown");
5624 }
5625
5626 #[test]
5627
5628 fn test_registry_visible_entries_cover_dispatch() {
5629 let registry = build_builtin_registry();
5634 let names: Vec<String> = registry
5635 .visible_entries()
5636 .iter()
5637 .map(|c| c.name.clone())
5638 .collect();
5639 for recognized in [
5640 "/help",
5641 "/clear",
5642 "/new",
5643 "/exit",
5644 "/quit",
5645 "/version",
5646 "/model",
5647 "/session",
5648 "/theme",
5649 "/compact",
5650 "/copy",
5651 "/hotkeys",
5652 "/tools",
5653 "/images",
5654 "/thinking",
5655 "/armin",
5656 "/earendil",
5657 ] {
5658 assert!(
5659 names.contains(&recognized.to_string()),
5660 "{recognized} missing from autocomplete list"
5661 );
5662 }
5663 for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
5665 assert!(
5666 !names.contains(&hidden.to_string()),
5667 "{hidden} should be hidden from autocomplete"
5668 );
5669 }
5670 }
5671
5672 #[test]
5673 fn test_agent_event_mapping_creates_assistant_and_tool() {
5674 use rpi_ai::types::{
5677 StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
5678 ToolCall, ToolCallType, Usage,
5679 };
5680
5681 let state = Arc::new(TuiState {
5682 current_assistant: std::sync::Mutex::new(None),
5683 tool_components: std::sync::Mutex::new(HashMap::new()),
5684 bash_components: std::sync::Mutex::new(HashMap::new()),
5685 last_tool_comp: std::sync::Mutex::new(None),
5686 status: std::sync::Mutex::new(RunStatus::Idle),
5687 footer: Arc::new(FooterComponent::new()),
5688 status_container: Arc::new(Container::new()),
5689 chat_container: Arc::new(Container::new()),
5690 loader: Arc::new(Loader::new()),
5691 last_assistant_text: std::sync::Mutex::new(String::new()),
5692 active_selector: std::sync::Mutex::new(None),
5693 active_extension_editor: std::sync::Mutex::new(None),
5694 autocomplete: AutocompleteManager::new(),
5695 autocomplete_container: Arc::new(Container::new()),
5696 theme_manager: Arc::new(ThemeManager::new()),
5697 tui: None,
5698 current_model_id: std::sync::Mutex::new(String::new()),
5699 show_images: std::sync::Mutex::new(true),
5700 history: std::sync::Mutex::new(Vec::new()),
5701 history_index: std::sync::Mutex::new(-1),
5702 history_draft: std::sync::Mutex::new(None),
5703 last_input_tokens: std::sync::Mutex::new(0),
5704 scoped_edit: std::sync::Mutex::new(None),
5705 markdown_transformer: std::sync::Mutex::new(None),
5706 extension_session: Arc::new(std::sync::Mutex::new(
5707 rpi_extensions::ExtensionSession::none(),
5708 )),
5709 });
5710
5711 let assistant = AssistantMessage {
5715 role: rpi_ai::types::AssistantRole,
5716 content: vec![
5717 Content::Thinking(ThinkingContent {
5718 kind: ThinkingContentType,
5719 thinking: "Reasoning about the reply.".into(),
5720 thinking_signature: None,
5721 redacted: false,
5722 }),
5723 Content::Text(TextContent {
5724 kind: TextContentType,
5725 text: "Hello.".into(),
5726 text_signature: None,
5727 }),
5728 Content::ToolCall(ToolCall {
5729 kind: ToolCallType,
5730 id: "tc1".into(),
5731 name: "bash".into(),
5732 arguments: serde_json::json!({"command": "echo hi"}),
5733 thought_signature: None,
5734 namespace: None,
5735 }),
5736 ],
5737 api: rpi_ai::Api::AnthropicMessages,
5738 provider: "anthropic".into(),
5739 model: "claude-sonnet-5".into(),
5740 response_model: None,
5741 response_id: None,
5742 usage: Usage::zero(),
5743 stop_reason: StopReason::Stop,
5744 deferred: None,
5745 error_message: None,
5746 raw_stop_reason: None,
5747 end_turn: None,
5748 timestamp: 0,
5749 };
5750
5751 let comp = Arc::new(AssistantMessageComponent::new(
5754 AssistantMessageOptions::default(),
5755 ));
5756 comp.set_streaming(true);
5757 comp.update_blocks(&assistant_blocks(&assistant));
5758 let chat = Arc::new(Container::new());
5759 chat.add_child(comp.clone());
5760 *state.current_assistant.lock().unwrap() = Some(comp);
5761
5762 for c in &assistant.content {
5764 if let Content::ToolCall(tc) = c {
5765 let mut tools = state.tool_components.lock().unwrap();
5766 if !tools.contains_key(&tc.id) {
5767 let tc_comp = Arc::new(ToolExecutionComponent::new(
5768 &tc.name,
5769 &tc.arguments.to_string(),
5770 ));
5771 tc_comp.set_running();
5772 chat.add_child(tc_comp.clone());
5773 tools.insert(tc.id.clone(), tc_comp);
5774 }
5775 }
5776 }
5777
5778 let rendered = chat.render(80);
5782 let joined: String = rendered.join("\n");
5783 assert!(
5784 joined.contains("Hello."),
5785 "assistant text not rendered: {joined}"
5786 );
5787 assert!(
5788 joined.contains("Reasoning about the reply."),
5789 "thinking block not rendered: {joined}"
5790 );
5791 assert_eq!(state.tool_components.lock().unwrap().len(), 1);
5792 assert!(state.current_assistant.lock().unwrap().is_some());
5793
5794 let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
5796 ended.set_result("hi", false);
5797 assert!(state.tool_components.lock().unwrap().is_empty());
5798
5799 assert!(state.try_start_working());
5803 assert!(
5804 !state.try_start_working(),
5805 "a second submit must be rejected"
5806 );
5807 state.set_status(RunStatus::Idle);
5808 state.set_status(RunStatus::Working);
5809 assert_eq!(state.status_container.child_count(), 1);
5810 {
5811 let mut bash = state.bash_components.lock().unwrap();
5812 bash.insert(
5813 "bash-1".into(),
5814 Arc::new(BashExecutionComponent::new("one")),
5815 );
5816 bash.insert(
5817 "bash-2".into(),
5818 Arc::new(BashExecutionComponent::new("two")),
5819 );
5820 }
5821 state.sync_working_loader_with_bash();
5822 assert_eq!(state.status_container.child_count(), 0);
5823 state.bash_components.lock().unwrap().remove("bash-1");
5824 state.sync_working_loader_with_bash();
5825 assert_eq!(state.status_container.child_count(), 0);
5826 state.bash_components.lock().unwrap().remove("bash-2");
5827 state.sync_working_loader_with_bash();
5828 assert_eq!(state.status_container.child_count(), 1);
5829
5830 state.set_status(RunStatus::Aborting);
5831 assert_eq!(state.status_container.child_count(), 0);
5832 assert!(!state.loader.is_running());
5833 }
5834
5835 #[test]
5836 fn fresh_launch_does_not_restore_old_history() {
5837 let fresh = Args::default();
5838 assert!(!launch_restores_history(&fresh));
5839
5840 let continued = Args {
5841 continue_session: true,
5842 ..Args::default()
5843 };
5844 assert!(launch_restores_history(&continued));
5845
5846 let selected = Args {
5847 session: Some("session-id".into()),
5848 ..Args::default()
5849 };
5850 assert!(launch_restores_history(&selected));
5851 }
5852
5853 #[test]
5854 fn test_short_model_name() {
5855 assert_eq!(
5856 short_model_name("anthropic:claude-sonnet-5"),
5857 "claude-sonnet-5"
5858 );
5859 assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
5860 }
5861
5862 #[test]
5863 fn test_cycle_next_model_wraps_around() {
5864 use rpi_ai::{Api, Model};
5865 let mk = |id: &str| {
5866 Model::new(
5867 id,
5868 id,
5869 Api::AnthropicMessages,
5870 "anthropic",
5871 "https://api.anthropic.com",
5872 )
5873 };
5874 let catalog = [mk("a"), mk("b"), mk("c")];
5875 assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
5877 assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
5878 assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
5880 let empty: Vec<Model> = vec![];
5882 assert!(cycle_next_model(&empty, "a").is_none());
5883 }
5884
5885 #[test]
5886 fn test_autocomplete_slash_suggestions_render() {
5887 let state = Arc::new(TuiState {
5890 current_assistant: std::sync::Mutex::new(None),
5891 tool_components: std::sync::Mutex::new(HashMap::new()),
5892 bash_components: std::sync::Mutex::new(HashMap::new()),
5893 last_tool_comp: std::sync::Mutex::new(None),
5894 status: std::sync::Mutex::new(RunStatus::Idle),
5895 footer: Arc::new(FooterComponent::new()),
5896 status_container: Arc::new(Container::new()),
5897 chat_container: Arc::new(Container::new()),
5898 loader: Arc::new(Loader::new()),
5899 last_assistant_text: std::sync::Mutex::new(String::new()),
5900 active_selector: std::sync::Mutex::new(None),
5901 active_extension_editor: std::sync::Mutex::new(None),
5902 autocomplete: AutocompleteManager::new(),
5903 autocomplete_container: Arc::new(Container::new()),
5904 theme_manager: Arc::new(ThemeManager::new()),
5905 tui: None,
5906 current_model_id: std::sync::Mutex::new(String::new()),
5907 show_images: std::sync::Mutex::new(true),
5908 history: std::sync::Mutex::new(Vec::new()),
5909 history_index: std::sync::Mutex::new(-1),
5910 history_draft: std::sync::Mutex::new(None),
5911 last_input_tokens: std::sync::Mutex::new(0),
5912 scoped_edit: std::sync::Mutex::new(None),
5913 markdown_transformer: std::sync::Mutex::new(None),
5914 extension_session: Arc::new(std::sync::Mutex::new(
5915 rpi_extensions::ExtensionSession::none(),
5916 )),
5917 });
5918 {
5919 let mut combined = CombinedAutocompleteProvider::new();
5920 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5921 build_builtin_registry().visible_entries(),
5922 )));
5923 state.autocomplete.set_provider(Arc::new(combined));
5924 }
5925
5926 let editor = Arc::new(Editor::simple());
5927 editor.set_text("/he");
5928 editor.set_cursor(0, 3);
5929 refresh_autocomplete(&state, &editor);
5930 let lines = state.autocomplete_container.render(80);
5931 let joined: String = lines.join("\n");
5932 assert!(
5933 joined.contains("/help"),
5934 "slash suggestions not rendered: {joined}"
5935 );
5936
5937 editor.set_text("hello");
5939 editor.set_cursor(0, 5);
5940 refresh_autocomplete(&state, &editor);
5941 assert!(state.autocomplete_container.render(80).is_empty());
5942 }
5943
5944 #[test]
5945 fn test_select_list_swap_restores_editor() {
5946 let state = Arc::new(TuiState {
5950 current_assistant: std::sync::Mutex::new(None),
5951 tool_components: std::sync::Mutex::new(HashMap::new()),
5952 bash_components: std::sync::Mutex::new(HashMap::new()),
5953 last_tool_comp: std::sync::Mutex::new(None),
5954 status: std::sync::Mutex::new(RunStatus::Idle),
5955 footer: Arc::new(FooterComponent::new()),
5956 status_container: Arc::new(Container::new()),
5957 chat_container: Arc::new(Container::new()),
5958 loader: Arc::new(Loader::new()),
5959 last_assistant_text: std::sync::Mutex::new(String::new()),
5960 active_selector: std::sync::Mutex::new(None),
5961 active_extension_editor: std::sync::Mutex::new(None),
5962 autocomplete: AutocompleteManager::new(),
5963 autocomplete_container: Arc::new(Container::new()),
5964 theme_manager: Arc::new(ThemeManager::new()),
5965 tui: None,
5966 current_model_id: std::sync::Mutex::new(String::new()),
5967 show_images: std::sync::Mutex::new(true),
5968 history: std::sync::Mutex::new(Vec::new()),
5969 history_index: std::sync::Mutex::new(-1),
5970 history_draft: std::sync::Mutex::new(None),
5971 last_input_tokens: std::sync::Mutex::new(0),
5972 scoped_edit: std::sync::Mutex::new(None),
5973 markdown_transformer: std::sync::Mutex::new(None),
5974 extension_session: Arc::new(std::sync::Mutex::new(
5975 rpi_extensions::ExtensionSession::none(),
5976 )),
5977 });
5978 let editor_container = Arc::new(Container::new());
5979 let editor = Arc::new(Editor::simple());
5980 editor_container.add_child(editor.clone());
5981 assert!(!state.selector_open());
5982
5983 let tui_terminal = Box::new(ProcessTerminal::new());
5984 let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
5985 let list = Arc::new(SelectList::new(
5986 vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
5987 5,
5988 ));
5989 open_selector(
5990 &state,
5991 &editor_container,
5992 &editor,
5993 &tui,
5994 list,
5995 SelectorKind::Theme,
5996 );
5997 assert!(state.selector_open());
5998 assert_eq!(editor_container.child_count(), 1);
6000
6001 close_selector(&state, &editor_container, &editor, &tui);
6002 assert!(!state.selector_open());
6003 assert_eq!(editor_container.child_count(), 1);
6005 }
6006
6007 #[test]
6008 fn test_message_history_browse_restores_draft() {
6009 let state = Arc::new(TuiState {
6013 current_assistant: std::sync::Mutex::new(None),
6014 tool_components: std::sync::Mutex::new(HashMap::new()),
6015 bash_components: std::sync::Mutex::new(HashMap::new()),
6016 last_tool_comp: std::sync::Mutex::new(None),
6017 status: std::sync::Mutex::new(RunStatus::Idle),
6018 footer: Arc::new(FooterComponent::new()),
6019 status_container: Arc::new(Container::new()),
6020 chat_container: Arc::new(Container::new()),
6021 loader: Arc::new(Loader::new()),
6022 last_assistant_text: std::sync::Mutex::new(String::new()),
6023 active_selector: std::sync::Mutex::new(None),
6024 active_extension_editor: std::sync::Mutex::new(None),
6025 autocomplete: AutocompleteManager::new(),
6026 autocomplete_container: Arc::new(Container::new()),
6027 theme_manager: Arc::new(ThemeManager::new()),
6028 tui: None,
6029 current_model_id: std::sync::Mutex::new(String::new()),
6030 show_images: std::sync::Mutex::new(true),
6031 history: std::sync::Mutex::new(Vec::new()),
6032 history_index: std::sync::Mutex::new(-1),
6033 history_draft: std::sync::Mutex::new(None),
6034 last_input_tokens: std::sync::Mutex::new(0),
6035 scoped_edit: std::sync::Mutex::new(None),
6036 markdown_transformer: std::sync::Mutex::new(None),
6037 extension_session: Arc::new(std::sync::Mutex::new(
6038 rpi_extensions::ExtensionSession::none(),
6039 )),
6040 });
6041 let editor = Arc::new(Editor::simple());
6042
6043 push_history(&state, "first message");
6044 push_history(&state, "second message");
6045 push_history(&state, "second message");
6047 push_history(&state, " "); assert_eq!(state.history.lock().unwrap().len(), 2);
6049 assert_eq!(state.history.lock().unwrap()[0], "second message");
6050
6051 editor.set_text("half-typed");
6053 editor.set_cursor(0, 11);
6054
6055 navigate_history(&state, &editor, -1);
6057 assert_eq!(editor.get_text(), "second message");
6058 assert_eq!(*state.history_index.lock().unwrap(), 0);
6059 navigate_history(&state, &editor, -1);
6061 assert_eq!(editor.get_text(), "first message");
6062 assert_eq!(*state.history_index.lock().unwrap(), 1);
6063 navigate_history(&state, &editor, -1);
6065 assert_eq!(editor.get_text(), "first message");
6066 navigate_history(&state, &editor, 1);
6068 assert_eq!(editor.get_text(), "second message");
6069 navigate_history(&state, &editor, 1);
6071 assert_eq!(editor.get_text(), "half-typed");
6072 assert_eq!(*state.history_index.lock().unwrap(), -1);
6073 }
6074
6075 #[test]
6076 fn test_accept_top_suggestion_replaces_prefix() {
6077 let state = Arc::new(TuiState {
6079 current_assistant: std::sync::Mutex::new(None),
6080 tool_components: std::sync::Mutex::new(HashMap::new()),
6081 bash_components: std::sync::Mutex::new(HashMap::new()),
6082 last_tool_comp: std::sync::Mutex::new(None),
6083 status: std::sync::Mutex::new(RunStatus::Idle),
6084 footer: Arc::new(FooterComponent::new()),
6085 status_container: Arc::new(Container::new()),
6086 chat_container: Arc::new(Container::new()),
6087 loader: Arc::new(Loader::new()),
6088 last_assistant_text: std::sync::Mutex::new(String::new()),
6089 active_selector: std::sync::Mutex::new(None),
6090 active_extension_editor: std::sync::Mutex::new(None),
6091 autocomplete: AutocompleteManager::new(),
6092 autocomplete_container: Arc::new(Container::new()),
6093 theme_manager: Arc::new(ThemeManager::new()),
6094 tui: None,
6095 current_model_id: std::sync::Mutex::new(String::new()),
6096 show_images: std::sync::Mutex::new(true),
6097 history: std::sync::Mutex::new(Vec::new()),
6098 history_index: std::sync::Mutex::new(-1),
6099 history_draft: std::sync::Mutex::new(None),
6100 last_input_tokens: std::sync::Mutex::new(0),
6101 scoped_edit: std::sync::Mutex::new(None),
6102 markdown_transformer: std::sync::Mutex::new(None),
6103 extension_session: Arc::new(std::sync::Mutex::new(
6104 rpi_extensions::ExtensionSession::none(),
6105 )),
6106 });
6107 {
6108 let mut combined = CombinedAutocompleteProvider::new();
6109 combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
6110 build_builtin_registry().visible_entries(),
6111 )));
6112 state.autocomplete.set_provider(Arc::new(combined));
6113 }
6114 let editor = Arc::new(Editor::simple());
6115 editor.set_text("/he");
6116 editor.set_cursor(0, 3);
6117 refresh_autocomplete(&state, &editor);
6118 let accepted = accept_top_suggestion(&state, &editor);
6119 assert!(accepted, "should accept the top suggestion");
6120 let text = editor.get_text();
6121 assert!(
6122 text.starts_with("/help"),
6123 "editor text should start with /help, got {text}"
6124 );
6125 }
6126}