Skip to content

LAB-1794: Restore vt soft-wrap APIs for amux - #14

Merged
cweill merged 10 commits into
mainfrom
lab-1794-restore-vt-methods
May 13, 2026
Merged

LAB-1794: Restore vt soft-wrap APIs for amux#14
cweill merged 10 commits into
mainfrom
lab-1794-restore-vt-methods

Conversation

@cweill

@cweill cweill commented May 13, 2026

Copy link
Copy Markdown

Motivation

LAB-1794: PR #13 squash-merged the upstream sync and left the fork tip without the vt soft-wrap APIs amux needs (LineWrapped and CursorPhantom).

Summary

  • Replay the soft-wrap preservation stack from origin/cweill/lab-1750-soft-wrap-reflow onto post-sync origin/main.
  • Restore Emulator.LineWrapped, SafeEmulator.LineWrapped, Emulator.CursorPhantom, and SafeEmulator.CursorPhantom.
  • Restore the screen soft-wrap metadata used by those APIs and the regression coverage around resize/wrap behavior.

Testing

  • cd vt && go mod tidy && go test ./...
  • cd /home/cweill/github/weill-labs/amux && go test -modfile=<temp modfile replacing x/vt with local checkout> ./internal/mux/... -race -timeout 60s
  • git diff --check origin/main..HEAD

Review focus

  • vt/screen.go soft-wrap metadata preservation across resize.
  • Public vt APIs consumed by amux: LineWrapped and CursorPhantom.

Closes LAB-1794

@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown

Claude finished @cweill's task in 3m 47s —— View job


PR Review: LAB-1794 Restore vt soft-wrap APIs

  • Read changed files and gather context
  • Review vt/screen.go — soft-wrap metadata preservation
  • Review vt/reflow.go — resize/reflow logic
  • Review vt/emulator.go — public API changes
  • Review vt/safe_emulator.go — SafeEmulator wrappers
  • Review vt/regression_test.go — test coverage
  • Review vt/terminal.go and vt/utf8.go — minor changes
  • Post final review summary

Review

vt/emulator.go

LineWrapped and CursorPhantom both call flushExpiredSynchronizedOutput() before reading state, matching the pattern of every other read accessor (Touched, CursorPosition, etc.). ✓

Resize now delegates the full atPhantom state to each screen's resize path via the cursorPhantom argument, then resets e.atPhantom = false unconditionally. This is cleaner than the previous manual pre-adjustment of x. ✓

vt/reflow.go

resizeNarrow overhaul — The old path truncated content silently; the new path runs a full reflow through captureReflowState + wrapReflowState, mirrors resizeWider, and correctly handles scrollback via pushReflowScrollback. The condition width >= oldWidth || oldWidth <= 0 || oldHeight <= 0 falls through to resizePlain for no-op or invalid cases. ✓

captureReflowState wrap detection — Replacing the heuristic screenLineUsesFullWidth with the explicit s.lineWrapped(y) flag is a significant correctness improvement. The added preserveCols = append(preserveCols, width-1) for rows followed by a wrap continuation correctly prevents reflowLineEnd from stripping trailing spaces that were load-bearing for the wrap relationship. ✓

wrapReflowState now returns a parallel lineWraps []bool slice, with j > 0 marking soft-wrap rows. Clean, correct. ✓

reflowVisibleStart — Pins the visible window so the cursor is at the bottom row of the viewport (start = cursorY - height + 1). The min(start, rowCount-height) clamp prevents overrunning. ✓

vt/screen.go

wrapped []bool slice management:

  • ensureLineWraps correctly handles the shrink/grow/reallocate cases. When reslicing within capacity, the explicit zeroing loop for y := oldLen; y < height is necessary (elements beyond oldLen may hold stale values from a prior shrink). ✓
  • replaceLineWraps always enforces wrapped[0] = false. ✓

deleteLineWraps (screen.go:591-610) — After copy(s.wrapped[top:bottom-n], s.wrapped[top+n:bottom]), the code unconditionally clears s.wrapped[top]. The reason: the row now at top was previously a continuation of top+n-1 (a deleted row), not of top-1. Correct, but a one-line comment would save the next reader a few seconds:

