Skip to content

Commit cf55a58

Browse files
Merge pull request #462 from bernardladenthin/agent-cls-cursor-position
llama-atmosphere-agent: make the pinned console survive a window resize
2 parents 80ddeea + 1878c85 commit cf55a58

18 files changed

Lines changed: 5691 additions & 118 deletions

‎CLAUDE.md‎

Lines changed: 409 additions & 22 deletions
Large diffs are not rendered by default.

‎docs/upstream-investigation-jline-status-windows-redraw.md‎

Lines changed: 958 additions & 0 deletions
Large diffs are not rendered by default.

‎llama-atmosphere-agent/README.md‎

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -425,14 +425,29 @@ every Enter, because the reader erases exactly one line (hold Enter, get a colum
425425
leaves one rule per turn behind, travelling up the scrollback. One rule, below the input, is the shape
426426
that has neither problem.
427427

428-
**Wiping the screen.** `/cls` clears the window and leaves the input and the block at the bottom —
429-
Ctrl-L does the same, bound by the line reader itself rather than by this project (a test pins that, so
430-
a keymap change cannot quietly take it away). `/clear` wipes the screen *and* drops the history: what is
431-
still on screen after a `/clear` is a conversation the model no longer has, which reads as if it were
432-
still in play. Neither touches the terminal emulator's own scrollback — what was written stays where
433-
the scrollbar can reach it. The block at the bottom is redrawn from a kept copy afterwards: JLine draws
434-
the pinned region only when its *content* changes, and a wipe does not change the content, it only takes
435-
it off the screen — so asking it to redraw does nothing and the bottom of the window stays empty.
428+
**Wiping the screen.** `/cls` clears the window and leaves the input and the block at the bottom, and
429+
Ctrl-L does exactly the same — the key is bound to this command rather than to the line reader's own
430+
clear, because the reader's own leaves the input somewhere else. `/clear` wipes the screen *and* drops the
431+
history: what is still on screen after a `/clear` is a conversation the model no longer has, which reads
432+
as if it were still in play. Neither touches the terminal emulator's own scrollback — what was written
433+
stays where the scrollbar can reach it.
434+
435+
**It clears by scrolling, not by erasing**, and that is the whole implementation: a window's worth of
436+
blank lines, printed above the prompt. Erasing was tried in three shapes and each was reported as a
437+
defect. Erase then print blank rows to push the input back down: erasing clears the *visible* area and
438+
leaves those lines in the scrollback, so scrolling pulls them straight back into view — text above the
439+
prompt. Erase then move the cursor: the reader owns the cursor while it prints above the prompt, so a
440+
cursor move smuggled in there leaves its bookkeeping wrong and stranded a character above the prompt.
441+
Erase and stop: the input then sat at the top left, and worse — the reader redraws its prompt as a
442+
*difference* against what it believes is on screen, an erase invalidates that belief, and the measured
443+
result was no prompt on screen at all. Scrolling has none of those problems, because it is nothing but
444+
output: the screen goes blank, the scrollback keeps everything, the pinned block is never touched, and
445+
the cursor ends on its row because printing is what puts it there.
446+
447+
**A useful side effect**: a screen that has lost rows on its own (see the resize note below) is repaired
448+
by `/cls` or Ctrl-L, because printing walks the cursor back down one row per line. That is the same
449+
recovery as pressing Enter a dozen times, in one keystroke.
450+
436451

437452
**Why the screen is scrolled once at startup.** The line reader draws its prompt where the cursor is,
438453
which is directly after the last thing printed; only the block below it is pinned to the window. On a
@@ -448,15 +463,94 @@ network. Each is an icon, a space, its value. Rows are cut by **screen columns**
448463
because an icon is one character and two columns — counting characters lets a row come out wider than
449464
the window, wrap, and push the pinned block out of place.
450465

451-
**Resizing the window** is left to JLine, which resizes the pinned region and re-cuts its rows itself.
452-
A handler of our own was tried for a reported row of `> > > > >` after dragging the window smaller and
453-
made it worse: the line reader installs its own handler for as long as it is reading, so ours only
454-
added a second writer on the terminal while the reader was redrawing. A test drives the real path — a
455-
size change plus the resize signal, with the reader reading as it does all session — across shrinking,
456-
growing and a changed row count, and every one draws exactly one prompt. The leftover `> ` row that is
457-
still reported is therefore not produced there; the remaining suspect is the console reflowing its own
458-
screen buffer on a resize, which moves lines the program never wrote again and which nothing on this
459-
side can reproduce. `/cls` or Ctrl-L cleans it up.
466+
**Output is folded by the agent, not by the console**, to one column less than the window. That is what
467+
makes dragging the window survivable, and the reason is worth knowing: a line the console wrapped is *one*
468+
logical line over two screen rows, and Windows joins such lines again when the window is widened. The text
469+
above then needs fewer rows and everything below moves **up** — including the pinned block's last-drawn rows,
470+
which end up above the block where nothing writes again. One leftover per drag, which is where the rules
471+
climbing up the screen came from; narrowing does it in reverse and walks the input upwards. A line that was
472+
never soft-wrapped has nothing to join. The price: text keeps the line breaks it was printed with, so widening
473+
the window does not re-flow the conversation — the same trade an append-only console already makes.
474+
475+
**Making the window narrower no longer erases the conversation.** The status bar's own housekeeping used to
476+
clear a band of rows on a geometry change and pulled the start of that band upwards "to account for wrapped
477+
status lines" — six rows above a three-row bar in a halved window, straight through the answers on screen.
478+
Measured: three answers, halve the width, two gone. Since a bar row is padded one column short it cannot wrap at
479+
all, so there was nothing to account for. That was the report "after making it narrower I cannot see it any
480+
more": the output was never scrolled away, it was erased.
481+
482+
**The pinned block never writes the last column of a row**, which is what stopped the stacks of bars. A row
483+
padded to the full reported width wraps when that width is not the screen's real one — a dragged window reports
484+
a width before it has applied it — and a wrapped row takes two screen rows where the bar reserves one, so the
485+
bar's last row wraps past the bottom and scrolls it: the bar moves up and leaves a copy above, one per bad
486+
render. Leaving the last column alone costs nothing, because each row is addressed rather than reached by
487+
wrapping, and it also stops the terminal from marking the row as wrapped at all.
488+
489+
**Changing the window's WIDTH clears the screen once you let go**, in either direction, and that is deliberate. A console reflows when
490+
the width changes: lines it had wrapped are joined again, the text above needs fewer rows, and everything below
491+
moves up — measured on a real console, a 32-row window widened from 86 to 111 columns left the cursor three rows
492+
higher, exactly the rows that joining freed. The pinned block's rows move up with everything else, the next
493+
redraw puts a fresh block at the bottom, and the carried-up copy stays above it: one leftover rule per size
494+
event, and a drag reports one every ~125 ms. It cannot be prevented — the console reflows before the program is
495+
told the size — and nothing can find the copy afterwards, because a program cannot read the screen back.
496+
Scrolling removes it, which is what `/cls` does and why that was always the repair. So the agent does it itself
497+
once the size settles. Narrowing needs the same treatment for the mirror-image reason: there the lines are split
498+
instead of joined, and the block's own rows — built for the old width — no longer fit and are re-wrapped across
499+
several screen rows. And then the conversation is **printed again**, folded for the width the window now has: the console remembers
500+
the lines it printed (the last few hundred, before folding), so after the wipe the recent ones come back and a
501+
paragraph that needed two rows in a narrow window takes one in a wide one. Dragging only the height changes
502+
nothing here, because without a width change nothing re-wraps.
503+
504+
**After a height change the prompt is printed back down to its row.** JLine draws the prompt wherever the
505+
cursor happens to be, and a window whose height changes moves the screen's content by however many rows the
506+
console's buffer gives it — measured: growing a 20-row window to 26 left the input three rows too high, and
507+
shrinking it to 14 left it three rows too *low*, which means inside the pinned block, where it is drawn over
508+
and vanishes. The amount cannot be computed, but it is bounded by the height change, and printing moves the
509+
cursor down one row per line until it reaches its row and then simply scrolls — so the agent prints that many
510+
blank lines once the size settles. A width change costs nothing, because folding means a width change moves no
511+
rows at all. Shrinking is not repaired this way (the prompt would have to move *up*, which nothing can do
512+
without breaking the reader's cursor bookkeeping); `/cls` puts it back.
513+
514+
**Resizing the window.** The pinned region is JLine's, but the rows in it are this project's: a rule built
515+
for a 113-column window stays 113 columns wide until somebody re-makes it, so the block is rebuilt whenever
516+
the window changes size, from a poll. A resize handler of our own was tried and made things worse — the line
517+
reader installs its own for as long as it is reading, so ours only added a second writer while the reader was
518+
redrawing.
519+
520+
**And the redraw after the size settles is a repaint rather than a redraw**, which is what fixed "smaller was
521+
fine, bigger still breaks". The mechanism is worth knowing because it explains a report that survived every
522+
earlier fix. JLine pads each region row to the width the terminal *reports* and writes the rows one after
523+
another, relying on the terminal wrapping at the right margin to start the next one — so a screen that is
524+
**wider** than the reported width never wraps and the next row continues on the same screen line: a rule with
525+
the activity row cut short beside it, and the last row still looking perfectly correct. Windows gets there by
526+
a second route that does not correct itself: it **reflows** its screen buffer when the window is widened,
527+
joining rows it had marked as wrapped, which is every region row. JLine is never told, so its model still
528+
matches what it wrote, every later update computes an empty difference and writes **nothing**, and the joined
529+
rows stay for the rest of the session — which is exactly why holding Enter repaired it and no redraw did.
530+
Once the size stops changing, the block is therefore repainted unconditionally instead of compared.
531+
532+
**The deeper half of that was in the library.** One measurement: a status update sends a single cursor
533+
address and then writes its rows back to back, each padded to the reported width — the second row begins on
534+
a new screen row only because writing the last column of the first one made the terminal *wrap*. So a screen
535+
wider than the reported width does not merely truncate: **every row of the block lands on one screen row,
536+
side by side**. And since the block is reserved from the bottom, what that pushes past the window ends up in
537+
the output area *above* it, where nothing writes again — one fragment per drag, which is why a dozen drags
538+
left a screen full of rule fragments and why `/cls` was the only thing that cleared them. The pinned region
539+
now addresses each of its rows instead of trusting the wrap.
540+
That needs a way to ask for a repaint, which the library did not have — `redraw()` is the same diff under another name, and `reset()` forgets the scroll region as well, so the next update grows the region by scrolling and leaves the old rows *above* the block. So a fifth fix is carried against JLine: a `Status.repaint()` that clears the model and nothing else. It is the only one of the five that adds a method, so it is called reflectively and skipped when absent: the agent must stay buildable with whatever JLine a copy of it finds, and the pom names the released version on purpose.
541+
542+
What that cannot reach: the prompt is drawn by a display of its own with the same difference logic and no way
543+
in from outside, so a reflow that damages the prompt's row is still beyond repair from here. `/cls` or Ctrl-L
544+
cleans that up, because they only print.
545+
546+
547+
**A known JLine defect on Windows.** With a pinned block, typing without pressing Enter and then
548+
widening the window shows the prompt and the typed text once per keystroke, side by side. The repeats
549+
are written *while typing* — clipped past the right edge, so widening merely reveals them. It is
550+
reproducible with plain JLine and a three-row status block and **no** code from this project, is not
551+
fixed in JLine 4.4.6, and is not caused by resize signals; the write-up is in
552+
[`../docs/upstream-investigation-jline-status-windows-redraw.md`](../docs/upstream-investigation-jline-status-windows-redraw.md).
553+
Nothing on this side can honestly fix it. `--plain` pins nothing and is unaffected.
460554

461555
**Three threads write to this console** and all of them had to be brought into line, because a write that
462556
goes around the line reader scrolls the screen without JLine noticing and the pinned block ends up

‎llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentTerminal.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ public interface AgentTerminal extends AutoCloseable {
7171
*/
7272
boolean pinsStatus();
7373

74+
/**
75+
* What kind of terminal this is, for the startup line.
76+
*
77+
* <p>The terminfo entry name on a real terminal — {@code windows-vtp}, {@code xterm} — because the entry
78+
* decides how the pinned block is drawn and two of the fixes carried against JLine are about exactly
79+
* that. A report from a console is much easier to read when it says which one it came from.
80+
*
81+
* @return the type, or a word describing this implementation when there is no terminal
82+
*/
83+
default String terminalType() {
84+
return "plain stream";
85+
}
86+
7487
/**
7588
* Wipe the window, leaving the input line and the block at the bottom.
7689
*

‎llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ApprovalMode.java‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,20 @@ public String symbol() {
5454
/**
5555
* Symbol and name together, as the status line and {@code /mode} print them.
5656
*
57-
* @return e.g. {@code "⏸ manual"}
57+
* <p><b>Two spaces, and it is not a typo.</b> Reported as "beim auto mode hat immer ein Leerzeichen
58+
* gefehlt": the transport symbols are drawn two columns wide by Windows Terminal while the column
59+
* arithmetic counts them as one, so the glyph covers the single space that follows it and the name
60+
* ends up touching it. The space is genuinely in the string — the screen-backed tests in
61+
* {@link ScreenUseCasesTest} assert the badge survives to the rendered row, and they pass — so the
62+
* disagreement is between the terminal's font and the width table, not in this code. A second space
63+
* is the fix that works on both: where the glyph really is two columns wide the result looks like one
64+
* space, and where it is one there is a wider gap, which is harmless. The same disagreement in a less
65+
* harmless form is why {@link StatusLine}'s icons are basic-plane rather than emoji.
66+
*
67+
* @return e.g. {@code "⏸ manual"}
5868
*/
5969
public String badge() {
60-
return symbol + " " + label();
70+
return symbol + " " + label();
6171
}
6272

6373
/**

0 commit comments

Comments
 (0)