Files
CST-agent/src/keymap.c
T
WHC 895b47274c v0.7.0: image input, model registry, prefix-cache savings
Images
- vision gate: text-only models get a note instead of image parts and no
  view_image tool; unknown models retry once without images on HTTP 400
- paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip,
  pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image
  file paths attaches them
- MCP image content is stored in the session and sent with the tool result
- attach: rel path validation, base64 decode, /retry keeps images

Model registry
- built-in src/models_builtin.json embedded at build time, merged with
  <config>/models.json and cached GET /models metadata (context, vision,
  pricing); most specific rule wins per field
- /model labels, /status source, --list-models [provider]

Cache and cost
- stable request prefix (frozen tools, notes as messages), DeepSeek prefix
  completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
2026-10-08 20:34:09 +08:00

571 lines
17 KiB
C

#include "cst_keymap.h"
#include "cJSON.h"
#include "cst_util.h"
#include "os_compat.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const cst_key_action_info_t INFO[CST_KA_COUNT] = {
[CST_KA_PALETTE] = {"palette", "General", "command palette", NULL},
[CST_KA_KEYS] = {"keys", "General", "keyboard shortcuts", "/keys"},
[CST_KA_MODE] = {"mode", "General", "switch Build / Plan mode", NULL},
[CST_KA_APPROVAL] = {"approval", "General", "cycle approval ask/auto/yolo",
NULL},
[CST_KA_NEWLINE] = {"newline", "General", "insert a newline", NULL},
[CST_KA_REDRAW] = {"redraw", "General", "redraw the screen", NULL},
[CST_KA_QUIT] = {"quit", "General", "quit", "/quit"},
[CST_KA_TOGGLE_TOOL] = {"toggle_tool", "View",
"expand/collapse the last tool output", NULL},
[CST_KA_TOGGLE_THOUGHT] = {"toggle_thought", "View",
"expand/collapse the last thought", NULL},
[CST_KA_THINKING_ALL] = {"thinking", "View",
"show/hide all thoughts by default", "/thinking"},
[CST_KA_DETAILS] = {"details", "View", "expand/collapse all tool output",
"/details"},
[CST_KA_TODO] = {"todo", "View", "show/hide the task panel", NULL},
[CST_KA_SIDEBAR] = {"sidebar", "View", "show/hide the sidebar",
"/sidebar"},
[CST_KA_SCROLL_UP] = {"scroll_up", "View", "scroll up half a page", NULL},
[CST_KA_SCROLL_DOWN] = {"scroll_down", "View", "scroll down half a page",
NULL},
[CST_KA_LINE_UP] = {"line_up", "View", "scroll up one line", NULL},
[CST_KA_LINE_DOWN] = {"line_down", "View", "scroll down one line", NULL},
[CST_KA_TOP] = {"top", "View", "jump to the first message", NULL},
[CST_KA_BOTTOM] = {"bottom", "View", "jump to the latest message", NULL},
[CST_KA_NEW_SESSION] = {"new_session", "Session", "start a new session",
"/new"},
[CST_KA_SESSIONS] = {"sessions", "Session", "pick a session", "/session"},
[CST_KA_UNDO] = {"undo", "Session", "revert the last turn's file changes",
"/undo"},
[CST_KA_RETRY] = {"retry", "Session", "drop the last reply and ask again",
"/retry"},
[CST_KA_COMPACT] = {"compact", "Session", "summarize the context",
"/compact"},
[CST_KA_COPY] = {"copy", "Session", "copy the last reply", "/copy"},
[CST_KA_EXPORT] = {"export", "Session", "export the transcript",
"/export"},
[CST_KA_PASTE_IMAGE] = {"paste_image", "Session",
"attach the clipboard image", "/attach paste"},
[CST_KA_MODEL] = {"model", "Model", "pick the model", "/model"},
[CST_KA_PROVIDER] = {"provider", "Model", "switch provider", "/provider"},
[CST_KA_EFFORT] = {"effort", "Model", "reasoning effort", "/effort"},
[CST_KA_AGENTS] = {"agents", "Agents", "sub-agents and roles", "/agents"},
[CST_KA_SKILLS] = {"skills", "Agents", "skills", "/skills"},
[CST_KA_PLUGINS] = {"plugins", "Agents", "plugins and MCP servers",
"/plugins"},
};
static const struct {
int action;
const char *keys;
} DEFAULTS[] = {
{CST_KA_PALETTE, "ctrl+p"},
{CST_KA_KEYS, "<leader> h"},
{CST_KA_KEYS, "f1"},
{CST_KA_MODE, "tab"},
{CST_KA_APPROVAL, "shift+tab"},
{CST_KA_NEWLINE, "ctrl+n"},
{CST_KA_NEWLINE, "alt+enter"},
{CST_KA_REDRAW, "ctrl+l"},
{CST_KA_QUIT, "<leader> q"},
{CST_KA_TOGGLE_TOOL, "ctrl+o"},
{CST_KA_TOGGLE_THOUGHT, "ctrl+r"},
{CST_KA_THINKING_ALL, "<leader> t"},
{CST_KA_DETAILS, "<leader> d"},
{CST_KA_TODO, "ctrl+t"},
{CST_KA_SIDEBAR, "<leader> s"},
{CST_KA_SCROLL_UP, "pgup"},
{CST_KA_SCROLL_DOWN, "pgdn"},
{CST_KA_LINE_UP, "alt+up"},
{CST_KA_LINE_DOWN, "alt+down"},
{CST_KA_TOP, "ctrl+home"},
{CST_KA_BOTTOM, "ctrl+end"},
{CST_KA_NEW_SESSION, "<leader> n"},
{CST_KA_SESSIONS, "<leader> l"},
{CST_KA_UNDO, "<leader> u"},
{CST_KA_RETRY, "<leader> r"},
{CST_KA_COMPACT, "<leader> c"},
{CST_KA_COPY, "<leader> y"},
{CST_KA_EXPORT, "<leader> x"},
{CST_KA_PASTE_IMAGE, "ctrl+v"},
{CST_KA_MODEL, "<leader> m"},
{CST_KA_PROVIDER, "<leader> p"},
{CST_KA_EFFORT, "<leader> e"},
{CST_KA_AGENTS, "<leader> a"},
{CST_KA_SKILLS, "<leader> k"},
{CST_KA_PLUGINS, "<leader> g"},
};
static const struct {
const char *name;
int code;
} NAMED[] = {
{"tab", 9},
{"enter", 13},
{"return", 13},
{"esc", 27},
{"escape", 27},
{"space", ' '},
{"backspace", 127},
{"up", CST_K_UP},
{"down", CST_K_DOWN},
{"left", CST_K_LEFT},
{"right", CST_K_RIGHT},
{"home", CST_K_HOME},
{"end", CST_K_END},
{"pgup", CST_K_PGUP},
{"pageup", CST_K_PGUP},
{"pgdn", CST_K_PGDN},
{"pagedown", CST_K_PGDN},
{"delete", CST_K_DEL},
{"del", CST_K_DEL},
{"insert", CST_K_INS},
};
const cst_key_action_info_t *cst_key_action_info(int action) {
return action > CST_KA_NONE && action < CST_KA_COUNT ? &INFO[action] : NULL;
}
int cst_key_action_find(const char *name) {
for (int i = 1; name && i < CST_KA_COUNT; i++)
if (INFO[i].name && strcmp(INFO[i].name, name) == 0)
return i;
return CST_KA_NONE;
}
static int ieq(const char *a, const char *b, size_t n) {
if (strlen(b) != n)
return 0;
for (size_t i = 0; i < n; i++)
if (tolower((unsigned char)a[i]) != b[i])
return 0;
return 1;
}
/* Decode one UTF-8 code point; returns bytes used (0 on error). */
static int utf8_one(const char *s, size_t n, int *cp) {
const unsigned char *u = (const unsigned char *)s;
if (n >= 1 && u[0] < 0x80) {
*cp = u[0];
return 1;
}
if (n >= 2 && (u[0] & 0xE0) == 0xC0) {
*cp = ((u[0] & 0x1F) << 6) | (u[1] & 0x3F);
return 2;
}
if (n >= 3 && (u[0] & 0xF0) == 0xE0) {
*cp = ((u[0] & 0x0F) << 12) | ((u[1] & 0x3F) << 6) | (u[2] & 0x3F);
return 3;
}
if (n >= 4 && (u[0] & 0xF8) == 0xF0) {
*cp = ((u[0] & 0x07) << 18) | ((u[1] & 0x3F) << 12) |
((u[2] & 0x3F) << 6) | (u[3] & 0x3F);
return 4;
}
return 0;
}
static int parse_key_n(const char *s, size_t n) {
int ctrl = 0, alt = 0, shift = 0;
for (;;) {
const char *plus = memchr(s, '+', n);
if (!plus || plus == s + n - 1)
break;
size_t m = (size_t)(plus - s);
if (ieq(s, "ctrl", m) || ieq(s, "c", m))
ctrl = 1;
else if (ieq(s, "alt", m) || ieq(s, "meta", m) || ieq(s, "m", m))
alt = 1;
else if (ieq(s, "shift", m) || ieq(s, "s", m))
shift = 1;
else
return -1;
n -= m + 1;
s = plus + 1;
}
if (n == 0)
return -1;
int code = -1;
for (size_t i = 0; i < sizeof(NAMED) / sizeof(NAMED[0]); i++)
if (ieq(s, NAMED[i].name, n))
code = NAMED[i].code;
if (code < 0 && (s[0] == 'f' || s[0] == 'F') && n >= 2 && n <= 3) {
int f = atoi(s + 1);
if (f >= 1 && f <= 12 && isdigit((unsigned char)s[1]))
code = CST_K_F1 + f - 1;
}
if (code < 0) {
int cp = 0;
if (utf8_one(s, n, &cp) != (int)n)
return -1;
code = cp;
}
if (shift) {
if (code == 9)
code = CST_K_BTAB;
else if (code < 128 && isalpha(code))
code = toupper(code);
else
return -1;
}
if (ctrl) {
if (code < 128 && isalpha(code))
code = tolower(code) - 'a' + 1;
else if (code == CST_K_HOME)
code = CST_K_CTRL_HOME;
else if (code == CST_K_END)
code = CST_K_CTRL_END;
else if (code == CST_K_UP)
code = CST_K_CTRL_UP;
else if (code == CST_K_DOWN)
code = CST_K_CTRL_DOWN;
else
return -1;
}
if (alt) {
if (code == CST_K_UP)
return CST_K_ALT_UP;
if (code == CST_K_DOWN)
return CST_K_ALT_DOWN;
code |= CST_KEY_ALT;
}
return code;
}
int cst_key_parse(const char *s) {
if (!s)
return -1;
while (*s == ' ')
s++;
size_t n = strlen(s);
while (n && s[n - 1] == ' ')
n--;
return parse_key_n(s, n);
}
const char *cst_key_name(int code, char *buf, size_t n) {
const char *alt = (code & CST_KEY_ALT) ? "alt+" : "";
int c = code & ~CST_KEY_ALT;
static const char *const SPECIAL[] = {
"up", "down", "left", "right", "home",
"end", "pgup", "pgdn", "shift+tab", "delete",
"insert", "ctrl+home", "ctrl+end", "ctrl+up", "ctrl+down",
"alt+up", "alt+down"};
if (c == 9)
snprintf(buf, n, "%stab", alt);
else if (c == 13 || c == 10)
snprintf(buf, n, "%senter", alt);
else if (c == 27)
snprintf(buf, n, "%sesc", alt);
else if (c == ' ')
snprintf(buf, n, "%sspace", alt);
else if (c == 127 || c == 8)
snprintf(buf, n, "%sbackspace", alt);
else if (c >= 1 && c <= 26)
snprintf(buf, n, "%sctrl+%c", alt, 'a' + c - 1);
else if (c >= CST_K_F1 && c <= CST_K_LAST)
snprintf(buf, n, "%sf%d", alt, c - CST_K_F1 + 1);
else if (c >= CST_KEY_SPECIAL && c < CST_K_F1)
snprintf(buf, n, "%s%s", alt, SPECIAL[c - CST_KEY_SPECIAL]);
else if (c >= 'A' && c <= 'Z')
snprintf(buf, n, "%sshift+%c", alt, c - 'A' + 'a');
else if (c < 0x80)
snprintf(buf, n, "%s%c", alt, c);
else {
char u[5] = {0};
if (c < 0x800) {
u[0] = (char)(0xC0 | (c >> 6));
u[1] = (char)(0x80 | (c & 0x3F));
} else if (c < 0x10000) {
u[0] = (char)(0xE0 | (c >> 12));
u[1] = (char)(0x80 | ((c >> 6) & 0x3F));
u[2] = (char)(0x80 | (c & 0x3F));
} else {
u[0] = (char)(0xF0 | (c >> 18));
u[1] = (char)(0x80 | ((c >> 12) & 0x3F));
u[2] = (char)(0x80 | ((c >> 6) & 0x3F));
u[3] = (char)(0x80 | (c & 0x3F));
}
snprintf(buf, n, "%s%s", alt, u);
}
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;
while (*s) {
while (*s == ' ')
s++;
if (!*s)
break;
size_t w = strcspn(s, " ");
if (n == 2)
return 0;
int k = ieq(s, "<leader>", w) ? km->leader : parse_key_n(s, w);
if (k < 0)
return 0;
keys[n++] = k;
s += w;
}
return n;
}
static void add(cst_keymap_t *km, int action, const int keys[2], int nkeys,
int user) {
if (km->n >= CST_KEYMAP_MAX || nkeys < 1)
return;
cst_binding_t *b = &km->b[km->n++];
b->action = action;
b->keys[0] = keys[0];
b->keys[1] = nkeys > 1 ? keys[1] : 0;
b->nkeys = nkeys;
b->user = user;
}
static void build_defaults(cst_keymap_t *km) {
km->n = 0;
for (size_t i = 0; i < sizeof(DEFAULTS) / sizeof(DEFAULTS[0]); i++) {
int keys[2];
int n = parse_seq(km, DEFAULTS[i].keys, keys);
add(km, DEFAULTS[i].action, keys, n, 0);
}
}
void cst_keymap_defaults(cst_keymap_t *km) {
memset(km, 0, sizeof(*km));
km->leader = 24; /* ctrl+x */
build_defaults(km);
}
static void drop_action(cst_keymap_t *km, int action) {
int o = 0;
for (int i = 0; i < km->n; i++)
if (km->b[i].action != action)
km->b[o++] = km->b[i];
km->n = o;
}
static void errf(char *err, size_t err_sz, int *bad, const char *what,
const char *name) {
if (err && err_sz && !*bad)
snprintf(err, err_sz, "keybinds.json: %s \"%.60s\"", what, name);
*bad = 1;
}
int cst_keymap_apply_json(cst_keymap_t *km, const char *json, char *err,
size_t err_sz) {
if (err && err_sz)
err[0] = 0;
cJSON *root = cJSON_Parse(json ? json : "");
if (!cJSON_IsObject(root)) {
cJSON_Delete(root);
if (err && err_sz)
snprintf(err, err_sz, "keybinds.json: not a JSON object");
return -1;
}
int bad = 0;
const cJSON *ld = cJSON_GetObjectItemCaseSensitive(root, "leader");
if (cJSON_IsString(ld)) {
int k = cst_key_parse(ld->valuestring);
if (k > 0) {
km->leader = k;
build_defaults(km);
} else {
errf(err, err_sz, &bad, "bad leader key", ld->valuestring);
}
}
const cJSON *it;
cJSON_ArrayForEach(it, root) {
if (!it->string || strcmp(it->string, "leader") == 0)
continue;
int action = cst_key_action_find(it->string);
if (action == CST_KA_NONE) {
errf(err, err_sz, &bad, "unknown action", it->string);
continue;
}
drop_action(km, action);
const cJSON *v;
if (cJSON_IsString(it)) {
if (strcmp(it->valuestring, "none") == 0)
continue;
int keys[2], n = parse_seq(km, it->valuestring, keys);
if (n)
add(km, action, keys, n, 1);
else
errf(err, err_sz, &bad, "bad key", it->valuestring);
} else if (cJSON_IsArray(it)) {
cJSON_ArrayForEach(v, it) {
if (!cJSON_IsString(v))
continue;
int keys[2], n = parse_seq(km, v->valuestring, keys);
if (n)
add(km, action, keys, n, 1);
else
errf(err, err_sz, &bad, "bad key", v->valuestring);
}
} else {
errf(err, err_sz, &bad, "expected a string or list for", it->string);
}
}
cJSON_Delete(root);
return bad ? -1 : 0;
}
int cst_keymap_load(cst_keymap_t *km, char *err, size_t err_sz) {
cst_keymap_defaults(km);
if (err && err_sz)
err[0] = 0;
char dir[OS_PATH_MAX], path[OS_PATH_MAX];
if (os_config_dir(dir, sizeof(dir)) != 0 ||
os_path_join(path, sizeof(path), dir, "keybinds.json") != 0)
return 0;
FILE *fp = fopen(path, "rb");
if (!fp)
return 0;
char buf[16384];
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
buf[n] = 0;
return cst_keymap_apply_json(km, buf + cst_bom_len(buf, n), err, err_sz);
}
int cst_keymap_lookup(const cst_keymap_t *km, const int *seq, int n,
int *action) {
int prefix = 0;
for (int i = 0; i < km->n; i++) {
const cst_binding_t *b = &km->b[i];
if (b->keys[0] != seq[0])
continue;
if (b->nkeys == n && (n == 1 || b->keys[1] == seq[1])) {
*action = b->action;
return CST_KM_MATCH;
}
if (n == 1 && b->nkeys == 2)
prefix = 1;
}
return prefix ? CST_KM_PREFIX : CST_KM_NONE;
}
static void seq_name(const cst_binding_t *b, char *buf, size_t n) {
char a[32], c[32];
cst_key_name(b->keys[0], a, sizeof(a));
if (b->nkeys > 1)
snprintf(buf, n, "%s %s", a, cst_key_name(b->keys[1], c, sizeof(c)));
else
snprintf(buf, n, "%s", a);
}
void cst_keymap_describe(const cst_keymap_t *km, int action, char *buf,
size_t n) {
size_t o = 0;
buf[0] = 0;
for (int i = 0; i < km->n && o + 1 < n; i++) {
if (km->b[i].action != action)
continue;
char s[80];
seq_name(&km->b[i], s, sizeof(s));
int w = snprintf(buf + o, n - o, "%s%s", o ? ", " : "", s);
if (w < 0 || (size_t)w >= n - o)
break;
o += (size_t)w;
}
}
int cst_keymap_conflicts(const cst_keymap_t *km, char *buf, size_t n) {
int count = 0;
size_t o = 0;
if (n)
buf[0] = 0;
for (int i = 0; i < km->n; i++) {
const cst_binding_t *a = &km->b[i];
for (int j = i + 1; j < km->n; j++) {
const cst_binding_t *b = &km->b[j];
int same = a->keys[0] == b->keys[0] && a->nkeys == b->nkeys &&
(a->nkeys == 1 || a->keys[1] == b->keys[1]);
int shadow = a->keys[0] == b->keys[0] && a->nkeys != b->nkeys;
if ((!same && !shadow) || a->action == b->action)
continue;
count++;
char s[80];
seq_name(a->nkeys <= b->nkeys ? a : b, s, sizeof(s));
if (o + 1 < n) {
int w = snprintf(buf + o, n - o, "%s%s: %s, %s",
o ? "\n" : "", s, INFO[a->action].name,
INFO[b->action].name);
if (w > 0 && (size_t)w < n - o)
o += (size_t)w;
}
}
}
return count;
}