TUI: kitty keyboard protocol, word movement and deletion keys

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 时入队 | | Enter | 发送;busy 时入队 |
| Ctrl+N / Alt+Enter | 换行(粘贴的多行文本保持原样,不会被当成发送) | | Ctrl+N / Alt+Enter | 换行(粘贴的多行文本保持原样,不会被当成发送) |
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F | 移动光标(按字 / 按词,支持中文宽字符) | | Shift+Enter / Ctrl+Enter | 换行;需要支持 kitty 键盘协议的终端(kitty、WezTerm、foot、Ghostty、WHC Shell 等),启动时自动探测 |
| Ctrl+W / Ctrl+U / Ctrl+K / Alt+Backspace / Delete | 删词 / 删到行首 / 删到行尾 | | ← → 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 只开放只读工具) | | Tab | 补全 `/命令` 和 `@文件`;没有可补全时切换 Build / Plan(Plan 只开放只读工具) |
| Shift+Tab | 循环切换审批模式 ask → auto → yolo | | Shift+Tab | 循环切换审批模式 ask → auto → yolo |
+7
View File
@@ -87,6 +87,13 @@ take the agent down.
- Bracketed paste needs ncurses (all platforms above). - Bracketed paste needs ncurses (all platforms above).
- macOS Terminal.app sends Option+Enter as Esc+Enter only with "Use Option as - macOS Terminal.app sends Option+Enter as Esc+Enter only with "Use Option as
Meta key"; Ctrl+N always inserts a newline. 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 ## Acceptance checklist
+8
View File
@@ -90,6 +90,14 @@ int cst_key_parse(const char *s);
/* code → canonical name ("ctrl+x", "tab", "alt+up", ...). */ /* code → canonical name ("ctrl+x", "tab", "alt+up", ...). */
const char *cst_key_name(int code, char *buf, size_t n); 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 #define CST_KEYMAP_MAX 128
typedef struct { typedef struct {
int action; int action;
+64
View File
@@ -291,6 +291,70 @@ const char *cst_key_name(int code, char *buf, size_t n) {
return buf; 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). */ /* "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]) { static int parse_seq(const cst_keymap_t *km, const char *s, int keys[2]) {
int n = 0; 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 COMP_MAX 8
#define TODO_PANEL_MAX 6 #define TODO_PANEL_MAX 6
#define ASK_OPTS_MAX 9 #define ASK_OPTS_MAX 9
/* Bracketed paste markers, bound with define_key() above KEY_MAX. */ /* Our define_key() codes. ncurses numbers the terminfo extended keys (kLFT5,
#define KEY_PASTE_BEGIN (KEY_MAX + 1) * kDC3, ...) from KEY_MAX up, and key codes must fit in 16 bits, so stay well
#define KEY_PASTE_END (KEY_MAX + 2) * 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 */ /* Color pair indices */
enum { enum {
@@ -3411,7 +3492,7 @@ static int picker_run(tui_t *t, const char *title, char items[][128], int n,
wrefresh(dlg); wrefresh(dlg);
wint_t wch = 0; wint_t wch = 0;
int rc = wget_wch(dlg, &wch); int rc = tui_wget_wch(dlg, &wch);
if (rc == ERR) if (rc == ERR)
continue; continue;
if (rc == KEY_CODE_YES) { if (rc == KEY_CODE_YES) {
@@ -3542,7 +3623,7 @@ static int secret_input_run(tui_t *t, const char *title, const char *hint,
refresh(); refresh();
wint_t wch = 0; wint_t wch = 0;
int r = get_wch(&wch); int r = tui_wget_wch(stdscr, &wch);
if (r == ERR) if (r == ERR)
continue; continue;
if (r == KEY_CODE_YES) { 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 cancel the turn / close popups esc",
"Edit clear input, twice to quit ctrl+c", "Edit clear input, twice to quit ctrl+c",
"Edit quit on an empty prompt ctrl+d", "Edit quit on an empty prompt ctrl+d",
"Edit move by char / word / line ← → alt+b alt+f ctrl+a ctrl+e", "Edit new line (kitty keyboard terminals) shift+enter ctrl+enter",
"Edit delete word / to start / to end ctrl+w ctrl+u ctrl+k", "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 ↑ ↓", "Edit prompt history ↑ ↓",
}; };
for (size_t i = 0; i < sizeof(FIXED) / sizeof(FIXED[0]); i++) { 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 #ifdef NCURSES_VERSION
static int g_k_alt_up, g_k_alt_down, g_k_ctrl_home, g_k_ctrl_end, 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) { static int ext_key(const char *cap) {
char *s = tigetstr(cap); char *s = tigetstr(cap);
@@ -5675,9 +5759,30 @@ static void keys_init(void) {
g_k_ctrl_end = ext_key("kEND5"); g_k_ctrl_end = ext_key("kEND5");
g_k_ctrl_up = ext_key("kUP5"); g_k_ctrl_up = ext_key("kUP5");
g_k_ctrl_down = ext_key("kDN5"); 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 #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). */ /* ncurses event → cst_keymap code (0 for events that are not keys). */
static int tui_key_code(int rc, wint_t wch) { static int tui_key_code(int rc, wint_t wch) {
if (rc != KEY_CODE_YES) if (rc != KEY_CODE_YES)
@@ -5747,7 +5852,7 @@ static void run_perm_modal(tui_t *t) {
draw_perm_modal(t); draw_perm_modal(t);
draw_status(t); draw_status(t);
wint_t wch = 0; wint_t wch = 0;
int rc = wget_wch(t->input, &wch); int rc = tui_wget_wch(t->input, &wch);
if (rc == ERR) if (rc == ERR)
continue; continue;
if (rc == KEY_CODE_YES) { if (rc == KEY_CODE_YES) {
@@ -5810,7 +5915,7 @@ static void run_ask_modal(tui_t *t) {
for (;;) { for (;;) {
draw_ask_modal(t); draw_ask_modal(t);
wint_t wch = 0; wint_t wch = 0;
int rc = wget_wch(t->input, &wch); int rc = tui_wget_wch(t->input, &wch);
if (rc == ERR) if (rc == ERR)
continue; continue;
if (rc == KEY_CODE_YES) { if (rc == KEY_CODE_YES) {
@@ -5940,6 +6045,7 @@ int cst_tui_run(cst_agent_t *agent) {
define_key("\033[201~", KEY_PASTE_END); define_key("\033[201~", KEY_PASTE_END);
printf("\033[?2004h"); printf("\033[?2004h");
fflush(stdout); fflush(stdout);
kitty_init();
#endif #endif
tui_t t; tui_t t;
@@ -6083,7 +6189,7 @@ int cst_tui_run(cst_agent_t *agent) {
} }
wint_t wch = 0; wint_t wch = 0;
int rc = wget_wch(t.input, &wch); int rc = tui_wget_wch(t.input, &wch);
if (rc == ERR) if (rc == ERR)
continue; continue;
@@ -6097,7 +6203,7 @@ int cst_tui_run(cst_agent_t *agent) {
if (code == 27 && !t.leader_pending) { if (code == 27 && !t.leader_pending) {
/* Alt+key arrives as Esc followed by the key */ /* Alt+key arrives as Esc followed by the key */
wtimeout(t.input, 30); wtimeout(t.input, 30);
nrc = wget_wch(t.input, &nx); nrc = tui_wget_wch(t.input, &nx);
wtimeout(t.input, 80); wtimeout(t.input, 80);
if (nrc != ERR) { if (nrc != ERR) {
int c2 = tui_key_code(nrc, nx); int c2 = tui_key_code(nrc, nx);
@@ -6181,6 +6287,18 @@ int cst_tui_run(cst_agent_t *agent) {
} }
continue; 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) { switch (wch) {
case KEY_HOME: case KEY_HOME:
if (t.in_len) { if (t.in_len) {
@@ -6376,6 +6494,7 @@ out:
tui_free(&t); tui_free(&t);
#ifdef NCURSES_VERSION #ifdef NCURSES_VERSION
kitty_restore();
printf("\033[?2004l"); printf("\033[?2004l");
fflush(stdout); fflush(stdout);
#endif #endif
+37
View File
@@ -117,10 +117,47 @@ static void test_json(void) {
CHECK(cst_keymap_conflicts(&km, conf, sizeof(conf)) > 0); 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) { int main(void) {
test_parse(); test_parse();
test_defaults(); test_defaults();
test_json(); test_json();
test_kitty();
puts("test_keymap: ok"); puts("test_keymap: ok");
return 0; return 0;
} }