// The row now at top was a continuation of a deleted row; it is no
// longer a continuation of top-1.
s.wrapped[top] = false

clearLineWrapsInArea guards on area.Min.X > 0, which means partial-row clears don't reset wrap flags. This is correct since wrap is a property of the row start. ✓

setLineWrapped called unconditionally when x == 0 (utf8.go:90-92):

if x == 0 {
    e.scr.setLineWrapped(y, wrapped)
}

When a character is written to column 0 via cursor positioning (not autowrap), wrapped = false and the existing flag is cleared. This is semantically correct — cursor-positioned writes to column 0 break the soft-wrap chain — but it is subtle behavior that isn't explicitly tested.

vt/safe_emulator.go

Both new methods acquire RLock before forwarding to Emulator, matching the pattern of Touched(), CursorPosition(), and all other read-only accessors. ✓

flushExpiredSynchronizedOutput operates on *Emulator fields directly (not *SafeEmulator) so no re-entrancy issue. ✓

vt/regression_test.go

Good coverage for the newly added paths:

  • TestResizeNarrowReflowsSoftWrappedLine — text content preserved after shrink ✓
  • TestResizeNarrowKeepsFullWidthHardNewlineRowsSeparate — hard newlines not collapsed ✓
  • TestLineWrappedReportsOnlySoftWrapContinuations — distinguishes soft from hard ✓
  • TestCursorPhantomReportsPendingAutowrap — phantom state transitions ✓
  • TestResizeHeightShrinkClampsCursorBeforeNextWrite — cursor clamped on height shrink ✓
  • TestResizePreservesSpacesBeforeSoftWrapContinuation — trailing-space preservation ✓

One gap worth noting: no test verifies LineWrapped(y) values after a resize (only after initial writes). For example: write a 25-char string on a 20-wide terminal (LineWrapped(1) == true), resize to width=30, then assert LineWrapped(1) == false (wrap was reflowed away). Given that wrap state post-resize is the core invariant amux depends on, a test or two there would strengthen confidence. Not blocking, but worth a follow-up.

TestResizeShrinkThenWidenKeepsDenseRowsSeparate — the removed assertion about screenLineUsesFullWidth is fine since that helper was deleted and the test was over-specifying an implementation detail. Height bump from 12→20 ensures the dense rows fit in view without scrollback. ✓


Non-blocking observations

  1. deleteLineWraps (vt/screen.go:604) — add a comment explaining the unconditional clear of s.wrapped[top] after the copy.
  2. No test for LineWrapped(y) after a resize (content correct, but wrap-flag correctness after reflow is untested).
  3. Screen.Resize now calls clampCursors — behavioral change for any external direct users of bare Screen, separate from Emulator.Resize. Low risk since Emulator always routes through resizeWider/resizeNarrow.

LGTM

@cweill
cweill merged commit 6adbf81 into main May 13, 2026
1 check passed
@cweill
cweill deleted the lab-1794-restore-vt-methods branch May 13, 2026 00:19
@greptile-apps

greptile-apps Bot commented May 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR restores the LineWrapped and CursorPhantom APIs lost in the post-sync squash merge and replaces the old heuristic full-width-line detection with explicit per-row soft-wrap metadata in a new Screen.wrapped []bool slice.

  • vt/screen.go gains a wrapped field plus helpers that maintain the metadata through clears, fills, insert-line, delete-line, and all resize paths.
  • vt/reflow.go replaces screenLineUsesFullWidth with metadata-driven logical-line reconstruction; resizeNarrow now does a full reflow mirroring resizeWider, and resizePlain gains clampCursors.
  • vt/emulator.go / vt/safe_emulator.go expose the new public API and add CursorPhantom() to the Terminal interface; five new regression tests cover the main wrap, reflow, and cursor-clamp scenarios.

Confidence Score: 4/5

Safe to merge with one design point worth confirming: whether setLineWrapped clearing the flag on any col-0 write matches amux's expectations for LineWrapped.

The core reflow and metadata machinery are well-reasoned and well-tested. The main open question is the 'clear wrap on any col-0 write' semantics in utf8.go — if amux-hosted programs do cursor-positioned writes at col 0 on continuation rows, LineWrapped results could be silently wrong.

