Skip to content

Commit ea267d6

Browse files
committed
Merge branch 'break'
2 parents 8605416 + 42a804c commit ea267d6

5 files changed

Lines changed: 201 additions & 10 deletions

File tree

‎src/threecode/engine.nim‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,16 @@ proc diffRowBytes(text, prevText: string; width: int): string =
243243
## clears to end of line without touching the next row: padding to the
244244
## full width would put the cursor on the last cell, which a following
245245
## `\n` (or the terminal's pending wrap) turns into an extra row shift.
246-
result = "\r" & text & "\x1b[K"
246+
## A row whose text fills the width skips the EL: in the wrap-pending
247+
## state the cursor still rests ON the last cell, and EL erases from
248+
## the cursor cell inclusive (xterm's ClearRight), which would blank
249+
## the just-written char or caret cell that filled the margin. ttty
250+
## models EL as preserving that cell, so only a real terminal shows
251+
## the loss.
252+
if width > 0 and minline.visualCols(text) >= width:
253+
result = "\r" & text
254+
else:
255+
result = "\r" & text & "\x1b[K"
247256

248257
proc paintVolatileRegion*(e: var TerminalEngine; width: int;
249258
sections: openArray[VolatileRow];

‎src/threecode/minline.nim‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,11 +494,13 @@ proc lineSpans*(text: string; promptW, contW, width: int): seq[LineSpan] =
494494
let rw = runeCellWidth(text.runeAt(i))
495495
if col + rw > width:
496496
if text[i] == ' ':
497-
# A space that overflows is itself the break.
497+
# A space that overflows is itself the break, and only that one
498+
# space collapses into it. The rest of the run is typed content:
499+
# it renders on the next row (via the caret slice while trailing,
500+
# via the span once text follows) and the caret steps over it,
501+
# instead of every trailing space vanishing into the wrap.
498502
result[result.high].stop = contentEnd
499-
var s = i
500-
while s < text.len and text[s] == ' ':
501-
s += runeLenSafe(text, s)
503+
let s = i + runeLenSafe(text, i)
502504
result.add (s, s)
503505
i = s
504506
col = contW

‎tests/core/test_minline.nim‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,13 @@ suite "minline pure helpers":
191191
let bytes = renderBuffer("hello world", "", "", 5)
192192
check bytes == "hello\r\nworld"
193193

194-
test "renderBuffer: multi-space run collapses at the break":
195-
# width 5: "a" then 8 spaces then "b". The spaces overflow and form one
196-
# break run; "b" lands on row 1 with no leading spaces.
194+
test "renderBuffer: multi-space run keeps all but one space":
195+
# width 5: "a" then 8 spaces then "b". Exactly one space collapses
196+
# into the wrap (the first that overflows the row); the rest are
197+
# content the user typed: the ones that still fit row 0 are blank
198+
# cells beyond the painted slice, the others render leading row 1.
197199
let bytes = renderBuffer("a b", "", "", 5)
198-
check bytes == "a\r\nb"
200+
check bytes == "a\r\n b"
199201

200202
test "renderBuffer: word-wrap does not duplicate fragment on original line":
201203
# width 30, prompt 2, contW 2 -> 28 data cells per row.

‎tests/tty/probe_margin_bytes.nim‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
## Byte-capture repro for the two margin bugs:
2+
##
3+
## 1. typing the char that fills the last column: caret and char omitted.
4+
## 2. multiple trailing spaces: all collapsed into the wrap, not just one.
5+
##
6+
## Drives the real stub binary under a PTY at width 12 and dumps the exact
7+
## bytes each keystroke paints, plus the grid state.
8+
9+
import std/[json, os, strutils, unicode]
10+
import tty_expect, stub_helpers
11+
import ttty/grid
12+
13+
proc newFixture(name: string): string =
14+
result = getCurrentDir() / "testdata" / "output" / "tty" /
15+
(name & "_" & $getCurrentProcessId())
16+
if dirExists(result): removeDir(result)
17+
createDir(result); createDir(result / "data"); createDir(result / "run")
18+
19+
proc writeConfiguredProvider(root: string) =
20+
createDir(root / "xdg" / "3code")
21+
writeFile(root / "xdg" / "3code" / "config", """
22+
[settings]
23+
current = "stub.stub-model"
24+
25+
[provider]
26+
name = "stub"
27+
url = "stub://provider"
28+
key = "stub"
29+
family = "glm"
30+
models = "stub-model"
31+
""")
32+
33+
proc stubEnv(root, responsesPath: string): seq[EnvVar] =
34+
createDir(root / "tmp")
35+
@[
36+
(key: "TERM", val: "xterm-256color"),
37+
(key: "PATH", val: getEnv("PATH")),
38+
(key: "HOME", val: root),
39+
(key: "TMPDIR", val: root / "tmp"),
40+
(key: "XDG_CONFIG_HOME", val: root / "xdg"),
41+
(key: "XDG_DATA_HOME", val: root / "data"),
42+
(key: "THREECODE_STUB_RESPONSES", val: responsesPath),
43+
]
44+
45+
proc startStub(root: string; cols = DefaultTtyCols; rows = DefaultTtyRows): TtySession =
46+
newTtySession(ensureStubBinary(), args = ["-x", "-i"], cwd = root / "run",
47+
env = stubEnv(root, root / "run" / "stub_responses.json"),
48+
cols = cols, rows = rows)
49+
50+
proc vis(b: string): string =
51+
## escape bytes made readable
52+
for ch in b:
53+
case ch
54+
of '\x1b': result.add "<E>"
55+
of '\r': result.add "<CR>"
56+
of '\n': result.add "<LF>"
57+
of '\x7f': result.add "<DEL>"
58+
else: result.add ch
59+
60+
proc dump(s: TtySession; label: string) =
61+
s.drain(150, recordFrame = true)
62+
echo "=== [", label, "] cursor row=", s.grid.row, " col=", s.grid.col, " ==="
63+
for i, row in s.grid.rows:
64+
var line = ""
65+
for cell in row:
66+
let ch = cell.rune.toUTF8
67+
if cell.attrs.hasAttr(saReverse): line.add "[" & ch & "]"
68+
else: line.add ch
69+
echo align($i, 2), " |", line, "|"
70+
71+
proc typeSlow(s: TtySession; text: string) =
72+
for ch in text:
73+
s.send $ch
74+
s.drain(40)
75+
76+
when isMainModule:
77+
let root = newFixture("margin_bytes_a")
78+
writeConfiguredProvider(root)
79+
writeFile(root / "run" / "stub_responses.json", $(%*[
80+
{"content": "ok", "usage": {"promptTokens": 1, "completionTokens": 1,
81+
"totalTokens": 2, "cachedTokens": 0}}
82+
]))
83+
84+
# Width 12: prompt "❯ " is 2 cells, 10 data cells fill the row.
85+
let tty = startStub(root, cols = 12, rows = 14)
86+
tty.expect "\u276f"
87+
tty.expectIdleCaret()
88+
discard tty.freshRaw()
89+
tty.advanceRawMark()
90+
dump(tty, "w12 idle")
91+
92+
typeSlow(tty, "012345678")
93+
tty.expectIdleCaret()
94+
var raw = tty.freshRaw()
95+
echo "--- bytes for '012345678' ---"
96+
echo raw.vis
97+
tty.advanceRawMark()
98+
dump(tty, "nine chars")
99+
100+
tty.send "9"
101+
tty.expectIdleCaret()
102+
raw = tty.freshRaw()
103+
echo "--- bytes for '9' (fills last cell) ---"
104+
echo raw.vis
105+
tty.advanceRawMark()
106+
dump(tty, "tenth char fills row")
107+
108+
tty.send "x"
109+
tty.expectIdleCaret()
110+
raw = tty.freshRaw()
111+
echo "--- bytes for 'x' (wraps to next row) ---"
112+
echo raw.vis
113+
tty.advanceRawMark()
114+
dump(tty, "eleventh char wraps")
115+
tty.close()
116+
117+
# Spaces: "abcdefg" + 5 spaces. abcdefg occupies cols 2..8; three spaces
118+
# fit (cols 9..11), the fourth is the margin break, the fifth belongs on
119+
# the next row.
120+
let root2 = newFixture("margin_bytes_b")
121+
writeConfiguredProvider(root2)
122+
writeFile(root2 / "run" / "stub_responses.json", $(%*[
123+
{"content": "ok", "usage": {"promptTokens": 1, "completionTokens": 1,
124+
"totalTokens": 2, "cachedTokens": 0}}
125+
]))
126+
let t2 = startStub(root2, cols = 12, rows = 14)
127+
t2.expect "\u276f"
128+
t2.expectIdleCaret()
129+
discard t2.freshRaw()
130+
t2.advanceRawMark()
131+
typeSlow(t2, "abcdefg")
132+
t2.expectIdleCaret()
133+
t2.advanceRawMark()
134+
dump(t2, "seven chars")
135+
for n in 1 .. 5:
136+
t2.send " "
137+
t2.expectIdleCaret()
138+
echo "--- bytes for space #", n, " ---"
139+
echo t2.freshRaw().vis
140+
t2.advanceRawMark()
141+
dump(t2, "after space #" & $n)
142+
t2.close()

‎tests/tty/test_tty_functional.nim‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3235,6 +3235,12 @@ when false:
32353235
check tty.drawnCaretCount(promptRow) == 0
32363236
check tty.drawnCaretCount(promptRow + 1) == 1
32373237
check tty.drawnCaretCol(promptRow + 1) == 2
3238+
# Byte contract, not grid: in the wrap-pending state xterm's cursor
3239+
# still rests ON the last cell and EL erases from the cursor cell
3240+
# inclusive, so a full row followed by `\x1b[K` blanks the char that
3241+
# filled the margin. ttty models EL as preserving that cell, which
3242+
# is exactly why the grid checks above can't catch the loss.
3243+
check "\u276f 0123456789\x1b[K" notin tty.raw
32383244
# Keep typing: the wrap settles on that same row, in place; no
32393245
# content moved and the prompt row did not shift down.
32403246
tty.send "xy"
@@ -3258,7 +3264,37 @@ when false:
32583264
tty.expectIdleCaret()
32593265
check tty.rows()[promptRow].startsWith("\u276f abcdefg")
32603266
check tty.rows()[promptRow + 1] == " xy"
3261-
check tty.drawnCaretCol(promptRow + 1) == 4
3267+
# Only ONE trailing space may collapse into the wrap. Typing spaces
3268+
# one keystroke at a time past the margin: each one after the
3269+
# collapsed break must paint on the caret's row and advance the
3270+
# caret a cell. Before the fix the whole trailing run collapsed into
3271+
# the break: the caret froze at the continuation prompt while the
3272+
# user kept typing spaces (arrow keys still stepped over every one).
3273+
tty.send "\x1b"
3274+
tty.drain(200)
3275+
tty.send "abcdefg"
3276+
tty.expectIdleCaret()
3277+
check tty.drawnCaretCol(promptRow) == 9
3278+
tty.send " "
3279+
tty.expectIdleCaret()
3280+
check tty.drawnCaretCol(promptRow) == 10
3281+
tty.send " "
3282+
tty.expectIdleCaret()
3283+
check tty.drawnCaretCol(promptRow) == 11
3284+
# The third space fills the row's last cell: the caret drops to the
3285+
# row below, the wrap's home.
3286+
tty.send " "
3287+
tty.expectIdleCaret()
3288+
check tty.drawnCaretCol(promptRow + 1) == 2
3289+
# The fourth space is the one space the wrap collapses.
3290+
tty.send " "
3291+
tty.expectIdleCaret()
3292+
check tty.drawnCaretCol(promptRow + 1) == 2
3293+
# The fifth paints and the caret advances past it.
3294+
tty.send " "
3295+
tty.expectIdleCaret()
3296+
check tty.drawnCaretCol(promptRow + 1) == 3
3297+
check tty.rows()[promptRow].startsWith("\u276f abcdefg")
32623298
# Submit from the caret-only row: the transient row must vanish into
32633299
# the committed block, with no blank row or leftover caret cell
32643300
# between the committed prompt and the reply chrome.

0 commit comments

Comments
 (0)