Skip to content

Commit d2bfdd5

Browse files
charley-oaicodex
andcommitted
tui: make slash parsing command-owned
Co-authored-by: Codex <noreply@openai.com>
1 parent 8a0c0ee commit d2bfdd5

6 files changed

Lines changed: 450 additions & 72 deletions

File tree

codex-rs/tui/src/chatwidget.rs

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ use crate::render::renderable::Renderable;
272272
use crate::render::renderable::RenderableExt;
273273
use crate::render::renderable::RenderableItem;
274274
use crate::slash_command::SlashCommand;
275+
use crate::slash_command_input::FastSlashCommandArgs;
276+
use crate::slash_command_input::ParsedSlashCommand;
275277
use crate::status::RateLimitSnapshotDisplay;
276278
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
277279
use crate::status_indicator_widget::StatusDetailsCapitalization;
@@ -4725,34 +4727,25 @@ impl ChatWidget {
47254727
return;
47264728
}
47274729

4728-
let trimmed = args.trim();
4729-
if trimmed.is_empty() {
4730-
self.dispatch_command(cmd);
4731-
return;
4732-
}
4733-
match cmd {
4734-
SlashCommand::Fast => {
4735-
match trimmed.to_ascii_lowercase().as_str() {
4736-
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
4737-
"off" => self.set_service_tier_selection(/*service_tier*/ None),
4738-
"status" => {
4739-
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
4740-
{
4741-
"on"
4742-
} else {
4743-
"off"
4744-
};
4745-
self.add_info_message(
4746-
format!("Fast mode is {status}."),
4747-
/*hint*/ None,
4748-
);
4749-
}
4750-
_ => {
4751-
self.add_error_message("Usage: /fast [on|off|status]".to_string());
4752-
}
4753-
}
4730+
match cmd.parse_invocation(&args) {
4731+
Ok(ParsedSlashCommand::Bare(_)) => {
4732+
self.dispatch_command(cmd);
4733+
}
4734+
Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::On)) => {
4735+
self.set_service_tier_selection(Some(ServiceTier::Fast));
47544736
}
4755-
SlashCommand::Rename if !trimmed.is_empty() => {
4737+
Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Off)) => {
4738+
self.set_service_tier_selection(/*service_tier*/ None);
4739+
}
4740+
Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Status)) => {
4741+
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
4742+
"on"
4743+
} else {
4744+
"off"
4745+
};
4746+
self.add_info_message(format!("Fast mode is {status}."), /*hint*/ None);
4747+
}
4748+
Ok(ParsedSlashCommand::Rename) => {
47564749
self.session_telemetry
47574750
.counter("codex.thread.rename", /*inc*/ 1, &[]);
47584751
let Some((prepared_args, _prepared_elements)) = self
@@ -4772,7 +4765,7 @@ impl ChatWidget {
47724765
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
47734766
self.bottom_pane.drain_pending_submission_state();
47744767
}
4775-
SlashCommand::Plan if !trimmed.is_empty() => {
4768+
Ok(ParsedSlashCommand::Plan) => {
47764769
self.dispatch_command(cmd);
47774770
if self.active_mode_kind() != ModeKind::Plan {
47784771
return;
@@ -4803,7 +4796,7 @@ impl ChatWidget {
48034796
self.queue_user_message(user_message);
48044797
}
48054798
}
4806-
SlashCommand::Review if !trimmed.is_empty() => {
4799+
Ok(ParsedSlashCommand::Review) => {
48074800
let Some((prepared_args, _prepared_elements)) = self
48084801
.bottom_pane
48094802
.prepare_inline_args_submission(/*record_history*/ false)
@@ -4820,7 +4813,7 @@ impl ChatWidget {
48204813
});
48214814
self.bottom_pane.drain_pending_submission_state();
48224815
}
4823-
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
4816+
Ok(ParsedSlashCommand::SandboxReadRoot) => {
48244817
let Some((prepared_args, _prepared_elements)) = self
48254818
.bottom_pane
48264819
.prepare_inline_args_submission(/*record_history*/ false)
@@ -4833,12 +4826,8 @@ impl ChatWidget {
48334826
});
48344827
self.bottom_pane.drain_pending_submission_state();
48354828
}
4836-
_ => {
4837-
let usage = cmd.usage_lines().join(" | ");
4838-
self.add_error_message(format!(
4839-
"'/{}' does not accept inline arguments. Usage: {usage}",
4840-
cmd.command()
4841-
));
4829+
Err(err) => {
4830+
self.add_error_message(err.message());
48424831
}
48434832
}
48444833
}

codex-rs/tui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ mod session_log;
122122
mod shimmer;
123123
mod skills_helpers;
124124
mod slash_command;
125+
mod slash_command_input;
125126
mod slash_command_invocation;
126127
mod status;
127128
mod status_indicator_widget;
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
use crate::slash_command::SlashCommand;
2+
3+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4+
pub(crate) enum FastSlashCommandArgs {
5+
On,
6+
Off,
7+
Status,
8+
}
9+
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub(crate) enum SlashCommandBareBehavior {
12+
DispatchesDirectly,
13+
OpensUi,
14+
}
15+
16+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17+
pub(crate) enum ParsedSlashCommand {
18+
Bare(SlashCommandBareBehavior),
19+
Fast(FastSlashCommandArgs),
20+
Rename,
21+
Plan,
22+
Review,
23+
SandboxReadRoot,
24+
}
25+
26+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+
pub(crate) enum SlashCommandUsageErrorKind {
28+
UnexpectedInlineArgs,
29+
InvalidInlineArgs,
30+
}
31+
32+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33+
pub(crate) struct SlashCommandUsageError {
34+
command: SlashCommand,
35+
kind: SlashCommandUsageErrorKind,
36+
}
37+
38+
impl SlashCommandUsageError {
39+
pub(crate) fn message(self) -> String {
40+
let usage = self.command.usage_lines().join(" | ");
41+
match self.kind {
42+
SlashCommandUsageErrorKind::UnexpectedInlineArgs => format!(
43+
"'/{}' does not accept inline arguments. Usage: {usage}",
44+
self.command.command()
45+
),
46+
SlashCommandUsageErrorKind::InvalidInlineArgs => format!("Usage: {usage}"),
47+
}
48+
}
49+
}
50+
51+
impl SlashCommand {
52+
pub(crate) fn bare_behavior(self) -> SlashCommandBareBehavior {
53+
match self {
54+
SlashCommand::Model
55+
| SlashCommand::Approvals
56+
| SlashCommand::Permissions
57+
| SlashCommand::Experimental
58+
| SlashCommand::Skills
59+
| SlashCommand::Review
60+
| SlashCommand::Rename
61+
| SlashCommand::Resume
62+
| SlashCommand::Collab
63+
| SlashCommand::Agent
64+
| SlashCommand::Statusline
65+
| SlashCommand::Theme
66+
| SlashCommand::Feedback
67+
| SlashCommand::Personality
68+
| SlashCommand::Settings
69+
| SlashCommand::MultiAgents => SlashCommandBareBehavior::OpensUi,
70+
SlashCommand::Fast
71+
| SlashCommand::ElevateSandbox
72+
| SlashCommand::SandboxReadRoot
73+
| SlashCommand::New
74+
| SlashCommand::Fork
75+
| SlashCommand::Init
76+
| SlashCommand::Compact
77+
| SlashCommand::Plan
78+
| SlashCommand::Diff
79+
| SlashCommand::Copy
80+
| SlashCommand::Mention
81+
| SlashCommand::Status
82+
| SlashCommand::DebugConfig
83+
| SlashCommand::Title
84+
| SlashCommand::Mcp
85+
| SlashCommand::Apps
86+
| SlashCommand::Logout
87+
| SlashCommand::Quit
88+
| SlashCommand::Exit
89+
| SlashCommand::Rollout
90+
| SlashCommand::Ps
91+
| SlashCommand::Stop
92+
| SlashCommand::Clear
93+
| SlashCommand::Realtime
94+
| SlashCommand::TestApproval
95+
| SlashCommand::MemoryDrop
96+
| SlashCommand::MemoryUpdate => SlashCommandBareBehavior::DispatchesDirectly,
97+
}
98+
}
99+
100+
pub(crate) fn parse_invocation(
101+
self,
102+
args: &str,
103+
) -> Result<ParsedSlashCommand, SlashCommandUsageError> {
104+
let trimmed = args.trim();
105+
if trimmed.is_empty() {
106+
return Ok(ParsedSlashCommand::Bare(self.bare_behavior()));
107+
}
108+
109+
match self {
110+
SlashCommand::Fast => match trimmed.to_ascii_lowercase().as_str() {
111+
"on" => Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::On)),
112+
"off" => Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Off)),
113+
"status" => Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Status)),
114+
_ => Err(SlashCommandUsageError {
115+
command: self,
116+
kind: SlashCommandUsageErrorKind::InvalidInlineArgs,
117+
}),
118+
},
119+
SlashCommand::Rename => Ok(ParsedSlashCommand::Rename),
120+
SlashCommand::Plan => Ok(ParsedSlashCommand::Plan),
121+
SlashCommand::Review => Ok(ParsedSlashCommand::Review),
122+
SlashCommand::SandboxReadRoot => Ok(ParsedSlashCommand::SandboxReadRoot),
123+
SlashCommand::Model
124+
| SlashCommand::Approvals
125+
| SlashCommand::Permissions
126+
| SlashCommand::ElevateSandbox
127+
| SlashCommand::Experimental
128+
| SlashCommand::Skills
129+
| SlashCommand::New
130+
| SlashCommand::Resume
131+
| SlashCommand::Fork
132+
| SlashCommand::Init
133+
| SlashCommand::Compact
134+
| SlashCommand::Collab
135+
| SlashCommand::Agent
136+
| SlashCommand::Diff
137+
| SlashCommand::Copy
138+
| SlashCommand::Mention
139+
| SlashCommand::Status
140+
| SlashCommand::DebugConfig
141+
| SlashCommand::Title
142+
| SlashCommand::Statusline
143+
| SlashCommand::Theme
144+
| SlashCommand::Mcp
145+
| SlashCommand::Apps
146+
| SlashCommand::Logout
147+
| SlashCommand::Quit
148+
| SlashCommand::Exit
149+
| SlashCommand::Feedback
150+
| SlashCommand::Rollout
151+
| SlashCommand::Ps
152+
| SlashCommand::Stop
153+
| SlashCommand::Clear
154+
| SlashCommand::Personality
155+
| SlashCommand::Realtime
156+
| SlashCommand::Settings
157+
| SlashCommand::TestApproval
158+
| SlashCommand::MultiAgents
159+
| SlashCommand::MemoryDrop
160+
| SlashCommand::MemoryUpdate => Err(SlashCommandUsageError {
161+
command: self,
162+
kind: SlashCommandUsageErrorKind::UnexpectedInlineArgs,
163+
}),
164+
}
165+
}
166+
}
167+
168+
#[cfg(test)]
169+
mod tests {
170+
use pretty_assertions::assert_eq;
171+
172+
use super::*;
173+
174+
#[test]
175+
fn review_bare_form_is_marked_as_ui_driven() {
176+
assert_eq!(
177+
SlashCommand::Review.parse_invocation(""),
178+
Ok(ParsedSlashCommand::Bare(SlashCommandBareBehavior::OpensUi))
179+
);
180+
}
181+
182+
#[test]
183+
fn fast_accepts_nonempty_inline_args() {
184+
assert_eq!(
185+
SlashCommand::Fast.parse_invocation("status"),
186+
Ok(ParsedSlashCommand::Fast(FastSlashCommandArgs::Status))
187+
);
188+
}
189+
190+
#[test]
191+
fn clear_rejects_unexpected_inline_args() {
192+
assert_eq!(
193+
SlashCommand::Clear
194+
.parse_invocation("now")
195+
.unwrap_err()
196+
.message(),
197+
"'/clear' does not accept inline arguments. Usage: /clear"
198+
);
199+
}
200+
}

0 commit comments

Comments
 (0)