bosun-tmux 2.0.5

Tmux-native orchestrator for AI agent sessions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
//! Encode crossterm `KeyEvent`s into the byte sequences a terminal-
//! based child process expects on stdin.
//!
//! Coverage is the MVP set for Step 4 focus mode: printable chars,
//! Ctrl modifiers, Enter / Tab / Backspace / Esc, arrow keys, Home /
//! End / PageUp / PageDown, F1-F12, and Insert / Delete. We emit
//! standard xterm-compatible sequences — the same encoding crossterm
//! itself decodes when *receiving* keystrokes on the bosun side.
//! That round-trip symmetry is what makes the encoded bytes look
//! like a "real" terminal to the child.
//!
//! ## Not covered (yet — see PLAN_2_0.md Step 4 follow-ups)
//!
//! - **Cursor key application mode** (DECCKM): the child can switch
//!   between `CSI A` / `SS3 A` for the same arrow. We always emit
//!   the CSI form, which is the default and what `xterm-256color`
//!   uses when CKM is off. Most modern apps handle both forms.
//! - **Application keypad mode** (DECPAM): numeric vs. SS3-encoded
//!   keypad. We always emit numeric.
//! - **modifyOtherKeys / kitty keyboard protocol**: enhanced shifted/
//!   meta combinations. We emit only the basics — Shift+arrow as
//!   `CSI 1;2 A`, Alt+char as `ESC c`. Anything beyond is dropped.
//! - **Bracketed paste**: large pastes don't get bracketed; the child
//!   sees the bytes as if typed. Fine for short input, not great for
//!   pasting code.
//! - **SGR mouse mode (1006)**: focus mode doesn't forward mouse yet.
//!   Bosun's own mouse handling (divider drag) is unaffected.
//!
//! These gaps are why focus mode is "MVP" — it's enough for typing
//! into Claude / Codex / a shell, not enough for `vim` / `fzf` /
//! `htop` to feel right. Post-MVP arc fills them in.

use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

/// Encoding context — knobs the inner app has communicated
/// through its DECSET stream that change the right byte sequence
/// for a given key press. Currently a single field; held as a
/// struct so future additions (modifyOtherKeys mode tracking,
/// kitty keyboard flags, application keypad) slot in without
/// churning every call site.
#[derive(Debug, Clone, Copy, Default)]
pub struct EncodeContext {
    /// DECCKM (cursor-key application mode). When true, arrow
    /// keys and Home/End use the SS3 form (`\eOA` etc.) instead
    /// of the default CSI form (`\e[A` etc.).
    pub application_cursor: bool,
}

