mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
feat: add support for commands in the Rust TUI
This commit is contained in:
25
codex-rs/Cargo.lock
generated
25
codex-rs/Cargo.lock
generated
@@ -632,6 +632,8 @@ dependencies = [
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"strum 0.27.1",
|
||||
"strum_macros 0.27.1",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -2711,7 +2713,7 @@ dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"strum 0.26.3",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
@@ -3482,9 +3484,15 @@ version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
"strum_macros 0.26.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
@@ -3498,6 +3506,19 @@ dependencies = [
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
||||
@@ -29,6 +29,8 @@ ratatui = { version = "0.29.0", features = [
|
||||
] }
|
||||
serde_json = "1"
|
||||
shlex = "1.3.0"
|
||||
strum = "0.27.1"
|
||||
strum_macros = "0.27.1"
|
||||
tokio = { version = "1", features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::chatwidget::ChatWidget;
|
||||
use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
use crate::scroll_event_helper::ScrollEventHelper;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
@@ -177,6 +178,14 @@ impl App<'_> {
|
||||
let _ = self.chat_widget.update_latest_log(line);
|
||||
}
|
||||
}
|
||||
AppEvent::DispatchCommand(command) => match command {
|
||||
SlashCommand::Clear => {
|
||||
let _ = self.chat_widget.clear_conversation_history();
|
||||
}
|
||||
SlashCommand::Quit => {
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
|
||||
@@ -22,4 +22,8 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Latest formatted log line emitted by `tracing`.
|
||||
LatestLog(String),
|
||||
|
||||
/// Dispatch a recognized slash command from the UI (composer) to the app
|
||||
/// layer so it can be handled centrally.
|
||||
DispatchCommand(crate::slash_command::SlashCommand),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ use tui_textarea::Input;
|
||||
use tui_textarea::Key;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
|
||||
use super::command_popup::CommandPopup;
|
||||
|
||||
/// Minimum number of visible text rows inside the textarea.
|
||||
const MIN_TEXTAREA_ROWS: usize = 1;
|
||||
/// Rows consumed by the border.
|
||||
@@ -26,15 +32,21 @@ pub enum InputResult {
|
||||
|
||||
pub(crate) struct ChatComposer<'a> {
|
||||
textarea: TextArea<'a>,
|
||||
command_popup: Option<CommandPopup>,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl ChatComposer<'_> {
|
||||
pub fn new(has_input_focus: bool) -> Self {
|
||||
pub fn new(has_input_focus: bool, app_event_tx: Sender<AppEvent>) -> Self {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.set_placeholder_text("send a message");
|
||||
textarea.set_cursor_line_style(ratatui::style::Style::default());
|
||||
|
||||
let mut this = Self { textarea };
|
||||
let mut this = Self {
|
||||
textarea,
|
||||
command_popup: None,
|
||||
app_event_tx,
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
}
|
||||
@@ -43,9 +55,117 @@ impl ChatComposer<'_> {
|
||||
self.update_border(has_focus);
|
||||
}
|
||||
|
||||
/// Handle key event when no overlay is present.
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
fn sync_command_popup(&mut self) {
|
||||
// Inspect only the first line to decide whether to show the popup. In
|
||||
// the common case (no leading slash) we avoid copying the entire
|
||||
// textarea contents.
|
||||
let first_line = self
|
||||
.textarea
|
||||
.lines()
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if first_line.starts_with('/') {
|
||||
// Create popup lazily when the user starts a slash command.
|
||||
let popup = self.command_popup.get_or_insert_with(CommandPopup::new);
|
||||
|
||||
// Forward *only* the first line since `CommandPopup` only needs
|
||||
// the command token.
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
} else {
|
||||
// Remove popup when '/' is no longer the first character.
|
||||
self.command_popup = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event coming from the main UI.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let result = match self.command_popup {
|
||||
Some(_) => self.handle_key_event_with_popup(key_event),
|
||||
None => self.handle_key_event_without_popup(key_event),
|
||||
};
|
||||
|
||||
// Update (or hide/show) popup after processing the key.
|
||||
self.sync_command_popup();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Handle key event when the slash-command popup is visible.
|
||||
fn handle_key_event_with_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let Some(popup) = self.command_popup.as_mut() else {
|
||||
tracing::error!("handle_key_event_with_popup called without an active popup");
|
||||
return (InputResult::None, false);
|
||||
};
|
||||
|
||||
match key_event.into() {
|
||||
Input { key: Key::Up, .. } => {
|
||||
popup.move_up();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input { key: Key::Down, .. } => {
|
||||
popup.move_down();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input { key: Key::Tab, .. } => {
|
||||
if let Some(cmd) = popup.selected_command() {
|
||||
let first_line = self
|
||||
.textarea
|
||||
.lines()
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let starts_with_cmd = first_line
|
||||
.trim_start()
|
||||
.starts_with(&format!("/{}", cmd.command()));
|
||||
|
||||
if !starts_with_cmd {
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
let _ = self.textarea.insert_str(format!("/{} ", cmd.command()));
|
||||
}
|
||||
|
||||
// hide popup
|
||||
self.command_popup = None;
|
||||
}
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input {
|
||||
key: Key::Enter,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
} => {
|
||||
if let Some(cmd) = popup.selected_command() {
|
||||
// Send command to the app layer.
|
||||
if let Err(e) = self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)) {
|
||||
tracing::error!("failed to send DispatchCommand event: {e}");
|
||||
}
|
||||
|
||||
// Clear textarea so no residual text remains.
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
|
||||
// Hide popup since the command has been dispatched.
|
||||
self.command_popup = None;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// Fallback to default newline handling if no command selected.
|
||||
self.handle_key_event_without_popup(key_event)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let input: Input = key_event.into();
|
||||
match input {
|
||||
Input {
|
||||
key: Key::Enter,
|
||||
shift: false,
|
||||
@@ -69,16 +189,25 @@ impl ChatComposer<'_> {
|
||||
self.textarea.insert_newline();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
input => {
|
||||
self.textarea.input(input);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle generic Input events that modify the textarea content.
|
||||
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) {
|
||||
self.textarea.input(input);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
pub fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||||
let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS);
|
||||
rows as u16 + BORDER_LINES
|
||||
let mut total = rows as u16 + BORDER_LINES;
|
||||
|
||||
if let Some(popup) = &self.command_popup {
|
||||
total += popup.calculate_required_height(_area);
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
fn update_border(&mut self, has_focus: bool) {
|
||||
@@ -108,10 +237,37 @@ impl ChatComposer<'_> {
|
||||
.border_style(bs.border_style),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn is_command_popup_visible(&self) -> bool {
|
||||
self.command_popup.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatComposer<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.textarea.render(area, buf);
|
||||
if let Some(popup) = &self.command_popup {
|
||||
let popup_height = popup.calculate_required_height(&area);
|
||||
|
||||
// Split the provided rect so that the popup is rendered at the
|
||||
// *top* and the textarea occupies the remaining space below.
|
||||
let popup_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: popup_height.min(area.height),
|
||||
};
|
||||
|
||||
let textarea_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y + popup_rect.height,
|
||||
width: area.width,
|
||||
height: area.height.saturating_sub(popup_rect.height),
|
||||
};
|
||||
|
||||
popup.render(popup_rect, buf);
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
} else {
|
||||
self.textarea.render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
codex-rs/tui/src/bottom_pane/command_popup.rs
Normal file
192
codex-rs/tui/src/bottom_pane/command_popup.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
|
||||
const MAX_POPUP_ROWS: usize = 5;
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
pub(crate) struct CommandPopup {
|
||||
command_filter: String,
|
||||
all_commands: HashMap<&'static str, SlashCommand>,
|
||||
selected_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
command_filter: String::new(),
|
||||
all_commands: built_in_slash_commands(),
|
||||
selected_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let matches_len = self.filtered_commands().len();
|
||||
self.selected_idx = match matches_len {
|
||||
0 => None,
|
||||
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Determine the preferred height of the popup. This is the number of
|
||||
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
|
||||
/// table/border overhead (one line at the top and one at the bottom).
|
||||
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||||
let matches = self.filtered_commands();
|
||||
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
|
||||
// Account for the border added by the Block that wraps the table.
|
||||
// 2 = one line at the top, one at the bottom.
|
||||
row_count + 2
|
||||
}
|
||||
|
||||
/// Return the list of commands that match the current filter. Matching is
|
||||
/// performed using a *prefix* comparison on the command name.
|
||||
fn filtered_commands(&self) -> Vec<&SlashCommand> {
|
||||
let mut cmds: Vec<&SlashCommand> = self
|
||||
.all_commands
|
||||
.values()
|
||||
.filter(|cmd| {
|
||||
if self.command_filter.is_empty() {
|
||||
true
|
||||
} else {
|
||||
cmd.command()
|
||||
.starts_with(&self.command_filter.to_ascii_lowercase())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort the commands alphabetically so the order is stable and
|
||||
// predictable.
|
||||
cmds.sort_by(|a, b| a.command().cmp(b.command()));
|
||||
cmds
|
||||
}
|
||||
|
||||
/// Move the selection cursor one step up.
|
||||
pub(crate) fn move_up(&mut self) {
|
||||
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
|
||||
if len == usize::MAX {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(idx) = self.selected_idx {
|
||||
if idx > 0 {
|
||||
self.selected_idx = Some(idx - 1);
|
||||
}
|
||||
} else if !self.filtered_commands().is_empty() {
|
||||
self.selected_idx = Some(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the selection cursor one step down.
|
||||
pub(crate) fn move_down(&mut self) {
|
||||
let matches_len = self.filtered_commands().len();
|
||||
if matches_len == 0 {
|
||||
self.selected_idx = None;
|
||||
return;
|
||||
}
|
||||
|
||||
match self.selected_idx {
|
||||
Some(idx) if idx + 1 < matches_len => {
|
||||
self.selected_idx = Some(idx + 1);
|
||||
}
|
||||
None => {
|
||||
self.selected_idx = Some(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return currently selected command, if any.
|
||||
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
|
||||
let matches = self.filtered_commands();
|
||||
self.selected_idx.and_then(|idx| matches.get(idx).copied())
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for CommandPopup {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Cell;
|
||||
use ratatui::widgets::Row;
|
||||
use ratatui::widgets::Table;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
let style = Style::default().bg(Color::Blue).fg(Color::White);
|
||||
|
||||
let matches = self.filtered_commands();
|
||||
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
let visible_matches: Vec<&SlashCommand> =
|
||||
matches.into_iter().take(MAX_POPUP_ROWS).collect();
|
||||
|
||||
if visible_matches.is_empty() {
|
||||
rows.push(Row::new(vec![
|
||||
Cell::from("").style(style),
|
||||
Cell::from("No matching commands").style(style.add_modifier(Modifier::ITALIC)),
|
||||
]));
|
||||
} else {
|
||||
for (idx, cmd) in visible_matches.iter().enumerate() {
|
||||
let highlight = Style::default().bg(Color::White).fg(Color::Blue);
|
||||
let cmd_style = if Some(idx) == self.selected_idx {
|
||||
highlight
|
||||
} else {
|
||||
style
|
||||
};
|
||||
|
||||
rows.push(Row::new(vec![
|
||||
Cell::from(cmd.command().to_string()).style(cmd_style),
|
||||
Cell::from(cmd.description().to_string()).style(style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
use ratatui::layout::Constraint;
|
||||
|
||||
let table = Table::new(rows, [Constraint::Length(15), Constraint::Min(10)])
|
||||
.style(style)
|
||||
.column_spacing(1)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.style(style),
|
||||
);
|
||||
|
||||
table.render(area, buf);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use crate::user_approval_widget::ApprovalRequest;
|
||||
mod approval_modal_view;
|
||||
mod bottom_pane_view;
|
||||
mod chat_composer;
|
||||
mod command_popup;
|
||||
mod status_indicator_view;
|
||||
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
@@ -45,7 +46,7 @@ pub(crate) struct BottomPaneParams {
|
||||
impl BottomPane<'_> {
|
||||
pub fn new(params: BottomPaneParams) -> Self {
|
||||
Self {
|
||||
composer: ChatComposer::new(params.has_input_focus),
|
||||
composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()),
|
||||
active_view: None,
|
||||
app_event_tx: params.app_event_tx,
|
||||
has_input_focus: params.has_input_focus,
|
||||
@@ -168,6 +169,11 @@ impl BottomPane<'_> {
|
||||
pub(crate) fn request_redraw(&self) -> Result<(), SendError<AppEvent>> {
|
||||
self.app_event_tx.send(AppEvent::Redraw)
|
||||
}
|
||||
|
||||
/// Returns true when the slash-command popup inside the composer is visible.
|
||||
pub(crate) fn is_command_popup_visible(&self) -> bool {
|
||||
self.active_view.is_none() && self.composer.is_command_popup_visible()
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
|
||||
@@ -124,8 +124,12 @@ impl ChatWidget<'_> {
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
// Special-case <tab>: does not get dispatched to child components.
|
||||
if matches!(key_event.code, crossterm::event::KeyCode::Tab) {
|
||||
// Special-case <Tab>: normally toggles focus between history and bottom panes.
|
||||
// However, when the slash-command popup is visible we forward the key
|
||||
// to the bottom pane so it can handle auto-completion.
|
||||
if matches!(key_event.code, crossterm::event::KeyCode::Tab)
|
||||
&& !self.bottom_pane.is_command_popup_visible()
|
||||
{
|
||||
self.input_focus = match self.input_focus {
|
||||
InputFocus::HistoryPane => InputFocus::BottomPane,
|
||||
InputFocus::BottomPane => InputFocus::HistoryPane,
|
||||
@@ -149,18 +153,7 @@ impl ChatWidget<'_> {
|
||||
InputFocus::BottomPane => {
|
||||
match self.bottom_pane.handle_key_event(key_event)? {
|
||||
InputResult::Submitted(text) => {
|
||||
// Special client‑side commands start with a leading slash.
|
||||
let trimmed = text.trim();
|
||||
match trimmed {
|
||||
"/clear" => {
|
||||
// Clear the current conversation history without exiting.
|
||||
self.conversation_history.clear();
|
||||
self.request_redraw()?;
|
||||
}
|
||||
_ => {
|
||||
self.submit_user_message(text)?;
|
||||
}
|
||||
}
|
||||
self.submit_user_message(text)?;
|
||||
}
|
||||
InputResult::None => {}
|
||||
}
|
||||
@@ -211,6 +204,13 @@ impl ChatWidget<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_conversation_history(
|
||||
&mut self,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
self.conversation_history.clear();
|
||||
self.request_redraw()
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(
|
||||
&mut self,
|
||||
event: Event,
|
||||
|
||||
@@ -26,6 +26,7 @@ mod history_cell;
|
||||
mod log_layer;
|
||||
mod markdown;
|
||||
mod scroll_event_helper;
|
||||
mod slash_command;
|
||||
mod status_indicator_widget;
|
||||
mod tui;
|
||||
mod user_approval_widget;
|
||||
|
||||
38
codex-rs/tui/src/slash_command.rs
Normal file
38
codex-rs/tui/src/slash_command.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::AsRefStr;
|
||||
use strum_macros::EnumIter;
|
||||
use strum_macros::EnumString;
|
||||
|
||||
/// Commands that can be invoked by starting a message with a leading slash.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
pub enum SlashCommand {
|
||||
Clear,
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl SlashCommand {
|
||||
/// User-visible description shown in the popup.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::Clear => "Clear the chat history.",
|
||||
SlashCommand::Quit => "Exit the application.",
|
||||
}
|
||||
}
|
||||
|
||||
/// Command string without the leading '/'. Provided for compatibility with
|
||||
/// existing code that expects a method named `command()`.
|
||||
pub fn command(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::Clear => "clear",
|
||||
SlashCommand::Quit => "quit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all built-in commands in a HashMap keyed by their command string.
|
||||
pub fn built_in_slash_commands() -> HashMap<&'static str, SlashCommand> {
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
}
|
||||
Reference in New Issue
Block a user