1use oxicode_vtui::tui::core::{
16 InlineHandle, InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
17};
18
19use crate::app::agent_session::AgentSessionHandle;
20use crate::store::settings::Settings;
21use crate::tui_vt::main_loop::{RenderState, plain_segment};
22use crate::tui_vt::settings_defs::{SettingWidget, SettingsTab, defs_for_tab, get_display_value};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(crate) enum SlashOutcome {
27 Handled,
29 Quit,
31 NotHandled,
33}
34
35pub(crate) struct SlashCtx<'a> {
38 pub session: &'a AgentSessionHandle,
39 pub handle: &'a InlineHandle,
40 pub state: &'a mut RenderState,
41}
42
43impl SlashCtx<'_> {
44 pub(crate) fn reply(&self, kind: InlineMessageKind, text: impl Into<String>) {
49 let text = text.into();
50 for line in text.split('\n') {
51 self.handle
52 .append_line(kind, vec![plain_segment(line.to_string())]);
53 }
54 }
55}
56
57pub(crate) trait SlashCommand: Send + Sync {
63 fn name(&self) -> &'static str;
65 fn aliases(&self) -> &'static [&'static str] {
67 &[]
68 }
69 fn description(&self) -> &'static str;
71 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome;
73
74 fn matches(&self, token: &str) -> bool {
76 token.eq_ignore_ascii_case(self.name())
77 || self.aliases().iter().any(|a| token.eq_ignore_ascii_case(a))
78 }
79}
80
81pub struct SlashRegistry {
83 builtins: Vec<Box<dyn SlashCommand>>,
84}
85
86impl SlashRegistry {
87 pub fn builtins() -> Self {
89 let mut registry = SlashRegistry {
90 builtins: Vec::new(),
91 };
92 register_all(&mut registry);
93 registry
94 }
95
96 pub(crate) fn register(&mut self, cmd: Box<dyn SlashCommand>) {
98 self.builtins.push(cmd);
99 }
100
101 pub fn builtin_commands() -> Vec<(&'static str, &'static str, Vec<&'static str>)> {
105 Self::builtins()
106 .builtins
107 .iter()
108 .map(|c| (c.name(), c.description(), c.aliases().to_vec()))
109 .collect()
110 }
111
112 pub(crate) fn dispatch(&self, input: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
116 let trimmed = input.trim();
117 let (cmd_token, arg) = match trimmed.find(' ') {
118 Some(space) => (&trimmed[..space], trimmed[space + 1..].trim()),
119 None => (trimmed, ""),
120 };
121 let token = cmd_token.strip_prefix('/').unwrap_or(cmd_token);
122
123 if matches!(token, "help" | "?" | "commands") {
124 self.render_help(ctx);
125 return SlashOutcome::Handled;
126 }
127
128 for command in &self.builtins {
129 if command.matches(token) {
130 return command.execute(arg, ctx);
131 }
132 }
133 SlashOutcome::NotHandled
134 }
135
136 fn render_help(&self, ctx: &mut SlashCtx<'_>) {
137 let mut items: Vec<InlineListItem> = self
138 .builtins
139 .iter()
140 .map(|c| {
141 let mut title = format!("/{}", c.name());
142 for alias in c.aliases() {
143 title.push_str(&format!(", /{alias}"));
144 }
145 InlineListItem {
146 title,
147 subtitle: Some(c.description().to_string()),
148 badge: None,
149 indent: 0,
150 selection: Some(InlineListSelection::SlashCommand(c.name().to_string())),
151 search_value: None,
152 }
153 })
154 .collect();
155 items.sort_by(|a, b| a.title.cmp(&b.title));
156 ctx.handle.show_list_modal(
157 "Commands".to_string(),
158 vec!["Browse commands and insert one into the composer.".to_string()],
159 items,
160 None,
161 None,
162 );
163 }
164}
165
166pub(crate) fn settings_overlay_items(
188 tab: SettingsTab,
189 settings: &Settings,
190) -> (
191 Vec<InlineListItem>,
192 Vec<Option<crate::tui_vt::settings_defs::SettingsMapRow>>,
193) {
194 use crate::tui_vt::settings_defs::{SettingKey, SettingsMapRow};
195 let mut items: Vec<InlineListItem> = Vec::new();
196 let mut rows: Vec<Option<SettingsMapRow>> = Vec::new();
197 let mut last_group: Option<&'static str> = None;
198 for def in defs_for_tab(tab, settings) {
199 if last_group != Some(def.group) {
200 items.push(InlineListItem {
201 title: def.group.to_string(),
202 subtitle: None,
203 badge: None,
204 indent: 0,
205 selection: None,
206 search_value: None,
207 });
208 rows.push(None);
209 last_group = Some(def.group);
210 }
211 match (def.widget, def.key) {
214 (SettingWidget::MapEditor, SettingKey::Keybindings) => {
215 expand_keybinding_rows(settings, &mut items, &mut rows);
216 continue;
217 }
218 (SettingWidget::MapEditor, SettingKey::ModelRoles) => {
219 expand_model_role_rows(settings, &mut items, &mut rows);
220 continue;
221 }
222 _ => {}
223 }
224 let selection = match def.widget {
225 SettingWidget::Toggle | SettingWidget::Cycle => {
228 Some(InlineListSelection::ConfigAction(format!("{:?}", def.key)))
229 }
230 SettingWidget::Text => Some(InlineListSelection::SettingTextEdit(format!(
235 "{:?}",
236 def.key
237 ))),
238 SettingWidget::SubmenuSelect(_) => Some(InlineListSelection::SettingSubmenuOpen(
239 format!("{:?}", def.key),
240 )),
241 SettingWidget::Multiselect => Some(InlineListSelection::SettingMultiselect(format!(
242 "{:?}",
243 def.key
244 ))),
245 SettingWidget::MapEditor | SettingWidget::Pointer => None,
250 };
251 items.push(InlineListItem {
252 title: def.label.to_string(),
253 subtitle: Some(def.description.to_string()),
254 badge: Some(get_display_value(def.key, settings)),
255 indent: 0,
256 selection,
257 search_value: Some(format!("{} {} {:?}", def.label, def.description, def.key)),
258 });
259 rows.push(None);
260 }
261 (items, rows)
262}
263
264fn expand_keybinding_rows(
271 settings: &Settings,
272 items: &mut Vec<InlineListItem>,
273 rows: &mut Vec<Option<crate::tui_vt::settings_defs::SettingsMapRow>>,
274) {
275 use crate::tui_vt::settings_defs::SettingsMapRow;
276 let keymap = crate::tui_vt::keymap::Keymap::from_settings(&settings.keybindings);
277 for action in crate::tui_vt::keymap::GlobalAction::all() {
278 let name = action.name();
279 items.push(InlineListItem {
280 title: name.to_string(),
281 subtitle: Some("Enter: capture a new combo".into()),
282 badge: None,
283 indent: 0,
284 selection: Some(InlineListSelection::SettingKeyCapture(name.to_string())),
285 search_value: Some(name.to_string()),
286 });
287 rows.push(Some(SettingsMapRow::KeybindingAction(action)));
288 for combo in keymap.action_combos(action) {
289 let combo = combo.to_string();
290 items.push(InlineListItem {
291 title: combo.clone(),
292 subtitle: Some("d: remove".into()),
293 badge: None,
294 indent: 1,
295 selection: None,
296 search_value: Some(format!("{name} {combo}")),
297 });
298 rows.push(Some(SettingsMapRow::KeybindingCombo(action, combo)));
299 }
300 }
301}
302
303fn expand_model_role_rows(
308 settings: &Settings,
309 items: &mut Vec<InlineListItem>,
310 rows: &mut Vec<Option<crate::tui_vt::settings_defs::SettingsMapRow>>,
311) {
312 use crate::tui_vt::settings_defs::SettingsMapRow;
313 if settings.model_roles.is_empty() {
314 items.push(InlineListItem {
315 title: "(no model roles)".into(),
316 subtitle: Some("n: add a role".into()),
317 badge: None,
318 indent: 1,
319 selection: None,
320 search_value: Some("model roles".into()),
321 });
322 rows.push(None);
323 return;
324 }
325 let mut roles: Vec<(&String, &String)> = settings.model_roles.iter().collect();
326 roles.sort();
327 for (role, model) in roles {
328 items.push(InlineListItem {
329 title: role.clone(),
330 subtitle: Some("Enter: edit \u{b7} d: delete".into()),
331 badge: Some(model.clone()),
332 indent: 0,
333 selection: None,
334 search_value: Some(format!("{role} {model}")),
335 });
336 rows.push(Some(SettingsMapRow::ModelRole(role.clone())));
337 }
338}
339
340struct SettingsCommand;
346
347impl SlashCommand for SettingsCommand {
348 fn name(&self) -> &'static str {
349 "settings"
350 }
351 fn aliases(&self) -> &'static [&'static str] {
352 &["config"]
353 }
354 fn description(&self) -> &'static str {
355 "Open the settings panel"
356 }
357 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
358 let settings = Settings::load().unwrap_or_default();
359 let (items, _rows) = settings_overlay_items(SettingsTab::General, &settings);
364
365 let search = InlineListSearchConfig {
366 label: "Filter settings".into(),
367 placeholder: Some("Type to filter".into()),
368 };
369 ctx.handle.show_list_modal(
370 "Settings".into(),
371 vec!["Browse settings by group; filter with the search bar.".into()],
372 items,
373 None,
374 Some(search),
375 );
376 SlashOutcome::Handled
377 }
378}
379
380struct SessionsCommand;
383
384pub(crate) fn sessions_dir() -> std::path::PathBuf {
388 oxicode_catalog::oxi_home::read_path(std::path::Path::new("sessions"))
389 .unwrap_or_else(|| std::path::PathBuf::from(".oxicode/sessions"))
390}
391
392impl SessionsCommand {
393 fn open_picker(&self, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
394 use oxicode_vtui::tui::core::{InlineListItem, InlineListSearchConfig};
395
396 let session_dir = sessions_dir();
397 let mut entries: Vec<(String, std::time::SystemTime)> = Vec::new();
398 if let Ok(dir) = std::fs::read_dir(session_dir) {
399 for entry in dir.flatten() {
400 let path = entry.path();
401 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
402 let id = path
403 .file_stem()
404 .map(|s| s.to_string_lossy().to_string())
405 .unwrap_or_default();
406 let mtime = entry
407 .metadata()
408 .ok()
409 .and_then(|m| m.modified().ok())
410 .unwrap_or(std::time::UNIX_EPOCH);
411 entries.push((id, mtime));
412 }
413 }
414 }
415 entries.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
416 entries.truncate(30);
417
418 if entries.is_empty() {
419 ctx.reply(InlineMessageKind::Info, "No saved sessions found.");
420 return SlashOutcome::Handled;
421 }
422
423 let items: Vec<InlineListItem> = entries
424 .iter()
425 .map(|(id, mtime)| {
426 let time_str = format_relative_time(*mtime);
427 InlineListItem {
428 title: format!("{id} \u{00b7} {time_str}"),
429 subtitle: Some("Enter to resume".into()),
430 badge: None,
431 indent: 0,
432 selection: Some(InlineListSelection::Session(id.clone())),
433 search_value: Some(id.clone()),
434 }
435 })
436 .collect();
437
438 let search = InlineListSearchConfig {
439 label: "Filter sessions".into(),
440 placeholder: Some("Type to filter\u{2026}".into()),
441 };
442 ctx.handle.show_list_modal(
443 "Sessions".into(),
444 vec!["Select a session to resume (Esc to close)".into()],
445 items,
446 None,
447 Some(search),
448 );
449 SlashOutcome::Handled
450 }
451}
452
453impl SlashCommand for SessionsCommand {
454 fn name(&self) -> &'static str {
455 "sessions"
456 }
457 fn aliases(&self) -> &'static [&'static str] {
458 &["resume"]
459 }
460 fn description(&self) -> &'static str {
461 "Browse and resume past sessions"
462 }
463 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
464 let arg = args.trim();
465 if arg.is_empty() {
466 return self.open_picker(ctx);
467 }
468
469 if ctx.session.is_streaming() {
470 ctx.reply(
471 InlineMessageKind::Error,
472 "Cannot resume while agent is running. Use /cancel first.",
473 );
474 return SlashOutcome::Handled;
475 }
476 let path = sessions_dir().join(format!("{arg}.jsonl"));
477 if !path.is_file() {
478 ctx.reply(
479 InlineMessageKind::Error,
480 format!("No session file: {}", path.display()),
481 );
482 return SlashOutcome::Handled;
483 }
484 ctx.state.pending_resume = Some(path);
485 ctx.reply(InlineMessageKind::Info, format!("Resuming {arg}…"));
486 SlashOutcome::Handled
487 }
488}
489fn format_relative_time(t: std::time::SystemTime) -> String {
491 let now = std::time::SystemTime::now();
492 match now.duration_since(t) {
493 Ok(d) => {
494 let mins = d.as_secs() / 60;
495 if mins < 1 {
496 "just now".into()
497 } else if mins < 60 {
498 format!("{mins}m ago")
499 } else if mins < 60 * 24 {
500 format!("{}h ago", mins / 60)
501 } else if mins < 60 * 24 * 7 {
502 format!("{}d ago", mins / (60 * 24))
503 } else {
504 format!("{}w ago", mins / (60 * 24 * 7))
505 }
506 }
507 Err(_) => "unknown".into(),
508 }
509}
510
511fn register_all(registry: &mut SlashRegistry) {
512 registry.register(Box::new(QuitCommand));
513 registry.register(Box::new(ClearCommand));
514 registry.register(Box::new(CompactCommand));
515 registry.register(Box::new(ModelCommand));
516 registry.register(Box::new(CancelCommand));
517 registry.register(Box::new(StatusCommand));
518 registry.register(Box::new(SettingsCommand));
519 registry.register(Box::new(VimCommand));
520 registry.register(Box::new(AgentsCommand));
521 registry.register(Box::new(ThemeCommand));
522 registry.register(Box::new(FindCommand));
523 registry.register(Box::new(SessionsCommand));
524 registry.register(Box::new(ShortcutsCommand));
525 registry.register(Box::new(HandoffCommand));
526 registry.register(Box::new(MemoryCommand));
527 registry.register(Box::new(super::todo_command::TodoCommand));
528 super::commands::register_extra(registry);
529}
530
531struct VimCommand;
533
534impl SlashCommand for VimCommand {
535 fn name(&self) -> &'static str {
536 "vim"
537 }
538 fn description(&self) -> &'static str {
539 "Toggle vim mode for prompt editing"
540 }
541 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
542 let enabled = !ctx.state.vim_state.enabled();
543 ctx.state.vim_state.set_enabled(enabled);
544 ctx.reply(
545 InlineMessageKind::Info,
546 if enabled {
547 "Vim mode: ON — press Esc for Normal, i for Insert".to_string()
548 } else {
549 "Vim mode: OFF".to_string()
550 },
551 );
552 SlashOutcome::Handled
553 }
554}
555
556struct MemoryCommand;
559
560impl SlashCommand for MemoryCommand {
561 fn name(&self) -> &'static str {
562 "memory"
563 }
564 fn aliases(&self) -> &'static [&'static str] {
565 &["brain", "mem"]
566 }
567 fn description(&self) -> &'static str {
568 "Show oxibrain memory status (health, stats, recovery hints)"
569 }
570 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
571 let args = _args.to_string();
572 let handle = ctx.handle.clone();
573 ctx.reply(InlineMessageKind::Info, "Brain memory — querying daemon…");
577 tokio::spawn(async move {
578 fn append(handle: &oxicode_vtui::tui::core::InlineHandle, text: String) {
579 for line in text.split('\n') {
580 handle.append_line(
581 InlineMessageKind::Info,
582 vec![plain_segment(line.to_string())],
583 );
584 }
585 }
586
587 let socket = crate::foundation::brain::default_socket_path();
588 let backend = crate::foundation::brain::BrainMemoryBackend::new(socket.clone());
589 let enabled = crate::store::settings::Settings::load()
590 .map(|s| s.memory_enabled)
591 .unwrap_or(true);
592 let mut out = format!("socket: {}", socket.display());
593 out.push_str(if enabled {
594 "\ntools: enabled (memory_enabled)"
595 } else {
596 "\ntools: disabled (memory_enabled = false in settings)"
597 });
598 let restart = args.trim().eq_ignore_ascii_case("restart");
599 if restart {
600 match crate::foundation::brain_control::revive().await {
601 Ok(msg) => out.push_str(&format!("\nrestart: {msg}")),
602 Err(err) => out.push_str(&format!("\nrestart: {err}")),
603 }
604 }
605 match backend.ping().await {
606 Ok(()) => {
607 out.push_str("\nhealth: ok — oxibrain daemon connected");
608 if let Ok(stats) = backend.stats().await {
609 out.push_str(&format!(
610 "\nstats: episodes {} · entities {} · statements {} · contradictions {}",
611 stats.get("episodes").and_then(|v| v.as_i64()).unwrap_or(-1),
612 stats.get("entities").and_then(|v| v.as_i64()).unwrap_or(-1),
613 stats.get("statements").and_then(|v| v.as_i64()).unwrap_or(-1),
614 stats.get("contradictions").and_then(|v| v.as_i64()).unwrap_or(-1),
615 ));
616 }
617 out.push_str(
618 "\nhints: chat tools memory_retain / memory_recall / memory_reflect \
619 / memory_edit read+write this daemon",
620 );
621 }
622 Err(e) => {
623 out.push_str(&format!("\nhealth: degraded — {e}"));
624 match crate::foundation::brain_control::revive().await {
628 Ok(msg) => {
629 out.push_str(&format!("\nrevive: {msg}"));
630 out.push_str("\n the health chip refreshes within ~20s");
631 }
632 Err(err) => out.push_str(&format!("\nrevive: {err}")),
633 }
634 out.push_str(
635 "\nhints: set OXIBRAIN_SOCKET if the daemon lives elsewhere; \
636 no local fallback exists by design",
637 );
638 }
639 }
640 if crate::foundation::migrate::default_legacy_path().exists() {
641 out.push_str(
642 "\nlegacy: a legacy local store exists — run `oxicode migrate brain` \
643 to move it into the daemon",
644 );
645 }
646 append(&handle, out);
647 });
648 SlashOutcome::Handled
649 }
650}
651
652struct AgentsCommand;
654
655impl SlashCommand for AgentsCommand {
656 fn name(&self) -> &'static str {
657 "agents"
658 }
659 fn aliases(&self) -> &'static [&'static str] {
660 &["hub"]
661 }
662 fn description(&self) -> &'static str {
663 "Open the Agent Hub overlay (alias: /hub)"
664 }
665 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
666 ctx.state.agent_hub_open = true;
667 ctx.state.hub_entries = ctx.session.hub().snapshot();
668 SlashOutcome::Handled
669 }
670}
671
672struct QuitCommand;
678
679impl SlashCommand for QuitCommand {
680 fn name(&self) -> &'static str {
681 "quit"
682 }
683 fn aliases(&self) -> &'static [&'static str] {
684 &["exit", "q"]
685 }
686 fn description(&self) -> &'static str {
687 "Quit oxicode (aliases: /exit, /q)"
688 }
689 fn execute(&self, _args: &str, _ctx: &mut SlashCtx<'_>) -> SlashOutcome {
690 SlashOutcome::Quit
691 }
692}
693
694struct ClearCommand;
696
697impl SlashCommand for ClearCommand {
698 fn name(&self) -> &'static str {
699 "clear"
700 }
701 fn aliases(&self) -> &'static [&'static str] {
702 &["cls"]
703 }
704 fn description(&self) -> &'static str {
705 "Clear the conversation and transcript (alias: /cls)"
706 }
707 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
708 if !args.split_whitespace().any(|a| a == "--yes") {
711 ctx.state.confirmation = Some(super::super::main_loop::clear_confirmation());
712 return SlashOutcome::Handled;
713 }
714 ctx.session.reset();
715 ctx.state.transcript.clear();
716 ctx.state.message_buffer.clear();
717 ctx.state.scroll_offset = usize::MAX;
718 ctx.reply(InlineMessageKind::Info, "Conversation cleared.");
719 SlashOutcome::Handled
720 }
721}
722
723struct CompactCommand;
725
726impl SlashCommand for CompactCommand {
727 fn name(&self) -> &'static str {
728 "compact"
729 }
730 fn description(&self) -> &'static str {
731 "Compact the context (optional: /compact <instructions>)"
732 }
733 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
734 let instructions = args.trim();
735 let arg = if instructions.is_empty() {
736 None
737 } else {
738 Some(instructions.to_string())
739 };
740 let session = ctx.session.clone();
741 ctx.reply(InlineMessageKind::Info, "Compacting\u{2026}");
742 tokio::spawn(async move {
743 match session.compact(arg).await {
744 Ok(result) => tracing::info!(?result, "manual compaction complete"),
745 Err(err) => tracing::warn!(%err, "manual compaction failed"),
746 }
747 });
748 SlashOutcome::Handled
749 }
750}
751
752struct HandoffCommand;
760
761impl SlashCommand for HandoffCommand {
762 fn name(&self) -> &'static str {
763 "handoff"
764 }
765 fn aliases(&self) -> &'static [&'static str] {
766 &["hd"]
767 }
768 fn description(&self) -> &'static str {
769 "Generate a handoff doc and start a fresh session (alias: /hd)"
770 }
771 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
772 use crate::app::handoff::{HandoffOptions, generate_and_apply_handoff};
773
774 let mut auto_continue = true;
776 let mut dry_run = false;
777 let mut slug = None;
778 for arg in args.split_whitespace() {
779 match arg {
780 "--review" => auto_continue = false,
781 "--dry-run" => dry_run = true,
782 s if !s.starts_with('-') => slug = Some(s.to_string()),
783 _ => {}
784 }
785 }
786
787 if ctx.session.is_streaming() {
789 ctx.reply(
790 InlineMessageKind::Error,
791 "Cannot hand off while agent is running. Use /cancel first.",
792 );
793 return SlashOutcome::Handled;
794 }
795
796 let msg_count = ctx.session.messages().len();
798 if msg_count < 2 {
799 ctx.reply(
800 InlineMessageKind::Error,
801 "Not enough conversation to hand off (need at least 2 messages).",
802 );
803 return SlashOutcome::Handled;
804 }
805
806 let opts = HandoffOptions {
807 slug,
808 auto_continue,
809 dry_run,
810 };
811
812 let msg = if dry_run {
813 "Generating handoff document (dry run)\u{2026}"
814 } else if auto_continue {
815 "Generating handoff and starting new session\u{2026}"
816 } else {
817 "Generating handoff document\u{2026}"
818 };
819 ctx.reply(InlineMessageKind::Info, msg);
820
821 let handle = ctx.handle.clone();
824 handle.set_reasoning_stage(Some("Generating handoff\u{2026}".to_string()));
825 let session = ctx.session.clone();
826
827 tokio::spawn(async move {
828 let result = generate_and_apply_handoff(&session, &opts).await;
829 handle.set_reasoning_stage(None);
832 match result {
833 Ok(path) => tracing::info!(%path, "handoff complete"),
834 Err(err) => {
835 tracing::warn!(%err, "handoff failed");
836 session.emit_handoff_failed(err.to_string());
837 }
838 }
839 });
840
841 SlashOutcome::Handled
842 }
843}
844
845struct ModelCommand;
850
851impl SlashCommand for ModelCommand {
852 fn name(&self) -> &'static str {
853 "model"
854 }
855 fn description(&self) -> &'static str {
856 "Show or switch model (/model [<id>|next])"
857 }
858 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
859 match args.trim() {
860 "" => {
861 let Some(catalog) = ctx.state.catalog.as_ref() else {
862 ctx.reply(
865 InlineMessageKind::Info,
866 format!("Current model: {}", ctx.session.model_id()),
867 );
868 return SlashOutcome::Handled;
869 };
870
871 let auth = crate::store::auth_storage::shared_auth_storage();
872 let current = ctx.session.model_id();
873 let (cur_provider, cur_model_id) = super::commands::split_model_id(¤t);
874
875 let (rows, used_fallback) =
876 model_picker_rows(catalog, &auth, cur_provider, cur_model_id);
877
878 let keyed_provider_count = rows
879 .iter()
880 .filter(|e| auth.has(&e.provider))
881 .map(|e| e.provider.as_str())
882 .collect::<std::collections::BTreeSet<_>>()
883 .len();
884 let filter_label = if used_fallback {
885 "Showing full catalog — no providers with keys configured yet".to_string()
886 } else {
887 format!(
888 "Showing models from {keyed_provider_count} keyed provider{}",
889 if keyed_provider_count == 1 { "" } else { "s" },
890 )
891 };
892
893 ctx.state.overlay_model_ids = rows
894 .iter()
895 .map(|e| format!("{}/{}", e.provider, e.model_id))
896 .collect();
897
898 let items: Vec<InlineListItem> = rows
899 .iter()
900 .enumerate()
901 .map(|(i, e)| {
902 let id = format!("{}/{}", e.provider, e.model_id);
903 let mut sub = format!(
904 "{} \u{00b7} {} in / {} out",
905 super::commands::fmt_ctx(e.context_window),
906 super::commands::fmt_cost(e.cost_input),
907 super::commands::fmt_cost(e.cost_output),
908 );
909 if e.reasoning {
910 sub.push_str(" \u{00b7} reasoning");
911 }
912 if e.supports_vision {
913 sub.push_str(" \u{00b7} vision");
914 }
915 let badge = if id == current {
916 Some("active".to_string())
917 } else if used_fallback {
918 None
919 } else if !auth.has(&e.provider) {
920 Some("no-key".to_string())
921 } else {
922 None
923 };
924 InlineListItem {
925 title: id.clone(),
926 subtitle: Some(sub),
927 badge,
928 indent: 0,
929 selection: Some(InlineListSelection::Model(i)),
930 search_value: Some(
931 format!("{} {} {}", e.provider, e.model_id, e.name,),
932 ),
933 }
934 })
935 .collect();
936
937 let total = items.len();
938 let search = InlineListSearchConfig {
939 label: "Filter models".into(),
940 placeholder: Some("Type to filter (provider / model / name)\u{2026}".into()),
941 };
942 ctx.handle.show_list_modal(
943 format!("Models ({total})"),
944 vec![format!(
945 "{filter_label} \u{2014} Enter to switch, Esc to close"
946 )],
947 items,
948 None,
949 Some(search),
950 );
951 }
952 "next" | "cycle" => match ctx.session.cycle_model() {
953 Some(new_id) => {
954 crate::tui_vt::main_loop::sync_model_chips(ctx.state, ctx.session);
955 ctx.reply(InlineMessageKind::Info, format!("Switched to {new_id}"))
956 }
957 None => ctx.reply(
958 InlineMessageKind::Warning,
959 "No scoped models configured to cycle.",
960 ),
961 },
962 id => match ctx.session.set_model(id) {
963 Ok(()) => {
964 crate::tui_vt::main_loop::sync_model_chips(ctx.state, ctx.session);
965 ctx.reply(InlineMessageKind::Info, format!("Switched to {id}"))
966 }
967 Err(err) => ctx.reply(
968 InlineMessageKind::Error,
969 format!("Failed to set model {id}: {err}"),
970 ),
971 },
972 }
973 SlashOutcome::Handled
974 }
975}
976
977fn model_picker_rows(
991 catalog: &std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>,
992 auth: &std::sync::Arc<crate::store::auth_storage::AuthStorage>,
993 cur_provider: &str,
994 cur_model_id: &str,
995) -> (Vec<oxicode_sdk::CatalogModelEntry>, bool) {
996 let all = catalog.search_sync("");
997
998 let mut keyed: Vec<_> = all
1003 .iter()
1004 .filter(|e| auth.has(&e.provider))
1005 .cloned()
1006 .collect();
1007
1008 let current_entry = all
1009 .iter()
1010 .find(|e| e.provider == cur_provider && e.model_id == cur_model_id)
1011 .cloned();
1012
1013 let mut rows = Vec::with_capacity(keyed.len() + 1);
1016 if let Some(ce) = current_entry {
1017 keyed.retain(|e| !(e.provider == ce.provider && e.model_id == ce.model_id));
1018 rows.push(ce); }
1020 rows.append(&mut keyed);
1021
1022 if rows.is_empty() {
1023 (all, true)
1024 } else {
1025 (rows, false)
1026 }
1027}
1028struct CancelCommand;
1030
1031impl SlashCommand for CancelCommand {
1032 fn name(&self) -> &'static str {
1033 "cancel"
1034 }
1035 fn aliases(&self) -> &'static [&'static str] {
1036 &["stop"]
1037 }
1038 fn description(&self) -> &'static str {
1039 "Abort the current run (alias: /stop)"
1040 }
1041 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1042 let session = ctx.session.clone();
1043 tokio::spawn(async move {
1044 session.abort().await;
1045 });
1046 SlashOutcome::Handled
1047 }
1048}
1049
1050struct StatusCommand;
1052
1053impl SlashCommand for StatusCommand {
1054 fn name(&self) -> &'static str {
1055 "status"
1056 }
1057 fn description(&self) -> &'static str {
1058 "Show model and session stats"
1059 }
1060 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1061 let stats = ctx.session.session_stats();
1062 let model = ctx.session.model_id();
1063 let (provider, model_part) = super::commands::split_model_id(&model);
1064 let auth = crate::store::auth_storage::shared_auth_storage();
1065 let key = if auth.has(provider) { "set" } else { "missing" };
1066 let ctx_win = ctx
1067 .state
1068 .catalog
1069 .as_ref()
1070 .and_then(|c| c.get_model_sync(provider, model_part))
1071 .map(|e| super::commands::fmt_ctx(e.context_window))
1072 .unwrap_or_else(|| "?".to_string());
1073 let compaction = if ctx.session.auto_compaction_enabled() {
1074 "on"
1075 } else {
1076 "off"
1077 };
1078 let advisor = if ctx.session.is_advisor_enabled() {
1079 "on"
1080 } else {
1081 "off"
1082 };
1083 let thinking = ctx.session.thinking_level();
1084 ctx.reply(
1085 InlineMessageKind::Info,
1086 format!(
1087 "Model: {model} (key: {key}, {ctx_win})\n\
1088 Provider: {provider} \u{00b7} Thinking: {thinking:?}\n\
1089 Compaction: {compaction} \u{00b7} Advisor: {advisor}\n\
1090 Messages: {} user / {} assistant\n\
1091 Tool calls: {} (results: {})\n\
1092 Total: {}",
1093 stats.user_messages,
1094 stats.assistant_messages,
1095 stats.tool_calls,
1096 stats.tool_results,
1097 stats.total_messages,
1098 ),
1099 );
1100 SlashOutcome::Handled
1101 }
1102}
1103
1104struct ThemeCommand;
1109
1110impl SlashCommand for ThemeCommand {
1111 fn name(&self) -> &'static str {
1112 "theme"
1113 }
1114 fn aliases(&self) -> &'static [&'static str] {
1115 &["t"]
1116 }
1117 fn description(&self) -> &'static str {
1118 "Cycle or pick a color theme (/theme [name|list])"
1119 }
1120 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1121 use oxicode_vtui::theme::{
1122 active_theme_id, available_themes, set_active_theme, theme_label,
1123 };
1124 match args.trim() {
1125 "" | "next" | "cycle" => {
1126 let themes = available_themes();
1127 if themes.len() <= 1 {
1128 ctx.reply(InlineMessageKind::Info, "Only one theme available.");
1129 } else {
1130 let current = active_theme_id();
1131 let pos = themes.iter().position(|t| *t == current).unwrap_or(0);
1132 let next_id = &themes[(pos + 1) % themes.len()];
1133 match set_active_theme(next_id) {
1134 Ok(()) => {
1135 let label = theme_label(next_id).unwrap_or(next_id.as_ref());
1136 ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
1137 }
1138 Err(e) => ctx.reply(
1139 InlineMessageKind::Error,
1140 format!("Failed to set theme: {e}"),
1141 ),
1142 }
1143 }
1144 }
1145 "list" | "picker" => {
1146 let themes = available_themes();
1147 let current = active_theme_id();
1148 let items: Vec<InlineListItem> = themes
1149 .iter()
1150 .map(|id| InlineListItem {
1151 title: theme_label(id).unwrap_or(id.as_ref()).to_string(),
1152 subtitle: Some(id.to_string()),
1153 badge: if *id == current {
1154 Some("active".to_string())
1155 } else {
1156 None
1157 },
1158 indent: 0,
1159 selection: Some(InlineListSelection::Theme(id.to_string())),
1160 search_value: Some(id.to_string()),
1161 })
1162 .collect();
1163 ctx.handle.show_list_modal(
1164 "Themes".to_string(),
1165 vec!["Select a theme (Esc to close, Enter to apply)".to_string()],
1166 items,
1167 None,
1168 None,
1169 );
1170 }
1171 name => match set_active_theme(name) {
1172 Ok(()) => {
1173 let label = theme_label(name).unwrap_or(name);
1174 ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
1175 }
1176 Err(e) => ctx.reply(
1177 InlineMessageKind::Error,
1178 format!("Unknown theme '{name}': {e}"),
1179 ),
1180 },
1181 }
1182 SlashOutcome::Handled
1183 }
1184}
1185
1186struct FindCommand;
1190
1191impl SlashCommand for FindCommand {
1192 fn name(&self) -> &'static str {
1193 "find"
1194 }
1195 fn aliases(&self) -> &'static [&'static str] {
1196 &["search", "/"]
1197 }
1198 fn description(&self) -> &'static str {
1199 "Search transcript (/find <query>, n/N to navigate)"
1200 }
1201 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1202 let query = args.trim();
1203 if query.is_empty() {
1204 ctx.state.search = None;
1205 ctx.reply(InlineMessageKind::Info, "Search cleared.");
1206 } else {
1207 ctx.state.start_search(query);
1208 let count = ctx
1209 .state
1210 .search
1211 .as_ref()
1212 .map(|s| s.matches.len())
1213 .unwrap_or(0);
1214 if count == 0 {
1215 ctx.reply(
1216 InlineMessageKind::Warning,
1217 format!("No matches for '{query}'."),
1218 );
1219 } else {
1220 ctx.reply(
1221 InlineMessageKind::Info,
1222 format!(
1223 "{count} match{} for '{query}'",
1224 if count == 1 { "" } else { "es" }
1225 ),
1226 );
1227 }
1228 }
1229 SlashOutcome::Handled
1230 }
1231}
1232
1233struct ShortcutsCommand;
1235
1236impl SlashCommand for ShortcutsCommand {
1237 fn name(&self) -> &'static str {
1238 "shortcuts"
1239 }
1240 fn aliases(&self) -> &'static [&'static str] {
1241 &["keys", "cheatsheet"]
1242 }
1243 fn description(&self) -> &'static str {
1244 "Show keyboard shortcuts (alias: ?)"
1245 }
1246 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1247 ctx.handle
1248 .show_modal("Keyboard Shortcuts".to_string(), shortcuts_lines(), None);
1249 SlashOutcome::Handled
1250 }
1251}
1252
1253fn shortcuts_lines() -> Vec<String> {
1255 vec![
1256 "".into(),
1257 " Navigation".into(),
1258 " j / ↓ Scroll down (line)".into(),
1259 " k / ↑ Scroll up (line)".into(),
1260 " Shift+J Next assistant turn".into(),
1261 " Shift+K Previous user turn".into(),
1262 " PgDn Scroll down (page)".into(),
1263 " PgUp Scroll up (page)".into(),
1264 " G Jump to bottom (follow)".into(),
1265 " g Jump to top".into(),
1266 "".into(),
1267 " Blocks".into(),
1268 " e Cycle block (collapse/truncate/expand)".into(),
1269 " Shift+E Expand all blocks".into(),
1270 " Ctrl+E Collapse all blocks".into(),
1271 "".into(),
1272 " Search".into(),
1273 " /find <q> Search transcript".into(),
1274 " n Next match".into(),
1275 " N Previous match".into(),
1276 " Esc Clear search".into(),
1277 "".into(),
1278 " Input".into(),
1279 " Ctrl+M Toggle multiline input".into(),
1280 " Ctrl+P Command palette".into(),
1281 " Ctrl+Enter Send now (abort + submit)".into(),
1282 " Esc Cancel run / quit (y to confirm)".into(),
1283 "".into(),
1284 " Other".into(),
1285 " ? Show this cheatsheet".into(),
1286 " /theme Cycle color theme".into(),
1287 " /model Pick a model".into(),
1288 " /models Browse all models".into(),
1289 " /providers Manage API keys".into(),
1290 " /tools List tools".into(),
1291 " /mcp MCP status".into(),
1292 " /info Diagnostics".into(),
1293 " /export Save as HTML".into(),
1294 " /vim Toggle vim mode".into(),
1295 " Ctrl+C Cancel run (then y to quit)".into(),
1296 "".into(),
1297 ]
1298}
1299#[cfg(test)]
1304mod tests {
1305 use super::*;
1306 use oxicode_sdk::ports::catalog::{
1307 CatalogEvent, CatalogModelEntry, CatalogProtocol, CatalogSource, ModelCatalog,
1308 };
1309 use oxicode_sdk::{Model, Provider, ProviderError, ProviderEvent};
1310 use oxicode_vtui::tui::core::{InlineCommand, OverlayRequest};
1311 use std::pin::Pin;
1312 use std::task::{Context as TaskContext, Poll};
1313
1314 #[test]
1315 fn builtins_register_expected_commands() {
1316 let reg = SlashRegistry::builtins();
1317 let names: Vec<&str> = reg.builtins.iter().map(|c| c.name()).collect();
1318 assert!(names.contains(&"quit"));
1319 assert!(names.contains(&"clear"));
1320 assert!(names.contains(&"memory"));
1321 assert!(names.contains(&"model"));
1322 assert!(names.contains(&"cancel"));
1323 assert!(names.contains(&"status"));
1324 }
1325
1326 #[test]
1327 fn matches_resolves_aliases_case_insensitively() {
1328 let cmd = QuitCommand;
1329 assert!(cmd.matches("quit"));
1330 assert!(cmd.matches("EXIT"));
1331 assert!(cmd.matches("q"));
1332 assert!(!cmd.matches("quitter"));
1333 }
1334
1335 #[test]
1336 fn builtin_commands_exposes_aliases_for_rpc() {
1337 let catalog = SlashRegistry::builtin_commands();
1338 let quit = catalog
1339 .iter()
1340 .find(|(name, _, _)| *name == "quit")
1341 .expect("quit command present");
1342 assert!(quit.2.contains(&"exit"));
1343 assert!(quit.2.contains(&"q"));
1344 assert!(!quit.1.is_empty(), "quit has a description");
1345 }
1346
1347 #[test]
1348 fn handoff_command_metadata() {
1349 let reg = SlashRegistry::builtins();
1352 let names: Vec<&str> = reg.builtins.iter().map(|c| c.name()).collect();
1353 assert!(names.contains(&"handoff"), "handoff command registered");
1354
1355 let catalog = SlashRegistry::builtin_commands();
1356 let handoff = catalog
1357 .iter()
1358 .find(|(name, _, _)| *name == "handoff")
1359 .expect("handoff present in catalog");
1360 assert!(
1361 handoff.2.contains(&"hd"),
1362 "handoff exposes /hd alias: got {:?}",
1363 handoff.2
1364 );
1365 assert!(!handoff.1.is_empty(), "handoff has a description");
1366
1367 let cmd = HandoffCommand;
1368 assert!(cmd.matches("handoff"));
1369 assert!(cmd.matches("HD"));
1370 assert!(cmd.matches("Hd"));
1371 }
1372
1373 #[test]
1377 fn model_picker_filters_by_keyed_providers() {
1378 use crate::store::auth_storage::AuthStorage;
1379 use std::sync::Arc;
1380
1381 let entries = vec![
1384 CatalogModelEntry {
1385 provider: "anthropic".into(),
1386 model_id: "claude-sonnet".into(),
1387 name: "Claude Sonnet".into(),
1388 protocol: CatalogProtocol::AnthropicMessages,
1389 source: CatalogSource::Embedded,
1390 base_url: None,
1391 reasoning: false,
1392 supports_vision: true,
1393 cost_input: 3.0,
1394 cost_output: 15.0,
1395 cost_cache_read: 0.0,
1396 cost_cache_write: 0.0,
1397 context_window: 200_000,
1398 max_tokens: 8_192,
1399 input_modalities: vec!["text".into(), "image".into()],
1400 release_date: None,
1401 status: None,
1402 },
1403 CatalogModelEntry {
1404 provider: "anthropic".into(),
1405 model_id: "claude-opus".into(),
1406 name: "Claude Opus".into(),
1407 protocol: CatalogProtocol::AnthropicMessages,
1408 source: CatalogSource::Embedded,
1409 base_url: None,
1410 reasoning: false,
1411 supports_vision: true,
1412 cost_input: 15.0,
1413 cost_output: 75.0,
1414 cost_cache_read: 0.0,
1415 cost_cache_write: 0.0,
1416 context_window: 200_000,
1417 max_tokens: 8_192,
1418 input_modalities: vec!["text".into(), "image".into()],
1419 release_date: None,
1420 status: None,
1421 },
1422 CatalogModelEntry {
1423 provider: "google".into(),
1424 model_id: "gemini-2.5-pro".into(),
1425 name: "Gemini 2.5 Pro".into(),
1426 protocol: CatalogProtocol::OpenAiCompatible,
1427 source: CatalogSource::Embedded,
1428 base_url: None,
1429 reasoning: true,
1430 supports_vision: true,
1431 cost_input: 1.25,
1432 cost_output: 5.0,
1433 cost_cache_read: 0.0,
1434 cost_cache_write: 0.0,
1435 context_window: 1_000_000,
1436 max_tokens: 65_536,
1437 input_modalities: vec!["text".into(), "image".into()],
1438 release_date: None,
1439 status: None,
1440 },
1441 ];
1442 let catalog: Arc<dyn ModelCatalog> = Arc::new(StaticCatalog::new(entries));
1443
1444 let auth: Arc<AuthStorage> = Arc::new(AuthStorage::in_memory());
1450 auth.set_api_key("anthropic", "test-anthropic-key".to_string());
1451
1452 let (rows, used_fallback) =
1453 model_picker_rows(&catalog, &auth, "anthropic", "claude-sonnet");
1454
1455 assert!(
1456 !used_fallback,
1457 "keyed providers exist — no fallback expected"
1458 );
1459 assert_eq!(rows.len(), 2);
1462 assert_eq!(rows[0].provider, "anthropic");
1463 assert_eq!(rows[0].model_id, "claude-sonnet");
1464 assert_eq!(rows[1].model_id, "claude-opus");
1465 }
1466 #[test]
1470 fn model_picker_falls_back_when_unkeyed_and_no_active_match() {
1471 use crate::store::auth_storage::AuthStorage;
1472 use std::sync::Arc;
1473
1474 let entries = vec![CatalogModelEntry {
1475 provider: "openai".into(),
1476 model_id: "gpt-4o".into(),
1477 name: "GPT-4o".into(),
1478 protocol: CatalogProtocol::OpenAiCompatible,
1479 source: CatalogSource::Embedded,
1480 base_url: None,
1481 reasoning: false,
1482 supports_vision: true,
1483 cost_input: 2.5,
1484 cost_output: 10.0,
1485 cost_cache_read: 0.0,
1486 cost_cache_write: 0.0,
1487 context_window: 128_000,
1488 max_tokens: 16_384,
1489 input_modalities: vec!["text".into(), "image".into()],
1490 release_date: None,
1491 status: None,
1492 }];
1493 let catalog: Arc<dyn ModelCatalog> = Arc::new(StaticCatalog::new(entries));
1494
1495 let auth: Arc<AuthStorage> = Arc::new(AuthStorage::in_memory());
1497 let (rows, used_fallback) =
1500 model_picker_rows(&catalog, &auth, "anthropic", "claude-not-in-catalog");
1501
1502 assert!(
1503 used_fallback,
1504 "no keys, no active match — fallback expected"
1505 );
1506 assert_eq!(rows.len(), 1);
1508 assert_eq!(rows[0].model_id, "gpt-4o");
1509 }
1510
1511 struct StaticCatalog {
1516 entries: Vec<CatalogModelEntry>,
1517 tx: tokio::sync::broadcast::Sender<CatalogEvent>,
1518 }
1519
1520 impl StaticCatalog {
1521 fn new(entries: Vec<CatalogModelEntry>) -> Self {
1522 let (tx, _) = tokio::sync::broadcast::channel(16);
1523 Self { entries, tx }
1524 }
1525 }
1526
1527 impl std::fmt::Debug for StaticCatalog {
1528 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1529 f.debug_struct("StaticCatalog")
1530 .field("entries", &self.entries.len())
1531 .finish_non_exhaustive()
1532 }
1533 }
1534
1535 impl ModelCatalog for StaticCatalog {
1536 fn list_providers(
1537 &self,
1538 ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<Vec<String>>> + Send + '_>>
1539 {
1540 let mut providers: Vec<String> =
1541 self.entries.iter().map(|e| e.provider.clone()).collect();
1542 providers.sort();
1543 providers.dedup();
1544 Box::pin(async move { Ok(providers) })
1545 }
1546 fn get_provider(
1547 &self,
1548 _id: &str,
1549 ) -> Pin<
1550 Box<
1551 dyn Future<
1552 Output = oxicode_sdk::SdkResult<Option<oxicode_sdk::CatalogProviderEntry>>,
1553 > + Send
1554 + '_,
1555 >,
1556 > {
1557 Box::pin(async { Ok(None) })
1558 }
1559 fn list_models(
1560 &self,
1561 provider_id: &str,
1562 ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<Vec<CatalogModelEntry>>> + Send + '_>>
1563 {
1564 let v: Vec<_> = self
1565 .entries
1566 .iter()
1567 .filter(|e| e.provider == provider_id)
1568 .cloned()
1569 .collect();
1570 Box::pin(async move { Ok(v) })
1571 }
1572 fn get_model(
1573 &self,
1574 provider: &str,
1575 model_id: &str,
1576 ) -> Pin<
1577 Box<dyn Future<Output = oxicode_sdk::SdkResult<Option<CatalogModelEntry>>> + Send + '_>,
1578 > {
1579 let hit = self.entries.iter().find_map(|e| {
1580 if e.provider == provider && e.model_id == model_id {
1581 Some(e.clone())
1582 } else {
1583 None
1584 }
1585 });
1586 Box::pin(async move { Ok(hit) })
1587 }
1588 fn search(
1589 &self,
1590 _pattern: &str,
1591 ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<Vec<CatalogModelEntry>>> + Send + '_>>
1592 {
1593 let v = self.entries.clone();
1594 Box::pin(async move { Ok(v) })
1595 }
1596 fn model_count(
1597 &self,
1598 ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<usize>> + Send + '_>> {
1599 let n = self.entries.len();
1600 Box::pin(async move { Ok(n) })
1601 }
1602 fn refresh(
1603 &self,
1604 ) -> Pin<
1605 Box<
1606 dyn Future<Output = oxicode_sdk::SdkResult<oxicode_sdk::RefreshOutcome>>
1607 + Send
1608 + '_,
1609 >,
1610 > {
1611 Box::pin(async { Ok(oxicode_sdk::RefreshOutcome::Unchanged) })
1612 }
1613 fn subscribe(&self) -> tokio::sync::broadcast::Receiver<CatalogEvent> {
1614 self.tx.subscribe()
1615 }
1616 fn search_sync(&self, _pattern: &str) -> Vec<CatalogModelEntry> {
1617 self.entries.clone()
1618 }
1619 }
1620
1621 #[test]
1627 fn settings_items_mirror_general_tab_defs() {
1628 let settings = Settings::default();
1629 let defs = defs_for_tab(SettingsTab::General, &settings);
1630 assert!(!defs.is_empty(), "General tab must define settings");
1631
1632 let items = settings_overlay_items(SettingsTab::General, &settings).0;
1633 assert!(!items.is_empty());
1634
1635 let mut next_def = 0usize;
1640 let mut last_group: Option<&str> = None;
1641 let mut heading_count = 0usize;
1642 for item in &items {
1643 if item.subtitle.is_none() && item.badge.is_none() {
1644 assert!(next_def < defs.len(), "heading with no def to follow");
1645 let def = defs[next_def];
1646 assert_eq!(item.title, def.group, "heading carries the group name");
1647 assert!(item.selection.is_none(), "headings are never interactive");
1648 assert_ne!(
1649 last_group,
1650 Some(def.group),
1651 "heading emitted without a group change"
1652 );
1653 last_group = Some(def.group);
1654 heading_count += 1;
1655 } else {
1656 let def = defs[next_def];
1657 assert_eq!(item.title, def.label, "rows follow SETTING_DEFS order");
1658 assert_eq!(item.subtitle.as_deref(), Some(def.description));
1659 assert_eq!(
1660 item.badge.as_deref(),
1661 Some(get_display_value(def.key, &settings).as_str())
1662 );
1663 let expected = match def.widget {
1664 SettingWidget::Toggle | SettingWidget::Cycle => {
1665 Some(InlineListSelection::ConfigAction(format!("{:?}", def.key)))
1666 }
1667 SettingWidget::Text => Some(InlineListSelection::SettingTextEdit(format!(
1668 "{:?}",
1669 def.key
1670 ))),
1671 SettingWidget::SubmenuSelect(_) => Some(
1672 InlineListSelection::SettingSubmenuOpen(format!("{:?}", def.key)),
1673 ),
1674 SettingWidget::Multiselect => Some(InlineListSelection::SettingMultiselect(
1675 format!("{:?}", def.key),
1676 )),
1677 SettingWidget::MapEditor | SettingWidget::Pointer => None,
1678 };
1679 assert_eq!(item.selection.as_ref(), expected.as_ref());
1680 assert!(
1681 item.search_value
1682 .as_deref()
1683 .is_some_and(|v| v.contains(def.label))
1684 );
1685 next_def += 1;
1686 }
1687 }
1688 assert_eq!(next_def, defs.len(), "exactly one row per General-tab def");
1689 assert!(heading_count > 0, "at least one group heading emitted");
1690 }
1691
1692 struct NullProvider;
1695
1696 struct EmptyStream;
1697
1698 impl futures::Stream for EmptyStream {
1699 type Item = ProviderEvent;
1700 fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
1701 Poll::Ready(None)
1702 }
1703 }
1704
1705 impl Provider for NullProvider {
1706 fn stream<'a>(
1707 &'a self,
1708 _model: &'a Model,
1709 _context: &'a oxicode_sdk::Context,
1710 _options: Option<oxicode_sdk::StreamOptions>,
1711 ) -> Pin<
1712 Box<
1713 dyn Future<
1714 Output = Result<
1715 Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
1716 ProviderError,
1717 >,
1718 > + Send
1719 + 'a,
1720 >,
1721 > {
1722 Box::pin(async move {
1723 Ok::<_, ProviderError>(Box::pin(EmptyStream)
1724 as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
1725 })
1726 }
1727 }
1728
1729 #[test]
1732 fn settings_command_opens_table_driven_overlay() {
1733 use crate::app::agent_session::AgentSession;
1734 use crate::store::session::SessionManager;
1735 use oxicode_agent::{Agent, AgentConfig, ToolRegistry};
1736
1737 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
1738 let handle = InlineHandle::new_for_tests(cmd_tx);
1739
1740 let session = AgentSession::new(
1741 std::sync::Arc::new(Agent::new(
1742 std::sync::Arc::new(NullProvider),
1743 AgentConfig::new("anthropic/claude-sonnet-4-20250514"),
1744 std::sync::Arc::new(ToolRegistry::new()),
1745 )),
1746 Settings::default(),
1747 SessionManager::in_memory("/tmp/test"),
1748 "/tmp/test".to_string(),
1749 crate::SessionState::default(),
1750 )
1751 .clone_handle();
1752
1753 let mut state = RenderState::default();
1754 let mut ctx = SlashCtx {
1755 session: &session,
1756 handle: &handle,
1757 state: &mut state,
1758 };
1759 assert!(matches!(
1760 SlashRegistry::builtins().dispatch("/settings", &mut ctx),
1761 SlashOutcome::Handled
1762 ));
1763
1764 let list = loop {
1766 match cmd_rx.try_recv().expect("overlay command emitted") {
1767 InlineCommand::ShowOverlay { request } => match *request {
1768 OverlayRequest::List(list) => break list,
1769 _ => panic!("expected a list overlay request"),
1770 },
1771 _ => continue,
1772 }
1773 };
1774 assert_eq!(list.title, "Settings");
1775 assert!(list.search.is_some(), "settings overlay is filterable");
1776
1777 let defs = defs_for_tab(SettingsTab::General, &Settings::default());
1782 let rows: Vec<&str> = list
1783 .items
1784 .iter()
1785 .filter(|i| i.subtitle.is_some())
1786 .map(|i| i.title.as_str())
1787 .collect();
1788 let labels: Vec<&str> = defs.iter().map(|d| d.label).collect();
1789 assert_eq!(rows, labels, "overlay rows mirror the General tab defs");
1790 assert!(
1791 list.items
1792 .iter()
1793 .any(|i| i.subtitle.is_none() && i.badge.is_none()),
1794 "at least one group heading row present"
1795 );
1796 }
1797}