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.
274 lines
7.8 KiB
Python
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))
|