/// Encode a `KeyEvent` into the byte sequence to write to the
/// child's PTY. Returns `None` for events we explicitly don't
/// forward — currently key-release events on terminals that report
/// them, and Null / unmapped function keys.
pub fn encode(key: KeyEvent, ctx: EncodeContext) -> Option<Vec<u8>> {
    // Only forward presses. Some terminals (kitty, foot, recent
    // alacritty with kitty keyboard) also emit Release events;
    // forwarding those would double every keystroke from the
    // child's perspective.
    if key.kind == KeyEventKind::Release {
        return None;
    }

    let m = key.modifiers;
    let ctrl = m.contains(KeyModifiers::CONTROL);
    let alt = m.contains(KeyModifiers::ALT);
    let shift = m.contains(KeyModifiers::SHIFT);

    match key.code {
        KeyCode::Char(c) => Some(encode_char(c, ctrl, alt)),
        // Enter / Tab / Backspace with a non-trivial modifier emit
        // the xterm `modifyOtherKeys=2` form so apps that opt in to
        // that protocol (Claude Code, kitty, recent xterm, etc.)
        // can distinguish e.g. Shift+Enter from plain Enter. Plain
        // (no modifier) keeps the bare byte for everything else.
        // Apps that don't understand modifyOtherKeys will see the
        // escape sequence as an unknown CSI and typically drop it
        // silently — strictly better than the prior behavior of
        // sending Shift+Enter as a literal `\r` (i.e. submit) and
        // confusing the user.
        KeyCode::Enter => Some(modified_or(13, shift, ctrl, alt, b"\r")),
        KeyCode::Tab => Some(modified_or(9, shift, ctrl, alt, b"\t")),
        KeyCode::BackTab => Some(b"\x1b[Z".to_vec()),
        // Alt+Backspace (macOS Option+Delete) is the universal
        // "delete previous word" chord. Real terminals send it as
        // Meta+DEL = ESC + 0x7f, which readline / zsh / bash / Claude
        // Code all map to backward-kill-word. The modifyOtherKeys
        // form used for the Ctrl/Shift combos below is *not*
        // recognized as a word-delete by those apps — they fall back
        // to deleting a single char — so Alt-only gets the
        // ESC-prefixed bare byte instead.
        KeyCode::Backspace if alt && !ctrl && !shift => Some(b"\x1b\x7f".to_vec()),
        KeyCode::Backspace => Some(modified_or(127, shift, ctrl, alt, b"\x7f")),
        KeyCode::Esc => Some(b"\x1b".to_vec()),
        // Alt+Left / Alt+Right (macOS Option+Arrow) = move by word.
        // Emit the readline Meta-b / Meta-f sequences (ESC b / ESC f),
        // the universal backward-word / forward-word bindings that
        // zsh / bash / readline / Claude Code all honor. The xterm
        // Alt+arrow CSI form (`\e[1;3D`) the modified `arrow_seq`
        // would otherwise produce is not recognized as word-motion by
        // those apps, so the cursor wouldn't move at all. Mirrors the
        // Alt+Backspace → `\x1b\x7f` (backward-kill-word) choice above.
        KeyCode::Left if alt && !ctrl && !shift => Some(b"\x1bb".to_vec()),
        KeyCode::Right if alt && !ctrl && !shift => Some(b"\x1bf".to_vec()),
        KeyCode::Left => Some(arrow_seq(b'D', shift, ctrl, alt, ctx.application_cursor)),
        KeyCode::Right => Some(arrow_seq(b'C', shift, ctrl, alt, ctx.application_cursor)),
        KeyCode::Up => Some(arrow_seq(b'A', shift, ctrl, alt, ctx.application_cursor)),
        KeyCode::Down => Some(arrow_seq(b'B', shift, ctrl, alt, ctx.application_cursor)),
        KeyCode::Home => Some(arrow_seq(b'H', shift, ctrl, alt, ctx.application_cursor)),
        KeyCode::End => Some(arrow_seq(b'F', shift, ctrl, alt, ctx.application_cursor)),
        KeyCode::PageUp => Some(tilde_seq(b"5", shift, ctrl, alt)),
        KeyCode::PageDown => Some(tilde_seq(b"6", shift, ctrl, alt)),
        KeyCode::Insert => Some(tilde_seq(b"2", shift, ctrl, alt)),
        KeyCode::Delete => Some(tilde_seq(b"3", shift, ctrl, alt)),
        KeyCode::F(n) => function_key(n, shift, ctrl, alt),
        // Skip everything we don't explicitly handle. Includes
        // Null, CapsLock, ScrollLock, NumLock, PrintScreen, Pause,
        // Menu, KeypadBegin, Media keys, Modifier-only events,
        // Mouse-as-key (kitty). Dropping silently is the right
        // default for MVP — none of these are needed to drive
        // typical agent / shell input.
        _ => None,
    }
}

fn encode_char(c: char, ctrl: bool, alt: bool) -> Vec<u8> {
    let bytes: Vec<u8> = if ctrl {
        ctrl_char_bytes(c)
    } else {
        let mut s = [0u8; 4];
        c.encode_utf8(&mut s).as_bytes().to_vec()
    };
    if alt {
        let mut out = Vec::with_capacity(bytes.len() + 1);
        out.push(0x1b);
        out.extend_from_slice(&bytes);
        out
    } else {
        bytes
    }
}

/// Map a character + Ctrl modifier to its control-code byte. The
/// classic 0x1f mask gives us `Ctrl-A=0x01 ... Ctrl-_=0x1f`. A few
/// special cases that diverge from the mask are handled by name.
fn ctrl_char_bytes(c: char) -> Vec<u8> {
    let lc = c.to_ascii_lowercase();
    let byte: u8 = match lc {
        // Most letters / standard punctuation: `c & 0x1f`. Note
        // that lowercase `c` is the canonical form because the
        // user typed without Shift; uppercase variants only show
        // up when Shift is also held, which we treat as a separate
        // modifier (and don't encode at the byte level — Ctrl-Shift-A
        // and Ctrl-A produce the same byte without kitty keyboard).
        'a'..='z' => (lc as u8) & 0x1f,
        '@' => 0x00,
        '[' => 0x1b,
        '\\' => 0x1c,
        ']' => 0x1d,
        '^' => 0x1e,
        '_' => 0x1f,
        '?' => 0x7f, // Ctrl-? often produces DEL on real terminals.
        ' ' => 0x00, // Ctrl-Space → NUL.
        // Fallback: numeric keys + everything else just pass
        // through. Ctrl-1, Ctrl-2 etc. on most terminals send the
        // raw digit; modifyOtherKeys would send a CSI sequence
        // instead, but we don't implement that in the MVP.
        other => other as u8,
    };
    vec![byte]
}

