aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:08:30 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:08:30 +0200
commit7521f217a5ed98cef16db5837a72fae32dce9552 (patch)
tree396837adb0cd65066d78f32eb3b6a9de7eabf67a /src
parentsl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer (diff)
downloadslosh-7521f217a5ed98cef16db5837a72fae32dce9552.tar.gz
slosh-7521f217a5ed98cef16db5837a72fae32dce9552.tar.xz
slosh-7521f217a5ed98cef16db5837a72fae32dce9552.zip
sl0ptty: input decoder + per-pane re-encode
The half libghostty-vt does not do: decode what the outer terminal sends us into semantic events, then re-encode each event for the target pane against that pane's own negotiated modes. Decoder (src/input.c): legacy keys and modifiers, SS3, tilde keys, the kitty keyboard protocol (CSI u, shifted/base layout sub-params, press/repeat/release, associated text), SGR mouse incl. drag and wheel, bracketed paste, focus events, alt-prefix, UTF-8, and partial sequences retained across reads. Re-encoding (pane_send_key/mouse/paste) drives lib-vt's key and mouse encoders via setopt_from_terminal, so a pane gets exactly what it negotiated. The mouse encoder wants surface pixels; we declare a 1x1 pixel cell so cell coordinates are exact rather than approximated. The prefix key is handled on decoded events, not bytes, so it cannot be confused by a byte that merely looks like C-a inside a UTF-8 or escape sequence. Tests: 40 table-driven decoder cases (tests/input_test.c) + a live check that drives the same client into a legacy pane and a kitty/mouse pane and asserts each receives its own encoding (tests/live_input.py). 59 checks, all green.
Diffstat (limited to 'src')
-rw-r--r--src/input.c525
-rw-r--r--src/input.h84
-rw-r--r--src/main.c88
-rw-r--r--src/pane.c77
-rw-r--r--src/sl0ptty.h6
5 files changed, 763 insertions, 17 deletions
diff --git a/src/input.c b/src/input.c
new file mode 100644
index 0000000..75da2fd
--- /dev/null
+++ b/src/input.c
@@ -0,0 +1,525 @@
+/* Input decoder: outer-terminal bytes -> semantic events. See input.h. */
+#define _GNU_SOURCE
+#include "input.h"
+
+#include <ghostty/vt.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+struct input_parser {
+ uint8_t *buf;
+ size_t len, cap;
+ bool in_paste;
+ size_t paste_start; /* offset of paste payload within buf */
+};
+
+input_parser_t *input_new(void) {
+ input_parser_t *p = calloc(1, sizeof *p);
+ p->cap = 4096;
+ p->buf = malloc(p->cap);
+ return p;
+}
+
+void input_free(input_parser_t *p) {
+ if (!p) return;
+ free(p->buf);
+ free(p);
+}
+
+static void buf_append(input_parser_t *p, const uint8_t *d, size_t n) {
+ if (p->len + n > p->cap) {
+ while (p->cap < p->len + n) p->cap *= 2;
+ p->buf = realloc(p->buf, p->cap);
+ }
+ memcpy(p->buf + p->len, d, n);
+ p->len += n;
+}
+
+static void buf_consume(input_parser_t *p, size_t n) {
+ if (n >= p->len) {
+ p->len = 0;
+ return;
+ }
+ memmove(p->buf, p->buf + n, p->len - n);
+ p->len -= n;
+}
+
+/* ---- key tables --------------------------------------------------------- */
+
+/* Printable ASCII -> physical key. Layout-independent codes; the text we send
+ * separately is what actually reaches the app for ordinary typing. */
+static int ascii_key(uint8_t c) {
+ if (c >= 'a' && c <= 'z') return GHOSTTY_KEY_A + (c - 'a');
+ if (c >= 'A' && c <= 'Z') return GHOSTTY_KEY_A + (c - 'A');
+ if (c >= '0' && c <= '9') return GHOSTTY_KEY_DIGIT_0 + (c - '0');
+ switch (c) {
+ case ' ': return GHOSTTY_KEY_SPACE;
+ case '`': case '~': return GHOSTTY_KEY_BACKQUOTE;
+ case '-': case '_': return GHOSTTY_KEY_MINUS;
+ case '=': case '+': return GHOSTTY_KEY_EQUAL;
+ case '[': case '{': return GHOSTTY_KEY_BRACKET_LEFT;
+ case ']': case '}': return GHOSTTY_KEY_BRACKET_RIGHT;
+ case '\\': case '|': return GHOSTTY_KEY_BACKSLASH;
+ case ';': case ':': return GHOSTTY_KEY_SEMICOLON;
+ case '\'': case '"': return GHOSTTY_KEY_QUOTE;
+ case ',': case '<': return GHOSTTY_KEY_COMMA;
+ case '.': case '>': return GHOSTTY_KEY_PERIOD;
+ case '/': case '?': return GHOSTTY_KEY_SLASH;
+ case '!': return GHOSTTY_KEY_DIGIT_1;
+ case '@': return GHOSTTY_KEY_DIGIT_2;
+ case '#': return GHOSTTY_KEY_DIGIT_3;
+ case '$': return GHOSTTY_KEY_DIGIT_4;
+ case '%': return GHOSTTY_KEY_DIGIT_5;
+ case '^': return GHOSTTY_KEY_DIGIT_6;
+ case '&': return GHOSTTY_KEY_DIGIT_7;
+ case '*': return GHOSTTY_KEY_DIGIT_8;
+ case '(': return GHOSTTY_KEY_DIGIT_9;
+ case ')': return GHOSTTY_KEY_DIGIT_0;
+ default: return GHOSTTY_KEY_UNIDENTIFIED;
+ }
+}
+
+/* CSI <n> ~ */
+static int tilde_key(int n) {
+ switch (n) {
+ case 1: case 7: return GHOSTTY_KEY_HOME;
+ case 2: return GHOSTTY_KEY_INSERT;
+ case 3: return GHOSTTY_KEY_DELETE;
+ case 4: case 8: return GHOSTTY_KEY_END;
+ case 5: return GHOSTTY_KEY_PAGE_UP;
+ case 6: return GHOSTTY_KEY_PAGE_DOWN;
+ case 11: return GHOSTTY_KEY_F1;
+ case 12: return GHOSTTY_KEY_F2;
+ case 13: return GHOSTTY_KEY_F3;
+ case 14: return GHOSTTY_KEY_F4;
+ case 15: return GHOSTTY_KEY_F5;
+ case 17: return GHOSTTY_KEY_F6;
+ case 18: return GHOSTTY_KEY_F7;
+ case 19: return GHOSTTY_KEY_F8;
+ case 20: return GHOSTTY_KEY_F9;
+ case 21: return GHOSTTY_KEY_F10;
+ case 23: return GHOSTTY_KEY_F11;
+ case 24: return GHOSTTY_KEY_F12;
+ default: return GHOSTTY_KEY_UNIDENTIFIED;
+ }
+}
+
+/* final byte of a CSI/SS3 cursor-ish sequence */
+static int final_key(uint8_t f) {
+ switch (f) {
+ case 'A': return GHOSTTY_KEY_ARROW_UP;
+ case 'B': return GHOSTTY_KEY_ARROW_DOWN;
+ case 'C': return GHOSTTY_KEY_ARROW_RIGHT;
+ case 'D': return GHOSTTY_KEY_ARROW_LEFT;
+ case 'E': return GHOSTTY_KEY_NUMPAD_BEGIN;
+ case 'H': return GHOSTTY_KEY_HOME;
+ case 'F': return GHOSTTY_KEY_END;
+ case 'P': return GHOSTTY_KEY_F1;
+ case 'Q': return GHOSTTY_KEY_F2;
+ case 'R': return GHOSTTY_KEY_F3;
+ case 'S': return GHOSTTY_KEY_F4;
+ default: return GHOSTTY_KEY_UNIDENTIFIED;
+ }
+}
+
+/* kitty functional codepoints (CSI u form) */
+static int kitty_key(uint32_t cp) {
+ switch (cp) {
+ case 27: return GHOSTTY_KEY_ESCAPE;
+ case 13: return GHOSTTY_KEY_ENTER;
+ case 9: return GHOSTTY_KEY_TAB;
+ case 127: return GHOSTTY_KEY_BACKSPACE;
+ case 57358: return GHOSTTY_KEY_CAPS_LOCK;
+ case 57359: return GHOSTTY_KEY_SCROLL_LOCK;
+ case 57360: return GHOSTTY_KEY_NUM_LOCK;
+ case 57361: return GHOSTTY_KEY_PRINT_SCREEN;
+ case 57362: return GHOSTTY_KEY_PAUSE;
+ case 57363: return GHOSTTY_KEY_CONTEXT_MENU;
+ case 57441: return GHOSTTY_KEY_SHIFT_LEFT;
+ case 57442: return GHOSTTY_KEY_CONTROL_LEFT;
+ case 57443: return GHOSTTY_KEY_ALT_LEFT;
+ case 57444: return GHOSTTY_KEY_META_LEFT;
+ case 57447: return GHOSTTY_KEY_SHIFT_RIGHT;
+ case 57448: return GHOSTTY_KEY_CONTROL_RIGHT;
+ case 57449: return GHOSTTY_KEY_ALT_RIGHT;
+ case 57450: return GHOSTTY_KEY_META_RIGHT;
+ default:
+ if (cp >= 57376 && cp <= 57398) return GHOSTTY_KEY_F13 + (int)(cp - 57376);
+ if (cp < 128) return ascii_key((uint8_t)cp);
+ return GHOSTTY_KEY_UNIDENTIFIED;
+ }
+}
+
+/* kitty/xterm modifier param (already decremented) -> our bits */
+static uint16_t decode_mods(int m) {
+ uint16_t out = 0;
+ if (m & 1) out |= MOD_SHIFT;
+ if (m & 2) out |= MOD_ALT;
+ if (m & 4) out |= MOD_CTRL;
+ if (m & 8) out |= MOD_SUPER;
+ if (m & 64) out |= MOD_CAPS;
+ if (m & 128) out |= MOD_NUM;
+ return out;
+}
+
+static size_t utf8_len(uint8_t b) {
+ if (b < 0x80) return 1;
+ if ((b & 0xe0) == 0xc0) return 2;
+ if ((b & 0xf0) == 0xe0) return 3;
+ if ((b & 0xf8) == 0xf0) return 4;
+ return 1; /* invalid lead: consume one byte rather than stall */
+}
+
+static uint32_t utf8_decode(const uint8_t *s, size_t n) {
+ if (n == 1) return s[0];
+ if (n == 2) return (uint32_t)(s[0] & 0x1f) << 6 | (s[1] & 0x3f);
+ if (n == 3)
+ return (uint32_t)(s[0] & 0x0f) << 12 | (uint32_t)(s[1] & 0x3f) << 6 |
+ (s[2] & 0x3f);
+ return (uint32_t)(s[0] & 0x07) << 18 | (uint32_t)(s[1] & 0x3f) << 12 |
+ (uint32_t)(s[2] & 0x3f) << 6 | (s[3] & 0x3f);
+}
+
+/* Capture instead of dispatch; used by the ESC-prefix path. */
+static void capture_cb(const input_event_t *ev, void *ud) {
+ *(input_event_t *)ud = *ev;
+}
+
+static void emit_key(input_cb_t cb, void *ud, int key, uint16_t mods,
+ uint8_t action, const char *text, size_t text_len,
+ uint32_t unshifted) {
+ input_event_t ev = {0};
+ ev.kind = EV_KEY;
+ ev.key = key;
+ ev.mods = mods;
+ ev.action = action;
+ ev.unshifted = unshifted;
+ if (text && text_len) {
+ if (text_len > sizeof ev.text) text_len = sizeof ev.text;
+ memcpy(ev.text, text, text_len);
+ ev.text_len = (uint8_t)text_len;
+ }
+ cb(&ev, ud);
+}
+
+/* Parse one event from the front of the buffer.
+ * Returns bytes consumed, or 0 if the buffer holds an incomplete sequence. */
+static size_t parse_one(input_parser_t *p, input_cb_t cb, void *ud) {
+ const uint8_t *b = p->buf;
+ size_t n = p->len;
+ if (n == 0) return 0;
+
+ /* --- inside a bracketed paste: swallow until the end marker ---------- */
+ if (p->in_paste) {
+ static const char end[] = "\x1b[201~";
+ for (size_t i = 0; i + sizeof end - 1 <= n; i++) {
+ if (memcmp(b + i, end, sizeof end - 1) == 0) {
+ input_event_t ev = {.kind = EV_PASTE,
+ .paste = (const char *)b,
+ .paste_len = i};
+ cb(&ev, ud);
+ p->in_paste = false;
+ return i + sizeof end - 1;
+ }
+ }
+ return 0; /* keep accumulating */
+ }
+
+ uint8_t c = b[0];
+
+ if (c == 0x1b) {
+ if (n == 1) return 0; /* bare ESC: resolved by input_timeout() */
+
+ /* --- CSI ---------------------------------------------------------- */
+ if (b[1] == '[') {
+ size_t i = 2;
+ while (i < n && (b[i] < 0x40 || b[i] > 0x7e)) i++;
+ if (i >= n) return 0; /* no final byte yet */
+ uint8_t final = b[i];
+ const uint8_t *params = b + 2;
+ size_t plen = i - 2;
+
+ /* bracketed paste start */
+ if (final == '~' && plen == 3 && memcmp(params, "200", 3) == 0) {
+ p->in_paste = true;
+ return i + 1;
+ }
+
+ /* focus in/out */
+ if (final == 'I' || final == 'O') {
+ input_event_t ev = {.kind = EV_FOCUS, .focused = final == 'I'};
+ cb(&ev, ud);
+ return i + 1;
+ }
+
+ /* SGR mouse: CSI < b ; x ; y M|m */
+ if (plen >= 1 && params[0] == '<' && (final == 'M' || final == 'm')) {
+ int f[3] = {0, 0, 0}, fi = 0;
+ for (size_t k = 1; k < plen && fi < 3; k++) {
+ if (params[k] == ';') { fi++; continue; }
+ if (params[k] >= '0' && params[k] <= '9')
+ f[fi] = f[fi] * 10 + (params[k] - '0');
+ }
+ input_event_t ev = {.kind = EV_MOUSE};
+ int btn = f[0];
+ ev.mx = (uint16_t)(f[1] > 0 ? f[1] - 1 : 0);
+ ev.my = (uint16_t)(f[2] > 0 ? f[2] - 1 : 0);
+ if (btn & 4) ev.mods |= MOD_SHIFT;
+ if (btn & 8) ev.mods |= MOD_ALT;
+ if (btn & 16) ev.mods |= MOD_CTRL;
+ bool motion = (btn & 32) != 0;
+ int base = btn & 0xc3; /* strip mods+motion, keep high wheel bits */
+ if (base >= 64 && base <= 67) {
+ ev.button = (uint8_t)(MBTN_FOUR + (base - 64)); /* wheel up/down/l/r */
+ ev.maction = MOUSE_PRESS;
+ } else {
+ switch (base & 3) {
+ case 0: ev.button = MBTN_LEFT; break;
+ case 1: ev.button = MBTN_MIDDLE; break;
+ case 2: ev.button = MBTN_RIGHT; break;
+ default: ev.button = MBTN_UNKNOWN; break; /* 3 = release/none */
+ }
+ ev.maction = motion ? MOUSE_MOTION
+ : (final == 'M' ? MOUSE_PRESS : MOUSE_RELEASE);
+ }
+ cb(&ev, ud);
+ return i + 1;
+ }
+
+ /* numeric params, ':' sub-params kept for the kitty forms */
+ int nums[8] = {0}, subs[8][3] = {{0}}, ncount = 0, sub_i = 0;
+ bool any = false;
+ for (size_t k = 0; k < plen && ncount < 8; k++) {
+ uint8_t ch = params[k];
+ if (ch >= '0' && ch <= '9') {
+ any = true;
+ if (sub_i == 0) nums[ncount] = nums[ncount] * 10 + (ch - '0');
+ else if (sub_i <= 2) subs[ncount][sub_i] = subs[ncount][sub_i] * 10 + (ch - '0');
+ } else if (ch == ':') {
+ if (sub_i < 2) sub_i++;
+ } else if (ch == ';') {
+ ncount++;
+ sub_i = 0;
+ }
+ }
+ if (any) ncount++;
+
+ int mod_param = ncount >= 2 ? nums[1] : 1;
+ uint16_t mods = decode_mods(mod_param > 0 ? mod_param - 1 : 0);
+ uint8_t action = KEY_PRESS;
+ if (ncount >= 2 && subs[1][1]) {
+ int et = subs[1][1];
+ action = et == 3 ? KEY_RELEASE : (et == 2 ? KEY_REPEAT : KEY_PRESS);
+ }
+
+ /* kitty CSI u */
+ if (final == 'u') {
+ uint32_t cp = (uint32_t)nums[0];
+ uint32_t shifted = (uint32_t)subs[0][1];
+ uint32_t base_layout = (uint32_t)subs[0][2];
+ int key = kitty_key(base_layout ? base_layout : cp);
+ char text[16];
+ size_t tlen = 0;
+ /* associated text (3rd param) wins; else derive from the codepoint */
+ uint32_t out_cp = 0;
+ if (ncount >= 3 && nums[2]) out_cp = (uint32_t)nums[2];
+ else if (!(mods & (MOD_CTRL | MOD_SUPER)) && cp >= 32 && cp != 127)
+ out_cp = (mods & MOD_SHIFT) && shifted ? shifted : cp;
+ if (out_cp) {
+ if (out_cp < 0x80) text[tlen++] = (char)out_cp;
+ else if (out_cp < 0x800) {
+ text[tlen++] = (char)(0xc0 | (out_cp >> 6));
+ text[tlen++] = (char)(0x80 | (out_cp & 0x3f));
+ } else if (out_cp < 0x10000) {
+ text[tlen++] = (char)(0xe0 | (out_cp >> 12));
+ text[tlen++] = (char)(0x80 | ((out_cp >> 6) & 0x3f));
+ text[tlen++] = (char)(0x80 | (out_cp & 0x3f));
+ } else {
+ text[tlen++] = (char)(0xf0 | (out_cp >> 18));
+ text[tlen++] = (char)(0x80 | ((out_cp >> 12) & 0x3f));
+ text[tlen++] = (char)(0x80 | ((out_cp >> 6) & 0x3f));
+ text[tlen++] = (char)(0x80 | (out_cp & 0x3f));
+ }
+ }
+ emit_key(cb, ud, key, mods, action, text, tlen, cp);
+ return i + 1;
+ }
+
+ if (final == '~') {
+ emit_key(cb, ud, tilde_key(nums[0]), mods, action, NULL, 0, 0);
+ return i + 1;
+ }
+
+ int key = final_key(final);
+ if (key != GHOSTTY_KEY_UNIDENTIFIED) {
+ emit_key(cb, ud, key, mods, action, NULL, 0, 0);
+ return i + 1;
+ }
+ return i + 1; /* unrecognised CSI: consume, do not forward */
+ }
+
+ /* --- SS3 ---------------------------------------------------------- */
+ if (b[1] == 'O') {
+ if (n < 3) return 0;
+ int key = final_key(b[2]);
+ if (key != GHOSTTY_KEY_UNIDENTIFIED)
+ emit_key(cb, ud, key, 0, KEY_PRESS, NULL, 0, 0);
+ return 3;
+ }
+
+ /* --- ESC <byte>: legacy alt ---------------------------------------- */
+ /* Parse the tail as its own event and add ALT, so alt+<utf8> and
+ * alt+ctrl-x both fall out of the same code path. */
+ {
+ input_parser_t tail = {.buf = p->buf + 1, .len = n - 1, .cap = n - 1};
+ input_event_t captured = {0};
+ size_t used = parse_one(&tail, capture_cb, &captured);
+ if (used == 0) return 0; /* incomplete: wait for more bytes */
+ if (captured.kind == EV_KEY) captured.mods |= MOD_ALT;
+ if (captured.kind != EV_NONE) cb(&captured, ud);
+ return used + 1;
+ }
+ }
+
+ /* --- C0 controls ----------------------------------------------------- */
+ if (c == '\r') { emit_key(cb, ud, GHOSTTY_KEY_ENTER, 0, KEY_PRESS, "\r", 1, 13); return 1; }
+ if (c == '\n') { emit_key(cb, ud, GHOSTTY_KEY_ENTER, 0, KEY_PRESS, "\r", 1, 13); return 1; }
+ if (c == '\t') { emit_key(cb, ud, GHOSTTY_KEY_TAB, 0, KEY_PRESS, "\t", 1, 9); return 1; }
+ if (c == 0x7f) { emit_key(cb, ud, GHOSTTY_KEY_BACKSPACE, 0, KEY_PRESS, NULL, 0, 127); return 1; }
+ if (c == 0x08) { emit_key(cb, ud, GHOSTTY_KEY_BACKSPACE, 0, KEY_PRESS, NULL, 0, 8); return 1; }
+ if (c == 0x00) { emit_key(cb, ud, GHOSTTY_KEY_SPACE, MOD_CTRL, KEY_PRESS, NULL, 0, ' '); return 1; }
+ if (c < 0x20) {
+ int key;
+ switch (c) {
+ case 0x1c: key = GHOSTTY_KEY_BACKSLASH; break;
+ case 0x1d: key = GHOSTTY_KEY_BRACKET_RIGHT; break;
+ case 0x1e: key = GHOSTTY_KEY_DIGIT_6; break;
+ case 0x1f: key = GHOSTTY_KEY_MINUS; break;
+ default: key = GHOSTTY_KEY_A + (c - 1); break;
+ }
+ uint32_t unshifted = c < 0x1b ? (uint32_t)('a' + c - 1) : 0;
+ emit_key(cb, ud, key, MOD_CTRL, KEY_PRESS, NULL, 0, unshifted);
+ return 1;
+ }
+
+ /* --- text ------------------------------------------------------------ */
+ size_t ulen = utf8_len(c);
+ if (ulen > n) return 0; /* split UTF-8 across reads */
+ uint32_t cp = utf8_decode(b, ulen);
+ int key = cp < 128 ? ascii_key((uint8_t)cp) : GHOSTTY_KEY_UNIDENTIFIED;
+ uint16_t mods = 0;
+ if (cp < 128 && cp >= 'A' && cp <= 'Z') mods |= MOD_SHIFT;
+ uint32_t unshifted = cp;
+ if (cp >= 'A' && cp <= 'Z') unshifted = cp + 32;
+ emit_key(cb, ud, key, mods, KEY_PRESS, (const char *)b, ulen, unshifted);
+ return ulen;
+}
+
+void input_feed(input_parser_t *p, const uint8_t *data, size_t len,
+ input_cb_t cb, void *ud) {
+ buf_append(p, data, len);
+ for (;;) {
+ size_t used = parse_one(p, cb, ud);
+ if (used == 0) break;
+ buf_consume(p, used);
+ if (p->len == 0) break;
+ }
+}
+
+bool input_pending(const input_parser_t *p) { return p->len > 0; }
+
+void input_timeout(input_parser_t *p, input_cb_t cb, void *ud) {
+ if (p->len == 0) return;
+ if (p->buf[0] == 0x1b) {
+ emit_key(cb, ud, GHOSTTY_KEY_ESCAPE, 0, KEY_PRESS, NULL, 0, 27);
+ buf_consume(p, 1);
+ /* whatever followed is now re-parsed as ordinary input */
+ input_feed(p, NULL, 0, cb, ud);
+ return;
+ }
+ p->len = 0; /* an unterminated non-ESC sequence is garbage; drop it */
+}
+
+/* ---- description (tests, logs) ------------------------------------------ */
+
+static const char *key_name(int key) {
+ switch (key) {
+ case GHOSTTY_KEY_ARROW_UP: return "ARROW_UP";
+ case GHOSTTY_KEY_ARROW_DOWN: return "ARROW_DOWN";
+ case GHOSTTY_KEY_ARROW_LEFT: return "ARROW_LEFT";
+ case GHOSTTY_KEY_ARROW_RIGHT: return "ARROW_RIGHT";
+ case GHOSTTY_KEY_ENTER: return "ENTER";
+ case GHOSTTY_KEY_TAB: return "TAB";
+ case GHOSTTY_KEY_ESCAPE: return "ESCAPE";
+ case GHOSTTY_KEY_BACKSPACE: return "BACKSPACE";
+ case GHOSTTY_KEY_SPACE: return "SPACE";
+ case GHOSTTY_KEY_HOME: return "HOME";
+ case GHOSTTY_KEY_END: return "END";
+ case GHOSTTY_KEY_INSERT: return "INSERT";
+ case GHOSTTY_KEY_DELETE: return "DELETE";
+ case GHOSTTY_KEY_PAGE_UP: return "PAGE_UP";
+ case GHOSTTY_KEY_PAGE_DOWN: return "PAGE_DOWN";
+ case GHOSTTY_KEY_UNIDENTIFIED: return "UNIDENTIFIED";
+ default: break;
+ }
+ if (key >= GHOSTTY_KEY_A && key <= GHOSTTY_KEY_Z) {
+ static char buf[2];
+ buf[0] = (char)('a' + (key - GHOSTTY_KEY_A));
+ buf[1] = 0;
+ return buf;
+ }
+ if (key >= GHOSTTY_KEY_DIGIT_0 && key <= GHOSTTY_KEY_DIGIT_9) {
+ static char buf[2];
+ buf[0] = (char)('0' + (key - GHOSTTY_KEY_DIGIT_0));
+ buf[1] = 0;
+ return buf;
+ }
+ if (key >= GHOSTTY_KEY_F1 && key <= GHOSTTY_KEY_F12) {
+ static char buf[4];
+ snprintf(buf, sizeof buf, "F%d", key - GHOSTTY_KEY_F1 + 1);
+ return buf;
+ }
+ static char other[16];
+ snprintf(other, sizeof other, "key#%d", key);
+ return other;
+}
+
+void input_event_describe(const input_event_t *ev, char *buf, size_t cap) {
+ char mods[64] = "";
+ if (ev->mods & MOD_SHIFT) strcat(mods, "S");
+ if (ev->mods & MOD_CTRL) strcat(mods, "C");
+ if (ev->mods & MOD_ALT) strcat(mods, "A");
+ if (ev->mods & MOD_SUPER) strcat(mods, "M");
+
+ switch (ev->kind) {
+ case EV_KEY: {
+ const char *act = ev->action == KEY_PRESS ? "press"
+ : ev->action == KEY_RELEASE ? "release" : "repeat";
+ char text[32] = "";
+ if (ev->text_len) {
+ snprintf(text, sizeof text, " text=%.*s", (int)ev->text_len, ev->text);
+ }
+ snprintf(buf, cap, "key %s mods=%s %s%s", key_name(ev->key),
+ *mods ? mods : "-", act, text);
+ break;
+ }
+ case EV_MOUSE: {
+ const char *act = ev->maction == MOUSE_PRESS ? "press"
+ : ev->maction == MOUSE_RELEASE ? "release" : "motion";
+ snprintf(buf, cap, "mouse btn=%u %s at=%u,%u mods=%s", ev->button, act,
+ ev->mx, ev->my, *mods ? mods : "-");
+ break;
+ }
+ case EV_PASTE:
+ snprintf(buf, cap, "paste len=%zu text=%.*s", ev->paste_len,
+ (int)(ev->paste_len > 20 ? 20 : ev->paste_len), ev->paste);
+ break;
+ case EV_FOCUS:
+ snprintf(buf, cap, "focus %s", ev->focused ? "in" : "out");
+ break;
+ default:
+ snprintf(buf, cap, "none");
+ break;
+ }
+}
diff --git a/src/input.h b/src/input.h
new file mode 100644
index 0000000..b92b2b0
--- /dev/null
+++ b/src/input.h
@@ -0,0 +1,84 @@
+/* Input decoding: the bytes the *outer* terminal sends us -> semantic events.
+ *
+ * libghostty-vt encodes events outward to a pane, but nothing decodes inward,
+ * so this half is ours (DESIGN.md). Decoding to a semantic event and
+ * re-encoding per pane against that pane's own modes is what makes kitty
+ * keyboard passthrough correct instead of approximate.
+ *
+ * The event carries GhosttyKey / GHOSTTY_MODS_* values as plain integers so
+ * this header stays free of libghostty-vt.
+ */
+#ifndef SL0PTTY_INPUT_H
+#define SL0PTTY_INPUT_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+typedef enum {
+ EV_NONE = 0,
+ EV_KEY,
+ EV_MOUSE,
+ EV_PASTE,
+ EV_FOCUS,
+} ev_kind_t;
+
+/* mirrors GhosttyKeyAction */
+enum { KEY_RELEASE = 0, KEY_PRESS = 1, KEY_REPEAT = 2 };
+/* mirrors GhosttyMouseAction */
+enum { MOUSE_PRESS = 0, MOUSE_RELEASE = 1, MOUSE_MOTION = 2 };
+/* mirrors GhosttyMouseButton */
+enum {
+ MBTN_UNKNOWN = 0, MBTN_LEFT = 1, MBTN_RIGHT = 2, MBTN_MIDDLE = 3,
+ MBTN_FOUR = 4, MBTN_FIVE = 5, MBTN_SIX = 6, MBTN_SEVEN = 7,
+};
+/* mirrors GHOSTTY_MODS_* */
+enum {
+ MOD_SHIFT = 1 << 0, MOD_CTRL = 1 << 1, MOD_ALT = 1 << 2, MOD_SUPER = 1 << 3,
+ MOD_CAPS = 1 << 4, MOD_NUM = 1 << 5,
+};
+
+typedef struct {
+ ev_kind_t kind;
+
+ /* EV_KEY */
+ int key; /* GhosttyKey */
+ uint16_t mods; /* MOD_* */
+ uint8_t action; /* KEY_* */
+ char text[16]; /* UTF-8 the key produced, if any */
+ uint8_t text_len;
+ uint32_t unshifted; /* unshifted codepoint, 0 if unknown */
+
+ /* EV_MOUSE */
+ uint8_t button; /* MBTN_* */
+ uint8_t maction; /* MOUSE_* */
+ uint16_t mx, my; /* 0-indexed cell coordinates */
+
+ /* EV_PASTE */
+ const char *paste;
+ size_t paste_len;
+
+ /* EV_FOCUS */
+ bool focused;
+} input_event_t;
+
+typedef struct input_parser input_parser_t;
+typedef void (*input_cb_t)(const input_event_t *ev, void *ud);
+
+input_parser_t *input_new(void);
+void input_free(input_parser_t *p);
+
+/* Feed bytes; complete events are dispatched to cb. Partial sequences are
+ * retained for the next call, so split reads are safe. */
+void input_feed(input_parser_t *p, const uint8_t *data, size_t len,
+ input_cb_t cb, void *ud);
+
+/* True when a bare ESC (or an unterminated sequence) is buffered. Call
+ * input_timeout() after ~50ms of quiet to resolve it as the Escape key. */
+bool input_pending(const input_parser_t *p);
+void input_timeout(input_parser_t *p, input_cb_t cb, void *ud);
+
+/* Debug/test rendering of an event, e.g. "key ARROW_UP mods=CTRL press". */
+void input_event_describe(const input_event_t *ev, char *buf, size_t cap);
+
+#endif /* SL0PTTY_INPUT_H */
diff --git a/src/main.c b/src/main.c
index 5514328..a2a587c 100644
--- a/src/main.c
+++ b/src/main.c
@@ -4,6 +4,8 @@
#define _GNU_SOURCE
#include "sl0ptty.h"
+#include <ghostty/vt.h>
+
#include <errno.h>
#include <poll.h>
#include <signal.h>
@@ -16,7 +18,22 @@
#include <time.h>
#include <unistd.h>
-#define FRAME_MS 8 /* ~120Hz cap; pty reads are coalesced into one paint */
+#define FRAME_MS 8 /* ~120Hz cap; pty reads are coalesced into one paint */
+#define ESC_MS 40 /* how long a bare ESC waits to become a sequence */
+
+/* What we ask the outer terminal for. Kitty keyboard flag 1 (disambiguate)
+ * costs nothing on terminals that ignore it, and everything a pane receives is
+ * re-encoded for that pane anyway, so an inner app that has never heard of
+ * kitty still gets legacy bytes. */
+#define TERM_ENTER \
+ "\x1b[?1049h\x1b[0m\x1b[2J" /* alt screen */ \
+ "\x1b[?1002h\x1b[?1006h" /* mouse: button+drag, SGR encoding */ \
+ "\x1b[?1004h" /* focus in/out */ \
+ "\x1b[?2004h" /* bracketed paste */ \
+ "\x1b[>1u" /* kitty keyboard, pushed */
+#define TERM_LEAVE \
+ "\x1b[<u\x1b[?2004l\x1b[?1004l\x1b[?1006l\x1b[?1002l" \
+ "\x1b[?25h\x1b[0m\x1b[?1049l"
static volatile sig_atomic_t g_winch = 0, g_quit = 0;
static struct termios g_saved_tio;
@@ -29,8 +46,7 @@ static void on_signal(int sig) {
static void restore_tty(void) {
if (g_tio_saved) tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_saved_tio);
- const char *leave = "\x1b[?25h\x1b[0m\x1b[?1049l";
- ssize_t r = write(STDOUT_FILENO, leave, strlen(leave));
+ ssize_t r = write(STDOUT_FILENO, TERM_LEAVE, strlen(TERM_LEAVE));
(void)r;
}
@@ -43,8 +59,7 @@ static int raw_mode(void) {
t.c_cc[VTIME] = 0;
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) != 0) return -1;
atexit(restore_tty);
- const char *enter = "\x1b[?1049h\x1b[0m\x1b[2J";
- ssize_t r = write(STDOUT_FILENO, enter, strlen(enter));
+ ssize_t r = write(STDOUT_FILENO, TERM_ENTER, strlen(TERM_ENTER));
(void)r;
return 0;
}
@@ -102,6 +117,41 @@ static int run_headless(const char *const argv[], uint16_t cols, uint16_t rows,
/* ---- interactive -------------------------------------------------------- */
+/* Where decoded events go. The prefix (C-a) is handled here, on semantic
+ * events, so it cannot be confused by a byte that merely looks like C-a in the
+ * middle of a UTF-8 sequence or an escape. */
+typedef struct {
+ pane_t *pane;
+ bool prefix;
+ volatile sig_atomic_t *quit;
+} dispatch_t;
+
+static void on_event(const input_event_t *ev, void *ud) {
+ dispatch_t *d = ud;
+
+ if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) {
+ bool ctrl_a = (ev->mods & MOD_CTRL) && ev->unshifted == 'a';
+ if (d->prefix) {
+ d->prefix = false;
+ if (ev->key == GHOSTTY_KEY_Q) { *d->quit = 1; return; }
+ if (ctrl_a) { pane_send_key(d->pane, ev); return; } /* C-a C-a = literal */
+ return; /* unbound: swallow */
+ }
+ if (ctrl_a) {
+ d->prefix = true;
+ return;
+ }
+ }
+
+ switch (ev->kind) {
+ case EV_KEY: pane_send_key(d->pane, ev); break;
+ case EV_MOUSE: pane_send_mouse(d->pane, ev); break;
+ case EV_PASTE: pane_send_paste(d->pane, ev->paste, ev->paste_len); break;
+ case EV_FOCUS: break; /* M1: focus follows the layout, not the client */
+ default: break;
+ }
+}
+
static int run_interactive(const char *const argv[]) {
uint16_t cols, rows;
term_size(&cols, &rows);
@@ -120,9 +170,12 @@ static int run_interactive(const char *const argv[]) {
screen_t s;
screen_init(&s, cols, rows);
+ input_parser_t *in = input_new();
+ dispatch_t d = {.pane = p, .quit = &g_quit};
+
bool pending_paint = true;
int64_t next_frame = 0;
- bool prefix = false; /* M0 scaffolding: C-a q quits, C-a C-a sends C-a */
+ int64_t esc_due = 0;
while (!g_quit && pane_alive(p)) {
int timeout = -1;
@@ -130,6 +183,11 @@ static int run_interactive(const char *const argv[]) {
int64_t due = next_frame - now_ms();
timeout = due <= 0 ? 0 : (int)due;
}
+ if (input_pending(in)) {
+ int64_t due = esc_due - now_ms();
+ int t = due <= 0 ? 0 : (int)due;
+ if (timeout < 0 || t < timeout) timeout = t;
+ }
struct pollfd pfds[2] = {
{.fd = STDIN_FILENO, .events = POLLIN},
@@ -149,22 +207,17 @@ static int run_interactive(const char *const argv[]) {
if (n < 0 && errno != EINTR) break;
if (n > 0 && (pfds[0].revents & POLLIN)) {
- char buf[8192];
+ uint8_t buf[8192];
ssize_t r = read(STDIN_FILENO, buf, sizeof buf);
if (r > 0) {
- for (ssize_t i = 0; i < r; i++) {
- if (prefix) {
- prefix = false;
- if (buf[i] == 'q') { g_quit = 1; break; }
- if (buf[i] == 0x01) { pane_write(p, "\x01", 1); continue; }
- continue; /* unknown prefix command: swallow */
- }
- if (buf[i] == 0x01) { prefix = true; continue; }
- pane_write(p, &buf[i], 1);
- }
+ input_feed(in, buf, (size_t)r, on_event, &d);
+ esc_due = now_ms() + ESC_MS;
}
}
+ /* A lone ESC is only the Escape key once nothing follows it. */
+ if (input_pending(in) && now_ms() >= esc_due) input_timeout(in, on_event, &d);
+
if (n > 0 && (pfds[1].revents & (POLLIN | POLLHUP))) {
pane_pump(p);
if (!pending_paint) {
@@ -180,6 +233,7 @@ static int run_interactive(const char *const argv[]) {
}
}
+ input_free(in);
screen_free(&s);
pane_free(p);
restore_tty();
diff --git a/src/pane.c b/src/pane.c
index ffa2aa4..f5eeac7 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -9,12 +9,18 @@
#include <string.h>
#include <unistd.h>
+#include "input.h"
+
struct pane {
pty_t pty;
GhosttyTerminal term;
GhosttyRenderState rstate;
GhosttyRenderStateRowIterator rows;
GhosttyRenderStateRowCells cells;
+ GhosttyKeyEncoder kenc;
+ GhosttyKeyEvent kev;
+ GhosttyMouseEncoder menc;
+ GhosttyMouseEvent mev;
uint16_t cols, rows_n;
bool alive;
bool dirty;
@@ -60,6 +66,10 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
goto fail;
if (ghostty_render_state_row_cells_new(NULL, &p->cells) != GHOSTTY_SUCCESS)
goto fail;
+ if (ghostty_key_encoder_new(NULL, &p->kenc) != GHOSTTY_SUCCESS) goto fail;
+ if (ghostty_key_event_new(NULL, &p->kev) != GHOSTTY_SUCCESS) goto fail;
+ if (ghostty_mouse_encoder_new(NULL, &p->menc) != GHOSTTY_SUCCESS) goto fail;
+ if (ghostty_mouse_event_new(NULL, &p->mev) != GHOSTTY_SUCCESS) goto fail;
void *self = p;
GhosttyTerminalWritePtyFn wfn = on_write_pty;
@@ -81,6 +91,10 @@ fail:
void pane_free(pane_t *p) {
if (!p) return;
if (p->pty.fd >= 0) pty_close(&p->pty);
+ if (p->mev) ghostty_mouse_event_free(p->mev);
+ if (p->menc) ghostty_mouse_encoder_free(p->menc);
+ if (p->kev) ghostty_key_event_free(p->kev);
+ if (p->kenc) ghostty_key_encoder_free(p->kenc);
if (p->cells) ghostty_render_state_row_cells_free(p->cells);
if (p->rows) ghostty_render_state_row_iterator_free(p->rows);
if (p->rstate) ghostty_render_state_free(p->rstate);
@@ -132,6 +146,69 @@ void pane_write(pane_t *p, const void *buf, size_t len) {
}
}
+/* Re-encode a decoded event for *this* pane, against the modes this pane has
+ * actually negotiated (kitty flags, cursor-key application mode, alt-esc
+ * prefix, modifyOtherKeys). This is the reason we decode at all. */
+void pane_send_key(pane_t *p, const input_event_t *ev) {
+ ghostty_key_encoder_setopt_from_terminal(p->kenc, p->term);
+ ghostty_key_event_set_key(p->kev, (GhosttyKey)ev->key);
+ ghostty_key_event_set_mods(p->kev, (GhosttyMods)ev->mods);
+ ghostty_key_event_set_action(p->kev, (GhosttyKeyAction)ev->action);
+ ghostty_key_event_set_utf8(p->kev, ev->text_len ? ev->text : NULL,
+ ev->text_len);
+ ghostty_key_event_set_unshifted_codepoint(p->kev, ev->unshifted);
+
+ char out[128];
+ size_t n = 0;
+ if (ghostty_key_encoder_encode(p->kenc, p->kev, out, sizeof out, &n) ==
+ GHOSTTY_SUCCESS &&
+ n > 0)
+ pane_write(p, out, n);
+}
+
+void pane_send_mouse(pane_t *p, const input_event_t *ev) {
+ ghostty_mouse_encoder_setopt_from_terminal(p->menc, p->term);
+
+ /* The encoder works in surface pixels. We have no pixels, so we declare a
+ * 1x1-pixel cell: cell coordinates and "pixels" become the same number, and
+ * SGR-pixel mode degrades to cell precision instead of lying. */
+ GhosttyMouseEncoderSize size = GHOSTTY_INIT_SIZED(GhosttyMouseEncoderSize);
+ size.screen_width = p->cols;
+ size.screen_height = p->rows_n;
+ size.cell_width = 1;
+ size.cell_height = 1;
+ ghostty_mouse_encoder_setopt(p->menc, GHOSTTY_MOUSE_ENCODER_OPT_SIZE, &size);
+
+ GhosttyMousePosition pos = {.x = (float)ev->mx, .y = (float)ev->my};
+ ghostty_mouse_event_set_position(p->mev, pos);
+ ghostty_mouse_event_set_button(p->mev, (GhosttyMouseButton)ev->button);
+ ghostty_mouse_event_set_action(p->mev, (GhosttyMouseAction)ev->maction);
+ ghostty_mouse_event_set_mods(p->mev, (GhosttyMods)ev->mods);
+
+ char out[128];
+ size_t n = 0;
+ if (ghostty_mouse_encoder_encode(p->menc, p->mev, out, sizeof out, &n) ==
+ GHOSTTY_SUCCESS &&
+ n > 0)
+ pane_write(p, out, n);
+}
+
+/* Bracketed-paste aware, and lib-vt decides whether the payload is safe. */
+void pane_send_paste(pane_t *p, const char *text, size_t len) {
+ GhosttyTerminalModeConfig mc = {.mode = ghostty_mode_new(2004, false)};
+ ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_MODE, &mc);
+ bool bracketed = mc.value;
+
+ size_t need = 0;
+ ghostty_paste_encode((char *)text, len, bracketed, NULL, 0, &need);
+ char *buf = malloc(need ? need : len + 16);
+ size_t n = 0;
+ if (ghostty_paste_encode((char *)text, len, bracketed, buf,
+ need ? need : len + 16, &n) == GHOSTTY_SUCCESS)
+ pane_write(p, buf, n);
+ free(buf);
+}
+
void pane_resize(pane_t *p, uint16_t cols, uint16_t rows) {
if (cols == p->cols && rows == p->rows_n) return;
p->cols = cols;
diff --git a/src/sl0ptty.h b/src/sl0ptty.h
index 52b9a09..c71ec9e 100644
--- a/src/sl0ptty.h
+++ b/src/sl0ptty.h
@@ -7,6 +7,8 @@
#include <stdint.h>
#include <sys/types.h>
+#include "input.h"
+
/* ---- screen: our composited cell buffer + diff emitter ---------------- */
enum {
@@ -88,6 +90,10 @@ bool pane_alive(const pane_t *p);
* -1 on error (EAGAIN is reported as 0 bytes with alive still true). */
ssize_t pane_pump(pane_t *p);
void pane_write(pane_t *p, const void *buf, size_t len);
+/* Re-encode a decoded event against this pane's own negotiated modes. */
+void pane_send_key(pane_t *p, const input_event_t *ev);
+void pane_send_mouse(pane_t *p, const input_event_t *ev);
+void pane_send_paste(pane_t *p, const char *text, size_t len);
void pane_resize(pane_t *p, uint16_t cols, uint16_t rows);
bool pane_dirty(pane_t *p);
const char *pane_title(const pane_t *p);