TUI: kitty keyboard protocol, word movement and deletion keys
ci / linux (clang) (push) Failing after 23s
ci / linux (gcc) (push) Failing after 22s
ci / sanitizers (push) Failing after 21s
ci / musl (push) Failing after 1m12s
ci / macos (push) Canceled after 0s

Query CSI ? u at startup and push the disambiguate flag on the alternate
screen when the terminal answers; CSI u reports are mapped back to legacy
input (Shift/Ctrl+Enter insert a newline, Ctrl+Backspace deletes a word).
Ctrl/Alt+Left/Right move by word, Ctrl/Alt+Delete delete the next word.
Our define_key codes move above the range ncurses uses for terminfo
extended keys.
This commit is contained in:
WHC
2026-10-06 01:52:48 +08:00
parent 8efd63bb31
commit b28bca2cfc
6 changed files with 251 additions and 14 deletions
+4 -2
View File
@@ -318,8 +318,10 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|---|---|
| Enter | 发送;busy 时入队 |
| Ctrl+N / Alt+Enter | 换行(粘贴的多行文本保持原样,不会被当成发送) |
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F | 移动光标(按字 / 按词,支持中文宽字符) |
| Ctrl+W / Ctrl+U / Ctrl+K / Alt+Backspace / Delete | 删词 / 删到行首 / 删到行尾 |
| Shift+Enter / Ctrl+Enter | 换行;需要支持 kitty 键盘协议的终端(kitty、WezTerm、foot、Ghostty、WHC Shell 等),启动时自动探测 |
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F Ctrl/Alt+← → | 移动光标(按字 / 按词,支持中文宽字符) |
| Ctrl+W / Alt+Backspace / Ctrl+Backspace / Ctrl+Delete | 向前删词 / 向后删词(Ctrl+Backspace 需要 kitty 键盘协议) |
| Ctrl+U / Ctrl+K / Delete | 删到行首 / 删到行尾 / 删后一个字符 |
| ↑ / ↓ | 提示历史(多行输入时先在行间移动) |
| Tab | 补全 `/命令` 和 `@文件`;没有可补全时切换 Build / Plan(Plan 只开放只读工具) |
| Shift+Tab | 循环切换审批模式 ask → auto → yolo |
+7
View File
@@ -87,6 +87,13 @@ take the agent down.
- Bracketed paste needs ncurses (all platforms above).
- macOS Terminal.app sends Option+Enter as Esc+Enter only with "Use Option as
Meta key"; Ctrl+N always inserts a newline.
- Kitty keyboard protocol: at startup the TUI sends `CSI ? u`; if the terminal
answers (kitty, WezTerm, foot, Ghostty, WHC Shell, ...) it pushes the
"disambiguate" flag (`CSI > 1 u`) on the alternate screen and pops it on
exit. That makes Shift+Enter / Ctrl+Enter (newline), Ctrl+Backspace (delete
word) and an instant Esc work. Other terminals (and tmux) don't answer and
keep the legacy keys. If the TUI is killed, the main screen's keyboard mode
is untouched because the flags live on the alternate screen's stack.
## Acceptance checklist
+8
View File
@@ -90,6 +90,14 @@ int cst_key_parse(const char *s);
/* code → canonical name ("ctrl+x", "tab", "alt+up", ...). */
const char *cst_key_name(int code, char *buf, size_t n);
/* Kitty keyboard protocol ("disambiguate" flag): the report CSI cp;1+mods u
* (mods: 1 shift, 2 alt, 4 ctrl) → the input a legacy terminal would send,
* so the rest of the TUI needs no changes. Alt becomes an Esc prefix;
* shift/ctrl+enter become alt+enter (newline) and ctrl+backspace becomes
* alt+backspace (delete word); shift+tab is CST_K_BTAB. Writes up to two codes
* to out and returns how many (0 when the report means nothing to us). */
int cst_key_from_kitty(int cp, int mods, int out[2]);
#define CST_KEYMAP_MAX 128
typedef struct {
int action;
+64
View File
@@ -291,6 +291,70 @@ const char *cst_key_name(int code, char *buf, size_t n) {
return buf;
}
/* Shift on a US layout; the kitty report names the unshifted key. */
static int us_shift(int c) {
static const char PLAIN[] = "`1234567890-=[]\\;',./";
static const char SHIFTED[] = "~!@#$%^&*()_+{}|:\"<>?";
if (c >= 'a' && c <= 'z')
return c - 'a' + 'A';
const char *p = c > 0 && c < 0x80 ? strchr(PLAIN, c) : NULL;
return p ? SHIFTED[p - PLAIN] : c;
}
/* The control character xterm sends for ctrl+c, or c when there is none. */
static int ctrl_char(int c) {
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))
return c & 0x1f;
switch (c) {
case ' ': case '2': case '@': return 0;
case '3': case '[': return 27;
case '4': case '\\': return 28;
case '5': case ']': return 29;
case '6': case '^': return 30;
case '7': case '-': case '_': case '/': return 31;
case '8': case '?': return 127;
default: return c;
}
}
int cst_key_from_kitty(int cp, int mods, int out[2]) {
int shift = mods & 1, alt = mods & 2, ctrl = mods & 4;
if (mods & ~7)
return 0; /* super, hyper, meta, caps/num lock: not ours */
int c;
switch (cp) {
case 13:
if (!mods)
return out[0] = 13, 1;
out[0] = 27, out[1] = 13; /* newline */
return 2;
case 127:
if (!alt && !ctrl)
return out[0] = 127, 1;
out[0] = 27, out[1] = 127; /* delete word */
return 2;
case 9:
if (shift && !alt && !ctrl)
return out[0] = CST_K_BTAB, 1;
c = 9;
break;
case 27:
c = 27;
break;
default:
if (cp < ' ' || cp > 0x10FFFF)
return 0;
c = shift ? us_shift(cp) : cp;
if (ctrl)
c = ctrl_char(c);
break;
}
if (!alt)
return out[0] = c, 1;
out[0] = 27, out[1] = c;
return 2;
}
/* "ctrl+x n", "<leader> n" or "f2" → keys; returns the count (0 on error). */
static int parse_seq(const cst_keymap_t *km, const char *s, int keys[2]) {
int n = 0;
+131 -12
View File
@@ -127,9 +127,90 @@ static int cst_clock_gettime_mono(struct timespec *ts) {
#define COMP_MAX 8
#define TODO_PANEL_MAX 6
#define ASK_OPTS_MAX 9
/* Bracketed paste markers, bound with define_key() above KEY_MAX. */
#define KEY_PASTE_BEGIN (KEY_MAX + 1)
#define KEY_PASTE_END (KEY_MAX + 2)
/* Our define_key() codes. ncurses numbers the terminfo extended keys (kLFT5,
* kDC3, ...) from KEY_MAX up, and key codes must fit in 16 bits, so stay well
* clear of the first and below the second. */
#define KEY_OWN_BASE (KEY_MAX + 0x1000)
/* Bracketed paste markers. */
#define KEY_PASTE_BEGIN (KEY_OWN_BASE + 1)
#define KEY_PASTE_END (KEY_OWN_BASE + 2)
/* Kitty keyboard protocol: the reply to CSI ? u, and CSI cp;1+mods u reports
* bound as KEY_KITTY_BASE + cp * 8 + mods for ASCII code points. */
#define KEY_KITTY_REPLY (KEY_OWN_BASE + 3)
#define KEY_KITTY_BASE (KEY_OWN_BASE + 16)
#define KITTY_CP_END 128
#ifdef NCURSES_VERSION
static int g_kitty_on;
/* Bind the reports and ask whether the terminal speaks the protocol; the
* flags are pushed once the reply arrives (see tui_wget_wch). */
static void kitty_init(void) {
char seq[24];
for (int f = 0; f < 32; f++) {
snprintf(seq, sizeof(seq), "\033[?%du", f);
define_key(seq, KEY_KITTY_REPLY);
}
for (int cp = 9; cp < KITTY_CP_END; cp++) {
if (cp != 9 && cp != 13 && cp != 27 && cp < ' ')
continue;
if (cp == 27)
define_key("\033[27u", KEY_KITTY_BASE + cp * 8);
for (int m = 1; m < 8; m++) {
snprintf(seq, sizeof(seq), "\033[%d;%du", cp, m + 1);
define_key(seq, KEY_KITTY_BASE + cp * 8 + m);
}
}
printf("\033[?u");
fflush(stdout);
}
/* Pop our flags before leaving; harmless if they were never pushed. */
static void kitty_restore(void) {
if (g_kitty_on) {
printf("\033[<u");
fflush(stdout);
g_kitty_on = 0;
}
}
#endif
/* wget_wch() that turns kitty key reports back into legacy input. */
static int tui_wget_wch(WINDOW *w, wint_t *wch) {
for (;;) {
int rc = wget_wch(w, wch);
#ifdef NCURSES_VERSION
if (rc != KEY_CODE_YES)
return rc;
if (*wch == KEY_KITTY_REPLY) {
if (!g_kitty_on) {
/* disambiguate: Esc, alt+x, ctrl+x, shift+enter... as CSI u */
printf("\033[>1u");
fflush(stdout);
g_kitty_on = 1;
}
continue;
}
int k = (int)*wch - KEY_KITTY_BASE;
if (k < 0 || k >= KITTY_CP_END * 8)
return rc;
int out[2];
int n = cst_key_from_kitty(k / 8, k % 8, out);
if (n == 0)
continue;
if (n == 2)
unget_wch((wchar_t)out[1]);
if (out[0] == CST_K_BTAB) {
*wch = KEY_BTAB;
return KEY_CODE_YES;
}
*wch = (wint_t)out[0];
return OK;
#else
return rc;
#endif
}
}
/* Color pair indices */
enum {
@@ -3411,7 +3492,7 @@ static int picker_run(tui_t *t, const char *title, char items[][128], int n,
wrefresh(dlg);
wint_t wch = 0;
int rc = wget_wch(dlg, &wch);
int rc = tui_wget_wch(dlg, &wch);
if (rc == ERR)
continue;
if (rc == KEY_CODE_YES) {
@@ -3542,7 +3623,7 @@ static int secret_input_run(tui_t *t, const char *title, const char *hint,
refresh();
wint_t wch = 0;
int r = get_wch(&wch);
int r = tui_wget_wch(stdscr, &wch);
if (r == ERR)
continue;
if (r == KEY_CODE_YES) {
@@ -5634,8 +5715,10 @@ static int run_keys_panel(tui_t *t, int busy, int *quit) {
"Edit cancel the turn / close popups esc",
"Edit clear input, twice to quit ctrl+c",
"Edit quit on an empty prompt ctrl+d",
"Edit move by char / word / line ← → alt+b alt+f ctrl+a ctrl+e",
"Edit delete word / to start / to end ctrl+w ctrl+u ctrl+k",
"Edit new line (kitty keyboard terminals) shift+enter ctrl+enter",
"Edit move by char / word / line ← → ctrl+← → alt+b alt+f ctrl+a ctrl+e",
"Edit delete word back / forward ctrl+w alt+backspace ctrl+backspace ctrl+del",
"Edit delete to start / to end ctrl+u ctrl+k",
"Edit prompt history ↑ ↓",
};
for (size_t i = 0; i < sizeof(FIXED) / sizeof(FIXED[0]); i++) {
@@ -5656,7 +5739,8 @@ static int run_keys_panel(tui_t *t, int busy, int *quit) {
#ifdef NCURSES_VERSION
static int g_k_alt_up, g_k_alt_down, g_k_ctrl_home, g_k_ctrl_end,
g_k_ctrl_up, g_k_ctrl_down;
g_k_ctrl_up, g_k_ctrl_down, g_k_ctrl_left, g_k_ctrl_right, g_k_alt_left,
g_k_alt_right, g_k_ctrl_dc, g_k_alt_dc;
static int ext_key(const char *cap) {
char *s = tigetstr(cap);
@@ -5675,9 +5759,30 @@ static void keys_init(void) {
g_k_ctrl_end = ext_key("kEND5");
g_k_ctrl_up = ext_key("kUP5");
g_k_ctrl_down = ext_key("kDN5");
g_k_ctrl_left = ext_key("kLFT5");
g_k_ctrl_right = ext_key("kRIT5");
g_k_alt_left = ext_key("kLFT3");
g_k_alt_right = ext_key("kRIT3");
g_k_ctrl_dc = ext_key("kDC5");
g_k_alt_dc = ext_key("kDC3");
#endif
}
/* ctrl/alt+left/right move by word, ctrl/alt+delete deletes the next word. */
static int word_key(int k, int *dir) {
#ifdef NCURSES_VERSION
if (k && (k == g_k_ctrl_left || k == g_k_alt_left))
return *dir = -1, 1;
if (k && (k == g_k_ctrl_right || k == g_k_alt_right))
return *dir = 1, 1;
if (k && (k == g_k_ctrl_dc || k == g_k_alt_dc))
return *dir = 0, 1;
#else
(void)k, (void)dir;
#endif
return 0;
}
/* ncurses event → cst_keymap code (0 for events that are not keys). */
static int tui_key_code(int rc, wint_t wch) {
if (rc != KEY_CODE_YES)
@@ -5747,7 +5852,7 @@ static void run_perm_modal(tui_t *t) {
draw_perm_modal(t);
draw_status(t);
wint_t wch = 0;
int rc = wget_wch(t->input, &wch);
int rc = tui_wget_wch(t->input, &wch);
if (rc == ERR)
continue;
if (rc == KEY_CODE_YES) {
@@ -5810,7 +5915,7 @@ static void run_ask_modal(tui_t *t) {
for (;;) {
draw_ask_modal(t);
wint_t wch = 0;
int rc = wget_wch(t->input, &wch);
int rc = tui_wget_wch(t->input, &wch);
if (rc == ERR)
continue;
if (rc == KEY_CODE_YES) {
@@ -5940,6 +6045,7 @@ int cst_tui_run(cst_agent_t *agent) {
define_key("\033[201~", KEY_PASTE_END);
printf("\033[?2004h");
fflush(stdout);
kitty_init();
#endif
tui_t t;
@@ -6083,7 +6189,7 @@ int cst_tui_run(cst_agent_t *agent) {
}
wint_t wch = 0;
int rc = wget_wch(t.input, &wch);
int rc = tui_wget_wch(t.input, &wch);
if (rc == ERR)
continue;
@@ -6097,7 +6203,7 @@ int cst_tui_run(cst_agent_t *agent) {
if (code == 27 && !t.leader_pending) {
/* Alt+key arrives as Esc followed by the key */
wtimeout(t.input, 30);
nrc = wget_wch(t.input, &nx);
nrc = tui_wget_wch(t.input, &nx);
wtimeout(t.input, 80);
if (nrc != ERR) {
int c2 = tui_key_code(nrc, nx);
@@ -6181,6 +6287,18 @@ int cst_tui_run(cst_agent_t *agent) {
}
continue;
}
int wdir;
if (word_key((int)wch, &wdir)) {
if (wdir < 0)
t.cur = word_left(&t, t.cur), draw_input(&t);
else if (wdir > 0)
t.cur = word_right(&t, t.cur), draw_input(&t);
else {
input_erase(&t, t.cur, word_right(&t, t.cur));
after_edit(&t);
}
continue;
}
switch (wch) {
case KEY_HOME:
if (t.in_len) {
@@ -6376,6 +6494,7 @@ out:
tui_free(&t);
#ifdef NCURSES_VERSION
kitty_restore();
printf("\033[?2004l");
fflush(stdout);
#endif
+37
View File
@@ -117,10 +117,47 @@ static void test_json(void) {
CHECK(cst_keymap_conflicts(&km, conf, sizeof(conf)) > 0);
}
static int kitty(int cp, int mods, int *a, int *b) {
int out[2] = {-1, -1};
int n = cst_key_from_kitty(cp, mods, out);
*a = out[0], *b = out[1];
return n;
}
static void test_kitty(void) {
int a, b;
CHECK(kitty('c', 4, &a, &b) == 1 && a == 3); /* ctrl+c */
CHECK(kitty('a', 5, &a, &b) == 1 && a == 1); /* ctrl+shift+a */
CHECK(kitty(' ', 4, &a, &b) == 1 && a == 0); /* ctrl+space */
CHECK(kitty('[', 4, &a, &b) == 1 && a == 27); /* ctrl+[ */
CHECK(kitty('b', 2, &a, &b) == 2 && a == 27 && b == 'b');
CHECK(kitty('b', 3, &a, &b) == 2 && a == 27 && b == 'B');
CHECK(kitty('1', 3, &a, &b) == 2 && a == 27 && b == '!');
CHECK(kitty('x', 6, &a, &b) == 2 && a == 27 && b == 24); /* ctrl+alt+x */
CHECK(kitty(27, 0, &a, &b) == 1 && a == 27);
CHECK(kitty(13, 0, &a, &b) == 1 && a == 13);
CHECK(kitty(13, 1, &a, &b) == 2 && a == 27 && b == 13); /* shift+enter */
CHECK(kitty(13, 4, &a, &b) == 2 && a == 27 && b == 13); /* ctrl+enter */
CHECK(kitty(127, 4, &a, &b) == 2 && a == 27 && b == 127);
CHECK(kitty(127, 1, &a, &b) == 1 && a == 127);
CHECK(kitty(9, 1, &a, &b) == 1 && a == CST_K_BTAB);
CHECK(kitty(9, 4, &a, &b) == 1 && a == 9);
CHECK(kitty('a', 8, &a, &b) == 0); /* super+a */
CHECK(kitty(5, 4, &a, &b) == 0);
/* the result goes through the keymap like legacy input */
cst_keymap_t km;
cst_keymap_defaults(&km);
int act = 0;
kitty(13, 1, &a, &b);
CHECK(lookup1(&km, b | CST_KEY_ALT, &act) == CST_KM_MATCH &&
act == CST_KA_NEWLINE);
}
int main(void) {
test_parse();
test_defaults();
test_json();
test_kitty();
puts("test_keymap: ok");
return 0;
}