Skip to main content

forge_workspace/
prompt.rs

1//! What a composer's text means at forge's own boundary.
2//!
3//! One rule, in one place: text that names a forge command is forge's, not
4//! the `claude` CLI's. A view reaches these commands because the decision is
5//! made where the view dispatches, since some of these names the CLI does not
6//! have at all and `/new` it answers as a different command.
7
8/// A forge command a composer can invoke, whatever view it was typed at.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum ForgePrompt {
11    /// `/new`: replace the seat's occupant with a fresh session.
12    NewSession,
13    /// `/resume <session_id>`: move the seat onto a session that already
14    /// exists.
15    ResumeSession { session_id: String },
16    /// `/handoff`: hand the seat over to a fresh session that continues from
17    /// a brief. One of the two entries, beside the automatic trigger (fill or
18    /// compaction); the `agents__handoff` call is the exit inside the handoff
19    /// turn, never a way in.
20    Handoff,
21    /// `/mode <id>`: switch the session's permission mode.
22    SetMode { mode: String },
23    /// `/mode`: what permission mode the session is on, as the core observed
24    /// it.
25    ShowMode,
26    /// `/effort`: what thinking effort the session is running at, as the core
27    /// observed it.
28    ShowEffort,
29    /// `/model <id>`: switch the model the session runs on.
30    SetModel { model: String },
31    /// `/effort <level>`: set the thinking effort the next launch carries.
32    SetEffort { level: String },
33}
34
35/// What one of forge's command names was asked to do.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Invocation {
38    /// Invoked the way the command takes it.
39    Command(ForgePrompt),
40    /// Invoked some other way, with the line the command answers instead of
41    /// acting. Answering is what keeps a mistyped command from being read as
42    /// prose by the model.
43    Misuse(&'static str),
44}
45
46impl ForgePrompt {
47    /// Whether running this replaces the seat's occupant.
48    ///
49    /// The slow ones, and the only ones a view blocks its input for: what
50    /// clears that block is the replacement arriving. Every other command
51    /// answers where it was asked, so a view that blocked on one would leave
52    /// the input disabled with nothing coming to release it.
53    pub const fn respawns(&self) -> bool {
54        matches!(self, Self::NewSession | Self::ResumeSession { .. } | Self::Handoff)
55    }
56}
57
58/// The names forge answers, and the line each answers a wrong invocation
59/// with. The one place a name is written down.
60const USAGE: &[(&str, &str)] = &[
61    ("/new", "Usage: /new"),
62    ("/resume", "Usage: /resume <session_id>"),
63    ("/handoff", "Usage: /handoff"),
64    ("/mode", "Usage: /mode <id>"),
65    ("/model", "Usage: /model <id>"),
66    ("/effort", "Usage: /effort <level>"),
67];
68
69/// The names the CLI resolves itself though it does not advertise them, so a
70/// view forwards them rather than answering or refusing.
71///
72/// Measured on 2.1.280 under forge's own argv: `/help`, `/plugins` and
73/// `/quit` each come back as a local line ("isn't available in this
74/// environment") with no model call, where a name the CLI does not know at
75/// all is sent to the model as a question.
76///
77/// `/mcp` is the fourth and the one the measurement did not have to establish:
78/// the CLI advertises it, so the catalogue covers it for a client, and it is
79/// here because it is a retired forge name that is forwarded rather than
80/// answered by the core. A name in both sets is forwarded either way.
81pub const FORWARDED: [&str; 4] = ["/help", "/mcp", "/plugins", "/quit"];
82
83/// Every name forge answers, which a view reads to decide to forward rather
84/// than refuse.
85pub fn forge_prompt_names() -> impl Iterator<Item = &'static str> {
86    USAGE.iter().map(|(name, _)| *name)
87}
88
89/// Whether `name` is one of forge's command names, invoked well or not.
90pub fn is_forge_prompt_name(name: &str) -> bool {
91    usage_for(name).is_some()
92}
93
94/// Whether the CLI resolves `name` itself, advertised or not.
95pub fn is_forwarded_name(name: &str) -> bool {
96    FORWARDED.contains(&name)
97}
98
99/// The forge command `text` invokes, or `None` when the text is the reader's
100/// own prose.
101///
102/// The name has to be a whole word: `/newer` is a message to the model rather
103/// than a near-miss of `/new`.
104pub fn forge_invocation(text: &str) -> Option<Invocation> {
105    let mut words = text.split_whitespace();
106    let name = words.next()?;
107    let arguments: Vec<&str> = words.collect();
108    let command = match (name, arguments.as_slice()) {
109        ("/new", []) => Some(ForgePrompt::NewSession),
110        ("/resume", [arg]) => Some(ForgePrompt::ResumeSession { session_id: (*arg).to_owned() }),
111        ("/handoff", []) => Some(ForgePrompt::Handoff),
112        ("/mode", []) => Some(ForgePrompt::ShowMode),
113        ("/mode", [arg]) => Some(ForgePrompt::SetMode { mode: (*arg).to_owned() }),
114        ("/model", [arg]) => Some(ForgePrompt::SetModel { model: (*arg).to_owned() }),
115        ("/effort", []) => Some(ForgePrompt::ShowEffort),
116        ("/effort", [arg]) => Some(ForgePrompt::SetEffort { level: (*arg).to_owned() }),
117        _ => None,
118    };
119    match command {
120        Some(command) => Some(Invocation::Command(command)),
121        None => usage_for(name).map(Invocation::Misuse),
122    }
123}
124
125fn usage_for(name: &str) -> Option<&'static str> {
126    USAGE.iter().find(|(known, _)| *known == name).map(|(_, usage)| *usage)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn a_lone_command_name_is_forges() {
135        assert_eq!(forge_invocation("/new"), Some(Invocation::Command(ForgePrompt::NewSession)));
136        assert_eq!(
137            forge_invocation("  /new  "),
138            Some(Invocation::Command(ForgePrompt::NewSession)),
139        );
140    }
141
142    #[test]
143    fn a_command_with_its_argument_carries_it() {
144        let named = |session_id: &str| {
145            Some(Invocation::Command(ForgePrompt::ResumeSession {
146                session_id: session_id.to_owned(),
147            }))
148        };
149        assert_eq!(forge_invocation("/resume 7f3a92e0"), named("7f3a92e0"));
150        assert_eq!(
151            forge_invocation("  /resume  7f3a92e0  "),
152            named("7f3a92e0"),
153            "the argument is read the way a shell would read it",
154        );
155    }
156
157    /// `/handoff` takes no argument, replaces the occupant, and answers a
158    /// worded invocation with its usage rather than sending it to the model.
159    #[test]
160    fn handoff_is_a_respawn_with_no_argument() {
161        assert_eq!(forge_invocation("/handoff"), Some(Invocation::Command(ForgePrompt::Handoff)));
162        assert!(ForgePrompt::Handoff.respawns(), "the swap replaces the occupant");
163        assert_eq!(
164            forge_invocation("/handoff now"),
165            Some(Invocation::Misuse("Usage: /handoff")),
166            "a brief is not an argument the command takes",
167        );
168    }
169
170    /// A forge name invoked some other way is still forge's: the reader gets
171    /// the command's own usage line rather than a question to the model.
172    #[test]
173    fn a_wrong_invocation_is_answered_with_its_usage() {
174        assert_eq!(forge_invocation("/new session"), Some(Invocation::Misuse("Usage: /new")),);
175        assert_eq!(
176            forge_invocation("/resume"),
177            Some(Invocation::Misuse("Usage: /resume <session_id>")),
178        );
179        assert_eq!(
180            forge_invocation("/resume two ids"),
181            Some(Invocation::Misuse("Usage: /resume <session_id>")),
182        );
183        assert_eq!(
184            forge_invocation("/new\nsecond line"),
185            Some(Invocation::Misuse("Usage: /new")),
186            "a command with words after it is answered, not read as prose",
187        );
188    }
189
190    /// **A bare `/mode` or `/effort` asks what the value is**, rather than
191    /// being a worded invocation: the core holds the observed value, so it
192    /// answers with that instead of reciting the usage line (#1626).
193    #[test]
194    fn a_bare_mode_or_effort_asks_for_the_value_in_force() {
195        assert_eq!(forge_invocation("/mode"), Some(Invocation::Command(ForgePrompt::ShowMode)));
196        assert_eq!(
197            forge_invocation("  /effort  "),
198            Some(Invocation::Command(ForgePrompt::ShowEffort)),
199        );
200        // The setters still carry their argument, and a second word is still a
201        // misuse: only the bare form changed meaning.
202        assert_eq!(
203            forge_invocation("/mode plan"),
204            Some(Invocation::Command(ForgePrompt::SetMode { mode: "plan".to_owned() })),
205        );
206        assert_eq!(
207            forge_invocation("/effort high low"),
208            Some(Invocation::Misuse("Usage: /effort <level>")),
209        );
210    }
211
212    /// Prose that starts like a command is the reader's own text.
213    #[test]
214    fn anything_but_a_whole_command_word_is_prose() {
215        for text in ["/newer", "hello /new", "/NEW", "/spinner", "hello", "/ne"] {
216            assert_eq!(forge_invocation(text), None, "{text:?} is not a forge command");
217        }
218    }
219
220    /// Every name the table offers has an invocation the parse answers.
221    ///
222    /// The guardian of the pair a view and the core each read: a view forwards
223    /// on the NAME while the core acts on the parsed INVOCATION, so a name
224    /// with no parse arm is one every view offers and nothing can run - it
225    /// would be answered with its own usage line forever. The examples are
226    /// here rather than derived, because the arity of each command is the
227    /// thing being pinned.
228    #[test]
229    fn every_name_the_table_offers_can_be_run() {
230        let examples = [
231            ("/new", "/new"),
232            ("/resume", "/resume 7f3a92e0"),
233            ("/handoff", "/handoff"),
234            ("/mode", "/mode plan"),
235            ("/model", "/model sonnet"),
236            ("/effort", "/effort high"),
237        ];
238        for name in forge_prompt_names() {
239            let Some((_, example)) = examples.iter().find(|(known, _)| *known == name) else {
240                panic!("{name} is offered with no example here, so nothing pins that it runs");
241            };
242            assert!(
243                matches!(forge_invocation(example), Some(Invocation::Command(_))),
244                "{name} is offered and cannot be run",
245            );
246            assert!(is_forge_prompt_name(name), "{name} is a name a view forwards on");
247        }
248    }
249
250    /// **The client's answered and forwarded sets mirror this file.** The
251    /// composer decides whether to post its pending echo from `coreAnswers`;
252    /// a name in one set and not the other leaves a row nothing clears, or
253    /// swallows a prompt the CLI would have run.
254    #[test]
255    fn the_clients_answer_sets_match_this_files() {
256        const CLIENT: &str =
257            concat!(env!("CARGO_MANIFEST_DIR"), "/../../client/src/composer/forge-commands.ts");
258        let source = std::fs::read_to_string(CLIENT)
259            .unwrap_or_else(|error| panic!("the client's forge commands are readable: {error}"));
260
261        let set = |marker: &str| -> Vec<String> {
262            let start = source
263                .find(marker)
264                .unwrap_or_else(|| panic!("the client still declares {marker:?}"));
265            let block = &source[start + marker.len()..];
266            let end = block
267                .find("]);")
268                .unwrap_or_else(|| panic!("the client's set closes after its entries"));
269            let mut names: Vec<String> = block[..end]
270                .split(',')
271                .map(|entry| entry.trim().trim_matches('\'').trim_matches('"').to_owned())
272                .filter(|name| !name.is_empty())
273                .collect();
274            names.sort();
275            names
276        };
277
278        let mut answered: Vec<String> = forge_prompt_names().map(str::to_owned).collect();
279        answered.sort();
280        assert_eq!(
281            set("const ANSWERED = new Set(["),
282            answered,
283            "the client answers exactly the names this file does",
284        );
285        let mut forwarded: Vec<String> = FORWARDED.iter().map(|name| (*name).to_owned()).collect();
286        forwarded.sort();
287        assert_eq!(
288            set("const FORWARDED = new Set(["),
289            forwarded,
290            "and forwards exactly the names this file forwards",
291        );
292    }
293}