Skip to main content

aft/commands/
bash_write.rs

1use crate::commands::bash_status::format_unknown_task_message;
2use crate::context::AppContext;
3use crate::protocol::{RawRequest, Response};
4use serde::Deserialize;
5use serde_json::json;
6
7const MAX_INPUT_BYTES: usize = 1_048_576;
8
9/// Input payload accepted by `bash_write`.
10///
11/// Two forms:
12/// * `String` — verbatim bytes written to the PTY. Backward-compatible with the
13///   v0.30 phase 1b shape; existing callers see no change.
14/// * `Sequence` — array of items, each either a plain string (text bytes) or a
15///   `{ "key": "<name>" }` object that expands to a known control-byte sequence
16///   (ESC, arrows, Ctrl chords, function keys, …). Items are concatenated into
17///   one atomic write so the PTY sees the whole sequence as one input chunk.
18///
19/// The agent never has to encode escape characters inside the `input` string —
20/// they use named keys instead. The string form remains the right choice when
21/// the agent wants to write literal `\u001b` characters (e.g. source code).
22#[derive(Debug, Deserialize)]
23#[serde(untagged)]
24pub enum BashWriteInput {
25    Text(String),
26    Sequence(Vec<SequenceItem>),
27}
28
29#[derive(Debug, Deserialize)]
30#[serde(untagged)]
31pub enum SequenceItem {
32    Text(String),
33    Key { key: String },
34}
35
36#[derive(Debug, Deserialize)]
37pub struct BashWriteParams {
38    pub task_id: String,
39    pub input: BashWriteInput,
40}
41
42pub fn handle(req: &RawRequest, ctx: &AppContext) -> Response {
43    let raw_params = req
44        .params
45        .get("params")
46        .cloned()
47        .unwrap_or_else(|| req.params.clone());
48    let params = match serde_json::from_value::<BashWriteParams>(raw_params) {
49        Ok(params) => params,
50        Err(e) => {
51            return Response::error(
52                &req.id,
53                "invalid_request",
54                format!("bash_write: invalid params: {e}"),
55            );
56        }
57    };
58
59    let bytes = match expand_input(&params.input) {
60        Ok(bytes) => bytes,
61        Err(message) => {
62            return Response::error(&req.id, "invalid_request", message);
63        }
64    };
65
66    if bytes.len() > MAX_INPUT_BYTES {
67        return Response::error(
68            &req.id,
69            "input_too_large",
70            "bash_write input exceeds 1 MiB limit",
71        );
72    }
73
74    match ctx
75        .bash_background()
76        .write_pty(&params.task_id, req.session(), &bytes)
77    {
78        Ok(bytes_written) => Response::success(&req.id, json!({ "bytes_written": bytes_written })),
79        Err(code) if code == "task_not_found" => Response::error(
80            &req.id,
81            "task_not_found",
82            format_unknown_task_message(&params.task_id),
83        ),
84        Err(code) if code == "task_not_pty" => Response::error(
85            &req.id,
86            "task_not_pty",
87            format!("background task is not a PTY task: {}", params.task_id),
88        ),
89        Err(code) if code == "task_exited" => Response::error(
90            &req.id,
91            "task_exited",
92            format!("PTY task is no longer running: {}", params.task_id),
93        ),
94        Err(message) => Response::error(&req.id, "write_failed", message),
95    }
96}
97
98fn expand_input(input: &BashWriteInput) -> Result<Vec<u8>, String> {
99    match input {
100        BashWriteInput::Text(s) => Ok(s.as_bytes().to_vec()),
101        BashWriteInput::Sequence(items) => {
102            let mut out: Vec<u8> = Vec::with_capacity(items.len() * 4);
103            for item in items {
104                match item {
105                    SequenceItem::Text(s) => out.extend_from_slice(s.as_bytes()),
106                    SequenceItem::Key { key } => {
107                        let bytes = key_to_bytes(key).ok_or_else(|| {
108                            format!(
109                                "bash_write: unknown key '{key}'; allowed keys: {}",
110                                allowed_keys_hint()
111                            )
112                        })?;
113                        out.extend_from_slice(bytes);
114                    }
115                }
116            }
117            Ok(out)
118        }
119    }
120}
121
122/// Map a named key to the byte sequence a terminal sends when that key is pressed.
123///
124/// Implementation notes:
125/// * Names are lowercased and ASCII-only; case-insensitive matching is done by
126///   lowercasing the caller-supplied name before lookup.
127/// * Control chords `ctrl-a` through `ctrl-z` map programmatically to `0x01..=0x1a`
128///   so we don't have to enumerate all 26.
129/// * Function keys use the xterm sequence variant (DECFNK / linux-console hybrid)
130///   that the vast majority of TUI programs accept.
131/// * Arrow / nav keys use the "normal" cursor-key mode sequence (`ESC [ X`)
132///   rather than application-keypad mode (`ESC O X`). Programs that toggle
133///   application mode (vim with `:set keymodel`) handle both; the normal form
134///   is the safer default.
135fn key_to_bytes(name: &str) -> Option<&'static [u8]> {
136    // Lowercased, hyphen-separated lookup. We allocate only when the input
137    // is already non-canonical (rare on the hot path).
138    let canonical: std::borrow::Cow<'_, str> = if name
139        .chars()
140        .all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit())
141    {
142        std::borrow::Cow::Borrowed(name)
143    } else {
144        std::borrow::Cow::Owned(name.to_ascii_lowercase())
145    };
146
147    static TABLE: &[(&str, &[u8])] = &[
148        // Line / whitespace
149        //
150        // ENTER maps to CR (\r, 0x0D) — the byte a real terminal sends when
151        // the user presses Enter. Cooked-mode programs (shells, REPLs) have
152        // the line discipline translate CR→LF (`icrnl`), so `\r` works for
153        // them too. Raw-mode TUIs (opencode TUI, vim insert mode, fzf, htop)
154        // see `\r` directly and treat it as submit. LF was wrong for the
155        // raw-mode case — opencode TUI would treat `\n` as multi-line input.
156        ("enter", b"\r"),
157        ("return", b"\r"),
158        ("tab", b"\t"),
159        ("space", b" "),
160        ("backspace", b"\x7f"),
161        // Escape
162        ("esc", b"\x1b"),
163        ("escape", b"\x1b"),
164        // Arrows (normal cursor-key mode)
165        ("up", b"\x1b[A"),
166        ("down", b"\x1b[B"),
167        ("right", b"\x1b[C"),
168        ("left", b"\x1b[D"),
169        // Navigation
170        ("home", b"\x1b[H"),
171        ("end", b"\x1b[F"),
172        ("page-up", b"\x1b[5~"),
173        ("page-down", b"\x1b[6~"),
174        ("delete", b"\x1b[3~"),
175        ("insert", b"\x1b[2~"),
176        // Function keys (xterm-style)
177        ("f1", b"\x1bOP"),
178        ("f2", b"\x1bOQ"),
179        ("f3", b"\x1bOR"),
180        ("f4", b"\x1bOS"),
181        ("f5", b"\x1b[15~"),
182        ("f6", b"\x1b[17~"),
183        ("f7", b"\x1b[18~"),
184        ("f8", b"\x1b[19~"),
185        ("f9", b"\x1b[20~"),
186        ("f10", b"\x1b[21~"),
187        ("f11", b"\x1b[23~"),
188        ("f12", b"\x1b[24~"),
189    ];
190
191    if let Some((_, bytes)) = TABLE.iter().find(|(n, _)| *n == canonical.as_ref()) {
192        return Some(bytes);
193    }
194
195    // Ctrl chords: ctrl-a → 0x01 … ctrl-z → 0x1a.
196    if let Some(rest) = canonical.strip_prefix("ctrl-") {
197        if rest.len() == 1 {
198            let c = rest.chars().next().unwrap();
199            if c.is_ascii_lowercase() {
200                let byte = (c as u8) - b'a' + 1;
201                return Some(CTRL_TABLE[byte as usize - 1]);
202            }
203        }
204    }
205
206    None
207}
208
209// Pre-materialized byte slices for ctrl-a..ctrl-z so key_to_bytes can return
210// `&'static [u8]` without allocating.
211static CTRL_TABLE: [&[u8]; 26] = [
212    b"\x01", b"\x02", b"\x03", b"\x04", b"\x05", b"\x06", b"\x07", b"\x08", b"\x09", b"\x0a",
213    b"\x0b", b"\x0c", b"\x0d", b"\x0e", b"\x0f", b"\x10", b"\x11", b"\x12", b"\x13", b"\x14",
214    b"\x15", b"\x16", b"\x17", b"\x18", b"\x19", b"\x1a",
215];
216
217fn allowed_keys_hint() -> &'static str {
218    "enter, return, tab, space, backspace, esc, escape, up, down, left, right, home, end, \
219     page-up, page-down, delete, insert, f1..f12, ctrl-a..ctrl-z"
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn text_form_passes_bytes_through_verbatim() {
228        let input = BashWriteInput::Text("hello\n".into());
229        let bytes = expand_input(&input).unwrap();
230        assert_eq!(bytes, b"hello\n");
231    }
232
233    #[test]
234    fn text_form_preserves_literal_escape_sequence_chars() {
235        // Critical backward-compat case: the agent wants to write the literal
236        // 6 characters \u001b (e.g. into source code), NOT an ESC byte.
237        let input = BashWriteInput::Text(r"\u001b[31mred\u001b[0m".into());
238        let bytes = expand_input(&input).unwrap();
239        assert_eq!(bytes, br"\u001b[31mred\u001b[0m");
240        // Sanity: byte count matches the literal char count
241        // (6 + 4 + 3 + 6 + 3 = 22), not 8 (the JSON-decoded count) and not
242        // some auto-stripped variant.
243        assert_eq!(bytes.len(), 22);
244    }
245
246    #[test]
247    fn sequence_form_expands_text_items() {
248        let input = BashWriteInput::Sequence(vec![
249            SequenceItem::Text("abc".into()),
250            SequenceItem::Text("def".into()),
251        ]);
252        let bytes = expand_input(&input).unwrap();
253        assert_eq!(bytes, b"abcdef");
254    }
255
256    #[test]
257    fn sequence_form_expands_named_keys_to_byte_sequences() {
258        let input = BashWriteInput::Sequence(vec![
259            SequenceItem::Key { key: "esc".into() },
260            SequenceItem::Key { key: "up".into() },
261            SequenceItem::Key {
262                key: "ctrl-c".into(),
263            },
264        ]);
265        let bytes = expand_input(&input).unwrap();
266        // ESC (\x1b) + arrow-up (\x1b[A) + ctrl-c (\x03)
267        assert_eq!(bytes, b"\x1b\x1b[A\x03");
268    }
269
270    #[test]
271    fn sequence_form_mixes_text_and_keys_in_order() {
272        // The vim "type some text, exit insert, save+quit" idiom.
273        let input = BashWriteInput::Sequence(vec![
274            SequenceItem::Text("iHello".into()),
275            SequenceItem::Key { key: "esc".into() },
276            SequenceItem::Text(":wq".into()),
277            SequenceItem::Key {
278                key: "enter".into(),
279            },
280        ]);
281        let bytes = expand_input(&input).unwrap();
282        // ENTER maps to CR (\r) for raw-mode TUI compatibility, not LF (\n).
283        // See key_to_bytes "Line / whitespace" docs for the rationale.
284        assert_eq!(bytes, b"iHello\x1b:wq\r");
285    }
286
287    #[test]
288    fn sequence_form_accepts_case_insensitive_key_names() {
289        let input = BashWriteInput::Sequence(vec![
290            SequenceItem::Key { key: "ESC".into() },
291            SequenceItem::Key {
292                key: "Ctrl-C".into(),
293            },
294        ]);
295        let bytes = expand_input(&input).unwrap();
296        assert_eq!(bytes, b"\x1b\x03");
297    }
298
299    #[test]
300    fn sequence_form_unknown_key_returns_error_with_hint() {
301        let input = BashWriteInput::Sequence(vec![SequenceItem::Key {
302            key: "windows-key".into(),
303        }]);
304        let err = expand_input(&input).unwrap_err();
305        assert!(err.contains("unknown key 'windows-key'"));
306        assert!(err.contains("allowed keys:"));
307    }
308
309    #[test]
310    fn ctrl_chord_table_covers_all_26_letters() {
311        for (i, letter) in ('a'..='z').enumerate() {
312            let name = format!("ctrl-{letter}");
313            let bytes = key_to_bytes(&name).unwrap_or_else(|| panic!("missing {name}"));
314            assert_eq!(bytes, &[(i as u8) + 1]);
315        }
316    }
317
318    #[test]
319    fn function_keys_use_documented_xterm_sequences() {
320        assert_eq!(key_to_bytes("f1"), Some(b"\x1bOP".as_slice()));
321        assert_eq!(key_to_bytes("f12"), Some(b"\x1b[24~".as_slice()));
322    }
323
324    #[test]
325    fn empty_sequence_produces_zero_bytes() {
326        let input = BashWriteInput::Sequence(vec![]);
327        let bytes = expand_input(&input).unwrap();
328        assert_eq!(bytes, b"");
329    }
330
331    #[test]
332    fn arrows_use_normal_cursor_key_mode_sequence() {
333        // ESC [ A/B/C/D form, not ESC O A/B/C/D (application mode).
334        assert_eq!(key_to_bytes("up"), Some(b"\x1b[A".as_slice()));
335        assert_eq!(key_to_bytes("down"), Some(b"\x1b[B".as_slice()));
336        assert_eq!(key_to_bytes("right"), Some(b"\x1b[C".as_slice()));
337        assert_eq!(key_to_bytes("left"), Some(b"\x1b[D".as_slice()));
338    }
339}