feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
use ratatui::buffer::Buffer;
|
|
|
|
|
use ratatui::layout::Rect;
|
|
|
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
|
|
2025-08-06 21:23:09 -07:00
|
|
|
use super::popup_consts::MAX_POPUP_ROWS;
|
|
|
|
|
use super::scroll_state::ScrollState;
|
|
|
|
|
use super::selection_popup_common::GenericDisplayRow;
|
|
|
|
|
use super::selection_popup_common::render_rows;
|
2025-10-01 14:29:05 -07:00
|
|
|
use crate::render::Insets;
|
|
|
|
|
use crate::render::RectExt;
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
use crate::slash_command::SlashCommand;
|
|
|
|
|
use crate::slash_command::built_in_slash_commands;
|
2025-08-06 21:23:09 -07:00
|
|
|
use codex_common::fuzzy_match::fuzzy_match;
|
2025-08-28 19:16:39 -07:00
|
|
|
use codex_protocol::custom_prompts::CustomPrompt;
|
2025-09-29 17:58:16 -07:00
|
|
|
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
2025-08-28 19:16:39 -07:00
|
|
|
use std::collections::HashSet;
|
|
|
|
|
|
|
|
|
|
/// A selectable item in the popup: either a built-in command or a user prompt.
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
|
|
|
pub(crate) enum CommandItem {
|
|
|
|
|
Builtin(SlashCommand),
|
|
|
|
|
// Index into `prompts`
|
|
|
|
|
UserPrompt(usize),
|
|
|
|
|
}
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
|
|
|
|
|
pub(crate) struct CommandPopup {
|
|
|
|
|
command_filter: String,
|
2025-08-28 19:16:39 -07:00
|
|
|
builtins: Vec<(&'static str, SlashCommand)>,
|
|
|
|
|
prompts: Vec<CustomPrompt>,
|
2025-08-06 21:23:09 -07:00
|
|
|
state: ScrollState,
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CommandPopup {
|
2025-08-28 19:16:39 -07:00
|
|
|
pub(crate) fn new(mut prompts: Vec<CustomPrompt>) -> Self {
|
|
|
|
|
let builtins = built_in_slash_commands();
|
|
|
|
|
// Exclude prompts that collide with builtin command names and sort by name.
|
|
|
|
|
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
|
|
|
|
prompts.retain(|p| !exclude.contains(&p.name));
|
|
|
|
|
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
Self {
|
|
|
|
|
command_filter: String::new(),
|
2025-08-28 19:16:39 -07:00
|
|
|
builtins,
|
|
|
|
|
prompts,
|
2025-08-06 21:23:09 -07:00
|
|
|
state: ScrollState::new(),
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-28 19:16:39 -07:00
|
|
|
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
|
|
|
|
|
let exclude: HashSet<String> = self
|
|
|
|
|
.builtins
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(n, _)| (*n).to_string())
|
|
|
|
|
.collect();
|
|
|
|
|
prompts.retain(|p| !exclude.contains(&p.name));
|
|
|
|
|
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
|
self.prompts = prompts;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-29 16:14:37 -07:00
|
|
|
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
|
|
|
|
|
self.prompts.get(idx)
|
2025-08-28 19:16:39 -07:00
|
|
|
}
|
|
|
|
|
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
/// Update the filter string based on the current composer text. The text
|
|
|
|
|
/// passed in is expected to start with a leading '/'. Everything after the
|
|
|
|
|
/// *first* '/" on the *first* line becomes the active filter that is used
|
|
|
|
|
/// to narrow down the list of available commands.
|
|
|
|
|
pub(crate) fn on_composer_text_change(&mut self, text: String) {
|
|
|
|
|
let first_line = text.lines().next().unwrap_or("");
|
|
|
|
|
|
|
|
|
|
if let Some(stripped) = first_line.strip_prefix('/') {
|
|
|
|
|
// Extract the *first* token (sequence of non-whitespace
|
|
|
|
|
// characters) after the slash so that `/clear something` still
|
|
|
|
|
// shows the help for `/clear`.
|
|
|
|
|
let token = stripped.trim_start();
|
|
|
|
|
let cmd_token = token.split_whitespace().next().unwrap_or("");
|
|
|
|
|
|
|
|
|
|
// Update the filter keeping the original case (commands are all
|
|
|
|
|
// lower-case for now but this may change in the future).
|
|
|
|
|
self.command_filter = cmd_token.to_string();
|
|
|
|
|
} else {
|
|
|
|
|
// The composer no longer starts with '/'. Reset the filter so the
|
|
|
|
|
// popup shows the *full* command list if it is still displayed
|
|
|
|
|
// for some reason.
|
|
|
|
|
self.command_filter.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset or clamp selected index based on new filtered list.
|
2025-08-28 19:16:39 -07:00
|
|
|
let matches_len = self.filtered_items().len();
|
2025-08-06 21:23:09 -07:00
|
|
|
self.state.clamp_selection(matches_len);
|
|
|
|
|
self.state
|
|
|
|
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
|
2025-09-15 17:34:04 -07:00
|
|
|
/// Determine the preferred height of the popup for a given width.
|
|
|
|
|
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
|
|
|
|
|
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
|
|
|
|
|
use super::selection_popup_common::measure_rows_height;
|
2025-09-19 12:08:04 -07:00
|
|
|
let rows = self.rows_from_matches(self.filtered());
|
|
|
|
|
|
|
|
|
|
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
|
2025-08-28 19:16:39 -07:00
|
|
|
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
|
|
|
|
/// paired with optional highlight indices and score. Sorted by ascending
|
|
|
|
|
/// score, then by name for stability.
|
|
|
|
|
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>, i32)> {
|
2025-08-06 21:23:09 -07:00
|
|
|
let filter = self.command_filter.trim();
|
2025-08-28 19:16:39 -07:00
|
|
|
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
|
2025-08-06 21:23:09 -07:00
|
|
|
if filter.is_empty() {
|
2025-08-28 19:16:39 -07:00
|
|
|
// Built-ins first, in presentation order.
|
|
|
|
|
for (_, cmd) in self.builtins.iter() {
|
|
|
|
|
out.push((CommandItem::Builtin(*cmd), None, 0));
|
|
|
|
|
}
|
|
|
|
|
// Then prompts, already sorted by name.
|
|
|
|
|
for idx in 0..self.prompts.len() {
|
|
|
|
|
out.push((CommandItem::UserPrompt(idx), None, 0));
|
2025-08-06 21:23:09 -07:00
|
|
|
}
|
2025-08-19 10:55:07 -07:00
|
|
|
return out;
|
2025-08-28 19:16:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (_, cmd) in self.builtins.iter() {
|
|
|
|
|
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
|
|
|
|
|
out.push((CommandItem::Builtin(*cmd), Some(indices), score));
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-29 17:58:16 -07:00
|
|
|
// Support both search styles:
|
|
|
|
|
// - Typing "name" should surface "/prompts:name" results.
|
|
|
|
|
// - Typing "prompts:name" should also work.
|
2025-08-28 19:16:39 -07:00
|
|
|
for (idx, p) in self.prompts.iter().enumerate() {
|
2025-09-29 17:58:16 -07:00
|
|
|
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
|
|
|
|
|
if let Some((indices, score)) = fuzzy_match(&display, filter) {
|
2025-08-28 19:16:39 -07:00
|
|
|
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
|
2025-08-06 21:23:09 -07:00
|
|
|
}
|
|
|
|
|
}
|
2025-08-28 19:16:39 -07:00
|
|
|
// When filtering, sort by ascending score and then by name for stability.
|
|
|
|
|
out.sort_by(|a, b| {
|
|
|
|
|
a.2.cmp(&b.2).then_with(|| {
|
|
|
|
|
let an = match a.0 {
|
|
|
|
|
CommandItem::Builtin(c) => c.command(),
|
|
|
|
|
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
|
|
|
|
};
|
|
|
|
|
let bn = match b.0 {
|
|
|
|
|
CommandItem::Builtin(c) => c.command(),
|
|
|
|
|
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
|
|
|
|
};
|
|
|
|
|
an.cmp(bn)
|
|
|
|
|
})
|
|
|
|
|
});
|
2025-08-06 21:23:09 -07:00
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-28 19:16:39 -07:00
|
|
|
fn filtered_items(&self) -> Vec<CommandItem> {
|
2025-08-06 21:23:09 -07:00
|
|
|
self.filtered().into_iter().map(|(c, _, _)| c).collect()
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
|
2025-09-19 12:08:04 -07:00
|
|
|
fn rows_from_matches(
|
|
|
|
|
&self,
|
|
|
|
|
matches: Vec<(CommandItem, Option<Vec<usize>>, i32)>,
|
|
|
|
|
) -> Vec<GenericDisplayRow> {
|
|
|
|
|
matches
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(item, indices, _)| {
|
|
|
|
|
let (name, description) = match item {
|
|
|
|
|
CommandItem::Builtin(cmd) => {
|
|
|
|
|
(format!("/{}", cmd.command()), cmd.description().to_string())
|
|
|
|
|
}
|
|
|
|
|
CommandItem::UserPrompt(i) => (
|
2025-09-29 17:58:16 -07:00
|
|
|
format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name),
|
2025-09-19 12:08:04 -07:00
|
|
|
"send saved prompt".to_string(),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
GenericDisplayRow {
|
|
|
|
|
name,
|
|
|
|
|
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
|
|
|
|
is_current: false,
|
2025-10-02 14:41:29 -07:00
|
|
|
display_shortcut: None,
|
2025-09-19 12:08:04 -07:00
|
|
|
description: Some(description),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
/// Move the selection cursor one step up.
|
|
|
|
|
pub(crate) fn move_up(&mut self) {
|
2025-08-28 19:16:39 -07:00
|
|
|
let len = self.filtered_items().len();
|
2025-08-06 21:23:09 -07:00
|
|
|
self.state.move_up_wrap(len);
|
|
|
|
|
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Move the selection cursor one step down.
|
|
|
|
|
pub(crate) fn move_down(&mut self) {
|
2025-08-28 19:16:39 -07:00
|
|
|
let matches_len = self.filtered_items().len();
|
2025-08-06 21:23:09 -07:00
|
|
|
self.state.move_down_wrap(matches_len);
|
|
|
|
|
self.state
|
|
|
|
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return currently selected command, if any.
|
2025-08-28 19:16:39 -07:00
|
|
|
pub(crate) fn selected_item(&self) -> Option<CommandItem> {
|
|
|
|
|
let matches = self.filtered_items();
|
2025-08-06 21:23:09 -07:00
|
|
|
self.state
|
|
|
|
|
.selected_idx
|
|
|
|
|
.and_then(|idx| matches.get(idx).copied())
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WidgetRef for CommandPopup {
|
|
|
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
2025-09-19 12:08:04 -07:00
|
|
|
let rows = self.rows_from_matches(self.filtered());
|
2025-09-02 16:38:43 -07:00
|
|
|
render_rows(
|
2025-10-01 14:29:05 -07:00
|
|
|
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
2025-09-02 16:38:43 -07:00
|
|
|
buf,
|
2025-09-19 12:08:04 -07:00
|
|
|
&rows,
|
2025-09-02 16:38:43 -07:00
|
|
|
&self.state,
|
|
|
|
|
MAX_POPUP_ROWS,
|
|
|
|
|
"no matches",
|
|
|
|
|
);
|
feat: add support for commands in the Rust TUI (#935)
Introduces support for slash commands like in the TypeScript CLI. We do
not support the full set of commands yet, but the core abstraction is
there now.
In particular, we have a `SlashCommand` enum and due to thoughtful use
of the [strum](https://crates.io/crates/strum) crate, it requires
minimal boilerplate to add a new command to the list.
The key new piece of UI is `CommandPopup`, though the keyboard events
are still handled by `ChatComposer`. The behavior is roughly as follows:
* if the first character in the composer is `/`, the command popup is
displayed (if you really want to send a message to Codex that starts
with a `/`, simply put a space before the `/`)
* while the popup is displayed, up/down can be used to change the
selection of the popup
* if there is a selection, hitting tab completes the command, but does
not send it
* if there is a selection, hitting enter sends the command
* if the prefix of the composer matches a command, the command will be
visible in the popup so the user can see the description (commands could
take arguments, so additional text may appear after the command name
itself)
https://github.com/user-attachments/assets/39c3e6ee-eeb7-4ef7-a911-466d8184975f
Incidentally, Codex wrote almost all the code for this PR!
2025-05-14 12:55:49 -07:00
|
|
|
}
|
|
|
|
|
}
|
2025-08-06 09:10:23 -07:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn filter_includes_init_when_typing_prefix() {
|
2025-08-28 19:16:39 -07:00
|
|
|
let mut popup = CommandPopup::new(Vec::new());
|
2025-08-06 09:10:23 -07:00
|
|
|
// Simulate the composer line starting with '/in' so the popup filters
|
|
|
|
|
// matching commands by prefix.
|
|
|
|
|
popup.on_composer_text_change("/in".to_string());
|
|
|
|
|
|
|
|
|
|
// Access the filtered list via the selected command and ensure that
|
|
|
|
|
// one of the matches is the new "init" command.
|
2025-08-28 19:16:39 -07:00
|
|
|
let matches = popup.filtered_items();
|
|
|
|
|
let has_init = matches.iter().any(|item| match item {
|
|
|
|
|
CommandItem::Builtin(cmd) => cmd.command() == "init",
|
|
|
|
|
CommandItem::UserPrompt(_) => false,
|
|
|
|
|
});
|
2025-08-06 09:10:23 -07:00
|
|
|
assert!(
|
2025-08-28 19:16:39 -07:00
|
|
|
has_init,
|
2025-08-06 09:10:23 -07:00
|
|
|
"expected '/init' to appear among filtered commands"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn selecting_init_by_exact_match() {
|
2025-08-28 19:16:39 -07:00
|
|
|
let mut popup = CommandPopup::new(Vec::new());
|
2025-08-06 09:10:23 -07:00
|
|
|
popup.on_composer_text_change("/init".to_string());
|
|
|
|
|
|
|
|
|
|
// When an exact match exists, the selected command should be that
|
|
|
|
|
// command by default.
|
2025-08-28 19:16:39 -07:00
|
|
|
let selected = popup.selected_item();
|
2025-08-06 09:10:23 -07:00
|
|
|
match selected {
|
2025-08-28 19:16:39 -07:00
|
|
|
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
|
|
|
|
|
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
|
2025-08-06 09:10:23 -07:00
|
|
|
None => panic!("expected a selected command for exact match"),
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-28 19:16:39 -07:00
|
|
|
|
2025-09-02 10:29:58 -07:00
|
|
|
#[test]
|
|
|
|
|
fn model_is_first_suggestion_for_mo() {
|
|
|
|
|
let mut popup = CommandPopup::new(Vec::new());
|
|
|
|
|
popup.on_composer_text_change("/mo".to_string());
|
|
|
|
|
let matches = popup.filtered_items();
|
|
|
|
|
match matches.first() {
|
|
|
|
|
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
|
|
|
|
|
Some(CommandItem::UserPrompt(_)) => {
|
|
|
|
|
panic!("unexpected prompt ranked before '/model' for '/mo'")
|
|
|
|
|
}
|
|
|
|
|
None => panic!("expected at least one match for '/mo'"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-28 19:16:39 -07:00
|
|
|
#[test]
|
|
|
|
|
fn prompt_discovery_lists_custom_prompts() {
|
|
|
|
|
let prompts = vec![
|
|
|
|
|
CustomPrompt {
|
|
|
|
|
name: "foo".to_string(),
|
|
|
|
|
path: "/tmp/foo.md".to_string().into(),
|
|
|
|
|
content: "hello from foo".to_string(),
|
2025-09-29 13:06:08 -07:00
|
|
|
description: None,
|
|
|
|
|
argument_hint: None,
|
2025-08-28 19:16:39 -07:00
|
|
|
},
|
|
|
|
|
CustomPrompt {
|
|
|
|
|
name: "bar".to_string(),
|
|
|
|
|
path: "/tmp/bar.md".to_string().into(),
|
|
|
|
|
content: "hello from bar".to_string(),
|
2025-09-29 13:06:08 -07:00
|
|
|
description: None,
|
|
|
|
|
argument_hint: None,
|
2025-08-28 19:16:39 -07:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
let popup = CommandPopup::new(prompts);
|
|
|
|
|
let items = popup.filtered_items();
|
|
|
|
|
let mut prompt_names: Vec<String> = items
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|it| match it {
|
2025-09-29 16:14:37 -07:00
|
|
|
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
|
2025-08-28 19:16:39 -07:00
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
prompt_names.sort();
|
|
|
|
|
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn prompt_name_collision_with_builtin_is_ignored() {
|
|
|
|
|
// Create a prompt named like a builtin (e.g. "init").
|
|
|
|
|
let popup = CommandPopup::new(vec![CustomPrompt {
|
|
|
|
|
name: "init".to_string(),
|
|
|
|
|
path: "/tmp/init.md".to_string().into(),
|
|
|
|
|
content: "should be ignored".to_string(),
|
2025-09-29 13:06:08 -07:00
|
|
|
description: None,
|
|
|
|
|
argument_hint: None,
|
2025-08-28 19:16:39 -07:00
|
|
|
}]);
|
|
|
|
|
let items = popup.filtered_items();
|
|
|
|
|
let has_collision_prompt = items.into_iter().any(|it| match it {
|
2025-09-29 16:14:37 -07:00
|
|
|
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
2025-08-28 19:16:39 -07:00
|
|
|
_ => false,
|
|
|
|
|
});
|
|
|
|
|
assert!(
|
|
|
|
|
!has_collision_prompt,
|
|
|
|
|
"prompt with builtin name should be ignored"
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-08-06 09:10:23 -07:00
|
|
|
}
|