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