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));