v0.7.5: arrow keys, image request fix, deepseek-flash vision, musl CI
ci / linux (clang) (push) Successful in 4m42s
ci / linux (gcc) (push) Successful in 5m35s
ci / musl (push) Successful in 5m52s
ci / windows-cross (push) Successful in 12m51s

TUI keys: register both CSI and SS3 forms of the cursor/Home/End keys and
the xterm modifier sequences when terminfo lacks them, so an arrow sent in
the "other" form no longer turns into Esc + a typed letter. Up/Down move by
visual (soft-wrapped) row, and the draft survives a trip through history.
scripts/tui_keys drives the TUI in a pty to check all of this.

Images: text inside a multimodal content array is now a {"type":"text"}
part; DeepSeek rejected the bare string with HTTP 422. deepseek-flash
(and its deepseek-chat / deepseek-reasoner aliases) read images, so the
registry marks them vision; deepseek-v4-pro stays text only.

CI: models.c lost _DEFAULT_SOURCE in 0.7.0 and gmtime_r broke the musl
build; tests sleep with poll() instead of usleep for the same reason.
This commit is contained in:
WHC
2026-10-08 22:35:34 +08:00
parent 32b4afd772
commit c7b32c3fd6
13 changed files with 646 additions and 49 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 7 #define CST_VERSION_MINOR 7
#define CST_VERSION_PATCH 1 #define CST_VERSION_PATCH 5
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+44
View File
@@ -0,0 +1,44 @@
import os
import sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from harness import Term
import fakeapi
from keys import input_text, styled, check, FAILS, base
for term in ("xterm-256color", "screen-256color"):
t = Term(args=("-b", base, "-k", "sk-test"), term=term)
t.send("some text")
t.send("\x1b", 0.4)
check(term + " lone esc clears input", input_text(t) == [], t)
t.send("alpha beta")
t.send("\x1bb") # alt+b word left
t.send("X")
check(term + " alt+b word left", "alpha Xbeta" in input_text(t), t)
t.send("\x1b\r") # alt+enter newline
t.send("Y")
check(term + " alt+enter newline", input_text(t)[-2:] == ["alpha X", "Ybeta"], t)
t.send("\x03", 0.2)
# picker with CSI form
t.send("/model\r", 0.8)
s0 = styled(t)
t.arrow("down", "csi")
s1 = styled(t)
t.arrow("up", "csi")
s2 = styled(t)
check(term + " picker csi down", s0 != s1, t)
check(term + " picker csi up", s0 == s2, t)
t.send("\x1b", 0.4)
# home/end variants
t.send("middle")
for seq in ("\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"):
t.send("\x1b[F" + seq)
t.send("<")
t.send("\x08" if False else "")
check(term + " home variants", input_text(t)[-1:] == ["<<<<middle"], t)
for seq in ("\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"):
t.send("\x1b[H" + seq)
t.send(">")
check(term + " end variants", input_text(t)[-1:] == ["<<<<middle>>>>"], t)
t.close()
print("FAILED:", len(FAILS))
sys.exit(min(len(FAILS), 100))
+43
View File
@@ -0,0 +1,43 @@
import json, sys, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG = []
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}, {"id": "deepseek-v4-pro"}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(n)
try:
LOG.append(json.loads(raw))
except Exception:
LOG.append({"_raw": raw[:200].decode("latin1")})
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
def ev(o):
self.wfile.write(b"data: " + json.dumps(o).encode() + b"\n\n")
self.wfile.flush()
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
ev({**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": "reply-%d" % len(LOG)}}]})
ev({**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
def start():
s = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=s.serve_forever, daemon=True).start()
return s, "http://127.0.0.1:%d/v1" % s.server_address[1]
+99
View File
@@ -0,0 +1,99 @@
"""Drive the cst-agent TUI in a pty and read the screen back (pyte).
Run from the repo root after `make`:
nix-shell -p python3Packages.pyte --run "python3 scripts/tui_keys/keys.py"
nix-shell -p python3Packages.pyte --run "python3 scripts/tui_keys/esc.py"
(or `pip install pyte`). A local fake OpenAI-compatible server answers the
model calls, so no API key is needed. Exit status is the failure count.
"""
import os, pty, sys, time, select, fcntl, termios, struct, signal, shutil, tempfile
import pyte
BIN = os.environ.get("CST_BIN") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "cst-agent")
class Term:
def __init__(self, cols=120, rows=36, kitty=False, env=None, args=(), home=None,
ws=None, term="xterm-256color"):
self.cols, self.rows, self.kitty = cols, rows, kitty
self.screen = pyte.Screen(cols, rows)
self.stream = pyte.ByteStream(self.screen)
self.home = home or tempfile.mkdtemp(prefix="cstk_home_")
self.ws = ws or tempfile.mkdtemp(prefix="cstk_ws_")
e = dict(os.environ)
e.update({"TERM": term, "HOME": self.home,
"XDG_CONFIG_HOME": os.path.join(self.home, ".config"),
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8",
"CST_PROVIDER": "deepseek", "DEEPSEEK_API_KEY": "sk-dummy-test",
"CST_METRICS": "0"})
e.pop("NO_COLOR", None)
if env:
e.update(env)
self.raw = b""
pid, fd = pty.fork()
if pid == 0:
os.chdir(self.ws)
os.execve(BIN, [BIN, "-N", *args], e)
self.pid, self.fd = pid, fd
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
self.pump(1.5)
def pump(self, t=0.25):
end = time.time() + t
while True:
left = end - time.time()
if left <= 0:
break
r, _, _ = select.select([self.fd], [], [], left)
if not r:
continue
try:
d = os.read(self.fd, 65536)
except OSError:
break
if not d:
break
self.raw += d
if self.kitty and b"\x1b[?u" in d:
os.write(self.fd, b"\x1b[?0u")
self.stream.feed(d)
def send(self, b, t=0.15):
if isinstance(b, str):
b = b.encode()
os.write(self.fd, b)
self.pump(t)
def appcursor(self):
return (1 << 5) in self.screen.mode # pyte DECCKM
def arrow(self, d, form=None, mod=None, t=0.15):
c = {"up": "A", "down": "B", "right": "C", "left": "D"}[d]
if mod:
seq = "\x1b[1;%d%s" % (mod, c)
else:
f = form or ("ss3" if self.appcursor() else "csi")
seq = ("\x1bO" if f == "ss3" else "\x1b[") + c
self.send(seq, t)
def lines(self):
return [l.rstrip() for l in self.screen.display]
def find(self, s):
return [(i, l) for i, l in enumerate(self.lines()) if s in l]
def cursor(self):
return self.screen.cursor.y, self.screen.cursor.x
def dump(self, title=""):
print("=" * 20, title, "cursor", self.cursor())
for i, l in enumerate(self.lines()):
print("%2d|%s" % (i, l))
def close(self):
try:
os.kill(self.pid, signal.SIGTERM)
os.waitpid(self.pid, 0)
except Exception:
pass
+273
View File
@@ -0,0 +1,273 @@
import os
import sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from harness import Term
import fakeapi
srv, base = fakeapi.start()
FAILS = []
VERBOSE = "-v" in sys.argv
def check(name, cond, t=None):
print(("PASS " if cond else "FAIL ") + name)
if not cond:
FAILS.append(name)
if t:
t.dump(name)
def input_text(t):
"""Input box rows: the bar rows right above the mode line."""
L = t.lines()
mode = [i for i, l in enumerate(L) if l.lstrip().startswith("┃ ") and
("Build ·" in l or "Plan ·" in l)]
if not mode:
return []
rows = []
i = mode[-1] - 1
while i >= 0:
s = L[i].lstrip()
if not s.startswith("┃"):
break
body = s[2:].split(" ")[0].rstrip() if s.startswith("┃ ") else ""
if body == "" and rows:
break
if body:
rows.append(body)
i -= 1
rows.reverse()
if rows and (rows[0].startswith("Ask anything") or rows[0].startswith("Enter queues")):
return []
return rows
def styled(t):
"""Screen text plus the reverse/bg attributes, to see highlight moves."""
out = []
for y in range(t.rows):
row = t.screen.buffer[y]
out.append("".join("%s%s%s" % (row[x].data, int(row[x].reverse), row[x].bg[:3])
for x in range(t.cols)))
return out
def clear(t):
if [r for r in input_text(t) if r]:
t.send("\x03", 0.2) # ctrl+c clears a non-empty input (twice on empty quits)
def new(kitty=False, **kw):
t = Term(kitty=kitty, args=("-b", base, "-k", "sk-test", "-m", "deepseek-flash"), **kw)
return t
def basic(t, form, tag):
clear(t)
t.send("hello world")
for _ in range(5):
t.arrow("left", form)
t.send("X")
check(f"{tag} left x5 insert", "hello Xworld" in input_text(t), t)
t.arrow("right", form); t.arrow("right", form)
t.send("Y")
check(f"{tag} right x2 insert", "hello XwoYrld" in input_text(t), t)
# boundaries
for _ in range(30):
t.arrow("left", form, t=0.02)
t.pump(0.2)
t.send("<")
check(f"{tag} left past start", "<hello XwoYrld" in input_text(t), t)
for _ in range(30):
t.arrow("right", form, t=0.02)
t.pump(0.2)
t.send(">")
check(f"{tag} right past end", "<hello XwoYrld>" in input_text(t), t)
clear(t)
def cjk(t):
clear(t)
t.send("你好世界")
y, x_end = t.cursor()
t.arrow("left"); t.arrow("left")
y2, x2 = t.cursor()
check("cjk cursor moves 2 cells per char", x_end - x2 == 4, t)
t.send("A")
check("cjk insert between wide chars", "你好A世界" in input_text(t), t)
t.arrow("right")
t.send("B")
check("cjk right over wide char", "你好A世B界" in input_text(t), t)
clear(t)
t.send("a😀b")
t.arrow("left"); t.arrow("left")
t.send("Z")
check("emoji left", "aZ😀b" in input_text(t), t)
clear(t)
def multiline(t):
clear(t)
t.send("line one is long")
t.send("\x0e") # ctrl+n newline
t.send("l2")
t.send("\x0e")
t.send("third line here")
rows = input_text(t)
check("multiline 3 rows", rows[-3:] == ["line one is long", "l2", "third line here"], t)
for _ in range(4):
t.arrow("left") # cursor before "here"
t.arrow("up") # col 11 -> clamp to end of "l2"
t.send("U")
rows = input_text(t)
check("up clamps column to short line", rows[-2] == "l2U", t)
t.arrow("up") # col 3 on line one
t.send("V")
rows = input_text(t)
check("up keeps column", rows[-3] == "linVe one is long", t)
t.arrow("down"); t.arrow("down")
t.send("W")
rows = input_text(t)
check("down twice to last line", "W" in rows[-1], t)
clear(t)
def history(t):
clear(t)
t.send("first prompt\r", 1.5)
t.send("second prompt\r", 1.5)
t.arrow("up")
check("hist up -> newest", input_text(t)[-1:] == ["second prompt"], t)
t.arrow("up")
check("hist up -> older", input_text(t)[-1:] == ["first prompt"], t)
t.arrow("up")
check("hist up at oldest stays", input_text(t)[-1:] == ["first prompt"], t)
t.arrow("down")
check("hist down -> newer", input_text(t)[-1:] == ["second prompt"], t)
t.arrow("down")
check("hist down past newest -> empty", input_text(t) in ([], [""]), t)
# draft survives a trip through history
t.send("my draft")
t.arrow("up")
t.arrow("down")
check("draft restored after up/down", input_text(t)[-1:] == ["my draft"], t)
clear(t)
def wrapped(t):
clear(t)
long = " ".join("w%02d" % i for i in range(40)) # ~160 chars, wraps
t.send(long, 0.4)
nrows = len(input_text(t))
t.arrow("up")
txt = "".join(input_text(t))
check("up on wrapped line keeps the text", "w39" in txt and "w00" in txt, t)
t.send("Q")
rows = input_text(t)
check("up on wrapped line moves one visual row", nrows >= 2 and "Q" in "".join(rows[:-1]), t)
clear(t)
def completion(t):
clear(t)
t.send("/", 0.3)
before = styled(t)
t.arrow("down"); t.arrow("down")
mid = styled(t)
t.arrow("up")
after = styled(t)
check("completion popup reacts to down", before != mid, t)
check("completion up differs from down-down", mid != after, t)
check("completion arrows do not touch input", input_text(t)[-1:] == ["/"], t)
t.send("\x1b", 0.3)
clear(t)
def modifiers(t):
clear(t)
t.send("alpha beta gamma")
t.arrow("left", mod=5) # ctrl+left -> word left
t.send("X")
check("ctrl+left word", "alpha beta Xgamma" in input_text(t), t)
t.arrow("left", mod=3) # alt+left
t.arrow("left", mod=3)
t.send("Y")
check("alt+left word", "alpha Ybeta Xgamma" in input_text(t), t)
t.arrow("right", mod=5)
t.send("Z")
check("ctrl+right word", "alpha YbetaZ Xgamma" in input_text(t), t)
t.arrow("left", mod=2) # shift+left: should at least not insert junk
t.send("S")
txt = "".join(input_text(t))
check("shift+left no junk", "[" not in txt and ";" not in txt and "1;2" not in txt, t)
clear(t)
def split_and_burst(t, form):
clear(t)
t.send("abcdef")
ss = "\x1bO" if form == "ss3" else "\x1b["
# escape sequence split across writes (slow link)
import os
os.write(t.fd, ss[:1].encode()); time.sleep(0.008)
os.write(t.fd, (ss[1:] + "D").encode()); t.pump(0.2)
t.send("1")
check(f"split {form} seq", "abcde1f" in input_text(t), t)
# key-repeat burst in one write
t.send((ss + "D") * 4, 0.3)
t.send("2")
check(f"burst {form} x4", "ab2cde1f" in input_text(t), t)
clear(t)
def picker(t):
clear(t)
t.send("/model\r", 0.8)
s0 = styled(t)
t.arrow("down")
s1 = styled(t)
t.arrow("up")
s2 = styled(t)
check("picker down changes selection", s0 != s1, t)
check("picker up returns", s0 == s2, t)
t.send("\x1b", 0.4)
clear(t)
def run(kitty, tag):
print("#####", tag)
t = new(kitty=kitty)
try:
basic(t, None, tag + " native")
basic(t, "csi" if t.appcursor() else "ss3", tag + " other-form")
cjk(t)
multiline(t)
modifiers(t)
split_and_burst(t, "ss3")
split_and_burst(t, "csi")
completion(t)
wrapped(t)
history(t)
picker(t)
finally:
t.close()
if __name__ == "__main__":
run(False, "xterm")
run(True, "kitty")
for term in ("screen-256color", "tmux-256color", "linux", "vt220", "xterm"):
print("#####", term)
t = Term(args=("-b", base, "-k", "sk-test"), term=term)
try:
basic(t, "ss3", term + " ss3")
basic(t, "csi", term + " csi")
modifiers(t)
multiline(t)
cjk(t)
finally:
t.close()
print("FAILED:", len(FAILS))
for f in FAILS:
print(" -", f)
sys.exit(min(len(FAILS), 100))
+8 -1
View File
@@ -162,7 +162,14 @@ static void parts_add_text(cJSON *parts, cJSON *txt) {
cJSON_Delete(txt); cJSON_Delete(txt);
return; return;
} }
cJSON_AddItemToArray(parts, txt); if (!txt->valuestring || !txt->valuestring[0]) {
cJSON_Delete(txt);
return;
}
cJSON *t = cJSON_CreateObject();
cJSON_AddStringToObject(t, "type", "text");
cJSON_AddItemToObject(t, "text", txt);
cJSON_AddItemToArray(parts, t);
} }
/* One stored image as an image_url part, or NULL with why filled in. */ /* One stored image as an image_url part, or NULL with why filled in. */
+5
View File
@@ -1,6 +1,11 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_agent.h" #include "cst_agent.h"
#include <string.h> #include <string.h>
#include <time.h>
/* Provider and model data live in model_registry.c (cst_models.h). */ /* Provider and model data live in model_registry.c (cst_models.h). */
+3
View File
@@ -31,6 +31,9 @@
"models": [ "models": [
{"match": "deepseek", "context": 1000000, "vision": false, {"match": "deepseek", "context": 1000000, "vision": false,
"price": {"currency": "¥", "miss": 2, "hit": 0.04, "out": 8, "offpeak_half": true}}, "price": {"currency": "¥", "miss": 2, "hit": 0.04, "out": 8, "offpeak_half": true}},
{"match": "deepseek-flash", "vision": true},
{"match": "deepseek-chat", "vision": true},
{"match": "deepseek-reasoner", "vision": true},
{"match": "deepseek-v4-pro", {"match": "deepseek-v4-pro",
"price": {"currency": "¥", "miss": 9, "hit": 0.3, "out": 27, "offpeak_half": true}}, "price": {"currency": "¥", "miss": 9, "hit": 0.3, "out": 27, "offpeak_half": true}},
{"match": "deepseek-vl", "vision": true}, {"match": "deepseek-vl", "vision": true},
+79 -43
View File
@@ -378,6 +378,7 @@ typedef struct {
char *prompt_hist[PROMPT_HIST_CAP]; char *prompt_hist[PROMPT_HIST_CAP];
int prompt_hist_n; int prompt_hist_n;
int hist_browse; int hist_browse;
char *hist_draft; /* the live input while browsing */
/* Busy-turn FIFO */ /* Busy-turn FIFO */
char *queue[QUEUE_CAP]; char *queue[QUEUE_CAP];
@@ -3163,28 +3164,16 @@ static size_t word_right(const tui_t *t, size_t at) {
return at; return at;
} }
/* Move to the previous/next logical line keeping the column (in chars). static size_t input_hit(const tui_t *t, int want_row, int want_col);
* Returns 0 when already on the first/last line. */
/* Move to the previous/next visual row (soft-wrapped or not) keeping the
* screen column. Returns 0 when already on the first/last row. */
static int input_line_move(tui_t *t, int dir) { static int input_line_move(tui_t *t, int dir) {
size_t ls = line_start(t, t->cur); int cr, cc;
int col = 0; int rows = input_layout(t, NULL, NULL, &cr, &cc);
for (size_t i = ls; i < t->cur; i += next_len(t, i)) if (cr + dir < 0 || cr + dir >= rows)
col++; return 0;
size_t target; t->cur = input_hit(t, cr + dir, cc);
if (dir < 0) {
if (ls == 0)
return 0;
target = line_start(t, ls - 1);
} else {
size_t le = line_end(t, t->cur);
if (le >= t->in_len)
return 0;
target = le + 1;
}
size_t te = line_end(t, target);
while (col-- > 0 && target < te)
target += next_len(t, target);
t->cur = target;
return 1; return 1;
} }
@@ -5854,7 +5843,7 @@ static int submit_input(tui_t *t, int busy) {
static size_t input_hit(const tui_t *t, int want_row, int want_col) { static size_t input_hit(const tui_t *t, int want_row, int want_col) {
int row = 0, col = IN_COL; int row = 0, col = IN_COL;
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2; int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
size_t i = 0, best = t->in_len; size_t i = 0, best = t->in_len, last = 0;
while (i < t->in_len) { while (i < t->in_len) {
if (row == want_row && col >= want_col) if (row == want_row && col >= want_col)
return i; return i;
@@ -5873,12 +5862,14 @@ static size_t input_hit(const tui_t *t, int want_row, int want_col) {
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten); utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
int cw = utf8_width(wc == L'\t' ? L' ' : wc); int cw = utf8_width(wc == L'\t' ? L' ' : wc);
if (col + cw > right && col > IN_COL) { if (col + cw > right && col > IN_COL) {
/* offset i would be drawn at the start of the next row */
if (row == want_row) if (row == want_row)
return i; return last;
row++; row++;
col = IN_COL; col = IN_COL;
} }
best = i + eaten; best = i + eaten;
last = i;
col += cw; col += cw;
i += eaten; i += eaten;
} }
@@ -6395,29 +6386,65 @@ 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_left, g_k_ctrl_right, g_k_alt_left, 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; g_k_alt_right, g_k_ctrl_dc, g_k_alt_dc;
static int ext_key(const char *cap) { /* The terminfo key, else the xterm sequence bound to our own code `own`. */
static int ext_key(const char *cap, const char *xterm, int own) {
char *s = tigetstr(cap); char *s = tigetstr(cap);
if (!s || s == (char *)-1) int k = s && s != (char *)-1 ? key_defined(s) : 0;
return 0; if (k > 0)
int k = key_defined(s); return k;
return k > 0 ? k : 0; #ifndef _WIN32
k = key_defined(xterm);
if (k > 0)
return k;
if (k == 0 && define_key(xterm, own) == OK)
return own;
#else
(void)xterm, (void)own;
#endif
return 0;
}
#endif
#if defined(NCURSES_VERSION) && !defined(_WIN32)
/* Terminfo lists one form per key, but terminals disagree: cursor keys come
* as CSI or SS3 whatever DECCKM says (tmux, mosh, a wrong TERM), Home/End as
* CSI H, SS3 H or CSI 1~/7~. Unknown forms would be read as Esc + text. */
static void vt_keys_init(void) {
static const struct {
const char *seq;
int key;
} K[] = {
{"\033[A", KEY_UP}, {"\033OA", KEY_UP}, {"\033[B", KEY_DOWN},
{"\033OB", KEY_DOWN}, {"\033[C", KEY_RIGHT}, {"\033OC", KEY_RIGHT},
{"\033[D", KEY_LEFT}, {"\033OD", KEY_LEFT}, {"\033[H", KEY_HOME},
{"\033OH", KEY_HOME}, {"\033[1~", KEY_HOME}, {"\033[7~", KEY_HOME},
{"\033[F", KEY_END}, {"\033OF", KEY_END}, {"\033[4~", KEY_END},
{"\033[8~", KEY_END}, {"\033[2~", KEY_IC}, {"\033[3~", KEY_DC},
{"\033[5~", KEY_PPAGE}, {"\033[6~", KEY_NPAGE}, {"\033[Z", KEY_BTAB},
{"\033[1;2A", KEY_SR}, {"\033[1;2B", KEY_SF}, {"\033[1;2C", KEY_SRIGHT},
{"\033[1;2D", KEY_SLEFT},
};
for (size_t i = 0; i < sizeof(K) / sizeof(K[0]); i++)
if (key_defined(K[i].seq) == 0)
define_key(K[i].seq, K[i].key);
} }
#endif #endif
static void keys_init(void) { static void keys_init(void) {
#ifdef NCURSES_VERSION #ifdef NCURSES_VERSION
g_k_alt_up = ext_key("kUP3"); int own = KEY_OWN_BASE + 4; /* up to KEY_KITTY_BASE - 1 */
g_k_alt_down = ext_key("kDN3"); g_k_alt_up = ext_key("kUP3", "\033[1;3A", own++);
g_k_ctrl_home = ext_key("kHOM5"); g_k_alt_down = ext_key("kDN3", "\033[1;3B", own++);
g_k_ctrl_end = ext_key("kEND5"); g_k_ctrl_home = ext_key("kHOM5", "\033[1;5H", own++);
g_k_ctrl_up = ext_key("kUP5"); g_k_ctrl_end = ext_key("kEND5", "\033[1;5F", own++);
g_k_ctrl_down = ext_key("kDN5"); g_k_ctrl_up = ext_key("kUP5", "\033[1;5A", own++);
g_k_ctrl_left = ext_key("kLFT5"); g_k_ctrl_down = ext_key("kDN5", "\033[1;5B", own++);
g_k_ctrl_right = ext_key("kRIT5"); g_k_ctrl_left = ext_key("kLFT5", "\033[1;5D", own++);
g_k_alt_left = ext_key("kLFT3"); g_k_ctrl_right = ext_key("kRIT5", "\033[1;5C", own++);
g_k_alt_right = ext_key("kRIT3"); g_k_alt_left = ext_key("kLFT3", "\033[1;3D", own++);
g_k_ctrl_dc = ext_key("kDC5"); g_k_alt_right = ext_key("kRIT3", "\033[1;3C", own++);
g_k_alt_dc = ext_key("kDC3"); g_k_ctrl_dc = ext_key("kDC5", "\033[3;5~", own++);
g_k_alt_dc = ext_key("kDC3", "\033[3;3~", own++);
#endif #endif
} }
@@ -6630,6 +6657,7 @@ static void tui_free(tui_t *t) {
queue_free_all(t); queue_free_all(t);
for (int i = 0; i < t->prompt_hist_n; i++) for (int i = 0; i < t->prompt_hist_n; i++)
free(t->prompt_hist[i]); free(t->prompt_hist[i]);
free(t->hist_draft);
for (int i = 0; i < t->nfiles; i++) for (int i = 0; i < t->nfiles; i++)
free(t->files[i]); free(t->files[i]);
free(t->files); free(t->files);
@@ -6720,6 +6748,7 @@ int cst_tui_run(cst_agent_t *agent) {
/* the Windows console driver reads key events, not VT sequences */ /* the Windows console driver reads key events, not VT sequences */
define_key("\033[200~", KEY_PASTE_BEGIN); define_key("\033[200~", KEY_PASTE_BEGIN);
define_key("\033[201~", KEY_PASTE_END); define_key("\033[201~", KEY_PASTE_END);
vt_keys_init();
printf("\033[?2004h"); printf("\033[?2004h");
fflush(stdout); fflush(stdout);
kitty_init(); kitty_init();
@@ -7032,15 +7061,22 @@ int cst_tui_run(cst_agent_t *agent) {
if (t.prompt_hist_n <= 0) if (t.prompt_hist_n <= 0)
continue; continue;
int idx = t.hist_browse; int idx = t.hist_browse;
if (idx < 0) {
free(t.hist_draft);
t.hist_draft = strdup(t.inbuf);
}
idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0); idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0);
hist_load_into_input(&t, idx); hist_load_into_input(&t, idx);
} else { } else {
if (t.hist_browse < 0) if (t.hist_browse < 0)
continue; continue;
if (t.hist_browse + 1 >= t.prompt_hist_n) if (t.hist_browse + 1 >= t.prompt_hist_n) {
input_clear(&t); input_set(&t, t.hist_draft);
else free(t.hist_draft);
t.hist_draft = NULL;
} else {
hist_load_into_input(&t, t.hist_browse + 1); hist_load_into_input(&t, t.hist_browse + 1);
}
} }
after_edit(&t); after_edit(&t);
continue; continue;
+2 -1
View File
@@ -19,6 +19,7 @@
#include <io.h> #include <io.h>
#include <windows.h> #include <windows.h>
#else #else
#include <poll.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#endif #endif
@@ -177,7 +178,7 @@ static CST_TEST_UNUSED void test_sleep_ms(int ms) {
#ifdef _WIN32 #ifdef _WIN32
Sleep((DWORD)ms); Sleep((DWORD)ms);
#else #else
usleep((useconds_t)ms * 1000); poll(NULL, 0, ms); /* usleep is hidden by -std=c11 on musl */
#endif #endif
} }
+82
View File
@@ -1,5 +1,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h"
#include "cst_test_util.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -325,8 +327,88 @@ static void test_images_off(void) {
cJSON_Delete(j); cJSON_Delete(j);
} }
/* Every element of every content array must be a typed part object. */
static int parts_typed(const cJSON *root) {
const cJSON *m;
cJSON_ArrayForEach(m, cJSON_GetObjectItemCaseSensitive(root, "messages")) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (!cJSON_IsArray(c))
continue;
const cJSON *p;
cJSON_ArrayForEach(p, c) {
const cJSON *t = cJSON_GetObjectItemCaseSensitive(p, "type");
if (!cJSON_IsObject(p) || !cJSON_IsString(t))
return 0;
}
}
return 1;
}
static void test_images_on(void) {
char dir[1024], sess[1100], err[256];
if (test_tmpdir(dir, sizeof(dir), "cst-body") != 0) {
EXPECT(0, "scratch dir");
return;
}
snprintf(sess, sizeof(sess), "%s/s.jsonl", dir);
static const unsigned char png[32] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
'\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R',
0, 0, 0, 2, 0, 0, 0, 2};
cst_attachment_t *img = cst_attach_import_bytes(sess, "shot.png",
(const char *)png,
sizeof(png), err, sizeof(err));
EXPECT(img != NULL, "image stored");
if (!img)
return;
cst_attachment_t gone = *img;
snprintf(gone.rel, sizeof(gone.rel), "missing.png");
cst_message_t m[5];
m[0] = msg(CST_ROLE_USER, "what is this");
m[0].attach = img;
m[0].nattach = 1;
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json = "[{\"id\":\"c1\",\"type\":\"function\",\"function\":"
"{\"name\":\"view_image\",\"arguments\":\"{}\"}}]";
m[2] = msg(CST_ROLE_TOOL, "view_image shot.png");
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
m[2].attach = img;
m[2].nattach = 1;
m[3] = msg(CST_ROLE_USER, "and now?");
m[4] = msg(CST_ROLE_USER, "");
m[4].attach = &gone;
m[4].nattach = 1;
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 5;
r.session_path = sess;
cJSON *j = build_req(&r);
EXPECT(parts_typed(j), "content arrays hold only typed parts");
const cJSON *p0 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(j, "messages"), 0),
"content");
const cJSON *t0 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetArrayItem(p0, 0), "text");
const cJSON *u1 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(p0, 1), "image_url"),
"url");
EXPECT(cJSON_IsString(t0) && strcmp(t0->valuestring, "what is this") == 0,
"user text is a text part");
EXPECT(cJSON_IsString(u1) &&
strncmp(u1->valuestring, "data:image/png;base64,", 22) == 0,
"image is a data URL part");
char *s = cJSON_PrintUnformatted(j);
EXPECT(s && strstr(s, "and now?") && strstr(s, "not sent"),
"follow-up text and the missing-image note are parts too");
free(s);
cJSON_Delete(j);
cst_attach_free(img);
}
int main(void) { int main(void) {
test_images_off(); test_images_off();
test_images_on();
test_reasoning_and_prefix(); test_reasoning_and_prefix();
test_cache_hints(); test_cache_hints();
test_tools_snapshot(); test_tools_snapshot();
+5 -1
View File
@@ -44,6 +44,10 @@ static void test_builtin(void) {
EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half, EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half,
"longer match wins the price"); "longer match wins the price");
EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in"); EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in");
cst_model_info("deepseek", "deepseek-flash", &mi);
EXPECT(mi.context == 1000000 && mi.vision == 1, "deepseek-flash takes images");
cst_model_info("deepseek", "deepseek-chat", &mi);
EXPECT(mi.vision == 1, "deepseek-chat is deepseek-flash");
cst_model_info(NULL, "gpt-4o-mini-2024", &mi); cst_model_info(NULL, "gpt-4o-mini-2024", &mi);
EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1, EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1,
"gpt-4o-mini over gpt-4o"); "gpt-4o-mini over gpt-4o");
@@ -152,7 +156,7 @@ static void test_cache(void) {
strcmp(mi.price.currency, "$") == 0, strcmp(mi.price.currency, "$") == 0,
"API vision and USD price"); "API vision and USD price");
cst_model_info("deepseek", "deepseek-flash", &mi); cst_model_info("deepseek", "deepseek-flash", &mi);
EXPECT(mi.vision == 0 && mi.price.miss == 2.0, EXPECT(mi.price.miss == 2.0 && strcmp(mi.price.currency, "¥") == 0,
"another provider's cache is not used"); "another provider's cache is not used");
EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000, EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000,
"no provider: any cache, exact id"); "no provider: any cache, exact id");
+2 -2
View File
@@ -210,7 +210,7 @@ static void test_vision(const char *root) {
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root); snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/blind.jsonl", dir); snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/blind.jsonl", dir);
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1"); snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash"); snprintf(cfg.model, sizeof(cfg.model), "deepseek-v4-pro");
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek"); snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
cfg.protocol = CST_PROTO_NATIVE_EOS; cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = 4; cfg.max_steps = 4;
@@ -220,7 +220,7 @@ static void test_vision(const char *root) {
EXPECT(a != NULL, "blind agent"); EXPECT(a != NULL, "blind agent");
if (!a) if (!a)
return; return;
EXPECT(cst_agent_sees_images(a) == 0, "deepseek-flash is text only"); EXPECT(cst_agent_sees_images(a) == 0, "deepseek-v4-pro is text only");
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err, EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
sizeof(err)) == 0, sizeof(err)) == 0,
"attach to a blind model"); "attach to a blind model");