Files
CST-agent/scripts/tui_keys/keys.py
T
WHC c7b32c3fd6 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.
2026-10-08 22:35:34 +08:00

274 lines
7.8 KiB
Python

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