#[allow(dead_code)]
fn prepend_alt(alt: bool, bytes: Vec<u8>) -> Option<Vec<u8>> {
    if alt {
        let mut out = Vec::with_capacity(bytes.len() + 1);
        out.push(0x1b);
        out.extend_from_slice(&bytes);
        Some(out)
    } else {
        Some(bytes)
    }
}

/// xterm `modifyOtherKeys=2` encoding: `\e[27;<mod>;<keycode>~`.
/// When no modifier is active (`mod == 1`), returns the plain
/// `bare` bytes so backwards-compat is preserved for apps that
/// don't understand the protocol. Callers pass the key's
/// codepoint as `keycode` (e.g. 13 for Enter, 9 for Tab).
fn modified_or(keycode: u16, shift: bool, ctrl: bool, alt: bool, bare: &[u8]) -> Vec<u8> {
    let code = modifier_code(shift, ctrl, alt);
    if code == 1 {
        return bare.to_vec();
    }
    let mut out = Vec::with_capacity(12);
    out.extend_from_slice(b"\x1b[27;");
    out.extend_from_slice(code.to_string().as_bytes());
    out.push(b';');
    out.extend_from_slice(keycode.to_string().as_bytes());
    out.push(b'~');
    out
}

/// CSI-letter sequences (`ESC [ <mods> <letter>`) used by arrow
/// keys, Home, End. With no modifiers, the bare form depends on
/// DECCKM (cursor-key application mode): SS3 `ESC O <letter>` when
/// the inner app has enabled it (vim command mode, readline,
/// etc.), CSI `ESC [ <letter>` otherwise. With modifiers it's
/// always `CSI 1; <code> <letter>` regardless of DECCKM — xterm's
/// modified-arrow encoding is the same in both modes.
fn arrow_seq(letter: u8, shift: bool, ctrl: bool, alt: bool, app_cursor: bool) -> Vec<u8> {
    let code = modifier_code(shift, ctrl, alt);
    if code == 1 {
        if app_cursor {
            vec![0x1b, b'O', letter]
        } else {
            vec![0x1b, b'[', letter]
        }
    } else {
        let mut out = Vec::with_capacity(8);
        out.extend_from_slice(b"\x1b[1;");
        out.extend_from_slice(code.to_string().as_bytes());
        out.push(letter);
        out
    }
}

/// CSI-tilde sequences (`ESC [ <num> ~`) used by PgUp/PgDn, Ins,
/// Del, F-keys. With modifiers: `ESC [ <num> ; <code> ~`.
fn tilde_seq(num: &[u8], shift: bool, ctrl: bool, alt: bool) -> Vec<u8> {
    let code = modifier_code(shift, ctrl, alt);
    let mut out = Vec::with_capacity(8);
    out.extend_from_slice(b"\x1b[");
    out.extend_from_slice(num);
    if code != 1 {
        out.push(b';');
        out.extend_from_slice(code.to_string().as_bytes());
    }
    out.push(b'~');
    out
}

/// xterm modifier code: 1 = none, 2 = Shift, 3 = Alt, 4 = Shift+Alt,
/// 5 = Ctrl, 6 = Ctrl+Shift, 7 = Ctrl+Alt, 8 = Ctrl+Shift+Alt.
fn modifier_code(shift: bool, ctrl: bool, alt: bool) -> u8 {
    let mut code = 1u8;
    if shift {
        code += 1;
    }
    if alt {
        code += 2;
    }
    if ctrl {
        code += 4;
    }
    code
}

