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