This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Emit backtab for shift+tab in the TUI input parser

The raw-stdin parser in src/tui/input.ts dropped the legacy xterm
encoding (ESC[Z) as unknown CSI and discarded the shift modifier in
the kitty keyboard protocol for code 9 (tab). Both paths now produce
{ name: "backtab", shift: true, ctrl: false, alt: false } so form
widgets can bind shift-tab for backward field navigation.

Also adds shift: boolean as a required field on KeyEvent, extracted
from the kitty modifier bitmask alongside ctrl and alt. Existing
consumers that only read ctrl/alt are unaffected.

Surfaced while building reminders — its form widget was using up/down
as a local workaround because backtab never reached the handler.

12 new focused tests in tests/input-parse.test.ts covering both
encodings, modifier combinations, and the existing ctrl/alt/arrow
paths.

Nathan Herald (Apr 20, 2026, 3:12 PM +0200) 9d4a75ee edd10535

+129 -20
+3
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### TUI framework 6 + - **Shift+Tab (backtab) now fires as a proper key event.** The raw-stdin parser in `src/tui/input.ts` previously dropped the legacy `ESC[Z` encoding as "unknown CSI" and ignored the shift modifier in the kitty keyboard protocol for code 9 (tab). Both paths now produce `{ name: "backtab", shift: true, ctrl: false, alt: false }`, so form widgets across apps can bind shift-tab for backward field navigation. Added `shift: boolean` to `KeyEvent` as a required field — existing consumers that only read ctrl/alt are unaffected. Surfaced while building the `reminders` app where the form widget needed backtab to move between fields. 7 + 5 8 ### Fixes 6 9 - **Shift+Enter / kitty keyboard protocol inside supervised (launchd-started) sessions.** Children of a daemon started from a minimal env (launchd, systemd, sparse CI runners) previously had no `TERM` at all, which caused modern TUIs — Claude Code, vim, nvim — to fall back to legacy key encoding where Shift+Enter is indistinguishable from Enter. Two related fixes: 7 10 - `buildChildEnv` in `server.ts` now defaults `TERM=xterm-256color` in the child's env whenever it would otherwise be absent. Never overrides an explicit value. Covers all three env paths (legacy inheritance, `isolateEnv` allow-list, verbatim `env:`).
+33 -20
src/tui/input.ts
··· 5 5 char?: string; 6 6 ctrl: boolean; 7 7 alt: boolean; 8 + shift: boolean; 8 9 } 9 10 10 11 export function parseKey(data: Buffer): KeyEvent[] { ··· 20 21 const rest = str.slice(i + 2); 21 22 22 23 // Arrow keys, home, end 23 - if (rest[0] === "A") { events.push({ name: "up", ctrl: false, alt: false }); i += 3; continue; } 24 - if (rest[0] === "B") { events.push({ name: "down", ctrl: false, alt: false }); i += 3; continue; } 25 - if (rest[0] === "C") { events.push({ name: "right", ctrl: false, alt: false }); i += 3; continue; } 26 - if (rest[0] === "D") { events.push({ name: "left", ctrl: false, alt: false }); i += 3; continue; } 27 - if (rest[0] === "H") { events.push({ name: "home", ctrl: false, alt: false }); i += 3; continue; } 28 - if (rest[0] === "F") { events.push({ name: "end", ctrl: false, alt: false }); i += 3; continue; } 24 + if (rest[0] === "A") { events.push({ name: "up", ctrl: false, alt: false, shift: false }); i += 3; continue; } 25 + if (rest[0] === "B") { events.push({ name: "down", ctrl: false, alt: false, shift: false }); i += 3; continue; } 26 + if (rest[0] === "C") { events.push({ name: "right", ctrl: false, alt: false, shift: false }); i += 3; continue; } 27 + if (rest[0] === "D") { events.push({ name: "left", ctrl: false, alt: false, shift: false }); i += 3; continue; } 28 + if (rest[0] === "H") { events.push({ name: "home", ctrl: false, alt: false, shift: false }); i += 3; continue; } 29 + if (rest[0] === "F") { events.push({ name: "end", ctrl: false, alt: false, shift: false }); i += 3; continue; } 30 + 31 + // Shift+Tab (legacy xterm encoding): ESC[Z 32 + if (rest[0] === "Z") { events.push({ name: "backtab", ctrl: false, alt: false, shift: true }); i += 3; continue; } 29 33 30 34 // Delete: ESC[3~ 31 - if (rest.startsWith("3~")) { events.push({ name: "delete", ctrl: false, alt: false }); i += 4; continue; } 35 + if (rest.startsWith("3~")) { events.push({ name: "delete", ctrl: false, alt: false, shift: false }); i += 4; continue; } 32 36 // Page Up: ESC[5~ 33 - if (rest.startsWith("5~")) { events.push({ name: "pageup", ctrl: false, alt: false }); i += 4; continue; } 37 + if (rest.startsWith("5~")) { events.push({ name: "pageup", ctrl: false, alt: false, shift: false }); i += 4; continue; } 34 38 // Page Down: ESC[6~ 35 - if (rest.startsWith("6~")) { events.push({ name: "pagedown", ctrl: false, alt: false }); i += 4; continue; } 39 + if (rest.startsWith("6~")) { events.push({ name: "pagedown", ctrl: false, alt: false, shift: false }); i += 4; continue; } 36 40 37 41 // Kitty keyboard protocol: ESC[<code>;<modifiers>u 38 42 const kittyMatch = rest.match(/^(\d+);(\d+)u/); 39 43 if (kittyMatch) { 40 44 const codepoint = parseInt(kittyMatch[1], 10); 45 + // Wire format is (modifiers + 1); subtract to get the bitmask. 46 + // Bit 0 = shift, bit 1 = alt, bit 2 = ctrl, bit 3 = super (ignored). 41 47 const mods = parseInt(kittyMatch[2], 10) - 1; 48 + const shift = (mods & 0x01) !== 0; 49 + const alt = (mods & 0x02) !== 0; 42 50 const ctrl = (mods & 0x04) !== 0; 43 - const alt = (mods & 0x02) !== 0; 44 - const ch = String.fromCodePoint(codepoint); 45 - events.push({ name: ch, char: ch, ctrl, alt }); 51 + // Shift+Tab via kitty protocol -> canonical "backtab" so consumers 52 + // can handle it the same as the legacy ESC[Z encoding. 53 + if (codepoint === 0x09 && shift) { 54 + events.push({ name: "backtab", ctrl, alt, shift }); 55 + } else { 56 + const ch = String.fromCodePoint(codepoint); 57 + events.push({ name: ch, char: ch, ctrl, alt, shift }); 58 + } 46 59 i += 2 + kittyMatch[0].length; 47 60 continue; 48 61 } ··· 57 70 // Alt+<char>: ESC followed by printable character 58 71 if (i + 1 < str.length && str[i + 1] >= " ") { 59 72 const ch = str[i + 1]; 60 - events.push({ name: ch, char: ch, ctrl: false, alt: true }); 73 + events.push({ name: ch, char: ch, ctrl: false, alt: true, shift: false }); 61 74 i += 2; 62 75 continue; 63 76 } 64 77 65 78 // Bare ESC 66 - events.push({ name: "escape", ctrl: false, alt: false }); 79 + events.push({ name: "escape", ctrl: false, alt: false, shift: false }); 67 80 i++; 68 81 continue; 69 82 } ··· 71 84 // Control characters 72 85 const code = str.charCodeAt(i); 73 86 74 - if (code === 0x0d) { events.push({ name: "return", ctrl: false, alt: false }); i++; continue; } 75 - if (code === 0x09) { events.push({ name: "tab", ctrl: false, alt: false }); i++; continue; } 76 - if (code === 0x7f) { events.push({ name: "backspace", ctrl: false, alt: false }); i++; continue; } 77 - if (code === 0x1c) { events.push({ name: "\\", ctrl: true, alt: false }); i++; continue; } 87 + if (code === 0x0d) { events.push({ name: "return", ctrl: false, alt: false, shift: false }); i++; continue; } 88 + if (code === 0x09) { events.push({ name: "tab", ctrl: false, alt: false, shift: false }); i++; continue; } 89 + if (code === 0x7f) { events.push({ name: "backspace", ctrl: false, alt: false, shift: false }); i++; continue; } 90 + if (code === 0x1c) { events.push({ name: "\\", ctrl: true, alt: false, shift: false }); i++; continue; } 78 91 79 92 // Ctrl+A through Ctrl+Z (0x01–0x1a) 80 93 if (code >= 0x01 && code <= 0x1a) { 81 94 const letter = String.fromCharCode(code + 0x60); // a-z 82 - events.push({ name: letter, ctrl: true, alt: false }); 95 + events.push({ name: letter, ctrl: true, alt: false, shift: false }); 83 96 i++; 84 97 continue; 85 98 } 86 99 87 100 // Regular printable character 88 101 if (code >= 0x20) { 89 - events.push({ name: str[i], char: str[i], ctrl: false, alt: false }); 102 + events.push({ name: str[i], char: str[i], ctrl: false, alt: false, shift: false }); 90 103 i++; 91 104 continue; 92 105 }
+93
tests/input-parse.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { parseKey } from "../src/tui/input.ts"; 3 + 4 + function parse(s: string) { 5 + return parseKey(Buffer.from(s, "utf8")); 6 + } 7 + 8 + describe("parseKey basics", () => { 9 + it("parses a plain printable character", () => { 10 + expect(parse("a")).toEqual([ 11 + { name: "a", char: "a", ctrl: false, alt: false, shift: false }, 12 + ]); 13 + }); 14 + 15 + it("parses return / tab / backspace as named keys", () => { 16 + expect(parse("\r")).toEqual([{ name: "return", ctrl: false, alt: false, shift: false }]); 17 + expect(parse("\t")).toEqual([{ name: "tab", ctrl: false, alt: false, shift: false }]); 18 + expect(parse("\x7f")).toEqual([{ name: "backspace", ctrl: false, alt: false, shift: false }]); 19 + }); 20 + 21 + it("parses a bare ESC as escape", () => { 22 + expect(parse("\x1b")).toEqual([{ name: "escape", ctrl: false, alt: false, shift: false }]); 23 + }); 24 + 25 + it("parses arrow keys", () => { 26 + expect(parse("\x1b[A")).toEqual([{ name: "up", ctrl: false, alt: false, shift: false }]); 27 + expect(parse("\x1b[B")).toEqual([{ name: "down", ctrl: false, alt: false, shift: false }]); 28 + expect(parse("\x1b[C")).toEqual([{ name: "right", ctrl: false, alt: false, shift: false }]); 29 + expect(parse("\x1b[D")).toEqual([{ name: "left", ctrl: false, alt: false, shift: false }]); 30 + }); 31 + 32 + it("parses Ctrl+letter", () => { 33 + // Ctrl+A = 0x01 34 + expect(parse("\x01")).toEqual([{ name: "a", ctrl: true, alt: false, shift: false }]); 35 + }); 36 + 37 + it("parses Alt+letter", () => { 38 + expect(parse("\x1ba")).toEqual([{ name: "a", char: "a", ctrl: false, alt: true, shift: false }]); 39 + }); 40 + }); 41 + 42 + describe("parseKey: shift+tab (backtab)", () => { 43 + // Two wire encodings matter in the wild: 44 + // - Legacy xterm: ESC [ Z (CSI-Z). Most terminals emit this by default. 45 + // - Kitty keyboard protocol: ESC [ 9 ; 2 u (code 9 = tab, modifiers 2 = shift+1). 46 + // Both must produce the canonical name "backtab" so consumers can key on 47 + // key.name === "backtab" without caring which terminal the client is in. 48 + 49 + it("ESC[Z -> backtab", () => { 50 + expect(parse("\x1b[Z")).toEqual([ 51 + { name: "backtab", ctrl: false, alt: false, shift: true }, 52 + ]); 53 + }); 54 + 55 + it("kitty encoding ESC[9;2u -> backtab", () => { 56 + expect(parse("\x1b[9;2u")).toEqual([ 57 + { name: "backtab", ctrl: false, alt: false, shift: true }, 58 + ]); 59 + }); 60 + 61 + it("kitty shift+ctrl+tab: backtab with ctrl modifier", () => { 62 + // mods = ctrl(4) + shift(1) = 5, wire = 5 + 1 = 6 63 + expect(parse("\x1b[9;6u")).toEqual([ 64 + { name: "backtab", ctrl: true, alt: false, shift: true }, 65 + ]); 66 + }); 67 + 68 + it("kitty plain tab (no shift) stays as tab char via existing path", () => { 69 + // mods = 0, wire = 1 70 + const events = parse("\x1b[9;1u"); 71 + expect(events).toHaveLength(1); 72 + expect(events[0].char).toBe("\t"); 73 + expect(events[0].shift).toBe(false); 74 + }); 75 + }); 76 + 77 + describe("parseKey: kitty protocol modifier extraction", () => { 78 + // Regression guard: before this change, shift was never extracted from 79 + // the kitty modifier bitmask. Now it is — keep it that way. 80 + it("extracts shift modifier from kitty sequences", () => { 81 + // 'a' with shift, modifiers wire = shift(1) + 1 = 2 82 + expect(parse("\x1b[97;2u")).toEqual([ 83 + { name: "a", char: "a", ctrl: false, alt: false, shift: true }, 84 + ]); 85 + }); 86 + 87 + it("extracts all three modifiers (ctrl+alt+shift) from one keystroke", () => { 88 + // mods = ctrl(4) + alt(2) + shift(1) = 7, wire = 8 89 + expect(parse("\x1b[97;8u")).toEqual([ 90 + { name: "a", char: "a", ctrl: true, alt: true, shift: true }, 91 + ]); 92 + }); 93 + });