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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user