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#[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(¶ms.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(¶ms.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(¶ms.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
122fn key_to_bytes(name: &str) -> Option<&'static [u8]> {
136 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 ("enter", b"\r"),
157 ("return", b"\r"),
158 ("tab", b"\t"),
159 ("space", b" "),
160 ("backspace", b"\x7f"),
161 ("esc", b"\x1b"),
163 ("escape", b"\x1b"),
164 ("up", b"\x1b[A"),
166 ("down", b"\x1b[B"),
167 ("right", b"\x1b[C"),
168 ("left", b"\x1b[D"),
169 ("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 ("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 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
209static 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 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 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 assert_eq!(bytes, b"\x1b\x1b[A\x03");
268 }
269
270 #[test]
271 fn sequence_form_mixes_text_and_keys_in_order() {
272 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 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 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}