@sigitcode-old / sigit / commits / b36bc3b
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

feat(tui): complete slash commands and skills as you type

Typing / in the chat box opens a popup above the input with the built-in commands, user-defined commands and skills, each with a dimmed description. It filters by prefix first, then by substring, and shows up to eight rows. Up and Down move the selection (wrapping). Tab completes to "/<name> ". Enter completes too, and submits once the input already equals the selected name. Esc closes the popup until the input changes. The tab-bar Tab handler only fires on empty input, so it does not clash. The candidate list is cached on App and rebuilt from disk each time the input becomes "/". Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 29, 2026 at 17:36 UTC b36bc3b9c1e0d529ba5997f181f106ebea53c684
1 file changed +414
crates/cli/src/chat.rs
+414
@@ -737,6 +737,13 @@ mod tui {
737 messages: Vec<ChatMessage>,
738 input: String,
739 cursor: usize,
740 + /// Slash-command completion source (name, description), refreshed
741 + /// whenever the input becomes `/`.
742 + slash_candidates: Vec<(String, String)>,
743 + /// Selected row of the completion popup.
744 + slash_index: usize,
745 + /// Esc closed the popup; it stays closed until the input changes.
746 + slash_dismissed: bool,
747 /// true while assistant tokens are streaming into `stream_buf`
748 streaming: bool,
749 stream_buf: String,
@@ -863,6 +870,9 @@ mod tui {
870 messages: Vec::new(),
871 input: String::new(),
872 cursor: 0,
873 + slash_candidates: Vec::new(),
874 + slash_index: 0,
875 + slash_dismissed: false,
876 streaming: false,
877 stream_buf: String::new(),
878 inference_rx: None,
@@ -917,6 +927,63 @@ mod tui {
927 }
928 }
929
930 + /// Candidates the popup shows for the current input: names starting
931 + /// with what follows the `/`, then names merely containing it. Empty
932 + /// once the input has whitespace (the argument is being typed) or the
933 + /// popup was dismissed.
934 + fn slash_matches(&self) -> Vec<(String, String)> {
935 + if self.slash_dismissed
936 + || !self.input.starts_with('/')
937 + || self.input.contains(char::is_whitespace)
938 + {
939 + return Vec::new();
940 + }
941 + let query = self.input[1..].to_lowercase();
942 + let lower = |name: &str| name.to_lowercase();
943 + let (prefix, rest): (Vec<_>, Vec<_>) = self
944 + .slash_candidates
945 + .iter()
946 + .filter(|(name, _)| lower(name).contains(&query))
947 + .partition(|(name, _)| lower(name).starts_with(&query));
948 + prefix.into_iter().chain(rest).cloned().collect()
949 + }
950 +
951 + fn slash_popup_open(&self) -> bool {
952 + !self.slash_matches().is_empty()
953 + }
954 +
955 + /// Every edit of the input resets the selection and reopens a
956 + /// dismissed popup; typing `/` rereads commands and skills from disk.
957 + fn on_input_edited(&mut self) {
958 + self.slash_index = 0;
959 + self.slash_dismissed = false;
960 + if self.input == "/" {
961 + self.slash_candidates = slash_candidates();
962 + }
963 + }
964 +
965 + fn move_slash_selection(&mut self, down: bool) {
966 + let count = self.slash_matches().len();
967 + if count == 0 {
968 + return;
969 + }
970 + self.slash_index = if down {
971 + (self.slash_index + 1) % count
972 + } else {
973 + (self.slash_index + count - 1) % count
974 + };
975 + }
976 +
977 + /// Put `/<selected> ` in the input. Returns the selected name.
978 + fn complete_slash(&mut self) -> Option<String> {
979 + let matches = self.slash_matches();
980 + let (name, _) = matches.get(self.slash_index.min(matches.len().checked_sub(1)?))?;
981 + self.input = format!("/{name} ");
982 + self.cursor = self.input.len();
983 + self.slash_index = 0;
984 + Some(name.clone())
985 + }
986 +
987 /// Whether the Repo tab exists for this session.
988 fn repo_visible(&self) -> bool {
989 self.repo.is_some()
@@ -2082,6 +2149,61 @@ mod tui {
2149 Unknown(String, Option<String>),
2150 }
2151
2152 + /// Every name `/` can complete to, with a description: the built-ins
2153 + /// `parse_slash` knows (plus the TUI-only ones), then user-defined
2154 + /// commands, then skills. A name already taken by an earlier source is
2155 + /// dropped, since the earlier one is what runs.
2156 + fn slash_candidates() -> Vec<(String, String)> {
2157 + const BUILT_INS: &[(&str, &str)] = &[
2158 + ("help", "Show available commands"),
2159 + ("models", "Open the model picker, or switch with a number"),
2160 + ("local", "Toggle on-device inference mode"),
2161 + ("skills", "List available Agent Skills"),
2162 + ("agents", "List configured subagent types"),
2163 + ("commands", "List user-defined commands"),
2164 + ("mcp", "List MCP servers and their tools"),
2165 + ("load", "Load the selected on-device model"),
2166 + ("login", "Sign in via your browser"),
2167 + ("logout", "Sign out of siGit Code Cloud"),
2168 + ("whoami", "Show the signed-in account"),
2169 + ("plan", "Plan mode: research only, no edits or commands"),
2170 + ("permissions", "Show the tool permission policy"),
2171 + ("thinking", "Expand or collapse model reasoning"),
2172 + ("tools", "Expand or collapse tool-call details"),
2173 + ("compact", "Summarize and shrink the conversation history"),
2174 + ("resume", "Restore the saved session from disk"),
2175 + ("init", "Generate or improve the repo's AGENTS.md"),
2176 + ("clear", "Wipe the conversation history"),
2177 + ("status", "Show engine status"),
2178 + ("exit", "Quit chat"),
2179 + ];
2180 + let mut candidates: Vec<(String, String)> = BUILT_INS
2181 + .iter()
2182 + .map(|(name, description)| (name.to_string(), description.to_string()))
2183 + .collect();
2184 + let extra = crate::commands::discover_commands()
2185 + .into_iter()
2186 + .map(|command| {
2187 + let description = command
2188 + .description
2189 + .unwrap_or_else(|| "User-defined command".to_string());
2190 + (command.name, description)
2191 + })
2192 + .chain(
2193 + crate::skills::discover_skills()
2194 + .into_iter()
2195 + .map(|skill| (skill.name, skill.description)),
2196 + );
2197 + for (name, description) in extra {
2198 + if candidates.iter().any(|(taken, _)| *taken == name) {
2199 + continue;
2200 + }
2201 + let line = description.split_whitespace().collect::<Vec<_>>().join(" ");
2202 + candidates.push((name, line));
2203 + }
2204 + candidates
2205 + }
2206 +
2207 fn parse_slash(input: &str) -> Option<SlashCommand> {
2208 let trimmed = input.trim();
2209 if !trimmed.starts_with('/') {
@@ -2619,6 +2741,8 @@ mod tui {
2741 inner,
2742 );
2743
2744 + render_slash_popup(frame, app, area);
2745 +
2746 let col = (app.cursor as u16) % inner.width;
2747 let row = (app.cursor as u16) / inner.width;
2748 frame.set_cursor_position(Position {
@@ -2627,6 +2751,81 @@ mod tui {
2751 });
2752 }
2753
2754 + /// Most rows the slash-command popup shows at once.
2755 + const SLASH_POPUP_ROWS: usize = 8;
2756 +
2757 + /// The completion popup, drawn just above the input box: a name column
2758 + /// and a dimmed description, with the selected row highlighted.
2759 + fn render_slash_popup(frame: &mut Frame, app: &App, input_area: ratatui::layout::Rect) {
2760 + let matches = app.slash_matches();
2761 + if matches.is_empty() {
2762 + return;
2763 + }
2764 + let selected = app.slash_index.min(matches.len() - 1);
2765 + let first = (selected + 1).saturating_sub(SLASH_POPUP_ROWS);
2766 + let shown = &matches[first..matches.len().min(first + SLASH_POPUP_ROWS)];
2767 +
2768 + let height = (shown.len() as u16 + 2).min(input_area.y);
2769 + if height < 3 {
2770 + return;
2771 + }
2772 + let popup = ratatui::layout::Rect {
2773 + x: input_area.x,
2774 + y: input_area.y - height,
2775 + width: input_area.width,
2776 + height,
2777 + };
2778 + frame.render_widget(Clear, popup);
2779 + let block = Block::default()
2780 + .title(" commands and skills ")
2781 + .borders(Borders::ALL)
2782 + .border_style(Style::default().fg(Color::DarkGray))
2783 + .style(Style::default().bg(Color::Black));
2784 + let inner = block.inner(popup);
2785 + frame.render_widget(block, popup);
2786 +
2787 + let name_width = shown
2788 + .iter()
2789 + .map(|(name, _)| name.chars().count() + 1)
2790 + .max()
2791 + .unwrap_or(0)
2792 + + 2;
2793 + let lines: Vec<Line> = shown
2794 + .iter()
2795 + .enumerate()
2796 + .take(inner.height as usize)
2797 + .map(|(row, (name, description))| {
2798 + let is_selected = first + row == selected;
2799 + let name_style = if is_selected {
2800 + Style::default()
2801 + .fg(Color::Black)
2802 + .bg(Color::Green)
2803 + .add_modifier(Modifier::BOLD)
2804 + } else {
2805 + Style::default().fg(Color::White).bg(Color::Black)
2806 + };
2807 + let description_style = if is_selected {
2808 + Style::default().fg(Color::Black).bg(Color::Green)
2809 + } else {
2810 + Style::default().fg(Color::DarkGray).bg(Color::Black)
2811 + };
2812 + let room = (inner.width as usize).saturating_sub(name_width + 1);
2813 + let mut description: String = description.chars().take(room).collect();
2814 + if description.chars().count() < room {
2815 + description.push_str(&" ".repeat(room - description.chars().count()));
2816 + }
2817 + Line::from(vec![
2818 + Span::styled(
2819 + format!(" {:<w$}", format!("/{name}"), w = name_width),
2820 + name_style,
2821 + ),
2822 + Span::styled(description, description_style),
2823 + ])
2824 + })
2825 + .collect();
2826 + frame.render_widget(Paragraph::new(lines), inner);
2827 + }
2828 +
2829 fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
2830 let key_style = Style::default().fg(Color::Black).bg(Color::Green);
2831 let label_style = Style::default().fg(Color::DarkGray);
@@ -2726,6 +2925,39 @@ mod tui {
2925 }
2926 }
2927
2928 + // The slash-command popup takes these keys while it is open.
2929 + if app.slash_popup_open() {
2930 + match key.code {
2931 + KeyCode::Up => {
2932 + app.move_slash_selection(false);
2933 + return None;
2934 + }
2935 + KeyCode::Down => {
2936 + app.move_slash_selection(true);
2937 + return None;
2938 + }
2939 + KeyCode::Esc => {
2940 + app.slash_dismissed = true;
2941 + return None;
2942 + }
2943 + KeyCode::Tab => {
2944 + app.complete_slash();
2945 + return None;
2946 + }
2947 + KeyCode::Enter => {
2948 + // Complete first. Once the input is exactly the selected
2949 + // name, Enter submits it like any other line.
2950 + let matches = app.slash_matches();
2951 + let selected = matches.get(app.slash_index).map(|(name, _)| name.as_str());
2952 + if selected.is_none_or(|name| app.input.trim() != format!("/{name}")) {
2953 + app.complete_slash();
2954 + return None;
2955 + }
2956 + }
2957 + _ => {}
2958 + }
2959 + }
2960 +
2961 match key.code {
2962 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2963 app.quit = true;
@@ -2747,12 +2979,14 @@ mod tui {
2979 if app.cursor > 0 {
2980 app.cursor -= 1;
2981 app.input.remove(app.cursor);
2982 + app.on_input_edited();
2983 }
2984 None
2985 }
2986 KeyCode::Delete => {
2987 if app.cursor < app.input.len() {
2988 app.input.remove(app.cursor);
2989 + app.on_input_edited();
2990 }
2991 None
2992 }
@@ -2777,6 +3011,7 @@ mod tui {
3011 KeyCode::Char(ch) => {
3012 app.input.insert(app.cursor, ch);
3013 app.cursor += 1;
3014 + app.on_input_edited();
3015 None
3016 }
3017 _ => None,
@@ -4115,10 +4350,189 @@ mod tui {
4350 mod tui_tests {
4351 use super::{
4352 App, OpenAiBackend, Status, compact_tokens, context_gauge_color, context_gauge_label,
4353 + handle_key, slash_candidates,
4354 };
4355 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4356 use ratatui::style::Color;
4357 use std::sync::Arc;
4358
4359 + fn slash_app(candidates: &[(&str, &str)]) -> App {
4360 + let backend = Arc::new(OpenAiBackend::new("http://localhost/v1", "", "test", None));
4361 + let mut app = App::new(
4362 + "test".into(),
4363 + backend,
4364 + super::Reporter::new(std::io::sink()),
4365 + );
4366 + app.finish_loading();
4367 + app.slash_candidates = candidates
4368 + .iter()
4369 + .map(|(name, description)| (name.to_string(), description.to_string()))
4370 + .collect();
4371 + app
4372 + }
4373 +
4374 + fn press(app: &mut App, code: KeyCode) -> Option<String> {
4375 + handle_key(app, KeyEvent::new(code, KeyModifiers::NONE))
4376 + }
4377 +
4378 + /// Seed the input without going through `handle_key`, which would
4379 + /// reload the candidates from disk when the text is `/`.
4380 + fn set_input(app: &mut App, text: &str) {
4381 + app.input = text.into();
4382 + app.cursor = text.len();
4383 + }
4384 +
4385 + fn type_text(app: &mut App, text: &str) {
4386 + for ch in text.chars() {
4387 + press(app, KeyCode::Char(ch));
4388 + }
4389 + }
4390 +
4391 + fn names(app: &App) -> Vec<String> {
4392 + app.slash_matches()
4393 + .into_iter()
4394 + .map(|(name, _)| name)
4395 + .collect()
4396 + }
4397 +
4398 + #[test]
4399 + fn slash_candidates_list_built_ins_first_without_duplicates() {
4400 + let candidates = slash_candidates();
4401 + let names: Vec<&str> = candidates.iter().map(|(name, _)| name.as_str()).collect();
4402 + assert_eq!(names[0], "help");
4403 + for built_in in ["whoami", "login", "logout", "thinking", "tools", "resume"] {
4404 + assert!(names.contains(&built_in), "{built_in} missing");
4405 + }
4406 + let mut unique = names.clone();
4407 + unique.sort();
4408 + unique.dedup();
4409 + assert_eq!(unique.len(), names.len(), "duplicate candidates: {names:?}");
4410 + }
4411 +
4412 + #[test]
4413 + fn slash_matches_prefix_first_then_substring() {
4414 + let mut app = slash_app(&[
4415 + ("rewhoami", ""),
4416 + ("whoami", ""),
4417 + ("help", ""),
4418 + ("whatever", ""),
4419 + ]);
4420 + app.input = "/wh".into();
4421 + assert_eq!(names(&app), vec!["whoami", "whatever", "rewhoami"]);
4422 + app.input = "/".into();
4423 + assert_eq!(names(&app).len(), 4);
4424 + app.input = "/WHO".into();
4425 + assert_eq!(names(&app), vec!["whoami", "rewhoami"]);
4426 + app.input = "/nothing".into();
4427 + assert!(names(&app).is_empty());
4428 + app.input = "/whoami now".into();
4429 + assert!(names(&app).is_empty(), "closed once an argument starts");
4430 + app.input = "whoami".into();
4431 + assert!(names(&app).is_empty(), "only a leading slash opens it");
4432 + }
4433 +
4434 + #[test]
4435 + fn tab_completes_the_selected_name() {
4436 + let mut app = slash_app(&[("help", ""), ("whoami", ""), ("web", "")]);
4437 + set_input(&mut app, "/w");
4438 + assert_eq!(names(&app), vec!["whoami", "web"]);
4439 + press(&mut app, KeyCode::Down);
4440 + assert_eq!(press(&mut app, KeyCode::Tab), None);
4441 + assert_eq!(app.input, "/web ");
4442 + assert_eq!(app.cursor, app.input.len());
4443 + assert!(!app.slash_popup_open(), "the argument is next");
4444 + }
4445 +
4446 + #[test]
4447 + fn selection_wraps_in_both_directions() {
4448 + let mut app = slash_app(&[("aa", ""), ("ab", ""), ("ac", "")]);
4449 + set_input(&mut app, "/a");
4450 + press(&mut app, KeyCode::Up);
4451 + assert_eq!(app.slash_index, 2);
4452 + press(&mut app, KeyCode::Down);
4453 + assert_eq!(app.slash_index, 0);
4454 + }
4455 +
4456 + #[test]
4457 + fn enter_completes_then_submits_on_an_exact_match() {
4458 + let mut app = slash_app(&[("whoami", ""), ("who", "")]);
4459 + set_input(&mut app, "/wh");
4460 + assert_eq!(press(&mut app, KeyCode::Enter), None);
4461 + assert_eq!(app.input, "/whoami ");
4462 +
4463 + // The input is exactly the selected name: Enter sends it.
4464 + let mut app = slash_app(&[("whoami", ""), ("who", "")]);
4465 + set_input(&mut app, "/whoami");
4466 + assert_eq!(press(&mut app, KeyCode::Enter), Some("/whoami".into()));
4467 + assert!(app.input.is_empty());
4468 + }
4469 +
4470 + #[test]
4471 + fn esc_closes_the_popup_until_the_input_changes() {
4472 + let mut app = slash_app(&[("whoami", "")]);
4473 + set_input(&mut app, "/wh");
4474 + assert!(app.slash_popup_open());
4475 + press(&mut app, KeyCode::Esc);
4476 + assert!(!app.slash_popup_open());
4477 + assert_eq!(app.input, "/wh", "Esc leaves the text alone");
4478 + type_text(&mut app, "o");
4479 + assert!(app.slash_popup_open());
4480 + }
4481 +
4482 + #[test]
4483 + fn typing_a_slash_refreshes_the_candidates() {
4484 + let mut app = slash_app(&[]);
4485 + type_text(&mut app, "/");
4486 + assert!(names(&app).contains(&"whoami".to_string()));
4487 + }
4488 +
4489 + #[test]
4490 + fn the_popup_draws_above_the_input_with_the_selection_visible() {
4491 + use ratatui::{Terminal, backend::TestBackend, layout::Rect};
4492 + let candidates: Vec<(String, String)> = (0..12)
4493 + .map(|n| (format!("cmd{n:02}"), format!("does thing {n}")))
4494 + .collect();
4495 + let refs: Vec<(&str, &str)> = candidates
4496 + .iter()
4497 + .map(|(a, b)| (a.as_str(), b.as_str()))
4498 + .collect();
4499 + let mut app = slash_app(&refs);
4500 + app.input = "/cmd".into();
4501 + app.slash_index = 10;
4502 +
4503 + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap();
4504 + terminal
4505 + .draw(|frame| super::render_input(frame, &app, Rect::new(0, 17, 60, 3)))
4506 + .unwrap();
4507 + let text: Vec<String> = terminal
4508 + .backend()
4509 + .buffer()
4510 + .content()
4511 + .chunks(60)
4512 + .map(|row| row.iter().map(|cell| cell.symbol()).collect())
4513 + .collect();
4514 + let all = text.join("\n");
4515 + assert!(
4516 + all.contains("/cmd10"),
4517 + "the selected row scrolls into view:\n{all}"
4518 + );
4519 + assert!(all.contains("does thing 10"), "{all}");
4520 + assert!(!all.contains("/cmd00"), "only 8 rows show:\n{all}");
4521 + assert!(
4522 + text[17].contains("message"),
4523 + "the input box stays put: {all}"
4524 + );
4525 + }
4526 +
4527 + #[test]
4528 + fn keys_are_untouched_when_the_popup_is_closed() {
4529 + let mut app = slash_app(&[("whoami", "")]);
4530 + type_text(&mut app, "hello");
4531 + assert_eq!(press(&mut app, KeyCode::Tab), None);
4532 + assert_eq!(app.input, "hello");
4533 + assert_eq!(press(&mut app, KeyCode::Enter), Some("hello".into()));
4534 + }
4535 +
4536 #[test]
4537 fn login_status_uses_the_result_instead_of_message_wording() {
4538 let backend = Arc::new(OpenAiBackend::new("http://localhost/v1", "", "test", None));