Skip to content

Commit cd00059

Browse files
authored
feat(tui): expose slash commands as hotbar actions (#3269)
Register built-in slash commands under slash.<name> hotbar action IDs. Arg-less and optional-arg commands dispatch through the existing slash command path, while commands with required args prefill the composer for completion. Refs #2067 Part of #2061
1 parent 4464de8 commit cd00059

4 files changed

Lines changed: 183 additions & 6 deletions

File tree

config.example.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ check_for_updates = true
103103
#
104104
# Invalid slots are skipped with a warning, duplicate slots use the last entry,
105105
# and unknown actions are preserved so the UI can show a disabled placeholder.
106+
# Slash commands can be bound as slash.<name>, for example slash.mode. Commands
107+
# that require arguments pre-fill the composer instead of running incomplete.
106108
#
107109
# [[hotbar]]
108110
# slot = 1
@@ -112,6 +114,11 @@ check_for_updates = true
112114
# [[hotbar]]
113115
# slot = 2
114116
# action = "session.compact"
117+
#
118+
# [[hotbar]]
119+
# slot = 3
120+
# label = "mode"
121+
# action = "slash.mode"
115122

116123
# ─────────────────────────────────────────────────────────────────────────────────
117124
# Paths

crates/tui/src/commands/traits.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ impl CommandInfo {
2020
self.usage.contains('<') || self.usage.contains('[')
2121
}
2222

23+
pub fn requires_required_argument(&self) -> bool {
24+
let mut optional_depth = 0usize;
25+
for ch in self.usage.chars() {
26+
match ch {
27+
'[' => optional_depth += 1,
28+
']' => optional_depth = optional_depth.saturating_sub(1),
29+
'<' if optional_depth == 0 => return true,
30+
_ => {}
31+
}
32+
}
33+
false
34+
}
35+
2336
pub fn palette_command(&self) -> String {
2437
if self.requires_argument() {
2538
format!("/{} ", self.name)

crates/tui/src/tui/hotbar/actions.rs

Lines changed: 143 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::sync::Arc;
33

44
use anyhow::{Result, bail};
55

6+
use crate::commands::{self, CommandInfo, CommandResult};
67
use crate::tui::app::{App, AppAction, AppMode, SidebarFocus};
78
use crate::tui::command_palette::{
89
CommandPaletteView, build_entries as build_command_palette_entries,
@@ -52,6 +53,7 @@ impl HotbarActionRegistry {
5253
pub fn with_builtins() -> Self {
5354
let mut registry = Self::new();
5455
registry.register_builtins();
56+
registry.register_slash_commands();
5557
registry
5658
}
5759

@@ -113,6 +115,12 @@ impl HotbarActionRegistry {
113115
));
114116
}
115117

118+
pub(crate) fn register_slash_commands(&mut self) {
119+
for info in commands::command_infos() {
120+
self.register(SlashHotbarAction::new(info));
121+
}
122+
}
123+
116124
#[allow(dead_code)]
117125
#[must_use]
118126
pub fn get(&self, id: &str) -> Option<Arc<dyn HotbarAction>> {
@@ -137,6 +145,13 @@ impl HotbarActionRegistry {
137145
}
138146
}
139147

148+
fn dispatch_command_result(app: &mut App, result: CommandResult) -> HotbarDispatch {
149+
app.status_message = result.message;
150+
result
151+
.action
152+
.map_or(HotbarDispatch::Handled, HotbarDispatch::AppAction)
153+
}
154+
140155
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141156
enum AppHotbarKind {
142157
VoiceToggle,
@@ -198,11 +213,7 @@ impl HotbarAction for AppHotbarAction {
198213
match self.kind {
199214
AppHotbarKind::VoiceToggle => {
200215
let result = crate::commands::voice::voice(app);
201-
app.status_message = result.message;
202-
match result.action {
203-
Some(action) => Ok(HotbarDispatch::AppAction(action)),
204-
None => Ok(HotbarDispatch::Handled),
205-
}
216+
Ok(dispatch_command_result(app, result))
206217
}
207218
AppHotbarKind::SessionCompact => {
208219
if app.is_compacting {
@@ -283,6 +294,64 @@ impl HotbarAction for AppHotbarAction {
283294
}
284295
}
285296

297+
#[allow(dead_code)]
298+
struct SlashHotbarAction {
299+
info: &'static CommandInfo,
300+
id: String,
301+
short_label: String,
302+
}
303+
304+
impl SlashHotbarAction {
305+
fn new(info: &'static CommandInfo) -> Self {
306+
Self {
307+
info,
308+
id: format!("slash.{}", info.name),
309+
short_label: info.name.chars().take(7).collect(),
310+
}
311+
}
312+
313+
fn prefill_composer(&self, app: &mut App) {
314+
app.clear_input_recoverable();
315+
app.input = format!("/{} ", self.info.name);
316+
app.cursor_position = app.input.chars().count();
317+
app.slash_menu_hidden = false;
318+
app.needs_redraw = true;
319+
app.status_message = Some(format!(
320+
"Command needs arguments; complete {}",
321+
app.input.trim_end()
322+
));
323+
}
324+
}
325+
326+
impl HotbarAction for SlashHotbarAction {
327+
fn id(&self) -> &str {
328+
&self.id
329+
}
330+
331+
fn short_label(&self) -> &str {
332+
&self.short_label
333+
}
334+
335+
fn category(&self) -> &str {
336+
"slash"
337+
}
338+
339+
fn is_active(&self, _app: &App) -> bool {
340+
false
341+
}
342+
343+
fn dispatch(&self, app: &mut App) -> Result<HotbarDispatch> {
344+
if self.info.requires_required_argument() {
345+
self.prefill_composer(app);
346+
return Ok(HotbarDispatch::Handled);
347+
}
348+
349+
let input = format!("/{}", self.info.name);
350+
let result = commands::execute(&input, app);
351+
Ok(dispatch_command_result(app, result))
352+
}
353+
}
354+
286355
#[cfg(test)]
287356
mod tests {
288357
use std::path::PathBuf;
@@ -322,7 +391,8 @@ mod tests {
322391

323392
#[test]
324393
fn builtins_register_expected_actions() {
325-
let registry = HotbarActionRegistry::with_builtins();
394+
let mut registry = HotbarActionRegistry::new();
395+
registry.register_builtins();
326396
let ids = registry.iter().map(HotbarAction::id).collect::<Vec<_>>();
327397

328398
assert_eq!(
@@ -359,6 +429,73 @@ mod tests {
359429
HotbarActionRegistry::with_builtins().len()
360430
);
361431
assert!(app.hotbar_actions.get("mode.agent").is_some());
432+
assert!(app.hotbar_actions.get("slash.help").is_some());
433+
assert!(app.hotbar_actions.get("slash.mode").is_some());
434+
}
435+
436+
#[test]
437+
fn slash_commands_register_as_hotbar_actions() {
438+
let registry = HotbarActionRegistry::with_builtins();
439+
440+
for info in commands::command_infos() {
441+
let action_id = format!("slash.{}", info.name);
442+
let action = registry
443+
.get(&action_id)
444+
.unwrap_or_else(|| panic!("missing slash hotbar action for /{}", info.name));
445+
assert_eq!(action.category(), "slash");
446+
assert!(!action.is_active(&test_app()));
447+
assert!(
448+
action.short_label().chars().count() <= 7,
449+
"{action_id} has an overlong short label"
450+
);
451+
}
452+
}
453+
454+
#[test]
455+
fn slash_hotbar_action_dispatches_argless_command() {
456+
let registry = HotbarActionRegistry::with_builtins();
457+
let mode = registry.get("slash.mode").expect("mode slash action");
458+
let mut app = test_app();
459+
460+
assert_eq!(
461+
mode.dispatch(&mut app).expect("dispatch /mode"),
462+
HotbarDispatch::AppAction(AppAction::OpenModePicker)
463+
);
464+
assert!(app.input.is_empty());
465+
}
466+
467+
#[test]
468+
fn slash_hotbar_action_dispatches_optional_argument_command_with_no_args() {
469+
let registry = HotbarActionRegistry::with_builtins();
470+
let task = registry.get("slash.task").expect("task slash action");
471+
let mut app = test_app();
472+
473+
assert_eq!(
474+
task.dispatch(&mut app).expect("dispatch /task"),
475+
HotbarDispatch::AppAction(AppAction::TaskList)
476+
);
477+
assert!(app.input.is_empty());
478+
}
479+
480+
#[test]
481+
fn slash_hotbar_action_prefills_required_argument_command() {
482+
let registry = HotbarActionRegistry::with_builtins();
483+
let rename = registry.get("slash.rename").expect("rename slash action");
484+
let mut app = test_app();
485+
app.input = "draft".to_string();
486+
app.cursor_position = app.input.chars().count();
487+
488+
assert_eq!(
489+
rename.dispatch(&mut app).expect("dispatch /rename"),
490+
HotbarDispatch::Handled
491+
);
492+
assert_eq!(app.input, "/rename ");
493+
assert_eq!(app.cursor_position, app.input.chars().count());
494+
assert_eq!(app.clear_undo_buffer.as_deref(), Some("draft"));
495+
assert_eq!(
496+
app.status_message.as_deref(),
497+
Some("Command needs arguments; complete /rename")
498+
);
362499
}
363500

364501
#[test]

crates/tui/src/tui/ui/tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3622,6 +3622,26 @@ fn hotbar_dispatches_bound_slot_and_ignores_empty_slot() {
36223622
);
36233623
}
36243624

3625+
#[test]
3626+
fn hotbar_dispatches_slash_command_slot() {
3627+
let mut app = create_test_app();
3628+
app.onboarding = OnboardingState::None;
3629+
let config = Config {
3630+
hotbar: Some(vec![codewhale_config::HotbarBindingToml {
3631+
slot: 1,
3632+
label: Some("mode".to_string()),
3633+
action: "slash.mode".to_string(),
3634+
}]),
3635+
..Config::default()
3636+
};
3637+
3638+
assert_eq!(
3639+
dispatch_hotbar_slot(&mut app, &config, 1).expect("slash slot dispatch"),
3640+
Some(HotbarDispatch::AppAction(AppAction::OpenModePicker))
3641+
);
3642+
assert!(app.input.is_empty());
3643+
}
3644+
36253645
#[test]
36263646
fn alt_0_restores_auto_sidebar_focus() {
36273647
let mut app = create_test_app();

0 commit comments

Comments
 (0)