File size: 4,491 Bytes
67d18ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * Incremental VT/ANSI input parser for the TUI keyboard loop.
 *
 * stdin is read in raw mode as a UTF-8 string stream; `KeyParser.feed`
 * converts chunks into actions. A chunk can end in the middle of an escape
 * sequence (lone `\x1b` before more bytes arrive), so unconsumed input is
 * buffered until the sequence completes on a later chunk. Works identically
 * under Bun and Node β€” no readline keypress events involved.
 *
 * Mouse: with SGR mouse tracking enabled (`\x1b[?1000h\x1b[?1006h`), the
 * terminal reports presses/releases as `ESC [ < b ; x ; y M/m` and wheel
 * events as button codes 64/65 β€” parsed into click/wheel actions so TUI
 * buttons are directly clickable (coordinates arrive 1-based, converted
 * to 0-based terminal cells).
 */

export type KeyAction =
  | { type: "char"; key: string }
  | { type: "up" }
  | { type: "down" }
  | { type: "pageup" }
  | { type: "pagedown" }
  | { type: "home" }
  | { type: "end" }
  | { type: "click"; x: number; y: number }
  | { type: "wheel-up" }
  | { type: "wheel-down" }
  | { type: "ctrl-c" }
  /** Recognized but without a binding (Enter, Tab, bare Esc, Alt+key, drag/release, …). */
  | { type: "ignore" };

export class KeyParser {
  private pending = "";

  /** Consume a raw stdin chunk and return the actions it completes. */
  feed(chunk: string): KeyAction[] {
    // Wedge guard: no real sequence is anywhere near 64 bytes (SGR mouse is
    // ~16), so a pending tail this long means the parser is stuck on garbage.
    // Drop it β€” otherwise the stuck prefix would swallow every future chunk
    // and the TUI would stop responding to keys and clicks for good.
    if (this.pending.length > 64) this.pending = "";
    const buf = this.pending + chunk;
    const actions: KeyAction[] = [];
    let i = 0;

    while (i < buf.length) {
      const ch = buf[i]!;

      if (ch === "\x1b") {
        if (i + 1 >= buf.length) break; // lone ESC at chunk end β€” wait for the rest
        if (buf[i + 1] === "[") {
          let j = i + 2;
          while (j < buf.length && !/[A-Za-z~]/.test(buf[j]!)) j++;
          if (j >= buf.length) break; // incomplete CSI β€” wait
          actions.push(csiAction(buf[j]!, buf.slice(i + 2, j)));
          i = j + 1;
          continue;
        }
        if (buf[i + 1] === "O") {
          if (i + 2 >= buf.length) break; // incomplete SS3 β€” wait
          actions.push(csiAction(buf[i + 2]!, ""));
          i += 3;
          continue;
        }
        // Alt+key or other two-byte sequence β€” no binding.
        actions.push({ type: "ignore" });
        i += 2;
        continue;
      }

      if (ch === "\x03") {
        actions.push({ type: "ctrl-c" });
        i++;
        continue;
      }

      if (ch < " " || ch === "\x7f") {
        // Remaining control bytes (Enter/Tab/backspace/…) carry no binding.
        actions.push({ type: "ignore" });
        i++;
        continue;
      }

      actions.push({ type: "char", key: ch });
      i++;
    }

    this.pending = buf.slice(i);
    return actions;
  }
}

function csiAction(final: string, params: string): KeyAction {
  if (params.startsWith("<")) return mouseAction(params.slice(1), final);
  switch (final) {
    case "A":
      return { type: "up" };
    case "B":
      return { type: "down" };
    case "H":
      return { type: "home" };
    case "F":
      return { type: "end" };
    case "~":
      if (params === "5" || params === "5;5") return { type: "pageup" };
      if (params === "6" || params === "6;5") return { type: "pagedown" };
      if (params === "1" || params === "7") return { type: "home" };
      if (params === "4" || params === "8") return { type: "end" };
      return { type: "ignore" };
    default:
      return { type: "ignore" };
  }
}

/** SGR mouse event: `<button;column;row` + `M` (press) or `m` (release). */
function mouseAction(params: string, final: string): KeyAction {
  if (final !== "M") return { type: "ignore" }; // releases and motion don't click
  const parts = params.split(";");
  const button = Number(parts[0]);
  const x = Number(parts[1]);
  const y = Number(parts[2]);
  if (!Number.isFinite(button) || !Number.isFinite(x) || !Number.isFinite(y)) {
    return { type: "ignore" };
  }
  if (button === 0) return { type: "click", x: x - 1, y: y - 1 };
  if (button === 64) return { type: "wheel-up" };
  if (button === 65) return { type: "wheel-down" };
  return { type: "ignore" }; // right/middle buttons, drag modifiers
}