v0.7.5: arrow keys, image request fix, deepseek-flash vision, musl CI

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
+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