diff --git a/include/cst_version.h b/include/cst_version.h index 0ca8f85..b13ca12 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #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) CST_VERSION_STR_(x) diff --git a/scripts/tui_keys/esc.py b/scripts/tui_keys/esc.py new file mode 100644 index 0000000..d55debb --- /dev/null +++ b/scripts/tui_keys/esc.py @@ -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:] == ["<<<") + check(term + " end variants", input_text(t)[-1:] == ["<<<>>>"], t) + t.close() +print("FAILED:", len(FAILS)) +sys.exit(min(len(FAILS), 100)) diff --git a/scripts/tui_keys/fakeapi.py b/scripts/tui_keys/fakeapi.py new file mode 100644 index 0000000..f308d8b --- /dev/null +++ b/scripts/tui_keys/fakeapi.py @@ -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] diff --git a/scripts/tui_keys/harness.py b/scripts/tui_keys/harness.py new file mode 100644 index 0000000..3000571 --- /dev/null +++ b/scripts/tui_keys/harness.py @@ -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 diff --git a/scripts/tui_keys/keys.py b/scripts/tui_keys/keys.py new file mode 100644 index 0000000..b32c3b8 --- /dev/null +++ b/scripts/tui_keys/keys.py @@ -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", "") + check(f"{tag} right past end", "" 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)) diff --git a/src/llm_openai.c b/src/llm_openai.c index 38b1cf8..5dc7c0d 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -162,7 +162,14 @@ static void parts_add_text(cJSON *parts, cJSON *txt) { cJSON_Delete(txt); 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. */ diff --git a/src/models.c b/src/models.c index 7e64d26..3385b55 100644 --- a/src/models.c +++ b/src/models.c @@ -1,6 +1,11 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE +#endif + #include "cst_agent.h" #include +#include /* Provider and model data live in model_registry.c (cst_models.h). */ diff --git a/src/models_builtin.json b/src/models_builtin.json index ef9253a..2ea41f4 100644 --- a/src/models_builtin.json +++ b/src/models_builtin.json @@ -31,6 +31,9 @@ "models": [ {"match": "deepseek", "context": 1000000, "vision": false, "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", "price": {"currency": "¥", "miss": 9, "hit": 0.3, "out": 27, "offpeak_half": true}}, {"match": "deepseek-vl", "vision": true}, diff --git a/src/tui.c b/src/tui.c index d3c4ce6..0cb9b61 100644 --- a/src/tui.c +++ b/src/tui.c @@ -378,6 +378,7 @@ typedef struct { char *prompt_hist[PROMPT_HIST_CAP]; int prompt_hist_n; int hist_browse; + char *hist_draft; /* the live input while browsing */ /* Busy-turn FIFO */ char *queue[QUEUE_CAP]; @@ -3163,28 +3164,16 @@ static size_t word_right(const tui_t *t, size_t at) { return at; } -/* Move to the previous/next logical line keeping the column (in chars). - * Returns 0 when already on the first/last line. */ +static size_t input_hit(const tui_t *t, int want_row, int want_col); + +/* 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) { - size_t ls = line_start(t, t->cur); - int col = 0; - for (size_t i = ls; i < t->cur; i += next_len(t, i)) - col++; - size_t target; - 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; + int cr, cc; + int rows = input_layout(t, NULL, NULL, &cr, &cc); + if (cr + dir < 0 || cr + dir >= rows) + return 0; + t->cur = input_hit(t, cr + dir, cc); 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) { int row = 0, col = IN_COL; 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) { if (row == want_row && col >= want_col) 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); int cw = utf8_width(wc == L'\t' ? L' ' : wc); if (col + cw > right && col > IN_COL) { + /* offset i would be drawn at the start of the next row */ if (row == want_row) - return i; + return last; row++; col = IN_COL; } best = i + eaten; + last = i; col += cw; 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_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); - if (!s || s == (char *)-1) - return 0; - int k = key_defined(s); - return k > 0 ? k : 0; + int k = s && s != (char *)-1 ? key_defined(s) : 0; + if (k > 0) + return k; +#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 static void keys_init(void) { #ifdef NCURSES_VERSION - g_k_alt_up = ext_key("kUP3"); - g_k_alt_down = ext_key("kDN3"); - g_k_ctrl_home = ext_key("kHOM5"); - 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"); + int own = KEY_OWN_BASE + 4; /* up to KEY_KITTY_BASE - 1 */ + g_k_alt_up = ext_key("kUP3", "\033[1;3A", own++); + g_k_alt_down = ext_key("kDN3", "\033[1;3B", own++); + g_k_ctrl_home = ext_key("kHOM5", "\033[1;5H", own++); + g_k_ctrl_end = ext_key("kEND5", "\033[1;5F", own++); + g_k_ctrl_up = ext_key("kUP5", "\033[1;5A", own++); + g_k_ctrl_down = ext_key("kDN5", "\033[1;5B", own++); + g_k_ctrl_left = ext_key("kLFT5", "\033[1;5D", own++); + g_k_ctrl_right = ext_key("kRIT5", "\033[1;5C", own++); + g_k_alt_left = ext_key("kLFT3", "\033[1;3D", own++); + g_k_alt_right = ext_key("kRIT3", "\033[1;3C", own++); + g_k_ctrl_dc = ext_key("kDC5", "\033[3;5~", own++); + g_k_alt_dc = ext_key("kDC3", "\033[3;3~", own++); #endif } @@ -6630,6 +6657,7 @@ static void tui_free(tui_t *t) { queue_free_all(t); for (int i = 0; i < t->prompt_hist_n; i++) free(t->prompt_hist[i]); + free(t->hist_draft); for (int i = 0; i < t->nfiles; i++) free(t->files[i]); 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 */ define_key("\033[200~", KEY_PASTE_BEGIN); define_key("\033[201~", KEY_PASTE_END); + vt_keys_init(); printf("\033[?2004h"); fflush(stdout); kitty_init(); @@ -7032,15 +7061,22 @@ int cst_tui_run(cst_agent_t *agent) { if (t.prompt_hist_n <= 0) continue; 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); hist_load_into_input(&t, idx); } else { if (t.hist_browse < 0) continue; - if (t.hist_browse + 1 >= t.prompt_hist_n) - input_clear(&t); - else + if (t.hist_browse + 1 >= t.prompt_hist_n) { + input_set(&t, t.hist_draft); + free(t.hist_draft); + t.hist_draft = NULL; + } else { hist_load_into_input(&t, t.hist_browse + 1); + } } after_edit(&t); continue; diff --git a/tests/cst_test_util.h b/tests/cst_test_util.h index 0b36f2e..c941ab7 100644 --- a/tests/cst_test_util.h +++ b/tests/cst_test_util.h @@ -19,6 +19,7 @@ #include #include #else +#include #include #include #endif @@ -177,7 +178,7 @@ static CST_TEST_UNUSED void test_sleep_ms(int ms) { #ifdef _WIN32 Sleep((DWORD)ms); #else - usleep((useconds_t)ms * 1000); + poll(NULL, 0, ms); /* usleep is hidden by -std=c11 on musl */ #endif } diff --git a/tests/test_llm_body.c b/tests/test_llm_body.c index 18184e5..bdc63d0 100644 --- a/tests/test_llm_body.c +++ b/tests/test_llm_body.c @@ -1,5 +1,7 @@ #include "cJSON.h" #include "cst_agent.h" +#include "cst_attach.h" +#include "cst_test_util.h" #include #include @@ -325,8 +327,88 @@ static void test_images_off(void) { 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) { test_images_off(); + test_images_on(); test_reasoning_and_prefix(); test_cache_hints(); test_tools_snapshot(); diff --git a/tests/test_model_registry.c b/tests/test_model_registry.c index 5070f08..016a07a 100644 --- a/tests/test_model_registry.c +++ b/tests/test_model_registry.c @@ -44,6 +44,10 @@ static void test_builtin(void) { EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half, "longer match wins the price"); 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); EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1, "gpt-4o-mini over gpt-4o"); @@ -152,7 +156,7 @@ static void test_cache(void) { strcmp(mi.price.currency, "$") == 0, "API vision and USD price"); 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"); EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000, "no provider: any cache, exact id"); diff --git a/tests/test_prefix_stable.c b/tests/test_prefix_stable.c index 153e2a2..de0b9a5 100644 --- a/tests/test_prefix_stable.c +++ b/tests/test_prefix_stable.c @@ -210,7 +210,7 @@ static void test_vision(const char *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.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"); cfg.protocol = CST_PROTO_NATIVE_EOS; cfg.max_steps = 4; @@ -220,7 +220,7 @@ static void test_vision(const char *root) { EXPECT(a != NULL, "blind agent"); if (!a) 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, sizeof(err)) == 0, "attach to a blind model");