vt/utf8.go — specifically the setLineWrapped call at lines 90-92 and whether its semantics align with how amux consumes LineWrapped.

Important Files Changed

Filename Overview
vt/screen.go Adds wrapped []bool field and all associated helpers. Logic is correct; insert/delete operations handle the first-row invariant cleanly.
vt/utf8.go setLineWrapped is called whenever a grapheme lands at x==0, clearing the flag for non-autowrap writes — a cursor-move-then-write at col 0 of a continuation row will silently erase its wrap metadata.
vt/reflow.go Replaces heuristic full-width detection with explicit lineWrapped metadata; adds preserveCols for trailing-space-before-wrap, reflowVisibleStart, pushReflowScrollback, and cursor clamping in resizePlain.
vt/emulator.go Adds LineWrapped and CursorPhantom public APIs; restructures Resize to delegate per-screen phantom state correctly.
vt/safe_emulator.go Adds thread-safe LineWrapped and CursorPhantom wrappers using RLock, consistent with existing SafeEmulator patterns.
vt/regression_test.go Adds five regression tests covering narrow-reflow, hard-newline separation, LineWrapped/CursorPhantom semantics, height-shrink cursor clamping, and space preservation.
vt/terminal.go Adds CursorPhantom() to the Terminal interface.

Sequence Diagram

sequenceDiagram
    participant App
    participant Emulator
    participant Screen
    participant WrappedMeta as wrapped[]bool

    App->>Emulator: Write fills row triggering autowrap
    Emulator->>Screen: "handleGrapheme(x=0, wrapped=true)"
    Screen->>WrappedMeta: setLineWrapped(y, true)
    App->>Emulator: LineWrapped(y)
    Emulator->>Screen: lineWrapped(y)
    Screen->>WrappedMeta: read wrapped[y]
    WrappedMeta-->>App: true

    App->>Emulator: Resize(narrower, height)
    Emulator->>Screen: resizeNarrow(w, h, cursorPhantom)
    Screen->>Screen: captureReflowState uses lineWrapped
    Screen->>Screen: wrapReflowState returns lineWraps
    Screen->>WrappedMeta: replaceLineWraps(lineWraps, start, h)

    App->>Emulator: CursorPhantom()
    Emulator-->>App: e.atPhantom
Loading

Reviews (1): Last reviewed commit: "vt: expose pending autowrap cursor state" | Re-trigger Greptile

Comment thread vt/utf8.go
Comment on lines +90 to +92
if x == 0 {
e.scr.setLineWrapped(y, wrapped)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Wrap flag silently cleared by cursor-move-then-write at column 0

setLineWrapped(y, false) fires for every grapheme that lands at x == 0, regardless of whether the cursor arrived there via autowrap or an explicit cursor-positioning sequence. If a program places the cursor at (0, y) using CUP/\r and then writes a character — without triggering autowrap — the soft-wrap flag on that row is erased. A future call to LineWrapped(y) will return false even though the row was originally produced by autowrap, breaking downstream reflow in amux.

A simple reproduction: write 25 characters to a 20-wide terminal (rows 0 and 1 set up with LineWrapped(1)==true), then send \033[2;1H and write a single printable character — LineWrapped(1) flips to false.

Most terminal emulators store the wrap attribute as a line property mutated only by the autowrap mechanism itself; overwriting characters via cursor movement does not touch it.

Comment thread vt/screen.go
Comment on lines +603 to +609
copy(s.wrapped[top:bottom-n], s.wrapped[top+n:bottom])
for row := bottom - n; row < bottom; row++ {
s.wrapped[row] = false
}
if top < bottom {
s.wrapped[top] = false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unconditional clear of wrapped[top] after deleteLineWraps copy

After copy(s.wrapped[top:bottom-n], s.wrapped[top+n:bottom]), s.wrapped[top] holds the old value of s.wrapped[top+n]. The code then unconditionally sets s.wrapped[top] = false, silently discarding the copied value. The intent is correct — the promoted line's original predecessor was deleted so it cannot be a continuation of the new top-1 — but a short comment would clarify why the override is always correct, preventing future readers from mistaking it for a bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant