diff --git a/README.md b/README.md index 38d9f07..7d5a7f3 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/compat.md b/docs/compat.md index 27073bf..5b7a387 100644 --- a/docs/compat.md +++ b/docs/compat.md @@ -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 diff --git a/include/cst_keymap.h b/include/cst_keymap.h index 495b1d9..062864e 100644 --- a/include/cst_keymap.h +++ b/include/cst_keymap.h @@ -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; diff --git a/src/keymap.c b/src/keymap.c index 0d999c4..a62294b 100644 --- a/src/keymap.c +++ b/src/keymap.c @@ -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", " 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; diff --git a/src/tui.c b/src/tui.c index 1122b8b..e2c8ad9 100644 --- a/src/tui.c +++ b/src/tui.c @@ -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[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 diff --git a/tests/test_keymap.c b/tests/test_keymap.c index 4bd535e..4396307 100644 --- a/tests/test_keymap.c +++ b/tests/test_keymap.c @@ -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; }