/// F1-F12 encoding. F1-F4 use the SS3 form (`ESC O P`...) without
/// modifiers and the CSI 1;<code>P form with modifiers. F5-F12 use
/// the CSI ~ form throughout. F13+ exists in xterm but is not
/// covered here (keyboards almost never have them and the encoding
/// changes again).
fn function_key(n: u8, shift: bool, ctrl: bool, alt: bool) -> Option<Vec<u8>> {
    let code = modifier_code(shift, ctrl, alt);
    match n {
        1..=4 => {
            let letter = b"PQRS"[(n - 1) as usize];
            if code == 1 {
                Some(vec![0x1b, b'O', letter])
            } else {
                let mut out = Vec::with_capacity(8);
                out.extend_from_slice(b"\x1b[1;");
                out.extend_from_slice(code.to_string().as_bytes());
                out.push(letter);
                Some(out)
            }
        }
        5 => Some(tilde_seq(b"15", shift, ctrl, alt)),
        6 => Some(tilde_seq(b"17", shift, ctrl, alt)),
        7 => Some(tilde_seq(b"18", shift, ctrl, alt)),
        8 => Some(tilde_seq(b"19", shift, ctrl, alt)),
        9 => Some(tilde_seq(b"20", shift, ctrl, alt)),
        10 => Some(tilde_seq(b"21", shift, ctrl, alt)),
        11 => Some(tilde_seq(b"23", shift, ctrl, alt)),
        12 => Some(tilde_seq(b"24", shift, ctrl, alt)),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    fn k(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
        KeyEvent::new(code, mods)
    }

    /// Encode shim that fills in a default (cursor-mode off)
    /// context so the bulk of the existing tests don't need to
    /// pass it explicitly. DECCKM-specific tests construct the
    /// context themselves.
    fn encode(key: KeyEvent) -> Option<Vec<u8>> {
        super::encode(key, EncodeContext::default())
    }

    #[test]
    fn plain_letter() {
        assert_eq!(
            encode(k(KeyCode::Char('a'), KeyModifiers::NONE)),
            Some(b"a".to_vec())
        );
    }

    #[test]
    fn ctrl_letter() {
        assert_eq!(
            encode(k(KeyCode::Char('a'), KeyModifiers::CONTROL)),
            Some(vec![0x01])
        );
        assert_eq!(
            encode(k(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            Some(vec![0x03])
        );
    }

    #[test]
    fn alt_letter_prepends_esc() {
        assert_eq!(
            encode(k(KeyCode::Char('x'), KeyModifiers::ALT)),
            Some(vec![0x1b, b'x'])
        );
    }

    #[test]
    fn enter_is_cr() {
        assert_eq!(
            encode(k(KeyCode::Enter, KeyModifiers::NONE)),
            Some(b"\r".to_vec())
        );
    }

    #[test]
    fn shift_enter_uses_modify_other_keys() {
        // Mod code 2 = shift. Keycode 13 = Enter (\r). Claude Code
        // listens for this exact sequence to insert a newline
        // without submitting.
        assert_eq!(
            encode(k(KeyCode::Enter, KeyModifiers::SHIFT)),
            Some(b"\x1b[27;2;13~".to_vec())
        );
    }

    #[test]
    fn ctrl_enter_uses_modify_other_keys() {
        // Mod code 5 = ctrl.
        assert_eq!(
            encode(k(KeyCode::Enter, KeyModifiers::CONTROL)),
            Some(b"\x1b[27;5;13~".to_vec())
        );
    }

    #[test]
    fn ctrl_tab_uses_modify_other_keys() {
        assert_eq!(
            encode(k(KeyCode::Tab, KeyModifiers::CONTROL)),
            Some(b"\x1b[27;5;9~".to_vec())
        );
    }

    #[test]
    fn backspace_is_del() {
        assert_eq!(
            encode(k(KeyCode::Backspace, KeyModifiers::NONE)),
            Some(b"\x7f".to_vec())
        );
    }

    #[test]
    fn alt_backspace_deletes_word() {
        // macOS Option+Delete. Meta+DEL = ESC + 0x7f, the readline
        // backward-kill-word chord — not the modifyOtherKeys form,
        // which apps don't treat as a word-delete.
        assert_eq!(
            encode(k(KeyCode::Backspace, KeyModifiers::ALT)),
            Some(b"\x1b\x7f".to_vec())
        );
    }

    #[test]
    fn ctrl_backspace_uses_modify_other_keys() {
        assert_eq!(
            encode(k(KeyCode::Backspace, KeyModifiers::CONTROL)),
            Some(b"\x1b[27;5;127~".to_vec())
        );
    }

    #[test]
    fn alt_left_right_move_by_word() {
        // macOS Option+Arrow. ESC b / ESC f are the readline
        // backward-word / forward-word bindings claude & shells honor;
        // the xterm `\e[1;3D` Alt-arrow form is ignored by them.
        assert_eq!(
            encode(k(KeyCode::Left, KeyModifiers::ALT)),
            Some(b"\x1bb".to_vec())
        );
        assert_eq!(
            encode(k(KeyCode::Right, KeyModifiers::ALT)),
            Some(b"\x1bf".to_vec())
        );
    }

    #[test]
    fn ctrl_alt_left_falls_through_to_csi() {
        // Only plain Alt+arrow is word-motion; combos keep the
        // modified CSI form (Ctrl+Alt code 7).
        assert_eq!(
            encode(k(KeyCode::Left, KeyModifiers::ALT | KeyModifiers::CONTROL)),
            Some(b"\x1b[1;7D".to_vec())
        );
    }

    #[test]
    fn esc_is_esc() {
        assert_eq!(
            encode(k(KeyCode::Esc, KeyModifiers::NONE)),
            Some(b"\x1b".to_vec())
        );
    }

    #[test]
    fn arrow_bare() {
        assert_eq!(
            encode(k(KeyCode::Up, KeyModifiers::NONE)),
            Some(b"\x1b[A".to_vec())
        );
        assert_eq!(
            encode(k(KeyCode::Left, KeyModifiers::NONE)),
            Some(b"\x1b[D".to_vec())
        );
    }

    #[test]
    fn arrow_bare_application_cursor_mode() {
        // DECCKM on: SS3 form instead of CSI.
        let ctx = EncodeContext {
            application_cursor: true,
        };
        assert_eq!(
            super::encode(k(KeyCode::Up, KeyModifiers::NONE), ctx),
            Some(b"\x1bOA".to_vec())
        );
        assert_eq!(
            super::encode(k(KeyCode::Down, KeyModifiers::NONE), ctx),
            Some(b"\x1bOB".to_vec())
        );
        assert_eq!(
            super::encode(k(KeyCode::Right, KeyModifiers::NONE), ctx),
            Some(b"\x1bOC".to_vec())
        );
        assert_eq!(
            super::encode(k(KeyCode::Left, KeyModifiers::NONE), ctx),
            Some(b"\x1bOD".to_vec())
        );
    }

    #[test]
    fn arrow_with_modifier_ignores_application_cursor() {
        // Modified arrows use the same `\e[1;<code>X` form in
        // both modes — DECCKM only affects the bare form.
        let ctx = EncodeContext {
            application_cursor: true,
        };
        assert_eq!(
            super::encode(k(KeyCode::Up, KeyModifiers::SHIFT), ctx),
            Some(b"\x1b[1;2A".to_vec())
        );
    }

    #[test]
    fn home_end_obey_application_cursor() {
        let ctx = EncodeContext {
            application_cursor: true,
        };
        assert_eq!(
            super::encode(k(KeyCode::Home, KeyModifiers::NONE), ctx),
            Some(b"\x1bOH".to_vec())
        );
        assert_eq!(
            super::encode(k(KeyCode::End, KeyModifiers::NONE), ctx),
            Some(b"\x1bOF".to_vec())
        );
    }

    #[test]
    fn arrow_with_shift() {
        // Shift modifier code = 2.
        assert_eq!(
            encode(k(KeyCode::Up, KeyModifiers::SHIFT)),
            Some(b"\x1b[1;2A".to_vec())
        );
    }

    #[test]
    fn arrow_with_ctrl() {
        // Ctrl modifier code = 5.
        assert_eq!(
            encode(k(KeyCode::Right, KeyModifiers::CONTROL)),
            Some(b"\x1b[1;5C".to_vec())
        );
    }

    #[test]
    fn pgup_bare_and_with_ctrl() {
        assert_eq!(
            encode(k(KeyCode::PageUp, KeyModifiers::NONE)),
            Some(b"\x1b[5~".to_vec())
        );
        assert_eq!(
            encode(k(KeyCode::PageUp, KeyModifiers::CONTROL)),
            Some(b"\x1b[5;5~".to_vec())
        );
    }

    #[test]
    fn f1_uses_ss3() {
        assert_eq!(
            encode(k(KeyCode::F(1), KeyModifiers::NONE)),
            Some(b"\x1bOP".to_vec())
        );
    }

    #[test]
    fn f5_uses_tilde() {
        assert_eq!(
            encode(k(KeyCode::F(5), KeyModifiers::NONE)),
            Some(b"\x1b[15~".to_vec())
        );
    }

    #[test]
    fn release_is_dropped() {
        let mut ev = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
        ev.kind = KeyEventKind::Release;
        assert_eq!(encode(ev), None);
    }
}